Observability and Logfire¶
Agent runs emit OpenTelemetry spans through the same ObservabilityRuntime the
rest of loom uses. This page covers the packaged Logfire setup, the one
configuration rule that makes it work, and — with no varnish — what the result
does not give you today.
The logfire extra¶
pip install "loom-kernel[logfire]"
Logfire is an OpenTelemetry distribution, not a
second telemetry stack. It ships an OTel SDK, an exporter and a large set of
instrumentations, and — this is the part that matters — logfire.configure()
installs a global TracerProvider.
Bootstrap¶
Two lines, and one configuration rule that ties them together.
# main.py — before create_app(), before anything emits a span
import logfire
logfire.configure(send_to_logfire=False) # installs the global TracerProvider
logfire.instrument_pydantic_ai() # LLM calls and tool calls
logfire.instrument_fastapi(app) # HTTP handlers
# config/api.yaml
app:
observability:
otel:
enabled: true
config:
service_name: incidents-api
endpoint: "" # ← empty ON PURPOSE: share Logfire's provider
send_to_logfire=False keeps the data out of Logfire’s SaaS and leaves you with
a plain, locally-configured OTel pipeline — point it wherever you already send
traces. Drop the argument if you do want Logfire’s backend.
```{admonition} endpoint and Logfire are mutually exclusive
:class: important
OtelConfig.endpoint decides whether loom builds a tracer provider of its
own:
endpoint: ""— loom callstrace.get_tracer(...)and therefore uses the global provider. When Logfire installed one, loom shares it. This is the setting that makes the integration work.endpoint: "https://collector…"— loom constructs its ownTracerProviderwith its ownBatchSpanProcessorand its own exporter.
Set both and you get two providers in one process. The trace itself stays intact — the OTel context is global even though the provider is not, so the spans still nest and still share one trace id — but each provider exports only its own spans, so loom’s half lands in loom’s collector and Logfire’s half in Logfire’s. Nothing raises; you simply have to query two backends to see one trace.
Pick one, unless both exporters point at the same collector. Using Logfire
(or any other OTel distribution that configures the global provider) means
endpoint stays empty and that distribution owns the export. Configuring
endpoint means loom owns the export.
Without Logfire nothing changes: leave `endpoint` set and loom builds and
exports its own provider exactly as it always has.
## What you actually get today
This is the honest part, and it is the reason this section exists rather than a
screenshot.
**What works.** Loom's lifecycle spans and Logfire's instrumented spans (the LLM
call, the tool calls, the FastAPI handler) end up in the same backend and
**share a trace id**. You can retrieve everything belonging to one request, and
correlate a slow agent run with the model call inside it.
Spans **nest**: loom opens each of its spans as the *current* span for the
duration of the work it covers, so an LLM call instrumented by Logfire hangs
off the agent run that made it, and a tool span hangs off the same run. A
waterfall view shows one tree.
The same holds in the other direction, and across pillars: a loom span opened
inside a Logfire-instrumented FastAPI handler is a child of that handler's
span, and ETL's `PIPELINE` / `PROCESS` / `STEP` and streaming's `POLL_CYCLE` /
`NODE` spans form trees of their own.
```{admonition} One exception: parallel ETL groups
:class: warning
Processes and steps inside a `ParallelProcessGroup` or `ParallelStepGroup` are
submitted to a thread pool without copying the OTel context, so their spans
are roots rather than children of the pipeline span. Sequential runs are
unaffected.
Sharing a provider gives a shared trace id; nesting comes from the shared
active context, which is global whether or not the provider is. That is why
loom never calls set_tracer_provider: it takes ownership of nothing, and
still parents correctly in both directions.
What the spans carry¶
Scope |
Emitted for |
|---|---|
|
one capability call — a use case, a SQL query, an MCP tool, a remote agent |
A Scope.TOOL span of an MCP call names the side that reached the
server, and only one of the two:
Attribute |
Present when |
|---|---|
|
the call came from an agent — its own capability, or an |
|
the call came from a use case’s |
They never appear together. Anything that groups Scope.TOOL spans by
agent alone will not see marker calls.
The agent span of a run — streamed or not — closes with what the run spent, under the OpenTelemetry GenAI names:
Attribute |
Meaning |
|---|---|
|
input tokens, cached ones included |
|
output tokens |
|
input tokens served from the prompt cache |
|
input tokens written to the prompt cache |
|
model round trips |
|
tool invocations the model completed |
|
run cost, absent when the engine could not price the model |
|
every other counter the engine reported, under its own name |
They land on the closing event, not the opening one: nothing is spent when a
run starts. gen_ai.usage.cost is missing rather than zero for a model with no
price entry — a Bedrock inference profile, say — because a zero would win a
cost comparison the model never entered. gen_ai.usage.cost_known is always
present, so SUM(cost) WHERE cost_known is a complete total and
WHERE NOT cost_known counts what the total is missing; summing the cost
without checking it gives a lower bound that looks like a total.
Failed runs are in the number. A run that made three model round trips and
then failed its output schema, was refused by a capability, or lost its output
hook still publishes what it burned: a model that fails often must not rank
better on cost than one that answers. The same attributes ride the ERROR
closing event.
Two gaps, stated rather than hidden. A run killed by its own
run_timeout_ms, tool_timeout_ms or max_iterations publishes no usage:
the supervisor terminates the run from outside the engine, and the counters of
the attempt it cancelled never leave it. And a caller that abandons a stream
mid-run closes the span without a terminal event, so that run reports nothing
either. Both cases are absences, never zeros — gen_ai.usage.requests is
simply not there.
Attributes are chosen so a trace can be shared without leaking a deployment:
an MCP span carries the server host, never the full URL and never the
resolved headers. The same containment applies everywhere the compiled plan is
rendered — an InferenceTarget redacts its credentials_ref and options in
repr, and refuses to serialise at all when it carries one.
Health¶
Each mounted agent serves GET /agents/{name}/health.
{ "status": "ok" }
Unauthenticated callers get the aggregate only — dependency identifiers are internal topology, and a health endpoint is not an inventory of your services. Authenticated callers get the breakdown:
{ "status": "ok", "checks": { "model": "ok", "mcp:runbooks": "ok" } }
status is ok, degraded or unavailable; the endpoint returns 503 when
unavailable. The value is cached and refreshed by a background probe — a
liveness probe that amplified into network I/O on every scrape would be a
self-inflicted outage under a Kubernetes readiness loop.
Note
The per-dependency breakdown is currently less informative than it looks:
several entries are fixed at start-up rather than re-checked by the probe, so
"model" is the entry to trust. Treat the aggregate status as the
contract and the breakdown as a hint until the probe is completed.