diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 7be879568dec..d1c4a69f0ae5 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -69,9 +69,12 @@ export { defaultIntegrations, init } from './sdk'; import { Integrations as CoreIntegrations } from '@sentry/core'; import { Integrations as NodeIntegrations } from '@sentry/node'; +import * as BunIntegrations from './integrations'; + const INTEGRATIONS = { ...CoreIntegrations, ...NodeIntegrations, + ...BunIntegrations, }; export { INTEGRATIONS as Integrations }; diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts new file mode 100644 index 000000000000..915856f473db --- /dev/null +++ b/packages/bun/src/integrations/bunserver.ts @@ -0,0 +1,132 @@ +import { captureException, getCurrentHub, runWithAsyncContext, startSpan, Transaction } from '@sentry/core'; +import type { Integration } from '@sentry/types'; +import { addExceptionMechanism, getSanitizedUrlString, parseUrl, tracingContextFromHeaders } from '@sentry/utils'; + +function sendErrorToSentry(e: unknown): unknown { + captureException(e, scope => { + scope.addEventProcessor(event => { + addExceptionMechanism(event, { + type: 'bun', + handled: false, + data: { + function: 'serve', + }, + }); + return event; + }); + + return scope; + }); + + return e; +} + +/** + * Instruments `Bun.serve` to automatically create transactions and capture errors. + */ +export class BunServer implements Integration { + /** + * @inheritDoc + */ + public static id: string = 'BunServer'; + + /** + * @inheritDoc + */ + public name: string = BunServer.id; + + /** + * @inheritDoc + */ + public setupOnce(): void { + instrumentBunServe(); + } +} + +/** + * Instruments Bun.serve by patching it's options. + */ +export function instrumentBunServe(): void { + Bun.serve = new Proxy(Bun.serve, { + apply(serveTarget, serveThisArg, serveArgs: Parameters) { + instrumentBunServeOptions(serveArgs[0]); + return serveTarget.apply(serveThisArg, serveArgs); + }, + }); +} + +/** + * 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 runWithAsyncContext(() => { + const hub = getCurrentHub(); + + const request = fetchArgs[0]; + const upperCaseMethod = request.method.toUpperCase(); + if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') { + return fetchTarget.apply(fetchThisArg, fetchArgs); + } + + const sentryTrace = request.headers.get('sentry-trace') || ''; + const baggage = request.headers.get('baggage'); + const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders( + sentryTrace, + baggage, + ); + hub.getScope().setPropagationContext(propagationContext); + + const parsedUrl = parseUrl(request.url); + const data: Record = { + 'http.request.method': request.method || 'GET', + }; + if (parsedUrl.search) { + data['http.query'] = parsedUrl.search; + } + + const url = getSanitizedUrlString(parsedUrl); + return startSpan( + { + op: 'http.server', + name: `${request.method} ${parsedUrl.path || '/'}`, + origin: 'auto.http.bun.serve', + ...traceparentData, + data, + metadata: { + source: 'url', + dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext, + request: { + url, + method: request.method, + headers: request.headers.toJSON(), + }, + }, + }, + async span => { + try { + const response = await (fetchTarget.apply(fetchThisArg, fetchArgs) as ReturnType< + typeof serveOptions.fetch + >); + if (response && response.status) { + span?.setHttpStatus(response.status); + span?.setData('http.response.status_code', response.status); + if (span instanceof Transaction) { + span.setContext('response', { + headers: response.headers.toJSON(), + status_code: response.status, + }); + } + } + return response; + } catch (e) { + sendErrorToSentry(e); + throw e; + } + }, + ); + }); + }, + }); +} diff --git a/packages/bun/src/integrations/index.ts b/packages/bun/src/integrations/index.ts new file mode 100644 index 000000000000..95d17cf80e66 --- /dev/null +++ b/packages/bun/src/integrations/index.ts @@ -0,0 +1 @@ +export { BunServer } from './bunserver'; diff --git a/packages/bun/src/sdk.ts b/packages/bun/src/sdk.ts index 11c7827b3faf..d512e7208bf5 100644 --- a/packages/bun/src/sdk.ts +++ b/packages/bun/src/sdk.ts @@ -3,6 +3,7 @@ import { Integrations as CoreIntegrations } from '@sentry/core'; import { init as initNode, Integrations as NodeIntegrations } from '@sentry/node'; import { BunClient } from './client'; +import { BunServer } from './integrations'; import { makeFetchTransport } from './transports'; import type { BunOptions } from './types'; @@ -25,6 +26,8 @@ export const defaultIntegrations = [ new NodeIntegrations.RequestData(), // Misc new NodeIntegrations.LinkedErrors(), + // Bun Specific + new BunServer(), ]; /** diff --git a/packages/bun/test/helpers.ts b/packages/bun/test/helpers.ts new file mode 100644 index 000000000000..32d4e4d716ac --- /dev/null +++ b/packages/bun/test/helpers.ts @@ -0,0 +1,14 @@ +import { createTransport } from '@sentry/core'; +import { resolvedSyncPromise } from '@sentry/utils'; + +import type { BunClientOptions } from '../src/types'; + +export function getDefaultBunClientOptions(options: Partial = {}): BunClientOptions { + return { + integrations: [], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => resolvedSyncPromise({})), + stackParser: () => [], + instrumenter: 'sentry', + ...options, + }; +} diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts new file mode 100644 index 000000000000..23253c729590 --- /dev/null +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -0,0 +1,126 @@ +import { Hub, makeMain } from '@sentry/core'; +// eslint-disable-next-line import/no-unresolved +import { beforeAll, beforeEach, describe, expect, test } from 'bun:test'; + +import { BunClient } from '../../src/client'; +import { instrumentBunServe } from '../../src/integrations/bunserver'; +import { getDefaultBunClientOptions } from '../helpers'; + +// Fun fact: Bun = 2 21 14 :) +const DEFAULT_PORT = 22114; + +describe('Bun Serve Integration', () => { + let hub: Hub; + let client: BunClient; + + beforeAll(() => { + instrumentBunServe(); + }); + + beforeEach(() => { + const options = getDefaultBunClientOptions({ tracesSampleRate: 1, debug: true }); + client = new BunClient(options); + hub = new Hub(client); + makeMain(hub); + }); + + test('generates a transaction around a request', async () => { + client.on('finishTransaction', transaction => { + expect(transaction.status).toBe('ok'); + expect(transaction.tags).toEqual({ + 'http.status_code': '200', + }); + expect(transaction.op).toEqual('http.server'); + expect(transaction.name).toEqual('GET /'); + }); + + const server = Bun.serve({ + async fetch(_req) { + return new Response('Bun!'); + }, + port: DEFAULT_PORT, + }); + + await fetch('http://localhost:22114/'); + + server.stop(); + }); + + test('generates a post transaction', async () => { + client.on('finishTransaction', transaction => { + expect(transaction.status).toBe('ok'); + expect(transaction.tags).toEqual({ + 'http.status_code': '200', + }); + expect(transaction.op).toEqual('http.server'); + expect(transaction.name).toEqual('POST /'); + }); + + const server = Bun.serve({ + async fetch(_req) { + return new Response('Bun!'); + }, + port: DEFAULT_PORT, + }); + + await fetch('http://localhost:22114/', { + method: 'POST', + }); + + server.stop(); + }); + + test('continues a trace', async () => { + const TRACE_ID = '12312012123120121231201212312012'; + const PARENT_SPAN_ID = '1121201211212012'; + const PARENT_SAMPLED = '1'; + + const SENTRY_TRACE_HEADER = `${TRACE_ID}-${PARENT_SPAN_ID}-${PARENT_SAMPLED}`; + const SENTRY_BAGGAGE_HEADER = 'sentry-version=1.0,sentry-environment=production'; + + client.on('finishTransaction', transaction => { + expect(transaction.traceId).toBe(TRACE_ID); + expect(transaction.parentSpanId).toBe(PARENT_SPAN_ID); + expect(transaction.sampled).toBe(true); + + expect(transaction.metadata?.dynamicSamplingContext).toStrictEqual({ version: '1.0', environment: 'production' }); + }); + + const server = Bun.serve({ + async fetch(_req) { + return new Response('Bun!'); + }, + port: DEFAULT_PORT, + }); + + await fetch('http://localhost:22114/', { + headers: { 'sentry-trace': SENTRY_TRACE_HEADER, baggage: SENTRY_BAGGAGE_HEADER }, + }); + + server.stop(); + }); + + test('does not create transactions for OPTIONS or HEAD requests', async () => { + client.on('finishTransaction', () => { + // This will never run, but we want to make sure it doesn't run. + expect(false).toEqual(true); + }); + + const server = Bun.serve({ + async fetch(_req) { + return new Response('Bun!'); + }, + port: DEFAULT_PORT, + }); + + await fetch('http://localhost:22114/', { + method: 'OPTIONS', + }); + + await fetch('http://localhost:22114/', { + method: 'HEAD', + }); + + server.stop(); + }); +}); diff --git a/packages/bun/tsconfig.test.json b/packages/bun/tsconfig.test.json index 87f6afa06b86..e5d8bb0fb535 100644 --- a/packages/bun/tsconfig.test.json +++ b/packages/bun/tsconfig.test.json @@ -5,7 +5,7 @@ "compilerOptions": { // should include all types from `./tsconfig.json` plus types for all test frameworks used - "types": ["node", "jest"] + "types": ["bun-types", "jest"] // other package-specific, test-specific options }