Skip to content

Commit e5d499d

Browse files
committed
feat(bun): Capture Bun.serve request bodies with maxRequestBodySize
Bun.serve handlers never captured request bodies, so dataCollection.httpBodies and maxRequestBodySize did nothing there. Use the same WinterCG helpers as Deno: winterCGRequestToRequestData for normalizedRequest and captureBodyFromWinterCGRequest for the body. bunServerIntegration gets a maxRequestBodySize option, an explicit value wins over httpBodies, GET is skipped. Swapping the hand-built normalizedRequest changes one field: query_string loses the leading "?" (it was URL.search, the helper strips it like Deno, Cloudflare and Node do). Headers are unchanged, both versions lower-case the keys and neither filters at the source, requestDataIntegration does that downstream against dataCollection.httpHeaders.request. The handler wrapper is now async so it always returns a promise, which Bun.serve accepts.
1 parent 52e26df commit e5d499d

5 files changed

Lines changed: 310 additions & 11 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as Sentry from '@sentry/bun';
2+
3+
// One scenario per process; the test picks the SDK setup through this variable.
4+
const mode = process.env.BODY_MODE;
5+
6+
Sentry.init({
7+
dsn: process.env.SENTRY_DSN,
8+
tracesSampleRate: 1.0,
9+
// Request bodies are only attached to transaction events, so this suite needs the static trace lifecycle.
10+
traceLifecycle: 'static',
11+
...(mode === 'explicit-small' && {
12+
dataCollection: { httpBodies: [] },
13+
integrations: integrations => [
14+
...integrations.filter(integration => integration.name !== 'BunServer'),
15+
Sentry.bunServerIntegration({ maxRequestBodySize: 'small' }),
16+
],
17+
}),
18+
...(mode === 'explicit-none' && {
19+
dataCollection: { httpBodies: ['incomingRequest'] },
20+
integrations: integrations => [
21+
...integrations.filter(integration => integration.name !== 'BunServer'),
22+
Sentry.bunServerIntegration({ maxRequestBodySize: 'none' }),
23+
],
24+
}),
25+
});
26+
27+
const server = Bun.serve({
28+
port: 0,
29+
async fetch(request) {
30+
// Read the body after the SDK did, so the handler still gets the full payload.
31+
return new Response(await request.text());
32+
},
33+
});
34+
35+
process.send?.(JSON.stringify({ event: 'READY', port: server.port }));
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { Envelope, TransactionEvent } from '@sentry/core';
2+
import { expect, it } from 'vitest';
3+
import { createRunner } from '../../runner';
4+
5+
function getTransaction(envelope: Envelope): TransactionEvent {
6+
const [itemHeader, itemPayload] = envelope[1][0];
7+
expect(itemHeader.type).toBe('transaction');
8+
return itemPayload as TransactionEvent;
9+
}
10+
11+
it('captures incoming request bodies by default', async ({ signal }) => {
12+
const runner = createRunner(__dirname)
13+
.expect(envelope => {
14+
const transaction = getTransaction(envelope);
15+
expect(transaction.request).toMatchObject({
16+
method: 'POST',
17+
url: expect.stringContaining('/default'),
18+
query_string: 'source=test',
19+
headers: expect.objectContaining({ 'content-type': 'text/plain' }),
20+
data: 'captured-by-default',
21+
});
22+
})
23+
.start(signal);
24+
25+
const response = await runner.makeRequest<string>('post', '/default?source=test', {
26+
headers: { 'content-type': 'text/plain' },
27+
data: 'captured-by-default',
28+
});
29+
expect(response).toBe('captured-by-default');
30+
await runner.completed();
31+
});
32+
33+
it('an explicit small size overrides disabled body collection', async ({ signal }) => {
34+
const runner = createRunner(__dirname)
35+
.withEnv({ BODY_MODE: 'explicit-small' })
36+
.expect(envelope => {
37+
const transaction = getTransaction(envelope);
38+
expect(transaction.request?.data).toBe(`${'a'.repeat(997)}...`);
39+
})
40+
.start(signal);
41+
42+
const body = 'a'.repeat(1_001);
43+
const response = await runner.makeRequest<string>('post', '/explicit-small', {
44+
headers: { 'content-type': 'text/plain' },
45+
data: body,
46+
});
47+
expect(response).toBe(body);
48+
await runner.completed();
49+
});
50+
51+
it('an explicit none overrides enabled body collection', async ({ signal }) => {
52+
const runner = createRunner(__dirname)
53+
.withEnv({ BODY_MODE: 'explicit-none' })
54+
.expect(envelope => {
55+
const transaction = getTransaction(envelope);
56+
expect(transaction.request?.method).toBe('POST');
57+
expect(transaction.request?.data).toBeUndefined();
58+
})
59+
.start(signal);
60+
61+
const response = await runner.makeRequest<string>('post', '/explicit-none', {
62+
headers: { 'content-type': 'text/plain' },
63+
data: 'do-not-capture',
64+
});
65+
expect(response).toBe('do-not-capture');
66+
await runner.completed();
67+
});

packages/bun/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ export {
207207
initWithoutDefaultIntegrations,
208208
} from './sdk';
209209
export { bunServerIntegration } from './integrations/bunserver';
210+
export type { BunServerIntegrationOptions } from './integrations/bunserver';
210211
export { bunHttpServerIntegration } from './integrations/bunHttpServer';
211212
export { fetchIntegration } from './integrations/fetch';
212213
export { bunRuntimeMetricsIntegration, type BunRuntimeMetricsOptions } from './integrations/bunRuntimeMetrics';

packages/bun/src/integrations/bunserver.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import type { IntegrationFn, RequestEventData, SpanAttributes } from '@sentry/core';
1+
import type { Integration, IntegrationFn, MaxRequestBodySize, SpanAttributes } from '@sentry/core';
22
import {
3+
captureBodyFromWinterCGRequest,
34
captureException,
45
continueTrace,
56
defineIntegration,
@@ -15,6 +16,7 @@ import {
1516
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
1617
setHttpStatus,
1718
startSpan,
19+
winterCGRequestToRequestData,
1820
withIsolationScope,
1921
filterCollectedUrl,
2022
filterCollectedUrlQuery,
@@ -35,9 +37,22 @@ import { HTTP_SERVER } from '@sentry/conventions/op';
3537

3638
const INTEGRATION_NAME = 'BunServer' as const;
3739

38-
const _bunServerIntegration = (() => {
40+
export type BunServerIntegrationOptions = {
41+
/**
42+
* Controls the maximum size of incoming HTTP request bodies attached to events.
43+
* An explicit value overrides `dataCollection.httpBodies`.
44+
*
45+
* If `dataCollection.httpBodies` excludes `'incomingRequest'`, body capture defaults to `'none'`.
46+
*
47+
* @default 'medium'
48+
*/
49+
maxRequestBodySize?: MaxRequestBodySize;
50+
};
51+
52+
const _bunServerIntegration = ((options: BunServerIntegrationOptions = {}) => {
3953
return {
4054
name: INTEGRATION_NAME,
55+
maxRequestBodySize: options.maxRequestBodySize,
4156
setupOnce() {
4257
instrumentBunServe();
4358
},
@@ -192,8 +207,8 @@ function wrapRequestHandler<T extends RouteHandler = RouteHandler>(
192207
thisArg: unknown,
193208
args: Parameters<T>,
194209
route?: string,
195-
): ReturnType<T> {
196-
return withIsolationScope(isolationScope => {
210+
): Promise<Awaited<ReturnType<T>>> {
211+
return withIsolationScope(async isolationScope => {
197212
const request = args[0];
198213
const upperCaseMethod = request.method.toUpperCase();
199214
if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') {
@@ -232,14 +247,20 @@ function wrapRequestHandler<T extends RouteHandler = RouteHandler>(
232247
}
233248

234249
isolationScope.setSDKProcessingMetadata({
235-
normalizedRequest: {
236-
url: request.url,
237-
method: request.method,
238-
headers: request.headers.toJSON(),
239-
query_string: parsedUrl?.search,
240-
} satisfies RequestEventData,
250+
normalizedRequest: winterCGRequestToRequestData(request),
241251
});
242252

253+
if (client && dataCollection) {
254+
const configuredBodySize = client.getIntegrationByName<Integration & { maxRequestBodySize?: MaxRequestBodySize }>(
255+
INTEGRATION_NAME,
256+
)?.maxRequestBodySize;
257+
const effectiveBodySize =
258+
configuredBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none');
259+
if (upperCaseMethod !== 'GET' && effectiveBodySize !== 'none') {
260+
await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize);
261+
}
262+
}
263+
243264
return continueTrace(
244265
{
245266
sentryTrace: request.headers.get('sentry-trace') ?? '',

packages/bun/test/integrations/bunserver.test.ts

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import type { RequestEventData } from '@sentry/core';
12
import * as SentryCore from '@sentry/core';
23
import { afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test';
34
import type { BunOptions } from '../../src';
4-
import { getDefaultIntegrationsWithoutPerformance, init } from '../../src';
5+
import { bunServerIntegration, getDefaultIntegrationsWithoutPerformance, init } from '../../src';
56
import { instrumentBunServe } from '../../src/integrations/bunserver';
67

78
describe('Bun Serve Integration', () => {
@@ -608,4 +609,178 @@ describe('Bun Serve Integration', () => {
608609
expect(responseAttributes?.['http.response.header.x_public']).toBe('public-value');
609610
});
610611
});
612+
613+
describe('request bodies', () => {
614+
const captureBodySpy = spyOn(SentryCore, 'captureBodyFromWinterCGRequest');
615+
616+
beforeEach(() => {
617+
captureBodySpy.mockClear();
618+
});
619+
620+
// Serves one request and returns the `normalizedRequest` the handler saw on its isolation scope. The body is
621+
// read after the SDK has had its turn, so this also proves capturing does not consume it.
622+
async function serveAndCapture(path: string, requestInit: RequestInit): Promise<RequestEventData | undefined> {
623+
let normalizedRequest: RequestEventData | undefined;
624+
const server = Bun.serve({
625+
async fetch(req) {
626+
normalizedRequest = SentryCore.getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest;
627+
return new Response(await req.text());
628+
},
629+
port,
630+
});
631+
632+
const response = await fetch(`http://localhost:${port}${path}`, requestInit);
633+
expect(await response.text()).toBe(typeof requestInit.body === 'string' ? requestInit.body : '');
634+
635+
await server.stop();
636+
return normalizedRequest;
637+
}
638+
639+
test('normalizes the request like the other WinterCG runtimes', async () => {
640+
const normalizedRequest = await serveAndCapture('/users?id=123&sort=asc', {
641+
method: 'POST',
642+
headers: { 'Content-Type': 'text/plain', 'X-Custom-Header': 'custom-value' },
643+
body: 'hello',
644+
});
645+
646+
expect(normalizedRequest).toEqual({
647+
method: 'POST',
648+
url: `http://localhost:${port}/users?id=123&sort=asc`,
649+
// No leading `?`, matching `winterCGRequestToRequestData` on Deno and Cloudflare
650+
query_string: 'id=123&sort=asc',
651+
headers: expect.objectContaining({
652+
'content-type': 'text/plain',
653+
'x-custom-header': 'custom-value',
654+
'content-length': '5',
655+
}),
656+
data: 'hello',
657+
});
658+
});
659+
660+
test('captures incoming request bodies by default', async () => {
661+
const body = JSON.stringify({ username: 'test', action: 'login' });
662+
const normalizedRequest = await serveAndCapture('/login', {
663+
method: 'POST',
664+
headers: { 'Content-Type': 'application/json' },
665+
body,
666+
});
667+
668+
expect(captureBodySpy).toHaveBeenCalledTimes(1);
669+
expect(captureBodySpy).toHaveBeenCalledWith(expect.any(Request), expect.any(SentryCore.Scope), 'medium');
670+
expect(normalizedRequest?.data).toBe(body);
671+
});
672+
673+
test('captures bodies on route handlers', async () => {
674+
let normalizedRequest: RequestEventData | undefined;
675+
const server = Bun.serve({
676+
routes: {
677+
'/api/posts': {
678+
POST: async req => {
679+
normalizedRequest = SentryCore.getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest;
680+
return new Response(await req.text());
681+
},
682+
},
683+
},
684+
port,
685+
});
686+
687+
const response = await fetch(`http://localhost:${port}/api/posts`, {
688+
method: 'POST',
689+
headers: { 'Content-Type': 'application/json' },
690+
body: '{"title":"New Post"}',
691+
});
692+
expect(await response.text()).toBe('{"title":"New Post"}');
693+
await server.stop();
694+
695+
expect(normalizedRequest?.data).toBe('{"title":"New Post"}');
696+
});
697+
698+
test('does not read bodies of GET requests', async () => {
699+
const normalizedRequest = await serveAndCapture('/users', { method: 'GET' });
700+
701+
expect(captureBodySpy).not.toHaveBeenCalled();
702+
expect(normalizedRequest?.method).toBe('GET');
703+
expect(normalizedRequest?.data).toBeUndefined();
704+
});
705+
706+
test('truncates bodies larger than the default medium size', async () => {
707+
const body = 'a'.repeat(10_001);
708+
const normalizedRequest = await serveAndCapture('/upload', {
709+
method: 'POST',
710+
headers: { 'Content-Type': 'text/plain' },
711+
body,
712+
});
713+
714+
expect(normalizedRequest?.data).toBe(`${'a'.repeat(9_997)}...`);
715+
});
716+
717+
test('does not capture bodies when dataCollection.httpBodies excludes incoming requests', async () => {
718+
setupClient({ dataCollection: { httpBodies: [] } });
719+
720+
const normalizedRequest = await serveAndCapture('/login', {
721+
method: 'POST',
722+
headers: { 'Content-Type': 'application/json' },
723+
body: '{"secret":"do-not-capture"}',
724+
});
725+
726+
expect(captureBodySpy).not.toHaveBeenCalled();
727+
expect(normalizedRequest?.data).toBeUndefined();
728+
});
729+
730+
test('an explicit maxRequestBodySize overrides disabled body collection', async () => {
731+
setupClient({
732+
dataCollection: { httpBodies: [] },
733+
integrations: [bunServerIntegration({ maxRequestBodySize: 'small' })],
734+
});
735+
736+
const normalizedRequest = await serveAndCapture('/upload', {
737+
method: 'POST',
738+
headers: { 'Content-Type': 'text/plain' },
739+
body: 'a'.repeat(1_001),
740+
});
741+
742+
expect(captureBodySpy).toHaveBeenCalledWith(expect.any(Request), expect.any(SentryCore.Scope), 'small');
743+
expect(normalizedRequest?.data).toBe(`${'a'.repeat(997)}...`);
744+
});
745+
746+
test('an explicit none overrides enabled body collection', async () => {
747+
setupClient({
748+
dataCollection: { httpBodies: ['incomingRequest'] },
749+
integrations: [bunServerIntegration({ maxRequestBodySize: 'none' })],
750+
});
751+
752+
const normalizedRequest = await serveAndCapture('/login', {
753+
method: 'POST',
754+
headers: { 'Content-Type': 'text/plain' },
755+
body: 'do-not-capture',
756+
});
757+
758+
expect(captureBodySpy).not.toHaveBeenCalled();
759+
expect(normalizedRequest?.data).toBeUndefined();
760+
});
761+
762+
test('always captures bodies beyond the medium size', async () => {
763+
setupClient({ integrations: [bunServerIntegration({ maxRequestBodySize: 'always' })] });
764+
765+
const body = 'a'.repeat(20_000);
766+
const normalizedRequest = await serveAndCapture('/upload', {
767+
method: 'POST',
768+
headers: { 'Content-Type': 'text/plain' },
769+
body,
770+
});
771+
772+
expect(captureBodySpy).toHaveBeenCalledWith(expect.any(Request), expect.any(SentryCore.Scope), 'always');
773+
expect(normalizedRequest?.data).toBe(body);
774+
});
775+
776+
test('skips non-textual bodies', async () => {
777+
const normalizedRequest = await serveAndCapture('/upload', {
778+
method: 'POST',
779+
headers: { 'Content-Type': 'application/octet-stream' },
780+
body: 'binary-ish',
781+
});
782+
783+
expect(normalizedRequest?.data).toBeUndefined();
784+
});
785+
});
611786
});

0 commit comments

Comments
 (0)