|
1 | 1 | import type * as http from 'node:http';
|
2 |
| -import type { RequestOptions } from 'node:http'; |
| 2 | +import type { IncomingMessage, RequestOptions } from 'node:http'; |
3 | 3 | import type * as https from 'node:https';
|
4 | 4 | import { VERSION } from '@opentelemetry/core';
|
5 | 5 | import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
|
6 | 6 | import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
|
7 | 7 | import { getRequestInfo } from '@opentelemetry/instrumentation-http';
|
8 | 8 | import { addBreadcrumb, getClient, getIsolationScope, withIsolationScope } from '@sentry/core';
|
9 |
| -import type { SanitizedRequestData } from '@sentry/types'; |
| 9 | +import type { PolymorphicRequest, Request, SanitizedRequestData } from '@sentry/types'; |
10 | 10 | import {
|
11 | 11 | getBreadcrumbLogLevelFromHttpStatusCode,
|
12 | 12 | getSanitizedUrlString,
|
| 13 | + logger, |
13 | 14 | parseUrl,
|
14 | 15 | stripUrlQueryAndFragment,
|
15 | 16 | } from '@sentry/utils';
|
| 17 | +import { DEBUG_BUILD } from '../../debug-build'; |
16 | 18 | import type { NodeClient } from '../../sdk/client';
|
17 | 19 | import { getRequestUrl } from '../../utils/getRequestUrl';
|
18 | 20 |
|
@@ -128,6 +130,28 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
|
128 | 130 |
|
129 | 131 | const isolationScope = getIsolationScope().clone();
|
130 | 132 |
|
| 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 | + |
131 | 155 | // Update the isolation scope, isolate this request
|
132 | 156 | isolationScope.setSDKProcessingMetadata({ request });
|
133 | 157 |
|
@@ -316,3 +340,128 @@ function getBreadcrumbData(request: http.ClientRequest): Partial<SanitizedReques
|
316 | 340 | return {};
|
317 | 341 | }
|
318 | 342 | }
|
| 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 | +} |
0 commit comments