Skip to content

Commit a71f347

Browse files
committed
fix(express): preserve legacy middleware dispatch semantics (#9351)
Legacy Express can call next before throwing, and multi-pattern route matching can fail outside the host dispatch boundary. Keep lifecycle publishing at most once and forward matcher failures through next(error). The native fallback reduced middleware dispatch from 350.10 to 299.50 ns/op on Node 18 and 224.67 to 204.93 ns/op on Node 24 (1M warm-up, seven 500K trials, trimmed mean).
1 parent 4154867 commit a71f347

3 files changed

Lines changed: 523 additions & 26 deletions

File tree

packages/datadog-instrumentations/src/helpers/router-helper.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ const routerMountPaths = new WeakMap() // to track mount paths for router instan
77
const layerMeta = new WeakMap() // per-layer middleware dispatch metadata (resolved name + route matchers)
88
const appMountedRouters = new WeakSet() // to track routers mounted via app.use()
99

10+
/**
11+
* @typedef {object} LayerMeta
12+
* @property {string} [name]
13+
* @property {string} [captureRoute]
14+
* @property {boolean} [needMultiMatch]
15+
* @property {Array<{ path?: string, regex?: RegExp }> & {
16+
* hasStarPath?: boolean,
17+
* hasSlashPath?: boolean
18+
* }} [matchers]
19+
*/
20+
1021
const METHODS = [...require('http').METHODS.map(v => v.toLowerCase()), 'all']
1122

1223
const routeAddedChannel = channel('apm:express:route:added')
@@ -121,10 +132,19 @@ function getRouterMountPaths (router) {
121132
return [...paths]
122133
}
123134

135+
/**
136+
* @param {object} layer
137+
* @param {LayerMeta} meta
138+
* @returns {void}
139+
*/
124140
function setLayerMeta (layer, meta) {
125141
layerMeta.set(layer, meta)
126142
}
127143

144+
/**
145+
* @param {object} layer
146+
* @returns {LayerMeta | undefined}
147+
*/
128148
function getLayerMeta (layer) {
129149
return layerMeta.get(layer)
130150
}

packages/datadog-instrumentations/src/router.js

Lines changed: 195 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict'
22

33
const METHODS = [...require('http').METHODS.map(v => v.toLowerCase()), 'all']
4+
const ROUTE_RESOLUTION_FAILED = Symbol('routeResolutionFailed')
45
const shimmer = require('../../datadog-shimmer')
56
const { addHook, channel, createErrorPublisher } = require('./helpers/instrument')
67
const { getCompileToRegexp } = require('./path-to-regexp')
@@ -35,6 +36,7 @@ function isFastSlash (layer, matchers) {
3536
* @param {{ handle: Function, name?: string, path?: string,
3637
* regexp?: { fast_star?: boolean, fast_slash?: boolean } }} layer
3738
* @param {Array<{ path?: string, regex?: RegExp }> & { hasStarPath?: boolean, hasSlashPath?: boolean }} matchers
39+
* @returns {void}
3840
*/
3941
function annotateLayer (layer, matchers) {
4042
const handle = layer.handle
@@ -71,6 +73,25 @@ function resolveLayerRoute (meta, layer) {
7173
}
7274
}
7375

76+
/**
77+
* Preserve the host Layer's error boundary while resolving a route outside its
78+
* dispatch method. This path runs only for multi-pattern layers.
79+
*
80+
* @param {{ captureRoute?: string, needMultiMatch: boolean,
81+
* matchers: Array<{ path?: string, regex?: RegExp }> }} meta
82+
* @param {{ path?: string }} layer
83+
* @param {Function} next
84+
* @returns {string | undefined | typeof ROUTE_RESOLUTION_FAILED}
85+
*/
86+
function resolveLayerRouteOrForwardError (meta, layer, next) {
87+
try {
88+
return resolveLayerRoute(meta, layer)
89+
} catch (error) {
90+
next(error)
91+
return ROUTE_RESOLUTION_FAILED
92+
}
93+
}
94+
7495
/**
7596
* Build the request/error dispatch wrappers for one host (`express` / `router`).
7697
* They wrap the layer's prototype dispatch and read the side-table metadata, so
@@ -79,6 +100,11 @@ function resolveLayerRoute (meta, layer) {
79100
* span is published only for the layer the host actually runs.
80101
*
81102
* @param {string} name Channel namespace (`apm:<name>:middleware:*`).
103+
* @returns {{
104+
* wrapLayerRequest: (originalRequest: Function) => Function,
105+
* wrapLayerError: (originalError: Function) => Function,
106+
* wrapLegacyHandle: (layer: object, original: Function, guardRepeatedNext?: boolean) => Function
107+
* }}
82108
*/
83109
function createLayerDispatchWrappers (name) {
84110
const enterChannel = channel(`apm:${name}:middleware:enter`)
@@ -128,15 +154,25 @@ function createLayerDispatchWrappers (name) {
128154
// `wrappedNext` through captures both without a tracer-side try/catch; only
129155
// `exit` needs the `finally`. express 4's native dispatch converts only the
130156
// synchronous throw — exactly what the pre-refactor handle wrap caught.
157+
/**
158+
* @param {Function} originalRequest
159+
* @returns {Function}
160+
*/
131161
function wrapLayerRequest (originalRequest) {
132162
return function (req, res, next) {
133163
if (!enterChannel.hasSubscribers) return originalRequest.call(this, req, res, next)
134164

135165
const meta = getLayerMeta(this)
136166
if (meta === undefined || this.handle.length > 3) return originalRequest.call(this, req, res, next)
137167

168+
let route = meta.captureRoute
169+
if (meta.needMultiMatch) {
170+
route = resolveLayerRouteOrForwardError(meta, this, next)
171+
if (route === ROUTE_RESOLUTION_FAILED) return
172+
}
173+
138174
const wrappedNext = typeof next === 'function' ? wrapNext(req, meta.name, next) : next
139-
enterChannel.publish({ name: meta.name, req, route: resolveLayerRoute(meta, this), layer: this })
175+
enterChannel.publish({ name: meta.name, req, route, layer: this })
140176

141177
try {
142178
return originalRequest.call(this, req, res, wrappedNext)
@@ -146,15 +182,25 @@ function createLayerDispatchWrappers (name) {
146182
}
147183
}
148184

185+
/**
186+
* @param {Function} originalError
187+
* @returns {Function}
188+
*/
149189
function wrapLayerError (originalError) {
150190
return function (error, req, res, next) {
151191
if (!enterChannel.hasSubscribers) return originalError.call(this, error, req, res, next)
152192

153193
const meta = getLayerMeta(this)
154194
if (meta === undefined || this.handle.length !== 4) return originalError.call(this, error, req, res, next)
155195

196+
let route = meta.captureRoute
197+
if (meta.needMultiMatch) {
198+
route = resolveLayerRouteOrForwardError(meta, this, next)
199+
if (route === ROUTE_RESOLUTION_FAILED) return
200+
}
201+
156202
const wrappedNext = typeof next === 'function' ? wrapNext(req, meta.name, next) : next
157-
enterChannel.publish({ name: meta.name, req, route: resolveLayerRoute(meta, this), layer: this })
203+
enterChannel.publish({ name: meta.name, req, route, layer: this })
158204

159205
try {
160206
return originalError.call(this, error, req, res, wrappedNext)
@@ -164,36 +210,163 @@ function createLayerDispatchWrappers (name) {
164210
}
165211
}
166212

167-
// express <4.6.0 has no `Layer` prototype dispatch: the router invokes
168-
// `layer.handle` directly and routes errors by its arity. There the handle is
169-
// replaced in place, with the arity preserved so the host still routes
170-
// correctly. Newer express, express 5, and the router package keep `handle`
171-
// pristine and are traced through the prototype wraps above.
172-
function wrapLegacyHandle (layer, original) {
213+
// express <4.6.0 dispatches `layer.handle` directly, so its replacement must preserve arity.
214+
/**
215+
* @param {object} layer
216+
* @param {Function} original
217+
* @returns {Function}
218+
*/
219+
function wrapNativeLegacyRequestHandle (layer, original) {
220+
const meta = getLayerMeta(layer)
221+
const { name, captureRoute, needMultiMatch } = meta
222+
return shimmer.wrapFunction(original, inner => function (req, res, next) {
223+
if (!enterChannel.hasSubscribers) return inner.call(this, req, res, next)
224+
225+
let calls = 0
226+
if (typeof next === 'function') {
227+
next = shimmer.wrapCallback(next, originalNext => function next (error) {
228+
calls++
229+
if (calls === 1) {
230+
if (error && error !== 'route' && error !== 'router') {
231+
publishError({ req, error })
232+
}
233+
234+
nextChannel.publish({ req })
235+
finishChannel.publish({ req })
236+
} else if (calls === 2) {
237+
repeatChannel.publish({ req, name, error })
238+
}
239+
240+
originalNext.apply(this, arguments)
241+
})
242+
}
243+
244+
const route = needMultiMatch ? resolveLayerRoute(meta, layer) : captureRoute
245+
enterChannel.publish({ name, req, route, layer })
246+
247+
try {
248+
return inner.call(this, req, res, next)
249+
} catch (error) {
250+
if (calls === 0) {
251+
calls = 1
252+
publishError({ req, error })
253+
nextChannel.publish({ req })
254+
finishChannel.publish({ req })
255+
}
256+
257+
throw error
258+
} finally {
259+
exitChannel.publish({ req })
260+
}
261+
})
262+
}
263+
264+
/**
265+
* @param {object} layer
266+
* @param {Function} original
267+
* @returns {Function}
268+
*/
269+
function wrapNativeLegacyErrorHandle (layer, original) {
270+
const meta = getLayerMeta(layer)
271+
const { name, captureRoute, needMultiMatch } = meta
272+
return shimmer.wrapFunction(original, inner => function (error, req, res, next) {
273+
if (!enterChannel.hasSubscribers) return inner.call(this, error, req, res, next)
274+
275+
let calls = 0
276+
if (typeof next === 'function') {
277+
next = shimmer.wrapCallback(next, originalNext => function next (nextError) {
278+
calls++
279+
if (calls === 1) {
280+
if (nextError && nextError !== 'route' && nextError !== 'router') {
281+
publishError({ req, error: nextError })
282+
}
283+
284+
nextChannel.publish({ req })
285+
finishChannel.publish({ req })
286+
} else if (calls === 2) {
287+
repeatChannel.publish({ req, name, error: nextError })
288+
}
289+
290+
originalNext.apply(this, arguments)
291+
})
292+
}
293+
294+
const route = needMultiMatch ? resolveLayerRoute(meta, layer) : captureRoute
295+
enterChannel.publish({ name, req, route, layer })
296+
297+
try {
298+
return inner.call(this, error, req, res, next)
299+
} catch (caught) {
300+
if (calls === 0) {
301+
calls = 1
302+
publishError({ req, error: caught })
303+
nextChannel.publish({ req })
304+
finishChannel.publish({ req })
305+
}
306+
307+
throw caught
308+
} finally {
309+
exitChannel.publish({ req })
310+
}
311+
})
312+
}
313+
314+
/**
315+
* @param {object} layer
316+
* @param {Function} original
317+
* @param {boolean} [guardRepeatedNext]
318+
* @returns {Function}
319+
*/
320+
function wrapLegacyHandle (layer, original, guardRepeatedNext = false) {
321+
if (!guardRepeatedNext) {
322+
return original.length === 4
323+
? wrapNativeLegacyErrorHandle(layer, original)
324+
: wrapNativeLegacyRequestHandle(layer, original)
325+
}
326+
173327
// `annotateLayer` always runs first in `wrapStack`, so the captured meta is
174328
// never undefined here (unlike the prototype wraps, where `this` can be any
175329
// layer the host dispatches).
176330
const meta = getLayerMeta(layer)
331+
const { name, captureRoute, needMultiMatch } = meta
177332
const wrapped = shimmer.wrapFunction(original, inner => function (...args) {
178333
if (!enterChannel.hasSubscribers) return inner.apply(this, args)
179334

180335
const isErrorHandler = original.length === 4
181336
const req = args[isErrorHandler ? 1 : 0]
182337
const nextIndex = isErrorHandler ? 3 : 2
183-
if (typeof args[nextIndex] === 'function') args[nextIndex] = wrapNext(req, meta.name, args[nextIndex])
338+
let calls = 0
339+
if (typeof args[nextIndex] === 'function') {
340+
args[nextIndex] = shimmer.wrapCallback(args[nextIndex], originalNext => function next (error) {
341+
calls++
342+
if (calls === 1) {
343+
if (error && error !== 'route' && error !== 'router') {
344+
publishError({ req, error })
345+
}
184346

185-
enterChannel.publish({ name: meta.name, req, route: resolveLayerRoute(meta, layer), layer })
347+
nextChannel.publish({ req })
348+
finishChannel.publish({ req })
349+
} else if (calls === 2) {
350+
repeatChannel.publish({ req, name, error })
351+
}
352+
353+
originalNext.apply(this, arguments)
354+
})
355+
}
356+
357+
const route = needMultiMatch ? resolveLayerRoute(meta, layer) : captureRoute
358+
enterChannel.publish({ name, req, route, layer })
186359

187360
try {
188361
return inner.apply(this, args)
189362
} catch (error) {
190-
// Unlike the prototype hosts, this router catches a synchronous throw
191-
// outside the layer and calls its own `next(error)`, never `wrappedNext`.
192-
// Mirror `wrapNext` here so the throwing layer still tags its error and
193-
// finishes, rather than lingering on the stack until request finish.
194-
publishError({ req, error })
195-
nextChannel.publish({ req })
196-
finishChannel.publish({ req })
363+
// Legacy hosts catch outside the layer and never call its wrapped `next`, so finish before rethrowing.
364+
if (calls === 0) {
365+
calls = 1
366+
publishError({ req, error })
367+
nextChannel.publish({ req })
368+
finishChannel.publish({ req })
369+
}
197370

198371
throw error
199372
} finally {
@@ -209,6 +382,7 @@ function createLayerDispatchWrappers (name) {
209382

210383
/**
211384
* @param {{ handle_request?: unknown, handleRequest?: unknown }} layer
385+
* @returns {boolean}
212386
*/
213387
function hasLayerDispatch (layer) {
214388
return typeof layer.handle_request === 'function' || typeof layer.handleRequest === 'function'
@@ -221,9 +395,11 @@ function hasLayerDispatch (layer) {
221395
* Host-resolved path-to-regexp compile adapter, or undefined when the host
222396
* instance ships no path-to-regexp. Captured here so each express/router
223397
* instance keeps the dialect it actually loaded.
224-
* @param {((layer: object, original: Function) => Function) | undefined} [wrapLegacyHandle]
398+
* @param {((layer: object, original: Function, guardRepeatedNext?: boolean) => Function) | undefined}
399+
* [wrapLegacyHandle]
225400
* Fallback that replaces `layer.handle` for hosts without a `Layer` prototype
226401
* dispatch (express <4.6.0). Omitted for hosts that always ship one.
402+
* @returns {(original: Function) => Function}
227403
*/
228404
function createWrapRouterMethod (name, compile, wrapLegacyHandle) {
229405
const routeAddedChannel = channel(`apm:${name}:route:added`)
@@ -234,7 +410,7 @@ function createWrapRouterMethod (name, compile, wrapLegacyHandle) {
234410

235411
if (wrapLegacyHandle !== undefined && !hasLayerDispatch(layer)) {
236412
if (layer.__handle) { // express-async-errors
237-
layer.__handle = wrapLegacyHandle(layer, layer.__handle)
413+
layer.__handle = wrapLegacyHandle(layer, layer.__handle, true)
238414
} else {
239415
layer.handle = wrapLegacyHandle(layer, layer.handle)
240416
}

0 commit comments

Comments
 (0)