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 stakingRoutes from './routes/staking.routes';
|
||||||
import lpRoutes from './routes/lp.routes';
|
import lpRoutes from './routes/lp.routes';
|
||||||
import approvalsRoutes from './routes/approvals.routes';
|
import approvalsRoutes from './routes/approvals.routes';
|
||||||
|
import { metricsHandler, metricsMiddleware } from './lib/metrics';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ app.use(
|
|||||||
app.use(express.json({ limit: '64kb' })); // защита от больших payload-DoS
|
app.use(express.json({ limit: '64kb' })); // защита от больших payload-DoS
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(traceMiddleware);
|
app.use(traceMiddleware);
|
||||||
|
app.use(metricsMiddleware);
|
||||||
|
|
||||||
// ── PUBLIC endpoints ─────────────────────────────────────────────────────────
|
// ── PUBLIC endpoints ─────────────────────────────────────────────────────────
|
||||||
// H11 — /api/health with DB probe (не возвращает OK если DB down)
|
// 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
|
// ── Глобальный rate limit на /api/* — ДО docs чтобы не было unauthenticated DoS на swagger.json
|
||||||
app.use('/api', globalLimiter);
|
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