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:
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
44
frontend/src/hooks/useTrackedFetch.ts
Normal file
44
frontend/src/hooks/useTrackedFetch.ts
Normal 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;
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
117
frontend/src/utils/__tests__/metrics.test.ts
Normal file
117
frontend/src/utils/__tests__/metrics.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
201
frontend/src/utils/metrics.ts
Normal file
201
frontend/src/utils/metrics.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user