Skip to content

Commit 4bb8dc8

Browse files
doudouOUCclaude
andauthored
fix(telemetry): address PR #3847 review follow-ups for trace correlation (#4058)
* fix(telemetry): address PR #3847 review follow-ups for trace correlation Addresses unresolved review feedback from PR #3847: - Respect OTEL_TRACES_SAMPLER env var when setting TraceFlags on the synthetic session root, so custom samplers (e.g. traceidratio) are not bypassed by forced SAMPLED flag - Store current session ID in session-context.ts and use it as a fallback in LogToSpanProcessor when the OTel Resource session.id attribute is stale after /clear or /resume - Wrap for-await loop body in runInSpan() so debug logs emitted during stream iteration see the stream span as active - Add autoOkOnSuccess option to withSpan, eliminating the need for the load-bearing setStatus(UNSET) hack in cancellation paths - Add defensive 5-minute timeout for stream spans to prevent leaks from abandoned generators 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fix(telemetry): address review issues in PR #4058 - Fix sdk.test.ts assertion to match new two-arg setSessionContext call - Add spanEnded guard to prevent double span.end() when timeout fires - Add .trim() to OTEL_TRACES_SAMPLER env var for robustness - Pass sessionId in debugLogger.test.ts for signature completeness - Clarify log-to-span-processor comment: fallback covers "missing" not "stale" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(telemetry): adopt review feedback for sampler and idle timeout - Fix shouldForceSampled to force SAMPLED for all parentbased_* samplers, not just parentbased_always_on — parentbased samplers delegate to localParentNotSampled (default AlwaysOff) when parent has NONE, which silently drops all traces - Convert stream span timeout from fixed wall-clock to idle timeout: reset timer on each chunk so legitimately long streams are never affected; timeout only fires when no chunks arrive for 5 minutes - Add test for parentbased_traceidratio → TraceFlags.SAMPLED * fix(telemetry): guard resetSpanTimeout against already-ended span Prevent zombie timer accumulation when chunks arrive after idle timeout has already ended the span. * fix(telemetry): handle parentbased_always_off sampler and fill test gaps - shouldForceSampled() now returns false for parentbased_always_off, preventing silent over-sampling that contradicts user intent. - Updated JSDoc to document the always_on exception for non-parentbased samplers. - Added test for parentbased_always_off → TraceFlags.NONE. - Added test for success path resilience when safeSetStatus throws in coreToolScheduler. * fix(telemetry): harden span timeout ordering and session ID fallback - Swap spanEnded/span.end() order so finally block can retry if end() throws. - Clear idle timeout immediately after for-await loop exits, before post-loop processing. - Use || instead of ?? for session.id fallback to handle empty strings. * fix(telemetry): address review round 4 — docs, cleanup, and test gaps - Expand shouldForceSampled JSDoc: document env-var assumption, parentbased_traceidratio 100% sampling semantics. - Remove unnecessary runInSpan wrapper around chunk field assignments. - Add stream.timed_out span attribute when idle timeout fires. - Deduplicate config.getSessionId() call in initializeTelemetry. - Add tests for always_on and always_off sampler values. * fix(telemetry): harden timeout callback ordering and add || comment - Move safeSetStatus before setAttribute in timeout callback so ERROR status is set even if setAttribute throws. - Set spanEnded=true before span.end() so finally block never overwrites ERROR with OK if end() throws. - Add comment explaining || vs ?? choice for session.id fallback. --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 97ac766 commit 4bb8dc8

12 files changed

Lines changed: 407 additions & 38 deletions

packages/core/src/core/coreToolScheduler.test.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,9 @@ vi.mock('../telemetry/tracer.js', () => ({
8888
setAttribute: (key: string, value: string | number | boolean) => void;
8989
end: () => void;
9090
}) => Promise<unknown>,
91+
options?: { autoOkOnSuccess?: boolean },
9192
) => {
93+
const autoOkOnSuccess = options?.autoOkOnSuccess ?? true;
9294
const record: ToolSpanRecord = {
9395
name,
9496
attributes,
@@ -119,7 +121,7 @@ vi.mock('../telemetry/tracer.js', () => ({
119121

120122
try {
121123
const result = await fn(span);
122-
if (!statusSet) {
124+
if (autoOkOnSuccess && !statusSet) {
123125
record.statusCalls.push({ code: 1 });
124126
}
125127
return result;
@@ -3273,7 +3275,7 @@ describe('CoreToolScheduler telemetry spans', () => {
32733275
);
32743276
});
32753277

3276-
it('marks cancellation as UNSET with a failure kind and no auto-OK', async () => {
3278+
it('leaves cancellation spans with no explicit status (autoOkOnSuccess: false)', async () => {
32773279
const abortController = new AbortController();
32783280
const { spanRecord, completedCalls } = await runSingleTool({
32793281
abortController,
@@ -3287,12 +3289,14 @@ describe('CoreToolScheduler telemetry spans', () => {
32873289
});
32883290

32893291
expect(completedCalls[0].status).toBe('cancelled');
3290-
expect(spanRecord.statusCalls).toEqual([{ code: SpanStatusCode.UNSET }]);
3292+
// autoOkOnSuccess: false prevents withSpan from auto-setting OK;
3293+
// setToolSpanCancelled only sets the failure_kind attribute, not a status.
3294+
expect(spanRecord.statusCalls).toEqual([]);
32913295
expect(spanRecord.spanAttributes['tool.failure_kind']).toBe('cancelled');
32923296
expect(spanRecord.ended).toBe(true);
32933297
});
32943298

3295-
it('sets cancellation status when span attribute recording fails', async () => {
3299+
it('sets cancellation attribute even when span attribute recording fails', async () => {
32963300
const abortController = new AbortController();
32973301
const { spanRecord, completedCalls } = await runSingleTool({
32983302
abortController,
@@ -3307,7 +3311,9 @@ describe('CoreToolScheduler telemetry spans', () => {
33073311
});
33083312

33093313
expect(completedCalls[0].status).toBe('cancelled');
3310-
expect(spanRecord.statusCalls).toEqual([{ code: SpanStatusCode.UNSET }]);
3314+
// No status set — autoOkOnSuccess: false, and setToolSpanCancelled
3315+
// only sets the attribute (which fails here, caught internally).
3316+
expect(spanRecord.statusCalls).toEqual([]);
33113317
expect(spanRecord.spanAttributes).not.toHaveProperty('tool.failure_kind');
33123318
expect(spanRecord.ended).toBe(true);
33133319
});
@@ -3327,11 +3333,25 @@ describe('CoreToolScheduler telemetry spans', () => {
33273333
});
33283334

33293335
expect(completedCalls[0].status).toBe('cancelled');
3336+
// setToolSpanCancelled no longer calls setStatus, so throwSpanSetStatus
3337+
// only affects the safeSetStatus(span, OK) in the success path (not hit).
3338+
// With autoOkOnSuccess: false, withSpan does not attempt setStatus either.
33303339
expect(spanRecord.statusCalls).toEqual([]);
33313340
expect(spanRecord.spanAttributes['tool.failure_kind']).toBe('cancelled');
33323341
expect(spanRecord.ended).toBe(true);
33333342
});
33343343

3344+
it('does not crash when safeSetStatus throws on the success path', async () => {
3345+
const { spanRecord, completedCalls } = await runSingleTool({
3346+
throwSpanSetStatus: true,
3347+
});
3348+
3349+
expect(completedCalls[0].status).toBe('success');
3350+
expect(spanRecord.statusCalls).toEqual([]);
3351+
expect(spanRecord.spanAttributes).not.toHaveProperty('tool.failure_kind');
3352+
expect(spanRecord.ended).toBe(true);
3353+
});
3354+
33353355
it('leaves successful tool calls to be marked OK by withSpan', async () => {
33363356
const { spanRecord, completedCalls } = await runSingleTool();
33373357

packages/core/src/core/coreToolScheduler.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,9 @@ function setToolSpanCancelled(span: Span): void {
131131
} catch {
132132
// OTel errors must not block the cancellation status update.
133133
}
134-
safeSetStatus(span, {
135-
code: SpanStatusCode.UNSET,
136-
});
134+
// No explicit span status — cancellation is neither OK nor ERROR.
135+
// The caller uses withSpan({ autoOkOnSuccess: false }) so withSpan
136+
// will not auto-set OK, and the span ends with the default UNSET status.
137137
}
138138

139139
async function safelyFirePostToolUseFailureHook(
@@ -1993,7 +1993,6 @@ export class CoreToolScheduler {
19931993
'User cancelled tool execution.',
19941994
);
19951995
}
1996-
// Load-bearing: prevents withSpan from auto-setting OK on normal return
19971996
setToolSpanCancelled(span);
19981997
return; // Both code paths should return here
19991998
}
@@ -2160,6 +2159,7 @@ export class CoreToolScheduler {
21602159
: {}),
21612160
};
21622161
this.setStatusInternal(callId, 'success', successResponse);
2162+
safeSetStatus(span, { code: SpanStatusCode.OK });
21632163
} else {
21642164
// It is a failure
21652165
// PostToolUseFailure Hook
@@ -2226,7 +2226,6 @@ export class CoreToolScheduler {
22262226
'User cancelled tool execution.',
22272227
);
22282228
}
2229-
// Load-bearing: prevents withSpan from auto-setting OK on normal return
22302229
setToolSpanCancelled(span);
22312230
return;
22322231
} else {
@@ -2267,6 +2266,7 @@ export class CoreToolScheduler {
22672266
}
22682267
}
22692268
},
2269+
{ autoOkOnSuccess: false },
22702270
);
22712271
}
22722272

packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -403,13 +403,42 @@ export class LoggingContentGenerator implements ContentGenerator {
403403
let firstModelVersion = '';
404404
let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined;
405405
let terminalStatusAttempted = false;
406+
let spanEnded = false;
406407

407408
// Helper to run code within the span context during iteration.
408409
// This ensures debug log lines emitted during stream processing
409410
// see the stream span as the active span.
410411
const runInSpan = <T>(fn: () => T): T =>
411412
spanContext ? context.with(spanContext, fn) : fn();
412413

414+
// Idle timeout: if no chunks arrive for this duration the consumer has
415+
// likely abandoned the generator without calling .return(). Close the
416+
// span so it doesn't leak forever. The timer resets on every chunk,
417+
// so legitimately long-running streams are never affected.
418+
const STREAM_IDLE_TIMEOUT_MS = 5 * 60_000; // 5 minutes
419+
let spanEndTimeout: ReturnType<typeof setTimeout> | undefined;
420+
const resetSpanTimeout = span
421+
? () => {
422+
if (spanEnded) return;
423+
if (spanEndTimeout !== undefined) clearTimeout(spanEndTimeout);
424+
spanEndTimeout = setTimeout(() => {
425+
try {
426+
safeSetStatus(span, {
427+
code: SpanStatusCode.ERROR,
428+
message: 'Stream span timed out (idle)',
429+
});
430+
spanEnded = true;
431+
span.setAttribute('stream.timed_out', true);
432+
span.end();
433+
} catch {
434+
// OTel errors must not interrupt the consumer.
435+
}
436+
}, STREAM_IDLE_TIMEOUT_MS);
437+
spanEndTimeout.unref();
438+
}
439+
: undefined;
440+
resetSpanTimeout?.();
441+
413442
try {
414443
for await (const response of stream) {
415444
if (!firstResponseId && response.responseId) {
@@ -424,8 +453,13 @@ export class LoggingContentGenerator implements ContentGenerator {
424453
if (response.usageMetadata) {
425454
lastUsageMetadata = response.usageMetadata;
426455
}
456+
resetSpanTimeout?.();
427457
yield response;
428458
}
459+
if (spanEndTimeout !== undefined) {
460+
clearTimeout(spanEndTimeout);
461+
spanEndTimeout = undefined;
462+
}
429463
// Only log successful API response if no error occurred
430464
const durationMs = Date.now() - startTime;
431465
const consolidatedResponse = shouldCollectResponses
@@ -483,15 +517,20 @@ export class LoggingContentGenerator implements ContentGenerator {
483517
}
484518
throw error;
485519
} finally {
486-
if (!terminalStatusAttempted) {
487-
if (span) {
488-
safeSetStatus(span, { code: SpanStatusCode.OK });
489-
}
520+
if (spanEndTimeout !== undefined) {
521+
clearTimeout(spanEndTimeout);
490522
}
491-
try {
492-
span?.end();
493-
} catch {
494-
// OTel errors must not mask the original API error
523+
if (!spanEnded) {
524+
if (!terminalStatusAttempted) {
525+
if (span) {
526+
safeSetStatus(span, { code: SpanStatusCode.OK });
527+
}
528+
}
529+
try {
530+
span?.end();
531+
} catch {
532+
// OTel errors must not mask the original API error
533+
}
495534
}
496535
}
497536
}

packages/core/src/telemetry/log-to-span-processor.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ import { LogToSpanProcessor } from './log-to-span-processor.js';
1616
import type { ReadableLogRecord } from '@opentelemetry/sdk-logs';
1717
import type { SpanExporter } from '@opentelemetry/sdk-trace-base';
1818

19+
let mockCurrentSessionId: string | undefined = undefined;
20+
21+
vi.mock('./session-context.js', () => ({
22+
getCurrentSessionId: () => mockCurrentSessionId,
23+
}));
24+
1925
interface ExportedSpan {
2026
name: string;
2127
kind: number;
@@ -34,6 +40,7 @@ describe('LogToSpanProcessor', () => {
3440

3541
beforeEach(() => {
3642
exportedSpans = [];
43+
mockCurrentSessionId = undefined;
3744
mockExporter = {
3845
export: vi.fn((spans, cb) => {
3946
exportedSpans.push(...spans);
@@ -708,4 +715,40 @@ describe('LogToSpanProcessor', () => {
708715

709716
expect(mockExporter.shutdown).toHaveBeenCalledTimes(1);
710717
});
718+
719+
it('falls back to getCurrentSessionId when log record has no session.id', async () => {
720+
mockCurrentSessionId = 'session-from-context';
721+
const logRecord = {
722+
body: 'event without session attr',
723+
hrTime: [1000, 0] as [number, number],
724+
attributes: {},
725+
} as unknown as ReadableLogRecord;
726+
727+
processor.onEmit(logRecord);
728+
await processor.forceFlush();
729+
730+
// The traceId should be derived from the fallback session ID,
731+
// not a random one.
732+
const { deriveTraceId } = await import('./trace-id-utils.js');
733+
expect(exportedSpans[0].spanContext().traceId).toBe(
734+
deriveTraceId('session-from-context'),
735+
);
736+
});
737+
738+
it('prefers log record session.id over getCurrentSessionId', async () => {
739+
mockCurrentSessionId = 'stale-session';
740+
const logRecord = {
741+
body: 'event with session attr',
742+
hrTime: [1000, 0] as [number, number],
743+
attributes: { 'session.id': 'fresh-session' },
744+
} as unknown as ReadableLogRecord;
745+
746+
processor.onEmit(logRecord);
747+
await processor.forceFlush();
748+
749+
const { deriveTraceId } = await import('./trace-id-utils.js');
750+
expect(exportedSpans[0].spanContext().traceId).toBe(
751+
deriveTraceId('fresh-session'),
752+
);
753+
});
711754
});

packages/core/src/telemetry/log-to-span-processor.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
randomHexString,
2929
randomSpanId,
3030
} from './trace-id-utils.js';
31+
import { getCurrentSessionId } from './session-context.js';
3132

3233
const EXPORT_TIMEOUT_MS = 30_000;
3334
const DEFAULT_MAX_BUFFER_SIZE = 10_000;
@@ -156,9 +157,13 @@ export class LogToSpanProcessor implements LogRecordProcessor {
156157

157158
// Prefer a real active span context when OTel logs provide one, preserving
158159
// direct parentage. Otherwise derive traceId from session.id so all events
159-
// in one session appear under a single trace.
160+
// in one session appear under a single trace. Fall back to
161+
// getCurrentSessionId() when the log record has no session.id attribute
162+
// (e.g. after a session change via /clear or /resume).
160163
const parentSpanContext = getValidParentSpanContext(logRecord.spanContext);
161-
const sessionId = logRecord.attributes?.['session.id'];
164+
// || (not ??) so empty-string session.id also falls through to the fallback
165+
const sessionId =
166+
logRecord.attributes?.['session.id'] || getCurrentSessionId();
162167
let traceId: string;
163168
if (parentSpanContext) {
164169
traceId = parentSpanContext.traceId;

packages/core/src/telemetry/sdk.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -544,9 +544,10 @@ describe('refreshSessionContext', () => {
544544
refreshSessionContext('new-session-id');
545545

546546
expect(createSessionRootContext).toHaveBeenCalledWith('new-session-id');
547-
expect(setSessionContext).toHaveBeenCalledWith({
548-
__sessionId: 'new-session-id',
549-
});
547+
expect(setSessionContext).toHaveBeenCalledWith(
548+
{ __sessionId: 'new-session-id' },
549+
'new-session-id',
550+
);
550551
});
551552

552553
it('should be a no-op when telemetry is not initialized', () => {

packages/core/src/telemetry/sdk.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,8 @@ export function initializeTelemetry(config: Config): void {
290290
sdk.start();
291291
debugLogger.debug('OpenTelemetry SDK started successfully.');
292292
telemetryInitialized = true;
293-
setSessionContext(createSessionRootContext(config.getSessionId()));
293+
const sessionId = config.getSessionId();
294+
setSessionContext(createSessionRootContext(sessionId), sessionId);
294295
initializeMetrics(config);
295296
} catch (error) {
296297
debugLogger.error('Error starting OpenTelemetry SDK:', error);
@@ -305,7 +306,7 @@ export function initializeTelemetry(config: Config): void {
305306
export function refreshSessionContext(sessionId: string): void {
306307
if (!telemetryInitialized) return;
307308
try {
308-
setSessionContext(createSessionRootContext(sessionId));
309+
setSessionContext(createSessionRootContext(sessionId), sessionId);
309310
} catch (error) {
310311
createDebugLogger('OTEL').warn('Failed to refresh session context:', error);
311312
}

packages/core/src/telemetry/session-context.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66

77
import { afterEach, describe, expect, it } from 'vitest';
88
import { ROOT_CONTEXT, createContextKey } from '@opentelemetry/api';
9-
import { getSessionContext, setSessionContext } from './session-context.js';
9+
import {
10+
getSessionContext,
11+
setSessionContext,
12+
getCurrentSessionId,
13+
} from './session-context.js';
1014

1115
describe('session-context', () => {
1216
afterEach(() => {
@@ -42,3 +46,36 @@ describe('session-context', () => {
4246
expect(getSessionContext()).toBeUndefined();
4347
});
4448
});
49+
50+
describe('getCurrentSessionId', () => {
51+
afterEach(() => {
52+
setSessionContext(undefined);
53+
});
54+
55+
it('returns undefined when no session has been set', () => {
56+
expect(getCurrentSessionId()).toBeUndefined();
57+
});
58+
59+
it('returns the session ID passed to setSessionContext', () => {
60+
const key = createContextKey('sid-test-key');
61+
setSessionContext(ROOT_CONTEXT.setValue(key, 'ctx'), 'session-abc');
62+
63+
expect(getCurrentSessionId()).toBe('session-abc');
64+
});
65+
66+
it('updates when setSessionContext is called with a new session ID', () => {
67+
const key = createContextKey('sid-update-key');
68+
setSessionContext(ROOT_CONTEXT.setValue(key, 'first'), 'session-1');
69+
setSessionContext(ROOT_CONTEXT.setValue(key, 'second'), 'session-2');
70+
71+
expect(getCurrentSessionId()).toBe('session-2');
72+
});
73+
74+
it('clears when setSessionContext is called without a session ID', () => {
75+
const key = createContextKey('sid-clear-key');
76+
setSessionContext(ROOT_CONTEXT.setValue(key, 'ctx'), 'session-xyz');
77+
setSessionContext(undefined);
78+
79+
expect(getCurrentSessionId()).toBeUndefined();
80+
});
81+
});

0 commit comments

Comments
 (0)