-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(server-utils): Migrate Google GenAI integration to orchestrion #21970
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
170 changes: 170 additions & 0 deletions
170
packages/server-utils/src/integrations/tracing-channel/google-genai.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| import * as diagnosticsChannel from 'node:diagnostics_channel'; | ||
| import type { GoogleGenAIOptions, GoogleGenAIResponse, IntegrationFn, Span } from '@sentry/core'; | ||
| import { | ||
| _INTERNAL_shouldSkipAiProviderWrapping, | ||
| addGoogleGenAIRequestAttributes, | ||
| addGoogleGenAIResponseAttributes, | ||
| debug, | ||
| defineIntegration, | ||
| extractGoogleGenAIRequestAttributes, | ||
| GEN_AI_REQUEST_MODEL_ATTRIBUTE, | ||
| getActiveSpan, | ||
| instrumentGoogleGenAIStream, | ||
| resolveAIRecordingOptions, | ||
| SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, | ||
| shouldEnableTruncation, | ||
| spanToJSON, | ||
| startInactiveSpan, | ||
| waitForTracingChannelBinding, | ||
| } from '@sentry/core'; | ||
| import { DEBUG_BUILD } from '../../debug-build'; | ||
| import { CHANNELS } from '../../orchestrion/channels'; | ||
| import { bindTracingChannelToSpan } from '../../tracing-channel'; | ||
|
|
||
| // Same name as the OTel integration by design: when enabled, the OTel 'Google_GenAI' | ||
| // integration is dropped from the default set (see the Node opt-in loader). | ||
| const INTEGRATION_NAME = 'Google_GenAI' as const; | ||
|
|
||
| // Distinct from the proxy's `auto.ai.google_genai` so spans from the orchestrion path | ||
| // are attributable separately from the OTel/proxy one. | ||
| const ORIGIN = 'auto.ai.orchestrion.google_genai'; | ||
|
|
||
| // Each instrumented method maps to the gen_ai operation its span reports. | ||
| const INSTRUMENTED_CHANNELS = [ | ||
| { channel: CHANNELS.GOOGLE_GENAI_GENERATE_CONTENT, operation: 'generate_content' }, | ||
| { channel: CHANNELS.GOOGLE_GENAI_EMBED_CONTENT, operation: 'embeddings' }, | ||
| { channel: CHANNELS.GOOGLE_GENAI_CHAT, operation: 'chat' }, | ||
| ] as const; | ||
|
|
||
| interface GoogleGenAIChannelContext { | ||
| arguments: unknown[]; | ||
| // The transform stashes the call's `this` here, which chat methods need since the model lives on | ||
| // the `Chat` instance (`this.model`/`this.modelVersion`), not in the call arguments. | ||
| self?: unknown; | ||
| result?: unknown; | ||
| } | ||
|
|
||
| let subscribed = false; | ||
|
|
||
| const _googleGenAIChannelIntegration = ((options: GoogleGenAIOptions = {}) => { | ||
| return { | ||
| name: INTEGRATION_NAME, | ||
| setupOnce() { | ||
| // `tracingChannel` is unavailable before Node 18.19, and a second `init()` would double-subscribe. | ||
| if (!diagnosticsChannel.tracingChannel || subscribed) { | ||
| return; | ||
| } | ||
| subscribed = true; | ||
|
|
||
| // `bindTracingChannelToSpan` needs the async-context binding that `initOpenTelemetry()` registers | ||
| // after `setupOnce` runs, so wait for it before subscribing. | ||
| waitForTracingChannelBinding(() => { | ||
| for (const { channel, operation } of INSTRUMENTED_CHANNELS) { | ||
| DEBUG_BUILD && debug.log(`[orchestrion:google-genai] subscribing to channel "${channel}"`); | ||
| bindTracingChannelToSpan( | ||
| diagnosticsChannel.tracingChannel<GoogleGenAIChannelContext>(channel), | ||
| data => createGenAiSpan(data, operation, options), | ||
| { | ||
| beforeSpanEnd: (span, data) => { | ||
| // Embeddings responses carry no content attributes. | ||
| if (operation !== 'embeddings') { | ||
| addGoogleGenAIResponseAttributes( | ||
| span, | ||
| data.result as GoogleGenAIResponse, | ||
| resolveAIRecordingOptions(options).recordOutputs, | ||
| ); | ||
| } | ||
| }, | ||
| deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, options), | ||
| }, | ||
| ); | ||
| } | ||
| }); | ||
| }, | ||
| }; | ||
| }) satisfies IntegrationFn; | ||
|
|
||
| /** | ||
| * Build the span for an instrumented call. | ||
| * Returning `undefined` opts the payload out so no span is opened. | ||
| */ | ||
| function createGenAiSpan( | ||
| data: GoogleGenAIChannelContext, | ||
| operation: string, | ||
| options: GoogleGenAIOptions, | ||
| ): Span | undefined { | ||
| // When another provider (e.g. LangChain) is driving the SDK, it records the spans itself and marks this | ||
| // provider as skipped; skip here to avoid double spans. | ||
| if (_INTERNAL_shouldSkipAiProviderWrapping(INTEGRATION_NAME)) { | ||
| return undefined; | ||
| } | ||
|
|
||
| // `chat.sendMessage()`/`sendMessageStream()` internally call `Models.generateContent(Stream)`, which | ||
| // publishes the `generate-content` channel while the chat span is active. Skip that nested event so a | ||
| // chat call yields a single `gen_ai.chat` span instead of a chat span wrapping a generate_content one. | ||
| if (operation !== 'chat') { | ||
| const activeSpan = getActiveSpan(); | ||
| if (activeSpan) { | ||
| const { op, origin } = spanToJSON(activeSpan); | ||
| if (origin === ORIGIN && op === 'gen_ai.chat') { | ||
| return undefined; | ||
| } | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| const args = data.arguments ?? []; | ||
| const params = args[0] as Record<string, unknown> | undefined; | ||
|
|
||
| const { recordInputs } = resolveAIRecordingOptions(options); | ||
| const enableTruncation = shouldEnableTruncation(options.enableTruncation); | ||
|
|
||
| const attributes = extractGoogleGenAIRequestAttributes(operation, params, data.self); | ||
| const model = (attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] as string) || 'unknown'; | ||
| attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = ORIGIN; | ||
|
|
||
| const span = startInactiveSpan({ | ||
| name: `${operation} ${model}`, | ||
| op: `gen_ai.${operation}`, | ||
| attributes, | ||
| }); | ||
|
|
||
| if (recordInputs && params) { | ||
| addGoogleGenAIRequestAttributes(span, params, operation, enableTruncation); | ||
| } | ||
|
|
||
| return span; | ||
| } | ||
|
|
||
| type AsyncIterableStream = { [Symbol.asyncIterator]: () => AsyncIterator<unknown> }; | ||
|
|
||
| function isAsyncIterable(value: unknown): value is AsyncIterableStream { | ||
| return !!value && typeof (value as AsyncIterableStream)[Symbol.asyncIterator] === 'function'; | ||
| } | ||
|
|
||
| /** | ||
| * Only the streaming methods (`generateContentStream`/`sendMessageStream`) resolve to an async iterable. | ||
| * For a stream we patch `result[Symbol.asyncIterator]` in place so `instrumentGoogleGenAIStream` ends the | ||
| * span when iteration finishes. | ||
| */ | ||
| function wrapStreamResult(span: Span, data: GoogleGenAIChannelContext, options: GoogleGenAIOptions): boolean { | ||
| const result = data.result; | ||
| if (!isAsyncIterable(result)) { | ||
| return false; | ||
| } | ||
|
|
||
| const { recordOutputs } = resolveAIRecordingOptions(options); | ||
| const iterate = result[Symbol.asyncIterator].bind(result); | ||
| const instrumented = instrumentGoogleGenAIStream({ [Symbol.asyncIterator]: iterate }, span, recordOutputs ?? false); | ||
| result[Symbol.asyncIterator] = () => instrumented; | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * EXPERIMENTAL — orchestrion-driven Google GenAI integration. Subscribes to the | ||
| * `orchestrion:@google/genai:*` diagnostics_channels injected into the SDK's `Models` | ||
| * (`generateContent`/`generateContentStream`/`embedContent`) and `Chat` | ||
| * (`sendMessage`/`sendMessageStream`) methods, so it requires the orchestrion runtime hook or | ||
| * bundler plugin. | ||
| */ | ||
| export const googleGenAIChannelIntegration = defineIntegration(_googleGenAIChannelIntegration); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
40 changes: 40 additions & 0 deletions
40
packages/server-utils/src/orchestrion/config/google-genai.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; | ||
|
|
||
| // `@google/genai` ships one bundled file per module format and the matcher compares `filePath` exactly, | ||
| // so we list every file the `node` export condition resolves to across the supported range: `index.js` | ||
| // (ESM+CJS for <0.15.0, CJS for <1.1.0), `index.mjs` (ESM for >=0.15.0), and `index.cjs` (CJS for >=1.1.0). | ||
| // A file that doesn't exist in a given version simply never matches, so listing all three is safe. | ||
| const NODE_DIST_FILES = ['dist/node/index.js', 'dist/node/index.mjs', 'dist/node/index.cjs']; | ||
|
|
||
| export const googleGenAiConfig = [ | ||
| // `generateContent`/`generateContentStream` are arrow properties assigned in the constructor, not class | ||
| // methods, so they need `expressionName` rather than `className`/`methodName`. | ||
| ...NODE_DIST_FILES.flatMap(filePath => | ||
| (['generateContent', 'generateContentStream'] as const).map(expressionName => ({ | ||
| channelName: 'generate-content', | ||
| module: { name: '@google/genai', versionRange: '>=0.10.0 <2', filePath }, | ||
| functionQuery: { expressionName, kind: 'Auto' as const }, | ||
| })), | ||
| ), | ||
| // `embedContent` and the `Chat` methods are real class methods. | ||
| ...NODE_DIST_FILES.map(filePath => ({ | ||
| channelName: 'embed-content', | ||
| module: { name: '@google/genai', versionRange: '>=0.10.0 <2', filePath }, | ||
| functionQuery: { className: 'Models', methodName: 'embedContent', kind: 'Auto' as const }, | ||
| })), | ||
| // `sendMessage`/`sendMessageStream` internally delegate to `Models.generateContent(Stream)`; the | ||
| // subscriber suppresses that nested `generate-content` event so a chat call yields a single span. | ||
| ...NODE_DIST_FILES.flatMap(filePath => | ||
| (['sendMessage', 'sendMessageStream'] as const).map(methodName => ({ | ||
| channelName: 'chat', | ||
| module: { name: '@google/genai', versionRange: '>=0.10.0 <2', filePath }, | ||
| functionQuery: { className: 'Chat', methodName, kind: 'Auto' as const }, | ||
| })), | ||
| ), | ||
| ] satisfies InstrumentationConfig[]; | ||
|
|
||
| export const googleGenAiChannels = { | ||
| GOOGLE_GENAI_GENERATE_CONTENT: 'orchestrion:@google/genai:generate-content', | ||
| GOOGLE_GENAI_EMBED_CONTENT: 'orchestrion:@google/genai:embed-content', | ||
| GOOGLE_GENAI_CHAT: 'orchestrion:@google/genai:chat', | ||
| } as const; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
q: Are there other values this can take else than
embeddings?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, for instance
chatfor chat completions