Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -20,6 +17,6 @@ export async function loader() {
}
}

export default function DbIoredis() {
export default function DbIoredis(_props: Route.ComponentProps) {
return <div>db-ioredis</div>;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import mysql from 'mysql';
import type { Route } from './+types/db-mysql';

const connection = mysql.createConnection({
user: 'root',
Expand All @@ -15,6 +16,6 @@ export function loader() {
});
}

export default function DbMysql() {
export default function DbMysql(_props: Route.ComponentProps) {
return <div>db-mysql</div>;
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
}
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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]/);
});
});
Comment thread
chargome marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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,
}),
}),
);
});
});

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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)],
};
});

This file was deleted.

Loading
Loading