Ctrl K
ComunidadGuías…

backend/app_logging.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Logging setup for the Sublarr Flask app.

Extracted from app.py to keep the factory module lean. Provides the
structured JSON formatter, the Flask-context-aware WebSocket log handler,
and the idempotent _setup_logging() routine called by create_app().
"""

import json
import logging
import os
import re
from logging.handlers import RotatingFileHandler

from extensions import socketio
from log_safety import LineBreakEscapeFilter
from secret_redaction import SecretRedactionFilter, redact

# `[%(request_id)s]` sits AFTER the level and BEFORE the logger name on purpose.
# Two consumers parse these lines with anchored regexes — support._extract_top_errors
# matches `^<ts>,\d+\s+\[(ERROR|WARNING)\]\s+[^:]+:` and the frontend Logs page
# tests `line.includes('[ERROR]')`. Placing the id before the level would break
# the first; placing it in this slot leaves both intact, because `[^:]+` happily
# spans `[<id>] <logger>` and the request id never contains a colon.
LOG_FORMAT = "%(asctime)s [%(levelname)s] [%(request_id)s] %(name)s: %(message)s"

# Rendered for any record logged outside a Flask request context — scheduler
# ticks, background threads, third-party libraries.
NO_REQUEST_ID = "-"


def _current_run_label() -> str | None:
    """The scheduled run this thread belongs to, if any.

    Imported lazily: app_logging is set up before most of the app exists, and
    a module-level import of a services package here would invert that order.
    """
    try:
        from services.scheduler.cancellation import current_run_label

        return current_run_label()
    except Exception:
        return None


def _current_request_id() -> str:
    """What to render in the id slot for this record.

    Four answers, in order of specificity: the Flask request being served,
    the scheduled run this thread belongs to, the id a pool worker inherited
    from its submitter (`utils.context_executor.submit_with_run_label`), or
    the placeholder.

    The middle one exists because everything outside a request rendered `-`,
    which is most of what Sublarr does. Sweep work, webhook follow-up work
    and queue-drain work all log from background threads through the same
    module names, so `wanted_search.post_processor: …` named the module and
    nothing about *which* piece of work produced it. On 2026-08-15 that left
    a `timeout_abandoned` verdict unattributable: the activity continuing
    after the cancel was indistinguishable from concurrent Sonarr webhooks.
    """
    if _has_app_context():
        try:
            from flask import g

            request_id = getattr(g, "request_id", None)
            if request_id:
                return str(request_id)
        except Exception:
            pass
    return _current_run_label() or _inherited_log_id() or NO_REQUEST_ID


def _inherited_log_id() -> str | None:
    """The id a pool worker was handed by `submit_with_run_label`, if any.

    Lazy import for the same reason as `_current_run_label`.
    """
    try:
        from utils.context_executor import inherited_log_id

        return inherited_log_id()
    except Exception:
        return None


class RequestIdFilter(logging.Filter):
    """Backstop that guarantees `record.request_id` exists before formatting.

    The record factory below covers everything logged through the normal
    `Logger.*` path. This filter covers what it cannot: records built by direct
    `logging.LogRecord(...)` construction, which bypasses the factory entirely.
    Without one of the two, `%(request_id)s` raises inside `Formatter.format`,
    the record is dropped, and the traceback goes to stderr — a logging change
    that loses logs.
    """

    def filter(self, record: logging.LogRecord) -> bool:
        if not hasattr(record, "request_id"):
            record.request_id = _current_request_id()
        return True


def _install_request_id_factory() -> None:
    """Make every record carry `request_id`, idempotently.

    Idempotence is not optional here: _setup_logging runs again on every config
    save and on the startup DB overlay, and a wrapper installed per call would
    nest N deep. The marker attribute makes re-installation a no-op.
    """
    existing = logging.getLogRecordFactory()
    if getattr(existing, "_sublarr_request_id", False):
        return

    def factory(*args, **kwargs):
        record = existing(*args, **kwargs)
        if not hasattr(record, "request_id"):
            record.request_id = _current_request_id()
        return record

    factory._sublarr_request_id = True  # type: ignore[attr-defined]
    logging.setLogRecordFactory(factory)


# Noisy third-party loggers stay capped regardless of the user-selected level.
# rebulk (guessit's rule engine) emits ~155 DEBUG records per filename parse;
# at DEBUG root level a single wanted-search burst produced ~8k records/min,
# each formatted twice (file + WebSocket handler), written to disk, and
# emitted to every connected browser — enough to starve concurrent API
# requests on low-power hosts (20s page opens on a Synology DS920+).
# The user-selected level still applies to all of Sublarr's own loggers.
_THIRD_PARTY_LOG_LEVELS = {
    "rebulk": logging.WARNING,
    "guessit": logging.WARNING,
    "watchdog": logging.WARNING,
    "urllib3": logging.WARNING,
    "chardet": logging.WARNING,
    "charset_normalizer": logging.WARNING,
    "engineio": logging.WARNING,
    "socketio": logging.WARNING,
    "apscheduler": logging.INFO,  # job-run lines are useful — keep INFO
}


# Rotation bounds and defaults. Single source of truth: PUT/GET
# /api/v1/logs/rotation imports these to validate input and to report the
# current window. They lived in two places before, and drifted — the API
# advertised 10 MB / 5 backups while the handler was hardcoded to 5 MB / 3, so
# the setting had no effect at all.
LOG_MAX_SIZE_MB_DEFAULT = 10
LOG_MAX_SIZE_MB_MIN = 1
LOG_MAX_SIZE_MB_MAX = 100
LOG_BACKUP_COUNT_DEFAULT = 5
LOG_BACKUP_COUNT_MIN = 1
LOG_BACKUP_COUNT_MAX = 20

# Config keys whose change requires _setup_logging() to run again. Two callers
# gate on this: the startup DB-overlay in app.py (logging is first configured
# from ENV/defaults before the DB is read) and the live-apply on config save in
# routes/config/core.py. Both previously hardcoded the level/file/format triple,
# which is why adding a rotation setting was not enough to make it take effect.
# tests/test_app_logging.py asserts this covers every `log_*` settings field.
LOGGING_CONFIG_KEYS = frozenset(
    {
        "log_level",
        "log_file",
        "log_format",
        "log_max_size_mb",
        "log_backup_count",
    }
)


def _clamped_int_setting(settings, name: str, default: int, low: int, high: int) -> int:
    """Read an integer rotation setting, clamped to [low, high].

    The value arrives from ``config_entries``, which is writable outside the
    route's validation (a direct DB edit, or a row written by a different
    version). Two failure modes are handled deliberately: a non-numeric row
    falls back to the default instead of stopping logging altogether, and the
    lower bound is never 0 because ``RotatingFileHandler`` reads
    ``maxBytes=0`` as "never roll over" — which would turn a size cap into
    unbounded growth.
    """
    raw = getattr(settings, name, default)
    try:
        value = int(raw)
    except (TypeError, ValueError):
        logging.getLogger(__name__).warning(
            "Invalid %s=%r in config; falling back to %d", name, raw, default
        )
        return default
    return max(low, min(value, high))


def _in_container() -> bool:
    """Best-effort container detection. Cheap, and wrong-answer-safe."""
    if os.path.exists("/.dockerenv"):
        return True
    try:
        with open("/proc/1/cgroup", encoding="utf-8") as fh:
            return any(marker in fh.read() for marker in ("docker", "kubepods", "containerd"))
    except OSError:
        return False


def _fingerprint_payload(settings) -> str:
    """One line identifying the instance that wrote this log file.

    Answers, without a round trip to the reporter, the questions every bug
    report needs: which version, on what, in which deployment mode, against
    which database, and how much history this file can hold. Two Discord
    reports were stalled for weeks on exactly these facts.
    """
    import platform

    from version import __version__

    if getattr(settings, "database_url", ""):
        backend = "postgres" if "postgres" in settings.database_url.lower() else "external"
    else:
        backend = "sqlite"

    if getattr(settings, "standalone_enabled", False):
        mode = "standalone"
    elif getattr(settings, "sonarr_url", "") or getattr(settings, "radarr_url", ""):
        mode = "arr"
    else:
        mode = "unconfigured"

    max_size_mb = _clamped_int_setting(
        settings,
        "log_max_size_mb",
        LOG_MAX_SIZE_MB_DEFAULT,
        LOG_MAX_SIZE_MB_MIN,
        LOG_MAX_SIZE_MB_MAX,
    )
    backup_count = _clamped_int_setting(
        settings,
        "log_backup_count",
        LOG_BACKUP_COUNT_DEFAULT,
        LOG_BACKUP_COUNT_MIN,
        LOG_BACKUP_COUNT_MAX,
    )

    fields = [
        f"version={__version__}",
        f"python={platform.python_version()}",
        f"os={platform.system()} {platform.release()} ({platform.machine()})",
        f"container={'yes' if _in_container() else 'no'}",
        f"db={backend}",
        f"mode={mode}",
        f"level={getattr(settings, 'log_level', '?')}",
        f"rotation={max_size_mb}MBx{backup_count}",
    ]
    return "sublarr-instance: " + " ".join(fields)


# Log paths already stamped in this process. Module-level because the handler is
# torn down and rebuilt on every _setup_logging call, so per-handler state cannot
# tell a genuine restart from a config save.
_stamped_log_paths: set[str] = set()


class FingerprintedRotatingFileHandler(RotatingFileHandler):
    """RotatingFileHandler that stamps its instance fingerprint into every file.

    Writing the fingerprint once at startup is not durable: rotation moves it
    to `sublarr.log.1` and eventually off the end of the backup chain, so the
    file a user uploads after a long uptime carries no identity. Re-stamping on
    every rollover makes each file self-describing on its own.

    The payload is built at write time from the live settings singleton, not
    captured when the handler is constructed. Most of what it reports comes
    from `config_entries` and can change without the handler ever being
    rebuilt — saving a Sonarr URL is not a logging change, so nothing re-runs
    `_setup_logging`. A frozen payload would keep re-stating the startup answer
    on every rollover for the whole uptime.

    The stamp is written through this handler's own formatter, so it obeys the
    text/JSON choice, and it is built with the live LogRecord factory so any
    attribute other filters or formats rely on is present.
    """

    def _write_fingerprint(self) -> None:
        if self.stream is None:
            return
        try:
            from config import get_settings

            record = logging.getLogRecordFactory()(
                "app_logging",
                logging.INFO,
                __file__,
                0,
                _fingerprint_payload(get_settings()),
                None,
                None,
            )
            self.stream.write(self.format(record) + self.terminator)
            self.flush()
        except Exception:
            # A diagnostic aid must never be able to break the log it annotates.
            pass

    def stamp_startup(self) -> None:
        """Stamp the target file once per process, per log path.

        The obvious guard — "only stamp an empty file" — was wrong, and wrong in
        exactly the case this feature exists for. On an upgrade the log file in
        /config already holds months of lines, so an empty-file check writes
        nothing and the instance stays unidentifiable until the file happens to
        rotate. That is the user who upgrades and immediately attaches their log
        to a bug report. Found on a live RC deploy; every test until then used a
        fresh file and could not see it.

        Keying on the path rather than a plain flag keeps two behaviours right:
        `_setup_logging` re-runs on every config save and must not stamp again
        (which is what the empty-file check was really protecting against), while
        a changed `log_file` genuinely starts a new file that does need one.
        """
        path = getattr(self, "baseFilename", None)
        if path is None or path in _stamped_log_paths:
            return
        _stamped_log_paths.add(path)
        self._write_fingerprint()

    def doRollover(self) -> None:  # noqa: N802 — stdlib camelCase override
        super().doRollover()
        self._write_fingerprint()


def stamp_log_fingerprint() -> None:
    """Stamp the startup fingerprint into the active log file.

    Split out of `_setup_logging` because of *when* it may run. Startup reads
    its configuration in two phases — ENV and defaults, then the
    `config_entries` overlay — and `_setup_logging` runs in phase one, because
    everything after it needs a logger. A stamp written there can only describe
    the half-loaded config: `mode` came out as `unconfigured` on every instance
    whose Sonarr/Radarr URL lives in the database, which is all of them.

    `create_app` therefore calls this once the overlay has been applied. The
    per-path guard in `stamp_startup` keeps it a no-op when the overlay already
    triggered a logging re-apply, so either route yields exactly one stamp.
    """
    for handler in logging.getLogger().handlers:
        if isinstance(handler, FingerprintedRotatingFileHandler):
            handler.stamp_startup()


def rotated_log_candidates(settings=None) -> list[str]:
    """The active log file plus one path per configured backup, newest first.

    Every consumer that reads log history — the support ZIP, the top-error
    parser, the redaction preview — must agree with the handler about how many
    rotated files exist. They each hardcoded a depth of 3, which was correct
    only while ``backupCount`` was itself hardcoded to 3; once rotation became
    user-configurable the bundle silently dropped everything from ``.4`` on.

    Paths are returned whether or not they exist: callers already skip missing
    files, and probing here would race with a rollover happening mid-read.
    """
    if settings is None:
        from config import get_settings

        settings = get_settings()
    log_file = getattr(settings, "log_file", "log/sublarr.log")
    backup_count = _clamped_int_setting(
        settings,
        "log_backup_count",
        LOG_BACKUP_COUNT_DEFAULT,
        LOG_BACKUP_COUNT_MIN,
        LOG_BACKUP_COUNT_MAX,
    )
    return [log_file] + [f"{log_file}.{i}" for i in range(1, backup_count + 1)]


# Parsing the lines this module writes. Two formats exist (text and JSON), and
# every reader — the /logs level filter, the support bundle's top-error parser,
# its recent-warnings file — used to understand the text one only, so on
# log_format=json the error list came back empty and the level filter matched
# nothing. They all go through these two helpers now.
#
# The bracketed id slot between level and logger name is consumed explicitly:
# a scheduler run label like `[wanted_search:a1b2c3d4]` contains a colon, and
# `[^:]+` would start the message mid-id. Optional, for logs predating the slot.
_TEXT_LINE_RE = re.compile(
    r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+\s+\[([A-Z]+)\]\s+"
    r"(?:\[[^\]]*\]\s+)?(?:[^:\s][^:]*:\s*(.*))?"
)


def parse_log_line(line: str) -> tuple[str, str, str | None] | None:
    """``(timestamp "YYYY-MM-DD HH:MM:SS", LEVEL, message)`` or None.

    None means "not the first line of a record" — a traceback or wrapped
    continuation line. The message is None when a text line has no
    ``logger: message`` part.
    """
    if line.startswith("{"):
        try:
            entry = json.loads(line)
        except ValueError:
            return None
        if not isinstance(entry, dict) or "level" not in entry:
            return None
        return (
            str(entry.get("timestamp", ""))[:19],
            str(entry["level"]).upper(),
            str(entry.get("message", "")),
        )
    match = _TEXT_LINE_RE.match(line)
    if match is None:
        return None
    return match.group(1), match.group(2), match.group(3)


def line_has_level(line: str, level: str) -> bool:
    """Whether a text or JSON log line carries ``level`` — substring-cheap."""
    return f"[{level}]" in line or f'"level": "{level}"' in line


class StructuredJSONFormatter(logging.Formatter):
    """JSON log formatter for structured logging (ELK, Loki, etc.)."""

    def format(self, record: logging.LogRecord) -> str:
        entry = {
            "timestamp": self.formatTime(record, self.datefmt),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
            "module": record.module,
            "function": record.funcName,
            "line": record.lineno,
        }

        # Read the id off the record rather than out of Flask's `g`: the record
        # factory already resolved it, and it answers for background work too.
        # Looking it up here a second time would see only requests and would
        # silently drop the scheduler run label that the text format shows.
        request_id = getattr(record, "request_id", None)
        if request_id and request_id != NO_REQUEST_ID:
            entry["request_id"] = str(request_id)

        if record.exc_info and record.exc_info[1]:
            entry["exception"] = {
                "type": type(record.exc_info[1]).__name__,
                "message": redact(str(record.exc_info[1])),
            }
            # The text format prints the traceback; this format used to drop it,
            # so anyone on log_format=json lost the one thing an error report
            # needs. exc_text is what SecretRedactionFilter already rendered and
            # scrubbed when the record went through a Sublarr handler.
            if not record.exc_text:
                record.exc_text = self.formatException(record.exc_info)
            entry["traceback"] = redact(record.exc_text)

        return json.dumps(entry, default=str)


def _has_app_context() -> bool:
    """Check if Flask application context is active (avoids import cycle)."""
    try:
        from flask import has_app_context

        return has_app_context()
    except Exception:
        return False


class SocketIOLogHandler(logging.Handler):
    """Emits log entries to connected WebSocket clients."""

    _DB_ERROR_PATTERNS = ("psycopg2.errors.", "psycopg2.exc.", "sqlalchemy.exc.")

    def __init__(self, sio):
        super().__init__()
        self.sio = sio

    @staticmethod
    def _sanitize(message: str) -> str:
        """Strip DB-internal error details before emitting to WebSocket clients.

        Replaces the portion of the message starting at the DB exception class name
        with a generic placeholder so that table names, column names, and query
        fragments never reach browser clients.
        """
        for pattern in SocketIOLogHandler._DB_ERROR_PATTERNS:
            idx = message.find(pattern)
            if idx != -1:
                return message[:idx] + "Database error (details hidden)"
        return message

    def emit(self, record: logging.LogRecord) -> None:
        try:
            server = getattr(self.sio, "server", None)
            if server is None or not server.manager.rooms.get("/", {}).get("logs"):
                return  # nobody watching the Logs page — skip all work
            msg = self._sanitize(self.format(record))
            self.sio.emit("log_entry", {"message": msg}, to="logs")
        except Exception:
            pass  # Never break the app because of log emission


def _setup_logging(settings, *, stamp: bool = True) -> None:
    """Set up file handler and WebSocket handler on the root logger.

    Idempotent: removes any previously-installed Sublarr handlers before
    re-adding them. create_app() can be called more than once in the same
    process (tests, WSGI reloaders) — without this guard each invocation
    leaks another RotatingFileHandler + SocketIOLogHandler, producing N-fold
    duplicated log lines.

    Args:
        stamp: Write the instance fingerprint into the log file. Callers that
            run before the `config_entries` overlay pass False and stamp later
            via `stamp_log_fingerprint()`; a stamp taken at that point would
            describe a config that is only half loaded. Every other caller
            (a settings save re-applying logging, a changed log path) already
            has the final settings and stamps here — see `stamp_log_fingerprint`.
    """
    raw_level = str(getattr(settings, "log_level", "INFO") or "INFO")
    log_level = logging.getLevelName(raw_level.strip().upper())
    level_is_valid = isinstance(log_level, int)
    if not level_is_valid:
        log_level = logging.INFO
    # Before basicConfig: LOG_FORMAT references %(request_id)s, and the handler
    # basicConfig installs would format records that do not have it yet.
    _install_request_id_factory()
    logging.basicConfig(level=log_level, format=LOG_FORMAT)

    root = logging.getLogger()
    # basicConfig() is a no-op once the root logger already has handlers, so on
    # a re-run (settings-save → live re-apply) it would NOT update the level.
    # Set it explicitly to keep _setup_logging idempotent for level changes.
    root.setLevel(log_level)

    if not level_is_valid:
        # Used to fall back to INFO silently, so a typo in the env var looked
        # like "DEBUG does nothing". basicConfig's console handler is live by
        # now, so the line is visible even on the very first start.
        logging.getLogger(__name__).warning(
            "Unknown log level %r (SUBLARR_LOG_LEVEL / log_level) — using INFO. "
            "Valid: DEBUG, INFO, WARNING, ERROR, CRITICAL",
            raw_level,
        )

    for noisy_name, noisy_level in _THIRD_PARTY_LOG_LEVELS.items():
        logging.getLogger(noisy_name).setLevel(noisy_level)

    for existing in list(root.handlers):
        if isinstance(existing, (RotatingFileHandler, SocketIOLogHandler)):
            root.removeHandler(existing)
            try:
                existing.close()
            except Exception:
                pass

    use_json = getattr(settings, "log_format", "text").lower() == "json"
    if use_json:
        formatter: logging.Formatter = StructuredJSONFormatter()
    else:
        formatter = logging.Formatter(LOG_FORMAT)

    log_file = settings.log_file
    try:
        log_dir = os.path.dirname(log_file)
        if log_dir:
            os.makedirs(log_dir, exist_ok=True)

        max_size_mb = _clamped_int_setting(
            settings,
            "log_max_size_mb",
            LOG_MAX_SIZE_MB_DEFAULT,
            LOG_MAX_SIZE_MB_MIN,
            LOG_MAX_SIZE_MB_MAX,
        )
        backup_count = _clamped_int_setting(
            settings,
            "log_backup_count",
            LOG_BACKUP_COUNT_DEFAULT,
            LOG_BACKUP_COUNT_MIN,
            LOG_BACKUP_COUNT_MAX,
        )
        fh = FingerprintedRotatingFileHandler(
            log_file,
            maxBytes=max_size_mb * 1024 * 1024,
            backupCount=backup_count,
            encoding="utf-8",
        )
        fh.setLevel(log_level)
        fh.setFormatter(formatter)
        root.addHandler(fh)
        if stamp:
            fh.stamp_startup()
    except Exception as e:
        logging.getLogger(__name__).warning("Could not set up log file %s: %s", log_file, e)

    ws_handler = SocketIOLogHandler(socketio)
    # Never stream DEBUG records to browsers: every record goes to ALL
    # connected clients, and DEBUG volume floods both the server (per-record
    # serialize + emit) and every open tab. The Logs page still shows DEBUG
    # lines via its 10s file-backed API poll.
    ws_handler.setLevel(max(log_level, logging.INFO))
    ws_handler.setFormatter(logging.Formatter(LOG_FORMAT))
    root.addHandler(ws_handler)

    # Every handler on root, not just ours: basicConfig installs a console
    # StreamHandler using LOG_FORMAT too, and a record reaching it without
    # `request_id` would raise inside Formatter.format.
    #
    # The same reach is why secret redaction lives here and not on a logger:
    # the file, the console and the live WebSocket stream all write through
    # these handlers, so a credential in any record is scrubbed before it
    # exists anywhere a user could copy it from. It used to be installed on
    # the two urllib3 loggers only (providers/http_session.py).
    #
    # Line breaks in logged values are escaped first (log injection, see
    # log_safety.py), then secrets are redacted from the rendered message.
    for handler in root.handlers:
        if not any(isinstance(f, RequestIdFilter) for f in handler.filters):
            handler.addFilter(RequestIdFilter())
        if not any(isinstance(f, LineBreakEscapeFilter) for f in handler.filters):
            handler.addFilter(LineBreakEscapeFilter())
        if not any(isinstance(f, SecretRedactionFilter) for f in handler.filters):
            handler.addFilter(SecretRedactionFilter())