Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/silent-shoes-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fixes an issue with REST API metrics sent to Prometheus not grouping endpoints by route
2 changes: 1 addition & 1 deletion apps/meteor/app/api/server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1217,7 +1217,7 @@ export const startRestAPI = () => {
.use(remoteAddressMiddleware)
.use(cors(settings))
.use(loggerMiddleware(logger))
.use(metricsMiddleware(API.v1, settings, metrics.rocketchatRestApi))
.use(metricsMiddleware({ basePathRegex: new RegExp(/^\/api\/v1\//), api: API.v1, settings, summary: metrics.rocketchatRestApi }))
.use(tracerSpanMiddleware)
.use(API.v1.router)
.use(API.default.router).router,
Expand Down
106 changes: 104 additions & 2 deletions apps/meteor/app/api/server/middlewares/metrics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe('Metrics middleware', () => {
const mockEndTimer = jest.fn();
summary.startTimer.mockReturnValue(mockEndTimer);

api.use(metricsMiddleware({ version: 1 } as any, settings, summary as any)).get(
api.use(metricsMiddleware({ api: { version: 1 } as any, settings, summary: summary as any })).get(
'/test',
{
response: {
Expand All @@ -52,6 +52,108 @@ describe('Metrics middleware', () => {
expect(response.body).toHaveProperty('message', 'Metrics test successful');

expect(summary.startTimer).toHaveBeenCalledTimes(1);
expect(mockEndTimer).toHaveBeenCalledWith({ status: 200 });
expect(mockEndTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 1, user_agent: 'test', entrypoint: '/api/test' });
});

it('should strip path from metrics', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const settings = new CachedSettings();
settings.set({
_id: 'Prometheus_API_User_Agent',
value: true,
} as any);

const summary = {
startTimer: jest.fn().mockImplementation(() => jest.fn()),
};

// Get the mock startTimer function
const mockEndTimer = jest.fn();
summary.startTimer.mockReturnValue(mockEndTimer);

api
.use(metricsMiddleware({ basePathRegex: new RegExp(/^\/api\//), api: { version: 1 } as any, settings, summary: summary as any }))
.get(
'/test',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => {
return {
statusCode: 200,
body: {
message: 'Metrics test successful',
},
};
},
);
app.use(api.router);
const response = await request(app).get('/api/test').set('user-agent', 'test');
expect(response.statusCode).toBe(200);
expect(response.body).toHaveProperty('message', 'Metrics test successful');

expect(summary.startTimer).toHaveBeenCalledTimes(1);
expect(mockEndTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 1, user_agent: 'test', entrypoint: 'test' });
});

it('should decode path for method.call endpoints', async () => {
const ajv = new Ajv();
const app = express();
const settings = new CachedSettings();

const api = new Router('/api');

const summary = {
startTimer: jest.fn().mockImplementation(() => jest.fn()),
};

// Get the mock startTimer function
const mockEndTimer = jest.fn();
summary.startTimer.mockReturnValue(mockEndTimer);

api
.use(metricsMiddleware({ basePathRegex: new RegExp(/^\/api\//), api: { version: 1 } as any, settings, summary: summary as any }))
.get(
'/method.call/:id',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => {
return {
statusCode: 200,
body: {
message: `Metrics test successful`,
},
};
},
);
app.use(api.router);
const response = await request(app).get('/api/method.call/get%3Aparam').set('user-agent', 'test');
expect(response.statusCode).toBe(200);
expect(response.body).toHaveProperty('message', 'Metrics test successful');

expect(summary.startTimer).toHaveBeenCalledTimes(1);
expect(mockEndTimer).toHaveBeenCalledWith({
status: 200,
method: 'get',
version: 1,
entrypoint: 'method.call/get:param',
});
});
});
31 changes: 22 additions & 9 deletions apps/meteor/app/api/server/middlewares/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,32 @@ import type { CachedSettings } from '../../../settings/server/CachedSettings';
import type { APIClass } from '../api';

export const metricsMiddleware =
(api: APIClass, settings: CachedSettings, summary: Summary): MiddlewareHandler =>
({
basePathRegex,
api,
settings,
summary,
}: {
basePathRegex?: RegExp;
api: APIClass;
settings: CachedSettings;
summary: Summary;
}): MiddlewareHandler =>
async (c, next) => {
const { method, path } = c.req;

const rocketchatRestApiEnd = summary.startTimer({
method,
version: api.version,
...(settings.get('Prometheus_API_User_Agent') && { user_agent: c.req.header('user-agent') }),
entrypoint: path.startsWith('method.call') ? decodeURIComponent(c.req.url.slice(8)) : path,
});
const rocketchatRestApiEnd = summary.startTimer();

await next();

const { method, path, routePath } = c.req;

// get rid of the base path (i.e.: /api/v1/)
const entrypoint = basePathRegex ? routePath.replace(basePathRegex, '') : routePath;

rocketchatRestApiEnd({
status: c.res.status,
method: method.toLowerCase(),
version: api.version,
...(settings.get('Prometheus_API_User_Agent') && { user_agent: c.req.header('user-agent') }),
entrypoint: basePathRegex && entrypoint.startsWith('method.call') ? decodeURIComponent(path.replace(basePathRegex, '')) : entrypoint,
});
};
Loading