Skip to content

Commit 103189a

Browse files
crysmagstlhunter
authored andcommitted
feat(llmobs): add support for OpenAI Agents (trace-processor) (#8044)
* feat(llmobs): add OpenAI Agents trace-processor integration * ci(llmobs): test OpenAI Agents integration
1 parent c4d1ca2 commit 103189a

41 files changed

Lines changed: 4222 additions & 122 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/llmobs.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,3 +303,33 @@ jobs:
303303
steps:
304304
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
305305
- uses: ./.github/actions/plugins/test
306+
307+
openai-agents:
308+
runs-on: ubuntu-latest
309+
permissions:
310+
id-token: write
311+
env:
312+
PLUGINS: openai-agents
313+
steps:
314+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
315+
- uses: ./.github/actions/testagent/start
316+
- uses: ./.github/actions/node/oldest-maintenance-lts
317+
- uses: ./.github/actions/install
318+
- run: npm run test:plugins:ci
319+
- run: npm run test:llmobs:plugins:ci
320+
shell: bash
321+
- uses: ./.github/actions/node/latest
322+
- run: npm run test:plugins:ci
323+
- run: npm run test:llmobs:plugins:ci
324+
shell: bash
325+
- uses: ./.github/actions/coverage
326+
with:
327+
flags: llmobs-${{ github.job }}
328+
- if: always()
329+
uses: ./.github/actions/testagent/logs
330+
with:
331+
suffix: llmobs-${{ github.job }}
332+
- uses: ./.github/actions/upload-junit-artifacts
333+
if: "!cancelled()"
334+
with:
335+
id: ${{ github.job }}

docs/API.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ tracer.use('pg', {
8686
<h5 id="next"></h5>
8787
<h5 id="nyc"></h5>
8888
<h5 id="openai"></h5>
89+
<h5 id="openai-agents"></h5>
8990
<h5 id="opensearch"></h5>
9091
<h5 id="oracledb"></h5>
9192
<h5 id="pg"></h5>
@@ -170,6 +171,7 @@ tracer.use('pg', {
170171
* [next](./interfaces/export_.plugins.next.html)
171172
* [nyc](./interfaces/export_.plugins.nyc.html)
172173
* [openai](./interfaces/export_.plugins.openai.html)
174+
* [openai-agents](./interfaces/export_.plugins.openai_agents.html)
173175
* [opensearch](./interfaces/export_.plugins.opensearch.html)
174176
* [oracledb](./interfaces/export_.plugins.oracledb.html)
175177
* [pg](./interfaces/export_.plugins.pg.html)

docs/test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ tracer.use('nats');
391391
tracer.use('net');
392392
tracer.use('next');
393393
tracer.use('next', nextOptions);
394+
tracer.use('openai-agents');
394395
tracer.use('opensearch');
395396
tracer.use('opensearch', openSearchOptions);
396397
tracer.use('oracledb');

index.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ interface Plugins {
291291
"next": tracer.plugins.next;
292292
"nyc": tracer.plugins.nyc;
293293
"openai": tracer.plugins.openai;
294+
"openai-agents": tracer.plugins.openai_agents;
294295
"opensearch": tracer.plugins.opensearch;
295296
"oracledb": tracer.plugins.oracledb;
296297
"playwright": tracer.plugins.playwright;
@@ -2968,6 +2969,12 @@ declare namespace tracer {
29682969
*/
29692970
interface openai extends Instrumentation {}
29702971

2972+
/**
2973+
* This plugin automatically instruments the
2974+
* [@openai/agents](https://www.npmjs.com/package/@openai/agents) library.
2975+
*/
2976+
interface openai_agents extends Instrumentation {}
2977+
29712978
/**
29722979
* This plugin automatically instruments the
29732980
* [opensearch](https://github.com/opensearch-project/opensearch-js) module.

index.d.v5.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ interface Plugins {
293293
"next": tracer.plugins.next;
294294
"nyc": tracer.plugins.nyc;
295295
"openai": tracer.plugins.openai;
296+
"openai-agents": tracer.plugins.openai_agents;
296297
"opensearch": tracer.plugins.opensearch;
297298
"oracledb": tracer.plugins.oracledb;
298299
"playwright": tracer.plugins.playwright;
@@ -3138,6 +3139,12 @@ declare namespace tracer {
31383139
*/
31393140
interface openai extends Instrumentation {}
31403141

3142+
/**
3143+
* This plugin automatically instruments the
3144+
* [@openai/agents](https://www.npmjs.com/package/@openai/agents) library.
3145+
*/
3146+
interface openai_agents extends Instrumentation {}
3147+
31413148
/**
31423149
* This plugin automatically instruments the
31433150
* [opensearch](https://github.com/opensearch-project/opensearch-js) module.

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ module.exports = {
2121
'@apollo/gateway': () => require('../apollo'),
2222
'@langchain/langgraph': { esmFirst: true, fn: () => require('../langgraph') },
2323
'@modelcontextprotocol/sdk': { esmFirst: true, fn: () => require('../modelcontextprotocol-sdk') },
24+
'@openai/agents': () => require('../openai-agents'),
25+
'@openai/agents-openai': () => require('../openai-agents'),
2426
'apollo-server-core': () => require('../apollo-server-core'),
2527
'@aws-sdk/smithy-client': () => require('../aws-sdk'),
2628
'@aws/durable-execution-sdk-js': () => require('../aws-durable-execution-sdk-js'),
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
'use strict'
2+
3+
const { channel } = require('dc-polyfill')
4+
const shimmer = require('../../datadog-shimmer')
5+
const { addHook } = require('./helpers/instrument')
6+
7+
// `WeakSet` keyed by module exports — replaces the underscored
8+
// `mod._datadogPatched` flag while keeping dedupe semantics. Mods are kept
9+
// alive by `require.cache` anyway, so this doesn't add lifetime to anything.
10+
const patchedMods = new WeakSet()
11+
12+
// Plugin subscribes to this and registers its TracingProcessor when
13+
// `@openai/agents` loads. Publishing from here keeps this file free of
14+
// any cross-package import from the plugin.
15+
const agentsCoreLoadedCh = channel('apm:openai-agents:agents-core:loaded')
16+
17+
// Plugin subscribes here to keep track of the OpenAI-compatible client's
18+
// baseURL — used to resolve `model_provider` (openai / azure_openai /
19+
// deepseek / unknown).
20+
const responseClientCh = channel('apm:openai-agents:response:client')
21+
22+
// Plugin uses addBind on this channel so that legacyStorage.run(store, fn) wraps
23+
// the model call — including async iterator advancement for streaming responses.
24+
// This ensures the active dd-trace span is visible to the openai plugin when it
25+
// creates its openai.request span, correctly parenting it under the agent span.
26+
const modelStartCh = channel('apm:openai-agents:model:start')
27+
28+
// Reference to the loaded @openai/agents module, captured in the first hook
29+
// so that wrapResponseMethod can call getCurrentSpan() without an additional
30+
// require (and without triggering n/no-missing-require on agents-core internals).
31+
let agentsMod
32+
33+
// @openai/agents >=0.8.0 moved addTraceProcessor / getCurrentSpan out of the
34+
// top-level re-exports. The new public surface uses getGlobalTraceProvider():
35+
// provider.registerProcessor(processor) (replaces addTraceProcessor)
36+
// provider.getCurrentSpan() (replaces getCurrentSpan)
37+
// Both APIs are tried so this file works across the full supported version range.
38+
// The plugin subscriber (index.js) handles processor registration via the channel.
39+
function getCurrentSpanId (mod) {
40+
if (typeof mod?.getCurrentSpan === 'function') {
41+
return mod.getCurrentSpan()?.spanId
42+
}
43+
if (typeof mod?.getGlobalTraceProvider === 'function') {
44+
return mod.getGlobalTraceProvider().getCurrentSpan()?.spanId
45+
}
46+
}
47+
48+
addHook({ name: '@openai/agents', versions: ['>=0.7.0'] }, (mod) => {
49+
if (patchedMods.has(mod)) return mod
50+
if (typeof mod?.addTraceProcessor !== 'function' && typeof mod?.getGlobalTraceProvider !== 'function') return mod
51+
patchedMods.add(mod)
52+
agentsMod = mod
53+
agentsCoreLoadedCh.publish({ mod })
54+
return mod
55+
})
56+
57+
function wrapResponseMethod (original) {
58+
return function (...args) {
59+
const agentsCoreSpanId = getCurrentSpanId(agentsMod)
60+
publishClientBaseURL(this)
61+
return modelStartCh.runStores({ agentsCoreSpanId }, () => original.apply(this, args))
62+
}
63+
}
64+
65+
function wrapStreamedResponseMethod (original) {
66+
return function (...args) {
67+
const agentsCoreSpanId = getCurrentSpanId(agentsMod)
68+
publishClientBaseURL(this)
69+
const iterator = modelStartCh.runStores({ agentsCoreSpanId }, () => original.apply(this, args))
70+
return wrapAsyncIterator(iterator, agentsCoreSpanId)
71+
}
72+
}
73+
74+
function publishClientBaseURL (model) {
75+
const baseURL = model?.client?.baseURL ?? model?._client?.baseURL
76+
if (baseURL) responseClientCh.publish({ baseURL })
77+
}
78+
79+
function wrapAsyncIterator (iterator, agentsCoreSpanId) {
80+
if (!iterator || typeof iterator !== 'object') return iterator
81+
82+
return {
83+
next () {
84+
return modelStartCh.runStores({ agentsCoreSpanId }, () => iterator.next.apply(iterator, arguments))
85+
},
86+
throw () {
87+
if (typeof iterator.throw !== 'function') return Promise.reject(arguments[0])
88+
return modelStartCh.runStores({ agentsCoreSpanId }, () => iterator.throw.apply(iterator, arguments))
89+
},
90+
return () {
91+
if (typeof iterator.return !== 'function') return Promise.resolve({ done: true, value: arguments[0] })
92+
return modelStartCh.runStores({ agentsCoreSpanId }, () => iterator.return.apply(iterator, arguments))
93+
},
94+
[Symbol.asyncIterator] () {
95+
return this
96+
},
97+
}
98+
}
99+
100+
addHook({ name: '@openai/agents-openai', versions: ['>=0.7.0'] }, (mod) => {
101+
if (patchedMods.has(mod)) return mod
102+
const proto = mod?.OpenAIResponsesModel?.prototype
103+
if (!proto) return mod
104+
105+
patchedMods.add(mod)
106+
shimmer.wrap(proto, 'getResponse', wrapResponseMethod)
107+
shimmer.wrap(proto, 'getStreamedResponse', wrapStreamedResponseMethod)
108+
return mod
109+
})
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
'use strict'
2+
3+
const { storage } = require('../../datadog-core')
4+
const Plugin = require('../../dd-trace/src/plugins/plugin')
5+
const { OpenAIAgentsIntegration } = require('./integration')
6+
const { DDOpenAIAgentsProcessor } = require('./processor')
7+
8+
const legacyStorage = storage('legacy')
9+
10+
/**
11+
* Drives the openai-agents integration through agents-core's
12+
* `TracingProcessor` interface. The instrumentation hook publishes the
13+
* loaded `@openai/agents` module on a channel; this plugin subscribes
14+
* during its constructor (which runs synchronously between `loadChannel`'s
15+
* publish and the addHook callback) and registers the processor.
16+
*
17+
* The instrumentation also publishes the OpenAI-compatible client's baseURL on
18+
* each model response call, so the integration can resolve `model_provider`.
19+
*
20+
* The integration's `enabled` flag follows this plugin's configure()
21+
* lifecycle. Each loaded version of the agents package replaces all processors
22+
* via setTraceProcessors() on module load, so the plugin re-registers a
23+
* fresh DDOpenAIAgentsProcessor for each module version that fires the channel.
24+
*/
25+
class OpenaiAgentsPlugin extends Plugin {
26+
static id = 'openai-agents'
27+
28+
#integration
29+
30+
constructor (tracer, tracerConfig) {
31+
super(tracer, tracerConfig)
32+
this.#integration = new OpenAIAgentsIntegration({
33+
tracer: this.tracer,
34+
config: tracerConfig,
35+
})
36+
37+
// Register a new processor each time @openai/agents fires the channel.
38+
// Each module version calls setTraceProcessors() on load (which replaces
39+
// all processors), so we must re-register after every new version loads.
40+
// The instrumentation's patchedMods WeakSet ensures each module instance
41+
// fires the channel exactly once, so no duplicates accumulate.
42+
this.addSub('apm:openai-agents:agents-core:loaded', ({ mod }) => {
43+
const processor = new DDOpenAIAgentsProcessor(() => this.#integration)
44+
if (typeof mod?.addTraceProcessor === 'function') {
45+
mod.addTraceProcessor(processor)
46+
} else {
47+
mod.getGlobalTraceProvider().registerProcessor(processor)
48+
}
49+
})
50+
51+
this.addSub('apm:openai-agents:response:client', ({ baseURL }) => {
52+
if (!this.#integration.enabled) return
53+
this.#integration.setClientBaseURL(baseURL)
54+
})
55+
56+
// Activate the current agent's dd-trace span in legacyStorage for the
57+
// duration of model response calls and stream iterator advancement. This
58+
// makes the openai plugin's shimmer see the correct parent when it creates its
59+
// openai.request span, so all spans land in the same trace.
60+
this.addBind('apm:openai-agents:model:start', ({ agentsCoreSpanId }) => {
61+
if (!this.#integration.enabled || !agentsCoreSpanId) return legacyStorage.getStore()
62+
const ddSpan = this.#integration.getDDSpan(agentsCoreSpanId)
63+
if (!ddSpan) return legacyStorage.getStore()
64+
return { ...legacyStorage.getStore(), span: ddSpan }
65+
})
66+
}
67+
68+
configure (config) {
69+
super.configure(config)
70+
this.#integration.setEnabled(!!config?.enabled)
71+
}
72+
}
73+
74+
module.exports = OpenaiAgentsPlugin

0 commit comments

Comments
 (0)