Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -1303,6 +1303,41 @@ Affected SDKs: `@sentry/remix`.

The plugin now also applies the build-time instrumentation transform. If you added `sentryOrchestrionPlugin()` from `@sentry/server-utils/orchestrion/vite` to your Vite config manually, remove it. Opt out with `sentryRemixVitePlugin({ buildTimeInstrumentation: false })`.

### React: Simpler React Router setup via `@sentry/react/react-router`

Affected SDKs: `@sentry/react`.

`@sentry/react` gained a new `@sentry/react/react-router` entry point that pulls the required React Router hooks (`useLocation`, `useNavigationType`, `matchRoutes`, `createRoutesFromChildren`) from `react-router` for you, so you no longer have to thread them through `reactRouterBrowserTracingIntegration` yourself:

```diff
- import * as Sentry from '@sentry/react';
- import { useEffect } from 'react';
- import { createRoutesFromChildren, matchRoutes, useLocation, useNavigationType } from 'react-router';
+ import * as Sentry from '@sentry/react';
+ import { reactRouterBrowserTracingIntegration } from '@sentry/react/react-router';

Sentry.init({
integrations: [
- Sentry.reactRouterBrowserTracingIntegration({
- useEffect,
- useLocation,
- useNavigationType,
- createRoutesFromChildren,
- matchRoutes,
- }),
+ reactRouterBrowserTracingIntegration(),
],
});
```

The `wrapReactRouterRouting`, `wrapUseRoutes`, `wrapCreateBrowserRouter` and `wrapCreateMemoryRouter` helpers are re-exported from `@sentry/react/react-router` as well.

This entry requires `react-router` to be resolvable — it is declared as an optional peer dependency and supports React Router v6, v7 and v8. If you are on React Router v6 with only `react-router-dom` installed, either add `react-router` as a dependency or keep importing `reactRouterBrowserTracingIntegration` from `@sentry/react` and pass the hooks explicitly.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

react-router is a dependency of react-router-dom (source), so people might not need to install it (it's installed anyway), but pnpm might be stricter with that.

But react-router in v6 also mentions that nothing should be directly imported from there (but I guess thi only applies to users): https://github.com/remix-run/react-router/tree/v6/packages/react-router

From v7, react-router is the package to import from: https://reactrouter.com/7.18.3/upgrading/v6#upgrade-to-v7

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@s1gr1d added e2e tests for react router v6 and v8 as well. in v6 I on purpose did not add the react-router v6 dependency directly and it seems to work, though this may or may not work depending on package manager etc. I think it's fine to recommend people to add this dependency (does not hurt at least) but it should mostly also work without!


The existing `@sentry/react` API is unchanged and keeps working; passing the hooks there is now optional too (`useEffect` in particular is no longer used and can be omitted).

Additionally — for **every** `@sentry/react` routing setup, not just the new entry — the order in which you add the browser tracing integration and wrap your routes no longer matters.

## 3. Removed APIs

### `@sentry/core` / All SDKs
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage

# production
/build

# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local

npm-debug.log*
yarn-debug.log*
yarn-error.log*

/test-results/
/playwright-report/
/playwright/.cache/

!*.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "react-router-6-router-entry",
"version": "0.1.0",
"private": true,
"dependencies": {
"@sentry/react": "file:../../packed/sentry-react-packed.tgz",
"@types/react": "18.3.1",
"@types/react-dom": "18.3.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "^6.30.0"
},
"devDependencies": {
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"vite": "^6.4.2",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.0.0"
},
"scripts": {
"build": "vite build",
"dev": "vite",
"preview": "vite preview",
"test": "playwright test",
"clean": "npx rimraf node_modules pnpm-lock.yaml",
"typecheck": "tsc --noEmit",
"test:build": "pnpm install && pnpm build",
"test:build-canary": "pnpm install && pnpm add react@canary react-dom@canary && pnpm build",
"test:assert": "pnpm typecheck && pnpm test"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"volta": {
"extends": "../../package.json"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

const config = getPlaywrightConfig({
startCommand: `pnpm preview --port 3030`,
port: 3030,
});

export default config;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
interface Window {
recordedTransactions?: string[];
capturedExceptionId?: string;
sentryReplayId?: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as Sentry from '@sentry/react';
// The `@sentry/react/react-router` entry pulls the required router hooks from `react-router` itself, so
// `reactRouterBrowserTracingIntegration()` needs no arguments. On React Router v6 the DOM bindings
// (`BrowserRouter`, `Link`) come from `react-router-dom`. Note this app depends only on
// `react-router-dom` (not `react-router` directly) - the entry's `react-router` import still resolves
// via the copy `react-router-dom` pulls in, which is the common real-world v6 setup.
import { reactRouterBrowserTracingIntegration, wrapReactRouterRouting } from '@sentry/react/react-router';
import * as React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import Index from './pages/Index';
import Products from './pages/Products';
import User from './pages/User';

Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: import.meta.env.PUBLIC_E2E_TEST_DSN,
integrations: [reactRouterBrowserTracingIntegration()],
tracesSampleRate: 1.0,
release: 'e2e-test',
tunnel: 'http://localhost:3031',
});

const SentryRoutes = wrapReactRouterRouting(Routes);

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
<BrowserRouter>
<SentryRoutes>
<Route path="/" element={<Index />} />
<Route path="/user/:id" element={<User />} />
<Route path="/products" element={<Products />} />
</SentryRoutes>
</BrowserRouter>,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as React from 'react';
import { Link } from 'react-router-dom';

const Index = () => {
return (
<>
<input
type="button"
value="Capture Exception"
id="exception-button"
onClick={() => {
throw new Error('I am an error!');
}}
/>
<Link to="/user/5" id="navigation">
navigate
</Link>
<Link to="/products" id="navigation-products">
products
</Link>
</>
);
};

export default Index;
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as React from 'react';

const Products = () => {
// Fired on mount, i.e. while navigating to /products. This mirrors a typical
// route component that loads its data in an effect. The request is same-origin,
// so the SDK attaches `sentry-trace`/`baggage` headers by default.
React.useEffect(() => {
fetch('/api/products').catch(() => {
// ignore network errors in the test environment
});
}, []);

return <div id="products">Products</div>;
};

export default Products;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as React from 'react';

const User = () => {
return <p>I am a blank page :)</p>;
};

export default User;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'react-router-6-router-entry',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { expect, test } from '@playwright/test';
import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils';

test('Sends correct error event', async ({ page }) => {
const errorEventPromise = waitForError('react-router-6-router-entry', event => {
return !event.type && event.exception?.values?.[0]?.value === 'I am an error!';
});

await page.goto('/');

const exceptionButton = page.locator('id=exception-button');
await exceptionButton.click();

const errorEvent = await errorEventPromise;

expect(errorEvent.exception?.values).toHaveLength(1);
expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!');

expect(errorEvent.request).toEqual({
headers: expect.any(Object),
url: 'http://localhost:3030/',
});

expect(errorEvent.transaction).toEqual('/');

expect(errorEvent.contexts?.trace).toEqual({
trace_id: expect.any(String),
span_id: expect.any(String),
});
});

test('Sets correct transactionName', async ({ page }) => {
const pageloadSpanPromise = waitForStreamedSpan('react-router-6-router-entry', span => {
return getSpanOp(span) === 'pageload' && span.is_segment;
});

const errorEventPromise = waitForError('react-router-6-router-entry', event => {
return !event.type && event.exception?.values?.[0]?.value === 'I am an error!';
});

await page.goto('/');
const pageloadSpan = await pageloadSpanPromise;

// Only capture error once the pageload span was sent
const exceptionButton = page.locator('id=exception-button');
await exceptionButton.click();

const errorEvent = await errorEventPromise;

expect(errorEvent.exception?.values).toHaveLength(1);
expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!');

expect(errorEvent.transaction).toEqual('/');

expect(errorEvent.contexts?.trace).toEqual({
trace_id: pageloadSpan.trace_id,
span_id: expect.not.stringContaining(pageloadSpan.span_id),
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { expect, test } from '@playwright/test';
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';

test('propagates the navigation trace (not the stale pageload trace) for a fetch in a route mount effect', async ({
page,
}) => {
// Intercept the /products data fetch and capture the tracing header the SDK attached.
let productsRequestSentryTrace: string | undefined;
await page.route('**/api/products', async route => {
productsRequestSentryTrace = route.request().headers()['sentry-trace'];
await route.fulfill({
status: 200,
contentType: 'application/json',
body: '[]',
});
});

const pageloadSpanPromise = waitForStreamedSpan('react-router-6-router-entry', span => {
return getSpanOp(span) === 'pageload' && span.is_segment;
});

const navigationSpanPromise = waitForStreamedSpan('react-router-6-router-entry', span => {
return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/products';
});

await page.goto('/');
const pageloadSpan = await pageloadSpanPromise;

await page.locator('id=navigation-products').click();
const navigationSpan = await navigationSpanPromise;

const pageloadTraceId = pageloadSpan.trace_id;
const navigationTraceId = navigationSpan.trace_id;
const propagatedTraceId = productsRequestSentryTrace?.split('-')[0];

expect(pageloadTraceId).toBeDefined();
expect(navigationTraceId).toBeDefined();
expect(propagatedTraceId).toBeDefined();
expect(navigationTraceId).not.toEqual(pageloadTraceId);

// The fetch fired on /products must carry the navigation trace, not the stale pageload trace.
expect(propagatedTraceId).toEqual(navigationTraceId);
expect(propagatedTraceId).not.toEqual(pageloadTraceId);
});
Loading
Loading