Skip to content

Inject traceparent on egress so a trace survives the service hop - #95

Merged
brentrager merged 2 commits into
mainfrom
feat/traceparent-injection
Aug 15, 2026
Merged

Inject traceparent on egress so a trace survives the service hop#95
brentrager merged 2 commits into
mainfrom
feat/traceparent-injection

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

The number that motivates this

In 3 hours of production traffic: 34,961 single-service traces and 4 multi-service ones.

Not because services don't call each other — because nothing injects W3C trace context on the way out. Every hop starts a new root, so a request crossing three services appears as three unrelated traces, and "show me this request end to end" cannot be answered.

Why here

The inbound half was already right: api-prime extracts traceparent, and the observability SDK ships a reqwest middleware that injects — with zero consumers. The missing piece is the client Rust services actually use for outbound calls, which is this one.

Injection happens at do_single_request, so retries and redirects each carry a current traceparent instead of reusing a stale one.

Opt-in

Behind an optional otel feature. Default builds keep the dependency-free client; callers that opt in get propagation with no code change.

One thing I checked instead of believing

Context resolution is tracing-first, then opentelemetry::Context::current(). A subagent told me that second read would be dead code under tracing-opentelemetry. I tested it — both are valid (tracing_span_context_valid=true otel_current_valid=true) — so the fallback stays for callers using the OTel API directly without a tracing subscriber.

Guards, tested at the wire

  • an invalid/absent span context injects nothing (no 00-000…-00 headers)
  • a caller-set traceparent is never overwritten

3 wire-level tests; 110 tests pass; clippy clean on both feature sets. Tracked in th-fb312d.

…fb312d)

In 3 hours of production traffic: 34,961 single-service traces and 4
multi-service ones. Not because services don't call each other — because
NOTHING injects W3C trace context on the way out. Every hop starts a new root,
so a request crossing three services looks like three unrelated traces and
"show me this request end to end" cannot be answered.

The inbound half was already correct: api-prime EXTRACTs traceparent, and the
observability SDK ships a reqwest middleware that injects. It had zero
consumers. The missing piece is the client Rust services actually use for
outbound calls — this one.

Injects at `do_single_request`, so retries and redirects each carry a current
traceparent rather than reusing a stale one.

Behind an optional `otel` feature: default builds keep the dependency-free
client, and callers that opt in get propagation with no code change.

Context resolution is tracing-first, then `opentelemetry::Context::current()`.
A subagent claimed the second read would be dead code under
tracing-opentelemetry. I tested it rather than believing it — both are valid
(tracing_span_context_valid=true otel_current_valid=true) — so the fallback
stays for callers using the OTel API directly without a tracing subscriber.

Two guards, both tested at the wire:
  - an invalid/absent span context injects NOTHING (no `00-000…-00` headers)
  - a caller-set traceparent is never overwritten

3 wire-level tests; 110 tests pass; clippy clean on both feature sets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3565561

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@smooai/fetch Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

…NET already does it

Same semantics in every language, because a trace that survives one hop and
breaks at the next is not much better than no trace:

  - inject at the SINGLE-REQUEST site, inside the retry/timeout/breaker
    wrappers, so each attempt carries a CURRENT traceparent instead of
    re-sending a stale one
  - never overwrite a caller-supplied traceparent
  - emit NOTHING when there is no valid span context — never `00-000…-00`

Per-language notes worth keeping:

TypeScript (src/fetch.ts, in doGlobalFetch) — builds a COPY of the headers
rather than mutating `init`. Mutating would make attempt 2 see our own header as
"caller-supplied" and pin every retry to a stale traceparent. Optional peer dep
on @opentelemetry/api via a guarded dynamic import; verified the built bundles
keep it external.

Go (go/fetch/client.go, in executeHTTPRequest) — injected AFTER the pre-request
hook, because that hook may replace the whole *http.Request and would silently
discard a header injected before it. Pinned otel v1.38: v1.39+ declare go 1.24
and CI pins 1.23.

Python (_client.py, in _do_request) — copies request kwargs per attempt so
retries re-inject. Optional `[otel]` extra.

.NET — NO production change. HttpClient's DiagnosticsHandler already injects
from the current Activity, outside redirects and pooling, and this client keeps
that chain intact. Adding our own would double-inject. Tests pin the behaviour
so a future custom primary handler can't silently drop it; the negative control
(propagation disabled) fails 5 of 7, which is what makes the verdict worth
anything.

Every language is tested at the WIRE — a real local server, asserting the
headers it actually received — not against mocks. A mock that agrees with our
own bookkeeping is precisely the failure mode being fixed here. Each suite was
mutation-checked: remove a guard, watch the specific test fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@brentrager
brentrager merged commit 2c20134 into main Aug 15, 2026
1 check passed
@brentrager
brentrager deleted the feat/traceparent-injection branch August 15, 2026 00:00
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.

1 participant