A deployment printed four different log formats and none of them could be joined up: uvicorn's log_config.json for the API, a hand-rolled StreamHandler in main.py for anything not started by uvicorn, logging.basicConfig() in the worker and in the download child (no timestamp at all), and alembic's fileConfig. app/logs.py replaces all four with one JSON line per event (OpenTelemetry's log data model, no SDK): ts, level, severity, source, logger, event code, message, attributes, plus the correlation the context carries — request id, account, download job. Redaction is applied at the HANDLER, not at the call sites: every message, every attribute and every traceback is scrubbed on the way out, so a secret cannot leak by being forgotten at one of ~125 call sites. Passwords, tokens, cookies, secret config values and email addresses never reach a line. The access log is ours now (`http.request`): the route template instead of the concrete path, the status, the duration and the account — and never the query string, which uvicorn's access log printed on every single request (`?account=`, `?source=`, the `?token=` of a share link). A well-formed client request id is accepted and echoed in X-Request-Id, so a browser line and a server line join up. The account comes from `request.state`, filled by the auth dependency: the middleware is the outermost one, so it runs outside SessionMiddleware and in a different task context. `log_level` becomes an admin knob (sysconfig), applied without a restart and re-applied at startup — best-effort on purpose, since a logging knob must never be what stops the app from booting. Every compose service gets bounded json-file logs (3×50 MB): Docker keeps every line forever by default, and the API now writes one line per request. Verified on the localdev stack: JSON everywhere including alembic and the worker, the query strings gone, a supplied X-Request-Id echoed and logged, and three /healthz hits of which only the one made while the level was DEBUG produced a line.
435 lines
18 KiB
Python
435 lines
18 KiB
Python
"""Structured logging for every Siftlode process — the ONE place logging is configured.
|
|
|
|
Before this module a single deployment printed FOUR different log formats: uvicorn's
|
|
`log_config.json` for the API, a hand-rolled StreamHandler in `main.py` for everything that did not
|
|
start under uvicorn (tests, scripts), `logging.basicConfig()` in the worker and in the download
|
|
child (no timestamp at all), and alembic's `fileConfig`. None of them carried a correlation id, and
|
|
uvicorn's access log printed the query string of every request (`?account=1`, `?source=feed`) — so
|
|
the logs were both unjoinable and leaky. `configure_logging()` replaces all four.
|
|
|
|
Event shape — the OpenTelemetry log data model, without the SDK; one JSON object per line:
|
|
ts · level · sev · source · logger · event · msg · attrs
|
|
plus whatever correlation the context carries (`request_id`, `user_id`, `debug_session_id`,
|
|
`job_id`) and the app version. That shape is what E22.6's admin console filters on and what a Gitea
|
|
issue attachment will carry, so it is deliberately flat and stable.
|
|
|
|
Call sites stay ordinary stdlib logging:
|
|
|
|
log = logging.getLogger("siftlode.downloads")
|
|
log.info("job finished", extra=logs.event("download.job.done", job_id=7, bytes=n))
|
|
|
|
`event()` is optional — a bare `log.info("…")` still comes out well-formed — but the dotted code is
|
|
what makes a line queryable later, so add one wherever the line matters.
|
|
|
|
REDACTION IS APPLIED AT THE HANDLER, not at the call site: every message, every attr value and
|
|
every traceback passes `redact()` on the way out. That is the whole point — a secret cannot be
|
|
leaked by forgetting to redact it at one of ~125 call sites, and a new call site is safe by
|
|
default. Never logged, at any level: passwords, tokens (session, reset/verify, setup, share,
|
|
OAuth), cookie blobs, secret config values, and email addresses.
|
|
"""
|
|
|
|
import contextlib
|
|
import contextvars
|
|
import json
|
|
import logging
|
|
import logging.config
|
|
import re
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from app.config import settings
|
|
|
|
# One level below DEBUG for the "Teljes / TRACE" diagnostics level (search strings, redacted request
|
|
# bodies). Registered on import so `logging.getLogger(...).log(logs.TRACE, …)` and a `LOG_LEVEL=TRACE`
|
|
# both work in every process, including one that never calls configure_logging (a test).
|
|
TRACE = 5
|
|
logging.addLevelName(TRACE, "TRACE")
|
|
|
|
# OpenTelemetry severity numbers for our level names. Kept beside the level because the number is
|
|
# what a log backend sorts and filters on; the name is for humans reading a terminal.
|
|
SEVERITY: dict[str, int] = {
|
|
"TRACE": 1,
|
|
"DEBUG": 5,
|
|
"INFO": 9,
|
|
"WARNING": 13,
|
|
"ERROR": 17,
|
|
"CRITICAL": 21,
|
|
}
|
|
|
|
# Which process this is. Set by configure_logging; every line carries it so one Loki/DB query can
|
|
# separate the API's view of a job from the worker's.
|
|
_source = "api"
|
|
|
|
|
|
# --- correlation context -------------------------------------------------------------------
|
|
# contextvars, like quota's actor attribution (app/quota.py): set at an entry point, read by the
|
|
# formatter, never threaded through call signatures. A child task copies the context at creation,
|
|
# so anything set before `await call_next(...)` is visible to the whole request.
|
|
_request_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|
"log_request_id", default=None
|
|
)
|
|
_user_id: contextvars.ContextVar[int | None] = contextvars.ContextVar("log_user_id", default=None)
|
|
_debug_session: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|
"log_debug_session", default=None
|
|
)
|
|
_job_id: contextvars.ContextVar[int | None] = contextvars.ContextVar("log_job_id", default=None)
|
|
|
|
_CONTEXT_VARS = {
|
|
"request_id": _request_id,
|
|
"user_id": _user_id,
|
|
"debug_session_id": _debug_session,
|
|
"job_id": _job_id,
|
|
}
|
|
|
|
|
|
def set_user(user_id: int | None) -> None:
|
|
"""Attribute the rest of this request's log lines to `user_id`. Called from the auth dependency
|
|
— the one place the session is actually resolved into an account — rather than from the request
|
|
middleware, which runs OUTSIDE the session middleware and in a different task context."""
|
|
_user_id.set(user_id)
|
|
|
|
|
|
def set_job(job_id: int | None) -> None:
|
|
"""Attribute this process's log lines to a download job — for the download CHILD, whose whole
|
|
life is one job (the worker uses `bind()` per job instead, since it juggles several)."""
|
|
_job_id.set(job_id)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def bind(**fields: Any):
|
|
"""Bind correlation fields for the duration of a block (a worker job, a scheduler run):
|
|
|
|
with logs.bind(job_id=job.id):
|
|
...
|
|
|
|
Unknown field names are ignored rather than raising — a caller binding a field this module does
|
|
not know about is a typo, not a reason to take down a download."""
|
|
tokens = [(_CONTEXT_VARS[k].set(v)) for k, v in fields.items() if k in _CONTEXT_VARS]
|
|
keys = [k for k in fields if k in _CONTEXT_VARS]
|
|
try:
|
|
yield
|
|
finally:
|
|
for key, token in zip(keys, tokens):
|
|
_CONTEXT_VARS[key].reset(token)
|
|
|
|
|
|
def event(code: str, **attrs: Any) -> dict[str, Any]:
|
|
"""`extra=` payload for a log call: a stable dotted event code plus structured attributes.
|
|
|
|
Returns a plain dict so it composes with stdlib logging and costs nothing when the level is off
|
|
(the dict is built either way; keep the attrs cheap — no DB reads inside the call)."""
|
|
return {"event": code, "attrs": attrs}
|
|
|
|
|
|
# --- redaction -----------------------------------------------------------------------------
|
|
# Substring matching, not exact keys: the same secret travels as `password`, `current_password`,
|
|
# `new_password`, `smtp_password`, `plex_server_token`, `access_token`, … Matching on the PART is
|
|
# what makes a key we have never seen safe by default.
|
|
_SECRET_KEY_PARTS = (
|
|
"password",
|
|
"passphrase",
|
|
"token",
|
|
"secret",
|
|
"cookie",
|
|
"authorization",
|
|
"auth_header",
|
|
"ciphertext",
|
|
"private_key",
|
|
"api_key",
|
|
"apikey",
|
|
"credential",
|
|
"session_key",
|
|
)
|
|
|
|
# Query/fragment parameters that carry a credential in a URL we log (OAuth `code`/`state`, our own
|
|
# `?token=` setup and share links, `#verify=` / `#reset=` fragments).
|
|
_SECRET_PARAM_RE = re.compile(
|
|
r"\b((?:access_token|refresh_token|id_token|token|code|state|key|secret|password|pwd|sig|"
|
|
r"signature|verify|reset|setup)=)([^&\s\"']+)",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# Deliberately loose: over-matching redacts a non-address that looks like one (harmless), while
|
|
# under-matching puts a real address in a log we attach to an issue (not harmless).
|
|
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
|
|
|
REDACTED = "[redacted]"
|
|
REDACTED_EMAIL = "[email]"
|
|
|
|
|
|
def redact_text(text: str) -> str:
|
|
"""Scrub a free-text string: credential-bearing URL params and email addresses."""
|
|
scrubbed = _SECRET_PARAM_RE.sub(lambda m: f"{m.group(1)}{REDACTED}", text)
|
|
return _EMAIL_RE.sub(REDACTED_EMAIL, scrubbed)
|
|
|
|
|
|
def redact(value: Any, *, key: str = "") -> Any:
|
|
"""Recursively scrub a structure destined for the log.
|
|
|
|
A value whose KEY names a secret is dropped whole (we never log a prefix of a token — a prefix
|
|
is still a leak and still useless). Everything else is scrubbed as text. Depth is bounded by
|
|
the structures we actually log (attrs dicts), and non-JSON types fall through to `str`."""
|
|
lowered = key.lower()
|
|
if key and any(part in lowered for part in _SECRET_KEY_PARTS):
|
|
return REDACTED
|
|
if isinstance(value, dict):
|
|
return {k: redact(v, key=str(k)) for k, v in value.items()}
|
|
if isinstance(value, (list, tuple)):
|
|
return [redact(v, key=key) for v in value]
|
|
if isinstance(value, str):
|
|
return redact_text(value)
|
|
return value
|
|
|
|
|
|
# --- formatters ----------------------------------------------------------------------------
|
|
# Attributes every LogRecord carries; anything else a caller passed via `extra=` is a structured
|
|
# attribute and is merged into `attrs` (so `extra={"job_id": 7}` works as well as `event(...)`).
|
|
_RECORD_STANDARD = frozenset(
|
|
vars(logging.LogRecord("", 0, "", 0, "", None, None)).keys()
|
|
| {"message", "asctime", "taskName", "event", "attrs"}
|
|
)
|
|
|
|
|
|
class ContextFilter(logging.Filter):
|
|
"""Copies the correlation contextvars onto the record. A Filter (not the formatter) so the
|
|
values are captured at emit time, before a QueueHandler can move the record to another
|
|
thread — the DB sink in S3 depends on that."""
|
|
|
|
def filter(self, record: logging.LogRecord) -> bool:
|
|
for name, var in _CONTEXT_VARS.items():
|
|
value = var.get()
|
|
if value is not None and not hasattr(record, name):
|
|
setattr(record, name, value)
|
|
return True
|
|
|
|
|
|
def _record_attrs(record: logging.LogRecord) -> dict[str, Any]:
|
|
extra = {k: v for k, v in vars(record).items() if k not in _RECORD_STANDARD}
|
|
attrs = dict(getattr(record, "attrs", None) or {})
|
|
attrs.update({k: v for k, v in extra.items() if k not in _CONTEXT_VARS})
|
|
return attrs
|
|
|
|
|
|
def _safe_message(record: logging.LogRecord) -> str:
|
|
"""`record.getMessage()` raises if the args don't match the format string. A broken log call
|
|
must not take down the caller, so the failure is reported IN the line instead."""
|
|
try:
|
|
return record.getMessage()
|
|
except Exception as exc: # noqa: BLE001 — any formatting error, reported not raised
|
|
return f"{record.msg!r} (log formatting failed: {exc})"
|
|
|
|
|
|
class JsonFormatter(logging.Formatter):
|
|
"""One JSON object per line — the shape Loki/the `log_events` table both ingest."""
|
|
|
|
def format(self, record: logging.LogRecord) -> str:
|
|
payload: dict[str, Any] = {
|
|
"ts": datetime.fromtimestamp(record.created, timezone.utc).isoformat(
|
|
timespec="milliseconds"
|
|
),
|
|
"level": record.levelname,
|
|
"sev": SEVERITY.get(record.levelname, 9),
|
|
"source": _source,
|
|
"logger": record.name,
|
|
"msg": redact_text(_safe_message(record)),
|
|
}
|
|
code = getattr(record, "event", None)
|
|
if code:
|
|
payload["event"] = code
|
|
for name in _CONTEXT_VARS:
|
|
value = getattr(record, name, None)
|
|
if value is not None:
|
|
payload[name] = value
|
|
attrs = _record_attrs(record)
|
|
if attrs:
|
|
payload["attrs"] = redact(attrs)
|
|
if record.exc_info:
|
|
payload["exc"] = redact_text(self.formatException(record.exc_info))
|
|
payload["version"] = settings.app_version
|
|
return json.dumps(payload, ensure_ascii=False, default=str)
|
|
|
|
|
|
class TextFormatter(logging.Formatter):
|
|
"""Human-readable single line for a terminal (`LOG_FORMAT=text`): the same content, ordered for
|
|
reading. Redaction applies here too — a dev log is still a log someone pastes into an issue."""
|
|
|
|
def format(self, record: logging.LogRecord) -> str:
|
|
stamp = datetime.fromtimestamp(record.created, timezone.utc).strftime("%H:%M:%S.%f")[:-3]
|
|
head = f"{stamp} {record.levelname:<5} [{record.name}]"
|
|
rid = getattr(record, "request_id", None)
|
|
if rid:
|
|
head += f" {rid}"
|
|
code = getattr(record, "event", None)
|
|
line = f"{head} {code + ' — ' if code else ''}{redact_text(_safe_message(record))}"
|
|
attrs = _record_attrs(record)
|
|
if attrs:
|
|
line += f" {json.dumps(redact(attrs), ensure_ascii=False, default=str)}"
|
|
if record.exc_info:
|
|
line += "\n" + redact_text(self.formatException(record.exc_info))
|
|
return line
|
|
|
|
|
|
# --- configuration -------------------------------------------------------------------------
|
|
# Third-party loggers that are noisy at our own level. Pinned explicitly so the diagnostics knob can
|
|
# lower the ROOT level to DEBUG/TRACE without drowning the output in SQL statements and HTTP chatter
|
|
# — raising THESE is a deliberate, separate act (E22.6's console).
|
|
_THIRD_PARTY_LEVELS = {
|
|
"sqlalchemy": "WARNING",
|
|
"sqlalchemy.engine": "WARNING",
|
|
"apscheduler": "WARNING",
|
|
"urllib3": "WARNING",
|
|
"httpx": "WARNING",
|
|
"httpcore": "WARNING",
|
|
"asyncio": "WARNING",
|
|
"multipart": "WARNING",
|
|
"PIL": "WARNING",
|
|
"alembic": "INFO",
|
|
# uvicorn's own startup/shutdown/WebSocket lines are worth keeping; its ACCESS log is not —
|
|
# it is one line per request with the query string in it, replaced by `log_requests` below.
|
|
"uvicorn": "INFO",
|
|
"uvicorn.error": "INFO",
|
|
"uvicorn.access": "CRITICAL",
|
|
}
|
|
|
|
|
|
def resolve_level(raw: str | None) -> int:
|
|
"""Level name (case-insensitive) → number, falling back to INFO for an unknown value. A typo in
|
|
a config knob must not silence the app."""
|
|
if not raw:
|
|
return logging.INFO
|
|
value = logging.getLevelName(raw.strip().upper())
|
|
return value if isinstance(value, int) else logging.INFO
|
|
|
|
|
|
def configure_logging(source: str, *, level: str | None = None) -> None:
|
|
"""Configure logging for THIS process. Call once, first thing, in every entry point:
|
|
`main.py` (api), `worker.main`, `downloads/runner.main`, `alembic/env.py`.
|
|
|
|
`source` names the process in every line. `level` overrides `LOG_LEVEL` (the bootstrap default);
|
|
the runtime admin knob goes through `set_level()` instead, so changing it needs no restart.
|
|
|
|
Our loggers deliberately keep `propagate=True` and own no handler: everything reaches the single
|
|
root StreamHandler. That is what keeps `caplog` working in tests, and it means a new logger name
|
|
is configured the moment it is created."""
|
|
global _source
|
|
_source = source
|
|
formatter = "json" if (settings.log_format or "json").lower() != "text" else "text"
|
|
logging.config.dictConfig(
|
|
{
|
|
"version": 1,
|
|
# Never disable loggers created at import time (module-level `getLogger` calls run
|
|
# before this function does).
|
|
"disable_existing_loggers": False,
|
|
"formatters": {
|
|
"json": {"()": f"{__name__}.JsonFormatter"},
|
|
"text": {"()": f"{__name__}.TextFormatter"},
|
|
},
|
|
"filters": {"context": {"()": f"{__name__}.ContextFilter"}},
|
|
"handlers": {
|
|
"stdout": {
|
|
"class": "logging.StreamHandler",
|
|
"stream": sys.stdout,
|
|
"formatter": formatter,
|
|
"filters": ["context"],
|
|
}
|
|
},
|
|
"root": {"level": resolve_level(level or settings.log_level), "handlers": ["stdout"]},
|
|
"loggers": {name: {"level": lvl} for name, lvl in _THIRD_PARTY_LEVELS.items()},
|
|
}
|
|
)
|
|
|
|
|
|
def set_level(raw: str | None) -> None:
|
|
"""Change the running process's level (the `log_level` admin knob). Third-party loggers keep
|
|
their pinned levels, so turning on DEBUG shows OUR detail, not SQLAlchemy's."""
|
|
logging.getLogger().setLevel(resolve_level(raw))
|
|
|
|
|
|
def current_level() -> str:
|
|
return logging.getLevelName(logging.getLogger().getEffectiveLevel())
|
|
|
|
|
|
# --- request logging -----------------------------------------------------------------------
|
|
# A client may supply its own request id so a browser log line and a server line join up (E22 S4).
|
|
# Accepted only in this shape: it ends up in a log field, and an unbounded header would be a way to
|
|
# write arbitrary text into the log.
|
|
_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
|
|
|
|
# Paths whose success is logged at DEBUG rather than INFO: the container healthcheck runs every 15s
|
|
# (5.7k lines/day saying nothing) and hashed static assets are served by the hundred per page load.
|
|
# A FAILURE on either still logs at WARNING/ERROR — this lowers the floor, it does not hide.
|
|
_QUIET_PREFIXES = ("/healthz", "/assets/", "/static/")
|
|
|
|
_request_log = logging.getLogger("siftlode.http")
|
|
|
|
|
|
async def log_requests(request, call_next):
|
|
"""The single access-log line, replacing uvicorn's.
|
|
|
|
What changes versus `uvicorn.access`: the ROUTE TEMPLATE instead of the concrete path (so
|
|
`/api/videos/{video_id}` groups), never the query string (it carried `?account=`, and at TRACE
|
|
the redacted query belongs in attrs, not in the URL), a request id echoed back in
|
|
`X-Request-Id`, the duration, and the account — read from `request.state`, which the auth
|
|
dependency fills, because this middleware sits outside both the session middleware and the
|
|
downstream task's context."""
|
|
incoming = request.headers.get("x-request-id", "")
|
|
rid = incoming if _REQUEST_ID_RE.match(incoming) else uuid.uuid4().hex[:16]
|
|
token = _request_id.set(rid)
|
|
request.state.request_id = rid
|
|
started = time.perf_counter()
|
|
try:
|
|
response = await call_next(request)
|
|
except Exception:
|
|
# The unhandled-exception path: FastAPI's ServerErrorMiddleware re-raises after its own
|
|
# 500, so without this the ONE request that most needs a log line is the one with no
|
|
# access-log entry at all.
|
|
_request_log.exception(
|
|
"request failed",
|
|
extra=event(
|
|
"http.request",
|
|
method=request.method,
|
|
path=_route_template(request),
|
|
duration_ms=round((time.perf_counter() - started) * 1000, 1),
|
|
user_id=getattr(request.state, "user_id", None),
|
|
),
|
|
)
|
|
_request_id.reset(token)
|
|
raise
|
|
duration_ms = round((time.perf_counter() - started) * 1000, 1)
|
|
status = response.status_code
|
|
quiet = request.url.path.startswith(_QUIET_PREFIXES)
|
|
if status >= 500:
|
|
level = logging.ERROR
|
|
elif status >= 400:
|
|
level = logging.WARNING
|
|
else:
|
|
level = logging.DEBUG if quiet else logging.INFO
|
|
_request_log.log(
|
|
level,
|
|
"%s %s %s",
|
|
request.method,
|
|
_route_template(request),
|
|
status,
|
|
extra=event(
|
|
"http.request",
|
|
method=request.method,
|
|
path=_route_template(request),
|
|
status=status,
|
|
duration_ms=duration_ms,
|
|
user_id=getattr(request.state, "user_id", None),
|
|
),
|
|
)
|
|
response.headers["X-Request-Id"] = rid
|
|
_request_id.reset(token)
|
|
return response
|
|
|
|
|
|
def _route_template(request) -> str:
|
|
"""The matched route's template (`/api/videos/{video_id}`), else the raw path for a 404 or a
|
|
static file. Never the query string."""
|
|
route = request.scope.get("route")
|
|
return getattr(route, "path", None) or request.url.path
|