---
title: "Telemetry Setup"
description: "Wire @evlog/telemetry into citty CLIs, standalone scripts, and GitHub Actions — delivery config, automatic flag capture, telemetry.set(), and debug mode."
canonical_url: "https://www.evlog.dev/use-cases/telemetry/setup"
---
# Telemetry Setup

> Wire @evlog/telemetry into citty CLIs, standalone scripts, and GitHub Actions — delivery config, automatic flag capture, telemetry.set(), and debug mode.

## Configure delivery

Point the CLI at your ingestion URL when you ship it, baked into the binary and overridable per environment:

```ts [src/index.ts]
withTelemetry(command, {
  name: TOOL,
  version: VERSION,
  endpoint: 'https://telemetry.my-tool.dev/api/telemetry/ingest',
})
```

<table>
<thead>
  <tr>
    <th>
      Override
    </th>
    
    <th>
      Effect
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        EVLOG_TELEMETRY_ENDPOINT
      </code>
    </td>
    
    <td>
      Replaces the baked-in URL (staging, self-hosted)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        EVLOG_TELEMETRY=0
      </code>
      
       / <code>
        DO_NOT_TRACK=1
      </code>
    </td>
    
    <td>
      No recording, no send
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        EVLOG_TELEMETRY_DEBUG=1
      </code>
    </td>
    
    <td>
      Print payloads to stderr without sending
    </td>
  </tr>
</tbody>
</table>

The built-in HTTP drain POSTs `Content-Type: application/json` with body `{ events: RunEvent[] }` and a 400ms timeout. On success (`res.ok`), delivered keys are removed from the outbox. `flush()` is capped at 500ms and never throws.

## Setup with citty

Wrap your root command in `withTelemetry()`, typically in `src/index.ts`, the file that calls `runMain()`. Subcommands can live in the same file or in `src/commands/*.ts`; only the entrypoint needs the wrapper.

```ts [src/index.ts]
import { defineCommand, runMain } from 'citty'
import { withTelemetry, defineTelemetryCommands } from '@evlog/telemetry'
import { doctorCommand } from './commands/doctor'
import { syncCommand } from './commands/sync'

const TOOL = 'my-tool'
const VERSION = '1.0.0'

export const main = withTelemetry(
  defineCommand({
    meta: { name: 'my-tool', description: '…', version: VERSION },
    subCommands: {
      doctor: doctorCommand,
      sync: syncCommand,
      telemetry: defineTelemetryCommands({ name: TOOL }),
    },
  }),
  {
    name: TOOL,
    version: VERSION,
    endpoint: 'https://telemetry.my-tool.dev/api/telemetry/ingest',
    collect: {
      flags: { format: ['json', 'csv'] },
      fields: { framework: ['nuxt', 'next'] },
    },
  },
)

runMain(main)
```

### What gets recorded automatically

`withTelemetry` walks your citty tree. Each `run` handler produces one event, with no per-command telemetry boilerplate.

<table>
<thead>
  <tr>
    <th>
      Invocation
    </th>
    
    <th>
      <code>
        event.command
      </code>
    </th>
    
    <th>
      Notes on <code>
        flags
      </code>
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        my-tool doctor
      </code>
    </td>
    
    <td>
      <code>
        doctor
      </code>
    </td>
    
    <td>
      <code>
        {}
      </code>
      
       — nothing was passed, and defaults are not choices
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        my-tool doctor --json
      </code>
    </td>
    
    <td>
      <code>
        doctor
      </code>
    </td>
    
    <td>
      <code>
        { json: true }
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        my-tool sync --dry-run --output ./out
      </code>
    </td>
    
    <td>
      <code>
        sync
      </code>
    </td>
    
    <td>
      <code>
        { dryRun: true, output: "<set>" }
      </code>
      
       — the path itself is never sent
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        my-tool sync --format json
      </code>
    </td>
    
    <td>
      <code>
        sync
      </code>
    </td>
    
    <td>
      <code>
        { format: "json" }
      </code>
      
       only if allowlisted in <code>
        collect.flags
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        my-tool telemetry status
      </code>
    </td>
    
    <td>
      <code>
        telemetry status
      </code>
    </td>
    
    <td>
      nested subcommands join with a space
    </td>
  </tr>
</tbody>
</table>

The root `meta.name` is not prefixed when the root only delegates to `subCommands`.

### Enriching a run

Call `telemetry.set()` anywhere inside a command handler, or in helpers that run on the same async stack (the run context is preserved via AsyncLocalStorage).

<code-collapse>

```ts [src/commands/doctor.ts]
import { telemetry } from '@evlog/telemetry'
import { existsSync } from 'node:fs'
import { resolveConfigPath } from '../lib/config'

async function runHealthChecks() {
  let checksFailed = 0
  let checksWarn = 0

  const configPath = resolveConfigPath()
  if (!existsSync(configPath)) {
    checksFailed++
  }

  try {
    await fetch('https://registry.npmjs.org/my-tool')
  } catch {
    checksWarn++
  }

  // Counters land in event.custom on the wide event for this run
  telemetry.set({ checksFailed, checksWarn })
  return checksFailed === 0
}

export const doctorCommand = {
  meta: { name: 'doctor', description: 'Check environment' },
  args: {
    json: { type: 'boolean', alias: 'j' },
  },
  async run({ args }: { args: { json?: boolean } }) {
    const ok = await runHealthChecks()
    if (!ok) {
      throw Object.assign(new Error('Health checks failed'), { code: 'DOCTOR_FAILED' })
    }
    if (!args.json) process.stdout.write('ok\n')
  },
}
```

</code-collapse>

Throw an error with a `code` property and `outcome: "error"` plus `errorCode` are recorded automatically:

```ts [src/commands/doctor.ts]
throw Object.assign(new Error('Config missing'), { code: 'CONFIG_NOT_FOUND' })
```

A minimal sync handler for comparison. Flags are auto-captured, you only `set()` business counters:

```ts [src/commands/sync.ts]
import { telemetry } from '@evlog/telemetry'

export const syncCommand = {
  meta: { name: 'sync', description: 'Pull remote state' },
  args: {
    dryRun: { type: 'boolean' },
    output: { type: 'string', description: 'Output path' },
  },
  async run() {
    const itemsSynced = await pullRemoteState()
    telemetry.set({ itemsSynced })
  },
}
```

## Setup without citty

For scripts, migrators, or custom CLIs, use `createTelemetry()` and wrap each logical run with `t.run()`:

```ts [scripts/migrate.ts]
import { createTelemetry, telemetry } from '@evlog/telemetry'

const t = createTelemetry({ name: 'my-migrator', version: '2.0.0' })

await t.run('migrate', async () => {
  const rows = await migrateBatch()
  telemetry.set({ rowsMigrated: rows.length, batchSize: 500 })
})

await t.flush() // optional — also runs at end of each t.run()
```

GitHub Actions: swap `createTelemetry` for `createGitHubActionsTelemetry()` in the same file, which adds `ghaAction` and `ghaEvent` to `custom` from `GITHUB_ACTION` / `GITHUB_EVENT_NAME` only (never repo content).

```ts [scripts/ci-report.ts]
import { createGitHubActionsTelemetry, telemetry } from '@evlog/telemetry'

const t = createGitHubActionsTelemetry({ name: 'my-action', version: '1.0.0' })

await t.run('report', async () => {
  const artifacts = await collectArtifacts()
  telemetry.set({ artifactCount: artifacts.length })
})
```

## Debug

```bash
EVLOG_TELEMETRY_DEBUG=1 my-tool doctor
EVLOG_TELEMETRY=0 my-tool sync
```

Debug mode prints would-be payloads to stderr. Nothing is sent unless an endpoint is configured and delivery succeeds.

---

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