Skip to content

Commit ea578a8

Browse files
IlyasShabipabloerhard
authored andcommitted
feat(aiguard): evaluating anthropic calls with AI guard automatically (#9563)
* feat(aiguard): evaluating anthropic calls with AI guard automatically
1 parent e9aa68f commit ea578a8

14 files changed

Lines changed: 3338 additions & 50 deletions

File tree

.github/workflows/instrumentation.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ jobs:
6161
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
6262
- uses: ./.github/actions/instrumentations/test
6363

64+
instrumentation-anthropic-lifecycle:
65+
runs-on: ubuntu-latest
66+
permissions:
67+
id-token: write
68+
env:
69+
PLUGINS: anthropic|anthropic-lifecycle
70+
steps:
71+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
72+
- uses: ./.github/actions/instrumentations/test
73+
6474
instrumentation-aws-sdk:
6575
runs-on: ubuntu-latest
6676
permissions:

packages/datadog-instrumentations/src/anthropic.js

Lines changed: 210 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,144 @@ const { addHook } = require('./helpers/instrument')
66

77
const anthropicTracingChannel = tracingChannel('apm:anthropic:request')
88
const onStreamedChunkCh = channel('apm:anthropic:request:chunk')
9+
const messagesBeforeChannel = channel('dd-trace:anthropic:messages:before')
10+
const messagesAfterChannel = channel('dd-trace:anthropic:messages:after')
11+
12+
/**
13+
* Publishes a provider-native lifecycle payload to a cancelable lifecycle channel.
14+
*
15+
* Subscribers push async work into `pending` synchronously during publication and
16+
* abort `abortController` with an error before the pushed promise resolves to block.
17+
*
18+
* @param {object} channel
19+
* @param {object} payload
20+
* @returns {Promise<void>}
21+
*/
22+
function publishLifecycle (channel, payload) {
23+
const abortController = new AbortController()
24+
const ctx = { ...payload, abortController, pending: [] }
25+
26+
channel.publish(ctx)
27+
28+
return Promise.all(ctx.pending).then(() => {
29+
if (abortController.signal.aborted) {
30+
throw abortController.signal.reason
31+
}
32+
})
33+
}
34+
35+
/**
36+
* @template T
37+
* @param {Promise<T>} promise
38+
* @param {Promise<void>|undefined} verdict
39+
* @returns {Promise<T>}
40+
*/
41+
function waitForVerdict (promise, verdict) {
42+
if (!verdict) return promise
43+
44+
// The lifecycle verdict takes precedence over an earlier SDK rejection.
45+
promise.catch(() => {})
46+
return verdict.then(() => promise)
47+
}
48+
49+
/**
50+
* @param {Array<unknown>} args
51+
* @returns {Array<unknown>|undefined}
52+
*/
53+
function snapshotLifecycleArgs (args) {
54+
const options = args[0]
55+
if (!options || typeof options !== 'object') return
56+
57+
const input = { messages: options.messages }
58+
if (options.system !== undefined) input.system = options.system
59+
60+
// Snapshot via JSON so AI Guard evaluates exactly what the SDK serializes and sends, immune to
61+
// later caller mutation. If it can't serialize (e.g. circular), the SDK's own serialization would
62+
// fail too, so skipping evaluation is safe — nothing reaches the model.
63+
try {
64+
const snapshot = [...args]
65+
// eslint-disable-next-line unicorn/prefer-structured-clone
66+
snapshot[0] = { ...options, ...JSON.parse(JSON.stringify(input)) }
67+
return snapshot
68+
} catch {
69+
// Unserializable input — leave AI Guard inactive for this call (the SDK send would fail too).
70+
}
71+
}
72+
73+
/**
74+
* Finishes after output evaluation so a block propagates to the caller and span.
75+
*
76+
* @param {object} ctx
77+
* @param {object} result
78+
* @param {(body: object) => Promise<void>|undefined} getVerdict
79+
* @param {object|string} [returnedResult]
80+
* @returns {object|string|Promise<object|string>}
81+
*/
82+
function finishResult (ctx, result, getVerdict, returnedResult = result) {
83+
const verdict = getVerdict(result)
84+
if (!verdict) {
85+
finish(ctx, result)
86+
return returnedResult
87+
}
88+
89+
return verdict.then(() => {
90+
finish(ctx, result)
91+
return returnedResult
92+
})
93+
}
94+
95+
/**
96+
* @param {object} ctx
97+
* @param {Error} error
98+
* @throws {Error} Always rethrows the supplied error.
99+
*/
100+
function finishAndThrow (ctx, error) {
101+
finish(ctx, null, error)
102+
throw error
103+
}
104+
105+
/**
106+
* @param {object} response
107+
* @param {'json'|'text'} method
108+
* @param {object} ctx
109+
* @param {(body: object) => Promise<void>|undefined} getVerdict
110+
*/
111+
function wrapResponseReader (response, method, ctx, getVerdict) {
112+
if (typeof response[method] !== 'function') return
113+
114+
shimmer.wrap(response, method, original => function (...args) {
115+
return original.apply(this, args)
116+
.then(body => {
117+
if (method === 'json') return finishResult(ctx, body, getVerdict)
118+
119+
try {
120+
return finishResult(ctx, JSON.parse(body), getVerdict, body)
121+
} catch {
122+
finish(ctx)
123+
return body
124+
}
125+
})
126+
.catch(error => finishAndThrow(ctx, error))
127+
})
128+
}
129+
130+
/**
131+
* @param {object} response
132+
* @param {object} ctx
133+
* @param {(body: object) => Promise<void>|undefined} getVerdict
134+
*/
135+
function wrapRawResponse (response, ctx, getVerdict) {
136+
wrapResponseReader(response, 'json', ctx, getVerdict)
137+
wrapResponseReader(response, 'text', ctx, getVerdict)
138+
139+
if (typeof response.clone !== 'function') return
140+
141+
shimmer.wrap(response, 'clone', clone => function (...args) {
142+
const clonedResponse = clone.apply(this, args)
143+
wrapRawResponse(clonedResponse, ctx, getVerdict)
144+
return clonedResponse
145+
})
146+
}
9147

10148
function wrapStreamIterator (iterator, ctx) {
11149
return function (...args) {
@@ -34,40 +172,94 @@ function wrapStreamIterator (iterator, ctx) {
34172

35173
function wrapCreate (create) {
36174
return function (...args) {
37-
if (!anthropicTracingChannel.start.hasSubscribers) {
175+
const options = args[0]
176+
const stream = options?.stream
177+
178+
// Streaming is out of scope for lifecycle evaluation.
179+
const lifecycleArgs = !stream &&
180+
(messagesBeforeChannel.hasSubscribers || messagesAfterChannel.hasSubscribers)
181+
? snapshotLifecycleArgs(args)
182+
: undefined
183+
184+
if (!anthropicTracingChannel.start.hasSubscribers && !lifecycleArgs) {
38185
return create.apply(this, args)
39186
}
40187

41-
const options = args[0]
42-
const stream = options.stream
43-
44-
const ctx = { options, resource: 'create', baseUrl: this._client?.baseURL }
188+
const ctx = { options: lifecycleArgs?.[0] ?? options, resource: 'create', baseUrl: this._client?.baseURL }
45189

46190
return anthropicTracingChannel.start.runStores(ctx, () => {
191+
const parentSpan = lifecycleArgs ? ctx.currentStore?.span : undefined
192+
47193
let apiPromise
48194
try {
49-
apiPromise = create.apply(this, args)
195+
// Anthropic starts the request eagerly; the input verdict only gates result delivery.
196+
apiPromise = create.apply(this, lifecycleArgs ?? args)
50197
} catch (error) {
51198
finish(ctx, null, error)
52199
throw error
53200
}
54201

55-
shimmer.wrap(apiPromise, 'parse', parse => function (...args) {
56-
return parse.apply(this, args)
202+
let beforeVerdict
203+
let afterVerdict
204+
let parseResult
205+
let wrappedResponse
206+
207+
function getBeforeVerdict () {
208+
if (!lifecycleArgs || beforeVerdict) return beforeVerdict
209+
if (!messagesBeforeChannel.hasSubscribers) return
210+
211+
beforeVerdict = publishLifecycle(messagesBeforeChannel, { args: lifecycleArgs, parentSpan })
212+
return beforeVerdict
213+
}
214+
215+
/**
216+
* @param {object|string} body
217+
*/
218+
function getAfterVerdict (body) {
219+
if (!lifecycleArgs || afterVerdict) return afterVerdict
220+
if (!messagesAfterChannel.hasSubscribers) return
221+
222+
afterVerdict = publishLifecycle(messagesAfterChannel, { args: lifecycleArgs, body, parentSpan })
223+
return afterVerdict
224+
}
225+
226+
shimmer.wrap(apiPromise, 'parse', parse => function (...parseArgs) {
227+
if (parseResult) return parseResult
228+
229+
const parsed = parse.apply(this, parseArgs)
230+
parseResult = waitForVerdict(parsed, getBeforeVerdict())
57231
.then(response => {
58232
if (stream) {
59233
shimmer.wrap(response, Symbol.asyncIterator, iterator => wrapStreamIterator(iterator, ctx))
60-
} else {
61-
finish(ctx, response, null)
234+
return response
62235
}
63-
64-
return response
65-
}).catch(error => {
66-
finish(ctx, null, error)
67-
throw error
236+
return finishResult(ctx, response, getAfterVerdict)
68237
})
238+
.catch(error => finishAndThrow(ctx, error))
239+
240+
return parseResult
69241
})
70242

243+
if (typeof apiPromise.asResponse === 'function') {
244+
shimmer.wrap(apiPromise, 'asResponse', origAsResponse => function (...asResponseArgs) {
245+
return waitForVerdict(origAsResponse.apply(this, asResponseArgs), getBeforeVerdict())
246+
.then(response => {
247+
// Wrap the raw response's json()/text()/clone() readers to evaluate output on read,
248+
// but only while someone still consumes the result and not twice for the same response.
249+
const outputObserved = anthropicTracingChannel.start.hasSubscribers ||
250+
afterVerdict || messagesAfterChannel.hasSubscribers
251+
if (!stream && outputObserved && wrappedResponse !== response) {
252+
wrappedResponse = response
253+
wrapRawResponse(response, ctx, getAfterVerdict)
254+
}
255+
256+
if (afterVerdict) return afterVerdict.then(() => response)
257+
return response
258+
})
259+
.catch(error => finishAndThrow(ctx, error))
260+
})
261+
}
262+
71263
anthropicTracingChannel.end.publish(ctx)
72264

73265
return apiPromise
@@ -76,13 +268,16 @@ function wrapCreate (create) {
76268
}
77269

78270
function finish (ctx, result, error) {
271+
if (ctx.finished) return
272+
79273
if (error) {
80274
ctx.error = error
81275
anthropicTracingChannel.error.publish(ctx)
82276
}
83277

84278
// streamed responses are handled and set separately
85279
ctx.result ??= result
280+
ctx.finished = true
86281

87282
anthropicTracingChannel.asyncEnd.publish(ctx)
88283
}

0 commit comments

Comments
 (0)