mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-16 15:45:11 +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.
273 lines
10 KiB
Python
273 lines
10 KiB
Python
"""v1.11.0 — operator-tunable settings for the request/response log.
|
|
|
|
The middleware runs on EVERY request, so the hot path must not touch the
|
|
database. `get_config()` returns a module-global immutable snapshot with no
|
|
`await`; `refresh_config()` reloads it from `system_settings` and is called
|
|
|
|
* once at startup, right after migrations,
|
|
* every `_TTL_SECONDS` from the sink's writer loop (off the request path),
|
|
* synchronously at the end of `PUT /api/request-logs/settings`, so an
|
|
operator's change takes effect immediately instead of up to 30s later.
|
|
|
|
asyncpg has no JSONB codec registered on this pool (see
|
|
database/connection.py), so every value comes back as a raw JSON *string* and
|
|
needs the `isinstance(v, str)` + `json.loads` guard used elsewhere in this
|
|
codebase (services/acme_service.py, utils/activity_log.py).
|
|
"""
|
|
import json
|
|
import logging
|
|
import time
|
|
from dataclasses import dataclass, replace
|
|
from typing import Any, Dict, Optional, Tuple
|
|
|
|
from database.connection import get_database_connection, close_database_connection
|
|
|
|
logger = logging.getLogger("haproxy_openmanager.request_log")
|
|
|
|
SETTINGS_CATEGORY = "requestlog"
|
|
|
|
# Kept in sync with the seed in database/migrations.ensure_request_log_settings().
|
|
# backend/tests/test_request_log_settings.py asserts the two agree, so a change
|
|
# here without a change there fails the suite rather than drifting silently.
|
|
DEFAULT_EXCLUDE_PATHS = (
|
|
"/api/request-logs",
|
|
"/api/health",
|
|
"/api/docs",
|
|
"/api/redoc",
|
|
"/api/openapi.json",
|
|
"/.well-known/acme-challenge",
|
|
"/api/agents/heartbeat",
|
|
"/static",
|
|
"/favicon.ico",
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RequestLogConfig:
|
|
enabled: bool = True
|
|
capture_inbound: bool = True
|
|
capture_outbound: bool = True
|
|
capture_bodies: bool = True
|
|
capture_get: bool = True
|
|
# SUCCESSFUL agent polls only. Off by default because the row rate of this
|
|
# table is otherwise a linear function of fleet size, not of operator
|
|
# activity: each agent runs a 30s cycle that issues three logged calls
|
|
# (config, pending-requests, upgrade-status; the heartbeat is already
|
|
# excluded) plus two more every fifth cycle. Measured, that is ~9 800 rows
|
|
# per day PER AGENT, so a 200-node fleet writes ~2M rows/day and reaches the
|
|
# 500 000 max_rows cap in about six hours - at which point the shipped
|
|
# "7 days of successes, 30 days of failures" is not 7 and 30, it is 0.25.
|
|
# FAILED agent calls are always kept regardless of this flag: they are the
|
|
# half an operator actually needs, and they are rare.
|
|
capture_agent_success: bool = False
|
|
max_body_bytes: int = 8192
|
|
sample_rate: float = 1.0
|
|
exclude_paths: Tuple[str, ...] = DEFAULT_EXCLUDE_PATHS
|
|
success_retention_days: int = 7
|
|
error_retention_days: int = 30
|
|
max_rows: int = 500000
|
|
prune_interval_minutes: int = 60
|
|
|
|
def as_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"enabled": self.enabled,
|
|
"capture_inbound": self.capture_inbound,
|
|
"capture_outbound": self.capture_outbound,
|
|
"capture_bodies": self.capture_bodies,
|
|
"capture_get": self.capture_get,
|
|
"capture_agent_success": self.capture_agent_success,
|
|
"max_body_bytes": self.max_body_bytes,
|
|
"sample_rate": self.sample_rate,
|
|
"exclude_paths": list(self.exclude_paths),
|
|
"success_retention_days": self.success_retention_days,
|
|
"error_retention_days": self.error_retention_days,
|
|
"max_rows": self.max_rows,
|
|
"prune_interval_minutes": self.prune_interval_minutes,
|
|
}
|
|
|
|
|
|
DEFAULT_CONFIG = RequestLogConfig()
|
|
|
|
_CACHE: RequestLogConfig = DEFAULT_CONFIG
|
|
_CACHE_AT: float = 0.0
|
|
_TTL_SECONDS: float = 30.0
|
|
|
|
# Bounds, mirrored by the Pydantic model in routers/request_logs.py. Kept here
|
|
# too because refresh_config() reads whatever is in the table, which may have
|
|
# been written by an older build or by hand.
|
|
_BOUNDS = {
|
|
"max_body_bytes": (0, 262144),
|
|
"success_retention_days": (1, 365),
|
|
"error_retention_days": (1, 365),
|
|
"max_rows": (1000, 50_000_000),
|
|
"prune_interval_minutes": (5, 1440),
|
|
}
|
|
|
|
MAX_EXCLUDE_PATHS = 64
|
|
MAX_EXCLUDE_PATH_LENGTH = 200
|
|
|
|
|
|
def get_config() -> RequestLogConfig:
|
|
"""Hot-path read: no await, no DB, no lock. Returns the last snapshot."""
|
|
return _CACHE
|
|
|
|
|
|
def set_config(config: RequestLogConfig) -> None:
|
|
"""Replace the snapshot directly. Used by the settings PUT handler (which
|
|
already has the validated values) and by tests."""
|
|
global _CACHE, _CACHE_AT
|
|
_CACHE = config
|
|
_CACHE_AT = time.monotonic()
|
|
|
|
|
|
def _clamp_int(raw: Any, field: str, fallback: int) -> int:
|
|
try:
|
|
value = int(raw)
|
|
except (TypeError, ValueError):
|
|
return fallback
|
|
low, high = _BOUNDS[field]
|
|
return max(low, min(high, value))
|
|
|
|
|
|
def _clamp_float(raw: Any, fallback: float, low: float, high: float) -> float:
|
|
try:
|
|
value = float(raw)
|
|
except (TypeError, ValueError):
|
|
return fallback
|
|
return max(low, min(high, value))
|
|
|
|
|
|
def _as_bool(raw: Any, fallback: bool) -> bool:
|
|
if isinstance(raw, bool):
|
|
return raw
|
|
if isinstance(raw, (int, float)):
|
|
return bool(raw)
|
|
if isinstance(raw, str):
|
|
lowered = raw.strip().lower()
|
|
if lowered in ("true", "1", "yes", "on"):
|
|
return True
|
|
if lowered in ("false", "0", "no", "off"):
|
|
return False
|
|
return fallback
|
|
|
|
|
|
def normalize_exclude_paths(raw: Any, fallback: Tuple[str, ...]) -> Tuple[str, ...]:
|
|
"""Coerce whatever is stored into a bounded tuple of path prefixes."""
|
|
if not isinstance(raw, (list, tuple)):
|
|
return fallback
|
|
out = []
|
|
for item in raw:
|
|
if not isinstance(item, str):
|
|
continue
|
|
candidate = item.strip()
|
|
if not candidate.startswith("/") or len(candidate) > MAX_EXCLUDE_PATH_LENGTH:
|
|
continue
|
|
out.append(candidate)
|
|
if len(out) >= MAX_EXCLUDE_PATHS:
|
|
break
|
|
return tuple(out) if out else fallback
|
|
|
|
|
|
def config_from_mapping(values: Dict[str, Any], base: Optional[RequestLogConfig] = None) -> RequestLogConfig:
|
|
"""Build a config from a plain suffix→value mapping, clamping every field.
|
|
|
|
Unknown keys are ignored and missing keys keep the value from `base`
|
|
(default: the shipped defaults), so a partially-seeded table still yields a
|
|
complete, usable config.
|
|
"""
|
|
base = base or DEFAULT_CONFIG
|
|
return replace(
|
|
base,
|
|
enabled=_as_bool(values.get("enabled", base.enabled), base.enabled),
|
|
capture_inbound=_as_bool(values.get("capture_inbound", base.capture_inbound), base.capture_inbound),
|
|
capture_outbound=_as_bool(values.get("capture_outbound", base.capture_outbound), base.capture_outbound),
|
|
capture_bodies=_as_bool(values.get("capture_bodies", base.capture_bodies), base.capture_bodies),
|
|
capture_get=_as_bool(values.get("capture_get", base.capture_get), base.capture_get),
|
|
capture_agent_success=_as_bool(
|
|
values.get("capture_agent_success", base.capture_agent_success),
|
|
base.capture_agent_success,
|
|
),
|
|
max_body_bytes=_clamp_int(values.get("max_body_bytes", base.max_body_bytes), "max_body_bytes", base.max_body_bytes),
|
|
sample_rate=_clamp_float(values.get("sample_rate", base.sample_rate), base.sample_rate, 0.0, 1.0),
|
|
exclude_paths=normalize_exclude_paths(values.get("exclude_paths"), base.exclude_paths),
|
|
success_retention_days=_clamp_int(
|
|
values.get("success_retention_days", base.success_retention_days),
|
|
"success_retention_days", base.success_retention_days,
|
|
),
|
|
error_retention_days=_clamp_int(
|
|
values.get("error_retention_days", base.error_retention_days),
|
|
"error_retention_days", base.error_retention_days,
|
|
),
|
|
max_rows=_clamp_int(values.get("max_rows", base.max_rows), "max_rows", base.max_rows),
|
|
prune_interval_minutes=_clamp_int(
|
|
values.get("prune_interval_minutes", base.prune_interval_minutes),
|
|
"prune_interval_minutes", base.prune_interval_minutes,
|
|
),
|
|
)
|
|
|
|
|
|
def _decode_setting_value(raw: Any) -> Any:
|
|
"""JSONB comes back as a raw string on this pool — parse it, but keep the
|
|
original text if it is not valid JSON (an operator may have hand-written
|
|
`7` or `seven`)."""
|
|
if isinstance(raw, str):
|
|
try:
|
|
return json.loads(raw)
|
|
except (json.JSONDecodeError, ValueError):
|
|
return raw
|
|
return raw
|
|
|
|
|
|
async def load_settings_rows(conn) -> Dict[str, Any]:
|
|
"""Read the `requestlog.*` rows into a suffix→value mapping."""
|
|
rows = await conn.fetch(
|
|
"SELECT key, value FROM system_settings WHERE category = $1",
|
|
SETTINGS_CATEGORY,
|
|
)
|
|
values: Dict[str, Any] = {}
|
|
for row in rows:
|
|
key = row["key"]
|
|
suffix = key.split(".", 1)[1] if "." in key else key
|
|
values[suffix] = _decode_setting_value(row["value"])
|
|
return values
|
|
|
|
|
|
async def refresh_config(force: bool = True) -> RequestLogConfig:
|
|
"""Reload the snapshot from the database.
|
|
|
|
Never raises and never leaves a half-built config behind: on any failure
|
|
the previous snapshot is kept, so a transient DB blip cannot silently turn
|
|
logging off (or on).
|
|
"""
|
|
global _CACHE_AT
|
|
if not force and (time.monotonic() - _CACHE_AT) < _TTL_SECONDS:
|
|
return _CACHE
|
|
|
|
conn = None
|
|
try:
|
|
conn = await get_database_connection()
|
|
values = await load_settings_rows(conn)
|
|
if values:
|
|
set_config(config_from_mapping(values))
|
|
else:
|
|
# Table not seeded yet (fresh install mid-migration) — keep the
|
|
# in-code defaults but stamp the timestamp so we don't re-query
|
|
# every tick.
|
|
_CACHE_AT = time.monotonic()
|
|
return _CACHE
|
|
except Exception as exc:
|
|
logger.debug(f"refresh_config: keeping previous snapshot ({exc})")
|
|
_CACHE_AT = time.monotonic()
|
|
return _CACHE
|
|
finally:
|
|
if conn is not None:
|
|
try:
|
|
await close_database_connection(conn)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
async def maybe_refresh_config() -> RequestLogConfig:
|
|
"""TTL-gated refresh, called from the sink's writer loop."""
|
|
return await refresh_config(force=False)
|