@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
bun add @evlog/telemetry
yarn add @evlog/telemetry
npm install @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()insrc/index.tsand adddefineTelemetryCommands({ name: TOOL }) - Script / migrator:
createTelemetry({ name, version })and wrap each logical unit witht.run('command', fn) - GitHub Actions:
createGitHubActionsTelemetry(). OnlyGITHUB_ACTION/GITHUB_EVENT_NAMEare injected intocustom - Declare
collect.flags/collect.fieldsfor any string values you need beyond the"<set>"marker - Put your product schema in
customviatelemetry.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 commitTELEMETRY.mdbeside the tool - Bake an ingestion URL in
endpoint(orEVLOG_TELEMETRY_ENDPOINT). The local outbox is a buffer, not the destination - Ship
POST /api/telemetry/ingeston my app: useparseIngestBody()from@evlog/telemetry/ingestwithallowedTools+allowedCustomKeys, dedupe onidempotencyKey, return 204 on success
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.
- Run:
my-tool doctorexecutes on the user's machine - Capture:
@evlog/telemetrysanitizes flags, checks consent, and records oneRunEvent - Buffer: the event appends to
~/.config/{toolName}/telemetry/outbox.ndjson - Send: on flush, POST
{ events: RunEvent[] }to your ingest URL - Validate:
parseIngestBody()allowlists tools and custom keys (Ingest) - Store: dedupe on
idempotencyKey, then DB / warehouse / evlog drain - 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 deps | citty + std-env only — both sideEffects: false, tree-shakeable ESM |
| Node | 18+ · ESM · sideEffects: false on the package |
Fits wherever your code runs
| Surface | Entry | Lines of integration |
|---|---|---|
| citty CLI | withTelemetry() on the root command | One wrapper + optional defineTelemetryCommands() |
| Script / migrator | createTelemetry() + t.run() | Wrap each logical run |
| GitHub Actions | createGitHubActionsTelemetry() | Same as script; ghaAction / ghaEvent auto-injected |
| Your API (ingest) | parseIngestBody() from @evlog/telemetry/ingest | One 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 (
doctorvssyncvstelemetry status) - Where runs fail (
outcome,errorCode, version breakdown) - How long operations take (
durationMsper command) - Which versions are still in the wild (
tool.versionon 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.
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