"""Prometheus API client service for querying time-series data.""" from __future__ import annotations from typing import Any, Dict, Optional import httpx from src.utils.errors import ( AuthorizationError, NetworkError, PrometheusAPIError, PrometheusTimeoutError, ) from src.utils.logging_utils import get_logger # Limits per specification MAX_TIME_SERIES = 50 MAX_DATA_POINTS_PER_SERIES = 1000 MAX_METRIC_NAMES = 500 MAX_LABEL_ITEMS = 100 DISCOVERY_TIMEOUT = 15.0 logger = get_logger("prometheus") class PrometheusClient: """Async HTTP client for Prometheus API.""" def __init__( self, base_url: str, timeout: float = 20.0, token: Optional[str] = None, ): """ Initialize Prometheus client. Args: base_url: Prometheus base URL (e.g. from observability service or auth resolution). timeout: Request timeout in seconds (default: 20.0) token: Optional bearer token for authorization. """ self._base_url = base_url self.timeout = timeout self._token = token def _get_client(self, timeout: float | None = None) -> httpx.AsyncClient: """Create and configure httpx async client. Uses instance timeout if timeout not given.""" base_url = self._base_url if base_url.rstrip("/").endswith("/api/v1"): base_url = base_url[: -len("/api/v1")] token = self._token headers = {} if token: headers["Authorization"] = f"Bearer {token}" return httpx.AsyncClient( base_url=base_url, headers=headers, timeout=timeout if timeout is not None else self.timeout, ) async def _request( self, path: str, params: Dict[str, Any], timeout: float, timeout_message: str, api_error_message: str = "Request failed", ) -> Dict[str, Any]: """ Perform GET request, handle HTTP/network errors and JSON error status. Returns parsed JSON body. Raises on 401, network, timeout, or status=error. """ async with self._get_client(timeout=timeout) as client: logger.info( "prometheus_request_start path=%s timeout=%s base_url=%s", path, timeout, self._base_url, ) try: response = await client.get(path, params=params) except httpx.TimeoutException as e: raise PrometheusTimeoutError(timeout_message) from e except (httpx.NetworkError, httpx.ConnectError, httpx.ConnectTimeout) as e: raise NetworkError( "Prometheus backend is unreachable. Please verify the observability URL " "and network connectivity." ) from e if response.status_code == 401: logger.warning("prometheus_request_unauthorized path=%s", path) raise AuthorizationError("Access denied: Unauthorized") data = response.json() logger.info("prometheus_request_success path=%s status=%s", path, response.status_code) if data.get("status") == "error": error_type = data.get("errorType", "unknown") error_msg = data.get("error", "Unknown error") raise PrometheusAPIError( api_error_message, prometheus_error=f"{error_type}: {error_msg}", ) return data async def query_range( self, query: str, start: str, end: str, step: str, ) -> Dict[str, Any]: """ Execute a PromQL range query. Args: query: PromQL query string start: Start timestamp (RFC3339) end: End timestamp (RFC3339) step: Duration step (e.g., "15s", "1m") Returns: Prometheus API response dictionary Raises: AuthorizationError: If authorization fails NetworkError: If network connection fails PrometheusTimeoutError: If request times out PrometheusAPIError: If Prometheus returns an error """ params = {"query": query, "start": start, "end": end, "step": step} response_data = await self._request( "/api/v1/query_range", params, timeout=30.0, timeout_message="Query exceeded 30-second timeout", api_error_message="Invalid range query parameters", ) # Apply truncation limits if "data" not in response_data: return response_data result_data = response_data["data"].get("result", []) warnings = [] # Truncate to max time-series if len(result_data) > MAX_TIME_SERIES: result_data = result_data[:MAX_TIME_SERIES] warnings.append(f"Results truncated to {MAX_TIME_SERIES} time-series (limit exceeded)") # Truncate data points per series for series in result_data: values = series.get("values", []) if len(values) > MAX_DATA_POINTS_PER_SERIES: series["values"] = values[:MAX_DATA_POINTS_PER_SERIES] series["truncated"] = True if not any("truncated" in w for w in warnings): warnings.append( "The requested time range is too broad or the step interval is too small. " "Data has been truncated to the first 1000 points. " "Please increase the 'step' value or narrow the time window." ) # Update response with truncated data and warnings response_data["data"]["result"] = result_data if warnings: if "warnings" not in response_data["data"]: response_data["data"]["warnings"] = [] response_data["data"]["warnings"].extend(warnings) return response_data async def query_instant( self, query: str, time: Optional[str] = None, timeout: float = 30.0, ) -> Dict[str, Any]: """ Execute a PromQL instant query at a single point in time. Args: query: PromQL query string time: Optional evaluation timestamp (RFC3339 or Unix). Defaults to now. timeout: Request timeout in seconds (default: 30.0 per spec) Returns: Prometheus API response dictionary with "data": {"result": [...]} Each result has "metric": {...}, "value": [timestamp, value_str] Raises: AuthorizationError: If authorization fails NetworkError: If network connection fails PrometheusTimeoutError: If request times out PrometheusAPIError: If Prometheus returns an error """ params: Dict[str, Any] = {"query": query} if time is not None and time.strip(): params["time"] = time response_data = await self._request( "/api/v1/query", params, timeout=timeout, timeout_message="Query exceeded 30-second timeout. Consider simplifying the query.", api_error_message="Invalid PromQL syntax.", ) if "data" not in response_data: return response_data result_data = response_data["data"].get("result", []) if len(result_data) > MAX_TIME_SERIES: result_data = result_data[:MAX_TIME_SERIES] if "warnings" not in response_data["data"]: response_data["data"]["warnings"] = [] response_data["data"]["warnings"].append( "Result set truncated. Only the first 50 series are displayed. " "Please refine your PromQL to narrow the scope." ) response_data["data"]["result"] = result_data return response_data async def list_metric_names( self, match: str | None = None, start: str | None = None, end: str | None = None, limit: int | None = None, timeout: float = DISCOVERY_TIMEOUT, ) -> list[str]: """ Fetch all known metric names from Prometheus (GET /api/v1/label/__name__/values). Args: timeout: Request timeout in seconds (default: 15.0 per spec). Returns: List of metric name strings (unsorted; caller may filter/truncate). Raises: AuthorizationError: If authorization fails. NetworkError: If backend is unreachable. PrometheusTimeoutError: If request exceeds timeout. PrometheusAPIError: If API returns an error. """ params: Dict[str, Any] = {} if match: params["match[]"] = match if start: params["start"] = start if end: params["end"] = end if limit is not None: params["limit"] = limit data = await self._request( "/api/v1/label/__name__/values", params, timeout=timeout, timeout_message="Metric discovery request exceeded 15-second timeout.", api_error_message="Metric discovery failed.", ) return data.get("data", []) async def get_metadata( self, metric: str | None = None, limit: int | None = None, timeout: float = DISCOVERY_TIMEOUT, ) -> Dict[str, Any]: """ Fetch metric metadata from Prometheus (GET /api/v1/metadata). Returns type, help text, and unit for each metric. Args: metric: Optional exact metric name to look up. limit: Optional max number of metrics to return. timeout: Request timeout in seconds (default: 15.0). Returns: Dict mapping metric names to lists of metadata entries, e.g. {"http_requests_total": [{"type": "counter", "help": "...", "unit": ""}]}. """ params: Dict[str, Any] = {} if metric: params["metric"] = metric if limit is not None: params["limit"] = limit data = await self._request( "/api/v1/metadata", params, timeout=timeout, timeout_message="Metadata request exceeded timeout.", api_error_message="Metadata discovery failed.", ) return data.get("data", {}) async def list_labels( self, match: str | None = None, start: str | None = None, end: str | None = None, limit: int | None = None, timeout: float = DISCOVERY_TIMEOUT, ) -> list[str]: """Fetch label names from Prometheus (GET /api/v1/labels).""" params: Dict[str, Any] = {} if match: params["match[]"] = match if start: params["start"] = start if end: params["end"] = end if limit is not None: params["limit"] = limit data = await self._request( "/api/v1/labels", params, timeout=timeout, timeout_message="Label discovery request exceeded timeout.", api_error_message="Label discovery failed.", ) return data.get("data", []) async def list_label_values( self, label: str, match: str | None = None, start: str | None = None, end: str | None = None, limit: int | None = None, timeout: float = DISCOVERY_TIMEOUT, ) -> list[str]: """Fetch values for one label (GET /api/v1/label/