UNPKG

@sentry/remix

Version:
524 lines (429 loc) • 16 kB
import { _optionalChain } from '@sentry/utils/esm/buildPolyfills'; import { hasTracingEnabled } from '@sentry/core'; import { getCurrentHub, captureException } from '@sentry/node'; import { getActiveTransaction } from '@sentry/tracing'; 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