Files
fluxer/packages/hono/src/middleware/tests/Metrics.test.tsx
2026-02-17 12:22:36 +00:00

364 lines
11 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {metrics} from '@fluxer/hono/src/middleware/Metrics';
import type {MetricsCollector} from '@fluxer/hono_types/src/MetricsTypes';
import {Hono} from 'hono';
import {describe, expect, test, vi} from 'vitest';
function createMockCollector(): MetricsCollector {
return {
recordCounter: vi.fn(),
recordHistogram: vi.fn(),
recordGauge: vi.fn(),
};
}
describe('Metrics Middleware', () => {
describe('enabled option', () => {
test('skips metrics when enabled is false', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({enabled: false, collector}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(collector.recordCounter).not.toHaveBeenCalled();
expect(collector.recordHistogram).not.toHaveBeenCalled();
});
test('skips metrics when collector is not provided', async () => {
const app = new Hono();
app.use('*', metrics({enabled: true}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(200);
});
test('records metrics when enabled and collector provided', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({enabled: true, collector}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(collector.recordCounter).toHaveBeenCalled();
expect(collector.recordHistogram).toHaveBeenCalled();
});
});
describe('skip paths', () => {
test('skips default health paths', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/_health', (c) => c.json({ok: true}));
app.get('/metrics', (c) => c.json({ok: true}));
await app.request('/_health');
await app.request('/metrics');
expect(collector.recordCounter).not.toHaveBeenCalled();
});
test('skips custom paths', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({skipPaths: ['/internal'], collector}));
app.get('/internal', (c) => c.json({ok: true}));
await app.request('/internal');
expect(collector.recordCounter).not.toHaveBeenCalled();
});
test('skips paths with wildcard patterns', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({skipPaths: ['/admin/*'], collector}));
app.get('/admin/dashboard', (c) => c.json({ok: true}));
app.get('/admin/users', (c) => c.json({ok: true}));
await app.request('/admin/dashboard');
await app.request('/admin/users');
expect(collector.recordCounter).not.toHaveBeenCalled();
});
test('records metrics for non-skipped paths', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({skipPaths: ['/skip'], collector}));
app.get('/api/users', (c) => c.json({ok: true}));
await app.request('/api/users');
expect(collector.recordCounter).toHaveBeenCalled();
});
});
describe('counter metrics', () => {
test('records http_requests_total counter', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(collector.recordCounter).toHaveBeenCalledWith(
expect.objectContaining({
name: 'http_requests_total',
value: 1,
}),
);
});
test('records http_errors_total for 4xx responses', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/test', (c) => c.json({error: 'Bad Request'}, 400));
await app.request('/test');
expect(collector.recordCounter).toHaveBeenCalledWith(
expect.objectContaining({
name: 'http_errors_total',
value: 1,
}),
);
});
test('records http_errors_total for 5xx responses', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/test', (c) => c.json({error: 'Server Error'}, 500));
await app.request('/test');
expect(collector.recordCounter).toHaveBeenCalledWith(
expect.objectContaining({
name: 'http_errors_total',
value: 1,
}),
);
});
test('does not record http_errors_total for 2xx responses', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
const errorCalls = (collector.recordCounter as ReturnType<typeof vi.fn>).mock.calls.filter(
(call) => call[0].name === 'http_errors_total',
);
expect(errorCalls).toHaveLength(0);
});
test('does not record http_errors_total for 3xx responses', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/test', (c) => c.redirect('/other'));
await app.request('/test');
const errorCalls = (collector.recordCounter as ReturnType<typeof vi.fn>).mock.calls.filter(
(call) => call[0].name === 'http_errors_total',
);
expect(errorCalls).toHaveLength(0);
});
});
describe('histogram metrics', () => {
test('records http_request_duration_ms histogram', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(collector.recordHistogram).toHaveBeenCalledWith(
expect.objectContaining({
name: 'http_request_duration_ms',
}),
);
});
test('records positive duration value', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
const histogramCall = (collector.recordHistogram as ReturnType<typeof vi.fn>).mock.calls.find(
(call) => call[0].name === 'http_request_duration_ms',
);
expect(histogramCall).toBeTruthy();
expect(histogramCall![0].value).toBeGreaterThanOrEqual(0);
});
});
describe('labels', () => {
test('includes method label when includeMethod is true', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector, includeMethod: true}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(collector.recordCounter).toHaveBeenCalledWith(
expect.objectContaining({
labels: expect.objectContaining({
method: 'GET',
}),
}),
);
});
test('includes method by default', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.post('/test', (c) => c.json({ok: true}));
await app.request('/test', {method: 'POST'});
expect(collector.recordCounter).toHaveBeenCalledWith(
expect.objectContaining({
labels: expect.objectContaining({
method: 'POST',
}),
}),
);
});
test('excludes method label when includeMethod is false', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector, includeMethod: false}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
const call = (collector.recordCounter as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[0].labels.method).toBeUndefined();
});
test('includes status label when includeStatus is true', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector, includeStatus: true}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(collector.recordCounter).toHaveBeenCalledWith(
expect.objectContaining({
labels: expect.objectContaining({
status: '200',
}),
}),
);
});
test('includes status by default', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/test', (c) => c.json({error: 'Not Found'}, 404));
await app.request('/test');
expect(collector.recordCounter).toHaveBeenCalledWith(
expect.objectContaining({
labels: expect.objectContaining({
status: '404',
}),
}),
);
});
test('excludes status label when includeStatus is false', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector, includeStatus: false}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
const call = (collector.recordCounter as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[0].labels.status).toBeUndefined();
});
test('includes path label when includePath is true', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector, includePath: true}));
app.get('/api/users', (c) => c.json({ok: true}));
await app.request('/api/users');
expect(collector.recordCounter).toHaveBeenCalledWith(
expect.objectContaining({
labels: expect.objectContaining({
path: '/api/users',
}),
}),
);
});
test('excludes path label by default', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/api/users', (c) => c.json({ok: true}));
await app.request('/api/users');
const call = (collector.recordCounter as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[0].labels.path).toBeUndefined();
});
});
describe('multiple requests', () => {
test('records metrics for each request', async () => {
const collector = createMockCollector();
const app = new Hono();
app.use('*', metrics({collector}));
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
await app.request('/test');
await app.request('/test');
expect(collector.recordCounter).toHaveBeenCalledTimes(3);
expect(collector.recordHistogram).toHaveBeenCalledTimes(3);
});
});
});