Skip to content

Commit f26ca68

Browse files
RulaKhaledclaude
andcommitted
fix(server-utils): Cover every way an Anthropic SSE body gets drained
Handing the span to the SSE body wrapper left several paths uncovered. A body that is not a web ReadableStream, which is what an injected node-fetch or undici shim hands back, could not be wrapped at all, so the span ended the moment create() resolved with request attributes only. Patching the SDK Stream's iterator now serves as the fallback for those. clone() tees the response's internal body and swaps in one branch, which left the stream the wrapper had captured locked, so the next read threw. The wrapper now resolves the source through the prototype getter on every read instead of holding on to the stream it was handed. text(), json() and arrayBuffer() read the internal body and never touch the property we shadow, so the span never ended. Those are wrapped too, and text() and arrayBuffer() feed their result through the accumulator so the response attributes survive. Three smaller ones: recordOutputs is resolved per call rather than once at subscribe time, since subscribeToSseStream runs once per process and a later client can carry different options. The frame parser's try now covers a single frame, so one unparsable line no longer costs us the rest of its chunk, where message_delta and message_stop ride. An unsampled span skips the wrap entirely, and settle() flushes a trailing frame left without a newline. The pass-through is a byte stream now, so getReader({ mode: 'byob' }) keeps working on a response that supported it before. instrumentRawSseBody moved to its own file to stay under the line cap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 50af097 commit f26ca68

7 files changed

Lines changed: 554 additions & 106 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { Readable } from 'node:stream';
2+
import Anthropic from '@anthropic-ai/sdk';
3+
import * as Sentry from '@sentry/node';
4+
import express from 'express';
5+
6+
function startMockAnthropicServer() {
7+
const app = express();
8+
app.use(express.json());
9+
10+
app.post('/anthropic/v1/messages', (req, res) => {
11+
res.writeHead(200, {
12+
'Content-Type': 'text/event-stream',
13+
'Cache-Control': 'no-cache',
14+
Connection: 'keep-alive',
15+
});
16+
17+
const model = req.body.model;
18+
const events = [
19+
{
20+
type: 'message_start',
21+
message: {
22+
id: 'msg_node_body',
23+
type: 'message',
24+
role: 'assistant',
25+
model,
26+
content: [],
27+
usage: { input_tokens: 10 },
28+
},
29+
},
30+
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
31+
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Node ' } },
32+
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'body!' } },
33+
{ type: 'content_block_stop', index: 0 },
34+
{
35+
type: 'message_delta',
36+
delta: { stop_reason: 'end_turn', stop_sequence: null },
37+
usage: { output_tokens: 15 },
38+
},
39+
{ type: 'message_stop' },
40+
];
41+
42+
events.forEach((event, index) => {
43+
setTimeout(() => {
44+
res.write(`event: ${event.type}\n`);
45+
res.write(`data: ${JSON.stringify(event)}\n\n`);
46+
if (index === events.length - 1) {
47+
res.end();
48+
}
49+
}, index * 10);
50+
});
51+
});
52+
53+
return new Promise(resolve => {
54+
const server = app.listen(0, () => {
55+
resolve(server);
56+
});
57+
});
58+
}
59+
60+
// Stands in for the node-fetch/undici-compat shims callers pass as `fetch`, whose responses carry a
61+
// Node `Readable` body the SSE body wrapper can't wrap.
62+
async function fetchWithNodeStreamBody(url, init) {
63+
const response = await fetch(url, init);
64+
Object.defineProperty(response, 'body', {
65+
value: Readable.fromWeb(response.body),
66+
configurable: true,
67+
});
68+
return response;
69+
}
70+
71+
async function run() {
72+
const server = await startMockAnthropicServer();
73+
74+
await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
75+
const client = new Anthropic({
76+
apiKey: 'mock-api-key',
77+
baseURL: `http://localhost:${server.address().port}/anthropic`,
78+
fetch: fetchWithNodeStreamBody,
79+
});
80+
81+
const stream = await client.messages.create({
82+
model: 'claude-3-haiku-20240307',
83+
messages: [{ role: 'user', content: 'Stream this please' }],
84+
stream: true,
85+
});
86+
87+
for await (const _ of stream) {
88+
void _;
89+
}
90+
});
91+
92+
await Sentry.flush(2000);
93+
94+
server.close();
95+
}
96+
97+
run();

‎dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream-raw-body.mjs‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,34 @@ async function run() {
8989
for await (const _ of stream) {
9090
void _;
9191
}
92+
93+
// 3) Clone first, then drain. `clone()` tees the response's internal body and swaps in one branch,
94+
// so the wrapper has to re-read the body rather than hold on to the stream it was handed.
95+
const cloned = await client.messages.create({ ...params }).asResponse();
96+
const copy = cloned.clone();
97+
for await (const _ of cloned.body) {
98+
void _;
99+
}
100+
void copy;
101+
102+
// 4) Read the body as text, which never touches the `body` property at all
103+
const asText = await client.messages.create({ ...params }).asResponse();
104+
const text = await asText.text();
105+
if (!text.includes('message_stop')) {
106+
throw new Error('raw Response text did not contain the streamed frames');
107+
}
108+
109+
// 5) Read the body through a BYOB reader, which only a byte stream supports
110+
const byob = await client.messages.create({ ...params }).asResponse();
111+
const reader = byob.body.getReader({ mode: 'byob' });
112+
let buffer = new ArrayBuffer(1024);
113+
for (;;) {
114+
const { done, value } = await reader.read(new Uint8Array(buffer, 0, 1024));
115+
if (done) {
116+
break;
117+
}
118+
buffer = value.buffer;
119+
}
92120
});
93121

94122
await Sentry.flush(2000);

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

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -272,9 +272,10 @@ describe('Anthropic integration', () => {
272272
.expect({
273273
span: container => {
274274
const genAiSpans = container.items.filter(span => span.attributes['sentry.op']?.value === 'gen_ai.chat');
275-
// Two calls, one drained via `.asResponse()` and one via the SDK `Stream`. Both must end,
276-
// and both must carry the response attributes accumulated off the SSE frames.
277-
expect(genAiSpans).toHaveLength(2);
275+
// One call per way of draining the stream: the raw `.asResponse()` body, the SDK `Stream`,
276+
// a cloned response, `text()`, and a BYOB reader. Every one of them must end its span with
277+
// the response attributes accumulated off the SSE frames.
278+
expect(genAiSpans).toHaveLength(5);
278279
for (const span of genAiSpans) {
279280
expect(span.name).toBe('chat claude-3-haiku-20240307');
280281
expect(span.status).toBe('ok');
@@ -294,6 +295,31 @@ describe('Anthropic integration', () => {
294295
});
295296
});
296297

298+
createEsmAndCjsTests(__dirname, 'scenario-stream-node-body.mjs', 'instrument-raw-body.mjs', (createRunner, test) => {
299+
test('ends the span when the response body is not a web ReadableStream', async () => {
300+
await createRunner()
301+
.unordered()
302+
.expect({
303+
span: container => {
304+
const genAiSpan = container.items.find(span => span.attributes['sentry.op']?.value === 'gen_ai.chat');
305+
// The body wrapper has nothing to hold on to here, so the SDK `Stream`'s iterator has to
306+
// carry the span instead — otherwise it would end with request attributes only.
307+
expect(genAiSpan).toBeDefined();
308+
expect(genAiSpan!.status).toBe('ok');
309+
expect(genAiSpan!.attributes[GEN_AI_RESPONSE_STREAMING].value).toBe(true);
310+
expect(genAiSpan!.attributes[GEN_AI_RESPONSE_ID].value).toBe('msg_node_body');
311+
expect(genAiSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS].value).toBe('["end_turn"]');
312+
expect(genAiSpan!.attributes[GEN_AI_RESPONSE_TEXT].value).toBe('Node body!');
313+
expect(genAiSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(10);
314+
expect(genAiSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(15);
315+
expect(genAiSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(25);
316+
},
317+
})
318+
.start()
319+
.completed();
320+
});
321+
});
322+
297323
createEsmAndCjsTests(__dirname, 'scenario-stream.mjs', 'instrument-with-pii.mjs', (createRunner, test) => {
298324
test('streams record response text when PII true', async () => {
299325
await createRunner()
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import type { Span } from '@sentry/core';
2+
import { endStreamSpan } from '../core/utils';
3+
import { createStreamingState, processEvent } from './streaming';
4+
import type { AnthropicAiStreamingEvent } from './types';
5+
6+
/** The slice of a stream controller the SSE pass-through uses, shared by the byte and default variants. */
7+
interface SseStreamController {
8+
enqueue: (chunk: Uint8Array) => void;
9+
close: () => void;
10+
error: (reason?: unknown) => void;
11+
}
12+
13+
/** Walks the prototype chain for a getter, so a shadowing own property doesn't hide the original. */
14+
function findBoundGetter(target: object, key: string): (() => unknown) | undefined {
15+
for (let proto = Object.getPrototypeOf(target); proto; proto = Object.getPrototypeOf(proto)) {
16+
const descriptor = Object.getOwnPropertyDescriptor(proto, key);
17+
if (descriptor?.get) {
18+
return descriptor.get.bind(target);
19+
}
20+
}
21+
return undefined;
22+
}
23+
24+
/**
25+
* Replace `response.body` with a pass-through that accumulates the SSE frames flowing through it and
26+
* ends `span` once the body is exhausted, cancelled or errors.
27+
*
28+
* Every way of draining an Anthropic stream bottoms out in `response.body`: the SDK `Stream`'s async
29+
* iterator, `tee()`, and a caller reading the raw `Response` from `.asResponse()`/`.withResponse()`.
30+
* Instrumenting the body instead of the `Stream` covers all of them with one accumulator.
31+
*
32+
* Returns `false`, leaving the response untouched, for a body we can't wrap.
33+
*
34+
* @internal Exported for the diagnostics-channel integration.
35+
*/
36+
export function instrumentRawSseBody(response: { body?: unknown }, span: Span, recordOutputs: boolean): boolean {
37+
const body = response.body as ReadableStream<Uint8Array> | null | undefined;
38+
// An unsampled span is discarded by `endStreamSpan`, so decoding and parsing every frame for it buys
39+
// nothing — leave the body alone and let the caller's response stay exactly as the SDK built it.
40+
if (!body || typeof body.getReader !== 'function' || !span.isRecording()) {
41+
return false;
42+
}
43+
44+
const state = createStreamingState();
45+
const decoder = new TextDecoder();
46+
let buffered = '';
47+
let settled = false;
48+
49+
// Never lets an accumulation failure reach the caller: their stream matters more than our attributes.
50+
// Scoped to a single frame so one line we can't parse doesn't cost us its neighbours — `message_delta`
51+
// (token usage) and `message_stop` ride in the last chunk, where a bad frame would hurt most.
52+
const consumeFrame = (line: string): void => {
53+
// An SSE frame's `event:` line only repeats the `type` already carried by the JSON payload.
54+
if (!line.startsWith('data:')) {
55+
return;
56+
}
57+
try {
58+
processEvent(JSON.parse(line.slice(5)) as AnthropicAiStreamingEvent, state, recordOutputs, span);
59+
} catch {
60+
// A frame we can't parse is not worth breaking the caller's stream over.
61+
}
62+
};
63+
64+
const consumeText = (text: string): void => {
65+
buffered += text;
66+
67+
let newline = buffered.indexOf('\n');
68+
while (newline !== -1) {
69+
consumeFrame(buffered.slice(0, newline).trim());
70+
buffered = buffered.slice(newline + 1);
71+
newline = buffered.indexOf('\n');
72+
}
73+
};
74+
75+
const consumeBytes = (chunk: Uint8Array): void => {
76+
try {
77+
consumeText(decoder.decode(chunk, { stream: true }));
78+
} catch {
79+
// As above: a chunk we can't decode is not worth breaking the caller's stream over.
80+
}
81+
};
82+
83+
// No error status on a torn-down body, matching `instrumentAsyncIterableStream`: the SDK surfaces the
84+
// failure to the caller, and an `error` SSE frame already marks the span through `isErrorEvent`.
85+
const settle = (): void => {
86+
if (settled) {
87+
return;
88+
}
89+
settled = true;
90+
// A body that ends without a trailing newline leaves its last frame sitting in `buffered`.
91+
const trailing = buffered.trim();
92+
buffered = '';
93+
if (trailing) {
94+
consumeFrame(trailing);
95+
}
96+
endStreamSpan(span, state, recordOutputs);
97+
};
98+
99+
// Resolved on every read rather than captured at wrap time: `clone()` tees the response's *internal*
100+
// body and swaps in one branch, which leaves the stream we were handed permanently locked.
101+
const readBody = findBoundGetter(response, 'body');
102+
const source = (): ReadableStream<Uint8Array> => (readBody?.() as ReadableStream<Uint8Array> | null) ?? body;
103+
104+
// Acquired on the first read, never at wrap time: taking a reader disturbs the body, which would
105+
// make `response.text()`, `arrayBuffer()` and `clone()` throw on a response nobody has read yet.
106+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
107+
108+
const underlyingSource = {
109+
async pull(controller: SseStreamController): Promise<void> {
110+
try {
111+
reader ??= source().getReader();
112+
const { done, value } = await reader.read();
113+
if (done) {
114+
settle();
115+
controller.close();
116+
return;
117+
}
118+
// Must precede the enqueue: a byte stream transfers the chunk's buffer, detaching it.
119+
consumeBytes(value);
120+
controller.enqueue(value);
121+
} catch (error) {
122+
settle();
123+
controller.error(error);
124+
}
125+
},
126+
async cancel(reason: unknown): Promise<void> {
127+
settle();
128+
await (reader ? reader.cancel(reason) : source().cancel(reason));
129+
},
130+
};
131+
132+
// A high-water mark of 0 keeps the stream from pulling a chunk before anyone asks for one. The
133+
// default of 1 would read ahead the moment we wrap, disturbing a body the caller may never read.
134+
const strategy = { highWaterMark: 0 };
135+
let instrumented: ReadableStream<Uint8Array>;
136+
try {
137+
// `response.body` is a byte stream, so a plain one here would break `getReader({ mode: 'byob' })`
138+
// on a response that supported it before we touched it.
139+
instrumented = new ReadableStream<Uint8Array>(
140+
{ ...underlyingSource, type: 'bytes' } as unknown as UnderlyingSource<Uint8Array>,
141+
strategy,
142+
);
143+
} catch {
144+
instrumented = new ReadableStream<Uint8Array>(underlyingSource as UnderlyingSource<Uint8Array>, strategy);
145+
}
146+
147+
try {
148+
// `body` is a prototype getter, so an own data property shadows it for every later read.
149+
Object.defineProperty(response, 'body', { value: instrumented, configurable: true });
150+
} catch {
151+
return false;
152+
}
153+
154+
// `text()`, `json()` and `arrayBuffer()` read the response's internal body and never touch the
155+
// property we just shadowed. Without wrapping them too, a caller draining the stream that way would
156+
// leave the span unended forever, since nothing else ends it once we take ownership.
157+
instrumentBodyConsumers(response, consumeText, settle);
158+
159+
return true;
160+
}
161+
162+
const BODY_CONSUMERS = ['text', 'json', 'arrayBuffer'] as const;
163+
164+
function instrumentBodyConsumers(
165+
response: Record<string, unknown>,
166+
consumeText: (text: string) => void,
167+
settle: () => void,
168+
): void {
169+
for (const name of BODY_CONSUMERS) {
170+
const original = response[name];
171+
if (typeof original !== 'function') {
172+
continue;
173+
}
174+
175+
const wrapped = function (this: unknown, ...args: unknown[]): Promise<unknown> {
176+
return Promise.resolve((original as (...a: unknown[]) => unknown).apply(this ?? response, args)).then(
177+
result => {
178+
// Whatever came back is the stream we would have accumulated off the body, so read the frames
179+
// out of it rather than settling for a span with request attributes only. `json()` returns
180+
// neither shape, since parsing an SSE body as JSON is a caller error to begin with.
181+
if (typeof result === 'string') {
182+
consumeText(result);
183+
} else if (result instanceof ArrayBuffer) {
184+
consumeText(new TextDecoder().decode(result));
185+
}
186+
settle();
187+
return result;
188+
},
189+
error => {
190+
settle();
191+
throw error;
192+
},
193+
);
194+
};
195+
196+
try {
197+
Object.defineProperty(response, name, { value: wrapped, configurable: true, writable: true });
198+
} catch {
199+
// A consumer we can't wrap just means the span leans on the body wrapper to end.
200+
}
201+
}
202+
}

0 commit comments

Comments
 (0)