CLI
@evlog/cli adds command → exit observability on top of evlog. It does not replace citty, Clack, or your stdout JSON contract — it owns telemetry (wide events + drain) while you keep your existing CLI stack.
pnpm example:cli doctor — wide events land in examples/cli/.evlog/logs/. Source: examples/cli.Add evlog to my existing CLI
Create a new CLI with evlog
Add evlog to an existing citty CLI
Three changes — everything else stays the same.
+ 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',
+ drain: createCliDrain(),
+ })
+ import { runMain } from '@evlog/cli/citty'
+ import { setup } from './evlog'
- runMain(main)
+ runMain(main, setup)
+ .then(() => setup.flush())
+ .catch(err => exitWithError(err))
+ import { useLogger } from '@evlog/cli'
async run() {
+ const log = useLogger()
+ log.set({ checks: results })
p.intro('my-cli doctor') // Clack unchanged
// …
}
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 (errorCatalog, throw errorCatalog.X()) | yes | |
Audit catalog (auditCatalog, log.audit()) | yes |
citty runMain
│
▼
evlog.invoke() ──────────────────────────► drain (.evlog/logs, Axiom, …)
│
▼
command handler ──► useLogger().set() / log.audit()
│
▼
Clack / console / your --json ──► stdout/stderr (unchanged)
Default: evlog console is silent — wide events go to the drain only. Pass --log (auto-injected by runMain) to echo wide events on stderr while debugging.
Why evlog on a CLI
You do not need audit catalogs on day one. evlog gives you one structured wide event per command — duration, exit status, flags (redacted), and whatever you log.set() — drained automatically to a file or your observability provider.
| Level | What you add | Demo command |
|---|---|---|
| Simple | setupEvlog + useLogger().set() | doctor |
| Medium | Outbound HTTP hooks, error catalog | sync |
| Advanced | Audit catalog, log.audit(), deny | pull, deploy |
Catalogs are optional. Start with a drain + log.set() — that alone replaces ad-hoc console.log debugging with queryable NDJSON.
Send events to Axiom (or another provider)
setupEvlog({ drain }). Without it, telemetry is dropped. The default in most setups is createFsDrain() — local NDJSON under .evlog/logs/.On HTTP apps, the framework wires the drain for you (Nuxt hook, Express middleware, …). On a CLI, you choose the adapter in one file — usually src/drain.ts — and pass it to setupEvlog.
1. Add src/drain.ts
Pick a backend at runtime from environment variables. Never hard-code API keys in source or the published bundle.
import type { DrainContext } from 'evlog'
import { createAxiomDrain } from 'evlog/axiom'
import { createDatadogDrain } from 'evlog/datadog'
import { createFsDrain } from 'evlog/fs'
import { createOtlpDrain } from 'evlog/otlp'
import { createDrainPipeline } from 'evlog/pipeline'
const pipeline = createDrainPipeline<DrainContext>({ batch: { size: 20 } })
export function createCliDrain() {
switch (process.env.EVLOG_DRAIN ?? 'fs') {
case 'axiom':
// Reads AXIOM_API_KEY / AXIOM_DATASET (or AXIOM_TOKEN) from the environment
return pipeline(createAxiomDrain())
case 'datadog':
return pipeline(createDatadogDrain())
case 'otlp':
return pipeline(createOtlpDrain())
default:
return pipeline(createFsDrain({
dir: process.env.EVLOG_LOG_DIR ?? '.evlog/logs',
}))
}
}
EVLOG_DRAIN is a convention for your CLI — evlog does not define it. Name it whatever you want; the demo uses fs | axiom | datadog | otlp.
Each adapter reads its own env vars when you call createXxxDrain() with no arguments. See the adapters overview for full lists.
2. Wire the drain in src/evlog.ts
import { setupEvlog } from '@evlog/cli'
import { createCliDrain } from './drain'
export const setup = setupEvlog({
service: 'my-cli',
version: '1.0.0',
redact: true,
drain: createCliDrain(),
})
3. Configure credentials where the CLI runs
| Where | What to set | Result |
|---|---|---|
| Local dev | nothing (default) | .evlog/logs/YYYY-MM-DD.jsonl on disk |
| Local dev + cloud | EVLOG_DRAIN=axiom + Axiom env | events in your Axiom dataset |
| CI / cron / server | same env on the host | operator chooses destination per environment |
| Published npm binary | env on the machine that runs my-cli | no token in the package |
# Local — inspect NDJSON
my-cli doctor
tail -f .evlog/logs/$(date +%Y-%m-%d).jsonl
# Send to Axiom instead (credentials on the host, not in your repo)
export EVLOG_DRAIN=axiom
export AXIOM_API_KEY=xaat-… # or AXIOM_TOKEN (deprecated alias)
export AXIOM_DATASET=my-cli
my-cli doctor
# Datadog
export EVLOG_DRAIN=datadog
export DATADOG_API_KEY=…
export DATADOG_SITE=datadoghq.com
# Any OTLP collector (Grafana Cloud, Honeycomb, …)
export EVLOG_DRAIN=otlp
export OTEL_EXPORTER_OTLP_ENDPOINT=https://…
export OTEL_SERVICE_NAME=my-cli
Ship a .env.example with commented placeholders — document what operators must set, not real secrets.
Provider env vars (quick reference)
| Adapter | Import | Required env (typical) | Docs |
|---|---|---|---|
| File system | evlog/fs | EVLOG_LOG_DIR (optional) | fs |
| Axiom | evlog/axiom | AXIOM_API_KEY, AXIOM_DATASET | axiom |
| Datadog | evlog/datadog | DATADOG_API_KEY, DATADOG_SITE | datadog |
| OTLP | evlog/otlp | OTEL_EXPORTER_OTLP_ENDPOINT | otlp |
| Better Stack | evlog/better-stack | BETTER_STACK_SOURCE_TOKEN | better-stack |
| HyperDX | evlog/hyperdx | HYPERDX_API_KEY | hyperdx |
| PostHog | evlog/posthog | POSTHOG_API_KEY | posthog |
| Sentry | evlog/sentry | SENTRY_DSN | sentry |
Adapters also accept config objects if you prefer explicit wiring in code (fine for internal tools, not for published CLIs):
pipeline(createAxiomDrain({ apiKey: process.env.AXIOM_API_KEY!, dataset: 'my-cli' }))
Packaged CLI — credentials never in the bundle
A published CLI (npm install -g my-cli) must not embed provider tokens. The pattern above keeps observability in the binary and the destination on the host:
- Default: local drain — works offline, zero credentials, good for
doctor/ support scripts. - Production: env on the host — platform team sets
EVLOG_DRAIN+ provider keys in systemd, CI secrets, or a.envthe operator loads.
The CLI author ships drain.ts + .env.example; the operator decides where events go.
Recommended project layout
Mirror of the demo CLI:
my-cli/
├── package.json
├── tsconfig.json
└── src/
├── index.ts # runMain, flush, exitWithError
├── drain.ts # createCliDrain() — fs default, axiom via env
├── evlog.ts # setupEvlog()
├── catalogs/ # optional — advanced commands only
│ ├── actor.ts # resolveCliActor() for audit.actor
│ ├── errors.ts
│ └── audit.ts
└── commands/
├── index.ts # main + subCommands
├── doctor.ts
└── pull.ts
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 |
Walkthrough
1. Drain + setup (no catalogs required)
Start with local files; add Axiom/Datadog/OTLP branches when you need cloud — see Send events to Axiom (or another provider) above.
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())
}
return pipeline(createFsDrain({
dir: process.env.EVLOG_LOG_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(),
})
Every command now emits a wide event to the drain on exit. Add errorCatalog / auditCatalog later when you need typed errors or audit trails.
2. Simple command — wide event only
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`)
},
})
That is enough for observability — one NDJSON line per run with checks, duration, status, cli.command.
3. Optional — error & 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'],
},
DEPLOY: {
target: 'deployment',
severity: 'critical',
requiresChanges: true,
description: 'Promote a build to a region',
},
})
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 them to setupEvlog({ errorCatalog, auditCatalog }). See the demo CLI pull and deploy commands for full audit usage.
4. Entry — src/index.ts
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(error => exitWithError(error))
What you see vs what gets drained
$ my-cli doctor
◆ my-cli doctor
│
◇ Running checks
│
◆ All 2 checks passed
$ my-cli doctor --log
◆ my-cli doctor
…
19:04:12 INFO [my-cli] CLI /doctor in 98ms
└─ checks: …
$ my-cli doctor --json
{"checks":[{"name":"config","ok":true},{"name":"api","ok":true}]}
The NDJSON wide event is always written to the drain — e.g. .evlog/logs/2026-05-30.jsonl with createFsDrain.
API reference
setupEvlog(config) vs useLogger()
Two roles — same split as HTTP middleware:
setupEvlog() | useLogger() | |
|---|---|---|
| When | once at startup (src/evlog.ts) | inside every command handler |
| Does | configure drain, redact, catalogs | returns the command-scoped logger |
| Analog | EvlogModule.forRoot() / Nitro plugin | useLogger() in Express, Nest, Hono |
| You call | runMain(main, setup), setup.flush() | log.set(), log.audit(), throw errorCatalog.X() |
setupEvlog configures the global pipeline. Each citty command gets its own logger (path /doctor, flags, duration) via AsyncLocalStorage — useLogger() retrieves it anywhere in the call stack.
const log = useLogger()
log.set({ checks }) // not setup.set() — setup is config, log is telemetry
setupEvlog(config)
Boot once at startup. Options: service, version, drain, errorCatalog, auditCatalog, redact (default true), flushOnExit (default true), logToConsole (always print wide events, same as --log).
Returns { invoke, log, errorCatalog, auditCatalog, audit, flush }.
errorCatalog— from config (defineErrorCatalog); import in commands forthrow errorCatalog.X()auditCatalog— from config (defineAuditCatalog); types forlog.audit({ action: 'myapp.…' })audit()— shortcut that callsuseLogger().audit()outside a handler (rare)
useLogger()
Inside any invoke() / runMain handler (or any function called from there). Import from @evlog/cli — no need to pass setup around.
runMain(main, setup) — @evlog/cli/citty
Wraps every citty run() in invoke(). Auto-injects global --log.
exitWithError(err) / parseCliError(err)
Human-readable message on stderr + process.exit. Throw catalog errors in handlers: throw errorCatalog.CONFIG_MISSING().
--log
evlog-only flag. Pretty wide events on stderr. Injected by runMain — no manual spread.
Wide event shape
| Field | CLI value |
|---|---|
method | 'CLI' |
path | '/<command>' or '/<cmd>/<subcmd>' |
status | exit code (0 success, catalog status on error) |
cli.command | command segment |
cli.flags | parsed flags (secrets redacted) |
cli.version | from setupEvlog({ version }) |
cli.tty | { stdin, stdout, stderr } booleans |
cli.ci | true when CI env is set |
Adoption levels
Level 0 — createCommandLogger
For libraries inside someone else's CLI — no global drain bootstrap:
import { createCommandLogger } from '@evlog/cli'
const log = createCommandLogger({ command: 'migrate', version: '2.0.0' })
log.set({ records: 150 })
log.emit({ status: 0 })
Level 1 — manual invoke()
When not using citty:
await setup.invoke({ command: 'backup', flags: { target: 's3' } }, async (log) => {
log.set({ files: 42 })
})
Level 2 — citty runMain
Recommended for multi-command CLIs — see walkthrough above.
Audit
Use the catalog wrapper — target.type comes from the catalog entry; add resource metadata on target:
import { auditCatalog } from '../catalogs/audit'
import { resolveCliActor } from '../catalogs/actor'
const log = useLogger()
const actor = resolveCliActor()
log.audit(auditCatalog.SECRET_PULL({
actor,
target: {
id: args.env,
env: args.env,
resource: 'secrets',
access: 'read',
},
outcome: 'success',
changes: {
after: { keyCount: 3, keys: ['DATABASE_URL', 'API_KEY'] },
},
}))
// AuthZ denial — auditors care about these too
log.audit.deny('Missing API token', auditCatalog.SECRET_PULL({
actor,
target: { id: args.env, access: 'read' },
}))
Fields merge into the wide event. cliRedactPreset + auditRedactPreset apply when redact: true.
Outbound HTTP (ofetch)
import { ofetch } from 'ofetch'
import { useLogger } from '@evlog/cli'
import { createOutboundHooks } from '@evlog/cli/http'
const log = useLogger()
const api = ofetch.create(createOutboundHooks(log))
await api('https://api.example.com/v1/records')
Drain & long-running commands
Same adapters as HTTP — evlog/fs, evlog/axiom, evlog/datadog, evlog/otlp, evlog/pipeline. Wire them in src/drain.ts and pass createCliDrain() to setupEvlog. Pipeline drains flush on exit by default.
await setup.flush()
Watch / REPL — disable auto-emit:
await setup.invoke({ command: 'run', longRunning: true }, async (log) => {
log.set({ phase: 'boot' })
log.emit()
})
Demo CLI
From the evlog monorepo root:
pnpm example:cli doctor
pnpm example:cli pull --env staging
pnpm example:cli doctor --log
pnpm example:cli doctor --json
tail -f examples/cli/.evlog/logs/$(date +%Y-%m-%d).jsonl
export EVLOG_DRAIN=axiom AXIOM_API_KEY=… AXIOM_DATASET=my-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.