From 5944d38bfc47151b03461de103a98e45dd7890b3 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:52:59 +0200 Subject: [PATCH] feat(deno): Add `maxRequestBodySize` to capture HTTP bodies --- .../suites/deno-serve-request-bodies/test.ts | 135 +++++++++++++++ packages/deno/src/index.ts | 1 + packages/deno/src/integrations/deno-serve.ts | 17 +- .../deno/src/wrap-deno-request-handler.ts | 11 ++ packages/deno/test/deno-serve.test.ts | 162 ++++++++++++++++++ 5 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 dev-packages/deno-integration-tests/suites/deno-serve-request-bodies/test.ts diff --git a/dev-packages/deno-integration-tests/suites/deno-serve-request-bodies/test.ts b/dev-packages/deno-integration-tests/suites/deno-serve-request-bodies/test.ts new file mode 100644 index 000000000000..49e732281bde --- /dev/null +++ b/dev-packages/deno-integration-tests/suites/deno-serve-request-bodies/test.ts @@ -0,0 +1,135 @@ +// + +import { denoServeIntegration, init } from '@sentry/deno'; +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; +import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; + +Deno.test('Deno.serve captures incoming request bodies by default', async () => { + resetGlobals(); + const sink = transactionSink(); + + init({ + traceLifecycle: 'static', + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + beforeSendTransaction: sink.beforeSendTransaction, + }); + + const requestBody = 'captured-by-default'; + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined; + const listening = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, async request => { + assertEquals(await request.text(), requestBody); + return new Response('OK'); + }); + await listening; + + const transactionPromise = withTimeout( + sink.waitFor(event => event.request?.url?.endsWith('/default') === true), + 5_000, + 'transaction for /default', + ); + const response = await fetch(`http://localhost:${server.addr.port}/default`, { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: requestBody, + }); + assertEquals(await response.text(), 'OK'); + + const transaction = await transactionPromise; + assertEquals(transaction.request?.data, requestBody); + + abortController.abort(); + await server.finished; +}); + +Deno.test('Deno.serve explicit small overrides disabled incoming request body collection', async () => { + resetGlobals(); + const sink = transactionSink(); + + init({ + traceLifecycle: 'static', + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + dataCollection: { httpBodies: [] }, + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ maxRequestBodySize: 'small' }), + ], + beforeSendTransaction: sink.beforeSendTransaction, + }); + + const requestBody = 'a'.repeat(1_001); + const expectedBody = `${'a'.repeat(997)}...`; + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined; + const listening = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, async request => { + assertEquals(await request.text(), requestBody); + return new Response('OK'); + }); + await listening; + + const transactionPromise = withTimeout( + sink.waitFor(event => event.request?.url?.endsWith('/explicit-small') === true), + 5_000, + 'transaction for /explicit-small', + ); + const response = await fetch(`http://localhost:${server.addr.port}/explicit-small`, { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: requestBody, + }); + assertEquals(await response.text(), 'OK'); + + const transaction = await transactionPromise; + assertEquals(transaction.request?.data, expectedBody); + + abortController.abort(); + await server.finished; +}); + +Deno.test('Deno.serve explicit none overrides enabled incoming request body collection', async () => { + resetGlobals(); + const sink = transactionSink(); + + init({ + traceLifecycle: 'static', + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + dataCollection: { httpBodies: ['incomingRequest'] }, + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ maxRequestBodySize: 'none' }), + ], + beforeSendTransaction: sink.beforeSendTransaction, + }); + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined; + const listening = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, async request => { + assertEquals(await request.text(), 'do-not-capture'); + return new Response('OK'); + }); + await listening; + + const transactionPromise = withTimeout( + sink.waitFor(event => event.request?.url?.endsWith('/explicit-none') === true), + 5_000, + 'transaction for /explicit-none', + ); + const response = await fetch(`http://localhost:${server.addr.port}/explicit-none`, { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: 'do-not-capture', + }); + assertEquals(await response.text(), 'OK'); + + const transaction = await transactionPromise; + assertEquals(transaction.request?.data, undefined); + + abortController.abort(); + await server.finished; +}); diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 52a04b18aeb7..65a708042822 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -107,6 +107,7 @@ export { DenoClient } from './client'; export { getDefaultIntegrations, init } from './sdk'; export { denoServeIntegration } from './integrations/deno-serve'; +export type { DenoServeIntegrationOptions } from './integrations/deno-serve'; export { denoHttpIntegration } from './integrations/http'; export type { DenoHttpIntegrationOptions } from './integrations/http'; export { denoRedisIntegration } from './integrations/redis'; diff --git a/packages/deno/src/integrations/deno-serve.ts b/packages/deno/src/integrations/deno-serve.ts index 05cf1b4cdd62..e51009c25249 100644 --- a/packages/deno/src/integrations/deno-serve.ts +++ b/packages/deno/src/integrations/deno-serve.ts @@ -1,10 +1,22 @@ -import type { IntegrationFn } from '@sentry/core'; +import type { IntegrationFn, MaxRequestBodySize } from '@sentry/core'; import { debug, defineIntegration } from '@sentry/core'; import type { RequestHandlerWrapperOptions } from '../wrap-deno-request-handler'; import { wrapDenoRequestHandler } from '../wrap-deno-request-handler'; const INTEGRATION_NAME = 'DenoServe' as const; +export type DenoServeIntegrationOptions = { + /** + * 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; +}; + export type ServeParams = // [(Request) => Response] | [Deno.ServeHandler] @@ -59,9 +71,10 @@ const instrumentedDenoServe = (serve: typeof Deno.serve): typeof Deno.serve => }, }); -const _denoServeIntegration = (() => { +const _denoServeIntegration = ((options: DenoServeIntegrationOptions = {}) => { return { name: INTEGRATION_NAME, + maxRequestBodySize: options.maxRequestBodySize, setupOnce() { const originalServe = Deno.serve; const wrappedServe = instrumentedDenoServe(originalServe); diff --git a/packages/deno/src/wrap-deno-request-handler.ts b/packages/deno/src/wrap-deno-request-handler.ts index f6b9ccb7de78..b96b4b59dec6 100644 --- a/packages/deno/src/wrap-deno-request-handler.ts +++ b/packages/deno/src/wrap-deno-request-handler.ts @@ -1,4 +1,6 @@ +import type { Integration, MaxRequestBodySize } from '@sentry/core'; import { + captureBodyFromWinterCGRequest, captureException, continueTrace, getClient, @@ -79,6 +81,15 @@ export const wrapDenoRequestHandler = ( normalizedRequest: winterCGRequestToRequestData(request), }); + const configuredBodySize = client.getIntegrationByName( + 'DenoServe', + )?.maxRequestBodySize; + const effectiveBodySize = + configuredBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none'); + if (request.method !== 'GET' && effectiveBodySize !== 'none') { + await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize); + } + return continueTrace( { sentryTrace: request.headers.get('sentry-trace') || '', diff --git a/packages/deno/test/deno-serve.test.ts b/packages/deno/test/deno-serve.test.ts index 5622f3b34dbf..7038d7eb48a9 100644 --- a/packages/deno/test/deno-serve.test.ts +++ b/packages/deno/test/deno-serve.test.ts @@ -10,6 +10,7 @@ import { getGlobalScope, getIsolationScope, init, + denoServeIntegration, setTag, setUser, } from '../build/esm/index.js'; @@ -65,6 +66,167 @@ Deno.test('Deno.serve should create http.server spans', async () => { assertEquals(transaction?.request?.url?.includes('/test'), true); }); +Deno.test('Deno.serve should capture incoming request bodies by default', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined = undefined; + const p = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, async request => { + assertEquals(await request.json(), { username: 'test', action: 'login' }); + return new Response('OK'); + }); + await p; + + const requestBody = JSON.stringify({ username: 'test', action: 'login' }); + const response = await fetch(`http://localhost:${server.addr.port}/test`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: requestBody, + }); + assertEquals(await response.text(), 'OK'); + + abortController.abort(); + await server.finished; + + assertEquals(transactionEvents.length, 1); + assertEquals(transactionEvents[0]?.request?.data, requestBody); +}); + +Deno.test('Deno.serve should not capture incoming request bodies when disabled', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + dataCollection: { httpBodies: [] }, + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined = undefined; + const p = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, async request => { + assertEquals(await request.json(), { secret: 'do-not-capture' }); + return new Response('OK'); + }); + await p; + + const response = await fetch(`http://localhost:${server.addr.port}/test`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ secret: 'do-not-capture' }), + }); + assertEquals(await response.text(), 'OK'); + + abortController.abort(); + await server.finished; + + assertEquals(transactionEvents.length, 1); + assertEquals(transactionEvents[0]?.request?.data, undefined); +}); + +Deno.test('Deno.serve should truncate incoming request bodies using an explicit size', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + dataCollection: { httpBodies: [] }, + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ maxRequestBodySize: 'small' }), + ], + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined = undefined; + const p = new Promise(resolve => (onListen = resolve)); + const requestBody = 'a'.repeat(1_001); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, async request => { + assertEquals(await request.text(), requestBody); + return new Response('OK'); + }); + await p; + + const response = await fetch(`http://localhost:${server.addr.port}/test`, { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: requestBody, + }); + assertEquals(await response.text(), 'OK'); + + abortController.abort(); + await server.finished; + + assertEquals(transactionEvents.length, 1); + assertEquals(transactionEvents[0]?.request?.data, `${'a'.repeat(997)}...`); +}); + +Deno.test('Deno.serve should honor explicit none when incoming request bodies are enabled', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + dataCollection: { httpBodies: ['incomingRequest'] }, + integrations: integrations => [ + ...integrations.filter(integration => integration.name !== 'DenoServe'), + denoServeIntegration({ maxRequestBodySize: 'none' }), + ], + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined = undefined; + const p = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, async request => { + assertEquals(await request.text(), 'do-not-capture'); + return new Response('OK'); + }); + await p; + + const response = await fetch(`http://localhost:${server.addr.port}/test`, { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: 'do-not-capture', + }); + assertEquals(await response.text(), 'OK'); + + abortController.abort(); + await server.finished; + + assertEquals(transactionEvents.length, 1); + assertEquals(transactionEvents[0]?.request?.data, undefined); +}); + Deno.test('Deno.serve should isolate context between concurrent requests', async () => { resetGlobals(); const transactionEvents: TransactionEvent[] = [];