cartethyia 2.6.6 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/.env.example +106 -113
  2. package/dist/dashboard/assets/{BannedPage-1v5rzKMl.js → BannedPage-P76QLGJA.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-chMg74ct.js → CliToolDetailPage-DdBggJS9.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-CKG-Vxrt.js → CliToolsPage-aN1iXhhP.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-JYmVKzIx.js → CombosPage-DE-F1nxZ.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CPflWDry.js → ConsoleLogPage-5SpmfLyw.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-B0EYUE47.js → CustomizationPage-3CUxMy2X.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
  14. package/dist/dashboard/assets/{HealthEventsModal-F1c4lkNG.js → HealthEventsModal-BkmJQKC0.js} +1 -1
  15. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-BYTpzcoU.js → LoginPage-B_0FUKWc.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-CA1kLs45.js → ModelPicker-DKVslqG3.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
  22. package/dist/dashboard/assets/{ProviderDetailPage-CM4MNy45.js → ProviderDetailPage-CEJw6Guo.js} +3 -3
  23. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
  24. package/dist/dashboard/assets/{ProvidersPage-Bsoqg2HG.js → ProvidersPage-CEplAR-i.js} +1 -1
  25. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProxyPage-8G3doV7j.js → ProxyPage-CAN7hy0f.js} +1 -1
  27. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
  28. package/dist/dashboard/assets/{QuotaPage-CVIQq6JG.js → QuotaPage-BjldQwIU.js} +1 -1
  29. package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
  32. package/dist/dashboard/assets/{SetupPage-BCx07-bj.js → SetupPage-_bCaTM0v.js} +1 -1
  33. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-MhkB6msu.js → SortableList-fI7JkdIB.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-CTqK6Nsj.js → StudioPage-CHVZqskZ.js} +1 -1
  37. package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
  38. package/dist/dashboard/assets/UsagePage-DiFBymHj.js +56 -0
  39. package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-C23S9sgp.js → api-keys-DFY_fZU4.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
  42. package/dist/dashboard/assets/app-BOJ1_xUB.css +2 -0
  43. package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
  44. package/dist/dashboard/assets/{assert-DU6qrMA_.js → assert-CwdnZARt.js} +1 -1
  45. package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
  46. package/dist/dashboard/assets/{badge-Bkz_-RrM.js → badge-Dr-uf0Au.js} +1 -1
  47. package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
  48. package/dist/dashboard/assets/{banner-C-7y3VKV.js → banner-BddmMdoS.js} +1 -1
  49. package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
  50. package/dist/dashboard/assets/{card-BOPBuBzD.js → card-CK0PXWlX.js} +1 -1
  51. package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
  52. package/dist/dashboard/assets/{cli-tools-BaFUuFFF.js → cli-tools-CcB6mGh2.js} +1 -1
  53. package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
  54. package/dist/dashboard/assets/{clipboard-button-e-Mlk0FH.js → clipboard-button-DGb2P9Yx.js} +1 -1
  55. package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
  56. package/dist/dashboard/assets/{dialog-Iec_ywbD.js → dialog-LkfuL50u.js} +1 -1
  57. package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
  58. package/dist/dashboard/assets/{drawer-Bs8MIIHc.js → drawer-ZbEZ_84c.js} +1 -1
  59. package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
  60. package/dist/dashboard/assets/{entry-DcobaKFu.js → entry-BJfsVMvA.js} +1 -1
  61. package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-CBHzQ6Qh.js → entry-CWBY5A0f.js} +3 -3
  63. package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-BRszm4_Z.js → entry-CvC1-Zqx.js} +1 -1
  65. package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
  66. package/dist/dashboard/assets/{github-badge-BuDsrsPn.js → github-badge-BxpznFLt.js} +1 -1
  67. package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
  68. package/dist/dashboard/assets/{index-Ck4uwuYb.js → index-CDzP3ovJ.js} +2 -2
  69. package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
  70. package/dist/dashboard/assets/{inline-DToMICwl.js → inline-Ae13MQ5w.js} +1 -1
  71. package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
  72. package/dist/dashboard/assets/{input-WlyHWeG2.js → input-D23fo7cp.js} +1 -1
  73. package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
  74. package/dist/dashboard/assets/{layout-DaJ5baJr.js → layout-CSsxjj5P.js} +1 -1
  75. package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
  76. package/dist/dashboard/assets/{list-row-B3oSK13z.js → list-row-dmbwZMDY.js} +1 -1
  77. package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
  78. package/dist/dashboard/assets/{live-ta6GMb-8.js → live-BG1oUsZZ.js} +1 -1
  79. package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
  80. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +1 -0
  81. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
  82. package/dist/dashboard/assets/{mutation-g_fduiNw.js → mutation-DSF-e4ho.js} +1 -1
  83. package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
  84. package/dist/dashboard/assets/{numbers-Dulqgys5.js → numbers-Bm_aECY7.js} +1 -1
  85. package/dist/dashboard/assets/{numbers-Dulqgys5.js.gz → numbers-Bm_aECY7.js.gz} +0 -0
  86. package/dist/dashboard/assets/{query-keys-UETqfytp.js → query-keys-CF56nYIV.js} +1 -1
  87. package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
  88. package/dist/dashboard/assets/{routing-BPUo-W8d.js → routing-CeA71n7G.js} +1 -1
  89. package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
  90. package/dist/dashboard/assets/{select-BrrUDUPc.js → select-KIT0rC6G.js} +1 -1
  91. package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
  92. package/dist/dashboard/assets/settings-B_2gkePf.js +1 -0
  93. package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
  94. package/dist/dashboard/assets/{stack-B1w5BM0u.js → stack-D7WOSzcX.js} +1 -1
  95. package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
  96. package/dist/dashboard/assets/{state-DhZBfWgx.js → state-BBxtKZOQ.js} +1 -1
  97. package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
  98. package/dist/dashboard/assets/{switch-yD7D99Lg.js → switch-DNS_D5Ea.js} +1 -1
  99. package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
  100. package/dist/dashboard/assets/{useMutation-EDFRkn1i.js → useMutation-BgoxdoMk.js} +1 -1
  101. package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
  102. package/dist/dashboard/index.html +1 -1
  103. package/dist/dashboard/index.html.gz +0 -0
  104. package/dist/dashboard/precompressed-assets.json +51 -51
  105. package/package.json +96 -96
  106. package/src/boot/app.ts +714 -722
  107. package/src/boot/dependencies.ts +729 -750
  108. package/src/config/resources.ts +70 -137
  109. package/src/config/security.ts +37 -73
  110. package/src/config/spec.ts +358 -378
  111. package/src/console/console-router.ts +225 -233
  112. package/src/console/domain-registration.ts +381 -391
  113. package/src/console/remote-support/contracts.ts +3 -1
  114. package/src/console/remote-support/service.ts +115 -6
  115. package/src/main.ts +167 -169
  116. package/src/observability/metrics.ts +467 -457
  117. package/src/observability/runtime-metrics.ts +269 -254
  118. package/src/protocol/gateway-error.ts +232 -233
  119. package/src/providers/integrations/commandcode/commandcode.ts +134 -104
  120. package/src/providers/integrations/commandcode/discovery.ts +6 -5
  121. package/src/providers/integrations/qoder/qoder.ts +597 -23
  122. package/src/providers/operations/account-health-service.ts +52 -3
  123. package/src/security/abuse.ts +17 -0
  124. package/src/transport/guards/auth-guard.ts +109 -127
  125. package/src/transport/guards/pipeline.ts +220 -226
  126. package/src/transport/guards/request-context.ts +238 -318
  127. package/src/transport/request/inflight.ts +21 -2
  128. package/src/workers/scheduler.ts +73 -6
  129. package/dist/dashboard/assets/BannedPage-1v5rzKMl.js.gz +0 -0
  130. package/dist/dashboard/assets/CliToolDetailPage-chMg74ct.js.gz +0 -0
  131. package/dist/dashboard/assets/CliToolsPage-CKG-Vxrt.js.gz +0 -0
  132. package/dist/dashboard/assets/CombosPage-JYmVKzIx.js.gz +0 -0
  133. package/dist/dashboard/assets/ConsoleLogPage-CPflWDry.js.gz +0 -0
  134. package/dist/dashboard/assets/CustomizationPage-B0EYUE47.js.gz +0 -0
  135. package/dist/dashboard/assets/HealthEventsModal-F1c4lkNG.js.gz +0 -0
  136. package/dist/dashboard/assets/LoginPage-BYTpzcoU.js.gz +0 -0
  137. package/dist/dashboard/assets/ModelPicker-CA1kLs45.js.gz +0 -0
  138. package/dist/dashboard/assets/OverviewPage-DIYZRAcb.js +0 -1
  139. package/dist/dashboard/assets/OverviewPage-DIYZRAcb.js.gz +0 -0
  140. package/dist/dashboard/assets/ProviderDetailPage-CM4MNy45.js.gz +0 -0
  141. package/dist/dashboard/assets/ProvidersPage-Bsoqg2HG.js.gz +0 -0
  142. package/dist/dashboard/assets/ProxyPage-8G3doV7j.js.gz +0 -0
  143. package/dist/dashboard/assets/QuotaPage-CVIQq6JG.js.gz +0 -0
  144. package/dist/dashboard/assets/SettingsPage-BgXvRN2Q.js +0 -1
  145. package/dist/dashboard/assets/SettingsPage-BgXvRN2Q.js.gz +0 -0
  146. package/dist/dashboard/assets/SetupPage-BCx07-bj.js.gz +0 -0
  147. package/dist/dashboard/assets/SortableList-MhkB6msu.js.gz +0 -0
  148. package/dist/dashboard/assets/StudioPage-CTqK6Nsj.js.gz +0 -0
  149. package/dist/dashboard/assets/UsagePage-Do65OWbr.js +0 -56
  150. package/dist/dashboard/assets/UsagePage-Do65OWbr.js.gz +0 -0
  151. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  152. package/dist/dashboard/assets/app-BUSiYL6F.css +0 -2
  153. package/dist/dashboard/assets/app-BUSiYL6F.css.gz +0 -0
  154. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  155. package/dist/dashboard/assets/badge-Bkz_-RrM.js.gz +0 -0
  156. package/dist/dashboard/assets/banner-C-7y3VKV.js.gz +0 -0
  157. package/dist/dashboard/assets/card-BOPBuBzD.js.gz +0 -0
  158. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  159. package/dist/dashboard/assets/clipboard-button-e-Mlk0FH.js.gz +0 -0
  160. package/dist/dashboard/assets/dialog-Iec_ywbD.js.gz +0 -0
  161. package/dist/dashboard/assets/drawer-Bs8MIIHc.js.gz +0 -0
  162. package/dist/dashboard/assets/entry-BRszm4_Z.js.gz +0 -0
  163. package/dist/dashboard/assets/entry-CBHzQ6Qh.js.gz +0 -0
  164. package/dist/dashboard/assets/entry-DcobaKFu.js.gz +0 -0
  165. package/dist/dashboard/assets/github-badge-BuDsrsPn.js.gz +0 -0
  166. package/dist/dashboard/assets/index-Ck4uwuYb.js.gz +0 -0
  167. package/dist/dashboard/assets/inline-DToMICwl.js.gz +0 -0
  168. package/dist/dashboard/assets/input-WlyHWeG2.js.gz +0 -0
  169. package/dist/dashboard/assets/layout-DaJ5baJr.js.gz +0 -0
  170. package/dist/dashboard/assets/list-row-B3oSK13z.js.gz +0 -0
  171. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  172. package/dist/dashboard/assets/model-bans-BmYTmbAk.js +0 -1
  173. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  174. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  175. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  176. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  177. package/dist/dashboard/assets/select-BrrUDUPc.js.gz +0 -0
  178. package/dist/dashboard/assets/settings--3hxSyry.js +0 -1
  179. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  180. package/dist/dashboard/assets/stack-B1w5BM0u.js.gz +0 -0
  181. package/dist/dashboard/assets/state-DhZBfWgx.js.gz +0 -0
  182. package/dist/dashboard/assets/switch-yD7D99Lg.js.gz +0 -0
  183. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  184. package/src/console/admin/model-abuse/contracts.ts +0 -38
  185. package/src/console/admin/model-abuse/routes.ts +0 -97
  186. package/src/security/model-abuse.ts +0 -704
  187. package/src/transport/guards/concurrency-guard.ts +0 -125
  188. /package/dist/dashboard/assets/{app-MdVfwraB.js → app-CaXZ9SCn.js} +0 -0
  189. /package/dist/dashboard/assets/{app-MdVfwraB.js.gz → app-CaXZ9SCn.js.gz} +0 -0
@@ -1,457 +1,467 @@
1
- /**
2
- * Hand-rolled Prometheus text-format registry (Requirement: no new dependency).
3
- * A module-level singleton (`metrics`) mirrors the existing `getDb` singleton
4
- * pattern. Callers obtain pre-registered families as named properties;
5
- * `render()` emits the Prometheus exposition format on demand.
6
- */
7
-
8
- export type MetricLabels = Readonly<Record<string, string | number>>;
9
-
10
- export interface CounterMetric {
11
- inc(value?: number, labels?: MetricLabels): void;
12
- }
13
-
14
- export interface GaugeMetric extends CounterMetric {
15
- set(value: number, labels?: MetricLabels): void;
16
- dec(value?: number, labels?: MetricLabels): void;
17
- }
18
-
19
- export interface HistogramMetric {
20
- observe(value: number, labels?: MetricLabels): void;
21
- }
22
-
23
- function escapeLabelValue(value: string | number): string {
24
- return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
25
- }
26
-
27
- function escapeHelp(help: string): string {
28
- return help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n");
29
- }
30
-
31
- function serializeLabels(labelNames: readonly string[], labels: MetricLabels | undefined): string {
32
- return labelNames.map((name) => String(labels?.[name] ?? "")).join("\u0001");
33
- }
34
-
35
- function renderLabelSet(labelNames: readonly string[], labels: MetricLabels): string {
36
- if (labelNames.length === 0) return "";
37
- const parts = labelNames.map((name) => `${name}="${escapeLabelValue(labels[name] ?? "")}"`);
38
- return `{${parts.join(",")}}`;
39
- }
40
-
41
- // Cardinality guards prevent unbounded label expansion from untrusted input
42
- // such as attacker-controlled status and reason values from exhausting memory.
43
- export const MAX_SERIES = 100;
44
- export const MAX_LABELS = 8;
45
- export const MAX_LABEL_VALUE_LENGTH = 64;
46
-
47
- /** Shared frozen empty label set for the label-free fast path. */
48
- const EMPTY_LABELS: MetricLabels = Object.freeze({});
49
-
50
- /** Sanitize and truncate labels to guard cardinality and memory. */
51
- export function normalizeLabels(
52
- labels: MetricLabels | undefined,
53
- labelNames: readonly string[],
54
- ): MetricLabels {
55
- if (!labels || labelNames.length === 0) return {};
56
- const out: Record<string, string> = {};
57
- let kept = 0;
58
- for (const name of labelNames) {
59
- if (kept >= MAX_LABELS) break;
60
- const raw = labels[name];
61
- if (raw === undefined) {
62
- out[name] = "";
63
- kept += 1;
64
- continue;
65
- }
66
- let value = String(raw).replace(/[\n\r"]/g, "_");
67
- if (value.length > MAX_LABEL_VALUE_LENGTH) value = value.slice(0, MAX_LABEL_VALUE_LENGTH);
68
- // Trim whitespace and collapse empty to placeholder to keep key stable.
69
- value = value.trim();
70
- out[name] = value;
71
- kept += 1;
72
- }
73
- return out;
74
- }
75
-
76
- /** True if a new series key may be added without exceeding MAX_SERIES. */
77
- function canAddSeries(series: Map<string, unknown>, key: string): boolean {
78
- if (series.has(key)) return true;
79
- return series.size < MAX_SERIES;
80
- }
81
-
82
- /**
83
- * Shared storage, cardinality guard, and exposition rendering for the scalar
84
- * families. Counter and Gauge differ only in the Prometheus type word they
85
- * emit; the sample store, label normalization, series guard, and `inc`
86
- * accumulation are identical, so both extend this base to keep the wire format
87
- * single-sourced.
88
- */
89
- abstract class ScalarMetric {
90
- private readonly samples = new Map<string, { labels: MetricLabels; value: number }>();
91
-
92
- /** The Prometheus type word emitted on the `# TYPE` line. */
93
- protected abstract readonly metricType: string;
94
-
95
- constructor(
96
- readonly name: string,
97
- readonly help: string,
98
- private readonly labelNames: readonly string[],
99
- ) {
100
- if (labelNames.length > MAX_LABELS) {
101
- throw new Error(`metric ${name}: too many label names ${labelNames.length} > ${MAX_LABELS}`);
102
- }
103
- }
104
-
105
- inc(value = 1, labels?: MetricLabels): void {
106
- // Label-free fast path: skips normalize (alloc) + join per inc.
107
- if (this.labelNames.length === 0) {
108
- const previous = this.samples.get("");
109
- this.samples.set("", {
110
- labels: EMPTY_LABELS,
111
- value: (previous?.value ?? 0) + value,
112
- });
113
- return;
114
- }
115
- const normalized = normalizeLabels(labels, this.labelNames);
116
- const key = serializeLabels(this.labelNames, normalized);
117
- if (!canAddSeries(this.samples, key)) return;
118
- const previous = this.samples.get(key);
119
- this.samples.set(key, {
120
- labels: normalized,
121
- value: (previous?.value ?? 0) + value,
122
- });
123
- }
124
-
125
- /** Absolute assignment for gauge semantics, guarded like `inc`. */
126
- protected setSample(value: number, labels?: MetricLabels): void {
127
- const normalized = normalizeLabels(labels, this.labelNames);
128
- const key = serializeLabels(this.labelNames, normalized);
129
- if (!canAddSeries(this.samples, key)) return;
130
- this.samples.set(key, { labels: normalized, value });
131
- }
132
-
133
- render(): string[] {
134
- const lines = [
135
- `# HELP ${this.name} ${escapeHelp(this.help)}`,
136
- `# TYPE ${this.name} ${this.metricType}`,
137
- ];
138
- for (const sample of this.samples.values()) {
139
- lines.push(`${this.name}${renderLabelSet(this.labelNames, sample.labels)} ${sample.value}`);
140
- }
141
- return lines;
142
- }
143
- }
144
-
145
- class Counter extends ScalarMetric implements CounterMetric {
146
- protected readonly metricType = "counter";
147
- }
148
-
149
- class Gauge extends ScalarMetric implements GaugeMetric {
150
- protected readonly metricType = "gauge";
151
-
152
- set(value: number, labels?: MetricLabels): void {
153
- this.setSample(value, labels);
154
- }
155
-
156
- dec(value = 1, labels?: MetricLabels): void {
157
- this.inc(-value, labels);
158
- }
159
- }
160
-
161
- /** One histogram series: the accumulators for a single label set. */
162
- interface HistogramSeries {
163
- labels: MetricLabels;
164
- sum: number;
165
- count: number;
166
- bucketCounts: number[];
167
- }
168
-
169
- class Histogram implements HistogramMetric {
170
- private readonly series = new Map<string, HistogramSeries>();
171
- /**
172
- * The label names this histogram emits. Declared at registration when the
173
- * caller passes them to `histogram()`, otherwise fixed by the first LABELLED
174
- * observation. A Prometheus metric family must expose one consistent label
175
- * set, so a later observation carrying different keys is normalized against
176
- * this set rather than creating a second, incompatible series.
177
- */
178
- private labelNames: readonly string[] = [];
179
-
180
- constructor(
181
- readonly name: string,
182
- readonly help: string,
183
- readonly buckets: readonly number[],
184
- labelNames: readonly string[] = [],
185
- ) {
186
- // A declared set is stable across runs and workers, so the exposition is
187
- // deterministic even before the first observation arrives. Sorting keeps
188
- // the order fixed either way.
189
- this.labelNames = [...labelNames].sort();
190
- }
191
-
192
- observe(value: number, labels?: MetricLabels): void {
193
- // Fall back to inferring the set from the first labelled observation when
194
- // the metric was registered without declaring one.
195
- if (this.labelNames.length === 0 && labels !== undefined) {
196
- this.labelNames = Object.keys(labels).sort();
197
- }
198
- const normalized = normalizeLabels(labels, this.labelNames);
199
- const key = serializeLabels(this.labelNames, normalized);
200
- if (!canAddSeries(this.series, key)) return;
201
- let sample = this.series.get(key);
202
- if (sample === undefined) {
203
- sample = { labels: normalized, sum: 0, count: 0, bucketCounts: this.buckets.map(() => 0) };
204
- this.series.set(key, sample);
205
- }
206
- sample.sum += value;
207
- sample.count += 1;
208
- for (let index = 0; index < this.buckets.length; index += 1) {
209
- const bucket = this.buckets[index];
210
- if (bucket !== undefined && value <= bucket) sample.bucketCounts[index]! += 1;
211
- }
212
- }
213
-
214
- render(): string[] {
215
- const lines = [`# HELP ${this.name} ${escapeHelp(this.help)}`, `# TYPE ${this.name} histogram`];
216
- // An unlabelled histogram that has never been observed still renders its
217
- // zeroed buckets, so a scrape before the first request is not an absent
218
- // metric. A labelled one renders one block per observed series.
219
- const samples =
220
- this.series.size > 0 ? [...this.series.values()] : [this.emptySeries()];
221
- for (const sample of samples) {
222
- const suffix = renderLabelSet(this.labelNames, sample.labels);
223
- for (let index = 0; index < this.buckets.length; index += 1) {
224
- const bucket = this.buckets[index];
225
- if (bucket === undefined) continue;
226
- const inner = this.bucketLabels(sample.labels, `le="${escapeLabelValue(bucket)}"`);
227
- lines.push(`${this.name}_bucket{${inner}} ${sample.bucketCounts[index] ?? 0}`);
228
- }
229
- lines.push(
230
- `${this.name}_bucket{${this.bucketLabels(sample.labels, 'le="+Inf"')}} ${sample.count}`,
231
- );
232
- lines.push(`${this.name}_sum${suffix} ${sample.sum}`);
233
- lines.push(`${this.name}_count${suffix} ${sample.count}`);
234
- }
235
- return lines;
236
- }
237
-
238
- /** A zeroed series, used only when nothing has been observed yet. */
239
- private emptySeries(): HistogramSeries {
240
- return { labels: {}, sum: 0, count: 0, bucketCounts: this.buckets.map(() => 0) };
241
- }
242
-
243
- /**
244
- * The `{...}` body of a `_bucket` line: the declared labels plus the `le`
245
- * bucket boundary. `le` is appended last so the label order matches what a
246
- * Prometheus scraper expects to find.
247
- */
248
- private bucketLabels(labels: MetricLabels, le: string): string {
249
- const declared = this.labelNames
250
- .map((name) => `${name}="${escapeLabelValue(labels[name] ?? "")}"`)
251
- .filter((part) => part.length > 0);
252
- return [...declared, le].join(",");
253
- }
254
- }
255
-
256
- interface RenderableMetric {
257
- render(): string[];
258
- }
259
-
260
- const REQUEST_LATENCY_BUCKETS = [10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000] as const;
261
- const ADAPTER_LOAD_BUCKETS = [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500] as const;
262
-
263
- export class PrometheusRegistry {
264
- readonly cartethyia_pg_pool_total: GaugeMetric;
265
- readonly cartethyia_pg_pool_idle: GaugeMetric;
266
- readonly cartethyia_pg_pool_waiting: GaugeMetric;
267
- readonly cartethyia_redis_up: GaugeMetric;
268
- readonly proxy_requests_total: CounterMetric;
269
- readonly proxy_admission_total: CounterMetric;
270
- readonly proxy_in_flight: GaugeMetric;
271
- readonly proxy_request_latency_ms: HistogramMetric;
272
- readonly cartethyia_telemetry_buffered: GaugeMetric;
273
- readonly cartethyia_telemetry_dropped_total: CounterMetric;
274
- readonly cartethyia_memory_rss_bytes: GaugeMetric;
275
- readonly cartethyia_memory_heap_used_bytes: GaugeMetric;
276
- readonly cartethyia_memory_heap_total_bytes: GaugeMetric;
277
- readonly cartethyia_memory_limit_bytes: GaugeMetric;
278
- readonly cartethyia_routing_roundrobin_entries: GaugeMetric;
279
- readonly cartethyia_ip_abuse_keys: GaugeMetric;
280
- readonly cartethyia_quota_cache_entries: GaugeMetric;
281
- readonly cartethyia_model_catalog_entries: GaugeMetric;
282
- readonly cartethyia_process_in_flight: GaugeMetric;
283
- readonly cartethyia_process_concurrency_limit: GaugeMetric;
284
- readonly cartethyia_telemetry_backlog_items: GaugeMetric;
285
- readonly cartethyia_telemetry_backlog_bytes: GaugeMetric;
286
- readonly cartethyia_proxy_dial_proxy_resolved_total: CounterMetric;
287
- readonly proxy_provider_adapter_load_ms: HistogramMetric;
288
- readonly pool_cooldown_record_failed: CounterMetric;
289
- readonly quota_cache_invalidate_failed: CounterMetric;
290
- /** Payload frames dropped because the writer backlog was at its bound. */
291
- readonly cartethyia_payload_backlog_dropped_total: CounterMetric;
292
- /**
293
- * Telemetry backfill conflicts: same (tenant, request) identity with
294
- * different content. Reported, never overwritten.
295
- */
296
- readonly cartethyia_telemetry_raw_conflicts_total: CounterMetric;
297
-
298
- private readonly all: RenderableMetric[] = [];
299
-
300
- constructor() {
301
- this.cartethyia_pg_pool_total = this.gauge(
302
- "cartethyia_pg_pool_total",
303
- "Total connections in the Postgres pool",
304
- );
305
- this.cartethyia_pg_pool_idle = this.gauge(
306
- "cartethyia_pg_pool_idle",
307
- "Idle connections in the Postgres pool",
308
- );
309
- this.cartethyia_pg_pool_waiting = this.gauge(
310
- "cartethyia_pg_pool_waiting",
311
- "Clients waiting for a Postgres pool connection",
312
- );
313
- this.cartethyia_redis_up = this.gauge("cartethyia_redis_up", "Redis connectivity (1 = ready)");
314
- this.proxy_requests_total = this.counter(
315
- "proxy_requests_total",
316
- "Completed proxy requests by outcome status",
317
- ["status"],
318
- );
319
- this.proxy_admission_total = this.counter(
320
- "proxy_admission_total",
321
- "Admission decisions by reason",
322
- ["reason"],
323
- );
324
- this.proxy_in_flight = this.gauge(
325
- "proxy_in_flight",
326
- "Provider dispatch requests currently in flight on this process",
327
- );
328
- this.proxy_request_latency_ms = this.histogram(
329
- "proxy_request_latency_ms",
330
- "Request latency in milliseconds",
331
- REQUEST_LATENCY_BUCKETS,
332
- );
333
- this.cartethyia_telemetry_buffered = this.gauge(
334
- "cartethyia_telemetry_buffered",
335
- "Telemetry events waiting to be flushed",
336
- );
337
- this.cartethyia_telemetry_dropped_total = this.counter(
338
- "cartethyia_telemetry_dropped_total",
339
- "Telemetry events dropped because the buffer was full",
340
- );
341
- this.cartethyia_memory_rss_bytes = this.gauge(
342
- "cartethyia_memory_rss_bytes",
343
- "Resident set size of the process in bytes",
344
- );
345
- this.cartethyia_memory_heap_used_bytes = this.gauge(
346
- "cartethyia_memory_heap_used_bytes",
347
- "JavaScript heap used in bytes",
348
- );
349
- this.cartethyia_memory_heap_total_bytes = this.gauge(
350
- "cartethyia_memory_heap_total_bytes",
351
- "JavaScript heap reserved in bytes",
352
- );
353
- this.cartethyia_memory_limit_bytes = this.gauge(
354
- "cartethyia_memory_limit_bytes",
355
- "Container/cgroup memory limit in bytes (0 when unknown)",
356
- );
357
- this.cartethyia_process_in_flight = this.gauge(
358
- "cartethyia_process_in_flight",
359
- "Gateway requests currently admitted into this process",
360
- );
361
- this.cartethyia_process_concurrency_limit = this.gauge(
362
- "cartethyia_process_concurrency_limit",
363
- "Effective in-flight ceiling for this process, derived from the memory limit",
364
- );
365
- this.cartethyia_telemetry_backlog_items = this.gauge(
366
- "cartethyia_telemetry_backlog_items",
367
- "Telemetry events queued for the next drain",
368
- );
369
- this.cartethyia_telemetry_backlog_bytes = this.gauge(
370
- "cartethyia_telemetry_backlog_bytes",
371
- "Estimated bytes queued for the next telemetry drain",
372
- );
373
- this.cartethyia_routing_roundrobin_entries = this.gauge(
374
- "cartethyia_routing_roundrobin_entries",
375
- "Round-robin routing state entries by scope",
376
- ["scope"],
377
- );
378
- this.cartethyia_ip_abuse_keys = this.gauge(
379
- "cartethyia_ip_abuse_keys",
380
- "Tracked keys in the in-memory IP abuse store (0 when Redis-backed)",
381
- );
382
- this.cartethyia_quota_cache_entries = this.gauge(
383
- "cartethyia_quota_cache_entries",
384
- "Entries in the in-process provider quota cache",
385
- );
386
- this.cartethyia_model_catalog_entries = this.gauge(
387
- "cartethyia_model_catalog_entries",
388
- "Entries in the per-provider model catalog cache (at the cap means eviction churn)",
389
- );
390
- this.cartethyia_proxy_dial_proxy_resolved_total = this.counter(
391
- "cartethyia_proxy_dial_proxy_resolved_total",
392
- "Pool/relay-bound dials whose target is resolved by the proxy or relay rather than by the local resolver, so local DNS for the target is advisory only",
393
- );
394
- this.proxy_provider_adapter_load_ms = this.histogram(
395
- "proxy_provider_adapter_load_ms",
396
- "Time to load a provider adapter on first use",
397
- ADAPTER_LOAD_BUCKETS,
398
- );
399
- this.pool_cooldown_record_failed = this.counter(
400
- "pool_cooldown_record_failed",
401
- "Pool cooldown writes that failed after a provider rate limit",
402
- );
403
- this.quota_cache_invalidate_failed = this.counter(
404
- "quota_cache_invalidate_failed",
405
- "Quota cache invalidations that failed",
406
- );
407
- this.cartethyia_payload_backlog_dropped_total = this.counter(
408
- "cartethyia_payload_backlog_dropped_total",
409
- "Payload captures dropped because the payload writer backlog was full",
410
- );
411
- this.cartethyia_telemetry_raw_conflicts_total = this.counter(
412
- "cartethyia_telemetry_raw_conflicts_total",
413
- "Telemetry backfill conflicts (same tenant/request identity, different content)",
414
- );
415
- }
416
-
417
- counter(name: string, help: string, labelNames: readonly string[] = []): CounterMetric {
418
- const metric = new Counter(name, help, labelNames);
419
- this.all.push(metric);
420
- return metric;
421
- }
422
-
423
- gauge(name: string, help: string, labelNames: readonly string[] = []): GaugeMetric {
424
- const metric = new Gauge(name, help, labelNames);
425
- this.all.push(metric);
426
- return metric;
427
- }
428
-
429
- /**
430
- * Registers a histogram.
431
- *
432
- * `labelNames` declares the label set up front. It is optional because a
433
- * histogram observed without labels still has to render, but passing it is
434
- * the correct call: a declared set is stable across runs and workers, so the
435
- * exposition is deterministic even before the first observation, and an
436
- * observation carrying undeclared keys cannot create a second, incompatible
437
- * series.
438
- */
439
- histogram(
440
- name: string,
441
- help: string,
442
- buckets: readonly number[],
443
- labelNames: readonly string[] = [],
444
- ): HistogramMetric {
445
- const metric = new Histogram(name, help, buckets, labelNames);
446
- this.all.push(metric);
447
- return metric;
448
- }
449
-
450
- render(): string {
451
- const lines: string[] = [];
452
- for (const metric of this.all) lines.push(...metric.render());
453
- return `${lines.join("\n")}\n`;
454
- }
455
- }
456
-
457
- export const metrics = new PrometheusRegistry();
1
+ /**
2
+ * Hand-rolled Prometheus text-format registry (Requirement: no new dependency).
3
+ * A module-level singleton (`metrics`) mirrors the existing `getDb` singleton
4
+ * pattern. Callers obtain pre-registered families as named properties;
5
+ * `render()` emits the Prometheus exposition format on demand.
6
+ */
7
+
8
+ export type MetricLabels = Readonly<Record<string, string | number>>;
9
+
10
+ export interface CounterMetric {
11
+ inc(value?: number, labels?: MetricLabels): void;
12
+ }
13
+
14
+ export interface GaugeMetric extends CounterMetric {
15
+ set(value: number, labels?: MetricLabels): void;
16
+ dec(value?: number, labels?: MetricLabels): void;
17
+ }
18
+
19
+ export interface HistogramMetric {
20
+ observe(value: number, labels?: MetricLabels): void;
21
+ }
22
+
23
+ function escapeLabelValue(value: string | number): string {
24
+ return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
25
+ }
26
+
27
+ function escapeHelp(help: string): string {
28
+ return help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n");
29
+ }
30
+
31
+ function serializeLabels(labelNames: readonly string[], labels: MetricLabels | undefined): string {
32
+ return labelNames.map((name) => String(labels?.[name] ?? "")).join("\u0001");
33
+ }
34
+
35
+ function renderLabelSet(labelNames: readonly string[], labels: MetricLabels): string {
36
+ if (labelNames.length === 0) return "";
37
+ const parts = labelNames.map((name) => `${name}="${escapeLabelValue(labels[name] ?? "")}"`);
38
+ return `{${parts.join(",")}}`;
39
+ }
40
+
41
+ // Cardinality guards prevent unbounded label expansion from untrusted input
42
+ // such as attacker-controlled status and reason values from exhausting memory.
43
+ export const MAX_SERIES = 100;
44
+ export const MAX_LABELS = 8;
45
+ export const MAX_LABEL_VALUE_LENGTH = 64;
46
+
47
+ /** Shared frozen empty label set for the label-free fast path. */
48
+ const EMPTY_LABELS: MetricLabels = Object.freeze({});
49
+
50
+ /** Sanitize and truncate labels to guard cardinality and memory. */
51
+ export function normalizeLabels(
52
+ labels: MetricLabels | undefined,
53
+ labelNames: readonly string[],
54
+ ): MetricLabels {
55
+ if (!labels || labelNames.length === 0) return {};
56
+ const out: Record<string, string> = {};
57
+ let kept = 0;
58
+ for (const name of labelNames) {
59
+ if (kept >= MAX_LABELS) break;
60
+ const raw = labels[name];
61
+ if (raw === undefined) {
62
+ out[name] = "";
63
+ kept += 1;
64
+ continue;
65
+ }
66
+ let value = String(raw).replace(/[\n\r"]/g, "_");
67
+ if (value.length > MAX_LABEL_VALUE_LENGTH) value = value.slice(0, MAX_LABEL_VALUE_LENGTH);
68
+ // Trim whitespace and collapse empty to placeholder to keep key stable.
69
+ value = value.trim();
70
+ out[name] = value;
71
+ kept += 1;
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /** True if a new series key may be added without exceeding MAX_SERIES. */
77
+ function canAddSeries(series: Map<string, unknown>, key: string): boolean {
78
+ if (series.has(key)) return true;
79
+ return series.size < MAX_SERIES;
80
+ }
81
+
82
+ /**
83
+ * Shared storage, cardinality guard, and exposition rendering for the scalar
84
+ * families. Counter and Gauge differ only in the Prometheus type word they
85
+ * emit; the sample store, label normalization, series guard, and `inc`
86
+ * accumulation are identical, so both extend this base to keep the wire format
87
+ * single-sourced.
88
+ */
89
+ abstract class ScalarMetric {
90
+ private readonly samples = new Map<string, { labels: MetricLabels; value: number }>();
91
+
92
+ /** The Prometheus type word emitted on the `# TYPE` line. */
93
+ protected abstract readonly metricType: string;
94
+
95
+ constructor(
96
+ readonly name: string,
97
+ readonly help: string,
98
+ private readonly labelNames: readonly string[],
99
+ ) {
100
+ if (labelNames.length > MAX_LABELS) {
101
+ throw new Error(`metric ${name}: too many label names ${labelNames.length} > ${MAX_LABELS}`);
102
+ }
103
+ }
104
+
105
+ inc(value = 1, labels?: MetricLabels): void {
106
+ // Label-free fast path: skips normalize (alloc) + join per inc.
107
+ if (this.labelNames.length === 0) {
108
+ const previous = this.samples.get("");
109
+ this.samples.set("", {
110
+ labels: EMPTY_LABELS,
111
+ value: (previous?.value ?? 0) + value,
112
+ });
113
+ return;
114
+ }
115
+ const normalized = normalizeLabels(labels, this.labelNames);
116
+ const key = serializeLabels(this.labelNames, normalized);
117
+ if (!canAddSeries(this.samples, key)) return;
118
+ const previous = this.samples.get(key);
119
+ this.samples.set(key, {
120
+ labels: normalized,
121
+ value: (previous?.value ?? 0) + value,
122
+ });
123
+ }
124
+
125
+ /** Absolute assignment for gauge semantics, guarded like `inc`. */
126
+ protected setSample(value: number, labels?: MetricLabels): void {
127
+ const normalized = normalizeLabels(labels, this.labelNames);
128
+ const key = serializeLabels(this.labelNames, normalized);
129
+ if (!canAddSeries(this.samples, key)) return;
130
+ this.samples.set(key, { labels: normalized, value });
131
+ }
132
+
133
+ render(): string[] {
134
+ const lines = [
135
+ `# HELP ${this.name} ${escapeHelp(this.help)}`,
136
+ `# TYPE ${this.name} ${this.metricType}`,
137
+ ];
138
+ for (const sample of this.samples.values()) {
139
+ lines.push(`${this.name}${renderLabelSet(this.labelNames, sample.labels)} ${sample.value}`);
140
+ }
141
+ return lines;
142
+ }
143
+ }
144
+
145
+ class Counter extends ScalarMetric implements CounterMetric {
146
+ protected readonly metricType = "counter";
147
+ }
148
+
149
+ class Gauge extends ScalarMetric implements GaugeMetric {
150
+ protected readonly metricType = "gauge";
151
+
152
+ set(value: number, labels?: MetricLabels): void {
153
+ this.setSample(value, labels);
154
+ }
155
+
156
+ dec(value = 1, labels?: MetricLabels): void {
157
+ this.inc(-value, labels);
158
+ }
159
+ }
160
+
161
+ /** One histogram series: the accumulators for a single label set. */
162
+ interface HistogramSeries {
163
+ labels: MetricLabels;
164
+ sum: number;
165
+ count: number;
166
+ bucketCounts: number[];
167
+ }
168
+
169
+ class Histogram implements HistogramMetric {
170
+ private readonly series = new Map<string, HistogramSeries>();
171
+ /**
172
+ * The label names this histogram emits. Declared at registration when the
173
+ * caller passes them to `histogram()`, otherwise fixed by the first LABELLED
174
+ * observation. A Prometheus metric family must expose one consistent label
175
+ * set, so a later observation carrying different keys is normalized against
176
+ * this set rather than creating a second, incompatible series.
177
+ */
178
+ private labelNames: readonly string[] = [];
179
+
180
+ constructor(
181
+ readonly name: string,
182
+ readonly help: string,
183
+ readonly buckets: readonly number[],
184
+ labelNames: readonly string[] = [],
185
+ ) {
186
+ // A declared set is stable across runs and workers, so the exposition is
187
+ // deterministic even before the first observation arrives. Sorting keeps
188
+ // the order fixed either way.
189
+ this.labelNames = [...labelNames].sort();
190
+ }
191
+
192
+ observe(value: number, labels?: MetricLabels): void {
193
+ // Fall back to inferring the set from the first labelled observation when
194
+ // the metric was registered without declaring one.
195
+ if (this.labelNames.length === 0 && labels !== undefined) {
196
+ this.labelNames = Object.keys(labels).sort();
197
+ }
198
+ const normalized = normalizeLabels(labels, this.labelNames);
199
+ const key = serializeLabels(this.labelNames, normalized);
200
+ if (!canAddSeries(this.series, key)) return;
201
+ let sample = this.series.get(key);
202
+ if (sample === undefined) {
203
+ sample = { labels: normalized, sum: 0, count: 0, bucketCounts: this.buckets.map(() => 0) };
204
+ this.series.set(key, sample);
205
+ }
206
+ sample.sum += value;
207
+ sample.count += 1;
208
+ for (let index = 0; index < this.buckets.length; index += 1) {
209
+ const bucket = this.buckets[index];
210
+ if (bucket !== undefined && value <= bucket) sample.bucketCounts[index]! += 1;
211
+ }
212
+ }
213
+
214
+ render(): string[] {
215
+ const lines = [`# HELP ${this.name} ${escapeHelp(this.help)}`, `# TYPE ${this.name} histogram`];
216
+ // An unlabelled histogram that has never been observed still renders its
217
+ // zeroed buckets, so a scrape before the first request is not an absent
218
+ // metric. A labelled one renders one block per observed series.
219
+ const samples =
220
+ this.series.size > 0 ? [...this.series.values()] : [this.emptySeries()];
221
+ for (const sample of samples) {
222
+ const suffix = renderLabelSet(this.labelNames, sample.labels);
223
+ for (let index = 0; index < this.buckets.length; index += 1) {
224
+ const bucket = this.buckets[index];
225
+ if (bucket === undefined) continue;
226
+ const inner = this.bucketLabels(sample.labels, `le="${escapeLabelValue(bucket)}"`);
227
+ lines.push(`${this.name}_bucket{${inner}} ${sample.bucketCounts[index] ?? 0}`);
228
+ }
229
+ lines.push(
230
+ `${this.name}_bucket{${this.bucketLabels(sample.labels, 'le="+Inf"')}} ${sample.count}`,
231
+ );
232
+ lines.push(`${this.name}_sum${suffix} ${sample.sum}`);
233
+ lines.push(`${this.name}_count${suffix} ${sample.count}`);
234
+ }
235
+ return lines;
236
+ }
237
+
238
+ /** A zeroed series, used only when nothing has been observed yet. */
239
+ private emptySeries(): HistogramSeries {
240
+ return { labels: {}, sum: 0, count: 0, bucketCounts: this.buckets.map(() => 0) };
241
+ }
242
+
243
+ /**
244
+ * The `{...}` body of a `_bucket` line: the declared labels plus the `le`
245
+ * bucket boundary. `le` is appended last so the label order matches what a
246
+ * Prometheus scraper expects to find.
247
+ */
248
+ private bucketLabels(labels: MetricLabels, le: string): string {
249
+ const declared = this.labelNames
250
+ .map((name) => `${name}="${escapeLabelValue(labels[name] ?? "")}"`)
251
+ .filter((part) => part.length > 0);
252
+ return [...declared, le].join(",");
253
+ }
254
+ }
255
+
256
+ interface RenderableMetric {
257
+ render(): string[];
258
+ }
259
+
260
+ const REQUEST_LATENCY_BUCKETS = [10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000] as const;
261
+ const ADAPTER_LOAD_BUCKETS = [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500] as const;
262
+
263
+ export class PrometheusRegistry {
264
+ readonly cartethyia_pg_pool_total: GaugeMetric;
265
+ readonly cartethyia_pg_pool_idle: GaugeMetric;
266
+ readonly cartethyia_pg_pool_waiting: GaugeMetric;
267
+ readonly cartethyia_redis_up: GaugeMetric;
268
+ readonly proxy_requests_total: CounterMetric;
269
+ readonly proxy_admission_total: CounterMetric;
270
+ /**
271
+ * Refusals from the per-IP abuse layer, by reason.
272
+ *
273
+ * These are the only 429s this process produces before dispatch, and they
274
+ * were invisible: a client-facing 429 could come from here, from admission,
275
+ * or from the upstream, and nothing recorded which. `ip-rate-limited` is the
276
+ * sliding window and `ip-banned` the escalation — different remedies, so
277
+ * they must not share a number.
278
+ */
279
+ readonly cartethyia_ip_abuse_refusals_total: CounterMetric;
280
+ readonly proxy_in_flight: GaugeMetric;
281
+ readonly proxy_request_latency_ms: HistogramMetric;
282
+ readonly cartethyia_telemetry_buffered: GaugeMetric;
283
+ readonly cartethyia_telemetry_dropped_total: CounterMetric;
284
+ readonly cartethyia_memory_rss_bytes: GaugeMetric;
285
+ readonly cartethyia_memory_heap_used_bytes: GaugeMetric;
286
+ readonly cartethyia_memory_heap_total_bytes: GaugeMetric;
287
+ readonly cartethyia_memory_limit_bytes: GaugeMetric;
288
+ readonly cartethyia_routing_roundrobin_entries: GaugeMetric;
289
+ readonly cartethyia_ip_abuse_keys: GaugeMetric;
290
+ readonly cartethyia_quota_cache_entries: GaugeMetric;
291
+ readonly cartethyia_model_catalog_entries: GaugeMetric;
292
+ readonly cartethyia_process_in_flight: GaugeMetric;
293
+ readonly cartethyia_telemetry_backlog_items: GaugeMetric;
294
+ readonly cartethyia_telemetry_backlog_bytes: GaugeMetric;
295
+ readonly cartethyia_proxy_dial_proxy_resolved_total: CounterMetric;
296
+ readonly proxy_provider_adapter_load_ms: HistogramMetric;
297
+ readonly pool_cooldown_record_failed: CounterMetric;
298
+ readonly quota_cache_invalidate_failed: CounterMetric;
299
+ /** Payload frames dropped because the writer backlog was at its bound. */
300
+ readonly cartethyia_payload_backlog_dropped_total: CounterMetric;
301
+ /**
302
+ * Telemetry backfill conflicts: same (tenant, request) identity with
303
+ * different content. Reported, never overwritten.
304
+ */
305
+ readonly cartethyia_telemetry_raw_conflicts_total: CounterMetric;
306
+
307
+ private readonly all: RenderableMetric[] = [];
308
+
309
+ constructor() {
310
+ this.cartethyia_pg_pool_total = this.gauge(
311
+ "cartethyia_pg_pool_total",
312
+ "Total connections in the Postgres pool",
313
+ );
314
+ this.cartethyia_pg_pool_idle = this.gauge(
315
+ "cartethyia_pg_pool_idle",
316
+ "Idle connections in the Postgres pool",
317
+ );
318
+ this.cartethyia_pg_pool_waiting = this.gauge(
319
+ "cartethyia_pg_pool_waiting",
320
+ "Clients waiting for a Postgres pool connection",
321
+ );
322
+ this.cartethyia_redis_up = this.gauge("cartethyia_redis_up", "Redis connectivity (1 = ready)");
323
+ this.proxy_requests_total = this.counter(
324
+ "proxy_requests_total",
325
+ "Completed proxy requests by outcome status",
326
+ ["status"],
327
+ );
328
+ this.proxy_admission_total = this.counter(
329
+ "proxy_admission_total",
330
+ "Admission decisions by reason",
331
+ ["reason"],
332
+ );
333
+ this.cartethyia_ip_abuse_refusals_total = this.counter(
334
+ "cartethyia_ip_abuse_refusals_total",
335
+ "Requests refused by the per-IP abuse limit, by reason",
336
+ ["reason"],
337
+ );
338
+ this.proxy_in_flight = this.gauge(
339
+ "proxy_in_flight",
340
+ "Provider dispatch requests currently in flight on this process",
341
+ );
342
+ this.proxy_request_latency_ms = this.histogram(
343
+ "proxy_request_latency_ms",
344
+ "Request latency in milliseconds",
345
+ REQUEST_LATENCY_BUCKETS,
346
+ );
347
+ this.cartethyia_telemetry_buffered = this.gauge(
348
+ "cartethyia_telemetry_buffered",
349
+ "Telemetry events waiting to be flushed",
350
+ );
351
+ this.cartethyia_telemetry_dropped_total = this.counter(
352
+ "cartethyia_telemetry_dropped_total",
353
+ "Telemetry events dropped because the buffer was full",
354
+ );
355
+ this.cartethyia_memory_rss_bytes = this.gauge(
356
+ "cartethyia_memory_rss_bytes",
357
+ "Resident set size of the process in bytes",
358
+ );
359
+ this.cartethyia_memory_heap_used_bytes = this.gauge(
360
+ "cartethyia_memory_heap_used_bytes",
361
+ "JavaScript heap used in bytes",
362
+ );
363
+ this.cartethyia_memory_heap_total_bytes = this.gauge(
364
+ "cartethyia_memory_heap_total_bytes",
365
+ "JavaScript heap reserved in bytes",
366
+ );
367
+ this.cartethyia_memory_limit_bytes = this.gauge(
368
+ "cartethyia_memory_limit_bytes",
369
+ "Container/cgroup memory limit in bytes (0 when unknown)",
370
+ );
371
+ this.cartethyia_process_in_flight = this.gauge(
372
+ "cartethyia_process_in_flight",
373
+ "Gateway requests currently admitted into this process",
374
+ );
375
+ this.cartethyia_telemetry_backlog_items = this.gauge(
376
+ "cartethyia_telemetry_backlog_items",
377
+ "Telemetry events queued for the next drain",
378
+ );
379
+ this.cartethyia_telemetry_backlog_bytes = this.gauge(
380
+ "cartethyia_telemetry_backlog_bytes",
381
+ "Estimated bytes queued for the next telemetry drain",
382
+ );
383
+ this.cartethyia_routing_roundrobin_entries = this.gauge(
384
+ "cartethyia_routing_roundrobin_entries",
385
+ "Round-robin routing state entries by scope",
386
+ ["scope"],
387
+ );
388
+ this.cartethyia_ip_abuse_keys = this.gauge(
389
+ "cartethyia_ip_abuse_keys",
390
+ "Tracked keys in the in-memory IP abuse store (0 when Redis-backed)",
391
+ );
392
+ this.cartethyia_quota_cache_entries = this.gauge(
393
+ "cartethyia_quota_cache_entries",
394
+ "Entries in the in-process provider quota cache",
395
+ );
396
+ this.cartethyia_model_catalog_entries = this.gauge(
397
+ "cartethyia_model_catalog_entries",
398
+ "Entries in the per-provider model catalog cache (at the cap means eviction churn)",
399
+ );
400
+ this.cartethyia_proxy_dial_proxy_resolved_total = this.counter(
401
+ "cartethyia_proxy_dial_proxy_resolved_total",
402
+ "Pool/relay-bound dials whose target is resolved by the proxy or relay rather than by the local resolver, so local DNS for the target is advisory only",
403
+ );
404
+ this.proxy_provider_adapter_load_ms = this.histogram(
405
+ "proxy_provider_adapter_load_ms",
406
+ "Time to load a provider adapter on first use",
407
+ ADAPTER_LOAD_BUCKETS,
408
+ );
409
+ this.pool_cooldown_record_failed = this.counter(
410
+ "pool_cooldown_record_failed",
411
+ "Pool cooldown writes that failed after a provider rate limit",
412
+ );
413
+ this.quota_cache_invalidate_failed = this.counter(
414
+ "quota_cache_invalidate_failed",
415
+ "Quota cache invalidations that failed",
416
+ );
417
+ this.cartethyia_payload_backlog_dropped_total = this.counter(
418
+ "cartethyia_payload_backlog_dropped_total",
419
+ "Payload captures dropped because the payload writer backlog was full",
420
+ );
421
+ this.cartethyia_telemetry_raw_conflicts_total = this.counter(
422
+ "cartethyia_telemetry_raw_conflicts_total",
423
+ "Telemetry backfill conflicts (same tenant/request identity, different content)",
424
+ );
425
+ }
426
+
427
+ counter(name: string, help: string, labelNames: readonly string[] = []): CounterMetric {
428
+ const metric = new Counter(name, help, labelNames);
429
+ this.all.push(metric);
430
+ return metric;
431
+ }
432
+
433
+ gauge(name: string, help: string, labelNames: readonly string[] = []): GaugeMetric {
434
+ const metric = new Gauge(name, help, labelNames);
435
+ this.all.push(metric);
436
+ return metric;
437
+ }
438
+
439
+ /**
440
+ * Registers a histogram.
441
+ *
442
+ * `labelNames` declares the label set up front. It is optional because a
443
+ * histogram observed without labels still has to render, but passing it is
444
+ * the correct call: a declared set is stable across runs and workers, so the
445
+ * exposition is deterministic even before the first observation, and an
446
+ * observation carrying undeclared keys cannot create a second, incompatible
447
+ * series.
448
+ */
449
+ histogram(
450
+ name: string,
451
+ help: string,
452
+ buckets: readonly number[],
453
+ labelNames: readonly string[] = [],
454
+ ): HistogramMetric {
455
+ const metric = new Histogram(name, help, buckets, labelNames);
456
+ this.all.push(metric);
457
+ return metric;
458
+ }
459
+
460
+ render(): string {
461
+ const lines: string[] = [];
462
+ for (const metric of this.all) lines.push(...metric.render());
463
+ return `${lines.join("\n")}\n`;
464
+ }
465
+ }
466
+
467
+ export const metrics = new PrometheusRegistry();