-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(telemetry): link daemon HTTP request spans to inbound W3C traceparent #9391
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
chiga0
merged 10 commits into
QwenLM:main
from
chiga0:feat/telemetry-daemon-http-traceparent
Aug 19, 2026
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
5d21a17
feat(telemetry): link daemon HTTP request spans to inbound W3C tracep…
e035931
fix(telemetry): guard inbound traceparent sampling and align W3C fall…
2ee0d77
chore(vscode): regenerate companion NOTICES.txt for @opentelemetry/core
b0d1512
fix(telemetry): lazy-load OTel core fallback propagator behind SDK init
52d572c
feat(telemetry): carry inbound trace id into daemon access log with t…
3616171
fix(telemetry): unify _meta/HTTP sampling and repair build export
e409fdb
fix(telemetry): align log-path traceparent parsing and emit traceId i…
258a2ea
fix(telemetry): move inbound trace id getter out of the middleware mo…
4471c0f
fix(telemetry): capture inbound trace id pre-auth under a dedicated s…
a4f66bd
test(telemetry): pin the trace id seam through the context module getter
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
142 changes: 142 additions & 0 deletions
142
docs/design/2026-08-18-daemon-http-inbound-trace-context.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| # Daemon HTTP inbound trace context | ||
|
|
||
| ## Motivation | ||
|
|
||
| The daemon already _propagates_ trace context outbound: prompt requests carry a | ||
| `traceparent` inside JSON-RPC `_meta`, and the daemon extracts it to parent its | ||
| bridge spans (`extractDaemonTraceContext`). The HTTP surface, however, only | ||
| _records_ request spans — every `qwen-code.daemon.request` span starts a new | ||
| trace. An HTTP caller that forwards the standard W3C `traceparent` header | ||
| (corporate proxies, OTel-instrumented clients, ACP gateways) gets no linkage: | ||
| the server-side span cannot be joined back to the caller's trace, so | ||
| cross-service debugging falls back to timestamps. | ||
|
|
||
| W3C Trace Context extraction at the HTTP server edge is standard | ||
| `SpanKind.SERVER`-adjacent behavior per the OTel HTTP semantic conventions, and | ||
| the plumbing already exists: `withDaemonSpan` accepts an explicit | ||
| `parentContext`. | ||
|
|
||
| ## Design | ||
|
|
||
| 1. **Core** (`daemon-tracing.ts`): the existing `_meta` extraction logic | ||
| (global `propagation.extract` first, then a direct | ||
| `W3CTraceContextPropagator` instance as fallback, so acceptance rules — | ||
| future traceparent versions, `tracestate`, all-zero ids — are identical | ||
| with and without a registered global propagator) moves into a shared | ||
| `contextFromTraceparentValues` helper. A new | ||
| `extractDaemonHttpTraceContext(headers)` reads `traceparent`/`tracestate` | ||
| from a Node-style (lowercased) header object and reuses that helper. | ||
| `DaemonRequestSpanOptions` gains an optional `parentContext` passed straight | ||
| through to `withDaemonSpan`. | ||
| 2. **Serve middleware**: `daemonTelemetryMiddleware` extracts from | ||
| `req.headers` per request (fail-closed to `undefined`, telemetry never | ||
| affects handling) and passes the context only when extraction succeeded — | ||
| requests without a valid header keep the exact current span shape. | ||
| Span-context extraction is gated on `isTelemetrySdkInitialized()`, so | ||
| telemetry-off deployments pay no OTel machinery on the hot path — only the | ||
| single-regex trace-id capture described under Log correlation — and a | ||
| present-but-invalid header emits a debug daemon log | ||
| (`qwen-code.daemon.traceparent.invalid`) so a rejected header is | ||
| diagnosable from daemon logs alone. | ||
|
|
||
| Note the whole subtree relocates with the request span, not just | ||
| `daemon.request` itself: session-subprocess spans reached via `_meta` | ||
| (prompt / model / tool) also join the caller's trace, so anything | ||
| aggregating or alerting by `traceId` sees session-side spans change | ||
| ownership too. | ||
|
|
||
| ## Log correlation | ||
|
|
||
| Daemon log lines already carry a `[trace_id=… span_id=…]` prefix when written | ||
| inside an active recording span (`getActiveTraceContext`, #9084). Telemetry | ||
| on, this PR closes the loop end to end: the request span parents to the | ||
| caller's trace, so every daemon log line for that request — including the | ||
| access log's `request completed` — is prefixed with the caller's trace id. | ||
| (The access log's `finish` listener reads the active span through the same | ||
| AsyncLocalStorage propagation as the logger, so ordering of the two `finish` | ||
| listeners is irrelevant for the prefix.) | ||
|
|
||
| Telemetry off — the default — there is no span, so the prefix never fires. | ||
| A separate lightweight path keeps the log-based join alive with no telemetry | ||
| config and no trace backend: the middleware parses the `traceparent` header | ||
| with a plain regex (`extractInboundTraceId`, acceptance mirroring the vendored | ||
| W3C propagator — same shape/all-zero/`ff` rejections, single optional | ||
| leading/trailing whitespace, trailing extension fields above version `00` — | ||
| so a header either joins on both paths or neither; `tracestate` stays in the | ||
| propagator path since a log line only needs a plausible trace id) and stores | ||
| the trace id on the per-response telemetry context (the same | ||
| symbol the workspace hash uses). The access log's `finish` callback reads it | ||
| back and emits it as the camelCase `traceId` field of `request completed` — | ||
| distinct from the logger's reserved snake_case `trace_id` prefix keys, so a | ||
| caller cannot spoof the span-derived prefix. The camelCase field is captured | ||
| in both telemetry modes whenever a valid header parses, so one log query | ||
| shape works for every deployment; with telemetry on the snake_case span | ||
| prefix carries the same id redundantly. The field is omitted, not | ||
| empty, when no valid header is present, and every step stays fail-closed | ||
| (telemetry must not affect request handling). | ||
|
|
||
| ## Sampling policy | ||
|
|
||
| The caller's `sampled` bit is not adopted verbatim on the HTTP path. Under | ||
| the default `parentbased_always_on` sampler (the daemon SDK configures no | ||
| sampler), a remote unsampled parent delegates to `AlwaysOffSampler` and would | ||
| silently delete the request span, everything under `next()`, and — via the | ||
| `_meta` forwarding — the session-subprocess spans. `sampled=0` is simply the | ||
| caller's head-based ratio sampling, so inbound HTTP parents force | ||
| `TraceFlags.SAMPLED` through the same `shouldForceSampled()` decision matrix | ||
| as the synthetic session root: `parentbased_*` defaults and `always_on` force | ||
| sampling; `parentbased_always_off` honors the operator's opt-out; | ||
| non-parentbased samplers (e.g. `traceidratio`) keep the caller's flags and | ||
| decide per span. The `_meta` path applies the same forcing: for the | ||
| in-process bridge (daemon → subprocess) it is a no-op — that parent is our | ||
| own span, already SAMPLED under this policy — while direct ACP clients can | ||
| also attach `_meta` with a caller-controlled `sampled=0`, which is external | ||
| input exactly like the HTTP header and gets the same protection. | ||
|
|
||
| ## Non-goals | ||
|
|
||
| - No new span kinds or attributes: existing `qwen-code.daemon.request` spans | ||
| stay `SpanKind.INTERNAL` with the same attributes; only the parent link | ||
| changes when a valid header is present. | ||
| - No `traceparent` _response_ injection and no W3C `tracingresponse` support. | ||
| - No new sampling configuration surface: the inbound policy reuses the | ||
| existing `shouldForceSampled()` matrix (see above); the SDK's own sampler | ||
| remains the only sampler authority. | ||
|
|
||
| ## Alternatives considered | ||
|
|
||
| - Changing the request span to `SpanKind.SERVER` per HTTP semconv: **known | ||
| gap** — `qwen-code.daemon.request` is the daemon's only SERVER-adjacent | ||
| span (`HttpInstrumentation` never patches the server side here because the | ||
| SDK loads lazily), so backends deriving service topology / RED metrics | ||
| from SERVER spans (Tempo service-graph, ARMS) will not recognize the | ||
| daemon as a service inside the caller's trace. Switching would mutate the | ||
| shape of every existing daemon.request span and can shift backend | ||
| grouping; deferred as a follow-up. | ||
| - Extracting inside the core `withDaemonRequestSpan` from a raw header bag: | ||
| rejected because core request-span options are transport-primitive today; | ||
| the middleware is the only place that knows the carrier is HTTP headers. | ||
|
|
||
| ## Testing | ||
|
|
||
| - Unit: header extraction (valid / absent / malformed / all-zero ids / array | ||
| value / version `ff` / version `00` with extension field / future version | ||
| `01` / inbound `tracestate`), request-span parenting through | ||
| `withDaemonRequestSpan`, the sampled-flag decision matrix (default forced, | ||
| `parentbased_always_off` and `traceidratio` verbatim, `_meta` path forced | ||
| under the default sampler and verbatim on opt-out), middleware pass-through | ||
| (present vs omitted key, telemetry-off | ||
| skip, rejected-header debug log), and a type-level guard keeping | ||
| `parentContext` on `DaemonRequestSpanOptions`. | ||
| - Unit (telemetry-off log join): `extractInboundTraceId` (valid / absent / | ||
| malformed / all-zero ids / version `ff` / array value / future version, | ||
| plus no-propagator-needed in the fresh-module state), middleware capture | ||
| (telemetry off with and without a header, telemetry on emitting the | ||
| camelCase field alongside the span prefix), | ||
| handler-resolved context initialization not clobbering the stored id), and | ||
| the access log emitting / omitting the `traceId` field. | ||
| - Dry run: `serve` with `QWEN_TELEMETRY_OUTFILE`, one curl with a fixed | ||
| `traceparent` — exported span must share the header's traceId and parent to | ||
| its spanId; a control request without the header must stay on its own trace. | ||
| With telemetry disabled, the same curl must still log | ||
| `request completed` with `traceId` matching the header. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Qwen Team | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import type { Response } from 'express'; | ||
|
|
||
| // This module must stay import-light: the access log (inside the serve | ||
| // fast-path's pre-listen static closure) reads the captured trace id from | ||
| // here, so it cannot reach the telemetry middleware's core import graph. | ||
| export interface DaemonTelemetryResponseContext { | ||
| workspaceCwd?: string; | ||
| } | ||
|
|
||
| export const daemonTelemetryResponseContext = Symbol( | ||
| 'daemonTelemetryResponseContext', | ||
| ); | ||
|
|
||
| export type TelemetryResponse = Response & { | ||
| [daemonTelemetryResponseContext]?: DaemonTelemetryResponseContext; | ||
| }; | ||
|
|
||
| // The captured caller trace id lives under its own symbol: the presence of | ||
| // the telemetry response context doubles as the opt-in gate for | ||
| // handler-resolved workspace attribution (see setDaemonTelemetryWorkspace), | ||
| // so capturing a trace id must never create it — otherwise a caller merely | ||
| // sending a traceparent header would silently change span attribution. | ||
| export const daemonInboundTraceIdContext = Symbol( | ||
| 'daemonInboundTraceIdContext', | ||
| ); | ||
|
|
||
| export type InboundTraceIdResponse = Response & { | ||
| [daemonInboundTraceIdContext]?: string; | ||
| }; | ||
|
|
||
| /** | ||
| * The caller trace id captured from a valid inbound `traceparent` header, | ||
| * in both telemetry modes. The access log reads it so a request's log line | ||
| * still joins with the caller's logs (or trace backend) with no daemon-side | ||
| * telemetry at all. | ||
| */ | ||
| export function getDaemonTelemetryInboundTraceId( | ||
| res: Response, | ||
| ): string | undefined { | ||
| try { | ||
| return (res as InboundTraceIdResponse)[daemonInboundTraceIdContext]; | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.