@sentry/remix
Version:
Official Sentry SDK for Remix
523 lines (428 loc) • 15.9 kB
JavaScript
import { _optionalChain } from '@sentry/utils/esm/buildPolyfills';
import { getActiveTransaction, hasTracingEnabled } from '@sentry/core';
import { getCurrentHub, captureException } from '@sentry/node';
import { fill, loadModule, logger, extractTraceparentData, baggageHeaderToDynamicSamplingContext, addExceptionMechanism, isNodeEnv, dynamicSamplingContextToSentryBaggageHeader } from '@sentry/utils';
import * as domain from 'domain';
import { normalizeRemixRequest } from './web-fetch.js';
/* eslint-disable max-lines */
// Flag to track if the core request handler is instrumented.
let isRequestHandlerWrapped = false;
// Taken from Remix Implementation
// https://github.com/remix-run/remix/blob/32300ec6e6e8025602cea63e17a2201989589eab/packages/remix-server-runtime/responses.ts#L60-L77
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isResponse(value) {
return (
value != null &&
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
typeof value.status === 'number' &&
typeof value.statusText === 'string' &&
typeof value.headers === 'object' &&
typeof value.body !== 'undefined'
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
);
}
const redirectStatusCodes = new Set([301, 302, 303, 307, 308]);
function isRedirectResponse(response) {
return redirectStatusCodes.has(response.status);
}
function isCatchResponse(response) {
return response.headers.get('X-Remix-Catch') != null;
}
// Based on Remix Implementation
// https://github.com/remix-run/remix/blob/7688da5c75190a2e29496c78721456d6e12e3abe/packages/remix-server-runtime/data.ts#L131-L145
async function extractData(response) {
const contentType = response.headers.get('Content-Type');
// Cloning the response to avoid consuming the original body stream
const responseClone = response.clone();
if (contentType && /\bapplication\/json\b/.test(contentType)) {
return responseClone.json();
}
return responseClone.text();
}
async function extractResponseError(response) {
const responseData = await extractData(response);
if (typeof responseData === 'string') {
return responseData;
}
if (response.statusText) {
return response.statusText;
}
return responseData;
}
async function captureRemixServerException(err, name, request) {
// Skip capturing if the thrown error is not a 5xx response
// https://remix.run/docs/en/v1/api/conventions#throwing-responses-in-loaders
if (isResponse(err) && err.status < 500) {
return;
}
let normalizedRequest = request ;
try {
normalizedRequest = normalizeRemixRequest(request );
} catch (e) {
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.warn('Failed to normalize Remix request');
}
captureException(isResponse(err) ? await extractResponseError(err) : err, scope => {
const activeTransactionName = _optionalChain([getActiveTransaction, 'call', _ => _(), 'optionalAccess', _2 => _2.name]);
scope.setSDKProcessingMetadata({
request: {
...normalizedRequest,
// When `route` is not defined, `RequestData` integration uses the full URL
route: activeTransactionName
? {
path: activeTransactionName,
}
: undefined,
},
});
scope.addEventProcessor(event => {
addExceptionMechanism(event, {
type: 'instrument',
handled: true,
data: {
function: name,
},
});
return event;
});
return scope;
});
}
function makeWrappedDocumentRequestFunction(
origDocumentRequestFunction,
) {
return async function (
request,
responseStatusCode,
responseHeaders,
context,
) {
let res;
const activeTransaction = getActiveTransaction();
const currentScope = getCurrentHub().getScope();
if (!currentScope) {
return origDocumentRequestFunction.call(this, request, responseStatusCode, responseHeaders, context);
}
try {
const span = _optionalChain([activeTransaction, 'optionalAccess', _3 => _3.startChild, 'call', _4 => _4({
op: 'function.remix.document_request',
description: activeTransaction.name,
tags: {
method: request.method,
url: request.url,
},
})]);
res = await origDocumentRequestFunction.call(this, request, responseStatusCode, responseHeaders, context);
_optionalChain([span, 'optionalAccess', _5 => _5.finish, 'call', _6 => _6()]);
} catch (err) {
await captureRemixServerException(err, 'documentRequest', request);
throw err;
}
return res;
};
}
function makeWrappedDataFunction(origFn, id, name) {
return async function ( args) {
let res;
const activeTransaction = getActiveTransaction();
const currentScope = getCurrentHub().getScope();
if (!currentScope) {
return origFn.call(this, args);
}
try {
const span = _optionalChain([activeTransaction, 'optionalAccess', _7 => _7.startChild, 'call', _8 => _8({
op: `function.remix.${name}`,
description: id,
tags: {
name,
},
})]);
if (span) {
// Assign data function to hub to be able to see `db` transactions (if any) as children.
currentScope.setSpan(span);
}
res = await origFn.call(this, args);
currentScope.setSpan(activeTransaction);
_optionalChain([span, 'optionalAccess', _9 => _9.finish, 'call', _10 => _10()]);
} catch (err) {
await captureRemixServerException(err, name, args.request);
throw err;
}
return res;
};
}
const makeWrappedAction =
(id) =>
(origAction) => {
return makeWrappedDataFunction(origAction, id, 'action');
};
const makeWrappedLoader =
(id) =>
(origLoader) => {
return makeWrappedDataFunction(origLoader, id, 'loader');
};
function getTraceAndBaggage() {
const transaction = getActiveTransaction();
const currentScope = getCurrentHub().getScope();
if (isNodeEnv() && hasTracingEnabled()) {
if (currentScope) {
const span = currentScope.getSpan();
if (span && transaction) {
const dynamicSamplingContext = transaction.getDynamicSamplingContext();
return {
sentryTrace: span.toTraceparent(),
sentryBaggage: dynamicSamplingContextToSentryBaggageHeader(dynamicSamplingContext),
};
}
}
}
return {};
}
// https://github.com/remix-run/remix/blob/7688da5c75190a2e29496c78721456d6e12e3abe/packages/remix-server-runtime/responses.ts#L1-L4
/**
* This is a shortcut for creating `application/json` responses. Converts `data`
* to JSON and sets the `Content-Type` header.
*
* @see https://remix.run/api/remix#json
*
* https://github.com/remix-run/remix/blob/7688da5c75190a2e29496c78721456d6e12e3abe/packages/remix-server-runtime/responses.ts#L12-L24
*/
const json = (data, init = {}) => {
const responseInit = typeof init === 'number' ? { status: init } : init;
const headers = new Headers(responseInit.headers);
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json; charset=utf-8');
}
return new Response(JSON.stringify(data), {
...responseInit,
headers,
});
};
function makeWrappedRootLoader(origLoader) {
return async function ( args) {
const res = await origLoader.call(this, args);
const traceAndBaggage = getTraceAndBaggage();
// Note: `redirect` and `catch` responses do not have bodies to extract
if (isResponse(res) && !isRedirectResponse(res) && !isCatchResponse(res)) {
const data = await extractData(res);
if (typeof data === 'object') {
return json(
{ ...data, ...traceAndBaggage },
{ headers: res.headers, statusText: res.statusText, status: res.status },
);
} else {
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.warn('Skipping injection of trace and baggage as the response body is not an object');
return res;
}
}
return { ...res, ...traceAndBaggage };
};
}
/**
* Creates routes from the server route manifest
*
* @param manifest
* @param parentId
*/
function createRoutes(manifest, parentId) {
return Object.entries(manifest)
.filter(([, route]) => route.parentId === parentId)
.map(([id, route]) => ({
...route,
children: createRoutes(manifest, id),
}));
}
// Remix Implementation:
// https://github.com/remix-run/remix/blob/38e127b1d97485900b9c220d93503de0deb1fc81/packages/remix-server-runtime/routeMatching.ts#L12-L24
//
// Changed so that `matchRoutes` function is passed in.
function matchServerRoutes(
routes,
pathname,
pkg,
) {
if (!pkg) {
return null;
}
const matches = pkg.matchRoutes(routes, pathname);
if (!matches) {
return null;
}
return matches.map(match => ({
params: match.params,
pathname: match.pathname,
route: match.route,
}));
}
/**
* Starts a new transaction for the given request to be used by different `RequestHandler` wrappers.
*
* @param request
* @param routes
* @param pkg
*/
function startRequestHandlerTransaction(
hub,
name,
source,
request
,
) {
// If there is a trace header set, we extract the data from it (parentSpanId, traceId, and sampling decision)
const traceparentData = extractTraceparentData(request.headers['sentry-trace']);
const dynamicSamplingContext = baggageHeaderToDynamicSamplingContext(request.headers.baggage);
const transaction = hub.startTransaction({
name,
op: 'http.server',
tags: {
method: request.method,
},
...traceparentData,
metadata: {
source,
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
},
});
_optionalChain([hub, 'access', _11 => _11.getScope, 'call', _12 => _12(), 'optionalAccess', _13 => _13.setSpan, 'call', _14 => _14(transaction)]);
return transaction;
}
/**
* Get transaction name from routes and url
*/
function getTransactionName(
routes,
url,
pkg,
) {
const matches = matchServerRoutes(routes, url.pathname, pkg);
const match = matches && getRequestMatch(url, matches);
return match === null ? [url.pathname, 'url'] : [match.route.id, 'route'];
}
function wrapRequestHandler(origRequestHandler, build) {
const routes = createRoutes(build.routes);
const pkg = loadModule('react-router-dom');
return async function ( request, loadContext) {
const local = domain.create();
// Waiting for the next tick to make sure that the domain is active
// https://github.com/nodejs/node/issues/40999#issuecomment-1002719169
await new Promise(resolve => setImmediate(resolve));
return local.bind(async () => {
const hub = getCurrentHub();
const options = _optionalChain([hub, 'access', _15 => _15.getClient, 'call', _16 => _16(), 'optionalAccess', _17 => _17.getOptions, 'call', _18 => _18()]);
const scope = hub.getScope();
let normalizedRequest = request;
try {
normalizedRequest = normalizeRemixRequest(request);
} catch (e) {
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.warn('Failed to normalize Remix request');
}
const url = new URL(request.url);
const [name, source] = getTransactionName(routes, url, pkg);
if (scope) {
scope.setSDKProcessingMetadata({
request: {
...normalizedRequest,
route: {
path: name,
},
},
});
}
if (!options || !hasTracingEnabled(options)) {
return origRequestHandler.call(this, request, loadContext);
}
const transaction = startRequestHandlerTransaction(hub, name, source, {
headers: {
'sentry-trace': request.headers.get('sentry-trace') || '',
baggage: request.headers.get('baggage') || '',
},
method: request.method,
});
const res = (await origRequestHandler.call(this, request, loadContext)) ;
transaction.setHttpStatus(res.status);
transaction.finish();
return res;
})();
};
}
// https://github.com/remix-run/remix/blob/97999d02493e8114c39d48b76944069d58526e8d/packages/remix-server-runtime/server.ts#L573-L586
function isIndexRequestUrl(url) {
for (const param of url.searchParams.getAll('index')) {
// only use bare `?index` params without a value
// ✅ /foo?index
// ✅ /foo?index&index=123
// ✅ /foo?index=123&index
// ❌ /foo?index=123
if (param === '') {
return true;
}
}
return false;
}
// https://github.com/remix-run/remix/blob/97999d02493e8114c39d48b76944069d58526e8d/packages/remix-server-runtime/server.ts#L588-L596
function getRequestMatch(url, matches) {
const match = matches.slice(-1)[0];
if (!isIndexRequestUrl(url) && match.route.id.endsWith('/index')) {
return matches.slice(-2)[0];
}
return match;
}
/**
* Instruments `remix` ServerBuild for performance tracing and error tracking.
*/
function instrumentBuild(build) {
const routes = {};
const wrappedEntry = { ...build.entry, module: { ...build.entry.module } };
// Not keeping boolean flags like it's done for `requestHandler` functions,
// Because the build can change between build and runtime.
// So if there is a new `loader` or`action` or `documentRequest` after build.
// We should be able to wrap them, as they may not be wrapped before.
if (!(wrappedEntry.module.default ).__sentry_original__) {
fill(wrappedEntry.module, 'default', makeWrappedDocumentRequestFunction);
}
for (const [id, route] of Object.entries(build.routes)) {
const wrappedRoute = { ...route, module: { ...route.module } };
if (wrappedRoute.module.action && !(wrappedRoute.module.action ).__sentry_original__) {
fill(wrappedRoute.module, 'action', makeWrappedAction(id));
}
if (wrappedRoute.module.loader && !(wrappedRoute.module.loader ).__sentry_original__) {
fill(wrappedRoute.module, 'loader', makeWrappedLoader(id));
}
// Entry module should have a loader function to provide `sentry-trace` and `baggage`
// They will be available for the root `meta` function as `data.sentryTrace` and `data.sentryBaggage`
if (!wrappedRoute.parentId) {
if (!wrappedRoute.module.loader) {
wrappedRoute.module.loader = () => ({});
}
// We want to wrap the root loader regardless of whether it's already wrapped before.
fill(wrappedRoute.module, 'loader', makeWrappedRootLoader);
}
routes[id] = wrappedRoute;
}
return { ...build, routes, entry: wrappedEntry };
}
function makeWrappedCreateRequestHandler(
origCreateRequestHandler,
) {
// To track if this wrapper has been applied, before other wrappers.
// Can't track `__sentry_original__` because it's not the same function as the potentially manually wrapped one.
isRequestHandlerWrapped = true;
return function ( build, ...args) {
const newBuild = instrumentBuild(build);
const requestHandler = origCreateRequestHandler.call(this, newBuild, ...args);
return wrapRequestHandler(requestHandler, newBuild);
};
}
/**
* Monkey-patch Remix's `createRequestHandler` from `@remix-run/server-runtime`
* which Remix Adapters (https://remix.run/docs/en/v1/api/remix) use underneath.
*/
function instrumentServer() {
const pkg = loadModule('@remix-run/server-runtime');
if (!pkg) {
(typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.warn('Remix SDK was unable to require `@remix-run/server-runtime` package.');
return;
}
fill(pkg, 'createRequestHandler', makeWrappedCreateRequestHandler);
}
export { createRoutes, getTransactionName, instrumentBuild, instrumentServer, isRequestHandlerWrapped, startRequestHandlerTransaction };
//# sourceMappingURL=instrumentServer.js.map