feat: store warnings and request lines in log_events
The log now has a stored, queryable copy for the diagnostics console: every WARNING and above plus the request line of every request, kept for log_retention_days (default 7, no "forever") and pruned hourly by the new log_gc job. Rows are built on the logging thread from the same redacted fields as the stdout line, queued without blocking and written in batches by one background thread on its own connection. A full queue or a failed write drops and reports instead of retrying. Flood control keeps 30 of any one message per minute plus a count of the rest; anonymous request lines are capped at 60 a minute per client, and the client address is never stored. Deleting an account deletes its stored events. Measured on prod first: a YouTube RSS outage wrote 11k warnings in four hours, one per channel per poll. The poll now writes one warning with per-reason counts and a few examples, and a failure on our side gets one traceback per poll instead of one per channel.
This commit is contained in:
@@ -13,7 +13,7 @@ import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import logs
|
||||
@@ -27,11 +27,13 @@ class _Sink(logging.Handler):
|
||||
def __init__(self):
|
||||
super().__init__(level=logging.NOTSET)
|
||||
self.lines: list[str] = []
|
||||
self.records: list[logging.LogRecord] = []
|
||||
self.setFormatter(logs.JsonFormatter())
|
||||
self.addFilter(logs.ContextFilter())
|
||||
|
||||
def emit(self, record):
|
||||
self.lines.append(self.format(record))
|
||||
self.records.append(record)
|
||||
|
||||
def objects(self) -> list[dict]:
|
||||
return [json.loads(line) for line in self.lines]
|
||||
@@ -220,6 +222,40 @@ def test_the_request_line_has_the_route_template_and_no_query_string(sink):
|
||||
assert isinstance(payload["attrs"]["duration_ms"], float)
|
||||
|
||||
|
||||
def test_anonymous_request_lines_are_capped_per_client_for_storage_but_still_logged(sink):
|
||||
# A scanner's 404s are WARNINGs, and WARNINGs are stored. Past the cap they stay on stdout but are
|
||||
# marked not-for-storage; a signed-in request is never capped. No address appears in any line.
|
||||
logs._anonymous_lines.reset()
|
||||
client = _client()
|
||||
for _ in range(logs.ANONYMOUS_LINES_PER_MINUTE + 3):
|
||||
client.get("/wp-login.php")
|
||||
lines = sink.objects()[-(logs.ANONYMOUS_LINES_PER_MINUTE + 3) :]
|
||||
assert len(lines) == logs.ANONYMOUS_LINES_PER_MINUTE + 3 # stdout keeps every one
|
||||
records = sink.records[-(logs.ANONYMOUS_LINES_PER_MINUTE + 3) :]
|
||||
stored = [getattr(r, logs.PERSIST, True) for r in records]
|
||||
assert stored.count(False) == 3 and all(stored[: logs.ANONYMOUS_LINES_PER_MINUTE])
|
||||
assert "testclient" not in "".join(sink.lines) # the client key never reaches the line
|
||||
logs._anonymous_lines.reset()
|
||||
|
||||
|
||||
def test_a_signed_in_request_is_never_capped_and_names_its_account(sink):
|
||||
logs._anonymous_lines.reset()
|
||||
app = FastAPI()
|
||||
app.middleware("http")(logs.log_requests)
|
||||
|
||||
@app.get("/api/me")
|
||||
def _me(request: Request):
|
||||
request.state.user_id = 42 # what the auth dependency does
|
||||
return {}
|
||||
|
||||
client = TestClient(app)
|
||||
for _ in range(logs.ANONYMOUS_LINES_PER_MINUTE + 3):
|
||||
client.get("/api/me")
|
||||
records = sink.records[-(logs.ANONYMOUS_LINES_PER_MINUTE + 3) :]
|
||||
assert all(getattr(r, logs.PERSIST, True) for r in records)
|
||||
assert sink.objects()[-1]["user_id"] == 42 and "user_id" not in sink.objects()[-1]["attrs"]
|
||||
|
||||
|
||||
def test_the_request_id_is_echoed_and_shared_by_every_line_of_that_request(sink):
|
||||
client = _client()
|
||||
r = client.get("/api/videos/abc123")
|
||||
|
||||
Reference in New Issue
Block a user