diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f56b14fd5..2a65c5880 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -32,6 +32,7 @@ Use this page as a command reference. If you are setting up a project for the fi | `void auth login` | Authenticate with Void | | `void project link` | Link directory to a project | | `void project logs` | Show runtime logs from deployed project | +| `void project requests` | Show request-level traffic (status, method, timing) | | `void project rollback` | Roll back to a previous deployment | | `void project cancel` | Cancel an active deployment | | `void project purge-cache` | Purge all cached pages | @@ -162,12 +163,12 @@ void project logs [--level ] [--filter ] [--range ] [--de Show runtime logs from the deployed project. Uses the linked project from `.void/project.json`. -| Flag | Purpose | Default | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| `--range ` | How far back to look. Format: `` (m/h/d). Max 7d. | `1h` | -| `--level ` | Filter by log level. One of `error`, `warn`, `info`, `log`, `debug`, `all`. `error` also includes uncaught exceptions and non-`ok` requests. | `all` | -| `--filter ` | Case-insensitive **substring** match against log message text and exception name/message — not a level filter. Shows the full request entry on any hit. | none | -| `--deployment ` | Filter logs to a specific deployment ID. | none | +| Flag | Purpose | Default | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| `--range ` | How far back to look. Format: `` (m/h/d). Max 7d. | `1h` | +| `--level ` | Filter by log level. One of `error`, `warn`, `info`, `log`, `debug`, `all`. `error` also includes uncaught exceptions, non-`ok` outcomes, and any 5xx response — even when the worker neither threw nor logged. | `all` | +| `--filter ` | Case-insensitive **substring** match against log message text and exception name/message — not a level filter. Shows the full request entry on any hit. | none | +| `--deployment ` | Filter logs to a specific deployment ID. | none | Output shows one line per request (`HH:MM:SS METHOD URL STATUS`) with indented console log and exception lines beneath. Errors and exceptions are colored red, warnings yellow. @@ -178,7 +179,32 @@ void project logs --level error --range 12h void project logs --level error --filter websocket ``` -Tip: `void project logs` only sees what Cloudflare Tail captures — top-level `console.*` calls and uncaught throws. Application errors caught and persisted to your own DB are invisible to tail. Surface them via `console.error(...)` or `void/log`'s `logger.error(...)` so they show up under `--level error`. +Tip: `void project logs` only sees what Cloudflare Tail captures — top-level `console.*` calls and uncaught throws. Application errors caught and persisted to your own DB are invisible to tail. Surface them via `console.error(...)` or `void/log`'s `logger.error(...)` so they show up under `--level error`. For 5xx that never reach your worker at all (edge-router errors, static/SPA projects), use `void project requests --status 5xx`. + +### `void project requests` + +``` +void project requests [--status ] [--range ] +``` + +Show request-level traffic recorded at the edge for the linked project: one line per request with time, method, HTTP status, request type, and server timing. Unlike `void project logs` (which only has rows for invoked user workers), this reads the edge request-metering data, so it also surfaces: + +- **5xx the edge router generated itself** — missing deployment manifest, static-asset read timeouts, SSR dispatch timeouts — which never invoke your worker and so never appear in logs. +- **Requests to static/SPA projects**, which are served directly from storage and never run a worker. + +| Flag | Purpose | Default | +| -------------------- | --------------------------------------------------------------------------------------------- | ------- | +| `--status ` | Filter by status: a class (`2xx`, `3xx`, `4xx`, `5xx`) or an exact 3-digit code (e.g. `500`). | none | +| `--range ` | How far back to look. Format: `` (m/h/d). Max 7d. | `1h` | + +Output shows one line per request (`HH:MM:SS METHOD STATUS TYPE DURATION`). 5xx are colored red, 4xx yellow. Note: request paths are not recorded, so this view shows status and type rather than URLs — use `void project logs` for per-URL, per-log detail on requests that do reach your worker. + +Examples: + +``` +void project requests --status 5xx +void project requests --range 24h +``` ### `void project rollback [deployId]` diff --git a/packages/void/AGENT_PROMPT.md b/packages/void/AGENT_PROMPT.md index d35b7953f..838f44350 100644 --- a/packages/void/AGENT_PROMPT.md +++ b/packages/void/AGENT_PROMPT.md @@ -12,6 +12,8 @@ Rewrites and redirects: declare static rules in `void.json` under `routing.redir Logs: surface app-level errors that should show up under `void project logs --level error` via `import { logger } from "void/log"` and `logger.error(msg, fields?)` (also `.warn` / `.info`). Anything caught and only persisted to your own DB is invisible to Cloudflare Tail; route it through `logger.*` or `console.*` so the platform can see it. +Requests: `void project logs` only has rows when a worker actually ran. 5xx generated by the edge router, and every request to a static/SPA project, produce no log line at all. Use `void project requests --status 5xx --range 12h` to see those — it lists status, method, and duration for every request the edge served, whether or not a worker was invoked. + Self-host deploy: `void deploy` targets the Void platform; to deploy to the user's OWN Cloudflare account instead, use `void deploy --backend cloudflare` (add `--provision` on the first deploy to create the D1/KV/R2/Queues/Hyperdrive resources the source needs). It uses local wrangler auth, so pin an account (`account_id` in `wrangler.jsonc` or `CLOUDFLARE_ACCOUNT_ID`) and keep real secrets in `wrangler secret put` — every `.env*` file this backend loads (`.env`, `.env.local`, `.env.production`, `.env.production.local`) ships as plaintext worker vars, `.local` included. Provisioning a Hyperdrive config reads `DATABASE_URL` from the shell, not from `.env*`. v1 supports full Void apps on the Cloudflare Workers target only (D1/KV/R2/Queues/Hyperdrive; auth and ISR on D1/SQLite); framework SSR of every kind (TanStack Start, React Router, vinext, SvelteKit, Nuxt, Analog, Astro), static/SPA/SSG apps (including `output: "static"`), WebSocket/Durable Object apps (`*.ws.ts`), a custom `migrations_pattern`, node/bun/deno targets, and PostgreSQL apps with auth all fail closed with guidance. Auth apps must ship checked-in migrations that produce the Better Auth schema. `--provision` is a single-operator, dev-machine step and is disabled in CI. Full docs are in `node_modules/void/docs/`. If you have the `void` skill available, use it for a complete API reference covering project structure, routing, pages mode, database, auth, typed fetch, KV, storage, queues, cron jobs, CLI, configuration, and deployment. diff --git a/packages/void/README.md b/packages/void/README.md index 7e3882c2d..5a6afb1ac 100644 --- a/packages/void/README.md +++ b/packages/void/README.md @@ -42,7 +42,7 @@ export default defineConfig({ - `void auth login` — platform auth - `void deploy` — build and deploy - `void db *` — local DB and migration commands -- `void project *` — link, status, logs, rollback, delete +- `void project *` — link, status, logs, requests, rollback, delete Runtime helpers include: diff --git a/packages/void/getting-started-prompt.txt b/packages/void/getting-started-prompt.txt index 977d97fdb..1c1e39365 100644 --- a/packages/void/getting-started-prompt.txt +++ b/packages/void/getting-started-prompt.txt @@ -21,7 +21,7 @@ Database workflow: define Drizzle tables in `db/schema.ts`, import `db` from `vo Routing extras: declare static redirects/rewrites/fallbacks in `void.json` under `routing.*`, or in a `public/_redirects` file. For dynamic rewrites, call `c.rewrite(path)` in a `defineMiddleware`. -Logs: surface app-level errors via `import { logger } from "void/log"` and `logger.error(msg, fields?)` (also `.warn` / `.info`) so they appear under `void project logs --level error`. Errors only persisted to your own DB are invisible to Cloudflare Tail. +Logs: surface app-level errors via `import { logger } from "void/log"` and `logger.error(msg, fields?)` (also `.warn` / `.info`) so they appear under `void project logs --level error`. Errors only persisted to your own DB are invisible to Cloudflare Tail. Logs only exist where a worker ran — for 5xx generated by the edge router or on static/SPA projects, use `void project requests --status 5xx` instead. CI / fresh clones: run `void prepare` after install to generate `.void/routes.d.ts`, `.void/db.d.ts`, `.void/queues.d.ts`, `.void/env.d.ts`, and `.void/tsconfig.json` without booting Vite. `vite dev` / `vite build` populate these during normal app workflows. diff --git a/packages/void/src/cli/cli.ts b/packages/void/src/cli/cli.ts index 519bad838..6d9b9f2d2 100644 --- a/packages/void/src/cli/cli.ts +++ b/packages/void/src/cli/cli.ts @@ -197,7 +197,7 @@ if (helpPath) { }, { name: 'project', - hint: 'status | link | list | logs | rollback | cancel | delete | purge-cache', + hint: 'status | link | list | logs | requests | rollback | cancel | delete | purge-cache', description: 'Manage projects and deployments', }, { name: 'secret', hint: 'list | put | delete | sync', description: 'Manage project secrets' }, diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index 70fa1c3a9..d8e0b5b3b 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -157,6 +157,15 @@ export type LogEntry = { status: string; }; +export type RequestEntry = { + timestamp: string; + type: string; + method: string; + status: string; + bytes: number; + durationMs: number; +}; + export type BuildSummary = { id: string; status: string; @@ -907,6 +916,34 @@ export class PlatformClient { return json.data; } + async getRequests( + projectId: string, + since: string, + limit = 200, + status?: string, + ): Promise> { + const params = new URLSearchParams({ + since, + limit: String(limit), + }); + if (status && status !== 'all') { + params.set('status', status); + } + + const res = await fetch(`${this.baseUrl}/projects/${projectId}/requests?${params}`, { + headers: { + ...this.authHeaders, + }, + }); + + if (!res.ok) { + await throwPlatformApiError('Failed to fetch requests', res); + } + + const json = (await res.json()) as { data: Array }; + return json.data; + } + async listBuilds(projectId: string, limit = 20): Promise> { const params = new URLSearchParams({ limit: String(limit) }); const res = await fetch(`${this.baseUrl}/projects/${projectId}/builds?${params}`, { diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 76592e920..d4d82c41d 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -102,6 +102,7 @@ const HELP_TREE: HelpTree = { { command: 'link [name]', description: 'Link this directory to a project' }, { command: 'list', description: 'List your projects' }, { command: 'logs', description: 'Show runtime logs from the deployed project' }, + { command: 'requests', description: 'Show request-level traffic (status, method, timing)' }, { command: 'rollback [deployId]', description: 'Roll back to a previous deployment' }, { command: 'cancel [deployId]', description: 'Cancel an active deployment' }, { command: 'delete [name]', description: 'Delete a project and all its resources' }, @@ -143,6 +144,28 @@ const HELP_TREE: HelpTree = { }, ], }, + requests: { + summary: + 'Show request-level traffic (status, method, type, timing). Surfaces 5xx that the edge router generated itself and requests to static/SPA projects that never invoke a worker — neither of which appears in `project logs`.', + usage: ['void project requests [options]'], + options: [ + { + command: '--status ', + description: 'Filter by status: a class like 5xx or an exact code like 500', + }, + { command: '--range ', description: 'Time range to query (default: 1h)' }, + ], + examples: [ + { + command: 'void project requests --status 5xx', + description: 'Show recent server-error responses, including edge-generated ones', + }, + { + command: 'void project requests --range 24h', + description: 'Show all request traffic in the last day', + }, + ], + }, rollback: basicSubcommand( 'Roll back to a previous deployment.', 'void project rollback [deployId]', diff --git a/packages/void/src/cli/logs.ts b/packages/void/src/cli/logs.ts index fd5347578..0a7f4a02c 100644 --- a/packages/void/src/cli/logs.ts +++ b/packages/void/src/cli/logs.ts @@ -128,6 +128,11 @@ export function filterLogEntries( if (entry.exceptions.length > 0) { return true; } + // A 5xx response is an error even when the worker neither threw nor + // logged (mirrors the server-side `blob6 LIKE '5%'` predicate). + if (parseInt(entry.status, 10) >= 500) { + return true; + } for (const l of entry.logs) { if (l.level === 'error') { return true; diff --git a/packages/void/src/cli/mcp.ts b/packages/void/src/cli/mcp.ts index 1ff092b2e..633a79a91 100644 --- a/packages/void/src/cli/mcp.ts +++ b/packages/void/src/cli/mcp.ts @@ -263,6 +263,11 @@ Available methods on the "void_" object: void_.getLogs(projectId, since, limit?, deploymentId?) → { timestamp, outcome, logs, exceptions, url, method, status }[] since: duration string (e.g. "1h"), limit: number (default 200) + Requests: + void_.getRequests(projectId, since, limit?, status?) → { timestamp, type, method, status, bytes, durationMs }[] + since: duration string (e.g. "1h"), limit: number (default 200), status: class like "5xx" or exact code like "500" + Edge request metering — includes edge-generated 5xx and static/SPA requests that never invoke a worker. + Secrets: void_.listSecrets(projectId) → { name }[] void_.setSecret(projectId, name, value) → { ok, name } diff --git a/packages/void/src/cli/project-args.ts b/packages/void/src/cli/project-args.ts index 3688b1753..4d085672a 100644 --- a/packages/void/src/cli/project-args.ts +++ b/packages/void/src/cli/project-args.ts @@ -1,12 +1,17 @@ import type { SubcommandDef } from './subcommand-prompt.ts'; const LOG_LEVELS = new Set(['error', 'warn', 'info', 'log', 'debug', 'all']); +// A status class (`5xx`), an exact code in the 100-599 HTTP range (`500`), or +// `all`. Exact codes are range-bounded so a typo like `600` errors instead of +// silently returning an empty result. +const STATUS_FILTER = /^([1-5]xx|[1-5]\d{2}|all)$/; export const projectSubcommands: Array = [ { name: 'status', arg: '[name]', description: 'Show deployments (omit name for linked project)' }, { name: 'link', arg: '[name]', description: 'Link directory to a project' }, { name: 'list', description: 'List your projects' }, { name: 'logs', description: 'Show runtime logs from the deployed project' }, + { name: 'requests', description: 'Show request-level traffic (status, method, timing)' }, { name: 'rollback', arg: '[deployId]', description: 'Roll back to a previous deployment' }, { name: 'cancel', arg: '[deployId]', description: 'Cancel an active deployment' }, { @@ -28,6 +33,7 @@ export type ProjectArgs = deployment: string; level: string; } + | { subcommand: 'requests'; status: string; range: string } | { subcommand: 'rollback'; deployId: string } | { subcommand: 'cancel'; deployId: string } | { subcommand: 'delete'; name: string; force: boolean } @@ -92,6 +98,27 @@ export function parseProjectArgs(args: Array): ProjectArgs { return { subcommand, filter, range, deployment, level }; } + if (subcommand === 'requests') { + let status = ''; + let range = '1h'; + for (let i = 0; i < rest.length; i++) { + if (rest[i] === '--status' && rest[i + 1]) { + const value = rest[++i]; + if (!STATUS_FILTER.test(value)) { + throw new Error( + `project: Invalid --status '${value}'. Use a class like 5xx or an exact code like 500.`, + ); + } + status = value; + } else if (rest[i] === '--range' && rest[i + 1]) { + range = rest[++i]; + } else { + throw new Error(`project: Unexpected argument '${rest[i]}'.`); + } + } + return { subcommand, status, range }; + } + if (subcommand === 'rollback') { const [deployId, extra] = rest; if (deployId?.startsWith('-')) { @@ -144,6 +171,6 @@ export function parseProjectArgs(args: Array): ProjectArgs { } throw new Error( - 'project: Usage: void project .', + 'project: Usage: void project .', ); } diff --git a/packages/void/src/cli/project-cmd.ts b/packages/void/src/cli/project-cmd.ts index fce4f6452..31e535be4 100644 --- a/packages/void/src/cli/project-cmd.ts +++ b/packages/void/src/cli/project-cmd.ts @@ -23,6 +23,10 @@ export async function runProjectCommand(root: string, args: ProjectArgs): Promis args.level || undefined, ); } + if (args.subcommand === 'requests') { + const { runRequestsCommand } = await import('./requests.ts'); + return runRequestsCommand(root, args.range, args.status || undefined); + } if (args.subcommand === 'rollback') { const { runRollbackCommand } = await import('./rollback.ts'); return runRollbackCommand(root, args.deployId || undefined); diff --git a/packages/void/src/cli/requests.ts b/packages/void/src/cli/requests.ts new file mode 100644 index 000000000..9954d0ab0 --- /dev/null +++ b/packages/void/src/cli/requests.ts @@ -0,0 +1,75 @@ +import { intro, log, outro } from '@clack/prompts'; +import { getToken } from './auth.ts'; +import { PlatformClient, type RequestEntry } from './client.ts'; +import { cliTitle, dim, pc } from './output.ts'; +import { readProjectConfig } from './project.ts'; + +export async function runRequestsCommand( + root: string, + range: string, + status?: string, +): Promise { + console.log(); + intro(cliTitle('project requests')); + + const token = getToken(root); + if (!token) { + log.error('Not logged in. Run `void auth login` first.'); + process.exit(1); + } + + if (!/^\d+(m|h|d)$/.test(range)) { + log.error(`requests: Invalid range '${range}'. Use a format like 15m, 2h, or 3d.`); + process.exit(1); + } + + const linked = readProjectConfig(root); + if (!linked) { + log.error('No linked project. Run `void project link` first.'); + process.exit(1); + } + + const client = new PlatformClient(token, { root }); + + let entries: Array; + try { + entries = await client.getRequests(linked.projectId, range, 200, status || undefined); + } catch (err) { + log.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + + if (entries.length === 0) { + const suffix = status ? ` matching status ${status}` : ''; + outro(`No requests found in the last ${range}${suffix}.`); + return; + } + + for (const entry of entries) { + printRequest(entry); + } + + outro(dim(`${entries.length} requests`)); +} + +function printRequest(entry: RequestEntry): void { + const time = formatTime(entry.timestamp); + const method = (entry.method || '-').padEnd(4); + const status = entry.status || '-'; + + const statusNum = parseInt(status, 10); + const coloredStatus = + statusNum >= 500 ? pc.red(status) : statusNum >= 400 ? pc.yellow(status) : status; + + const duration = Number.isFinite(entry.durationMs) + ? dim(`${Math.round(entry.durationMs)}ms`) + : ''; + console.log(`${dim(time)} ${method} ${coloredStatus} ${dim(entry.type)} ${duration}`.trimEnd()); +} + +// Analytics Engine returns `YYYY-MM-DD HH:MM:SS` in UTC (no zone); parse as UTC +// and render in the local timezone, matching `project logs`. +function formatTime(ts: string): string { + const d = new Date(`${ts.replace(' ', 'T')}Z`); + return Number.isNaN(d.getTime()) ? ts : d.toLocaleTimeString('en-GB', { hour12: false }); +} diff --git a/packages/void/test/unit/logs-filter.test.ts b/packages/void/test/unit/logs-filter.test.ts index b6844f2eb..7e4edee33 100644 --- a/packages/void/test/unit/logs-filter.test.ts +++ b/packages/void/test/unit/logs-filter.test.ts @@ -70,6 +70,17 @@ describe('filterLogEntries', () => { ); }); + it('--level error matches a 5xx status even when the worker did not throw or log', () => { + // A returned Response(status:500): outcome ok, no logs, no exceptions. + const silent500 = entry({ outcome: 'ok', logs: [], exceptions: [], status: '500' }); + expect(filterLogEntries([E_OK, silent500], 'error', '')).toEqual([silent500]); + }); + + it('--level error ignores a non-5xx status with no error signal', () => { + const ok404 = entry({ outcome: 'ok', logs: [], exceptions: [], status: '404' }); + expect(filterLogEntries([ok404], 'error', '')).toEqual([]); + }); + it('--level warn matches only warn entries (does not include error)', () => { expect( filterLogEntries([E_OK, E_ERROR_LOG, E_WARN_LOG, E_INFO_LOG, E_EXCEPTION], 'warn', ''), @@ -200,3 +211,54 @@ describe('PlatformClient.getLogs URL', () => { expect(url).toContain('level=warn'); }); }); + +describe('PlatformClient.getRequests URL', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + function setFetchMock() { + const fetchMock: ReturnType = vi.fn(async () => Response.json({ data: [] })); + globalThis.fetch = fetchMock as typeof fetch; + return fetchMock; + } + + function lastUrl(fetchMock: ReturnType): string { + const calls = fetchMock.mock.calls as Array>; + if (calls.length === 0) { + throw new Error('fetch was not called'); + } + return String(calls[0]?.[0]); + } + + it('hits the /requests endpoint with since and limit, omitting status by default', async () => { + const fetchMock = setFetchMock(); + const client = new PlatformClient('tok', { apiUrl: 'http://localhost:1234' }); + await client.getRequests('proj_1', '12h'); + + const url = lastUrl(fetchMock); + expect(url).toContain('/projects/proj_1/requests?'); + expect(url).toContain('since=12h'); + expect(url).toContain('limit=200'); + expect(url).not.toContain('status='); + }); + + it("omits status when status === 'all'", async () => { + const fetchMock = setFetchMock(); + const client = new PlatformClient('tok', { apiUrl: 'http://localhost:1234' }); + await client.getRequests('proj_1', '1h', 200, 'all'); + + expect(lastUrl(fetchMock)).not.toContain('status='); + }); + + it('appends a status class filter', async () => { + const fetchMock = setFetchMock(); + const client = new PlatformClient('tok', { apiUrl: 'http://localhost:1234' }); + await client.getRequests('proj_1', '1h', 200, '5xx'); + + expect(lastUrl(fetchMock)).toContain('status=5xx'); + }); +}); diff --git a/packages/void/test/unit/project-args.test.ts b/packages/void/test/unit/project-args.test.ts index 81bda5a26..44ff0d9fa 100644 --- a/packages/void/test/unit/project-args.test.ts +++ b/packages/void/test/unit/project-args.test.ts @@ -80,6 +80,54 @@ describe('parseProjectArgs', () => { }); }); + describe('requests', () => { + it('parses requests with no flags', () => { + expect(parseProjectArgs(['requests'])).toEqual({ + subcommand: 'requests', + status: '', + range: '1h', + }); + }); + + it('parses --status and --range together', () => { + expect(parseProjectArgs(['requests', '--status', '5xx', '--range', '30m'])).toEqual({ + subcommand: 'requests', + status: '5xx', + range: '30m', + }); + }); + + it('accepts status classes, exact codes, and all', () => { + for (const v of ['2xx', '4xx', '5xx', '500', '404', 'all']) { + const parsed = parseProjectArgs(['requests', '--status', v]); + if (parsed.subcommand !== 'requests') { + throw new Error('expected requests subcommand'); + } + expect(parsed.status).toBe(v); + } + }); + + it('rejects an invalid --status value', () => { + expect(() => parseProjectArgs(['requests', '--status', 'oops'])).toThrow( + "project: Invalid --status 'oops'. Use a class like 5xx or an exact code like 500.", + ); + }); + + it('rejects exact codes outside the 100-599 HTTP range', () => { + for (const v of ['000', '099', '600', '999']) { + expect(() => parseProjectArgs(['requests', '--status', v])).toThrow( + `project: Invalid --status '${v}'. Use a class like 5xx or an exact code like 500.`, + ); + } + }); + + it('rejects unknown flags', () => { + expect(() => parseProjectArgs(['requests', '--bogus'])).toThrow( + "project: Unexpected argument '--bogus'.", + ); + }); + }); + describe('purge-cache', () => { it('parses purge-cache', () => { expect(parseProjectArgs(['purge-cache'])).toEqual({