Skip to main content
For anyone — human or agent — adding a tool, an event, a finding kind, or a failure path to Reticle. The rules here are enforced by packages/server/src/telemetry/telemetry-contract.test.ts. If you break one, that test tells you which and where. This page is why.

Why this has its own contract

Telemetry fails silently. Nothing throws when an event is missed. No test goes red. No user complains. The data is simply, permanently absent — and you find out months later when someone asks a question the data cannot answer, about a period you can never re-collect. That has already happened here twice, and both times the code looked correct:
  • daemon_stopped was emitted fire-and-forget microseconds before process.exit(0). The POST was killed every single time. The event never once arrived, and nothing anywhere indicated a problem.
  • bug_found hand-copied the twelve contradiction kinds into a local Set. Correct on the day it was written; the thirteenth kind would have been silently miscounted, quietly deflating the one number we intend to publish.
So the rule is not “remember to add telemetry”. The rule is that the guard lives in a test.

The five rules

1. Everything routes through a chokepoint

Tool usage, timing, errors, verifications and bugs are all recorded in one placerunTool in tools/invoke-tool.ts. Adding a tool to TOOLS is all it takes to be instrumented. Do not add telemetry inside a tool handler. If you find yourself wanting to, the metric probably belongs at the chokepoint, read off the result.
The one exception is a path that genuinely does not go through runTool — currently only the verification runner (reticle verify, the HTTP verify surface), which has its own reporter in telemetry/run-telemetry.ts. If you add a second dispatch path, it needs the same treatment, and until it has one it is invisible. That gap existed for real: CI-found bugs were uncounted.

2. Names say what happened

<noun>_<verbed>, lowercase, no abbreviations: verification_completed, bug_found, runtime_crashed.

Counting defects: instances vs distinct

bug_found fires once per OCCURRENCE. A defect hit five times in a session is five events, which is the right raw signal — frequency is what says which classes of defect actually cost anybody anything. But it means a naive count answers “how often were defects hit”, not “how many defects were found”, while looking like it answers the second. So every bug_found carries repeat: false the first time a KIND is seen in a session, true after. Count repeat: false for distinct defects; count everything for instances. Measured on a real app, the instance count was more than double the defect count. Publishing instances as defects inflates the claim accordingly. The denominator is verification_completed, which fires per verdict with via, verified, passed and falseGreenCaught. Defects per verification is the honest rate; raw defect counts grow with usage and say nothing on their own. And repeat only means anything if the session remembers. SessionMetrics.reset() runs at every periodic flush and used to clear the seen-kinds set with the window counters — so the same defect, re-found after a flush, reported repeat: false again. Sessions in the data run to 11.5 hours. Window counters zero on a flush; session-lifetime memory does not. (session-window.test.ts) Two rules follow, and both are gated:
  • repeat is set at the EMISSION site, never by the classifier. bugsInResult is a pure function over one tool result and cannot know what a session has already seen; if it ever grows a repeat field it will be guessing, and the guess becomes the published number. (telemetry-contract.test.ts)
  • Session-scoped, and it cannot be otherwise. The payload carries no selector, URL or app detail by design, so the same defect in two sessions is unrecognisable as one — and making it recognisable would require collecting exactly what this event refuses to collect.
The old set failed this so badly it confused its own authors — invoke meant “the CLI ran” while tool meant “a tool was called”, which is the opposite of how both read. A name that has to be looked up is a name that gets misread on a dashboard a year from now.

3. Names, never values

--http-token holds a secret. reticle_act’s args holds the text being typed into the app, which on a login form is a password. Assume every value is the worst thing it could be. There is one narrow exception, and it is explicit rather than heuristic: parameters whose values are enums we defined are allow-listed in telemetry/argument-shape.ts, and anything unrecognised reports as other so a schema change cannot start forwarding free text.

4. Never derive a vocabulary by copying it

If a set of kinds already exists in @reticlehq/core, import it. Do not re-list it.
A copied enum is a drift hazard anywhere. It is a correctness hazard when the thing that drifts is a number you publish.

5. A metric may never change behaviour

Every send is wrapped and best-effort. A telemetry failure must not fail a tool call, a verification, a daemon start, or reticle init. The single exception is daemon_stopped, which is awaited — because the process exits immediately after and the send would otherwise be killed. Even then a failure resolves rather than throws.

The event kinds, all of them

TelemetryEventKind in @reticlehq/core is the closed list. Seven of these went undocumented here for months — the doc described only the session-lifecycle half — so anyone building a dashboard from this page could not know that the transport and install events existed at all. A kind nobody documents is a kind nobody queries, and telemetry that nobody queries is telemetry nobody notices has stopped arriving. telemetry-contract.test.ts now fails when a kind is missing from this table.

The install has two halves — app_instrumented

Reticle is only usable when both halves are done: the MCP server is registered so the agent has the tools, and the SDK is loaded by a running page so there is something for those tools to look at. They are done by different commands, at different times, often in different directories. Almost everyone completes the first. The second is where the users go. Nothing measured the second. daemon_started and mcp_client_connected describe the agent half. session_appConnects describes the app half but is a window counter — it resets on every flush, so a user whose app connected in one window reads zero in every other. The population it under-counts is precisely the population being measured, and a funnel built on it reported fewer instrumented users than there were users calling tools, which is impossible on its face. app_instrumented fires once per daemon run, on the first session-ready only, so daemon_startedapp_instrumented is a rate rather than an inference and a reloading page cannot inflate it. It carries initialized (had init run here), agentAttached (was an agent already waiting), and msToFirstApp (how long the daemon sat with nothing wired). It deliberately carries no stack and no framework: project_profiled already reports both for the same run, and the two join on sessionId. What it still cannot see is why an app never connected — every cause for that is page-side (the non-localhost gate, a port mismatch, a stale build, a dev server never restarted), where the daemon has no visibility. That needs the SDK to report its own refusals, and it is the next thing to build.

Sessions: daemon_stopped vs session_progress

Count sessions with daemon_stopped (final: true). It fires once, at a clean exit. A running daemon rolls its window up every 5 minutes as session_progress (final: false), same payload shape. Sum work across both; count sessions with neither summed nor doubled. This split exists because the flush used to be emitted AS daemon_stopped — an event named for an exit, fired while the process was alive. One day’s export: 98 daemon_stopped events were 73 exits + 25 flushes, so a session count was 34% high. Worse, the two populations are opposites — every one of the 25 flushes had tool calls and not one of the 73 exits did, because a daemon that has served a tool never idle-exits and so never reaches a clean shutdown. A funnel over the raw event describes active sessions at one end and abandoned ones at the other. The flush interval is also the bound on what is lost: nothing calls shutdown when a working daemon is finally killed, so its last partial window dies with it. At 30 minutes against a median 28-minute session that was most of the session. Only non-empty windows emit, so a short interval costs nothing on the daemons that never serve a tool.

The session summary’s newer fields

Four counters and one flag were added because the data could not answer questions we were already asking. All are properties on events that already exist — no new kinds — and all four counters are omitted rather than sent as zero, so a field’s presence is itself the signal.

Why a verdict came out that way — verification.reason

verified has three values. The rule that produces it has eleven clauses. Everything in between was thrown away at the moment it was known. Captured against the real classifier: verified: 'unknown' covered “the agent malformed the call”, “the consequence was already true”, “the app answered 202”, “a 2xx body went unread”, “the capture was not clean”, “nothing was asserted at a real grade” and “the page never settled” — seven causes, two wire payloads. They belong to three different owners (the agent, the app, Reticle) and need opposite responses: teach the agent, wait and re-check, or ship a fix. On a dashboard they were one bar. verified: 'no' collapsed the same way — “channels disagree” (Reticle earning its keep) and “the agent’s predicate failed” were the same string. VerifiedReason lives in @reticlehq/core and is the single list. decideVerified returns a member from every clause, so a new clause cannot compile without one; verified.test.ts drives all ten and fails if a member exists that no clause produces. verification-of.ts narrows the result field against Object.values(VerifiedReason) — anything else is dropped rather than forwarded, because a string nobody can group by is worse than a gap. Nothing re-lists these, including the battery spec, which imports the enum from core’s build. Optional on purpose: a suite verdict (flow_verify) is a pass/fail with no clause behind it, and an older sender has none. Absent means unclassified.

Whose defect it was — removed, on purpose

bug.attribution shipped twice and was wrong both times. A real drive found that across two full runs EVERY attribution: 'app' was a misattribution, while the one defect that genuinely was a bad agent predicate carried none — a single session would have published “2 defects in the app” against a true count of 0. A metric that is confidently wrong about whose fault a defect is, is worse than no metric: it is the number a founder steers on, and it points at the customer. Counting nothing is recoverable; publishing a false accusation about somebody’s product is not. It comes back when a verdict carries the reason it came out that way. Today the payload cannot separate the cases — element.present covers “the button is missing”, “the API is down” and “the agent mistyped a testid” identically.

What is NOT a crash — expected disconnects

runtime_crashed answers exactly one question: is Reticle stable. One real session put nine events into it, all write EPIPE — the MCP client closed its half of the stdio pipe and the next process.stdout.write failed, which is how a client is supposed to leave. daemon-resilience.ts matches err.code against EPIPE / ECONNRESET / ERR_STREAM_DESTROYED and logs reticle_daemon_client_disconnected (or reticle_mcp_proxy_client_disconnected) instead of emitting. Two rules make this safe rather than a hole:
  • Code, never message. Prose gets wrapped, localised and rewritten; matching it would eventually swallow a real crash that merely mentioned a pipe. daemon-resilience.test.ts drives an error whose message says write EPIPE and carries no code, and asserts it is still a crash.
  • Visible, never swallowed. It still logs a line with the code. A daemon emitting a hundred of these is a finding, just not a crash.
A disconnect is also no longer fatal for the daemon: Node’s “process state is undefined” guidance is about a throw that escaped everything, not about writing to a socket somebody closed, and exiting there let one departing client take down the daemon serving every other agent.

Did MCP stay up — the outage block

The transport-stability metric shipped with an empty payload for months, and it is the exact failure this page opens with. reportMcpOutage passed { outage: { stage, reason, attempts } }, TelemetryExtra declared the field, and it typechecked — but emit() builds its event from an explicit allow-list of keys and outage was not on it, nor in the blocks flattening map, nor in core’s TelemetryEventSchema. Two deliberately different outages produced byte-identical events. Nothing threw, no test went red, and the data for that whole period cannot be recovered. The lesson is not “wire the field”. It is that the battery asserted the event ARRIVED and never that it carried anything, and a kind-only assertion cannot see an empty payload. When you add an event kind, the live check has to assert the FIELDS.
  • stagefirst (this session lost MCP at all) or budget_spent (it stopped retrying). These are the two facts the event exists to separate: the share of sessions that lose MCP, and the share where it never came back on its own.
  • reason — closed OutageReason: sse_ended | sse_error | sse_aborted | sse_closed | connect_error | other. The proxy’s own reason strings are free text that also feeds a log, so mcp-outage.ts narrows them and reports other for anything unnamed. A classifier that cannot say “I don’t know” lies instead, and an unbounded string must never reach the wire.
  • attempts — consecutive reconnects tried when this was reported.
Still true and worth knowing when you query it: mcp_connection_lost carries no sessionId (it fires from the proxy process, not the daemon), and is capped at two per proxy process by design.

Recording locally instead of sending — RETICLE_TELEMETRY_FILE

Set it to a path and every event is appended there as one JSON object per line, and nothing is sent. The payload is the one the wire would have carried, built by the same code and redacted by the same rules, so what a run records is what a user would have sent. It exists for two reasons that pull the same way:
  • A release sweep is not a user. Driving dozens of sessions through a gate emits real daemon_started / verification_completed / bug_found events, indistinguishable in PostHog from people. Test runs polluting the numbers is the same class of error as counting cli_command_run { mcp } as human intent: the metric stops describing what it claims to.
  • Verifying telemetry should not need a hand-rolled HTTP server. Ad-hoc harnesses are how a check ends up measuring nothing.
One deliberate exception to the rules above: RETICLE_TELEMETRY_FILE keeps telemetry ENABLED inside a Reticle source checkout. The checkout guard exists to stop us phoning home, and writing a local file is not phoning home — while a release sweep is driven from exactly there, so a sink that inherited the guard would record nothing and look like it had worked. sent: true from reticle_feedback means the record landed in the file, which is the honest reading of “captured” for a recorded run. An unwritable path degrades to a no-op and reports false; it never takes the daemon down.

Adding things: what to do

Verifying it actually works

Unit tests cannot see the failure mode that matters, because nothing throws. Two things do:
The second is the one that matters. It drives the real built modules against a real capture server — real network, real process semantics, real redaction — and asserts each event arrives. Half its checks are leak checks, asserting that secrets, passwords, customer emails and home directories are absent. Both halves are mutation-tested. Reintroducing the fire-and-forget bug fails 9 checks; disabling redaction fails 3. A guard that cannot fail is theatre, so these are periodically proven to bite.

The privacy line, in one sentence

We measure that something happened and what class of thing it was — never what it was, in whose app, or containing what.
Last modified on August 14, 2026