Skip to content

Commit a55e2b0

Browse files
mydealforst
andauthored
feat(node): Ensure request bodies are reliably captured for http requests (#13746)
This PR started out as trying to fix capturing request bodies for Koa. Some investigation later, we found out that the fundamental problem was that we relied on the request body being on `request.body`, which is non-standard and thus does not necessarily works. It seems that in express this works because it under the hood writes the body there, but this is non-standard and rather undefined behavior. For other frameworks (e.g. Koa and probably more) this did not work, the body was not on the request and thus never captured. We also had no test coverage for this overall. This PR ended up doing a few things: * Add tests for this for express and koa * Streamline types for `sdkProcessingMetadata` - this used to be `any`, which lead to any usage of this not really being typed at all. I added proper types for this now. * Generic extraction of the http request body in the http instrumentation - this should now work for any node framework Most importantly, I opted to not force this into the existing, rather complicated and hard to follow request data integration flow. This used to take an IsomorphicRequest and then did a bunch of conversion etc. Since now in Node, we always have the same, proper http request (for any framework, because this always goes through http instrumentation), we can actually streamline this and normalize this properly at the time where we set this. So with this PR, we set a `normalizedRequest` which already has the url, headers etc. set in a way that we need it/it makes sense. Additionally, the parsed & stringified request body will be set on this too. If this normalized request is set in sdkProcessingMetadata, we will use it as source of truth instead of the plain `request`. (Note that we still need the plain request for some auxiliary data that is non-standard, e.g. `request.user`). For the body parsing itself, we monkey-patch `req.on('data')`. this way, we ensure to not add more handlers than a user has, and we only extract the body if the user is extracting it anyhow, ensuring we do not alter behavior. Closes #13722 --------- Co-authored-by: Luca Forstner <[email protected]>
1 parent 9a84484 commit a55e2b0

File tree

16 files changed

+711
-102
lines changed

16 files changed

+711
-102
lines changed

dev-packages/e2e-tests/test-applications/node-koa/index.js

+6
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ const port1 = 3030;
1414
const port2 = 3040;
1515

1616
const Koa = require('koa');
17+
const { bodyParser } = require('@koa/bodyparser');
1718
const Router = require('@koa/router');
1819
const http = require('http');
1920

2021
const app1 = new Koa();
22+
app1.use(bodyParser());
2123

2224
Sentry.setupKoaErrorHandler(app1);
2325

@@ -109,6 +111,10 @@ router1.get('/test-assert/:condition', async ctx => {
109111
ctx.assert(condition, 400, 'ctx.assert failed');
110112
});
111113

114+
router1.post('/test-post', async ctx => {
115+
ctx.body = { status: 'ok', body: ctx.request.body };
116+
});
117+
112118
app1.use(router1.routes()).use(router1.allowedMethods());
113119

114120
app1.listen(port1);

dev-packages/e2e-tests/test-applications/node-koa/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"test:assert": "pnpm test"
1111
},
1212
"dependencies": {
13+
"@koa/bodyparser": "^5.1.1",
1314
"@koa/router": "^12.0.1",
1415
"@sentry/node": "latest || *",
1516
"@sentry/types": "latest || *",

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

+123-70
Original file line numberDiff line numberDiff line change
@@ -46,76 +46,129 @@ test('Sends an API route transaction', async ({ baseURL }) => {
4646
origin: 'auto.http.otel.http',
4747
});
4848

49-
expect(transactionEvent).toEqual(
50-
expect.objectContaining({
51-
spans: [
52-
{
53-
data: {
54-
'koa.name': '',
55-
'koa.type': 'middleware',
56-
'sentry.origin': 'auto.http.otel.koa',
57-
'sentry.op': 'middleware.koa',
58-
},
59-
op: 'middleware.koa',
60-
origin: 'auto.http.otel.koa',
61-
description: '< unknown >',
62-
parent_span_id: expect.any(String),
63-
span_id: expect.any(String),
64-
start_timestamp: expect.any(Number),
65-
status: 'ok',
66-
timestamp: expect.any(Number),
67-
trace_id: expect.any(String),
68-
},
69-
{
70-
data: {
71-
'http.route': '/test-transaction',
72-
'koa.name': '/test-transaction',
73-
'koa.type': 'router',
74-
'sentry.origin': 'auto.http.otel.koa',
75-
'sentry.op': 'router.koa',
76-
},
77-
op: 'router.koa',
78-
description: '/test-transaction',
79-
parent_span_id: expect.any(String),
80-
span_id: expect.any(String),
81-
start_timestamp: expect.any(Number),
82-
status: 'ok',
83-
timestamp: expect.any(Number),
84-
trace_id: expect.any(String),
85-
origin: 'auto.http.otel.koa',
86-
},
87-
{
88-
data: {
89-
'sentry.origin': 'manual',
90-
},
91-
description: 'test-span',
92-
parent_span_id: expect.any(String),
93-
span_id: expect.any(String),
94-
start_timestamp: expect.any(Number),
95-
status: 'ok',
96-
timestamp: expect.any(Number),
97-
trace_id: expect.any(String),
98-
origin: 'manual',
99-
},
100-
{
101-
data: {
102-
'sentry.origin': 'manual',
103-
},
104-
description: 'child-span',
105-
parent_span_id: expect.any(String),
106-
span_id: expect.any(String),
107-
start_timestamp: expect.any(Number),
108-
status: 'ok',
109-
timestamp: expect.any(Number),
110-
trace_id: expect.any(String),
111-
origin: 'manual',
112-
},
113-
],
114-
transaction: 'GET /test-transaction',
115-
type: 'transaction',
116-
transaction_info: {
117-
source: 'route',
49+
expect(transactionEvent).toMatchObject({
50+
transaction: 'GET /test-transaction',
51+
type: 'transaction',
52+
transaction_info: {
53+
source: 'route',
54+
},
55+
});
56+
57+
expect(transactionEvent.spans).toEqual([
58+
{
59+
data: {
60+
'koa.name': 'bodyParser',
61+
'koa.type': 'middleware',
62+
'sentry.op': 'middleware.koa',
63+
'sentry.origin': 'auto.http.otel.koa',
64+
},
65+
description: 'bodyParser',
66+
op: 'middleware.koa',
67+
origin: 'auto.http.otel.koa',
68+
parent_span_id: expect.any(String),
69+
span_id: expect.any(String),
70+
start_timestamp: expect.any(Number),
71+
status: 'ok',
72+
timestamp: expect.any(Number),
73+
trace_id: expect.any(String),
74+
},
75+
{
76+
data: {
77+
'koa.name': '',
78+
'koa.type': 'middleware',
79+
'sentry.origin': 'auto.http.otel.koa',
80+
'sentry.op': 'middleware.koa',
81+
},
82+
op: 'middleware.koa',
83+
origin: 'auto.http.otel.koa',
84+
description: '< unknown >',
85+
parent_span_id: expect.any(String),
86+
span_id: expect.any(String),
87+
start_timestamp: expect.any(Number),
88+
status: 'ok',
89+
timestamp: expect.any(Number),
90+
trace_id: expect.any(String),
91+
},
92+
{
93+
data: {
94+
'http.route': '/test-transaction',
95+
'koa.name': '/test-transaction',
96+
'koa.type': 'router',
97+
'sentry.origin': 'auto.http.otel.koa',
98+
'sentry.op': 'router.koa',
11899
},
100+
op: 'router.koa',
101+
description: '/test-transaction',
102+
parent_span_id: expect.any(String),
103+
span_id: expect.any(String),
104+
start_timestamp: expect.any(Number),
105+
status: 'ok',
106+
timestamp: expect.any(Number),
107+
trace_id: expect.any(String),
108+
origin: 'auto.http.otel.koa',
109+
},
110+
{
111+
data: {
112+
'sentry.origin': 'manual',
113+
},
114+
description: 'test-span',
115+
parent_span_id: expect.any(String),
116+
span_id: expect.any(String),
117+
start_timestamp: expect.any(Number),
118+
status: 'ok',
119+
timestamp: expect.any(Number),
120+
trace_id: expect.any(String),
121+
origin: 'manual',
122+
},
123+
{
124+
data: {
125+
'sentry.origin': 'manual',
126+
},
127+
description: 'child-span',
128+
parent_span_id: expect.any(String),
129+
span_id: expect.any(String),
130+
start_timestamp: expect.any(Number),
131+
status: 'ok',
132+
timestamp: expect.any(Number),
133+
trace_id: expect.any(String),
134+
origin: 'manual',
135+
},
136+
]);
137+
});
138+
139+
test('Captures request metadata', async ({ baseURL }) => {
140+
const transactionEventPromise = waitForTransaction('node-koa', transactionEvent => {
141+
return (
142+
transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'POST /test-post'
143+
);
144+
});
145+
146+
const res = await fetch(`${baseURL}/test-post`, {
147+
method: 'POST',
148+
body: JSON.stringify({ foo: 'bar', other: 1 }),
149+
headers: {
150+
'Content-Type': 'application/json',
151+
},
152+
});
153+
const resBody = await res.json();
154+
155+
expect(resBody).toEqual({ status: 'ok', body: { foo: 'bar', other: 1 } });
156+
157+
const transactionEvent = await transactionEventPromise;
158+
159+
expect(transactionEvent.request).toEqual({
160+
cookies: {},
161+
url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/),
162+
method: 'POST',
163+
headers: expect.objectContaining({
164+
'user-agent': expect.stringContaining(''),
165+
'content-type': 'application/json',
119166
}),
120-
);
167+
data: JSON.stringify({
168+
foo: 'bar',
169+
other: 1,
170+
}),
171+
});
172+
173+
expect(transactionEvent.user).toEqual(undefined);
121174
});

dev-packages/node-integration-tests/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"amqplib": "^0.10.4",
4242
"apollo-server": "^3.11.1",
4343
"axios": "^1.7.7",
44+
"body-parser": "^1.20.3",
4445
"connect": "^3.7.0",
4546
"cors": "^2.8.5",
4647
"cron": "^3.1.6",

dev-packages/node-integration-tests/suites/express/tracing/server.js

+8
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,15 @@ Sentry.init({
1313
// express must be required after Sentry is initialized
1414
const express = require('express');
1515
const cors = require('cors');
16+
const bodyParser = require('body-parser');
1617
const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests');
1718

1819
const app = express();
1920

2021
app.use(cors());
22+
app.use(bodyParser.json());
23+
app.use(bodyParser.text());
24+
app.use(bodyParser.raw());
2125

2226
app.get('/test/express', (_req, res) => {
2327
res.send({ response: 'response 1' });
@@ -35,6 +39,10 @@ app.get(['/test/arr/:id', /\/test\/arr[0-9]*\/required(path)?(\/optionalPath)?\/
3539
res.send({ response: 'response 4' });
3640
});
3741

42+
app.post('/test-post', function (req, res) {
43+
res.send({ status: 'ok', body: req.body });
44+
});
45+
3846
Sentry.setupExpressErrorHandler(app);
3947

4048
startExpressServerAndSendPortToRunner(app);

dev-packages/node-integration-tests/suites/express/tracing/test.ts

+101
Original file line numberDiff line numberDiff line change
@@ -137,5 +137,106 @@ describe('express tracing', () => {
137137
.start(done)
138138
.makeRequest('get', `/test/${segment}`);
139139
}) as any);
140+
141+
describe('request data', () => {
142+
test('correctly captures JSON request data', done => {
143+
const runner = createRunner(__dirname, 'server.js')
144+
.expect({
145+
transaction: {
146+
transaction: 'POST /test-post',
147+
request: {
148+
url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/),
149+
method: 'POST',
150+
headers: {
151+
'user-agent': expect.stringContaining(''),
152+
'content-type': 'application/json',
153+
},
154+
data: JSON.stringify({
155+
foo: 'bar',
156+
other: 1,
157+
}),
158+
},
159+
},
160+
})
161+
.start(done);
162+
163+
runner.makeRequest('post', '/test-post', { data: { foo: 'bar', other: 1 } });
164+
});
165+
166+
test('correctly captures plain text request data', done => {
167+
const runner = createRunner(__dirname, 'server.js')
168+
.expect({
169+
transaction: {
170+
transaction: 'POST /test-post',
171+
request: {
172+
url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/),
173+
method: 'POST',
174+
headers: {
175+
'user-agent': expect.stringContaining(''),
176+
'content-type': 'text/plain',
177+
},
178+
data: 'some plain text',
179+
},
180+
},
181+
})
182+
.start(done);
183+
184+
runner.makeRequest('post', '/test-post', {
185+
headers: { 'Content-Type': 'text/plain' },
186+
data: 'some plain text',
187+
});
188+
});
189+
190+
test('correctly captures text buffer request data', done => {
191+
const runner = createRunner(__dirname, 'server.js')
192+
.expect({
193+
transaction: {
194+
transaction: 'POST /test-post',
195+
request: {
196+
url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/),
197+
method: 'POST',
198+
headers: {
199+
'user-agent': expect.stringContaining(''),
200+
'content-type': 'application/octet-stream',
201+
},
202+
data: 'some plain text in buffer',
203+
},
204+
},
205+
})
206+
.start(done);
207+
208+
runner.makeRequest('post', '/test-post', {
209+
headers: { 'Content-Type': 'application/octet-stream' },
210+
data: Buffer.from('some plain text in buffer'),
211+
});
212+
});
213+
214+
test('correctly captures non-text buffer request data', done => {
215+
const runner = createRunner(__dirname, 'server.js')
216+
.expect({
217+
transaction: {
218+
transaction: 'POST /test-post',
219+
request: {
220+
url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/),
221+
method: 'POST',
222+
headers: {
223+
'user-agent': expect.stringContaining(''),
224+
'content-type': 'application/octet-stream',
225+
},
226+
// This is some non-ascii string representation
227+
data: expect.any(String),
228+
},
229+
},
230+
})
231+
.start(done);
232+
233+
const body = new Uint8Array([1, 2, 3, 4, 5]).buffer;
234+
235+
runner.makeRequest('post', '/test-post', {
236+
headers: { 'Content-Type': 'application/octet-stream' },
237+
data: body,
238+
});
239+
});
240+
});
140241
});
141242
});

0 commit comments

Comments
 (0)