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:
parent
d636578761
commit
3e36284146
|
|
@ -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
37
app/api/track/route.ts
Normal 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
8
instrumentation.ts
Normal 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
49
lib/api-metrics.ts
Normal 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 où withMetrics ne peut pas être utilisé
|
||||
*/
|
||||
export { recordHttpRequest } from './metrics';
|
||||
109
lib/metrics.ts
Normal file
109
lib/metrics.ts
Normal 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();
|
||||
|
|
@ -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).*)',
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user