>) => {
+ 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/app/routes/_index.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/_index.tsx
new file mode 100644
index 000000000000..b646c62ee4da
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/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-express/app/routes/action-formdata.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/action-formdata.tsx
new file mode 100644
index 000000000000..6bb5b40977a0
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/action-formdata.tsx
@@ -0,0 +1,21 @@
+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'));
+
+ return json({ message: 'success' });
+}
+
+export default function ActionFormData() {
+ return (
+
+ );
+}
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
new file mode 100644
index 000000000000..4e5330621191
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/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-express/app/routes/loader-error.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express/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/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/app/routes/navigate.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/navigate.tsx
new file mode 100644
index 000000000000..c7dcea798501
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/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/app/routes/user.$id.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express/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/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/env.d.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/env.d.ts
new file mode 100644
index 000000000000..78ed2345c6e4
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/globals.d.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/globals.d.ts
new file mode 100644
index 000000000000..4130ac6a8a09
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/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/package.json b/dev-packages/e2e-tests/test-applications/create-remix-app-express/package.json
new file mode 100644
index 000000000000..43333e87c01a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/package.json
@@ -0,0 +1,58 @@
+{
+ "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": {
+ "@sentry-internal/event-proxy-server": "link:../../../event-proxy-server",
+ "@playwright/test": "^1.36.2",
+ "@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",
+ "wait-port": "1.0.4"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/playwright.config.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/playwright.config.ts
new file mode 100644
index 000000000000..c5df75ca747e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/playwright.config.ts
@@ -0,0 +1,65 @@
+import type { PlaywrightTestConfig } from '@playwright/test';
+import { devices } from '@playwright/test';
+
+const remixPort = 3030;
+const eventProxyPort = 3031;
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+const config: PlaywrightTestConfig = {
+ testDir: './tests',
+ /* Maximum time one test can run for. */
+ timeout: 150_000,
+ expect: {
+ /**
+ * Maximum time expect() should wait for the condition to be met.
+ * For example in `await expect(locator).toHaveText();`
+ */
+ timeout: 5000,
+ },
+ /* Run tests in files in parallel */
+ fullyParallel: true,
+ /* Fail the build on CI if you accidentally left test.only in the source code. */
+ forbidOnly: !!process.env.CI,
+ /* Retry on CI only */
+ retries: 0,
+ /* Opt out of parallel tests on CI. */
+ workers: 1,
+ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+ reporter: 'list',
+ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+ use: {
+ /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
+ actionTimeout: 0,
+
+ baseURL: `http://localhost:${remixPort}`,
+
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+ trace: 'on-first-retry',
+ },
+
+ /* Configure projects for major browsers */
+ projects: [
+ {
+ name: 'chromium',
+ use: {
+ ...devices['Desktop Chrome'],
+ },
+ },
+ // For now we only test Chrome!
+ ],
+
+ /* Run your local dev server before starting the tests */
+ webServer: [
+ {
+ command: 'pnpm tsx start-event-proxy.ts',
+ port: eventProxyPort,
+ },
+ {
+ command: `PORT=${remixPort} pnpm start`,
+ port: remixPort,
+ },
+ ],
+};
+
+export default config;
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
new file mode 100644
index 000000000000..f432b2d49184
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/server.mjs
@@ -0,0 +1,53 @@
+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
+ : 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(
+ '*',
+ sentryCreateRequestHandler({
+ 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/start-event-proxy.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/start-event-proxy.ts
new file mode 100644
index 000000000000..d42dd8e93d17
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/start-event-proxy.ts
@@ -0,0 +1,5 @@
+import { startEventProxyServer } from '@sentry-internal/event-proxy-server';
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'create-remix-app-express',
+});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/behaviour-client.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/behaviour-client.test.ts
new file mode 100644
index 000000000000..944dcb07b4bd
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/behaviour-client.test.ts
@@ -0,0 +1,236 @@
+import { expect, test } from '@playwright/test';
+import axios, { AxiosError } from 'axios';
+
+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_TEST_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 () => {
+ try {
+ const response = await axios.get(
+ `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`,
+ { headers: { Authorization: `Bearer ${authToken}` } },
+ );
+
+ return response.status;
+ } catch (e) {
+ if (e instanceof AxiosError && e.response) {
+ if (e.response.status !== 404) {
+ throw e;
+ } else {
+ return e.response.status;
+ }
+ } else {
+ throw e;
+ }
+ }
+ },
+ {
+ 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 () => {
+ try {
+ const response = await axios.get(
+ `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`,
+ { headers: { Authorization: `Bearer ${authToken}` } },
+ );
+
+ if (response.data.contexts.trace.op === 'pageload') {
+ hadPageLoadTransaction = true;
+ }
+
+ return response.status;
+ } catch (e) {
+ if (e instanceof AxiosError && e.response) {
+ if (e.response.status !== 404) {
+ throw e;
+ } else {
+ return e.response.status;
+ }
+ } else {
+ throw e;
+ }
+ }
+ },
+ {
+ 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 () => {
+ try {
+ const response = await axios.get(
+ `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`,
+ { headers: { Authorization: `Bearer ${authToken}` } },
+ );
+
+ if (response.data.contexts.trace.op === 'navigation') {
+ hadPageNavigationTransaction = true;
+ }
+
+ return response.status;
+ } catch (e) {
+ if (e instanceof AxiosError && e.response) {
+ if (e.response.status !== 404) {
+ throw e;
+ } else {
+ return e.response.status;
+ }
+ } else {
+ throw e;
+ }
+ }
+ },
+ {
+ 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 () => {
+ try {
+ const response = await axios.get(
+ `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`,
+ { headers: { Authorization: `Bearer ${authToken}` } },
+ );
+ return response.status;
+ } catch (e) {
+ if (e instanceof AxiosError && e.response) {
+ if (e.response.status !== 404) {
+ throw e;
+ } else {
+ return e.response.status;
+ }
+ } else {
+ throw e;
+ }
+ }
+ },
+ {
+ 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/tests/behaviour-server.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/behaviour-server.test.ts
new file mode 100644
index 000000000000..83582d68bf39
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/behaviour-server.test.ts
@@ -0,0 +1,87 @@
+import { expect, test } from '@playwright/test';
+import { waitForError, waitForTransaction } from '@sentry-internal/event-proxy-server';
+import { uuid4 } from '@sentry/utils';
+
+test('Sends a loader error to Sentry', async ({ page }) => {
+ const loaderErrorPromise = waitForError('create-remix-app-express', 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', transactionEvent => {
+ return transactionEvent?.spans?.some(span => span.op === 'function.remix.action');
+ });
+
+ const actionTransaction = await formdataActionTransaction;
+
+ 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',
+ });
+});
+
+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', 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(`/?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/tsconfig.json b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tsconfig.json
new file mode 100644
index 000000000000..01252c0ee549
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "include": ["env.d.ts", "**/*.ts", "**/*.tsx"],
+ "exclude": ["start-event-proxy.ts"],
+ "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/vite.config.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/vite.config.ts
new file mode 100644
index 000000000000..13de9243b22a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/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-v2/package.json b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json
index 25f13aa5fd3f..d2f414349938 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
@@ -33,7 +33,7 @@
"typescript": "^5.0.4",
"ts-node": "10.9.1"
},
- "engines": {
- "node": ">=18.0.0"
+ "volta": {
+ "extends": "../../package.json"
}
}
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 67d2f5ba4564..72e8f414d403 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
@@ -33,9 +33,6 @@
"typescript": "^5.0.4",
"ts-node": "10.9.1"
},
- "engines": {
- "node": ">=14.18"
- },
"volta": {
"extends": "../../package.json"
}
diff --git a/packages/remix/src/utils/instrumentServer.ts b/packages/remix/src/utils/instrumentServer.ts
index 991459cace59..86f596b61eb7 100644
--- a/packages/remix/src/utils/instrumentServer.ts
+++ b/packages/remix/src/utils/instrumentServer.ts
@@ -281,9 +281,37 @@ function makeWrappedDataFunction(
name,
},
},
- () => {
+ span => {
return handleCallbackErrors(
- () => origFn.call(this, args),
+ 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;
diff --git a/packages/remix/src/utils/serverAdapters/express.ts b/packages/remix/src/utils/serverAdapters/express.ts
index bc3641bb1a59..d4caed091015 100644
--- a/packages/remix/src/utils/serverAdapters/express.ts
+++ b/packages/remix/src/utils/serverAdapters/express.ts
@@ -204,7 +204,11 @@ async function finishSentryProcessing(res: AugmentedExpressResponse): Promise(resolve => {
setImmediate(() => {
- span.end();
+ // 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();
});
});