Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🧪 QA (push) Failing after 34s
Build & Deploy / 🏗️ Build (push) Has started running
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
246 lines
8.9 KiB
Plaintext
246 lines
8.9 KiB
Plaintext
import { subscribe } from 'node:diagnostics_channel';
|
|
import { createContextKey, context, propagation } from '@opentelemetry/api';
|
|
import { debug, addNonEnumerableProperty, getClient, getIsolationScope, httpRequestToRequestData, stripUrlQueryAndFragment, withIsolationScope, getCurrentScope, generateSpanId } from '@sentry/core';
|
|
import { DEBUG_BUILD } from '../../debug-build.js';
|
|
import { patchRequestToCaptureBody } from '../../utils/captureRequestBody.js';
|
|
|
|
const HTTP_SERVER_INSTRUMENTED_KEY = createContextKey('sentry_http_server_instrumented');
|
|
const INTEGRATION_NAME = 'Http.Server';
|
|
|
|
const clientToRequestSessionAggregatesMap = new Map
|
|
|
|
();
|
|
|
|
// We keep track of emit functions we wrapped, to avoid double wrapping
|
|
// We do this instead of putting a non-enumerable property on the function, because
|
|
// sometimes the property seems to be migrated to forks of the emit function, which we do not want to happen
|
|
// This was the case in the nestjs-distributed-tracing E2E test
|
|
const wrappedEmitFns = new WeakSet();
|
|
|
|
/**
|
|
* Add a callback to the request object that will be called when the request is started.
|
|
* The callback will receive the next function to continue processing the request.
|
|
*/
|
|
function addStartSpanCallback(request, callback) {
|
|
addNonEnumerableProperty(request, '_startSpanCallback', new WeakRef(callback));
|
|
}
|
|
|
|
const _httpServerIntegration = ((options = {}) => {
|
|
const _options = {
|
|
sessions: options.sessions ?? true,
|
|
sessionFlushingDelayMS: options.sessionFlushingDelayMS ?? 60000,
|
|
maxRequestBodySize: options.maxRequestBodySize ?? 'medium',
|
|
ignoreRequestBody: options.ignoreRequestBody,
|
|
};
|
|
|
|
return {
|
|
name: INTEGRATION_NAME,
|
|
setupOnce() {
|
|
const onHttpServerRequestStart = ((_data) => {
|
|
const data = _data ;
|
|
|
|
instrumentServer(data.server, _options);
|
|
}) ;
|
|
|
|
subscribe('http.server.request.start', onHttpServerRequestStart);
|
|
},
|
|
afterAllSetup(client) {
|
|
if (DEBUG_BUILD && client.getIntegrationByName('Http')) {
|
|
debug.warn(
|
|
'It seems that you have manually added `httpServerIntegration` while `httpIntegration` is also present. Make sure to remove `httpServerIntegration` when adding `httpIntegration`.',
|
|
);
|
|
}
|
|
},
|
|
};
|
|
}) ;
|
|
|
|
/**
|
|
* This integration handles request isolation, trace continuation and other core Sentry functionality around incoming http requests
|
|
* handled via the node `http` module.
|
|
*
|
|
* This version uses OpenTelemetry for context propagation and span management.
|
|
*
|
|
* @see {@link ../../light/integrations/httpServerIntegration.ts} for the lightweight version without OpenTelemetry
|
|
*/
|
|
const httpServerIntegration = _httpServerIntegration
|
|
|
|
;
|
|
|
|
/**
|
|
* Instrument a server to capture incoming requests.
|
|
*
|
|
*/
|
|
function instrumentServer(
|
|
server,
|
|
{
|
|
ignoreRequestBody,
|
|
maxRequestBodySize,
|
|
sessions,
|
|
sessionFlushingDelayMS,
|
|
}
|
|
|
|
,
|
|
) {
|
|
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
const originalEmit = server.emit;
|
|
|
|
if (wrappedEmitFns.has(originalEmit)) {
|
|
return;
|
|
}
|
|
|
|
const newEmit = new Proxy(originalEmit, {
|
|
apply(target, thisArg, args) {
|
|
// Only traces request events
|
|
if (args[0] !== 'request') {
|
|
return target.apply(thisArg, args);
|
|
}
|
|
|
|
const client = getClient();
|
|
|
|
// Make sure we do not double execute our wrapper code, for edge cases...
|
|
// Without this check, if we double-wrap emit, for whatever reason, you'd get two http.server spans (one the children of the other)
|
|
if (context.active().getValue(HTTP_SERVER_INSTRUMENTED_KEY) || !client) {
|
|
return target.apply(thisArg, args);
|
|
}
|
|
|
|
DEBUG_BUILD && debug.log(INTEGRATION_NAME, 'Handling 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 (maxRequestBodySize !== 'none' && !ignoreRequestBody?.(url, request)) {
|
|
patchRequestToCaptureBody(request, isolationScope, maxRequestBodySize, INTEGRATION_NAME);
|
|
}
|
|
|
|
// 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 httpTargetWithoutQueryFragment = stripUrlQueryAndFragment(url);
|
|
|
|
const bestEffortTransactionName = `${httpMethod} ${httpTargetWithoutQueryFragment}`;
|
|
|
|
isolationScope.setTransactionName(bestEffortTransactionName);
|
|
|
|
if (sessions && client) {
|
|
recordRequestSession(client, {
|
|
requestIsolationScope: isolationScope,
|
|
response,
|
|
sessionFlushingDelayMS: 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();
|
|
|
|
const ctx = propagation
|
|
.extract(context.active(), normalizedRequest.headers)
|
|
.setValue(HTTP_SERVER_INSTRUMENTED_KEY, true);
|
|
|
|
return context.with(ctx, () => {
|
|
// This is used (optionally) by the httpServerSpansIntegration to attach _startSpanCallback to the request object
|
|
client.emit('httpServerRequest', request, response, normalizedRequest);
|
|
|
|
const callback = (request )._startSpanCallback?.deref();
|
|
if (callback) {
|
|
return callback(() => target.apply(thisArg, args));
|
|
}
|
|
return target.apply(thisArg, args);
|
|
});
|
|
});
|
|
},
|
|
});
|
|
|
|
wrappedEmitFns.add(newEmit);
|
|
server.emit = newEmit;
|
|
}
|
|
|
|
/**
|
|
* 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(
|
|
client,
|
|
{
|
|
requestIsolationScope,
|
|
response,
|
|
sessionFlushingDelayMS,
|
|
}
|
|
|
|
,
|
|
) {
|
|
requestIsolationScope.setSDKProcessingMetadata({
|
|
requestSession: { status: 'ok' },
|
|
});
|
|
response.once('close', () => {
|
|
const requestSession = requestIsolationScope.getScopeData().sdkProcessingMetadata.requestSession;
|
|
|
|
if (client && requestSession) {
|
|
DEBUG_BUILD && debug.log(`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 && debug.log('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 && debug.log('Sending request session aggregate due to client flush');
|
|
flushPendingClientAggregates();
|
|
});
|
|
const timeout = setTimeout(() => {
|
|
DEBUG_BUILD && debug.log('Sending request session aggregate due to flushing schedule');
|
|
flushPendingClientAggregates();
|
|
}, sessionFlushingDelayMS).unref();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
export { addStartSpanCallback, httpServerIntegration, recordRequestSession };
|
|
//# sourceMappingURL=httpServerIntegration.js.map
|