improved logging
This commit is contained in:
+137
-55
@@ -12,13 +12,12 @@ from datetime import datetime
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from PyQt6.QtCore import QTimer
|
||||
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
|
||||
from PyQt6.QtGui import QTextCursor
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow, QMessageBox
|
||||
|
||||
@@ -40,12 +39,56 @@ from python_app.orchestration.pipeline_metrics import PipelineMetrics
|
||||
from python_app.orchestration.preprocess_assets import VISIBLE_PREPROCESS_ASSET_KEYS, preprocess_asset_model
|
||||
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
||||
from python_app.orchestration.shm_reader import ShmRingReader
|
||||
from python_app.logging_setup import (
|
||||
DEFAULT_LOG_LEVEL,
|
||||
add_handler,
|
||||
configure_logging,
|
||||
get_logger,
|
||||
set_log_level,
|
||||
)
|
||||
from python_app.storage.npz_store import NpzStore
|
||||
from python_app.workflows.multi_radar_capture_workflow import MultiRadarSequentialCaptureSession
|
||||
from python_app.workflows.radar_config_variants import RadarConfigScanSummary, RadarConfigVariant
|
||||
from python_app.workflows.sequential_capture_workflow import SequentialCaptureSession
|
||||
|
||||
|
||||
def _panel_extra(details: str | None, once_key: str | None) -> dict[str, object]:
|
||||
"""Carry GUI-panel-only fields (details block, once-key dedup) on a log record."""
|
||||
return {"panel_details": details, "panel_once_key": once_key}
|
||||
|
||||
|
||||
class _PanelLogBridge(QObject):
|
||||
"""Marshals log records from any thread onto the GUI thread for panel rendering.
|
||||
|
||||
A :class:`logging.Handler` can fire on a worker thread (readers, broadcaster),
|
||||
but the log widget may only be touched on the GUI thread; emitting this queued
|
||||
signal hands the record across safely (the GPIO-button pattern).
|
||||
"""
|
||||
|
||||
record = pyqtSignal(str, str, object, object) # display level, message, details, once_key
|
||||
|
||||
|
||||
class _QtLogPanelHandler(logging.Handler):
|
||||
"""Logging handler that forwards application log records to the GUI log panel."""
|
||||
|
||||
def __init__(self, bridge: _PanelLogBridge) -> None:
|
||||
super().__init__()
|
||||
self._bridge = bridge
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
"""Forward one record to the panel bridge, mapping WARNING to the short 'WARN'."""
|
||||
try:
|
||||
display_level = "WARN" if record.levelname == "WARNING" else record.levelname
|
||||
self._bridge.record.emit(
|
||||
display_level,
|
||||
record.getMessage(),
|
||||
getattr(record, "panel_details", None),
|
||||
getattr(record, "panel_once_key", None),
|
||||
)
|
||||
except Exception: # noqa: BLE001 - logging must never raise into the caller
|
||||
self.handleError(record)
|
||||
|
||||
|
||||
class AppWindow(
|
||||
AppWindowUiMixin,
|
||||
AppWindowConfigMixin,
|
||||
@@ -64,7 +107,7 @@ class AppWindow(
|
||||
super().__init__()
|
||||
|
||||
self._init_paths(project_root)
|
||||
self._init_headless_logger()
|
||||
self._init_logging()
|
||||
self._init_runtime_services()
|
||||
self._init_config_profile_state()
|
||||
self._init_reader_handles()
|
||||
@@ -85,39 +128,35 @@ class AppWindow(
|
||||
# Guards closeEvent against re-entrant teardown (e.g. a second signal).
|
||||
self._closing = False
|
||||
|
||||
def _init_headless_logger(self) -> None:
|
||||
"""Create a Python logger so headless WARN/ERROR reach journald and disk.
|
||||
def _init_logging(self) -> None:
|
||||
"""Configure the application logger and the bridge that feeds the GUI panel.
|
||||
|
||||
In headless mode the in-app log only reaches an offscreen widget, so an
|
||||
operator (or `journalctl`) would never see failures. We attach a stderr
|
||||
StreamHandler (captured by journald) plus a small rotating file under
|
||||
`runtime/logs`; in GUI mode no handler is attached and the logger stays
|
||||
inert, preserving the visible log widget as the sole sink.
|
||||
Installs the rotating-file (``runtime/logs/radar.log``) and stderr handlers on
|
||||
the ``python_app`` logger so every module's logs — and the GUI's own ``_log*``
|
||||
calls — share one level-controlled, rotated pipeline. The verbosity floor starts
|
||||
at the default and is replaced with the configured level once run_config loads;
|
||||
the GUI panel is wired in once its widget exists (see ``_attach_log_panel``).
|
||||
"""
|
||||
self._headless_logger: logging.Logger | None = None
|
||||
if not self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
|
||||
log_dir = self._project_root / "python_app/runtime/logs"
|
||||
configure_logging(level=DEFAULT_LOG_LEVEL, log_dir=log_dir, console=True)
|
||||
self._gui_logger = get_logger("gui")
|
||||
self._log_panel_bridge = _PanelLogBridge()
|
||||
self._log_panel_bridge.record.connect(self._on_log_record)
|
||||
|
||||
def _attach_log_panel(self) -> None:
|
||||
"""Route application log records into the on-screen panel (widget now exists)."""
|
||||
add_handler(_QtLogPanelHandler(self._log_panel_bridge))
|
||||
|
||||
def _on_log_record(self, level: str, text: str, details: object, once_key: object) -> None:
|
||||
"""Render one forwarded log record in the panel (always on the GUI thread)."""
|
||||
if not hasattr(self, "_log_box"):
|
||||
return
|
||||
logger = logging.getLogger("radar_system.gui")
|
||||
logger.setLevel(logging.WARNING)
|
||||
logger.propagate = False
|
||||
logger.handlers.clear()
|
||||
formatter = logging.Formatter(
|
||||
fmt="%(asctime)s | %(levelname)-5s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
self._append_log_entry(
|
||||
level,
|
||||
text,
|
||||
details=details if isinstance(details, str) else None,
|
||||
once_key=once_key if isinstance(once_key, str) else None,
|
||||
)
|
||||
stream_handler = logging.StreamHandler(stream=sys.stderr)
|
||||
stream_handler.setFormatter(formatter)
|
||||
logger.addHandler(stream_handler)
|
||||
# A rotating file keeps recent failures around after a journald restart.
|
||||
with suppress(Exception):
|
||||
log_dir = self._project_root / "python_app/runtime/logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = RotatingFileHandler(
|
||||
log_dir / "gui.log", maxBytes=1_000_000, backupCount=3, encoding="utf-8"
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
self._headless_logger = logger
|
||||
|
||||
def _init_runtime_services(self) -> None:
|
||||
"""Initialize long-lived service objects used by mixins."""
|
||||
@@ -155,7 +194,12 @@ class AppWindow(
|
||||
return 50
|
||||
|
||||
def _init_config_profile_state(self) -> None:
|
||||
"""Resolve startup profile path, load active profile, and queue fallback notices."""
|
||||
"""Resolve the startup profile path, load the active profile, and queue any
|
||||
fallback notices for replay once the log panel exists.
|
||||
|
||||
On failure to load a non-root profile, falls back to the root run_config.json;
|
||||
a failure to load the root profile itself is fatal and re-raised.
|
||||
"""
|
||||
active_profile_path = self._resolve_startup_profile_path()
|
||||
try:
|
||||
profile = GuiProfileModel.load_from_path(active_profile_path)
|
||||
@@ -172,6 +216,7 @@ class AppWindow(
|
||||
|
||||
self._active_profile_path = active_profile_path
|
||||
self._defaults_config = profile.run_config.clone()
|
||||
set_log_level(self._defaults_config.logging.level)
|
||||
if profile.gui is not None:
|
||||
self._gui_defaults = profile.gui
|
||||
else:
|
||||
@@ -277,8 +322,13 @@ class AppWindow(
|
||||
self._timer.timeout.connect(self._poll_rings)
|
||||
|
||||
def _bootstrap_ui_runtime(self) -> None:
|
||||
"""Build UI and apply initial runtime-bound state after widgets exist."""
|
||||
"""Build the UI and apply initial runtime-bound state once widgets exist.
|
||||
|
||||
This is the first point at which the log panel is wired in, so it also
|
||||
replays any startup entries buffered during the headless init phase.
|
||||
"""
|
||||
self._build_ui()
|
||||
self._attach_log_panel()
|
||||
self._flush_pending_startup_log_entries()
|
||||
self._log(f"Active config profile: {self._active_profile_path}")
|
||||
self._refresh_preprocess_summary_labels()
|
||||
@@ -293,6 +343,10 @@ class AppWindow(
|
||||
self._init_web_ui()
|
||||
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
|
||||
self._install_headless_watchdog()
|
||||
self._log_debug(
|
||||
f"Window bootstrap complete (mode={self._active_processing_mode}, "
|
||||
f"ring poll interval={self._timer.interval()}ms)."
|
||||
)
|
||||
|
||||
def _resolve_startup_profile_path(self) -> Path:
|
||||
"""Resolve active profile path from session-state or root fallback path."""
|
||||
@@ -373,6 +427,7 @@ class AppWindow(
|
||||
self._headless_watchdog.setInterval(2000)
|
||||
self._headless_watchdog.timeout.connect(self._headless_watchdog_tick)
|
||||
self._headless_watchdog.start()
|
||||
self._log_debug("Headless watchdog armed (interval=2000ms).")
|
||||
|
||||
def _headless_watchdog_tick(self) -> None:
|
||||
"""Escalate any unexpected managed-process exit to a fatal headless restart."""
|
||||
@@ -410,7 +465,11 @@ class AppWindow(
|
||||
return path.expanduser().resolve(strict=False)
|
||||
|
||||
def _remember_active_profile_path(self, path: Path, *, startup: bool = False) -> None:
|
||||
"""Persist last successfully used config profile path."""
|
||||
"""Persist the last successfully used config profile path to GUI session-state.
|
||||
|
||||
A write failure is non-fatal: it is queued during startup or logged as a
|
||||
warning afterwards, since it only affects which profile reopens next launch.
|
||||
"""
|
||||
normalized_path = self._normalize_profile_path(path)
|
||||
self._active_profile_path = normalized_path
|
||||
try:
|
||||
@@ -434,19 +493,29 @@ class AppWindow(
|
||||
self._pending_startup_log_entries.append((level.upper(), text, details))
|
||||
|
||||
def _flush_pending_startup_log_entries(self) -> None:
|
||||
"""Flush startup log entries into the runtime log box after UI creation."""
|
||||
if not self._pending_startup_log_entries:
|
||||
return
|
||||
"""Replay queued startup entries through the logger now that every sink exists.
|
||||
|
||||
Entries logged before the panel widget existed were buffered; routing them
|
||||
through the logger here delivers them to the file/console and the panel at once.
|
||||
"""
|
||||
levels = {"WARN": logging.WARNING, "ERROR": logging.ERROR}
|
||||
for level, text, details in self._pending_startup_log_entries:
|
||||
self._append_log_entry(level, text, details=details)
|
||||
self._gui_logger.log(levels.get(level, logging.INFO), text, extra=_panel_extra(details, None))
|
||||
self._pending_startup_log_entries.clear()
|
||||
|
||||
def _apply_initial_radar_limits(self) -> None:
|
||||
"""Apply startup radar-limits strategy according to selected radar mode."""
|
||||
"""Apply the startup radar-limits strategy according to the selected radar mode.
|
||||
|
||||
In ``native`` mode the limits are queried from the connected device; otherwise
|
||||
the UI is populated with no device-imposed limits.
|
||||
"""
|
||||
if self._defaults_config.radar.driver_mode == "native":
|
||||
self._log_debug("Querying radar limits from device (native driver mode).")
|
||||
self._refresh_radar_limits_from_device()
|
||||
return
|
||||
self._log_debug(
|
||||
f"Skipping device radar-limit query (driver mode={self._defaults_config.radar.driver_mode})."
|
||||
)
|
||||
self._apply_radar_limits_to_ui(None)
|
||||
|
||||
@staticmethod
|
||||
@@ -483,6 +552,7 @@ class AppWindow(
|
||||
|
||||
level_upper = level.upper()
|
||||
palette = {
|
||||
"DEBUG": ("#6c7b8d", "#52627a", "#8a97a8"),
|
||||
"INFO": ("#1d5fbf", "#1f2937", "#526277"),
|
||||
"WARN": ("#9a5b00", "#5c4300", "#7a6640"),
|
||||
"ERROR": ("#c43d4d", "#6b1f2a", "#8b5d66"),
|
||||
@@ -513,26 +583,32 @@ class AppWindow(
|
||||
if level_upper == "ERROR" and hasattr(self, "_status_label"):
|
||||
self._status_label.setText("Status: error")
|
||||
|
||||
# In headless mode the offscreen widget above is invisible, so also mirror
|
||||
# WARN/ERROR to the Python logger (stderr -> journald, plus rotating file)
|
||||
# where an operator can actually observe failures.
|
||||
headless_logger = getattr(self, "_headless_logger", None)
|
||||
if headless_logger is not None and level_upper in {"WARN", "ERROR"}:
|
||||
log_message = text if not details else f"{text}\n{details}"
|
||||
log_level = logging.ERROR if level_upper == "ERROR" else logging.WARNING
|
||||
headless_logger.log(log_level, log_message)
|
||||
def _on_log_level_selected(self, level_text: str) -> None:
|
||||
"""Apply the chosen log level immediately (sub-level logs stop being generated).
|
||||
|
||||
The value lives in run_config like any other field: it is recorded in the active
|
||||
config here and saved with the config through the normal path — no separate write.
|
||||
"""
|
||||
level = level_text.strip().lower()
|
||||
set_log_level(level)
|
||||
self._defaults_config.logging.level = level
|
||||
self._log(f"Log level set to {level.upper()}.")
|
||||
|
||||
def _log_debug(self, text: str, *, once_key: str | None = None) -> None:
|
||||
"""Log a diagnostic message (emitted only while the level is DEBUG)."""
|
||||
self._gui_logger.debug(text, extra=_panel_extra(None, once_key))
|
||||
|
||||
def _log(self, text: str, *, once_key: str | None = None) -> None:
|
||||
"""Append informational message to runtime log panel."""
|
||||
self._append_log_entry("INFO", text, once_key=once_key)
|
||||
"""Log an informational message to the panel, file, and console."""
|
||||
self._gui_logger.info(text, extra=_panel_extra(None, once_key))
|
||||
|
||||
def _log_warning(self, text: str, *, details: str | None = None, once_key: str | None = None) -> None:
|
||||
"""Append warning message to runtime log panel."""
|
||||
self._append_log_entry("WARN", text, details=details, once_key=once_key)
|
||||
"""Log a warning to the panel, file, and console."""
|
||||
self._gui_logger.warning(text, extra=_panel_extra(details, once_key))
|
||||
|
||||
def _log_error(self, text: str, *, details: str | None = None, once_key: str | None = None) -> None:
|
||||
"""Append error message to runtime log panel."""
|
||||
self._append_log_entry("ERROR", text, details=details, once_key=once_key)
|
||||
"""Log an error to the panel, file, and console."""
|
||||
self._gui_logger.error(text, extra=_panel_extra(details, once_key))
|
||||
|
||||
def _log_exception(self, context: str, exc: Exception, *, level: str = "ERROR") -> tuple[str, str]:
|
||||
"""Log exception with detailed traceback and return `(message, details)`."""
|
||||
@@ -624,13 +700,18 @@ class AppWindow(
|
||||
dialog.exec()
|
||||
|
||||
def closeEvent(self, event) -> None: # noqa: N802
|
||||
"""Ensure workers and dialogs are closed before window destruction."""
|
||||
"""Tear down workers, readers, and dialogs before the window is destroyed.
|
||||
|
||||
Guarded against re-entrancy so a second close signal (or a ``window.close()``
|
||||
after the event loop has already returned) does not run teardown twice.
|
||||
"""
|
||||
if self._closing:
|
||||
# Re-entrant close (second signal, or window.close() after the event loop
|
||||
# already returned): teardown is in progress or done — do nothing more.
|
||||
super().closeEvent(event)
|
||||
return
|
||||
self._closing = True
|
||||
self._log("Window closing; shutting down runtime.")
|
||||
try:
|
||||
# 0) Stop the web server first so a late request cannot start work.
|
||||
self._shutdown_web_ui()
|
||||
@@ -645,4 +726,5 @@ class AppWindow(
|
||||
if self._preprocess_dialog is not None:
|
||||
self._preprocess_dialog.close()
|
||||
finally:
|
||||
self._log_debug("Window teardown finished.")
|
||||
super().closeEvent(event)
|
||||
|
||||
Reference in New Issue
Block a user