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');