Configure delivery
Point the CLI at your ingestion URL when you ship it, baked into the binary and overridable per environment:
withTelemetry(command, {
name: TOOL,
version: VERSION,
endpoint: 'https://telemetry.my-tool.dev/api/telemetry/ingest',
})
| Override | Effect |
|---|---|
EVLOG_TELEMETRY_ENDPOINT | Replaces the baked-in URL (staging, self-hosted) |
EVLOG_TELEMETRY=0 / DO_NOT_TRACK=1 | No recording, no send |
EVLOG_TELEMETRY_DEBUG=1 | Print 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.
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.
| Invocation | event.command | Notes on flags |
|---|---|---|
my-tool doctor | doctor | {} — nothing was passed, and defaults are not choices |
my-tool doctor --json | doctor | { json: true } |
my-tool sync --dry-run --output ./out | sync | { dryRun: true, output: "<set>" } — the path itself is never sent |
my-tool sync --format json | sync | { format: "json" } only if allowlisted in collect.flags |
my-tool telemetry status | telemetry status | nested 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).
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:
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:
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():
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).
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.