diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/.gitignore b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/.gitignore new file mode 100644 index 000000000000..a547bf36d8d1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/docker-compose.yml b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/docker-compose.yml new file mode 100644 index 000000000000..954690210f58 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/docker-compose.yml @@ -0,0 +1,31 @@ +services: + db: + image: mysql:8.0 + restart: always + container_name: e2e-tests-tanstackstart-react-static-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-tanstackstart-react-static-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/tanstackstart-react-static/global-setup.mjs b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/global-setup.mjs new file mode 100644 index 000000000000..8f5f52009158 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/global-setup.mjs @@ -0,0 +1,14 @@ +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() { + // Start MySQL and Redis via Docker Compose. `--wait` blocks until the + // healthchecks in docker-compose.yml pass, so the app can connect immediately. + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/global-teardown.mjs new file mode 100644 index 000000000000..2742279431ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/global-teardown.mjs @@ -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', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/instrument.server.mjs b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/instrument.server.mjs new file mode 100644 index 000000000000..d9bccefea72a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/instrument.server.mjs @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/tanstackstart-react'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + traceLifecycle: 'static', + transportOptions: { + // We expect the app to send a lot of events in a short time + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/package.json new file mode 100644 index 000000000000..ff237684be5d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/package.json @@ -0,0 +1,52 @@ +{ + "name": "tanstackstart-react-static", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "build": "vite build && cp instrument.server.mjs .output/server", + "dev": "vite dev", + "start": "node --import ./.output/server/instrument.server.mjs .output/server/index.mjs", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:build:tunnel-generated": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:build:tunnel-static": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:build:tunnel-custom": "pnpm install && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build", + "test:build:tunnel-object": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest && pnpm install && pnpm build", + "test:assert:proxy": "pnpm test && TEST_ENV=development pnpm playwright test db-drivers", + "test:assert": "pnpm test:assert:proxy", + "test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", + "test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", + "test:assert:tunnel-custom": "E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm test", + "test:assert:tunnel-object": "E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test" + }, + "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", + "dependencies": { + "@sentry/tanstackstart-react": "file:../../packed/sentry-tanstackstart-react-packed.tgz", + "@tanstack/react-start": "1.168.35", + "@tanstack/react-router": "1.170.18", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "nitro": "latest || *", + "ioredis": "5.10.1", + "mysql": "^2.18.1" + }, + "devDependencies": { + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@types/node": "^24.10.0", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "@typescript-eslint/parser": "^7.2.0", + "@vitejs/plugin-react-swc": "^3.5.0", + "typescript": "^5.9.0", + "vite": "7.3.2", + "vite-tsconfig-paths": "^5.1.4", + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/playwright.config.mjs new file mode 100644 index 000000000000..cc18dd0ea437 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/playwright.config.mjs @@ -0,0 +1,26 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +// The DB driver tests only run in the default (proxy) variant, so the tunnel-route variants don't +// need MySQL/Redis — skip Docker there instead of booting the containers once per variant. +const usesManagedTunnelRoute = + (process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +// The dev server has no build output, so `--import` points at the app's own instrumentation file. +// `vite dev` ignores PORT, so the port goes on the command. +const startCommand = + process.env.TEST_ENV === 'development' + ? `NODE_OPTIONS='--import ./instrument.server.mjs' pnpm dev --port 3000` + : `pnpm start`; + +const config = getPlaywrightConfig({ + startCommand, + port: 3000, +}); + +export default usesManagedTunnelRoute + ? config + : { + ...config, + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', + }; diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/client.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/client.tsx new file mode 100644 index 000000000000..fc489cd7bdeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/client.tsx @@ -0,0 +1,21 @@ +// Imported first so Sentry.init() runs before any other import's side effects. +import './instrument.client'; +import './early-side-effect'; +import { StartClient } from '@tanstack/react-start/client'; +import { StrictMode, startTransition } from 'react'; +import { hydrateRoot } from 'react-dom/client'; + +console.log('early-breadcrumb-from-client-entry'); + +if (window.location.pathname === '/crash-before-hydration') { + throw new Error('Client Entry Crash'); +} + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/early-side-effect.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/early-side-effect.ts new file mode 100644 index 000000000000..1118a2e72f1a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/early-side-effect.ts @@ -0,0 +1,7 @@ +// Simulates a third-party import whose side effects run at module-evaluation time, +// i.e. before the client entry's body (and before hydration). +console.log('early-breadcrumb-from-imported-module'); + +if (typeof window !== 'undefined' && window.location.pathname === '/crash-in-imported-module') { + throw new Error('Imported Module Side Effect Crash'); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/globals.d.ts new file mode 100644 index 000000000000..6e7d31c7a4e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/globals.d.ts @@ -0,0 +1,2 @@ +declare const __APP_DSN__: string; +declare const __APP_TUNNEL__: string | undefined; diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/instrument.client.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/instrument.client.ts new file mode 100644 index 000000000000..95351c60f9ca --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/instrument.client.ts @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/tanstackstart-react'; + +Sentry.init({ + traceLifecycle: 'static', + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: __APP_DSN__, + tracesSampleRate: 1.0, + release: 'e2e-test', + tunnel: __APP_TUNNEL__, +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/middleware.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/middleware.ts new file mode 100644 index 000000000000..a15bd3744910 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/middleware.ts @@ -0,0 +1,41 @@ +import { createMiddleware } from '@tanstack/react-start'; + +// Global request middleware - runs on every request +// NOTE: This is exported unwrapped to test auto-instrumentation via the Vite plugin +export const globalRequestMiddleware = createMiddleware().server(async ({ next }) => { + console.log('Global request middleware executed'); + return next(); +}); + +// Global function middleware - runs on every server function +// NOTE: This is exported unwrapped to test auto-instrumentation via the Vite plugin +export const globalFunctionMiddleware = createMiddleware({ type: 'function' }).server(async ({ next }) => { + console.log('Global function middleware executed'); + return next(); +}); + +// Server function middleware - exported unwrapped for auto-instrumentation via Vite plugin +export const serverFnMiddleware = createMiddleware({ type: 'function' }).server(async ({ next }) => { + console.log('Server function middleware executed'); + return next(); +}); + +// Server route request middleware - exported unwrapped for auto-instrumentation via Vite plugin +export const serverRouteRequestMiddleware = createMiddleware().server(async ({ next }) => { + console.log('Server route request middleware executed'); + return next(); +}); + +// Early return middleware - returns without calling next() +// Exported unwrapped for auto-instrumentation via Vite plugin +export const earlyReturnMiddleware = createMiddleware({ type: 'function' }).server(async () => { + console.log('Early return middleware executed - not calling next()'); + return { earlyReturn: true, message: 'Middleware returned early without calling next()' }; +}); + +// Error middleware - throws an exception +// Exported unwrapped for auto-instrumentation via Vite plugin +export const errorMiddleware = createMiddleware({ type: 'function' }).server(async () => { + console.log('Error middleware executed - throwing error'); + throw new Error('Middleware Error Test'); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/router.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/router.tsx new file mode 100644 index 000000000000..670b22bc688d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/router.tsx @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/tanstackstart-react'; +import { createRouter } from '@tanstack/react-router'; +import { routeTree } from './routeTree.gen'; + +export const getRouter = () => { + const router = createRouter({ + routeTree, + scrollRestoration: true, + }); + + if (!router.isServer) { + Sentry.addIntegration(Sentry.tanstackRouterBrowserTracingIntegration(router)); + } + + return router; +}; diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/__root.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/__root.tsx new file mode 100644 index 000000000000..204276e1bdf2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/__root.tsx @@ -0,0 +1,50 @@ +import { useEffect, type ReactNode } from 'react'; +import { Outlet, createRootRoute, HeadContent, Scripts } from '@tanstack/react-router'; + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { + charSet: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + { + title: 'TanStack Start Starter', + }, + ], + }), + component: RootComponent, +}); + +function RootComponent() { + // Mark the document as hydrated so tests can wait for React to attach event + // handlers before clicking. `toBeVisible()` only confirms the SSR HTML is + // rendered; on slow CI runs Playwright can click before hydration completes, + // the onClick handler isn't yet attached, and tests asserting on the + // resulting client-side error time out (see #20641, #20685, #20867). + useEffect(() => { + document.documentElement.setAttribute('data-hydrated', 'true'); + }, []); + return ( + + + + ); +} + +function RootDocument({ children }: Readonly<{ children: ReactNode }>) { + return ( + + + + + + {children} + + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/api.db-ioredis.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/api.db-ioredis.ts new file mode 100644 index 000000000000..c3453f5be70f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/api.db-ioredis.ts @@ -0,0 +1,24 @@ +import { createFileRoute } from '@tanstack/react-router'; +import Redis from 'ioredis'; + +export const Route = createFileRoute('/api/db-ioredis')({ + server: { + handlers: { + GET: async () => { + const redis = new Redis({ + // Don't keep retrying forever if Redis goes away (e.g. on test teardown) + maxRetriesPerRequest: 1, + retryStrategy: () => null, + }); + + try { + await redis.set('test-key', 'test-value'); + const value = await redis.get('test-key'); + return Response.json({ value }); + } finally { + redis.disconnect(); + } + }, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/api.db-mysql.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/api.db-mysql.ts new file mode 100644 index 000000000000..3c5e634c015b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/api.db-mysql.ts @@ -0,0 +1,23 @@ +import { createFileRoute } from '@tanstack/react-router'; +import mysql from 'mysql'; + +const connection = mysql.createConnection({ + user: 'root', + password: 'docker', +}); + +export const Route = createFileRoute('/api/db-mysql')({ + server: { + handlers: { + GET: () => { + return new Promise(resolve => { + connection.query('SELECT 1 + 1 AS solution', () => { + connection.query('SELECT NOW()', ['1', '2'], () => { + resolve(Response.json({ status: 'ok' })); + }); + }); + }); + }, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/api.error.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/api.error.ts new file mode 100644 index 000000000000..041fb175c1f1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/api.error.ts @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/api/error')({ + server: { + handlers: { + GET: async () => { + throw new Error('Sentry API Route Test Error'); + }, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/api.flush.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/api.flush.ts new file mode 100644 index 000000000000..fb296b07d9b0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/api.flush.ts @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { flush } from '@sentry/tanstackstart-react'; + +export const Route = createFileRoute('/api/flush')({ + server: { + handlers: { + GET: async () => { + await flush(); + return new Response('ok'); + }, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/api.test-middleware.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/api.test-middleware.ts new file mode 100644 index 000000000000..d24f8ed61a45 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/api.test-middleware.ts @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { serverRouteRequestMiddleware } from '../middleware'; + +export const Route = createFileRoute('/api/test-middleware')({ + server: { + middleware: [serverRouteRequestMiddleware], + handlers: { + GET: async () => { + return { message: 'Server route middleware test' }; + }, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/api.user.$id.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/api.user.$id.ts new file mode 100644 index 000000000000..ce20e2f74ce1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/api.user.$id.ts @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/api/user/$id')({ + server: { + handlers: { + GET: async ({ params }) => { + return new Response(JSON.stringify({ id: params.id }), { + headers: { 'Content-Type': 'application/json' }, + }); + }, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/crash-before-hydration.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/crash-before-hydration.tsx new file mode 100644 index 000000000000..772a045a7e33 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/crash-before-hydration.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/crash-before-hydration')({ + component: CrashPage, +}); + +function CrashPage() { + return ( +
+

This page crashes in client.tsx before hydration

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/custom-monitor.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/custom-monitor.ts new file mode 100644 index 000000000000..1409123f0402 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/custom-monitor.ts @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/tanstackstart-react'; +import { createFileRoute } from '@tanstack/react-router'; + +const USE_CUSTOM_TUNNEL_ROUTE = process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +const DEFAULT_DSN = 'https://public@dsn.ingest.sentry.io/1337'; +const TUNNEL_DSN = 'http://public@localhost:3031/1337'; + +// Example of a manually defined tunnel endpoint without relying on the +// managed route injected by `sentryTanstackStart({ tunnelRoute: ... })`. +// If you use a custom route like this one, set `tunnel: '/custom-monitor'` in the client SDK's +// `Sentry.init()` call so browser events are sent to the same endpoint. +export const Route = createFileRoute('/custom-monitor')({ + server: Sentry.createSentryTunnelRoute({ + allowedDsns: [USE_CUSTOM_TUNNEL_ROUTE ? TUNNEL_DSN : DEFAULT_DSN], + }), +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/index.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/index.tsx new file mode 100644 index 000000000000..a92fabf20c4a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/index.tsx @@ -0,0 +1,41 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { createServerFn } from '@tanstack/react-start'; + +const throwServerError = createServerFn().handler(async () => { + throw new Error('Sentry Server Function Test Error'); +}); + +export const Route = createFileRoute('/')({ + component: Home, +}); + +function Home() { + return ( +
+ + + +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/param.$id.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/param.$id.tsx new file mode 100644 index 000000000000..43d32823168e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/param.$id.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/param/$id')({ + component: ParamPage, +}); + +function ParamPage() { + const { id } = Route.useParams(); + return ( +
+

Param: {id}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/ssr-error.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/ssr-error.tsx new file mode 100644 index 000000000000..71ba7ce92d29 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/ssr-error.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/ssr-error')({ + loader: () => { + throw new Error('Sentry SSR Test Error'); + }, + component: () =>
SSR Error Page
, +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/test-middleware.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/test-middleware.tsx new file mode 100644 index 000000000000..81d621b92a46 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/test-middleware.tsx @@ -0,0 +1,86 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { createServerFn } from '@tanstack/react-start'; +import { serverFnMiddleware, earlyReturnMiddleware, errorMiddleware } from '../middleware'; + +// Server function with specific middleware (also gets global function middleware) +const serverFnWithMiddleware = createServerFn() + .middleware([serverFnMiddleware]) + .handler(async () => { + console.log('Server function with specific middleware executed'); + return { message: 'Server function middleware test' }; + }); + +// Server function without specific middleware (only gets global function middleware) +const serverFnWithoutMiddleware = createServerFn().handler(async () => { + console.log('Server function without specific middleware executed'); + return { message: 'Global middleware only test' }; +}); + +// Server function with early return middleware (middleware returns without calling next) +const serverFnWithEarlyReturnMiddleware = createServerFn() + .middleware([earlyReturnMiddleware]) + .handler(async () => { + console.log('This should not be executed - middleware returned early'); + return { message: 'This should not be returned' }; + }); + +// Server function with error middleware (middleware throws an error) +const serverFnWithErrorMiddleware = createServerFn() + .middleware([errorMiddleware]) + .handler(async () => { + console.log('This should not be executed - middleware threw error'); + return { message: 'This should not be returned' }; + }); + +export const Route = createFileRoute('/test-middleware')({ + component: TestMiddleware, +}); + +function TestMiddleware() { + return ( +
+

Test Middleware Page

+ + + + +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/test-serverFn.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/test-serverFn.tsx new file mode 100644 index 000000000000..894ff4e85ee3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/test-serverFn.tsx @@ -0,0 +1,45 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { createServerFn } from '@tanstack/react-start'; +import { startSpan } from '@sentry/tanstackstart-react'; + +const testLog = createServerFn().handler(async () => { + console.log('Test log from server function'); + return { message: 'Log created' }; +}); + +const testNestedLog = createServerFn().handler(async () => { + await startSpan({ name: 'testNestedLog' }, async () => { + await testLog(); + }); + + console.log('Outer test log from server function'); + return { message: 'Nested log created' }; +}); + +export const Route = createFileRoute('/test-serverFn')({ + component: TestLog, +}); + +function TestLog() { + return ( +
+

Test Log Page

+ + +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/users.$userId.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/users.$userId.tsx new file mode 100644 index 000000000000..60f16f5cfece --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/users.$userId.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/users/$userId')({ + component: UserPage, +}); + +function UserPage() { + const { userId } = Route.useParams(); + return ( +
+

User: {userId}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/users.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/users.tsx new file mode 100644 index 000000000000..97684c05df19 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/routes/users.tsx @@ -0,0 +1,13 @@ +import { Outlet, createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/users')({ + component: UsersLayout, +}); + +function UsersLayout() { + return ( +
+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/server.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/server.ts new file mode 100644 index 000000000000..b10a3bc1e37b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/server.ts @@ -0,0 +1,12 @@ +import { wrapFetchWithSentry } from '@sentry/tanstackstart-react'; + +import handler, { createServerEntry } from '@tanstack/react-start/server-entry'; +import type { ServerEntry } from '@tanstack/react-start/server-entry'; + +const requestHandler: ServerEntry = wrapFetchWithSentry({ + fetch(request: Request) { + return handler.fetch(request); + }, +}); + +export default createServerEntry(requestHandler); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/start.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/start.ts new file mode 100644 index 000000000000..110e3602b1c9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/src/start.ts @@ -0,0 +1,11 @@ +import { sentryGlobalFunctionMiddleware, sentryGlobalRequestMiddleware } from '@sentry/tanstackstart-react'; +import { createStart } from '@tanstack/react-start'; +// NOTE: These are NOT wrapped - auto-instrumentation via the Vite plugin will wrap them +import { globalFunctionMiddleware, globalRequestMiddleware } from './middleware'; + +export const startInstance = createStart(() => { + return { + requestMiddleware: [sentryGlobalRequestMiddleware, globalRequestMiddleware], + functionMiddleware: [sentryGlobalFunctionMiddleware, globalFunctionMiddleware], + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/start-event-proxy.mjs new file mode 100644 index 000000000000..a3bc0afc007f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'tanstackstart-react-static', +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/db-drivers.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/db-drivers.test.ts new file mode 100644 index 000000000000..32858b1c2eb0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/db-drivers.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +const usesManagedTunnelRoute = + (process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant'); + +// Same spans in both runs, from two injectors: the orchestrion build-time transform that +// `sentryTanstackStart()` auto-wires into the server bundle, and the runtime hook in `vite dev`, +// where the drivers stay external on Node's own loader. +test('Instruments ioredis automatically', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react-static', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/db-ioredis' + ); + }); + + await fetch(`${baseURL}/api/db-ioredis`); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db.query', + origin: 'auto.db.redis', + description: 'set test-key [1 other arguments]', + status: 'ok', + data: expect.objectContaining({ + 'db.system.name': 'redis', + 'db.operation.name': 'set', + 'db.query.text': 'set test-key [1 other arguments]', + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db.query', + origin: 'auto.db.redis', + description: 'get test-key', + status: 'ok', + data: expect.objectContaining({ + 'db.system.name': 'redis', + 'db.operation.name': 'get', + 'db.query.text': 'get test-key', + }), + }), + ); +}); + +test('Instruments mysql automatically', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react-static', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/db-mysql' + ); + }); + + await fetch(`${baseURL}/api/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.name': 'mysql', + 'db.query.text': 'SELECT 1 + 1 AS solution', + 'db.user': 'root', + 'db.connection_string': expect.any(String), + 'server.address': expect.any(String), + 'server.port': 3306, + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.mysql', + description: 'SELECT NOW()', + status: 'ok', + data: expect.objectContaining({ + 'db.system.name': 'mysql', + 'db.query.text': 'SELECT NOW()', + 'db.user': 'root', + 'db.connection_string': expect.any(String), + 'server.address': expect.any(String), + 'server.port': 3306, + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/early-init.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/early-init.test.ts new file mode 100644 index 000000000000..914537d8554e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/early-init.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +const usesManagedTunnelRoute = + (process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant'); + +test('should capture errors thrown in client entry before hydration', async ({ page }) => { + const errorEventPromise = waitForError('tanstackstart-react-static', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Client Entry Crash'; + }); + + await page.goto('/crash-before-hydration'); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: 'Client Entry Crash', + }); + + const consoleBreadcrumbs = errorEvent.breadcrumbs?.filter( + b => b.category === 'console' && b.message?.includes('early-breadcrumb-from-client-entry'), + ); + + expect(consoleBreadcrumbs?.length).toBeGreaterThanOrEqual(1); +}); + +test('should capture errors thrown in a module imported by the client entry before hydration', async ({ page }) => { + const errorEventPromise = waitForError('tanstackstart-react-static', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Imported Module Side Effect Crash'; + }); + + await page.goto('/crash-in-imported-module'); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: 'Imported Module Side Effect Crash', + }); + + const consoleBreadcrumbs = errorEvent.breadcrumbs?.filter( + b => b.category === 'console' && b.message?.includes('early-breadcrumb-from-imported-module'), + ); + + expect(consoleBreadcrumbs?.length).toBeGreaterThanOrEqual(1); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/errors.test.ts new file mode 100644 index 000000000000..765bc5d7caa0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/errors.test.ts @@ -0,0 +1,140 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +const usesManagedTunnelRoute = + (process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant'); + +test('Sends client-side error to Sentry with auto-instrumentation', async ({ page }) => { + const errorEventPromise = waitForError('tanstackstart-react-static', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Sentry Client Test Error'; + }); + + await page.goto(`/`); + + // Wait for React to hydrate (see __root.tsx) before clicking — the SSR HTML + // renders the button before the onClick handler is attached, and clicking + // pre-hydration would fire no handler and produce no error. + await page.locator('html[data-hydrated="true"]').waitFor(); + + await expect(page.locator('button').filter({ hasText: 'Break the client' })).toBeVisible(); + + await page.locator('button').filter({ hasText: 'Break the client' }).click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Sentry Client Test Error', + mechanism: { + handled: false, + }, + }, + ], + }, + }); + + expect(errorEvent.transaction).toBe('/'); +}); + +test('Sends server-side function error to Sentry with auto-instrumentation', async ({ page }) => { + const errorEventPromise = waitForError('tanstackstart-react-static', errorEvent => { + // The thrown error propagates back to the client over the server-function RPC and is also + // captured there as an `onunhandledrejection` with the same message. Match on the server-function + // mechanism so we deterministically pick the server-side event instead of racing the client one. + return ( + errorEvent?.exception?.values?.[0]?.value === 'Sentry Server Function Test Error' && + errorEvent?.exception?.values?.[0]?.mechanism?.type === 'auto.middleware.tanstackstart.server_function' + ); + }); + + await page.goto(`/`); + + await expect(page.locator('button').filter({ hasText: 'Break server function' })).toBeVisible(); + + await page.locator('button').filter({ hasText: 'Break server function' }).click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Sentry Server Function Test Error', + mechanism: { + type: 'auto.middleware.tanstackstart.server_function', + handled: false, + }, + }, + ], + }, + }); + + expect(errorEvent.transaction).toEqual(expect.stringContaining('GET /_serverFn/')); +}); + +test('Sends API route error to Sentry with auto-instrumentation', async ({ page }) => { + const errorEventPromise = waitForError('tanstackstart-react-static', errorEvent => { + // As with the server-function test, guard against a same-message client-side duplicate by + // matching the server request mechanism, so we always assert against the server-side event. + return ( + errorEvent?.exception?.values?.[0]?.value === 'Sentry API Route Test Error' && + errorEvent?.exception?.values?.[0]?.mechanism?.type === 'auto.middleware.tanstackstart.request' + ); + }); + + await page.goto(`/`); + + await expect(page.locator('button').filter({ hasText: 'Break API route' })).toBeVisible(); + + await page.locator('button').filter({ hasText: 'Break API route' }).click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Sentry API Route Test Error', + mechanism: { + type: 'auto.middleware.tanstackstart.request', + handled: false, + }, + }, + ], + }, + }); + + expect(errorEvent.transaction).toBe('GET /api/error'); +}); + +// the sentry global middleware does not capture errors from SSR loader errors since they are serialized before they reach the middleware layer +// this test verifies that the error is in fact not sent to Sentry +test('Does not send SSR loader error to Sentry', async ({ baseURL, page }) => { + let errorEventOccurred = false; + + waitForError('tanstackstart-react-static', event => { + if (!event.type && event.exception?.values?.[0]?.value === 'Sentry SSR Test Error') { + errorEventOccurred = true; + } + return event?.transaction === 'GET /ssr-error'; + }); + + const transactionEventPromise = waitForTransaction('tanstackstart-react-static', transactionEvent => { + return transactionEvent?.transaction === 'GET /ssr-error'; + }); + + await page.goto('/ssr-error'); + + await transactionEventPromise; + + await (await fetch(`${baseURL}/api/flush`)).text(); + + expect(errorEventOccurred).toBe(false); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/middleware.test.ts new file mode 100644 index 000000000000..7a55b56d282c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/middleware.test.ts @@ -0,0 +1,197 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +const usesManagedTunnelRoute = + (process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant'); + +test('Sends spans for multiple middlewares and verifies they are siblings under the same parent span', async ({ + page, +}) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + !!transactionEvent?.transaction?.startsWith('GET /_serverFn') + ); + }); + + await page.goto('/test-middleware'); + await expect(page.locator('#server-fn-middleware-btn')).toBeVisible(); + await page.locator('#server-fn-middleware-btn').click(); + + const transactionEvent = await transactionEventPromise; + + expect(Array.isArray(transactionEvent?.spans)).toBe(true); + + // Find both middleware spans + const serverFnMiddlewareSpan = transactionEvent?.spans?.find( + (span: { description?: string; origin?: string }) => + span.description === 'serverFnMiddleware' && span.origin === 'auto.middleware.tanstackstart', + ); + const globalFunctionMiddlewareSpan = transactionEvent?.spans?.find( + (span: { description?: string; origin?: string }) => + span.description === 'globalFunctionMiddleware' && span.origin === 'auto.middleware.tanstackstart', + ); + + // Verify both middleware spans exist with expected properties + expect(serverFnMiddlewareSpan).toEqual( + expect.objectContaining({ + description: 'serverFnMiddleware', + op: 'middleware', + origin: 'auto.middleware.tanstackstart', + status: 'ok', + }), + ); + expect(globalFunctionMiddlewareSpan).toEqual( + expect.objectContaining({ + description: 'globalFunctionMiddleware', + op: 'middleware', + origin: 'auto.middleware.tanstackstart', + status: 'ok', + }), + ); + + // Both middleware spans should be siblings under the same parent + expect(serverFnMiddlewareSpan?.parent_span_id).toBe(globalFunctionMiddlewareSpan?.parent_span_id); +}); + +test('Sends spans for global function middleware', async ({ page }) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + !!transactionEvent?.transaction?.startsWith('GET /_serverFn') + ); + }); + + await page.goto('/test-middleware'); + await expect(page.locator('#server-fn-global-only-btn')).toBeVisible(); + await page.locator('#server-fn-global-only-btn').click(); + + const transactionEvent = await transactionEventPromise; + + expect(Array.isArray(transactionEvent?.spans)).toBe(true); + + // Check for the global function middleware span + expect(transactionEvent?.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'globalFunctionMiddleware', + op: 'middleware', + origin: 'auto.middleware.tanstackstart', + status: 'ok', + }), + ]), + ); +}); + +test('Sends spans for global request middleware', async ({ page }) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-middleware' + ); + }); + + await page.goto('/test-middleware'); + + const transactionEvent = await transactionEventPromise; + + expect(Array.isArray(transactionEvent?.spans)).toBe(true); + + // Check for the global request middleware span + expect(transactionEvent?.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'globalRequestMiddleware', + op: 'middleware', + origin: 'auto.middleware.tanstackstart', + status: 'ok', + }), + ]), + ); +}); + +test('Sends spans for server route request middleware', async ({ page }) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/test-middleware' + ); + }); + + await page.goto('/api/test-middleware'); + + const transactionEvent = await transactionEventPromise; + + expect(Array.isArray(transactionEvent?.spans)).toBe(true); + + // Check for the server route request middleware span + expect(transactionEvent?.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'serverRouteRequestMiddleware', + op: 'middleware', + origin: 'auto.middleware.tanstackstart', + status: 'ok', + }), + ]), + ); +}); + +test('Sends span for middleware that returns early without calling next()', async ({ page }) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + !!transactionEvent?.transaction?.startsWith('GET /_serverFn') + ); + }); + + await page.goto('/test-middleware'); + await expect(page.locator('#server-fn-early-return-btn')).toBeVisible(); + await page.locator('#server-fn-early-return-btn').click(); + + const transactionEvent = await transactionEventPromise; + + expect(Array.isArray(transactionEvent?.spans)).toBe(true); + + // Check for the early return middleware span + expect(transactionEvent?.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'earlyReturnMiddleware', + op: 'middleware', + origin: 'auto.middleware.tanstackstart', + status: 'ok', + }), + ]), + ); +}); + +test('Sends span for middleware that throws an error', async ({ page }) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + !!transactionEvent?.transaction?.startsWith('GET /_serverFn') + ); + }); + + await page.goto('/test-middleware'); + await expect(page.locator('#server-fn-error-btn')).toBeVisible(); + await page.locator('#server-fn-error-btn').click(); + + const transactionEvent = await transactionEventPromise; + + expect(Array.isArray(transactionEvent?.spans)).toBe(true); + + // Check for the error middleware span + expect(transactionEvent?.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'errorMiddleware', + op: 'middleware', + origin: 'auto.middleware.tanstackstart', + }), + ]), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/route-parametrization.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/route-parametrization.test.ts new file mode 100644 index 000000000000..3e94c7ce7bb9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/route-parametrization.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +const usesManagedTunnelRoute = + (process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant'); + +test('should parametrize server and client transaction names for dynamic routes', async ({ page }) => { + const serverTxPromise = waitForTransaction('tanstackstart-react-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + typeof transactionEvent?.transaction === 'string' && + transactionEvent.transaction.includes('/param/') + ); + }); + + const clientTxPromise = waitForTransaction('tanstackstart-react-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'pageload' && + typeof transactionEvent?.transaction === 'string' && + transactionEvent.transaction.includes('/param/') + ); + }); + + await page.goto('/param/42'); + + const serverTx = await serverTxPromise; + const clientTx = await clientTxPromise; + + expect(serverTx.transaction).toBe('GET /param/$id'); + expect(serverTx.transaction_info?.source).toBe('route'); + + expect(clientTx.transaction).toBe('/param/$id'); + expect(clientTx.transaction_info?.source).toBe('route'); +}); + +test('should parametrize server and client transaction names for nested dynamic routes', async ({ page }) => { + const serverTxPromise = waitForTransaction('tanstackstart-react-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + typeof transactionEvent?.transaction === 'string' && + transactionEvent.transaction.includes('/users/') + ); + }); + + const clientTxPromise = waitForTransaction('tanstackstart-react-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'pageload' && + typeof transactionEvent?.transaction === 'string' && + transactionEvent.transaction.includes('/users/') + ); + }); + + await page.goto('/users/123'); + + const serverTx = await serverTxPromise; + const clientTx = await clientTxPromise; + + expect(serverTx.transaction).toBe('GET /users/$userId'); + expect(serverTx.transaction_info?.source).toBe('route'); + + expect(clientTx.transaction).toBe('/users/$userId'); + expect(clientTx.transaction_info?.source).toBe('route'); +}); + +test('should parametrize API route transaction names', async ({ baseURL }) => { + const serverTxPromise = waitForTransaction('tanstackstart-react-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + typeof transactionEvent?.transaction === 'string' && + transactionEvent.transaction.includes('/api/user/') + ); + }); + + await fetch(`${baseURL}/api/user/456`); + + const serverTx = await serverTxPromise; + + expect(serverTx.transaction).toBe('GET /api/user/$id'); + expect(serverTx.transaction_info?.source).toBe('route'); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/trace-propagation.test.ts new file mode 100644 index 000000000000..8acaa89f791d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/trace-propagation.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +const usesManagedTunnelRoute = + (process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant'); + +test.describe('Trace propagation', () => { + test('should inject metatags in ssr pageload', async ({ page }) => { + await page.goto('/'); + + const sentryTraceContent = await page.getAttribute('meta[name="sentry-trace"]', 'content'); + expect(sentryTraceContent).toBeDefined(); + expect(sentryTraceContent).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/); + + const baggageContent = await page.getAttribute('meta[name="baggage"]', 'content'); + expect(baggageContent).toBeDefined(); + expect(baggageContent).toContain('sentry-environment=qa'); + expect(baggageContent).toContain('sentry-public_key='); + expect(baggageContent).toContain('sentry-trace_id='); + expect(baggageContent).toContain('sentry-sampled='); + }); + + test('should have trace connection between server and client', async ({ page }) => { + const serverTxPromise = waitForTransaction('tanstackstart-react-static', transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /'; + }); + + const clientTxPromise = waitForTransaction('tanstackstart-react-static', transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; + }); + + await page.goto('/'); + + const serverTx = await serverTxPromise; + const clientTx = await clientTxPromise; + + expect(clientTx.contexts?.trace?.trace_id).toBe(serverTx.contexts?.trace?.trace_id); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/transaction.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/transaction.test.ts new file mode 100644 index 000000000000..ad8cd5e9dfa9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/transaction.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +const usesManagedTunnelRoute = + (process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant'); + +test('Sends a server function transaction with auto-instrumentation', async ({ page }) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + !!transactionEvent?.transaction?.startsWith('GET /_serverFn') + ); + }); + + await page.goto('/test-serverFn'); + + await expect(page.getByText('Call server function', { exact: true })).toBeVisible(); + + await page.getByText('Call server function', { exact: true }).click(); + + const transactionEvent = await transactionEventPromise; + + // Check for the auto-instrumented server function span + expect(Array.isArray(transactionEvent?.spans)).toBe(true); + expect(transactionEvent?.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'GET /_serverFn/testLog', + op: 'function', + origin: 'auto.function.tanstackstart.server', + data: { + 'sentry.op': 'function', + 'sentry.origin': 'auto.function.tanstackstart.server', + 'tanstackstart.function.id': expect.any(String), + 'tanstackstart.function.filename': 'src/routes/test-serverFn.tsx', + }, + status: 'ok', + }), + ]), + ); +}); + +test('Sends a server function transaction for a nested server function only if it is manually instrumented', async ({ + page, +}) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + !!transactionEvent?.transaction?.startsWith('GET /_serverFn') + ); + }); + + await page.goto('/test-serverFn'); + + await expect(page.getByText('Call server function nested')).toBeVisible(); + + await page.getByText('Call server function nested').click(); + + const transactionEvent = await transactionEventPromise; + + expect(Array.isArray(transactionEvent?.spans)).toBe(true); + + // Check for the auto-instrumented server function span + expect(transactionEvent?.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'GET /_serverFn/testNestedLog', + op: 'function', + origin: 'auto.function.tanstackstart.server', + data: { + 'sentry.op': 'function', + 'sentry.origin': 'auto.function.tanstackstart.server', + 'tanstackstart.function.id': expect.any(String), + 'tanstackstart.function.filename': 'src/routes/test-serverFn.tsx', + }, + status: 'ok', + }), + ]), + ); + + // Check for the manually instrumented nested span + expect(transactionEvent?.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'testNestedLog', + origin: 'manual', + status: 'ok', + }), + ]), + ); + + // Verify that globalFunctionMiddleware and testNestedLog are sibling spans under the root + const functionMiddlewareSpan = transactionEvent?.spans?.find( + (span: { description?: string; origin?: string }) => + span.description === 'globalFunctionMiddleware' && span.origin === 'auto.middleware.tanstackstart', + ); + const nestedSpan = transactionEvent?.spans?.find( + (span: { description?: string; origin?: string }) => + span.description === 'testNestedLog' && span.origin === 'manual', + ); + + expect(functionMiddlewareSpan).toBeDefined(); + expect(nestedSpan).toBeDefined(); + + // Both spans should be siblings under the same parent (root transaction) + expect(nestedSpan?.parent_span_id).toBe(functionMiddlewareSpan?.parent_span_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/tunnel.test.ts new file mode 100644 index 000000000000..76a39f44be81 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/tunnel.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +const tunnelRouteMode = + process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? (process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1' ? 'custom' : 'off'); +const expectedTunnelPathMatcher = + tunnelRouteMode === 'static' + ? '/monitor' + : tunnelRouteMode === 'custom' + ? '/custom-monitor' + : tunnelRouteMode === 'object' + ? '/object-monitor' + : /^\/[a-z0-9]{8}$/; + +test.skip(tunnelRouteMode === 'off', 'Tunnel assertions only run in the tunnel-route variants'); + +test('Sends client-side errors through the configured tunnel route', async ({ page }) => { + const errorEventPromise = waitForError('tanstackstart-react-static', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Sentry Client Test Error'; + }); + + await page.goto('/'); + const pageOrigin = new URL(page.url()).origin; + + // Wait for React to hydrate (see __root.tsx) before clicking — the SSR HTML + // renders the button before the onClick handler is attached, and clicking + // pre-hydration would fire no handler and produce no error. + await page.locator('html[data-hydrated="true"]').waitFor(); + + await expect(page.locator('button').filter({ hasText: 'Break the client' })).toBeVisible(); + + const managedTunnelResponsePromise = page.waitForResponse(response => { + const responseUrl = new URL(response.url()); + + return ( + responseUrl.origin === pageOrigin && + response.request().method() === 'POST' && + (typeof expectedTunnelPathMatcher === 'string' + ? responseUrl.pathname === expectedTunnelPathMatcher + : expectedTunnelPathMatcher.test(responseUrl.pathname)) + ); + }); + + await page.locator('button').filter({ hasText: 'Break the client' }).click(); + + const managedTunnelResponse = await managedTunnelResponsePromise; + const managedTunnelUrl = new URL(managedTunnelResponse.url()); + const errorEvent = await errorEventPromise; + + expect(managedTunnelResponse.status()).toBe(200); + expect(managedTunnelUrl.origin).toBe(pageOrigin); + + if (typeof expectedTunnelPathMatcher === 'string') { + expect(managedTunnelUrl.pathname).toBe(expectedTunnelPathMatcher); + } else { + expect(managedTunnelUrl.pathname).toMatch(expectedTunnelPathMatcher); + expect(managedTunnelUrl.pathname).not.toBe('/monitor'); + } + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Sentry Client Test Error'); + expect(errorEvent.transaction).toBe('/'); +}); + +function pathnameMatchesTunnelRoute(pathname: string): boolean { + return typeof expectedTunnelPathMatcher === 'string' + ? pathname === expectedTunnelPathMatcher + : expectedTunnelPathMatcher.test(pathname); +} + +function waitForServerHttpEvent(matchesPathname: (pathname: string) => boolean): Promise { + return waitForTransaction('tanstackstart-react-static', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + matchesPathname((transactionEvent.transaction ?? '').split(' ')[1] ?? '') + ); + }); +} + +test('Does not create a server transaction for the tunnel route', async ({ page }) => { + // The incoming POST to the tunnel route must not be turned into an `http.server` + // transaction by the server SDK — tunnel traffic is plumbing, not application requests. + const tunnelServerEventPromise = waitForServerHttpEvent(pathnameMatchesTunnelRoute); + + await page.goto('/'); + const pageOrigin = new URL(page.url()).origin; + + await page.locator('html[data-hydrated="true"]').waitFor(); + await expect(page.locator('button').filter({ hasText: 'Break the client' })).toBeVisible(); + + const managedTunnelResponsePromise = page.waitForResponse(response => { + const responseUrl = new URL(response.url()); + + return ( + responseUrl.origin === pageOrigin && + response.request().method() === 'POST' && + pathnameMatchesTunnelRoute(responseUrl.pathname) + ); + }); + + await page.locator('button').filter({ hasText: 'Break the client' }).click(); + + // Ensure the tunnel POST was fully handled server-side before we issue the anchor request below. + await managedTunnelResponsePromise; + + // Anchor on a regular server transaction issued *after* the tunnel POST. The Node transport flushes + // transactions/spans in FIFO order, so a (buggy) tunnel-route transaction would always arrive before + // this anchor. Racing the two lets us assert the absence of a tunnel transaction without idling on a timeout. + const anchorServerEventPromise = waitForServerHttpEvent(pathname => pathname.includes('/api/user/')); + + await page.request.get('/api/user/456'); + + const winner = await Promise.race([ + tunnelServerEventPromise.then(() => 'tunnel' as const), + anchorServerEventPromise.then(() => 'anchor' as const), + ]); + + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tsconfig.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tsconfig.json new file mode 100644 index 000000000000..5dcdb1fa6f4a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "strictNullChecks": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tsconfig.node.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tsconfig.node.json new file mode 100644 index 000000000000..97ede7ee6f2d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/vite.config.ts new file mode 100644 index 000000000000..2385d8aa5e93 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/vite.config.ts @@ -0,0 +1,60 @@ +import { defineConfig } from 'vite'; +import tsConfigPaths from 'vite-tsconfig-paths'; +import { tanstackStart } from '@tanstack/react-start/plugin/vite'; +import viteReact from '@vitejs/plugin-react-swc'; +import { nitro } from 'nitro/vite'; +import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; + +const tunnelRouteMode = process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off'; +const useManagedTunnelRoute = tunnelRouteMode !== 'off'; +const useCustomTunnelRoute = process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +const appDsn = + useManagedTunnelRoute || useCustomTunnelRoute + ? 'http://public@localhost:3031/1337' + : 'https://public@dsn.ingest.sentry.io/1337'; + +const appTunnel = useManagedTunnelRoute + ? undefined + : useCustomTunnelRoute + ? '/custom-monitor' + : 'http://localhost:3031/'; + +function resolveTunnelRouteOption() { + switch (tunnelRouteMode) { + case 'dynamic': + return true; + case 'static': + return '/monitor'; + case 'object': + return { path: '/object-monitor', allowedDsns: [appDsn] }; + default: + return undefined; + } +} + +const tunnelRoute = resolveTunnelRouteOption(); + +export default defineConfig({ + server: { + port: 3000, + }, + define: { + __APP_DSN__: JSON.stringify(appDsn), + __APP_TUNNEL__: appTunnel === undefined ? 'undefined' : JSON.stringify(appTunnel), + }, + plugins: [ + tsConfigPaths(), + tanstackStart(), + nitro(), + // react's vite plugin must come after start's vite plugin + viteReact(), + sentryTanstackStart({ + org: process.env.E2E_TEST_SENTRY_ORG_SLUG, + project: process.env.E2E_TEST_SENTRY_PROJECT, + authToken: process.env.E2E_TEST_AUTH_TOKEN, + debug: true, + tunnelRoute, + }), + ], +});