Skip to content

Commit 2c119e7

Browse files
crysmagsBridgeAR
authored andcommitted
Google GenAI integration for LLM observability and APM tracing.
* Instrumentation of Google GenAI, with tracing and llm plugins.
1 parent 9a0612b commit 2c119e7

24 files changed

Lines changed: 1531 additions & 15 deletions

File tree

.github/workflows/llmobs.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,30 @@ jobs:
205205
with:
206206
api_key: ${{ secrets.DD_API_KEY }}
207207
service: dd-trace-js-tests
208+
209+
google-genai:
210+
runs-on: ubuntu-latest
211+
env:
212+
PLUGINS: google-genai
213+
steps:
214+
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
215+
- uses: ./.github/actions/testagent/start
216+
- uses: ./.github/actions/node/oldest-maintenance-lts
217+
- uses: ./.github/actions/install
218+
- run: yarn test:plugins:ci
219+
- run: yarn test:llmobs:plugins:ci
220+
shell: bash
221+
- uses: ./.github/actions/node/latest
222+
- run: yarn test:plugins:ci
223+
- run: yarn test:llmobs:plugins:ci
224+
shell: bash
225+
- uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
226+
- if: always()
227+
uses: ./.github/actions/testagent/logs
228+
with:
229+
suffix: llmobs-${{ github.job }}
230+
- uses: DataDog/junit-upload-github-action@762867566348d59ac9bcf479ebb4ec040db8940a # v2.0.0
231+
if: always()
232+
with:
233+
api_key: ${{ secrets.DD_API_KEY }}
234+
service: dd-trace-js-tests

docs/test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,7 @@ tracer.use('fetch', httpClientOptions);
332332
tracer.use('generic-pool');
333333
tracer.use('google-cloud-pubsub');
334334
tracer.use('google-cloud-vertexai');
335+
tracer.use('google-genai');
335336
tracer.use('graphql');
336337
tracer.use('graphql', graphqlOptions);
337338
tracer.use('graphql', { variables: ['foo', 'bar'] });

index.d.ts

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ interface Plugins {
207207
"generic-pool": tracer.plugins.generic_pool;
208208
"google-cloud-pubsub": tracer.plugins.google_cloud_pubsub;
209209
"google-cloud-vertexai": tracer.plugins.google_cloud_vertexai;
210+
"google-genai": tracer.plugins.google_genai;
210211
"graphql": tracer.plugins.graphql;
211212
"grpc": tracer.plugins.grpc;
212213
"hapi": tracer.plugins.hapi;
@@ -1843,23 +1844,29 @@ declare namespace tracer {
18431844
* [@google-cloud/pubsub](https://github.com/googleapis/nodejs-pubsub) module.
18441845
*/
18451846
interface google_cloud_pubsub extends Integration {}
1846-
1847+
18471848
/**
18481849
* This plugin automatically instruments the
18491850
* [@google-cloud/vertexai](https://github.com/googleapis/nodejs-vertexai) module.
1850-
*/
1851-
interface google_cloud_vertexai extends Integration {}
1851+
*/
1852+
interface google_cloud_vertexai extends Integration {}
18521853

1853-
/** @hidden */
1854-
interface ExecutionArgs {
1855-
schema: any,
1856-
document: any,
1857-
rootValue?: any,
1858-
contextValue?: any,
1859-
variableValues?: any,
1860-
operationName?: string,
1861-
fieldResolver?: any,
1862-
typeResolver?: any,
1854+
/**
1855+
* This plugin automatically instruments the
1856+
* [@google-genai](https://github.com/googleapis/js-genai) module.
1857+
*/
1858+
interface google_genai extends Integration {}
1859+
1860+
/** @hidden */
1861+
interface ExecutionArgs {
1862+
schema: any,
1863+
document: any,
1864+
rootValue?: any,
1865+
contextValue?: any,
1866+
variableValues?: any,
1867+
operationName?: string,
1868+
fieldResolver?: any,
1869+
typeResolver?: any,
18631870
}
18641871

18651872
/**
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
'use strict'
2+
3+
const { addHook } = require('./helpers/instrument')
4+
const shimmer = require('../../datadog-shimmer')
5+
const tracingChannel = require('dc-polyfill').tracingChannel
6+
const channel = require('dc-polyfill').channel
7+
8+
const genaiTracingChannel = tracingChannel('apm:google:genai:request')
9+
const onStreamedChunkCh = channel('apm:google:genai:request:chunk')
10+
11+
function wrapGenerateContent (method) {
12+
return function wrappedGenerateContent (original) {
13+
return function (...args) {
14+
if (!genaiTracingChannel.start.hasSubscribers) {
15+
return original.apply(this, args)
16+
}
17+
18+
const normalizedName = normalizeMethodName(method)
19+
20+
const ctx = { args, methodName: normalizedName }
21+
22+
return genaiTracingChannel.start.runStores(ctx, () => {
23+
let result
24+
try {
25+
result = original.apply(this, arguments)
26+
} catch (error) {
27+
finish(ctx, null, error)
28+
throw error
29+
} finally {
30+
genaiTracingChannel.end.publish(ctx)
31+
}
32+
return result.then(response => {
33+
if (response[Symbol.asyncIterator]) {
34+
shimmer.wrap(response, Symbol.asyncIterator, iterator => wrapStreamIterator(iterator, ctx))
35+
} else {
36+
finish(ctx, response, null)
37+
}
38+
return response
39+
}).catch(error => {
40+
finish(ctx, null, error)
41+
throw error
42+
})
43+
})
44+
}
45+
}
46+
}
47+
48+
function wrapStreamIterator (iterator, ctx) {
49+
return function () {
50+
const itr = iterator.apply(this, arguments)
51+
shimmer.wrap(itr, 'next', next => function () {
52+
return next.apply(this, arguments)
53+
.then(res => {
54+
const { done, value: chunk } = res
55+
onStreamedChunkCh.publish({ ctx, chunk, done })
56+
57+
if (done) {
58+
finish(ctx)
59+
}
60+
61+
return res
62+
})
63+
.catch(error => {
64+
finish(ctx, null, error)
65+
throw error
66+
})
67+
})
68+
69+
return itr
70+
}
71+
}
72+
function finish (ctx, result, error) {
73+
if (error) {
74+
ctx.error = error
75+
genaiTracingChannel.error.publish(ctx)
76+
}
77+
78+
// streamed responses are handled and set separately
79+
ctx.result ??= result
80+
81+
genaiTracingChannel.asyncEnd.publish(ctx)
82+
}
83+
// Hook the main package entry point
84+
addHook({
85+
name: '@google/genai',
86+
versions: ['>=1.19.0']
87+
}, exports => {
88+
// Wrap GoogleGenAI to intercept when it creates Models instances
89+
if (!exports.GoogleGenAI) return exports
90+
91+
shimmer.wrap(exports, 'GoogleGenAI', GoogleGenAI => {
92+
return class extends GoogleGenAI {
93+
constructor (...args) {
94+
super(...args)
95+
96+
// We are patching the instance instead of the prototype because when it is compiled from
97+
// typescript, the models property is not available on the prototype.
98+
if (this.models) {
99+
if (this.models.generateContent) {
100+
shimmer.wrap(this.models, 'generateContent', wrapGenerateContent('generateContent'))
101+
}
102+
if (this.models.generateContentStream) {
103+
shimmer.wrap(this.models, 'generateContentStream', wrapGenerateContent('generateContentStream'))
104+
}
105+
if (this.models.embedContent) {
106+
shimmer.wrap(this.models, 'embedContent', wrapGenerateContent('embedContent'))
107+
}
108+
}
109+
}
110+
}
111+
})
112+
return exports
113+
})
114+
115+
function normalizeMethodName (methodName) {
116+
// Convert camelCase to snake_case and add Models prefix
117+
return 'Models.' + methodName
118+
.replaceAll(/([a-z0-9])([A-Z])/g, '$1_$2')
119+
.toLowerCase()
120+
}

packages/datadog-instrumentations/src/helpers/hooks.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ module.exports = {
1313
'@playwright/test': () => require('../playwright'),
1414
'@elastic/elasticsearch': () => require('../elasticsearch'),
1515
'@elastic/transport': () => require('../elasticsearch'),
16+
'@google/genai': () => require('../google-genai'),
1617
'@google-cloud/pubsub': () => require('../google-cloud-pubsub'),
1718
'@google-cloud/vertexai': () => require('../google-cloud-vertexai'),
1819
'@graphql-tools/executor': () => require('../graphql'),
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
'use strict'
2+
3+
const CompositePlugin = require('../../dd-trace/src/plugins/composite')
4+
const GenAiTracingPlugin = require('./tracing')
5+
const GenAiLLMObsPlugin = require('../../dd-trace/src/llmobs/plugins/genai')
6+
7+
class GenAiPlugin extends CompositePlugin {
8+
static id = 'google-genai'
9+
static get plugins () {
10+
return {
11+
llmobs: GenAiLLMObsPlugin,
12+
tracing: GenAiTracingPlugin
13+
}
14+
}
15+
}
16+
17+
module.exports = GenAiPlugin
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
'use strict'
2+
3+
const TracingPlugin = require('../../dd-trace/src/plugins/tracing.js')
4+
5+
class GenAiTracingPlugin extends TracingPlugin {
6+
static id = 'google-genai'
7+
static operation = 'request'
8+
static prefix = 'tracing:apm:google:genai:request'
9+
10+
static get type () { return 'web' }
11+
static get kind () { return 'client' }
12+
13+
bindStart (ctx) {
14+
const { args, methodName } = ctx
15+
16+
const inputs = args[0]
17+
const model = inputs?.model || 'unknown'
18+
19+
this.startSpan('google_genai.request', {
20+
meta: {
21+
'resource.name': methodName,
22+
'google_genai.request.model': model,
23+
'google_genai.request.provider': 'google'
24+
}
25+
}, ctx)
26+
27+
return ctx.currentStore
28+
}
29+
30+
asyncEnd (ctx) {
31+
const { span } = ctx.currentStore
32+
if (!span) return
33+
34+
if (ctx.result) {
35+
span.setTag('google_genai.response.model', ctx.result.modelVersion || ctx.inputs?.model)
36+
}
37+
span.finish()
38+
}
39+
}
40+
41+
module.exports = GenAiTracingPlugin
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
'use strict'
2+
3+
const { describe, before, after, it } = require('mocha')
4+
const { withVersions } = require('../../dd-trace/test/setup/mocha')
5+
const agent = require('../../dd-trace/test/plugins/agent')
6+
const assert = require('node:assert')
7+
8+
describe('Plugin', () => {
9+
withVersions('google-genai', '@google/genai', (version) => {
10+
let client
11+
12+
before(async () => {
13+
await agent.load('google-genai')
14+
15+
const { GoogleGenAI } = require(`../../../versions/@google/genai@${version}`).get()
16+
client = new GoogleGenAI({
17+
apiKey: process.env.GOOGLE_API_KEY || '<not-a-real-key>',
18+
httpOptions: { baseUrl: 'http://127.0.0.1:9126/vcr/genai' }
19+
})
20+
})
21+
22+
after(async () => {
23+
await agent.close({ ritmReset: false })
24+
})
25+
26+
describe('models.generateContent', () => {
27+
it('creates a span', async () => {
28+
const tracesPromise = agent.assertSomeTraces(traces => {
29+
const span = traces[0][0]
30+
31+
assert.equal(span.name, 'google_genai.request')
32+
assert.equal(span.resource, 'Models.generate_content')
33+
assert.equal(span.meta['google_genai.request.model'], 'gemini-2.0-flash')
34+
})
35+
36+
const result = await client.models.generateContent({
37+
model: 'gemini-2.0-flash',
38+
contents: 'Hello, world!'
39+
})
40+
41+
assert.ok(result)
42+
43+
await tracesPromise
44+
})
45+
})
46+
47+
describe('models.generateContentStream', () => {
48+
it('creates a span', async () => {
49+
const tracesPromise = agent.assertSomeTraces(traces => {
50+
const span = traces[0][0]
51+
52+
assert.equal(span.name, 'google_genai.request')
53+
assert.equal(span.resource, 'Models.generate_content_stream')
54+
assert.equal(span.meta['google_genai.request.model'], 'gemini-2.0-flash')
55+
})
56+
57+
const stream = await client.models.generateContentStream({
58+
model: 'gemini-2.0-flash',
59+
contents: 'Hello, world!'
60+
})
61+
62+
for await (const chunk of stream) {
63+
assert.ok(chunk)
64+
}
65+
66+
await tracesPromise
67+
})
68+
})
69+
70+
describe('models.embedContent', () => {
71+
it('creates a span', async () => {
72+
const tracesPromise = agent.assertSomeTraces(traces => {
73+
const span = traces[0][0]
74+
75+
assert.equal(span.name, 'google_genai.request')
76+
assert.equal(span.resource, 'Models.embed_content')
77+
assert.equal(span.meta['google_genai.request.model'], 'text-embedding-004')
78+
})
79+
80+
const result = await client.models.embedContent({
81+
model: 'text-embedding-004',
82+
contents: 'Hello, world!'
83+
})
84+
85+
assert.ok(result)
86+
87+
await tracesPromise
88+
})
89+
})
90+
})
91+
})

0 commit comments

Comments
 (0)