feat: add Prometheus metrics endpoint
- Add /api/metrics endpoint for Prometheus scraping - Add /api/track endpoint for metrics tracking - Add metrics library with HTTP request counters - Add middleware for request tracking - Add instrumentation for Next.js 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f104643847
commit
a200fbfc7d
26
app/api/metrics/route.ts
Normal file
26
app/api/metrics/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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();
|
||||||
75
middleware.ts
Normal file
75
middleware.ts
Normal file
|
|
@ -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).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user