mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-02 17:31:05 -07:00
250 lines
7.2 KiB
TypeScript
250 lines
7.2 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { trace, SpanStatusCode, diag, type Tracer } from '@opentelemetry/api';
|
|
import { runInDevTraceSpan, truncateForTelemetry } from './trace.js';
|
|
import {
|
|
GeminiCliOperation,
|
|
GEN_AI_CONVERSATION_ID,
|
|
GEN_AI_AGENT_DESCRIPTION,
|
|
GEN_AI_AGENT_NAME,
|
|
GEN_AI_INPUT_MESSAGES,
|
|
GEN_AI_OPERATION_NAME,
|
|
GEN_AI_OUTPUT_MESSAGES,
|
|
SERVICE_DESCRIPTION,
|
|
SERVICE_NAME,
|
|
} from './constants.js';
|
|
|
|
vi.mock('@opentelemetry/api', async (importOriginal) => {
|
|
const original = await importOriginal<typeof import('@opentelemetry/api')>();
|
|
return {
|
|
...original,
|
|
trace: {
|
|
getTracer: vi.fn(),
|
|
},
|
|
diag: {
|
|
error: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('../utils/session.js', () => ({
|
|
sessionId: 'test-session-id',
|
|
}));
|
|
|
|
describe('truncateForTelemetry', () => {
|
|
it('should return string unchanged if within maxLength', () => {
|
|
expect(truncateForTelemetry('hello', 10)).toBe('hello');
|
|
});
|
|
|
|
it('should truncate string if exceeding maxLength', () => {
|
|
const result = truncateForTelemetry('hello world', 5);
|
|
expect(result).toBe('hello...[TRUNCATED: original length 11]');
|
|
});
|
|
|
|
it('should correctly truncate strings with multi-byte unicode characters (emojis)', () => {
|
|
// 5 emojis, each is multiple bytes in UTF-16
|
|
const emojis = '👋🌍🚀🔥🎉';
|
|
|
|
// Truncating to length 5 (which is 2.5 emojis in UTF-16 length terms)
|
|
// truncateString will stop after the full grapheme clusters that fit within 5
|
|
const result = truncateForTelemetry(emojis, 5);
|
|
|
|
expect(result).toBe('👋🌍...[TRUNCATED: original length 10]');
|
|
});
|
|
|
|
it('should stringify and truncate objects if exceeding maxLength', () => {
|
|
const obj = { message: 'hello world', nested: { a: 1 } };
|
|
const stringified = JSON.stringify(obj);
|
|
const result = truncateForTelemetry(obj, 10);
|
|
expect(result).toBe(
|
|
stringified.substring(0, 10) +
|
|
`...[TRUNCATED: original length ${stringified.length}]`,
|
|
);
|
|
});
|
|
|
|
it('should stringify objects unchanged if within maxLength', () => {
|
|
const obj = { a: 1 };
|
|
expect(truncateForTelemetry(obj, 100)).toBe(JSON.stringify(obj));
|
|
});
|
|
|
|
it('should return booleans and numbers unchanged', () => {
|
|
expect(truncateForTelemetry(100)).toBe(100);
|
|
expect(truncateForTelemetry(true)).toBe(true);
|
|
expect(truncateForTelemetry(false)).toBe(false);
|
|
});
|
|
|
|
it('should return undefined for unsupported types', () => {
|
|
expect(truncateForTelemetry(undefined)).toBeUndefined();
|
|
expect(truncateForTelemetry(() => {})).toBeUndefined();
|
|
expect(truncateForTelemetry(Symbol('test'))).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('runInDevTraceSpan', () => {
|
|
const mockSpan = {
|
|
setAttribute: vi.fn(),
|
|
setStatus: vi.fn(),
|
|
recordException: vi.fn(),
|
|
end: vi.fn(),
|
|
};
|
|
|
|
const mockTracer = {
|
|
startActiveSpan: vi.fn((name, options, callback) => callback(mockSpan)),
|
|
} as unknown as Tracer;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(trace.getTracer).mockReturnValue(mockTracer);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it('should start an active span', async () => {
|
|
const fn = vi.fn(async () => 'result');
|
|
|
|
const result = await runInDevTraceSpan(
|
|
{ operation: GeminiCliOperation.LLMCall },
|
|
fn,
|
|
);
|
|
|
|
expect(result).toBe('result');
|
|
expect(trace.getTracer).toHaveBeenCalled();
|
|
expect(mockTracer.startActiveSpan).toHaveBeenCalledWith(
|
|
GeminiCliOperation.LLMCall,
|
|
{},
|
|
expect.any(Function),
|
|
);
|
|
});
|
|
|
|
it('should set default attributes on the span metadata', async () => {
|
|
await runInDevTraceSpan(
|
|
{ operation: GeminiCliOperation.LLMCall },
|
|
async ({ metadata }) => {
|
|
expect(metadata.attributes[GEN_AI_OPERATION_NAME]).toBe(
|
|
GeminiCliOperation.LLMCall,
|
|
);
|
|
expect(metadata.attributes[GEN_AI_AGENT_NAME]).toBe(SERVICE_NAME);
|
|
expect(metadata.attributes[GEN_AI_AGENT_DESCRIPTION]).toBe(
|
|
SERVICE_DESCRIPTION,
|
|
);
|
|
expect(metadata.attributes[GEN_AI_CONVERSATION_ID]).toBe(
|
|
'test-session-id',
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should set span attributes from metadata on completion', async () => {
|
|
await runInDevTraceSpan(
|
|
{ operation: GeminiCliOperation.LLMCall },
|
|
async ({ metadata }) => {
|
|
metadata.input = { query: 'hello' };
|
|
metadata.output = { response: 'world' };
|
|
metadata.attributes['custom.attr'] = 'value';
|
|
},
|
|
);
|
|
|
|
expect(mockSpan.setAttribute).toHaveBeenCalledWith(
|
|
GEN_AI_INPUT_MESSAGES,
|
|
JSON.stringify({ query: 'hello' }),
|
|
);
|
|
expect(mockSpan.setAttribute).toHaveBeenCalledWith(
|
|
GEN_AI_OUTPUT_MESSAGES,
|
|
JSON.stringify({ response: 'world' }),
|
|
);
|
|
expect(mockSpan.setAttribute).toHaveBeenCalledWith('custom.attr', 'value');
|
|
expect(mockSpan.setStatus).toHaveBeenCalledWith({
|
|
code: SpanStatusCode.OK,
|
|
});
|
|
expect(mockSpan.end).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle errors in the wrapped function', async () => {
|
|
const error = new Error('test error');
|
|
await expect(
|
|
runInDevTraceSpan({ operation: GeminiCliOperation.LLMCall }, async () => {
|
|
throw error;
|
|
}),
|
|
).rejects.toThrow(error);
|
|
|
|
expect(mockSpan.setStatus).toHaveBeenCalledWith({
|
|
code: SpanStatusCode.ERROR,
|
|
message: 'test error',
|
|
});
|
|
expect(mockSpan.recordException).toHaveBeenCalledWith(error);
|
|
expect(mockSpan.end).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should auto-wrap async iterators and end span when iterator completes', async () => {
|
|
async function* testStream() {
|
|
yield 1;
|
|
yield 2;
|
|
}
|
|
|
|
const resultStream = await runInDevTraceSpan(
|
|
{ operation: GeminiCliOperation.LLMCall },
|
|
async () => testStream(),
|
|
);
|
|
|
|
expect(mockSpan.end).not.toHaveBeenCalled();
|
|
|
|
const results = [];
|
|
for await (const val of resultStream) {
|
|
results.push(val);
|
|
}
|
|
|
|
expect(results).toEqual([1, 2]);
|
|
expect(mockSpan.end).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should end span automatically on error in async iterators', async () => {
|
|
const error = new Error('streaming error');
|
|
async function* errorStream() {
|
|
yield 1;
|
|
throw error;
|
|
}
|
|
|
|
const resultStream = await runInDevTraceSpan(
|
|
{ operation: GeminiCliOperation.LLMCall },
|
|
async () => errorStream(),
|
|
);
|
|
|
|
await expect(async () => {
|
|
for await (const _ of resultStream) {
|
|
// iterate
|
|
}
|
|
}).rejects.toThrow(error);
|
|
|
|
expect(mockSpan.end).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle exceptions in endSpan gracefully', async () => {
|
|
mockSpan.setAttribute.mockImplementation(() => {
|
|
throw new Error('attribute error');
|
|
});
|
|
|
|
await runInDevTraceSpan(
|
|
{ operation: GeminiCliOperation.LLMCall },
|
|
async ({ metadata }) => {
|
|
metadata.input = 'trigger error';
|
|
},
|
|
);
|
|
|
|
expect(diag.error).toHaveBeenCalled();
|
|
expect(mockSpan.setStatus).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
code: SpanStatusCode.ERROR,
|
|
message: expect.stringContaining('attribute error'),
|
|
}),
|
|
);
|
|
expect(mockSpan.end).toHaveBeenCalled();
|
|
});
|
|
});
|