Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as Sentry from '@sentry/bun';

// One scenario per process; the test picks the SDK setup through this variable.
const mode = process.env.BODY_MODE;

Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 1.0,
// Request bodies are only attached to transaction events, so this suite needs the static trace lifecycle.
traceLifecycle: 'static',
...(mode === 'explicit-small' && {
dataCollection: { httpBodies: [] },
integrations: integrations => [
...integrations.filter(integration => integration.name !== 'BunServer'),
Sentry.bunServerIntegration({ maxRequestBodySize: 'small' }),
],
}),
...(mode === 'explicit-none' && {
dataCollection: { httpBodies: ['incomingRequest'] },
integrations: integrations => [
...integrations.filter(integration => integration.name !== 'BunServer'),
Sentry.bunServerIntegration({ maxRequestBodySize: 'none' }),
],
}),
});

const server = Bun.serve({
port: 0,
async fetch(request) {
// Read the body after the SDK did, so the handler still gets the full payload.
return new Response(await request.text());
},
});

process.send?.(JSON.stringify({ event: 'READY', port: server.port }));
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Envelope, TransactionEvent } from '@sentry/core';
import { expect, it } from 'vitest';
import { createRunner } from '../../runner';

function getTransaction(envelope: Envelope): TransactionEvent {
const [itemHeader, itemPayload] = envelope[1][0];
expect(itemHeader.type).toBe('transaction');
return itemPayload as TransactionEvent;
}

it('captures incoming request bodies by default', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect(envelope => {
const transaction = getTransaction(envelope);
expect(transaction.request).toMatchObject({
method: 'POST',
url: expect.stringContaining('/default'),
query_string: 'source=test',
headers: expect.objectContaining({ 'content-type': 'text/plain' }),
data: 'captured-by-default',
});
})
.start(signal);

const response = await runner.makeRequest<string>('post', '/default?source=test', {
headers: { 'content-type': 'text/plain' },
data: 'captured-by-default',
});
expect(response).toBe('captured-by-default');
await runner.completed();
});

it('an explicit small size overrides disabled body collection', async ({ signal }) => {
const runner = createRunner(__dirname)
.withEnv({ BODY_MODE: 'explicit-small' })
.expect(envelope => {
const transaction = getTransaction(envelope);
expect(transaction.request?.data).toBe(`${'a'.repeat(997)}...`);
})
.start(signal);

const body = 'a'.repeat(1_001);
const response = await runner.makeRequest<string>('post', '/explicit-small', {
headers: { 'content-type': 'text/plain' },
data: body,
});
expect(response).toBe(body);
await runner.completed();
});

it('an explicit none overrides enabled body collection', async ({ signal }) => {
const runner = createRunner(__dirname)
.withEnv({ BODY_MODE: 'explicit-none' })
.expect(envelope => {
const transaction = getTransaction(envelope);
expect(transaction.request?.method).toBe('POST');
expect(transaction.request?.data).toBeUndefined();
})
.start(signal);

const response = await runner.makeRequest<string>('post', '/explicit-none', {
headers: { 'content-type': 'text/plain' },
data: 'do-not-capture',
});
expect(response).toBe('do-not-capture');
await runner.completed();
});
1 change: 1 addition & 0 deletions packages/bun/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ export {
initWithoutDefaultIntegrations,
} from './sdk';
export { bunServerIntegration } from './integrations/bunserver';
export type { BunServerIntegrationOptions } from './integrations/bunserver';
export { bunHttpServerIntegration } from './integrations/bunHttpServer';
export { fetchIntegration } from './integrations/fetch';
export { bunRuntimeMetricsIntegration, type BunRuntimeMetricsOptions } from './integrations/bunRuntimeMetrics';
Expand Down
41 changes: 31 additions & 10 deletions packages/bun/src/integrations/bunserver.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { IntegrationFn, RequestEventData, SpanAttributes } from '@sentry/core';
import type { Integration, IntegrationFn, MaxRequestBodySize, SpanAttributes } from '@sentry/core';
import {
captureBodyFromWinterCGRequest,
captureException,
continueTrace,
defineIntegration,
Expand All @@ -15,6 +16,7 @@ import {
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
setHttpStatus,
startSpan,
winterCGRequestToRequestData,
withIsolationScope,
filterCollectedUrl,
filterCollectedUrlQuery,
Expand All @@ -35,9 +37,22 @@ import { HTTP_SERVER } from '@sentry/conventions/op';

const INTEGRATION_NAME = 'BunServer' as const;

const _bunServerIntegration = (() => {
export type BunServerIntegrationOptions = {
/**
* Controls the maximum size of incoming HTTP request bodies attached to events.
* An explicit value overrides `dataCollection.httpBodies`.
*
* If `dataCollection.httpBodies` excludes `'incomingRequest'`, body capture defaults to `'none'`.
*
* @default 'medium'
*/
maxRequestBodySize?: MaxRequestBodySize;
};

const _bunServerIntegration = ((options: BunServerIntegrationOptions = {}) => {
return {
name: INTEGRATION_NAME,
maxRequestBodySize: options.maxRequestBodySize,
setupOnce() {
instrumentBunServe();
},
Expand Down Expand Up @@ -192,8 +207,8 @@ function wrapRequestHandler<T extends RouteHandler = RouteHandler>(
thisArg: unknown,
args: Parameters<T>,
route?: string,
): ReturnType<T> {
return withIsolationScope(isolationScope => {
): Promise<Awaited<ReturnType<T>>> {
return withIsolationScope(async isolationScope => {
const request = args[0];
const upperCaseMethod = request.method.toUpperCase();
if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') {
Expand Down Expand Up @@ -232,14 +247,20 @@ function wrapRequestHandler<T extends RouteHandler = RouteHandler>(
}

isolationScope.setSDKProcessingMetadata({
normalizedRequest: {
url: request.url,
method: request.method,
headers: request.headers.toJSON(),
query_string: parsedUrl?.search,
} satisfies RequestEventData,
normalizedRequest: winterCGRequestToRequestData(request),
});

if (client && dataCollection) {
const configuredBodySize = client.getIntegrationByName<Integration & { maxRequestBodySize?: MaxRequestBodySize }>(
INTEGRATION_NAME,
)?.maxRequestBodySize;
const effectiveBodySize =
configuredBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none');
if (upperCaseMethod !== 'GET' && effectiveBodySize !== 'none') {
await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize);
}
}

return continueTrace(
{
sentryTrace: request.headers.get('sentry-trace') ?? '',
Expand Down
177 changes: 176 additions & 1 deletion packages/bun/test/integrations/bunserver.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { RequestEventData } from '@sentry/core';
import * as SentryCore from '@sentry/core';
import { afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test';
import type { BunOptions } from '../../src';
import { getDefaultIntegrationsWithoutPerformance, init } from '../../src';
import { bunServerIntegration, getDefaultIntegrationsWithoutPerformance, init } from '../../src';
import { instrumentBunServe } from '../../src/integrations/bunserver';

describe('Bun Serve Integration', () => {
Expand Down Expand Up @@ -608,4 +609,178 @@ describe('Bun Serve Integration', () => {
expect(responseAttributes?.['http.response.header.x_public']).toBe('public-value');
});
});

describe('request bodies', () => {
const captureBodySpy = spyOn(SentryCore, 'captureBodyFromWinterCGRequest');

beforeEach(() => {
captureBodySpy.mockClear();
});

// Serves one request and returns the `normalizedRequest` the handler saw on its isolation scope. The body is
// read after the SDK has had its turn, so this also proves capturing does not consume it.
async function serveAndCapture(path: string, requestInit: RequestInit): Promise<RequestEventData | undefined> {
let normalizedRequest: RequestEventData | undefined;
const server = Bun.serve({
async fetch(req) {
normalizedRequest = SentryCore.getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest;
return new Response(await req.text());
},
port,
});

const response = await fetch(`http://localhost:${port}${path}`, requestInit);
expect(await response.text()).toBe(typeof requestInit.body === 'string' ? requestInit.body : '');

await server.stop();
return normalizedRequest;
}

test('normalizes the request like the other WinterCG runtimes', async () => {
const normalizedRequest = await serveAndCapture('/users?id=123&sort=asc', {
method: 'POST',
headers: { 'Content-Type': 'text/plain', 'X-Custom-Header': 'custom-value' },
body: 'hello',
});

expect(normalizedRequest).toEqual({
method: 'POST',
url: `http://localhost:${port}/users?id=123&sort=asc`,
// No leading `?`, matching `winterCGRequestToRequestData` on Deno and Cloudflare
query_string: 'id=123&sort=asc',
headers: expect.objectContaining({
'content-type': 'text/plain',
'x-custom-header': 'custom-value',
'content-length': '5',
}),
data: 'hello',
});
});

test('captures incoming request bodies by default', async () => {
const body = JSON.stringify({ username: 'test', action: 'login' });
const normalizedRequest = await serveAndCapture('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});

expect(captureBodySpy).toHaveBeenCalledTimes(1);
expect(captureBodySpy).toHaveBeenCalledWith(expect.any(Request), expect.any(SentryCore.Scope), 'medium');
expect(normalizedRequest?.data).toBe(body);
});

test('captures bodies on route handlers', async () => {
let normalizedRequest: RequestEventData | undefined;
const server = Bun.serve({
routes: {
'/api/posts': {
POST: async req => {
normalizedRequest = SentryCore.getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest;
return new Response(await req.text());
},
},
},
port,
});

const response = await fetch(`http://localhost:${port}/api/posts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{"title":"New Post"}',
});
expect(await response.text()).toBe('{"title":"New Post"}');
await server.stop();

expect(normalizedRequest?.data).toBe('{"title":"New Post"}');
});

test('does not read bodies of GET requests', async () => {
const normalizedRequest = await serveAndCapture('/users', { method: 'GET' });

expect(captureBodySpy).not.toHaveBeenCalled();
expect(normalizedRequest?.method).toBe('GET');
expect(normalizedRequest?.data).toBeUndefined();
});

test('truncates bodies larger than the default medium size', async () => {
const body = 'a'.repeat(10_001);
const normalizedRequest = await serveAndCapture('/upload', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body,
});

expect(normalizedRequest?.data).toBe(`${'a'.repeat(9_997)}...`);
});

test('does not capture bodies when dataCollection.httpBodies excludes incoming requests', async () => {
setupClient({ dataCollection: { httpBodies: [] } });

const normalizedRequest = await serveAndCapture('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{"secret":"do-not-capture"}',
});

expect(captureBodySpy).not.toHaveBeenCalled();
expect(normalizedRequest?.data).toBeUndefined();
});

test('an explicit maxRequestBodySize overrides disabled body collection', async () => {
setupClient({
dataCollection: { httpBodies: [] },
integrations: [bunServerIntegration({ maxRequestBodySize: 'small' })],
});

const normalizedRequest = await serveAndCapture('/upload', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: 'a'.repeat(1_001),
});

expect(captureBodySpy).toHaveBeenCalledWith(expect.any(Request), expect.any(SentryCore.Scope), 'small');
expect(normalizedRequest?.data).toBe(`${'a'.repeat(997)}...`);
});

test('an explicit none overrides enabled body collection', async () => {
setupClient({
dataCollection: { httpBodies: ['incomingRequest'] },
integrations: [bunServerIntegration({ maxRequestBodySize: 'none' })],
});

const normalizedRequest = await serveAndCapture('/login', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: 'do-not-capture',
});

expect(captureBodySpy).not.toHaveBeenCalled();
expect(normalizedRequest?.data).toBeUndefined();
});

test('always captures bodies beyond the medium size', async () => {
setupClient({ integrations: [bunServerIntegration({ maxRequestBodySize: 'always' })] });

const body = 'a'.repeat(20_000);
const normalizedRequest = await serveAndCapture('/upload', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body,
});

expect(captureBodySpy).toHaveBeenCalledWith(expect.any(Request), expect.any(SentryCore.Scope), 'always');
expect(normalizedRequest?.data).toBe(body);
});

test('skips non-textual bodies', async () => {
const normalizedRequest = await serveAndCapture('/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: 'binary-ish',
});

expect(normalizedRequest?.data).toBeUndefined();
});
});
});
Loading