Skip to content

Read-side helpers

The trace_reader submodule is the inverse of the write-side decorators: it pulls structured information back out of a finished Langfuse trace. It exists so every pod that evaluates a turn against its own trace shares one polling strategy and one set of JSON coercions, rather than each writing their own.

Why polling is necessary

OTel spans flushed by the running application reach Langfuse asynchronously. At the moment an HTTP turn returns, the trace is often not yet queryable. Without polling, a predictor would either sleep blindly for a fixed duration or write its own retry loop. fetch_trace eliminates both by running a backoff schedule and returning as soon as the trace has observations.

fetch_trace

from semantic_tracing.trace_reader import fetch_trace

trace = await fetch_trace(trace_id)

fetch_trace polls Langfuse with exponential backoff (DEFAULT_BACKOFF_SECONDS = (2.0, 4.0, 8.0, 16.0, 32.0, 64.0)) and returns once the trace has at least one observation. Pass a custom backoff tuple to adjust the budget:

trace = await fetch_trace(trace_id, backoff=(0.5, 1.0, 2.0, 4.0))

fetch_trace returns a LangfuseTrace, assembled by read_trace(trace_id, client=None). read_trace reads every observation of the trace through Langfuse's v2 observations API (cursor paged) and returns None when there are none yet.

Field Meaning
id, name The trace id and the root observation's trace name
input, output, metadata, tags The root observation's. In Langfuse a trace has no input or output of its own. Input and output are JSON-decoded where they parse
latency Seconds from the first observation start to the last end, ignoring observations in the sdk-experiment environment. None when no observation has both times
total_cost Sum of the observations' costs; None when none has a cost
observations The Langfuse ObservationV2 objects, with input and output decoded

select_root(observations) picks the trace's root. An offline evaluation adds a bookkeeping span onto a finished trace in the sdk-experiment environment (EXPERIMENT_ENVIRONMENT). It is a second root, and readers ignore it for the root, latency, input and output.

The v2 API truncates a metadata value longer than 200 characters, so a long value arrives cut off.

If no trace with observations appears within the backoff budget, fetch_trace raises TimeoutError with the last underlying error chained via raise ... from. Predictors typically catch this and record it in the prediction record rather than letting the exception propagate.

find_observations

from semantic_tracing.trace_reader import find_observations

obs = find_observations(trace, name="route_request")
obs = find_observations(trace, name_in={"route_request", "orchestrate"})

find_observations filters a trace's observation list by name, preserving the original order. Pass name for an exact single match or name_in for an allow-set. The two parameters are mutually exclusive.

as_dict and coerce_text

Langfuse stores observation inputs and outputs as JSON that may arrive as either a dict or a serialized string depending on how it was set. These helpers normalize the inconsistency:

from semantic_tracing.trace_reader import as_dict, coerce_text

args = as_dict(obs.input)          # always returns a dict or raises
text = coerce_text(obs.output)     # always returns a string

coerce_text accepts an optional prefer_keys tuple. When the value is a dict, it tries each key in order and returns the first string value found:

text = coerce_text(obs.output, prefer_keys=("content", "text", "answer"))

Pod composition pattern

Pod-specific extractors should compose these helpers rather than reach into Langfuse directly. Pod code provides the domain knowledge — which observation names are relevant, which output fields contain the data — while the SDK handles the polling, coercion, and backoff:

from semantic_tracing.trace_reader import (
    as_dict, coerce_text, fetch_trace, find_observations,
)

TOOL_NAMES = frozenset({"read_policy", "create_ticket", "lookup_user"})

def extract_tool_calls(trace):
    return [
        ToolCall(
            name=obs.name,
            args=as_dict(obs.input),
            output=obs.output,
        )
        for obs in find_observations(trace, name_in=TOOL_NAMES)
    ]

This keeps pod-specific knowledge (the tool name set, the output container keys) in the pod and the generic polling and coercion plumbing in the SDK.