Skip to content

Commit 26731ed

Browse files
authored
feat(react-router): Add otel instrumentation for server requests (#16147)
This PR adds OpenTelemetry instrumentation for server-side requests in React Router by tracing server requests for data loaders and actions.
1 parent efb86de commit 26731ed

File tree

13 files changed

+413
-7
lines changed

13 files changed

+413
-7
lines changed

dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ export default [
1515
route('ssr', 'routes/performance/ssr.tsx'),
1616
route('with/:param', 'routes/performance/dynamic-param.tsx'),
1717
route('static', 'routes/performance/static.tsx'),
18+
route('server-loader', 'routes/performance/server-loader.tsx'),
1819
]),
1920
] satisfies RouteConfig;

dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/dynamic-param.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import type { Route } from './+types/dynamic-param';
22

3+
export async function loader() {
4+
await new Promise(resolve => setTimeout(resolve, 500));
5+
return { data: 'burritos' };
6+
}
7+
38
export default function DynamicParamPage({ params }: Route.ComponentProps) {
49
const { param } = params;
510

dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export default function PerformancePage() {
77
<nav>
88
<Link to="/performance/ssr">SSR Page</Link>
99
<Link to="/performance/with/sentry">With Param Page</Link>
10+
<Link to="/performance/server-loader">Server Loader</Link>
1011
</nav>
1112
</div>
1213
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { Route } from './+types/server-loader';
2+
3+
export async function loader() {
4+
await new Promise(resolve => setTimeout(resolve, 500));
5+
return { data: 'burritos' };
6+
}
7+
8+
export default function ServerLoaderPage({ loaderData }: Route.ComponentProps) {
9+
const { data } = loaderData;
10+
return (
11+
<div>
12+
<h1>Server Loader Page</h1>
13+
<div>{data}</div>
14+
</div>
15+
);
16+
}

dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,32 @@ test.describe('servery - performance', () => {
104104
},
105105
});
106106
});
107+
108+
test('should automatically instrument server loader', async ({ page }) => {
109+
const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
110+
return transactionEvent.transaction === 'GET /performance/server-loader.data';
111+
});
112+
113+
await page.goto(`/performance`); // initial ssr pageloads do not contain .data requests
114+
await page.waitForTimeout(500); // quick breather before navigation
115+
await page.getByRole('link', { name: 'Server Loader' }).click(); // this will actually trigger a .data request
116+
117+
const transaction = await txPromise;
118+
119+
expect(transaction?.spans?.[transaction.spans?.length - 1]).toMatchObject({
120+
span_id: expect.any(String),
121+
trace_id: expect.any(String),
122+
data: {
123+
'sentry.origin': 'auto.http.react-router',
124+
'sentry.op': 'function.react-router.loader',
125+
},
126+
description: 'Executing Server Loader',
127+
parent_span_id: expect.any(String),
128+
start_timestamp: expect.any(Number),
129+
timestamp: expect.any(Number),
130+
status: 'ok',
131+
op: 'function.react-router.loader',
132+
origin: 'auto.http.react-router',
133+
});
134+
});
107135
});

packages/react-router/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"@opentelemetry/api": "^1.9.0",
3838
"@opentelemetry/core": "^1.30.1",
3939
"@opentelemetry/semantic-conventions": "^1.30.0",
40+
"@opentelemetry/instrumentation": "0.57.2",
4041
"@sentry/browser": "9.18.0",
4142
"@sentry/cli": "^2.43.0",
4243
"@sentry/core": "9.18.0",
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
2+
import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
3+
import {
4+
getActiveSpan,
5+
getRootSpan,
6+
logger,
7+
SDK_VERSION,
8+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
9+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
10+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
11+
startSpan,
12+
} from '@sentry/core';
13+
import type * as reactRouter from 'react-router';
14+
import { DEBUG_BUILD } from '../../common/debug-build';
15+
import { getOpName, getSpanName, isDataRequest, SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE } from './util';
16+
17+
type ReactRouterModuleExports = typeof reactRouter;
18+
19+
const supportedVersions = ['>=7.0.0'];
20+
const COMPONENT = 'react-router';
21+
22+
/**
23+
* Instrumentation for React Router's server request handler.
24+
* This patches the requestHandler function to add Sentry performance monitoring for data loaders.
25+
*/
26+
export class ReactRouterInstrumentation extends InstrumentationBase<InstrumentationConfig> {
27+
public constructor(config: InstrumentationConfig = {}) {
28+
super('ReactRouterInstrumentation', SDK_VERSION, config);
29+
}
30+
31+
/**
32+
* Initializes the instrumentation by defining the React Router server modules to be patched.
33+
*/
34+
// eslint-disable-next-line @typescript-eslint/naming-convention
35+
protected init(): InstrumentationNodeModuleDefinition {
36+
const reactRouterServerModule = new InstrumentationNodeModuleDefinition(
37+
COMPONENT,
38+
supportedVersions,
39+
(moduleExports: ReactRouterModuleExports) => {
40+
return this._createPatchedModuleProxy(moduleExports);
41+
},
42+
(_moduleExports: unknown) => {
43+
// nothing to unwrap here
44+
return _moduleExports;
45+
},
46+
);
47+
48+
return reactRouterServerModule;
49+
}
50+
51+
/**
52+
* Creates a proxy around the React Router module exports that patches the createRequestHandler function.
53+
* This allows us to wrap the request handler to add performance monitoring for data loaders and actions.
54+
*/
55+
private _createPatchedModuleProxy(moduleExports: ReactRouterModuleExports): ReactRouterModuleExports {
56+
return new Proxy(moduleExports, {
57+
get(target, prop, receiver) {
58+
if (prop === 'createRequestHandler') {
59+
const original = target[prop];
60+
return function sentryWrappedCreateRequestHandler(this: unknown, ...args: unknown[]) {
61+
const originalRequestHandler = original.apply(this, args);
62+
63+
return async function sentryWrappedRequestHandler(request: Request, initialContext?: unknown) {
64+
let url: URL;
65+
try {
66+
url = new URL(request.url);
67+
} catch (error) {
68+
return originalRequestHandler(request, initialContext);
69+
}
70+
71+
// We currently just want to trace loaders and actions
72+
if (!isDataRequest(url.pathname)) {
73+
return originalRequestHandler(request, initialContext);
74+
}
75+
76+
const activeSpan = getActiveSpan();
77+
const rootSpan = activeSpan && getRootSpan(activeSpan);
78+
79+
if (!rootSpan) {
80+
DEBUG_BUILD && logger.debug('No active root span found, skipping tracing for data request');
81+
return originalRequestHandler(request, initialContext);
82+
}
83+
84+
// Set the source and overwrite attributes on the root span to ensure the transaction name
85+
// is derived from the raw URL pathname rather than any parameterized route that may be set later
86+
// TODO: try to set derived parameterized route from build here (args[0])
87+
rootSpan.setAttributes({
88+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
89+
[SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE]: `${request.method} ${url.pathname}`,
90+
});
91+
92+
return startSpan(
93+
{
94+
name: getSpanName(url.pathname, request.method),
95+
attributes: {
96+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router',
97+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: getOpName(url.pathname, request.method),
98+
},
99+
},
100+
() => {
101+
return originalRequestHandler(request, initialContext);
102+
},
103+
);
104+
};
105+
};
106+
}
107+
return Reflect.get(target, prop, receiver);
108+
},
109+
});
110+
}
111+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* Gets the op name for a request based on whether it's a loader or action request.
3+
* @param pathName The URL pathname to check
4+
* @param requestMethod The HTTP request method
5+
*/
6+
export function getOpName(pathName: string, requestMethod: string): string {
7+
return isLoaderRequest(pathName, requestMethod)
8+
? 'function.react-router.loader'
9+
: isActionRequest(pathName, requestMethod)
10+
? 'function.react-router.action'
11+
: 'function.react-router';
12+
}
13+
14+
/**
15+
* Gets the span name for a request based on whether it's a loader or action request.
16+
* @param pathName The URL pathname to check
17+
* @param requestMethod The HTTP request method
18+
*/
19+
export function getSpanName(pathName: string, requestMethod: string): string {
20+
return isLoaderRequest(pathName, requestMethod)
21+
? 'Executing Server Loader'
22+
: isActionRequest(pathName, requestMethod)
23+
? 'Executing Server Action'
24+
: 'Unknown Data Request';
25+
}
26+
27+
/**
28+
* Checks if the request is a server loader request
29+
* @param pathname The URL pathname to check
30+
* @param requestMethod The HTTP request method
31+
*/
32+
export function isLoaderRequest(pathname: string, requestMethod: string): boolean {
33+
return isDataRequest(pathname) && requestMethod === 'GET';
34+
}
35+
36+
/**
37+
* Checks if the request is a server action request
38+
* @param pathname The URL pathname to check
39+
* @param requestMethod The HTTP request method
40+
*/
41+
export function isActionRequest(pathname: string, requestMethod: string): boolean {
42+
return isDataRequest(pathname) && requestMethod === 'POST';
43+
}
44+
45+
/**
46+
* Checks if the request is a react-router data request
47+
* @param pathname The URL pathname to check
48+
*/
49+
export function isDataRequest(pathname: string): boolean {
50+
return pathname.endsWith('.data');
51+
}
52+
53+
export const SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE = 'sentry.overwrite-route';
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { defineIntegration } from '@sentry/core';
2+
import { generateInstrumentOnce } from '@sentry/node';
3+
import { ReactRouterInstrumentation } from '../instrumentation/reactRouter';
4+
5+
const INTEGRATION_NAME = 'ReactRouterServer';
6+
7+
const instrumentReactRouter = generateInstrumentOnce('React-Router-Server', () => {
8+
return new ReactRouterInstrumentation();
9+
});
10+
11+
export const instrumentReactRouterServer = Object.assign(
12+
(): void => {
13+
instrumentReactRouter();
14+
},
15+
{ id: INTEGRATION_NAME },
16+
);
17+
18+
/**
19+
* Integration capturing tracing data for React Router server functions.
20+
*/
21+
export const reactRouterServerIntegration = defineIntegration(() => {
22+
return {
23+
name: INTEGRATION_NAME,
24+
setupOnce() {
25+
instrumentReactRouterServer();
26+
},
27+
};
28+
});

packages/react-router/src/server/sdk.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,32 @@
1-
import type { Integration } from '@sentry/core';
2-
import { applySdkMetadata, logger, setTag } from '@sentry/core';
1+
import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions';
2+
import type { EventProcessor, Integration } from '@sentry/core';
3+
import { applySdkMetadata, getGlobalScope, logger, setTag } from '@sentry/core';
34
import type { NodeClient, NodeOptions } from '@sentry/node';
45
import { getDefaultIntegrations as getNodeDefaultIntegrations, init as initNodeSdk } from '@sentry/node';
56
import { DEBUG_BUILD } from '../common/debug-build';
7+
import { SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE } from './instrumentation/util';
8+
import { reactRouterServerIntegration } from './integration/reactRouterServer';
69
import { lowQualityTransactionsFilterIntegration } from './lowQualityTransactionsFilterIntegration';
710

8-
function getDefaultIntegrations(options: NodeOptions): Integration[] {
9-
return [...getNodeDefaultIntegrations(options), lowQualityTransactionsFilterIntegration(options)];
11+
/**
12+
* Returns the default integrations for the React Router SDK.
13+
* @param options The options for the SDK.
14+
*/
15+
export function getDefaultReactRouterServerIntegrations(options: NodeOptions): Integration[] {
16+
return [
17+
...getNodeDefaultIntegrations(options),
18+
lowQualityTransactionsFilterIntegration(options),
19+
reactRouterServerIntegration(),
20+
];
1021
}
1122

1223
/**
1324
* Initializes the server side of the React Router SDK
1425
*/
1526
export function init(options: NodeOptions): NodeClient | undefined {
16-
const opts = {
27+
const opts: NodeOptions = {
1728
...options,
18-
defaultIntegrations: getDefaultIntegrations(options),
29+
defaultIntegrations: getDefaultReactRouterServerIntegrations(options),
1930
};
2031

2132
DEBUG_BUILD && logger.log('Initializing SDK...');
@@ -26,6 +37,31 @@ export function init(options: NodeOptions): NodeClient | undefined {
2637

2738
setTag('runtime', 'node');
2839

40+
// Overwrite the transaction name for instrumented data loaders because the trace data gets overwritten at a later point.
41+
// We only update the tx in case SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE got set in our instrumentation before.
42+
getGlobalScope().addEventProcessor(
43+
Object.assign(
44+
(event => {
45+
const overwrite = event.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE];
46+
if (
47+
event.type === 'transaction' &&
48+
event.transaction === 'GET *' &&
49+
event.contexts?.trace?.data?.[ATTR_HTTP_ROUTE] === '*' &&
50+
overwrite
51+
) {
52+
event.transaction = overwrite;
53+
event.contexts.trace.data[ATTR_HTTP_ROUTE] = 'url';
54+
}
55+
56+
// always yeet this attribute into the void, as this should not reach the server
57+
delete event.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE];
58+
59+
return event;
60+
}) satisfies EventProcessor,
61+
{ id: 'ReactRouterTransactionEnhancer' },
62+
),
63+
);
64+
2965
DEBUG_BUILD && logger.log('SDK successfully initialized');
3066

3167
return client;

packages/react-router/src/server/wrapSentryHandleRequest.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { context } from '@opentelemetry/api';
22
import { getRPCMetadata, RPCType } from '@opentelemetry/core';
33
import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions';
4-
import { getActiveSpan, getRootSpan, getTraceMetaTags, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core';
4+
import {
5+
getActiveSpan,
6+
getRootSpan,
7+
getTraceMetaTags,
8+
SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME,
9+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
10+
} from '@sentry/core';
511
import type { AppLoadContext, EntryContext } from 'react-router';
612
import type { PassThrough } from 'stream';
713
import { Transform } from 'stream';
@@ -30,6 +36,7 @@ export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest):
3036
) {
3137
const parameterizedPath =
3238
routerContext?.staticHandlerContext?.matches?.[routerContext.staticHandlerContext.matches.length - 1]?.route.path;
39+
3340
if (parameterizedPath) {
3441
const activeSpan = getActiveSpan();
3542
if (activeSpan) {
@@ -38,6 +45,7 @@ export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest):
3845

3946
// The express instrumentation writes on the rpcMetadata and that ends up stomping on the `http.route` attribute.
4047
const rpcMetadata = getRPCMetadata(context.active());
48+
4149
if (rpcMetadata?.type === RPCType.HTTP) {
4250
rpcMetadata.route = routeName;
4351
}
@@ -46,6 +54,7 @@ export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest):
4654
rootSpan.setAttributes({
4755
[ATTR_HTTP_ROUTE]: routeName,
4856
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
57+
[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: `${request.method} ${routeName}`,
4958
});
5059
}
5160
}

0 commit comments

Comments
 (0)