Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
52bfce4
feat(telemetry): propagate W3C traceparent on outbound LLM requests
doudouOUC May 21, 2026
0607c57
fix(telemetry): harden OTLP feedback-loop guard + slim lockfile diff
doudouOUC May 21, 2026
e1fd6b4
feat(telemetry): propagate X-Qwen-Code-Session-Id on outbound LLM req…
doudouOUC May 21, 2026
a1a8a5a
fix(telemetry): R2 review fixes — critical correctness + tsc + bounda…
doudouOUC May 21, 2026
cb34526
chore(deps): allow patch updates for @opentelemetry/instrumentation-u…
doudouOUC May 21, 2026
d91eb9a
test(telemetry): stub getTelemetryEnabled + getSessionId in Gemini fa…
doudouOUC May 22, 2026
e1cd295
fix(telemetry): R3 review fixes — port + protocol + quote + safety
doudouOUC May 22, 2026
dacea05
docs(telemetry): fix misleading "BOTH" wording in wrapFetchWithCorrel…
doudouOUC May 22, 2026
fc6c13a
fix(telemetry): strip port from req.host fallback + document undici s…
doudouOUC May 22, 2026
1c8528a
feat(telemetry): scope X-Qwen-Code-Session-Id to first-party hosts by…
doudouOUC May 22, 2026
cb162e7
fix(telemetry): R5 review fixups — Vertex destination + ["*"] trim + …
doudouOUC May 22, 2026
40e1efc
chore: regenerate settings.schema.json for sessionIdHeaderHosts
doudouOUC May 22, 2026
106598c
docs(design): update telemetry-outbound-propagation design for R3 hos…
doudouOUC May 22, 2026
7a1b4f8
fix(telemetry): defensive allowlist normalization + positive proxy test
doudouOUC May 24, 2026
9bdd3bd
refactor(telemetry): split outbound correlation out of telemetry scop…
doudouOUC May 25, 2026
0be0df2
docs(telemetry): disclose telemetry.enabled dependency on propagateTr…
doudouOUC May 25, 2026
c0352fd
test(config): cover getOutboundCorrelationPropagateTraceContext defaults
doudouOUC May 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
618 changes: 618 additions & 0 deletions docs/design/telemetry-outbound-propagation-design.md

Large diffs are not rendered by default.

66 changes: 66 additions & 0 deletions docs/developers/development/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,72 @@ 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

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:

```
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.

**Feedback-loop avoidance.** OTel SDK uses `fetch` internally to upload OTLP
data. Without protection, instrumenting `fetch` would trace those uploads,
which would themselves be uploaded, causing an infinite loop. Qwen Code's
undici instrumentation is configured with an `ignoreRequestHook` that skips
URLs matching the configured `telemetry.otlpEndpoint` /
`telemetry.otlpTracesEndpoint` / `telemetry.otlpLogsEndpoint` /
`telemetry.otlpMetricsEndpoint` prefixes. In file-outfile mode there are no
outbound HTTP uploads, so the hook is a no-op.

### Session correlation header

Alongside `traceparent`, Qwen Code injects a custom HTTP header on every
outbound LLM request 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 custom DashScope
proxy 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.

The header value comes from `Config.getSessionId()`, read fresh on every
outbound request (not captured at SDK construction time). After a session
reset triggered by `/clear`, subsequent LLM requests carry the new session
id automatically — see the "Known limitation" note below for the one
exception.

Empty session ids are not emitted (some HTTP middleware rejects empty
header values). The header is omitted entirely when telemetry is disabled.

**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. After a `/clear`-triggered session reset, outbound Gemini requests
carry the OLD session id in `X-Qwen-Code-Session-Id` until the underlying
content generator is recreated (the current code does not recreate it on
reset). 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.

## Aliyun Telemetry

### Manual OTLP Export
Expand Down
23 changes: 20 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@opentelemetry/exporter-trace-otlp-grpc": "^0.203.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.203.0",
"@opentelemetry/instrumentation-http": "^0.203.0",
"@opentelemetry/instrumentation-undici": "^0.14.0",
"@opentelemetry/sdk-node": "^0.203.0",
"@types/html-to-text": "^9.0.4",
"@xterm/headless": "5.5.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,39 @@ describe('AnthropicContentGenerator', () => {
mockConfig = {
getCliVersion: vi.fn().mockReturnValue('1.2.3'),
getProxy: vi.fn().mockReturnValue(undefined),
getTelemetryEnabled: vi.fn().mockReturnValue(false),
getSessionId: vi.fn().mockReturnValue('test-session'),
} as unknown as Config;
});

afterEach(() => {
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)`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ 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';
Expand Down Expand Up @@ -203,6 +204,13 @@ 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 }
Expand All @@ -212,6 +220,17 @@ 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(
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/core/contentGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ describe('createContentGenerator', () => {
getUsageStatisticsEnabled: () => true,
getContentGeneratorConfig: () => ({}),
getCliVersion: () => '1.0.0',
getTelemetryEnabled: () => false,
getSessionId: () => 'test-session',
} as unknown as Config;

const mockGenerator = {
Expand Down Expand Up @@ -57,6 +59,8 @@ describe('createContentGenerator', () => {
getUsageStatisticsEnabled: () => false,
getContentGeneratorConfig: () => ({}),
getCliVersion: () => '1.0.0',
getTelemetryEnabled: () => false,
getSessionId: () => 'test-session',
} as unknown as Config;
const mockGenerator = {
models: {},
Expand Down
40 changes: 40 additions & 0 deletions packages/core/src/core/geminiContentGenerator/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ describe('createGeminiContentGenerator', () => {
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
getTelemetryEnabled: vi.fn().mockReturnValue(false),
getSessionId: vi.fn().mockReturnValue('test-session'),
} as unknown as Config;
});

Expand Down Expand Up @@ -89,4 +91,42 @@ 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('includes X-Qwen-Code-Session-Id in httpOptions.headers when telemetry is enabled', () => {
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'),
} 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['X-Qwen-Code-Session-Id']).toBe(
'sess-gemini',
);
});
});
8 changes: 8 additions & 0 deletions packages/core/src/core/geminiContentGenerator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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';

Expand Down Expand Up @@ -38,6 +39,13 @@ 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.
headers = { ...headers, ...staticCorrelationHeaders(gcConfig) };
const httpOptions = config.baseUrl
? {
headers,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,17 @@ 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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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';

Expand Down Expand Up @@ -138,13 +139,17 @@ 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,
timeout,
maxRetries,
defaultHeaders,
...(runtimeOptions || {}),
fetch: wrapFetchWithCorrelation(baseFetch, this.cliConfig),
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,41 @@ 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', () => {
// 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() as unknown as typeof fetch;
const mockedBuildRuntimeFetchOptions =
buildRuntimeFetchOptions as unknown as MockedFunction<
(sdkType: 'openai', proxyUrl?: string) => OpenAIRuntimeFetchOptions
>;
Comment thread
doudouOUC marked this conversation as resolved.
Outdated
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);
});
});

describe('buildRequest', () => {
Expand Down
Loading
Loading