Merge tag 'v0.1.15' into feature/yiheng/sync-gemini-cli-0.1.15

This commit is contained in:
奕桁
2025-08-01 23:06:11 +08:00
340 changed files with 36528 additions and 22931 deletions

View File

@@ -6,6 +6,8 @@
import { Buffer } from 'buffer';
import * as https from 'https';
import { HttpsProxyAgent } from 'https-proxy-agent';
import {
StartSessionEvent,
EndSessionEvent,
@@ -16,6 +18,7 @@ import {
ApiErrorEvent,
FlashFallbackEvent,
LoopDetectedEvent,
FlashDecidedToContinueEvent,
} from '../types.js';
import { EventMetadataKey } from './event-metadata-key.js';
import { Config } from '../../config/config.js';
@@ -35,6 +38,7 @@ const api_error_event_name = 'api_error';
const end_session_event_name = 'end_session';
const flash_fallback_event_name = 'flash_fallback';
const loop_detected_event_name = 'loop_detected';
const flash_decided_to_continue_event_name = 'flash_decided_to_continue';
export interface LogResponse {
nextRequestWaitMs?: number;
@@ -132,12 +136,18 @@ export class ClearcutLogger {
headers: { 'Content-Length': Buffer.byteLength(body) },
};
const bufs: Buffer[] = [];
const req = https.request(options, (res) => {
res.on('data', (buf) => bufs.push(buf));
res.on('end', () => {
resolve(Buffer.concat(bufs));
});
});
const req = https.request(
{
...options,
agent: this.getProxyAgent(),
},
(res) => {
res.on('data', (buf) => bufs.push(buf));
res.on('end', () => {
resolve(Buffer.concat(bufs));
});
},
);
req.on('error', (e) => {
if (this.config?.getDebugMode()) {
console.log('Clearcut POST request error: ', e);
@@ -205,11 +215,16 @@ export class ClearcutLogger {
}
logStartSessionEvent(event: StartSessionEvent): void {
const surface = process.env.SURFACE || 'SURFACE_NOT_SET';
const data = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_START_SESSION_MODEL,
value: event.model,
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_SESSION_ID,
value: this.config?.getSessionId() ?? '',
},
{
gemini_cli_key:
EventMetadataKey.GEMINI_CLI_START_SESSION_EMBEDDING_MODEL,
@@ -266,7 +281,12 @@ export class ClearcutLogger {
EventMetadataKey.GEMINI_CLI_START_SESSION_TELEMETRY_LOG_USER_PROMPTS_ENABLED,
value: event.telemetry_log_user_prompts_enabled.toString(),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_SURFACE,
value: surface,
},
];
// Flush start event immediately
this.enqueueLogEvent(this.createLogEvent(start_session_event_name, data));
this.flushToClearcut().catch((error) => {
@@ -280,6 +300,10 @@ export class ClearcutLogger {
gemini_cli_key: EventMetadataKey.GEMINI_CLI_USER_PROMPT_LENGTH,
value: JSON.stringify(event.prompt_length),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_SESSION_ID,
value: this.config?.getSessionId() ?? '',
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_PROMPT_ID,
value: JSON.stringify(event.prompt_id),
@@ -442,6 +466,10 @@ export class ClearcutLogger {
gemini_cli_key: EventMetadataKey.GEMINI_CLI_AUTH_TYPE,
value: JSON.stringify(event.auth_type),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_SESSION_ID,
value: this.config?.getSessionId() ?? '',
},
];
this.enqueueLogEvent(this.createLogEvent(flash_fallback_event_name, data));
@@ -452,6 +480,10 @@ export class ClearcutLogger {
logLoopDetectedEvent(event: LoopDetectedEvent): void {
const data = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_PROMPT_ID,
value: JSON.stringify(event.prompt_id),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_LOOP_DETECTED_TYPE,
value: JSON.stringify(event.loop_type),
@@ -462,10 +494,28 @@ export class ClearcutLogger {
this.flushIfNeeded();
}
logFlashDecidedToContinueEvent(event: FlashDecidedToContinueEvent): void {
const data = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_PROMPT_ID,
value: JSON.stringify(event.prompt_id),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_SESSION_ID,
value: this.config?.getSessionId() ?? '',
},
];
this.enqueueLogEvent(
this.createLogEvent(flash_decided_to_continue_event_name, data),
);
this.flushIfNeeded();
}
logEndSessionEvent(event: EndSessionEvent): void {
const data = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_END_SESSION_ID,
gemini_cli_key: EventMetadataKey.GEMINI_CLI_SESSION_ID,
value: event?.session_id?.toString() ?? '',
},
];
@@ -477,6 +527,18 @@ export class ClearcutLogger {
});
}
getProxyAgent() {
const proxyUrl = this.config?.getProxy();
if (!proxyUrl) return undefined;
// undici which is widely used in the repo can only support http & https proxy protocol,
// https://github.com/nodejs/undici/issues/2224
if (proxyUrl.startsWith('http')) {
return new HttpsProxyAgent(proxyUrl);
} else {
throw new Error('Unsupported proxy type');
}
}
shutdown() {
const event = new EndSessionEvent(this.config);
this.logEndSessionEvent(event);

View File

@@ -151,6 +151,12 @@ export enum EventMetadataKey {
// Logs the total number of Google accounts ever used.
GEMINI_CLI_GOOGLE_ACCOUNTS_COUNT = 37,
// Logs the Surface from where the Gemini CLI was invoked, eg: VSCode.
GEMINI_CLI_SURFACE = 39,
// Logs the session id
GEMINI_CLI_SESSION_ID = 40,
// ==========================================================================
// Loop Detected Event Keys
// ===========================================================================

View File

@@ -13,7 +13,8 @@ export const EVENT_API_ERROR = 'qwen-code.api_error';
export const EVENT_API_RESPONSE = 'qwen-code.api_response';
export const EVENT_CLI_CONFIG = 'qwen-code.config';
export const EVENT_FLASH_FALLBACK = 'qwen-code.flash_fallback';
export const EVENT_FLASH_DECIDED_TO_CONTINUE =
'qwen-code.flash_decided_to_continue';
export const METRIC_TOOL_CALL_COUNT = 'qwen-code.tool.call.count';
export const METRIC_TOOL_CALL_LATENCY = 'qwen-code.tool.call.latency';
export const METRIC_API_REQUEST_COUNT = 'qwen-code.api.request.count';

View File

@@ -0,0 +1,89 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import { ExportResult, ExportResultCode } from '@opentelemetry/core';
import { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base';
import { ReadableLogRecord, LogRecordExporter } from '@opentelemetry/sdk-logs';
import {
ResourceMetrics,
PushMetricExporter,
AggregationTemporality,
} from '@opentelemetry/sdk-metrics';
class FileExporter {
protected writeStream: fs.WriteStream;
constructor(filePath: string) {
this.writeStream = fs.createWriteStream(filePath, { flags: 'a' });
}
protected serialize(data: unknown): string {
return JSON.stringify(data, null, 2) + '\n';
}
shutdown(): Promise<void> {
return new Promise((resolve) => {
this.writeStream.end(resolve);
});
}
}
export class FileSpanExporter extends FileExporter implements SpanExporter {
export(
spans: ReadableSpan[],
resultCallback: (result: ExportResult) => void,
): void {
const data = spans.map((span) => this.serialize(span)).join('');
this.writeStream.write(data, (err) => {
resultCallback({
code: err ? ExportResultCode.FAILED : ExportResultCode.SUCCESS,
error: err || undefined,
});
});
}
}
export class FileLogExporter extends FileExporter implements LogRecordExporter {
export(
logs: ReadableLogRecord[],
resultCallback: (result: ExportResult) => void,
): void {
const data = logs.map((log) => this.serialize(log)).join('');
this.writeStream.write(data, (err) => {
resultCallback({
code: err ? ExportResultCode.FAILED : ExportResultCode.SUCCESS,
error: err || undefined,
});
});
}
}
export class FileMetricExporter
extends FileExporter
implements PushMetricExporter
{
export(
metrics: ResourceMetrics,
resultCallback: (result: ExportResult) => void,
): void {
const data = this.serialize(metrics);
this.writeStream.write(data, (err) => {
resultCallback({
code: err ? ExportResultCode.FAILED : ExportResultCode.SUCCESS,
error: err || undefined,
});
});
}
getPreferredAggregationTemporality(): AggregationTemporality {
return AggregationTemporality.CUMULATIVE;
}
async forceFlush(): Promise<void> {
return Promise.resolve();
}
}

View File

@@ -6,11 +6,12 @@
export enum TelemetryTarget {
GCP = 'gcp',
QW = 'qw',
LOCAL = 'local',
QWEN = 'qwen',
}
const DEFAULT_TELEMETRY_TARGET = TelemetryTarget.QW;
const DEFAULT_OTLP_ENDPOINT = 'http://tracing-analysis-dc-hz.aliyuncs.com:8090';
const DEFAULT_TELEMETRY_TARGET = TelemetryTarget.LOCAL;
const DEFAULT_OTLP_ENDPOINT = 'http://localhost:4317';
export { DEFAULT_TELEMETRY_TARGET, DEFAULT_OTLP_ENDPOINT };
export {

View File

@@ -5,7 +5,6 @@
*/
import { logs, LogRecord, LogAttributes } from '@opentelemetry/api-logs';
import { trace, context } from '@opentelemetry/api';
import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
import { Config } from '../config/config.js';
import {
@@ -16,6 +15,7 @@ import {
EVENT_TOOL_CALL,
EVENT_USER_PROMPT,
EVENT_FLASH_FALLBACK,
EVENT_FLASH_DECIDED_TO_CONTINUE,
SERVICE_NAME,
} from './constants.js';
import {
@@ -26,6 +26,7 @@ import {
ToolCallEvent,
UserPromptEvent,
FlashFallbackEvent,
FlashDecidedToContinueEvent,
LoopDetectedEvent,
} from './types.js';
import {
@@ -36,7 +37,7 @@ import {
} from './metrics.js';
import { isTelemetrySdkInitialized } from './sdk.js';
import { uiTelemetryService, UiEvent } from './uiTelemetry.js';
// import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
const shouldLogUserPrompts = (config: Config): boolean =>
@@ -48,32 +49,11 @@ function getCommonAttributes(config: Config): LogAttributes {
};
}
// Helper function to create spans and emit logs within span context
function logWithSpan(
spanName: string,
logBody: string,
attributes: LogAttributes,
): void {
const tracer = trace.getTracer(SERVICE_NAME);
const span = tracer.startSpan(spanName);
context.with(trace.setSpan(context.active(), span), () => {
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: logBody,
attributes,
};
logger.emit(logRecord);
});
span.end();
}
export function logCliConfiguration(
config: Config,
event: StartSessionEvent,
): void {
// ClearcutLogger.getInstance(config)?.logStartSessionEvent(event);
ClearcutLogger.getInstance(config)?.logStartSessionEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
@@ -93,11 +73,16 @@ export function logCliConfiguration(
mcp_servers: event.mcp_servers,
};
logWithSpan('cli.configuration', 'CLI configuration loaded.', attributes);
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: 'CLI configuration loaded.',
attributes,
};
logger.emit(logRecord);
}
export function logUserPrompt(config: Config, event: UserPromptEvent): void {
// ClearcutLogger.getInstance(config)?.logNewPromptEvent(event);
ClearcutLogger.getInstance(config)?.logNewPromptEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
@@ -111,11 +96,12 @@ export function logUserPrompt(config: Config, event: UserPromptEvent): void {
attributes.prompt = event.prompt;
}
logWithSpan(
'user.prompt',
`User prompt. Length: ${event.prompt_length}.`,
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `User prompt. Length: ${event.prompt_length}.`,
attributes,
);
};
logger.emit(logRecord);
}
export function logToolCall(config: Config, event: ToolCallEvent): void {
@@ -125,7 +111,7 @@ export function logToolCall(config: Config, event: ToolCallEvent): void {
'event.timestamp': new Date().toISOString(),
} as UiEvent;
uiTelemetryService.addEvent(uiEvent);
// ClearcutLogger.getInstance(config)?.logToolCallEvent(event);
ClearcutLogger.getInstance(config)?.logToolCallEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
@@ -142,11 +128,12 @@ export function logToolCall(config: Config, event: ToolCallEvent): void {
}
}
logWithSpan(
`tool.${event.function_name}`,
`Tool call: ${event.function_name}${event.decision ? `. Decision: ${event.decision}` : ''}. Success: ${event.success}. Duration: ${event.duration_ms}ms.`,
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `Tool call: ${event.function_name}${event.decision ? `. Decision: ${event.decision}` : ''}. Success: ${event.success}. Duration: ${event.duration_ms}ms.`,
attributes,
);
};
logger.emit(logRecord);
recordToolCallMetrics(
config,
event.function_name,
@@ -157,7 +144,7 @@ export function logToolCall(config: Config, event: ToolCallEvent): void {
}
export function logApiRequest(config: Config, event: ApiRequestEvent): void {
// ClearcutLogger.getInstance(config)?.logApiRequestEvent(event);
ClearcutLogger.getInstance(config)?.logApiRequestEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
@@ -167,18 +154,19 @@ export function logApiRequest(config: Config, event: ApiRequestEvent): void {
'event.timestamp': new Date().toISOString(),
};
logWithSpan(
`api.request.${event.model}`,
`API request to ${event.model}.`,
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `API request to ${event.model}.`,
attributes,
);
};
logger.emit(logRecord);
}
export function logFlashFallback(
config: Config,
event: FlashFallbackEvent,
): void {
// ClearcutLogger.getInstance(config)?.logFlashFallbackEvent(event);
ClearcutLogger.getInstance(config)?.logFlashFallbackEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
@@ -188,11 +176,12 @@ export function logFlashFallback(
'event.timestamp': new Date().toISOString(),
};
logWithSpan(
'api.flash_fallback',
'Switching to flash as Fallback.',
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `Switching to flash as Fallback.`,
attributes,
);
};
logger.emit(logRecord);
}
export function logApiError(config: Config, event: ApiErrorEvent): void {
@@ -202,7 +191,7 @@ export function logApiError(config: Config, event: ApiErrorEvent): void {
'event.timestamp': new Date().toISOString(),
} as UiEvent;
uiTelemetryService.addEvent(uiEvent);
// ClearcutLogger.getInstance(config)?.logApiErrorEvent(event);
ClearcutLogger.getInstance(config)?.logApiErrorEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
@@ -222,11 +211,12 @@ export function logApiError(config: Config, event: ApiErrorEvent): void {
attributes[SemanticAttributes.HTTP_STATUS_CODE] = event.status_code;
}
logWithSpan(
`api.error.${event.model}`,
`API error for ${event.model}. Error: ${event.error}. Duration: ${event.duration_ms}ms.`,
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `API error for ${event.model}. Error: ${event.error}. Duration: ${event.duration_ms}ms.`,
attributes,
);
};
logger.emit(logRecord);
recordApiErrorMetrics(
config,
event.model,
@@ -243,7 +233,7 @@ export function logApiResponse(config: Config, event: ApiResponseEvent): void {
'event.timestamp': new Date().toISOString(),
} as UiEvent;
uiTelemetryService.addEvent(uiEvent);
// ClearcutLogger.getInstance(config)?.logApiResponseEvent(event);
ClearcutLogger.getInstance(config)?.logApiResponseEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
...getCommonAttributes(config),
@@ -262,11 +252,12 @@ export function logApiResponse(config: Config, event: ApiResponseEvent): void {
}
}
logWithSpan(
`api.response.${event.model}`,
`API response from ${event.model}. Status: ${event.status_code || 'N/A'}. Duration: ${event.duration_ms}ms.`,
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `API response from ${event.model}. Status: ${event.status_code || 'N/A'}. Duration: ${event.duration_ms}ms.`,
attributes,
);
};
logger.emit(logRecord);
recordApiResponseMetrics(
config,
event.model,
@@ -305,7 +296,7 @@ export function logLoopDetected(
config: Config,
event: LoopDetectedEvent,
): void {
// ClearcutLogger.getInstance(config)?.logLoopDetectedEvent(event);
ClearcutLogger.getInstance(config)?.logLoopDetectedEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
@@ -313,9 +304,31 @@ export function logLoopDetected(
...event,
};
logWithSpan(
'loop.detected',
`Loop detected. Type: ${event.loop_type}.`,
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `Loop detected. Type: ${event.loop_type}.`,
attributes,
);
};
logger.emit(logRecord);
}
export function logFlashDecidedToContinue(
config: Config,
event: FlashDecidedToContinueEvent,
): void {
ClearcutLogger.getInstance(config)?.logFlashDecidedToContinueEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
...getCommonAttributes(config),
...event,
'event.name': EVENT_FLASH_DECIDED_TO_CONTINUE,
};
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `Flash decided to continue.`,
attributes,
};
logger.emit(logRecord);
}

View File

@@ -6,23 +6,34 @@
import { DiagConsoleLogger, DiagLogLevel, diag } from '@opentelemetry/api';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-grpc';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc';
import { CompressionAlgorithm } from '@opentelemetry/otlp-exporter-base';
import { Metadata } from '@grpc/grpc-js';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
import { Resource } from '@opentelemetry/resources';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-node';
import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
import type { LogRecord } from '@opentelemetry/sdk-logs';
import type { ResourceMetrics } from '@opentelemetry/sdk-metrics';
import type { ExportResult } from '@opentelemetry/core';
import {
BatchSpanProcessor,
ConsoleSpanExporter,
} from '@opentelemetry/sdk-trace-node';
import {
BatchLogRecordProcessor,
ConsoleLogRecordExporter,
} from '@opentelemetry/sdk-logs';
import {
ConsoleMetricExporter,
PeriodicExportingMetricReader,
} from '@opentelemetry/sdk-metrics';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { Config } from '../config/config.js';
import { SERVICE_NAME } from './constants.js';
import { initializeMetrics } from './metrics.js';
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
import {
FileLogExporter,
FileMetricExporter,
FileSpanExporter,
} from './file-exporters.js';
// For troubleshooting, set the log level to DiagLogLevel.DEBUG
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO);
@@ -68,63 +79,41 @@ export function initializeTelemetry(config: Config): void {
const otlpEndpoint = config.getTelemetryOtlpEndpoint();
const grpcParsedEndpoint = parseGrpcEndpoint(otlpEndpoint);
const useOtlp = !!grpcParsedEndpoint;
const metadata = new Metadata();
metadata.set(
'Authentication',
'gb7x9m2kzp@8f4e3b6c9d2a1e5_qw7x9m2kzp@19a8c5f2b4e7d93',
);
const telemetryOutfile = config.getTelemetryOutfile();
const spanExporter = useOtlp
? new OTLPTraceExporter({
url: grpcParsedEndpoint,
compression: CompressionAlgorithm.GZIP,
metadata,
})
: {
export: (
spans: ReadableSpan[],
callback: (result: ExportResult) => void,
) => callback({ code: 0 }),
forceFlush: () => Promise.resolve(),
shutdown: () => Promise.resolve(),
};
// FIXME: Temporarily disable OTLP log export due to gRPC endpoint not supporting LogsService
// const logExporter = useOtlp
// ? new OTLPLogExporter({
// url: grpcParsedEndpoint,
// compression: CompressionAlgorithm.GZIP,
// metadata: _metadata,
// })
// : new ConsoleLogRecordExporter();
// Create a no-op log exporter to avoid cluttering console output
const logExporter = {
export: (logs: LogRecord[], callback: (result: ExportResult) => void) =>
callback({ code: 0 }),
shutdown: () => Promise.resolve(),
};
: telemetryOutfile
? new FileSpanExporter(telemetryOutfile)
: new ConsoleSpanExporter();
const logExporter = useOtlp
? new OTLPLogExporter({
url: grpcParsedEndpoint,
compression: CompressionAlgorithm.GZIP,
})
: telemetryOutfile
? new FileLogExporter(telemetryOutfile)
: new ConsoleLogRecordExporter();
const metricReader = useOtlp
? new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: grpcParsedEndpoint,
compression: CompressionAlgorithm.GZIP,
metadata,
}),
exportIntervalMillis: 10000,
})
: new PeriodicExportingMetricReader({
exporter: {
export: (
metrics: ResourceMetrics,
callback: (result: ExportResult) => void,
) => callback({ code: 0 }),
forceFlush: () => Promise.resolve(),
shutdown: () => Promise.resolve(),
},
exportIntervalMillis: 10000,
});
: telemetryOutfile
? new PeriodicExportingMetricReader({
exporter: new FileMetricExporter(telemetryOutfile),
exportIntervalMillis: 10000,
})
: new PeriodicExportingMetricReader({
exporter: new ConsoleMetricExporter(),
exportIntervalMillis: 10000,
});
sdk = new NodeSDK({
resource,
@@ -152,7 +141,7 @@ export async function shutdownTelemetry(): Promise<void> {
return;
}
try {
// ClearcutLogger.getInstance()?.shutdown();
ClearcutLogger.getInstance()?.shutdown();
await sdk.shutdown();
console.log('OpenTelemetry SDK shut down successfully.');
} catch (error) {

View File

@@ -249,17 +249,32 @@ export class FlashFallbackEvent {
export enum LoopType {
CONSECUTIVE_IDENTICAL_TOOL_CALLS = 'consecutive_identical_tool_calls',
CHANTING_IDENTICAL_SENTENCES = 'chanting_identical_sentences',
LLM_DETECTED_LOOP = 'llm_detected_loop',
}
export class LoopDetectedEvent {
'event.name': 'loop_detected';
'event.timestamp': string; // ISO 8601
loop_type: LoopType;
prompt_id: string;
constructor(loop_type: LoopType) {
constructor(loop_type: LoopType, prompt_id: string) {
this['event.name'] = 'loop_detected';
this['event.timestamp'] = new Date().toISOString();
this.loop_type = loop_type;
this.prompt_id = prompt_id;
}
}
export class FlashDecidedToContinueEvent {
'event.name': 'flash_decided_to_continue';
'event.timestamp': string; // ISO 8601
prompt_id: string;
constructor(prompt_id: string) {
this['event.name'] = 'flash_decided_to_continue';
this['event.timestamp'] = new Date().toISOString();
this.prompt_id = prompt_id;
}
}
@@ -272,4 +287,5 @@ export type TelemetryEvent =
| ApiErrorEvent
| ApiResponseEvent
| FlashFallbackEvent
| LoopDetectedEvent;
| LoopDetectedEvent
| FlashDecidedToContinueEvent;

View File

@@ -508,4 +508,116 @@ describe('UiTelemetryService', () => {
expect(tools.byName['tool_B'].count).toBe(1);
});
});
describe('resetLastPromptTokenCount', () => {
it('should reset the last prompt token count to 0', () => {
// First, set up some initial token count
const event = {
'event.name': EVENT_API_RESPONSE,
model: 'gemini-2.5-pro',
duration_ms: 500,
input_token_count: 100,
output_token_count: 200,
total_token_count: 300,
cached_content_token_count: 50,
thoughts_token_count: 20,
tool_token_count: 30,
} as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE };
service.addEvent(event);
expect(service.getLastPromptTokenCount()).toBe(100);
// Now reset the token count
service.resetLastPromptTokenCount();
expect(service.getLastPromptTokenCount()).toBe(0);
});
it('should emit an update event when resetLastPromptTokenCount is called', () => {
const spy = vi.fn();
service.on('update', spy);
// Set up initial token count
const event = {
'event.name': EVENT_API_RESPONSE,
model: 'gemini-2.5-pro',
duration_ms: 500,
input_token_count: 100,
output_token_count: 200,
total_token_count: 300,
cached_content_token_count: 50,
thoughts_token_count: 20,
tool_token_count: 30,
} as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE };
service.addEvent(event);
spy.mockClear(); // Clear the spy to focus on the reset call
service.resetLastPromptTokenCount();
expect(spy).toHaveBeenCalledOnce();
const { metrics, lastPromptTokenCount } = spy.mock.calls[0][0];
expect(metrics).toBeDefined();
expect(lastPromptTokenCount).toBe(0);
});
it('should not affect other metrics when resetLastPromptTokenCount is called', () => {
// Set up initial state with some metrics
const event = {
'event.name': EVENT_API_RESPONSE,
model: 'gemini-2.5-pro',
duration_ms: 500,
input_token_count: 100,
output_token_count: 200,
total_token_count: 300,
cached_content_token_count: 50,
thoughts_token_count: 20,
tool_token_count: 30,
} as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE };
service.addEvent(event);
const metricsBefore = service.getMetrics();
service.resetLastPromptTokenCount();
const metricsAfter = service.getMetrics();
// Metrics should be unchanged
expect(metricsAfter).toEqual(metricsBefore);
// Only the last prompt token count should be reset
expect(service.getLastPromptTokenCount()).toBe(0);
});
it('should work correctly when called multiple times', () => {
const spy = vi.fn();
service.on('update', spy);
// Set up initial token count
const event = {
'event.name': EVENT_API_RESPONSE,
model: 'gemini-2.5-pro',
duration_ms: 500,
input_token_count: 100,
output_token_count: 200,
total_token_count: 300,
cached_content_token_count: 50,
thoughts_token_count: 20,
tool_token_count: 30,
} as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE };
service.addEvent(event);
expect(service.getLastPromptTokenCount()).toBe(100);
// Reset once
service.resetLastPromptTokenCount();
expect(service.getLastPromptTokenCount()).toBe(0);
// Reset again - should still be 0 and still emit event
spy.mockClear();
service.resetLastPromptTokenCount();
expect(service.getLastPromptTokenCount()).toBe(0);
expect(spy).toHaveBeenCalledOnce();
});
});
});

View File

@@ -133,6 +133,14 @@ export class UiTelemetryService extends EventEmitter {
return this.#lastPromptTokenCount;
}
resetLastPromptTokenCount(): void {
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();