Skip to content

Commit ff5ddbf

Browse files
isaacsclaude
andcommitted
feat(bun): Add incoming request options and sessions to bunServerIntegration
`Bun.serve` took only `maxRequestBodySize`. A user could not skip body capture or spans for one route, and release health showed no request sessions or crash-free rate for `Bun.serve` apps. `denoServeIntegration` got these in #25493 and #25496. `bunServerIntegration` now accepts `ignoreRequestBody`, `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated`, `onSpanEnd`, `sessions` and `sessionFlushingDelayMS`. They use the same names and behavior as `denoServeIntegration`, and the callbacks receive the web `Request`. The shared `ServerRequestOptions` check for `Bun.serve` now lists no unsupported options. When the SDK skips the span, it still continues the incoming trace, attaches request data and the body, records the session and captures errors. For a streamed response, `onSpanEnd` and the session end when the stream finishes. A user callback that throws does not fail the request. If `ignoreRequestBody` throws, the SDK skips the body, so it cannot leak data that the filter was meant to hide. Three changes in behavior: - `ignoreStaticAssets` defaults to `true`, so `GET` requests for files such as `favicon.ico` or `*.js` no longer create spans. - `spans` defaults to the client's tracing configuration, so the integration no longer starts spans when tracing is disabled. - Errors thrown for `OPTIONS` and `HEAD` requests are now captured. These requests also record sessions, as on Node and Deno. Fixes #25571 Fixes JS-3969 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 32fc3b4 commit ff5ddbf

3 files changed

Lines changed: 706 additions & 64 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @ph
5151

5252
`denoServeIntegration` now records a request session for each incoming request, as `denoHttpIntegration` does for `node:http`. This gives release health and crash-free rates for `Deno.serve` apps. Sessions are sent only when a `release` is set. To turn them off, set `denoServeIntegration({ sessions: false })`. `sessionFlushingDelayMS` sets how long the SDK waits before it sends the session aggregate.
5353

54+
- **feat(bun): Add incoming request options and request sessions to `bunServerIntegration`**
55+
56+
`bunServerIntegration` now accepts the same options as `denoServeIntegration`: `ignoreRequestBody`, `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated`, `onSpanEnd`, `sessions` and `sessionFlushingDelayMS`. The callbacks receive the web `Request`.
57+
58+
`Bun.serve` now records a request session for each incoming request. Sessions are sent only when a `release` is set. To turn them off, set `bunServerIntegration({ sessions: false })`.
59+
60+
`ignoreStaticAssets` defaults to `true`, so `Bun.serve` no longer creates spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `bunServerIntegration({ ignoreStaticAssets: false })`. `Bun.serve` spans also follow the client's tracing configuration by default, so the integration does not start spans when tracing is disabled. Errors thrown for `OPTIONS` and `HEAD` requests are now captured.
61+
5462
## 11.8.0
5563

5664
### Important Changes

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

Lines changed: 223 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
/* eslint-disable max-lines */
2-
import type { Integration, IntegrationFn, MaxRequestBodySize, SpanAttributes } from '@sentry/core';
2+
import type { Client, Integration, IntegrationFn, MaxRequestBodySize, Span, SpanAttributes } from '@sentry/core';
33
import {
44
captureBodyFromWinterCGRequest,
55
captureException,
66
continueTrace,
7+
debug,
78
defineIntegration,
89
getClient,
910
getUrlFragment,
1011
getUrlQuery,
12+
hasSpansEnabled,
1113
hasSpanStreamingEnabled,
1214
httpHeadersToSpanAttributes,
1315
HTTP_SPAN_NAME_FALLBACK,
@@ -22,7 +24,12 @@ import {
2224
filterCollectedUrlQuery,
2325
} from '@sentry/core';
2426
import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server';
25-
import { classifyResponseStreaming, getClientIPAddress } from '@sentry/core/server';
27+
import {
28+
classifyResponseStreaming,
29+
getClientIPAddress,
30+
isStaticAssetRequest,
31+
startRequestSession,
32+
} from '@sentry/core/server';
2633
import type { Server, ServeOptions } from 'bun';
2734
import {
2835
CLIENT_ADDRESS,
@@ -55,32 +62,86 @@ export type BunServerIntegrationOptions = {
5562
* @default 'medium'
5663
*/
5764
maxRequestBodySize?: MaxRequestBodySize;
65+
66+
/**
67+
* Do not capture the request body for incoming requests where the given callback returns `true`.
68+
*
69+
* `url` is the full URL of the request, including the query string. `request` is the incoming
70+
* {@link Request}. The callback runs before the SDK reads the body.
71+
*/
72+
ignoreRequestBody?: (url: string, request: Request) => boolean;
73+
74+
/**
75+
* Do not capture server spans for incoming requests whose URL path makes the given callback return `true`.
76+
*
77+
* `urlPath` is the path of the request, including the query string. `request` is the incoming
78+
* {@link Request}. Errors thrown by the handler are still captured.
79+
*/
80+
ignoreIncomingRequests?: (urlPath: string, request: Request) => boolean;
81+
82+
/**
83+
* Whether to automatically ignore common static asset requests (favicon.ico, robots.txt, etc.)
84+
* when creating server spans.
85+
*
86+
* @default `true`
87+
*/
88+
ignoreStaticAssets?: boolean;
89+
90+
/**
91+
* Whether to create spans for incoming requests.
92+
* Errors are still captured and request data is still attached when this is `false`.
93+
* Defaults to the client's tracing configuration (`hasSpansEnabled`).
94+
*/
95+
spans?: boolean;
96+
97+
/**
98+
* A hook that can be used to mutate the span for incoming requests.
99+
* This is triggered after the span is created, before the request handler runs.
100+
*/
101+
onSpanCreated?: (span: Span, request: Request) => void;
102+
103+
/**
104+
* A hook that can be used to mutate the span one last time before it ends.
105+
* For a streamed response, this runs when the stream finishes.
106+
* It is not called when the request handler throws.
107+
*/
108+
onSpanEnd?: (span: Span, request: Request, response: Response) => void;
109+
110+
/**
111+
* Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for
112+
* incoming requests to track the health and crash-free rate of your releases in Sentry.
113+
*
114+
* For a streamed response, the session ends when the stream finishes.
115+
*
116+
* @default `true`
117+
*/
118+
sessions?: boolean;
119+
120+
/**
121+
* Number of milliseconds until sessions are flushed as a session aggregate.
122+
*
123+
* @default `60000` (60s)
124+
*/
125+
sessionFlushingDelayMS?: number;
58126
};
59127

60128
// Checks these options against the shared `ServerRequestOptions`. A new
61129
// shared field fails to compile here, until this integration supports
62130
// it or lists it as unsupported.
63-
type _ServerRequestOptionsCheck = AssertTrue<
64-
CheckServerRequestOptions<
65-
BunServerIntegrationOptions,
66-
Request,
67-
Response,
68-
// Not supported yet.
69-
| 'spans'
70-
| 'sessions'
71-
| 'sessionFlushingDelayMS'
72-
| 'ignoreRequestBody'
73-
| 'ignoreIncomingRequests'
74-
| 'ignoreStaticAssets'
75-
| 'onSpanCreated'
76-
| 'onSpanEnd'
77-
>
78-
>;
131+
type _ServerRequestOptionsCheck = AssertTrue<CheckServerRequestOptions<BunServerIntegrationOptions, Request, Response>>;
79132

80133
const _bunServerIntegration = ((options: BunServerIntegrationOptions = {}) => {
81134
return {
82135
name: INTEGRATION_NAME,
83136
maxRequestBodySize: options.maxRequestBodySize,
137+
ignoreRequestBody: options.ignoreRequestBody,
138+
ignoreIncomingRequests: options.ignoreIncomingRequests,
139+
ignoreStaticAssets: options.ignoreStaticAssets ?? true,
140+
spans: options.spans,
141+
onSpanCreated: options.onSpanCreated,
142+
onSpanEnd: options.onSpanEnd,
143+
sessions: options.sessions ?? true,
144+
sessionFlushingDelayMS: options.sessionFlushingDelayMS,
84145
setupOnce() {
85146
instrumentBunServe();
86147
},
@@ -230,6 +291,59 @@ type RouteHandler = Extract<
230291
Function
231292
>;
232293

294+
function captureServeError(error: unknown): void {
295+
captureException(error, { mechanism: { type: 'auto.http.bun.serve', handled: false } });
296+
}
297+
298+
// A user callback that throws must not fail the request or leave the span open.
299+
function callUserCallback<T>(name: string, callback: () => T, fallback: T): T {
300+
try {
301+
return callback();
302+
} catch (e) {
303+
debug.error(`Error in the Bun.serve ${name} callback.`, e);
304+
return fallback;
305+
}
306+
}
307+
308+
function shouldCreateSpan(
309+
client: Client | undefined,
310+
integration: BunServerIntegrationOptions | undefined,
311+
request: Request,
312+
urlPath: string,
313+
): boolean {
314+
if (!(integration?.spans ?? (client ? hasSpansEnabled(client.getOptions()) : false))) {
315+
return false;
316+
}
317+
if (integration?.ignoreStaticAssets && request.method.toUpperCase() === 'GET' && isStaticAssetRequest(urlPath)) {
318+
return false;
319+
}
320+
const ignoreIncomingRequests = integration?.ignoreIncomingRequests;
321+
return !(
322+
ignoreIncomingRequests &&
323+
callUserCallback('ignoreIncomingRequests', () => ignoreIncomingRequests(urlPath, request), false)
324+
);
325+
}
326+
327+
/**
328+
* Calls `onDone` when the response is done: at once, or when a streamed body finishes.
329+
* Returns the response to send.
330+
*/
331+
function onResponseDone<T extends Response | undefined>(response: T, onDone: () => void, onStreamError: () => void): T {
332+
if (response?.body && !response.body.locked && classifyResponseStreaming(response).isStreaming) {
333+
const body = monitorStream(response.body, onDone, error => {
334+
onStreamError();
335+
captureServeError(error);
336+
});
337+
return new Response(body, {
338+
status: response.status,
339+
statusText: response.statusText,
340+
headers: response.headers,
341+
}) as T;
342+
}
343+
onDone();
344+
return response;
345+
}
346+
233347
function wrapRequestHandler<T extends RouteHandler = RouteHandler>(
234348
target: T,
235349
thisArg: unknown,
@@ -239,8 +353,26 @@ function wrapRequestHandler<T extends RouteHandler = RouteHandler>(
239353
return withIsolationScope(async isolationScope => {
240354
const request = args[0];
241355
const upperCaseMethod = request.method.toUpperCase();
356+
357+
const client = getClient();
358+
const integration = client?.getIntegrationByName<Integration & BunServerIntegrationOptions>(INTEGRATION_NAME);
359+
const endSession =
360+
client && integration?.sessions
361+
? startRequestSession(client, {
362+
requestIsolationScope: isolationScope,
363+
sessionFlushingDelayMS: integration.sessionFlushingDelayMS,
364+
})
365+
: undefined;
366+
242367
if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') {
243-
return target.apply(thisArg, args);
368+
try {
369+
return await target.apply(thisArg, args);
370+
} catch (e) {
371+
captureServeError(e);
372+
throw e;
373+
} finally {
374+
endSession?.();
375+
}
244376
}
245377

246378
const parsedUrl = parseStringToURLObject(request.url);
@@ -267,7 +399,6 @@ function wrapRequestHandler<T extends RouteHandler = RouteHandler>(
267399
routeName = route;
268400
}
269401

270-
const client = getClient();
271402
const dataCollection = client?.getDataCollectionOptions();
272403

273404
let socketAddress: { address: string; port: number } | undefined;
@@ -301,24 +432,52 @@ function wrapRequestHandler<T extends RouteHandler = RouteHandler>(
301432
ipAddress: socketAddress?.address,
302433
});
303434

304-
if (client && dataCollection) {
305-
const configuredBodySize = client.getIntegrationByName<Integration & { maxRequestBodySize?: MaxRequestBodySize }>(
306-
INTEGRATION_NAME,
307-
)?.maxRequestBodySize;
435+
if (dataCollection) {
308436
const effectiveBodySize =
309-
configuredBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none');
310-
if (upperCaseMethod !== 'GET' && effectiveBodySize !== 'none') {
437+
integration?.maxRequestBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none');
438+
const ignoreRequestBody = integration?.ignoreRequestBody;
439+
if (
440+
upperCaseMethod !== 'GET' &&
441+
effectiveBodySize !== 'none' &&
442+
// If the filter throws, skip the body so it cannot leak data the filter was meant to hide.
443+
!(
444+
ignoreRequestBody &&
445+
callUserCallback('ignoreRequestBody', () => ignoreRequestBody(request.url, request), true)
446+
)
447+
) {
311448
await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize);
312449
}
313450
}
314451

452+
const urlPath = parsedUrl ? `${parsedUrl.pathname}${parsedUrl.search}` : '/';
453+
const createSpan = shouldCreateSpan(client, integration, request, urlPath);
454+
315455
return continueTrace(
316456
{
317457
sentryTrace: request.headers.get('sentry-trace') ?? '',
318458
baggage: request.headers.get('baggage'),
319459
},
320-
() =>
321-
startSpanManual(
460+
async () => {
461+
if (!createSpan) {
462+
let response: Response | undefined;
463+
try {
464+
response = (await target.apply(thisArg, args)) as Response | undefined;
465+
} catch (e) {
466+
captureServeError(e);
467+
endSession?.();
468+
throw e;
469+
}
470+
if (response?.status) {
471+
isolationScope.setContext('response', { status_code: response.status });
472+
}
473+
return onResponseDone(
474+
response,
475+
() => endSession?.(),
476+
() => undefined,
477+
);
478+
}
479+
480+
return startSpanManual(
322481
{
323482
attributes: { ...attributes, [SENTRY_OP]: HTTP_SERVER },
324483
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL path.
@@ -328,47 +487,49 @@ function wrapRequestHandler<T extends RouteHandler = RouteHandler>(
328487
: request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK,
329488
},
330489
async (span, endSpan) => {
490+
const { onSpanCreated, onSpanEnd } = integration ?? {};
491+
if (onSpanCreated) {
492+
callUserCallback('onSpanCreated', () => onSpanCreated(span, request), undefined);
493+
}
494+
495+
let response: Response | undefined;
331496
try {
332-
const response = (await target.apply(thisArg, args)) as Response | undefined;
333-
if (response?.status) {
334-
setHttpStatus(span, response.status);
335-
336-
isolationScope.setContext('response', {
337-
status_code: response.status,
338-
});
339-
340-
if (dataCollection) {
341-
span.setAttributes(
342-
httpHeadersToSpanAttributes(response.headers.toJSON(), dataCollection, 'response'),
343-
);
344-
}
345-
}
346-
if (response?.body && !response.body.locked && classifyResponseStreaming(response).isStreaming) {
347-
const body = monitorStream(response.body, endSpan, error => {
348-
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
349-
captureException(error, { mechanism: { type: 'auto.http.bun.serve', handled: false } });
350-
});
351-
return new Response(body, {
352-
status: response.status,
353-
statusText: response.statusText,
354-
headers: response.headers,
355-
});
356-
}
357-
endSpan();
358-
return response;
497+
response = (await target.apply(thisArg, args)) as Response | undefined;
359498
} catch (e) {
360499
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
361-
captureException(e, {
362-
mechanism: {
363-
type: 'auto.http.bun.serve',
364-
handled: false,
365-
},
366-
});
500+
captureServeError(e);
367501
endSpan();
502+
endSession?.();
368503
throw e;
369504
}
505+
506+
if (response?.status) {
507+
setHttpStatus(span, response.status);
508+
509+
isolationScope.setContext('response', {
510+
status_code: response.status,
511+
});
512+
513+
if (dataCollection) {
514+
span.setAttributes(httpHeadersToSpanAttributes(response.headers.toJSON(), dataCollection, 'response'));
515+
}
516+
}
517+
518+
const finalResponse = response;
519+
return onResponseDone(
520+
response,
521+
() => {
522+
if (onSpanEnd && finalResponse) {
523+
callUserCallback('onSpanEnd', () => onSpanEnd(span, request, finalResponse), undefined);
524+
}
525+
endSpan();
526+
endSession?.();
527+
},
528+
() => span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }),
529+
);
370530
},
371-
),
531+
);
532+
},
372533
);
373534
});
374535
}

0 commit comments

Comments
 (0)