feat: add Prometheus HTTP metrics for frontend

- Add metrics middleware for request tracking
- Add /api/metrics endpoint
- Add /api/track endpoint for async tracking

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
soufiane 2025-11-26 13:42:03 +01:00
parent d636578761
commit 3e36284146
7 changed files with 253 additions and 54 deletions

View File

@ -1,56 +1,15 @@
import { NextResponse } from 'next/server';
import client from 'prom-client';
import { getRegistry } from '@/lib/metrics';
// Force Node.js runtime for this route
// Force Node.js runtime pour cette route
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
// Singleton pattern to avoid duplicate metric registration
const globalForProm = globalThis as unknown as {
promRegistry: client.Registry | undefined;
metricsInitialized: boolean | undefined;
};
function getRegistry(): client.Registry {
if (!globalForProm.promRegistry) {
globalForProm.promRegistry = new client.Registry();
}
return globalForProm.promRegistry;
}
function initializeMetrics(register: client.Registry) {
if (globalForProm.metricsInitialized) {
return;
}
// Add default metrics (CPU, memory, etc.)
client.collectDefaultMetrics({ register });
// Custom metrics
new client.Counter({
name: 'http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'path', 'status'],
registers: [register],
});
new client.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'path'],
buckets: [0.1, 0.3, 0.5, 0.7, 1, 3, 5, 7, 10],
registers: [register],
});
globalForProm.metricsInitialized = true;
}
export async function GET() {
try {
const register = getRegistry();
initializeMetrics(register);
const metrics = await register.metrics();
return new NextResponse(metrics, {
status: 200,
headers: {
@ -59,6 +18,9 @@ export async function GET() {
});
} catch (error) {
console.error('Metrics error:', error);
return NextResponse.json({ error: 'Failed to get metrics' }, { status: 500 });
return NextResponse.json(
{ error: 'Failed to get metrics' },
{ status: 500 }
);
}
}

37
app/api/track/route.ts Normal file
View File

@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { recordHttpRequest } from '@/lib/metrics';
// Force Node.js runtime
export const runtime = 'nodejs';
interface TrackPayload {
method: string;
path: string;
statusCode: number;
durationMs: number;
}
export async function POST(request: NextRequest) {
try {
const body: TrackPayload = await request.json();
const { method, path, statusCode, durationMs } = body;
if (!method || !path || statusCode === undefined || durationMs === undefined) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
recordHttpRequest(method, path, statusCode, durationMs);
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error('Track error:', error);
return NextResponse.json(
{ error: 'Failed to track request' },
{ status: 500 }
);
}
}

8
instrumentation.ts Normal file
View File

@ -0,0 +1,8 @@
export async function register() {
// Initialiser les métriques seulement côté serveur Node.js
if (process.env.NEXT_RUNTIME === 'nodejs') {
// Import dynamique pour éviter les erreurs côté Edge
await import('./lib/metrics');
console.log('📊 Prometheus metrics initialized');
}
}

49
lib/api-metrics.ts Normal file
View File

@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { recordHttpRequest } from './metrics';
type RouteHandler = (
request: NextRequest,
context?: { params: Record<string, string> }
) => Promise<NextResponse> | NextResponse;
/**
* Wrapper pour les routes API qui enregistre automatiquement les métriques HTTP
*
* Usage:
* ```ts
* import { withMetrics } from '@/lib/api-metrics';
*
* export const GET = withMetrics(async (request) => {
* return NextResponse.json({ data: 'hello' });
* });
* ```
*/
export function withMetrics(handler: RouteHandler): RouteHandler {
return async (request: NextRequest, context?: { params: Record<string, string> }) => {
const startTime = Date.now();
const method = request.method;
const path = new URL(request.url).pathname;
try {
const response = await handler(request, context);
const duration = Date.now() - startTime;
recordHttpRequest(method, path, response.status, duration);
return response;
} catch (error) {
const duration = Date.now() - startTime;
// Enregistrer comme erreur 500
recordHttpRequest(method, path, 500, duration);
throw error;
}
};
}
/**
* Fonction pour enregistrer manuellement une métrique HTTP
* Utile pour les cas withMetrics ne peut pas être utilisé
*/
export { recordHttpRequest } from './metrics';

109
lib/metrics.ts Normal file
View File

@ -0,0 +1,109 @@
import client from 'prom-client';
// Singleton pattern pour éviter les duplications
const globalForProm = globalThis as unknown as {
promRegistry: client.Registry | undefined;
httpRequestsTotal: client.Counter<string> | undefined;
httpRequestDuration: client.Histogram<string> | undefined;
httpErrorsTotal: client.Counter<string> | undefined;
metricsInitialized: boolean | undefined;
};
// Registry singleton
export function getRegistry(): client.Registry {
if (!globalForProm.promRegistry) {
globalForProm.promRegistry = new client.Registry();
}
return globalForProm.promRegistry;
}
// Initialiser les métriques une seule fois
function ensureMetricsInitialized() {
if (globalForProm.metricsInitialized) {
return;
}
const register = getRegistry();
// Métriques par défaut (CPU, mémoire, event loop, etc.)
client.collectDefaultMetrics({ register });
// Compteur de requêtes HTTP totales
globalForProm.httpRequestsTotal = new client.Counter({
name: 'http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'path', 'status_code'],
registers: [register],
});
// Histogramme de durée des requêtes
globalForProm.httpRequestDuration = new client.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'path', 'status_code'],
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 0.7, 1, 2, 5, 10],
registers: [register],
});
// Compteur d'erreurs HTTP
globalForProm.httpErrorsTotal = new client.Counter({
name: 'http_errors_total',
help: 'Total number of HTTP errors (4xx and 5xx)',
labelNames: ['method', 'path', 'status_code'],
registers: [register],
});
globalForProm.metricsInitialized = true;
}
// Getters pour les métriques
export function getHttpRequestsTotal(): client.Counter<string> {
ensureMetricsInitialized();
return globalForProm.httpRequestsTotal!;
}
export function getHttpRequestDuration(): client.Histogram<string> {
ensureMetricsInitialized();
return globalForProm.httpRequestDuration!;
}
export function getHttpErrorsTotal(): client.Counter<string> {
ensureMetricsInitialized();
return globalForProm.httpErrorsTotal!;
}
// Fonction helper pour normaliser les paths (éviter haute cardinalité)
export function normalizePath(path: string): string {
return path
// Remplacer les UUIDs
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id')
// Remplacer les IDs numériques
.replace(/\/\d+/g, '/:id')
// Remplacer les codes de ticket
.replace(/\/[A-Z0-9]{6,}/g, '/:code');
}
// Fonction pour enregistrer une requête HTTP
export function recordHttpRequest(
method: string,
path: string,
statusCode: number,
durationMs: number
): void {
const normalizedPath = normalizePath(path);
const labels = {
method,
path: normalizedPath,
status_code: statusCode.toString(),
};
getHttpRequestsTotal().inc(labels);
getHttpRequestDuration().observe(labels, durationMs / 1000);
if (statusCode >= 400) {
getHttpErrorsTotal().inc(labels);
}
}
// Initialiser les métriques au chargement du module
ensureMetricsInitialized();

View File

@ -4,8 +4,12 @@ import type { NextRequest } from 'next/server';
// Routes only accessible when not authenticated
const authRoutes = ['/login', '/register'];
// Routes à ne pas tracker
const excludedPaths = ['/api/track', '/api/metrics', '/_next', '/favicon.ico'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const startTime = Date.now();
// Get token from cookies or headers
const token = request.cookies.get('auth_token')?.value ||
@ -14,29 +18,58 @@ export function middleware(request: NextRequest) {
// Check if route is auth route
const isAuthRoute = authRoutes.some(route => pathname.startsWith(route));
// Préparer la réponse
let response: NextResponse;
// If accessing auth routes with token in cookies, redirect to home
// Note: We only check cookies here, not localStorage
// Client-side protection is handled by the components themselves
if (isAuthRoute && token) {
return NextResponse.redirect(new URL('/', request.url));
response = NextResponse.redirect(new URL('/', request.url));
} else {
response = NextResponse.next();
}
// Allow all other routes to pass through
// Authentication will be handled on the client side by the components
// This is necessary because tokens stored in localStorage are not accessible in middleware
return NextResponse.next();
// Tracker les métriques HTTP (fire and forget)
const shouldTrack = !excludedPaths.some(path => pathname.startsWith(path));
if (shouldTrack) {
const durationMs = Date.now() - startTime;
const statusCode = response.status || 200;
// Envoyer les métriques de manière asynchrone (non bloquant)
const trackUrl = new URL('/api/track', request.url);
// Utiliser waitUntil pour ne pas bloquer la réponse
// Note: waitUntil n'est pas disponible dans tous les environnements
try {
fetch(trackUrl.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
method: request.method,
path: pathname,
statusCode,
durationMs,
}),
}).catch(() => {
// Ignorer les erreurs de tracking
});
} catch {
// Ignorer les erreurs de tracking
}
}
return response;
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public files (public folder)
*/
'/((?!api|_next/static|_next/image|favicon.ico|.*\\..*|public).*)',
'/((?!_next/static|_next/image|favicon.ico|.*\\..*|public).*)',
],
};

View File

@ -14,6 +14,7 @@ const nextConfig = {
// Skip static page generation errors for client-only pages
experimental: {
missingSuspenseWithCSRBailout: false,
instrumentationHook: true,
},
// Generate error pages instead of failing build on prerender errors
staticPageGenerationTimeout: 120,