feat(telemetry): client-side HTTP span + opt-in W3C traceparent propagation (#4384) (#4390)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

* feat(telemetry): propagate W3C traceparent on outbound LLM requests

Part 1 of #4384 (sub-issue of #3731 P3 deeper observability).

Today qwen-code's only OTel instrumentation is `HttpInstrumentation`,
which only patches Node's `http`/`https` modules. The `openai` and
`@google/genai` SDKs use `globalThis.fetch` (undici), so outbound LLM
requests carry no `traceparent` header and trace context dies at the
qwen-code process boundary.

Adds `@opentelemetry/instrumentation-undici@0.14.0` (peer-compatible
with the installed `@opentelemetry/instrumentation@0.203.0`) and wires
it into `initializeTelemetry()` next to the existing
`HttpInstrumentation`. Default propagator (W3C tracecontext + baggage)
remains unchanged — no explicit `textMapPropagator` needed.

`ignoreRequestHook` skips OTLP exporter endpoints to avoid the
classic feedback loop (OTel SDK uses fetch to upload OTLP data; without
the hook each upload would create a span that gets uploaded, infinitely).
Configured `otlpEndpoint` / per-signal endpoints are stripped of trailing
slash and query string for robust prefix matching against undici's
`request.origin + request.path`.

Outbound LLM calls now also produce a client-side HTTP span (separating
network TTFB / transfer time from the existing `api.generateContent`
total-duration span).

Design doc: docs/design/telemetry-outbound-propagation-design.md
(Part A — traceparent; Part B — session id header — lands in a
follow-up PR per the design's split rationale.)

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(telemetry): harden OTLP feedback-loop guard + slim lockfile diff

Review feedback on #4390:

1. CI was failing on npm ci because the lockfile was generated with npm 11
   locally (it sprinkles `peer: true` annotations npm 10 reads differently
   and rejects). Regenerated with npm 10 (matching CI's Node 22.x default),
   so the diff vs main is now 18 lines (the actual instrumentation-undici
   entry) instead of 105 lines of npm-version drift noise.

2. (Copilot inline at sdk.ts:330) `otlpUrlPrefixes` was derived from raw
   Config strings, so a settings.json `"otlpEndpoint": "\"http://...\""`
   (quoted) or trailing `#fragment` would silently miss the prefix match
   and reintroduce the feedback loop the hook exists to prevent. Replaced
   the regex-based suffix trim with a WHATWG URL parser:
   - strips ?query, #fragment, trailing slash
   - trims symmetric ASCII quotes a user may have placed in settings.json
   - falls back to safe suffix trimming if URL parsing fails (misconfigured
     endpoint still gets SOME protection)

3. (CodeQL inline) Replaced the `/\?.*$/` regex in ignoreRequestHook with
   `indexOf('?')`/`indexOf('#')` slicing for ReDoS hygiene. The regex was
   linear in practice but flagged as polynomial — using indexOf removes
   the ambiguity and is arguably simpler.

Added 3 tests in sdk.test.ts covering the new normalizations (#fragment
on incoming path, quoted endpoint, #fragment on configured endpoint).

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* feat(telemetry): propagate X-Qwen-Code-Session-Id on outbound LLM requests

Part 2 of #4384. Stacks on top of PR #4390 (traceparent via undici).

Adds a product-namespaced HTTP header X-Qwen-Code-Session-Id to every
outbound LLM request when telemetry is enabled, so server-side ingestion
can correlate observed requests with qwen-code session metric/log records.
Pattern matched from claude-code (X-Claude-Code-Session-Id, verified at
src/services/api/client.ts:108 in their open-source repo).

Critical design decision (design doc section 4.3): the OpenAI / Anthropic
providers use a per-request fetch wrapper rather than the SDK defaultHeaders
option, because content-generator SDK clients are constructed once and NOT
recreated on /clear-triggered session resets (Config.resetSession updates
this.sessionId but the contentGenerator keeps using the stale header value).
Reading config.getSessionId() from inside the wrapper at request time gives
the live value.

Gemini provider uses static httpOptions.headers — @google/genai HttpOptions
interface does not expose a fetch hook (only headers, baseUrl, apiVersion,
timeout, extraParams). This is a known limitation: after session reset,
Gemini X-Qwen-Code-Session-Id stays stale until the contentGenerator is
recreated. Documented in telemetry.md and the design doc section 8.6;
spans/logs continue to carry the live session id for trace/log correlation.
Lazy-invalidate fix is a follow-up sub-issue.

Header is omitted when telemetry is disabled OR when getSessionId returns
an empty string (some HTTP middleware rejects empty header values).

Integration sites:
- packages/core/src/core/openaiContentGenerator/provider/default.ts
  (base class — automatically covered by deepseek/minimax/mistral/
  modelscope/openrouter subclasses; openrouter calls super.buildHeaders)
- packages/core/src/core/openaiContentGenerator/provider/dashscope.ts
  (overrides buildClient — must be touched separately; QwenContentGenerator
  inherits via this provider)
- packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts
- packages/core/src/core/geminiContentGenerator/index.ts (factory function,
  not the GeminiContentGenerator class — no signature change)

End-to-end verification (local HTTP server in tmux):
  PASS: traceparent + X-Qwen-Code-Session-Id on every LLM request
  PASS: session id refreshes after simulated /clear (staleness regression
        guarded by llm-correlation-fetch.test.ts)
  PASS: OTLP upload traffic not traced (no feedback loop — PR A
        ignoreRequestHook working)

Robot generated with Qwen Code https://github.com/QwenLM/qwen-code

* fix(telemetry): R2 review fixes — critical correctness + tsc + boundary safety

Adopts 7 review findings from wenshao on #4390 (+ duplicates from now-closed
#4393). Critical bugs first, polish second.

CRITICAL:

1. tsc TS2322 — wrapper return type incompatible with Anthropic SDK Fetch.
   `typeof fetch` (Node WHATWG, 2 overloads) is not structurally assignable
   to Anthropic's narrower `Fetch = (input: RequestInfo, init?) => ...`,
   even though they're call-compatible at runtime. Make wrapper generic
   `<TFetch extends FetchLikeLoose>` so callers preserve their exact fetch
   signature; cast the Anthropic call site through `unknown` with a comment
   explaining why.

2. tsc TS2352 / TS2493 — `baseFetch.mock.calls[0]![1] as RequestInit` was
   out-of-bounds when wrapped was called with no init arg. Replaced with a
   `makeFetchMock()` helper returning typed accessors.

3. normalizeOtlpPrefix catch fallback was DANGEROUS — a config of `"http"`
   produced prefix `"http"` which `startsWith`-matched every outbound HTTP
   request → silently disabled ALL instrumentation (no client spans, no
   correlation header — defeats the entire feature). Fixed: catch returns
   undefined + diag.warn. Misconfigured endpoint loses its feedback-loop
   guard (acceptable) instead of disabling all guards (catastrophic).

4. `url.startsWith(prefix)` matching was NOT boundary-safe — port collision
   (`:4318` matches `:43180`), hostname suffix collision (`otlp.example.com`
   matches `otlp.example.com.evil.net`), path-segment collision (`/v1`
   matches `/v1foo/x`). Replaced with origin-equality + path-prefix +
   boundary-char check (next char must be `/`, `?`, `#`, or end-of-string).

5. HttpInstrumentation also lacked the OTLP feedback-loop guard. The OTLP
   HTTP exporter (`@opentelemetry/exporter-trace-otlp-http`) uses node:http
   (patched by HttpInstrumentation, NOT undici). Without this, every OTLP
   upload batch creates a parasitic client span → feedback loop. Added
   `ignoreOutgoingRequestHook` that reuses the same `matchesOtlpPrefix` /
   `stripPathSuffix` helpers as the undici instrumentation.

SAFETY:

6. Request input + undefined init dropped the Request's own headers
   (Authorization etc.) because `new Headers(undefined)` → `{...init, headers}`
   replaced them with just our session header. Fix: when input is a Request
   and init.headers is unset, seed from input.headers before adding ours.

7. Wrapped fetch had no try/catch — a throwing Config getter or Headers
   constructor would propagate as TypeError and break the LLM request path.
   Wrapped header construction in try/catch; on failure, fall through to
   baseFetch with original init (no header) + diag.warn. Telemetry must
   never break the model call.

COVERAGE:

- 3 new sdk.test.ts boundary tests (port/host/path)
- 1 new sdk.test.ts normalizeOtlpPrefix catch-branch coverage
- 1 new sdk.test.ts HttpInstrumentation OTLP guard test
- 1 new sdk.test.ts proxy-mode wrapped-fetch test (default.test.ts)
- 1 new anthropic test asserting wrapped fetch installed on Anthropic SDK
- 2 new llm-correlation-fetch.test.ts (Request-headers preservation + try/catch fall-through)

All 668 tests pass (1 pre-existing Anthropic User-Agent failure on main is
unrelated). tsc clean.

Declined: #10 DRY-refactor of baseFetch extraction across 3 sites — the
duplication was pre-existing (default/dashscope buildClient was already
near-identical), refactoring is a separate cleanup PR not gated by this
feature. Will reply on the thread.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* chore(deps): allow patch updates for @opentelemetry/instrumentation-undici

Switch from exact pin `0.14.0` to `^0.14.0` for consistency with the rest
of the `@opentelemetry/*` deps in this block (all carated).

For 0.x semver, npm treats `^0.14.0` as `>=0.14.0 <0.15.0`, so patch
updates within the 0.14.x line — which are tied to the same
`@opentelemetry/instrumentation@0.203.x` peer — flow in via `npm update`
without requiring a manual package.json edit. A bump across the 0.x
minor (e.g. 0.15.x) would shift the instrumentation peer compatibility
and still requires explicit attention, which the caret correctly blocks.

Per review feedback on #4390 (wenshao).

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* test(telemetry): stub getTelemetryEnabled + getSessionId in Gemini factory tests

The X-Qwen-Code-Session-Id commit added a `staticCorrelationHeaders(gcConfig)`
call inside the Gemini content generator factory. That helper reads
`gcConfig.getTelemetryEnabled()` and `gcConfig.getSessionId()` per request.

Both pre-existing Gemini tests in `contentGenerator.test.ts` build a minimal
partial Config stub via `as unknown as Config` and only stub the methods the
factory used to need. The new call path now hits the unstubbed methods at
runtime, surfacing as `TypeError: config.getTelemetryEnabled is not a function`
on all three CI platforms.

Add the two missing stubs to both test cases. The Gemini factory continues
to ignore the values when telemetry is off — these stubs only have to exist,
not return anything in particular.

Local check ran the full test suite for the four directories `/loop` covers
plus `src/core/contentGenerator.test.ts` itself; all green. Also re-ran the
other test files that build partial Config mocks via the same idiom
(`client.test.ts`, `config.test.ts`, `nextSpeakerChecker.test.ts`,
`content-generator-config.test.ts`) — none exercise the new code path.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(telemetry): R3 review fixes — port + protocol + quote + safety

Four issues found by wenshao reviewing the R2 boundary-safety pass on PR
#4390. All four close gaps where the OTLP feedback-loop guard or the
correlation-header path could fail silently.

1. **Port normalization mismatch** (sdk.ts ignoreOutgoingRequestHook):
   `normalizeOtlpPrefix` builds prefixes via `URL.origin`, which strips
   default ports (`:80` for http, `:443` for https). The hook reconstructed
   request origin manually as `${proto}://${host}${portPart}`, keeping the
   port. Result: prefix `http://collector` (no explicit port) didn't match
   a request to `http://collector:80/v1/traces` because their `.origin`
   differed → guard bypassed → feedback loop. Now the reconstructed origin
   is also routed through `URL` so both sides apply the same default-port
   stripping.

2. **HTTPS proto silent fallback** (sdk.ts ignoreOutgoingRequestHook):
   The `(req.protocol && ...) || 'http'` fallback would silently mis-bucket
   HTTPS requests as HTTP when `req.protocol` was unset, so HTTPS OTLP
   endpoints couldn't match their prefix. Changed to fail open: when proto
   can't be determined, return false (request gets instrumented). Worst
   case is a parasitic client span — observable, recoverable — versus the
   previous unbounded silent feedback loop. Picked fail-open over the bot's
   port-based heuristic because non-standard HTTPS ports break the
   heuristic but not fail-open.

3. **Quote-stripping divergence** (sdk.ts normalizeOtlpPrefix):
   `parseOtlpEndpoint` (line 109) uses `/^["']|["']$/g` which strips
   asymmetric leading/trailing quotes; `normalizeOtlpPrefix` previously
   only stripped symmetric pairs. A settings.json typo like `"value'` would
   let the exporter connect (parseOtlpEndpoint trims) but leave the guard
   returning `undefined` (normalizeOtlpPrefix rejected) → parasitic loop.
   Aligned `normalizeOtlpPrefix` to the same lenient regex.

4. **`staticCorrelationHeaders` missing try/catch** (llm-correlation-fetch.ts):
   `wrapFetchWithCorrelation` already catches all internal exceptions and
   falls through to baseFetch — same "telemetry must never break LLM path"
   contract was missing on the static-headers helper. A throw here would
   propagate up to the Gemini content-generator factory and crash
   content-generator init for the whole session. Wrapped the body in
   try/catch with `diag.warn` fall-through to `{}`.

Tests: added 4 regression tests covering each scenario:
- default-port HTTP request matched against portless prefix (1)
- hook returns false when req.protocol missing on https endpoint (2)
- asymmetric-quoted endpoint normalizes for guard parity (3)
- staticCorrelationHeaders returns {} when config getter throws (4)

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* docs(telemetry): fix misleading "BOTH" wording in wrapFetchWithCorrelation

The comment described the header-seeding logic as merging "BOTH the
init.headers AND the Request's own headers", but the two branches are
mutually exclusive — `new Headers(init?.headers)` runs unconditionally
(empty Headers when init.headers is undefined), and the Request-headers
copy only runs when init.headers is undefined. So in practice it's
either-or, not BOTH.

Reworded to match the actual logic per #4390 review feedback (wenshao).
Behavior unchanged.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(telemetry): strip port from req.host fallback + document undici scope

Two issues found by wenshao reviewing the R3 boundary-safety fixes on PR
#4390.

1. **`req.host` may already include `:port`** (sdk.ts ignoreOutgoingRequestHook):
   When `req.hostname` is absent and `req.host` is the fallback, the value
   may already be `"collector:4318"`. Naively appending `:${req.port}`
   produced `"http://collector:4318:4318"` → `new URL()` rejects → catch
   returns false → silent guard bypass for that request. Currently
   unreachable because `@opentelemetry/otlp-exporter-base` always sets
   `hostname` from WHATWG URL parsing, but the fallback exists in the
   code and must be correct — a future OTLP transport that emits `host`
   without `hostname` would silently trigger the feedback loop. Strip the
   port when falling back; bracketed IPv6 literals like `"[::1]:443"`
   keep their bracketed host intact.

2. **Undici scope honesty** (telemetry.md):
   Previous docs framed the propagation as "outbound LLM requests", but
   `UndiciInstrumentation` actually patches `globalThis.fetch` for the
   whole process — `WebFetch`, MCP clients, IDE extension calls all get
   spans + `traceparent` injection too. Added a "Scope: all fetch() calls,
   not just LLM" subsection covering: (a) trace ID leakage to third-party
   URLs (the user-supplied destinations of `WebFetch` see our trace ID;
   not secret per W3C but worth knowing); (b) non-LLM span volume
   inflating OTLP batches with a workaround tip. Per-destination scoping
   toggle deferred as a follow-up — out of scope for this PR.

Added regression test for the host:port-fallback path. Test exercises
the previously broken combination (hostname absent, host carries port)
through the existing test harness.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* feat(telemetry): scope X-Qwen-Code-Session-Id to first-party hosts by default

Address LaZzyMan's REQUEST_CHANGES review of PR #4390.

The original design injected `X-Qwen-Code-Session-Id` on every outbound
LLM request gated only by `telemetry.enabled`. Review caught that this
broadcasts a stable cross-request client identifier to every configured
third-party provider (OpenAI, Anthropic, OpenRouter, MiniMax, ModelScope,
Mistral, vanilla Gemini, ...), which the claude-code precedent does NOT
justify — claude-code is a first-party Anthropic→Anthropic flow; qwen-code
is an open-source CLI connecting to many providers.

Fix: add a host allowlist with a deliberately narrow default. The header
is now only attached to destinations whose hostname matches:

  dashscope.aliyuncs.com
  dashscope-intl.aliyuncs.com
  *.dashscope.aliyuncs.com
  *.dashscope-intl.aliyuncs.com
  *.alibaba-inc.com
  *.aliyun-inc.com

This is exactly the set where the LLM provider, the upstream telemetry
backend (ARMS Tracing), and qwen-code itself are the same legal entity —
mirroring the first-party claude-code pattern and preserving the real
product value (server-side trace stitching against DashScope) without
exposing the session id to third parties.

Operators with broader correlation requirements override via:

  "telemetry": {
    "sessionIdHeaderHosts": ["*"]                          // restore broadcast
    "sessionIdHeaderHosts": []                              // fully disable
    "sessionIdHeaderHosts": ["api.example.com", "*.foo"]    // custom allowlist
  }

Implementation:

- NEW `telemetry/trusted-llm-hosts.ts`: `DEFAULT_SESSION_ID_HEADER_HOSTS`
  + `matchesTrustedHost(hostname, patterns)` + `extractRequestHost(input)`.
  Pattern syntax is intentionally tiny (bare hostname OR `*.suffix`,
  dot-anchored to reject `evil-alibaba-inc.com` style attacks). Unit-tested
  in dedicated test file including TLD/sub-domain attack vectors.

- `wrapFetchWithCorrelation` (openai + anthropic providers): resolves the
  allowlist at wrap time (Config snapshot), inspects each request's
  destination URL inside `correlationFetch`, falls through to baseFetch
  for non-trusted destinations. Wildcard escape hatch via `["*"]`.

- `staticCorrelationHeaders` (Gemini factory): now takes an optional
  `destinationUrl` and applies the same host gate. The Gemini SDK default
  endpoint `generativelanguage.googleapis.com` is NOT on the default
  allowlist, so vanilla Gemini calls receive no header — matching the
  "first-party only" scope. Operators who put the Gemini SDK on a
  DashScope-compatible endpoint via `baseUrl` get the header naturally.

- `Config.getTelemetrySessionIdHeaderHosts()` getter +
  `TelemetrySettings.sessionIdHeaderHosts` interface field + JSON schema
  entry in `settingsSchema.ts`. Wired through `resolveTelemetrySettings`.

- Defensive optional-chaining + try/catch on the Config getter call at
  wrap time so partial test mocks (or pre-getter Config implementations)
  fall back to the default allowlist rather than crashing buildClient.

Tests: 12 new cases covering host match/skip on default allowlist,
sub-domain handling, TLD-suffix attack rejection, `["*"]` broadcast
override, `[]` full-disable, custom operator allowlist, unparseable
destination (fail closed), and the three Gemini factory paths
(googleapis.com default → omit; DashScope `baseUrl` → inject; custom
allowlist → inject).

Docs updated in `docs/developers/development/telemetry.md` Session
correlation header section, including override examples and the new
Gemini host-gate semantics.

Closes the LaZzyMan REQUEST_CHANGES blocker. The cross-vendor
fingerprint-broadcast failure mode is now opt-in rather than default,
restoring the first-party-only semantics that make the claude-code
precedent applicable.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(telemetry): R5 review fixups — Vertex destination + ["*"] trim + docs

Self-review pass on commit 1c8528a56 (host-scoped session-id header):

1. **Vertex AI destination guessing**
   (geminiContentGenerator/index.ts)
   `@google/genai` routes to `{region}-aiplatform.googleapis.com` (not
   `generativelanguage.googleapis.com`) when `vertexai: true` and no
   `baseUrl`. The previous "guess generativelanguage" default would have
   mis-bucketed Vertex traffic under any operator-supplied allowlist that
   covered the public Gemini endpoint but not the Vertex one. Today
   invisible (both off the default allowlist), but a latent gotcha for
   operators tuning `telemetry.sessionIdHeaderHosts`.
   Fix: pass `undefined` when `config.baseUrl` is unset (fail-closed —
   no header). Operators who want correlation against Google endpoints
   must set `baseUrl` explicitly, which is also the SDK's input for
   destination resolution.

2. **`["*"]` broadcast escape hatch tolerates whitespace**
   (llm-correlation-fetch.ts)
   `[" * "]` (a settings.json hand-edit with a stray space) previously
   silently fell back to "no host matches" — the opposite of operator
   intent. Now `.trim()` before comparing, so common whitespace mistakes
   still trigger broadcast.

3. **Doc note on wrap-time allowlist snapshot**
   (llm-correlation-fetch.ts JSDoc)
   The session id is read live per-request, but `trustedHosts` is
   snapshotted once at `wrapFetchWithCorrelation` call time. Spell this
   out in the JSDoc so a future maintainer doesn't read the live
   `getSessionId()` and assume the allowlist is the same shape.

4. **Defensive test coverage**
   (trusted-llm-hosts.test.ts, llm-correlation-fetch.test.ts)
   Added: extractRequestHost with explicit port / userinfo / query /
   fragment / IPv6 bracket form. Whitespace `[" * "]` broadcast test.
   IPv6 case documents the "bracketed → never matches" behavior is
   intentional fail-closed for the named-host allowlist scope.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* chore: regenerate settings.schema.json for sessionIdHeaderHosts

Lint check `Check settings schema is up-to-date` failed because the
checked-in `packages/vscode-ide-companion/schemas/settings.schema.json`
wasn't regenerated after adding `telemetry.sessionIdHeaderHosts` to
`settingsSchema.ts` in commit 1c8528a56. Regenerated via
`npm run generate:settings-schema`.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* docs(design): update telemetry-outbound-propagation design for R3 host-allowlist scoping

Adds a "修订历史" header table at the top and a new §11 "R3 修订 —
Host-Allowlist Scoping for X-Qwen-Code-Session-Id" capturing what changed
after LaZzyMan's REQUEST_CHANGES review, why, and how. Inline pointers
added at §3.1, §3.2, §4.3, §4.4, §9 (claude-code comparison table) to
point readers at §11 — original prose preserved as a record of the
decision path rather than rewritten in place.

Concretely §11 covers:
- The three-step LazzyMan critique and why R1's "broadcast to all
  providers" was structurally wrong for an open-source multi-provider CLI
- The default allowlist (`DEFAULT_SESSION_ID_HEADER_HOSTS`) and its
  semantic alignment with the DashScope provider detector
- Pattern grammar (bare hostname / `*.suffix` dot-anchored), the
  TLD-suffix attack vectors it rejects, why no regex / port-aware globbing
- `wrapFetchWithCorrelation` host gate, wrap-time vs request-time
  semantics, `[" * "]` whitespace tolerance
- `staticCorrelationHeaders` `destinationUrl` parameter, Gemini factory's
  fail-closed treatment of unset `baseUrl` (avoids the Vertex
  vs `generativelanguage.googleapis.com` ambiguity)
- All R3 file changes mapped to the original §5 file-change list
- Mapping of LazzyMan's three concerns to R3's responses
- §10 future-work additions: `traceparent` per-destination toggle,
  `X-Qwen-Code-Request-Id`, IPv6 allowlist syntax

No code changes; documentation only.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(telemetry): defensive allowlist normalization + positive proxy test

Three issues found by wenshao reviewing the R3 host-allowlist scoping.

1. **[Critical] `broadcastAll` outside safety try/catch**
   (llm-correlation-fetch.ts wrapFetchWithCorrelation)
   The try/catch only fires when `getTelemetrySessionIdHeaderHosts()`
   throws. If it returns a malformed value — a bare string (settings.json
   typo `"sessionIdHeaderHosts": "host"` instead of `["host"]`), an array
   containing `null`/`undefined`/number entries, or whitespace-padded
   entries — `.some((p) => p.trim() === '*')` throws TypeError at
   buildClient time, bricking the LLM session before the first prompt.
   `staticCorrelationHeaders` already handled this via its end-to-end
   try/catch but the sister helper diverged. Settings loader does no
   runtime schema validation so this is reachable via a single typo.

   Fix: normalize the allowlist at wrap time:
     1. catch a throwing getter (existing)
     2. reject non-array → default allowlist (NEW — bare string typo)
     3. filter out non-string elements (NEW — [null, ...] typo)
     4. trim every surviving entry uniformly (NEW — see #2 below)
   Then `trustedHosts.includes('*')` instead of `.some((p) => p.trim() === '*')`,
   since patterns are already pre-trimmed.

2. **Trim asymmetry between `*` detection and host-pattern match**
   (llm-correlation-fetch.ts)
   `[" * "]` was tolerated (trimmed before `===` compare) but
   `[" dashscope.aliyuncs.com "]` silently never matched. The
   normalization above fixes this by trimming uniformly upstream.

3. **Proxy fetch test: only negative assertions**
   (openaiContentGenerator/provider/default.test.ts)
   The test asserted `callArg.fetch !== proxyFetch` and `!== globalThis.fetch`
   but both passed for ANY wrapper, including a buggy one that
   accidentally wraps globalThis.fetch instead of proxyFetch. Added a
   positive assertion: call the wrapped fetch and verify proxyFetch was
   the delegation target.

Tests: 4 new cases — whitespace-padded host pattern, bare-string
malformed config (both wrapper and static), null/number-containing
array malformed config (both wrapper and static), positive proxy fetch
delegation. All pass; pre-existing Anthropic User-Agent failure
unrelated.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* refactor(telemetry): split outbound correlation out of telemetry scope (R4)

Address LaZzyMan round-8 follow-up review on PR #4390: even though R3's
host allowlist made the default behavior safe, the meta-architectural
concern remains: telemetry's namespace and consent flow shouldn't quietly
extend to wire-level behavior aimed at third-party LLM provider request
streams. The recipient sets differ; the consent decisions differ; they
deserve separate namespaces, separate threat models, separate PRs.

This commit (called R4 in the design doc) collapses the PR scope so it
lands ONLY telemetry observability work:

REMOVED from this PR:
  - packages/core/src/telemetry/llm-correlation-fetch.ts(.test.ts)
  - packages/core/src/telemetry/trusted-llm-hosts.ts(.test.ts)
  - telemetry.sessionIdHeaderHosts setting + Config getter +
    resolveTelemetrySettings wiring + settingsSchema entry
  - wrapFetchWithCorrelation usage from four provider construction
    points (default.ts, dashscope.ts, anthropicContentGenerator.ts,
    geminiContentGenerator/index.ts)
  - All session-id provider tests across the four providers + the
    contentGenerator.test.ts mock stub
  - "Session correlation header" section in telemetry.md

ADDED:
  - OutboundCorrelationSettings interface in packages/core/src/config/config.ts,
    standalone top-level namespace separate from TelemetrySettings —
    SECURITY-RELEVANT label, all defaults off
  - Config.getOutboundCorrelationPropagateTraceContext() getter
  - outboundCorrelation top-level entry in settingsSchema.ts with
    propagateTraceContext: { default: false } and explicit
    SECURITY-RELEVANT framing in the description
  - CLI config-load pipeline passes settings.outboundCorrelation into
    ConfigParameters
  - NOOP_PROPAGATOR (TextMapPropagator no-op) in sdk.ts, conditionally
    installed on NodeSDK when propagateTraceContext is false (default).
    When true, omits textMapPropagator from NodeSDK options so the SDK
    keeps its default W3C composite propagator
  - 2 new sdk.test.ts cases covering the propagator gate behavior

UNCHANGED:
  - UndiciInstrumentation registration + OTLP feedback-loop guard +
    HttpInstrumentation OTLP guard from R2/R3 stay intact — they are
    pure telemetry (client HTTP spans into the operator's own OTLP
    collector), no wire-level data egress
  - Documentation rewrites telemetry.md to split "client-side HTTP
    span on outbound fetch" (telemetry) from a new "Outbound
    correlation (SECURITY-RELEVANT)" top-level section
  - design doc gets R4 revision row + new §12 "R4 Scope Conflation
    Split" capturing the rationale and follow-up PR outline

The session-id apparatus (R3 code) lives in git history at commits
1c8528a56 / cb162e716 / 7a1b4f8d0 / 40e1efc1f / 106598ca2; the
follow-up PR can cherry-pick or restore those files under the new
outboundCorrelation.* namespace as LazzyMan suggested.

Vscode-ide-companion settings.schema.json regenerated.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* docs(telemetry): disclose telemetry.enabled dependency on propagateTraceContext

Self-review pass on R4 commit 9bdd3bd6f flagged one footgun: both
`docs/developers/development/telemetry.md` and the settingsSchema.ts
description for `outboundCorrelation.propagateTraceContext` describe
the toggle's behavior without noting that the flag is a silent no-op
when `telemetry.enabled` is false. An operator who sets only
`outboundCorrelation.propagateTraceContext: true` and forgets the
telemetry switch gets zero behavior change — no error, no warning, no
traceparent.

Fix: add the dependency disclosure to both surfaces, plus a JSON
example showing both flags wired together for the ARMS+DashScope
cross-process trace continuation use case.

Also fix a minor comment accuracy nit at `sdk.test.ts:683`: said the
SDK installs `W3CTraceContextPropagator` instance when opt-in is true,
but the actual default is `CompositePropagator(W3CTraceContextPropagator
+ W3CBaggagePropagator)` per `@opentelemetry/sdk-node` source.

Vscode-ide-companion settings.schema.json regenerated to reflect the
expanded description string.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* test(config): cover getOutboundCorrelationPropagateTraceContext defaults

R4 (commit 9bdd3bd6f) added the getter but the test file didn't grow a
corresponding describe block — sibling telemetry getters all have unit
tests but this new one was missed.

Add 4 cases covering the security-relevant default-to-false invariant
and explicit-set behavior:
- omitted outboundCorrelation → false
- empty outboundCorrelation: {} → false (the `?? false` collapse on the
  getter, complementing the same on the constructor)
- explicit true → true
- explicit false → false

PR #4390 review (wenshao).

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* docs(design): reflect post-R4 polish commits in §12

R4 (commit 9bdd3bd6f) was followed by two polish commits that the
design doc §12 didn't track:

- 0be0df270 (docs): telemetry.enabled dependency disclosure on
  propagateTraceContext — added to telemetry.md + settingsSchema
  description because a self-review pass identified the silent-no-op
  footgun (operator sets propagateTraceContext: true but forgets
  telemetry.enabled: true, sees zero behavior change with no error).
- c0352fd5b (test): 4 config.test.ts cases covering the
  getOutboundCorrelationPropagateTraceContext default-false invariant
  (omitted / {} / explicit true / explicit false) — wenshao review
  flagged the test gap.

Updates §12.4 with a new "Hidden dependency: telemetry.enabled" sub-
section explaining the gating relationship and pointing forward at the
follow-up PR (future outboundCorrelation.* settings inherit the same
dependency). Updates §12.5 implementation table to add the
config.test.ts row and clarify the telemetry.md / vscode-schema rows
were touched again in the polish pass.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* refactor: simplify post-R4 polish per /simplify review

/simplify review pass on commits 0be0df270 + c0352fd5b + 62cf6b4ee
flagged 4 concerns. Fix all 4:

1. **settingsSchema.ts description**: footgun warning ("Depends on
   telemetry.enabled: true") was at char 600+ of a 650-char description.
   VS Code settings UI truncates to ~300 chars inline → the most
   important warning was hidden in the most-glanced view. Hoist to first
   sentence ("Requires telemetry.enabled: true.").

2. **config.test.ts**: drop the task-narration comment
   ("PR #4390 R4: keep wire-level toggle out of telemetry namespace.")
   that just restated the change context. The remaining 2-line comment
   explaining WHY default-to-false is security-relevant survives.

3. **config.test.ts**: collapse 4 separate `it()` blocks into a single
   `it.each([...])` covering the same 4 precondition × expectation
   combinations. Removes boilerplate (`new Config({...baseParams, ...})`
   repeated 4×) without losing assertion power; case-3 ("explicit false")
   was a weak duplicate of case-2 ("empty object") since both hit the
   same `?? false` branch, but keeping all 4 in the parametric table
   documents intent more clearly than dropping case-3.

4. **design doc §12.4 + §12.5**: strip specific commit SHAs
   (`0be0df270`, `c0352fd5b`) — design docs should be evergreen, not
   doubled-up commit logs (those live in `git log`). Keep the design
   intent ("two panels both document the dependency" / "test block
   added") without naming the specific commits.

Regenerated vscode-ide-companion/schemas/settings.schema.json to
reflect the hoisted description sentence.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
This commit is contained in:
jinye 2026-05-25 22:16:54 +08:00 committed by GitHub
parent a8a6ad2d06
commit 62ed44e1f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1822 additions and 5 deletions

View file

@ -0,0 +1,878 @@
# Telemetry: Outbound Trace Context & Session ID Header Propagation
> 配套 issue: [#4384](https://github.com/QwenLM/qwen-code/issues/4384)
> 父 issue: [#3731](https://github.com/QwenLM/qwen-code/issues/3731) (P3 deeper observability)
> 前置 PR: #4367 (resource attributes — merged 2026-05-21, commit `64401e1`)
> 基于 2026-05-21 对 qwen-code main 分支 + 直接验证的 claude-code 源码
## 修订历史
| 修订 | 日期 | 触发 | 摘要 |
| ---- | ---------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| R1 | 2026-05-21 | 初稿 | 全广播:所有出站 LLM 请求都带 `X-Qwen-Code-Session-Id` + `traceparent` |
| R2 | 2026-05-22 | wenshao R2/R3 review | 边界安全URL normalize、port matching、quote 对齐、staticCorrelationHeaders try/catch、host:port fallback strip |
| R3 | 2026-05-23 | LaZzyMan REQUEST_CHANGES | **重大语义改动**`X-Qwen-Code-Session-Id` 默认作用域收窄到 first-partyAlibaba/DashScopehost 白名单。详见 §11 |
| R4 | 2026-05-25 | LaZzyMan round-8 follow-up (scope conflation) | **PR scope 大幅收窄**:本 PR 仅保留 client HTTP span + OTLP loop guard`traceparent` 默认 offNoopTextMapPropagator新增 `outboundCorrelation.*` 顶级 namespace 放安全相关 toggleR3 落地的整套 `X-Qwen-Code-Session-Id` 机器**移除本 PR**,搬到独立 follow-up PR。详见 §12 |
**特别提示**:阅读 §3.1(目标)/ §3.2(非目标)/ §4.3Part B 设计)/ §4.4(配置 schema 影响)/ §5文件改动清单/ §9与 claude-code 对比)/ §10未来工作/ §11R3 host-allowlist scoping请同时参考 §12 —— **R4 修订让 R1-R3 关于"本 PR 同时落地 traceparent + session id header"的论断不再成立**:本 PR 现仅为 telemetry observability + 独立的 outbound trace-context toggle所有 outbound correlation header 工作(包括 R3 的 host allowlist整体搬到独立 follow-up PR。R3 工作代码本身没浪费,挪到 follow-up PR 即可复用。
## 1. 背景
#4367 解决了**emitted telemetry 上的 attribute 与 cardinality**(操作员能给 span/log/metric 打 `user.id`/`tenant.id` 这类标签)。但有一类东西它没碰:**outbound LLM 请求的 HTTP header**。今天 qwen-code 发往 DashScope / OpenAI / Gemini / Anthropic 的请求**完全不带任何 cross-process correlation header**——既没有 W3C `traceparent`,也没有 session id。
后果:
1. trace context 在 qwen-code 进程边界断开。若模型服务(如 ARMS Tracing 接入的 DashScope本身有 OTel instrumentation它产生的 span 与 qwen-code 的 trace 彼此独立,端到端 trace tree 不存在。
2. 没有 session id 在 wire 上。后端要把 qwen-code 的 metric/log 与服务端日志关联,需要离线匹配 trace id 或时间戳,远不如直接读 header 简单。
3. 本地 trace 缺一层 client-side HTTP span。今天只能看 `api.generateContent` 的总耗时,看不到网络 TTFB / 响应体大小 / 重试次数。
## 2. 现状
### 2.1 仅启用了 `HttpInstrumentation`
`packages/core/src/telemetry/sdk.ts:330`
```ts
instrumentations: [new HttpInstrumentation()],
```
`HttpInstrumentation` 只 hook Node 内建的 `http`/`https` 模块,**不**覆盖 `globalThis.fetch` / undici 路径。
### 2.2 两套 LLM SDK 都走 fetch / undici
| SDK | HTTP 实现 | `HttpInstrumentation` 是否覆盖 |
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
| `openai@5.11.0` | `globalThis.fetch`Node 18+ 即 undici。证据`node_modules/openai/internal/shims.mjs` 报错 `'fetch' is not defined as a global` | ❌ |
| `@google/genai@1.30.0` | `globalThis.fetch` + `new Headers()`。证据:`dist/node/index.mjs` 内的 `new Headers()` 调用 | ❌ |
| `@anthropic-ai/sdk`anthropicContentGenerator | 同样基于 fetch | ❌ |
### 2.3 代码库零 manual propagation
```
grep -rn "propagation\.\|setGlobalPropagator\|W3CTraceContext\|traceparent" packages/core/src --include="*.ts" | grep -v "\.test\."
```
→ 空。没有任何 `propagation.inject()` 调用,没有手动 traceparent 注入。
### 2.4 各 provider 的 `defaultHeaders` 现状
OpenAI 家族(用 `openai` SDK
所有 OpenAI 子 provider 都 `extends DefaultOpenAICompatibleProvider`。**buildHeaders override 行为分两类**(已 grep audit 验证):
| Provider | 文件 | `buildHeaders()` 行为 | 影响 |
| ---------- | ---------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------------- |
| 基类 | `default.ts:63-74` | 提供 `{ 'User-Agent' }` + customHeaders | 改这里 |
| DashScope | `dashscope.ts:110-124` | **`override` 但不 call `super`**——返回 `User-Agent` + `X-DashScope-*` 全新对象 | **必须单独改这里**,否则 correlation header 丢 |
| OpenRouter | `openrouter.ts:20-30` | `override` 但**先 `const baseHeaders = super.buildHeaders()`** | 改基类自动继承 ✅ |
| DeepSeek | `deepseek.ts` | 不 override `buildHeaders`(只 override `buildRequest` / `getDefaultGenerationConfig` | 改基类自动继承 ✅ |
| Minimax | `minimax.ts` | 同 deepseek | 自动继承 ✅ |
| Mistral | `mistral.ts` | 同 deepseek | 自动继承 ✅ |
| ModelScope | `modelscope.ts` | 同 deepseek | 自动继承 ✅ |
**OpenAI 家族需要触动 2 个文件**`default.ts``dashscope.ts`。其余 5 个自动继承。
Google Gemini
| Provider | 文件 | 头注入路径 |
| -------- | ------------------------------ | -------------------------------------------------------------- |
| Gemini | `geminiContentGenerator.ts:59` | `new GoogleGenAI({ httpOptions: { headers } })` — SDK 原生支持 |
Anthropic
| Provider | 文件 | 头注入路径 |
| --------- | ------------------------------------------------------------------------------------------------------ | ---------------- |
| Anthropic | `anthropicContentGenerator.ts:177` (`buildHeaders`) + `:212` (`defaultHeaders` arg to `new Anthropic`) | `defaultHeaders` |
**总计 4 个 SDK 构造点**需要注入 session id header。所有 SDK 都已支持 `defaultHeaders` / `httpOptions.headers`,无需 fetch wrapper。
### 2.5 已有的 proxy 与 fetch 配置
`provider/default.ts:87-89`
```ts
const runtimeOptions = buildRuntimeFetchOptions(
'openai',
this.cliConfig.getProxy(),
);
```
`buildRuntimeFetchOptions` 在用户配 proxy 时返回 `{ fetch: customFetch }` 或类似,触发 `setGlobalDispatcher(new ProxyAgent(...))`(见 `config.ts:1126-1128`)。**undici 全局 dispatcher 模式与 `UndiciInstrumentation` 兼容**——它通过 monkey-patch `globalThis.fetch` 与 undici 的 channel diagnostics 协作,不依赖具体 dispatcher。
## 3. 目标 / 非目标
### 3.1 目标
- 所有 outbound LLM 请求自动带 W3C `traceparent` headerOTel SDK 默认的 `W3CTraceContextPropagator`
- ~~所有~~ 出站 LLM 请求带 `X-Qwen-Code-Session-Id` headerclaude-code 同款产品命名空间) — **R3 修订**:默认仅向 first-party (Alibaba/DashScope) host 注入,第三方 provider 默认不发;详见 §11
- 自动避免对 OTLP exporter endpoint 自身的 tracefeedback loop
- 给 LLM 请求加一层精确的 client span网络耗时 vs 模型耗时分离)
- 覆盖 4 个 provider 构造点OpenAI 基类、DashScope override、Gemini、Anthropic
- streaming 请求 / proxy 模式 / 重试场景全部不退化
- 与 #4367 的设计哲学一致:通过 `defaultHeaders` 这种 SDK-native 选项 — **R1 修订**:因 staleness 问题转用 fetch wrapper**R3 修订**fetch wrapper 内再叠加 host gate
### 3.2 非目标
- **`baggage` header**:标准 SDK 已支持,但 qwen-code 没调 `propagation.setBaggage()`,默认不会发送。本设计不主动开启。
- **subprocess `TRACEPARENT` env var 继承**claude-code 给 Bash/PowerShell 子进程注入 `TRACEPARENT`。qwen-code 的 `BashTool` 没做。是独立 follow-up sub-issue。
- **inbound `TRACEPARENT` / `TRACESTATE` 读取**claude-code 的 `-p` 模式和 Agent SDK 从 env 读 traceparent 接续父进程 trace。qwen-code 没做。独立 follow-up。
- **`X-Qwen-Code-Request-Id`**claude-code 有 `x-client-request-id`,对超时容错 correlation 有用。本期不做,可作为下一个 sub-issue。
- **自定义 propagatorB3 / Jaeger / X-Ray**:默认 W3C 已覆盖 99% 场景。可作为 future config option。
- ~~**per-endpoint 选择性注入**claude-code 对第三方 endpoint (Bedrock / Vertex) 不发 traceparentqwen-code 没有第三方区分需要,统一发即可。~~**R3 修订**此论断已被推翻。LaZzyMan review 指出 qwen-code 是开源 CLI 连接多个第三方 providerOpenAI / Anthropic / OpenRouter / 等claude-code 的 first-party→first-party 类比不适用session id header 必须按 host 区分。详见 §11。`traceparent` 仍按 R1 设计全注入OTel 标准 header且 trace id 是 `sha256(sessionId)` 哈希值),可作为独立 follow-up 加 per-destination toggle`telemetry.propagateTraceContext`)。
## 4. 设计
### 4.1 总体分层
```
┌─ qwen-code process ────────────────────────────────────────────┐
│ │
│ ┌─ session-tracing.ts ─┐ │
│ │ active span ctx │ │
│ └──────┬───────────────┘ │
│ │ │
│ ▼ │
│ ┌─ propagation.inject() (called by undici instrumentation) ─┐│
│ │ writes `traceparent: 00-<traceId>-<spanId>-01` to headers ││
│ └─────────────────────────────────────────────────────────────┘│
│ │ │
│ ┌──────▼──────────────────────────────────────────────────┐ │
│ │ fetch() — undici, instrumented │ │
│ │ creates HTTP client span │ │
│ │ injects traceparent into request headers │ │
│ │ (skipped via ignoreRequestHook if endpoint is OTLP) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ │ ┌─ defaultHeaders (per SDK constructor) ───────┐ │
│ │ │ { 'X-Qwen-Code-Session-Id': sessionId, ... } │ │
│ └───┴────────────────────────────────────────────────┘ │
│ │ │
└─────────────┼──────────────────────────────────────────────────┘
▼ outbound HTTP
POST /v1/chat/completions
traceparent: 00-...
X-Qwen-Code-Session-Id: ...
... (existing User-Agent, X-DashScope-*, etc.)
```
两条注入路径独立、互不依赖:
| Layer | 何时注入 | 由谁注入 |
| ------------------------ | ------------------------------------- | ------------------------------------------------------------- |
| `traceparent` | 每次 fetch 调用时 | `UndiciInstrumentation` 自动(来自 OTel SDK 默认 propagator |
| `X-Qwen-Code-Session-Id` | SDK 构造时一次性写入 `defaultHeaders` | 应用代码 |
### 4.2 Part A — `traceparent` via undici instrumentation
**改动点**`packages/core/src/telemetry/sdk.ts`
```ts
import { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici';
// ...
const otlpUrls = [
config.getTelemetryOtlpEndpoint(),
config.getTelemetryOtlpTracesEndpoint(),
config.getTelemetryOtlpLogsEndpoint(),
config.getTelemetryOtlpMetricsEndpoint(),
]
.filter((u): u is string => !!u)
.map((u) => u.replace(/\/$/, ''));
instrumentations: [
new HttpInstrumentation(),
new UndiciInstrumentation({
ignoreRequestHook: (request) => {
// request.origin = "https://collector:4318", request.path = "/v1/traces"
const url = `${request.origin}${request.path}`;
return otlpUrls.some((e) => url.startsWith(e));
},
}),
],
```
#### 为什么 `ignoreRequestHook` 必须
OTel SDK 自己用 fetch 把数据 POST 到 OTLP collector。如果不跳UndiciInstrumentation 会给"上报数据"的请求也建一个 span → 这个新 span 会被再次上报 → 无限循环 / 巨量噪声。每个 OTel 项目都踩过这个坑OTel 文档明确推荐这种 hook。
#### 默认 propagator
OTel SDK `NodeSDK` 不传 `textMapPropagator` 时默认是 `CompositePropagator([W3CTraceContextPropagator, W3CBaggagePropagator])`。无需显式设置。
#### `traceparent` 格式
```
traceparent: 00-<32hex traceId>-<16hex spanId>-<01 sampled | 00 not sampled>
─┬─ ─┬─
version (固定 00) flags
```
固定 55 bytes无 padding。
#### `tracestate``baggage`
- `tracestate`: 上游传过来才续传;自己 inject 不会主动加OTel SDK 行为)。
- `baggage`: 仅当 `propagation.setBaggage(ctx, ...)` 被调用过才有。qwen-code 不调,所以不会发送。
### 4.3 Part B — `X-Qwen-Code-Session-Id` via fetch wrapperOpenAI / Anthropic+ static headersGemini
> **R3 修订**:以下设计描述的是 fetch wrapper 的 staleness 解决和 4 个 provider 集成点 — 这些都保留。但 wrapper 内部增加了一道 host allowlist gate`staticCorrelationHeaders` 也加了 `destinationUrl` 参数。带 host gate 的最新实现代码与 default allowlist 见 §11。
#### Criticalstaleness 问题与方案选择
天真做法(`defaultHeaders` 直接 bake-in `getSessionId()`)有**真 bug**
1. `pipeline.ts:60` 在 contentGenerator 构造时一次性 `this.client = this.config.provider.buildClient()`SDK client 的 `defaultHeaders` 在那一刻 capture 当时的 session id
2. `config.ts:1850` 的 session reset用户 `/clear` 时触发)更新 `this.sessionId``refreshSessionContext()`,但**不重建 contentGenerator**
3. 后续 LLM 调用仍走旧 client → wire header 仍是旧 session id → 后端 correlation 错位
→ 必须读取 session id **per-request**,不能 bake at构造时。
#### 方案
```
┌─ fetch 支持 ─┐ 方案
OpenAI SDK │ ✅ │ fetch wrapper (per-request 读 sessionId) ✅
Anthropic SDK │ ✅ │ fetch wrapper ✅
@google/genai SDK │ ❌ │ static httpOptions.headers + 接受 staleness
└──────────────┘
```
`@google/genai`'s `HttpOptions` interface 不支持 `fetch`(已 grep `node_modules/@google/genai/dist/genai.d.ts` 验证:只有 `baseUrl`/`apiVersion`/`headers`/`timeout`/`extraParams`)。所以 Gemini 走 static headers与 OpenAI/Anthropic 不一致——这是 **known limitation**,见 §8.6。
#### 集中辅助函数per-request fetch wrapper
新文件 `packages/core/src/telemetry/llm-correlation-fetch.ts`
```ts
import type { Config } from '../config/config.js';
/**
* Wrap a fetch implementation so every outbound request gets correlation
* headers (`X-Qwen-Code-Session-Id`) populated from the **current** session
* id, not the value captured when the SDK client was constructed.
*
* Matches claude-code's pattern (src/services/api/client.ts:370-390 —
* `buildFetch()`). Per-request injection is necessary because `/clear`
* resets the session id mid-process; SDK clients (and their static
* `defaultHeaders`) are NOT recreated on reset.
*
* Caller responsible for choosing the base fetch — usually
* `runtimeOptions?.fetch ?? globalThis.fetch` so proxy-aware fetch is
* preserved when ProxyAgent is in use.
*
* If telemetry is disabled, returns baseFetch unchanged (no correlation
* header is added, matching the privacy stance of §3.1).
*/
export function wrapFetchWithCorrelation(
baseFetch: typeof fetch,
config: Config,
): typeof fetch {
return async function correlationFetch(input, init) {
if (!config.getTelemetryEnabled()) {
return baseFetch(input, init);
}
const sid = config.getSessionId();
if (!sid) {
// Defensive: empty header value is rejected by some HTTP middleware.
// Skip injection rather than send `X-Qwen-Code-Session-Id: `.
return baseFetch(input, init);
}
const headers = new Headers(init?.headers);
headers.set('X-Qwen-Code-Session-Id', sid);
return baseFetch(input, { ...init, headers });
};
}
```
Companion helper for the SDKs that can only take static headers (Gemini):
```ts
/**
* Static correlation headers. Captures the session id at call time —
* **subject to staleness** if the host SDK keeps these headers in a
* captured-at-construction slot (e.g. `@google/genai`'s `httpOptions.headers`).
* Prefer `wrapFetchWithCorrelation` whenever the SDK exposes a `fetch` hook.
*/
export function staticCorrelationHeaders(
config: Config,
): Record<string, string> {
if (!config.getTelemetryEnabled()) return {};
return { 'X-Qwen-Code-Session-Id': config.getSessionId() };
}
```
#### 集成点 1: `provider/default.ts` (OpenAI 基类)
`buildClient()` 改动——compose 现有 `runtimeOptions.fetch`proxy与我们的 wrapper
```ts
buildClient(): OpenAI {
// ... existing ...
const runtimeOptions = buildRuntimeFetchOptions('openai', this.cliConfig.getProxy());
const baseFetch =
(runtimeOptions as { fetch?: typeof fetch } | undefined)?.fetch
?? globalThis.fetch;
return new OpenAI({
apiKey,
baseURL: baseUrl,
timeout,
maxRetries,
defaultHeaders,
...(runtimeOptions || {}),
// After spread, override `fetch` so our correlation wrapper wraps the
// proxy-aware fetch (or globalThis.fetch when no proxy).
fetch: wrapFetchWithCorrelation(baseFetch, this.cliConfig),
});
}
```
`buildHeaders()` itself unchanged.
#### 集成点 2: `provider/dashscope.ts` (override)
`buildClient()` 同样的 compose 模式(它本来就 override buildClient`buildHeaders()` 不动。
#### 集成点 3: `geminiContentGenerator/index.ts` (factory, NOT 构造器)
**修正先前设计的过度声明**`geminiContentGenerator.ts` 构造器**不需要**改签名。`index.ts:48` 的 factory 函数已经接收 `gcConfig: Config`line 33 已经在用 `gcConfig?.getUsageStatisticsEnabled()`),只需要在 factory 里把 correlation 静态 headers merge 进 `httpOptions.headers`
```ts
// geminiContentGenerator/index.ts
let headers: Record<string, string> = { ...baseHeaders };
if (gcConfig?.getUsageStatisticsEnabled()) {
// ... existing x-gemini-api-privileged-user-id ...
}
headers = { ...headers, ...staticCorrelationHeaders(gcConfig) }; // ← 新增
const httpOptions = config.baseUrl
? { headers, baseUrl: config.baseUrl }
: { headers };
// new GeminiContentGenerator(...) unchanged
```
零 signature 改动。
#### 集成点 4: `anthropicContentGenerator.ts`
Anthropic SDK 同样接受 custom `fetch`(已经在用 `buildRuntimeFetchOptions`)。把 `buildClient` 路径里那个 fetch wrap 一下,方式同 OpenAI default.ts。`buildHeaders` 不变。
#### 优先级链
不变:用户的 `customHeaders``defaultHeaders` merge 中仍然赢(见 §8.2 spoofing 讨论。fetch wrapper 注入的 `X-Qwen-Code-Session-Id` 在 SDK 的 headers list 之**后**追加到最终 `Headers` 对象上——以 Node `Headers.set()` 的语义,等于覆盖任何之前同名的(包括 user 的 customHeaders 里写的同名 header
**对 OpenAI/Anthropicfetch wrapper 路径)**correlation > customHeaders > SDK defaults。
**对 Geministatic headers 路径)**customHeaders > correlation > SDK defaults沿用既有 spread 顺序)。
差异是 fetch wrapper 路径下 spoofing 不再可能fetch wrapper 在 SDK headers 之后跑)。这是 **bug 修复的副产品**,并非有意收紧——但更安全。要在 §8.2 明示。
### 4.4 配置 schema 影响
~~**几乎为零**。本设计不引入新 setting~~ — **R3 修订**:引入了一项新 setting `telemetry.sessionIdHeaderHosts: string[]`,用于覆盖默认的 first-party host 白名单。schema 项已加入 `packages/cli/src/config/settingsSchema.ts`,描述与 override 语法(`["*"]` 恢复广播 / `[]` 全关 / 自定义数组)见 §11。原文以下描述仅适用于 R3 之前:
- `traceparent` 注入由 telemetry enabled 触发(已有 toggle
- `X-Qwen-Code-Session-Id` 注入也由 telemetry enabled 触发
- `ignoreRequestHook` 的 OTLP url 已经从现有 config 读
未来可以加的 setting**out of scope**
- `telemetry.outboundCorrelationHeader`: 自定义 header name默认 `X-Qwen-Code-Session-Id`
- `telemetry.outboundPropagationDisabled`: 全局关闭(如果 LLM 服务对未知 header 严格)
- ~~per-destination header scope toggle~~**R3 已落地**,见 §11
## 5. 文件改动清单
| 文件 | 改动类型 | 说明 |
| ------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `packages/core/package.json` | 加依赖 | `@opentelemetry/instrumentation-undici` |
| `packages/core/src/telemetry/sdk.ts` | 修改 | +`UndiciInstrumentation` + `ignoreRequestHook` |
| `packages/core/src/telemetry/llm-correlation-fetch.ts` | 新文件 | `wrapFetchWithCorrelation()` (OpenAI/Anthropic) + `staticCorrelationHeaders()` (Gemini fallback) |
| `packages/core/src/core/openaiContentGenerator/provider/default.ts` | 修改 | `buildClient()``new OpenAI({...})` 里加 `fetch: wrapFetchWithCorrelation(baseFetch, cliConfig)` |
| `packages/core/src/core/openaiContentGenerator/provider/dashscope.ts` | 修改 | 同上override `buildClient` |
| `packages/core/src/core/geminiContentGenerator/index.ts` | 修改 | factory 函数里 merge `staticCorrelationHeaders(gcConfig)``httpOptions.headers`**caller 已有 Config零 signature 改动** — 修正之前的 over-specification |
| `packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts` | 修改 | `buildClient` 路径下用 `wrapFetchWithCorrelation` 包 SDK 的 `fetch` option |
**显式 audited 但无需改动**(避免 reviewer 怀疑漏路径):
- `packages/core/src/qwen/qwenContentGenerator.ts``extends OpenAIContentGenerator`,用 `DashScopeOpenAICompatibleProvider`**自动继承 dashscope.ts 的 buildClient 改动**。所有 Qwen OAuth 流程同样受益。
- `packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts` — wrapper 模式,不构造 SDK client它包装其他 contentGenerator 做 telemetry logging无需改动。
- `packages/core/src/core/contentGenerator.ts` — factory 入口,不持有 client。
| `packages/core/src/telemetry/sdk.test.ts` | 修改 | 加 undici instrumentation 注册 + ignoreRequestHook 测试 |
| `packages/core/src/telemetry/llm-correlation-fetch.test.ts` | 新文件 | telemetry-on/off 行为单测 + per-request 读 sessionId 验证criticalsession reset 后 wrapped fetch 读到新 id |
| 各 provider 的 `*.test.ts` | 修改 | 断言 SDK 构造时 `fetch` option 是 wrapped 版本OpenAI/Anthropic断言 Gemini 构造时 `httpOptions.headers``X-Qwen-Code-Session-Id` |
| `docs/developers/development/telemetry.md` | 修改 | 新增 "Trace context & session correlation propagation" 段 |
| `docs/design/telemetry-outbound-propagation-design.md` | 本文件 | 设计文档 |
## 6. 分 PR 拆分
按 review 友好度分两个 PR也可以合一规模允许
### PR 1 — `traceparent` 自动注入structural
- 加 `@opentelemetry/instrumentation-undici` 依赖
- `sdk.ts``UndiciInstrumentation` + `ignoreRequestHook`
- 测试SDK 注册、OTLP endpoint 不被 trace
- 文档片段
**风险**低。Additive。已有 client span 是 net 增益,不会改变现有 span 结构。
### PR 2 — `X-Qwen-Code-Session-Id` header结合 helper 函数)
- 新文件 `llm-correlation-headers.ts`
- 4 个 provider 集成
- 测试:每个 provider 断言 header 存在telemetry-off 时不发
- 文档片段
**风险**:低-中。要小心 `geminiContentGenerator` 构造器签名扩展可能波及调用方。
### PR 3可选 — Docs + E2E verify
- 完善 `telemetry.md` 段落
- 加 E2E verify script复用 `/tmp/verify-telemetry-pr-4367.mjs` 模式):实际跑 fetch + 抓 header
也可以合并到 PR 2 里。
### 顺序偏好
PR 1 和 PR 2 技术上**互相独立**——不共享代码。但**推荐 PR 1 先合**
- `traceparent` 是 OTel **标准** header任何 OTel-aware collector / 后端立刻识别 → 用户立即获益
- `X-Qwen-Code-Session-Id` 是**产品自定义** header需要后端配置识别才有价值 → 价值滞后
- 万一 PR 2 review 周期长PR 1 已经把 cross-process trace 跑通了
- PR 1 是 additive structural低风险适合先建立信心
## 7. 测试计划
### 7.1 `sdk.ts` 单测
- ✅ `UndiciInstrumentation``NodeSDK``instrumentations` 中存在
- ✅ `ignoreRequestHook``https://collector:4318/v1/traces` 返回 true
- ✅ `ignoreRequestHook``https://dashscope.aliyuncs.com/...` 返回 false
- ✅ trailing slash 与无 trailing slash 都正确匹配
### 7.2 `llm-correlation-fetch.ts` 单测
**`wrapFetchWithCorrelation`**
| 场景 | 期望 |
| ------------------------------------------------------- | ---------------------------------------------------------------------- |
| `getTelemetryEnabled() === false` | wrapped fetch = baseFetch不加任何 header |
| `getTelemetryEnabled() === true`, sessionId = "abc-123" | wrapped fetch 发出的 init.headers 含 `X-Qwen-Code-Session-Id: abc-123` |
| `init.headers` 已有 `X-Qwen-Code-Session-Id: spoof` | wrapper 后覆盖为真 sessionIdfetch wrapper 路径不允许 spoof§8.1 |
| **session reset 后 wrapped fetch 被再次调用** | **读取新 sessionId**regression guard for staleness fix |
| baseFetch reject | wrapper 透传 reject 不吞 |
**`staticCorrelationHeaders`**Gemini path
| 场景 | 期望返回 |
| ------------------------------------------------------- | ---------------------------------------------------------------- |
| `getTelemetryEnabled() === false` | `{}` |
| `getTelemetryEnabled() === true`, sessionId = "abc-123" | `{ 'X-Qwen-Code-Session-Id': 'abc-123' }` |
| sessionId 中含 unicode`會話-1` | 原样返回——HTTP header value 由 SDK 负责编码 |
| sessionId 为空字符串 | `{ 'X-Qwen-Code-Session-Id': '' }`——业务 invariant不在此层校验 |
### 7.3 Per-provider 集成测试
每个 provider 的 `buildHeaders()` / 构造测试加:
```ts
it('includes X-Qwen-Code-Session-Id when telemetry enabled', () => {
const config = makeFakeConfig({
sessionId: 'sess-xyz',
telemetry: { enabled: true },
});
const provider = new DefaultProvider(genConfig, config);
expect(provider.buildHeaders()['X-Qwen-Code-Session-Id']).toBe('sess-xyz');
});
it('omits X-Qwen-Code-Session-Id when telemetry disabled', () => {
const config = makeFakeConfig({ telemetry: { enabled: false } });
const provider = new DefaultProvider(genConfig, config);
expect(provider.buildHeaders()).not.toHaveProperty('X-Qwen-Code-Session-Id');
});
```
### 7.4 E2E verificationtmux + local HTTP server
⚠️ **不要** mock `globalThis.fetch` 来抓 header`UndiciInstrumentation` 通过 undici 的 diagnostics channel hookmonkey-patching globalThis.fetch 可能完全 bypass instrumentation取决于 patch 顺序),让 `traceparent` 注入测不到。**正确做法是起 local HTTP server**,让 SDK 真发请求server 端记录收到的 headers。
写一个仿 `/tmp/verify-telemetry-pr-4367.mjs` 的脚本:
1. `http.createServer((req, res) => { capturedHeaders.push(req.headers); res.end('{}') })` 起本地 server
2. 启 telemetry + outfile + 把 OpenAI SDK 的 `baseURL` 指向 `http://127.0.0.1:<port>`(或者用 mock provider 让 SDK 真发 fetch
3. 触发一次 `client.chat.completions.create(...)`(要带最小可解析的 mock 响应,否则 SDK 解析报错——本地 server 返回合法但空的 OpenAI 响应即可)
4. 断言 `capturedHeaders[0]``traceparent: 00-...``X-Qwen-Code-Session-Id: <sessionId>`
5. 另起一个 OTLP collector mock 在 different port验证给它发的 OTLP 上报**不**触发 `traceparent` 注入(验证 `ignoreRequestHook`
6. **额外staleness 验证** — emit request 1 → call `config.resetSession(...)` → emit request 2 → 断言 request 2 的 `X-Qwen-Code-Session-Id` 是新 session id**这是 #1 fix 的关键回归测试**
### 7.5 回归保护
- streaming chat completion 的 fetch`stream: true`)仍正常关闭——`UndiciInstrumentation` 历史上对 streaming response 的 span lifecycle 有过 bug**实施时需要实际跑一次 streaming completion 端到端验证 client span 正常 end + 无 leaked span + 流不被截断**;不假设具体版本号已修
- proxy mode (`ProxyAgent`) 与 instrumentation 同时启用——`ignoreRequestHook` 仍按 endpoint 字符串匹配proxy 不影响
- 重试(`maxRetries`)下每次重试都得到独立 client span但都共享同一个 `traceparent` parent理想是 retry 作为同一个父 span 下多个 child span — 这部分由 SDK 行为决定,本设计不强制)
## 8. 边界 / 边角
### 8.1 customHeaders override 与 spoofing 的不一致行为
不同 provider 路径的 spoofing 表面**不同**(设计后果,非原意收紧):
| Provider 路径 | spoofing 可能? | 原因 |
| --------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------- |
| OpenAI / Anthropic (fetch wrapper 路径) | ❌ 不能 spoof | fetch wrapper 在 SDK headers list 之后 `headers.set('X-Qwen-Code-Session-Id', ...)`,覆盖 user customHeaders 的同名 |
| Gemini (static headers 路径) | ✅ 可 spoof | merge 顺序 `{ ...baseHeaders, ...correlationHeaders, ...customHeaders }`——customHeaders 最后赢 |
claude-code 同样使用 fetch wrapper 路径,行为与 OpenAI/Anthropic 一致spoofing 不能)。这是修 staleness bug 的副产品,不是原本要做的事。
**不打算"对齐"两条路径**——Gemini 路径的行为是 SDK 限制(没有 `fetch` hook导致的反向把 OpenAI 也降级到 static 不合理。
Session id spoofing 不是真威胁(用户控制本地,可以直接改 source code。文档里要明示这个差异避免 reviewer 看到 fetch wrapper 路径无法 spoof 时质疑 customHeaders 优先级。
### 8.2 OTLP collector URL 匹配的两类 edge case
#### (a) Auth token in URL
如果用户 OTLP endpoint 形如 `https://collector/path?token=secret``ignoreRequestHook``url.startsWith(e)` 比对应包含 query string。但 undici 给的 `request.path` 只到 path不含 query所以比较时 `e` 也只用到 path 部分。为安全起见,剥掉 query
```ts
const otlpUrls = [...]
.map((u) => u.replace(/\?.*$/, '').replace(/\/$/, ''));
```
#### (b) startsWith 跨 hostname 边界的理论 false positive
`e = "http://collector"`(无 port来路 url = `http://collector-fake/v1/traces` 会被 startsWith 错误匹配。
**实际触发概率极低**
- OTLP endpoint 几乎总带 port4317 gRPC / 4318 HTTP`http://collector:4318` 形态后 `-fake` 这种延伸不可能port 后跟的是 `/`
- 用户配 endpoint 不带 port 是配置错误,本来 SDK 就要默认 fallback
**如果想 harden**:解析 URL origin + path 分别比较,不用裸 startsWith
```ts
const parsed = otlpUrls.map((u) => new URL(u));
return parsed.some(
(e) =>
`${request.origin}` === e.origin && request.path.startsWith(e.pathname),
);
```
本期不做——开销没必要false positive 实际触发不到。
### 8.3 Vertex AI 模式的 Gemini
`@google/genai` 支持 `vertexai: true` 模式(用 GCP 凭据走 Vertex 端点而非 generative ai endpoint。两种模式都走 fetch所以 instrumentation 都覆盖。`httpOptions.headers` 在两种模式下都有效。
### 8.4 Anthropic SDK 已有 `defaultHeaders` 逻辑
`anthropicContentGenerator.ts:177` 已经在调 `buildHeaders()` 然后传给 `new Anthropic({ defaultHeaders })`。但 staleness 同样适用——本设计改用 `fetch` wrapper 路径(与 OpenAI 一致)。
### 8.5 SDK 与 fetch 之间的 trailer header
`openai` SDK 在 streaming 时可能用 `Transfer-Encoding: chunked` 和 trailer headers。这些都不影响 request-time 的 `traceparent` / `X-Qwen-Code-Session-Id` 注入——它们都是请求头,发出时一次性写入。
### 8.6 ⚠️ Known limitation: Gemini 的 session id 在 `/clear` 后 stale
由于 `@google/genai` SDK 不支持 `fetch` hook`HttpOptions` 接口只有 `baseUrl`/`apiVersion`/`headers`/`timeout`/`extraParams`Gemini provider 走 static `httpOptions.headers` 路径——session id 在 SDK 构造时 capture**`/clear` 触发 session reset 后不刷新**。
**实际影响范围**
- 用户启动 qwen-code → `/clear` → 用 Gemini 模型 → wire 上的 `X-Qwen-Code-Session-Id` 是旧 session id
- 后端 correlation 错位trace id 和 log 已正确切换到新 session但 wire header 滞后)
**为什么不修**(本期):
- OpenAI / Anthropic 路径**没有这个 bug**fetch wrapper 路径 per-request 读 session id
- Gemini fix path 有几个选项,全部超出本期 scope见下
**Future fix path 选项**(按推荐顺序):
| 选项 | 描述 | 代价 |
| --------------------------------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
| **A. Lazy invalidate** ★ 推荐 | session reset 时只 mark contentGenerator dirty下次 LLM 调用时 lazy recreate | 小:~10 行加在 `resetSession` + LLM 调用入口;同步 API无侵入 |
| B. Eager recreate | session reset 时立即 `await createContentGenerator(...)`,需 async 化 `resetSession` | 中API 改动级联多处 |
| C. Proxy headers object | 给 `httpOptions.headers` 包 Proxy 拦截 getter | 风险高:`@google/genai` 内部是否 per-request 重读 headers 不可知,行为可能 silently break |
| D. 推动 `@google/genai` 上游加 `fetch` option | 提 PR 给 google-deepmind/generative-ai-js | 长期;不可控 |
**文档要在用户面前说明**:使用 Gemini provider 时如果 `/clear` 后立刻有 LLM 调用wire 上的 session id 在那一刻是旧的。可以靠 trace correlation 间接修正spans/logs 上 session.id 已经是新的)。
应单开 follow-up sub-issue 跟踪选项 A。
## 9. 与 claude-code 对比
| 维度 | claude-code | qwen-code 本设计 | 决策依据 |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| Session id header 命名 | `X-Claude-Code-Session-Id`(产品前缀) | `X-Qwen-Code-Session-Id`(产品前缀) | ✅ 同样命名空间策略 |
| Session id 注入机制 | SDK `defaultHeaders``client.ts:108`+ 自定义 `buildFetch()` wrapper`client.ts:370-390`per-request `randomUUID()` 注入 `x-client-request-id` | OpenAI/Anthropic 走 fetch wrapperper-request 读 session id避免 `/clear` stalenessGemini 走 static `httpOptions.headers`SDK 限制) | 与 claude-code 的 fetch wrapper 模式对齐。claude-code 也用 fetch wrapper 才能 per-request 加 `x-client-request-id` |
| Session id 持久性 | claude-code 没有 `/clear`-式 session resetsession = process | 有 `/clear` reset → fetch wrapper 路径自动跟随static headers 路径会 stale§8.6 | qwen-code 独有的复杂度 |
| Session id 编码 | HTTP header不是 baggage | HTTP header | ✅ 同——backend 友好 |
| `traceparent` 注入 | 闭源;公开 docs 描述存在;开源 repo 无 `propagation.inject` / `UndiciInstrumentation` 引用 | `@opentelemetry/instrumentation-undici` 自动 | claude-code 怎么实现的不可见。我们选 OTel 官方推荐路径,更轻 |
| `traceparent` 发送范围 | 仅第一方 Anthropic API不发 Bedrock/Vertex/Foundry | 发给所有出站 fetch (W3C 标准trace id 是 `sha256(sessionId)` 哈希)。**R3 修订**session id header 仅向 first-party (Alibaba/DashScope) 白名单注入,第三方默认不发。详见 §11 | R3 后 qwen-code 的 session header 与 claude-code 同样的 first-party-only 语义;`traceparent` 仍待 per-destination toggle follow-up |
| `x-client-request-id` (随机) | 有,自动 | 暂不做(独立 follow-up sub-issue 价值更高) | 范围控制 |
| 子进程 `TRACEPARENT` env | 文档承认存在(实现闭源) | 不做(独立 follow-up | 范围控制 |
| 入站 `TRACEPARENT` 读取 | 文档承认存在(`-p` / Agent SDK 模式) | 不做(独立 follow-up | 范围控制 |
**verified vs documented 注解**
| claim | 验证状态 |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-Claude-Code-Session-Id` via `defaultHeaders` | ✅ Open source `src/services/api/client.ts:108` 已读 |
| `x-client-request-id` via fetch wrapper | ✅ Open source `src/services/api/client.ts:370-390` 已读 |
| `traceparent` 注入 | ⚠️ 仅 docs.claude.com/docs/en/monitoring-usage.md 提到;开源 repo `grep -rn "propagation\.inject\|UndiciInstrumentation\|traceparent" src` 返回空 |
## 10. 未来工作
挂在 #3731 P3 下,本设计**不**包含但与之相关:
- **`X-Qwen-Code-Request-Id`** 随机 UUID per requestclaude-code 等价:`x-client-request-id`)。对超时/timeout error correlation 有用——超时时服务端可能还没 assign request id客户端先发的 id 是唯一关联手段。R3 修订后这个建议变得更有意义per-request UUID 没有"跨请求行为画像"风险,可以作为"对所有 LLM provider 发送的支持/调试 header"。
- **`traceparent` 的 per-destination scope toggle** — R3 修订仅处理了 session id header 的作用域;`traceparent` 仍向所有出站 fetch 注入。可以加 `telemetry.propagateTraceContext: 'trusted-hosts' | 'all' | 'none'`,使用与 §11 同一份 allowlist 决定行为。
- **Gemini 的 session id staleness lazy-invalidate fix**§8.6 选项 A`/clear` 时 mark contentGenerator dirty下次 LLM 调用 lazy recreate。让 Gemini 路径也享受 fetch wrapper 的实时性。
- **子进程 `TRACEPARENT` env**:给 `BashTool` 执行子进程时注入 env让外部工具能续传 trace。需要单独看 tool execution lifecycle。
- **入站 `TRACEPARENT`**`--prompt` 模式启动时读 env让 CI / 外部 orchestrator 能把 qwen-code 接到更大的 trace。
- **可配置 `correlationHeader` name**:让企业 ops 自定义 header默认 `X-Qwen-Code-Session-Id`)。
- **`baggage` propagation 策略**:是否主动 set baggage 让 `user.id` / `tenant.id` 等也走 baggage 传到下游。本期不做,等需求明确。
## 11. R3 修订 — Host-Allowlist Scoping for `X-Qwen-Code-Session-Id`
> 触发:[LaZzyMan 在 PR #4390 的 REQUEST_CHANGES review](https://github.com/QwenLM/qwen-code/pull/4390)
> 落地 commit`1c8528a56` (核心实现) + `cb162e716` (Vertex baseUrl fail-closed + `["*"]` trim 容错)
### 11.1 触发与论证
R1 设计把 `X-Qwen-Code-Session-Id` 向**所有**出站 LLM 请求注入,仅由 `telemetry.enabled` 控制。LaZzyMan review 指出了三个递进的问题:
1. **标签错位**`feat(telemetry):` + `telemetry/` 路径 + `getTelemetryEnabled()` gate 让用户合理理解为"自家可观测性数据流向自家 collector"。但 `X-Qwen-Code-Session-Id` 不会到达 OTLP 后端,它走在 LLM API 请求里发给 DashScope / OpenAI / Anthropic / Gemini / OpenRouter / MiniMax / ModelScope / Mistral。两种不同的数据出口决策绑在一个开关上。
2. **claude-code 类比不成立**R1 在 §9 把命名空间策略和 fetch wrapper 模式都"对齐"了 claude-code。但 claude-code 是 Anthropic 一方 → Anthropic 一方single vendor, single directionqwen-code 是开源 CLI → 多个第三方 provider。"一个稳定 cross-request UUID 广播到所有第三方"是 R1 没正面回答的问题。
3. **traceparent 是同一指纹的另一通道**trace id = `sha256(sessionId).slice(0, 32)`,对接收方来说仍是稳定 per-session 标识符(哈希后不可逆,但同一 session 仍稳定)。
LaZzyMan 标定 severitysession id `high` / traceparent `medium`
### 11.2 解法概要
**收窄默认作用域到 first-party hosts**。新增一项 setting
```jsonc
"telemetry": {
"sessionIdHeaderHosts": ["*"] // 恢复 R1 广播行为
"sessionIdHeaderHosts": [] // 全关 header
"sessionIdHeaderHosts": ["api.mycompany.com",
"*.gateway.mycompany.internal"]
}
```
默认值(来自 `packages/core/src/telemetry/trusted-llm-hosts.ts:DEFAULT_SESSION_ID_HEADER_HOSTS`
```
dashscope.aliyuncs.com
dashscope-intl.aliyuncs.com
*.dashscope.aliyuncs.com
*.dashscope-intl.aliyuncs.com
*.alibaba-inc.com
*.aliyun-inc.com
```
这个集合的语义是"LLM provider、ARMS Tracing 后端、qwen-code distribution 同一法律实体"——也就是 claude-code 那个 single-vendor / single-direction 关系在 qwen-code 的对应集合。第三方 providerOpenAI / Anthropic / OpenRouter / 等)默认**不**接收 header。
### 11.3 Pattern 语法intentionally tiny
`matchesTrustedHost(hostname, patterns)` 只支持两种模式,与 `DashScopeOpenAICompatibleProvider.isDashScopeProvider` 对齐:
- bare hostname → 精确匹配case-insensitive
- `*.suffix` → 匹配 `suffix` 自身 **AND** 任何子域dot-anchored 拒绝 `evil-alibaba-inc.com` / `alibaba-inc.com.attacker.tld` 等 typo-suffix 攻击向量
不引入 regex、不引入端口/scheme 感知 globbing —— 让 settings 里的字符串就是它字面看起来的语义。
### 11.4 实现差异 vs R1
#### `wrapFetchWithCorrelation` (OpenAI / Anthropic)
R1 的 wrapper 只有 telemetry-enabled + sessionId 两个 gate。R3 在两者之间插入第三个 gate
```ts
const trustedHosts =
config.getTelemetrySessionIdHeaderHosts?.() ??
DEFAULT_SESSION_ID_HEADER_HOSTS;
const broadcastAll = trustedHosts.some((p) => p.trim() === '*');
return async function correlationFetch(input, init) {
if (!config.getTelemetryEnabled()) return baseFetch(input, init);
if (!broadcastAll) {
const host = extractRequestHost(input);
if (!host || !matchesTrustedHost(host, trustedHosts)) {
return baseFetch(input, init); // host gate
}
}
const sid = config.getSessionId();
if (!sid) return baseFetch(input, init);
// ... header injection
};
```
`trustedHosts` 在 wrap 时一次性 snapshot与 session id 的"每请求实时读"不同)。中途修改 `telemetry.sessionIdHeaderHosts` 需要重建 contentGenerator 才生效。`[" * "]` 之类带空格的写法通过 `.trim()` 兜底成 broadcast避免 settings.json 手敲笔误沉默退化。
#### `staticCorrelationHeaders` (Gemini)
签名加一个 `destinationUrl?: string` 参数:
```ts
export function staticCorrelationHeaders(
config: Config,
destinationUrl?: string,
): Record<string, string> {
if (!config.getTelemetryEnabled()) return {};
if (!destinationUrl) return {}; // fail-closed: 不知道目的地就不发
if (!matchesTrustedHost(new URL(destinationUrl).hostname, trustedHosts)) {
return {};
}
return { [SESSION_ID_HEADER]: config.getSessionId() };
}
```
#### Gemini factory 集成
Gemini SDK 有两个不可见 default endpoint`generativelanguage.googleapis.com``{region}-aiplatform.googleapis.com`,由 `vertexai` 决定factory 层无法准确还原其中之一。R3 选择"`config.baseUrl` 没设就传 `undefined`",让 helper fail-closed → 不发 header。运营商想要相关性必须显式设 `baseUrl`(也是 SDK 自己用来解 destination 的同一输入)。这一改动避免了猜错 Vertex destination 后被允许列表错误命中。
### 11.5 新文件 / 新代码
| 文件 | 说明 |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `packages/core/src/telemetry/trusted-llm-hosts.ts` (NEW) | `DEFAULT_SESSION_ID_HEADER_HOSTS` + `matchesTrustedHost` + `extractRequestHost` |
| `packages/core/src/telemetry/trusted-llm-hosts.test.ts` (NEW) | 单测,含 TLD-suffix 攻击向量、IPv6 fail-closed、port/userinfo/query 提取 |
| `packages/core/src/telemetry/llm-correlation-fetch.ts` | 加 host gate`staticCorrelationHeaders``destinationUrl` 参数 |
| `packages/core/src/telemetry/llm-correlation-fetch.test.ts` | 加 host-gate 8 个 case`mockConfig``'hosts' in opts` 区分 "default allowlist" vs "broadcast" |
| `packages/core/src/telemetry/config.ts` (`resolveTelemetrySettings`) | 透传 `sessionIdHeaderHosts` |
| `packages/core/src/config/config.ts` | `TelemetrySettings.sessionIdHeaderHosts` + `getTelemetrySessionIdHeaderHosts()` getter |
| `packages/core/src/core/geminiContentGenerator/index.ts` | 传 `config.baseUrl` 给 helperfail-closed when undefined |
| `packages/core/src/core/geminiContentGenerator/index.test.ts` | 重写 telemetry-on Gemini 测试以匹配新 fail-closed 语义 |
| `packages/cli/src/config/settingsSchema.ts` | `sessionIdHeaderHosts` JSON schema 入口 |
| `packages/vscode-ide-companion/schemas/settings.schema.json` | 由 `npm run generate:settings-schema` 重新生成 |
| `docs/developers/development/telemetry.md` | "Session correlation header" 段落改写 + 默认 scope + override 语法 |
### 11.6 对各 LazzyMan 论点的回应
| LazzyMan 论点 | R3 回应 |
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ① telemetry 标签错位 | **化解**:在 DashScope 用例下session id header 字面就是发给 ARMS Tracing 后端(同一法律实体),`telemetry.enabled` 语义对齐 |
| ② cross-vendor stable identifier 广播 | **化解**:默认 allowlist 只含阿里系 first-party host广播退化为 opt-in (`["*"]`) |
| ③ traceparent 是同一指纹的另一通道 | **暂保留**traceparent 仍按 R1 全注入。理由W3C 标准、trace id 是 sha256 哈希、in-vendor trace 续接是 W3C 的核心设计场景。per-destination traceparent toggle 列入 §10 future work |
### 11.7 已知遗留 + 跟进
- **traceparent scope** — 见上文第 ③ 点,列入 §10
- **Per-request random UUID** (`X-Qwen-Code-Request-Id`) — LazzyMan 提的替代方案,列入 §10
- **Gemini staleness lazy-invalidate** (§8.6 选项 A) — 与 R3 解耦,独立 sub-issue
- **`matchesTrustedHost` IPv6 支持** — 当前 IPv6 destination 永不在 allowlist 上(`URL.hostname` 返回 `[::1]` 带方括号pattern 语法无对应形式)。当前满足"命名 first-party endpoint"用例。若将来有 raw IP allowlist 需求再扩展。
## 12. R4 修订 — Scope Conflation Split
> 触发:[LaZzyMan round-8 follow-up review on PR #4390](https://github.com/QwenLM/qwen-code/pull/4390)
> 落地:本 PR 收窄R3 落地的 session-id 整套挪到独立 follow-up PR
### 12.1 触发与论证
R3 化解了 LaZzyMan 第一轮 review 的「广播稳定指纹给第三方 provider」担忧severity: high。但在 round-8 follow-up 中他升级到更深的架构原则反对:
> "Telemetry is not a container for adjacent features. The `traceparent` cross-process propagation and the `X-Qwen-Code-Session-Id` header injection are **not telemetry**. They are outbound-identity / outbound-correlation work that uses some OTel APIs internally as an implementation detail."
他的核心元论点:
- **"telemetry" namespace 暗示 recipient = 用户自己的 OTLP collector**
- 但 `traceparent``X-Qwen-Code-Session-Id` 的 recipient = **第三方 LLM provider**
- 两类不同 recipient 应该有两类不同的同意决策树
- 即使默认行为安全R3 已实现),把 wire-level 行为放在 `telemetry.*` 下**设了坏先例**:未来 telemetry PR 可以继续偷渡 wire 行为给第三方
- "If we accept that principle, the split is mechanical. If we don't, this PR is the wrong place to debate it because the technical fixes are already in."
### 12.2 解法概要("方案 C" hybrid split
经过几轮内部讨论(含 yiliang 提出的 customHeader 模板替代方案,最终判定 customHeader 不能携带 runtime-dynamic 值),决定走 **方案 C**
**本 PR 留下**
- `UndiciInstrumentation` 注册(产 client HTTP span → 用户自家 OTLP collector
- OTLP feedback-loop guard前者的必要副作用
- **`NoopTextMapPropagator` 默认安装** → `propagation.inject()` 是 no-op → outbound `fetch` 上**不再有 `traceparent`**
- **新增 `outboundCorrelation.propagateTraceContext: bool` (默认 false)** 作为独立 namespace 顶级设置;设 true 时安装默认 W3C composite propagator
- 整套 `R3 session-id` 代码(`llm-correlation-fetch.ts` / `trusted-llm-hosts.ts` / `telemetry.sessionIdHeaderHosts` setting / 4 个 provider 集成点 / 所有相关测试)**全部移除**
**搬到 follow-up PR**
- `X-Qwen-Code-Session-Id` header 整套机器R3 实现复用)
- 进入新 `outboundCorrelation.*` namespace具体 setting key TBD但**不会**叫 `telemetry.*`
- Follow-up PR 自带threat model section、独立 review、security-relevant 标注的 docs
- `X-Qwen-Code-Request-Id` per-request UUIDLazzyMan 在 R3 round 提出的替代设计)也归入此 follow-up 的考虑范围
### 12.3 与 R3 R1 论点的映射
| R1/R3 论点 | R4 后状态 |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| §3.1 "所有出站 LLM 请求带 traceparent" | ❌ **R4 默认 off**;需 `outboundCorrelation.propagateTraceContext: true` 才开 |
| §3.1 "所有出站 LLM 请求带 `X-Qwen-Code-Session-Id`" | ❌ **R4 整套移出本 PR**,搬到 follow-up PR |
| §4.3 fetch wrapper 注入 session id | ❌ 整段代码不在本 PR复用到 follow-up PR |
| §11 host allowlist (R3 设计) | ❌ 同上;整体迁移 follow-up PR |
| §4.4 不引入新 setting | ❌ **本 PR 新增 `outboundCorrelation.propagateTraceContext`** 一个 booleansession id 相关 setting 在 follow-up PR |
| §10 future work "`X-Qwen-Code-Request-Id`" | ✅ 仍是 future work与 session-id follow-up 一起设计 |
### 12.4 新 namespace 设计意图
`outboundCorrelation.*` 顶级 namespace 在本 PR 只有一个 boolean (`propagateTraceContext`),看起来过度结构化。但这是**精心选择的**
- **建立命名空间作为承诺**:让后续 session-id / request-id / etc. 自然进入这个 namespace
- **标注为 security-relevant**`settingsSchema.ts` description 显式写 "SECURITY-RELEVANT",文档化为"安全设置"而非"observability 设置"
- **defaults 全部 off**:符合 LazzyMan 提出的"open-source 客户端不应未经显式同意向第三方发稳定 id"原则
- **与 telemetry.\* 解耦**:用户读 settings.json 看到 `outboundCorrelation.*` 立刻能识别这是出站 wire 行为,不是 observability
#### 隐性依赖:`telemetry.enabled`
虽然 namespace 与 `telemetry.*` 解耦,**运行时生效仍依赖 `telemetry.enabled: true`** —— OTel SDK 只在 telemetry 启用时初始化,没有 SDK 就没有 propagator 安装、没有 `propagation.inject()` 调用flag 等于沉默 no-op。容易踩的 footgun运营商加 `propagateTraceContext: true` 却忘开 telemetrytrap server 上看不到任何 `traceparent`,无 error / 无 warning。
两个面向用户的面板都显式标注此依赖:
- `telemetry.md``propagateTraceContext` 段附完整双 flag JSON 示例
- `settingsSchema.ts` 的 description string **首句**即 "Requires `telemetry.enabled: true`"(前置以避免 VS Code 设置 UI 长描述折叠后看不到)
未来若添加 session-id header 或其他 `outboundCorrelation.*` setting**同一依赖关系适用** —— 都得在 telemetry 启用前提下才有意义(因为它们都通过 OTel instrumentation/SDK 注入。Follow-up PR 应继承此 footgun 提示模式。
### 12.5 实施
| 文件 | 改动 |
| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `packages/core/src/telemetry/llm-correlation-fetch.ts` | **删除** |
| `packages/core/src/telemetry/llm-correlation-fetch.test.ts` | **删除** |
| `packages/core/src/telemetry/trusted-llm-hosts.ts` | **删除** |
| `packages/core/src/telemetry/trusted-llm-hosts.test.ts` | **删除** |
| `packages/core/src/telemetry/sdk.ts` | + `NoopTextMapPropagator`;按 `getOutboundCorrelationPropagateTraceContext()` 决定 SDK textMapPropagator |
| `packages/core/src/core/openaiContentGenerator/provider/default.ts` | 移除 `wrapFetchWithCorrelation` 引用 |
| `packages/core/src/core/openaiContentGenerator/provider/dashscope.ts` | 同上 |
| `packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts` | 同上 |
| `packages/core/src/core/geminiContentGenerator/index.ts` | 移除 `staticCorrelationHeaders` 引用 |
| 上述 4 个 provider 的 `*.test.ts` | 删 session-id 相关测试 case |
| `packages/core/src/config/config.ts` | 删 `TelemetrySettings.sessionIdHeaderHosts``getTelemetrySessionIdHeaderHosts`**新增 `OutboundCorrelationSettings` 接口 + `outboundCorrelationSettings` 字段 + `getOutboundCorrelationPropagateTraceContext()` getter** |
| `packages/core/src/telemetry/config.ts` | 删 `resolveTelemetrySettings` 中 sessionIdHeaderHosts 透传 |
| `packages/cli/src/config/settingsSchema.ts` | 删 `sessionIdHeaderHosts` schema**新增 `outboundCorrelation` 顶级 schema 项** |
| `packages/cli/src/config/config.ts` | 透传 `outboundCorrelation: settings.outboundCorrelation``ConfigParameters` |
| `packages/vscode-ide-companion/schemas/settings.schema.json` | `npm run generate:settings-schema` 重新生成description 后续更新时同步刷新) |
| `docs/developers/development/telemetry.md` | 重写 "Trace context propagation" → "Client-side HTTP span on outbound fetch";删 "Session correlation header" 整节;新增 "Outbound correlation (SECURITY-RELEVANT)" 顶级 section`telemetry.enabled` 依赖说明 + JSON 配置示例 |
| `docs/design/telemetry-outbound-propagation-design.md` | 本节 + R4 表头 + 修订指针 |
| `packages/core/src/config/config.test.ts` | **新增 `OutboundCorrelation Configuration` describe block**`it.each` 4 个 case 锁定 `getOutboundCorrelationPropagateTraceContext` 的 default-false 安全不变性omitted / `{}` / explicit true / explicit false |
### 12.6 对 LazzyMan 元论点的回应
| 论点 | R4 后状态 |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| "Telemetry namespace 暗示自家 collector 接收方" | ✅ wire 行为已搬出 `telemetry.*`;新 `outboundCorrelation.*` namespace 显式标识"出站第三方"语义 |
| "默认行为不应未经显式同意向第三方发标识符" | ✅ `propagateTraceContext` 默认 falsesession-id 整套 follow-up PR 也将默认 off |
| "telemetry PR 不应偷渡 wire-level 行为" | ✅ 本 PR 不再添加任何"telemetry 控制 wire 行为"的代码路径wire 行为统一由 `outboundCorrelation.*` 管 |
| "split is mechanical, work isn't wasted" | ✅ R3 落地代码物理删除自本 branch留在 git history 里给 follow-up PR 复用(或 cherry-pick |
### 12.7 follow-up PR 大纲(信息性,不在本 PR 范围)
未来 follow-up PR 应包含:
- `outboundCorrelation.sessionIdHeader: { enabled, trustedHosts }` 或类似 setting
- 复用 R3 已实现的 `wrapFetchWithCorrelation` / `matchesTrustedHost` / `DEFAULT_SESSION_ID_HEADER_HOSTS` 代码骨架
- threat model 一节明确recipient 集合、稳定 id 的去匿名化窗口、可选 per-request UUID 配套
- **默认 off**(无 default allowlist —— 比 R3 更严,符合 LazzyMan 的开源 CLI 原则)
- security-relevant 标注 + docs/users/configuration/settings.md 收录

View file

@ -262,6 +262,99 @@ and logs still carry `session.id`, and trace / log backends (Jaeger, Tempo,
Loki, Aliyun SLS / ARMS Tracing) handle per-session slicing natively without
cardinality pressure.
### Client-side HTTP span on outbound fetch
When telemetry is enabled, Qwen Code registers `UndiciInstrumentation`
which creates a client-side HTTP span for every outbound `fetch()`
request originated by the process — including the LLM SDKs (`openai`,
`@google/genai`, `@anthropic-ai/sdk`), the MCP StreamableHTTP client, the
`WebFetch` tool, and any IDE-extension out-of-process calls. The span
lets you see network latency (TTFB / response body transfer) separately
from upstream model processing time, which the existing
`api.generateContent` span alone can't distinguish.
These spans go to your **own** OTLP collector (or file outfile) just like
the rest of the telemetry — they do not affect what is written onto the
outbound HTTP request itself. Whether the W3C `traceparent` header is
also written into the outgoing request stream is controlled by a
**separate, security-relevant setting** documented in
[outbound correlation](#outbound-correlation-security-relevant) below.
**Feedback-loop avoidance.** OTel SDK uses `fetch` internally to upload OTLP
data. Without protection, instrumenting `fetch` would trace those uploads,
which would themselves be uploaded, causing an infinite loop. Qwen Code's
undici instrumentation is configured with an `ignoreRequestHook` that skips
URLs matching the configured `telemetry.otlpEndpoint` /
`telemetry.otlpTracesEndpoint` / `telemetry.otlpLogsEndpoint` /
`telemetry.otlpMetricsEndpoint` prefixes. In file-outfile mode there are no
outbound HTTP uploads, so the hook is a no-op.
## Outbound correlation (SECURITY-RELEVANT)
These settings live in a **separate top-level namespace** from `telemetry.*`
on purpose: telemetry controls data flow into the operator's own
observability backend, while `outboundCorrelation.*` controls what
client-side correlation data qwen-code writes **into outbound LLM API
request streams** that reach third-party LLM provider endpoints
(DashScope, OpenAI, Anthropic, etc.). Different recipients, different
consent decision. **All values default to off.** See PR #4390 review
discussion for the framing rationale.
### `outboundCorrelation.propagateTraceContext`
```jsonc
"outboundCorrelation": {
"propagateTraceContext": false // default
}
```
When `false` (default), Qwen Code installs a no-op `TextMapPropagator` on
the OTel SDK. UndiciInstrumentation still creates client HTTP spans for
your OTLP collector, but `propagation.inject()` is a no-op so **no
`traceparent` is written onto outbound requests**. Trace IDs stay
internal to the operator's collector.
When `true`, the SDK's default W3C composite propagator
(`tracecontext` + `baggage`) is installed and the standard `traceparent`
header is written on every outbound `fetch`:
```
traceparent: 00-<32-hex traceId>-<16-hex parentSpanId>-<01-sampled | 00-not-sampled>
```
Opt in only when the LLM provider also reports into your OTel collector
for cross-process trace stitching — e.g. ARMS Tracing serving DashScope.
For most operators the value is `false`; cross-vendor trace continuation
is niche.
**Depends on `telemetry.enabled: true`.** The OTel SDK only initializes
when telemetry is enabled, so `propagateTraceContext` only takes effect
in that state. Setting it to `true` while telemetry is disabled is a
silent no-op — no SDK, no propagator, no `traceparent` on the wire.
Verify both flags when wiring an ARMS+DashScope correlation setup:
```jsonc
{
"telemetry": {
"enabled": true,
"otlpTracesEndpoint": "http://tracing-analysis-...",
},
"outboundCorrelation": {
"propagateTraceContext": true,
},
}
```
### Other outbound correlation headers
`X-Qwen-Code-Session-Id` and `X-Qwen-Code-Request-Id` are **not part of
this PR**. They will be designed and proposed in their own follow-up
PR(s) under the same `outboundCorrelation.*` namespace, each with its
own threat model and operator-consent flow. PR #4390 review (LaZzyMan)
established the principle: "telemetry's scope of work doesn't include
sending identifiers to LLM providers"; correlation-header work moves to
its own design discussion rather than landing under telemetry.
## Aliyun Telemetry
### Manual OTLP Export

23
package-lock.json generated
View file

@ -2561,6 +2561,22 @@
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/instrumentation-undici": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.14.0.tgz",
"integrity": "sha512-2HN+7ztxAReXuxzrtA3WboAKlfP5OsPA57KQn2AdYZbJ3zeRPcLXyW4uO/jpLE6PLm0QRtmeGCmfYpqRlwgSwg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "^2.0.0",
"@opentelemetry/instrumentation": "^0.203.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.7.0"
}
},
"node_modules/@opentelemetry/otlp-exporter-base": {
"version": "0.203.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.203.0.tgz",
@ -17564,6 +17580,7 @@
"@opentelemetry/exporter-trace-otlp-grpc": "^0.203.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.203.0",
"@opentelemetry/instrumentation-http": "^0.203.0",
"@opentelemetry/instrumentation-undici": "^0.14.0",
"@opentelemetry/sdk-node": "^0.203.0",
"@types/html-to-text": "^9.0.4",
"@xterm/headless": "5.5.0",
@ -20941,9 +20958,9 @@
}
},
"packages/web-templates/node_modules/@types/react": {
"version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"version": "18.3.29",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz",
"integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==",
"dev": true,
"license": "MIT",
"dependencies": {

View file

@ -1710,6 +1710,7 @@ export async function loadCliConfig(
screenReader,
},
telemetry: telemetrySettings,
outboundCorrelation: settings.outboundCorrelation,
usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true,
clearContextOnIdle: settings.context?.clearContextOnIdle,
fileFiltering: settings.context?.fileFiltering,

View file

@ -8,6 +8,7 @@ import type {
MCPServerConfig,
BugCommandSettings,
TelemetrySettings,
OutboundCorrelationSettings,
AuthType,
ChatCompressionSettings,
ModelProvidersConfig,
@ -1037,6 +1038,29 @@ const SETTINGS_SCHEMA = {
},
},
outboundCorrelation: {
type: 'object',
label: 'Outbound Correlation',
category: 'Advanced',
requiresRestart: true,
default: undefined as OutboundCorrelationSettings | undefined,
description:
"SECURITY-RELEVANT. Controls what client-side correlation data qwen-code writes into outbound LLM API requests (DashScope, OpenAI, Anthropic, etc.) — separate from `telemetry.*` which governs data flow into the operator's OWN OTLP collector. All values default to off. Opt in only when the LLM provider also reports into your OTel collector for cross-process trace stitching (e.g. ARMS Tracing + DashScope).",
showInDialog: false,
jsonSchemaOverride: {
type: 'object',
properties: {
propagateTraceContext: {
description:
"Requires `telemetry.enabled: true`. Inject W3C `traceparent` header on outbound `fetch` requests (LLM SDK calls, MCP StreamableHTTP, WebFetch, ...). Default: false — trace context stays internal to the operator's OTLP collector and is NOT written onto third-party request streams. Set true only when you want cross-process trace stitching with an OTel-aware LLM provider (e.g. ARMS+DashScope). Client HTTP spans are still emitted in either case; this flag only governs the wire `traceparent` header.",
type: 'boolean',
default: false,
},
},
additionalProperties: false,
},
},
fastModel: {
type: 'string',
label: 'Fast Model',

View file

@ -35,6 +35,7 @@
"@opentelemetry/exporter-trace-otlp-grpc": "^0.203.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.203.0",
"@opentelemetry/instrumentation-http": "^0.203.0",
"@opentelemetry/instrumentation-undici": "^0.14.0",
"@opentelemetry/sdk-node": "^0.203.0",
"@types/html-to-text": "^9.0.4",
"@xterm/headless": "5.5.0",

View file

@ -1659,6 +1659,37 @@ describe('Server Config (config.ts)', () => {
});
});
describe('OutboundCorrelation Configuration', () => {
// Default-to-false is security-relevant — controls whether
// `traceparent` is written onto outbound LLM/fetch request streams.
it.each<{
label: string;
outboundCorrelation: ConfigParameters['outboundCorrelation'];
expected: boolean;
}>([
{ label: 'omitted', outboundCorrelation: undefined, expected: false },
{ label: 'empty object', outboundCorrelation: {}, expected: false },
{
label: 'explicit true',
outboundCorrelation: { propagateTraceContext: true },
expected: true,
},
{
label: 'explicit false',
outboundCorrelation: { propagateTraceContext: false },
expected: false,
},
])(
'propagateTraceContext resolves to $expected when $label',
({ outboundCorrelation, expected }) => {
const config = new Config({ ...baseParams, outboundCorrelation });
expect(config.getOutboundCorrelationPropagateTraceContext()).toBe(
expected,
);
},
);
});
describe('UseRipgrep Configuration', () => {
it('should default useRipgrep to true when not provided', () => {
const config = new Config(baseParams);

View file

@ -336,6 +336,42 @@ export interface TelemetryMetricsSettings {
includeSessionId?: boolean;
}
/**
* Security-relevant settings controlling what client-side correlation
* data qwen-code writes into outbound LLM API requests.
*
* **Why this is a separate namespace from `telemetry.*`:** telemetry
* controls data flow into the user's OWN observability backend (OTLP
* collector / file outfile). The settings here control data flow OUT of
* the qwen-code process and INTO third-party LLM provider request
* streams (DashScope, OpenAI, Anthropic, etc.). Different recipients =
* different consent decision, so a different settings tree. See PR
* #4390 review (LaZzyMan) for the framing rationale.
*
* All values default to off / no propagation. Operators who want to
* propagate trace context for server-side trace stitching (e.g. ARMS
* Tracing + DashScope) opt in explicitly.
*/
export interface OutboundCorrelationSettings {
/**
* Inject W3C `traceparent` header on outbound HTTP requests
* originated by undici / global `fetch` (LLM SDK calls, MCP
* StreamableHTTP clients, WebFetch tool, etc.). Default: `false`.
*
* When `false`, the SDK is configured with a no-op
* `TextMapPropagator` so trace context stays internal to the user's
* OTLP collector (operator still gets client HTTP spans, but the
* trace id is not written onto third-party request streams).
*
* When `true`, the OTel default W3C composite propagator
* (`tracecontext` + `baggage`) is installed and `traceparent` is
* written on every outbound `fetch`. Useful when the LLM provider
* also reports into the operator's OTel collector e.g. ARMS
* Tracing + DashScope for cross-process trace stitching.
*/
propagateTraceContext?: boolean;
}
export interface OutputSettings {
format?: OutputFormat;
}
@ -564,6 +600,7 @@ export interface ConfigParameters {
contextFileName?: string | string[];
accessibility?: AccessibilitySettings;
telemetry?: TelemetrySettings;
outboundCorrelation?: OutboundCorrelationSettings;
gitCoAuthor?: GitCoAuthorParam;
usageStatisticsEnabled?: boolean;
/**
@ -853,6 +890,7 @@ export class Config {
private autoModeDenialState: AutoModeDenialState = createDenialState();
private readonly accessibility: AccessibilitySettings;
private readonly telemetrySettings: TelemetrySettings;
private readonly outboundCorrelationSettings: OutboundCorrelationSettings;
private readonly gitCoAuthor: GitCoAuthorSettings;
private readonly usageStatisticsEnabled: boolean;
private readonly fileReadCacheDisabled: boolean;
@ -1021,6 +1059,10 @@ export class Config {
metrics: params.telemetry?.metrics,
resourceAttributeWarnings: params.telemetry?.resourceAttributeWarnings,
};
this.outboundCorrelationSettings = {
propagateTraceContext:
params.outboundCorrelation?.propagateTraceContext ?? false,
};
this.gitCoAuthor = {
...normalizeGitCoAuthor(params.gitCoAuthor),
name: 'Qwen-Coder',
@ -2870,6 +2912,15 @@ export class Config {
return this.telemetrySettings.resourceAttributeWarnings ?? [];
}
/**
* Whether to inject W3C `traceparent` on outbound `fetch` requests
* (LLM SDKs, MCP, WebFetch, etc.). Default false see
* `OutboundCorrelationSettings` for rationale.
*/
getOutboundCorrelationPropagateTraceContext(): boolean {
return this.outboundCorrelationSettings.propagateTraceContext ?? false;
}
getTelemetryOutfile(): string | undefined {
return this.telemetrySettings.outfile;
}

View file

@ -97,6 +97,8 @@ describe('AnthropicContentGenerator', () => {
mockConfig = {
getCliVersion: vi.fn().mockReturnValue('1.2.3'),
getProxy: vi.fn().mockReturnValue(undefined),
getTelemetryEnabled: vi.fn().mockReturnValue(false),
getSessionId: vi.fn().mockReturnValue('test-session'),
} as unknown as Config;
});

View file

@ -22,6 +22,8 @@ describe('createContentGenerator', () => {
getUsageStatisticsEnabled: () => true,
getContentGeneratorConfig: () => ({}),
getCliVersion: () => '1.0.0',
getTelemetryEnabled: () => false,
getSessionId: () => 'test-session',
} as unknown as Config;
const mockGenerator = {
@ -57,6 +59,8 @@ describe('createContentGenerator', () => {
getUsageStatisticsEnabled: () => false,
getContentGeneratorConfig: () => ({}),
getCliVersion: () => '1.0.0',
getTelemetryEnabled: () => false,
getSessionId: () => 'test-session',
} as unknown as Config;
const mockGenerator = {
models: {},

View file

@ -23,6 +23,8 @@ describe('createGeminiContentGenerator', () => {
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
getTelemetryEnabled: vi.fn().mockReturnValue(false),
getSessionId: vi.fn().mockReturnValue('test-session'),
} as unknown as Config;
});

View file

@ -52,6 +52,8 @@ vi.mock('@opentelemetry/exporter-trace-otlp-http');
vi.mock('@opentelemetry/exporter-logs-otlp-http');
vi.mock('@opentelemetry/exporter-metrics-otlp-http');
vi.mock('@opentelemetry/sdk-node');
vi.mock('@opentelemetry/instrumentation-http');
vi.mock('@opentelemetry/instrumentation-undici');
vi.mock('./gcp-exporters.js');
vi.mock('./log-to-span-processor.js');
vi.mock('./session-context.js');
@ -62,6 +64,8 @@ vi.mock('./tracer.js', () => ({
import { LogToSpanProcessor } from './log-to-span-processor.js';
import { setSessionContext } from './session-context.js';
import { createSessionRootContext } from './tracer.js';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici';
describe('resolveHttpOtlpUrl', () => {
it('appends signal path to base collector URL', () => {
@ -143,6 +147,7 @@ describe('Telemetry SDK', () => {
getDebugMode: () => false,
getSessionId: () => 'test-session',
getCliVersion: () => '1.0.0-test',
getOutboundCorrelationPropagateTraceContext: () => false,
} as unknown as Config;
});
@ -635,6 +640,504 @@ describe('Telemetry SDK', () => {
expect(attrs['team']).toBe('x');
});
});
describe('Outbound trace-context propagation gate', () => {
function getTextMapPropagator(): unknown {
const constructorCall = vi.mocked(NodeSDK).mock.calls[0]![0]!;
return (constructorCall as { textMapPropagator?: unknown })
.textMapPropagator;
}
it('installs a no-op TextMapPropagator by default (propagateTraceContext=false)', () => {
// Default behavior per PR #4390 R4 split: traceparent is NOT written
// onto outbound wire. The propagator's inject() must be a no-op so
// UndiciInstrumentation's `propagation.inject(carrier)` call writes
// nothing into the outgoing request's headers.
initializeTelemetry(mockConfig);
const propagator = getTextMapPropagator() as
| { inject: (...args: unknown[]) => void; fields: () => string[] }
| undefined;
expect(propagator).toBeDefined();
expect(typeof propagator!.inject).toBe('function');
// Sanity: fields() returns empty array → instrumentation knows there
// are no headers to clear / no propagator state.
expect(propagator!.fields()).toEqual([]);
// inject is a no-op — does not throw, does not mutate the carrier.
const carrier: Record<string, string> = { existing: 'h' };
expect(() =>
propagator!.inject({} as never, carrier, {} as never),
).not.toThrow();
expect(carrier).toEqual({ existing: 'h' });
});
it('uses the SDK default propagator when propagateTraceContext=true (operator opt-in)', () => {
vi.spyOn(
mockConfig,
'getOutboundCorrelationPropagateTraceContext',
).mockReturnValue(true);
initializeTelemetry(mockConfig);
// textMapPropagator is omitted from NodeSDK options → SDK installs
// its default `CompositePropagator` (W3CTraceContextPropagator +
// W3CBaggagePropagator). Test asserts the absence at the constructor
// boundary because the default composite is constructed inside
// @opentelemetry/sdk-node, which is auto-mocked here.
expect(getTextMapPropagator()).toBeUndefined();
});
});
describe('Instrumentations', () => {
function getInstrumentations(): unknown[] {
const constructorCall = vi.mocked(NodeSDK).mock.calls[0]![0]!;
return (constructorCall.instrumentations ?? []) as unknown[];
}
it('registers both HttpInstrumentation and UndiciInstrumentation', () => {
initializeTelemetry(mockConfig);
const instrumentations = getInstrumentations();
// The mocks make HttpInstrumentation / UndiciInstrumentation auto-mocked
// classes; instance-of checks against the mocked class still work.
expect(
instrumentations.some((i) => i instanceof HttpInstrumentation),
).toBe(true);
expect(
instrumentations.some((i) => i instanceof UndiciInstrumentation),
).toBe(true);
});
it('UndiciInstrumentation receives ignoreRequestHook that skips configured OTLP endpoints', () => {
vi.spyOn(mockConfig, 'getTelemetryOtlpProtocol').mockReturnValue('http');
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
'http://collector.example.com:4318',
);
initializeTelemetry(mockConfig);
const config = vi.mocked(UndiciInstrumentation).mock.calls[0]![0]! as {
ignoreRequestHook: (req: { origin: string; path: string }) => boolean;
};
// Configured OTLP endpoint must be skipped to avoid feedback loops.
expect(
config.ignoreRequestHook({
origin: 'http://collector.example.com:4318',
path: '/v1/traces',
}),
).toBe(true);
// Non-OTLP URLs (e.g. an LLM provider) must be traced.
expect(
config.ignoreRequestHook({
origin: 'https://dashscope.aliyuncs.com',
path: '/compatible-mode/v1/chat/completions',
}),
).toBe(false);
});
it('ignoreRequestHook is a pure no-op when no OTLP endpoint is configured', () => {
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue('');
vi.spyOn(mockConfig, 'getTelemetryOtlpTracesEndpoint').mockReturnValue(
undefined,
);
vi.spyOn(mockConfig, 'getTelemetryOtlpLogsEndpoint').mockReturnValue(
undefined,
);
vi.spyOn(mockConfig, 'getTelemetryOtlpMetricsEndpoint').mockReturnValue(
undefined,
);
vi.spyOn(mockConfig, 'getTelemetryOutfile').mockReturnValue('/tmp/x');
initializeTelemetry(mockConfig);
const config = vi.mocked(UndiciInstrumentation).mock.calls[0]![0]! as {
ignoreRequestHook: (req: { origin: string; path: string }) => boolean;
};
// No OTLP endpoint → nothing to ignore. Returning false means every
// request gets a client span (the desired behavior in outfile mode).
expect(
config.ignoreRequestHook({
origin: 'https://api.openai.com',
path: '/v1/chat/completions',
}),
).toBe(false);
});
it('ignoreRequestHook handles per-signal endpoint configuration', () => {
vi.spyOn(mockConfig, 'getTelemetryOtlpProtocol').mockReturnValue('http');
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue('');
vi.spyOn(mockConfig, 'getTelemetryOtlpTracesEndpoint').mockReturnValue(
'http://traces.example.com:4318/v1/traces',
);
vi.spyOn(mockConfig, 'getTelemetryOtlpLogsEndpoint').mockReturnValue(
'http://logs.example.com:4318/v1/logs',
);
initializeTelemetry(mockConfig);
const config = vi.mocked(UndiciInstrumentation).mock.calls[0]![0]! as {
ignoreRequestHook: (req: { origin: string; path: string }) => boolean;
};
// Traces endpoint matched verbatim.
expect(
config.ignoreRequestHook({
origin: 'http://traces.example.com:4318',
path: '/v1/traces',
}),
).toBe(true);
// Logs endpoint matched verbatim.
expect(
config.ignoreRequestHook({
origin: 'http://logs.example.com:4318',
path: '/v1/logs',
}),
).toBe(true);
// Unrelated host not skipped.
expect(
config.ignoreRequestHook({
origin: 'https://api.openai.com',
path: '/v1/chat/completions',
}),
).toBe(false);
});
it('ignoreRequestHook strips query string from incoming path for matching', () => {
vi.spyOn(mockConfig, 'getTelemetryOtlpProtocol').mockReturnValue('http');
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
'http://collector.example.com:4318',
);
initializeTelemetry(mockConfig);
const config = vi.mocked(UndiciInstrumentation).mock.calls[0]![0]! as {
ignoreRequestHook: (req: { origin: string; path: string }) => boolean;
};
// OTel SDK may append query params to OTLP requests; we still want
// those to be ignored.
expect(
config.ignoreRequestHook({
origin: 'http://collector.example.com:4318',
path: '/v1/traces?token=secret',
}),
).toBe(true);
});
it('ignoreRequestHook strips #fragment from incoming path for matching', () => {
vi.spyOn(mockConfig, 'getTelemetryOtlpProtocol').mockReturnValue('http');
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
'http://collector.example.com:4318',
);
initializeTelemetry(mockConfig);
const config = vi.mocked(UndiciInstrumentation).mock.calls[0]![0]! as {
ignoreRequestHook: (req: { origin: string; path: string }) => boolean;
};
expect(
config.ignoreRequestHook({
origin: 'http://collector.example.com:4318',
path: '/v1/traces#fragment',
}),
).toBe(true);
});
it('ignoreRequestHook normalizes endpoint config quoted in settings.json', () => {
// Defense against settings.json `"otlpEndpoint": "\"http://...\""` —
// quoted strings would otherwise miss the prefix match and reintroduce
// the feedback loop. Per PR review feedback.
vi.spyOn(mockConfig, 'getTelemetryOtlpProtocol').mockReturnValue('http');
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
'"http://collector.example.com:4318"',
);
initializeTelemetry(mockConfig);
const config = vi.mocked(UndiciInstrumentation).mock.calls[0]![0]! as {
ignoreRequestHook: (req: { origin: string; path: string }) => boolean;
};
expect(
config.ignoreRequestHook({
origin: 'http://collector.example.com:4318',
path: '/v1/traces',
}),
).toBe(true);
});
it('ignoreRequestHook strips #fragment from configured endpoint', () => {
vi.spyOn(mockConfig, 'getTelemetryOtlpProtocol').mockReturnValue('http');
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
'http://collector.example.com:4318/v1/traces#anchor',
);
initializeTelemetry(mockConfig);
const config = vi.mocked(UndiciInstrumentation).mock.calls[0]![0]! as {
ignoreRequestHook: (req: { origin: string; path: string }) => boolean;
};
expect(
config.ignoreRequestHook({
origin: 'http://collector.example.com:4318',
path: '/v1/traces',
}),
).toBe(true);
});
it('ignoreRequestHook does NOT bleed across port boundary (4318 vs 43180)', () => {
// Defense against the URL prefix boundary collision: a naive
// `url.startsWith(prefix)` would match `http://host:43180/...` against
// prefix `http://host:4318`. Origin comparison is exact, so a
// different port has a different origin and must not match.
vi.spyOn(mockConfig, 'getTelemetryOtlpProtocol').mockReturnValue('http');
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
'http://collector.example.com:4318',
);
initializeTelemetry(mockConfig);
const config = vi.mocked(UndiciInstrumentation).mock.calls[0]![0]! as {
ignoreRequestHook: (req: { origin: string; path: string }) => boolean;
};
expect(
config.ignoreRequestHook({
origin: 'http://collector.example.com:43180',
path: '/v1/traces',
}),
).toBe(false);
});
it('ignoreRequestHook does NOT bleed across hostname boundary (otlp vs otlp.evil)', () => {
// Defense against the hostname suffix collision: prefix
// `https://otlp.example.com` must NOT match
// `https://otlp.example.com.evil.net`. Origin comparison is exact.
vi.spyOn(mockConfig, 'getTelemetryOtlpProtocol').mockReturnValue('http');
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
'https://otlp.example.com',
);
initializeTelemetry(mockConfig);
const config = vi.mocked(UndiciInstrumentation).mock.calls[0]![0]! as {
ignoreRequestHook: (req: { origin: string; path: string }) => boolean;
};
expect(
config.ignoreRequestHook({
origin: 'https://otlp.example.com.evil.net',
path: '/v1/traces',
}),
).toBe(false);
});
it('ignoreRequestHook does NOT bleed across path-segment boundary (/v1 vs /v1foo)', () => {
// Prefix `http://host/v1` must NOT match `http://host/v1foo/x`.
vi.spyOn(mockConfig, 'getTelemetryOtlpProtocol').mockReturnValue('http');
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
'http://collector.example.com:4318/v1',
);
initializeTelemetry(mockConfig);
const config = vi.mocked(UndiciInstrumentation).mock.calls[0]![0]! as {
ignoreRequestHook: (req: { origin: string; path: string }) => boolean;
};
expect(
config.ignoreRequestHook({
origin: 'http://collector.example.com:4318',
path: '/v1foo/x',
}),
).toBe(false);
// Sanity: same-origin match still works.
expect(
config.ignoreRequestHook({
origin: 'http://collector.example.com:4318',
path: '/v1/traces',
}),
).toBe(true);
});
it('normalizeOtlpPrefix rejects unparseable URLs entirely (no dangerous "http" fallback)', () => {
// Critical fix: previously the catch fallback would let a typo like
// `"http"` produce the prefix `"http"`, which startsWith-matches every
// outbound HTTP request → silently disabled all instrumentation. The
// fix returns undefined for unparseable URLs and warns via diag.
const warnSpy = vi.spyOn(diag, 'warn').mockImplementation(() => {});
vi.spyOn(mockConfig, 'getTelemetryOtlpProtocol').mockReturnValue('http');
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
'not-a-valid-url',
);
vi.spyOn(mockConfig, 'getTelemetryOtlpTracesEndpoint').mockReturnValue(
undefined,
);
vi.spyOn(mockConfig, 'getTelemetryOtlpLogsEndpoint').mockReturnValue(
undefined,
);
vi.spyOn(mockConfig, 'getTelemetryOtlpMetricsEndpoint').mockReturnValue(
undefined,
);
initializeTelemetry(mockConfig);
const config = vi.mocked(UndiciInstrumentation).mock.calls[0]![0]! as {
ignoreRequestHook: (req: { origin: string; path: string }) => boolean;
};
// Unparseable endpoint produced NO prefix → hook is a no-op. Outbound
// LLM requests are NOT erroneously masked (this is the danger we
// prevent — the previous "http" fallback would mask everything).
expect(
config.ignoreRequestHook({
origin: 'https://api.openai.com',
path: '/v1/chat/completions',
}),
).toBe(false);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('not a valid URL'),
);
warnSpy.mockRestore();
});
it('HttpInstrumentation also receives ignoreOutgoingRequestHook for OTLP exporter', () => {
// The OTLP HTTP exporter uses node:http (patched by HttpInstrumentation,
// NOT undici). Without this guard, every OTLP upload batch creates a
// parasitic client span → feedback loop. PR #4390 review feedback.
vi.spyOn(mockConfig, 'getTelemetryOtlpProtocol').mockReturnValue('http');
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
'http://collector.example.com:4318',
);
initializeTelemetry(mockConfig);
const httpInstrumentationConfig = vi.mocked(HttpInstrumentation).mock
.calls[0]![0]! as {
ignoreOutgoingRequestHook: (req: {
protocol: string;
host?: string;
hostname?: string;
port?: string | number;
path: string;
}) => boolean;
};
// OTLP upload to configured collector → skipped.
expect(
httpInstrumentationConfig.ignoreOutgoingRequestHook({
protocol: 'http:',
host: 'collector.example.com:4318',
hostname: 'collector.example.com',
port: 4318,
path: '/v1/traces',
}),
).toBe(true);
// Unrelated LLM endpoint → traced.
expect(
httpInstrumentationConfig.ignoreOutgoingRequestHook({
protocol: 'https:',
host: 'dashscope.aliyuncs.com',
hostname: 'dashscope.aliyuncs.com',
path: '/compatible-mode/v1/chat/completions',
}),
).toBe(false);
});
it('matches default-port requests against a portless prefix (URL.origin parity)', () => {
// Regression: `URL.origin` strips `:80` from `http://collector` to give
// `http://collector`. The hook's manual `${proto}://${host}${portPart}`
// reconstruction kept `:80`, so prefix and request origin diverged →
// guard bypassed → feedback loop. PR #4390 review feedback (wenshao).
vi.spyOn(mockConfig, 'getTelemetryOtlpProtocol').mockReturnValue('http');
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
'http://collector.example.com',
);
initializeTelemetry(mockConfig);
const httpInstrumentationConfig = vi.mocked(HttpInstrumentation).mock
.calls[0]![0]! as {
ignoreOutgoingRequestHook: (req: {
protocol: string;
host?: string;
hostname?: string;
port?: string | number;
path: string;
}) => boolean;
};
// Default port HTTP request to portless prefix → must match.
expect(
httpInstrumentationConfig.ignoreOutgoingRequestHook({
protocol: 'http:',
hostname: 'collector.example.com',
port: 80,
path: '/v1/traces',
}),
).toBe(true);
});
it('fails open when req.protocol is missing (no silent HTTPS guard bypass)', () => {
// Regression: previous `|| 'http'` fallback silently mis-bucketed HTTPS
// requests as HTTP when `req.protocol` was unset, so HTTPS OTLP
// endpoints never matched their prefix → guard bypassed. Now: missing
// proto → return false → request gets instrumented (worst case is a
// parasitic span, observable; the previous default produced an
// unbounded feedback loop). PR #4390 review feedback (wenshao).
vi.spyOn(mockConfig, 'getTelemetryOtlpProtocol').mockReturnValue('http');
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
'https://collector.example.com:4318',
);
initializeTelemetry(mockConfig);
const httpInstrumentationConfig = vi.mocked(HttpInstrumentation).mock
.calls[0]![0]! as {
ignoreOutgoingRequestHook: (req: {
protocol?: string;
host?: string;
hostname?: string;
port?: string | number;
path: string;
}) => boolean;
};
expect(
httpInstrumentationConfig.ignoreOutgoingRequestHook({
// protocol intentionally omitted
hostname: 'collector.example.com',
port: 4318,
path: '/v1/traces',
}),
).toBe(false);
});
it('strips port from req.host fallback to avoid `host:port:port` URL reject', () => {
// Defensive: when `req.hostname` is absent and `req.host` already
// includes `:port` (e.g. `"collector:4318"`), naively appending
// `:${req.port}` produced `"http://collector:4318:4318"`, which
// `URL` rejects → silent guard bypass. Currently unreachable in
// practice (`@opentelemetry/otlp-exporter-base` always sets
// `hostname`) but the fallback path must be correct. PR #4390
// review feedback (wenshao).
vi.spyOn(mockConfig, 'getTelemetryOtlpProtocol').mockReturnValue('http');
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
'http://collector.example.com:4318',
);
initializeTelemetry(mockConfig);
const httpInstrumentationConfig = vi.mocked(HttpInstrumentation).mock
.calls[0]![0]! as {
ignoreOutgoingRequestHook: (req: {
protocol: string;
host?: string;
hostname?: string;
port?: string | number;
path: string;
}) => boolean;
};
expect(
httpInstrumentationConfig.ignoreOutgoingRequestHook({
protocol: 'http:',
// hostname intentionally absent; host carries the port already
host: 'collector.example.com:4318',
port: 4318,
path: '/v1/traces',
}),
).toBe(true);
});
it('normalizeOtlpPrefix strips asymmetric quotes for parity with parseOtlpEndpoint', () => {
// parseOtlpEndpoint (line 109) uses /^["']|["']$/g which strips
// asymmetric leading/trailing quotes. Previously normalizeOtlpPrefix
// only stripped symmetric quotes, so settings.json typos like
// `"value'` would let the exporter connect (parseOtlpEndpoint accepts)
// while the guard returned undefined (normalizeOtlpPrefix rejected) →
// parasitic-span loop. PR #4390 review feedback (wenshao).
vi.spyOn(mockConfig, 'getTelemetryOtlpProtocol').mockReturnValue('http');
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
'"http://collector.example.com:4318\'',
);
vi.spyOn(mockConfig, 'getTelemetryOtlpTracesEndpoint').mockReturnValue(
undefined,
);
vi.spyOn(mockConfig, 'getTelemetryOtlpLogsEndpoint').mockReturnValue(
undefined,
);
vi.spyOn(mockConfig, 'getTelemetryOtlpMetricsEndpoint').mockReturnValue(
undefined,
);
initializeTelemetry(mockConfig);
const config = vi.mocked(UndiciInstrumentation).mock.calls[0]![0]! as {
ignoreRequestHook: (req: { origin: string; path: string }) => boolean;
};
// Asymmetric-quoted endpoint normalized → guard matches OTLP traffic.
expect(
config.ignoreRequestHook({
origin: 'http://collector.example.com:4318',
path: '/v1/traces',
}),
).toBe(true);
});
});
});
describe('refreshSessionContext', () => {
@ -657,6 +1160,7 @@ describe('refreshSessionContext', () => {
getDebugMode: () => false,
getSessionId: () => 'test-session',
getCliVersion: () => '1.0.0-test',
getOutboundCorrelationPropagateTraceContext: () => false,
} as unknown as Config;
});

View file

@ -5,7 +5,11 @@
*/
import { DiagLogLevel, diag } from '@opentelemetry/api';
import type { DiagLogger } from '@opentelemetry/api';
import type {
Context,
DiagLogger,
TextMapPropagator,
} from '@opentelemetry/api';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-grpc';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc';
@ -20,6 +24,7 @@ import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-node';
import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici';
import type { Config } from '../config/config.js';
import { SERVICE_NAME } from './constants.js';
import { initializeMetrics } from './metrics.js';
@ -89,6 +94,29 @@ export function resolveHttpOtlpUrl(
// (2s) and overall (5s) timeouts, so this value is effectively unreachable there.
const SHUTDOWN_TIMEOUT_MS = 10_000;
/**
* `TextMapPropagator` that emits nothing. Installed when
* `outboundCorrelation.propagateTraceContext` is false (the default), so
* trace context stays internal to the user's OTLP collector and is not
* written into outbound `fetch` requests to third-party LLM providers.
*
* UndiciInstrumentation still creates client HTTP spans the propagator
* only governs whether `propagation.inject()` writes `traceparent` into
* the outgoing request's header carrier. With this propagator installed,
* inject is a no-op and outbound requests carry no trace headers. PR
* #4390 review (LaZzyMan): split outbound-wire behavior out of telemetry
* default-on.
*/
const NOOP_PROPAGATOR: TextMapPropagator = {
inject() {},
extract(context: Context): Context {
return context;
},
fields(): string[] {
return [];
},
};
let sdk: NodeSDK | undefined;
let telemetryInitialized = false;
let telemetryShutdownPromise: Promise<void> | undefined;
@ -314,12 +342,108 @@ export function initializeTelemetry(config: Config): void {
}
// If no exporter is configured for a signal, it is silently skipped.
// Build OTLP exporter URL prefixes once. Both HttpInstrumentation (which
// patches Node's built-in `http`/`https` — used by the OTLP HTTP exporter)
// and UndiciInstrumentation (which patches `fetch` / undici — used by LLM
// SDKs but also by some OTLP exporters when configured) must ignore
// requests to these endpoints. Otherwise an upload would create a span
// that gets exported, creating an infinite feedback loop. Use WHATWG URL
// parsing so a parsed prefix is always { origin, pathname } — never the
// dangerous bare `"http"` fallback that startsWith would match against
// every HTTP URL on the wire. See PR #4390 review feedback (wenshao).
function normalizeOtlpPrefix(
raw: string | undefined,
): { origin: string; pathname: string } | undefined {
if (!raw) return undefined;
// Trim surrounding whitespace + ASCII quotes a user may have placed in
// settings.json (`"value"` → `value`). Use the SAME lenient regex as
// `parseOtlpEndpoint` (line 109) so any endpoint the exporter accepts
// also gets a feedback-loop guard. Asymmetric quotes (e.g. `"value'`)
// are almost certainly typos but `parseOtlpEndpoint` strips them too —
// mismatching here would let the exporter connect while the guard
// returned `undefined`, reintroducing the parasitic-span loop. See PR
// #4390 review feedback (wenshao).
const s = raw.trim().replace(/^["']|["']$/g, '');
try {
const u = new URL(s);
// Drop ?query and #fragment — they're never part of the request
// signature an instrumentation observer sees on outbound requests.
// Strip a trailing `/` from path to keep prefix matching tight.
const pathname = u.pathname === '/' ? '' : u.pathname.replace(/\/$/, '');
return { origin: u.origin, pathname };
} catch {
// Unparseable URL (e.g. typo, placeholder). Reject entirely rather than
// attempt a string-level fallback — a fallback like `"http"` from input
// `"http"` would `startsWith`-match every outbound HTTP request and
// silently disable all instrumentation. Returning undefined means this
// misconfigured endpoint loses its feedback-loop guard, but the rest of
// the system stays correct.
diag.warn(
`Telemetry OTLP endpoint "${raw}" is not a valid URL; instrumentation feedback-loop guard for it is disabled.`,
);
return undefined;
}
}
const otlpUrlPrefixes = [
config.getTelemetryOtlpEndpoint(),
config.getTelemetryOtlpTracesEndpoint(),
config.getTelemetryOtlpLogsEndpoint(),
config.getTelemetryOtlpMetricsEndpoint(),
]
.map(normalizeOtlpPrefix)
.filter((u): u is { origin: string; pathname: string } => !!u);
// Boundary-safe URL match. `url.startsWith(prefix)` is unsafe because:
// - port: prefix `http://host:4318` matches `http://host:43180/x`
// - path: prefix `http://host/v1` matches `http://host/v1foo/x`
// - host: prefix `https://otlp.example.com` matches `https://otlp.example.com.evil.net`
// Comparing origin exactly + pathname with a path-boundary check avoids all
// three. The next char after the prefix pathname must be `/`, `?`, `#`, or
// end-of-string. See PR #4390 review feedback (wenshao).
const matchesOtlpPrefix = (origin: string, path: string): boolean => {
for (const prefix of otlpUrlPrefixes) {
if (origin !== prefix.origin) continue;
if (prefix.pathname === '') return true;
if (!path.startsWith(prefix.pathname)) continue;
const next = path.charAt(prefix.pathname.length);
if (next === '' || next === '/' || next === '?' || next === '#') {
return true;
}
}
return false;
};
// Strip ?query / #fragment from a path. `indexOf` (not regex) for CodeQL
// ReDoS hygiene.
const stripPathSuffix = (path: string): string => {
const qIdx = path.indexOf('?');
const fIdx = path.indexOf('#');
let cut = path.length;
if (qIdx !== -1) cut = Math.min(cut, qIdx);
if (fIdx !== -1) cut = Math.min(cut, fIdx);
return path.slice(0, cut);
};
// Outbound trace-context propagation gate (PR #4390 review, LaZzyMan):
// by default, install a no-op propagator so `traceparent` does NOT get
// written onto outbound `fetch` requests to LLM providers. Operators
// who want server-side trace stitching (e.g. ARMS+DashScope) opt in via
// `outboundCorrelation.propagateTraceContext: true`, which leaves the
// SDK's default W3C composite propagator in place. UndiciInstrumentation
// still creates client HTTP spans either way — the propagator only
// governs whether trace ids leak onto third-party request streams.
const textMapPropagator: TextMapPropagator | undefined =
config.getOutboundCorrelationPropagateTraceContext()
? undefined // undefined → NodeSDK keeps its default W3C propagator
: NOOP_PROPAGATOR;
sdk = new NodeSDK({
resource,
// Disable async host/process/env resource detectors: they leave attributes
// pending and trigger an OTel diag.error on any resource attribute read
// before the detectors settle (e.g. during HttpInstrumentation span creation).
autoDetectResources: false,
...(textMapPropagator && { textMapPropagator }),
spanProcessors: spanExporter ? [new BatchSpanProcessor(spanExporter)] : [],
logRecordProcessors: logExporter
? [new BatchLogRecordProcessor(logExporter)]
@ -327,7 +451,80 @@ export function initializeTelemetry(config: Config): void {
? [logToSpanProcessor]
: [],
...(metricReader && { metricReader }),
instrumentations: [new HttpInstrumentation()],
instrumentations: [
new HttpInstrumentation({
// OTLP HTTP exporter uses node:http (patched here, not by undici).
// Without this, every OTLP upload batch creates a parasitic client
// span that itself gets exported → feedback loop. See PR #4390
// review feedback (wenshao).
ignoreOutgoingRequestHook: (req) => {
if (otlpUrlPrefixes.length === 0) return false;
// Protocol must be known to compare reliably. The previous
// `|| 'http'` fallback silently mis-bucketed HTTPS requests as
// HTTP when `req.protocol` was unset, so HTTPS OTLP endpoints
// wouldn't match their prefix → guard bypassed → feedback loop.
// Now: when proto can't be determined, fail open (return false →
// request gets instrumented). Worst case is a parasitic client
// span for an OTLP request — observable and recoverable, vs. the
// unbounded feedback loop the previous default produced. See PR
// #4390 review feedback (wenshao).
const proto = req.protocol
? String(req.protocol).replace(/:$/, '')
: undefined;
if (!proto) return false;
// `req.host` may already include `:port` (e.g. `"collector:4318"`).
// Naively concatenating `:${req.port}` below would yield
// `"http://collector:4318:4318"`, which `new URL()` rejects → catch
// returns false → silent guard bypass. Currently unreachable because
// `@opentelemetry/otlp-exporter-base` always sets `hostname`, but
// the fallback exists and must be correct. Strip the port — IPv6
// literals like `"[::1]:443"` keep their bracketed host. See PR
// #4390 review feedback (wenshao).
let host = req.hostname || '';
if (!host && req.host) {
const h = String(req.host);
const bracketEnd = h.indexOf(']');
const portIdx =
bracketEnd !== -1 ? h.indexOf(':', bracketEnd) : h.indexOf(':');
host = portIdx !== -1 ? h.slice(0, portIdx) : h;
}
const portPart =
req.port !== undefined && req.port !== null && String(req.port)
? `:${req.port}`
: '';
// Route through `URL` so the reconstructed origin gets the same
// default-port stripping (`:80` for http, `:443` for https) that
// `normalizeOtlpPrefix` applies via `URL.origin`. Without this,
// prefix `http://collector` (no explicit port) wouldn't match a
// request to `http://collector:80/v1/traces` because `prefix.origin`
// strips `:80` while the manually built string keeps it. See PR
// #4390 review feedback (wenshao).
let origin: string;
try {
origin = new URL(`${proto}://${host}${portPart}`).origin;
} catch {
return false;
}
const path =
typeof req.path === 'string' ? stripPathSuffix(req.path) : '';
return matchesOtlpPrefix(origin, path);
},
}),
// Modern fetch (`globalThis.fetch` / undici) is the HTTP layer used by
// `openai`, `@google/genai`, and `@anthropic-ai/sdk`. Without this
// instrumentation, outbound LLM requests carry no `traceparent` header
// and the trace tree terminates at the qwen-code process boundary.
new UndiciInstrumentation({
ignoreRequestHook: (request) => {
if (otlpUrlPrefixes.length === 0) return false;
const path =
typeof request.path === 'string'
? stripPathSuffix(request.path)
: '';
return matchesOtlpPrefix(request.origin, path);
},
}),
],
});
try {

View file

@ -440,6 +440,18 @@
"additionalProperties": true,
"description": "Telemetry configuration."
},
"outboundCorrelation": {
"type": "object",
"properties": {
"propagateTraceContext": {
"description": "Requires `telemetry.enabled: true`. Inject W3C `traceparent` header on outbound `fetch` requests (LLM SDK calls, MCP StreamableHTTP, WebFetch, ...). Default: false — trace context stays internal to the operator's OTLP collector and is NOT written onto third-party request streams. Set true only when you want cross-process trace stitching with an OTel-aware LLM provider (e.g. ARMS+DashScope). Client HTTP spans are still emitted in either case; this flag only governs the wire `traceparent` header.",
"type": "boolean",
"default": false
}
},
"additionalProperties": false,
"description": "SECURITY-RELEVANT. Controls what client-side correlation data qwen-code writes into outbound LLM API requests (DashScope, OpenAI, Anthropic, etc.) — separate from `telemetry.*` which governs data flow into the operator's OWN OTLP collector. All values default to off. Opt in only when the LLM provider also reports into your OTel collector for cross-process trace stitching (e.g. ARMS Tracing + DashScope)."
},
"fastModel": {
"description": "Model used for generating prompt suggestions and speculative execution. Leave empty to use the main model. A smaller/faster model (e.g., qwen3-coder-flash) reduces latency and cost.",
"type": "string",