Skip to content

Commit 2a4bf1d

Browse files
onurtemizkananonrig
authored andcommitted
ref(remix): Isolate Express instrumentation from server auto-instrumentation. (#9966)
1 parent 8f6dd08 commit 2a4bf1d

File tree

5 files changed

+82
-59
lines changed

5 files changed

+82
-59
lines changed

packages/remix/src/utils/instrumentServer.ts

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from './vendor/response';
2929
import type {
3030
AppData,
31+
AppLoadContext,
3132
CreateRequestHandlerFunction,
3233
DataFunction,
3334
DataFunctionArgs,
@@ -46,9 +47,6 @@ import { normalizeRemixRequest } from './web-fetch';
4647
let FUTURE_FLAGS: FutureConfig | undefined;
4748
let IS_REMIX_V2: boolean | undefined;
4849

49-
// Flag to track if the core request handler is instrumented.
50-
export let isRequestHandlerWrapped = false;
51-
5250
const redirectStatusCodes = new Set([301, 302, 303, 307, 308]);
5351
function isRedirectResponse(response: Response): boolean {
5452
return redirectStatusCodes.has(response.status);
@@ -222,8 +220,13 @@ function makeWrappedDataFunction(
222220
id: string,
223221
name: 'action' | 'loader',
224222
remixVersion: number,
223+
manuallyInstrumented: boolean,
225224
): DataFunction {
226225
return async function (this: unknown, args: DataFunctionArgs): Promise<Response | AppData> {
226+
if (args.context.__sentry_express_wrapped__ && !manuallyInstrumented) {
227+
return origFn.call(this, args);
228+
}
229+
227230
let res: Response | AppData;
228231
const activeTransaction = getActiveTransaction();
229232
const currentScope = getCurrentScope();
@@ -265,15 +268,15 @@ function makeWrappedDataFunction(
265268
}
266269

267270
const makeWrappedAction =
268-
(id: string, remixVersion: number) =>
271+
(id: string, remixVersion: number, manuallyInstrumented: boolean) =>
269272
(origAction: DataFunction): DataFunction => {
270-
return makeWrappedDataFunction(origAction, id, 'action', remixVersion);
273+
return makeWrappedDataFunction(origAction, id, 'action', remixVersion, manuallyInstrumented);
271274
};
272275

273276
const makeWrappedLoader =
274-
(id: string, remixVersion: number) =>
277+
(id: string, remixVersion: number, manuallyInstrumented: boolean) =>
275278
(origLoader: DataFunction): DataFunction => {
276-
return makeWrappedDataFunction(origLoader, id, 'loader', remixVersion);
279+
return makeWrappedDataFunction(origLoader, id, 'loader', remixVersion, manuallyInstrumented);
277280
};
278281

279282
function getTraceAndBaggage(): {
@@ -419,7 +422,13 @@ function wrapRequestHandler(origRequestHandler: RequestHandler, build: ServerBui
419422
const routes = createRoutes(build.routes);
420423
const pkg = loadModule<ReactRouterDomPkg>('react-router-dom');
421424

422-
return async function (this: unknown, request: RemixRequest, loadContext?: unknown): Promise<Response> {
425+
return async function (this: unknown, request: RemixRequest, loadContext?: AppLoadContext): Promise<Response> {
426+
// This means that the request handler of the adapter (ex: express) is already wrapped.
427+
// So we don't want to double wrap it.
428+
if (loadContext?.__sentry_express_wrapped__) {
429+
return origRequestHandler.call(this, request, loadContext);
430+
}
431+
423432
return runWithAsyncContext(async () => {
424433
const hub = getCurrentHub();
425434
const options = getClient()?.getOptions();
@@ -473,7 +482,7 @@ function wrapRequestHandler(origRequestHandler: RequestHandler, build: ServerBui
473482
/**
474483
* Instruments `remix` ServerBuild for performance tracing and error tracking.
475484
*/
476-
export function instrumentBuild(build: ServerBuild): ServerBuild {
485+
export function instrumentBuild(build: ServerBuild, manuallyInstrumented: boolean = false): ServerBuild {
477486
const routes: ServerRouteManifest = {};
478487

479488
const remixVersion = getRemixVersionFromBuild(build);
@@ -495,12 +504,12 @@ export function instrumentBuild(build: ServerBuild): ServerBuild {
495504

496505
const routeAction = wrappedRoute.module.action as undefined | WrappedFunction;
497506
if (routeAction && !routeAction.__sentry_original__) {
498-
fill(wrappedRoute.module, 'action', makeWrappedAction(id, remixVersion));
507+
fill(wrappedRoute.module, 'action', makeWrappedAction(id, remixVersion, manuallyInstrumented));
499508
}
500509

501510
const routeLoader = wrappedRoute.module.loader as undefined | WrappedFunction;
502511
if (routeLoader && !routeLoader.__sentry_original__) {
503-
fill(wrappedRoute.module, 'loader', makeWrappedLoader(id, remixVersion));
512+
fill(wrappedRoute.module, 'loader', makeWrappedLoader(id, remixVersion, manuallyInstrumented));
504513
}
505514

506515
// Entry module should have a loader function to provide `sentry-trace` and `baggage`
@@ -523,13 +532,9 @@ export function instrumentBuild(build: ServerBuild): ServerBuild {
523532
function makeWrappedCreateRequestHandler(
524533
origCreateRequestHandler: CreateRequestHandlerFunction,
525534
): CreateRequestHandlerFunction {
526-
// To track if this wrapper has been applied, before other wrappers.
527-
// Can't track `__sentry_original__` because it's not the same function as the potentially manually wrapped one.
528-
isRequestHandlerWrapped = true;
529-
530535
return function (this: unknown, build: ServerBuild, ...args: unknown[]): RequestHandler {
531536
FUTURE_FLAGS = getFutureFlagsServer(build);
532-
const newBuild = instrumentBuild(build);
537+
const newBuild = instrumentBuild(build, false);
533538
const requestHandler = origCreateRequestHandler.call(this, newBuild, ...args);
534539

535540
return wrapRequestHandler(requestHandler, newBuild);

packages/remix/src/utils/serverAdapters/express.ts

Lines changed: 52 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,20 @@
1-
import { getClient, getCurrentHub, getCurrentScope, hasTracingEnabled } from '@sentry/core';
1+
import { getClient, getCurrentHub, getCurrentScope, hasTracingEnabled, runWithAsyncContext } from '@sentry/core';
22
import { flush } from '@sentry/node';
33
import type { Transaction } from '@sentry/types';
4-
import { extractRequestData, isString, logger } from '@sentry/utils';
4+
import { extractRequestData, fill, isString, logger } from '@sentry/utils';
55
import { cwd } from 'process';
66

77
import { DEBUG_BUILD } from '../debug-build';
8-
import {
9-
createRoutes,
10-
getTransactionName,
11-
instrumentBuild,
12-
isRequestHandlerWrapped,
13-
startRequestHandlerTransaction,
14-
} from '../instrumentServer';
8+
import { createRoutes, getTransactionName, instrumentBuild, startRequestHandlerTransaction } from '../instrumentServer';
159
import type {
10+
AppLoadContext,
1611
ExpressCreateRequestHandler,
1712
ExpressCreateRequestHandlerOptions,
1813
ExpressNextFunction,
1914
ExpressRequest,
2015
ExpressRequestHandler,
2116
ExpressResponse,
17+
GetLoadContextFunction,
2218
ReactRouterDomPkg,
2319
ServerBuild,
2420
} from '../vendor/types';
@@ -31,11 +27,6 @@ function wrapExpressRequestHandler(
3127
): ExpressRequestHandler {
3228
const routes = createRoutes(build.routes);
3329

34-
// If the core request handler is already wrapped, don't wrap Express handler which uses it.
35-
if (isRequestHandlerWrapped) {
36-
return origRequestHandler;
37-
}
38-
3930
return async function (
4031
this: unknown,
4132
req: ExpressRequest,
@@ -54,33 +45,46 @@ function wrapExpressRequestHandler(
5445
}
5546
}
5647

57-
// eslint-disable-next-line @typescript-eslint/unbound-method
58-
res.end = wrapEndMethod(res.end);
48+
await runWithAsyncContext(async () => {
49+
// eslint-disable-next-line @typescript-eslint/unbound-method
50+
res.end = wrapEndMethod(res.end);
5951

60-
const request = extractRequestData(req);
61-
const hub = getCurrentHub();
62-
const options = getClient()?.getOptions();
63-
const scope = getCurrentScope();
52+
const request = extractRequestData(req);
53+
const hub = getCurrentHub();
54+
const options = getClient()?.getOptions();
55+
const scope = getCurrentScope();
6456

65-
scope.setSDKProcessingMetadata({ request });
57+
scope.setSDKProcessingMetadata({ request });
6658

67-
if (!options || !hasTracingEnabled(options) || !request.url || !request.method) {
68-
return origRequestHandler.call(this, req, res, next);
69-
}
59+
if (!options || !hasTracingEnabled(options) || !request.url || !request.method) {
60+
return origRequestHandler.call(this, req, res, next);
61+
}
7062

71-
const url = new URL(request.url);
72-
const [name, source] = getTransactionName(routes, url, pkg);
73-
const transaction = startRequestHandlerTransaction(hub, name, source, {
74-
headers: {
75-
'sentry-trace': (req.headers && isString(req.headers['sentry-trace']) && req.headers['sentry-trace']) || '',
76-
baggage: (req.headers && isString(req.headers.baggage) && req.headers.baggage) || '',
77-
},
78-
method: request.method,
63+
const url = new URL(request.url);
64+
65+
const [name, source] = getTransactionName(routes, url, pkg);
66+
const transaction = startRequestHandlerTransaction(hub, name, source, {
67+
headers: {
68+
'sentry-trace': (req.headers && isString(req.headers['sentry-trace']) && req.headers['sentry-trace']) || '',
69+
baggage: (req.headers && isString(req.headers.baggage) && req.headers.baggage) || '',
70+
},
71+
method: request.method,
72+
});
73+
// save a link to the transaction on the response, so that even if there's an error (landing us outside of
74+
// the domain), we can still finish it (albeit possibly missing some scope data)
75+
(res as AugmentedExpressResponse).__sentryTransaction = transaction;
76+
return origRequestHandler.call(this, req, res, next);
7977
});
80-
// save a link to the transaction on the response, so that even if there's an error (landing us outside of
81-
// the domain), we can still finish it (albeit possibly missing some scope data)
82-
(res as AugmentedExpressResponse).__sentryTransaction = transaction;
83-
return origRequestHandler.call(this, req, res, next);
78+
};
79+
}
80+
81+
function wrapGetLoadContext(origGetLoadContext: () => AppLoadContext): GetLoadContextFunction {
82+
return function (this: unknown, req: ExpressRequest, res: ExpressResponse): AppLoadContext {
83+
const loadContext = (origGetLoadContext.call(this, req, res) || {}) as AppLoadContext;
84+
85+
loadContext['__sentry_express_wrapped__'] = true;
86+
87+
return loadContext;
8488
};
8589
}
8690

@@ -92,9 +96,18 @@ export function wrapExpressCreateRequestHandler(
9296
// eslint-disable-next-line @typescript-eslint/no-explicit-any
9397
): (options: any) => ExpressRequestHandler {
9498
// eslint-disable-next-line @typescript-eslint/no-explicit-any
95-
return function (this: unknown, options: any): ExpressRequestHandler {
96-
const newBuild = instrumentBuild((options as ExpressCreateRequestHandlerOptions).build);
97-
const requestHandler = origCreateRequestHandler.call(this, { ...options, build: newBuild });
99+
return function (this: unknown, options: ExpressCreateRequestHandlerOptions): ExpressRequestHandler {
100+
if (!('getLoadContext' in options)) {
101+
options['getLoadContext'] = () => ({});
102+
}
103+
104+
fill(options, 'getLoadContext', wrapGetLoadContext);
105+
106+
const newBuild = instrumentBuild(options.build, true);
107+
const requestHandler = origCreateRequestHandler.call(this, {
108+
...options,
109+
build: newBuild,
110+
});
98111

99112
return wrapExpressRequestHandler(requestHandler, newBuild);
100113
};

packages/remix/src/utils/vendor/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export type RemixRequest = Request &
6464
agent: Agent | ((parsedURL: URL) => Agent) | undefined;
6565
};
6666

67-
export type AppLoadContext = any;
67+
export type AppLoadContext = Record<string, unknown> & { __sentry_express_wrapped__?: boolean };
6868
export type AppData = any;
6969
export type RequestHandler = (request: RemixRequest, loadContext?: AppLoadContext) => Promise<Response>;
7070
export type CreateRequestHandlerFunction = (this: unknown, build: ServerBuild, ...args: any[]) => RequestHandler;
@@ -246,4 +246,4 @@ export interface ExpressCreateRequestHandlerOptions {
246246
mode?: string;
247247
}
248248

249-
type GetLoadContextFunction = (req: any, res: any) => any;
249+
export type GetLoadContextFunction = (req: any, res: any) => AppLoadContext;

packages/remix/test/integration/test/server/loader.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,9 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada
170170
const val = key[key.length - 1];
171171
expect(tags[key]).toEqual(val);
172172
});
173-
});
173+
// express tests tend to take slightly longer on node >= 20
174+
// TODO: check why this is happening
175+
}, 10000);
174176

175177
it('continues transaction from sentry-trace header and baggage', async () => {
176178
const env = await RemixTestEnv.init(adapter);

packages/utils/src/requestdata.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,10 @@ export function extractRequestData(
200200
// express: req.hostname in > 4 and req.host in < 4
201201
// koa: req.host
202202
// node, nextjs: req.headers.host
203-
const host = req.hostname || req.host || headers.host || '<no host>';
203+
// Express 4 mistakenly strips off port number from req.host / req.hostname so we can't rely on them
204+
// See: https://github.com/expressjs/express/issues/3047#issuecomment-236653223
205+
// Also: https://github.com/getsentry/sentry-javascript/issues/1917
206+
const host = headers.host || req.hostname || req.host || '<no host>';
204207
// protocol:
205208
// node, nextjs: <n/a>
206209
// express, koa: req.protocol

0 commit comments

Comments
 (0)