Spans, attributes, and hierarchies

Auto-instrumentation alone gives you a flat list of LLM spans. A routing agent has structure: a turn contains a router call, a tool call, a synthesis call. If you do not add that structure, the Phoenix timeline looks like an unsorted log. A parent span per agent step is the fix.

Flat spans vs a readable hierarchy

The left side is what you get without parent spans. The right side is what a good trace looks like.

session.py (with turn span)
python
from opentelemetry import trace

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

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

A single start_as_current_span at the top of the turn. Every downstream auto-span becomes a child, so the trace tree mirrors your agent graph.

Matching exercise: OpenInference span kinds

Loading practice…

Attributes are where the insight lives. Use stable names (session.id, agent.route, llm.model) so you can filter and aggregate across traces. Prefix your app-specific attributes with a namespace like agent. so they do not collide with OTel semantic conventions.

AI prompt: Try it: draft your span map

Loading practice…