Local trace capture
local_capture mirrors every span into an in-process, trace_id-keyed store as
it ends, so a predictor can read a complete trace synchronously instead of
polling Langfuse. It exists to remove a structural timing race: fetch_trace
polls an external system that ingests asynchronously, and its readiness check
(at least one observation) can accept a partially-ingested trace
before Langfuse has finished processing a turn with many spans. Local capture
sidesteps the race entirely — the trace is complete the instant the turn's
OTel spans have all ended, in the same process that created them.
Enabling it
from semantic_tracing import setup_tracing
setup_tracing(
service_name="my_workflow",
capture_local_traces=True,
local_trace_cache_size=500, # default; LRU eviction beyond this many traces
)
Default is False — opt in per pod, typically only in an eval or local-dev
configuration where a predictor needs to read back what just happened. There
is no meaningful use for local capture without an active predictor reading
from it, so leave it off in general production traffic.
Reading a trace
Same process — call get_local_trace() directly:
from semantic_tracing import get_local_trace
trace = get_local_trace(trace_id) # None if absent or capture was never enabled
Separate process (the common evaluation shape — a predictor process calling
a running pod over HTTP) — the pod exposes a debug-only endpoint that calls
get_local_trace() and returns .to_dict(), and the predictor fetches it:
from semantic_tracing import fetch_local_trace_via_http
trace = await fetch_local_trace_via_http(f"{base_url}/v1/debug/trace/{trace_id}")
fetch_local_trace_via_http only performs the GET and deserializes the JSON
body — the pod owns the endpoint path and its own trace-not-found handling
(typically a 404 when get_local_trace() returns None).
flowchart TD
subgraph Turn["One evaluated turn"]
Spans["@step / span() / event() and\nauto-instrumented framework spans"]
end
Spans -->|on_end| Proc["LocalTraceCaptureProcessor\n(in-process, trace_id-keyed, LRU)"]
Proc --> Same["get_local_trace(trace_id)\n(same process)"]
Proc --> Debug["pod debug endpoint\nGET /debug/trace/{trace_id}"]
Debug -->|fetch_local_trace_via_http| Predictor["Predictor process"]
Reading the result
A LocalTrace exposes .observations, matching the shape find_observations,
as_dict, and coerce_text already read from a Langfuse-fetched trace — so
existing pod-side extractors built on those helpers need no changes when fed a
LocalTrace instead:
from semantic_tracing import as_dict, coerce_text, find_observations
for obs in find_observations(trace, name_in={"read_policy"}):
args = as_dict(obs.input)
output = coerce_text(obs.output)
Each LocalObservation also carries .attributes (every raw OTel attribute on
the span), .start_time and .end_time (the span's OTel timestamps, in
nanoseconds since the epoch, from 0.7.0; None when read from a pod on an older
release), and .metadata (the observation's .update(metadata=...) dict,
reconstructed from Langfuse's flattened langfuse.observation.metadata.<key>
attributes, whose values are JSON-encoded and decoded back to the original
values). This means fields stamped by
link_app_insights_to_langfuse
— app_insights.request_span_id, app_insights.query_url — are present on
.metadata with no extra plumbing.
What gets captured, and from where
Every span on the global TracerProvider is captured generically: @step,
span(), and event() observations, and any auto-instrumented framework spans
that read the same provider (e.g. Microsoft Agent Framework). There is no
per-framework special-casing — input/output are resolved from whichever
attribute convention is present on the span:
| Source | Input/output attributes |
|---|---|
@step / span() / event() |
langfuse.observation.input / .output (or the bare langfuse.input / .output variant) |
| Auto-instrumented (e.g. Agent Framework) | gen_ai.input.messages / gen_ai.output.messages |
Both conventions are the same ones read today by Langfuse's own span exporter
and by FilteringExporter._is_empty().
Interaction with existing mechanisms
| Mechanism | Effect on local capture |
|---|---|
Span-name filtering (drop_span_names / drop_span_name_prefixes) |
Respected. A span matching either list is skipped here as well as excluded from Langfuse, so a span named as noise does not have to be filtered a second time when reading a prediction out of the local trace. |
sample_rate / drop_empty_observations |
No effect. Both run in FilteringExporter at batch-export time, strictly downstream of on_end(). A trace excluded from Langfuse by sample_rate is still fully present in local capture. |
capture_sensitive_data |
Inherited, not introduced. Agent Framework attaches prompt/tool-call bodies to spans only when this is enabled — local capture mirrors whatever the span carries, same as a Langfuse-fetched trace would. |
| App Insights / Langfuse dual-provider split | No conflict. The capture processor attaches to the same global (Langfuse-owned) TracerProvider that setup_tracing() promotes specifically so auto-instrumented frameworks converge there. The separate App Insights provider carries only HTTP/infra spans, none of which are eval-relevant. |
propagate(langfuse_export=False) |
Respected. Spans stamped langfuse.suppress_export by SuppressionProcessor are skipped — that scope was explicitly excluded from observability, not just from Langfuse specifically. |
Bounded memory
Captured traces are held in an LRU store keyed by trace_id, evicting the
least-recently-touched trace once local_trace_cache_size is exceeded (default
500). A trace that receives a new span moves to the most-recently-used
position, so a long-running turn is never evicted mid-flight by unrelated
traffic.