divagr1925 commited on
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 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._node_deployment_map = self._load_node_deployment_map()
 
 
 
 
 
 
 
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
- return self._mock_execution(action_type, target, parameter)
28
-
29
- try:
30
- return self._real_execution(action_type, target, parameter)
31
- except Exception as e:
32
- return f"Error: Failed to execute {action_type} on {target}: {str(e)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  def _real_execution(self, action_type: str, target: str, parameter: float) -> str:
35
  """Execute bounded actions on a Kubernetes cluster."""
36
- if action_type == "NO_OP":
 
 
37
  return "Ack: NO_OP - no cluster mutation"
38
 
39
- if action_type in ("SCALE_UP", "SCALE_DOWN"):
40
- return self._scale_deployment(action_type, target, parameter)
41
 
42
- return f"Rejected: {action_type} is not enabled for live Kubernetes execution"
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
- return f"Ack: {action_type} for {target} with value {parameter} - Status: Applied"
 
48
 
49
  def _scale_deployment(self, action_type: str, target: str, parameter: float) -> str:
50
- deployment_name = self._resolve_deployment_name(target)
51
  apps_v1 = self._get_apps_v1_api()
52
 
53
  scale_obj = apps_v1.read_namespaced_deployment_scale(
54
  name=deployment_name,
55
- namespace=self.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=self.namespace,
74
  body={"spec": {"replicas": desired}},
75
  )
76
 
77
- return f"Ack: {action_type} for {target} - deployment {deployment_name} scaled {current}->{desired}"
 
 
 
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 _load_node_deployment_map(self) -> dict[str, str]:
94
- raw = os.getenv("ANTIATROPOS_NODE_DEPLOYMENT_MAP", "")
95
- if not raw:
96
- return {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
- def _resolve_deployment_name(self, target: str) -> str:
106
- if target in self._node_deployment_map:
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 action_type == "SHED_LOAD" and target in self.critical_nodes:
20
  return False, f"Forbidden: Load shedding on critical node {target}."
21
-
22
- if action_type in ["SCALE_UP", "SCALE_DOWN"] and parameter < 0.0:
23
- return False, "Negative scaling parameters are not allowed."
 
 
 
 
 
 
 
 
 
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
- is_valid, error = self._validator.validate(
 
 
 
 
 
 
 
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
- self._action_ack_status = self._executor.execute(action.action_type, action.target_node_id, action.parameter)
 
 
 
 
 
 
 
 
 
132
  else:
133
  self._action_ack_status = "success (simulated)"
134
-
135
- # Always update the physics engine to keep tracking expectations
 
 
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 pod-to-node mapping from config/labels."""
16
- # Simple default mapping for demonstration
 
 
 
 
 
 
 
 
 
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
- """Aggregates pod-level metrics into node-level telemetry."""
25
- # TODO: Implement aggregation based on strategy (sum, mean, max)
26
- # For now, we return a structural placeholder
27
- return {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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{node_id="{node_id}"}[1m]))'
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{node_id="{node_id}"}[5m])) by (le)) * 1000'
34
  )
35
  self.error_rate_query = os.getenv(
36
  "ANTIATROPOS_PROM_QUERY_ERROR_RATE",
37
- 'sum(rate(http_requests_total{node_id="{node_id}",status=~"5.."}[1m])) / clamp_min(sum(rate(http_requests_total{node_id="{node_id}"}[1m])), 1)'
38
  )
39
  self.cpu_query = os.getenv(
40
  "ANTIATROPOS_PROM_QUERY_CPU",
41
- 'avg(rate(container_cpu_usage_seconds_total{pod=~".*{node_id}.*"}[1m]))'
42
  )
43
  self.queue_depth_query = os.getenv(
44
  "ANTIATROPOS_PROM_QUERY_QUEUE_DEPTH",
45
- 'sum(queue_depth{node_id="{node_id}"})'
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 = self._query_scalar(self.request_rate_query.format(node_id=node_id))
71
- lat_ms = self._query_scalar(self.latency_ms_query.format(node_id=node_id))
72
- err_rate = self._query_scalar(self.error_rate_query.format(node_id=node_id))
73
- cpu = self._query_scalar(self.cpu_query.format(node_id=node_id))
74
- q_depth = self._query_scalar(self.queue_depth_query.format(node_id=node_id))
75
 
76
- if any(v is not None for v in [req_rate, lat_ms, err_rate, cpu, q_depth]):
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 = {}