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.
Add anonymous telemetry to my CLI or script
From CLI to your backend
The disk outbox (~/.config/{toolName}/telemetry/outbox.ndjson) is a reliability buffer, not where telemetry lives long-term. Each run appends one event locally, then the HTTP drain POSTs the backlog to your ingestion endpoint. Short-lived CI jobs and offline machines drain on the next invocation.
my-tool doctor
│
▼
┌─────────────────────────────────────────┐
│ @evlog/telemetry (on the user's machine) │
│ sanitize flags · consent · one RunEvent │
└─────────────────────────────────────────┘
│ append (always)
▼
outbox.ndjson POST { events: RunEvent[] }
│ flush ───────────────────────────────► your /api/telemetry/ingest
│ │
│ ▼
│ validate · allowlist · dedupe
│ │
│ ▼
│ DB · warehouse · evlog drain
▼
remove delivered keys on 2xx
Nothing is sent until you configure an endpoint and the server returns a success status. Until then, events stay in the outbox (or are purged on opt-out).
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.
Build an ingestion endpoint
You ship a CLI. Users run it on their machines. You want to know which commands run, how often they fail, and which versions are out there — without building a separate analytics SDK or reading raw argv.
That means a server endpoint your CLI POSTs to. The local outbox is only there so short CI jobs and offline runs do not lose data before the POST succeeds.
The security question (read this first)
"I put the ingest URL in my CLI — isn't that a secret?"
No. The URL lives in your npm package or binary. Anyone can read it with strings, by installing the package, or by watching network traffic. The same applies to an API key or Authorization header baked into the CLI — extractable, not a real barrier.
"So can't anyone send fake events?"
In theory, yes — someone could curl your endpoint with forged JSON. That is why you treat the endpoint as semi-public (like a browser analytics key) and defend on the server:
| What you might worry about | Practical answer |
|---|---|
| Random internet bots | Validate the payload shape; reject garbage with 400 |
Someone spamming fake doctor runs | Rate-limit by IP; data is low-value counters, not money |
Forged tool.name | Allowlist only your published tool names server-side |
Unexpected fields in custom | Filter to the keys you declared — same list as collect on the CLI |
| Double-counting on retry | Dedupe on idempotencyKey when storing |
| Leaking user secrets | CLI never reads raw argv; you only allowlisted flags and telemetry.set() counters |
What "good enough" looks like: validate every POST, store idempotently, rate-limit at the edge (CDN, API gateway, or middleware), and keep the payload boring (no PII by design). That is the same playbook as client-side analytics — not banking-grade auth, but reliable product insight.
@evlog/telemetry ships parseIngestBody() for your server — the same validation rules documented here, so you do not copy-paste a validator from the docs. Pair it with your framework route and your database or evlog drain.Step 1 — Validate with parseIngestBody()
Mirror the CLI config on the server: same name, same custom keys you allow via collect / telemetry.set().
import { parseIngestBody } from '@evlog/telemetry/ingest'
export const ingestOptions = {
allowedTools: ['my-tool'],
allowedCustomKeys: {
'my-tool': ['checksFailed', 'checksWarn', 'itemsSynced'],
},
} as const
export function parseTelemetryBody(raw: string) {
return parseIngestBody(raw, ingestOptions)
}
parseIngestBody() checks batch size, JSON shape, event: 'run', tool allowlist, envelope types, ISO timestamp, and strips custom keys you did not declare. Throws IngestValidationError on failure — return 400 from your route.
Step 2 — Wire your route
Same contract for any framework — read the raw body, validate, store, return 204 so the CLI clears its outbox:
import { defineEventHandler, readRawBody, setResponseStatus } from 'h3'
import { IngestValidationError } from '@evlog/telemetry/ingest'
import { parseTelemetryBody } from '~/lib/telemetry-ingest'
import { storeRunEvents } from '~/lib/telemetry-store'
export default defineEventHandler(async (event) => {
const raw = await readRawBody(event, 'utf8')
if (!raw) {
setResponseStatus(event, 400)
return { error: 'empty body' }
}
try {
const events = parseTelemetryBody(raw)
await storeRunEvents(events)
setResponseStatus(event, 204)
return null
} catch (err) {
setResponseStatus(event, err instanceof IngestValidationError ? 400 : 500)
return { error: 'invalid payload' }
}
})
import { IngestValidationError } from '@evlog/telemetry/ingest'
import { parseTelemetryBody } from '@/lib/telemetry-ingest'
import { storeRunEvents } from '@/lib/telemetry-store'
export async function POST(request: Request) {
const raw = await request.text()
try {
const events = parseTelemetryBody(raw)
await storeRunEvents(events)
return new Response(null, { status: 204 })
} catch (err) {
const status = err instanceof IngestValidationError ? 400 : 500
return Response.json({ error: 'invalid payload' }, { status })
}
}
import { Hono } from 'hono'
import { IngestValidationError } from '@evlog/telemetry/ingest'
import { parseTelemetryBody } from '../lib/telemetry-ingest'
import { storeRunEvents } from '../lib/telemetry-store'
const app = new Hono()
app.post('/api/telemetry/ingest', async (c) => {
const raw = await c.req.text()
try {
const events = parseTelemetryBody(raw)
await storeRunEvents(events)
return c.body(null, 204)
} catch (err) {
const status = err instanceof IngestValidationError ? 400 : 500
return c.json({ error: 'invalid payload' }, status)
}
})
export default app
Step 3 — Store idempotently
Retries and backlog drains can POST the same idempotencyKey twice. Upsert (or skip) so metrics stay correct:
import type { RunEvent } from '@evlog/telemetry'
export async function storeRunEvents(events: RunEvent[]): Promise<void> {
for (const run of events) {
await db.telemetryRun.upsert({
where: { idempotencyKey: run.idempotencyKey },
create: {
tool: run.tool.name,
version: run.tool.version,
command: run.command,
outcome: run.outcome,
durationMs: run.durationMs,
custom: run.custom,
recordedAt: run.timestamp,
},
update: {},
})
}
// Or forward into evlog's drain pipeline for Axiom / Datadog / OTLP:
// await ingestWideEvents(events.map(run => ({ source: 'telemetry', ...run })))
}
Step 4 — Rate-limit at the edge
parseIngestBody() is your last line of schema defense, not your only one. Add rate limits where traffic enters — CDN, API gateway, or middleware — especially per IP. Optional: also bucket by machineId hash inside the handler if you need tighter control.
Non-2xx responses keep events in the user's outbox; they will retry on the next CLI invocation. Return 204 (or any 2xx) only when storage succeeded.
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 })
})
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 }
}
Extending the schema
The envelope above is fixed — every tool sends the same top-level shape so parseIngestBody() and disclosure stay predictable. You do not add fields next to command or durationMs.
Your product schema lives in two extension zones:
| Zone | Who sets it | What goes there |
|---|---|---|
flags | citty (auto) + collect.flags | CLI switches — booleans/numbers as values, strings only when allowlisted |
custom | you via telemetry.set() + collect.fields | Business counters, categorical dimensions, schema version |
Think of it as two contracts:
- Transport contract (
RunEvent) — owned by@evlog/telemetry, stable across tools - Product contract (
custom+ declaredflags) — owned by you, declared incollect, mirrored on the server
Declare your extensions once
Everything you collect beyond the standard envelope is declared inline in the same withTelemetry() / createTelemetry() call:
withTelemetry(command, {
name: 'acme-cli',
version: '2.1.0',
endpoint: 'https://telemetry.acme.dev/api/ingest',
collect: {
flags: {
format: ['json', 'yaml', 'table'],
target: ['staging', 'production'],
},
fields: {
product: ['cli', 'action', 'migrator'],
plan: ['free', 'pro', 'enterprise'],
framework: ['nuxt', 'next', 'remix'],
},
},
})
Inside handlers, add counters and dimensions:
await t.run('deploy', async () => {
const result = await deployServices()
telemetry.set({
servicesDeployed: result.count, // number — always allowed
rollbackUsed: false, // boolean — always allowed
framework: 'nuxt', // string — allowed (in collect.fields)
schemaVersion: 2, // number — version your custom shape
})
})
Undeclared strings are dropped at runtime, never thrown. That is the privacy guarantee — no accidental paths, tokens, or free-form PII in custom.
TelemetryHandle.set() autocomplete covers declared collect.fields keys. Use the ambient telemetry.set() for extra numeric/boolean counters in the same run — both merge into custom before the event is recorded.Mirror on the server
Your ingest handler is the second gate. Pass the same tool name and custom keys you declared on the CLI:
import { parseIngestBody } from '@evlog/telemetry/ingest'
const ALLOWED_CUSTOM = [
'servicesDeployed', 'rollbackUsed', 'framework',
'product', 'plan', 'schemaVersion',
'ghaAction', 'ghaEvent',
] as const
export async function POST(req: Request) {
const raw = await req.text()
const events = parseIngestBody(raw, {
allowedTools: ['acme-cli'],
allowedCustomKeys: { 'acme-cli': ALLOWED_CUSTOM },
})
// dedupe on idempotencyKey, then persist
}
parseIngestBody() strips any custom key you did not list — even if a client somehow sent it.
Map to your warehouse schema
After validation, shape the event for your database. The evlog envelope is the wire format; your tables are your choice:
function toRow(event: RunEvent) {
return {
command: event.command,
duration_ms: event.durationMs,
outcome: event.outcome,
tool_version: event.tool.version,
format: event.flags.format,
target: event.flags.target,
services_deployed: event.custom.servicesDeployed,
framework: event.custom.framework,
plan: event.custom.plan,
schema_version: event.custom.schemaVersion ?? 1,
is_ci: event.env.ci,
node: event.env.node,
received_at: new Date(),
}
}
This is where a company "modifies the schema" in practice — not by changing RunEvent, but by defining how custom maps into internal analytics tables.
Version breaking changes in custom
When your product counters evolve, bump a numeric schemaVersion in custom (no allowlist needed) and branch in the ingest mapper:
const version = (event.custom.schemaVersion as number | undefined) ?? 1
if (version === 1) return mapV1(event)
return mapV2(event)
Commit an updated TELEMETRY.md whenever collect changes so disclosure stays aligned with releases.
Multiple tools, one endpoint
If several CLIs POST to the same ingest URL, namespace custom keys or rely on tool.name:
// Option A — prefix keys per product line
telemetry.set({ acme_product: 'cli', deployCount: 3 })
// Option B — separate allowedCustomKeys per tool.name in parseIngestBody
allowedCustomKeys: {
'acme-cli': ['deployCount', 'framework'],
'acme-action': ['jobDuration', 'ghaAction', 'ghaEvent'],
}
What v1 does not support
| Need | v1 answer | Workaround |
|---|---|---|
New top-level field (tenantId) | Not on the wire | Flat key in custom + allowlist, or map server-side from machineId |
Nested objects (custom.user.plan) | Flat records only | Flatten: userPlan: 'pro' with collect.fields |
| Free-form strings (paths, emails) | Blocked by design | Presence-only on flags (output: true) or hash before telemetry.set() |
| Different schema per command | One envelope per run | Convention: only set relevant keys per command |
| Transform before outbox | No hook yet | Map on the server (recommended) or see planned v2 below |
TELEMETRY.md and fits a closed set or a number, it belongs in collect + custom. Everything else stays server-side.Planned v2 extensions
Not shipped yet — design targets for when the v1 extension zones are not enough:
enrich hook — run after sanitization, before the outbox append. Lets you inject computed fields without forking the package:
// Planned API — not available in v1
createTelemetry({
name: 'acme-cli',
version: '3.0.0',
collect: { fields: { product: ['cli', 'action'] } },
enrich(event) {
return {
...event,
custom: {
...event.custom,
schemaVersion: 2,
},
}
},
})
The hook cannot bypass sanitization rules — strings still require collect.fields. Server-side allowedCustomKeys stays the source of truth.
Namespaced keys — optional acme.product convention for multi-tool endpoints without prefix collisions. Would be documented in disclosure as acme.product: cli | action.
If you need one of these before v2 lands, open a discussion on the repo — the v1 path (custom + server mapping) covers most product analytics needs.
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 locally before any network attempt. Offline machines and CI matrix jobs drain the backlog on the next invocation once your ingestion endpoint returns 2xx.
Endpoint: EVLOG_TELEMETRY_ENDPOINT env → endpoint option baked into the CLI. Omit both during local development; ship the URL when your ingest handler is live.
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.
Why install it
You can wire anonymous usage telemetry yourself — outbox file, consent flags, flag sanitization, disclosure markdown, ingest validation, first-run notice. Or you add one dependency and get the evlog playbook baked in.
What you get for ~28 KB
| Published size | ~28 KB ESM (@evlog/telemetry), ~8.6 KB gzip — standalone, no dependency on evlog core |
| Server ingest only | @evlog/telemetry/ingest ~5 KB (~1.7 KB gzip) — parseIngestBody() without pulling citty wiring into your API |
| Runtime deps | citty + std-env only — both sideEffects: false, tree-shakeable ESM |
| Node | 18+ · ESM · sideEffects: false on the package |
Fits wherever your code runs
| Surface | Entry | Lines of integration |
|---|---|---|
| citty CLI | withTelemetry() on the root command | One wrapper + optional defineTelemetryCommands() |
| Script / migrator | createTelemetry() + t.run() | Wrap each logical run |
| GitHub Actions | createGitHubActionsTelemetry() | Same as script; ghaAction / ghaEvent auto-injected |
| Your API (ingest) | parseIngestBody() from @evlog/telemetry/ingest | One validator + one POST route |
No framework lock-in. No PostHog / Segment / custom analytics SDK. No second schema to maintain — disclosure is generated from the same collect config the CLI uses at runtime.
What you skip building
- Privacy-safe flag capture (never reads raw
argv) DO_NOT_TRACK/EVLOG_TELEMETRY/ persisted opt-out with outbox purge- Disk outbox with lockfile, stale-lock recovery, and backlog drain
- Auto-generated disclosure (
TELEMETRY.md+telemetry status) - First-run notice with opt-out instructions
- Typed
telemetry.set()with allowlisted string fields - Server-side ingest validation aligned with the CLI envelope
- Hard guarantee: telemetry never throws, never blocks exit
The payoff
Once ingest is live you can answer product questions that CLI authors usually guess at:
- Which commands are actually used (
doctorvssyncvstelemetry status) - Where runs fail (
outcome,errorCode, version breakdown) - How long operations take (
durationMsper command) - Which versions are still in the wild (
tool.versionon every event) - Whether CI or local dev dominates (
env.ci,env.agent,env.tty)
All from one wide event per run — the same mental model as evlog on the server, without bolting a browser analytics stack onto a terminal tool.
examples/telemetry-playground — pnpm run cli -- doctor, EVLOG_TELEMETRY_DEBUG=1 to inspect payloads, telemetry status for disclosure.See also
- Audit — wide events for security-sensitive actions
- Drain pipeline — forward ingested events into Axiom, Datadog, OTLP, or a custom store
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).