Commit ·
dfe5268
1
Parent(s): 77ede9e
feat: Add observability setup guide and integrate Prometheus and Grafana
Browse files- Introduced OBSERVABILITY_SETUP.md for comprehensive observability setup instructions.
- Added docker-compose configuration for Prometheus and Grafana.
- Created Grafana dashboards for AntiAtropos live control plane and Kubernetes overview.
- Updated README.md to reflect Kubernetes integration and observability features.
- Implemented Prometheus metrics endpoint in the AntiAtropos server.
- Enhanced Kubernetes integration with explicit workload mapping and telemetry label mapping.
- Added tests for Kubernetes integration and Prometheus adapter functionality.
- control/kubernetes_executor.py +151 -29
- control/validation.py +18 -5
- models.py +3 -0
- pyproject.toml +1 -0
- server/AntiAtropos_environment.py +100 -7
- server/Dockerfile +1 -0
- server/app.py +10 -0
- server/requirements.txt +1 -0
- telemetry/__init__.py +1 -0
- telemetry/mapping.py +75 -8
- telemetry/observability.py +127 -0
- telemetry/prometheus_client.py +83 -12
control/kubernetes_executor.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
import os
|
| 2 |
import json
|
|
|
|
|
|
|
| 3 |
from typing import Optional
|
| 4 |
|
| 5 |
class KubernetesExecutor:
|
|
@@ -12,47 +14,99 @@ class KubernetesExecutor:
|
|
| 12 |
self.kubeconfig = kubeconfig or os.getenv("KUBECONFIG")
|
| 13 |
self.is_mock = not self.kubeconfig or self.kubeconfig.lower() == "mock"
|
| 14 |
self.namespace = os.getenv("ANTIATROPOS_K8S_NAMESPACE", "default")
|
| 15 |
-
self.deployment_prefix = os.getenv("ANTIATROPOS_DEPLOYMENT_PREFIX", "")
|
| 16 |
self.min_replicas = int(os.getenv("ANTIATROPOS_MIN_REPLICAS", "1"))
|
| 17 |
self.max_replicas = int(os.getenv("ANTIATROPOS_MAX_REPLICAS", "20"))
|
| 18 |
self.scale_step = int(os.getenv("ANTIATROPOS_SCALE_STEP", "3"))
|
| 19 |
self._apps_v1_api = None
|
| 20 |
-
self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
def execute(self, action_type: str, target: str, parameter: float) -> str:
|
| 23 |
"""
|
| 24 |
Translates SRE actions to Kube requests (ScaleDeployment, PatchIngress, etc.)
|
| 25 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
if self.is_mock:
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
def _real_execution(self, action_type: str, target: str, parameter: float) -> str:
|
| 35 |
"""Execute bounded actions on a Kubernetes cluster."""
|
| 36 |
-
|
|
|
|
|
|
|
| 37 |
return "Ack: NO_OP - no cluster mutation"
|
| 38 |
|
| 39 |
-
if
|
| 40 |
-
return self._scale_deployment(
|
| 41 |
|
| 42 |
-
return f"Rejected: {
|
| 43 |
|
| 44 |
def _mock_execution(self, action_type: str, target: str, parameter: float) -> str:
|
| 45 |
"""Returns mock acknowledgement for actions."""
|
| 46 |
# TODO: Add realistic latency simulation for K8s control plane
|
| 47 |
-
|
|
|
|
| 48 |
|
| 49 |
def _scale_deployment(self, action_type: str, target: str, parameter: float) -> str:
|
| 50 |
-
deployment_name = self.
|
| 51 |
apps_v1 = self._get_apps_v1_api()
|
| 52 |
|
| 53 |
scale_obj = apps_v1.read_namespaced_deployment_scale(
|
| 54 |
name=deployment_name,
|
| 55 |
-
namespace=
|
| 56 |
)
|
| 57 |
|
| 58 |
current = int(scale_obj.spec.replicas or self.min_replicas)
|
|
@@ -70,11 +124,14 @@ class KubernetesExecutor:
|
|
| 70 |
|
| 71 |
apps_v1.patch_namespaced_deployment_scale(
|
| 72 |
name=deployment_name,
|
| 73 |
-
namespace=
|
| 74 |
body={"spec": {"replicas": desired}},
|
| 75 |
)
|
| 76 |
|
| 77 |
-
return
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
def _get_apps_v1_api(self):
|
| 80 |
if self._apps_v1_api is not None:
|
|
@@ -90,19 +147,84 @@ class KubernetesExecutor:
|
|
| 90 |
self._apps_v1_api = client.AppsV1Api()
|
| 91 |
return self._apps_v1_api
|
| 92 |
|
| 93 |
-
def
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
try:
|
| 98 |
data = json.loads(raw)
|
| 99 |
-
if isinstance(data, dict):
|
| 100 |
-
return {str(k): str(v) for k, v in data.items()}
|
| 101 |
except json.JSONDecodeError:
|
| 102 |
-
return
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
return self._node_deployment_map[target]
|
| 108 |
-
return f"{self.deployment_prefix}{target}"
|
|
|
|
| 1 |
import os
|
| 2 |
import json
|
| 3 |
+
import time
|
| 4 |
+
from uuid import uuid4
|
| 5 |
from typing import Optional
|
| 6 |
|
| 7 |
class KubernetesExecutor:
|
|
|
|
| 14 |
self.kubeconfig = kubeconfig or os.getenv("KUBECONFIG")
|
| 15 |
self.is_mock = not self.kubeconfig or self.kubeconfig.lower() == "mock"
|
| 16 |
self.namespace = os.getenv("ANTIATROPOS_K8S_NAMESPACE", "default")
|
|
|
|
| 17 |
self.min_replicas = int(os.getenv("ANTIATROPOS_MIN_REPLICAS", "1"))
|
| 18 |
self.max_replicas = int(os.getenv("ANTIATROPOS_MAX_REPLICAS", "20"))
|
| 19 |
self.scale_step = int(os.getenv("ANTIATROPOS_SCALE_STEP", "3"))
|
| 20 |
self._apps_v1_api = None
|
| 21 |
+
self._node_workload_map = self._load_node_workload_map()
|
| 22 |
+
self._live_supported_actions = {"NO_OP", "SCALE_UP", "SCALE_DOWN"}
|
| 23 |
+
|
| 24 |
+
@staticmethod
|
| 25 |
+
def _normalize_action_type(action_type) -> str:
|
| 26 |
+
if hasattr(action_type, "value"):
|
| 27 |
+
return str(action_type.value)
|
| 28 |
+
return str(action_type)
|
| 29 |
|
| 30 |
def execute(self, action_type: str, target: str, parameter: float) -> str:
|
| 31 |
"""
|
| 32 |
Translates SRE actions to Kube requests (ScaleDeployment, PatchIngress, etc.)
|
| 33 |
"""
|
| 34 |
+
return self.execute_with_metadata(action_type, target, parameter)["ack_status"]
|
| 35 |
+
|
| 36 |
+
def execute_with_metadata(self, action_type: str, target: str, parameter: float) -> dict:
|
| 37 |
+
"""
|
| 38 |
+
Execute action and return acknowledgement plus executor metadata.
|
| 39 |
+
"""
|
| 40 |
+
action_id = str(uuid4())
|
| 41 |
+
started = time.perf_counter()
|
| 42 |
+
ack_status = ""
|
| 43 |
+
error_code = ""
|
| 44 |
+
|
| 45 |
if self.is_mock:
|
| 46 |
+
ack_status = self._mock_execution(action_type, target, parameter)
|
| 47 |
+
else:
|
| 48 |
+
try:
|
| 49 |
+
ack_status = self._real_execution(action_type, target, parameter)
|
| 50 |
+
except Exception as e:
|
| 51 |
+
ack_status = f"Error: Failed to execute {action_type} on {target}: {str(e)}"
|
| 52 |
+
error_code = "EXECUTION_ERROR"
|
| 53 |
+
|
| 54 |
+
if ack_status.startswith("Rejected:") and not error_code:
|
| 55 |
+
error_code = "REJECTED_ACTION"
|
| 56 |
+
elif ack_status.startswith("Error:") and not error_code:
|
| 57 |
+
error_code = "EXECUTION_ERROR"
|
| 58 |
+
|
| 59 |
+
latency_ms = (time.perf_counter() - started) * 1000.0
|
| 60 |
+
return {
|
| 61 |
+
"action_id": action_id,
|
| 62 |
+
"ack_status": ack_status,
|
| 63 |
+
"executor_latency_ms": latency_ms,
|
| 64 |
+
"executor_error_code": error_code,
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
def live_enabled_actions(self) -> set[str]:
|
| 68 |
+
"""Action types that are actually executable in real live mode."""
|
| 69 |
+
if self.is_mock:
|
| 70 |
+
return {"NO_OP"}
|
| 71 |
+
return set(self._live_supported_actions)
|
| 72 |
+
|
| 73 |
+
def live_capability_error(self, action_type: str) -> Optional[str]:
|
| 74 |
+
"""Returns reason when action is not runnable in live mode, else None."""
|
| 75 |
+
action = self._normalize_action_type(action_type)
|
| 76 |
+
if action not in self.live_enabled_actions():
|
| 77 |
+
if self.is_mock:
|
| 78 |
+
return (
|
| 79 |
+
f"Live mode rejected {action}: no real Kubernetes executor is configured "
|
| 80 |
+
"(set KUBECONFIG and ANTIATROPOS_WORKLOAD_MAP)."
|
| 81 |
+
)
|
| 82 |
+
return f"Live mode rejected {action}: no executor is enabled for this action."
|
| 83 |
+
return None
|
| 84 |
|
| 85 |
def _real_execution(self, action_type: str, target: str, parameter: float) -> str:
|
| 86 |
"""Execute bounded actions on a Kubernetes cluster."""
|
| 87 |
+
action = self._normalize_action_type(action_type)
|
| 88 |
+
|
| 89 |
+
if action == "NO_OP":
|
| 90 |
return "Ack: NO_OP - no cluster mutation"
|
| 91 |
|
| 92 |
+
if action in ("SCALE_UP", "SCALE_DOWN"):
|
| 93 |
+
return self._scale_deployment(action, target, parameter)
|
| 94 |
|
| 95 |
+
return f"Rejected: {action} is not enabled for live Kubernetes execution"
|
| 96 |
|
| 97 |
def _mock_execution(self, action_type: str, target: str, parameter: float) -> str:
|
| 98 |
"""Returns mock acknowledgement for actions."""
|
| 99 |
# TODO: Add realistic latency simulation for K8s control plane
|
| 100 |
+
action = self._normalize_action_type(action_type)
|
| 101 |
+
return f"Ack: {action} for {target} with value {parameter} - Status: Applied"
|
| 102 |
|
| 103 |
def _scale_deployment(self, action_type: str, target: str, parameter: float) -> str:
|
| 104 |
+
namespace, deployment_name = self._resolve_workload_target(target)
|
| 105 |
apps_v1 = self._get_apps_v1_api()
|
| 106 |
|
| 107 |
scale_obj = apps_v1.read_namespaced_deployment_scale(
|
| 108 |
name=deployment_name,
|
| 109 |
+
namespace=namespace,
|
| 110 |
)
|
| 111 |
|
| 112 |
current = int(scale_obj.spec.replicas or self.min_replicas)
|
|
|
|
| 124 |
|
| 125 |
apps_v1.patch_namespaced_deployment_scale(
|
| 126 |
name=deployment_name,
|
| 127 |
+
namespace=namespace,
|
| 128 |
body={"spec": {"replicas": desired}},
|
| 129 |
)
|
| 130 |
|
| 131 |
+
return (
|
| 132 |
+
f"Ack: {action_type} for {target} - deployment {deployment_name} "
|
| 133 |
+
f"in namespace {namespace} scaled {current}->{desired}"
|
| 134 |
+
)
|
| 135 |
|
| 136 |
def _get_apps_v1_api(self):
|
| 137 |
if self._apps_v1_api is not None:
|
|
|
|
| 147 |
self._apps_v1_api = client.AppsV1Api()
|
| 148 |
return self._apps_v1_api
|
| 149 |
|
| 150 |
+
def _load_node_workload_map(self) -> dict[str, dict[str, str]]:
|
| 151 |
+
"""
|
| 152 |
+
Load node->workload mapping.
|
| 153 |
+
|
| 154 |
+
Preferred format (ANTIATROPOS_WORKLOAD_MAP):
|
| 155 |
+
{
|
| 156 |
+
"node-0": {"deployment": "payments", "namespace": "prod-sre"},
|
| 157 |
+
"node-1": {"deployment": "checkout"}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
Legacy fallback (ANTIATROPOS_NODE_DEPLOYMENT_MAP):
|
| 161 |
+
{
|
| 162 |
+
"node-0": "payments",
|
| 163 |
+
"node-1": "checkout"
|
| 164 |
+
}
|
| 165 |
+
"""
|
| 166 |
+
raw = os.getenv("ANTIATROPOS_WORKLOAD_MAP", "")
|
| 167 |
+
if raw:
|
| 168 |
+
parsed = self._parse_json_mapping(raw)
|
| 169 |
+
if parsed is not None:
|
| 170 |
+
return parsed
|
| 171 |
+
|
| 172 |
+
legacy_raw = os.getenv("ANTIATROPOS_NODE_DEPLOYMENT_MAP", "")
|
| 173 |
+
if legacy_raw:
|
| 174 |
+
legacy = self._parse_legacy_mapping(legacy_raw)
|
| 175 |
+
if legacy is not None:
|
| 176 |
+
return legacy
|
| 177 |
+
|
| 178 |
+
return {}
|
| 179 |
+
|
| 180 |
+
def _parse_json_mapping(self, raw: str) -> Optional[dict[str, dict[str, str]]]:
|
| 181 |
try:
|
| 182 |
data = json.loads(raw)
|
|
|
|
|
|
|
| 183 |
except json.JSONDecodeError:
|
| 184 |
+
return None
|
| 185 |
+
|
| 186 |
+
if not isinstance(data, dict):
|
| 187 |
+
return None
|
| 188 |
+
|
| 189 |
+
out: dict[str, dict[str, str]] = {}
|
| 190 |
+
for node_id, workload in data.items():
|
| 191 |
+
if not isinstance(workload, dict):
|
| 192 |
+
return None
|
| 193 |
+
deployment = workload.get("deployment")
|
| 194 |
+
if not deployment:
|
| 195 |
+
return None
|
| 196 |
+
namespace = workload.get("namespace", self.namespace)
|
| 197 |
+
out[str(node_id)] = {
|
| 198 |
+
"deployment": str(deployment),
|
| 199 |
+
"namespace": str(namespace),
|
| 200 |
+
}
|
| 201 |
+
return out
|
| 202 |
+
|
| 203 |
+
def _parse_legacy_mapping(self, raw: str) -> Optional[dict[str, dict[str, str]]]:
|
| 204 |
+
try:
|
| 205 |
+
data = json.loads(raw)
|
| 206 |
+
except json.JSONDecodeError:
|
| 207 |
+
return None
|
| 208 |
+
|
| 209 |
+
if not isinstance(data, dict):
|
| 210 |
+
return None
|
| 211 |
+
|
| 212 |
+
out: dict[str, dict[str, str]] = {}
|
| 213 |
+
for node_id, deployment in data.items():
|
| 214 |
+
if not deployment:
|
| 215 |
+
return None
|
| 216 |
+
out[str(node_id)] = {
|
| 217 |
+
"deployment": str(deployment),
|
| 218 |
+
"namespace": self.namespace,
|
| 219 |
+
}
|
| 220 |
+
return out
|
| 221 |
+
|
| 222 |
+
def _resolve_workload_target(self, target: str) -> tuple[str, str]:
|
| 223 |
+
if target not in self._node_workload_map:
|
| 224 |
+
raise ValueError(
|
| 225 |
+
f"Missing workload mapping for target '{target}'. "
|
| 226 |
+
"Set ANTIATROPOS_WORKLOAD_MAP with node->deployment bindings."
|
| 227 |
+
)
|
| 228 |
|
| 229 |
+
workload = self._node_workload_map[target]
|
| 230 |
+
return workload["namespace"], workload["deployment"]
|
|
|
|
|
|
control/validation.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
from typing import List, Optional
|
| 2 |
-
from pydantic import BaseModel
|
| 3 |
|
| 4 |
class ActionValidator:
|
| 5 |
"""
|
|
@@ -13,13 +12,27 @@ class ActionValidator:
|
|
| 13 |
"""
|
| 14 |
Returns (is_valid, error_message).
|
| 15 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
if valid_targets is not None and target not in valid_targets:
|
| 17 |
return False, f"Unknown target node: {target}"
|
| 18 |
|
| 19 |
-
if
|
| 20 |
return False, f"Forbidden: Load shedding on critical node {target}."
|
| 21 |
-
|
| 22 |
-
if
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
return True, "Success"
|
|
|
|
| 1 |
from typing import List, Optional
|
|
|
|
| 2 |
|
| 3 |
class ActionValidator:
|
| 4 |
"""
|
|
|
|
| 12 |
"""
|
| 13 |
Returns (is_valid, error_message).
|
| 14 |
"""
|
| 15 |
+
if hasattr(action_type, "value"):
|
| 16 |
+
action = str(action_type.value)
|
| 17 |
+
else:
|
| 18 |
+
action = str(action_type)
|
| 19 |
+
|
| 20 |
if valid_targets is not None and target not in valid_targets:
|
| 21 |
return False, f"Unknown target node: {target}"
|
| 22 |
|
| 23 |
+
if action == "SHED_LOAD" and target in self.critical_nodes:
|
| 24 |
return False, f"Forbidden: Load shedding on critical node {target}."
|
| 25 |
+
|
| 26 |
+
if action in ["SCALE_UP", "SCALE_DOWN"]:
|
| 27 |
+
if parameter < 0.0:
|
| 28 |
+
return False, "Negative scaling parameters are not allowed."
|
| 29 |
+
if parameter > 10.0:
|
| 30 |
+
return False, "Scaling parameter must be <= 10.0."
|
| 31 |
+
|
| 32 |
+
if action in ["REROUTE_TRAFFIC", "SHED_LOAD"] and not (0.0 <= parameter <= 1.0):
|
| 33 |
+
return False, f"{action} parameter must be in [0.0, 1.0]."
|
| 34 |
+
|
| 35 |
+
if action == "NO_OP" and parameter != 0.0:
|
| 36 |
+
return False, "NO_OP requires parameter=0.0."
|
| 37 |
|
| 38 |
return True, "Success"
|
models.py
CHANGED
|
@@ -152,6 +152,9 @@ class ClusterObservation(BaseModel):
|
|
| 152 |
metric_timestamp: float = 0.0
|
| 153 |
data_freshness_ms: int = 0
|
| 154 |
action_ack_status: str = "success"
|
|
|
|
|
|
|
|
|
|
| 155 |
choke_level: float = 0.0
|
| 156 |
|
| 157 |
nodes: list[NodeObservation]
|
|
|
|
| 152 |
metric_timestamp: float = 0.0
|
| 153 |
data_freshness_ms: int = 0
|
| 154 |
action_ack_status: str = "success"
|
| 155 |
+
action_id: str = ""
|
| 156 |
+
executor_latency_ms: float = Field(default=0.0, ge=0.0)
|
| 157 |
+
executor_error_code: str = ""
|
| 158 |
choke_level: float = 0.0
|
| 159 |
|
| 160 |
nodes: list[NodeObservation]
|
pyproject.toml
CHANGED
|
@@ -28,6 +28,7 @@ dependencies = [
|
|
| 28 |
# "smolagents>=1.22.0,<2",
|
| 29 |
"kubernetes>=28.0.0",
|
| 30 |
"prometheus-api-client>=0.5.0",
|
|
|
|
| 31 |
"requests>=2.31.0",
|
| 32 |
]
|
| 33 |
|
|
|
|
| 28 |
# "smolagents>=1.22.0,<2",
|
| 29 |
"kubernetes>=28.0.0",
|
| 30 |
"prometheus-api-client>=0.5.0",
|
| 31 |
+
"prometheus-client>=0.20.0",
|
| 32 |
"requests>=2.31.0",
|
| 33 |
]
|
| 34 |
|
server/AntiAtropos_environment.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
import time
|
|
|
|
|
|
|
| 2 |
from uuid import uuid4
|
| 3 |
|
| 4 |
from openenv.core.env_server.interfaces import Environment
|
|
@@ -8,13 +10,13 @@ try:
|
|
| 8 |
from ..models import SREAction, ClusterObservation, NodeObservation, NodeStatus, EnvironmentMode
|
| 9 |
from ..simulator import ClusterSimulator, COST_PER_CAPACITY_UNIT_PER_HOUR
|
| 10 |
from ..stability import compute_lyapunov, compute_reward
|
| 11 |
-
from ..telemetry import PrometheusClient
|
| 12 |
from ..control import KubernetesExecutor, ActionValidator
|
| 13 |
except ImportError:
|
| 14 |
from models import SREAction, ClusterObservation, NodeObservation, NodeStatus, EnvironmentMode # type: ignore[no-redef]
|
| 15 |
from simulator import ClusterSimulator, COST_PER_CAPACITY_UNIT_PER_HOUR # type: ignore[no-redef]
|
| 16 |
from stability import compute_lyapunov, compute_reward # type: ignore[no-redef]
|
| 17 |
-
from telemetry import PrometheusClient # type: ignore[no-redef]
|
| 18 |
from control import KubernetesExecutor, ActionValidator # type: ignore[no-redef]
|
| 19 |
|
| 20 |
|
|
@@ -57,12 +59,17 @@ class AntiAtroposEnvironment(Environment):
|
|
| 57 |
self._telemetry = PrometheusClient()
|
| 58 |
self._executor = KubernetesExecutor()
|
| 59 |
self._validator = ActionValidator()
|
|
|
|
|
|
|
| 60 |
|
| 61 |
self._nodes_true: list[dict] = []
|
| 62 |
self._nodes_obs: list[dict] = []
|
| 63 |
self._prev_lyapunov: float = 0.0
|
| 64 |
self._sla_violations: int = 0
|
| 65 |
self._action_ack_status: str = "success"
|
|
|
|
|
|
|
|
|
|
| 66 |
self._last_metric_time: float = 0.0
|
| 67 |
|
| 68 |
def reset(self, task_id: str = "task-1", mode: str = "simulated") -> ClusterObservation:
|
|
@@ -78,6 +85,9 @@ class AntiAtroposEnvironment(Environment):
|
|
| 78 |
|
| 79 |
self._sla_violations = 0
|
| 80 |
self._action_ack_status = "success"
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
# Only set baseline metric time for hybrid/live to prevent misleading freshness in SIM
|
| 83 |
if self._mode in [EnvironmentMode.HYBRID, EnvironmentMode.LIVE]:
|
|
@@ -110,29 +120,54 @@ class AntiAtroposEnvironment(Environment):
|
|
| 110 |
Advance the simulation by one discrete time tick.
|
| 111 |
"""
|
| 112 |
self._state.step_count += 1
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
# 1. Action Validation & Execution
|
| 115 |
valid_targets = [n["node_id"] for n in self._nodes_true]
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
action.action_type,
|
| 118 |
action.target_node_id,
|
| 119 |
action.parameter,
|
| 120 |
valid_targets=valid_targets
|
| 121 |
-
|
| 122 |
|
|
|
|
|
|
|
| 123 |
if not is_valid:
|
| 124 |
self._action_ack_status = f"Rejected: {error}"
|
|
|
|
|
|
|
| 125 |
# Increment invalid action count on the simulator so it's consistent
|
| 126 |
self._sim.invalid_action_count += 1
|
| 127 |
# Still advance time but the action didn't happen
|
| 128 |
else:
|
| 129 |
if self._mode == EnvironmentMode.LIVE:
|
| 130 |
# In LIVE mode, we actually hit the cluster
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
else:
|
| 133 |
self._action_ack_status = "success (simulated)"
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
| 136 |
self._sim.apply_action(action)
|
| 137 |
|
| 138 |
# 2. Advance Physics
|
|
@@ -183,6 +218,46 @@ class AntiAtroposEnvironment(Environment):
|
|
| 183 |
obs = self._build_observation()
|
| 184 |
obs.done = done
|
| 185 |
obs.reward = reward
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
return obs
|
| 187 |
|
| 188 |
@property
|
|
@@ -192,6 +267,21 @@ class AntiAtroposEnvironment(Environment):
|
|
| 192 |
# -----------------------------------------------------------------------
|
| 193 |
# Logic Helpers
|
| 194 |
# -----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
|
| 196 |
def _compute_cost(self, nodes_true: list[dict]) -> float:
|
| 197 |
"""Calculates current running infra cost using provisioned capacity units."""
|
|
@@ -271,6 +361,9 @@ class AntiAtroposEnvironment(Environment):
|
|
| 271 |
metric_timestamp=self._last_metric_time,
|
| 272 |
data_freshness_ms=freshness,
|
| 273 |
action_ack_status=self._action_ack_status,
|
|
|
|
|
|
|
|
|
|
| 274 |
choke_level=0.0,
|
| 275 |
done=False,
|
| 276 |
reward=0.0,
|
|
|
|
| 1 |
import time
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
from uuid import uuid4
|
| 5 |
|
| 6 |
from openenv.core.env_server.interfaces import Environment
|
|
|
|
| 10 |
from ..models import SREAction, ClusterObservation, NodeObservation, NodeStatus, EnvironmentMode
|
| 11 |
from ..simulator import ClusterSimulator, COST_PER_CAPACITY_UNIT_PER_HOUR
|
| 12 |
from ..stability import compute_lyapunov, compute_reward
|
| 13 |
+
from ..telemetry import PrometheusClient, get_observability_tracker
|
| 14 |
from ..control import KubernetesExecutor, ActionValidator
|
| 15 |
except ImportError:
|
| 16 |
from models import SREAction, ClusterObservation, NodeObservation, NodeStatus, EnvironmentMode # type: ignore[no-redef]
|
| 17 |
from simulator import ClusterSimulator, COST_PER_CAPACITY_UNIT_PER_HOUR # type: ignore[no-redef]
|
| 18 |
from stability import compute_lyapunov, compute_reward # type: ignore[no-redef]
|
| 19 |
+
from telemetry import PrometheusClient, get_observability_tracker # type: ignore[no-redef]
|
| 20 |
from control import KubernetesExecutor, ActionValidator # type: ignore[no-redef]
|
| 21 |
|
| 22 |
|
|
|
|
| 59 |
self._telemetry = PrometheusClient()
|
| 60 |
self._executor = KubernetesExecutor()
|
| 61 |
self._validator = ActionValidator()
|
| 62 |
+
self._observability = get_observability_tracker()
|
| 63 |
+
self._logger = logging.getLogger("antiatropos.env")
|
| 64 |
|
| 65 |
self._nodes_true: list[dict] = []
|
| 66 |
self._nodes_obs: list[dict] = []
|
| 67 |
self._prev_lyapunov: float = 0.0
|
| 68 |
self._sla_violations: int = 0
|
| 69 |
self._action_ack_status: str = "success"
|
| 70 |
+
self._last_action_id: str = ""
|
| 71 |
+
self._last_executor_latency_ms: float = 0.0
|
| 72 |
+
self._last_executor_error_code: str = ""
|
| 73 |
self._last_metric_time: float = 0.0
|
| 74 |
|
| 75 |
def reset(self, task_id: str = "task-1", mode: str = "simulated") -> ClusterObservation:
|
|
|
|
| 85 |
|
| 86 |
self._sla_violations = 0
|
| 87 |
self._action_ack_status = "success"
|
| 88 |
+
self._last_action_id = ""
|
| 89 |
+
self._last_executor_latency_ms = 0.0
|
| 90 |
+
self._last_executor_error_code = ""
|
| 91 |
|
| 92 |
# Only set baseline metric time for hybrid/live to prevent misleading freshness in SIM
|
| 93 |
if self._mode in [EnvironmentMode.HYBRID, EnvironmentMode.LIVE]:
|
|
|
|
| 120 |
Advance the simulation by one discrete time tick.
|
| 121 |
"""
|
| 122 |
self._state.step_count += 1
|
| 123 |
+
self._last_action_id = str(uuid4())
|
| 124 |
+
self._last_executor_latency_ms = 0.0
|
| 125 |
+
self._last_executor_error_code = ""
|
| 126 |
|
| 127 |
# 1. Action Validation & Execution
|
| 128 |
valid_targets = [n["node_id"] for n in self._nodes_true]
|
| 129 |
+
is_enabled, mode_error = self._is_action_enabled_for_mode(action.action_type)
|
| 130 |
+
if not is_enabled:
|
| 131 |
+
self._action_ack_status = f"Rejected: {mode_error}"
|
| 132 |
+
self._last_executor_error_code = "MODE_UNSUPPORTED"
|
| 133 |
+
is_valid = False
|
| 134 |
+
error = mode_error
|
| 135 |
+
else:
|
| 136 |
+
is_valid, error = self._validator.validate(
|
| 137 |
action.action_type,
|
| 138 |
action.target_node_id,
|
| 139 |
action.parameter,
|
| 140 |
valid_targets=valid_targets
|
| 141 |
+
)
|
| 142 |
|
| 143 |
+
apply_to_simulator = False
|
| 144 |
+
|
| 145 |
if not is_valid:
|
| 146 |
self._action_ack_status = f"Rejected: {error}"
|
| 147 |
+
if not self._last_executor_error_code:
|
| 148 |
+
self._last_executor_error_code = "VALIDATION_FAILED"
|
| 149 |
# Increment invalid action count on the simulator so it's consistent
|
| 150 |
self._sim.invalid_action_count += 1
|
| 151 |
# Still advance time but the action didn't happen
|
| 152 |
else:
|
| 153 |
if self._mode == EnvironmentMode.LIVE:
|
| 154 |
# In LIVE mode, we actually hit the cluster
|
| 155 |
+
exec_result = self._executor.execute_with_metadata(
|
| 156 |
+
action.action_type,
|
| 157 |
+
action.target_node_id,
|
| 158 |
+
action.parameter,
|
| 159 |
+
)
|
| 160 |
+
self._last_action_id = exec_result.get("action_id", self._last_action_id)
|
| 161 |
+
self._action_ack_status = exec_result.get("ack_status", "Error: missing ack status")
|
| 162 |
+
self._last_executor_latency_ms = float(exec_result.get("executor_latency_ms", 0.0))
|
| 163 |
+
self._last_executor_error_code = str(exec_result.get("executor_error_code", ""))
|
| 164 |
+
apply_to_simulator = self._action_ack_status.startswith("Ack:")
|
| 165 |
else:
|
| 166 |
self._action_ack_status = "success (simulated)"
|
| 167 |
+
apply_to_simulator = True
|
| 168 |
+
|
| 169 |
+
# Keep simulator aligned with control-plane ack semantics.
|
| 170 |
+
if apply_to_simulator:
|
| 171 |
self._sim.apply_action(action)
|
| 172 |
|
| 173 |
# 2. Advance Physics
|
|
|
|
| 218 |
obs = self._build_observation()
|
| 219 |
obs.done = done
|
| 220 |
obs.reward = reward
|
| 221 |
+
|
| 222 |
+
self._observability.record_step(
|
| 223 |
+
task_id=self._task_id,
|
| 224 |
+
mode=str(self._mode.value),
|
| 225 |
+
action_type=str(action.action_type.value),
|
| 226 |
+
target_node_id=str(action.target_node_id),
|
| 227 |
+
ack_status=self._action_ack_status,
|
| 228 |
+
reward=reward,
|
| 229 |
+
lyapunov_energy=obs.lyapunov_energy,
|
| 230 |
+
total_queue_backlog=obs.total_queue_backlog,
|
| 231 |
+
average_latency_ms=obs.average_latency_ms,
|
| 232 |
+
executor_latency_ms=self._last_executor_latency_ms,
|
| 233 |
+
executor_error_code=self._last_executor_error_code,
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
self._logger.info(
|
| 237 |
+
json.dumps(
|
| 238 |
+
{
|
| 239 |
+
"event": "antiatropos_step",
|
| 240 |
+
"episode_id": self._state.episode_id,
|
| 241 |
+
"task_id": self._task_id,
|
| 242 |
+
"mode": self._mode.value,
|
| 243 |
+
"step": self._state.step_count,
|
| 244 |
+
"action_type": action.action_type.value,
|
| 245 |
+
"target_node_id": action.target_node_id,
|
| 246 |
+
"parameter": float(action.parameter),
|
| 247 |
+
"action_id": self._last_action_id,
|
| 248 |
+
"action_ack_status": self._action_ack_status,
|
| 249 |
+
"executor_latency_ms": self._last_executor_latency_ms,
|
| 250 |
+
"executor_error_code": self._last_executor_error_code,
|
| 251 |
+
"reward": reward,
|
| 252 |
+
"lyapunov_energy": obs.lyapunov_energy,
|
| 253 |
+
"average_latency_ms_norm": obs.average_latency_ms,
|
| 254 |
+
"total_queue_backlog_norm": obs.total_queue_backlog,
|
| 255 |
+
"error_rate": obs.error_rate,
|
| 256 |
+
"done": done,
|
| 257 |
+
}
|
| 258 |
+
)
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
return obs
|
| 262 |
|
| 263 |
@property
|
|
|
|
| 267 |
# -----------------------------------------------------------------------
|
| 268 |
# Logic Helpers
|
| 269 |
# -----------------------------------------------------------------------
|
| 270 |
+
def _is_action_enabled_for_mode(self, action_type: str) -> tuple[bool, str]:
|
| 271 |
+
if hasattr(action_type, "value"):
|
| 272 |
+
action = str(action_type.value)
|
| 273 |
+
else:
|
| 274 |
+
action = str(action_type)
|
| 275 |
+
if self._mode in [EnvironmentMode.SIMULATED, EnvironmentMode.HYBRID]:
|
| 276 |
+
return True, "Enabled"
|
| 277 |
+
|
| 278 |
+
if self._mode == EnvironmentMode.LIVE:
|
| 279 |
+
capability_error = self._executor.live_capability_error(action)
|
| 280 |
+
if capability_error:
|
| 281 |
+
return False, capability_error
|
| 282 |
+
return True, "Enabled"
|
| 283 |
+
|
| 284 |
+
return False, f"Unsupported environment mode: {self._mode}"
|
| 285 |
|
| 286 |
def _compute_cost(self, nodes_true: list[dict]) -> float:
|
| 287 |
"""Calculates current running infra cost using provisioned capacity units."""
|
|
|
|
| 361 |
metric_timestamp=self._last_metric_time,
|
| 362 |
data_freshness_ms=freshness,
|
| 363 |
action_ack_status=self._action_ack_status,
|
| 364 |
+
action_id=self._last_action_id,
|
| 365 |
+
executor_latency_ms=self._last_executor_latency_ms,
|
| 366 |
+
executor_error_code=self._last_executor_error_code,
|
| 367 |
choke_level=0.0,
|
| 368 |
done=False,
|
| 369 |
reward=0.0,
|
server/Dockerfile
CHANGED
|
@@ -87,6 +87,7 @@ ENV ANTIATROPOS_DEPLOYMENT_PREFIX=""
|
|
| 87 |
ENV ANTIATROPOS_MIN_REPLICAS="1"
|
| 88 |
ENV ANTIATROPOS_MAX_REPLICAS="20"
|
| 89 |
ENV ANTIATROPOS_SCALE_STEP="3"
|
|
|
|
| 90 |
ENV ANTIATROPOS_NODE_DEPLOYMENT_MAP="{}"
|
| 91 |
|
| 92 |
# Health check
|
|
|
|
| 87 |
ENV ANTIATROPOS_MIN_REPLICAS="1"
|
| 88 |
ENV ANTIATROPOS_MAX_REPLICAS="20"
|
| 89 |
ENV ANTIATROPOS_SCALE_STEP="3"
|
| 90 |
+
ENV ANTIATROPOS_WORKLOAD_MAP="{}"
|
| 91 |
ENV ANTIATROPOS_NODE_DEPLOYMENT_MAP="{}"
|
| 92 |
|
| 93 |
# Health check
|
server/app.py
CHANGED
|
@@ -39,10 +39,12 @@ try:
|
|
| 39 |
# Change these names to match models.py
|
| 40 |
from ..models import SREAction, ClusterObservation
|
| 41 |
from .AntiAtropos_environment import AntiAtroposEnvironment
|
|
|
|
| 42 |
except (ModuleNotFoundError, ImportError):
|
| 43 |
# And here as well
|
| 44 |
from models import SREAction, ClusterObservation
|
| 45 |
from server.AntiAtropos_environment import AntiAtroposEnvironment
|
|
|
|
| 46 |
|
| 47 |
|
| 48 |
# Create the app with web interface and README integration
|
|
@@ -56,6 +58,14 @@ app = create_app(
|
|
| 56 |
)
|
| 57 |
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
def main(host: str = "0.0.0.0", port: int = 8000):
|
| 60 |
"""
|
| 61 |
Entry point for direct execution via uv run or python -m.
|
|
|
|
| 39 |
# Change these names to match models.py
|
| 40 |
from ..models import SREAction, ClusterObservation
|
| 41 |
from .AntiAtropos_environment import AntiAtroposEnvironment
|
| 42 |
+
from ..telemetry import render_prometheus_metrics
|
| 43 |
except (ModuleNotFoundError, ImportError):
|
| 44 |
# And here as well
|
| 45 |
from models import SREAction, ClusterObservation
|
| 46 |
from server.AntiAtropos_environment import AntiAtroposEnvironment
|
| 47 |
+
from telemetry import render_prometheus_metrics
|
| 48 |
|
| 49 |
|
| 50 |
# Create the app with web interface and README integration
|
|
|
|
| 58 |
)
|
| 59 |
|
| 60 |
|
| 61 |
+
@app.get("/metrics")
|
| 62 |
+
def metrics():
|
| 63 |
+
from fastapi import Response
|
| 64 |
+
|
| 65 |
+
payload = render_prometheus_metrics()
|
| 66 |
+
return Response(content=payload, media_type="text/plain; version=0.0.4; charset=utf-8")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
def main(host: str = "0.0.0.0", port: int = 8000):
|
| 70 |
"""
|
| 71 |
Entry point for direct execution via uv run or python -m.
|
server/requirements.txt
CHANGED
|
@@ -3,4 +3,5 @@ fastapi>=0.115.0
|
|
| 3 |
uvicorn>=0.24.0
|
| 4 |
kubernetes>=28.0.0
|
| 5 |
prometheus-api-client>=0.5.0
|
|
|
|
| 6 |
requests>=2.31.0
|
|
|
|
| 3 |
uvicorn>=0.24.0
|
| 4 |
kubernetes>=28.0.0
|
| 5 |
prometheus-api-client>=0.5.0
|
| 6 |
+
prometheus-client>=0.20.0
|
| 7 |
requests>=2.31.0
|
telemetry/__init__.py
CHANGED
|
@@ -1,2 +1,3 @@
|
|
| 1 |
from .prometheus_client import PrometheusClient, TelemetryRecord
|
| 2 |
from .mapping import MetricMapper
|
|
|
|
|
|
| 1 |
from .prometheus_client import PrometheusClient, TelemetryRecord
|
| 2 |
from .mapping import MetricMapper
|
| 3 |
+
from .observability import get_observability_tracker, render_prometheus_metrics
|
telemetry/mapping.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
-
from typing import Dict, List, Any
|
| 2 |
import os
|
|
|
|
| 3 |
|
| 4 |
class MetricMapper:
|
| 5 |
"""
|
|
@@ -8,20 +9,86 @@ class MetricMapper:
|
|
| 8 |
aggregate for a given node_id.
|
| 9 |
"""
|
| 10 |
def __init__(self, mapping_strategy: str = "sum"):
|
| 11 |
-
self.strategy = mapping_strategy
|
| 12 |
self.node_mapping = self._load_node_mapping()
|
| 13 |
|
| 14 |
def _load_node_mapping(self) -> Dict[str, str]:
|
| 15 |
-
"""Loads
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
return {
|
| 18 |
"web-node-1": "node-0",
|
| 19 |
"api-node-2": "node-1",
|
| 20 |
"db-node-3": "node-2"
|
| 21 |
}
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
def aggregate_node_metrics(self, raw_metrics: List[Dict[str, Any]]) -> Dict[str, Dict[str, float]]:
|
| 24 |
-
"""
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, List, Any, Optional
|
| 2 |
import os
|
| 3 |
+
import json
|
| 4 |
|
| 5 |
class MetricMapper:
|
| 6 |
"""
|
|
|
|
| 9 |
aggregate for a given node_id.
|
| 10 |
"""
|
| 11 |
def __init__(self, mapping_strategy: str = "sum"):
|
| 12 |
+
self.strategy = mapping_strategy.lower()
|
| 13 |
self.node_mapping = self._load_node_mapping()
|
| 14 |
|
| 15 |
def _load_node_mapping(self) -> Dict[str, str]:
|
| 16 |
+
"""Loads label-value -> node_id mapping from env config."""
|
| 17 |
+
raw = os.getenv("ANTIATROPOS_LABEL_NODE_MAP", "")
|
| 18 |
+
if raw:
|
| 19 |
+
try:
|
| 20 |
+
data = json.loads(raw)
|
| 21 |
+
if isinstance(data, dict):
|
| 22 |
+
return {str(k): str(v) for k, v in data.items()}
|
| 23 |
+
except json.JSONDecodeError:
|
| 24 |
+
pass
|
| 25 |
+
|
| 26 |
+
# Safe default mapping for local demos.
|
| 27 |
return {
|
| 28 |
"web-node-1": "node-0",
|
| 29 |
"api-node-2": "node-1",
|
| 30 |
"db-node-3": "node-2"
|
| 31 |
}
|
| 32 |
|
| 33 |
+
def _resolve_node_id(self, labels: Dict[str, Any]) -> Optional[str]:
|
| 34 |
+
"""Resolve internal node_id from a Prometheus sample labelset."""
|
| 35 |
+
explicit = labels.get("node_id")
|
| 36 |
+
if explicit:
|
| 37 |
+
return str(explicit)
|
| 38 |
+
|
| 39 |
+
for key in ("pod", "service", "app", "workload", "deployment", "instance"):
|
| 40 |
+
label_value = labels.get(key)
|
| 41 |
+
if label_value and str(label_value) in self.node_mapping:
|
| 42 |
+
return self.node_mapping[str(label_value)]
|
| 43 |
+
|
| 44 |
+
return None
|
| 45 |
+
|
| 46 |
+
def _reduce(self, values: List[float]) -> float:
|
| 47 |
+
if not values:
|
| 48 |
+
return 0.0
|
| 49 |
+
if self.strategy == "max":
|
| 50 |
+
return max(values)
|
| 51 |
+
if self.strategy == "mean":
|
| 52 |
+
return sum(values) / len(values)
|
| 53 |
+
# default: sum
|
| 54 |
+
return sum(values)
|
| 55 |
+
|
| 56 |
def aggregate_node_metrics(self, raw_metrics: List[Dict[str, Any]]) -> Dict[str, Dict[str, float]]:
|
| 57 |
+
"""
|
| 58 |
+
Aggregates labeled metric samples into node-level telemetry.
|
| 59 |
+
|
| 60 |
+
Expected sample shape:
|
| 61 |
+
{
|
| 62 |
+
"metric_name": "request_rate",
|
| 63 |
+
"labels": {"pod": "web-node-1"},
|
| 64 |
+
"value": 42.0
|
| 65 |
+
}
|
| 66 |
+
"""
|
| 67 |
+
bucket: Dict[str, Dict[str, List[float]]] = {}
|
| 68 |
+
|
| 69 |
+
for sample in raw_metrics:
|
| 70 |
+
metric_name = str(sample.get("metric_name", ""))
|
| 71 |
+
labels = sample.get("labels") or {}
|
| 72 |
+
value = sample.get("value")
|
| 73 |
+
if not metric_name or not isinstance(labels, dict) or value is None:
|
| 74 |
+
continue
|
| 75 |
+
|
| 76 |
+
node_id = self._resolve_node_id(labels)
|
| 77 |
+
if not node_id:
|
| 78 |
+
continue
|
| 79 |
+
|
| 80 |
+
try:
|
| 81 |
+
val = float(value)
|
| 82 |
+
except (TypeError, ValueError):
|
| 83 |
+
continue
|
| 84 |
+
|
| 85 |
+
bucket.setdefault(node_id, {}).setdefault(metric_name, []).append(val)
|
| 86 |
+
|
| 87 |
+
aggregated: Dict[str, Dict[str, float]] = {}
|
| 88 |
+
for node_id, metric_map in bucket.items():
|
| 89 |
+
aggregated[node_id] = {
|
| 90 |
+
metric_name: self._reduce(values)
|
| 91 |
+
for metric_name, values in metric_map.items()
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
return aggregated
|
telemetry/observability.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import threading
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
try:
|
| 5 |
+
from prometheus_client import Counter, Gauge, Histogram, generate_latest
|
| 6 |
+
except ImportError: # pragma: no cover
|
| 7 |
+
Counter = Gauge = Histogram = None
|
| 8 |
+
|
| 9 |
+
def generate_latest() -> bytes: # type: ignore[override]
|
| 10 |
+
return b"# prometheus_client not installed\n"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _enabled() -> bool:
|
| 14 |
+
return Counter is not None and Gauge is not None and Histogram is not None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class ObservabilityTracker:
|
| 18 |
+
"""Prometheus metrics for action/reward/health monitoring."""
|
| 19 |
+
|
| 20 |
+
def __init__(self):
|
| 21 |
+
self._lock = threading.Lock()
|
| 22 |
+
self._is_enabled = _enabled()
|
| 23 |
+
if not self._is_enabled:
|
| 24 |
+
return
|
| 25 |
+
|
| 26 |
+
self.steps_total = Counter(
|
| 27 |
+
"antiatropos_steps_total",
|
| 28 |
+
"Total environment steps",
|
| 29 |
+
["task_id", "mode"],
|
| 30 |
+
)
|
| 31 |
+
self.actions_total = Counter(
|
| 32 |
+
"antiatropos_actions_total",
|
| 33 |
+
"Actions executed by type/target/status",
|
| 34 |
+
["task_id", "mode", "action_type", "target_node_id", "ack_class"],
|
| 35 |
+
)
|
| 36 |
+
self.executor_errors_total = Counter(
|
| 37 |
+
"antiatropos_executor_errors_total",
|
| 38 |
+
"Executor errors by code",
|
| 39 |
+
["mode", "error_code"],
|
| 40 |
+
)
|
| 41 |
+
self.executor_latency_ms = Histogram(
|
| 42 |
+
"antiatropos_executor_latency_ms",
|
| 43 |
+
"Executor latency in milliseconds",
|
| 44 |
+
["mode"],
|
| 45 |
+
buckets=(1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000),
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
self.reward_gauge = Gauge(
|
| 49 |
+
"antiatropos_reward",
|
| 50 |
+
"Latest reward value",
|
| 51 |
+
["task_id", "mode"],
|
| 52 |
+
)
|
| 53 |
+
self.lyapunov_gauge = Gauge(
|
| 54 |
+
"antiatropos_lyapunov_energy",
|
| 55 |
+
"Latest Lyapunov energy",
|
| 56 |
+
["task_id", "mode"],
|
| 57 |
+
)
|
| 58 |
+
self.queue_gauge = Gauge(
|
| 59 |
+
"antiatropos_total_queue_backlog",
|
| 60 |
+
"Latest normalized total queue backlog",
|
| 61 |
+
["task_id", "mode"],
|
| 62 |
+
)
|
| 63 |
+
self.latency_gauge = Gauge(
|
| 64 |
+
"antiatropos_average_latency_norm",
|
| 65 |
+
"Latest normalized average latency",
|
| 66 |
+
["task_id", "mode"],
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
def record_step(
|
| 70 |
+
self,
|
| 71 |
+
task_id: str,
|
| 72 |
+
mode: str,
|
| 73 |
+
action_type: str,
|
| 74 |
+
target_node_id: str,
|
| 75 |
+
ack_status: str,
|
| 76 |
+
reward: float,
|
| 77 |
+
lyapunov_energy: float,
|
| 78 |
+
total_queue_backlog: float,
|
| 79 |
+
average_latency_ms: float,
|
| 80 |
+
executor_latency_ms: float,
|
| 81 |
+
executor_error_code: str,
|
| 82 |
+
) -> None:
|
| 83 |
+
if not self._is_enabled:
|
| 84 |
+
return
|
| 85 |
+
|
| 86 |
+
ack_class = self._classify_ack(ack_status)
|
| 87 |
+
with self._lock:
|
| 88 |
+
self.steps_total.labels(task_id=task_id, mode=mode).inc()
|
| 89 |
+
self.actions_total.labels(
|
| 90 |
+
task_id=task_id,
|
| 91 |
+
mode=mode,
|
| 92 |
+
action_type=action_type,
|
| 93 |
+
target_node_id=target_node_id,
|
| 94 |
+
ack_class=ack_class,
|
| 95 |
+
).inc()
|
| 96 |
+
self.reward_gauge.labels(task_id=task_id, mode=mode).set(float(reward))
|
| 97 |
+
self.lyapunov_gauge.labels(task_id=task_id, mode=mode).set(float(lyapunov_energy))
|
| 98 |
+
self.queue_gauge.labels(task_id=task_id, mode=mode).set(float(total_queue_backlog))
|
| 99 |
+
self.latency_gauge.labels(task_id=task_id, mode=mode).set(float(average_latency_ms))
|
| 100 |
+
self.executor_latency_ms.labels(mode=mode).observe(max(0.0, float(executor_latency_ms)))
|
| 101 |
+
if executor_error_code:
|
| 102 |
+
self.executor_errors_total.labels(mode=mode, error_code=executor_error_code).inc()
|
| 103 |
+
|
| 104 |
+
@staticmethod
|
| 105 |
+
def _classify_ack(ack_status: str) -> str:
|
| 106 |
+
status = str(ack_status)
|
| 107 |
+
if status.startswith("Ack:") or status.startswith("success"):
|
| 108 |
+
return "ack"
|
| 109 |
+
if status.startswith("Rejected:"):
|
| 110 |
+
return "rejected"
|
| 111 |
+
if status.startswith("Error:"):
|
| 112 |
+
return "error"
|
| 113 |
+
return "unknown"
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
_TRACKER: Optional[ObservabilityTracker] = None
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def get_observability_tracker() -> ObservabilityTracker:
|
| 120 |
+
global _TRACKER
|
| 121 |
+
if _TRACKER is None:
|
| 122 |
+
_TRACKER = ObservabilityTracker()
|
| 123 |
+
return _TRACKER
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def render_prometheus_metrics() -> bytes:
|
| 127 |
+
return generate_latest()
|
telemetry/prometheus_client.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
import os
|
| 2 |
import random
|
| 3 |
-
from typing import Dict, List, Optional
|
| 4 |
import requests
|
| 5 |
from pydantic import BaseModel
|
|
|
|
| 6 |
|
| 7 |
class TelemetryRecord(BaseModel):
|
| 8 |
node_id: str
|
|
@@ -23,26 +24,29 @@ class PrometheusClient:
|
|
| 23 |
self.is_mock = not self.url or self.url.lower() == "mock"
|
| 24 |
self.timeout_s = float(os.getenv("ANTIATROPOS_PROM_TIMEOUT_S", "2.5"))
|
| 25 |
self.strict_real = os.getenv("ANTIATROPOS_STRICT_REAL", "false").lower() == "true"
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
self.request_rate_query = os.getenv(
|
| 28 |
"ANTIATROPOS_PROM_QUERY_REQUEST_RATE",
|
| 29 |
-
'sum(rate(http_requests_total
|
| 30 |
)
|
| 31 |
self.latency_ms_query = os.getenv(
|
| 32 |
"ANTIATROPOS_PROM_QUERY_LATENCY_MS",
|
| 33 |
-
'histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket
|
| 34 |
)
|
| 35 |
self.error_rate_query = os.getenv(
|
| 36 |
"ANTIATROPOS_PROM_QUERY_ERROR_RATE",
|
| 37 |
-
'sum(rate(http_requests_total{
|
| 38 |
)
|
| 39 |
self.cpu_query = os.getenv(
|
| 40 |
"ANTIATROPOS_PROM_QUERY_CPU",
|
| 41 |
-
'avg(rate(container_cpu_usage_seconds_total
|
| 42 |
)
|
| 43 |
self.queue_depth_query = os.getenv(
|
| 44 |
"ANTIATROPOS_PROM_QUERY_QUEUE_DEPTH",
|
| 45 |
-
'sum(queue_depth
|
| 46 |
)
|
| 47 |
|
| 48 |
def fetch_latest_metrics(self, node_ids: List[str]) -> Dict[str, TelemetryRecord]:
|
|
@@ -66,14 +70,20 @@ class PrometheusClient:
|
|
| 66 |
metrics: Dict[str, TelemetryRecord] = {}
|
| 67 |
saw_any_real_signal = False
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
for node_id in node_ids:
|
| 70 |
-
req_rate =
|
| 71 |
-
lat_ms =
|
| 72 |
-
err_rate =
|
| 73 |
-
cpu =
|
| 74 |
-
q_depth =
|
| 75 |
|
| 76 |
-
if any(v is not None for v in
|
| 77 |
saw_any_real_signal = True
|
| 78 |
|
| 79 |
metrics[node_id] = TelemetryRecord(
|
|
@@ -90,6 +100,50 @@ class PrometheusClient:
|
|
| 90 |
|
| 91 |
return metrics
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
def _query_scalar(self, promql: str) -> Optional[float]:
|
| 94 |
"""Runs a scalar/vector Prometheus instant query and returns the first value."""
|
| 95 |
if not self.url:
|
|
@@ -119,6 +173,23 @@ class PrometheusClient:
|
|
| 119 |
except (TypeError, ValueError):
|
| 120 |
return None
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
def _generate_mock_metrics(self, node_ids: List[str]) -> Dict[str, TelemetryRecord]:
|
| 123 |
"""Generates realistic-looking mock telemetry."""
|
| 124 |
metrics = {}
|
|
|
|
| 1 |
import os
|
| 2 |
import random
|
| 3 |
+
from typing import Any, Dict, List, Optional
|
| 4 |
import requests
|
| 5 |
from pydantic import BaseModel
|
| 6 |
+
from .mapping import MetricMapper
|
| 7 |
|
| 8 |
class TelemetryRecord(BaseModel):
|
| 9 |
node_id: str
|
|
|
|
| 24 |
self.is_mock = not self.url or self.url.lower() == "mock"
|
| 25 |
self.timeout_s = float(os.getenv("ANTIATROPOS_PROM_TIMEOUT_S", "2.5"))
|
| 26 |
self.strict_real = os.getenv("ANTIATROPOS_STRICT_REAL", "false").lower() == "true"
|
| 27 |
+
self.metric_mapper = MetricMapper(
|
| 28 |
+
mapping_strategy=os.getenv("ANTIATROPOS_METRIC_AGGREGATION", "sum")
|
| 29 |
+
)
|
| 30 |
|
| 31 |
self.request_rate_query = os.getenv(
|
| 32 |
"ANTIATROPOS_PROM_QUERY_REQUEST_RATE",
|
| 33 |
+
'sum(rate(http_requests_total[1m])) by (pod)'
|
| 34 |
)
|
| 35 |
self.latency_ms_query = os.getenv(
|
| 36 |
"ANTIATROPOS_PROM_QUERY_LATENCY_MS",
|
| 37 |
+
'histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (pod, le)) * 1000'
|
| 38 |
)
|
| 39 |
self.error_rate_query = os.getenv(
|
| 40 |
"ANTIATROPOS_PROM_QUERY_ERROR_RATE",
|
| 41 |
+
'sum(rate(http_requests_total{status=~"5.."}[1m])) by (pod) / clamp_min(sum(rate(http_requests_total[1m])) by (pod), 1)'
|
| 42 |
)
|
| 43 |
self.cpu_query = os.getenv(
|
| 44 |
"ANTIATROPOS_PROM_QUERY_CPU",
|
| 45 |
+
'avg(rate(container_cpu_usage_seconds_total[1m])) by (pod)'
|
| 46 |
)
|
| 47 |
self.queue_depth_query = os.getenv(
|
| 48 |
"ANTIATROPOS_PROM_QUERY_QUEUE_DEPTH",
|
| 49 |
+
'sum(queue_depth) by (pod)'
|
| 50 |
)
|
| 51 |
|
| 52 |
def fetch_latest_metrics(self, node_ids: List[str]) -> Dict[str, TelemetryRecord]:
|
|
|
|
| 70 |
metrics: Dict[str, TelemetryRecord] = {}
|
| 71 |
saw_any_real_signal = False
|
| 72 |
|
| 73 |
+
req_by_node = self._collect_metric_values("request_rate", self.request_rate_query, node_ids)
|
| 74 |
+
lat_by_node = self._collect_metric_values("latency_ms", self.latency_ms_query, node_ids)
|
| 75 |
+
err_by_node = self._collect_metric_values("error_rate", self.error_rate_query, node_ids)
|
| 76 |
+
cpu_by_node = self._collect_metric_values("cpu_utilization", self.cpu_query, node_ids)
|
| 77 |
+
q_by_node = self._collect_metric_values("queue_depth", self.queue_depth_query, node_ids)
|
| 78 |
+
|
| 79 |
for node_id in node_ids:
|
| 80 |
+
req_rate = req_by_node.get(node_id)
|
| 81 |
+
lat_ms = lat_by_node.get(node_id)
|
| 82 |
+
err_rate = err_by_node.get(node_id)
|
| 83 |
+
cpu = cpu_by_node.get(node_id)
|
| 84 |
+
q_depth = q_by_node.get(node_id)
|
| 85 |
|
| 86 |
+
if any(v is not None for v in (req_rate, lat_ms, err_rate, cpu, q_depth)):
|
| 87 |
saw_any_real_signal = True
|
| 88 |
|
| 89 |
metrics[node_id] = TelemetryRecord(
|
|
|
|
| 100 |
|
| 101 |
return metrics
|
| 102 |
|
| 103 |
+
def _collect_metric_values(
|
| 104 |
+
self,
|
| 105 |
+
metric_name: str,
|
| 106 |
+
query: str,
|
| 107 |
+
node_ids: List[str],
|
| 108 |
+
) -> Dict[str, Optional[float]]:
|
| 109 |
+
"""
|
| 110 |
+
Collect node values for one logical metric.
|
| 111 |
+
|
| 112 |
+
If query contains "{node_id}", execute per-node scalar queries.
|
| 113 |
+
Otherwise run one vector query and aggregate labels via MetricMapper.
|
| 114 |
+
"""
|
| 115 |
+
out: Dict[str, Optional[float]] = {node_id: None for node_id in node_ids}
|
| 116 |
+
|
| 117 |
+
if "{node_id}" in query:
|
| 118 |
+
for node_id in node_ids:
|
| 119 |
+
out[node_id] = self._query_scalar(query.format(node_id=node_id))
|
| 120 |
+
return out
|
| 121 |
+
|
| 122 |
+
samples = self._query_vector(query)
|
| 123 |
+
raw_metrics: List[Dict[str, Any]] = []
|
| 124 |
+
for sample in samples:
|
| 125 |
+
labels = sample.get("metric")
|
| 126 |
+
value = sample.get("value")
|
| 127 |
+
if not isinstance(labels, dict):
|
| 128 |
+
continue
|
| 129 |
+
if not value or len(value) < 2:
|
| 130 |
+
continue
|
| 131 |
+
raw_metrics.append(
|
| 132 |
+
{
|
| 133 |
+
"metric_name": metric_name,
|
| 134 |
+
"labels": labels,
|
| 135 |
+
"value": value[1],
|
| 136 |
+
}
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
by_node = self.metric_mapper.aggregate_node_metrics(raw_metrics)
|
| 140 |
+
for node_id in node_ids:
|
| 141 |
+
metric_map = by_node.get(node_id, {})
|
| 142 |
+
value = metric_map.get(metric_name)
|
| 143 |
+
out[node_id] = value if value is not None else None
|
| 144 |
+
|
| 145 |
+
return out
|
| 146 |
+
|
| 147 |
def _query_scalar(self, promql: str) -> Optional[float]:
|
| 148 |
"""Runs a scalar/vector Prometheus instant query and returns the first value."""
|
| 149 |
if not self.url:
|
|
|
|
| 173 |
except (TypeError, ValueError):
|
| 174 |
return None
|
| 175 |
|
| 176 |
+
def _query_vector(self, promql: str) -> List[Dict[str, Any]]:
|
| 177 |
+
"""Runs a Prometheus instant query and returns the full vector result list."""
|
| 178 |
+
if not self.url:
|
| 179 |
+
return []
|
| 180 |
+
|
| 181 |
+
response = requests.get(
|
| 182 |
+
f"{self.url.rstrip('/')}/api/v1/query",
|
| 183 |
+
params={"query": promql},
|
| 184 |
+
timeout=self.timeout_s,
|
| 185 |
+
)
|
| 186 |
+
response.raise_for_status()
|
| 187 |
+
payload = response.json()
|
| 188 |
+
if payload.get("status") != "success":
|
| 189 |
+
return []
|
| 190 |
+
result = payload.get("data", {}).get("result", [])
|
| 191 |
+
return result if isinstance(result, list) else []
|
| 192 |
+
|
| 193 |
def _generate_mock_metrics(self, node_ids: List[str]) -> Dict[str, TelemetryRecord]:
|
| 194 |
"""Generates realistic-looking mock telemetry."""
|
| 195 |
metrics = {}
|