Docs ledger

Diagnostics and adoption telemetry

Rendered from docs/telemetry.md.

Diagnostics and adoption telemetry

aireceipts can send small, content-free telemetry events to help us find bugs and understand which CLI features are actually used. This document is the full, authoritative description of what is sent, when, and how to turn it off. If anything here disagrees with src/telemetry/, that is a bug.

tl;dr

  • On by default in installed builds, but every event is one of a fixed 10-event catalog: cli_run, cli_error, parse_failure, receipt_generated, export_generated, pr_flow_completed, hook_configured, integration_surface_rendered, activation_milestone, statusline_heartbeat.
  • Never sent: transcript content, prompts, file paths, repo names, hostnames, usernames, session IDs, dollar amounts, raw model strings, raw counts, or session timestamps. (The App Insights wire format requires one client-stamped send time per envelope — the flush moment, nothing about your session's timeline; see "What is never sent".)
  • Derived by the receiver, not sent by the CLI: a coarse location (country and city) from the sending IP, which the shipped resource masks before storing; see "How sending works".
  • Pseudonymous install identity: when telemetry is enabled, a random install id is stored locally and sent only as a salted sha256 hash so events from the same install can be counted over time. Delete ~/.aireceipts/state.json and any state.json.corrupt-* backups there to reset it.
  • Disable anytime: AIRECEIPTS_TELEMETRY=off (or 0/false) or DO_NOT_TRACK=1. Either one results in zero network calls and prevents install-id creation on a fresh install.
  • On by default in CI too: CI/GITHUB_ACTIONS environments are treated the same as any other — telemetry is enabled by default there. Use a kill switch (AIRECEIPTS_TELEMETRY=off or DO_NOT_TRACK=1) to disable it in CI. (Before v0.7.0 it defaulted off in CI; reversed — see SPEC-0002.)
  • Inspect before you decide: aireceipts --telemetry-show prints the exact application payload the current run would send, and sends nothing. It cannot show transport metadata (the flush time the wire format requires, the sending IP the receiver sees) or fields the receiver derives from them; those are described in "How sending works".
  • Bounded and fail-safe: sending is capped at 300ms and can never throw, hang the CLI, or change its exit code.

Event catalog

Every field below is validated against a .strict() zod schema before it is queued. Extra keys are rejected, so a bug elsewhere cannot smuggle a new field into a payload.

cli_run — one per controlled return from a catalogued command

The fixed catalog covers every ordinary user-facing command listed below, including setup and integrations. Unrecognized or hidden internal command names never widen the schema: they are dropped rather than sent as raw text. Thrown paths emit cli_error only. telemetry-show and the hidden hook pre-push surface record nothing. Statusline polls are counted locally and send a surface event for each new state in an hour plus a heartbeat after the hour ends.

FieldTypeValuesNotes
cliVersionstringsemverFrom this package's package.json.
osenumdarwin | linux | win32 | otherCollapsed from process.platform.
nodeMajorintegere.g. 22Major Node version only.
commandClassenumbackfill | benchmark | check-budget | compare | demo | handoff | help | install-hook | integrations | list | methodology | mini | pr | quota | receipt | setup | stats | statusline | telemetry-show | templates | uninstall-hook | version | weekSelected command name only; never raw argv or flag values.
agentTypeenumclaude-code | codex | cursor | gemini | opencode | unknownWhich agent format was parsed, if known.
durationBucketenum<100ms | 100-500ms | 500ms-2s | 2-10s | >10sCoarse bucket; never raw milliseconds.
okbooleanWhether the command returned exit code 0.
exitClassenum (optional)no-session-match | invalid-arguments | budget-exceeded | not-comparable | other-controlledPresent only when ok is false for a controlled return: respectively, no matching session/query; rejected flags or options; check-budget over its cap; compare lacking two comparable sessions; or another deliberate non-zero outcome. Thrown errors omit this field and emit cli_error instead. Never free text.
isCIbooleanTrue when CI or GITHUB_ACTIONS is set and not false. Telemetry is enabled by default in CI, so this field distinguishes CI runs from human runs in the data.
installHashstring64-hex sha256 or unavailableSalted hash of the random local install id; raw id never leaves disk.
runOrdinalBucketenum1 | 2-3 | 4-10 | 11-50 | >50 | unavailableLifetime run ordinal bucket; never the raw count.
installIdSourceenumexisting | new | recovered_after_corrupt | unavailableWhether the id came from state, was newly minted, followed recovery of unparseable state, or was unavailable.
handoffFormatenum (optional)text | jsonSPEC-0042: emission mode, present only on handoff-command runs — never content.
{
  "cliVersion": "0.11.0",
  "os": "linux",
  "nodeMajor": 22,
  "commandClass": "receipt",
  "agentType": "unknown",
  "durationBucket": "<100ms",
  "ok": true,
  "isCI": false,
  "installHash": "unavailable",
  "installIdSource": "unavailable",
  "runOrdinalBucket": "1"
}

cli_error — one per uncaught top-level CLI error

FieldTypeValuesNotes
errorClassenumparse_error | io_error | network_error | validation_error | unknown_errorDerived from bounded error metadata; never error.message.
commandenumbackfill | benchmark | check-budget | compare | demo | handoff | help | install-hook | integrations | list | methodology | mini | pr | quota | receipt | setup | stats | statusline | telemetry-show | templates | uninstall-hook | version | weekNever raw argv.
agentTypeenumclaude-code | codex | cursor | gemini | opencode | unknown
inPackagebooleanWhether the top stack frame is inside aireceipts; the stack text never leaves the process.
{
  "errorClass": "io_error",
  "command": "receipt",
  "agentType": "unknown",
  "inPackage": false
}

parse_failure — one per transcript parsing failure

FieldTypeValuesNotes
agentTypeenumclaude-code | codex | cursor | gemini | opencode | unknown
adapterVersionstringshort opaque tokenInternal adapter version, not read from a transcript.
signatureHashstring64-hex sha256Hash of a content-free structural failure descriptor.
{
  "agentType": "codex",
  "adapterVersion": "1",
  "signatureHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}

receipt_generated — one per rendered cost receipt

FieldTypeValuesNotes
cliVersionstringsemverFrom this package's package.json.
installHashstring64-hex sha256 or unavailableSalted hash of the random local install id; raw id never leaves disk.
isCIbooleanTrue when CI or GITHUB_ACTIONS is set and not false. Telemetry is enabled by default in CI, so this field distinguishes CI runs from human runs in the data.
surfaceenumreceipt | compare | mini | prStatusline/quota/template previews are not receipts.
agentTypeenumclaude-code | codex | cursor | gemini | opencode | unknownunknown for mixed-agent/multi-session surfaces.
multiAgentbooleanTrue when the rendered surface combines more than one session/model.
outputModeenumtext | json | csv | svg | png | markdown
templateenumclassic | grocery | datavis | nonenone when no template flag drove the render.
pricedRowCoverageenumn/a | none | some | alln/a: zero tool rows; none: rows exist but none are priced. No dollars are sent.
hasStuckLoopWasteboolean
hasTrivialSpansWasteboolean
hasContextThrashWasteboolean
hasPriceDeltabooleanWhether the receipt had an arithmetic cheaper-model delta line.
hasSubagentsbooleanWhether subagent (child) transcripts were folded into the receipt's totals (SPEC-0061). A boolean, never a count.
hasPreEditSharebooleanWhether the receipt rendered a pre-edit cost-share line (SPEC-0067). A boolean, never the percentage, counts, or $.
detailsViewbooleanWhether the receipt rendered the opt-in --details section.
turnCountBucketenum0 | 1 | 2-3 | 4-10 | 11-50 | >50Never raw turn count.
toolCallCountBucketenum0 | 1 | 2-3 | 4-10 | 11-50 | >50Never raw tool-call count.
receiptOrdinalBucketenum1 | 2-3 | 4-10 | 11-50 | >50 | unavailableLifetime local receipt ordinal bucket.
{
  "cliVersion": "0.11.0",
  "installHash": "unavailable",
  "isCI": false,
  "surface": "receipt",
  "agentType": "unknown",
  "multiAgent": false,
  "outputMode": "text",
  "template": "none",
  "pricedRowCoverage": "n/a",
  "hasStuckLoopWaste": false,
  "hasTrivialSpansWaste": false,
  "hasContextThrashWaste": false,
  "hasPriceDelta": false,
  "hasSubagents": false,
  "hasPreEditShare": false,
  "detailsView": false,
  "turnCountBucket": "0",
  "toolCallCountBucket": "0",
  "receiptOrdinalBucket": "1"
}

export_generated — one per successful export path

FieldTypeValuesNotes
surfaceenumreceipt | compare | week | list | pr | backfill
formatenumjson | csv_session | csv_tool | svg | png | markdown | html | text
wroteFilebooleanFalse for stdout exports.
resultenumsuccess | no_data | invalid_args | declined | external_missing | external_failed | write_failed | internal_error
{
  "surface": "receipt",
  "format": "json",
  "wroteFile": false,
  "result": "success"
}

pr_flow_completed — one per aireceipts pr flow

FieldTypeValuesNotes
modeenumdry_run | post
artifactRequestedboolean
shareRequestedboolean
contributorCountBucketenum0 | 1 | 2-3 | 4-10 | 11-50 | >50Never raw contributor count.
commentResultenumsuccess | failed | skipped
artifactResultenumsuccess | failed | skipped
shareResultenumsuccess | failed | skipped
handoffSectionIncludedbooleanSPEC-0059: the rendered body carried the handoff section (rendering rate only — never engagement, never contents).
resultenumsuccess | no_data | invalid_args | declined | external_missing | external_failed | write_failed | internal_error
{
  "mode": "dry_run",
  "artifactRequested": false,
  "shareRequested": false,
  "contributorCountBucket": "0",
  "commentResult": "skipped",
  "artifactResult": "skipped",
  "shareResult": "skipped",
  "handoffSectionIncluded": false,
  "result": "success"
}

hook_configured — one per hook install/uninstall command

FieldTypeValuesNotes
operationenuminstall | uninstall
promptOutcomeenumaccepted | declined | not_prompted
resultenumsuccess | no_data | invalid_args | declined | external_missing | external_failed | write_failed | internal_error
{
  "operation": "install",
  "promptOutcome": "accepted",
  "result": "success"
}

integration_surface_rendered — one per passive integration render

FieldTypeValuesNotes
integrationenumstatusline | quotamini is a receipt, not an integration event.
inputModeenumstdin_payload | disk_fallback | none
payloadValidbooleanWhether the stdin payload was usable for the integration.
customFormatboolean (optional)statusline only (SPEC-0062): an explicit --format was passed. The boolean only — never the format string.
scopedboolean (optional)statusline only (SPEC-0075 R6): --cwd was supplied. The boolean only — never the path.
configFileboolean (optional)statusline only (SPEC-0075 R6): a valid statusline.json supplied the item order. The boolean only — never the items.
cliVersionstring (optional)semverPresent on statusline and quota rows.
installHashstring (optional)64-hex sha256 or unavailablePresent on statusline and quota rows.
isCIboolean (optional)Present on statusline and quota rows.
resultenumsuccess | no_data | invalid_args | declined | external_missing | external_failed | write_failed | internal_error

Scoped and unscoped statusline polls use the same rule: the queue is flushed only when non-empty. A statusline surface tuple is reported at most once per UTC hour in sequential execution. Quota rows are reported per run and carry the current run's identity trio. The trio remains optional in the shared schema for older payloads.

{
  "integration": "statusline",
  "inputMode": "none",
  "payloadValid": false,
  "result": "no_data",
  "customFormat": false,
  "scoped": false,
  "configFile": false,
  "cliVersion": "0.11.0",
  "installHash": "unavailable",
  "isCI": false
}

activation_milestone — once per milestone per local state file

FieldTypeValuesNotes
milestoneenumfirst_run | first_receipt | third_receipt | tenth_receipt | first_export | first_compare | first_week | first_hook_install | first_pr | first_pr_post | first_artifact
commandenumbackfill | benchmark | check-budget | compare | demo | handoff | help | install-hook | integrations | list | methodology | mini | pr | quota | receipt | setup | stats | statusline | telemetry-show | templates | uninstall-hook | version | weekCommand that caused the milestone.
installAgeBucketenumfirst_day | 2-7d | 8-30d | 31-90d | >90d | unavailableDerived locally from firstRunAt; raw date is not sent.
{
  "milestone": "first_run",
  "command": "receipt",
  "installAgeBucket": "first_day"
}

statusline_heartbeat — one completed UTC hour on a later poll

FieldTypeValuesNotes
cliVersionstringsemverFrom this package.
osenumdarwin | linux | win32 | otherCoarse platform.
nodeMajorintegere.g. 22Major version only.
installHashstring64-hex sha256 or unavailableSalted random install identity.
isCIbooleanCI marker.
runOrdinalBucketenum1 | 2-3 | 4-10 | 11-50 | >50 | unavailableLifetime run bucket at flush.
pollCountBucketenum1 | 2-10 | 11-50 | 51-200 | >200Completed hour polls.
failedPollCountBucketenum0 | 1 | 2-10 | >10Thrown polls in completed hour.
hourOffsetenum1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | >24Completed UTC hours between stored hour and flush hour. Arrival hour minus offset recovers the attributed hour except for >24.

The counters are lock-free and last-write-wins. Under concurrent pollers, counts are lower bounds; an event can be duplicated or lost. A completed hour with no later poll is never sent. A state write failure records nothing and flushes nothing. An older CLI can erase unflushed current or completed hours. The heartbeat has no poll latency, exact hour or date field. Error rows represent distinct classes per hour; the failed-poll bucket counts every thrown poll.

{
  "cliVersion": "0.11.0",
  "os": "linux",
  "nodeMajor": 22,
  "installHash": "unavailable",
  "isCI": false,
  "runOrdinalBucket": "2-3",
  "pollCountBucket": "2-10",
  "failedPollCountBucket": "0",
  "hourOffset": "1"
}

Install identifier

On the first telemetry-enabled run, aireceipts creates a random UUID in ~/.aireceipts/state.json. It is never derived from hostname, username, MAC address, machine id, repo, path, or transcript data. The wire payload carries only:

sha256("aireceipts-install-v1:" + installId)

That hash intentionally links events from the same install over time so adoption and retention can be counted. It does not identify a person, machine, or repo. To reset it, delete ~/.aireceipts/state.json and any ~/.aireceipts/state.json.corrupt-* backups (they keep the bytes of an unparseable state file, which can include the old raw id). If AIRECEIPTS_TELEMETRY=off or DO_NOT_TRACK=1 is active on a fresh install, no install id is created.

An unparseable state file is moved aside as state.json.corrupt-<stamp>.<pid>.<random> rather than silently replaced.

Local counters

~/.aireceipts/state.json also stores local counters:

  • runCount
  • receiptCount
  • firstRunAt
  • once-only activation milestone booleans
  • optional statusline UTC hour, poll and failed-poll counts, reported surface tuples and error classes

These exact counts stay on your machine. The aireceipts stats command prints the local receipt/run counters and labels them "on this machine." Telemetry payloads use only buckets.

What is never sent

Permanently, structurally banned:

  • Transcript content or any excerpt of it
  • Prompts or user/assistant message text
  • File paths
  • Repo names or URLs
  • Hostnames
  • Usernames
  • Session IDs
  • Dollar amounts or cost/pricing data
  • Raw model strings
  • Raw counts
  • Raw timestamps

Kill switches

Either of the following disables telemetry completely. When disabled, flushTelemetry() returns immediately without making any network call, and fresh installs do not create an install id.

AIRECEIPTS_TELEMETRY=off aireceipts ...
# or: AIRECEIPTS_TELEMETRY=0 / AIRECEIPTS_TELEMETRY=false (case-insensitive)

DO_NOT_TRACK=1 aireceipts ...

CI behavior (on by default)

Telemetry is enabled by default in CI, the same as any other environment — CI and GITHUB_ACTIONS are not special-cased. Automated CI runs are counted; the isCI field (above) records whether a run was in CI so CI vs. human usage stays distinguishable in the data. To turn telemetry off in a CI environment, use a kill switch:

AIRECEIPTS_TELEMETRY=off aireceipts ...   # or DO_NOT_TRACK=1 — disables telemetry in CI or anywhere

Precedence is: AIRECEIPTS_TELEMETRY=off/DO_NOT_TRACK=1 (always win) → the connection-string checks below. (Before v0.7.0, telemetry defaulted off in CI; that default was reversed — see SPEC-0002's 2026-07-08 amendment.)

Inspecting what would be sent

aireceipts --telemetry-show

This prints whether telemetry is currently enabled and the exact events queued for the current run without sending anything. In a development build using the shipped connection, it reports reason: "development-build". The command itself records nothing and skips the flush. It cannot preview a future hourly heartbeat; the validated event examples above show its shape. What it shows is the application payload, the properties of each event. It does not show the envelope's flush time or anything the receiving service sees or derives on its own (the sending IP and the coarse location derived from it, see "How sending works"), because those never exist inside the CLI.

How sending works

  • Events are queued in-process as they occur and sent as a single batched request at CLI shutdown.
  • The send is bounded to 300ms. If the network call is slow or hangs, it is abandoned; the CLI does not wait for it, and nothing is retried in the background.
  • Every failure mode is swallowed inside the telemetry module. Telemetry can never throw, block the CLI, or change its exit code.
  • The transport is Azure Application Insights, reached via a connection string (InstrumentationKey=...;IngestionEndpoint=https://.../) POSTed to <ingestionEndpoint>/v2/track.
  • The App Insights wire format requires a time field per envelope; the sender stamps it client-side at flush (src/telemetry/sender.ts). It records when the batch was sent — not when your session ran, started, or ended. The "no timestamps" rule covers aireceipts' own event payload fields (properties), which carry only coarse buckets and no time fields.
  • Ingestion-side geolocation. The receiving service sees the sending IP address. On the shipped Application Insights resource, Azure's default handling applies: the IP itself is masked (every stored row carries 0.0.0.0) and a coarse location derived from it (country and city, sometimes state or province) is stored on the row before the address is discarded. aireceipts never sends, reads, or uses that field; it is not in the payload and --telemetry-show cannot show it, but it exists in the stored data. A custom resource set through AIRECEIPTS_TELEMETRY_CONNECTION follows that resource's own settings (Azure's DisableIpMasking option can retain the IP). If you do not want even a coarse location recorded, use a kill switch (AIRECEIPTS_TELEMETRY=off or DO_NOT_TRACK=1). Blanking the field at ingestion for the shipped resource is proposed in SPEC-0098; until that work ships, treat the coarse location as recorded.

Connection-string honesty

  • The ingestion key this package ships with is not a secret; Application Insights instrumentation keys are write-only and are commonly embedded in open-source clients. The shipped key: InstrumentationKey=394da360-a50c-4700-bcf9-87b8d9d6e0ee (ingestion endpoint eastus-8.in.applicationinsights.azure.com). <!-- gitleaks:allow -->
  • AIRECEIPTS_TELEMETRY_CONNECTION overrides the shipped default. Set it to your own Application Insights resource or to an empty string to force-disable telemetry.
  • With the shipped default connection string, a development build (0.0.0 fallback version or package root containing .git) disables telemetry and creates no install id. An explicit AIRECEIPTS_TELEMETRY_CONNECTION override enables sends from that build. --telemetry-show reports whether it is disabled.
  • A malformed connection string also degrades to enabled: false rather than sending to an incomplete endpoint.

First-run notice

The first time aireceipts runs for a given user while telemetry is enabled, it prints a one-line disclosure pointing here, then persists { "shown": true } to ~/.aireceipts/telemetry.json so it never prints again. If AIRECEIPTS_TELEMETRY=off or DO_NOT_TRACK=1 is active before that first enabled run, aireceipts prints no notice and does not persist the shown flag; the notice appears on the first later run where telemetry is enabled. If the notice file cannot be read or written, the notice is shown again on the next enabled run rather than failing the CLI.

Source of truth

The schemas in src/telemetry/schemas.ts are the actual source of truth. This document is kept in sync with them and reviewed alongside any schema change.