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
2 changes: 2 additions & 0 deletions docs/commands/logs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <deploy-id>
```


Expand Down
7 changes: 7 additions & 0 deletions src/commands/logs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <deploy-id>',
'Show logs for a specific deploy by ID, including failed builds. Cannot be combined with --url',
),
)
.addOption(
new Option('-l, --level <levels...>', `Log levels to include. Choices are:${CLI_LOG_LEVEL_CHOICES_STRING}`),
)
Expand All @@ -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 <deploy-id>',
])
.action(async (options: OptionValues, command: BaseCommand) => {
const { logsCommand } = await import('./logs.js')
Expand Down
50 changes: 35 additions & 15 deletions src/commands/logs/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type BaseCommand from '../base-command.js'

import {
createColorAssigner,
DEPLOY_ID_RE,
formatJsonLine,
formatLogLine,
parseTimeValue,
Expand All @@ -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'
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} 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) {
Expand Down Expand Up @@ -218,33 +232,36 @@ 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

if (historicalRange) {
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
}
Expand All @@ -259,6 +276,7 @@ export const logsCommand = async (options: OptionValues, command: BaseCommand) =
siteId,
accessToken: client.accessToken,
deployId,
deployTargeted,
functionNames,
edgeFunctionNames,
levelsToPrint,
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -360,6 +378,7 @@ const runFollowMode = async ({
siteId,
accessToken,
deployId,
deployTargeted,
functionNames,
edgeFunctionNames,
levelsToPrint,
Expand All @@ -370,6 +389,7 @@ const runFollowMode = async ({
siteId: string
accessToken: string | null | undefined
deployId?: string
deployTargeted: boolean
functionNames: string[]
edgeFunctionNames: string[]
levelsToPrint: string[]
Expand All @@ -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.'))
}
Expand Down
152 changes: 104 additions & 48 deletions src/commands/logs/sources/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,62 +1,124 @@
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
section?: string
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<LogEntry[]> => {
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<void>((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 = (
Expand All @@ -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()
}
})
Expand All @@ -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<string | undefined> => {
const deploys = (await client.listSiteDeploys({ siteId, per_page: 1 })) as { id: string }[]
return deploys.length > 0 ? deploys[0].id : undefined
}
Loading
Loading