@@ -13,9 +13,10 @@ import {
1313 setCapturedScopesOnSpan ,
1414} from '@sentry/core' ;
1515import { getClient } from '@sentry/opentelemetry' ;
16- import type { IntegrationFn , SanitizedRequestData } from '@sentry/types' ;
16+ import type { IntegrationFn , PolymorphicRequest , Request , SanitizedRequestData } from '@sentry/types' ;
1717
18- import { getSanitizedUrlString , parseUrl , stripUrlQueryAndFragment } from '@sentry/utils' ;
18+ import { getSanitizedUrlString , logger , parseUrl , stripUrlQueryAndFragment } from '@sentry/utils' ;
19+ import { DEBUG_BUILD } from '../debug-build' ;
1920import type { NodeClient } from '../sdk/client' ;
2021import { setIsolationScope } from '../sdk/scope' ;
2122import type { HTTPModuleRequestIncomingMessage } from '../transports/http-module' ;
@@ -148,7 +149,27 @@ export const instrumentHttp = Object.assign(
148149 const isolationScope = ( scopes . isolationScope || getIsolationScope ( ) ) . clone ( ) ;
149150 const scope = scopes . scope || getCurrentScope ( ) ;
150151
152+ const headers = req . headers ;
153+ const host = headers . host || '<no host>' ;
154+ const protocol = req . socket && ( req . socket as { encrypted ?: boolean } ) . encrypted ? 'https' : 'http' ;
155+ const originalUrl = req . url || '' ;
156+ const absoluteUrl = originalUrl . startsWith ( protocol ) ? originalUrl : `${ protocol } ://${ host } ${ originalUrl } ` ;
157+
158+ // This is non-standard, but may be set on e.g. Next.js or Express requests
159+ const cookies = ( req as PolymorphicRequest ) . cookies ;
160+
161+ const normalizedRequest : Request = {
162+ url : absoluteUrl ,
163+ method : req . method ,
164+ query_string : extractQueryParams ( req ) ,
165+ headers : headersToDict ( req . headers ) ,
166+ cookies,
167+ } ;
168+
169+ patchRequestToCaptureBody ( req , normalizedRequest ) ;
170+
151171 // Update the isolation scope, isolate this request
172+ isolationScope . setSDKProcessingMetadata ( { normalizedRequest } ) ;
152173 isolationScope . setSDKProcessingMetadata ( { request : req } ) ;
153174
154175 const client = getClient < NodeClient > ( ) ;
@@ -301,3 +322,128 @@ function isKnownPrefetchRequest(req: HTTPModuleRequestIncomingMessage): boolean
301322 // Currently only handles Next.js prefetch requests but may check other frameworks in the future.
302323 return req . headers [ 'next-router-prefetch' ] === '1' ;
303324}
325+
326+ /**
327+ * This method patches the request object to capture the body.
328+ * Instead of actually consuming the streamed body ourselves, which has potential side effects,
329+ * we monkey patch `req.on('data')` to intercept the body chunks.
330+ * This way, we only read the body if the user also consumes the body, ensuring we do not change any behavior in unexpected ways.
331+ */
332+ function patchRequestToCaptureBody ( req : HTTPModuleRequestIncomingMessage , normalizedRequest : Request ) : void {
333+ const chunks : Buffer [ ] = [ ] ;
334+
335+ /**
336+ * We need to keep track of the original callbacks, in order to be able to remove listeners again.
337+ * Since `off` depends on having the exact same function reference passed in, we need to be able to map
338+ * original listeners to our wrapped ones.
339+ */
340+ const callbackMap = new WeakMap ( ) ;
341+
342+ try {
343+ // eslint-disable-next-line @typescript-eslint/unbound-method
344+ req . on = new Proxy ( req . on , {
345+ apply : ( target , thisArg , args : Parameters < typeof req . on > ) => {
346+ const [ event , listener ] = args ;
347+
348+ if ( event === 'data' ) {
349+ const callback = new Proxy ( listener , {
350+ apply : ( target , thisArg , args : Parameters < typeof listener > ) => {
351+ const chunk = args [ 0 ] ;
352+ try {
353+ chunks . push ( chunk ) ;
354+ } catch {
355+ // ignore errors here...
356+ }
357+ return Reflect . apply ( target , thisArg , args ) ;
358+ } ,
359+ } ) ;
360+
361+ callbackMap . set ( listener , callback ) ;
362+
363+ return Reflect . apply ( target , thisArg , [ event , callback ] ) ;
364+ }
365+
366+ if ( event === 'end' ) {
367+ const callback = new Proxy ( listener , {
368+ apply : ( target , thisArg , args ) => {
369+ const body = Buffer . concat ( chunks ) . toString ( 'utf-8' ) ;
370+
371+ // We mutate the passed in normalizedRequest and add the body to it
372+ if ( body ) {
373+ normalizedRequest . data = body ;
374+ }
375+
376+ return Reflect . apply ( target , thisArg , args ) ;
377+ } ,
378+ } ) ;
379+
380+ callbackMap . set ( listener , callback ) ;
381+
382+ return Reflect . apply ( target , thisArg , [ event , callback ] ) ;
383+ }
384+
385+ return Reflect . apply ( target , thisArg , args ) ;
386+ } ,
387+ } ) ;
388+
389+ // Ensure we also remove callbacks correctly
390+ // eslint-disable-next-line @typescript-eslint/unbound-method
391+ req . off = new Proxy ( req . off , {
392+ apply : ( target , thisArg , args : Parameters < typeof req . off > ) => {
393+ const [ , listener ] = args ;
394+
395+ const callback = callbackMap . get ( listener ) ;
396+ if ( callback ) {
397+ callbackMap . delete ( listener ) ;
398+
399+ const modifiedArgs = args . slice ( ) ;
400+ modifiedArgs [ 1 ] = callback ;
401+ return Reflect . apply ( target , thisArg , modifiedArgs ) ;
402+ }
403+
404+ return Reflect . apply ( target , thisArg , args ) ;
405+ } ,
406+ } ) ;
407+ } catch {
408+ // ignore errors if we can't patch stuff
409+ }
410+ }
411+
412+ function extractQueryParams ( req : IncomingMessage ) : string | undefined {
413+ // url (including path and query string):
414+ let originalUrl = req . url || '' ;
415+
416+ if ( ! originalUrl ) {
417+ return ;
418+ }
419+
420+ // The `URL` constructor can't handle internal URLs of the form `/some/path/here`, so stick a dummy protocol and
421+ // hostname on the beginning. Since the point here is just to grab the query string, it doesn't matter what we use.
422+ if ( originalUrl . startsWith ( '/' ) ) {
423+ originalUrl = `http://dogs.are.great${ originalUrl } ` ;
424+ }
425+
426+ try {
427+ const queryParams = new URL ( originalUrl ) . search . slice ( 1 ) ;
428+ return queryParams . length ? queryParams : undefined ;
429+ } catch {
430+ return undefined ;
431+ }
432+ }
433+
434+ function headersToDict ( reqHeaders : Record < string , string | string [ ] | undefined > ) : Record < string , string > {
435+ const headers : Record < string , string > = { } ;
436+
437+ try {
438+ Object . entries ( reqHeaders ) . forEach ( ( [ key , value ] ) => {
439+ if ( typeof value === 'string' ) {
440+ headers [ key ] = value ;
441+ }
442+ } ) ;
443+ } catch ( e ) {
444+ DEBUG_BUILD &&
445+ logger . warn ( 'Sentry failed extracting headers from a request object. If you see this, please file an issue.' ) ;
446+ }
447+
448+ return headers ;
449+ }
0 commit comments