Frameworks

CLI

Example CLI
Observability for command-line tools — wide events per command, drain pipeline, error/audit catalogs, and a citty adapter. Your UI stack stays unchanged.

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

Runnable demo in the evlog repo: 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.

src/drain.ts
+ 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' }))
+ }
src/evlog.ts
+ import { setupEvlog } from '@evlog/cli'
+ import { createCliDrain } from './drain'
+
+ export const setup = setupEvlog({
+   service: 'my-cli',
+   version: '1.0.0',
+   drain: createCliDrain(),
+ })
src/index.ts
+ import { runMain } from '@evlog/cli/citty'
+ import { setup } from './evlog'
- runMain(main)
+ runMain(main, setup)
+   .then(() => setup.flush())
+   .catch(err => exitWithError(err))
src/commands/doctor.ts
+ 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

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

LevelWhat you addDemo command
SimplesetupEvlog + useLogger().set()doctor
MediumOutbound HTTP hooks, error catalogsync
AdvancedAudit catalog, log.audit(), denypull, 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)

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

src/drain.ts
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

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

WhereWhat to setResult
Local devnothing (default).evlog/logs/YYYY-MM-DD.jsonl on disk
Local dev + cloudEVLOG_DRAIN=axiom + Axiom envevents in your Axiom dataset
CI / cron / serversame env on the hostoperator chooses destination per environment
Published npm binaryenv on the machine that runs my-clino 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)

AdapterImportRequired env (typical)Docs
File systemevlog/fsEVLOG_LOG_DIR (optional)fs
Axiomevlog/axiomAXIOM_API_KEY, AXIOM_DATASETaxiom
Datadogevlog/datadogDATADOG_API_KEY, DATADOG_SITEdatadog
OTLPevlog/otlpOTEL_EXPORTER_OTLP_ENDPOINTotlp
Better Stackevlog/better-stackBETTER_STACK_SOURCE_TOKENbetter-stack
HyperDXevlog/hyperdxHYPERDX_API_KEYhyperdx
PostHogevlog/posthogPOSTHOG_API_KEYposthog
Sentryevlog/sentrySENTRY_DSNsentry

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:

  1. Default: local drain — works offline, zero credentials, good for doctor / support scripts.
  2. Production: env on the host — platform team sets EVLOG_DRAIN + provider keys in systemd, CI secrets, or a .env the operator loads.

The CLI author ships drain.ts + .env.example; the operator decides where events go.

Mirror of the demo CLI:

my-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
PackageImportRole
citty@evlog/cli/cittyrunMain(main, setup) wraps each run() in invoke()
ofetch@evlog/cli/httpOutbound 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.

src/drain.ts
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',
  }))
}
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(),
})

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

src/commands/doctor.ts
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:

src/catalogs/errors.ts
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
  }
}
src/catalogs/audit.ts
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
  }
}
src/catalogs/actor.ts
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

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

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()
Whenonce at startup (src/evlog.ts)inside every command handler
Doesconfigure drain, redact, catalogsreturns the command-scoped logger
AnalogEvlogModule.forRoot() / Nitro pluginuseLogger() in Express, Nest, Hono
You callrunMain(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 for throw errorCatalog.X()
  • auditCatalog — from config (defineAuditCatalog); types for log.audit({ action: 'myapp.…' })
  • audit() — shortcut that calls useLogger().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

FieldCLI value
method'CLI'
path'/<command>' or '/<cmd>/<subcmd>'
statusexit code (0 success, catalog status on error)
cli.commandcommand segment
cli.flagsparsed flags (secrets redacted)
cli.versionfrom setupEvlog({ version })
cli.tty{ stdin, stdout, stderr } booleans
cli.citrue 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