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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
"fastify-type-provider-zod": "^6.1.0",
"ioredis": "^5.10.1",
"pino": "^10.3.1",
"prom-client": "^15.1.3",
"zod": "^4.4.3"
},
"devDependencies": {
Expand Down
24 changes: 24 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/routes/agent-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Disallow: /verify
Disallow: /settle
Disallow: /upload
Disallow: /files/
Disallow: /metrics

Sitemap: /sitemap.xml
`;
Expand Down
61 changes: 61 additions & 0 deletions src/routes/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Prometheus metrics endpoint.
//
// Exposes default Node.js process/runtime metrics (heap, GC, event loop)
// plus per-route HTTP request count + duration histogram, scrape-able by
// any Prometheus-compatible system. Mounted at GET /metrics.
//
// /metrics itself and /health are excluded from request tracking -- the
// former to avoid recursive accounting, the latter to keep liveness-probe
// noise out of latency percentiles.

import type { FastifyPluginCallback } from 'fastify';
import fp from 'fastify-plugin';
import { Counter, Histogram, Registry, collectDefaultMetrics } from 'prom-client';

const SKIP_ROUTES = new Set(['/metrics', '/health']);

const metricsPlugin: FastifyPluginCallback = (fastify, _options, done) => {
const registry = new Registry();
registry.setDefaultLabels({ service: 'cardano402' });
collectDefaultMetrics({ register: registry });

const httpDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request duration in seconds, labeled by method, route, and status code',
labelNames: ['method', 'route', 'status_code'],
// Buckets cover the realistic facilitator latency band (sub-ms to a few seconds).
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
registers: [registry],
});

const httpTotal = new Counter({
name: 'http_requests_total',
help: 'Total HTTP requests, labeled by method, route, and status code',
labelNames: ['method', 'route', 'status_code'],
registers: [registry],
});

fastify.addHook('onResponse', async (request, reply) => {
// Prefer the route pattern (e.g. "/files/:cid") over the raw URL so
// cardinality stays bounded. Falls back to raw URL for unmatched paths.
const route = request.routeOptions?.url ?? request.url;
if (SKIP_ROUTES.has(route)) return;
const method = request.method;
const statusCode = String(reply.statusCode);
const elapsedMs = reply.elapsedTime;
httpDuration.labels(method, route, statusCode).observe(elapsedMs / 1000);
httpTotal.labels(method, route, statusCode).inc();
});

fastify.get('/metrics', async (_req, reply) => {
const body = await registry.metrics();
return reply.type(registry.contentType).status(200).send(body);
});

done();
};

export const metricsRoutesPlugin = fp(metricsPlugin, {
name: 'metrics-routes',
fastify: '5.x',
});
5 changes: 5 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { agentDiscoveryRoutesPlugin } from './routes/agent-discovery.js';
import { demoRoutesPlugin } from './routes/demo.js';
import { downloadRoutesPlugin } from './routes/download.js';
import { healthRoutesPlugin } from './routes/health.js';
import { metricsRoutesPlugin } from './routes/metrics.js';
import { settleRoutesPlugin } from './routes/settle.js';
import { statusRoutesPlugin } from './routes/status.js';
import { supportedRoutesPlugin } from './routes/supported.js';
Expand Down Expand Up @@ -218,6 +219,10 @@ export async function createServer(options: CreateServerOptions): Promise<Fastif
await server.register(demoRoutesPlugin);
await server.register(wellKnownRoutesPlugin);
await server.register(agentDiscoveryRoutesPlugin);
// Metrics plugin registers an onResponse hook that tracks ALL routes
// (regardless of registration order via fastify-plugin's encapsulation
// bypass). The hook skips /metrics and /health internally.
await server.register(metricsRoutesPlugin);

// Landing page — serve landing/index.html at / (and static assets)
// Must be registered after API routes so /docs etc. take precedence
Expand Down
95 changes: 95 additions & 0 deletions tests/unit/routes/metrics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { FastifyInstance } from 'fastify';
import fastify from 'fastify';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { metricsRoutesPlugin } from '../../../src/routes/metrics.js';

describe('Metrics routes', () => {
let server: FastifyInstance;

beforeEach(async () => {
server = fastify({ logger: false });
await server.register(metricsRoutesPlugin);
// Sample routes for traffic that should be tracked
server.get('/sample', async () => ({ ok: true }));
server.get('/files/:cid', async (req) => ({ cid: (req.params as { cid: string }).cid }));
server.get('/health', async () => ({ status: 'ok' }));
await server.ready();
});

afterEach(async () => {
if (server) await server.close();
});

describe('GET /metrics', () => {
it('returns 200 with Prometheus text/plain content type', async () => {
const res = await server.inject({ method: 'GET', url: '/metrics' });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('text/plain');
expect(res.headers['content-type']).toContain('version=0.0.4');
});

it('exposes default Node.js process metrics', async () => {
const res = await server.inject({ method: 'GET', url: '/metrics' });
expect(res.body).toMatch(/# HELP process_cpu_user_seconds_total/);
expect(res.body).toMatch(/# HELP nodejs_heap_size_total_bytes/);
expect(res.body).toMatch(/# HELP nodejs_eventloop_lag_seconds/);
});

it('exposes the http_requests_total counter and http_request_duration_seconds histogram', async () => {
const res = await server.inject({ method: 'GET', url: '/metrics' });
expect(res.body).toMatch(/# HELP http_requests_total/);
expect(res.body).toMatch(/# HELP http_request_duration_seconds/);
});

it('attaches a service="cardano402" default label', async () => {
const res = await server.inject({ method: 'GET', url: '/metrics' });
expect(res.body).toMatch(/service="cardano402"/);
});
});

describe('HTTP request tracking', () => {
it('tracks the request count for tracked routes', async () => {
await server.inject({ method: 'GET', url: '/sample' });
await server.inject({ method: 'GET', url: '/sample' });
const res = await server.inject({ method: 'GET', url: '/metrics' });
expect(res.body).toMatch(/http_requests_total\{[^}]*route="\/sample"[^}]*\}\s+2/);
expect(res.body).toMatch(
/http_request_duration_seconds_count\{[^}]*route="\/sample"[^}]*\}\s+2/
);
});

it('uses the route pattern not the raw URL (bounded cardinality)', async () => {
await server.inject({ method: 'GET', url: '/files/abc123' });
await server.inject({ method: 'GET', url: '/files/xyz789' });
const res = await server.inject({ method: 'GET', url: '/metrics' });
// Both calls collapse onto a single time series for the templated route
expect(res.body).toMatch(/http_requests_total\{[^}]*route="\/files\/:cid"[^}]*\}\s+2/);
// The raw cids are NOT present as labels (would explode cardinality)
expect(res.body).not.toMatch(/route="\/files\/abc123"/);
expect(res.body).not.toMatch(/route="\/files\/xyz789"/);
});

it('labels by method and status_code', async () => {
await server.inject({ method: 'GET', url: '/sample' });
const res = await server.inject({ method: 'GET', url: '/metrics' });
expect(res.body).toMatch(/method="GET"/);
expect(res.body).toMatch(/status_code="200"/);
});
});

describe('Excluded routes', () => {
it('does NOT track requests to /metrics (avoid recursive accounting)', async () => {
await server.inject({ method: 'GET', url: '/metrics' });
const res = await server.inject({ method: 'GET', url: '/metrics' });
expect(res.body).not.toMatch(/http_requests_total\{[^}]*route="\/metrics"[^}]*\}/);
});

it('does NOT track requests to /health (liveness-probe noise)', async () => {
await server.inject({ method: 'GET', url: '/health' });
await server.inject({ method: 'GET', url: '/health' });
const res = await server.inject({ method: 'GET', url: '/metrics' });
expect(res.body).not.toMatch(/http_requests_total\{[^}]*route="\/health"[^}]*\}/);
});
});
});
Loading