Traces
The TraceEvent stream, OTel GenAI attribute conventions, and the BigQuery and OTLP sinks.
StableImplemented, specified, and covered by tests in the repository.
.traces carries TraceEvent records — spans for the activation and for each
model call, tool call, and staged intent inside it. Attributes follow the
OpenTelemetry GenAI semantic conventions, and trace_id/span_id use the W3C
trace-context wire sizes so an exporter passes them through untranslated.
The shape of one trace
A trace is scoped to (entity_key, seq), not to a single process() call. A
suspension and its later resume run under the same seq, so the resume
recomputes the same trace_id with nothing carried on the wire, and its
activation span hangs under the first attempt's rather than starting a second
trace.
What a failed activation leaves behind
Traces show the atomic-commit rule from the outside: a committed activation
emits its whole span set, while a failed one emits a single ERROR event,
because the spans it staged are discarded along with its other effects.
async def route_by_event(ctx: ActivationContext) -> Complete:
"""Take a different path per event so one pipeline exercises each output."""
if ctx.event == b"BROKEN":
# Routed to `.errors` as `activation_error`. Nothing this activation
# staged — including this memory write — reaches durable state.
ctx.memory.set("scratch", b"never-persisted")
raise RuntimeError("downstream schema changed")
if ctx.event == b"NOTIFY":
ctx.act("slack.post", '{"channel": "#ops"}', ttl_ms=INTENT_TTL_MS)
return Complete(output=b"notified")
response = await ctx.call_model(
LlmRequest(
model_id="fake-1",
messages=[ctx.event.decode()],
tools_schema=None,
sampling_params=None,
)
)
return Complete(output=response.response)The otlp:// sink is valid only for traces_to. It is a best-effort tap that
drops on delivery failure by contract, and intents and errors are
correctness-bearing streams that need a lossless sink.
Rendered from docs/traces.md in the repository. This page and that file are the same text — there is no second copy to fall out of date.
RunAgent exposes every trace event on its .traces tagged output — a
PCollection[TraceEvent] you can consume yourself — and AgentConfig.traces_to
ships it to a sink for you:
config = AgentConfig(
provider_factory=make_client,
traces_to="otlp://collector:4318", # or kafka://, pubsub://, bigquery://
)
| Scheme | What the sink receives | Delivery |
|---|---|---|
kafka://<brokers>/<topic> | deterministic proto bytes, keyed by entity_key | lossless (at-least-once) |
pubsub://<project>/<topic> | deterministic proto bytes, keyed by entity_key | lossless (at-least-once) |
bigquery://<project>/<dataset>/<table> | flat rows (see layout below) | lossless (at-least-once) |
otlp://<host>[:<port>][?opts] | OTLP/HTTP protobuf spans | best-effort |
Trace events carry deterministic identity (trace_id/span_id are pure
functions of activation scope), so at-least-once duplicates from bundle retries
collapse exactly under downstream dedup on (trace_id, span_id, event_type).
Consuming .traces downstream
A kafka:///pubsub:// traces topic carries deterministic TraceEvent bytes,
so an ordinary Beam pipeline can consume it with nothing but the published
proto bindings — no runtime imports, no adapter.
continuous_eval.md is a worked example: it joins exported
traces with lagging business outcomes in a deadline-bounded stateful DoFn,
scores each joined record with an LLM-as-judge through the LLMClient seam,
and emits per-scenario quality metrics. Its code is held verbatim by
tests/examples/test_continuous_eval.py, which runs it offline against bytes
from this page's own encoder.
The OTLP exporter (otlp://)
Sends spans to any OTLP/HTTP collector (an OTel Collector, Jaeger, Tempo,
Cloud Trace via a collector, ...) at the standard /v1/traces endpoint.
Requires the otlp extra:
pip install 'beam-agents[otlp]'
URI options, all optional:
| Option | Default | Meaning |
|---|---|---|
tls=true | false (http) | POST over https |
batch_size | 512 | spans per export request |
flush_deadline_s | 5 | max wait at each bundle boundary, and each batch's total retry budget |
queue_batches | 8 | bounded hand-off queue between the pipeline and the sender thread |
service_name | beam-agents | the OTLP resource's service.name |
The port defaults to 4318; the URI carries no path (/v1/traces is implied).
Example: otlp://collector:4318?tls=true&service_name=fraud-triage.
The delivery contract: lossy by design
OTLP export must never make telemetry a source of pipeline unavailability, so its failure mode is drop and count, never block, never raise:
- The element path does no network I/O. Spans are batched and handed to one background sender thread through a bounded queue; a full queue (the collector is slower than the pipeline) drops the batch rather than backpressuring.
- A failed POST is retried with backoff only inside
flush_deadline_s, then dropped. A non-retryable response (4xx other than 429) drops immediately. - Each bundle boundary flushes, waiting at most
flush_deadline_s; whatever cannot drain is dropped and counted. A dead collector costs dropped telemetry — bundles keep committing.
Loss is visible in Beam counters under the beam_agents.otlp namespace:
spans_exported, spans_dropped, export_failures, batches_sent. If you
need lossless trace retention, point traces_to at Kafka/Pub/Sub/BigQuery
instead (or alongside, by consuming .traces yourself).
Mapping notes
- IDs pass through byte-for-byte:
TraceEventalready uses OTel wire widths (16-byte trace, 8-byte span IDs). - Span names are the lowercase event-type name (
llm_call,tool_call,intent_emitted,suspended,error,activation_end);ERRORevents get OTLP error status. Attributes map to string key/values. ACTIVATION_STARTis not exported. It shares its span ID withACTIVATION_END(they bracket one activation attempt) and OTLP names a span by(trace_id, span_id);ACTIVATION_ENDcarries strictly more (theactivation.statusoutcome alongside the sameactivation.kind), so it is the activation span. Both events remain on.tracesfor other consumers.- Spans are zero-width by design (both timestamps come from the activation
clock); latency lives in the
beam_agents.runtimemetrics (seedocs/metrics.md), not in trace bytes.
The BigQuery trace table (bigquery://)
A bigquery:// traces sink provisions its own table: the writer carries the
published schema (beam_agents.observability.exporters.TRACE_TABLE_SCHEMA),
CREATE_IF_NEEDED/WRITE_APPEND, day partitioning on event_time, and
clustering on trace_id — pointing traces_to at an empty dataset just
works.
| Column | Type | Notes |
|---|---|---|
trace_id | STRING | hex; one trace per (entity_key, seq); the cluster key |
span_id | STRING | hex |
parent_span_id | STRING | hex; empty for the trace root |
entity_key | STRING | hex |
seq | INT64 | per-key activation counter |
step_index | INT64 | |
event_type | STRING | the enum name (LLM_CALL, ERROR, ...) |
start_ms / end_ms | INT64 | epoch millis from the activation clock |
event_time | TIMESTAMP | start_ms as RFC 3339 UTC; the partition column |
attributes | REPEATED RECORD(key STRING, value STRING) | sorted by key |
Partitioning uses event_time (a derived TIMESTAMP) rather than ingestion
time so replays and backfills land in the partitions their events belong to.
A table created by hand before this writer existed needs the one nullable
event_time column added; everything else is unchanged.
Example — token spend by day, cache hits separated from billed calls:
SELECT DATE(event_time) AS day,
(SELECT value FROM UNNEST(attributes) WHERE key = 'beam_agents.billed') AS billed,
SUM(CAST((SELECT value FROM UNNEST(attributes)
WHERE key = 'gen_ai.usage.input_tokens') AS INT64)) AS input_tokens
FROM `my-project.my_dataset.traces`
WHERE event_type = 'LLM_CALL'
GROUP BY day, billedRelated
- The four outputs — the program embedded above, and
the failed activation whose span set collapses to one
ERROR. - Metrics — the counters recorded on the same path.
- The errors output — the dead letter a failed activation emits
alongside that
ERRORtrace.
What backs this page
- Symbol
- beam_agents.observability.ActivationTrace
- Source
- src/beam_agents/observability/traces.py
- Source
- src/beam_agents/observability/otlp.py
- Source
- src/beam_agents/core/loop.py
- Source
- src/beam_agents/core/dofn.py
- Source
- docs/traces.md
- Specification
- openspec/specs/wire-schemas/spec.md
- Test
- tests/observability/test_activation_trace.py
- Test
- tests/observability/test_otlp.py
- Example
- four_outputs.py