Frameworks

CLI

Example CLI
Observability for command-line tools. One wide event per command, drain pipeline, optional error and audit catalogs. Your citty and Clack stack stays unchanged.

@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.

Try it in the repo: 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
PackageImportRole
citty@evlog/cli/cittyrunMain(main, setup) wraps each run() in invoke()
ofetch@evlog/cli/httpOutbound 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' }))
}

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

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

evlogYour app
Routing (--help, subcommands)citty
Terminal UI (spinners, colors)Clack, consola, …
--json stdout shapeyour flag, your format
Wide events + drainyes
--log debug on stderryes
Redact secrets in cli.flagsyes
Error catalogyes
Audit catalogyes
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.

LevelWhat you addDemo
SimplesetupEvlog + useLogger().set()doctor
MediumOutbound HTTP hooks, error catalogsync
AdvancedAudit catalog, log.audit(), denypull, deploy

See the demo CLI for all three levels in one repo.

Send events to Axiom (or another provider)

evlog does not auto-send to Axiom, Datadog, or OTLP. Wide events leave the process only when you pass a drain to 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',
  }))
}

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.

AdapterImportTypical env
File systemevlog/fsEVLOG_LOG_DIR
Axiomevlog/axiomAXIOM_API_KEY, AXIOM_DATASET
Datadogevlog/datadogDATADOG_API_KEY, DATADOG_SITE
OTLPevlog/otlpOTEL_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
  }
}

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()
Whenonce in src/evlog.tsinside every command handler
Roledrain, redact, catalogscommand-scoped logger
You callrunMain(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

FieldCLI value
method'CLI'
path'/<command>'
statusexit code
cli.commandcommand segment
cli.flagsparsed flags (redacted)
cli.versionfrom 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