Files
mustafa.ulukaya ef26860df9 feat(logging): unified request/response log with configurable retention (v1.11.0)
Until now the only record of what happened was `user_activity_logs`, which
stores non-GET 2xx operations with no bodies. When something failed you could
see that a counter went up, never what was sent or what came back.

This adds one queryable timeline covering both directions:

- inbound: every API call, including GETs and including 4xx/5xx, with the
  user, client IP, status, duration and — redacted, size-capped — the request
  and response bodies.
- outbound: every HTTP call the backend makes, tagged with who it went to
  (ACME/Let's Encrypt, Cloudflare, GoDaddy, HAProxy stats, agents, the ACME
  diagnostics probe).

Outbound rows inherit the inbound request's id, so one operator action and the
CA/DNS calls it triggered read as a single trace: opening a failed "Request
Certificate" shows the exact POST /acme/new-order and the CA's 429 underneath.

Implementation notes:

- Capture is a pure-ASGI middleware that TEES the request and response streams
  rather than draining them. `await request.body()` inside a BaseHTTPMiddleware
  would consume the receive channel and break the raw-body agent heartbeat
  handler. Registered last so it is outermost: it then sees the final
  client-visible response and seeds correlation_id_context before the error
  handler reads it.
- Rows are written by a batching background writer with a bounded queue, so the
  request path never awaits the database and a saturated logger drops rows
  visibly (surfaced on the page) instead of blocking. Redaction runs on the
  writer, off the request coroutine.
- Secrets never land: headers are an allowlist with Authorization/Cookie kept
  only as a presence marker; body keys and value shapes are redacted
  (passwords, tokens, api_token, API keys, private-key PEMs, JWTs); the ACME
  JWS request body is never stored, because a stored protected+signature pair
  is a replayable credential — a summary is logged instead; DNS-provider errors
  record only the exception type; the ACME HTTP-01 challenge endpoint is
  excluded so key_authorization is never captured.
- Retention is operator-configurable in Settings -> Request Log: separate day
  counts for successful and failed rows (7 / 30) plus a hard row cap (500k),
  whichever is reached first. Pruned in batches under a Postgres advisory lock,
  with the day counts bound as parameters, never interpolated.
- New permissions requestlog.read / requestlog.manage. super_admin and
  security_admin get both, operator gets read, viewer gets neither.

Schema: one new table (request_logs) plus its settings seed, SCHEMA_VERSION
10 -> 11, auto-migrated. No existing table altered, no agent or rendered-config
change. Kill switches: REQUEST_LOG_ENABLED=false (middleware never registered)
or the `enabled` toggle in Settings.

Tests: 245 new (7 backend files + 1 frontend), full suite 1655 backend +
17 frontend passing.
2026-08-11 02:36:03 +03:00

277 lines
9.3 KiB
Python

"""v1.11.0: the batching writer must never slow down or break a request.
One row per API call is the highest write volume in the system and the asyncpg
pool (min=10/max=50) is shared with every handler and four background loops. So
the hot path enqueues and returns; a single writer task batches and inserts.
The properties pinned here:
* `offer()` never blocks and never raises — a full queue drops and counts;
* the parameter list stays aligned with the INSERT placeholders (a column
added to one and not the other would fail every write at runtime, in
production, with the migration already applied);
* a failed batch is dropped with a warning rather than killing the loop.
"""
import asyncio
import json
import os
import re
import sys
from datetime import datetime, timezone
from unittest.mock import AsyncMock, patch
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dataclasses import replace # noqa: E402
from utils import request_log_settings # noqa: E402
from utils import request_log_sink as sink_module # noqa: E402
from utils.request_log_sink import ( # noqa: E402
RequestLogRow,
RequestLogSink,
_INSERT_SQL,
)
from utils.request_log_settings import DEFAULT_CONFIG # noqa: E402
def _row(**overrides):
base = dict(
request_id="abc123",
direction="inbound",
method="POST",
url="/api/backends",
path="/api/backends",
status_code=200,
duration_ms=12,
created_at=datetime(2026, 8, 11, 9, 0, tzinfo=timezone.utc),
)
base.update(overrides)
return RequestLogRow(**base)
@pytest.fixture(autouse=True)
def defaults(monkeypatch):
monkeypatch.setattr(request_log_settings, "_CACHE", DEFAULT_CONFIG)
monkeypatch.setattr(sink_module, "get_config", lambda: request_log_settings._CACHE)
def _set(monkeypatch, **overrides):
monkeypatch.setattr(request_log_settings, "_CACHE", replace(DEFAULT_CONFIG, **overrides))
# --------------------------------------------------------------------------
# SQL / parameter alignment
# --------------------------------------------------------------------------
def test_insert_placeholders_match_the_column_list():
columns = _INSERT_SQL.split("(", 1)[1].split(")", 1)[0]
n_columns = len([c for c in columns.split(",") if c.strip()])
n_placeholders = len(set(re.findall(r"\$(\d+)", _INSERT_SQL)))
assert n_columns == n_placeholders, (
f"the INSERT names {n_columns} columns but binds {n_placeholders} placeholders — "
f"every write would fail at runtime, on a database where the migration has "
f"already succeeded"
)
def test_row_produces_exactly_as_many_params_as_the_insert_binds():
n_placeholders = len(set(re.findall(r"\$(\d+)", _INSERT_SQL)))
assert len(_row().to_params()) == n_placeholders, (
"RequestLogRow.to_params() drifted from _INSERT_SQL"
)
def test_jsonb_params_are_serialized_strings_not_dicts():
"""No JSONB codec is registered on this pool, so JSONB values travel as text
and are cast in SQL — handing asyncpg a dict raises."""
row = _row(
query_params={"page": "2"},
request_headers={"content-type": "application/json"},
request_body_value={"name": "web"},
)
params = row.to_params()
for value in params:
assert not isinstance(value, (dict, list)), (
f"{value!r} was passed as a Python container; asyncpg cannot bind it to a "
f"jsonb parameter"
)
assert json.loads(params[6]) == {"page": "2"}
def test_client_ip_is_never_a_placeholder_string():
"""client_ip is an INET column: 'unknown' or a comma-joined X-Forwarded-For
raises on INSERT."""
params = _row(client_ip=None).to_params()
assert params[12] is None
def test_status_class_is_zero_when_there_was_no_response():
assert _row(status_code=None).status_class == 0
assert _row(status_code=204).status_class == 2
assert _row(status_code=503).status_class == 5
# --------------------------------------------------------------------------
# offer(): the hot path
# --------------------------------------------------------------------------
def test_offer_drops_and_counts_when_the_queue_is_full():
sink = RequestLogSink(maxsize=3, batch_size=10, flush_ms=10)
async def run():
for _ in range(10):
sink.offer(_row())
asyncio.run(run())
assert sink.stats["queued"] == 3
assert sink.stats["dropped"] == 7, (
"a full queue must drop and count, never block the request or raise"
)
def test_offer_never_raises_on_a_broken_row():
sink = RequestLogSink(maxsize=10, batch_size=10, flush_ms=10)
async def run():
sink.offer(None) # not a RequestLogRow at all
asyncio.run(run()) # must not raise
def test_offer_respects_the_kill_switch(monkeypatch):
_set(monkeypatch, enabled=False)
sink = RequestLogSink(maxsize=10, batch_size=10, flush_ms=10)
asyncio.run(_offer(sink, _row()))
assert sink.stats["queued"] == 0
def test_offer_respects_the_per_direction_switches(monkeypatch):
_set(monkeypatch, capture_outbound=False)
sink = RequestLogSink(maxsize=10, batch_size=10, flush_ms=10)
async def run():
sink.offer(_row(direction="outbound", target="acme"))
sink.offer(_row(direction="inbound"))
asyncio.run(run())
assert sink.stats["queued"] == 1
def test_sampling_never_drops_errors(monkeypatch):
"""A sample rate of zero must still capture every failure — that is the whole
point of sampling successes only."""
_set(monkeypatch, sample_rate=0.0)
sink = RequestLogSink(maxsize=100, batch_size=10, flush_ms=10)
async def run():
for _ in range(20):
sink.offer(_row(status_code=200))
for _ in range(5):
sink.offer(_row(status_code=500))
for _ in range(5):
sink.offer(_row(status_code=None))
asyncio.run(run())
assert sink.stats["queued"] == 10, (
"sampling removed error rows; only 1xx/2xx/3xx inbound traffic may be sampled out"
)
def test_sampling_does_not_touch_outbound_rows(monkeypatch):
_set(monkeypatch, sample_rate=0.0)
sink = RequestLogSink(maxsize=100, batch_size=10, flush_ms=10)
async def run():
for _ in range(5):
sink.offer(_row(direction="outbound", target="acme", status_code=200))
asyncio.run(run())
assert sink.stats["queued"] == 5, (
"outbound calls are low-volume and high-value; sampling them away hides which CA "
"or DNS call was made"
)
def test_capture_bodies_off_strips_the_payload_before_queueing(monkeypatch):
_set(monkeypatch, capture_bodies=False)
sink = RequestLogSink(maxsize=10, batch_size=10, flush_ms=10)
row = _row(request_body_raw=b'{"a":1}', request_body_bytes=7)
asyncio.run(_offer(sink, row))
assert row.request_body_raw is None
assert row.request_body_bytes == 7, "the size must survive so growth is still measurable"
async def _offer(sink, row):
sink.offer(row)
# --------------------------------------------------------------------------
# The writer
# --------------------------------------------------------------------------
def test_a_batch_is_written_with_one_executemany():
conn = AsyncMock()
sink = RequestLogSink(maxsize=100, batch_size=10, flush_ms=10)
async def run():
for _ in range(5):
sink.offer(_row())
with patch.object(sink_module, "get_database_connection", AsyncMock(return_value=conn)), \
patch.object(sink_module, "close_database_connection", AsyncMock()):
return await sink.flush(timeout=1.0)
written = asyncio.run(run())
assert written == 5
assert conn.executemany.await_count == 1, (
"rows were inserted one at a time; that is one pool acquire per API call and the "
"pool has 50 connections"
)
sql, params = conn.executemany.await_args.args
assert "INSERT INTO request_logs" in sql
assert len(params) == 5
def test_a_failed_batch_does_not_kill_the_writer():
conn = AsyncMock()
conn.executemany = AsyncMock(side_effect=RuntimeError("relation does not exist"))
sink = RequestLogSink(maxsize=100, batch_size=10, flush_ms=10)
async def run():
sink.offer(_row())
with patch.object(sink_module, "get_database_connection", AsyncMock(return_value=conn)), \
patch.object(sink_module, "close_database_connection", AsyncMock()):
await sink.flush(timeout=1.0)
asyncio.run(run()) # must not raise
assert sink.stats["failed_batches"] == 1
def test_the_connection_is_released_even_when_the_write_fails():
conn = AsyncMock()
conn.executemany = AsyncMock(side_effect=RuntimeError("boom"))
release = AsyncMock()
sink = RequestLogSink(maxsize=100, batch_size=10, flush_ms=10)
async def run():
sink.offer(_row())
with patch.object(sink_module, "get_database_connection", AsyncMock(return_value=conn)), \
patch.object(sink_module, "close_database_connection", release):
await sink.flush(timeout=1.0)
asyncio.run(run())
assert release.await_count == 1, "a failed batch leaked a pooled connection"
def test_flush_on_an_empty_queue_is_a_noop():
sink = RequestLogSink(maxsize=10, batch_size=10, flush_ms=10)
assert asyncio.run(sink.flush(timeout=0.1)) == 0