Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Comment thread
doudouOUC marked this conversation as resolved.
type: 'boolean',
default: false,
},
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
53 changes: 53 additions & 0 deletions packages/core/src/telemetry/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
});
5 changes: 5 additions & 0 deletions packages/core/src/telemetry/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -547,6 +548,9 @@ export function initializeTelemetry(config: Config): void {
telemetryInitialized = true;
const sessionId = config.getSessionId();
setSessionContext(createSessionRootContext(sessionId), sessionId);
setShellTracePropagation(
Comment thread
doudouOUC marked this conversation as resolved.
config.getOutboundCorrelationPropagateTraceContext(),
);
initializeMetrics(config);
} catch (error) {
debugLogger.error('Error starting OpenTelemetry SDK:', error);
Expand Down Expand Up @@ -623,6 +627,7 @@ export async function shutdownTelemetry(): Promise<void> {
sdk = undefined;
telemetryShutdownPromise = undefined;
setSessionContext(undefined);
setShellTracePropagation(false);
}
})();
return telemetryShutdownPromise;
Expand Down
245 changes: 245 additions & 0 deletions packages/core/src/telemetry/trace-context.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Comment thread
doudouOUC marked this conversation as resolved.
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);
});
});
});
Loading
Loading