Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sanitize paths for Prometheus #94

Merged
merged 1 commit into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ app.use(
makeMetricsApiMiddleware({
port: 9082,
isNormalizePathEnabled: true,
discardUnmatched: false,
discardUnmatched: true,
}),
);

Expand Down
86 changes: 39 additions & 47 deletions src/middleware/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import UrlPattern from 'url-pattern';

import logger from '../utils/logging';

const withoutTrailingSlash = (path: string) => {
// Express routes don't include a trailing slash unless it's actually just `/` path, then it stays
if (path !== '/' && path.endsWith('/')) {
return path.slice(0, -1);
}
return path;
};

type Options = {
port?: number,
isNormalizePathEnabled?: boolean,
Expand All @@ -32,69 +40,46 @@ const setupMetricService = ({ port }: Options = { port: 9082 }) => {
});
};

const captureAllRoutes = ({ urlPatternMaker }: Options, app: express.Express) => {
const allRoutes = listEndpoints(app).filter(
(route) => route.path !== '/*' && route.path !== '*',
);

return allRoutes.map((route) => {
const path = route.path.endsWith('/') ? route.path.replace(/\/$/, '') : route.path;

logger.log('debug', `Route found: ${route.path} - ${JSON.stringify(route)}`);
// route.pattern = route.path; // TODO remove?

// NOTE: urlPatternMaker has to create an UrlPattern compatible object.
const pattern = urlPatternMaker ? urlPatternMaker(route.path) : new UrlPattern(route.path, {
segmentNameCharset: 'a-zA-Z0-9_-',
}).toString();

if (!pattern) {
logger.log('debug', 'skipping route due to empty path');
}

return { ...route, path: pattern || path };
});
type Route = {
methods: string[],
path: string,
pattern: UrlPattern
};

const makeMetricsApiMiddleware = (options: Options = {}) => {
const { isNormalizePathEnabled, discardUnmatched, createServer } = options;
const allRoutes = [] as { methods: string[], path: string, pattern?: UrlPattern }[];
let allRoutes: Route[];

// This function takes a path request and returns a path that should go into the `path` label of the metric
// The goal here is to not have an infinite number of paths so get rid of path variables and unknown paths
const normalizePath: NormalizePathFn = (req, _opts) => {
if (!isNormalizePathEnabled) {
return req.url;
}

const path = (() => {
const parsedPath = url.parse(req.originalUrl || req.url).pathname || req.url;
if (parsedPath.endsWith('/')) {
// Remove trailing slash
return parsedPath.replace(/\/$/, '');
}
return parsedPath;
})();

const pattern = allRoutes.filter((route) => {
if (route.methods.indexOf(req.method) === -1) {
const parsedPath = url.parse(req.originalUrl || req.url).pathname || req.url;
const path = withoutTrailingSlash(parsedPath);

const matchingRoute = allRoutes.find((route) => {
if (!route.methods.includes(req.method)) {
return false;
}

// match will be null if it doesn't fit the pattern and will be some object depending on path params if it does
// eg path /abc/123 will match route /abc/:id with object {id: 123} but will return null for route /xyz/:id
try {
if (route.path.match(path)) {
return true;
}
const match: null | object = route.pattern.match(path);
return match !== null;
} catch (e: unknown) {
logger.error('Error: something went wrong.');
return false;
}
return false;
})[0]?.pattern;
});

if (discardUnmatched && !pattern) {
return '';
if (discardUnmatched && !matchingRoute) {
return 'unmatched-url';
}

return pattern?.stringify() || path || '';
return matchingRoute?.path ?? 'no-path';
};

const metricsMiddleware = promBundle({
Expand All @@ -113,11 +98,18 @@ const makeMetricsApiMiddleware = (options: Options = {}) => {
}

const handler: RequestHandler = (req, res, next) => {
if (allRoutes.length === 0) {
if (!allRoutes) {
// Scrape all the paths registered with express on the first recording of metrics. These paths will later be used
// to ensure we don't use unknown paths (ie spam calls) in metric labels and don't overwhelm Prometheus.
// Unfortunately, this doesn't include static paths since they are not registered with Express. If desired,
// we could add them by recursively listing /public.
try {
captureAllRoutes(options, req.app as express.Express).forEach((route) => {
allRoutes.push(route);
});
allRoutes = listEndpoints(req.app as express.Express)
.filter((route) => route.path !== '/*' && route.path !== '*')
.map((route) => ({
...route,
pattern: new UrlPattern(route.path),
}));
} catch (e) {
logger.log('error', `unable to capture route for prom-metrics: ${e}`);
}
Expand Down
Loading