dd-trace
Version:
Datadog APM tracing client for JavaScript
493 lines (405 loc) • 17.6 kB
JavaScript
'use strict'
const METHODS = [...require('http').METHODS.map(v => v.toLowerCase()), 'all']
const shimmer = require('../../datadog-shimmer')
const { addHook, channel, createErrorPublisher } = require('./helpers/instrument')
const { getCompileToRegexp } = require('./path-to-regexp')
const {
getRouterMountPaths,
joinPath,
setLayerMeta,
getLayerMeta,
isAppMounted,
setRouterMountPath,
extractMountPaths,
getRouteFullPaths,
wrapRouteMethodsAndPublish,
collectRoutesFromRouter,
} = require('./helpers/router-helper')
function isFastStar (layer, matchers) {
return layer.regexp?.fast_star ?? matchers.hasStarPath
}
function isFastSlash (layer, matchers) {
return layer.regexp?.fast_slash ?? matchers.hasSlashPath
}
/**
* Cache the per-layer dispatch metadata in a side table instead of replacing
* `layer.handle`. Phase-sorting hosts (loopback's `_findLayerByHandler`) map a
* layer back to the user handler by scanning the handle, so the handle has to
* stay the user's function.
*
* @param {{ handle: Function, name?: string, path?: string,
* regexp?: { fast_star?: boolean, fast_slash?: boolean } }} layer
* @param {Array<{ path?: string, regex?: RegExp }> & { hasStarPath?: boolean, hasSlashPath?: boolean }} matchers
*/
function annotateLayer (layer, matchers) {
const handle = layer.handle
const name = handle._name || layer.name || handle.name
let captureRoute
let needMultiMatch = false
if (matchers.length !== 0 && !isFastStar(layer, matchers) && !isFastSlash(layer, matchers)) {
if (matchers.length === 1) {
captureRoute = matchers[0].path
} else {
needMultiMatch = true
}
}
setLayerMeta(layer, { name, captureRoute, needMultiMatch, matchers })
}
/**
* Resolve the route for a dispatched layer. Single-pattern layers carry a
* constant route; only multi-pattern stacks need the per-request `layer.path`
* match the host already computed.
*
* @param {{ captureRoute?: string, needMultiMatch: boolean,
* matchers: Array<{ path?: string, regex?: RegExp }> }} meta
* @param {{ path?: string }} layer
* @returns {string | undefined}
*/
function resolveLayerRoute (meta, layer) {
if (!meta.needMultiMatch) return meta.captureRoute
for (const matcher of meta.matchers) {
if (matcher.regex?.test(layer.path)) return matcher.path
}
}
/**
* Build the request/error dispatch wrappers for one host (`express` / `router`).
* They wrap the layer's prototype dispatch and read the side-table metadata, so
* `layer.handle` is never replaced. The arity guard mirrors the host's own
* (`handle_request` skips 4-arg handlers, `handle_error` skips the rest), so a
* span is published only for the layer the host actually runs.
*
* @param {string} name Channel namespace (`apm:<name>:middleware:*`).
*/
function createLayerDispatchWrappers (name) {
const enterChannel = channel(`apm:${name}:middleware:enter`)
const exitChannel = channel(`apm:${name}:middleware:exit`)
const finishChannel = channel(`apm:${name}:middleware:finish`)
const errorChannel = channel(`apm:${name}:middleware:error`)
const nextChannel = channel(`apm:${name}:middleware:next`)
const repeatChannel = channel(`apm:${name}:middleware:repeat`)
// Bound per name so express and a bare router keep independent guards.
const publishError = createErrorPublisher(errorChannel)
/**
* @param {import('node:http').IncomingMessage} req
* @param {string | undefined} layerName
* @param {(error?: unknown) => void} originalNext
*/
function wrapNext (req, layerName, originalNext) {
// Per layer dispatch, N per request. Named `next`/arity-1 mirrors the
// router continuation so wrapCallback skips its name/length rewrite.
let calls = 0
return shimmer.wrapCallback(originalNext, original => function next (error) {
// A handler that calls `next()` and then rejects (`next(); await bg()`)
// makes the host call this continuation twice. Publish once so the second
// pass cannot tag the already-finished span's parent with a late error.
calls++
if (calls === 1) {
if (error && error !== 'route' && error !== 'router') {
publishError({ req, error })
}
nextChannel.publish({ req })
finishChannel.publish({ req })
} else if (calls === 2) {
// Surface the repeat as a diagnostic on the still-live request span. The
// host cannot tell a legitimate `next(); await bg()` from a buggy double
// `next()`, so this only records that it happened, not that it is wrong.
repeatChannel.publish({ req, name: layerName, error })
}
original.apply(this, arguments)
})
}
// Every host dispatch turns a synchronous throw into `next(error)`, and the
// hosts that await the handler (router >=2, express 5, express 4 +
// express-async-errors) do the same for a rejected promise. Passing
// `wrappedNext` through captures both without a tracer-side try/catch; only
// `exit` needs the `finally`. express 4's native dispatch converts only the
// synchronous throw — exactly what the pre-refactor handle wrap caught.
function wrapLayerRequest (originalRequest) {
return function (req, res, next) {
if (!enterChannel.hasSubscribers) return originalRequest.call(this, req, res, next)
const meta = getLayerMeta(this)
if (meta === undefined || this.handle.length > 3) return originalRequest.call(this, req, res, next)
const wrappedNext = typeof next === 'function' ? wrapNext(req, meta.name, next) : next
enterChannel.publish({ name: meta.name, req, route: resolveLayerRoute(meta, this), layer: this })
try {
return originalRequest.call(this, req, res, wrappedNext)
} finally {
exitChannel.publish({ req })
}
}
}
function wrapLayerError (originalError) {
return function (error, req, res, next) {
if (!enterChannel.hasSubscribers) return originalError.call(this, error, req, res, next)
const meta = getLayerMeta(this)
if (meta === undefined || this.handle.length !== 4) return originalError.call(this, error, req, res, next)
const wrappedNext = typeof next === 'function' ? wrapNext(req, meta.name, next) : next
enterChannel.publish({ name: meta.name, req, route: resolveLayerRoute(meta, this), layer: this })
try {
return originalError.call(this, error, req, res, wrappedNext)
} finally {
exitChannel.publish({ req })
}
}
}
// express <4.6.0 has no `Layer` prototype dispatch: the router invokes
// `layer.handle` directly and routes errors by its arity. There the handle is
// replaced in place, with the arity preserved so the host still routes
// correctly. Newer express, express 5, and the router package keep `handle`
// pristine and are traced through the prototype wraps above.
function wrapLegacyHandle (layer, original) {
// `annotateLayer` always runs first in `wrapStack`, so the captured meta is
// never undefined here (unlike the prototype wraps, where `this` can be any
// layer the host dispatches).
const meta = getLayerMeta(layer)
const wrapped = shimmer.wrapFunction(original, inner => function (...args) {
if (!enterChannel.hasSubscribers) return inner.apply(this, args)
const isErrorHandler = original.length === 4
const req = args[isErrorHandler ? 1 : 0]
const nextIndex = isErrorHandler ? 3 : 2
if (typeof args[nextIndex] === 'function') args[nextIndex] = wrapNext(req, meta.name, args[nextIndex])
enterChannel.publish({ name: meta.name, req, route: resolveLayerRoute(meta, layer), layer })
try {
return inner.apply(this, args)
} catch (error) {
// Unlike the prototype hosts, this router catches a synchronous throw
// outside the layer and calls its own `next(error)`, never `wrappedNext`.
// Mirror `wrapNext` here so the throwing layer still tags its error and
// finishes, rather than lingering on the stack until request finish.
publishError({ req, error })
nextChannel.publish({ req })
finishChannel.publish({ req })
throw error
} finally {
exitChannel.publish({ req })
}
})
Object.defineProperty(wrapped, 'length', { value: original.length, configurable: true })
return wrapped
}
return { wrapLayerRequest, wrapLayerError, wrapLegacyHandle }
}
/**
* @param {{ handle_request?: unknown, handleRequest?: unknown }} layer
*/
function hasLayerDispatch (layer) {
return typeof layer.handle_request === 'function' || typeof layer.handleRequest === 'function'
}
// TODO: Move this function to a shared file between Express and Router
/**
* @param {string} name Channel namespace (`apm:<name>:middleware:*`).
* @param {((pattern: string | RegExp) => RegExp | undefined) | undefined} compile
* Host-resolved path-to-regexp compile adapter, or undefined when the host
* instance ships no path-to-regexp. Captured here so each express/router
* instance keeps the dialect it actually loaded.
* @param {((layer: object, original: Function) => Function) | undefined} [wrapLegacyHandle]
* Fallback that replaces `layer.handle` for hosts without a `Layer` prototype
* dispatch (express <4.6.0). Omitted for hosts that always ship one.
*/
function createWrapRouterMethod (name, compile, wrapLegacyHandle) {
const routeAddedChannel = channel(`apm:${name}:route:added`)
function wrapStack (layers, matchers) {
for (const layer of layers) {
annotateLayer(layer, matchers)
if (wrapLegacyHandle !== undefined && !hasLayerDispatch(layer)) {
if (layer.__handle) { // express-async-errors
layer.__handle = wrapLegacyHandle(layer, layer.__handle)
} else {
layer.handle = wrapLegacyHandle(layer, layer.handle)
}
}
if (layer.route) {
for (const method of METHODS) {
if (typeof layer.route.stack === 'function') {
layer.route.stack = [{ handle: layer.route.stack }]
}
layer.route[method] = wrapMethod(layer.route[method])
}
}
}
}
function extractMatchers (fn) {
const arg = Array.isArray(fn) ? fn.flat(Infinity) : [fn]
if (typeof arg[0] === 'function') {
return []
}
if (arg.length === 1) {
const pattern = arg[0]
const path = pattern instanceof RegExp ? `(${pattern})` : pattern
const matchers = [{ path }]
matchers.hasStarPath = path === '*'
matchers.hasSlashPath = path === '/'
return matchers
}
// hasStarPath/hasSlashPath cache the lookups isFastStar/isFastSlash
// would otherwise re-run on every request.
let hasStarPath = false
let hasSlashPath = false
const matchers = arg.map(pattern => {
const isRegExp = pattern instanceof RegExp
const path = isRegExp ? `(${pattern})` : pattern
if (path === '*') {
hasStarPath = true
} else if (path === '/') {
hasSlashPath = true
}
return {
path,
regex: isRegExp ? pattern : compile?.(pattern),
}
})
matchers.hasStarPath = hasStarPath
matchers.hasSlashPath = hasSlashPath
return matchers
}
function wrapMethod (original) {
return shimmer.wrapFunction(original, original => function methodWithTrace (...args) {
let offset = 0
if (this.stack) {
offset = Array.isArray(this.stack) ? this.stack.length : 1
}
const router = original.apply(this, args)
if (typeof this.stack === 'function') {
this.stack = [{ handle: this.stack }]
}
if (routeAddedChannel.hasSubscribers) {
routeAddedChannel.publish({ topOfStackFunc: methodWithTrace, layer: this.stack?.at(-1) })
}
const fn = args[0]
// Publish only if this router was mounted by app.use() (prevents early '/sub/...')
if (routeAddedChannel.hasSubscribers && isAppMounted(this) && this.stack?.length > offset) {
// Handle nested router mounting for 'use' method
if (original.name === 'use' && args.length >= 2) {
const { mountPaths, startIdx } = extractMountPaths(fn)
if (mountPaths.length) {
const parentPaths = getRouterMountPaths(this)
for (let i = startIdx; i < args.length; i++) {
const nestedRouter = args[i]
if (!nestedRouter || typeof nestedRouter !== 'function') continue
for (const parentPath of parentPaths) {
for (const normalizedMountPath of mountPaths) {
const fullMountPath = joinPath(parentPath, normalizedMountPath)
if (fullMountPath === null) continue
setRouterMountPath(nestedRouter, fullMountPath)
collectRoutesFromRouter(nestedRouter, fullMountPath)
}
}
}
}
}
const mountPaths = getRouterMountPaths(this)
if (mountPaths.length) {
const layer = this.stack.at(-1)
if (layer?.route) {
const route = layer.route
const fullPaths = mountPaths.flatMap(mountPath => getRouteFullPaths(route, mountPath))
wrapRouteMethodsAndPublish(route, fullPaths, (payload) => {
routeAddedChannel.publish(payload)
})
}
}
}
if (this.stack?.length > offset) {
wrapStack(this.stack.slice(offset), extractMatchers(fn))
}
return router
})
}
return wrapMethod
}
addHook({ name: 'router', versions: ['>=1 <2'] }, Router => {
const wrapRouterMethod = createWrapRouterMethod('router', getCompileToRegexp())
shimmer.wrap(Router.prototype, 'use', wrapRouterMethod)
shimmer.wrap(Router.prototype, 'route', wrapRouterMethod)
return Router
})
addHook({ name: 'router', file: 'lib/layer.js', versions: ['>=1 <2'] }, Layer => {
const { wrapLayerRequest, wrapLayerError } = createLayerDispatchWrappers('router')
shimmer.wrap(Layer.prototype, 'handle_request', wrapLayerRequest)
shimmer.wrap(Layer.prototype, 'handle_error', wrapLayerError)
return Layer
})
const queryParserReadCh = channel('datadog:query:read:finish')
addHook({ name: 'router', versions: ['>=2'] }, Router => {
const wrapRouterMethod = createWrapRouterMethod('router', getCompileToRegexp())
const WrappedRouter = shimmer.wrapFunction(Router, function (originalRouter) {
return function wrappedMethod (...args) {
const router = originalRouter.apply(this, args)
shimmer.wrap(router, 'handle', function wrapHandle (originalHandle) {
return function wrappedHandle (req, res, next) {
if (queryParserReadCh.hasSubscribers && req) {
const abortController = new AbortController()
queryParserReadCh.publish({ req, res, query: req.query, abortController })
if (abortController.signal.aborted) return
}
return originalHandle.call(this, req, res, next)
}
})
return router
}
})
shimmer.wrap(WrappedRouter.prototype, 'use', wrapRouterMethod)
shimmer.wrap(WrappedRouter.prototype, 'route', wrapRouterMethod)
return WrappedRouter
})
const routerParamStartCh = channel('datadog:router:param:start')
const visitedParams = new WeakSet()
function wrapHandleRequest (original) {
return function wrappedHandleRequest (...args) {
const req = args[0]
if (routerParamStartCh.hasSubscribers && !visitedParams.has(req.params) && Object.keys(req.params).length) {
visitedParams.add(req.params)
const abortController = new AbortController()
routerParamStartCh.publish({
req,
res: args[1],
params: req?.params,
abortController,
})
if (abortController.signal.aborted) return
}
return Reflect.apply(original, this, args)
}
}
addHook({
name: 'router', file: 'lib/layer.js', versions: ['>=2'],
}, Layer => {
const { wrapLayerRequest, wrapLayerError } = createLayerDispatchWrappers('router')
// `handleRequest` carries two concerns: the middleware dispatch span and the
// param-start publish (`wrapHandleRequest`). Wrap the dispatch first so it
// sits inner and param-start still fires before `middleware:enter`, matching
// the order from when the handle itself was wrapped.
shimmer.wrap(Layer.prototype, 'handleRequest', wrapLayerRequest)
shimmer.wrap(Layer.prototype, 'handleError', wrapLayerError)
shimmer.wrap(Layer.prototype, 'handleRequest', wrapHandleRequest)
return Layer
})
function wrapParam (original) {
return function wrappedProcessParams (...args) {
args[1] = shimmer.wrapFunction(args[1], (originalFn) => {
return function wrappedFn (...fnArgs) {
const req = fnArgs[0]
if (routerParamStartCh.hasSubscribers && Object.keys(req.params).length && !visitedParams.has(req.params)) {
visitedParams.add(req.params)
const abortController = new AbortController()
routerParamStartCh.publish({
req,
res: fnArgs[1],
params: req?.params,
abortController,
})
if (abortController.signal.aborted) return
}
return Reflect.apply(originalFn, this, fnArgs)
}
})
return original.apply(this, args)
}
}
addHook({
name: 'router', versions: ['>=2'],
}, router => {
shimmer.wrap(router.prototype, 'param', wrapParam)
return router
})
module.exports = { createWrapRouterMethod, createLayerDispatchWrappers }