From 34965933bf031cd163f3c030cb0693eaec69c6a6 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:49:55 +0200 Subject: [PATCH 1/2] feat(core)!: Gate incoming HTTP body capture on `dataCollection.httpBodies` --- .../cloudflare/src/integrations/httpServer.ts | 15 ++-- packages/cloudflare/test/request.test.ts | 70 +++++++++++++++++-- .../integrations/http/server-subscription.ts | 10 ++- packages/deno/src/integrations/http.ts | 2 +- .../http/httpServerIntegration.ts | 2 +- 5 files changed, 82 insertions(+), 17 deletions(-) diff --git a/packages/cloudflare/src/integrations/httpServer.ts b/packages/cloudflare/src/integrations/httpServer.ts index 07b1204e17b5..97e960f916d2 100644 --- a/packages/cloudflare/src/integrations/httpServer.ts +++ b/packages/cloudflare/src/integrations/httpServer.ts @@ -42,14 +42,14 @@ export interface HttpServerIntegrationOptions { interface HttpServerIntegrationInstance { name: string; - maxRequestBodySize: MaxRequestBodySize; + maxRequestBodySize: MaxRequestBodySize | undefined; ignoreRequestBody?: (url: string, request: Request) => boolean; } const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}): HttpServerIntegrationInstance => { return { name: INTEGRATION_NAME, - maxRequestBodySize: options.maxRequestBodySize ?? 'medium', + maxRequestBodySize: options.maxRequestBodySize, ignoreRequestBody: options.ignoreRequestBody, }; }) satisfies IntegrationFn; @@ -85,11 +85,12 @@ export async function captureIncomingRequestBody(client: Client, request: Reques return; } - // TODO(v11): Gate incoming request body capture on `dataCollection.httpBodies` (capture only when - // `'incomingRequest'` is listed) instead of defaulting to `'medium'`. - const maxRequestBodySize = integration.maxRequestBodySize; + const configuredBodySize = integration.maxRequestBodySize; + const effectiveBodySize: MaxRequestBodySize = + configuredBodySize ?? + (client.getDataCollectionOptions().httpBodies.includes('incomingRequest') ? 'medium' : 'none'); - if (maxRequestBodySize === 'none') { + if (effectiveBodySize === 'none') { return; } @@ -104,5 +105,5 @@ export async function captureIncomingRequestBody(client: Client, request: Reques } const isolationScope = getIsolationScope(); - await captureBodyFromWinterCGRequest(request, isolationScope, maxRequestBodySize); + await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize); } diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index a517c5b9ef39..375efa4b6dd8 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -8,6 +8,7 @@ import { beforeAll, beforeEach, describe, expect, onTestFinished, test, vi } fro import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async'; import type { CloudflareOptions } from '../src/client'; import { CloudflareClient } from '../src/client'; +import { httpServerIntegration } from '../src/integrations/httpServer'; import { wrapRequestHandler } from '../src/request'; const MOCK_OPTIONS: CloudflareOptions = { @@ -206,10 +207,7 @@ describe('withSentry', () => { expect(sentryEvent.contexts?.culture).toEqual({ timezone: 'UTC' }); }); - // TODO(v11): Body capture should be gated on `dataCollection.httpBodies` (only capture when - // `'incomingRequest'` is listed). Until then we keep the historical behavior of capturing - // incoming request bodies by default at `'medium'`, consistent with the Node SDK. - test('captures request body with default integration (medium size)', async () => { + test('captures request body by default (all body types included by default)', async () => { let sentryEvent: Event = {}; const context = createMockExecutionContext(); @@ -217,7 +215,6 @@ describe('withSentry', () => { { options: { ...MOCK_OPTIONS, - // Default integrations include httpServerIntegration with 'medium' default beforeSend(event) { sentryEvent = event; return null; @@ -241,6 +238,69 @@ describe('withSentry', () => { ); }); + test('does not capture request body when dataCollection.httpBodies excludes incomingRequest', async () => { + let sentryEvent: Event = {}; + const context = createMockExecutionContext(); + + await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + dataCollection: { httpBodies: [] }, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'test', data: 'value' }), + }), + context, + }, + () => { + SentryCore.captureMessage('request body'); + return new Response('test'); + }, + ); + + expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toBeUndefined(); + }); + + test('explicit maxRequestBodySize overrides dataCollection.httpBodies', async () => { + let sentryEvent: Event = {}; + const context = createMockExecutionContext(); + + await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + integrations: [ + // httpBodies not set → would default to 'none', but explicit override wins + httpServerIntegration({ maxRequestBodySize: 'medium' }), + ], + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ key: 'value' }), + }), + context, + }, + () => { + SentryCore.captureMessage('request body'); + return new Response('test'); + }, + ); + + expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toEqual(JSON.stringify({ key: 'value' })); + }); + // TODO(v11): Cookies should be attached (subject to denylist filtering) by default. Until then we keep the // historical Cloudflare behavior of not attaching cookies unless the user explicitly opts in. test('does not capture cookies by default', async () => { diff --git a/packages/core/src/integrations/http/server-subscription.ts b/packages/core/src/integrations/http/server-subscription.ts index 7b3c26b19f4f..98ade1cac0f6 100644 --- a/packages/core/src/integrations/http/server-subscription.ts +++ b/packages/core/src/integrations/http/server-subscription.ts @@ -108,14 +108,18 @@ export function instrumentServer(options: HttpInstrumentationOptions, server: Ht const url = request.url || '/'; const normalizedRequest = httpRequestToRequestData(request); const { - maxRequestBodySize = 'medium', + maxRequestBodySize: configuredBodySize, ignoreRequestBody, sessions = true, sessionFlushingDelayMS = 60_000, } = options; - if (maxRequestBodySize !== 'none' && !ignoreRequestBody?.(url, request)) { - patchRequestToCaptureBody(request, isolationScope, maxRequestBodySize, INTEGRATION_NAME); + const effectiveBodySize = + configuredBodySize ?? + (client.getDataCollectionOptions().httpBodies.includes('incomingRequest') ? 'medium' : 'none'); + + if (effectiveBodySize !== 'none' && !ignoreRequestBody?.(url, request)) { + patchRequestToCaptureBody(request, isolationScope, effectiveBodySize, INTEGRATION_NAME); } // Update the isolation scope, isolate this request diff --git a/packages/deno/src/integrations/http.ts b/packages/deno/src/integrations/http.ts index ee977ae3a74e..e12b03c1a9dd 100644 --- a/packages/deno/src/integrations/http.ts +++ b/packages/deno/src/integrations/http.ts @@ -99,7 +99,7 @@ const _denoHttpIntegration = ((options: DenoHttpIntegrationOptions = {}) => { spans: options.spans, ignoreStaticAssets: options.ignoreStaticAssets, ignoreIncomingRequests: options.ignoreIncomingRequests, - maxRequestBodySize: options.maxRequestBodySize ?? 'medium', + maxRequestBodySize: options.maxRequestBodySize, ignoreRequestBody: options.ignoreRequestBody, onSpanCreated: options.onIncomingSpanCreated, onSpanEnd: options.onIncomingSpanEnd, diff --git a/packages/node/src/integrations/http/httpServerIntegration.ts b/packages/node/src/integrations/http/httpServerIntegration.ts index f98b0db01205..21e697a7bdf0 100644 --- a/packages/node/src/integrations/http/httpServerIntegration.ts +++ b/packages/node/src/integrations/http/httpServerIntegration.ts @@ -83,7 +83,7 @@ const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}) => const _options = { sessions: options.sessions ?? true, sessionFlushingDelayMS: options.sessionFlushingDelayMS ?? 60_000, - maxRequestBodySize: options.maxRequestBodySize ?? 'medium', + maxRequestBodySize: options.maxRequestBodySize, // Server spans are created by `httpServerSpansIntegration` via the // `httpServerRequest` client event + `_startSpanCallback`, not by the // core subscription helper. Explicitly opt out so the helper does not From 9ffbe05e464c9d0614ad7e0ab000bfc8469fbcdd Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:07:55 +0200 Subject: [PATCH 2/2] fix tests --- .../instrument-small.mjs | 1 + .../maxIncomingRequestBodySize/test.ts | 28 +++++++++++++++++++ packages/cloudflare/test/request.test.ts | 6 ++-- .../defaultPiiToCollectionOptions.ts | 3 +- .../defaultPiiToCollectionOptions.test.ts | 6 ++-- .../resolveDataCollectionOptions.test.ts | 4 +-- 6 files changed, 38 insertions(+), 10 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-small.mjs b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-small.mjs index 469b9a00770a..5e056b9dff30 100644 --- a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-small.mjs +++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-small.mjs @@ -7,5 +7,6 @@ Sentry.init({ release: '1.0', tracesSampleRate: 1.0, transport: loggingTransport, + dataCollection: { httpBodies: [] }, integrations: [Sentry.httpIntegration({ maxIncomingRequestBodySize: 'small' })], }); diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/test.ts b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/test.ts index 84f0a5ee213a..6e5843b54b9b 100644 --- a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/test.ts +++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/test.ts @@ -60,6 +60,34 @@ describe('express with httpIntegration and not defined maxIncomingRequestBodySiz }); }); +describe('express with httpIntegration, disabled httpBodies, and explicit maxIncomingRequestBodySize', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-small.mjs', (createRunner, test) => { + test('captures request bodies because the explicit size overrides dataCollection.httpBodies', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'POST /test-body-size', + request: { + data: JSON.stringify(generatePayload(MAX_SMALL)), + }, + }, + }) + .start(); + + await runner.makeRequest('post', '/test-body-size', { + headers: { 'Content-Type': 'application/json' }, + data: JSON.stringify(generatePayload(MAX_SMALL)), + }); + + await runner.completed(); + }); + }); +}); + describe('express with httpIntegration and maxIncomingRequestBodySize: "none"', () => { afterAll(() => { cleanupChildProcesses(); diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index 375efa4b6dd8..f9d8932aa87f 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -276,10 +276,8 @@ describe('withSentry', () => { { options: { ...MOCK_OPTIONS, - integrations: [ - // httpBodies not set → would default to 'none', but explicit override wins - httpServerIntegration({ maxRequestBodySize: 'medium' }), - ], + dataCollection: { httpBodies: [] }, + integrations: [httpServerIntegration({ maxRequestBodySize: 'medium' })], beforeSend(event) { sentryEvent = event; return null; diff --git a/packages/core/src/utils/data-collection/defaultPiiToCollectionOptions.ts b/packages/core/src/utils/data-collection/defaultPiiToCollectionOptions.ts index a5bc57cb410a..f388e56365c4 100644 --- a/packages/core/src/utils/data-collection/defaultPiiToCollectionOptions.ts +++ b/packages/core/src/utils/data-collection/defaultPiiToCollectionOptions.ts @@ -26,7 +26,8 @@ export function defaultPiiToCollectionOptions(sendDefaultPii?: boolean): Resolve userInfo: false, cookies: { deny: PII_HEADER_SNIPPETS }, httpHeaders: { request: { deny: PII_HEADER_SNIPPETS }, response: { deny: PII_HEADER_SNIPPETS } }, - httpBodies: [], + // Request bodies were governed by maxRequestBodySize, not sendDefaultPii, so preserve that behavior. + httpBodies: ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'], urlQueryParams: { deny: PII_HEADER_SNIPPETS }, // The GraphQL document has literal values redacted at collection time, so it was historically // always attached regardless of `sendDefaultPii`; keep it on to preserve that behavior. diff --git a/packages/core/test/lib/utils/data-collection/defaultPiiToCollectionOptions.test.ts b/packages/core/test/lib/utils/data-collection/defaultPiiToCollectionOptions.test.ts index 23a5f38baeef..dcbe278362e8 100644 --- a/packages/core/test/lib/utils/data-collection/defaultPiiToCollectionOptions.test.ts +++ b/packages/core/test/lib/utils/data-collection/defaultPiiToCollectionOptions.test.ts @@ -25,7 +25,7 @@ describe('defaultPiiToCollectionOptions', () => { request: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, response: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, }, - httpBodies: [], + httpBodies: ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'], urlQueryParams: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, @@ -43,7 +43,7 @@ describe('defaultPiiToCollectionOptions', () => { request: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, response: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, }, - httpBodies: [], + httpBodies: ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'], urlQueryParams: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, @@ -61,7 +61,7 @@ describe('defaultPiiToCollectionOptions', () => { request: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, response: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, }, - httpBodies: [], + httpBodies: ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'], urlQueryParams: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, diff --git a/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts b/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts index 5521462d2334..310ae1ca85ad 100644 --- a/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts +++ b/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts @@ -21,7 +21,7 @@ describe('resolveDataCollectionOptions', () => { // sendDefaultPii undefined → restrictive bridge (backward compat; userInfo defaults to true only when dataCollection is set) expect(result.userInfo).toBe(false); - expect(result.httpBodies).toEqual([]); + expect(result.httpBodies).toEqual(['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse']); expect(result.genAI).toEqual({ inputs: true, outputs: true }); // GraphQL documents are redacted at collection time, so they stay on to preserve legacy behavior. expect(result.graphQL).toEqual({ document: true, variables: true }); @@ -59,7 +59,7 @@ describe('resolveDataCollectionOptions', () => { const result = resolveDataCollectionOptions({ sendDefaultPii: false }); expect(result.userInfo).toBe(false); - expect(result.httpBodies).toEqual([]); + expect(result.httpBodies).toEqual(['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse']); expect(result.genAI).toEqual({ inputs: true, outputs: true }); expect(result.graphQL).toEqual({ document: true, variables: true }); expect(result.databaseQueryData).toBe(false);