Skip to content

Commit 0910b35

Browse files
Hotfix/metrics stream error (#7156)
Co-authored-by: Victor May <[email protected]>
1 parent dc21a06 commit 0910b35

File tree

8 files changed

+400
-7
lines changed

8 files changed

+400
-7
lines changed

packages/core/src/core/geminiChat.test.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,19 @@ const mockModelsModule = {
2525
batchEmbedContents: vi.fn(),
2626
} as unknown as Models;
2727

28+
const { mockLogInvalidChunk, mockLogContentRetry, mockLogContentRetryFailure } =
29+
vi.hoisted(() => ({
30+
mockLogInvalidChunk: vi.fn(),
31+
mockLogContentRetry: vi.fn(),
32+
mockLogContentRetryFailure: vi.fn(),
33+
}));
34+
35+
vi.mock('../telemetry/loggers.js', () => ({
36+
logInvalidChunk: mockLogInvalidChunk,
37+
logContentRetry: mockLogContentRetry,
38+
logContentRetryFailure: mockLogContentRetryFailure,
39+
}));
40+
2841
describe('GeminiChat', () => {
2942
let chat: GeminiChat;
3043
let mockConfig: Config;
@@ -483,7 +496,7 @@ describe('GeminiChat', () => {
483496
});
484497

485498
describe('sendMessageStream with retries', () => {
486-
it('should retry on invalid content and succeed on the second attempt', async () => {
499+
it('should retry on invalid content, succeed, and report metrics', async () => {
487500
// Use mockImplementationOnce to provide a fresh, promise-wrapped generator for each attempt.
488501
vi.mocked(mockModelsModule.generateContentStream)
489502
.mockImplementationOnce(async () =>
@@ -515,6 +528,9 @@ describe('GeminiChat', () => {
515528
}
516529

517530
// Assertions
531+
expect(mockLogInvalidChunk).toHaveBeenCalledTimes(1);
532+
expect(mockLogContentRetry).toHaveBeenCalledTimes(1);
533+
expect(mockLogContentRetryFailure).not.toHaveBeenCalled();
518534
expect(mockModelsModule.generateContentStream).toHaveBeenCalledTimes(2);
519535
expect(
520536
chunks.some(
@@ -537,7 +553,7 @@ describe('GeminiChat', () => {
537553
});
538554
});
539555

540-
it('should fail after all retries on persistent invalid content', async () => {
556+
it('should fail after all retries on persistent invalid content and report metrics', async () => {
541557
vi.mocked(mockModelsModule.generateContentStream).mockImplementation(
542558
async () =>
543559
(async function* () {
@@ -571,6 +587,9 @@ describe('GeminiChat', () => {
571587

572588
// Should be called 3 times (initial + 2 retries)
573589
expect(mockModelsModule.generateContentStream).toHaveBeenCalledTimes(3);
590+
expect(mockLogInvalidChunk).toHaveBeenCalledTimes(3);
591+
expect(mockLogContentRetry).toHaveBeenCalledTimes(2);
592+
expect(mockLogContentRetryFailure).toHaveBeenCalledTimes(1);
574593

575594
// History should be clean, as if the failed turn never happened.
576595
const history = chat.getHistory();
@@ -585,7 +604,7 @@ describe('GeminiChat', () => {
585604
];
586605
chat.setHistory(initialHistory);
587606

588-
// 2. Mock the API
607+
// 2. Mock the API to fail once with an empty stream, then succeed.
589608
vi.mocked(mockModelsModule.generateContentStream)
590609
.mockImplementationOnce(async () =>
591610
(async function* () {
@@ -595,6 +614,7 @@ describe('GeminiChat', () => {
595614
})(),
596615
)
597616
.mockImplementationOnce(async () =>
617+
// Second attempt succeeds
598618
(async function* () {
599619
yield {
600620
candidates: [{ content: { parts: [{ text: 'Second answer' }] } }],
@@ -611,10 +631,13 @@ describe('GeminiChat', () => {
611631
// consume stream
612632
}
613633

614-
// 4. Assert the final history
634+
// 4. Assert the final history and metrics
615635
const history = chat.getHistory();
616636
expect(history.length).toBe(4);
617637

638+
// Assert that the correct metrics were reported for one empty-stream retry
639+
expect(mockLogContentRetry).toHaveBeenCalledTimes(1);
640+
618641
// Explicitly verify the structure of each part to satisfy TypeScript
619642
const turn1 = history[0];
620643
if (!turn1?.parts?.[0] || !('text' in turn1.parts[0])) {

packages/core/src/core/geminiChat.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ import { Config } from '../config/config.js';
2323
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
2424
import { hasCycleInSchema } from '../tools/tools.js';
2525
import { StructuredError } from './turn.js';
26+
import {
27+
logContentRetry,
28+
logContentRetryFailure,
29+
logInvalidChunk,
30+
} from '../telemetry/loggers.js';
31+
import {
32+
ContentRetryEvent,
33+
ContentRetryFailureEvent,
34+
InvalidChunkEvent,
35+
} from '../telemetry/types.js';
2636

2737
/**
2838
* Options for retrying due to invalid content from the model.
@@ -38,7 +48,6 @@ const INVALID_CONTENT_RETRY_OPTIONS: ContentRetryOptions = {
3848
maxAttempts: 3, // 1 initial call + 2 retries
3949
initialDelayMs: 500,
4050
};
41-
4251
/**
4352
* Returns true if the response is valid, false otherwise.
4453
*/
@@ -349,7 +358,7 @@ export class GeminiChat {
349358

350359
for (
351360
let attempt = 0;
352-
attempt <= INVALID_CONTENT_RETRY_OPTIONS.maxAttempts;
361+
attempt < INVALID_CONTENT_RETRY_OPTIONS.maxAttempts;
353362
attempt++
354363
) {
355364
try {
@@ -373,6 +382,14 @@ export class GeminiChat {
373382
if (isContentError) {
374383
// Check if we have more attempts left.
375384
if (attempt < INVALID_CONTENT_RETRY_OPTIONS.maxAttempts - 1) {
385+
logContentRetry(
386+
self.config,
387+
new ContentRetryEvent(
388+
attempt,
389+
'EmptyStreamError',
390+
INVALID_CONTENT_RETRY_OPTIONS.initialDelayMs,
391+
),
392+
);
376393
await new Promise((res) =>
377394
setTimeout(
378395
res,
@@ -388,6 +405,15 @@ export class GeminiChat {
388405
}
389406

390407
if (lastError) {
408+
if (lastError instanceof EmptyStreamError) {
409+
logContentRetryFailure(
410+
self.config,
411+
new ContentRetryFailureEvent(
412+
INVALID_CONTENT_RETRY_OPTIONS.maxAttempts,
413+
'EmptyStreamError',
414+
),
415+
);
416+
}
391417
// If the stream fails, remove the user message that was added.
392418
if (self.history[self.history.length - 1] === userContent) {
393419
self.history.pop();
@@ -545,6 +571,10 @@ export class GeminiChat {
545571
}
546572
}
547573
} else {
574+
logInvalidChunk(
575+
this.config,
576+
new InvalidChunkEvent('Invalid chunk received from stream.'),
577+
);
548578
isStreamInvalid = true;
549579
}
550580
yield chunk; // Yield every chunk to the UI immediately.

packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import {
1919
IdeConnectionEvent,
2020
KittySequenceOverflowEvent,
2121
ChatCompressionEvent,
22+
InvalidChunkEvent,
23+
ContentRetryEvent,
24+
ContentRetryFailureEvent,
2225
} from '../types.js';
2326
import { EventMetadataKey } from './event-metadata-key.js';
2427
import { Config } from '../../config/config.js';
@@ -48,6 +51,9 @@ export enum EventNames {
4851
IDE_CONNECTION = 'ide_connection',
4952
KITTY_SEQUENCE_OVERFLOW = 'kitty_sequence_overflow',
5053
CHAT_COMPRESSION = 'chat_compression',
54+
INVALID_CHUNK = 'invalid_chunk',
55+
CONTENT_RETRY = 'content_retry',
56+
CONTENT_RETRY_FAILURE = 'content_retry_failure',
5157
}
5258

5359
export interface LogResponse {
@@ -675,6 +681,69 @@ export class ClearcutLogger {
675681
});
676682
}
677683

684+
logInvalidChunkEvent(event: InvalidChunkEvent): void {
685+
const data: EventValue[] = [];
686+
687+
if (event.error_message) {
688+
data.push({
689+
gemini_cli_key: EventMetadataKey.GEMINI_CLI_INVALID_CHUNK_ERROR_MESSAGE,
690+
value: event.error_message,
691+
});
692+
}
693+
694+
this.enqueueLogEvent(this.createLogEvent(EventNames.INVALID_CHUNK, data));
695+
this.flushIfNeeded();
696+
}
697+
698+
logContentRetryEvent(event: ContentRetryEvent): void {
699+
const data: EventValue[] = [
700+
{
701+
gemini_cli_key:
702+
EventMetadataKey.GEMINI_CLI_CONTENT_RETRY_ATTEMPT_NUMBER,
703+
value: String(event.attempt_number),
704+
},
705+
{
706+
gemini_cli_key: EventMetadataKey.GEMINI_CLI_CONTENT_RETRY_ERROR_TYPE,
707+
value: event.error_type,
708+
},
709+
{
710+
gemini_cli_key: EventMetadataKey.GEMINI_CLI_CONTENT_RETRY_DELAY_MS,
711+
value: String(event.retry_delay_ms),
712+
},
713+
];
714+
715+
this.enqueueLogEvent(this.createLogEvent(EventNames.CONTENT_RETRY, data));
716+
this.flushIfNeeded();
717+
}
718+
719+
logContentRetryFailureEvent(event: ContentRetryFailureEvent): void {
720+
const data: EventValue[] = [
721+
{
722+
gemini_cli_key:
723+
EventMetadataKey.GEMINI_CLI_CONTENT_RETRY_FAILURE_TOTAL_ATTEMPTS,
724+
value: String(event.total_attempts),
725+
},
726+
{
727+
gemini_cli_key:
728+
EventMetadataKey.GEMINI_CLI_CONTENT_RETRY_FAILURE_FINAL_ERROR_TYPE,
729+
value: event.final_error_type,
730+
},
731+
];
732+
733+
if (event.total_duration_ms) {
734+
data.push({
735+
gemini_cli_key:
736+
EventMetadataKey.GEMINI_CLI_CONTENT_RETRY_FAILURE_TOTAL_DURATION_MS,
737+
value: String(event.total_duration_ms),
738+
});
739+
}
740+
741+
this.enqueueLogEvent(
742+
this.createLogEvent(EventNames.CONTENT_RETRY_FAILURE, data),
743+
);
744+
this.flushIfNeeded();
745+
}
746+
678747
/**
679748
* Adds default fields to data, and returns a new data array. This fields
680749
* should exist on all log events.

packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,16 @@ export enum EventMetadataKey {
229229
// Logs the length of the kitty sequence that overflowed.
230230
GEMINI_CLI_KITTY_SEQUENCE_LENGTH = 53,
231231

232+
// ==========================================================================
233+
// Conversation Finished Event Keys
234+
// ===========================================================================
235+
236+
// Logs the approval mode of the session.
237+
GEMINI_CLI_APPROVAL_MODE = 58,
238+
239+
// Logs the number of turns
240+
GEMINI_CLI_CONVERSATION_TURN_COUNT = 59,
241+
232242
// Logs the number of tokens before context window compression.
233243
GEMINI_CLI_COMPRESSION_TOKENS_BEFORE = 60,
234244

@@ -237,4 +247,72 @@ export enum EventMetadataKey {
237247

238248
// Logs tool type whether it is mcp or native.
239249
GEMINI_CLI_TOOL_TYPE = 62,
250+
// Logs name of MCP tools as comma separated string
251+
GEMINI_CLI_START_SESSION_MCP_TOOLS = 65,
252+
253+
// ==========================================================================
254+
// Research Event Keys
255+
// ===========================================================================
256+
257+
// Logs the research opt-in status (true/false)
258+
GEMINI_CLI_RESEARCH_OPT_IN_STATUS = 66,
259+
260+
// Logs the contact email for research participation
261+
GEMINI_CLI_RESEARCH_CONTACT_EMAIL = 67,
262+
263+
// Logs the user ID for research events
264+
GEMINI_CLI_RESEARCH_USER_ID = 68,
265+
266+
// Logs the type of research feedback
267+
GEMINI_CLI_RESEARCH_FEEDBACK_TYPE = 69,
268+
269+
// Logs the content of research feedback
270+
GEMINI_CLI_RESEARCH_FEEDBACK_CONTENT = 70,
271+
272+
// Logs survey responses for research feedback (JSON stringified)
273+
GEMINI_CLI_RESEARCH_SURVEY_RESPONSES = 71,
274+
275+
// ==========================================================================
276+
// File Operation Event Keys
277+
// ===========================================================================
278+
279+
// Logs the programming language of the project.
280+
GEMINI_CLI_PROGRAMMING_LANGUAGE = 56,
281+
282+
// Logs the operation type of the file operation.
283+
GEMINI_CLI_FILE_OPERATION_TYPE = 57,
284+
285+
// Logs the number of lines in the file operation.
286+
GEMINI_CLI_FILE_OPERATION_LINES = 72,
287+
288+
// Logs the mimetype of the file in the file operation.
289+
GEMINI_CLI_FILE_OPERATION_MIMETYPE = 73,
290+
291+
// Logs the extension of the file in the file operation.
292+
GEMINI_CLI_FILE_OPERATION_EXTENSION = 74,
293+
294+
// ==========================================================================
295+
// Content Streaming Event Keys
296+
// ===========================================================================
297+
298+
// Logs the error message for an invalid chunk.
299+
GEMINI_CLI_INVALID_CHUNK_ERROR_MESSAGE = 75,
300+
301+
// Logs the attempt number for a content retry.
302+
GEMINI_CLI_CONTENT_RETRY_ATTEMPT_NUMBER = 76,
303+
304+
// Logs the error type for a content retry.
305+
GEMINI_CLI_CONTENT_RETRY_ERROR_TYPE = 77,
306+
307+
// Logs the delay in milliseconds for a content retry.
308+
GEMINI_CLI_CONTENT_RETRY_DELAY_MS = 78,
309+
310+
// Logs the total number of attempts for a content retry failure.
311+
GEMINI_CLI_CONTENT_RETRY_FAILURE_TOTAL_ATTEMPTS = 79,
312+
313+
// Logs the final error type for a content retry failure.
314+
GEMINI_CLI_CONTENT_RETRY_FAILURE_FINAL_ERROR_TYPE = 80,
315+
316+
// Logs the total duration in milliseconds for a content retry failure.
317+
GEMINI_CLI_CONTENT_RETRY_FAILURE_TOTAL_DURATION_MS = 81,
240318
}

packages/core/src/telemetry/constants.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,19 @@ export const EVENT_NEXT_SPEAKER_CHECK = 'gemini_cli.next_speaker_check';
1717
export const EVENT_SLASH_COMMAND = 'gemini_cli.slash_command';
1818
export const EVENT_IDE_CONNECTION = 'gemini_cli.ide_connection';
1919
export const EVENT_CHAT_COMPRESSION = 'gemini_cli.chat_compression';
20+
export const EVENT_INVALID_CHUNK = 'gemini_cli.chat.invalid_chunk';
21+
export const EVENT_CONTENT_RETRY = 'gemini_cli.chat.content_retry';
22+
export const EVENT_CONTENT_RETRY_FAILURE =
23+
'gemini_cli.chat.content_retry_failure';
24+
2025
export const METRIC_TOOL_CALL_COUNT = 'gemini_cli.tool.call.count';
2126
export const METRIC_TOOL_CALL_LATENCY = 'gemini_cli.tool.call.latency';
2227
export const METRIC_API_REQUEST_COUNT = 'gemini_cli.api.request.count';
2328
export const METRIC_API_REQUEST_LATENCY = 'gemini_cli.api.request.latency';
2429
export const METRIC_TOKEN_USAGE = 'gemini_cli.token.usage';
2530
export const METRIC_SESSION_COUNT = 'gemini_cli.session.count';
2631
export const METRIC_FILE_OPERATION_COUNT = 'gemini_cli.file.operation.count';
32+
export const METRIC_INVALID_CHUNK_COUNT = 'gemini_cli.chat.invalid_chunk.count';
33+
export const METRIC_CONTENT_RETRY_COUNT = 'gemini_cli.chat.content_retry.count';
34+
export const METRIC_CONTENT_RETRY_FAILURE_COUNT =
35+
'gemini_cli.chat.content_retry_failure.count';

0 commit comments

Comments
 (0)