UNPKG

@sentry/remix

Version:
480 lines (399 loc) 15.5 kB
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