From 956976de55317114e17f7bf49f4653146a9923dd Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 1 Jul 2026 18:48:43 +0300 Subject: [PATCH] Add cryptowallet business metrics --- apps/api/src/app.ts | 4 ++ apps/api/src/lib/metrics.ts | 122 ++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 apps/api/src/lib/metrics.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index be790a1..c8fb556 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -21,6 +21,7 @@ import bridgeRoutes from './routes/bridge.routes'; import stakingRoutes from './routes/staking.routes'; import lpRoutes from './routes/lp.routes'; import approvalsRoutes from './routes/approvals.routes'; +import { metricsHandler, metricsMiddleware } from './lib/metrics'; const app = express(); @@ -50,6 +51,7 @@ app.use( app.use(express.json({ limit: '64kb' })); // защита от больших payload-DoS app.use(cookieParser()); app.use(traceMiddleware); +app.use(metricsMiddleware); // ── PUBLIC endpoints ───────────────────────────────────────────────────────── // H11 — /api/health with DB probe (не возвращает OK если DB down) @@ -78,6 +80,8 @@ app.get(['/healthcheck', '/api/healthcheck'], async (_req, res) => { } }); +app.get(['/metrics', '/api/metrics'], metricsHandler); + // ── Глобальный rate limit на /api/* — ДО docs чтобы не было unauthenticated DoS на swagger.json app.use('/api', globalLimiter); diff --git a/apps/api/src/lib/metrics.ts b/apps/api/src/lib/metrics.ts new file mode 100644 index 0000000..85110bb --- /dev/null +++ b/apps/api/src/lib/metrics.ts @@ -0,0 +1,122 @@ +import type { NextFunction, Request, Response } from 'express'; +import { db } from '../config/database'; + +type Labels = Record; + +const SERVICE = 'cryptowallet'; +const buckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]; +const httpRequests = new Map(); +const httpLatencySum = new Map(); +const httpLatencyCount = new Map(); +const httpLatencyBuckets = new Map(); + +function key(parts: string[]): string { + return parts.join('\u0000'); +} + +function inc(map: Map, parts: string[], by = 1): void { + const k = key(parts); + map.set(k, (map.get(k) || 0) + by); +} + +function labels(values: Labels): string { + return Object.entries(values) + .map(([k, v]) => `${k}="${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`) + .join(','); +} + +function sample(name: string, value: number, values?: Labels): string { + return values ? `${name}{${labels(values)}} ${value}` : `${name} ${value}`; +} + +function routeName(req: Request): string { + return req.route?.path ? String(req.baseUrl || '') + String(req.route.path) : req.path; +} + +export function metricsMiddleware(req: Request, res: Response, next: NextFunction): void { + if (req.path === '/metrics' || req.path === '/api/metrics') { + next(); + return; + } + + const started = process.hrtime.bigint(); + res.on('finish', () => { + const elapsed = Number(process.hrtime.bigint() - started) / 1_000_000_000; + const route = routeName(req); + const method = req.method; + const statusCode = String(res.statusCode); + inc(httpRequests, [method, route, statusCode]); + inc(httpLatencySum, [method, route], elapsed); + inc(httpLatencyCount, [method, route]); + for (const bucket of buckets) { + if (elapsed <= bucket) { + inc(httpLatencyBuckets, [method, route, String(bucket)]); + } + } + }); + next(); +} + +async function walletMetrics(): Promise { + const lines: string[] = []; + const totalRow = await db('wallets').count<{ count: string }[]>('* as count').first(); + lines.push(sample('cryptowallet_wallets_total', Number(totalRow?.count || 0), { service: SERVICE })); + + const byChain = await db('wallets').select('chain').count<{ chain: string; count: string }[]>('* as count').groupBy('chain'); + for (const row of byChain) { + lines.push(sample('cryptowallet_wallets_by_chain_total', Number(row.count || 0), { service: SERVICE, chain: row.chain })); + } + + const windows: [string, string | null][] = [ + ['all', null], + ['30d', "now() - interval '30 days'"], + ['7d', "now() - interval '7 days'"], + ['24h', "now() - interval '24 hours'"], + ['1h', "now() - interval '1 hour'"], + ]; + for (const [window, threshold] of windows) { + const query = db('wallets').count<{ count: string }[]>('* as count').first(); + if (threshold) { + query.whereRaw(`created_at >= ${threshold}`); + } + const row = await query; + lines.push(sample('cryptowallet_wallets_created_total', Number(row?.count || 0), { service: SERVICE, window })); + } + return lines; +} + +function httpMetrics(): string[] { + const lines: string[] = ['# TYPE cryptowallet_http_requests_total counter']; + for (const [k, value] of [...httpRequests.entries()].sort()) { + const [method, route, statusCode] = k.split('\u0000'); + lines.push(sample('cryptowallet_http_requests_total', value, { service: SERVICE, method, route, status_code: statusCode })); + } + + lines.push('# TYPE cryptowallet_http_request_duration_seconds histogram'); + for (const [k, count] of [...httpLatencyCount.entries()].sort()) { + const [method, route] = k.split('\u0000'); + for (const bucket of buckets) { + lines.push(sample('cryptowallet_http_request_duration_seconds_bucket', httpLatencyBuckets.get(key([method, route, String(bucket)])) || 0, { service: SERVICE, method, route, le: String(bucket) })); + } + lines.push(sample('cryptowallet_http_request_duration_seconds_bucket', count, { service: SERVICE, method, route, le: '+Inf' })); + lines.push(sample('cryptowallet_http_request_duration_seconds_sum', httpLatencySum.get(k) || 0, { service: SERVICE, method, route })); + lines.push(sample('cryptowallet_http_request_duration_seconds_count', count, { service: SERVICE, method, route })); + } + return lines; +} + +export async function metricsHandler(_req: Request, res: Response): Promise { + const lines = [ + '# TYPE cryptowallet_wallets_total gauge', + '# TYPE cryptowallet_wallets_by_chain_total gauge', + '# TYPE cryptowallet_wallets_created_total gauge', + ]; + try { + lines.push(...await walletMetrics()); + lines.push(sample('cryptowallet_metrics_scrape_success', 1, { service: SERVICE })); + } catch { + lines.push(sample('cryptowallet_metrics_scrape_success', 0, { service: SERVICE })); + } + lines.push(...httpMetrics()); + res.type('text/plain; version=0.0.4; charset=utf-8').send(`${lines.join('\n')}\n`); +}