Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ test.describe('Trace propagation', () => {

test('should have trace connection', async ({ page }) => {
const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => {
return transactionEvent.transaction === 'GET *';
return transactionEvent.transaction === 'GET /';
});

const clientTxPromise = waitForTransaction(APP_NAME, async transactionEvent => {
Expand Down
5 changes: 5 additions & 0 deletions packages/react-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
"./package.json": "./package.json",
".": {
"types": "./build/types/index.types.d.ts",
"workerd": {
"import": "./build/esm/cloudflare/index.js",
"require": "./build/cjs/cloudflare/index.js",
"default": "./build/esm/cloudflare/index.js"
},
"browser": {
"import": "./build/esm/index.client.js",
"require": "./build/cjs/index.client.js"
Expand Down
5 changes: 5 additions & 0 deletions packages/react-router/src/cloudflare/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getTraceMetaTags } from '@sentry/core';
import { isPrerenderRequest } from '../server/serverBuild';

export * from '../client';

Expand All @@ -11,6 +12,10 @@ export { wrapSentryHandleRequest } from '../server/wrapSentryHandleRequest';
* @returns A new ReadableStream with Sentry trace meta tags injected into the head section
*/
export function injectTraceMetaTags(body: ReadableStream): ReadableStream {
if (isPrerenderRequest()) {
return body;
}

const headClosingTag = '</head>';

const reader = body.getReader();
Expand Down
31 changes: 29 additions & 2 deletions packages/react-router/src/server/serverBuild.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { GLOBAL_OBJ } from '@sentry/core';
import { getIsolationScope, GLOBAL_OBJ, parseStringToURLObject } from '@sentry/core';

/**
* Subset of ServerBuild shape for middleware name lookup.
* Subset of ServerBuild shape for middleware name lookup and prerender detection.
* The official React Router types don't expose `middleware` on route modules yet.
* @internal
*/
Expand All @@ -14,6 +14,8 @@ interface ServerBuildLike {
};
}
>;
/** The paths React Router prerenders to static HTML at build time. */
prerender?: string[];
}

/** @internal */
Expand Down Expand Up @@ -53,6 +55,31 @@ export function getMiddlewareName(routeId: string, index: number): string | unde
return middlewareFn?.name || undefined;
}

function withoutTrailingSlash(path: string): string {
return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path;
}

/**
* Whether the current request renders a path from the build's `prerender` list.
*
* In production these paths are served as static files and never reach the request handler, so a
* render of one is the build-time prerender. Trace meta tags must not be written into that HTML:
* every visitor of the static page would continue the same trace.
*
* @internal
*/
export function isPrerenderRequest(): boolean {
const prerender = _serverBuild?.prerender;
if (!prerender?.length) return false;

const url = getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest?.url;
const pathname = url ? parseStringToURLObject(url)?.pathname : undefined;
if (!pathname) return false;

const requestPath = withoutTrailingSlash(pathname);
return prerender.some(path => withoutTrailingSlash(path) === requestPath);
}

/** @internal */
export function registerServerBuildGlobal(): void {
(GLOBAL_OBJ as GlobalObjWithBuildCapture)[GLOBAL_KEY] = setServerBuild;
Expand Down
13 changes: 11 additions & 2 deletions packages/react-router/src/server/wrapSentryHandleRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '@sentry/core';
import { flushIfServerless } from '@sentry/core/server';
import type { AppLoadContext, EntryContext, RouterContextProvider } from 'react-router';
import { registerServerBuildGlobal } from './serverBuild';
import { isInstrumentationApiUsed } from './serverGlobals';

type OriginalHandleRequestWithoutMiddleware = (
Expand Down Expand Up @@ -53,15 +54,23 @@ export function wrapSentryHandleRequest(
export function wrapSentryHandleRequest(
originalHandle: OriginalHandleRequestWithoutMiddleware | OriginalHandleRequestWithMiddleware,
): OriginalHandleRequestWithoutMiddleware | OriginalHandleRequestWithMiddleware {
// `entry.server` is evaluated before the server build module, so the build's capture call at the
// end of that module finds this. Runtimes without the Node server integration (Cloudflare) only
// register it here.
registerServerBuildGlobal();

return async function sentryInstrumentedHandleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext | RouterContextProvider,
) {
const parameterizedPath =
routerContext?.staticHandlerContext?.matches?.[routerContext.staticHandlerContext.matches.length - 1]?.route.path;
const matches = routerContext?.staticHandlerContext?.matches;
// An index route has no `path` of its own and renders at its nearest ancestor's path, or at `/`.
const parameterizedPath = matches?.length
? ([...matches].reverse().find(match => match.route.path)?.route.path ?? '/')
: undefined;

const activeSpan = getActiveSpan();
const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ export function makeServerBuildCapturePlugin(): Plugin {
return null;
}

// `routes` is a module-scope export in the virtual:react-router/server-build module
// `routes` and `prerender` are module-scope exports in the virtual:react-router/server-build
// module. `prerender` is guarded with `typeof` because older React Router versions don't declare it.
const injectedCode = `${code}
if (typeof globalThis !== 'undefined' && typeof globalThis["${GLOBAL_KEY}"] === 'function') {
globalThis["${GLOBAL_KEY}"]({ routes });
globalThis["${GLOBAL_KEY}"]({ routes, prerender: typeof prerender !== 'undefined' ? prerender : undefined });
}
`;

Expand Down
35 changes: 34 additions & 1 deletion packages/react-router/test/server/serverBuild.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { GLOBAL_OBJ } from '@sentry/core';
import { getIsolationScope, GLOBAL_OBJ } from '@sentry/core';
import { afterEach, describe, expect, it } from 'vitest';
import {
_resetServerBuild,
getMiddlewareName,
isPrerenderRequest,
isServerBuildLike,
registerServerBuildGlobal,
setServerBuild,
Expand Down Expand Up @@ -61,6 +62,38 @@ describe('serverBuild', () => {
});
});

describe('isPrerenderRequest', () => {
afterEach(() => {
getIsolationScope().setSDKProcessingMetadata({ normalizedRequest: undefined });
});

it('is true when the current request path is in the prerender list, with or without a trailing slash', () => {
setServerBuild({ routes: {}, prerender: ['/performance/static', '/about/'] });

getIsolationScope().setSDKProcessingMetadata({
normalizedRequest: { url: 'http://localhost/performance/static/' },
});
expect(isPrerenderRequest()).toBe(true);

getIsolationScope().setSDKProcessingMetadata({ normalizedRequest: { url: 'http://localhost/about' } });
expect(isPrerenderRequest()).toBe(true);
});

it('is false for other paths, without a prerender list, or without a request', () => {
setServerBuild({ routes: {}, prerender: ['/performance/static'] });
expect(isPrerenderRequest()).toBe(false);

getIsolationScope().setSDKProcessingMetadata({ normalizedRequest: { url: 'http://localhost/performance' } });
expect(isPrerenderRequest()).toBe(false);

setServerBuild({ routes: {} });
getIsolationScope().setSDKProcessingMetadata({
normalizedRequest: { url: 'http://localhost/performance/static' },
});
expect(isPrerenderRequest()).toBe(false);
});
});

describe('registerServerBuildGlobal', () => {
it('should register a global callback that calls setServerBuild', () => {
registerServerBuildGlobal();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,45 @@ describe('wrapSentryHandleRequest', () => {
});
});

test('should name an index route after its nearest ancestor path, or `/`', async () => {
const mockRootSpan = { setAttributes: vi.fn() };

(getActiveSpan as unknown as ReturnType<typeof vi.fn>).mockReturnValue({});
(getRootSpan as unknown as ReturnType<typeof vi.fn>).mockReturnValue(mockRootSpan);

const wrappedHandler = wrapSentryHandleRequest(vi.fn().mockResolvedValue('test'));

const rootIndexContext = {
staticHandlerContext: { matches: [{ route: { path: '' } }, { route: { index: true } }] },
} as any;
await wrappedHandler(new Request('https://nacho.queso/'), 200, new Headers(), rootIndexContext, {} as any);

expect(mockRootSpan.setAttributes).toHaveBeenLastCalledWith({
[HTTP_ROUTE]: '/',
[SENTRY_SEGMENT_NAME_SOURCE]: 'route',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.request_handler',
});

const nestedIndexContext = {
staticHandlerContext: {
matches: [{ route: { path: '' } }, { route: { path: 'dashboard' } }, { route: { index: true } }],
},
} as any;
await wrappedHandler(
new Request('https://nacho.queso/dashboard'),
200,
new Headers(),
nestedIndexContext,
{} as any,
);

expect(mockRootSpan.setAttributes).toHaveBeenLastCalledWith({
[HTTP_ROUTE]: '/dashboard',
[SENTRY_SEGMENT_NAME_SOURCE]: 'route',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.request_handler',
});
});

test('should not set span attributes when parameterized path does not exist', async () => {
const mockActiveSpan = {};
const mockRootSpan = { setAttributes: vi.fn() };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('makeServerBuildCapturePlugin', () => {
expect(result).not.toBeNull();
expect(result.code).toContain(SERVER_BUILD_CODE);
expect(result.code).toContain('__sentrySetServerBuild');
expect(result.code).toContain('({ routes })');
expect(result.code).toContain("({ routes, prerender: typeof prerender !== 'undefined' ? prerender : undefined })");
expect(result.map).toBeNull();
});
});
Loading