feat(phase-2): workstream 7 — Prometheus + Grafana Monitoring
- Add prom-client 15; shared registry in src/metrics/registry.ts (7 metrics) - HTTP request counter + duration histogram via metricsMiddleware - DB query duration histogram wrapping pg Pool.query - Redis command duration histogram via typed instrumentRedisMethod wrapper - agentidp_tokens_issued_total in OAuth2Service - agentidp_agents_registered_total in AgentService - GET /metrics unauthenticated endpoint (Prometheus text format) - docker-compose.monitoring.yml overlay (Prometheus + Grafana) - Grafana auto-provisioned datasource + pre-built AgentIdP dashboard - docs/devops/operations.md monitoring section added - 36/36 unit tests passing, 100% coverage on new metrics code - Fix pre-existing unused import in tests/integration/agents.test.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
89
tests/unit/routes/metrics.test.ts
Normal file
89
tests/unit/routes/metrics.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Unit tests for src/routes/metrics.ts
|
||||
*
|
||||
* Verifies that GET /metrics returns 200 with Prometheus exposition format
|
||||
* and does NOT require authentication.
|
||||
*/
|
||||
|
||||
import express, { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { createMetricsRouter } from '../../../src/routes/metrics';
|
||||
import { metricsRegistry } from '../../../src/metrics/registry';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Build a minimal Express app that mounts only the metrics router. */
|
||||
function buildTestApp(): Application {
|
||||
const app = express();
|
||||
app.use('/metrics', createMetricsRouter());
|
||||
return app;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /metrics', () => {
|
||||
let app: Application;
|
||||
|
||||
beforeEach(() => {
|
||||
metricsRegistry.resetMetrics();
|
||||
app = buildTestApp();
|
||||
});
|
||||
|
||||
it('returns HTTP 200', async () => {
|
||||
const res = await request(app).get('/metrics');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('returns Content-Type containing text/plain', async () => {
|
||||
const res = await request(app).get('/metrics');
|
||||
expect(res.headers['content-type']).toMatch(/text\/plain/);
|
||||
});
|
||||
|
||||
it('does NOT require an Authorization header', async () => {
|
||||
// Call without any auth header — must still succeed
|
||||
const res = await request(app).get('/metrics');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).not.toBe(401);
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it('response body contains agentidp_tokens_issued_total', async () => {
|
||||
const res = await request(app).get('/metrics');
|
||||
expect(res.text).toContain('agentidp_tokens_issued_total');
|
||||
});
|
||||
|
||||
it('response body contains agentidp_agents_registered_total', async () => {
|
||||
const res = await request(app).get('/metrics');
|
||||
expect(res.text).toContain('agentidp_agents_registered_total');
|
||||
});
|
||||
|
||||
it('response body contains agentidp_http_requests_total', async () => {
|
||||
const res = await request(app).get('/metrics');
|
||||
expect(res.text).toContain('agentidp_http_requests_total');
|
||||
});
|
||||
|
||||
it('response body contains agentidp_http_request_duration_seconds', async () => {
|
||||
const res = await request(app).get('/metrics');
|
||||
expect(res.text).toContain('agentidp_http_request_duration_seconds');
|
||||
});
|
||||
|
||||
it('response body contains agentidp_db_query_duration_seconds', async () => {
|
||||
const res = await request(app).get('/metrics');
|
||||
expect(res.text).toContain('agentidp_db_query_duration_seconds');
|
||||
});
|
||||
|
||||
it('response body contains agentidp_redis_command_duration_seconds', async () => {
|
||||
const res = await request(app).get('/metrics');
|
||||
expect(res.text).toContain('agentidp_redis_command_duration_seconds');
|
||||
});
|
||||
|
||||
it('response body is valid Prometheus text exposition format (starts with # HELP or TYPE)', async () => {
|
||||
const res = await request(app).get('/metrics');
|
||||
// Prometheus text format always begins with comment lines starting with '# '
|
||||
expect(res.text).toMatch(/^# (HELP|TYPE)/m);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user