diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts index 1d8ab1b24a74..c1aacf4e5ce2 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts @@ -15,5 +15,6 @@ export default [ route('ssr', 'routes/performance/ssr.tsx'), route('with/:param', 'routes/performance/dynamic-param.tsx'), route('static', 'routes/performance/static.tsx'), + route('server-loader', 'routes/performance/server-loader.tsx'), ]), ] satisfies RouteConfig; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/dynamic-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/dynamic-param.tsx index 39cf7bd5dbf6..1ac02775f2ff 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/dynamic-param.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/dynamic-param.tsx @@ -1,5 +1,10 @@ import type { Route } from './+types/dynamic-param'; +export async function loader() { + await new Promise(resolve => setTimeout(resolve, 500)); + return { data: 'burritos' }; +} + export default function DynamicParamPage({ params }: Route.ComponentProps) { const { param } = params; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx index 99086aadfeae..e5383306625a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx @@ -7,6 +7,7 @@ export default function PerformancePage() { ); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/server-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/server-loader.tsx new file mode 100644 index 000000000000..e5c222ff4c05 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/server-loader.tsx @@ -0,0 +1,16 @@ +import type { Route } from './+types/server-loader'; + +export async function loader() { + await new Promise(resolve => setTimeout(resolve, 500)); + return { data: 'burritos' }; +} + +export default function ServerLoaderPage({ loaderData }: Route.ComponentProps) { + const { data } = loaderData; + return ( +
+

Server Loader Page

+
{data}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts index 4f570beca144..36e37f1ff288 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts @@ -104,4 +104,32 @@ test.describe('servery - performance', () => { }, }); }); + + test('should automatically instrument server loader', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance/server-loader.data'; + }); + + await page.goto(`/performance`); // initial ssr pageloads do not contain .data requests + await page.waitForTimeout(500); // quick breather before navigation + await page.getByRole('link', { name: 'Server Loader' }).click(); // this will actually trigger a .data request + + const transaction = await txPromise; + + expect(transaction?.spans?.[transaction.spans?.length - 1]).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.http.react-router', + 'sentry.op': 'function.react-router.loader', + }, + description: 'Executing Server Loader', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + op: 'function.react-router.loader', + origin: 'auto.http.react-router', + }); + }); }); diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 3f5cfdcd3011..356affde67c8 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -37,6 +37,7 @@ "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1", "@opentelemetry/semantic-conventions": "^1.30.0", + "@opentelemetry/instrumentation": "0.57.2", "@sentry/browser": "9.18.0", "@sentry/cli": "^2.43.0", "@sentry/core": "9.18.0", diff --git a/packages/react-router/src/server/instrumentation/reactRouter.ts b/packages/react-router/src/server/instrumentation/reactRouter.ts new file mode 100644 index 000000000000..5bfc0b62e352 --- /dev/null +++ b/packages/react-router/src/server/instrumentation/reactRouter.ts @@ -0,0 +1,111 @@ +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; +import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; +import { + getActiveSpan, + getRootSpan, + logger, + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + startSpan, +} from '@sentry/core'; +import type * as reactRouter from 'react-router'; +import { DEBUG_BUILD } from '../../common/debug-build'; +import { getOpName, getSpanName, isDataRequest, SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE } from './util'; + +type ReactRouterModuleExports = typeof reactRouter; + +const supportedVersions = ['>=7.0.0']; +const COMPONENT = 'react-router'; + +/** + * Instrumentation for React Router's server request handler. + * This patches the requestHandler function to add Sentry performance monitoring for data loaders. + */ +export class ReactRouterInstrumentation extends InstrumentationBase { + public constructor(config: InstrumentationConfig = {}) { + super('ReactRouterInstrumentation', SDK_VERSION, config); + } + + /** + * Initializes the instrumentation by defining the React Router server modules to be patched. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected init(): InstrumentationNodeModuleDefinition { + const reactRouterServerModule = new InstrumentationNodeModuleDefinition( + COMPONENT, + supportedVersions, + (moduleExports: ReactRouterModuleExports) => { + return this._createPatchedModuleProxy(moduleExports); + }, + (_moduleExports: unknown) => { + // nothing to unwrap here + return _moduleExports; + }, + ); + + return reactRouterServerModule; + } + + /** + * Creates a proxy around the React Router module exports that patches the createRequestHandler function. + * This allows us to wrap the request handler to add performance monitoring for data loaders and actions. + */ + private _createPatchedModuleProxy(moduleExports: ReactRouterModuleExports): ReactRouterModuleExports { + return new Proxy(moduleExports, { + get(target, prop, receiver) { + if (prop === 'createRequestHandler') { + const original = target[prop]; + return function sentryWrappedCreateRequestHandler(this: unknown, ...args: unknown[]) { + const originalRequestHandler = original.apply(this, args); + + return async function sentryWrappedRequestHandler(request: Request, initialContext?: unknown) { + let url: URL; + try { + url = new URL(request.url); + } catch (error) { + return originalRequestHandler(request, initialContext); + } + + // We currently just want to trace loaders and actions + if (!isDataRequest(url.pathname)) { + return originalRequestHandler(request, initialContext); + } + + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan && getRootSpan(activeSpan); + + if (!rootSpan) { + DEBUG_BUILD && logger.debug('No active root span found, skipping tracing for data request'); + return originalRequestHandler(request, initialContext); + } + + // Set the source and overwrite attributes on the root span to ensure the transaction name + // is derived from the raw URL pathname rather than any parameterized route that may be set later + // TODO: try to set derived parameterized route from build here (args[0]) + rootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE]: `${request.method} ${url.pathname}`, + }); + + return startSpan( + { + name: getSpanName(url.pathname, request.method), + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: getOpName(url.pathname, request.method), + }, + }, + () => { + return originalRequestHandler(request, initialContext); + }, + ); + }; + }; + } + return Reflect.get(target, prop, receiver); + }, + }); + } +} diff --git a/packages/react-router/src/server/instrumentation/util.ts b/packages/react-router/src/server/instrumentation/util.ts new file mode 100644 index 000000000000..19aec91999fc --- /dev/null +++ b/packages/react-router/src/server/instrumentation/util.ts @@ -0,0 +1,53 @@ +/** + * Gets the op name for a request based on whether it's a loader or action request. + * @param pathName The URL pathname to check + * @param requestMethod The HTTP request method + */ +export function getOpName(pathName: string, requestMethod: string): string { + return isLoaderRequest(pathName, requestMethod) + ? 'function.react-router.loader' + : isActionRequest(pathName, requestMethod) + ? 'function.react-router.action' + : 'function.react-router'; +} + +/** + * Gets the span name for a request based on whether it's a loader or action request. + * @param pathName The URL pathname to check + * @param requestMethod The HTTP request method + */ +export function getSpanName(pathName: string, requestMethod: string): string { + return isLoaderRequest(pathName, requestMethod) + ? 'Executing Server Loader' + : isActionRequest(pathName, requestMethod) + ? 'Executing Server Action' + : 'Unknown Data Request'; +} + +/** + * Checks if the request is a server loader request + * @param pathname The URL pathname to check + * @param requestMethod The HTTP request method + */ +export function isLoaderRequest(pathname: string, requestMethod: string): boolean { + return isDataRequest(pathname) && requestMethod === 'GET'; +} + +/** + * Checks if the request is a server action request + * @param pathname The URL pathname to check + * @param requestMethod The HTTP request method + */ +export function isActionRequest(pathname: string, requestMethod: string): boolean { + return isDataRequest(pathname) && requestMethod === 'POST'; +} + +/** + * Checks if the request is a react-router data request + * @param pathname The URL pathname to check + */ +export function isDataRequest(pathname: string): boolean { + return pathname.endsWith('.data'); +} + +export const SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE = 'sentry.overwrite-route'; diff --git a/packages/react-router/src/server/integration/reactRouterServer.ts b/packages/react-router/src/server/integration/reactRouterServer.ts new file mode 100644 index 000000000000..548b21f6f039 --- /dev/null +++ b/packages/react-router/src/server/integration/reactRouterServer.ts @@ -0,0 +1,28 @@ +import { defineIntegration } from '@sentry/core'; +import { generateInstrumentOnce } from '@sentry/node'; +import { ReactRouterInstrumentation } from '../instrumentation/reactRouter'; + +const INTEGRATION_NAME = 'ReactRouterServer'; + +const instrumentReactRouter = generateInstrumentOnce('React-Router-Server', () => { + return new ReactRouterInstrumentation(); +}); + +export const instrumentReactRouterServer = Object.assign( + (): void => { + instrumentReactRouter(); + }, + { id: INTEGRATION_NAME }, +); + +/** + * Integration capturing tracing data for React Router server functions. + */ +export const reactRouterServerIntegration = defineIntegration(() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + instrumentReactRouterServer(); + }, + }; +}); diff --git a/packages/react-router/src/server/sdk.ts b/packages/react-router/src/server/sdk.ts index c980078ac7b5..b0ca0e79bd49 100644 --- a/packages/react-router/src/server/sdk.ts +++ b/packages/react-router/src/server/sdk.ts @@ -1,21 +1,32 @@ -import type { Integration } from '@sentry/core'; -import { applySdkMetadata, logger, setTag } from '@sentry/core'; +import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; +import type { EventProcessor, Integration } from '@sentry/core'; +import { applySdkMetadata, getGlobalScope, logger, setTag } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrations as getNodeDefaultIntegrations, init as initNodeSdk } from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE } from './instrumentation/util'; +import { reactRouterServerIntegration } from './integration/reactRouterServer'; import { lowQualityTransactionsFilterIntegration } from './lowQualityTransactionsFilterIntegration'; -function getDefaultIntegrations(options: NodeOptions): Integration[] { - return [...getNodeDefaultIntegrations(options), lowQualityTransactionsFilterIntegration(options)]; +/** + * Returns the default integrations for the React Router SDK. + * @param options The options for the SDK. + */ +export function getDefaultReactRouterServerIntegrations(options: NodeOptions): Integration[] { + return [ + ...getNodeDefaultIntegrations(options), + lowQualityTransactionsFilterIntegration(options), + reactRouterServerIntegration(), + ]; } /** * Initializes the server side of the React Router SDK */ export function init(options: NodeOptions): NodeClient | undefined { - const opts = { + const opts: NodeOptions = { ...options, - defaultIntegrations: getDefaultIntegrations(options), + defaultIntegrations: getDefaultReactRouterServerIntegrations(options), }; DEBUG_BUILD && logger.log('Initializing SDK...'); @@ -26,6 +37,31 @@ export function init(options: NodeOptions): NodeClient | undefined { setTag('runtime', 'node'); + // Overwrite the transaction name for instrumented data loaders because the trace data gets overwritten at a later point. + // We only update the tx in case SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE got set in our instrumentation before. + getGlobalScope().addEventProcessor( + Object.assign( + (event => { + const overwrite = event.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE]; + if ( + event.type === 'transaction' && + event.transaction === 'GET *' && + event.contexts?.trace?.data?.[ATTR_HTTP_ROUTE] === '*' && + overwrite + ) { + event.transaction = overwrite; + event.contexts.trace.data[ATTR_HTTP_ROUTE] = 'url'; + } + + // always yeet this attribute into the void, as this should not reach the server + delete event.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE]; + + return event; + }) satisfies EventProcessor, + { id: 'ReactRouterTransactionEnhancer' }, + ), + ); + DEBUG_BUILD && logger.log('SDK successfully initialized'); return client; diff --git a/packages/react-router/src/server/wrapSentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts index ab13ea30ff0b..40a336a40fbd 100644 --- a/packages/react-router/src/server/wrapSentryHandleRequest.ts +++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts @@ -1,7 +1,13 @@ import { context } from '@opentelemetry/api'; import { getRPCMetadata, RPCType } from '@opentelemetry/core'; import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; -import { getActiveSpan, getRootSpan, getTraceMetaTags, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { + getActiveSpan, + getRootSpan, + getTraceMetaTags, + SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; import type { AppLoadContext, EntryContext } from 'react-router'; import type { PassThrough } from 'stream'; import { Transform } from 'stream'; @@ -30,6 +36,7 @@ export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest): ) { const parameterizedPath = routerContext?.staticHandlerContext?.matches?.[routerContext.staticHandlerContext.matches.length - 1]?.route.path; + if (parameterizedPath) { const activeSpan = getActiveSpan(); if (activeSpan) { @@ -38,6 +45,7 @@ export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest): // The express instrumentation writes on the rpcMetadata and that ends up stomping on the `http.route` attribute. const rpcMetadata = getRPCMetadata(context.active()); + if (rpcMetadata?.type === RPCType.HTTP) { rpcMetadata.route = routeName; } @@ -46,6 +54,7 @@ export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest): rootSpan.setAttributes({ [ATTR_HTTP_ROUTE]: routeName, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: `${request.method} ${routeName}`, }); } } diff --git a/packages/react-router/test/server/instrumentation/reactRouterServer.test.ts b/packages/react-router/test/server/instrumentation/reactRouterServer.test.ts new file mode 100644 index 000000000000..ddcb856c68b9 --- /dev/null +++ b/packages/react-router/test/server/instrumentation/reactRouterServer.test.ts @@ -0,0 +1,115 @@ +import type { Span } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ReactRouterInstrumentation } from '../../../src/server/instrumentation/reactRouter'; +import * as Util from '../../../src/server/instrumentation/util'; + +vi.mock('@sentry/core', async () => { + return { + getActiveSpan: vi.fn(), + getRootSpan: vi.fn(), + logger: { + debug: vi.fn(), + }, + SDK_VERSION: '1.0.0', + SEMANTIC_ATTRIBUTE_SENTRY_OP: 'sentry.op', + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin', + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source', + startSpan: vi.fn((opts, fn) => fn({})), + }; +}); + +vi.mock('./util', async () => { + return { + getSpanName: vi.fn((pathname: string, method: string) => `span:${pathname}:${method}`), + isDataRequest: vi.fn(), + }; +}); + +const mockSpan = { + spanContext: () => ({ traceId: '1', spanId: '2', traceFlags: 1 }), + setAttributes: vi.fn(), +}; + +function createRequest(url: string, method = 'GET') { + return { url, method } as unknown as Request; +} + +describe('ReactRouterInstrumentation', () => { + let instrumentation: ReactRouterInstrumentation; + let mockModule: any; + let originalHandler: any; + + beforeEach(() => { + instrumentation = new ReactRouterInstrumentation(); + originalHandler = vi.fn(); + mockModule = { + createRequestHandler: vi.fn(() => originalHandler), + }; + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should patch createRequestHandler', () => { + const proxy = (instrumentation as any)._createPatchedModuleProxy(mockModule); + expect(typeof proxy.createRequestHandler).toBe('function'); + expect(proxy.createRequestHandler).not.toBe(mockModule.createRequestHandler); + }); + + it('should call original handler for non-data requests', async () => { + vi.spyOn(Util, 'isDataRequest').mockReturnValue(false); + + const proxy = (instrumentation as any)._createPatchedModuleProxy(mockModule); + const wrappedHandler = proxy.createRequestHandler(); + const req = createRequest('https://test.com/page'); + await wrappedHandler(req); + + expect(Util.isDataRequest).toHaveBeenCalledWith('/page'); + expect(originalHandler).toHaveBeenCalledWith(req, undefined); + }); + + it('should call original handler if no active root span', async () => { + vi.spyOn(Util, 'isDataRequest').mockReturnValue(true); + vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue(undefined); + + const proxy = (instrumentation as any)._createPatchedModuleProxy(mockModule); + const wrappedHandler = proxy.createRequestHandler(); + const req = createRequest('https://test.com/data'); + await wrappedHandler(req); + + expect(SentryCore.logger.debug).toHaveBeenCalledWith( + 'No active root span found, skipping tracing for data request', + ); + expect(originalHandler).toHaveBeenCalledWith(req, undefined); + }); + + it('should start a span for data requests with active root span', async () => { + vi.spyOn(Util, 'isDataRequest').mockReturnValue(true); + vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue(mockSpan as Span); + vi.spyOn(SentryCore, 'getRootSpan').mockReturnValue(mockSpan as Span); + vi.spyOn(Util, 'getSpanName').mockImplementation((pathname, method) => `span:${pathname}:${method}`); + vi.spyOn(SentryCore, 'startSpan').mockImplementation((_opts, fn) => fn(mockSpan as Span)); + + const proxy = (instrumentation as any)._createPatchedModuleProxy(mockModule); + const wrappedHandler = proxy.createRequestHandler(); + const req = createRequest('https://test.com/data', 'POST'); + await wrappedHandler(req); + + expect(Util.isDataRequest).toHaveBeenCalledWith('/data'); + expect(Util.getSpanName).toHaveBeenCalledWith('/data', 'POST'); + expect(SentryCore.startSpan).toHaveBeenCalled(); + expect(originalHandler).toHaveBeenCalledWith(req, undefined); + }); + + it('should handle invalid URLs gracefully', async () => { + const proxy = (instrumentation as any)._createPatchedModuleProxy(mockModule); + const wrappedHandler = proxy.createRequestHandler(); + const req = { url: 'not a url', method: 'GET' } as any; + await wrappedHandler(req); + + expect(originalHandler).toHaveBeenCalledWith(req, undefined); + }); +}); diff --git a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts index ced113261709..61a92e7b6546 100644 --- a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts +++ b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts @@ -12,6 +12,7 @@ vi.mock('@opentelemetry/core', () => ({ vi.mock('@sentry/core', () => ({ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source', + SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME: 'sentry.custom-span-name', getActiveSpan: vi.fn(), getRootSpan: vi.fn(), getTraceMetaTags: vi.fn(), @@ -69,6 +70,7 @@ describe('wrapSentryHandleRequest', () => { expect(getRootSpan).toHaveBeenCalledWith(mockActiveSpan); expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ [ATTR_HTTP_ROUTE]: '/some-path', + 'sentry.custom-span-name': 'GET /some-path', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', }); expect(mockRpcMetadata.route).toBe('/some-path');