@sentry/node
Version:
Sentry Node SDK using OpenTelemetry for performance instrumentation
458 lines (383 loc) • 17 kB
JavaScript
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
const api = require('@opentelemetry/api');
const core = require('@opentelemetry/core');
const instrumentation = require('@opentelemetry/instrumentation');
const core$1 = require('@sentry/core');
const debugBuild = require('../../debug-build.js');
const getRequestUrl = require('../../utils/getRequestUrl.js');
const utils = require('./utils.js');
const getRequestInfo = require('./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 instrumentation.InstrumentationBase {
constructor(config = {}) {
super(INSTRUMENTATION_NAME, core.VERSION, config);
}
/** @inheritdoc */
init() {
return [this._getHttpsInstrumentation(), this._getHttpInstrumentation()];
}
/** Get the instrumentation for the http module. */
_getHttpInstrumentation() {
return new instrumentation.InstrumentationNodeModuleDefinition(
'http',
['*'],
(moduleExports) => {
// Patch incoming requests for request isolation
utils.stealthWrap(moduleExports.Server.prototype, 'emit', this._getPatchIncomingRequestFunction());
// Patch outgoing requests for breadcrumbs
const patchedRequest = utils.stealthWrap(moduleExports, 'request', this._getPatchOutgoingRequestFunction());
utils.stealthWrap(moduleExports, 'get', this._getPatchOutgoingGetFunction(patchedRequest));
return moduleExports;
},
() => {
// no unwrap here
},
);
}
/** Get the instrumentation for the https module. */
_getHttpsInstrumentation() {
return new instrumentation.InstrumentationNodeModuleDefinition(
'https',
['*'],
(moduleExports) => {
// Patch incoming requests for request isolation
utils.stealthWrap(moduleExports.Server.prototype, 'emit', this._getPatchIncomingRequestFunction());
// Patch outgoing requests for breadcrumbs
const patchedRequest = utils.stealthWrap(moduleExports, 'request', this._getPatchOutgoingRequestFunction());
utils.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 = 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)) {
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 = 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 original.apply(this, args);
}
const ctx = api.propagation.extract(api.context.active(), normalizedRequest.headers);
return api.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.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.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 = 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) {
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 (debugBuild.DEBUG_BUILD) {
core$1.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 (debugBuild.DEBUG_BUILD) {
core$1.logger.log(
INSTRUMENTATION_NAME,
`Dropping request body chunk because maximum body length of ${MAX_BODY_BYTE_LENGTH}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) {
isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: body } });
}
} 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);
}
}
}
/**
* 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