DEV Community

Cover image for One tool call, counted twice: a Google GenAI streaming double-dip in Sentry's JS SDK
Asuran
Asuran

Posted on

One tool call, counted twice: a Google GenAI streaming double-dip in Sentry's JS SDK

Summer Bug Smash: Clear the Lineup 🐛🛹

This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.

The bug

When you call @google/genai in streaming mode and the model asks to run a tool, Sentry's JavaScript SDK records that tool call to the span twice. One tool call in, two entries out.

The attribute that carries them is gen_ai.response.tool_calls. It should hold one object per call. For a single streamed controlLight call it held two. Worse, the two did not even agree on their shape. Here is a real capture, which I come back to at the end:

[
  {"id":"call_2079699","args":{"colorTemperature":"warm","brightness":30},"name":"controlLight"},
  {"type":"function","id":"call_2079699","name":"controlLight","arguments":{"colorTemperature":"warm","brightness":30}}
]
Enter fullscreen mode Exit fullscreen mode

Same id, same call, listed twice. One entry keys the parameters under args, the other under arguments. Anything reading this later sees two tool invocations where the model made one.

Following the value

The streaming instrumentation lives in packages/server-utils/src/ai/google-genai/streaming.ts. Every chunk of the stream runs through handleCandidateContent. That function wrote tool calls from two places:

function handleCandidateContent(chunk, state, recordOutputs) {
  if (Array.isArray(chunk.functionCalls)) {
    state.toolCalls.push(...chunk.functionCalls);          // push #1
  }

  for (const candidate of chunk.candidates ?? []) {
    // ...finish reasons...
    for (const part of candidate?.content?.parts ?? []) {
      if (recordOutputs && part.text) state.responseTexts.push(part.text);
      if (part.functionCall) {
        state.toolCalls.push({                              // push #2
          type: 'function',
          id: part.functionCall.id,
          name: part.functionCall.name,
          arguments: part.functionCall.args,
        });
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Push #1 spreads chunk.functionCalls into the accumulator. Push #2 walks candidate.content.parts and pushes every functionCall it finds. They look like two different sources. They are not.

chunk.functionCalls is a getter on the @google/genai response object. Here is what it actually does inside the SDK:

get functionCalls() {
  // ...
  const functionCalls = this.candidates?.[0]?.content?.parts
    ?.filter(part => part.functionCall)
    .map(part => part.functionCall)
    .filter(fc => fc !== undefined);
  // ...
  return functionCalls;
}
Enter fullscreen mode Exit fullscreen mode

It reads the exact same candidates[].content.parts that push #2 iterates, filters for functionCall and hands them back. So both pushes are pulling the identical tool call out of the identical parts array. The first keeps the SDK-native shape { id, name, args }. The second rebuilds it as { type, id, name, arguments }. Two views of one thing, both written down.

The non-streaming path never had this problem. Its addResponseAttributes reads tool calls once, straight from response.functionCalls, then emits a single entry per call. So streaming and non-streaming disagreed on both the count and the key name for the very same response.

The fix

There is a single source of truth here: the SDK accessor the non-streaming path already trusts. Take the tool calls only from chunk.functionCalls and drop the second push:

function handleCandidateContent(chunk, state, recordOutputs) {
  // `chunk.functionCalls` is the SDK accessor over the candidate's function-call parts,
  // so it is the single source of truth for tool calls. Also reading `part.functionCall`
  // from those same parts would record every call twice, with two different shapes. This
  // mirrors the non-streaming path, which likewise takes tool calls from `response.functionCalls`.
  if (Array.isArray(chunk.functionCalls)) {
    state.toolCalls.push(...chunk.functionCalls);
  }

  for (const candidate of chunk.candidates ?? []) {
    // ...finish reasons...
    for (const part of candidate?.content?.parts ?? []) {
      if (recordOutputs && part.text) state.responseTexts.push(part.text);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

I kept chunk.functionCalls rather than the parts loop on purpose. Both code paths now share one source and emit the SDK-native shape, so streaming output matches non-streaming output byte for byte. That is also how the OpenAI and Anthropic integrations in this repo are built: their streaming paths reconstruct the exact tool-call shape their non-streaming paths produce. The deprecated gen_ai.response.tool_calls example happens to show { name, arguments }, but no provider integration normalizes to that literally (Anthropic keeps input, OpenAI nests under function), so consistency within a provider was the property worth protecting.

Proving it on real traffic

I did not want to trust a mock for this. I made one real streaming call to gemini-3.6-flash with a controlLight tool, captured the actual chunks the model sent back (response id IuF-auTiDpW2g8UPnNDKsAI), then replayed that identical response through the instrumentation twice: once against develop, once with the fix.

Before, gen_ai.response.tool_calls:

[{"id":"call_2079699","args":{"colorTemperature":"warm","brightness":30},"name":"controlLight"},{"type":"function","id":"call_2079699","name":"controlLight","arguments":{"colorTemperature":"warm","brightness":30}}]
Enter fullscreen mode Exit fullscreen mode

After:

[{"id":"call_2079699","args":{"colorTemperature":"warm","brightness":30},"name":"controlLight"}]
Enter fullscreen mode Exit fullscreen mode

One real call, one entry.

A unit test covers the single-call case, several calls arriving across chunks and the recordOutputs: false case, then checks that the non-streaming path still records one entry in the same shape. It fails on develop with two entries and passes with the fix. Locally the full @sentry/server-utils unit suite is green (377 passing), with oxlint, oxfmt --check and the TypeScript build all clean.

The change is open as getsentry/sentry-javascript#23432.


AI assistance (Claude, Anthropic) was used in developing this change. The design, review and verification were done by me. I verified it locally before submitting: the new and existing @sentry/server-utils unit tests, oxlint, oxfmt --check and the TypeScript build all pass, plus the real gemini-3.6-flash run captured before and after.

Top comments (0)