#!/usr/bin/env python3 """ Structured JSON logging for GNSS Guard Logs to date-based folders with daily rotation and cleanup """ import json import logging import time from datetime import datetime, timedelta from pathlib import Path from typing import Dict, Any, Optional logger = logging.getLogger("gnss_guard.logger") class StructuredLogger: """Structured JSON logger with date-based folders""" def __init__(self, logs_base_path: Path, retention_days: int = 14): """ Initialize structured logger Args: logs_base_path: Base path for logs directory retention_days: Number of days to retain logs """ self.logs_base_path = Path(logs_base_path) self.retention_days = retention_days self.current_log_file: Optional[Path] = None self.current_date: Optional[str] = None self.log_file_handle = None self._closed = False def _get_log_path(self, date: datetime) -> Path: """Get log file path for a given date""" year = date.strftime("%Y") month = date.strftime("%m") day = date.strftime("%d") date_str = date.strftime("%Y-%m-%d") log_dir = self.logs_base_path / year / month / day log_dir.mkdir(parents=True, exist_ok=True) return log_dir / f"app_{date_str}.json" def _ensure_log_file(self): """Ensure log file is open for current date""" today = datetime.now() today_str = today.strftime("%Y-%m-%d") if self.current_date != today_str or self.current_log_file is None: # Close previous file if open if self.log_file_handle: self.log_file_handle.close() self.log_file_handle = None # Cleanup old logs self._cleanup_old_logs() # Open new log file self.current_log_file = self._get_log_path(today) self.current_date = today_str # Open file in append mode self.log_file_handle = open(self.current_log_file, "a") logger.info(f"Opened log file: {self.current_log_file}") def _cleanup_old_logs(self): """Delete log files older than retention_days""" try: cutoff_date = datetime.now() - timedelta(days=self.retention_days) cutoff_timestamp = cutoff_date.timestamp() deleted_count = 0 # Walk through all log directories if self.logs_base_path.exists(): for log_file in self.logs_base_path.rglob("app_*.json"): try: if log_file.stat().st_mtime < cutoff_timestamp: log_file.unlink() deleted_count += 1 except Exception as e: logger.debug(f"Failed to delete old log file {log_file}: {e}") if deleted_count > 0: logger.info(f"Cleaned up {deleted_count} old log file(s) (> {self.retention_days} days)") except Exception as e: logger.error(f"Error during log cleanup: {e}") def log(self, level: str, source: str, message: str, data: Optional[Dict[str, Any]] = None): """ Write structured log entry Args: level: Log level (INFO, WARNING, ERROR, DEBUG) source: Source identifier message: Log message data: Optional additional data dictionary """ try: # Don't write if logger is explicitly closed if self._closed: return # Ensure log file is open self._ensure_log_file() # Check if file handle is still None (shouldn't happen, but be safe) if self.log_file_handle is None: logger.warning(f"Cannot write log entry: logger file handle is None") return log_entry = { "timestamp": datetime.now().isoformat(), "level": level, "source": source, "message": message, } if data: log_entry["data"] = data # Write as JSON line json_line = json.dumps(log_entry, separators=(",", ":")) self.log_file_handle.write(json_line + "\n") self.log_file_handle.flush() except Exception as e: logger.error(f"Failed to write log entry: {e}") def info(self, source: str, message: str, data: Optional[Dict[str, Any]] = None): """Log info message""" self.log("INFO", source, message, data) def warning(self, source: str, message: str, data: Optional[Dict[str, Any]] = None): """Log warning message""" self.log("WARNING", source, message, data) def error(self, source: str, message: str, data: Optional[Dict[str, Any]] = None): """Log error message""" self.log("ERROR", source, message, data) def debug(self, source: str, message: str, data: Optional[Dict[str, Any]] = None): """Log debug message""" self.log("DEBUG", source, message, data) def close(self): """Close log file handle""" self._closed = True if self.log_file_handle: self.log_file_handle.close() self.log_file_handle = None