diff --git a/viseron/__init__.py b/viseron/__init__.py index d440f8807..57575090f 100644 --- a/viseron/__init__.py +++ b/viseron/__init__.py @@ -12,6 +12,7 @@ import tracemalloc from collections.abc import Callable from functools import partial +from logging.handlers import RotatingFileHandler from timeit import default_timer as timer from typing import TYPE_CHECKING, Any, Literal, overload @@ -47,12 +48,15 @@ DOMAIN_LOADING, DOMAIN_SETUP_TASKS, DOMAINS_TO_SETUP, + ENV_LOG_BACKUP_COUNT, + ENV_LOG_MAX_BYTES, ENV_PROFILE_MEMORY, EVENT_DOMAIN_REGISTERED, FAILED, LOADED, LOADING, REGISTERED_DOMAINS, + VISERON_LOG_PATH, VISERON_SIGNAL_LAST_WRITE, VISERON_SIGNAL_SHUTDOWN, VISERON_SIGNAL_STOPPING, @@ -60,9 +64,11 @@ from viseron.domains.camera.const import DOMAIN as CAMERA_DOMAIN from viseron.events import Event, EventData from viseron.exceptions import DataStreamNotLoaded, DomainNotRegisteredError -from viseron.helpers import memory_usage_profiler, utcnow +from viseron.helpers import memory_usage_profiler, parse_size_to_bytes, utcnow from viseron.helpers.json import JSONEncoder from viseron.helpers.logs import ( + LOG_DATE_FORMAT, + LOG_FORMAT, DuplicateFilter, SensitiveInformationFilter, ViseronLogFormat, @@ -100,16 +106,61 @@ LOGGER = logging.getLogger(f"{__name__}.core") +def _get_rotation_rules() -> tuple[int, int]: + env_max_bytes = os.getenv(ENV_LOG_MAX_BYTES) + env_backup_count = os.getenv(ENV_LOG_BACKUP_COUNT) + + max_bytes = 0 + if env_max_bytes is not None: + try: + max_bytes = parse_size_to_bytes(env_max_bytes) + except ValueError as error: + LOGGER.error( + f"Failed to parse {ENV_LOG_MAX_BYTES} as int, using default value", + exc_info=error, + ) + + backup_count = 1 + if env_backup_count is not None: + try: + backup_count = parse_size_to_bytes(env_backup_count) + except ValueError as error: + LOGGER.error( + f"Failed to parse {ENV_LOG_BACKUP_COUNT} as int, using default value", + exc_info=error, + ) + + return max_bytes, backup_count + + def enable_logging() -> None: """Enable logging.""" root_logger = logging.getLogger() root_logger.propagate = False - handler = logging.StreamHandler() formatter = ViseronLogFormat() + duplicate_filter = DuplicateFilter() + sensitive_information_filter = SensitiveInformationFilter() + + handler = logging.StreamHandler() handler.setFormatter(formatter) - handler.addFilter(DuplicateFilter()) - handler.addFilter(SensitiveInformationFilter()) + handler.addFilter(duplicate_filter) + handler.addFilter(sensitive_information_filter) root_logger.addHandler(handler) + + max_bytes, backup_count = _get_rotation_rules() + file_handler = RotatingFileHandler( + VISERON_LOG_PATH, + maxBytes=max_bytes, + backupCount=backup_count, + delay=True, + ) + file_handler.setFormatter( + logging.Formatter(fmt=LOG_FORMAT, datefmt=LOG_DATE_FORMAT) + ) + file_handler.addFilter(sensitive_information_filter) + file_handler.doRollover() + root_logger.addHandler(file_handler) + root_logger.setLevel(logging.INFO) # Silence noisy loggers diff --git a/viseron/const.py b/viseron/const.py index a9b3e4438..819559b9e 100644 --- a/viseron/const.py +++ b/viseron/const.py @@ -7,6 +7,7 @@ CONFIG_PATH = "/config/config.yaml" SECRETS_PATH = "/config/secrets.yaml" STORAGE_PATH = "/config/.viseron" +VISERON_LOG_PATH = "/config/viseron.log" TEMP_DIR = "/tmp/viseron" DEFAULT_CONFIG = """# Thanks for trying out Viseron! # This is a small walkthrough of the configuration to get you started. @@ -88,6 +89,7 @@ CAMERA_SEGMENT_DURATION = 5 +# Environment variables ENV_CUDA_SUPPORTED = "VISERON_CUDA_SUPPORTED" ENV_VAAPI_SUPPORTED = "VISERON_VAAPI_SUPPORTED" ENV_OPENCL_SUPPORTED = "VISERON_OPENCL_SUPPORTED" @@ -95,6 +97,8 @@ ENV_RASPBERRYPI4 = "VISERON_RASPBERRYPI4" ENV_JETSON_NANO = "VISERON_JETSON_NANO" ENV_PROFILE_MEMORY = "VISERON_PROFILE_MEMORY" +ENV_LOG_MAX_BYTES = "VISERON_LOG_MAX_BYTES" +ENV_LOG_BACKUP_COUNT = "VISERON_LOG_BACKUP_COUNT" FONT = cv2.FONT_HERSHEY_SIMPLEX diff --git a/viseron/helpers/__init__.py b/viseron/helpers/__init__.py index c1575890d..ec3104f91 100644 --- a/viseron/helpers/__init__.py +++ b/viseron/helpers/__init__.py @@ -639,6 +639,37 @@ def escape_string(string: str) -> str: return urllib.parse.quote(string, safe="") +def parse_size_to_bytes(size_str: str) -> int: + """Convert human-readable size strings to bytes (e.g. '10mb' -> 10485760).""" + + units = { + "tb": 1024**4, + "gb": 1024**3, + "mb": 1024**2, + "kb": 1024, + "b": 1, + } + + size_str = str(size_str).strip().lower() + + # If it's just a number, assume bytes + if size_str.isdigit(): + return int(size_str) + + # Extract number and unit + for unit in units: + if size_str.endswith(unit): + try: + number = float(size_str[: -len(unit)]) + return int(number * units[unit]) + except ValueError as err: + raise ValueError(f"Invalid size format: {size_str}") from err + + raise ValueError( + f"Invalid size unit in {size_str}. Must be one of: {', '.join(units.keys())}" + ) + + def memory_usage_profiler(logger, key_type="lineno", limit=5) -> None: """Print a table with the lines that are using the most memory.""" snapshot = tracemalloc.take_snapshot() diff --git a/viseron/helpers/logs.py b/viseron/helpers/logs.py index 2aa234a4b..53abc4db0 100644 --- a/viseron/helpers/logs.py +++ b/viseron/helpers/logs.py @@ -13,6 +13,10 @@ from colorlog import ColoredFormatter +LOG_FORMAT = "%(asctime)s.%(msecs)03d [%(levelname)-8s] [%(name)s] - %(message)s" +STREAM_LOG_FORMAT = "%(log_color)s" + LOG_FORMAT +LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" + class DuplicateFilter(logging.Filter): """Formats identical log entries to overwrite the last.""" @@ -103,13 +107,13 @@ def filter(self, record) -> bool: class ViseronLogFormat(ColoredFormatter): - """Log formatter.""" + """Log formatter. + + Used only by the StreamHandler logs. + """ # pylint: disable=protected-access - base_format = ( - "%(log_color)s[%(asctime)s] [%(levelname)-8s] [%(name)s] - %(message)s" - ) - overwrite_fmt = "\x1b[80D\x1b[1A\x1b[K" + base_format + overwrite_fmt = "\x1b[80D\x1b[1A\x1b[K" + STREAM_LOG_FORMAT def __init__(self) -> None: log_colors = { @@ -121,8 +125,8 @@ def __init__(self) -> None: } super().__init__( - fmt=self.base_format, - datefmt="%Y-%m-%d %H:%M:%S", + fmt=STREAM_LOG_FORMAT, + datefmt=LOG_DATE_FORMAT, style="%", reset=True, log_colors=log_colors,