Skip to content

Commit 755ba73

Browse files
fix(tracing-internal): Parameterize express middleware parameters (#8668)
Account for the use case when path params are define in express middleware (`use`) not in express endpoint route (`get`, `post`, .etc).
1 parent 771be51 commit 755ba73

File tree

8 files changed

+352
-3
lines changed

8 files changed

+352
-3
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as Sentry from '@sentry/node';
2+
import * as Tracing from '@sentry/tracing';
3+
import express from 'express';
4+
5+
const app = express();
6+
7+
Sentry.init({
8+
dsn: 'https://[email protected]/1337',
9+
release: '1.0',
10+
// eslint-disable-next-line deprecation/deprecation
11+
integrations: [new Sentry.Integrations.Http({ tracing: true }), new Tracing.Integrations.Express({ app })],
12+
tracesSampleRate: 1.0,
13+
});
14+
15+
app.use(Sentry.Handlers.requestHandler());
16+
app.use(Sentry.Handlers.tracingHandler());
17+
18+
const APIv1 = express.Router();
19+
20+
APIv1.use(
21+
'/users/:userId',
22+
APIv1.get('/posts/:postId', (_req, res) => {
23+
Sentry.captureMessage('Custom Message');
24+
return res.send('Success');
25+
}),
26+
);
27+
28+
const router = express.Router();
29+
30+
app.use('/api', router);
31+
app.use('/api/api/v1', APIv1.use('/sub-router', APIv1));
32+
33+
app.use(Sentry.Handlers.errorHandler());
34+
35+
export default app;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { assertSentryEvent, TestEnv } from '../../../../utils/index';
2+
3+
test('should construct correct url with multiple parameterized routers, when param is also contain in middle layer route and express used multiple middlewares with route', async () => {
4+
const env = await TestEnv.init(__dirname, `${__dirname}/server.ts`);
5+
const event = await env.getEnvelopeRequest({
6+
url: env.url.replace('test', 'api/api/v1/sub-router/users/123/posts/456'),
7+
envelopeType: 'transaction',
8+
});
9+
// parse node.js major version
10+
const [major] = process.versions.node.split('.').map(Number);
11+
// Split test result base on major node version because regex d flag is support from node 16+
12+
if (major >= 16) {
13+
assertSentryEvent(event[2] as any, {
14+
transaction: 'GET /api/api/v1/sub-router/users/:userId/posts/:postId',
15+
transaction_info: {
16+
source: 'route',
17+
},
18+
});
19+
} else {
20+
assertSentryEvent(event[2] as any, {
21+
transaction: 'GET /api/api/v1/sub-router/users/123/posts/:postId',
22+
transaction_info: {
23+
source: 'route',
24+
},
25+
});
26+
}
27+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as Sentry from '@sentry/node';
2+
import * as Tracing from '@sentry/tracing';
3+
import express from 'express';
4+
5+
const app = express();
6+
7+
Sentry.init({
8+
dsn: 'https://[email protected]/1337',
9+
release: '1.0',
10+
// eslint-disable-next-line deprecation/deprecation
11+
integrations: [new Sentry.Integrations.Http({ tracing: true }), new Tracing.Integrations.Express({ app })],
12+
tracesSampleRate: 1.0,
13+
});
14+
15+
app.use(Sentry.Handlers.requestHandler());
16+
app.use(Sentry.Handlers.tracingHandler());
17+
18+
const APIv1 = express.Router();
19+
20+
APIv1.use(
21+
'/users/:userId',
22+
APIv1.get('/posts/:postId', (_req, res) => {
23+
Sentry.captureMessage('Custom Message');
24+
return res.send('Success');
25+
}),
26+
);
27+
28+
const root = express.Router();
29+
30+
app.use('/api/v1', APIv1);
31+
app.use('/api', root);
32+
33+
app.use(Sentry.Handlers.errorHandler());
34+
35+
export default app;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { assertSentryEvent, TestEnv } from '../../../../utils/index';
2+
3+
test('should construct correct url with multiple parameterized routers, when param is also contain in middle layer route', async () => {
4+
const env = await TestEnv.init(__dirname, `${__dirname}/server.ts`);
5+
const event = await env.getEnvelopeRequest({
6+
url: env.url.replace('test', 'api/v1/users/123/posts/456'),
7+
envelopeType: 'transaction',
8+
});
9+
10+
// parse node.js major version
11+
const [major] = process.versions.node.split('.').map(Number);
12+
// Split test result base on major node version because regex d flag is support from node 16+
13+
if (major >= 16) {
14+
assertSentryEvent(event[2] as any, {
15+
transaction: 'GET /api/v1/users/:userId/posts/:postId',
16+
transaction_info: {
17+
source: 'route',
18+
},
19+
});
20+
} else {
21+
assertSentryEvent(event[2] as any, {
22+
transaction: 'GET /api/v1/users/123/posts/:postId',
23+
transaction_info: {
24+
source: 'route',
25+
},
26+
});
27+
}
28+
});

packages/node/src/requestdata.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,9 @@ function extractTransaction(req: PolymorphicRequest, type: boolean | Transaction
109109
}
110110
case 'methodPath':
111111
default: {
112-
return extractPathForTransaction(req, { path: true, method: true })[0];
112+
// if exist _reconstructedRoute return that path instead of route.path
113+
const customRoute = req._reconstructedRoute ? req._reconstructedRoute : undefined;
114+
return extractPathForTransaction(req, { path: true, method: true, customRoute })[0];
113115
}
114116
}
115117
}

packages/tracing-internal/src/node/integrations/express.ts

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ type Layer = {
6464
handle_request: (req: PatchedRequest, res: ExpressResponse, next: () => void) => void;
6565
route?: { path: RouteType | RouteType[] };
6666
path?: string;
67+
regexp?: RegExp;
68+
keys?: { name: string; offset: number; optional: boolean }[];
6769
};
6870

6971
type RouteType = string | RegExp;
@@ -318,7 +320,24 @@ function instrumentRouter(appOrRouter: ExpressRouter): void {
318320
}
319321

320322
// Otherwise, the hardcoded path (i.e. a partial route without params) is stored in layer.path
321-
const partialRoute = layerRoutePath || layer.path || '';
323+
let partialRoute;
324+
325+
if (layerRoutePath) {
326+
partialRoute = layerRoutePath;
327+
} else {
328+
/**
329+
* prevent duplicate segment in _reconstructedRoute param if router match multiple routes before final path
330+
* example:
331+
* original url: /api/v1/1234
332+
* prevent: /api/api/v1/:userId
333+
* router structure
334+
* /api -> middleware
335+
* /api/v1 -> middleware
336+
* /1234 -> endpoint with param :userId
337+
* final _reconstructedRoute is /api/v1/:userId
338+
*/
339+
partialRoute = preventDuplicateSegments(req.originalUrl, req._reconstructedRoute, layer.path) || '';
340+
}
322341

323342
// Normalize the partial route so that it doesn't contain leading or trailing slashes
324343
// and exclude empty or '*' wildcard routes.
@@ -370,6 +389,79 @@ type LayerRoutePathInfo = {
370389
numExtraSegments: number;
371390
};
372391

392+
/**
393+
* Recreate layer.route.path from layer.regexp and layer.keys.
394+
* Works until express.js used package [email protected]
395+
* or until layer.keys contain offset attribute
396+
*
397+
* @param layer the layer to extract the stringified route from
398+
*
399+
* @returns string in layer.route.path structure 'router/:pathParam' or undefined
400+
*/
401+
export const extractOriginalRoute = (
402+
path?: Layer['path'],
403+
regexp?: Layer['regexp'],
404+
keys?: Layer['keys'],
405+
): string | undefined => {
406+
if (!path || !regexp || !keys || Object.keys(keys).length === 0 || !keys[0]?.offset) {
407+
return undefined;
408+
}
409+
410+
const orderedKeys = keys.sort((a, b) => a.offset - b.offset);
411+
412+
// add d flag for getting indices from regexp result
413+
const pathRegex = new RegExp(regexp, `${regexp.flags}d`);
414+
/**
415+
* use custom type cause of TS error with missing indices in RegExpExecArray
416+
*/
417+
const execResult = pathRegex.exec(path) as (RegExpExecArray & { indices: [number, number][] }) | null;
418+
419+
if (!execResult || !execResult.indices) {
420+
return undefined;
421+
}
422+
/**
423+
* remove first match from regex cause contain whole layer.path
424+
*/
425+
const [, ...paramIndices] = execResult.indices;
426+
427+
if (paramIndices.length !== orderedKeys.length) {
428+
return undefined;
429+
}
430+
let resultPath = path;
431+
let indexShift = 0;
432+
433+
/**
434+
* iterate param matches from regexp.exec
435+
*/
436+
paramIndices.forEach(([startOffset, endOffset], index: number) => {
437+
/**
438+
* isolate part before param
439+
*/
440+
const substr1 = resultPath.substring(0, startOffset - indexShift);
441+
/**
442+
* define paramName as replacement in format :pathParam
443+
*/
444+
const replacement = `:${orderedKeys[index].name}`;
445+
446+
/**
447+
* isolate part after param
448+
*/
449+
const substr2 = resultPath.substring(endOffset - indexShift);
450+
451+
/**
452+
* recreate original path but with param replacement
453+
*/
454+
resultPath = substr1 + replacement + substr2;
455+
456+
/**
457+
* calculate new index shift after resultPath was modified
458+
*/
459+
indexShift = indexShift + (endOffset - startOffset - replacement.length);
460+
});
461+
462+
return resultPath;
463+
};
464+
373465
/**
374466
* Extracts and stringifies the layer's route which can either be a string with parameters (`users/:id`),
375467
* a RegEx (`/test/`) or an array of strings and regexes (`['/path1', /\/path[2-5]/, /path/:id]`). Additionally
@@ -382,11 +474,24 @@ type LayerRoutePathInfo = {
382474
* if the route was an array (defaults to 0).
383475
*/
384476
function getLayerRoutePathInfo(layer: Layer): LayerRoutePathInfo {
385-
const lrp = layer.route?.path;
477+
let lrp = layer.route?.path;
386478

387479
const isRegex = isRegExp(lrp);
388480
const isArray = Array.isArray(lrp);
389481

482+
if (!lrp) {
483+
// parse node.js major version
484+
const [major] = process.versions.node.split('.').map(Number);
485+
486+
// allow call extractOriginalRoute only if node version support Regex d flag, node 16+
487+
if (major >= 16) {
488+
/**
489+
* If lrp does not exist try to recreate original layer path from route regexp
490+
*/
491+
lrp = extractOriginalRoute(layer.path, layer.regexp, layer.keys);
492+
}
493+
}
494+
390495
if (!lrp) {
391496
return { isRegex, isArray, numExtraSegments: 0 };
392497
}
@@ -424,3 +529,28 @@ function getLayerRoutePathString(isArray: boolean, lrp?: RouteType | RouteType[]
424529
}
425530
return lrp && lrp.toString();
426531
}
532+
533+
/**
534+
* remove duplicate segment contain in layerPath against reconstructedRoute,
535+
* and return only unique segment that can be added into reconstructedRoute
536+
*/
537+
export function preventDuplicateSegments(
538+
originalUrl?: string,
539+
reconstructedRoute?: string,
540+
layerPath?: string,
541+
): string | undefined {
542+
const originalUrlSplit = originalUrl?.split('/').filter(v => !!v);
543+
let tempCounter = 0;
544+
const currentOffset = reconstructedRoute?.split('/').filter(v => !!v).length || 0;
545+
const result = layerPath
546+
?.split('/')
547+
.filter(segment => {
548+
if (originalUrlSplit?.[currentOffset + tempCounter] === segment) {
549+
tempCounter += 1;
550+
return true;
551+
}
552+
return false;
553+
})
554+
.join('/');
555+
return result;
556+
}

0 commit comments

Comments
 (0)