Skip to content

[BUG] otel_traces GenAI enrichment doesn't propagate gen_ai.operation.name to root, so agent traces with an HTTP root are hidden in Agent Traces #7191

Closed

Description

@kylehounslow

Describe the bug

GenAI root enrichment in otel_traces (#6548) copies four string attributes to the root span, and gen_ai.operation.name isn't one of them. The OpenSearch Dashboards Agent Traces view only lists a trace if its root has gen_ai.operation.name, so any agent running behind an HTTP handler is missing from the Traces and Sessions tabs. Its spans still show up in the Spans tab.

Root cause

Propagated keys: PROPAGATED_STRING_KEYS is gen_ai.system, gen_ai.provider.name, gen_ai.agent.name and gen_ai.request.model (GenAiEnrichmentHelper.java#L32-L42). It's been that way since #6548. The README says gen_ai.operation.name is propagated too (README.md#L26), so docs and code disagree. The only operation.name assertions in GenAiEnrichmentTest cover vendor value normalization, which is why this wasn't caught.

What the UI filters on: the Traces tab uses prepareRootSpansQuery, which appends where parentSpanId = "" AND isnotnull(attributes.gen_ai.operation.name) (tab_queries.ts#L12-L18). Sessions reuses the same query (register_tabs.ts#L32-L45, L68-L76). With an HTTP root, the root has an empty parent but no operation name, and the spans that have an operation name all have a parent. Nothing matches.

To reproduce

A Strands agent served by FastAPI, instrumented with OTel, sent through otel_traces. One trace, trimmed:

POST /runs                        parentSpanId=""
└── invoke_agent observability    gen_ai.operation.name=invoke_agent
    ├── execute_event_loop_cycle  (5 cycles)
    │   ├── chat                  gen_ai.operation.name=chat
    │   └── execute_tool ...      gen_ai.operation.name=execute_tool
    └── ...

Root span as indexed:

"name": "POST /runs",
"parentSpanId": "",
"attributes": {
  "gen_ai": {
    "request": { "model": "us.anthropic.claude-sonnet-4-6" },
    "agent": { "name": "observability" },
    "system": "strands-agents",
    "provider": { "name": "aws" }
  }
}

The root has no gen_ai.operation.name, while 18 of its descendants do.

Expected behavior

The root carries gen_ai.operation.name, so the trace lists in Agent Traces regardless of what the root span is.

Proposed fix

  • Operation name: add gen_ai.operation.name to the propagated keys, taken from the outermost GenAI span (here invoke_agent). First-child-wins could land on chat or execute_tool depending on span order in the batch, which would be wrong for the trace as a whole.
  • Tests and docs: add a test with a non-GenAI root, and make the README match what's propagated.

Environment

  • Data Prepper with feat: Add GenAI agent trace enrichment to otel_traces processor #6548 (2.14.0+). Code links are main at 7bae96e.
  • OpenSearch Dashboards links are main at 21711129. I didn't confirm the UI build the trace was viewed on, but the behavior matches that query.
  • Source: Strands Agents (Python), OTel Python SDK 1.45.1, opentelemetry.instrumentation.fastapi 0.66b1.

Additional context

Enrichment only sees children that land in the same batch as the root. A request that outlives trace_flush_interval (default 180s) gets its children flushed as orphans before the root arrives, so the root isn't enriched at all. That's separate from this bug, but long agent runs hit it, so it's worth knowing when reproducing.

Activity

  1. ps48 commented on Oct 15, 2026

    @ps48
    Member

    Thanks for the detailed report. Confirmed on main: PROPAGATED_STRING_KEYS doesn't include gen_ai.operation.name, and the README says it does.

    Before fixing it as proposed, I looked at how the GenAI semantic conventions and other tools handle agent traces with a non-GenAI root, and I'd like to suggest a slightly different fix.

    Why not copy gen_ai.operation.name to the root

    • Spans tab: the Agent Traces Spans tab lists every span that has gen_ai.operation.name, so POST /runs would show up there as an extra Agent span.
    • Trace row: the Traces row would still be POST /runs with empty input and output, because the prompts and responses are on the invoke_agent span.
    • Late roots: root enrichment only works when the root and its children land in the same batch. As you noted, a run longer than trace_flush_interval flushes the children first, so the root never gets enriched.

    What the spec and other tools do

    • OTel GenAI semconv says nothing about trace roots or propagation. In-process invoke_agent spans (kind INTERNAL) are expected to sit under whatever span is active, such as an HTTP SERVER span. The closest idea is the outermost agent or workflow: #385 (gen_ai.main_agent.name, open), which replaced #355 (gen_ai.root_operation.name, closed).
    • Datadog makes each top-level GenAI span its own trace: "When an APM trace's top-most span is not a gen_ai span (for example, an HTTP handler...), Agent Observability produces a separate Agent Observability trace for each top-level gen_ai span".
    • OpenInference can re-root AI spans whose parent is a non-AI span, and tags them AGENT "so it is kept as the trace root".
    • Phoenix and Honeycomb keep the OTel root. Langfuse and Braintrust can filter non-AI spans at export, which leaves the GenAI span as the root.

    So the common idea is that the outermost GenAI span is the agent trace, not the OTel root.

    Proposal

    1. Data Prepper (this issue): in otel_traces, mark the outermost GenAI span in each trace (a span with gen_ai.operation.name and no GenAI ancestor) with derived.gen_ai.is_root = true, and aggregate token usage onto it.
      • GenAI root: if the trace root is already a GenAI span, it is left as it is.
      • Precedence: root propagation of gen_ai.agent.name, gen_ai.request.model and the other keys takes values from the outermost GenAI span instead of whichever child comes first.
      • Late roots: this also works when the root arrives late, because flushed children still carry their own parent chain.
      • Docs: fix the README, and document the new field and the precedence.
    2. OpenSearch Dashboards Agent Traces: list agent traces with (parentSpanId = "" AND gen_ai.operation.name exists) OR derived.gen_ai.is_root = true. The row is then the agent span, with the right name, kind, input/output and latency. The Spans tab and Sessions are unchanged.
    3. GenAI SDKs (py, ts): add a mark_agent_root() helper for cases the structure can't settle, for example a service's agent entry point. Users without the SDK can set the attribute themselves.

    Known trade-offs

    • Parallel agents: two top-level agents under one HTTP request show as two rows. Datadog does the same, and both rows open the full trace.
    • Latency: the row's latency is the agent's duration, not the whole HTTP request.
    • Duplicates: if a GenAI root arrives late, the trace could match twice. Agent Traces keeps one row per traceId, preferring the true root.

    Happy to put up the PRs if this direction sounds good. Feedback on the attribute name is especially welcome.

  2. ps48 commented on Oct 15, 2026

    @ps48
    Member

    Draft PRs for the approach above:

    I tested them together end to end on observability-stack, using a FastAPI app with a real HTTP SERVER root:

    • Single agent: invoke_agent is listed with its input, output and tokens, and the tokens are not double counted on the root.
    • Parallel agents: they show as separate rows.
    • GenAI roots: unchanged.
    • Late roots: a root that arrives after trace_flush_interval, after its children were flushed as orphans, is still listed through the marked agent span.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions