"""Loki API client service for querying log data.""" import logging import re from datetime import datetime, timezone from typing import Any, Dict, List, Optional import httpx from src.utils.errors import ( AuthorizationError, LokiAPIError, LokiTimeoutError, NetworkError, ) logger = logging.getLogger(__name__) MAX_LOG_ENTRIES = 10000 DEFAULT_QUERY_TIMEOUT = 30.0 def _convert_to_unix_timestamp(ts: str) -> str: """ Convert RFC3339 timestamp to Unix timestamp (seconds). Handles UTC designator (Z), timezone offsets (e.g. +08:00, -05:30), and naive timestamps (treated as UTC). Examples: '2026-03-09T14:37:03+08:00' -> converted from +08:00 to UTC timestamp '2026-03-09T14:37:03Z' -> UTC timestamp """ ts_input = ts.strip() if not ts_input: return ts try: ts_normalized = ts_input if ts_normalized.endswith("Z"): ts_normalized = ts_normalized[:-1] + "+00:00" # Support offsets like +0800 and +08 in addition to +08:00. ts_normalized = re.sub(r"([+-]\d{2})(\d{2})$", r"\1:\2", ts_normalized) ts_normalized = re.sub(r"([+-]\d{2})$", r"\1:00", ts_normalized) dt = datetime.fromisoformat(ts_normalized) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) else: dt = dt.astimezone(timezone.utc) return str(int(dt.timestamp())) except ValueError: logger.warning(f"Failed to parse timestamp '{ts_input}', using as-is") return ts class LokiClient: """Async HTTP client for Loki API.""" def __init__( self, base_url: str, timeout: float = DEFAULT_QUERY_TIMEOUT, token: Optional[str] = None, ): """ Initialize Loki client. Args: base_url: Loki base URL (e.g. from observability service or auth resolution). timeout: Request timeout in seconds (default: 30.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 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: full_url = str(client.build_request("GET", path, params=params).url) logger.info(f"Loki request: GET {full_url}") logger.info(f"Loki request params: {params}") try: response = await client.get(path, params=params) logger.info(f"Loki response status: {response.status_code}") except httpx.TimeoutException as e: raise LokiTimeoutError(timeout_message) from e except (httpx.NetworkError, httpx.ConnectError, httpx.ConnectTimeout) as e: raise NetworkError( "Loki backend is unreachable. Please verify the observability URL " "and network connectivity." ) from e if response.status_code == 401: raise AuthorizationError("Access denied: Unauthorized") data = response.json() logger.info(f"Loki response data keys: {data.keys()}") if data.get("status") == "error": error_type = data.get("errorType", "unknown") error_msg = data.get("error", "Unknown error") raise LokiAPIError( api_error_message, loki_error=f"{error_type}: {error_msg}", ) return data async def query_range( self, query: str, start: str, end: str, limit: int = 100, direction: str = "backward", step: Optional[str] = None, ) -> Dict[str, Any]: """ Execute a Loki range query. Args: query: LogQL query string start: Start timestamp (RFC3339) end: End timestamp (RFC3339) limit: Maximum number of log entries to return (default: 100) direction: Query direction - "forward" or "backward" (default: "backward") step: Optional step duration for quantization (e.g., "15s", "1m") Returns: Loki API response dictionary Raises: AuthorizationError: If authorization fails NetworkError: If network connection fails LokiTimeoutError: If request times out LokiAPIError: If Loki returns an error """ start_ts = _convert_to_unix_timestamp(start) end_ts = _convert_to_unix_timestamp(end) params: Dict[str, Any] = { "query": query, "start": start_ts, "end": end_ts, "limit": limit, "direction": direction, } if step is not None: params["step"] = step logger.info( f"Loki query_range called with query='{query}', start='{start}' ({start_ts}), " f"end='{end}' ({end_ts}), limit={limit}, direction='{direction}'" ) response_data = await self._request( "/loki/api/v1/query_range", params, timeout=30.0, timeout_message="Log query exceeded 30-second timeout", api_error_message="Invalid log query parameters", ) if "data" not in response_data: return response_data result_data = response_data["data"].get("result", []) warnings = [] if len(result_data) > MAX_LOG_ENTRIES: result_data = result_data[:MAX_LOG_ENTRIES] warnings.append(f"Results truncated to {MAX_LOG_ENTRIES} log entries (limit exceeded)") 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, limit: int = 100, direction: str = "backward", timeout: float = 10.0, ) -> Dict[str, Any]: """ Execute a Loki instant query at a single point in time. Args: query: LogQL query string time: Optional evaluation timestamp (RFC3339 or Unix). Defaults to now. limit: Maximum number of log entries to return (default: 100) direction: Query direction - "forward" or "backward" (default: "backward") timeout: Request timeout in seconds (default: 10.0) Returns: Loki API response dictionary Raises: AuthorizationError: If authorization fails NetworkError: If network connection fails LokiTimeoutError: If request times out LokiAPIError: If Loki returns an error """ params: Dict[str, Any] = { "query": query, "limit": limit, "direction": direction, } if time is not None and time.strip(): params["time"] = _convert_to_unix_timestamp(time) response_data = await self._request( "/loki/api/v1/query", params, timeout=timeout, timeout_message="Query exceeded 10-second timeout. Consider simplifying the query.", api_error_message="Invalid LogQL syntax.", ) return response_data async def list_labels( self, start: Optional[str] = None, end: Optional[str] = None, query: Optional[str] = None, timeout: float = 15.0, ) -> List[str]: """ Fetch all label names from Loki (GET /loki/api/v1/labels). Args: start: Optional start timestamp (RFC3339) end: Optional end timestamp (RFC3339) query: Optional LogQL query to filter labels timeout: Request timeout in seconds (default: 15.0) Returns: List of label name strings Raises: AuthorizationError: If authorization fails NetworkError: If backend is unreachable LokiTimeoutError: If request exceeds timeout LokiAPIError: If API returns an error """ params: Dict[str, Any] = {} if start is not None: params["start"] = _convert_to_unix_timestamp(start) if end is not None: params["end"] = _convert_to_unix_timestamp(end) if query is not None: params["query"] = query data = await self._request( "/loki/api/v1/labels", params, timeout=timeout, timeout_message="Label discovery request exceeded 15-second timeout.", api_error_message="Label discovery failed.", ) return data.get("data", []) async def get_label_values( self, label_name: str, start: Optional[str] = None, end: Optional[str] = None, query: Optional[str] = None, timeout: float = 15.0, ) -> List[str]: """ Fetch all values for a specific label from Loki (GET /loki/api/v1/label//values). Args: label_name: Name of the label to get values for start: Optional start timestamp (RFC3339) end: Optional end timestamp (RFC3339) query: Optional LogQL query to filter values timeout: Request timeout in seconds (default: 15.0) Returns: List of label value strings Raises: AuthorizationError: If authorization fails NetworkError: If backend is unreachable LokiTimeoutError: If request exceeds timeout LokiAPIError: If API returns an error """ params: Dict[str, Any] = {} if start is not None: params["start"] = _convert_to_unix_timestamp(start) if end is not None: params["end"] = _convert_to_unix_timestamp(end) if query is not None: params["query"] = query data = await self._request( f"/loki/api/v1/label/{label_name}/values", params, timeout=timeout, timeout_message="Label values request exceeded 15-second timeout.", api_error_message="Label values discovery failed.", ) return data.get("data", []) async def list_series( self, match: str, start: Optional[str] = None, end: Optional[str] = None, timeout: float = 15.0, ) -> List[Dict[str, str]]: """ Fetch series metadata from Loki (GET /loki/api/v1/series). Args: match: LogQL query pattern to match series start: Optional start timestamp (RFC3339) end: Optional end timestamp (RFC3339) timeout: Request timeout in seconds (default: 15.0) Returns: List of series label dictionaries Raises: AuthorizationError: If authorization fails NetworkError: If backend is unreachable LokiTimeoutError: If request exceeds timeout LokiAPIError: If API returns an error """ params: Dict[str, Any] = {"match": match} if start is not None: params["start"] = _convert_to_unix_timestamp(start) if end is not None: params["end"] = _convert_to_unix_timestamp(end) data = await self._request( "/loki/api/v1/series", params, timeout=timeout, timeout_message="Series discovery request exceeded 15-second timeout.", api_error_message="Series discovery failed.", ) return data.get("data", [])