diff --git a/.changeset/trac-934-logs-query-pagination.md b/.changeset/trac-934-logs-query-pagination.md new file mode 100644 index 000000000..9c0b45a7d --- /dev/null +++ b/.changeset/trac-934-logs-query-pagination.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": minor +--- + +Add pagination to `catalyst logs query`. Use `--limit ` (1–500) to cap the page size, `--after ` to page toward older entries, and `--before ` to page toward newer ones. When more entries are available, the CLI prints a ready-to-run command for the next page with the time window pinned to absolute timestamps. diff --git a/packages/catalyst/src/cli/commands/logs.spec.ts b/packages/catalyst/src/cli/commands/logs.spec.ts index b5c70914b..2af99c297 100644 --- a/packages/catalyst/src/cli/commands/logs.spec.ts +++ b/packages/catalyst/src/cli/commands/logs.spec.ts @@ -1,7 +1,17 @@ import { Command } from 'commander'; import Conf from 'conf'; import { http, HttpResponse } from 'msw'; -import { afterAll, afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + MockInstance, + test, + vi, +} from 'vitest'; import { server } from '../../../tests/mocks/node'; import { consola } from '../lib/logger'; @@ -91,6 +101,7 @@ beforeAll(async () => { afterEach(() => { vi.clearAllMocks(); + vi.unstubAllEnvs(); config.delete('storeHash'); config.delete('accessToken'); config.delete('projectUuid'); @@ -137,6 +148,9 @@ describe('command configuration', () => { expect.objectContaining({ flags: '--access-token ' }), expect.objectContaining({ flags: '--api-host ' }), expect.objectContaining({ flags: '--project-uuid ' }), + expect.objectContaining({ flags: '--limit ' }), + expect.objectContaining({ flags: '--after ' }), + expect.objectContaining({ flags: '--before ' }), expect.objectContaining({ flags: '--format ', defaultValue: 'default' }), ]), ); @@ -589,6 +603,13 @@ describe('query subcommand', () => { const start = '2026-06-01T00:00:00Z'; const end = '2026-06-02T00:00:00Z'; + beforeEach(() => { + // The vitest process itself runs under pnpm, which would leak a + // `pnpm catalyst` prefix into the pagination hints. Tests assert the bare + // binary unless they stub a specific package-manager user agent. + vi.stubEnv('npm_config_user_agent', ''); + }); + const queryArgs = (extra: string[] = []) => [ 'node', 'catalyst', @@ -613,39 +634,9 @@ describe('query subcommand', () => { expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('/cart')); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('[TypeError]')); expect(consola.info).toHaveBeenCalledWith('1 entry shown (oldest first, times in UTC).'); - // TODO(TRAC-934): no next-page hint is printed while pagination is removed. - expect(consola.info).not.toHaveBeenCalledWith(expect.stringContaining('More available')); - }); - - test('warns when the result fills --limit exactly', async () => { - server.use( - http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', () => - HttpResponse.json({ - data: [ - { id: '2', timestamp: '2026-06-01T13:00:00Z', level: 'info', messages: ['newer'] }, - { id: '1', timestamp: '2026-06-01T12:00:00Z', level: 'info', messages: ['older'] }, - ], - }), - ), - ); - - await program.parseAsync(queryArgs(['--limit', '2'])); - - expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('Limit of 2 reached')); - }); - - test('does not warn when the result is below --limit', async () => { - server.use( - http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', () => - HttpResponse.json({ - data: [{ id: '1', timestamp: '2026-06-01T12:00:00Z', level: 'info', messages: ['only'] }], - }), - ), + expect(consola.info).not.toHaveBeenCalledWith( + expect.stringContaining('More results available'), ); - - await program.parseAsync(queryArgs(['--limit', '5'])); - - expect(consola.info).not.toHaveBeenCalledWith(expect.stringContaining('reached')); }); test('prints entries oldest-first', async () => { @@ -657,7 +648,12 @@ describe('query subcommand', () => { { id: '1', timestamp: '2026-06-01T12:00:00Z', level: 'info', messages: ['older'] }, ], meta: { - cursor_pagination: { count: 2, per_page: 50, start_cursor: null, end_cursor: null }, + cursor_pagination: { + has_next_page: false, + has_prev_page: false, + start_cursor: '2', + end_cursor: '1', + }, }, }), ), @@ -685,7 +681,12 @@ describe('query subcommand', () => { return HttpResponse.json({ data: [], meta: { - cursor_pagination: { count: 0, per_page: 50, start_cursor: null, end_cursor: null }, + cursor_pagination: { + has_next_page: false, + has_prev_page: false, + start_cursor: null, + end_cursor: null, + }, }, }); }, @@ -738,11 +739,38 @@ describe('query subcommand', () => { test('outputs one raw JSON entry per line with --format json', async () => { await program.parseAsync(queryArgs(['--format', 'json'])); - // NDJSON to stdout (like `tail --format json`), no footer chrome. + // NDJSON to stdout, no footer chrome; pagination meta arrives as a final + // JSON line so scripts can page without re-running interactively. expect(stdoutWriteMock).toHaveBeenCalledWith(expect.stringContaining('"is_exception":true')); + expect(stdoutWriteMock).toHaveBeenLastCalledWith( + `${JSON.stringify({ + meta: { + cursor_pagination: { + has_next_page: false, + has_prev_page: false, + start_cursor: 'cursor_start', + end_cursor: 'cursor_end', + }, + }, + })}\n`, + ); expect(consola.info).not.toHaveBeenCalledWith(expect.stringContaining('entry shown')); }); + test('omits the trailing meta line with --format json when the backend omits meta', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', () => + HttpResponse.json({ + data: [{ id: '9', timestamp: '2026-06-01T12:00:00Z', level: 'info', messages: ['hi'] }], + }), + ), + ); + + await program.parseAsync(queryArgs(['--format', 'json'])); + + expect(stdoutWriteMock).not.toHaveBeenCalledWith(expect.stringContaining('cursor_pagination')); + }); + test('includes request details with --format request', async () => { await program.parseAsync(queryArgs(['--format', 'request'])); @@ -755,7 +783,12 @@ describe('query subcommand', () => { HttpResponse.json({ data: [], meta: { - cursor_pagination: { count: 0, per_page: 50, start_cursor: null, end_cursor: null }, + cursor_pagination: { + has_next_page: false, + has_prev_page: false, + start_cursor: null, + end_cursor: null, + }, }, }), ), @@ -780,7 +813,12 @@ describe('query subcommand', () => { return HttpResponse.json({ data: [], meta: { - cursor_pagination: { count: 0, per_page: 50, start_cursor: null, end_cursor: null }, + cursor_pagination: { + has_next_page: false, + has_prev_page: false, + start_cursor: null, + end_cursor: null, + }, }, }); }, @@ -797,8 +835,6 @@ describe('query subcommand', () => { '/cart', '--level-min', 'warn', - '--limit', - '10', ]), ); @@ -806,10 +842,203 @@ describe('query subcommand', () => { expect(captured?.get('status_code')).toBe('500'); expect(captured?.get('url:like')).toBe('/cart'); expect(captured?.get('level:min')).toBe('warn'); - expect(captured?.get('limit')).toBe('10'); + expect(captured?.get('limit')).toBeNull(); + expect(captured?.get('after')).toBeNull(); + expect(captured?.get('before')).toBeNull(); + }); + + test('forwards --limit and --after as query params', async () => { + let captured: URLSearchParams | undefined; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', + ({ request }) => { + captured = new URL(request.url).searchParams; + + return HttpResponse.json({ data: [] }); + }, + ), + ); + + await program.parseAsync(queryArgs(['--limit', '25', '--after', 'cursor_end'])); + + expect(captured?.get('limit')).toBe('25'); + expect(captured?.get('after')).toBe('cursor_end'); + }); + + test('forwards --before as a query param', async () => { + let captured: URLSearchParams | undefined; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', + ({ request }) => { + captured = new URL(request.url).searchParams; + + return HttpResponse.json({ data: [] }); + }, + ), + ); + + await program.parseAsync(queryArgs(['--before', 'cursor_start'])); + + expect(captured?.get('before')).toBe('cursor_start'); expect(captured?.get('after')).toBeNull(); }); + test('prints a runnable next-page hint when more entries are available', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', () => + HttpResponse.json({ + data: [{ id: '9', timestamp: '2026-06-01T12:00:00Z', level: 'info', messages: ['hi'] }], + meta: { + cursor_pagination: { + start_cursor: '9', + end_cursor: '9', + links: { + next: '?start=2026-06-01T00%3A00%3A00Z&end=2026-06-01T01%3A00%3A00Z&after=9', + }, + }, + }, + }), + ), + ); + + await program.parseAsync(queryArgs(['--level-min', 'info', '--limit', '1'])); + + const hint = vi + .mocked(consola.info) + .mock.calls.map(([line]) => String(line)) + .find((line) => line.includes('More results available')); + + expect(hint).toBeDefined(); + expect(hint).toMatch(/^More results available\. Next page:\n {2}catalyst logs query /); + // The window is pinned to the resolved absolute timestamps. + expect(hint).toContain(`--start ${start}`); + expect(hint).toContain(`--end ${end}`); + expect(hint).toContain('--level-min info'); + expect(hint).toContain('--limit 1'); + expect(hint).toContain('--after 9'); + }); + + test('prints a runnable previous-page hint when newer entries are available', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', () => + HttpResponse.json({ + data: [{ id: '5', timestamp: '2026-06-01T11:00:00Z', level: 'info', messages: ['hi'] }], + meta: { + cursor_pagination: { + start_cursor: '5', + end_cursor: '5', + links: { + previous: '?start=2026-06-01T00%3A00%3A00Z&end=2026-06-02T00%3A00%3A00Z&before=5', + }, + }, + }, + }), + ), + ); + + await program.parseAsync(queryArgs(['--limit', '1', '--after', '9'])); + + const hint = vi + .mocked(consola.info) + .mock.calls.map(([line]) => String(line)) + .find((line) => line.includes('Newer results available')); + + expect(hint).toBeDefined(); + expect(hint).toMatch(/^Newer results available\. Previous page:\n {2}catalyst logs query /); + expect(hint).toContain(`--start ${start}`); + expect(hint).toContain(`--end ${end}`); + expect(hint).toContain('--limit 1'); + // The hint swaps the consumed --after cursor for the new page's --before. + expect(hint).toContain('--before 5'); + expect(hint).not.toContain('--after'); + }); + + test('prefixes pagination hints with the invoking package manager', async () => { + vi.stubEnv('npm_config_user_agent', 'pnpm/10.0.0 npm/? node/v24.14.0 darwin arm64'); + + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', () => + HttpResponse.json({ + data: [{ id: '9', timestamp: '2026-06-01T12:00:00Z', level: 'info', messages: ['hi'] }], + meta: { + cursor_pagination: { + start_cursor: '9', + end_cursor: '9', + links: { next: '?after=9' }, + }, + }, + }), + ), + ); + + await program.parseAsync(queryArgs(['--limit', '1'])); + + expect(consola.info).toHaveBeenCalledWith( + expect.stringContaining('\n pnpm catalyst logs query '), + ); + }); + + test('uses npx in pagination hints when invoked via npm', async () => { + vi.stubEnv('npm_config_user_agent', 'npm/10.9.2 node/v24.14.0 darwin arm64 workspaces/false'); + + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', () => + HttpResponse.json({ + data: [{ id: '9', timestamp: '2026-06-01T12:00:00Z', level: 'info', messages: ['hi'] }], + meta: { + cursor_pagination: { + start_cursor: '9', + end_cursor: '9', + links: { next: '?after=9' }, + }, + }, + }), + ), + ); + + await program.parseAsync(queryArgs(['--limit', '1'])); + + expect(consola.info).toHaveBeenCalledWith( + expect.stringContaining('\n npx catalyst logs query '), + ); + }); + + test('prints no hint when the backend omits pagination meta', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', () => + HttpResponse.json({ + data: [{ id: '9', timestamp: '2026-06-01T12:00:00Z', level: 'info', messages: ['hi'] }], + }), + ), + ); + + await program.parseAsync(queryArgs()); + + expect(consola.info).not.toHaveBeenCalledWith( + expect.stringContaining('More results available'), + ); + }); + + test('rejects --after combined with --before', async () => { + await program.parseAsync(queryArgs(['--after', 'a', '--before', 'b'])).catch(() => { + // commander throws in test mode; the assertion below covers the outcome + }); + + expect(exitMock).toHaveBeenCalledWith(1); + }); + + test('rejects an out-of-range --limit', async () => { + await program.parseAsync(queryArgs(['--limit', '501'])).catch(() => { + // commander throws in test mode; the assertion below covers the outcome + }); + + expect(exitMock).toHaveBeenCalledWith(1); + }); + test('exits with error when no project UUID can be resolved', async () => { await program.parseAsync([ 'node', diff --git a/packages/catalyst/src/cli/commands/logs.ts b/packages/catalyst/src/cli/commands/logs.ts index 1bf0a4a60..64044e928 100644 --- a/packages/catalyst/src/cli/commands/logs.ts +++ b/packages/catalyst/src/cli/commands/logs.ts @@ -5,7 +5,13 @@ import { z } from 'zod'; import { UnauthorizedError } from '../lib/auth-errors'; import { httpError } from '../lib/http-errors'; import { consola } from '../lib/logger'; -import { formatLogEntry, LOG_LEVELS, queryLogs, resolveTimeWindow } from '../lib/observability'; +import { + formatLogEntry, + LOG_LEVELS, + queryLogs, + QueryLogsResult, + resolveTimeWindow, +} from '../lib/observability'; import { getProjectConfig } from '../lib/project-config'; import { resolveCredentials } from '../lib/resolve-credentials'; import { @@ -16,12 +22,10 @@ import { resolveProjectUuid, storeHashOption, } from '../lib/shared-options'; -import { Telemetry } from '../lib/telemetry'; +import { getTelemetry } from '../lib/telemetry'; type LogFormat = 'json' | 'pretty' | 'default' | 'short' | 'request'; -const telemetry = new Telemetry(); - const DEFAULT_CONNECTION_TTL_MS = 1 * 60 * 1000; // 1 minute const MAX_RETRIES = 5; @@ -339,7 +343,7 @@ Examples: const apiHost = resolveApiHost(options, config); const { storeHash, accessToken } = resolveCredentials(options, config); - await telemetry.identify(storeHash); + await getTelemetry().identify(storeHash); const projectUuid = resolveProjectUuid(options); @@ -362,6 +366,79 @@ const parseIntInRange = (flag: string, min: number, max: number) => (value: stri return parsed; }; +// A bare `catalyst` is only on PATH for global installs. When the CLI runs +// through a package manager (`pnpm catalyst`, `npx catalyst`, ...) the printed +// hint must include that wrapper or copy-pasting it fails with +// "command not found". +function invocationPrefix(): string { + const packageManager = process.env.npm_config_user_agent?.split('/')[0]; + + switch (packageManager) { + case 'pnpm': + case 'yarn': + return `${packageManager} catalyst`; + + case 'bun': + return 'bunx catalyst'; + + case 'npm': + return 'npx catalyst'; + + default: + return 'catalyst'; + } +} + +interface QueryHintOptions { + method?: string; + statusCode?: number; + urlLike?: string; + levelMin?: string; + limit?: number; + format: string; +} + +function printPaginationHints( + pagination: NonNullable['cursor_pagination'], + start: string, + end: string, + options: QueryHintOptions, +): void { + // The REST API signals availability with `links` URLs. Retain the gRPC + // booleans as a compatibility fallback for direct API consumers. + const hasNextPage = Boolean(pagination?.links?.next) || pagination?.has_next_page === true; + const hasPrevPage = Boolean(pagination?.links?.previous) || pagination?.has_prev_page === true; + const endCursor = pagination?.end_cursor; + const startCursor = pagination?.start_cursor; + + if (!(hasNextPage && endCursor) && !(hasPrevPage && startCursor)) return; + + const quoteArg = (value: string) => (/\s/.test(value) ? `'${value}'` : value); + // Cursors are only valid with the same window and filters, so pin the + // resolved absolute timestamps (a --since window drifts with "now"). + const baseFlags = [ + `--start ${quoteArg(start)}`, + `--end ${quoteArg(end)}`, + ...(options.method ? [`--method ${quoteArg(options.method)}`] : []), + ...(options.statusCode != null ? [`--status-code ${options.statusCode}`] : []), + ...(options.urlLike ? [`--url-like ${quoteArg(options.urlLike)}`] : []), + ...(options.levelMin ? [`--level-min ${options.levelMin}`] : []), + ...(options.limit != null ? [`--limit ${options.limit}`] : []), + ...(options.format !== 'default' ? [`--format ${options.format}`] : []), + ]; + const command = `${invocationPrefix()} logs query ${baseFlags.join(' ')}`; + + if (hasNextPage && endCursor) { + consola.info(`More results available. Next page:\n ${command} --after ${quoteArg(endCursor)}`); + } + + if (hasPrevPage && startCursor) { + consola.info( + `Newer results available. Previous page:\n ${command} --before ${quoteArg(startCursor)}`, + ); + } +} + const query = new Command('query') .configureHelp({ showGlobalOptions: true }) .description('Query historical logs from your deployed application.') @@ -370,12 +447,20 @@ const query = new Command('query') ` Specify a time window with \`--since\` (relative to now) or \`--start\`/\`--end\` (ISO-8601 timestamps or Unix epoch seconds, UTC). The window may not exceed -7 days. Entries print oldest-first; timestamps are UTC. +7 days. Entries print oldest-first; timestamps are UTC. Page toward older +entries by passing a previous page's end cursor to \`--after\` (the CLI prints +a ready-to-run command when more entries are available), or toward newer +entries with \`--before\`. Cursors are only valid with the same window and +filters they came from. With \`--format json\`, the pagination cursors are +emitted as a final \`{"meta": ...}\` line after the entries. Examples: # Last hour of logs $ catalyst logs query --since 1h + # Newest 20 errors in the window (follow the printed --after hint for more) + $ catalyst logs query --since 24h --level-min error --limit 20 + # Everything from today (UTC) $ catalyst logs query --start 2026-06-11T00:00:00Z @@ -414,12 +499,23 @@ Examples: new Option('--level-min ', 'Minimum log level.').choices(Array.from(LOG_LEVELS)), ) .addOption( - new Option('--limit ', 'Maximum number of entries to return (1-500).') - .argParser(parseIntInRange('--limit', 1, 500)) - .default(100), + new Option( + '--limit ', + 'Maximum entries per page (1-500). Defaults to the API default (100).', + ).argParser(parseIntInRange('--limit', 1, 500)), + ) + .addOption( + new Option( + '--after ', + "Return the page after (older than) this cursor, from a previous page's end_cursor.", + ).conflicts('before'), + ) + .addOption( + new Option( + '--before ', + "Return the page before (newer than) this cursor, from a previous page's start_cursor.", + ), ) - // TODO(TRAC-934): --after (cursor) and --all (follow pagination) were - // removed until Cloudflare fixes invocations-view pagination .addOption( new Option('--format ', 'Output format for log entries.') .choices(['json', 'pretty', 'default', 'short', 'request']) @@ -431,7 +527,7 @@ Examples: const apiHost = resolveApiHost(options, config); const { storeHash, accessToken } = resolveCredentials(options, config); - await telemetry.identify(storeHash); + await getTelemetry().identify(storeHash); const projectUuid = resolveProjectUuid(options); const { start, end } = resolveTimeWindow(options); @@ -444,6 +540,8 @@ Examples: urlLike: options.urlLike, levelMin: options.levelMin, limit: options.limit, + after: options.after, + before: options.before, }); const entries = result.data; @@ -452,6 +550,16 @@ Examples: if (options.format === 'json') { ordered.forEach((entry) => process.stdout.write(`${JSON.stringify(entry)}\n`)); + // Entries carry no cursors, so scripted pagination needs the meta + // echoed back; a trailing line keeps the stream line-delimited. + const cursorPagination = result.meta?.cursor_pagination; + + if (cursorPagination) { + process.stdout.write( + `${JSON.stringify({ meta: { cursor_pagination: cursorPagination } })}\n`, + ); + } + return; } @@ -477,12 +585,7 @@ Examples: `${entries.length} ${entries.length === 1 ? 'entry' : 'entries'} shown (oldest first, times in UTC).`, ); - if (entries.length === options.limit) { - consola.info( - `Limit of ${options.limit} reached; older entries may exist. ` + - 'Narrow the time window or raise --limit (max 500) to see more.', - ); - } + printPaginationHints(result.meta?.cursor_pagination, start, end, options); } catch (error) { consola.error(error instanceof Error ? error.message : error); process.exit(1); diff --git a/packages/catalyst/src/cli/lib/observability.spec.ts b/packages/catalyst/src/cli/lib/observability.spec.ts index 1acc5b5ad..e4643b691 100644 --- a/packages/catalyst/src/cli/lib/observability.spec.ts +++ b/packages/catalyst/src/cli/lib/observability.spec.ts @@ -314,10 +314,10 @@ describe('queryLogs', () => { meta: { cursor_pagination: { count: 1, - per_page: 50, + per_page: 1, start_cursor: 'cursor_start', end_cursor: 'cursor_end', - links: {}, + links: { next: '?after=cursor_end' }, }, }, }), @@ -331,8 +331,46 @@ describe('queryLogs', () => { expect(result.data).toHaveLength(1); expect(result.data[0].level).toBe('error'); - // TODO(TRAC-934): meta.cursor_pagination is no longer parsed; the - // fixture keeps it to confirm the schema tolerates (ignores) extra keys. + expect(result.meta?.cursor_pagination?.links?.next).toBe('?after=cursor_end'); + expect(result.meta?.cursor_pagination?.end_cursor).toBe('cursor_end'); + }); + + test('tolerates a response without meta (pre-pagination backend)', async () => { + server.use(http.get(logsUrl, () => HttpResponse.json({ data: [] }))); + + const result = await queryLogs(projectUuid, storeHash, accessToken, apiHost, { + start: '2026-06-01T00:00:00Z', + end: '2026-06-02T00:00:00Z', + }); + + expect(result.data).toHaveLength(0); + expect(result.meta?.cursor_pagination?.end_cursor).toBeUndefined(); + }); + + test('tolerates null cursors on an empty page', async () => { + server.use( + http.get(logsUrl, () => + HttpResponse.json({ + data: [], + meta: { + cursor_pagination: { + has_next_page: false, + has_prev_page: true, + start_cursor: null, + end_cursor: null, + }, + }, + }), + ), + ); + + const result = await queryLogs(projectUuid, storeHash, accessToken, apiHost, { + start: '2026-06-01T00:00:00Z', + end: '2026-06-02T00:00:00Z', + }); + + expect(result.meta?.cursor_pagination?.has_next_page).toBe(false); + expect(result.meta?.cursor_pagination?.end_cursor).toBeNull(); }); test('accepts levels outside the known set and non-string messages', async () => { @@ -385,7 +423,6 @@ describe('queryLogs', () => { statusCode: 500, urlLike: '/cart', levelMin: 'warn', - limit: 25, }); expect(captured?.get('start')).toBe('2026-06-01T00:00:00Z'); @@ -394,7 +431,52 @@ describe('queryLogs', () => { expect(captured?.get('status_code')).toBe('500'); expect(captured?.get('url:like')).toBe('/cart'); expect(captured?.get('level:min')).toBe('warn'); + expect(captured?.get('limit')).toBeNull(); + expect(captured?.get('after')).toBeNull(); + expect(captured?.get('before')).toBeNull(); + }); + + test('forwards limit and cursor params', async () => { + let captured: URLSearchParams | undefined; + + server.use( + http.get(logsUrl, ({ request }) => { + captured = new URL(request.url).searchParams; + + return HttpResponse.json({ data: [] }); + }), + ); + + await queryLogs(projectUuid, storeHash, accessToken, apiHost, { + start: '2026-06-01T00:00:00Z', + end: '2026-06-02T00:00:00Z', + limit: 25, + after: 'cursor_end', + }); + expect(captured?.get('limit')).toBe('25'); + expect(captured?.get('after')).toBe('cursor_end'); + expect(captured?.get('before')).toBeNull(); + }); + + test('forwards a before cursor', async () => { + let captured: URLSearchParams | undefined; + + server.use( + http.get(logsUrl, ({ request }) => { + captured = new URL(request.url).searchParams; + + return HttpResponse.json({ data: [] }); + }), + ); + + await queryLogs(projectUuid, storeHash, accessToken, apiHost, { + start: '2026-06-01T00:00:00Z', + end: '2026-06-02T00:00:00Z', + before: 'cursor_start', + }); + + expect(captured?.get('before')).toBe('cursor_start'); expect(captured?.get('after')).toBeNull(); }); diff --git a/packages/catalyst/src/cli/lib/observability.ts b/packages/catalyst/src/cli/lib/observability.ts index 9d0cd9ff0..d043344bb 100644 --- a/packages/catalyst/src/cli/lib/observability.ts +++ b/packages/catalyst/src/cli/lib/observability.ts @@ -27,9 +27,29 @@ const logEntrySchema = z.object({ .optional(), }); -// TODO(TRAC-934): any `meta.cursor_pagination` the backend still returns is ignored (zod strips unknown keys) const queryLogsSchema = z.object({ data: z.array(logEntrySchema), + meta: z + .object({ + cursor_pagination: z + .object({ + // The REST proxy exposes page availability via links, while the + // gRPC response uses has_next_page/has_prev_page. Accept both while + // the API contract is transitioning. + has_next_page: z.boolean().optional(), + has_prev_page: z.boolean().optional(), + start_cursor: z.string().nullable().optional(), + end_cursor: z.string().nullable().optional(), + links: z + .object({ + next: z.string().optional(), + previous: z.string().optional(), + }) + .optional(), + }) + .optional(), + }) + .optional(), }); export type LogEntry = z.infer; @@ -43,6 +63,8 @@ export interface QueryLogsParams { urlLike?: string; levelMin?: LogLevel; limit?: number; + after?: string; + before?: string; } const v3ErrorSchema = z.object({ @@ -234,6 +256,8 @@ export async function queryLogs( if (params.urlLike) search.set('url:like', params.urlLike); // colon param if (params.levelMin) search.set('level:min', params.levelMin); // colon param if (params.limit != null) search.set('limit', String(params.limit)); + if (params.after) search.set('after', params.after); + if (params.before) search.set('before', params.before); const response = await fetch( `https://${apiHost}/stores/${storeHash}/v3/infrastructure/logs/${projectUuid}?${search.toString()}`, @@ -256,7 +280,12 @@ export async function queryLogs( } if (response.status === 404) { - throw new UserActionableError('Project not found. Check the project UUID.'); + // The gateway also 404s when the token belongs to a different store than + // the one in the URL, so a bad credential pairing looks identical to a + // missing project. + throw new UserActionableError( + 'Project not found. Check the project UUID, and that --store-hash and --access-token belong to the store that owns the project.', + ); } // 400 (bad UUID) and 422 (invalid window/filter) both carry the field-keyed diff --git a/packages/catalyst/src/cli/lib/telemetry.ts b/packages/catalyst/src/cli/lib/telemetry.ts index 6c74e2a96..ee7a517d3 100644 --- a/packages/catalyst/src/cli/lib/telemetry.ts +++ b/packages/catalyst/src/cli/lib/telemetry.ts @@ -9,6 +9,12 @@ import { getUserConfig, UserConfigSchema } from './user-config'; const TELEMETRY_KEY_ENABLED = 'telemetry.enabled'; const TELEMETRY_KEY_ID = `telemetry.anonymousId`; +// CLI telemetry is best-effort: command completion must never be delayed by a +// slow or unreachable analytics endpoint. `closeAndFlush()` uses the flush +// interval to derive its timeout, so keep both bounds intentionally short. +const TELEMETRY_FLUSH_INTERVAL_MS = 200; +const TELEMETRY_HTTP_REQUEST_TIMEOUT_MS = 200; + export class Telemetry { readonly correlationId: string; readonly analytics: Analytics; @@ -30,6 +36,9 @@ export class Telemetry { this.startTime = Date.now(); this.analytics = new Analytics({ writeKey: process.env.CLI_SEGMENT_WRITE_KEY ?? 'not-a-valid-segment-write-key', + flushInterval: TELEMETRY_FLUSH_INTERVAL_MS, + httpRequestTimeout: TELEMETRY_HTTP_REQUEST_TIMEOUT_MS, + maxRetries: 0, }); } diff --git a/packages/catalyst/tests/mocks/handlers.ts b/packages/catalyst/tests/mocks/handlers.ts index 1c335ac3b..009151119 100644 --- a/packages/catalyst/tests/mocks/handlers.ts +++ b/packages/catalyst/tests/mocks/handlers.ts @@ -218,18 +218,16 @@ export const handlers = [ level: 'error', messages: ['Unhandled exception while rendering /cart'], is_exception: true, - request_id: '8f1c2d3e4b5a6978', exception_name: 'TypeError', request: { method: 'GET', url: '/cart', status_code: 500 }, }, ], meta: { cursor_pagination: { - count: 1, - per_page: 50, + has_next_page: false, + has_prev_page: false, start_cursor: 'cursor_start', end_cursor: 'cursor_end', - links: {}, }, }, }),