diff --git a/services/service-adapters/main.py b/services/service-adapters/main.py index f13bf49..51804ff 100644 --- a/services/service-adapters/main.py +++ b/services/service-adapters/main.py @@ -12,6 +12,7 @@ from services.status_checker import status_checker # Set up unified logging for both application and request logs setup_logging(level="INFO", enable_request_logging=True) + @asynccontextmanager async def lifespan(app: FastAPI): """Manage application lifespan events.""" @@ -61,8 +62,8 @@ if __name__ == "__main__": # Configure uvicorn to use our unified logging uvicorn.run( - app, - host="127.0.0.1", + app, + host="127.0.0.1", port=8001, log_config=None, # Disable uvicorn's default logging config access_log=True, # Enable access logging diff --git a/services/service-adapters/middleware/logging_middleware.py b/services/service-adapters/middleware/logging_middleware.py index e00b471..7a49fc5 100644 --- a/services/service-adapters/middleware/logging_middleware.py +++ b/services/service-adapters/middleware/logging_middleware.py @@ -5,7 +5,6 @@ This module provides custom logging middleware for FastAPI requests to ensure consistent logging format with application logs. """ -import logging import time from typing import Callable @@ -19,57 +18,48 @@ logger = get_request_logger() class LoggingMiddleware(BaseHTTPMiddleware): """Custom logging middleware for unified request logging.""" - + async def dispatch(self, request: Request, call_next: Callable) -> Response: """ Log each request with unified formatting. - + Args: request: The incoming request call_next: The next middleware/handler in the chain - + Returns: The response """ # Start timing start_time = time.time() - + # Extract request information method = request.method url_path = request.url.path client_ip = request.client.host if request.client else "unknown" user_agent = request.headers.get("user-agent", "unknown") - + # Log request start - logger.info( - f"Request started: {method} {url_path} from {client_ip} " - f"(User-Agent: {user_agent})" - ) - + logger.info(f"Request started: {method} {url_path} from {client_ip} " f"(User-Agent: {user_agent})") + try: # Process the request response = await call_next(request) - + # Calculate processing time process_time = time.time() - start_time - + # Log successful response - logger.info( - f"Request completed: {method} {url_path} -> " - f"{response.status_code} in {process_time:.3f}s" - ) - + logger.info(f"Request completed: {method} {url_path} -> " f"{response.status_code} in {process_time:.3f}s") + return response - + except Exception as e: # Calculate processing time for failed requests process_time = time.time() - start_time - + # Log error - logger.error( - f"Request failed: {method} {url_path} -> " - f"Exception: {str(e)} in {process_time:.3f}s" - ) - + logger.error(f"Request failed: {method} {url_path} -> " f"Exception: {str(e)} in {process_time:.3f}s") + # Re-raise the exception raise diff --git a/services/service-adapters/routes/general.py b/services/service-adapters/routes/general.py index db791f3..75d981c 100644 --- a/services/service-adapters/routes/general.py +++ b/services/service-adapters/routes/general.py @@ -51,17 +51,19 @@ async def debug_logging(): logger.info("This is an INFO message from routes.general") logger.warning("This is a WARNING message from routes.general") logger.error("This is an ERROR message from routes.general") - + # Test request logger from services.logging_config import get_request_logger + request_logger = get_request_logger() request_logger.info("This is a request logger message") - + # Test application logger from services.logging_config import get_application_logger + app_logger = get_application_logger() app_logger.info("This is an application logger message") - + # Get current logging configuration root_logger = logging.getLogger() config_info = { @@ -74,13 +76,9 @@ async def debug_logging(): "application_logger_level": logging.getLevelName(app_logger.level), "uvicorn_access_level": logging.getLevelName(logging.getLogger("uvicorn.access").level), } - + logger.info("Unified logging debug info requested") - return { - "message": "Unified log messages sent to console", - "config": config_info, - "note": "All logs now use the same format and handler" - } + return {"message": "Unified log messages sent to console", "config": config_info, "note": "All logs now use the same format and handler"} @router.get( @@ -93,27 +91,22 @@ async def debug_sensor(service_name: str): """Debug endpoint to inspect raw sensor data""" from services.config import SERVICES from services.health_checkers import factory - + if service_name not in SERVICES: return {"error": f"Service {service_name} not found"} - + config = SERVICES[service_name] if config.get("health_check_type") != "sensor": return {"error": f"Service {service_name} is not using sensor health checking"} - + try: # Create sensor checker checker = factory.create_checker("sensor", timeout=10.0) - + # Get raw sensor data result = await checker.check_health(service_name, config) - - return { - "service_name": service_name, - "config": config, - "result": result.to_dict(), - "raw_sensor_data": result.metadata - } + + return {"service_name": service_name, "config": config, "result": result.to_dict(), "raw_sensor_data": result.metadata} except Exception as e: logger.error(f"Error debugging sensor for {service_name}: {e}") return {"error": str(e)} @@ -129,10 +122,10 @@ async def debug_sensor(service_name: str): async def get_services(): """Get status of all configured external services (Home Assistant, Frigate, Immich, n8n)""" logger.info("Service status endpoint called - checking all services") - + # Check all services concurrently status_results = await status_checker.check_all_services() - + service_status = {} for service_name, config in SERVICES.items(): status_info = status_results.get(service_name, {}) @@ -143,8 +136,8 @@ async def get_services(): response_time=status_info.get("response_time"), error=status_info.get("error"), uptime=status_info.get("uptime"), - metadata=status_info.get("metadata", {}) + metadata=status_info.get("metadata", {}), ) - + logger.info(f"Service status check completed - returning status for {len(service_status)} services") return service_status diff --git a/services/service-adapters/services/config.py b/services/service-adapters/services/config.py index 26bd465..c95e2ef 100644 --- a/services/service-adapters/services/config.py +++ b/services/service-adapters/services/config.py @@ -1,4 +1,3 @@ -from operator import truediv import os from dotenv import load_dotenv @@ -10,7 +9,12 @@ load_dotenv() SERVICES = { "home_assistant": { "url": os.getenv("HOME_ASSISTANT_URL", "http://192.168.2.158:8123"), - "token": os.getenv("HOME_ASSISTANT_TOKEN", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI3MjdiY2QwMjNkNmM0NzgzYmRiMzg2ZDYxYzQ3N2NmYyIsImlhdCI6MTc1ODE4MDg2MiwiZXhwIjoyMDczNTQwODYyfQ.rN_dBtYmXIo4J1DffgWb6G0KLsgaQ6_kH-kiWJeQQQM"), + "token": os.getenv( + "HOME_ASSISTANT_TOKEN", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + "eyJpc3MiOiI3MjdiY2QwMjNkNmM0NzgzYmRiMzg2ZDYxYzQ3N2NmYyIsImlhdCI6MTc1ODE4MDg2MiwiZXhwIjoyMDczNTQwODYyfQ." + "rN_dBtYmXIo4J1DffgWb6G0KLsgaQ6_kH-kiWJeQQQM", + ), "enabled": True, "health_check_type": "sensor", # Use sensor-based health checking "sensor_entity": "sensor.uptime_34", # Check uptime sensor diff --git a/services/service-adapters/services/health_checkers/api_checker.py b/services/service-adapters/services/health_checkers/api_checker.py index 34abc0b..294a4a7 100644 --- a/services/service-adapters/services/health_checkers/api_checker.py +++ b/services/service-adapters/services/health_checkers/api_checker.py @@ -11,32 +11,29 @@ from typing import Any, Dict, Optional import httpx from httpx import HTTPError, TimeoutException -from .base import BaseHealthChecker, HealthCheckResult from utils.time_formatter import format_uptime_for_frontend +from .base import BaseHealthChecker, HealthCheckResult + logger = logging.getLogger(__name__) class APIHealthChecker(BaseHealthChecker): """Health checker for services with API health endpoints.""" - - async def check_health( - self, - service_name: str, - config: Dict[str, Any] - ) -> HealthCheckResult: + + async def check_health(self, service_name: str, config: Dict[str, Any]) -> HealthCheckResult: """ Check health via API endpoint. - + Args: service_name: Name of the service config: Service configuration - + Returns: HealthCheckResult with status information """ logger.debug(f"Starting API health check for {service_name}") - + if not config.get("enabled", False): logger.debug(f"Service {service_name} is disabled") return HealthCheckResult("disabled") @@ -49,13 +46,13 @@ class APIHealthChecker(BaseHealthChecker): # Get health endpoint from config or use default health_endpoint = config.get("health_endpoint", "/") health_url = f"{url.rstrip('/')}{health_endpoint}" - + logger.debug(f"Checking {service_name} at {health_url}") try: start_time = time.time() headers = self._get_auth_headers(service_name, config) - + response = await self.client.get(health_url, headers=headers) response_time = time.time() - start_time @@ -65,16 +62,12 @@ class APIHealthChecker(BaseHealthChecker): if response.status_code == 200: # Check if response body indicates health health_status = self._parse_health_response(response, service_name) - + # Try to extract uptime from response uptime_info = self._extract_uptime_from_response(response, service_name) formatted_uptime = format_uptime_for_frontend(uptime_info) - - metadata = { - "http_status": response.status_code, - "response_size": len(response.content), - "health_status": health_status - } + + metadata = {"http_status": response.status_code, "response_size": len(response.content), "health_status": health_status} return HealthCheckResult("healthy", response_time, metadata=metadata, uptime=formatted_uptime) elif response.status_code == 401: logger.warning(f"Service {service_name} returned 401 - authentication required") @@ -99,18 +92,18 @@ class APIHealthChecker(BaseHealthChecker): def _parse_health_response(self, response: httpx.Response, service_name: str) -> str: """ Parse health response to determine actual health status. - + Args: response: HTTP response service_name: Name of the service - + Returns: Health status string """ try: # Try to parse JSON response data = response.json() - + # Service-specific health parsing if service_name == "home_assistant": # Home Assistant returns {"message": "API running."} for healthy @@ -127,7 +120,7 @@ class APIHealthChecker(BaseHealthChecker): else: # Generic check - if we got JSON, assume healthy return "healthy" - + except Exception as e: logger.debug(f"Could not parse JSON response from {service_name}: {e}") # If we can't parse JSON but got 200, assume healthy @@ -136,17 +129,17 @@ class APIHealthChecker(BaseHealthChecker): def _extract_uptime_from_response(self, response: httpx.Response, service_name: str) -> Optional[str]: """ Extract uptime information from API response. - + Args: response: HTTP response service_name: Name of the service - + Returns: Uptime information string or None """ try: data = response.json() - + # Service-specific uptime extraction if service_name == "frigate": # Frigate might have uptime in version response @@ -160,7 +153,7 @@ class APIHealthChecker(BaseHealthChecker): else: # Generic uptime extraction return data.get("uptime") or data.get("uptime_seconds") - + except Exception as e: logger.debug(f"Could not extract uptime from {service_name} response: {e}") return None diff --git a/services/service-adapters/services/health_checkers/base.py b/services/service-adapters/services/health_checkers/base.py index fc1e718..ab7abb7 100644 --- a/services/service-adapters/services/health_checkers/base.py +++ b/services/service-adapters/services/health_checkers/base.py @@ -7,7 +7,7 @@ health checking strategies. import logging from abc import ABC, abstractmethod -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, Optional import httpx @@ -16,14 +16,14 @@ logger = logging.getLogger(__name__) class HealthCheckResult: """Result of a health check operation.""" - + def __init__( self, status: str, response_time: Optional[float] = None, error: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, - uptime: Optional[str] = None + uptime: Optional[str] = None, ): self.status = status self.response_time = response_time @@ -33,22 +33,16 @@ class HealthCheckResult: def to_dict(self) -> Dict[str, Any]: """Convert result to dictionary.""" - return { - "status": self.status, - "response_time": self.response_time, - "error": self.error, - "uptime": self.uptime, - "metadata": self.metadata - } + return {"status": self.status, "response_time": self.response_time, "error": self.error, "uptime": self.uptime, "metadata": self.metadata} class BaseHealthChecker(ABC): """Abstract base class for health checkers.""" - + def __init__(self, timeout: float = 5.0): """ Initialize the health checker. - + Args: timeout: Request timeout in seconds """ @@ -57,18 +51,14 @@ class BaseHealthChecker(ABC): logger.debug(f"Initialized {self.__class__.__name__} with timeout: {timeout}s") @abstractmethod - async def check_health( - self, - service_name: str, - config: Dict[str, Any] - ) -> HealthCheckResult: + async def check_health(self, service_name: str, config: Dict[str, Any]) -> HealthCheckResult: """ Check the health of a service. - + Args: service_name: Name of the service config: Service configuration - + Returns: HealthCheckResult with status information """ @@ -77,16 +67,16 @@ class BaseHealthChecker(ABC): def _get_auth_headers(self, service_name: str, config: Dict[str, Any]) -> Dict[str, str]: """ Get authentication headers for the service. - + Args: service_name: Name of the service config: Service configuration - + Returns: Dictionary of headers """ headers = {"User-Agent": "LabFusion-ServiceAdapters/1.0.0"} - + # Service-specific authentication if service_name == "home_assistant" and config.get("token"): headers["Authorization"] = f"Bearer {config['token']}" @@ -96,7 +86,7 @@ class BaseHealthChecker(ABC): headers["X-API-Key"] = config["api_key"] elif service_name == "n8n" and config.get("api_key"): headers["X-API-Key"] = config["api_key"] - + return headers async def close(self): diff --git a/services/service-adapters/services/health_checkers/custom_checker.py b/services/service-adapters/services/health_checkers/custom_checker.py index 3740248..4fc9f69 100644 --- a/services/service-adapters/services/health_checkers/custom_checker.py +++ b/services/service-adapters/services/health_checkers/custom_checker.py @@ -9,9 +9,6 @@ import logging import time from typing import Any, Dict, List -import httpx -from httpx import HTTPError, TimeoutException - from .base import BaseHealthChecker, HealthCheckResult logger = logging.getLogger(__name__) @@ -19,24 +16,20 @@ logger = logging.getLogger(__name__) class CustomHealthChecker(BaseHealthChecker): """Health checker for services requiring custom health check logic.""" - - async def check_health( - self, - service_name: str, - config: Dict[str, Any] - ) -> HealthCheckResult: + + async def check_health(self, service_name: str, config: Dict[str, Any]) -> HealthCheckResult: """ Check health using custom logic. - + Args: service_name: Name of the service config: Service configuration - + Returns: HealthCheckResult with status information """ logger.debug(f"Starting custom health check for {service_name}") - + if not config.get("enabled", False): logger.debug(f"Service {service_name} is disabled") return HealthCheckResult("disabled") @@ -50,44 +43,40 @@ class CustomHealthChecker(BaseHealthChecker): # Run all health checks results = [] overall_start_time = time.time() - + for check_config in health_checks: check_result = await self._run_single_check(service_name, check_config) results.append(check_result) - + overall_response_time = time.time() - overall_start_time - + # Determine overall health status overall_status = self._determine_overall_status(results) - + metadata = { "total_checks": len(health_checks), "check_results": [result.to_dict() for result in results], - "overall_response_time": overall_response_time + "overall_response_time": overall_response_time, } - + return HealthCheckResult(overall_status, overall_response_time, metadata=metadata) - async def _run_single_check( - self, - service_name: str, - check_config: Dict[str, Any] - ) -> HealthCheckResult: + async def _run_single_check(self, service_name: str, check_config: Dict[str, Any]) -> HealthCheckResult: """ Run a single health check. - + Args: service_name: Name of the service check_config: Configuration for this specific check - + Returns: HealthCheckResult for this check """ check_type = check_config.get("type", "api") check_name = check_config.get("name", "unknown") - + logger.debug(f"Running {check_type} check '{check_name}' for {service_name}") - + if check_type == "api": return await self._api_check(service_name, check_config) elif check_type == "sensor": @@ -103,19 +92,19 @@ class CustomHealthChecker(BaseHealthChecker): url = check_config.get("url") if not url: return HealthCheckResult("error", error="No URL in check config") - + try: start_time = time.time() headers = self._get_auth_headers(service_name, check_config) - + response = await self.client.get(url, headers=headers) response_time = time.time() - start_time - + if response.status_code == 200: return HealthCheckResult("healthy", response_time) else: return HealthCheckResult("unhealthy", response_time, f"HTTP {response.status_code}") - + except Exception as e: return HealthCheckResult("error", error=str(e)) @@ -126,11 +115,11 @@ class CustomHealthChecker(BaseHealthChecker): sensor_entity = check_config.get("sensor_entity") if not sensor_entity: return HealthCheckResult("error", error="No sensor_entity in check config") - + # Build sensor URL base_url = check_config.get("url", "") sensor_url = f"{base_url.rstrip('/')}/api/states/{sensor_entity}" - + # Update check config with sensor URL check_config["url"] = sensor_url return await self._api_check(service_name, check_config) @@ -144,22 +133,22 @@ class CustomHealthChecker(BaseHealthChecker): def _determine_overall_status(self, results: List[HealthCheckResult]) -> str: """ Determine overall health status from multiple check results. - + Args: results: List of individual check results - + Returns: Overall health status """ if not results: return "error" - + # Count statuses - status_counts = {} + status_counts: Dict[str, int] = {} for result in results: status = result.status status_counts[status] = status_counts.get(status, 0) + 1 - + # Determine overall status based on priority if status_counts.get("healthy", 0) == len(results): return "healthy" diff --git a/services/service-adapters/services/health_checkers/registry.py b/services/service-adapters/services/health_checkers/registry.py index 317e62e..53c714e 100644 --- a/services/service-adapters/services/health_checkers/registry.py +++ b/services/service-adapters/services/health_checkers/registry.py @@ -5,7 +5,7 @@ This module provides a registry and factory for different health checker types. """ import logging -from typing import Any, Dict, Type +from typing import Any, Dict, Optional, Type from .api_checker import APIHealthChecker from .base import BaseHealthChecker @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) class HealthCheckerRegistry: """Registry for health checker types.""" - + def __init__(self): """Initialize the registry with default checkers.""" self._checkers: Dict[str, Type[BaseHealthChecker]] = { @@ -30,7 +30,7 @@ class HealthCheckerRegistry: def register(self, name: str, checker_class: Type[BaseHealthChecker]) -> None: """ Register a new health checker type. - + Args: name: Name of the checker type checker_class: Health checker class @@ -41,26 +41,26 @@ class HealthCheckerRegistry: def get_checker(self, name: str) -> Type[BaseHealthChecker]: """ Get a health checker class by name. - + Args: name: Name of the checker type - + Returns: Health checker class - + Raises: ValueError: If checker type not found """ if name not in self._checkers: available = ", ".join(self._checkers.keys()) raise ValueError(f"Unknown health checker type '{name}'. Available: {available}") - + return self._checkers[name] def list_checkers(self) -> list[str]: """ List all available health checker types. - + Returns: List of checker type names """ @@ -69,29 +69,25 @@ class HealthCheckerRegistry: class HealthCheckerFactory: """Factory for creating health checker instances.""" - - def __init__(self, registry: HealthCheckerRegistry = None): + + def __init__(self, registry: Optional[HealthCheckerRegistry] = None): """ Initialize the factory. - + Args: registry: Health checker registry (uses default if None) """ self.registry = registry or HealthCheckerRegistry() logger.debug("Initialized health checker factory") - def create_checker( - self, - checker_type: str, - timeout: float = 5.0 - ) -> BaseHealthChecker: + def create_checker(self, checker_type: str, timeout: float = 5.0) -> BaseHealthChecker: """ Create a health checker instance. - + Args: checker_type: Type of checker to create timeout: Request timeout in seconds - + Returns: Health checker instance """ @@ -100,32 +96,27 @@ class HealthCheckerFactory: logger.debug(f"Created {checker_type} health checker with timeout {timeout}s") return checker - def create_checker_for_service( - self, - service_name: str, - config: Dict[str, Any], - timeout: float = 5.0 - ) -> BaseHealthChecker: + def create_checker_for_service(self, service_name: str, config: Dict[str, Any], timeout: float = 5.0) -> BaseHealthChecker: """ Create a health checker for a specific service based on its configuration. - + Args: service_name: Name of the service config: Service configuration timeout: Request timeout in seconds - + Returns: Health checker instance """ # Determine checker type from config checker_type = config.get("health_check_type", "api") - + # Override based on service-specific logic if service_name == "home_assistant" and config.get("sensor_entity"): checker_type = "sensor" elif config.get("health_checks"): checker_type = "custom" - + logger.debug(f"Creating {checker_type} checker for {service_name}") return self.create_checker(checker_type, timeout) diff --git a/services/service-adapters/services/health_checkers/sensor_checker.py b/services/service-adapters/services/health_checkers/sensor_checker.py index ec5c6d0..7dc3b56 100644 --- a/services/service-adapters/services/health_checkers/sensor_checker.py +++ b/services/service-adapters/services/health_checkers/sensor_checker.py @@ -9,35 +9,31 @@ import logging import time from typing import Any, Dict, Optional -import httpx from httpx import HTTPError, TimeoutException -from .base import BaseHealthChecker, HealthCheckResult from utils.time_formatter import format_uptime_for_frontend +from .base import BaseHealthChecker, HealthCheckResult + logger = logging.getLogger(__name__) class SensorHealthChecker(BaseHealthChecker): """Health checker for services with sensor-based health information.""" - - async def check_health( - self, - service_name: str, - config: Dict[str, Any] - ) -> HealthCheckResult: + + async def check_health(self, service_name: str, config: Dict[str, Any]) -> HealthCheckResult: """ Check health via sensor data. - + Args: service_name: Name of the service config: Service configuration - + Returns: HealthCheckResult with status information """ logger.debug(f"Starting sensor health check for {service_name}") - + if not config.get("enabled", False): logger.debug(f"Service {service_name} is disabled") return HealthCheckResult("disabled") @@ -55,13 +51,13 @@ class SensorHealthChecker(BaseHealthChecker): # Build sensor API URL sensor_url = f"{url.rstrip('/')}/api/states/{sensor_entity}" - + logger.debug(f"Checking {service_name} sensor {sensor_entity} at {sensor_url}") try: start_time = time.time() headers = self._get_auth_headers(service_name, config) - + response = await self.client.get(sensor_url, headers=headers) response_time = time.time() - start_time @@ -71,24 +67,24 @@ class SensorHealthChecker(BaseHealthChecker): # Parse sensor data sensor_data = response.json() logger.debug(f"Raw sensor data for {service_name}: {sensor_data}") - + health_status = self._parse_sensor_data(sensor_data, service_name) logger.info(f"Parsed health status for {service_name}: {health_status}") - + # Extract uptime information for top-level field uptime_info = self._extract_uptime_info(sensor_data, service_name) # Format uptime for frontend display formatted_uptime = format_uptime_for_frontend(uptime_info) - + metadata = { "http_status": response.status_code, "sensor_entity": sensor_entity, "sensor_state": sensor_data.get("state"), "sensor_attributes": sensor_data.get("attributes", {}), "last_updated": sensor_data.get("last_updated"), - "entity_id": sensor_data.get("entity_id") + "entity_id": sensor_data.get("entity_id"), } - + return HealthCheckResult(health_status, response_time, metadata=metadata, uptime=formatted_uptime) elif response.status_code == 401: logger.warning(f"Service {service_name} returned 401 - authentication required") @@ -113,11 +109,11 @@ class SensorHealthChecker(BaseHealthChecker): def _parse_sensor_data(self, sensor_data: Dict[str, Any], service_name: str) -> str: """ Parse sensor data to determine health status. - + Args: sensor_data: Sensor data from API service_name: Name of the service - + Returns: Health status string """ @@ -125,9 +121,9 @@ class SensorHealthChecker(BaseHealthChecker): state = sensor_data.get("state", "") entity_id = sensor_data.get("entity_id", "").lower() attributes = sensor_data.get("attributes", {}) - + logger.debug(f"Parsing sensor data for {service_name}: entity_id={entity_id}, state={state}") - + # Service-specific sensor parsing if service_name == "home_assistant": # For HA, check uptime sensor or system health @@ -138,10 +134,12 @@ class SensorHealthChecker(BaseHealthChecker): # Timestamp sensor - if it has a valid timestamp, service is healthy try: from datetime import datetime + # Try to parse the timestamp - parsed_time = datetime.fromisoformat(state.replace('Z', '+00:00')) + parsed_time = datetime.fromisoformat(state.replace("Z", "+00:00")) # If we can parse it and it's recent (within last 24 hours), it's healthy from datetime import datetime, timezone + now = datetime.now(timezone.utc) time_diff = now - parsed_time is_healthy = time_diff.total_seconds() < 86400 # 24 hours @@ -176,7 +174,7 @@ class SensorHealthChecker(BaseHealthChecker): is_healthy = state.lower() not in ["unavailable", "unknown", "off", "error"] logger.debug(f"Generic sensor: state={state}, healthy: {is_healthy}") return "healthy" if is_healthy else "unhealthy" - + except Exception as e: logger.error(f"Could not parse sensor data from {service_name}: {e}") return "unhealthy" @@ -184,11 +182,11 @@ class SensorHealthChecker(BaseHealthChecker): def _extract_uptime_info(self, sensor_data: Dict[str, Any], service_name: str) -> Optional[str]: """ Extract uptime information from sensor data for top-level display. - + Args: sensor_data: Sensor data from API service_name: Name of the service - + Returns: Uptime information string or None """ @@ -196,7 +194,7 @@ class SensorHealthChecker(BaseHealthChecker): state = sensor_data.get("state", "") entity_id = sensor_data.get("entity_id", "").lower() attributes = sensor_data.get("attributes", {}) - + if service_name == "home_assistant" and "uptime" in entity_id: device_class = attributes.get("device_class", "") if device_class == "timestamp": @@ -214,7 +212,7 @@ class SensorHealthChecker(BaseHealthChecker): if "uptime" in entity_id or "duration" in entity_id.lower(): return state return None - + except Exception as e: logger.debug(f"Could not extract uptime info from {service_name}: {e}") return None diff --git a/services/service-adapters/services/logging_config.py b/services/service-adapters/services/logging_config.py index 4d85235..82bfff2 100644 --- a/services/service-adapters/services/logging_config.py +++ b/services/service-adapters/services/logging_config.py @@ -14,14 +14,11 @@ DEFAULT_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno def setup_logging( - level: str = "INFO", - format_string: Optional[str] = None, - include_timestamp: bool = True, - enable_request_logging: bool = True + level: str = "INFO", format_string: Optional[str] = None, include_timestamp: bool = True, enable_request_logging: bool = True ) -> None: """ Set up unified logging configuration for the application and requests. - + Args: level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) format_string: Custom format string for log messages @@ -42,7 +39,7 @@ def setup_logging( # Create a single handler for all logs handler = logging.StreamHandler(sys.stdout) handler.setFormatter(logging.Formatter(format_string)) - + # Configure root logger root_logger.setLevel(getattr(logging, level.upper())) root_logger.addHandler(handler) @@ -82,7 +79,7 @@ def setup_logging( def _setup_request_logging(handler: logging.Handler) -> None: """ Set up FastAPI request logging with the same handler. - + Args: handler: The logging handler to use for requests """ @@ -102,10 +99,10 @@ def _setup_request_logging(handler: logging.Handler) -> None: def get_logger(name: str) -> logging.Logger: """ Get a logger instance for the given name. - + Args: name: Logger name (usually __name__) - + Returns: Logger instance """ @@ -115,7 +112,7 @@ def get_logger(name: str) -> logging.Logger: def get_request_logger() -> logging.Logger: """ Get the request logger for FastAPI requests. - + Returns: Request logger instance """ @@ -125,7 +122,7 @@ def get_request_logger() -> logging.Logger: def get_application_logger() -> logging.Logger: """ Get the main application logger. - + Returns: Application logger instance """ diff --git a/services/service-adapters/services/status_checker.py b/services/service-adapters/services/status_checker.py index b6854c1..046cad1 100644 --- a/services/service-adapters/services/status_checker.py +++ b/services/service-adapters/services/status_checker.py @@ -11,6 +11,7 @@ from typing import Dict from services.config import SERVICES from services.health_checkers import factory +from services.health_checkers.base import BaseHealthChecker # Configure logger logger = logging.getLogger(__name__) @@ -22,63 +23,53 @@ class ServiceStatusChecker: def __init__(self, timeout: float = 5.0): """ Initialize the status checker. - + Args: timeout: Request timeout in seconds """ self.timeout = timeout - self.checkers = {} # Cache for checker instances + self.checkers: Dict[str, BaseHealthChecker] = {} # Cache for checker instances logger.info(f"ServiceStatusChecker initialized with timeout: {timeout}s") async def check_service_health(self, service_name: str, config: Dict) -> Dict: """ Check the health status of a specific service. - + Args: service_name: Name of the service to check config: Service configuration dictionary - + Returns: Dictionary with status information """ logger.debug(f"Starting health check for service: {service_name}") - + if not config.get("enabled", False): logger.debug(f"Service {service_name} is disabled, skipping health check") - return { - "status": "disabled", - "response_time": None, - "error": None, - "metadata": {} - } + return {"status": "disabled", "response_time": None, "error": None, "metadata": {}} try: # Get or create checker for this service checker = await self._get_checker_for_service(service_name, config) - + # Run health check result = await checker.check_health(service_name, config) - + logger.info(f"Service {service_name} health check completed: {result.status}") return result.to_dict() - + except Exception as e: logger.error(f"Unexpected error checking {service_name}: {str(e)}") - return { - "status": "error", - "response_time": None, - "error": f"Unexpected error: {str(e)}", - "metadata": {} - } + return {"status": "error", "response_time": None, "error": f"Unexpected error: {str(e)}", "metadata": {}} async def _get_checker_for_service(self, service_name: str, config: Dict): """ Get or create a health checker for the service. - + Args: service_name: Name of the service config: Service configuration - + Returns: Health checker instance """ @@ -87,18 +78,18 @@ class ServiceStatusChecker: checker = factory.create_checker_for_service(service_name, config, self.timeout) self.checkers[service_name] = checker logger.debug(f"Created new checker for {service_name}") - + return self.checkers[service_name] async def check_all_services(self) -> Dict[str, Dict]: """ Check the health status of all configured services. - + Returns: Dictionary mapping service names to their status information """ logger.info(f"Starting health check for {len(SERVICES)} services") - + tasks = [] service_names = [] @@ -109,28 +100,32 @@ class ServiceStatusChecker: logger.debug(f"Created {len(tasks)} concurrent health check tasks") results = await asyncio.gather(*tasks, return_exceptions=True) - service_status = {} + service_status: Dict[str, Dict] = {} healthy_count = 0 error_count = 0 - + for service_name, result in zip(service_names, results): if isinstance(result, Exception): logger.error(f"Exception during health check for {service_name}: {str(result)}") - service_status[service_name] = { - "status": "error", - "response_time": None, - "error": f"Exception: {str(result)}", - "metadata": {} - } + service_status[service_name] = {"status": "error", "response_time": None, "error": f"Exception: {str(result)}", "metadata": {}} error_count += 1 else: - service_status[service_name] = result - if result["status"] == "healthy": - healthy_count += 1 - elif result["status"] in ["error", "timeout", "unhealthy"]: + # result is a Dict at this point, but we need to ensure it's actually a dict + if isinstance(result, dict): + service_status[service_name] = result + if result.get("status") == "healthy": + healthy_count += 1 + elif result.get("status") in ["error", "timeout", "unhealthy"]: + error_count += 1 + else: + # This shouldn't happen, but handle it gracefully + logger.error(f"Unexpected result type for {service_name}: {type(result)}") + service_status[service_name] = {"status": "error", "response_time": None, "error": "Unexpected result type", "metadata": {}} error_count += 1 - logger.info(f"Health check completed: {healthy_count} healthy, {error_count} errors, {len(SERVICES) - healthy_count - error_count} other statuses") + logger.info( + f"Health check completed: {healthy_count} healthy, {error_count} errors, " f"{len(SERVICES) - healthy_count - error_count} other statuses" + ) return service_status async def close(self): diff --git a/services/service-adapters/utils/__init__.py b/services/service-adapters/utils/__init__.py index 31af50b..cd713b6 100644 --- a/services/service-adapters/utils/__init__.py +++ b/services/service-adapters/utils/__init__.py @@ -4,7 +4,7 @@ Utilities Package This package contains utility functions for the service adapters. """ -from .time_formatter import format_uptime_for_frontend, format_response_time +from .time_formatter import format_response_time, format_uptime_for_frontend __all__ = [ "format_uptime_for_frontend", diff --git a/services/service-adapters/utils/time_formatter.py b/services/service-adapters/utils/time_formatter.py index d76a8d3..06fb261 100644 --- a/services/service-adapters/utils/time_formatter.py +++ b/services/service-adapters/utils/time_formatter.py @@ -13,36 +13,36 @@ from typing import Optional, Union def format_uptime_for_frontend(uptime_value: Optional[str]) -> str: """ Format uptime value for frontend display in "Xd Xh Xm" format. - + Args: uptime_value: Raw uptime value (timestamp, epoch, duration string, etc.) - + Returns: Formatted uptime string like "2d 5h 30m" or "0d 0h" if invalid """ if not uptime_value: return "0d 0h" - + try: # Try to parse as timestamp (ISO format) if _is_timestamp(uptime_value): return _format_timestamp_uptime(uptime_value) - + # Try to parse as epoch timestamp if _is_epoch(uptime_value): return _format_epoch_uptime(uptime_value) - + # Try to parse as duration string (e.g., "2h 30m", "5d 2h 15m") if _is_duration_string(uptime_value): return _format_duration_string(uptime_value) - + # Try to parse as numeric seconds if _is_numeric_seconds(uptime_value): return _format_seconds_uptime(float(uptime_value)) - + # If none of the above, return as-is or default return uptime_value if len(uptime_value) < 50 else "0d 0h" - + except Exception: return "0d 0h" @@ -50,7 +50,7 @@ def format_uptime_for_frontend(uptime_value: Optional[str]) -> str: def _is_timestamp(value: str) -> bool: """Check if value is an ISO timestamp.""" try: - datetime.fromisoformat(value.replace('Z', '+00:00')) + datetime.fromisoformat(value.replace("Z", "+00:00")) return True except (ValueError, AttributeError): return False @@ -68,7 +68,7 @@ def _is_epoch(value: str) -> bool: def _is_duration_string(value: str) -> bool: """Check if value is a duration string like '2h 30m' or '5d 2h 15m'.""" # Look for patterns like "2h 30m", "5d 2h 15m", "1d 2h 3m 4s" - pattern = r'^\d+[dhms]\s*(\d+[dhms]\s*)*$' + pattern = r"^\d+[dhms]\s*(\d+[dhms]\s*)*$" return bool(re.match(pattern, value.strip())) @@ -85,14 +85,14 @@ def _format_timestamp_uptime(timestamp: str) -> str: """Format timestamp uptime (time since timestamp).""" try: # Parse timestamp - dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) + dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) - + # Calculate time difference now = datetime.now(timezone.utc) diff = now - dt - + return _format_timedelta(diff) except Exception: return "0d 0h" @@ -105,7 +105,7 @@ def _format_epoch_uptime(epoch_str: str) -> str: dt = datetime.fromtimestamp(epoch, tz=timezone.utc) now = datetime.now(timezone.utc) diff = now - dt - + return _format_timedelta(diff) except Exception: return "0d 0h" @@ -129,27 +129,27 @@ def _format_seconds_uptime(seconds: float) -> str: def _parse_duration_string(duration: str) -> float: """Parse duration string to total seconds.""" total_seconds = 0 - + # Extract days - days_match = re.search(r'(\d+)d', duration) + days_match = re.search(r"(\d+)d", duration) if days_match: total_seconds += int(days_match.group(1)) * 86400 - + # Extract hours - hours_match = re.search(r'(\d+)h', duration) + hours_match = re.search(r"(\d+)h", duration) if hours_match: total_seconds += int(hours_match.group(1)) * 3600 - + # Extract minutes - minutes_match = re.search(r'(\d+)m', duration) + minutes_match = re.search(r"(\d+)m", duration) if minutes_match: total_seconds += int(minutes_match.group(1)) * 60 - + # Extract seconds - seconds_match = re.search(r'(\d+)s', duration) + seconds_match = re.search(r"(\d+)s", duration) if seconds_match: total_seconds += int(seconds_match.group(1)) - + return total_seconds @@ -163,14 +163,14 @@ def _format_timedelta_from_seconds(total_seconds: Union[int, float]) -> str: """Format total seconds to "Xd Xh Xm" format.""" if total_seconds < 0: return "0d 0h" - + # Convert to int to avoid decimal places total_seconds = int(total_seconds) - + days = total_seconds // 86400 hours = (total_seconds % 86400) // 3600 minutes = (total_seconds % 3600) // 60 - + # Only show days if > 0 if days > 0: return f"{days}d {hours}h {minutes}m" @@ -183,16 +183,16 @@ def _format_timedelta_from_seconds(total_seconds: Union[int, float]) -> str: def format_response_time(seconds: Optional[float]) -> str: """ Format response time for display. - + Args: seconds: Response time in seconds - + Returns: Formatted response time string """ if seconds is None: return "N/A" - + if seconds < 1: return f"{seconds * 1000:.0f}ms" else: