"""Prometheus-compatible Clinic Metrics Data Proxy client.""" import json import logging import math import os from dataclasses import dataclass from datetime import datetime from http.client import RemoteDisconnected from typing import Any from urllib import error, parse, request logger = logging.getLogger(__name__) DEFAULT_INTERNAL_BASE_URL = "http://clinic-data-service:8087" DEFAULT_TIMEOUT_SECONDS = 120 class MetricsQueryError(RuntimeError): """Raised when Clinic Metrics Data Proxy query fails.""" def __init__(self, message: str): self.raw_message = message super().__init__(message) @dataclass(frozen=True, slots=True) class MetricSample: timestamp: float value: float @dataclass(frozen=True, slots=True) class MetricSeries: metric: dict[str, Any] values: list[MetricSample] class MetricsDataProxyClient: """Small Prometheus API client backed by Clinic Data Proxy metrics endpoints.""" def __init__( self, cluster_id: str, base_url: str | None = None, timeout_seconds: int | None = None, ): self.cluster_id = cluster_id self.base_url = self._resolve_base_url(base_url) self.timeout_seconds = self._resolve_timeout(timeout_seconds) self.api_key = os.environ.get("CLINIC_DATA_PROXY_API_KEY") or os.environ.get("CLINIC_API_KEY") def query(self, promql: str, *, query_time: datetime | float | int | None = None) -> dict[str, Any]: params: dict[str, str] = {"query": promql} if query_time is not None: params["time"] = self._format_time(query_time) return self._request("query", params) def query_range( self, promql: str, *, start: datetime | float | int, end: datetime | float | int, step_seconds: int, ) -> dict[str, Any]: params = { "query": promql, "start": self._format_time(start), "end": self._format_time(end), "step": f"{int(step_seconds)}s", } return self._request("query_range", params) def range_matrix( self, promql: str, *, start: datetime | float | int, end: datetime | float | int, step_seconds: int, ) -> list[MetricSeries]: payload = self.query_range(promql, start=start, end=end, step_seconds=step_seconds) return self._parse_matrix(payload) def instant_vector( self, promql: str, *, query_time: datetime | float | int | None = None, ) -> list[tuple[dict[str, Any], float]]: payload = self.query(promql, query_time=query_time) return self._parse_vector(payload) def _request(self, api: str, params: dict[str, str]) -> dict[str, Any]: url = ( f"{self.base_url}/data-proxy/metrics/" f"{parse.quote(str(self.cluster_id), safe='')}/api/v1/{api}" ) body = parse.urlencode(params).encode("utf-8") headers = {"Content-Type": "application/x-www-form-urlencoded"} if self.api_key: headers["Authorization"] = f"Bearer {self.api_key}" req = request.Request(url, data=body, headers=headers, method="POST") try: with request.urlopen(req, timeout=self.timeout_seconds + 5) as response: raw_body = response.read() except RemoteDisconnected as exc: raise MetricsQueryError("Clinic Metrics Data Proxy request failed: remote end closed connection") from exc except error.HTTPError as exc: detail = exc.read().decode("utf-8", errors="replace").strip() raise MetricsQueryError( f"Clinic Metrics Data Proxy query failed status={exc.code}: {detail or exc.reason}" ) from exc except error.URLError as exc: raise MetricsQueryError(f"Clinic Metrics Data Proxy request failed: {exc.reason}") from exc try: payload = json.loads(raw_body) except json.JSONDecodeError as exc: raise MetricsQueryError("Clinic Metrics Data Proxy returned invalid JSON payload") from exc if payload.get("status") != "success": error_text = payload.get("error") or "unknown prometheus error" raise MetricsQueryError(str(error_text)) return payload @classmethod def _parse_matrix(cls, payload: dict[str, Any]) -> list[MetricSeries]: data = payload.get("data") or {} if data.get("resultType") != "matrix": raise MetricsQueryError(f"expected matrix result, got {data.get('resultType')}") result = data.get("result") or [] if not isinstance(result, list): raise MetricsQueryError("invalid matrix result payload") series: list[MetricSeries] = [] for item in result: metric = item.get("metric") if isinstance(item, dict) else {} values = item.get("values") if isinstance(item, dict) else [] samples = [sample for sample in (cls._parse_sample(raw) for raw in values or []) if sample] series.append(MetricSeries(metric=metric or {}, values=samples)) return series @classmethod def _parse_vector(cls, payload: dict[str, Any]) -> list[tuple[dict[str, Any], float]]: data = payload.get("data") or {} if data.get("resultType") != "vector": raise MetricsQueryError(f"expected vector result, got {data.get('resultType')}") result = data.get("result") or [] if not isinstance(result, list): raise MetricsQueryError("invalid vector result payload") vector: list[tuple[dict[str, Any], float]] = [] for item in result: metric = item.get("metric") if isinstance(item, dict) else {} sample = cls._parse_sample(item.get("value") if isinstance(item, dict) else None) if sample: vector.append((metric or {}, sample.value)) return vector @staticmethod def _parse_sample(raw: Any) -> MetricSample | None: if not isinstance(raw, (list, tuple)) or len(raw) < 2: return None try: value = float(raw[1]) timestamp = float(raw[0]) except (TypeError, ValueError): return None if not math.isfinite(value) or not math.isfinite(timestamp): return None return MetricSample(timestamp=timestamp, value=value) @staticmethod def _format_time(value: datetime | float | int) -> str: if isinstance(value, datetime): return format(value.timestamp(), ".6f").rstrip("0").rstrip(".") return format(float(value), ".6f").rstrip("0").rstrip(".") @staticmethod def _resolve_base_url(override: str | None) -> str: if override: return override.rstrip("/") explicit = os.environ.get("CLINIC_METRICS_DATA_PROXY_BASE_URL") if explicit: return explicit.rstrip("/") api_base = os.environ.get("CLINIC_API_BASE_URL") if api_base: return api_base.rstrip("/") return DEFAULT_INTERNAL_BASE_URL @staticmethod def _resolve_timeout(candidate: int | None) -> int: resolved = int(candidate if candidate is not None else os.environ.get( "CLINIC_METRICS_DATA_PROXY_TIMEOUT_SECONDS", DEFAULT_TIMEOUT_SECONDS, )) if resolved <= 0: raise ValueError("metrics data proxy timeout must be positive") return resolved