96db9b6c7f
Extends the #355 perf-metrics registry (all behind the METRICS_PORT hard gate — nullable instruments, no-op helpers when unset). New families: - collab_doc_load_duration_seconds{size_bucket} — onLoadDocument timed on the real DB-load paths only (the already-loaded early-return is skipped). - size_bucket added to collab_store_duration_seconds; storeDocument returns the ydoc byte length (reusing its single Y.encodeStateAsUpdate, no second encode) so onStoreDocument observes size without extra cost. - collab_docs_open (gauge, read-on-scrape via collect() from hocuspocus.getDocumentsCount() — never inc/dec'd, so it can't drift) + collab_doc_loads_total / collab_doc_unloads_total (afterLoad/afterUnload). - collab_connect_duration_seconds — onConnect->connected, correlated by a WeakMap keyed on the shared request (leak-free; the current client uses one socket per document). - collab_auth_duration_seconds — wraps onAuthenticate (success and failure). - sizeBucket() shared helper (lt64k|lt256k|lt1m|ge1m, 4 bounded values). The mcp_tool_duration_seconds histogram + collab_connect_timeouts_total counter helpers are registered here but wired in pass 2 (MCP callback). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
131 lines
5.3 KiB
TypeScript
131 lines
5.3 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';
|
|
|
|
// 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;
|
|
}
|