diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/.gitignore b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/.gitignore new file mode 100644 index 000000000000..f67821b00540 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +.output +.wrangler +.tanstack +src/routeTree.gen.ts diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/package.json new file mode 100644 index 000000000000..b5450d0e198c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/package.json @@ -0,0 +1,44 @@ +{ + "name": "tanstackstart-react-cloudflare", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "wrangler dev --var E2E_TEST_DSN:$E2E_TEST_DSN --log-level=$(test $CI && echo 'none' || echo 'log')", + "test": "playwright test", + "typecheck": "tsc --noEmit", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm typecheck && pnpm test" + }, + "dependencies": { + "@sentry/browser": "file:../../packed/sentry-browser-packed.tgz", + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", + "@sentry/tanstackstart-react": "file:../../packed/sentry-tanstackstart-react-packed.tgz", + "@tanstack/react-start": "^1.136.0", + "@tanstack/react-router": "^1.136.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.35.0", + "@cloudflare/workers-types": "^4.20260504.0", + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^4.5.0", + "typescript": "^5.9.0", + "vite": "7.3.1", + "vite-tsconfig-paths": "^5.1.4", + "wrangler": "^4.68.1" + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + }, + "sentryTest": { + "optional": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/playwright.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/playwright.config.ts new file mode 100644 index 000000000000..94d9558b37b6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/playwright.config.ts @@ -0,0 +1,6 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +export default getPlaywrightConfig({ + startCommand: 'pnpm preview', + port: 8787, +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/env.d.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/env.d.ts new file mode 100644 index 000000000000..eb80bafb4834 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/env.d.ts @@ -0,0 +1,3 @@ +interface Env { + E2E_TEST_DSN: string; +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/router.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/router.tsx new file mode 100644 index 000000000000..c18a1a0b8167 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/router.tsx @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/browser'; +import { createRouter } from '@tanstack/react-router'; +import { routeTree } from './routeTree.gen'; + +export const getRouter = () => { + const router = createRouter({ + routeTree, + scrollRestoration: true, + }); + + if (!router.isServer) { + Sentry.init({ + environment: 'qa', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracesSampleRate: 1.0, + release: 'e2e-test', + tunnel: 'http://localhost:3031/', + }); + } + + return router; +}; diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/routes/__root.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/routes/__root.tsx new file mode 100644 index 000000000000..bc3a376d7eba --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/routes/__root.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from 'react'; +import { Outlet, createRootRoute, HeadContent, Scripts } from '@tanstack/react-router'; +import { getTraceData } from '@sentry/tanstackstart-react'; + +export const Route = createRootRoute({ + head: () => { + const traceData = getTraceData(); + const sentryMeta = Object.entries(traceData).map(([key, value]) => ({ + name: key, + content: value, + })); + + return { + meta: [ + { + charSet: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + { + title: 'TanStack Start Cloudflare E2E Test', + }, + ...sentryMeta, + ], + }; + }, + component: RootComponent, +}); + +function RootComponent() { + return ( + + + + ); +} + +function RootDocument({ children }: Readonly<{ children: ReactNode }>) { + return ( + + + + + + {children} + + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/routes/api.error.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/routes/api.error.ts new file mode 100644 index 000000000000..041fb175c1f1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/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-cloudflare/src/routes/api.flush.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/routes/api.flush.ts new file mode 100644 index 000000000000..b8f2313504e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/routes/api.flush.ts @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { flush } from '@sentry/cloudflare'; + +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-cloudflare/src/routes/index.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/routes/index.tsx new file mode 100644 index 000000000000..5adc3a25968b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/routes/index.tsx @@ -0,0 +1,45 @@ +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 ( +
+

TanStack Start Cloudflare E2E Test

+ + + +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/routes/ssr-error.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/routes/ssr-error.tsx new file mode 100644 index 000000000000..71ba7ce92d29 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/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-cloudflare/src/routes/test-serverFn.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/routes/test-serverFn.tsx new file mode 100644 index 000000000000..1c77b94bde2b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/routes/test-serverFn.tsx @@ -0,0 +1,47 @@ +import * as Sentry from '@sentry/cloudflare'; +import { createFileRoute } from '@tanstack/react-router'; +import { createServerFn } from '@tanstack/react-start'; + +const testLog = createServerFn().handler(async () => { + console.log('Test log from server function'); + return { message: 'Log created' }; +}); + +const testNestedLog = createServerFn().handler(async () => { + await Sentry.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: TestServerFn, +}); + +function TestServerFn() { + return ( +
+

Test Server Function Page

+ + +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts new file mode 100644 index 000000000000..9ade4ffba0f2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/cloudflare'; +import { wrapFetchWithSentry } from '@sentry/tanstackstart-react'; +import handler from '@tanstack/react-start/server-entry'; + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1.0, + environment: 'qa', + }), + // @ts-expect-error - handler is not typed as a Cloudflare handler + wrapFetchWithSentry(handler), +); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/start.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/start.ts new file mode 100644 index 000000000000..719869f235ef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/start.ts @@ -0,0 +1,9 @@ +import { sentryGlobalFunctionMiddleware, sentryGlobalRequestMiddleware } from '@sentry/tanstackstart-react'; +import { createStart } from '@tanstack/react-start'; + +export const startInstance = createStart(() => { + return { + requestMiddleware: [sentryGlobalRequestMiddleware], + functionMiddleware: [sentryGlobalFunctionMiddleware], + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/start-event-proxy.mjs new file mode 100644 index 000000000000..14ed61c3b9bd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'tanstackstart-react-cloudflare', +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/errors.test.ts new file mode 100644 index 000000000000..db34cb3a908d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/errors.test.ts @@ -0,0 +1,113 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends client-side error to Sentry', async ({ page }) => { + const errorEventPromise = waitForError('tanstackstart-react-cloudflare', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Sentry Client Test Error'; + }); + + await page.goto(`/`); + + await expect(page.locator('#client-error-btn')).toBeVisible(); + + await page.locator('#client-error-btn').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', async ({ page }) => { + const errorEventPromise = waitForError('tanstackstart-react-cloudflare', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Sentry Server Function Test Error'; + }); + + await page.goto(`/`); + + await expect(page.locator('#throw-server-fn-btn')).toBeVisible(); + + await page.locator('#throw-server-fn-btn').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, + }, + }, + ], + }, + }); +}); + +test('Sends API route error to Sentry', async ({ page }) => { + const errorEventPromise = waitForError('tanstackstart-react-cloudflare', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Sentry API Route Test Error'; + }); + + await page.goto(`/`); + + await expect(page.locator('#api-error-btn')).toBeVisible(); + + await page.locator('#api-error-btn').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, + }, + }, + ], + }, + }); +}); + +test('Does not send SSR loader error to Sentry', async ({ baseURL, page }) => { + let errorEventOccurred = false; + + waitForError('tanstackstart-react-cloudflare', 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-cloudflare', 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-cloudflare/tests/transaction.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/transaction.test.ts new file mode 100644 index 000000000000..e790b49f6d37 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/transaction.test.ts @@ -0,0 +1,119 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a server function transaction with span from wrapFetchWithSentry', async ({ page }) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react-cloudflare', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + !!transactionEvent?.transaction?.startsWith('GET /_serverFn') + ); + }); + + await page.goto('/test-serverFn'); + + await expect(page.locator('#server-fn-btn')).toBeVisible(); + + await page.locator('#server-fn-btn').click(); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace).toMatchObject({ + op: 'http.server', + origin: 'auto.http.cloudflare', + }); + + expect(transactionEvent?.spans).toHaveLength(1); + expect(transactionEvent?.spans).toEqual([ + expect.objectContaining({ + description: expect.stringContaining('GET /_serverFn/'), + op: 'function.tanstackstart', + origin: 'auto.function.tanstackstart.server', + data: expect.objectContaining({ + 'sentry.op': 'function.tanstackstart', + 'sentry.origin': 'auto.function.tanstackstart.server', + 'tanstackstart.function.hash.sha256': expect.any(String), + }), + }), + ]); +}); + +test('Sends a server function transaction for a nested server function with manual span', async ({ page }) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react-cloudflare', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + !!transactionEvent?.transaction?.startsWith('GET /_serverFn') + ); + }); + + await page.goto('/test-serverFn'); + + await expect(page.locator('#server-fn-nested-btn')).toBeVisible(); + + await page.locator('#server-fn-nested-btn').click(); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace).toMatchObject({ + op: 'http.server', + origin: 'auto.http.cloudflare', + }); + + expect(transactionEvent?.spans).toHaveLength(2); + expect(transactionEvent?.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: expect.stringContaining('GET /_serverFn/'), + op: 'function.tanstackstart', + origin: 'auto.function.tanstackstart.server', + data: expect.objectContaining({ + 'sentry.op': 'function.tanstackstart', + 'sentry.origin': 'auto.function.tanstackstart.server', + 'tanstackstart.function.hash.sha256': expect.any(String), + }), + }), + expect.objectContaining({ + description: 'testNestedLog', + origin: 'manual', + }), + ]), + ); +}); + +test('Sends server-side transaction for page request', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react-cloudflare', transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /'; + }); + + await fetch(`${baseURL}/`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.transaction).toBe('GET /'); + expect(transactionEvent.contexts?.trace).toMatchObject({ + op: 'http.server', + origin: 'auto.http.cloudflare', + status: 'ok', + }); +}); + +test('Propagates trace from server to client', async ({ page }) => { + const serverTransactionPromise = waitForTransaction('tanstackstart-react-cloudflare', transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /'; + }); + + const clientTransactionPromise = waitForTransaction('tanstackstart-react-cloudflare', transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; + }); + + await page.goto('/'); + + const serverTransaction = await serverTransactionPromise; + const clientTransaction = await clientTransactionPromise; + + const serverTraceId = serverTransaction.contexts?.trace?.trace_id; + const clientTraceId = clientTransaction.contexts?.trace?.trace_id; + + expect(serverTraceId).toMatch(/[a-f0-9]{32}/); + expect(clientTraceId).toMatch(/[a-f0-9]{32}/); + expect(clientTraceId).toBe(serverTraceId); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tsconfig.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tsconfig.json new file mode 100644 index 000000000000..ecf9f5694249 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["@cloudflare/workers-types"] + }, + "include": ["src"] +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/vite.config.ts new file mode 100644 index 000000000000..8e749133c7d8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import tsConfigPaths from 'vite-tsconfig-paths'; +import { tanstackStart } from '@tanstack/react-start/plugin/vite'; +import viteReact from '@vitejs/plugin-react'; +import { cloudflare } from '@cloudflare/vite-plugin'; + +export default defineConfig({ + server: { + port: 3030, + }, + plugins: [cloudflare({ viteEnvironment: { name: 'ssr' } }), tsConfigPaths(), tanstackStart(), viteReact()], +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/wrangler.jsonc new file mode 100644 index 000000000000..cea7ef58657d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/wrangler.jsonc @@ -0,0 +1,10 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "tanstackstart-react-cloudflare", + "compatibility_date": "2026-05-04", + "compatibility_flags": ["nodejs_compat"], + "main": "src/server.ts", + "observability": { + "enabled": true, + }, +}