diff --git a/app/api/metrics/route.ts b/app/api/metrics/route.ts index a150f48..76a08f7 100644 --- a/app/api/metrics/route.ts +++ b/app/api/metrics/route.ts @@ -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 } + ); } } diff --git a/app/api/track/route.ts b/app/api/track/route.ts new file mode 100644 index 0000000..68e3cbb --- /dev/null +++ b/app/api/track/route.ts @@ -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 } + ); + } +} diff --git a/instrumentation.ts b/instrumentation.ts new file mode 100644 index 0000000..9ea6765 --- /dev/null +++ b/instrumentation.ts @@ -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'); + } +} diff --git a/lib/api-metrics.ts b/lib/api-metrics.ts new file mode 100644 index 0000000..d734737 --- /dev/null +++ b/lib/api-metrics.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { recordHttpRequest } from './metrics'; + +type RouteHandler = ( + request: NextRequest, + context?: { params: Record } +) => Promise | 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 }) => { + 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'; diff --git a/lib/metrics.ts b/lib/metrics.ts new file mode 100644 index 0000000..ccd1c3d --- /dev/null +++ b/lib/metrics.ts @@ -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 | undefined; + httpRequestDuration: client.Histogram | undefined; + httpErrorsTotal: client.Counter | 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 { + ensureMetricsInitialized(); + return globalForProm.httpRequestsTotal!; +} + +export function getHttpRequestDuration(): client.Histogram { + ensureMetricsInitialized(); + return globalForProm.httpRequestDuration!; +} + +export function getHttpErrorsTotal(): client.Counter { + 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(); diff --git a/middleware.ts b/middleware.ts index 684c35a..ee92688 100644 --- a/middleware.ts +++ b/middleware.ts @@ -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).*)', ], }; diff --git a/next.config.js b/next.config.js index 0727e70..9fc2570 100644 --- a/next.config.js +++ b/next.config.js @@ -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,