Skip to content

Commit 88ba26b

Browse files
lforstmydea
andauthored
fix(node): Make body capturing more robust (#16105)
I have a hunch that not all frameworks call `req.on('end')` but may only do `req.on('close')` or whatever else and this is why we are not reliably capturing bodies. It should be safe to attach a `req.on('end')` handler, as that doesn't change any semantics AFAIK. Fixes #16090 --------- Co-authored-by: Francesco Gringl-Novy <[email protected]>
1 parent 447e62e commit 88ba26b

File tree

3 files changed

+69
-38
lines changed

3 files changed

+69
-38
lines changed

dev-packages/e2e-tests/test-applications/node-fastify/src/app.ts

+4
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ app.get('/test-outgoing-http-external-disallowed', async function (req, res) {
119119
res.send(data);
120120
});
121121

122+
app.post('/test-post', function (req, res) {
123+
res.send({ status: 'ok', body: req.body });
124+
});
125+
122126
app.listen({ port: port });
123127

124128
// A second app so we can test header propagation between external URLs

dev-packages/e2e-tests/test-applications/node-fastify/tests/transactions.test.ts

+37
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,40 @@ test('Sends an API route transaction', async ({ baseURL }) => {
124124
origin: 'manual',
125125
});
126126
});
127+
128+
test('Captures request metadata', async ({ baseURL }) => {
129+
const transactionEventPromise = waitForTransaction('node-fastify', transactionEvent => {
130+
return (
131+
transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'POST /test-post'
132+
);
133+
});
134+
135+
const res = await fetch(`${baseURL}/test-post`, {
136+
method: 'POST',
137+
body: JSON.stringify({ foo: 'bar', other: 1 }),
138+
headers: {
139+
'Content-Type': 'application/json',
140+
},
141+
});
142+
const resBody = await res.json();
143+
144+
expect(resBody).toEqual({ status: 'ok', body: { foo: 'bar', other: 1 } });
145+
146+
const transactionEvent = await transactionEventPromise;
147+
148+
expect(transactionEvent.request).toEqual({
149+
cookies: {},
150+
url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/),
151+
method: 'POST',
152+
headers: expect.objectContaining({
153+
'user-agent': expect.stringContaining(''),
154+
'content-type': 'application/json',
155+
}),
156+
data: JSON.stringify({
157+
foo: 'bar',
158+
other: 1,
159+
}),
160+
});
161+
162+
expect(transactionEvent.user).toEqual(undefined);
163+
});

packages/node/src/integrations/http/SentryHttpInstrumentation.ts

+28-38
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { context, propagation } from '@opentelemetry/api';
33
import { VERSION } from '@opentelemetry/core';
44
import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
55
import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
6-
import type { AggregationCounts, Client, RequestEventData, SanitizedRequestData, Scope } from '@sentry/core';
6+
import type { AggregationCounts, Client, SanitizedRequestData, Scope } from '@sentry/core';
77
import {
88
addBreadcrumb,
99
generateSpanId,
@@ -360,12 +360,9 @@ function getBreadcrumbData(request: http.ClientRequest): Partial<SanitizedReques
360360
* This way, we only read the body if the user also consumes the body, ensuring we do not change any behavior in unexpected ways.
361361
*/
362362
function patchRequestToCaptureBody(req: IncomingMessage, isolationScope: Scope): void {
363+
let bodyByteLength = 0;
363364
const chunks: Buffer[] = [];
364365

365-
function getChunksSize(): number {
366-
return chunks.reduce((acc, chunk) => acc + chunk.byteLength, 0);
367-
}
368-
369366
/**
370367
* We need to keep track of the original callbacks, in order to be able to remove listeners again.
371368
* Since `off` depends on having the exact same function reference passed in, we need to be able to map
@@ -386,41 +383,21 @@ function patchRequestToCaptureBody(req: IncomingMessage, isolationScope: Scope):
386383
if (event === 'data') {
387384
const callback = new Proxy(listener, {
388385
apply: (target, thisArg, args: Parameters<typeof listener>) => {
389-
// If we have already read more than the max body length, we stop adding chunks
390-
// To avoid growing the memory indefinitely if a response is e.g. streamed
391-
if (getChunksSize() < MAX_BODY_BYTE_LENGTH) {
392-
const chunk = args[0] as Buffer;
393-
chunks.push(chunk);
394-
} else if (DEBUG_BUILD) {
395-
logger.log(
396-
INSTRUMENTATION_NAME,
397-
`Dropping request body chunk because maximum body length of ${MAX_BODY_BYTE_LENGTH}b is exceeded.`,
398-
);
399-
}
400-
401-
return Reflect.apply(target, thisArg, args);
402-
},
403-
});
404-
405-
callbackMap.set(listener, callback);
406-
407-
return Reflect.apply(target, thisArg, [event, callback, ...restArgs]);
408-
}
409-
410-
if (event === 'end') {
411-
const callback = new Proxy(listener, {
412-
apply: (target, thisArg, args) => {
413386
try {
414-
const body = Buffer.concat(chunks).toString('utf-8');
415-
416-
if (body) {
417-
const normalizedRequest = { data: body } satisfies RequestEventData;
418-
isolationScope.setSDKProcessingMetadata({ normalizedRequest });
419-
}
420-
} catch (error) {
421-
if (DEBUG_BUILD) {
422-
logger.error(INSTRUMENTATION_NAME, 'Error building captured request body', error);
387+
const chunk = args[0] as Buffer | string;
388+
const bufferifiedChunk = Buffer.from(chunk);
389+
390+
if (bodyByteLength < MAX_BODY_BYTE_LENGTH) {
391+
chunks.push(bufferifiedChunk);
392+
bodyByteLength += bufferifiedChunk.byteLength;
393+
} else if (DEBUG_BUILD) {
394+
logger.log(
395+
INSTRUMENTATION_NAME,
396+
`Dropping request body chunk because maximum body length of ${MAX_BODY_BYTE_LENGTH}b is exceeded.`,
397+
);
423398
}
399+
} catch (err) {
400+
DEBUG_BUILD && logger.error(INSTRUMENTATION_NAME, 'Encountered error while storing body chunk.');
424401
}
425402

426403
return Reflect.apply(target, thisArg, args);
@@ -454,6 +431,19 @@ function patchRequestToCaptureBody(req: IncomingMessage, isolationScope: Scope):
454431
return Reflect.apply(target, thisArg, args);
455432
},
456433
});
434+
435+
req.on('end', () => {
436+
try {
437+
const body = Buffer.concat(chunks).toString('utf-8');
438+
if (body) {
439+
isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: body } });
440+
}
441+
} catch (error) {
442+
if (DEBUG_BUILD) {
443+
logger.error(INSTRUMENTATION_NAME, 'Error building captured request body', error);
444+
}
445+
}
446+
});
457447
} catch (error) {
458448
if (DEBUG_BUILD) {
459449
logger.error(INSTRUMENTATION_NAME, 'Error patching request to capture body', error);

0 commit comments

Comments
 (0)