Skip to content
beam-agents
GitHub

Runtime metrics

The counters and distributions RunAgent publishes, the two identities worth alerting on, and why there are no percentiles.

StableImplemented, specified, and covered by tests in the repository.

Metrics are published unconditionally — there is no knob to enable them — under the Beam user-metrics namespace beam_agents.runtime. They surface wherever your runner surfaces user metrics.

One thing to know before reading the tables: Beam distributions carry no percentiles. They report sum, count, min, and max, and Beam's histogram cell is marked internal-use-only, so a portable percentile is not available from this surface. Percentile work belongs to the trace sink.

Where each metric is recorded

The reference below is a list of names. What a name cannot tell you is when it moves — and for these counters that is most of the story, because nearly all of them are recorded in one step at commit, from counts accumulated somewhere a Beam metric update would have been thrown away.

Where the runtime metrics are recordedAn activation's path runs down the left: admission, then running the agent on the async bridge thread, then commit on the Beam worker thread. A separate rail bypasses that chain down the far left and ends at a dead-letter box, fed from admission and from the agent run, because a dead letter is an alternative outcome reached by a refusal, a failure, a timeout, or a timer rather than a step after commit. To the right of each stage is what it records. Admission records nothing: a refused resume never runs the agent, so there is no sample at all. Running the agent records activation_ms in the beam_agents.runtime namespace on every exit including failures, and soft_cap_warnings in the beam_agents.memory namespace when working memory passes its soft cap; everything else it counts accumulates in an ActivationTally, a plain object with no Beam involvement, because a metric update made off the Beam worker thread is silently discarded with no exception and no log. Commit records the rest of beam_agents.runtime in one step: the counters activations, suspensions, intents_emitted, llm_calls and tool_calls, and the distributions memory_bytes, iterations, tokens, llm_ms and overhead_ms. One further intents_emitted increment comes from a human-in-the-loop escalation, which mints an intent outside the commit path. The dead-letter site records agent_errors and orphaned_results in beam_agents.runtime, and every record on the errors output passes through that one chokepoint. Off the activation path entirely, the OTLP trace sender thread counts spans_exported, spans_dropped, export_failures and batches_sent in the beam_agents.otlp namespace, and finish_bundle records them on the Beam thread under the same rule.admissionFAIL-CLOSED CHECKrun the agentASYNC BRIDGE THREADcommitBEAM WORKER THREADdead-letterANY .errors RECORDREFUSED · FAILED · TIMED OUT,OR A TIMER FIREDno sampleA REFUSED RESUME NEVER RUNS THE AGENTbeam_agents.runtimeactivation_msON EVERY EXIT, INCLUDING FAILURESbeam_agents.memorysoft_cap_warningsWHEN WORKING MEMORY PASSES ITS SOFT CAPActivationTallyEVERYTHING ELSE WAITS FOR THE BEAM THREADA METRIC UPDATE OFF THE BEAM THREAD IS DISCARDEDWITH NO EXCEPTION AND NO LOGbeam_agents.runtimeCOUNTERSDISTRIBUTIONSactivationssuspensionsintents_emittedllm_callstool_callsmemory_bytesiterationstokensllm_msoverhead_msONE STEP, ON THE BEAM THREAD, AT COMMITPLUS intents_emitted FROM A HITL ESCALATIONbeam_agents.runtimeagent_errorsorphaned_resultsEVERY .errors RECORDPASSES THROUGH HEREbeam_agents.otlpTHE traces_to SENDER THREAD COUNTS,finish_bundle RECORDS ON THE BEAM THREADspans_exportedspans_droppedexport_failuresbatches_sent
Nearly every counter moves in a single step at commit, from counts accumulated in an ActivationTally during the run — because Beam resolves a metric cell through a thread-local state sampler, and an increment made on the async bridge thread is dropped with no exception and no log. Two namespaces sit outside beam_agents.runtime: beam_agents.memory for the working-memory soft cap, and beam_agents.otlp for trace delivery.

Rendered from docs/metrics.md in the repository. This page and that file are the same text — there is no second copy to fall out of date.

RunAgent publishes Beam user metrics under the namespace beam_agents.runtime. They surface wherever your runner surfaces user metrics: the Dataflow job page, the Flink metrics reporter, or PipelineResult.metrics() on the DirectRunner.

from apache_beam.metrics.metric import MetricResults, MetricsFilter

result = pipeline.run()
result.wait_until_finish()
query = result.metrics().query(MetricsFilter().with_namespace("beam_agents.runtime"))
{m.key.metric.name: m.result for m in query[MetricResults.COUNTERS]}

Nothing needs enabling: metrics are published unconditionally, with no configuration knob. (AgentConfig.tool_registry supplies the tools ctx.run_tool executes — it configures tool execution, not metrics.) Working memory's own beam_agents.memory/soft_cap_warnings counter is unchanged and keeps its separate namespace.

Counters

CounterIncremented
activationsOnce per activation that reached the commit path — a start or a resume. Same event as the SEQ increment.
llm_callsOnce per model call that reached the provider. A replay-cache hit is not a call.
tool_callsOnce per read-only tool executed inline via ctx.run_tool(...), on either activation surface. The tools come from AgentConfig.tool_registry.
intents_emittedOnce per ToolIntent put on .intents, including one minted by a HITL escalation.
agent_errorsOnce per .errors record that is not an orphaned result (activation_timeout, activation_error, budget_exceeded, hitl_timeout, ttl_wiped_suspension, ttl_wiped_batch, batch_buffer_overflow).
suspensionsOnce per committed activation whose outcome was Suspend.
orphaned_resultsOnce per .errors record with reason orphaned_result.
longterm_upsertsOnce per long-term memory row flushed through the MemoryStore in a committed activation's commit tail (docs/memory.md). A failed activation flushes nothing and a failed flush fails the activation, so this only counts durable writes on the committed path.
events_bufferedOnce per event appended to a key's adaptive-batching buffer (docs/batching.md). Zero under the default BatchPolicy.NONE.
batch_flushes_sizeOnce per committed flush that the max_batch_size threshold triggered.
batch_flushes_timerOnce per committed flush that the max_wait_ms FLUSH_TIMER triggered.

Three identities hold by construction, and are worth alerting on if they break:

  • intents_emitted equals the element count on .intents.
  • agent_errors + orphaned_results equals the element count on .errors.
  • batch_flushes_size + batch_flushes_timer equals the batch_size sample count, and each committed flush counts as exactly one activation.

Distributions

Beam distributions are integer-only and report sum, count, min, max — there are no percentiles. (Beam's histogram cell is marked internal-use-only, so a portable percentile is not available; percentile work belongs to the trace sink or the benchmark suite.)

DistributionOne sample per
activation_msAgent run, including failures and timeouts. Sample count is therefore activations + failed activations. A resume refused at admission never runs the agent and is not sampled.
overhead_msCommitted activation: its wall time minus its model-call and inline-tool time, clamped at zero. This is the release-gate figure (the budget excludes LLM/tool time). Sample count equals activations; a failed activation's tally does not escape, so failures contribute activation_ms only.
llm_msProvider-reached model call. Sample count equals llm_calls.
tokensCommitted activation whose provider usage was actually decoded. Activations that decoded no usage contribute no sample, so the count means "activations with known usage".
prompt_tokensThe same activations, summed input tokens. Same sampling rule as tokens, so the three counts move together.
completion_tokensThe same activations, summed output tokens. Input and output are priced differently by every provider, so the split — not the total — is what a price sheet multiplies.
memory_bytesCommitted activation: the working-memory size that was committed.
iterationsCommitted activation: the agent steps it consumed. A resume reports only its own steps.
batch_sizeCommitted batch flush: how many events it activated over. The mean is the batching ratio — how many events one activation (and one set of model calls) covered. No samples under BatchPolicy.NONE.

The cost distributions vs. the token budget

tokens/prompt_tokens/completion_tokens are billed: they are fed only by provider-reached calls, exactly as the model facade has always accumulated usage, so a replayed activation whose calls all came out of the replay cache records nothing at all.

AgentConfig.max_tokens_per_activation (docs/errors.md) meters something different on purpose. It charges every response the agent consumes, replay-cache hits included, because it is a decision rather than a measurement: the budget check is a branch taken upstream of every intent the activation mints, and provider-reached-ness is exactly the property a bundle retry does not preserve. Charging billed tokens would make a retried walk take a different branch than the original and break the byte-identical-intents guarantee.

So the two are allowed to disagree, and on a replayed walk they do: the budget charges N, these distributions record nothing. That disagreement is the replay cache working, not a bug in either.

activation_ms vs. overhead_ms

activation_ms is total wall time and includes provider and tool latency — useful for end-to-end latency questions, wrong for the release gate. The release-gating budget (p50 < 15 ms, p99 < 60 ms per activation) excludes LLM and tool time; overhead_ms publishes exactly that subtraction, so it is the distribution to alert on for the budget. It is clamped at zero: an agent that awaits calls concurrently can make summed call time exceed wall time. Beam distributions carry no percentiles (see above), so the p99 check itself is rendered by the benchmark suite (docs/benchmarks.md — its overhead_*ms tiers record the same subtraction per activation and its gate enforces the p50/p99 budget); overhead_ms's sum/count/max give the dashboard-level early warning.

What these numbers are, and are not

Attempted, not committed. Most runners report attempted metric values, so a bundle that fails and is retried re-applies its increments even though its state and outputs roll back. These counters are telemetry, not an accounting ledger: nothing in the runtime reads one back, and the authoritative record of what happened is .traces, .intents, and .errors. Effectively-once execution is the effector's intent_id dedup, not a counter.

No labels. Beam user metrics carry no dimensions, so there is no per-tool, per-model, or per-error-reason breakdown here. Those dimensions are on every TraceEvent (gen_ai.request.model, beam_agents.cache_hit, …) and on every ActivationError (reason), which is where dimensioned analysis belongs.

Cache-hit ratio is a trace question. llm_calls deliberately counts only provider-reached calls, which is what makes "a replayed bundle adds zero provider calls" visible on a dashboard. The hit/miss split lives on the LLM_CALL traces' beam_agents.cache_hit attribute.

A DirectRunner caveat for local runs

_AgentDoFn declares a REAL_TIME timer (the HITL deadline), which rules out Beam's FnApiRunner, so every RunAgent pipeline runs on the classic DirectRunner. Its metrics implementation reports one bundle's updates and drops the rest, so a local multi-bundle run (several TestStream groups, or a timer-fired bundle) under-reports. This is a runner artifact — a plain beam.ParDo counter shows the same thing — not a property of the metrics themselves. Dataflow and Flink aggregate normally.

What backs this page

Symbol
beam_agents.observability.metrics.ActivationTally
Source
src/beam_agents/observability/metrics.py
Source
src/beam_agents/observability/otlp.py
Source
src/beam_agents/memory/facade.py
Source
src/beam_agents/core/dofn.py
Source
docs/metrics.md
Specification
openspec/specs/wire-schemas/spec.md
Test
tests/observability/test_metrics.py
Test
tests/core/test_dofn_metrics.py