Skip to content

Commit d5298b5

Browse files
authored
fix(node): Adjust Express URL parameterization for array routes (#5495)
Fix an error thrown from our Node SDK that when our Express router parameterization logic (introduced in #5450) would try to parameterize a route consisting of an array of paths (which could be strings or RegExes). Since a crucial part of our parameterization approach is to determine if the currently resolved layer is the "last" part of the route (in which case we update the transaction name and source), this patch also makes a few modifications to determine this correctly for arrays. In order to do so, we check for the number of URL segments in the original URL vs. that of the parameterized URL. In the case of arrays, we'll likely have more segments than in the raw URL path. Therefore, we balance this out by determining the number of extra segments we got from the array. Additionally, added tests for array routes.
1 parent 101a023 commit d5298b5

File tree

3 files changed

+147
-11
lines changed

3 files changed

+147
-11
lines changed

packages/node-integration-tests/suites/express/tracing/server.ts

+8
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ app.get(/\/test\/regex/, (_req, res) => {
2525
res.send({ response: 'response 2' });
2626
});
2727

28+
app.get(['/test/array1', /\/test\/array[2-9]/], (_req, res) => {
29+
res.send({ response: 'response 3' });
30+
});
31+
32+
app.get(['/test/arr/:id', /\/test\/arr[0-9]*\/required(path)?(\/optionalPath)?\/(lastParam)?/], (_req, res) => {
33+
res.send({ response: 'response 4' });
34+
});
35+
2836
app.use(Sentry.Handlers.errorHandler());
2937

3038
export default app;

packages/node-integration-tests/suites/express/tracing/test.ts

+64
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,67 @@ test('should set a correct transaction name for routes specified in RegEx', asyn
5353
},
5454
});
5555
});
56+
57+
test.each([['array1'], ['array5']])(
58+
'should set a correct transaction name for routes consisting of arrays of routes',
59+
async segment => {
60+
const url = await runServer(__dirname, `${__dirname}/server.ts`);
61+
const envelope = await getEnvelopeRequest(`${url}/${segment}`);
62+
63+
expect(envelope).toHaveLength(3);
64+
65+
assertSentryTransaction(envelope[2], {
66+
transaction: 'GET /test/array1,/\\/test\\/array[2-9]',
67+
transaction_info: {
68+
source: 'route',
69+
},
70+
contexts: {
71+
trace: {
72+
data: {
73+
url: `/test/${segment}`,
74+
},
75+
op: 'http.server',
76+
status: 'ok',
77+
tags: {
78+
'http.status_code': '200',
79+
},
80+
},
81+
},
82+
});
83+
},
84+
);
85+
86+
test.each([
87+
['arr/545'],
88+
['arr/required'],
89+
['arr/required'],
90+
['arr/requiredPath'],
91+
['arr/required/lastParam'],
92+
['arr55/required/lastParam'],
93+
['arr/requiredPath/optionalPath/'],
94+
['arr/requiredPath/optionalPath/lastParam'],
95+
])('should handle more complex regexes in route arrays correctly', async segment => {
96+
const url = await runServer(__dirname, `${__dirname}/server.ts`);
97+
const envelope = await getEnvelopeRequest(`${url}/${segment}`);
98+
99+
expect(envelope).toHaveLength(3);
100+
101+
assertSentryTransaction(envelope[2], {
102+
transaction: 'GET /test/arr/:id,/\\/test\\/arr[0-9]*\\/required(path)?(\\/optionalPath)?\\/(lastParam)?',
103+
transaction_info: {
104+
source: 'route',
105+
},
106+
contexts: {
107+
trace: {
108+
data: {
109+
url: `/test/${segment}`,
110+
},
111+
op: 'http.server',
112+
status: 'ok',
113+
tags: {
114+
'http.status_code': '200',
115+
},
116+
},
117+
},
118+
});
119+
});

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

+75-11
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ type Router = {
3636
/* Extend the CrossPlatformRequest type with a patched parameter to build a reconstructed route */
3737
type PatchedRequest = CrossPlatformRequest & { _reconstructedRoute?: string };
3838

39-
/* Type used for pathing the express router prototype */
39+
/* Types used for patching the express router prototype */
4040
type ExpressRouter = Router & {
4141
_router?: ExpressRouter;
4242
stack?: Layer[];
@@ -51,14 +51,15 @@ type ExpressRouter = Router & {
5151
) => unknown;
5252
};
5353

54-
/* Type used for pathing the express router prototype */
5554
type Layer = {
5655
match: (path: string) => boolean;
5756
handle_request: (req: PatchedRequest, res: ExpressResponse, next: () => void) => void;
58-
route?: { path: string | RegExp };
57+
route?: { path: RouteType | RouteType[] };
5958
path?: string;
6059
};
6160

61+
type RouteType = string | RegExp;
62+
6263
interface ExpressResponse {
6364
once(name: string, callback: () => void): void;
6465
}
@@ -273,11 +274,8 @@ function instrumentRouter(appOrRouter: ExpressRouter): void {
273274
req._reconstructedRoute = '';
274275
}
275276

276-
// If the layer's partial route has params, the route is stored in layer.route.
277-
// Since a route might be defined with a RegExp, we convert it toString to make sure we end up with a string
278-
const lrp = layer.route?.path;
279-
const isRegex = isRegExp(lrp);
280-
const layerRoutePath = isRegex ? lrp?.toString() : (lrp as string);
277+
// If the layer's partial route has params, is a regex or an array, the route is stored in layer.route.
278+
const { layerRoutePath, isRegex, isArray, numExtraSegments }: LayerRoutePathInfo = getLayerRoutePathInfo(layer);
281279

282280
// Otherwise, the hardcoded path (i.e. a partial route without params) is stored in layer.path
283281
const partialRoute = layerRoutePath || layer.path || '';
@@ -289,7 +287,7 @@ function instrumentRouter(appOrRouter: ExpressRouter): void {
289287
// We want to end up with the parameterized URL of the incoming request without any extraneous path segments.
290288
const finalPartialRoute = partialRoute
291289
.split('/')
292-
.filter(segment => segment.length > 0 && !segment.includes('*'))
290+
.filter(segment => segment.length > 0 && (isRegex || isArray || !segment.includes('*')))
293291
.join('/');
294292

295293
// If we found a valid partial URL, we append it to the reconstructed route
@@ -301,7 +299,7 @@ function instrumentRouter(appOrRouter: ExpressRouter): void {
301299
// Now we check if we are in the "last" part of the route. We determine this by comparing the
302300
// number of URL segments from the original URL to that of our reconstructed parameterized URL.
303301
// If we've reached our final destination, we update the transaction name.
304-
const urlLength = getNumberOfUrlSegments(req.originalUrl || '');
302+
const urlLength = getNumberOfUrlSegments(req.originalUrl || '') + numExtraSegments;
305303
const routeLength = getNumberOfUrlSegments(req._reconstructedRoute);
306304

307305
if (urlLength === routeLength) {
@@ -319,7 +317,73 @@ function instrumentRouter(appOrRouter: ExpressRouter): void {
319317
};
320318
}
321319

320+
type LayerRoutePathInfo = {
321+
layerRoutePath?: string;
322+
isRegex: boolean;
323+
isArray: boolean;
324+
numExtraSegments: number;
325+
};
326+
327+
/**
328+
* Extracts and stringifies the layer's route which can either be a string with parameters (`users/:id`),
329+
* a RegEx (`/test/`) or an array of strings and regexes (`['/path1', /\/path[2-5]/, /path/:id]`). Additionally
330+
* returns extra information about the route, such as if the route is defined as regex or as an array.
331+
*
332+
* @param layer the layer to extract the stringified route from
333+
*
334+
* @returns an object containing the stringified route, a flag determining if the route was a regex
335+
* and the number of extra segments to the matched path that are additionally in the route,
336+
* if the route was an array (defaults to 0).
337+
*/
338+
function getLayerRoutePathInfo(layer: Layer): LayerRoutePathInfo {
339+
const lrp = layer.route?.path;
340+
341+
const isRegex = isRegExp(lrp);
342+
const isArray = Array.isArray(lrp);
343+
344+
if (!lrp) {
345+
return { isRegex, isArray, numExtraSegments: 0 };
346+
}
347+
348+
const numExtraSegments = isArray
349+
? Math.max(getNumberOfArrayUrlSegments(lrp as RouteType[]) - getNumberOfUrlSegments(layer.path || ''), 0)
350+
: 0;
351+
352+
const layerRoutePath = getLayerRoutePathString(isArray, lrp);
353+
354+
return { layerRoutePath, isRegex, isArray, numExtraSegments };
355+
}
356+
357+
/**
358+
* Returns the number of URL segments in an array of routes
359+
*
360+
* Example: ['/api/test', /\/api\/post[0-9]/, '/users/:id/details`] -> 7
361+
*/
362+
function getNumberOfArrayUrlSegments(routesArray: RouteType[]): number {
363+
return routesArray.reduce((accNumSegments: number, currentRoute: RouteType) => {
364+
// array members can be a RegEx -> convert them toString
365+
return accNumSegments + getNumberOfUrlSegments(currentRoute.toString());
366+
}, 0);
367+
}
368+
369+
/**
370+
* Returns number of URL segments of a passed URL.
371+
* Also handles URLs of type RegExp
372+
*/
322373
function getNumberOfUrlSegments(url: string): number {
323374
// split at '/' or at '\/' to split regex urls correctly
324-
return url.split(/\\?\//).filter(s => s.length > 0).length;
375+
return url.split(/\\?\//).filter(s => s.length > 0 && s !== ',').length;
376+
}
377+
378+
/**
379+
* Extracts and returns the stringified version of the layers route path
380+
* Handles route arrays (by joining the paths together) as well as RegExp and normal
381+
* string values (in the latter case the toString conversion is technically unnecessary but
382+
* it doesn't hurt us either).
383+
*/
384+
function getLayerRoutePathString(isArray: boolean, lrp?: RouteType | RouteType[]): string | undefined {
385+
if (isArray) {
386+
return (lrp as RouteType[]).map(r => r.toString()).join(',');
387+
}
388+
return lrp && lrp.toString();
325389
}

0 commit comments

Comments
 (0)