Telemetry

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:

src/index.ts
withTelemetry(command, {
  name: TOOL,
  version: VERSION,
  endpoint: 'https://telemetry.my-tool.dev/api/telemetry/ingest',
})
OverrideEffect
EVLOG_TELEMETRY_ENDPOINTReplaces the baked-in URL (staging, self-hosted)
EVLOG_TELEMETRY=0 / DO_NOT_TRACK=1No recording, no send
EVLOG_TELEMETRY_DEBUG=1Print payloads to stderr without sending

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.

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.

Invocationevent.commandNotes on flags
my-tool doctordoctor{} — nothing was passed, and defaults are not choices
my-tool doctor --jsondoctor{ json: true }
my-tool sync --dry-run --output ./outsync{ dryRun: true, output: "<set>" } — the path itself is never sent
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 in helpers that run on the same async stack (the run context is preserved via AsyncLocalStorage).

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')
  },
}

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

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:

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():

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

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

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.