Skip to content

Commit 199b4e4

Browse files
committed
feat(aiguard): evaluating anthropic calls with AI guard automatically (#9219)
* feat(aiguard): evaluating anthropic calls with AI guard automatically
1 parent 26de44d commit 199b4e4

14 files changed

Lines changed: 2804 additions & 44 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-lifecycle
70+
steps:
71+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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: 127 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,59 @@ 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+
return verdict
43+
? Promise.all([verdict, promise]).then(([, value]) => value)
44+
: promise
45+
}
46+
47+
/**
48+
* @param {object} response
49+
* @param {'json'|'text'} method
50+
* @param {(body: object|string) => Promise<void>|undefined} getVerdict
51+
*/
52+
function wrapResponseReader (response, method, getVerdict) {
53+
if (typeof response[method] !== 'function') return
54+
55+
shimmer.wrap(response, method, original => function (...args) {
56+
return original.apply(this, args).then(body => {
57+
const verdict = getVerdict(body)
58+
return verdict ? verdict.then(() => body) : body
59+
})
60+
})
61+
}
962

1063
function wrapStreamIterator (iterator, ctx) {
1164
return function (...args) {
@@ -34,16 +87,20 @@ function wrapStreamIterator (iterator, ctx) {
3487

3588
function wrapCreate (create) {
3689
return function (...args) {
37-
if (!anthropicTracingChannel.start.hasSubscribers) {
90+
const options = args[0]
91+
const stream = options?.stream
92+
93+
const hasLifecycle = !stream && (messagesBeforeChannel.hasSubscribers || messagesAfterChannel.hasSubscribers)
94+
95+
if (!anthropicTracingChannel.start.hasSubscribers && !hasLifecycle) {
3896
return create.apply(this, args)
3997
}
4098

41-
const options = args[0]
42-
const stream = options.stream
43-
4499
const ctx = { options, resource: 'create', baseUrl: this._client?.baseURL }
45100

46101
return anthropicTracingChannel.start.runStores(ctx, () => {
102+
const parentSpan = hasLifecycle ? ctx.currentStore?.span : undefined
103+
47104
let apiPromise
48105
try {
49106
apiPromise = create.apply(this, args)
@@ -52,18 +109,76 @@ function wrapCreate (create) {
52109
throw error
53110
}
54111

55-
shimmer.wrap(apiPromise, 'parse', parse => function (...args) {
56-
return parse.apply(this, args)
112+
let afterVerdict
113+
let parseResult
114+
let wrappedResponse
115+
116+
let beforeVerdict
117+
function getBeforeVerdict () {
118+
if (!hasLifecycle || beforeVerdict) return beforeVerdict
119+
if (!messagesBeforeChannel.hasSubscribers) return
120+
121+
beforeVerdict = publishLifecycle(messagesBeforeChannel, { args, parentSpan })
122+
return beforeVerdict
123+
}
124+
125+
/**
126+
* @param {object|string} body
127+
*/
128+
function getAfterVerdict (body) {
129+
if (!hasLifecycle || afterVerdict) return afterVerdict
130+
if (!messagesAfterChannel.hasSubscribers) return
131+
132+
afterVerdict = publishLifecycle(messagesAfterChannel, { args, body, parentSpan })
133+
return afterVerdict
134+
}
135+
136+
shimmer.wrap(apiPromise, 'parse', parse => function (...parseArgs) {
137+
if (parseResult) return parseResult
138+
139+
const parsed = parse.apply(this, parseArgs)
140+
parseResult = waitForVerdict(parsed, getBeforeVerdict())
57141
.then(response => {
58142
if (stream) {
59143
shimmer.wrap(response, Symbol.asyncIterator, iterator => wrapStreamIterator(iterator, ctx))
60-
} else {
144+
return response
145+
}
146+
const verdict = getAfterVerdict(response)
147+
if (!verdict) {
148+
finish(ctx, response, null)
149+
return response
150+
}
151+
// Finish after evaluation so a block propagates the error to anthropic.request
152+
// and the span wraps its child instead of closing before it.
153+
return verdict.then(() => {
61154
finish(ctx, response, null)
155+
return response
156+
})
157+
}).catch(error => {
158+
if (!ctx.finished) finish(ctx, null, error)
159+
throw error
160+
})
161+
162+
return parseResult
163+
})
164+
165+
// Gate `.asResponse()` callers on the before verdict so raw-response paths still block,
166+
// then evaluate output only if the caller consumes the JSON body.
167+
shimmer.wrap(apiPromise, 'asResponse', origAsResponse => function (...asResponseArgs) {
168+
return waitForVerdict(origAsResponse.apply(this, asResponseArgs), getBeforeVerdict())
169+
.then(response => {
170+
if (!stream && hasLifecycle && wrappedResponse !== response) {
171+
wrappedResponse = response
172+
wrapResponseReader(response, 'json', getAfterVerdict)
173+
wrapResponseReader(response, 'text', getAfterVerdict)
62174
}
63175

176+
if (afterVerdict) return afterVerdict.then(() => response)
177+
if (!stream && !ctx.finished && !parseResult) finish(ctx, null, null)
64178
return response
65-
}).catch(error => {
66-
finish(ctx, null, error)
179+
})
180+
.catch(error => {
181+
if (!ctx.finished) finish(ctx, null, error)
67182
throw error
68183
})
69184
})
@@ -76,13 +191,16 @@ function wrapCreate (create) {
76191
}
77192

78193
function finish (ctx, result, error) {
194+
if (ctx.finished) return
195+
79196
if (error) {
80197
ctx.error = error
81198
anthropicTracingChannel.error.publish(ctx)
82199
}
83200

84201
// streamed responses are handled and set separately
85202
ctx.result ??= result
203+
ctx.finished = true
86204

87205
anthropicTracingChannel.asyncEnd.publish(ctx)
88206
}

0 commit comments

Comments
 (0)