CLAUDE.md@src/grove/core/telemetry · git:20260908.bbb0fcb · 2026-09-08 · sha256 2895cc64cf833c2c
CLAUDE.md@src/grove/core/telemetry git:20260908.bbb0fcbA
Immutable. This exact content is served forever at /api/v1/blob/2895cc64cf833c2c.
# grove.core.telemetry — one shape on the wire, whoever produced it > ↑ [grove.core](../CLAUDE.md) · [root](../../../../CLAUDE.md) Grove is an OpenTelemetry **gateway**, not a passenger. Everything a workspace emits — Grove's own transcript replay, the live context tier, and a harness's own OTLP re-exported through the receiver — is spelled from one vocabulary so a fleet of mixed harnesses lands as one shape. This file owns the collection, export and ingestion decisions; the LAUNCH boundary that decides whether an agent exports at all (`_launch_spec`, `TelemetryConfig.reserve`) stays in [grove.core](../CLAUDE.md), because that is manager and config territory. **The three tiers, and why each exists.** Tier 1 stamps identity into the agent's launch environment (`otel_resource.py`) — frozen at process start, by construction. Tier 2 is the live context span on the activity bus (`trace_forwarder.py`), carrying everything a frozen resource attribute cannot: the phase, the branch actually created, tickets attached later. Tier 3 is the transcript replay (`trace.py`), the only tier that can reconstruct a session that already happened. The OTLP receiver (`receiver.py`) is now **mounted** in the daemon, default OFF behind `telemetry.receiver.enabled`. **Mounting it turned out to be one `app.mount(...)` and two things that fail by looking like success.** A mounted sub-app's own lifespan does NOT run — Starlette drives only the outermost app's — so `OtlpIngest.aclose()` is driven from the daemon's own lifespan; and because `submit()` self-starts, a mount missing that drain works perfectly right up until shutdown, where it silently drops whatever was buffered. It also needed a new `_aclose_best_effort`, because the existing helper takes a *sync* callable and handing it a coroutine-returning `aclose` builds the coroutine and discards it unawaited — which drains nothing and raises nothing. Separately, `receiver.py` imports `opentelemetry-proto` at MODULE scope (unlike `trace.py`'s lazy imports), so the import is deferred inside the enabled branch: a module-level one breaks a lean `[daemon]` install — which omits the `telemetry` extra — at `import app`, with the receiver switched off. **Both bugs are invisible to a test that only asserts the route answers.** The mount is left unauthenticated on purpose: the daemon's loopback-only bind is the same load-bearing constraint that defers auth on every other route, and the agents posting here are local processes holding no daemon session token. **The measurement this subsystem was reshaped by (2026-08-11).** Against the live LangFuse instance, 300 traces: **266 (89%) carried neither input nor output**, and 48 of 50 sampled carried no tags at all. By producer — Grove's own context traces 119 (all blank), `claude_code.*` native OTel 105 (all blank), the Stop-hook exporter 30 (none blank), Grove's replay 9 (all blank, from a daemon running code older than the fix). A session view is the only way into that instance, so a blank row is work that was recorded and cannot be found. - **Historical telemetry is append-only even though agent sessions resume.** A completed human turn is the trace boundary and `langfuse.session.id` groups those traces into the native agent session. Generations parent tools; Claude sub-agent roots parent to their spawning tool, **recursively and with no depth bound** — the walk consumes each spawning call id once, which is what makes it terminate on a malformed sidecar, and visits ids in SORTED order because every downstream span id is derived from them and set iteration would make two replays of one transcript disagree. Trace-wide filter attributes propagate to every observation. Live context changes are zero-width revision spans that all share ONE per-session context trace; deriving the TRACE id from the revision instead minted a whole single-span trace per attribute change (8 of 14 traces in one live session), so the revision belongs in the span id and never the trace id. A restatement-only key must stay out of the change GATE while still being emitted — a phase file re-saved with an unchanged phase moved a timestamp and bought a revision for nothing. Deterministic ids support reconciliation; they do not make OTLP itself idempotent. - **One vocabulary, written twice, defined once (`telemetry/semconv.py`).** Every span carries both the OpenTelemetry GenAI convention's keys and the vendor's, because portable and readable are different keys *today*: the convention specifies `gen_ai.input.messages`, LangFuse maps `gen_ai.prompt`, and picking one buys either lock-in or a blank UI. **The convention strings are literals with a test asserting them equal to the SDK's**, not an import: `opentelemetry` is the optional `telemetry` extra and `trace.py` imports it lazily precisely so `grove.core` stays importable without it, so a module-scope import of the pre-stable `semconv._incubating` would undo that for every consumer of the vocabulary — and that path is one the OTel project has said will move. CI has the extra, so drift fails there. **A `grove.*` key that identifies a workspace must be emitted by BOTH this module and `otel_resource.compose_resource_attributes`** (asserted by test): the agent's own spans and Grove's describe one workspace, and a filter that finds half of it is worse than one that finds neither, because the half looks complete. - **A trace LangFuse lists with no input and no output is one a human never opens, so "has content" is a navigation property, not a nicety.** Measured against the live instance on 2026-08-11: **266 of 300 traces (89%) were blank on both**, and the single largest producer was Grove's own — 119 context traces, every one of them empty, 40% of everything on the page. A session view is the only way into that instance, so a blank row is work that was recorded and cannot be found. The context span therefore renders a brief (title, description, tickets) and a standing report (status, phase, todo counts). **Every line of it is Grove's OWN record and not one word is transcript** — `current_task` is the near miss, and including it would have made the `content_owner` gate conditional on which axis a payload arrived through, which is not a gate. A test enumerates the fixture's payloads and asserts each is absent, because an absence assertion is only as strong as its list. - **Identity rides EVERY span, not the trace root, and the reason is the consumer's aggregation model.** LangFuse filters and aggregates across individual observations rather than only at the root, so a trace-scoped fact present on the root alone is one a reader cannot narrow by — their own guidance is to propagate. `TraceIdentity` (in `semconv.py`) is the one object both the live context tier and the transcript replay spend, which is what stopped the replay carrying the session join key and nothing else: the richest tree Grove produces was the one nobody could filter by repo, branch or agent. - **Tags are a PROJECTION of those attributes, never a second vocabulary.** `langfuse.trace.tags` is the one surface a human narrows by clicking instead of by writing a query, and a tag set assembled independently drifts from the attributes it claims to mirror; a test asserts every `prefix:value` tag names a fact some attribute states. The tag table is deliberately a SUBSET of the attribute table — a worktree path and a free-prose title stay queryable but would bury every useful facet in a list nobody can scan. Tags sort, so two replays of one session cannot render as visibly different traces. - **`langfuse.trace.tags` is specified as `string[]`, which makes it the one attribute Grove writes that is not a scalar — and widening `AttributeValue` without widening `_otlp`'s copy was a latent protobuf crash.** A tuple fell through to `string_value`, which protobuf rejects, reachable only from the re-export tier and only for a span Grove had stamped tags onto. `apply` overwrites in place, so the array arm must CLEAR before filling: a tag list that grows by one copy of itself per export is a bug that appears only under retry. The two unions are contracted as identical; widen them together. - **Provenance belongs ON the observation, because the tree walk is exactly what a list view cannot do.** Every generation and tool span carries its owning agent's id, name and depth (`_SpanOwner`, applied once at the single point every descendant leaves the builder). A fleet's tool calls all render as siblings in a list, and without an owner on each the only way to tell a sub-agent's `Read` from the root's is to open both. Applying it at one exit rather than per constructor is deliberate: provenance some records carry and others do not reads as a fact about the call rather than as a missed call site. - **A replay that drops a sub-agent drops CONTENT, not just an edge — and the measurement said the edge is often simply not written down.** Census of 2592 real sidecars, 2026-08-11: 1025 are genuine sub-agents (`spawnDepth >= 1`), 747 carry a `toolUseId` and attach, **278 carry none — and only 8 of those 278 carry `parentAgentId` either.** So "correlate the rest by `parentAgentId`" recovers eight threads, not the class; there is no second key waiting to be read. **Every depth-2 and depth-3 thread attaches**, so nesting was never the problem — the whole gap is at depth 1. A thread nothing attaches never becomes a span and its messages sit in the transcript as sidechains nobody replays: on one real session that silence was **1434 of 2420 messages, 59% of the work**, absent from the trace entirely. `_Fleet` splits the two halves at the read; the adrift ones are placed in the turn their own start instant falls in, parented to the turn root, against the turn's ORIGINAL bounds so an attached grandchild widening the root cannot pull in an unrelated thread. They carry `grove.agent.attachment` (`spawn_tool` | `turn_window`), because one is evidence about causation and the other is a clock, and a reader comparing two sub-agents needs to know which they are looking at. - **A `grove.*` attribute is STORED and UNFINDABLE, so anything a consumer must SELECT on is written a second time, flat (`shell.py`, `semconv.filterable_identity`).** LangFuse files every attribute it does not recognise under `metadata.attributes` and every resource attribute under `metadata.resourceAttributes`, and its own docs state that only top-level `metadata` keys are filterable — so the whole identity vocabulary above was correct, present and matched nothing, which is the worst of the three states because the data is visibly there. The escape is the documented `langfuse.observation.metadata.<key>` prefix. **Dots are folded to underscores** on the way through: the vendor's filterable examples are all flat identifiers and a dotted key is exactly the shape its nesting rules use, so `grove.tool.category` risks re-creating the nesting the prefix exists to escape. The flat name is DERIVED from the attribute it mirrors, never a second hand-written table. The flat set is deliberately a third, smaller projection beside attributes and tags: attributes are the complete record, tags are what a human clicks, metadata is what an automated rule filters a cohort on. - **A shell call is the one tool call worth normalizing across harnesses, and it is normalized in `SpanRecord.tool` so BOTH tiers get it from one place.** Claude spells it `Bash`/`command`, Codex spells it `exec_command`/`cmd`, and the native OTLP spells it `full_command` on the span with `bash_command` on a `tool.output` event (both measured in the checked-in capture). An evaluator is one rule with one variable mapping and cannot hold a per-harness case, so `telemetry/shell.py` publishes one category, one metadata block and stable `input.command` / `output.content` envelopes. **Classification is by exact provider tool NAME** (`agents/shell.py`, shared with the usage audit so the two cannot disagree) — never by substring, never by sniffing the payload, and never converting an arbitrary tool into a shell observation. The category is therefore also the EXCLUSION filter: an external hook exporting the same transcript beside Grove carries no category at all, so selecting on it is structurally safe without disabling anybody's hook. - **Result evidence and process outcome are TWO keys, and success is asserted from a recorded exit code alone.** `empty` + `succeeded` is the case that forces the split: a command that printed nothing and exited zero is a complete, successful call, and one enum would have to drop one of those facts. The absence of a tool-error flag is never evidence — Codex records no structural flag on a shell result at all, so a published `false` there reads as "this worked" to any consumer that does not know which harness wrote it, and only `true` is ever emitted. `redacted` exists for the same reason at the other end: a content policy nulls the TEXT of a call that plainly finished, so folding it onto `pending` reported a redacting fleet as permanently mid-command. **Whether a result LANDED cannot be read off whether its text is present** — the caller holds the record and must say (`resolved`). - **Clipping an ENCODED payload produces a document that is a valid attribute and invalid JSON, and nothing on the wire admits it.** `json.dumps(x)[:CAP]` cuts mid-token. Fitting now happens on the PAYLOAD before encoding, optional keys are shed before the graded text is cut, and the flag rides inside the payload (it only ever flips `false` → `true`, which shortens the encoding, so a payload measured as fitting cannot stop fitting when marked). **The search for the surviving prefix is exact rather than arithmetic**: JSON escaping is not length-preserving, so a budget from the unescaped length overflows on precisely the multi-line output the cap exists to carry. `ATTR_TEXT_CAP` is Grove's own conservative ceiling, not a vendor limit — LangFuse documents a 5 MB request ceiling and no per-attribute limit — and it stays because a receiver rejecting one oversized span drops the batch it rode in. - **Changing a span's attributes changes `TraceManifest.fingerprint`, which is a HISTORY decision, not a schema one.** `telemetry_backfill` compares that hash against a completed checkpoint and reports `degraded` on a mismatch: it never re-exports and never deletes. So a canonical-shape change applies to sessions replayed from here on and leaves history exactly as it was — which is the correct default, and the reason no reindex is needed. Do not "fix" the mismatch by clearing checkpoints. - **A generation span is zero-width and that is the honest answer, so do not "fix" it.** A transcript stamps a message once, when it was written, and carries no request-start or first-token time; tool spans get real durations because a resolving `tool_result` has its own timestamp. Model latency is only knowable from a tier that watches the call happen — which is precisely the TTFT cost the telemetry reservation makes explicit.