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,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
|
|
File without changes
|