>) => {
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default withSentry(App);
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/routes/_index.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/routes/_index.tsx
new file mode 100644
index 000000000000..8c787ebd7c2f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/routes/_index.tsx
@@ -0,0 +1,29 @@
+import { Link, useSearchParams } from '@remix-run/react';
+import * as Sentry from '@sentry/remix';
+
+export default function Index() {
+ const [searchParams] = useSearchParams();
+
+ if (searchParams.get('tag')) {
+ Sentry.setTags({
+ sentry_test: searchParams.get('tag'),
+ });
+ }
+
+ return (
+
+ {
+ const eventId = Sentry.captureException(new Error('I am an error!'));
+ window.capturedExceptionId = eventId;
+ }}
+ />
+
+ navigate
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/routes/action-formdata.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/routes/action-formdata.tsx
new file mode 100644
index 000000000000..884beb2f51e9
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/routes/action-formdata.tsx
@@ -0,0 +1,17 @@
+import { json } from '@remix-run/node';
+import { Form } from '@remix-run/react';
+
+export async function action() {
+ return json({ message: 'success' });
+}
+
+export default function ActionFormData() {
+ return (
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/routes/client-error.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/routes/client-error.tsx
new file mode 100644
index 000000000000..f4d6ec9c4f0a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/routes/client-error.tsx
@@ -0,0 +1,24 @@
+import { useSearchParams } from '@remix-run/react';
+import * as Sentry from '@sentry/remix';
+
+import { useState } from 'react';
+
+export default function ErrorBoundaryCapture() {
+ const [searchParams] = useSearchParams();
+
+ if (searchParams.get('tag')) {
+ Sentry.setTags({
+ sentry_test: searchParams.get('tag'),
+ });
+ }
+
+ const [count, setCount] = useState(0);
+
+ if (count > 0) {
+ throw new Error('Sentry React Component Error');
+ } else {
+ setTimeout(() => setCount(count + 1), 0);
+ }
+
+ return {count}
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/routes/loader-error.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/routes/loader-error.tsx
new file mode 100644
index 000000000000..75d454571fa5
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/routes/loader-error.tsx
@@ -0,0 +1,16 @@
+import { LoaderFunction } from '@remix-run/node';
+import { useLoaderData } from '@remix-run/react';
+
+export default function LoaderError() {
+ useLoaderData();
+
+ return (
+
+
Loader Error
+
+ );
+}
+
+export const loader: LoaderFunction = () => {
+ throw new Error('Loader Error');
+};
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/routes/navigate.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/routes/navigate.tsx
new file mode 100644
index 000000000000..c7dcea798501
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/routes/navigate.tsx
@@ -0,0 +1,20 @@
+import { LoaderFunction } from '@remix-run/node';
+import { useLoaderData } from '@remix-run/react';
+
+export const loader: LoaderFunction = async ({ params: { id } }) => {
+ if (id === '-1') {
+ throw new Error('Unexpected Server Error');
+ }
+
+ return null;
+};
+
+export default function LoaderError() {
+ const data = useLoaderData();
+
+ return (
+
+
{data && data.test ? data.test : 'Not Found'}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/routes/user.$id.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/routes/user.$id.tsx
new file mode 100644
index 000000000000..13b2e0a34d1e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/app/routes/user.$id.tsx
@@ -0,0 +1,3 @@
+export default function User() {
+ return I am a blank page
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/env.d.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/env.d.ts
new file mode 100644
index 000000000000..78ed2345c6e4
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/globals.d.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/globals.d.ts
new file mode 100644
index 000000000000..4130ac6a8a09
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/globals.d.ts
@@ -0,0 +1,7 @@
+interface Window {
+ recordedTransactions?: string[];
+ capturedExceptionId?: string;
+ ENV: {
+ SENTRY_DSN: string;
+ };
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/instrument.cjs b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/instrument.cjs
new file mode 100644
index 000000000000..feea6fb5388e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/instrument.cjs
@@ -0,0 +1,14 @@
+const Sentry = require('@sentry/remix');
+const process = require('process');
+
+Sentry.init({
+ tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production!
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: process.env.E2E_TEST_DSN,
+ tunnel: 'http://localhost:3031/', // proxy server
+ sendDefaultPii: true, // Testing the FormData
+ captureActionFormDataKeys: {
+ file: true,
+ text: true,
+ },
+});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/package.json b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/package.json
new file mode 100644
index 000000000000..53ccbdff9e7f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/package.json
@@ -0,0 +1,57 @@
+{
+ "private": true,
+ "sideEffects": false,
+ "type": "module",
+ "scripts": {
+ "build": "remix vite:build",
+ "dev": "node ./server.mjs",
+ "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
+ "start": "cross-env NODE_ENV=production node ./server.mjs",
+ "typecheck": "tsc",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml",
+ "test:build": "pnpm install && npx playwright install && pnpm build",
+ "test:assert": "pnpm playwright test"
+ },
+ "dependencies": {
+ "@remix-run/css-bundle": "^2.7.2",
+ "@remix-run/express": "^2.7.2",
+ "@remix-run/node": "^2.7.2",
+ "@remix-run/react": "^2.7.2",
+ "@sentry/remix": "latest || *",
+ "compression": "^1.7.4",
+ "cross-env": "^7.0.3",
+ "express": "^4.18.2",
+ "isbot": "^4.1.0",
+ "morgan": "^1.10.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "source-map-support": "^0.5.21"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.44.1",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "@remix-run/dev": "^2.7.2",
+ "@sentry/types": "latest || *",
+ "@sentry/utils": "latest || *",
+ "@types/compression": "^1.7.2",
+ "@types/express": "^4.17.17",
+ "@types/morgan": "^1.9.4",
+ "@types/react": "^18.2.20",
+ "@types/react-dom": "^18.2.7",
+ "@types/source-map-support": "^0.5.6",
+ "@typescript-eslint/eslint-plugin": "^6.7.4",
+ "chokidar": "^3.5.3",
+ "eslint": "^8.38.0",
+ "eslint-import-resolver-typescript": "^3.6.1",
+ "eslint-plugin-import": "^2.28.1",
+ "eslint-plugin-jsx-a11y": "^6.7.1",
+ "eslint-plugin-react": "^7.33.2",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "tsx": "4.7.2",
+ "typescript": "^5.1.6",
+ "vite-tsconfig-paths": "^4.2.1"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/playwright.config.mjs
new file mode 100644
index 000000000000..31f2b913b58b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/playwright.config.mjs
@@ -0,0 +1,7 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig({
+ startCommand: `pnpm start`,
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/server.mjs b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/server.mjs
new file mode 100644
index 000000000000..eb6078bf0321
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/server.mjs
@@ -0,0 +1,52 @@
+import './instrument.cjs';
+
+import { createRequestHandler } from '@remix-run/express';
+import { installGlobals } from '@remix-run/node';
+import compression from 'compression';
+import express from 'express';
+import morgan from 'morgan';
+
+installGlobals();
+
+const viteDevServer =
+ process.env.NODE_ENV === 'production'
+ ? undefined
+ : await import('vite').then(vite =>
+ vite.createServer({
+ server: { middlewareMode: true },
+ }),
+ );
+
+const app = express();
+
+app.use(compression());
+
+// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
+app.disable('x-powered-by');
+
+// handle asset requests
+if (viteDevServer) {
+ app.use(viteDevServer.middlewares);
+} else {
+ // Vite fingerprints its assets so we can cache forever.
+ app.use('/assets', express.static('build/client/assets', { immutable: true, maxAge: '1y' }));
+}
+
+// Everything else (like favicon.ico) is cached for an hour. You may want to be
+// more aggressive with this caching.
+app.use(express.static('build/client', { maxAge: '1h' }));
+
+app.use(morgan('tiny'));
+
+// handle SSR requests
+app.all(
+ '*',
+ createRequestHandler({
+ build: viteDevServer
+ ? () => viteDevServer.ssrLoadModule('virtual:remix/server-build')
+ : await import('./build/server/index.js'),
+ }),
+);
+
+const port = process.env.PORT || 3000;
+app.listen(port, () => console.log(`Express server listening at http://localhost:${port}`));
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/start-event-proxy.mjs
new file mode 100644
index 000000000000..ccbe23f1804d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'create-remix-app-express-legacy',
+});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/tests/behaviour-client.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/tests/behaviour-client.test.ts
new file mode 100644
index 000000000000..aecc2fa8c983
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/tests/behaviour-client.test.ts
@@ -0,0 +1,192 @@
+import { expect, test } from '@playwright/test';
+
+const EVENT_POLLING_TIMEOUT = 90_000;
+
+const authToken = process.env.E2E_TEST_AUTH_TOKEN;
+const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG;
+const sentryTestProject = process.env.E2E_TEST_SENTRY_PROJECT;
+
+test('Sends a client-side exception to Sentry', async ({ page }) => {
+ await page.goto('/');
+
+ const exceptionButton = page.locator('id=exception-button');
+ await exceptionButton.click();
+
+ const exceptionIdHandle = await page.waitForFunction(() => window.capturedExceptionId);
+ const exceptionEventId = await exceptionIdHandle.jsonValue();
+
+ console.log(`Polling for error eventId: ${exceptionEventId}`);
+
+ await expect
+ .poll(
+ async () => {
+ const response = await fetch(
+ `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`,
+ { headers: { Authorization: `Bearer ${authToken}` } },
+ );
+ return response.status;
+ },
+ {
+ timeout: EVENT_POLLING_TIMEOUT,
+ },
+ )
+ .toBe(200);
+});
+
+test('Sends a pageload transaction to Sentry', async ({ page }) => {
+ await page.goto('/');
+
+ const recordedTransactionsHandle = await page.waitForFunction(() => {
+ if (window.recordedTransactions && window.recordedTransactions?.length >= 1) {
+ return window.recordedTransactions;
+ } else {
+ return undefined;
+ }
+ });
+ const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue();
+
+ if (recordedTransactionEventIds === undefined) {
+ throw new Error("Application didn't record any transaction event IDs.");
+ }
+
+ let hadPageLoadTransaction = false;
+
+ console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`);
+
+ await Promise.all(
+ recordedTransactionEventIds.map(async transactionEventId => {
+ await expect
+ .poll(
+ async () => {
+ const response = await fetch(
+ `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`,
+ { headers: { Authorization: `Bearer ${authToken}` } },
+ );
+
+ if (response.ok) {
+ const data = await response.json();
+ if (data.contexts.trace.op === 'pageload') {
+ hadPageLoadTransaction = true;
+ }
+ }
+
+ return response.status;
+ },
+ {
+ timeout: EVENT_POLLING_TIMEOUT,
+ },
+ )
+ .toBe(200);
+ }),
+ );
+
+ expect(hadPageLoadTransaction).toBe(true);
+});
+
+test('Sends a navigation transaction to Sentry', async ({ page }) => {
+ await page.goto('/');
+
+ // Give pageload transaction time to finish
+ await page.waitForTimeout(4000);
+
+ const linkElement = page.locator('id=navigation');
+ await linkElement.click();
+
+ const recordedTransactionsHandle = await page.waitForFunction(() => {
+ if (window.recordedTransactions && window.recordedTransactions?.length >= 2) {
+ return window.recordedTransactions;
+ } else {
+ return undefined;
+ }
+ });
+ const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue();
+
+ if (recordedTransactionEventIds === undefined) {
+ throw new Error("Application didn't record any transaction event IDs.");
+ }
+
+ let hadPageNavigationTransaction = false;
+
+ console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`);
+
+ await Promise.all(
+ recordedTransactionEventIds.map(async transactionEventId => {
+ await expect
+ .poll(
+ async () => {
+ const response = await fetch(
+ `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`,
+ { headers: { Authorization: `Bearer ${authToken}` } },
+ );
+
+ if (response.ok) {
+ const data = await response.json();
+ if (data.contexts.trace.op === 'navigation') {
+ hadPageNavigationTransaction = true;
+ }
+ }
+
+ return response.status;
+ },
+ {
+ timeout: EVENT_POLLING_TIMEOUT,
+ },
+ )
+ .toBe(200);
+ }),
+ );
+
+ expect(hadPageNavigationTransaction).toBe(true);
+});
+
+test('Sends a client-side ErrorBoundary exception to Sentry', async ({ page }) => {
+ await page.goto('/client-error');
+
+ const exceptionIdHandle = await page.waitForSelector('#event-id');
+ const exceptionEventId = await exceptionIdHandle.textContent();
+
+ console.log(`Polling for error eventId: ${exceptionEventId}`);
+
+ await expect
+ .poll(
+ async () => {
+ const response = await fetch(
+ `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`,
+ { headers: { Authorization: `Bearer ${authToken}` } },
+ );
+ return response.status;
+ },
+ {
+ timeout: EVENT_POLLING_TIMEOUT,
+ },
+ )
+ .toBe(200);
+});
+
+test('Renders `sentry-trace` and `baggage` meta tags for the root route', async ({ page }) => {
+ await page.goto('/');
+
+ const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', {
+ state: 'attached',
+ });
+ const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', {
+ state: 'attached',
+ });
+
+ expect(sentryTraceMetaTag).toBeTruthy();
+ expect(baggageMetaTag).toBeTruthy();
+});
+
+test('Renders `sentry-trace` and `baggage` meta tags for a sub-route', async ({ page }) => {
+ await page.goto('/user/123');
+
+ const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', {
+ state: 'attached',
+ });
+ const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', {
+ state: 'attached',
+ });
+
+ expect(sentryTraceMetaTag).toBeTruthy();
+ expect(baggageMetaTag).toBeTruthy();
+});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/tests/behaviour-server.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/tests/behaviour-server.test.ts
new file mode 100644
index 000000000000..cd760d9cc7bc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/tests/behaviour-server.test.ts
@@ -0,0 +1,146 @@
+import { expect, test } from '@playwright/test';
+import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
+import { uuid4 } from '@sentry/utils';
+
+test('Sends a loader error to Sentry', async ({ page }) => {
+ const loaderErrorPromise = waitForError('create-remix-app-express-legacy', errorEvent => {
+ return errorEvent.exception.values[0].value === 'Loader Error';
+ });
+
+ await page.goto('/loader-error');
+
+ const loaderError = await loaderErrorPromise;
+
+ expect(loaderError).toBeDefined();
+});
+
+test('Sends form data with action error to Sentry', async ({ page }) => {
+ await page.goto('/action-formdata');
+
+ await page.fill('input[name=text]', 'test');
+ await page.setInputFiles('input[type=file]', {
+ name: 'file.txt',
+ mimeType: 'text/plain',
+ buffer: Buffer.from('this is test'),
+ });
+
+ await page.locator('button[type=submit]').click();
+
+ const formdataActionTransaction = waitForTransaction('create-remix-app-express-legacy', transactionEvent => {
+ return transactionEvent?.spans?.some(span => span.op === 'function.remix.action');
+ });
+
+ const actionSpan = (await formdataActionTransaction).spans.find(span => span.op === 'function.remix.action');
+
+ expect(actionSpan).toBeDefined();
+ expect(actionSpan.op).toBe('function.remix.action');
+ expect(actionSpan.data).toMatchObject({
+ 'remix.action_form_data.text': 'test',
+ 'remix.action_form_data.file': 'file.txt',
+ });
+});
+
+test('Sends a loader span to Sentry', async ({ page }) => {
+ const loaderTransactionPromise = waitForTransaction('create-remix-app-express-legacy', transactionEvent => {
+ return transactionEvent?.spans?.some(span => span.op === 'function.remix.loader');
+ });
+
+ await page.goto('/');
+
+ const loaderSpan = (await loaderTransactionPromise).spans.find(span => span.op === 'function.remix.loader');
+
+ expect(loaderSpan).toBeDefined();
+ expect(loaderSpan.op).toBe('function.remix.loader');
+});
+
+test('Propagates trace when ErrorBoundary is triggered', async ({ page }) => {
+ // We use this to identify the transactions
+ const testTag = uuid4();
+
+ const httpServerTransactionPromise = waitForTransaction('create-remix-app-express-legacy', transactionEvent => {
+ return (
+ transactionEvent.type === 'transaction' &&
+ transactionEvent.contexts?.trace?.op === 'http.server' &&
+ transactionEvent.tags?.['sentry_test'] === testTag
+ );
+ });
+
+ const pageLoadTransactionPromise = waitForTransaction('create-remix-app-express-legacy', transactionEvent => {
+ return (
+ transactionEvent.type === 'transaction' &&
+ transactionEvent.contexts?.trace?.op === 'pageload' &&
+ transactionEvent.tags?.['sentry_test'] === testTag
+ );
+ });
+
+ page.goto(`/client-error?tag=${testTag}`);
+
+ const pageloadTransaction = await pageLoadTransactionPromise;
+ const httpServerTransaction = await httpServerTransactionPromise;
+
+ expect(pageloadTransaction).toBeDefined();
+ expect(httpServerTransaction).toBeDefined();
+
+ const httpServerTraceId = httpServerTransaction.contexts?.trace?.trace_id;
+ const httpServerSpanId = httpServerTransaction.contexts?.trace?.span_id;
+
+ const pageLoadTraceId = pageloadTransaction.contexts?.trace?.trace_id;
+ const pageLoadSpanId = pageloadTransaction.contexts?.trace?.span_id;
+ const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id;
+
+ expect(httpServerTransaction.transaction).toBe('routes/client-error');
+ expect(pageloadTransaction.transaction).toBe('routes/client-error');
+
+ expect(httpServerTraceId).toBeDefined();
+ expect(httpServerSpanId).toBeDefined();
+
+ expect(pageLoadTraceId).toEqual(httpServerTraceId);
+ expect(pageLoadParentSpanId).toEqual(httpServerSpanId);
+ expect(pageLoadSpanId).not.toEqual(httpServerSpanId);
+});
+
+test('Sends two linked transactions (server & client) to Sentry', async ({ page }) => {
+ // We use this to identify the transactions
+ const testTag = uuid4();
+
+ const httpServerTransactionPromise = waitForTransaction('create-remix-app-express-legacy', transactionEvent => {
+ return (
+ transactionEvent.type === 'transaction' &&
+ transactionEvent.contexts?.trace?.op === 'http.server' &&
+ transactionEvent.tags?.['sentry_test'] === testTag
+ );
+ });
+
+ const pageLoadTransactionPromise = waitForTransaction('create-remix-app-express-legacy', transactionEvent => {
+ return (
+ transactionEvent.type === 'transaction' &&
+ transactionEvent.contexts?.trace?.op === 'pageload' &&
+ transactionEvent.tags?.['sentry_test'] === testTag
+ );
+ });
+
+ page.goto(`/?tag=${testTag}`);
+
+ const pageloadTransaction = await pageLoadTransactionPromise;
+ const httpServerTransaction = await httpServerTransactionPromise;
+
+ expect(pageloadTransaction).toBeDefined();
+ expect(httpServerTransaction).toBeDefined();
+
+ const httpServerTraceId = httpServerTransaction.contexts?.trace?.trace_id;
+ const httpServerSpanId = httpServerTransaction.contexts?.trace?.span_id;
+
+ const pageLoadTraceId = pageloadTransaction.contexts?.trace?.trace_id;
+ const pageLoadSpanId = pageloadTransaction.contexts?.trace?.span_id;
+ const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id;
+
+ expect(httpServerTransaction.transaction).toBe('routes/_index');
+ expect(pageloadTransaction.transaction).toBe('routes/_index');
+
+ expect(httpServerTraceId).toBeDefined();
+ expect(httpServerSpanId).toBeDefined();
+
+ expect(pageLoadTraceId).toEqual(httpServerTraceId);
+ expect(pageLoadParentSpanId).toEqual(httpServerSpanId);
+ expect(pageLoadSpanId).not.toEqual(httpServerSpanId);
+});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/tsconfig.json b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/tsconfig.json
new file mode 100644
index 000000000000..b58e7d722f35
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "include": ["env.d.ts", "**/*.ts", "**/*.tsx"],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "target": "ES2022",
+ "strict": true,
+ "allowJs": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ },
+ // Remix takes care of building everything in `remix build`.
+ "noEmit": true,
+ },
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/vite.config.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/vite.config.ts
new file mode 100644
index 000000000000..13de9243b22a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-legacy/vite.config.ts
@@ -0,0 +1,18 @@
+import { vitePlugin as remix } from '@remix-run/dev';
+import { defineConfig } from 'vite';
+import tsconfigPaths from 'vite-tsconfig-paths';
+
+import { installGlobals } from '@remix-run/node';
+
+installGlobals();
+
+export default defineConfig({
+ plugins: [
+ remix(),
+ 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
+ ignoreConfigErrors: true,
+ }),
+ ],
+});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/README.md b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/README.md
deleted file mode 100644
index 31400e85106f..000000000000
--- a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/README.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# Welcome to Remix + Vite!
-
-📖 See the [Remix docs](https://remix.run/docs) and the [Remix Vite docs](https://remix.run/docs/en/main/future/vite)
-for details on supported features.
-
-## Development
-
-Run the Express server with Vite dev middleware:
-
-```shellscript
-npm run dev
-```
-
-## Deployment
-
-First, build your app for production:
-
-```sh
-npm run build
-```
-
-Then run the app in production mode:
-
-```sh
-npm start
-```
-
-Now you'll need to pick a host to deploy it to.
-
-### DIY
-
-If you're familiar with deploying Express applications you should be right at home. Just make sure to deploy the output
-of `npm run build`
-
-- `build/server`
-- `build/client`
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/app/entry.server.tsx
index 5e0608ff5749..a38636a8745d 100644
--- a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/app/entry.server.tsx
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/app/entry.server.tsx
@@ -1,12 +1,5 @@
import * as Sentry from '@sentry/remix';
-Sentry.init({
- tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production!
- environment: 'qa', // dynamic sampling bias to keep transactions
- dsn: process.env.E2E_TEST_DSN,
- tunnel: 'http://localhost:3031/', // proxy server
-});
-
import { PassThrough } from 'node:stream';
import type { AppLoadContext, EntryContext } from '@remix-run/node';
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/instrument.mjs b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/instrument.mjs
new file mode 100644
index 000000000000..9d8e4e7fa408
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/instrument.mjs
@@ -0,0 +1,10 @@
+import * as Sentry from '@sentry/remix';
+import * as process from 'process';
+
+Sentry.init({
+ tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production!
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: process.env.E2E_TEST_DSN,
+ tunnel: 'http://localhost:3031/', // proxy server
+ autoInstrumentRemix: true, // auto instrument Remix
+});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/server.mjs b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/server.mjs
index f432b2d49184..a3ddf0a15424 100644
--- a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/server.mjs
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/server.mjs
@@ -1,14 +1,13 @@
+import './instrument.mjs';
+
import { createRequestHandler } from '@remix-run/express';
import { installGlobals } from '@remix-run/node';
-import { wrapExpressCreateRequestHandler } from '@sentry/remix';
import compression from 'compression';
import express from 'express';
import morgan from 'morgan';
installGlobals();
-const sentryCreateRequestHandler = wrapExpressCreateRequestHandler(createRequestHandler);
-
const viteDevServer =
process.env.NODE_ENV === 'production'
? undefined
@@ -42,7 +41,7 @@ app.use(morgan('tiny'));
// handle SSR requests
app.all(
'*',
- sentryCreateRequestHandler({
+ createRequestHandler({
build: viteDevServer
? () => viteDevServer.ssrLoadModule('virtual:remix/server-build')
: await import('./build/server/index.js'),
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/behaviour-server.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/behaviour-server.test.ts
index c2e2873f60c6..f638141dcf57 100644
--- a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/behaviour-server.test.ts
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/behaviour-server.test.ts
@@ -33,18 +33,21 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page
const httpServerTraceId = httpServerTransaction.contexts?.trace?.trace_id;
const httpServerSpanId = httpServerTransaction.contexts?.trace?.span_id;
+ const loaderSpanId = httpServerTransaction.spans.find(
+ span => span.data && span.data['code.function'] === 'loader',
+ )?.span_id;
const pageLoadTraceId = pageloadTransaction.contexts?.trace?.trace_id;
const pageLoadSpanId = pageloadTransaction.contexts?.trace?.span_id;
const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id;
- expect(httpServerTransaction.transaction).toBe('routes/_index');
+ expect(httpServerTransaction.transaction).toBe('GET http://localhost:3030/');
expect(pageloadTransaction.transaction).toBe('routes/_index');
expect(httpServerTraceId).toBeDefined();
expect(httpServerSpanId).toBeDefined();
expect(pageLoadTraceId).toEqual(httpServerTraceId);
- expect(pageLoadParentSpanId).toEqual(httpServerSpanId);
+ expect(pageLoadParentSpanId).toEqual(loaderSpanId);
expect(pageLoadSpanId).not.toEqual(httpServerSpanId);
});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/entry.server.tsx
index 9171a6e46cb7..4e1e9e0ba537 100644
--- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/entry.server.tsx
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/entry.server.tsx
@@ -1,15 +1,7 @@
import * as Sentry from '@sentry/remix';
-import * as isbotModule from 'isbot';
-
-Sentry.init({
- tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production!
- environment: 'qa', // dynamic sampling bias to keep transactions
- dsn: process.env.E2E_TEST_DSN,
- tunnel: 'http://localhost:3031/', // proxy server
- sendDefaultPii: true, // Testing the FormData
-});
import { PassThrough } from 'node:stream';
+import * as isbotModule from 'isbot';
import type { AppLoadContext, EntryContext } from '@remix-run/node';
import { createReadableStreamFromReadable } from '@remix-run/node';
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/_index.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/_index.tsx
index b646c62ee4da..8c787ebd7c2f 100644
--- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/_index.tsx
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/_index.tsx
@@ -5,7 +5,9 @@ export default function Index() {
const [searchParams] = useSearchParams();
if (searchParams.get('tag')) {
- Sentry.setTag('sentry_test', searchParams.get('tag'));
+ Sentry.setTags({
+ sentry_test: searchParams.get('tag'),
+ });
}
return (
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/action-formdata.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/action-formdata.tsx
index 6bb5b40977a0..884beb2f51e9 100644
--- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/action-formdata.tsx
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/action-formdata.tsx
@@ -1,11 +1,7 @@
import { json } from '@remix-run/node';
import { Form } from '@remix-run/react';
-export async function action({ request }) {
- const formData = await request.formData();
-
- console.log('form data', formData.get('text'), formData.get('file'));
-
+export async function action() {
return json({ message: 'success' });
}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/client-error.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/client-error.tsx
index 4e5330621191..f4d6ec9c4f0a 100644
--- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/client-error.tsx
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/client-error.tsx
@@ -1,6 +1,17 @@
+import { useSearchParams } from '@remix-run/react';
+import * as Sentry from '@sentry/remix';
+
import { useState } from 'react';
export default function ErrorBoundaryCapture() {
+ const [searchParams] = useSearchParams();
+
+ if (searchParams.get('tag')) {
+ Sentry.setTags({
+ sentry_test: searchParams.get('tag'),
+ });
+ }
+
const [count, setCount] = useState(0);
if (count > 0) {
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/instrument.mjs b/dev-packages/e2e-tests/test-applications/create-remix-app-express/instrument.mjs
new file mode 100644
index 000000000000..f2e7d35fab80
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/instrument.mjs
@@ -0,0 +1,15 @@
+import * as Sentry from '@sentry/remix';
+import process from 'process';
+
+Sentry.init({
+ tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production!
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: process.env.E2E_TEST_DSN,
+ tunnel: 'http://localhost:3031/', // proxy server
+ sendDefaultPii: true, // Testing the FormData
+ autoInstrumentRemix: true, // auto instrument Remix
+ captureActionFormDataKeys: {
+ file: true,
+ text: true,
+ },
+});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/server.mjs b/dev-packages/e2e-tests/test-applications/create-remix-app-express/server.mjs
index f432b2d49184..a3ddf0a15424 100644
--- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/server.mjs
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/server.mjs
@@ -1,14 +1,13 @@
+import './instrument.mjs';
+
import { createRequestHandler } from '@remix-run/express';
import { installGlobals } from '@remix-run/node';
-import { wrapExpressCreateRequestHandler } from '@sentry/remix';
import compression from 'compression';
import express from 'express';
import morgan from 'morgan';
installGlobals();
-const sentryCreateRequestHandler = wrapExpressCreateRequestHandler(createRequestHandler);
-
const viteDevServer =
process.env.NODE_ENV === 'production'
? undefined
@@ -42,7 +41,7 @@ app.use(morgan('tiny'));
// handle SSR requests
app.all(
'*',
- sentryCreateRequestHandler({
+ createRequestHandler({
build: viteDevServer
? () => viteDevServer.ssrLoadModule('virtual:remix/server-build')
: await import('./build/server/index.js'),
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/behaviour-server.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/behaviour-server.test.ts
index 18825fb95e7b..292d827c783e 100644
--- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/behaviour-server.test.ts
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/behaviour-server.test.ts
@@ -27,19 +27,85 @@ test('Sends form data with action error to Sentry', async ({ page }) => {
await page.locator('button[type=submit]').click();
const formdataActionTransaction = waitForTransaction('create-remix-app-express', transactionEvent => {
- return transactionEvent?.spans?.some(span => span.op === 'function.remix.action');
+ return transactionEvent?.spans?.some(span => span.data && span.data['code.function'] === 'action');
});
- const actionTransaction = await formdataActionTransaction;
+ const actionSpan = (await formdataActionTransaction).spans.find(
+ span => span.data && span.data['code.function'] === 'action',
+ );
- expect(actionTransaction).toBeDefined();
- expect(actionTransaction.contexts.trace.op).toBe('http.server');
- expect(actionTransaction.spans[0].data).toMatchObject({
- 'remix.action_form_data.text': 'test',
- 'remix.action_form_data.file': 'file.txt',
+ expect(actionSpan).toBeDefined();
+ expect(actionSpan.op).toBe('action.remix');
+ expect(actionSpan.data).toMatchObject({
+ 'formData.text': 'test',
+ 'formData.file': 'file.txt',
});
});
+test('Sends a loader span to Sentry', async ({ page }) => {
+ const loaderTransactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => {
+ return transactionEvent?.spans?.some(span => span.data && span.data['code.function'] === 'loader');
+ });
+
+ await page.goto('/');
+
+ const loaderSpan = (await loaderTransactionPromise).spans.find(
+ span => span.data && span.data['code.function'] === 'loader',
+ );
+
+ expect(loaderSpan).toBeDefined();
+ expect(loaderSpan.op).toBe('loader.remix');
+});
+
+test('Propagates trace when ErrorBoundary is triggered', async ({ page }) => {
+ // We use this to identify the transactions
+ const testTag = uuid4();
+
+ const httpServerTransactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => {
+ return (
+ transactionEvent.type === 'transaction' &&
+ transactionEvent.contexts?.trace?.op === 'http.server' &&
+ transactionEvent.tags?.['sentry_test'] === testTag
+ );
+ });
+
+ const pageLoadTransactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => {
+ return (
+ transactionEvent.type === 'transaction' &&
+ transactionEvent.contexts?.trace?.op === 'pageload' &&
+ transactionEvent.tags?.['sentry_test'] === testTag
+ );
+ });
+
+ page.goto(`/client-error?tag=${testTag}`);
+
+ const pageloadTransaction = await pageLoadTransactionPromise;
+ const httpServerTransaction = await httpServerTransactionPromise;
+
+ expect(pageloadTransaction).toBeDefined();
+ expect(httpServerTransaction).toBeDefined();
+
+ const httpServerTraceId = httpServerTransaction.contexts?.trace?.trace_id;
+ const httpServerSpanId = httpServerTransaction.contexts?.trace?.span_id;
+ const loaderSpanId = httpServerTransaction.spans.find(
+ span => span.data && span.data['code.function'] === 'loader',
+ )?.span_id;
+
+ const pageLoadTraceId = pageloadTransaction.contexts?.trace?.trace_id;
+ const pageLoadSpanId = pageloadTransaction.contexts?.trace?.span_id;
+ const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id;
+
+ expect(httpServerTransaction.transaction).toBe('GET client-error');
+ expect(pageloadTransaction.transaction).toBe('routes/client-error');
+
+ expect(httpServerTraceId).toBeDefined();
+ expect(httpServerSpanId).toBeDefined();
+
+ expect(pageLoadTraceId).toEqual(httpServerTraceId);
+ expect(pageLoadParentSpanId).toEqual(loaderSpanId);
+ expect(pageLoadSpanId).not.toEqual(httpServerSpanId);
+});
+
test('Sends two linked transactions (server & client) to Sentry', async ({ page }) => {
// We use this to identify the transactions
const testTag = uuid4();
@@ -71,17 +137,22 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page
const httpServerTraceId = httpServerTransaction.contexts?.trace?.trace_id;
const httpServerSpanId = httpServerTransaction.contexts?.trace?.span_id;
+ const loaderSpan = httpServerTransaction?.spans?.find(span => span.data && span.data['code.function'] === 'loader');
+ const loaderSpanId = loaderSpan?.span_id;
+ const loaderParentSpanId = loaderSpan?.parent_span_id;
+
const pageLoadTraceId = pageloadTransaction.contexts?.trace?.trace_id;
const pageLoadSpanId = pageloadTransaction.contexts?.trace?.span_id;
const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id;
- expect(httpServerTransaction.transaction).toBe('routes/_index');
+ expect(httpServerTransaction.transaction).toBe('GET http://localhost:3030/');
expect(pageloadTransaction.transaction).toBe('routes/_index');
expect(httpServerTraceId).toBeDefined();
expect(httpServerSpanId).toBeDefined();
+ expect(loaderParentSpanId).toEqual(httpServerSpanId);
expect(pageLoadTraceId).toEqual(httpServerTraceId);
- expect(pageLoadParentSpanId).toEqual(httpServerSpanId);
+ expect(pageLoadParentSpanId).toEqual(loaderSpanId);
expect(pageLoadSpanId).not.toEqual(httpServerSpanId);
});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/.eslintrc.js b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/.eslintrc.js
new file mode 100644
index 000000000000..f2faf1470fd8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/.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-legacy/.gitignore b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/.gitignore
new file mode 100644
index 000000000000..3f7bf98da3e1
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/.gitignore
@@ -0,0 +1,6 @@
+node_modules
+
+/.cache
+/build
+/public/build
+.env
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/.npmrc b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/.npmrc
new file mode 100644
index 000000000000..070f80f05092
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/.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-legacy/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/app/entry.client.tsx
new file mode 100644
index 000000000000..93eab0f819fb
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/app/entry.client.tsx
@@ -0,0 +1,46 @@
+/**
+ * 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
+ */
+
+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, useMatches, useLocation }), 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:3031/', // proxy server
+});
+
+Sentry.addEventProcessor(event => {
+ if (
+ event.type === 'transaction' &&
+ (event.contexts?.trace?.op === 'pageload' || event.contexts?.trace?.op === 'navigation')
+ ) {
+ const eventId = event.event_id;
+ if (eventId) {
+ window.recordedTransactions = window.recordedTransactions || [];
+ window.recordedTransactions.push(eventId);
+ }
+ }
+
+ return event;
+});
+
+startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+ ,
+ );
+});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/app/entry.server.tsx
new file mode 100644
index 000000000000..b0f1c5d19f09
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/app/entry.server.tsx
@@ -0,0 +1,114 @@
+import * as Sentry from '@sentry/remix';
+
+Sentry.init({
+ tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production!
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: process.env.E2E_TEST_DSN,
+ tunnel: 'http://localhost:3031/', // proxy server
+});
+
+/**
+ * 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 { Response } from '@remix-run/node';
+import { RemixServer } from '@remix-run/react';
+import isbot from 'isbot';
+import { renderToPipeableStream } from 'react-dom/server';
+
+const ABORT_DELAY = 5_000;
+
+export const handleError = Sentry.wrapRemixHandleError;
+
+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) => {
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set('Content-Type', 'text/html');
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ }),
+ );
+
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ responseStatusCode = 500;
+ console.error(error);
+ },
+ },
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
+
+function handleBrowserRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext,
+) {
+ return new Promise((resolve, reject) => {
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ const body = new PassThrough();
+
+ responseHeaders.set('Content-Type', 'text/html');
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ }),
+ );
+
+ pipe(body);
+ },
+ onShellError(error: unknown) {
+ reject(error);
+ },
+ onError(error: unknown) {
+ console.error(error);
+ responseStatusCode = 500;
+ },
+ },
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/app/root.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/app/root.tsx
new file mode 100644
index 000000000000..e99991f5994d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/app/root.tsx
@@ -0,0 +1,76 @@
+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';
+
+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: MetaFunction = ({ data }) => {
+ return [
+ {
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default withSentry(App);
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/app/routes/_index.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/app/routes/_index.tsx
new file mode 100644
index 000000000000..b646c62ee4da
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/app/routes/_index.tsx
@@ -0,0 +1,27 @@
+import { Link, useSearchParams } from '@remix-run/react';
+import * as Sentry from '@sentry/remix';
+
+export default function Index() {
+ const [searchParams] = useSearchParams();
+
+ if (searchParams.get('tag')) {
+ Sentry.setTag('sentry_test', searchParams.get('tag'));
+ }
+
+ return (
+
+ {
+ const eventId = Sentry.captureException(new Error('I am an error!'));
+ window.capturedExceptionId = eventId;
+ }}
+ />
+
+ navigate
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/app/routes/client-error.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/app/routes/client-error.tsx
new file mode 100644
index 000000000000..4e5330621191
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/app/routes/client-error.tsx
@@ -0,0 +1,13 @@
+import { useState } from 'react';
+
+export default function ErrorBoundaryCapture() {
+ const [count, setCount] = useState(0);
+
+ if (count > 0) {
+ throw new Error('Sentry React Component Error');
+ } else {
+ setTimeout(() => setCount(count + 1), 0);
+ }
+
+ return {count}
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/app/routes/navigate.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/app/routes/navigate.tsx
new file mode 100644
index 000000000000..c7dcea798501
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/app/routes/navigate.tsx
@@ -0,0 +1,20 @@
+import { LoaderFunction } from '@remix-run/node';
+import { useLoaderData } from '@remix-run/react';
+
+export const loader: LoaderFunction = async ({ params: { id } }) => {
+ if (id === '-1') {
+ throw new Error('Unexpected Server Error');
+ }
+
+ return null;
+};
+
+export default function LoaderError() {
+ const data = useLoaderData();
+
+ return (
+
+
{data && data.test ? data.test : 'Not Found'}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/app/routes/user.$id.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/app/routes/user.$id.tsx
new file mode 100644
index 000000000000..13b2e0a34d1e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/app/routes/user.$id.tsx
@@ -0,0 +1,3 @@
+export default function User() {
+ return I am a blank page
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/globals.d.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/globals.d.ts
new file mode 100644
index 000000000000..4130ac6a8a09
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/globals.d.ts
@@ -0,0 +1,7 @@
+interface Window {
+ recordedTransactions?: string[];
+ capturedExceptionId?: string;
+ ENV: {
+ SENTRY_DSN: string;
+ };
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/package.json b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/package.json
new file mode 100644
index 000000000000..d70c8f824dbc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/package.json
@@ -0,0 +1,38 @@
+{
+ "private": true,
+ "sideEffects": false,
+ "scripts": {
+ "build": "remix build --sourcemap && ./upload-sourcemaps.sh",
+ "dev": "remix dev",
+ "start": "remix-serve build",
+ "typecheck": "tsc",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml",
+ "test:build": "pnpm install && npx playwright install && pnpm build",
+ "test:assert": "pnpm playwright test"
+ },
+ "dependencies": {
+ "@sentry/remix": "latest || *",
+ "@remix-run/css-bundle": "^1.19.3",
+ "@remix-run/node": "^1.19.3",
+ "@remix-run/react": "^1.19.3",
+ "@remix-run/serve": "^1.19.3",
+ "isbot": "^3.6.8",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.44.1",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "@remix-run/dev": "^1.19.3",
+ "@remix-run/eslint-config": "^1.19.3",
+ "@types/react": "^18.0.35",
+ "@types/react-dom": "^18.0.11",
+ "@sentry/types": "latest || *",
+ "@sentry/utils": "latest || *",
+ "eslint": "^8.38.0",
+ "typescript": "^5.0.4"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/playwright.config.mjs
new file mode 100644
index 000000000000..31f2b913b58b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/playwright.config.mjs
@@ -0,0 +1,7 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig({
+ startCommand: `pnpm start`,
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/remix.config.js b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/remix.config.js
new file mode 100644
index 000000000000..1cab174f72fe
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/remix.config.js
@@ -0,0 +1,17 @@
+/** @type {import('@remix-run/dev').AppConfig} */
+module.exports = {
+ ignoredRouteFiles: ['**/.*'],
+ // appDirectory: "app",
+ // assetsBuildDirectory: "public/build",
+ // serverBuildPath: "build/index.js",
+ // publicPath: "/build/",
+ serverModuleFormat: 'cjs',
+ future: {
+ v2_dev: true,
+ v2_headers: true,
+ v2_errorBoundary: true,
+ v2_meta: true,
+ v2_normalizeFormMethod: true,
+ v2_routeConvention: true,
+ },
+};
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/start-event-proxy.mjs
new file mode 100644
index 000000000000..b438dfab2193
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'create-remix-app-legacy',
+});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/tests/behaviour-client.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/tests/behaviour-client.test.ts
new file mode 100644
index 000000000000..aecc2fa8c983
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/tests/behaviour-client.test.ts
@@ -0,0 +1,192 @@
+import { expect, test } from '@playwright/test';
+
+const EVENT_POLLING_TIMEOUT = 90_000;
+
+const authToken = process.env.E2E_TEST_AUTH_TOKEN;
+const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG;
+const sentryTestProject = process.env.E2E_TEST_SENTRY_PROJECT;
+
+test('Sends a client-side exception to Sentry', async ({ page }) => {
+ await page.goto('/');
+
+ const exceptionButton = page.locator('id=exception-button');
+ await exceptionButton.click();
+
+ const exceptionIdHandle = await page.waitForFunction(() => window.capturedExceptionId);
+ const exceptionEventId = await exceptionIdHandle.jsonValue();
+
+ console.log(`Polling for error eventId: ${exceptionEventId}`);
+
+ await expect
+ .poll(
+ async () => {
+ const response = await fetch(
+ `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`,
+ { headers: { Authorization: `Bearer ${authToken}` } },
+ );
+ return response.status;
+ },
+ {
+ timeout: EVENT_POLLING_TIMEOUT,
+ },
+ )
+ .toBe(200);
+});
+
+test('Sends a pageload transaction to Sentry', async ({ page }) => {
+ await page.goto('/');
+
+ const recordedTransactionsHandle = await page.waitForFunction(() => {
+ if (window.recordedTransactions && window.recordedTransactions?.length >= 1) {
+ return window.recordedTransactions;
+ } else {
+ return undefined;
+ }
+ });
+ const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue();
+
+ if (recordedTransactionEventIds === undefined) {
+ throw new Error("Application didn't record any transaction event IDs.");
+ }
+
+ let hadPageLoadTransaction = false;
+
+ console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`);
+
+ await Promise.all(
+ recordedTransactionEventIds.map(async transactionEventId => {
+ await expect
+ .poll(
+ async () => {
+ const response = await fetch(
+ `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`,
+ { headers: { Authorization: `Bearer ${authToken}` } },
+ );
+
+ if (response.ok) {
+ const data = await response.json();
+ if (data.contexts.trace.op === 'pageload') {
+ hadPageLoadTransaction = true;
+ }
+ }
+
+ return response.status;
+ },
+ {
+ timeout: EVENT_POLLING_TIMEOUT,
+ },
+ )
+ .toBe(200);
+ }),
+ );
+
+ expect(hadPageLoadTransaction).toBe(true);
+});
+
+test('Sends a navigation transaction to Sentry', async ({ page }) => {
+ await page.goto('/');
+
+ // Give pageload transaction time to finish
+ await page.waitForTimeout(4000);
+
+ const linkElement = page.locator('id=navigation');
+ await linkElement.click();
+
+ const recordedTransactionsHandle = await page.waitForFunction(() => {
+ if (window.recordedTransactions && window.recordedTransactions?.length >= 2) {
+ return window.recordedTransactions;
+ } else {
+ return undefined;
+ }
+ });
+ const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue();
+
+ if (recordedTransactionEventIds === undefined) {
+ throw new Error("Application didn't record any transaction event IDs.");
+ }
+
+ let hadPageNavigationTransaction = false;
+
+ console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`);
+
+ await Promise.all(
+ recordedTransactionEventIds.map(async transactionEventId => {
+ await expect
+ .poll(
+ async () => {
+ const response = await fetch(
+ `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`,
+ { headers: { Authorization: `Bearer ${authToken}` } },
+ );
+
+ if (response.ok) {
+ const data = await response.json();
+ if (data.contexts.trace.op === 'navigation') {
+ hadPageNavigationTransaction = true;
+ }
+ }
+
+ return response.status;
+ },
+ {
+ timeout: EVENT_POLLING_TIMEOUT,
+ },
+ )
+ .toBe(200);
+ }),
+ );
+
+ expect(hadPageNavigationTransaction).toBe(true);
+});
+
+test('Sends a client-side ErrorBoundary exception to Sentry', async ({ page }) => {
+ await page.goto('/client-error');
+
+ const exceptionIdHandle = await page.waitForSelector('#event-id');
+ const exceptionEventId = await exceptionIdHandle.textContent();
+
+ console.log(`Polling for error eventId: ${exceptionEventId}`);
+
+ await expect
+ .poll(
+ async () => {
+ const response = await fetch(
+ `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`,
+ { headers: { Authorization: `Bearer ${authToken}` } },
+ );
+ return response.status;
+ },
+ {
+ timeout: EVENT_POLLING_TIMEOUT,
+ },
+ )
+ .toBe(200);
+});
+
+test('Renders `sentry-trace` and `baggage` meta tags for the root route', async ({ page }) => {
+ await page.goto('/');
+
+ const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', {
+ state: 'attached',
+ });
+ const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', {
+ state: 'attached',
+ });
+
+ expect(sentryTraceMetaTag).toBeTruthy();
+ expect(baggageMetaTag).toBeTruthy();
+});
+
+test('Renders `sentry-trace` and `baggage` meta tags for a sub-route', async ({ page }) => {
+ await page.goto('/user/123');
+
+ const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', {
+ state: 'attached',
+ });
+ const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', {
+ state: 'attached',
+ });
+
+ expect(sentryTraceMetaTag).toBeTruthy();
+ expect(baggageMetaTag).toBeTruthy();
+});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/tests/behaviour-server.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/tests/behaviour-server.test.ts
new file mode 100644
index 000000000000..7354257a2dd0
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/tests/behaviour-server.test.ts
@@ -0,0 +1,50 @@
+import { expect, test } from '@playwright/test';
+import { uuid4 } from '@sentry/utils';
+
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+test('Sends two linked transactions (server & client) to Sentry', async ({ page }) => {
+ // We use this to identify the transactions
+ const testTag = uuid4();
+
+ const httpServerTransactionPromise = waitForTransaction('create-remix-app-legacy', transactionEvent => {
+ return (
+ transactionEvent.type === 'transaction' &&
+ transactionEvent.contexts?.trace?.op === 'http.server' &&
+ transactionEvent.tags?.['sentry_test'] === testTag
+ );
+ });
+
+ const pageLoadTransactionPromise = waitForTransaction('create-remix-app-legacy', transactionEvent => {
+ return (
+ transactionEvent.type === 'transaction' &&
+ transactionEvent.contexts?.trace?.op === 'pageload' &&
+ transactionEvent.tags?.['sentry_test'] === testTag
+ );
+ });
+
+ page.goto(`/?tag=${testTag}`);
+
+ const pageloadTransaction = await pageLoadTransactionPromise;
+ const httpServerTransaction = await httpServerTransactionPromise;
+
+ expect(pageloadTransaction).toBeDefined();
+ expect(httpServerTransaction).toBeDefined();
+
+ const httpServerTraceId = httpServerTransaction.contexts?.trace?.trace_id;
+ const httpServerSpanId = httpServerTransaction.contexts?.trace?.span_id;
+
+ const pageLoadTraceId = pageloadTransaction.contexts?.trace?.trace_id;
+ const pageLoadSpanId = pageloadTransaction.contexts?.trace?.span_id;
+ const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id;
+
+ expect(httpServerTransaction.transaction).toBe('routes/_index');
+ expect(pageloadTransaction.transaction).toBe('routes/_index');
+
+ expect(httpServerTraceId).toBeDefined();
+ expect(httpServerSpanId).toBeDefined();
+
+ expect(pageLoadTraceId).toEqual(httpServerTraceId);
+ expect(pageLoadParentSpanId).toEqual(httpServerSpanId);
+ expect(pageLoadSpanId).not.toEqual(httpServerSpanId);
+});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/tsconfig.json b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/tsconfig.json
new file mode 100644
index 000000000000..20f8a386a6c4
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2019"],
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "target": "ES2019",
+ "strict": true,
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ },
+
+ // Remix takes care of building everything in `remix build`.
+ "noEmit": true
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/upload-sourcemaps.sh b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/upload-sourcemaps.sh
new file mode 100755
index 000000000000..35238b47305d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-legacy/upload-sourcemaps.sh
@@ -0,0 +1,3 @@
+export SENTRY_AUTH_TOKEN=${E2E_TEST_AUTH_TOKEN}
+
+sentry-upload-sourcemaps --org ${E2E_TEST_SENTRY_ORG_SLUG} --project ${E2E_TEST_SENTRY_PROJECT}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/.eslintrc.js b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/.eslintrc.js
new file mode 100644
index 000000000000..f2faf1470fd8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/.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-legacy/.gitignore b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/.gitignore
new file mode 100644
index 000000000000..3f7bf98da3e1
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/.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-legacy/.npmrc b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/.npmrc
new file mode 100644
index 000000000000..070f80f05092
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/.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-legacy/README.md b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/README.md
new file mode 100644
index 000000000000..54336d746713
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/README.md
@@ -0,0 +1,61 @@
+# Welcome to Remix!
+
+- [Remix Docs](https://remix.run/docs)
+
+## Development
+
+From your terminal:
+
+```sh
+npm run dev
+```
+
+This starts your app in development mode, rebuilding assets on file changes.
+
+## Deployment
+
+First, build your app for production:
+
+```sh
+npm run build
+```
+
+Then run the app in production mode:
+
+```sh
+npm start
+```
+
+Now you'll need to pick a host to deploy it to.
+
+### DIY
+
+If you're familiar with deploying node applications, the built-in Remix app server is production-ready.
+
+Make sure to deploy the output of `remix build`
+
+- `build/`
+- `public/build/`
+
+### Using a Template
+
+When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new
+project, then copy over relevant code/assets from your current app to the new project that's pre-configured for your
+target server.
+
+Most importantly, this means everything in the `app/` directory, but if you've further customized your current
+application outside of there it may also include:
+
+- Any assets you've added/updated in `public/`
+- Any updated versions of root files such as `.eslintrc.js`, etc.
+
+```sh
+cd ..
+# create a new project, and pick a pre-configured host
+npx create-remix@latest
+cd my-new-remix-app
+# remove the new project's app (not the old one!)
+rm -rf app
+# copy your app over
+cp -R ../my-old-remix-app/app app
+```
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/app/entry.client.tsx
new file mode 100644
index 000000000000..b3b5db3d9b3d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/app/entry.client.tsx
@@ -0,0 +1,53 @@
+/**
+ * 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
+ */
+
+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:3031/', // proxy server
+});
+
+Sentry.addEventProcessor(event => {
+ if (
+ event.type === 'transaction' &&
+ (event.contexts?.trace?.op === 'pageload' || event.contexts?.trace?.op === 'navigation')
+ ) {
+ const eventId = event.event_id;
+ if (eventId) {
+ window.recordedTransactions = window.recordedTransactions || [];
+ window.recordedTransactions.push(eventId);
+ }
+ }
+
+ return event;
+});
+
+startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+ ,
+ );
+});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/app/entry.server.tsx
new file mode 100644
index 000000000000..c34e49664197
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/app/entry.server.tsx
@@ -0,0 +1,137 @@
+import * as Sentry from '@sentry/remix';
+
+Sentry.init({
+ tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production!
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: process.env.E2E_TEST_DSN,
+ tunnel: 'http://localhost:3031/', // proxy server
+});
+
+/**
+ * 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';
+
+installGlobals();
+
+const ABORT_DELAY = 5_000;
+
+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-legacy/app/root.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/app/root.tsx
new file mode 100644
index 000000000000..517a37a9d76b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default withSentry(App);
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/app/routes/_index.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/app/routes/_index.tsx
new file mode 100644
index 000000000000..b646c62ee4da
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/app/routes/_index.tsx
@@ -0,0 +1,27 @@
+import { Link, useSearchParams } from '@remix-run/react';
+import * as Sentry from '@sentry/remix';
+
+export default function Index() {
+ const [searchParams] = useSearchParams();
+
+ if (searchParams.get('tag')) {
+ Sentry.setTag('sentry_test', searchParams.get('tag'));
+ }
+
+ return (
+
+ {
+ const eventId = Sentry.captureException(new Error('I am an error!'));
+ window.capturedExceptionId = eventId;
+ }}
+ />
+
+ navigate
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/app/routes/client-error.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/app/routes/client-error.tsx
new file mode 100644
index 000000000000..4e5330621191
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/app/routes/client-error.tsx
@@ -0,0 +1,13 @@
+import { useState } from 'react';
+
+export default function ErrorBoundaryCapture() {
+ const [count, setCount] = useState(0);
+
+ if (count > 0) {
+ throw new Error('Sentry React Component Error');
+ } else {
+ setTimeout(() => setCount(count + 1), 0);
+ }
+
+ return {count}
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/app/routes/navigate.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/app/routes/navigate.tsx
new file mode 100644
index 000000000000..c7dcea798501
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/app/routes/navigate.tsx
@@ -0,0 +1,20 @@
+import { LoaderFunction } from '@remix-run/node';
+import { useLoaderData } from '@remix-run/react';
+
+export const loader: LoaderFunction = async ({ params: { id } }) => {
+ if (id === '-1') {
+ throw new Error('Unexpected Server Error');
+ }
+
+ return null;
+};
+
+export default function LoaderError() {
+ const data = useLoaderData();
+
+ return (
+
+
{data && data.test ? data.test : 'Not Found'}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/app/routes/user.$id.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/app/routes/user.$id.tsx
new file mode 100644
index 000000000000..13b2e0a34d1e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/app/routes/user.$id.tsx
@@ -0,0 +1,3 @@
+export default function User() {
+ return I am a blank page
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/globals.d.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/globals.d.ts
new file mode 100644
index 000000000000..4130ac6a8a09
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/globals.d.ts
@@ -0,0 +1,7 @@
+interface Window {
+ recordedTransactions?: string[];
+ capturedExceptionId?: string;
+ ENV: {
+ SENTRY_DSN: string;
+ };
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/package.json b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/package.json
new file mode 100644
index 000000000000..a5f932f7cab4
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/package.json
@@ -0,0 +1,38 @@
+{
+ "private": true,
+ "sideEffects": false,
+ "scripts": {
+ "build": "remix build",
+ "dev": "remix dev",
+ "start": "remix-serve build/index.js",
+ "typecheck": "tsc",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml",
+ "test:build": "pnpm install && npx playwright install && pnpm build",
+ "test:assert": "pnpm playwright test"
+ },
+ "dependencies": {
+ "@sentry/remix": "latest || *",
+ "@remix-run/css-bundle": "2.7.2",
+ "@remix-run/node": "2.7.2",
+ "@remix-run/react": "2.7.2",
+ "@remix-run/serve": "2.7.2",
+ "isbot": "^3.6.8",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.44.1",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "@remix-run/dev": "2.7.2",
+ "@remix-run/eslint-config": "2.7.2",
+ "@sentry/types": "latest || *",
+ "@sentry/utils": "latest || *",
+ "@types/react": "^18.0.35",
+ "@types/react-dom": "^18.0.11",
+ "eslint": "^8.38.0",
+ "typescript": "^5.0.4"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/playwright.config.mjs
new file mode 100644
index 000000000000..31f2b913b58b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/playwright.config.mjs
@@ -0,0 +1,7 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig({
+ startCommand: `pnpm start`,
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/remix.config.js b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/remix.config.js
new file mode 100644
index 000000000000..cb3c8c7a9fb7
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/remix.config.js
@@ -0,0 +1,9 @@
+/** @type {import('@remix-run/dev').AppConfig} */
+module.exports = {
+ ignoredRouteFiles: ['**/.*'],
+ // appDirectory: 'app',
+ // assetsBuildDirectory: 'public/build',
+ // serverBuildPath: 'build/index.js',
+ // publicPath: '/build/',
+ serverModuleFormat: 'cjs',
+};
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/remix.env.d.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/remix.env.d.ts
new file mode 100644
index 000000000000..dcf8c45e1d4c
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/remix.env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/start-event-proxy.mjs
new file mode 100644
index 000000000000..1719aa397840
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'create-remix-app-v2-legacy',
+});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/tests/behaviour-client.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/tests/behaviour-client.test.ts
new file mode 100644
index 000000000000..aecc2fa8c983
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/tests/behaviour-client.test.ts
@@ -0,0 +1,192 @@
+import { expect, test } from '@playwright/test';
+
+const EVENT_POLLING_TIMEOUT = 90_000;
+
+const authToken = process.env.E2E_TEST_AUTH_TOKEN;
+const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG;
+const sentryTestProject = process.env.E2E_TEST_SENTRY_PROJECT;
+
+test('Sends a client-side exception to Sentry', async ({ page }) => {
+ await page.goto('/');
+
+ const exceptionButton = page.locator('id=exception-button');
+ await exceptionButton.click();
+
+ const exceptionIdHandle = await page.waitForFunction(() => window.capturedExceptionId);
+ const exceptionEventId = await exceptionIdHandle.jsonValue();
+
+ console.log(`Polling for error eventId: ${exceptionEventId}`);
+
+ await expect
+ .poll(
+ async () => {
+ const response = await fetch(
+ `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`,
+ { headers: { Authorization: `Bearer ${authToken}` } },
+ );
+ return response.status;
+ },
+ {
+ timeout: EVENT_POLLING_TIMEOUT,
+ },
+ )
+ .toBe(200);
+});
+
+test('Sends a pageload transaction to Sentry', async ({ page }) => {
+ await page.goto('/');
+
+ const recordedTransactionsHandle = await page.waitForFunction(() => {
+ if (window.recordedTransactions && window.recordedTransactions?.length >= 1) {
+ return window.recordedTransactions;
+ } else {
+ return undefined;
+ }
+ });
+ const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue();
+
+ if (recordedTransactionEventIds === undefined) {
+ throw new Error("Application didn't record any transaction event IDs.");
+ }
+
+ let hadPageLoadTransaction = false;
+
+ console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`);
+
+ await Promise.all(
+ recordedTransactionEventIds.map(async transactionEventId => {
+ await expect
+ .poll(
+ async () => {
+ const response = await fetch(
+ `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`,
+ { headers: { Authorization: `Bearer ${authToken}` } },
+ );
+
+ if (response.ok) {
+ const data = await response.json();
+ if (data.contexts.trace.op === 'pageload') {
+ hadPageLoadTransaction = true;
+ }
+ }
+
+ return response.status;
+ },
+ {
+ timeout: EVENT_POLLING_TIMEOUT,
+ },
+ )
+ .toBe(200);
+ }),
+ );
+
+ expect(hadPageLoadTransaction).toBe(true);
+});
+
+test('Sends a navigation transaction to Sentry', async ({ page }) => {
+ await page.goto('/');
+
+ // Give pageload transaction time to finish
+ await page.waitForTimeout(4000);
+
+ const linkElement = page.locator('id=navigation');
+ await linkElement.click();
+
+ const recordedTransactionsHandle = await page.waitForFunction(() => {
+ if (window.recordedTransactions && window.recordedTransactions?.length >= 2) {
+ return window.recordedTransactions;
+ } else {
+ return undefined;
+ }
+ });
+ const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue();
+
+ if (recordedTransactionEventIds === undefined) {
+ throw new Error("Application didn't record any transaction event IDs.");
+ }
+
+ let hadPageNavigationTransaction = false;
+
+ console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`);
+
+ await Promise.all(
+ recordedTransactionEventIds.map(async transactionEventId => {
+ await expect
+ .poll(
+ async () => {
+ const response = await fetch(
+ `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`,
+ { headers: { Authorization: `Bearer ${authToken}` } },
+ );
+
+ if (response.ok) {
+ const data = await response.json();
+ if (data.contexts.trace.op === 'navigation') {
+ hadPageNavigationTransaction = true;
+ }
+ }
+
+ return response.status;
+ },
+ {
+ timeout: EVENT_POLLING_TIMEOUT,
+ },
+ )
+ .toBe(200);
+ }),
+ );
+
+ expect(hadPageNavigationTransaction).toBe(true);
+});
+
+test('Sends a client-side ErrorBoundary exception to Sentry', async ({ page }) => {
+ await page.goto('/client-error');
+
+ const exceptionIdHandle = await page.waitForSelector('#event-id');
+ const exceptionEventId = await exceptionIdHandle.textContent();
+
+ console.log(`Polling for error eventId: ${exceptionEventId}`);
+
+ await expect
+ .poll(
+ async () => {
+ const response = await fetch(
+ `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`,
+ { headers: { Authorization: `Bearer ${authToken}` } },
+ );
+ return response.status;
+ },
+ {
+ timeout: EVENT_POLLING_TIMEOUT,
+ },
+ )
+ .toBe(200);
+});
+
+test('Renders `sentry-trace` and `baggage` meta tags for the root route', async ({ page }) => {
+ await page.goto('/');
+
+ const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', {
+ state: 'attached',
+ });
+ const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', {
+ state: 'attached',
+ });
+
+ expect(sentryTraceMetaTag).toBeTruthy();
+ expect(baggageMetaTag).toBeTruthy();
+});
+
+test('Renders `sentry-trace` and `baggage` meta tags for a sub-route', async ({ page }) => {
+ await page.goto('/user/123');
+
+ const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', {
+ state: 'attached',
+ });
+ const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', {
+ state: 'attached',
+ });
+
+ expect(sentryTraceMetaTag).toBeTruthy();
+ expect(baggageMetaTag).toBeTruthy();
+});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/tests/behaviour-server.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/tests/behaviour-server.test.ts
new file mode 100644
index 000000000000..d19dec15e0bc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/tests/behaviour-server.test.ts
@@ -0,0 +1,50 @@
+import { expect, test } from '@playwright/test';
+import { uuid4 } from '@sentry/utils';
+
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+test('Sends two linked transactions (server & client) to Sentry', async ({ page }) => {
+ // We use this to identify the transactions
+ const testTag = uuid4();
+
+ const httpServerTransactionPromise = waitForTransaction('create-remix-app-v2-legacy', transactionEvent => {
+ return (
+ transactionEvent.type === 'transaction' &&
+ transactionEvent.contexts?.trace?.op === 'http.server' &&
+ transactionEvent.tags?.['sentry_test'] === testTag
+ );
+ });
+
+ const pageLoadTransactionPromise = waitForTransaction('create-remix-app-v2-legacy', transactionEvent => {
+ return (
+ transactionEvent.type === 'transaction' &&
+ transactionEvent.contexts?.trace?.op === 'pageload' &&
+ transactionEvent.tags?.['sentry_test'] === testTag
+ );
+ });
+
+ page.goto(`/?tag=${testTag}`);
+
+ const pageloadTransaction = await pageLoadTransactionPromise;
+ const httpServerTransaction = await httpServerTransactionPromise;
+
+ expect(pageloadTransaction).toBeDefined();
+ expect(httpServerTransaction).toBeDefined();
+
+ const httpServerTraceId = httpServerTransaction.contexts?.trace?.trace_id;
+ const httpServerSpanId = httpServerTransaction.contexts?.trace?.span_id;
+
+ const pageLoadTraceId = pageloadTransaction.contexts?.trace?.trace_id;
+ const pageLoadSpanId = pageloadTransaction.contexts?.trace?.span_id;
+ const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id;
+
+ expect(httpServerTransaction.transaction).toBe('routes/_index');
+ expect(pageloadTransaction.transaction).toBe('routes/_index');
+
+ expect(httpServerTraceId).toBeDefined();
+ expect(httpServerSpanId).toBeDefined();
+
+ expect(pageLoadTraceId).toEqual(httpServerTraceId);
+ expect(pageLoadParentSpanId).toEqual(httpServerSpanId);
+ expect(pageLoadSpanId).not.toEqual(httpServerSpanId);
+});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/tsconfig.json b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/tsconfig.json
new file mode 100644
index 000000000000..20f8a386a6c4
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-legacy/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2019"],
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "target": "ES2019",
+ "strict": true,
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ },
+
+ // Remix takes care of building everything in `remix build`.
+ "noEmit": true
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.server.tsx
index c49e814246a8..0529e2417e48 100644
--- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.server.tsx
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.server.tsx
@@ -1,12 +1,5 @@
import * as Sentry from '@sentry/remix';
-Sentry.init({
- tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production!
- environment: 'qa', // dynamic sampling bias to keep transactions
- dsn: process.env.E2E_TEST_DSN,
- tunnel: 'http://localhost:3031/', // proxy server
-});
-
/**
* 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` ✨
@@ -26,13 +19,6 @@ installGlobals();
const ABORT_DELAY = 5_000;
-Sentry.init({
- environment: 'qa', // dynamic sampling bias to keep transactions
- dsn: process.env.E2E_TEST_DSN,
- // Performance Monitoring
- tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production!
-});
-
const handleErrorImpl = () => {
Sentry.setTag('remix-test-tag', 'remix-test-value');
};
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/instrument.server.cjs b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/instrument.server.cjs
new file mode 100644
index 000000000000..5b80ca7b8695
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/instrument.server.cjs
@@ -0,0 +1,9 @@
+const Sentry = require('@sentry/remix');
+
+Sentry.init({
+ tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production!
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: process.env.E2E_TEST_DSN,
+ tunnel: 'http://localhost:3031/', // proxy server
+ autoInstrumentRemix: true, // auto instrument Remix
+});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json
index a5f932f7cab4..a1fab6f6de9a 100644
--- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json
@@ -4,7 +4,7 @@
"scripts": {
"build": "remix build",
"dev": "remix dev",
- "start": "remix-serve build/index.js",
+ "start": "NODE_OPTIONS='--require=./instrument.server.cjs' remix-serve build/index.js",
"typecheck": "tsc",
"clean": "npx rimraf node_modules pnpm-lock.yaml",
"test:build": "pnpm install && npx playwright install && pnpm build",
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/behaviour-server.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/behaviour-server.test.ts
index 9387cec33752..ea95b97fa611 100644
--- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/behaviour-server.test.ts
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/behaviour-server.test.ts
@@ -33,18 +33,21 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page
const httpServerTraceId = httpServerTransaction.contexts?.trace?.trace_id;
const httpServerSpanId = httpServerTransaction.contexts?.trace?.span_id;
+ const loaderSpanId = httpServerTransaction?.spans?.find(
+ span => span.data && span.data['code.function'] === 'loader',
+ )?.span_id;
const pageLoadTraceId = pageloadTransaction.contexts?.trace?.trace_id;
const pageLoadSpanId = pageloadTransaction.contexts?.trace?.span_id;
const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id;
- expect(httpServerTransaction.transaction).toBe('routes/_index');
+ expect(httpServerTransaction.transaction).toBe('GET http://localhost:3030/');
expect(pageloadTransaction.transaction).toBe('routes/_index');
expect(httpServerTraceId).toBeDefined();
expect(httpServerSpanId).toBeDefined();
expect(pageLoadTraceId).toEqual(httpServerTraceId);
- expect(pageLoadParentSpanId).toEqual(httpServerSpanId);
+ expect(pageLoadParentSpanId).toEqual(loaderSpanId);
expect(pageLoadSpanId).not.toEqual(httpServerSpanId);
});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app/app/entry.server.tsx
index b0f1c5d19f09..ee0aaa79b814 100644
--- a/dev-packages/e2e-tests/test-applications/create-remix-app/app/entry.server.tsx
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app/app/entry.server.tsx
@@ -1,12 +1,3 @@
-import * as Sentry from '@sentry/remix';
-
-Sentry.init({
- tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production!
- environment: 'qa', // dynamic sampling bias to keep transactions
- dsn: process.env.E2E_TEST_DSN,
- tunnel: 'http://localhost:3031/', // proxy server
-});
-
/**
* 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` ✨
@@ -14,6 +5,7 @@ Sentry.init({
*/
import { PassThrough } from 'node:stream';
+import * as Sentry from '@sentry/remix';
import type { AppLoadContext, EntryContext } from '@remix-run/node';
import { Response } from '@remix-run/node';
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app/instrument.server.cjs b/dev-packages/e2e-tests/test-applications/create-remix-app/instrument.server.cjs
new file mode 100644
index 000000000000..5b80ca7b8695
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app/instrument.server.cjs
@@ -0,0 +1,9 @@
+const Sentry = require('@sentry/remix');
+
+Sentry.init({
+ tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production!
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: process.env.E2E_TEST_DSN,
+ tunnel: 'http://localhost:3031/', // proxy server
+ autoInstrumentRemix: true, // auto instrument Remix
+});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app/package.json b/dev-packages/e2e-tests/test-applications/create-remix-app/package.json
index d70c8f824dbc..1db0d3858918 100644
--- a/dev-packages/e2e-tests/test-applications/create-remix-app/package.json
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app/package.json
@@ -4,7 +4,7 @@
"scripts": {
"build": "remix build --sourcemap && ./upload-sourcemaps.sh",
"dev": "remix dev",
- "start": "remix-serve build",
+ "start": "NODE_OPTIONS='--require=./instrument.server.cjs' remix-serve build",
"typecheck": "tsc",
"clean": "npx rimraf node_modules pnpm-lock.yaml",
"test:build": "pnpm install && npx playwright install && pnpm build",
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app/tests/behaviour-server.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app/tests/behaviour-server.test.ts
index 5107990507e4..45f24ad9d18b 100644
--- a/dev-packages/e2e-tests/test-applications/create-remix-app/tests/behaviour-server.test.ts
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app/tests/behaviour-server.test.ts
@@ -33,18 +33,21 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page
const httpServerTraceId = httpServerTransaction.contexts?.trace?.trace_id;
const httpServerSpanId = httpServerTransaction.contexts?.trace?.span_id;
+ const loaderSpanId = httpServerTransaction?.spans?.find(
+ span => span.data && span.data['code.function'] === 'loader',
+ )?.span_id;
const pageLoadTraceId = pageloadTransaction.contexts?.trace?.trace_id;
const pageLoadSpanId = pageloadTransaction.contexts?.trace?.span_id;
const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id;
- expect(httpServerTransaction.transaction).toBe('routes/_index');
+ expect(httpServerTransaction.transaction).toBe('GET http://localhost:3030/');
expect(pageloadTransaction.transaction).toBe('routes/_index');
expect(httpServerTraceId).toBeDefined();
expect(httpServerSpanId).toBeDefined();
expect(pageLoadTraceId).toEqual(httpServerTraceId);
- expect(pageLoadParentSpanId).toEqual(httpServerSpanId);
+ expect(pageLoadParentSpanId).toEqual(loaderSpanId);
expect(pageLoadSpanId).not.toEqual(httpServerSpanId);
});
diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts
index d1635cdada54..e9d809bae4d8 100644
--- a/packages/astro/src/index.server.ts
+++ b/packages/astro/src/index.server.ts
@@ -21,6 +21,7 @@ export {
getCurrentHub,
getClient,
isInitialized,
+ generateInstrumentOnce,
getCurrentScope,
getGlobalScope,
getIsolationScope,
diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts
index 62165a710127..eee24075bdf8 100644
--- a/packages/aws-serverless/src/index.ts
+++ b/packages/aws-serverless/src/index.ts
@@ -16,6 +16,7 @@ export {
getCurrentHub,
getClient,
isInitialized,
+ generateInstrumentOnce,
getCurrentScope,
getGlobalScope,
getIsolationScope,
diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts
index c3e8eff8beac..199013b959ff 100644
--- a/packages/bun/src/index.ts
+++ b/packages/bun/src/index.ts
@@ -36,6 +36,7 @@ export {
getCurrentHub,
getClient,
isInitialized,
+ generateInstrumentOnce,
getCurrentScope,
getGlobalScope,
getIsolationScope,
diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts
index 6affee429e1f..73e94aa5f271 100644
--- a/packages/google-cloud-serverless/src/index.ts
+++ b/packages/google-cloud-serverless/src/index.ts
@@ -16,6 +16,7 @@ export {
getCurrentHub,
getClient,
isInitialized,
+ generateInstrumentOnce,
getCurrentScope,
getGlobalScope,
getIsolationScope,
diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts
index 0003850d8f71..85d001b465e5 100644
--- a/packages/node/src/index.ts
+++ b/packages/node/src/index.ts
@@ -27,6 +27,8 @@ export { connectIntegration, setupConnectErrorHandler } from './integrations/tra
export { spotlightIntegration } from './integrations/spotlight';
export { SentryContextManager } from './otel/contextManager';
+export { generateInstrumentOnce } from './otel/instrument';
+
export {
init,
getDefaultIntegrations,
diff --git a/packages/remix/.eslintrc.js b/packages/remix/.eslintrc.js
index 4195984e1745..992bcd3e9d2b 100644
--- a/packages/remix/.eslintrc.js
+++ b/packages/remix/.eslintrc.js
@@ -6,7 +6,7 @@ module.exports = {
parserOptions: {
jsx: true,
},
- ignorePatterns: ['playwright.config.ts', 'test/integration/**'],
+ ignorePatterns: ['playwright.config.ts', 'vitest.config.ts', 'test/integration/**'],
extends: ['../../.eslintrc.js'],
rules: {
'@sentry-internal/sdk/no-optional-chaining': 'off',
diff --git a/packages/remix/test/integration/.gitignore b/packages/remix/.gitignore
similarity index 80%
rename from packages/remix/test/integration/.gitignore
rename to packages/remix/.gitignore
index 94f2cbbd8a42..3062ec3dd163 100644
--- a/packages/remix/test/integration/.gitignore
+++ b/packages/remix/.gitignore
@@ -1,6 +1,6 @@
node_modules
-/.cache
+test/integration/.cache
/build
/public/build
.env
@@ -8,3 +8,4 @@ node_modules
/playwright-report/
/playwright/.cache/
yarn.lock
+
diff --git a/packages/remix/package.json b/packages/remix/package.json
index e4aa316b7e85..5f15d8fbbd96 100644
--- a/packages/remix/package.json
+++ b/packages/remix/package.json
@@ -57,6 +57,7 @@
"access": "public"
},
"dependencies": {
+ "@opentelemetry/instrumentation-http": "0.52.0",
"@remix-run/router": "1.x",
"@sentry/cli": "^2.32.1",
"@sentry/core": "8.9.2",
@@ -66,12 +67,15 @@
"@sentry/types": "8.9.2",
"@sentry/utils": "8.9.2",
"glob": "^10.3.4",
+ "opentelemetry-instrumentation-remix": "0.7.0",
"yargs": "^17.6.0"
},
"devDependencies": {
"@remix-run/node": "^1.4.3",
"@remix-run/react": "^1.4.3",
- "@types/express": "^4.17.14"
+ "@types/express": "^4.17.14",
+ "vite": "^5.2.11",
+ "vitest": "^1.6.0"
},
"peerDependencies": {
"@remix-run/node": "1.x || 2.x",
@@ -95,7 +99,9 @@
"fix": "eslint . --format stylish --fix",
"lint": "eslint . --format stylish",
"test": "yarn test:unit",
- "test:integration": "run-s test:integration:v1 test:integration:v2",
+ "test:integration": "run-s test:integration:otel test:integration:legacy",
+ "test:integration:otel": "export USE_OTEL=1 && run-s test:integration:v1 test:integration:v2",
+ "test:integration:legacy": "export USE_OTEL=0 && run-s test:integration:v1 test:integration:v2",
"test:integration:v1": "run-s test:integration:clean test:integration:prepare test:integration:client test:integration:server",
"test:integration:v2": "export REMIX_VERSION=2 && run-s test:integration:v1",
"test:integration:ci": "run-s test:integration:clean test:integration:prepare test:integration:client:ci test:integration:server",
@@ -103,7 +109,7 @@
"test:integration:clean": "(cd test/integration && rimraf .cache node_modules build)",
"test:integration:client": "yarn playwright install-deps && yarn playwright test test/integration/test/client/ --project='chromium'",
"test:integration:client:ci": "yarn test:integration:client --reporter='line'",
- "test:integration:server": "export NODE_OPTIONS='--stack-trace-limit=25' && jest --config=test/integration/jest.config.js test/integration/test/server/",
+ "test:integration:server": "export NODE_OPTIONS='--stack-trace-limit=25' && vitest run",
"test:unit": "jest",
"test:watch": "jest --watch",
"yalc:publish": "ts-node ../../scripts/prepack.ts && yalc publish build --push --sig"
diff --git a/packages/remix/playwright.config.ts b/packages/remix/playwright.config.ts
index b75f865d9518..72e9bd749a52 100644
--- a/packages/remix/playwright.config.ts
+++ b/packages/remix/playwright.config.ts
@@ -12,6 +12,9 @@ const config: PlaywrightTestConfig = {
// Note that 3 is a random number selected to work well with our CI setup
workers: process.env.CI ? 3 : undefined,
webServer: {
+ env: {
+ NODE_OPTIONS: process.env.USE_OTEL === '1' ? '--require ./instrument.server.cjs' : '',
+ },
command: '(cd test/integration/ && yarn build && yarn start)',
port: 3000,
},
diff --git a/packages/remix/src/index.server.ts b/packages/remix/src/index.server.ts
index a6476b692fbf..9ffc69a4ec12 100644
--- a/packages/remix/src/index.server.ts
+++ b/packages/remix/src/index.server.ts
@@ -1,10 +1,18 @@
-import { applySdkMetadata, isInitialized } from '@sentry/core';
+import { applySdkMetadata } from '@sentry/core';
import type { NodeOptions } from '@sentry/node';
-import { init as nodeInit, setTag } from '@sentry/node';
+import {
+ getDefaultIntegrations as getDefaultNodeIntegrations,
+ init as nodeInit,
+ isInitialized,
+ setTag,
+} from '@sentry/node';
+import type { Integration } from '@sentry/types';
import { logger } from '@sentry/utils';
import { DEBUG_BUILD } from './utils/debug-build';
import { instrumentServer } from './utils/instrumentServer';
+import { httpIntegration } from './utils/integrations/http';
+import { remixIntegration } from './utils/integrations/opentelemetry';
import type { RemixOptions } from './utils/remixOptions';
// We need to explicitly export @sentry/node as they end up under `default` in ESM builds
@@ -41,7 +49,6 @@ export {
withScope,
withIsolationScope,
makeNodeTransport,
- getDefaultIntegrations,
defaultStackParser,
lastEventId,
flush,
@@ -109,20 +116,48 @@ export {
export * from '@sentry/node';
export {
- captureRemixServerException,
// eslint-disable-next-line deprecation/deprecation
wrapRemixHandleError,
sentryHandleError,
wrapHandleErrorWithSentry,
} from './utils/instrumentServer';
+
+export { captureRemixServerException } from './utils/errors';
+
export { ErrorBoundary, withErrorBoundary } from '@sentry/react';
export { withSentry } from './client/performance';
export { captureRemixErrorBoundaryError } from './client/errors';
export { browserTracingIntegration } from './client/browserTracingIntegration';
-export { wrapExpressCreateRequestHandler } from './utils/serverAdapters/express';
export type { SentryMetaArgs } from './utils/types';
+/**
+ * Returns the default Remix integrations.
+ *
+ * @param options The options for the SDK.
+ */
+export function getRemixDefaultIntegrations(options: RemixOptions): Integration[] {
+ return [
+ ...getDefaultNodeIntegrations(options as NodeOptions).filter(integration => integration.name !== 'Http'),
+ httpIntegration(),
+ options.autoInstrumentRemix ? remixIntegration() : undefined,
+ ].filter(int => int) as Integration[];
+}
+
+/**
+ * Returns the given Express createRequestHandler function.
+ * This function is no-op and only returns the given function.
+ *
+ * @deprecated No need to wrap the Express request handler.
+ * @param createRequestHandlerFn The Remix Express `createRequestHandler`.
+ * @returns `createRequestHandler` function.
+ */
+export function wrapExpressCreateRequestHandler(createRequestHandlerFn: unknown): unknown {
+ DEBUG_BUILD && logger.warn('wrapExpressCreateRequestHandler is deprecated and no longer needed.');
+
+ return createRequestHandlerFn;
+}
+
/** Initializes Sentry Remix SDK on Node. */
export function init(options: RemixOptions): void {
applySdkMetadata(options, 'remix', ['remix', 'node']);
@@ -133,9 +168,11 @@ export function init(options: RemixOptions): void {
return;
}
- instrumentServer();
+ options.defaultIntegrations = getRemixDefaultIntegrations(options as NodeOptions);
nodeInit(options as NodeOptions);
+ instrumentServer(options);
+
setTag('runtime', 'node');
}
diff --git a/packages/remix/src/utils/errors.ts b/packages/remix/src/utils/errors.ts
new file mode 100644
index 000000000000..3c8943d2c107
--- /dev/null
+++ b/packages/remix/src/utils/errors.ts
@@ -0,0 +1,195 @@
+import type { AppData, DataFunctionArgs, EntryContext, HandleDocumentRequestFunction } from '@remix-run/node';
+import {
+ captureException,
+ getActiveSpan,
+ getClient,
+ getRootSpan,
+ handleCallbackErrors,
+ spanToJSON,
+} from '@sentry/core';
+import type { Span } from '@sentry/types';
+import { addExceptionMechanism, isPrimitive, logger, objectify } from '@sentry/utils';
+import { DEBUG_BUILD } from './debug-build';
+import type { RemixOptions } from './remixOptions';
+import { storeFormDataKeys } from './utils';
+import { extractData, isResponse, isRouteErrorResponse } from './vendor/response';
+import type { DataFunction, RemixRequest } from './vendor/types';
+import { normalizeRemixRequest } from './web-fetch';
+
+/**
+ * Captures an exception happened in the Remix server.
+ *
+ * @param err The error to capture.
+ * @param name The name of the origin function.
+ * @param request The request object.
+ * @param isRemixV2 Whether the error is from Remix v2 or not. Default is `true`.
+ *
+ * @returns A promise that resolves when the exception is captured.
+ */
+export async function captureRemixServerException(
+ err: unknown,
+ name: string,
+ request: Request,
+ isRemixV2: boolean = true,
+): Promise {
+ // Skip capturing if the thrown error is not a 5xx response
+ // https://remix.run/docs/en/v1/api/conventions#throwing-responses-in-loaders
+ if (isRemixV2 && isRouteErrorResponse(err) && err.status < 500) {
+ return;
+ }
+
+ if (isResponse(err) && err.status < 500) {
+ return;
+ }
+ // Skip capturing if the request is aborted as Remix docs suggest
+ // Ref: https://remix.run/docs/en/main/file-conventions/entry.server#handleerror
+ if (request.signal.aborted) {
+ DEBUG_BUILD && logger.warn('Skipping capture of aborted request');
+ return;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let normalizedRequest: Record = request as unknown as any;
+
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ normalizedRequest = normalizeRemixRequest(request as unknown as any);
+ } catch (e) {
+ DEBUG_BUILD && logger.warn('Failed to normalize Remix request');
+ }
+
+ const objectifiedErr = objectify(err);
+
+ captureException(isResponse(objectifiedErr) ? await extractResponseError(objectifiedErr) : objectifiedErr, scope => {
+ const activeSpan = getActiveSpan();
+ const rootSpan = activeSpan && getRootSpan(activeSpan);
+ const activeRootSpanName = rootSpan ? spanToJSON(rootSpan).description : undefined;
+
+ scope.setSDKProcessingMetadata({
+ request: {
+ ...normalizedRequest,
+ // When `route` is not defined, `RequestData` integration uses the full URL
+ route: activeRootSpanName
+ ? {
+ path: activeRootSpanName,
+ }
+ : undefined,
+ },
+ });
+
+ scope.addEventProcessor(event => {
+ addExceptionMechanism(event, {
+ type: 'instrument',
+ handled: false,
+ data: {
+ function: name,
+ },
+ });
+
+ return event;
+ });
+
+ return scope;
+ });
+}
+
+/**
+ * Wraps the original `HandleDocumentRequestFunction` with error handling.
+ *
+ * @param origDocumentRequestFunction The original `HandleDocumentRequestFunction`.
+ * @param requestContext The request context.
+ * @param isRemixV2 Whether the Remix version is v2 or not.
+ *
+ * @returns The wrapped `HandleDocumentRequestFunction`.
+ */
+export function errorHandleDocumentRequestFunction(
+ this: unknown,
+ origDocumentRequestFunction: HandleDocumentRequestFunction,
+ requestContext: {
+ request: RemixRequest;
+ responseStatusCode: number;
+ responseHeaders: Headers;
+ context: EntryContext;
+ loadContext?: Record;
+ },
+ isRemixV2: boolean,
+): HandleDocumentRequestFunction {
+ const { request, responseStatusCode, responseHeaders, context, loadContext } = requestContext;
+
+ return handleCallbackErrors(
+ () => {
+ return origDocumentRequestFunction.call(this, request, responseStatusCode, responseHeaders, context, loadContext);
+ },
+ err => {
+ // This exists to capture the server-side rendering errors on Remix v1
+ // On Remix v2, we capture SSR errors at `handleError`
+ // We also skip primitives here, as we can't dedupe them, and also we don't expect any primitive SSR errors.
+ if (!isRemixV2 && !isPrimitive(err)) {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ captureRemixServerException(err, 'documentRequest', request, isRemixV2);
+ }
+
+ throw err;
+ },
+ );
+}
+
+/**
+ * Wraps the original `DataFunction` with error handling.
+ * This function also stores the form data keys if the action is being called.
+ *
+ * @param origFn The original `DataFunction`.
+ * @param name The name of the function.
+ * @param args The arguments of the function.
+ * @param isRemixV2 Whether the Remix version is v2 or not.
+ * @param span The span to store the form data keys.
+ *
+ * @returns The wrapped `DataFunction`.
+ */
+export async function errorHandleDataFunction(
+ this: unknown,
+ origFn: DataFunction,
+ name: string,
+ args: DataFunctionArgs,
+ isRemixV2: boolean,
+ span?: Span,
+): Promise {
+ return handleCallbackErrors(
+ async () => {
+ if (name === 'action' && span) {
+ const options = getClient()?.getOptions() as RemixOptions;
+
+ if (options.sendDefaultPii && options.captureActionFormDataKeys) {
+ await storeFormDataKeys(args, span);
+ }
+ }
+
+ return origFn.call(this, args);
+ },
+ err => {
+ // On Remix v2, we capture all unexpected errors (except the `Route Error Response`s / Thrown Responses) in `handleError` function.
+ // This is both for consistency and also avoid duplicates such as primitives like `string` or `number` being captured twice.
+ // Remix v1 does not have a `handleError` function, so we capture all errors here.
+ if (isRemixV2 ? isResponse(err) : true) {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ captureRemixServerException(err, name, args.request);
+ }
+
+ throw err;
+ },
+ );
+}
+
+async function extractResponseError(response: Response): Promise {
+ const responseData = await extractData(response);
+
+ if (typeof responseData === 'string' && responseData.length > 0) {
+ return new Error(responseData);
+ }
+
+ if (response.statusText) {
+ return new Error(response.statusText);
+ }
+
+ return responseData;
+}
diff --git a/packages/remix/src/utils/instrumentServer.ts b/packages/remix/src/utils/instrumentServer.ts
index 86f596b61eb7..e83c14dfbbc4 100644
--- a/packages/remix/src/utils/instrumentServer.ts
+++ b/packages/remix/src/utils/instrumentServer.ts
@@ -3,12 +3,9 @@ import {
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
- captureException,
- continueTrace,
getActiveSpan,
getClient,
getRootSpan,
- handleCallbackErrors,
hasTracingEnabled,
setHttpStatus,
spanToJSON,
@@ -16,30 +13,17 @@ import {
startSpan,
withIsolationScope,
} from '@sentry/core';
-import { getDynamicSamplingContextFromSpan } from '@sentry/opentelemetry';
-import type { Span, TransactionSource, WrappedFunction } from '@sentry/types';
-import {
- addExceptionMechanism,
- dynamicSamplingContextToSentryBaggageHeader,
- fill,
- isNodeEnv,
- isPrimitive,
- loadModule,
- logger,
- objectify,
-} from '@sentry/utils';
+import { continueTrace, getDynamicSamplingContextFromSpan } from '@sentry/opentelemetry';
+import type { TransactionSource, WrappedFunction } from '@sentry/types';
+import type { Span } from '@sentry/types';
+import { dynamicSamplingContextToSentryBaggageHeader, fill, isNodeEnv, loadModule, logger } from '@sentry/utils';
import { DEBUG_BUILD } from './debug-build';
+import { captureRemixServerException, errorHandleDataFunction, errorHandleDocumentRequestFunction } from './errors';
import { getFutureFlagsServer, getRemixVersionFromBuild } from './futureFlags';
-import {
- extractData,
- getRequestMatch,
- isDeferredData,
- isResponse,
- isRouteErrorResponse,
- json,
- matchServerRoutes,
-} from './vendor/response';
+import type { RemixOptions } from './remixOptions';
+import { createRoutes, getTransactionName } from './utils';
+import { extractData, isDeferredData, isResponse, isRouteErrorResponse, json } from './vendor/response';
import type {
AppData,
AppLoadContext,
@@ -58,7 +42,6 @@ import type {
import { normalizeRemixRequest } from './web-fetch';
let FUTURE_FLAGS: FutureConfig | undefined;
-let IS_REMIX_V2: boolean | undefined;
const redirectStatusCodes = new Set([301, 302, 303, 307, 308]);
function isRedirectResponse(response: Response): boolean {
@@ -69,20 +52,6 @@ function isCatchResponse(response: Response): boolean {
return response.headers.get('X-Remix-Catch') != null;
}
-async function extractResponseError(response: Response): Promise {
- const responseData = await extractData(response);
-
- if (typeof responseData === 'string' && responseData.length > 0) {
- return new Error(responseData);
- }
-
- if (response.statusText) {
- return new Error(response.statusText);
- }
-
- return responseData;
-}
-
/**
* Sentry utility to be used in place of `handleError` function of Remix v2
* Remix Docs: https://remix.run/docs/en/main/file-conventions/entry.server#handleerror
@@ -129,78 +98,7 @@ export function wrapHandleErrorWithSentry(
};
}
-/**
- * Captures an exception happened in the Remix server.
- *
- * @param err The error to capture.
- * @param name The name of the origin function.
- * @param request The request object.
- *
- * @returns A promise that resolves when the exception is captured.
- */
-export async function captureRemixServerException(err: unknown, name: string, request: Request): Promise {
- // Skip capturing if the thrown error is not a 5xx response
- // https://remix.run/docs/en/v1/api/conventions#throwing-responses-in-loaders
- if (IS_REMIX_V2 && isRouteErrorResponse(err) && err.status < 500) {
- return;
- }
-
- if (isResponse(err) && err.status < 500) {
- return;
- }
- // Skip capturing if the request is aborted as Remix docs suggest
- // Ref: https://remix.run/docs/en/main/file-conventions/entry.server#handleerror
- if (request.signal.aborted) {
- DEBUG_BUILD && logger.warn('Skipping capture of aborted request');
- return;
- }
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- let normalizedRequest: Record = request as unknown as any;
-
- try {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- normalizedRequest = normalizeRemixRequest(request as unknown as any);
- } catch (e) {
- DEBUG_BUILD && logger.warn('Failed to normalize Remix request');
- }
-
- const objectifiedErr = objectify(err);
-
- captureException(isResponse(objectifiedErr) ? await extractResponseError(objectifiedErr) : objectifiedErr, scope => {
- const activeSpan = getActiveSpan();
- const rootSpan = activeSpan && getRootSpan(activeSpan);
- const activeRootSpanName = rootSpan ? spanToJSON(rootSpan).description : undefined;
-
- scope.setSDKProcessingMetadata({
- request: {
- ...normalizedRequest,
- // When `route` is not defined, `RequestData` integration uses the full URL
- route: activeRootSpanName
- ? {
- path: activeRootSpanName,
- }
- : undefined,
- },
- });
-
- scope.addEventProcessor(event => {
- addExceptionMechanism(event, {
- type: 'instrument',
- handled: false,
- data: {
- function: name,
- },
- });
-
- return event;
- });
-
- return scope;
- });
-}
-
-function makeWrappedDocumentRequestFunction(remixVersion?: number) {
+function makeWrappedDocumentRequestFunction(autoInstrumentRemix?: boolean, remixVersion?: number) {
return function (origDocumentRequestFunction: HandleDocumentRequestFunction): HandleDocumentRequestFunction {
return async function (
this: unknown,
@@ -210,52 +108,52 @@ function makeWrappedDocumentRequestFunction(remixVersion?: number) {
context: EntryContext,
loadContext?: Record,
): Promise {
- const activeSpan = getActiveSpan();
- const rootSpan = activeSpan && getRootSpan(activeSpan);
-
- const name = rootSpan ? spanToJSON(rootSpan).description : undefined;
+ const documentRequestContext = {
+ request,
+ responseStatusCode,
+ responseHeaders,
+ context,
+ loadContext,
+ };
- return startSpan(
- {
- // If we don't have a root span, `onlyIfParent` will lead to the span not being created anyhow
- // So we don't need to care too much about the fallback name, it's just for typing purposes....
- name: name || '',
- onlyIfParent: true,
- attributes: {
- method: request.method,
- url: request.url,
- [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.remix',
- [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.remix.document_request',
- },
- },
- () => {
- return handleCallbackErrors(
- () => {
- return origDocumentRequestFunction.call(
- this,
- request,
- responseStatusCode,
- responseHeaders,
- context,
- loadContext,
- );
+ const isRemixV2 = FUTURE_FLAGS?.v2_errorBoundary || remixVersion === 2;
+
+ if (!autoInstrumentRemix) {
+ const activeSpan = getActiveSpan();
+ const rootSpan = activeSpan && getRootSpan(activeSpan);
+
+ const name = rootSpan ? spanToJSON(rootSpan).description : undefined;
+
+ return startSpan(
+ {
+ // If we don't have a root span, `onlyIfParent` will lead to the span not being created anyhow
+ // So we don't need to care too much about the fallback name, it's just for typing purposes....
+ name: name || '',
+ onlyIfParent: true,
+ attributes: {
+ method: request.method,
+ url: request.url,
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.remix',
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.remix.document_request',
},
- err => {
- const isRemixV1 = !FUTURE_FLAGS?.v2_errorBoundary && remixVersion !== 2;
-
- // This exists to capture the server-side rendering errors on Remix v1
- // On Remix v2, we capture SSR errors at `handleError`
- // We also skip primitives here, as we can't dedupe them, and also we don't expect any primitive SSR errors.
- if (isRemixV1 && !isPrimitive(err)) {
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- captureRemixServerException(err, 'documentRequest', request);
- }
-
- throw err;
- },
- );
- },
- );
+ },
+ () => {
+ return errorHandleDocumentRequestFunction.call(
+ this,
+ origDocumentRequestFunction,
+ documentRequestContext,
+ isRemixV2,
+ );
+ },
+ );
+ } else {
+ return errorHandleDocumentRequestFunction.call(
+ this,
+ origDocumentRequestFunction,
+ documentRequestContext,
+ isRemixV2,
+ );
+ }
};
};
}
@@ -265,82 +163,41 @@ function makeWrappedDataFunction(
id: string,
name: 'action' | 'loader',
remixVersion: number,
- manuallyInstrumented: boolean,
+ autoInstrumentRemix?: boolean,
): DataFunction {
return async function (this: unknown, args: DataFunctionArgs): Promise {
- if (args.context.__sentry_express_wrapped__ && !manuallyInstrumented) {
- return origFn.call(this, args);
- }
+ const isRemixV2 = FUTURE_FLAGS?.v2_errorBoundary || remixVersion === 2;
- return startSpan(
- {
- op: `function.remix.${name}`,
- name: id,
- attributes: {
- [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.remix',
- name,
- },
- },
- span => {
- return handleCallbackErrors(
- async () => {
- if (span) {
- const options = getClient()?.getOptions();
-
- // We only capture form data for `action` functions, when `sendDefaultPii` is enabled.
- if (name === 'action' && options?.sendDefaultPii) {
- try {
- // We clone the request for Remix be able to read the FormData later.
- const clonedRequest = args.request.clone();
-
- // This only will return the last name of multiple file uploads in a single FormData entry.
- // We can switch to `unstable_parseMultipartFormData` when it's stable.
- // https://remix.run/docs/en/main/utils/parse-multipart-form-data#unstable_parsemultipartformdata
- const formData = await clonedRequest.formData();
-
- formData.forEach((value, key) => {
- span.setAttribute(
- `remix.action_form_data.${key}`,
- typeof value === 'string' ? value : '[non-string value]',
- );
- });
- } catch (e) {
- DEBUG_BUILD && logger.warn('Failed to read FormData from request', e);
- }
- }
- }
-
- return origFn.call(this, args);
- },
- err => {
- const isRemixV2 = FUTURE_FLAGS?.v2_errorBoundary || remixVersion === 2;
-
- // On Remix v2, we capture all unexpected errors (except the `Route Error Response`s / Thrown Responses) in `handleError` function.
- // This is both for consistency and also avoid duplicates such as primitives like `string` or `number` being captured twice.
- // Remix v1 does not have a `handleError` function, so we capture all errors here.
- if (isRemixV2 ? isResponse(err) : true) {
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- captureRemixServerException(err, name, args.request);
- }
-
- throw err;
+ if (!autoInstrumentRemix) {
+ return startSpan(
+ {
+ op: `function.remix.${name}`,
+ name: id,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.remix',
+ name,
},
- );
- },
- );
+ },
+ (span: Span) => {
+ return errorHandleDataFunction.call(this, origFn, name, args, isRemixV2, span);
+ },
+ );
+ } else {
+ return errorHandleDataFunction.call(this, origFn, name, args, isRemixV2);
+ }
};
}
const makeWrappedAction =
- (id: string, remixVersion: number, manuallyInstrumented: boolean) =>
+ (id: string, remixVersion: number, autoInstrumentRemix?: boolean) =>
(origAction: DataFunction): DataFunction => {
- return makeWrappedDataFunction(origAction, id, 'action', remixVersion, manuallyInstrumented);
+ return makeWrappedDataFunction(origAction, id, 'action', remixVersion, autoInstrumentRemix);
};
const makeWrappedLoader =
- (id: string, remixVersion: number, manuallyInstrumented: boolean) =>
+ (id: string, remixVersion: number, autoInstrumentRemix?: boolean) =>
(origLoader: DataFunction): DataFunction => {
- return makeWrappedDataFunction(origLoader, id, 'loader', remixVersion, manuallyInstrumented);
+ return makeWrappedDataFunction(origLoader, id, 'loader', remixVersion, autoInstrumentRemix);
};
function getTraceAndBaggage(): {
@@ -409,88 +266,33 @@ function makeWrappedRootLoader(remixVersion: number) {
};
}
-/**
- * Creates routes from the server route manifest
- *
- * @param manifest
- * @param parentId
- */
-export function createRoutes(manifest: ServerRouteManifest, parentId?: string): ServerRoute[] {
- return Object.entries(manifest)
- .filter(([, route]) => route.parentId === parentId)
- .map(([id, route]) => ({
- ...route,
- children: createRoutes(manifest, id),
- }));
-}
-
-/**
- * Starts a new active span for the given request to be used by different `RequestHandler` wrappers.
- */
-export function startRequestHandlerSpan(
- {
- name,
- source,
- sentryTrace,
- baggage,
- method,
- }: {
- name: string;
- source: TransactionSource;
- sentryTrace: string;
- baggage: string;
- method: string;
- },
- callback: (span: Span) => T,
-): T {
- return continueTrace(
- {
- sentryTrace,
- baggage,
- },
- () => {
- return startSpan(
- {
- name,
- attributes: {
- [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.remix',
- [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
- [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
- method,
- },
- },
- callback,
- );
- },
- );
-}
-
-/**
- * Get transaction name from routes and url
- */
-export function getTransactionName(routes: ServerRoute[], url: URL): [string, TransactionSource] {
- const matches = matchServerRoutes(routes, url.pathname);
- const match = matches && getRequestMatch(url, matches);
- return match === null ? [url.pathname, 'url'] : [match.route.id || 'no-route-id', 'route'];
-}
-
-function wrapRequestHandler(origRequestHandler: RequestHandler, build: ServerBuild): RequestHandler {
- const routes = createRoutes(build.routes);
+function wrapRequestHandler(
+ origRequestHandler: RequestHandler,
+ build: ServerBuild | (() => ServerBuild | Promise),
+ autoInstrumentRemix: boolean,
+): RequestHandler {
+ let resolvedBuild: ServerBuild;
+ let routes: ServerRoute[];
+ let name: string;
+ let source: TransactionSource;
return async function (this: unknown, request: RemixRequest, loadContext?: AppLoadContext): Promise {
- // This means that the request handler of the adapter (ex: express) is already wrapped.
- // So we don't want to double wrap it.
- if (loadContext?.__sentry_express_wrapped__) {
- return origRequestHandler.call(this, request, loadContext);
- }
-
const upperCaseMethod = request.method.toUpperCase();
-
// We don't want to wrap OPTIONS and HEAD requests
if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') {
return origRequestHandler.call(this, request, loadContext);
}
+ if (!autoInstrumentRemix) {
+ if (typeof build === 'function') {
+ resolvedBuild = await build();
+ } else {
+ resolvedBuild = build;
+ }
+
+ routes = createRoutes(resolvedBuild.routes);
+ }
+
return withIsolationScope(async isolationScope => {
const options = getClient()?.getOptions();
@@ -502,10 +304,13 @@ function wrapRequestHandler(origRequestHandler: RequestHandler, build: ServerBui
DEBUG_BUILD && logger.warn('Failed to normalize Remix request');
}
- const url = new URL(request.url);
- const [name, source] = getTransactionName(routes, url);
+ if (!autoInstrumentRemix) {
+ const url = new URL(request.url);
+ [name, source] = getTransactionName(routes, url);
+
+ isolationScope.setTransactionName(name);
+ }
- isolationScope.setTransactionName(name);
isolationScope.setSDKProcessingMetadata({
request: {
...normalizedRequest,
@@ -519,37 +324,45 @@ function wrapRequestHandler(origRequestHandler: RequestHandler, build: ServerBui
return origRequestHandler.call(this, request, loadContext);
}
- return startRequestHandlerSpan(
+ return continueTrace(
{
- name,
- source,
sentryTrace: request.headers.get('sentry-trace') || '',
baggage: request.headers.get('baggage') || '',
- method: request.method,
},
- async span => {
- const res = (await origRequestHandler.call(this, request, loadContext)) as Response;
+ async () => {
+ if (!autoInstrumentRemix) {
+ return startSpan(
+ {
+ name,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.remix',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
+ method: request.method,
+ },
+ },
+ async span => {
+ const res = (await origRequestHandler.call(this, request, loadContext)) as Response;
- if (isResponse(res)) {
- setHttpStatus(span, res.status);
+ if (isResponse(res)) {
+ setHttpStatus(span, res.status);
+ }
+
+ return res;
+ },
+ );
}
- return res;
+ return (await origRequestHandler.call(this, request, loadContext)) as Response;
},
);
});
};
}
-/**
- * Instruments `remix` ServerBuild for performance tracing and error tracking.
- */
-export function instrumentBuild(build: ServerBuild, manuallyInstrumented: boolean = false): ServerBuild {
+function instrumentBuildCallback(build: ServerBuild, autoInstrumentRemix: boolean): ServerBuild {
const routes: ServerRouteManifest = {};
-
const remixVersion = getRemixVersionFromBuild(build);
- IS_REMIX_V2 = remixVersion === 2;
-
const wrappedEntry = { ...build.entry, module: { ...build.entry.module } };
// Not keeping boolean flags like it's done for `requestHandler` functions,
@@ -558,7 +371,7 @@ export function instrumentBuild(build: ServerBuild, manuallyInstrumented: boolea
// We should be able to wrap them, as they may not be wrapped before.
const defaultExport = wrappedEntry.module.default as undefined | WrappedFunction;
if (defaultExport && !defaultExport.__sentry_original__) {
- fill(wrappedEntry.module, 'default', makeWrappedDocumentRequestFunction(remixVersion));
+ fill(wrappedEntry.module, 'default', makeWrappedDocumentRequestFunction(autoInstrumentRemix, remixVersion));
}
for (const [id, route] of Object.entries(build.routes)) {
@@ -566,12 +379,12 @@ export function instrumentBuild(build: ServerBuild, manuallyInstrumented: boolea
const routeAction = wrappedRoute.module.action as undefined | WrappedFunction;
if (routeAction && !routeAction.__sentry_original__) {
- fill(wrappedRoute.module, 'action', makeWrappedAction(id, remixVersion, manuallyInstrumented));
+ fill(wrappedRoute.module, 'action', makeWrappedAction(id, remixVersion, autoInstrumentRemix));
}
const routeLoader = wrappedRoute.module.loader as undefined | WrappedFunction;
if (routeLoader && !routeLoader.__sentry_original__) {
- fill(wrappedRoute.module, 'loader', makeWrappedLoader(id, remixVersion, manuallyInstrumented));
+ fill(wrappedRoute.module, 'loader', makeWrappedLoader(id, remixVersion, autoInstrumentRemix));
}
// Entry module should have a loader function to provide `sentry-trace` and `baggage`
@@ -591,23 +404,57 @@ export function instrumentBuild(build: ServerBuild, manuallyInstrumented: boolea
return { ...build, routes, entry: wrappedEntry };
}
-function makeWrappedCreateRequestHandler(
- origCreateRequestHandler: CreateRequestHandlerFunction,
-): CreateRequestHandlerFunction {
- return function (this: unknown, build: ServerBuild, ...args: unknown[]): RequestHandler {
+/**
+ * Instruments `remix` ServerBuild for performance tracing and error tracking.
+ */
+export function instrumentBuild(
+ build: ServerBuild | (() => ServerBuild | Promise),
+ options: RemixOptions,
+): ServerBuild | (() => ServerBuild | Promise) {
+ const autoInstrumentRemix = options?.autoInstrumentRemix || false;
+
+ if (typeof build === 'function') {
+ return function () {
+ const resolvedBuild = build();
+
+ if (resolvedBuild instanceof Promise) {
+ return resolvedBuild.then(build => {
+ FUTURE_FLAGS = getFutureFlagsServer(build);
+
+ return instrumentBuildCallback(build, autoInstrumentRemix);
+ });
+ } else {
+ FUTURE_FLAGS = getFutureFlagsServer(resolvedBuild);
+
+ return instrumentBuildCallback(resolvedBuild, autoInstrumentRemix);
+ }
+ };
+ } else {
FUTURE_FLAGS = getFutureFlagsServer(build);
- const newBuild = instrumentBuild(build, false);
- const requestHandler = origCreateRequestHandler.call(this, newBuild, ...args);
- return wrapRequestHandler(requestHandler, newBuild);
- };
+ return instrumentBuildCallback(build, autoInstrumentRemix);
+ }
}
+const makeWrappedCreateRequestHandler = (options: RemixOptions) =>
+ function (origCreateRequestHandler: CreateRequestHandlerFunction): CreateRequestHandlerFunction {
+ return function (
+ this: unknown,
+ build: ServerBuild | (() => Promise),
+ ...args: unknown[]
+ ): RequestHandler {
+ const newBuild = instrumentBuild(build, options);
+ const requestHandler = origCreateRequestHandler.call(this, newBuild, ...args);
+
+ return wrapRequestHandler(requestHandler, newBuild, options.autoInstrumentRemix || false);
+ };
+ };
+
/**
* Monkey-patch Remix's `createRequestHandler` from `@remix-run/server-runtime`
* which Remix Adapters (https://remix.run/docs/en/v1/api/remix) use underneath.
*/
-export function instrumentServer(): void {
+export function instrumentServer(options: RemixOptions): void {
const pkg = loadModule<{
createRequestHandler: CreateRequestHandlerFunction;
}>('@remix-run/server-runtime');
@@ -618,5 +465,5 @@ export function instrumentServer(): void {
return;
}
- fill(pkg, 'createRequestHandler', makeWrappedCreateRequestHandler);
+ fill(pkg, 'createRequestHandler', makeWrappedCreateRequestHandler(options));
}
diff --git a/packages/remix/src/utils/integrations/http.ts b/packages/remix/src/utils/integrations/http.ts
new file mode 100644
index 000000000000..7c4b80f44fe7
--- /dev/null
+++ b/packages/remix/src/utils/integrations/http.ts
@@ -0,0 +1,42 @@
+// This integration is ported from the Next.JS SDK.
+import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
+import { httpIntegration as originalHttpIntegration } from '@sentry/node';
+import type { IntegrationFn } from '@sentry/types';
+
+class RemixHttpIntegration extends HttpInstrumentation {
+ // Instead of the default behavior, we just don't do any wrapping for incoming requests
+ protected _getPatchIncomingRequestFunction(_component: 'http' | 'https') {
+ return (
+ original: (event: string, ...args: unknown[]) => boolean,
+ ): ((this: unknown, event: string, ...args: unknown[]) => boolean) => {
+ return function incomingRequest(this: unknown, event: string, ...args: unknown[]): boolean {
+ return original.apply(this, [event, ...args]);
+ };
+ };
+ }
+}
+
+interface HttpOptions {
+ /**
+ * Whether breadcrumbs should be recorded for requests.
+ * Defaults to true
+ */
+ breadcrumbs?: boolean;
+
+ /**
+ * Do not capture spans or breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`.
+ * This controls both span & breadcrumb creation - spans will be non recording if tracing is disabled.
+ */
+ ignoreOutgoingRequests?: (url: string) => boolean;
+}
+
+/**
+ * The http integration instruments Node's internal http and https modules.
+ * It creates breadcrumbs and spans for outgoing HTTP requests which will be attached to the currently active span.
+ */
+export const httpIntegration = ((options: HttpOptions = {}) => {
+ return originalHttpIntegration({
+ ...options,
+ _instrumentation: RemixHttpIntegration,
+ });
+}) satisfies IntegrationFn;
diff --git a/packages/remix/src/utils/integrations/opentelemetry.ts b/packages/remix/src/utils/integrations/opentelemetry.ts
new file mode 100644
index 000000000000..24648bb8db22
--- /dev/null
+++ b/packages/remix/src/utils/integrations/opentelemetry.ts
@@ -0,0 +1,62 @@
+import { RemixInstrumentation } from 'opentelemetry-instrumentation-remix';
+
+import { defineIntegration } from '@sentry/core';
+import { SEMANTIC_ATTRIBUTE_SENTRY_OP, generateInstrumentOnce, getClient, spanToJSON } from '@sentry/node';
+import type { Client, IntegrationFn, Span } from '@sentry/types';
+import type { RemixOptions } from '../remixOptions';
+
+const INTEGRATION_NAME = 'Remix';
+
+const instrumentRemix = generateInstrumentOnce(
+ INTEGRATION_NAME,
+ (_options?: RemixOptions) =>
+ new RemixInstrumentation({
+ actionFormDataAttributes: _options?.sendDefaultPii ? _options?.captureActionFormDataKeys : undefined,
+ }),
+);
+
+const _remixIntegration = (() => {
+ return {
+ name: 'Remix',
+ setupOnce() {
+ const client = getClient();
+ const options = client?.getOptions();
+
+ instrumentRemix(options);
+ },
+
+ setup(client: Client) {
+ client.on('spanStart', span => {
+ addRemixSpanAttributes(span);
+ });
+ },
+ };
+}) satisfies IntegrationFn;
+
+const addRemixSpanAttributes = (span: Span): void => {
+ const attributes = spanToJSON(span).data || {};
+
+ // this is one of: loader, action, requestHandler
+ const type = attributes['code.function'];
+
+ // If this is already set, or we have no remix span, no need to process again...
+ if (attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] || !type) {
+ return;
+ }
+
+ // `requestHandler` span from `opentelemetry-instrumentation-remix` is the main server span.
+ // It should be marked as the `http.server` operation.
+ // The incoming requests are skipped by the custom `RemixHttpIntegration` package.
+ if (type === 'requestHandler') {
+ span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.server');
+ return;
+ }
+
+ // All other spans are marked as `remix` operations with their specific type [loader, action]
+ span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, `${type}.remix`);
+};
+
+/**
+ * Instrumentation for aws-sdk package
+ */
+export const remixIntegration = defineIntegration(_remixIntegration);
diff --git a/packages/remix/src/utils/remixOptions.ts b/packages/remix/src/utils/remixOptions.ts
index 4a1fe13e18e1..4f73eca92ff3 100644
--- a/packages/remix/src/utils/remixOptions.ts
+++ b/packages/remix/src/utils/remixOptions.ts
@@ -2,4 +2,7 @@ import type { NodeOptions } from '@sentry/node';
import type { BrowserOptions } from '@sentry/react';
import type { Options } from '@sentry/types';
-export type RemixOptions = Options | BrowserOptions | NodeOptions;
+export type RemixOptions = (Options | BrowserOptions | NodeOptions) & {
+ captureActionFormDataKeys?: Record;
+ autoInstrumentRemix?: boolean;
+};
diff --git a/packages/remix/src/utils/serverAdapters/express.ts b/packages/remix/src/utils/serverAdapters/express.ts
deleted file mode 100644
index d4caed091015..000000000000
--- a/packages/remix/src/utils/serverAdapters/express.ts
+++ /dev/null
@@ -1,226 +0,0 @@
-import { getClient, hasTracingEnabled, setHttpStatus, withIsolationScope } from '@sentry/core';
-import { flush } from '@sentry/node';
-import type { Span, TransactionSource } from '@sentry/types';
-import { extractRequestData, fill, isString, logger } from '@sentry/utils';
-
-import { DEBUG_BUILD } from '../debug-build';
-import { createRoutes, getTransactionName, instrumentBuild, startRequestHandlerSpan } from '../instrumentServer';
-import type {
- AppLoadContext,
- ExpressCreateRequestHandler,
- ExpressCreateRequestHandlerOptions,
- ExpressNextFunction,
- ExpressRequest,
- ExpressRequestHandler,
- ExpressResponse,
- GetLoadContextFunction,
- ServerBuild,
- ServerRoute,
-} from '../vendor/types';
-
-function wrapExpressRequestHandler(
- origRequestHandler: ExpressRequestHandler,
- build: ServerBuild | (() => Promise | ServerBuild),
-): ExpressRequestHandler {
- let routes: ServerRoute[];
-
- return async function (
- this: unknown,
- req: ExpressRequest,
- res: ExpressResponse,
- next: ExpressNextFunction,
- ): Promise {
- await withIsolationScope(async isolationScope => {
- // eslint-disable-next-line @typescript-eslint/unbound-method
- res.end = wrapEndMethod(res.end);
-
- const request = extractRequestData(req);
- const options = getClient()?.getOptions();
-
- isolationScope.setSDKProcessingMetadata({ request });
-
- if (!options || !hasTracingEnabled(options) || !request.url || !request.method) {
- return origRequestHandler.call(this, req, res, next);
- }
-
- const url = new URL(request.url);
-
- // This is only meant to be used on development servers, so we don't need to worry about performance here
- if (build && typeof build === 'function') {
- const resolvedBuild = build();
-
- if (resolvedBuild instanceof Promise) {
- return resolvedBuild.then(resolved => {
- routes = createRoutes(resolved.routes);
-
- const [name, source] = getTransactionName(routes, url);
- isolationScope.setTransactionName(name);
-
- startRequestHandlerTransaction.call(this, origRequestHandler, req, res, next, name, source);
- });
- } else {
- routes = createRoutes(resolvedBuild.routes);
- }
- } else {
- routes = createRoutes(build.routes);
- }
-
- const [name, source] = getTransactionName(routes, url);
- isolationScope.setTransactionName(name);
-
- return startRequestHandlerTransaction.call(this, origRequestHandler, req, res, next, name, source);
- });
- };
-}
-
-function startRequestHandlerTransaction(
- this: unknown,
- origRequestHandler: ExpressRequestHandler,
- req: ExpressRequest,
- res: ExpressResponse,
- next: ExpressNextFunction,
- name: string,
- source: TransactionSource,
-): unknown {
- return startRequestHandlerSpan(
- {
- name,
- source,
- sentryTrace: (req.headers && isString(req.headers['sentry-trace']) && req.headers['sentry-trace']) || '',
- baggage: (req.headers && isString(req.headers.baggage) && req.headers.baggage) || '',
- method: req.method,
- },
- span => {
- // save a link to the transaction on the response, so that even if there's an error (landing us outside of
- // the domain), we can still finish it (albeit possibly missing some scope data)
- (res as AugmentedExpressResponse).__sentrySpan = span;
- return origRequestHandler.call(this, req, res, next);
- },
- );
-}
-
-function wrapGetLoadContext(origGetLoadContext: () => AppLoadContext): GetLoadContextFunction {
- return function (this: unknown, req: ExpressRequest, res: ExpressResponse): AppLoadContext {
- const loadContext = (origGetLoadContext.call(this, req, res) || {}) as AppLoadContext;
-
- loadContext['__sentry_express_wrapped__'] = true;
-
- return loadContext;
- };
-}
-
-// wrap build function which returns either a Promise or the build itself
-// This is currently only required for Vite development mode with HMR
-function wrapBuildFn(origBuildFn: () => Promise | ServerBuild): () => Promise | ServerBuild {
- return async function (this: unknown, ...args: unknown[]) {
- const resolvedBuild = origBuildFn.call(this, ...args);
-
- if (resolvedBuild instanceof Promise) {
- return resolvedBuild.then(resolved => {
- return instrumentBuild(resolved, true);
- });
- }
-
- return instrumentBuild(resolvedBuild, true);
- };
-}
-
-// A wrapper around build if it's a Promise or a function that returns a Promise that calls instrumentServer on the resolved value
-// This is currently only required for Vite development mode with HMR
-function wrapBuild(
- build: ServerBuild | (() => Promise | ServerBuild),
-): ServerBuild | (() => Promise | ServerBuild) {
- if (typeof build === 'function') {
- return wrapBuildFn(build);
- }
-
- return instrumentBuild(build, true);
-}
-
-/**
- * Instruments `createRequestHandler` from `@remix-run/express`
- */
-export function wrapExpressCreateRequestHandler(
- origCreateRequestHandler: ExpressCreateRequestHandler,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
-): (options: any) => ExpressRequestHandler {
- return function (this: unknown, options: ExpressCreateRequestHandlerOptions): ExpressRequestHandler {
- if (!('getLoadContext' in options)) {
- options['getLoadContext'] = () => ({});
- }
-
- fill(options, 'getLoadContext', wrapGetLoadContext);
-
- const newBuild = wrapBuild(options.build);
- const requestHandler = origCreateRequestHandler.call(this, {
- ...options,
- build: newBuild,
- });
-
- return wrapExpressRequestHandler(requestHandler, newBuild);
- };
-}
-
-export type AugmentedExpressResponse = ExpressResponse & {
- __sentrySpan?: Span;
-};
-
-type ResponseEndMethod = AugmentedExpressResponse['end'];
-type WrappedResponseEndMethod = AugmentedExpressResponse['end'];
-
-/**
- * Wrap `res.end()` so that it closes the transaction and flushes events before letting the request finish.
- *
- * Note: This wraps a sync method with an async method. While in general that's not a great idea in terms of keeping
- * things in the right order, in this case it's safe, because the native `.end()` actually *is* async, and its run
- * actually *is* awaited, just manually so (which reflects the fact that the core of the request/response code in Node
- * by far predates the introduction of `async`/`await`). When `.end()` is done, it emits the `prefinish` event, and
- * only once that fires does request processing continue. See
- * https://github.com/nodejs/node/commit/7c9b607048f13741173d397795bac37707405ba7.
- *
- * @param origEnd The original `res.end()` method
- * @returns The wrapped version
- */
-function wrapEndMethod(origEnd: ResponseEndMethod): WrappedResponseEndMethod {
- return async function newEnd(this: AugmentedExpressResponse, ...args: unknown[]) {
- await finishSentryProcessing(this);
-
- return origEnd.call(this, ...args);
- } as unknown as WrappedResponseEndMethod;
-}
-
-/**
- * Close the open transaction (if any) and flush events to Sentry.
- *
- * @param res The outgoing response for this request, on which the transaction is stored
- */
-async function finishSentryProcessing(res: AugmentedExpressResponse): Promise {
- const { __sentrySpan: span } = res;
-
- if (span) {
- setHttpStatus(span, res.statusCode);
-
- // Push `transaction.finish` to the next event loop so open spans have a better chance of finishing before the
- // transaction closes, and make sure to wait until that's done before flushing events
- await new Promise(resolve => {
- setImmediate(() => {
- // Double checking whether the span is not already finished,
- // OpenTelemetry gives error if we try to end a finished span
- if (span.isRecording()) {
- span.end();
- }
- resolve();
- });
- });
- }
-
- // Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda
- // ends. If there was an error, rethrow it so that the normal exception-handling mechanisms can apply.
- try {
- DEBUG_BUILD && logger.log('Flushing events...');
- await flush(2000);
- DEBUG_BUILD && logger.log('Done flushing events');
- } catch (e) {
- DEBUG_BUILD && logger.log('Error while flushing events:\n', e);
- }
-}
diff --git a/packages/remix/src/utils/utils.ts b/packages/remix/src/utils/utils.ts
new file mode 100644
index 000000000000..fed9e721e013
--- /dev/null
+++ b/packages/remix/src/utils/utils.ts
@@ -0,0 +1,51 @@
+import type { DataFunctionArgs } from '@remix-run/node';
+import type { Span, TransactionSource } from '@sentry/types';
+import { logger } from '@sentry/utils';
+import { DEBUG_BUILD } from './debug-build';
+import { getRequestMatch, matchServerRoutes } from './vendor/response';
+import type { ServerRoute, ServerRouteManifest } from './vendor/types';
+
+/**
+ *
+ */
+export async function storeFormDataKeys(args: DataFunctionArgs, span: Span): Promise {
+ try {
+ // We clone the request for Remix be able to read the FormData later.
+ const clonedRequest = args.request.clone();
+
+ // This only will return the last name of multiple file uploads in a single FormData entry.
+ // We can switch to `unstable_parseMultipartFormData` when it's stable.
+ // https://remix.run/docs/en/main/utils/parse-multipart-form-data#unstable_parsemultipartformdata
+ const formData = await clonedRequest.formData();
+
+ formData.forEach((value, key) => {
+ span.setAttribute(`remix.action_form_data.${key}`, typeof value === 'string' ? value : '[non-string value]');
+ });
+ } catch (e) {
+ DEBUG_BUILD && logger.warn('Failed to read FormData from request', e);
+ }
+}
+
+/**
+ * Get transaction name from routes and url
+ */
+export function getTransactionName(routes: ServerRoute[], url: URL): [string, TransactionSource] {
+ const matches = matchServerRoutes(routes, url.pathname);
+ const match = matches && getRequestMatch(url, matches);
+ return match === null ? [url.pathname, 'url'] : [match.route.id || 'no-route-id', 'route'];
+}
+
+/**
+ * Creates routes from the server route manifest
+ *
+ * @param manifest
+ * @param parentId
+ */
+export function createRoutes(manifest: ServerRouteManifest, parentId?: string): ServerRoute[] {
+ return Object.entries(manifest)
+ .filter(([, route]) => route.parentId === parentId)
+ .map(([id, route]) => ({
+ ...route,
+ children: createRoutes(manifest, id),
+ }));
+}
diff --git a/packages/remix/test/integration/app_v1/entry.server.tsx b/packages/remix/test/integration/app_v1/entry.server.tsx
index d4ad53d80aec..9ecf5f467588 100644
--- a/packages/remix/test/integration/app_v1/entry.server.tsx
+++ b/packages/remix/test/integration/app_v1/entry.server.tsx
@@ -1,13 +1,6 @@
-// it is important this is first!
-import * as Sentry from '@sentry/remix';
-
-Sentry.init({
- dsn: 'https://public@dsn.ingest.sentry.io/1337',
- tracesSampleRate: 1,
- tracePropagationTargets: ['example.org'],
- // Disabling to test series of envelopes deterministically.
- autoSessionTracking: false,
-});
+if (process.env.USE_OTEL !== '1') {
+ require('../instrument.server.cjs');
+}
import type { EntryContext } from '@remix-run/node';
import { RemixServer } from '@remix-run/react';
diff --git a/packages/remix/test/integration/app_v2/entry.server.tsx b/packages/remix/test/integration/app_v2/entry.server.tsx
index 04d5ef52f6c2..968ec19a5f59 100644
--- a/packages/remix/test/integration/app_v2/entry.server.tsx
+++ b/packages/remix/test/integration/app_v2/entry.server.tsx
@@ -1,13 +1,8 @@
-// it is important this is first!
-import * as Sentry from '@sentry/remix';
+if (process.env.USE_OTEL !== '1') {
+ require('../instrument.server.cjs');
+}
-Sentry.init({
- dsn: 'https://public@dsn.ingest.sentry.io/1337',
- tracesSampleRate: 1,
- tracePropagationTargets: ['example.org'],
- // Disabling to test series of envelopes deterministically.
- autoSessionTracking: false,
-});
+import * as Sentry from '@sentry/remix';
import type { EntryContext } from '@remix-run/node';
import { RemixServer } from '@remix-run/react';
diff --git a/packages/remix/test/integration/app_v2/root.tsx b/packages/remix/test/integration/app_v2/root.tsx
index 15b78b8a6325..399136e04089 100644
--- a/packages/remix/test/integration/app_v2/root.tsx
+++ b/packages/remix/test/integration/app_v2/root.tsx
@@ -8,7 +8,11 @@ export const ErrorBoundary: V2_ErrorBoundaryComponent = () => {
captureRemixErrorBoundaryError(error);
- return error
;
+ return (
+
+
+
+ );
};
export const meta: V2_MetaFunction = ({ data }) => [
diff --git a/packages/remix/test/integration/instrument.server.cjs b/packages/remix/test/integration/instrument.server.cjs
new file mode 100644
index 000000000000..5e1d9e31ab46
--- /dev/null
+++ b/packages/remix/test/integration/instrument.server.cjs
@@ -0,0 +1,10 @@
+const Sentry = require('@sentry/remix');
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ tracesSampleRate: 1,
+ tracePropagationTargets: ['example.org'],
+ // Disabling to test series of envelopes deterministically.
+ autoSessionTracking: false,
+ autoInstrumentRemix: process.env.USE_OTEL === '1',
+});
diff --git a/packages/remix/test/integration/jest.config.js b/packages/remix/test/integration/jest.config.js
deleted file mode 100644
index 82c2059da915..000000000000
--- a/packages/remix/test/integration/jest.config.js
+++ /dev/null
@@ -1,8 +0,0 @@
-const baseConfig = require('../../jest.config.js');
-
-module.exports = {
- ...baseConfig,
- testMatch: [`${__dirname}/test/server/**/*.test.ts`],
- testPathIgnorePatterns: [`${__dirname}/test/client`],
- forceExit: true,
-};
diff --git a/packages/remix/test/integration/package.json b/packages/remix/test/integration/package.json
index 63560ec64e8b..4f954e1b4eea 100644
--- a/packages/remix/test/integration/package.json
+++ b/packages/remix/test/integration/package.json
@@ -4,7 +4,7 @@
"scripts": {
"build": "remix build",
"dev": "remix dev",
- "start": "remix-serve build"
+ "start":"remix-serve build"
},
"dependencies": {
"@remix-run/express": "1.17.0",
diff --git a/packages/remix/test/integration/test/client/errorboundary.test.ts b/packages/remix/test/integration/test/client/errorboundary.test.ts
index 6d249d563a41..8fdfad307179 100644
--- a/packages/remix/test/integration/test/client/errorboundary.test.ts
+++ b/packages/remix/test/integration/test/client/errorboundary.test.ts
@@ -42,4 +42,9 @@ test('should capture React component errors.', async ({ page }) => {
expect(errorEnvelope.transaction).toBe(
useV2 ? 'routes/error-boundary-capture.$id' : 'routes/error-boundary-capture/$id',
);
+
+ if (useV2) {
+ // The error boundary should be rendered
+ expect(await page.textContent('#error-header')).toBe('ErrorBoundary Error');
+ }
});
diff --git a/packages/remix/test/integration/test/client/meta-tags.test.ts b/packages/remix/test/integration/test/client/meta-tags.test.ts
index 34bf6fec1e01..db919d950a2c 100644
--- a/packages/remix/test/integration/test/client/meta-tags.test.ts
+++ b/packages/remix/test/integration/test/client/meta-tags.test.ts
@@ -42,10 +42,10 @@ test('should send transactions with corresponding `sentry-trace` and `baggage` i
const sentryBaggageContent = await sentryBaggageTag?.getAttribute('content');
expect(sentryTraceContent).toContain(
- `${envelope.contexts?.trace.trace_id}-${envelope.contexts?.trace.parent_span_id}-`,
+ `${envelope.contexts?.trace?.trace_id}-${envelope.contexts?.trace?.parent_span_id}-`,
);
- expect(sentryBaggageContent).toContain(envelope.contexts?.trace.trace_id);
+ expect(sentryBaggageContent).toContain(envelope.contexts?.trace?.trace_id);
});
test('should send transactions with corresponding `sentry-trace` and `baggage` inside a parameterized route', async ({
@@ -59,8 +59,8 @@ test('should send transactions with corresponding `sentry-trace` and `baggage` i
const sentryBaggageContent = await sentryBaggageTag?.getAttribute('content');
expect(sentryTraceContent).toContain(
- `${envelope.contexts?.trace.trace_id}-${envelope.contexts?.trace.parent_span_id}-`,
+ `${envelope.contexts?.trace?.trace_id}-${envelope.contexts?.trace?.parent_span_id}-`,
);
- expect(sentryBaggageContent).toContain(envelope.contexts?.trace.trace_id);
+ expect(sentryBaggageContent).toContain(envelope.contexts?.trace?.trace_id);
});
diff --git a/packages/remix/test/integration/test/server/action.test.ts b/packages/remix/test/integration/test/server/instrumentation-legacy/action.test.ts
similarity index 94%
rename from packages/remix/test/integration/test/server/action.test.ts
rename to packages/remix/test/integration/test/server/instrumentation-legacy/action.test.ts
index 4755523262dc..572b0a011fb2 100644
--- a/packages/remix/test/integration/test/server/action.test.ts
+++ b/packages/remix/test/integration/test/server/instrumentation-legacy/action.test.ts
@@ -1,15 +1,17 @@
-import { RemixTestEnv, assertSentryEvent, assertSentryTransaction } from './utils/helpers';
+import { describe, it } from 'vitest';
+import { RemixTestEnv, assertSentryEvent, assertSentryTransaction } from '../utils/helpers';
const useV2 = process.env.REMIX_VERSION === '2';
-jest.spyOn(console, 'error').mockImplementation();
-
-// Repeat tests for each adapter
-describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', adapter => {
+describe('Remix API Actions', () => {
it('correctly instruments a parameterized Remix API action', async () => {
- const env = await RemixTestEnv.init(adapter);
+ const env = await RemixTestEnv.init();
const url = `${env.url}/action-json-response/123123`;
- const envelope = await env.getEnvelopeRequest({ url, method: 'post', envelopeType: 'transaction' });
+ const envelope = await env.getEnvelopeRequest({
+ url,
+ method: 'post',
+ envelopeType: 'transaction',
+ });
const transaction = envelope[2];
assertSentryTransaction(transaction, {
@@ -45,7 +47,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
});
it('reports an error thrown from the action', async () => {
- const env = await RemixTestEnv.init(adapter);
+ const env = await RemixTestEnv.init();
const url = `${env.url}/action-json-response/-1`;
const envelopes = await env.getMultipleEnvelopeRequest({
@@ -91,7 +93,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
});
it('includes request data in transaction and error events', async () => {
- const env = await RemixTestEnv.init(adapter);
+ const env = await RemixTestEnv.init();
const url = `${env.url}/action-json-response/-1`;
const envelopes = await env.getMultipleEnvelopeRequest({
@@ -140,7 +142,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
});
it('handles an error-throwing redirection target', async () => {
- const env = await RemixTestEnv.init(adapter);
+ const env = await RemixTestEnv.init();
const url = `${env.url}/action-json-response/-2`;
const envelopes = await env.getMultipleEnvelopeRequest({
@@ -152,7 +154,6 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
const [transaction_1, transaction_2] = envelopes.filter(envelope => envelope[1].type === 'transaction');
const [event] = envelopes.filter(envelope => envelope[1].type === 'event');
-
assertSentryTransaction(transaction_1[2], {
contexts: {
trace: {
@@ -203,7 +204,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
});
it('handles a thrown `json()` error response with `statusText`', async () => {
- const env = await RemixTestEnv.init(adapter);
+ const env = await RemixTestEnv.init();
const url = `${env.url}/action-json-response/-3`;
const envelopes = await env.getMultipleEnvelopeRequest({
@@ -252,7 +253,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
});
it('handles a thrown `json()` error response without `statusText`', async () => {
- const env = await RemixTestEnv.init(adapter);
+ const env = await RemixTestEnv.init();
const url = `${env.url}/action-json-response/-4`;
const envelopes = await env.getMultipleEnvelopeRequest({
@@ -301,7 +302,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
});
it('handles a thrown `json()` error response with string body', async () => {
- const env = await RemixTestEnv.init(adapter);
+ const env = await RemixTestEnv.init();
const url = `${env.url}/action-json-response/-5`;
const envelopes = await env.getMultipleEnvelopeRequest({
@@ -350,7 +351,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
});
it('handles a thrown `json()` error response with an empty object', async () => {
- const env = await RemixTestEnv.init(adapter);
+ const env = await RemixTestEnv.init();
const url = `${env.url}/action-json-response/-6`;
const envelopes = await env.getMultipleEnvelopeRequest({
@@ -399,7 +400,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
});
it('handles thrown string (primitive) from an action', async () => {
- const env = await RemixTestEnv.init(adapter);
+ const env = await RemixTestEnv.init();
const url = `${env.url}/server-side-unexpected-errors/-1`;
const envelopes = await env.getMultipleEnvelopeRequest({
@@ -448,7 +449,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
});
it('handles thrown object from an action', async () => {
- const env = await RemixTestEnv.init(adapter);
+ const env = await RemixTestEnv.init();
const url = `${env.url}/server-side-unexpected-errors/-2`;
const envelopes = await env.getMultipleEnvelopeRequest({
diff --git a/packages/remix/test/integration/test/server/loader.test.ts b/packages/remix/test/integration/test/server/instrumentation-legacy/loader.test.ts
similarity index 93%
rename from packages/remix/test/integration/test/server/loader.test.ts
rename to packages/remix/test/integration/test/server/instrumentation-legacy/loader.test.ts
index da046d02f7e5..a90dda8b60bd 100644
--- a/packages/remix/test/integration/test/server/loader.test.ts
+++ b/packages/remix/test/integration/test/server/instrumentation-legacy/loader.test.ts
@@ -1,14 +1,12 @@
import { Event } from '@sentry/types';
-import { RemixTestEnv, assertSentryEvent, assertSentryTransaction } from './utils/helpers';
+import { describe, expect, it } from 'vitest';
+import { RemixTestEnv, assertSentryEvent, assertSentryTransaction } from '../utils/helpers';
const useV2 = process.env.REMIX_VERSION === '2';
-jest.spyOn(console, 'error').mockImplementation();
-
-// Repeat tests for each adapter
-describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', adapter => {
+describe('Remix API Loaders', () => {
it('reports an error thrown from the loader', async () => {
- const env = await RemixTestEnv.init(adapter);
+ const env = await RemixTestEnv.init();
const url = `${env.url}/loader-json-response/-2`;
const envelopes = await env.getMultipleEnvelopeRequest({ url, count: 2, envelopeType: ['transaction', 'event'] });
@@ -49,7 +47,7 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada
});
it('reports a thrown error response the loader', async () => {
- const env = await RemixTestEnv.init(adapter);
+ const env = await RemixTestEnv.init();
const url = `${env.url}/loader-throw-response/-1`;
// We also wait for the transaction, even though we don't care about it for this test
@@ -92,7 +90,7 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada
});
it('correctly instruments a parameterized Remix API loader', async () => {
- const env = await RemixTestEnv.init(adapter);
+ const env = await RemixTestEnv.init();
const url = `${env.url}/loader-json-response/123123`;
const envelope = await env.getEnvelopeRequest({ url, envelopeType: 'transaction' });
const transaction = envelope[2];
@@ -120,7 +118,7 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada
});
it('handles an error-throwing redirection target', async () => {
- const env = await RemixTestEnv.init(adapter);
+ const env = await RemixTestEnv.init();
const url = `${env.url}/loader-json-response/-1`;
const envelopes = await env.getMultipleEnvelopeRequest({
@@ -182,7 +180,7 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada
});
it('makes sure scope does not bleed between requests', async () => {
- const env = await RemixTestEnv.init(adapter);
+ const env = await RemixTestEnv.init();
const envelopes = await Promise.all([
env.getEnvelopeRequest({ url: `${env.url}/scope-bleed/1`, endServer: false, envelopeType: 'transaction' }),
@@ -205,7 +203,7 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada
});
it('continues transaction from sentry-trace header and baggage', async () => {
- const env = await RemixTestEnv.init(adapter);
+ const env = await RemixTestEnv.init();
const url = `${env.url}/loader-json-response/3`;
// send sentry-trace and baggage headers to loader
@@ -232,7 +230,7 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada
});
it('correctly instruments a deferred loader', async () => {
- const env = await RemixTestEnv.init(adapter);
+ const env = await RemixTestEnv.init();
const url = `${env.url}/loader-defer-response/123123`;
const envelope = await env.getEnvelopeRequest({ url, envelopeType: 'transaction' });
const transaction = envelope[2];
@@ -275,7 +273,7 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada
});
it('does not capture thrown redirect responses', async () => {
- const env = await RemixTestEnv.init(adapter);
+ const env = await RemixTestEnv.init();
const url = `${env.url}/throw-redirect`;
const envelopesCount = await env.countEnvelopes({
diff --git a/packages/remix/test/integration/test/server/ssr.test.ts b/packages/remix/test/integration/test/server/instrumentation-legacy/ssr.test.ts
similarity index 92%
rename from packages/remix/test/integration/test/server/ssr.test.ts
rename to packages/remix/test/integration/test/server/instrumentation-legacy/ssr.test.ts
index 39c49a0d2957..8400b9f9cff2 100644
--- a/packages/remix/test/integration/test/server/ssr.test.ts
+++ b/packages/remix/test/integration/test/server/instrumentation-legacy/ssr.test.ts
@@ -1,15 +1,15 @@
-import { RemixTestEnv, assertSentryEvent, assertSentryTransaction } from './utils/helpers';
+import { describe, expect, it } from 'vitest';
+import { RemixTestEnv, assertSentryEvent, assertSentryTransaction } from '../utils/helpers';
const useV2 = process.env.REMIX_VERSION === '2';
describe('Server Side Rendering', () => {
it('correctly reports a server side rendering error', async () => {
- const env = await RemixTestEnv.init('builtin');
+ const env = await RemixTestEnv.init();
const url = `${env.url}/ssr-error`;
const envelopes = await env.getMultipleEnvelopeRequest({ url, count: 2, envelopeType: ['transaction', 'event'] });
const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction');
const [event] = envelopes.filter(envelope => envelope[1].type === 'event');
-
assertSentryTransaction(transaction[2], {
contexts: {
trace: {
diff --git a/packages/remix/test/integration/test/server/instrumentation-otel/action.test.ts b/packages/remix/test/integration/test/server/instrumentation-otel/action.test.ts
new file mode 100644
index 000000000000..a784cd3b8d9c
--- /dev/null
+++ b/packages/remix/test/integration/test/server/instrumentation-otel/action.test.ts
@@ -0,0 +1,495 @@
+import { describe, it } from 'vitest';
+import { RemixTestEnv, assertSentryEvent, assertSentryTransaction } from '../utils/helpers';
+
+const useV2 = process.env.REMIX_VERSION === '2';
+
+describe('Remix API Actions', () => {
+ it('correctly instruments a parameterized Remix API action', async () => {
+ const env = await RemixTestEnv.init();
+ const url = `${env.url}/action-json-response/123123`;
+ const envelopes = await env.getMultipleEnvelopeRequest({
+ url,
+ method: 'post',
+ envelopeType: 'transaction',
+ count: 1,
+ });
+ const transaction = envelopes[0][2];
+
+ assertSentryTransaction(transaction, {
+ transaction: `POST action-json-response/:id`,
+ spans: [
+ {
+ data: {
+ 'code.function': 'action',
+ 'sentry.op': 'action.remix',
+ 'otel.kind': 'INTERNAL',
+ 'match.route.id': `routes/action-json-response${useV2 ? '.' : '/'}$id`,
+ 'match.params.id': '123123',
+ },
+ },
+ {
+ data: {
+ 'code.function': 'loader',
+ 'sentry.op': 'loader.remix',
+ 'otel.kind': 'INTERNAL',
+ 'match.route.id': `routes/action-json-response${useV2 ? '.' : '/'}$id`,
+ 'match.params.id': '123123',
+ },
+ },
+ {
+ data: {
+ 'code.function': 'loader',
+ 'sentry.op': 'loader.remix',
+ 'otel.kind': 'INTERNAL',
+ 'match.route.id': 'root',
+ 'match.params.id': '123123',
+ },
+ },
+ ],
+ request: {
+ method: 'POST',
+ url,
+ cookies: expect.any(Object),
+ headers: {
+ 'user-agent': expect.any(String),
+ host: expect.stringContaining('localhost:'),
+ },
+ },
+ });
+ });
+
+ it('reports an error thrown from the action', async () => {
+ const env = await RemixTestEnv.init();
+ const url = `${env.url}/action-json-response/-1`;
+
+ const envelopes = await env.getMultipleEnvelopeRequest({
+ url,
+ count: 2,
+ method: 'post',
+ envelopeType: ['transaction', 'event'],
+ });
+
+ const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction');
+ const [event] = envelopes.filter(envelope => envelope[1].type === 'event');
+
+ assertSentryTransaction(transaction[2], {
+ contexts: {
+ trace: {
+ status: 'internal_error',
+ data: {
+ 'http.response.status_code': 500,
+ },
+ },
+ },
+ });
+
+ assertSentryEvent(event[2], {
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'Unexpected Server Error',
+ stacktrace: expect.any(Object),
+ mechanism: {
+ data: {
+ function: useV2 ? 'remix.server.handleError' : 'action',
+ },
+ handled: false,
+ type: 'instrument',
+ },
+ },
+ ],
+ },
+ });
+ });
+
+ it('includes request data in transaction and error events', async () => {
+ const env = await RemixTestEnv.init();
+ const url = `${env.url}/action-json-response/-1`;
+
+ const envelopes = await env.getMultipleEnvelopeRequest({
+ url,
+ count: 2,
+ method: 'post',
+ envelopeType: ['transaction', 'event'],
+ });
+
+ const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction');
+ const [event] = envelopes.filter(envelope => envelope[1].type === 'event');
+
+ assertSentryTransaction(transaction[2], {
+ transaction: `POST action-json-response/:id`,
+ request: {
+ method: 'POST',
+ url,
+ cookies: expect.any(Object),
+ headers: {
+ 'user-agent': expect.any(String),
+ host: expect.stringContaining('localhost:'),
+ },
+ },
+ });
+
+ assertSentryEvent(event[2], {
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'Unexpected Server Error',
+ },
+ ],
+ },
+ request: {
+ method: 'POST',
+ url,
+ cookies: expect.any(Object),
+ headers: {
+ 'user-agent': expect.any(String),
+ host: expect.stringContaining('localhost:'),
+ },
+ },
+ });
+ });
+
+ it('handles an error-throwing redirection target', async () => {
+ const env = await RemixTestEnv.init();
+ const url = `${env.url}/action-json-response/-2`;
+
+ const envelopes = await env.getMultipleEnvelopeRequest({
+ url,
+ count: 3,
+ method: 'post',
+ envelopeType: ['transaction', 'event'],
+ });
+
+ const [transaction_1, transaction_2] = envelopes.filter(envelope => envelope[1].type === 'transaction');
+ const [event] = envelopes.filter(envelope => envelope[1].type === 'event');
+
+ assertSentryTransaction(transaction_1[2], {
+ contexts: {
+ trace: {
+ op: 'http.server',
+ status: 'ok',
+ data: {
+ 'http.response.status_code': 302,
+ },
+ },
+ },
+ transaction: `POST action-json-response/:id`,
+ });
+
+ assertSentryTransaction(transaction_2[2], {
+ contexts: {
+ trace: {
+ op: 'http.server',
+ status: 'internal_error',
+ data: {
+ 'http.response.status_code': 500,
+ },
+ },
+ },
+ transaction: `GET action-json-response/:id`,
+ });
+
+ assertSentryEvent(event[2], {
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'Unexpected Server Error',
+ stacktrace: expect.any(Object),
+ mechanism: {
+ data: {
+ function: useV2 ? 'remix.server.handleError' : 'loader',
+ },
+ handled: false,
+ type: 'instrument',
+ },
+ },
+ ],
+ },
+ });
+ });
+
+ it('handles a thrown `json()` error response with `statusText`', async () => {
+ const env = await RemixTestEnv.init();
+ const url = `${env.url}/action-json-response/-3`;
+
+ const envelopes = await env.getMultipleEnvelopeRequest({
+ url,
+ count: 2,
+ method: 'post',
+ envelopeType: ['transaction', 'event'],
+ });
+
+ const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction');
+ const [event] = envelopes.filter(envelope => envelope[1].type === 'event');
+
+ assertSentryTransaction(transaction[2], {
+ contexts: {
+ trace: {
+ op: 'http.server',
+ status: 'internal_error',
+ data: {
+ 'http.response.status_code': 500,
+ },
+ },
+ },
+ transaction: `POST action-json-response/:id`,
+ });
+
+ assertSentryEvent(event[2], {
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'Sentry Test Error',
+ stacktrace: expect.any(Object),
+ mechanism: {
+ data: {
+ function: 'action',
+ },
+ handled: false,
+ type: 'instrument',
+ },
+ },
+ ],
+ },
+ });
+ });
+
+ it('handles a thrown `json()` error response without `statusText`', async () => {
+ const env = await RemixTestEnv.init();
+ const url = `${env.url}/action-json-response/-4`;
+
+ const envelopes = await env.getMultipleEnvelopeRequest({
+ url,
+ count: 2,
+ method: 'post',
+ envelopeType: ['transaction', 'event'],
+ });
+
+ const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction');
+ const [event] = envelopes.filter(envelope => envelope[1].type === 'event');
+
+ assertSentryTransaction(transaction[2], {
+ contexts: {
+ trace: {
+ op: 'http.server',
+ status: 'internal_error',
+ data: {
+ 'http.response.status_code': 500,
+ },
+ },
+ },
+ transaction: `POST action-json-response/:id`,
+ });
+
+ assertSentryEvent(event[2], {
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'Object captured as exception with keys: data',
+ stacktrace: expect.any(Object),
+ mechanism: {
+ data: {
+ function: 'action',
+ },
+ handled: false,
+ type: 'instrument',
+ },
+ },
+ ],
+ },
+ });
+ });
+
+ it('handles a thrown `json()` error response with string body', async () => {
+ const env = await RemixTestEnv.init();
+ const url = `${env.url}/action-json-response/-5`;
+
+ const envelopes = await env.getMultipleEnvelopeRequest({
+ url,
+ count: 2,
+ method: 'post',
+ envelopeType: ['transaction', 'event'],
+ });
+
+ const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction');
+ const [event] = envelopes.filter(envelope => envelope[1].type === 'event');
+
+ assertSentryTransaction(transaction[2], {
+ contexts: {
+ trace: {
+ op: 'http.server',
+ status: 'internal_error',
+ data: {
+ 'http.response.status_code': 500,
+ },
+ },
+ },
+ transaction: `POST action-json-response/:id`,
+ });
+
+ assertSentryEvent(event[2], {
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'Sentry Test Error [string body]',
+ stacktrace: expect.any(Object),
+ mechanism: {
+ data: {
+ function: 'action',
+ },
+ handled: false,
+ type: 'instrument',
+ },
+ },
+ ],
+ },
+ });
+ });
+
+ it('handles a thrown `json()` error response with an empty object', async () => {
+ const env = await RemixTestEnv.init();
+ const url = `${env.url}/action-json-response/-6`;
+
+ const envelopes = await env.getMultipleEnvelopeRequest({
+ url,
+ count: 2,
+ method: 'post',
+ envelopeType: ['transaction', 'event'],
+ });
+
+ const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction');
+ const [event] = envelopes.filter(envelope => envelope[1].type === 'event');
+
+ assertSentryTransaction(transaction[2], {
+ contexts: {
+ trace: {
+ op: 'http.server',
+ status: 'internal_error',
+ data: {
+ 'http.response.status_code': 500,
+ },
+ },
+ },
+ transaction: `POST action-json-response/:id`,
+ });
+
+ assertSentryEvent(event[2], {
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'Object captured as exception with keys: [object has no keys]',
+ stacktrace: expect.any(Object),
+ mechanism: {
+ data: {
+ function: 'action',
+ },
+ handled: false,
+ type: 'instrument',
+ },
+ },
+ ],
+ },
+ });
+ });
+
+ it('handles thrown string (primitive) from an action', async () => {
+ const env = await RemixTestEnv.init();
+ const url = `${env.url}/server-side-unexpected-errors/-1`;
+
+ const envelopes = await env.getMultipleEnvelopeRequest({
+ url,
+ count: 2,
+ method: 'post',
+ envelopeType: ['event', 'transaction'],
+ });
+
+ const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction');
+ const [event] = envelopes.filter(envelope => envelope[1].type === 'event');
+
+ assertSentryTransaction(transaction[2], {
+ contexts: {
+ trace: {
+ op: 'http.server',
+ status: 'internal_error',
+ data: {
+ 'http.response.status_code': 500,
+ },
+ },
+ },
+ transaction: `POST server-side-unexpected-errors/:id`,
+ });
+
+ assertSentryEvent(event[2], {
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'Thrown String Error',
+ stacktrace: expect.any(Object),
+ mechanism: {
+ data: {
+ function: useV2 ? 'remix.server.handleError' : 'action',
+ },
+ handled: false,
+ type: 'instrument',
+ },
+ },
+ ],
+ },
+ });
+ });
+
+ it('handles thrown object from an action', async () => {
+ const env = await RemixTestEnv.init();
+ const url = `${env.url}/server-side-unexpected-errors/-2`;
+
+ const envelopes = await env.getMultipleEnvelopeRequest({
+ url,
+ count: 2,
+ method: 'post',
+ envelopeType: ['event', 'transaction'],
+ });
+
+ const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction');
+ const [event] = envelopes.filter(envelope => envelope[1].type === 'event');
+
+ assertSentryTransaction(transaction[2], {
+ contexts: {
+ trace: {
+ op: 'http.server',
+ status: 'internal_error',
+ data: {
+ 'http.response.status_code': 500,
+ },
+ },
+ },
+ transaction: `POST server-side-unexpected-errors/:id`,
+ });
+
+ assertSentryEvent(event[2], {
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'Thrown Object Error',
+ stacktrace: expect.any(Object),
+ mechanism: {
+ data: {
+ function: useV2 ? 'remix.server.handleError' : 'action',
+ },
+ handled: false,
+ type: 'instrument',
+ },
+ },
+ ],
+ },
+ });
+ });
+});
diff --git a/packages/remix/test/integration/test/server/instrumentation-otel/loader.test.ts b/packages/remix/test/integration/test/server/instrumentation-otel/loader.test.ts
new file mode 100644
index 000000000000..62e0bf78ac10
--- /dev/null
+++ b/packages/remix/test/integration/test/server/instrumentation-otel/loader.test.ts
@@ -0,0 +1,275 @@
+import { Event } from '@sentry/types';
+import { describe, expect, it } from 'vitest';
+import { RemixTestEnv, assertSentryEvent, assertSentryTransaction } from '../utils/helpers';
+
+const useV2 = process.env.REMIX_VERSION === '2';
+
+describe('Remix API Loaders', () => {
+ it('reports an error thrown from the loader', async () => {
+ const env = await RemixTestEnv.init();
+ const url = `${env.url}/loader-json-response/-2`;
+
+ const envelopes = await env.getMultipleEnvelopeRequest({ url, count: 2, envelopeType: ['transaction', 'event'] });
+
+ const event = envelopes[0][2].type === 'transaction' ? envelopes[1][2] : envelopes[0][2];
+ const transaction = envelopes[0][2].type === 'transaction' ? envelopes[0][2] : envelopes[1][2];
+
+ assertSentryTransaction(transaction, {
+ contexts: {
+ trace: {
+ status: 'internal_error',
+ data: {
+ 'http.response.status_code': 500,
+ },
+ },
+ },
+ });
+
+ assertSentryEvent(event, {
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'Unexpected Server Error',
+ stacktrace: expect.any(Object),
+ mechanism: {
+ data: {
+ function: useV2 ? 'remix.server.handleError' : 'loader',
+ },
+ handled: false,
+ type: 'instrument',
+ },
+ },
+ ],
+ },
+ });
+ });
+
+ it('reports a thrown error response the loader', async () => {
+ const env = await RemixTestEnv.init();
+ const url = `${env.url}/loader-throw-response/-1`;
+
+ // We also wait for the transaction, even though we don't care about it for this test
+ // but otherwise this may leak into another test
+ const envelopes = await env.getMultipleEnvelopeRequest({ url, count: 2, envelopeType: ['event', 'transaction'] });
+
+ const event = envelopes[0][2].type === 'transaction' ? envelopes[1][2] : envelopes[0][2];
+ const transaction = envelopes[0][2].type === 'transaction' ? envelopes[0][2] : envelopes[1][2];
+
+ assertSentryTransaction(transaction, {
+ contexts: {
+ trace: {
+ status: 'internal_error',
+ data: {
+ 'http.response.status_code': 500,
+ },
+ },
+ },
+ });
+
+ assertSentryEvent(event, {
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'Not found',
+ stacktrace: expect.any(Object),
+ mechanism: {
+ data: {
+ function: 'loader',
+ },
+ handled: false,
+ type: 'instrument',
+ },
+ },
+ ],
+ },
+ });
+ });
+
+ it('correctly instruments a parameterized Remix API loader', async () => {
+ const env = await RemixTestEnv.init();
+ const url = `${env.url}/loader-json-response/123123`;
+ const envelope = await env.getEnvelopeRequest({ url, envelopeType: 'transaction' });
+ const transaction = envelope[2];
+
+ assertSentryTransaction(transaction, {
+ transaction: `GET loader-json-response/:id`,
+ transaction_info: {
+ source: 'route',
+ },
+ spans: [
+ {
+ data: {
+ 'code.function': 'loader',
+ 'otel.kind': 'INTERNAL',
+ 'sentry.op': 'loader.remix',
+ },
+ origin: 'manual',
+ },
+ {
+ data: {
+ 'code.function': 'loader',
+ 'otel.kind': 'INTERNAL',
+ 'sentry.op': 'loader.remix',
+ },
+ origin: 'manual',
+ },
+ ],
+ });
+ });
+
+ it('handles an error-throwing redirection target', async () => {
+ const env = await RemixTestEnv.init();
+ const url = `${env.url}/loader-json-response/-1`;
+
+ const envelopes = await env.getMultipleEnvelopeRequest({
+ url,
+ count: 3,
+ envelopeType: ['transaction', 'event'],
+ });
+
+ const [transaction_1, transaction_2] = envelopes.filter(envelope => envelope[1].type === 'transaction');
+ const [event] = envelopes.filter(envelope => envelope[1].type === 'event');
+
+ assertSentryTransaction(transaction_1[2], {
+ contexts: {
+ trace: {
+ op: 'http.server',
+ status: 'ok',
+ data: {
+ 'http.response.status_code': 302,
+ },
+ },
+ },
+ transaction: `GET loader-json-response/:id`,
+ });
+
+ assertSentryTransaction(transaction_2[2], {
+ contexts: {
+ trace: {
+ op: 'http.server',
+ status: 'internal_error',
+ data: {
+ 'http.response.status_code': 500,
+ },
+ },
+ },
+ transaction: `GET loader-json-response/:id`,
+ });
+
+ assertSentryEvent(event[2], {
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'Unexpected Server Error',
+ stacktrace: expect.any(Object),
+ mechanism: {
+ data: {
+ function: useV2 ? 'remix.server.handleError' : 'loader',
+ },
+ handled: false,
+ type: 'instrument',
+ },
+ },
+ ],
+ },
+ });
+ });
+
+ it('makes sure scope does not bleed between requests', async () => {
+ const env = await RemixTestEnv.init();
+
+ const envelopes = await Promise.all([
+ env.getEnvelopeRequest({ url: `${env.url}/scope-bleed/1`, endServer: false, envelopeType: 'transaction' }),
+ env.getEnvelopeRequest({ url: `${env.url}/scope-bleed/2`, endServer: false, envelopeType: 'transaction' }),
+ env.getEnvelopeRequest({ url: `${env.url}/scope-bleed/3`, endServer: false, envelopeType: 'transaction' }),
+ env.getEnvelopeRequest({ url: `${env.url}/scope-bleed/4`, endServer: false, envelopeType: 'transaction' }),
+ ]);
+
+ await new Promise(resolve => env.server.close(resolve));
+
+ envelopes.forEach(envelope => {
+ const tags = envelope[2].tags as NonNullable;
+ const customTagArr = Object.keys(tags).filter(t => t.startsWith('tag'));
+ expect(customTagArr).toHaveLength(1);
+
+ const key = customTagArr[0];
+ const val = key[key.length - 1];
+ expect(tags[key]).toEqual(val);
+ });
+ });
+
+ it('continues transaction from sentry-trace header and baggage', async () => {
+ const env = await RemixTestEnv.init();
+ const url = `${env.url}/loader-json-response/3`;
+
+ // send sentry-trace and baggage headers to loader
+ env.setAxiosConfig({
+ headers: {
+ 'sentry-trace': '12312012123120121231201212312012-1121201211212012-1',
+ baggage: 'sentry-version=1.0,sentry-environment=production,sentry-trace_id=12312012123120121231201212312012',
+ },
+ });
+ const envelope = await env.getEnvelopeRequest({ url, envelopeType: 'transaction' });
+
+ expect(envelope[0].trace).toMatchObject({
+ trace_id: '12312012123120121231201212312012',
+ });
+
+ assertSentryTransaction(envelope[2], {
+ contexts: {
+ trace: {
+ trace_id: '12312012123120121231201212312012',
+ parent_span_id: '1121201211212012',
+ },
+ },
+ });
+ });
+
+ it('correctly instruments a deferred loader', async () => {
+ const env = await RemixTestEnv.init();
+ const url = `${env.url}/loader-defer-response/123123`;
+ const envelope = await env.getEnvelopeRequest({ url, envelopeType: 'transaction' });
+ const transaction = envelope[2];
+
+ assertSentryTransaction(transaction, {
+ transaction: 'GET loader-defer-response/:id',
+ transaction_info: {
+ source: 'route',
+ },
+ spans: [
+ {
+ data: {
+ 'code.function': 'loader',
+ 'sentry.op': 'loader.remix',
+ 'otel.kind': 'INTERNAL',
+ 'match.route.id': `routes/loader-defer-response${useV2 ? '.' : '/'}$id`,
+ },
+ },
+ {
+ data: {
+ 'code.function': 'loader',
+ 'sentry.op': 'loader.remix',
+ 'otel.kind': 'INTERNAL',
+ 'match.route.id': 'root',
+ },
+ },
+ ],
+ });
+ });
+
+ it('does not capture thrown redirect responses', async () => {
+ const env = await RemixTestEnv.init();
+ const url = `${env.url}/throw-redirect`;
+
+ const envelopesCount = await env.countEnvelopes({
+ url,
+ envelopeType: 'event',
+ timeout: 3000,
+ });
+
+ expect(envelopesCount).toBe(0);
+ });
+});
diff --git a/packages/remix/test/integration/test/server/instrumentation-otel/ssr.test.ts b/packages/remix/test/integration/test/server/instrumentation-otel/ssr.test.ts
new file mode 100644
index 000000000000..587e57abb1c3
--- /dev/null
+++ b/packages/remix/test/integration/test/server/instrumentation-otel/ssr.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+import { RemixTestEnv, assertSentryEvent, assertSentryTransaction } from '../utils/helpers';
+
+const useV2 = process.env.REMIX_VERSION === '2';
+
+describe('Server Side Rendering', () => {
+ it('correctly reports a server side rendering error', async () => {
+ const env = await RemixTestEnv.init();
+ const url = `${env.url}/ssr-error`;
+ const envelopes = await env.getMultipleEnvelopeRequest({ url, count: 2, envelopeType: ['transaction', 'event'] });
+ const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction');
+ const [event] = envelopes.filter(envelope => envelope[1].type === 'event');
+
+ assertSentryTransaction(transaction[2], {
+ contexts: {
+ trace: {
+ status: 'internal_error',
+ data: {
+ 'http.response.status_code': 500,
+ },
+ },
+ },
+ tags: useV2
+ ? {
+ // Testing that the wrapped `handleError` correctly adds tags
+ 'remix-test-tag': 'remix-test-value',
+ }
+ : {},
+ });
+
+ assertSentryEvent(event[2], {
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'Sentry SSR Test Error',
+ stacktrace: expect.any(Object),
+ mechanism: {
+ data: {
+ function: useV2 ? 'remix.server.handleError' : 'documentRequest',
+ },
+ handled: false,
+ type: 'instrument',
+ },
+ },
+ ],
+ },
+ });
+ });
+});
diff --git a/packages/remix/test/integration/test/server/utils/helpers.ts b/packages/remix/test/integration/test/server/utils/helpers.ts
index caf9d5525fd7..eccda209fb48 100644
--- a/packages/remix/test/integration/test/server/utils/helpers.ts
+++ b/packages/remix/test/integration/test/server/utils/helpers.ts
@@ -1,7 +1,6 @@
import * as http from 'http';
import { AddressInfo } from 'net';
import { createRequestHandler } from '@remix-run/express';
-import { wrapExpressCreateRequestHandler } from '@sentry/remix';
import express from 'express';
import { TestEnv } from '../../../../../../../dev-packages/node-integration-tests/utils';
@@ -12,15 +11,12 @@ export class RemixTestEnv extends TestEnv {
super(server, url);
}
- public static async init(adapter: string = 'builtin'): Promise {
- const requestHandlerFactory =
- adapter === 'express' ? wrapExpressCreateRequestHandler(createRequestHandler) : createRequestHandler;
-
+ public static async init(): Promise {
let serverPort;
const server = await new Promise(resolve => {
const app = express();
- app.all('*', requestHandlerFactory({ build: require('../../../build') }));
+ app.all('*', createRequestHandler({ build: require('../../../build') }));
const server = app.listen(0, () => {
serverPort = (server.address() as AddressInfo).port;
diff --git a/packages/remix/test/integration/tsconfig.test.json b/packages/remix/test/integration/tsconfig.test.json
index d3175b6a1b01..8ce7525d33fd 100644
--- a/packages/remix/test/integration/tsconfig.test.json
+++ b/packages/remix/test/integration/tsconfig.test.json
@@ -4,6 +4,6 @@
"include": ["test/**/*"],
"compilerOptions": {
- "types": ["node", "jest"]
+ "types": ["node", "vitest/globals"]
}
}
diff --git a/packages/remix/tsconfig.test.json b/packages/remix/tsconfig.test.json
index 7aa20c05d60c..ffcc2b26016c 100644
--- a/packages/remix/tsconfig.test.json
+++ b/packages/remix/tsconfig.test.json
@@ -1,7 +1,7 @@
{
"extends": "./tsconfig.json",
- "include": ["test/**/*"],
+ "include": ["test/**/*", "vitest.config.ts"],
"compilerOptions": {
"types": ["node", "jest"],
diff --git a/packages/remix/vitest.config.ts b/packages/remix/vitest.config.ts
new file mode 100644
index 000000000000..23c2383b9e8b
--- /dev/null
+++ b/packages/remix/vitest.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vitest/config';
+
+const useOtel = process.env.USE_OTEL === '1';
+
+export default defineConfig({
+ test: {
+ globals: true,
+ disableConsoleIntercept: true,
+ silent: false,
+ setupFiles: useOtel ? './test/integration/instrument.server.cjs' : undefined,
+ include: useOtel ? ['**/instrumentation-otel/*.test.ts'] : ['**/instrumentation-legacy/*.test.ts'],
+ },
+});
diff --git a/yarn.lock b/yarn.lock
index e83c8fe9f310..f4d66c26b8dc 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -24190,6 +24190,14 @@ opentelemetry-instrumentation-fetch-node@1.2.0:
"@opentelemetry/instrumentation" "^0.43.0"
"@opentelemetry/semantic-conventions" "^1.17.0"
+opentelemetry-instrumentation-remix@0.7.0:
+ version "0.7.0"
+ resolved "https://registry.yarnpkg.com/opentelemetry-instrumentation-remix/-/opentelemetry-instrumentation-remix-0.7.0.tgz#ea3ac4d6da69300de1417c938eade2d029c5cd21"
+ integrity sha512-dF7IdcLkN2xIUATaa2X4ahb/Plk/c2wPdOz90MCVgFHuQZvGtzP9DwBpxXEzs6dz4f57ZzJsHpwJvAXHCSJrbg==
+ dependencies:
+ "@opentelemetry/instrumentation" "^0.43.0"
+ "@opentelemetry/semantic-conventions" "^1.17.0"
+
optional-require@1.0.x:
version "1.0.3"
resolved "https://registry.yarnpkg.com/optional-require/-/optional-require-1.0.3.tgz#275b8e9df1dc6a17ad155369c2422a440f89cb07"
@@ -25565,7 +25573,7 @@ postcss@^8.2.14, postcss@^8.4.7, postcss@^8.4.8:
picocolors "^1.0.0"
source-map-js "^1.1.0"
-postcss@^8.4.23, postcss@^8.4.36:
+postcss@^8.4.23, postcss@^8.4.36, postcss@^8.4.38:
version "8.4.38"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e"
integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==
@@ -30634,6 +30642,17 @@ vite@^5.0.10:
optionalDependencies:
fsevents "~2.3.3"
+vite@^5.2.11:
+ version "5.2.11"
+ resolved "https://registry.yarnpkg.com/vite/-/vite-5.2.11.tgz#726ec05555431735853417c3c0bfb36003ca0cbd"
+ integrity sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==
+ dependencies:
+ esbuild "^0.20.1"
+ postcss "^8.4.38"
+ rollup "^4.13.0"
+ optionalDependencies:
+ fsevents "~2.3.3"
+
vitefu@^0.2.2, vitefu@^0.2.5:
version "0.2.5"
resolved "https://registry.yarnpkg.com/vitefu/-/vitefu-0.2.5.tgz#c1b93c377fbdd3e5ddd69840ea3aa70b40d90969"