diff --git a/packages/remix/src/utils/instrumentServer.ts b/packages/remix/src/utils/instrumentServer.ts index 7a1a070bd78a..2e6657b6bac9 100644 --- a/packages/remix/src/utils/instrumentServer.ts +++ b/packages/remix/src/utils/instrumentServer.ts @@ -1,14 +1,7 @@ /* eslint-disable max-lines */ import { captureException, getCurrentHub } from '@sentry/node'; import { getActiveTransaction } from '@sentry/tracing'; -import { - addExceptionMechanism, - fill, - loadModule, - logger, - serializeBaggage, - stripUrlQueryAndFragment, -} from '@sentry/utils'; +import { addExceptionMechanism, fill, loadModule, logger, serializeBaggage } from '@sentry/utils'; // Types vendored from @remix-run/server-runtime@1.6.0: // https://github.com/remix-run/remix/blob/f3691d51027b93caa3fd2cdfe146d7b62a6eb8f2/packages/remix-server-runtime/server.ts @@ -92,6 +85,18 @@ interface DataFunction { (args: DataFunctionArgs): Promise | Response | Promise | AppData; } +interface ReactRouterDomPkg { + matchRoutes: (routes: ServerRoute[], pathname: string) => RouteMatch[] | null; +} + +// Taken from Remix Implementation +// https://github.com/remix-run/remix/blob/97999d02493e8114c39d48b76944069d58526e8d/packages/remix-server-runtime/routeMatching.ts#L6-L10 +export interface RouteMatch { + params: Params; + pathname: string; + route: Route; +} + // Taken from Remix Implementation // https://github.com/remix-run/remix/blob/7688da5c75190a2e29496c78721456d6e12e3abe/packages/remix-server-runtime/responses.ts#L54-L62 // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -262,18 +267,61 @@ function makeWrappedMeta(origMeta: MetaFunction | HtmlMetaDescriptor = {}): Meta }; } -function wrapRequestHandler(origRequestHandler: RequestHandler): RequestHandler { +function createRoutes(manifest: ServerRouteManifest, parentId?: string): ServerRoute[] { + 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: ServerRoute[], + pathname: string, + pkg?: ReactRouterDomPkg, +): RouteMatch[] | null { + 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, + })); +} + +function wrapRequestHandler(origRequestHandler: RequestHandler, build: ServerBuild): RequestHandler { + const routes = createRoutes(build.routes); + const pkg = loadModule('react-router-dom'); return async function (this: unknown, request: Request, loadContext?: unknown): Promise { const hub = getCurrentHub(); const currentScope = hub.getScope(); + + const url = new URL(request.url); + const matches = matchServerRoutes(routes, url.pathname, pkg); + + const match = matches && getRequestMatch(url, matches); + const name = match === null ? url.pathname : match.route.id; + const source = match === null ? 'url' : 'route'; const transaction = hub.startTransaction({ - name: stripUrlQueryAndFragment(request.url), + name, op: 'http.server', tags: { method: request.method, }, metadata: { - source: 'url', + source, }, }); @@ -290,6 +338,33 @@ function wrapRequestHandler(origRequestHandler: RequestHandler): RequestHandler }; } +// https://github.com/remix-run/remix/blob/97999d02493e8114c39d48b76944069d58526e8d/packages/remix-server-runtime/server.ts#L573-L586 +function isIndexRequestUrl(url: URL): boolean { + 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: URL, matches: RouteMatch[]): RouteMatch { + const match = matches.slice(-1)[0]; + + if (!isIndexRequestUrl(url) && match.route.id.endsWith('/index')) { + return matches.slice(-2)[0]; + } + + return match; +} + function makeWrappedCreateRequestHandler( origCreateRequestHandler: CreateRequestHandlerFunction, ): CreateRequestHandlerFunction { @@ -316,9 +391,11 @@ function makeWrappedCreateRequestHandler( routes[id] = wrappedRoute; } - const requestHandler = origCreateRequestHandler.call(this, { ...build, routes, entry: wrappedEntry }, mode); + const newBuild = { ...build, routes, entry: wrappedEntry }; + + const requestHandler = origCreateRequestHandler.call(this, newBuild, mode); - return wrapRequestHandler(requestHandler); + return wrapRequestHandler(requestHandler, newBuild); }; } diff --git a/packages/remix/test/integration/test/server/loader.test.ts b/packages/remix/test/integration/test/server/loader.test.ts index 07cf52213150..6811a4d9550a 100644 --- a/packages/remix/test/integration/test/server/loader.test.ts +++ b/packages/remix/test/integration/test/server/loader.test.ts @@ -8,13 +8,17 @@ describe('Remix API Loaders', () => { const transaction = envelope[2]; assertSentryTransaction(transaction, { + transaction: 'routes/loader-json-response/$id', + transaction_info: { + source: 'route', + }, spans: [ { - description: url, + description: 'routes/loader-json-response/$id', op: 'remix.server.loader', }, { - description: url, + description: 'routes/loader-json-response/$id', op: 'remix.server.documentRequest', }, ],