Skip to content
Closed
Show file tree
Hide file tree
Changes from 14 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 @@ netlify logs
| 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
Copy Markdown
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
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 @@ netlify logs:deploy
- `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 @@ netlify logs:function

**Arguments**

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

**Flags**

- `deploy-id` (*string*) - Deploy ID to find the function from
- `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
44 changes: 40 additions & 4 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 }),
'Function logs': terminalLink(results.functionLogsUrl, results.functionLogsUrl, { fallback: false }),
'Edge function Logs': terminalLink(results.edgeFunctionLogsUrl, results.edgeFunctionLogsUrl, { fallback: false }),
}

const functionLogsData: Record<string, string> = {
'Functions logs': terminalLink(results.functionLogsUrl, results.functionLogsUrl, { fallback: false }),
'Functions CLI': `netlify logs:function --deploy-id ${results.deployId} <function-name-or-id>`,
}

const edgeFunctionLogsData: Record<string, string> = {
'Edge Functions logs': terminalLink(results.edgeFunctionLogsUrl, results.edgeFunctionLogsUrl, { fallback: false }),
'Edge Functions 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, buildEdgeFunctionLogsUrl, 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

--deploy-id becomes a no-op in historical mode.

deployId is captured from the CLI options, but the --from path always builds a site-wide analytics URL and returns immediately. netlify logs:edge-functions --deploy-id <id> --from ... therefore ignores the requested deploy and can show unrelated logs. Either include deploy scoping in the historical request or fail fast for this flag combination.

Also applies to: 32-39

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

In `@src/commands/logs/edge-functions.ts` at line 13, The CLI's historical code
path ignores the deployId option (deployId / options.deployId) when --from is
used, causing --deploy-id to be a no-op; update the historical request
builder/handler (the code branch that handles options.from / "historical mode")
to include deployId as a filter/query parameter when constructing the historical
analytics/logs URL/request (or alternatively, detect the unsupported combination
and fail fast with a clear error), and apply the same fix to the related code
around the other occurrences noted (the block covering lines 32-39) so deploy
scoping is respected or rejected consistently.

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.join(',')}`)
}

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 = buildEdgeFunctionLogsUrl({ siteId, from: fromMs, to: toMs })
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
Copy Markdown
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))
})
Comment on lines +88 to +94
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard websocket parsing to prevent command crashes.

Line 89 assumes every frame is valid JSON; one malformed payload will throw and terminate the stream.

🛡️ Proposed defensive parsing
   ws.on('message', (data: string) => {
-    const logData = JSON.parse(data) as { level: string; message: string; timestamp?: string }
+    let logData: { level: string; message: string; timestamp?: string }
+    try {
+      logData = JSON.parse(data) as { level: string; message: string; timestamp?: string }
+    } catch {
+      log('Received malformed log payload')
+      return
+    }
+
     if (!levelsToPrint.includes(logData.level.toLowerCase())) {
       return
     }
     log(formatLogEntry(logData))
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/logs/edge-functions.ts` around lines 88 - 94, The websocket
message handler assumes every message is valid JSON and will crash on malformed
frames; wrap the JSON.parse and subsequent processing in a try/catch inside the
ws.on('message', ...) callback (where you currently reference levelsToPrint,
formatLogEntry, and log), ignore or warn on parse errors and return early for
invalid payloads, and only proceed to check levelsToPrint and call
log(formatLogEntry(...)) if parsing succeeds.


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

ws.on('error', (err: Error) => {
log('Connection error')
log(err.message)
})
}
38 changes: 32 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, buildFunctionLogsUrl, 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,27 +29,41 @@ 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 (!siteId) {
log('You must link a project before attempting to view function logs')
return
}

if (options.level && !options.level.every((level: string) => LOG_LEVELS_LIST.includes(level))) {
log(`Invalid log level. Choices are:${CLI_LOG_LEVEL_CHOICES_STRING}`)
}

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 ?? []
}
Comment on lines +50 to +57
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Keep deploy-scoped historical lookups on the deploy's branch.

The --deploy-id path switches function discovery over to getSiteDeploy(), but the historical fetch still hard-codes siteInfo.build_settings?.repo_branch ?? 'main'. For branch deploys and deploy previews, that will query the wrong analytics path and return default-branch logs instead of logs for the selected deploy. Retain the deploy response and derive the branch from it, or reject deploy-scoped historical queries until that data is available.

Also applies to: 85-92

🤖 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 50 - 57, When options.deployId
is provided the code uses getSiteDeploy() to load a deploy into the local
variable deploy but later still computes the branch from
siteInfo.build_settings?.repo_branch ?? 'main', causing historical lookups to
use the wrong (default) branch; update the deploy-id path to derive the branch
from the deploy object (e.g., deploy.build_settings?.repo_branch or
deploy.branch) and pass that branch into any historical analytics/fetch code, or
else detect the absence of branch info on the deploy and reject deploy-scoped
historical queries; change logic in the getSiteDeploy branch where functions is
set (and mirror the same fix for the other occurrence around the 85-92 area) so
historical lookups use the deploy-derived branch instead of
siteInfo.build_settings?.repo_branch.


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 +80,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 = buildFunctionLogsUrl({ siteId, branch, functionName: resolvedFunctionName, from: fromMs, to: toMs })
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