feat(logging): archive historical logs daily into Archive/

At startup, previously produced logs are relocated into an Archive/
subfolder next to the active log: the project sync.log gets a
-YYYY-MM-DD suffix when archived, and NSSM's nssm_*.log captures are
moved as-is. The log root then only shows the current day's sync.log.
Idempotent handler setup is preserved.

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
This commit is contained in:
Misaka_Company
2026-07-16 09:24:41 +08:00
parent d8a423c983
commit 4179d3e232

View File

@@ -2,10 +2,89 @@
Configures the root logger with a RotatingFileHandler (10 MB x 5, UTF-8) plus Configures the root logger with a RotatingFileHandler (10 MB x 5, UTF-8) plus
a console StreamHandler. The level and log path come from ``cfg.logging``. a console StreamHandler. The level and log path come from ``cfg.logging``.
Log files are managed per-day: at startup any previously produced log
(including the project's own ``sync.log`` and NSSM's ``nssm_*.log`` captures)
is relocated into an ``Archive/`` subfolder next to the active log. Where a log
lacks a timestamp, a ``-YYYY-MM-DD`` suffix is added (derived from its first
log line, falling back to mtime) so historical files carry a date. The log
root therefore only ever shows the current day's ``sync.log``.
""" """
import datetime
import logging import logging
import logging.handlers import logging.handlers
import os import os
import re
import shutil
_LOG_DATE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})")
def _first_line_date(path: str) -> str | None:
"""Best-effort extraction of the first log line's YYYY-MM-DD date."""
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
m = _LOG_DATE_RE.match(line)
if m:
return m.group(1)
except OSError:
pass
return None
def _archive_completed_logs(log_dir: str, active_name: str):
"""Move already-produced logs out of *log_dir* into *log_dir*/Archive.
The active log (*active_name*) is left in place only when it belongs to the
current day; an older ``sync.log`` is archived (with a ``-YYYY-MM-DD``
suffix) so a fresh one can be opened. NSSM's own ``nssm_*.log`` captures are
timestamped already and are moved as-is. Moves are best-effort: files locked
by another process (e.g. NSSM's live handles) are skipped.
"""
archive_dir = os.path.join(log_dir, "Archive")
os.makedirs(archive_dir, exist_ok=True)
today = datetime.date.today()
for name in os.listdir(log_dir):
src = os.path.join(log_dir, name)
if not os.path.isfile(src):
continue
if name == "Archive":
continue
if not (name.endswith(".log") or ".log." in name):
continue
# Keep NSSM's live, currently-open handles in place.
if name in ("nssm_stderr.log", "nssm_stdout.log"):
continue
if name == active_name:
# Only archive the active log if it is from a previous day.
log_date = _first_line_date(src)
log_date = (
datetime.date.fromisoformat(log_date)
if log_date
else datetime.date.fromtimestamp(os.path.getmtime(src))
)
if log_date >= today:
continue # today's log: keep appending
new_name = f"sync-{log_date.strftime('%Y-%m-%d')}.log"
else:
new_name = name
dst = os.path.join(archive_dir, new_name)
if os.path.exists(dst):
stem, ext = os.path.splitext(new_name)
i = 2
while os.path.exists(os.path.join(archive_dir, f"{stem}({i}){ext}")):
i += 1
dst = os.path.join(archive_dir, f"{stem}({i}){ext}")
try:
shutil.move(src, dst)
except OSError:
# Locked by another process (e.g. NSSM holding the file open).
pass
def setup_logging(cfg_dict: dict | None): def setup_logging(cfg_dict: dict | None):
@@ -13,11 +92,16 @@ def setup_logging(cfg_dict: dict | None):
``cfg_dict`` is ``SyncConfig.logging`` (a dict or None). ``level`` is a ``cfg_dict`` is ``SyncConfig.logging`` (a dict or None). ``level`` is a
logging-level name string (default ``"INFO"``); ``path`` is the log file logging-level name string (default ``"INFO"``); ``path`` is the log file
path (default ``"sync.log"``). The parent directory is created if missing. path (default ``"sync.log"``). The parent directory is created if missing,
and any previously produced logs are archived before the new handler opens.
""" """
level = getattr(logging, (cfg_dict or {}).get("level", "INFO")) if cfg_dict else logging.INFO level = getattr(logging, (cfg_dict or {}).get("level", "INFO")) if cfg_dict else logging.INFO
path = (cfg_dict or {}).get("path", "sync.log") path = (cfg_dict or {}).get("path", "sync.log")
os.makedirs(os.path.dirname(path) or ".", exist_ok=True) log_dir = os.path.dirname(path) or "."
os.makedirs(log_dir, exist_ok=True)
# Archive everything from prior runs so the root only shows today's log.
_archive_completed_logs(log_dir, os.path.basename(path))
# Idempotent: drop any handlers already attached to the root logger before # Idempotent: drop any handlers already attached to the root logger before
# re-adding. setup_logging can be called from more than one entry point # re-adding. setup_logging can be called from more than one entry point