nthlayer-workers 1.0.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.
- nthlayer_workers/__init__.py +5 -0
- nthlayer_workers/cli.py +234 -0
- nthlayer_workers/correlate/__init__.py +1 -0
- nthlayer_workers/correlate/cli.py +847 -0
- nthlayer_workers/correlate/config.py +111 -0
- nthlayer_workers/correlate/correlation/__init__.py +1 -0
- nthlayer_workers/correlate/correlation/changes.py +87 -0
- nthlayer_workers/correlate/correlation/dedup.py +62 -0
- nthlayer_workers/correlate/correlation/engine.py +244 -0
- nthlayer_workers/correlate/correlation/temporal.py +79 -0
- nthlayer_workers/correlate/correlation/topology.py +104 -0
- nthlayer_workers/correlate/ingestion/__init__.py +1 -0
- nthlayer_workers/correlate/ingestion/protocol.py +10 -0
- nthlayer_workers/correlate/ingestion/severity.py +18 -0
- nthlayer_workers/correlate/ingestion/webhook.py +197 -0
- nthlayer_workers/correlate/notifications.py +85 -0
- nthlayer_workers/correlate/prometheus.py +234 -0
- nthlayer_workers/correlate/reasoning.py +375 -0
- nthlayer_workers/correlate/session.py +189 -0
- nthlayer_workers/correlate/snapshot/__init__.py +1 -0
- nthlayer_workers/correlate/snapshot/generator.py +170 -0
- nthlayer_workers/correlate/snapshot/model.py +177 -0
- nthlayer_workers/correlate/snapshot/token.py +14 -0
- nthlayer_workers/correlate/state.py +88 -0
- nthlayer_workers/correlate/store/__init__.py +5 -0
- nthlayer_workers/correlate/store/protocol.py +48 -0
- nthlayer_workers/correlate/store/sqlite.py +443 -0
- nthlayer_workers/correlate/summary.py +180 -0
- nthlayer_workers/correlate/traces/__init__.py +1 -0
- nthlayer_workers/correlate/traces/protocol.py +120 -0
- nthlayer_workers/correlate/traces/tempo.py +667 -0
- nthlayer_workers/correlate/traces/topology.py +39 -0
- nthlayer_workers/correlate/types.py +77 -0
- nthlayer_workers/correlate/worker.py +630 -0
- nthlayer_workers/learn/__init__.py +5 -0
- nthlayer_workers/learn/__main__.py +5 -0
- nthlayer_workers/learn/cli.py +164 -0
- nthlayer_workers/learn/retrospective.py +381 -0
- nthlayer_workers/learn/trends.py +102 -0
- nthlayer_workers/learn/worker.py +366 -0
- nthlayer_workers/measure/__init__.py +3 -0
- nthlayer_workers/measure/__main__.py +5 -0
- nthlayer_workers/measure/_parsing.py +15 -0
- nthlayer_workers/measure/adapters/__init__.py +0 -0
- nthlayer_workers/measure/adapters/_util.py +24 -0
- nthlayer_workers/measure/adapters/devin.py +119 -0
- nthlayer_workers/measure/adapters/gastown.py +88 -0
- nthlayer_workers/measure/adapters/prometheus.py +277 -0
- nthlayer_workers/measure/adapters/protocol.py +20 -0
- nthlayer_workers/measure/adapters/webhook.py +161 -0
- nthlayer_workers/measure/api/__init__.py +0 -0
- nthlayer_workers/measure/api/normalise.py +50 -0
- nthlayer_workers/measure/api/queue.py +243 -0
- nthlayer_workers/measure/api/response.py +51 -0
- nthlayer_workers/measure/api/server.py +504 -0
- nthlayer_workers/measure/calibration/__init__.py +0 -0
- nthlayer_workers/measure/calibration/loop.py +62 -0
- nthlayer_workers/measure/calibration/slos.py +212 -0
- nthlayer_workers/measure/calibration/verdict_calibration.py +31 -0
- nthlayer_workers/measure/cli.py +753 -0
- nthlayer_workers/measure/config.py +191 -0
- nthlayer_workers/measure/detection/__init__.py +6 -0
- nthlayer_workers/measure/detection/detector.py +82 -0
- nthlayer_workers/measure/detection/protocol.py +29 -0
- nthlayer_workers/measure/governance/__init__.py +0 -0
- nthlayer_workers/measure/governance/engine.py +163 -0
- nthlayer_workers/measure/manifest.py +77 -0
- nthlayer_workers/measure/notifications.py +53 -0
- nthlayer_workers/measure/pipeline/__init__.py +0 -0
- nthlayer_workers/measure/pipeline/evaluator.py +155 -0
- nthlayer_workers/measure/pipeline/router.py +160 -0
- nthlayer_workers/measure/store/__init__.py +0 -0
- nthlayer_workers/measure/store/protocol.py +38 -0
- nthlayer_workers/measure/store/sqlite.py +276 -0
- nthlayer_workers/measure/telemetry.py +116 -0
- nthlayer_workers/measure/tiering/__init__.py +0 -0
- nthlayer_workers/measure/tiering/classifier.py +58 -0
- nthlayer_workers/measure/tiering/promotion.py +118 -0
- nthlayer_workers/measure/trends/__init__.py +0 -0
- nthlayer_workers/measure/trends/tracker.py +72 -0
- nthlayer_workers/measure/types.py +75 -0
- nthlayer_workers/measure/worker.py +439 -0
- nthlayer_workers/observe/__init__.py +25 -0
- nthlayer_workers/observe/__main__.py +5 -0
- nthlayer_workers/observe/api/__init__.py +1 -0
- nthlayer_workers/observe/assessment.py +95 -0
- nthlayer_workers/observe/cli.py +737 -0
- nthlayer_workers/observe/config.py +11 -0
- nthlayer_workers/observe/db/__init__.py +1 -0
- nthlayer_workers/observe/decision_records.py +220 -0
- nthlayer_workers/observe/dependencies/__init__.py +18 -0
- nthlayer_workers/observe/dependencies/discovery.py +294 -0
- nthlayer_workers/observe/dependencies/providers/__init__.py +48 -0
- nthlayer_workers/observe/dependencies/providers/backstage.py +467 -0
- nthlayer_workers/observe/dependencies/providers/base.py +76 -0
- nthlayer_workers/observe/dependencies/providers/consul.py +518 -0
- nthlayer_workers/observe/dependencies/providers/etcd.py +360 -0
- nthlayer_workers/observe/dependencies/providers/kubernetes.py +682 -0
- nthlayer_workers/observe/dependencies/providers/prometheus.py +368 -0
- nthlayer_workers/observe/dependencies/providers/zookeeper.py +399 -0
- nthlayer_workers/observe/deployments/__init__.py +1 -0
- nthlayer_workers/observe/discovery/__init__.py +14 -0
- nthlayer_workers/observe/discovery/classifier.py +66 -0
- nthlayer_workers/observe/discovery/client.py +189 -0
- nthlayer_workers/observe/discovery/models.py +53 -0
- nthlayer_workers/observe/drift/__init__.py +26 -0
- nthlayer_workers/observe/drift/analyzer.py +383 -0
- nthlayer_workers/observe/drift/models.py +174 -0
- nthlayer_workers/observe/drift/patterns.py +88 -0
- nthlayer_workers/observe/explanation.py +118 -0
- nthlayer_workers/observe/gate/__init__.py +39 -0
- nthlayer_workers/observe/gate/conditions.py +92 -0
- nthlayer_workers/observe/gate/correlator.py +154 -0
- nthlayer_workers/observe/gate/evaluator.py +192 -0
- nthlayer_workers/observe/gate/policies.py +226 -0
- nthlayer_workers/observe/gate_adapter.py +40 -0
- nthlayer_workers/observe/incident.py +36 -0
- nthlayer_workers/observe/portfolio/__init__.py +17 -0
- nthlayer_workers/observe/portfolio/aggregator.py +168 -0
- nthlayer_workers/observe/portfolio/scorer.py +13 -0
- nthlayer_workers/observe/slo/__init__.py +19 -0
- nthlayer_workers/observe/slo/collector.py +235 -0
- nthlayer_workers/observe/slo/spec_loader.py +40 -0
- nthlayer_workers/observe/sqlite_store.py +152 -0
- nthlayer_workers/observe/store.py +92 -0
- nthlayer_workers/observe/verification/__init__.py +22 -0
- nthlayer_workers/observe/verification/exporter_guidance.py +146 -0
- nthlayer_workers/observe/verification/extractor.py +127 -0
- nthlayer_workers/observe/verification/models.py +101 -0
- nthlayer_workers/observe/verification/verifier.py +111 -0
- nthlayer_workers/observe/worker.py +332 -0
- nthlayer_workers/respond/__init__.py +2 -0
- nthlayer_workers/respond/__main__.py +4 -0
- nthlayer_workers/respond/agents/__init__.py +0 -0
- nthlayer_workers/respond/agents/base.py +556 -0
- nthlayer_workers/respond/agents/communication.py +115 -0
- nthlayer_workers/respond/agents/investigation.py +124 -0
- nthlayer_workers/respond/agents/remediation.py +219 -0
- nthlayer_workers/respond/agents/triage.py +132 -0
- nthlayer_workers/respond/cli.py +772 -0
- nthlayer_workers/respond/config.py +135 -0
- nthlayer_workers/respond/context_store.py +256 -0
- nthlayer_workers/respond/coordinator.py +487 -0
- nthlayer_workers/respond/metrics.py +104 -0
- nthlayer_workers/respond/notification_backends/__init__.py +1 -0
- nthlayer_workers/respond/notification_backends/ntfy_backend.py +158 -0
- nthlayer_workers/respond/notification_backends/protocol.py +59 -0
- nthlayer_workers/respond/notification_backends/slack_backend.py +203 -0
- nthlayer_workers/respond/notification_backends/stdout_backend.py +56 -0
- nthlayer_workers/respond/notifications.py +247 -0
- nthlayer_workers/respond/oncall/__init__.py +1 -0
- nthlayer_workers/respond/oncall/escalation.py +103 -0
- nthlayer_workers/respond/oncall/runner.py +193 -0
- nthlayer_workers/respond/oncall/schedule.py +243 -0
- nthlayer_workers/respond/safe_actions/__init__.py +0 -0
- nthlayer_workers/respond/safe_actions/actions.py +139 -0
- nthlayer_workers/respond/safe_actions/registry.py +171 -0
- nthlayer_workers/respond/safe_actions/webhook.py +194 -0
- nthlayer_workers/respond/server.py +357 -0
- nthlayer_workers/respond/sre/__init__.py +1 -0
- nthlayer_workers/respond/sre/brief.py +175 -0
- nthlayer_workers/respond/sre/delegation.py +101 -0
- nthlayer_workers/respond/sre/post_incident.py +146 -0
- nthlayer_workers/respond/sre/shift_report.py +129 -0
- nthlayer_workers/respond/sre/suppression.py +91 -0
- nthlayer_workers/respond/types.py +109 -0
- nthlayer_workers/respond/verdict_submission.py +56 -0
- nthlayer_workers/respond/worker.py +533 -0
- nthlayer_workers/respond/worker_helpers.py +140 -0
- nthlayer_workers/runner.py +198 -0
- nthlayer_workers-1.0.0.dist-info/METADATA +19 -0
- nthlayer_workers-1.0.0.dist-info/RECORD +175 -0
- nthlayer_workers-1.0.0.dist-info/WHEEL +5 -0
- nthlayer_workers-1.0.0.dist-info/entry_points.txt +2 -0
- nthlayer_workers-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Zookeeper dependency provider.
|
|
3
|
+
|
|
4
|
+
Discovers service dependencies from Zookeeper-based service registries.
|
|
5
|
+
|
|
6
|
+
Supports:
|
|
7
|
+
- Curator-style service discovery (0.90 confidence)
|
|
8
|
+
- Service ZNode metadata with dependencies (0.85 confidence)
|
|
9
|
+
- Connection strings and endpoints (0.75 confidence)
|
|
10
|
+
|
|
11
|
+
Environment variables:
|
|
12
|
+
- NTHLAYER_ZOOKEEPER_HOSTS: Zookeeper connection string
|
|
13
|
+
- NTHLAYER_ZOOKEEPER_ROOT: Service registry root path
|
|
14
|
+
|
|
15
|
+
Requires optional dependency: kazoo
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from nthlayer_common.dependency_models import (
|
|
25
|
+
DependencyType,
|
|
26
|
+
DiscoveredDependency,
|
|
27
|
+
)
|
|
28
|
+
from nthlayer_common.errors import ProviderError
|
|
29
|
+
from nthlayer_workers.observe.dependencies.providers.base import (
|
|
30
|
+
BaseDepProvider,
|
|
31
|
+
ProviderHealth,
|
|
32
|
+
deduplicate_dependencies,
|
|
33
|
+
infer_dependency_type,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Optional kazoo import
|
|
37
|
+
try:
|
|
38
|
+
from kazoo.client import KazooClient, KazooState
|
|
39
|
+
from kazoo.exceptions import NoNodeError, ZookeeperError
|
|
40
|
+
|
|
41
|
+
KAZOO_AVAILABLE = True
|
|
42
|
+
except ImportError:
|
|
43
|
+
KAZOO_AVAILABLE = False
|
|
44
|
+
KazooClient = None # type: ignore
|
|
45
|
+
KazooState = None # type: ignore
|
|
46
|
+
NoNodeError = Exception # type: ignore
|
|
47
|
+
ZookeeperError = Exception # type: ignore
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ZookeeperDepProviderError(ProviderError):
|
|
51
|
+
"""Zookeeper provider error."""
|
|
52
|
+
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class ZookeeperDepProvider(BaseDepProvider):
|
|
58
|
+
"""
|
|
59
|
+
Dependency provider that discovers dependencies from Zookeeper.
|
|
60
|
+
|
|
61
|
+
Queries Zookeeper znodes to find:
|
|
62
|
+
- Registered services under the root path
|
|
63
|
+
- Service instance metadata (Curator format)
|
|
64
|
+
- Explicit dependency declarations in service data
|
|
65
|
+
|
|
66
|
+
Supports Curator-style service discovery format:
|
|
67
|
+
/services/<service-name>/instances/<instance-id>
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
hosts: Zookeeper connection string (e.g., localhost:2181)
|
|
71
|
+
root_path: Service registry root path (default: /services)
|
|
72
|
+
timeout: Connection timeout in seconds
|
|
73
|
+
auth: Optional tuple of (scheme, credential) for auth
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
hosts: str = "localhost:2181"
|
|
77
|
+
root_path: str = "/services"
|
|
78
|
+
timeout: float = 30.0
|
|
79
|
+
auth: tuple[str, str] | None = None
|
|
80
|
+
|
|
81
|
+
# Private fields
|
|
82
|
+
_client: Any = field(default=None, repr=False)
|
|
83
|
+
_initialized: bool = field(default=False, repr=False, compare=False)
|
|
84
|
+
|
|
85
|
+
def __post_init__(self) -> None:
|
|
86
|
+
"""Validate kazoo is available."""
|
|
87
|
+
if not KAZOO_AVAILABLE:
|
|
88
|
+
raise ZookeeperDepProviderError(
|
|
89
|
+
"kazoo library is required for Zookeeper provider. "
|
|
90
|
+
"Install with: pip install kazoo"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def name(self) -> str:
|
|
95
|
+
"""Provider name."""
|
|
96
|
+
return "zookeeper"
|
|
97
|
+
|
|
98
|
+
def _ensure_initialized(self) -> None:
|
|
99
|
+
"""Initialize Zookeeper client if not already done."""
|
|
100
|
+
if self._initialized:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
if not KAZOO_AVAILABLE:
|
|
104
|
+
raise ZookeeperDepProviderError("kazoo library not available")
|
|
105
|
+
|
|
106
|
+
self._client = KazooClient(
|
|
107
|
+
hosts=self.hosts,
|
|
108
|
+
timeout=self.timeout,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if self.auth:
|
|
112
|
+
self._client.add_auth(self.auth[0], self.auth[1])
|
|
113
|
+
|
|
114
|
+
self._client.start(timeout=self.timeout)
|
|
115
|
+
self._initialized = True
|
|
116
|
+
|
|
117
|
+
async def _close(self) -> None:
|
|
118
|
+
"""Close Zookeeper client."""
|
|
119
|
+
if self._client:
|
|
120
|
+
self._client.stop()
|
|
121
|
+
self._client.close()
|
|
122
|
+
self._client = None
|
|
123
|
+
self._initialized = False
|
|
124
|
+
|
|
125
|
+
def _get_service_path(self, service: str) -> str:
|
|
126
|
+
"""Get the ZNode path for a service."""
|
|
127
|
+
root = self.root_path.rstrip("/")
|
|
128
|
+
return f"{root}/{service}"
|
|
129
|
+
|
|
130
|
+
def _get_instances_path(self, service: str) -> str:
|
|
131
|
+
"""Get the instances path for a service."""
|
|
132
|
+
return f"{self._get_service_path(service)}/instances"
|
|
133
|
+
|
|
134
|
+
def _parse_curator_instance(self, data: bytes) -> dict[str, Any]:
|
|
135
|
+
"""
|
|
136
|
+
Parse Curator ServiceInstance JSON format.
|
|
137
|
+
|
|
138
|
+
Expected format:
|
|
139
|
+
{
|
|
140
|
+
"name": "service-name",
|
|
141
|
+
"id": "instance-id",
|
|
142
|
+
"address": "10.0.0.1",
|
|
143
|
+
"port": 8080,
|
|
144
|
+
"payload": {
|
|
145
|
+
"dependencies": ["dep1", "dep2"],
|
|
146
|
+
"metadata": {...}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
data: Raw ZNode data
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Parsed instance dictionary
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
if not data:
|
|
158
|
+
return {}
|
|
159
|
+
return json.loads(data.decode("utf-8"))
|
|
160
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
161
|
+
return {}
|
|
162
|
+
|
|
163
|
+
def _parse_dependencies_from_payload(
|
|
164
|
+
self, payload: dict[str, Any]
|
|
165
|
+
) -> list[tuple[str, DependencyType]]:
|
|
166
|
+
"""
|
|
167
|
+
Parse dependencies from Curator payload.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
payload: Instance payload dictionary
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
List of (target_name, dep_type) tuples
|
|
174
|
+
"""
|
|
175
|
+
deps: list[tuple[str, DependencyType]] = []
|
|
176
|
+
|
|
177
|
+
# Check for explicit dependencies list
|
|
178
|
+
dependencies = payload.get("dependencies", [])
|
|
179
|
+
if isinstance(dependencies, str):
|
|
180
|
+
dependencies = [d.strip() for d in dependencies.split(",")]
|
|
181
|
+
|
|
182
|
+
for dep in dependencies:
|
|
183
|
+
if dep:
|
|
184
|
+
deps.append((dep, infer_dependency_type(dep)))
|
|
185
|
+
|
|
186
|
+
# Check for typed dependencies
|
|
187
|
+
for dep_type, key in [
|
|
188
|
+
(DependencyType.DATASTORE, "databases"),
|
|
189
|
+
(DependencyType.QUEUE, "queues"),
|
|
190
|
+
(DependencyType.EXTERNAL, "external"),
|
|
191
|
+
]:
|
|
192
|
+
type_deps = payload.get(key, [])
|
|
193
|
+
if isinstance(type_deps, str):
|
|
194
|
+
type_deps = [d.strip() for d in type_deps.split(",")]
|
|
195
|
+
for dep in type_deps:
|
|
196
|
+
if dep:
|
|
197
|
+
deps.append((dep, dep_type))
|
|
198
|
+
|
|
199
|
+
return deps
|
|
200
|
+
|
|
201
|
+
async def discover(self, service: str) -> list[DiscoveredDependency]:
|
|
202
|
+
"""
|
|
203
|
+
Discover upstream dependencies for a service.
|
|
204
|
+
|
|
205
|
+
Reads service instances from Zookeeper and extracts dependencies
|
|
206
|
+
from the Curator-style payload.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
service: Service name to find dependencies for
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
List of discovered dependencies
|
|
213
|
+
"""
|
|
214
|
+
self._ensure_initialized()
|
|
215
|
+
deps: list[DiscoveredDependency] = []
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
# Check if service exists
|
|
219
|
+
service_path = self._get_service_path(service)
|
|
220
|
+
if not self._client.exists(service_path):
|
|
221
|
+
return deps
|
|
222
|
+
|
|
223
|
+
# Try to read service-level metadata first
|
|
224
|
+
data, _ = self._client.get(service_path)
|
|
225
|
+
if data:
|
|
226
|
+
service_data = self._parse_curator_instance(data)
|
|
227
|
+
payload = service_data.get("payload", service_data)
|
|
228
|
+
payload_deps = self._parse_dependencies_from_payload(payload)
|
|
229
|
+
for target, dep_type in payload_deps:
|
|
230
|
+
deps.append(
|
|
231
|
+
DiscoveredDependency(
|
|
232
|
+
source_service=service,
|
|
233
|
+
target_service=target,
|
|
234
|
+
provider=self.name,
|
|
235
|
+
dep_type=dep_type,
|
|
236
|
+
confidence=0.85,
|
|
237
|
+
metadata={
|
|
238
|
+
"source": "service_znode",
|
|
239
|
+
"path": service_path,
|
|
240
|
+
},
|
|
241
|
+
raw_source=service,
|
|
242
|
+
raw_target=target,
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Try to read instance-level metadata
|
|
247
|
+
instances_path = self._get_instances_path(service)
|
|
248
|
+
if self._client.exists(instances_path):
|
|
249
|
+
instances = self._client.get_children(instances_path)
|
|
250
|
+
for instance_id in instances:
|
|
251
|
+
instance_path = f"{instances_path}/{instance_id}"
|
|
252
|
+
data, _ = self._client.get(instance_path)
|
|
253
|
+
instance_data = self._parse_curator_instance(data)
|
|
254
|
+
|
|
255
|
+
# Extract dependencies from payload
|
|
256
|
+
payload = instance_data.get("payload", {})
|
|
257
|
+
payload_deps = self._parse_dependencies_from_payload(payload)
|
|
258
|
+
|
|
259
|
+
for target, dep_type in payload_deps:
|
|
260
|
+
deps.append(
|
|
261
|
+
DiscoveredDependency(
|
|
262
|
+
source_service=service,
|
|
263
|
+
target_service=target,
|
|
264
|
+
provider=self.name,
|
|
265
|
+
dep_type=dep_type,
|
|
266
|
+
confidence=0.90,
|
|
267
|
+
metadata={
|
|
268
|
+
"source": "curator_instance",
|
|
269
|
+
"instance_id": instance_id,
|
|
270
|
+
"path": instance_path,
|
|
271
|
+
},
|
|
272
|
+
raw_source=service,
|
|
273
|
+
raw_target=target,
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
except NoNodeError:
|
|
278
|
+
pass
|
|
279
|
+
except ZookeeperError as e:
|
|
280
|
+
raise ZookeeperDepProviderError(f"Zookeeper error: {e}") from e
|
|
281
|
+
|
|
282
|
+
return deduplicate_dependencies(deps)
|
|
283
|
+
|
|
284
|
+
async def list_services(self) -> list[str]:
|
|
285
|
+
"""
|
|
286
|
+
List all services in Zookeeper registry.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
List of service names
|
|
290
|
+
"""
|
|
291
|
+
self._ensure_initialized()
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
root = self.root_path.rstrip("/")
|
|
295
|
+
if not self._client.exists(root):
|
|
296
|
+
return []
|
|
297
|
+
|
|
298
|
+
children = self._client.get_children(root)
|
|
299
|
+
# Filter out non-service znodes (like "instances" paths at wrong level)
|
|
300
|
+
services = [
|
|
301
|
+
child for child in children if not child.startswith("_") and child != "instances"
|
|
302
|
+
]
|
|
303
|
+
return sorted(services)
|
|
304
|
+
|
|
305
|
+
except NoNodeError:
|
|
306
|
+
return []
|
|
307
|
+
except ZookeeperError as e:
|
|
308
|
+
raise ZookeeperDepProviderError(f"Zookeeper error: {e}") from e
|
|
309
|
+
|
|
310
|
+
async def health_check(self) -> ProviderHealth:
|
|
311
|
+
"""
|
|
312
|
+
Check Zookeeper connectivity.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
Provider health status
|
|
316
|
+
"""
|
|
317
|
+
if not KAZOO_AVAILABLE:
|
|
318
|
+
return ProviderHealth(
|
|
319
|
+
healthy=False,
|
|
320
|
+
message="kazoo library not installed",
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
self._ensure_initialized()
|
|
325
|
+
|
|
326
|
+
# Check if connected
|
|
327
|
+
state = self._client.state
|
|
328
|
+
if state == KazooState.CONNECTED:
|
|
329
|
+
return ProviderHealth(
|
|
330
|
+
healthy=True,
|
|
331
|
+
message=f"Connected to Zookeeper at {self.hosts}",
|
|
332
|
+
)
|
|
333
|
+
else:
|
|
334
|
+
return ProviderHealth(
|
|
335
|
+
healthy=False,
|
|
336
|
+
message=f"Zookeeper state: {state}",
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
except ZookeeperError as e:
|
|
340
|
+
return ProviderHealth(
|
|
341
|
+
healthy=False,
|
|
342
|
+
message=f"Zookeeper error: {e}",
|
|
343
|
+
)
|
|
344
|
+
except Exception as e:
|
|
345
|
+
return ProviderHealth(
|
|
346
|
+
healthy=False,
|
|
347
|
+
message=f"Connection failed: {e}",
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
async def get_service_attributes(self, service: str) -> dict[str, Any]:
|
|
351
|
+
"""
|
|
352
|
+
Get service attributes from Zookeeper.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
service: Service name
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
Dictionary of attributes
|
|
359
|
+
"""
|
|
360
|
+
self._ensure_initialized()
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
service_path = self._get_service_path(service)
|
|
364
|
+
if not self._client.exists(service_path):
|
|
365
|
+
return {}
|
|
366
|
+
|
|
367
|
+
# Get service znode data
|
|
368
|
+
data, stat = self._client.get(service_path)
|
|
369
|
+
service_data = self._parse_curator_instance(data)
|
|
370
|
+
|
|
371
|
+
# Get instance count
|
|
372
|
+
instances_path = self._get_instances_path(service)
|
|
373
|
+
instance_count = 0
|
|
374
|
+
instances: list[dict[str, Any]] = []
|
|
375
|
+
|
|
376
|
+
if self._client.exists(instances_path):
|
|
377
|
+
instance_ids = self._client.get_children(instances_path)
|
|
378
|
+
instance_count = len(instance_ids)
|
|
379
|
+
|
|
380
|
+
# Get first instance details
|
|
381
|
+
if instance_ids:
|
|
382
|
+
first_instance_path = f"{instances_path}/{instance_ids[0]}"
|
|
383
|
+
instance_data, _ = self._client.get(first_instance_path)
|
|
384
|
+
instances.append(self._parse_curator_instance(instance_data))
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
"name": service_data.get("name", service),
|
|
388
|
+
"path": service_path,
|
|
389
|
+
"instance_count": instance_count,
|
|
390
|
+
"instances": instances,
|
|
391
|
+
"metadata": service_data.get("payload", {}).get("metadata", {}),
|
|
392
|
+
"created": stat.created if stat else None,
|
|
393
|
+
"modified": stat.last_modified if stat else None,
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
except NoNodeError:
|
|
397
|
+
return {}
|
|
398
|
+
except ZookeeperError as e:
|
|
399
|
+
raise ZookeeperDepProviderError(f"Zookeeper error: {e}") from e
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Deployment event handling."""
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Metric and service discovery."""
|
|
2
|
+
|
|
3
|
+
from nthlayer_workers.observe.discovery.classifier import MetricClassifier
|
|
4
|
+
from nthlayer_workers.observe.discovery.client import MetricDiscoveryClient
|
|
5
|
+
from nthlayer_workers.observe.discovery.models import DiscoveredMetric, DiscoveryResult, MetricType, TechnologyGroup
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"MetricDiscoveryClient",
|
|
9
|
+
"MetricClassifier",
|
|
10
|
+
"DiscoveredMetric",
|
|
11
|
+
"DiscoveryResult",
|
|
12
|
+
"MetricType",
|
|
13
|
+
"TechnologyGroup",
|
|
14
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Metric classifier for technology and type detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from nthlayer_workers.observe.discovery.models import DiscoveredMetric, MetricType, TechnologyGroup
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MetricClassifier:
|
|
11
|
+
"""Classifies metrics by technology using pattern matching."""
|
|
12
|
+
|
|
13
|
+
TECHNOLOGY_PATTERNS = [
|
|
14
|
+
(r"^pg_", TechnologyGroup.POSTGRESQL),
|
|
15
|
+
(r"postgres", TechnologyGroup.POSTGRESQL),
|
|
16
|
+
(r"^redis_", TechnologyGroup.REDIS),
|
|
17
|
+
(r"cache_hits", TechnologyGroup.REDIS),
|
|
18
|
+
(r"cache_misses", TechnologyGroup.REDIS),
|
|
19
|
+
(r"^mongodb_", TechnologyGroup.MONGODB),
|
|
20
|
+
(r"^mongo_", TechnologyGroup.MONGODB),
|
|
21
|
+
(r"^kafka_", TechnologyGroup.KAFKA),
|
|
22
|
+
(r"^mysql_", TechnologyGroup.MYSQL),
|
|
23
|
+
(r"^rabbitmq_", TechnologyGroup.RABBITMQ),
|
|
24
|
+
(r"^kube_", TechnologyGroup.KUBERNETES),
|
|
25
|
+
(r"^container_", TechnologyGroup.KUBERNETES),
|
|
26
|
+
(r"_pod_", TechnologyGroup.KUBERNETES),
|
|
27
|
+
(r"^ecs_", TechnologyGroup.KUBERNETES),
|
|
28
|
+
(r"^http_", TechnologyGroup.HTTP),
|
|
29
|
+
(r"_request", TechnologyGroup.HTTP),
|
|
30
|
+
(r"_response", TechnologyGroup.HTTP),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
TYPE_PATTERNS = [
|
|
34
|
+
(r"_total$", MetricType.COUNTER),
|
|
35
|
+
(r"_count$", MetricType.COUNTER),
|
|
36
|
+
(r"_created$", MetricType.COUNTER),
|
|
37
|
+
(r"_bucket$", MetricType.HISTOGRAM),
|
|
38
|
+
(r"_sum$", MetricType.SUMMARY),
|
|
39
|
+
(r"_seconds_", MetricType.HISTOGRAM),
|
|
40
|
+
(r"_bytes", MetricType.GAUGE),
|
|
41
|
+
(r"_ratio$", MetricType.GAUGE),
|
|
42
|
+
(r"_percentage$", MetricType.GAUGE),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
def classify(self, metric: DiscoveredMetric) -> DiscoveredMetric:
|
|
46
|
+
"""Classify a discovered metric by technology and type."""
|
|
47
|
+
metric.technology = self._classify_technology(metric.name)
|
|
48
|
+
|
|
49
|
+
if metric.type == MetricType.UNKNOWN:
|
|
50
|
+
metric.type = self._infer_type(metric.name)
|
|
51
|
+
|
|
52
|
+
return metric
|
|
53
|
+
|
|
54
|
+
def _classify_technology(self, metric_name: str) -> TechnologyGroup:
|
|
55
|
+
metric_lower = metric_name.lower()
|
|
56
|
+
for pattern, technology in self.TECHNOLOGY_PATTERNS:
|
|
57
|
+
if re.search(pattern, metric_lower):
|
|
58
|
+
return technology
|
|
59
|
+
return TechnologyGroup.CUSTOM
|
|
60
|
+
|
|
61
|
+
def _infer_type(self, metric_name: str) -> MetricType:
|
|
62
|
+
metric_lower = metric_name.lower()
|
|
63
|
+
for pattern, metric_type in self.TYPE_PATTERNS:
|
|
64
|
+
if re.search(pattern, metric_lower):
|
|
65
|
+
return metric_type
|
|
66
|
+
return MetricType.GAUGE
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Prometheus metric discovery client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import structlog
|
|
9
|
+
|
|
10
|
+
from nthlayer_workers.observe.discovery.classifier import MetricClassifier
|
|
11
|
+
from nthlayer_workers.observe.discovery.models import DiscoveredMetric, DiscoveryResult, MetricType, TechnologyGroup
|
|
12
|
+
|
|
13
|
+
logger = structlog.get_logger()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MetricDiscoveryClient:
|
|
17
|
+
"""Client for discovering metrics from Prometheus."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
prometheus_url: str,
|
|
22
|
+
username: str | None = None,
|
|
23
|
+
password: str | None = None,
|
|
24
|
+
bearer_token: str | None = None,
|
|
25
|
+
):
|
|
26
|
+
self.prometheus_url = prometheus_url.rstrip("/")
|
|
27
|
+
self.auth = (username, password) if username and password else None
|
|
28
|
+
self.headers = {"Authorization": f"Bearer {bearer_token}"} if bearer_token else {}
|
|
29
|
+
self.classifier = MetricClassifier()
|
|
30
|
+
|
|
31
|
+
def discover(self, selector: str) -> DiscoveryResult:
|
|
32
|
+
"""Discover all metrics matching the given selector."""
|
|
33
|
+
metric_names = self._get_metric_names(selector)
|
|
34
|
+
|
|
35
|
+
metrics = []
|
|
36
|
+
for name in metric_names:
|
|
37
|
+
metric = self._discover_metric(name, selector)
|
|
38
|
+
if metric:
|
|
39
|
+
metrics.append(metric)
|
|
40
|
+
|
|
41
|
+
classified_metrics = [self.classifier.classify(m) for m in metrics]
|
|
42
|
+
|
|
43
|
+
result = DiscoveryResult(
|
|
44
|
+
service=self._extract_service_from_selector(selector),
|
|
45
|
+
total_metrics=len(classified_metrics),
|
|
46
|
+
metrics=classified_metrics,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
for metric in classified_metrics:
|
|
50
|
+
tech = metric.technology.value if isinstance(metric.technology, TechnologyGroup) else metric.technology
|
|
51
|
+
if tech not in result.metrics_by_technology:
|
|
52
|
+
result.metrics_by_technology[tech] = []
|
|
53
|
+
result.metrics_by_technology[tech].append(metric)
|
|
54
|
+
|
|
55
|
+
for metric in classified_metrics:
|
|
56
|
+
mtype = metric.type.value if isinstance(metric.type, MetricType) else metric.type
|
|
57
|
+
if mtype not in result.metrics_by_type:
|
|
58
|
+
result.metrics_by_type[mtype] = []
|
|
59
|
+
result.metrics_by_type[mtype].append(metric)
|
|
60
|
+
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
def _get_metric_names(self, selector: str) -> list[str]:
|
|
64
|
+
"""Query Prometheus for all metric names matching selector."""
|
|
65
|
+
url = f"{self.prometheus_url}/api/v1/series"
|
|
66
|
+
params = {"match[]": selector}
|
|
67
|
+
|
|
68
|
+
if "/metrics" in self.prometheus_url or "fly.dev" in self.prometheus_url:
|
|
69
|
+
return self._get_metrics_from_endpoint(selector)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
response = httpx.get(
|
|
73
|
+
url, params=params, auth=self.auth, headers=self.headers, timeout=30
|
|
74
|
+
)
|
|
75
|
+
response.raise_for_status()
|
|
76
|
+
data = response.json()
|
|
77
|
+
|
|
78
|
+
if data.get("status") != "success":
|
|
79
|
+
return []
|
|
80
|
+
|
|
81
|
+
metric_names = set()
|
|
82
|
+
for series in data.get("data", []):
|
|
83
|
+
if "__name__" in series:
|
|
84
|
+
metric_names.add(series["__name__"])
|
|
85
|
+
|
|
86
|
+
return sorted(metric_names)
|
|
87
|
+
|
|
88
|
+
except Exception as e:
|
|
89
|
+
logger.warning("prometheus_series_query_failed", error=str(e))
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
def _discover_metric(self, metric_name: str, selector: str) -> DiscoveredMetric | None:
|
|
93
|
+
"""Discover detailed information about a specific metric."""
|
|
94
|
+
metadata = self._get_metric_metadata(metric_name)
|
|
95
|
+
labels = self._get_label_values(metric_name, selector)
|
|
96
|
+
|
|
97
|
+
return DiscoveredMetric(
|
|
98
|
+
name=metric_name,
|
|
99
|
+
type=MetricType(metadata.get("type", "unknown")),
|
|
100
|
+
technology=TechnologyGroup.UNKNOWN,
|
|
101
|
+
help_text=metadata.get("help"),
|
|
102
|
+
labels=labels,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _get_metric_metadata(self, metric_name: str) -> dict:
|
|
106
|
+
"""Query Prometheus metadata API for metric type and help text."""
|
|
107
|
+
url = f"{self.prometheus_url}/api/v1/metadata"
|
|
108
|
+
params = {"metric": metric_name}
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
response = httpx.get(
|
|
112
|
+
url, params=params, auth=self.auth, headers=self.headers, timeout=10
|
|
113
|
+
)
|
|
114
|
+
response.raise_for_status()
|
|
115
|
+
data = response.json()
|
|
116
|
+
|
|
117
|
+
if data.get("status") == "success":
|
|
118
|
+
metric_data = data.get("data", {}).get(metric_name, [])
|
|
119
|
+
if metric_data:
|
|
120
|
+
return metric_data[0]
|
|
121
|
+
|
|
122
|
+
return {}
|
|
123
|
+
|
|
124
|
+
except Exception:
|
|
125
|
+
return {}
|
|
126
|
+
|
|
127
|
+
def _get_label_values(self, metric_name: str, selector: str) -> dict[str, list[str]]:
|
|
128
|
+
"""Get all label values for a metric."""
|
|
129
|
+
url = f"{self.prometheus_url}/api/v1/series"
|
|
130
|
+
full_selector = f"{metric_name}{selector}"
|
|
131
|
+
params = {"match[]": full_selector}
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
response = httpx.get(
|
|
135
|
+
url, params=params, auth=self.auth, headers=self.headers, timeout=10
|
|
136
|
+
)
|
|
137
|
+
response.raise_for_status()
|
|
138
|
+
data = response.json()
|
|
139
|
+
|
|
140
|
+
if data.get("status") != "success":
|
|
141
|
+
return {}
|
|
142
|
+
|
|
143
|
+
labels: dict[str, set] = {}
|
|
144
|
+
for series in data.get("data", []):
|
|
145
|
+
for label, value in series.items():
|
|
146
|
+
if label == "__name__":
|
|
147
|
+
continue
|
|
148
|
+
if label not in labels:
|
|
149
|
+
labels[label] = set()
|
|
150
|
+
labels[label].add(value)
|
|
151
|
+
|
|
152
|
+
return {k: sorted(v) for k, v in labels.items()}
|
|
153
|
+
|
|
154
|
+
except Exception:
|
|
155
|
+
return {}
|
|
156
|
+
|
|
157
|
+
def _get_metrics_from_endpoint(self, selector: str) -> list[str]:
|
|
158
|
+
"""Parse /metrics endpoint directly (fallback for non-Prometheus targets)."""
|
|
159
|
+
service = self._extract_service_from_selector(selector)
|
|
160
|
+
url = f"{self.prometheus_url}/metrics"
|
|
161
|
+
filter_by_service = service and service != "unknown"
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
response = httpx.get(url, auth=self.auth, headers=self.headers, timeout=30)
|
|
165
|
+
response.raise_for_status()
|
|
166
|
+
|
|
167
|
+
metric_names = set()
|
|
168
|
+
for line in response.text.split("\n"):
|
|
169
|
+
if not line or line.startswith("#"):
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
if filter_by_service and f'service="{service}"' not in line:
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
if "{" in line:
|
|
176
|
+
metric_names.add(line.split("{")[0])
|
|
177
|
+
elif " " in line:
|
|
178
|
+
metric_names.add(line.split(" ")[0])
|
|
179
|
+
|
|
180
|
+
return sorted(metric_names)
|
|
181
|
+
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.warning("metrics_endpoint_parse_failed", error=str(e))
|
|
184
|
+
return []
|
|
185
|
+
|
|
186
|
+
def _extract_service_from_selector(self, selector: str) -> str:
|
|
187
|
+
"""Extract service name from selector string."""
|
|
188
|
+
match = re.search(r'service="([^"]+)"', selector)
|
|
189
|
+
return match.group(1) if match else "unknown"
|