Skip to content

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.