diff --git a/src/commands/logs/log-api.ts b/src/commands/logs/log-api.ts index 96e0ee582d9..c6ee99d429f 100644 --- a/src/commands/logs/log-api.ts +++ b/src/commands/logs/log-api.ts @@ -141,9 +141,9 @@ export const fetchHistoricalLogs = async ({ throw new Error(`Failed to fetch logs: ${response.status.toString()} ${response.statusText}`) } - const body = (await response.json()) as { logs?: HistoricalLogEntry[]; pagination?: { next?: string } } + const body = (await response.json()) as { logs?: HistoricalLogEntry[]; next?: string } entries.push(...(body.logs ?? [])) - cursor = body.pagination?.next + cursor = body.next } while (cursor) return entries diff --git a/src/commands/logs/logs.ts b/src/commands/logs/logs.ts index 7efbcd2ebea..97d4a6bcbfc 100644 --- a/src/commands/logs/logs.ts +++ b/src/commands/logs/logs.ts @@ -344,7 +344,9 @@ const runHistoricalMode = async ({ to, deployId, }) - allEntries.push(...entries) + for (const entry of entries) { + allEntries.push(entry) + } } } @@ -357,7 +359,9 @@ const runHistoricalMode = async ({ deployId, filterNames: edgeFunctionNames, }) - allEntries.push(...entries) + for (const entry of entries) { + allEntries.push(entry) + } } let serverLogsTruncated = false diff --git a/tests/unit/commands/logs/log-api.test.ts b/tests/unit/commands/logs/log-api.test.ts new file mode 100644 index 00000000000..afbfef8cf54 --- /dev/null +++ b/tests/unit/commands/logs/log-api.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { fetchHistoricalLogs } from '../../../../src/commands/logs/log-api.js' + +const fetchMock = vi.fn() +const from = 1_000_000 +const to = 2_000_000 +const deployId = 'test-deploy' +const accessToken = 'test-token' +const line = { ts: from, type: 'line', level: 'INFO', message: 'first page' } +const marker = { ...line, ts: from + 100, message: 'expected marker' } + +beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe.each(['function_logs/test-function', 'edge_function_logs'])('historical %s', (path) => { + const baseUrl = `https://analytics.services.netlify.com/v2/sites/test-site/${path}?search=test` + const options = { baseUrl, accessToken, from, to, deployId } + + it('follows the top-level next cursor and includes events beyond the first 100 lines', async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => ({ ...line, ts: from + index })) + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ logs: firstPage, next: 'page-2' }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ logs: [marker], next: '' }))) + + const entries = await fetchHistoricalLogs(options) + + expect(entries).toEqual([...firstPage, marker]) + expect(fetchMock).toHaveBeenCalledTimes(2) + const urls = fetchMock.mock.calls.map(([input]) => new URL(input as string)) + expect(urls.map((url) => url.searchParams.get('cursor'))).toEqual([null, 'page-2']) + for (const url of urls) { + expect(url.searchParams.get('from')).toBe(String(from)) + expect(url.searchParams.get('to')).toBe(String(to)) + expect(url.searchParams.get('deploy_id')).toBe(deployId) + expect(url.searchParams.get('search')).toBe('test') + } + for (const [, init] of fetchMock.mock.calls) { + expect(init?.headers).toMatchObject({ Authorization: `Bearer ${accessToken}` }) + } + }) + + it.each([{ logs: [] }, { logs: [line] }])('returns a final page without a cursor: $logs', async ({ logs }) => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ logs }))) + + expect(await fetchHistoricalLogs(options)).toEqual(logs) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('reports a later page failure instead of returning incomplete logs', async () => { + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ logs: [line], next: 'page-2' }))) + .mockResolvedValueOnce(new Response('', { status: 502, statusText: 'Bad Gateway' })) + + await expect(fetchHistoricalLogs(options)).rejects.toThrow('Failed to fetch logs: 502 Bad Gateway') + }) +}) diff --git a/tests/unit/commands/logs/logs.test.ts b/tests/unit/commands/logs/logs.test.ts index cfb6d5d7114..466aee1e181 100644 --- a/tests/unit/commands/logs/logs.test.ts +++ b/tests/unit/commands/logs/logs.test.ts @@ -6,6 +6,17 @@ const findLatestReadyDeployMock = vi.fn<(...args: unknown[]) => Promise void>() const findCurrentBuildingDeployMock = vi.fn<(...args: unknown[]) => Promise>() const isDeployFinishedMock = vi.fn<(...args: unknown[]) => Promise>() +const fetchFunctionHistoricalLogsMock = vi.fn<(...args: unknown[]) => Promise>() +const fetchEdgeFunctionHistoricalLogsMock = vi.fn<(...args: unknown[]) => Promise>() + +vi.mock('../../../../src/commands/logs/sources/functions.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + listFunctions: () => Promise.resolve([{ n: 'test-function' }]), + fetchFunctionHistoricalLogs: (...args: unknown[]) => fetchFunctionHistoricalLogsMock(...args), + } +}) vi.mock('../../../../src/commands/logs/sources/deploy.js', () => ({ fetchDeployHistoricalLogs: (...args: unknown[]) => fetchDeployHistoricalLogsMock(...args), @@ -19,7 +30,7 @@ vi.mock('../../../../src/commands/logs/sources/deploy.js', () => ({ })) vi.mock('../../../../src/commands/logs/sources/edge-functions.js', () => ({ - fetchEdgeFunctionHistoricalLogs: () => Promise.resolve([]), + fetchEdgeFunctionHistoricalLogs: (...args: unknown[]) => fetchEdgeFunctionHistoricalLogsMock(...args), streamEdgeFunctions: vi.fn(), })) @@ -71,10 +82,42 @@ beforeEach(() => { streamDeployMock.mockClear() findCurrentBuildingDeployMock.mockReset().mockResolvedValue('building-deploy') isDeployFinishedMock.mockReset().mockResolvedValue(false) + fetchFunctionHistoricalLogsMock.mockReset().mockResolvedValue([]) + fetchEdgeFunctionHistoricalLogsMock.mockReset().mockResolvedValue([]) fetchServerHistoricalLogsMock.mockReset().mockResolvedValue({ entries: [], truncated: false }) streamServerMock.mockClear() }) +describe('logsCommand large histories', () => { + it.each(['functions', 'edge-functions'])('prints a large %s history without changing entry order', async (source) => { + const visibleIndices = [0, 500_000, 999_999] + const hiddenEntry = { + source: source === 'functions' ? 'function' : 'edge-function', + name: 'test-function', + ts: 1, + level: 'DEBUG', + message: 'hidden', + } + const entries = Array(1_000_000).fill(hiddenEntry) + for (const index of visibleIndices) { + entries[index] = { ...hiddenEntry, level: 'INFO', message: `entry-${index.toString()}` } + } + const fetchLogs = source === 'functions' ? fetchFunctionHistoricalLogsMock : fetchEdgeFunctionHistoricalLogsMock + fetchLogs.mockResolvedValueOnce(entries) + const output = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + try { + await logsCommand({ source: [source], since: '1h', level: ['info'], json: true }, makeCommand()) + + expect(output.mock.calls.map(([line]) => (JSON.parse(line.toString()) as { message: string }).message)).toEqual( + visibleIndices.map((index) => `entry-${index.toString()}`), + ) + } finally { + output.mockRestore() + } + }) +}) + describe('logsCommand deploy auto-selection', () => { it('auto-selects the latest finished deploy for --source deploy', async () => { await logsCommand({ source: ['deploy'], since: '1h' }, makeCommand())