Skip to content

Commit dc1b9cf

Browse files
committed
feat(core, node): portable Express integration
This extracts the functionality from the OTel Express intstrumentation, replacing it with a portable standalone integration in `@sentry/core`, which can be extended and applied to patch any Express module import in whatever way makes sense for the platform in question. Currently in node, that is still an OpenTelemetry intstrumentation, but just handling the automatic module load instrumentation, not the entire tracing integration. This is somewhat a proof of concept, to see what it takes to port a fairly invovled OTel integration into a state where it can support all of the platforms that we care about, but it does impose a bit less of a translation layer between OTel and Sentry semantics (for example, no need to use the no-op `span.recordException()`). The only user-visible change (beyond the additional export in `@sentry/core`, of course) is that spans have an origin of `auto.http.express` rather than `auto.http.otel.express`, since it's no longer technically an otel integration. Obviously this is not a full clean-room reimplementation, and relies on the fact that the opentelemetry-js-contrib project is Apache 2.0 licensed. I included the link to the upstream license in the index file for the Express integration, but there may be a more appropriate way to ensure that the license is respected properly. It was arguably a derivative work already, but simple redistribution is somewhat different than re-implementation with subtly different context.
1 parent 4a4b4a3 commit dc1b9cf

File tree

13 files changed

+2349
-177
lines changed

13 files changed

+2349
-177
lines changed

dev-packages/node-integration-tests/suites/express/tracing/test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ describe('express tracing', () => {
3232
}),
3333
description: 'corsMiddleware',
3434
op: 'middleware.express',
35-
origin: 'auto.http.otel.express',
35+
origin: 'auto.http.express',
3636
}),
3737
expect.objectContaining({
3838
data: expect.objectContaining({
@@ -41,7 +41,7 @@ describe('express tracing', () => {
4141
}),
4242
description: '/test/express',
4343
op: 'request_handler.express',
44-
origin: 'auto.http.otel.express',
44+
origin: 'auto.http.express',
4545
}),
4646
]),
4747
},

packages/core/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ export { linkedErrorsIntegration } from './integrations/linkederrors';
116116
export { moduleMetadataIntegration } from './integrations/moduleMetadata';
117117
export { requestDataIntegration } from './integrations/requestdata';
118118
export { captureConsoleIntegration } from './integrations/captureconsole';
119+
export {
120+
unpatchExpressModule,
121+
patchExpressModule,
122+
setupExpressErrorHandler,
123+
expressErrorHandler,
124+
} from './integrations/express/index';
125+
export type { ExpressIntegrationOptions } from './integrations/express/types';
119126
export { dedupeIntegration } from './integrations/dedupe';
120127
export { extraErrorDataIntegration } from './integrations/extraerrordata';
121128
export { rewriteFramesIntegration } from './integrations/rewriteframes';
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
/**
2+
* Platform-portable Express tracing integration, OTel-free, for use
3+
* on Cloudflare, Deno, Bun, etc.
4+
*
5+
* @module
6+
*
7+
* This Sentry integration is a derivative work based on the OpenTelemetry
8+
* Express instrumentation.
9+
*
10+
* <https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-express>
11+
*
12+
* Extended under the terms of the Apache 2.0 license linked below:
13+
*
14+
* ----
15+
*
16+
* Copyright The OpenTelemetry Authors
17+
*
18+
* Licensed under the Apache License, Version 2.0 (the "License");
19+
* you may not use this file except in compliance with the License.
20+
* You may obtain a copy of the License at
21+
*
22+
* https://www.apache.org/licenses/LICENSE-2.0
23+
*
24+
* Unless required by applicable law or agreed to in writing, software
25+
* distributed under the License is distributed on an "AS IS" BASIS,
26+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
27+
* See the License for the specific language governing permissions and
28+
* limitations under the License.
29+
*/
30+
31+
import { debug } from '../../utils/debug-logger';
32+
import { captureException } from '../../exports';
33+
import { getIsolationScope } from '../../currentScopes';
34+
import { httpRequestToRequestData } from '../../utils/request';
35+
import { DEBUG_BUILD } from '../../debug-build';
36+
import type {
37+
ExpressApplication,
38+
ExpressErrorMiddleware,
39+
ExpressExport,
40+
ExpressHandlerOptions,
41+
ExpressIntegrationOptions,
42+
ExpressLayer,
43+
ExpressMiddleware,
44+
ExpressModuleExport,
45+
ExpressRouter,
46+
ExpressRouterv4,
47+
ExpressRouterv5,
48+
MiddlewareError,
49+
} from './types';
50+
import type { IncomingMessage, ServerResponse } from 'node:http';
51+
import {
52+
defaultShouldHandleError,
53+
getLayerPath,
54+
hasDefaultProp,
55+
isExpressWithoutRouterPrototype,
56+
isExpressWithRouterPrototype,
57+
} from './utils';
58+
import { unwrapMethod, wrapMethod } from '../../utils/object';
59+
import { patchLayer } from './patch-layer';
60+
61+
const getExpressExport = (express: ExpressModuleExport): ExpressExport =>
62+
hasDefaultProp(express) ? express.default : (express as ExpressExport);
63+
64+
/**
65+
* This is a portable instrumentatiton function that works in any environment
66+
* where Express can be loaded, without depending on OpenTelemetry.
67+
*
68+
* @example
69+
* ```javascript
70+
* import express from 'express';
71+
* import * as Sentry from '@sentry/deno'; // or any SDK that extends core
72+
*
73+
* Sentry.patchExpressModule({ express })
74+
*/
75+
export const patchExpressModule = (options: ExpressIntegrationOptions) => {
76+
// pass in the require() or import() result of express
77+
const express = getExpressExport(options.express);
78+
const routerProto: ExpressRouterv4 | ExpressRouterv5 | undefined = isExpressWithRouterPrototype(express)
79+
? express.Router.prototype // Express v5
80+
: isExpressWithoutRouterPrototype(express)
81+
? express.Router // Express v4
82+
: undefined;
83+
84+
if (!routerProto) {
85+
throw new TypeError('no valid Express route function to instrument');
86+
}
87+
88+
// oxlint-disable-next-line @typescript-eslint/unbound-method
89+
const originalRouteMethod = routerProto.route;
90+
try {
91+
wrapMethod(
92+
routerProto,
93+
'route',
94+
function routeTrace(this: ExpressRouter, ...args: Parameters<typeof originalRouteMethod>[]) {
95+
const route = originalRouteMethod.apply(this, args);
96+
const layer = this.stack[this.stack.length - 1] as ExpressLayer;
97+
patchLayer(options, layer, getLayerPath(args));
98+
return route;
99+
},
100+
);
101+
} catch (e) {
102+
DEBUG_BUILD && debug.error('Failed to patch express route method:', e);
103+
}
104+
105+
// oxlint-disable-next-line @typescript-eslint/unbound-method
106+
const originalRouterUse = routerProto.use;
107+
try {
108+
wrapMethod(
109+
routerProto,
110+
'use',
111+
function useTrace(this: ExpressApplication, ...args: Parameters<typeof originalRouterUse>) {
112+
const route = originalRouterUse.apply(this, args);
113+
const layer = this.stack[this.stack.length - 1];
114+
if (!layer) return route;
115+
patchLayer(options, layer, getLayerPath(args));
116+
return route;
117+
},
118+
);
119+
} catch (e) {
120+
DEBUG_BUILD && debug.error('Failed to patch express use method:', e);
121+
}
122+
123+
const { application } = express;
124+
const originalApplicationUse = application.use;
125+
try {
126+
wrapMethod(
127+
application,
128+
'use',
129+
function appUseTrace(
130+
this: ExpressApplication & {
131+
_router?: ExpressRouter;
132+
router?: ExpressRouter;
133+
},
134+
...args: Parameters<ExpressApplication['use']>
135+
) {
136+
// If we access app.router in express 4.x we trigger an assertion error.
137+
// This property existed in v3, was removed in v4 and then re-added in v5.
138+
const router = isExpressWithRouterPrototype(express) ? this.router : this._router;
139+
const route = originalApplicationUse.apply(this, args);
140+
if (router) {
141+
const layer = router.stack[router.stack.length - 1];
142+
if (layer) {
143+
patchLayer(options, layer, getLayerPath(args));
144+
}
145+
}
146+
return route;
147+
},
148+
);
149+
} catch (e) {
150+
DEBUG_BUILD && debug.error('Failed to patch express application.use method:', e);
151+
}
152+
153+
return express;
154+
};
155+
156+
export const unpatchExpressModule = (options: ExpressIntegrationOptions) => {
157+
const express = getExpressExport(options.express);
158+
const routerProto: ExpressRouterv5 | ExpressRouterv4 | undefined = isExpressWithRouterPrototype(express)
159+
? express.Router.prototype // Express v5
160+
: isExpressWithoutRouterPrototype(express)
161+
? express.Router // Express v4
162+
: undefined;
163+
164+
if (!routerProto) {
165+
throw new TypeError('no valid Express route function to deinstrument');
166+
}
167+
168+
try {
169+
unwrapMethod(routerProto, 'route');
170+
} catch (e) {
171+
DEBUG_BUILD && debug.error('Failed to unpatch express route method:', e);
172+
}
173+
174+
try {
175+
unwrapMethod(routerProto, 'use');
176+
} catch (e) {
177+
DEBUG_BUILD && debug.error('Failed to unpatch express use method:', e);
178+
}
179+
180+
const { application } = express;
181+
try {
182+
unwrapMethod(application, 'use');
183+
} catch (e) {
184+
DEBUG_BUILD && debug.error('Failed to unpatch express application.use method:', e);
185+
}
186+
187+
return express;
188+
};
189+
190+
/**
191+
* An Express-compatible error handler, used by setupExpressErrorHandler
192+
*/
193+
export function expressErrorHandler(options?: ExpressHandlerOptions): ExpressErrorMiddleware {
194+
return function sentryErrorMiddleware(
195+
error: MiddlewareError,
196+
request: IncomingMessage,
197+
res: ServerResponse,
198+
next: (error: MiddlewareError) => void,
199+
): void {
200+
const normalizedRequest = httpRequestToRequestData(request);
201+
// Ensure we use the express-enhanced request here, instead of the plain HTTP one
202+
// When an error happens, the `expressRequestHandler` middleware does not run, so we set it here too
203+
getIsolationScope().setSDKProcessingMetadata({ normalizedRequest });
204+
205+
const shouldHandleError = options?.shouldHandleError || defaultShouldHandleError;
206+
207+
if (shouldHandleError(error)) {
208+
const eventId = captureException(error, {
209+
mechanism: { type: 'auto.middleware.express', handled: false },
210+
});
211+
(res as { sentry?: string }).sentry = eventId;
212+
}
213+
214+
next(error);
215+
};
216+
}
217+
218+
/**
219+
* Add an Express error handler to capture errors to Sentry.
220+
*
221+
* The error handler must be before any other middleware and after all controllers.
222+
*
223+
* @param app The Express instances
224+
* @param options {ExpressHandlerOptions} Configuration options for the handler
225+
*
226+
* @example
227+
* ```javascript
228+
* import * as Sentry from 'sentry/deno'; // or any other @sentry/<platform>
229+
* import * as express from 'express';
230+
*
231+
* Sentry.instrumentExpress(express);
232+
*
233+
* const app = express();
234+
*
235+
* // Add your routes, etc.
236+
*
237+
* // Add this after all routes,
238+
* // but before any and other error-handling middlewares are defined
239+
* Sentry.setupExpressErrorHandler(app);
240+
*
241+
* app.listen(3000);
242+
* ```
243+
*/
244+
export function setupExpressErrorHandler(
245+
app: {
246+
use: (middleware: ExpressMiddleware | ExpressErrorMiddleware) => unknown;
247+
},
248+
options?: ExpressHandlerOptions,
249+
): void {
250+
app.use(expressRequestHandler());
251+
app.use(expressErrorHandler(options));
252+
}
253+
254+
function expressRequestHandler(): ExpressMiddleware {
255+
return function sentryRequestMiddleware(request: IncomingMessage, _res: ServerResponse, next: () => void): void {
256+
const normalizedRequest = httpRequestToRequestData(request);
257+
// Ensure we use the express-enhanced request here, instead of the plain HTTP one
258+
getIsolationScope().setSDKProcessingMetadata({ normalizedRequest });
259+
260+
next();
261+
};
262+
}

0 commit comments

Comments
 (0)