Files
siftlode/backend/tests/test_logsink.py
T
peter 54d19cc386 fix(logs): deep-review round 1 — say each failure once, and scrub it everywhere
- yt-dlp's text is scrubbed wherever it is logged, not only in the adapter:
  `formats.log_safe` takes out the URL, the `[extractor] <id>:` prefix and
  file paths, and the job-failed, refused, edit-failed and backfill lines use
  it. Done lines log the asset id, not the title-built path. The PII contract
  now bans `rel`/`rel_path`/`title`.
- A traceback goes with a download/edit failure only when it is our own code
  failing; for yt-dlp's DownloadError, our RuntimeErrors and OSErrors its foot
  is the unscrubbed message.
- yt-dlp's `error` maps to WARNING: it fires on attempts the fallback client
  set recovers from; the job's verdict is `download.job.failed`.
- The sink groups a bare `%s` pass-through by its text (numbers folded), and
  a process's last batch closes the repeat window, so a short-lived download
  child still reports what it dropped.
- Worker-side failures (stall, wall-clock, no asset, cached asset error) log
  `download.job.failed` with a cause; they left no line at all before. The
  crash, supervisor-error and non-zero-exit lines carry the code too.
- An edit failure is one coded line in `_fail_edit`, for every path.
- `logs.get_logger("__main__")` resolves to the `-m` module, so the worker
  and the download child keep their names.
- TRACE lines carrying user content are gated on `logs.consented_trace()`,
  not on the process level; a contract test pins it.
- The OAuth token refresh goes through `_upstream_failure`; transport errors
  carry reason `transport`. Subscribe recognises a duplicate by reason code
  only (the prose fallback was dead).
- Once-only guards for a stale sysconfig override and a refusing HLS session
  directory; item_detail splits Plex failures from our own parse bugs.
- The playlist breaker line names the playlist and the action; comments that
  were false (runner noqa, `_merge_vtt`, sink grouping) are corrected.
2026-09-18 03:16:15 +02:00

297 lines
12 KiB
Python

"""E22 S3 — the `log_events` sink: what reaches the table, in what shape, and what it refuses to do.
The sink is driven synchronously (`write_pending()` on the test's own thread) so nothing here waits on
a timer. It is attached to a private logger rather than the root, for the same reason test_logs.py's
capture handler is: a `configure_logging` call elsewhere replaces the root handlers.
DB lane (`db` fixture): the rows are read back from a real Postgres.
"""
import logging
import threading
from datetime import datetime, timedelta, timezone
import pytest
from fastapi import BackgroundTasks
from sqlalchemy import create_engine, func, select
from app import logs, logsink, scheduler
from app.auth import purge_user
from app.db import engine
from app.models import LogEvent
@pytest.fixture
def stored(db):
"""A LogSink on the logger `siftlode.sinktest`, and a reader for the rows it wrote."""
sink = logsink.LogSink(engine)
logger = logging.getLogger("siftlode.sinktest")
logger.addHandler(sink)
logger.setLevel(logs.TRACE)
def rows() -> list[LogEvent]:
sink.write_pending()
db.expire_all()
return list(db.execute(select(LogEvent).order_by(LogEvent.id)).scalars())
try:
yield sink, logger, rows
finally:
logger.removeHandler(sink)
def test_warnings_and_request_lines_are_stored_and_everything_else_is_not(stored):
_sink, log, rows = stored
log.info("an ordinary info line")
log.debug("GET /healthz 200", extra=logs.event("http.request", path="/healthz", status=200))
log.info("GET /api/feed 200", extra=logs.event("http.request", path="/api/feed", status=200))
log.warning("scanner", extra={**logs.event("http.request", status=404), logs.PERSIST: False})
log.warning("disk nearly full")
assert [r.msg for r in rows()] == ["GET /api/feed 200", "disk nearly full"]
def test_a_row_is_redacted_exactly_like_the_stdout_line(stored):
_sink, log, rows = stored
try:
raise ValueError("reset link https://x.test/#reset=abc123")
except ValueError:
log.exception(
"sign-in failed for someone@example.com",
extra=logs.event("auth.failed", password="hunter2", reason="bad_password"),
)
(row,) = rows()
assert row.level == "ERROR" and row.sev == logs.SEVERITY["ERROR"]
assert row.event == "auth.failed"
assert "someone@example.com" not in row.msg and logs.REDACTED_EMAIL in row.msg
assert row.attrs == {"password": logs.REDACTED, "reason": "bad_password"}
assert "abc123" not in row.exc and "ValueError" in row.exc
assert row.source == logs.current_source() and row.version
def test_correlation_is_captured_when_the_line_is_logged_not_when_it_is_written(stored):
_sink, log, rows = stored
with logs.bind(job_id=7, request_id="req-0123456789"):
log.warning("inside the job")
# The writer runs later, outside the bind — the row still belongs to job 7.
(row,) = rows()
assert (row.job_id, row.request_id) == (7, "req-0123456789")
def test_a_flood_of_one_message_is_stored_up_to_the_limit_and_then_summarised(stored):
sink, log, rows = stored
limit = logsink.REPEATS_PER_MINUTE
for i in range(limit + 70):
log.warning("RSS poll skipped for channel %s: HTTP 404", f"UC{i}")
log.warning("a different message")
assert len(rows()) == limit + 1 # the flood capped; the other message untouched
sink._repeats.started -= logsink.REPEAT_WINDOW_SECONDS # the minute is over
notice = rows()[-1]
assert notice.event == "log.repeats_suppressed"
assert notice.attrs["suppressed"] == 70
assert notice.attrs["template"] == "RSS poll skipped for channel %s: HTTP 404"
def test_a_pass_through_template_groups_by_its_text_not_by_the_bare_placeholder(stored):
"""`"%s"` is how the yt-dlp adapter hands over yt-dlp's own line, and as a key it says nothing:
every distinct warning would share one bucket and the thirty-first — whatever it said — would be
dropped as a "repeat" of the first. The text groups instead, numbers folded, so a retry storm is
still one repeat and the warning that explains the failure still gets through."""
sink, log, rows = stored
limit = logsink.REPEATS_PER_MINUTE
for i in range(limit + 10):
log.warning("%s", f"[download] Got error: timed out. Retrying fragment {i} (1/10)...")
log.warning("%s", "[youtube] …: Requested format is not available")
stored_rows = rows()
assert len(stored_rows) == limit + 1
assert stored_rows[-1].msg == "[youtube] …: Requested format is not available"
sink._repeats.started -= logsink.REPEAT_WINDOW_SECONDS
notice = rows()[-1]
assert notice.attrs["suppressed"] == 10
assert notice.attrs["template"].startswith(
"[download] Got error: timed out. Retrying fragment #"
)
def test_a_process_that_ends_inside_the_minute_still_says_what_it_dropped(stored):
"""Every download child lives well under the limiter's minute. Its last batch closes the window
early, or the repeats it dropped would go unreported."""
sink, log, rows = stored
for i in range(logsink.REPEATS_PER_MINUTE + 3):
log.warning("RSS poll skipped for channel %s: HTTP 404", f"UC{i}")
rows()
sink.write_pending(final=True)
notice = rows()[-1]
assert notice.event == "log.repeats_suppressed"
assert notice.attrs["suppressed"] == 3
def test_request_lines_are_never_treated_as_a_flood(stored):
_sink, log, rows = stored
for _ in range(logsink.REPEATS_PER_MINUTE + 5):
log.info("%s %s %s", "GET", "/api/feed", 200, extra=logs.event("http.request"))
assert len(rows()) == logsink.REPEATS_PER_MINUTE + 5
def test_a_full_queue_drops_and_says_how_many(db, monkeypatch):
monkeypatch.setattr(logsink, "QUEUE_MAX", 3)
sink = logsink.LogSink(engine)
log = logging.getLogger("siftlode.sinktest.small")
log.addHandler(sink)
try:
for i in range(5):
log.warning("message number %d", i)
finally:
log.removeHandler(sink)
assert sink.write_pending() == 4
notice = db.execute(select(LogEvent).where(LogEvent.event == "log.sink.dropped")).scalar_one()
assert notice.attrs == {"dropped": 2}
def test_an_unreachable_database_costs_the_batch_not_the_caller(capsys):
dead = create_engine(
"postgresql+psycopg://x:y@127.0.0.1:1/none", connect_args={"connect_timeout": 1}
)
sink = logsink.LogSink(dead)
log = logging.getLogger("siftlode.sinktest.dead")
log.addHandler(sink)
try:
log.error("this has nowhere to go") # must not raise
finally:
log.removeHandler(sink)
assert sink.write_pending() == 0
assert "could not store 1 log events" in capsys.readouterr().err
def test_the_writer_thread_does_not_store_its_own_records(stored):
sink, log, rows = stored
sink._writer = threading.current_thread() # as if this line came from inside the writer
log.error("a driver warning raised while inserting")
sink._writer = None
assert rows() == []
def test_gc_keeps_the_retention_window_and_never_keeps_forever(db):
now = datetime.now(timezone.utc)
def add(age: timedelta) -> None:
db.add(
LogEvent(ts=now - age, level="WARNING", sev=13, source="api", logger="t", msg=str(age))
)
for age in (timedelta(hours=1), timedelta(days=2), timedelta(days=8)):
add(age)
db.commit()
assert logsink.gc(db, 7) == 1
assert logsink.gc(db, 0) == 1 # 0 is not "forever": stored logs always expire (floor: 1 day)
db.commit()
assert [r.msg for r in db.execute(select(LogEvent)).scalars()] == [str(timedelta(hours=1))]
def test_log_gc_is_a_scheduled_job():
assert "log_gc" in scheduler.JOB_FUNCS and scheduler.JOB_INTERVALS["log_gc"] >= 1
def test_deleting_an_account_deletes_its_stored_log_events(db, make_user):
gone, kept = make_user(), make_user()
for uid in (gone.id, kept.id, gone.id):
db.add(
LogEvent(
ts=datetime.now(timezone.utc),
level="INFO",
sev=9,
source="api",
logger="t",
msg="x",
user_id=uid,
)
)
db.commit()
purge_user(db, gone, BackgroundTasks())
assert [r.user_id for r in db.execute(select(LogEvent)).scalars()] == [kept.id]
def test_an_erased_accounts_late_lines_are_stored_without_it(stored, db, make_user):
"""The DELETE in purge_user cannot reach a row still on its way: one queued before the erasure,
or the erasing request's own access line, logged after the handler committed."""
_sink, log, rows = stored
user = make_user()
# `logs._erased` outlives the test; conftest's `_reset_log_erasures` clears it before each one.
log.warning("queued before the erasure", extra={"user_id": user.id})
purge_user(db, user, BackgroundTasks())
log.info("DELETE /api/me/account 200", extra={**logs.event("http.request"), "user_id": user.id})
assert [(r.msg, r.user_id) for r in rows()] == [
("queued before the erasure", None),
("DELETE /api/me/account 200", None),
]
def test_every_path_into_the_table_drops_an_erased_account():
"""The two behavioural tests above cover the two writers there are today (this sink and
`POST /api/diagnostics/events`). This one covers the THIRD one nobody has written yet: a new
`insert(LogEvent)` that forgets the filter would leave an erased account's rows named, and no
test of an existing path can notice. Source-level on purpose — it is a rule about call sites."""
import pathlib
root = pathlib.Path(__file__).resolve().parents[1] / "app"
unfiltered = [
f"{path.relative_to(root)}:{n}"
for path in root.rglob("*.py")
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
if "insert(LogEvent)" in line and "without_erased" not in line
]
assert unfiltered == [], f"insert(LogEvent) without logsink.without_erased at {unfiltered}"
def test_a_nul_character_costs_nothing_but_itself(stored):
"""Postgres refuses NUL in text and JSONB, and a refused row fails its whole batch — every other
user's rows with it. `GET /assets/%00` logs exactly that."""
_sink, log, rows = stored
log.warning("before")
log.warning("GET /assets/\x00 404", extra=logs.event("http.request", path="/assets/\x00"))
log.warning("after")
before, nul, after = rows()
assert (before.msg, after.msg) == ("before", "after")
assert nul.msg == "GET /assets/� 404" and nul.attrs["path"] == "/assets/�"
def test_the_writer_drains_what_was_queued_when_it_is_stopped(db):
sink = logsink.LogSink(engine)
log = logging.getLogger("siftlode.sinktest.drain")
log.addHandler(sink)
try:
for i in range(logsink.BATCH_MAX + 5):
# noqa G004: the f-string is the POINT — a distinct rendered message per line is
# what keeps the flood limiter (which groups by template) out of this case.
log.warning(f"line {i}") # noqa: G004
finally:
log.removeHandler(sink)
sink._stopping.set()
sink._run() # stopped before its first wait: everything goes through the drain
assert (
db.execute(select(func.count()).select_from(LogEvent)).scalar_one() == logsink.BATCH_MAX + 5
)
def test_the_running_sink_keeps_the_base_level_for_everyone_the_elevation_is_not_for(
db, monkeypatch
):
"""`logsink.start()` as production runs it. One user's elevation lowers the ROOT logger, so an
ordinary request line below the base level reaches every handler — the sink's own level gate is
what keeps it out of the table, as stdout's keeps it off the terminal."""
root = logging.getLogger()
saved = (logs._base_level, dict(logs._elevations), logs._root_applied, root.level)
monkeypatch.setattr(logsink.settings, "log_db_sink", True)
logs.set_level("WARNING")
logs._track_elevation(1, logging.DEBUG, datetime.now(timezone.utc) + timedelta(hours=1))
log = logging.getLogger("siftlode.sinktest.live")
try:
logsink.start()
log.info("GET /api/feed 200", extra={**logs.event("http.request"), "user_id": 2})
log.warning("a warning")
finally:
logsink.stop()
logs._base_level, logs._root_applied = saved[0], saved[2]
logs._elevations.clear()
logs._elevations.update(saved[1])
root.setLevel(saved[3])
assert [r.msg for r in db.execute(select(LogEvent)).scalars()] == ["a warning"]