diff --git a/docs/commands/logs.md b/docs/commands/logs.md index 8c761e85b25..3aa133598ad 100644 --- a/docs/commands/logs.md +++ b/docs/commands/logs.md @@ -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 | | [`logs:function`](/commands/logs#logsfunction) | Stream netlify function logs to the console | @@ -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 ``` --- @@ -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 +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` @@ -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 look up 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 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 ``` --- diff --git a/docs/index.md b/docs/index.md index a306d1b873d..151b0f2000d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 | diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index 5b847a8654d..c6250819699 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -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 @@ -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, @@ -672,6 +681,8 @@ const runDeploy = async ({ functionLogsUrl, edgeFunctionLogsUrl, sourceZipFileName: uploadSourceZipResult?.sourceZipFileName, + deployedFunctions, + hasEdgeFunctions, } } @@ -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 } @@ -796,10 +808,18 @@ const printResults = ({ results: Awaited> runBuildCommand: boolean }): void => { - const msgData: Record = { + const buildLogsData: Record = { 'Build logs': terminalLink(results.logsUrl, results.logsUrl, { fallback: false }), + } + + const functionLogsData: Record = { '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} `, + } + + const edgeFunctionLogsData: Record = { + 'Edge function logs': terminalLink(results.edgeFunctionLogsUrl, results.edgeFunctionLogsUrl, { fallback: false }), + 'Edge function CLI': `netlify logs:edge-functions --deploy-id ${results.deployId}`, } log('') @@ -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 @@ -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 and --to to fetch historical logs (ISO 8601 format)')) + } if (!deployToProduction) { log() diff --git a/src/commands/logs/edge-functions.ts b/src/commands/logs/edge-functions.ts new file mode 100644 index 00000000000..6737118af8d --- /dev/null +++ b/src/commands/logs/edge-functions.ts @@ -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') + + 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) + }) +} diff --git a/src/commands/logs/functions.ts b/src/commands/logs/functions.ts index 54fa8f54599..22d0932cda4 100644 --- a/src/commands/logs/functions.ts +++ b/src/commands/logs/functions.ts @@ -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 }) { @@ -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))) { @@ -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', @@ -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()}` + const data = await fetchHistoricalLogs({ url, accessToken: client.accessToken ?? '' }) + printHistoricalLogs(data, levelsToPrint) + return + } const ws = getWebSocket('wss://socketeer.services.netlify.com/function/logs') diff --git a/src/commands/logs/index.ts b/src/commands/logs/index.ts index 3c1be8415ac..d0053ed1db6 100644 --- a/src/commands/logs/index.ts +++ b/src/commands/logs/index.ts @@ -22,11 +22,17 @@ export const createLogsFunctionCommand = (program: BaseCommand) => { .addOption( new Option('-l, --level ', `Log levels to stream. Choices are:${CLI_LOG_LEVEL_CHOICES_STRING}`), ) - .addArgument(new Argument('[functionName]', 'Name of the function to stream logs for')) + .addOption(new Option('--deploy-id ', 'Deploy ID to look up the function from')) + .addOption(new Option('--from ', 'Start date for historical logs (ISO 8601 format)')) + .addOption(new Option('--to ', 'End date for historical logs (ISO 8601 format, defaults to now)')) + .addArgument(new Argument('[functionName]', 'Name or ID of the function to stream logs for')) .addExamples([ 'netlify logs:function', 'netlify logs:function my-function', + 'netlify logs:function my-function --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', ]) .description('Stream netlify function logs to the console') .action(async (functionName: string | undefined, options: OptionValues, command: BaseCommand) => { @@ -35,15 +41,46 @@ export const createLogsFunctionCommand = (program: BaseCommand) => { }) } +export const createLogsEdgeFunctionCommand = (program: BaseCommand) => { + program + .command('logs:edge-functions') + .addOption( + new Option('-l, --level ', `Log levels to stream. Choices are:${CLI_LOG_LEVEL_CHOICES_STRING}`), + ) + .addOption(new Option('--deploy-id ', 'Deploy ID to stream edge function logs for')) + .addOption(new Option('--from ', 'Start date for historical logs (ISO 8601 format)')) + .addOption(new Option('--to ', 'End date for historical logs (ISO 8601 format, defaults to now)')) + .addExamples([ + 'netlify logs:edge-functions', + 'netlify logs:edge-functions --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', + ]) + .description('Stream netlify edge function logs to the console') + .action(async (options: OptionValues, command: BaseCommand) => { + const { logsEdgeFunction } = await import('./edge-functions.js') + await logsEdgeFunction(options, command) + }) +} + export const createLogsCommand = (program: BaseCommand) => { createLogsBuildCommand(program) createLogsFunctionCommand(program) + createLogsEdgeFunctionCommand(program) + return program .command('logs') .alias('log') .description('Stream logs from your project') - .addExamples(['netlify logs:deploy', 'netlify logs:function', 'netlify logs:function my-function']) + .addExamples([ + 'netlify logs:deploy', + 'netlify logs:function', + 'netlify logs:function my-function', + 'netlify logs:edge-functions', + 'netlify logs:edge-functions --deploy-id ', + ]) .action((_, command: BaseCommand) => command.help()) } diff --git a/src/commands/logs/log-api.ts b/src/commands/logs/log-api.ts new file mode 100644 index 00000000000..bd8312d2579 --- /dev/null +++ b/src/commands/logs/log-api.ts @@ -0,0 +1,71 @@ +import { chalk, log } from '../../utils/command-helpers.js' +import { LOG_LEVELS } from './log-levels.js' + +export function parseDateToMs(dateString: string): number { + const ms = new Date(dateString).getTime() + if (Number.isNaN(ms)) { + throw new Error(`Invalid date: ${dateString}`) + } + return ms +} + +export async function fetchHistoricalLogs({ + url, + accessToken, +}: { + url: string + accessToken: string +}): Promise { + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { error?: string } + throw new Error(errorData.error ?? `HTTP ${response.status.toString()}: ${response.statusText}`) + } + + return response.json() +} + +export function formatLogEntry(entry: { timestamp?: string; level?: string; message?: string }): string { + const timestamp = entry.timestamp ? new Date(entry.timestamp).toISOString() : '' + let levelString = entry.level ?? '' + + switch (levelString.toUpperCase()) { + case LOG_LEVELS.INFO: + levelString = chalk.blueBright(levelString) + break + case LOG_LEVELS.WARN: + levelString = chalk.yellowBright(levelString) + break + case LOG_LEVELS.ERROR: + levelString = chalk.redBright(levelString) + break + default: + break + } + + const parts = [timestamp, levelString, entry.message ?? ''].filter(Boolean) + return parts.join(' ') +} + +export function printHistoricalLogs(data: unknown, levelsToPrint: string[]): void { + const entries = Array.isArray(data) ? (data as { timestamp?: string; level?: string; message?: string }[]) : [] + + if (entries.length === 0) { + log('No logs found for the specified time range') + return + } + + for (const entry of entries) { + const level = (entry.level ?? '').toLowerCase() + if (levelsToPrint.length > 0 && !levelsToPrint.includes(level)) { + continue + } + log(formatLogEntry(entry)) + } +} diff --git a/src/utils/deploy/deploy-site.ts b/src/utils/deploy/deploy-site.ts index 438bf7387de..6b76c95e45f 100644 --- a/src/utils/deploy/deploy-site.ts +++ b/src/utils/deploy/deploy-site.ts @@ -221,6 +221,7 @@ For more information, visit https://ntl.fyi/cli-native-modules.`) deployId, deploy, uploadList, + edgeFunctionsCount, } return deployManifest } diff --git a/tests/integration/commands/logs/edge-functions.test.ts b/tests/integration/commands/logs/edge-functions.test.ts new file mode 100644 index 00000000000..adebf139ac3 --- /dev/null +++ b/tests/integration/commands/logs/edge-functions.test.ts @@ -0,0 +1,224 @@ +import { Mock, afterAll, afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import BaseCommand from '../../../../src/commands/base-command.js' +import { createLogsEdgeFunctionCommand } from '../../../../src/commands/logs/index.js' +import { LOG_LEVELS } from '../../../../src/commands/logs/log-levels.js' +import { log } from '../../../../src/utils/command-helpers.js' +import { getWebSocket } from '../../../../src/utils/websockets/index.js' +import { startMockApi } from '../../utils/mock-api-vitest.js' +import { getEnvironmentVariables } from '../../utils/mock-api.js' + +vi.mock('../../../../src/utils/websockets/index.js', () => ({ + getWebSocket: vi.fn(), +})) + +vi.mock('../../../../src/utils/command-helpers.js', async () => { + const actual = await vi.importActual('../../../../src/utils/command-helpers.js') + return { + ...actual, + log: vi.fn(), + } +}) + +vi.mock('inquirer', () => ({ + default: { + prompt: vi.fn().mockResolvedValue({ result: 'deploy-id-1' }), + registerPrompt: vi.fn(), + }, +})) + +const siteInfo = { + admin_url: 'https://app.netlify.com/projects/site-name/overview', + ssl_url: 'https://site-name.netlify.app/', + id: 'site_id', + name: 'site-name', + build_settings: { repo_url: 'https://github.com/owner/repo' }, +} + +const routes = [ + { + path: 'accounts', + response: [{ slug: 'test-account' }], + }, + { + path: 'sites', + response: [], + }, + { path: 'sites/site_id', response: siteInfo }, + { path: 'sites/site_id/service-instances', response: [] }, + { + path: 'user', + response: { name: 'test user', slug: 'test-user', email: 'user@test.com' }, + }, + { + path: 'sites/site_id/deploys', + response: [ + { + id: 'deploy-id-1', + context: 'production', + user_id: 'user-1', + review_id: null, + }, + ], + }, +] + +describe('logs:edge-functions command', () => { + const originalEnv = process.env + + let program: BaseCommand + + afterEach(() => { + vi.clearAllMocks() + process.env = { ...originalEnv } + }) + + beforeEach(() => { + program = new BaseCommand('netlify') + + createLogsEdgeFunctionCommand(program) + }) + + afterAll(() => { + vi.restoreAllMocks() + vi.resetModules() + + process.env = { ...originalEnv } + }) + + test('should setup the edge functions stream correctly', async () => { + const { apiUrl } = await startMockApi({ routes }) + const spyWebsocket = getWebSocket as unknown as Mock + const spyOn = vi.fn() + const spySend = vi.fn() + spyWebsocket.mockReturnValue({ + on: spyOn, + send: spySend, + }) + + const env = getEnvironmentVariables({ apiUrl }) + Object.assign(process.env, env) + + await program.parseAsync(['', '', 'logs:edge-functions']) + + expect(spyWebsocket).toHaveBeenCalledOnce() + expect(spyWebsocket).toHaveBeenCalledWith('wss://socketeer.services.netlify.com/edge-function/logs') + expect(spyOn).toHaveBeenCalledTimes(4) + }) + + test('should send the correct payload to the websocket', async () => { + const { apiUrl } = await startMockApi({ routes }) + const spyWebsocket = getWebSocket as unknown as Mock + const spyOn = vi.fn() + const spySend = vi.fn() + spyWebsocket.mockReturnValue({ + on: spyOn, + send: spySend, + }) + + const env = getEnvironmentVariables({ apiUrl }) + Object.assign(process.env, env) + + await program.parseAsync(['', '', 'logs:edge-functions']) + + const setupCall = spyOn.mock.calls.find((args) => args[0] === 'open') + expect(setupCall).toBeDefined() + + const openCallback = setupCall?.[1] as (() => void) | undefined + openCallback?.() + + expect(spySend).toHaveBeenCalledOnce() + const call = spySend.mock.calls[0] as string[] + const body = JSON.parse(call[0]) as Record + + expect(body.deploy_id).toEqual('deploy-id-1') + expect(body.site_id).toEqual('site_id') + expect(body.access_token).toEqual(env.NETLIFY_AUTH_TOKEN) + expect(body.since).toBeDefined() + }) + + test('should use deploy ID from --deploy-id option when provided', async () => { + const { apiUrl } = await startMockApi({ routes }) + const spyWebsocket = getWebSocket as unknown as Mock + const spyOn = vi.fn() + const spySend = vi.fn() + spyWebsocket.mockReturnValue({ + on: spyOn, + send: spySend, + }) + + const env = getEnvironmentVariables({ apiUrl }) + Object.assign(process.env, env) + + await program.parseAsync(['', '', 'logs:edge-functions', '--deploy-id', 'my-deploy-id']) + + const setupCall = spyOn.mock.calls.find((args) => args[0] === 'open') + const openCallback = setupCall?.[1] as (() => void) | undefined + openCallback?.() + + const call = spySend.mock.calls[0] as string[] + const body = JSON.parse(call[0]) as Record + + expect(body.deploy_id).toEqual('my-deploy-id') + }) + + test('should print only specified log levels', async () => { + const { apiUrl } = await startMockApi({ routes }) + const spyWebsocket = getWebSocket as unknown as Mock + const spyOn = vi.fn() + const spySend = vi.fn() + spyWebsocket.mockReturnValue({ + on: spyOn, + send: spySend, + }) + const spyLog = log as unknown as Mock + + const env = getEnvironmentVariables({ apiUrl }) + Object.assign(process.env, env) + + await program.parseAsync(['', '', 'logs:edge-functions', '--level', 'info']) + const messageCallback = spyOn.mock.calls.find((args) => args[0] === 'message') + const messageCallbackFunc = messageCallback?.[1] as ((data: string) => void) | undefined + + messageCallbackFunc?.(JSON.stringify({ level: LOG_LEVELS.INFO, message: 'Hello World' })) + messageCallbackFunc?.(JSON.stringify({ level: LOG_LEVELS.WARN, message: 'There was a warning' })) + + expect(spyLog).toHaveBeenCalledTimes(1) + }) + + test('should fetch historical logs when --from is specified', async () => { + const { apiUrl } = await startMockApi({ routes }) + const spyWebsocket = getWebSocket as unknown as Mock + + const env = getEnvironmentVariables({ apiUrl }) + Object.assign(process.env, env) + + const mockLogs = [{ timestamp: '2026-01-15T10:00:00Z', level: 'info', message: 'Edge function executed' }] + + const originalFetch = global.fetch + const spyFetch = vi.fn().mockImplementation((url: string) => { + if (url.includes('analytics.services.netlify.com')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockLogs), + }) + } + return originalFetch(url) + }) + global.fetch = spyFetch + + try { + await program.parseAsync(['', '', 'logs:edge-functions', '--from', '2026-01-01T00:00:00Z']) + + expect(spyWebsocket).not.toHaveBeenCalled() + const analyticsCall = spyFetch.mock.calls.find((args: string[]) => + args[0].includes('analytics.services.netlify.com'), + ) + expect(analyticsCall).toBeDefined() + expect((analyticsCall as string[])[0]).toContain('edge_function_logs') + expect((analyticsCall as string[])[0]).toContain('site_id') + } finally { + global.fetch = originalFetch + } + }) +}) diff --git a/tests/integration/commands/logs/functions.test.ts b/tests/integration/commands/logs/functions.test.ts index f7c740a11c2..d663ec38820 100644 --- a/tests/integration/commands/logs/functions.test.ts +++ b/tests/integration/commands/logs/functions.test.ts @@ -201,4 +201,106 @@ describe('logs:function command', () => { expect(spyLog).toHaveBeenCalledTimes(2) }) + + test('should find function by ID', async ({}) => { + const { apiUrl } = await startMockApi({ routes }) + const spyWebsocket = getWebSocket as unknown as Mock + const spyOn = vi.fn() + const spySend = vi.fn() + spyWebsocket.mockReturnValue({ + on: spyOn, + send: spySend, + }) + + const env = getEnvironmentVariables({ apiUrl }) + Object.assign(process.env, env) + + await program.parseAsync(['', '', 'logs:function', 'function-id']) + + const setupCall = spyOn.mock.calls.find((args) => args[0] === 'open') + const openCallback = setupCall?.[1] + openCallback?.() + + const call = spySend.mock.calls[0] + const body = JSON.parse(call[0]) + + expect(body.function_id).toEqual('function-id') + expect(body.site_id).toEqual('site_id') + }) + + test('should look up function from deploy when --deploy is specified', async ({}) => { + const deployRoutes = [ + ...routes, + { + path: 'sites/site_id/deploys/deploy-123', + response: { + id: 'deploy-123', + available_functions: [{ n: 'deploy-function', oid: 'deploy-fn-id' }], + }, + }, + ] + const { apiUrl } = await startMockApi({ routes: deployRoutes }) + const spyWebsocket = getWebSocket as unknown as Mock + const spyOn = vi.fn() + const spySend = vi.fn() + spyWebsocket.mockReturnValue({ + on: spyOn, + send: spySend, + }) + + const env = getEnvironmentVariables({ apiUrl }) + Object.assign(process.env, env) + + await program.parseAsync(['', '', 'logs:function', 'deploy-function', '--deploy-id', 'deploy-123']) + + const setupCall = spyOn.mock.calls.find((args) => args[0] === 'open') + const openCallback = setupCall?.[1] + openCallback?.() + + const call = spySend.mock.calls[0] + const body = JSON.parse(call[0]) + + expect(body.function_id).toEqual('deploy-fn-id') + expect(body.site_id).toEqual('site_id') + }) + + test('should fetch historical logs when --from is specified', async ({}) => { + const { apiUrl } = await startMockApi({ routes }) + const spyWebsocket = getWebSocket as unknown as Mock + + const env = getEnvironmentVariables({ apiUrl }) + Object.assign(process.env, env) + + const mockLogs = [{ timestamp: '2026-01-15T10:00:00Z', level: 'info', message: 'Function executed' }] + + const originalFetch = global.fetch + const spyFetch = vi.fn().mockImplementation((url: string) => { + const parsedUrl = new URL(url) + if (parsedUrl.hostname === 'analytics.services.netlify.com') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockLogs), + }) + } + return originalFetch(url) + }) + global.fetch = spyFetch + + try { + await program.parseAsync(['', '', 'logs:function', 'cool-function', '--from', '2026-01-01T00:00:00Z']) + + expect(spyWebsocket).not.toHaveBeenCalled() + + const analyticsCall = spyFetch.mock.calls.find((args: string[]) => { + const parsedUrl = new URL(args[0]) + return parsedUrl.hostname === 'analytics.services.netlify.com' + }) + expect(analyticsCall).toBeDefined() + expect((analyticsCall as string[])[0]).toContain('function_logs') + expect((analyticsCall as string[])[0]).toContain('cool-function') + expect((analyticsCall as string[])[0]).toContain('site_id') + } finally { + global.fetch = originalFetch + } + }) })