Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion docs/commands/logs.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
| Subcommand | description |
|:--------------------------- |:-----|
| [`logs:deploy`](/commands/logs#logsdeploy) | Stream the logs of deploys currently being built to the console |
| [`logs:edge-functions`](/commands/logs#logsedge-functions) | Stream netlify edge function logs to the console |
Copy link
Member

@eduardoboucas eduardoboucas Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand why this is plural and logs:function is singular, but it still itches! 😖

In the future, I think we could rename logs:function to logs:functions and start accepting multiple function names, since there's nothing stopping us from listening to different streams and interleaving them, just like we do with edge functions.

| [`logs:function`](/commands/logs#logsfunction) | Stream netlify function logs to the console |


Expand All @@ -33,6 +34,8 @@
netlify logs:deploy
netlify logs:function
netlify logs:function my-function
netlify logs:edge-functions
netlify logs:edge-functions --deploy-id <deploy-id>
```

---
Expand All @@ -52,6 +55,37 @@
- `debug` (*boolean*) - Print debugging information
- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in

---
## `logs:edge-functions`

Stream netlify edge function logs to the console

**Usage**

```bash
netlify logs:edge-functions
```

**Flags**

- `deploy-id` (*string*) - Deploy ID to stream edge function logs for
- `filter` (*string*) - For monorepos, specify the name of the application to run the command in
- `from` (*string*) - Start date for historical logs (ISO 8601 format)
- `level` (*string*) - Log levels to stream. Choices are: trace, debug, info, warn, error, fatal
- `debug` (*boolean*) - Print debugging information
- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in
- `to` (*string*) - End date for historical logs (ISO 8601 format, defaults to now)

**Examples**

```bash
netlify logs:edge-functions
netlify logs:edge-functions --deploy-id <deploy-id>
netlify logs:edge-functions --from 2026-01-01T00:00:00Z
netlify logs:edge-functions --from 2026-01-01T00:00:00Z --to 2026-01-02T00:00:00Z
netlify logs:edge-functions -l info warn
```

---
## `logs:function`

Expand All @@ -65,21 +99,27 @@

**Arguments**

- functionName - Name of the function to stream logs for
- functionName - Name or ID of the function to stream logs for

Check warning on line 102 in docs/commands/logs.md

View workflow job for this annotation

GitHub Actions / lint-docs

[vale] reported by reviewdog 🐶 [base.spelling] Spellcheck: did you really mean 'functionName'? Raw Output: {"message": "[base.spelling] Spellcheck: did you really mean 'functionName'?", "location": {"path": "docs/commands/logs.md", "range": {"start": {"line": 102, "column": 3}}}, "severity": "WARNING"}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Vale spellcheck warning: functionName in user-facing docs.

The vale linter flags functionName as a misspelling. Either wrap it in backticks or rephrase to "function name" for the argument description.

✏️ Suggested fix
-- functionName - Name or ID of the function to stream logs for
+- `function-name` - Name or ID of the function to stream logs for
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- functionName - Name or ID of the function to stream logs for
- `function-name` - Name or ID of the function to stream logs for
🧰 Tools
🪛 GitHub Check: lint-docs

[warning] 102-102:
[vale] reported by reviewdog 🐶
[base.spelling] Spellcheck: did you really mean 'functionName'?

Raw Output:
{"message": "[base.spelling] Spellcheck: did you really mean 'functionName'?", "location": {"path": "docs/commands/logs.md", "range": {"start": {"line": 102, "column": 3}}}, "severity": "WARNING"}

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/commands/logs.md` at line 102, Update the user-facing argument
description that currently uses the raw token functionName so Vale stops
flagging it; either enclose the token in backticks (`functionName - Name or ID
of the function to stream logs for`) or reword it to plain English (e.g.,
"function name - Name or ID of the function to stream logs for") in the logs
command documentation entry where functionName appears.


**Flags**

- `deploy-id` (*string*) - Deploy ID to look up the function from

Check warning on line 106 in docs/commands/logs.md

View workflow job for this annotation

GitHub Actions / lint-docs

[vale] reported by reviewdog 🐶 [base.accessibilityVision] Don't use vision-based terms. Use something inclusive like 'check', 'search', or 'examine' instead of 'look'. Raw Output: {"message": "[base.accessibilityVision] Don't use vision-based terms. Use something inclusive like 'check', 'search', or 'examine' instead of 'look'.", "location": {"path": "docs/commands/logs.md", "range": {"start": {"line": 106, "column": 41}}}, "severity": "WARNING"}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Vale accessibility warning: avoid vision-based term "look".

Vale (base.accessibilityVision) flags "look up" as a vision-based term. Replace with "find" or "retrieve".

✏️ Suggested fix
-- `deploy-id` (*string*) - Deploy ID to look up the function from
+- `deploy-id` (*string*) - Deploy ID to find the function from
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- `deploy-id` (*string*) - Deploy ID to look up the function from
- `deploy-id` (*string*) - Deploy ID to find the function from
🧰 Tools
🪛 GitHub Check: lint-docs

[warning] 108-108:
[vale] reported by reviewdog 🐶
[base.accessibilityVision] Don't use vision-based terms. Use something inclusive like 'check', 'search', or 'examine' instead of 'look'.

Raw Output:
{"message": "[base.accessibilityVision] Don't use vision-based terms. Use something inclusive like 'check', 'search', or 'examine' instead of 'look'.", "location": {"path": "docs/commands/logs.md", "range": {"start": {"line": 108, "column": 41}}}, "severity": "WARNING"}

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/commands/logs.md` at line 108, Replace the vision-based phrase "look up"
in the `deploy-id` flag description with a non-vision term; update the line that
reads "`deploy-id` (*string*) - Deploy ID to look up the function from" to use
"find" or "retrieve" (e.g., "Deploy ID to find the function" or "Deploy ID to
retrieve the function") so Vale's accessibility rule base.accessibilityVision is
satisfied.

- `filter` (*string*) - For monorepos, specify the name of the application to run the command in
- `from` (*string*) - Start date for historical logs (ISO 8601 format)
- `level` (*string*) - Log levels to stream. Choices are: trace, debug, info, warn, error, fatal
- `debug` (*boolean*) - Print debugging information
- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in
- `to` (*string*) - End date for historical logs (ISO 8601 format, defaults to now)

**Examples**

```bash
netlify logs:function
netlify logs:function my-function
netlify logs:function my-function --deploy-id <deploy-id>
netlify logs:function my-function -l info warn
netlify logs:function my-function --from 2026-01-01T00:00:00Z
netlify logs:function my-function --from 2026-01-01T00:00:00Z --to 2026-01-02T00:00:00Z
```

---
Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ Stream logs from your project
| Subcommand | description |
|:--------------------------- |:-----|
| [`logs:deploy`](/commands/logs#logsdeploy) | Stream the logs of deploys currently being built to the console |
| [`logs:edge-functions`](/commands/logs#logsedge-functions) | Stream netlify edge function logs to the console |
| [`logs:function`](/commands/logs#logsfunction) | Stream netlify function logs to the console |


Expand Down
42 changes: 39 additions & 3 deletions src/commands/deploy/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,8 @@ const runDeploy = async ({
functionLogsUrl: string
edgeFunctionLogsUrl: string
sourceZipFileName?: string
deployedFunctions: { name: string; id: string }[]
hasEdgeFunctions: boolean
}> => {
let results
let deployId = existingDeployId
Expand Down Expand Up @@ -662,6 +664,13 @@ const runDeploy = async ({
edgeFunctionLogsUrl += `?scope=deployid:${deployId}`
}

const availableFunctions = (results.deploy.available_functions ?? []) as { n?: string; oid?: string }[]
const deployedFunctions = availableFunctions
.filter((fn): fn is { n: string; oid: string } => Boolean(fn.n && fn.oid))
.map((fn) => ({ name: fn.n, id: fn.oid }))

const hasEdgeFunctions = (results.edgeFunctionsCount ?? 0) > 0

return {
siteId: results.deploy.site_id,
siteName: results.deploy.name,
Expand All @@ -672,6 +681,8 @@ const runDeploy = async ({
functionLogsUrl,
edgeFunctionLogsUrl,
sourceZipFileName: uploadSourceZipResult?.sourceZipFileName,
deployedFunctions,
hasEdgeFunctions,
}
}

Expand Down Expand Up @@ -779,6 +790,7 @@ interface JsonData {
logs: string
function_logs: string
edge_function_logs: string
deployed_functions: { name: string; id: string }[]
url?: string
source_zip_filename?: string
}
Expand All @@ -796,10 +808,18 @@ const printResults = ({
results: Awaited<ReturnType<typeof prepAndRunDeploy>>
runBuildCommand: boolean
}): void => {
const msgData: Record<string, string> = {
const buildLogsData: Record<string, string> = {
'Build logs': terminalLink(results.logsUrl, results.logsUrl, { fallback: false }),
}

const functionLogsData: Record<string, string> = {
'Function logs': terminalLink(results.functionLogsUrl, results.functionLogsUrl, { fallback: false }),
'Edge function Logs': terminalLink(results.edgeFunctionLogsUrl, results.edgeFunctionLogsUrl, { fallback: false }),
'Function CLI': `netlify logs:function --deploy-id ${results.deployId} <function-name>`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Function CLI" feels a bit weird? How about:

- Functions logs
  - Web: https://(...)
  - CLI: `netlify logs:function (...)`
- Edge Functions logs
  - Web: https://(...)
  - CLI: `netlify logs:edge-functions (...)`

}

const edgeFunctionLogsData: Record<string, string> = {
'Edge function logs': terminalLink(results.edgeFunctionLogsUrl, results.edgeFunctionLogsUrl, { fallback: false }),
'Edge function CLI': `netlify logs:edge-functions --deploy-id ${results.deployId}`,
}

log('')
Expand All @@ -816,6 +836,7 @@ const printResults = ({
logs: results.logsUrl,
function_logs: results.functionLogsUrl,
edge_function_logs: results.edgeFunctionLogsUrl,
deployed_functions: results.deployedFunctions,
}
if (deployToProduction) {
jsonData.url = results.siteUrl
Expand Down Expand Up @@ -847,7 +868,22 @@ const printResults = ({
}),
)

log(prettyjson.render(msgData))
log(prettyjson.render(buildLogsData))

if (results.deployedFunctions.length > 0) {
log()
log(prettyjson.render(functionLogsData))
}

if (results.hasEdgeFunctions) {
log()
log(prettyjson.render(edgeFunctionLogsData))
}

if (results.deployedFunctions.length > 0 || results.hasEdgeFunctions) {
log()
log(chalk.dim('Use --from <datetime> and --to <datetime> to fetch historical logs (ISO 8601 format)'))
}

if (!deployToProduction) {
log()
Expand Down
98 changes: 98 additions & 0 deletions src/commands/logs/edge-functions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { OptionValues } from 'commander'
import inquirer from 'inquirer'

import { chalk, log } from '../../utils/command-helpers.js'
import { getWebSocket } from '../../utils/websockets/index.js'
import type BaseCommand from '../base-command.js'

import { parseDateToMs, fetchHistoricalLogs, printHistoricalLogs, formatLogEntry } from './log-api.js'
import { CLI_LOG_LEVEL_CHOICES_STRING, LOG_LEVELS_LIST } from './log-levels.js'
import { getName } from './build.js'

export const logsEdgeFunction = async (options: OptionValues, command: BaseCommand) => {
let deployId = options.deployId as string | undefined
await command.authenticate()

const client = command.netlify.api
const { site } = command.netlify
const { id: siteId } = site

if (!siteId) {
log('You must link a project before attempting to view edge function logs')
return
}

const levels = options.level as string[] | undefined
if (levels && !levels.every((level) => LOG_LEVELS_LIST.includes(level))) {
log(`Invalid log level. Choices are:${CLI_LOG_LEVEL_CHOICES_STRING.toString()}`)
}

const levelsToPrint: string[] = levels || LOG_LEVELS_LIST

if (options.from) {
const fromMs = parseDateToMs(options.from as string)
const toMs = options.to ? parseDateToMs(options.to as string) : Date.now()

const url = `https://analytics.services.netlify.com/v2/sites/${siteId}/edge_function_logs?from=${fromMs.toString()}&to=${toMs.toString()}`
const data = await fetchHistoricalLogs({ url, accessToken: client.accessToken ?? '' })
printHistoricalLogs(data, levelsToPrint)
return
}

const userId = command.netlify.globalConfig.get('userId') as string

if (!deployId) {
const deploys = await client.listSiteDeploys({ siteId })

if (deploys.length === 0) {
log('No deploys found for the project')
return
}

if (deploys.length === 1) {
deployId = deploys[0].id
} else {
const { result } = (await inquirer.prompt({
name: 'result',
type: 'list',
message: `Select a deploy\n\n${chalk.yellow('*')} indicates a deploy created by you`,
choices: deploys.map((deploy) => ({
name: getName({ deploy, userId }),
value: deploy.id,
})),
})) as { result: string }

deployId = result
}
}

const ws = getWebSocket('wss://socketeer.services.netlify.com/edge-function/logs')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side note: I hate that we're exposing this. We should set up a customer-facing domain name for this.


ws.on('open', () => {
ws.send(
JSON.stringify({
deploy_id: deployId,
site_id: siteId,
access_token: client.accessToken,
since: new Date().toISOString(),
}),
)
})

ws.on('message', (data: string) => {
const logData = JSON.parse(data) as { level: string; message: string; timestamp?: string }
if (!levelsToPrint.includes(logData.level.toLowerCase())) {
return
}
log(formatLogEntry(logData))
})

ws.on('close', () => {
log('Connection closed')
})

ws.on('error', (err: Error) => {
log('Connection error')
log(err.message)
})
}
33 changes: 27 additions & 6 deletions src/commands/logs/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { chalk, log } from '../../utils/command-helpers.js'
import { getWebSocket } from '../../utils/websockets/index.js'
import type BaseCommand from '../base-command.js'

import { parseDateToMs, fetchHistoricalLogs, printHistoricalLogs } from './log-api.js'
import { CLI_LOG_LEVEL_CHOICES_STRING, LOG_LEVELS, LOG_LEVELS_LIST } from './log-levels.js'

function getLog(logData: { level: string; message: string }) {
Expand All @@ -28,8 +29,10 @@ function getLog(logData: { level: string; message: string }) {
}

export const logsFunction = async (functionName: string | undefined, options: OptionValues, command: BaseCommand) => {
await command.authenticate()

const client = command.netlify.api
const { site } = command.netlify
const { site, siteInfo } = command.netlify
const { id: siteId } = site

if (options.level && !options.level.every((level: string) => LOG_LEVELS_LIST.includes(level))) {
Expand All @@ -38,17 +41,24 @@ export const logsFunction = async (functionName: string | undefined, options: Op

const levelsToPrint = options.level || LOG_LEVELS_LIST

// TODO: Update type once the open api spec is updated https://open-api.netlify.com/#tag/function/operation/searchSiteFunctions
const { functions = [] } = (await client.searchSiteFunctions({ siteId: siteId! })) as any
let functions: any[]
if (options.deployId) {
const deploy = (await client.getSiteDeploy({ siteId: siteId!, deployId: options.deployId })) as any
functions = deploy.available_functions ?? []
} else {
// TODO: Update type once the open api spec is updated https://open-api.netlify.com/#tag/function/operation/searchSiteFunctions
const result = (await client.searchSiteFunctions({ siteId: siteId! })) as any
functions = result.functions ?? []
}

if (functions.length === 0) {
log(`No functions found for the project`)
log(`No functions found for the ${options.deployId ? 'deploy' : 'project'}`)
return
}

let selectedFunction
if (functionName) {
selectedFunction = functions.find((fn: any) => fn.n === functionName)
selectedFunction = functions.find((fn: any) => fn.n === functionName || fn.oid === functionName)
} else {
const { result } = await inquirer.prompt({
name: 'result',
Expand All @@ -65,7 +75,18 @@ export const logsFunction = async (functionName: string | undefined, options: Op
return
}

const { a: accountId, oid: functionId } = selectedFunction
const { a: accountId, n: resolvedFunctionName, oid: functionId } = selectedFunction

if (options.from) {
const fromMs = parseDateToMs(options.from)
const toMs = options.to ? parseDateToMs(options.to) : Date.now()
const branch = siteInfo.build_settings?.repo_branch ?? 'main'

const url = `https://analytics.services.netlify.com/v2/sites/${siteId}/branch/${branch}/function_logs/${resolvedFunctionName}?from=${fromMs.toString()}&to=${toMs.toString()}`
Comment on lines +83 to +85
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unencoded path segments will break for feature-branch workflows.

branch and resolvedFunctionName are interpolated directly into the URL path. Git branches can legally contain / (e.g. feature/foo), which adds an unintended extra path segment and silently routes to the wrong endpoint. encodeURIComponent should be applied to each variable segment.

🐛 Proposed fix
-    const url = `https://analytics.services.netlify.com/v2/sites/${siteId}/branch/${branch}/function_logs/${resolvedFunctionName}?from=${fromMs.toString()}&to=${toMs.toString()}`
+    const url = `https://analytics.services.netlify.com/v2/sites/${encodeURIComponent(siteId!)}/branch/${encodeURIComponent(branch)}/function_logs/${encodeURIComponent(resolvedFunctionName)}?from=${fromMs.toString()}&to=${toMs.toString()}`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/logs/functions.ts` around lines 83 - 85, The URL currently
interpolates branch and resolvedFunctionName directly into the path (see
variables branch and resolvedFunctionName and the url constant), which breaks
for branch names containing slashes; update the URL construction to apply
encodeURIComponent to each path segment (encode branch and resolvedFunctionName)
before interpolation so the path segments are safely encoded while leaving query
parameters (fromMs/toMs) unchanged.

const data = await fetchHistoricalLogs({ url, accessToken: client.accessToken ?? '' })
printHistoricalLogs(data, levelsToPrint)
return
}

const ws = getWebSocket('wss://socketeer.services.netlify.com/function/logs')

Expand Down
Loading
Loading