UNPKG

@sentry/node

Version:

Sentry Node SDK using OpenTelemetry for performance instrumentation

559 lines (465 loc) 20.7 kB
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); const diagnosticsChannel = require('node:diagnostics_channel'); const api = require('@opentelemetry/api'); const core = require('@opentelemetry/core'); const instrumentation = require('@opentelemetry/instrumentation'); const core$1 = require('@sentry/core'); const opentelemetry = require('@sentry/opentelemetry'); const debugBuild = require('../../debug-build.js'); const baggage = require('../../utils/baggage.js'); const getRequestUrl = require('../../utils/getRequestUrl.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 instrumentation.InstrumentationBase { constructor(config = {}) { super(INSTRUMENTATION_NAME, core.VERSION, config); this._propagationDecisionMap = new core$1.LRUMap(100); this._ignoreOutgoingRequestsMap = new WeakMap(); } /** @inheritdoc */ init() { // We register handlers when either http or https is instrumented // but we only want to register them once, whichever is loaded first let hasRegisteredHandlers = false; const onHttpServerRequestStart = ((_data) => { const data = _data ; this._patchServerEmitOnce(data.server); }) ; const onHttpClientResponseFinish = ((_data) => { const data = _data ; this._onOutgoingRequestFinish(data.request, data.response); }) ; const onHttpClientRequestError = ((_data) => { const data = _data ; this._onOutgoingRequestFinish(data.request, undefined); }) ; const onHttpClientRequestCreated = ((_data) => { const data = _data ; this._onOutgoingRequestCreated(data.request); }) ; const wrap = (moduleExports) => { if (hasRegisteredHandlers) { return moduleExports; } hasRegisteredHandlers = true; diagnosticsChannel.subscribe('http.server.request.start', onHttpServerRequestStart); diagnosticsChannel.subscribe('http.client.response.finish', onHttpClientResponseFinish); // When an error happens, we still want to have a breadcrumb // In this case, `http.client.response.finish` is not triggered diagnosticsChannel.subscribe('http.client.request.error', onHttpClientRequestError); // NOTE: This channel only exist since Node 22 // Before that, outgoing requests are not patched // and trace headers are not propagated, sadly. if (this.getConfig().propagateTraceInOutgoingRequests) { diagnosticsChannel.subscribe('http.client.request.created', onHttpClientRequestCreated); } return moduleExports; }; const unwrap = () => { diagnosticsChannel.unsubscribe('http.server.request.start', onHttpServerRequestStart); diagnosticsChannel.unsubscribe('http.client.response.finish', onHttpClientResponseFinish); diagnosticsChannel.unsubscribe('http.client.request.error', onHttpClientRequestError); diagnosticsChannel.unsubscribe('http.client.request.created', onHttpClientRequestCreated); }; /** * You may be wondering why we register these diagnostics-channel listeners * in such a convoluted way (as InstrumentationNodeModuleDefinition...)˝, * instead of simply subscribing to the events once in here. * The reason for this is timing semantics: These functions are called once the http or https module is loaded. * If we'd subscribe before that, there seem to be conflicts with the OTEL native instrumentation in some scenarios, * especially the "import-on-top" pattern of setting up ESM applications. */ return [ new instrumentation.InstrumentationNodeModuleDefinition('http', ['*'], wrap, unwrap), new instrumentation.InstrumentationNodeModuleDefinition('https', ['*'], wrap, unwrap), ]; } /** * This is triggered when an outgoing request finishes. * It has access to the final request and response objects. */ _onOutgoingRequestFinish(request, response) { debugBuild.DEBUG_BUILD && core$1.logger.log(INSTRUMENTATION_NAME, 'Handling finished outgoing request'); const _breadcrumbs = this.getConfig().breadcrumbs; const breadCrumbsEnabled = typeof _breadcrumbs === 'undefined' ? true : _breadcrumbs; // Note: We cannot rely on the map being set by `_onOutgoingRequestCreated`, because that is not run in Node <22 const shouldIgnore = this._ignoreOutgoingRequestsMap.get(request) ?? this._shouldIgnoreOutgoingRequest(request); this._ignoreOutgoingRequestsMap.set(request, shouldIgnore); if (breadCrumbsEnabled && !shouldIgnore) { addRequestBreadcrumb(request, response); } } /** * This is triggered when an outgoing request is created. * It has access to the request object, and can mutate it before the request is sent. */ _onOutgoingRequestCreated(request) { const shouldIgnore = this._ignoreOutgoingRequestsMap.get(request) ?? this._shouldIgnoreOutgoingRequest(request); this._ignoreOutgoingRequestsMap.set(request, shouldIgnore); if (shouldIgnore) { return; } // Add trace propagation headers const url = getRequestUrl.getRequestUrl(request); // Manually add the trace headers, if it applies // Note: We do not use `propagation.inject()` here, because our propagator relies on an active span // Which we do not have in this case const tracePropagationTargets = core$1.getClient()?.getOptions().tracePropagationTargets; const addedHeaders = opentelemetry.shouldPropagateTraceForUrl(url, tracePropagationTargets, this._propagationDecisionMap) ? core$1.getTraceData() : undefined; if (!addedHeaders) { return; } const { 'sentry-trace': sentryTrace, baggage: baggage$1 } = addedHeaders; // We do not want to overwrite existing header here, if it was already set if (sentryTrace && !request.getHeader('sentry-trace')) { try { request.setHeader('sentry-trace', sentryTrace); debugBuild.DEBUG_BUILD && core$1.logger.log(INSTRUMENTATION_NAME, 'Added sentry-trace header to outgoing request'); } catch (error) { debugBuild.DEBUG_BUILD && core$1.logger.error( INSTRUMENTATION_NAME, 'Failed to add sentry-trace header to outgoing request:', core$1.isError(error) ? error.message : 'Unknown error', ); } } if (baggage$1) { // For baggage, we make sure to merge this into a possibly existing header const newBaggage = baggage.mergeBaggageHeaders(request.getHeader('baggage'), baggage$1); if (newBaggage) { try { request.setHeader('baggage', newBaggage); debugBuild.DEBUG_BUILD && core$1.logger.log(INSTRUMENTATION_NAME, 'Added baggage header to outgoing request'); } catch (error) { debugBuild.DEBUG_BUILD && core$1.logger.error( INSTRUMENTATION_NAME, 'Failed to add baggage header to outgoing request:', core$1.isError(error) ? error.message : 'Unknown error', ); } } } } /** * Patch a server.emit function to handle process isolation for incoming requests. * This will only patch the emit function if it was not already patched. */ _patchServerEmitOnce(server) { // eslint-disable-next-line @typescript-eslint/unbound-method const originalEmit = server.emit; // This means it was already patched, do nothing if ((originalEmit ).__sentry_patched__) { return; } debugBuild.DEBUG_BUILD && core$1.logger.log(INSTRUMENTATION_NAME, 'Patching server.emit'); // eslint-disable-next-line @typescript-eslint/no-this-alias const instrumentation = this; const { ignoreIncomingRequestBody, maxIncomingRequestBodySize = 'medium' } = instrumentation.getConfig(); const newEmit = new Proxy(originalEmit, { apply(target, thisArg, args) { // Only traces request events if (args[0] !== 'request') { return target.apply(thisArg, args); } debugBuild.DEBUG_BUILD && core$1.logger.log(INSTRUMENTATION_NAME, 'Handling incoming request'); const isolationScope = core$1.getIsolationScope().clone(); const request = args[1] ; const response = args[2] ; const normalizedRequest = core$1.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) && maxIncomingRequestBodySize !== 'none') { patchRequestToCaptureBody(request, isolationScope, maxIncomingRequestBodySize); } // 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 = core$1.stripUrlQueryAndFragment(url); const bestEffortTransactionName = `${httpMethod} ${httpTarget}`; isolationScope.setTransactionName(bestEffortTransactionName); if (instrumentation.getConfig().trackIncomingRequestsAsSessions !== false) { recordRequestSession({ requestIsolationScope: isolationScope, response, sessionFlushingDelayMS: instrumentation.getConfig().sessionFlushingDelayMS ?? 60000, }); } return core$1.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 core$1.getCurrentScope().getPropagationContext().propagationSpanId = core$1.generateSpanId(); // If we don't want to extract the trace from the header, we can skip this if (!instrumentation.getConfig().extractIncomingTraceFromHeader) { return target.apply(thisArg, args); } const ctx = api.propagation.extract(api.context.active(), normalizedRequest.headers); return api.context.with(ctx, () => { return target.apply(thisArg, args); }); }); }, }); core$1.addNonEnumerableProperty(newEmit, '__sentry_patched__', true); server.emit = newEmit; } /** * Check if the given outgoing request should be ignored. */ _shouldIgnoreOutgoingRequest(request) { if (core.isTracingSuppressed(api.context.active())) { return true; } const ignoreOutgoingRequests = this.getConfig().ignoreOutgoingRequests; if (!ignoreOutgoingRequests) { return false; } const options = getRequestOptions(request); const url = getRequestUrl.getRequestUrl(request); return ignoreOutgoingRequests(url, options); } } /** Add a breadcrumb for outgoing requests. */ function addRequestBreadcrumb(request, response) { const data = getBreadcrumbData(request); const statusCode = response?.statusCode; const level = core$1.getBreadcrumbLogLevelFromHttpStatusCode(statusCode); core$1.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 = core$1.parseUrl(url.toString()); const data = { url: core$1.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, maxIncomingRequestBodySize, ) { let bodyByteLength = 0; const chunks = []; debugBuild.DEBUG_BUILD && core$1.logger.log(INSTRUMENTATION_NAME, 'Patching request.on'); /** * 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(); const maxBodySize = maxIncomingRequestBodySize === 'small' ? 1000 : maxIncomingRequestBodySize === 'medium' ? 10000 : MAX_BODY_BYTE_LENGTH; 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 (event === 'data') { debugBuild.DEBUG_BUILD && core$1.logger.log(INSTRUMENTATION_NAME, `Handling request.on("data") with maximum body size of ${maxBodySize}b`); const callback = new Proxy(listener, { apply: (target, thisArg, args) => { try { const chunk = args[0] ; const bufferifiedChunk = Buffer.from(chunk); if (bodyByteLength < maxBodySize) { chunks.push(bufferifiedChunk); bodyByteLength += bufferifiedChunk.byteLength; } else if (debugBuild.DEBUG_BUILD) { core$1.logger.log( INSTRUMENTATION_NAME, `Dropping request body chunk because maximum body length of ${maxBodySize}b is exceeded.`, ); } } catch (err) { debugBuild.DEBUG_BUILD && core$1.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) { // Using Buffer.byteLength here, because the body may contain characters that are not 1 byte long const bodyByteLength = Buffer.byteLength(body, 'utf-8'); const truncatedBody = bodyByteLength > maxBodySize ? `${Buffer.from(body) .subarray(0, maxBodySize - 3) .toString('utf-8')}...` : body; isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: truncatedBody } }); } } catch (error) { if (debugBuild.DEBUG_BUILD) { core$1.logger.error(INSTRUMENTATION_NAME, 'Error building captured request body', error); } } }); } catch (error) { if (debugBuild.DEBUG_BUILD) { core$1.logger.error(INSTRUMENTATION_NAME, 'Error patching request to capture body', error); } } } function getRequestOptions(request) { return { method: request.method, protocol: request.protocol, host: request.host, hostname: request.host, path: request.path, headers: request.getHeaders(), }; } /** * 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 = core$1.getClient(); const requestSession = requestIsolationScope.getScopeData().sdkProcessingMetadata.requestSession; if (client && requestSession) { debugBuild.DEBUG_BUILD && core$1.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 { debugBuild.DEBUG_BUILD && core$1.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', () => { debugBuild.DEBUG_BUILD && core$1.logger.debug('Sending request session aggregate due to client flush'); flushPendingClientAggregates(); }); const timeout = setTimeout(() => { debugBuild.DEBUG_BUILD && core$1.logger.debug('Sending request session aggregate due to flushing schedule'); flushPendingClientAggregates(); }, sessionFlushingDelayMS).unref(); } } }); } const clientToRequestSessionAggregatesMap = new Map (); exports.SentryHttpInstrumentation = SentryHttpInstrumentation; exports.recordRequestSession = recordRequestSession; //# sourceMappingURL=SentryHttpInstrumentation.js.map