Files
gitmost/apps/server/src/integrations/metrics/metrics.constants.ts
T
agent_coder 96db9b6c7f feat(metrics): #402 pass 1 — collab-cycle observability (load/lifecycle/connect/auth)
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>
2026-07-07 02:10:54 +03:00

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;
}