Skip to content
beam-agents
GitHub

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.

One activation's span treeA trace is identified by the entity key and the activation sequence number. Its root is the activation span, which carries both the ACTIVATION_START and the ACTIVATION_END event because the two bracket one attempt and share a span id. Hanging under that root are the first attempt's child spans: LLM_CALL, one per model call whether it reached the provider or hit the replay cache; TOOL_CALL, one per inline read-only tool; INTENT_EMITTED, one per staged ToolIntent; and SUSPENDED, which records the deadline and the pending intent ids. The resume attempt gets its own activation span nested under the first attempt's, rather than starting a second trace, with its own children such as a further LLM_CALL made once the tool result arrives. The whole set is staged during the activation and emitted on the .traces PCollection at commit, from where AgentConfig.traces_to ships it to one sink: otlp:// over OTLP/HTTP, which is best-effort; or bigquery:// as flat rows, kafka:// as deterministic proto bytes, or pubsub:// as deterministic proto bytes, all three of which are lossless at-least-once. ACTIVATION_START alone is not exported over OTLP, because it shares its span id with ACTIVATION_END and OTLP names a span by trace id and span id.ONE TRACE PER (ENTITY_KEY, SEQ)activation spanACTIVATION_START + ACTIVATION_ENDLLM_CALLONE PER MODEL CALL, HIT OR MISSTOOL_CALLINLINE READ-ONLY TOOLINTENT_EMITTEDONE PER STAGED TOOLINTENTSUSPENDEDDEADLINE + PENDING INTENT IDSactivation spanTHE RESUME, UNDER THE ROOTLLM_CALLAFTER THE RESULT ARRIVESSTAGED DURING THE ACTIVATION, EMITTED AT COMMIT.tracesPCOLLECTIONotlp://OTLP/HTTP · BEST-EFFORTbigquery://FLAT ROWS · LOSSLESSkafka://PROTO BYTES · LOSSLESSpubsub://PROTO BYTES · LOSSLESSACTIVATION_START IS NOTEXPORTED OVER OTLP
Every child event's parent is the activation span of the attempt it happened in, and a resume's activation span is a child of the first attempt's — so one suspended-and-resumed activation is a single two-level trace. ACTIVATION_START stays off the OTLP wire because ACTIVATION_END carries strictly more on the same span id; both remain on .traces for every other consumer.

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.

Committed and failed activations on the traces streamOne activation forks into two possible outcomes. If it commits, the span set it staged is emitted whole and the entire set reaches the .traces output. If it raises part-way through, the staged spans are discarded and never emitted; instead a FailureContext holding only position scalars — the step index, the name of the last staged event, and counts of staged intents and model calls — is built directly from the failure. That context produces two records: one ERROR event on .traces, and one activation_error dead letter on .errors. Nothing leaves the discarded box, because a failed activation's effects stay discarded even for telemetry.one activationTWO POSSIBLE OUTCOMESit commitsATOMIC WITH STATEstaged spansEMITTED, ALL OF THEM.tracesTHE WHOLE SPAN SETit raisesPART-WAY THROUGHstaged spansDISCARDEDFailureContextPOSITION ONLY.tracesONE ERROR.errorsACTIVATION_ERRORTHE SPANS A FAILED ACTIVATION STAGED GO WITH ITS OTHER EFFECTSITS ERROR RECORD IS SYNTHESIZED FROM POSITION, NOT FROM THEM
The spans a failed activation staged go with everything else it staged, so a query counting LLM_CALL events is counting committed model calls only. Its single ERROR event is synthesized from position scalars — step index, last staged event name, counts — never from the rolled-back 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)
website/examples/four_outputs.py (region: agent) — executed by the repository’s offline test tier.

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://
)
SchemeWhat the sink receivesDelivery
kafka://<brokers>/<topic>deterministic proto bytes, keyed by entity_keylossless (at-least-once)
pubsub://<project>/<topic>deterministic proto bytes, keyed by entity_keylossless (at-least-once)
bigquery://<project>/<dataset>/<table>flat rows (see layout below)lossless (at-least-once)
otlp://<host>[:<port>][?opts]OTLP/HTTP protobuf spansbest-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:

OptionDefaultMeaning
tls=truefalse (http)POST over https
batch_size512spans per export request
flush_deadline_s5max wait at each bundle boundary, and each batch's total retry budget
queue_batches8bounded hand-off queue between the pipeline and the sender thread
service_namebeam-agentsthe 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: TraceEvent already 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); ERROR events get OTLP error status. Attributes map to string key/values.
  • ACTIVATION_START is not exported. It shares its span ID with ACTIVATION_END (they bracket one activation attempt) and OTLP names a span by (trace_id, span_id); ACTIVATION_END carries strictly more (the activation.status outcome alongside the same activation.kind), so it is the activation span. Both events remain on .traces for other consumers.
  • Spans are zero-width by design (both timestamps come from the activation clock); latency lives in the beam_agents.runtime metrics (see docs/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.

ColumnTypeNotes
trace_idSTRINGhex; one trace per (entity_key, seq); the cluster key
span_idSTRINGhex
parent_span_idSTRINGhex; empty for the trace root
entity_keySTRINGhex
seqINT64per-key activation counter
step_indexINT64
event_typeSTRINGthe enum name (LLM_CALL, ERROR, ...)
start_ms / end_msINT64epoch millis from the activation clock
event_timeTIMESTAMPstart_ms as RFC 3339 UTC; the partition column
attributesREPEATED 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, billed
  • 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 ERROR trace.

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