We'd been running a support chatbot backed by Gemini for about six weeks when someone on the team asked a simple question in standup: "why does temperature show up as blank on half our AI spans in Sentry?" I didn't have a good answer, because I'd wired up googleGenAIIntegration myself two sprints earlier and assumed it just worked. It did, for the one-shot generateContent() calls we used in the summarization job. It did not, for the multi-turn chat sessions that made up 90% of our actual Gemini traffic.
That gap sat there for weeks because nobody was actively watching the AI observability dashboard until we needed to debug a customer complaint about the bot ignoring its system instructions. We pulled up the trace in Sentry expecting to see the systemInstruction, temperature, and maxOutputTokens we'd configured for that chat session. Instead we got a span named chat unknown with a request model of undefined and no generation config at all. The request had clearly gone through — the response was there, tokens were counted, latency was tracked — but every attribute that would have told us what config was actually in play was just gone.
This post is the walkthrough of how I tracked that down inside sentry-javascript's Google GenAI instrumentation, why it only broke for chat sessions and not single-shot generation calls, and the small but slightly annoying fix that got it working again.
How Sentry actually instruments the Google GenAI SDK
The googleGenAIIntegration in @sentry/node works by monkey-patching methods on the @google/genai client after you construct it, either automatically (Node runtime, enabled by default) or manually via Sentry.instrumentGoogleGenAIClient() if you want more control. It wraps calls like models.generateContent() and models.generateContentStream(), starts a span around each one, and fills that span with attributes following the OpenTelemetry generative-AI semantic conventions — things like gen_ai.request.model, gen_ai.request.temperature, and, if you opt in with recordInputs/recordOutputs, the actual prompt and completion text.
For single-shot calls this is straightforward because everything the span needs — the model name, the generation config, the contents — arrives as a single argument object on the call you're wrapping. The instrumentation reads that argument, pulls out the fields it cares about, and attaches them to the span before calling through to the real SDK method. There's no state to track between calls because there's no persistent object; every call is self-contained.
Chat sessions don't work that way. The @google/genai SDK gives you a stateful Chat object, and you configure it once:
import { GoogleGenAI } from '@google/genai';
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
const chat = ai.chats.create({
model: 'gemini-2.0-flash',
config: {
temperature: 0.4,
maxOutputTokens: 512,
systemInstruction: 'You are a support assistant for Acme Billing. Never discuss refunds outside policy.',
},
});
const response = await chat.sendMessage({ message: userQuestion });
Notice that sendMessage() only takes the message. The model name and the generation config were handed over once, back when chats.create() ran, and the Chat instance holds onto them internally for the lifetime of the conversation. That distinction turned out to be exactly where things fell apart.
Where the chat's generationConfig actually lives
Once I noticed the pattern — one-shot calls fine, chat calls broken — the shape of the bug became obvious in hindsight. The instrumentation for sendMessage() was built the same way as the instrumentation for generateContent(): read the arguments of the call you're wrapping, pull the config off of them, done. But sendMessage({ message }) doesn't have a config argument to pull from. It never did. The config lives one level up, on the Chat object itself, set at chats.create() time and never passed again.
So the wrapper around sendMessage was dutifully looking for params.config.temperature on an object shaped like { message: "..." }, finding nothing, and setting the attribute to undefined. Sentry doesn't send undefined attributes, so they just silently vanished from the span. Nothing threw, nothing logged a warning — it just looked like the config had never been captured in the first place, which made this particularly annoying to track down from the outside. There was no error to search for, just an absence.
gen_ai.request.model, gen_ai.request.temperature, and so on — come from the OpenTelemetry generative-AI semantic conventions, not something Sentry invented. That's worth knowing because it means the same attribute names show up whether the span came from Sentry's instrumentation, a raw OTel exporter, or a different vendor entirely, which makes cross-tool debugging a lot less painful.What made it worse is that the model name was missing too, for the same reason, so the span title itself degraded to something generic instead of showing which Gemini model actually served the request. On a dashboard with several chat flows running different models, that's the difference between "immediately obvious which service is slow" and "grep the logs and hope."
Tracing it through the instrumentation code
I started by cloning sentry-javascript locally and running our chatbot against a linked build so I could add my own console.log calls inside the integration without waiting on a published release. That's usually my first move when a third-party SDK is doing something unexplained — reading the source is fine, but watching the actual objects flow through it in a real request tells you things a static read never will.
Here's roughly what the broken wrapper looked like, simplified down to the part that mattered:
function instrumentSendMessage(chat, integrationOptions) {
const originalSendMessage = chat.sendMessage.bind(chat);
chat.sendMessage = async function (params) {
return startSpan(
{
name: 'chat gemini',
op: 'gen_ai.chat',
attributes: buildRequestAttributes(params),
},
() => originalSendMessage(params),
);
};
return chat;
}
function buildRequestAttributes(params) {
return {
'gen_ai.request.model': params.model,
'gen_ai.request.temperature': params.config?.temperature,
'gen_ai.request.max_tokens': params.config?.maxOutputTokens,
};
}
Every field in buildRequestAttributes is reading off params, which is just the argument passed to sendMessage — and as we'd already established, that argument is only ever { message }. The fix isn't in buildRequestAttributes at all; it's upstream, in how the wrapper gets its hands on the config in the first place. The config has to be captured when chats.create() runs, then handed down to the sendMessage wrapper somehow, since by the time sendMessage is called that context is gone unless something remembered it.
Here's the version that actually works, which stores the creation-time config in a WeakMap keyed by the chat instance so it survives across every subsequent sendMessage call on that same chat:
const chatConfigs = new WeakMap();
function instrumentChatsCreate(chats, integrationOptions) {
const originalCreate = chats.create.bind(chats);
chats.create = function (createParams) {
const chat = originalCreate(createParams);
chatConfigs.set(chat, createParams);
return instrumentSendMessage(chat, integrationOptions);
};
return chats;
}
function instrumentSendMessage(chat, integrationOptions) {
const originalSendMessage = chat.sendMessage.bind(chat);
chat.sendMessage = async function (params) {
const createParams = chatConfigs.get(chat) ?? {};
return startSpan(
{
name: `chat ${createParams.model ?? 'unknown'}`,
op: 'gen_ai.chat',
attributes: buildRequestAttributes(createParams),
},
() => originalSendMessage(params),
);
};
return chat;
}
function buildRequestAttributes(createParams) {
const config = createParams.config ?? {};
return {
'gen_ai.request.model': createParams.model,
'gen_ai.request.temperature': config.temperature,
'gen_ai.request.max_tokens': config.maxOutputTokens,
'gen_ai.request.top_p': config.topP,
};
}
The real fix that eventually landed upstream is more involved than this — it also has to handle sendMessageStream(), tool-calling config, and cases where recordInputs is off and we shouldn't be capturing the raw system instruction text at all — but this is the core of it. Capture the config where it's actually created, not where you happen to be patching a method.
The fix, and what it cost us
Patching this ourselves ahead of an upstream release meant maintaining a small local override of the integration for about two weeks, which is never fun but was manageable since it was maybe forty lines of code. What I didn't love was the WeakMap approach once I sat with it longer. It works, but it means the instrumentation now has to track object identity across two separate patched methods, and if the SDK ever changes so that chats.create() returns a new object on every call internally, or wraps the chat instance again itself, the map lookup silently misses and you're back to blank attributes with no error to tell you why.
I'd rather this kind of state lived on the object itself as a non-enumerable property than in an external map, if I'm honest, because it's easier to inspect in a debugger and doesn't depend on reference equality holding up across SDK versions. That's a design opinion, not a bug report, and I know why the Sentry team didn't go that route — mutating a third-party class instance has its own risks if the SDK ever adds a property with the same name. There's no clean answer here, just a tradeoff between "state that's easy to inspect but might collide" and "state that's hidden but collision-proof."
chat.config.temperature = 0.9 somewhere after creation instead of setting it up front, a WeakMap captured at chats.create() time will still report the original value on every span, because it's holding a reference to the object you handed it — not a snapshot. If the object is mutated in place, the map reflects the mutation too, but if it's replaced wholesale, you're stuck with stale data.Verifying the fix actually held
Once the patched integration was running, I didn't just eyeball one trace and call it done — chat instrumentation bugs like this have a habit of looking fixed for the happy path and then falling over the moment someone calls sendMessageStream() instead of sendMessage(), or creates a chat with no config object at all because they're relying on SDK defaults. So I ran through a short checklist against a staging build before I trusted it in production:
- Send a message on a chat created with a full
configobject and confirmgen_ai.request.model,temperature, andmax_tokensall show up on the span. - Send a message on a chat created with no config at all and confirm the span doesn't throw, and just omits the attributes instead of setting them to the literal string "undefined".
- Call
sendMessageStream()on the same chat and confirm the streamed span picks up the same creation-time config, not just the non-streaming path. - Send two messages on the same chat instance and confirm both spans carry the config, not just the first one.
- Turn
recordInputsoff and confirm the system instruction text itself doesn't leak into span attributes, even though the model name and temperature still should.
That last check mattered more than I expected going in. It's easy to fix "the config is missing" and accidentally overcorrect into "now we're capturing the full system prompt on every span whether or not the team opted into that," which is its own problem if that system prompt contains anything sensitive. Sentry's recordInputs/recordOutputs flags exist specifically so teams can turn off prompt and completion capture while keeping structural metadata like model name and temperature, and it's worth testing that boundary explicitly rather than assuming it still holds after you've touched the code path.
What I'd check if you're seeing the same thing
If you're staring at a Gemini chat span in Sentry with a blank model name or missing temperature, the first thing to check is your SDK version — the Google GenAI integration is relatively new, and the chat-specific config capture I've described here was a gap in early releases rather than a permanent limitation. Bumping @sentry/node (or whichever platform package you're on) to a current version is worth trying before you reach for a custom patch like the one above.
If you're still missing config after upgrading, the next thing I'd check is whether you're constructing the chat through ai.chats.create() directly versus wrapping it yourself somewhere in a factory or a dependency-injection container. Any extra layer between the raw SDK client and where Sentry's instrumentation gets applied is a place where the patched methods can end up pointing at the wrong object, and you'll see the same symptom — blank attributes — for a completely different reason than the one in this post.
Closing thoughts
The annoying thing about this class of bug is that it doesn't fail loudly. Nothing in our test suite caught it because our tests asserted that spans existed and had reasonable status codes, not that every attribute we cared about was actually populated. I've since added a small assertion to our AI observability smoke test that checks a known chat trace for a non-empty gen_ai.request.temperature, specifically because "the span exists" and "the span has the data you actually need to debug with" turned out to be two very different bars to clear.
If you're instrumenting anything stateful — a chat session, a pooled connection, a long-lived worker — go check whether your tracing wrapper is reading configuration from the call it's patching or from the object that call belongs to. Those are easy to conflate when you're copying a pattern from a stateless call that happened to work fine, and the failure mode is exactly the quiet, non-throwing kind that sits in production for weeks before anyone notices the dashboard's been lying to them.

Comments
No comments yet — be the first to share your thoughts.