// server/plugins/metrics.ts // // Prometheus-метрики: гистограмма длительности HTTP-запросов для BFF-роутов и // для исходящих fetch (API). Метрики отдаются на GET /metrics. Маршруты // нормализуются (числовые сегменты → `id`), чтобы не плодить лейблы. import { Histogram, Registry } from 'prom-client'; const register = new Registry(); // Общий хистограмма для SSR и API const httpRequestDuration = new Histogram({ name: 'gay_cool__http_request_duration_seconds', help: 'Длительность HTTP-запросов', labelNames: ['method', 'route', 'status_code', 'type'], // type: frontend | bff buckets: [0.05, 0.1, 0.3, 0.5, 1, 2, 5], registers: [register], }); // Экспортим register, чтобы можно было использовать снаружи export { register, httpRequestDuration }; type MarkedFetch = typeof fetch & { __wrapped_by_metrics__?: boolean }; const wrapFetch = (originalFetch: typeof fetch | undefined) => { if (!originalFetch) return; if ((originalFetch as MarkedFetch).__wrapped_by_metrics__) return; // не оборачивать дважды const wrapped = async function(input: RequestInfo, init?: RequestInit) { const method = (init && init.method) || (typeof input === 'object' && 'method' in input ? (input as Request).method : 'GET'); const url = typeof input === 'string' ? input : (input as Request).url; try { const u = new URL(url, 'https://gay.cool'); // base на случай относительных путей const start = process.hrtime(); const route = `${u.pathname}`; const resp = await originalFetch(input as any, init as any); if (route.includes('__nuxt') || /[^/]+\.[^/]+$/.test(route)) return resp; const [s, ns] = process.hrtime(start); const duration = s + ns / 1e9; const cleanRoute = route.split('/').filter(Boolean).map(chunk => { const noQueryChunk = chunk.replace(/\?.*$/u, ''); if (Number.isNaN(+noQueryChunk)) { return noQueryChunk; } return 'id'; }).join('/'); httpRequestDuration.observe( { method: String(method || 'GET'), route: '/' + cleanRoute, status_code: String(resp.status), type: 'api', }, duration, ); return resp; } catch { return originalFetch(input as any, init as any); } }; const markedFetch = wrapped as unknown as MarkedFetch; markedFetch.__wrapped_by_metrics__ = true; globalThis.fetch = markedFetch; }; export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('request', async (event) => { if (event.path === '/metrics') { event.node.res.setHeader('Content-Type', register.contentType); event.node.res.end(await register.metrics()); return; } const start = process.hrtime(); const method = event.node.req.method || 'GET'; const path = event.path; const cleanPath = path.replace(/\?.*$/u, '').replace(/\d+/g, 'id'); const [type] = event.path.split('/').filter(Boolean); const isBFF = type === 'bff'; if (isBFF) { event.node.res.on('finish', () => { const [s, ns] = process.hrtime(start); const duration = s + ns / 1e9; httpRequestDuration.observe( { method, route: cleanPath, status_code: event.node.res.statusCode, type, }, duration, ); }); } }); try { wrapFetch(globalThis.fetch); } catch { /* metrics wrapping is best-effort */ } });