invarlock 0.2.0__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.
- invarlock/__init__.py +33 -0
- invarlock/__main__.py +10 -0
- invarlock/_data/runtime/profiles/ci_cpu.yaml +15 -0
- invarlock/_data/runtime/profiles/release.yaml +23 -0
- invarlock/_data/runtime/tiers.yaml +76 -0
- invarlock/adapters/__init__.py +102 -0
- invarlock/adapters/_capabilities.py +45 -0
- invarlock/adapters/auto.py +99 -0
- invarlock/adapters/base.py +530 -0
- invarlock/adapters/base_types.py +85 -0
- invarlock/adapters/hf_bert.py +852 -0
- invarlock/adapters/hf_gpt2.py +403 -0
- invarlock/adapters/hf_llama.py +485 -0
- invarlock/adapters/hf_mixin.py +383 -0
- invarlock/adapters/hf_onnx.py +112 -0
- invarlock/adapters/hf_t5.py +137 -0
- invarlock/adapters/py.typed +1 -0
- invarlock/assurance/__init__.py +43 -0
- invarlock/cli/__init__.py +8 -0
- invarlock/cli/__main__.py +8 -0
- invarlock/cli/_evidence.py +25 -0
- invarlock/cli/_json.py +75 -0
- invarlock/cli/adapter_auto.py +162 -0
- invarlock/cli/app.py +287 -0
- invarlock/cli/commands/__init__.py +26 -0
- invarlock/cli/commands/certify.py +403 -0
- invarlock/cli/commands/doctor.py +1358 -0
- invarlock/cli/commands/explain_gates.py +151 -0
- invarlock/cli/commands/export_html.py +100 -0
- invarlock/cli/commands/plugins.py +1331 -0
- invarlock/cli/commands/report.py +354 -0
- invarlock/cli/commands/run.py +4146 -0
- invarlock/cli/commands/verify.py +1040 -0
- invarlock/cli/config.py +396 -0
- invarlock/cli/constants.py +68 -0
- invarlock/cli/device.py +92 -0
- invarlock/cli/doctor_helpers.py +74 -0
- invarlock/cli/errors.py +6 -0
- invarlock/cli/overhead_utils.py +60 -0
- invarlock/cli/provenance.py +66 -0
- invarlock/cli/utils.py +41 -0
- invarlock/config.py +56 -0
- invarlock/core/__init__.py +62 -0
- invarlock/core/abi.py +15 -0
- invarlock/core/api.py +274 -0
- invarlock/core/auto_tuning.py +317 -0
- invarlock/core/bootstrap.py +226 -0
- invarlock/core/checkpoint.py +221 -0
- invarlock/core/contracts.py +73 -0
- invarlock/core/error_utils.py +64 -0
- invarlock/core/events.py +298 -0
- invarlock/core/exceptions.py +95 -0
- invarlock/core/registry.py +481 -0
- invarlock/core/retry.py +146 -0
- invarlock/core/runner.py +2041 -0
- invarlock/core/types.py +154 -0
- invarlock/edits/__init__.py +12 -0
- invarlock/edits/_edit_utils.py +249 -0
- invarlock/edits/_external_utils.py +268 -0
- invarlock/edits/noop.py +47 -0
- invarlock/edits/py.typed +1 -0
- invarlock/edits/quant_rtn.py +801 -0
- invarlock/edits/registry.py +166 -0
- invarlock/eval/__init__.py +23 -0
- invarlock/eval/bench.py +1207 -0
- invarlock/eval/bootstrap.py +50 -0
- invarlock/eval/data.py +2052 -0
- invarlock/eval/metrics.py +2167 -0
- invarlock/eval/primary_metric.py +767 -0
- invarlock/eval/probes/__init__.py +24 -0
- invarlock/eval/probes/fft.py +139 -0
- invarlock/eval/probes/mi.py +213 -0
- invarlock/eval/probes/post_attention.py +323 -0
- invarlock/eval/providers/base.py +67 -0
- invarlock/eval/providers/seq2seq.py +111 -0
- invarlock/eval/providers/text_lm.py +113 -0
- invarlock/eval/providers/vision_text.py +93 -0
- invarlock/eval/py.typed +1 -0
- invarlock/guards/__init__.py +18 -0
- invarlock/guards/_contracts.py +9 -0
- invarlock/guards/invariants.py +640 -0
- invarlock/guards/policies.py +805 -0
- invarlock/guards/py.typed +1 -0
- invarlock/guards/rmt.py +2097 -0
- invarlock/guards/spectral.py +1419 -0
- invarlock/guards/tier_config.py +354 -0
- invarlock/guards/variance.py +3298 -0
- invarlock/guards_ref/__init__.py +15 -0
- invarlock/guards_ref/rmt_ref.py +40 -0
- invarlock/guards_ref/spectral_ref.py +135 -0
- invarlock/guards_ref/variance_ref.py +60 -0
- invarlock/model_profile.py +353 -0
- invarlock/model_utils.py +221 -0
- invarlock/observability/__init__.py +10 -0
- invarlock/observability/alerting.py +535 -0
- invarlock/observability/core.py +546 -0
- invarlock/observability/exporters.py +565 -0
- invarlock/observability/health.py +588 -0
- invarlock/observability/metrics.py +457 -0
- invarlock/observability/py.typed +1 -0
- invarlock/observability/utils.py +553 -0
- invarlock/plugins/__init__.py +12 -0
- invarlock/plugins/hello_guard.py +33 -0
- invarlock/plugins/hf_awq_adapter.py +82 -0
- invarlock/plugins/hf_bnb_adapter.py +79 -0
- invarlock/plugins/hf_gptq_adapter.py +78 -0
- invarlock/plugins/py.typed +1 -0
- invarlock/py.typed +1 -0
- invarlock/reporting/__init__.py +7 -0
- invarlock/reporting/certificate.py +3221 -0
- invarlock/reporting/certificate_schema.py +244 -0
- invarlock/reporting/dataset_hashing.py +215 -0
- invarlock/reporting/guards_analysis.py +948 -0
- invarlock/reporting/html.py +32 -0
- invarlock/reporting/normalizer.py +235 -0
- invarlock/reporting/policy_utils.py +517 -0
- invarlock/reporting/primary_metric_utils.py +265 -0
- invarlock/reporting/render.py +1442 -0
- invarlock/reporting/report.py +903 -0
- invarlock/reporting/report_types.py +278 -0
- invarlock/reporting/utils.py +175 -0
- invarlock/reporting/validate.py +631 -0
- invarlock/security.py +176 -0
- invarlock/sparsity_utils.py +323 -0
- invarlock/utils/__init__.py +150 -0
- invarlock/utils/digest.py +45 -0
- invarlock-0.2.0.dist-info/METADATA +586 -0
- invarlock-0.2.0.dist-info/RECORD +132 -0
- invarlock-0.2.0.dist-info/WHEEL +5 -0
- invarlock-0.2.0.dist-info/entry_points.txt +20 -0
- invarlock-0.2.0.dist-info/licenses/LICENSE +201 -0
- invarlock-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Metrics exporters for various monitoring systems.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from invarlock.core.exceptions import ObservabilityError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ExportedMetric:
|
|
18
|
+
"""Represents a metric for export."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
value: float | int
|
|
22
|
+
timestamp: float
|
|
23
|
+
labels: dict[str, str] = field(default_factory=dict)
|
|
24
|
+
metric_type: str = "gauge" # gauge, counter, histogram, summary
|
|
25
|
+
help_text: str = ""
|
|
26
|
+
|
|
27
|
+
def to_prometheus_format(self) -> str:
|
|
28
|
+
"""Convert to Prometheus exposition format."""
|
|
29
|
+
lines = []
|
|
30
|
+
|
|
31
|
+
# Add help text
|
|
32
|
+
if self.help_text:
|
|
33
|
+
lines.append(f"# HELP {self.name} {self.help_text}")
|
|
34
|
+
|
|
35
|
+
# Add type
|
|
36
|
+
lines.append(f"# TYPE {self.name} {self.metric_type}")
|
|
37
|
+
|
|
38
|
+
# Format labels
|
|
39
|
+
if self.labels:
|
|
40
|
+
label_str = ",".join([f'{k}="{v}"' for k, v in self.labels.items()])
|
|
41
|
+
metric_line = (
|
|
42
|
+
f"{self.name}{{{label_str}}} {self.value} {int(self.timestamp * 1000)}"
|
|
43
|
+
)
|
|
44
|
+
else:
|
|
45
|
+
metric_line = f"{self.name} {self.value} {int(self.timestamp * 1000)}"
|
|
46
|
+
|
|
47
|
+
lines.append(metric_line)
|
|
48
|
+
return "\n".join(lines)
|
|
49
|
+
|
|
50
|
+
def to_json_format(self) -> dict[str, Any]:
|
|
51
|
+
"""Convert to JSON format."""
|
|
52
|
+
return {
|
|
53
|
+
"metric": self.name,
|
|
54
|
+
"value": self.value,
|
|
55
|
+
"timestamp": self.timestamp,
|
|
56
|
+
"labels": self.labels,
|
|
57
|
+
"type": self.metric_type,
|
|
58
|
+
"help": self.help_text,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class MetricsExporter(ABC):
|
|
63
|
+
"""Base class for metrics exporters."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, name: str):
|
|
66
|
+
self.name = name
|
|
67
|
+
self.logger = logging.getLogger(f"{__name__}.{name}")
|
|
68
|
+
self.enabled = True
|
|
69
|
+
self.last_export_time: float = 0.0
|
|
70
|
+
self.export_count = 0
|
|
71
|
+
self.error_count = 0
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def export(self, metrics: list[ExportedMetric]) -> bool:
|
|
75
|
+
"""Export metrics. Returns True if successful."""
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
def get_stats(self) -> dict[str, Any]:
|
|
79
|
+
"""Get exporter statistics."""
|
|
80
|
+
return {
|
|
81
|
+
"name": self.name,
|
|
82
|
+
"enabled": self.enabled,
|
|
83
|
+
"last_export_time": self.last_export_time,
|
|
84
|
+
"export_count": self.export_count,
|
|
85
|
+
"error_count": self.error_count,
|
|
86
|
+
"success_rate": (self.export_count - self.error_count)
|
|
87
|
+
/ max(1, self.export_count),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PrometheusExporter(MetricsExporter):
|
|
92
|
+
"""Exporter for Prometheus format."""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
gateway_url: str | None = None,
|
|
97
|
+
job_name: str = "invarlock",
|
|
98
|
+
push_interval: int = 15,
|
|
99
|
+
instance: str | None = None,
|
|
100
|
+
):
|
|
101
|
+
super().__init__("prometheus")
|
|
102
|
+
self.gateway_url = gateway_url
|
|
103
|
+
self.job_name = job_name
|
|
104
|
+
self.push_interval = push_interval
|
|
105
|
+
self.instance = instance or "localhost"
|
|
106
|
+
|
|
107
|
+
# For HTTP server mode
|
|
108
|
+
self._metrics_cache: dict[str, ExportedMetric] = {}
|
|
109
|
+
self._cache_lock = threading.Lock()
|
|
110
|
+
|
|
111
|
+
def export(self, metrics: list[ExportedMetric]) -> bool:
|
|
112
|
+
"""Export metrics to Prometheus."""
|
|
113
|
+
try:
|
|
114
|
+
if self.gateway_url:
|
|
115
|
+
return self._push_to_gateway(metrics)
|
|
116
|
+
else:
|
|
117
|
+
return self._update_cache(metrics)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
self.logger.error(f"Failed to export to Prometheus: {e}")
|
|
120
|
+
self.error_count += 1
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
def _push_to_gateway(self, metrics: list[ExportedMetric]) -> bool:
|
|
124
|
+
"""Push metrics to Prometheus Gateway."""
|
|
125
|
+
try:
|
|
126
|
+
import requests
|
|
127
|
+
except ImportError:
|
|
128
|
+
self.logger.error("requests library required for Prometheus Gateway")
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
# Convert metrics to Prometheus format
|
|
132
|
+
prometheus_data = "\n".join([m.to_prometheus_format() for m in metrics])
|
|
133
|
+
|
|
134
|
+
# Push to gateway
|
|
135
|
+
url = f"{self.gateway_url}/metrics/job/{self.job_name}/instance/{self.instance}"
|
|
136
|
+
|
|
137
|
+
response = requests.post(
|
|
138
|
+
url,
|
|
139
|
+
data=prometheus_data,
|
|
140
|
+
headers={"Content-Type": "text/plain; version=0.0.4; charset=utf-8"},
|
|
141
|
+
timeout=10,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if response.status_code == 200:
|
|
145
|
+
self.export_count += 1
|
|
146
|
+
self.last_export_time = time.time()
|
|
147
|
+
return True
|
|
148
|
+
else:
|
|
149
|
+
self.logger.error(
|
|
150
|
+
f"Prometheus Gateway returned {response.status_code}: {response.text}"
|
|
151
|
+
)
|
|
152
|
+
self.error_count += 1
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
def _update_cache(self, metrics: list[ExportedMetric]) -> bool:
|
|
156
|
+
"""Update internal cache for HTTP server mode."""
|
|
157
|
+
with self._cache_lock:
|
|
158
|
+
for metric in metrics:
|
|
159
|
+
key = f"{metric.name}_{hash(str(sorted(metric.labels.items())))}"
|
|
160
|
+
self._metrics_cache[key] = metric
|
|
161
|
+
|
|
162
|
+
self.export_count += 1
|
|
163
|
+
self.last_export_time = time.time()
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
def get_metrics_text(self) -> str:
|
|
167
|
+
"""Get current metrics in Prometheus format (for HTTP server)."""
|
|
168
|
+
with self._cache_lock:
|
|
169
|
+
return "\n".join(
|
|
170
|
+
[m.to_prometheus_format() for m in self._metrics_cache.values()]
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class JSONExporter(MetricsExporter):
|
|
175
|
+
"""Exporter for JSON format."""
|
|
176
|
+
|
|
177
|
+
def __init__(self, output_file: str | None = None, pretty_print: bool = True):
|
|
178
|
+
super().__init__("json")
|
|
179
|
+
self.output_file = output_file
|
|
180
|
+
self.pretty_print = pretty_print
|
|
181
|
+
self._metrics_buffer: list[dict[str, Any]] = []
|
|
182
|
+
|
|
183
|
+
def export(self, metrics: list[ExportedMetric]) -> bool:
|
|
184
|
+
"""Export metrics to JSON."""
|
|
185
|
+
try:
|
|
186
|
+
json_metrics = [m.to_json_format() for m in metrics]
|
|
187
|
+
|
|
188
|
+
if self.output_file:
|
|
189
|
+
return self._write_to_file(json_metrics)
|
|
190
|
+
else:
|
|
191
|
+
return self._buffer_metrics(json_metrics)
|
|
192
|
+
|
|
193
|
+
except Exception as e:
|
|
194
|
+
self.logger.error(f"Failed to export to JSON: {e}")
|
|
195
|
+
self.error_count += 1
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
def _write_to_file(self, json_metrics: list[dict[str, Any]]) -> bool:
|
|
199
|
+
"""Write metrics to JSON file."""
|
|
200
|
+
if self.output_file is None:
|
|
201
|
+
self.logger.error("No output file specified")
|
|
202
|
+
self.error_count += 1
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
with open(self.output_file, "w") as f:
|
|
207
|
+
if self.pretty_print:
|
|
208
|
+
json.dump(json_metrics, f, indent=2, default=str)
|
|
209
|
+
else:
|
|
210
|
+
json.dump(json_metrics, f, default=str)
|
|
211
|
+
|
|
212
|
+
self.export_count += 1
|
|
213
|
+
self.last_export_time = time.time()
|
|
214
|
+
return True
|
|
215
|
+
|
|
216
|
+
except Exception as e:
|
|
217
|
+
self.logger.error(f"Failed to write JSON file: {e}")
|
|
218
|
+
self.error_count += 1
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
def _buffer_metrics(self, json_metrics: list[dict[str, Any]]) -> bool:
|
|
222
|
+
"""Buffer metrics in memory."""
|
|
223
|
+
self._metrics_buffer.extend(json_metrics)
|
|
224
|
+
|
|
225
|
+
# Keep buffer size limited
|
|
226
|
+
if len(self._metrics_buffer) > 10000:
|
|
227
|
+
self._metrics_buffer = self._metrics_buffer[-10000:]
|
|
228
|
+
|
|
229
|
+
self.export_count += 1
|
|
230
|
+
self.last_export_time = time.time()
|
|
231
|
+
return True
|
|
232
|
+
|
|
233
|
+
def get_buffered_metrics(self) -> list[dict[str, Any]]:
|
|
234
|
+
"""Get buffered metrics."""
|
|
235
|
+
return self._metrics_buffer.copy()
|
|
236
|
+
|
|
237
|
+
def clear_buffer(self):
|
|
238
|
+
"""Clear metrics buffer."""
|
|
239
|
+
self._metrics_buffer.clear()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def export_or_raise(exporter: MetricsExporter, metrics: list[ExportedMetric]) -> None:
|
|
243
|
+
"""Export metrics via an exporter or raise a typed ObservabilityError.
|
|
244
|
+
|
|
245
|
+
- Raises ObservabilityError(E801) when export returns False or raises.
|
|
246
|
+
- Includes exporter name and reason in details for debugging.
|
|
247
|
+
"""
|
|
248
|
+
try:
|
|
249
|
+
ok = exporter.export(metrics)
|
|
250
|
+
except (
|
|
251
|
+
Exception
|
|
252
|
+
) as e: # pragma: no cover - covered via tests using failing exporter
|
|
253
|
+
raise ObservabilityError(
|
|
254
|
+
code="E801",
|
|
255
|
+
message="OBSERVABILITY-EXPORT-FAILED",
|
|
256
|
+
details={"exporter": exporter.name, "reason": type(e).__name__},
|
|
257
|
+
) from e
|
|
258
|
+
if not ok:
|
|
259
|
+
raise ObservabilityError(
|
|
260
|
+
code="E801",
|
|
261
|
+
message="OBSERVABILITY-EXPORT-FAILED",
|
|
262
|
+
details={"exporter": exporter.name, "reason": "returned_false"},
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class InfluxDBExporter(MetricsExporter):
|
|
267
|
+
"""Exporter for InfluxDB."""
|
|
268
|
+
|
|
269
|
+
def __init__(
|
|
270
|
+
self,
|
|
271
|
+
url: str,
|
|
272
|
+
database: str,
|
|
273
|
+
username: str | None = None,
|
|
274
|
+
password: str | None = None,
|
|
275
|
+
retention_policy: str = "autogen",
|
|
276
|
+
):
|
|
277
|
+
super().__init__("influxdb")
|
|
278
|
+
self.url = url.rstrip("/")
|
|
279
|
+
self.database = database
|
|
280
|
+
self.username = username
|
|
281
|
+
self.password = password
|
|
282
|
+
self.retention_policy = retention_policy
|
|
283
|
+
|
|
284
|
+
def export(self, metrics: list[ExportedMetric]) -> bool:
|
|
285
|
+
"""Export metrics to InfluxDB."""
|
|
286
|
+
try:
|
|
287
|
+
import requests
|
|
288
|
+
except ImportError:
|
|
289
|
+
self.logger.error("requests library required for InfluxDB")
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
# Convert metrics to InfluxDB line protocol
|
|
294
|
+
lines = []
|
|
295
|
+
for metric in metrics:
|
|
296
|
+
line = self._to_line_protocol(metric)
|
|
297
|
+
if line:
|
|
298
|
+
lines.append(line)
|
|
299
|
+
|
|
300
|
+
if not lines:
|
|
301
|
+
return True
|
|
302
|
+
|
|
303
|
+
# Write to InfluxDB
|
|
304
|
+
write_url = f"{self.url}/write"
|
|
305
|
+
params = {
|
|
306
|
+
"db": self.database,
|
|
307
|
+
"rp": self.retention_policy,
|
|
308
|
+
"precision": "ms",
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
auth = None
|
|
312
|
+
if self.username and self.password:
|
|
313
|
+
auth = (self.username, self.password)
|
|
314
|
+
|
|
315
|
+
response = requests.post(
|
|
316
|
+
write_url,
|
|
317
|
+
params=params,
|
|
318
|
+
data="\n".join(lines),
|
|
319
|
+
auth=auth,
|
|
320
|
+
headers={"Content-Type": "text/plain"},
|
|
321
|
+
timeout=10,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
if response.status_code == 204:
|
|
325
|
+
self.export_count += 1
|
|
326
|
+
self.last_export_time = time.time()
|
|
327
|
+
return True
|
|
328
|
+
else:
|
|
329
|
+
self.logger.error(
|
|
330
|
+
f"InfluxDB returned {response.status_code}: {response.text}"
|
|
331
|
+
)
|
|
332
|
+
self.error_count += 1
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
except Exception as e:
|
|
336
|
+
self.logger.error(f"Failed to export to InfluxDB: {e}")
|
|
337
|
+
self.error_count += 1
|
|
338
|
+
return False
|
|
339
|
+
|
|
340
|
+
def _to_line_protocol(self, metric: ExportedMetric) -> str:
|
|
341
|
+
"""Convert metric to InfluxDB line protocol."""
|
|
342
|
+
# Escape special characters in measurement name
|
|
343
|
+
measurement = (
|
|
344
|
+
metric.name.replace(" ", "\\ ").replace(",", "\\,").replace("=", "\\=")
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Build tags
|
|
348
|
+
tag_parts = []
|
|
349
|
+
for key, value in metric.labels.items():
|
|
350
|
+
escaped_key = (
|
|
351
|
+
key.replace(" ", "\\ ").replace(",", "\\,").replace("=", "\\=")
|
|
352
|
+
)
|
|
353
|
+
escaped_value = (
|
|
354
|
+
str(value).replace(" ", "\\ ").replace(",", "\\,").replace("=", "\\=")
|
|
355
|
+
)
|
|
356
|
+
tag_parts.append(f"{escaped_key}={escaped_value}")
|
|
357
|
+
|
|
358
|
+
tags = "," + ",".join(tag_parts) if tag_parts else ""
|
|
359
|
+
|
|
360
|
+
# Build fields (for InfluxDB, we need at least one field)
|
|
361
|
+
fields = f"value={metric.value}"
|
|
362
|
+
|
|
363
|
+
# Timestamp in milliseconds
|
|
364
|
+
timestamp = int(metric.timestamp * 1000)
|
|
365
|
+
|
|
366
|
+
return f"{measurement}{tags} {fields} {timestamp}"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class StatsExporter(MetricsExporter):
|
|
370
|
+
"""Exporter for StatsD protocol."""
|
|
371
|
+
|
|
372
|
+
def __init__(
|
|
373
|
+
self, host: str = "localhost", port: int = 8125, prefix: str = "invarlock"
|
|
374
|
+
):
|
|
375
|
+
super().__init__("statsd")
|
|
376
|
+
self.host = host
|
|
377
|
+
self.port = port
|
|
378
|
+
self.prefix = prefix
|
|
379
|
+
self._socket: Any | None = None
|
|
380
|
+
|
|
381
|
+
def export(self, metrics: list[ExportedMetric]) -> bool:
|
|
382
|
+
"""Export metrics to StatsD."""
|
|
383
|
+
try:
|
|
384
|
+
import socket
|
|
385
|
+
except ImportError:
|
|
386
|
+
self.logger.error("socket library required for StatsD")
|
|
387
|
+
return False
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
if not self._socket:
|
|
391
|
+
self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
392
|
+
|
|
393
|
+
for metric in metrics:
|
|
394
|
+
statsd_line = self._to_statsd_format(metric)
|
|
395
|
+
if statsd_line and self._socket:
|
|
396
|
+
self._socket.sendto(
|
|
397
|
+
statsd_line.encode("utf-8"), (self.host, self.port)
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
self.export_count += 1
|
|
401
|
+
self.last_export_time = time.time()
|
|
402
|
+
return True
|
|
403
|
+
|
|
404
|
+
except Exception as e:
|
|
405
|
+
self.logger.error(f"Failed to export to StatsD: {e}")
|
|
406
|
+
self.error_count += 1
|
|
407
|
+
return False
|
|
408
|
+
|
|
409
|
+
def _to_statsd_format(self, metric: ExportedMetric) -> str:
|
|
410
|
+
"""Convert metric to StatsD format."""
|
|
411
|
+
# Build metric name with prefix
|
|
412
|
+
name_parts = [self.prefix] if self.prefix else []
|
|
413
|
+
name_parts.append(metric.name.replace(".", "_").replace(" ", "_"))
|
|
414
|
+
|
|
415
|
+
# Add labels as tags (if supported)
|
|
416
|
+
if metric.labels:
|
|
417
|
+
label_parts = [f"{k}:{v}" for k, v in metric.labels.items()]
|
|
418
|
+
name_parts.extend(label_parts)
|
|
419
|
+
|
|
420
|
+
metric_name = ".".join(name_parts)
|
|
421
|
+
|
|
422
|
+
# Determine StatsD type
|
|
423
|
+
if metric.metric_type == "counter":
|
|
424
|
+
return f"{metric_name}:{metric.value}|c"
|
|
425
|
+
elif metric.metric_type == "histogram":
|
|
426
|
+
return f"{metric_name}:{metric.value}|h"
|
|
427
|
+
else: # gauge
|
|
428
|
+
return f"{metric_name}:{metric.value}|g"
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
class ExportManager:
|
|
432
|
+
"""Manages multiple metrics exporters."""
|
|
433
|
+
|
|
434
|
+
def __init__(self):
|
|
435
|
+
self.logger = logging.getLogger(__name__)
|
|
436
|
+
self.exporters: dict[str, MetricsExporter] = {}
|
|
437
|
+
self.export_interval = 10 # seconds
|
|
438
|
+
self._running = False
|
|
439
|
+
self._export_thread = None
|
|
440
|
+
self._metrics_queue = []
|
|
441
|
+
self._queue_lock = threading.Lock()
|
|
442
|
+
|
|
443
|
+
def add_exporter(self, exporter: MetricsExporter):
|
|
444
|
+
"""Add a metrics exporter."""
|
|
445
|
+
self.exporters[exporter.name] = exporter
|
|
446
|
+
self.logger.info(f"Added exporter: {exporter.name}")
|
|
447
|
+
|
|
448
|
+
def remove_exporter(self, name: str):
|
|
449
|
+
"""Remove a metrics exporter."""
|
|
450
|
+
self.exporters.pop(name, None)
|
|
451
|
+
self.logger.info(f"Removed exporter: {name}")
|
|
452
|
+
|
|
453
|
+
def queue_metrics(self, metrics: list[ExportedMetric]):
|
|
454
|
+
"""Queue metrics for export."""
|
|
455
|
+
with self._queue_lock:
|
|
456
|
+
self._metrics_queue.extend(metrics)
|
|
457
|
+
|
|
458
|
+
def export_now(
|
|
459
|
+
self, metrics: list[ExportedMetric] | None = None
|
|
460
|
+
) -> dict[str, bool]:
|
|
461
|
+
"""Export metrics immediately."""
|
|
462
|
+
if metrics is None:
|
|
463
|
+
with self._queue_lock:
|
|
464
|
+
metrics = self._metrics_queue.copy()
|
|
465
|
+
self._metrics_queue.clear()
|
|
466
|
+
|
|
467
|
+
results = {}
|
|
468
|
+
for name, exporter in self.exporters.items():
|
|
469
|
+
if exporter.enabled:
|
|
470
|
+
try:
|
|
471
|
+
results[name] = exporter.export(metrics)
|
|
472
|
+
except Exception as e:
|
|
473
|
+
self.logger.error(f"Exporter {name} failed: {e}")
|
|
474
|
+
results[name] = False
|
|
475
|
+
else:
|
|
476
|
+
results[name] = False # Disabled
|
|
477
|
+
|
|
478
|
+
return results
|
|
479
|
+
|
|
480
|
+
def start_background_export(self):
|
|
481
|
+
"""Start background export thread."""
|
|
482
|
+
if self._running:
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
self._running = True
|
|
486
|
+
self._export_thread = threading.Thread(target=self._export_loop, daemon=True)
|
|
487
|
+
self._export_thread.start()
|
|
488
|
+
self.logger.info("Started background metrics export")
|
|
489
|
+
|
|
490
|
+
def stop_background_export(self):
|
|
491
|
+
"""Stop background export thread."""
|
|
492
|
+
self._running = False
|
|
493
|
+
if self._export_thread:
|
|
494
|
+
self._export_thread.join(timeout=5)
|
|
495
|
+
self.logger.info("Stopped background metrics export")
|
|
496
|
+
|
|
497
|
+
def _export_loop(self):
|
|
498
|
+
"""Background export loop."""
|
|
499
|
+
while self._running:
|
|
500
|
+
try:
|
|
501
|
+
time.sleep(self.export_interval)
|
|
502
|
+
|
|
503
|
+
# Get queued metrics
|
|
504
|
+
with self._queue_lock:
|
|
505
|
+
if self._metrics_queue:
|
|
506
|
+
metrics = self._metrics_queue.copy()
|
|
507
|
+
self._metrics_queue.clear()
|
|
508
|
+
|
|
509
|
+
# Export to all enabled exporters
|
|
510
|
+
self.export_now(metrics)
|
|
511
|
+
|
|
512
|
+
except Exception as e:
|
|
513
|
+
self.logger.error(f"Error in export loop: {e}")
|
|
514
|
+
|
|
515
|
+
def get_exporter_stats(self) -> dict[str, dict[str, Any]]:
|
|
516
|
+
"""Get statistics for all exporters."""
|
|
517
|
+
return {name: exporter.get_stats() for name, exporter in self.exporters.items()}
|
|
518
|
+
|
|
519
|
+
def get_summary(self) -> dict[str, Any]:
|
|
520
|
+
"""Get export manager summary."""
|
|
521
|
+
total_exports = sum(e.export_count for e in self.exporters.values())
|
|
522
|
+
total_errors = sum(e.error_count for e in self.exporters.values())
|
|
523
|
+
|
|
524
|
+
with self._queue_lock:
|
|
525
|
+
queue_size = len(self._metrics_queue)
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
"total_exporters": len(self.exporters),
|
|
529
|
+
"enabled_exporters": len([e for e in self.exporters.values() if e.enabled]),
|
|
530
|
+
"total_exports": total_exports,
|
|
531
|
+
"total_errors": total_errors,
|
|
532
|
+
"success_rate": (total_exports - total_errors) / max(1, total_exports),
|
|
533
|
+
"queue_size": queue_size,
|
|
534
|
+
"background_running": self._running,
|
|
535
|
+
"export_interval": self.export_interval,
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# Utility functions for common exporter setups
|
|
540
|
+
def setup_prometheus_exporter(
|
|
541
|
+
gateway_url: str | None = None, job_name: str = "invarlock"
|
|
542
|
+
) -> PrometheusExporter:
|
|
543
|
+
"""Setup Prometheus exporter."""
|
|
544
|
+
return PrometheusExporter(gateway_url=gateway_url, job_name=job_name)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def setup_json_file_exporter(output_file: str) -> JSONExporter:
|
|
548
|
+
"""Setup JSON file exporter."""
|
|
549
|
+
return JSONExporter(output_file=output_file)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def setup_influxdb_exporter(
|
|
553
|
+
url: str, database: str, username: str | None = None, password: str | None = None
|
|
554
|
+
) -> InfluxDBExporter:
|
|
555
|
+
"""Setup InfluxDB exporter."""
|
|
556
|
+
return InfluxDBExporter(
|
|
557
|
+
url=url, database=database, username=username, password=password
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def setup_statsd_exporter(
|
|
562
|
+
host: str = "localhost", port: int = 8125, prefix: str = "invarlock"
|
|
563
|
+
) -> StatsExporter:
|
|
564
|
+
"""Setup StatsD exporter."""
|
|
565
|
+
return StatsExporter(host=host, port=port, prefix=prefix)
|