Skip to content

fix(acp): forward W3C trace-context headers to downstream agents - #396

Merged
NiteshDhanpal merged 3 commits into
mainfrom
agentex-obs
Aug 5, 2026
Merged

fix(acp): forward W3C trace-context headers to downstream agents#396
NiteshDhanpal merged 3 commits into
mainfrom
agentex-obs

Conversation

@NiteshDhanpal

@NiteshDhanpal NiteshDhanpal commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Problem

filter_request_headers (agent_acp_service.py) only forwards x-* headers to the downstream ACP server, so it silently strips W3C traceparent/tracestate/baggage. That detaches the downstream agent's observability trace — and the Temporal workflow/activity it signals — from the ingress trace, so a request can't be followed from the API call (task/create / event/send) to the agent's work. The agent's activity shows up in Tempo as a fresh root with no link back to the gateway.

Verification (infra-staging, rocket-mock-async)

  • Confirmed the agent's business↔obs correlation + the Temporal OTel interceptor already stitch workflow → activity → egp/db into one trace.
  • Confirmed the only gap is this header strip: the control plane forwards the RPC to the agent's ACP server but drops traceparent, so the agent starts a fresh trace.

Fix

Allow the three W3C trace-context headers (traceparent, tracestate, baggage) through the forward allowlist. They are neither hop-by-hop nor sensitive. With this, event/send (gateway) → agent ACP → workflow → activity → egp/db becomes one continuous trace.

Tests

  • Existing header-filter test still passes.
  • Added test_forwards_w3c_trace_context_headers pinning the new behavior.
  • ruff clean.

🤖 Generated with Claude Code

Greptile Summary

The PR completes W3C trace-context propagation from ingress requests to downstream ACP operations.

  • Allows traceparent, tracestate, and baggage through the safe-header filter.
  • Extracts trace context directly from the inbound request for every downstream ACP operation.
  • Adds focused tests for filtering and case-insensitive extraction.

Confidence Score: 5/5

The PR appears safe to merge.

The previously reported trace-propagation gap is resolved because every downstream ACP operation obtains headers through get_headers, which now extracts W3C context from the request-bound inbound headers.

Important Files Changed

Filename Overview
agentex/src/domain/services/agent_acp_service.py Adds case-insensitive W3C header allowlisting and merges ingress trace context into headers used by every downstream ACP operation, resolving the previously reported propagation gap.
agentex/tests/unit/services/test_agent_acp_service.py Adds focused coverage for forwarding, filtering, case-insensitive extraction, and empty trace-header inputs.

Sequence Diagram

sequenceDiagram
    participant Client
    participant AgentEx as AgentEx ingress
    participant ACP as Downstream ACP agent
    participant Temporal
    Client->>AgentEx: Request with traceparent/tracestate/baggage
    AgentEx->>AgentEx: Extract and filter trace context
    AgentEx->>ACP: JSON-RPC with trace-context headers
    ACP->>Temporal: Start workflow/activity in continued trace
Loading

Reviews (3): Last reviewed commit: "Merge branch 'main' into agentex-obs" | Re-trigger Greptile

filter_request_headers only allowed x-* headers through the forward to the
downstream ACP server, silently stripping traceparent/tracestate/baggage. That
detached the agent's observability trace (and the Temporal workflow/activity it
signals) from the ingress trace, so a request couldn't be followed from the API
call to the agent's work. Allow the three W3C trace-context headers (not
sensitive, not hop-by-hop) through the forward.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@NiteshDhanpal
NiteshDhanpal requested a review from a team as a code owner August 5, 2026 21:08
Comment thread agentex/src/domain/services/agent_acp_service.py
Greptile P1: the allowlist change only helped callers that thread
request_headers through get_headers() — i.e. event/send. task/create, message,
streaming and cancel call get_headers(agent) with no request_headers, so
filter_request_headers received nothing and traceparent was still dropped,
leaving those downstream traces detached.

get_headers now always extracts inbound W3C trace-context from self._request
(which it already reads for delegation headers) and forwards it regardless of
request_headers, so every operation continues the ingress trace.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@NiteshDhanpal

Copy link
Copy Markdown
Contributor Author

Addressed the Greptile P1 (trace propagation incomplete) in 2b65058.

The allowlist change alone only helped event/send — it's the one caller that threads request_headers into get_headers(). task/create, message, streaming and cancel call get_headers(agent) with no request_headers, so filter_request_headers received nothing and traceparent was still dropped for those.

Fix: get_headers now always extracts inbound W3C trace-context (traceparent/tracestate/baggage) from self._request.headers (which it already reads for delegation headers) and forwards it regardless of request_headers — so every downstream operation continues the ingress trace, not just event/send. Added extract_trace_context_headers + unit tests.

@MeeSo-Scale

MeeSo-Scale commented Aug 5, 2026

Copy link
Copy Markdown

what is DD_TRACE_ENABLED in the cluster where this was verified, and in prod? If it differs, this fix silently won't apply where it matters most.

-- My bad just saw the description

@NiteshDhanpal

Copy link
Copy Markdown
Contributor Author

what is DD_TRACE_ENABLED in the cluster where this was verified, and in prod? If it differs, this fix silently won't apply where it matters most.

Good point — the environment I'm testing on doesn't have ddtrace enabled.. For envs where it is enabled, this will just be a no-op. Still working through the end-to-end testing for trace propagation and trace correlation for both sync and async paths; the goal is to provide a paved migration path from ddtrace to LGTM for such services

@MeeSo-Scale MeeSo-Scale left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM -- nit - forward_task_to_acp passes no headers, so filter_request_headers never runs on the create path

@NiteshDhanpal

Copy link
Copy Markdown
Contributor Author

Re: the create-path nit (forward_task_to_acp passes no headers → filter_request_headers never runs on create) — accurate, and it's covered by 2b65058.

filter_request_headers indeed still doesn't run on the create path (create intentionally doesn't forward arbitrary client x-* headers). But trace-context no longer depends on it: get_headers now unconditionally extracts traceparent/tracestate/baggage from self._request.headers (via extract_trace_context_headers) and forwards them regardless of request_headers. So create_task → get_headers propagates trace context on the create path too — same fix that resolved the P1. No further change needed.

}


class TestExtractTraceContextHeaders:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The new tests cover the two helpers, but the behavior this PR exists for is that get_headers forwards trace context even when the caller passes no request_headers (task/create, message, streaming, cancel). Consider one test that sets mock_request.headers = {"traceparent": "...", "baggage": "..."} and asserts await agent_acp_service.get_headers(sample_agent) includes them, similar to test_get_headers_server_request_id_wins_over_passthrough. That pins the actual wiring rather than just the helper.

# message, streaming and cancel (which call get_headers(agent) with no
# request_headers) would drop traceparent and the downstream agent would
# start a detached trace. The inbound headers are on self._request.
inbound_headers = dict(self._request.headers) if getattr(self, "_request", None) is not None else {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: _request is always assigned in __init__, and get_delegation_headers() two lines below dereferences self._request.state unconditionally, so this getattr guard never actually protects anything. Simpler to write dict(self._request.headers) directly.

@stephen-wang24 stephen-wang24 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lg

@NiteshDhanpal
NiteshDhanpal merged commit e5f13ba into main Aug 5, 2026
46 checks passed
@NiteshDhanpal
NiteshDhanpal deleted the agentex-obs branch August 5, 2026 22:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants