diff --git a/dev-packages/bun-integration-tests/suites/request-bodies/index.ts b/dev-packages/bun-integration-tests/suites/request-bodies/index.ts new file mode 100644 index 000000000000..d3bf4d5417a5 --- /dev/null +++ b/dev-packages/bun-integration-tests/suites/request-bodies/index.ts @@ -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 })); diff --git a/dev-packages/bun-integration-tests/suites/request-bodies/test.ts b/dev-packages/bun-integration-tests/suites/request-bodies/test.ts new file mode 100644 index 000000000000..da08cbc248c7 --- /dev/null +++ b/dev-packages/bun-integration-tests/suites/request-bodies/test.ts @@ -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('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('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('post', '/explicit-none', { + headers: { 'content-type': 'text/plain' }, + data: 'do-not-capture', + }); + expect(response).toBe('do-not-capture'); + await runner.completed(); +}); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index fe427aa97424..022cf4383495 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -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'; diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 0378d28f6b68..a61f7dbf1a3f 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -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, @@ -15,6 +16,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, setHttpStatus, startSpan, + winterCGRequestToRequestData, withIsolationScope, filterCollectedUrl, filterCollectedUrlQuery, @@ -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(); }, @@ -192,8 +207,8 @@ function wrapRequestHandler( thisArg: unknown, args: Parameters, route?: string, -): ReturnType { - return withIsolationScope(isolationScope => { +): Promise>> { + return withIsolationScope(async isolationScope => { const request = args[0]; const upperCaseMethod = request.method.toUpperCase(); if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') { @@ -232,14 +247,20 @@ function wrapRequestHandler( } 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_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') ?? '', diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index c1362865cd98..db2667737a47 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -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', () => { @@ -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 { + 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(); + }); + }); });