feat(analytics): a per-document retrieval trail, keyed by trace id - #21
Merged
Conversation
Analytics recorded one row per read: the question, never the answer. That tells
you a search happened and returned nine things, which is no help at all when an
agent did something odd on the back of it.
`retrieval_results` now holds one row per document returned — rank, score,
keyword and semantic score, and why it matched — so the answerable question
becomes "this turn retrieved that document, third, on a semantic match at 0.71".
A lookup is recorded the same way: a trail showing what was searched but not
what was then opened is half a trail.
Rank is the order the caller received, not score order — that is what the agent
saw. Only rows that survived confidence and validity filtering are recorded, or
a document dropped on the way out would look like it was handed over.
**trace_id** ties those reads to the turn that caused them, and enters at the
edges rather than threading through every method:
HTTP an X-Trace-Id header, bound by middleware for the whole request and
echoed back so a caller can confirm it took
MCP an explicit trace_id argument, because a tool has no ambient request
A ContextVar carries it, so concurrent requests never see each other's id and a
worker cannot leak one into the next caller's reads. Nothing generates an id:
an absent one is recorded as NULL, because inventing one would create ids that
correlate nothing. Malformed ids are dropped rather than sanitised — a mangled
id fails to match what the caller believes it sent.
Two directions are now askable: `by_trace` (what did this turn retrieve?) and
`retrievals_of` (what keeps retrieving this document?), through the CLI
(`open-index trace <id>`), the Analytics tab, and a "what retrieved this" panel
on each entity.
Also here:
- a state volume in compose, and the directory pre-created in the image. The
trail lives in the container's state dir, so without it every redeploy
silently discarded exactly the history you would want while debugging one.
- retention: per-result rows multiply volume by the page size. Oldest reads go
first with their documents, so a trace is wholly present or wholly gone.
- the navigation guide now tells agents about mode, filters and trace_id —
it is injected into the MCP handshake, so this is where they will read it.
Found while writing the tests: a patch had duplicated the new methods into
NullAnalyticsStore, where they referenced a connection that does not exist. The
no-op fallback stands in when the state directory is unwritable, so a broken
method there would fail on exactly the machine where analytics were already
degraded. It is now asserted to mirror the real store's surface.
583 tests pass; analytics.py 99% (the miss is an unreachable guard),
tracing.py 100%.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dimittal
force-pushed
the
feat/retrieval-audit
branch
from
August 11, 2026 17:54
d11ec32 to
d856d33
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Analytics recorded one row per read: the question, never the answer. That tells you a search happened and returned nine things, which is no help when an agent then does something odd on the back of it.
One row per document returned
retrieval_resultsholds rank, score, keyword score, semantic score, and why it matched — so the answerable question becomes "this turn retrieved that document, third, on a semantic match at 0.71".Two details that decide whether the trail can be trusted:
trace_id, bound at the edges
X-Trace-Idheader, bound by middleware for the whole request and echoed back so a caller can confirm it tooktrace_idargument, because a tool has no ambient request to readA
ContextVarcarries it — per-task and per-thread, so concurrent requests never see each other's id, and the context manager restores on exit so a reused worker cannot credit the next caller's retrievals to the previous one.Nothing generates an id. An absent one is recorded as NULL; inventing one would create ids that correlate nothing. Malformed ids are dropped rather than sanitised — a mangled id silently fails to match what the caller believes it sent, which is worse than having none.
Both directions
by_trace— what did this turn retrieve?retrievals_of— what keeps retrieving this document?Surfaced through
open-index trace <id>, a lookup box on the Analytics tab, and a "what retrieved this" panel on every entity that links back to the trace.Also here
A state volume, and the directory pre-created in the image with the right ownership. The trail lives in the container's state directory, so without this every redeploy silently discarded exactly the history you would want while debugging one. Same failure shape as the model-cache volume already documented in the Dockerfile.
Retention. Per-result rows multiply volume by the page size. Oldest reads go first with their documents, so a trace is wholly present or wholly gone rather than surviving as a read whose documents were collected out from under it.
OPEN_INDEX_ANALYTICS_MAXtunes it;0disables it for a deployment shipping the file somewhere durable.The navigation guide now covers
mode,filtersandtrace_id. It is injected into the MCP handshake, so that is where agents will actually read it — including "readmatchbefore trusting a result: a semantic-only hit at a low score is a guess, not a fact."A bug the coverage gap caught
One of my patches used
str.replace, which hit both store classes — duplicating the new methods intoNullAnalyticsStore, where they referenced a connection that does not exist. That fallback stands in when the state directory is unwritable, so a broken method there would have failed on precisely the machine where analytics were already degraded. There is now a test asserting the null store mirrors the real store's public surface.I only noticed because coverage claimed methods were unexercised while the tests asserting their results were passing — the two facts could not both be true.
583 tests pass.
tracing.py100%,analytics.py99% — the single miss is an unreachable defensive guard, left rather than covered by an artificial test.🤖 Generated with Claude Code