Skip to content

Commit a97be1b

Browse files
nicohrubecclaude
andcommitted
feat(server-utils): Migrate Google GenAI integration to orchestrion
Adds an orchestrion (diagnostics-channel injection) based Google GenAI integration to server-utils, covering all APIs (generateContent, generateContentStream, embedContent, chat sendMessage/sendMessageStream) and both streaming and non-streaming mode. Leaves the core integration intact and only exports the utils the orchestrion integration needs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 14ed5f1 commit a97be1b

8 files changed

Lines changed: 246 additions & 6 deletions

File tree

dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import {
2222
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
2323
} from '../../../../../packages/core/src/tracing/ai/gen-ai-attributes';
2424
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
25+
import { isOrchestrionEnabled } from '../../../utils';
26+
27+
const EXPECTED_ORIGIN = isOrchestrionEnabled() ? 'auto.ai.orchestrion.google_genai' : 'auto.ai.google_genai';
2528

2629
describe('Google GenAI integration', () => {
2730
afterAll(() => {
@@ -47,7 +50,7 @@ describe('Google GenAI integration', () => {
4750
expect(chatSpan!.status).toBe('ok');
4851
expect(chatSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat');
4952
expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE].value).toBe('chat');
50-
expect(chatSpan!.attributes['sentry.origin'].value).toBe('auto.ai.google_genai');
53+
expect(chatSpan!.attributes['sentry.origin'].value).toBe(EXPECTED_ORIGIN);
5154
expect(chatSpan!.attributes[GEN_AI_SYSTEM_ATTRIBUTE].value).toBe('google_genai');
5255
expect(chatSpan!.attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE].value).toBe('gemini-1.5-pro');
5356
expect(chatSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE].value).toBe(8);
@@ -439,7 +442,7 @@ describe('Google GenAI integration', () => {
439442
for (const span of successfulSpans) {
440443
expect(span.attributes['sentry.op'].value).toBe('gen_ai.embeddings');
441444
expect(span.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE].value).toBe('embeddings');
442-
expect(span.attributes['sentry.origin'].value).toBe('auto.ai.google_genai');
445+
expect(span.attributes['sentry.origin'].value).toBe(EXPECTED_ORIGIN);
443446
expect(span.attributes[GEN_AI_SYSTEM_ATTRIBUTE].value).toBe('google_genai');
444447
expect(span.attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE].value).toBe('text-embedding-004');
445448
expect(span.attributes[GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE]).toBeUndefined();

packages/core/src/shared-exports.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,13 @@ export {
205205
} from './tracing/anthropic-ai';
206206
export { instrumentAsyncIterableStream, instrumentMessageStream } from './tracing/anthropic-ai/streaming';
207207
export { ANTHROPIC_AI_INTEGRATION_NAME } from './tracing/anthropic-ai/constants';
208-
export { instrumentGoogleGenAIClient } from './tracing/google-genai';
208+
export {
209+
instrumentGoogleGenAIClient,
210+
extractRequestAttributes as extractGoogleGenAIRequestAttributes,
211+
addPrivateRequestAttributes as addGoogleGenAIRequestAttributes,
212+
addResponseAttributes as addGoogleGenAIResponseAttributes,
213+
} from './tracing/google-genai';
214+
export { instrumentStream as instrumentGoogleGenAIStream } from './tracing/google-genai/streaming';
209215
export { GOOGLE_GENAI_INTEGRATION_NAME } from './tracing/google-genai/constants';
210216
export type { GoogleGenAIResponse } from './tracing/google-genai/types';
211217
export { createLangChainCallbackHandler, instrumentLangChainEmbeddings } from './tracing/langchain';

packages/core/src/tracing/google-genai/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ function extractConfigAttributes(config: Record<string, unknown>): Record<string
101101
* Extract request attributes from method arguments
102102
* Builds the base attributes for span creation including system info, model, and config
103103
*/
104-
function extractRequestAttributes(
104+
export function extractRequestAttributes(
105105
operationName: string,
106106
params?: Record<string, unknown>,
107107
context?: unknown,
@@ -140,7 +140,7 @@ function extractRequestAttributes(
140140
* This is only recorded if recordInputs is true.
141141
* Handles different parameter formats for different Google GenAI methods.
142142
*/
143-
function addPrivateRequestAttributes(
143+
export function addPrivateRequestAttributes(
144144
span: Span,
145145
params: Record<string, unknown>,
146146
isEmbeddings: boolean,
@@ -206,7 +206,7 @@ function addPrivateRequestAttributes(
206206
* Add response attributes from the Google GenAI response
207207
* @see https://github.com/googleapis/js-genai/blob/v1.19.0/src/types.ts#L2313
208208
*/
209-
function addResponseAttributes(span: Span, response: GoogleGenAIResponse, recordOutputs?: boolean): void {
209+
export function addResponseAttributes(span: Span, response: GoogleGenAIResponse, recordOutputs?: boolean): void {
210210
if (!response || typeof response !== 'object') return;
211211

212212
if (response.modelVersion) {
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import * as diagnosticsChannel from 'node:diagnostics_channel';
2+
import type { GoogleGenAIOptions, GoogleGenAIResponse, IntegrationFn, Span } from '@sentry/core';
3+
import {
4+
_INTERNAL_shouldSkipAiProviderWrapping,
5+
addGoogleGenAIRequestAttributes,
6+
addGoogleGenAIResponseAttributes,
7+
debug,
8+
defineIntegration,
9+
extractGoogleGenAIRequestAttributes,
10+
GEN_AI_REQUEST_MODEL_ATTRIBUTE,
11+
getActiveSpan,
12+
instrumentGoogleGenAIStream,
13+
resolveAIRecordingOptions,
14+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
15+
shouldEnableTruncation,
16+
spanToJSON,
17+
startInactiveSpan,
18+
waitForTracingChannelBinding,
19+
} from '@sentry/core';
20+
import { DEBUG_BUILD } from '../../debug-build';
21+
import { CHANNELS } from '../../orchestrion/channels';
22+
import { bindTracingChannelToSpan } from '../../tracing-channel';
23+
24+
// Same name as the OTel integration by design: when enabled, the OTel 'Google_GenAI'
25+
// integration is dropped from the default set (see the Node opt-in loader).
26+
const INTEGRATION_NAME = 'Google_GenAI' as const;
27+
28+
// Distinct from the proxy's `auto.ai.google_genai` so spans from the orchestrion path
29+
// are attributable separately from the OTel/proxy one.
30+
const ORIGIN = 'auto.ai.orchestrion.google_genai';
31+
32+
const CHAT_OP = 'gen_ai.chat';
33+
34+
// `stream` determines how the span is ended. `chat` and `generate-content` both carry streaming and
35+
// non-streaming calls on one channel, so their mode is resolved at runtime from the result shape.
36+
const INSTRUMENTED_CHANNELS = [
37+
{ channel: CHANNELS.GOOGLE_GENAI_GENERATE_CONTENT, operation: 'generate_content', stream: 'async-iterable' },
38+
{ channel: CHANNELS.GOOGLE_GENAI_EMBED_CONTENT, operation: 'embeddings', stream: 'none' },
39+
{ channel: CHANNELS.GOOGLE_GENAI_CHAT, operation: 'chat', stream: 'async-iterable' },
40+
] as const;
41+
42+
type StreamMode = (typeof INSTRUMENTED_CHANNELS)[number]['stream'];
43+
44+
interface GoogleGenAIChannelContext {
45+
arguments: unknown[];
46+
// The transform stashes the call's `this` here, which chat methods need since the model lives on
47+
// the `Chat` instance (`this.model`/`this.modelVersion`), not in the call arguments.
48+
self?: unknown;
49+
result?: unknown;
50+
}
51+
52+
let subscribed = false;
53+
54+
const _googleGenAIChannelIntegration = ((options: GoogleGenAIOptions = {}) => {
55+
return {
56+
name: INTEGRATION_NAME,
57+
setupOnce() {
58+
// tracingChannel is unavailable before Node 18.19 and prevent double-subscribe
59+
if (!diagnosticsChannel.tracingChannel || subscribed) {
60+
return;
61+
}
62+
subscribed = true;
63+
64+
// `bindTracingChannelToSpan` needs the async-context binding that `initOpenTelemetry()` registers
65+
// after `setupOnce` runs, so wait for it before subscribing.
66+
waitForTracingChannelBinding(() => {
67+
for (const { channel, operation, stream } of INSTRUMENTED_CHANNELS) {
68+
DEBUG_BUILD && debug.log(`[orchestrion:google-genai] subscribing to channel "${channel}"`);
69+
bindTracingChannelToSpan(
70+
diagnosticsChannel.tracingChannel<GoogleGenAIChannelContext>(channel),
71+
data => createGenAiSpan(data, operation, options),
72+
{
73+
beforeSpanEnd: (span, data) => {
74+
// Embeddings responses carry no content attributes, mirroring the OTel integration.
75+
if (operation !== 'embeddings') {
76+
addGoogleGenAIResponseAttributes(
77+
span,
78+
data.result as GoogleGenAIResponse,
79+
resolveAIRecordingOptions(options).recordOutputs,
80+
);
81+
}
82+
},
83+
deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, stream, options),
84+
},
85+
);
86+
}
87+
});
88+
},
89+
};
90+
}) satisfies IntegrationFn;
91+
92+
/**
93+
* Build the span for an instrumented call.
94+
* Returning `undefined` opts the payload out so no span is opened.
95+
*/
96+
function createGenAiSpan(
97+
data: GoogleGenAIChannelContext,
98+
operation: string,
99+
options: GoogleGenAIOptions,
100+
): Span | undefined {
101+
const args = data.arguments ?? [];
102+
103+
// When LangChain (or another provider) is driving the SDK, it records the spans itself and marks this
104+
// provider as skipped — mirror the OTel integration and don't double-instrument.
105+
if (_INTERNAL_shouldSkipAiProviderWrapping(INTEGRATION_NAME)) {
106+
return undefined;
107+
}
108+
109+
// `chat.sendMessage()`/`sendMessageStream()` internally call `Models.generateContent(Stream)`, which
110+
// publishes the `generate-content` channel while the chat span is active. Skip that nested event so a
111+
// chat call yields a single `gen_ai.chat` span instead of a chat span wrapping a generate_content one.
112+
if (operation !== 'chat') {
113+
const activeSpan = getActiveSpan();
114+
if (activeSpan) {
115+
const { op, origin } = spanToJSON(activeSpan);
116+
if (origin === ORIGIN && op === CHAT_OP) {
117+
return undefined;
118+
}
119+
}
120+
}
121+
122+
const params = typeof args[0] === 'object' && args[0] !== null ? (args[0] as Record<string, unknown>) : undefined;
123+
124+
const { recordInputs } = resolveAIRecordingOptions(options);
125+
const enableTruncation = shouldEnableTruncation(options.enableTruncation);
126+
127+
const attributes = extractGoogleGenAIRequestAttributes(operation, params, data.self);
128+
const model = (attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] as string) || 'unknown';
129+
attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = ORIGIN;
130+
131+
const span = startInactiveSpan({
132+
name: `${operation} ${model}`,
133+
op: `gen_ai.${operation}`,
134+
attributes,
135+
});
136+
137+
if (recordInputs && params) {
138+
addGoogleGenAIRequestAttributes(span, params, operation === 'embeddings', enableTruncation);
139+
}
140+
141+
return span;
142+
}
143+
144+
type AsyncIterableStream = { [Symbol.asyncIterator]: () => AsyncIterator<unknown> };
145+
146+
function isAsyncIterable(value: unknown): value is AsyncIterableStream {
147+
return !!value && typeof (value as AsyncIterableStream)[Symbol.asyncIterator] === 'function';
148+
}
149+
150+
/**
151+
* Hand span-ending ownership to a streamed result: returns `true` to skip the normal `beforeSpanEnd`,
152+
* `false` for non-streaming results (which end via `beforeSpanEnd`).
153+
*
154+
* `generateContentStream`/`sendMessageStream` resolve to an async iterable, while `generateContent`/
155+
* `sendMessage` resolve to a plain response — so the runtime `isAsyncIterable` check picks the right
156+
* path even though both share a channel. For streams we patch `result[Symbol.asyncIterator]` in place
157+
* so `instrumentGoogleGenAIStream` ends the span when iteration finishes.
158+
*/
159+
function wrapStreamResult(
160+
span: Span,
161+
data: GoogleGenAIChannelContext,
162+
stream: StreamMode,
163+
options: GoogleGenAIOptions,
164+
): boolean {
165+
const { recordOutputs } = resolveAIRecordingOptions(options);
166+
const result = data.result;
167+
168+
if (stream === 'async-iterable' && isAsyncIterable(result)) {
169+
const iterate = result[Symbol.asyncIterator].bind(result);
170+
const instrumented = instrumentGoogleGenAIStream({ [Symbol.asyncIterator]: iterate }, span, !!recordOutputs);
171+
result[Symbol.asyncIterator] = () => instrumented;
172+
return true;
173+
}
174+
175+
return false;
176+
}
177+
178+
/**
179+
* EXPERIMENTAL — orchestrion-driven Google GenAI integration. Subscribes to the
180+
* `orchestrion:@google/genai:*` diagnostics_channels injected into the SDK's `Models`
181+
* (`generateContent`/`generateContentStream`/`embedContent`) and `Chat`
182+
* (`sendMessage`/`sendMessageStream`) methods, so it requires the orchestrion runtime hook or
183+
* bundler plugin.
184+
*/
185+
export const googleGenAIChannelIntegration = defineIntegration(_googleGenAIChannelIntegration);

packages/server-utils/src/orchestrion/channels.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ioredisChannels } from './config/ioredis';
44
import { pgChannels } from './config/pg';
55
import { openaiChannels } from './config/openai';
66
import { anthropicAiChannels } from './config/anthropic-ai';
7+
import { googleGenAiChannels } from './config/google-genai';
78
import { vercelAiChannels } from './config/vercel-ai';
89

910
/**
@@ -26,6 +27,7 @@ export const CHANNELS = {
2627
...pgChannels,
2728
...openaiChannels,
2829
...anthropicAiChannels,
30+
...googleGenAiChannels,
2931
...vercelAiChannels,
3032
} as const;
3133

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { InstrumentationConfig } from '@apm-js-collab/code-transformer';
2+
3+
export const googleGenAiConfig = [
4+
// `@google/genai` ships a single bundled file per format (`dist/node/index.{mjs,cjs}`), so all
5+
// classes live in one file and the matcher compares `filePath` exactly, hence one entry per format.
6+
// `Models.generateContent`/`generateContentStream` are arrow-function properties assigned in the
7+
// constructor (`this.generateContent = async (params) => {...}`), NOT class methods, so they need
8+
// `expressionName` (which matches `AssignmentExpression[left.property.name=...] > ArrowFunctionExpression`),
9+
// not `className`/`methodName`. Both return a promise (the stream variant resolves to the async
10+
// iterable), so `Auto` resolves to `wrapPromise` and stores the resolved value on `ctx.result`.
11+
...['dist/node/index.mjs', 'dist/node/index.cjs'].flatMap(filePath =>
12+
(['generateContent', 'generateContentStream'] as const).map(expressionName => ({
13+
channelName: 'generate-content',
14+
module: { name: '@google/genai', versionRange: '>=0.10.0 <2', filePath },
15+
functionQuery: { expressionName, kind: 'Auto' as const },
16+
})),
17+
),
18+
// `embedContent` and the `Chat` methods ARE real `async` class methods.
19+
...['dist/node/index.mjs', 'dist/node/index.cjs'].map(filePath => ({
20+
channelName: 'embed-content',
21+
module: { name: '@google/genai', versionRange: '>=0.10.0 <2', filePath },
22+
functionQuery: { className: 'Models', methodName: 'embedContent', kind: 'Auto' as const },
23+
})),
24+
// `sendMessage`/`sendMessageStream` internally delegate to `Models.generateContent(Stream)`; the
25+
// subscriber suppresses that nested `generate-content` event so a chat call yields a single span.
26+
...['dist/node/index.mjs', 'dist/node/index.cjs'].flatMap(filePath =>
27+
(['sendMessage', 'sendMessageStream'] as const).map(methodName => ({
28+
channelName: 'chat',
29+
module: { name: '@google/genai', versionRange: '>=0.10.0 <2', filePath },
30+
functionQuery: { className: 'Chat', methodName, kind: 'Auto' as const },
31+
})),
32+
),
33+
] satisfies InstrumentationConfig[];
34+
35+
export const googleGenAiChannels = {
36+
GOOGLE_GENAI_GENERATE_CONTENT: 'orchestrion:@google/genai:generate-content',
37+
GOOGLE_GENAI_EMBED_CONTENT: 'orchestrion:@google/genai:embed-content',
38+
GOOGLE_GENAI_CHAT: 'orchestrion:@google/genai:chat',
39+
} as const;

packages/server-utils/src/orchestrion/config/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ioredisConfig } from './ioredis';
55
import { openaiConfig } from './openai';
66
import { pgConfig } from './pg';
77
import { anthropicAiConfig } from './anthropic-ai';
8+
import { googleGenAiConfig } from './google-genai';
89
import { vercelAiConfig } from './vercel-ai';
910

1011
export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [
@@ -14,6 +15,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [
1415
...openaiConfig,
1516
...pgConfig,
1617
...anthropicAiConfig,
18+
...googleGenAiConfig,
1719
...vercelAiConfig,
1820
];
1921

packages/server-utils/src/orchestrion/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { anthropicChannelIntegration } from '../integrations/tracing-channel/anthropic';
2+
import { googleGenAIChannelIntegration } from '../integrations/tracing-channel/google-genai';
23
import { ioredisChannelIntegration } from '../integrations/tracing-channel/ioredis';
34
import { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer';
45
import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql';
@@ -9,6 +10,7 @@ import { vercelAiChannelIntegration } from '../integrations/tracing-channel/verc
910
export { detectOrchestrionSetup, isOrchestrionInjected } from './detect';
1011
export {
1112
anthropicChannelIntegration,
13+
googleGenAIChannelIntegration,
1214
ioredisChannelIntegration,
1315
lruMemoizerChannelIntegration,
1416
mysqlChannelIntegration,
@@ -37,5 +39,6 @@ export const channelIntegrations = {
3739
lruMemoizerIntegration: lruMemoizerChannelIntegration,
3840
openaiIntegration: openaiChannelIntegration,
3941
anthropicIntegration: anthropicChannelIntegration,
42+
googleGenAIIntegration: googleGenAIChannelIntegration,
4043
vercelAiIntegration: vercelAiChannelIntegration,
4144
} as const;

0 commit comments

Comments
 (0)