From 97ea84e3ea6d224dfdf680806e5c3e4e234b8e36 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Wed, 15 Jun 2022 18:42:01 +0100 Subject: [PATCH] feat: Add Remix client SDK. --- packages/remix/package.json | 8 +- packages/remix/src/flags.ts | 18 +++ packages/remix/src/index.client.tsx | 21 ++++ packages/remix/src/index.ts | 3 +- packages/remix/src/performance/client.tsx | 141 ++++++++++++++++++++++ packages/remix/src/utils/metadata.ts | 23 ++++ packages/remix/src/utils/remixOptions.ts | 4 + packages/remix/test/index.client.test.ts | 54 +++++++++ 8 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 packages/remix/src/flags.ts create mode 100644 packages/remix/src/index.client.tsx create mode 100644 packages/remix/src/performance/client.tsx create mode 100644 packages/remix/src/utils/metadata.ts create mode 100644 packages/remix/src/utils/remixOptions.ts create mode 100644 packages/remix/test/index.client.test.ts diff --git a/packages/remix/package.json b/packages/remix/package.json index 852a541ff3ee..475d1cc81b09 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -9,6 +9,10 @@ "engines": { "node": ">=14" }, + "main": "build/esm/index.js", + "module": "build/esm/index.js", + "browser": "build/esm/index.client.js", + "types": "build/types/index.d.ts", "private": true, "dependencies": { "@sentry/core": "7.1.1", @@ -40,13 +44,11 @@ }, "scripts": { "build": "run-p build:rollup", - "build:cjs": "tsc -p tsconfig.cjs.json", "build:dev": "run-s build", "build:esm": "tsc -p tsconfig.esm.json", "build:rollup": "rollup -c rollup.npm.config.js", "build:types": "tsc -p tsconfig.types.json", - "build:watch": "run-p build:cjs:watch build:esm:watch", - "build:cjs:watch": "tsc -p tsconfig.cjs.json --watch", + "build:watch": "run-p build:esm:watch", "build:dev:watch": "run-s build:watch", "build:esm:watch": "tsc -p tsconfig.esm.json --watch", "build:rollup:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/remix/src/flags.ts b/packages/remix/src/flags.ts new file mode 100644 index 000000000000..fb99adbc2aa7 --- /dev/null +++ b/packages/remix/src/flags.ts @@ -0,0 +1,18 @@ +/* + * This file defines flags and constants that can be modified during compile time in order to facilitate tree shaking + * for users. + * + * Debug flags need to be declared in each package individually and must not be imported across package boundaries, + * because some build tools have trouble tree-shaking imported guards. + * + * As a convention, we define debug flags in a `flags.ts` file in the root of a package's `src` folder. + * + * Debug flag files will contain "magic strings" like `__SENTRY_DEBUG__` that may get replaced with actual values during + * our, or the user's build process. Take care when introducing new flags - they must not throw if they are not + * replaced. + */ + +declare const __SENTRY_DEBUG__: boolean; + +/** Flag that is true for debug builds, false otherwise. */ +export const IS_DEBUG_BUILD = typeof __SENTRY_DEBUG__ === 'undefined' ? true : __SENTRY_DEBUG__; diff --git a/packages/remix/src/index.client.tsx b/packages/remix/src/index.client.tsx new file mode 100644 index 000000000000..bd87458c993d --- /dev/null +++ b/packages/remix/src/index.client.tsx @@ -0,0 +1,21 @@ +/* eslint-disable import/export */ +import { configureScope, init as reactInit, Integrations } from '@sentry/react'; + +import { buildMetadata } from './utils/metadata'; +import { RemixOptions } from './utils/remixOptions'; +export { remixRouterInstrumentation, withSentryRouteTracing } from './performance/client'; +export { BrowserTracing } from '@sentry/tracing'; +export * from '@sentry/react'; + +export { Integrations }; + +export function init(options: RemixOptions): void { + buildMetadata(options, ['remix', 'react']); + options.environment = options.environment || process.env.NODE_ENV; + + reactInit(options); + + configureScope(scope => { + scope.setTag('runtime', 'browser'); + }); +} diff --git a/packages/remix/src/index.ts b/packages/remix/src/index.ts index 7646bbd17d04..f56038ae9853 100644 --- a/packages/remix/src/index.ts +++ b/packages/remix/src/index.ts @@ -1 +1,2 @@ -export default null; +export { remixRouterInstrumentation, withSentryRouteTracing } from './performance/client'; +export { BrowserTracing, Integrations } from '@sentry/tracing'; diff --git a/packages/remix/src/performance/client.tsx b/packages/remix/src/performance/client.tsx new file mode 100644 index 000000000000..a55d4fec54a4 --- /dev/null +++ b/packages/remix/src/performance/client.tsx @@ -0,0 +1,141 @@ +import { Transaction, TransactionContext } from '@sentry/types'; +import { getGlobalObject, logger } from '@sentry/utils'; +import * as React from 'react'; + +import { IS_DEBUG_BUILD } from '../flags'; + +const DEFAULT_TAGS = { + 'routing.instrumentation': 'remix-router', +} as const; + +type Params = { + readonly [key in Key]: string | undefined; +}; + +interface RouteMatch { + params: Params; + pathname: string; + id: string; + handle: unknown; +} + +type UseEffect = (cb: () => void, deps: unknown[]) => void; +type UseLocation = () => { + pathname: string; + search?: string; + hash?: string; + state?: unknown; + key?: unknown; +}; +type UseMatches = () => RouteMatch[] | null; + +let activeTransaction: Transaction | undefined; + +let _useEffect: UseEffect; +let _useLocation: UseLocation; +let _useMatches: UseMatches; + +let _customStartTransaction: (context: TransactionContext) => Transaction | undefined; +let _startTransactionOnLocationChange: boolean; + +const global = getGlobalObject(); + +function getInitPathName(): string | undefined { + if (global && global.location) { + return global.location.pathname; + } + + return undefined; +} + +/** + * Creates a react-router v6 instrumention for Remix applications. + * + * This implementation is slightly different (and simpler) from the react-router instrumentation + * as in Remix, `useMatches` hook is available where in react-router-v6 it's not yet. + */ +export function remixRouterInstrumentation(useEffect: UseEffect, useLocation: UseLocation, useMatches: UseMatches) { + return ( + customStartTransaction: (context: TransactionContext) => Transaction | undefined, + startTransactionOnPageLoad = true, + startTransactionOnLocationChange = true, + ): void => { + const initPathName = getInitPathName(); + if (startTransactionOnPageLoad && initPathName) { + activeTransaction = customStartTransaction({ + name: initPathName, + op: 'pageload', + tags: DEFAULT_TAGS, + }); + } + + _useEffect = useEffect; + _useLocation = useLocation; + _useMatches = useMatches; + + _customStartTransaction = customStartTransaction; + _startTransactionOnLocationChange = startTransactionOnLocationChange; + }; +} + +/** + * Wraps a remix `root` (see: https://remix.run/docs/en/v1/guides/migrating-react-router-app#creating-the-root-route) + * To enable pageload/navigation tracing on every route. + */ +export function withSentryRouteTracing

, R extends React.FC

>(OrigApp: R): R { + // Early return when any of the required functions is not available. + if (!_useEffect || !_useLocation || !_useMatches || !_customStartTransaction) { + IS_DEBUG_BUILD && logger.warn('Remix SDK was unable to wrap your root because of one or more missing parameters.'); + + // @ts-ignore Setting more specific React Component typing for `R` generic above + // will break advanced type inference done by react router params + return OrigApp; + } + + const SentryRoot: React.FC

= (props: P) => { + let isBaseLocation: boolean = false; + + const location = _useLocation(); + const matches = _useMatches(); + + _useEffect(() => { + if (activeTransaction && matches && matches.length) { + activeTransaction.setName(matches[matches.length - 1].id); + } + + isBaseLocation = true; + }, []); + + _useEffect(() => { + if (isBaseLocation) { + if (activeTransaction) { + activeTransaction.finish(); + } + + return; + } + + if (_startTransactionOnLocationChange && matches && matches.length) { + if (activeTransaction) { + activeTransaction.finish(); + } + + activeTransaction = _customStartTransaction({ + name: matches[matches.length - 1].id, + op: 'navigation', + tags: DEFAULT_TAGS, + }); + } + }, [location]); + + isBaseLocation = false; + + // @ts-ignore Setting more specific React Component typing for `R` generic above + // will break advanced type inference done by react router params + return ; + }; + + // @ts-ignore Setting more specific React Component typing for `R` generic above + // will break advanced type inference done by react router params + return SentryRoot; +} diff --git a/packages/remix/src/utils/metadata.ts b/packages/remix/src/utils/metadata.ts new file mode 100644 index 000000000000..243cdcc3826f --- /dev/null +++ b/packages/remix/src/utils/metadata.ts @@ -0,0 +1,23 @@ +import { SDK_VERSION } from '@sentry/core'; +import { Options, SdkInfo } from '@sentry/types'; + +const PACKAGE_NAME_PREFIX = 'npm:@sentry/'; + +/** + * A builder for the SDK metadata in the options for the SDK initialization. + * @param options sdk options object that gets mutated + * @param names list of package names + */ +export function buildMetadata(options: Options, names: string[]): void { + options._metadata = options._metadata || {}; + options._metadata.sdk = + options._metadata.sdk || + ({ + name: 'sentry.javascript.remix', + packages: names.map(name => ({ + name: `${PACKAGE_NAME_PREFIX}${name}`, + version: SDK_VERSION, + })), + version: SDK_VERSION, + } as SdkInfo); +} diff --git a/packages/remix/src/utils/remixOptions.ts b/packages/remix/src/utils/remixOptions.ts new file mode 100644 index 000000000000..74d67074b0a3 --- /dev/null +++ b/packages/remix/src/utils/remixOptions.ts @@ -0,0 +1,4 @@ +import { BrowserOptions } from '@sentry/react'; +import { Options } from '@sentry/types'; + +export type RemixOptions = Options | BrowserOptions; diff --git a/packages/remix/test/index.client.test.ts b/packages/remix/test/index.client.test.ts new file mode 100644 index 000000000000..eaca0b58c0f5 --- /dev/null +++ b/packages/remix/test/index.client.test.ts @@ -0,0 +1,54 @@ +import { getCurrentHub } from '@sentry/hub'; +import * as SentryReact from '@sentry/react'; +import { getGlobalObject } from '@sentry/utils'; + +import { init } from '../src/index.client'; + +const global = getGlobalObject(); + +const reactInit = jest.spyOn(SentryReact, 'init'); + +describe('Client init()', () => { + afterEach(() => { + jest.clearAllMocks(); + global.__SENTRY__.hub = undefined; + }); + + it('inits the React SDK', () => { + expect(reactInit).toHaveBeenCalledTimes(0); + init({}); + expect(reactInit).toHaveBeenCalledTimes(1); + expect(reactInit).toHaveBeenCalledWith( + expect.objectContaining({ + _metadata: { + sdk: { + name: 'sentry.javascript.remix', + version: expect.any(String), + packages: [ + { + name: 'npm:@sentry/remix', + version: expect.any(String), + }, + { + name: 'npm:@sentry/react', + version: expect.any(String), + }, + ], + }, + }, + }), + ); + }); + + it('sets runtime on scope', () => { + const currentScope = getCurrentHub().getScope(); + + // @ts-ignore need access to protected _tags attribute + expect(currentScope._tags).toEqual({}); + + init({}); + + // @ts-ignore need access to protected _tags attribute + expect(currentScope._tags).toEqual({ runtime: 'browser' }); + }); +});