Skip to content

Commit ef30920

Browse files
authored
feat: Add Remix client SDK (#5264)
Adds client side SDK for error tracking / performance tracing of Remix. - Uses React SDK underneath, and requires `<ErrorBoundary />` to properly catch errors. - Supports parameterized route paths with `remixRouterInstrumentation` and `withSentryRouteTracing`.
1 parent 3a938aa commit ef30920

File tree

8 files changed

+268
-4
lines changed

8 files changed

+268
-4
lines changed

packages/remix/package.json

+5-3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
"engines": {
1010
"node": ">=14"
1111
},
12+
"main": "build/esm/index.js",
13+
"module": "build/esm/index.js",
14+
"browser": "build/esm/index.client.js",
15+
"types": "build/types/index.d.ts",
1216
"private": true,
1317
"dependencies": {
1418
"@sentry/core": "7.1.1",
@@ -40,13 +44,11 @@
4044
},
4145
"scripts": {
4246
"build": "run-p build:rollup",
43-
"build:cjs": "tsc -p tsconfig.cjs.json",
4447
"build:dev": "run-s build",
4548
"build:esm": "tsc -p tsconfig.esm.json",
4649
"build:rollup": "rollup -c rollup.npm.config.js",
4750
"build:types": "tsc -p tsconfig.types.json",
48-
"build:watch": "run-p build:cjs:watch build:esm:watch",
49-
"build:cjs:watch": "tsc -p tsconfig.cjs.json --watch",
51+
"build:watch": "run-p build:esm:watch",
5052
"build:dev:watch": "run-s build:watch",
5153
"build:esm:watch": "tsc -p tsconfig.esm.json --watch",
5254
"build:rollup:watch": "rollup -c rollup.npm.config.js --watch",

packages/remix/src/flags.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
* This file defines flags and constants that can be modified during compile time in order to facilitate tree shaking
3+
* for users.
4+
*
5+
* Debug flags need to be declared in each package individually and must not be imported across package boundaries,
6+
* because some build tools have trouble tree-shaking imported guards.
7+
*
8+
* As a convention, we define debug flags in a `flags.ts` file in the root of a package's `src` folder.
9+
*
10+
* Debug flag files will contain "magic strings" like `__SENTRY_DEBUG__` that may get replaced with actual values during
11+
* our, or the user's build process. Take care when introducing new flags - they must not throw if they are not
12+
* replaced.
13+
*/
14+
15+
declare const __SENTRY_DEBUG__: boolean;
16+
17+
/** Flag that is true for debug builds, false otherwise. */
18+
export const IS_DEBUG_BUILD = typeof __SENTRY_DEBUG__ === 'undefined' ? true : __SENTRY_DEBUG__;

packages/remix/src/index.client.tsx

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/* eslint-disable import/export */
2+
import { configureScope, init as reactInit, Integrations } from '@sentry/react';
3+
4+
import { buildMetadata } from './utils/metadata';
5+
import { RemixOptions } from './utils/remixOptions';
6+
export { remixRouterInstrumentation, withSentryRouteTracing } from './performance/client';
7+
export { BrowserTracing } from '@sentry/tracing';
8+
export * from '@sentry/react';
9+
10+
export { Integrations };
11+
12+
export function init(options: RemixOptions): void {
13+
buildMetadata(options, ['remix', 'react']);
14+
options.environment = options.environment || process.env.NODE_ENV;
15+
16+
reactInit(options);
17+
18+
configureScope(scope => {
19+
scope.setTag('runtime', 'browser');
20+
});
21+
}

packages/remix/src/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
export default null;
1+
export { remixRouterInstrumentation, withSentryRouteTracing } from './performance/client';
2+
export { BrowserTracing, Integrations } from '@sentry/tracing';
+141
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { Transaction, TransactionContext } from '@sentry/types';
2+
import { getGlobalObject, logger } from '@sentry/utils';
3+
import * as React from 'react';
4+
5+
import { IS_DEBUG_BUILD } from '../flags';
6+
7+
const DEFAULT_TAGS = {
8+
'routing.instrumentation': 'remix-router',
9+
} as const;
10+
11+
type Params<Key extends string = string> = {
12+
readonly [key in Key]: string | undefined;
13+
};
14+
15+
interface RouteMatch<ParamKey extends string = string> {
16+
params: Params<ParamKey>;
17+
pathname: string;
18+
id: string;
19+
handle: unknown;
20+
}
21+
22+
type UseEffect = (cb: () => void, deps: unknown[]) => void;
23+
type UseLocation = () => {
24+
pathname: string;
25+
search?: string;
26+
hash?: string;
27+
state?: unknown;
28+
key?: unknown;
29+
};
30+
type UseMatches = () => RouteMatch[] | null;
31+
32+
let activeTransaction: Transaction | undefined;
33+
34+
let _useEffect: UseEffect;
35+
let _useLocation: UseLocation;
36+
let _useMatches: UseMatches;
37+
38+
let _customStartTransaction: (context: TransactionContext) => Transaction | undefined;
39+
let _startTransactionOnLocationChange: boolean;
40+
41+
const global = getGlobalObject<Window>();
42+
43+
function getInitPathName(): string | undefined {
44+
if (global && global.location) {
45+
return global.location.pathname;
46+
}
47+
48+
return undefined;
49+
}
50+
51+
/**
52+
* Creates a react-router v6 instrumention for Remix applications.
53+
*
54+
* This implementation is slightly different (and simpler) from the react-router instrumentation
55+
* as in Remix, `useMatches` hook is available where in react-router-v6 it's not yet.
56+
*/
57+
export function remixRouterInstrumentation(useEffect: UseEffect, useLocation: UseLocation, useMatches: UseMatches) {
58+
return (
59+
customStartTransaction: (context: TransactionContext) => Transaction | undefined,
60+
startTransactionOnPageLoad = true,
61+
startTransactionOnLocationChange = true,
62+
): void => {
63+
const initPathName = getInitPathName();
64+
if (startTransactionOnPageLoad && initPathName) {
65+
activeTransaction = customStartTransaction({
66+
name: initPathName,
67+
op: 'pageload',
68+
tags: DEFAULT_TAGS,
69+
});
70+
}
71+
72+
_useEffect = useEffect;
73+
_useLocation = useLocation;
74+
_useMatches = useMatches;
75+
76+
_customStartTransaction = customStartTransaction;
77+
_startTransactionOnLocationChange = startTransactionOnLocationChange;
78+
};
79+
}
80+
81+
/**
82+
* Wraps a remix `root` (see: https://remix.run/docs/en/v1/guides/migrating-react-router-app#creating-the-root-route)
83+
* To enable pageload/navigation tracing on every route.
84+
*/
85+
export function withSentryRouteTracing<P extends Record<string, unknown>, R extends React.FC<P>>(OrigApp: R): R {
86+
// Early return when any of the required functions is not available.
87+
if (!_useEffect || !_useLocation || !_useMatches || !_customStartTransaction) {
88+
IS_DEBUG_BUILD && logger.warn('Remix SDK was unable to wrap your root because of one or more missing parameters.');
89+
90+
// @ts-ignore Setting more specific React Component typing for `R` generic above
91+
// will break advanced type inference done by react router params
92+
return OrigApp;
93+
}
94+
95+
const SentryRoot: React.FC<P> = (props: P) => {
96+
let isBaseLocation: boolean = false;
97+
98+
const location = _useLocation();
99+
const matches = _useMatches();
100+
101+
_useEffect(() => {
102+
if (activeTransaction && matches && matches.length) {
103+
activeTransaction.setName(matches[matches.length - 1].id);
104+
}
105+
106+
isBaseLocation = true;
107+
}, []);
108+
109+
_useEffect(() => {
110+
if (isBaseLocation) {
111+
if (activeTransaction) {
112+
activeTransaction.finish();
113+
}
114+
115+
return;
116+
}
117+
118+
if (_startTransactionOnLocationChange && matches && matches.length) {
119+
if (activeTransaction) {
120+
activeTransaction.finish();
121+
}
122+
123+
activeTransaction = _customStartTransaction({
124+
name: matches[matches.length - 1].id,
125+
op: 'navigation',
126+
tags: DEFAULT_TAGS,
127+
});
128+
}
129+
}, [location]);
130+
131+
isBaseLocation = false;
132+
133+
// @ts-ignore Setting more specific React Component typing for `R` generic above
134+
// will break advanced type inference done by react router params
135+
return <OrigApp {...props} />;
136+
};
137+
138+
// @ts-ignore Setting more specific React Component typing for `R` generic above
139+
// will break advanced type inference done by react router params
140+
return SentryRoot;
141+
}

packages/remix/src/utils/metadata.ts

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { SDK_VERSION } from '@sentry/core';
2+
import { Options, SdkInfo } from '@sentry/types';
3+
4+
const PACKAGE_NAME_PREFIX = 'npm:@sentry/';
5+
6+
/**
7+
* A builder for the SDK metadata in the options for the SDK initialization.
8+
* @param options sdk options object that gets mutated
9+
* @param names list of package names
10+
*/
11+
export function buildMetadata(options: Options, names: string[]): void {
12+
options._metadata = options._metadata || {};
13+
options._metadata.sdk =
14+
options._metadata.sdk ||
15+
({
16+
name: 'sentry.javascript.remix',
17+
packages: names.map(name => ({
18+
name: `${PACKAGE_NAME_PREFIX}${name}`,
19+
version: SDK_VERSION,
20+
})),
21+
version: SDK_VERSION,
22+
} as SdkInfo);
23+
}
+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { BrowserOptions } from '@sentry/react';
2+
import { Options } from '@sentry/types';
3+
4+
export type RemixOptions = Options | BrowserOptions;
+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { getCurrentHub } from '@sentry/hub';
2+
import * as SentryReact from '@sentry/react';
3+
import { getGlobalObject } from '@sentry/utils';
4+
5+
import { init } from '../src/index.client';
6+
7+
const global = getGlobalObject();
8+
9+
const reactInit = jest.spyOn(SentryReact, 'init');
10+
11+
describe('Client init()', () => {
12+
afterEach(() => {
13+
jest.clearAllMocks();
14+
global.__SENTRY__.hub = undefined;
15+
});
16+
17+
it('inits the React SDK', () => {
18+
expect(reactInit).toHaveBeenCalledTimes(0);
19+
init({});
20+
expect(reactInit).toHaveBeenCalledTimes(1);
21+
expect(reactInit).toHaveBeenCalledWith(
22+
expect.objectContaining({
23+
_metadata: {
24+
sdk: {
25+
name: 'sentry.javascript.remix',
26+
version: expect.any(String),
27+
packages: [
28+
{
29+
name: 'npm:@sentry/remix',
30+
version: expect.any(String),
31+
},
32+
{
33+
name: 'npm:@sentry/react',
34+
version: expect.any(String),
35+
},
36+
],
37+
},
38+
},
39+
}),
40+
);
41+
});
42+
43+
it('sets runtime on scope', () => {
44+
const currentScope = getCurrentHub().getScope();
45+
46+
// @ts-ignore need access to protected _tags attribute
47+
expect(currentScope._tags).toEqual({});
48+
49+
init({});
50+
51+
// @ts-ignore need access to protected _tags attribute
52+
expect(currentScope._tags).toEqual({ runtime: 'browser' });
53+
});
54+
});

0 commit comments

Comments
 (0)