Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ async function run() {
temperature: 0.8,
topP: 0.9,
maxOutputTokens: 150,
systemInstruction: 'You are a friendly robot who likes to be funny.',
},
history: [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ describe('Google GenAI integration', () => {
'gen_ai.request.temperature': 0.8,
'gen_ai.request.top_p': 0.9,
'gen_ai.request.max_tokens': 150,
'gen_ai.request.messages': expect.any(String), // Should include history when recordInputs: true
'gen_ai.request.messages': expect.stringMatching(
/\[\{"role":"system","content":"You are a friendly robot who likes to be funny."\},/,
), // Should include history when recordInputs: true
}),
description: 'chat gemini-1.5-pro create',
op: 'gen_ai.chat',
Expand Down
51 changes: 35 additions & 16 deletions packages/core/src/tracing/google-genai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,16 @@ import {
GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE,
GEN_AI_REQUEST_TOP_K_ATTRIBUTE,
GEN_AI_REQUEST_TOP_P_ATTRIBUTE,
GEN_AI_RESPONSE_MODEL_ATTRIBUTE,
GEN_AI_RESPONSE_TEXT_ATTRIBUTE,
GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE,
GEN_AI_SYSTEM_ATTRIBUTE,
GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE,
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
} from '../ai/gen-ai-attributes';
import { buildMethodPath, getFinalOperationName, getSpanOperation, getTruncatedJsonString } from '../ai/utils';
import { truncateGenAiMessages } from '../ai/messageTruncation';
import { buildMethodPath, getFinalOperationName, getSpanOperation } from '../ai/utils';
import { CHAT_PATH, CHATS_CREATE_METHOD, GOOGLE_GENAI_SYSTEM_NAME } from './constants';
import { instrumentStream } from './streaming';
import type {
Expand All @@ -33,7 +35,8 @@ import type {
GoogleGenAIOptions,
GoogleGenAIResponse,
} from './types';
import { isStreamingMethod, shouldInstrument } from './utils';
import type { ContentListUnion, ContentUnion, Message, PartListUnion } from './utils';
import { contentUnionToMessages, isStreamingMethod, shouldInstrument } from './utils';

/**
* Extract model from parameters or chat context object
Expand Down Expand Up @@ -134,26 +137,38 @@ function extractRequestAttributes(
* Handles different parameter formats for different Google GenAI methods.
*/
function addPrivateRequestAttributes(span: Span, params: Record<string, unknown>): void {
// For models.generateContent: ContentListUnion: Content | Content[] | PartUnion | PartUnion[]
const messages: Message[] = [];

// config.systemInstruction: ContentUnion
if (
'config' in params &&
params.config &&
typeof params.config === 'object' &&
'systemInstruction' in params.config &&
params.config.systemInstruction
) {
messages.push(...contentUnionToMessages(params.config.systemInstruction as ContentUnion, 'system'));
}

// For chats.create: history contains the conversation history
if ('history' in params) {
messages.push(...contentUnionToMessages(params.history as PartListUnion, 'user'));
}

// For models.generateContent: ContentListUnion
if ('contents' in params) {
const contents = params.contents;
// For models.generateContent: ContentListUnion: Content | Content[] | PartUnion | PartUnion[]
const truncatedContents = getTruncatedJsonString(contents);
span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedContents });
messages.push(...contentUnionToMessages(params.contents as ContentListUnion, 'user'));
}

// For chat.sendMessage: message can be string or Part[]
// For chat.sendMessage: message can be PartListUnion
if ('message' in params) {
const message = params.message;
const truncatedMessage = getTruncatedJsonString(message);
span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessage });
messages.push(...contentUnionToMessages(params.message as PartListUnion, 'user'));
}

// For chats.create: history contains the conversation history
if ('history' in params) {
const history = params.history;
const truncatedHistory = getTruncatedJsonString(history);
span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedHistory });
if (messages.length) {
span.setAttributes({
[GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(truncateGenAiMessages(messages)),
});
}
}

Expand All @@ -164,6 +179,10 @@ function addPrivateRequestAttributes(span: Span, params: Record<string, unknown>
function addResponseAttributes(span: Span, response: GoogleGenAIResponse, recordOutputs?: boolean): void {
if (!response || typeof response !== 'object') return;

if (response.modelVersion) {
span.setAttribute(GEN_AI_RESPONSE_MODEL_ATTRIBUTE, response.modelVersion);
}

// Add usage metadata if present
if (response.usageMetadata && typeof response.usageMetadata === 'object') {
const usage = response.usageMetadata;
Expand Down
51 changes: 46 additions & 5 deletions packages/core/src/tracing/google-genai/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,50 @@ export function shouldInstrument(methodPath: string): methodPath is GoogleGenAII
* Check if a method is a streaming method
*/
export function isStreamingMethod(methodPath: string): boolean {
return (
methodPath.includes('Stream') ||
methodPath.endsWith('generateContentStream') ||
methodPath.endsWith('sendMessageStream')
);
return methodPath.includes('Stream');
}

// Copied from https://googleapis.github.io/js-genai/release_docs/index.html
export type ContentListUnion = Content | Content[] | PartListUnion;
export type ContentUnion = Content | PartUnion[] | PartUnion;
export type Content = {
parts?: Part[];
role?: string;
};
export type PartUnion = Part | string;
export type Part = Record<string, unknown> & {
inlineData?: {
data?: string;
displayName?: string;
mimeType?: string;
};
text?: string;
};
export type PartListUnion = PartUnion[] | PartUnion;

// our consistent span message shape
export type Message = Record<string, unknown> & {
role: string;
content?: PartListUnion;
parts?: PartListUnion;
};

/**
*
*/
export function contentUnionToMessages(content: ContentListUnion, role = 'user'): Message[] {
if (typeof content === 'string') {
return [{ role, content }];
}
if (Array.isArray(content)) {
return content.flatMap(content => contentUnionToMessages(content, role));
}
if (typeof content !== 'object' || !content) return [];
if ('role' in content && typeof content.role === 'string') {
return [content as Message];
}
if ('parts' in content) {
return [{ ...content, role } as Message];
}
return [{ role, content }];
}
85 changes: 85 additions & 0 deletions packages/core/test/lib/utils/google-genai-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import type { ContentListUnion } from '../../../src/tracing/google-genai/utils';
import { contentUnionToMessages, isStreamingMethod, shouldInstrument } from '../../../src/tracing/google-genai/utils';

describe('isStreamingMethod', () => {
it('detects streaming methods', () => {
expect(isStreamingMethod('messageStreamBlah')).toBe(true);
expect(isStreamingMethod('blahblahblah generateContentStream')).toBe(true);
expect(isStreamingMethod('blahblahblah sendMessageStream')).toBe(true);
expect(isStreamingMethod('blahblahblah generateContentStream')).toBe(true);
expect(isStreamingMethod('blahblahblah sendMessageStream')).toBe(true);
expect(isStreamingMethod('blahblahblah generateContent')).toBe(false);
expect(isStreamingMethod('blahblahblah sendMessage')).toBe(false);
});
});

describe('shouldInstrument', () => {
it('detects which methods to instrument', () => {
expect(shouldInstrument('models.generateContent')).toBe(true);
expect(shouldInstrument('some.path.to.sendMessage')).toBe(true);
expect(shouldInstrument('unknown')).toBe(false);
});
});

describe('convert google-genai messages to consistent message', () => {
it('converts strings to messages', () => {
expect(contentUnionToMessages('hello', 'system')).toStrictEqual([{ role: 'system', content: 'hello' }]);
expect(contentUnionToMessages('hello')).toStrictEqual([{ role: 'user', content: 'hello' }]);
});

it('converts arrays of strings to messages', () => {
expect(contentUnionToMessages(['hello', 'goodbye'], 'system')).toStrictEqual([
{ role: 'system', content: 'hello' },
{ role: 'system', content: 'goodbye' },
]);
expect(contentUnionToMessages(['hello', 'goodbye'])).toStrictEqual([
{ role: 'user', content: 'hello' },
{ role: 'user', content: 'goodbye' },
]);
});

it('converts PartUnion to messages', () => {
expect(contentUnionToMessages(['hello', { parts: ['i am here', { text: 'goodbye' }] }], 'system')).toStrictEqual([
{ role: 'system', content: 'hello' },
{ role: 'system', parts: ['i am here', { text: 'goodbye' }] },
]);

expect(contentUnionToMessages(['hello', { parts: ['i am here', { text: 'goodbye' }] }])).toStrictEqual([
{ role: 'user', content: 'hello' },
{ role: 'user', parts: ['i am here', { text: 'goodbye' }] },
]);
});

it('converts ContentUnion to messages', () => {
expect(
contentUnionToMessages(
{
parts: ['hello', 'goodbye'],
role: 'agent',
},
'user',
),
).toStrictEqual([{ parts: ['hello', 'goodbye'], role: 'agent' }]);
});

it('handles unexpected formats safely', () => {
expect(
contentUnionToMessages(
[
{
parts: ['hello', 'goodbye'],
role: 'agent',
},
null,
21345,
{ data: 'this is content' },
] as unknown as ContentListUnion,
'user',
),
).toStrictEqual([
{ parts: ['hello', 'goodbye'], role: 'agent' },
{ role: 'user', content: { data: 'this is content' } },
]);
});
});
Loading