Skip to content

Commit ee5c584

Browse files
onurtemizkanAbhiPrasadJamieDanielson
authored
feat(instrumentation-express): Use router path in router span names (#2319)
This PR adds a new utility (getRouterPath) to recursively search the (possibly nested) parameterised router path when getting the layer metadata. This only affects the span name, not the http.route attribute of the span. http.route is still showing the mount path. (Active discussion on this: Express instrumentation does not properly handle router usage #1993 (comment)) Uses route primarily to assign the requestHandler span name instead of layerPath. layerPath does not represent the full path, only the path of the last matching router when nested routers are used. Co-authored-by: Abhijeet Prasad <[email protected]> Co-authored-by: Jamie Danielson <[email protected]> Co-authored-by: Jamie Danielson <[email protected]>
1 parent aa9710f commit ee5c584

File tree

7 files changed

+320
-9
lines changed

7 files changed

+320
-9
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ export class ExpressInstrumentation extends InstrumentationBase<ExpressInstrumen
180180
const attributes: Attributes = {
181181
[SEMATTRS_HTTP_ROUTE]: route.length > 0 ? route : '/',
182182
};
183-
const metadata = getLayerMetadata(layer, layerPath);
183+
const metadata = getLayerMetadata(route, layer, layerPath);
184184
const type = metadata.attributes[
185185
AttributeNames.EXPRESS_TYPE
186186
] as ExpressLayerType;

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export type ExpressLayer = {
6464
params: { [key: string]: string };
6565
path: string;
6666
regexp: RegExp;
67+
route?: ExpressLayer;
6768
};
6869

6970
export type LayerMetadata = {

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

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,33 +44,61 @@ export const storeLayerPath = (request: PatchedRequest, value?: string) => {
4444
(request[_LAYERS_STORE_PROPERTY] as string[]).push(value);
4545
};
4646

47+
/**
48+
* Recursively search the router path from layer stack
49+
* @param path The path to reconstruct
50+
* @param layer The layer to reconstruct from
51+
* @returns The reconstructed path
52+
*/
53+
export const getRouterPath = (path: string, layer: ExpressLayer): string => {
54+
const stackLayer = layer.handle?.stack?.[0];
55+
56+
if (stackLayer?.route?.path) {
57+
return `${path}${stackLayer.route.path}`;
58+
}
59+
60+
if (stackLayer?.handle?.stack) {
61+
return getRouterPath(path, stackLayer);
62+
}
63+
64+
return path;
65+
};
66+
4767
/**
4868
* Parse express layer context to retrieve a name and attributes.
69+
* @param route The route of the layer
4970
* @param layer Express layer
5071
* @param [layerPath] if present, the path on which the layer has been mounted
5172
*/
5273
export const getLayerMetadata = (
74+
route: string,
5375
layer: ExpressLayer,
5476
layerPath?: string
5577
): {
5678
attributes: Attributes;
5779
name: string;
5880
} => {
5981
if (layer.name === 'router') {
82+
const maybeRouterPath = getRouterPath('', layer);
83+
const extractedRouterPath = maybeRouterPath
84+
? maybeRouterPath
85+
: layerPath || route || '/';
86+
6087
return {
6188
attributes: {
62-
[AttributeNames.EXPRESS_NAME]: layerPath,
89+
[AttributeNames.EXPRESS_NAME]: extractedRouterPath,
6390
[AttributeNames.EXPRESS_TYPE]: ExpressLayerType.ROUTER,
6491
},
65-
name: `router - ${layerPath}`,
92+
name: `router - ${extractedRouterPath}`,
6693
};
6794
} else if (layer.name === 'bound dispatch') {
6895
return {
6996
attributes: {
70-
[AttributeNames.EXPRESS_NAME]: layerPath ?? 'request handler',
97+
[AttributeNames.EXPRESS_NAME]:
98+
(route || layerPath) ?? 'request handler',
7199
[AttributeNames.EXPRESS_TYPE]: ExpressLayerType.REQUEST_HANDLER,
72100
},
73-
name: `request handler${layer.path ? ` - ${layerPath}` : ''}`,
101+
name: `request handler${layer.path ? ` - ${route || layerPath}` : ''}`,
74102
};
75103
} else {
76104
return {
@@ -159,11 +187,13 @@ export const asErrorAndMessage = (
159187
export const getLayerPath = (
160188
args: [LayerPathSegment | LayerPathSegment[], ...unknown[]]
161189
): string | undefined => {
162-
if (Array.isArray(args[0])) {
163-
return args[0].map(arg => extractLayerPathSegment(arg) || '').join(',');
190+
const firstArg = args[0];
191+
192+
if (Array.isArray(firstArg)) {
193+
return firstArg.map(arg => extractLayerPathSegment(arg) || '').join(',');
164194
}
165195

166-
return extractLayerPathSegment(args[0]);
196+
return extractLayerPathSegment(firstArg);
167197
};
168198

169199
const extractLayerPathSegment = (arg: LayerPathSegment) => {

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,86 @@ describe('ExpressInstrumentation', () => {
641641
});
642642
});
643643

644+
it('should work with Express routers', async () => {
645+
await testUtils.runTestFixture({
646+
cwd: __dirname,
647+
argv: ['fixtures/use-express-router.mjs'],
648+
env: {
649+
NODE_OPTIONS:
650+
'--experimental-loader=@opentelemetry/instrumentation/hook.mjs',
651+
NODE_NO_WARNINGS: '1',
652+
},
653+
checkResult: (err, stdout, stderr) => {
654+
assert.ifError(err);
655+
},
656+
checkCollector: (collector: testUtils.TestCollector) => {
657+
const spans = collector.sortedSpans;
658+
659+
assert.strictEqual(spans[0].name, 'GET');
660+
assert.strictEqual(spans[0].kind, testUtils.OtlpSpanKind.CLIENT);
661+
assert.strictEqual(spans[1].name, 'GET /api/user/:id');
662+
assert.strictEqual(spans[1].kind, testUtils.OtlpSpanKind.SERVER);
663+
assert.strictEqual(spans[1].parentSpanId, spans[0].spanId);
664+
assert.strictEqual(spans[2].name, 'middleware - query');
665+
assert.strictEqual(spans[3].kind, testUtils.OtlpSpanKind.INTERNAL);
666+
assert.strictEqual(spans[3].parentSpanId, spans[1].spanId);
667+
assert.strictEqual(spans[4].name, 'middleware - simpleMiddleware');
668+
assert.strictEqual(spans[4].kind, testUtils.OtlpSpanKind.INTERNAL);
669+
assert.strictEqual(spans[4].parentSpanId, spans[1].spanId);
670+
assert.strictEqual(spans[5].name, 'router - /api/user/:id');
671+
assert.strictEqual(spans[5].kind, testUtils.OtlpSpanKind.INTERNAL);
672+
assert.strictEqual(spans[5].parentSpanId, spans[1].spanId);
673+
assert.strictEqual(spans[6].name, 'request handler - /api/user/:id');
674+
assert.strictEqual(spans[6].kind, testUtils.OtlpSpanKind.INTERNAL);
675+
assert.strictEqual(spans[6].parentSpanId, spans[1].spanId);
676+
},
677+
});
678+
});
679+
680+
it('should work with nested Express routers', async () => {
681+
await testUtils.runTestFixture({
682+
cwd: __dirname,
683+
argv: ['fixtures/use-express-nested-router.mjs'],
684+
env: {
685+
NODE_OPTIONS:
686+
'--experimental-loader=@opentelemetry/instrumentation/hook.mjs',
687+
NODE_NO_WARNINGS: '1',
688+
},
689+
checkResult: (err, stdout, stderr) => {
690+
assert.ifError(err);
691+
},
692+
checkCollector: (collector: testUtils.TestCollector) => {
693+
const spans = collector.sortedSpans;
694+
695+
assert.strictEqual(spans[0].name, 'GET');
696+
assert.strictEqual(spans[0].kind, testUtils.OtlpSpanKind.CLIENT);
697+
assert.strictEqual(spans[1].name, 'GET /api/user/:id/posts/:postId');
698+
assert.strictEqual(spans[1].kind, testUtils.OtlpSpanKind.SERVER);
699+
assert.strictEqual(spans[2].name, 'middleware - query');
700+
assert.strictEqual(spans[2].kind, testUtils.OtlpSpanKind.INTERNAL);
701+
assert.strictEqual(spans[2].parentSpanId, spans[1].spanId);
702+
assert.strictEqual(spans[3].name, 'middleware - expressInit');
703+
assert.strictEqual(spans[3].kind, testUtils.OtlpSpanKind.INTERNAL);
704+
assert.strictEqual(spans[3].parentSpanId, spans[1].spanId);
705+
assert.strictEqual(spans[4].name, 'middleware - simpleMiddleware');
706+
assert.strictEqual(spans[4].kind, testUtils.OtlpSpanKind.INTERNAL);
707+
assert.strictEqual(spans[4].parentSpanId, spans[1].spanId);
708+
assert.strictEqual(spans[5].name, 'router - /api/user/:id');
709+
assert.strictEqual(spans[5].kind, testUtils.OtlpSpanKind.INTERNAL);
710+
assert.strictEqual(spans[5].parentSpanId, spans[1].spanId);
711+
assert.strictEqual(spans[6].name, 'router - /:postId');
712+
assert.strictEqual(spans[6].kind, testUtils.OtlpSpanKind.INTERNAL);
713+
assert.strictEqual(spans[6].parentSpanId, spans[1].spanId);
714+
assert.strictEqual(
715+
spans[7].name,
716+
'request handler - /api/user/:id/posts/:postId'
717+
);
718+
assert.strictEqual(spans[7].kind, testUtils.OtlpSpanKind.INTERNAL);
719+
assert.strictEqual(spans[7].parentSpanId, spans[1].spanId);
720+
},
721+
});
722+
});
723+
644724
it('should set a correct transaction name for routes specified in RegEx', async () => {
645725
await testUtils.runTestFixture({
646726
cwd: __dirname,
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { promisify } from 'util';
18+
import { createTestNodeSdk } from '@opentelemetry/contrib-test-utils';
19+
20+
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
21+
import { ExpressInstrumentation } from '../../build/src/index.js';
22+
23+
const sdk = createTestNodeSdk({
24+
serviceName: 'use-express-nested',
25+
instrumentations: [
26+
new HttpInstrumentation(),
27+
new ExpressInstrumentation()
28+
]
29+
})
30+
31+
sdk.start();
32+
33+
import express from 'express';
34+
import * as http from 'http';
35+
36+
const app = express();
37+
38+
app.use(async function simpleMiddleware(req, res, next) {
39+
// Wait a short delay to ensure this "middleware - ..." span clearly starts
40+
// before the "router - ..." span. The test rely on a start-time-based sort
41+
// of the produced spans. If they start in the same millisecond, then tests
42+
// can be flaky.
43+
await promisify(setTimeout)(10);
44+
next();
45+
});
46+
47+
const userRouter = express.Router();
48+
const postsRouter = express.Router();
49+
50+
postsRouter.get('/:postId', (req, res, next) => {
51+
res.json({ hello: 'yes' });
52+
res.end();
53+
next();
54+
});
55+
56+
userRouter.get('/api/user/:id', (req, res, next) => {
57+
res.json({ hello: 'yes' });
58+
res.end();
59+
next();
60+
});
61+
62+
userRouter.use('/api/user/:id/posts', postsRouter);
63+
64+
app.use(userRouter);
65+
66+
const server = http.createServer(app);
67+
await new Promise(resolve => server.listen(0, resolve));
68+
const port = server.address().port;
69+
70+
71+
await new Promise(resolve => {
72+
http.get(`http://localhost:${port}/api/user/123/posts/321`, (res) => {
73+
res.resume();
74+
res.on('end', data => {
75+
resolve(data);
76+
});
77+
})
78+
});
79+
80+
await new Promise(resolve => server.close(resolve));
81+
await sdk.shutdown();
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { promisify } from 'util';
18+
import { createTestNodeSdk } from '@opentelemetry/contrib-test-utils';
19+
20+
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
21+
import { ExpressInstrumentation } from '../../build/src/index.js';
22+
23+
const sdk = createTestNodeSdk({
24+
serviceName: 'use-express-nested',
25+
instrumentations: [
26+
new HttpInstrumentation(),
27+
new ExpressInstrumentation()
28+
]
29+
})
30+
31+
sdk.start();
32+
33+
import express from 'express';
34+
import * as http from 'http';
35+
36+
const app = express();
37+
38+
app.use(async function simpleMiddleware(req, res, next) {
39+
// Wait a short delay to ensure this "middleware - ..." span clearly starts
40+
// before the "router - ..." span. The test rely on a start-time-based sort
41+
// of the produced spans. If they start in the same millisecond, then tests
42+
// can be flaky.
43+
await promisify(setTimeout)(10);
44+
next();
45+
});
46+
47+
const router = express.Router();
48+
49+
router.get('/api/user/:id', (req, res, next) => {
50+
res.json({ hello: 'yes' });
51+
res.end();
52+
next();
53+
});
54+
55+
app.use(router);
56+
57+
const server = http.createServer(app);
58+
await new Promise(resolve => server.listen(0, resolve));
59+
const port = server.address().port;
60+
61+
62+
await new Promise(resolve => {
63+
http.get(`http://localhost:${port}/api/user/123`, (res) => {
64+
res.resume();
65+
res.on('end', data => {
66+
resolve(data);
67+
});
68+
})
69+
});
70+
71+
await new Promise(resolve => server.close(resolve));
72+
await sdk.shutdown();

0 commit comments

Comments
 (0)