diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx index faa62bd97197..567edfe4e032 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx @@ -9,7 +9,7 @@ import type { AppLoadContext, EntryContext } from 'react-router'; import { ServerRouter } from 'react-router'; const ABORT_DELAY = 5_000; -export default function handleRequest( +function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, @@ -60,6 +60,8 @@ export default function handleRequest( }); } +export default Sentry.sentryHandleRequest(handleRequest); + import { type HandleErrorFunction } from 'react-router'; export const handleError: HandleErrorFunction = (error, { request }) => { diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json index cdd96f39569e..a9afbbfcd07b 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/package.json @@ -10,6 +10,15 @@ "@react-router/node": "^7.1.5", "@react-router/serve": "^7.1.5", "@sentry/react-router": "latest || *", + "@sentry-internal/feedback": "latest || *", + "@sentry-internal/replay-canvas": "latest || *", + "@sentry-internal/browser-utils": "latest || *", + "@sentry/browser": "latest || *", + "@sentry/core": "latest || *", + "@sentry/node": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@sentry/react": "latest || *", + "@sentry-internal/replay": "latest || *", "isbot": "^5.1.17" }, "devDependencies": { 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 f080d01064ea..4f570beca144 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 @@ -5,8 +5,7 @@ import { APP_NAME } from '../constants'; test.describe('servery - performance', () => { test('should send server transaction on pageload', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - // todo: should be GET /performance - return transactionEvent.transaction === 'GET *'; + return transactionEvent.transaction === 'GET /performance'; }); await page.goto(`/performance`); @@ -30,8 +29,7 @@ test.describe('servery - performance', () => { spans: expect.any(Array), start_timestamp: expect.any(Number), timestamp: expect.any(Number), - // todo: should be GET /performance - transaction: 'GET *', + transaction: 'GET /performance', type: 'transaction', transaction_info: { source: 'route' }, platform: 'node', @@ -58,8 +56,7 @@ test.describe('servery - performance', () => { test('should send server transaction on parameterized route', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - // todo: should be GET /performance/with/:param - return transactionEvent.transaction === 'GET *'; + return transactionEvent.transaction === 'GET /performance/with/:param'; }); await page.goto(`/performance/with/some-param`); @@ -83,8 +80,7 @@ test.describe('servery - performance', () => { spans: expect.any(Array), start_timestamp: expect.any(Number), timestamp: expect.any(Number), - // todo: should be GET /performance/with/:param - transaction: 'GET *', + transaction: 'GET /performance/with/:param', type: 'transaction', transaction_info: { source: 'route' }, platform: 'node', diff --git a/packages/react-router/package.json b/packages/react-router/package.json index ff1d52a11fa4..d943fab5dd76 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -39,6 +39,9 @@ "@sentry/core": "9.10.1", "@sentry/node": "9.10.1", "@sentry/vite-plugin": "^3.2.4", + "@opentelemetry/semantic-conventions": "^1.30.0", + "@opentelemetry/core": "^1.30.1", + "@opentelemetry/api": "^1.9.0", "glob": "11.0.1" }, "devDependencies": { diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts index 6ac8d97b4241..44acfec7d4f2 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -1,3 +1,4 @@ export * from '@sentry/node'; export { init } from './sdk'; +export { sentryHandleRequest } from './sentryHandleRequest'; diff --git a/packages/react-router/src/server/sdk.ts b/packages/react-router/src/server/sdk.ts index bae99dee4983..d1e6b32b1d96 100644 --- a/packages/react-router/src/server/sdk.ts +++ b/packages/react-router/src/server/sdk.ts @@ -1,6 +1,7 @@ -import { applySdkMetadata, setTag } from '@sentry/core'; +import { applySdkMetadata, logger, setTag } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { init as initNodeSdk } from '@sentry/node'; +import { DEBUG_BUILD } from '../common/debug-build'; /** * Initializes the server side of the React Router SDK @@ -10,11 +11,14 @@ export function init(options: NodeOptions): NodeClient | undefined { ...options, }; + DEBUG_BUILD && logger.log('Initializing SDK...'); + applySdkMetadata(opts, 'react-router', ['react-router', 'node']); const client = initNodeSdk(opts); setTag('runtime', 'node'); + DEBUG_BUILD && logger.log('SDK successfully initialized'); return client; } diff --git a/packages/react-router/src/server/sentryHandleRequest.ts b/packages/react-router/src/server/sentryHandleRequest.ts new file mode 100644 index 000000000000..9c5f4abf72e8 --- /dev/null +++ b/packages/react-router/src/server/sentryHandleRequest.ts @@ -0,0 +1,52 @@ +import { context } from '@opentelemetry/api'; +import { RPCType, getRPCMetadata } from '@opentelemetry/core'; +import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; +import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, getActiveSpan, getRootSpan } from '@sentry/core'; +import type { AppLoadContext, EntryContext } from 'react-router'; + +type OriginalHandleRequest = ( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + loadContext: AppLoadContext, +) => Promise; + +/** + * Wraps the original handleRequest function to add Sentry instrumentation. + * + * @param originalHandle - The original handleRequest function to wrap + * @returns A wrapped version of the handle request function with Sentry instrumentation + */ +export function sentryHandleRequest(originalHandle: OriginalHandleRequest): OriginalHandleRequest { + return async function sentryInstrumentedHandleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + loadContext: AppLoadContext, + ) { + const parameterizedPath = + routerContext?.staticHandlerContext?.matches?.[routerContext.staticHandlerContext.matches.length - 1]?.route.path; + if (parameterizedPath) { + const activeSpan = getActiveSpan(); + if (activeSpan) { + const rootSpan = getRootSpan(activeSpan); + const routeName = `/${parameterizedPath}`; + + // 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; + } + + // The span exporter picks up the `http.route` (ATTR_HTTP_ROUTE) attribute to set the transaction name + rootSpan.setAttributes({ + [ATTR_HTTP_ROUTE]: routeName, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + }); + } + } + return originalHandle(request, responseStatusCode, responseHeaders, routerContext, loadContext); + }; +}