haechi 1.1.2 → 1.2.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.
@@ -0,0 +1,181 @@
1
+ // WS4-A telemetry seam (reliability-hardening-track §WS4 "Telemetry").
2
+ //
3
+ // A minimal, zero-dependency in-memory metrics collector rendering the
4
+ // Prometheus text exposition format. It is an INJECTABLE collaborator
5
+ // (providers.metrics in createRuntime), mirroring auditSink/rateLimiter; an
6
+ // operator who wants a real metrics backend injects their own object exposing
7
+ // the same { increment, observe, render } contract.
8
+ //
9
+ // HARD INVARIANT (the no-plaintext-in-audit invariant, extended to telemetry):
10
+ // every metric name AND every label value is a BOUNDED ENUM — a route id, a
11
+ // policy mode, or a decision class. It is NEVER an identity id/subject, a token,
12
+ // a detected value, or any other unbounded/PII-bearing string. This module does
13
+ // not — and structurally cannot — accept a payload value: callers pass only
14
+ // pre-classified enum labels, and label values are coerced + length-capped here
15
+ // as defence in depth so an accidental high-cardinality value cannot explode
16
+ // the series set or leak content.
17
+
18
+ // Metric catalogue: name -> { type, help }. Counters and one histogram. Keeping
19
+ // the catalogue explicit (rather than letting callers invent metric names)
20
+ // bounds the metric-name dimension to this fixed set.
21
+ const COUNTERS = {
22
+ haechi_requests_total: "Proxy requests by route, mode, and decision class.",
23
+ haechi_blocks_total: "Requests blocked by a policy decision.",
24
+ haechi_auth_denied_total: "Requests denied at authentication.",
25
+ haechi_rate_limited_total: "Requests rejected by the rate limiter.",
26
+ haechi_upstream_timeout_total: "Upstream requests that timed out.",
27
+ haechi_upstream_error_total: "Upstream requests that failed (non-timeout).",
28
+ haechi_response_unprotected_total: "Responses forwarded without protection (size/encoding/parse).",
29
+ haechi_internal_error_total: "Unexpected internal proxy errors.",
30
+ haechi_overloaded_total: "Requests rejected by the max-in-flight backpressure ceiling (503)."
31
+ };
32
+
33
+ const HISTOGRAMS = {
34
+ haechi_request_duration_seconds: "End-to-end proxy request handling duration in seconds."
35
+ };
36
+
37
+ // Default request-duration histogram buckets (seconds). Bounded, fixed set.
38
+ const DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
39
+
40
+ // Defence-in-depth label hygiene: a label value must be a short, bounded token.
41
+ // We coerce to string, trim, cap length, and collapse anything outside a safe
42
+ // charset to "_". This guarantees that even a caller mistake cannot place a raw
43
+ // payload value or a long identity string into a series label.
44
+ const MAX_LABEL_LENGTH = 64;
45
+
46
+ function safeLabelValue(value) {
47
+ if (value === undefined || value === null) {
48
+ return "none";
49
+ }
50
+ const text = String(value).slice(0, MAX_LABEL_LENGTH);
51
+ // Allow a conservative identifier charset only (route ids, modes, decisions
52
+ // are all of this shape). Everything else becomes "_".
53
+ return text.replace(/[^A-Za-z0-9_.:/-]/g, "_") || "none";
54
+ }
55
+
56
+ function seriesKey(name, labels) {
57
+ const parts = Object.keys(labels)
58
+ .sort()
59
+ .map((labelName) => `${labelName}="${escapeLabel(labels[labelName])}"`);
60
+ return parts.length > 0 ? `${name}{${parts.join(",")}}` : name;
61
+ }
62
+
63
+ function escapeLabel(value) {
64
+ return String(value).replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, "\\\"");
65
+ }
66
+
67
+ export function createMetrics({ buckets = DEFAULT_BUCKETS } = {}) {
68
+ // counterSeries: metricName -> Map(seriesKey -> { labels, value })
69
+ const counterSeries = new Map();
70
+ // histogramSeries: metricName -> Map(seriesKey -> { labels, bucketCounts, sum, count })
71
+ const histogramSeries = new Map();
72
+ const sortedBuckets = [...buckets].sort((a, b) => a - b);
73
+
74
+ function normalizeLabels(labels = {}) {
75
+ const out = {};
76
+ for (const [key, value] of Object.entries(labels)) {
77
+ // Label NAMES are caller-fixed identifiers; coerce defensively anyway.
78
+ const labelName = String(key).replace(/[^A-Za-z0-9_]/g, "_");
79
+ out[labelName] = safeLabelValue(value);
80
+ }
81
+ return out;
82
+ }
83
+
84
+ return {
85
+ // Increment a known counter by `amount` (default 1), labelled by a bounded
86
+ // enum set. An unknown metric name is ignored (fail-soft for telemetry — a
87
+ // metric typo must never break a request path).
88
+ increment(name, labels = {}, amount = 1) {
89
+ if (!(name in COUNTERS)) {
90
+ return;
91
+ }
92
+ const safe = normalizeLabels(labels);
93
+ const key = seriesKey(name, safe);
94
+ let series = counterSeries.get(name);
95
+ if (!series) {
96
+ series = new Map();
97
+ counterSeries.set(name, series);
98
+ }
99
+ const existing = series.get(key);
100
+ if (existing) {
101
+ existing.value += amount;
102
+ } else {
103
+ series.set(key, { labels: safe, value: amount });
104
+ }
105
+ },
106
+
107
+ // Observe a value into a known histogram (request-duration seconds).
108
+ observe(name, value, labels = {}) {
109
+ if (!(name in HISTOGRAMS) || typeof value !== "number" || !Number.isFinite(value)) {
110
+ return;
111
+ }
112
+ const safe = normalizeLabels(labels);
113
+ const key = seriesKey(name, safe);
114
+ let series = histogramSeries.get(name);
115
+ if (!series) {
116
+ series = new Map();
117
+ histogramSeries.set(name, series);
118
+ }
119
+ let entry = series.get(key);
120
+ if (!entry) {
121
+ entry = { labels: safe, bucketCounts: new Array(sortedBuckets.length).fill(0), sum: 0, count: 0 };
122
+ series.set(key, entry);
123
+ }
124
+ entry.sum += value;
125
+ entry.count += 1;
126
+ for (let i = 0; i < sortedBuckets.length; i += 1) {
127
+ if (value <= sortedBuckets[i]) {
128
+ entry.bucketCounts[i] += 1;
129
+ }
130
+ }
131
+ },
132
+
133
+ // Render the full Prometheus text exposition. Every declared counter and
134
+ // histogram emits its HELP/TYPE header even with no observations, so the
135
+ // surface is stable for scrapers.
136
+ render() {
137
+ const lines = [];
138
+
139
+ for (const [name, help] of Object.entries(COUNTERS)) {
140
+ lines.push(`# HELP ${name} ${help}`);
141
+ lines.push(`# TYPE ${name} counter`);
142
+ const series = counterSeries.get(name);
143
+ if (series) {
144
+ for (const { labels, value } of series.values()) {
145
+ lines.push(`${seriesKey(name, labels)} ${value}`);
146
+ }
147
+ }
148
+ }
149
+
150
+ for (const [name, help] of Object.entries(HISTOGRAMS)) {
151
+ lines.push(`# HELP ${name} ${help}`);
152
+ lines.push(`# TYPE ${name} histogram`);
153
+ const series = histogramSeries.get(name);
154
+ if (series) {
155
+ for (const entry of series.values()) {
156
+ // bucketCounts[i] already holds the cumulative count of observations
157
+ // with value <= sortedBuckets[i] (observe() increments every bucket
158
+ // the value falls under), which is exactly the Prometheus le="..."
159
+ // cumulative bucket semantics — emit it directly.
160
+ for (let i = 0; i < sortedBuckets.length; i += 1) {
161
+ const labels = { ...entry.labels, le: String(sortedBuckets[i]) };
162
+ lines.push(`${seriesKey(`${name}_bucket`, labels)} ${entry.bucketCounts[i]}`);
163
+ }
164
+ const infLabels = { ...entry.labels, le: "+Inf" };
165
+ lines.push(`${seriesKey(`${name}_bucket`, infLabels)} ${entry.count}`);
166
+ lines.push(`${seriesKey(`${name}_sum`, entry.labels)} ${entry.sum}`);
167
+ lines.push(`${seriesKey(`${name}_count`, entry.labels)} ${entry.count}`);
168
+ }
169
+ }
170
+ }
171
+
172
+ return `${lines.join("\n")}\n`;
173
+ }
174
+ };
175
+ }
176
+
177
+ // Exported for tests / operators who want to assert the bounded metric surface.
178
+ export const METRIC_NAMES = Object.freeze({
179
+ counters: Object.keys(COUNTERS),
180
+ histograms: Object.keys(HISTOGRAMS)
181
+ });