Skip to content

Commit 8001498

Browse files
authored
feat(bun): Instrument Bun.serve (#9080)
1 parent 685d3b2 commit 8001498

File tree

7 files changed

+280
-1
lines changed

7 files changed

+280
-1
lines changed

Diff for: packages/bun/src/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,12 @@ export { defaultIntegrations, init } from './sdk';
6969
import { Integrations as CoreIntegrations } from '@sentry/core';
7070
import { Integrations as NodeIntegrations } from '@sentry/node';
7171

72+
import * as BunIntegrations from './integrations';
73+
7274
const INTEGRATIONS = {
7375
...CoreIntegrations,
7476
...NodeIntegrations,
77+
...BunIntegrations,
7578
};
7679

7780
export { INTEGRATIONS as Integrations };

Diff for: packages/bun/src/integrations/bunserver.ts

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { captureException, getCurrentHub, runWithAsyncContext, startSpan, Transaction } from '@sentry/core';
2+
import type { Integration } from '@sentry/types';
3+
import { addExceptionMechanism, getSanitizedUrlString, parseUrl, tracingContextFromHeaders } from '@sentry/utils';
4+
5+
function sendErrorToSentry(e: unknown): unknown {
6+
captureException(e, scope => {
7+
scope.addEventProcessor(event => {
8+
addExceptionMechanism(event, {
9+
type: 'bun',
10+
handled: false,
11+
data: {
12+
function: 'serve',
13+
},
14+
});
15+
return event;
16+
});
17+
18+
return scope;
19+
});
20+
21+
return e;
22+
}
23+
24+
/**
25+
* Instruments `Bun.serve` to automatically create transactions and capture errors.
26+
*/
27+
export class BunServer implements Integration {
28+
/**
29+
* @inheritDoc
30+
*/
31+
public static id: string = 'BunServer';
32+
33+
/**
34+
* @inheritDoc
35+
*/
36+
public name: string = BunServer.id;
37+
38+
/**
39+
* @inheritDoc
40+
*/
41+
public setupOnce(): void {
42+
instrumentBunServe();
43+
}
44+
}
45+
46+
/**
47+
* Instruments Bun.serve by patching it's options.
48+
*/
49+
export function instrumentBunServe(): void {
50+
Bun.serve = new Proxy(Bun.serve, {
51+
apply(serveTarget, serveThisArg, serveArgs: Parameters<typeof Bun.serve>) {
52+
instrumentBunServeOptions(serveArgs[0]);
53+
return serveTarget.apply(serveThisArg, serveArgs);
54+
},
55+
});
56+
}
57+
58+
/**
59+
* Instruments Bun.serve `fetch` option to automatically create spans and capture errors.
60+
*/
61+
function instrumentBunServeOptions(serveOptions: Parameters<typeof Bun.serve>[0]): void {
62+
serveOptions.fetch = new Proxy(serveOptions.fetch, {
63+
apply(fetchTarget, fetchThisArg, fetchArgs: Parameters<typeof serveOptions.fetch>) {
64+
return runWithAsyncContext(() => {
65+
const hub = getCurrentHub();
66+
67+
const request = fetchArgs[0];
68+
const upperCaseMethod = request.method.toUpperCase();
69+
if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') {
70+
return fetchTarget.apply(fetchThisArg, fetchArgs);
71+
}
72+
73+
const sentryTrace = request.headers.get('sentry-trace') || '';
74+
const baggage = request.headers.get('baggage');
75+
const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders(
76+
sentryTrace,
77+
baggage,
78+
);
79+
hub.getScope().setPropagationContext(propagationContext);
80+
81+
const parsedUrl = parseUrl(request.url);
82+
const data: Record<string, unknown> = {
83+
'http.request.method': request.method || 'GET',
84+
};
85+
if (parsedUrl.search) {
86+
data['http.query'] = parsedUrl.search;
87+
}
88+
89+
const url = getSanitizedUrlString(parsedUrl);
90+
return startSpan(
91+
{
92+
op: 'http.server',
93+
name: `${request.method} ${parsedUrl.path || '/'}`,
94+
origin: 'auto.http.bun.serve',
95+
...traceparentData,
96+
data,
97+
metadata: {
98+
source: 'url',
99+
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
100+
request: {
101+
url,
102+
method: request.method,
103+
headers: request.headers.toJSON(),
104+
},
105+
},
106+
},
107+
async span => {
108+
try {
109+
const response = await (fetchTarget.apply(fetchThisArg, fetchArgs) as ReturnType<
110+
typeof serveOptions.fetch
111+
>);
112+
if (response && response.status) {
113+
span?.setHttpStatus(response.status);
114+
span?.setData('http.response.status_code', response.status);
115+
if (span instanceof Transaction) {
116+
span.setContext('response', {
117+
headers: response.headers.toJSON(),
118+
status_code: response.status,
119+
});
120+
}
121+
}
122+
return response;
123+
} catch (e) {
124+
sendErrorToSentry(e);
125+
throw e;
126+
}
127+
},
128+
);
129+
});
130+
},
131+
});
132+
}

Diff for: packages/bun/src/integrations/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { BunServer } from './bunserver';

Diff for: packages/bun/src/sdk.ts

+3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Integrations as CoreIntegrations } from '@sentry/core';
33
import { init as initNode, Integrations as NodeIntegrations } from '@sentry/node';
44

55
import { BunClient } from './client';
6+
import { BunServer } from './integrations';
67
import { makeFetchTransport } from './transports';
78
import type { BunOptions } from './types';
89

@@ -25,6 +26,8 @@ export const defaultIntegrations = [
2526
new NodeIntegrations.RequestData(),
2627
// Misc
2728
new NodeIntegrations.LinkedErrors(),
29+
// Bun Specific
30+
new BunServer(),
2831
];
2932

3033
/**

Diff for: packages/bun/test/helpers.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { createTransport } from '@sentry/core';
2+
import { resolvedSyncPromise } from '@sentry/utils';
3+
4+
import type { BunClientOptions } from '../src/types';
5+
6+
export function getDefaultBunClientOptions(options: Partial<BunClientOptions> = {}): BunClientOptions {
7+
return {
8+
integrations: [],
9+
transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => resolvedSyncPromise({})),
10+
stackParser: () => [],
11+
instrumenter: 'sentry',
12+
...options,
13+
};
14+
}

Diff for: packages/bun/test/integrations/bunserver.test.ts

+126
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { Hub, makeMain } from '@sentry/core';
2+
// eslint-disable-next-line import/no-unresolved
3+
import { beforeAll, beforeEach, describe, expect, test } from 'bun:test';
4+
5+
import { BunClient } from '../../src/client';
6+
import { instrumentBunServe } from '../../src/integrations/bunserver';
7+
import { getDefaultBunClientOptions } from '../helpers';
8+
9+
// Fun fact: Bun = 2 21 14 :)
10+
const DEFAULT_PORT = 22114;
11+
12+
describe('Bun Serve Integration', () => {
13+
let hub: Hub;
14+
let client: BunClient;
15+
16+
beforeAll(() => {
17+
instrumentBunServe();
18+
});
19+
20+
beforeEach(() => {
21+
const options = getDefaultBunClientOptions({ tracesSampleRate: 1, debug: true });
22+
client = new BunClient(options);
23+
hub = new Hub(client);
24+
makeMain(hub);
25+
});
26+
27+
test('generates a transaction around a request', async () => {
28+
client.on('finishTransaction', transaction => {
29+
expect(transaction.status).toBe('ok');
30+
expect(transaction.tags).toEqual({
31+
'http.status_code': '200',
32+
});
33+
expect(transaction.op).toEqual('http.server');
34+
expect(transaction.name).toEqual('GET /');
35+
});
36+
37+
const server = Bun.serve({
38+
async fetch(_req) {
39+
return new Response('Bun!');
40+
},
41+
port: DEFAULT_PORT,
42+
});
43+
44+
await fetch('http://localhost:22114/');
45+
46+
server.stop();
47+
});
48+
49+
test('generates a post transaction', async () => {
50+
client.on('finishTransaction', transaction => {
51+
expect(transaction.status).toBe('ok');
52+
expect(transaction.tags).toEqual({
53+
'http.status_code': '200',
54+
});
55+
expect(transaction.op).toEqual('http.server');
56+
expect(transaction.name).toEqual('POST /');
57+
});
58+
59+
const server = Bun.serve({
60+
async fetch(_req) {
61+
return new Response('Bun!');
62+
},
63+
port: DEFAULT_PORT,
64+
});
65+
66+
await fetch('http://localhost:22114/', {
67+
method: 'POST',
68+
});
69+
70+
server.stop();
71+
});
72+
73+
test('continues a trace', async () => {
74+
const TRACE_ID = '12312012123120121231201212312012';
75+
const PARENT_SPAN_ID = '1121201211212012';
76+
const PARENT_SAMPLED = '1';
77+
78+
const SENTRY_TRACE_HEADER = `${TRACE_ID}-${PARENT_SPAN_ID}-${PARENT_SAMPLED}`;
79+
const SENTRY_BAGGAGE_HEADER = 'sentry-version=1.0,sentry-environment=production';
80+
81+
client.on('finishTransaction', transaction => {
82+
expect(transaction.traceId).toBe(TRACE_ID);
83+
expect(transaction.parentSpanId).toBe(PARENT_SPAN_ID);
84+
expect(transaction.sampled).toBe(true);
85+
86+
expect(transaction.metadata?.dynamicSamplingContext).toStrictEqual({ version: '1.0', environment: 'production' });
87+
});
88+
89+
const server = Bun.serve({
90+
async fetch(_req) {
91+
return new Response('Bun!');
92+
},
93+
port: DEFAULT_PORT,
94+
});
95+
96+
await fetch('http://localhost:22114/', {
97+
headers: { 'sentry-trace': SENTRY_TRACE_HEADER, baggage: SENTRY_BAGGAGE_HEADER },
98+
});
99+
100+
server.stop();
101+
});
102+
103+
test('does not create transactions for OPTIONS or HEAD requests', async () => {
104+
client.on('finishTransaction', () => {
105+
// This will never run, but we want to make sure it doesn't run.
106+
expect(false).toEqual(true);
107+
});
108+
109+
const server = Bun.serve({
110+
async fetch(_req) {
111+
return new Response('Bun!');
112+
},
113+
port: DEFAULT_PORT,
114+
});
115+
116+
await fetch('http://localhost:22114/', {
117+
method: 'OPTIONS',
118+
});
119+
120+
await fetch('http://localhost:22114/', {
121+
method: 'HEAD',
122+
});
123+
124+
server.stop();
125+
});
126+
});

Diff for: packages/bun/tsconfig.test.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
"compilerOptions": {
77
// should include all types from `./tsconfig.json` plus types for all test frameworks used
8-
"types": ["node", "jest"]
8+
"types": ["bun-types", "jest"]
99

1010
// other package-specific, test-specific options
1111
}

0 commit comments

Comments
 (0)