---
title: "Telemetry Reference"
description: "RunEvent envelope, extending the schema with collect and custom fields, privacy rules, TELEMETRY.md disclosure, and consent / outbox reliability."
canonical_url: "https://www.evlog.dev/use-cases/telemetry/reference"
---
# Telemetry Reference

> RunEvent envelope, extending the schema with collect and custom fields, privacy rules, TELEMETRY.md disclosure, and consent / outbox reliability.

## Standard envelope

Every run shares the same shape. You do not declare per-command schemas.

```jsonc
{
  "event": "run",
  "command": "sync",
  "durationMs": 412,
  "outcome": "success",
  "flags": { "dryRun": true, "output": "<set>" },
  "tool": { "name": "my-tool", "version": "1.0.0" },
  "env": {
    "node": "20.11",
    "ci": false,
    "provider": null,
    "tty": true,
    "agent": "cursor"   // std-env: cursor, claude, codex, … or null
  },
  "machineId": "ab3f…", // hashed; omitted in ephemeral CI
  "custom": { "itemsSynced": 42 }
}
```

## Extending the schema

The envelope above is **fixed**: every tool sends the same top-level shape so `parseIngestBody()` and disclosure stay predictable. You do not add fields next to `command` or `durationMs`.

Your product schema lives in two extension zones:

<table>
<thead>
  <tr>
    <th>
      Zone
    </th>
    
    <th>
      Who sets it
    </th>
    
    <th>
      What goes there
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        flags
      </code>
    </td>
    
    <td>
      citty (auto) + <code>
        collect.flags
      </code>
    </td>
    
    <td>
      Flags you passed — booleans/numbers as values, strings only when allowlisted
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        custom
      </code>
    </td>
    
    <td>
      you via <code>
        telemetry.set()
      </code>
      
       + <code>
        collect.fields
      </code>
    </td>
    
    <td>
      Business counters, categorical dimensions, schema version
    </td>
  </tr>
</tbody>
</table>

Think of it as two contracts:

- **Transport contract** (`RunEvent`): owned by `@evlog/telemetry`, stable across tools
- **Product contract** (`custom` + declared `flags`): owned by you, declared in `collect`, mirrored on the server

### Declare your extensions once

Everything you collect beyond the standard envelope is declared inline in the same `withTelemetry()` / `createTelemetry()` call:

```ts [src/index.ts]
withTelemetry(command, {
  name: 'acme-cli',
  version: '2.1.0',
  endpoint: 'https://telemetry.acme.dev/api/ingest',
  collect: {
    flags: {
      format: ['json', 'yaml', 'table'],
      target: ['staging', 'production'],
    },
    fields: {
      product: ['cli', 'action', 'migrator'],
      plan: ['free', 'pro', 'enterprise'],
      framework: ['nuxt', 'next', 'remix'],
    },
  },
})
```

Inside handlers, add counters and dimensions:

```ts [src/commands/deploy.ts]
await t.run('deploy', async () => {
  const result = await deployServices()
  telemetry.set({
    servicesDeployed: result.count,   // number — always allowed
    rollbackUsed: false,              // boolean — always allowed
    framework: 'nuxt',                // string — allowed (in collect.fields)
    schemaVersion: 2,                 // number — version your custom shape
  })
})
```

Undeclared strings are **dropped at runtime**, never thrown. That is the privacy guarantee: no accidental paths, tokens, or free-form PII in `custom`.

<note>

**Typing:** `TelemetryHandle.set()` autocomplete covers declared `collect.fields` keys. Use the ambient `telemetry.set()` for extra numeric/boolean counters in the same run, and both merge into `custom` before the event is recorded.

</note>

### Mirror on the server

Your ingest handler is the second gate. Pass the same tool name and custom keys you declared on the CLI:

```ts [app/api/telemetry/ingest/route.ts]
import { parseIngestBody } from '@evlog/telemetry/ingest'

const ALLOWED_CUSTOM = [
  'servicesDeployed', 'rollbackUsed', 'framework',
  'product', 'plan', 'schemaVersion',
  'ghaAction', 'ghaEvent',
] as const

export async function POST(req: Request) {
  const raw = await req.text()
  const events = parseIngestBody(raw, {
    allowedTools: ['acme-cli'],
    allowedCustomKeys: { 'acme-cli': ALLOWED_CUSTOM },
  })
  // dedupe on idempotencyKey, then persist
}
```

`parseIngestBody()` strips any `custom` key you did not list, even if a client somehow sent it.

### Map to your warehouse schema

After validation, shape the event for **your** database. The evlog envelope is the wire format; your tables are your choice:

```ts [lib/telemetry-store.ts]
function toRow(event: RunEvent) {
  return {
    command: event.command,
    duration_ms: event.durationMs,
    outcome: event.outcome,
    tool_version: event.tool.version,
    format: event.flags.format,
    target: event.flags.target,
    services_deployed: event.custom.servicesDeployed,
    framework: event.custom.framework,
    plan: event.custom.plan,
    schema_version: event.custom.schemaVersion ?? 1,
    is_ci: event.env.ci,
    node: event.env.node,
    received_at: new Date(),
  }
}
```

This is where a company "modifies the schema" in practice: not by changing `RunEvent`, but by defining how `custom` maps into internal analytics tables.

### Version breaking changes in `custom`

When your product counters evolve, bump a numeric `schemaVersion` in `custom` (no allowlist needed) and branch in the ingest mapper:

```ts
const version = (event.custom.schemaVersion as number | undefined) ?? 1
if (version === 1) return mapV1(event)
return mapV2(event)
```

Commit an updated `TELEMETRY.md` whenever `collect` changes so disclosure stays aligned with releases.

### Multiple tools, one endpoint

If several CLIs POST to the same ingest URL, namespace custom keys or rely on `tool.name`:

```ts
// Option A — prefix keys per product line
telemetry.set({ acme_product: 'cli', deployCount: 3 })

// Option B — separate allowedCustomKeys per tool.name in parseIngestBody
allowedCustomKeys: {
  'acme-cli': ['deployCount', 'framework'],
  'acme-action': ['jobDuration', 'ghaAction', 'ghaEvent'],
}
```

### What v1 does not support

<table>
<thead>
  <tr>
    <th>
      Need
    </th>
    
    <th>
      v1 answer
    </th>
    
    <th>
      Workaround
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      New top-level field (<code>
        tenantId
      </code>
      
      )
    </td>
    
    <td>
      Not on the wire
    </td>
    
    <td>
      Flat key in <code>
        custom
      </code>
      
       + allowlist, or map server-side from <code>
        machineId
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Nested objects (<code>
        custom.user.plan
      </code>
      
      )
    </td>
    
    <td>
      Flat records only
    </td>
    
    <td>
      Flatten: <code>
        userPlan: 'pro'
      </code>
      
       with <code>
        collect.fields
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Free-form strings (paths, emails)
    </td>
    
    <td>
      Blocked by design
    </td>
    
    <td>
      Flags record <code>
        "<set>"
      </code>
      
       instead of the value; hash before <code>
        telemetry.set()
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Different schema per command
    </td>
    
    <td>
      One envelope per run
    </td>
    
    <td>
      Convention: only set relevant keys per command
    </td>
  </tr>
  
  <tr>
    <td>
      Transform before outbox
    </td>
    
    <td>
      No hook yet
    </td>
    
    <td>
      Map on the server (recommended) or see planned v2 below
    </td>
  </tr>
</tbody>
</table>

<tip>

**Rule of thumb:** if it is safe to print in `TELEMETRY.md` and fits a closed set or a number, it belongs in `collect` + `custom`. Everything else stays server-side.

</tip>

### Planned v2 extensions

Not shipped yet. These are design targets for when the v1 extension zones are not enough:

**enrich hook.** Runs after sanitization, before the outbox append. Lets you inject computed fields without forking the package:

```ts
// Planned API — not available in v1
createTelemetry({
  name: 'acme-cli',
  version: '3.0.0',
  collect: { fields: { product: ['cli', 'action'] } },
  enrich(event) {
    return {
      ...event,
      custom: {
        ...event.custom,
        schemaVersion: 2,
      },
    }
  },
})
```

The hook cannot bypass sanitization rules, and strings still require `collect.fields`. Server-side `allowedCustomKeys` stays the source of truth.

**Namespaced keys.** An optional `acme.product` convention for multi-tool endpoints without prefix collisions. Would be documented in disclosure as `acme.product: cli | action`.

If you need one of these before v2 lands, open a discussion on the repo. The v1 path (`custom` + server mapping) covers most product analytics needs.

## Privacy

Raw `argv` is never read. Sanitization applies to **citty-parsed** flags only:

- **Booleans / numbers** → value stored (`json: true`, `limit: 50`)
- **Strings** → `"<set>"` (`output: "<set>"`) unless allowlisted in `collect.flags`
- **telemetry.set()** → numbers and booleans always; strings only via `collect.fields` (undeclared values are dropped at runtime, never thrown)

```ts [src/index.ts]
collect: {
  flags: { format: ['json', 'csv'] },           // --format json → "json"; --format yaml → "<set>"
  fields: { framework: ['nuxt', 'next'] },      // telemetry.set({ framework: 'nuxt' }) ok
}
```

Three kinds of parser noise are dropped before the event is built, so `flags` reads
as what the user asked for rather than what the parser filled in:

- **Positionals**: citty's `_` bucket holds argument values, not flag names
- **Defaults**: a flag still sitting at its declared `default` is not a choice anyone made
- **Kebab-case duplicates**: `--min-score` arrives as both `minScore` and `min-score`; only the camelCase name is kept, and that is the name `collect.flags` allowlists

Negation still reports: `--no-write` against `write: { default: true }` records `write: false`.

Declare allowlists in the same `withTelemetry()` / `createTelemetry()` call as `collect`, with no separate config file.

## Disclosure

`generateDisclosure()` produces markdown + JSON from the standard envelope plus your `collect` extensions. Commit the output (e.g. `TELEMETRY.md`) so it stays in sync with releases:

```ts [scripts/generate-disclosure.ts]
import { writeFile } from 'node:fs/promises'
import { generateDisclosure } from '@evlog/telemetry'

const { markdown } = generateDisclosure('my-tool', {
  flags: { format: ['json', 'csv'] },
})

await writeFile('TELEMETRY.md', markdown)
```

Users can also read it at runtime via `my-tool telemetry status` when you wire `defineTelemetryCommands()`.

## Consent and reliability

**Opt-out priority:** `DO_NOT_TRACK=1` → `EVLOG_TELEMETRY=0` → persisted preference (`disableTelemetry()` / `telemetry disable`). Opt-out purges the undelivered outbox.

**Never harms the host:** telemetry never throws, never blocks exit; `flush()` has a 500ms hard cap.

**Outbox:** events append locally before any network attempt. Offline machines and CI matrix jobs drain the backlog on the next invocation once your ingestion endpoint returns `2xx`.

**Endpoint:** `EVLOG_TELEMETRY_ENDPOINT` env → `endpoint` option baked into the CLI. Omit both during local development; ship the URL when your ingest handler is live.

---

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


## Sitemap

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