aboutsummaryrefslogtreecommitdiff
path: root/app/logging_kit.py
blob: 0fe39c268ebe25d50e4aa2aaeb5082f9186e8bb7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
"""Ownership of app/logs: naming, handles, and retention.

Every log file this app writes follows one of two conventions, and all of
them are created through this module so the policy lives in one place:

  * Streams — continuous app activity, appended to across runs:
      ``<prefix>_YYYYMMDD.log``  (e.g. audiobook_20260828.log, tui_20260828.log)
  * Artifacts — one discrete operation, kept as its own file and referenced
    by path afterwards (failure flashes, post-TUI notices):
      ``<name>_YYYYmmdd_HHMMSS.log``  (e.g. audiocpp_build_..., audiocpp_start_...)

The one exception is ``<name>-server.log``: managed-server processes
(backends.servers) spawn with their stdout/stderr attached to that file and
outlive this app's runs, so it stays a per-process append file — and
prune_logs never deletes it.

Everything here is stdlib-only and best-effort: logging must never break
the app, so an unwritable directory or a failed write degrades to a no-op.
"""

import logging
import time
import traceback
from datetime import datetime
from pathlib import Path

# The single log directory (app/logs, already gitignored).
LOG_DIR = Path(__file__).resolve().parent / "logs"

# Reserved logger for file-only traceback dumps: records emitted on this
# name reach the dated log file through the root handlers, while the
# converter's console filter (setup_logging's _console_log_filter) drops
# them, so a handled failure can keep its full traceback out of the
# console without losing it from the logs.
TRACEBACK_LOGGER = "app.traceback"


def log_traceback() -> None:
    """Log the active exception's traceback to the log file only.

    Call from inside an ``except`` block: the record reaches the file
    handler but not the console, where the caller shows a single friendly
    message instead (tracebacks are for the logs, or for real crashes).
    """
    logging.getLogger(TRACEBACK_LOGGER).error(traceback.format_exc())


# Stream/artifact files older than this are deleted by prune_logs (called
# once per app start). Server logs and pid files are never touched.
RETENTION_DAYS = 30


def day_stream(prefix: str, log_dir: Path = None):
    """Open today's ``<prefix>_YYYYMMDD.log`` stream for appending.

    Returns the open text handle (write through write_line so lines are
    flushed), or None when the directory/file cannot be opened.
    """
    directory = log_dir if log_dir is not None else LOG_DIR
    try:
        directory.mkdir(parents=True, exist_ok=True)
        return (directory / f"{prefix}_{datetime.now():%Y%m%d}.log"
                ).open("a", encoding="utf-8")
    except OSError:
        return None


def run_artifact(name: str, log_dir: Path = None):
    """Create ``<name>_YYYYmmdd_HHMMSS.log``; return ``(path, handle)``.

    The path is always returned so callers can point the user at it even
    when HANDLE is None (the directory/file could not be created).
    """
    directory = log_dir if log_dir is not None else LOG_DIR
    path = directory / f"{name}_{datetime.now():%Y%m%d_%H%M%S}.log"
    try:
        directory.mkdir(parents=True, exist_ok=True)
        return path, path.open("w", encoding="utf-8")
    except OSError:
        return path, None


def write_line(handle, text: str) -> None:
    """Append one line to HANDLE (None-safe), flushed; never raises."""
    if handle is None:
        return
    try:
        handle.write(f"{text}\n")
        handle.flush()
    except (OSError, ValueError):
        pass


class TeeWriter:
    """A file-like that mirrors writes to a log file and an inner stream.

    Used with ``contextlib.redirect_stdout`` to tee plain-console output
    into a persistent log without losing the original consumer (e.g. the
    task view's line writer). Either side may be None, and write errors
    are swallowed, so logging never breaks the caller.
    """

    def __init__(self, logf=None, inner=None):
        self._logf = logf
        self._inner = inner

    def write(self, text) -> int:
        if not text:
            return 0
        for stream in (self._logf, self._inner):
            if stream is not None:
                try:
                    stream.write(text)
                except (OSError, ValueError):
                    pass
        return len(text)

    def flush(self) -> None:
        for stream in (self._logf, self._inner):
            if stream is not None:
                try:
                    stream.flush()
                except (OSError, ValueError, AttributeError):
                    pass

    def isatty(self) -> bool:
        return False


def prune_logs(days: int = RETENTION_DAYS, log_dir: Path = None) -> None:
    """Delete stream/artifact log files older than DAYS (by mtime).

    ``<name>-server.log`` files are skipped — a managed server may still
    hold its log open, and its lifetime is not tied to this app's runs.
    Non-log files (pid files, anything else) are never touched.
    """
    directory = log_dir if log_dir is not None else LOG_DIR
    try:
        entries = list(directory.iterdir())
    except OSError:
        return
    cutoff = time.time() - days * 86400
    for entry in entries:
        if not entry.name.endswith(".log") \
                or entry.name.endswith("-server.log"):
            continue
        try:
            if entry.stat().st_mtime < cutoff:
                entry.unlink()
        except OSError:
            pass