-
Notifications
You must be signed in to change notification settings - Fork 620
Description
What version of OpenTelemetry are you using?
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/instrumentation-express": "^0.57.0",
"@opentelemetry/instrumentation-http": "^0.208.0",
"@opentelemetry/sdk-metrics": "^2.2.0",
"@opentelemetry/sdk-node": "^0.208.0",
"@opentelemetry/sdk-trace-node": "^2.2.0",
"express": "^5.1.0"
}What version of Node are you using?
v24.11.0
This behavior was also reproduced in other applications using v18 and above.
What did you do?
When an Express application includes a bare middleware that returns a response before the route handler runs — such as an ETag cache check returning 304 Not Modified — the http.route attribute isn't populated with the full matched route.
The snippet below is a minimal example. Full code is available at: https://github.com/vitorvasc/js-otel-instrumentation-express.
main.js
// Bare Middleware that may return early (e.g., cache hit with 304)
apiRouter.use([(req, res, next) => {
const etag = req.headers['if-none-match'];
const currentETag = '"12345-abcdef"';
if (etag && etag === currentETag) {
res.status(304).end(); // Early return
return;
}
next();
}]);
// Define routes
apiRouter.get('/users', handler);
apiRouter.get('/products', handler);
// Mount router
app.use('/', apiRouter);cURL
curl http://localhost:${port}/api/users -H 'If-None-Match: "12345-abcdef"'What did you expect to see?
Even if a middleware responds early, the http.route attribute should reflect the full matched route.
Expected behavior:
- Normal request (200):
http.route = "/api/users" - Bare middleware (304):
http.route = "/api/users"
What did you see instead?
When a bare middleware finishes the request, the instrumentation captures only the router's mount path and loses the specific route information:
Actual behavior:
- Normal request (200):
http.route = "/api/users" - Bare middleware (304):
http.route = "/api"
In this scenario, only /api is recorded instead of /api/users.
Metric output: `http.server.duration`
{
descriptor: {
name: 'http.server.duration',
type: 'HISTOGRAM',
description: 'Measures the duration of inbound HTTP requests.',
unit: 'ms',
valueType: 1,
advice: {}
},
dataPointType: 0,
dataPoints: [
{
attributes: {
'http.scheme': 'http',
'http.method': 'GET',
'net.host.name': 'localhost',
'http.flavor': '1.1',
'http.status_code': 304,
'net.host.port': 3000,
'http.route': '/'
},
startTime: [ 1763685623, 177000000 ],
endTime: [ 1763685624, 307000000 ],
value: {
min: 12.619138,
max: 12.619138,
sum: 12.619138,
buckets: {
boundaries: [
0, 5, 10, 25,
50, 75, 100, 250,
500, 750, 1000, 2500,
5000, 7500, 10000
],
counts: [
0, 0, 0, 1, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0
]
},
count: 1
}
}
]
}Additional context
I'm not sure whether this could be related to #1947.
Although the example uses an ETag scenario, this behavior appears in any application where bare middleware finishes the request before reaching an actual router — such as cached endpoints (HTTP 304), authentication (HTTP 401/403), rate limiting (HTTP 429), or request validation errors (HTTP 400).
If this is confirmed to be a valid issue from @opentelemetry/instrumentation-express, I'm happy to open a PR and contribute with a fix.
Tip: React with 👍 to help prioritize this issue. Please use comments to provide useful context, avoiding +1 or me too, to help us triage it. Learn more here.