diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/.gitignore deleted file mode 100644 index e637ae342782..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/.gitignore +++ /dev/null @@ -1,35 +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 - -# cloudflare -.wrangler diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/entry.client.tsx deleted file mode 100644 index 0d928d68760a..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/entry.client.tsx +++ /dev/null @@ -1,22 +0,0 @@ -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({ - 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-cloudflare/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/entry.server.tsx deleted file mode 100644 index df56b2a3a8c0..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/entry.server.tsx +++ /dev/null @@ -1,51 +0,0 @@ -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 { - let shellRendered = false; - const userAgent = request.headers.get('user-agent'); - - const body = await renderToReadableStream(, { - 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); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/root.tsx deleted file mode 100644 index c09b53b99d46..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/root.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; - -export function Layout({ children }: { children: React.ReactNode }) { - return ( - - - - - - - - - {children} - - - - - ); -} - -export default function App() { - return ; -} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes.ts deleted file mode 100644 index 744ffc9485de..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes.ts +++ /dev/null @@ -1,6 +0,0 @@ -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; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes/home.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes/home.tsx deleted file mode 100644 index 7e59685f3987..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes/home.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Link } from 'react-router'; - -export default function Home() { - return ( -
-

react-router-8-cloudflare

- db-mysql -
- ); -} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/docker-compose.yml b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/docker-compose.yml deleted file mode 100644 index c03828e36b43..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/docker-compose.yml +++ /dev/null @@ -1,18 +0,0 @@ -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 diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/global-setup.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/global-setup.mjs deleted file mode 100644 index b0695e3731f7..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/global-setup.mjs +++ /dev/null @@ -1,13 +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 globalSetup() { - // `--wait` blocks until the healthcheck passes, so the first request can connect. - execSync('docker compose up -d --wait', { - cwd: __dirname, - stdio: 'inherit', - }); -} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/global-teardown.mjs deleted file mode 100644 index 2742279431ad..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/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-cloudflare/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/package.json deleted file mode 100644 index 094e2e4ebfbe..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "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.63.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.5", - "wrangler": "^4.120.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 - } -} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/playwright.config.mjs deleted file mode 100644 index 579222812424..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/playwright.config.mjs +++ /dev/null @@ -1,14 +0,0 @@ -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; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/public/favicon.ico b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/public/favicon.ico deleted file mode 100644 index 5dbdfcddcb14..000000000000 Binary files a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/public/favicon.ico and /dev/null differ diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/react-router.config.ts deleted file mode 100644 index 51e8967770b3..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/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-cloudflare/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/start-event-proxy.mjs deleted file mode 100644 index d78c58fa1714..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/start-event-proxy.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { startEventProxyServer } from '@sentry-internal/test-utils'; - -startEventProxyServer({ - port: 3031, - proxyServerName: 'react-router-8-cloudflare', -}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tests/db.test.ts deleted file mode 100644 index 1dfa920d1625..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tests/db.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; - -// Under span streaming the mysql span name is the query summary, so both queries here are named -// `SELECT`. `db.query.text` is what tells them apart. -test('a real mysql query emits a db span with orchestrion-channel attributes', async ({ request }) => { - const spansPromise = collectStreamedSpans( - 'react-router-8-cloudflare', - spansOfTrace => - spansOfTrace.some(span => getSpanOp(span) === 'http.server' && span.is_segment) && - spansOfTrace.some(span => getSpanOp(span) === 'db'), - ); - - const res = await request.get('/performance/db-mysql'); - expect(res.status()).toBe(200); - - const spans = await spansPromise; - const dbSpans = spans.filter(span => getSpanOp(span) === 'db'); - - const firstQuery = dbSpans.find(span => span.attributes['db.query.text']?.value === 'SELECT ? + ? AS solution'); - expect(firstQuery).toBeDefined(); - expect(firstQuery!.name).toBe('SELECT'); - expect(firstQuery!.attributes['sentry.origin']?.value).toBe('auto.db.mysql'); - expect(firstQuery!.attributes['db.system.name']?.value).toBe('mysql'); - expect(firstQuery!.attributes['server.address']?.value).toBe('127.0.0.1'); - expect(firstQuery!.attributes['server.port']?.value).toBe(3306); - expect(firstQuery!.attributes['db.user']?.value).toBe('root'); -}); - -test('a nested query lands on the same segment (async context restored)', async ({ request }) => { - const spansPromise = collectStreamedSpans( - 'react-router-8-cloudflare', - spansOfTrace => - spansOfTrace.some(span => getSpanOp(span) === 'http.server' && span.is_segment) && - spansOfTrace.filter(span => getSpanOp(span) === 'db').length >= 2, - ); - - const res = await request.get('/performance/db-mysql'); - expect(res.status()).toBe(200); - - const spans = await spansPromise; - - // These are scoped to the http.server segment's trace, so both queries landing here is what proves - // the nested one kept the async context. - const queryTexts = spans - .filter(span => getSpanOp(span) === 'db') - .map(span => span.attributes['db.query.text']?.value); - expect(queryTexts).toContain('SELECT ? + ? AS solution'); - expect(queryTexts).toContain('SELECT NOW()'); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tsconfig.json deleted file mode 100644 index 573bcd3ad911..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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/**/*"] -} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/vite.config.ts deleted file mode 100644 index cd07d7a3fe03..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/vite.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { cloudflare } from '@cloudflare/vite-plugin'; -import { reactRouter } from '@react-router/dev/vite'; -import { sentryReactRouter } from '@sentry/react-router/vite'; -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[]), - ], -})); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/workers/app.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/workers/app.ts deleted file mode 100644 index d9a6c5451d72..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/workers/app.ts +++ /dev/null @@ -1,26 +0,0 @@ -import * as Sentry from '@sentry/cloudflare'; -import { createRequestHandler } from 'react-router'; - -const requestHandler = createRequestHandler(() => import('virtual:react-router/server-build'), import.meta.env.MODE); - -interface Env { - E2E_TEST_DSN: string; -} - -// `withSentry` is what reads the build-time orchestrion marker; without it the injected -// `diagnostics_channel` publishers would fire with nobody subscribed. -export default Sentry.withSentry( - (env: Env) => ({ - dsn: env.E2E_TEST_DSN, - tunnel: 'http://localhost:3031/', - tracesSampleRate: 1.0, - environment: 'qa', // dynamic sampling bias to keep transactions - }), - { - // No load context: React Router 8 takes a `RouterContextProvider`, not v7's `{ cloudflare }` - // object, and nothing here reads bindings from a loader. - async fetch(request) { - return requestHandler(request); - }, - } satisfies ExportedHandler, -); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/wrangler.jsonc deleted file mode 100644 index 32521671d64e..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/wrangler.jsonc +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "node_modules/wrangler/config-schema.json", - "name": "react-router-8-cloudflare", - "compatibility_date": "2026-06-29", - "compatibility_flags": ["nodejs_compat"], - "main": "./workers/app.ts", - "assets": { - "directory": "./build/client", - }, - "observability": { - "enabled": true, - }, -} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes.ts index 3e3839295b1d..1e2b1b1dfd10 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes.ts @@ -20,5 +20,6 @@ export default [ route('server-action', 'routes/performance/server-action.tsx'), route('with-middleware', 'routes/performance/with-middleware.tsx'), route('redis', 'routes/performance/redis.tsx'), + route('db-mysql', 'routes/performance/db-mysql.tsx'), ]), ] satisfies RouteConfig; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes/performance/db-mysql.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/db-mysql.tsx similarity index 87% rename from dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes/performance/db-mysql.tsx rename to dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/db-mysql.tsx index ea2ac1fcea61..55d73a44004c 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/routes/performance/db-mysql.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/routes/performance/db-mysql.tsx @@ -1,8 +1,8 @@ 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. +// On Cloudflare these `db` spans come from the build-time orchestrion transform alone, because workerd +// can't monkey-patch requires. export async function loader(): Promise<{ status: string }> { // Connect inside the loader: workerd forbids I/O in global scope. const connection = mysql.createConnection({ diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/docker-compose.yml b/dev-packages/e2e-tests/test-applications/react-router-8-framework/docker-compose.yml index 27367c5ae12a..dfb0c2de26c0 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/docker-compose.yml +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/docker-compose.yml @@ -10,3 +10,20 @@ services: interval: 1s timeout: 3s retries: 30 + mysql: + image: mysql:8.0 + restart: always + container_name: e2e-tests-react-router-8-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-8-framework/global-setup.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-framework/global-setup.mjs index 55af1feaa14c..a7ab404749f5 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/global-setup.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/global-setup.mjs @@ -13,7 +13,7 @@ export default async function globalSetup() { // 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-8-redis', { stdio: 'ignore' }); + execSync('docker rm -f e2e-tests-react-router-8-redis e2e-tests-react-router-8-mysql', { stdio: 'ignore' }); } catch { // no stale container to remove } diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json index 55a78a9f66c7..9df6c921ef55 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json @@ -12,10 +12,12 @@ "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", "@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz", "ioredis": "^5.11.1", - "isbot": "^5.1.43" + "isbot": "^5.1.43", + "mysql": "^2.18.1" }, "devDependencies": { "@cloudflare/vite-plugin": "^1.35.0", + "@types/mysql": "^2.15.26", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@types/node": "^22", diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/mysql.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/mysql.server.test.ts new file mode 100644 index 000000000000..20377d10c47d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/mysql.server.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +// Under span streaming the mysql span name is the query summary, so both queries here are named +// `SELECT`. `db.query.text` is what tells them apart. +test.describe('server - mysql db spans', () => { + test('a real mysql query emits a db span with orchestrion-channel attributes', async ({ request }) => { + const spansPromise = collectStreamedSpans( + APP_NAME, + spansOfTrace => + spansOfTrace.some(span => getSpanOp(span) === 'http.server' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'db'), + ); + + const res = await request.get('/performance/db-mysql'); + expect(res.status()).toBe(200); + + const spans = await spansPromise; + const dbSpans = spans.filter(span => getSpanOp(span) === 'db'); + + const firstQuery = dbSpans.find(span => span.attributes['db.query.text']?.value === 'SELECT ? + ? AS solution'); + expect(firstQuery).toBeDefined(); + expect(firstQuery!.name).toBe('SELECT'); + expect(firstQuery!.attributes['sentry.origin']?.value).toBe('auto.db.mysql'); + expect(firstQuery!.attributes['db.system.name']?.value).toBe('mysql'); + expect(firstQuery!.attributes['server.address']?.value).toBe('127.0.0.1'); + expect(firstQuery!.attributes['server.port']?.value).toBe(3306); + expect(firstQuery!.attributes['db.user']?.value).toBe('root'); + }); + + test('a nested query lands on the same segment (async context restored)', async ({ request }) => { + const spansPromise = collectStreamedSpans( + APP_NAME, + spansOfTrace => + spansOfTrace.some(span => getSpanOp(span) === 'http.server' && span.is_segment) && + spansOfTrace.filter(span => getSpanOp(span) === 'db').length >= 2, + ); + + const res = await request.get('/performance/db-mysql'); + expect(res.status()).toBe(200); + + const spans = await spansPromise; + + // These are scoped to the http.server segment's trace, so both queries landing here is what proves + // the nested one kept the async context. + const queryTexts = spans + .filter(span => getSpanOp(span) === 'db') + .map(span => span.attributes['db.query.text']?.value); + expect(queryTexts).toContain('SELECT ? + ? AS solution'); + expect(queryTexts).toContain('SELECT NOW()'); + }); +});