Add cryptowallet business metrics
This commit is contained in:
@@ -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
122
apps/api/src/lib/metrics.ts
Normal 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`);
|
||||
}
|
||||
Reference in New Issue
Block a user