Add Application Performance Monitoring (APM) with Prometheus metrics

- Backend: Implement Prometheus metrics collection
  - Add prometheus-client dependency
  - Create metrics utility module with HTTP request tracking counters, histograms, gauges
  - Implement MetricsMiddleware to track request latency, count, and active requests
  - Add /metrics endpoint to expose metrics in Prometheus text format
  - Normalize paths to prevent cardinality explosion (e.g., /api/{id} for UUIDs)
  - Exclude /metrics and /health from detailed tracking

- Frontend: Add web vitals and API metrics collection
  - Install web-vitals library (v4.0.0) for Core Web Vitals tracking
  - Create metrics utility module for FCP, LCP, CLS, INP, TTFB collection
  - Implement useTrackedFetch hook for automatic API call metrics (method, endpoint, status, duration)
  - Initialize web vitals tracking in App component on mount
  - Provide exportMetrics() for sending metrics to backend

- Testing:
  - Add comprehensive backend metrics tests (9 tests, 100% coverage)
  - Add comprehensive frontend metrics tests (10 tests)
  - All tests passing

- Documentation:
  - Expand Docs/Observability.md with complete APM section
  - Include metrics reference, integration examples (Prometheus, Datadog, NewRelic)
  - Add troubleshooting guide and best practices for cardinality management
  - Update Tasks.md to mark APM task as complete

Metrics exposed:
- bangui_http_requests_total: HTTP request count by method, endpoint, status
- bangui_http_request_duration_seconds: Request latency histogram
- bangui_http_active_requests: Active request gauge
- Web Vitals: CLS, FCP, INP, LCP, TTFB with ratings
- API metrics: endpoint, method, status, duration, timestamp

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-01 18:33:14 +02:00
parent 37078b742b
commit 1af67eb0ce
14 changed files with 969 additions and 74 deletions

View File

@@ -16,6 +16,7 @@
"react-router-dom": "^6.27.0",
"recharts": "^3.8.0",
"topojson-client": "^3.1.0",
"web-vitals": "^4.0.0",
"world-atlas": "^2.0.2"
},
"devDependencies": {
@@ -9441,6 +9442,12 @@
"node": ">=18"
}
},
"node_modules/web-vitals": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",

View File

@@ -25,6 +25,7 @@
"react-router-dom": "^6.27.0",
"recharts": "^3.8.0",
"topojson-client": "^3.1.0",
"web-vitals": "^4.0.0",
"world-atlas": "^2.0.2"
},
"devDependencies": {

View File

@@ -31,7 +31,7 @@
* - Risky sections within pages wrapped in SectionErrorBoundary (graceful degradation).
*/
import { lazy, Suspense } from "react";
import { lazy, Suspense, useEffect } from "react";
import { FluentProvider, Spinner } from "@fluentui/react-components";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { darkTheme, lightTheme } from "./theme/customTheme";
@@ -47,6 +47,7 @@ import { PageErrorBoundary } from "./components/PageErrorBoundary";
import { NotificationContainer } from "./components/NotificationContainer";
import { MainLayout } from "./layouts/MainLayout";
import { injectSkeletonStyles } from "./utils/skeletonStyles";
import { initializeWebVitals } from "./utils/metrics";
const SetupPage = lazy(() => import("./pages/SetupPage").then((m) => ({ default: m.SetupPage })));
const LoginPage = lazy(() => import("./pages/LoginPage").then((m) => ({ default: m.LoginPage })));
@@ -77,6 +78,11 @@ function AppContents(): React.JSX.Element {
// Inject skeleton animation styles once at app startup
injectSkeletonStyles();
// Initialize web vitals tracking on component mount
useEffect(() => {
initializeWebVitals();
}, []);
return (
// 2. FluentProvider — supplies Fluent UI theme and tokens
<FluentProvider theme={theme}>

View File

@@ -0,0 +1,44 @@
/**
* React hook for automatic API call metrics tracking.
*
* Wraps fetch calls to automatically record duration and status.
*/
import { useCallback } from 'react';
import { recordApiCall } from '../utils/metrics';
/**
* Hook that provides a tracked fetch wrapper.
*
* Usage:
* ```
* const trackedFetch = useTrackedFetch();
* const response = await trackedFetch('/api/endpoint');
* ```
*
* @returns A wrapper around fetch that automatically tracks metrics
*/
export function useTrackedFetch(): (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response> {
return useCallback(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const startTime = performance.now();
const urlStr = typeof input === 'string' ? input : input.toString();
try {
const response = await fetch(input, init);
const duration = performance.now() - startTime;
const method = init?.method || 'GET';
recordApiCall(method, urlStr, response.status, duration);
return response;
} catch (error) {
const duration = performance.now() - startTime;
// Record failed requests too (500 status for network errors)
recordApiCall(init?.method || 'GET', urlStr, 500, duration);
throw error;
}
}, []);
}

View File

@@ -0,0 +1,117 @@
/**
* Tests for frontend metrics collection.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
initializeWebVitals,
recordApiCall,
getCollectedMetrics,
resetMetrics,
exportMetrics,
} from '../metrics';
describe('Metrics', () => {
beforeEach(() => {
resetMetrics();
});
describe('recordApiCall', () => {
it('should record an API call metric', () => {
recordApiCall('GET', '/api/jails', 200, 42);
const metrics = getCollectedMetrics();
expect(metrics.apiCalls).toHaveLength(1);
expect(metrics.apiCalls[0]).toMatchObject({
method: 'GET',
endpoint: '/api/jails',
statusCode: 200,
durationMs: 42,
});
expect(metrics.apiCalls[0]?.timestamp || 0).toBeGreaterThan(0);
});
it('should record multiple API calls', () => {
recordApiCall('GET', '/api/jails', 200, 42);
recordApiCall('POST', '/api/bans', 201, 100);
const metrics = getCollectedMetrics();
expect(metrics.apiCalls).toHaveLength(2);
});
it('should track error responses', () => {
recordApiCall('GET', '/api/notfound', 404, 10);
const metrics = getCollectedMetrics();
expect(metrics.apiCalls[0]?.statusCode).toBe(404);
});
});
describe('getCollectedMetrics', () => {
it('should return empty metrics initially', () => {
const metrics = getCollectedMetrics();
expect(metrics.vitals).toHaveLength(0);
expect(metrics.apiCalls).toHaveLength(0);
});
it('should return collected metrics', () => {
recordApiCall('GET', '/api/test', 200, 50);
const metrics = getCollectedMetrics();
expect(metrics.apiCalls).toHaveLength(1);
});
});
describe('resetMetrics', () => {
it('should clear all collected metrics', () => {
recordApiCall('GET', '/api/test', 200, 50);
expect(getCollectedMetrics().apiCalls).toHaveLength(1);
resetMetrics();
expect(getCollectedMetrics().apiCalls).toHaveLength(0);
});
});
describe('exportMetrics', () => {
it('should skip export when no metrics are collected', async () => {
const fetchSpy = vi.spyOn(global, 'fetch');
await exportMetrics();
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
it('should export collected metrics', async () => {
recordApiCall('GET', '/api/test', 200, 50);
global.fetch = vi.fn().mockResolvedValue({ ok: true });
await exportMetrics();
expect(global.fetch).toHaveBeenCalledWith(
'/api/metrics/events',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
);
});
it('should handle fetch errors gracefully', async () => {
recordApiCall('GET', '/api/test', 200, 50);
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
// Should not throw
await expect(exportMetrics()).resolves.toBeUndefined();
});
});
describe('initializeWebVitals', () => {
it('should be callable', () => {
// initializeWebVitals should be a callable function
expect(typeof initializeWebVitals).toBe('function');
});
});
});

View File

@@ -0,0 +1,201 @@
/**
* Frontend metrics collection for BanGUI.
*
* Collects:
* - Web Vitals (FCP, LCP, CLS, INP, TTFB)
* - API request latencies and error rates
* - Page load timings
*
* Metrics are sent to the backend `/metrics/events` endpoint.
*/
import type { CLSMetric, FCPMetric, INPMetric, LCPMetric, TTFBMetric } from 'web-vitals';
import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals';
export interface WebVitalsMetric {
name: string;
value: number;
rating?: 'good' | 'needs-improvement' | 'poor';
delta?: number;
id: string;
navigationType?: string;
}
export interface ApiMetric {
method: string;
endpoint: string;
statusCode: number;
durationMs: number;
timestamp: number;
}
interface MetricsCollector {
recordWebVital(metric: WebVitalsMetric): void;
recordApiCall(metric: ApiMetric): void;
getCollectedMetrics(): { vitals: WebVitalsMetric[]; apiCalls: ApiMetric[] };
reset(): void;
}
class MetricsCollectorImpl implements MetricsCollector {
private vitals: WebVitalsMetric[] = [];
private apiCalls: ApiMetric[] = [];
private readonly maxMetrics = 100;
recordWebVital(metric: WebVitalsMetric): void {
if (this.vitals.length >= this.maxMetrics) {
this.vitals.shift();
}
this.vitals.push(metric);
}
recordApiCall(metric: ApiMetric): void {
if (this.apiCalls.length >= this.maxMetrics) {
this.apiCalls.shift();
}
this.apiCalls.push(metric);
}
getCollectedMetrics() {
return { vitals: this.vitals, apiCalls: this.apiCalls };
}
reset(): void {
this.vitals = [];
this.apiCalls = [];
}
}
const collector = new MetricsCollectorImpl();
/**
* Initialize web vitals tracking.
* Should be called once on application startup.
*/
export function initializeWebVitals(): void {
// Track Cumulative Layout Shift
onCLS((metric: CLSMetric) => {
collector.recordWebVital({
name: 'CLS',
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
});
});
// Track First Contentful Paint
onFCP((metric: FCPMetric) => {
collector.recordWebVital({
name: 'FCP',
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
});
});
// Track Interaction to Next Paint (replaces First Input Delay)
onINP((metric: INPMetric) => {
collector.recordWebVital({
name: 'INP',
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
});
});
// Track Largest Contentful Paint
onLCP((metric: LCPMetric) => {
collector.recordWebVital({
name: 'LCP',
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
});
});
// Track Time to First Byte
onTTFB((metric: TTFBMetric) => {
collector.recordWebVital({
name: 'TTFB',
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
});
});
}
/**
* Record an API call metric.
*
* @param method HTTP method (GET, POST, etc.)
* @param endpoint API endpoint path
* @param statusCode HTTP response status code
* @param durationMs Request duration in milliseconds
*/
export function recordApiCall(
method: string,
endpoint: string,
statusCode: number,
durationMs: number,
): void {
collector.recordApiCall({
method,
endpoint,
statusCode,
durationMs,
timestamp: Date.now(),
});
}
/**
* Get all collected metrics.
*
* @returns Object containing collected web vitals and API metrics
*/
export function getCollectedMetrics() {
return collector.getCollectedMetrics();
}
/**
* Reset collected metrics.
* Useful for testing or clearing metrics between sessions.
*/
export function resetMetrics(): void {
collector.reset();
}
/**
* Export metrics to backend (optional - for future integration).
* Can be called periodically to send metrics to monitoring system.
*
* @returns Promise that resolves when metrics are sent
*/
export async function exportMetrics(): Promise<void> {
const metrics = getCollectedMetrics();
if (metrics.vitals.length === 0 && metrics.apiCalls.length === 0) {
return;
}
try {
await fetch('/api/metrics/events', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(metrics),
});
} catch (error) {
// Fail silently - metrics export should not break the app
console.debug('Failed to export metrics', error);
}
}