Pick a depth. Each prompt opens in your AI pre-loaded with the lesson. Click a row to preview the prompt.
In an ordinary service the unit of work is obvious: one request, one trace. In an agent system there are at least four candidate units — a user turn, an agent run, a single model call, and a whole conversation — and they nest differently depending on your product. Choose wrong and simple questions become impossible: if a trace covers a whole conversation, you cannot compute per-turn latency; if it covers one model call, you cannot see the loop. The decision also determines where the trace boundary sits relative to your HTTP request, which matters because a streaming response outlives the handler and a background agent has no request at all.
The four candidate units with what each one makes easy and impossible, then the recommendation. The conversation identifier is the piece that makes the recommendation work: you get per-turn traces and can still reconstruct a conversation by grouping on an attribute.
# ── The four candidates ──────────────────────────────────────────
#
# A. CONVERSATION as the trace
# one trace per chat session, spanning hours
# easy: "what happened in this whole conversation"
# hard: per-turn latency (the trace duration is wall-clock
# including the user thinking for 20 minutes)
# broken: sampling (one decision for an hour of activity),
# trace size limits, and any percentile at all
#
# B. TURN as the trace <- RECOMMENDED
# one trace per user message, root span = the turn
# easy: per-turn latency, cost, step count, all percentiles
# easy: sampling decisions that mean something
# needs: gen_ai.conversation.id as an ATTRIBUTE so you can
# group turns back into a conversation
#
# C. AGENT RUN as the trace
# one trace per agent invocation. Same as B when one turn is one
# run; different when a turn spawns several runs, or when a run
# is a background job with no user waiting.
# use when: agents run detached from any user turn
#
# D. MODEL CALL as the trace
# one trace per LLM request
# easy: per-call latency and token accounting
# broken: the loop is invisible. Never the right root.
# ── The recommendation, and why ──────────────────────────────────
# Root span = the TURN. Everything the turn causes is a child.
# Conversation is an attribute, not a structure.
#
# This gives you:
# - "p95 turn latency" a percentile that means
# something to a user
# - "cost per turn" the unit economics number
# - "steps per turn" the runaway-loop signal
# - "everything in conversation X" group by an attribute
# - a sampling decision per turn (module 12)
#
# And it matches the semantic conventions, which define
# gen_ai.conversation.id as an attribute precisely so that the
# conversation does not have to be the span.
from opentelemetry import trace, SpanKind
tracer = trace.get_tracer("agent")
def handle_turn(conversation_id: str, turn_index: int, message: str):
with tracer.start_as_current_span(
"invoke_agent research-assistant", # naming: module 2
kind=SpanKind.SERVER,
attributes={
"gen_ai.operation.name": "invoke_agent",
"gen_ai.agent.name": "research-assistant",
# The conversation as an ATTRIBUTE. Bounded per trace,
# unbounded across traces — fine on a span, never on a
# metric label.
"gen_ai.conversation.id": conversation_id,
"gen_ai.conversation.turn_index": turn_index,
},
) as turn:
return run_agent(message, turn)
# ── Where the boundary sits vs your HTTP request ────────────────
# a) NON-STREAMING: the turn span and the HTTP handler coincide.
# Simple. Use the SERVER span as the turn.
#
# b) STREAMING: the response starts before the turn is done. The
# turn span must outlive the first byte and end when generation
# completes — NOT when the handler returns. -> module 7
#
# c) BACKGROUND / SCHEDULED: no HTTP request at all. The turn span
# is a deliberate root, exactly like the cron pattern in the
# engineering telemetry course.
#
# d) HUMAN-IN-THE-LOOP: the agent pauses for approval, sometimes
# for hours. Do NOT hold a span open across that: end the turn
# at the pause and start a new trace on resumption, LINKED to
# the first. Same reasoning as a queue boundary — a span held
# open for four hours makes every duration meaningless.
# ── Write it down ────────────────────────────────────────────────
# Whichever you pick, record it in a one-line standard, because
# every dashboard and every alert threshold depends on it. Two
# services in one system using different units is the fastest way
# to make an estate's telemetry unusable.python3 main.py