Telemetry
Telemetry tells you what an agent did and how long it took: every run,
turn, model call and tool call opens a timed span, and token counts,
cost and durations are emitted as metrics. Pass a telemetry sink to
Agent(telemetry=...) to see them. Loomflow ships six: five that need
no collector (including the no-op default and a fan-out helper), and
OTelTelemetry, which sends everything to any OpenTelemetry-compatible
backend when you’re ready for production.
from loomflow import Agent
from loomflow.observability import ConsoleTelemetry
agent = Agent(
"...",
model="claude-opus-4-7",
telemetry=ConsoleTelemetry(),
)Run the agent and span lines print to stderr as they happen. No collector, no SDK, no extras to install.
To browse runs in a web page, with the messages each model call sent and each tool call’s arguments and result, turn on Studio: it records alongside whatever telemetry you configure here.
The five sinks that need no collector
| Sink | What it does | Best for |
|---|---|---|
NoTelemetry | No-op default. Yields empty spans and drops metrics at zero overhead. | Production paths with telemetry off |
InMemoryTelemetry | Accumulates spans + metrics in lists. Inspect via .spans() / .metrics(). | Unit tests, exploration |
ConsoleTelemetry | Prints each span + metric to a stream (sys.stderr by default) with nested-trace indentation. | ”Tail my agent in dev” |
FileTelemetry | Append-only JSONL on disk. One JSON record per span / metric. Parseable by jq, Splunk, Datadog log pipelines. | Long-running dev / staging, post-mortems |
MultiTelemetry | Fan-out. Forwards every span + metric to every sink in declaration order. Trace IDs are shared. | Watch live AND inspect after |
from loomflow.observability import (
NoTelemetry,
InMemoryTelemetry,
ConsoleTelemetry,
FileTelemetry,
MultiTelemetry,
)All five implement the same Telemetry protocol as OTelTelemetry,
so swapping sinks never touches agent code.
Spans
| Span | Opens | Attributes |
|---|---|---|
loom.run | each agent.run() / agent.stream() | session_id, max_turns, model, architecture, plus gen_ai.* (invoke_agent) |
loom.turn | each iteration of the loop | turn, session_id |
loom.model.complete | each model call from agent.run() | model, turn, session_id, tool_count, plus gen_ai.* |
loom.model.stream | each model call from agent.stream() | same as loom.model.complete |
loom.tool | each tool call | tool, call_id, turn, loom.permission (allow, deny, ask), loom.approval (approved, declined) after an ask, plus gen_ai.* |
loom.workflow.step | each step of a Workflow | step, user_id, session_id, pattern |
These are the names the in-memory, console and file sinks record.
OTelTelemetry exports the GenAI spans under their OpenTelemetry
spec names instead: model calls as chat {model} (kind CLIENT),
tool calls as execute_tool {tool}, and the run as invoke_agent.
The loomflow name moves to the loom.span.name attribute, so a query
on loom.span.name = "loom.model.complete" still finds them.
loom.turn and loom.workflow.step keep their names. See
GenAI semantic conventions.
For multi-agent architectures, the worker’s spans nest under the supervisor’s tool call:
loom.run
├── loom.turn (turn=1)
│ ├── loom.model.complete
│ └── loom.tool (tool=delegate)
│ └── loom.run (the worker)
│ └── loom.turn ... (recursive)
└── loom.turn (turn=2)
└── loom.model.completeEvery sink preserves the parent_span_id link, including
FileTelemetry’s JSONL output, so you can rebuild this tree offline.
Metrics
| Metric | Type | Unit | Tags |
|---|---|---|---|
loom.tokens.input | counter | {token} | model |
loom.tokens.output | counter | {token} | model |
loom.cost.usd | counter | model (only emitted when the call has a cost) | |
loom.tool.duration_ms | histogram | ms | tool, ok, denied |
loom.session.duration_ms | histogram | ms | interrupted |
loom.budget.exceeded | counter | reason | |
loom.auto_extract.duration_ms | histogram | ms | status |
loom.auto_extract.invocations | counter | status |
Each model and tool call also emits the OpenTelemetry GenAI client metrics, with the spec’s units and bucket boundaries:
| Metric | Type | Unit | Tags |
|---|---|---|---|
gen_ai.client.inference.duration | histogram | s | gen_ai.operation.name, gen_ai.provider.name, gen_ai.request.model |
gen_ai.client.inference.usage.input_tokens | counter | {token} | the same, plus gen_ai.token.modality |
gen_ai.client.inference.usage.output_tokens | counter | {token} | the same, plus gen_ai.token.modality |
gen_ai.client.inference.usage.cache_read.input_tokens | counter | {token} | as above (only when the call read the cache) |
gen_ai.client.inference.usage.cache_write.input_tokens | counter | {token} | as above (only when the call wrote the cache) |
gen_ai.client.inference.operation.input_tokens | histogram | {token} | gen_ai.operation.name, gen_ai.provider.name, gen_ai.request.model |
gen_ai.client.inference.operation.output_tokens | histogram | {token} | the same |
gen_ai.execute_tool.duration | histogram | s | gen_ai.operation.name, gen_ai.tool.name, error.type on failure |
The gen_ai.* input-token counts include cached tokens; the
loom.tokens.input count doesn’t.
Metrics carry no session_id, turns or user_id tags: one series
per session or per user is unbounded cardinality. session_id stays
on the agent spans; user_id is on the workflow step spans and in
every audit entry.
The gen_ai.* instruments have fixed types. For the rest, the type
follows the metric name: names ending in _ms, _seconds or _bytes
become histograms (with unit ms, s or By), loom.tokens.* are
token counters, and everything else is a counter. There is one
emit_metric() call, and the in-memory, console and file sinks tag
each captured metric with the same instrument_kind, so test
assertions match what OTLP would have emitted.
NoTelemetry (the default)
from loomflow import Agent, NoTelemetry
agent = Agent("...", model="claude-opus-4-7", telemetry=NoTelemetry()) # explicit
agent = Agent("...", model="claude-opus-4-7") # implicit (same)NoTelemetry makes every span / metric call a no-op. The agent loop
detects this at construction and skips the async with telemetry.trace(...)
context managers entirely on the hot path.
Worked example: inspect spans and metrics in-process
The simplest way to see what an agent emits is InMemoryTelemetry.
There’s no collector, SDK or log file; you get the captured records
as plain dataclasses:
import asyncio
from loomflow import Agent, ScriptedModel, ScriptedTurn, ToolCall, tool
from loomflow.observability import InMemoryTelemetry
@tool
async def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
async def main():
telemetry = InMemoryTelemetry()
# ScriptedModel keeps the run deterministic, with no network call
model = ScriptedModel([
ScriptedTurn(tool_calls=[
ToolCall(id="c1", tool="add", args={"a": 2, "b": 3})
]),
ScriptedTurn(text="The sum is 5."),
])
agent = Agent(
"Arithmetic assistant.",
model=model,
tools=[add],
telemetry=telemetry,
)
await agent.run("What is 2 + 3?", user_id="alice")
# Spans carry timing + parent-span linkage
for s in telemetry.spans():
attrs = ", ".join(f"{k}={v}" for k, v in s.attributes.items())
print(f"{s.name:<22} ({s.duration_ms:5.1f}ms) [{attrs}]")
# Metrics carry their auto-detected instrument kind
for m in telemetry.metrics():
print(f"{m.name:<28} {m.value:<8} ({m.instrument_kind})")
asyncio.run(main())Output (trimmed; spans are recorded as they close, so children come before their parents):
loom.model.complete ( 0.0ms) [model=scripted, turn=1, session_id=sess_..., tool_count=1, gen_ai.operation.name=chat, ...]
loom.tool ( 0.1ms) [tool=add, call_id=c1, turn=1, gen_ai.operation.name=execute_tool, ...]
loom.turn ( 0.3ms) [turn=1, session_id=sess_...]
loom.model.complete ( 0.0ms) [model=scripted, turn=2, session_id=sess_..., tool_count=1, ...]
loom.turn ( 0.0ms) [turn=2, session_id=sess_...]
loom.run ( 0.8ms) [session_id=sess_..., max_turns=50, model=scripted, architecture=react, gen_ai.operation.name=invoke_agent, ...]
loom.tokens.input 0 (counter)
loom.tokens.output 0 (counter)
gen_ai.client.inference.duration 4.1e-05 (histogram)
gen_ai.client.inference.usage.input_tokens 0 (counter)
...
loom.tool.duration_ms 0.05 (histogram)
gen_ai.execute_tool.duration 5.3e-05 (histogram)
loom.tokens.input 0 (counter)
...
loom.session.duration_ms 0.68 (histogram)Token counts are 0 because these ScriptedTurns don’t set usage=;
a real model reports its own.
CapturedSpan carries name, trace_id, span_id,
parent_span_id, started_at, ended_at, duration_ms,
attributes, and an exception repr if the body raised.
CapturedMetric carries name, value, instrument_kind,
attributes, and emitted_at. Both are frozen dataclasses. Assert
on them directly in tests.
Tail in stderr with ConsoleTelemetry
Swap the sink to see spans appear live as the agent runs, with nested-trace indentation:
import sys
from loomflow.observability import ConsoleTelemetry
agent = Agent(
"...",
model="claude-opus-4-7",
telemetry=ConsoleTelemetry(stream=sys.stderr, show_metrics=False),
)Each span prints when it closes, indented under its parent (attributes trimmed here):
• loom.model.complete (0.0ms) model=scripted, turn=1, session_id=sess_..., tool_count=1, ...
• loom.tool (0.1ms) tool=add, call_id=c1, turn=1, ...
• loom.turn (0.5ms) turn=1, session_id=sess_...
• loom.model.complete (0.0ms) model=scripted, turn=2, ...
• loom.turn (0.1ms) turn=2, session_id=sess_...
• loom.run (1.0ms) session_id=sess_..., max_turns=50, model=scripted, architecture=reactIndentation follows the active parent span automatically, even when
spans open inside anyio.create_task_group() (the framework’s
parallel tool-dispatch path).
Write JSONL with FileTelemetry
from loomflow.observability import FileTelemetry
agent = Agent(
"...",
model="claude-opus-4-7",
telemetry=FileTelemetry("./traces.jsonl"),
)Each line is a structured JSON record with "kind": "span" or
"kind": "metric" discriminator. Span records carry the
parent_span_id linkage needed to reconstruct the trace tree
offline. Writes go through anyio.to_thread.run_sync so the event
loop never blocks on disk I/O; an internal lock serialises
concurrent writes from parallel tool dispatches.
Query offline with jq:
# Spans that took longer than 1s
jq -c 'select(.kind=="span" and .duration_ms > 1000)' traces.jsonl
# One session
jq -c 'select(.attributes.session_id=="sess_...")' traces.jsonl
# All cost metrics
jq -c 'select(.kind=="metric" and .name=="loom.cost.usd")' traces.jsonlThere’s no built-in rotation. Use logrotate, journald or your
platform’s log management to cap file size. The framework deliberately stays
out of that policy decision.
FileTelemetry ≠ FileAuditLog. They capture different things: audit log = business events for compliance (“did Alice’s refund go through?”); telemetry = performance / diagnostic spans (“why was this run slow?”). Run both together in production.
Fan out with MultiTelemetry
Watch live AND assert in tests by stacking sinks:
from loomflow import Agent, ScriptedModel, ScriptedTurn, ToolCall, tool
from loomflow.observability import (
ConsoleTelemetry, InMemoryTelemetry, MultiTelemetry,
)
@tool
async def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
model = ScriptedModel([
ScriptedTurn(tool_calls=[ToolCall(id="c1", tool="add", args={"a": 2, "b": 3})]),
ScriptedTurn(text="The sum is 5."),
])
in_mem = InMemoryTelemetry()
telemetry = MultiTelemetry([ConsoleTelemetry(), in_mem])
agent = Agent("Arithmetic assistant.", model=model, tools=[add], telemetry=telemetry)
await agent.run("What is 2 + 3?")
# Watched live in stderr, and still assertable
assert any(s.name == "loom.tool" for s in in_mem.spans())Span IDs are minted by the first sink and shared with the others, so
trace hierarchy stays consistent across every capture. Exceptions
inside one sink’s trace propagate after every other sink has had a
chance to record its cleanup (finally blocks fire even on
exceptional exit thanks to AsyncExitStack).
The framework ships examples/13_telemetry.py
running InMemoryTelemetry, ConsoleTelemetry, MultiTelemetry and
FileTelemetry end-to-end against a ScriptedModel. No API key
required.
Production: OTelTelemetry
For Honeycomb / Datadog / Grafana Tempo / Jaeger / any OTLP-compatible
backend, wire the OpenTelemetry SDK and pass it to OTelTelemetry:
from loomflow.observability import OTelTelemetry
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
tracer_provider = TracerProvider()
tracer_provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint="https://otlp.your-vendor"))
)
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint="https://otlp.your-vendor")
)
meter_provider = MeterProvider(metric_readers=[metric_reader])
telemetry = OTelTelemetry(
tracer_provider=tracer_provider,
meter_provider=meter_provider,
)
agent = Agent("...", model="claude-opus-4-7", telemetry=telemetry)Install the OTel extras:
pip install 'loomflow[otel]'The adapter is the same Telemetry protocol. Swapping sinks never
touches agent code. The one difference you’ll see in the backend is
the span names: model, tool and run spans arrive as chat {model},
execute_tool {tool} and invoke_agent, with the loom.* name in
the loom.span.name attribute. A failed or refused tool call sets
error.type and an ERROR status on its span.
Model and tool spans also carry the OTel GenAI semantic-convention
attributes (gen_ai.operation.name, gen_ai.usage.input_tokens,
…), so attribute-keyed backends like Langfuse and Datadog LLM
Observability recognise them without mapping config. See
GenAI semantic conventions.
Custom telemetry
Any class with two methods satisfies the Telemetry protocol: a
trace() async context manager that yields a span handle, and an
async emit_metric():
import time
from contextlib import asynccontextmanager
from loomflow import Agent
from loomflow.core.types import Span
class PrintTelemetry:
@asynccontextmanager
async def trace(self, name: str, **attrs):
start = time.perf_counter()
# Emit sites add attributes (token usage, for example) to the
# yielded span's ``attributes`` dict before it closes.
span = Span(name=name, trace_id="", span_id="", attributes=dict(attrs))
try:
yield span
finally:
print(f"{name} took {(time.perf_counter() - start) * 1000:.1f}ms")
async def emit_metric(self, name: str, value: float, **attrs):
print(f"metric {name}={value}")
agent = Agent("...", model="echo", telemetry=PrintTelemetry())
await agent.run("hi")Useful when you have a custom metric backend not covered by OTel
(e.g. a homegrown StatsD or Prometheus pushgateway). Wrap it in
MultiTelemetry alongside one of the built-in sinks to keep the
live-tail / file-on-disk affordances while still sending to your
backend.
Telemetry vs the audit log. Spans are for operational observability: latency, error rates, dependencies. The audit log is for compliance observability: who did what, with HMAC signatures. Different lifecycles, different retention, different consumers. Wire both; they don’t overlap.