Repository navigation
Mark the outermost GenAI span of traces with a non-GenAI root - #7192
Conversation
An agent served behind an HTTP handler has an HTTP root span, and the GenAI spans (invoke_agent, chat, execute_tool) are its descendants. Consumers that list agent traces by their root, such as OpenSearch Dashboards Agent Traces, miss these traces (opensearch-project#7191). When a trace's root is not a GenAI span, otel_traces now sets derived.gen_ai.is_root = true on each outermost GenAI span (a span with gen_ai.operation.name and no GenAI ancestor) and aggregates its descendants' tokens onto it. Spans flushed without their root are still marked. A marker already set by an SDK or by hand is kept, and the processor then marks nothing else in the trace. Root propagation of gen_ai.agent.name, gen_ai.request.model and the other keys now takes values from the outermost GenAI spans first, instead of whichever child came first in the batch. The README no longer says gen_ai.operation.name is propagated. Resolves opensearch-project#7191 Signed-off-by: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com>
PR Reviewer Guide 🔍(Review updated until commit 242387f)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to 242387f Explore these optional code suggestions:
Previous suggestionsSuggestions up to commit 96742f1
Suggestions up to commit 5198311
Suggestions up to commit 3e5a02f
|
| 1. **Vendor attribute normalization** — Maps vendor-specific attributes (OpenInference, OpenLLMetry) to [OTel GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/). Original attributes are preserved. See [`genai-attribute-mappings.yaml`](src/main/resources/genai-attribute-mappings.yaml) for the full mapping table. | ||
| 2. **Root span enrichment** — Propagates `gen_ai.system`, `gen_ai.provider.name`, `gen_ai.agent.name`, `gen_ai.request.model`, and `gen_ai.operation.name` from child spans to root (first-child-wins, skip-if-present). Aggregates `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens` across children (sum). | ||
| 3. **Flattened sub-key stripping** — Removes flattened sub-keys (e.g. `llm.input_messages.0.message.content`) that conflict with parent string values, preventing OpenSearch mapping failures. | ||
| 2. **Root span enrichment** — Propagates `gen_ai.system`, `gen_ai.provider.name`, `gen_ai.agent.name`, and `gen_ai.request.model` from child spans to root (skip-if-present). Values come from the outermost GenAI span first (earliest start time), then other GenAI spans, then the remaining children. Aggregates `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens` across children (sum). |
There was a problem hiding this comment.
Propagates
gen_ai.system,gen_ai.provider.name,gen_ai.agent.name, andgen_ai.request.modelfrom child spans to root (skip-if-present)
Please create a follow-up issue to remove this propagation. Once this change has stabilized
There was a problem hiding this comment.
Agreed. I'll open a follow-up issue to remove the propagation once the marker has shipped and stabilized.
With the marker, consumers read agent data (name, model, input/output, tokens) from the agent span itself, so the copies on the root become redundant. They're also misleading: an HTTP or runtime span ends up carrying a model, an agent name and tokens it didn't produce.
Before removing it:
- the OpenSearch Dashboards trace flyout header has to read the AI root instead of the top span. That's now in [Agent Traces] List agents behind an HTTP handler by their marked AI root span OpenSearch-Dashboards#12977;
- the change needs a release note, since it changes the data on root spans for existing pipelines.
While checking this on a real Strands trace, I found that root enrichment double counted tokens. It summed every child with usage, including invoke_agent spans that already report the total of their own chat calls, so the root showed about 2× the real usage. The new marker aggregation had the same flaw. Both now count only spans that have no ancestor reporting usage. The fix is in this PR (5198311), with tests.
| 2. **Root span enrichment** — Propagates `gen_ai.system`, `gen_ai.provider.name`, `gen_ai.agent.name`, `gen_ai.request.model`, and `gen_ai.operation.name` from child spans to root (first-child-wins, skip-if-present). Aggregates `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens` across children (sum). | ||
| 3. **Flattened sub-key stripping** — Removes flattened sub-keys (e.g. `llm.input_messages.0.message.content`) that conflict with parent string values, preventing OpenSearch mapping failures. | ||
| 2. **Root span enrichment** — Propagates `gen_ai.system`, `gen_ai.provider.name`, `gen_ai.agent.name`, and `gen_ai.request.model` from child spans to root (skip-if-present). Values come from the outermost GenAI span first (earliest start time), then other GenAI spans, then the remaining children. Aggregates `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens` across children (sum). | ||
| 3. **Agent root marker** — When a trace's root is not a GenAI span (for example an agent served behind an HTTP handler), sets `derived.gen_ai.is_root: true` on each outermost GenAI span: a span with `gen_ai.operation.name` and no GenAI ancestor. It also aggregates the token usage of that span's descendants onto it, unless it already has `gen_ai.usage.input_tokens`. A trace whose root is a GenAI span is not marked. |
There was a problem hiding this comment.
on each outermost GenAI span: a span with
gen_ai.operation.nameand no GenAI ancestor.
How would this work when agents make http requests to services which result in no gen_ai attributes? E.g.
(1)Agent --> (2)http request --> (3)Agent --> (4)MCP tool --> etc...
Would spans (1) and (3) be listed as separate agent traces in OSD front-end?
There was a problem hiding this comment.
Good question. In the normal case they're listed as one agent trace:
POST /runs (root, no gen_ai)
└── (1) invoke_agent Planner <- marked
└── (2) HTTP client > HTTP server (other service, no gen_ai)
└── (3) invoke_agent Weather <- not marked: (1) is a GenAI ancestor
└── (4) execute_tool (MCP)
All of these spans share the trace ID, and otel_traces holds the children until the root arrives, so the whole chain is usually in one batch. (3) has a GenAI ancestor through (2), so only (1) is marked. If (1) is the trace root, nothing is marked.
There is one edge case where (3) is listed separately. If the called service's spans are flushed after trace_flush_interval without (1) in the batch, the chain stops at the missing span, (3) gets marked too, and Agent Traces shows it as its own row. The same happens if that service marks (3) itself with the SDK.
I added tests for both (marksOnlyTheCallingAgentWhenAnAgentCallsAnotherAgentOverHttp, marksTheRemoteAgentWhenItsCallerIsMissingFromTheBatch), documented the edge case in the README, and added it to the PR description.
I also replayed a real Strands trace that includes calls to other services whose parent spans weren't exported. Only the outer invoke_agent was marked, both with the root present and with it removed.
Root enrichment summed token usage over every child, and the agent root marker did the same over its descendants. Agent spans such as invoke_agent often report the total of their own LLM calls, so those calls were counted twice; on a real Strands trace the root showed about twice the actual usage. Both now count only spans that have no ancestor reporting usage. Adds tests for an agent that calls another agent over HTTP in the same trace: only the calling agent is marked, and the called agent is marked only when its spans are flushed without the caller's. The README documents both. Signed-off-by: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com>
|
Persistent review updated to latest commit 5198311 |
- The agent root marker now also goes on a trace root that is a GenAI span, so every agent trace carries one marker, wherever the agent sits. - Spans that arrive after their trace's root was processed are enriched on their own, without their ancestors, so a late chat span or the spans of a called service looked outermost and were marked. The processor now remembers, per trace and for trace_group_cache_ttl, which span ids are covered by a GenAI span or a marker (bounded by span id count), and looks there when a parent is not in the batch. - A span marked at the source also covers the spans below it. - README documents the remaining edge cases. Signed-off-by: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com>
|
Persistent review updated to latest commit 96742f1 |
kylehounslow
left a comment
There was a problem hiding this comment.
LGTM! Thanks for this critical fix
|
@dlvenable / @srikanthpadakanti can you please help review and merge this PR Thanks! |
dlvenable
left a comment
There was a problem hiding this comment.
Automated review (AI agent)
Note: This review was generated by an AI agent (Claude Code) run by @dlvenable. Candidate issues were surfaced by multiple review passes and each was then verified by an independent pass against the checked-out branch (not just the diff). Labels: CONFIRMED = every premise was checked against the code and holds; PLAUSIBLE = the mechanism is real in the code, but firing depends on inputs/environment that can't be proven from the repo alone. Please treat these as input for human judgment.
This is a well-tested and well-documented change — the marker semantics, edge cases, and the double-counting fix are carefully thought through, and the end-to-end validation in the PR description is thorough. The findings below are ranked most severe first; details are in the inline comments.
Correctness
- CONFIRMED —
GenAiCoverageTTL (trace_group_cache_ttl, default 15s) is disjoint from the orphan GC-flush window (trace_flush_interval, default 180s), so the late-span protection can never cover GC-flushed spans at default settings; every GC-flushed outermost GenAI orphan gets a duplicatederived.gen_ai.is_rootmark. - CONFIRMED — the
coverage.covered()→markAgentRoots()→coverage.record()sequence is a non-atomic check-then-act on state shared across pipeline worker threads; two concurrent batches of the same trace can both mark a root. - CONFIRMED —
BY_START_TIMEcompares ISO-8601 start times lexicographically;Instant.toString()emits variable-length fractions, so ordering inverts across precision boundaries and the wrong agent's name/model can propagate to the root. - CONFIRMED —
markAgentRootsskips aggregation only when the agent root hasinput_tokens; an agent reporting onlyoutput_tokensgets its own value overwritten. - PLAUSIBLE (pre-existing, widened) — unguarded
(Number)/(String)casts on attribute values can throwClassCastExceptionand fail the whole batch. - CONFIRMED (narrow edge) —
enrichRootSpancomputesoutermostGenAiSpans(children)without theknowncoverage set, so propagation and marking can disagree in late-root batches.
Efficiency
- CONFIRMED —
JacksonSpan.getAttributes()deserializes a fresh map per call, and the new predicates call it O(n·depth + n·log n) times per trace (previously O(n)), on the hot path for all traces including non-GenAI ones.
Design notes
- PLAUSIBLE — the topmost-token rule can undercount nested sub-agents whose usage isn't included in the ancestor's reported total (documented trade-off; flagging for awareness).
- Presence-based opt-out: an SDK emitting
derived.gen_ai.is_root: falseon every span silently disables marking for the whole trace. - The new 500k-span-id coverage cache has no metric gauge and no config knob;
GenAiCoverage.estimatedSize()is currently dead code.
Not flagged: the README correction is accurate (gen_ai.operation.name was indeed never propagated), and all existing otel-trace-raw-processor tests pass on this branch.
| .build(); | ||
|
|
||
|
|
||
| genAiCoverage = GenAiCoverage.create(GENAI_COVERAGE_MAX_SPAN_IDS, |
There was a problem hiding this comment.
[AI agent finding #1 — CONFIRMED] Coverage TTL is disjoint from the orphan-flush window, so the late-span protection never covers GC-flushed spans at defaults.
GenAiCoverage uses trace_group_cache_ttl (default 15s), but a child arriving more than 15s after its root misses traceIdTraceGroupCache, is parked in traceIdSpanSetMap, and is only flushed by getTracesToFlushByGarbageCollection after trace_flush_interval (default 180s, often 180–360s given the GC cadence). By then the coverage entry recorded when the agent was marked has always expired, so covered(traceId) returns empty and the orphan GenAI span is unconditionally marked derived.gen_ai.is_root: true.
So at default settings the window where the protection works (≤15s, the trace-group-cache-hit path) and the window where orphans are flushed (≥180s) are disjoint — the README's "a span that arrives later than that may be marked as well" is systematic for this path, not occasional.
Suggestion: give the coverage cache its own TTL of at least 2 × trace_flush_interval + trace_group_cache_ttl. The cache is already weight-bounded at 500k span ids, so a longer TTL is cheap.
| enrichRootSpan(rootSpan, children); | ||
| } | ||
| final String traceId = traceSpans.get(0).getTraceId(); | ||
| final Set<String> known = coverage.covered(traceId); |
There was a problem hiding this comment.
[AI agent finding #2 — CONFIRMED] Non-atomic check-then-act on shared GenAiCoverage across pipeline workers.
OTelTraceRawProcessor is not @SingleThread, so a single instance is shared by all pipeline workers and doExecute runs concurrently (the class already uses ConcurrentHashMap/ReentrantLock for its other shared state). The sequence coverage.covered(traceId) → markAgentRoots(traceSpans, known) → coverage.record(...) has no synchronization, and spans of one trace can land in two concurrent batches: pass-through spans (trace group cached) race the GC flush, whose tryLock only guards its own path.
Both threads then read an empty covered set and both mark a root — e.g. the caller service's invoke_agent on worker A and the called service's invoke_agent on worker B — the exact duplication the coverage cache was added to prevent. The in-batch source-marker guard can't help across threads, and the new tests are single-threaded so they can't catch it.
Suggestion: serialize the covered-read → mark → record sequence per trace id (e.g. compute-style locking keyed by traceId, or a lock striped on traceId).
| GEN_AI_SYSTEM_KEY, GEN_AI_PROVIDER_NAME_KEY, GEN_AI_AGENT_NAME_KEY, GEN_AI_REQUEST_MODEL_KEY | ||
| }; | ||
|
|
||
| private static final Comparator<Span> BY_START_TIME = Comparator.comparing( |
There was a problem hiding this comment.
[AI agent finding #3 — CONFIRMED] Lexicographic comparison of ISO-8601 start times mis-orders across fractional precision.
Span.getStartTime() is a String produced via Instant.toString() (OTelProtoCommonUtils.convertUnixNanosToISO8601), which emits 0/3/6/9 fractional digits. "2026-01-01T00:00:01Z".compareTo("2026-01-01T00:00:01.500Z") > 0 because 'Z' (0x5A) > '.' (0x2E) — the later timestamp sorts first.
This breaks the "earliest first" contract of outermostGenAiSpans and the tiebreak in enrichRootSpan's sort: with two parallel top-level agents whose start times straddle a precision boundary (one landing exactly on a second/millisecond boundary), the trace root receives the wrong agent's gen_ai.agent.name/gen_ai.request.model.
Suggestion: compare parsed instants, e.g. Comparator.comparing(span -> Instant.parse(span.getStartTime()), ...) (with a null/parse-failure fallback), instead of raw strings.
| for (final Span agentRoot : outermostGenAiSpans(traceSpans, known)) { | ||
| agentRoot.put(ATTRIBUTES_PREFIX + DERIVED_GEN_AI_IS_ROOT_KEY, true); | ||
| final Map<String, Object> attrs = agentRoot.getAttributes(); | ||
| if (attrs != null && attrs.containsKey(GEN_AI_INPUT_TOKENS_KEY)) { |
There was a problem hiding this comment.
[AI agent finding #4 — CONFIRMED] An agent root reporting only output_tokens gets its own value overwritten.
This guard checks only GEN_AI_INPUT_TOKENS_KEY, asymmetric with hasTokens() below, which treats either key as "reports usage". Both token attributes are only conditionally required by the OTel GenAI semconv, so an agent span carrying only gen_ai.usage.output_tokens (say 700) passes this guard, and the subsequent puts clobber its genuine value with the descendants' aggregate.
The same span would meanwhile count as a token-reporting ancestor in sumTopmostTokens, so totals become inconsistent between the root and the marker.
Suggestion: make this check symmetric with hasTokens() (skip if either key is present). The pre-existing one-key rootHasTokens check in enrichRootSpan has the same shape and could be fixed in passing.
| continue; | ||
| } | ||
| final Map<String, Object> attrs = span.getAttributes(); | ||
| final Number input = (Number) attrs.get(GEN_AI_INPUT_TOKENS_KEY); |
There was a problem hiding this comment.
[AI agent finding #5 — PLAUSIBLE, pre-existing but widened] Unguarded casts on attribute values can fail the whole batch.
(Number) attrs.get(...) here (and (String) attrs.get(entry.getKey()) in the propagation loop) throws ClassCastException on a string-typed token attribute. That's legal OTLP — the codec maps AnyValue string values to String, and normalizeAttributes copies vendor values (e.g. llm.usage.prompt_tokens → gen_ai.usage.input_tokens) verbatim without type conversion — so a vendor exporter emitting token counts as strings reaches this cast. The exception propagates out of doExecute and drops/DLQs the entire batch, including GC-flushed spans of unrelated traces.
The casts pre-exist on main, but this PR widens exposure: sumTopmostTokens now also runs per agent root via markAgentRoots, including when there is no trace root (the old path was gated on rootSpan != null).
Suggestion: an instanceof Number check (skip or attempt parse otherwise) makes enrichment degrade per-attribute instead of per-batch.
| boolean foundTokens = false; | ||
| // Propagated values come from the outermost GenAI spans first, so the trace gets the outer | ||
| // agent's name and model whatever order the batch is in. | ||
| final Set<Span> outermost = new HashSet<>(outermostGenAiSpans(children)); |
There was a problem hiding this comment.
[AI agent finding #6 — CONFIRMED, narrow edge] Propagation ignores the known coverage set, so it can disagree with marking.
enrichRootSpan calls the outermostGenAiSpans(children) overload without known, while markAgentRoots passes it. When children were GC-flushed in an earlier batch and the root is later flushed with newly buffered children, this runs with a non-empty known it ignores — a covered descendant can win propagation of gen_ai.agent.name/gen_ai.request.model over the true outer agent.
Threading known through here (or computing the outermost list once per trace in enrichBatch and passing it to both consumers — see the efficiency comment) closes the gap.
| } | ||
| } | ||
|
|
||
| private static boolean isGenAiSpan(final Span span) { |
There was a problem hiding this comment.
[AI agent finding #7 — CONFIRMED] getAttributes() deserializes per call, and the new code calls it O(n·depth + n·log n) times per trace.
JacksonSpan.getAttributes() goes through JacksonEvent.get(..., Map.class) → mapper.treeToValue(...) — a full fresh deserialization on every call, including large prompt/completion payloads on GenAI spans. The previous code read each child's attributes once (O(n) per trace). The new code invokes it inside isGenAiSpan/isMarked/hasTokens, which run in per-span ancestor walks (3 passes: outermostGenAiSpans ×2 + coveredSpanIds), in enrichRootSpan's sort comparator (per comparison), and alongside 5–6 separately rebuilt bySpanId/childrenByParent indexes.
This is the hot path for all otel_traces traffic, and there's no fast-path bail-out for GenAI-free traces (markAgentRoots builds childrenByParent before discovering there's nothing to mark; enrichRootSpan copies and sorts all children even when nothing will propagate).
Suggestion: build one per-trace structure at the top of the loop in enrichBatch — bySpanId, childrenByParent, and a memoized per-span record {isGenAi, isMarked, inputTokens, outputTokens, startTime} — and thread it through. That restores O(1) deserializations per span, allows an early return for GenAI-free traces, and also collapses the duplicated hasGenAiAncestor/hasTokenAncestor walks into one parameterized helper.
| } | ||
|
|
||
| /** | ||
| * Sums token usage over the given spans, counting only spans with no ancestor among them that |
There was a problem hiding this comment.
[AI agent finding #8 — PLAUSIBLE, design note] Topmost-token rule can undercount nested sub-agents.
The "nearest token-reporting ancestor shadows the subtree" rule fixes the confirmed 2× Strands double-count, but in the agent-as-tool pattern a sub-agent runs its own event loop whose tokens are not included in the outer agent's counter — those now vanish from trace-level totals, with no opt-out.
This looks like a deliberate, documented trade-off, so flagging only for awareness: a README caveat ("an ancestor's reported usage is assumed to include its subtree") or a future refinement may be worth considering.
| return parentId != null && known.contains(parentId); | ||
| } | ||
|
|
||
| private static boolean isMarked(final Span span) { |
There was a problem hiding this comment.
[AI agent finding #9 — design note] Presence-based opt-out may surprise SDKs that emit is_root: false everywhere.
isMarked (and the skip-check in markAgentRoots) uses containsKey, so a source that conscientiously emits derived.gen_ai.is_root: false on every non-root span — a natural reading of a boolean attribute — silently disables marking for all its traces, and nothing logs why.
Treating only value == true as a source marker, with a separate explicit opt-out mechanism, would be less surprising. If the presence-based contract is intentional (the README does document it), consider at least a debug log when a trace is skipped due to an existing marker.
| }); | ||
| } | ||
|
|
||
| long estimatedSize() { |
There was a problem hiding this comment.
[AI agent finding #10 — cleanup] estimatedSize() is dead code; the cache has no gauge and no config knob.
Nothing calls this method — it mirrors the gauge pattern used for traceIdTraceGroupCache/traceIdSpanSetMap (traceGroupCacheCount, spanSetCount), but no gauge is ever registered for genAiCoverage. Without it, operators can't see the 500k-span-id cache filling or evicting under weight pressure — and eviction produces the same late-span mismarking the cache exists to prevent.
Suggestion: register pluginMetrics.gauge(...) for it like the sibling caches (which appears to have been the intent), and consider making GENAI_COVERAGE_MAX_SPAN_IDS configurable alongside trace_group_cache_max_size.
… values - Coverage now outlives the orphan flush (2 x trace_flush_interval + trace_group_cache_ttl), so spans flushed without their root still see what earlier batches covered; its size is reported as genAiCoverageCount. - The covered read, the marking and the write run atomically per trace (Caffeine compute), so pipeline workers handling spans of one trace at the same time cannot both mark an outermost span. - Each trace is indexed once: attributes are read once per span, traces without GenAI data are skipped, and one ancestor walk serves marking, coverage and token counting. - Start times compare as instants, not text (fractional precision). - An agent span reporting only output tokens keeps its value; the root check is symmetric too. - Token counts and propagated values are read tolerantly: numeric strings count, anything else is ignored instead of failing the batch. - Propagation uses the coverage too, like marking. - Only derived.gen_ai.is_root: true is a source marker; a span set to false is never marked. A debug log notes traces skipped for a marker. - README: the coverage window, the marker values, and the token rule's assumption that an ancestor's usage includes its subtree. Signed-off-by: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com>
|
Persistent review updated to latest commit 242387f |
Description
Agents served behind an HTTP handler produce traces whose root is an HTTP
SERVERspan (for examplePOST /runs), with the GenAI spans (invoke_agent,chat,execute_tool) below it. OpenSearch Dashboards Agent Traces lists a trace by a root that hasgen_ai.operation.name, so these traces were missing from the Traces and Sessions tabs (#7191).As proposed in #7191 (comment),
otel_tracesnow marks the trace's AI root, not the HTTP root:Marker: each outermost GenAI span of a trace (a span with
gen_ai.operation.nameand no GenAI ancestor) getsderived.gen_ai.is_root = true. That's the root itself when it is a GenAI span, or the agent span below a non-GenAI root, so every agent trace carries one marker. The token usage of its descendants is aggregated onto it, unless it already hasgen_ai.usage.input_tokens.Parallel agents: several top-level agents under one root are each marked.
Late roots: spans flushed after
trace_flush_intervalwithout their root are still marked, because the marker only needs the spans below it. Root enrichment cannot help in that case.Source markers: if any span in the trace already carries
derived.gen_ai.is_root(set by the GenAI SDKs or by hand, with any value), the processor marks nothing else in that part of the trace. Setting it tofalseopts a trace out.Known edge cases (documented):
trace_group_cache_ttlmay be marked as well;Root propagation:
gen_ai.agent.name,gen_ai.request.modeland the other propagated keys now come from the outermost GenAI spans first (earliest start time), instead of whichever child came first in the batch.README: fixed the propagated-keys list (
gen_ai.operation.namewas listed but never propagated) and documented the marker.Token totals counted once: root enrichment used to sum token usage over every child, and the marker did the same over its descendants. Agent spans such as
invoke_agentoften report the total of their own LLM calls, so those calls were counted twice; on a real Strands trace the root showed about 2× the actual usage. Both now count only spans that have no ancestor reporting usage. Root enrichment also runs before marking, so the root doesn't count the marker's aggregate.Late spans: spans that arrive after their trace's root was processed are enriched on their own, without their ancestors, so a late
chatspan or a called service's spans looked outermost. The processor now remembers, per trace and fortrace_group_cache_ttl, which span ids are covered by a GenAI span or a marker (bounded at 500,000 span ids), and checks there when a parent isn't in the batch.Agents calling agents: an agent that calls another agent over HTTP or RPC in the same trace (
invoke_agent> HTTP client > HTTP server >invoke_agent) gets one mark, on the calling agent, because the called agent has a GenAI ancestor. Edge case: if the called service's spans are flushed aftertrace_flush_intervalwithout the caller's spans, the called agent is marked too, and Agent Traces lists it as a separate row. The same happens if that service marks its own span with the SDK. This is documented in the README.Why mark the span instead of copying
gen_ai.operation.nameto the root: copying would make the HTTP span look like a GenAI span (it would show up as an extra Agent span), the trace row would still have no input or output, and late roots would stay hidden. Datadog and OpenInference also treat the outermost GenAI span as the agent trace; details in the issue comment.Related:
mark_agent_root()/markAgentRoot(): feat: add mark_agent_root() to mark a trace's agent root span genai-observability-sdk-py#48, feat: add markAgentRoot() to mark a trace's agent root span genai-observability-sdk-ts#88Issues Resolved
Resolves #7191
Testing
New
GenAiAgentRootTest:invoke_agentis marked, and it gets the summed tokens without double counting the root;chatarriving after its agent, a called service arriving after its caller, and a span below a source-marked span are not marked, both through the helper and through the processor;invoke_agentthat reports the total of its chats, and a nested sub-agent that does, are each counted once, on the marker and on the root../gradlew :data-prepper-plugins:otel-trace-raw-processor:testplus checkstyle pass (53 tests, JDK 17).End to end on observability-stack, with a Data Prepper image built from this branch, OpenSearch 3.9.0, and a FastAPI app instrumented with OTel and the GenAI Python SDK:
POST /runs>invoke_agent>chat,execute_tool>chatinvoke_agentmarked, tokens 150/30. Root not marked; root totals 150/30.There are 4 spans with missing trace groups). The late root has no enrichment, butinvoke_agentis marked with 150/30.Replay of a real Strands on Bedrock AgentCore trace (389 spans: an outer
invoke_agent, a nested sub-agent underexecute_tool, and calls to other services whose parent spans weren't exported):invoke_agentis marked, both with the trace root present and with the root removed. In the second case all 388 spans are flushed as orphans and the marker still applies;Check List
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.