Telemetry

Tool telemetry

Audit
Wide-event telemetry for CLIs and automation — evlog's one-event-per-run model for tools on user machines, with privacy-safe flags, consent, outbox, and auto-generated disclosure.

@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.

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.

src/index.ts
import { defineCommand, runMain } from 'citty'
import { withTelemetry, defineTelemetryCommands, telemetry } from '@evlog/telemetry'

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

export const main = withTelemetry(
  defineCommand({
    meta: { name: 'my-tool', description: '', version: VERSION },
    subCommands: {
      doctor: {
        meta: { name: 'doctor', description: 'Check environment' },
        args: {
          json: { type: 'boolean', alias: 'j' },
        },
        async run({ args }) {
          const checksFailed = 0 // your logic…
          telemetry.set({ checksFailed })
          if (!args.json) process.stdout.write('ok\n')
        },
      },
      sync: {
        meta: { name: 'sync', description: 'Pull remote state' },
        args: {
          dryRun: { type: 'boolean' },
          output: { type: 'string', description: 'Output path' },
        },
        async run({ args }) {
          telemetry.set({ itemsSynced: 42 })
          // …
        },
      },
      telemetry: defineTelemetryCommands({ name: TOOL }),
    },
  }),
  {
    name: TOOL,
    version: VERSION,
    // endpoint optional — omit for outbox-only until you ship ingestion
    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 — no per-command telemetry boilerplate.

Invocationevent.commandNotes on flags
my-tool doctordoctor{ json: true } — booleans captured as values
my-tool doctor --jsondoctor{ json: true }
my-tool sync --dry-run --output ./outsync{ dryRun: true, output: true } — string path is presence only
my-tool sync --format jsonsync{ format: "json" } only if allowlisted in collect.flags
my-tool telemetry statustelemetry statusnested subcommands join with a space

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 any code that runs within that handler's async stack):

telemetry.set({ checksFailed: 2, cacheHit: true })

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

throw Object.assign(new Error('Config missing'), { code: 'CONFIG_NOT_FOUND' })

Setup without citty

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

import { createTelemetry, telemetry } from '@evlog/telemetry'

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

await t.run('migrate', async () => {
  telemetry.set({ rowsMigrated: 120 })
})

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

GitHub Actions: swap createTelemetry for createGitHubActionsTelemetry() — it adds ghaAction and ghaEvent to custom from GITHUB_ACTION / GITHUB_EVENT_NAME only (never repo content).

Standard envelope

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

{
  "event": "run",
  "command": "sync",
  "durationMs": 412,
  "outcome": "success",
  "flags": { "dryRun": true, "output": true },
  "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 }
}

Privacy

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

  • Booleans / numbers → value stored (json: true, limit: 50)
  • Strings → presence only (output: true) unless allowlisted in collect.flags
  • telemetry.set() → numbers and booleans always; strings only via collect.fields (undeclared values are dropped at runtime, never thrown)
collect: {
  flags: { format: ['json', 'csv'] },           // --format json → "json"; --format yaml → true
  fields: { framework: ['nuxt', 'next'] },      // telemetry.set({ framework: 'nuxt' }) ok
}

Declare allowlists in the same withTelemetry() / createTelemetry() call as collect — 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:

import { generateDisclosure } from '@evlog/telemetry'

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

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

Opt-out priority: DO_NOT_TRACK=1EVLOG_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 to ~/.config/{toolName}/telemetry/outbox.ndjson before any network send. Short-lived runs and offline machines drain the backlog on the next invocation.

Endpoint: EVLOG_TELEMETRY_ENDPOINT env → endpoint option → outbox-only (default until you ship ingestion).

Debug

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.

See also

  • Audit — wide events for security-sensitive actions