Skip to content

Commit 019c889

Browse files
minwookshincodexJPeer264
authored
feat(bun): keep server spans open until streaming responses finish (#25025)
Bun server spans now remain open until a streaming response completes, errors, or is cancelled. Source failures are captured with their request context and mark the span as failed; client cancellation remains separate. The shared content-type classifier lives in `server-utils`, with Bun and Deno using the same implementation. Targeted classifier, Bun unit/integration and Deno streaming tests pass, along with the four affected package builds/type checks and scoped lint/formatting. Earlier checks found base-reproduced Bun test-typecheck diagnostics and a Deno concurrent-request failure; those broader checks were not rerun for this move. Fixes #23883. --------- Co-authored-by: GPT-6 <codex@openai.com> Co-authored-by: JPeer264 <jan.peer@sentry.io>
1 parent 99d3ed6 commit 019c889

10 files changed

Lines changed: 366 additions & 49 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { sendPortToRunner } from '@sentry-internal/node-integration-tests';
2+
import * as Sentry from '@sentry/bun';
3+
4+
Sentry.init({ dsn: process.env.SENTRY_DSN, tracesSampleRate: 1 });
5+
6+
const server = Bun.serve({
7+
port: 0,
8+
fetch(request) {
9+
const path = new URL(request.url).pathname;
10+
if (path === '/error') {
11+
throw new Error('handler failed');
12+
}
13+
const span = Sentry.getActiveSpan();
14+
const body = new ReadableStream({
15+
async start(controller) {
16+
controller.enqueue(new TextEncoder().encode('data: first\n\n'));
17+
await Bun.sleep(50);
18+
if (path === '/stream-error') {
19+
controller.error(new Error('stream failed'));
20+
return;
21+
}
22+
controller.enqueue(new TextEncoder().encode(`data: recording=${span?.isRecording()}\n\n`));
23+
span?.setAttribute('test.stream.completed', true);
24+
controller.close();
25+
},
26+
});
27+
return new Response(body, { status: 201, headers: { 'content-type': 'text/event-stream' } });
28+
},
29+
error() {
30+
return new Response('failed', { status: 500 });
31+
},
32+
});
33+
sendPortToRunner(server.port!);
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { afterAll, expect, test } from 'vitest';
2+
import { cleanupChildProcesses, createRunner } from '../../../node-integration-tests/utils/runner';
3+
4+
afterAll(cleanupChildProcesses);
5+
6+
test('records streaming work after the response headers have been returned', async () => {
7+
const runner = createRunner(__dirname, 'index.ts')
8+
.withMockSentryServer()
9+
.unordered()
10+
.expect({
11+
span: container => {
12+
const root = container.items.find(item => item.is_segment);
13+
expect(root).toEqual(
14+
expect.objectContaining({
15+
attributes: expect.objectContaining({
16+
'url.path': { value: '/events', type: 'string' },
17+
'http.response.status_code': { value: 201, type: 'integer' },
18+
'test.stream.completed': { value: true, type: 'boolean' },
19+
}),
20+
}),
21+
);
22+
},
23+
})
24+
.start();
25+
expect(await runner.makeRequest('get', '/events')).toBe('data: first\n\ndata: recording=true\n\n');
26+
await runner.completed();
27+
});
28+
29+
test('records a thrown handler as an error span', async () => {
30+
const runner = createRunner(__dirname, 'index.ts')
31+
.withMockSentryServer()
32+
.unordered()
33+
.expect({
34+
span: container => {
35+
const root = container.items.find(item => item.is_segment);
36+
expect(root).toEqual(expect.objectContaining({ status: 'error' }));
37+
},
38+
})
39+
.start();
40+
await runner.makeRequest('get', '/error', { expectError: true });
41+
await runner.completed();
42+
});
43+
44+
test('captures a response stream failure with its request context and error span', async () => {
45+
let spanTraceId: string | undefined;
46+
let eventTraceId: string | undefined;
47+
const runner = createRunner(__dirname, 'index.ts')
48+
.withMockSentryServer()
49+
.unordered()
50+
.expect({
51+
span: container => {
52+
const roots = container.items.filter(item => item.is_segment);
53+
expect(roots).toHaveLength(1);
54+
const root = roots[0]!;
55+
expect(root.status).toBe('error');
56+
expect(root.attributes['url.path']?.value).toBe('/stream-error');
57+
expect(root.attributes['http.response.status_code']?.value).toBe(201);
58+
spanTraceId = root.trace_id;
59+
},
60+
})
61+
.expect({
62+
event: event => {
63+
expect(event.exception?.values).toHaveLength(1);
64+
const exception = event.exception!.values![0]!;
65+
expect(exception.type).toBe('Error');
66+
expect(exception.value).toBe('stream failed');
67+
expect(exception.mechanism).toEqual({ type: 'auto.http.bun.serve', handled: false });
68+
expect(event.request?.method).toBe('GET');
69+
expect(new URL(event.request!.url!).pathname).toBe('/stream-error');
70+
eventTraceId = event.contexts?.trace?.trace_id;
71+
},
72+
})
73+
.start();
74+
75+
await expect.poll(() => runner.getPort(), { timeout: 30_000 }).toBeTypeOf('number');
76+
const response = await fetch(`http://localhost:${runner.getPort()}/stream-error`);
77+
expect(response.status).toBe(201);
78+
// Bun versions differ in whether a failed stream rejects or closes the HTTP response body.
79+
await Promise.allSettled([response.text()]);
80+
await runner.completed();
81+
expect(spanTraceId).toMatch(/^[a-f\d]{32}$/);
82+
expect(eventTraceId).toBe(spanTraceId);
83+
});

‎packages/bun/src/integrations/bunserver.ts‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,14 @@ import {
1313
isURLObjectRelative,
1414
parseStringToURLObject,
1515
setHttpStatus,
16-
startSpan,
16+
SPAN_STATUS_ERROR,
17+
startSpanManual,
1718
winterCGRequestToRequestData,
1819
withIsolationScope,
1920
filterCollectedUrl,
2021
filterCollectedUrlQuery,
2122
} from '@sentry/core';
22-
import { getClientIPAddress } from '@sentry/core/server';
23+
import { classifyResponseStreaming, getClientIPAddress } from '@sentry/core/server';
2324
import type { Server, ServeOptions } from 'bun';
2425
import {
2526
CLIENT_ADDRESS,
@@ -38,6 +39,7 @@ import {
3839
SENTRY_ORIGIN,
3940
} from '@sentry/conventions/attributes';
4041
import { HTTP_SERVER } from '@sentry/conventions/op';
42+
import { monitorStream } from '../utils/streaming';
4143

4244
const INTEGRATION_NAME = 'BunServer' as const;
4345

@@ -294,7 +296,7 @@ function wrapRequestHandler<T extends RouteHandler = RouteHandler>(
294296
baggage: request.headers.get('baggage'),
295297
},
296298
() =>
297-
startSpan(
299+
startSpanManual(
298300
{
299301
attributes: { ...attributes, [SENTRY_OP]: HTTP_SERVER },
300302
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL path.
@@ -303,7 +305,7 @@ function wrapRequestHandler<T extends RouteHandler = RouteHandler>(
303305
? `${request.method} ${routeName}`
304306
: request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK,
305307
},
306-
async span => {
308+
async (span, endSpan) => {
307309
try {
308310
const response = (await target.apply(thisArg, args)) as Response | undefined;
309311
if (response?.status) {
@@ -319,14 +321,28 @@ function wrapRequestHandler<T extends RouteHandler = RouteHandler>(
319321
);
320322
}
321323
}
324+
if (response?.body && !response.body.locked && classifyResponseStreaming(response).isStreaming) {
325+
const body = monitorStream(response.body, endSpan, error => {
326+
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
327+
captureException(error, { mechanism: { type: 'auto.http.bun.serve', handled: false } });
328+
});
329+
return new Response(body, {
330+
status: response.status,
331+
statusText: response.statusText,
332+
headers: response.headers,
333+
});
334+
}
335+
endSpan();
322336
return response;
323337
} catch (e) {
338+
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
324339
captureException(e, {
325340
mechanism: {
326341
type: 'auto.http.bun.serve',
327342
handled: false,
328343
},
329344
});
345+
endSpan();
330346
throw e;
331347
}
332348
},
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/** Monitor the source without reading ahead or treating cancellation as a source failure. */
2+
export function monitorStream(
3+
stream: ReadableStream<Uint8Array>,
4+
onDone: () => void,
5+
onError: (error: unknown) => void,
6+
): ReadableStream<Uint8Array> {
7+
const reader = stream.getReader();
8+
// A cancelled reader closes normally; only a source failure should be captured as an error.
9+
void reader.closed.then(onDone, error => {
10+
onError(error);
11+
onDone();
12+
});
13+
14+
return new ReadableStream<Uint8Array>(
15+
{
16+
async pull(controller) {
17+
try {
18+
const { done, value } = await reader.read();
19+
if (done) {
20+
controller.close();
21+
reader.releaseLock();
22+
} else {
23+
controller.enqueue(value);
24+
}
25+
} catch (error) {
26+
controller.error(error);
27+
reader.releaseLock();
28+
}
29+
},
30+
async cancel(reason) {
31+
try {
32+
await reader.cancel(reason);
33+
} finally {
34+
reader.releaseLock();
35+
}
36+
},
37+
},
38+
{ highWaterMark: 0 },
39+
);
40+
}

0 commit comments

Comments
 (0)