Traces that span several requests
Prerequisites: 02-tracing-observations.md and 05-tracing-frameworks.md.
Some units of work are one trace in Langfuse but several requests to a service. The common case is an agent with a tool its client executes. Under AG-UI, a run ends at a call only the client can answer. The client answers it, and its next request continues the same turn, possibly on another replica. No span can stay open across those requests, because nothing in the process survives from one to the next.
Three helpers make the trace's identity a function of a key the client sends on every request, such as a turn identifier it mints when the user sends a message.
| Helper | What it does |
|---|---|
resume_trace(key) |
Context manager. Every observation started inside it, including Agent Framework's own spans, joins the trace the key names, as a child of that trace's root |
record_trace_root(key, name, ...) |
Records the root observation the key names, with times the caller supplies. Recording it again under the same key updates that one observation |
record_observation(name, ...) |
Records work that already happened, with its own start and end, under the current parent |
derived_ids(key) |
The trace id and root observation id a key resolves to, for logging or for reading the trace back |
All of them do nothing when setup_tracing() did not wire Langfuse in.
How the ids are chosen
The trace id and the root's id are both taken from a SHA-256 of the key. Any request on any replica that knows the key therefore knows both ids, and the root does not have to exist yet for a request's spans to name it as their parent.
setup_tracing() installs an id generator on the tracer provider that answers
with these ids for the one span record_trace_root() starts, and delegates to
the provider's own generator for every other span.
graph TD
subgraph R1["`**Request 1**`"]
A1["resume_trace key"]
S1["agent spans, round 1"]
end
subgraph R2["`**Request 2**`"]
A2["resume_trace key"]
C2["record_observation: the client's tool call"]
S2["agent spans, round 2"]
T2["record_trace_root key"]
end
ROOT["Root observation, id derived from key"]
A1 --> S1
A2 --> C2
A2 --> S2
S1 -.->|"parent"| ROOT
C2 -.->|"parent"| ROOT
S2 -.->|"parent"| ROOT
T2 -->|"writes"| ROOT
style R1 fill:#eef3fa,stroke:#5588cc,stroke-width:2px
style R2 fill:#eef3fa,stroke:#5588cc,stroke-width:2px
Usage
from datetime import datetime, timezone
from semantic_tracing import propagate, record_observation, record_trace_root, resume_trace
async def handle_run(body: dict) -> None:
turn = body["forwardedProps"]["turn"]
with resume_trace(turn["id"]), propagate(session_id=body["threadId"], trace_name="agent_turn"):
for answered in body["forwardedProps"].get("answered", []):
record_observation(
answered["name"],
as_type="tool",
start_time=datetime.fromisoformat(answered["startedAt"]),
end_time=datetime.fromisoformat(answered["endedAt"]),
metadata=answered.get("notes"),
)
answer = await run_the_agent(body)
record_trace_root(
turn["id"],
"agent_turn",
as_type="agent",
start_time=datetime.fromisoformat(turn["startedAt"]),
end_time=datetime.now(timezone.utc),
input=turn["question"],
output=answer,
)
Where to record the root
The root can be recorded by every request, with whatever is known so far, or only by the request that ends the unit of work.
| Option | Upside | Downside |
|---|---|---|
| Every request | A unit of work the client abandons halfway still has a root showing how far it got | One more span per request |
| The last request only | One root span per unit of work | A unit of work that never reaches its last request leaves its observations pointing at a root that never arrives, and Langfuse renders them detached |
Recording the root again with the same key exports the same observation id, and
Langfuse keeps the latest version, measured with a two request turn against
Langfuse Cloud: one root, carrying the last request's output. The merge is not
immediate. For a few minutes after ingestion the trace can list every version
side by side, all under the one id, so a reader that counts roots right after a
turn will count more than one. Pass the same
start_time every time, which is why the client should send it rather than each
request measuring its own.
Put nothing on the root that differs between requests and has to be filtered on at trace level. Langfuse lifts the root's observation metadata into the trace's own metadata when it first sees the root, and a later version of the root updates the observation without updating that copy. A round counter on the root therefore reads 1 in the trace list forever. Say what varies per request on the spans of that request instead.
Attributes on the root
A propagate() around the call stamps user, session, trace name and metadata on
the root like on any other span. The environment is read from
LANGFUSE_TRACING_ENVIRONMENT, the variable the Langfuse client reads for its own
observations.
The release works differently. No running parent exists to copy it from, so each
recorded observation takes the release current when it is recorded, and the
trace's release is its root's. While no release is set, LANGFUSE_RELEASE applies,
as it does for the Langfuse client's own observations. See
Releases.