Skip to content

Commit 57fddd7

Browse files
authored
feat(deno): Add maxRequestBodySize to capture HTTP bodies (#22912)
Related to this comment: #22834 (review) Closes #22858
1 parent 09d25b4 commit 57fddd7

5 files changed

Lines changed: 324 additions & 2 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// <reference lib="deno.ns" />
2+
3+
import { denoServeIntegration, init } from '@sentry/deno';
4+
import { assertEquals } from 'https://deno.land/[email protected]/assert/assert_equals.ts';
5+
import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts';
6+
7+
Deno.test('Deno.serve captures incoming request bodies by default', async () => {
8+
resetGlobals();
9+
const sink = transactionSink();
10+
11+
init({
12+
traceLifecycle: 'static',
13+
dsn: 'https://username@domain/123',
14+
tracesSampleRate: 1,
15+
beforeSendTransaction: sink.beforeSendTransaction,
16+
});
17+
18+
const requestBody = 'captured-by-default';
19+
const abortController = new AbortController();
20+
let onListen: ((_: unknown) => void) | undefined;
21+
const listening = new Promise(resolve => (onListen = resolve));
22+
const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, async request => {
23+
assertEquals(await request.text(), requestBody);
24+
return new Response('OK');
25+
});
26+
await listening;
27+
28+
const transactionPromise = withTimeout(
29+
sink.waitFor(event => event.request?.url?.endsWith('/default') === true),
30+
5_000,
31+
'transaction for /default',
32+
);
33+
const response = await fetch(`http://localhost:${server.addr.port}/default`, {
34+
method: 'POST',
35+
headers: { 'content-type': 'text/plain' },
36+
body: requestBody,
37+
});
38+
assertEquals(await response.text(), 'OK');
39+
40+
const transaction = await transactionPromise;
41+
assertEquals(transaction.request?.data, requestBody);
42+
43+
abortController.abort();
44+
await server.finished;
45+
});
46+
47+
Deno.test('Deno.serve explicit small overrides disabled incoming request body collection', async () => {
48+
resetGlobals();
49+
const sink = transactionSink();
50+
51+
init({
52+
traceLifecycle: 'static',
53+
dsn: 'https://username@domain/123',
54+
tracesSampleRate: 1,
55+
dataCollection: { httpBodies: [] },
56+
integrations: integrations => [
57+
...integrations.filter(integration => integration.name !== 'DenoServe'),
58+
denoServeIntegration({ maxRequestBodySize: 'small' }),
59+
],
60+
beforeSendTransaction: sink.beforeSendTransaction,
61+
});
62+
63+
const requestBody = 'a'.repeat(1_001);
64+
const expectedBody = `${'a'.repeat(997)}...`;
65+
const abortController = new AbortController();
66+
let onListen: ((_: unknown) => void) | undefined;
67+
const listening = new Promise(resolve => (onListen = resolve));
68+
const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, async request => {
69+
assertEquals(await request.text(), requestBody);
70+
return new Response('OK');
71+
});
72+
await listening;
73+
74+
const transactionPromise = withTimeout(
75+
sink.waitFor(event => event.request?.url?.endsWith('/explicit-small') === true),
76+
5_000,
77+
'transaction for /explicit-small',
78+
);
79+
const response = await fetch(`http://localhost:${server.addr.port}/explicit-small`, {
80+
method: 'POST',
81+
headers: { 'content-type': 'text/plain' },
82+
body: requestBody,
83+
});
84+
assertEquals(await response.text(), 'OK');
85+
86+
const transaction = await transactionPromise;
87+
assertEquals(transaction.request?.data, expectedBody);
88+
89+
abortController.abort();
90+
await server.finished;
91+
});
92+
93+
Deno.test('Deno.serve explicit none overrides enabled incoming request body collection', async () => {
94+
resetGlobals();
95+
const sink = transactionSink();
96+
97+
init({
98+
traceLifecycle: 'static',
99+
dsn: 'https://username@domain/123',
100+
tracesSampleRate: 1,
101+
dataCollection: { httpBodies: ['incomingRequest'] },
102+
integrations: integrations => [
103+
...integrations.filter(integration => integration.name !== 'DenoServe'),
104+
denoServeIntegration({ maxRequestBodySize: 'none' }),
105+
],
106+
beforeSendTransaction: sink.beforeSendTransaction,
107+
});
108+
109+
const abortController = new AbortController();
110+
let onListen: ((_: unknown) => void) | undefined;
111+
const listening = new Promise(resolve => (onListen = resolve));
112+
const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, async request => {
113+
assertEquals(await request.text(), 'do-not-capture');
114+
return new Response('OK');
115+
});
116+
await listening;
117+
118+
const transactionPromise = withTimeout(
119+
sink.waitFor(event => event.request?.url?.endsWith('/explicit-none') === true),
120+
5_000,
121+
'transaction for /explicit-none',
122+
);
123+
const response = await fetch(`http://localhost:${server.addr.port}/explicit-none`, {
124+
method: 'POST',
125+
headers: { 'content-type': 'text/plain' },
126+
body: 'do-not-capture',
127+
});
128+
assertEquals(await response.text(), 'OK');
129+
130+
const transaction = await transactionPromise;
131+
assertEquals(transaction.request?.data, undefined);
132+
133+
abortController.abort();
134+
await server.finished;
135+
});

‎packages/deno/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export { DenoClient } from './client';
107107

108108
export { getDefaultIntegrations, init } from './sdk';
109109
export { denoServeIntegration } from './integrations/deno-serve';
110+
export type { DenoServeIntegrationOptions } from './integrations/deno-serve';
110111
export { denoHttpIntegration } from './integrations/http';
111112
export type { DenoHttpIntegrationOptions } from './integrations/http';
112113
export { denoRedisIntegration } from './integrations/redis';

‎packages/deno/src/integrations/deno-serve.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
1-
import type { IntegrationFn } from '@sentry/core';
1+
import type { IntegrationFn, MaxRequestBodySize } from '@sentry/core';
22
import { debug, defineIntegration } from '@sentry/core';
33
import type { RequestHandlerWrapperOptions } from '../wrap-deno-request-handler';
44
import { wrapDenoRequestHandler } from '../wrap-deno-request-handler';
55

66
const INTEGRATION_NAME = 'DenoServe' as const;
77

8+
export type DenoServeIntegrationOptions = {
9+
/**
10+
* Controls the maximum size of incoming HTTP request bodies attached to events.
11+
* An explicit value overrides `dataCollection.httpBodies`.
12+
*
13+
* If `dataCollection.httpBodies` excludes `'incomingRequest'`, body capture defaults to `'none'`.
14+
*
15+
* @default 'medium'
16+
*/
17+
maxRequestBodySize?: MaxRequestBodySize;
18+
};
19+
820
export type ServeParams =
921
// [(Request) => Response]
1022
| [Deno.ServeHandler<Deno.NetAddr>]
@@ -59,9 +71,10 @@ const instrumentedDenoServe = (serve: typeof Deno.serve): typeof Deno.serve =>
5971
},
6072
});
6173

62-
const _denoServeIntegration = (() => {
74+
const _denoServeIntegration = ((options: DenoServeIntegrationOptions = {}) => {
6375
return {
6476
name: INTEGRATION_NAME,
77+
maxRequestBodySize: options.maxRequestBodySize,
6578
setupOnce() {
6679
const originalServe = Deno.serve;
6780
const wrappedServe = instrumentedDenoServe(originalServe);

‎packages/deno/src/wrap-deno-request-handler.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import type { Integration, MaxRequestBodySize } from '@sentry/core';
12
import {
3+
captureBodyFromWinterCGRequest,
24
captureException,
35
continueTrace,
46
getClient,
@@ -79,6 +81,15 @@ export const wrapDenoRequestHandler = <Addr extends Deno.Addr = Deno.Addr>(
7981
normalizedRequest: winterCGRequestToRequestData(request),
8082
});
8183

84+
const configuredBodySize = client.getIntegrationByName<Integration & { maxRequestBodySize?: MaxRequestBodySize }>(
85+
'DenoServe',
86+
)?.maxRequestBodySize;
87+
const effectiveBodySize =
88+
configuredBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none');
89+
if (request.method !== 'GET' && effectiveBodySize !== 'none') {
90+
await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize);
91+
}
92+
8293
return continueTrace(
8394
{
8495
sentryTrace: request.headers.get('sentry-trace') || '',

‎packages/deno/test/deno-serve.test.ts‎

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
getGlobalScope,
1111
getIsolationScope,
1212
init,
13+
denoServeIntegration,
1314
setTag,
1415
setUser,
1516
} from '../build/esm/index.js';
@@ -65,6 +66,167 @@ Deno.test('Deno.serve should create http.server spans', async () => {
6566
assertEquals(transaction?.request?.url?.includes('/test'), true);
6667
});
6768

69+
Deno.test('Deno.serve should capture incoming request bodies by default', async () => {
70+
resetGlobals();
71+
const transactionEvents: TransactionEvent[] = [];
72+
73+
init({
74+
dsn: 'https://username@domain/123',
75+
tracesSampleRate: 1,
76+
traceLifecycle: 'static',
77+
beforeSendTransaction: (event: TransactionEvent) => {
78+
transactionEvents.push(event);
79+
return null;
80+
},
81+
}) as DenoClient;
82+
83+
const abortController = new AbortController();
84+
let onListen: ((_: unknown) => void) | undefined = undefined;
85+
const p = new Promise(resolve => (onListen = resolve));
86+
const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, async request => {
87+
assertEquals(await request.json(), { username: 'test', action: 'login' });
88+
return new Response('OK');
89+
});
90+
await p;
91+
92+
const requestBody = JSON.stringify({ username: 'test', action: 'login' });
93+
const response = await fetch(`http://localhost:${server.addr.port}/test`, {
94+
method: 'POST',
95+
headers: { 'content-type': 'application/json' },
96+
body: requestBody,
97+
});
98+
assertEquals(await response.text(), 'OK');
99+
100+
abortController.abort();
101+
await server.finished;
102+
103+
assertEquals(transactionEvents.length, 1);
104+
assertEquals(transactionEvents[0]?.request?.data, requestBody);
105+
});
106+
107+
Deno.test('Deno.serve should not capture incoming request bodies when disabled', async () => {
108+
resetGlobals();
109+
const transactionEvents: TransactionEvent[] = [];
110+
111+
init({
112+
dsn: 'https://username@domain/123',
113+
tracesSampleRate: 1,
114+
traceLifecycle: 'static',
115+
dataCollection: { httpBodies: [] },
116+
beforeSendTransaction: (event: TransactionEvent) => {
117+
transactionEvents.push(event);
118+
return null;
119+
},
120+
}) as DenoClient;
121+
122+
const abortController = new AbortController();
123+
let onListen: ((_: unknown) => void) | undefined = undefined;
124+
const p = new Promise(resolve => (onListen = resolve));
125+
const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, async request => {
126+
assertEquals(await request.json(), { secret: 'do-not-capture' });
127+
return new Response('OK');
128+
});
129+
await p;
130+
131+
const response = await fetch(`http://localhost:${server.addr.port}/test`, {
132+
method: 'POST',
133+
headers: { 'content-type': 'application/json' },
134+
body: JSON.stringify({ secret: 'do-not-capture' }),
135+
});
136+
assertEquals(await response.text(), 'OK');
137+
138+
abortController.abort();
139+
await server.finished;
140+
141+
assertEquals(transactionEvents.length, 1);
142+
assertEquals(transactionEvents[0]?.request?.data, undefined);
143+
});
144+
145+
Deno.test('Deno.serve should truncate incoming request bodies using an explicit size', async () => {
146+
resetGlobals();
147+
const transactionEvents: TransactionEvent[] = [];
148+
149+
init({
150+
dsn: 'https://username@domain/123',
151+
tracesSampleRate: 1,
152+
traceLifecycle: 'static',
153+
dataCollection: { httpBodies: [] },
154+
integrations: integrations => [
155+
...integrations.filter(integration => integration.name !== 'DenoServe'),
156+
denoServeIntegration({ maxRequestBodySize: 'small' }),
157+
],
158+
beforeSendTransaction: (event: TransactionEvent) => {
159+
transactionEvents.push(event);
160+
return null;
161+
},
162+
}) as DenoClient;
163+
164+
const abortController = new AbortController();
165+
let onListen: ((_: unknown) => void) | undefined = undefined;
166+
const p = new Promise(resolve => (onListen = resolve));
167+
const requestBody = 'a'.repeat(1_001);
168+
const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, async request => {
169+
assertEquals(await request.text(), requestBody);
170+
return new Response('OK');
171+
});
172+
await p;
173+
174+
const response = await fetch(`http://localhost:${server.addr.port}/test`, {
175+
method: 'POST',
176+
headers: { 'content-type': 'text/plain' },
177+
body: requestBody,
178+
});
179+
assertEquals(await response.text(), 'OK');
180+
181+
abortController.abort();
182+
await server.finished;
183+
184+
assertEquals(transactionEvents.length, 1);
185+
assertEquals(transactionEvents[0]?.request?.data, `${'a'.repeat(997)}...`);
186+
});
187+
188+
Deno.test('Deno.serve should honor explicit none when incoming request bodies are enabled', async () => {
189+
resetGlobals();
190+
const transactionEvents: TransactionEvent[] = [];
191+
192+
init({
193+
dsn: 'https://username@domain/123',
194+
tracesSampleRate: 1,
195+
traceLifecycle: 'static',
196+
dataCollection: { httpBodies: ['incomingRequest'] },
197+
integrations: integrations => [
198+
...integrations.filter(integration => integration.name !== 'DenoServe'),
199+
denoServeIntegration({ maxRequestBodySize: 'none' }),
200+
],
201+
beforeSendTransaction: (event: TransactionEvent) => {
202+
transactionEvents.push(event);
203+
return null;
204+
},
205+
}) as DenoClient;
206+
207+
const abortController = new AbortController();
208+
let onListen: ((_: unknown) => void) | undefined = undefined;
209+
const p = new Promise(resolve => (onListen = resolve));
210+
const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, async request => {
211+
assertEquals(await request.text(), 'do-not-capture');
212+
return new Response('OK');
213+
});
214+
await p;
215+
216+
const response = await fetch(`http://localhost:${server.addr.port}/test`, {
217+
method: 'POST',
218+
headers: { 'content-type': 'text/plain' },
219+
body: 'do-not-capture',
220+
});
221+
assertEquals(await response.text(), 'OK');
222+
223+
abortController.abort();
224+
await server.finished;
225+
226+
assertEquals(transactionEvents.length, 1);
227+
assertEquals(transactionEvents[0]?.request?.data, undefined);
228+
});
229+
68230
Deno.test('Deno.serve should isolate context between concurrent requests', async () => {
69231
resetGlobals();
70232
const transactionEvents: TransactionEvent[] = [];

0 commit comments

Comments
 (0)