mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-22 18:43:26 +00:00
4e2d936c27
The row rate of `request_logs` was a function of how many nodes are installed,
not of what anyone did. Counted from the agent loop in linux_install.sh, each
agent's 30s cycle issues three logged calls - config, pending-requests,
upgrade-status (the heartbeat is already on the default exclude list) - plus
keepalived-config and keepalived-status every fifth cycle. That is ~9 800
rows/day per agent, essentially all of them 200s meaning "nothing changed".
Measured on PostgreSQL 15 against the real DDL and all nine indexes, at 2 424
bytes/row:
20 agents ~196k rows/day 453 MB/day row cap reached in 2.5 days
200 agents ~2.0M rows/day 4.4 GB/day row cap reached in 6 hours
500 agents ~4.9M rows/day 11 GB/day row cap reached in 2 hours
The cap holds, so nothing runs away - but it holds by deleting, and what it
deletes is everything else. The shipped policy says 7 days of successes and 30
days of failures; on a 200-node fleet it delivers about six HOURS of both. The
forensic record the feature exists for is evicted by polling noise, and the
larger the installation the less history it keeps.
`requestlog.capture_agent_success`, default FALSE: a SUCCESSFUL inbound call
from an agent is not recorded. Failures always are, whatever the flag says -
they are what an operator needs and they are rare, so they cost nothing. With
this the table's size follows operator activity, and adding nodes does not
shorten anyone's retention.
Agent traffic is identified by header only, no database round-trip on the hot
path: the installed agent sends `X-API-Key` and never `Authorization`, the UI
sends a JWT and never an agent key. `generate-install-script`, the one endpoint
that accepts either, classifies correctly under the same rule - an operator
generating a script sends Authorization, a self-upgrading agent sends only the
key. The result is stored in the existing `target` column, which already means
"who was on the other end" for outbound rows and now means the same for inbound
ones, so no schema change and the existing target index applies.
Second half, and the reason this is one commit: `operator` holds
`requestlog.read` because, per the migration that grants it, "operators debug
failing applies and ACME orders". They could not. An apply fails on the NODE,
and the node reports that over its own API key, so the row carrying the
diagnosis has `user_id IS NULL` - and own-rows-only scoping hid it from exactly
the role the grant was written for. Scoping now admits agent rows alongside the
caller's own. Deliberately keyed on `target = 'agent'` rather than `user_id IS
NULL`: anonymous traffic is not agent traffic, so failed logins and their
usernames, and unauthenticated probes, stay admin-only.
Verified end to end through the real middleware: a successful agent poll is
dropped, a 422 from config-validation-failed is kept, operator and anonymous
calls are unaffected, and flipping the setting on restores the old behaviour.
345 lines
14 KiB
Python
345 lines
14 KiB
Python
"""v1.11.0 — batching writer for the unified request/response log.
|
|
|
|
One row per API call is the highest write volume in this system, and the
|
|
asyncpg pool (min=10/max=50, see database/connection.py) is shared with every
|
|
request handler and four background loops. Acquiring a connection per logged
|
|
request would exhaust it under any real load, so instead:
|
|
|
|
hot path ──offer(row)──▶ bounded asyncio.Queue ──▶ single writer task
|
|
(drops when full) (executemany batches)
|
|
|
|
The hot path never awaits I/O and never raises. When the queue is full rows are
|
|
counted as dropped and reported through `GET /api/request-logs/stats`, so a
|
|
saturated logger is visible rather than silent.
|
|
|
|
Redaction deliberately happens HERE, on the writer task, not in the middleware:
|
|
the recursive walk is the most expensive part of building a row and it has no
|
|
business running inside the request coroutine.
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import random
|
|
from contextvars import ContextVar
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from config import REQUEST_LOG_BATCH_SIZE, REQUEST_LOG_FLUSH_MS, REQUEST_LOG_QUEUE_MAX
|
|
from database.connection import get_database_connection, close_database_connection
|
|
from utils.request_log_redaction import decode_body, redact_headers
|
|
from utils.request_log_settings import get_config, maybe_refresh_config
|
|
|
|
logger = logging.getLogger("haproxy_openmanager.request_log")
|
|
|
|
# Set by the inbound middleware; read by outbound_span so an outbound call
|
|
# inherits the id of the inbound request that caused it. That is what turns
|
|
# "operator clicked Issue Certificate" and "we POSTed to Let's Encrypt" into
|
|
# one readable trace.
|
|
request_id_context: ContextVar[Optional[str]] = ContextVar(
|
|
"request_log_request_id", default=None
|
|
)
|
|
|
|
# `target` on an INBOUND row means the same thing it means on an outbound one:
|
|
# who was on the other end. Set by the middleware when the caller authenticated
|
|
# with an agent API key rather than a user JWT, which is how agent traffic is
|
|
# told apart from operator traffic without a database lookup on the hot path.
|
|
# Deliberately the same literal as http_instrumentation.TARGET_AGENT, so
|
|
# `target = 'agent'` selects the whole conversation with the fleet in both
|
|
# directions; `direction` separates them when that matters.
|
|
TARGET_INBOUND_AGENT = "agent"
|
|
|
|
_INSERT_SQL = """
|
|
INSERT INTO request_logs (
|
|
request_id, direction, target, method, url, path, query_params,
|
|
status_code, status_class, duration_ms, user_id, username, client_ip,
|
|
user_agent, request_headers, request_body, request_body_bytes,
|
|
response_headers, response_body, response_body_bytes, error, truncated,
|
|
created_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7::jsonb,
|
|
$8, $9, $10, $11, $12, $13::inet,
|
|
$14, $15::jsonb, $16::jsonb, $17,
|
|
$18::jsonb, $19::jsonb, $20, $21, $22,
|
|
$23
|
|
)
|
|
"""
|
|
|
|
|
|
def _jsonb(value: Any) -> Optional[str]:
|
|
"""asyncpg has no JSONB codec on this pool, so JSONB params travel as text
|
|
and are cast in SQL — the house idiom (utils/activity_log.py)."""
|
|
if value is None:
|
|
return None
|
|
try:
|
|
return json.dumps(value, default=str)
|
|
except Exception:
|
|
return json.dumps({"_serialize_error": True})
|
|
|
|
|
|
@dataclass
|
|
class RequestLogRow:
|
|
"""One captured exchange, still holding RAW body bytes.
|
|
|
|
Decoding and redaction run in `to_params()` on the writer task.
|
|
"""
|
|
|
|
request_id: str
|
|
direction: str
|
|
method: str
|
|
url: str
|
|
target: Optional[str] = None
|
|
path: Optional[str] = None
|
|
query_string: Optional[str] = None
|
|
query_params: Optional[Dict[str, Any]] = None
|
|
status_code: Optional[int] = None
|
|
duration_ms: int = 0
|
|
user_id: Optional[int] = None
|
|
username: Optional[str] = None
|
|
client_ip: Optional[str] = None
|
|
user_agent: Optional[str] = None
|
|
request_headers: Optional[Dict[str, str]] = None
|
|
response_headers: Optional[Dict[str, str]] = None
|
|
request_body_raw: Optional[bytes] = None
|
|
request_body_bytes: int = 0
|
|
request_content_type: Optional[str] = None
|
|
response_body_raw: Optional[bytes] = None
|
|
response_body_bytes: int = 0
|
|
response_content_type: Optional[str] = None
|
|
# Pre-decoded body override, used by outbound spans that hold a dict/str
|
|
# rather than wire bytes (e.g. the synthetic JWS summary).
|
|
request_body_value: Optional[Any] = None
|
|
response_body_value: Optional[Any] = None
|
|
error: Optional[str] = None
|
|
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
|
|
@property
|
|
def status_class(self) -> int:
|
|
"""`status_code // 100`, or 0 when there was no HTTP response at all
|
|
(transport error / unhandled exception). 0 is what the error-retention
|
|
prune treats as an error alongside >= 4."""
|
|
if not self.status_code:
|
|
return 0
|
|
return int(self.status_code) // 100
|
|
|
|
def to_params(self) -> List[Any]:
|
|
req_body, req_truncated = (self.request_body_value, False)
|
|
if req_body is None:
|
|
req_body, req_truncated = decode_body(
|
|
self.request_body_raw, self.request_content_type, self.request_body_bytes
|
|
)
|
|
|
|
res_body, res_truncated = (self.response_body_value, False)
|
|
if res_body is None:
|
|
res_body, res_truncated = decode_body(
|
|
self.response_body_raw, self.response_content_type, self.response_body_bytes
|
|
)
|
|
|
|
return [
|
|
self.request_id[:64],
|
|
self.direction,
|
|
self.target[:32] if self.target else None,
|
|
(self.method or "")[:10],
|
|
self.url or "",
|
|
self.path[:512] if self.path else None,
|
|
_jsonb(self.query_params),
|
|
self.status_code,
|
|
self.status_class,
|
|
max(0, int(self.duration_ms)),
|
|
self.user_id,
|
|
self.username[:50] if self.username else None,
|
|
self.client_ip,
|
|
self.user_agent[:1024] if self.user_agent else None,
|
|
_jsonb(redact_headers(self.request_headers)),
|
|
_jsonb(req_body),
|
|
max(0, int(self.request_body_bytes)),
|
|
_jsonb(redact_headers(self.response_headers)),
|
|
_jsonb(res_body),
|
|
max(0, int(self.response_body_bytes)),
|
|
self.error[:4000] if self.error else None,
|
|
bool(req_truncated or res_truncated),
|
|
self.created_at,
|
|
]
|
|
|
|
|
|
class RequestLogSink:
|
|
"""Bounded queue + single batching writer task (one per uvicorn worker)."""
|
|
|
|
def __init__(self, maxsize: int, batch_size: int, flush_ms: int):
|
|
self._maxsize = maxsize
|
|
self._batch_size = batch_size
|
|
self._flush_seconds = flush_ms / 1000.0
|
|
self._queue: Optional[asyncio.Queue] = None
|
|
self._dropped = 0
|
|
self._written = 0
|
|
self._failed = 0
|
|
self._running = False
|
|
|
|
# -- lifecycle ---------------------------------------------------------
|
|
|
|
def _ensure_queue(self) -> asyncio.Queue:
|
|
# Created lazily so importing this module never needs a running loop
|
|
# (matters for the test suite, which imports main.py without one).
|
|
if self._queue is None:
|
|
self._queue = asyncio.Queue(maxsize=self._maxsize)
|
|
return self._queue
|
|
|
|
@property
|
|
def stats(self) -> Dict[str, int]:
|
|
return {
|
|
"queued": self._queue.qsize() if self._queue is not None else 0,
|
|
"queue_capacity": self._maxsize,
|
|
"written": self._written,
|
|
"dropped": self._dropped,
|
|
"failed_batches": self._failed,
|
|
"running": 1 if self._running else 0,
|
|
}
|
|
|
|
# -- producer side (hot path) -----------------------------------------
|
|
|
|
def offer(self, row: RequestLogRow) -> None:
|
|
"""Enqueue a row. NEVER blocks, NEVER raises.
|
|
|
|
Sampling is applied here rather than in the middleware so both
|
|
directions go through one policy: successful *inbound* traffic can be
|
|
sampled down, errors never are.
|
|
"""
|
|
try:
|
|
cfg = get_config()
|
|
if not cfg.enabled:
|
|
return
|
|
if row.direction == "inbound" and not cfg.capture_inbound:
|
|
return
|
|
if row.direction == "outbound" and not cfg.capture_outbound:
|
|
return
|
|
# Fleet-scale gate, and the reason this table's size is a function of
|
|
# operator activity rather than of node count. An agent's 30s cycle
|
|
# issues three logged calls, plus two more every fifth cycle: ~9 800
|
|
# rows/day PER AGENT, all of them 200s saying "nothing changed". At a
|
|
# few hundred nodes that is millions of rows a day, and the row cap is
|
|
# then reached in hours, which silently shortens the configured
|
|
# retention for EVERYTHING else in the table - including the failures
|
|
# the log exists for. Successes are dropped, failures never are.
|
|
if (
|
|
row.direction == "inbound"
|
|
and row.target == TARGET_INBOUND_AGENT
|
|
and not cfg.capture_agent_success
|
|
and row.status_class in (1, 2, 3)
|
|
):
|
|
return
|
|
if (
|
|
row.direction == "inbound"
|
|
and cfg.sample_rate < 1.0
|
|
and row.status_class in (1, 2, 3)
|
|
and random.random() > cfg.sample_rate
|
|
):
|
|
return
|
|
if not cfg.capture_bodies:
|
|
row.request_body_raw = None
|
|
row.response_body_raw = None
|
|
row.request_body_value = None
|
|
row.response_body_value = None
|
|
|
|
self._ensure_queue().put_nowait(row)
|
|
except asyncio.QueueFull:
|
|
self._dropped += 1
|
|
if self._dropped % 500 == 1:
|
|
logger.warning(
|
|
f"request_log: queue full, {self._dropped} row(s) dropped so far "
|
|
f"(capacity {self._maxsize}; raise REQUEST_LOG_QUEUE_MAX or lower "
|
|
f"requestlog.sample_rate)"
|
|
)
|
|
except Exception as exc:
|
|
# Instrumentation must never break the thing it instruments.
|
|
logger.debug(f"request_log: offer() failed: {exc}")
|
|
|
|
# -- consumer side (writer task) --------------------------------------
|
|
|
|
async def _collect(self) -> List[RequestLogRow]:
|
|
"""Wait for at least one row, then drain up to batch_size or flush_ms."""
|
|
queue = self._ensure_queue()
|
|
first = await queue.get()
|
|
batch = [first]
|
|
loop = asyncio.get_running_loop()
|
|
deadline = loop.time() + self._flush_seconds
|
|
while len(batch) < self._batch_size:
|
|
remaining = deadline - loop.time()
|
|
if remaining <= 0:
|
|
break
|
|
try:
|
|
batch.append(await asyncio.wait_for(queue.get(), timeout=remaining))
|
|
except asyncio.TimeoutError:
|
|
break
|
|
return batch
|
|
|
|
async def _write(self, batch: List[RequestLogRow]) -> None:
|
|
if not batch:
|
|
return
|
|
params = []
|
|
for row in batch:
|
|
try:
|
|
params.append(row.to_params())
|
|
except Exception as exc:
|
|
logger.debug(f"request_log: row serialization failed, skipped: {exc}")
|
|
if not params:
|
|
return
|
|
|
|
conn = None
|
|
try:
|
|
conn = await get_database_connection()
|
|
await conn.executemany(_INSERT_SQL, params)
|
|
self._written += len(params)
|
|
except Exception as exc:
|
|
self._failed += 1
|
|
# A missing table (pre-migration) or a transient pool error must not
|
|
# take the writer loop down — drop the batch and carry on.
|
|
logger.warning(f"request_log: batch write failed ({len(params)} rows): {exc}")
|
|
finally:
|
|
if conn is not None:
|
|
try:
|
|
await close_database_connection(conn)
|
|
except Exception:
|
|
pass
|
|
|
|
async def run(self) -> None:
|
|
"""Writer loop. Started once per worker from startup_event()."""
|
|
self._running = True
|
|
logger.info(
|
|
f"request_log sink started (queue={self._maxsize}, batch={self._batch_size}, "
|
|
f"flush={int(self._flush_seconds * 1000)}ms)"
|
|
)
|
|
try:
|
|
while True:
|
|
try:
|
|
batch = await self._collect()
|
|
await self._write(batch)
|
|
await maybe_refresh_config()
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
logger.error(f"request_log sink loop error: {exc}")
|
|
await asyncio.sleep(1)
|
|
finally:
|
|
self._running = False
|
|
|
|
async def flush(self, timeout: float = 3.0) -> int:
|
|
"""Drain and persist whatever is queued. Called on shutdown."""
|
|
queue = self._queue
|
|
if queue is None or queue.empty():
|
|
return 0
|
|
written = 0
|
|
loop = asyncio.get_running_loop()
|
|
deadline = loop.time() + timeout
|
|
while not queue.empty() and loop.time() < deadline:
|
|
batch: List[RequestLogRow] = []
|
|
while not queue.empty() and len(batch) < self._batch_size:
|
|
batch.append(queue.get_nowait())
|
|
await self._write(batch)
|
|
written += len(batch)
|
|
return written
|
|
|
|
|
|
request_log_sink = RequestLogSink(
|
|
REQUEST_LOG_QUEUE_MAX, REQUEST_LOG_BATCH_SIZE, REQUEST_LOG_FLUSH_MS
|
|
)
|