qwen-code/packages/core/src/utils/debugLogger.test.ts
jinye 4bb8dc894a
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>
2026-05-13 22:02:20 +08:00

406 lines
12 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
createDebugLogger,
isDebugLoggingDegraded,
resetDebugLoggingState,
setDebugLogSession,
type DebugLogSession,
} from './debugLogger.js';
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';
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
promises: {
...actual.promises,
mkdir: vi.fn().mockResolvedValue(undefined),
appendFile: vi.fn().mockResolvedValue(undefined),
unlink: vi.fn().mockResolvedValue(undefined),
symlink: vi.fn().mockResolvedValue(undefined),
copyFile: vi.fn().mockResolvedValue(undefined),
},
};
});
vi.mock('@opentelemetry/api', () => ({
trace: {
getActiveSpan: vi.fn().mockReturnValue(undefined),
getSpan: vi.fn().mockReturnValue(undefined),
},
}));
describe('debugLogger', () => {
const mockSession: DebugLogSession = {
getSessionId: () => 'test-session-123',
};
const previousDebugLogFileEnv = process.env['QWEN_DEBUG_LOG_FILE'];
beforeEach(() => {
process.env['QWEN_DEBUG_LOG_FILE'] = '1';
Storage.setRuntimeBaseDir(null);
vi.clearAllMocks();
vi.useFakeTimers();
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);
});
afterEach(() => {
vi.useRealTimers();
setDebugLogSession(null);
setSessionContext(undefined);
Storage.setRuntimeBaseDir(null);
if (previousDebugLogFileEnv === undefined) {
delete process.env['QWEN_DEBUG_LOG_FILE'];
} else {
process.env['QWEN_DEBUG_LOG_FILE'] = previousDebugLogFileEnv;
}
});
describe('createDebugLogger', () => {
it('returns no-op logger when session is unset', () => {
setDebugLogSession(null);
const logger = createDebugLogger();
logger.debug('test');
logger.info('test');
logger.warn('test');
logger.error('test');
expect(fs.appendFile).not.toHaveBeenCalled();
});
it('writes debug log without trace context when telemetry context is unset', async () => {
const logger = createDebugLogger();
logger.debug('Hello world');
await vi.runAllTimersAsync();
expect(fs.mkdir).toHaveBeenCalledWith(Storage.getGlobalDebugDir(), {
recursive: true,
});
expect(fs.appendFile).toHaveBeenCalledWith(
Storage.getDebugLogPath('test-session-123'),
'2026-01-24T10:30:00.000Z [DEBUG] Hello world\n',
'utf8',
);
});
it('writes log with tag when provided', async () => {
const logger = createDebugLogger('STARTUP');
logger.info('Server started');
await vi.runAllTimersAsync();
expect(fs.appendFile).toHaveBeenCalledWith(
Storage.getDebugLogPath('test-session-123'),
'2026-01-24T10:30:00.000Z [INFO] [STARTUP] Server started\n',
'utf8',
);
});
it('writes different log levels correctly', async () => {
const logger = createDebugLogger();
logger.debug('debug message');
logger.info('info message');
logger.warn('warn message');
logger.error('error message');
await vi.runAllTimersAsync();
const calls = vi.mocked(fs.appendFile).mock.calls;
expect(calls[0]?.[1]).toContain('[DEBUG]');
expect(calls[1]?.[1]).toContain('[INFO]');
expect(calls[2]?.[1]).toContain('[WARN]');
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);
const logger = createDebugLogger();
logger.debug('with real span');
await vi.runAllTimersAsync();
expect(fs.appendFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining(
'[trace_id=realtraceidddddddddddddddddddddd span_id=realspanid111111]',
),
'utf8',
);
});
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');
});
const logger = createDebugLogger();
logger.debug('otel failure');
await vi.runAllTimersAsync();
expect(fs.appendFile).toHaveBeenCalledWith(
expect.any(String),
expect.not.stringContaining('trace_id='),
'utf8',
);
});
it('does not synthesize span ids when telemetry context is unset', async () => {
const logger = createDebugLogger();
logger.debug('first line');
logger.debug('second line');
await vi.runAllTimersAsync();
const calls = vi.mocked(fs.appendFile).mock.calls;
expect(calls).toHaveLength(2);
expect(calls[0]?.[1]).not.toContain('span_id=');
expect(calls[1]?.[1]).not.toContain('span_id=');
});
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,
);
const logger = createDebugLogger();
logger.debug('session root fallback');
await vi.runAllTimersAsync();
expect(fs.appendFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining(
'[trace_id=cccccccccccccccccccccccccccccccc span_id=dddddddddddddddd]',
),
'utf8',
);
});
it('creates a new debug directory after the runtime base dir changes', async () => {
Storage.setRuntimeBaseDir(path.resolve('runtime-a'));
const logger = createDebugLogger();
logger.debug('first');
await vi.runAllTimersAsync();
Storage.setRuntimeBaseDir(path.resolve('runtime-b'));
logger.debug('second');
await vi.runAllTimersAsync();
const mkdirCalls = vi.mocked(fs.mkdir).mock.calls;
expect(mkdirCalls).toContainEqual([
path.join(path.resolve('runtime-a'), 'debug'),
{ recursive: true },
]);
expect(mkdirCalls).toContainEqual([
path.join(path.resolve('runtime-b'), 'debug'),
{ recursive: true },
]);
});
it('formats multiple arguments', async () => {
const logger = createDebugLogger();
logger.debug('Count:', 42, 'items');
await vi.runAllTimersAsync();
expect(fs.appendFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('Count: 42 items'),
'utf8',
);
});
it('formats Error objects with stack trace', async () => {
const logger = createDebugLogger();
const error = new Error('Something went wrong');
logger.error('Failed:', error);
await vi.runAllTimersAsync();
const call = vi.mocked(fs.appendFile).mock.calls[0];
expect(call?.[1]).toContain('Failed:');
expect(call?.[1]).toContain('Error: Something went wrong');
});
it('formats objects using util.inspect', async () => {
const logger = createDebugLogger();
logger.debug('Data:', { foo: 'bar', count: 123 });
await vi.runAllTimersAsync();
const call = vi.mocked(fs.appendFile).mock.calls[0];
expect(call?.[1]).toContain('foo');
expect(call?.[1]).toContain('bar');
});
});
describe('isDebugLoggingDegraded', () => {
it('returns false when no failures have occurred', () => {
expect(isDebugLoggingDegraded()).toBe(false);
});
it('returns true when mkdir fails', async () => {
resetDebugLoggingState();
vi.mocked(fs.mkdir).mockRejectedValueOnce(new Error('Permission denied'));
const logger = createDebugLogger();
logger.debug('test');
await vi.runAllTimersAsync();
expect(isDebugLoggingDegraded()).toBe(true);
});
it('returns true when appendFile fails', async () => {
vi.mocked(fs.appendFile).mockRejectedValueOnce(new Error('Disk full'));
const logger = createDebugLogger();
logger.debug('test');
await vi.runAllTimersAsync();
expect(isDebugLoggingDegraded()).toBe(true);
});
it('stays true after failure even if subsequent writes succeed', async () => {
vi.mocked(fs.appendFile).mockRejectedValueOnce(
new Error('Temporary error'),
);
const logger = createDebugLogger();
logger.debug('first write fails');
await vi.runAllTimersAsync();
expect(isDebugLoggingDegraded()).toBe(true);
vi.mocked(fs.appendFile).mockResolvedValue(undefined);
logger.debug('second write succeeds');
await vi.runAllTimersAsync();
expect(isDebugLoggingDegraded()).toBe(true);
});
});
describe('latest debug log symlink', () => {
const expectedLatestPath = path.join(Storage.getGlobalDebugDir(), 'latest');
it('creates a symlink to the current session log file', async () => {
resetDebugLoggingState();
setDebugLogSession(mockSession);
await vi.runAllTimersAsync();
expect(fs.unlink).toHaveBeenCalledWith(expectedLatestPath);
expect(fs.symlink).toHaveBeenCalledWith(
'test-session-123.txt',
expectedLatestPath,
);
});
it('does not create symlink when session is cleared', async () => {
vi.clearAllMocks();
resetDebugLoggingState();
setDebugLogSession(null);
await vi.runAllTimersAsync();
expect(fs.symlink).not.toHaveBeenCalled();
});
it('does not fall back to copy when symlink fails', async () => {
resetDebugLoggingState();
vi.mocked(fs.symlink).mockRejectedValueOnce(new Error('EPERM'));
setDebugLogSession(mockSession);
await vi.runAllTimersAsync();
expect(fs.copyFile).not.toHaveBeenCalled();
});
it('does not create symlink when debug logging is disabled', async () => {
process.env['QWEN_DEBUG_LOG_FILE'] = '0';
vi.clearAllMocks();
resetDebugLoggingState();
setDebugLogSession(mockSession);
await vi.runAllTimersAsync();
expect(fs.symlink).not.toHaveBeenCalled();
});
});
describe('resetDebugLoggingState', () => {
it('resets the degraded state', async () => {
vi.mocked(fs.appendFile).mockRejectedValueOnce(new Error('Disk full'));
const logger = createDebugLogger();
logger.debug('test');
await vi.runAllTimersAsync();
expect(isDebugLoggingDegraded()).toBe(true);
resetDebugLoggingState();
expect(isDebugLoggingDegraded()).toBe(false);
});
});
});