import { propagation, context } from '@opentelemetry/api'; import { VERSION } from '@opentelemetry/core'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; import { getIsolationScope, httpRequestToRequestData, stripUrlQueryAndFragment, withIsolationScope, generateSpanId, getCurrentScope, logger, getClient, getBreadcrumbLogLevelFromHttpStatusCode, addBreadcrumb, parseUrl, getSanitizedUrlString } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build.js'; import { getRequestUrl } from '../../utils/getRequestUrl.js'; import { stealthWrap } from './utils.js'; import { getRequestInfo } from './vendor/getRequestInfo.js'; const INSTRUMENTATION_NAME = '@sentry/instrumentation-http'; // We only want to capture request bodies up to 1mb. const MAX_BODY_BYTE_LENGTH = 1024 * 1024; /** * This custom HTTP instrumentation is used to isolate incoming requests and annotate them with additional information. * It does not emit any spans. * * The reason this is isolated from the OpenTelemetry instrumentation is that users may overwrite this, * which would lead to Sentry not working as expected. * * Important note: Contrary to other OTEL instrumentation, this one cannot be unwrapped. * It only does minimal things though and does not emit any spans. * * This is heavily inspired & adapted from: * https://github.com/open-telemetry/opentelemetry-js/blob/f8ab5592ddea5cba0a3b33bf8d74f27872c0367f/experimental/packages/opentelemetry-instrumentation-http/src/http.ts */ class SentryHttpInstrumentation extends InstrumentationBase { constructor(config = {}) { super(INSTRUMENTATION_NAME, VERSION, config); } /** @inheritdoc */ init() { return [this._getHttpsInstrumentation(), this._getHttpInstrumentation()]; } /** Get the instrumentation for the http module. */ _getHttpInstrumentation() { return new InstrumentationNodeModuleDefinition( 'http', ['*'], (moduleExports) => { // Patch incoming requests for request isolation stealthWrap(moduleExports.Server.prototype, 'emit', this._getPatchIncomingRequestFunction()); // Patch outgoing requests for breadcrumbs const patchedRequest = stealthWrap(moduleExports, 'request', this._getPatchOutgoingRequestFunction()); stealthWrap(moduleExports, 'get', this._getPatchOutgoingGetFunction(patchedRequest)); return moduleExports; }, () => { // no unwrap here }, ); } /** Get the instrumentation for the https module. */ _getHttpsInstrumentation() { return new InstrumentationNodeModuleDefinition( 'https', ['*'], (moduleExports) => { // Patch incoming requests for request isolation stealthWrap(moduleExports.Server.prototype, 'emit', this._getPatchIncomingRequestFunction()); // Patch outgoing requests for breadcrumbs const patchedRequest = stealthWrap(moduleExports, 'request', this._getPatchOutgoingRequestFunction()); stealthWrap(moduleExports, 'get', this._getPatchOutgoingGetFunction(patchedRequest)); return moduleExports; }, () => { // no unwrap here }, ); } /** * Patch the incoming request function for request isolation. */ _getPatchIncomingRequestFunction() { // eslint-disable-next-line @typescript-eslint/no-this-alias const instrumentation = this; const { ignoreIncomingRequestBody } = instrumentation.getConfig(); return ( original, ) => { return function incomingRequest( ...args) { // Only traces request events if (args[0] !== 'request') { return original.apply(this, args); } instrumentation._diag.debug('http instrumentation for incoming request'); const isolationScope = getIsolationScope().clone(); const request = args[1] ; const response = args[2] ; const normalizedRequest = httpRequestToRequestData(request); // request.ip is non-standard but some frameworks set this const ipAddress = (request ).ip || request.socket?.remoteAddress; const url = request.url || '/'; if (!ignoreIncomingRequestBody?.(url, request)) { patchRequestToCaptureBody(request, isolationScope); } // Update the isolation scope, isolate this request isolationScope.setSDKProcessingMetadata({ normalizedRequest, ipAddress }); // attempt to update the scope's `transactionName` based on the request URL // Ideally, framework instrumentations coming after the HttpInstrumentation // update the transactionName once we get a parameterized route. const httpMethod = (request.method || 'GET').toUpperCase(); const httpTarget = stripUrlQueryAndFragment(url); const bestEffortTransactionName = `${httpMethod} ${httpTarget}`; isolationScope.setTransactionName(bestEffortTransactionName); if (instrumentation.getConfig().trackIncomingRequestsAsSessions !== false) { recordRequestSession({ requestIsolationScope: isolationScope, response, sessionFlushingDelayMS: instrumentation.getConfig().sessionFlushingDelayMS ?? 60000, }); } return withIsolationScope(isolationScope, () => { // Set a new propagationSpanId for this request // We rely on the fact that `withIsolationScope()` will implicitly also fork the current scope // This way we can save an "unnecessary" `withScope()` invocation getCurrentScope().getPropagationContext().propagationSpanId = generateSpanId(); // If we don't want to extract the trace from the header, we can skip this if (!instrumentation.getConfig().extractIncomingTraceFromHeader) { return original.apply(this, args); } const ctx = propagation.extract(context.active(), normalizedRequest.headers); return context.with(ctx, () => { return original.apply(this, args); }); }); }; }; } /** * Patch the outgoing request function for breadcrumbs. */ _getPatchOutgoingRequestFunction() { // eslint-disable-next-line @typescript-eslint/no-this-alias const instrumentation = this; return (original) => { return function outgoingRequest( ...args) { instrumentation._diag.debug('http instrumentation for outgoing requests'); // Making a copy to avoid mutating the original args array // We need to access and reconstruct the request options object passed to `ignoreOutgoingRequests` // so that it matches what Otel instrumentation passes to `ignoreOutgoingRequestHook`. // @see https://github.com/open-telemetry/opentelemetry-js/blob/7293e69c1e55ca62e15d0724d22605e61bd58952/experimental/packages/opentelemetry-instrumentation-http/src/http.ts#L756-L789 const argsCopy = [...args]; const options = argsCopy.shift() ; const extraOptions = typeof argsCopy[0] === 'object' && (typeof options === 'string' || options instanceof URL) ? (argsCopy.shift() ) : undefined; const { optionsParsed } = getRequestInfo(instrumentation._diag, options, extraOptions); const request = original.apply(this, args) ; request.prependListener('response', (response) => { const _breadcrumbs = instrumentation.getConfig().breadcrumbs; const breadCrumbsEnabled = typeof _breadcrumbs === 'undefined' ? true : _breadcrumbs; const _ignoreOutgoingRequests = instrumentation.getConfig().ignoreOutgoingRequests; const shouldCreateBreadcrumb = typeof _ignoreOutgoingRequests === 'function' ? !_ignoreOutgoingRequests(getRequestUrl(request), optionsParsed) : true; if (breadCrumbsEnabled && shouldCreateBreadcrumb) { addRequestBreadcrumb(request, response); } }); return request; }; }; } /** Path the outgoing get function for breadcrumbs. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any _getPatchOutgoingGetFunction(clientRequest) { return (_original) => { // Re-implement http.get. This needs to be done (instead of using // getPatchOutgoingRequestFunction to patch it) because we need to // set the trace context header before the returned http.ClientRequest is // ended. The Node.js docs state that the only differences between // request and get are that (1) get defaults to the HTTP GET method and // (2) the returned request object is ended immediately. The former is // already true (at least in supported Node versions up to v10), so we // simply follow the latter. Ref: // https://nodejs.org/dist/latest/docs/api/http.html#http_http_get_options_callback // https://github.com/googleapis/cloud-trace-nodejs/blob/master/src/instrumentations/instrumentation-http.ts#L198 return function outgoingGetRequest(...args) { const req = clientRequest(...args); req.end(); return req; }; }; } } /** Add a breadcrumb for outgoing requests. */ function addRequestBreadcrumb(request, response) { const data = getBreadcrumbData(request); const statusCode = response.statusCode; const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode); addBreadcrumb( { category: 'http', data: { status_code: statusCode, ...data, }, type: 'http', level, }, { event: 'response', request, response, }, ); } function getBreadcrumbData(request) { try { // `request.host` does not contain the port, but the host header does const host = request.getHeader('host') || request.host; const url = new URL(request.path, `${request.protocol}//${host}`); const parsedUrl = parseUrl(url.toString()); const data = { url: getSanitizedUrlString(parsedUrl), 'http.method': request.method || 'GET', }; if (parsedUrl.search) { data['http.query'] = parsedUrl.search; } if (parsedUrl.hash) { data['http.fragment'] = parsedUrl.hash; } return data; } catch { return {}; } } /** * This method patches the request object to capture the body. * Instead of actually consuming the streamed body ourselves, which has potential side effects, * we monkey patch `req.on('data')` to intercept the body chunks. * This way, we only read the body if the user also consumes the body, ensuring we do not change any behavior in unexpected ways. */ function patchRequestToCaptureBody(req, isolationScope) { let bodyByteLength = 0; const chunks = []; /** * We need to keep track of the original callbacks, in order to be able to remove listeners again. * Since `off` depends on having the exact same function reference passed in, we need to be able to map * original listeners to our wrapped ones. */ const callbackMap = new WeakMap(); try { // eslint-disable-next-line @typescript-eslint/unbound-method req.on = new Proxy(req.on, { apply: (target, thisArg, args) => { const [event, listener, ...restArgs] = args; if (DEBUG_BUILD) { logger.log(INSTRUMENTATION_NAME, 'Patching request.on', event); } if (event === 'data') { const callback = new Proxy(listener, { apply: (target, thisArg, args) => { try { const chunk = args[0] ; const bufferifiedChunk = Buffer.from(chunk); if (bodyByteLength < MAX_BODY_BYTE_LENGTH) { chunks.push(bufferifiedChunk); bodyByteLength += bufferifiedChunk.byteLength; } else if (DEBUG_BUILD) { logger.log( INSTRUMENTATION_NAME, `Dropping request body chunk because maximum body length of ${MAX_BODY_BYTE_LENGTH}b is exceeded.`, ); } } catch (err) { DEBUG_BUILD && logger.error(INSTRUMENTATION_NAME, 'Encountered error while storing body chunk.'); } return Reflect.apply(target, thisArg, args); }, }); callbackMap.set(listener, callback); return Reflect.apply(target, thisArg, [event, callback, ...restArgs]); } return Reflect.apply(target, thisArg, args); }, }); // Ensure we also remove callbacks correctly // eslint-disable-next-line @typescript-eslint/unbound-method req.off = new Proxy(req.off, { apply: (target, thisArg, args) => { const [, listener] = args; const callback = callbackMap.get(listener); if (callback) { callbackMap.delete(listener); const modifiedArgs = args.slice(); modifiedArgs[1] = callback; return Reflect.apply(target, thisArg, modifiedArgs); } return Reflect.apply(target, thisArg, args); }, }); req.on('end', () => { try { const body = Buffer.concat(chunks).toString('utf-8'); if (body) { isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: body } }); } } catch (error) { if (DEBUG_BUILD) { logger.error(INSTRUMENTATION_NAME, 'Error building captured request body', error); } } }); } catch (error) { if (DEBUG_BUILD) { logger.error(INSTRUMENTATION_NAME, 'Error patching request to capture body', error); } } } /** * Starts a session and tracks it in the context of a given isolation scope. * When the passed response is finished, the session is put into a task and is * aggregated with other sessions that may happen in a certain time window * (sessionFlushingDelayMs). * * The sessions are always aggregated by the client that is on the current scope * at the time of ending the response (if there is one). */ // Exported for unit tests function recordRequestSession({ requestIsolationScope, response, sessionFlushingDelayMS, } ) { requestIsolationScope.setSDKProcessingMetadata({ requestSession: { status: 'ok' }, }); response.once('close', () => { // We need to grab the client off the current scope instead of the isolation scope because the isolation scope doesn't hold any client out of the box. const client = getClient(); const requestSession = requestIsolationScope.getScopeData().sdkProcessingMetadata.requestSession; if (client && requestSession) { DEBUG_BUILD && logger.debug(`Recorded request session with status: ${requestSession.status}`); const roundedDate = new Date(); roundedDate.setSeconds(0, 0); const dateBucketKey = roundedDate.toISOString(); const existingClientAggregate = clientToRequestSessionAggregatesMap.get(client); const bucket = existingClientAggregate?.[dateBucketKey] || { exited: 0, crashed: 0, errored: 0 }; bucket[({ ok: 'exited', crashed: 'crashed', errored: 'errored' } )[requestSession.status]]++; if (existingClientAggregate) { existingClientAggregate[dateBucketKey] = bucket; } else { DEBUG_BUILD && logger.debug('Opened new request session aggregate.'); const newClientAggregate = { [dateBucketKey]: bucket }; clientToRequestSessionAggregatesMap.set(client, newClientAggregate); const flushPendingClientAggregates = () => { clearTimeout(timeout); unregisterClientFlushHook(); clientToRequestSessionAggregatesMap.delete(client); const aggregatePayload = Object.entries(newClientAggregate).map( ([timestamp, value]) => ({ started: timestamp, exited: value.exited, errored: value.errored, crashed: value.crashed, }), ); client.sendSession({ aggregates: aggregatePayload }); }; const unregisterClientFlushHook = client.on('flush', () => { DEBUG_BUILD && logger.debug('Sending request session aggregate due to client flush'); flushPendingClientAggregates(); }); const timeout = setTimeout(() => { DEBUG_BUILD && logger.debug('Sending request session aggregate due to flushing schedule'); flushPendingClientAggregates(); }, sessionFlushingDelayMS).unref(); } } }); } const clientToRequestSessionAggregatesMap = new Map (); export { SentryHttpInstrumentation, recordRequestSession }; //# sourceMappingURL=SentryHttpInstrumentation.js.map