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)
This commit is contained in:
doudouOUC 2026-05-25 13:42:32 +08:00
parent 7a1b4f8d00
commit 9bdd3bd6f9
22 changed files with 334 additions and 1564 deletions

View file

@ -7,13 +7,14 @@
## 修订历史
| 修订 | 日期 | 触发 | 摘要 |
| ---- | ---------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| 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 |
| 修订 | 日期 | 触发 | 摘要 |
| ---- | ---------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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未来工作时,请同时参考 §11 —— **R3 修订让上述章节中关于"统一向所有 LLM provider 发送 session id 不区分第一方/第三方"的论断不再成立**。原文保留作为决策路径记录
**特别提示**:阅读 §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. 背景
@ -764,3 +765,102 @@ Gemini SDK 有两个不可见 default endpoint`generativelanguage.googleapis.
- **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
### 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` 重新生成 |
| `docs/developers/development/telemetry.md` | 重写 "Trace context propagation" → "Client-side HTTP span on outbound fetch";删 "Session correlation header" 整节;新增 "Outbound correlation (SECURITY-RELEVANT)" 顶级 section |
| `docs/design/telemetry-outbound-propagation-design.md` | 本节 + R4 表头 + 修订指针 |
### 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,25 +262,23 @@ 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.
### Trace context propagation
### Client-side HTTP span on outbound fetch
When telemetry is enabled, Qwen Code instruments outgoing `fetch()` requests
(used by `openai`, `@google/genai`, and `@anthropic-ai/sdk`) and automatically
injects the standard W3C `traceparent` header on every LLM service call:
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.
```
traceparent: 00-<32-hex traceId>-<16-hex parentSpanId>-<01-sampled | 00-not-sampled>
```
The traceId is the qwen-code interaction trace id; the parent span is the
active `api.generateContent` span. Any OTel-instrumented LLM service (e.g.
DashScope serving from an ARMS Tracing backend) that reads this header will
make its server-side spans children of qwen-code's trace, giving you a single
end-to-end trace tree across the process boundary.
You also get a free client-side HTTP span per LLM call — useful for separating
network latency (TTFB / response body transfer) 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,
@ -291,98 +289,53 @@ URLs matching the configured `telemetry.otlpEndpoint` /
`telemetry.otlpMetricsEndpoint` prefixes. In file-outfile mode there are no
outbound HTTP uploads, so the hook is a no-op.
**Scope: all `fetch()` calls, not just LLM.** The undici instrumentation
patches `globalThis.fetch` for the entire process, so a client span +
`traceparent` injection happens on every outbound `fetch` — including the
`WebFetch` tool, MCP StreamableHTTP clients, and any IDE-extension
out-of-process calls — not only LLM SDK traffic. Two consequences worth
knowing:
## Outbound correlation (SECURITY-RELEVANT)
- **Trace ID leakage.** The W3C `traceparent` header carries the trace ID
to every destination, including third-party URLs supplied at runtime
(e.g. a `WebFetch` to an arbitrary domain). The trace ID is not a
secret per the W3C spec — the value is `sha256(sessionId).slice(0, 32)`
(a one-way hash, not the session id itself). Operators with stricter
requirements who don't want even the hashed trace ID reaching
third-party endpoints can disable telemetry entirely
(`telemetry.enabled: false`); a per-destination scoping toggle for
trace propagation specifically is tracked as a follow-up.
- **Span volume.** Non-LLM `fetch` calls show up as client HTTP spans in
your OTLP backend. Filter on `http.url` or `peer.service` if you want
to isolate the LLM-only subset.
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.
### Session correlation header
Alongside `traceparent`, Qwen Code can inject a custom HTTP header on
outbound LLM requests when telemetry is enabled:
```
X-Qwen-Code-Session-Id: <session id>
```
Pattern matched from Claude Code's `X-Claude-Code-Session-Id` (see
`src/services/api/client.ts:108` in the claude-code repo). The header is
product-namespaced to avoid collision with generic `X-Session-Id` headers
other tools may inject. Server-side ingestion (e.g. a DashScope
gateway or an OTLP-aware API gateway) can use this header to stitch its
observation of an LLM request back to the originating Qwen Code session
without having to parse trace context.
**Default scope: first-party Alibaba/DashScope endpoints only.** The
header is sent only to destinations whose hostname matches the
`telemetry.sessionIdHeaderHosts` allowlist. The default allowlist is:
```
dashscope.aliyuncs.com
dashscope-intl.aliyuncs.com
*.dashscope.aliyuncs.com
*.dashscope-intl.aliyuncs.com
*.alibaba-inc.com
*.aliyun-inc.com
```
Requests to third-party LLM providers (OpenAI, Anthropic, OpenRouter,
MiniMax, ModelScope, Mistral, DeepSeek, vanilla Gemini, ...) **do not
receive this header by default** — avoids broadcasting a stable
client-side session identifier to providers who don't need it for the
API call itself. See [PR #4390 review discussion](https://github.com/QwenLM/qwen-code/pull/4390)
for the full rationale.
Operators with broader correlation requirements can override the scope
in `settings.json`:
### `outboundCorrelation.propagateTraceContext`
```jsonc
"telemetry": {
"sessionIdHeaderHosts": ["*"] // restore broadcast
"sessionIdHeaderHosts": [] // fully disable header
"sessionIdHeaderHosts": ["api.mycompany.com",
"*.gateway.mycompany.internal"] // custom allowlist
"outboundCorrelation": {
"propagateTraceContext": false // default
}
```
The header value comes from `Config.getSessionId()`, read fresh on every
outbound request (not captured at SDK construction time) on the OpenAI/
Anthropic providers. After a session reset triggered by `/clear`,
subsequent requests carry the new session id automatically — see the
"Known limitation" note below for the one exception.
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.
Empty session ids are not emitted (some HTTP middleware rejects empty
header values). The header is omitted entirely when telemetry is disabled
or the destination host is outside the allowlist.
When `true`, the SDK's default W3C composite propagator
(`tracecontext` + `baggage`) is installed and the standard `traceparent`
header is written on every outbound `fetch`:
**Known limitation: Gemini provider.** `@google/genai`'s `HttpOptions`
interface does not expose a `fetch` hook (only static `headers`), so the
Gemini provider can only inject the session-id header at SDK construction
time. The host-allowlist check happens once against the SDK's
`baseUrl` (defaults to `generativelanguage.googleapis.com` — not on the
default allowlist, so no header by default). When operators do put the
Gemini SDK on a first-party endpoint via `baseUrl` and the allowlist
matches, the header IS attached but goes stale on `/clear` until the
content generator is recreated. All other providers (`openai`-family,
`anthropic`) use a fetch wrapper and are immune to this. Tracked as a
follow-up; in the meantime, spans and logs still carry the live session
id, so trace/log backends can correctly attribute requests to the new
session.
```
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.
### 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

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,
@ -1032,17 +1033,34 @@ const SETTINGS_SCHEMA = {
},
},
},
sessionIdHeaderHosts: {
description:
'Destination hostnames (or "*.suffix" patterns) that receive the X-Qwen-Code-Session-Id outbound correlation header. Defaults to Alibaba/DashScope first-party endpoints (dashscope.aliyuncs.com, dashscope-intl.aliyuncs.com, *.dashscope.aliyuncs.com, *.dashscope-intl.aliyuncs.com, *.alibaba-inc.com, *.aliyun-inc.com) so the stable session identifier is not broadcast to third-party LLM providers like OpenAI or Anthropic. Set to ["*"] to restore the broadcast-to-everywhere behavior, or [] to fully disable the header.',
type: 'array',
items: { type: 'string' },
},
},
additionalProperties: true,
},
},
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:
"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). Note: 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

@ -314,23 +314,6 @@ export interface TelemetrySettings {
resourceAttributes?: Record<string, string>;
/** Per-signal cardinality controls. */
metrics?: TelemetryMetricsSettings;
/**
* Hostnames (or `*.suffix` patterns) that receive the
* `X-Qwen-Code-Session-Id` outbound correlation header.
*
* Default (when unset): Alibaba/DashScope first-party endpoints only
* see `DEFAULT_SESSION_ID_HEADER_HOSTS` in `telemetry/trusted-llm-hosts.ts`.
* The default is deliberately narrow so the stable session identifier
* does not get broadcast to arbitrary third-party LLM providers
* (OpenAI, Anthropic, OpenRouter, ...) configured under the
* OpenAI-compatible provider. PR #4390 review (LaZzyMan) for rationale.
*
* Overrides:
* - `[]` fully disables the header (no destinations)
* - `["*"]` restores the broadcast behavior across all destinations
* - `["api.mycompany.com", "*.internal.mycompany.com"]` custom allowlist
*/
sessionIdHeaderHosts?: string[];
/**
* Human-readable diagnostics produced while resolving
* `resourceAttributes` (drops, coercions, reserved-key strips).
@ -354,6 +337,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;
}
@ -582,6 +601,7 @@ export interface ConfigParameters {
contextFileName?: string | string[];
accessibility?: AccessibilitySettings;
telemetry?: TelemetrySettings;
outboundCorrelation?: OutboundCorrelationSettings;
gitCoAuthor?: GitCoAuthorParam;
usageStatisticsEnabled?: boolean;
/**
@ -871,6 +891,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;
@ -1037,9 +1058,12 @@ export class Config {
outfile: params.telemetry?.outfile,
resourceAttributes: params.telemetry?.resourceAttributes,
metrics: params.telemetry?.metrics,
sessionIdHeaderHosts: params.telemetry?.sessionIdHeaderHosts,
resourceAttributeWarnings: params.telemetry?.resourceAttributeWarnings,
};
this.outboundCorrelationSettings = {
propagateTraceContext:
params.outboundCorrelation?.propagateTraceContext ?? false,
};
this.gitCoAuthor = {
...normalizeGitCoAuthor(params.gitCoAuthor),
name: 'Qwen-Coder',
@ -2867,21 +2891,19 @@ export class Config {
return this.telemetrySettings.metrics?.includeSessionId ?? false;
}
/**
* Returns the configured host allowlist for the
* `X-Qwen-Code-Session-Id` outbound header, or `undefined` to indicate
* "use `DEFAULT_SESSION_ID_HEADER_HOSTS`". The caller (`llm-correlation-fetch`)
* resolves the default keeping it out of Config avoids importing the
* telemetry helper from here.
*/
getTelemetrySessionIdHeaderHosts(): readonly string[] | undefined {
return this.telemetrySettings.sessionIdHeaderHosts;
}
getTelemetryResourceAttributeWarnings(): readonly string[] {
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

@ -106,30 +106,6 @@ describe('AnthropicContentGenerator', () => {
vi.restoreAllMocks();
});
it('installs the correlation fetch wrapper on the Anthropic client', async () => {
const { AnthropicContentGenerator } = await importGenerator();
void new AnthropicContentGenerator(
{
model: 'claude-test',
apiKey: 'test-key',
baseUrl: 'https://api.anthropic.com',
timeout: 10_000,
maxRetries: 2,
samplingParams: {},
schemaCompliance: 'auto',
},
mockConfig,
);
const fetchFn = anthropicState.constructorOptions?.['fetch'] as
| unknown
| undefined;
// Wrapper installed regardless of telemetry-enabled state — per-call
// header injection is gated inside the wrapper itself. Exhaustive
// behavior is in llm-correlation-fetch.test.ts.
expect(typeof fetchFn).toBe('function');
expect(fetchFn).not.toBe(globalThis.fetch);
});
it('uses claude-cli identity (User-Agent + x-app + Bearer auth) for non-Anthropic baseURLs', async () => {
// Non-Anthropic-native baseURL → IdeaLab-style proxy path:
// - User-Agent presents as `claude-cli/<version> (external, cli)`

View file

@ -33,7 +33,6 @@ import {
buildRuntimeFetchOptions,
redactProxyError,
} from '../../utils/runtimeFetchOptions.js';
import { wrapFetchWithCorrelation } from '../../telemetry/llm-correlation-fetch.js';
import { DEFAULT_TIMEOUT } from '../openaiContentGenerator/constants.js';
import { createDebugLogger } from '../../utils/debugLogger.js';
import { runtimeDiagnostics } from '../../utils/runtimeDiagnostics.js';
@ -204,13 +203,6 @@ export class AnthropicContentGenerator implements ContentGenerator {
// key as `X-Api-Key` to the IdeaLab proxy — leaking the credential to
// a third-party endpoint. Explicit `null` suppresses the back-fill
// and forces the intended auth path.
// Wrap fetch with per-request correlation header injection. Read the
// base fetch from runtimeOptions (proxy-aware) when present, else
// globalThis. See design doc §4.3 — fetch wrapper (not defaultHeaders)
// is required so the header tracks live session id across /clear resets.
const baseFetch =
(runtimeOptions as { fetch?: typeof fetch } | undefined)?.fetch ??
globalThis.fetch;
this.client = new Anthropic({
...(useProxyIdentity
? { authToken: contentGeneratorConfig.apiKey, apiKey: null }
@ -220,17 +212,6 @@ export class AnthropicContentGenerator implements ContentGenerator {
maxRetries: contentGeneratorConfig.maxRetries,
defaultHeaders,
...runtimeOptions,
// Cast through unknown: Anthropic SDK's `Fetch` type uses the older
// DOM-style `RequestInfo` (no URL) which `typeof fetch` (Node WHATWG
// overloads) is not structurally assignable to, even though they're
// call-compatible at runtime. The cast is safe because the wrapper
// delegates to baseFetch (= runtimeOptions.fetch ?? globalThis.fetch)
// without altering its call shape.
fetch: wrapFetchWithCorrelation(
baseFetch,
this.cliConfig,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as unknown as any,
});
this.converter = new AnthropicContentConverter(

View file

@ -24,7 +24,6 @@ describe('createContentGenerator', () => {
getCliVersion: () => '1.0.0',
getTelemetryEnabled: () => false,
getSessionId: () => 'test-session',
getTelemetrySessionIdHeaderHosts: () => undefined,
} as unknown as Config;
const mockGenerator = {
@ -62,7 +61,6 @@ describe('createContentGenerator', () => {
getCliVersion: () => '1.0.0',
getTelemetryEnabled: () => false,
getSessionId: () => 'test-session',
getTelemetrySessionIdHeaderHosts: () => undefined,
} as unknown as Config;
const mockGenerator = {
models: {},

View file

@ -91,103 +91,4 @@ describe('createGeminiContentGenerator', () => {
}),
);
});
it('omits X-Qwen-Code-Session-Id from httpOptions.headers when telemetry is disabled', () => {
const config = {
model: 'gemini-1.5-flash',
apiKey: 'k',
authType: AuthType.USE_GEMINI,
};
createGeminiContentGenerator(config, mockConfig);
const callArgs = vi.mocked(GeminiContentGenerator).mock.calls[0]?.[0] as
| { httpOptions?: { headers?: Record<string, string> } }
| undefined;
expect(callArgs?.httpOptions?.headers).toBeDefined();
expect(callArgs?.httpOptions?.headers ?? {}).not.toHaveProperty(
'X-Qwen-Code-Session-Id',
);
});
it('omits X-Qwen-Code-Session-Id for vanilla Gemini endpoint even when telemetry is enabled (third-party scope)', () => {
// PR #4390 review (LaZzyMan): the session id header is scoped to
// first-party (Alibaba/DashScope) destinations by default. A vanilla
// Gemini API call resolves to `generativelanguage.googleapis.com`,
// which is NOT on the default allowlist, so no header.
mockConfig = {
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
getTelemetryEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('sess-gemini'),
getTelemetrySessionIdHeaderHosts: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const config = {
model: 'gemini-1.5-flash',
apiKey: 'k',
authType: AuthType.USE_GEMINI,
};
createGeminiContentGenerator(config, mockConfig);
const callArgs = vi.mocked(GeminiContentGenerator).mock.calls[0]?.[0] as {
httpOptions: { headers: Record<string, string> };
};
expect(callArgs.httpOptions.headers).not.toHaveProperty(
'X-Qwen-Code-Session-Id',
);
});
it('includes X-Qwen-Code-Session-Id when baseUrl points at a trusted DashScope endpoint', () => {
mockConfig = {
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
getTelemetryEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('sess-gemini'),
getTelemetrySessionIdHeaderHosts: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const config = {
model: 'qwen-vl-plus',
apiKey: 'k',
authType: AuthType.USE_GEMINI,
// Operator has pointed the Gemini SDK at a DashScope-compatible
// endpoint via baseUrl override. This IS on the default allowlist.
baseUrl: 'https://dashscope.aliyuncs.com/api/v1',
};
createGeminiContentGenerator(config, mockConfig);
const callArgs = vi.mocked(GeminiContentGenerator).mock.calls[0]?.[0] as {
httpOptions: { headers: Record<string, string> };
};
expect(callArgs.httpOptions.headers['X-Qwen-Code-Session-Id']).toBe(
'sess-gemini',
);
});
it('omits X-Qwen-Code-Session-Id when baseUrl is unset, even with allowlist override (factory fail-closed for unknown destination)', () => {
// The factory only passes a destinationUrl to staticCorrelationHeaders
// when `config.baseUrl` is set, because the Gemini SDK has two
// invisible defaults (generativelanguage.googleapis.com for public,
// {region}-aiplatform.googleapis.com for Vertex). Guessing one would
// mis-bucket the other under a googleapis-covering allowlist override.
// Operators who want correlation against Google endpoints must set
// `baseUrl` explicitly.
mockConfig = {
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
getTelemetryEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('sess-gemini'),
getTelemetrySessionIdHeaderHosts: vi.fn().mockReturnValue(['*']),
} as unknown as Config;
const config = {
model: 'gemini-1.5-flash',
apiKey: 'k',
authType: AuthType.USE_GEMINI,
};
createGeminiContentGenerator(config, mockConfig);
const callArgs = vi.mocked(GeminiContentGenerator).mock.calls[0]?.[0] as {
httpOptions: { headers: Record<string, string> };
};
expect(callArgs.httpOptions.headers).not.toHaveProperty(
'X-Qwen-Code-Session-Id',
);
});
});

View file

@ -11,7 +11,6 @@ import type {
} from '../contentGenerator.js';
import type { Config } from '../../config/config.js';
import { InstallationManager } from '../../utils/installationManager.js';
import { staticCorrelationHeaders } from '../../telemetry/llm-correlation-fetch.js';
export { GeminiContentGenerator } from './geminiContentGenerator.js';
@ -39,27 +38,6 @@ export function createGeminiContentGenerator(
'x-gemini-api-privileged-user-id': `${installationId}`,
};
}
// Merge the session-id correlation header. `@google/genai`'s HttpOptions
// does not expose a `fetch` hook (unlike `openai` / `@anthropic-ai/sdk`),
// so we can only inject a static header here — captured at construction.
// Known limitation: after a `/clear`-triggered session reset, the Gemini
// SDK's cached headers retain the OLD session id until the contentGenerator
// is recreated. See design doc §8.6 + #4384 follow-up sub-issue tracking.
//
// Destination resolution: only pass when we KNOW the destination — i.e.
// when the operator has explicitly set `config.baseUrl`. The SDK has two
// different invisible defaults (`generativelanguage.googleapis.com` for
// public Gemini, `{region}-aiplatform.googleapis.com` for Vertex AI) and
// guessing wrong here would mis-bucket Vertex traffic if the operator
// overrides `telemetry.sessionIdHeaderHosts` to include any googleapis
// domain. Passing `undefined` makes `staticCorrelationHeaders` return
// `{}` (no header) — the fail-closed default. Operators who want
// correlation against a Google endpoint set `baseUrl` explicitly
// (which is the same input the SDK uses to resolve the destination).
headers = {
...headers,
...staticCorrelationHeaders(gcConfig, config.baseUrl),
};
const httpOptions = config.baseUrl
? {
headers,

View file

@ -492,17 +492,6 @@ describe('DashScopeOpenAICompatibleProvider', () => {
}),
);
});
it('installs the correlation fetch wrapper on the OpenAI client', () => {
provider.buildClient();
const callArg = vi.mocked(OpenAI).mock.calls[0]![0] as {
fetch?: typeof fetch;
};
// Wrapper is always installed; per-request behavior depends on
// telemetry-enabled (verified end-to-end in llm-correlation-fetch.test.ts).
expect(typeof callArg.fetch).toBe('function');
expect(callArg.fetch).not.toBe(globalThis.fetch);
});
});
describe('buildMetadata', () => {

View file

@ -16,7 +16,6 @@ import type {
ChatCompletionToolWithCache,
} from './types.js';
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
import { wrapFetchWithCorrelation } from '../../../telemetry/llm-correlation-fetch.js';
import { createDebugLogger } from '../../../utils/debugLogger.js';
import { DefaultOpenAICompatibleProvider } from './default.js';
@ -139,9 +138,6 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr
'openai',
this.cliConfig.getProxy(),
);
const baseFetch =
(runtimeOptions as { fetch?: typeof fetch } | undefined)?.fetch ??
globalThis.fetch;
return new OpenAI({
apiKey,
baseURL: baseUrl,
@ -149,7 +145,6 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr
maxRetries,
defaultHeaders,
...(runtimeOptions || {}),
fetch: wrapFetchWithCorrelation(baseFetch, this.cliConfig),
});
}

View file

@ -168,50 +168,6 @@ describe('DefaultOpenAICompatibleProvider', () => {
}),
);
});
it('installs the correlation fetch wrapper on the OpenAI client', () => {
provider.buildClient();
const callArg = vi.mocked(OpenAI).mock.calls[0]![0] as {
fetch?: typeof fetch;
};
// Wrapper is always installed (so it can read live config per request);
// per-call header injection behavior is verified exhaustively in
// llm-correlation-fetch.test.ts.
expect(typeof callArg.fetch).toBe('function');
expect(callArg.fetch).not.toBe(globalThis.fetch);
});
it('wraps the proxy fetch (not globalThis.fetch) when runtimeOptions provides one', async () => {
// Regression guard for design §4.3: when proxy is configured,
// buildRuntimeFetchOptions returns { fetch: <bundled undici fetch> }
// so the proxy dispatcher and fetch share a single undici version.
// The correlation wrapper must wrap THAT fetch, not globalThis.fetch.
const proxyFetch = vi.fn(
async () => new Response(),
) as unknown as typeof fetch;
const mockedBuildRuntimeFetchOptions =
buildRuntimeFetchOptions as unknown as MockedFunction<
(sdkType: 'openai', proxyUrl?: string) => OpenAIRuntimeFetchOptions
>;
mockedBuildRuntimeFetchOptions.mockReturnValue({
fetch: proxyFetch,
} as OpenAIRuntimeFetchOptions);
provider.buildClient();
const callArg = vi.mocked(OpenAI).mock.calls[0]![0] as {
fetch?: typeof fetch;
};
expect(typeof callArg.fetch).toBe('function');
// Wrapped, not raw — the wrapper is a different function reference.
expect(callArg.fetch).not.toBe(proxyFetch);
expect(callArg.fetch).not.toBe(globalThis.fetch);
// Positive assertion (PR #4390 review): the negatives above pass
// for ANY wrapper, including a buggy one that wraps globalThis.fetch
// instead of proxyFetch. Call the wrapped fetch and verify the
// delegation target is actually proxyFetch (telemetry off in
// mockCliConfig, so wrapper short-circuits straight to baseFetch).
await callArg.fetch!('https://api.example.com/v1');
expect(proxyFetch).toHaveBeenCalledTimes(1);
});
});
describe('buildRequest', () => {

View file

@ -5,7 +5,6 @@ import type { ContentGeneratorConfig } from '../../contentGenerator.js';
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js';
import type { OpenAICompatibleProvider } from './types.js';
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
import { wrapFetchWithCorrelation } from '../../../telemetry/llm-correlation-fetch.js';
import {
tokenLimit,
CAPPED_DEFAULT_MAX_TOKENS,
@ -89,13 +88,6 @@ export class DefaultOpenAICompatibleProvider
'openai',
this.cliConfig.getProxy(),
);
// Wrap fetch with per-request correlation header injection. Read the base
// fetch from runtimeOptions (proxy-aware) when present, else globalThis.
// Spread runtimeOptions FIRST, then override `fetch:` so our wrapper wraps
// the proxy fetch instead of being replaced by it. See design doc §4.3.
const baseFetch =
(runtimeOptions as { fetch?: typeof fetch } | undefined)?.fetch ??
globalThis.fetch;
return new OpenAI({
apiKey,
baseURL: baseUrl,
@ -103,7 +95,6 @@ export class DefaultOpenAICompatibleProvider
maxRetries,
defaultHeaders,
...(runtimeOptions || {}),
fetch: wrapFetchWithCorrelation(baseFetch, this.cliConfig),
});
}

View file

@ -188,7 +188,6 @@ export async function resolveTelemetrySettings(options: {
outfile,
resourceAttributes,
metrics: { includeSessionId: metricsIncludeSessionId },
sessionIdHeaderHosts: settings.sessionIdHeaderHosts,
resourceAttributeWarnings: resourceAttributeWarnings.length
? resourceAttributeWarnings
: undefined,

View file

@ -1,662 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { diag } from '@opentelemetry/api';
import type { Config } from '../config/config.js';
import {
SESSION_ID_HEADER,
staticCorrelationHeaders,
wrapFetchWithCorrelation,
} from './llm-correlation-fetch.js';
// Local alias for tests; the public helper is generic.
type FetchLike = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>;
function mockConfig(opts: {
enabled?: boolean;
sessionId?: string | (() => string);
/**
* Host allowlist returned by `getTelemetrySessionIdHeaderHosts`.
*
* - Key OMITTED: returns `['*']` (broadcast) so the bulk of tests below
* which exercise header-injection mechanics, not host-gating
* keep operating against the `api.example.com` test URLs.
* - Key PRESENT and `undefined`: returns `undefined`, letting the
* wrapper fall back to `DEFAULT_SESSION_ID_HEADER_HOSTS` (the real
* default allowlist). Used by host-gate tests.
* - Key PRESENT and an array: returns that array verbatim.
*/
hosts?: readonly string[];
}): Config {
const hostsKeyPresent = 'hosts' in opts;
return {
getTelemetryEnabled: () => opts.enabled ?? true,
getSessionId: () =>
typeof opts.sessionId === 'function'
? opts.sessionId()
: (opts.sessionId ?? ''),
getTelemetrySessionIdHeaderHosts: () =>
hostsKeyPresent ? opts.hosts : ['*'],
} as unknown as Config;
}
// Typed fetch mock returning a recorded-args view that avoids the
// `mock.calls[0]![1] as RequestInit` cast — calls is properly typed as
// `[input, init?][]` and `init` is optional.
function makeFetchMock(): {
fetch: FetchLike;
spy: ReturnType<typeof vi.fn>;
lastInit: () => RequestInit | undefined;
lastInput: () => string | URL | Request | undefined;
callCount: () => number;
} {
const spy = vi.fn(
async (_input: string | URL | Request, _init?: RequestInit) =>
new Response(),
);
return {
fetch: spy as unknown as FetchLike,
spy,
lastInit: () =>
spy.mock.calls.length > 0
? (spy.mock.calls[spy.mock.calls.length - 1]?.[1] as
| RequestInit
| undefined)
: undefined,
lastInput: () =>
spy.mock.calls.length > 0
? (spy.mock.calls[spy.mock.calls.length - 1]?.[0] as
| string
| URL
| Request
| undefined)
: undefined,
callCount: () => spy.mock.calls.length,
};
}
describe('wrapFetchWithCorrelation', () => {
it('attaches X-Qwen-Code-Session-Id when telemetry is enabled', async () => {
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({ enabled: true, sessionId: 'sess-A' }),
);
await wrapped('https://api.example.com/v1/chat');
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
'sess-A',
);
});
it('does not attach header when telemetry is disabled (passes init through unchanged)', async () => {
const m = makeFetchMock();
const userInit = { method: 'POST', headers: { 'X-Custom': 'keep' } };
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({ enabled: false, sessionId: 'sess-A' }),
);
await wrapped('https://api.example.com/v1/chat', userInit);
expect(m.spy).toHaveBeenCalledWith(
'https://api.example.com/v1/chat',
userInit,
);
});
it('does not attach header when sessionId is empty (skips defensively)', async () => {
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({ enabled: true, sessionId: '' }),
);
await wrapped('https://api.example.com/v1/chat');
expect(m.spy).toHaveBeenCalledWith(
'https://api.example.com/v1/chat',
undefined,
);
});
it('overrides existing same-name header on init (server gets real session id, not user-supplied spoof)', async () => {
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({ enabled: true, sessionId: 'real-sess' }),
);
await wrapped('https://api.example.com/v1/chat', {
headers: { [SESSION_ID_HEADER]: 'spoofed' },
});
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
'real-sess',
);
});
it('preserves other headers and init fields when injecting', async () => {
const m = makeFetchMock();
const signal = new AbortController().signal;
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({ enabled: true, sessionId: 'sess' }),
);
await wrapped('https://api.example.com/v1/chat', {
method: 'POST',
headers: {
Authorization: 'Bearer sk-xxx',
'Content-Type': 'application/json',
},
body: '{"x":1}',
signal,
});
const init = m.lastInit()!;
expect(init.method).toBe('POST');
expect(init.body).toBe('{"x":1}');
expect(init.signal).toBe(signal);
const h = init.headers as Headers;
expect(h.get('Authorization')).toBe('Bearer sk-xxx');
expect(h.get('Content-Type')).toBe('application/json');
expect(h.get(SESSION_ID_HEADER)).toBe('sess');
});
it('reads fresh session id after a session reset (staleness regression — design §4.3 critical)', async () => {
let current = 'sess-A';
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({ enabled: true, sessionId: () => current }),
);
await wrapped('https://api.example.com/1');
expect(
(
(m.spy.mock.calls[0]?.[1] as RequestInit | undefined)?.headers as
| Headers
| undefined
)?.get(SESSION_ID_HEADER),
).toBe('sess-A');
// Simulate /clear updating Config.sessionId without recreating SDK clients.
current = 'sess-B';
await wrapped('https://api.example.com/2');
expect(
(
(m.spy.mock.calls[1]?.[1] as RequestInit | undefined)?.headers as
| Headers
| undefined
)?.get(SESSION_ID_HEADER),
).toBe('sess-B');
});
it('propagates baseFetch rejection unchanged', async () => {
const err = new Error('network unreachable');
const spy = vi.fn(async () => {
throw err;
});
const wrapped = wrapFetchWithCorrelation(
spy as unknown as FetchLike,
mockConfig({ enabled: true, sessionId: 'sess' }),
);
await expect(wrapped('https://api.example.com/x')).rejects.toBe(err);
});
it('preserves Request input headers when init is undefined (defends Authorization etc.)', async () => {
// PR #4393 review feedback: previously `new Headers(init?.headers)` with
// undefined init dropped the Request's own headers (e.g. Authorization)
// because we then passed `{...init, headers}` which had only our session
// header. Fix seeds from input.headers when input is a Request.
const m = makeFetchMock();
const req = new Request('https://api.example.com/v1/chat', {
method: 'POST',
headers: {
Authorization: 'Bearer sk-xxx',
'Content-Type': 'application/json',
},
});
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({ enabled: true, sessionId: 'sess' }),
);
await wrapped(req);
const init = m.lastInit()!;
const h = init.headers as Headers;
expect(h.get('Authorization')).toBe('Bearer sk-xxx');
expect(h.get('Content-Type')).toBe('application/json');
expect(h.get(SESSION_ID_HEADER)).toBe('sess');
});
it('falls through to baseFetch + diag.warn when header construction throws (telemetry never breaks LLM)', async () => {
const warnSpy = vi.spyOn(diag, 'warn').mockImplementation(() => {});
const m = makeFetchMock();
// Config getter that throws — simulates a runtime bug that must not
// propagate and break the LLM request. Use a TRUSTED destination
// (broadcast allowlist) so we reach the throwing getSessionId() — a
// third-party destination would short-circuit at the host gate before
// ever calling getSessionId.
const config = {
getTelemetryEnabled: () => true,
getSessionId: () => {
throw new Error('config bug');
},
getTelemetrySessionIdHeaderHosts: () => ['*'],
} as unknown as Config;
const wrapped = wrapFetchWithCorrelation(m.fetch, config);
const userInit = { method: 'POST' };
const res = await wrapped('https://api.example.com/v1/chat', userInit);
expect(res).toBeInstanceOf(Response);
// baseFetch was called with the ORIGINAL init (no correlation header added)
expect(m.spy).toHaveBeenCalledWith(
'https://api.example.com/v1/chat',
userInit,
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('correlation header'),
);
warnSpy.mockRestore();
});
});
describe('staticCorrelationHeaders', () => {
// Tests pass `hosts: ['*']` (broadcast) unless they're specifically
// exercising the host gate, since broadcast was the original behavior
// before the LaZzyMan-review-driven scope narrowing.
const TRUSTED = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
it('returns header when telemetry enabled and sessionId non-empty', () => {
expect(
staticCorrelationHeaders(
mockConfig({ enabled: true, sessionId: 'sess-A' }),
TRUSTED,
),
).toEqual({ [SESSION_ID_HEADER]: 'sess-A' });
});
it('returns {} when telemetry disabled', () => {
expect(
staticCorrelationHeaders(
mockConfig({ enabled: false, sessionId: 'sess-A' }),
TRUSTED,
),
).toEqual({});
});
it('returns {} when sessionId is empty', () => {
expect(
staticCorrelationHeaders(
mockConfig({ enabled: true, sessionId: '' }),
TRUSTED,
),
).toEqual({});
});
it('returns {} when destinationUrl is undefined (fail closed)', () => {
expect(
staticCorrelationHeaders(
mockConfig({ enabled: true, sessionId: 'sess-A' }),
),
).toEqual({});
});
it('returns {} when destinationUrl host is not on the trusted allowlist', () => {
// Default allowlist is Alibaba/DashScope only. A vanilla Gemini API call
// to googleapis.com should NOT receive the header. PR #4390 review
// (LaZzyMan): "the header should not be broadcast to every LLM provider".
expect(
staticCorrelationHeaders(
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: undefined, // use real default allowlist
}),
'https://generativelanguage.googleapis.com/v1beta',
),
).toEqual({});
});
it('returns header when destinationUrl host matches the default allowlist (DashScope)', () => {
expect(
staticCorrelationHeaders(
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: undefined, // use real default allowlist
}),
'https://dashscope.aliyuncs.com/compatible-mode/v1',
),
).toEqual({ [SESSION_ID_HEADER]: 'sess-A' });
});
it('returns header when destinationUrl host matches the default allowlist (internal alibaba-inc)', () => {
expect(
staticCorrelationHeaders(
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: undefined,
}),
'https://idealab.alibaba-inc.com/api/openai/v1',
),
).toEqual({ [SESSION_ID_HEADER]: 'sess-A' });
});
it('respects ["*"] override to restore broadcast', () => {
expect(
staticCorrelationHeaders(
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: ['*'],
}),
'https://api.openai.com/v1',
),
).toEqual({ [SESSION_ID_HEADER]: 'sess-A' });
});
it('respects [] override to fully disable', () => {
expect(
staticCorrelationHeaders(
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: [],
}),
TRUSTED,
),
).toEqual({});
});
it('returns {} when destinationUrl is unparseable', () => {
expect(
staticCorrelationHeaders(
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: undefined,
}),
'not a url',
),
).toEqual({});
});
it('captures session id at call time — caller responsible for re-invoking on reset (Gemini staleness §8.6)', () => {
// Document by behavior: this helper takes a snapshot. Caller (e.g.
// geminiContentGenerator/index.ts factory) calls it once and bakes the
// value into SDK-construction `httpOptions.headers`. A session reset
// after construction won't update the header — known limitation.
let current = 'sess-A';
const config = mockConfig({ enabled: true, sessionId: () => current });
const snapshot = staticCorrelationHeaders(config, TRUSTED);
current = 'sess-B';
expect(snapshot[SESSION_ID_HEADER]).toBe('sess-A');
});
it('returns {} (does not throw) when allowlist is a bare string instead of array', () => {
// Same defensive normalization as wrapFetchWithCorrelation. Bare
// string from a malformed settings.json must not crash construction.
const malformedConfig = {
getTelemetryEnabled: () => true,
getSessionId: () => 'sess-A',
getTelemetrySessionIdHeaderHosts: () =>
'dashscope.aliyuncs.com' as unknown as readonly string[],
} as unknown as Config;
// Falls back to DEFAULT — DashScope destination matches.
expect(staticCorrelationHeaders(malformedConfig, TRUSTED)).toEqual({
[SESSION_ID_HEADER]: 'sess-A',
});
});
it('returns {} (does not throw) when allowlist array contains non-string entries', () => {
const malformedConfig = {
getTelemetryEnabled: () => true,
getSessionId: () => 'sess-A',
getTelemetrySessionIdHeaderHosts: () =>
[null, 'dashscope.aliyuncs.com', 42] as unknown as
| readonly string[]
| undefined,
} as unknown as Config;
expect(staticCorrelationHeaders(malformedConfig, TRUSTED)).toEqual({
[SESSION_ID_HEADER]: 'sess-A',
});
});
it('falls through to {} when config getters throw (telemetry must never break LLM path)', () => {
// Mirror the safety contract of `wrapFetchWithCorrelation`. This helper
// is called from the Gemini factory at construction time, so a throw
// here would propagate up and crash content-generator init for the
// whole session. PR #4390 review feedback (wenshao).
const warnSpy = vi.spyOn(diag, 'warn').mockImplementation(() => {});
const exploding = {
getTelemetryEnabled: () => {
throw new Error('config exploded');
},
getSessionId: () => 'unreached',
getTelemetrySessionIdHeaderHosts: () => ['*'],
} as unknown as Config;
expect(staticCorrelationHeaders(exploding, TRUSTED)).toEqual({});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('staticCorrelationHeaders'),
);
warnSpy.mockRestore();
});
});
describe('wrapFetchWithCorrelation — host allowlist gating', () => {
// Dedicated block for the LaZzyMan-review-driven host gate. Uses the
// real default allowlist (no `hosts: ['*']` override) and exercises
// both the trusted-host pass and the third-party-host skip.
it('injects header for default-allowlisted host (dashscope.aliyuncs.com)', async () => {
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: undefined, // real default allowlist
}),
);
await wrapped('https://dashscope.aliyuncs.com/compatible-mode/v1/chat');
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
'sess-A',
);
});
it('injects header for sub-domain of allowlisted suffix (*.alibaba-inc.com)', async () => {
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: undefined,
}),
);
await wrapped('https://idealab.alibaba-inc.com/api/openai/v1');
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
'sess-A',
);
});
it('skips header for third-party host (api.openai.com) under default allowlist', async () => {
// This is the core LaZzyMan-review fix: the stable session id no longer
// gets broadcast to third-party LLM providers by default.
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: undefined,
}),
);
await wrapped('https://api.openai.com/v1/chat/completions');
expect(m.lastInit()?.headers).toBeUndefined();
});
it('skips header for third-party host (api.anthropic.com) under default allowlist', async () => {
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: undefined,
}),
);
await wrapped('https://api.anthropic.com/v1/messages');
expect(m.lastInit()?.headers).toBeUndefined();
});
it('respects ["*"] override to restore broadcast behavior', async () => {
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: ['*'],
}),
);
await wrapped('https://api.openai.com/v1/chat/completions');
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
'sess-A',
);
});
it('tolerates whitespace around "*" so [" * "] still enables broadcast', async () => {
// Defensive: settings.json hand-edits are easy to get a stray space
// wrong. Without trim() the operator would silently get "no host
// matches" instead of broadcast.
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: [' * '],
}),
);
await wrapped('https://api.openai.com/v1/chat/completions');
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
'sess-A',
);
});
it('tolerates whitespace around host patterns (parity with "*" trim)', async () => {
// Same defensive normalization applies to host patterns, not just the
// broadcast wildcard. Without it, `" dashscope.aliyuncs.com"` would
// silently never match — inconsistent with the `[" * "]` tolerance.
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: [' dashscope.aliyuncs.com '],
}),
);
await wrapped('https://dashscope.aliyuncs.com/compatible-mode/v1');
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
'sess-A',
);
});
it('does not throw at buildClient when settings returns a bare string instead of array (malformed JSON)', async () => {
// Regression: `broadcastAll` computation was outside the safety
// try/catch. A typo like `"sessionIdHeaderHosts": "dashscope..."`
// (forgot brackets) would let `.some(...)` throw TypeError at
// wrap time — bricking buildClient and the LLM session.
const m = makeFetchMock();
const malformedConfig = {
getTelemetryEnabled: () => true,
getSessionId: () => 'sess-A',
getTelemetrySessionIdHeaderHosts: () =>
'dashscope.aliyuncs.com' as unknown as readonly string[],
} as unknown as Config;
// The wrap itself must not throw — fall back to default allowlist.
expect(() =>
wrapFetchWithCorrelation(m.fetch, malformedConfig),
).not.toThrow();
const wrapped = wrapFetchWithCorrelation(m.fetch, malformedConfig);
// Default allowlist applies → header IS attached for DashScope.
await wrapped('https://dashscope.aliyuncs.com/v1');
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
'sess-A',
);
});
it('does not throw when settings array contains non-string entries (malformed JSON)', async () => {
// Regression: `[null, "*.dashscope.aliyuncs.com"]` typo placeholder
// would let `null.trim()` throw in the broadcastAll predicate.
const m = makeFetchMock();
const malformedConfig = {
getTelemetryEnabled: () => true,
getSessionId: () => 'sess-A',
getTelemetrySessionIdHeaderHosts: () =>
[null, 'dashscope.aliyuncs.com', undefined, 42] as unknown as
| readonly string[]
| undefined,
} as unknown as Config;
expect(() =>
wrapFetchWithCorrelation(m.fetch, malformedConfig),
).not.toThrow();
const wrapped = wrapFetchWithCorrelation(m.fetch, malformedConfig);
// Non-string entries filtered out, surviving string matched.
await wrapped('https://dashscope.aliyuncs.com/v1');
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
'sess-A',
);
});
it('respects [] override to fully disable injection', async () => {
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: [],
}),
);
// Even an otherwise-trusted destination is skipped when allowlist is [].
await wrapped('https://dashscope.aliyuncs.com/compatible-mode/v1');
expect(m.lastInit()?.headers).toBeUndefined();
});
it('respects custom allowlist (operator-supplied host)', async () => {
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: ['gateway.mycompany.internal'],
}),
);
await wrapped('https://gateway.mycompany.internal/llm/v1');
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
'sess-A',
);
// Default allowlist hosts are NOT included when operator overrides.
await wrapped('https://dashscope.aliyuncs.com/v1');
expect(m.lastInit()?.headers).toBeUndefined();
});
it('skips header when destination URL is unparseable (fail closed)', async () => {
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: undefined,
}),
);
await wrapped('not a valid url');
expect(m.lastInit()?.headers).toBeUndefined();
});
});

View file

@ -1,247 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { diag } from '@opentelemetry/api';
import type { Config } from '../config/config.js';
import {
DEFAULT_SESSION_ID_HEADER_HOSTS,
extractRequestHost,
matchesTrustedHost,
} from './trusted-llm-hosts.js';
/**
* Custom HTTP header attached to outbound LLM service requests when
* telemetry is enabled AND the destination is on the trusted-host
* allowlist. Product-namespaced (matching claude-code's
* `X-Claude-Code-Session-Id` pattern from `src/services/api/client.ts:108`)
* to avoid collision with generic `X-Session-Id` headers other tools may
* inject. Server-side ingestion can use this to stitch its observation of
* an LLM request back to the originating qwen-code session.
*
* Scope: see `DEFAULT_SESSION_ID_HEADER_HOSTS` for the default destination
* set and PR #4390 review (LaZzyMan) for the rationale behind not
* broadcasting to every third-party LLM provider.
*/
export const SESSION_ID_HEADER = 'X-Qwen-Code-Session-Id';
/**
* Loose fetch-like signature the wrapper internally programs against.
*
* Exists because the SDKs we wrap have **incompatible** Fetch types:
* - `openai@5.11`: `(input: string | URL | Request, init?) => Promise<Response>`
* - `@anthropic-ai/sdk`: `(input: RequestInfo, init?) => Promise<Response>`
* where `RequestInfo = string | Request` (NOT including URL).
*
* No single concrete signature satisfies both as a structural subtype, so the
* public `wrapFetchWithCorrelation` is generic and preserves the caller's
* exact type (the SDK's own Fetch). This type only describes the input shape
* we touch inside the wrapper.
*/
type FetchLikeLoose = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>;
/**
* Wrap a fetch implementation so outbound requests to trusted LLM
* destinations get the `X-Qwen-Code-Session-Id` correlation header
* populated from the **current** session id, not the value captured
* when the SDK client was constructed.
*
* Three gates, in order:
* 1. Telemetry enabled (`config.getTelemetryEnabled()`)
* 2. Destination host is on the trusted allowlist
* (`config.getTelemetrySessionIdHeaderHosts()` ?? default in-vendor set)
* 3. Session id is non-empty
*
* Any failed gate falls through to `baseFetch(input, init)` unchanged
* no header attached, no behavior change. This means a request to
* `api.openai.com` from a default-config install goes out exactly the
* same as it did before this PR landed.
*
* Why per-request and not `defaultHeaders`: SDK clients (and their static
* `defaultHeaders`) are constructed once at content-generator init and are
* NOT recreated on `/clear`-triggered session reset (`Config.resetSession()`
* updates `this.sessionId` but doesn't rebuild the contentGenerator). A
* static header would therefore go stale immediately after the first reset.
* Reading `config.getSessionId()` from inside the wrapper on every call
* gives the live value.
*
* Note on `trustedHosts`: snapshotted once at wrap time, not read per
* request. The session id is live-read but the allowlist is not a
* mid-session change to `telemetry.sessionIdHeaderHosts` in settings.json
* takes effect at next content-generator init (any change that mutates
* Config snapshot the wrapper retains is by definition a Config-level
* concern, not a request-time concern). Operators tuning the scope live
* should restart, or call the openai/anthropic clients via a fresh
* provider after settings reload.
*
* The caller is responsible for choosing the base fetch usually
* `runtimeOptions?.fetch ?? globalThis.fetch` so proxy-aware fetch (set up
* by `buildRuntimeFetchOptions`) is preserved when ProxyAgent is in use.
*
* Safety: the wrapper catches its own exceptions and falls through to
* baseFetch on any internal error. Telemetry must never break the LLM
* request path if Config getters throw, header construction fails, etc.,
* we still want the model call to proceed.
*/
export function wrapFetchWithCorrelation<TFetch extends FetchLikeLoose>(
baseFetch: TFetch,
config: Config,
): TFetch {
// Resolve the host allowlist once at wrap time (Config snapshot).
// Operators override via `telemetry.sessionIdHeaderHosts` in
// settings.json (e.g. `["*.api.openai.com"]` for a specific OpenAI-
// compatible proxy, or `["*"]` to restore the broadcast behavior the
// initial design proposed).
//
// Defensive normalization (PR #4390 review feedback): qwen-code's
// settings loader does not enforce JSON schema at runtime, so the
// getter can legitimately return a malformed value — a bare string
// ("dashscope.aliyuncs.com" instead of ["dashscope.aliyuncs.com"]),
// an array containing non-strings, or whitespace-padded entries
// (" * " or " dashscope.aliyuncs.com"). The chain below:
// 1. catches a throwing getter (mock without stub, pre-getter Config)
// 2. rejects a non-array value (bare string typo) → default allowlist
// 3. filters out non-string elements ([null, "..."] typo placeholder)
// 4. trims every surviving entry uniformly, so the `*` broadcast
// escape hatch and the host-pattern match path have parity
// Violating any of these would let `.includes / .some / matchesTrustedHost`
// throw at buildClient time — bricking the LLM session before the first
// prompt and violating the "telemetry must never break the LLM request
// path" contract that `staticCorrelationHeaders` already honors via its
// end-to-end try/catch.
let trustedHosts: readonly string[];
try {
const raw =
config.getTelemetrySessionIdHeaderHosts?.() ??
DEFAULT_SESSION_ID_HEADER_HOSTS;
trustedHosts = Array.isArray(raw)
? raw
.filter((p): p is string => typeof p === 'string')
.map((p) => p.trim())
: DEFAULT_SESSION_ID_HEADER_HOSTS;
} catch {
trustedHosts = DEFAULT_SESSION_ID_HEADER_HOSTS;
}
// Wildcard escape hatch so operators who want the old broadcast
// behavior can opt in via `["*"]` without us extending the pattern
// grammar in `matchesTrustedHost` (which would tempt other globbing).
// Pre-trimmed above, so `.includes('*')` covers `["*"]` / `[" * "]` /
// `["\t*\n"]` uniformly.
const broadcastAll = trustedHosts.includes('*');
const wrapped: FetchLikeLoose = async function correlationFetch(
input: string | URL | Request,
init?: RequestInit,
): Promise<Response> {
let headers: Headers;
try {
if (!config.getTelemetryEnabled()) {
return baseFetch(input, init);
}
if (!broadcastAll) {
// Host gate: skip injection for destinations not on the allowlist.
// This is what scopes the "stable client fingerprint" exposure to
// first-party endpoints only. PR #4390 review (LaZzyMan).
const host = extractRequestHost(input);
if (!host || !matchesTrustedHost(host, trustedHosts)) {
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);
}
// Seed headers: prefer init.headers when caller provided them (their
// intent overrides). Otherwise, if input is a Request, copy its own
// headers so they aren't lost when we pass `{...init, headers}` back
// to baseFetch — `new Headers(undefined)` would otherwise produce an
// empty Headers and drop the Request's headers (including
// Authorization). See PR #4393 review feedback.
headers = new Headers(init?.headers);
if (init?.headers === undefined && input instanceof Request) {
input.headers.forEach((value, key) => headers.set(key, value));
}
headers.set(SESSION_ID_HEADER, sid);
} catch (err) {
// Telemetry must never break the LLM request path. Log and fall through.
diag.warn(
`wrapFetchWithCorrelation: header construction failed, sending request without correlation header: ${err instanceof Error ? err.message : String(err)}`,
);
return baseFetch(input, init);
}
return baseFetch(input, { ...init, headers });
};
// Cast back to TFetch: runtime behavior matches whatever signature the
// caller's baseFetch has (we delegate to it without altering shape).
return wrapped as unknown as TFetch;
}
/**
* 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` see design doc §8.6 for the known Gemini
* limitation). Prefer `wrapFetchWithCorrelation` whenever the SDK exposes
* a `fetch` hook.
*
* Host scope: same trusted-host allowlist as `wrapFetchWithCorrelation`,
* but evaluated once against the `destinationUrl` known at SDK
* construction. Callers that can't determine the destination should pass
* `undefined` the helper returns `{}` (no header, same as telemetry off).
*
* When telemetry is disabled or the destination isn't trusted, returns
* `{}` so the caller can spread it unconditionally without changing wire
* behavior.
*/
export function staticCorrelationHeaders(
config: Config,
destinationUrl?: string,
): Record<string, string> {
// Mirror the safety contract of `wrapFetchWithCorrelation`: telemetry must
// never break the LLM request path. This helper is called from the Gemini
// content-generator factory at construction time — a throw here would
// propagate up and crash content-generator init for the entire session.
// Fall through to `{}` instead. See PR #4390 review feedback (wenshao).
try {
if (!config.getTelemetryEnabled()) return {};
if (!destinationUrl) return {};
// Same defensive normalization as `wrapFetchWithCorrelation`. Bare
// string / array-with-nulls / whitespace-padded entries from a
// hand-edited settings.json would otherwise crash `.includes` /
// `matchesTrustedHost`. See sibling helper for full rationale.
const raw =
config.getTelemetrySessionIdHeaderHosts?.() ??
DEFAULT_SESSION_ID_HEADER_HOSTS;
const trustedHosts: readonly string[] = Array.isArray(raw)
? raw
.filter((p): p is string => typeof p === 'string')
.map((p) => p.trim())
: DEFAULT_SESSION_ID_HEADER_HOSTS;
const broadcastAll = trustedHosts.includes('*');
if (!broadcastAll) {
let host: string;
try {
host = new URL(destinationUrl).hostname;
} catch {
// Unparseable destination → treat as not on allowlist (fail closed).
return {};
}
if (!matchesTrustedHost(host, trustedHosts)) return {};
}
const sid = config.getSessionId();
if (!sid) return {};
return { [SESSION_ID_HEADER]: sid };
} catch (err) {
diag.warn(
`staticCorrelationHeaders: config read failed, omitting correlation header: ${err instanceof Error ? err.message : String(err)}`,
);
return {};
}
}

View file

@ -147,6 +147,7 @@ describe('Telemetry SDK', () => {
getDebugMode: () => false,
getSessionId: () => 'test-session',
getCliVersion: () => '1.0.0-test',
getOutboundCorrelationPropagateTraceContext: () => false,
} as unknown as Config;
});
@ -640,6 +641,49 @@ describe('Telemetry SDK', () => {
});
});
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 W3C composite propagator. Test asserts the absence
// because the actual W3CTraceContextPropagator instance is created
// 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]!;
@ -1115,6 +1159,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';
@ -90,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;
@ -397,12 +424,26 @@ export function initializeTelemetry(config: Config): void {
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)]

View file

@ -1,181 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
DEFAULT_SESSION_ID_HEADER_HOSTS,
extractRequestHost,
matchesTrustedHost,
} from './trusted-llm-hosts.js';
describe('matchesTrustedHost', () => {
it('returns false for empty hostname (defensive)', () => {
expect(matchesTrustedHost('', ['dashscope.aliyuncs.com'])).toBe(false);
});
it('exact match (case-insensitive)', () => {
expect(
matchesTrustedHost('dashscope.aliyuncs.com', ['dashscope.aliyuncs.com']),
).toBe(true);
expect(
matchesTrustedHost('DashScope.Aliyuncs.COM', ['dashscope.aliyuncs.com']),
).toBe(true);
});
it('exact pattern rejects sub-domain (no implicit wildcard)', () => {
expect(
matchesTrustedHost('sub.dashscope.aliyuncs.com', [
'dashscope.aliyuncs.com',
]),
).toBe(false);
});
it('*.suffix matches the bare suffix domain', () => {
expect(matchesTrustedHost('alibaba-inc.com', ['*.alibaba-inc.com'])).toBe(
true,
);
});
it('*.suffix matches sub-domains', () => {
expect(
matchesTrustedHost('idealab.alibaba-inc.com', ['*.alibaba-inc.com']),
).toBe(true);
expect(
matchesTrustedHost('a.b.alibaba-inc.com', ['*.alibaba-inc.com']),
).toBe(true);
});
it('*.suffix rejects unrelated hosts that contain the suffix mid-name', () => {
// `evil-alibaba-inc.com` ends with `alibaba-inc.com` as a substring but
// is not a true sub-domain — the dot-anchored check should reject it.
expect(
matchesTrustedHost('evil-alibaba-inc.com', ['*.alibaba-inc.com']),
).toBe(false);
// Trailing-suffix attack: a host whose name HAPPENS to end in the suffix
// text but on a different TLD.
expect(
matchesTrustedHost('alibaba-inc.com.evil.net', ['*.alibaba-inc.com']),
).toBe(false);
});
it('mixed pattern list (exact + wildcard)', () => {
const patterns = [
'dashscope.aliyuncs.com',
'*.dashscope.aliyuncs.com',
'*.alibaba-inc.com',
];
expect(matchesTrustedHost('dashscope.aliyuncs.com', patterns)).toBe(true);
expect(matchesTrustedHost('sub.dashscope.aliyuncs.com', patterns)).toBe(
true,
);
expect(matchesTrustedHost('idealab.alibaba-inc.com', patterns)).toBe(true);
expect(matchesTrustedHost('api.openai.com', patterns)).toBe(false);
});
it('empty pattern list rejects everything', () => {
expect(matchesTrustedHost('dashscope.aliyuncs.com', [])).toBe(false);
});
});
describe('extractRequestHost', () => {
it('extracts from string URL', () => {
expect(extractRequestHost('https://dashscope.aliyuncs.com/v1/x')).toBe(
'dashscope.aliyuncs.com',
);
});
it('extracts from URL object', () => {
expect(extractRequestHost(new URL('https://api.openai.com/v1'))).toBe(
'api.openai.com',
);
});
it('extracts from Request', () => {
expect(
extractRequestHost(new Request('https://api.anthropic.com/v1/messages')),
).toBe('api.anthropic.com');
});
it('strips explicit port (URL.hostname does not include port)', () => {
expect(extractRequestHost('https://dashscope.aliyuncs.com:443/v1')).toBe(
'dashscope.aliyuncs.com',
);
});
it('strips userinfo (URL.hostname does not include user/password)', () => {
expect(
extractRequestHost('https://user:pw@dashscope.aliyuncs.com/v1'),
).toBe('dashscope.aliyuncs.com');
});
it('ignores query string and fragment', () => {
expect(
extractRequestHost('https://dashscope.aliyuncs.com/v1?key=secret#frag'),
).toBe('dashscope.aliyuncs.com');
});
it('returns bracketed form for IPv6 (will never match a string pattern, behaves as fail-closed)', () => {
// Document the IPv6 behavior so it's intentional rather than a
// surprise: URL.hostname returns `[::1]` (with brackets), our
// pattern grammar has no `[…]` form, so IPv6 destinations are
// effectively never on the allowlist. This is acceptable because
// qwen-code's allowlist is for named first-party endpoints, not
// raw IPs. If/when raw-IP correlation becomes a real ask, extend
// matchesTrustedHost — don't change extractRequestHost.
expect(extractRequestHost('http://[::1]:8080/x')).toBe('[::1]');
});
it('returns undefined for unparseable string', () => {
expect(extractRequestHost('not a url')).toBeUndefined();
});
});
describe('DEFAULT_SESSION_ID_HEADER_HOSTS', () => {
it('matches DashScope global + intl endpoints', () => {
expect(
matchesTrustedHost(
'dashscope.aliyuncs.com',
DEFAULT_SESSION_ID_HEADER_HOSTS,
),
).toBe(true);
expect(
matchesTrustedHost(
'dashscope-intl.aliyuncs.com',
DEFAULT_SESSION_ID_HEADER_HOSTS,
),
).toBe(true);
});
it('matches internal *.alibaba-inc.com / *.aliyun-inc.com', () => {
expect(
matchesTrustedHost(
'idealab.alibaba-inc.com',
DEFAULT_SESSION_ID_HEADER_HOSTS,
),
).toBe(true);
expect(
matchesTrustedHost('gw.aliyun-inc.com', DEFAULT_SESSION_ID_HEADER_HOSTS),
).toBe(true);
});
it('does NOT match third-party LLM providers', () => {
expect(
matchesTrustedHost('api.openai.com', DEFAULT_SESSION_ID_HEADER_HOSTS),
).toBe(false);
expect(
matchesTrustedHost('api.anthropic.com', DEFAULT_SESSION_ID_HEADER_HOSTS),
).toBe(false);
expect(
matchesTrustedHost('openrouter.ai', DEFAULT_SESSION_ID_HEADER_HOSTS),
).toBe(false);
expect(
matchesTrustedHost(
'generativelanguage.googleapis.com',
DEFAULT_SESSION_ID_HEADER_HOSTS,
),
).toBe(false);
});
});

View file

@ -1,89 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Default allowlist for outbound correlation headers
* (`X-Qwen-Code-Session-Id`). Limited to Alibaba/DashScope endpoints where
* the LLM provider, the upstream telemetry backend (ARMS Tracing), and
* the qwen-code distribution are the same legal entity so the session
* id stays a first-party, in-vendor correlation handle.
*
* Why a default scope (rather than broadcasting to every LLM provider):
* PR #4390 review (LaZzyMan) pointed out that an open-source CLI sending
* a stable cross-request identifier to arbitrary third-party providers
* (OpenAI, Anthropic, OpenRouter, ...) is a cross-vendor fingerprinting
* surface those providers don't need for the API call itself. Restricting
* the default to in-vendor destinations mirrors the claude-code pattern
* (first-party client first-party backend), preserves the real product
* value (ARMS server-side trace stitching against DashScope), and makes
* the third-party-broadcast failure mode opt-in rather than default.
*
* Mirrors `DashScopeOpenAICompatibleProvider.isDashScopeProvider` so the
* two layers stay aligned. If you add a hostname there, add it here too.
*
* Pattern syntax (matches `matchesTrustedHost` below):
* - bare hostname exact match (case-insensitive)
* - `*.suffix` matches `suffix` itself AND any sub-domain of it
* (e.g. `*.alibaba-inc.com` matches `alibaba-inc.com`
* and `gw.alibaba-inc.com`)
*/
export const DEFAULT_SESSION_ID_HEADER_HOSTS: readonly string[] = [
'dashscope.aliyuncs.com',
'dashscope-intl.aliyuncs.com',
'*.dashscope.aliyuncs.com',
'*.dashscope-intl.aliyuncs.com',
'*.alibaba-inc.com',
'*.aliyun-inc.com',
];
/**
* Check whether `hostname` matches any pattern in `patterns`.
* Case-insensitive. Empty `hostname` always returns false.
*
* `*.suffix` patterns match:
* - the suffix domain itself (`alibaba-inc.com` matches `*.alibaba-inc.com`)
* - any sub-domain (`gw.alibaba-inc.com` matches `*.alibaba-inc.com`)
*
* This is intentionally a tiny pure helper, not a generic glob. Anything
* more elaborate (regex, port-aware, scheme-aware) should be added at the
* call site, not here, so the allowlist semantics stay obvious from the
* pattern strings users put in settings.
*/
export function matchesTrustedHost(
hostname: string,
patterns: readonly string[],
): boolean {
if (!hostname) return false;
const h = hostname.toLowerCase();
for (const raw of patterns) {
const p = raw.toLowerCase();
if (p.startsWith('*.')) {
const bare = p.slice(2);
if (h === bare || h.endsWith('.' + bare)) return true;
} else if (h === p) {
return true;
}
}
return false;
}
/**
* Extract the destination hostname from a fetch input. Returns `undefined`
* if the URL can't be parsed caller should treat that as "not on the
* allowlist" (fail closed).
*/
export function extractRequestHost(
input: string | URL | Request,
): string | undefined {
try {
if (typeof input === 'string') return new URL(input).hostname;
if (input instanceof URL) return input.hostname;
if (input instanceof Request) return new URL(input.url).hostname;
} catch {
return undefined;
}
return undefined;
}

View file

@ -435,18 +435,23 @@
"default": false
}
}
},
"sessionIdHeaderHosts": {
"description": "Destination hostnames (or \"*.suffix\" patterns) that receive the X-Qwen-Code-Session-Id outbound correlation header. Defaults to Alibaba/DashScope first-party endpoints (dashscope.aliyuncs.com, dashscope-intl.aliyuncs.com, *.dashscope.aliyuncs.com, *.dashscope-intl.aliyuncs.com, *.alibaba-inc.com, *.aliyun-inc.com) so the stable session identifier is not broadcast to third-party LLM providers like OpenAI or Anthropic. Set to [\"*\"] to restore the broadcast-to-everywhere behavior, or [] to fully disable the header.",
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": true,
"description": "Telemetry configuration."
},
"outboundCorrelation": {
"type": "object",
"properties": {
"propagateTraceContext": {
"description": "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). Note: 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",