Skip to content

Commit 85f6398

Browse files
authored
fix(express): Ensure 404 routes don't attach route attribute (#2843)
1 parent d6e7fe7 commit 85f6398

File tree

4 files changed

+414
-11
lines changed

4 files changed

+414
-11
lines changed

plugins/node/opentelemetry-instrumentation-express/src/instrumentation.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import {
3232
getLayerPath,
3333
isLayerIgnored,
3434
storeLayerPath,
35+
getActualMatchedRoute,
36+
getConstructedRoute,
3537
} from './utils';
3638
/** @knipignore */
3739
import { PACKAGE_NAME, PACKAGE_VERSION } from './version';
@@ -188,23 +190,21 @@ export class ExpressInstrumentation extends InstrumentationBase<ExpressInstrumen
188190
res: express.Response
189191
) {
190192
const { isLayerPathStored } = storeLayerPath(req, layerPath);
191-
const route = (req[_LAYERS_STORE_PROPERTY] as string[])
192-
.filter(path => path !== '/' && path !== '/*')
193-
.join('')
194-
// remove duplicate slashes to normalize route
195-
.replace(/\/{2,}/g, '/');
193+
194+
const constructedRoute = getConstructedRoute(req);
195+
const actualMatchedRoute = getActualMatchedRoute(req);
196196

197197
const attributes: Attributes = {
198-
[ATTR_HTTP_ROUTE]: route.length > 0 ? route : '/',
198+
[ATTR_HTTP_ROUTE]: actualMatchedRoute,
199199
};
200-
const metadata = getLayerMetadata(route, layer, layerPath);
200+
const metadata = getLayerMetadata(constructedRoute, layer, layerPath);
201201
const type = metadata.attributes[
202202
AttributeNames.EXPRESS_TYPE
203203
] as ExpressLayerType;
204204

205205
const rpcMetadata = getRPCMetadata(context.active());
206206
if (rpcMetadata?.type === RPCType.HTTP) {
207-
rpcMetadata.route = route || '/';
207+
rpcMetadata.route = actualMatchedRoute;
208208
}
209209

210210
// verify against the config if the layer should be ignored
@@ -223,7 +223,7 @@ export class ExpressInstrumentation extends InstrumentationBase<ExpressInstrumen
223223
{
224224
request: req,
225225
layerType: type,
226-
route,
226+
route: constructedRoute,
227227
},
228228
metadata.name
229229
);
@@ -241,7 +241,7 @@ export class ExpressInstrumentation extends InstrumentationBase<ExpressInstrumen
241241
requestHook(span, {
242242
request: req,
243243
layerType: type,
244-
route,
244+
route: constructedRoute,
245245
}),
246246
e => {
247247
if (e) {

plugins/node/opentelemetry-instrumentation-express/src/utils.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,93 @@ const extractLayerPathSegment = (arg: LayerPathSegment) => {
213213

214214
return;
215215
};
216+
217+
export function getConstructedRoute(req: {
218+
originalUrl: PatchedRequest['originalUrl'];
219+
[_LAYERS_STORE_PROPERTY]?: string[];
220+
}) {
221+
const layersStore: string[] = Array.isArray(req[_LAYERS_STORE_PROPERTY])
222+
? (req[_LAYERS_STORE_PROPERTY] as string[])
223+
: [];
224+
225+
const meaningfulPaths = layersStore.filter(
226+
path => path !== '/' && path !== '/*'
227+
);
228+
229+
if (meaningfulPaths.length === 1 && meaningfulPaths[0] === '*') {
230+
return '*';
231+
}
232+
233+
// Join parts and remove duplicate slashes
234+
return meaningfulPaths.join('').replace(/\/{2,}/g, '/');
235+
}
236+
237+
/**
238+
* Extracts the actual matched route from Express request for OpenTelemetry instrumentation.
239+
* Returns the route that should be used as the http.route attribute.
240+
*
241+
* @param req - The Express request object with layers store
242+
* @param layersStoreProperty - The property name where layer paths are stored
243+
* @returns The matched route string or undefined if no valid route is found
244+
*/
245+
export function getActualMatchedRoute(req: {
246+
originalUrl: PatchedRequest['originalUrl'];
247+
[_LAYERS_STORE_PROPERTY]?: string[];
248+
}): string | undefined {
249+
const layersStore: string[] = Array.isArray(req[_LAYERS_STORE_PROPERTY])
250+
? (req[_LAYERS_STORE_PROPERTY] as string[])
251+
: [];
252+
253+
// If no layers are stored, no route can be determined
254+
if (layersStore.length === 0) {
255+
return undefined;
256+
}
257+
258+
// Handle root path case - if all paths are root, only return root if originalUrl is also root
259+
// The layer store also includes root paths in case a non-existing url was requested
260+
if (layersStore.every(path => path === '/')) {
261+
return req.originalUrl === '/' ? '/' : undefined;
262+
}
263+
264+
const constructedRoute = getConstructedRoute(req);
265+
if (constructedRoute === '*') {
266+
return constructedRoute;
267+
}
268+
269+
// For RegExp routes or route arrays, return the constructed route
270+
// This handles the case where the route is defined using RegExp or an array
271+
if (
272+
constructedRoute.includes('/') &&
273+
(constructedRoute.includes(',') ||
274+
constructedRoute.includes('\\') ||
275+
constructedRoute.includes('*') ||
276+
constructedRoute.includes('['))
277+
) {
278+
return constructedRoute;
279+
}
280+
281+
// Ensure route starts with '/' if it doesn't already
282+
const normalizedRoute = constructedRoute.startsWith('/')
283+
? constructedRoute
284+
: `/${constructedRoute}`;
285+
286+
// Validate that this appears to be a matched route
287+
// A route is considered matched if:
288+
// 1. We have a constructed route
289+
// 2. The original URL matches or starts with our route pattern
290+
const isValidRoute =
291+
normalizedRoute.length > 0 &&
292+
(req.originalUrl === normalizedRoute ||
293+
req.originalUrl.startsWith(normalizedRoute) ||
294+
isRoutePattern(normalizedRoute));
295+
296+
return isValidRoute ? normalizedRoute : undefined;
297+
}
298+
299+
/**
300+
* Checks if a route contains parameter patterns (e.g., :id, :userId)
301+
* which are valid even if they don't exactly match the original URL
302+
*/
303+
function isRoutePattern(route: string): boolean {
304+
return route.includes(':') || route.includes('*');
305+
}

plugins/node/opentelemetry-instrumentation-express/test/express.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,50 @@ describe('ExpressInstrumentation', () => {
6767
server?.close();
6868
});
6969

70+
it('does not attach semantic route attribute for 404 page', async () => {
71+
const rootSpan = tracer.startSpan('rootSpan');
72+
const httpServer = await serverWithMiddleware(tracer, rootSpan, app => {
73+
app.use(express.json());
74+
});
75+
server = httpServer.server;
76+
port = httpServer.port;
77+
assert.strictEqual(memoryExporter.getFinishedSpans().length, 0);
78+
79+
await context.with(
80+
trace.setSpan(context.active(), rootSpan),
81+
async () => {
82+
try {
83+
await httpRequest.get(
84+
`http://localhost:${port}/non-existing-route`
85+
);
86+
} catch (error) {}
87+
rootSpan.end();
88+
89+
const spans = memoryExporter.getFinishedSpans();
90+
91+
// Should have middleware spans but no request handler span
92+
const middlewareSpans = spans.filter(
93+
span =>
94+
span.name.includes('middleware') ||
95+
span.name.includes('expressInit') ||
96+
span.name.includes('jsonParser')
97+
);
98+
99+
assert.ok(
100+
middlewareSpans.length > 0,
101+
'Middleware spans should be created'
102+
);
103+
104+
for (const span of spans) {
105+
assert.strictEqual(
106+
span.attributes[ATTR_HTTP_ROUTE],
107+
undefined, // none of the spans have the HTTP route attribute
108+
`Span "${span.name}" should not have HTTP route attribute for non-existing route`
109+
);
110+
}
111+
}
112+
);
113+
});
70114
it('should create a child span for middlewares', async () => {
71115
const rootSpan = tracer.startSpan('rootSpan');
72116
const customMiddleware: express.RequestHandler = (req, res, next) => {

0 commit comments

Comments
 (0)