Tool telemetry
@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.
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 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.
| 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 })
})
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 incollect.flags telemetry.set()→ numbers and booleans always; strings only viacollect.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 { writeFile } from 'node:fs/promises'
import { generateDisclosure } from '@evlog/telemetry'
const { markdown } = generateDisclosure('my-tool', {
flags: { format: ['json', 'csv'] },
})
await writeFile('TELEMETRY.md', markdown)
Users can also read it at runtime via my-tool telemetry status when you wire defineTelemetryCommands().
Consent and reliability
Opt-out priority: DO_NOT_TRACK=1 → EVLOG_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
eve
Export one evlog wide event per eve agent turn — token usage, tool executions, business context, drains, enrichers, and tail sampling alongside Agent Runs and OpenTelemetry.
Overview
Observe what flows through the pipeline (stream, fs reader, consumer recipes), plug into the pipeline (plugins, enrichers, tail sampling, identity headers), or build your own bricks (custom drains, drain pipeline, custom framework integration).