diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.server.tsx index 178a8ed4e377..b646f036b3ad 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.server.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.server.tsx @@ -1,3 +1,4 @@ +import '../instrument.mjs'; import { createReadableStreamFromReadable } from '@react-router/node'; import * as Sentry from '@sentry/react-router'; import { renderToPipeableStream } from 'react-dom/server'; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes.ts index 2754c153ad2f..9f0dd2325c42 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes.ts @@ -16,6 +16,7 @@ export default [ route('error-middleware', 'routes/performance/error-middleware.tsx'), route('lazy-route', 'routes/performance/lazy-route.tsx'), route('fetcher-test', 'routes/performance/fetcher-test.tsx'), - route('redis', 'routes/performance/redis.tsx'), + route('db-ioredis', 'routes/performance/db-ioredis.tsx'), + route('db-mysql', 'routes/performance/db-mysql.tsx'), ]), ] satisfies RouteConfig; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes/db-ioredis.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/db-ioredis.tsx similarity index 55% rename from dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes/db-ioredis.tsx rename to dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/db-ioredis.tsx index 8774bf6d1555..ba48126c0c3d 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes/db-ioredis.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/db-ioredis.tsx @@ -1,9 +1,6 @@ import Redis from 'ioredis'; +import type { Route } from './+types/db-ioredis'; -// Page route (not a loader-only resource route): `@sentry/react-router` only -// renames the `http.server` transaction to the matched route (`GET /db-ioredis`) -// for rendered routes, so the orchestrion-injected ioredis spans land on a -// per-route transaction rather than the Express catch-all `GET /{*splat}`. export async function loader() { const redis = new Redis({ // Don't keep retrying forever if Redis goes away (e.g. on test teardown) @@ -20,6 +17,6 @@ export async function loader() { } } -export default function DbIoredis() { +export default function DbIoredis(_props: Route.ComponentProps) { return
db-ioredis
; } diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes/db-mysql.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/db-mysql.tsx similarity index 78% rename from dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes/db-mysql.tsx rename to dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/db-mysql.tsx index d7031799b70e..85fa18d37a3f 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes/db-mysql.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/db-mysql.tsx @@ -1,4 +1,5 @@ import mysql from 'mysql'; +import type { Route } from './+types/db-mysql'; const connection = mysql.createConnection({ user: 'root', @@ -15,6 +16,6 @@ export function loader() { }); } -export default function DbMysql() { +export default function DbMysql(_props: Route.ComponentProps) { return
db-mysql
; } diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/redis.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/redis.tsx deleted file mode 100644 index cba8275fcf63..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/redis.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import Redis from 'ioredis'; -import type { Route } from './+types/redis'; - -const redis = new Redis(); - -export async function loader() { - const key = 'cache:greeting'; - await redis.set(key, 'hello from react-router'); - const value = await redis.get(key); - - return { value }; -} - -export default function RedisPage({ loaderData }: Route.ComponentProps) { - const { value } = loaderData; - return ( -
-

Redis Page

-
{value}
-
- ); -} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/docker-compose.yml b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/docker-compose.yml index a2cb7ab5f088..489862f006f7 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/docker-compose.yml +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/docker-compose.yml @@ -10,3 +10,21 @@ services: interval: 1s timeout: 3s retries: 30 + + db: + image: mysql:8.0 + restart: always + container_name: e2e-tests-react-router-7-instrumentation-mysql + # The `mysql` 2.x driver doesn't speak MySQL 8's default + # `caching_sha2_password` auth, so force the legacy plugin. + command: ['--default-authentication-plugin=mysql_native_password'] + ports: + - '3306:3306' + environment: + MYSQL_ROOT_PASSWORD: docker + healthcheck: + test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -pdocker'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/global-setup.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/global-setup.mjs index aaf07f256cd3..560a7875fe60 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/global-setup.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/global-setup.mjs @@ -7,12 +7,17 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); export default async function globalSetup() { // Each run copies this app to a fresh temp dir, so `docker compose` doesn't // recognize a leftover container from a previous (e.g. interrupted) run as - // part of the same project - but the container name is fixed, so the daemon - // still refuses to create a new one. Force-remove any stale leftover first. - try { - execSync('docker rm -f e2e-tests-react-router-7-instrumentation-redis', { stdio: 'ignore' }); - } catch { - // no stale container to remove + // part of the same project - but the container names are fixed, so the daemon + // still refuses to create new ones. Force-remove any stale leftovers first. + for (const container of [ + 'e2e-tests-react-router-7-instrumentation-redis', + 'e2e-tests-react-router-7-instrumentation-mysql', + ]) { + try { + execSync(`docker rm -f ${container}`, { stdio: 'ignore' }); + } catch { + // no stale container to remove + } } execSync('docker compose up -d --wait', { cwd: __dirname, stdio: 'inherit' }); } diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs index bf39b453fb97..00a6d2952286 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs @@ -1,7 +1,5 @@ import * as Sentry from '@sentry/react-router'; -// Initialize Sentry early (before the server starts) -// The server instrumentations are created in entry.server.tsx Sentry.init({ traceLifecycle: 'static', dsn: 'https://username@domain/123', diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/package.json index 41a5cec4c2ee..56a3736fafd2 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/package.json @@ -3,12 +3,14 @@ "version": "0.1.0", "type": "module", "private": true, + "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels, so the orchestrion build-time transform injects the diagnostics_channel publishers.", "dependencies": { "@react-router/node": "^7", "@react-router/serve": "^7", "@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz", - "ioredis": "^5.4.1", + "ioredis": "5.10.1", "isbot": "^5.1.17", + "mysql": "^2.18.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router": "^7" @@ -17,6 +19,7 @@ "@playwright/test": "~1.56.0", "@react-router/dev": "^7", "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/mysql": "^2.15.26", "@types/node": "^20", "@types/react": "18.3.1", "@types/react-dom": "18.3.1", @@ -25,8 +28,8 @@ }, "scripts": { "build": "react-router build", - "dev": "NODE_OPTIONS='--import ./instrument.mjs' react-router dev", - "start": "NODE_OPTIONS='--import ./instrument.mjs' react-router-serve ./build/server/index.js", + "dev": "react-router dev", + "start": "react-router-serve ./build/server/index.js", "proxy": "node start-event-proxy.mjs", "typecheck": "react-router typegen && tsc", "clean": "npx rimraf node_modules pnpm-lock.yaml", diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/build-injection.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/build-injection.test.ts new file mode 100644 index 000000000000..d66ff8436650 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/build-injection.test.ts @@ -0,0 +1,37 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from '@playwright/test'; + +function readBundle(dir: string): string { + const root = path.join(process.cwd(), dir); + return readdirSync(root, { recursive: true }) + .filter((file): file is string => typeof file === 'string' && file.endsWith('.js')) + .map(file => readFileSync(path.join(root, file), 'utf8')) + .join('\n'); +} + +test.describe('Orchestrion build-time injection', () => { + const serverBundle = readBundle('build/server'); + const clientBundle = readBundle('build/client'); + + test('force-bundles instrumented dependencies', () => { + expect(serverBundle).not.toMatch(/(from\s*["']mysql["']|require\(["']mysql["']\))/); + expect(serverBundle).not.toMatch(/(from\s*["']ioredis["']|require\(["']ioredis["']\))/); + }); + + test('injects diagnostics-channel publishers into the server build', () => { + expect(serverBundle).toContain('__SENTRY_ORCHESTRION__'); + expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:mysql:query["']\)/); + expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:command["']\)/); + expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:connect["']\)/); + }); + + test('does not inject diagnostics-channel publishers into the client build', () => { + // The client build may carry the inert `__SENTRY_ORCHESTRION__.bundler = []` detection marker + // (it's environment-agnostic and harmless in a browser), but the actual `tracingChannel` publishers + // and their channel names must never leak — `applyToEnvironment` keeps the transform server-only, so + // a browser never hits a `diagnostics_channel` call that would throw. + expect(clientBundle).not.toMatch(/tracingChannel\(/); + expect(clientBundle).not.toMatch(/orchestrion:[a-z]/); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/db.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/db.server.test.ts new file mode 100644 index 000000000000..7812077a547d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/db.server.test.ts @@ -0,0 +1,114 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +// Orchestrion injects `diagnostics_channel` publishers at build time, so these spans only exist in the +// bundled server build. `react-router dev` serves an unbundled SSR pipeline where the transform never +// runs, so the assertions are meaningless there — skip them in the dev run. +const isDev = process.env.TEST_ENV === 'development'; + +test.describe('server - orchestrion build-time db instrumentation', () => { + test.skip(isDev, 'orchestrion only injects into the bundled server build, not the dev server'); + + test('instruments ioredis automatically via orchestrion', async ({ page }) => { + const transactionEventPromise = waitForTransaction(APP_NAME, transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /performance/db-ioredis' + ); + }); + + await page.goto('/performance/db-ioredis'); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + // The server transaction must come from the native instrumentation API (not the legacy handler), + // proving the orchestrion-injected db spans share context with the React Router server span. + expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.react_router.instrumentation_api'); + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.redis', + description: 'set test-key [1 other arguments]', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'redis', + 'db.statement': 'set test-key [1 other arguments]', + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.redis', + description: 'get test-key', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'redis', + 'db.statement': 'get test-key', + }), + }), + ); + + // Each command maps to exactly one span (no offline-queue duplicate). + const setSpans = spans.filter(span => span.description === 'set test-key [1 other arguments]'); + expect(setSpans).toHaveLength(1); + + // Every db span nests under the native instrumentation-API http.server transaction. + const rootSpanId = transactionEvent.contexts?.trace?.span_id; + const spanIds = new Set([rootSpanId, ...spans.map(span => span.span_id)]); + const dbSpans = spans.filter(span => span.op === 'db'); + expect(dbSpans.every(span => typeof span.parent_span_id === 'string' && spanIds.has(span.parent_span_id))).toBe( + true, + ); + }); + + test('instruments mysql automatically via orchestrion', async ({ page }) => { + const transactionEventPromise = waitForTransaction(APP_NAME, transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /performance/db-mysql' + ); + }); + + await page.goto('/performance/db-mysql'); + + const transactionEvent = await transactionEventPromise; + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.mysql', + description: 'SELECT 1 + 1 AS solution', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'mysql', + 'db.statement': 'SELECT 1 + 1 AS solution', + 'db.user': 'root', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': 3306, + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.mysql', + description: 'SELECT NOW()', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'mysql', + 'db.statement': 'SELECT NOW()', + 'db.user': 'root', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': 3306, + }), + }), + ); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/redis.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/redis.server.test.ts deleted file mode 100644 index eb60915bd89c..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/redis.server.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; -import { APP_NAME } from '../constants'; - -test.describe('server - redis db spans (instrumentation API)', () => { - test('OTel db.query spans nest under the native instrumentation-API http.server transaction', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === 'GET /performance/redis' && - (transactionEvent.spans?.some(span => span.op === 'db.query') ?? false) - ); - }); - - await page.goto('/performance/redis'); - - const transaction = await txPromise; - - // The server transaction must come from the native instrumentation API (not the legacy handler), - // proving auto-instrumented OTel spans still share context with the React Router server span. - expect(transaction.contexts?.trace?.op).toBe('http.server'); - expect(transaction.contexts?.trace?.origin).toBe('auto.http.react_router.instrumentation_api'); - - // Collect every span id in the transaction (root + children) so we can verify nesting. - const rootSpanId = transaction.contexts?.trace?.span_id; - const spanIds = new Set([rootSpanId, ...(transaction.spans ?? []).map(span => span.span_id)]); - - const redisSpans = transaction.spans!.filter(span => span.op === 'db.query'); - - // loader runs SET then GET => at least two redis command spans - expect(redisSpans.length).toBeGreaterThanOrEqual(2); - - // every redis span nests under the native instrumentation-API http.server transaction - const allNested = redisSpans.every( - span => typeof span.parent_span_id === 'string' && spanIds.has(span.parent_span_id), - ); - expect(allNested).toBe(true); - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts index 68ba30d69397..912a22c538fe 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.config.ts @@ -1,6 +1,9 @@ import { reactRouter } from '@react-router/dev/vite'; +import { sentryReactRouter } from '@sentry/react-router'; import { defineConfig } from 'vite'; -export default defineConfig({ - plugins: [reactRouter()], +export default defineConfig(config => { + return { + plugins: [reactRouter(), sentryReactRouter({ sourcemaps: { disable: true } }, config)], + }; }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/.gitignore deleted file mode 100644 index ebb991370034..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/.gitignore +++ /dev/null @@ -1,32 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# production -/build - -# misc -.DS_Store -.env.local -.env.development.local -.env.test.local -.env.production.local - -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -/test-results/ -/playwright-report/ -/playwright/.cache/ - -!*.d.ts - -# react router -.react-router diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/app.css b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/app.css deleted file mode 100644 index b31c3a9d0ddf..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/app.css +++ /dev/null @@ -1,6 +0,0 @@ -html, -body { - @media (prefers-color-scheme: dark) { - color-scheme: dark; - } -} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/entry.client.tsx deleted file mode 100644 index 83f29c042f0b..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/entry.client.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import * as Sentry from '@sentry/react-router'; -import { StrictMode, startTransition } from 'react'; -import { hydrateRoot } from 'react-dom/client'; -import { HydratedRouter } from 'react-router/dom'; - -Sentry.init({ - traceLifecycle: 'static', - environment: 'qa', // dynamic sampling bias to keep transactions - dsn: 'https://username@domain/123', - tunnel: `http://localhost:3031/`, // proxy server - integrations: [Sentry.reactRouterTracingIntegration()], - tracesSampleRate: 1.0, - tracePropagationTargets: [/^\//], -}); - -startTransition(() => { - hydrateRoot( - document, - - - , - ); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/entry.server.tsx deleted file mode 100644 index b646f036b3ad..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/entry.server.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import '../instrument.mjs'; -import { createReadableStreamFromReadable } from '@react-router/node'; -import * as Sentry from '@sentry/react-router'; -import { renderToPipeableStream } from 'react-dom/server'; -import { ServerRouter } from 'react-router'; -import { type HandleErrorFunction } from 'react-router'; - -const ABORT_DELAY = 5_000; - -const handleRequest = Sentry.createSentryHandleRequest({ - streamTimeout: ABORT_DELAY, - ServerRouter, - renderToPipeableStream, - createReadableStreamFromReadable, -}); - -export default handleRequest; - -export const handleError: HandleErrorFunction = Sentry.createSentryHandleError({ logErrors: true }); - -export const instrumentations = [Sentry.createSentryServerInstrumentation()]; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/root.tsx deleted file mode 100644 index fc20ee4f6895..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/root.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } from 'react-router'; -import type { Route } from './+types/root'; -import stylesheet from './app.css?url'; - -export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }]; - -export function Layout({ children }: { children: React.ReactNode }) { - return ( - - - - - - - - - {children} - - - - - ); -} - -export default function App() { - return ; -} - -export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { - let message = 'Oops!'; - let details = 'An unexpected error occurred.'; - let stack: string | undefined; - - if (isRouteErrorResponse(error)) { - message = error.status === 404 ? '404' : 'Error'; - details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details; - } else if (error && error instanceof Error) { - if (import.meta.env.DEV) { - details = error.message; - stack = error.stack; - } - } - - return ( -
-

{message}

-

{details}

- {stack && ( -
-          {stack}
-        
- )} -
- ); -} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes.ts deleted file mode 100644 index 560647eefa08..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type RouteConfig, index, route } from '@react-router/dev/routes'; - -export default [ - index('routes/home.tsx'), - route('db-mysql', 'routes/db-mysql.tsx'), - route('db-ioredis', 'routes/db-ioredis.tsx'), -] satisfies RouteConfig; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes/home.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes/home.tsx deleted file mode 100644 index 8d968568dcb7..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/app/routes/home.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export function meta() { - return [{ title: 'React Router 8 Orchestrion' }]; -} - -export default function Home() { - return
home
; -} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/docker-compose.yml b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/docker-compose.yml deleted file mode 100644 index f62a56205dc9..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/docker-compose.yml +++ /dev/null @@ -1,31 +0,0 @@ -services: - db: - image: mysql:8.0 - restart: always - container_name: e2e-tests-react-router-8-orchestrion-mysql - # The `mysql` 2.x driver doesn't speak MySQL 8's default - # `caching_sha2_password` auth, so force the legacy plugin. - command: ['--default-authentication-plugin=mysql_native_password'] - ports: - - '3306:3306' - environment: - MYSQL_ROOT_PASSWORD: docker - healthcheck: - test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -pdocker'] - interval: 2s - timeout: 3s - retries: 30 - start_period: 10s - - redis: - image: redis:7 - restart: always - container_name: e2e-tests-react-router-8-orchestrion-redis - ports: - - '6379:6379' - healthcheck: - test: ['CMD', 'redis-cli', 'ping'] - interval: 2s - timeout: 3s - retries: 30 - start_period: 5s diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/global-setup.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/global-setup.mjs deleted file mode 100644 index fddc0ab46143..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/global-setup.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import { execSync } from 'child_process'; -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -// Boot MySQL and Redis here (rather than in the `start` script) so the cold -// image pulls happen outside Playwright's webServer startup-timeout window. -// `--wait` blocks until the healthchecks in docker-compose.yml pass, so the app -// can connect immediately. -export default async function globalSetup() { - // Each run copies this app to a fresh temp dir, so `docker compose` doesn't - // recognize a leftover container from a previous (e.g. interrupted) run as - // part of the same project - but the container names are fixed, so the daemon - // still refuses to create new ones. Force-remove any stale leftovers first. - for (const container of [ - 'e2e-tests-react-router-8-orchestrion-mysql', - 'e2e-tests-react-router-8-orchestrion-redis', - ]) { - try { - execSync(`docker rm -f ${container}`, { stdio: 'ignore' }); - } catch { - // no stale container to remove - } - } - execSync('docker compose up -d --wait', { cwd: __dirname, stdio: 'inherit' }); -} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/global-teardown.mjs deleted file mode 100644 index 2742279431ad..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/global-teardown.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { execSync } from 'child_process'; -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export default async function globalTeardown() { - execSync('docker compose down --volumes', { - cwd: __dirname, - stdio: 'inherit', - }); -} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/instrument.mjs deleted file mode 100644 index 67b6ca2ff092..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/instrument.mjs +++ /dev/null @@ -1,9 +0,0 @@ -import * as Sentry from '@sentry/react-router'; - -Sentry.init({ - traceLifecycle: 'static', - dsn: 'https://username@domain/123', - environment: 'qa', // dynamic sampling bias to keep transactions - tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', // proxy server -}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/package.json deleted file mode 100644 index cc390c344d24..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "react-router-8-orchestrion", - "version": "0.1.0", - "type": "module", - "private": true, - "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", - "dependencies": { - "react": "^19.2.7", - "react-dom": "^19.2.7", - "react-router": "^8.0.0", - "@react-router/node": "^8.0.0", - "@react-router/serve": "^8.0.0", - "@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz", - "@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz", - "ioredis": "5.10.1", - "mysql": "^2.18.1" - }, - "devDependencies": { - "@types/react": "19.2.17", - "@types/react-dom": "19.2.3", - "@types/node": "^22", - "@react-router/dev": "^8.0.0", - "@playwright/test": "~1.58.0", - "@sentry-internal/test-utils": "link:../../../test-utils", - "typescript": "^5.6.3", - "vite": "^7.3.2" - }, - "scripts": { - "build": "react-router build", - "test:build-latest": "pnpm install && pnpm add react-router@latest && pnpm add @react-router/node@latest && pnpm add @react-router/serve@latest && pnpm build", - "dev": "react-router dev", - "start": "NODE_ENV=production react-router-serve ./build/server/index.js", - "proxy": "node start-event-proxy.mjs", - "clean": "npx rimraf node_modules pnpm-lock.yaml", - "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test", - "test": "playwright test" - }, - "volta": { - "extends": "../../package.json", - "node": "22.22.0" - }, - "sentryTest": { - "variants": [ - { - "build-command": "pnpm test:build-latest", - "label": "react-router-8-orchestrion (latest)" - } - ] - } -} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/playwright.config.mjs deleted file mode 100644 index ba35892417a8..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/playwright.config.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; -import { fileURLToPath } from 'url'; - -const config = getPlaywrightConfig( - { - startCommand: `PORT=3030 pnpm start`, - port: 3030, - }, - // Boot MySQL and Redis before the tests run, outside the webServer startup-timeout window. - { - globalSetup: fileURLToPath(new URL('./global-setup.mjs', import.meta.url)), - globalTeardown: fileURLToPath(new URL('./global-teardown.mjs', import.meta.url)), - }, -); - -export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/public/favicon.ico b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/public/favicon.ico deleted file mode 100644 index 5dbdfcddcb14..000000000000 Binary files a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/public/favicon.ico and /dev/null differ diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/react-router.config.ts deleted file mode 100644 index 51e8967770b3..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/react-router.config.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Config } from '@react-router/dev/config'; - -export default { - ssr: true, -} satisfies Config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/start-event-proxy.mjs deleted file mode 100644 index b7b1bbc72a53..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/start-event-proxy.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { startEventProxyServer } from '@sentry-internal/test-utils'; - -startEventProxyServer({ - port: 3031, - proxyServerName: 'react-router-8-orchestrion', -}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/tests/db.test.ts deleted file mode 100644 index 824ed4101bf0..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/tests/db.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('Instruments ioredis automatically via orchestrion', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('react-router-8-orchestrion', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /db-ioredis'; - }); - - await fetch(`${baseURL}/db-ioredis`); - - const transactionEvent = await transactionEventPromise; - - const spans = transactionEvent.spans || []; - - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.redis', - description: 'set test-key [1 other arguments]', - status: 'ok', - data: expect.objectContaining({ - 'db.system': 'redis', - 'db.statement': 'set test-key [1 other arguments]', - }), - }), - ); - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.redis', - description: 'get test-key', - status: 'ok', - data: expect.objectContaining({ - 'db.system': 'redis', - 'db.statement': 'get test-key', - }), - }), - ); - - // Each command maps to exactly one span (no offline-queue duplicate). - const setSpans = spans.filter(span => span.description === 'set test-key [1 other arguments]'); - expect(setSpans).toHaveLength(1); -}); - -test('Instruments mysql automatically via orchestrion', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('react-router-8-orchestrion', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /db-mysql'; - }); - - await fetch(`${baseURL}/db-mysql`); - - const transactionEvent = await transactionEventPromise; - - const spans = transactionEvent.spans || []; - - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.mysql', - description: 'SELECT 1 + 1 AS solution', - status: 'ok', - data: expect.objectContaining({ - 'db.system': 'mysql', - 'db.statement': 'SELECT 1 + 1 AS solution', - 'db.user': 'root', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': 3306, - }), - }), - ); - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.mysql', - description: 'SELECT NOW()', - status: 'ok', - data: expect.objectContaining({ - 'db.system': 'mysql', - 'db.statement': 'SELECT NOW()', - 'db.user': 'root', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': 3306, - }), - }), - ); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/tsconfig.json deleted file mode 100644 index a16df276e8bc..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "lib": ["DOM", "DOM.Iterable", "ES2022"], - "types": ["node", "vite/client"], - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "jsx": "react-jsx", - "rootDirs": [".", "./.react-router/types"], - "baseUrl": ".", - - "esModuleInterop": true, - "verbatimModuleSyntax": true, - "noEmit": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "strict": true - }, - "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"] -} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/vite.config.ts deleted file mode 100644 index 494f3d41f2ce..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-orchestrion/vite.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { reactRouter } from '@react-router/dev/vite'; -import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; -import { defineConfig } from 'vite'; - -export default defineConfig({ - plugins: [ - reactRouter(), - // Runs the orchestrion code transform over the SSR server bundle and - // force-bundles the instrumented deps (mysql, ioredis, …) so the - // diagnostics-channel calls are actually injected at build time. - sentryOrchestrionPlugin(), - ], -}); diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 54d85e4ed55b..94bc5f2f5134 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -52,6 +52,7 @@ "@sentry/core": "10.67.0", "@sentry/node": "10.67.0", "@sentry/react": "10.67.0", + "@sentry/server-utils": "10.67.0", "@sentry/bundler-plugins": "10.67.0", "glob": "^13.0.6" }, diff --git a/packages/react-router/src/vite/plugin.ts b/packages/react-router/src/vite/plugin.ts index 4a66a2575987..602ca6f4c412 100644 --- a/packages/react-router/src/vite/plugin.ts +++ b/packages/react-router/src/vite/plugin.ts @@ -1,3 +1,4 @@ +import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; import type { ConfigEnv, Plugin } from 'vite'; import { makeConfigInjectorPlugin } from './makeConfigInjectorPlugin'; import { makeCustomSentryVitePlugins } from './makeCustomSentryVitePlugins'; @@ -22,6 +23,7 @@ export async function sentryReactRouter( plugins.push(makeServerBuildCapturePlugin()); if (process.env.NODE_ENV !== 'development' && viteConfig.command === 'build' && viteConfig.mode !== 'development') { + plugins.push(sentryOrchestrionPlugin({ buildTimeInstrumentation: options.buildTimeInstrumentation })); plugins.push(makeEnableSourceMapsPlugin(options)); plugins.push(...(await makeCustomSentryVitePlugins(options))); } diff --git a/packages/react-router/test/vite/plugin.test.ts b/packages/react-router/test/vite/plugin.test.ts index 52306eb0dbd1..52cbecceb9a9 100644 --- a/packages/react-router/test/vite/plugin.test.ts +++ b/packages/react-router/test/vite/plugin.test.ts @@ -17,11 +17,21 @@ vi.mock('../../src/vite/makeEnableSourceMapsPlugin'); vi.mock('../../src/vite/makeConfigInjectorPlugin'); vi.mock('../../src/vite/makeServerBuildCapturePlugin'); +// Stub the orchestrion plugin so these stay pure wiring tests (no apm code transformer pulled in). +// Mirror the real plugin's contract: `buildTimeInstrumentation: false` yields the inert variant. +const orchestrionVite = vi.fn((options?: { buildTimeInstrumentation?: boolean }) => ({ + name: options?.buildTimeInstrumentation === false ? 'sentry-orchestrion-disabled' : 'sentry-orchestrion-vite', +})); +vi.mock('@sentry/server-utils/orchestrion/vite', () => ({ + sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean }) => orchestrionVite(options), +})); + describe('sentryReactRouter', () => { const mockPlugins = [{ name: 'test-plugin' }]; const mockSourceMapsPlugin = { name: 'source-maps-plugin' }; const mockConfigInjectorPlugin = { name: 'sentry-config-injector' }; const mockServerBuildCapturePlugin = { name: 'sentry-react-router-server-build-capture' }; + const mockOrchestrionPlugin = { name: 'sentry-orchestrion-vite' }; beforeEach(() => { vi.clearAllMocks(); @@ -73,6 +83,7 @@ describe('sentryReactRouter', () => { expect(result).toEqual([ mockConfigInjectorPlugin, mockServerBuildCapturePlugin, + mockOrchestrionPlugin, mockSourceMapsPlugin, ...mockPlugins, ]); @@ -102,4 +113,39 @@ describe('sentryReactRouter', () => { process.env.NODE_ENV = originalNodeEnv; }); + + it('adds the orchestrion plugin to the server build by default', async () => { + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const result = await sentryReactRouter({}, { command: 'build', mode: 'production' }); + expect(orchestrionVite).toHaveBeenCalledWith({ buildTimeInstrumentation: undefined }); + expect(result.map(plugin => plugin?.name)).toContain('sentry-orchestrion-vite'); + + process.env.NODE_ENV = originalNodeEnv; + }); + + it('adds an inert orchestrion plugin when `buildTimeInstrumentation` is `false`', async () => { + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const result = await sentryReactRouter( + { buildTimeInstrumentation: false }, + { command: 'build', mode: 'production' }, + ); + const pluginNames = result.map(plugin => plugin?.name); + expect(orchestrionVite).toHaveBeenCalledWith({ buildTimeInstrumentation: false }); + expect(pluginNames).toContain('sentry-orchestrion-disabled'); + expect(pluginNames).not.toContain('sentry-orchestrion-vite'); + + process.env.NODE_ENV = originalNodeEnv; + }); + + it('does not add the orchestrion plugin to the dev server (serve command)', async () => { + const result = await sentryReactRouter({}, { command: 'serve', mode: 'production' }); + expect(orchestrionVite).not.toHaveBeenCalled(); + const pluginNames = result.map(plugin => plugin?.name); + expect(pluginNames).not.toContain('sentry-orchestrion-vite'); + expect(pluginNames).not.toContain('sentry-orchestrion-disabled'); + }); });