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[] = [];