Add cryptowallet business metrics

This commit is contained in:
Codex
2026-07-01 18:48:43 +03:00
parent b7d7a231c5
commit 956976de55
2 changed files with 126 additions and 0 deletions

View File

@@ -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);

122
apps/api/src/lib/metrics.ts Normal file
View File

@@ -0,0 +1,122 @@
import type { NextFunction, Request, Response } from 'express';
import { db } from '../config/database';
type Labels = Record<string, string>;
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<string, number>();
const httpLatencySum = new Map<string, number>();
const httpLatencyCount = new Map<string, number>();
const httpLatencyBuckets = new Map<string, number>();
function key(parts: string[]): string {
return parts.join('\u0000');
}
function inc(map: Map<string, number>, 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<string[]> {
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<void> {
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`);
}