Repository navigation
feat(cli): add evlog logs to read the events the fs drain wrote #774
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
0cd7948
feat(cli): add evlog logs to read the events the fs drain wrote
HugoRCD ff74fb7
feat(cli): logs --where, stats, workspace reading and --url
HugoRCD 9ecfa47
fix(cli): stream what logs -f already found, keep following a flaky e…
HugoRCD 69f644b
fix(cli): interrupt a stalled logs --url poll and report a quiet endp…
HugoRCD 7cf7d32
docs: a 4xx event still counts as an error when it carries one
HugoRCD File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@evlog/cli": minor | ||
| --- | ||
|
|
||
| `evlog logs` reads the wide events the fs drain wrote to `.evlog/logs`: the last 50 (`evlog logs`), the failures (`evlog logs errors`), the slowest (`evlog logs slow --over 1s`), or one request in full by id (`evlog logs <requestId>`, a UUID prefix is enough). Filters compose with every view: `--since 15m`, `--until`, `--level error,fatal`, `--path`, `--status 5xx`, `--limit`, `--dir`. `-f` follows new events like `tail -f`; `--json` returns the events as JSON. It finds the project's log directory the way `doctor` does, reads both the compact and the pretty layout, and never writes. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "evlog": patch | ||
| --- | ||
|
|
||
| `readFsLogs()` and `tailFsLogs()` from `evlog/fs` now read files the drain wrote with `pretty: true`, assembling each indented event, where they used to skip every line of them as malformed. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| --- | ||
| title: evlog logs | ||
| description: Read the wide events your app wrote to .evlog/logs from the terminal — the latest requests, the failures, the slowest, or one request in full. | ||
| navigation: | ||
| title: logs | ||
| icon: i-lucide-scroll-text | ||
| links: | ||
| - label: File system adapter | ||
| icon: i-lucide-folder-open | ||
| to: /integrate/adapters/self-hosted/fs | ||
| color: neutral | ||
| variant: subtle | ||
| --- | ||
|
|
||
| `evlog logs` reads the events the [file system drain](/integrate/adapters/self-hosted/fs) wrote and shows them the way you would want to read them: one line per request, the thing that mattered at the end of it, and one request in full when you have its id. It reads files; it never touches the app. | ||
|
|
||
| ```bash [Terminal] | ||
| evlog logs | ||
| ``` | ||
|
|
||
| ```text [Output] | ||
| 6 events · .evlog/logs | ||
|
|
||
| 08:00:00 GET /api/health 200 2ms | ||
| 10:00:00 POST /api/checkout 402 412ms ✗ Error: Payment processing failed · Card declined by issuer | ||
| 10:30:00 GET /api/reports 200 1.2s report.id=r-1 | ||
| 11:00:00 POST /api/refund 200 80ms audit billing.refund user:usr_42 → invoice:inv_1 success | ||
| 11:30:00 GET /api/items 500 30ms | ||
| 11:45:00 GET /api/items warn 700ms user.id=usr_7 | ||
|
|
||
| evlog logs errors — the 2 that failed · evlog logs <requestId> — one request in full · evlog logs slow — worst first | ||
| ``` | ||
|
|
||
| The newest event is at the bottom, like `tail`. Nothing is written: the fs drain writes on every request and this reads what it wrote, both the compact and the `pretty: true` layout, across the dated files. | ||
|
|
||
| ## Four views | ||
|
|
||
| | Command | Shows | | ||
| | --- | --- | | ||
| | `evlog logs` | The last 50 events, oldest first | | ||
| | `evlog logs errors` | Events that failed: a `5xx` status, an `error` or `fatal` level, or an `error` block | | ||
| | `evlog logs slow` | Events over 500ms, worst first (`--over 1s` to move the bar) | | ||
| | `evlog logs <requestId>` | Every event carrying that id, in full: request, error with `why` and `fix`, audit record, then the business fields. The first block of a UUID is enough | | ||
|
|
||
| ## Filters | ||
|
|
||
| Filters compose with any view. | ||
|
|
||
| | Flag | What it does | | ||
| | --- | --- | | ||
| | `--since <when>` | Only events after: a duration back from now (`15m`, `2h`, `3d`) or a date (`2026-10-01`, `2026-10-01T09:00`) | | ||
| | `--until <when>` | Only events before, same spellings | | ||
| | `--level <a,b>` | Only these levels: `trace`, `debug`, `info`, `warn`, `error`, `fatal` | | ||
| | `--path <path>` | Only requests on this exact path | | ||
| | `--status <n>` | Only this status (`404`) or class (`4xx`) | | ||
| | `--over <duration>` | For `slow`: what a request has to exceed (default `500ms`) | | ||
| | `--limit <n>` | Most events to show (default 50) | | ||
| | `--dir <path>` | Read another directory (default: the project's `.evlog/logs`, or the directory its fs drain is configured for) | | ||
| | `-f`, `--follow` | Keep reading as the app writes, like `tail -f`; `Ctrl-C` stops | | ||
| | `--json` | The events as JSON on stdout | | ||
| | `--cwd <dir>` | Another project in the workspace | | ||
|
|
||
| A time, level, status or limit that cannot be read stops the command (exit 2) rather than silently widening the query. | ||
|
|
||
| ```bash [Terminal] | ||
| evlog logs errors --since 1h | ||
| evlog logs slow --over 2s --path /api/reports | ||
| evlog logs --status 5xx --level error,fatal --limit 20 | ||
| evlog logs -f --path /api/checkout | ||
| ``` | ||
|
|
||
| ## For agents | ||
|
|
||
| `--json` returns one envelope: the directory, the view, how many events matched, and the events shown. | ||
|
|
||
| ```bash [Terminal] | ||
| evlog logs errors --since 30m --json | ||
| ``` | ||
|
|
||
| ```json | ||
| { | ||
| "schemaVersion": 2, | ||
| "dir": "/app/.evlog/logs", | ||
| "view": "errors", | ||
| "count": 2, | ||
| "matched": 2, | ||
| "events": [ { "timestamp": "…", "path": "/api/checkout", "status": 402, "error": { "data": { "why": "…", "fix": "…" } } } ] | ||
| } | ||
| ``` | ||
|
|
||
| With `--follow --json`, each new event is one JSON line on stdout, since a stream has no end. The [`analyze-logs` skill](/reference/agent-skills) calls `evlog logs` first and falls back to reading the files only when the CLI is not available. | ||
|
|
||
| ## What it will not do | ||
|
|
||
| - **It reads local files.** A remote drain (Axiom, Datadog, …) has its own query language and UI; this command does not wrap them. | ||
| - **One directory per run.** In a monorepo, run it from the app (`--cwd apps/web`) or pass `--dir`. | ||
| - **No aggregation yet.** Counts by route or status are a `jq` away from `--json`; a `stats` view may come later. | ||
|
|
||
| ## Next | ||
|
|
||
| - [File system drain](/integrate/adapters/self-hosted/fs): what writes the files, rotation, `pretty` | ||
| - [`evlog map`](/cli/map): which handlers emit a wide event at all |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| import { existsSync } from 'node:fs' | ||
| import { resolve } from 'node:path' | ||
| import { telemetry } from '@evlog/telemetry' | ||
| import { EvlogError } from 'evlog' | ||
| import type { WideEvent } from 'evlog' | ||
| import { readFsLogs, tailFsLogs } from 'evlog/fs' | ||
| import type { CliContext } from '../core/context' | ||
| import { createStyle, EXIT_FAIL, EXIT_USAGE } from '../core/output' | ||
| import { defineEvlogCommand } from '../lib/command' | ||
| import { cliErrors } from '../lib/errors' | ||
| import { buildQuery, select } from '../lib/logs/query' | ||
| import type { LogsArgs, LogsQuery } from '../lib/logs/query' | ||
| import { formatLine, formatLogsReport } from '../lib/logs/render' | ||
| import type { LogsResult } from '../lib/logs/render' | ||
| import { findConfiguredFsDrain, findLogsSink, resolveProject } from '../lib/project' | ||
|
|
||
| export type { LogsResult } from '../lib/logs/render' | ||
|
|
||
| /** | ||
| * Where the events are. `--dir` wins; otherwise the sink the project already | ||
| * writes to, or the directory its fs drain is configured for, which may not | ||
| * exist yet when nothing has run. | ||
| */ | ||
| export async function resolveLogsDir(ctx: CliContext, dir: string | undefined): Promise<string> { | ||
| if (dir) return resolve(ctx.cwd, dir) | ||
| const project = await resolveProject(ctx.cwd) | ||
| const sink = await findLogsSink(project) | ||
| if (sink) return sink.dir | ||
| const configured = await findConfiguredFsDrain(project, ctx.env) | ||
| if (configured) return resolve(project.packageDir, configured.dir) | ||
| throw cliErrors.LOGS_NO_SINK({ cwd: ctx.cwd }) | ||
| } | ||
|
|
||
| export interface RunLogsOptions { | ||
| dir?: string | ||
| /** Keep reading as the app writes; resolves when `signal` aborts. */ | ||
| follow?: boolean | ||
| signal?: AbortSignal | ||
| /** Called for each event that arrives while following. */ | ||
| onEvent?: (event: WideEvent) => void | ||
| now?: Date | ||
| } | ||
|
|
||
| /** Read the events the query asks for. Pure with respect to the context: nothing is written. */ | ||
| export async function runLogs(ctx: CliContext, args: LogsArgs, options: RunLogsOptions = {}): Promise<LogsResult> { | ||
| const query = buildQuery(args, options.now) | ||
| const dir = await resolveLogsDir(ctx, options.dir) | ||
| if (!existsSync(dir) && !options.follow) throw cliErrors.LOGS_NO_SINK({ cwd: ctx.cwd }) | ||
|
|
||
| const matched: WideEvent[] = [] | ||
| for await (const event of readFsLogs({ dir, since: query.since, until: query.until, level: query.level, filter: query.filter })) { | ||
| matched.push(event) | ||
| } | ||
| const result: LogsResult = { dir, query, matched: matched.length, events: select(matched, query) } | ||
|
|
||
| if (options.follow) { | ||
| await followLogs(dir, query, options) | ||
| } | ||
| return result | ||
| } | ||
|
|
||
| async function followLogs(dir: string, query: LogsQuery, options: RunLogsOptions): Promise<void> { | ||
| for await (const event of tailFsLogs({ dir, fromEnd: true, level: query.level, filter: query.filter, pollIntervalMs: 250, signal: options.signal })) { | ||
| options.onEvent?.(event) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * `evlog logs` — the wide events the app wrote, from the terminal. | ||
| * Logic lives in {@link runLogs}; this file owns the citty surface. | ||
| */ | ||
| export default defineEvlogCommand('logs', { | ||
| meta: { name: 'logs' }, | ||
| args: { | ||
| what: { type: 'positional', required: false, description: '`errors`, `slow`, or a request id to show in full' }, | ||
| follow: { type: 'boolean', alias: 'f', description: 'Keep reading as the app writes, like tail -f' }, | ||
| since: { type: 'string', description: 'Only events after this: a duration back (15m, 2h, 3d) or a date' }, | ||
| until: { type: 'string', description: 'Only events before this: a duration back or a date' }, | ||
| level: { type: 'string', description: 'Only these levels, comma-separated (error,fatal)' }, | ||
| path: { type: 'string', description: 'Only requests on this exact path' }, | ||
| status: { type: 'string', description: 'Only this status (500) or class (5xx)' }, | ||
| over: { type: 'string', description: 'For `slow`: the duration a request has to exceed (default 500ms)' }, | ||
| limit: { type: 'string', description: 'Most events to show (default 50)' }, | ||
| dir: { type: 'string', description: 'Log directory (default: the project\'s .evlog/logs)' }, | ||
| }, | ||
| async run({ args, cli, ui }) { | ||
| const style = createStyle(cli) | ||
| const controller = new AbortController() | ||
| const stop = (): void => controller.abort() | ||
| if (args.follow) process.once('SIGINT', stop) | ||
|
|
||
| let result: LogsResult | ||
| try { | ||
| result = await runLogs(cli, args, { | ||
| dir: args.dir, | ||
| follow: args.follow, | ||
| signal: controller.signal, | ||
| onEvent: (event) => { | ||
| if (args.json) ui.stdout(JSON.stringify(event)) | ||
| else ui.human(formatLine(style, event)) | ||
| }, | ||
| }) | ||
| } catch (error) { | ||
| if (error instanceof EvlogError) { | ||
| ui.done({ | ||
| jsonMode: args.json, | ||
| json: { error: { code: error.code, message: error.message, why: error.why, fix: error.fix } }, | ||
| human: error.fix ? `${error.message}\n→ ${error.fix}` : error.message, | ||
| }) | ||
| ui.exit(error.code === cliErrors.LOGS_NO_SINK.code ? EXIT_FAIL : EXIT_USAGE) | ||
| return | ||
| } | ||
| throw error | ||
| } finally { | ||
| process.off('SIGINT', stop) | ||
| } | ||
|
|
||
| telemetry.set({ logsView: result.query.view, logsFollow: args.follow === true, logsEvents: result.matched } as unknown as Record<string, boolean | number>) | ||
|
|
||
| /* While following, the one-shot part was already streamed line by line | ||
| before the tail started, so the report is only for the one-shot run. */ | ||
| if (args.follow) return | ||
| ui.done({ | ||
| jsonMode: args.json, | ||
| json: { dir: result.dir, view: result.query.view, count: result.events.length, matched: result.matched, events: result.events }, | ||
| human: formatLogsReport(cli, result), | ||
| }) | ||
| }, | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.