diff --git a/.talismanrc b/.talismanrc index e0fac544..9fa05033 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,8 +1,10 @@ fileignoreconfig: +- filename: src/logs/logs.inputs.test.ts + checksum: 7b4df8790179c7dc4385acd1d380bb91397523bab111593742e2fc53c94da375 - filename: test/integration/logs-get.test.ts - checksum: 7822d714f476fa4ee588f1b602a7c9e69b8843cc60c09b4ae14cbe4eb2b4ad3b + checksum: 909965f29b2e6a0b6220b22c175b4a39edc05759fba1274a7a1f2f750a7e0e3b - filename: src/commands/launch/logs/get.test.ts - checksum: 5ee3d8ba6b014b3a9ac558a750a9a58782bb679d32d30b621e41f977dcc98a0a + checksum: d94d390de150956c799f64a84adfe0e02b5b21c66ddcc5cf55a1329aa67ec1a7 - filename: src/core/failure.test.ts checksum: 242d43885d1e3ef46edf1e347899a90549ebf95ec6827426746852f7a6dfc418 - filename: test/integration/retired-commands.test.ts @@ -229,12 +231,12 @@ fileignoreconfig: allowed_patterns: - "FlagKey" - filename: src/resources.test.ts - checksum: beb9cd5f1d8a0c73c15db7a82e5d5feb9b3418bb1d29dab5191eec947e91fa81 + checksum: 6ff9df735323c036cbd5eb5fb321fef110a05b7ae99e61d7687bebc2c0b6dde1 allowed_patterns: - "a project name passed on argv to the project resolver" - "expect\\(Object.keys\\(DEPENDENCIES\\)\\).toEqual\\(\\['project', 'env'\\]\\)" - filename: AGENTS.md - checksum: aafd85677e293bf72dd5764875b202ac3a5531aa083c1c452adaa55021af8c95 + checksum: ba37b6b17d97940a6f7d5e69a531796ab0ee5b6859a9cd5cbdec56e4b7731b26 allowed_patterns: - "Do not add an exemption to a guard to make a new file pass" - "passes its own error wording to `client.request\\(req, MESSAGES\\)`" @@ -330,7 +332,7 @@ fileignoreconfig: - filename: src/commands/launch/env/update.test.ts checksum: 2da219594e7bdd589adc0eff9ff035252a75aaa51dfa20514b18fd3266d7b752 - filename: README.md - checksum: 20f317d623537458342d10926c2442f5674fd26b8a023599fec4626a0d0ac5ba + checksum: b6af89e3dea468d56b666f4098a9b2224a5224ca45c9e94f7970d9e6674f3e6e allowed_patterns: - "password-protection" - "-p, --password" diff --git a/AGENTS.md b/AGENTS.md index 682770c3..8ff3847f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -163,9 +163,9 @@ src/ git/ the internal git-namespace / repository / branch lookups organizations/ the --org flag, its organization picker, and the lookup behind it, which reads the Contentstack Management API rather than Launch - logs/ logs:get: the server-log window parser and repository, the live log API over SSE, and - the LiveLogFollower that reconnects it; the build-log follow is deployments'. It - reaches deployments only + logs/ logs:get: the server-log window parser, the server-log repository and its cursor walk, + the live log API over SSE, and the LiveLogFollower that reconnects it; the build-log + follow is deployments'. It reaches deployments only cache/ the CDN cache purge behind launch:cache:purge: CacheApi, the --path / --prefix / --tags / --hosts inputs and their normalisation, the criterion prompt and the success lines site/ the URL choice for an environment's live site (`siteUrlOf`, shared with the deployment follower), @@ -495,7 +495,7 @@ promoting these to `src/core/catalog.ts` now. | 0 | none - oclif exits 0 implicitly | the command did what it was asked to do | | 1 | `EXIT_RUNTIME` | a runtime failure - `LaunchApiError`, `LaunchNetworkError`, `UnauthenticatedError`, anything oclif handles | | 2 | `EXIT_USAGE` | a usage error - `UsageError`, `MissingInputError`, a failing cross-flag rule | -| 3 | `EXIT_CANCELLED` | the user declined a confirmation, chose nothing at a picker (`CancelledError`) or pressed Ctrl-C while the CLI was following a deployment's logs or a live log stream (`logs:get`) | +| 3 | `EXIT_CANCELLED` | the user declined a confirmation, chose nothing at a picker (`CancelledError`) or pressed Ctrl-C while the CLI was following a deployment's logs, walking server-log pages or following a live log stream (`logs:get`) | `launch:functions:serve` is deliberately a plain oclif `Command` rather than a `LaunchCommand`, because it talks to no API and needs no auth gate, and it is **outside** @@ -746,6 +746,16 @@ server's own 5-minute cap: it exits 0 and is never followed by a reconnect. Ctrl sleep and the socket listen to, exit 3. The header builder, `buildRequestHeaders` in `rest-client.ts`, is the only place `X-CS-CLI`, `x-cs-api-version`, the auth headers and the org/project scope headers are assembled. +**Server logs are a cursor walk.** `logs:get --type server` reads `GET …/logs/server-logs` oldest-first +(`sortDirection=asc`) through `ServerLogsApi.pages`, which follows `pageInfo.newestCursor` with the same window, +`--search` and `--cursor` and prints each page as it arrives, so a window is never held in memory. `nextCursorOf` +is the one rule for "this page leads to another": `hasNewer` true, a non-empty page and a non-blank cursor. The walk +also ends after `MAX_PAGES` pages, and a logs service without `pageInfo` (before CL-7180) ends it after one. When the +last page still leads on, at the cap or on Ctrl-C (exit 3, after the page in hand is printed), the command prints a +`Resume with:` command whose `--since`/`--until` are the **absolute** window of the run: re-resolving a relative +`--since` on resume would move the start past the cursor and silently skip the logs between. The cursor is opaque; +the CLI prints it single-quoted and never parses it, and the server's `INVALID_CURSOR` is the only check on it. + **Writing a test for it: the fake clock must advance.** `now()` and `sleep()` are injected and both are required, not optional with defaults. A fake `now: () => 0` with a `sleep` that does nothing makes the deadline unreachable and the suite **hangs** rather than failing - the same failure mode diff --git a/README.md b/README.md index 5c047fed..a9f9ae72 100644 --- a/README.md +++ b/README.md @@ -560,15 +560,17 @@ Read the build or server logs of a deployment of a Launch environment ``` USAGE $ csdx launch:logs:get [-c ] [-d ] [--org ] [--project ] [--env ] [--deployment - ] [--since ] [--until ] [--type ] + ] [--since ] [--until ] [--search ] [--cursor ] [--type ] FLAGS -c, --config= Path to the local '.cs-launch.json' file -d, --data-dir= Current working directory + --cursor= Server logs only: resume from the cursor printed by an earlier run --deployment= Deployment UID --env= Environment name or UID --org= Organization UID --project= Project name or UID + --search= Server logs only: show only lines containing this text, up to 256 characters --since= Server logs only: start of the window, relative (45s, 30m, 12hr, 2d) or an ISO 8601 timestamp with an offset. Defaults to one hour before --until --type= Kind of logs to read (deployment | server | live). Defaults to deployment diff --git a/src/commands/launch/logs/get.test.ts b/src/commands/launch/logs/get.test.ts index bc100625..b5d6e9e6 100644 --- a/src/commands/launch/logs/get.test.ts +++ b/src/commands/launch/logs/get.test.ts @@ -19,6 +19,7 @@ interface Options { latest?: unknown; get?: () => Promise; serverLogs?: unknown[]; + serverPages?: unknown[]; outputIsTTY?: boolean; } @@ -32,7 +33,7 @@ function commandUnderTest(options: Options = {}) { }; const latestCalls: unknown[] = []; const getCalls: unknown[] = []; - const windowCalls: unknown[] = []; + const walkCalls: unknown[] = []; const command = Object.create(LogsGet.prototype) as LogsGet; Object.assign(command, { @@ -54,16 +55,16 @@ function commandUnderTest(options: Options = {}) { }, }, serverLogs: { - window: async (params: unknown) => { - windowCalls.push(params); - return options.serverLogs ?? []; + async *pages(params: unknown) { + walkCalls.push(params); + yield* options.serverPages ?? [{ logs: options.serverLogs ?? [], pageInfo: { hasNewer: false } }]; }, }, }, }, }); - return { command, lines, latestCalls, getCalls, windowCalls }; + return { command, lines, latestCalls, getCalls, walkCalls }; } function outcome(kind: DeploymentOutcome['kind'], status: string, deployment: object = {}): DeploymentOutcome { @@ -76,12 +77,22 @@ describe('LogsGet', () => { }); it('exposes the log type as --type and never as --log-type', () => { - expect(Object.keys(LogsGet.flags).sort()).toEqual(['deployment', 'env', 'org', 'project', 'since', 'type', 'until']); + expect(Object.keys(LogsGet.flags).sort()).toEqual([ + 'cursor', + 'deployment', + 'env', + 'org', + 'project', + 'search', + 'since', + 'type', + 'until', + ]); expect(LogsGet.flags.type).toBe(catalog['log-type']); }); - it('declares the window rules and the log type input the rules read', () => { - expect(LogsGet.rules).toHaveLength(2); + it('declares the four server-only rules and the log type input the rules read', () => { + expect(LogsGet.rules).toHaveLength(4); expect(Object.keys(LogsGet.inputs)).toContain('log-type'); }); @@ -90,7 +101,7 @@ describe('LogsGet', () => { jest.spyOn(interruptible, 'onInterrupt').mockReturnValue(release); const follow = jest.spyOn(LiveLogFollower.prototype, 'follow').mockResolvedValue(undefined); const watch = jest.spyOn(DeploymentFollower.prototype, 'watch'); - const { command, latestCalls, getCalls, windowCalls } = commandUnderTest({ resolved: { 'log-type': 'live' } }); + const { command, latestCalls, getCalls, walkCalls } = commandUnderTest({ resolved: { 'log-type': 'live' } }); await command.run(); @@ -100,7 +111,7 @@ describe('LogsGet', () => { expect(follow.mock.calls[0][1].aborted).toBe(false); expect(latestCalls).toEqual([{ org: 'org1', project: 'proj1', environment: 'env1' }]); expect(getCalls).toEqual([]); - expect(windowCalls).toEqual([]); + expect(walkCalls).toEqual([]); expect(watch).not.toHaveBeenCalled(); expect(release).toHaveBeenCalledTimes(1); }); @@ -152,15 +163,15 @@ describe('LogsGet', () => { it('refuses a missing latest deployment with exit 2 for both types', async () => { for (const type of ['deployment', 'server']) { - const { command, windowCalls } = commandUnderTest({ resolved: { 'log-type': type }, latest: undefined }); + const { command, walkCalls } = commandUnderTest({ resolved: { 'log-type': type }, latest: undefined }); await expect(command.run()).rejects.toThrow('No deployments found in this environment.'); - expect(windowCalls).toEqual([]); + expect(walkCalls).toEqual([]); } }); it('verifies a given deployment with a get and never asks for the latest', async () => { - const { command, latestCalls, getCalls, windowCalls } = commandUnderTest({ + const { command, latestCalls, getCalls, walkCalls } = commandUnderTest({ resolved: { 'log-type': 'server', deployment: 'd9' }, }); @@ -168,7 +179,7 @@ describe('LogsGet', () => { expect(getCalls).toEqual([{ org: 'org1', project: 'proj1', environment: 'env1', deployment: 'd9' }]); expect(latestCalls).toEqual([]); - expect(windowCalls).toEqual([ + expect(walkCalls).toEqual([ { org: 'org1', project: 'proj1', @@ -181,18 +192,18 @@ describe('LogsGet', () => { }); it('reads the server logs of the latest deployment when none is given', async () => { - const { command, latestCalls, getCalls, windowCalls } = commandUnderTest({ resolved: { 'log-type': 'server' } }); + const { command, latestCalls, getCalls, walkCalls } = commandUnderTest({ resolved: { 'log-type': 'server' } }); await command.run(); expect(latestCalls).toEqual([{ org: 'org1', project: 'proj1', environment: 'env1' }]); expect(getCalls).toEqual([]); - expect(windowCalls).toEqual([expect.objectContaining({ deployment: 'dLatest' })]); + expect(walkCalls).toEqual([expect.objectContaining({ deployment: 'dLatest' })]); }); it('turns a not-found verification into the generic exit-2 wording and reads no logs', async () => { const missing = new LaunchApiError(404, [{ code: 'launch.DEPLOYMENT.NOT_FOUND', message: 'raw' }]); - const { command, windowCalls } = commandUnderTest({ + const { command, walkCalls } = commandUnderTest({ resolved: { 'log-type': 'server', deployment: 'd9' }, get: async () => Promise.reject(missing), }); @@ -201,7 +212,7 @@ describe('LogsGet', () => { await expect(running).rejects.toThrow(UsageError); await expect(running).rejects.toThrow('No deployment with UID "d9" found in this environment.'); - expect(windowCalls).toEqual([]); + expect(walkCalls).toEqual([]); }); it('propagates a failed verification untouched', async () => { @@ -211,8 +222,71 @@ describe('LogsGet', () => { await expect(command.run()).rejects.toBe(failure); }); - it('prints the server lines and stays quiet about the cap at 4999 lines', async () => { - const serverLogs = Array.from({ length: 4999 }, (_, index) => ({ + it('walks the server pages with the window, search and cursor and prints every line in order', async () => { + const { command, lines, walkCalls } = commandUnderTest({ + resolved: { 'log-type': 'server', deployment: 'd9', search: 'timeout', cursor: '[1,"a"]' }, + serverPages: [ + { logs: [{ message: 'one' }, { message: 'two' }], pageInfo: { hasNewer: true, newestCursor: '[2,"b"]' } }, + { logs: [{ message: 'three' }], pageInfo: { hasNewer: false, newestCursor: '[3,"c"]' } }, + ], + }); + + await command.run(); + + expect(lines).toEqual(['one', 'two', 'three']); + expect(walkCalls).toEqual([ + { + org: 'org1', + project: 'proj1', + environment: 'env1', + deployment: 'd9', + startTime: NOW - 3_600_000, + endTime: NOW, + search: 'timeout', + cursor: '[1,"a"]', + }, + ]); + }); + + it('prints the cap notice and a resume command from the last newest cursor when the walk ends with more to read', async () => { + const { command, lines } = commandUnderTest({ + resolved: { 'log-type': 'server', deployment: 'd9', search: 'time out', since: '2h' }, + serverPages: [ + { logs: [{ message: 'one' }], pageInfo: { hasNewer: true, newestCursor: '[1,"a"]' } }, + { logs: [{ message: 'two' }], pageInfo: { hasNewer: true, newestCursor: '[2,"b"]' } }, + ], + }); + + await command.run(); + + expect(lines).toEqual([ + 'one', + 'two', + 'Stopped after 100 pages of server logs; more remain in this window.', + 'Resume with: csdx launch:logs:get --org org1 --project proj1 --env env1 --deployment d9 --type server ' + + `--since 2026-10-28T10:00:00.000Z --until 2026-10-28T12:00:00.000Z --search 'time out' --cursor '[2,"b"]'`, + ]); + }); + + it.each([ + [5000, true], + [4999, false], + ])('on a logs service without page info, %i lines show the 5000-line notice: %s', async (count, shows) => { + const logs = Array.from({ length: count }, (_, index) => ({ message: `line ${index}` })); + const { command, lines } = commandUnderTest({ resolved: { 'log-type': 'server' }, serverPages: [{ logs }] }); + + await command.run(); + + expect(lines).toHaveLength(shows ? count + 1 : count); + expect(lines.at(-1)).toBe( + shows + ? 'Showing the newest 5000 server log lines in this window. Narrow --since/--until to see earlier ones.' + : `line ${count - 1}`, + ); + }); + + it('prints a full page of 5000 lines with no cap notice when the server reports nothing newer', async () => { + const serverLogs = Array.from({ length: 5000 }, (_, index) => ({ timestamp: '2026-10-28T11:30:00.000Z', message: `line ${index}`, })); @@ -220,41 +294,34 @@ describe('LogsGet', () => { await command.run(); - expect(lines).toHaveLength(4999); + expect(lines).toHaveLength(5000); expect(lines[0]).toBe('2026-10-28 11:30:00.000: line 0'); - expect(lines.join('\n')).not.toContain('Showing the newest'); + expect(lines.at(-1)).toBe('2026-10-28 11:30:00.000: line 4999'); }); - it('prints the cap notice last at exactly 5000 lines and not at 5001', async () => { - const entries = (count: number) => - Array.from({ length: count }, (_, index) => ({ timestamp: '2026-10-28T11:30:00.000Z', message: `line ${index}` })); - const capped = commandUnderTest({ resolved: { 'log-type': 'server' }, serverLogs: entries(5000) }); - const over = commandUnderTest({ resolved: { 'log-type': 'server' }, serverLogs: entries(5001) }); + it('prints the empty server notice for the resolved window, yellow on a terminal', async () => { + const { command, lines } = commandUnderTest({ resolved: { 'log-type': 'server' }, outputIsTTY: true }); - await capped.command.run(); - await over.command.run(); + await command.run(); - expect(capped.lines).toHaveLength(5001); - expect(capped.lines.at(-1)).toBe( - 'Showing the newest 5000 server log lines in this window. Narrow --since/--until to see earlier ones.', - ); - expect(over.lines).toHaveLength(5001); - expect(over.lines.join('\n')).not.toContain('Showing the newest'); + expect(lines).toEqual([ + '\u001b[33mNo server logs found between 2026-10-28T11:00:00.000Z and 2026-10-28T12:00:00.000Z.\u001b[39m', + ]); }); - it('prints the empty server notice for the resolved window, yellow on a terminal', async () => { - const { command, lines } = commandUnderTest({ resolved: { 'log-type': 'server' }, outputIsTTY: true }); + it('names the search in the empty server notice when nothing matched it', async () => { + const { command, lines } = commandUnderTest({ resolved: { 'log-type': 'server', search: 'timeout' } }); await command.run(); expect(lines).toEqual([ - '\u001b[33mNo server logs found between 2026-10-28T11:00:00.000Z and 2026-10-28T12:00:00.000Z.\u001b[39m', + 'No server logs matched "timeout" between 2026-10-28T11:00:00.000Z and 2026-10-28T12:00:00.000Z.', ]); }); it('prints the 24 hour notice before the lines and sends the window as the user gave it', async () => { const serverLogs = [{ timestamp: '2026-10-27T11:00:00.000Z', message: 'old' }]; - const { command, lines, windowCalls } = commandUnderTest({ + const { command, lines, walkCalls } = commandUnderTest({ resolved: { 'log-type': 'server', since: '2d' }, serverLogs, }); @@ -265,7 +332,7 @@ describe('LogsGet', () => { 'Server logs are kept for 24 hours; showing from 2026-10-27T12:00:00.000Z.', '2026-10-27 11:00:00.000: old', ]); - expect(windowCalls).toEqual([expect.objectContaining({ startTime: NOW - 172_800_000, endTime: NOW })]); + expect(walkCalls).toEqual([expect.objectContaining({ startTime: NOW - 172_800_000, endTime: NOW })]); }); it('prints no notice for a window of exactly 24 hours', async () => { @@ -280,17 +347,19 @@ describe('LogsGet', () => { }); it('rejects a bad window before any read', async () => { - const { command, windowCalls } = commandUnderTest({ resolved: { 'log-type': 'server', since: 'yesterday' } }); + const { command, walkCalls } = commandUnderTest({ resolved: { 'log-type': 'server', since: 'yesterday' } }); await expect(command.run()).rejects.toThrow(UsageError); - expect(windowCalls).toEqual([]); + expect(walkCalls).toEqual([]); }); it('propagates a failed server read', async () => { const failure = new LaunchApiError(500, [{ code: 'launch.UNKNOWN', message: 'boom' }]); const { command } = commandUnderTest({ resolved: { 'log-type': 'server' } }); Object.assign((command as unknown as { services: { api: { serverLogs: object } } }).services.api.serverLogs, { - window: async () => Promise.reject(failure), + async *pages() { + yield await Promise.reject(failure); + }, }); await expect(command.run()).rejects.toBe(failure); @@ -408,14 +477,40 @@ describe('LogsGet', () => { expect(lines).toEqual([]); }); - it('registers no interrupt listener and reads no deployment logs for --type server', async () => { - const listener = jest.spyOn(interruptible, 'onInterrupt'); + it('stops the server walk on Ctrl-C after the page in hand, prints a resume command, exits 3 and releases', async () => { + const release = jest.fn(); + let interrupt: () => void = () => undefined; + jest.spyOn(interruptible, 'onInterrupt').mockImplementation((listener) => { + interrupt = listener; + return release; + }); const watch = jest.spyOn(DeploymentFollower.prototype, 'watch'); - const { command } = commandUnderTest({ resolved: { 'log-type': 'server' } }); + const { command, lines } = commandUnderTest({ resolved: { 'log-type': 'server', deployment: 'd9' } }); + let requested = 0; + Object.assign((command as unknown as { services: { api: { serverLogs: object } } }).services.api.serverLogs, { + async *pages() { + requested = 1; + yield { logs: [{ message: 'one' }], pageInfo: { hasNewer: true, newestCursor: '[1,"a"]' } }; + requested = 2; + interrupt(); + yield { logs: [{ message: 'two' }], pageInfo: { hasNewer: true, newestCursor: '[2,"b"]' } }; + requested = 3; + yield { logs: [{ message: 'three' }], pageInfo: { hasNewer: false } }; + }, + }); - await command.run(); + const running = command.run(); - expect(listener).not.toHaveBeenCalled(); + await expect(running).rejects.toMatchObject({ oclif: { exit: EXIT_CANCELLED } }); + expect(requested).toBe(2); + expect(lines).toEqual([ + 'one', + 'two', + 'Stopped following the logs.', + 'Resume with: csdx launch:logs:get --org org1 --project proj1 --env env1 --deployment d9 --type server ' + + `--since 2026-10-28T11:00:00.000Z --until 2026-10-28T12:00:00.000Z --cursor '[2,"b"]'`, + ]); + expect(release).toHaveBeenCalledTimes(1); expect(watch).not.toHaveBeenCalled(); }); }); diff --git a/src/commands/launch/logs/get.ts b/src/commands/launch/logs/get.ts index da0d7d25..6780e23d 100644 --- a/src/commands/launch/logs/get.ts +++ b/src/commands/launch/logs/get.ts @@ -11,7 +11,7 @@ import { DeploymentUnsuccessfulError, DeploymentWaitInterruptedError } from '../ import { DeploymentFollower } from '../../../deployments/deployment.follower'; import { fetchDeployment } from '../../../deployments/deployment.resolver'; import { defaultWatchTiming } from '../../../deployments/deployment.watcher'; -import { LOG_TYPE_FLAG, windowOnlyWithServer } from '../../../logs/logs.inputs'; +import { LOG_TYPE_FLAG, onlyWithServer } from '../../../logs/logs.inputs'; import { deploymentStatusLine, logsWaitTimedOutMessage, @@ -19,12 +19,16 @@ import { noServerLogsLine, serverLogLine, serverLogsCapLine, + serverLogsPageCapLine, + serverLogsResumeLine, serverLogsRetentionLine, stoppedFollowingLine, } from '../../../logs/logs.presenter'; import { LiveLogFollower } from '../../../logs/live-log.follower'; import { parseLogWindow, type LogWindow } from '../../../logs/log-window'; -import { SERVER_LOGS_PAGE_CAP } from '../../../logs/types'; +import { nextCursorOf } from '../../../logs/server-logs.api'; +import { SERVER_LOGS_PAGE_CAP, type ServerLogsPage } from '../../../logs/types'; +import { isRecord } from '../../../core/values'; const getInputs = inputs({ org: { required: true }, @@ -34,10 +38,16 @@ const getInputs = inputs({ 'log-type': {}, since: {}, until: {}, + search: {}, + cursor: {}, }); const { 'log-type': logTypeFlag, ...otherFlags } = flagsFor(getInputs); +interface ServerResume { + cursor?: string; +} + interface LogScope { org: string; project: string; @@ -58,7 +68,12 @@ export default class LogsGet extends LaunchCommand { static flags = { ...otherFlags, [LOG_TYPE_FLAG]: logTypeFlag }; - static rules = [windowOnlyWithServer('since'), windowOnlyWithServer('until')]; + static rules = [ + onlyWithServer('since'), + onlyWithServer('until'), + onlyWithServer('search'), + onlyWithServer('cursor'), + ]; protected async parse< F extends Record, @@ -80,8 +95,13 @@ export default class LogsGet extends LaunchCommand { if (type === 'server') { const window = parseLogWindow({ since: this.resolved.since, until: this.resolved.until }, () => Date.now()); + const scope = await this.verifiedDeployment({ org, project, environment }); + const resume: ServerResume = {}; - await this.printServerLogs(await this.verifiedDeployment({ org, project, environment }), window); + await this.untilInterrupted( + (stop) => this.printServerLogs(scope, window, stop, resume), + () => (resume.cursor === undefined ? [] : [this.resumeLine(scope, window, resume.cursor)]), + ); return; } @@ -111,30 +131,58 @@ export default class LogsGet extends LaunchCommand { return { ...environmentScope, deployment: latest.uid }; } - private async printServerLogs(scope: LogScope, window: LogWindow): Promise { + private resumeLine(scope: LogScope, { startTime, endTime }: LogWindow, cursor: string): string { + return serverLogsResumeLine({ ...scope, startTime, endTime, search: this.resolved.search, cursor }); + } + + private async printServerLogs( + scope: LogScope, + window: LogWindow, + stop: AbortSignal, + resume: ServerResume, + ): Promise { const outputIsTTY = this.services.outputIsTTY === true; const { startTime, endTime, clampedFrom } = window; - const logs = await this.services.api.serverLogs.window({ ...scope, startTime, endTime }); + const { search, cursor } = this.resolved; + let printed = 0; + let last: ServerLogsPage | undefined; if (clampedFrom !== undefined) { this.ux.print(serverLogsRetentionLine(clampedFrom, outputIsTTY)); } - if (logs.length === 0) { - this.ux.print(noServerLogsLine(startTime, endTime, outputIsTTY)); - return; + for await (const page of this.services.api.serverLogs.pages({ ...scope, startTime, endTime, search, cursor })) { + for (const log of page.logs) { + this.ux.print(serverLogLine(log, outputIsTTY, process.stdout.columns)); + printed += 1; + } + + resume.cursor = nextCursorOf(page); + last = page; + + if (stop.aborted) { + throw stop.reason; + } } - for (const log of logs) { - this.ux.print(serverLogLine(log, outputIsTTY, process.stdout.columns)); + if (printed === 0) { + this.ux.print(noServerLogsLine(startTime, endTime, outputIsTTY, search)); } - if (logs.length === SERVER_LOGS_PAGE_CAP) { + if (last !== undefined && !isRecord(last.pageInfo) && last.logs.length === SERVER_LOGS_PAGE_CAP) { this.ux.print(serverLogsCapLine(outputIsTTY)); } + + if (resume.cursor !== undefined) { + this.ux.print(serverLogsPageCapLine(outputIsTTY)); + this.ux.print(this.resumeLine(scope, window, resume.cursor)); + } } - private async untilInterrupted(follow: (stop: AbortSignal) => Promise): Promise { + private async untilInterrupted( + follow: (stop: AbortSignal) => Promise, + afterStopped: () => string[] = () => [], + ): Promise { const stop = new AbortController(); const release = onInterrupt(() => stop.abort(new DeploymentWaitInterruptedError('Stopped following the logs.'))); @@ -143,6 +191,11 @@ export default class LogsGet extends LaunchCommand { } catch (error) { if (stop.signal.aborted && error === stop.signal.reason) { this.ux.print(stoppedFollowingLine(this.services.outputIsTTY === true)); + + for (const line of afterStopped()) { + this.ux.print(line); + } + this.exit(EXIT_CANCELLED); } diff --git a/src/logs/logs.errors.test.ts b/src/logs/logs.errors.test.ts index 7df5638c..07051116 100644 --- a/src/logs/logs.errors.test.ts +++ b/src/logs/logs.errors.test.ts @@ -17,6 +17,16 @@ describe('logs error wording', () => { ['launch.DEPLOYMENT.ENVIRONMENT_MISMATCH', 'That deployment does not belong to this environment.'], ['launch.PROJECT.NOT_FOUND', 'No project found with that UID.'], ['launch.DEPLOYMENT.NOT_FOUND', 'No deployment found with that UID.'], + ['launch.SERVERLOGS.SEARCH_TOO_LONG', '--search must be 256 characters or fewer.'], + [ + 'launch.SERVERLOGS.INVALID_CURSOR', + 'That cursor was not produced by launch:logs:get. Run the command again without --cursor.', + ], + ['launch.SERVERLOGS.INVALID_SOURCE', 'The Launch API rejected the server log query. Update the CLI and try again.'], + [ + 'launch.SERVERLOGS.INVALID_SORT_DIRECTION', + 'The Launch API rejected the server log query. Update the CLI and try again.', + ], ])('rewords %s and never shows the raw message', (code, wording) => { const error = new LaunchApiError(400, [{ code, message: 'raw server text' }], LOGS_ERROR_MESSAGES); diff --git a/src/logs/logs.errors.ts b/src/logs/logs.errors.ts index cb94369f..dd16e106 100644 --- a/src/logs/logs.errors.ts +++ b/src/logs/logs.errors.ts @@ -1,5 +1,8 @@ import type { ErrorMessages } from '../transport/errors'; import { DEPLOYMENT_ERROR_MESSAGES } from '../deployments/deployment.errors'; +import { MAX_SEARCH_LENGTH } from './logs.inputs'; + +const QUERY_REJECTED = 'The Launch API rejected the server log query. Update the CLI and try again.'; export const LOGS_ERROR_MESSAGES: ErrorMessages = { ...DEPLOYMENT_ERROR_MESSAGES, @@ -11,4 +14,9 @@ export const LOGS_ERROR_MESSAGES: ErrorMessages = { 'launch.DEPLOYMENT.PROJECT_MISMATCH': 'That deployment does not belong to this project.', 'launch.DEPLOYMENT.ENVIRONMENT_MISMATCH': 'That deployment does not belong to this environment.', 'launch.PROJECT.NOT_FOUND': 'No project found with that UID.', + 'launch.SERVERLOGS.SEARCH_TOO_LONG': `--search must be ${MAX_SEARCH_LENGTH} characters or fewer.`, + 'launch.SERVERLOGS.INVALID_CURSOR': + 'That cursor was not produced by launch:logs:get. Run the command again without --cursor.', + 'launch.SERVERLOGS.INVALID_SOURCE': QUERY_REJECTED, + 'launch.SERVERLOGS.INVALID_SORT_DIRECTION': QUERY_REJECTED, }; diff --git a/src/logs/logs.inputs.test.ts b/src/logs/logs.inputs.test.ts index 2f592960..f8cb2b3a 100644 --- a/src/logs/logs.inputs.test.ts +++ b/src/logs/logs.inputs.test.ts @@ -6,10 +6,10 @@ import { resolveInputs, resolveInputsTraced } from '../core/resolve'; import { projectTypeChoiceOf } from '../projects/project.inputs'; import type { ApiSurface } from '../resources'; import { catalog, resolution } from '../resources'; -import { logTypeOf, logsFlags, logsResolution, windowOnlyWithServer } from './logs.inputs'; +import { logTypeOf, logsFlags, logsResolution, onlyWithServer } from './logs.inputs'; -const logInputs = inputs({ 'log-type': {}, since: {}, until: {} }); -const rules = [windowOnlyWithServer('since'), windowOnlyWithServer('until')]; +const logInputs = inputs({ 'log-type': {}, since: {}, until: {}, search: {}, cursor: {} }); +const rules = [onlyWithServer('since'), onlyWithServer('until'), onlyWithServer('search'), onlyWithServer('cursor')]; const prompts: unknown[] = []; const services = { api: {} as ApiSurface, @@ -28,6 +28,10 @@ function resolve(parsed: Record) { return resolveInputsTraced(logInputs, { parsed, projectConfig: {}, services, rules }); } +function resolveSearch(parsed: Record) { + return resolveInputsTraced(inputs({ 'log-type': {}, search: {} }), { parsed, projectConfig: {}, services, rules: [] }); +} + async function failureOf(parsed: Record): Promise { return resolve(parsed).then( () => { @@ -42,9 +46,16 @@ beforeEach(() => { }); describe('logs inputs', () => { - it('shares the log type, since and until through the catalog without touching the project type flag', () => { + it('shares the log type, window, search and cursor through the catalog without touching the project type flag', () => { expect(Object.keys(logsFlags).sort()).toEqual(Object.keys(logsResolution).sort()); - expect(Object.keys(logsFlags)).toHaveLength(3); + expect(Object.keys(logsFlags).sort()).toEqual(['cursor', 'log-type', 'search', 'since', 'until']); + expect(catalog.search).toBe(logsFlags.search); + expect(catalog.cursor).toBe(logsFlags.cursor); + expect(resolution.search).toBe(logsResolution.search); + expect(resolution.cursor).toBe(logsResolution.cursor); + expect(logsFlags.search.description).toContain('256 characters'); + expect(logsFlags.search.required).toBeFalsy(); + expect(logsFlags.cursor.required).toBeFalsy(); expect(catalog['log-type']).toBe(logsFlags['log-type']); expect(catalog.since).toBe(logsFlags.since); expect(catalog.until).toBe(logsFlags.until); @@ -130,16 +141,50 @@ describe('logs inputs', () => { expect(untilDefault.message).toBe('--until is only supported when --type is one of server; --type is deployment.'); }); - it('accepts since and until with the server type, and a blank one with any type', async () => { - const server = await resolve({ 'log-type': 'Server', since: ' 2h ', until: '1h' }); - const blank = await resolve({ since: ' ', until: '' }); + it('refuses --search and --cursor with the default and explicit deployment type and with live', async () => { + const searchDefault = await failureOf({ search: 'timeout' }); + const searchLive = await failureOf({ 'log-type': 'live', search: 'timeout' }); + const cursorDeployment = await failureOf({ 'log-type': 'deployment', cursor: '[1,"a"]' }); + + expect(searchDefault).toBeInstanceOf(UsageError); + expect(searchDefault.exitCode).toBe(2); + expect(searchDefault.message).toBe('--search is only supported when --type is one of server; --type is deployment.'); + expect(searchLive.message).toBe('--search is only supported when --type is one of server; --type is live.'); + expect(cursorDeployment.message).toBe('--cursor is only supported when --type is one of server; --type is deployment.'); + }); + + it('accepts the server-only flags with the server type, and a blank one with any type', async () => { + const server = await resolve({ 'log-type': 'Server', since: ' 2h ', until: '1h', search: ' timeout ', cursor: ' [1,"a"] ' }); + const blank = await resolve({ since: ' ', until: '', search: ' ', cursor: '' }); const nulls = await resolve({ 'log-type': 'live', since: null, until: undefined }); - expect(server.resolved).toEqual({ 'log-type': 'server', since: '2h', until: '1h' }); - expect(blank.resolved).toEqual({ 'log-type': 'deployment', since: undefined, until: undefined }); + expect(server.resolved).toEqual({ 'log-type': 'server', since: '2h', until: '1h', search: 'timeout', cursor: '[1,"a"]' }); + expect(blank.resolved).toEqual({ + 'log-type': 'deployment', + since: undefined, + until: undefined, + search: undefined, + cursor: undefined, + }); expect(nulls.resolved['log-type']).toBe('live'); }); + it('accepts a search of exactly 256 characters and refuses 257 with a usage error naming --search', async () => { + const longest = 'a'.repeat(256); + const accepted = await resolveSearch({ 'log-type': 'server', search: ` ${longest} ` }); + const failure = await resolveSearch({ 'log-type': 'server', search: `${longest}b` }).then( + () => { + throw new Error('resolution did not fail'); + }, + (error: UsageError) => error, + ); + + expect(accepted.resolved.search).toBe(longest); + expect(failure).toBeInstanceOf(UsageError); + expect(failure.exitCode).toBe(2); + expect(failure.message).toBe('--search must be 256 characters or fewer; that value is 257 characters.'); + }); + it('refuses a window flag when the log type is not an input of the command, rather than skipping the check', async () => { const bare = inputs({ since: {} }); const run = resolveInputs(bare, { parsed: { since: '1h' }, projectConfig: {}, services, rules }); diff --git a/src/logs/logs.inputs.ts b/src/logs/logs.inputs.ts index 09d244c1..e0cacf21 100644 --- a/src/logs/logs.inputs.ts +++ b/src/logs/logs.inputs.ts @@ -4,7 +4,8 @@ import { InputDependencyError } from '../core/errors'; import type { ResolutionSpec } from '../core/resolution'; import type { Rule } from '../core/rules'; import { requireValueOf } from '../core/rules'; -import { isAbsent, oneOf } from '../core/values'; +import { isAbsent, oneOf, withinLength } from '../core/values'; +import type { FlagKey } from '../resources'; export const LOG_TYPES = ['deployment', 'server', 'live'] as const; @@ -14,7 +15,9 @@ export const DEFAULT_LOG_TYPE: LogType = 'deployment'; export const LOG_TYPE_FLAG = 'type'; -export const WINDOW_LOG_TYPES: readonly LogType[] = ['server']; +export const SERVER_ONLY_LOG_TYPES: readonly LogType[] = ['server']; + +export const MAX_SEARCH_LENGTH = 256; const WINDOW_FORMATS = 'relative (45s, 30m, 12hr, 2d) or an ISO 8601 timestamp with an offset'; @@ -28,6 +31,12 @@ export const logsFlags = { until: Flags.string({ description: `Server logs only: end of the window, ${WINDOW_FORMATS}. Defaults to now`, }), + search: Flags.string({ + description: `Server logs only: show only lines containing this text, up to ${MAX_SEARCH_LENGTH} characters`, + }), + cursor: Flags.string({ + description: 'Server logs only: resume from the cursor printed by an earlier run', + }), }; export function logTypeOf(value: string): LogType { @@ -41,9 +50,11 @@ export const logsResolution = { } satisfies ResolutionSpec, since: { normalize: async (value) => value.trim() } satisfies ResolutionSpec, until: { normalize: async (value) => value.trim() } satisfies ResolutionSpec, + search: { normalize: (value) => withinLength('search', value, MAX_SEARCH_LENGTH) } satisfies ResolutionSpec, + cursor: { normalize: async (value) => value.trim() } satisfies ResolutionSpec, }; -export function windowOnlyWithServer(flag: 'since' | 'until'): Rule { +export function onlyWithServer(flag: FlagKey): Rule { return (resolved) => { if (isAbsent(resolved[flag])) { return; @@ -57,6 +68,6 @@ export function windowOnlyWithServer(flag: 'since' | 'until'): Rule { ); } - requireValueOf(flag, LOG_TYPE_FLAG, WINDOW_LOG_TYPES, type); + requireValueOf(flag, LOG_TYPE_FLAG, SERVER_ONLY_LOG_TYPES, type); }; } diff --git a/src/logs/logs.presenter.test.ts b/src/logs/logs.presenter.test.ts index 892c686a..a7d5ef8c 100644 --- a/src/logs/logs.presenter.test.ts +++ b/src/logs/logs.presenter.test.ts @@ -9,6 +9,8 @@ import { noServerLogsLine, serverLogLine, serverLogsCapLine, + serverLogsPageCapLine, + serverLogsResumeLine, serverLogsRetentionLine, stoppedFollowingLine, } from './logs.presenter'; @@ -38,13 +40,60 @@ describe('logs presenter', () => { ); }); - it('words the 5000-line notice exactly', () => { + it('names the search text when nothing in the window matched it', () => { + const start = Date.parse('2026-10-28T09:00:00.000Z'); + const end = Date.parse('2026-10-28T10:00:00.000Z'); + + expect(noServerLogsLine(start, end, false, 'upstream timeout')).toBe( + 'No server logs matched "upstream timeout" between 2026-10-28T09:00:00.000Z and 2026-10-28T10:00:00.000Z.', + ); + expect(noServerLogsLine(start, end, true, 'x')).toBe(yellow(noServerLogsLine(start, end, false, 'x'))); + }); + + it('words the 5000-line notice of a logs service without page info exactly', () => { expect(serverLogsCapLine(false)).toBe( 'Showing the newest 5000 server log lines in this window. Narrow --since/--until to see earlier ones.', ); expect(serverLogsCapLine(true)).toBe(yellow(serverLogsCapLine(false))); }); + it('words the page cap notice in yellow on a terminal and plain on a pipe', () => { + expect(serverLogsPageCapLine(false)).toBe('Stopped after 100 pages of server logs; more remain in this window.'); + expect(serverLogsPageCapLine(true)).toBe(yellow(serverLogsPageCapLine(false))); + }); + + it('builds a resume command with the absolute window and the cursor, and no --search when there was none', () => { + const line = serverLogsResumeLine({ + org: 'o1', + project: 'p1', + environment: 'e1', + deployment: 'd1', + startTime: Date.parse('2026-10-28T00:00:00.000Z'), + endTime: Date.parse('2026-10-28T12:00:00.250Z'), + cursor: '[1761652800000,"AZr3kQ"]', + }); + + expect(line).toBe( + 'Resume with: csdx launch:logs:get --org o1 --project p1 --env e1 --deployment d1 --type server ' + + '--since 2026-10-28T00:00:00.000Z --until 2026-10-28T12:00:00.250Z --cursor \'[1761652800000,"AZr3kQ"]\'', + ); + }); + + it('quotes the search for a shell, escaping a single quote inside it', () => { + const line = serverLogsResumeLine({ + org: 'o1', + project: 'p1', + environment: 'e1', + deployment: 'd1', + startTime: 0, + endTime: 1, + search: "can't connect", + cursor: '[1,"a"]', + }); + + expect(line).toContain(` --search 'can'\\''t connect' --cursor '[1,"a"]'`); + }); + it('words the 24 hour notice from the clamped start', () => { const from = Date.parse('2026-10-27T10:00:00.000Z'); diff --git a/src/logs/logs.presenter.ts b/src/logs/logs.presenter.ts index b1890160..12ad24b4 100644 --- a/src/logs/logs.presenter.ts +++ b/src/logs/logs.presenter.ts @@ -1,3 +1,4 @@ +import { MAX_PAGES } from '../core/constants'; import { DEPLOYMENT_WAIT_TIMEOUT_MS } from '../deployments/deployment.watcher'; import { clockOf, deploymentLogLine } from '../deployments/deployment.presenter'; import type { Deployment } from '../deployments/types'; @@ -19,6 +20,18 @@ function deploymentLogsHint(scope: DeploymentScopeLabels, deploymentUid: string) return `csdx launch:logs:get ${flags} --deployment ${deploymentUid}`; } +export interface ServerLogsResume extends DeploymentScopeLabels { + deployment: string; + startTime: number; + endTime: number; + search?: string; + cursor: string; +} + +function shellQuoted(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + function isoOf(time: number): string { return new Date(time).toISOString(); } @@ -42,8 +55,10 @@ export function noDeploymentLogsLine(deployment: string, outputIsTTY: boolean): return styled(`No deployment logs found for deployment ${deployment}.`, 'yellow', outputIsTTY); } -export function noServerLogsLine(startTime: number, endTime: number, outputIsTTY: boolean): string { - return styled(`No server logs found between ${isoOf(startTime)} and ${isoOf(endTime)}.`, 'yellow', outputIsTTY); +export function noServerLogsLine(startTime: number, endTime: number, outputIsTTY: boolean, search?: string): string { + const found = search === undefined ? 'found' : `matched "${search}"`; + + return styled(`No server logs ${found} between ${isoOf(startTime)} and ${isoOf(endTime)}.`, 'yellow', outputIsTTY); } export function serverLogsCapLine(outputIsTTY: boolean): string { @@ -55,6 +70,20 @@ export function serverLogsCapLine(outputIsTTY: boolean): string { ); } +export function serverLogsPageCapLine(outputIsTTY: boolean): string { + return styled(`Stopped after ${MAX_PAGES} pages of server logs; more remain in this window.`, 'yellow', outputIsTTY); +} + +export function serverLogsResumeLine(resume: ServerLogsResume): string { + const window = `--type server --since ${isoOf(resume.startTime)} --until ${isoOf(resume.endTime)}`; + const search = resume.search === undefined ? '' : ` --search ${shellQuoted(resume.search)}`; + + return ( + `Resume with: ${deploymentLogsHint(resume, resume.deployment)} ${window}` + + `${search} --cursor ${shellQuoted(resume.cursor)}` + ); +} + export function serverLogsRetentionLine(clampedFrom: number, outputIsTTY: boolean): string { return styled(`Server logs are kept for 24 hours; showing from ${isoOf(clampedFrom)}.`, 'yellow', outputIsTTY); } diff --git a/src/logs/server-logs.api.test.ts b/src/logs/server-logs.api.test.ts index 8af58e24..0fc5764b 100644 --- a/src/logs/server-logs.api.test.ts +++ b/src/logs/server-logs.api.test.ts @@ -1,5 +1,6 @@ import { randomBytes } from 'node:crypto'; +import { MAX_PAGES } from '../core/constants'; import { LaunchApiError } from '../transport/errors'; import type { RestApiClient, RestRequest } from '../transport/rest-client'; import { LOGS_ERROR_MESSAGES } from './logs.errors'; @@ -26,33 +27,158 @@ function fakeRestClient(result: unknown) { return { client, requests, messages }; } +function fakePagedClient(pages: unknown[]) { + const queries: unknown[] = []; + const client = { + request: async (req: RestRequest) => { + queries.push(req.query); + return pages[queries.length - 1]; + }, + } as unknown as RestApiClient; + + return { client, queries }; +} + +async function collect(pages: AsyncIterable): Promise { + const seen: unknown[] = []; + + for await (const page of pages) { + seen.push(page); + } + + return seen; +} + describe('ServerLogsApi', () => { - it('reads the window as a scoped GET on the deployment path and returns the entries in order', async () => { - const entries = [ - { timestamp: '2026-10-28T10:00:00.000Z', message: 'first', source: 'edge' }, - { timestamp: '2026-10-28T10:00:01.000Z', message: 'second', source: 'edge' }, - ]; - const { client, requests, messages } = fakeRestClient({ serverLogs: entries }); + it('reads one page oldest-first without search or cursor and returns its entries and page info', async () => { + const entries = [{ timestamp: '2026-10-28T10:00:00.000Z', message: 'first', source: 'edge', cursor: '[1,"a"]' }]; + const pageInfo = { hasOlder: null, hasNewer: true, oldestCursor: '[1,"a"]', newestCursor: '[1,"a"]' }; + const { client, requests, messages } = fakeRestClient({ serverLogs: entries, pageInfo }); - const result = await new ServerLogsApi(client).window(WINDOW); + const result = await new ServerLogsApi(client).page(WINDOW); - expect(result).toEqual(entries); + expect(result).toEqual({ logs: entries, pageInfo }); expect(requests).toEqual([ { method: 'GET', path: `/projects/${PROJECT}/environments/${ENVIRONMENT}/deployments/${DEPLOYMENT}/logs/server-logs`, orgUid: ORG, projectUid: PROJECT, - query: { startTime: 1_000, endTime: 2_000 }, + query: { startTime: 1_000, endTime: 2_000, sortDirection: 'asc' }, }, ]); expect(messages[0]).toBe(LOGS_ERROR_MESSAGES); }); - it('returns an empty page as an empty list', async () => { - const { client } = fakeRestClient({ serverLogs: [] }); + it('sends the search text and the cursor exactly as given', async () => { + const cursor = `[${Date.now()},"${randomBytes(6).toString('hex')}"]`; + const pageInfo = { hasNewer: false, newestCursor: null }; + const { client, requests } = fakeRestClient({ serverLogs: [], pageInfo }); + + const result = await new ServerLogsApi(client).page({ ...WINDOW, search: 'upstream timeout', cursor }); + + expect(result).toEqual({ logs: [], pageInfo }); + expect(requests[0].query).toEqual({ + startTime: 1_000, + endTime: 2_000, + sortDirection: 'asc', + search: 'upstream timeout', + cursor, + }); + }); + + it('walks a single page and stops when the server reports nothing newer', async () => { + const only = { serverLogs: [{ message: 'only' }], pageInfo: { hasNewer: false, newestCursor: '[1,"a"]' } }; + const { client, queries } = fakePagedClient([only, only]); + + const seen = await collect(new ServerLogsApi(client).pages({ ...WINDOW, search: 'x' })); + + expect(seen).toEqual([{ logs: only.serverLogs, pageInfo: only.pageInfo }]); + expect(queries).toEqual([{ startTime: 1_000, endTime: 2_000, sortDirection: 'asc', search: 'x' }]); + }); + + it('follows the newest cursor of each page with the same window and search until nothing is newer', async () => { + const first = { serverLogs: [{ message: 'one' }], pageInfo: { hasNewer: true, newestCursor: '[1,"a"]' } }; + const second = { serverLogs: [{ message: 'two' }], pageInfo: { hasNewer: true, newestCursor: '[2,"b"]' } }; + const last = { serverLogs: [{ message: 'three' }], pageInfo: { hasNewer: false, newestCursor: '[3,"c"]' } }; + const { client, queries } = fakePagedClient([first, second, last, last]); + + const seen = await collect(new ServerLogsApi(client).pages({ ...WINDOW, search: 'x', cursor: '[0,"z"]' })); + + expect(seen).toEqual([first, second, last].map(({ serverLogs, pageInfo }) => ({ logs: serverLogs, pageInfo }))); + expect(queries).toEqual([ + { startTime: 1_000, endTime: 2_000, sortDirection: 'asc', search: 'x', cursor: '[0,"z"]' }, + { startTime: 1_000, endTime: 2_000, sortDirection: 'asc', search: 'x', cursor: '[1,"a"]' }, + { startTime: 1_000, endTime: 2_000, sortDirection: 'asc', search: 'x', cursor: '[2,"b"]' }, + ]); + }); + + it.each([ + ['a server without page info', { serverLogs: [{ message: 'one' }] }], + ['no direction answer', { serverLogs: [{ message: 'one' }], pageInfo: { hasNewer: null, newestCursor: '[1,"a"]' } }], + ['an empty page that claims more', { serverLogs: [], pageInfo: { hasNewer: true, newestCursor: '[1,"a"]' } }], + ['no newest cursor', { serverLogs: [{ message: 'one' }], pageInfo: { hasNewer: true, newestCursor: null } }], + ['a blank newest cursor', { serverLogs: [{ message: 'one' }], pageInfo: { hasNewer: true, newestCursor: ' ' } }], + ])('stops after one request for %s', async (_case, body: { serverLogs: unknown[]; pageInfo?: unknown }) => { + const { client, queries } = fakePagedClient([body, body]); + + const seen = await collect(new ServerLogsApi(client).pages(WINDOW)); + + expect(seen).toEqual([{ logs: body.serverLogs, pageInfo: body.pageInfo }]); + expect(queries).toHaveLength(1); + }); + + it('stops after exactly MAX_PAGES requests from a server that always has more, ending on a page that has more', async () => { + const endless = Array.from({ length: MAX_PAGES + 1 }, (_, index) => ({ + serverLogs: [{ message: `line ${index}` }], + pageInfo: { hasNewer: true, newestCursor: `[${index},"c"]` }, + })); + const { client, queries } = fakePagedClient(endless); + + const seen = (await collect(new ServerLogsApi(client).pages(WINDOW))) as { pageInfo: { newestCursor: string } }[]; + + expect(MAX_PAGES).toBe(100); + expect(queries).toHaveLength(MAX_PAGES); + expect(seen).toHaveLength(MAX_PAGES); + expect(seen.at(-1)?.pageInfo.newestCursor).toBe(`[${MAX_PAGES - 1},"c"]`); + }); + + it('yields the pages it read and then rejects with the failure of a later page', async () => { + const failure = new LaunchApiError(408, [{ code: 'launch.LOGS.QUERY_TIMEOUT', message: 'slow' }]); + const first = { serverLogs: [{ message: 'one' }], pageInfo: { hasNewer: true, newestCursor: '[1,"a"]' } }; + let calls = 0; + const client = { + request: async () => { + calls += 1; + return calls === 1 ? first : Promise.reject(failure); + }, + } as unknown as RestApiClient; + const seen: unknown[] = []; + + const walking = (async () => { + for await (const page of new ServerLogsApi(client).pages(WINDOW)) { + seen.push(page); + } + })(); + + await expect(walking).rejects.toBe(failure); + expect(seen).toEqual([{ logs: first.serverLogs, pageInfo: first.pageInfo }]); + expect(calls).toBe(2); + }); + + it.each([ + [{ search: 'timeout' }], + [{ cursor: '[1,"a"]' }], + ])('refuses a page without page info for %j, since an older logs service ignores both', async (extra) => { + const { client } = fakeRestClient({ serverLogs: [{ message: 'unfiltered' }] }); + + const reading = new ServerLogsApi(client).page({ ...WINDOW, ...extra }); - await expect(new ServerLogsApi(client).window(WINDOW)).resolves.toEqual([]); + await expect(reading).rejects.toThrow(LaunchApiError); + await expect(reading).rejects.toThrow( + 'The Launch API in this region does not support --search or --cursor for server logs yet. ' + + 'Run the command without them.', + ); }); it.each([[{}], [{ serverLogs: null }], [{ serverLogs: {} }], [null], ['text']])( @@ -60,7 +186,7 @@ describe('ServerLogsApi', () => { async (body) => { const { client } = fakeRestClient(body); - const reading = new ServerLogsApi(client).window(WINDOW); + const reading = new ServerLogsApi(client).page(WINDOW); await expect(reading).rejects.toThrow(LaunchApiError); await expect(reading).rejects.toThrow('The Launch API returned a server log page without a serverLogs array.'); @@ -71,6 +197,6 @@ describe('ServerLogsApi', () => { const failure = new LaunchApiError(502, [{ code: 'launch.UPSTREAM', message: 'Bad gateway' }]); const client = { request: async () => Promise.reject(failure) } as unknown as RestApiClient; - await expect(new ServerLogsApi(client).window(WINDOW)).rejects.toBe(failure); + await expect(new ServerLogsApi(client).page(WINDOW)).rejects.toBe(failure); }); }); diff --git a/src/logs/server-logs.api.ts b/src/logs/server-logs.api.ts index e0ea909e..566cff50 100644 --- a/src/logs/server-logs.api.ts +++ b/src/logs/server-logs.api.ts @@ -1,33 +1,73 @@ +import { MAX_PAGES } from '../core/constants'; import { DeploymentsApi, type GetDeploymentParams } from '../deployments/deployments.api'; -import { assertArray } from '../transport/envelope'; +import { isRecord } from '../core/values'; +import { assertArray, malformed } from '../transport/envelope'; import type { RestApiClient } from '../transport/rest-client'; import { LOGS_ERROR_MESSAGES } from './logs.errors'; -import type { ServerLog, ServerLogsResponse } from './types'; +import type { ServerLogsPage, ServerLogsResponse } from './types'; export * from './types'; export interface ServerLogsWindowParams extends GetDeploymentParams { startTime: number; endTime: number; + search?: string; + cursor?: string; +} + +export function nextCursorOf(page: ServerLogsPage): string | undefined { + const next = page.pageInfo?.newestCursor; + const hasNext = typeof next === 'string' && next.trim() !== ''; + + return page.pageInfo?.hasNewer === true && page.logs.length > 0 && hasNext ? next : undefined; } export class ServerLogsApi { constructor(private readonly client: RestApiClient) {} - async window(params: ServerLogsWindowParams): Promise { + async page(params: ServerLogsWindowParams): Promise { const response = await this.client.request( { method: 'GET', path: `${DeploymentsApi.pathOf(params)}/logs/server-logs`, orgUid: params.org, projectUid: params.project, - query: { startTime: params.startTime, endTime: params.endTime }, + query: { + startTime: params.startTime, + endTime: params.endTime, + sortDirection: 'asc', + ...(params.search !== undefined && { search: params.search }), + ...(params.cursor !== undefined && { cursor: params.cursor }), + }, }, LOGS_ERROR_MESSAGES, ); assertArray(response, 'serverLogs', 'server log page'); - return response.serverLogs; + if ((params.search !== undefined || params.cursor !== undefined) && !isRecord(response.pageInfo)) { + throw malformed( + 'The Launch API in this region does not support --search or --cursor for server logs yet. ' + + 'Run the command without them.', + ); + } + + return { logs: response.serverLogs, pageInfo: response.pageInfo }; + } + + async *pages(params: ServerLogsWindowParams): AsyncGenerator { + let cursor = params.cursor; + + for (let fetched = 0; fetched < MAX_PAGES; fetched += 1) { + const page = await this.page({ ...params, cursor }); + + yield page; + + cursor = nextCursorOf(page); + + if (cursor === undefined) { + return; + } + } } } diff --git a/src/logs/types.ts b/src/logs/types.ts index aaf9d153..4d6ae1f1 100644 --- a/src/logs/types.ts +++ b/src/logs/types.ts @@ -4,8 +4,22 @@ export interface ServerLog { timestamp?: string | number; message?: string; source?: string; + cursor?: string; +} + +export interface ServerLogsPageInfo { + hasOlder?: boolean | null; + hasNewer?: boolean | null; + oldestCursor?: string | null; + newestCursor?: string | null; } export interface ServerLogsResponse { serverLogs: ServerLog[]; + pageInfo?: ServerLogsPageInfo; +} + +export interface ServerLogsPage { + logs: ServerLog[]; + pageInfo?: ServerLogsPageInfo; } diff --git a/src/resources.test.ts b/src/resources.test.ts index 86765a55..0704999d 100644 --- a/src/resources.test.ts +++ b/src/resources.test.ts @@ -240,7 +240,7 @@ describe('the api surface', () => { const scope = { org: 'o1', project: 'p1', environment: 'e1', deployment: 'd1' }; await api.deploymentLogs.after({ ...scope, timestamp: '2026-09-25T10:00:00.000Z' }); - await api.serverLogs.window({ ...scope, startTime: 1, endTime: 2 }); + await api.serverLogs.page({ ...scope, startTime: 1, endTime: 2 }); await api.deployments.get(scope); expect(sent.logs.map((req) => req.path)).toEqual([ diff --git a/test/integration/logs-get.test.ts b/test/integration/logs-get.test.ts index f99f6934..3aad9c8b 100644 --- a/test/integration/logs-get.test.ts +++ b/test/integration/logs-get.test.ts @@ -98,8 +98,8 @@ function buildLogs(lines: unknown[]): nock.Scope { function serverLogs(window: { startTime: number; endTime: number }, entries: unknown[]): nock.Scope { return hub() .get(SERVER_LOGS) - .query({ startTime: String(window.startTime), endTime: String(window.endTime) }) - .reply(200, { serverLogs: entries }); + .query({ startTime: String(window.startTime), endTime: String(window.endTime), sortDirection: 'asc' }) + .reply(200, { serverLogs: entries, pageInfo: { hasNewer: false } }); } const SESSION_FRAME = 'event: session\ndata: {"expiresAt":"2026-10-28T12:05:00.000Z"}\n\n'; @@ -497,7 +497,7 @@ describe('integration: launch:logs:get on the wire', () => { deploymentGet('LIVE'); const read = hub() .get(SERVER_LOGS) - .query({ startTime: String(startTime), endTime: String(endTime) }) + .query({ startTime: String(startTime), endTime: String(endTime), sortDirection: 'asc' }) .reply(200, { serverLogs: [ { timestamp: '2026-10-28T09:15:00.123Z', message: 'GET /index 200', source: 'edge-fn-source' }, @@ -567,27 +567,96 @@ describe('integration: launch:logs:get on the wire', () => { ]); }); - it.each([ - [5000, true], - [4999, false], - ])('T54: with %i server lines the cap notice shows is %s', async (count, shows) => { + it('T54: walks two pages on the wire, carrying the newest cursor and the same window, and prints them in order', async () => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + const window = { startTime: String(NOW - 3_600_000), endTime: String(NOW), sortDirection: 'asc', search: 'timeout' }; + const firstCursor = `[${NOW - 1_800_000},"${DEPLOYMENT_UID}"]`; + ownedProject(); + deploymentGet('LIVE'); + const first = hub() + .get(SERVER_LOGS) + .query(window) + .reply(200, { + serverLogs: [{ timestamp: '2026-10-28T11:30:00.000Z', message: 'upstream timeout one' }], + pageInfo: { hasOlder: null, hasNewer: true, oldestCursor: firstCursor, newestCursor: firstCursor }, + }); + const second = hub() + .get(SERVER_LOGS) + .query({ ...window, cursor: firstCursor }) + .reply(200, { + serverLogs: [{ timestamp: '2026-10-28T11:45:00.000Z', message: 'upstream timeout two' }], + pageInfo: { hasOlder: null, hasNewer: false, oldestCursor: null, newestCursor: null }, + }); + + const { error, stdout } = await runCommand( + getArgs('--deployment', DEPLOYMENT_UID, '--type', 'server', '--search', ' timeout '), + config, + ); + + expect(error).toBeUndefined(); + expect(first.isDone()).toBe(true); + expect(second.isDone()).toBe(true); + expect(outputLines(stdout)).toEqual([ + '2026-10-28 11:30:00.000: upstream timeout one', + '2026-10-28 11:45:00.000: upstream timeout two', + ]); + expect(onWire).toEqual([]); + }); + + it('stops a server that always has more after 100 requests and prints a resume command with an absolute window', async () => { jest.spyOn(Date, 'now').mockReturnValue(NOW); + let reads = 0; ownedProject(); deploymentGet('LIVE'); - serverLogs( - { startTime: NOW - 3_600_000, endTime: NOW }, - Array.from({ length: count }, (_, index) => ({ timestamp: '2026-10-28T11:30:00.000Z', message: `line ${index}` })), + hub() + .get(SERVER_LOGS) + .query(true) + .times(101) + .reply(200, () => { + reads += 1; + const cursor = `[${NOW - 3_600_000 + reads},"${DEPLOYMENT_UID}"]`; + return { + serverLogs: [{ timestamp: NOW - 3_600_000 + reads, message: `line ${reads}` }], + pageInfo: { hasNewer: true, newestCursor: cursor }, + }; + }); + + const { error, stdout } = await runCommand( + getArgs('--deployment', DEPLOYMENT_UID, '--type', 'server', '--since', '1h'), + config, ); + const lines = outputLines(stdout); + + expect(error).toBeUndefined(); + expect(reads).toBe(100); + expect(lines).toHaveLength(102); + expect(lines.slice(-2)).toEqual([ + 'Stopped after 100 pages of server logs; more remain in this window.', + `Resume with: csdx launch:logs:get --org ${ORG_UID} --project ${PROJECT_UID} --env ${ENV_UID} ` + + `--deployment ${DEPLOYMENT_UID} --type server --since 2026-10-28T11:00:00.000Z ` + + `--until 2026-10-28T12:00:00.000Z --cursor '[${NOW - 3_600_000 + 100},"${DEPLOYMENT_UID}"]'`, + ]); + }); + + it('T54: keeps the 5000-line notice when a logs service before CL-7180 returns a full page with no page info', async () => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + ownedProject(); + deploymentGet('LIVE'); + const read = hub() + .get(SERVER_LOGS) + .query({ startTime: String(NOW - 3_600_000), endTime: String(NOW), sortDirection: 'asc' }) + .reply(200, { + serverLogs: Array.from({ length: 5000 }, (_, index) => ({ timestamp: '2026-10-28T11:30:00.000Z', message: `line ${index}` })), + }); const { error, stdout } = await runCommand(getArgs('--deployment', DEPLOYMENT_UID, '--type', 'server'), config); const lines = outputLines(stdout); expect(error).toBeUndefined(); - expect(lines).toHaveLength(shows ? count + 1 : count); + expect(read.isDone()).toBe(true); + expect(lines).toHaveLength(5001); expect(lines.at(-1)).toBe( - shows - ? 'Showing the newest 5000 server log lines in this window. Narrow --since/--until to see earlier ones.' - : `2026-10-28 11:30:00.000: line ${count - 1}`, + 'Showing the newest 5000 server log lines in this window. Narrow --since/--until to see earlier ones.', ); }); @@ -662,6 +731,53 @@ describe('integration: launch:logs:get on the wire', () => { expect(onWire).toEqual([]); }, 20_000); + it('words a field-keyed 400 for a foreign --cursor in the CLI own words, sends the cursor as given, exit 1', async () => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + ownedProject(); + deploymentGet('LIVE'); + const read = hub() + .get(SERVER_LOGS) + .query({ startTime: String(NOW - 3_600_000), endTime: String(NOW), sortDirection: 'asc', cursor: 'garbage' }) + .reply(400, { + errors: [{ cursor: { code: 'launch.SERVERLOGS.INVALID_CURSOR', message: 'cursor is not valid' } }], + status: 400, + }); + + const { error, stdout } = await runCommand( + getArgs('--deployment', DEPLOYMENT_UID, '--type', 'server', '--cursor', 'garbage'), + config, + ); + + expect(read.isDone()).toBe(true); + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe('That cursor was not produced by launch:logs:get. Run the command again without --cursor.'); + expect(stdout).toBe(''); + expect(onWire).toEqual([]); + }); + + it('exits 1 without printing unfiltered logs when a logs service before CL-7180 ignores --search', async () => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + ownedProject(); + deploymentGet('LIVE'); + const read = hub() + .get(SERVER_LOGS) + .query({ startTime: String(NOW - 3_600_000), endTime: String(NOW), sortDirection: 'asc', search: 'timeout' }) + .reply(200, { serverLogs: [{ timestamp: '2026-10-28T11:30:00.000Z', message: 'GET / 200' }] }); + + const { error, stdout } = await runCommand( + getArgs('--deployment', DEPLOYMENT_UID, '--type', 'server', '--search', 'timeout'), + config, + ); + + expect(read.isDone()).toBe(true); + expect(error?.oclif?.exit).toBe(1); + expect(error?.message).toBe( + 'The Launch API in this region does not support --search or --cursor for server logs yet. ' + + 'Run the command without them.', + ); + expect(stdout).toBe(''); + }); + it('T58: prints a fallback for server entries missing their timestamp, message or everything', async () => { jest.spyOn(Date, 'now').mockReturnValue(NOW); ownedProject(); @@ -680,10 +796,11 @@ describe('integration: launch:logs:get on the wire', () => { expect(stdout.split('\n')).toEqual(['', 'no time', '2026-10-28 11:00:00.000:', '2026-10-28 11:00:01.000: epoch', '', '']); }); - it('T59: a server run registers no SIGINT handler and starts no spinner', async () => { + it('T59: stops a server walk on Ctrl-C with a resume command and exit 3, no spinner, no listener left', async () => { const restoreStdout = stdoutReportingTTY(true); const loader = jest.spyOn(cliux, 'loaderV2'); const baseline = process.listenerCount('SIGINT'); + const firstCursor = `[${NOW - 1_800_000},"${DEPLOYMENT_UID}"]`; let during = -1; try { @@ -692,16 +809,29 @@ describe('integration: launch:logs:get on the wire', () => { deploymentGet('LIVE'); hub() .get(SERVER_LOGS) - .query(true) + .query({ startTime: String(NOW - 3_600_000), endTime: String(NOW), sortDirection: 'asc' }) .reply(200, () => { during = process.listenerCount('SIGINT'); - return { serverLogs: [{ timestamp: '2026-10-28T11:30:00.000Z', message: 'hi' }] }; + setImmediate(() => process.emit('SIGINT')); + return { + serverLogs: [{ timestamp: '2026-10-28T11:30:00.000Z', message: 'hi' }], + pageInfo: { hasNewer: true, newestCursor: firstCursor }, + }; }); + const second = hub().get(SERVER_LOGS).query(true).reply(200, { serverLogs: [], pageInfo: { hasNewer: false } }); - const { error } = await runCommand(getArgs('--deployment', DEPLOYMENT_UID, '--type', 'server'), config); + const { error, stdout } = await runCommand(getArgs('--deployment', DEPLOYMENT_UID, '--type', 'server'), config); - expect(error).toBeUndefined(); - expect(during).toBe(baseline); + expect(error?.oclif?.exit).toBe(3); + expect(outputLines(stdout)).toEqual([ + expect.stringMatching(/^\d{2}:\d{2}:\d{2} {2}hi$/), + 'Stopped following the logs.', + `Resume with: csdx launch:logs:get --org ${ORG_UID} --project ${PROJECT_UID} --env ${ENV_UID} ` + + `--deployment ${DEPLOYMENT_UID} --type server --since 2026-10-28T11:00:00.000Z ` + + `--until 2026-10-28T12:00:00.000Z --cursor '${firstCursor}'`, + ]); + expect(second.isDone()).toBe(false); + expect(during).toBe(baseline + 1); expect(process.listenerCount('SIGINT')).toBe(baseline); expect(loader).not.toHaveBeenCalled(); } finally { @@ -1032,6 +1162,8 @@ describe('integration: launch:logs:get on the wire', () => { it.each([ [['--since', '1h'], '--since is only supported when --type is one of server; --type is deployment.'], [['--until', '1h', '--type', 'live'], '--until is only supported when --type is one of server; --type is live.'], + [['--search', 'timeout'], '--search is only supported when --type is one of server; --type is deployment.'], + [['--cursor', '[1,"a"]', '--type', 'live'], '--cursor is only supported when --type is one of server; --type is live.'], [['--since', '12hr', '--until', '2026-10-28T10:00:00Z', '--type', 'server'], '--since and --until must both be relative (like 12hr) or both absolute (ISO 8601) timestamps.'], [['--since', '30m', '--until', '2h', '--type', 'server'], '--since must not be later than --until.'], ])('T61: exits 2 for %j with nothing read', async (flags, message) => {