The shared envelope
TelemetryEventSchema wraps every event. These fields are on all sixteen kinds.
Then exactly one payload block, keyed by event. The keys are
feedback, command plus flags, session, project, verification, versionChange, crash, identity, connection, init, bug, outage, instrumentation.
Session scope
isSessionScoped(kind) decides whether sessionId is attached. Eleven kinds are session-scoped:
daemon_started, daemon_stopped, session_progress, mcp_client_connected, app_instrumented, project_profiled, verification_completed, bug_found, tool_refused, runtime_crashed, feedback_submitted.
The other six are one-shot. A per-process id on reticle status is not a session: it joins to nothing and inflates every session count that runs a distinct count over the field.
reticle_installed
Wire name:reticle_installed. Emitted from: packages/server/src/telemetry/cli-telemetry.ts:50. Session-scoped: no. Payload block: none.
Fires on the first ever run on a machine. Carries installSource.
installSource is declared, never detected. A channel sets RETICLE_INSTALL_SOURCE on the process that runs the install and anything unrecognised reports unknown, so unknown is expected to be the largest bucket while the marker spreads across four separately published artifacts. Read a small unknown as a marker that spread, never as success. Nothing infers it: npm_config_user_agent only says npx ran us and every route goes through npx; the presence of a plugin directory or a skill folder says a route exists rather than that it ran the install. The same field rides on init_completed.
The question it answers: how many machines have installed this, and what does the new-user curve look like? There is deliberately no uninstall event, because npm 7 and pnpm run no uninstall lifecycle script. Churn is inferred server-side from inactivity instead.
cli_command_run
Wire name:cli_command_run. Emitted from: packages/server/src/telemetry/cli-telemetry.ts:52. Session-scoped: no. Payload: two envelope fields.
The question it answers: what do humans actually do with the CLI?
verify and gate mean something very different from status, and that ratio is the closest honest read on intent available.
Flag values are never sent. A value is a port, a URL, a file path, or in
--http-token’s case
a secret. Names alone answer “does anybody use --storage-state” with none of that risk.daemon_started
Wire name:daemon_started. Emitted from: packages/server/src/telemetry/daemon-telemetry.ts:73. Session-scoped: yes. Payload block: none.
The question it answers: how many active daemon runs are there? This is the numerator of daily, weekly and monthly actives, and the first step of the install funnel.
daemon_stopped
Wire name:daemon_stopped. Emitted from: packages/server/src/telemetry/daemon-telemetry.ts:120. Session-scoped: yes. Payload block: session, a SessionSummary with final: true.
The rich one. One event carries the entire session rolled up, replacing the hundreds a per-tool-call event would send. PostHog bills per ingested event and a single verification loop is a great many tool calls, so aggregation here is a design decision, not an optimisation.
SessionSummarySchema
The question it answers: what did a whole session look like, and how did it end?
endReason and exit answer different questions on purpose: a daemon can exit tidily on idle while the agent’s work was abandoned mid-task.
session_progress
Wire name:session_progress. Emitted from: packages/server/src/telemetry/daemon-telemetry.ts:98. Session-scoped: yes. Payload block: session, the same SessionSummary shape with final: false and no exit.
A periodic roll-up from a daemon that is still running.
The question it answers: what work is happening in sessions that have not ended yet?
verification_completed
Wire name:verification_completed. Emitted from: packages/server/src/tools/invoke-tool.ts:125 and packages/server/src/telemetry/run-telemetry.ts:38. Session-scoped: yes. Payload block: verification.
VerificationSchema
The question it answers: was an app actually verified, and how often did that catch something a green test would have missed? This is the product’s reason to exist expressed as an event.
project_profiled
Wire name:project_profiled. Emitted from: packages/server/src/telemetry/daemon-telemetry.ts:87. Session-scoped: yes. Payload block: project. Once per daemon start, so it is cheap.
ProjectProfileSchema
The question it answers: are they using all of Reticle or three tools of it? That is the difference between a retention problem and an activation problem, and both look identical in a DAU chart.
version_changed
Wire name:version_changed. Emitted from: packages/server/src/update/updater.ts:35. Session-scoped: no. Payload block: versionChange.
The question it answers: are people upgrading, and did our nudge do anything? Without
nudged, “the nudge never fired” and “the nudge fired and nobody acted” are indistinguishable, and they need opposite responses.
runtime_crashed
Wire name:runtime_crashed. Emitted from: packages/server/src/daemon/daemon-resilience.ts:155. Session-scoped: yes. Payload block: crash.
CrashSchema
The question it answers: what is crashing, where, and can we fix it without asking the user for a repro? The first version of this carried only a fingerprint, which made crashes rankable and completely undiagnosable.
feedback_submitted
Wire name:feedback_submitted. Emitted from: packages/server/src/telemetry/feedback.ts:256 and :270. Session-scoped: yes. Payload block: feedback.
The one kind that carries author-written free text, which is exactly why it is never emitted passively. It exists only because an agent called reticle_feedback or a human ran reticle feedback. It has its own kill switch.
FeedbackSchema
The question it answers: what is broken, missing or awkward, in the words of the person or agent who hit it? The three agent kinds are kept distinct on purpose: a bug is our defect, a gap is a thing we cannot see at all, and an ambiguity is a verdict the agent could not act on.
text and trace are the only free-text fields Reticle ever sends. They are capped and redacted client-side before the wire, and the CLI prints the payload before sending it.
identified
Wire name:identified. Emitted from: packages/server/src/telemetry/identify.ts:77. Session-scoped: no. Payload block: identity.
The question it answers: who is this, when they have chosen to say? The only personal data Reticle ever transmits, and it transmits it only because a human ran
reticle identify. Reticle never infers an identity from a git remote, a git config email, or anything else. That refusal is deliberate.
mcp_client_connected
Wire name:mcp_client_connected. Emitted from: packages/server/src/telemetry/mcp-connection.ts:29. Session-scoped: yes. Payload block: connection.
The question it answers: is somebody actually using this, as opposed to having it installed and running? A daemon can sit up for days with no agent attached. A large
daemonAgeMs on the first connect is the interesting case: Reticle was started and then sat unused, which is an onboarding failure nobody would otherwise report. Reconnect churn is visible here too, and a client reattaching every few minutes looks identical to healthy usage in every other metric.
app_instrumented
Wire name:app_instrumented. Emitted from: packages/server/src/telemetry/app-instrumented.ts:55. Session-scoped: yes. Payload block: instrumentation.
The question it answers: did the second half of the install ever happen? Reticle’s install has two halves, registering the MCP server and getting the SDK into a running page, done at different times by different commands and often in different directories. Almost everyone completes the first.
Fired once per daemon run, on the first connect only, so
daemon_started to app_instrumented is a real rate and a reconnecting page cannot inflate it. It deliberately carries no stack and no framework: project_profiled reports both for the same daemon run, and the two join on sessionId.
mcp_connection_lost
Wire name:mcp_connection_lost. Emitted from: packages/server/src/mcp/mcp-outage.ts:53. Session-scoped: no. Payload block: outage.
The question it answers: how often does a real user’s MCP server go down, and does it come back? Capped at two per proxy process: once on the first outage of a session, and once if the retry budget is spent. An event per reconnect would bill for the pathology instead of measuring it.
pendingLost is the part an agent can feel. Zero means nobody noticed. Non-zero is the number of calls that came back an error, and the count worth driving down. OutageReason.OTHER exists so a new proxy reason string can arrive without raw free text reaching the wire.
init_completed
Wire name:init_completed. Emitted from: packages/server/src/telemetry/init-telemetry.ts:31. Session-scoped: no. Payload block: init.
The question it answers: does the onboarding work? Before this event existed, a setup that failed on a missing dependency was indistinguishable from a user who never tried.
bug_found
Wire name:bug_found. Emitted from: packages/server/src/tools/invoke-tool.ts:147 and packages/server/src/telemetry/run-telemetry.ts:58. Session-scoped: yes. Payload block: bug.
The question it answers: does Reticle work? Everything else here measures whether Reticle is used.
Three properties of this schema exist to stop the number being wrong in a way that only shows up after it has been published:
falseGreenis defined by the presentation, not by “an assertion passed”, because the same defect arrives both ways: throughreticle_assertthere is a passing assertion to contradict, and through a crawl there is no assertion at all.repeatseparates distinct defects from instances. Countrepeat: falsefor distinct, count everything for frequency. Scoped to the session, because the payload deliberately carries no selector or URL, so the same defect in two sessions cannot be recognised as one, and must not be.attributionseparates a defect in the app under test from the agent’s own bad predicate and from Reticle’s own blind spot. Onlyattribution: 'app'belongs in a published defect count, andapprequires positive evidence: something the app itself did, taken from core’sABSENCE_DERIVED_CONTRADICTIONSline, never “nothing else explained it”. Everything the evidence cannot settle isunclassified, which is a value rather than a gap.
tool_refused
Wire name:tool_refused. Emitted from: packages/server/src/tools/invoke-tool.ts, via packages/server/src/telemetry/tool-refused.ts. Session-scoped: yes. Payload block: refusal.
The question it answers: why does the largest cohort in the funnel go quiet? The refusal path already computes a precise diagnosis and hands it to the agent as prose; until this event it then threw it away, so a user who hit a wall on their first call emitted nothing at all.
- The reason is a bucket over the recovery table in
error-recovery.ts, not a second list of patterns, so a recovery added without a reason does not compile. retriedlands on the retry, not the first refusal. Reporting it the other way round means holding the first event back until a next call reveals whether one came, which loses it entirely for the agent that gives up, the population the event exists to describe.- Capped at 50 per daemon run. A stuck agent is the shape that produces hundreds, and
consecutiveRepeatson the session summary still reports how long the loop ran.
Adding a new event
Readdocs/telemetry-contract.md before you touch anything that emits. The short version:
- Add the kind to
TelemetryEventKindinpackages/core/src/telemetry.ts, with a doc comment saying what question it answers. - Define its payload schema in the same file, or in
telemetry-session.tsif it is a rollup. - Add the payload key to
TelemetryEventSchema. - Add it to the emitter’s key allowlist, or it will be silently dropped.
- Decide whether it is session-scoped and add it to
SESSION_SCOPEDif so. - If it produces a verdict, add the tool to
VERIFICATION_TOOLS. If it introduces a finding kind, add that to core’s enum and never re-list it locally.
telemetry-contract.test.ts enforces the parts that can be enforced, and pnpm test:e2e runs a spec that fires every event kind against a real capture endpoint and asserts each one lands.
What users see
The plain-language version, and every way to switch it off.