qwen-code/docs/design/telemetry-outbound-propagation-design.md
jinye 62ed44e1f3
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): client-side HTTP span + opt-in W3C traceparent propagation (#4384) (#4390)
* 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)
2026-05-25 22:16:54 +08:00

71 KiB
Raw Permalink Blame History

Telemetry: Outbound Trace Context & Session ID Header Propagation

配套 issue: #4384 父 issue: #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 guardtraceparent 默认 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

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.fetchNode 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/sdkanthropicContentGenerator 同样基于 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 DefaultOpenAICompatibleProviderbuildHeaders 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 overrideconst 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.tsdashscope.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

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 wrapperR3 修订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-Idclaude-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 toggletelemetry.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

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。

tracestatebaggage

  • 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 gatestaticCorrelationHeaders 也加了 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.sessionIdrefreshSessionContext(),但不重建 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

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):

/**
 * 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.fetchproxy与我们的 wrapper

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 buildClientbuildHeaders() 不动。

集成点 3: geminiContentGenerator/index.ts (factory, NOT 构造器)

修正先前设计的过度声明geminiContentGenerator.ts 构造器不需要改签名。index.ts:48 的 factory 函数已经接收 gcConfig: Configline 33 已经在用 gcConfig?.getUsageStatisticsEnabled()),只需要在 factory 里把 correlation 静态 headers merge 进 httpOptions.headers

// 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 不变。

优先级链

不变:用户的 customHeadersdefaultHeaders 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 影响

几乎为零。本设计不引入新 settingR3 修订:引入了一项新 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 读

未来可以加的 settingout of scope

  • telemetry.outboundCorrelationHeader: 自定义 header name默认 X-Qwen-Code-Session-Id
  • telemetry.outboundPropagationDisabled: 全局关闭(如果 LLM 服务对未知 header 严格)
  • per-destination header scope toggleR3 已落地,见 §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.headerscaller 已有 Config零 signature 改动 — 修正之前的 over-specification
packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts 修改 buildClient 路径下用 wrapFetchWithCorrelation 包 SDK 的 fetch option

显式 audited 但无需改动(避免 reviewer 怀疑漏路径):

  • packages/core/src/qwen/qwenContentGenerator.tsextends 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.headersX-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.tsUndiciInstrumentation + 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 单测

  • UndiciInstrumentationNodeSDKinstrumentations 中存在
  • ignoreRequestHookhttps://collector:4318/v1/traces 返回 true
  • ignoreRequestHookhttps://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 被再次调用 读取新 sessionIdregression guard for staleness fix
baseFetch reject wrapper 透传 reject 不吞

staticCorrelationHeadersGemini 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() / 构造测试加:

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 来抓 headerUndiciInstrumentation 通过 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 的 fetchstream: 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=secretignoreRequestHookurl.startsWith(e) 比对应包含 query string。但 undici 给的 request.path 只到 path不含 query所以比较时 e 也只用到 path 部分。为安全起见,剥掉 query

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 HTTPhttp://collector:4318 形态后 -fake 这种延伸不可能port 后跟的是 /
  • 用户配 endpoint 不带 port 是配置错误,本来 SDK 就要默认 fallback

如果想 harden:解析 URL origin + path 分别比较,不用裸 startsWith

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 hookHttpOptions 接口只有 baseUrl/apiVersion/headers/timeout/extraParamsGemini 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 路径没有这个 bugfetch 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 defaultHeadersclient.ts:108+ 自定义 buildFetch() wrapperclient.ts:370-390per-request randomUUID() 注入 x-client-request-id OpenAI/Anthropic 走 fetch wrapperper-request 读 session id避免 /clear stalenessGemini 走 static httpOptions.headersSDK 限制) 与 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 落地 commit1c8528a56 (核心实现) + 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

"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

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 参数:

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 endpointgenerativelanguage.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 gatestaticCorrelationHeadersdestinationUrl 参数
packages/core/src/telemetry/llm-correlation-fetch.test.ts 加 host-gate 8 个 casemockConfig'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 落地:本 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
  • traceparentX-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-relevantsettingsSchema.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.mdpropagateTraceContext 段附完整双 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.sessionIdHeaderHostsgetTelemetrySessionIdHeaderHosts新增 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.outboundCorrelationConfigParameters
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)" 顶级 sectiontelemetry.enabled 依赖说明 + JSON 配置示例
docs/design/telemetry-outbound-propagation-design.md 本节 + R4 表头 + 修订指针
packages/core/src/config/config.test.ts 新增 OutboundCorrelation Configuration describe blockit.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 收录