improved logging

This commit is contained in:
Ayzen
2026-06-06 00:52:52 +03:00
parent af6005d68f
commit aea49f6128
65 changed files with 1206 additions and 240 deletions
+137 -55
View File
@@ -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)
@@ -256,6 +256,12 @@ class AppWindowLiveProcessingMixin:
for name, value in fields.items()
if name not in {"history_command", "history_command_seq"}
}
# Log only field names (not values) so remote edits are traceable
# without recording arbitrary client-supplied payloads.
self._log_debug(
f"Applying web live settings: fields={sorted(settings)}, "
f"history_command={history_command}."
)
self._suppress_live_settings_handler = True
try:
# processor_mode first: dual-sourced gpr_* fields route to the gpr or
@@ -218,7 +218,16 @@ class AppWindowConfigProfileIOMixin:
self._show_exception("Failed to load config profile", exc)
def _apply_loaded_profile(self, profile: GuiProfileModel, profile_path: Path) -> None:
"""Apply already parsed profile to GUI state without restarting the pipeline."""
"""Apply an already parsed profile to GUI state without restarting the pipeline.
Repopulates every radar/processing/preprocess widget under signal blockers,
resizes history buffers, and refreshes derived state. The pipeline is left
untouched; callers handle user-facing logging and error reporting.
"""
self._log_debug(
f"Applying loaded profile: path={profile_path}, "
f"has_gui_state={profile.gui is not None}."
)
config = profile.run_config.clone()
gui_state = profile.gui if profile.gui is not None else self._default_gui_state_for_config(config)
self._defaults_config = config
@@ -422,6 +422,7 @@ class AppWindowConfigStateBuildersMixin:
config.runtime.settling_ms = int(self._settling_ms.text().strip())
config.runtime.processing_live_config_path = str(self._live_config_writer.path)
config.logging.level = self._log_level_combo.currentText().strip().lower()
if config.is_matrix_radar:
if config.is_multi_device and len(config.radar.multi_device.slave_serials) != 2:
@@ -304,16 +304,22 @@ class AppWindowPipelineMixin:
self._log("All pipeline processes stopped")
def _close_readers(self, *, keep_results: bool = False) -> None:
"""Close active ring readers."""
"""Close active ring readers; keep the results reader when ``keep_results``."""
closed = []
if self._raw_reader is not None:
self._raw_reader.close()
self._raw_reader = None
closed.append("raw")
if self._pre_reader is not None:
self._pre_reader.close()
self._pre_reader = None
closed.append("preprocessed")
if not keep_results and self._result_reader is not None:
self._result_reader.close()
self._result_reader = None
closed.append("results")
if closed:
self._log_debug(f"Closed ring readers: {', '.join(closed)}.")
def _poll_rings(self) -> None:
"""Poll readers, ingest history, and trigger rendering."""
@@ -58,7 +58,13 @@ class AppWindowPreprocessMixin:
"""Clear selected preprocess sets when radar-key-defining settings change."""
try:
radar_key = self._radar_key_from_ui()
except Exception:
except Exception as exc: # noqa: BLE001
# Radar fields can be mid-edit (empty/partial) while signals fire; the
# key cannot be computed yet, so skip until the inputs are valid again.
self._log_debug(
f"Skipping preprocess-selection reset; radar key unavailable: "
f"{type(exc).__name__}: {exc}"
)
return
previous_radar_key = getattr(self, "_selected_preprocess_radar_key", radar_key)
@@ -778,8 +784,11 @@ class AppWindowPreprocessMixin:
)
def _cleanup_capture_session(self) -> None:
"""Close and clear current capture session object."""
"""Close and clear the current capture session object."""
if self._capture_session is not None:
self._log_debug(
f"Closing capture session: kind={self._capture_session.kind}."
)
self._capture_session.close()
self._capture_session = None
self._update_capture_dialog_state()
@@ -33,6 +33,7 @@ from python_app.gui.controllers.sections import (
build_radar_group,
build_switch_group,
)
from python_app.logging_setup import LOG_LEVELS
class AppWindowUiMixin:
@@ -178,6 +179,20 @@ class AppWindowUiMixin:
self._log_toggle_button.setObjectName("sectionToggleButton")
self._log_toggle_button.clicked.connect(lambda: self._toggle_log_panel())
# Log-level selector: live verbosity control, persisted to run_config.
self._log_level_label = QLabel("Level", self._settings_panel)
self._log_level_label.setObjectName("hintLabel")
self._log_level_combo = QComboBox(self._settings_panel)
self._log_level_combo.setObjectName("logLevelCombo")
self._log_level_combo.addItems([name.capitalize() for name in LOG_LEVELS])
self._log_level_combo.setToolTip("Logging verbosity — applied live and saved to run_config")
current_level = self._defaults_config.logging.level.capitalize()
current_index = self._log_level_combo.findText(current_level)
if current_index >= 0:
self._log_level_combo.setCurrentIndex(current_index)
# Connect AFTER seeding the value so reflecting the config does not save it back.
self._log_level_combo.currentTextChanged.connect(self._on_log_level_selected)
self._log_box = QTextEdit(self._settings_panel)
self._log_box.setObjectName("runtimeLogBox")
self._log_box.setReadOnly(True)
@@ -196,6 +211,8 @@ class AppWindowUiMixin:
header_layout.setSpacing(8)
header_layout.addWidget(self._log_panel_title)
header_layout.addStretch(1)
header_layout.addWidget(self._log_level_label)
header_layout.addWidget(self._log_level_combo)
header_layout.addWidget(self._log_toggle_button)
panel_layout.addWidget(header_row)
@@ -198,6 +198,7 @@ class AppWindowWebMixin:
with contextlib.suppress(Exception):
server.stop()
self._web_server = None
self._log("Web UI stopped.")
self._web_controller = None
@staticmethod
+8 -1
View File
@@ -462,7 +462,12 @@ class PreprocessDialog(QDialog):
)
def _ensure_preview_plots(self) -> bool:
"""Create the amplitude+phase plot pair lazily on first successful capture."""
"""Lazily create the amplitude+phase plot pair and report whether it exists.
Returns True once both plots are available. If construction fails (e.g. an
incompatible PyQtGraph/PyQt6 build), marks the preview permanently
unavailable so later captures fall back to the placeholder without retrying.
"""
if self._amplitude_plot is not None and self._phase_plot is not None:
return True
if self._preview_plot_unavailable:
@@ -474,6 +479,8 @@ class PreprocessDialog(QDialog):
amplitude_plot = self._build_preview_axis("Magnitude", "dB")
phase_plot = self._build_preview_axis("Phase", "deg")
except Exception:
# Some PyQtGraph/PyQt6 combinations cannot build a PlotWidget here;
# latch the failure so we show the text placeholder instead of retrying.
self._preview_plot_unavailable = True
return False