Skip to content

feat(react-router): Add otel instrumentation for server requests #16147

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: develop
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions packages/react-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.15.0",
"@sentry/cli": "^2.43.0",
"@sentry/core": "9.15.0",
Expand Down
106 changes: 106 additions & 0 deletions packages/react-router/src/server/instrumentation/reactRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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 { getSpanName, isDataRequest } 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<InstrumentationConfig> {
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 wrappedCreateRequestHandler(this: unknown, ...args: unknown[]) {
const originalRequestHandler = original.apply(this, args);

return async function wrappedRequestHandler(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);
}

return startSpan(
{
name: getSpanName(url.pathname, request.method),
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader',
url: url.pathname,
method: request.method,
},
},
() => {
return originalRequestHandler(request, initialContext);
},
);
};
};
}
return Reflect.get(target, prop, receiver);
},
});
}
}
38 changes: 38 additions & 0 deletions packages/react-router/src/server/instrumentation/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* 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');
}
28 changes: 28 additions & 0 deletions packages/react-router/src/server/integration/reactRouterServer.ts
Original file line number Diff line number Diff line change
@@ -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();
},
};
});
15 changes: 13 additions & 2 deletions packages/react-router/src/server/sdk.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import type { Integration } 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 { getDefaultIntegrations, init as initNodeSdk } from '@sentry/node';
import { DEBUG_BUILD } from '../common/debug-build';
import { reactRouterServerIntegration } from './integration/reactRouterServer';

/**
* Initializes the server side of the React Router SDK
*/
export function init(options: NodeOptions): NodeClient | undefined {
const opts = {
const opts: NodeOptions = {
defaultIntegrations: [...getDefaultReactRouterServerIntegrations(options)],
...options,
};

Expand All @@ -22,3 +25,11 @@ export function init(options: NodeOptions): NodeClient | undefined {
DEBUG_BUILD && logger.log('SDK successfully initialized');
return client;
}

/**
* Returns the default integrations for the React Router SDK.
* @param options The options for the SDK.
*/
export function getDefaultReactRouterServerIntegrations(options: NodeOptions): Integration[] {
return [...getDefaultIntegrations(options), reactRouterServerIntegration()];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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 }),
};

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);
});
});
Loading