Configure delivery
Point the CLI at your ingestion URL when you ship it — baked into the binary, 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 — no per-command telemetry boilerplate.
| Invocation | event.command | Notes on flags |
|---|---|---|
my-tool doctor | doctor | { json: true } — booleans captured as values |
my-tool doctor --json | doctor | { json: true } |
my-tool sync --dry-run --output ./out | sync | { dryRun: true, output: true } — string path is presence only |
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 — it 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.
Overview
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.
Ingest
Build a server ingestion endpoint for @evlog/telemetry — semi-public threat model, parseIngestBody validation, framework routes, idempotent storage, and rate limiting.