diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 881b5f4b6580..7c5d801c5f6a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -810,7 +810,7 @@ jobs: needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_remix == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 15 strategy: fail-fast: false matrix: diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/client-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/client-transactions.test.ts index 9a5caf46fd66..d85d9d82747d 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/client-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/client-transactions.test.ts @@ -3,7 +3,7 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; test('Sends a pageload transaction to Sentry', async ({ page }) => { const transactionPromise = waitForTransaction('create-remix-app-express-vite-dev', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === 'routes/_index'; + return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/'; }); await page.goto('/'); @@ -15,7 +15,7 @@ test('Sends a pageload transaction to Sentry', async ({ page }) => { test('Sends a navigation transaction to Sentry', async ({ page }) => { const transactionPromise = waitForTransaction('create-remix-app-express-vite-dev', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === 'routes/user.$id'; + return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/user/:id'; }); await page.goto('/'); @@ -28,6 +28,22 @@ test('Sends a navigation transaction to Sentry', async ({ page }) => { expect(transactionEvent).toBeDefined(); }); +test('Sends a navigation transaction with parameterized route to Sentry', async ({ page }) => { + const transactionPromise = waitForTransaction('create-remix-app-express-vite-dev', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + + const linkElement = page.locator('id=navigation'); + await linkElement.click(); + + const transactionEvent = await transactionPromise; + + expect(transactionEvent).toBeDefined(); + expect(transactionEvent.transaction).toBeTruthy(); +}); + test('Renders `sentry-trace` and `baggage` meta tags for the root route', async ({ page }) => { await page.goto('/'); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/server-transactions.test.ts index 58e81eeee529..4213aae3e3de 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/server-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/server-transactions.test.ts @@ -48,7 +48,7 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id; expect(httpServerTransaction.transaction).toBe('GET http://localhost:3030/'); - expect(pageloadTransaction.transaction).toBe('routes/_index'); + expect(pageloadTransaction.transaction).toBe('/'); expect(httpServerTraceId).toBeDefined(); expect(httpServerSpanId).toBeDefined(); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/vite.config.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/vite.config.ts index 13de9243b22a..6ebb8eacc6a5 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/vite.config.ts @@ -1,14 +1,15 @@ +import { installGlobals } from '@remix-run/node'; import { vitePlugin as remix } from '@remix-run/dev'; +import { sentryRemixVitePlugin } from '@sentry/remix'; import { defineConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; -import { installGlobals } from '@remix-run/node'; - installGlobals(); export default defineConfig({ plugins: [ remix(), + sentryRemixVitePlugin(), tsconfigPaths({ // The dev server config errors are not relevant to this test app // https://github.com/aleclarson/vite-tsconfig-paths?tab=readme-ov-file#options diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/client-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/client-transactions.test.ts index a06aa02ceb9c..68237301b635 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/client-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/client-transactions.test.ts @@ -3,7 +3,7 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; test('Sends a pageload transaction to Sentry', async ({ page }) => { const transactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === 'routes/_index'; + return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/'; }); await page.goto('/'); @@ -15,7 +15,7 @@ test('Sends a pageload transaction to Sentry', async ({ page }) => { test('Sends a navigation transaction to Sentry', async ({ page }) => { const transactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === 'routes/user.$id'; + return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/user/:id'; }); await page.goto('/'); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts index d57c45545caf..ddb866e5dbaa 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts @@ -90,7 +90,7 @@ test('Propagates trace when ErrorBoundary is triggered', async ({ page }) => { const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id; expect(httpServerTransaction.transaction).toBe('GET client-error'); - expect(pageloadTransaction.transaction).toBe('routes/client-error'); + expect(pageloadTransaction.transaction).toBe('/client-error'); expect(httpServerTraceId).toBeDefined(); expect(httpServerSpanId).toBeDefined(); @@ -132,7 +132,7 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id; expect(httpServerTransaction.transaction).toBe('GET http://localhost:3030/'); - expect(pageloadTransaction.transaction).toBe('routes/_index'); + expect(pageloadTransaction.transaction).toBe('/'); expect(httpServerTraceId).toBeDefined(); expect(httpServerSpanId).toBeDefined(); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/vite.config.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/vite.config.ts index 13de9243b22a..6ebb8eacc6a5 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/vite.config.ts @@ -1,14 +1,15 @@ +import { installGlobals } from '@remix-run/node'; import { vitePlugin as remix } from '@remix-run/dev'; +import { sentryRemixVitePlugin } from '@sentry/remix'; import { defineConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; -import { installGlobals } from '@remix-run/node'; - installGlobals(); export default defineConfig({ plugins: [ remix(), + sentryRemixVitePlugin(), tsconfigPaths({ // The dev server config errors are not relevant to this test app // https://github.com/aleclarson/vite-tsconfig-paths?tab=readme-ov-file#options diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/.eslintrc.js b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/.eslintrc.js new file mode 100644 index 000000000000..f2faf1470fd8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/.eslintrc.js @@ -0,0 +1,4 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: ['@remix-run/eslint-config', '@remix-run/eslint-config/node'], +}; diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/.gitignore b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/.gitignore new file mode 100644 index 000000000000..3f7bf98da3e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +/public/build +.env diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/.npmrc b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/entry.client.tsx new file mode 100644 index 000000000000..f540f3c35c1d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/entry.client.tsx @@ -0,0 +1,48 @@ +/** + * By default, Remix will handle hydrating your app on the client for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.client + */ + +// Extend the Window interface to include ENV +declare global { + interface Window { + ENV: { + SENTRY_DSN: string; + [key: string]: unknown; + }; + } +} + +import { RemixBrowser, useLocation, useMatches } from '@remix-run/react'; +import * as Sentry from '@sentry/remix'; +import { StrictMode, startTransition, useEffect } from 'react'; +import { hydrateRoot } from 'react-dom/client'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: window.ENV.SENTRY_DSN, + integrations: [ + Sentry.browserTracingIntegration({ + useEffect, + useLocation, + useMatches, + }), + Sentry.replayIntegration(), + ], + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! + // Session Replay + replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production. + replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur. + tunnel: 'http://localhost:3032/', // proxy server +}); + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/entry.server.tsx new file mode 100644 index 000000000000..41974897eeae --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/entry.server.tsx @@ -0,0 +1,136 @@ +/** + * By default, Remix will handle generating the HTTP Response for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.server + */ + +import { PassThrough } from 'node:stream'; + +import type { AppLoadContext, EntryContext } from '@remix-run/node'; +import { createReadableStreamFromReadable } from '@remix-run/node'; +import { installGlobals } from '@remix-run/node'; +import { RemixServer } from '@remix-run/react'; +import isbot from 'isbot'; +import { renderToPipeableStream } from 'react-dom/server'; +import * as Sentry from '@sentry/remix'; + +installGlobals(); + +const ABORT_DELAY = 5_000; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tunnel: 'http://localhost:3031/', // proxy server + tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! +}); + +const handleErrorImpl = () => { + Sentry.setTag('remix-test-tag', 'remix-test-value'); +}; + +export const handleError = Sentry.wrapHandleErrorWithSentry(handleErrorImpl); + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + loadContext: AppLoadContext, +) { + return isbot(request.headers.get('user-agent')) + ? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext) + : handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext); +} + +function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set('Content-Type', 'text/html'); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + }, + ); + + setTimeout(abort, ABORT_DELAY); + }); +} + +function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set('Content-Type', 'text/html'); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + }, + ); + + setTimeout(abort, ABORT_DELAY); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/root.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/root.tsx new file mode 100644 index 000000000000..517a37a9d76b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/root.tsx @@ -0,0 +1,80 @@ +import { cssBundleHref } from '@remix-run/css-bundle'; +import { LinksFunction, MetaFunction, json } from '@remix-run/node'; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, + useRouteError, +} from '@remix-run/react'; +import { captureRemixErrorBoundaryError, withSentry } from '@sentry/remix'; +import type { SentryMetaArgs } from '@sentry/remix'; + +export const links: LinksFunction = () => [...(cssBundleHref ? [{ rel: 'stylesheet', href: cssBundleHref }] : [])]; + +export const loader = () => { + return json({ + ENV: { + SENTRY_DSN: process.env.E2E_TEST_DSN, + }, + }); +}; + +export const meta = ({ data }: SentryMetaArgs>) => { + return [ + { + env: data.ENV, + }, + { + name: 'sentry-trace', + content: data.sentryTrace, + }, + { + name: 'baggage', + content: data.sentryBaggage, + }, + ]; +}; + +export function ErrorBoundary() { + const error = useRouteError(); + const eventId = captureRemixErrorBoundaryError(error); + + return ( +
+ ErrorBoundary Error + {eventId} +
+ ); +} + +function App() { + const { ENV } = useLoaderData(); + + return ( + + + + + or