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.
Files changed (175) hide show
  1. nthlayer_workers/__init__.py +5 -0
  2. nthlayer_workers/cli.py +234 -0
  3. nthlayer_workers/correlate/__init__.py +1 -0
  4. nthlayer_workers/correlate/cli.py +847 -0
  5. nthlayer_workers/correlate/config.py +111 -0
  6. nthlayer_workers/correlate/correlation/__init__.py +1 -0
  7. nthlayer_workers/correlate/correlation/changes.py +87 -0
  8. nthlayer_workers/correlate/correlation/dedup.py +62 -0
  9. nthlayer_workers/correlate/correlation/engine.py +244 -0
  10. nthlayer_workers/correlate/correlation/temporal.py +79 -0
  11. nthlayer_workers/correlate/correlation/topology.py +104 -0
  12. nthlayer_workers/correlate/ingestion/__init__.py +1 -0
  13. nthlayer_workers/correlate/ingestion/protocol.py +10 -0
  14. nthlayer_workers/correlate/ingestion/severity.py +18 -0
  15. nthlayer_workers/correlate/ingestion/webhook.py +197 -0
  16. nthlayer_workers/correlate/notifications.py +85 -0
  17. nthlayer_workers/correlate/prometheus.py +234 -0
  18. nthlayer_workers/correlate/reasoning.py +375 -0
  19. nthlayer_workers/correlate/session.py +189 -0
  20. nthlayer_workers/correlate/snapshot/__init__.py +1 -0
  21. nthlayer_workers/correlate/snapshot/generator.py +170 -0
  22. nthlayer_workers/correlate/snapshot/model.py +177 -0
  23. nthlayer_workers/correlate/snapshot/token.py +14 -0
  24. nthlayer_workers/correlate/state.py +88 -0
  25. nthlayer_workers/correlate/store/__init__.py +5 -0
  26. nthlayer_workers/correlate/store/protocol.py +48 -0
  27. nthlayer_workers/correlate/store/sqlite.py +443 -0
  28. nthlayer_workers/correlate/summary.py +180 -0
  29. nthlayer_workers/correlate/traces/__init__.py +1 -0
  30. nthlayer_workers/correlate/traces/protocol.py +120 -0
  31. nthlayer_workers/correlate/traces/tempo.py +667 -0
  32. nthlayer_workers/correlate/traces/topology.py +39 -0
  33. nthlayer_workers/correlate/types.py +77 -0
  34. nthlayer_workers/correlate/worker.py +630 -0
  35. nthlayer_workers/learn/__init__.py +5 -0
  36. nthlayer_workers/learn/__main__.py +5 -0
  37. nthlayer_workers/learn/cli.py +164 -0
  38. nthlayer_workers/learn/retrospective.py +381 -0
  39. nthlayer_workers/learn/trends.py +102 -0
  40. nthlayer_workers/learn/worker.py +366 -0
  41. nthlayer_workers/measure/__init__.py +3 -0
  42. nthlayer_workers/measure/__main__.py +5 -0
  43. nthlayer_workers/measure/_parsing.py +15 -0
  44. nthlayer_workers/measure/adapters/__init__.py +0 -0
  45. nthlayer_workers/measure/adapters/_util.py +24 -0
  46. nthlayer_workers/measure/adapters/devin.py +119 -0
  47. nthlayer_workers/measure/adapters/gastown.py +88 -0
  48. nthlayer_workers/measure/adapters/prometheus.py +277 -0
  49. nthlayer_workers/measure/adapters/protocol.py +20 -0
  50. nthlayer_workers/measure/adapters/webhook.py +161 -0
  51. nthlayer_workers/measure/api/__init__.py +0 -0
  52. nthlayer_workers/measure/api/normalise.py +50 -0
  53. nthlayer_workers/measure/api/queue.py +243 -0
  54. nthlayer_workers/measure/api/response.py +51 -0
  55. nthlayer_workers/measure/api/server.py +504 -0
  56. nthlayer_workers/measure/calibration/__init__.py +0 -0
  57. nthlayer_workers/measure/calibration/loop.py +62 -0
  58. nthlayer_workers/measure/calibration/slos.py +212 -0
  59. nthlayer_workers/measure/calibration/verdict_calibration.py +31 -0
  60. nthlayer_workers/measure/cli.py +753 -0
  61. nthlayer_workers/measure/config.py +191 -0
  62. nthlayer_workers/measure/detection/__init__.py +6 -0
  63. nthlayer_workers/measure/detection/detector.py +82 -0
  64. nthlayer_workers/measure/detection/protocol.py +29 -0
  65. nthlayer_workers/measure/governance/__init__.py +0 -0
  66. nthlayer_workers/measure/governance/engine.py +163 -0
  67. nthlayer_workers/measure/manifest.py +77 -0
  68. nthlayer_workers/measure/notifications.py +53 -0
  69. nthlayer_workers/measure/pipeline/__init__.py +0 -0
  70. nthlayer_workers/measure/pipeline/evaluator.py +155 -0
  71. nthlayer_workers/measure/pipeline/router.py +160 -0
  72. nthlayer_workers/measure/store/__init__.py +0 -0
  73. nthlayer_workers/measure/store/protocol.py +38 -0
  74. nthlayer_workers/measure/store/sqlite.py +276 -0
  75. nthlayer_workers/measure/telemetry.py +116 -0
  76. nthlayer_workers/measure/tiering/__init__.py +0 -0
  77. nthlayer_workers/measure/tiering/classifier.py +58 -0
  78. nthlayer_workers/measure/tiering/promotion.py +118 -0
  79. nthlayer_workers/measure/trends/__init__.py +0 -0
  80. nthlayer_workers/measure/trends/tracker.py +72 -0
  81. nthlayer_workers/measure/types.py +75 -0
  82. nthlayer_workers/measure/worker.py +439 -0
  83. nthlayer_workers/observe/__init__.py +25 -0
  84. nthlayer_workers/observe/__main__.py +5 -0
  85. nthlayer_workers/observe/api/__init__.py +1 -0
  86. nthlayer_workers/observe/assessment.py +95 -0
  87. nthlayer_workers/observe/cli.py +737 -0
  88. nthlayer_workers/observe/config.py +11 -0
  89. nthlayer_workers/observe/db/__init__.py +1 -0
  90. nthlayer_workers/observe/decision_records.py +220 -0
  91. nthlayer_workers/observe/dependencies/__init__.py +18 -0
  92. nthlayer_workers/observe/dependencies/discovery.py +294 -0
  93. nthlayer_workers/observe/dependencies/providers/__init__.py +48 -0
  94. nthlayer_workers/observe/dependencies/providers/backstage.py +467 -0
  95. nthlayer_workers/observe/dependencies/providers/base.py +76 -0
  96. nthlayer_workers/observe/dependencies/providers/consul.py +518 -0
  97. nthlayer_workers/observe/dependencies/providers/etcd.py +360 -0
  98. nthlayer_workers/observe/dependencies/providers/kubernetes.py +682 -0
  99. nthlayer_workers/observe/dependencies/providers/prometheus.py +368 -0
  100. nthlayer_workers/observe/dependencies/providers/zookeeper.py +399 -0
  101. nthlayer_workers/observe/deployments/__init__.py +1 -0
  102. nthlayer_workers/observe/discovery/__init__.py +14 -0
  103. nthlayer_workers/observe/discovery/classifier.py +66 -0
  104. nthlayer_workers/observe/discovery/client.py +189 -0
  105. nthlayer_workers/observe/discovery/models.py +53 -0
  106. nthlayer_workers/observe/drift/__init__.py +26 -0
  107. nthlayer_workers/observe/drift/analyzer.py +383 -0
  108. nthlayer_workers/observe/drift/models.py +174 -0
  109. nthlayer_workers/observe/drift/patterns.py +88 -0
  110. nthlayer_workers/observe/explanation.py +118 -0
  111. nthlayer_workers/observe/gate/__init__.py +39 -0
  112. nthlayer_workers/observe/gate/conditions.py +92 -0
  113. nthlayer_workers/observe/gate/correlator.py +154 -0
  114. nthlayer_workers/observe/gate/evaluator.py +192 -0
  115. nthlayer_workers/observe/gate/policies.py +226 -0
  116. nthlayer_workers/observe/gate_adapter.py +40 -0
  117. nthlayer_workers/observe/incident.py +36 -0
  118. nthlayer_workers/observe/portfolio/__init__.py +17 -0
  119. nthlayer_workers/observe/portfolio/aggregator.py +168 -0
  120. nthlayer_workers/observe/portfolio/scorer.py +13 -0
  121. nthlayer_workers/observe/slo/__init__.py +19 -0
  122. nthlayer_workers/observe/slo/collector.py +235 -0
  123. nthlayer_workers/observe/slo/spec_loader.py +40 -0
  124. nthlayer_workers/observe/sqlite_store.py +152 -0
  125. nthlayer_workers/observe/store.py +92 -0
  126. nthlayer_workers/observe/verification/__init__.py +22 -0
  127. nthlayer_workers/observe/verification/exporter_guidance.py +146 -0
  128. nthlayer_workers/observe/verification/extractor.py +127 -0
  129. nthlayer_workers/observe/verification/models.py +101 -0
  130. nthlayer_workers/observe/verification/verifier.py +111 -0
  131. nthlayer_workers/observe/worker.py +332 -0
  132. nthlayer_workers/respond/__init__.py +2 -0
  133. nthlayer_workers/respond/__main__.py +4 -0
  134. nthlayer_workers/respond/agents/__init__.py +0 -0
  135. nthlayer_workers/respond/agents/base.py +556 -0
  136. nthlayer_workers/respond/agents/communication.py +115 -0
  137. nthlayer_workers/respond/agents/investigation.py +124 -0
  138. nthlayer_workers/respond/agents/remediation.py +219 -0
  139. nthlayer_workers/respond/agents/triage.py +132 -0
  140. nthlayer_workers/respond/cli.py +772 -0
  141. nthlayer_workers/respond/config.py +135 -0
  142. nthlayer_workers/respond/context_store.py +256 -0
  143. nthlayer_workers/respond/coordinator.py +487 -0
  144. nthlayer_workers/respond/metrics.py +104 -0
  145. nthlayer_workers/respond/notification_backends/__init__.py +1 -0
  146. nthlayer_workers/respond/notification_backends/ntfy_backend.py +158 -0
  147. nthlayer_workers/respond/notification_backends/protocol.py +59 -0
  148. nthlayer_workers/respond/notification_backends/slack_backend.py +203 -0
  149. nthlayer_workers/respond/notification_backends/stdout_backend.py +56 -0
  150. nthlayer_workers/respond/notifications.py +247 -0
  151. nthlayer_workers/respond/oncall/__init__.py +1 -0
  152. nthlayer_workers/respond/oncall/escalation.py +103 -0
  153. nthlayer_workers/respond/oncall/runner.py +193 -0
  154. nthlayer_workers/respond/oncall/schedule.py +243 -0
  155. nthlayer_workers/respond/safe_actions/__init__.py +0 -0
  156. nthlayer_workers/respond/safe_actions/actions.py +139 -0
  157. nthlayer_workers/respond/safe_actions/registry.py +171 -0
  158. nthlayer_workers/respond/safe_actions/webhook.py +194 -0
  159. nthlayer_workers/respond/server.py +357 -0
  160. nthlayer_workers/respond/sre/__init__.py +1 -0
  161. nthlayer_workers/respond/sre/brief.py +175 -0
  162. nthlayer_workers/respond/sre/delegation.py +101 -0
  163. nthlayer_workers/respond/sre/post_incident.py +146 -0
  164. nthlayer_workers/respond/sre/shift_report.py +129 -0
  165. nthlayer_workers/respond/sre/suppression.py +91 -0
  166. nthlayer_workers/respond/types.py +109 -0
  167. nthlayer_workers/respond/verdict_submission.py +56 -0
  168. nthlayer_workers/respond/worker.py +533 -0
  169. nthlayer_workers/respond/worker_helpers.py +140 -0
  170. nthlayer_workers/runner.py +198 -0
  171. nthlayer_workers-1.0.0.dist-info/METADATA +19 -0
  172. nthlayer_workers-1.0.0.dist-info/RECORD +175 -0
  173. nthlayer_workers-1.0.0.dist-info/WHEEL +5 -0
  174. nthlayer_workers-1.0.0.dist-info/entry_points.txt +2 -0
  175. 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"