From 2194da2b0213fa39062302ea3a2ee75ef768648e Mon Sep 17 00:00:00 2001 From: lp-peg <35035802+lp-peg@users.noreply.github.com> Date: Thu, 30 Apr 2026 06:43:34 +0900 Subject: [PATCH] Respect logPrompts flag for logging sensitive fields (#26153) Co-authored-by: David Pierce Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com> --- .../core/src/telemetry/conseca-logger.test.ts | 171 ++++++++++++++++ packages/core/src/telemetry/conseca-logger.ts | 72 ++++--- packages/core/src/telemetry/loggers.test.ts | 183 ++++++++++++++++-- packages/core/src/telemetry/types.ts | 68 +++++-- 4 files changed, 437 insertions(+), 57 deletions(-) diff --git a/packages/core/src/telemetry/conseca-logger.test.ts b/packages/core/src/telemetry/conseca-logger.test.ts index 0df06f6d80..0627bbb38f 100644 --- a/packages/core/src/telemetry/conseca-logger.test.ts +++ b/packages/core/src/telemetry/conseca-logger.test.ts @@ -19,6 +19,7 @@ import { import type { Config } from '../config/config.js'; import * as sdk from './sdk.js'; import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js'; +import { EventMetadataKey } from './clearcut-logger/event-metadata-key.js'; vi.mock('@opentelemetry/api-logs'); vi.mock('./sdk.js'); @@ -144,4 +145,174 @@ describe('conseca-logger', () => { expect(mockLogger.emit).not.toHaveBeenCalled(); }); + + it('should omit user_prompt/trusted_content/policy from OTEL when logPrompts is disabled', () => { + const configNoPrompts = { + getTelemetryEnabled: vi.fn().mockReturnValue(true), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false), + getTelemetryTracesEnabled: vi.fn().mockReturnValue(false), + isInteractive: vi.fn().mockReturnValue(true), + getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }), + getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'oauth' }), + } as unknown as Config; + + const event = new ConsecaPolicyGenerationEvent( + 'sensitive prompt', + 'sensitive content', + 'sensitive policy', + ); + + logConsecaPolicyGeneration(configNoPrompts, event); + + const attrs = mockLogger.emit.mock.calls[0][0].attributes as Record< + string, + unknown + >; + expect(attrs['user_prompt']).toBeUndefined(); + expect(attrs['trusted_content']).toBeUndefined(); + expect(attrs['policy']).toBeUndefined(); + expect(attrs['event.name']).toBe(EVENT_CONSECA_POLICY_GENERATION); + }); + + it('should omit user_prompt/trusted_content/policy from Clearcut when logPrompts is disabled', () => { + const configNoPrompts = { + getTelemetryEnabled: vi.fn().mockReturnValue(true), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false), + getTelemetryTracesEnabled: vi.fn().mockReturnValue(false), + isInteractive: vi.fn().mockReturnValue(true), + getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }), + getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'oauth' }), + } as unknown as Config; + + const event = new ConsecaPolicyGenerationEvent( + 'sensitive prompt', + 'sensitive content', + 'sensitive policy', + 'some error', + ); + + logConsecaPolicyGeneration(configNoPrompts, event); + + expect(mockClearcutLogger.createLogEvent).toHaveBeenCalledWith( + expect.anything(), + [ + { + gemini_cli_key: EventMetadataKey.CONSECA_ERROR, + value: 'some error', + }, + ], + ); + }); + + it('should include user_prompt/trusted_content/policy in OTEL when logPrompts is enabled', () => { + const event = new ConsecaPolicyGenerationEvent( + 'visible prompt', + 'visible content', + 'visible policy', + ); + + logConsecaPolicyGeneration(mockConfig, event); + + const attrs = mockLogger.emit.mock.calls[0][0].attributes as Record< + string, + unknown + >; + expect(attrs['user_prompt']).toBe('visible prompt'); + expect(attrs['trusted_content']).toBe('visible content'); + expect(attrs['policy']).toBe('visible policy'); + }); + + it('should omit sensitive fields from verdict OTEL when logPrompts is disabled', () => { + const configNoPrompts = { + getTelemetryEnabled: vi.fn().mockReturnValue(true), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false), + getTelemetryTracesEnabled: vi.fn().mockReturnValue(false), + isInteractive: vi.fn().mockReturnValue(true), + getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }), + getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'oauth' }), + } as unknown as Config; + + const event = new ConsecaVerdictEvent( + 'sensitive prompt', + 'sensitive policy', + 'sensitive tool call', + 'allow', + 'sensitive rationale', + ); + + logConsecaVerdict(configNoPrompts, event); + + const attrs = mockLogger.emit.mock.calls[0][0].attributes as Record< + string, + unknown + >; + expect(attrs['user_prompt']).toBeUndefined(); + expect(attrs['policy']).toBeUndefined(); + expect(attrs['tool_call']).toBeUndefined(); + expect(attrs['verdict_rationale']).toBeUndefined(); + // verdict (the allow/deny result) is not sensitive and should be present + expect(attrs['verdict']).toBe('allow'); + }); + + it('should omit sensitive fields from verdict Clearcut when logPrompts is disabled', () => { + const configNoPrompts = { + getTelemetryEnabled: vi.fn().mockReturnValue(true), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false), + getTelemetryTracesEnabled: vi.fn().mockReturnValue(false), + isInteractive: vi.fn().mockReturnValue(true), + getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }), + getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'oauth' }), + } as unknown as Config; + + const event = new ConsecaVerdictEvent( + 'sensitive prompt', + 'sensitive policy', + 'sensitive tool call', + 'allow', + 'sensitive rationale', + 'some error', + ); + + logConsecaVerdict(configNoPrompts, event); + + expect(mockClearcutLogger.createLogEvent).toHaveBeenCalledWith( + expect.anything(), + [ + { + gemini_cli_key: EventMetadataKey.CONSECA_VERDICT_RESULT, + value: '"allow"', + }, + { + gemini_cli_key: EventMetadataKey.CONSECA_ERROR, + value: 'some error', + }, + ], + ); + }); + + it('should include sensitive fields in verdict OTEL when logPrompts is enabled', () => { + const event = new ConsecaVerdictEvent( + 'visible prompt', + 'visible policy', + 'visible tool call', + 'deny', + 'visible rationale', + ); + + logConsecaVerdict(mockConfig, event); + + const attrs = mockLogger.emit.mock.calls[0][0].attributes as Record< + string, + unknown + >; + expect(attrs['user_prompt']).toBe('visible prompt'); + expect(attrs['policy']).toBe('visible policy'); + expect(attrs['tool_call']).toBe('visible tool call'); + expect(attrs['verdict_rationale']).toBe('visible rationale'); + expect(attrs['verdict']).toBe('deny'); + }); }); diff --git a/packages/core/src/telemetry/conseca-logger.ts b/packages/core/src/telemetry/conseca-logger.ts index ad88d092ee..132f567540 100644 --- a/packages/core/src/telemetry/conseca-logger.ts +++ b/packages/core/src/telemetry/conseca-logger.ts @@ -11,6 +11,7 @@ import { isTelemetrySdkInitialized } from './sdk.js'; import { ClearcutLogger, EventNames, + type EventValue, } from './clearcut-logger/clearcut-logger.js'; import { EventMetadataKey } from './clearcut-logger/event-metadata-key.js'; import { safeJsonStringify } from '../utils/safeJsonStringify.js'; @@ -27,20 +28,24 @@ export function logConsecaPolicyGeneration( debugLogger.debug('Conseca Policy Generation Event:', event); const clearcutLogger = ClearcutLogger.getInstance(config); if (clearcutLogger) { - const data = [ - { - gemini_cli_key: EventMetadataKey.CONSECA_USER_PROMPT, - value: safeJsonStringify(event.user_prompt), - }, - { - gemini_cli_key: EventMetadataKey.CONSECA_TRUSTED_CONTENT, - value: safeJsonStringify(event.trusted_content), - }, - { - gemini_cli_key: EventMetadataKey.CONSECA_GENERATED_POLICY, - value: safeJsonStringify(event.policy), - }, - ]; + const data: EventValue[] = []; + + if (config.getTelemetryLogPromptsEnabled()) { + data.push( + { + gemini_cli_key: EventMetadataKey.CONSECA_USER_PROMPT, + value: safeJsonStringify(event.user_prompt), + }, + { + gemini_cli_key: EventMetadataKey.CONSECA_TRUSTED_CONTENT, + value: safeJsonStringify(event.trusted_content), + }, + { + gemini_cli_key: EventMetadataKey.CONSECA_GENERATED_POLICY, + value: safeJsonStringify(event.policy), + }, + ); + } if (event.error) { data.push({ @@ -71,29 +76,34 @@ export function logConsecaVerdict( debugLogger.debug('Conseca Verdict Event:', event); const clearcutLogger = ClearcutLogger.getInstance(config); if (clearcutLogger) { - const data = [ - { - gemini_cli_key: EventMetadataKey.CONSECA_USER_PROMPT, - value: safeJsonStringify(event.user_prompt), - }, - { - gemini_cli_key: EventMetadataKey.CONSECA_GENERATED_POLICY, - value: safeJsonStringify(event.policy), - }, - { - gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_CALL_NAME, - value: safeJsonStringify(event.tool_call), - }, + const data: EventValue[] = [ { gemini_cli_key: EventMetadataKey.CONSECA_VERDICT_RESULT, value: safeJsonStringify(event.verdict), }, - { - gemini_cli_key: EventMetadataKey.CONSECA_VERDICT_RATIONALE, - value: event.verdict_rationale, - }, ]; + if (config.getTelemetryLogPromptsEnabled()) { + data.push( + { + gemini_cli_key: EventMetadataKey.CONSECA_USER_PROMPT, + value: safeJsonStringify(event.user_prompt), + }, + { + gemini_cli_key: EventMetadataKey.CONSECA_GENERATED_POLICY, + value: safeJsonStringify(event.policy), + }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_CALL_NAME, + value: safeJsonStringify(event.tool_call), + }, + { + gemini_cli_key: EventMetadataKey.CONSECA_VERDICT_RATIONALE, + value: event.verdict_rationale, + }, + ); + } + if (event.error) { data.push({ gemini_cli_key: EventMetadataKey.CONSECA_ERROR, diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index f999d72962..0dfc1459c3 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -642,6 +642,54 @@ describe('loggers', () => { }), }); }); + it('should not include response_text when logPrompts is disabled', () => { + const mockConfigNoPrompts = { + getSessionId: () => 'test-session-id', + getTargetDir: () => 'target-dir', + getUsageStatisticsEnabled: () => true, + getTelemetryEnabled: () => true, + getTelemetryLogPromptsEnabled: () => false, + getTelemetryTracesEnabled: () => false, + isInteractive: () => false, + getExperiments: () => undefined, + getExperimentsAsync: async () => undefined, + getContentGeneratorConfig: () => undefined, + } as unknown as Config; + + const event = new ApiResponseEvent( + 'test-model', + 100, + { prompt_id: 'prompt-id-noprompts', contents: [] }, + { candidates: [] }, + AuthType.LOGIN_WITH_GOOGLE, + {}, + 'this response should be hidden', + ); + + logApiResponse(mockConfigNoPrompts, event); + + const firstEmitCall = mockLogger.emit.mock.calls[0][0]; + expect(firstEmitCall.attributes['response_text']).toBeUndefined(); + }); + + it('should include response_text when logPrompts is enabled', () => { + const event = new ApiResponseEvent( + 'test-model', + 100, + { prompt_id: 'prompt-id-withprompts', contents: [] }, + { candidates: [] }, + AuthType.LOGIN_WITH_GOOGLE, + {}, + 'this response should be visible', + ); + + logApiResponse(mockConfig, event); + + const firstEmitCall = mockLogger.emit.mock.calls[0][0]; + expect(firstEmitCall.attributes['response_text']).toBe( + 'this response should be visible', + ); + }); }); describe('logApiError', () => { @@ -1076,6 +1124,10 @@ describe('loggers', () => { expect(attributes['gen_ai.provider.name']).toBe('gcp.vertex_ai'); // Ensure prompt messages are NOT included expect(attributes['gen_ai.input.messages']).toBeUndefined(); + + // Ensure request_text is also NOT included in the first (toLogRecord) log + const firstLogCall = mockLogger.emit.mock.calls[0][0]; + expect(firstLogCall.attributes['request_text']).toBeUndefined(); }); it('should correctly derive model from prompt details if available in semantic log', () => { @@ -1373,16 +1425,20 @@ describe('loggers', () => { error_type: undefined, mcp_server_name: undefined, extension_id: undefined, - metadata: { - model_added_lines: 1, - model_removed_lines: 2, - model_added_chars: 3, - model_removed_chars: 4, - user_added_lines: 5, - user_removed_lines: 6, - user_added_chars: 7, - user_removed_chars: 8, - }, + metadata: JSON.stringify( + { + model_added_lines: 1, + model_removed_lines: 2, + model_added_chars: 3, + model_removed_chars: 4, + user_added_lines: 5, + user_removed_lines: 6, + user_added_chars: 7, + user_removed_chars: 8, + }, + null, + 2, + ), content_length: 13, }, }); @@ -1455,12 +1511,16 @@ describe('loggers', () => { body: 'Tool call: ask_user. Decision: accept. Success: true. Duration: 100ms.', attributes: expect.objectContaining({ function_name: 'ask_user', - metadata: expect.objectContaining({ - ask_user: { - question_types: ['choice'], - dismissed: false, + metadata: JSON.stringify( + { + ask_user: { + question_types: ['choice'], + dismissed: false, + }, }, - }), + null, + 2, + ), }), }); }); @@ -1867,6 +1927,99 @@ describe('loggers', () => { }); }); + describe('logToolCall — logPrompts flag', () => { + it('should omit function_args when logPrompts is disabled', () => { + const mockConfigNoPrompts = { + getSessionId: () => 'test-session-id', + getTargetDir: () => 'target-dir', + getUsageStatisticsEnabled: () => true, + getTelemetryEnabled: () => true, + getTelemetryLogPromptsEnabled: () => false, + getTelemetryTracesEnabled: () => false, + isInteractive: () => false, + getExperiments: () => undefined, + getExperimentsAsync: async () => undefined, + getContentGeneratorConfig: () => undefined, + } as unknown as Config; + + const call: CompletedToolCall = { + status: CoreToolCallStatus.Success, + request: { + name: 'run_bash', + args: { command: 'echo sensitive' }, + callId: 'call-1', + isClientInitiated: false, + prompt_id: 'prompt-noprompts', + }, + response: { + callId: 'call-1', + responseParts: [], + resultDisplay: undefined, + error: undefined, + errorType: undefined, + contentLength: undefined, + }, + tool: undefined as unknown as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + durationMs: 50, + }; + const event = new ToolCallEvent(call); + logToolCall(mockConfigNoPrompts, event); + + const emitted = mockLogger.emit.mock.calls[0][0] as { + attributes: Record; + }; + expect(emitted.attributes['function_args']).toBeUndefined(); + expect(emitted.attributes['function_name']).toBe('run_bash'); + }); + + it('should include function_args when logPrompts is enabled', () => { + const mockConfigWithPrompts = { + getSessionId: () => 'test-session-id', + getTargetDir: () => 'target-dir', + getUsageStatisticsEnabled: () => true, + getTelemetryEnabled: () => true, + getTelemetryLogPromptsEnabled: () => true, + getTelemetryTracesEnabled: () => false, + isInteractive: () => false, + getExperiments: () => undefined, + getExperimentsAsync: async () => undefined, + getContentGeneratorConfig: () => undefined, + } as unknown as Config; + + const call: CompletedToolCall = { + status: CoreToolCallStatus.Success, + request: { + name: 'run_bash', + args: { command: 'echo visible' }, + callId: 'call-2', + isClientInitiated: false, + prompt_id: 'prompt-withprompts', + }, + response: { + callId: 'call-2', + responseParts: [], + resultDisplay: undefined, + error: undefined, + errorType: undefined, + contentLength: undefined, + }, + tool: undefined as unknown as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + durationMs: 50, + }; + const event = new ToolCallEvent(call); + logToolCall(mockConfigWithPrompts, event); + + const emitted = mockLogger.emit.mock.calls[0][0] as { + attributes: Record; + }; + expect(emitted.attributes['function_args']).toBe( + JSON.stringify({ command: 'echo visible' }, null, 2), + ); + }); + }); + describe('logMalformedJsonResponse', () => { beforeEach(() => { vi.spyOn(ClearcutLogger.prototype, 'logMalformedJsonResponseEvent'); diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 3e91b587a4..e306c972dc 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -231,6 +231,17 @@ export class UserPromptEvent implements BaseTelemetryEvent { } export const EVENT_TOOL_CALL = 'gemini_cli.tool_call'; + +const TOOL_CALL_METADATA_SAFE_KEYS = [ + 'model_added_lines', + 'model_removed_lines', + 'model_added_chars', + 'model_removed_chars', + 'user_added_lines', + 'user_removed_lines', + 'user_added_chars', + 'user_removed_chars', +] as const; export class ToolCallEvent implements BaseTelemetryEvent { 'event.name': 'tool_call'; 'event.timestamp': string; @@ -355,7 +366,6 @@ export class ToolCallEvent implements BaseTelemetryEvent { 'event.name': EVENT_TOOL_CALL, 'event.timestamp': this['event.timestamp'], function_name: this.function_name, - function_args: safeJsonStringify(this.function_args, 2), duration_ms: this.duration_ms, success: this.success, decision: this.decision, @@ -367,8 +377,22 @@ export class ToolCallEvent implements BaseTelemetryEvent { extension_id: this.extension_id, start_time: this.start_time, end_time: this.end_time, - metadata: this.metadata, }; + if (config.getTelemetryLogPromptsEnabled() && this.function_args) { + attributes['function_args'] = safeJsonStringify(this.function_args, 2); + } + if (this.metadata) { + const metadata = config.getTelemetryLogPromptsEnabled() + ? this.metadata + : Object.fromEntries( + Object.entries(this.metadata).filter(([k]) => + (TOOL_CALL_METADATA_SAFE_KEYS as readonly string[]).includes(k), + ), + ); + if (Object.keys(metadata).length > 0) { + attributes['metadata'] = safeJsonStringify(metadata, 2); + } + } if (this.error) { attributes[CoreToolCallStatus.Error] = this.error; @@ -423,8 +447,10 @@ export class ApiRequestEvent implements BaseTelemetryEvent { 'event.timestamp': this['event.timestamp'], model: this.model, prompt_id: this.prompt.prompt_id, - request_text: this.request_text, }; + if (config.getTelemetryLogPromptsEnabled() && this.request_text) { + attributes['request_text'] = this.request_text; + } if (this.role) { attributes['role'] = this.role; } @@ -692,7 +718,7 @@ export class ApiResponseEvent implements BaseTelemetryEvent { if (this.role) { attributes['role'] = this.role; } - if (this.response_text) { + if (config.getTelemetryLogPromptsEnabled() && this.response_text) { attributes['response_text'] = this.response_text; } if (this.status_code) { @@ -954,11 +980,20 @@ export class ConsecaPolicyGenerationEvent implements BaseTelemetryEvent { ...getCommonAttributes(config), 'event.name': EVENT_CONSECA_POLICY_GENERATION, 'event.timestamp': this['event.timestamp'], - user_prompt: this.user_prompt, - trusted_content: this.trusted_content, - policy: this.policy, }; + if (config.getTelemetryLogPromptsEnabled()) { + if (this.user_prompt) { + attributes['user_prompt'] = this.user_prompt; + } + if (this.trusted_content) { + attributes['trusted_content'] = this.trusted_content; + } + if (this.policy) { + attributes['policy'] = this.policy; + } + } + if (this.error) { attributes['error'] = this.error; } @@ -1005,13 +1040,24 @@ export class ConsecaVerdictEvent implements BaseTelemetryEvent { ...getCommonAttributes(config), 'event.name': EVENT_CONSECA_VERDICT, 'event.timestamp': this['event.timestamp'], - user_prompt: this.user_prompt, - policy: this.policy, - tool_call: this.tool_call, verdict: this.verdict, - verdict_rationale: this.verdict_rationale, }; + if (config.getTelemetryLogPromptsEnabled()) { + if (this.user_prompt) { + attributes['user_prompt'] = this.user_prompt; + } + if (this.policy) { + attributes['policy'] = this.policy; + } + if (this.tool_call) { + attributes['tool_call'] = this.tool_call; + } + if (this.verdict_rationale) { + attributes['verdict_rationale'] = this.verdict_rationale; + } + } + if (this.error) { attributes['error'] = this.error; }