Add corpus-side trace distiller + transcript exporter - #229
Merged
Conversation
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.
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.
Lets us view anyone's agent run from the cloud corpus, without asking them for their SQLite database.
The problem
otlp_export.pyproduces 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.executealone was 3,653 spans of 18,892 on Raghav's session, plusSession.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>.mdtranscript +<session>.spans.json, same format as the SQLite path.Verified structurally equivalent to the existing pipeline:
otlp_export.pyknown-goodrun a x gate locally I want to test…Optimize X gate on transmonIdentity: the submitter, not the client-reported id
Three user identities are in this data and only one is trustworthy:
submitter(S3 object metadata)ai.telemetry.metadata.userIdraghavchari) — spoofableamicode.usermachineIdhashThe scripts prefer
--submitter, fall back to the client id, and tag the traceidentity:verifiedoridentity:client-reportedso provenance is never implicit. All three are recorded asamicode.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_submittersidecar. The README documents thehead-objectone-liner.Langfuse's OTLP mapping was verified empirically against a live instance rather than assumed:
langfuse.user.id→userId(a plainuser.idis silently ignored),langfuse.session.id→sessionId,langfuse.tags(JSON array string) → tags.Known fidelity limit, measured
OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT=16384clips long values. On a real session:ai.toolCall.argsai.toolCall.resultai.response.textai.response.reasoningai.promptNeither script relies on
ai.promptparsing.ai.promptis 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 namedGenerate 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 onfeat/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']..gitignoreextended for*.spans.jsonand_submitter— these outputs carry raw prompts and chain-of-thought, same as the existing exports.Not in scope