diff --git a/.size-limit.js b/.size-limit.js
index bbe29ceded7c..0c03c0ff1b8b 100644
--- a/.size-limit.js
+++ b/.size-limit.js
@@ -18,7 +18,6 @@ module.exports = [
limit: '24.1 KB',
modifyWebpackConfig: function (config) {
const webpack = require('webpack');
- const TerserPlugin = require('terser-webpack-plugin');
config.plugins.push(
new webpack.DefinePlugin({
@@ -30,7 +29,6 @@ module.exports = [
);
config.optimization.minimize = true;
- config.optimization.minimizer = [new TerserPlugin()];
return config;
},
@@ -57,7 +55,6 @@ module.exports = [
limit: '70.1 KB',
modifyWebpackConfig: function (config) {
const webpack = require('webpack');
- const TerserPlugin = require('terser-webpack-plugin');
config.plugins.push(
new webpack.DefinePlugin({
@@ -69,7 +66,6 @@ module.exports = [
);
config.optimization.minimize = true;
- config.optimization.minimizer = [new TerserPlugin()];
return config;
},
@@ -139,7 +135,7 @@ module.exports = [
path: 'packages/vue/build/esm/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
- limit: '40 KB',
+ limit: '41 KB',
},
// Svelte SDK (ESM)
{
@@ -239,7 +235,6 @@ module.exports = [
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
modifyWebpackConfig: function (config) {
const webpack = require('webpack');
- const TerserPlugin = require('terser-webpack-plugin');
config.plugins.push(
new webpack.DefinePlugin({
@@ -248,7 +243,6 @@ module.exports = [
);
config.optimization.minimize = true;
- config.optimization.minimizer = [new TerserPlugin()];
return config;
},
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d117a6907bf4..78064f48920a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,16 @@
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
+## 9.19.0
+
+- feat(react-router): Add otel instrumentation for server requests ([#16147](https://github.com/getsentry/sentry-javascript/pull/16147))
+- feat(remix): Vendor in `opentelemetry-instrumentation-remix` ([#16145](https://github.com/getsentry/sentry-javascript/pull/16145))
+- fix(browser): Ensure spans auto-ended for navigations have `cancelled` reason ([#16277](https://github.com/getsentry/sentry-javascript/pull/16277))
+- fix(node): Pin `@fastify/otel` fork to direct url to allow installing without git ([#16287](https://github.com/getsentry/sentry-javascript/pull/16287))
+- fix(react): Handle nested parameterized routes in reactrouterv3 transaction normalization ([#16274](https://github.com/getsentry/sentry-javascript/pull/16274))
+
+Work in this release was contributed by @sidx1024. Thank you for your contribution!
+
## 9.18.0
### Important changes
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js
new file mode 100644
index 000000000000..c0424a9b743f
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration()],
+ tracesSampleRate: 1,
+});
+
+// Immediately navigate to a new page to abort the pageload
+window.location.href = '#foo';
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/test.ts
new file mode 100644
index 000000000000..ad224aa6d1d9
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/test.ts
@@ -0,0 +1,58 @@
+import { expect } from '@playwright/test';
+import {
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+} from '@sentry/core';
+import { sentryTest } from '../../../../utils/fixtures';
+import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers';
+
+sentryTest(
+ 'should create a navigation transaction that aborts an ongoing pageload',
+ async ({ getLocalTestUrl, page }) => {
+ if (shouldSkipTracingTest()) {
+ sentryTest.skip();
+ }
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadRequestPromise = waitForTransactionRequest(page, event => event.contexts?.trace?.op === 'pageload');
+ const navigationRequestPromise = waitForTransactionRequest(
+ page,
+ event => event.contexts?.trace?.op === 'navigation',
+ );
+
+ await page.goto(url);
+
+ const pageloadRequest = envelopeRequestParser(await pageloadRequestPromise);
+ const navigationRequest = envelopeRequestParser(await navigationRequestPromise);
+
+ expect(pageloadRequest.contexts?.trace?.op).toBe('pageload');
+ expect(navigationRequest.contexts?.trace?.op).toBe('navigation');
+
+ expect(navigationRequest.transaction_info?.source).toEqual('url');
+
+ const pageloadTraceId = pageloadRequest.contexts?.trace?.trace_id;
+ const navigationTraceId = navigationRequest.contexts?.trace?.trace_id;
+
+ expect(pageloadTraceId).toBeDefined();
+ expect(navigationTraceId).toBeDefined();
+ expect(pageloadTraceId).not.toEqual(navigationTraceId);
+
+ expect(pageloadRequest.contexts?.trace?.data).toMatchObject({
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload',
+ ['sentry.idle_span_finish_reason']: 'cancelled',
+ });
+ expect(navigationRequest.contexts?.trace?.data).toMatchObject({
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
+ ['sentry.idle_span_finish_reason']: 'idleTimeout',
+ });
+ },
+);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation/test.ts
index 2f170c7b7c3e..503aa73ba4ff 100644
--- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation/test.ts
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation/test.ts
@@ -1,5 +1,11 @@
import { expect } from '@playwright/test';
import type { Event } from '@sentry/core';
+import {
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+} from '@sentry/core';
import { sentryTest } from '../../../../utils/fixtures';
import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers';
@@ -25,6 +31,21 @@ sentryTest('should create a navigation transaction on page navigation', async ({
expect(navigationTraceId).toBeDefined();
expect(pageloadTraceId).not.toEqual(navigationTraceId);
+ expect(pageloadRequest.contexts?.trace?.data).toMatchObject({
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload',
+ ['sentry.idle_span_finish_reason']: 'idleTimeout',
+ });
+ expect(navigationRequest.contexts?.trace?.data).toMatchObject({
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
+ ['sentry.idle_span_finish_reason']: 'idleTimeout',
+ });
+
const pageloadSpans = pageloadRequest.spans;
const navigationSpans = navigationRequest.spans;
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload/test.ts
index 0146be46057c..9dd0086ba411 100644
--- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload/test.ts
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload/test.ts
@@ -30,6 +30,7 @@ sentryTest('creates a pageload transaction with url as source', async ({ getLoca
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload',
+ ['sentry.idle_span_finish_reason']: 'idleTimeout',
});
expect(eventData.contexts?.trace?.op).toBe('pageload');
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/dev-packages/size-limit-gh-action/package.json b/dev-packages/size-limit-gh-action/package.json
index 9e061ce0b026..fce4dbd63a1f 100644
--- a/dev-packages/size-limit-gh-action/package.json
+++ b/dev-packages/size-limit-gh-action/package.json
@@ -20,7 +20,7 @@
"@actions/github": "^5.0.0",
"@actions/glob": "0.4.0",
"@actions/io": "1.1.3",
- "bytes": "3.1.2",
+ "bytes-iec": "3.1.1",
"markdown-table": "3.0.3"
},
"volta": {
diff --git a/dev-packages/size-limit-gh-action/utils/SizeLimitFormatter.mjs b/dev-packages/size-limit-gh-action/utils/SizeLimitFormatter.mjs
index d7b3a444ff7c..ff1f40c6a716 100644
--- a/dev-packages/size-limit-gh-action/utils/SizeLimitFormatter.mjs
+++ b/dev-packages/size-limit-gh-action/utils/SizeLimitFormatter.mjs
@@ -1,5 +1,5 @@
import * as core from '@actions/core';
-import bytes from 'bytes';
+import bytes from 'bytes-iec';
const SIZE_RESULTS_HEADER = ['Path', 'Size', '% Change', 'Change'];
diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts
index 99036353d4d7..643b561af583 100644
--- a/packages/browser/src/tracing/browserTracingIntegration.ts
+++ b/packages/browser/src/tracing/browserTracingIntegration.ts
@@ -386,6 +386,7 @@ export const browserTracingIntegration = ((_options: Partial=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');
diff --git a/packages/react/src/reactrouterv3.ts b/packages/react/src/reactrouterv3.ts
index 73058307353f..f49948a2a74b 100644
--- a/packages/react/src/reactrouterv3.ts
+++ b/packages/react/src/reactrouterv3.ts
@@ -150,9 +150,8 @@ function getRouteStringFromRoutes(routes: Route[]): string {
}
}
- return routesWithPaths
- .slice(index)
- .filter(({ path }) => !!path)
- .map(({ path }) => path)
- .join('');
+ return routesWithPaths.slice(index).reduce((acc, { path }) => {
+ const pathSegment = acc === '/' || acc === '' ? path : `/${path}`;
+ return `${acc}${pathSegment}`;
+ }, '');
}
diff --git a/packages/react/test/reactrouterv3.test.tsx b/packages/react/test/reactrouterv3.test.tsx
index 841cbf018647..18fc34527c3f 100644
--- a/packages/react/test/reactrouterv3.test.tsx
+++ b/packages/react/test/reactrouterv3.test.tsx
@@ -64,6 +64,11 @@ describe('browserTracingReactRouterV3', () => {
OrgId
} />
Team
} />
+
+
+ Team Details
} />
+
+
);
const history = createMemoryHistory();
@@ -192,6 +197,22 @@ describe('browserTracingReactRouterV3', () => {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
},
});
+ expect(getCurrentScope().getScopeData().transactionName).toEqual('/users/:userid');
+
+ act(() => {
+ history.push('/teams/456/details');
+ });
+
+ expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2);
+ expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), {
+ name: '/teams/:teamId/details',
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v3',
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
+ },
+ });
+ expect(getCurrentScope().getScopeData().transactionName).toEqual('/teams/:teamId/details');
});
it("updates the scope's `transactionName` on a navigation", () => {
diff --git a/packages/remix/package.json b/packages/remix/package.json
index 122b1dfdf329..f2c8d2c4f2a2 100644
--- a/packages/remix/package.json
+++ b/packages/remix/package.json
@@ -65,6 +65,8 @@
},
"dependencies": {
"@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/instrumentation": "^0.57.2",
+ "@opentelemetry/semantic-conventions": "^1.30.0",
"@remix-run/router": "1.x",
"@sentry/cli": "^2.43.0",
"@sentry/core": "9.18.0",
@@ -72,7 +74,6 @@
"@sentry/opentelemetry": "9.18.0",
"@sentry/react": "9.18.0",
"glob": "^10.3.4",
- "opentelemetry-instrumentation-remix": "0.8.0",
"yargs": "^17.6.0"
},
"devDependencies": {
diff --git a/packages/remix/src/server/errors.ts b/packages/remix/src/server/errors.ts
index 90359212300d..0e26242a0164 100644
--- a/packages/remix/src/server/errors.ts
+++ b/packages/remix/src/server/errors.ts
@@ -134,7 +134,7 @@ export async function errorHandleDataFunction(
const options = getClient()?.getOptions() as RemixOptions | undefined;
if (options?.sendDefaultPii && options.captureActionFormDataKeys) {
- await storeFormDataKeys(args, span);
+ await storeFormDataKeys(args, span, options.captureActionFormDataKeys);
}
}
diff --git a/packages/remix/src/server/integrations/opentelemetry.ts b/packages/remix/src/server/integrations/opentelemetry.ts
index 42654201da18..b4ad1d28bb6a 100644
--- a/packages/remix/src/server/integrations/opentelemetry.ts
+++ b/packages/remix/src/server/integrations/opentelemetry.ts
@@ -1,8 +1,8 @@
import type { Client, IntegrationFn, Span } from '@sentry/core';
import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
import { generateInstrumentOnce, getClient, spanToJSON } from '@sentry/node';
-import { RemixInstrumentation } from 'opentelemetry-instrumentation-remix';
import type { RemixOptions } from '../../utils/remixOptions';
+import { RemixInstrumentation } from '../../vendor/instrumentation';
const INTEGRATION_NAME = 'Remix';
diff --git a/packages/remix/src/utils/utils.ts b/packages/remix/src/utils/utils.ts
index a1d878ac1314..62fee4b20d61 100644
--- a/packages/remix/src/utils/utils.ts
+++ b/packages/remix/src/utils/utils.ts
@@ -10,7 +10,11 @@ type ServerRouteManifest = ServerBuild['routes'];
/**
*
*/
-export async function storeFormDataKeys(args: LoaderFunctionArgs | ActionFunctionArgs, span: Span): Promise {
+export async function storeFormDataKeys(
+ args: LoaderFunctionArgs | ActionFunctionArgs,
+ span: Span,
+ formDataKeys?: Record | undefined,
+): Promise {
try {
// We clone the request for Remix be able to read the FormData later.
const clonedRequest = args.request.clone();
@@ -21,7 +25,18 @@ export async function storeFormDataKeys(args: LoaderFunctionArgs | ActionFunctio
const formData = await clonedRequest.formData();
formData.forEach((value, key) => {
- span.setAttribute(`remix.action_form_data.${key}`, typeof value === 'string' ? value : '[non-string value]');
+ let attrKey = key;
+
+ if (formDataKeys?.[key]) {
+ if (typeof formDataKeys[key] === 'string') {
+ attrKey = formDataKeys[key] as string;
+ }
+
+ span.setAttribute(
+ `remix.action_form_data.${attrKey}`,
+ typeof value === 'string' ? value : '[non-string value]',
+ );
+ }
});
} catch (e) {
DEBUG_BUILD && logger.warn('Failed to read FormData from request', e);
diff --git a/packages/remix/src/vendor/instrumentation.ts b/packages/remix/src/vendor/instrumentation.ts
new file mode 100644
index 000000000000..317a17da663d
--- /dev/null
+++ b/packages/remix/src/vendor/instrumentation.ts
@@ -0,0 +1,375 @@
+/* eslint-disable deprecation/deprecation */
+/* eslint-disable max-lines */
+/* eslint-disable jsdoc/require-jsdoc */
+
+// Vendored and modified from:
+// https://github.com/justindsmith/opentelemetry-instrumentations-js/blob/3b1e8c3e566e5cc3389e9c28cafce6a5ebb39600/packages/instrumentation-remix/src/instrumentation.ts
+
+/*
+ * Copyright Justin Smith
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { Span } from '@opentelemetry/api';
+import opentelemetry, { SpanStatusCode } from '@opentelemetry/api';
+import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
+import {
+ InstrumentationBase,
+ InstrumentationNodeModuleDefinition,
+ InstrumentationNodeModuleFile,
+ isWrapped,
+} from '@opentelemetry/instrumentation';
+import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
+import type { Params } from '@remix-run/router';
+import type * as remixRunServerRuntime from '@remix-run/server-runtime';
+import type * as remixRunServerRuntimeData from '@remix-run/server-runtime/dist/data';
+import type * as remixRunServerRuntimeRouteMatching from '@remix-run/server-runtime/dist/routeMatching';
+import type { RouteMatch } from '@remix-run/server-runtime/dist/routeMatching';
+import type { ServerRoute } from '@remix-run/server-runtime/dist/routes';
+import { SDK_VERSION } from '@sentry/core';
+
+const RemixSemanticAttributes = {
+ MATCH_PARAMS: 'match.params',
+ MATCH_ROUTE_ID: 'match.route.id',
+};
+
+const VERSION = SDK_VERSION;
+
+export interface RemixInstrumentationConfig extends InstrumentationConfig {
+ /**
+ * Mapping of FormData field to span attribute names. Appends attribute as `formData.${name}`.
+ *
+ * Provide `true` value to use the FormData field name as the attribute name, or provide
+ * a `string` value to map the field name to a custom attribute name.
+ *
+ * @default { _action: "actionType" }
+ */
+ actionFormDataAttributes?: Record;
+}
+
+const DEFAULT_CONFIG: RemixInstrumentationConfig = {
+ actionFormDataAttributes: {
+ _action: 'actionType',
+ },
+};
+
+export class RemixInstrumentation extends InstrumentationBase {
+ public constructor(config: RemixInstrumentationConfig = {}) {
+ super('RemixInstrumentation', VERSION, Object.assign({}, DEFAULT_CONFIG, config));
+ }
+
+ public getConfig(): RemixInstrumentationConfig {
+ return this._config;
+ }
+
+ public setConfig(config: RemixInstrumentationConfig = {}): void {
+ this._config = Object.assign({}, DEFAULT_CONFIG, config);
+ }
+
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ protected init(): InstrumentationNodeModuleDefinition {
+ const remixRunServerRuntimeRouteMatchingFile = new InstrumentationNodeModuleFile(
+ '@remix-run/server-runtime/dist/routeMatching.js',
+ ['2.x'],
+ (moduleExports: typeof remixRunServerRuntimeRouteMatching) => {
+ // createRequestHandler
+ if (isWrapped(moduleExports['matchServerRoutes'])) {
+ this._unwrap(moduleExports, 'matchServerRoutes');
+ }
+ this._wrap(moduleExports, 'matchServerRoutes', this._patchMatchServerRoutes());
+
+ return moduleExports;
+ },
+ (moduleExports: typeof remixRunServerRuntimeRouteMatching) => {
+ this._unwrap(moduleExports, 'matchServerRoutes');
+ },
+ );
+
+ const remixRunServerRuntimeData_File = new InstrumentationNodeModuleFile(
+ '@remix-run/server-runtime/dist/data.js',
+ ['2.9.0 - 2.x'],
+ (moduleExports: typeof remixRunServerRuntimeData) => {
+ // callRouteLoader
+ if (isWrapped(moduleExports['callRouteLoader'])) {
+ this._unwrap(moduleExports, 'callRouteLoader');
+ }
+ this._wrap(moduleExports, 'callRouteLoader', this._patchCallRouteLoader());
+
+ // callRouteAction
+ if (isWrapped(moduleExports['callRouteAction'])) {
+ this._unwrap(moduleExports, 'callRouteAction');
+ }
+ this._wrap(moduleExports, 'callRouteAction', this._patchCallRouteAction());
+ return moduleExports;
+ },
+ (moduleExports: typeof remixRunServerRuntimeData) => {
+ this._unwrap(moduleExports, 'callRouteLoader');
+ this._unwrap(moduleExports, 'callRouteAction');
+ },
+ );
+
+ /*
+ * In Remix 2.9.0, the `callXXLoaderRR` functions were renamed to `callXXLoader`.
+ */
+ const remixRunServerRuntimeDataPre_2_9_File = new InstrumentationNodeModuleFile(
+ '@remix-run/server-runtime/dist/data.js',
+ ['2.0.0 - 2.8.x'],
+ (
+ moduleExports: typeof remixRunServerRuntimeData & {
+ callRouteLoaderRR: typeof remixRunServerRuntimeData.callRouteLoader;
+ callRouteActionRR: typeof remixRunServerRuntimeData.callRouteAction;
+ },
+ ) => {
+ // callRouteLoader
+ if (isWrapped(moduleExports['callRouteLoaderRR'])) {
+ this._unwrap(moduleExports, 'callRouteLoaderRR');
+ }
+ this._wrap(moduleExports, 'callRouteLoaderRR', this._patchCallRouteLoader());
+
+ // callRouteAction
+ if (isWrapped(moduleExports['callRouteActionRR'])) {
+ this._unwrap(moduleExports, 'callRouteActionRR');
+ }
+ this._wrap(moduleExports, 'callRouteActionRR', this._patchCallRouteAction());
+ return moduleExports;
+ },
+ (
+ moduleExports: typeof remixRunServerRuntimeData & {
+ callRouteLoaderRR: typeof remixRunServerRuntimeData.callRouteLoader;
+ callRouteActionRR: typeof remixRunServerRuntimeData.callRouteAction;
+ },
+ ) => {
+ this._unwrap(moduleExports, 'callRouteLoaderRR');
+ this._unwrap(moduleExports, 'callRouteActionRR');
+ },
+ );
+
+ const remixRunServerRuntimeModule = new InstrumentationNodeModuleDefinition(
+ '@remix-run/server-runtime',
+ ['2.x'],
+ (moduleExports: typeof remixRunServerRuntime) => {
+ // createRequestHandler
+ if (isWrapped(moduleExports['createRequestHandler'])) {
+ this._unwrap(moduleExports, 'createRequestHandler');
+ }
+ this._wrap(moduleExports, 'createRequestHandler', this._patchCreateRequestHandler());
+
+ return moduleExports;
+ },
+ (moduleExports: typeof remixRunServerRuntime) => {
+ this._unwrap(moduleExports, 'createRequestHandler');
+ },
+ [remixRunServerRuntimeRouteMatchingFile, remixRunServerRuntimeData_File, remixRunServerRuntimeDataPre_2_9_File],
+ );
+
+ return remixRunServerRuntimeModule;
+ }
+
+ private _patchMatchServerRoutes(): (original: typeof remixRunServerRuntimeRouteMatching.matchServerRoutes) => any {
+ return function matchServerRoutes(original) {
+ return function patchMatchServerRoutes(
+ this: any,
+ ...args: Parameters
+ ): RouteMatch[] | null {
+ const result = original.apply(this, args) as RouteMatch[] | null;
+
+ const span = opentelemetry.trace.getSpan(opentelemetry.context.active());
+
+ const route = (result || []).slice(-1)[0]?.route;
+
+ const routePath = route?.path;
+ if (span && routePath) {
+ span.setAttribute(SemanticAttributes.HTTP_ROUTE, routePath);
+ span.updateName(`remix.request ${routePath}`);
+ }
+
+ const routeId = route?.id;
+ if (span && routeId) {
+ span.setAttribute(RemixSemanticAttributes.MATCH_ROUTE_ID, routeId);
+ }
+
+ return result;
+ };
+ };
+ }
+
+ private _patchCreateRequestHandler(): (original: typeof remixRunServerRuntime.createRequestHandler) => any {
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
+ const plugin = this;
+ return function createRequestHandler(original) {
+ return function patchCreateRequestHandler(
+ this: any,
+ ...args: Parameters
+ ): remixRunServerRuntime.RequestHandler {
+ const originalRequestHandler: remixRunServerRuntime.RequestHandler = original.apply(this, args);
+
+ return (request: Request, loadContext?: remixRunServerRuntime.AppLoadContext) => {
+ const span = plugin.tracer.startSpan(
+ 'remix.request',
+ {
+ attributes: { [SemanticAttributes.CODE_FUNCTION]: 'requestHandler' },
+ },
+ opentelemetry.context.active(),
+ );
+ addRequestAttributesToSpan(span, request);
+
+ const originalResponsePromise = opentelemetry.context.with(
+ opentelemetry.trace.setSpan(opentelemetry.context.active(), span),
+ () => originalRequestHandler(request, loadContext),
+ );
+ return originalResponsePromise
+ .then(response => {
+ addResponseAttributesToSpan(span, response);
+ return response;
+ })
+ .catch(error => {
+ plugin._addErrorToSpan(span, error);
+ throw error;
+ })
+ .finally(() => {
+ span.end();
+ });
+ };
+ };
+ };
+ }
+
+ private _patchCallRouteLoader(): (original: typeof remixRunServerRuntimeData.callRouteLoader) => any {
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
+ const plugin = this;
+ return function callRouteLoader(original) {
+ return function patchCallRouteLoader(this: any, ...args: Parameters): Promise {
+ const [params] = args;
+
+ const span = plugin.tracer.startSpan(
+ `LOADER ${params.routeId}`,
+ { attributes: { [SemanticAttributes.CODE_FUNCTION]: 'loader' } },
+ opentelemetry.context.active(),
+ );
+
+ addRequestAttributesToSpan(span, params.request);
+ addMatchAttributesToSpan(span, { routeId: params.routeId, params: params.params });
+
+ return opentelemetry.context.with(opentelemetry.trace.setSpan(opentelemetry.context.active(), span), () => {
+ const originalResponsePromise: Promise = original.apply(this, args);
+ return originalResponsePromise
+ .then(response => {
+ addResponseAttributesToSpan(span, response);
+ return response;
+ })
+ .catch(error => {
+ plugin._addErrorToSpan(span, error);
+ throw error;
+ })
+ .finally(() => {
+ span.end();
+ });
+ });
+ };
+ };
+ }
+
+ private _patchCallRouteAction(): (original: typeof remixRunServerRuntimeData.callRouteAction) => any {
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
+ const plugin = this;
+ return function callRouteAction(original) {
+ return async function patchCallRouteAction(this: any, ...args: Parameters): Promise {
+ const [params] = args;
+ const clonedRequest = params.request.clone();
+ const span = plugin.tracer.startSpan(
+ `ACTION ${params.routeId}`,
+ { attributes: { [SemanticAttributes.CODE_FUNCTION]: 'action' } },
+ opentelemetry.context.active(),
+ );
+
+ addRequestAttributesToSpan(span, clonedRequest);
+ addMatchAttributesToSpan(span, { routeId: params.routeId, params: params.params });
+
+ return opentelemetry.context.with(
+ opentelemetry.trace.setSpan(opentelemetry.context.active(), span),
+ async () => {
+ const originalResponsePromise: Promise = original.apply(this, args);
+
+ return originalResponsePromise
+ .then(async response => {
+ addResponseAttributesToSpan(span, response);
+
+ try {
+ const formData = await clonedRequest.formData();
+ const { actionFormDataAttributes: actionFormAttributes } = plugin.getConfig();
+
+ formData.forEach((value: unknown, key: string) => {
+ if (
+ actionFormAttributes?.[key] &&
+ actionFormAttributes[key] !== false &&
+ typeof value === 'string'
+ ) {
+ const keyName = actionFormAttributes[key] === true ? key : actionFormAttributes[key];
+ span.setAttribute(`formData.${keyName}`, value.toString());
+ }
+ });
+ } catch {
+ // Silently continue on any error. Typically happens because the action body cannot be processed
+ // into FormData, in which case we should just continue.
+ }
+
+ return response;
+ })
+ .catch(async error => {
+ plugin._addErrorToSpan(span, error);
+ throw error;
+ })
+ .finally(() => {
+ span.end();
+ });
+ },
+ );
+ };
+ };
+ }
+
+ private _addErrorToSpan(span: Span, error: Error): void {
+ addErrorEventToSpan(span, error);
+ }
+}
+
+const addRequestAttributesToSpan = (span: Span, request: Request): void => {
+ span.setAttributes({
+ [SemanticAttributes.HTTP_METHOD]: request.method,
+ [SemanticAttributes.HTTP_URL]: request.url,
+ });
+};
+
+const addMatchAttributesToSpan = (span: Span, match: { routeId: string; params: Params }): void => {
+ span.setAttributes({
+ [RemixSemanticAttributes.MATCH_ROUTE_ID]: match.routeId,
+ });
+
+ Object.keys(match.params).forEach(paramName => {
+ span.setAttribute(`${RemixSemanticAttributes.MATCH_PARAMS}.${paramName}`, match.params[paramName] || '(undefined)');
+ });
+};
+
+const addResponseAttributesToSpan = (span: Span, response: Response | null): void => {
+ if (response) {
+ span.setAttributes({
+ [SemanticAttributes.HTTP_STATUS_CODE]: response.status,
+ });
+ }
+};
+
+const addErrorEventToSpan = (span: Span, error: Error): void => {
+ span.recordException(error);
+ span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
+};
diff --git a/yarn.lock b/yarn.lock
index 1acd6df25742..78c0f0fb30a3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3912,9 +3912,9 @@
resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.0.0.tgz#f22824caff3ae506b18207bad4126dbc6ccdb6b8"
integrity sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==
-"@fastify/otel@getsentry/fastify-otel#otel-v1":
+"@fastify/otel@https://codeload.github.com/getsentry/fastify-otel/tar.gz/ae3088d65e286bdc94ac5d722573537d6a6671bb":
version "0.8.0"
- resolved "https://codeload.github.com/getsentry/fastify-otel/tar.gz/d6bb1756c3db3d00d4d82c39c93ee3316e06d305"
+ resolved "https://codeload.github.com/getsentry/fastify-otel/tar.gz/ae3088d65e286bdc94ac5d722573537d6a6671bb#1632d3df7ebf8cd86996a50e9e42721aea05b39c"
dependencies:
"@opentelemetry/core" "^1.30.1"
"@opentelemetry/instrumentation" "^0.57.2"
@@ -5427,13 +5427,6 @@
dependencies:
"@opentelemetry/api" "^1.3.0"
-"@opentelemetry/api-logs@0.52.1":
- version "0.52.1"
- resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz#52906375da4d64c206b0c4cb8ffa209214654ecc"
- integrity sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==
- dependencies:
- "@opentelemetry/api" "^1.0.0"
-
"@opentelemetry/api-logs@0.57.2":
version "0.57.2"
resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz#d4001b9aa3580367b40fe889f3540014f766cc87"
@@ -5441,7 +5434,7 @@
dependencies:
"@opentelemetry/api" "^1.3.0"
-"@opentelemetry/api@1.9.0", "@opentelemetry/api@^1.0.0", "@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.9.0":
+"@opentelemetry/api@1.9.0", "@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.9.0":
version "1.9.0"
resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe"
integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==
@@ -5711,18 +5704,6 @@
require-in-the-middle "^7.1.1"
shimmer "^1.2.1"
-"@opentelemetry/instrumentation@^0.52.1":
- version "0.52.1"
- resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.52.1.tgz#2e7e46a38bd7afbf03cf688c862b0b43418b7f48"
- integrity sha512-uXJbYU/5/MBHjMp1FqrILLRuiJCs3Ofk0MeRDk8g1S1gD47U8X3JnSwcMO1rtRo1x1a7zKaQHaoYu49p/4eSKw==
- dependencies:
- "@opentelemetry/api-logs" "0.52.1"
- "@types/shimmer" "^1.0.2"
- import-in-the-middle "^1.8.1"
- require-in-the-middle "^7.1.1"
- semver "^7.5.2"
- shimmer "^1.2.1"
-
"@opentelemetry/propagation-utils@^0.30.16":
version "0.30.16"
resolved "https://registry.yarnpkg.com/@opentelemetry/propagation-utils/-/propagation-utils-0.30.16.tgz#6715d0225b618ea66cf34cc3800fa3452a8475fa"
@@ -5772,10 +5753,10 @@
resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz#337fb2bca0453d0726696e745f50064411f646d6"
integrity sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==
-"@opentelemetry/semantic-conventions@^1.25.1", "@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.28.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.30.0":
- version "1.33.0"
- resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.33.0.tgz#ec8ebd2ac768ab366aff94e0e7f27e8ae24fa49f"
- integrity sha512-TIpZvE8fiEILFfTlfPnltpBaD3d9/+uQHVCyC3vfdh6WfCXKhNFzoP5RyDDIndfvZC5GrA4pyEDNyjPloJud+w==
+"@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.28.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.30.0":
+ version "1.32.0"
+ resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.32.0.tgz#a15e8f78f32388a7e4655e7f539570e40958ca3f"
+ integrity sha512-s0OpmpQFSfMrmedAn9Lhg4KWJELHCU6uU9dtIJ28N8UGhf9Y55im5X8fEzwhwDwiSqN+ZPSNrDJF7ivf/AuRPQ==
"@opentelemetry/sql-common@^0.40.1":
version "0.40.1"
@@ -8302,7 +8283,7 @@
"@types/mime" "*"
"@types/node" "*"
-"@types/shimmer@^1.0.2", "@types/shimmer@^1.2.0":
+"@types/shimmer@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@types/shimmer/-/shimmer-1.2.0.tgz#9b706af96fa06416828842397a70dfbbf1c14ded"
integrity sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==
@@ -11367,7 +11348,7 @@ byte-size@8.1.1:
resolved "https://registry.yarnpkg.com/byte-size/-/byte-size-8.1.1.tgz#3424608c62d59de5bfda05d31e0313c6174842ae"
integrity sha512-tUkzZWK0M/qdoLEqikxBWe4kumyuwjl3HO6zHTr4yEI23EojPtLYXdG1+AQY7MN0cGyNDvEaJ8wiYQm6P2bPxg==
-bytes-iec@^3.1.1:
+bytes-iec@3.1.1, bytes-iec@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/bytes-iec/-/bytes-iec-3.1.1.tgz#94cd36bf95c2c22a82002c247df8772d1d591083"
integrity sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA==
@@ -22496,14 +22477,6 @@ opener@^1.5.2:
resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598"
integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==
-opentelemetry-instrumentation-remix@0.8.0:
- version "0.8.0"
- resolved "https://registry.yarnpkg.com/opentelemetry-instrumentation-remix/-/opentelemetry-instrumentation-remix-0.8.0.tgz#cf917395f82b2c995ee46068d85d9fa1c95eb36f"
- integrity sha512-2XhIEWfzHeQmxnzv9HzklwkgYMx4NuWwloZuVIwjUb9R28gH5j3rJPqjErTvYSyz0fLbw0gyI+gfYHKHn/v/1Q==
- dependencies:
- "@opentelemetry/instrumentation" "^0.52.1"
- "@opentelemetry/semantic-conventions" "^1.25.1"
-
optional-require@1.0.x:
version "1.0.3"
resolved "https://registry.yarnpkg.com/optional-require/-/optional-require-1.0.3.tgz#275b8e9df1dc6a17ad155369c2422a440f89cb07"