Skip to content

Commit be737e4

Browse files
authoredApr 11, 2025
feat(bun): Support new Bun.serve APIs (#16035)
Supercedes #15978 resolves #15941 resolves #15827 resolves #15816 Bun recently updated their `Bun.serve` API with new functionality, which unfortunately broke our existing instrumentation. This is detailed with https://bun.sh/docs/api/http#bun-serve. Specifically, they added a new routing API that looks like so: ```ts Bun.serve({ // `routes` requires Bun v1.2.3+ routes: { // Dynamic routes "/users/:id": req => { return new Response(`Hello User ${req.params.id}!`); }, // Per-HTTP method handlers "/api/posts": { GET: () => new Response("List posts"), POST: async req => { const body = await req.json(); return Response.json({ created: true, ...body }); }, }, // Wildcard route for all routes that start with "/api/" and aren't otherwise matched "/api/*": Response.json({ message: "Not found" }, { status: 404 }), // Redirect from /blog/hello to /blog/hello/world "/blog/hello": Response.redirect("/blog/hello/world"), // Serve a file by buffering it in memory "/favicon.ico": new Response(await Bun.file("./favicon.ico").bytes(), { headers: { "Content-Type": "image/x-icon", }, }), }, // (optional) fallback for unmatched routes: // Required if Bun's version < 1.2.3 fetch(req) { return new Response("Not Found", { status: 404 }); }, }); ``` Because there are now dynamic routes and wildcard routes, we can actually generate `route` transaction source and send parameterized routes to Sentry. The `fetch` API is still supported. The only API we don't support is [static routes/responses](https://bun.sh/docs/api/http#static-responses). This is because these are optimized by Bun itself, and if we turn it into a function (which we need to do to time it), we'll lose out on the optimization. For now they aren't instrumented.
1 parent 1bbe0d9 commit be737e4

File tree

4 files changed

+540
-175
lines changed

4 files changed

+540
-175
lines changed
 

‎packages/bun/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
"@sentry/opentelemetry": "9.12.0"
4545
},
4646
"devDependencies": {
47-
"bun-types": "latest"
47+
"bun-types": "^1.2.9"
4848
},
4949
"scripts": {
5050
"build": "run-p build:transpile build:types",
Lines changed: 223 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
1+
import type { ServeOptions } from 'bun';
12
import type { IntegrationFn, RequestEventData, SpanAttributes } from '@sentry/core';
23
import {
34
SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD,
45
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
56
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
67
captureException,
7-
continueTrace,
8-
defineIntegration,
9-
extractQueryParamsFromUrl,
10-
getSanitizedUrlString,
11-
parseUrl,
8+
isURLObjectRelative,
129
setHttpStatus,
10+
defineIntegration,
11+
continueTrace,
1312
startSpan,
1413
withIsolationScope,
14+
parseStringToURLObject,
1515
} from '@sentry/core';
1616

1717
const INTEGRATION_NAME = 'BunServer';
@@ -28,6 +28,8 @@ const _bunServerIntegration = (() => {
2828
/**
2929
* Instruments `Bun.serve` to automatically create transactions and capture errors.
3030
*
31+
* Does not support instrumenting static routes.
32+
*
3133
* Enabled by default in the Bun SDK.
3234
*
3335
* ```js
@@ -40,10 +42,18 @@ const _bunServerIntegration = (() => {
4042
*/
4143
export const bunServerIntegration = defineIntegration(_bunServerIntegration);
4244

45+
let hasPatchedBunServe = false;
46+
4347
/**
4448
* Instruments Bun.serve by patching it's options.
49+
*
50+
* Only exported for tests.
4551
*/
4652
export function instrumentBunServe(): void {
53+
if (hasPatchedBunServe) {
54+
return;
55+
}
56+
4757
Bun.serve = new Proxy(Bun.serve, {
4858
apply(serveTarget, serveThisArg, serveArgs: Parameters<typeof Bun.serve>) {
4959
instrumentBunServeOptions(serveArgs[0]);
@@ -53,89 +63,231 @@ export function instrumentBunServe(): void {
5363
// We can't use a Proxy for this as Bun does `instanceof` checks internally that fail if we
5464
// wrap the Server instance.
5565
const originalReload: typeof server.reload = server.reload.bind(server);
56-
server.reload = (serveOptions: Parameters<typeof Bun.serve>[0]) => {
66+
server.reload = (serveOptions: ServeOptions) => {
5767
instrumentBunServeOptions(serveOptions);
5868
return originalReload(serveOptions);
5969
};
6070

6171
return server;
6272
},
6373
});
74+
75+
hasPatchedBunServe = true;
6476
}
6577

6678
/**
67-
* Instruments Bun.serve `fetch` option to automatically create spans and capture errors.
79+
* Instruments Bun.serve options.
80+
*
81+
* @param serveOptions - The options for the Bun.serve function.
6882
*/
6983
function instrumentBunServeOptions(serveOptions: Parameters<typeof Bun.serve>[0]): void {
84+
// First handle fetch
85+
instrumentBunServeOptionFetch(serveOptions);
86+
// then handle routes
87+
instrumentBunServeOptionRoutes(serveOptions);
88+
}
89+
90+
/**
91+
* Instruments the `fetch` option of Bun.serve.
92+
*
93+
* @param serveOptions - The options for the Bun.serve function.
94+
*/
95+
function instrumentBunServeOptionFetch(serveOptions: Parameters<typeof Bun.serve>[0]): void {
96+
if (typeof serveOptions.fetch !== 'function') {
97+
return;
98+
}
99+
70100
serveOptions.fetch = new Proxy(serveOptions.fetch, {
71101
apply(fetchTarget, fetchThisArg, fetchArgs: Parameters<typeof serveOptions.fetch>) {
72-
return withIsolationScope(isolationScope => {
73-
const request = fetchArgs[0];
74-
const upperCaseMethod = request.method.toUpperCase();
75-
if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') {
76-
return fetchTarget.apply(fetchThisArg, fetchArgs);
77-
}
102+
return wrapRequestHandler(fetchTarget, fetchThisArg, fetchArgs);
103+
},
104+
});
105+
}
78106

79-
const parsedUrl = parseUrl(request.url);
80-
const attributes: SpanAttributes = {
81-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.bun.serve',
82-
[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: request.method || 'GET',
83-
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
84-
};
85-
if (parsedUrl.search) {
86-
attributes['http.query'] = parsedUrl.search;
87-
}
107+
/**
108+
* Instruments the `routes` option of Bun.serve.
109+
*
110+
* @param serveOptions - The options for the Bun.serve function.
111+
*/
112+
function instrumentBunServeOptionRoutes(serveOptions: Parameters<typeof Bun.serve>[0]): void {
113+
if (!serveOptions.routes) {
114+
return;
115+
}
88116

89-
const url = getSanitizedUrlString(parsedUrl);
90-
91-
isolationScope.setSDKProcessingMetadata({
92-
normalizedRequest: {
93-
url,
94-
method: request.method,
95-
headers: request.headers.toJSON(),
96-
query_string: extractQueryParamsFromUrl(url),
97-
} satisfies RequestEventData,
98-
});
99-
100-
return continueTrace(
101-
{ sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') },
102-
() => {
103-
return startSpan(
104-
{
105-
attributes,
106-
op: 'http.server',
107-
name: `${request.method} ${parsedUrl.path || '/'}`,
108-
},
109-
async span => {
110-
try {
111-
const response = await (fetchTarget.apply(fetchThisArg, fetchArgs) as ReturnType<
112-
typeof serveOptions.fetch
113-
>);
114-
if (response?.status) {
115-
setHttpStatus(span, response.status);
116-
isolationScope.setContext('response', {
117-
headers: response.headers.toJSON(),
118-
status_code: response.status,
119-
});
120-
}
121-
return response;
122-
} catch (e) {
123-
captureException(e, {
124-
mechanism: {
125-
type: 'bun',
126-
handled: false,
127-
data: {
128-
function: 'serve',
129-
},
130-
},
131-
});
132-
throw e;
133-
}
117+
if (typeof serveOptions.routes !== 'object') {
118+
return;
119+
}
120+
121+
Object.keys(serveOptions.routes).forEach(route => {
122+
const routeHandler = serveOptions.routes[route];
123+
124+
// Handle route handlers that are an object
125+
if (typeof routeHandler === 'function') {
126+
serveOptions.routes[route] = new Proxy(routeHandler, {
127+
apply: (routeHandlerTarget, routeHandlerThisArg, routeHandlerArgs: Parameters<typeof routeHandler>) => {
128+
return wrapRequestHandler(routeHandlerTarget, routeHandlerThisArg, routeHandlerArgs, route);
129+
},
130+
});
131+
}
132+
133+
// Static routes are not instrumented
134+
if (routeHandler instanceof Response) {
135+
return;
136+
}
137+
138+
// Handle the route handlers that are an object. This means they define a route handler for each method.
139+
if (typeof routeHandler === 'object') {
140+
Object.entries(routeHandler).forEach(([routeHandlerObjectHandlerKey, routeHandlerObjectHandler]) => {
141+
if (typeof routeHandlerObjectHandler === 'function') {
142+
(serveOptions.routes[route] as Record<string, RouteHandler>)[routeHandlerObjectHandlerKey] = new Proxy(
143+
routeHandlerObjectHandler,
144+
{
145+
apply: (
146+
routeHandlerObjectHandlerTarget,
147+
routeHandlerObjectHandlerThisArg,
148+
routeHandlerObjectHandlerArgs: Parameters<typeof routeHandlerObjectHandler>,
149+
) => {
150+
return wrapRequestHandler(
151+
routeHandlerObjectHandlerTarget,
152+
routeHandlerObjectHandlerThisArg,
153+
routeHandlerObjectHandlerArgs,
154+
route,
155+
);
134156
},
135-
);
136-
},
137-
);
157+
},
158+
);
159+
}
138160
});
139-
},
161+
}
140162
});
141163
}
164+
165+
type RouteHandler = Extract<
166+
NonNullable<Parameters<typeof Bun.serve>[0]['routes']>[string],
167+
// eslint-disable-next-line @typescript-eslint/ban-types
168+
Function
169+
>;
170+
171+
function wrapRequestHandler<T extends RouteHandler = RouteHandler>(
172+
target: T,
173+
thisArg: unknown,
174+
args: Parameters<T>,
175+
route?: string,
176+
): ReturnType<T> {
177+
return withIsolationScope(isolationScope => {
178+
const request = args[0];
179+
const upperCaseMethod = request.method.toUpperCase();
180+
if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') {
181+
return target.apply(thisArg, args);
182+
}
183+
184+
const parsedUrl = parseStringToURLObject(request.url);
185+
const attributes = getSpanAttributesFromParsedUrl(parsedUrl, request);
186+
187+
let routeName = parsedUrl?.pathname || '/';
188+
if (request.params) {
189+
Object.keys(request.params).forEach(key => {
190+
attributes[`url.path.parameter.${key}`] = (request.params as Record<string, string>)[key];
191+
});
192+
193+
// If a route has parameters, it's a parameterized route
194+
if (route) {
195+
attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route';
196+
attributes['url.template'] = route;
197+
routeName = route;
198+
}
199+
}
200+
201+
// Handle wildcard routes
202+
if (route?.endsWith('/*')) {
203+
attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route';
204+
attributes['url.template'] = route;
205+
routeName = route;
206+
}
207+
208+
isolationScope.setSDKProcessingMetadata({
209+
normalizedRequest: {
210+
url: request.url,
211+
method: request.method,
212+
headers: request.headers.toJSON(),
213+
query_string: parsedUrl?.search,
214+
} satisfies RequestEventData,
215+
});
216+
217+
return continueTrace(
218+
{
219+
sentryTrace: request.headers.get('sentry-trace') ?? '',
220+
baggage: request.headers.get('baggage'),
221+
},
222+
() =>
223+
startSpan(
224+
{
225+
attributes,
226+
op: 'http.server',
227+
name: `${request.method} ${routeName}`,
228+
},
229+
async span => {
230+
try {
231+
const response = (await target.apply(thisArg, args)) as Response | undefined;
232+
if (response?.status) {
233+
setHttpStatus(span, response.status);
234+
isolationScope.setContext('response', {
235+
headers: response.headers.toJSON(),
236+
status_code: response.status,
237+
});
238+
}
239+
return response;
240+
} catch (e) {
241+
captureException(e, {
242+
mechanism: {
243+
type: 'bun',
244+
handled: false,
245+
data: {
246+
function: 'serve',
247+
},
248+
},
249+
});
250+
throw e;
251+
}
252+
},
253+
),
254+
);
255+
});
256+
}
257+
258+
function getSpanAttributesFromParsedUrl(
259+
parsedUrl: ReturnType<typeof parseStringToURLObject>,
260+
request: Request,
261+
): SpanAttributes {
262+
const attributes: SpanAttributes = {
263+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.bun.serve',
264+
[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: request.method || 'GET',
265+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
266+
};
267+
268+
if (parsedUrl) {
269+
if (parsedUrl.search) {
270+
attributes['url.query'] = parsedUrl.search;
271+
}
272+
if (parsedUrl.hash) {
273+
attributes['url.fragment'] = parsedUrl.hash;
274+
}
275+
if (parsedUrl.pathname) {
276+
attributes['url.path'] = parsedUrl.pathname;
277+
}
278+
if (!isURLObjectRelative(parsedUrl)) {
279+
attributes['url.full'] = parsedUrl.href;
280+
if (parsedUrl.port) {
281+
attributes['url.port'] = parsedUrl.port;
282+
}
283+
if (parsedUrl.protocol) {
284+
attributes['url.scheme'] = parsedUrl.protocol;
285+
}
286+
if (parsedUrl.hostname) {
287+
attributes['url.domain'] = parsedUrl.hostname;
288+
}
289+
}
290+
}
291+
292+
return attributes;
293+
}

0 commit comments

Comments
 (0)