Linking spans across the agent graph

Decorators give you spans. LangGraph calls your node functions inside graph.invoke. If you wrap the whole invoke in a parent span, the decorators attach as children automatically. No context plumbing, just a with block.

session.py
python
import uuid
from opentelemetry import trace
from agent.graph import build_graph

tracer = trace.get_tracer("agent.session")

class EcommerceSession:
    def __init__(self):
        self.session_id = str(uuid.uuid4())[:8]
        self.graph = build_graph()
        self.conversation_history: list[dict] = []
        self.turn_number = 0

    def ask(self, question: str) -> str:
        self.turn_number += 1
        with tracer.start_as_current_span(
            f"turn_{self.turn_number}",
            attributes={
                "openinference.span.kind": "CHAIN",
                "session.id": self.session_id,
                "session.turn_number": self.turn_number,
                "agent.user_message": question[:200],
            },
        ):
            final_state = self.graph.invoke({
                "user_message": question,
                "conversation_history": self.conversation_history,
                "turn_number": self.turn_number,
            })
        self.conversation_history = final_state["conversation_history"]
        return final_state["final_answer"]

The turn span owns the whole invocation. Every decorated node lives underneath it as a child span, and the Phoenix tree mirrors your graph.

What Phoenix shows for one turn

Yes. OpenTelemetry uses a ContextVar for the current span, same mechanism as the request_id. Tasks spawned inside start_as_current_span inherit the context, so a concurrent tool call still attaches to the turn span. If you spawn threads with an executor, copy the context first, same rule as before.

Validation checklist: Span coverage check

Loading practice…