Telemetry

telemetry

One event per run for CLIs and automation, with privacy-safe flags, consent, an outbox, and disclosure generated from the same config.

@evlog/telemetry brings evlog's wide-event model to tools that run on other people's machines: CLIs, GitHub Actions, dev scripts, and CI jobs. Same philosophy as HTTP logging: one command execution → one structured event, not a stream of analytics calls.

pnpm add @evlog/telemetry

You get command name, sanitized flags, duration, and outcome automatically. Call telemetry.set() only when you have extra counters, numbers and booleans by default. Raw argv is never read; disclosure is generated from your runtime config so it cannot drift from what you actually collect.

Add privacy-respecting telemetry to my CLI, script, or GitHub Action with @evlog/telemetry.

  • Detect my entrypoint: citty CLI (withTelemetry), standalone script (createTelemetry), or GitHub Actions (createGitHubActionsTelemetry)
  • Install: pnpm add @evlog/telemetry (or npm/yarn/bun)
  • Citty: wrap the root command in withTelemetry() in src/index.ts and add defineTelemetryCommands({ name: TOOL })
  • Script / migrator: createTelemetry({ name, version }) and wrap each logical unit with t.run('command', fn)
  • GitHub Actions: createGitHubActionsTelemetry(). Only GITHUB_ACTION / GITHUB_EVENT_NAME are injected into custom
  • Declare collect.flags / collect.fields for any string values you need beyond the "<set>" marker
  • Put your product schema in custom via telemetry.set(), see Extending the schema
  • Call telemetry.set() for business counters (numbers/booleans) inside handlers or helpers on the same async stack
  • Run generateDisclosure() and commit TELEMETRY.md beside the tool
  • Bake an ingestion URL in endpoint (or EVLOG_TELEMETRY_ENDPOINT). The local outbox is a buffer, not the destination
  • Ship POST /api/telemetry/ingest on my app: use parseIngestBody() from @evlog/telemetry/ingest with allowedTools + allowedCustomKeys, dedupe on idempotencyKey, return 204 on success

Docs: https://www.evlog.dev/use-cases/telemetry/overview

From CLI to your backend

The disk outbox (~/.config/{toolName}/telemetry/outbox.ndjson) is a reliability buffer, not where telemetry lives long-term. Each run appends one event locally, then the HTTP drain POSTs the backlog to your ingestion endpoint. Short-lived CI jobs and offline machines drain on the next invocation.

  1. Run: my-tool doctor executes on the user's machine
  2. Capture: @evlog/telemetry sanitizes flags, checks consent, and records one RunEvent
  3. Buffer: the event appends to ~/.config/{toolName}/telemetry/outbox.ndjson
  4. Send: on flush, POST { events: RunEvent[] } to your ingest URL
  5. Validate: parseIngestBody() allowlists tools and custom keys (Ingest)
  6. Store: dedupe on idempotencyKey, then DB / warehouse / evlog drain
  7. Ack: on 2xx, delivered keys are removed from the outbox
CLI → @evlog/telemetry → outbox.ndjson → POST /ingest → validate → store

Nothing is sent until you configure an endpoint and the server returns a success status. Until then, events stay in the outbox (or are purged on opt-out).

Why install it

You can wire anonymous usage telemetry yourself: outbox file, consent flags, flag sanitization, disclosure markdown, ingest validation, first-run notice. Or you add one dependency and get the evlog playbook baked in.

What you get for ~28 KB

Published size~28 KB ESM (@evlog/telemetry), ~8.6 KB gzip — standalone, no dependency on evlog core
Server ingest only@evlog/telemetry/ingest ~5 KB (~1.7 KB gzip) — parseIngestBody() without pulling citty wiring into your API
Runtime depscitty + std-env only — both sideEffects: false, tree-shakeable ESM
Node18+ · ESM · sideEffects: false on the package

Fits wherever your code runs

SurfaceEntryLines of integration
citty CLIwithTelemetry() on the root commandOne wrapper + optional defineTelemetryCommands()
Script / migratorcreateTelemetry() + t.run()Wrap each logical run
GitHub ActionscreateGitHubActionsTelemetry()Same as script; ghaAction / ghaEvent auto-injected
Your API (ingest)parseIngestBody() from @evlog/telemetry/ingestOne validator + one POST route

No framework lock-in. No PostHog / Segment / custom analytics SDK. No second schema to maintain, since disclosure is generated from the same collect config the CLI uses at runtime.

What you skip building

  • Privacy-safe flag capture (never reads raw argv)
  • DO_NOT_TRACK / EVLOG_TELEMETRY / persisted opt-out with outbox purge
  • Disk outbox with lockfile, stale-lock recovery, and backlog drain
  • Auto-generated disclosure (TELEMETRY.md + telemetry status)
  • First-run notice with opt-out instructions
  • Typed telemetry.set() with allowlisted string fields
  • Server-side ingest validation aligned with the CLI envelope
  • Hard guarantee: telemetry never throws, never blocks exit

The payoff

Once ingest is live you can answer product questions that CLI authors usually guess at:

  • Which commands are actually used (doctor vs sync vs telemetry status)
  • Where runs fail (outcome, errorCode, version breakdown)
  • How long operations take (durationMs per command)
  • Which versions are still in the wild (tool.version on every event)
  • Whether CI or local dev dominates (env.ci, env.agent, env.tty)

All from one wide event per run, the same mental model as evlog on the server, without bolting a browser analytics stack onto a terminal tool.

Try it locally: examples/telemetry-playground: pnpm run cli -- doctor, EVLOG_TELEMETRY_DEBUG=1 to inspect payloads, telemetry status for disclosure.

See also

  • Setup: citty, scripts, GitHub Actions, delivery config
  • Ingest: server endpoint, validation, storage
  • Reference: envelope, schema extensions, disclosure, consent
  • Audit: wide events for security-sensitive actions
  • Drain pipeline: forward ingested events into Axiom, Datadog, OTLP, or a custom store