Skip to content

Commit 45818f3

Browse files
feat(remix): Add Remix server SDK (#5269)
Adds server side SDK for error tracking / performance tracing of Remix. - Uses Node SDK underneath. - For tracing, monkey-patches `createRequestHandler` from `@remix-run/server-runtime` which apparently is used by all server-side adapters of Remix. - `action` and `loader` functions are patched as parameters of `createRequestHandler`. Co-authored-by: Abhijeet Prasad <[email protected]>
1 parent a1c79cd commit 45818f3

7 files changed

+307
-10
lines changed

packages/remix/package.json

+9-5
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
"engines": {
1010
"node": ">=14"
1111
},
12-
"main": "build/esm/index.js",
13-
"module": "build/esm/index.js",
12+
"main": "build/esm/index.server.js",
13+
"module": "build/esm/index.server.js",
1414
"browser": "build/esm/index.client.js",
15-
"types": "build/types/index.d.ts",
15+
"types": "build/types/index.server.d.ts",
1616
"private": true,
1717
"dependencies": {
1818
"@sentry/core": "7.2.0",
@@ -52,7 +52,7 @@
5252
"build:rollup:watch": "rollup -c rollup.npm.config.js --watch",
5353
"build:types:watch": "tsc -p tsconfig.types.json --watch",
5454
"build:npm": "ts-node ../../scripts/prepack.ts && npm pack ./build",
55-
"circularDepCheck": "madge --circular src/index.ts",
55+
"circularDepCheck": "madge --circular src/index.server.ts",
5656
"clean": "rimraf build coverage sentry-remix-*.tgz",
5757
"fix": "run-s fix:eslint fix:prettier",
5858
"fix:eslint": "eslint . --format stylish --fix",
@@ -66,5 +66,9 @@
6666
},
6767
"volta": {
6868
"extends": "../../package.json"
69-
}
69+
},
70+
"sideEffects": [
71+
"./esm/index.server.js",
72+
"./src/index.server.ts"
73+
]
7074
}

packages/remix/rollup.npm.config.js

+1-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '../../rollup/index.js'
22

33
export default makeNPMConfigVariants(
44
makeBaseNPMConfig({
5-
// Todo: Replace with -> ['src/index.server.ts', 'src/index.client.tsx'],
6-
entrypoints: 'src/index.ts',
5+
entrypoints: ['src/index.server.ts', 'src/index.client.tsx'],
76
}),
87
);

packages/remix/src/index.server.ts

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/* eslint-disable import/export */
2+
import { configureScope, getCurrentHub, init as nodeInit } from '@sentry/node';
3+
import { logger } from '@sentry/utils';
4+
5+
import { instrumentServer } from './utils/instrumentServer';
6+
import { buildMetadata } from './utils/metadata';
7+
import { RemixOptions } from './utils/remixOptions';
8+
9+
export { ErrorBoundary, withErrorBoundary } from '@sentry/react';
10+
export { remixRouterInstrumentation, withSentryRouteTracing } from './performance/client';
11+
export { BrowserTracing, Integrations } from '@sentry/tracing';
12+
export * from '@sentry/node';
13+
14+
function sdkAlreadyInitialized(): boolean {
15+
const hub = getCurrentHub();
16+
return !!hub.getClient();
17+
}
18+
19+
/** Initializes Sentry Remix SDK on Node. */
20+
export function init(options: RemixOptions): void {
21+
buildMetadata(options, ['remix', 'node']);
22+
23+
if (sdkAlreadyInitialized()) {
24+
__DEBUG_BUILD__ && logger.log('SDK already initialized');
25+
26+
return;
27+
}
28+
29+
instrumentServer();
30+
31+
nodeInit(options);
32+
33+
configureScope(scope => {
34+
scope.setTag('runtime', 'node');
35+
});
36+
}

packages/remix/src/index.ts

-2
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { captureException, configureScope, getCurrentHub, startTransaction } from '@sentry/node';
2+
import { getActiveTransaction } from '@sentry/tracing';
3+
import { addExceptionMechanism, fill, loadModule, logger } from '@sentry/utils';
4+
5+
// Types vendored from @remix-run/[email protected]:
6+
// https://github.com/remix-run/remix/blob/f3691d51027b93caa3fd2cdfe146d7b62a6eb8f2/packages/remix-server-runtime/server.ts
7+
type AppLoadContext = unknown;
8+
type AppData = unknown;
9+
type RequestHandler = (request: Request, loadContext?: AppLoadContext) => Promise<Response>;
10+
type CreateRequestHandlerFunction = (build: ServerBuild, mode?: string) => RequestHandler;
11+
type ServerRouteManifest = RouteManifest<Omit<ServerRoute, 'children'>>;
12+
type Params<Key extends string = string> = {
13+
readonly [key in Key]: string | undefined;
14+
};
15+
16+
interface Route {
17+
index?: boolean;
18+
caseSensitive?: boolean;
19+
id: string;
20+
parentId?: string;
21+
path?: string;
22+
}
23+
24+
interface ServerRouteModule {
25+
action?: DataFunction;
26+
headers?: unknown;
27+
loader?: DataFunction;
28+
}
29+
30+
interface ServerRoute extends Route {
31+
children: ServerRoute[];
32+
module: ServerRouteModule;
33+
}
34+
35+
interface RouteManifest<Route> {
36+
[routeId: string]: Route;
37+
}
38+
39+
interface ServerBuild {
40+
entry: {
41+
module: ServerEntryModule;
42+
};
43+
routes: ServerRouteManifest;
44+
assets: unknown;
45+
}
46+
47+
interface HandleDocumentRequestFunction {
48+
(request: Request, responseStatusCode: number, responseHeaders: Headers, context: Record<symbol, unknown>):
49+
| Promise<Response>
50+
| Response;
51+
}
52+
53+
interface HandleDataRequestFunction {
54+
(response: Response, args: DataFunctionArgs): Promise<Response> | Response;
55+
}
56+
57+
interface ServerEntryModule {
58+
default: HandleDocumentRequestFunction;
59+
handleDataRequest?: HandleDataRequestFunction;
60+
}
61+
62+
interface DataFunctionArgs {
63+
request: Request;
64+
context: AppLoadContext;
65+
params: Params;
66+
}
67+
68+
interface DataFunction {
69+
(args: DataFunctionArgs): Promise<Response> | Response | Promise<AppData> | AppData;
70+
}
71+
72+
function makeWrappedDataFunction(origFn: DataFunction, name: 'action' | 'loader'): DataFunction {
73+
return async function (this: unknown, args: DataFunctionArgs): Promise<Response | AppData> {
74+
let res: Response | AppData;
75+
const activeTransaction = getActiveTransaction();
76+
const currentScope = getCurrentHub().getScope();
77+
78+
if (!activeTransaction || !currentScope) {
79+
return origFn.call(this, args);
80+
}
81+
82+
try {
83+
const span = activeTransaction.startChild({
84+
op: `remix.server.${name}`,
85+
description: activeTransaction.name,
86+
tags: {
87+
name,
88+
},
89+
});
90+
91+
if (span) {
92+
// Assign data function to hub to be able to see `db` transactions (if any) as children.
93+
currentScope.setSpan(span);
94+
}
95+
96+
res = await origFn.call(this, args);
97+
98+
currentScope.setSpan(activeTransaction);
99+
span.finish();
100+
} catch (err) {
101+
configureScope(scope => {
102+
scope.addEventProcessor(event => {
103+
addExceptionMechanism(event, {
104+
type: 'instrument',
105+
handled: true,
106+
data: {
107+
function: name,
108+
},
109+
});
110+
111+
return event;
112+
});
113+
});
114+
115+
captureException(err);
116+
117+
// Rethrow for other handlers
118+
throw err;
119+
}
120+
121+
return res;
122+
};
123+
}
124+
125+
function makeWrappedAction(origAction: DataFunction): DataFunction {
126+
return makeWrappedDataFunction(origAction, 'action');
127+
}
128+
129+
function makeWrappedLoader(origAction: DataFunction): DataFunction {
130+
return makeWrappedDataFunction(origAction, 'loader');
131+
}
132+
133+
function wrapRequestHandler(origRequestHandler: RequestHandler): RequestHandler {
134+
return async function (this: unknown, request: Request, loadContext?: unknown): Promise<Response> {
135+
const currentScope = getCurrentHub().getScope();
136+
const transaction = startTransaction({
137+
name: request.url,
138+
op: 'http.server',
139+
tags: {
140+
method: request.method,
141+
},
142+
});
143+
144+
if (transaction) {
145+
currentScope?.setSpan(transaction);
146+
}
147+
148+
const res = (await origRequestHandler.call(this, request, loadContext)) as Response;
149+
150+
transaction?.setHttpStatus(res.status);
151+
transaction?.finish();
152+
153+
return res;
154+
};
155+
}
156+
157+
function makeWrappedCreateRequestHandler(
158+
origCreateRequestHandler: CreateRequestHandlerFunction,
159+
): CreateRequestHandlerFunction {
160+
return function (this: unknown, build: ServerBuild, mode: string | undefined): RequestHandler {
161+
const routes: ServerRouteManifest = {};
162+
163+
for (const [id, route] of Object.entries(build.routes)) {
164+
const wrappedRoute = { ...route, module: { ...route.module } };
165+
166+
if (wrappedRoute.module.action) {
167+
fill(wrappedRoute.module, 'action', makeWrappedAction);
168+
}
169+
170+
if (wrappedRoute.module.loader) {
171+
fill(wrappedRoute.module, 'loader', makeWrappedLoader);
172+
}
173+
174+
routes[id] = wrappedRoute;
175+
}
176+
177+
const requestHandler = origCreateRequestHandler.call(this, { ...build, routes }, mode);
178+
179+
return wrapRequestHandler(requestHandler);
180+
};
181+
}
182+
183+
/**
184+
* Monkey-patch Remix's `createRequestHandler` from `@remix-run/server-runtime`
185+
* which Remix Adapters (https://remix.run/docs/en/v1/api/remix) use underneath.
186+
*/
187+
export function instrumentServer(): void {
188+
const pkg = loadModule<{ createRequestHandler: CreateRequestHandlerFunction }>('@remix-run/server-runtime');
189+
190+
if (!pkg) {
191+
__DEBUG_BUILD__ && logger.warn('Remix SDK was unable to require `@remix-run/server-runtime` package.');
192+
193+
return;
194+
}
195+
196+
fill(pkg, 'createRequestHandler', makeWrappedCreateRequestHandler);
197+
}
+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1+
import { NodeOptions } from '@sentry/node';
12
import { BrowserOptions } from '@sentry/react';
23
import { Options } from '@sentry/types';
34

4-
export type RemixOptions = Options | BrowserOptions;
5+
export type RemixOptions = Options | BrowserOptions | NodeOptions;
+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import * as SentryNode from '@sentry/node';
2+
import { getCurrentHub } from '@sentry/node';
3+
import { getGlobalObject } from '@sentry/utils';
4+
5+
import { init } from '../src/index.server';
6+
7+
const global = getGlobalObject();
8+
9+
const nodeInit = jest.spyOn(SentryNode, 'init');
10+
11+
describe('Server init()', () => {
12+
afterEach(() => {
13+
jest.clearAllMocks();
14+
global.__SENTRY__.hub = undefined;
15+
});
16+
17+
it('inits the Node SDK', () => {
18+
expect(nodeInit).toHaveBeenCalledTimes(0);
19+
init({});
20+
expect(nodeInit).toHaveBeenCalledTimes(1);
21+
expect(nodeInit).toHaveBeenLastCalledWith(
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/node',
34+
version: expect.any(String),
35+
},
36+
],
37+
},
38+
},
39+
}),
40+
);
41+
});
42+
43+
it("doesn't reinitialize the node SDK if already initialized", () => {
44+
expect(nodeInit).toHaveBeenCalledTimes(0);
45+
init({});
46+
expect(nodeInit).toHaveBeenCalledTimes(1);
47+
init({});
48+
expect(nodeInit).toHaveBeenCalledTimes(1);
49+
});
50+
51+
it('sets runtime on scope', () => {
52+
const currentScope = getCurrentHub().getScope();
53+
54+
// @ts-ignore need access to protected _tags attribute
55+
expect(currentScope._tags).toEqual({});
56+
57+
init({});
58+
59+
// @ts-ignore need access to protected _tags attribute
60+
expect(currentScope._tags).toEqual({ runtime: 'node' });
61+
});
62+
});

0 commit comments

Comments
 (0)