---
title: "telemetry"
description: "One event per run for CLIs and automation, with privacy-safe flags, consent, an outbox, and disclosure generated from the same config."
canonical_url: "https://www.evlog.dev/use-cases/telemetry/overview"
---
# 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](https://evlog.dev)'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.

<code-group>

```bash [pnpm]
pnpm add @evlog/telemetry
```

```bash [bun]
bun add @evlog/telemetry
```

```bash [yarn]
yarn add @evlog/telemetry
```

```bash [npm]
npm install @evlog/telemetry
```

</code-group>

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.

<prompt :actions="["copy","cursor","claude"]" description="Add anonymous telemetry to my CLI or script" icon="i-lucide-gauge">

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](https://www.evlog.dev/use-cases/telemetry/reference#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](https://www.evlog.dev/use-cases/telemetry/overview)

</prompt>

## 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](https://www.evlog.dev/use-cases/telemetry/ingest))
6. **Store**: dedupe on `idempotencyKey`, then DB / warehouse / evlog drain
7. **Ack**: on `2xx`, delivered keys are removed from the outbox

```text
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

<table>
<thead>
  <tr>
    <th>
      
    </th>
    
    <th>
      
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <strong>
        Published size
      </strong>
    </td>
    
    <td>
      ~28 KB ESM (<code>
        @evlog/telemetry
      </code>
      
      ), ~8.6 KB gzip — standalone, no dependency on <code>
        evlog
      </code>
      
       core
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        Server ingest only
      </strong>
    </td>
    
    <td>
      <code>
        @evlog/telemetry/ingest
      </code>
      
       ~5 KB (~1.7 KB gzip) — <code>
        parseIngestBody()
      </code>
      
       without pulling citty wiring into your API
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        Runtime deps
      </strong>
    </td>
    
    <td>
      <code>
        citty
      </code>
      
       + <code>
        std-env
      </code>
      
       only — both <code>
        sideEffects: false
      </code>
      
      , tree-shakeable ESM
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        Node
      </strong>
    </td>
    
    <td>
      18+ · ESM · <code>
        sideEffects: false
      </code>
      
       on the package
    </td>
  </tr>
</tbody>
</table>

### Fits wherever your code runs

<table>
<thead>
  <tr>
    <th>
      Surface
    </th>
    
    <th>
      Entry
    </th>
    
    <th>
      Lines of integration
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <strong>
        citty CLI
      </strong>
    </td>
    
    <td>
      <code>
        withTelemetry()
      </code>
      
       on the root command
    </td>
    
    <td>
      One wrapper + optional <code>
        defineTelemetryCommands()
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        Script / migrator
      </strong>
    </td>
    
    <td>
      <code>
        createTelemetry()
      </code>
      
       + <code>
        t.run()
      </code>
    </td>
    
    <td>
      Wrap each logical run
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        GitHub Actions
      </strong>
    </td>
    
    <td>
      <code>
        createGitHubActionsTelemetry()
      </code>
    </td>
    
    <td>
      Same as script; <code>
        ghaAction
      </code>
      
       / <code>
        ghaEvent
      </code>
      
       auto-injected
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        Your API (ingest)
      </strong>
    </td>
    
    <td>
      <code>
        parseIngestBody()
      </code>
      
       from <code>
        @evlog/telemetry/ingest
      </code>
    </td>
    
    <td>
      One validator + one POST route
    </td>
  </tr>
</tbody>
</table>

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.

<callout color="neutral" icon="i-lucide-package">

Try it locally: [`examples/telemetry-playground`](https://github.com/evloghq/evlog/tree/main/examples/telemetry-playground): `pnpm run cli -- doctor`, `EVLOG_TELEMETRY_DEBUG=1` to inspect payloads, `telemetry status` for disclosure.

</callout>

## See also

- [Setup](https://www.evlog.dev/use-cases/telemetry/setup): citty, scripts, GitHub Actions, delivery config
- [Ingest](https://www.evlog.dev/use-cases/telemetry/ingest): server endpoint, validation, storage
- [Reference](https://www.evlog.dev/use-cases/telemetry/reference): envelope, schema extensions, disclosure, consent
- [Audit](https://www.evlog.dev/use-cases/audit/overview): wide events for security-sensitive actions
- [Drain pipeline](https://www.evlog.dev/extend/drain-pipeline): forward ingested events into Axiom, Datadog, OTLP, or a custom store

---

- [Setup](https://www.evlog.dev/use-cases/telemetry/setup)
- [Ingest](https://www.evlog.dev/use-cases/telemetry/ingest)
- [Reference](https://www.evlog.dev/use-cases/telemetry/reference)


## Sitemap

See the full [sitemap](https://www.evlog.dev/sitemap.md) for all pages.
