feat: implement logging setup and CSV telemetry logging for performance metrics
This commit is contained in:
118
app/logging_setup.py
Normal file
118
app/logging_setup.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Logging initialisation — file + console handlers with session isolation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import platform
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import APP_NAME, APP_VERSION, MAX_LOG_FILES
|
||||
|
||||
_LOG_FORMAT = "%(asctime)s.%(msecs)03d [%(levelname)-7s] %(name)s: %(message)s"
|
||||
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
_console_handler: logging.StreamHandler | None = None
|
||||
|
||||
|
||||
def setup_logging(log_dir: Path, session_id: str) -> Path:
|
||||
"""
|
||||
Configure the root logger for a new session.
|
||||
|
||||
Creates:
|
||||
<log_dir>/duck-preview_<session_id>.log — DEBUG level, all messages
|
||||
console StreamHandler — WARNING by default
|
||||
|
||||
Prunes oldest log files when count exceeds MAX_LOG_FILES.
|
||||
|
||||
Args:
|
||||
log_dir: Directory where log files are stored.
|
||||
session_id: Timestamp string used as filename suffix (e.g. "2026-05-12_14-30-00").
|
||||
|
||||
Returns:
|
||||
Path to the created log file.
|
||||
"""
|
||||
global _console_handler
|
||||
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = log_dir / f"duck-preview_{session_id}.log"
|
||||
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.DEBUG) # handlers filter individually
|
||||
|
||||
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT)
|
||||
|
||||
# --- File handler — always DEBUG ---
|
||||
file_handler = logging.FileHandler(log_path, encoding="utf-8")
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(formatter)
|
||||
root.addHandler(file_handler)
|
||||
|
||||
# --- Console handler — WARNING by default, toggled by Debug menu ---
|
||||
_console_handler = logging.StreamHandler(sys.stdout)
|
||||
_console_handler.setLevel(logging.WARNING)
|
||||
_console_handler.setFormatter(formatter)
|
||||
root.addHandler(_console_handler)
|
||||
|
||||
# Write session header to file
|
||||
_write_session_header(log_path, session_id)
|
||||
|
||||
# Prune old log files
|
||||
_prune_old_logs(log_dir, log_path)
|
||||
|
||||
return log_path
|
||||
|
||||
|
||||
def set_console_level(debug: bool) -> None:
|
||||
"""Toggle console handler between DEBUG and WARNING (called from Debug menu)."""
|
||||
if _console_handler is not None:
|
||||
_console_handler.setLevel(logging.DEBUG if debug else logging.WARNING)
|
||||
|
||||
|
||||
def _write_session_header(log_path: Path, session_id: str) -> None:
|
||||
"""Write a human-readable header block at the top of the log file."""
|
||||
try:
|
||||
import PySide6
|
||||
pyside_version = PySide6.__version__
|
||||
except Exception:
|
||||
pyside_version = "unknown"
|
||||
|
||||
try:
|
||||
import psutil
|
||||
mem = psutil.virtual_memory()
|
||||
cpu_count = psutil.cpu_count(logical=True)
|
||||
mem_total_gb = mem.total / (1024 ** 3)
|
||||
hw_info = f"{cpu_count} logical CPUs, {mem_total_gb:.1f} GB RAM"
|
||||
except Exception:
|
||||
hw_info = "unknown"
|
||||
|
||||
lines = [
|
||||
"=" * 72,
|
||||
f" {APP_NAME} {APP_VERSION}",
|
||||
f" Session: {session_id}",
|
||||
"=" * 72,
|
||||
f" Platform : {platform.platform()}",
|
||||
f" Python : {sys.version.split()[0]}",
|
||||
f" PySide6 : {pyside_version}",
|
||||
f" Hardware : {hw_info}",
|
||||
f" Log file : {log_path.resolve()}",
|
||||
"=" * 72,
|
||||
"",
|
||||
]
|
||||
with log_path.open("w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def _prune_old_logs(log_dir: Path, current: Path) -> None:
|
||||
"""Delete oldest .log files if total count exceeds MAX_LOG_FILES."""
|
||||
log_files = sorted(
|
||||
[p for p in log_dir.glob("duck-preview_*.log") if p != current],
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
)
|
||||
excess = len(log_files) - (MAX_LOG_FILES - 1)
|
||||
for path in log_files[:excess]:
|
||||
try:
|
||||
path.unlink()
|
||||
logging.getLogger(__name__).debug("Pruned old log: %s", path.name)
|
||||
except OSError:
|
||||
pass
|
||||
Reference in New Issue
Block a user