Skip to content

Commit f3a484d

Browse files
committed
fix after rebase
1 parent d1fd378 commit f3a484d

File tree

2 files changed

+155
-3
lines changed

2 files changed

+155
-3
lines changed

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

+151-2
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
import type * as http from 'node:http';
2-
import type { RequestOptions } from 'node:http';
2+
import type { IncomingMessage, RequestOptions } from 'node:http';
33
import type * as https from 'node:https';
44
import { VERSION } from '@opentelemetry/core';
55
import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
66
import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
77
import { getRequestInfo } from '@opentelemetry/instrumentation-http';
88
import { addBreadcrumb, getClient, getIsolationScope, withIsolationScope } from '@sentry/core';
9-
import type { SanitizedRequestData } from '@sentry/types';
9+
import type { PolymorphicRequest, Request, SanitizedRequestData } from '@sentry/types';
1010
import {
1111
getBreadcrumbLogLevelFromHttpStatusCode,
1212
getSanitizedUrlString,
13+
logger,
1314
parseUrl,
1415
stripUrlQueryAndFragment,
1516
} from '@sentry/utils';
17+
import { DEBUG_BUILD } from '../../debug-build';
1618
import type { NodeClient } from '../../sdk/client';
1719
import { getRequestUrl } from '../../utils/getRequestUrl';
1820

@@ -128,6 +130,28 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
128130

129131
const isolationScope = getIsolationScope().clone();
130132

133+
const headers = request.headers;
134+
const host = headers.host || '<no host>';
135+
const protocol = request.socket && (request.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http';
136+
const originalUrl = request.url || '';
137+
const absoluteUrl = originalUrl.startsWith(protocol) ? originalUrl : `${protocol}://${host}${originalUrl}`;
138+
139+
// This is non-standard, but may be set on e.g. Next.js or Express requests
140+
const cookies = (request as PolymorphicRequest).cookies;
141+
142+
const normalizedRequest: Request = {
143+
url: absoluteUrl,
144+
method: request.method,
145+
query_string: extractQueryParams(request),
146+
headers: headersToDict(request.headers),
147+
cookies,
148+
};
149+
150+
patchRequestToCaptureBody(request, normalizedRequest);
151+
152+
// Update the isolation scope, isolate this request
153+
isolationScope.setSDKProcessingMetadata({ request, normalizedRequest });
154+
131155
// Update the isolation scope, isolate this request
132156
isolationScope.setSDKProcessingMetadata({ request });
133157

@@ -316,3 +340,128 @@ function getBreadcrumbData(request: http.ClientRequest): Partial<SanitizedReques
316340
return {};
317341
}
318342
}
343+
344+
/**
345+
* This method patches the request object to capture the body.
346+
* Instead of actually consuming the streamed body ourselves, which has potential side effects,
347+
* we monkey patch `req.on('data')` to intercept the body chunks.
348+
* This way, we only read the body if the user also consumes the body, ensuring we do not change any behavior in unexpected ways.
349+
*/
350+
function patchRequestToCaptureBody(req: IncomingMessage, normalizedRequest: Request): void {
351+
const chunks: Buffer[] = [];
352+
353+
/**
354+
* We need to keep track of the original callbacks, in order to be able to remove listeners again.
355+
* Since `off` depends on having the exact same function reference passed in, we need to be able to map
356+
* original listeners to our wrapped ones.
357+
*/
358+
const callbackMap = new WeakMap();
359+
360+
try {
361+
// eslint-disable-next-line @typescript-eslint/unbound-method
362+
req.on = new Proxy(req.on, {
363+
apply: (target, thisArg, args: Parameters<typeof req.on>) => {
364+
const [event, listener, ...restArgs] = args;
365+
366+
if (event === 'data') {
367+
const callback = new Proxy(listener, {
368+
apply: (target, thisArg, args: Parameters<typeof listener>) => {
369+
const chunk = args[0];
370+
chunks.push(chunk);
371+
return Reflect.apply(target, thisArg, args);
372+
},
373+
});
374+
375+
callbackMap.set(listener, callback);
376+
377+
return Reflect.apply(target, thisArg, [event, callback, ...restArgs]);
378+
}
379+
380+
if (event === 'end') {
381+
const callback = new Proxy(listener, {
382+
apply: (target, thisArg, args) => {
383+
try {
384+
const body = Buffer.concat(chunks).toString('utf-8');
385+
386+
// We mutate the passed in normalizedRequest and add the body to it
387+
if (body) {
388+
normalizedRequest.data = body;
389+
}
390+
} catch {
391+
// ignore errors here
392+
}
393+
394+
return Reflect.apply(target, thisArg, args);
395+
},
396+
});
397+
398+
callbackMap.set(listener, callback);
399+
400+
return Reflect.apply(target, thisArg, [event, callback, ...restArgs]);
401+
}
402+
403+
return Reflect.apply(target, thisArg, args);
404+
},
405+
});
406+
407+
// Ensure we also remove callbacks correctly
408+
// eslint-disable-next-line @typescript-eslint/unbound-method
409+
req.off = new Proxy(req.off, {
410+
apply: (target, thisArg, args: Parameters<typeof req.off>) => {
411+
const [, listener] = args;
412+
413+
const callback = callbackMap.get(listener);
414+
if (callback) {
415+
callbackMap.delete(listener);
416+
417+
const modifiedArgs = args.slice();
418+
modifiedArgs[1] = callback;
419+
return Reflect.apply(target, thisArg, modifiedArgs);
420+
}
421+
422+
return Reflect.apply(target, thisArg, args);
423+
},
424+
});
425+
} catch {
426+
// ignore errors if we can't patch stuff
427+
}
428+
}
429+
430+
function extractQueryParams(req: IncomingMessage): string | undefined {
431+
// url (including path and query string):
432+
let originalUrl = req.url || '';
433+
434+
if (!originalUrl) {
435+
return;
436+
}
437+
438+
// The `URL` constructor can't handle internal URLs of the form `/some/path/here`, so stick a dummy protocol and
439+
// hostname on the beginning. Since the point here is just to grab the query string, it doesn't matter what we use.
440+
if (originalUrl.startsWith('/')) {
441+
originalUrl = `http://dogs.are.great${originalUrl}`;
442+
}
443+
444+
try {
445+
const queryParams = new URL(originalUrl).search.slice(1);
446+
return queryParams.length ? queryParams : undefined;
447+
} catch {
448+
return undefined;
449+
}
450+
}
451+
452+
function headersToDict(reqHeaders: Record<string, string | string[] | undefined>): Record<string, string> {
453+
const headers: Record<string, string> = {};
454+
455+
try {
456+
Object.entries(reqHeaders).forEach(([key, value]) => {
457+
if (typeof value === 'string') {
458+
headers[key] = value;
459+
}
460+
});
461+
} catch (e) {
462+
DEBUG_BUILD &&
463+
logger.warn('Sentry failed extracting headers from a request object. If you see this, please file an issue.');
464+
}
465+
466+
return headers;
467+
}

packages/types/src/request.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
/** Request data included in an event as sent to Sentry */
1+
/**
2+
* Request data included in an event as sent to Sentry.
3+
* TODO(v9): Rename this to avoid confusion, because Request is also a native type.
4+
*/
25
export interface Request {
36
url?: string;
47
method?: string;

0 commit comments

Comments
 (0)