From ef5c87fd3b00cec31ceef131bc9b106e25ea6008 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 3 Apr 2025 19:12:56 -0700 Subject: [PATCH] Update Bun integration to support routes & optional fetch --- packages/bun/src/integrations/bunserver.ts | 345 ++++++++++++++---- .../bun/test/integrations/bunserver.test.ts | 137 +++++-- yarn.lock | 6 +- 3 files changed, 400 insertions(+), 88 deletions(-) diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 1f1974839455..68d06878c1de 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -1,4 +1,5 @@ import type { IntegrationFn, RequestEventData, SpanAttributes } from '@sentry/core'; +import type { BunRequest, ServeOptions } from 'bun'; import { SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -40,11 +41,17 @@ const _bunServerIntegration = (() => { */ export const bunServerIntegration = defineIntegration(_bunServerIntegration); +let originalServe: typeof Bun.serve; + /** * Instruments Bun.serve by patching it's options. */ export function instrumentBunServe(): void { - Bun.serve = new Proxy(Bun.serve, { + if (!originalServe) { + originalServe = Bun.serve; + } + + Bun.serve = new Proxy(originalServe, { apply(serveTarget, serveThisArg, serveArgs: Parameters) { instrumentBunServeOptions(serveArgs[0]); const server: ReturnType = serveTarget.apply(serveThisArg, serveArgs); @@ -53,7 +60,7 @@ export function instrumentBunServe(): void { // We can't use a Proxy for this as Bun does `instanceof` checks internally that fail if we // wrap the Server instance. const originalReload: typeof server.reload = server.reload.bind(server); - server.reload = (serveOptions: Parameters[0]) => { + server.reload = (serveOptions: ServeOptions) => { instrumentBunServeOptions(serveOptions); return originalReload(serveOptions); }; @@ -67,75 +74,285 @@ export function instrumentBunServe(): void { * Instruments Bun.serve `fetch` option to automatically create spans and capture errors. */ function instrumentBunServeOptions(serveOptions: Parameters[0]): void { - serveOptions.fetch = new Proxy(serveOptions.fetch, { - apply(fetchTarget, fetchThisArg, fetchArgs: Parameters) { - return withIsolationScope(isolationScope => { - const request = fetchArgs[0]; - const upperCaseMethod = request.method.toUpperCase(); - if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') { - return fetchTarget.apply(fetchThisArg, fetchArgs); - } + const originalFetch: typeof serveOptions.fetch = serveOptions?.fetch; + // Instrument the fetch handler + if (typeof originalFetch === 'function') { + serveOptions.fetch = new Proxy(originalFetch, { + apply(fetchTarget, fetchThisArg, fetchArgs: Parameters) { + return withIsolationScope(isolationScope => { + const request = fetchArgs[0]; + const upperCaseMethod = request.method.toUpperCase(); + if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') { + return fetchTarget.apply(fetchThisArg, fetchArgs); + } - const parsedUrl = parseUrl(request.url); - const attributes: SpanAttributes = { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.bun.serve', - [SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: request.method || 'GET', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - }; - if (parsedUrl.search) { - attributes['http.query'] = parsedUrl.search; - } + const parsedUrl = parseUrl(request.url); + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.bun.serve', + [SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: request.method || 'GET', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }; + if (parsedUrl.search) { + attributes['http.query'] = parsedUrl.search; + } - const url = getSanitizedUrlString(parsedUrl); + const url = getSanitizedUrlString(parsedUrl); - isolationScope.setSDKProcessingMetadata({ - normalizedRequest: { - url, - method: request.method, - headers: request.headers.toJSON(), - query_string: extractQueryParamsFromUrl(url), - } satisfies RequestEventData, - }); + isolationScope.setSDKProcessingMetadata({ + normalizedRequest: { + url, + method: request.method, + headers: request.headers.toJSON(), + query_string: extractQueryParamsFromUrl(url), + } satisfies RequestEventData, + }); - return continueTrace( - { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') }, - () => { - return startSpan( - { - attributes, - op: 'http.server', - name: `${request.method} ${parsedUrl.path || '/'}`, - }, - async span => { - try { - const response = await (fetchTarget.apply(fetchThisArg, fetchArgs) as ReturnType< - typeof serveOptions.fetch - >); - if (response?.status) { - setHttpStatus(span, response.status); - isolationScope.setContext('response', { - headers: response.headers.toJSON(), - status_code: response.status, + return continueTrace( + { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') }, + () => { + return startSpan( + { + attributes, + op: 'http.server', + name: `${request.method} ${parsedUrl.path || '/'}`, + }, + async span => { + try { + const response = await (fetchTarget.apply(fetchThisArg, fetchArgs) as ReturnType< + typeof originalFetch + >); + if (response?.status) { + setHttpStatus(span, response.status); + isolationScope.setContext('response', { + headers: response.headers.toJSON(), + status_code: response.status, + }); + } + return response; + } catch (e) { + captureException(e, { + mechanism: { + type: 'bun', + handled: false, + data: { + function: 'serve', + }, + }, }); + throw e; } - return response; - } catch (e) { - captureException(e, { - mechanism: { - type: 'bun', - handled: false, - data: { - function: 'serve', - }, - }, - }); - throw e; - } + }, + ); + }, + ); + }); + }, + }); + } + + // Instrument routes if present + if ( + (typeof serveOptions?.routes === 'object' || typeof serveOptions?.routes === 'function') && + // Hono routes. This was an issue in Bun. + !Array.isArray(serveOptions?.routes) + ) { + serveOptions.routes = instrumentBunServeRoutes(serveOptions.routes) as typeof serveOptions.routes; + } +} + +/** + * Instruments the routes option in Bun.serve() + */ +function instrumentBunServeRoutes( + routes: NonNullable[0]['routes']>, +): Parameters[0]['routes'] { + let anyMatches = false; + const instrumentedRoutes: Parameters[0]['routes'] = {}; + + for (const [routePath, handler] of Object.entries(routes)) { + if (handler === null || handler === undefined || !routePath.startsWith('/')) { + instrumentedRoutes[routePath] = handler; + continue; + } + + // Case 2: Route handler function + if (typeof handler === 'function') { + anyMatches = true; + instrumentedRoutes[routePath] = new Proxy(handler, { + apply(handlerTarget, handlerThisArg, handlerArgs) { + return withIsolationScope(isolationScope => { + const request = handlerArgs[0] as BunRequest; + const upperCaseMethod = request.method.toUpperCase(); + if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') { + return handlerTarget.apply(handlerThisArg, handlerArgs); + } + + const parsedUrl = parseUrl(request.url); + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.bun.serve.route', + [SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: request.method || 'GET', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }; + if (parsedUrl.search) { + attributes['http.query'] = parsedUrl.search; + } + + const url = getSanitizedUrlString(parsedUrl); + + isolationScope.setSDKProcessingMetadata({ + normalizedRequest: { + url, + method: request.method, + headers: request.headers.toJSON(), + query_string: extractQueryParamsFromUrl(url), + // For routes with parameters, add them to request data + ...(request.params ? { params: request.params } : {}), + } satisfies RequestEventData, + }); + + return continueTrace( + { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') }, + () => { + // Use routePath for the name to capture route parameters + return startSpan( + { + attributes, + op: 'http.server', + name: `${request.method} ${routePath}`, + }, + async span => { + try { + const response = await (handlerTarget.apply(handlerThisArg, handlerArgs) as + | Promise + | Response); + if (response?.status) { + setHttpStatus(span, response.status); + isolationScope.setContext('response', { + headers: response.headers.toJSON(), + status_code: response.status, + }); + } + return response; + } catch (e) { + captureException(e, { + mechanism: { + type: 'bun', + handled: false, + data: { + function: 'serve.route', + }, + }, + }); + throw e; + } + }, + ); }, ); - }, - ); + }); + }, }); - }, - }); + continue; + } + + // Case 3: HTTP method handlers object { GET: fn, POST: fn, ... } + if (typeof handler === 'object') { + const methodHandlers = handler as Record; + const instrumentedMethodHandlers: Record = {}; + + for (const [method, methodHandler] of Object.entries(methodHandlers)) { + if (typeof methodHandler === 'function') { + anyMatches = true; + instrumentedMethodHandlers[method] = new Proxy(methodHandler, { + apply(methodHandlerTarget, methodHandlerThisArg, methodHandlerArgs) { + return withIsolationScope(isolationScope => { + const request = methodHandlerArgs[0] as BunRequest; + const upperCaseMethod = method.toUpperCase(); + if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') { + return methodHandlerTarget.apply(methodHandlerThisArg, methodHandlerArgs); + } + + const parsedUrl = parseUrl(request.url); + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.bun.serve.route.method', + [SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: upperCaseMethod, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }; + if (parsedUrl.search) { + attributes['http.query'] = parsedUrl.search; + } + + const url = getSanitizedUrlString(parsedUrl); + + isolationScope.setSDKProcessingMetadata({ + normalizedRequest: { + url, + method: upperCaseMethod, + headers: request.headers.toJSON(), + query_string: extractQueryParamsFromUrl(url), + // For routes with parameters, add them to request data + ...(request.params ? { params: request.params } : {}), + } satisfies RequestEventData, + }); + + return continueTrace( + { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') }, + () => { + return startSpan( + { + attributes, + op: 'http.server', + name: `${upperCaseMethod} ${routePath}`, + }, + async span => { + try { + const response = await (methodHandlerTarget.apply(methodHandlerThisArg, methodHandlerArgs) as + | Promise + | Response); + if (response?.status) { + setHttpStatus(span, response.status); + isolationScope.setContext('response', { + headers: response.headers.toJSON(), + status_code: response.status, + }); + } + return response; + } catch (e) { + captureException(e, { + mechanism: { + type: 'bun', + handled: false, + data: { + function: 'serve.route.method', + }, + }, + }); + throw e; + } + }, + ); + }, + ); + }); + }, + }); + } else { + // If method handler is not a function (e.g., static response), keep it as is + instrumentedMethodHandlers[method] = methodHandler; + } + } + + instrumentedRoutes[routePath] = instrumentedMethodHandlers; + continue; + } + + // Default case: keep the handler as is if it's not a recognized type + instrumentedRoutes[routePath] = handler; + } + + if (!anyMatches) { + return routes; + } + + return instrumentedRoutes; } diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index 66a66476f78d..bfe5de21ccf9 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -1,30 +1,31 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; import type { Span } from '@sentry/core'; import { getDynamicSamplingContextFromSpan, spanIsSampled, spanToJSON } from '@sentry/core'; +import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; -import { init } from '../../src'; import type { NodeClient } from '../../src'; +import { init } from '../../src'; import { instrumentBunServe } from '../../src/integrations/bunserver'; import { getDefaultBunClientOptions } from '../helpers'; describe('Bun Serve Integration', () => { + // Initialize client only once for all tests let client: NodeClient | undefined; - // Fun fact: Bun = 2 21 14 :) - let port: number = 22114; beforeAll(() => { instrumentBunServe(); }); + // Set up client before any tests run beforeEach(() => { - const options = getDefaultBunClientOptions({ tracesSampleRate: 1 }); - client = init(options); + client = init(getDefaultBunClientOptions({ tracesSampleRate: 1 })); }); - afterEach(() => { - // Don't reuse the port; Bun server stops lazily so tests may accidentally hit a server still closing from a - // previous test - port += 1; + // Clean up after all tests + afterAll(() => { + if (client) { + client.close(); + client = undefined; + } }); test('generates a transaction around a request', async () => { @@ -38,9 +39,9 @@ describe('Bun Serve Integration', () => { async fetch(_req) { return new Response('Bun!'); }, - port, + port: 0, }); - await fetch(`http://localhost:${port}/users?id=123`); + await fetch(`http://localhost:${server.port}/users?id=123`); server.stop(); if (!generatedSpan) { @@ -62,6 +63,100 @@ describe('Bun Serve Integration', () => { }); }); + test('generates a transaction for routes with a function handler', async () => { + let generatedSpan: Span | undefined; + + client?.on('spanEnd', span => { + generatedSpan = span; + }); + + const server = Bun.serve({ + routes: { + '/users': () => { + return new Response('Users Route'); + }, + }, + port: 0, + }); + await fetch(`http://localhost:${server.port}/users`); + server.stop(); + + if (!generatedSpan) { + throw 'No span was generated in the test'; + } + + const spanJson = spanToJSON(generatedSpan); + expect(spanJson.status).toBe('ok'); + expect(spanJson.op).toEqual('http.server'); + expect(spanJson.description).toEqual('GET /users'); + expect(spanJson.data).toEqual({ + 'http.request.method': 'GET', + 'http.response.status_code': 200, + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.bun.serve.route', + 'sentry.sample_rate': 1, + 'sentry.source': 'url', + }); + }); + + test('generates a transaction for routes with HTTP method handlers', async () => { + let generatedSpan: Span | undefined; + + client?.on('spanEnd', span => { + generatedSpan = span; + }); + + const server = Bun.serve({ + routes: { + '/api': { + GET: () => new Response('GET API'), + POST: () => new Response('POST API'), + }, + }, + port: 0, + }); + await fetch(`http://localhost:${server.port}/api`, { method: 'POST' }); + server.stop(); + + if (!generatedSpan) { + throw 'No span was generated in the test'; + } + + const spanJson = spanToJSON(generatedSpan); + expect(spanJson.status).toBe('ok'); + expect(spanJson.op).toEqual('http.server'); + expect(spanJson.description).toEqual('POST /api'); + expect(spanJson.data).toEqual({ + 'http.request.method': 'POST', + 'http.response.status_code': 200, + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.bun.serve.route.method', + 'sentry.sample_rate': 1, + 'sentry.source': 'url', + }); + }); + + test('does not capture Static Response objects in routes', async () => { + let generatedSpan: Span | undefined; + + client?.on('spanEnd', span => { + generatedSpan = span; + }); + + const server = Bun.serve({ + routes: { + '/static': new Response('Static Response'), + }, + fetch: () => new Response('Default'), + port: 0, + }); + await fetch(`http://localhost:${server.port}/static`); + server.stop(); + + // Static responses don't trigger spans + expect(generatedSpan).toBeUndefined(); + }); + test('generates a post transaction', async () => { let generatedSpan: Span | undefined; @@ -73,10 +168,10 @@ describe('Bun Serve Integration', () => { async fetch(_req) { return new Response('Bun!'); }, - port, + port: 0, }); - await fetch(`http://localhost:${port}/`, { + await fetch(`http://localhost:${server.port}/`, { method: 'POST', }); @@ -110,10 +205,10 @@ describe('Bun Serve Integration', () => { async fetch(_req) { return new Response('Bun!'); }, - port, + port: 0, }); - await fetch(`http://localhost:${port}/`, { + await fetch(`http://localhost:${server.port}/`, { headers: { 'sentry-trace': SENTRY_TRACE_HEADER, baggage: SENTRY_BAGGAGE_HEADER }, }); @@ -146,14 +241,14 @@ describe('Bun Serve Integration', () => { async fetch(_req) { return new Response('Bun!'); }, - port, + port: 0, }); - await fetch(`http://localhost:${port}/`, { + await fetch(`http://localhost:${server.port}/`, { method: 'OPTIONS', }); - await fetch(`http://localhost:${port}/`, { + await fetch(`http://localhost:${server.port}/`, { method: 'HEAD', }); @@ -172,7 +267,7 @@ describe('Bun Serve Integration', () => { async fetch(_req) { return new Response('Bun!'); }, - port, + port: 0, }); server.reload({ @@ -181,7 +276,7 @@ describe('Bun Serve Integration', () => { }, }); - await fetch(`http://localhost:${port}/`); + await fetch(`http://localhost:${server.port}/`); server.stop(); diff --git a/yarn.lock b/yarn.lock index 27c0b61bb063..361982c50b39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11211,9 +11211,9 @@ builtins@^5.0.0, builtins@^5.0.1: semver "^7.0.0" bun-types@latest: - version "1.0.1" - resolved "https://registry.yarnpkg.com/bun-types/-/bun-types-1.0.1.tgz#8bcb10ae3a1548a39f0932fdb365f4b3a649efba" - integrity sha512-7NrXqhMIaNKmWn2dSWEQ50znMZqrN/5Z0NBMXvQTRu/+Y1CvoXRznFy0pnqLe024CeZgVdXoEpARNO1JZLAPGw== + version "1.2.8" + resolved "https://registry.yarnpkg.com/bun-types/-/bun-types-1.2.8.tgz#b6f373bac4054449a9d5150475b829f58c089f09" + integrity sha512-D5npfxKIGuYe9dTHLK1hi4XFmbMdKYoLrgyd25rrUyCrnyU4ljmQW7vDdonvibKeyU72mZuixIhQ2J+q6uM0Mg== bundle-name@^3.0.0: version "3.0.0"