Skip to content

Commit e0d7e86

Browse files
zkasuranclaude
andcommitted
fix(server-utils): Propagate chats.create() config to google-genai message spans
Removing the chats.create() span dropped the config it captured (temperature, top_p, top_k, max_tokens, frequency_penalty, presence_penalty, available_tools and system_instructions). That config is set once on the chat instance and reused for every chat.sendMessage() and chat.sendMessageStream() call, so those spans lost it and the trace no longer showed the chat configuration. Capture the params at chats.create() time and weld model plus config onto each message span. The create-time config is the default and a per-message config overrides it key by key. The create history is left off the message spans. Fixes #20086 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4f70f75 commit e0d7e86

2 files changed

Lines changed: 241 additions & 9 deletions

File tree

‎packages/server-utils/src/ai/google-genai/index.ts‎

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,40 @@ export function addResponseAttributes(span: Span, response: GoogleGenAIResponse,
250250
}
251251
}
252252

253+
function asConfigObject(config: unknown): Record<string, unknown> | undefined {
254+
return config && typeof config === 'object' ? (config as Record<string, unknown>) : undefined;
255+
}
256+
257+
/**
258+
* Merge the parameters captured at `chats.create()` time onto a `chat.sendMessage()` /
259+
* `chat.sendMessageStream()` call, so the config that is defined once on the chat lands on every
260+
* message span. The create-time config is the default and a per-message config overrides it key by
261+
* key. Only `model` and `config` are inherited; the create `history` is intentionally left off the
262+
* message spans. See issue #20086.
263+
*/
264+
function mergeChatCreateParams(
265+
chatCreateParams: Record<string, unknown> | undefined,
266+
callParams: Record<string, unknown> | undefined,
267+
): Record<string, unknown> | undefined {
268+
if (!chatCreateParams) {
269+
return callParams;
270+
}
271+
272+
const merged: Record<string, unknown> = { ...callParams };
273+
274+
if (!('model' in merged) && 'model' in chatCreateParams) {
275+
merged.model = chatCreateParams.model;
276+
}
277+
278+
const createConfig = asConfigObject(chatCreateParams.config);
279+
const callConfig = asConfigObject(callParams?.config);
280+
if (createConfig || callConfig) {
281+
merged.config = { ...createConfig, ...callConfig };
282+
}
283+
284+
return merged;
285+
}
286+
253287
/**
254288
* Instrument any async or synchronous genai method with Sentry spans
255289
* Handles operations like models.generateContent and chat.sendMessage and chats.create
@@ -261,14 +295,17 @@ function instrumentMethod<T extends unknown[], R>(
261295
instrumentedMethod: InstrumentedMethodEntry,
262296
context: unknown,
263297
options: GoogleGenAIOptions,
298+
chatCreateParams?: Record<string, unknown>,
264299
): (...args: T) => R | Promise<R> {
265300
const isEmbeddings = instrumentedMethod.operation === 'embeddings';
266301

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

274311
// Check if this is a streaming method
@@ -282,8 +319,8 @@ function instrumentMethod<T extends unknown[], R>(
282319
},
283320
async (span: Span) => {
284321
try {
285-
if (options.recordInputs && params) {
286-
addPrivateRequestAttributes(span, params, operationName);
322+
if (options.recordInputs && attributeParams) {
323+
addPrivateRequestAttributes(span, attributeParams, operationName);
287324
}
288325
const stream = await target.apply(context, args);
289326
return instrumentStream(stream, span, Boolean(options.recordOutputs)) as R;
@@ -310,8 +347,8 @@ function instrumentMethod<T extends unknown[], R>(
310347
attributes: requestAttributes,
311348
},
312349
(span: Span) => {
313-
if (options.recordInputs && params) {
314-
addPrivateRequestAttributes(span, params, operationName);
350+
if (options.recordInputs && attributeParams) {
351+
addPrivateRequestAttributes(span, attributeParams, operationName);
315352
}
316353

317354
return handleCallbackErrors(
@@ -339,7 +376,12 @@ function instrumentMethod<T extends unknown[], R>(
339376
* Create a deep proxy for Google GenAI client instrumentation
340377
* Recursively instruments methods and handles special cases like chats.create
341378
*/
342-
function createDeepProxy<T extends object>(target: T, currentPath = '', options: GoogleGenAIOptions): T {
379+
function createDeepProxy<T extends object>(
380+
target: T,
381+
currentPath = '',
382+
options: GoogleGenAIOptions,
383+
chatCreateParams?: Record<string, unknown>,
384+
): T {
343385
return new Proxy(target, {
344386
get: (t, prop, receiver) => {
345387
const value = Reflect.get(t, prop, receiver);
@@ -350,7 +392,14 @@ function createDeepProxy<T extends object>(target: T, currentPath = '', options:
350392
if (typeof value === 'function' && instrumentedMethod) {
351393
// If an operation is specified, we need to instrument the method itself
352394
const wrappedMethod = instrumentedMethod.operation
353-
? instrumentMethod(value as (...args: unknown[]) => unknown, methodPath, instrumentedMethod, t, options)
395+
? instrumentMethod(
396+
value as (...args: unknown[]) => unknown,
397+
methodPath,
398+
instrumentedMethod,
399+
t,
400+
options,
401+
chatCreateParams,
402+
)
354403
: value.bind(t);
355404

356405
if (!instrumentedMethod.proxyResultPath) {
@@ -364,7 +413,14 @@ function createDeepProxy<T extends object>(target: T, currentPath = '', options:
364413
return function (...args: unknown[]): unknown {
365414
const result = wrappedMethod(...args);
366415
if (result && typeof result === 'object') {
367-
return createDeepProxy(result as object, instrumentedMethod.proxyResultPath, options);
416+
// The result (e.g. a chat object from chats.create()) carries the config passed here, so
417+
// hand those params down to instrument its message methods with them (issue #20086).
418+
return createDeepProxy(
419+
result as object,
420+
instrumentedMethod.proxyResultPath,
421+
options,
422+
args[0] as Record<string, unknown> | undefined,
423+
);
368424
}
369425
return result;
370426
};
@@ -376,7 +432,7 @@ function createDeepProxy<T extends object>(target: T, currentPath = '', options:
376432
}
377433

378434
if (value && typeof value === 'object') {
379-
return createDeepProxy(value, methodPath, options);
435+
return createDeepProxy(value, methodPath, options, chatCreateParams);
380436
}
381437

382438
return value;
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import {
2+
GEN_AI_INPUT_MESSAGES,
3+
GEN_AI_OPERATION_NAME,
4+
GEN_AI_PROVIDER_NAME,
5+
GEN_AI_REQUEST_FREQUENCY_PENALTY,
6+
GEN_AI_REQUEST_MAX_TOKENS,
7+
GEN_AI_REQUEST_MODEL,
8+
GEN_AI_REQUEST_PRESENCE_PENALTY,
9+
GEN_AI_REQUEST_TEMPERATURE,
10+
GEN_AI_REQUEST_TOP_K,
11+
GEN_AI_REQUEST_TOP_P,
12+
GEN_AI_SYSTEM_INSTRUCTIONS,
13+
GEN_AI_TOOL_DEFINITIONS,
14+
} from '@sentry/conventions/attributes';
15+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
16+
import { getMainCarrier, setCurrentClient, spanToJSON } from '@sentry/core';
17+
import type { Span } from '@sentry/core';
18+
import { instrumentGoogleGenAIClient } from '../../../../src/ai/google-genai';
19+
import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client';
20+
21+
const MODEL = 'gemini-1.5-pro';
22+
23+
const CHAT_CONFIG = {
24+
temperature: 0.8,
25+
topP: 0.9,
26+
topK: 40,
27+
maxOutputTokens: 150,
28+
frequencyPenalty: 0.5,
29+
presencePenalty: 0.3,
30+
tools: [{ functionDeclarations: [{ name: 'getWeather' }] }],
31+
systemInstruction: 'You are a friendly robot.',
32+
};
33+
34+
const MOCK_RESPONSE = {
35+
modelVersion: MODEL,
36+
candidates: [{ content: { parts: [{ text: 'Hi there!' }], role: 'model' } }],
37+
usageMetadata: { promptTokenCount: 8, candidatesTokenCount: 12, totalTokenCount: 20 },
38+
};
39+
40+
/**
41+
* Minimal stand-in for a `@google/genai` client. `chats.create()` returns a chat object that keeps
42+
* the model (as the real SDK does) and exposes the two message-sending methods. The config passed to
43+
* `create()` is stored on the SDK internally and is not repeated on each `sendMessage()` call.
44+
*/
45+
function createFakeClient(): { chats: { create: (params: Record<string, unknown>) => unknown } } {
46+
return {
47+
chats: {
48+
create: (params: Record<string, unknown>) => ({
49+
model: params.model,
50+
sendMessage: async (_params: Record<string, unknown>) => MOCK_RESPONSE,
51+
sendMessageStream: async (_params: Record<string, unknown>) =>
52+
(async function* () {
53+
yield MOCK_RESPONSE;
54+
})(),
55+
}),
56+
},
57+
};
58+
}
59+
60+
function setupClient(): Span[] {
61+
const client = new TestClient(
62+
getDefaultTestClientOptions({
63+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
64+
tracesSampleRate: 1,
65+
}),
66+
);
67+
setCurrentClient(client);
68+
client.init();
69+
70+
const endedSpans: Span[] = [];
71+
client.on('spanEnd', span => endedSpans.push(span));
72+
return endedSpans;
73+
}
74+
75+
async function drain(stream: AsyncIterable<unknown>): Promise<void> {
76+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
77+
for await (const _ of stream) {
78+
// consume so the streaming span is ended
79+
}
80+
}
81+
82+
describe('instrumentGoogleGenAIClient chat config propagation (#20086)', () => {
83+
beforeEach(() => {
84+
getMainCarrier().__SENTRY__ = undefined;
85+
});
86+
87+
afterEach(() => {
88+
getMainCarrier().__SENTRY__ = undefined;
89+
});
90+
91+
it('welds chats.create() config onto chat.sendMessage() spans', async () => {
92+
const endedSpans = setupClient();
93+
const instrumented = instrumentGoogleGenAIClient(createFakeClient());
94+
95+
const chat = instrumented.chats.create({ model: MODEL, config: CHAT_CONFIG }) as {
96+
sendMessage: (params: Record<string, unknown>) => Promise<unknown>;
97+
};
98+
await chat.sendMessage({ message: 'Tell me a joke' });
99+
100+
expect(endedSpans).toHaveLength(1);
101+
const data = spanToJSON(endedSpans[0]!).data;
102+
103+
expect(data[GEN_AI_OPERATION_NAME]).toBe('chat');
104+
expect(data[GEN_AI_PROVIDER_NAME]).toBe('google_genai');
105+
expect(data[GEN_AI_REQUEST_MODEL]).toBe(MODEL);
106+
expect(data[GEN_AI_REQUEST_TEMPERATURE]).toBe(0.8);
107+
expect(data[GEN_AI_REQUEST_TOP_P]).toBe(0.9);
108+
expect(data[GEN_AI_REQUEST_TOP_K]).toBe(40);
109+
expect(data[GEN_AI_REQUEST_MAX_TOKENS]).toBe(150);
110+
expect(data[GEN_AI_REQUEST_FREQUENCY_PENALTY]).toBe(0.5);
111+
expect(data[GEN_AI_REQUEST_PRESENCE_PENALTY]).toBe(0.3);
112+
expect(data[GEN_AI_TOOL_DEFINITIONS]).toBe('[{"name":"getWeather"}]');
113+
expect(data[GEN_AI_SYSTEM_INSTRUCTIONS]).toBe('[{"type":"text","content":"You are a friendly robot."}]');
114+
// The chat message stays as the only input message; the system instruction is split out above.
115+
expect(data[GEN_AI_INPUT_MESSAGES]).toBe('[{"role":"user","content":"Tell me a joke"}]');
116+
});
117+
118+
it('welds chats.create() config onto chat.sendMessageStream() spans', async () => {
119+
const endedSpans = setupClient();
120+
const instrumented = instrumentGoogleGenAIClient(createFakeClient());
121+
122+
const chat = instrumented.chats.create({ model: MODEL, config: CHAT_CONFIG }) as {
123+
sendMessageStream: (params: Record<string, unknown>) => Promise<AsyncIterable<unknown>>;
124+
};
125+
await drain(await chat.sendMessageStream({ message: 'Tell me a joke' }));
126+
127+
expect(endedSpans).toHaveLength(1);
128+
const data = spanToJSON(endedSpans[0]!).data;
129+
130+
expect(data[GEN_AI_OPERATION_NAME]).toBe('chat');
131+
expect(data[GEN_AI_REQUEST_MODEL]).toBe(MODEL);
132+
expect(data[GEN_AI_REQUEST_TEMPERATURE]).toBe(0.8);
133+
expect(data[GEN_AI_REQUEST_TOP_P]).toBe(0.9);
134+
expect(data[GEN_AI_REQUEST_TOP_K]).toBe(40);
135+
expect(data[GEN_AI_REQUEST_MAX_TOKENS]).toBe(150);
136+
expect(data[GEN_AI_REQUEST_FREQUENCY_PENALTY]).toBe(0.5);
137+
expect(data[GEN_AI_REQUEST_PRESENCE_PENALTY]).toBe(0.3);
138+
expect(data[GEN_AI_TOOL_DEFINITIONS]).toBe('[{"name":"getWeather"}]');
139+
expect(data[GEN_AI_SYSTEM_INSTRUCTIONS]).toBe('[{"type":"text","content":"You are a friendly robot."}]');
140+
});
141+
142+
it('lets a per-message config override the config from chats.create()', async () => {
143+
const endedSpans = setupClient();
144+
const instrumented = instrumentGoogleGenAIClient(createFakeClient());
145+
146+
const chat = instrumented.chats.create({ model: MODEL, config: CHAT_CONFIG }) as {
147+
sendMessage: (params: Record<string, unknown>) => Promise<unknown>;
148+
};
149+
await chat.sendMessage({ message: 'Tell me a joke', config: { temperature: 0.1 } });
150+
151+
const data = spanToJSON(endedSpans[0]!).data;
152+
// Per-call temperature wins; the untouched create-time values still come through.
153+
expect(data[GEN_AI_REQUEST_TEMPERATURE]).toBe(0.1);
154+
expect(data[GEN_AI_REQUEST_TOP_P]).toBe(0.9);
155+
expect(data[GEN_AI_REQUEST_MAX_TOKENS]).toBe(150);
156+
});
157+
158+
it('does not leak chat config onto models.generateContent spans', async () => {
159+
const endedSpans = setupClient();
160+
const client = {
161+
chats: { create: createFakeClient().chats.create },
162+
models: { generateContent: async (_params: Record<string, unknown>) => MOCK_RESPONSE },
163+
};
164+
const instrumented = instrumentGoogleGenAIClient(client);
165+
166+
// Create a chat (with config) first, then make an unrelated generateContent call.
167+
instrumented.chats.create({ model: MODEL, config: CHAT_CONFIG });
168+
await instrumented.models.generateContent({ model: 'gemini-1.5-flash' });
169+
170+
const genContentSpan = endedSpans.find(span => spanToJSON(span).data[GEN_AI_OPERATION_NAME] === 'generate_content');
171+
const data = spanToJSON(genContentSpan!).data;
172+
expect(data[GEN_AI_REQUEST_MODEL]).toBe('gemini-1.5-flash');
173+
expect(data[GEN_AI_REQUEST_TEMPERATURE]).toBeUndefined();
174+
expect(data[GEN_AI_SYSTEM_INSTRUCTIONS]).toBeUndefined();
175+
});
176+
});

0 commit comments

Comments
 (0)