From 1d9578872c73ede1b1835243615334440f3a9fad Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 9 Jun 2026 20:51:31 +0800 Subject: [PATCH 1/5] feat(telemetry): inject TRACEPARENT env var into shell child processes When `outboundCorrelation.propagateTraceContext` is enabled, inject a W3C `TRACEPARENT` environment variable into all shell child processes (Bash tool, hooks, monitor) so that CLI tools and Python scripts can participate in distributed tracing. - Extract shared trace-context module from debugLogger's private helpers - Use OTel's `isSpanContextValid` and `INVALID_TRACEID` for validation - Gate injection via module-level setter (set in initializeTelemetry, reset in shutdownTelemetry) - Update settingsSchema description to document shell env var behavior --- packages/cli/src/config/settingsSchema.ts | 2 +- packages/core/src/telemetry/index.ts | 2 + packages/core/src/telemetry/sdk.ts | 5 + .../core/src/telemetry/trace-context.test.ts | 229 ++++++++++++++++++ packages/core/src/telemetry/trace-context.ts | 71 ++++++ packages/core/src/utils/debugLogger.test.ts | 77 ++---- packages/core/src/utils/debugLogger.ts | 46 +--- .../core/src/utils/shellContextEnv.test.ts | 51 +++- packages/core/src/utils/shellContextEnv.ts | 12 + 9 files changed, 392 insertions(+), 103 deletions(-) create mode 100644 packages/core/src/telemetry/trace-context.test.ts create mode 100644 packages/core/src/telemetry/trace-context.ts diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 654731defda..eb51ad19c9c 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1081,7 +1081,7 @@ const SETTINGS_SCHEMA = { properties: { propagateTraceContext: { description: - "Requires `telemetry.enabled: true`. Inject W3C `traceparent` header on outbound `fetch` requests (LLM SDK calls, MCP StreamableHTTP, WebFetch, ...). Default: false — trace context stays internal to the operator's OTLP collector and is NOT written onto third-party request streams. Set true only when you want cross-process trace stitching with an OTel-aware LLM provider (e.g. ARMS+DashScope). Client HTTP spans are still emitted in either case; this flag only governs the wire `traceparent` header.", + "Requires `telemetry.enabled: true`. Inject W3C `traceparent` on outbound `fetch` requests (LLM SDK calls, MCP StreamableHTTP, WebFetch, ...) AND as a `TRACEPARENT` environment variable in shell child processes (Bash tool, hooks, monitor). When enabled, any existing `TRACEPARENT` in the parent environment is overwritten with qwen-code's own trace context. Default: false — trace context stays internal to the operator's OTLP collector. Set true when you want cross-process trace stitching with an OTel-aware LLM provider (e.g. ARMS+DashScope) or need shell scripts / CLI tools to participate in distributed tracing.", type: 'boolean', default: false, }, diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index a24338a84fe..62bf09a2cde 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -182,3 +182,5 @@ export { truncateContent, clearDetailedSpanState, } from './detailed-span-attributes.js'; +export { getTraceContext, formatTraceparent, ZERO_TRACE_ID } from './trace-context.js'; +export type { TraceContext } from './trace-context.js'; diff --git a/packages/core/src/telemetry/sdk.ts b/packages/core/src/telemetry/sdk.ts index 322fd1763f1..c59b7728138 100644 --- a/packages/core/src/telemetry/sdk.ts +++ b/packages/core/src/telemetry/sdk.ts @@ -37,6 +37,7 @@ import { createDebugLogger } from '../utils/debugLogger.js'; import { LogToSpanProcessor } from './log-to-span-processor.js'; import { createSessionRootContext } from './tracer.js'; import { setSessionContext } from './session-context.js'; +import { setShellTracePropagation } from './trace-context.js'; import { endInteractionSpan } from './session-tracing.js'; function createTelemetryDiagLogger(): DiagLogger { @@ -547,6 +548,9 @@ export function initializeTelemetry(config: Config): void { telemetryInitialized = true; const sessionId = config.getSessionId(); setSessionContext(createSessionRootContext(sessionId), sessionId); + setShellTracePropagation( + config.getOutboundCorrelationPropagateTraceContext(), + ); initializeMetrics(config); } catch (error) { debugLogger.error('Error starting OpenTelemetry SDK:', error); @@ -623,6 +627,7 @@ export async function shutdownTelemetry(): Promise { sdk = undefined; telemetryShutdownPromise = undefined; setSessionContext(undefined); + setShellTracePropagation(false); } })(); return telemetryShutdownPromise; diff --git a/packages/core/src/telemetry/trace-context.test.ts b/packages/core/src/telemetry/trace-context.test.ts new file mode 100644 index 00000000000..c68aa9ccf68 --- /dev/null +++ b/packages/core/src/telemetry/trace-context.test.ts @@ -0,0 +1,229 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { trace } from '@opentelemetry/api'; +import type { Span, Context } from '@opentelemetry/api'; +import { getSessionContext } from './session-context.js'; +import { + getActiveSpanTraceContext, + getSessionRootTraceContext, + getTraceContext, + formatTraceparent, + setShellTracePropagation, + isShellTracePropagationEnabled, + ZERO_TRACE_ID, +} from './trace-context.js'; + +const { INVALID_TRACE, INVALID_SPAN } = vi.hoisted(() => ({ + INVALID_TRACE: '0'.repeat(32), + INVALID_SPAN: '0'.repeat(16), +})); + +vi.mock('@opentelemetry/api', () => ({ + trace: { + getActiveSpan: vi.fn().mockReturnValue(undefined), + getSpan: vi.fn().mockReturnValue(undefined), + }, + INVALID_TRACEID: INVALID_TRACE, + isSpanContextValid: vi + .fn() + .mockImplementation( + (ctx: { traceId: string; spanId: string }) => + ctx.traceId !== INVALID_TRACE && ctx.spanId !== INVALID_SPAN, + ), +})); + +vi.mock('./session-context.js', () => ({ + getSessionContext: vi.fn().mockReturnValue(undefined), +})); + +function mockSpan( + traceId: string, + spanId: string, + traceFlags: number, +): Span { + return { + spanContext: () => ({ traceId, spanId, traceFlags }), + } as unknown as Span; +} + +describe('trace-context', () => { + beforeEach(() => { + vi.mocked(trace.getActiveSpan).mockReturnValue(undefined); + vi.mocked(trace.getSpan).mockReturnValue(undefined); + vi.mocked(getSessionContext).mockReturnValue(undefined); + setShellTracePropagation(false); + }); + + describe('getActiveSpanTraceContext', () => { + it('returns trace context from active span', () => { + vi.mocked(trace.getActiveSpan).mockReturnValue( + mockSpan('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbb', 1), + ); + + const ctx = getActiveSpanTraceContext(); + expect(ctx).toEqual({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanId: 'bbbbbbbbbbbbbbbb', + traceFlags: 1, + }); + }); + + it('returns null for NOOP span with zero traceId', () => { + vi.mocked(trace.getActiveSpan).mockReturnValue( + mockSpan(ZERO_TRACE_ID, 'bbbbbbbbbbbbbbbb', 0), + ); + + expect(getActiveSpanTraceContext()).toBeNull(); + }); + + it('returns null when no active span', () => { + vi.mocked(trace.getActiveSpan).mockReturnValue(undefined); + expect(getActiveSpanTraceContext()).toBeNull(); + }); + + it('returns null when getActiveSpan throws', () => { + vi.mocked(trace.getActiveSpan).mockImplementation(() => { + throw new Error('otel unavailable'); + }); + + expect(getActiveSpanTraceContext()).toBeNull(); + }); + }); + + describe('getSessionRootTraceContext', () => { + it('returns trace context from session root span', () => { + const sessionCtx = {} as Context; + vi.mocked(getSessionContext).mockReturnValue(sessionCtx); + vi.mocked(trace.getSpan).mockImplementation((ctx) => + ctx === sessionCtx + ? mockSpan( + 'cccccccccccccccccccccccccccccccc', + 'dddddddddddddddd', + 1, + ) + : undefined, + ); + + const ctx = getSessionRootTraceContext(); + expect(ctx).toEqual({ + traceId: 'cccccccccccccccccccccccccccccccc', + spanId: 'dddddddddddddddd', + traceFlags: 1, + }); + }); + + it('returns null when no session context', () => { + vi.mocked(getSessionContext).mockReturnValue(undefined); + expect(getSessionRootTraceContext()).toBeNull(); + }); + + it('returns null when session span has zero traceId', () => { + const sessionCtx = {} as Context; + vi.mocked(getSessionContext).mockReturnValue(sessionCtx); + vi.mocked(trace.getSpan).mockReturnValue( + mockSpan(ZERO_TRACE_ID, 'dddddddddddddddd', 0), + ); + + expect(getSessionRootTraceContext()).toBeNull(); + }); + }); + + describe('getTraceContext', () => { + it('prefers active span over session root', () => { + vi.mocked(trace.getActiveSpan).mockReturnValue( + mockSpan('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbb', 1), + ); + const sessionCtx = {} as Context; + vi.mocked(getSessionContext).mockReturnValue(sessionCtx); + vi.mocked(trace.getSpan).mockReturnValue( + mockSpan('cccccccccccccccccccccccccccccccc', 'dddddddddddddddd', 1), + ); + + const ctx = getTraceContext(); + expect(ctx?.traceId).toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + }); + + it('falls back to session root when no active span', () => { + vi.mocked(trace.getActiveSpan).mockReturnValue(undefined); + const sessionCtx = {} as Context; + vi.mocked(getSessionContext).mockReturnValue(sessionCtx); + vi.mocked(trace.getSpan).mockImplementation((ctx) => + ctx === sessionCtx + ? mockSpan( + 'cccccccccccccccccccccccccccccccc', + 'dddddddddddddddd', + 1, + ) + : undefined, + ); + + const ctx = getTraceContext(); + expect(ctx?.traceId).toBe('cccccccccccccccccccccccccccccccc'); + }); + + it('returns null when neither source has context', () => { + expect(getTraceContext()).toBeNull(); + }); + }); + + describe('formatTraceparent', () => { + it('formats with traceFlags=0', () => { + expect( + formatTraceparent({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanId: 'bbbbbbbbbbbbbbbb', + traceFlags: 0, + }), + ).toBe('00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00'); + }); + + it('formats with traceFlags=1 (sampled)', () => { + expect( + formatTraceparent({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanId: 'bbbbbbbbbbbbbbbb', + traceFlags: 1, + }), + ).toBe('00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01'); + }); + + it('formats with traceFlags=255', () => { + expect( + formatTraceparent({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanId: 'bbbbbbbbbbbbbbbb', + traceFlags: 255, + }), + ).toBe('00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-ff'); + }); + + it('masks traceFlags to one byte', () => { + expect( + formatTraceparent({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanId: 'bbbbbbbbbbbbbbbb', + traceFlags: 0x1ff, + }), + ).toBe('00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-ff'); + }); + }); + + describe('shellTracePropagation', () => { + it('defaults to false', () => { + expect(isShellTracePropagationEnabled()).toBe(false); + }); + + it('can be enabled and disabled', () => { + setShellTracePropagation(true); + expect(isShellTracePropagationEnabled()).toBe(true); + + setShellTracePropagation(false); + expect(isShellTracePropagationEnabled()).toBe(false); + }); + }); +}); diff --git a/packages/core/src/telemetry/trace-context.ts b/packages/core/src/telemetry/trace-context.ts new file mode 100644 index 00000000000..8414ef9d4a0 --- /dev/null +++ b/packages/core/src/telemetry/trace-context.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + trace, + isSpanContextValid, + INVALID_TRACEID, +} from '@opentelemetry/api'; +import type { Span } from '@opentelemetry/api'; +import { getSessionContext } from './session-context.js'; + +export const ZERO_TRACE_ID = INVALID_TRACEID; + +export interface TraceContext { + traceId: string; + spanId: string; + traceFlags: number; +} + +function extractTraceContext(span: Span | undefined): TraceContext | null { + const ctx = span?.spanContext(); + if (ctx && isSpanContextValid(ctx)) { + return { + traceId: ctx.traceId, + spanId: ctx.spanId, + traceFlags: ctx.traceFlags, + }; + } + return null; +} + +export function getActiveSpanTraceContext(): TraceContext | null { + try { + return extractTraceContext(trace.getActiveSpan()); + } catch { + return null; + } +} + +export function getSessionRootTraceContext(): TraceContext | null { + try { + const sessionCtx = getSessionContext(); + return extractTraceContext( + sessionCtx ? trace.getSpan(sessionCtx) : undefined, + ); + } catch { + return null; + } +} + +export function getTraceContext(): TraceContext | null { + return getActiveSpanTraceContext() ?? getSessionRootTraceContext(); +} + +export function formatTraceparent(ctx: TraceContext): string { + const flags = (ctx.traceFlags & 0xff).toString(16).padStart(2, '0'); + return `00-${ctx.traceId}-${ctx.spanId}-${flags}`; +} + +let shellTracePropagationEnabled = false; + +export function setShellTracePropagation(enabled: boolean): void { + shellTracePropagationEnabled = enabled; +} + +export function isShellTracePropagationEnabled(): boolean { + return shellTracePropagationEnabled; +} diff --git a/packages/core/src/utils/debugLogger.test.ts b/packages/core/src/utils/debugLogger.test.ts index 813d84a76d4..e85fc214a90 100644 --- a/packages/core/src/utils/debugLogger.test.ts +++ b/packages/core/src/utils/debugLogger.test.ts @@ -15,8 +15,7 @@ import { import { promises as fs } from 'node:fs'; import path from 'node:path'; import { Storage } from '../config/storage.js'; -import { trace, type Context, type Span } from '@opentelemetry/api'; -import { setSessionContext } from '../telemetry/session-context.js'; +import { getTraceContext } from '../telemetry/trace-context.js'; vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); @@ -33,11 +32,8 @@ vi.mock('node:fs', async (importOriginal) => { }; }); -vi.mock('@opentelemetry/api', () => ({ - trace: { - getActiveSpan: vi.fn().mockReturnValue(undefined), - getSpan: vi.fn().mockReturnValue(undefined), - }, +vi.mock('../telemetry/trace-context.js', () => ({ + getTraceContext: vi.fn().mockReturnValue(null), })); describe('debugLogger', () => { @@ -55,16 +51,12 @@ describe('debugLogger', () => { vi.setSystemTime(new Date('2026-01-24T10:30:00.000Z')); resetDebugLoggingState(); setDebugLogSession(mockSession); - setSessionContext(undefined); - // Default: no active OTel span - vi.mocked(trace.getActiveSpan).mockReturnValue(undefined); - vi.mocked(trace.getSpan).mockReturnValue(undefined); + vi.mocked(getTraceContext).mockReturnValue(null); }); afterEach(() => { vi.useRealTimers(); setDebugLogSession(null); - setSessionContext(undefined); Storage.setRuntimeBaseDir(null); if (previousDebugLogFileEnv === undefined) { delete process.env['QWEN_DEBUG_LOG_FILE']; @@ -130,14 +122,12 @@ describe('debugLogger', () => { expect(calls[3]?.[1]).toContain('[ERROR]'); }); - it('uses real OTel span context when an active span exists', async () => { - vi.mocked(trace.getActiveSpan).mockReturnValue({ - spanContext: () => ({ - traceId: 'realtraceidddddddddddddddddddddd', - spanId: 'realspanid111111', - traceFlags: 1, - }), - } as unknown as Span); + it('uses trace context when getTraceContext returns a context', async () => { + vi.mocked(getTraceContext).mockReturnValue({ + traceId: 'realtraceidddddddddddddddddddddd', + spanId: 'realspanid111111', + traceFlags: 1, + }); const logger = createDebugLogger(); logger.debug('with real span'); @@ -153,34 +143,11 @@ describe('debugLogger', () => { ); }); - it('omits trace context when active span is noop and telemetry context is unset', async () => { - vi.mocked(trace.getActiveSpan).mockReturnValue({ - spanContext: () => ({ - traceId: '00000000000000000000000000000000', - spanId: 'deadbeefdeadbeef', - traceFlags: 0, - }), - } as unknown as Span); - - const logger = createDebugLogger(); - logger.debug('noop span'); - - await vi.runAllTimersAsync(); - - expect(fs.appendFile).toHaveBeenCalledWith( - expect.any(String), - expect.not.stringContaining('trace_id='), - 'utf8', - ); - }); - - it('omits trace context when reading the active span throws and telemetry context is unset', async () => { - vi.mocked(trace.getActiveSpan).mockImplementationOnce(() => { - throw new Error('otel unavailable'); - }); + it('omits trace context when getTraceContext returns null', async () => { + vi.mocked(getTraceContext).mockReturnValue(null); const logger = createDebugLogger(); - logger.debug('otel failure'); + logger.debug('no trace context'); await vi.runAllTimersAsync(); @@ -206,19 +173,11 @@ describe('debugLogger', () => { }); it('uses the session root span context for fallback trace context', async () => { - const sessionRootContext = { root: true } as unknown as Context; - setSessionContext(sessionRootContext, 'test-session'); - vi.mocked(trace.getSpan).mockImplementation((ctx) => - ctx === sessionRootContext - ? ({ - spanContext: () => ({ - traceId: 'cccccccccccccccccccccccccccccccc', - spanId: 'dddddddddddddddd', - traceFlags: 1, - }), - } as unknown as Span) - : undefined, - ); + vi.mocked(getTraceContext).mockReturnValue({ + traceId: 'cccccccccccccccccccccccccccccccc', + spanId: 'dddddddddddddddd', + traceFlags: 1, + }); const logger = createDebugLogger(); logger.debug('session root fallback'); diff --git a/packages/core/src/utils/debugLogger.ts b/packages/core/src/utils/debugLogger.ts index f8023801513..76f3c07c730 100644 --- a/packages/core/src/utils/debugLogger.ts +++ b/packages/core/src/utils/debugLogger.ts @@ -8,10 +8,12 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { AsyncLocalStorage } from 'node:async_hooks'; import util from 'node:util'; -import { trace } from '@opentelemetry/api'; import { Storage } from '../config/storage.js'; import { updateSymlink } from './symlink.js'; -import { getSessionContext } from '../telemetry/session-context.js'; +import { + getTraceContext, + type TraceContext, +} from '../telemetry/trace-context.js'; type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'; @@ -33,11 +35,6 @@ let hasWriteFailure = false; let globalSession: DebugLogSession | null = null; const sessionContext = new AsyncLocalStorage(); -interface TraceContext { - traceId: string; - spanId: string; -} - function isDebugLogFileEnabled(): boolean { const value = process.env['QWEN_DEBUG_LOG_FILE']; if (!value) return true; @@ -77,41 +74,6 @@ function formatArgs(args: unknown[]): string { .join(' '); } -const ZERO_TRACE_ID = '00000000000000000000000000000000'; - -function getActiveSpanTraceContext(): TraceContext | null { - try { - const activeSpan = trace.getActiveSpan(); - if (activeSpan) { - const ctx = activeSpan.spanContext(); - if (ctx.traceId !== ZERO_TRACE_ID) { - return { traceId: ctx.traceId, spanId: ctx.spanId }; - } - } - return null; - } catch { - return null; - } -} - -function getSessionRootTraceContext(): TraceContext | null { - try { - const sessionContext = getSessionContext(); - const sessionSpan = sessionContext ? trace.getSpan(sessionContext) : null; - const ctx = sessionSpan?.spanContext(); - if (ctx && ctx.traceId !== ZERO_TRACE_ID) { - return { traceId: ctx.traceId, spanId: ctx.spanId }; - } - return null; - } catch { - return null; - } -} - -function getTraceContext(): TraceContext | null { - return getActiveSpanTraceContext() ?? getSessionRootTraceContext(); -} - /** * Builds a log line in the format: * `2026-01-23T06:58:02.011Z [DEBUG] [TAG] [trace_id=xxx span_id=yyy] message` diff --git a/packages/core/src/utils/shellContextEnv.test.ts b/packages/core/src/utils/shellContextEnv.test.ts index 17680915eb6..1f8d37c89d5 100644 --- a/packages/core/src/utils/shellContextEnv.test.ts +++ b/packages/core/src/utils/shellContextEnv.test.ts @@ -4,10 +4,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { getShellContextEnvVars } from './shellContextEnv.js'; import { runWithAgentContext } from '../agents/runtime/agent-context.js'; import { promptIdContext } from './promptIdContext.js'; +import { + isShellTracePropagationEnabled, + getTraceContext, + formatTraceparent, +} from '../telemetry/trace-context.js'; + +vi.mock('../telemetry/trace-context.js', () => ({ + isShellTracePropagationEnabled: vi.fn().mockReturnValue(false), + getTraceContext: vi.fn().mockReturnValue(null), + formatTraceparent: vi.fn().mockReturnValue('00-aaaa-bbbb-01'), +})); describe('getShellContextEnvVars', () => { let originalSessionId: string | undefined; @@ -72,4 +83,42 @@ describe('getShellContextEnvVars', () => { expect(env['QWEN_CODE_PROMPT_ID']).toBe(''); // Empty strings will overwrite any stale inherited values in process.env }); + + describe('TRACEPARENT injection', () => { + afterEach(() => { + vi.mocked(isShellTracePropagationEnabled).mockReturnValue(false); + vi.mocked(getTraceContext).mockReturnValue(null); + }); + + it('does not inject TRACEPARENT when propagation is disabled', () => { + vi.mocked(isShellTracePropagationEnabled).mockReturnValue(false); + const env = getShellContextEnvVars(); + expect(env['TRACEPARENT']).toBeUndefined(); + }); + + it('injects TRACEPARENT when propagation is enabled and context exists', () => { + vi.mocked(isShellTracePropagationEnabled).mockReturnValue(true); + vi.mocked(getTraceContext).mockReturnValue({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanId: 'bbbbbbbbbbbbbbbb', + traceFlags: 1, + }); + vi.mocked(formatTraceparent).mockReturnValue( + '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', + ); + + const env = getShellContextEnvVars(); + expect(env['TRACEPARENT']).toBe( + '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', + ); + }); + + it('does not inject TRACEPARENT when propagation is enabled but no context', () => { + vi.mocked(isShellTracePropagationEnabled).mockReturnValue(true); + vi.mocked(getTraceContext).mockReturnValue(null); + + const env = getShellContextEnvVars(); + expect(env['TRACEPARENT']).toBeUndefined(); + }); + }); }); diff --git a/packages/core/src/utils/shellContextEnv.ts b/packages/core/src/utils/shellContextEnv.ts index 7956eecb7dc..bf81c74e086 100644 --- a/packages/core/src/utils/shellContextEnv.ts +++ b/packages/core/src/utils/shellContextEnv.ts @@ -19,6 +19,11 @@ import { getCurrentAgentId } from '../agents/runtime/agent-context.js'; import { promptIdContext } from './promptIdContext.js'; +import { + isShellTracePropagationEnabled, + getTraceContext, + formatTraceparent, +} from '../telemetry/trace-context.js'; export function getShellContextEnvVars(): Record { const env: Record = {}; @@ -37,5 +42,12 @@ export function getShellContextEnvVars(): Record { const promptId = promptIdContext.getStore(); env['QWEN_CODE_PROMPT_ID'] = promptId ?? ''; + if (isShellTracePropagationEnabled()) { + const ctx = getTraceContext(); + if (ctx) { + env['TRACEPARENT'] = formatTraceparent(ctx); + } + } + return env; } From 8e659a515636bf8146f2ac319d70241b6c0b0369 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 9 Jun 2026 21:01:15 +0800 Subject: [PATCH 2/5] fix: clear TRACEPARENT when enabled but no trace context available When propagation is enabled but getTraceContext() returns null (e.g., during initialization before any span exists), explicitly set TRACEPARENT to empty string to prevent stale/foreign values inherited from process.env from leaking into child processes. --- packages/core/src/utils/shellContextEnv.test.ts | 4 ++-- packages/core/src/utils/shellContextEnv.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/shellContextEnv.test.ts b/packages/core/src/utils/shellContextEnv.test.ts index 1f8d37c89d5..e26bc4d7283 100644 --- a/packages/core/src/utils/shellContextEnv.test.ts +++ b/packages/core/src/utils/shellContextEnv.test.ts @@ -113,12 +113,12 @@ describe('getShellContextEnvVars', () => { ); }); - it('does not inject TRACEPARENT when propagation is enabled but no context', () => { + it('clears TRACEPARENT when propagation is enabled but no context', () => { vi.mocked(isShellTracePropagationEnabled).mockReturnValue(true); vi.mocked(getTraceContext).mockReturnValue(null); const env = getShellContextEnvVars(); - expect(env['TRACEPARENT']).toBeUndefined(); + expect(env['TRACEPARENT']).toBe(''); }); }); }); diff --git a/packages/core/src/utils/shellContextEnv.ts b/packages/core/src/utils/shellContextEnv.ts index bf81c74e086..b8a24718f33 100644 --- a/packages/core/src/utils/shellContextEnv.ts +++ b/packages/core/src/utils/shellContextEnv.ts @@ -46,6 +46,8 @@ export function getShellContextEnvVars(): Record { const ctx = getTraceContext(); if (ctx) { env['TRACEPARENT'] = formatTraceparent(ctx); + } else { + env['TRACEPARENT'] = ''; } } From 326386e296681a7466f87cfaa15925cb12413f30 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 9 Jun 2026 21:25:37 +0800 Subject: [PATCH 3/5] chore: regenerate settings.schema.json after description update --- packages/vscode-ide-companion/schemas/settings.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index a1213736744..f95e9307a48 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -453,7 +453,7 @@ "type": "object", "properties": { "propagateTraceContext": { - "description": "Requires `telemetry.enabled: true`. Inject W3C `traceparent` header on outbound `fetch` requests (LLM SDK calls, MCP StreamableHTTP, WebFetch, ...). Default: false — trace context stays internal to the operator's OTLP collector and is NOT written onto third-party request streams. Set true only when you want cross-process trace stitching with an OTel-aware LLM provider (e.g. ARMS+DashScope). Client HTTP spans are still emitted in either case; this flag only governs the wire `traceparent` header.", + "description": "Requires `telemetry.enabled: true`. Inject W3C `traceparent` on outbound `fetch` requests (LLM SDK calls, MCP StreamableHTTP, WebFetch, ...) AND as a `TRACEPARENT` environment variable in shell child processes (Bash tool, hooks, monitor). When enabled, any existing `TRACEPARENT` in the parent environment is overwritten with qwen-code's own trace context. Default: false — trace context stays internal to the operator's OTLP collector. Set true when you want cross-process trace stitching with an OTel-aware LLM provider (e.g. ARMS+DashScope) or need shell scripts / CLI tools to participate in distributed tracing.", "type": "boolean", "default": false } From 28be283b6860ca02c4e992882b50ea9c19188556 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 9 Jun 2026 22:09:35 +0800 Subject: [PATCH 4/5] fix: address wenshao review round 1 - Remove ZERO_TRACE_ID from barrel export (no external consumers) - Clear TRACESTATE alongside TRACEPARENT when context unavailable - Add zero-spanId rejection test (isSpanContextValid contract) - Add getSessionRootTraceContext error path test - Add sdk.test.ts assertions for setShellTracePropagation wiring --- packages/core/src/telemetry/index.ts | 2 +- packages/core/src/telemetry/sdk.test.ts | 53 +++++++++++++++++++ .../core/src/telemetry/trace-context.test.ts | 16 ++++++ .../core/src/utils/shellContextEnv.test.ts | 3 +- packages/core/src/utils/shellContextEnv.ts | 1 + 5 files changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index 62bf09a2cde..cb61f15484e 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -182,5 +182,5 @@ export { truncateContent, clearDetailedSpanState, } from './detailed-span-attributes.js'; -export { getTraceContext, formatTraceparent, ZERO_TRACE_ID } from './trace-context.js'; +export { getTraceContext, formatTraceparent } from './trace-context.js'; export type { TraceContext } from './trace-context.js'; diff --git a/packages/core/src/telemetry/sdk.test.ts b/packages/core/src/telemetry/sdk.test.ts index 63091359a50..d73789d6bca 100644 --- a/packages/core/src/telemetry/sdk.test.ts +++ b/packages/core/src/telemetry/sdk.test.ts @@ -57,12 +57,14 @@ vi.mock('@opentelemetry/instrumentation-undici'); vi.mock('./gcp-exporters.js'); vi.mock('./log-to-span-processor.js'); vi.mock('./session-context.js'); +vi.mock('./trace-context.js'); vi.mock('./tracer.js', () => ({ createSessionRootContext: vi.fn((id: string) => ({ __sessionId: id })), })); import { LogToSpanProcessor } from './log-to-span-processor.js'; import { setSessionContext } from './session-context.js'; +import { setShellTracePropagation } from './trace-context.js'; import { createSessionRootContext } from './tracer.js'; import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; import { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici'; @@ -1303,3 +1305,54 @@ describe('refreshSessionContext', () => { expect(setSessionContext).not.toHaveBeenCalled(); }); }); + +describe('shell trace propagation wiring', () => { + let mockConfig: Config; + + beforeEach(() => { + vi.clearAllMocks(); + mockConfig = { + getTelemetryEnabled: () => true, + getTelemetryOtlpEndpoint: () => 'http://localhost:4317', + getTelemetryOtlpProtocol: () => 'grpc', + getTelemetryOtlpTracesEndpoint: () => undefined, + getTelemetryOtlpLogsEndpoint: () => undefined, + getTelemetryOtlpMetricsEndpoint: () => undefined, + getTelemetryTarget: () => 'local', + getTelemetryOutfile: () => undefined, + getTelemetryIncludeSensitiveSpanAttributes: () => false, + getTelemetryResourceAttributes: () => ({}), + getTelemetryMetricsIncludeSessionId: () => false, + getTelemetryResourceAttributeWarnings: () => [], + getDebugMode: () => false, + getSessionId: () => 'test-session', + getCliVersion: () => '1.0.0-test', + getOutboundCorrelationPropagateTraceContext: () => false, + isInteractive: () => false, + } as unknown as Config; + }); + + afterEach(async () => { + await shutdownTelemetry(); + }); + + it('sets shell trace propagation on init based on config', () => { + const config = { + ...mockConfig, + getOutboundCorrelationPropagateTraceContext: () => true, + } as unknown as Config; + + initializeTelemetry(config); + + expect(setShellTracePropagation).toHaveBeenCalledWith(true); + }); + + it('resets shell trace propagation on shutdown', async () => { + initializeTelemetry(mockConfig); + vi.mocked(setShellTracePropagation).mockClear(); + + await shutdownTelemetry(); + + expect(setShellTracePropagation).toHaveBeenCalledWith(false); + }); +}); diff --git a/packages/core/src/telemetry/trace-context.test.ts b/packages/core/src/telemetry/trace-context.test.ts index c68aa9ccf68..a79272cd2ca 100644 --- a/packages/core/src/telemetry/trace-context.test.ts +++ b/packages/core/src/telemetry/trace-context.test.ts @@ -93,6 +93,14 @@ describe('trace-context', () => { expect(getActiveSpanTraceContext()).toBeNull(); }); + + it('rejects span with valid traceId but zero spanId', () => { + vi.mocked(trace.getActiveSpan).mockReturnValue( + mockSpan('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', INVALID_SPAN, 1), + ); + + expect(getActiveSpanTraceContext()).toBeNull(); + }); }); describe('getSessionRootTraceContext', () => { @@ -122,6 +130,14 @@ describe('trace-context', () => { expect(getSessionRootTraceContext()).toBeNull(); }); + it('returns null when getSessionContext throws', () => { + vi.mocked(getSessionContext).mockImplementation(() => { + throw new Error('session unavailable'); + }); + + expect(getSessionRootTraceContext()).toBeNull(); + }); + it('returns null when session span has zero traceId', () => { const sessionCtx = {} as Context; vi.mocked(getSessionContext).mockReturnValue(sessionCtx); diff --git a/packages/core/src/utils/shellContextEnv.test.ts b/packages/core/src/utils/shellContextEnv.test.ts index e26bc4d7283..2ddfe1c812b 100644 --- a/packages/core/src/utils/shellContextEnv.test.ts +++ b/packages/core/src/utils/shellContextEnv.test.ts @@ -113,12 +113,13 @@ describe('getShellContextEnvVars', () => { ); }); - it('clears TRACEPARENT when propagation is enabled but no context', () => { + it('clears TRACEPARENT and TRACESTATE when propagation is enabled but no context', () => { vi.mocked(isShellTracePropagationEnabled).mockReturnValue(true); vi.mocked(getTraceContext).mockReturnValue(null); const env = getShellContextEnvVars(); expect(env['TRACEPARENT']).toBe(''); + expect(env['TRACESTATE']).toBe(''); }); }); }); diff --git a/packages/core/src/utils/shellContextEnv.ts b/packages/core/src/utils/shellContextEnv.ts index b8a24718f33..7462eaf62f6 100644 --- a/packages/core/src/utils/shellContextEnv.ts +++ b/packages/core/src/utils/shellContextEnv.ts @@ -48,6 +48,7 @@ export function getShellContextEnvVars(): Record { env['TRACEPARENT'] = formatTraceparent(ctx); } else { env['TRACEPARENT'] = ''; + env['TRACESTATE'] = ''; } } From 395c6b92708fe9c475703bcb5a36113f61b28b14 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 9 Jun 2026 22:28:23 +0800 Subject: [PATCH 5/5] fix: always clear TRACESTATE when overriding TRACEPARENT Clear TRACESTATE unconditionally when propagation is enabled, not only when trace context is null. Prevents stale vendor state from the parent environment pairing with qwen-code's own traceparent. --- packages/core/src/utils/shellContextEnv.test.ts | 1 + packages/core/src/utils/shellContextEnv.ts | 8 ++------ 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/core/src/utils/shellContextEnv.test.ts b/packages/core/src/utils/shellContextEnv.test.ts index 2ddfe1c812b..df9b021ef5a 100644 --- a/packages/core/src/utils/shellContextEnv.test.ts +++ b/packages/core/src/utils/shellContextEnv.test.ts @@ -111,6 +111,7 @@ describe('getShellContextEnvVars', () => { expect(env['TRACEPARENT']).toBe( '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', ); + expect(env['TRACESTATE']).toBe(''); }); it('clears TRACEPARENT and TRACESTATE when propagation is enabled but no context', () => { diff --git a/packages/core/src/utils/shellContextEnv.ts b/packages/core/src/utils/shellContextEnv.ts index 7462eaf62f6..be700fcbdab 100644 --- a/packages/core/src/utils/shellContextEnv.ts +++ b/packages/core/src/utils/shellContextEnv.ts @@ -44,12 +44,8 @@ export function getShellContextEnvVars(): Record { if (isShellTracePropagationEnabled()) { const ctx = getTraceContext(); - if (ctx) { - env['TRACEPARENT'] = formatTraceparent(ctx); - } else { - env['TRACEPARENT'] = ''; - env['TRACESTATE'] = ''; - } + env['TRACEPARENT'] = ctx ? formatTraceparent(ctx) : ''; + env['TRACESTATE'] = ''; } return env;