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/__init__.py +17 -0
- dosync/adapters/__init__.py +258 -0
- dosync/adapters/ble.py +199 -0
- dosync/adapters/homeassistant.py +655 -0
- dosync/adapters/matter.py +320 -0
- dosync/adapters/mavlink.py +1205 -0
- dosync/adapters/mqtt.py +409 -0
- dosync/adapters/notifications.py +153 -0
- dosync/adapters/shelly.py +348 -0
- dosync/adapters/wiz.py +357 -0
- dosync/audit_backup.py +184 -0
- dosync/auth.py +194 -0
- dosync/auth_fastapi.py +87 -0
- dosync/cert_signing.py +117 -0
- dosync/certify.py +1091 -0
- dosync/cli.py +61 -0
- dosync/composite_operations.py +306 -0
- dosync/db.py +826 -0
- dosync/device_arbiter.py +270 -0
- dosync/discovery.py +208 -0
- dosync/ed25519_pure.py +204 -0
- dosync/executor.py +97 -0
- dosync/geo.py +63 -0
- dosync/hub.py +2923 -0
- dosync/hub_monitor.py +144 -0
- dosync/manage.py +913 -0
- dosync/mcp_server.py +746 -0
- dosync/metrics.py +244 -0
- dosync/models.py +562 -0
- dosync/operation_guards.py +228 -0
- dosync/operation_supervisor.py +216 -0
- dosync/operations.py +331 -0
- dosync/policies.py +1210 -0
- dosync/policy_config.py +252 -0
- dosync/py.typed +0 -0
- dosync/reconciler.py +177 -0
- dosync/route_composer.py +189 -0
- dosync/security.py +680 -0
- dosync/server.py +1911 -0
- dosync/validation.py +98 -0
- dosync-0.4.1.dist-info/METADATA +372 -0
- dosync-0.4.1.dist-info/RECORD +46 -0
- dosync-0.4.1.dist-info/WHEEL +5 -0
- dosync-0.4.1.dist-info/entry_points.txt +4 -0
- dosync-0.4.1.dist-info/licenses/LICENSE +201 -0
- dosync-0.4.1.dist-info/top_level.txt +1 -0
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
|
+
)
|