Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// <reference lib="deno.ns" />

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;
});
1 change: 1 addition & 0 deletions packages/deno/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
17 changes: 15 additions & 2 deletions packages/deno/src/integrations/deno-serve.ts
Original file line number Diff line number Diff line change
@@ -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<Deno.NetAddr>]
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions packages/deno/src/wrap-deno-request-handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { Integration, MaxRequestBodySize } from '@sentry/core';
import {
captureBodyFromWinterCGRequest,
captureException,
continueTrace,
getClient,
Expand Down Expand Up @@ -79,6 +81,15 @@ export const wrapDenoRequestHandler = <Addr extends Deno.Addr = Deno.Addr>(
normalizedRequest: winterCGRequestToRequestData(request),
});

const configuredBodySize = client.getIntegrationByName<Integration & { maxRequestBodySize?: MaxRequestBodySize }>(
'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') || '',
Expand Down
162 changes: 162 additions & 0 deletions packages/deno/test/deno-serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getGlobalScope,
getIsolationScope,
init,
denoServeIntegration,
setTag,
setUser,
} from '../build/esm/index.js';
Expand Down Expand Up @@ -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[] = [];
Expand Down
Loading