diff --git a/packages/node/src/handlers.ts b/packages/node/src/handlers.ts index f03e7aa5c50f..fd8334a170c7 100644 --- a/packages/node/src/handlers.ts +++ b/packages/node/src/handlers.ts @@ -2,18 +2,13 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { captureException, getCurrentHub, startTransaction, withScope } from '@sentry/core'; import { extractTraceparentData, Span } from '@sentry/tracing'; -import { Event, Transaction } from '@sentry/types'; -import { - extractNodeRequestData, - forget, - isPlainObject, - isString, - logger, - stripUrlQueryAndFragment, -} from '@sentry/utils'; +import { Event, ExtractedNodeRequestData, Transaction } from '@sentry/types'; +import { forget, isPlainObject, isString, logger, normalize, stripUrlQueryAndFragment } from '@sentry/utils'; +import * as cookie from 'cookie'; import * as domain from 'domain'; import * as http from 'http'; import * as os from 'os'; +import * as url from 'url'; import { NodeClient } from './client'; import { flush } from './sdk'; @@ -58,11 +53,14 @@ export function tracingHandler(): ( traceparentData = extractTraceparentData(req.headers['sentry-trace'] as string); } - const transaction = startTransaction({ - name: extractExpressTransactionName(req, { path: true, method: true }), - op: 'http.server', - ...traceparentData, - }); + const transaction = startTransaction( + { + name: extractExpressTransactionName(req, { path: true, method: true }), + op: 'http.server', + ...traceparentData, + }, + { request: extractRequestData(req) }, + ); // We put the transaction on the scope so users can attach children to it getCurrentHub().configureScope(scope => { @@ -179,6 +177,97 @@ function extractUserData( return extractedUser; } +/** Default request keys that'll be used to extract data from the request */ +const DEFAULT_REQUEST_KEYS = ['cookies', 'data', 'headers', 'method', 'query_string', 'url']; + +/** + * Normalizes data from the request object, accounting for framework differences. + * + * @param req The request object from which to extract data + * @param keys An optional array of keys to include in the normalized data. Defaults to DEFAULT_REQUEST_KEYS if not + * provided. + * @returns An object containing normalized request data + */ +export function extractRequestData( + req: { [key: string]: any }, + keys: string[] = DEFAULT_REQUEST_KEYS, +): ExtractedNodeRequestData { + const requestData: { [key: string]: any } = {}; + + // headers: + // node, express: req.headers + // koa: req.header + const headers = (req.headers || req.header || {}) as { + host?: string; + cookie?: string; + }; + // method: + // node, express, koa: req.method + const method = req.method; + // host: + // express: req.hostname in > 4 and req.host in < 4 + // koa: req.host + // node: req.headers.host + const host = req.hostname || req.host || headers.host || ''; + // protocol: + // node: + // express, koa: req.protocol + const protocol = + req.protocol === 'https' || req.secure || ((req.socket || {}) as { encrypted?: boolean }).encrypted + ? 'https' + : 'http'; + // url (including path and query string): + // node, express: req.originalUrl + // koa: req.url + const originalUrl = (req.originalUrl || req.url || '') as string; + // absolute url + const absoluteUrl = `${protocol}://${host}${originalUrl}`; + + keys.forEach(key => { + switch (key) { + case 'headers': + requestData.headers = headers; + break; + case 'method': + requestData.method = method; + break; + case 'url': + requestData.url = absoluteUrl; + break; + case 'cookies': + // cookies: + // node, express, koa: req.headers.cookie + // vercel, sails.js, express (w/ cookie middleware): req.cookies + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + requestData.cookies = req.cookies || cookie.parse(headers.cookie || ''); + break; + case 'query_string': + // query string: + // node: req.url (raw) + // express, koa: req.query + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + requestData.query_string = url.parse(originalUrl || '', false).query; + break; + case 'data': + if (method === 'GET' || method === 'HEAD') { + break; + } + // body data: + // node, express, koa: req.body + if (req.body !== undefined) { + requestData.data = isString(req.body) ? req.body : JSON.stringify(normalize(req.body)); + } + break; + default: + if ({}.hasOwnProperty.call(req, key)) { + requestData[key] = (req as { [key: string]: any })[key]; + } + } + }); + + return requestData; +} + /** * Options deciding what parts of the request to use when enhancing an event */ @@ -222,10 +311,10 @@ export function parseRequest(event: Event, req: ExpressRequest, options?: ParseR } if (options.request) { - // if the option value is `true`, use the default set of keys by not passing anything to `extractNodeRequestData()` + // if the option value is `true`, use the default set of keys by not passing anything to `extractRequestData()` const extractedRequestData = Array.isArray(options.request) - ? extractNodeRequestData(req, options.request) - : extractNodeRequestData(req); + ? extractRequestData(req, options.request) + : extractRequestData(req); event.request = { ...event.request, ...extractedRequestData, diff --git a/packages/node/test/handlers.test.ts b/packages/node/test/handlers.test.ts index 6540a5d765c2..af3ee7031ef3 100644 --- a/packages/node/test/handlers.test.ts +++ b/packages/node/test/handlers.test.ts @@ -8,7 +8,7 @@ import * as net from 'net'; import { Event, Request, User } from '../src'; import { NodeClient } from '../src/client'; -import { ExpressRequest, parseRequest, tracingHandler } from '../src/handlers'; +import { ExpressRequest, extractRequestData, parseRequest, tracingHandler } from '../src/handlers'; describe('parseRequest', () => { let mockReq: { [key: string]: any }; @@ -180,8 +180,12 @@ describe('parseRequest', () => { }); describe('tracingHandler', () => { - const urlString = 'http://dogs.are.great:1231/yay/'; - const queryString = '?furry=yes&funny=very'; + const headers = { ears: 'furry', nose: 'wet', tongue: 'spotted', cookie: 'favorite=zukes' }; + const method = 'wagging'; + const protocol = 'mutualsniffing'; + const hostname = 'the.dog.park'; + const path = '/by/the/trees/'; + const queryString = 'chase=me&please=thankyou'; const fragment = '#adoptnotbuy'; const sentryTracingMiddleware = tracingHandler(); @@ -194,9 +198,13 @@ describe('tracingHandler', () => { } beforeEach(() => { - req = new http.IncomingMessage(new net.Socket()); - req.url = `${urlString}`; - req.method = 'GET'; + req = ({ + headers, + method, + protocol, + hostname, + originalUrl: `${path}?${queryString}`, + } as unknown) as http.IncomingMessage; res = new http.ServerResponse(req); next = createNoOpSpy(); }); @@ -226,6 +234,29 @@ describe('tracingHandler', () => { expect(transaction.sampled).toEqual(false); }); + it('extracts request data for sampling context', () => { + const tracesSampler = jest.fn(); + const hub = new Hub(new NodeClient({ tracesSampler })); + // we need to mock both of these because the tracing handler relies on `@sentry/core` while the sampler relies on + // `@sentry/hub`, and mocking breaks the link between the two + jest.spyOn(sentryCore, 'getCurrentHub').mockReturnValue(hub); + jest.spyOn(sentryHub, 'getCurrentHub').mockReturnValue(hub); + + sentryTracingMiddleware(req, res, next); + + expect(tracesSampler).toHaveBeenCalledWith( + expect.objectContaining({ + request: { + headers, + method, + url: `http://${hostname}${path}?${queryString}`, + cookies: { favorite: 'zukes' }, + query_string: queryString, + }, + }), + ); + }); + it('puts its transaction on the scope', () => { const hub = new Hub(new NodeClient({ tracesSampleRate: 1.0 })); // we need to mock both of these because the tracing handler relies on `@sentry/core` while the sampler relies on @@ -241,7 +272,9 @@ describe('tracingHandler', () => { ?.getTransaction(); expect(transaction).toBeDefined(); - expect(transaction).toEqual(expect.objectContaining({ name: `GET ${urlString}`, op: 'http.server' })); + expect(transaction).toEqual( + expect.objectContaining({ name: `${method.toUpperCase()} ${path}`, op: 'http.server' }), + ); }); it('puts its transaction on the response object', () => { @@ -250,7 +283,9 @@ describe('tracingHandler', () => { const transaction = (res as any).__sentry_transaction; expect(transaction).toBeDefined(); - expect(transaction).toEqual(expect.objectContaining({ name: `GET ${urlString}`, op: 'http.server' })); + expect(transaction).toEqual( + expect.objectContaining({ name: `${method.toUpperCase()} ${path}`, op: 'http.server' }), + ); }); it('pulls status code from the response', done => { @@ -271,33 +306,33 @@ describe('tracingHandler', () => { }); it('strips query string from request path', () => { - req.url = `${urlString}${queryString}`; + req.url = `${path}?${queryString}`; sentryTracingMiddleware(req, res, next); const transaction = (res as any).__sentry_transaction; - expect(transaction?.name).toBe(`GET ${urlString}`); + expect(transaction?.name).toBe(`${method.toUpperCase()} ${path}`); }); it('strips fragment from request path', () => { - req.url = `${urlString}${fragment}`; + req.url = `${path}${fragment}`; sentryTracingMiddleware(req, res, next); const transaction = (res as any).__sentry_transaction; - expect(transaction?.name).toBe(`GET ${urlString}`); + expect(transaction?.name).toBe(`${method.toUpperCase()} ${path}`); }); it('strips query string and fragment from request path', () => { - req.url = `${urlString}${queryString}${fragment}`; + req.url = `${path}?${queryString}${fragment}`; sentryTracingMiddleware(req, res, next); const transaction = (res as any).__sentry_transaction; - expect(transaction?.name).toBe(`GET ${urlString}`); + expect(transaction?.name).toBe(`${method.toUpperCase()} ${path}`); }); it('closes the transaction when request processing is done', done => { @@ -345,4 +380,179 @@ describe('tracingHandler', () => { done(); }); }); -}); // end describe('tracingHandler') +}); + +describe('extractRequestData()', () => { + describe('default behaviour', () => { + test('node', () => { + expect( + extractRequestData({ + headers: { host: 'example.com' }, + method: 'GET', + secure: true, + originalUrl: '/', + }), + ).toEqual({ + cookies: {}, + headers: { + host: 'example.com', + }, + method: 'GET', + query_string: null, + url: 'https://example.com/', + }); + }); + + test('degrades gracefully without request data', () => { + expect(extractRequestData({})).toEqual({ + cookies: {}, + headers: {}, + method: undefined, + query_string: null, + url: 'http://', + }); + }); + }); + + describe('cookies', () => { + it('uses `req.cookies` if available', () => { + expect( + extractRequestData( + { + cookies: { foo: 'bar' }, + }, + ['cookies'], + ), + ).toEqual({ + cookies: { foo: 'bar' }, + }); + }); + + it('parses the cookie header', () => { + expect( + extractRequestData( + { + headers: { + cookie: 'foo=bar;', + }, + }, + ['cookies'], + ), + ).toEqual({ + cookies: { foo: 'bar' }, + }); + }); + + it('falls back if no cookies are defined', () => { + expect(extractRequestData({}, ['cookies'])).toEqual({ + cookies: {}, + }); + }); + }); + + describe('data', () => { + it('includes data from `req.body` if available', () => { + expect( + extractRequestData( + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'foo=bar', + }, + ['data'], + ), + ).toEqual({ + data: 'foo=bar', + }); + }); + + it('encodes JSON body contents back to a string', () => { + expect( + extractRequestData( + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: { foo: 'bar' }, + }, + ['data'], + ), + ).toEqual({ + data: '{"foo":"bar"}', + }); + }); + }); + + describe('query_string', () => { + it('parses the query parms from the url', () => { + expect( + extractRequestData( + { + headers: { host: 'example.com' }, + secure: true, + originalUrl: '/?foo=bar', + }, + ['query_string'], + ), + ).toEqual({ + query_string: 'foo=bar', + }); + }); + + it('gracefully degrades if url cannot be determined', () => { + expect(extractRequestData({}, ['query_string'])).toEqual({ + query_string: null, + }); + }); + }); + + describe('url', () => { + test('express/koa', () => { + expect( + extractRequestData( + { + host: 'example.com', + protocol: 'https', + url: '/', + }, + ['url'], + ), + ).toEqual({ + url: 'https://example.com/', + }); + }); + + test('node', () => { + expect( + extractRequestData( + { + headers: { host: 'example.com' }, + secure: true, + originalUrl: '/', + }, + ['url'], + ), + ).toEqual({ + url: 'https://example.com/', + }); + }); + }); + + describe('custom key', () => { + it('includes the custom key if present', () => { + expect( + extractRequestData( + { + httpVersion: '1.1', + }, + ['httpVersion'], + ), + ).toEqual({ + httpVersion: '1.1', + }); + }); + + it('gracefully degrades if the custom key is missing', () => { + expect(extractRequestData({}, ['httpVersion'])).toEqual({}); + }); + }); +}); diff --git a/packages/tracing/src/browser/browsertracing.ts b/packages/tracing/src/browser/browsertracing.ts index ee51900df7a5..921204bf33bd 100644 --- a/packages/tracing/src/browser/browsertracing.ts +++ b/packages/tracing/src/browser/browsertracing.ts @@ -1,6 +1,6 @@ import { Hub } from '@sentry/hub'; import { EventProcessor, Integration, Transaction, TransactionContext } from '@sentry/types'; -import { logger } from '@sentry/utils'; +import { getGlobalObject, logger } from '@sentry/utils'; import { startIdleTransaction } from '../hubextensions'; import { DEFAULT_IDLE_TIMEOUT, IdleTransaction } from '../idletransaction'; @@ -209,7 +209,15 @@ export class BrowserTracing implements Integration { } const hub = this._getCurrentHub(); - const idleTransaction = startIdleTransaction(hub, finalContext, idleTimeout, true); + const { location } = getGlobalObject() as WindowOrWorkerGlobalScope & { location: Location }; + + const idleTransaction = startIdleTransaction( + hub, + finalContext, + idleTimeout, + true, + { location }, // for use in the tracesSampler + ); logger.log(`[Tracing] Starting ${finalContext.op} transaction on scope`); idleTransaction.registerBeforeFinishCallback((transaction, endTimestamp) => { this._metrics.addPerformanceEntries(transaction); diff --git a/packages/tracing/src/hubextensions.ts b/packages/tracing/src/hubextensions.ts index 7b2f2aec308d..b770155fe760 100644 --- a/packages/tracing/src/hubextensions.ts +++ b/packages/tracing/src/hubextensions.ts @@ -1,13 +1,6 @@ -import { getActiveDomain, getMainCarrier, Hub } from '@sentry/hub'; +import { getMainCarrier, Hub } from '@sentry/hub'; import { CustomSamplingContext, SamplingContext, TransactionContext, TransactionSamplingMethod } from '@sentry/types'; -import { - dynamicRequire, - extractNodeRequestData, - getGlobalObject, - isInstanceOf, - isNodeEnv, - logger, -} from '@sentry/utils'; +import { logger } from '@sentry/utils'; import { registerErrorInstrumentation } from './errors'; import { IdleTransaction } from './idletransaction'; @@ -124,50 +117,6 @@ function sample(hub: Hub, transaction: T, samplingContext logger.log(`[Tracing] starting ${transaction.op} transaction - ${transaction.name}`); return transaction; } -/** - * Gets the correct context to pass to the tracesSampler, based on the environment (i.e., which SDK is being used) - * - * @returns The default sample context - */ -function getDefaultSamplingContext(transactionContext: TransactionContext): SamplingContext { - // promote parent sampling decision (if any) for easy access - const { parentSampled } = transactionContext; - const defaultSamplingContext: SamplingContext = { transactionContext, parentSampled }; - - if (isNodeEnv()) { - const domain = getActiveDomain(); - - if (domain) { - // for all node servers that we currently support, we store the incoming request object (which is an instance of - // http.IncomingMessage) on the domain - - // the domain members are stored as an array, so our only way to find the request is to iterate through the array - // and compare types - - const nodeHttpModule = dynamicRequire(module, 'http'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const requestType = nodeHttpModule.IncomingMessage; - - const request = domain.members.find(member => isInstanceOf(member, requestType)); - if (request) { - defaultSamplingContext.request = extractNodeRequestData(request); - } - } - } - - // we must be in browser-js (or some derivative thereof) - else { - // we use `getGlobalObject()` rather than `window` since service workers also have a `location` property on `self` - const globalObject = getGlobalObject(); - - if ('location' in globalObject) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any - defaultSamplingContext.location = { ...(globalObject as any).location }; - } - } - - return defaultSamplingContext; -} /** * Checks the given sample rate to make sure it is valid type and value (a boolean, or a number between 0 and 1). @@ -214,7 +163,8 @@ function _startTransaction( ): Transaction { const transaction = new Transaction(transactionContext, this); return sample(this, transaction, { - ...getDefaultSamplingContext(transactionContext), + parentSampled: transactionContext.parentSampled, + transactionContext, ...customSamplingContext, }); } @@ -227,9 +177,14 @@ export function startIdleTransaction( transactionContext: TransactionContext, idleTimeout?: number, onScope?: boolean, + customSamplingContext?: CustomSamplingContext, ): IdleTransaction { const transaction = new IdleTransaction(transactionContext, hub, idleTimeout, onScope); - return sample(hub, transaction, getDefaultSamplingContext(transactionContext)); + return sample(hub, transaction, { + parentSampled: transactionContext.parentSampled, + transactionContext, + ...customSamplingContext, + }); } /** diff --git a/packages/tracing/test/browser/browsertracing.test.ts b/packages/tracing/test/browser/browsertracing.test.ts index e902dfa133b9..4ab599e3f518 100644 --- a/packages/tracing/test/browser/browsertracing.test.ts +++ b/packages/tracing/test/browser/browsertracing.test.ts @@ -1,5 +1,6 @@ import { BrowserClient } from '@sentry/browser'; import { Hub, makeMain } from '@sentry/hub'; +import { getGlobalObject } from '@sentry/utils'; import { JSDOM } from 'jsdom'; import { SpanStatus } from '../../src'; @@ -224,6 +225,7 @@ describe('BrowserTracing', () => { }), expect.any(Number), expect.any(Boolean), + expect.any(Object), ); }); @@ -442,4 +444,52 @@ describe('BrowserTracing', () => { }); }); }); + + describe('sampling', () => { + const dogParkLocation = { + hash: '#next-to-the-fountain', + host: 'the.dog.park', + hostname: 'the.dog.park', + href: 'mutualsniffing://the.dog.park/by/the/trees/?chase=me&please=thankyou#next-to-the-fountain', + origin: "'mutualsniffing://the.dog.park", + pathname: '/by/the/trees/', + port: '', + protocol: 'mutualsniffing:', + search: '?chase=me&please=thankyou', + }; + + it('extracts window.location/self.location for sampling context in pageload transactions', () => { + getGlobalObject().location = dogParkLocation as any; + + const tracesSampler = jest.fn(); + hub.bindClient(new BrowserClient({ tracesSampler })); + // setting up the BrowserTracing integration automatically starts a pageload transaction + createBrowserTracing(true); + + expect(tracesSampler).toHaveBeenCalledWith( + expect.objectContaining({ + location: dogParkLocation, + transactionContext: expect.objectContaining({ op: 'pageload' }), + }), + ); + }); + + it('extracts window.location/self.location for sampling context in navigation transactions', () => { + getGlobalObject().location = dogParkLocation as any; + + const tracesSampler = jest.fn(); + hub.bindClient(new BrowserClient({ tracesSampler })); + // setting up the BrowserTracing integration normally automatically starts a pageload transaction, but that's not + // what we're testing here + createBrowserTracing(true, { startTransactionOnPageLoad: false }); + + mockChangeHistory({ to: 'here', from: 'there' }); + expect(tracesSampler).toHaveBeenCalledWith( + expect.objectContaining({ + location: dogParkLocation, + transactionContext: expect.objectContaining({ op: 'navigation' }), + }), + ); + }); + }); }); diff --git a/packages/tracing/test/hub.test.ts b/packages/tracing/test/hub.test.ts index 4d8733422bb4..b053512f8b26 100644 --- a/packages/tracing/test/hub.test.ts +++ b/packages/tracing/test/hub.test.ts @@ -1,10 +1,9 @@ /* eslint-disable @typescript-eslint/unbound-method */ import { BrowserClient } from '@sentry/browser'; -import { getMainCarrier, Hub } from '@sentry/hub'; +import { Hub } from '@sentry/hub'; import * as hubModule from '@sentry/hub'; import * as utilsModule from '@sentry/utils'; // for mocking -import { getGlobalObject, isNodeEnv, logger } from '@sentry/utils'; -import * as nodeHttpModule from 'http'; +import { logger } from '@sentry/utils'; import { BrowserTracing } from '../src/browser/browsertracing'; import { addExtensionMethods } from '../src/hubextensions'; @@ -65,80 +64,6 @@ describe('Hub', () => { describe('transaction sampling', () => { describe('default sample context', () => { - it('should extract request data for default sampling context when in node', () => { - // make sure we look like we're in node - (isNodeEnv as jest.Mock).mockReturnValue(true); - - // pre-normalization request object - const mockRequestObject = ({ - headers: { ears: 'furry', nose: 'wet', tongue: 'panting', cookie: 'favorite=zukes' }, - method: 'wagging', - protocol: 'mutualsniffing', - hostname: 'the.dog.park', - originalUrl: '/by/the/trees/?chase=me&please=thankyou', - } as unknown) as nodeHttpModule.IncomingMessage; - - // The "as unknown as nodeHttpModule.IncomingMessage" casting above keeps TS happy, but doesn't actually mean that - // mockRequestObject IS an instance of our desired class. Fix that so that when we search for it by type, we - // actually find it. - Object.setPrototypeOf(mockRequestObject, nodeHttpModule.IncomingMessage.prototype); - - // in production, the domain will have at minimum the request and the response, so make a response object to prove - // that our code identifying the request in domain.members works - const mockResponseObject = new nodeHttpModule.ServerResponse(mockRequestObject); - - // normally the node request handler does this, but that's not part of this test - (getMainCarrier().__SENTRY__!.extensions as any).domain = { - active: { members: [mockRequestObject, mockResponseObject] }, - }; - - // Ideally we'd use a NodeClient here, but @sentry/tracing can't depend on @sentry/node since the reverse is - // already true (node's request handlers start their own transactions) - even as a dev dependency. Fortunately, - // we're not relying on anything other than the client having a captureEvent method, which all clients do (it's - // in the abstract base class), so a BrowserClient will do. - const tracesSampler = jest.fn(); - const hub = new Hub(new BrowserClient({ tracesSampler })); - hub.startTransaction({ name: 'dogpark' }); - - // post-normalization request object - expect(tracesSampler).toHaveBeenCalledWith( - expect.objectContaining({ - request: { - headers: { ears: 'furry', nose: 'wet', tongue: 'panting', cookie: 'favorite=zukes' }, - method: 'wagging', - url: 'http://the.dog.park/by/the/trees/?chase=me&please=thankyou', - cookies: { favorite: 'zukes' }, - query_string: 'chase=me&please=thankyou', - }, - }), - ); - }); - - it('should extract window.location/self.location for default sampling context when in browser/service worker', () => { - // make sure we look like we're in the browser - (isNodeEnv as jest.Mock).mockReturnValue(false); - - const dogParkLocation = { - hash: '#next-to-the-fountain', - host: 'the.dog.park', - hostname: 'the.dog.park', - href: 'mutualsniffing://the.dog.park/by/the/trees/?chase=me&please=thankyou#next-to-the-fountain', - origin: "'mutualsniffing://the.dog.park", - pathname: '/by/the/trees/', - port: '', - protocol: 'mutualsniffing:', - search: '?chase=me&please=thankyou', - }; - - getGlobalObject().location = dogParkLocation as any; - - const tracesSampler = jest.fn(); - const hub = new Hub(new BrowserClient({ tracesSampler })); - hub.startTransaction({ name: 'dogpark' }); - - expect(tracesSampler).toHaveBeenCalledWith(expect.objectContaining({ location: dogParkLocation })); - }); - it('should add transaction context data to default sample context', () => { const tracesSampler = jest.fn(); const hub = new Hub(new BrowserClient({ tracesSampler })); diff --git a/packages/utils/src/node.ts b/packages/utils/src/node.ts index 8abfd6d2e132..790725b69ca1 100644 --- a/packages/utils/src/node.ts +++ b/packages/utils/src/node.ts @@ -1,9 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { ExtractedNodeRequestData } from '@sentry/types'; - -import { isString } from './is'; -import { normalize } from './object'; - /** * Checks whether we're in the Node.js or Browser environment * @@ -18,104 +12,8 @@ export function isNodeEnv(): boolean { * * @param request The module path to resolve */ -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any export function dynamicRequire(mod: any, request: string): any { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access return mod.require(request); } - -/** Default request keys that'll be used to extract data from the request */ -const DEFAULT_REQUEST_KEYS = ['cookies', 'data', 'headers', 'method', 'query_string', 'url']; - -/** - * Normalizes data from the request object, accounting for framework differences. - * - * @param req The request object from which to extract data - * @param keys An optional array of keys to include in the normalized data. Defaults to DEFAULT_REQUEST_KEYS if not - * provided. - * @returns An object containing normalized request data - */ -export function extractNodeRequestData( - req: { [key: string]: any }, - keys: string[] = DEFAULT_REQUEST_KEYS, -): ExtractedNodeRequestData { - // make sure we can safely use dynamicRequire below - if (!isNodeEnv()) { - throw new Error("Can't get node request data outside of a node environment"); - } - - const requestData: { [key: string]: any } = {}; - - // headers: - // node, express: req.headers - // koa: req.header - const headers = (req.headers || req.header || {}) as { - host?: string; - cookie?: string; - }; - // method: - // node, express, koa: req.method - const method = req.method; - // host: - // express: req.hostname in > 4 and req.host in < 4 - // koa: req.host - // node: req.headers.host - const host = req.hostname || req.host || headers.host || ''; - // protocol: - // node: - // express, koa: req.protocol - const protocol = - req.protocol === 'https' || req.secure || ((req.socket || {}) as { encrypted?: boolean }).encrypted - ? 'https' - : 'http'; - // url (including path and query string): - // node, express: req.originalUrl - // koa: req.url - const originalUrl = (req.originalUrl || req.url || '') as string; - // absolute url - const absoluteUrl = `${protocol}://${host}${originalUrl}`; - - keys.forEach(key => { - switch (key) { - case 'headers': - requestData.headers = headers; - break; - case 'method': - requestData.method = method; - break; - case 'url': - requestData.url = absoluteUrl; - break; - case 'cookies': - // cookies: - // node, express, koa: req.headers.cookie - // vercel, sails.js, express (w/ cookie middleware): req.cookies - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - requestData.cookies = req.cookies || dynamicRequire(module, 'cookie').parse(headers.cookie || ''); - break; - case 'query_string': - // query string: - // node: req.url (raw) - // express, koa: req.query - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - requestData.query_string = dynamicRequire(module, 'url').parse(originalUrl || '', false).query; - break; - case 'data': - if (method === 'GET' || method === 'HEAD') { - break; - } - // body data: - // node, express, koa: req.body - if (req.body !== undefined) { - requestData.data = isString(req.body) ? req.body : JSON.stringify(normalize(req.body)); - } - break; - default: - if ({}.hasOwnProperty.call(req, key)) { - requestData[key] = (req as { [key: string]: any })[key]; - } - } - }); - - return requestData; -} diff --git a/packages/utils/test/node.test.ts b/packages/utils/test/node.test.ts deleted file mode 100644 index 93d13e4428e0..000000000000 --- a/packages/utils/test/node.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { extractNodeRequestData } from '../src/node'; - -describe('extractNodeRequestData()', () => { - describe('default behaviour', () => { - test('node', () => { - expect( - extractNodeRequestData({ - headers: { host: 'example.com' }, - method: 'GET', - secure: true, - originalUrl: '/', - }), - ).toEqual({ - cookies: {}, - headers: { - host: 'example.com', - }, - method: 'GET', - query_string: null, - url: 'https://example.com/', - }); - }); - - test('degrades gracefully without request data', () => { - expect(extractNodeRequestData({})).toEqual({ - cookies: {}, - headers: {}, - method: undefined, - query_string: null, - url: 'http://', - }); - }); - }); - - describe('cookies', () => { - it('uses `req.cookies` if available', () => { - expect( - extractNodeRequestData( - { - cookies: { foo: 'bar' }, - }, - ['cookies'], - ), - ).toEqual({ - cookies: { foo: 'bar' }, - }); - }); - - it('parses the cookie header', () => { - expect( - extractNodeRequestData( - { - headers: { - cookie: 'foo=bar;', - }, - }, - ['cookies'], - ), - ).toEqual({ - cookies: { foo: 'bar' }, - }); - }); - - it('falls back if no cookies are defined', () => { - expect(extractNodeRequestData({}, ['cookies'])).toEqual({ - cookies: {}, - }); - }); - }); - - describe('data', () => { - it('includes data from `req.body` if available', () => { - expect( - extractNodeRequestData( - { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: 'foo=bar', - }, - ['data'], - ), - ).toEqual({ - data: 'foo=bar', - }); - }); - - it('encodes JSON body contents back to a string', () => { - expect( - extractNodeRequestData( - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: { foo: 'bar' }, - }, - ['data'], - ), - ).toEqual({ - data: '{"foo":"bar"}', - }); - }); - }); - - describe('query_string', () => { - it('parses the query parms from the url', () => { - expect( - extractNodeRequestData( - { - headers: { host: 'example.com' }, - secure: true, - originalUrl: '/?foo=bar', - }, - ['query_string'], - ), - ).toEqual({ - query_string: 'foo=bar', - }); - }); - - it('gracefully degrades if url cannot be determined', () => { - expect(extractNodeRequestData({}, ['query_string'])).toEqual({ - query_string: null, - }); - }); - }); - - describe('url', () => { - test('express/koa', () => { - expect( - extractNodeRequestData( - { - host: 'example.com', - protocol: 'https', - url: '/', - }, - ['url'], - ), - ).toEqual({ - url: 'https://example.com/', - }); - }); - - test('node', () => { - expect( - extractNodeRequestData( - { - headers: { host: 'example.com' }, - secure: true, - originalUrl: '/', - }, - ['url'], - ), - ).toEqual({ - url: 'https://example.com/', - }); - }); - }); - - describe('custom key', () => { - it('includes the custom key if present', () => { - expect( - extractNodeRequestData( - { - httpVersion: '1.1', - }, - ['httpVersion'], - ), - ).toEqual({ - httpVersion: '1.1', - }); - }); - - it('gracefully degrades if the custom key is missing', () => { - expect(extractNodeRequestData({}, ['httpVersion'])).toEqual({}); - }); - }); -});