diff --git a/packages/remix/package.json b/packages/remix/package.json index 43d9ca5f9918..b1fc3c461e2d 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -9,10 +9,10 @@ "engines": { "node": ">=14" }, - "main": "build/esm/index.js", - "module": "build/esm/index.js", + "main": "build/esm/index.server.js", + "module": "build/esm/index.server.js", "browser": "build/esm/index.client.js", - "types": "build/types/index.d.ts", + "types": "build/types/index.server.d.ts", "private": true, "dependencies": { "@sentry/core": "7.1.1", @@ -52,7 +52,7 @@ "build:rollup:watch": "rollup -c rollup.npm.config.js --watch", "build:types:watch": "tsc -p tsconfig.types.json --watch", "build:npm": "ts-node ../../scripts/prepack.ts && npm pack ./build", - "circularDepCheck": "madge --circular src/index.ts", + "circularDepCheck": "madge --circular src/index.server.ts", "clean": "rimraf build coverage sentry-remix-*.tgz", "fix": "run-s fix:eslint fix:prettier", "fix:eslint": "eslint . --format stylish --fix", @@ -66,5 +66,9 @@ }, "volta": { "extends": "../../package.json" - } + }, + "sideEffects": [ + "./esm/index.server.js", + "./src/index.server.ts" + ] } diff --git a/packages/remix/rollup.npm.config.js b/packages/remix/rollup.npm.config.js index af604639855a..4689937a652c 100644 --- a/packages/remix/rollup.npm.config.js +++ b/packages/remix/rollup.npm.config.js @@ -2,7 +2,6 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '../../rollup/index.js' export default makeNPMConfigVariants( makeBaseNPMConfig({ - // Todo: Replace with -> ['src/index.server.ts', 'src/index.client.tsx'], - entrypoints: 'src/index.ts', + entrypoints: ['src/index.server.ts', 'src/index.client.tsx'], }), ); diff --git a/packages/remix/src/index.server.ts b/packages/remix/src/index.server.ts new file mode 100644 index 000000000000..d57b5942fbf8 --- /dev/null +++ b/packages/remix/src/index.server.ts @@ -0,0 +1,36 @@ +/* eslint-disable import/export */ +import { configureScope, getCurrentHub, init as nodeInit } from '@sentry/node'; +import { logger } from '@sentry/utils'; + +import { instrumentServer } from './utils/instrumentServer'; +import { buildMetadata } from './utils/metadata'; +import { RemixOptions } from './utils/remixOptions'; + +export { ErrorBoundary, withErrorBoundary } from '@sentry/react'; +export { remixRouterInstrumentation, withSentryRouteTracing } from './performance/client'; +export { BrowserTracing, Integrations } from '@sentry/tracing'; +export * from '@sentry/node'; + +function sdkAlreadyInitialized(): boolean { + const hub = getCurrentHub(); + return !!hub.getClient(); +} + +/** Initializes Sentry Remix SDK on Node. */ +export function init(options: RemixOptions): void { + buildMetadata(options, ['remix', 'node']); + + if (sdkAlreadyInitialized()) { + __DEBUG_BUILD__ && logger.log('SDK already initialized'); + + return; + } + + instrumentServer(); + + nodeInit(options); + + configureScope(scope => { + scope.setTag('runtime', 'node'); + }); +} diff --git a/packages/remix/src/index.ts b/packages/remix/src/index.ts deleted file mode 100644 index f56038ae9853..000000000000 --- a/packages/remix/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { remixRouterInstrumentation, withSentryRouteTracing } from './performance/client'; -export { BrowserTracing, Integrations } from '@sentry/tracing'; diff --git a/packages/remix/src/utils/instrumentServer.ts b/packages/remix/src/utils/instrumentServer.ts new file mode 100644 index 000000000000..486ab06b149d --- /dev/null +++ b/packages/remix/src/utils/instrumentServer.ts @@ -0,0 +1,197 @@ +import { captureException, configureScope, getCurrentHub, startTransaction } from '@sentry/node'; +import { getActiveTransaction } from '@sentry/tracing'; +import { addExceptionMechanism, fill, loadModule, logger } from '@sentry/utils'; + +// Types vendored from @remix-run/server-runtime@1.6.0: +// https://github.com/remix-run/remix/blob/f3691d51027b93caa3fd2cdfe146d7b62a6eb8f2/packages/remix-server-runtime/server.ts +type AppLoadContext = unknown; +type AppData = unknown; +type RequestHandler = (request: Request, loadContext?: AppLoadContext) => Promise; +type CreateRequestHandlerFunction = (build: ServerBuild, mode?: string) => RequestHandler; +type ServerRouteManifest = RouteManifest>; +type Params = { + readonly [key in Key]: string | undefined; +}; + +interface Route { + index?: boolean; + caseSensitive?: boolean; + id: string; + parentId?: string; + path?: string; +} + +interface ServerRouteModule { + action?: DataFunction; + headers?: unknown; + loader?: DataFunction; +} + +interface ServerRoute extends Route { + children: ServerRoute[]; + module: ServerRouteModule; +} + +interface RouteManifest { + [routeId: string]: Route; +} + +interface ServerBuild { + entry: { + module: ServerEntryModule; + }; + routes: ServerRouteManifest; + assets: unknown; +} + +interface HandleDocumentRequestFunction { + (request: Request, responseStatusCode: number, responseHeaders: Headers, context: Record): + | Promise + | Response; +} + +interface HandleDataRequestFunction { + (response: Response, args: DataFunctionArgs): Promise | Response; +} + +interface ServerEntryModule { + default: HandleDocumentRequestFunction; + handleDataRequest?: HandleDataRequestFunction; +} + +interface DataFunctionArgs { + request: Request; + context: AppLoadContext; + params: Params; +} + +interface DataFunction { + (args: DataFunctionArgs): Promise | Response | Promise | AppData; +} + +function makeWrappedDataFunction(origFn: DataFunction, name: 'action' | 'loader'): DataFunction { + return async function (this: unknown, args: DataFunctionArgs): Promise { + let res: Response | AppData; + const activeTransaction = getActiveTransaction(); + const currentScope = getCurrentHub().getScope(); + + if (!activeTransaction || !currentScope) { + return origFn.call(this, args); + } + + try { + const span = activeTransaction.startChild({ + op: `remix.server.${name}`, + description: activeTransaction.name, + tags: { + name, + }, + }); + + if (span) { + // Assign data function to hub to be able to see `db` transactions (if any) as children. + currentScope.setSpan(span); + } + + res = await origFn.call(this, args); + + currentScope.setSpan(activeTransaction); + span.finish(); + } catch (err) { + configureScope(scope => { + scope.addEventProcessor(event => { + addExceptionMechanism(event, { + type: 'instrument', + handled: true, + data: { + function: name, + }, + }); + + return event; + }); + }); + + captureException(err); + + // Rethrow for other handlers + throw err; + } + + return res; + }; +} + +function makeWrappedAction(origAction: DataFunction): DataFunction { + return makeWrappedDataFunction(origAction, 'action'); +} + +function makeWrappedLoader(origAction: DataFunction): DataFunction { + return makeWrappedDataFunction(origAction, 'loader'); +} + +function wrapRequestHandler(origRequestHandler: RequestHandler): RequestHandler { + return async function (this: unknown, request: Request, loadContext?: unknown): Promise { + const currentScope = getCurrentHub().getScope(); + const transaction = startTransaction({ + name: request.url, + op: 'http.server', + tags: { + method: request.method, + }, + }); + + if (transaction) { + currentScope?.setSpan(transaction); + } + + const res = (await origRequestHandler.call(this, request, loadContext)) as Response; + + transaction?.setHttpStatus(res.status); + transaction?.finish(); + + return res; + }; +} + +function makeWrappedCreateRequestHandler( + origCreateRequestHandler: CreateRequestHandlerFunction, +): CreateRequestHandlerFunction { + return function (this: unknown, build: ServerBuild, mode: string | undefined): RequestHandler { + const routes: ServerRouteManifest = {}; + + for (const [id, route] of Object.entries(build.routes)) { + const wrappedRoute = { ...route, module: { ...route.module } }; + + if (wrappedRoute.module.action) { + fill(wrappedRoute.module, 'action', makeWrappedAction); + } + + if (wrappedRoute.module.loader) { + fill(wrappedRoute.module, 'loader', makeWrappedLoader); + } + + routes[id] = wrappedRoute; + } + + const requestHandler = origCreateRequestHandler.call(this, { ...build, routes }, mode); + + return wrapRequestHandler(requestHandler); + }; +} + +/** + * Monkey-patch Remix's `createRequestHandler` from `@remix-run/server-runtime` + * which Remix Adapters (https://remix.run/docs/en/v1/api/remix) use underneath. + */ +export function instrumentServer(): void { + const pkg = loadModule<{ createRequestHandler: CreateRequestHandlerFunction }>('@remix-run/server-runtime'); + + if (!pkg) { + __DEBUG_BUILD__ && logger.warn('Remix SDK was unable to require `@remix-run/server-runtime` package.'); + + return; + } + + fill(pkg, 'createRequestHandler', makeWrappedCreateRequestHandler); +} diff --git a/packages/remix/src/utils/remixOptions.ts b/packages/remix/src/utils/remixOptions.ts index 74d67074b0a3..9534ed57de3b 100644 --- a/packages/remix/src/utils/remixOptions.ts +++ b/packages/remix/src/utils/remixOptions.ts @@ -1,4 +1,5 @@ +import { NodeOptions } from '@sentry/node'; import { BrowserOptions } from '@sentry/react'; import { Options } from '@sentry/types'; -export type RemixOptions = Options | BrowserOptions; +export type RemixOptions = Options | BrowserOptions | NodeOptions; diff --git a/packages/remix/test/index.server.test.ts b/packages/remix/test/index.server.test.ts new file mode 100644 index 000000000000..2bfe118fcc5f --- /dev/null +++ b/packages/remix/test/index.server.test.ts @@ -0,0 +1,62 @@ +import * as SentryNode from '@sentry/node'; +import { getCurrentHub } from '@sentry/node'; +import { getGlobalObject } from '@sentry/utils'; + +import { init } from '../src/index.server'; + +const global = getGlobalObject(); + +const nodeInit = jest.spyOn(SentryNode, 'init'); + +describe('Server init()', () => { + afterEach(() => { + jest.clearAllMocks(); + global.__SENTRY__.hub = undefined; + }); + + it('inits the Node SDK', () => { + expect(nodeInit).toHaveBeenCalledTimes(0); + init({}); + expect(nodeInit).toHaveBeenCalledTimes(1); + expect(nodeInit).toHaveBeenLastCalledWith( + expect.objectContaining({ + _metadata: { + sdk: { + name: 'sentry.javascript.remix', + version: expect.any(String), + packages: [ + { + name: 'npm:@sentry/remix', + version: expect.any(String), + }, + { + name: 'npm:@sentry/node', + version: expect.any(String), + }, + ], + }, + }, + }), + ); + }); + + it("doesn't reinitialize the node SDK if already initialized", () => { + expect(nodeInit).toHaveBeenCalledTimes(0); + init({}); + expect(nodeInit).toHaveBeenCalledTimes(1); + init({}); + expect(nodeInit).toHaveBeenCalledTimes(1); + }); + + 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: 'node' }); + }); +});