Skip to content
Open
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
74 changes: 65 additions & 9 deletions packages/server-utils/src/ai/google-genai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,40 @@ export function addResponseAttributes(span: Span, response: GoogleGenAIResponse,
}
}

function asConfigObject(config: unknown): Record<string, unknown> | undefined {
return config && typeof config === 'object' ? (config as Record<string, unknown>) : undefined;
}

/**
* Merge the parameters captured at `chats.create()` time onto a `chat.sendMessage()` /
* `chat.sendMessageStream()` call, so the config that is defined once on the chat lands on every
* message span. The create-time config is the default and a per-message config overrides it key by
* key. Only `model` and `config` are inherited; the create `history` is intentionally left off the
* message spans. See issue #20086.
*/
function mergeChatCreateParams(
chatCreateParams: Record<string, unknown> | undefined,
callParams: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!chatCreateParams) {
return callParams;
}

const merged: Record<string, unknown> = { ...callParams };

if (!('model' in merged) && 'model' in chatCreateParams) {
merged.model = chatCreateParams.model;
}

const createConfig = asConfigObject(chatCreateParams.config);
const callConfig = asConfigObject(callParams?.config);
if (createConfig || callConfig) {
merged.config = { ...createConfig, ...callConfig };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Config merge mismatches SDK behavior

Medium Severity

mergeChatCreateParams shallow-merges create-time and per-message config, but @google/genai replaces the chat config entirely when sendMessage/sendMessageStream provides one. Spans can then show create-time fields such as systemInstruction, tools, or sampling settings that were not actually sent on that request.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0f1c434. Configure here.


return merged;
}

/**
* Instrument any async or synchronous genai method with Sentry spans
* Handles operations like models.generateContent and chat.sendMessage and chats.create
Expand All @@ -261,14 +295,17 @@ function instrumentMethod<T extends unknown[], R>(
instrumentedMethod: InstrumentedMethodEntry,
context: unknown,
options: GoogleGenAIOptions,
chatCreateParams?: Record<string, unknown>,
): (...args: T) => R | Promise<R> {
const isEmbeddings = instrumentedMethod.operation === 'embeddings';

return new Proxy(originalMethod, {
apply(target, _, args: T): R | Promise<R> {
const operationName = instrumentedMethod.operation || 'unknown';
const params = args[0] as Record<string, unknown> | undefined;
const requestAttributes = extractRequestAttributes(operationName, params, context);
// The chat config is set once on chats.create() and reused for every message, so weld it on.
const attributeParams = mergeChatCreateParams(chatCreateParams, params);
const requestAttributes = extractRequestAttributes(operationName, attributeParams, context);
const model = requestAttributes[GEN_AI_REQUEST_MODEL] ?? 'unknown';

// Check if this is a streaming method
Expand All @@ -282,8 +319,8 @@ function instrumentMethod<T extends unknown[], R>(
},
async (span: Span) => {
try {
if (options.recordInputs && params) {
addPrivateRequestAttributes(span, params, operationName);
if (options.recordInputs && attributeParams) {
addPrivateRequestAttributes(span, attributeParams, operationName);
}
const stream = await target.apply(context, args);
return instrumentStream(stream, span, Boolean(options.recordOutputs)) as R;
Expand All @@ -310,8 +347,8 @@ function instrumentMethod<T extends unknown[], R>(
attributes: requestAttributes,
},
(span: Span) => {
if (options.recordInputs && params) {
addPrivateRequestAttributes(span, params, operationName);
if (options.recordInputs && attributeParams) {
addPrivateRequestAttributes(span, attributeParams, operationName);
}

return handleCallbackErrors(
Expand Down Expand Up @@ -339,7 +376,12 @@ function instrumentMethod<T extends unknown[], R>(
* Create a deep proxy for Google GenAI client instrumentation
* Recursively instruments methods and handles special cases like chats.create
*/
function createDeepProxy<T extends object>(target: T, currentPath = '', options: GoogleGenAIOptions): T {
function createDeepProxy<T extends object>(
target: T,
currentPath = '',
options: GoogleGenAIOptions,
chatCreateParams?: Record<string, unknown>,
): T {
return new Proxy(target, {
get: (t, prop, receiver) => {
const value = Reflect.get(t, prop, receiver);
Expand All @@ -350,7 +392,14 @@ function createDeepProxy<T extends object>(target: T, currentPath = '', options:
if (typeof value === 'function' && instrumentedMethod) {
// If an operation is specified, we need to instrument the method itself
const wrappedMethod = instrumentedMethod.operation
? instrumentMethod(value as (...args: unknown[]) => unknown, methodPath, instrumentedMethod, t, options)
? instrumentMethod(
value as (...args: unknown[]) => unknown,
methodPath,
instrumentedMethod,
t,
options,
chatCreateParams,
)
: value.bind(t);

if (!instrumentedMethod.proxyResultPath) {
Expand All @@ -364,7 +413,14 @@ function createDeepProxy<T extends object>(target: T, currentPath = '', options:
return function (...args: unknown[]): unknown {
const result = wrappedMethod(...args);
if (result && typeof result === 'object') {
return createDeepProxy(result as object, instrumentedMethod.proxyResultPath, options);
// The result (e.g. a chat object from chats.create()) carries the config passed here, so
// hand those params down to instrument its message methods with them (issue #20086).
return createDeepProxy(
result as object,
instrumentedMethod.proxyResultPath,
options,
args[0] as Record<string, unknown> | undefined,
);
}
return result;
};
Expand All @@ -376,7 +432,7 @@ function createDeepProxy<T extends object>(target: T, currentPath = '', options:
}

if (value && typeof value === 'object') {
return createDeepProxy(value, methodPath, options);
return createDeepProxy(value, methodPath, options, chatCreateParams);
}

return value;
Expand Down
176 changes: 176 additions & 0 deletions packages/server-utils/test/ai/lib/tracing/google-genai.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import {
GEN_AI_INPUT_MESSAGES,
GEN_AI_OPERATION_NAME,
GEN_AI_PROVIDER_NAME,
GEN_AI_REQUEST_FREQUENCY_PENALTY,
GEN_AI_REQUEST_MAX_TOKENS,
GEN_AI_REQUEST_MODEL,
GEN_AI_REQUEST_PRESENCE_PENALTY,
GEN_AI_REQUEST_TEMPERATURE,
GEN_AI_REQUEST_TOP_K,
GEN_AI_REQUEST_TOP_P,
GEN_AI_SYSTEM_INSTRUCTIONS,
GEN_AI_TOOL_DEFINITIONS,
} from '@sentry/conventions/attributes';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getMainCarrier, setCurrentClient, spanToJSON } from '@sentry/core';
import type { Span } from '@sentry/core';
import { instrumentGoogleGenAIClient } from '../../../../src/ai/google-genai';
import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client';

const MODEL = 'gemini-1.5-pro';

const CHAT_CONFIG = {
temperature: 0.8,
topP: 0.9,
topK: 40,
maxOutputTokens: 150,
frequencyPenalty: 0.5,
presencePenalty: 0.3,
tools: [{ functionDeclarations: [{ name: 'getWeather' }] }],
systemInstruction: 'You are a friendly robot.',
};

const MOCK_RESPONSE = {
modelVersion: MODEL,
candidates: [{ content: { parts: [{ text: 'Hi there!' }], role: 'model' } }],
usageMetadata: { promptTokenCount: 8, candidatesTokenCount: 12, totalTokenCount: 20 },
};

/**
* Minimal stand-in for a `@google/genai` client. `chats.create()` returns a chat object that keeps
* the model (as the real SDK does) and exposes the two message-sending methods. The config passed to
* `create()` is stored on the SDK internally and is not repeated on each `sendMessage()` call.
*/
function createFakeClient(): { chats: { create: (params: Record<string, unknown>) => unknown } } {
return {
chats: {
create: (params: Record<string, unknown>) => ({
model: params.model,
sendMessage: async (_params: Record<string, unknown>) => MOCK_RESPONSE,
sendMessageStream: async (_params: Record<string, unknown>) =>
(async function* () {
yield MOCK_RESPONSE;
})(),
}),
},
};
}

function setupClient(): Span[] {
const client = new TestClient(
getDefaultTestClientOptions({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
tracesSampleRate: 1,
}),
);
setCurrentClient(client);
client.init();

const endedSpans: Span[] = [];
client.on('spanEnd', span => endedSpans.push(span));
return endedSpans;
}

async function drain(stream: AsyncIterable<unknown>): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const _ of stream) {
// consume so the streaming span is ended
}
}

describe('instrumentGoogleGenAIClient chat config propagation (#20086)', () => {
beforeEach(() => {
getMainCarrier().__SENTRY__ = undefined;
});

afterEach(() => {
getMainCarrier().__SENTRY__ = undefined;
});

it('welds chats.create() config onto chat.sendMessage() spans', async () => {
const endedSpans = setupClient();
const instrumented = instrumentGoogleGenAIClient(createFakeClient());

const chat = instrumented.chats.create({ model: MODEL, config: CHAT_CONFIG }) as {
sendMessage: (params: Record<string, unknown>) => Promise<unknown>;
};
await chat.sendMessage({ message: 'Tell me a joke' });

expect(endedSpans).toHaveLength(1);
const data = spanToJSON(endedSpans[0]!).data;

expect(data[GEN_AI_OPERATION_NAME]).toBe('chat');
expect(data[GEN_AI_PROVIDER_NAME]).toBe('google_genai');
expect(data[GEN_AI_REQUEST_MODEL]).toBe(MODEL);
expect(data[GEN_AI_REQUEST_TEMPERATURE]).toBe(0.8);
expect(data[GEN_AI_REQUEST_TOP_P]).toBe(0.9);
expect(data[GEN_AI_REQUEST_TOP_K]).toBe(40);
expect(data[GEN_AI_REQUEST_MAX_TOKENS]).toBe(150);
expect(data[GEN_AI_REQUEST_FREQUENCY_PENALTY]).toBe(0.5);
expect(data[GEN_AI_REQUEST_PRESENCE_PENALTY]).toBe(0.3);
expect(data[GEN_AI_TOOL_DEFINITIONS]).toBe('[{"name":"getWeather"}]');
expect(data[GEN_AI_SYSTEM_INSTRUCTIONS]).toBe('[{"type":"text","content":"You are a friendly robot."}]');
// The chat message stays as the only input message; the system instruction is split out above.
expect(data[GEN_AI_INPUT_MESSAGES]).toBe('[{"role":"user","content":"Tell me a joke"}]');
});

it('welds chats.create() config onto chat.sendMessageStream() spans', async () => {
const endedSpans = setupClient();
const instrumented = instrumentGoogleGenAIClient(createFakeClient());

const chat = instrumented.chats.create({ model: MODEL, config: CHAT_CONFIG }) as {
sendMessageStream: (params: Record<string, unknown>) => Promise<AsyncIterable<unknown>>;
};
await drain(await chat.sendMessageStream({ message: 'Tell me a joke' }));

expect(endedSpans).toHaveLength(1);
const data = spanToJSON(endedSpans[0]!).data;

expect(data[GEN_AI_OPERATION_NAME]).toBe('chat');
expect(data[GEN_AI_REQUEST_MODEL]).toBe(MODEL);
expect(data[GEN_AI_REQUEST_TEMPERATURE]).toBe(0.8);
expect(data[GEN_AI_REQUEST_TOP_P]).toBe(0.9);
expect(data[GEN_AI_REQUEST_TOP_K]).toBe(40);
expect(data[GEN_AI_REQUEST_MAX_TOKENS]).toBe(150);
expect(data[GEN_AI_REQUEST_FREQUENCY_PENALTY]).toBe(0.5);
expect(data[GEN_AI_REQUEST_PRESENCE_PENALTY]).toBe(0.3);
expect(data[GEN_AI_TOOL_DEFINITIONS]).toBe('[{"name":"getWeather"}]');
expect(data[GEN_AI_SYSTEM_INSTRUCTIONS]).toBe('[{"type":"text","content":"You are a friendly robot."}]');
});

it('lets a per-message config override the config from chats.create()', async () => {
const endedSpans = setupClient();
const instrumented = instrumentGoogleGenAIClient(createFakeClient());

const chat = instrumented.chats.create({ model: MODEL, config: CHAT_CONFIG }) as {
sendMessage: (params: Record<string, unknown>) => Promise<unknown>;
};
await chat.sendMessage({ message: 'Tell me a joke', config: { temperature: 0.1 } });

const data = spanToJSON(endedSpans[0]!).data;
// Per-call temperature wins; the untouched create-time values still come through.
expect(data[GEN_AI_REQUEST_TEMPERATURE]).toBe(0.1);
expect(data[GEN_AI_REQUEST_TOP_P]).toBe(0.9);
expect(data[GEN_AI_REQUEST_MAX_TOKENS]).toBe(150);
});

it('does not leak chat config onto models.generateContent spans', async () => {
const endedSpans = setupClient();
const client = {
chats: { create: createFakeClient().chats.create },
models: { generateContent: async (_params: Record<string, unknown>) => MOCK_RESPONSE },
};
const instrumented = instrumentGoogleGenAIClient(client);

// Create a chat (with config) first, then make an unrelated generateContent call.
instrumented.chats.create({ model: MODEL, config: CHAT_CONFIG });
await instrumented.models.generateContent({ model: 'gemini-1.5-flash' });

const genContentSpan = endedSpans.find(span => spanToJSON(span).data[GEN_AI_OPERATION_NAME] === 'generate_content');
const data = spanToJSON(genContentSpan!).data;
expect(data[GEN_AI_REQUEST_MODEL]).toBe('gemini-1.5-flash');
expect(data[GEN_AI_REQUEST_TEMPERATURE]).toBeUndefined();
expect(data[GEN_AI_SYSTEM_INSTRUCTIONS]).toBeUndefined();
});
});