Skip to content
Open
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
@@ -0,0 +1,35 @@
# 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

# cloudflare
.wrangler
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as Sentry from '@sentry/react-router';
import { startTransition, StrictMode } 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,
<StrictMode>
<HydratedRouter onError={Sentry.sentryOnError} />
</StrictMode>,
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import * as Sentry from '@sentry/react-router/cloudflare';
import { isbot } from 'isbot';
import { renderToReadableStream } from 'react-dom/server';
import { type EntryContext, type HandleErrorFunction, ServerRouter } from 'react-router';

// workerd has no `renderToPipeableStream`, so this renders to a web stream instead.
async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
): Promise<Response> {
let shellRendered = false;
const userAgent = request.headers.get('user-agent');

const body = await renderToReadableStream(<ServerRouter context={routerContext} url={request.url} />, {
signal: request.signal,
onError(error: unknown) {
responseStatusCode = 500;
// Errors thrown after the shell has flushed can't change the status code, so surface them.
if (shellRendered) {
// eslint-disable-next-line no-console
console.error(error);
}
},
});
shellRendered = true;

// Bots need complete markup rather than a streamed shell.
if (userAgent && isbot(userAgent)) {
await body.allReady;
}

responseHeaders.set('Content-Type', 'text/html');

return new Response(Sentry.injectTraceMetaTags(body), {
headers: responseHeaders,
status: responseStatusCode,
});
}

export const handleError: HandleErrorFunction = (error, { request }) => {
// React Router aborts interrupted requests, don't report those.
if (!request.signal.aborted) {
Sentry.captureException(error);
// eslint-disable-next-line no-console
console.error(error);
}
};

export default Sentry.wrapSentryHandleRequest(handleRequest);
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';

export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}

export default function App() {
return <Outlet />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { index, prefix, route, type RouteConfig } from '@react-router/dev/routes';

export default [
index('routes/home.tsx'),
...prefix('performance', [route('db-mysql', 'routes/performance/db-mysql.tsx')]),
] satisfies RouteConfig;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Link } from 'react-router';

export default function Home() {
return (
<div>
<h1>react-router-8-cloudflare</h1>
<Link to="/performance/db-mysql">db-mysql</Link>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import mysql from 'mysql';
import type { Route } from './+types/db-mysql';

// These queries produce `db` spans from the build-time orchestrion transform alone — workerd can't
// monkey-patch requires, so there's no OTel hook involved.
export async function loader(): Promise<{ status: string }> {
// Connect inside the loader: workerd forbids I/O in global scope.
const connection = mysql.createConnection({
host: '127.0.0.1',
port: 3306,
user: 'root',
password: 'docker',
});

// Swallow socket-level errors so they don't fail the request for reasons unrelated to the spans.
connection.on('error', () => {
// no-op
});

try {
// The nested query runs in a fresh async context (mysql dispatches callbacks from its socket
// handler), so it only lands on this transaction if the subscriber restored the parent span.
await new Promise<void>((resolve, reject) => {
connection.query('SELECT 1 + 1 AS solution', err1 => {
if (err1) return reject(err1);
connection.query('SELECT NOW()', err2 => {
if (err2) return reject(err2);
resolve();
});
});
});
return { status: 'ok' };
} finally {
connection.end();
}
}

export default function DbMysql(_props: Route.ComponentProps) {
return <div>db-mysql</div>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
services:
db:
image: mysql:8.0
restart: always
container_name: e2e-tests-react-router-8-cloudflare-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
@@ -0,0 +1,13 @@
import { execSync } from 'child_process';
import { dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));

export default async function globalSetup() {
// `--wait` blocks until the healthcheck passes, so the first request can connect.
execSync('docker compose up -d --wait', {
cwd: __dirname,
stdio: 'inherit',
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
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',
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"name": "react-router-8-cloudflare",
"version": "0.1.0",
"type": "module",
"private": true,
"dependencies": {
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
"@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz",
"isbot": "^5.1.17",
"mysql": "^2.18.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router": "^8"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.35.0",
"@cloudflare/workers-types": "^4.20260504.0",
"@playwright/test": "~1.56.0",
"@react-router/dev": "^8",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@types/mysql": "^2.15.26",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"typescript": "^5.9.0",
"vite": "7.3.2",
"wrangler": "^4.72.0"
},
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
"preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --port 3030",
"proxy": "node start-event-proxy.mjs",
"typecheck": "react-router typegen && tsc",
"clean": "npx rimraf node_modules pnpm-lock.yaml",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm typecheck && TEST_ENV=production playwright test"
},
"volta": {
"node": "24.15.0",
"extends": "../../package.json"
},
"sentryTest": {
"optional": true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

const config = getPlaywrightConfig(
{
startCommand: 'pnpm preview',
port: 3030,
},
{
globalSetup: './global-setup.mjs',
globalTeardown: './global-teardown.mjs',
},
);

export default config;
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { Config } from '@react-router/dev/config';

export default {
ssr: true,
} satisfies Config;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'react-router-8-cloudflare',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

test('a real mysql query emits a db span with orchestrion-channel attributes', async ({ request }) => {
const transactionPromise = waitForTransaction('react-router-8-cloudflare', transactionEvent => {
return (
transactionEvent.contexts?.trace?.op === 'http.server' &&
(transactionEvent.spans?.some(span => span.op === 'db') ?? false)
);
});

const res = await request.get('/performance/db-mysql');
expect(res.status()).toBe(200);

const transactionEvent = await transactionPromise;
const dbSpans = transactionEvent.spans!.filter(span => span.op === 'db');

const firstQuery = dbSpans.find(span => span.description === 'SELECT 1 + 1 AS solution');
expect(firstQuery).toBeDefined();
expect(firstQuery!.data?.['sentry.origin']).toBe('auto.db.mysql');
expect(firstQuery!.data?.['db.system']).toBe('mysql');
expect(firstQuery!.data?.['db.statement']).toBe('SELECT 1 + 1 AS solution');
expect(firstQuery!.data?.['net.peer.name']).toBe('127.0.0.1');
expect(firstQuery!.data?.['net.peer.port']).toBe(3306);
expect(firstQuery!.data?.['db.user']).toBe('root');
});

test('a nested query lands on the same transaction (async context restored)', async ({ request }) => {
const transactionPromise = waitForTransaction('react-router-8-cloudflare', transactionEvent => {
return (
transactionEvent.contexts?.trace?.op === 'http.server' &&
(transactionEvent.spans?.filter(span => span.op === 'db').length ?? 0) >= 2
);
});

const res = await request.get('/performance/db-mysql');
expect(res.status()).toBe(200);

const transactionEvent = await transactionPromise;
const descriptions = transactionEvent.spans!.filter(span => span.op === 'db').map(span => span.description);
expect(descriptions).toContain('SELECT 1 + 1 AS solution');
expect(descriptions).toContain('SELECT NOW()');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
// workers-types rather than node: the server runs in workerd.
"types": ["@cloudflare/workers-types", "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/**/*"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { cloudflare } from '@cloudflare/vite-plugin';
import { reactRouter } from '@react-router/dev/vite';
import { sentryReactRouter } from '@sentry/react-router';
import { defineConfig } from 'vite';

export default defineConfig(async config => ({
plugins: [
cloudflare({ viteEnvironment: { name: 'ssr' } }),
reactRouter(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...((await sentryReactRouter({ sourcemaps: { disable: true } }, config)) as any[]),
],
}));
Loading
Loading