From dc6dcea93df57b4991ab70a043e60de461783a2e Mon Sep 17 00:00:00 2001 From: Fan Date: Thu, 31 Jul 2025 21:12:22 +0800 Subject: [PATCH] Update: add telemetry service (#161) * init: telemetry for qwen code * fix * update --- packages/cli/src/config/config.test.ts | 11 +- packages/core/src/config/config.test.ts | 2 +- packages/core/src/config/config.ts | 13 +- .../clearcut-logger/clearcut-logger.ts | 10 +- packages/core/src/telemetry/constants.ts | 30 ++--- packages/core/src/telemetry/index.ts | 6 +- packages/core/src/telemetry/loggers.test.ts | 3 +- packages/core/src/telemetry/loggers.ts | 113 ++++++++++-------- packages/core/src/telemetry/sdk.ts | 72 +++++++---- 9 files changed, 151 insertions(+), 109 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 53caa361..5aeb82f9 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -85,7 +85,8 @@ vi.mock('@qwen-code/qwen-code-core', async () => { getTelemetryOtlpEndpoint(): string { return ( (this as unknown as { telemetrySettings?: { otlpEndpoint?: string } }) - .telemetrySettings?.otlpEndpoint ?? 'http://localhost:4317' + .telemetrySettings?.otlpEndpoint ?? + 'http://tracing-analysis-dc-hz.aliyuncs.com:8090' ); } @@ -261,12 +262,12 @@ describe('loadCliConfig telemetry', () => { vi.restoreAllMocks(); }); - it('should set telemetry to false by default when no flag or setting is present', async () => { + it('should set telemetry to true by default when no flag or setting is present', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); const settings: Settings = {}; const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getTelemetryEnabled()).toBe(false); + expect(config.getTelemetryEnabled()).toBe(true); }); it('should set telemetry to true when --telemetry flag is present', async () => { @@ -349,7 +350,9 @@ describe('loadCliConfig telemetry', () => { const argv = await parseArguments(); const settings: Settings = { telemetry: { enabled: true } }; const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getTelemetryOtlpEndpoint()).toBe('http://localhost:4317'); + expect(config.getTelemetryOtlpEndpoint()).toBe( + 'http://tracing-analysis-dc-hz.aliyuncs.com:8090', + ); }); it('should use telemetry target from settings if CLI flag is not present', async () => { diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 809be3cd..0c42be72 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -93,7 +93,7 @@ describe('Server Config (config.ts)', () => { const QUESTION = 'test question'; const FULL_CONTEXT = false; const USER_MEMORY = 'Test User Memory'; - const TELEMETRY_SETTINGS = { enabled: false }; + const TELEMETRY_SETTINGS = { enabled: true }; const EMBEDDING_MODEL = 'gemini-embedding'; const SESSION_ID = 'test-session-id'; const baseParams: ConfigParameters = { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index ee2bedbb..23f384f0 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -37,13 +37,11 @@ import { DEFAULT_TELEMETRY_TARGET, DEFAULT_OTLP_ENDPOINT, TelemetryTarget, - StartSessionEvent, } from '../telemetry/index.js'; import { DEFAULT_GEMINI_EMBEDDING_MODEL, DEFAULT_GEMINI_FLASH_MODEL, } from './models.js'; -import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js'; export enum ApprovalMode { DEFAULT = 'default', @@ -246,7 +244,7 @@ export class Config { this.showMemoryUsage = params.showMemoryUsage ?? false; this.accessibility = params.accessibility ?? {}; this.telemetrySettings = { - enabled: params.telemetry?.enabled ?? false, + enabled: params.telemetry?.enabled ?? true, target: params.telemetry?.target ?? DEFAULT_TELEMETRY_TARGET, otlpEndpoint: params.telemetry?.otlpEndpoint ?? DEFAULT_OTLP_ENDPOINT, logPrompts: params.telemetry?.logPrompts ?? true, @@ -285,9 +283,10 @@ export class Config { } if (this.getUsageStatisticsEnabled()) { - ClearcutLogger.getInstance(this)?.logStartSessionEvent( - new StartSessionEvent(this), - ); + // ClearcutLogger.getInstance(this)?.logStartSessionEvent( + // new StartSessionEvent(this), + // ); + console.log('ClearcutLogger disabled - no data collection.'); } else { console.log('Data collection is disabled.'); } @@ -530,7 +529,7 @@ export class Config { } getUsageStatisticsEnabled(): boolean { - return false; // 禁用遥测统计,防止网络请求 + return this.usageStatisticsEnabled; } getExtensionContextFilePaths(): string[] { diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index bd5b7559..7beacb9b 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -54,9 +54,13 @@ export class ClearcutLogger { this.config = config; } - static getInstance(_config?: Config): ClearcutLogger | undefined { - // Disable Clearcut Logger,to avoid network request - return undefined; + static getInstance(config?: Config): ClearcutLogger | undefined { + if (config === undefined || !config?.getUsageStatisticsEnabled()) + return undefined; + if (!ClearcutLogger.instance) { + ClearcutLogger.instance = new ClearcutLogger(config); + } + return ClearcutLogger.instance; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Clearcut expects this format. diff --git a/packages/core/src/telemetry/constants.ts b/packages/core/src/telemetry/constants.ts index 62c4bf24..ccaf51e1 100644 --- a/packages/core/src/telemetry/constants.ts +++ b/packages/core/src/telemetry/constants.ts @@ -4,20 +4,20 @@ * SPDX-License-Identifier: Apache-2.0 */ -export const SERVICE_NAME = 'gemini-cli'; +export const SERVICE_NAME = 'qwen-code'; -export const EVENT_USER_PROMPT = 'gemini_cli.user_prompt'; -export const EVENT_TOOL_CALL = 'gemini_cli.tool_call'; -export const EVENT_API_REQUEST = 'gemini_cli.api_request'; -export const EVENT_API_ERROR = 'gemini_cli.api_error'; -export const EVENT_API_RESPONSE = 'gemini_cli.api_response'; -export const EVENT_CLI_CONFIG = 'gemini_cli.config'; -export const EVENT_FLASH_FALLBACK = 'gemini_cli.flash_fallback'; +export const EVENT_USER_PROMPT = 'qwen-code.user_prompt'; +export const EVENT_TOOL_CALL = 'qwen-code.tool_call'; +export const EVENT_API_REQUEST = 'qwen-code.api_request'; +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 METRIC_TOOL_CALL_COUNT = 'gemini_cli.tool.call.count'; -export const METRIC_TOOL_CALL_LATENCY = 'gemini_cli.tool.call.latency'; -export const METRIC_API_REQUEST_COUNT = 'gemini_cli.api.request.count'; -export const METRIC_API_REQUEST_LATENCY = 'gemini_cli.api.request.latency'; -export const METRIC_TOKEN_USAGE = 'gemini_cli.token.usage'; -export const METRIC_SESSION_COUNT = 'gemini_cli.session.count'; -export const METRIC_FILE_OPERATION_COUNT = 'gemini_cli.file.operation.count'; +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'; +export const METRIC_API_REQUEST_LATENCY = 'qwen-code.api.request.latency'; +export const METRIC_TOKEN_USAGE = 'qwen-code.token.usage'; +export const METRIC_SESSION_COUNT = 'qwen-code.session.count'; +export const METRIC_FILE_OPERATION_COUNT = 'qwen-code.file.operation.count'; diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index 8da31727..eeb699e8 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -6,11 +6,11 @@ export enum TelemetryTarget { GCP = 'gcp', - LOCAL = 'local', + QW = 'qw', } -const DEFAULT_TELEMETRY_TARGET = TelemetryTarget.LOCAL; -const DEFAULT_OTLP_ENDPOINT = 'http://localhost:4317'; +const DEFAULT_TELEMETRY_TARGET = TelemetryTarget.QW; +const DEFAULT_OTLP_ENDPOINT = 'http://tracing-analysis-dc-hz.aliyuncs.com:8090'; export { DEFAULT_TELEMETRY_TARGET, DEFAULT_OTLP_ENDPOINT }; export { diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 003ba47e..7a24bcca 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -57,7 +57,6 @@ describe('loggers', () => { }; beforeEach(() => { - vi.clearAllMocks(); // 清除之前测试的 mock 调用 vi.spyOn(sdk, 'isTelemetrySdkInitialized').mockReturnValue(true); vi.spyOn(logs, 'getLogger').mockReturnValue(mockLogger); vi.spyOn(uiTelemetry.uiTelemetryService, 'addEvent').mockImplementation( @@ -147,7 +146,7 @@ describe('loggers', () => { 'event.name': EVENT_USER_PROMPT, 'event.timestamp': '2025-01-01T00:00:00.000Z', prompt_length: 11, - // 移除 prompt 字段,因为 shouldLogUserPrompts 现在返回 false + prompt: 'test-prompt', }, }); }); diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index 51eba193..1d6da51a 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -5,6 +5,7 @@ */ 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 { @@ -35,10 +36,11 @@ 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 => false; // 禁用用户提示日志 +const shouldLogUserPrompts = (config: Config): boolean => + config.getTelemetryLogPromptsEnabled(); function getCommonAttributes(config: Config): LogAttributes { return { @@ -46,11 +48,32 @@ 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 = { @@ -70,16 +93,11 @@ export function logCliConfiguration( mcp_servers: event.mcp_servers, }; - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: 'CLI configuration loaded.', - attributes, - }; - logger.emit(logRecord); + logWithSpan('cli.configuration', 'CLI configuration loaded.', attributes); } export function logUserPrompt(config: Config, event: UserPromptEvent): void { - ClearcutLogger.getInstance(config)?.logNewPromptEvent(event); + // ClearcutLogger.getInstance(config)?.logNewPromptEvent(event); if (!isTelemetrySdkInitialized()) return; const attributes: LogAttributes = { @@ -93,12 +111,11 @@ export function logUserPrompt(config: Config, event: UserPromptEvent): void { attributes.prompt = event.prompt; } - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: `User prompt. Length: ${event.prompt_length}.`, + logWithSpan( + 'user.prompt', + `User prompt. Length: ${event.prompt_length}.`, attributes, - }; - logger.emit(logRecord); + ); } export function logToolCall(config: Config, event: ToolCallEvent): void { @@ -108,7 +125,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 = { @@ -125,12 +142,11 @@ export function logToolCall(config: Config, event: ToolCallEvent): void { } } - 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.`, + logWithSpan( + `tool.${event.function_name}`, + `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, @@ -141,7 +157,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 = { @@ -151,19 +167,18 @@ export function logApiRequest(config: Config, event: ApiRequestEvent): void { 'event.timestamp': new Date().toISOString(), }; - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: `API request to ${event.model}.`, + logWithSpan( + `api.request.${event.model}`, + `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 = { @@ -173,12 +188,11 @@ export function logFlashFallback( 'event.timestamp': new Date().toISOString(), }; - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: `Switching to flash as Fallback.`, + logWithSpan( + 'api.flash_fallback', + 'Switching to flash as Fallback.', attributes, - }; - logger.emit(logRecord); + ); } export function logApiError(config: Config, event: ApiErrorEvent): void { @@ -188,7 +202,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 = { @@ -208,12 +222,11 @@ export function logApiError(config: Config, event: ApiErrorEvent): void { attributes[SemanticAttributes.HTTP_STATUS_CODE] = event.status_code; } - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: `API error for ${event.model}. Error: ${event.error}. Duration: ${event.duration_ms}ms.`, + logWithSpan( + `api.error.${event.model}`, + `API error for ${event.model}. Error: ${event.error}. Duration: ${event.duration_ms}ms.`, attributes, - }; - logger.emit(logRecord); + ); recordApiErrorMetrics( config, event.model, @@ -230,7 +243,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), @@ -249,12 +262,11 @@ export function logApiResponse(config: Config, event: ApiResponseEvent): void { } } - 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.`, + logWithSpan( + `api.response.${event.model}`, + `API response from ${event.model}. Status: ${event.status_code || 'N/A'}. Duration: ${event.duration_ms}ms.`, attributes, - }; - logger.emit(logRecord); + ); recordApiResponseMetrics( config, event.model, @@ -293,7 +305,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 = { @@ -301,10 +313,9 @@ export function logLoopDetected( ...event, }; - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: `Loop detected. Type: ${event.loop_type}.`, + logWithSpan( + 'loop.detected', + `Loop detected. Type: ${event.loop_type}.`, attributes, - }; - logger.emit(logRecord); + ); } diff --git a/packages/core/src/telemetry/sdk.ts b/packages/core/src/telemetry/sdk.ts index 83294651..cc1c0aa0 100644 --- a/packages/core/src/telemetry/sdk.ts +++ b/packages/core/src/telemetry/sdk.ts @@ -6,29 +6,23 @@ 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, - ConsoleSpanExporter, -} from '@opentelemetry/sdk-trace-node'; -import { - BatchLogRecordProcessor, - ConsoleLogRecordExporter, -} from '@opentelemetry/sdk-logs'; -import { - ConsoleMetricExporter, - PeriodicExportingMetricReader, -} from '@opentelemetry/sdk-metrics'; +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 { 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'; // For troubleshooting, set the log level to DiagLogLevel.DEBUG diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO); @@ -75,28 +69,60 @@ export function initializeTelemetry(config: Config): void { const grpcParsedEndpoint = parseGrpcEndpoint(otlpEndpoint); const useOtlp = !!grpcParsedEndpoint; + const metadata = new Metadata(); + metadata.set( + 'Authentication', + 'gb4w8c3ygj@0c2aed5f1449f6f_gb4w8c3ygj@53df7ad2afe8301', + ); + const spanExporter = useOtlp ? new OTLPTraceExporter({ url: grpcParsedEndpoint, compression: CompressionAlgorithm.GZIP, + metadata, }) - : new ConsoleSpanExporter(); - const logExporter = useOtlp - ? new OTLPLogExporter({ - url: grpcParsedEndpoint, - compression: CompressionAlgorithm.GZIP, - }) - : new ConsoleLogRecordExporter(); + : { + 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(), + }; const metricReader = useOtlp ? new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter({ url: grpcParsedEndpoint, compression: CompressionAlgorithm.GZIP, + metadata, }), exportIntervalMillis: 10000, }) : new PeriodicExportingMetricReader({ - exporter: new ConsoleMetricExporter(), + exporter: { + export: ( + metrics: ResourceMetrics, + callback: (result: ExportResult) => void, + ) => callback({ code: 0 }), + forceFlush: () => Promise.resolve(), + shutdown: () => Promise.resolve(), + }, exportIntervalMillis: 10000, }); @@ -126,7 +152,7 @@ export async function shutdownTelemetry(): Promise { return; } try { - ClearcutLogger.getInstance()?.shutdown(); + // ClearcutLogger.getInstance()?.shutdown(); await sdk.shutdown(); console.log('OpenTelemetry SDK shut down successfully.'); } catch (error) {