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.
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…