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,127 @@
1
+ """Extract declared metrics from service resources to form the metric contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from nthlayer_workers.observe.verification.models import DeclaredMetric, MetricContract, MetricSource
10
+
11
+
12
+ @dataclass
13
+ class Resource:
14
+ """Minimal resource representation for metric extraction.
15
+
16
+ Compatible with nthlayer.specs.models.Resource.
17
+ """
18
+
19
+ kind: str
20
+ spec: dict[str, Any]
21
+ name: str | None = None
22
+
23
+
24
+ def extract_metric_contract(
25
+ service_name: str,
26
+ resources: list[Resource],
27
+ ) -> MetricContract:
28
+ """Extract all declared metrics from service resources."""
29
+ contract = MetricContract(service_name=service_name)
30
+
31
+ for resource in resources:
32
+ if resource.kind == "SLO":
33
+ contract.metrics.extend(_extract_slo_metrics(resource))
34
+ elif resource.kind == "Observability":
35
+ contract.metrics.extend(_extract_observability_metrics(resource))
36
+
37
+ # Deduplicate by metric name (keep first occurrence)
38
+ seen: set[str] = set()
39
+ unique_metrics = []
40
+ for metric in contract.metrics:
41
+ if metric.name not in seen:
42
+ seen.add(metric.name)
43
+ unique_metrics.append(metric)
44
+ contract.metrics = unique_metrics
45
+
46
+ return contract
47
+
48
+
49
+ def _extract_slo_metrics(resource: Resource) -> list[DeclaredMetric]:
50
+ """Extract metrics from SLO indicator queries."""
51
+ metrics = []
52
+ spec = resource.spec or {}
53
+ indicators = spec.get("indicators", [])
54
+
55
+ for indicator in indicators:
56
+ success_ratio = indicator.get("success_ratio", {})
57
+ for query_key in ["total_query", "good_query", "error_query"]:
58
+ query = success_ratio.get(query_key, "")
59
+ if query:
60
+ for name in _extract_metrics_from_query(query):
61
+ metrics.append(
62
+ DeclaredMetric(
63
+ name=name,
64
+ source=MetricSource.SLO_INDICATOR,
65
+ query=query,
66
+ resource_name=resource.name,
67
+ )
68
+ )
69
+
70
+ latency_query = indicator.get("latency_query", "")
71
+ if latency_query:
72
+ for name in _extract_metrics_from_query(latency_query):
73
+ metrics.append(
74
+ DeclaredMetric(
75
+ name=name,
76
+ source=MetricSource.SLO_INDICATOR,
77
+ query=latency_query,
78
+ resource_name=resource.name,
79
+ )
80
+ )
81
+
82
+ return metrics
83
+
84
+
85
+ def _extract_observability_metrics(resource: Resource) -> list[DeclaredMetric]:
86
+ """Extract metrics from Observability declarations."""
87
+ metrics = []
88
+ spec = resource.spec or {}
89
+
90
+ for metric_name in spec.get("metrics", []):
91
+ if isinstance(metric_name, str):
92
+ metrics.append(
93
+ DeclaredMetric(
94
+ name=metric_name,
95
+ source=MetricSource.OBSERVABILITY,
96
+ resource_name=resource.name,
97
+ )
98
+ )
99
+
100
+ return metrics
101
+
102
+
103
+ # PromQL functions and keywords to exclude from metric name extraction
104
+ _PROMQL_KEYWORDS = frozenset({
105
+ "sum", "rate", "irate", "increase", "histogram_quantile", "avg", "min", "max",
106
+ "count", "stddev", "stdvar", "topk", "bottomk", "quantile", "count_values",
107
+ "group", "by", "without", "on", "ignoring", "group_left", "group_right",
108
+ "bool", "and", "or", "unless", "offset", "vector", "scalar", "abs", "absent",
109
+ "ceil", "floor", "round", "clamp", "clamp_max", "clamp_min", "day_of_month",
110
+ "day_of_week", "days_in_month", "delta", "deriv", "exp", "hour", "idelta",
111
+ "label_join", "label_replace", "ln", "log2", "log10", "minute", "month",
112
+ "predict_linear", "resets", "sort", "sort_desc", "sqrt", "time", "timestamp",
113
+ "year", "avg_over_time", "min_over_time", "max_over_time", "sum_over_time",
114
+ "count_over_time", "quantile_over_time", "stddev_over_time", "stdvar_over_time",
115
+ "last_over_time", "present_over_time", "changes", "le",
116
+ })
117
+
118
+
119
+ def _extract_metrics_from_query(query: str) -> list[str]:
120
+ """Extract metric names from a PromQL query."""
121
+ pattern = r"([a-zA-Z_:][a-zA-Z0-9_:]*)\s*(?:\{|\[|$)"
122
+ matches = re.findall(pattern, query)
123
+
124
+ return [
125
+ m for m in matches
126
+ if m.lower() not in _PROMQL_KEYWORDS and not m.startswith("__")
127
+ ]
@@ -0,0 +1,101 @@
1
+ """Models for contract verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+
8
+
9
+ class MetricSource(Enum):
10
+ """Source of a declared metric in service.yaml."""
11
+
12
+ SLO_INDICATOR = "slo_indicator"
13
+ OBSERVABILITY = "observability"
14
+ ALERT = "alert"
15
+
16
+
17
+ @dataclass
18
+ class DeclaredMetric:
19
+ """A metric declared in service.yaml."""
20
+
21
+ name: str
22
+ source: MetricSource
23
+ query: str | None = None
24
+ resource_name: str | None = None
25
+
26
+ @property
27
+ def is_critical(self) -> bool:
28
+ """SLO and alert metrics are critical for service reliability."""
29
+ return self.source in (MetricSource.SLO_INDICATOR, MetricSource.ALERT)
30
+
31
+
32
+ @dataclass
33
+ class MetricContract:
34
+ """The metric contract for a service."""
35
+
36
+ service_name: str
37
+ metrics: list[DeclaredMetric] = field(default_factory=list)
38
+
39
+ @property
40
+ def critical_metrics(self) -> list[DeclaredMetric]:
41
+ return [m for m in self.metrics if m.is_critical]
42
+
43
+ @property
44
+ def optional_metrics(self) -> list[DeclaredMetric]:
45
+ return [m for m in self.metrics if not m.is_critical]
46
+
47
+ @property
48
+ def unique_metric_names(self) -> set[str]:
49
+ return {m.name for m in self.metrics}
50
+
51
+
52
+ @dataclass
53
+ class VerificationResult:
54
+ """Result of verifying a single metric."""
55
+
56
+ metric: DeclaredMetric
57
+ exists: bool
58
+ error: str | None = None
59
+ sample_labels: dict | None = None
60
+
61
+ @property
62
+ def is_critical_failure(self) -> bool:
63
+ return not self.exists and self.metric.is_critical
64
+
65
+
66
+ @dataclass
67
+ class ContractVerificationResult:
68
+ """Result of verifying the entire metric contract."""
69
+
70
+ service_name: str
71
+ target_url: str
72
+ results: list[VerificationResult] = field(default_factory=list)
73
+
74
+ @property
75
+ def all_verified(self) -> bool:
76
+ return all(r.exists for r in self.results)
77
+
78
+ @property
79
+ def critical_verified(self) -> bool:
80
+ return all(r.exists for r in self.results if r.metric.is_critical)
81
+
82
+ @property
83
+ def missing_critical(self) -> list[VerificationResult]:
84
+ return [r for r in self.results if r.is_critical_failure]
85
+
86
+ @property
87
+ def missing_optional(self) -> list[VerificationResult]:
88
+ return [r for r in self.results if not r.exists and not r.metric.is_critical]
89
+
90
+ @property
91
+ def verified_count(self) -> int:
92
+ return sum(1 for r in self.results if r.exists)
93
+
94
+ @property
95
+ def exit_code(self) -> int:
96
+ """Exit code for CI/CD: 0=all verified, 1=optional missing, 2=critical missing."""
97
+ if self.missing_critical:
98
+ return 2
99
+ if self.missing_optional:
100
+ return 1
101
+ return 0
@@ -0,0 +1,111 @@
1
+ """Prometheus metric verifier."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import httpx
8
+ import structlog
9
+
10
+ from nthlayer_workers.observe.verification.models import (
11
+ ContractVerificationResult,
12
+ DeclaredMetric,
13
+ MetricContract,
14
+ VerificationResult,
15
+ )
16
+
17
+ logger = structlog.get_logger()
18
+
19
+
20
+ class MetricVerifier:
21
+ """Verifies metrics exist in Prometheus."""
22
+
23
+ def __init__(
24
+ self,
25
+ prometheus_url: str,
26
+ username: str | None = None,
27
+ password: str | None = None,
28
+ timeout: float = 30.0,
29
+ ):
30
+ self.prometheus_url = prometheus_url.rstrip("/")
31
+ self.auth = (username, password) if username and password else None
32
+ self.timeout = timeout
33
+
34
+ if not self.auth:
35
+ env_user = os.environ.get("PROMETHEUS_USERNAME")
36
+ env_pass = os.environ.get("PROMETHEUS_PASSWORD")
37
+ if env_user and env_pass:
38
+ self.auth = (env_user, env_pass)
39
+
40
+ def verify_contract(self, contract: MetricContract) -> ContractVerificationResult:
41
+ """Verify all metrics in a contract exist."""
42
+ result = ContractVerificationResult(
43
+ service_name=contract.service_name,
44
+ target_url=self.prometheus_url,
45
+ )
46
+
47
+ for metric in contract.metrics:
48
+ verification = self.verify_metric(metric, contract.service_name)
49
+ result.results.append(verification)
50
+
51
+ return result
52
+
53
+ def verify_metric(self, metric: DeclaredMetric, service_name: str) -> VerificationResult:
54
+ """Verify a single metric exists in Prometheus."""
55
+ try:
56
+ exists, sample_labels = self._check_metric_exists(metric.name, service_name)
57
+ return VerificationResult(metric=metric, exists=exists, sample_labels=sample_labels)
58
+ except Exception as e:
59
+ logger.warning("metric_verification_failed", metric=metric.name, error=str(e))
60
+ return VerificationResult(metric=metric, exists=False, error=str(e))
61
+
62
+ def _check_metric_exists(
63
+ self, metric_name: str, service_name: str
64
+ ) -> tuple[bool, dict | None]:
65
+ """Check if a metric exists. Tries with service label, then without."""
66
+ selector = f'{metric_name}{{service="{service_name}"}}'
67
+ exists, labels = self._query_series(selector)
68
+ if exists:
69
+ return True, labels
70
+
71
+ selector = f"{metric_name}"
72
+ return self._query_series(selector)
73
+
74
+ def _query_series(self, selector: str) -> tuple[bool, dict | None]:
75
+ """Query Prometheus series API."""
76
+ url = f"{self.prometheus_url}/api/v1/series"
77
+ params: dict[str, str | int] = {"match[]": selector, "limit": 1}
78
+
79
+ try:
80
+ with httpx.Client(timeout=self.timeout) as client:
81
+ response = client.get(url, params=params, auth=self.auth)
82
+ response.raise_for_status()
83
+ data = response.json()
84
+
85
+ if data.get("status") == "success":
86
+ series = data.get("data", [])
87
+ if series:
88
+ sample = series[0]
89
+ labels = {k: v for k, v in sample.items() if k != "__name__"}
90
+ return True, labels
91
+
92
+ return False, None
93
+
94
+ except httpx.HTTPStatusError as e:
95
+ if e.response.status_code == 404:
96
+ return False, None
97
+ raise
98
+ except httpx.ConnectError as e:
99
+ raise ConnectionError(f"Cannot connect to Prometheus at {self.prometheus_url}") from e
100
+ except httpx.TimeoutException as e:
101
+ raise TimeoutError(f"Timeout connecting to Prometheus at {self.prometheus_url}") from e
102
+
103
+ def test_connection(self) -> bool:
104
+ """Test connection to Prometheus."""
105
+ try:
106
+ url = f"{self.prometheus_url}/api/v1/status/buildinfo"
107
+ with httpx.Client(timeout=10.0) as client:
108
+ response = client.get(url, auth=self.auth)
109
+ return response.status_code == 200
110
+ except Exception:
111
+ return False
@@ -0,0 +1,332 @@
1
+ """Observe worker modules — SLO collection, drift detection, topology.
2
+
3
+ Three modules implementing WorkerModule protocol, each on its own interval:
4
+ - ObserveCollectModule: SLO collection + portfolio (60s)
5
+ - ObserveDriftModule: drift analysis per service+SLO (1800s)
6
+ - ObserveTopologyModule: dependency graph / blast radius (86400s)
7
+
8
+ Deploy gate is CLI-only (not a worker module).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections import defaultdict
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ import structlog
18
+
19
+ from nthlayer_common.api_client import CoreAPIClient
20
+ from nthlayer_common.manifest.models import SLODefinition
21
+
22
+ from nthlayer_workers.observe.assessment import create, to_dict
23
+ from nthlayer_workers.observe.portfolio.aggregator import build_portfolio_from_results
24
+ from nthlayer_workers.observe.slo.collector import (
25
+ SLOMetricCollector,
26
+ SLOResult,
27
+ ServiceSLO,
28
+ results_to_assessments,
29
+ )
30
+
31
+ logger = structlog.get_logger()
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # ObserveCollectModule — SLO collection + portfolio
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ @dataclass
40
+ class ObserveCollectModule:
41
+ """SLO collection + portfolio synthesis.
42
+
43
+ Each process_cycle():
44
+ 1. Fetches manifests from core's GET /manifests
45
+ 2. Extracts ServiceSLO pairs
46
+ 3. Queries Prometheus for SLI values via SLOMetricCollector
47
+ 4. Converts results to slo_status assessments, submits to core
48
+ 5. Builds portfolio from current-cycle results, submits portfolio_status assessment
49
+ """
50
+
51
+ client: CoreAPIClient
52
+ prometheus_url: str
53
+
54
+ def __post_init__(self):
55
+ # Constructed once to reuse connections and avoid re-reading
56
+ # Prometheus auth env vars on every cycle.
57
+ self._collector = SLOMetricCollector(self.prometheus_url)
58
+
59
+ @property
60
+ def name(self) -> str:
61
+ return "observe.collect"
62
+
63
+ async def restore_state(self, state: dict | None) -> None:
64
+ pass
65
+
66
+ async def process_cycle(self) -> None:
67
+ manifests = await _fetch_manifests(self.client)
68
+ if not manifests:
69
+ return
70
+
71
+ service_slos = _extract_service_slos(manifests)
72
+ if not service_slos:
73
+ return
74
+
75
+ by_service: dict[str, list[ServiceSLO]] = defaultdict(list)
76
+ for item in service_slos:
77
+ by_service[item.service].append(item)
78
+
79
+ # Collect SLOs and track results + assessment IDs for portfolio
80
+ all_results: dict[str, list[SLOResult]] = {}
81
+ slo_assessment_ids: list[str] = []
82
+ collection_errors = 0
83
+
84
+ for service, slos in sorted(by_service.items()):
85
+ try:
86
+ results = await self._collector.collect(slos)
87
+ except Exception:
88
+ logger.exception("observe_collect_failed", service=service)
89
+ collection_errors += 1
90
+ continue
91
+
92
+ all_results[service] = results
93
+ assessments = results_to_assessments(results, service)
94
+ for assessment in assessments:
95
+ submit_result = await self.client.submit_assessment(to_dict(assessment))
96
+ if submit_result.ok:
97
+ slo_assessment_ids.append(assessment.id)
98
+ else:
99
+ logger.warning(
100
+ "observe_submit_failed",
101
+ assessment_id=assessment.id,
102
+ error=submit_result.error,
103
+ )
104
+
105
+ # Portfolio: synthesise current-cycle results
106
+ if all_results:
107
+ portfolio = build_portfolio_from_results(all_results)
108
+ portfolio_assessment = create(
109
+ "portfolio_status",
110
+ # "__portfolio__" sentinel: portfolio is an org-level assessment,
111
+ # not per-service. Uses a reserved name that cannot collide with
112
+ # real service names (OpenSRM metadata.name cannot start with _).
113
+ "__portfolio__",
114
+ {
115
+ "total_services": portfolio.total_services,
116
+ "healthy_count": portfolio.healthy_count,
117
+ "warning_count": portfolio.warning_count,
118
+ "critical_count": portfolio.critical_count,
119
+ "exhausted_count": portfolio.exhausted_count,
120
+ "services": [
121
+ {
122
+ "service": svc.service,
123
+ "overall_status": svc.overall_status,
124
+ "slo_count": len(svc.slos),
125
+ }
126
+ for svc in portfolio.services
127
+ ],
128
+ "collection_errors": collection_errors,
129
+ # parent_ids is best-effort: only includes IDs of
130
+ # successfully submitted SLO assessments.
131
+ "parent_ids": slo_assessment_ids,
132
+ },
133
+ )
134
+ await self.client.submit_assessment(to_dict(portfolio_assessment))
135
+
136
+ async def get_state(self) -> dict:
137
+ return {}
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # ObserveDriftModule — drift analysis per service+SLO
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ @dataclass
146
+ class ObserveDriftModule:
147
+ """Drift detection for all SLOs across all services.
148
+
149
+ Queries Prometheus range data directly for each service+SLO
150
+ combination from manifests. Produces drift_signal assessments.
151
+ """
152
+
153
+ client: CoreAPIClient
154
+ prometheus_url: str
155
+
156
+ @property
157
+ def name(self) -> str:
158
+ return "observe.drift"
159
+
160
+ async def restore_state(self, state: dict | None) -> None:
161
+ pass
162
+
163
+ async def process_cycle(self) -> None:
164
+ from nthlayer_workers.observe.drift import DriftAnalysisError, DriftAnalyzer
165
+
166
+ manifests = await _fetch_manifests(self.client)
167
+ if not manifests:
168
+ return
169
+
170
+ analyzer = DriftAnalyzer(self.prometheus_url)
171
+
172
+ for service, tier, slo_name, window in _drift_targets(manifests):
173
+ try:
174
+ result = await analyzer.analyze(service, tier, slo_name, window)
175
+ except DriftAnalysisError:
176
+ logger.warning("drift_analysis_failed", service=service, slo=slo_name)
177
+ continue
178
+ except Exception:
179
+ logger.exception("drift_unexpected_error", service=service, slo=slo_name)
180
+ continue
181
+
182
+ assessment = create("drift_signal", service, {
183
+ "slo_name": result.slo_name,
184
+ "severity": result.severity.value,
185
+ "pattern": result.pattern.value,
186
+ "slope_per_week": result.metrics.slope_per_week,
187
+ "days_until_exhaustion": result.projection.days_until_exhaustion,
188
+ "current_budget": result.metrics.current_budget,
189
+ "summary": result.summary,
190
+ "recommendation": result.recommendation,
191
+ })
192
+ await self.client.submit_assessment(to_dict(assessment))
193
+
194
+ async def get_state(self) -> dict:
195
+ return {}
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # ObserveTopologyModule — dependency graph / blast radius
200
+ # ---------------------------------------------------------------------------
201
+
202
+
203
+ @dataclass
204
+ class ObserveTopologyModule:
205
+ """Dependency graph discovery and blast radius computation.
206
+
207
+ Queries Prometheus for dependency metrics, builds graph, computes
208
+ blast radius per service. Produces dependency_graph assessments.
209
+
210
+ Use case for v1.5: topology observation for planning purposes.
211
+ The "incident blast radius" use case lives in respond's correlation,
212
+ not here. Daily cadence is appropriate.
213
+
214
+ Scale note: per-service loop over calculate_blast_radius() builds a
215
+ single graph then evaluates each service. Fine for v1.5; may need a
216
+ bulk API if service count grows meaningfully (100+).
217
+ """
218
+
219
+ client: CoreAPIClient
220
+ prometheus_url: str
221
+
222
+ @property
223
+ def name(self) -> str:
224
+ return "observe.topology"
225
+
226
+ async def restore_state(self, state: dict | None) -> None:
227
+ pass
228
+
229
+ async def process_cycle(self) -> None:
230
+ from nthlayer_workers.observe.dependencies import DependencyDiscovery
231
+ from nthlayer_workers.observe.dependencies.providers.prometheus import PrometheusDepProvider
232
+
233
+ manifests = await _fetch_manifests(self.client)
234
+ if not manifests:
235
+ return
236
+
237
+ discovery = DependencyDiscovery()
238
+ discovery.add_provider(PrometheusDepProvider(url=self.prometheus_url))
239
+
240
+ service_names = [m["name"] for m in manifests if m.get("name")]
241
+ if not service_names:
242
+ return
243
+
244
+ try:
245
+ graph = await discovery.build_graph(service_names)
246
+ except Exception:
247
+ logger.exception("topology_graph_build_failed")
248
+ return
249
+
250
+ for m in manifests:
251
+ service = m.get("name")
252
+ if not service:
253
+ continue
254
+ try:
255
+ result = discovery.calculate_blast_radius(service, graph)
256
+ except Exception:
257
+ logger.exception("topology_blast_radius_failed", service=service)
258
+ continue
259
+
260
+ assessment = create("dependency_graph", service, {
261
+ "total_services_affected": result.total_services_affected,
262
+ "critical_services_affected": result.critical_services_affected,
263
+ "risk_level": result.risk_level,
264
+ "direct_downstream_count": len(result.direct_downstream),
265
+ "recommendation": result.recommendation,
266
+ })
267
+ await self.client.submit_assessment(to_dict(assessment))
268
+
269
+ async def get_state(self) -> dict:
270
+ return {}
271
+
272
+
273
+ # ---------------------------------------------------------------------------
274
+ # Shared helpers
275
+ # ---------------------------------------------------------------------------
276
+
277
+
278
+ async def _fetch_manifests(client: CoreAPIClient) -> list[dict] | None:
279
+ """Fetch manifests from core API. Returns None on error, [] on empty catalogue."""
280
+ result = await client.get_manifests()
281
+ if not result.ok:
282
+ logger.warning("observe_manifest_fetch_failed", error=result.error)
283
+ return None
284
+ return result.data or []
285
+
286
+
287
+ def _extract_service_slos(manifest_dicts: list[dict]) -> list[ServiceSLO]:
288
+ """Extract ServiceSLO pairs from core API manifest response dicts.
289
+
290
+ Parallel to spec_loader.load_specs(): that path reads YAML files on disk
291
+ (CLI use), this path parses manifest dicts from core's GET /manifests
292
+ (worker use).
293
+ """
294
+ results: list[ServiceSLO] = []
295
+ for m in manifest_dicts:
296
+ service = m.get("name")
297
+ if not service:
298
+ continue
299
+ for slo_dict in m.get("slos", []):
300
+ name = slo_dict.get("name")
301
+ target = slo_dict.get("target")
302
+ if not name or target is None:
303
+ continue
304
+ slo = SLODefinition(
305
+ name=name,
306
+ target=target,
307
+ slo_type=slo_dict.get("slo_type", "availability"),
308
+ window=slo_dict.get("window", "30d"),
309
+ indicator_query=slo_dict.get("indicator_query"),
310
+ total_query=slo_dict.get("total_query"),
311
+ good_query=slo_dict.get("good_query"),
312
+ judgment_type=slo_dict.get("judgment_type"),
313
+ )
314
+ results.append(ServiceSLO(service=service, slo=slo))
315
+ return results
316
+
317
+
318
+ def _drift_targets(manifest_dicts: list[dict]) -> list[tuple[str, str, str, str | None]]:
319
+ """Extract (service, tier, slo_name, window) tuples for drift analysis."""
320
+ targets: list[tuple[str, str, str, str | None]] = []
321
+ for m in manifest_dicts:
322
+ service = m.get("name")
323
+ tier = m.get("tier", "standard")
324
+ if not service:
325
+ continue
326
+ for slo_dict in m.get("slos", []):
327
+ slo_name = slo_dict.get("name")
328
+ if not slo_name:
329
+ continue
330
+ window = slo_dict.get("window")
331
+ targets.append((service, tier, slo_name, window))
332
+ return targets
@@ -0,0 +1,2 @@
1
+ """Mayday — Multi-agent incident response."""
2
+ __version__ = "0.1.0a1"
@@ -0,0 +1,4 @@
1
+ """Entry point for python -m nthlayer_respond."""
2
+ from nthlayer_workers.respond.cli import main
3
+
4
+ main()
File without changes