Skip to content

Commit 83cabf3

Browse files
authored
fix(core): Preserve .withResponse() on Anthropic instrumentation (#19935)
The Anthropic SDK lets you call `.withResponse()` and `.asResponse()` on the result of `client.messages.create()` (see https://platform.claude.com/docs/en/api/sdks/typescript#accessing-raw-response-data-e-g-headers). Our instrumentation was breaking this because the SDK returns a custom `APIPromise` (a Promise subclass with extra methods) and our wrapping stripped those methods away. We had the exact same problem with OpenAI (#19073) and fixed it using a Proxy that routes `.then/.catch/.finally` to the instrumented promise (preserving spans) while forwarding SDK-specific methods like `.withResponse()` to the original. This PR refactors that solution into a shared utility and applies it to the Anthropic integration for both non-streaming and streaming paths. Closes #19912
1 parent 51e2cee commit 83cabf3

File tree

6 files changed

+436
-93
lines changed

6 files changed

+436
-93
lines changed
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import * as Sentry from '@sentry/node';
2+
import express from 'express';
3+
import Anthropic from '@anthropic-ai/sdk';
4+
5+
function startMockAnthropicServer() {
6+
const app = express();
7+
app.use(express.json());
8+
9+
app.post('/anthropic/v1/messages', (req, res) => {
10+
const model = req.body.model;
11+
12+
res.set('request-id', 'req_withresponse_test');
13+
14+
if (req.body.stream) {
15+
res.set('content-type', 'text/event-stream');
16+
res.flushHeaders();
17+
18+
const events = [
19+
`event: message_start\ndata: ${JSON.stringify({ type: 'message_start', message: { id: 'msg_stream_withresponse', type: 'message', role: 'assistant', model, content: [], usage: { input_tokens: 10 } } })}\n\n`,
20+
`event: content_block_start\ndata: ${JSON.stringify({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })}\n\n`,
21+
`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Streaming with response!' } })}\n\n`,
22+
`event: content_block_stop\ndata: ${JSON.stringify({ type: 'content_block_stop', index: 0 })}\n\n`,
23+
`event: message_delta\ndata: ${JSON.stringify({ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 5 } })}\n\n`,
24+
`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`,
25+
];
26+
27+
let i = 0;
28+
const interval = setInterval(() => {
29+
if (i < events.length) {
30+
res.write(events[i]);
31+
i++;
32+
} else {
33+
clearInterval(interval);
34+
res.end();
35+
}
36+
}, 10);
37+
return;
38+
}
39+
40+
res.send({
41+
id: 'msg_withresponse',
42+
type: 'message',
43+
model: model,
44+
role: 'assistant',
45+
content: [
46+
{
47+
type: 'text',
48+
text: 'Testing .withResponse() method!',
49+
},
50+
],
51+
});
52+
});
53+
54+
return new Promise(resolve => {
55+
const server = app.listen(0, () => {
56+
resolve(server);
57+
});
58+
});
59+
}
60+
61+
async function run() {
62+
const server = await startMockAnthropicServer();
63+
64+
await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
65+
const client = new Anthropic({
66+
apiKey: 'mock-api-key',
67+
baseURL: `http://localhost:${server.address().port}/anthropic`,
68+
});
69+
70+
// Test 1: Verify .withResponse() method is preserved and works correctly
71+
const result = client.messages.create({
72+
model: 'claude-3-haiku-20240307',
73+
messages: [{ role: 'user', content: 'Test withResponse' }],
74+
});
75+
76+
// Verify .withResponse() method exists and can be called
77+
if (typeof result.withResponse !== 'function') {
78+
throw new Error('.withResponse() method does not exist');
79+
}
80+
81+
// Call .withResponse() and verify structure
82+
const withResponseResult = await result.withResponse();
83+
84+
// Verify expected properties are present
85+
if (!withResponseResult.data) {
86+
throw new Error('.withResponse() did not return data');
87+
}
88+
if (!withResponseResult.response) {
89+
throw new Error('.withResponse() did not return response');
90+
}
91+
if (withResponseResult.request_id === undefined) {
92+
throw new Error('.withResponse() did not return request_id');
93+
}
94+
95+
// Verify returned data structure matches expected Anthropic response
96+
const { data } = withResponseResult;
97+
if (data.id !== 'msg_withresponse') {
98+
throw new Error(`Expected data.id to be 'msg_withresponse', got '${data.id}'`);
99+
}
100+
if (data.model !== 'claude-3-haiku-20240307') {
101+
throw new Error(`Expected data.model to be 'claude-3-haiku-20240307', got '${data.model}'`);
102+
}
103+
if (data.content[0].text !== 'Testing .withResponse() method!') {
104+
throw new Error(
105+
`Expected data.content[0].text to be 'Testing .withResponse() method!', got '${data.content[0].text}'`,
106+
);
107+
}
108+
109+
// Verify response is a Response object with correct headers
110+
if (!(withResponseResult.response instanceof Response)) {
111+
throw new Error('response is not a Response object');
112+
}
113+
if (withResponseResult.response.headers.get('request-id') !== 'req_withresponse_test') {
114+
throw new Error(
115+
`Expected request-id header 'req_withresponse_test', got '${withResponseResult.response.headers.get('request-id')}'`,
116+
);
117+
}
118+
119+
// Verify request_id matches the header
120+
if (withResponseResult.request_id !== 'req_withresponse_test') {
121+
throw new Error(`Expected request_id 'req_withresponse_test', got '${withResponseResult.request_id}'`);
122+
}
123+
124+
// Test 2: Verify .asResponse() method works
125+
const result2 = client.messages.create({
126+
model: 'claude-3-haiku-20240307',
127+
messages: [{ role: 'user', content: 'Test asResponse' }],
128+
});
129+
130+
// Verify .asResponse() method exists and can be called
131+
if (typeof result2.asResponse !== 'function') {
132+
throw new Error('.asResponse() method does not exist');
133+
}
134+
135+
// Call .asResponse() and verify it returns raw Response
136+
const rawResponse = await result2.asResponse();
137+
138+
// Verify response is a Response object with correct headers
139+
if (!(rawResponse instanceof Response)) {
140+
throw new Error('.asResponse() did not return a Response object');
141+
}
142+
143+
// Verify response has correct status
144+
if (rawResponse.status !== 200) {
145+
throw new Error(`Expected status 200, got ${rawResponse.status}`);
146+
}
147+
148+
// Verify response headers
149+
if (rawResponse.headers.get('request-id') !== 'req_withresponse_test') {
150+
throw new Error(
151+
`Expected request-id header 'req_withresponse_test', got '${rawResponse.headers.get('request-id')}'`,
152+
);
153+
}
154+
155+
// Test 3: Verify .withResponse() works with streaming (stream: true)
156+
const streamResult = client.messages.create({
157+
model: 'claude-3-haiku-20240307',
158+
messages: [{ role: 'user', content: 'Test stream withResponse' }],
159+
stream: true,
160+
});
161+
162+
if (typeof streamResult.withResponse !== 'function') {
163+
throw new Error('.withResponse() method does not exist on streaming result');
164+
}
165+
166+
const streamWithResponse = await streamResult.withResponse();
167+
168+
if (!streamWithResponse.data) {
169+
throw new Error('streaming .withResponse() did not return data');
170+
}
171+
if (!streamWithResponse.response) {
172+
throw new Error('streaming .withResponse() did not return response');
173+
}
174+
if (streamWithResponse.request_id === undefined) {
175+
throw new Error('streaming .withResponse() did not return request_id');
176+
}
177+
178+
if (!(streamWithResponse.response instanceof Response)) {
179+
throw new Error('streaming response is not a Response object');
180+
}
181+
if (streamWithResponse.response.headers.get('request-id') !== 'req_withresponse_test') {
182+
throw new Error(
183+
`Expected request-id header 'req_withresponse_test', got '${streamWithResponse.response.headers.get('request-id')}'`,
184+
);
185+
}
186+
187+
// Consume the stream to allow span to complete
188+
for await (const _ of streamWithResponse.data) {
189+
void _;
190+
}
191+
});
192+
193+
// Wait for the stream event handler to finish
194+
await Sentry.flush(2000);
195+
196+
server.close();
197+
}
198+
199+
run();

dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,37 @@ describe('Anthropic integration', () => {
359359
});
360360
});
361361

362+
createEsmAndCjsTests(__dirname, 'scenario-with-response.mjs', 'instrument.mjs', (createRunner, test) => {
363+
const chatSpan = (responseId: string) =>
364+
expect.objectContaining({
365+
data: expect.objectContaining({
366+
[GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'chat',
367+
[GEN_AI_REQUEST_MODEL_ATTRIBUTE]: 'claude-3-haiku-20240307',
368+
[GEN_AI_RESPONSE_ID_ATTRIBUTE]: responseId,
369+
}),
370+
description: 'chat claude-3-haiku-20240307',
371+
op: 'gen_ai.chat',
372+
status: 'ok',
373+
});
374+
375+
test('preserves .withResponse() and .asResponse() for non-streaming and streaming', async () => {
376+
await createRunner()
377+
.ignore('event')
378+
.expect({
379+
transaction: {
380+
transaction: 'main',
381+
spans: expect.arrayContaining([
382+
chatSpan('msg_withresponse'),
383+
chatSpan('msg_withresponse'),
384+
chatSpan('msg_stream_withresponse'),
385+
]),
386+
},
387+
})
388+
.start()
389+
.completed();
390+
});
391+
});
392+
362393
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
363394
test('creates anthropic related spans with sendDefaultPii: false', async () => {
364395
await createRunner()

packages/core/src/tracing/ai/utils.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
/**
22
* Shared utils for AI integrations (OpenAI, Anthropic, Verce.AI, etc.)
33
*/
4+
import { captureException } from '../../exports';
45
import { getClient } from '../../currentScopes';
56
import type { Span } from '../../types-hoist/span';
7+
import { isThenable } from '../../utils/is';
68
import {
79
GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE,
810
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
@@ -172,3 +174,81 @@ export function extractSystemInstructions(messages: unknown[] | unknown): {
172174

173175
return { systemInstructions, filteredMessages };
174176
}
177+
178+
/**
179+
* Creates a wrapped version of .withResponse() that replaces the data field
180+
* with the instrumented result while preserving metadata (response, request_id).
181+
*/
182+
async function createWithResponseWrapper<T>(
183+
originalWithResponse: Promise<unknown>,
184+
instrumentedPromise: Promise<T>,
185+
mechanismType: string,
186+
): Promise<unknown> {
187+
// Attach catch handler to originalWithResponse immediately to prevent unhandled rejection
188+
// If instrumentedPromise rejects first, we still need this handled
189+
const safeOriginalWithResponse = originalWithResponse.catch(error => {
190+
captureException(error, {
191+
mechanism: {
192+
handled: false,
193+
type: mechanismType,
194+
},
195+
});
196+
throw error;
197+
});
198+
199+
const instrumentedResult = await instrumentedPromise;
200+
const originalWrapper = await safeOriginalWithResponse;
201+
202+
// Combine instrumented result with original metadata
203+
if (originalWrapper && typeof originalWrapper === 'object' && 'data' in originalWrapper) {
204+
return {
205+
...originalWrapper,
206+
data: instrumentedResult,
207+
};
208+
}
209+
return instrumentedResult;
210+
}
211+
212+
/**
213+
* Wraps a promise-like object to preserve additional methods (like .withResponse())
214+
* that AI SDK clients (OpenAI, Anthropic) attach to their APIPromise return values.
215+
*
216+
* Standard Promise methods (.then, .catch, .finally) are routed to the instrumented
217+
* promise to preserve Sentry's span instrumentation, while custom SDK methods are
218+
* forwarded to the original promise to maintain the SDK's API surface.
219+
*/
220+
export function wrapPromiseWithMethods<R>(
221+
originalPromiseLike: Promise<R>,
222+
instrumentedPromise: Promise<R>,
223+
mechanismType: string,
224+
): Promise<R> {
225+
// If the original result is not thenable, return the instrumented promise
226+
if (!isThenable(originalPromiseLike)) {
227+
return instrumentedPromise;
228+
}
229+
230+
// Create a proxy that forwards Promise methods to instrumentedPromise
231+
// and preserves additional methods from the original result
232+
return new Proxy(originalPromiseLike, {
233+
get(target: object, prop: string | symbol): unknown {
234+
// For standard Promise methods (.then, .catch, .finally, Symbol.toStringTag),
235+
// use instrumentedPromise to preserve Sentry instrumentation.
236+
// For custom methods (like .withResponse()), use the original target.
237+
const useInstrumentedPromise = prop in Promise.prototype || prop === Symbol.toStringTag;
238+
const source = useInstrumentedPromise ? instrumentedPromise : target;
239+
240+
const value = Reflect.get(source, prop) as unknown;
241+
242+
// Special handling for .withResponse() to preserve instrumentation
243+
// .withResponse() returns { data: T, response: Response, request_id: string }
244+
if (prop === 'withResponse' && typeof value === 'function') {
245+
return function wrappedWithResponse(this: unknown): unknown {
246+
const originalWithResponse = (value as (...args: unknown[]) => unknown).call(target);
247+
return createWithResponseWrapper(originalWithResponse, instrumentedPromise, mechanismType);
248+
};
249+
}
250+
251+
return typeof value === 'function' ? value.bind(source) : value;
252+
},
253+
}) as Promise<R>;
254+
}

0 commit comments

Comments
 (0)