Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(remix): Set formData as action span data. #10836

Merged
merged 12 commits into from
Apr 26, 2024
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,7 @@ jobs:
'create-next-app',
'create-remix-app',
'create-remix-app-v2',
'create-remix-app-express',
'create-remix-app-express-vite-dev',
'debug-id-sourcemaps',
# 'esm-loader-node-express-app', # This is currently broken for upstream reasons. See https://github.com/getsentry/sentry-javascript/pull/11338#issuecomment-2025450675
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"vite-tsconfig-paths": "^4.2.1",
"ts-node": "10.9.1"
},
"engines": {
"node": ">=18.0.0"
"volta": {
"extends": "../../package.json"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* This is intended to be a basic starting point for linting in your app.
* It relies on recommended configs out of the box for simplicity, but you can
* and should modify this configuration to best suit your team's needs.
*/

/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
env: {
browser: true,
commonjs: true,
es6: true,
},

// Base config
extends: ['eslint:recommended'],

overrides: [
// React
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: ['react', 'jsx-a11y'],
extends: [
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended',
],
settings: {
react: {
version: 'detect',
},
formComponents: ['Form'],
linkComponents: [
{ name: 'Link', linkAttribute: 'to' },
{ name: 'NavLink', linkAttribute: 'to' },
],
'import/resolver': {
typescript: {},
},
},
},

// Typescript
{
files: ['**/*.{ts,tsx}'],
plugins: ['@typescript-eslint', 'import'],
parser: '@typescript-eslint/parser',
settings: {
'import/internal-regex': '^~/',
'import/resolver': {
node: {
extensions: ['.ts', '.tsx'],
},
typescript: {
alwaysTryTypes: true,
},
},
},
extends: ['plugin:@typescript-eslint/recommended', 'plugin:import/recommended', 'plugin:import/typescript'],
},

// Node
{
files: ['.eslintrc.cjs', 'server.js'],
env: {
node: true,
},
},
],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules

/.cache
/build
/public/build
.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@sentry:registry=http://127.0.0.1:4873
@sentry-internal:registry=http://127.0.0.1:4873
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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!
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,
<StrictMode>
<RemixBrowser />
</StrictMode>,
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
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 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 { renderToPipeableStream } from 'react-dom/server';

installGlobals();

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 isBotRequest(request.headers.get('user-agent'))
? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext)
: handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext);
}

// We have some Remix apps in the wild already running with isbot@3 so we need
// to maintain backwards compatibility even though we want new apps to use
// isbot@4. That way, we can ship this as a minor Semver update to @remix-run/dev.
function isBotRequest(userAgent: string | null) {
if (!userAgent) {
return false;
}

// isbot >= 3.8.0, >4
if ('isbot' in isbotModule && typeof isbotModule.isbot === 'function') {
return isbotModule.isbot(userAgent);
}

// isbot < 3.8.0
if ('default' in isbotModule && typeof isbotModule.default === 'function') {
return isbotModule.default(userAgent);
}

return false;
}

function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
{
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(
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
{
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);
});
}
Original file line number Diff line number Diff line change
@@ -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<MetaFunction<typeof loader>>) => {
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 (
<div>
<span>ErrorBoundary Error</span>
<span id="event-id">{eventId}</span>
</div>
);
}

function App() {
const { ENV } = useLoaderData();

return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<script
dangerouslySetInnerHTML={{
__html: `window.ENV = ${JSON.stringify(ENV)}`,
}}
/>
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}

export default withSentry(App);
Loading
Loading