70a9e2a9cb
Правки по ревью #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>
140 lines
5.8 KiB
TypeScript
140 lines
5.8 KiB
TypeScript
/**
|
|
* Perf-metrics contract (#355). These names/labels are FIXED by the already
|
|
* deployed scrape+dashboard infra (VictoriaMetrics scraping docmost:9464,
|
|
* Grafana dashboards, alerts). Do NOT rename them.
|
|
*
|
|
* #402 extends the #355 table with the collab-lifecycle + MCP-tool families
|
|
* (grouped below). These are the fixed contract from #402; same "do not rename"
|
|
* rule applies. Server-side families land in Pass 1; the MCP-tool histogram is
|
|
* fed by the MCP callback in a later pass but its NAME is fixed here now.
|
|
*/
|
|
export const METRIC_HTTP_REQUEST_DURATION = 'http_request_duration_seconds';
|
|
export const METRIC_DB_QUERY_DURATION = 'db_query_duration_seconds';
|
|
export const METRIC_BULLMQ_QUEUE_DEPTH = 'bullmq_queue_depth';
|
|
export const METRIC_BULLMQ_JOB_DURATION = 'bullmq_job_duration_seconds';
|
|
export const METRIC_COLLAB_STORE_DURATION = 'collab_store_duration_seconds';
|
|
|
|
// #402 additions — collaboration lifecycle + MCP tool timing.
|
|
export const METRIC_COLLAB_LOAD_DURATION = 'collab_doc_load_duration_seconds';
|
|
export const METRIC_COLLAB_DOCS_OPEN = 'collab_docs_open';
|
|
export const METRIC_COLLAB_DOC_LOADS_TOTAL = 'collab_doc_loads_total';
|
|
export const METRIC_COLLAB_DOC_UNLOADS_TOTAL = 'collab_doc_unloads_total';
|
|
export const METRIC_COLLAB_CONNECT_DURATION = 'collab_connect_duration_seconds';
|
|
export const METRIC_COLLAB_CONNECT_TIMEOUTS_TOTAL =
|
|
'collab_connect_timeouts_total';
|
|
export const METRIC_COLLAB_AUTH_DURATION = 'collab_auth_duration_seconds';
|
|
export const METRIC_MCP_TOOL_DURATION = 'mcp_tool_duration_seconds';
|
|
|
|
// #479 — getPage PM→Markdown conversion cache hit/miss counters. Emitted by the
|
|
// MCP package via its dependency-neutral onMetric sink and routed onto these two
|
|
// prom counters by the mcp.service onMetric callback; a >50% hit-rate is the
|
|
// success signal for the getPage perf work. Same "do not rename" contract.
|
|
export const METRIC_MCP_GETPAGE_CACHE_HITS_TOTAL =
|
|
'mcp_getpage_cache_hits_total';
|
|
export const METRIC_MCP_GETPAGE_CACHE_MISSES_TOTAL =
|
|
'mcp_getpage_cache_misses_total';
|
|
|
|
// Histogram buckets (seconds). Chosen to give useful p50/p95/p99 resolution
|
|
// for typical web/DB latencies without exploding series cardinality.
|
|
export const HTTP_BUCKETS = [
|
|
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
|
|
];
|
|
export const DB_BUCKETS = [
|
|
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5,
|
|
];
|
|
export const COLLAB_BUCKETS = [
|
|
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5,
|
|
];
|
|
export const JOB_BUCKETS = [
|
|
0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120,
|
|
];
|
|
// #402 — MCP tool-call latency. Same shape as COLLAB_BUCKETS but stretched to
|
|
// 10s at the top: an MCP tool round-trip (LLM-driven doc ops) can be slower
|
|
// than a single collab store, so keep resolution out to 10s.
|
|
export const MCP_TOOL_BUCKETS = [
|
|
0.005, 0.025, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
|
|
];
|
|
|
|
/**
|
|
* Extract the first SQL token (select/insert/update/delete/...) from a query,
|
|
* lower-cased, to use as a BOUNDED label for db_query_duration_seconds. Using
|
|
* the full query text would blow up label cardinality; the leading keyword is a
|
|
* finite set. Unknown/empty queries collapse to `other`.
|
|
*/
|
|
// The bounded set of SQL leading keywords used as db_query_duration_seconds
|
|
// labels. Module-const so it is built ONCE, not per query (this runs on every DB
|
|
// query when metrics are enabled).
|
|
const KNOWN_SQL_TOKENS = new Set([
|
|
'select',
|
|
'insert',
|
|
'update',
|
|
'delete',
|
|
'with',
|
|
'begin',
|
|
'commit',
|
|
'rollback',
|
|
'alter',
|
|
'create',
|
|
'drop',
|
|
'truncate',
|
|
'explain',
|
|
]);
|
|
|
|
export function firstSqlToken(sql: string | undefined): string {
|
|
if (!sql) return 'other';
|
|
// Skip leading whitespace / comments and grab the first word.
|
|
const match = /^[\s(]*([a-zA-Z]+)/.exec(sql);
|
|
if (!match) return 'other';
|
|
const token = match[1].toLowerCase();
|
|
return KNOWN_SQL_TOKENS.has(token) ? token : 'other';
|
|
}
|
|
|
|
/**
|
|
* #402 — bucket a document byte size into ONE of four fixed labels for the
|
|
* collab load/store histograms' `size_bucket` label. Using the raw byte count
|
|
* would be a continuous, unbounded label; four coarse buckets keep series
|
|
* cardinality bounded (each of collab_doc_load / collab_store gets ×4 series).
|
|
*
|
|
* The SAME function is shared by both the load and store paths so their buckets
|
|
* can never drift apart. Non-finite / negative sizes collapse to the smallest
|
|
* bucket ('lt64k') as a safe default (they can't be legitimately huge and we
|
|
* must never throw here — this runs on every store/load when metrics are on).
|
|
*/
|
|
// Module-const thresholds, built once (not per observe).
|
|
const SIZE_THRESHOLDS = { lt64k: 65536, lt256k: 262144, lt1m: 1048576 } as const;
|
|
|
|
export function sizeBucket(
|
|
bytes: number | undefined | null,
|
|
): 'lt64k' | 'lt256k' | 'lt1m' | 'ge1m' {
|
|
if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return 'lt64k';
|
|
if (bytes < SIZE_THRESHOLDS.lt64k) return 'lt64k';
|
|
if (bytes < SIZE_THRESHOLDS.lt256k) return 'lt256k';
|
|
if (bytes < SIZE_THRESHOLDS.lt1m) return 'lt1m';
|
|
return 'ge1m';
|
|
}
|
|
|
|
/**
|
|
* Whether an HTTP response must be EXCLUDED from http_request_duration_seconds.
|
|
*
|
|
* SSE/streaming responses (the AI-chat `text/event-stream`) keep the connection
|
|
* open for the whole conversation, so Fastify's onResponse fires only when the
|
|
* client disconnects — recording the connection lifetime, not a response time,
|
|
* which would poison p95/p99. We skip by content-type (authoritative) with a
|
|
* route-suffix fallback for the two known stream endpoints.
|
|
*/
|
|
export function isStreamingResponse(
|
|
contentType: unknown,
|
|
route: string | undefined,
|
|
): boolean {
|
|
if (
|
|
typeof contentType === 'string' &&
|
|
contentType.toLowerCase().includes('text/event-stream')
|
|
) {
|
|
return true;
|
|
}
|
|
// Fallback: the AI-chat stream routes (/api/ai-chat/stream,
|
|
// /api/shares/ai/stream) both end in `/stream`.
|
|
if (route && route.endsWith('/stream')) return true;
|
|
return false;
|
|
}
|