CLI
@evlog/cli adds observability from command start to exit. It does not replace citty, Clack, or your --json stdout contract. You keep routing and terminal UI; evlog owns wide events, the drain, redaction, and optional catalogs.
pnpm example:cli doctor writes NDJSON to examples/cli/.evlog/logs/. Source: examples/cli.Add evlog to my existing CLI
Create a new CLI with evlog
Install
pnpm add @evlog/cli evlog citty
pnpm add @clack/prompts # optional — UI only
bun add @evlog/cli evlog citty
bun add @clack/prompts
yarn add @evlog/cli evlog citty
yarn add @clack/prompts
npm install @evlog/cli evlog citty
npm install @clack/prompts
| Package | Import | Role |
|---|---|---|
citty | @evlog/cli/citty | runMain(main, setup) wraps each run() in invoke() |
ofetch | @evlog/cli/http | Outbound HTTP fields on the wide event |
Integrate (four files)
Add evlog to an existing citty CLI in four steps. Your Clack spinners, --json flag, and subcommand tree stay as they are.
import type { DrainContext } from 'evlog'
import { createFsDrain } from 'evlog/fs'
import { createDrainPipeline } from 'evlog/pipeline'
const pipeline = createDrainPipeline<DrainContext>({ batch: { size: 20 } })
export function createCliDrain() {
return pipeline(createFsDrain({ dir: '.evlog/logs' }))
}
import { setupEvlog } from '@evlog/cli'
import { createCliDrain } from './drain'
export const setup = setupEvlog({
service: 'my-cli',
version: '1.0.0',
redact: true,
drain: createCliDrain(),
})
import { exitWithError } from '@evlog/cli'
import { runMain } from '@evlog/cli/citty'
import { setup } from './evlog'
import { main } from './commands'
runMain(main, setup)
.then(() => setup.flush())
.catch(async (error) => {
await setup.flush().catch(() => {})
exitWithError(error)
})
import { defineCommand } from 'citty'
import * as p from '@clack/prompts'
import { useLogger } from '@evlog/cli'
export const doctor = defineCommand({
meta: { name: 'doctor', description: 'Health checks' },
async run() {
const log = useLogger()
p.intro('my-cli doctor')
const checks = [{ name: 'config', ok: true }, { name: 'api', ok: true }]
log.set({ checks })
p.outro(`All ${checks.length} checks passed`)
},
})
Each command run emits one wide event to the drain: duration, exit status, redacted flags, and whatever you pass to log.set().
What you see vs what gets drained
$ my-cli doctor
◆ my-cli doctor
◇ Running checks
◆ All 2 checks passed
$ my-cli doctor --log
… wide event pretty-printed on stderr …
$ my-cli doctor --json
{"checks":[{"name":"config","ok":true}]}
Stdout stays yours. NDJSON wide events always go to the drain (for example .evlog/logs/2026-05-30.jsonl with createFsDrain).
What evlog does / does not do
| evlog | Your app | |
|---|---|---|
Routing (--help, subcommands) | citty | |
| Terminal UI (spinners, colors) | Clack, consola, … | |
--json stdout shape | your flag, your format | |
| Wide events + drain | yes | |
--log debug on stderr | yes | |
Redact secrets in cli.flags | yes | |
| Error catalog | yes | |
| Audit catalog | yes |
citty runMain → evlog.invoke() → drain (.evlog/logs, Axiom, …)
↓
useLogger().set() / log.audit()
↓
Clack / console / your --json → stdout (unchanged)
By default the evlog console is silent. Pass --log (auto-injected by runMain) to echo wide events on stderr while debugging.
Why evlog on a CLI
Every command is a small operation with a clear start and end. That maps cleanly to a wide event: duration, exit code, redacted flags, and the fields you attach with log.set(). The result lands as queryable NDJSON locally, or in Axiom, Datadog, or OTLP when you wire a cloud drain.
Audit and error catalogs are optional. Add them when you need typed throws or security-sensitive actions, not on day one.
| Level | What you add | Demo |
|---|---|---|
| Simple | setupEvlog + useLogger().set() | doctor |
| Medium | Outbound HTTP hooks, error catalog | sync |
| Advanced | Audit catalog, log.audit(), deny | pull, deploy |
See the demo CLI for all three levels in one repo.
Send events to Axiom (or another provider)
setupEvlog({ drain }). Without it, telemetry is dropped. Most setups start with createFsDrain() and local NDJSON under .evlog/logs/.On HTTP apps the framework wires the drain for you. On a CLI, you pick the adapter in src/drain.ts and pass createCliDrain() to setupEvlog. Credentials live on the host that runs the binary, never in the published package.
import type { DrainContext } from 'evlog'
import { createAxiomDrain } from 'evlog/axiom'
import { createFsDrain } from 'evlog/fs'
import { createDrainPipeline } from 'evlog/pipeline'
const pipeline = createDrainPipeline<DrainContext>({ batch: { size: 20 } })
export function createCliDrain() {
if (process.env.EVLOG_DRAIN === 'axiom') {
return pipeline(createAxiomDrain()) // AXIOM_API_KEY, AXIOM_DATASET from env
}
return pipeline(createFsDrain({
dir: process.env.EVLOG_LOG_DIR ?? '.evlog/logs',
}))
}
my-cli doctor
tail -f .evlog/logs/$(date +%Y-%m-%d).jsonl
export EVLOG_DRAIN=axiom
export AXIOM_API_KEY=xaat-…
export AXIOM_DATASET=my-cli
my-cli doctor
EVLOG_DRAIN is a convention for your CLI, not a built-in evlog flag. Swap in evlog/datadog, evlog/otlp, etc. the same way. Full env lists: adapters overview.
| Adapter | Import | Typical env |
|---|---|---|
| File system | evlog/fs | EVLOG_LOG_DIR |
| Axiom | evlog/axiom | AXIOM_API_KEY, AXIOM_DATASET |
| Datadog | evlog/datadog | DATADOG_API_KEY, DATADOG_SITE |
| OTLP | evlog/otlp | OTEL_EXPORTER_OTLP_ENDPOINT |
Ship a .env.example with commented placeholders for operators. Never commit real keys.
Go further (optional)
Error and audit catalogs
Add when commands throw typed errors or record sensitive actions:
import { defineErrorCatalog } from 'evlog'
export const errorCatalog = defineErrorCatalog('myapp', {
CONFIG_MISSING: {
status: 1,
message: 'No config file found',
fix: 'Run myapp init or set MYAPP_CONFIG.',
},
})
declare module 'evlog' {
interface RegisteredErrorCatalogs {
myapp: typeof errorCatalog
}
}
import { defineAuditCatalog } from 'evlog'
export const auditCatalog = defineAuditCatalog('myapp', {
SECRET_PULL: {
target: 'secret_store',
severity: 'high',
description: 'Read secrets from a remote store',
redactPaths: ['token', 'password'],
},
})
declare module 'evlog' {
interface RegisteredAuditCatalogs {
myapp: typeof auditCatalog
}
}
import type { AuditActor } from 'evlog'
export function resolveCliActor(): AuditActor {
const id = process.env.USER ?? 'unknown'
return { type: 'user', id, displayName: id }
}
Pass errorCatalog and auditCatalog to setupEvlog(). Full audit usage: demo pull and deploy commands.
log.audit(auditCatalog.SECRET_PULL({
actor: resolveCliActor(),
target: { id: args.env, resource: 'secrets', access: 'read' },
outcome: 'success',
}))
log.audit.deny('Missing API token', auditCatalog.SECRET_PULL({
actor: resolveCliActor(),
target: { id: args.env, access: 'read' },
}))
Outbound HTTP (ofetch)
import { ofetch } from 'ofetch'
import { useLogger } from '@evlog/cli'
import { createOutboundHooks } from '@evlog/cli/http'
const api = ofetch.create(createOutboundHooks(useLogger()))
await api('https://api.example.com/v1/records')
Project layout
my-cli/src/
├── index.ts # runMain, flush, exitWithError
├── drain.ts # createCliDrain()
├── evlog.ts # setupEvlog()
├── catalogs/ # optional
└── commands/
API reference
setupEvlog() vs useLogger()
setupEvlog() | useLogger() | |
|---|---|---|
| When | once in src/evlog.ts | inside every command handler |
| Role | drain, redact, catalogs | command-scoped logger |
| You call | runMain(main, setup), setup.flush() | log.set(), log.audit(), throw errorCatalog.X() |
setupEvlog configures the pipeline once. Each citty command gets its own logger (path: /doctor, flags, duration) via async context.
setupEvlog(config) — service, version, drain, errorCatalog, auditCatalog, redact (default true), flushOnExit, logToConsole. Returns { invoke, log, errorCatalog, auditCatalog, audit, flush }.
useLogger() — inside any invoke() / runMain handler. Import from @evlog/cli; no need to pass setup around.
runMain(main, setup) — @evlog/cli/citty. Wraps every run() in invoke(). Auto-injects --log.
exitWithError(err) — human-readable stderr + process.exit. Use after setup.flush() on the error path.
Wide event shape
| Field | CLI value |
|---|---|
method | 'CLI' |
path | '/<command>' |
status | exit code |
cli.command | command segment |
cli.flags | parsed flags (redacted) |
cli.version | from setupEvlog({ version }) |
createCommandLogger (libraries)
For code inside someone else's CLI, without global bootstrap:
import { createCommandLogger } from '@evlog/cli'
const log = createCommandLogger({ command: 'migrate', version: '2.0.0' })
log.set({ records: 150 })
log.emit({ status: 0 })
Long-running commands
await setup.flush()
await setup.invoke({ command: 'watch', longRunning: true }, async (log) => {
log.set({ phase: 'boot' })
log.emit()
})
Try it
From the evlog monorepo root:
pnpm example:cli doctor
pnpm example:cli sync 3
pnpm example:cli doctor --log
tail -f examples/cli/.evlog/logs/$(date +%Y-%m-%d).jsonl
export EVLOG_DRAIN=axiom AXIOM_API_KEY=… AXIOM_DATASET=evlog-demo-cli
pnpm example:cli doctor
AWS Lambda
Wide events and structured logging in AWS Lambda functions, including SQS consumers and event-driven handlers.
Overview
Recipes that solve a specific problem with evlog — capture browser logs, observe AI SDK calls, identify users from Better Auth, build a tamper-evident audit trail, enrich every event with derived context.