/** * Metrics (TASK-0091 R6, doc §8) — a small in-process metrics registry * with Prometheus text exposition on `/metrics`. * * Required series (task R6 / doc §8): * - `ws_connections` (gauge, by room type) * - `ws_messages_total` (counter, by frame) * - `ws_drops_total` (counter, by cause) * - `ws_room_peers` (gauge, by room type — the largest room occupancy) * - `ws_update_log_size` (gauge, by room type) * * Preserved from the scaffold (back-compat, note 15): * - `uptime_seconds`, `connected_clients`, * `events_processed_total{type}` (sync/update/presence/connection), * `ws_handshake_failures_total`. */ type SeriesKey = string; function seriesKey(name: string, tags?: Record): SeriesKey { if (tags === undefined) return name; const keys = Object.keys(tags).sort(); if (keys.length === 0) return name; const parts = keys.map((k) => `${k}="${tags[k]}"`); return `${name}{${parts.join(",")}}`; } export interface MetricDef { readonly name: string; readonly help: string; readonly type: "counter" | "gauge"; } const DEFS: readonly MetricDef[] = [ { name: "uptime_seconds", help: "Process uptime in seconds.", type: "gauge" }, { name: "connected_clients", help: "Currently connected WebSocket clients.", type: "gauge" }, { name: "events_processed_total", help: "Total client events processed.", type: "counter" }, { name: "ws_handshake_failures_total", help: "WebSocket upgrade attempts rejected.", type: "counter" }, { name: "ws_connections", help: "Live WebSocket connections, by room type.", type: "gauge" }, { name: "ws_messages_total", help: "Frames received, by frame type.", type: "counter" }, { name: "ws_drops_total", help: "Sockets dropped, by cause (close code).", type: "counter" }, { name: "ws_room_peers", help: "Largest room occupancy, by room type.", type: "gauge" }, { name: "ws_update_log_size", help: "Update-log entry count, by room type.", type: "gauge" }, { name: "ws_rooms", help: "Number of live rooms, by room type.", type: "gauge" }, ]; export class Metrics { private readonly counters = new Map(); private readonly gauges = new Map(); private readonly startedAt = Date.now(); /** Explicit gauge setters keep exposition stable (a dropped series * re-appears as 0 once observed again). */ constructor(private readonly now: () => number = Date.now) {} /** Counter +1. */ inc(name: string, tags?: Record): void { const key = seriesKey(name, tags); this.counters.set(key, (this.counters.get(key) ?? 0) + 1); } /** Counter +n. */ add(name: string, n: number, tags?: Record): void { const key = seriesKey(name, tags); this.counters.set(key, (this.counters.get(key) ?? 0) + n); } /** Gauge set. */ set(name: string, value: number, tags?: Record): void { const key = seriesKey(name, tags); this.gauges.set(key, value); } /** Convenience for the preserved `realtime.*` event names. */ increment(event: "realtime.sync" | "realtime.update" | "realtime.presence" | "realtime.connection"): void { this.inc("events_processed_total", { type: event.replace("realtime.", "") }); } /** Render the Prometheus text exposition (version 0.0.4). */ render(): string { const lines: string[] = []; for (const def of DEFS) { lines.push(`# HELP ${def.name} ${def.help}`); lines.push(`# TYPE ${def.name} ${def.type}`); const map = def.type === "gauge" ? this.gauges : this.counters; let seen = false; for (const [key, value] of map) { const brace = key.indexOf("{"); const base = brace === -1 ? key : key.slice(0, brace); if (base !== def.name) continue; const rendered = def.name === "uptime_seconds" ? Math.round((this.now() - this.startedAt) / 1000) : value; lines.push(`${key} ${rendered}`); seen = true; } if (def.name === "uptime_seconds" && !seen) { lines.push(`uptime_seconds ${Math.round((this.now() - this.startedAt) / 1000)}`); } } return lines.join("\n") + "\n"; } /** Snapshot for tests (flat map, base names aggregated). */ snapshot(): Record { const out: Record = {}; for (const [key, value] of this.counters) { const base = key.slice(0, key.indexOf("{") === -1 ? key.length : key.indexOf("{")); out[base] = (out[base] ?? 0) + value; } for (const [key, value] of this.gauges) { out[key] = value; } out.uptime_seconds = Math.round((this.now() - this.startedAt) / 1000); return out; } } /** * TASK-0135 — merge the local registry's exposition with the OTel exposition * (rendered by `@factory/observability.renderPrometheus()`). * * One Prometheus exposition may not carry the same family twice with * different label sets, and the local registry's families are the app's * AUTHORITATIVE form (richer label sets — `ws_connections{room}`, * `ws_messages_total{frame}`). So: a family present in BOTH texts is kept in * the LOCAL form and dropped from the OTel text; OTel-only families * (`target_info`, `ws_active_connections`, …) are appended verbatim. An empty * OTel text (OTel disabled) → the local text unchanged. */ export function mergeExposition(localText: string, otelText: string): string { if (!otelText) return localText; const localFamilies = new Set(); for (const line of localText.split("\n")) { if (line.startsWith("# TYPE ")) { localFamilies.add(line.slice("# TYPE ".length).trim().split(" ")[0]); } } const keep = (line: string): boolean => { if (line === "") return true; if (line.startsWith("# TYPE ")) { return !localFamilies.has(line.slice("# TYPE ".length).trim().split(" ")[0]); } if (line.startsWith("# HELP ")) { return !localFamilies.has(line.slice("# HELP ".length).trim().split(" ")[0]); } // A sample line: the family name runs to the first "{" or space. const end = line.search(/[{\s]/); return !localFamilies.has(end === -1 ? line : line.slice(0, end)); }; const kept = otelText.split("\n").filter(keep).join("\n").trim(); if (!kept) return localText; return localText.endsWith("\n") ? `${localText}\n${kept}\n` : `${localText}\n\n${kept}\n`; }