Session-Level Conversation History Management (#1113)

This commit is contained in:
tanzhenxin
2025-12-03 18:04:48 +08:00
committed by GitHub
parent a7abd8d09f
commit 0a75d85ac9
114 changed files with 9257 additions and 4039 deletions

View File

@@ -142,6 +142,7 @@ describe('ClearcutLogger', () => {
const loggerConfig = makeFakeConfig({
...config,
sessionId: 'test-session-id',
});
ClearcutLogger.clearInstance();
@@ -248,7 +249,7 @@ describe('ClearcutLogger', () => {
it('logs default metadata', () => {
// Define expected values
const session_id = 'my-session-id';
const session_id = 'test-session-id';
const auth_type = AuthType.USE_GEMINI;
const google_accounts = 123;
const surface = 'ide-1234';
@@ -260,7 +261,7 @@ describe('ClearcutLogger', () => {
// Setup logger with expected values
const { logger, loggerConfig } = setup({
lifetimeGoogleAccounts: google_accounts,
config: { sessionId: session_id },
config: {},
});
vi.spyOn(loggerConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: auth_type,

View File

@@ -25,7 +25,7 @@ export {
parseTelemetryTargetValue,
} from './config.js';
export {
logCliConfiguration,
logStartSession,
logUserPrompt,
logToolCall,
logApiRequest,

View File

@@ -41,7 +41,7 @@ import {
import {
logApiRequest,
logApiResponse,
logCliConfiguration,
logStartSession,
logUserPrompt,
logToolCall,
logFlashFallback,
@@ -116,7 +116,7 @@ describe('loggers', () => {
});
it('logs the chat compression event to QwenLogger', () => {
const mockConfig = makeFakeConfig();
const mockConfig = makeFakeConfig({ sessionId: 'test-session-id' });
const event = makeChatCompressionEvent({
tokens_before: 9001,
@@ -131,7 +131,7 @@ describe('loggers', () => {
});
it('records the chat compression event to OTEL', () => {
const mockConfig = makeFakeConfig();
const mockConfig = makeFakeConfig({ sessionId: 'test-session-id' });
logChatCompression(
mockConfig,
@@ -177,10 +177,12 @@ describe('loggers', () => {
getTargetDir: () => 'target-dir',
getProxy: () => 'http://test.proxy.com:8080',
getOutputFormat: () => OutputFormat.JSON,
getToolRegistry: () => undefined,
getChatRecordingService: () => undefined,
} as unknown as Config;
const startSessionEvent = new StartSessionEvent(mockConfig);
logCliConfiguration(mockConfig, startSessionEvent);
logStartSession(mockConfig, startSessionEvent);
expect(mockLogger.emit).toHaveBeenCalledWith({
body: 'CLI configuration loaded.',
@@ -281,7 +283,8 @@ describe('loggers', () => {
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => true,
} as Config;
getChatRecordingService: () => undefined,
} as unknown as Config;
const mockMetrics = {
recordApiResponseMetrics: vi.fn(),
@@ -368,7 +371,7 @@ describe('loggers', () => {
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => true,
} as Config;
} as unknown as Config;
it('should log an API request with request_text', () => {
const event = new ApiRequestEvent(
@@ -498,6 +501,7 @@ describe('loggers', () => {
const cfg2 = {
getSessionId: () => 'test-session-id',
getTargetDir: () => 'target-dir',
getProjectRoot: () => '/test/project/root',
getProxy: () => 'http://test.proxy.com:8080',
getContentGeneratorConfig: () =>
({ model: 'test-model' }) as ContentGeneratorConfig,
@@ -530,7 +534,8 @@ describe('loggers', () => {
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => true,
} as Config;
getChatRecordingService: () => undefined,
} as unknown as Config;
const mockMetrics = {
recordToolCallMetrics: vi.fn(),
@@ -1029,7 +1034,7 @@ describe('loggers', () => {
});
it('logs the event to Clearcut and OTEL', () => {
const mockConfig = makeFakeConfig();
const mockConfig = makeFakeConfig({ sessionId: 'test-session-id' });
const event = new MalformedJsonResponseEvent('test-model');
logMalformedJsonResponse(mockConfig, event);

View File

@@ -101,7 +101,7 @@ function getCommonAttributes(config: Config): LogAttributes {
};
}
export function logCliConfiguration(
export function logStartSession(
config: Config,
event: StartSessionEvent,
): void {
@@ -172,6 +172,7 @@ export function logToolCall(config: Config, event: ToolCallEvent): void {
'event.timestamp': new Date().toISOString(),
} as UiEvent;
uiTelemetryService.addEvent(uiEvent);
config.getChatRecordingService()?.recordUiTelemetryEvent(uiEvent);
QwenLogger.getInstance(config)?.logToolCallEvent(event);
if (!isTelemetrySdkInitialized()) return;
@@ -339,6 +340,7 @@ export function logApiError(config: Config, event: ApiErrorEvent): void {
'event.timestamp': new Date().toISOString(),
} as UiEvent;
uiTelemetryService.addEvent(uiEvent);
config.getChatRecordingService()?.recordUiTelemetryEvent(uiEvent);
QwenLogger.getInstance(config)?.logApiErrorEvent(event);
if (!isTelemetrySdkInitialized()) return;
@@ -405,6 +407,7 @@ export function logApiResponse(config: Config, event: ApiResponseEvent): void {
'event.timestamp': new Date().toISOString(),
} as UiEvent;
uiTelemetryService.addEvent(uiEvent);
config.getChatRecordingService()?.recordUiTelemetryEvent(uiEvent);
QwenLogger.getInstance(config)?.logApiResponseEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {

View File

@@ -134,7 +134,9 @@ describe('Telemetry Metrics', () => {
});
it('records token compression with the correct attributes', () => {
const config = makeFakeConfig({});
const config = makeFakeConfig({
sessionId: 'test-session-id',
});
initializeMetricsModule(config);
recordChatCompressionMetricsModule(config, {

View File

@@ -59,6 +59,7 @@ const makeFakeConfig = (overrides: Partial<Config> = {}): Config => {
getTelemetryLogPromptsEnabled: () => false,
getFileFilteringRespectGitIgnore: () => true,
getOutputFormat: () => 'text',
getToolRegistry: () => undefined,
...overrides,
};
return defaults as Config;

View File

@@ -39,8 +39,8 @@ import type {
ExtensionDisableEvent,
AuthEvent,
RipgrepFallbackEvent,
EndSessionEvent,
} from '../types.js';
import { EndSessionEvent } from '../types.js';
import type {
RumEvent,
RumViewEvent,
@@ -102,6 +102,7 @@ export class QwenLogger {
private lastFlushTime: number = Date.now();
private userId: string;
private sessionId: string;
/**
@@ -115,17 +116,12 @@ export class QwenLogger {
*/
private pendingFlush: boolean = false;
private isShutdown: boolean = false;
private constructor(config?: Config) {
private constructor(config: Config) {
this.config = config;
this.events = new FixedDeque<RumEvent>(Array, MAX_EVENTS);
this.installationManager = new InstallationManager();
this.userId = this.generateUserId();
this.sessionId =
typeof this.config?.getSessionId === 'function'
? this.config.getSessionId()
: '';
this.sessionId = config.getSessionId();
}
private generateUserId(): string {
@@ -139,10 +135,6 @@ export class QwenLogger {
return undefined;
if (!QwenLogger.instance) {
QwenLogger.instance = new QwenLogger(config);
process.on(
'exit',
QwenLogger.instance.shutdown.bind(QwenLogger.instance),
);
}
return QwenLogger.instance;
@@ -241,10 +233,10 @@ export class QwenLogger {
id: this.userId,
},
session: {
id: this.sessionId,
id: this.sessionId || this.config?.getSessionId(),
},
view: {
id: this.sessionId,
id: this.sessionId || this.config?.getSessionId(),
name: 'qwen-code-cli',
},
os: osMetadata,
@@ -364,7 +356,24 @@ export class QwenLogger {
}
// session events
logStartSessionEvent(event: StartSessionEvent): void {
async logStartSessionEvent(event: StartSessionEvent): Promise<void> {
// Flush all pending events with the old session ID first.
// If flush fails, discard the pending events to avoid mixing sessions.
await this.flushToRum().catch((error: unknown) => {
if (this.config?.getDebugMode()) {
console.debug(
'Error flushing pending events before session start:',
error,
);
}
});
// Clear any remaining events (discard if flush failed)
this.events.clear();
// Now set the new session ID
this.sessionId = event.session_id;
const applicationEvent = this.createViewEvent('session', 'session_start', {
properties: {
model: event.model,
@@ -852,14 +861,6 @@ export class QwenLogger {
}
}
shutdown() {
if (this.isShutdown) return;
this.isShutdown = true;
const event = new EndSessionEvent(this.config);
this.logEndSessionEvent(event);
}
private requeueFailedEvents(eventsToSend: RumEvent[]): void {
// Add the events back to the front of the queue to be retried, but limit retry queue size
const eventsToRetry = eventsToSend.slice(-MAX_RETRY_EVENTS); // Keep only the most recent events

View File

@@ -24,7 +24,6 @@ describe('telemetry', () => {
vi.resetAllMocks();
mockConfig = new Config({
sessionId: 'test-session-id',
model: 'test-model',
targetDir: '/test/dir',
debugMode: false,

View File

@@ -17,7 +17,6 @@ import {
} from './tool-call-decision.js';
import type { FileOperation } from './metrics.js';
export { ToolCallDecision };
import type { ToolRegistry } from '../tools/tool-registry.js';
import type { OutputFormat } from '../output/types.js';
export interface BaseTelemetryEvent {
@@ -31,6 +30,7 @@ type CommonFields = keyof BaseTelemetryEvent;
export class StartSessionEvent implements BaseTelemetryEvent {
'event.name': 'cli_config';
'event.timestamp': string;
session_id: string;
model: string;
embedding_model: string;
sandbox_enabled: boolean;
@@ -48,9 +48,10 @@ export class StartSessionEvent implements BaseTelemetryEvent {
mcp_tools?: string;
output_format: OutputFormat;
constructor(config: Config, toolRegistry?: ToolRegistry) {
constructor(config: Config) {
const generatorConfig = config.getContentGeneratorConfig();
const mcpServers = config.getMcpServers();
const toolRegistry = config.getToolRegistry();
let useGemini = false;
let useVertex = false;
@@ -60,6 +61,7 @@ export class StartSessionEvent implements BaseTelemetryEvent {
}
this['event.name'] = 'cli_config';
this.session_id = config.getSessionId();
this.model = config.getModel();
this.embedding_model = config.getEmbeddingModel();
this.sandbox_enabled =

View File

@@ -152,6 +152,18 @@ export class UiTelemetryService extends EventEmitter {
});
}
/**
* Resets metrics to the initial state (used when resuming a session).
*/
reset(): void {
this.#metrics = createInitialMetrics();
this.#lastPromptTokenCount = 0;
this.emit('update', {
metrics: this.#metrics,
lastPromptTokenCount: this.#lastPromptTokenCount,
});
}
private getOrCreateModelMetrics(modelName: string): ModelMetrics {
if (!this.#metrics.models[modelName]) {
this.#metrics.models[modelName] = createInitialModelMetrics();