TelemetryEventKind in core/src/telemetry.ts; if this page and that file disagree, the file wins and this page is a bug.
Source of truth for everything below:
The shared envelope
TelemetryEventSchema wraps every event. These fields are on every event kind, whatever it is.
Then exactly one payload block, keyed by event. The keys are
feedback, command plus flags, session, project, verification, versionChange, crash, identity, connection, init, bug, refusal, outage, instrumentation, stall.
Plus installSource, which is not a block. It is resolved once per process and stamped on every event, so it is the one attribution field you can group any chart by.
Session scope
isSessionScoped(kind) decides whether sessionId is attached. Twelve 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.
mcp_connection_lost is deliberately not on that list even though it feels session-shaped. It fires from the proxy process rather than the daemon, so it carries no sessionId and cannot be joined to a session at all.
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: server/src/telemetry/cli-telemetry.ts:51. 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. installSource is not limited to this event. It is resolved once per process (server/src/telemetry/telemetry.ts:392) and stamped on every event that process sends (:453), so any chart can group by it. It is also sourced config first, then environment: projectInstallSource() prefers the value recorded in .reticle.json, because the environment variable exists for exactly one command and is gone by the next, which is why the field originally reached a handful of events and nothing else.
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: server/src/telemetry/cli-telemetry.ts:58. 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: server/src/telemetry/daemon-telemetry.ts:75. 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: server/src/telemetry/daemon-telemetry.ts:138. 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: server/src/telemetry/daemon-telemetry.ts:116. 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: server/src/tools/invoke-tool.ts:144 and server/src/telemetry/run-telemetry.ts:39. 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: server/src/telemetry/daemon-telemetry.ts:91. 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: 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: 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: 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: 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: 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: 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: 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: server/src/telemetry/init-telemetry.ts:32. 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: server/src/tools/invoke-tool.ts:166 and server/src/telemetry/run-telemetry.ts:59. 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: server/src/telemetry/tool-refused.ts:49, reached from four call sites in server/src/tools/invoke-tool.ts (:285 a handler threw, :325 the wrong-tab guard, :346 a message-shaped refusal, :376 the {error} return convention). 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.
onboarding_step
Wire name:onboarding_step. Emitted from: server/src/telemetry/onboarding-funnel.ts:22, reached from the CLI (command/cli-onboarding.ts), the installer (command/setup/setup-install.ts, command/setup/setup-mcp.ts), init (init/src/host.ts through the injected InitHost) and the tool dispatcher (surface/tools/invoke-tool.ts). Session-scoped: no. Payload block: onboarding.
The question it answers: where do people stop, between never having heard of Reticle and having a verdict on their own app. No other event can answer it.
init_completed fires when the files are written, and the story continues for minutes after that; app_instrumented sees one moment of it. This is the only event that spans the whole route.
The steps are the funnel, in order, and they live in one table in core/src/onboarding.ts:
first_run.verdict_produced is the conversion event. Everything before it is setup that proved nothing.
Four decisions in this shape are worth knowing before you add to it:
- One kind for every step, not a kind per step. A phase plus a step keeps a funnel a
GROUP BYinstead of a union, so inserting or renaming a step does not silently break the queries watching it, and a new step appears in the existing chart rather than beside it. stepis a closedz.enum, not a capped string. It wasz.string().max(48), with a comment promising it was never user text, and a cap is not a promise./Users/someone/secret/projectis 28 characters and would have validated cleanly, out of the one payload a person can edit: the installer’s breadcrumb file, in their own home directory. The enum is derived fromOnboardingSteps, so a step added there is accepted on the same edit and a renamed step cannot arrive under both spellings.abandonedis a status of its own. A step that isstartedand never resolved is indistinguishable from one that was never reached, and both read as “we never got there”. Somebody walking away from a step we know they began is a different fact from the step failing, and only one of those is our bug.reportStepTimed’sfinallyis what makes it reliable: an exception escaping without a report turns a failure into silence.skippedis neither a loss nor a win. Already installed, already instrumented. Counting it as either makes a re-run look like a conversion or like a drop.
Adding a new event
Readdocs/telemetry-contract.md before you touch anything that emits. The short version:
- Add the kind to
TelemetryEventKindincore/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
VERDICT_TOOLS(server/src/tools/feedback-tools.ts). 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.