From 2f79da38db0026f84593c63a218d9ef7f2e9a0b3 Mon Sep 17 00:00:00 2001 From: Salma Alam-Naylor Date: Tue, 1 Sep 2026 14:29:48 +0100 Subject: [PATCH 1/6] fix(logs): retrieve historical deploy logs over websocket `netlify logs --source deploy` (historical) always 404'd: it called `${apiBase}/api/v1/deploys/:id/log`, which both double-prefixed `/api/v1` (apiBase already ends in it) and targeted a REST endpoint that does not exist. Rewrite the historical deploy source to replay stored build logs over the socketeer websocket, the same transport the working `--follow` path and the deploy UI use. Also make failed builds reachable: `--source deploy` now auto-selects the latest deploy of any state, add a `--deploy ` flag to target a specific deploy (including failed builds), and show the full build log when a deploy is explicitly targeted instead of applying the time window. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/logs/index.ts | 7 + src/commands/logs/logs.ts | 39 ++-- src/commands/logs/sources/deploy.ts | 134 +++++++++----- tests/unit/commands/logs/deploy.test.ts | 235 ++++++++++++++++++++++++ 4 files changed, 355 insertions(+), 60 deletions(-) create mode 100644 tests/unit/commands/logs/deploy.test.ts diff --git a/src/commands/logs/index.ts b/src/commands/logs/index.ts index 091dbdd6139..60d13d0bf77 100644 --- a/src/commands/logs/index.ts +++ b/src/commands/logs/index.ts @@ -129,6 +129,12 @@ export const createLogsCommand = (program: BaseCommand) => { 'Show logs for the deploy behind the given URL. Supports deploy permalinks and branch subdomains', ), ) + .addOption( + new Option( + '-d, --deploy ', + 'Show logs for a specific deploy by ID, including failed builds. Cannot be combined with --url', + ), + ) .addOption( new Option('-l, --level ', `Log levels to include. Choices are:${CLI_LOG_LEVEL_CHOICES_STRING}`), ) @@ -143,6 +149,7 @@ export const createLogsCommand = (program: BaseCommand) => { 'netlify logs --follow --source functions --source edge-functions', 'netlify logs --json --since 1h', 'netlify logs --url https://my-branch--my-site.netlify.app --since 1h', + 'netlify logs --source deploy --deploy 5b4e23db82d3f1780abd74f2 --since 7d', ]) .action(async (options: OptionValues, command: BaseCommand) => { const { logsCommand } = await import('./logs.js') diff --git a/src/commands/logs/logs.ts b/src/commands/logs/logs.ts index 09c3a2671fe..ef70b296b6a 100644 --- a/src/commands/logs/logs.ts +++ b/src/commands/logs/logs.ts @@ -6,6 +6,7 @@ import type BaseCommand from '../base-command.js' import { createColorAssigner, + DEPLOY_ID_RE, formatJsonLine, formatLogLine, parseTimeValue, @@ -18,6 +19,7 @@ import { LOG_LEVELS_LIST, CLI_LOG_LEVEL_CHOICES_STRING } from './log-levels.js' import { fetchDeployHistoricalLogs, findCurrentBuildingDeploy, + findLatestDeploy, findLatestReadyDeploy, streamDeploy, } from './sources/deploy.js' @@ -167,9 +169,21 @@ export const logsCommand = async (options: OptionValues, command: BaseCommand) = } let deployId: string | undefined - if (options.url) { + let deployTargeted = false + if (options.deploy) { + const explicitDeployId = (options.deploy as string).trim() + if (!DEPLOY_ID_RE.test(explicitDeployId)) { + return logAndThrowError(`Invalid --deploy value: ${explicitDeployId}. Expected a deploy ID.`) + } + if (options.url) { + return logAndThrowError('--deploy cannot be used together with --url.') + } + deployId = explicitDeployId + deployTargeted = true + } else if (options.url) { try { deployId = await resolveDeployIdFromUrl(options.url as string, client, siteId, siteInfo) + deployTargeted = deployId !== undefined } catch (error) { const message = (error as Error).message if (message.includes("doesn't seem to match") && siteInfo.name) { @@ -218,15 +232,15 @@ export const logsCommand = async (options: OptionValues, command: BaseCommand) = } if (!deployId) { - const latestId = await findLatestReadyDeploy(client, siteId) + const latestId = sources.includes('deploy') + ? await findLatestDeploy(client, siteId) + : await findLatestReadyDeploy(client, siteId) if (latestId) { deployId = latestId } } } - const apiBase = client.basePath - const sinceValue = (options.since as string | undefined) ?? DEFAULT_SINCE const untilValue = options.until as string | undefined @@ -234,17 +248,20 @@ export const logsCommand = async (options: OptionValues, command: BaseCommand) = await runHistoricalMode({ sources, client, - apiBase, siteId, accessToken: client.accessToken, deployId, + deployTargeted, functionNames, edgeFunctionNames, from: historicalRange.from, to: historicalRange.to, levelsToPrint, json, - timeDescription: humanizeTimeRange(sinceValue, untilValue), + timeDescription: + deployTargeted && sources.length === 1 && sources[0] === 'deploy' + ? (deployId ?? '') + : humanizeTimeRange(sinceValue, untilValue), }) return } @@ -269,10 +286,10 @@ export const logsCommand = async (options: OptionValues, command: BaseCommand) = const runHistoricalMode = async ({ sources, client, - apiBase, siteId, accessToken, deployId, + deployTargeted, functionNames, edgeFunctionNames, from, @@ -283,10 +300,10 @@ const runHistoricalMode = async ({ }: { sources: Source[] client: NetlifyAPI - apiBase: string siteId: string accessToken: string | null | undefined deployId?: string + deployTargeted: boolean functionNames: string[] edgeFunctionNames: string[] from: number @@ -299,11 +316,11 @@ const runHistoricalMode = async ({ if (sources.includes('deploy') && deployId) { const deployEntries = await fetchDeployHistoricalLogs({ - apiBase, + siteId, accessToken, deployId, - from, - to, + from: deployTargeted ? undefined : from, + to: deployTargeted ? undefined : to, }) allEntries.push(...deployEntries) } diff --git a/src/commands/logs/sources/deploy.ts b/src/commands/logs/sources/deploy.ts index d58c548ddb0..2d1d0543efe 100644 --- a/src/commands/logs/sources/deploy.ts +++ b/src/commands/logs/sources/deploy.ts @@ -1,11 +1,10 @@ import type { NetlifyAPI } from '@netlify/api' import { getWebSocket } from '../../../utils/websockets/index.js' -import { debugFetch } from '../log-api.js' import type { LogEntry } from '../log-api.js' -interface DeployLogLine { - ts: string +interface DeployLogMessage { + ts?: string | number log?: string message?: string level?: string @@ -13,50 +12,96 @@ interface DeployLogLine { type?: string } +const DEPLOY_LOG_REPLAY_TIMEOUT_MS = 30_000 + +const parseDeployLogTimestamp = (message: DeployLogMessage): number | undefined => { + if (!message.ts) { + return undefined + } + const ts = new Date(message.ts).getTime() + return Number.isNaN(ts) ? undefined : ts +} + +const toLogEntry = (message: DeployLogMessage, ts: number): LogEntry => ({ + source: 'deploy', + name: 'deploy', + ts, + level: message.level ?? 'INFO', + message: message.log ?? message.message ?? '', + section: message.section, +}) + +const isEndOfBuild = (message: DeployLogMessage): boolean => + message.type === 'report' && message.section === 'building' + export const fetchDeployHistoricalLogs = async ({ - apiBase, + siteId, accessToken, deployId, from, to, }: { - apiBase: string + siteId: string accessToken: string | null | undefined deployId: string - from: number - to: number + from?: number + to?: number }): Promise => { - const response = await debugFetch(`${apiBase}/api/v1/deploys/${encodeURIComponent(deployId)}/log`, { - headers: { - Authorization: `Bearer ${accessToken ?? ''}`, - }, - }) + const collected: { entry: LogEntry; hasTimestamp: boolean }[] = [] + const ws = getWebSocket('wss://socketeer.services.netlify.com/build/logs') - if (!response.ok) { - throw new Error(`Failed to fetch deploy logs: ${response.status.toString()} ${response.statusText}`) - } + await new Promise((resolve) => { + let settled = false + const settle = () => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + ws.close() + resolve() + } + const timeout = setTimeout(settle, DEPLOY_LOG_REPLAY_TIMEOUT_MS) - const logData = (await response.json()) as DeployLogLine[] - if (!Array.isArray(logData)) { - return [] - } + ws.on('open', () => { + ws.send( + JSON.stringify({ + deploy_id: deployId, + site_id: siteId, + access_token: accessToken, + }), + ) + }) - return logData - .map((line): LogEntry | null => { - const ts = new Date(line.ts).getTime() - if (Number.isNaN(ts) || ts < from || ts > to) { - return null + ws.on('message', (data: string) => { + let message: DeployLogMessage + try { + message = JSON.parse(data) as DeployLogMessage + } catch { + return } - return { - source: 'deploy', - name: 'deploy', - ts, - level: line.level ?? 'INFO', - message: line.log ?? line.message ?? '', - section: line.section, + if (isEndOfBuild(message)) { + settle() + return } + const parsedTs = parseDeployLogTimestamp(message) + collected.push({ + entry: toLogEntry(message, parsedTs ?? Date.now()), + hasTimestamp: parsedTs !== undefined, + }) }) - .filter((entry): entry is LogEntry => entry !== null) + + ws.on('close', settle) + ws.on('error', settle) + }) + + return collected + .filter( + ({ entry, hasTimestamp }) => + !hasTimestamp || ((from === undefined || entry.ts >= from) && (to === undefined || entry.ts <= to)), + ) + .map(({ entry }) => entry) + .sort((a, b) => a.ts - b.ts) } export const streamDeploy = ( @@ -79,24 +124,10 @@ export const streamDeploy = ( }) ws.on('message', (data: string) => { - const logData = JSON.parse(data) as { - message: string - section?: string - type?: string - level?: string - ts?: string - } - - onEntry({ - source: 'deploy', - name: 'deploy', - ts: logData.ts ? new Date(logData.ts).getTime() : Date.now(), - level: logData.level ?? 'INFO', - message: logData.message, - section: logData.section, - }) + const message = JSON.parse(data) as DeployLogMessage + onEntry(toLogEntry(message, parseDeployLogTimestamp(message) ?? Date.now())) - if (logData.type === 'report' && logData.section === 'building') { + if (isEndOfBuild(message)) { ws.close() } }) @@ -119,3 +150,8 @@ export const findLatestReadyDeploy = async (client: NetlifyAPI, siteId: string): const deploys = (await client.listSiteDeploys({ siteId, state: 'ready', per_page: 1 })) as { id: string }[] return deploys.length > 0 ? deploys[0].id : undefined } + +export const findLatestDeploy = async (client: NetlifyAPI, siteId: string): Promise => { + const deploys = (await client.listSiteDeploys({ siteId, per_page: 1 })) as { id: string }[] + return deploys.length > 0 ? deploys[0].id : undefined +} diff --git a/tests/unit/commands/logs/deploy.test.ts b/tests/unit/commands/logs/deploy.test.ts new file mode 100644 index 00000000000..6e4d08dee3d --- /dev/null +++ b/tests/unit/commands/logs/deploy.test.ts @@ -0,0 +1,235 @@ +import { EventEmitter } from 'node:events' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +class FakeWebSocket extends EventEmitter { + sent: string[] = [] + closed = false + + send(data: string): void { + this.sent.push(data) + } + + close(): void { + if (this.closed) { + return + } + this.closed = true + this.emit('close') + } +} + +const sockets: FakeWebSocket[] = [] +const getWebSocketMock = vi.fn((_url: string) => { + const ws = new FakeWebSocket() + sockets.push(ws) + return ws +}) + +vi.mock('../../../../src/utils/websockets/index.js', () => ({ + getWebSocket: (url: string) => getWebSocketMock(url), +})) + +const { fetchDeployHistoricalLogs, findLatestDeploy, findLatestReadyDeploy, streamDeploy } = await import( + '../../../../src/commands/logs/sources/deploy.js' +) + +const FROM = Date.parse('2026-01-01T00:00:00.000Z') +const TO = Date.parse('2026-01-01T00:10:00.000Z') + +const drive = (ws: FakeWebSocket, messages: unknown[]) => { + ws.emit('open') + for (const message of messages) { + ws.emit('message', JSON.stringify(message)) + } +} + +beforeEach(() => { + sockets.length = 0 + getWebSocketMock.mockClear() +}) + +describe('fetchDeployHistoricalLogs', () => { + it('connects to socketeer and sends the deploy handshake', async () => { + const promise = fetchDeployHistoricalLogs({ + siteId: 'site-1', + accessToken: 'token-1', + deployId: 'deploy-1', + from: FROM, + to: TO, + }) + + const [ws] = sockets + drive(ws, [{ type: 'report', section: 'building' }]) + await promise + + expect(getWebSocketMock).toHaveBeenCalledWith('wss://socketeer.services.netlify.com/build/logs') + expect(JSON.parse(ws.sent[0])).toEqual({ + deploy_id: 'deploy-1', + site_id: 'site-1', + access_token: 'token-1', + }) + }) + + it('replays stored build logs as sorted deploy entries', async () => { + const promise = fetchDeployHistoricalLogs({ + siteId: 'site-1', + accessToken: 'token-1', + deployId: 'deploy-1', + from: FROM, + to: TO, + }) + + drive(sockets[0], [ + { ts: '2026-01-01T00:05:00.000Z', log: 'second', level: 'INFO', section: 'building' }, + { ts: '2026-01-01T00:02:00.000Z', message: 'first', level: 'WARNING', section: 'initializing' }, + { type: 'report', section: 'building' }, + ]) + const entries = await promise + + expect(entries.map((entry) => entry.message)).toEqual(['first', 'second']) + expect(entries[0]).toMatchObject({ source: 'deploy', name: 'deploy', level: 'WARNING', section: 'initializing' }) + }) + + it('drops timestamped lines outside the requested window but keeps untimestamped build lines', async () => { + const promise = fetchDeployHistoricalLogs({ + siteId: 'site-1', + accessToken: 'token-1', + deployId: 'deploy-1', + from: FROM, + to: TO, + }) + + drive(sockets[0], [ + { ts: '2025-06-01T00:00:00.000Z', log: 'too old' }, + { ts: '2026-01-01T00:05:00.000Z', log: 'in range' }, + { log: 'no timestamp' }, + { type: 'report', section: 'building' }, + ]) + const entries = await promise + + const messages = entries.map((entry) => entry.message) + expect(messages).toContain('in range') + expect(messages).toContain('no timestamp') + expect(messages).not.toContain('too old') + }) + + it('returns the full build log when no time window is given (explicitly targeted deploy)', async () => { + const promise = fetchDeployHistoricalLogs({ + siteId: 'site-1', + accessToken: 'token-1', + deployId: 'deploy-1', + }) + + drive(sockets[0], [ + { ts: '2020-01-01T00:00:00.000Z', log: 'very old but requested' }, + { ts: '2026-01-01T00:05:00.000Z', log: 'newer' }, + { type: 'report', section: 'building' }, + ]) + const entries = await promise + + expect(entries.map((entry) => entry.message)).toEqual(['very old but requested', 'newer']) + }) + + it('parses numeric epoch-millisecond timestamps', async () => { + const promise = fetchDeployHistoricalLogs({ + siteId: 'site-1', + accessToken: 'token-1', + deployId: 'deploy-1', + from: FROM, + to: TO, + }) + + drive(sockets[0], [ + { ts: Date.parse('2026-01-01T00:05:00.000Z'), log: 'numeric ts' }, + { type: 'report', section: 'building' }, + ]) + const entries = await promise + + expect(entries).toHaveLength(1) + expect(entries[0]).toMatchObject({ message: 'numeric ts', ts: Date.parse('2026-01-01T00:05:00.000Z') }) + }) + + it('ignores malformed messages without throwing', async () => { + const promise = fetchDeployHistoricalLogs({ + siteId: 'site-1', + accessToken: 'token-1', + deployId: 'deploy-1', + from: FROM, + to: TO, + }) + + const [ws] = sockets + ws.emit('open') + ws.emit('message', 'not-json') + ws.emit('message', JSON.stringify({ ts: '2026-01-01T00:05:00.000Z', log: 'valid' })) + ws.emit('message', JSON.stringify({ type: 'report', section: 'building' })) + const entries = await promise + + expect(entries.map((entry) => entry.message)).toEqual(['valid']) + }) + + it('resolves when the socket closes without a terminal report', async () => { + const promise = fetchDeployHistoricalLogs({ + siteId: 'site-1', + accessToken: 'token-1', + deployId: 'deploy-1', + from: FROM, + to: TO, + }) + + const [ws] = sockets + ws.emit('open') + ws.emit('message', JSON.stringify({ ts: '2026-01-01T00:05:00.000Z', log: 'partial' })) + ws.close() + const entries = await promise + + expect(entries.map((entry) => entry.message)).toEqual(['partial']) + }) +}) + +describe('deploy selection', () => { + it('findLatestDeploy requests the newest deploy regardless of state', async () => { + const listSiteDeploys = vi.fn().mockResolvedValue([{ id: 'failed-deploy', state: 'error' }]) + const client = { listSiteDeploys } as never + + const id = await findLatestDeploy(client, 'site-1') + + expect(id).toBe('failed-deploy') + expect(listSiteDeploys).toHaveBeenCalledWith({ siteId: 'site-1', per_page: 1 }) + }) + + it('findLatestReadyDeploy filters to ready deploys only', async () => { + const listSiteDeploys = vi.fn().mockResolvedValue([{ id: 'ready-deploy', state: 'ready' }]) + const client = { listSiteDeploys } as never + + const id = await findLatestReadyDeploy(client, 'site-1') + + expect(id).toBe('ready-deploy') + expect(listSiteDeploys).toHaveBeenCalledWith({ siteId: 'site-1', state: 'ready', per_page: 1 }) + }) + + it('returns undefined when there are no deploys', async () => { + const client = { listSiteDeploys: vi.fn().mockResolvedValue([]) } as never + expect(await findLatestDeploy(client, 'site-1')).toBeUndefined() + }) +}) + +describe('streamDeploy', () => { + it('streams entries and closes on the terminal build report', () => { + const onEntry = vi.fn() + const onClose = vi.fn() + + streamDeploy('site-1', 'deploy-1', 'token-1', onEntry, onClose) + const [ws] = sockets + + ws.emit('open') + ws.emit('message', JSON.stringify({ ts: '2026-01-01T00:05:00.000Z', message: 'live line', level: 'INFO' })) + expect(onEntry).toHaveBeenCalledWith(expect.objectContaining({ source: 'deploy', message: 'live line' })) + expect(ws.closed).toBe(false) + + ws.emit('message', JSON.stringify({ type: 'report', section: 'building' })) + expect(ws.closed).toBe(true) + expect(onClose).toHaveBeenCalledOnce() + }) +}) From 835e25b8fe60c6cae48624e13baeff01e209f8fb Mon Sep 17 00:00:00 2001 From: Salma Alam-Naylor Date: Tue, 1 Sep 2026 14:35:02 +0100 Subject: [PATCH 2/6] docs(logs): regenerate for --deploy flag Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/commands/logs.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/commands/logs.md b/docs/commands/logs.md index 09230084d95..01d6e306e95 100644 --- a/docs/commands/logs.md +++ b/docs/commands/logs.md @@ -17,6 +17,7 @@ netlify logs **Flags** +- `deploy` (*string*) - Show logs for a specific deploy by ID, including failed builds. Cannot be combined with --url - `edge-function` (*string*) - Filter to specific edge functions by name or path - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `follow` (*boolean*) - Stream logs in real time instead of showing historical logs @@ -42,6 +43,7 @@ netlify logs --follow netlify logs --follow --source functions --source edge-functions netlify logs --json --since 1h netlify logs --url https://my-branch--my-site.netlify.app --since 1h +netlify logs --source deploy --deploy 5b4e23db82d3f1780abd74f2 --since 7d ``` From 9d3511cfabe4fa4adfb244270f40ff713869e33c Mon Sep 17 00:00:00 2001 From: Salma Alam-Naylor Date: Tue, 1 Sep 2026 14:37:59 +0100 Subject: [PATCH 3/6] fix(logs): stream the explicitly targeted deploy in --follow `--follow --source deploy --deploy ` ignored the requested deploy: runFollowMode always streamed the current building deploy, so a finished deploy produced no output. Stream the explicitly targeted deploy directly (socketeer replays finished deploys and streams live ones), keeping the building-deploy behaviour only when no deploy was explicitly targeted. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/logs/logs.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/commands/logs/logs.ts b/src/commands/logs/logs.ts index ef70b296b6a..7de78189630 100644 --- a/src/commands/logs/logs.ts +++ b/src/commands/logs/logs.ts @@ -276,6 +276,7 @@ export const logsCommand = async (options: OptionValues, command: BaseCommand) = siteId, accessToken: client.accessToken, deployId, + deployTargeted, functionNames, edgeFunctionNames, levelsToPrint, @@ -377,6 +378,7 @@ const runFollowMode = async ({ siteId, accessToken, deployId, + deployTargeted, functionNames, edgeFunctionNames, levelsToPrint, @@ -387,6 +389,7 @@ const runFollowMode = async ({ siteId: string accessToken: string | null | undefined deployId?: string + deployTargeted: boolean functionNames: string[] edgeFunctionNames: string[] levelsToPrint: string[] @@ -399,10 +402,10 @@ const runFollowMode = async ({ printEntry(entry, levelsToPrint, json, assignColor(key)) } - if (sources.includes('deploy') && deployId) { - const buildingDeployId = await findCurrentBuildingDeploy(client, siteId) - if (buildingDeployId) { - streamDeploy(siteId, buildingDeployId, accessToken, onEntry, () => { + if (sources.includes('deploy')) { + const deployStreamId = deployTargeted ? deployId : await findCurrentBuildingDeploy(client, siteId) + if (deployStreamId) { + streamDeploy(siteId, deployStreamId, accessToken, onEntry, () => { if (!json) { log(chalk.dim('Deploy stream closed.')) } From cf239f3d0c847aa18e7699d5d7f0bf920f660203 Mon Sep 17 00:00:00 2001 From: Salma Alam-Naylor Date: Tue, 1 Sep 2026 14:41:44 +0100 Subject: [PATCH 4/6] fix(logs): harden deploy log frame parsing and surface replay errors Reuse a single parser for both the historical and live websocket paths that rejects invalid JSON and non-object frames (a `null` payload previously crashed the historical handler on isEndOfBuild). Reject the replay promise on socket error instead of resolving with partial results, so a failed connection surfaces to the caller rather than looking like an empty log. Run oxfmt on the touched files. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/logs/sources/deploy.ts | 48 +++++++++++++++++-------- tests/unit/commands/logs/deploy.test.ts | 43 ++++++++++++++++++++-- 2 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/commands/logs/sources/deploy.ts b/src/commands/logs/sources/deploy.ts index 2d1d0543efe..82b2ace5141 100644 --- a/src/commands/logs/sources/deploy.ts +++ b/src/commands/logs/sources/deploy.ts @@ -31,8 +31,17 @@ const toLogEntry = (message: DeployLogMessage, ts: number): LogEntry => ({ section: message.section, }) -const isEndOfBuild = (message: DeployLogMessage): boolean => - message.type === 'report' && message.section === 'building' +const isEndOfBuild = (message: DeployLogMessage): boolean => message.type === 'report' && message.section === 'building' + +const parseDeployLogMessage = (data: string): DeployLogMessage | null => { + let parsed: unknown + try { + parsed = JSON.parse(data) + } catch { + return null + } + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : null +} export const fetchDeployHistoricalLogs = async ({ siteId, @@ -50,18 +59,24 @@ export const fetchDeployHistoricalLogs = async ({ const collected: { entry: LogEntry; hasTimestamp: boolean }[] = [] const ws = getWebSocket('wss://socketeer.services.netlify.com/build/logs') - await new Promise((resolve) => { + await new Promise((resolve, reject) => { let settled = false - const settle = () => { + const finish = (error?: Error) => { if (settled) { return } settled = true clearTimeout(timeout) ws.close() - resolve() + if (error) { + reject(error) + } else { + resolve() + } } - const timeout = setTimeout(settle, DEPLOY_LOG_REPLAY_TIMEOUT_MS) + const timeout = setTimeout(() => { + finish() + }, DEPLOY_LOG_REPLAY_TIMEOUT_MS) ws.on('open', () => { ws.send( @@ -74,14 +89,12 @@ export const fetchDeployHistoricalLogs = async ({ }) ws.on('message', (data: string) => { - let message: DeployLogMessage - try { - message = JSON.parse(data) as DeployLogMessage - } catch { + const message = parseDeployLogMessage(data) + if (!message) { return } if (isEndOfBuild(message)) { - settle() + finish() return } const parsedTs = parseDeployLogTimestamp(message) @@ -91,8 +104,12 @@ export const fetchDeployHistoricalLogs = async ({ }) }) - ws.on('close', settle) - ws.on('error', settle) + ws.on('close', () => { + finish() + }) + ws.on('error', (error: Error) => { + finish(error) + }) }) return collected @@ -124,7 +141,10 @@ export const streamDeploy = ( }) ws.on('message', (data: string) => { - const message = JSON.parse(data) as DeployLogMessage + const message = parseDeployLogMessage(data) + if (!message) { + return + } onEntry(toLogEntry(message, parseDeployLogTimestamp(message) ?? Date.now())) if (isEndOfBuild(message)) { diff --git a/tests/unit/commands/logs/deploy.test.ts b/tests/unit/commands/logs/deploy.test.ts index 6e4d08dee3d..2fd06594b9e 100644 --- a/tests/unit/commands/logs/deploy.test.ts +++ b/tests/unit/commands/logs/deploy.test.ts @@ -30,9 +30,8 @@ vi.mock('../../../../src/utils/websockets/index.js', () => ({ getWebSocket: (url: string) => getWebSocketMock(url), })) -const { fetchDeployHistoricalLogs, findLatestDeploy, findLatestReadyDeploy, streamDeploy } = await import( - '../../../../src/commands/logs/sources/deploy.js' -) +const { fetchDeployHistoricalLogs, findLatestDeploy, findLatestReadyDeploy, streamDeploy } = + await import('../../../../src/commands/logs/sources/deploy.js') const FROM = Date.parse('2026-01-01T00:00:00.000Z') const TO = Date.parse('2026-01-01T00:10:00.000Z') @@ -169,6 +168,28 @@ describe('fetchDeployHistoricalLogs', () => { expect(entries.map((entry) => entry.message)).toEqual(['valid']) }) + it('skips null and non-object frames without throwing', async () => { + const promise = fetchDeployHistoricalLogs({ + siteId: 'site-1', + accessToken: 'token-1', + deployId: 'deploy-1', + from: FROM, + to: TO, + }) + + drive(sockets[0], [ + null, + 42, + 'a string', + ['an', 'array'], + { ts: '2026-01-01T00:05:00.000Z', log: 'valid' }, + { type: 'report', section: 'building' }, + ]) + const entries = await promise + + expect(entries.map((entry) => entry.message)).toEqual(['valid']) + }) + it('resolves when the socket closes without a terminal report', async () => { const promise = fetchDeployHistoricalLogs({ siteId: 'site-1', @@ -186,6 +207,22 @@ describe('fetchDeployHistoricalLogs', () => { expect(entries.map((entry) => entry.message)).toEqual(['partial']) }) + + it('rejects the replay promise on socket error', async () => { + const promise = fetchDeployHistoricalLogs({ + siteId: 'site-1', + accessToken: 'token-1', + deployId: 'deploy-1', + from: FROM, + to: TO, + }) + + const [ws] = sockets + ws.emit('open') + ws.emit('error', new Error('connection refused')) + + await expect(promise).rejects.toThrow('connection refused') + }) }) describe('deploy selection', () => { From aaeac38016a0e80310ea165d7ffb3e5372eff325 Mon Sep 17 00:00:00 2001 From: Salma Alam-Naylor Date: Tue, 1 Sep 2026 14:45:41 +0100 Subject: [PATCH 5/6] docs(logs): drop ineffective --since from --deploy example --deploy targets a specific deploy and shows its full build log, so the --since window has no effect. Remove it from the example to avoid implying otherwise. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/commands/logs.md | 2 +- src/commands/logs/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/commands/logs.md b/docs/commands/logs.md index 01d6e306e95..169c42f1b3a 100644 --- a/docs/commands/logs.md +++ b/docs/commands/logs.md @@ -43,7 +43,7 @@ netlify logs --follow netlify logs --follow --source functions --source edge-functions netlify logs --json --since 1h netlify logs --url https://my-branch--my-site.netlify.app --since 1h -netlify logs --source deploy --deploy 5b4e23db82d3f1780abd74f2 --since 7d +netlify logs --source deploy --deploy 5b4e23db82d3f1780abd74f2 ``` diff --git a/src/commands/logs/index.ts b/src/commands/logs/index.ts index 60d13d0bf77..570cc4148e9 100644 --- a/src/commands/logs/index.ts +++ b/src/commands/logs/index.ts @@ -149,7 +149,7 @@ export const createLogsCommand = (program: BaseCommand) => { 'netlify logs --follow --source functions --source edge-functions', 'netlify logs --json --since 1h', 'netlify logs --url https://my-branch--my-site.netlify.app --since 1h', - 'netlify logs --source deploy --deploy 5b4e23db82d3f1780abd74f2 --since 7d', + 'netlify logs --source deploy --deploy 5b4e23db82d3f1780abd74f2', ]) .action(async (options: OptionValues, command: BaseCommand) => { const { logsCommand } = await import('./logs.js') From a14d6f56dc471ed6e48553bed86a314ff5daed6d Mon Sep 17 00:00:00 2001 From: Salma Alam-Naylor Date: Tue, 1 Sep 2026 14:55:11 +0100 Subject: [PATCH 6/6] docs(logs): use placeholder in --deploy example Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/commands/logs.md | 2 +- src/commands/logs/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/commands/logs.md b/docs/commands/logs.md index 169c42f1b3a..1653d371561 100644 --- a/docs/commands/logs.md +++ b/docs/commands/logs.md @@ -43,7 +43,7 @@ netlify logs --follow netlify logs --follow --source functions --source edge-functions netlify logs --json --since 1h netlify logs --url https://my-branch--my-site.netlify.app --since 1h -netlify logs --source deploy --deploy 5b4e23db82d3f1780abd74f2 +netlify logs --source deploy --deploy ``` diff --git a/src/commands/logs/index.ts b/src/commands/logs/index.ts index 570cc4148e9..ea730de0ea8 100644 --- a/src/commands/logs/index.ts +++ b/src/commands/logs/index.ts @@ -149,7 +149,7 @@ export const createLogsCommand = (program: BaseCommand) => { 'netlify logs --follow --source functions --source edge-functions', 'netlify logs --json --since 1h', 'netlify logs --url https://my-branch--my-site.netlify.app --since 1h', - 'netlify logs --source deploy --deploy 5b4e23db82d3f1780abd74f2', + 'netlify logs --source deploy --deploy ', ]) .action(async (options: OptionValues, command: BaseCommand) => { const { logsCommand } = await import('./logs.js')