Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/guides/dev/tracing.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,43 @@ build the attribute slices. Start attributes: `iteration`,
`exit_code`, `gen_ai.system`, model, token counts, `fullsend.cost_usd`,
`fullsend.tool_calls`.

### Level 3 content on agent spans

When the content-capture gate is on
(`telemetry.ContentCaptureEnabled()`), `runAgent` constructs one
`contentCollector` per iteration — iteration and agent span are 1:1, so a
run-scoped collector would repeat earlier iterations' content on later
spans — and tees the runtime's normalized event stream to it through
`RunParams.OnEvent`.

**The tee trap:** supplying any `OnEvent` replaces the runtime's default
console renderer (`internal/runtime/claude.go`), so the handler built by
`contentEventHandler` always calls the renderer first and the collector
second. With the gate off the collector is nil and `contentEventHandler`
returns nil, leaving the default renderer path byte-identical to before
Level 3 existed.

The collector (`internal/cli/content_collector.go`) coalesces contiguous
text/reasoning deltas, maps tool use to `tool_call` parts, redacts every
part through `security.OutputPipeline()` at assembly (redaction runs
before the size budget — truncating first could split a secret past
recognition), enforces a 256 KiB ordered-suffix budget (the ending survives — the
final answer is what consumers judge) with exact dropped-byte accounting
across content, tool names, and summaries, and emits
`gen_ai.output.messages` JSON following the GenAI output-messages schema,
including the schema-required `finish_reason` from the iteration outcome. `attachContent` records the
content and its marker attributes on the span before either
`finalizeAgentSpan` path can end it, so failed iterations keep their
content.

**Consumer contract** (for eval scorers and other readers of
`run-telemetry.jsonl`): parse the `gen_ai.output.messages` attribute as
JSON; check `fullsend.content.truncated` / `fullsend.content.dropped_bytes`
before treating content as complete; masked secrets appear as the
redactor's mask tokens and are counted in `fullsend.content.redactions`.
The attribute names and shapes above are the consumption contract — see the
[Tracing reference](../infrastructure/distributed-tracing.md#content-capture-level-3).

## Trace identity and TRACEPARENT propagation

`resolveTraceIdentity()` handles W3C trace context propagation in three
Expand Down
108 changes: 97 additions & 11 deletions docs/guides/infrastructure/distributed-tracing.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ For implementation details, see the
|-------|-----------------|----------------------|
| 1 | `run-telemetry.jsonl` file in the run output directory | None |
| 2 | OTLP/HTTP export to a remote backend (metadata only) | `OTEL_EXPORTER_OTLP_*ENDPOINT` |
| 3 | Content capture (prompts, completions, tool I/O) in spans | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true` *(planned, not yet implemented)* |
| 3 | Conversation content (assistant text, reasoning, tool calls) on `agent` spans | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true` |

All levels produce metadata (timing, token counts, tool names, errors).
Level 3 adds prompt/completion content to spans.
Level 3 adds the agent's conversation content to spans — enabled by one
environment variable, exactly like Level 2's endpoint
([ADR 0050](../../ADRs/0050-distributed-tracing-instrumentation.md)).

## Environment variables

Expand Down Expand Up @@ -66,15 +68,95 @@ unset OTEL_EXPORTER_OTLP_TRACES_ENDPOINT
| `TRACEPARENT` | W3C Trace Context parent | When present, the root span becomes `SpanKindConsumer`; when the sampled flag is unset (`-00`), OTLP export is suppressed but the local file is still written |
| `TRACESTATE` | W3C Trace Context state | Propagated alongside `TRACEPARENT` |

### Content capture (planned)

| Variable | Value | Effect |
|----------|-------|--------|
| `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | `true` | Includes prompts, completions, tool arguments, tool results, and reasoning text in spans |

Content capture follows the [OTel GenAI semantic conventions](https://github.com/open-telemetry/semantic-conventions/blob/v1.37.0/docs/gen-ai/gen-ai-spans.md).
When enabled, spans may contain proprietary source code, PII, or
credentials visible in tool outputs.
### Content capture (Level 3)

**The agent runtime's native content telemetry is never enabled.** Level 3
content is assembled by fullsend's own runner from the same normalized
event stream the console renders, redacted through the security pipeline
at assembly, and attached to the per-iteration `agent` span. There is no
second export pipeline and no redaction bypass: fullsend reads the
variable below itself and never sets the runtime's own content-logging
variables (`OTEL_LOG_USER_PROMPTS`, `OTEL_LOG_ASSISTANT_RESPONSES`,
`OTEL_LOG_TOOL_CONTENT`, `OTEL_LOG_TOOL_DETAILS`, `OTEL_LOG_RAW_API_BODIES`).

| Variable | Values that enable capture | Values that keep it off |
|----------|---------------------------|-------------------------|
| `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | `true`, `span_only`, `span_and_event` (case-insensitive) | unset, `false`, `NO_CONTENT`, `event_only`, anything unrecognized |

The variable name and value vocabulary come from the OpenTelemetry GenAI
instrumentation convention (documented by the
[opentelemetry-python-contrib GenAI instrumentations](https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation-genai));
the pinned semantic-conventions v1.37.0 release does not define the
variable itself. Fullsend records content on span attributes only, so `event_only`
stays off — honoring it on spans would contradict the operator's "only".
An unrecognized value disables capture rather than erroring: telemetry
never fails a run.

**What is captured:** the assistant's text, its reasoning, and its tool
calls (name plus a short summary), as a
`gen_ai.output.messages` span attribute — one assistant message carrying
the schema-required `finish_reason` (`stop` for a clean exit, `error` for
a failed iteration), as a JSON string following the
[GenAI output-messages schema](https://github.com/open-telemetry/semantic-conventions/blob/v1.37.0/docs/gen-ai/gen-ai-output-messages.json)
(reasoning uses the schema's extensible part type). Sub-agent activity in
the stream appears unattributed, exactly as it does in the console; nested
attribution is deferred along with ADR 0050's sub-agent span item.

**What is not captured:** model input (`gen_ai.input.messages`) — the CLI
passes a constant literal, so there is no meaningful input to record;
tool results — not in the normalized event stream today; pre/post-script
content.

> **Planned:** Tool results will join the captured content once a parser
> extension adds them to the normalized event stream — the next change in
> this series after [#6429](https://github.com/fullsend-ai/fullsend/pull/6429).

**Redaction and size:** every part passes through the security output
pipeline (Unicode normalization, then secret redaction) before it reaches
the span; redaction hits are masked, counted on the span, and warned in
the console. Content is bounded per iteration: 256 KiB of raw part bytes
before JSON encoding (the encoded attribute is larger by escaping
overhead), kept as an ordered **suffix** — the iteration's ending, the
final answer, is what consumers judge, so overflow drops the oldest
content first. The bound is a constant in v1, sized well above what
text, reasoning, and tool-call summaries produce (full-transcript
measurements that exceed it are dominated by tool results, which are not
captured yet) and validated whole against the pilot backend; it will be
revisited when tool results join the stream. Any cut is marked on the
span (see the custom attributes below) so a consumer can always tell
partial content from complete content. While the
gate is on, the SDK's span attribute length cap is lifted so it cannot
cut the content JSON mid-value — an explicit
`OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT` still wins. A finite explicit
limit therefore cuts any over-limit `gen_ai.output.messages` value
mid-JSON, in both sinks, and `fullsend.content.truncated` does not flag
an SDK cut; fullsend warns on stderr at startup about this combination —
raise the limit, set it to `-1`, or unset it to keep content parseable.
Backends and
collectors have their own ingestion limits; validate the target backend
accepts your typical content size before relying on it.

**Where content goes:** content rides the span to both sinks — always to
`run-telemetry.jsonl`, and to the OTLP endpoint whenever one is
configured (subject to the `TRACEPARENT` unsampled-flag suppression
above, which applies to all spans). Per ADR 0050, the organization enabling capture is
responsible for ensuring its backend's access controls suit the content's
sensitivity. When enabled, spans may contain proprietary source code,
PII, or credentials visible in agent output. The OTel specification
recommends external storage with span references for high-volume or
high-sensitivity production use.

> **Planned:** A bucket-export pipeline
> ([#6410](https://github.com/fullsend-ai/fullsend/issues/6410)) is the
> natural home for that external-storage pattern.

**MLflow rendering note:** MLflow derives its trace-list Request/Response
preview columns from the root span (capped at 1000 characters), so they stay
empty for fullsend traces — content lives on the per-iteration `agent`
spans and is visible when opening the trace's span view. Content is
deliberately not duplicated onto the root span: duplicated span data is
what produced the token double-count fixed by
[#5788](https://github.com/fullsend-ai/fullsend/pull/5788).

## Span hierarchy

Expand Down Expand Up @@ -126,6 +208,10 @@ and are recognized by LLM-aware backends for GenAI dashboards.
| `fullsend.prescript.skipped` | `run` | Whether the pre-script signaled a skip |
| `fullsend.prescript.skip_reason` | `run` | Human-readable skip reason from the pre-script |
| `fullsend.transcript_error` | `agent` | Present (`true`) when the agent exited 0 but its transcript reported an error — the span's status is Error while `exit_code` keeps the raw process exit |
| `gen_ai.output.messages` | `agent` | Level 3 only: the iteration's conversation content as a JSON string (see Content capture) |
| `fullsend.content.truncated` | `agent` | Level 3 only: present (`true`) when the size budget cut or dropped content |
| `fullsend.content.dropped_bytes` | `agent` | Level 3 only: exact content bytes removed by the size budget |
| `fullsend.content.redactions` | `agent` | Level 3 only: number of security findings raised while redacting content at assembly (including findings from parts the size budget later dropped) |

### Common attributes

Expand Down
1 change: 1 addition & 0 deletions docs/problems/security-threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,3 +467,4 @@ Issue [#1685](https://github.com/fullsend-ai/fullsend/issues/1685) explores usin
6. **Immutable agent policy** — agent rules cannot be modified through the channels agents operate on
7. **No agent self-modification** — agents cannot change their own configuration, permissions, or system prompts
8. **Verify, don't trust** — system state must be checked independently of agent self-reports (see [agent self-report unreliability](#cross-cutting-concern-agent-self-report-unreliability))
9. **Telemetry content boundary** — fullsend's content-handling guarantees (redaction at assembly, size bounds, opt-in gating) apply to its own extraction and redaction pipeline only; the agent runtime's native OTel instrumentation inside the sandbox is out of scope, like any other in-sandbox capability
Loading
Loading