1
17
18type SeriesKey = string;
19
20function seriesKey(name: string, tags?: Record<string, string>): SeriesKey {
21 if (tags === undefined) return name;
22 const keys = Object.keys(tags).sort();
23 if (keys.length === 0) return name;
24 const parts = keys.map((k) => `${k}="${tags[k]}"`);
25 return `${name}{${parts.join(",")}}`;
26}
27
28export interface MetricDef {
29 readonly name: string;
30 readonly help: string;
31 readonly type: "counter" | "gauge";
32}
33
34const DEFS: readonly MetricDef[] = [
35 { name: "uptime_seconds", help: "Process uptime in seconds.", type: "gauge" },
36 { name: "connected_clients", help: "Currently connected WebSocket clients.", type: "gauge" },
37 { name: "events_processed_total", help: "Total client events processed.", type: "counter" },
38 { name: "ws_handshake_failures_total", help: "WebSocket upgrade attempts rejected.", type: "counter" },
39 { name: "ws_connections", help: "Live WebSocket connections, by room type.", type: "gauge" },
40 { name: "ws_messages_total", help: "Frames received, by frame type.", type: "counter" },
41 { name: "ws_drops_total", help: "Sockets dropped, by cause (close code).", type: "counter" },
42 { name: "ws_room_peers", help: "Largest room occupancy, by room type.", type: "gauge" },
43 { name: "ws_update_log_size", help: "Update-log entry count, by room type.", type: "gauge" },
44 { name: "ws_rooms", help: "Number of live rooms, by room type.", type: "gauge" },
45];
46
47export class Metrics {
48 private readonly counters = new Map<SeriesKey, number>();
49 private readonly gauges = new Map<SeriesKey, number>();
50 private readonly startedAt = Date.now();
51
53
54 constructor(private readonly now: () => number = Date.now) {}
55
56
57 inc(name: string, tags?: Record<string, string>): void {
58 const key = seriesKey(name, tags);
59 this.counters.set(key, (this.counters.get(key) ?? 0) + 1);
60 }
61
62
63 add(name: string, n: number, tags?: Record<string, string>): void {
64 const key = seriesKey(name, tags);
65 this.counters.set(key, (this.counters.get(key) ?? 0) + n);
66 }
67
68
69 set(name: string, value: number, tags?: Record<string, string>): void {
70 const key = seriesKey(name, tags);
71 this.gauges.set(key, value);
72 }
73
74
75 increment(event: "realtime.sync" | "realtime.update" | "realtime.presence" | "realtime.connection"): void {
76 this.inc("events_processed_total", { type: event.replace("realtime.", "") });
77 }
78
79
80 render(): string {
81 const lines: string[] = [];
82 for (const def of DEFS) {
83 lines.push(`# HELP ${def.name} ${def.help}`);
84 lines.push(`# TYPE ${def.name} ${def.type}`);
85 const map = def.type === "gauge" ? this.gauges : this.counters;
86 let seen = false;
87 for (const [key, value] of map) {
88 const brace = key.indexOf("{");
89 const base = brace === -1 ? key : key.slice(0, brace);
90 if (base !== def.name) continue;
91 const rendered =
92 def.name === "uptime_seconds"
93 ? Math.round((this.now() - this.startedAt) / 1000)
94 : value;
95 lines.push(`${key} ${rendered}`);
96 seen = true;
97 }
98 if (def.name === "uptime_seconds" && !seen) {
99 lines.push(`uptime_seconds ${Math.round((this.now() - this.startedAt) / 1000)}`);
100 }
101 }
102 return lines.join("\n") + "\n";
103 }
104
105
106 snapshot(): Record<string, number> {
107 const out: Record<string, number> = {};
108 for (const [key, value] of this.counters) {
109 const base = key.slice(0, key.indexOf("{") === -1 ? key.length : key.indexOf("{"));
110 out[base] = (out[base] ?? 0) + value;
111 }
112 for (const [key, value] of this.gauges) {
113 out[key] = value;
114 }
115 out.uptime_seconds = Math.round((this.now() - this.startedAt) / 1000);
116 return out;
117 }
118}
119
120
132export function mergeExposition(localText: string, otelText: string): string {
133 if (!otelText) return localText;
134 const localFamilies = new Set<string>();
135 for (const line of localText.split("\n")) {
136 if (line.startsWith("# TYPE ")) {
137 localFamilies.add(line.slice("# TYPE ".length).trim().split(" ")[0]);
138 }
139 }
140 const keep = (line: string): boolean => {
141 if (line === "") return true;
142 if (line.startsWith("# TYPE ")) {
143 return !localFamilies.has(line.slice("# TYPE ".length).trim().split(" ")[0]);
144 }
145 if (line.startsWith("# HELP ")) {
146 return !localFamilies.has(line.slice("# HELP ".length).trim().split(" ")[0]);
147 }
148
149 const end = line.search(/[{\s]/);
150 return !localFamilies.has(end === -1 ? line : line.slice(0, end));
151 };
152 const kept = otelText.split("\n").filter(keep).join("\n").trim();
153 if (!kept) return localText;
154 return localText.endsWith("\n") ? `${localText}\n${kept}\n` : `${localText}\n\n${kept}\n`;
155}
156