Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,56 @@ router.route('/pet/:id')
server.listen(8080)
```

## Diagnostics

`router` integrates with Node.js [`diagnostics_channel`](https://nodejs.org/api/diagnostics_channel.html)
via a [`TracingChannel`](https://nodejs.org/api/diagnostics_channel.html#class-tracingchannel)
named `express.router.request`. This lets observability tools (APMs, tracers,
loggers) hook into middleware and route handler execution without monkey-patching.

Each layer's handler invocation publishes the standard tracing channel sub-events
(`start`, `end`, `asyncStart`, `asyncEnd`, `error`). The published context object
contains:

- `req`: the incoming `http.IncomingMessage`
- `res`: the `http.ServerResponse`
- `layer`: the internal `Layer` instance being invoked (exposes `.name`, `.path`, `.handle`, etc.). Note that `Layer` is an internal implementation detail and its shape may change between releases.
- `error`: the error passed to `next(err)`, when applicable
- `handled`: `true` when the layer is an error-handling middleware (4-arg signature)

The `error` event is also published when a handler calls `next(err)` with a real
error. The control-flow sentinels `'route'` and `'router'` are not treated as
errors and will not publish to the `error` channel.

When no subscribers are attached, tracing is bypassed entirely, so there is no
context allocation or channel publishing overhead on the hot path.

```js
const dc = require('node:diagnostics_channel')

const channel = dc.tracingChannel('express.router.request')

channel.subscribe({
start (ctx) {
ctx.startTime = process.hrtime.bigint()
},
end (ctx) {
// do whatever you need on synchronous completion
},
asyncStart (ctx) {
// do whatever you need when the async portion begins
},
asyncEnd (ctx) {
const durationNs = process.hrtime.bigint() - ctx.startTime
console.log('%s %s -> %s (%dns)',
ctx.req.method, ctx.req.url, ctx.layer.name, durationNs)
},
error (ctx) {
console.error('handler error in %s:', ctx.layer.name, ctx.error)
}
})
```

## License

[MIT](LICENSE)
Expand Down
102 changes: 78 additions & 24 deletions lib/layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* @private
*/

const dc = require('node:diagnostics_channel')
const isPromise = require('is-promise')
const pathRegexp = require('path-to-regexp')
const debug = require('debug')('router:layer')
Expand All @@ -25,6 +26,20 @@ const deprecate = require('depd')('router')
const TRAILING_SLASH_REGEXP = /\/+$/
const MATCHING_GROUP_REGEXP = /\((?:\?<(.*?)>)?(?!\?)/g

/**
* TracingChannel setup.
* @private
*/

const requestChannel = dc.tracingChannel && dc.tracingChannel('express.router.request')

/**
* Check if the channel has subscribers.
*/
function shouldTrace (ch) {
return ch && ch.hasSubscribers !== false
}

/**
* Expose `Layer`.
*/
Expand Down Expand Up @@ -111,23 +126,12 @@ Layer.prototype.handleError = function handleError (error, req, res, next) {
return next(error)
}

try {
// invoke function
const ret = fn(error, req, res, next)

// wait for returned promise
if (isPromise(ret)) {
if (!(ret instanceof Promise)) {
deprecate('handlers that are Promise-like are deprecated, use a native Promise instead')
}

ret.then(null, function (error) {
next(error || new Error('Rejected promise'))
})
}
} catch (err) {
next(err)
}
const layer = this
invokeWithTrace(function (wrappedNext) {
return fn(error, req, res, wrappedNext)
}, function () {
return { req, res, layer, error, handled: true }
}, next)
}

/**
Expand All @@ -147,16 +151,52 @@ Layer.prototype.handleRequest = function handleRequest (req, res, next) {
return next()
}

try {
// invoke function
const ret = fn(req, res, next)
// Skip tracing for route dispatch wrappers (this.route is only set on
// the internal layer that calls route.dispatch). The actual user handlers
// inside the route are traced individually.
if (this.route) {
return invokeWithTrace(function (wrappedNext) {
return fn(req, res, wrappedNext)
}, null, next)
}

const layer = this
invokeWithTrace(function (wrappedNext) {
return fn(req, res, wrappedNext)
}, function () {
return { req, res, layer }
}, next)
}

/**
* Invoke a handler function, optionally wrapping it in TracingChannel.
* The ctxFactory is only called when tracing is active, ensuring zero
* allocation overhead when no subscribers are registered.
* @private
*/

// wait for returned promise
if (isPromise(ret)) {
if (!(ret instanceof Promise)) {
deprecate('handlers that are Promise-like are deprecated, use a native Promise instead')
function invokeWithTrace (exec, ctxFactory, next) {
const tracing = ctxFactory && shouldTrace(requestChannel)
const ctx = tracing ? ctxFactory() : null
const wrappedNext = tracing
? function (err) {
// 'route' and 'router' are control-flow sentinels (skip route / exit router),
// not real errors — don't pollute the error channel with them.
if (err && err !== 'route' && err !== 'router') {
ctx.error = err
// Explicitly publish the error to the error channel
requestChannel.error.publish(ctx)
}
next(err)
}
: next

try {
const ret = tracing
? requestChannel.tracePromise(function () { return handlePromise(exec(wrappedNext)) }, ctx)
: handlePromise(exec(wrappedNext))

if (ret) {
ret.then(null, function (error) {
next(error || new Error('Rejected promise'))
})
Expand All @@ -166,6 +206,20 @@ Layer.prototype.handleRequest = function handleRequest (req, res, next) {
}
}

/**
* If the return value is a promise, validate it and return it.
* @private
*/

function handlePromise (ret) {
if (isPromise(ret)) {
if (!(ret instanceof Promise)) {
deprecate('handlers that are Promise-like are deprecated, use a native Promise instead')
}
return ret
}
}

/**
* Check if this route matches `path`, if so
* populate `.params`.
Expand Down
Loading