Files
gitmost/apps/server/src/integrations/metrics/metrics.registry.ts
T
agent_coder 70a9e2a9cb fix(mcp): оживить счётчики кэша getPage + тест «хит пропускает конверсию» (#479, ревью)
Правки по ревью #480.

F1 (мёртвые метрики): счётчики mcp_getpage_cache_hits_total/_misses_total
эмитились через onMetricFn, но серверный синк mcp.service.ts диспатчил по имени
через if/else-if БЕЗ default и знал только 2 имени → мои дропались молча; в
metrics.registry их вообще не было. Починка по существующему 3-частному паттерну
(как collab_connect_timeouts_total): имена-константы в metrics.constants.ts; два
Counter'а + incGetPageCacheHit/Miss в metrics.registry.ts; два else-if в
mcp.service.ts, роутящие ровно эти имена (существующие 2 ветки не тронуты).
Проверено end-to-end: скрейп prom-реестра показывает hits=2/misses=1 после
прогона роутинга.

F2 (нет теста на пропуск конверсии): добавлен overridable seam
convertPageMarkdown в read.ts (идиома проекта для юнит-тестируемости ESM-импортов);
getPage miss-ветка идёт через него. Тест мокает convertPageMarkdown и ассертит
callCount===1 через MISS→HIT одной страницы (конверсия один раз на промахе, ноль
на хите). Мутационно доказан: пропатчил hit-ветку на повторную конверсию → тест
покраснел (callCount=2), откатил → зелёный.

mcp node --test 814/814 (+1); pmd+mcp tsc чисто; серверные metrics-файлы
компилируются изолированно, runtime-тест счётчиков зелёный.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 02:59:47 +03:00

281 lines
8.8 KiB
TypeScript

import {
collectDefaultMetrics,
Counter,
Histogram,
Gauge,
Registry,
} from 'prom-client';
import {
COLLAB_BUCKETS,
DB_BUCKETS,
HTTP_BUCKETS,
JOB_BUCKETS,
MCP_TOOL_BUCKETS,
METRIC_BULLMQ_JOB_DURATION,
METRIC_BULLMQ_QUEUE_DEPTH,
METRIC_COLLAB_AUTH_DURATION,
METRIC_COLLAB_CONNECT_DURATION,
METRIC_COLLAB_CONNECT_TIMEOUTS_TOTAL,
METRIC_COLLAB_DOC_LOADS_TOTAL,
METRIC_COLLAB_DOC_UNLOADS_TOTAL,
METRIC_COLLAB_DOCS_OPEN,
METRIC_COLLAB_LOAD_DURATION,
METRIC_COLLAB_STORE_DURATION,
METRIC_DB_QUERY_DURATION,
METRIC_HTTP_REQUEST_DURATION,
METRIC_MCP_TOOL_DURATION,
METRIC_MCP_GETPAGE_CACHE_HITS_TOTAL,
METRIC_MCP_GETPAGE_CACHE_MISSES_TOTAL,
sizeBucket,
} from './metrics.constants';
/**
* Process-wide perf-metrics registry (#355).
*
* This is a plain module singleton (NOT a Nest provider) because the collectors
* are cross-cutting: the Kysely `log` callback (built in a DI factory), the
* Fastify onResponse hook (main.ts, before the Nest container hands out
* providers) and the collab persistence extension all need the SAME instruments
* without threading DI through them.
*
* HARD CONTRACT: when `METRICS_PORT` is unset the whole subsystem is OFF — the
* registry is never created, `collectDefaultMetrics` never runs, and every
* observe/set helper is a cheap no-op. Nothing is exposed on :3000.
*/
// Decided once at process start. Deliberately read here (not via
// EnvironmentService) so the toggle is identical for the DI and non-DI callers.
const enabled = Boolean(process.env.METRICS_PORT);
let registry: Registry | null = null;
let httpHist: Histogram<'method' | 'route' | 'status'> | null = null;
let dbHist: Histogram<'op'> | null = null;
let queueDepthGauge: Gauge<'queue'> | null = null;
let jobHist: Histogram<'queue'> | null = null;
// #402 — collab store now carries a size_bucket label (see sizeBucket()).
let collabHist: Histogram<'size_bucket'> | null = null;
// #402 collab-lifecycle + MCP instruments.
let collabLoadHist: Histogram<'size_bucket'> | null = null;
let docsOpenGauge: Gauge | null = null;
let docLoadsCounter: Counter | null = null;
let docUnloadsCounter: Counter | null = null;
let connectTimeoutsCounter: Counter | null = null;
let collabConnectHist: Histogram | null = null;
let collabAuthHist: Histogram | null = null;
let mcpToolHist: Histogram<'tool'> | null = null;
// #479 — getPage conversion-cache hit/miss counters.
let getPageCacheHitsCounter: Counter | null = null;
let getPageCacheMissesCounter: Counter | null = null;
// #402 — read-on-scrape source for collab_docs_open. The gauge is NEVER
// inc/dec'd (that drifts under crashes/handoffs); instead its collect() callback
// pulls the authoritative live count from here on every scrape. Registered once,
// gated, by the collaboration gateway via registerDocsOpenSource(). Null until
// then → the collect() is a no-op.
let docsOpenSource: (() => number) | null = null;
function init(): void {
if (registry || !enabled) return;
registry = new Registry();
// Node/runtime metrics: gives nodejs_eventloop_lag_p99_seconds, GC, heap, etc.
collectDefaultMetrics({ register: registry });
httpHist = new Histogram({
name: METRIC_HTTP_REQUEST_DURATION,
help: 'HTTP request duration in seconds, by method, route template and status',
labelNames: ['method', 'route', 'status'],
buckets: HTTP_BUCKETS,
registers: [registry],
});
dbHist = new Histogram({
name: METRIC_DB_QUERY_DURATION,
help: 'Database query duration in seconds, by leading SQL keyword',
labelNames: ['op'],
buckets: DB_BUCKETS,
registers: [registry],
});
queueDepthGauge = new Gauge({
name: METRIC_BULLMQ_QUEUE_DEPTH,
help: 'Number of not-yet-finished BullMQ jobs per queue',
labelNames: ['queue'],
registers: [registry],
});
jobHist = new Histogram({
name: METRIC_BULLMQ_JOB_DURATION,
help: 'BullMQ job processing duration in seconds, per queue',
labelNames: ['queue'],
buckets: JOB_BUCKETS,
registers: [registry],
});
collabHist = new Histogram({
name: METRIC_COLLAB_STORE_DURATION,
help: 'Collaboration onStoreDocument duration in seconds, by document size bucket',
labelNames: ['size_bucket'],
buckets: COLLAB_BUCKETS,
registers: [registry],
});
collabLoadHist = new Histogram({
name: METRIC_COLLAB_LOAD_DURATION,
help: 'Collaboration onLoadDocument DB-load duration in seconds, by document size bucket',
labelNames: ['size_bucket'],
buckets: COLLAB_BUCKETS,
registers: [registry],
});
docsOpenGauge = new Gauge({
name: METRIC_COLLAB_DOCS_OPEN,
help: 'Number of collaboration documents currently open in memory',
registers: [registry],
// Read-on-scrape: pull the live count from the registered source (the
// hocuspocus instance) so the value can never drift. No-op until a source
// is registered.
collect() {
if (docsOpenSource) this.set(docsOpenSource());
},
});
docLoadsCounter = new Counter({
name: METRIC_COLLAB_DOC_LOADS_TOTAL,
help: 'Total collaboration documents loaded into memory',
registers: [registry],
});
docUnloadsCounter = new Counter({
name: METRIC_COLLAB_DOC_UNLOADS_TOTAL,
help: 'Total collaboration documents unloaded from memory',
registers: [registry],
});
connectTimeoutsCounter = new Counter({
name: METRIC_COLLAB_CONNECT_TIMEOUTS_TOTAL,
help: 'Total collaboration connection setup timeouts',
registers: [registry],
});
collabConnectHist = new Histogram({
name: METRIC_COLLAB_CONNECT_DURATION,
help: 'Collaboration connection acceptance duration in seconds (onConnect→connected)',
buckets: COLLAB_BUCKETS,
registers: [registry],
});
collabAuthHist = new Histogram({
name: METRIC_COLLAB_AUTH_DURATION,
help: 'Collaboration onAuthenticate duration in seconds',
buckets: COLLAB_BUCKETS,
registers: [registry],
});
mcpToolHist = new Histogram({
name: METRIC_MCP_TOOL_DURATION,
help: 'MCP tool-call duration in seconds, by tool name',
labelNames: ['tool'],
buckets: MCP_TOOL_BUCKETS,
registers: [registry],
});
getPageCacheHitsCounter = new Counter({
name: METRIC_MCP_GETPAGE_CACHE_HITS_TOTAL,
help: 'Total getPage PM→Markdown conversions served from the cache (skipped)',
registers: [registry],
});
getPageCacheMissesCounter = new Counter({
name: METRIC_MCP_GETPAGE_CACHE_MISSES_TOTAL,
help: 'Total getPage PM→Markdown conversions computed (cache misses)',
registers: [registry],
});
}
// Runs once when this module is first imported. Safe to call again (idempotent).
init();
export function isMetricsEnabled(): boolean {
return enabled;
}
/** The prom-client registry, or null when metrics are disabled. */
export function getMetricsRegistry(): Registry | null {
return registry;
}
export function observeHttp(
method: string,
route: string,
status: number,
seconds: number,
): void {
httpHist?.observe({ method, route, status }, seconds);
}
export function observeDbQuery(op: string, seconds: number): void {
dbHist?.observe({ op }, seconds);
}
export function setQueueDepth(queue: string, depth: number): void {
queueDepthGauge?.set({ queue }, depth);
}
export function observeJobDuration(queue: string, seconds: number): void {
jobHist?.observe({ queue }, seconds);
}
export function observeCollabStore(bytes: number, seconds: number): void {
collabHist?.observe({ size_bucket: sizeBucket(bytes) }, seconds);
}
export function observeCollabLoad(bytes: number, seconds: number): void {
collabLoadHist?.observe({ size_bucket: sizeBucket(bytes) }, seconds);
}
/**
* Register the live open-document count source for the collab_docs_open gauge.
* Called ONCE, gated by isMetricsEnabled(), by the collaboration gateway. The
* gauge reads this on every scrape (collect()); nothing inc/dec's it.
*/
export function registerDocsOpenSource(fn: () => number): void {
docsOpenSource = fn;
}
export function incDocLoad(): void {
docLoadsCounter?.inc();
}
export function incDocUnload(): void {
docUnloadsCounter?.inc();
}
export function incConnectTimeout(): void {
connectTimeoutsCounter?.inc();
}
export function observeCollabConnect(seconds: number): void {
collabConnectHist?.observe(seconds);
}
export function observeCollabAuth(seconds: number): void {
collabAuthHist?.observe(seconds);
}
export function incGetPageCacheHit(): void {
getPageCacheHitsCounter?.inc();
}
export function incGetPageCacheMiss(): void {
getPageCacheMissesCounter?.inc();
}
export function observeMcpTool(tool: string, seconds: number): void {
// `tool` MUST be a bounded, registration-derived MCP tool name (the caller
// guarantees it comes from the registered-tool set) — never free-form input —
// so this label stays low-cardinality with no 'other' bucketing needed.
mcpToolHist?.observe({ tool }, seconds);
}