Skip to content

Commit 1eef53a

Browse files
committed
add normalized request data in http
1 parent 00d5a6b commit 1eef53a

File tree

7 files changed

+400
-25
lines changed

7 files changed

+400
-25
lines changed

packages/core/src/integrations/requestdata.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { IntegrationFn } from '@sentry/types';
22
import type { AddRequestDataToEventOptions, TransactionNamingScheme } from '@sentry/utils';
3+
import { addNormalizedRequestDataToEvent } from '@sentry/utils';
34
import { addRequestDataToEvent } from '@sentry/utils';
45
import { defineIntegration } from '../integration';
56

@@ -73,15 +74,24 @@ const _requestDataIntegration = ((options: RequestDataIntegrationOptions = {}) =
7374
// that's happened, it will be easier to add this logic in without worrying about unexpected side effects.)
7475

7576
const { sdkProcessingMetadata = {} } = event;
76-
const req = sdkProcessingMetadata.request;
77+
const { request, normalizedRequest } = sdkProcessingMetadata;
7778

78-
if (!req) {
79-
return event;
79+
const addRequestDataOptions = convertReqDataIntegrationOptsToAddReqDataOpts(_options);
80+
81+
// If this is set, it takes precedence over the plain request object
82+
if (normalizedRequest) {
83+
// Some other data is not available in standard HTTP requests, but can sometimes be augmented by e.g. Express or Next.js
84+
const ipAddress = request ? request.ip || (request.socket && request.socket.remoteAddress) : undefined;
85+
const user = request ? request.user : undefined;
86+
87+
return addNormalizedRequestDataToEvent(event, normalizedRequest, { ipAddress, user }, addRequestDataOptions);
8088
}
8189

82-
const addRequestDataOptions = convertReqDataIntegrationOptsToAddReqDataOpts(_options);
90+
if (!request) {
91+
return event;
92+
}
8393

84-
return addRequestDataToEvent(event, req, addRequestDataOptions);
94+
return addRequestDataToEvent(event, request, addRequestDataOptions);
8595
},
8696
};
8797
}) satisfies IntegrationFn;

packages/node/src/integrations/http.ts

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ import {
1313
setCapturedScopesOnSpan,
1414
} from '@sentry/core';
1515
import { 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';
1920
import type { NodeClient } from '../sdk/client';
2021
import { setIsolationScope } from '../sdk/scope';
2122
import 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+
}

packages/node/src/transports/http-module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ export type HTTPModuleRequestOptions = HTTPRequestOptions | HTTPSRequestOptions
1010
export interface HTTPModuleRequestIncomingMessage {
1111
headers: IncomingHttpHeaders;
1212
statusCode?: number;
13-
on(event: 'data' | 'end', listener: () => void): void;
13+
on(event: 'data' | 'end', listener: (chunk: Buffer) => void): void;
14+
off(event: 'data' | 'end', listener: (chunk: Buffer) => void): void;
1415
setEncoding(encoding: string): void;
1516
}
1617

packages/types/src/envelope.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export type ProfileItem = BaseEnvelopeItem<ProfileItemHeaders, Profile>;
101101
export type ProfileChunkItem = BaseEnvelopeItem<ProfileChunkItemHeaders, ProfileChunk>;
102102
export type SpanItem = BaseEnvelopeItem<SpanItemHeaders, Partial<SpanJSON>>;
103103

104-
export type EventEnvelopeHeaders = { event_id: string; sent_at: string; trace?: DynamicSamplingContext };
104+
export type EventEnvelopeHeaders = { event_id: string; sent_at: string; trace?: Partial<DynamicSamplingContext> };
105105
type SessionEnvelopeHeaders = { sent_at: string };
106106
type CheckInEnvelopeHeaders = { trace?: DynamicSamplingContext };
107107
type ClientReportEnvelopeHeaders = BaseEnvelopeHeaders;

packages/types/src/event.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import type { Attachment } from './attachment';
22
import type { Breadcrumb } from './breadcrumb';
33
import type { Contexts } from './context';
44
import type { DebugMeta } from './debugMeta';
5+
import type { DynamicSamplingContext } from './envelope';
56
import type { Exception } from './exception';
67
import type { Extras } from './extra';
78
import type { Measurements } from './measurement';
89
import type { Mechanism } from './mechanism';
910
import type { Primitive } from './misc';
11+
import type { PolymorphicRequest } from './polymorphics';
1012
import type { Request } from './request';
11-
import type { CaptureContext } from './scope';
13+
import type { CaptureContext, Scope } from './scope';
1214
import type { SdkInfo } from './sdkinfo';
1315
import type { SeverityLevel } from './severity';
1416
import type { MetricSummary, SpanJSON } from './span';
@@ -51,7 +53,15 @@ export interface Event {
5153
measurements?: Measurements;
5254
debug_meta?: DebugMeta;
5355
// A place to stash data which is needed at some point in the SDK's event processing pipeline but which shouldn't get sent to Sentry
54-
sdkProcessingMetadata?: { [key: string]: any };
56+
// Note: This is considered internal and is subject to change in minors
57+
sdkProcessingMetadata?: { [key: string]: unknown } & {
58+
request?: PolymorphicRequest;
59+
normalizedRequest?: Request;
60+
dynamicSamplingContext?: Partial<DynamicSamplingContext>;
61+
capturedSpanScope?: Scope;
62+
capturedSpanIsolationScope?: Scope;
63+
spanCountBeforeProcessing?: number;
64+
};
5565
transaction_info?: {
5666
source: TransactionSource;
5767
};

0 commit comments

Comments
 (0)