@@ -6,6 +6,144 @@ const { addHook } = require('./helpers/instrument')
66
77const anthropicTracingChannel = tracingChannel ( 'apm:anthropic:request' )
88const 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
10148function wrapStreamIterator ( iterator , ctx ) {
11149 return function ( ...args ) {
@@ -34,40 +172,94 @@ function wrapStreamIterator (iterator, ctx) {
34172
35173function 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
78270function 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