Skip to content

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.