diff --git a/docs/commands/logs.md b/docs/commands/logs.md index 09230084d95..1653d371561 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 ``` diff --git a/src/commands/logs/index.ts b/src/commands/logs/index.ts index 091dbdd6139..ea730de0ea8 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 ', ]) .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..7de78189630 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 } @@ -259,6 +276,7 @@ export const logsCommand = async (options: OptionValues, command: BaseCommand) = siteId, accessToken: client.accessToken, deployId, + deployTargeted, functionNames, edgeFunctionNames, levelsToPrint, @@ -269,10 +287,10 @@ export const logsCommand = async (options: OptionValues, command: BaseCommand) = const runHistoricalMode = async ({ sources, client, - apiBase, siteId, accessToken, deployId, + deployTargeted, functionNames, edgeFunctionNames, from, @@ -283,10 +301,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 +317,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) } @@ -360,6 +378,7 @@ const runFollowMode = async ({ siteId, accessToken, deployId, + deployTargeted, functionNames, edgeFunctionNames, levelsToPrint, @@ -370,6 +389,7 @@ const runFollowMode = async ({ siteId: string accessToken: string | null | undefined deployId?: string + deployTargeted: boolean functionNames: string[] edgeFunctionNames: string[] levelsToPrint: string[] @@ -382,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.')) } diff --git a/src/commands/logs/sources/deploy.ts b/src/commands/logs/sources/deploy.ts index d58c548ddb0..82b2ace5141 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,113 @@ 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' + +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 ({ - 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, reject) => { + let settled = false + const finish = (error?: Error) => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + ws.close() + if (error) { + reject(error) + } else { + resolve() + } + } + const timeout = setTimeout(() => { + finish() + }, 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) => { + const message = parseDeployLogMessage(data) + if (!message) { + return } - return { - source: 'deploy', - name: 'deploy', - ts, - level: line.level ?? 'INFO', - message: line.log ?? line.message ?? '', - section: line.section, + if (isEndOfBuild(message)) { + finish() + 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', () => { + finish() + }) + ws.on('error', (error: Error) => { + finish(error) + }) + }) + + 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 +141,13 @@ export const streamDeploy = ( }) ws.on('message', (data: string) => { - const logData = JSON.parse(data) as { - message: string - section?: string - type?: string - level?: string - ts?: string + const message = parseDeployLogMessage(data) + if (!message) { + return } + onEntry(toLogEntry(message, parseDeployLogTimestamp(message) ?? Date.now())) - 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, - }) - - if (logData.type === 'report' && logData.section === 'building') { + if (isEndOfBuild(message)) { ws.close() } }) @@ -119,3 +170,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..2fd06594b9e --- /dev/null +++ b/tests/unit/commands/logs/deploy.test.ts @@ -0,0 +1,272 @@ +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('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', + 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']) + }) + + 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', () => { + 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() + }) +})