@sentry/remix
Version:
Official Sentry SDK for Remix
480 lines (399 loc) • 15.5 kB
JavaScript
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
const router = require('@remix-run/router');
const core = require('@sentry/core');
const debugBuild = require('../utils/debug-build.js');
const utils = require('../utils/utils.js');
const response = require('../utils/vendor/response.js');
const errors = require('./errors.js');
const serverTimingTracePropagation = require('./serverTimingTracePropagation.js');
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;
}
/**
* Sentry utility to be used in place of `handleError` function of Remix v2
* Remix Docs: https://remix.run/docs/en/main/file-conventions/entry.server#handleerror
*
* Should be used in `entry.server` like:
*
* export const handleError = Sentry.sentryHandleError
*/
function sentryHandleError(err, { request }) {
// We are skipping thrown responses here as they are handled by
// `captureRemixServerException` at loader / action level
// We don't want to capture them twice.
// This function is only for capturing unhandled server-side exceptions.
// https://remix.run/docs/en/main/file-conventions/entry.server#thrown-responses
if (response.isResponse(err) || router.isRouteErrorResponse(err)) {
return;
}
errors.captureRemixServerException(err, 'remix.server.handleError', request).then(null, e => {
debugBuild.DEBUG_BUILD && core.debug.warn('Failed to capture Remix Server exception.', e);
});
}
/**
* Sentry wrapper for Remix's `handleError` function.
* Remix Docs: https://remix.run/docs/en/main/file-conventions/entry.server#handleerror
*/
function wrapHandleErrorWithSentry(
origHandleError,
) {
return function ( err, args) {
// This is expected to be void but just in case it changes in the future.
const res = origHandleError.call(this, err, args);
sentryHandleError(err, args );
return res;
};
}
function getTraceAndBaggage()
{
if (core.isNodeEnv() || utils.isCloudflareEnv()) {
const traceData = core.getTraceData();
return {
sentryTrace: traceData['sentry-trace'],
sentryBaggage: traceData.baggage,
};
}
return {};
}
function makeWrappedDocumentRequestFunction(instrumentTracing) {
return function (origDocumentRequestFunction) {
return async function ( request, ...args) {
const serverTimingHeader = serverTimingTracePropagation.generateSentryServerTimingHeader();
let response;
if (instrumentTracing) {
const activeSpan = core.getActiveSpan();
const rootSpan = activeSpan && core.getRootSpan(activeSpan);
const name = rootSpan ? core.spanToJSON(rootSpan).description : undefined;
response = await core.startSpan(
{
// If we don't have a root span, `onlyIfParent` will lead to the span not being created anyhow
// So we don't need to care too much about the fallback name, it's just for typing purposes....
name: name || '<unknown>',
onlyIfParent: true,
attributes: {
method: request.method,
url: request.url,
[core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.remix',
[core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.remix.document_request',
},
},
() => {
return origDocumentRequestFunction.call(this, request, ...args);
},
);
} else {
response = await origDocumentRequestFunction.call(this, request, ...args);
}
if (serverTimingHeader && response instanceof Response) {
return serverTimingTracePropagation.injectServerTimingHeaderValue(response, serverTimingHeader);
}
return response;
};
};
}
/**
* Updates the root span name with the parameterized route name.
* This is necessary for runtimes like Cloudflare Workers/Hydrogen where
* the request handler is not wrapped by Remix's wrapRequestHandler.
*/
function updateSpanWithRoute(args, build) {
try {
const activeSpan = core.getActiveSpan();
const rootSpan = activeSpan && core.getRootSpan(activeSpan);
if (!rootSpan) {
return;
}
const routes = utils.createRoutes(build.routes);
const url = new URL(args.request.url);
const [transactionName] = utils.getTransactionName(routes, url);
// Preserve the HTTP method prefix if the span already has one
const method = args.request.method.toUpperCase();
const currentSpanName = core.spanToJSON(rootSpan).description;
const newSpanName = currentSpanName?.startsWith(method) ? `${method} ${transactionName}` : transactionName;
rootSpan.updateName(newSpanName);
} catch (e) {
debugBuild.DEBUG_BUILD && core.debug.warn('Failed to update span name with route', e);
}
}
function makeWrappedDataFunction(
origFn,
id,
name,
instrumentTracing,
build,
) {
return async function ( args) {
let res;
if (instrumentTracing) {
// Update span name for Cloudflare Workers/Hydrogen environments
if (build) {
updateSpanWithRoute(args, build);
}
res = await core.startSpan(
{
op: `function.remix.${name}`,
name: id,
attributes: {
[core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.remix',
[core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: `function.remix.${name}`,
name,
},
},
(span) => {
return errors.errorHandleDataFunction.call(this, origFn, name, args, span);
},
);
} else {
res = await errors.errorHandleDataFunction.call(this, origFn, name, args);
}
// Redirects bypass makeWrappedDocumentRequestFunction, so we inject Server-Timing here.
if (response.isResponse(res) && isRedirectResponse(res)) {
const serverTimingHeader = serverTimingTracePropagation.generateSentryServerTimingHeader();
if (serverTimingHeader) {
return serverTimingTracePropagation.injectServerTimingHeaderValue(res, serverTimingHeader);
}
}
return res;
};
}
const makeWrappedAction =
(id, instrumentTracing, build) =>
(origAction) => {
return makeWrappedDataFunction(origAction, id, 'action', instrumentTracing, build);
};
const makeWrappedLoader =
(id, instrumentTracing, build) =>
(origLoader) => {
return makeWrappedDataFunction(origLoader, id, 'loader', instrumentTracing, build);
};
function makeWrappedRootLoader(instrumentTracing, build) {
return function (origLoader) {
return async function ( args) {
// Update span name for Cloudflare Workers/Hydrogen environments
// The root loader always runs, even for routes that don't have their own loaders
if (instrumentTracing && build) {
updateSpanWithRoute(args, build);
}
const res = await origLoader.call(this, args);
const traceAndBaggage = getTraceAndBaggage();
if (router.isDeferredData(res)) {
res.data['sentryTrace'] = traceAndBaggage.sentryTrace;
res.data['sentryBaggage'] = traceAndBaggage.sentryBaggage;
return res;
}
if (response.isResponse(res)) {
// Note: `redirect` and `catch` responses do not have bodies to extract.
// We skip injection of trace and baggage in those cases.
// For `redirect`, a valid internal redirection target will have the trace and baggage injected.
if (isRedirectResponse(res) || isCatchResponse(res)) {
debugBuild.DEBUG_BUILD && core.debug.warn('Skipping injection of trace and baggage as the response does not have a body');
return res;
} else {
const data = await response.extractData(res);
if (typeof data === 'object') {
return response.json(
{ ...data, ...traceAndBaggage },
{
headers: res.headers,
statusText: res.statusText,
status: res.status,
},
);
} else {
debugBuild.DEBUG_BUILD && core.debug.warn('Skipping injection of trace and baggage as the response body is not an object');
return res;
}
}
}
return { ...res, ...traceAndBaggage };
};
};
}
function wrapRequestHandler(
origRequestHandler,
build,
options
,
) {
let resolvedBuild;
let name;
let source;
return async function ( request, loadContext) {
const upperCaseMethod = request.method.toUpperCase();
// We don't want to wrap OPTIONS and HEAD requests
if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') {
return origRequestHandler.call(this, request, loadContext);
}
let resolvedRoutes;
if (options?.instrumentTracing) {
if (typeof build === 'function') {
resolvedBuild = await build();
} else {
resolvedBuild = build;
}
// check if the build is nested under `build` key
if ('build' in resolvedBuild) {
resolvedRoutes = utils.createRoutes((resolvedBuild.build ).routes);
} else {
resolvedRoutes = utils.createRoutes(resolvedBuild.routes);
}
}
return core.withIsolationScope(async isolationScope => {
const clientOptions = core.getClient()?.getOptions();
let normalizedRequest = {};
try {
normalizedRequest = core.winterCGRequestToRequestData(request);
} catch {
debugBuild.DEBUG_BUILD && core.debug.warn('Failed to normalize Remix request');
}
if (options?.instrumentTracing && resolvedRoutes) {
const url = new URL(request.url);
[name, source] = utils.getTransactionName(resolvedRoutes, url);
isolationScope.setTransactionName(name);
// Update the span name if we're running inside an existing span
const parentSpan = core.getActiveSpan();
if (parentSpan) {
const rootSpan = core.getRootSpan(parentSpan);
rootSpan?.updateName(name);
}
}
isolationScope.setSDKProcessingMetadata({ normalizedRequest });
if (!clientOptions || !core.hasSpansEnabled(clientOptions)) {
return origRequestHandler.call(this, request, loadContext);
}
return core.continueTrace(
{
sentryTrace: request.headers.get('sentry-trace') || '',
baggage: request.headers.get('baggage') || '',
},
async () => {
if (options?.instrumentTracing) {
const parentSpan = core.getActiveSpan();
const rootSpan = parentSpan && core.getRootSpan(parentSpan);
rootSpan?.updateName(name);
return core.startSpan(
{
name,
attributes: {
[core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.remix',
[core.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
[core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
method: request.method,
...core.httpHeadersToSpanAttributes(
core.winterCGHeadersToDict(request.headers),
clientOptions.sendDefaultPii ?? false,
),
},
},
async span => {
const res = (await origRequestHandler.call(this, request, loadContext)) ;
if (response.isResponse(res)) {
core.setHttpStatus(span, res.status);
}
return res;
},
);
}
return (await origRequestHandler.call(this, request, loadContext)) ;
},
);
});
};
}
function instrumentBuildCallback(
build,
options
,
) {
const routes = build.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.
const defaultExport = wrappedEntry.module.default ;
if (defaultExport && !defaultExport.__sentry_original__) {
core.fill(wrappedEntry.module, 'default', makeWrappedDocumentRequestFunction(options?.instrumentTracing));
}
for (const [id, route] of Object.entries(build.routes)) {
const wrappedRoute = { ...route, module: { ...route.module } };
// 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 = () => ({});
}
if (!(wrappedRoute.module.loader ).__sentry_original__) {
core.fill(wrappedRoute.module, 'loader', makeWrappedRootLoader(options?.instrumentTracing, build));
}
}
const routeAction = wrappedRoute.module.action ;
if (routeAction && !routeAction.__sentry_original__) {
core.fill(wrappedRoute.module, 'action', makeWrappedAction(id, options?.instrumentTracing, build));
}
const routeLoader = wrappedRoute.module.loader ;
if (routeLoader && !routeLoader.__sentry_original__) {
core.fill(wrappedRoute.module, 'loader', makeWrappedLoader(id, options?.instrumentTracing, build));
}
routes[id] = wrappedRoute;
}
const instrumentedBuild = { ...build, routes };
if (wrappedEntry) {
instrumentedBuild.entry = wrappedEntry;
}
return instrumentedBuild;
}
/**
* Instruments `remix` ServerBuild for performance tracing and error tracking.
*/
function instrumentBuild(
build,
options
,
) {
if (typeof build === 'function') {
return function () {
const resolvedBuild = build();
if (resolvedBuild instanceof Promise) {
return resolvedBuild.then(build => {
return instrumentBuildCallback(build, options);
});
} else {
return instrumentBuildCallback(resolvedBuild, options);
}
} ;
} else {
return instrumentBuildCallback(build, options) ;
}
}
const makeWrappedCreateRequestHandler = (options) =>
function (origCreateRequestHandler) {
return function ( build, ...args) {
const newBuild = instrumentBuild(build, options);
const requestHandler = origCreateRequestHandler.call(this, newBuild, ...args);
return wrapRequestHandler(requestHandler, newBuild, options);
};
};
/**
* 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(options) {
const pkg = core.loadModule
('@remix-run/server-runtime', module);
if (!pkg) {
debugBuild.DEBUG_BUILD && core.debug.warn('Remix SDK was unable to require `@remix-run/server-runtime` package.');
return;
}
core.fill(pkg, 'createRequestHandler', makeWrappedCreateRequestHandler(options));
}
exports.instrumentBuild = instrumentBuild;
exports.instrumentServer = instrumentServer;
exports.makeWrappedCreateRequestHandler = makeWrappedCreateRequestHandler;
exports.sentryHandleError = sentryHandleError;
exports.wrapHandleErrorWithSentry = wrapHandleErrorWithSentry;
//# sourceMappingURL=instrumentServer.js.map