diff --git a/app/api/metrics/route.ts b/app/api/metrics/route.ts new file mode 100644 index 0000000..76a08f7 --- /dev/null +++ b/app/api/metrics/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server'; +import { getRegistry } from '@/lib/metrics'; + +// Force Node.js runtime pour cette route +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + const register = getRegistry(); + const metrics = await register.metrics(); + + return new NextResponse(metrics, { + status: 200, + headers: { + 'Content-Type': register.contentType, + }, + }); + } catch (error) { + console.error('Metrics error:', error); + 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 new file mode 100644 index 0000000..ee92688 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,75 @@ +import { NextResponse } from 'next/server'; +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 || + request.headers.get('authorization')?.replace('Bearer ', ''); + + // 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 + if (isAuthRoute && token) { + response = NextResponse.redirect(new URL('/', request.url)); + } else { + response = 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: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - public files (public folder) + */ + '/((?!_next/static|_next/image|favicon.ico|.*\\..*|public).*)', + ], +};