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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user