Skip to content

Add corpus-side trace distiller + transcript exporter - #229

Merged
jack-champagne merged 1 commit into
mainfrom
feat/corpus-trace-tools
Jul 29, 2026
Merged

Add corpus-side trace distiller + transcript exporter#229
jack-champagne merged 1 commit into
mainfrom
feat/corpus-trace-tools

Conversation

@jack-champagne

Copy link
Copy Markdown
Member

Lets us view anyone's agent run from the cloud corpus, without asking them for their SQLite database.

The problem

otlp_export.py produces the clean AGENT→LLM→TOOL traces we actually want to look at — but it builds them by reading opencode's SQLite at ~/.local/share/opencode. That file is on the user's machine, so it can only ever export your own sessions. When Raghav's first telemetry landed today, the only way to get a readable trace of it would have been to ask him to copy his database over.

Why that isn't necessary

The raw wire capture already contains everything: ai.prompt, ai.response.text/reasoning, ai.toolCall.args/result, ai.usage.*, ai.model.id, and the real opencode session id. It's just diluted ~300:1 by opencode's own internal instrumentation — sql.execute alone was 3,653 spans of 18,892 on Raghav's session, plus Session.getPart, SessionProcessor.*, http.server.

So the clean view is a filter over data we already own, not a different data source.

What's added

  • corpus_distill.py — corpus *.otlp.gz → OpenInference trace, POSTed as protobuf. Measured 18,918 → 67 spans (282:1) on Raghav's real session.
  • corpus_export.py — corpus *.otlp.gz<session>.md transcript + <session>.spans.json, same format as the SQLite path.

Verified structurally equivalent to the existing pipeline:

corpus-distilled (Raghav) otlp_export.py known-good
observations 67 67
types 38 TOOL / 28 GENERATION / 1 AGENT 37 TOOL / 29 GENERATION / 1 AGENT
root run a x gate locally I want to test… Optimize X gate on transmon

Identity: the submitter, not the client-reported id

Three user identities are in this data and only one is trustworthy:

source trustworthy what it is
submitter (S3 object metadata) yes derived by the ingest lambda from sha256 of the bearer token; never client-supplied
ai.telemetry.metadata.userId no opencode config value (raghavchari) — spoofable
amicode.user no VS Code machineId hash

The scripts prefer --submitter, fall back to the client id, and tag the trace identity:verified or identity:client-reported so provenance is never implicit. All three are recorded as amicode.submitter / amicode.opencode_user / amicode.machine_id.

Note S3 object metadata is not preserved by aws s3 sync, so the submitter has to be carried out-of-band — hence the flag and the _submitter sidecar. The README documents the head-object one-liner.

Langfuse's OTLP mapping was verified empirically against a live instance rather than assumed: langfuse.user.iduserId (a plain user.id is silently ignored), langfuse.session.idsessionId, langfuse.tags (JSON array string) → tags.

Known fidelity limit, measured

OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT=16384 clips long values. On a real session:

attribute intact
ai.toolCall.args 38/38
ai.toolCall.result 35/37
ai.response.text 56/56
ai.response.reasoning 52/52
ai.prompt 1/28 — often invalid JSON

Neither script relies on ai.prompt parsing. ai.prompt is the whole conversation re-sent on every call, so it's quadratically redundant — turn N restates turns 1..N-1, which we already hold untruncated. Both scrape it with a regex fallback and rebuild from the pieces.

Root titles come from a quirk worth documenting: opencode's first LLM call in every session is its own title generator ([system, "Generate a title for this conversation:", <real request>]) and is small enough to escape the cap. Skipping that boilerplate is required, or every root gets named Generate a title for this conversation: — which is exactly what my first pass did.

Genuinely lost content is limited to tool results over 16 KB, marked [TRUNCATED at 16 KB cap] rather than silently dropped. The uncapped fix is the transcript channel on feat/telemetry-transcript-channel, still unshipped.

Verification

Smoke-tested from the committed copies against real prod corpus data: transcript reconstruction (27 steps / 38 tool calls / 2 clipped) and a Langfuse push returning HTTP 200 with userId='raghav', sessionId='ses_052d6d2e…', tags ['distilled','identity:verified','repo:amico','run-corpus'].

.gitignore extended for *.spans.json and _submitter — these outputs carry raw prompts and chain-of-thought, same as the existing exports.

Not in scope

  • Deleting the superseded traces I pushed to the local Langfuse while iterating (no clean per-trace delete via the public API).
  • Raising the attribute cap, or shipping the transcript channel. Both are the real fix for the 16 KB loss and belong in their own PRs.

otlp_export.py can only export your OWN sessions: it reads opencode's
SQLite at ~/.local/share/opencode, which lives on the user's machine. So
looking at a teammate's run meant asking them to send you their database.

It does not have to. The raw wire capture in the run corpus already
carries every semantic attribute the AI SDK emits -- ai.prompt,
ai.response.text/reasoning, ai.toolCall.args/result, ai.usage.*,
ai.model.id, and the real opencode session id. It is just diluted ~300:1
by opencode's own internal instrumentation (sql.execute 3.6k spans,
Session.getPart, SessionProcessor.*, http.server). The clean view is a
filter over data we already own, not a different source.

- corpus_distill.py: corpus *.otlp.gz -> AGENT/LLM/TOOL OpenInference
  trace, POSTed as protobuf. Measured 18918 -> 67 spans (282:1) on a real
  session; output is structurally identical to otlp_export.py's.
- corpus_export.py: corpus *.otlp.gz -> readable <session>.md transcript
  plus <session>.spans.json, same format as the SQLite path.

Identity is the token-verified `submitter` from S3 object metadata, never
the client-reported ai.telemetry.metadata.userId or the machineId hash --
both of those are client-supplied and spoofable. All three are recorded,
and the trace is tagged identity:verified or identity:client-reported so
provenance is never ambiguous. Langfuse's mapping was verified against a
live instance: langfuse.user.id -> userId (a plain user.id is silently
ignored), langfuse.session.id -> sessionId, langfuse.tags -> tags.

Neither script relies on ai.prompt parsing. The 16 KB attribute cap
clips 27 of 28 prompts into invalid JSON, so both scrape it with a regex
fallback and rebuild the transcript from the untruncated pieces instead.
ai.prompt is the whole conversation re-sent every call, so it is
quadratically redundant -- the content is already held as reasoning/text/
tool spans. Root titles come from opencode's own title-generator call,
which is small enough to escape the cap; the boilerplate prompt is
skipped so roots are not all named "Generate a title for this
conversation:". Genuinely lost content is limited to tool results over
16 KB, which are marked [TRUNCATED at 16 KB cap] rather than dropped.

gitignore extended for the new outputs: they carry raw prompts and
chain-of-thought, same as the existing exports.
@jack-champagne
jack-champagne merged commit 6c13ac0 into main Jul 29, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant