# Under what conditions must an instance treat a GenAI tool-call span as a child of the model-invocation span rather than the enclosing agent span **Date of Record:** 2026 November 13 **Scope:** Applies to services emitting OpenTelemetry spans under the GenAI semantic conventions (`gen_ai.*`) where an agent loop invokes a model, and the model returns one or more tool calls that the same process executes. Does not apply to tool execution delegated to a separate remote agent whose span is joined via context propagation across a network boundary, nor to streaming partial tool-calls that are never materialized as discrete invocations. ## Core Distinction The GenAI semantic conventions describe a *model invocation* span (`gen_ai.operation.name = "chat"`) and separate *tool execution* spans. The non-obvious invariant is this: a tool-call span is a **child of the model-invocation span that produced it**, not a sibling of that span under the enclosing agent/chain span — *and only when the tool call is causally derived from exactly one model response within the same trace segment*. The failure mode arises because the two candidate parents (the model span and the agent span) are both live in the context at the moment the tool executes, and naive instrumentation picks whichever is "current" in the active context, which is usually the agent span after the model span has been closed. The distinction matters because the model span's end timestamp is the boundary of the model's contribution. If the tool span is parented to the agent span, the tool's latency is attributed to the agent loop rather than to the model turn, and any consumer that sums "time under model invocation" versus "time in tools" double-counts or misattributes. The convention's intent is that the model span brackets *only* the inference, and tool spans hang beneath it to record what that inference requested. ## What Was Observed In a production agent loop, the model span was closed with `span.end()` immediately after the response was parsed, and then each returned tool call was executed. Because the model span was no longer the active span, the OpenTelemetry context at tool-execution time resolved to the agent span. The resulting trace showed tool spans as siblings of the model span: ``` agent.loop (span) ├── gen_ai.chat (model invocation) └── tool.execute (sibling — WRONG per convention intent) ``` The consequence was measurable: a downstream cost/latency dashboard that joined `gen_ai.chat` spans to their tool spans by parent-child relationship found zero tool spans per model span. The join silently returned empty rather than erroring, so the "tools per inference" metric read as 0.0 across the entire fleet while tools were demonstrably executing. A second symptom appeared in sampling: a tail-sampling policy that kept traces containing tool errors inspected the tool span's parent to decide whether to retain the model span; because the tool span's parent was the agent span, the policy retained the whole trace instead of the intended subtree, inflating storage by roughly the tool-call fan-out factor. A third, subtler observation: when the model returned *multiple* tool calls in one response, re-parenting them under the model span produced a correct one-to-many fan-out, but only if the model span was kept open (or its `SpanContext` retained) until all child tool spans were created. Closing the model span before creating children and then passing its context explicitly via `trace.set_span_in_context(model_span)` worked, but only when the instrumentation used the *captured* context rather than the ambient one. ## Condition of Validity The parent-under-model rule is valid if and only if all of the following hold: 1. **Single-response derivation.** The tool call was emitted by one specific model response. If a tool call is synthesized by the agent framework itself (e.g., a planner step that emits a tool call without a model turn), it has no model span to parent to and must attach to the agent span. 2. **Same trace segment, live or captured context.** The model span's `SpanContext` is either still active or explicitly captured and re-injected when the tool span is created. If the model span was ended *and* its context discarded, the tool span cannot be correctly parented without fabricating a link — in which case use a span *link* to the model span, not a parent. 3. **Exactly one model invocation owns the call.** Parallel model invocations (e.g., a mixture-of-agents fan-out) must not share a parent; each tool call parents to the specific invocation that produced it. If attribution is ambiguous, the correct fallback is the agent span plus a link to the candidate model spans. 4. **The tool call is not retried across model turns.** A retry that re-invokes the model produces a *new* model span; the retried tool span parents to the new model span, not the original. If any condition fails, parenting to the agent span is correct and the convention is not violated. ## Decision Boundary The decision is not "always parent tools under the model span." It is a boundary test evaluated at tool-span creation time: - **Parent = model invocation span** when the tool call is causally bound to a single, identifiable, same-segment model response whose context is available (live or captured). - **Parent = agent span, with a span link to the model span** when the model span is closed and its context unrecoverable, when the tool call is framework-synthesized, or when multiple model invocations could have produced it. - **Do not invent a parent.** Never re-open a closed span or fabricate a `SpanContext` to force the hierarchy; that corrupts start/end ordering and breaks downstream span-duration invariants. The trap is treating the ambient context as authoritative. The ambient context after `span.end()` is the agent span, and that is precisely the wrong parent for a model-derived tool call. Instrumentation must capture the model span's context *before* ending it. ## Operational Effect Enforcing the boundary restored the `tools per inference` join from 0.0 to the true fan-out (median 1.8, p99 6) and reduced tail-sampling storage by the fan-out factor. The captured-context pattern also eliminated a class of orphaned tool spans that had been appearing as roots when the agent span was not active during async tool execution. The residual cost is that model spans must retain their context until all child tool spans are created, which for long-running tools means holding the `SpanContext` (not the span) across the tool's async boundary — a small, bounded allocation. The failure to avoid is the silent one: a wrong parent does not raise, it merely makes every downstream relational query quietly wrong. ```python from __future__ import annotations from dataclasses import dataclass from typing import Optional from opentelemetry import trace class ToolSpanParentingError(RuntimeError): """Raised when a tool-call span cannot be parented per GenAI conventions.""" @dataclass class ModelInvocation: span: trace.Span context: object # captured SpanContext-bearing context closed: bool = False def parent_tool_span( tracer: trace.Tracer, tool_name: str, model: Optional[ModelInvocation], agent_context: object, *, framework_synthesized: bool = False, ambiguous_owner: bool = False, ) -> trace.Span: """Create a tool-call span with the convention-correct parent. Parent under the model invocation span only when the call is causally bound to exactly one same-segment model response whose context is available. Otherwise parent under the agent span and link the model span. """ if framework_synthesized: # No model turn produced this call; agent span is the correct parent. return tracer.start_span(f"tool.{tool_name}", context=agent_context) if model is None: raise ToolSpanParentingError( f"tool '{tool_name}' claims model derivation but no model invocation given" ) if ambiguous_owner: # Cannot attribute to a single invocation: parent to agent, link model. span = tracer.start_span(f"tool.{tool_name}", context=agent_context) span.add_link(model.span.get_span_context()) return span if model.closed and model.context is None: # Model span ended and context discarded: do NOT fabricate a parent. raise ToolSpanParentingError( f"tool '{tool_name}': model span closed with no captured context; " "cannot parent per convention (use a link, not a parent)" ) # Correct path: parent under the captured model context. return tracer.start_span(f"tool.{tool_name}", context=model.context) def close_model_span(model: ModelInvocation) -> None: """End the model span only after capturing its context for children.""" if model.context is None: raise ToolSpanParentingError( "refusing to close model span before capturing its context" ) model.span.end() model.closed = True ```