dosync 0.4.1__py3-none-any.whl

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.
dosync/metrics.py ADDED
@@ -0,0 +1,244 @@
1
+ """
2
+ DoSync — minimal metrics (Prometheus text exposition format, zero dependencies).
3
+ ================================================================================
4
+
5
+ Design (see docs panel 2026-07-07, REL-1):
6
+
7
+ * Metrics are an OPERATIONAL feature of the reference implementation — they are
8
+ NOT part of the normative protocol. A hub without /metrics is still conforming.
9
+ The spec carries a non-normative "Recommended metrics" appendix so independent
10
+ implementations can expose comparable series under the same names.
11
+
12
+ * Phase 1 is counters + gauges only, hand-rolled (no prometheus_client dep).
13
+ Latency histograms are phase 2 — if/when they land, prometheus_client is the
14
+ right tool (correct histogram semantics are easy to get wrong by hand).
15
+
16
+ * CARDINALITY RULE: label values must be bounded sets (intent classes, urgency
17
+ levels, outcome enums). Never put unbounded user input in a label — rejected
18
+ intents are counted under intent_class="_invalid" for exactly this reason.
19
+ device_id appears ONLY in the low-volume health gauge, never in hot counters.
20
+
21
+ * Counters live in memory and reset on restart — that is normal Prometheus
22
+ behavior (rate()/increase() handle counter resets). Gauges are derived at
23
+ scrape time from the registry/DB via cheap callbacks; nothing walks the full
24
+ audit log per scrape.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import threading
30
+ from typing import Callable, Iterable, Sequence
31
+
32
+
33
+ def _escape_label(v: str) -> str:
34
+ """Escape a label value per the Prometheus text format."""
35
+ return str(v).replace("\\", r"\\").replace('"', r"\"").replace("\n", r"\n")
36
+
37
+
38
+ def _num(v) -> str:
39
+ """Render a number without trailing .0 noise for integers."""
40
+ f = float(v)
41
+ return str(int(f)) if f.is_integer() else repr(f)
42
+
43
+
44
+ class Counter:
45
+ """A monotonically increasing counter with optional labels. Thread-safe."""
46
+
47
+ def __init__(self, name: str, help_: str, labelnames: Sequence[str] = ()):
48
+ self.name = name
49
+ self.help = help_
50
+ self.labelnames = tuple(labelnames)
51
+ self._values: dict[tuple, float] = {}
52
+ self._lock = threading.Lock()
53
+
54
+ def inc(self, labels: dict | None = None, amount: float = 1) -> None:
55
+ key = tuple(str((labels or {}).get(n, "")) for n in self.labelnames)
56
+ with self._lock:
57
+ self._values[key] = self._values.get(key, 0) + amount
58
+
59
+ def samples(self) -> dict[tuple, float]:
60
+ with self._lock:
61
+ return dict(self._values)
62
+
63
+ def render(self) -> list[str]:
64
+ lines = [f"# HELP {self.name} {self.help}", f"# TYPE {self.name} counter"]
65
+ samples = self.samples()
66
+ if not samples:
67
+ # Expose an explicit zero so the series exists before first increment
68
+ # (only possible without labels; labeled series appear on first inc).
69
+ if not self.labelnames:
70
+ lines.append(f"{self.name} 0")
71
+ return lines
72
+ for key in sorted(samples):
73
+ if self.labelnames:
74
+ labelstr = ",".join(
75
+ f'{n}="{_escape_label(v)}"' for n, v in zip(self.labelnames, key)
76
+ )
77
+ lines.append(f"{self.name}{{{labelstr}}} {_num(samples[key])}")
78
+ else:
79
+ lines.append(f"{self.name} {_num(samples[key])}")
80
+ return lines
81
+
82
+
83
+ class Histogram:
84
+ """Cumulative histogram in Prometheus text format. Thread-safe, zero-dep.
85
+
86
+ Emits the standard trio: _bucket{le=...} (cumulative counts, including +Inf),
87
+ _sum, and _count. Buckets are upper bounds in seconds. We hand-roll this
88
+ rather than take a prometheus_client dependency (DoSync keeps its dep
89
+ surface minimal); the exposition format is covered exactly by
90
+ tests/test_metrics.py so the "histograms are fiddly by hand" risk is pinned.
91
+ """
92
+
93
+ def __init__(self, name: str, help_: str, buckets: Sequence[float],
94
+ labelnames: Sequence[str] = ()):
95
+ self.name = name
96
+ self.help = help_
97
+ self.labelnames = tuple(labelnames)
98
+ self._buckets = tuple(sorted(buckets))
99
+ # per label-key: [counts-per-bucket (len = nbuckets, no +Inf), sum, count]
100
+ self._data: dict[tuple, list] = {}
101
+ self._lock = threading.Lock()
102
+
103
+ def observe(self, value: float, labels: dict | None = None) -> None:
104
+ key = tuple(str((labels or {}).get(n, "")) for n in self.labelnames)
105
+ with self._lock:
106
+ rec = self._data.get(key)
107
+ if rec is None:
108
+ rec = [[0] * len(self._buckets), 0.0, 0]
109
+ self._data[key] = rec
110
+ counts, _sum, _count = rec
111
+ # Record in the single tightest bucket; render() makes it cumulative.
112
+ for i, ub in enumerate(self._buckets):
113
+ if value <= ub:
114
+ counts[i] += 1
115
+ break
116
+ rec[1] = _sum + value
117
+ rec[2] = _count + 1
118
+
119
+ def render(self) -> list[str]:
120
+ lines = [f"# HELP {self.name} {self.help}", f"# TYPE {self.name} histogram"]
121
+ with self._lock:
122
+ data = {k: [list(v[0]), v[1], v[2]] for k, v in self._data.items()}
123
+ for key in sorted(data):
124
+ counts, _sum, _count = data[key]
125
+ base_labels = list(zip(self.labelnames, key))
126
+ cumulative = 0
127
+ for i, ub in enumerate(self._buckets):
128
+ cumulative += counts[i]
129
+ le = _num(ub)
130
+ pairs = base_labels + [("le", le)]
131
+ labelstr = ",".join(f'{n}="{_escape_label(v)}"' for n, v in pairs)
132
+ lines.append(f"{self.name}_bucket{{{labelstr}}} {cumulative}")
133
+ # +Inf bucket == total count
134
+ inf_pairs = base_labels + [("le", "+Inf")]
135
+ inf_labelstr = ",".join(f'{n}="{_escape_label(v)}"' for n, v in inf_pairs)
136
+ lines.append(f"{self.name}_bucket{{{inf_labelstr}}} {_count}")
137
+ suffix = ("{" + ",".join(f'{n}="{_escape_label(v)}"' for n, v in base_labels) + "}") if base_labels else ""
138
+ lines.append(f"{self.name}_sum{suffix} {_num(_sum)}")
139
+ lines.append(f"{self.name}_count{suffix} {_count}")
140
+ return lines
141
+
142
+
143
+ class MetricsRegistry:
144
+ """Holds counters and gauge callbacks; renders the exposition document."""
145
+
146
+ def __init__(self):
147
+ self._counters: list[Counter] = []
148
+ self._histograms: list[Histogram] = []
149
+ self._gauges: list[tuple[str, str, Callable]] = []
150
+
151
+ def counter(self, name: str, help_: str, labelnames: Sequence[str] = ()) -> Counter:
152
+ c = Counter(name, help_, labelnames)
153
+ self._counters.append(c)
154
+ return c
155
+
156
+ def histogram(self, name: str, help_: str, buckets: Sequence[float],
157
+ labelnames: Sequence[str] = ()) -> Histogram:
158
+ h = Histogram(name, help_, buckets, labelnames)
159
+ self._histograms.append(h)
160
+ return h
161
+
162
+ def gauge_func(self, name: str, help_: str, fn: Callable) -> None:
163
+ """Register a gauge computed at scrape time.
164
+
165
+ `fn` returns either a scalar, or an iterable of (labels_dict, value)
166
+ for labeled gauges. Exceptions in `fn` skip the gauge for that scrape
167
+ rather than failing the whole endpoint — a broken gauge must never
168
+ take observability down with it.
169
+ """
170
+ self._gauges.append((name, help_, fn))
171
+
172
+ def render(self) -> str:
173
+ lines: list[str] = []
174
+ for c in self._counters:
175
+ lines.extend(c.render())
176
+ for h in self._histograms:
177
+ lines.extend(h.render())
178
+ for name, help_, fn in self._gauges:
179
+ try:
180
+ out = fn()
181
+ except Exception:
182
+ continue
183
+ lines.append(f"# HELP {name} {help_}")
184
+ lines.append(f"# TYPE {name} gauge")
185
+ if isinstance(out, (int, float, bool)):
186
+ lines.append(f"{name} {_num(out)}")
187
+ else:
188
+ for labels, value in out:
189
+ labelstr = ",".join(
190
+ f'{k}="{_escape_label(v)}"' for k, v in sorted(labels.items())
191
+ )
192
+ lines.append(f"{name}{{{labelstr}}} {_num(value)}")
193
+ return "\n".join(lines) + "\n"
194
+
195
+
196
+ # ── DoSync registry and counters ──────────────────────────────────────────────
197
+ # Gauges are registered by the server at startup (they close over live state).
198
+
199
+ REGISTRY = MetricsRegistry()
200
+
201
+ intents_total = REGISTRY.counter(
202
+ "dosync_intents_total",
203
+ "Intents received by the hub, by class, urgency and acceptance outcome",
204
+ ("intent_class", "urgency", "outcome"), # outcome: accepted | rejected
205
+ )
206
+ intent_executions_total = REGISTRY.counter(
207
+ "dosync_intent_executions_total",
208
+ "Completed intent executions by outcome",
209
+ ("outcome",), # success | partial | failed
210
+ )
211
+ intent_actions_total = REGISTRY.counter(
212
+ "dosync_intent_actions_total",
213
+ "Individual device actions dispatched by intents, by result",
214
+ ("result",), # success | failed | superseded
215
+ )
216
+ policy_decisions_total = REGISTRY.counter(
217
+ "dosync_policy_decisions_total",
218
+ "Policy engine decisions",
219
+ ("decision",), # allow | block | confirm | modify
220
+ )
221
+ emergency_intents_total = REGISTRY.counter(
222
+ "dosync_emergency_intents_total",
223
+ "Intents accepted with emergency urgency",
224
+ )
225
+ device_preemptions_total = REGISTRY.counter(
226
+ "dosync_device_preemptions_total",
227
+ "Lower-urgency actions superseded because the device was claimed by an emergency",
228
+ )
229
+
230
+ # Latency histograms (REL-1 phase 2). Buckets in seconds, tuned for a hub that
231
+ # targets sub-second intent handling: fine-grained below 100ms, headroom to 5s.
232
+ _LATENCY_BUCKETS = (0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0)
233
+
234
+ intent_resolution_seconds = REGISTRY.histogram(
235
+ "dosync_intent_resolution_seconds",
236
+ "Time for the resolver to turn an intent into an action plan",
237
+ _LATENCY_BUCKETS,
238
+ )
239
+ action_execution_seconds = REGISTRY.histogram(
240
+ "dosync_action_execution_seconds",
241
+ "Time to execute a single device action, by adapter result",
242
+ _LATENCY_BUCKETS,
243
+ ("result",), # success | failed — bounded, safe as a label
244
+ )