Skip to content

Commit d60b826

Browse files
committed
fix(core, node): support loading Express options lazily (#20211)
Update the Express integration to accept the module export and a configuration function, rather than a configuration object. This is needed to support lazily calling Sentry.init *after* the module has been instrumented, without re-wrapping the methods to get the new config. via: @mydea in #20188
1 parent 7a9837c commit d60b826

File tree

9 files changed

+219
-62
lines changed

9 files changed

+219
-62
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
// First: preload the express instrumentation without calling Sentry.init().
5+
// registers OTel module hook, patches the Express module with no config.
6+
Sentry.preloadOpenTelemetry({ integrations: ['Express'] });
7+
8+
// call Sentry.init() with express integration config.
9+
// instrumentExpress is already registered, so this calls setConfig() on the
10+
// existing instrumentation to update its options. The lazy getOptions()
11+
// in patchLayer ensures the updated options are read at request time.
12+
Sentry.init({
13+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
14+
release: '1.0',
15+
tracesSampleRate: 1.0,
16+
transport: loggingTransport,
17+
// suppress the middleware layer that the cors module generates
18+
integrations: [Sentry.expressIntegration({ ignoreLayersType: ['middleware'] })],
19+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import cors from 'cors';
2+
import express from 'express';
3+
import * as Sentry from '@sentry/node';
4+
import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests';
5+
6+
const app = express();
7+
8+
// cors() would normally create a 'middleware' type span, but the
9+
// ignoreLayersType: ['middleware'] option set via Sentry.init() suppresses it.
10+
app.use(cors());
11+
12+
app.get('/test/express', (_req, res) => {
13+
res.send({ response: 'response 1' });
14+
});
15+
16+
Sentry.setupExpressErrorHandler(app);
17+
18+
startExpressServerAndSendPortToRunner(app);
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { afterAll, describe, expect } from 'vitest';
2+
import { assertSentryTransaction } from '../../../utils/assertions';
3+
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
4+
5+
describe('express late init', () => {
6+
afterAll(() => {
7+
cleanupChildProcesses();
8+
});
9+
10+
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
11+
test('applies expressIntegration config set via Sentry.init() called after instrumentExpress()', async () => {
12+
const runner = createRunner()
13+
.expect({
14+
transaction: transaction => {
15+
assertSentryTransaction(transaction, {
16+
transaction: 'GET /test/express',
17+
contexts: {
18+
trace: {
19+
op: 'http.server',
20+
status: 'ok',
21+
},
22+
},
23+
});
24+
// request_handler span IS present
25+
// confirms the express patch was applied.
26+
expect(transaction.spans).toContainEqual(
27+
expect.objectContaining({
28+
data: expect.objectContaining({
29+
'express.type': 'request_handler',
30+
}),
31+
}),
32+
);
33+
// Middleware spans NOT present, ignoreLayersType: ['middleware']
34+
// configured via the Sentry.init() AFTER instrumentExpress().
35+
expect(transaction.spans).not.toContainEqual(
36+
expect.objectContaining({
37+
data: expect.objectContaining({
38+
'express.type': 'middleware',
39+
}),
40+
}),
41+
);
42+
},
43+
})
44+
.start();
45+
runner.makeRequest('get', '/test/express');
46+
await runner.completed();
47+
});
48+
});
49+
});

packages/core/src/integrations/express/index.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,24 @@ import { setSDKProcessingMetadata } from './set-sdk-processing-metadata';
6060
const getExpressExport = (express: ExpressModuleExport): ExpressExport =>
6161
hasDefaultProp(express) ? express.default : (express as ExpressExport);
6262

63+
function isLegacyOptions(
64+
options: ExpressModuleExport | (ExpressIntegrationOptions & { express: ExpressModuleExport }),
65+
): options is ExpressIntegrationOptions & { express: ExpressModuleExport } {
66+
return !!(options as { express: ExpressModuleExport }).express;
67+
}
68+
69+
// TODO: remove this deprecation handling in v11
70+
let didLegacyDeprecationWarning = false;
71+
function deprecationWarning() {
72+
if (!didLegacyDeprecationWarning) {
73+
didLegacyDeprecationWarning = true;
74+
DEBUG_BUILD &&
75+
debug.warn(
76+
'[Express] `patchExpressModule(options)` is deprecated. Use `patchExpressModule(moduleExports, getOptions)` instead.',
77+
);
78+
}
79+
}
80+
6381
/**
6482
* This is a portable instrumentatiton function that works in any environment
6583
* where Express can be loaded, without depending on OpenTelemetry.
@@ -69,11 +87,39 @@ const getExpressExport = (express: ExpressModuleExport): ExpressExport =>
6987
* import express from 'express';
7088
* import * as Sentry from '@sentry/deno'; // or any SDK that extends core
7189
*
72-
* Sentry.patchExpressModule({ express })
90+
* Sentry.patchExpressModule(express, () => ({}));
91+
* ```
7392
*/
74-
export const patchExpressModule = (options: ExpressIntegrationOptions) => {
93+
export function patchExpressModule(
94+
moduleExports: ExpressModuleExport,
95+
getOptions: () => ExpressIntegrationOptions,
96+
): ExpressModuleExport;
97+
/**
98+
* @deprecated Pass the Express module export as the first argument and options getter as the second argument.
99+
*/
100+
export function patchExpressModule(
101+
options: ExpressIntegrationOptions & { express: ExpressModuleExport },
102+
): ExpressModuleExport;
103+
export function patchExpressModule(
104+
optionsOrExports: ExpressModuleExport | (ExpressIntegrationOptions & { express: ExpressModuleExport }),
105+
maybeGetOptions?: () => ExpressIntegrationOptions,
106+
): ExpressModuleExport {
107+
let getOptions: () => ExpressIntegrationOptions;
108+
let moduleExports: ExpressModuleExport;
109+
if (!maybeGetOptions && isLegacyOptions(optionsOrExports)) {
110+
const { express, ...options } = optionsOrExports;
111+
moduleExports = express;
112+
getOptions = () => options;
113+
deprecationWarning();
114+
} else if (typeof maybeGetOptions !== 'function') {
115+
throw new TypeError('`patchExpressModule(moduleExports, getOptions)` requires a `getOptions` callback');
116+
} else {
117+
getOptions = maybeGetOptions;
118+
moduleExports = optionsOrExports as ExpressModuleExport;
119+
}
120+
75121
// pass in the require() or import() result of express
76-
const express = getExpressExport(options.express);
122+
const express = getExpressExport(moduleExports);
77123
const routerProto: ExpressRouterv4 | ExpressRouterv5 | undefined = isExpressWithRouterPrototype(express)
78124
? express.Router.prototype // Express v5
79125
: isExpressWithoutRouterPrototype(express)
@@ -93,7 +139,7 @@ export const patchExpressModule = (options: ExpressIntegrationOptions) => {
93139
function routeTrace(this: ExpressRouter, ...args: Parameters<typeof originalRouteMethod>[]) {
94140
const route = originalRouteMethod.apply(this, args);
95141
const layer = this.stack[this.stack.length - 1] as ExpressLayer;
96-
patchLayer(options, layer, getLayerPath(args));
142+
patchLayer(getOptions, layer, getLayerPath(args));
97143
return route;
98144
},
99145
);
@@ -113,7 +159,7 @@ export const patchExpressModule = (options: ExpressIntegrationOptions) => {
113159
if (!layer) {
114160
return route;
115161
}
116-
patchLayer(options, layer, getLayerPath(args));
162+
patchLayer(getOptions, layer, getLayerPath(args));
117163
return route;
118164
},
119165
);
@@ -141,7 +187,7 @@ export const patchExpressModule = (options: ExpressIntegrationOptions) => {
141187
if (router) {
142188
const layer = router.stack[router.stack.length - 1];
143189
if (layer) {
144-
patchLayer(options, layer, getLayerPath(args));
190+
patchLayer(getOptions, layer, getLayerPath(args));
145191
}
146192
}
147193
return route;
@@ -152,7 +198,7 @@ export const patchExpressModule = (options: ExpressIntegrationOptions) => {
152198
}
153199

154200
return express;
155-
};
201+
}
156202

157203
/**
158204
* An Express-compatible error handler, used by setupExpressErrorHandler

packages/core/src/integrations/express/patch-layer.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,11 @@ export type ExpressPatchLayerOptions = Pick<
6161
'onRouteResolved' | 'ignoreLayers' | 'ignoreLayersType'
6262
>;
6363

64-
export function patchLayer(options: ExpressPatchLayerOptions, maybeLayer?: ExpressLayer, layerPath?: string): void {
64+
export function patchLayer(
65+
getOptions: () => ExpressPatchLayerOptions,
66+
maybeLayer?: ExpressLayer,
67+
layerPath?: string,
68+
): void {
6569
if (!maybeLayer?.handle) {
6670
return;
6771
}
@@ -86,6 +90,8 @@ export function patchLayer(options: ExpressPatchLayerOptions, maybeLayer?: Expre
8690
//oxlint-disable-next-line no-explicit-any
8791
...otherArgs: any[]
8892
) {
93+
const options = getOptions();
94+
8995
// Set normalizedRequest here because expressRequestHandler middleware
9096
// (registered via setupExpressErrorHandler) is added after routes and
9197
// therefore never runs for successful requests — route handlers typically

packages/core/src/integrations/express/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,12 @@ export type ExpressRouter = {
136136
export type IgnoreMatcher = string | RegExp | ((name: string) => boolean);
137137

138138
export type ExpressIntegrationOptions = {
139-
express: ExpressModuleExport; //Express
139+
/**
140+
* @deprecated Pass the express module as the first argument, and an
141+
* options getter as the second argument to patchExpressModule.
142+
*/
143+
express?: ExpressModuleExport;
144+
140145
/** Ignore specific based on their name */
141146
ignoreLayers?: IgnoreMatcher[];
142147
/** Ignore specific layers based on their type */

0 commit comments

Comments
 (0)