Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/cli-logs.md
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.
5 changes: 5 additions & 0 deletions .changeset/fs-reader-pretty.md
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.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ node_modules
logs
!examples/eve/app/api/demo/logs/
!examples/eve/app/api/demo/logs/**
!packages/cli/src/lib/logs/
!packages/cli/src/lib/logs/**
*.log

# Misc
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/3.cli/0.overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The `evlog` executable ships with the `evlog` package. It runs separately from y

Start with [`evlog map`](/cli/map) to check supported entry points for evlog logging patterns and get a static observability score with suggested fixes. It writes `evlog.map.json` unless you pass `--no-write`. The score describes recognized source patterns, not the logs your handlers produce in production.

[`evlog init`](/cli/init) adds evlog configuration to the app. [`evlog agents`](/cli/agents) writes logging conventions for the AI agents working in the repository.
[`evlog init`](/cli/init) adds evlog configuration to the app. [`evlog logs`](/cli/logs) reads the events the fs drain wrote: the latest requests, the failures, the slowest, or one request in full. [`evlog agents`](/cli/agents) writes logging conventions for the AI agents working in the repository.

::warning{icon="i-lucide-flask-conical"}
**Early days.** `evlog map` has adapters for Nuxt, Nitro, Next.js App Router, TanStack Start, Hono, Express, and Fastify. Each adapter recognizes specific entry-point shapes. Check the detected framework and entry-point count before interpreting the score. Rules can change between releases, so [pin the version](/cli/ci#pin-the-version) when you gate CI on the number.
Expand Down
102 changes: 102 additions & 0 deletions apps/docs/content/3.cli/10.logs.md
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
8 changes: 7 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,15 @@ pnpm evlog map
| `evlog map --baseline [ref]` | Exit 1 on a regression against the committed `evlog.map.json` (path, or `git:<ref>`) |
| `evlog map --no-write` | Skip writing `evlog.map.json` to the project root |
| `evlog map --format github` | GitHub Actions annotations on stdout; or use [`evloghq/action`](https://github.com/evloghq/action), which adds the base comparison, the job summary and a pull request comment |
| `evlog map --format sarif` | SARIF 2.1.0 on stdout, for code scanning |
| `evlog map --verbose` | Show per-file parse warnings |
| `evlog map --cwd <dir>` | Scan another app in the workspace |
| `evlog logs` | The last 50 wide events the fs drain wrote, oldest first |
| `evlog logs errors` | The ones that failed: a `5xx`, an `error` level, or an `error` block |
| `evlog logs slow [--over 1s]` | Over the bar (default 500ms), worst first |
| `evlog logs <requestId>` | One request in full: error with `why`/`fix`, audit record, business fields |
| `evlog logs -f` | Follow new events as the app writes them |
| `evlog logs --since 15m --path /api/x --status 5xx --level error` | Filters, composable with every view |
| `evlog logs --json` | The events as JSON on stdout (one line per event with `-f`) |
| `evlog doctor` | Monorepo-aware diagnosis: Node, project/workspace, stack, evlog install, `.evlog/logs` |
| `evlog doctor --cwd <dir>` | Run against another directory |
| `evlog doctor --debug` | Same, plus a debug wide event (see Debug) |
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export const subCommands = {
{ name: 'doctor', description: 'Diagnose your evlog setup' },
() => import('./doctor'),
),
logs: lazyCommand(
{ name: 'logs', description: 'Read the wide events your app wrote to .evlog/logs' },
() => import('./logs'),
),
map: lazyCommand(
{ name: 'map', description: 'Static observability map — Lighthouse for wide events' },
() => import('./map'),
Expand Down
129 changes: 129 additions & 0 deletions packages/cli/src/commands/logs.ts
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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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),
})
},
})
3 changes: 2 additions & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { COMMON_ARGS } from './lib/command'
import { TELEMETRY_ENDPOINT, TOOL_NAME, VERSION } from './lib/constants'
import { resolveCliEnvironment } from './lib/environment'
import { INIT_TELEMETRY_FIELDS } from './lib/init/telemetry'
import { LOGS_TELEMETRY_FIELDS } from './lib/logs/query'
import { MAP_TELEMETRY_FIELDS } from './lib/map/telemetry-fields'

/**
Expand Down Expand Up @@ -34,7 +35,7 @@ export const main = withTelemetry(
can be calibrated against reality. Values are ids from this CLI's own
catalog — the allowlist is what keeps a free-text answer from ever being
sent. */
collect: { fields: { ...INIT_TELEMETRY_FIELDS, ...MAP_TELEMETRY_FIELDS } },
collect: { fields: { ...INIT_TELEMETRY_FIELDS, ...LOGS_TELEMETRY_FIELDS, ...MAP_TELEMETRY_FIELDS } },
},
)

Expand Down
49 changes: 49 additions & 0 deletions packages/cli/src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,55 @@ export const cliErrors = defineErrorCatalog('cli', {
fix: 'Drop --json, or pass --format json',
tags: ['map'],
},
LOGS_NO_SINK: {
status: 404,
message: ({ cwd }: { cwd: string }) =>
`No local logs under ${cwd}`,
why: 'There is no .evlog/logs directory here and no fs drain is configured, so nothing has been written to read',
fix: 'Run evlog init --drain fs, start the app and make a request, or pass --dir <path>',
link: 'https://evlog.dev/cli/logs',
tags: ['logs'],
},
LOGS_INVALID_TIME: {
status: 400,
message: ({ flag, value }: { flag: string, value: string }) =>
`Invalid --${flag} "${value}"`,
why: 'A time bound that cannot be read would silently become no bound, and the run would show everything',
fix: 'Pass a duration back from now (15m, 2h, 3d) or a date (2026-10-01, 2026-10-01T09:00)',
tags: ['logs'],
},
LOGS_INVALID_LEVEL: {
status: 400,
message: ({ value }: { value: string }) =>
`Unknown level "${value}"`,
why: 'Levels are the six evlog writes',
fix: 'Pass one or more of: trace, debug, info, warn, error, fatal',
tags: ['logs'],
},
LOGS_INVALID_STATUS: {
status: 400,
message: ({ value }: { value: string }) =>
`Invalid --status "${value}"`,
why: 'A status filter that cannot be read would silently match nothing',
fix: 'Pass a status (404) or a class (4xx)',
tags: ['logs'],
},
LOGS_INVALID_DURATION: {
status: 400,
message: ({ flag, value }: { flag: string, value: string }) =>
`Invalid --${flag} "${value}"`,
why: 'A duration that cannot be read would silently fall back to the default',
fix: 'Pass milliseconds (500) or a unit (500ms, 1.5s)',
tags: ['logs'],
},
LOGS_INVALID_LIMIT: {
status: 400,
message: ({ value }: { value: string }) =>
`Invalid --limit "${value}"`,
why: 'A limit that cannot be read would silently become the default',
fix: 'Pass a whole number of 1 or more, e.g. --limit 20',
tags: ['logs'],
},
})

declare module 'evlog' {
Expand Down
Loading
Loading