feat(telemetry): implement ADR 0050 Level 3 content capture - #6429
Conversation
E2E tests did not runE2E tests run automatically for org/repo members and collaborators on pull requests. For other contributors, a maintainer must add the See E2E testing guide for details. |
1 similar comment
E2E tests did not runE2E tests run automatically for org/repo members and collaborators on pull requests. For other contributors, a maintainer must add the See E2E testing guide for details. |
Site previewPreview: https://eca2d23e-site.fullsend-ai.workers.dev Commit: |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
5a11d23 to
589a882
Compare
PR Summary by Qodofeat(telemetry): implement Level 3 content capture on agent spans
AI Description
Diagram
High-Level Assessment
Files changed (12)
|
Code Review by Qodo
1.
|
|
You need to add the variable to the workflows and add the variable to the user guide, follow the same style and tone of the guide. It shouldn't take more than 7 or 8 lines. |
|
LGTM but I would want to see a review instrumented trace, as they are very complex. |
|
Fixed in 829bd9e — |
|
Review-instrumented trace, as requested: What it shows: the |
|
On the $10.57 for |
dbf888d to
0f8865a
Compare
Signed-off-by: Dharit Shah <dhshah@redhat.com>
Signed-off-by: Dharit Shah <dhshah@redhat.com>
Signed-off-by: Dharit Shah <dhshah@redhat.com>
…tion Signed-off-by: Dharit Shah <dhshah@redhat.com>
- emit the schema-required finish_reason from the iteration outcome - budget keeps an ordered suffix so the final answer survives a cut; exact accounting includes tool-call name bytes, and names are redacted - treat sanitized-to-empty as redacted, never as unchanged - evict over-budget accumulation early so long sessions stay bounded - attach truncation markers even when the budget drops every part - bound free-text attributes (model, skip_reason, work_item_id) at their call sites now that the content gate lifts the provider-wide SDK cap Signed-off-by: Dharit Shah <dhshah@redhat.com>
Signed-off-by: Dharit Shah <dhshah@redhat.com>
The eviction pre-trim cut raw bytes before redaction, violating the documented redaction-before-truncation invariant: a secret straddling the trim boundary lost its prefix, no pattern matched the surviving fragment, and it rode to both sinks unmasked with zero findings. The pre-trim now redacts first and trims the masked text, so a boundary-straddling secret is whole when scanned. Whole parts evicted during accumulation were never scanned, so fullsend.content.redactions undercounted against its documented contract (findings from parts the size budget later dropped). Evicted parts are now scanned before discard and their findings carried to Result. The evicted-counter comment claimed eviction happens without changing the outcome; eviction compares pre-redaction sizes while the Result budget runs post-redaction, so it can drop content the documented policy would keep. The comment now states the approximation honestly. Signed-off-by: Dharit Shah <dhshah@redhat.com>
When the Level 3 content gate is on, spanLimits lifts the SDK attribute value cap so the SDK cannot cut gen_ai.output.messages mid-value — but an operator's explicit OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT or OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT still wins. Under a finite explicit limit every over-limit content value was cut mid-JSON by the SDK, in both sinks, with no fullsend.content.truncated marker (it reflects only collector-side cuts) and no signal to the operator, silently breaking the documented parse-the-JSON consumer contract. Surface the collision at Setup: when the gate is on and the operator limit resolved to a finite value, warn on stderr that content will be cut mid-JSON without the truncation marker. An explicit -1 (unlimited) cannot cut and stays silent. The operator limit still wins — telemetry never fails a run, and the warning makes the consequence visible instead of altering precedence. Signed-off-by: Dharit Shah <dhshah@redhat.com>
The admin guide said only that an explicit OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT still wins over the content-gate cap lift. State the consequence: a finite explicit limit cuts over-limit gen_ai.output.messages values mid-JSON in both sinks, the fullsend.content.truncated marker does not flag an SDK cut, and fullsend warns on stderr at startup about the combination. Signed-off-by: Dharit Shah <dhshah@redhat.com>
Signed-off-by: Dharit Shah <dhshah@redhat.com>
Signed-off-by: Dharit Shah <dhshah@redhat.com>
Signed-off-by: Dharit Shah <dhshah@redhat.com>
Signed-off-by: Dharit Shah <dhshah@redhat.com>
…ement step Signed-off-by: Dharit Shah <dhshah@redhat.com>
…ation Add OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT to the managed workflows variable table and the bring-your-own-workflow env block so both match what the managed agent steps now forward. Signed-off-by: Dharit Shah <dhshah@redhat.com>
…i#6502 Also adapt the bounded-model test to agentSpanEndAttrs's new runtimeName parameter from the runtime-selection work (fullsend-ai#6526). Signed-off-by: Dharit Shah <dhshah@redhat.com>
0f8865a to
94c3c4b
Compare
|
Merge-queue dequeue was two unrelated infra flakes, not the diff: functional |
The final review gauntlet confirmed three mechanisms the per-id bound alone left open. Ids serialize into the attribute but counted toward nothing, so their bytes bypassed the budget in aggregate — partSize now includes them, making the dropped-byte accounting exact over every serialized part byte, and the suffix boundary reserves a part's id bytes before fitting its response tail. Ids were also the only stream-derived string never passed through the redaction pipeline — they are scanned now, and a finding drops the id entirely rather than substituting one that could falsely collide. Parts with no content-bearing bytes are refused at Handle: they contributed nothing to output yet accumulated unboundedly, invisible to size-based eviction. Also disclose two residuals instead of implying their absence: the marshaled attribute carries JSON syntax/escaping above the counted budget (pre-existing fullsend-ai#6429 semantics), and stream lines beyond 1MiB are skipped whole — newly lossy for tool results, noted at the skip site. Signed-off-by: Dharit Shah <dhshah@redhat.com>
The final review gauntlet confirmed three mechanisms the per-id bound alone left open. Ids serialize into the attribute but counted toward nothing, so their bytes bypassed the budget in aggregate — partSize now includes them, making the dropped-byte accounting exact over every serialized part byte, and the suffix boundary reserves a part's id bytes before fitting its response tail. Ids were also the only stream-derived string never passed through the redaction pipeline — they are scanned now, and a finding drops the id entirely rather than substituting one that could falsely collide. Parts with no content-bearing bytes are refused at Handle: they contributed nothing to output yet accumulated unboundedly, invisible to size-based eviction. Also disclose two residuals instead of implying their absence: the marshaled attribute carries JSON syntax/escaping above the counted budget (pre-existing fullsend-ai#6429 semantics), and stream lines beyond 1MiB are skipped whole — newly lossy for tool results, noted at the skip site. Signed-off-by: Dharit Shah <dhshah@redhat.com>
The final review gauntlet confirmed three mechanisms the per-id bound alone left open. Ids serialize into the attribute but counted toward nothing, so their bytes bypassed the budget in aggregate — partSize now includes them, making the dropped-byte accounting exact over every serialized part byte, and the suffix boundary reserves a part's id bytes before fitting its response tail. Ids were also the only stream-derived string never passed through the redaction pipeline — they are scanned now, and a finding drops the id entirely rather than substituting one that could falsely collide. Parts with no content-bearing bytes are refused at Handle: they contributed nothing to output yet accumulated unboundedly, invisible to size-based eviction. Also disclose two residuals instead of implying their absence: the marshaled attribute carries JSON syntax/escaping above the counted budget (pre-existing fullsend-ai#6429 semantics), and stream lines beyond 1MiB are skipped whole — newly lossy for tool results, noted at the skip site. Signed-off-by: Dharit Shah <dhshah@redhat.com>
The final review gauntlet confirmed three mechanisms the per-id bound alone left open. Ids serialize into the attribute but counted toward nothing, so their bytes bypassed the budget in aggregate — partSize now includes them, making the dropped-byte accounting exact over every serialized part byte, and the suffix boundary reserves a part's id bytes before fitting its response tail. Ids were also the only stream-derived string never passed through the redaction pipeline — they are scanned now, and a finding drops the id entirely rather than substituting one that could falsely collide. Parts with no content-bearing bytes are refused at Handle: they contributed nothing to output yet accumulated unboundedly, invisible to size-based eviction. Also disclose two residuals instead of implying their absence: the marshaled attribute carries JSON syntax/escaping above the counted budget (pre-existing fullsend-ai#6429 semantics), and stream lines beyond 1MiB are skipped whole — newly lossy for tool results, noted at the skip site. Signed-off-by: Dharit Shah <dhshah@redhat.com>
The final review gauntlet confirmed three mechanisms the per-id bound alone left open. Ids serialize into the attribute but counted toward nothing, so their bytes bypassed the budget in aggregate — partSize now includes them, making the dropped-byte accounting exact over every serialized part byte, and the suffix boundary reserves a part's id bytes before fitting its response tail. Ids were also the only stream-derived string never passed through the redaction pipeline — they are scanned now, and a finding drops the id entirely rather than substituting one that could falsely collide. Parts with no content-bearing bytes are refused at Handle: they contributed nothing to output yet accumulated unboundedly, invisible to size-based eviction. Also disclose two residuals instead of implying their absence: the marshaled attribute carries JSON syntax/escaping above the counted budget (pre-existing fullsend-ai#6429 semantics), and stream lines beyond 1MiB are skipped whole — newly lossy for tool results, noted at the skip site. Signed-off-by: Dharit Shah <dhshah@redhat.com>
The final review gauntlet confirmed three mechanisms the per-id bound alone left open. Ids serialize into the attribute but counted toward nothing, so their bytes bypassed the budget in aggregate — partSize now includes them, making the dropped-byte accounting exact over every serialized part byte, and the suffix boundary reserves a part's id bytes before fitting its response tail. Ids were also the only stream-derived string never passed through the redaction pipeline — they are scanned now, and a finding drops the id entirely rather than substituting one that could falsely collide. Parts with no content-bearing bytes are refused at Handle: they contributed nothing to output yet accumulated unboundedly, invisible to size-based eviction. Also disclose two residuals instead of implying their absence: the marshaled attribute carries JSON syntax/escaping above the counted budget (pre-existing fullsend-ai#6429 semantics), and stream lines beyond 1MiB are skipped whole — newly lossy for tool results, noted at the skip site. Signed-off-by: Dharit Shah <dhshah@redhat.com>
Implements ADR 0050's Level 3 exactly as accepted: when the org sets
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, conversation content is included in spans. One environment variable, the Level 2 enablement pattern — no new ADR, no additional consent surface.The agent runtime's native content telemetry is never enabled. Content is assembled by fullsend's own runner from the normalized event stream the console already renders, redacted through
security.OutputPipeline()at assembly, and attached to the per-iterationagentspan. Fullsend reads the gate variable itself — it is a GenAI-instrumentation convention documented by opentelemetry-python-contrib; the pinned semconv v1.37.0 release does not define it, and Claude Code does not read it (0 occurrences in the shipped binary). Fullsend never sets the runtime's ownOTEL_LOG_*variables, so there is no second export pipeline and no redaction bypass.What ships
internal/telemetry/content.gointernal/cli/content_collector.gogen_ai.output.messagesattribute per iterationinternal/telemetry/telemetry.goboundedStringAttr;Setupwarns if a finite operator limit would cut content JSONinternal/cli/run.goRunParams.OnEventwith the console renderer preserved; gate off =OnEventnil = the pre-change path, byte-identicalGate values
true,span_only,span_and_event(case-insensitive)false,NO_CONTENT,event_only(we cannot honor "only"), anything unrecognizedContent shape. One assistant message per iteration, conformant to the v1.37.0 output-messages JSON schema including the required
finish_reason(stop/errorfrom the iteration outcome). Parts:text,reasoning(the schema's GenericPart extension point), andtool_callwith name + summary — no fabricatedarguments.Redaction and size. Redaction runs before any cut (truncating first could split a secret); hits are masked, counted in
fullsend.content.redactions, and warned on the console. The 256 KiB budget keeps an ordered suffix — the final answer survives a cut — with exact dropped-byte accounting surfaced asfullsend.content.truncated/fullsend.content.dropped_bytes, and early eviction keeps long sessions memory-bounded.Deliberately not captured (documented as dispositions)
gen_ai.input.messages— first-iteration runs have no meaningful runner-side input; retry iterations now carry the injected validation feedback (Validation feedback is injected into the agent prompt unframed and unsanitized (feedback_mode: append) #6502), which makes input capture a natural follow-up in this series.Next in this series (PR B, starts after this merges)
Tool results complete the captured record. One PR, three parts:
ToolResultEventininternal/runtime, emitted from thetool_resultblocks the Claude stream parser currently discards, as a runtime-agnostic addition to the normalizedAgentEventcontract.tool_call_responsepart ({type, id, result}); the gate, redaction, suffix budget, and markers in this PR handle it with no new surface (still one env var).The parser half ships together with its consumer rather than ahead of it, so no event type lands without production callers.
Evidence (pilot MLflow, experiment 1)
tr-5802e956713c146edef284bca8a0d338finish_reason=stop, byte-identical inrun-telemetry.jsonland on the backend; console rendered normally through the teetr-a1703820bb276f1121a9f808d0bde07etr-4a4ce9d96307f239880d5e2ba1802f72Test plan
go test ./internal/telemetry/ ./internal/cli/ -race— green. (The twoTestDummyRuntime_*failures on the author's machine are pre-existing environment flakes present on bare main.)telemetry.Setup()file sink for both gate states.Notes for review
Relates to #5361 (content capture is the last telemetry level; the enrichment legs remain open). Relates to #6036 (eval scorers read
run-telemetry.jsonl; content-aware scorers become possible once this lands). Relates to #294 (retention; unchanged by this PR).