Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/reference/cli/project.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ void project logs --level error --range 12h
void project logs --level error --filter websocket
```

Logs include top-level `console.*` calls and uncaught errors captured by Cloudflare Tail. If you catch an error and save it only to your database, it won't appear here. Also log it with `console.error()` or `logger.error()` from `void/log`.
Logs include top-level `console.*` calls and uncaught errors captured by Cloudflare Tail. If you catch an error and save it only to your database, it won't appear here. Also log it with `console.error()` or `logger.error()` from `void/log`. On hosted Void targets, pass the `Error` object itself to `console.error()`, as in `console.error('Save failed:', error)`, to see its stack trace under the log line. Logging only `error.message` shows no stack.

For errors that never reach your Worker, such as edge routing errors or static site requests, use `void project requests --status 5xx`.

Expand Down
5 changes: 4 additions & 1 deletion packages/deploy-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ export type RedirectRule = {
export type AssetConfig = {
not_found_handling?: 'none' | 'single-page-application' | '404-page';
html_handling?: 'auto-trailing-slash' | 'force-trailing-slash' | 'drop-trailing-slash' | 'none';
/** Native Cloudflare Workers Assets routing; ignored by managed dispatch. */
/**
* Native Cloudflare Workers Assets routing. Managed dispatch does not route
* by it; it reads it only to tell whether the app's worker answered a request.
*/
run_worker_first?: boolean | Array<string>;
};

Expand Down
9 changes: 7 additions & 2 deletions packages/void/src/cli/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,8 +353,13 @@ export type SecretResponse = {
export type LogEntry = {
timestamp: number;
outcome: string;
logs: Array<{ level: string; message: Array<unknown>; timestamp: number }>;
exceptions: Array<{ name: string; message: string; timestamp: number }>;
logs: Array<{
level: string;
message: Array<unknown>;
timestamp: number;
errorInfo?: Array<{ name: string; message: string; stack?: string } | null>;
}>;
exceptions: Array<{ name: string; message: string; timestamp: number; stack?: string }>;
url: string;
method: string;
status: string;
Expand Down
28 changes: 25 additions & 3 deletions packages/void/src/cli/logs.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { outro, log, wrapTextWithPrefix } from '@voidzero-dev/vite-plus-prompts';
import { getToken } from './auth.ts';
import { PlatformClient, type LogEntry } from './client.ts';
import { dim, formatCount, pc } from './output.ts';
import { dim, errorStackFrames, formatCount, pc } from './output.ts';
import { readDeploymentPlatform, readProjectConfig } from './project.ts';
import { sanitizeTerminalLogText } from './terminal-log-text.ts';

Expand Down Expand Up @@ -109,7 +109,7 @@ function printEntry(entry: LogEntry): void {
);

for (const l of entry.logs) {
const text = sanitizeTerminalLogText(l.message.map(String).join(' '));
const text = formatLogMessage(l);
const level = l.level ?? 'log';
const marker =
level === 'error'
Expand All @@ -126,14 +126,36 @@ function printEntry(entry: LogEntry): void {
console.log(
wrapTextWithPrefix(
process.stdout,
sanitizeTerminalLogText(`${ex.name}: ${ex.message}`),
[
sanitizeTerminalLogText(`${ex.name}: ${ex.message}`),
...errorStackFrames(ex).map((frame) => dim(sanitizeTerminalLogText(frame))),
].join('\n'),
' '.repeat(14),
` ${pc.red('[exception]')} `,
),
);
}
}

/**
* Join a console call's arguments; an argument that was an `Error` is followed
* by its dimmed stack frames. Arguments and frames are app data, so each is
* sanitized before the dim style is added.
*/
function formatLogMessage(log: LogEntry['logs'][number]): string {
let text = '';
let separator = '';
for (const [index, value] of log.message.entries()) {
text += `${separator}${sanitizeTerminalLogText(String(value))}`;
const frames = errorStackFrames(log.errorInfo?.[index]);
if (frames.length > 0) {
text += `\n${frames.map((frame) => dim(sanitizeTerminalLogText(frame))).join('\n')}`;
}
separator = frames.length > 0 ? '\n' : ' ';
}
return text;
}

function formatTime(ts: number): string {
const d = new Date(ts);
return d.toLocaleTimeString('en-GB', { hour12: false });
Expand Down
23 changes: 23 additions & 0 deletions packages/void/src/cli/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,26 @@ export function ensureUtc(s: string): string {
}
return s + 'Z';
}

/**
* Stack frames of an error a Worker reported, one line per frame. The log or
* exception line already shows `Name: message`, so a leading copy of that
* header is dropped; Workers send stacks both with and without it.
*/
export function errorStackFrames(error: unknown): Array<string> {
if (!error || typeof error !== 'object') {
return [];
}
const { name, message, stack } = error as { name?: unknown; message?: unknown; stack?: unknown };
if (typeof stack !== 'string') {
return [];
}
const header = message ? `${String(name)}: ${String(message)}` : String(name);
const frames =
stack === header
? ''
: stack.startsWith(`${header}\n`)
? stack.slice(header.length + 1)
: stack;
return frames.trimEnd() ? frames.trimEnd().split('\n') : [];
}
32 changes: 23 additions & 9 deletions packages/void/src/router/compile.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fileURLToPath } from 'node:url';
import { relative } from 'pathslash';
import type { VoidAuthProvider } from '../auth/providers.ts';
import type { DevRedirectRule, HeaderRule } from '../cli/types.ts';
import type { NotFoundHandling } from '../config.ts';
Expand Down Expand Up @@ -478,25 +479,38 @@ export function compileRouteTable(
lines.push(`const job${i}Crons = ${JSON.stringify(job.crons)};`);
}
lines.push('');
// Workers log capture keeps only an error's own name, message and stack (not
// AggregateError.errors or cause), so each job logs its own error, with the job file
// and cron, as soon as it fails.
lines.push('export async function scheduled(controller, env, ctx) {');
lines.push(' const run = async (job, loadJob) => {');
lines.push(' try {');
lines.push(' const mod = await loadJob();');
lines.push(
" if (typeof mod.default === 'function') await mod.default(controller, env, ctx);",
);
lines.push(' } catch (error) {');
lines.push(
' console.error(`[void] Scheduled job ${job} failed for cron "${controller.cron}":`, error);',
);
lines.push(' return { job, error };');
lines.push(' }');
lines.push(' };');
lines.push(' const tasks = [];');
for (let i = 0; i < scan.jobs.length; i++) {
const job = scan.jobs[i];
const filePath = JSON.stringify(`${jobsDir}/${job.filePath}`);
lines.push(` if (job${i}Crons.includes(controller.cron)) {`);
lines.push(' tasks.push((async () => {');
lines.push(` const mod = await import(${filePath});`);
const label = JSON.stringify(relative(options.projectRoot, `${jobsDir}/${job.filePath}`));
lines.push(
" if (typeof mod.default === 'function') await mod.default(controller, env, ctx);",
` if (job${i}Crons.includes(controller.cron)) tasks.push(run(${label}, () => import(${filePath})));`,
);
lines.push(' })());');
lines.push(' }');
}
lines.push(' const results = await Promise.allSettled(tasks);');
lines.push(' const failures = (await Promise.all(tasks)).filter(Boolean);');
lines.push(' if (failures.length) {');
lines.push(
' const errors = results.filter(result => result.status === "rejected").map(result => result.reason);',
' throw new AggregateError(failures.map((failure) => failure.error), `Scheduled jobs failed: ${failures.map((failure) => failure.job).join(", ")}`);',
);
lines.push(' if (errors.length) throw new AggregateError(errors, "Scheduled jobs failed");');
lines.push(' }');
lines.push('}');
lines.push('');
} else {
Expand Down
142 changes: 142 additions & 0 deletions packages/void/test/unit/logs-rendering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,145 @@ it('keeps color codes, newlines and tabs, and removes other escape sequences who
expect(sanitizeTerminalLogText('\u009b2J\u0085\u007f\u0000\u0008end')).toBe('2Jend');
expect(sanitizeTerminalLogText('plain text, ünïcode ✓')).toBe('plain text, ünïcode ✓');
});

it('prints stack frames under the error that logged them', async () => {
logsFixture.entries = [
{
timestamp: 0,
method: 'GET',
url: '/sponsors.svg',
status: '500',
outcome: 'ok',
logs: [
{
level: 'error',
message: ["TypeError: Cannot read properties of undefined (reading 'href')"],
errorInfo: [
{
name: 'TypeError',
message: "Cannot read properties of undefined (reading 'href')",
stack: ' at assets/resvg.js:14881:64\n at assets/resvg.js:15409:3',
},
],
},
{
level: 'error',
message: ['Render failed:', 'Error: font missing', 'retrying'],
errorInfo: [
null,
{
name: 'Error',
message: 'font missing',
stack: 'Error: font missing\n at render (index.js:10:5)',
},
null,
],
},
{
level: 'error',
message: ['Error'],
errorInfo: [{ name: 'Error', message: '', stack: 'Error\n at g (y.js:2:2)' }],
},
{
level: 'warn',
message: ['Error: retry later'],
errorInfo: [{ name: 'Error', message: 'retry later' }],
},
],
exceptions: [
{
name: 'Error',
message: 'Worker threw',
stack: 'Error: Worker threw\n at fetch (index.js:1:1)',
},
{ name: 'Error', message: 'boom', stack: 'Error: boom' },
{ name: 'Error', message: 'Worker exceeded CPU time limit.' },
],
},
];
const output = Object.assign(
new Writable({
write(_chunk, _encoding, callback) {
callback();
},
}),
{ columns: 120 },
);
vi.spyOn(process, 'stdout', 'get').mockReturnValue(output as typeof process.stdout);
const print = vi.spyOn(console, 'log').mockImplementation(() => {});
await runLogsCommand('/fixture', '', '1h');
const messages = print.mock.calls.map((args) => String(args[0] ?? ''));
const header = messages.findIndex((line) => line.includes('/sponsors.svg'));
expect(header).toBeGreaterThanOrEqual(0);
expect(
messages.slice(header + 1).flatMap((message) => stripVTControlCharacters(message).split('\n')),
).toEqual([
" [error] TypeError: Cannot read properties of undefined (reading 'href')",
' at assets/resvg.js:14881:64',
' at assets/resvg.js:15409:3',
' [error] Render failed: Error: font missing',
' at render (index.js:10:5)',
' retrying',
' [error] Error',
' at g (y.js:2:2)',
' [warn] Error: retry later',
' [exception] Error: Worker threw',
' at fetch (index.js:1:1)',
' [exception] Error: boom',
' [exception] Error: Worker exceeded CPU time limit.',
]);
expect(messages.join('\n')).toContain('\x1b[2m at assets/resvg.js:14881:64');
});

it('drops terminal control sequences from stack frames', async () => {
logsFixture.entries = [
{
timestamp: 0,
method: 'GET',
url: '/sponsors.svg',
status: '500',
outcome: 'exception',
logs: [
{
level: 'error',
message: ['Error: boom'],
errorInfo: [
{
name: 'Error',
message: 'boom',
stack: 'Error: boom\n at \x1b]52;c;ZXZpbA==\x07f (a.js:1:1)\x1b[2J\r',
},
],
},
],
exceptions: [
{
name: 'Error',
message: 'x',
stack:
'Error: x\n at \x1b]8;;https://evil.example\x07g\x1b]8;;\x07 (b.js:2:2)\u009b2J',
},
],
},
];
const output = Object.assign(
new Writable({
write(_chunk, _encoding, callback) {
callback();
},
}),
{ columns: 120 },
);
vi.spyOn(process, 'stdout', 'get').mockReturnValue(output as typeof process.stdout);
const print = vi.spyOn(console, 'log').mockImplementation(() => {});
await runLogsCommand('/fixture', '', '1h');

const printed = print.mock.calls.map((args) => String(args[0] ?? '')).join('\n');
for (const unsafe of ['\x1b]', '\x1b[2J', '\r', '\x07', '\u009b']) {
expect(printed).not.toContain(unsafe);
}
expect(printed).not.toContain('evil.example');
const plain = stripVTControlCharacters(printed);
expect(plain).toContain(' at f (a.js:1:1)');
expect(plain).toContain(' at g (b.js:2:2)2J');
});
Loading
Loading