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..cb61f15484e 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 } 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/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..a79272cd2ca --- /dev/null +++ b/packages/core/src/telemetry/trace-context.test.ts @@ -0,0 +1,245 @@ +/** + * @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(); + }); + + it('rejects span with valid traceId but zero spanId', () => { + vi.mocked(trace.getActiveSpan).mockReturnValue( + mockSpan('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', INVALID_SPAN, 1), + ); + + 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 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); + 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..df9b021ef5a 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,44 @@ 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', + ); + expect(env['TRACESTATE']).toBe(''); + }); + + 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 7956eecb7dc..be700fcbdab 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,11 @@ export function getShellContextEnvVars(): Record { const promptId = promptIdContext.getStore(); env['QWEN_CODE_PROMPT_ID'] = promptId ?? ''; + if (isShellTracePropagationEnabled()) { + const ctx = getTraceContext(); + env['TRACEPARENT'] = ctx ? formatTraceparent(ctx) : ''; + env['TRACESTATE'] = ''; + } + return env; } 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 }