Respect logPrompts flag for logging sensitive fields (#26153)

Co-authored-by: David Pierce <davidapierce@google.com>
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
This commit is contained in:
lp-peg
2026-04-30 06:43:34 +09:00
committed by GitHub
parent dce13019b9
commit 2194da2b02
4 changed files with 437 additions and 57 deletions
@@ -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');
});
});
+41 -31
View File
@@ -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,
+168 -15
View File
@@ -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<string, unknown>;
};
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<string, unknown>;
};
expect(emitted.attributes['function_args']).toBe(
JSON.stringify({ command: 'echo visible' }, null, 2),
);
});
});
describe('logMalformedJsonResponse', () => {
beforeEach(() => {
vi.spyOn(ClearcutLogger.prototype, 'logMalformedJsonResponseEvent');
+57 -11
View File
@@ -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;
}