mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-12 05:48:58 +00:00
bd4a50943f
Three hardening fixes with the same shape: a number that was true under the
defaults and untrue at the edges.
1. QUEUE MEMORY WAS AN OPERATOR SETTING, NOT A LIMIT.
The queue was bounded by ROW COUNT only, and how much a row weighs is
`requestlog.max_body_bytes` - editable from Settings, documented ceiling 256 KB,
and a row can hold that twice (request + response). Measured on the real
dataclass with distinct buffers per row:
defaults, 2 000 rows x 8 KB 33.9 MiB 3.3% of the 1 GiB pod limit
max_body_bytes at its 256 KB ceiling 1003 MiB at the pod limit
REQUEST_LOG_QUEUE_MAX at its ceiling 1695 MiB over the pod limit
Both are reachable from in-range, documented values, and the drop warning
advised "raise REQUEST_LOG_QUEUE_MAX" - so following the tool's own advice on a
busy install could OOM the worker. REQUEST_LOG_QUEUE_MAX_BYTES (default 64 MiB)
now caps the queue in bytes as well as in rows, whichever binds first, released
as rows drain. Verified: with max_body_bytes at 256 KB the queue holds 7.5 MiB
against an 8 MiB budget where it would otherwise have held 1003 MiB, and it
accepts rows again as soon as the writer drains it. The warning text now names
the setting that actually helps.
2. SINK COUNTERS ARE PER WORKER AND DID NOT SAY SO.
The sink is a module global, so with UVICORN_WORKERS > 1 each process has its
own queue and its own counters, and `GET /api/request-logs/stats` reports
whichever worker happened to serve the request. The feature is sold on "a
saturated logger drops rows visibly"; at 4 workers the visible number was a
quarter of the truth. Labelled `"scope": "this worker only"` rather than
aggregated - there is no cross-process channel here, and a number that looks
fleet-wide but is not is worse than one that admits its scope.
3. AN EMPTY EXCLUDE LIST IS NOT APPLIED AS "LOG EVERYTHING".
normalize_exclude_paths() falls back to the shipped defaults when the list comes
out empty, which is the right call - it keeps the log viewer and the raw-body
heartbeat endpoint excluded - but the UI kept displaying the empty list the
operator typed, so the form showed a policy that was not in effect. The save
handler now re-applies whatever the server actually stored (which also surfaces
server-side clamping of every numeric field) and says plainly that the defaults
were restored.
102 lines
4.5 KiB
Python
102 lines
4.5 KiB
Python
import os
|
|
from typing import Optional
|
|
|
|
# Database connection settings
|
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://haproxy_user:haproxy_password@postgres:5432/haproxy_openmanager")
|
|
|
|
# Redis connection
|
|
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379")
|
|
|
|
# CORS settings
|
|
# Configurable via CORS_ORIGINS env var (comma-separated), e.g. "http://localhost:3000,http://server:8080"
|
|
# Default allows common development and Docker Compose origins
|
|
_cors_env = os.getenv("CORS_ORIGINS", "")
|
|
CORS_ORIGINS = [o.strip() for o in _cors_env.split(",") if o.strip()] if _cors_env else [
|
|
"http://localhost:3000",
|
|
"http://localhost:8080",
|
|
"http://localhost:8000",
|
|
]
|
|
|
|
# Security settings
|
|
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here")
|
|
JWT_SECRET_KEY = SECRET_KEY # Alias for JWT middleware
|
|
ALGORITHM = "HS256"
|
|
JWT_ALGORITHM = ALGORITHM # Alias for JWT middleware
|
|
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
|
|
|
# Logging level
|
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
|
|
|
# Public URL configuration (for agent installation scripts)
|
|
PUBLIC_URL = os.getenv("PUBLIC_URL", "http://localhost:8000")
|
|
MANAGEMENT_BASE_URL = os.getenv("MANAGEMENT_BASE_URL", PUBLIC_URL) # Backward compatibility
|
|
|
|
# Agent settings
|
|
AGENT_HEARTBEAT_TIMEOUT_SECONDS = 15
|
|
AGENT_CONFIG_SYNC_INTERVAL_SECONDS = 30
|
|
|
|
# Entity snapshot enabled by default (rollback functionality)
|
|
# Set to "false" only if you need to disable snapshot temporarily
|
|
ENTITY_SNAPSHOT_ENABLED = os.getenv("ENTITY_SNAPSHOT_ENABLED", "true").lower() == "true"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# v1.11.0 — unified request/response log
|
|
# ---------------------------------------------------------------------------
|
|
# These four are deliberately ENV-only (not database settings): they decide
|
|
# whether the middleware is even registered and how much memory the writer
|
|
# queue may hold, so they must be resolvable before the DB pool exists.
|
|
# Everything the operator tunes at runtime (retention, body capture, sampling,
|
|
# excluded paths) lives in `system_settings` under the `requestlog.` category
|
|
# and is editable from Settings → Request Log.
|
|
|
|
def _bool_env(name: str, default: bool) -> bool:
|
|
raw = os.getenv(name)
|
|
if raw is None:
|
|
return default
|
|
return raw.strip().lower() not in ("0", "false", "no", "off", "")
|
|
|
|
|
|
def _int_env(name: str, default: int, minimum: int, maximum: int) -> int:
|
|
"""Read an int env var, clamped. A malformed value falls back to the
|
|
default rather than crashing the process at import time."""
|
|
raw = os.getenv(name)
|
|
if raw is None or not raw.strip():
|
|
return default
|
|
try:
|
|
value = int(raw.strip())
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return max(minimum, min(maximum, value))
|
|
|
|
|
|
# Hard kill-switch. When false the logging middleware is never added to the
|
|
# ASGI stack and neither the writer nor the prune task is started — literally
|
|
# zero overhead, not even a settings lookup.
|
|
REQUEST_LOG_ENABLED = _bool_env("REQUEST_LOG_ENABLED", True)
|
|
# Per-worker in-process queue depth. When full, rows are DROPPED (counted, and
|
|
# reported through GET /api/request-logs/stats) — the request path never blocks
|
|
# on the database.
|
|
REQUEST_LOG_QUEUE_MAX = _int_env("REQUEST_LOG_QUEUE_MAX", 2000, 100, 100000)
|
|
# HARD memory ceiling for the same queue, per worker. The row count above does
|
|
# NOT bound memory on its own, because how much a row weighs is an operator
|
|
# setting: `requestlog.max_body_bytes` is editable from Settings and its stated
|
|
# ceiling is 256 KB, which a row can carry twice (request + response). Measured
|
|
# on the real dataclass, the two limits multiply out to:
|
|
#
|
|
# defaults (2 000 rows x 8 KB) 33.9 MiB 3.3% of the 1 GiB pod limit
|
|
# max_body_bytes at its 256 KB ceiling 1003 MiB at the pod limit
|
|
# REQUEST_LOG_QUEUE_MAX at its ceiling 1695 MiB over the pod limit
|
|
#
|
|
# Both of those are reachable from documented, in-range values, and the drop
|
|
# warning used to advise raising the queue - so following the tool's own advice
|
|
# could OOM the worker. Whichever limit is hit FIRST now stops the queue, so
|
|
# memory stays bounded no matter what the other is set to.
|
|
REQUEST_LOG_QUEUE_MAX_BYTES = _int_env(
|
|
"REQUEST_LOG_QUEUE_MAX_BYTES", 64 * 1024 * 1024, 1024 * 1024, 1024 * 1024 * 1024
|
|
)
|
|
# Rows per batched INSERT: one pool acquire per batch, not per request.
|
|
REQUEST_LOG_BATCH_SIZE = _int_env("REQUEST_LOG_BATCH_SIZE", 100, 1, 1000)
|
|
# Max wait before a partial batch is flushed (milliseconds).
|
|
REQUEST_LOG_FLUSH_MS = _int_env("REQUEST_LOG_FLUSH_MS", 500, 50, 10000)
|