Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
95e23ba
feat: add TracingChannel support for express:request
logaretm Apr 14, 2026
858399c
refactor: reduce duplication in invokeWithTrace
logaretm Apr 14, 2026
9f1aea7
fix: guard against missing dc.tracingChannel on older Node versions
logaretm Apr 15, 2026
dea81ae
ref: use node: prefix
logaretm Apr 15, 2026
1f6bc25
fix: use the top-level hasSubscribers flag
logaretm Apr 15, 2026
1ad1b9c
test: pin down error event semantics for next(err) flows
logaretm Apr 20, 2026
28b3213
fix: always publish error events before calling next
logaretm Apr 20, 2026
90ed7e2
fix: refine error handling for control-flow sentinels in invokeWithTrace
logaretm Apr 20, 2026
758030e
test: added case for router control flow error
logaretm Apr 20, 2026
9c9b4a7
test: assert handled flag on error events in both-layers error tests
logaretm Apr 20, 2026
67c3851
refactor: rename tracing channel to pillarjs.router.request
logaretm Apr 21, 2026
6aa0575
docs: document pillarjs.router.request tracing channel
logaretm Apr 27, 2026
5e80078
refactor: rename tracing channel to express.router.request
logaretm Apr 27, 2026
da0da14
fix: detect tracing subscribers on Node versions without TracingChann…
logaretm Jul 15, 2026
8fd857f
refactor: gate tracing on subscribers in callers and pass ctx directly
logaretm Jul 15, 2026
337e212
fix: report each request error once, at its origin layer
logaretm Jul 15, 2026
232143b
fix: don't report thrown 'route'/'router' signals as errors
logaretm Jul 15, 2026
10a47bc
docs: clarify error semantics on the request tracing channel
logaretm Jul 15, 2026
cc5deb7
fix: report value-less rejections as the forwarded error on the channel
logaretm Jul 15, 2026
9aa666a
test: extract channel-name constant and shared byLayer helper
logaretm Jul 15, 2026
04b2f72
fix: route traced outcomes through next so tracing never changes routing
logaretm Jul 15, 2026
97da6d8
test: hoist repeated setup and predicates into shared helpers
logaretm Jul 15, 2026
583c8c4
refactor: rename the error-handler context flag to errorHandler
logaretm Jul 15, 2026
d0d8f7e
perf: publish lifecycle via start.runStores instead of tracePromise
logaretm Jul 15, 2026
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
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,58 @@ 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 the layer failed with, when applicable
- `errorHandler`: `true` when the layer is an error-handling middleware (4-arg signature)

The `error` event is published once, on the layer where the error originates,
whether the handler calls `next(err)`, throws, or returns a rejected promise. An
error bubbling up through outer layers is not reported again. The `'route'` and
`'router'` routing signals 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
176 changes: 151 additions & 25 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,28 @@ 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')

// Tracks errors already reported for a request so the same error bubbling up
// through mounted routers or forwarding error handlers is only reported once.
const publishedErrors = Symbol('router.tracing.publishedErrors')

// TracingChannel#hasSubscribers is undefined before Node 20.13/22, so check the sub-channels.
function shouldTrace (ch) {
return Boolean(ch) && (
ch.start.hasSubscribers ||
ch.end.hasSubscribers ||
ch.asyncStart.hasSubscribers ||
ch.asyncEnd.hasSubscribers ||
ch.error.hasSubscribers
)
}

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

try {
// invoke function
const ret = fn(error, req, res, next)
if (!shouldTrace(requestChannel)) {
try {
// invoke function
const ret = handlePromise(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')
// wait for returned promise
if (ret) {
ret.then(null, function (error) {
next(error || new Error('Rejected promise'))
})
}

ret.then(null, function (error) {
next(error || new Error('Rejected promise'))
})
} catch (err) {
next(err)
}
} catch (err) {
next(err)
return
}

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

/**
Expand All @@ -147,23 +174,122 @@ Layer.prototype.handleRequest = function handleRequest (req, res, next) {
return next()
}

try {
// invoke function
const ret = fn(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')
// Skip tracing for route dispatch wrappers (this.route is only set on the
// internal layer that calls route.dispatch); the user handlers inside the
// route are traced individually. Also skip when there are no subscribers,
// to avoid allocating a context object on every request.
if (this.route || !shouldTrace(requestChannel)) {
try {
// invoke function
const ret = handlePromise(fn(req, res, next))

// wait for returned promise
if (ret) {
ret.then(null, function (error) {
next(error || new Error('Rejected promise'))
})
}
} catch (err) {
next(err)
}
return
}

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

ret.then(null, function (error) {
next(error || new Error('Rejected promise'))
})
/**
* Record an error as reported for this request. Returns false when the same
* error was already reported, so it isn't published again as it bubbles up.
* @private
*/

function recordError (req, err) {
let seen = req[publishedErrors]
if (!seen) {
seen = req[publishedErrors] = new Set()
} else if (seen.has(err)) {
return false
}
seen.add(err)
return true
}

// 'route' and 'router' signal to exit the route / router (see route.js), not
// real errors.
function isRoutingSignal (err) {
return err === 'route' || err === 'router'
}

/**
* Invoke a handler wrapped in the request TracingChannel. Only called when the
* channel has subscribers, so ctx is always present.
* @private
*/

function invokeWithTrace (exec, ctx, next) {
const wrappedNext = function (err) {
// Routing signals aren't errors; only report a real error once, at its
// origin, not again as it bubbles up through outer layers.
if (err && !isRoutingSignal(err) && recordError(ctx.req, err)) {
ctx.error = err
requestChannel.error.publish(ctx)
}
} catch (err) {
next(err)
}

// runStores fires `start` and sets the async context; the rest are published
// manually, so a synchronous layer emits only start/end. All outcomes flow
// through wrappedNext to keep signal filtering and dedup in one place.
requestChannel.start.runStores(ctx, function () {
let out
try {
out = handlePromise(exec(wrappedNext))
} catch (err) {
// A sync throw is forwarded verbatim, so a falsy value keeps routing
// exactly as the untraced path does.
wrappedNext(err)
requestChannel.end.publish(ctx)
return
}

if (!out) {
requestChannel.end.publish(ctx)
return
}

requestChannel.end.publish(ctx)
out.then(
function () {
requestChannel.asyncStart.publish(ctx)
requestChannel.asyncEnd.publish(ctx)
},
function (err) {
// A rejected promise is an error even when the reason is falsy;
// normalize it to the error the router forwards to next().
wrappedNext(err || new Error('Rejected promise'))
requestChannel.asyncStart.publish(ctx)
requestChannel.asyncEnd.publish(ctx)
}
)
})
}

/**
* 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
}
}

/**
Expand Down
Loading