Skip to content

Commit 1201eb2

Browse files
authored
feat(remix): Migrate to opentelemetry-instrumentation-remix. (#12110)
Ref: #11040 Migrates Remix server-side SDK to [opentelemetry-instrumentation-remix](https://www.npmjs.com/package/opentelemetry-instrumentation-remix). This PR also keeps the original implementation not the break the developer experience for non-Express Remix projects. Remix projects using Express are supported as is using the new `autoInstrumentRemix` option. **Usage with Express:** ```javascript // instrument.(cjs | mjs) const Sentry = require('@sentry/remix'); Sentry.init({ dsn: YOUR_DSN // ... // auto instrument Remix with OpenTelemetry autoInstrumentRemix: true, // Optionally capture action formData attributes with errors. // This requires `sendDefaultPii` set to true as well. captureActionFormDataKeys: { file: true, text: true, }, // To capture action formData attributes. sendDefaultPii: true }); ``` ```javascript // server.(cjs | mjs) // import the Sentry instrumentation file before anything else. import './instrument.cjs'; // alternatively `require('./instrument.cjs')` // ... const app = express(); // ... ``` **Usage with built-in Remix server:** You need to run the Remix server with `NODE_OPTIONS=`--require(...)` set. ```json // package.json // ... "scripts": { "start": "NODE_OPTIONS='--require=./instrument.server.cjs' remix-serve build" // or "start": "NODE_OPTIONS='--import=./instrument.server.mjs' remix-serve build" } // ... ``` This PR _**removes**_: - Express server adapter. There is no need to use `wrapExpressCreateRequestHandler` anymore even if you don't opt-in to `autoInstrumentRemix`. `wrapExpressCreateRequestHandler` is kept exported as a no-op function. - Built in HTTP incoming request instrumentation for both `legacy` and `otel` implementations. Instead we mark `requestHandler` spans as the root `http.server` spans. When `autoInstrumentRemix` is set to `true`, this update _**replaces**_: - Performance tracing on `action` / `loader` / `documentRequest` functions. Leaving them to be traced by `opentelemetry-instrumentation-remix` - Request handler instrumentation as they are also traced by `opentelemetry-instrumentation-remix` - Auto-instrumentation for http as default integration for Remix SDK. - With the new instrumentation, `pageload` span is the child of the `loader` span. (Example: [Trace](https://sentry-sdks.sentry.io/performance/trace/e51c640933786fd491bb428fb1eff826/?fov=0%2C436.5&node=error-cd2dc8610c5e4affb7356147daa77393&statsPeriod=7d&timestamp=1718029546)) - Legacy instrumentation keeps recording `pageload` span as the child of the `http.server` span. (Example: [Trace](https://sentry-sdks.sentry.io/performance/trace/01107a555eeee9b44d1f8c25ea0c45f3/?fov=0%2C818.599853515625&node=txn-0695246d026b4da792cf8f5e0a372b50&statsPeriod=7d&timestamp=1718103519)) Also: Migrates Remix integration tests from Jest to Vitest. - Related issues: jestjs/jest#15033 nodejs/require-in-the-middle#50 Fixes Backlogged Issues: - Fixes: #12082 - [Code](https://github.com/getsentry/sentry-javascript/pull/12110/files#diff-754e32c1c14ac3c3c93eedeb7680548b986bc02a8b0bc63d2efc189210322acdR416-R440) - Fixes: #9737 - [Test](https://github.com/getsentry/sentry-javascript/pull/12110/files#diff-1484a5311699a814a39921d440f831e9babe69f8225a0de667ebd8cc63ca5accR109) - Fixes: #12285
1 parent 073b649 commit 1201eb2

File tree

119 files changed

+4055
-746
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

119 files changed

+4055
-746
lines changed

.github/workflows/build.yml

+4-3
Original file line numberDiff line numberDiff line change
@@ -917,10 +917,8 @@ jobs:
917917
matrix:
918918
node: [18, 20, 22]
919919
remix: [1, 2]
920-
# Remix v2 only supports Node 18+, so run Node 14, 16 tests separately
920+
# Remix v2 only supports Node 18+, so run 16 tests separately
921921
include:
922-
- node: 14
923-
remix: 1
924922
- node: 16
925923
remix: 1
926924
steps:
@@ -1042,8 +1040,11 @@ jobs:
10421040
'create-react-app',
10431041
'create-next-app',
10441042
'create-remix-app',
1043+
'create-remix-app-legacy',
10451044
'create-remix-app-v2',
1045+
'create-remix-app-v2-legacy',
10461046
'create-remix-app-express',
1047+
'create-remix-app-express-legacy',
10471048
'create-remix-app-express-vite-dev',
10481049
'node-express-esm-loader',
10491050
'node-express-esm-preload',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* This is intended to be a basic starting point for linting in your app.
3+
* It relies on recommended configs out of the box for simplicity, but you can
4+
* and should modify this configuration to best suit your team's needs.
5+
*/
6+
7+
/** @type {import('eslint').Linter.Config} */
8+
module.exports = {
9+
root: true,
10+
parserOptions: {
11+
ecmaVersion: 'latest',
12+
sourceType: 'module',
13+
ecmaFeatures: {
14+
jsx: true,
15+
},
16+
},
17+
env: {
18+
browser: true,
19+
commonjs: true,
20+
es6: true,
21+
},
22+
23+
// Base config
24+
extends: ['eslint:recommended'],
25+
26+
overrides: [
27+
// React
28+
{
29+
files: ['**/*.{js,jsx,ts,tsx}'],
30+
plugins: ['react', 'jsx-a11y'],
31+
extends: [
32+
'plugin:react/recommended',
33+
'plugin:react/jsx-runtime',
34+
'plugin:react-hooks/recommended',
35+
'plugin:jsx-a11y/recommended',
36+
],
37+
settings: {
38+
react: {
39+
version: 'detect',
40+
},
41+
formComponents: ['Form'],
42+
linkComponents: [
43+
{ name: 'Link', linkAttribute: 'to' },
44+
{ name: 'NavLink', linkAttribute: 'to' },
45+
],
46+
'import/resolver': {
47+
typescript: {},
48+
},
49+
},
50+
},
51+
52+
// Typescript
53+
{
54+
files: ['**/*.{ts,tsx}'],
55+
plugins: ['@typescript-eslint', 'import'],
56+
parser: '@typescript-eslint/parser',
57+
settings: {
58+
'import/internal-regex': '^~/',
59+
'import/resolver': {
60+
node: {
61+
extensions: ['.ts', '.tsx'],
62+
},
63+
typescript: {
64+
alwaysTryTypes: true,
65+
},
66+
},
67+
},
68+
extends: ['plugin:@typescript-eslint/recommended', 'plugin:import/recommended', 'plugin:import/typescript'],
69+
},
70+
71+
// Node
72+
{
73+
files: ['.eslintrc.cjs', 'server.js'],
74+
env: {
75+
node: true,
76+
},
77+
},
78+
],
79+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
node_modules
2+
3+
/.cache
4+
/build
5+
/public/build
6+
.env
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@sentry:registry=http://127.0.0.1:4873
2+
@sentry-internal:registry=http://127.0.0.1:4873
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { RemixBrowser, useLocation, useMatches } from '@remix-run/react';
2+
import * as Sentry from '@sentry/remix';
3+
import { StrictMode, startTransition, useEffect } from 'react';
4+
import { hydrateRoot } from 'react-dom/client';
5+
6+
Sentry.init({
7+
environment: 'qa', // dynamic sampling bias to keep transactions
8+
dsn: window.ENV.SENTRY_DSN,
9+
integrations: [
10+
Sentry.browserTracingIntegration({
11+
useEffect,
12+
useLocation,
13+
useMatches,
14+
}),
15+
Sentry.replayIntegration(),
16+
],
17+
// Performance Monitoring
18+
tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production!
19+
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.
20+
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
21+
tunnel: 'http://localhost:3031/', // proxy server
22+
});
23+
24+
Sentry.addEventProcessor(event => {
25+
if (
26+
event.type === 'transaction' &&
27+
(event.contexts?.trace?.op === 'pageload' || event.contexts?.trace?.op === 'navigation')
28+
) {
29+
const eventId = event.event_id;
30+
if (eventId) {
31+
window.recordedTransactions = window.recordedTransactions || [];
32+
window.recordedTransactions.push(eventId);
33+
}
34+
}
35+
36+
return event;
37+
});
38+
39+
startTransition(() => {
40+
hydrateRoot(
41+
document,
42+
<StrictMode>
43+
<RemixBrowser />
44+
</StrictMode>,
45+
);
46+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import * as Sentry from '@sentry/remix';
2+
3+
import { PassThrough } from 'node:stream';
4+
import * as isbotModule from 'isbot';
5+
6+
import type { AppLoadContext, EntryContext } from '@remix-run/node';
7+
import { createReadableStreamFromReadable } from '@remix-run/node';
8+
import { installGlobals } from '@remix-run/node';
9+
import { RemixServer } from '@remix-run/react';
10+
import { renderToPipeableStream } from 'react-dom/server';
11+
12+
installGlobals();
13+
14+
const ABORT_DELAY = 5_000;
15+
16+
export const handleError = Sentry.wrapRemixHandleError;
17+
18+
export default function handleRequest(
19+
request: Request,
20+
responseStatusCode: number,
21+
responseHeaders: Headers,
22+
remixContext: EntryContext,
23+
loadContext: AppLoadContext,
24+
) {
25+
return isBotRequest(request.headers.get('user-agent'))
26+
? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext)
27+
: handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext);
28+
}
29+
30+
// We have some Remix apps in the wild already running with isbot@3 so we need
31+
// to maintain backwards compatibility even though we want new apps to use
32+
// isbot@4. That way, we can ship this as a minor Semver update to @remix-run/dev.
33+
function isBotRequest(userAgent: string | null) {
34+
if (!userAgent) {
35+
return false;
36+
}
37+
38+
// isbot >= 3.8.0, >4
39+
if ('isbot' in isbotModule && typeof isbotModule.isbot === 'function') {
40+
return isbotModule.isbot(userAgent);
41+
}
42+
43+
// isbot < 3.8.0
44+
if ('default' in isbotModule && typeof isbotModule.default === 'function') {
45+
return isbotModule.default(userAgent);
46+
}
47+
48+
return false;
49+
}
50+
51+
function handleBotRequest(
52+
request: Request,
53+
responseStatusCode: number,
54+
responseHeaders: Headers,
55+
remixContext: EntryContext,
56+
) {
57+
return new Promise((resolve, reject) => {
58+
let shellRendered = false;
59+
const { pipe, abort } = renderToPipeableStream(
60+
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
61+
{
62+
onAllReady() {
63+
shellRendered = true;
64+
const body = new PassThrough();
65+
const stream = createReadableStreamFromReadable(body);
66+
67+
responseHeaders.set('Content-Type', 'text/html');
68+
69+
resolve(
70+
new Response(stream, {
71+
headers: responseHeaders,
72+
status: responseStatusCode,
73+
}),
74+
);
75+
76+
pipe(body);
77+
},
78+
onShellError(error: unknown) {
79+
reject(error);
80+
},
81+
onError(error: unknown) {
82+
responseStatusCode = 500;
83+
// Log streaming rendering errors from inside the shell. Don't log
84+
// errors encountered during initial shell rendering since they'll
85+
// reject and get logged in handleDocumentRequest.
86+
if (shellRendered) {
87+
console.error(error);
88+
}
89+
},
90+
},
91+
);
92+
93+
setTimeout(abort, ABORT_DELAY);
94+
});
95+
}
96+
97+
function handleBrowserRequest(
98+
request: Request,
99+
responseStatusCode: number,
100+
responseHeaders: Headers,
101+
remixContext: EntryContext,
102+
) {
103+
return new Promise((resolve, reject) => {
104+
let shellRendered = false;
105+
const { pipe, abort } = renderToPipeableStream(
106+
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
107+
{
108+
onShellReady() {
109+
shellRendered = true;
110+
const body = new PassThrough();
111+
const stream = createReadableStreamFromReadable(body);
112+
113+
responseHeaders.set('Content-Type', 'text/html');
114+
115+
resolve(
116+
new Response(stream, {
117+
headers: responseHeaders,
118+
status: responseStatusCode,
119+
}),
120+
);
121+
122+
pipe(body);
123+
},
124+
onShellError(error: unknown) {
125+
reject(error);
126+
},
127+
onError(error: unknown) {
128+
responseStatusCode = 500;
129+
// Log streaming rendering errors from inside the shell. Don't log
130+
// errors encountered during initial shell rendering since they'll
131+
// reject and get logged in handleDocumentRequest.
132+
if (shellRendered) {
133+
console.error(error);
134+
}
135+
},
136+
},
137+
);
138+
139+
setTimeout(abort, ABORT_DELAY);
140+
});
141+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { cssBundleHref } from '@remix-run/css-bundle';
2+
import { LinksFunction, MetaFunction, json } from '@remix-run/node';
3+
import {
4+
Links,
5+
LiveReload,
6+
Meta,
7+
Outlet,
8+
Scripts,
9+
ScrollRestoration,
10+
useLoaderData,
11+
useRouteError,
12+
} from '@remix-run/react';
13+
import { captureRemixErrorBoundaryError, withSentry } from '@sentry/remix';
14+
import type { SentryMetaArgs } from '@sentry/remix';
15+
16+
export const links: LinksFunction = () => [...(cssBundleHref ? [{ rel: 'stylesheet', href: cssBundleHref }] : [])];
17+
18+
export const loader = () => {
19+
return json({
20+
ENV: {
21+
SENTRY_DSN: process.env.E2E_TEST_DSN,
22+
},
23+
});
24+
};
25+
26+
export const meta = ({ data }: SentryMetaArgs<MetaFunction<typeof loader>>) => {
27+
return [
28+
{
29+
env: data.ENV,
30+
},
31+
{
32+
name: 'sentry-trace',
33+
content: data.sentryTrace,
34+
},
35+
{
36+
name: 'baggage',
37+
content: data.sentryBaggage,
38+
},
39+
];
40+
};
41+
42+
export function ErrorBoundary() {
43+
const error = useRouteError();
44+
const eventId = captureRemixErrorBoundaryError(error);
45+
46+
return (
47+
<div>
48+
<span>ErrorBoundary Error</span>
49+
<span id="event-id">{eventId}</span>
50+
</div>
51+
);
52+
}
53+
54+
function App() {
55+
const { ENV } = useLoaderData();
56+
57+
return (
58+
<html lang="en">
59+
<head>
60+
<meta charSet="utf-8" />
61+
<meta name="viewport" content="width=device-width,initial-scale=1" />
62+
<script
63+
dangerouslySetInnerHTML={{
64+
__html: `window.ENV = ${JSON.stringify(ENV)}`,
65+
}}
66+
/>
67+
<Meta />
68+
<Links />
69+
</head>
70+
<body>
71+
<Outlet />
72+
<ScrollRestoration />
73+
<Scripts />
74+
<LiveReload />
75+
</body>
76+
</html>
77+
);
78+
}
79+
80+
export default withSentry(App);

0 commit comments

Comments
 (0)