Skip to content

Commit aa7dd5e

Browse files
Brooooooklynclaude
andauthored
feat(cli): summarize the deploy trace when a deploy fails (#294)
## The problem Every `void deploy` writes a JSONL trace to `~/.void/logs/`. On failure the CLI printed its path: ``` ■ deploy: Deploy failed: deploy in progress │ Deployment: dpl_7zgitxdrxxz9 │ Detailed log: /home/runner/.void/logs/deploy-2026-08-21T03-24-19-764Z.jsonl ``` That runner is destroyed with the job, so the file is unreachable exactly where the trace matters most. Seen in [vite-plus#2511](https://github.com/voidzero-dev/vite-plus/actions/runs/32443212608/job/96657870407?pr=2511). ## The change `runDeploy` closes the logger, reads the file back, and appends a summary to the error message. ``` ■ deploy: Deploy failed: deploy in progress │ Deployment: dpl_7zgitxdrxxz9 │ Detailed log: /home/runner/.void/logs/deploy-2026-08-21T03-24-19-764Z.jsonl │ │ Errors │ 9.0s deploy_server_error │ deploymentId=dpl_7zgitxdrxxz9 │ message=deploy in progress │ err=PlatformApiError: Deploy failed (code=409) -> Error: another deployment... │ │ Last 20 of 26 entries │ 8.4s info finalize_start assets=98 workers=0 │ 8.4s info deploy_post url=https://api.void.app/projects/prj_9k1/deploy │ 8.6s info stream_deployment_id deploymentId=dpl_7zgitxdrxxz9 │ 9.0s error deploy_server_error message=deploy in progress │ 9.0s error deploy_end ok=false err=Error: deploy: Deploy failed... ``` Two blocks: every `error` record with its flattened cause chain, then the last 20 records as a timeline. It prints on every failure, in CI and locally. ## Redaction Void masks the credentials it emits itself, because the summary reaches CI logs that the trace file never did. | Pattern | Catches | | --- | --- | | Signed query parameters | `?X-Amz-Signature=…`, `&token=…` | | Bearer tokens | `Authorization: Bearer …` | | Field key names a credential | `apiToken`, `sessionCookie`, `privateKey` | Masking your own values is left to your CI platform, which holds the secrets and masks them before the log is written. GitHub Actions does this for everything under `secrets.*`. Void does not guess at credential-shaped variable names, and it does not parse credentials out of values you supplied. A password inside a `DATABASE_URL` in your build command prints as written. The docs say so, and say to register such values as CI secrets instead. `auth` is likewise absent from the key list — it matches metadata keys such as `authMethod` far more often than credentials. ## Safety A postmortem aid must never replace the failure it describes. - `formatErrorNodeInline` coerces every part of a parsed record instead of trusting its declared shape. A record with `err.message: 123` used to throw `TypeError: t.replace is not a function`. - The enrichment is wrapped in `try`/`catch`. `message` is read-only on a frozen `Error` and getter-only on a `DOMException`, which an aborted fetch produces, so the assignment alone can throw. Losing the summary is fine; losing the deploy error is not. ## Not covered Pre-flight failures print no summary. There are 73 `exit()` sites reachable inside `runDeploy` — a missing project, a rejected flag combination, an unsupported `--backend cloudflare` feature. Each one calls `log.error()` with a full, hand-written message first, and each fires before any deploy work happens, so there is no trace to summarize. `deps.exit` is an injected seam the tests rely on. A build failure prints no summary either: `execSync` runs with `stdio: 'inherit'`, so the compiler output is already on the terminal in full. Routing those sites through one finalization boundary would change exit codes and output across the Cloudflare backend, auth, and validation. That belongs in its own PR. ## Files | File | Change | | --- | --- | | `packages/void/src/cli/log-summary.ts` | New. Pure formatter plus a file reader that returns `null` on any failure. | | `packages/void/src/cli/log-file.ts` | Export `ErrChainNode`; add `formatErrorNodeInline` for already-serialized nodes. | | `packages/void/src/cli/deploy.ts` | Close, read, append, rethrow in `runDeploy`'s catch. | | `packages/void/test/unit/deploy-log-summary.test.ts` | New. 20 tests. | | `docs/reference/cli.md` | Document the summary. | ## Verification - `vp run check` — clean - `vp lint` — clean - `vp fmt` — no changes - `vp test run packages/void` — 3787 passed, 14 skipped One browser file, `packages/react/test/integration/react-pages-client.test.ts`, timed out on hydration in a full local run (31 of 93 tests, `page.waitForSelector('#app[data-hydrated="true"]')`). `deploy.ts` already imported `node:fs` and `picocolors`, and `log-summary.ts` has no top-level side effects, so this diff adds nothing executable to the graph that test loads. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01YZ88Y4fzBDSBBserEfvBrB <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > The summary is appended to stderr/CI logs, so incomplete redaction could leak credentials. Failures in summarization are swallowed so they cannot replace the original deploy error. > > **Overview** > On a failed `void deploy`, the CLI now flushes the JSONL trace, reads it back, and **appends a summary to the error message** so the cause is visible in CI after the runner (and `~/.void/logs/`) is gone. > > The summary has two blocks: up to five `error` records with flattened cause chains (skipping the terminal `deploy_end` echo), then the last 20 records as a timeline. Pre-flight and build failures still print no summary. > > Credentials the CLI emits itself are masked: signed query params, bearer tokens, and fields whose keys look like secrets. User-supplied values (e.g. a password inside `DATABASE_URL` in a build command) are left as-is for the CI secret masker. Summarization is wrapped so a read-only `Error.message` or a corrupt log cannot hide the original failure. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 38b9eab. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent cc128cf commit aa7dd5e

5 files changed

Lines changed: 616 additions & 1 deletion

File tree

‎docs/reference/cli.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,30 @@ For Drizzle projects, deploy performs a read-only schema drift check. If a new m
271271

272272
Every deploy writes a structured JSONL trace to `~/.void/logs/deploy-<timestamp>.jsonl` regardless of `--debug`. On failure the path is printed at the end of the error message so you can attach it when reporting platform issues. `VOID_DEPLOY_DEBUG=1` is accepted as an alternate trigger for stderr mirroring.
273273

274+
When a deploy fails after it starts, the CLI also prints a summary of that trace under the error, so the cause is visible where the file is not — a CI runner, for example, is discarded with the job. The summary has two blocks: every `error` record with its flattened cause chain, then the last 20 records as a timeline.
275+
276+
Pre-flight failures print no summary. A missing project, a rejected flag combination, or an unsupported `--backend cloudflare` feature stops before any trace exists, and each of those prints its own message explaining what to change. A build failure prints no summary either — the build streams its own output straight to the terminal.
277+
278+
Void masks the credentials it emits itself: signed query parameters, bearer tokens, and any field whose key names a credential.
279+
280+
Masking your own values is left to your CI platform, which holds the secrets and masks them before the log is written. GitHub Actions does this for everything under `secrets.*`. Void does not guess at credential-shaped variable names, and it does not parse credentials out of values you supplied — a password inside a `DATABASE_URL` in your build command prints as written. Register such values as CI secrets, or keep them out of the build command.
281+
282+
```
283+
■ deploy: Deploy failed: deploy in progress
284+
│ Deployment: dpl_7zgitxdrxxz9
285+
│ Detailed log: ~/.void/logs/deploy-2026-08-21T03-24-19-764Z.jsonl
286+
│
287+
│ Errors
288+
│ 9.0s deploy_server_error
289+
│ deploymentId=dpl_7zgitxdrxxz9
290+
│ message=deploy in progress
291+
│
292+
│ Last 20 of 26 entries
293+
│ 8.4s info finalize_start assets=98 workers=0
294+
│ 8.6s info stream_deployment_id deploymentId=dpl_7zgitxdrxxz9
295+
│ 9.0s error deploy_server_error message=deploy in progress
296+
```
297+
274298
Project resolution precedence:
275299

276300
1. `--project <name>`

‎packages/void/src/cli/deploy.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ import {
111111
} from './github-oidc.ts';
112112
import { lintDestinationSplats, lintDuplicateSources, mergeRoutingRules } from './headers.ts';
113113
import { openDeployLog, type CliLogger } from './log-file.ts';
114+
import { buildDeployLogSummary } from './log-summary.ts';
114115
import { promptForLoginToken } from './login.ts';
115116
import { cliTitle, createSpinner, pc } from './output.ts';
116117
import {
@@ -423,6 +424,23 @@ export async function runDeploy(root: string, options?: RunDeployOptions): Promi
423424
cliLog.info('deploy_end', { ok: true });
424425
} catch (err) {
425426
cliLog.error('deploy_end', err, { ok: false });
427+
// Flush before reading the file back. The `Detailed log:` path is useless
428+
// on a CI runner that is destroyed with the job, so inline the errors and
429+
// the trailing timeline into the message the caller prints.
430+
await cliLog.close().catch(() => {});
431+
try {
432+
if (err instanceof Error) {
433+
const summary = buildDeployLogSummary(cliLog.path);
434+
if (summary) {
435+
err.message = `${err.message}\n${summary}`;
436+
}
437+
}
438+
} catch {
439+
// A postmortem aid must never replace the failure it describes. The
440+
// assignment alone can throw: `message` is read-only on a frozen Error
441+
// and getter-only on a DOMException, which an aborted fetch produces.
442+
// Losing the summary is fine; losing the deploy error is not.
443+
}
426444
throw err;
427445
} finally {
428446
await cliLog.close().catch(() => {});

‎packages/void/src/cli/log-file.ts‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,51 @@ export type CliLogger = {
1515
close(): Promise<void>;
1616
};
1717

18-
type ErrChainNode = {
18+
export type ErrChainNode = {
1919
name?: string;
2020
message?: string;
2121
code?: string | number;
2222
errno?: string | number;
2323
cause?: ErrChainNode;
2424
};
2525

26+
/**
27+
* Flatten an already-serialized `ErrChainNode` (as read back out of a JSONL
28+
* record) to a single inline string. Mirrors `formatErrorChainInline`, which
29+
* does the same job for a live `Error` before it is written.
30+
*/
31+
export function formatErrorNodeInline(
32+
node: ErrChainNode | string | null | undefined,
33+
depth = 0,
34+
): string {
35+
// `typeof null` is `'object'`, so the null check has to come first.
36+
if (node == null || typeof node !== 'object') {
37+
return String(node);
38+
}
39+
// A JSONL record is data, not a typed value. Coerce every part instead of
40+
// trusting the declared shape: a hand-edited log, or an `Error` subclass that
41+
// assigned a non-string `message`, must not make a postmortem aid throw.
42+
const message = node.message == null ? '' : String(node.message);
43+
if (depth > 5) {
44+
return message;
45+
}
46+
let head = node.name ? `${String(node.name)}: ${message}` : message;
47+
const tags: Array<string> = [];
48+
if (node.code !== undefined) {
49+
tags.push(`code=${node.code}`);
50+
}
51+
if (node.errno !== undefined) {
52+
tags.push(`errno=${node.errno}`);
53+
}
54+
if (tags.length > 0) {
55+
head = `${head} (${tags.join(' ')})`;
56+
}
57+
if (node.cause != null) {
58+
return `${head} -> ${formatErrorNodeInline(node.cause, depth + 1)}`;
59+
}
60+
return head;
61+
}
62+
2663
/**
2764
* Walk `err.cause` up to 6 levels and return a plain JSON-friendly chain.
2865
* Skips stack traces deliberately — keeps output line-oriented and readable.
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { readFileSync } from 'node:fs';
2+
import pc from 'picocolors';
3+
import { type ErrChainNode, formatErrorNodeInline } from './log-file.ts';
4+
5+
/** How many trailing records the tail block shows. */
6+
export const LOG_TAIL_LIMIT = 20;
7+
/** How many error records the error block shows. */
8+
export const LOG_ERROR_LIMIT = 5;
9+
10+
const FIELD_VALUE_MAX = 80;
11+
const LINE_MAX = 160;
12+
const MSG_COLUMN_MAX = 28;
13+
const ELAPSED_WIDTH = 7;
14+
const LEVEL_WIDTH = 5;
15+
/** Width of `' ' + elapsed + ' '`, used to indent the error sub-lines. */
16+
const DETAIL_INDENT = ' '.repeat(2 + ELAPSED_WIDTH + 2);
17+
18+
/**
19+
* `runDeploy` writes this record last, and its `err` field is the very message
20+
* printed above the summary. Keeping it out of the error block avoids echoing
21+
* the same text twice; it still shows up as one line in the tail.
22+
*/
23+
const TERMINAL_ERROR_MSG = 'deploy_end';
24+
25+
const RESERVED_KEYS = new Set(['ts', 'elapsedMs', 'lvl', 'msg', 'err']);
26+
27+
/**
28+
* The trace was a file the user opened by hand; the summary publishes it to
29+
* whatever reads stderr, which in CI is a stored, often public, log.
30+
*
31+
* Scrubbing here covers only credentials this code emits itself — a presigned
32+
* URL it built, an auth header it set, a field it named. Scrubbing user data is
33+
* left to the CI platform, which owns the secrets and masks them before the log
34+
* is written. GitHub Actions does this for everything under `secrets.*`. Void
35+
* does not guess at credential-shaped names or at credentials embedded in
36+
* values the user supplied.
37+
*/
38+
/** Query parameters whose value must never reach a CI log. */
39+
const SIGNED_QUERY_RE =
40+
/([?&](?:X-Amz-[A-Za-z0-9-]+|[Ss]ignature|sig|token|access_token|key)=)[^&\s]+/g;
41+
const BEARER_RE = /\b(Bearer\s+)\S+/g;
42+
/**
43+
* Field keys whose value is masked whole, whatever it looks like. `auth` is
44+
* deliberately absent — it matches metadata keys such as `authMethod` far more
45+
* often than credentials.
46+
*/
47+
const SECRET_KEY_RE =
48+
/token|secret|password|passwd|credential|cookie|signature|api[-_]?key|private[-_]?key/i;
49+
50+
type LogRecord = {
51+
elapsedMs?: number;
52+
lvl?: string;
53+
msg?: string;
54+
err?: ErrChainNode | string;
55+
[key: string]: unknown;
56+
};
57+
58+
function redact(text: string): string {
59+
return text.replace(SIGNED_QUERY_RE, '$1[redacted]').replace(BEARER_RE, '$1[redacted]');
60+
}
61+
62+
function truncate(text: string, max: number): string {
63+
return text.length <= max ? text : `${text.slice(0, max)}...`;
64+
}
65+
66+
/** Collapse every run of whitespace so one record always renders as one line. */
67+
function oneLine(text: string): string {
68+
return text.replace(/\s+/g, ' ').trim();
69+
}
70+
71+
function stringifyValue(value: unknown): string {
72+
if (typeof value === 'string') {
73+
return value;
74+
}
75+
if (value === null || typeof value === 'number' || typeof value === 'boolean') {
76+
return String(value);
77+
}
78+
try {
79+
return JSON.stringify(value) ?? String(value);
80+
} catch {
81+
return String(value);
82+
}
83+
}
84+
85+
/** Render one `key=value` pair, collapsed, redacted, and length-capped. */
86+
function formatField(key: string, value: unknown): string {
87+
if (SECRET_KEY_RE.test(key)) {
88+
return `${key}=[redacted]`;
89+
}
90+
return `${key}=${truncate(redact(oneLine(stringifyValue(value))), FIELD_VALUE_MAX)}`;
91+
}
92+
93+
function fieldPairs(record: LogRecord): Array<string> {
94+
const pairs: Array<string> = [];
95+
for (const [key, value] of Object.entries(record)) {
96+
if (RESERVED_KEYS.has(key) || value === undefined) {
97+
continue;
98+
}
99+
pairs.push(formatField(key, value));
100+
}
101+
if (record.err !== undefined) {
102+
pairs.push(
103+
`err=${truncate(redact(oneLine(formatErrorNodeInline(record.err))), FIELD_VALUE_MAX)}`,
104+
);
105+
}
106+
return pairs;
107+
}
108+
109+
function formatElapsed(elapsedMs: unknown): string {
110+
const ms = typeof elapsedMs === 'number' && Number.isFinite(elapsedMs) ? elapsedMs : 0;
111+
return `${(ms / 1000).toFixed(1)}s`.padStart(ELAPSED_WIDTH);
112+
}
113+
114+
function colorLevel(level: string, padded: string): string {
115+
if (level === 'error') {
116+
return pc.red(padded);
117+
}
118+
if (level === 'warn') {
119+
return pc.yellow(padded);
120+
}
121+
// `info` and `debug` are context, not signal — dim them so the red and
122+
// yellow rows stand out in a wall of CI output.
123+
return pc.dim(padded);
124+
}
125+
126+
function parseRecords(lines: Array<string>): Array<LogRecord> {
127+
const records: Array<LogRecord> = [];
128+
for (const raw of lines) {
129+
const trimmed = raw.trim();
130+
if (!trimmed) {
131+
continue;
132+
}
133+
try {
134+
const parsed: unknown = JSON.parse(trimmed);
135+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
136+
records.push(parsed as LogRecord);
137+
}
138+
} catch {
139+
// A partially flushed or corrupt line must not hide the rest of the log.
140+
}
141+
}
142+
return records;
143+
}
144+
145+
/** One aligned line: ` 8.9s error deploy_server_error message=...`. */
146+
function formatTailLine(record: LogRecord, msgWidth: number): string {
147+
const level = typeof record.lvl === 'string' ? record.lvl : 'info';
148+
const msg = typeof record.msg === 'string' ? record.msg : '';
149+
const elapsed = formatElapsed(record.elapsedMs);
150+
const paddedLevel = level.padEnd(LEVEL_WIDTH);
151+
const paddedMsg = msg.padEnd(msgWidth);
152+
const head = ` ${elapsed} ${paddedLevel} ${paddedMsg}`;
153+
const fields = fieldPairs(record).join(' ');
154+
if (!fields) {
155+
return ` ${elapsed} ${colorLevel(level, paddedLevel)} ${msg}`;
156+
}
157+
const budget = Math.max(20, LINE_MAX - head.length);
158+
return ` ${elapsed} ${colorLevel(level, paddedLevel)} ${paddedMsg} ${truncate(fields, budget)}`;
159+
}
160+
161+
/** An error entry: a headline, then one indented line per field. */
162+
function formatErrorEntry(record: LogRecord): Array<string> {
163+
const msg = typeof record.msg === 'string' ? record.msg : 'error';
164+
const lines = [` ${formatElapsed(record.elapsedMs)} ${pc.red(msg)}`];
165+
for (const pair of fieldPairs(record)) {
166+
lines.push(`${DETAIL_INDENT}${pair}`);
167+
}
168+
return lines;
169+
}
170+
171+
/**
172+
* Turn raw JSONL lines into the block appended to a failed deploy's error
173+
* message. Returns `null` when there is nothing worth printing.
174+
*
175+
* The block has two parts: every `error` record with its flattened cause
176+
* chain, then the last `LOG_TAIL_LIMIT` records for timeline context.
177+
*/
178+
export function formatDeployLogSummary(lines: Array<string>): string | null {
179+
const records = parseRecords(lines);
180+
if (records.length === 0) {
181+
return null;
182+
}
183+
184+
const blocks: Array<string> = [];
185+
186+
const errors = records.filter((r) => r.lvl === 'error' && r.msg !== TERMINAL_ERROR_MSG);
187+
if (errors.length > 0) {
188+
const shown = errors.slice(0, LOG_ERROR_LIMIT);
189+
const errorLines = shown.flatMap((record) => formatErrorEntry(record));
190+
if (errors.length > shown.length) {
191+
errorLines.push(`${DETAIL_INDENT}(+${errors.length - shown.length} more)`);
192+
}
193+
blocks.push([pc.bold('Errors'), ...errorLines].join('\n'));
194+
}
195+
196+
const tail = records.slice(-LOG_TAIL_LIMIT);
197+
const msgWidth = Math.min(
198+
MSG_COLUMN_MAX,
199+
tail.reduce((max, r) => Math.max(max, typeof r.msg === 'string' ? r.msg.length : 0), 0),
200+
);
201+
const heading =
202+
records.length > tail.length
203+
? `Last ${LOG_TAIL_LIMIT} of ${records.length} entries`
204+
: `Log (${records.length} ${records.length === 1 ? 'entry' : 'entries'})`;
205+
blocks.push(
206+
[pc.bold(heading), ...tail.map((record) => formatTailLine(record, msgWidth))].join('\n'),
207+
);
208+
209+
return `\n${blocks.join('\n\n')}`;
210+
}
211+
212+
/**
213+
* Read a deploy JSONL log and format its summary. Returns `null` for a missing
214+
* path or any read failure — a postmortem aid must never mask the real error.
215+
*/
216+
export function buildDeployLogSummary(path: string | null | undefined): string | null {
217+
if (!path) {
218+
return null;
219+
}
220+
let text: string;
221+
try {
222+
text = readFileSync(path, 'utf-8');
223+
} catch {
224+
return null;
225+
}
226+
return formatDeployLogSummary(text.split('\n'));
227+
}

0 commit comments

Comments
 (0)