Files
MajoorWaldi-ComfyUI-Majoor-…/mjr_am_backend/observability_runtime.py
T
Ewald 57dfbaddb7 refactor(mjr_am_backend): update architectural plan and status
- Review and update the master plan against the current state of the repo
- Formalize the end of the frontend migration in the master plan
- Add new modules and changes to the checklist
- Update the target structure and criteria for acceptance
- Add new sections for database splitting, handlers separation, and registry/security/observability clarification
- Update the summary and operational resume
2026-04-09 11:08:10 +02:00

377 lines
11 KiB
Python

"""Runtime observability helpers for aiohttp routes."""
from __future__ import annotations
import os
import time
from typing import Any
from uuid import uuid4
from aiohttp import web
from .shared import get_logger, request_id_var
from .utils import env_float
logger = get_logger(__name__)
_CLIENT_DISCONNECT_ERRORS = (
ConnectionResetError,
BrokenPipeError,
ConnectionAbortedError,
OSError,
)
_CLIENT_DISCONNECT_ERRNO = frozenset({10053, 10054, 104, 32, 9})
MS_PER_S = 1000.0
_DEFAULT_LOG_RATELIMIT_MS = 2000.0
_DEFAULT_SLOW_MS = 750.0
_DEFAULT_RATELIMIT_CLEAN_INTERVAL_MS = 60_000.0
_DEFAULT_RATELIMIT_CLEAN_WINDOW_MULT = 4.0
_DEFAULT_RATELIMIT_CLEAN_MIN_CUTOFF_MS = 120_000.0
_LOG_RATELIMIT_STATE: dict[str, float] = {}
_LOG_RATELIMIT_CLEAN_AT = 0.0
_SENSITIVE_QUERY_KEYS = frozenset(
{
"token",
"api_token",
"apikey",
"api_key",
"authorization",
"auth",
"password",
"secret",
"key",
}
)
def _is_client_disconnect(exc: BaseException) -> bool:
if isinstance(exc, (ConnectionResetError, BrokenPipeError, ConnectionAbortedError)):
return True
if isinstance(exc, OSError):
errno = getattr(exc, "errno", None)
if errno in _CLIENT_DISCONNECT_ERRNO:
return True
winerror = getattr(exc, "winerror", None)
if winerror in _CLIENT_DISCONNECT_ERRNO:
return True
return False
def _new_request_id() -> str:
return uuid4().hex
def _get_request_id(request: web.Request) -> str:
rid = (request.headers.get("X-Request-ID") or "").strip()
return rid or _new_request_id()
def _env_flag(name: str, default: bool = False) -> bool:
try:
raw = os.environ.get(name)
except Exception:
raw = None
if raw is None:
return default
value = str(raw).strip().lower()
return value in ("1", "true", "yes", "on")
def _should_emit_log(key: str, *, window_ms: float) -> bool:
global _LOG_RATELIMIT_CLEAN_AT
now = time.monotonic() * MS_PER_S
try:
last = _LOG_RATELIMIT_STATE.get(key, 0.0)
if last and now - last < window_ms:
return False
_LOG_RATELIMIT_STATE[key] = now
clean_interval_ms = env_float("MJR_OBS_RATELIMIT_CLEAN_INTERVAL_MS", _DEFAULT_RATELIMIT_CLEAN_INTERVAL_MS)
clean_window_mult = env_float("MJR_OBS_RATELIMIT_CLEAN_WINDOW_MULT", _DEFAULT_RATELIMIT_CLEAN_WINDOW_MULT)
clean_min_cutoff_ms = env_float("MJR_OBS_RATELIMIT_CLEAN_MIN_CUTOFF_MS", _DEFAULT_RATELIMIT_CLEAN_MIN_CUTOFF_MS)
if not _LOG_RATELIMIT_CLEAN_AT or now - _LOG_RATELIMIT_CLEAN_AT > clean_interval_ms:
cutoff = now - max(window_ms * clean_window_mult, clean_min_cutoff_ms)
for k, ts in list(_LOG_RATELIMIT_STATE.items()):
if ts < cutoff:
_LOG_RATELIMIT_STATE.pop(k, None)
_LOG_RATELIMIT_CLEAN_AT = now
except Exception:
return True
return True
def _request_path_or_empty(request: web.Request) -> str:
try:
return request.path or ""
except Exception:
return ""
def _is_majoor_route(path: str) -> bool:
return path.startswith("/mjr/am/")
def _is_health_poll_path(path: str) -> bool:
return path in ("/mjr/am/health", "/mjr/am/health/counters")
def _is_error_status(status: int | None) -> bool:
return status is not None and status >= 400
def _request_header_lower(request: web.Request, header_name: str) -> str:
try:
return (request.headers.get(header_name) or "").strip().lower()
except Exception:
return ""
def _is_obs_client_opt_out(value: str) -> bool:
return value in ("0", "false", "off", "disable", "disabled", "no")
def _is_obs_client_opt_in(value: str) -> bool:
return value in ("1", "true", "on", "enable", "enabled", "yes")
def _client_observability_flags(request: web.Request) -> tuple[str, bool]:
client_flag = _request_header_lower(request, "X-MJR-OBS")
return client_flag, _is_obs_client_opt_in(client_flag)
def _should_log(request: web.Request, *, status: int | None, duration_ms: float) -> bool:
path = _request_path_or_empty(request)
if not _is_majoor_route(path):
return False
client_flag, client_explicit_on = _client_observability_flags(request)
if _is_obs_client_opt_out(client_flag):
return False
if _env_flag("MJR_OBS_LOG_ALL", default=False):
return True
if _is_health_poll_path(path):
return _is_error_status(status)
if _is_error_status(status):
return True
if not _env_flag("MJR_OBS_LOG_SUCCESS", default=False):
return False
if not client_explicit_on:
return False
if not _env_flag("MJR_OBS_LOG_SLOW", default=False):
return False
slow_ms = env_float("MJR_OBS_SLOW_MS", _DEFAULT_SLOW_MS)
return duration_ms >= slow_ms
@web.middleware
async def request_context_middleware(request: web.Request, handler):
if _env_flag("MJR_OBS_DISABLE", default=False):
return await handler(request)
rid = _get_request_id(request)
request["mjr_request_id"] = rid
token = _set_request_id_context(rid)
start = time.perf_counter()
status: int | None = None
error: str | None = None
error_type: str | None = None
try:
response = await handler(request)
status = _response_status_code(response)
_attach_request_id_header(response, rid)
return response
except web.HTTPException as exc:
status = _http_exception_status_code(exc)
error_type, error = _exception_error_fields(exc, fallback="HTTPException")
_attach_request_id_header_to_http_exception(exc, rid)
raise
except RuntimeError as exc:
if "resetting" in str(exc).lower():
status = 503
error_type = "DB_RESETTING"
error = str(exc)
raise web.HTTPServiceUnavailable(reason="Database is temporarily unavailable") from exc
status = 500
error_type, error = _exception_error_fields(exc, fallback="Unhandled error")
raise
except Exception as exc:
status = 500
error_type, error = _exception_error_fields(exc, fallback="Unhandled error")
raise
finally:
duration_ms = (time.perf_counter() - start) * 1000.0
request["mjr_duration_ms"] = duration_ms
_reset_request_id_context(token)
_emit_request_log(request, status=status, duration_ms=duration_ms, error=error, error_type=error_type)
def _response_status_code(response: Any) -> int:
try:
return int(getattr(response, "status", 200) or 200)
except Exception:
return 200
def _http_exception_status_code(exc: web.HTTPException) -> int:
try:
return int(getattr(exc, "status", 500) or 500)
except Exception:
return 500
def _exception_error_fields(exc: Exception, *, fallback: str) -> tuple[str, str]:
error_type = exc.__class__.__name__
try:
return error_type, str(exc)
except Exception:
return error_type, fallback
def _attach_request_id_header(response: Any, rid: str) -> None:
try:
response.headers["X-Request-ID"] = rid
except Exception:
return
def _attach_request_id_header_to_http_exception(exc: web.HTTPException, rid: str) -> None:
try:
exc.headers["X-Request-ID"] = rid
except (AttributeError, TypeError, KeyError) as exc2:
logger.debug("Unable to attach X-Request-ID to HTTPException: %s", exc2)
def _set_request_id_context(rid: str):
try:
if request_id_var is not None:
return request_id_var.set(rid)
except Exception:
return None
return None
def _reset_request_id_context(token: Any) -> None:
try:
if token is not None and request_id_var is not None:
request_id_var.reset(token)
except Exception:
return
def _sanitize_query_params(request: web.Request) -> dict[str, Any]:
out: dict[str, Any] = {}
try:
iterator = request.query.items()
except Exception:
return out
for key, value in iterator:
k = str(key or "")
lower = k.lower()
if lower in _SENSITIVE_QUERY_KEYS or lower.endswith("_token") or lower.endswith("_key"):
out[k] = "[redacted]"
continue
try:
text = str(value or "")
except Exception:
text = ""
if len(text) > 256:
text = text[:256] + "..."
out[k] = text
return out
def build_request_log_fields(request: web.Request, response_status: int | None = None) -> dict[str, Any]:
stats = request.get("mjr_stats")
return {
"request_id": request.get("mjr_request_id"),
"method": request.method,
"path": request.path,
"query": _sanitize_query_params(request),
"status": response_status,
"duration_ms": request.get("mjr_duration_ms"),
"remote": getattr(request, "remote", None),
"stats": stats if isinstance(stats, dict) else None,
}
def _emit_request_log(
request: web.Request,
*,
status: int | None,
duration_ms: float,
error: str | None,
error_type: str | None,
) -> None:
try:
fields = build_request_log_fields(request, response_status=status)
if error:
fields["error"] = error
fields["error_type"] = error_type
if not _should_log(request, status=status, duration_ms=duration_ms):
return
key = _request_log_key(request, status)
window_ms = env_float("MJR_OBS_RATELIMIT_MS", _DEFAULT_LOG_RATELIMIT_MS)
if _should_emit_log(key, window_ms=window_ms):
_emit_request_log_by_status(status, fields)
except Exception as exc:
try:
logger.debug("Observability log emit failed: %s", exc)
except Exception:
return
def _request_log_key(request: web.Request, status: int | None) -> str:
try:
return f"{request.method}:{request.path}:{status}"
except Exception:
return f"unknown:{status}"
def _emit_request_log_by_status(status: int | None, fields: dict[str, Any]) -> None:
method = fields.get("method") or "?"
path = fields.get("path") or "?"
dur = fields.get("duration_ms")
dur_part = f" ({dur:.0f}ms)" if isinstance(dur, (int, float)) else ""
msg = f"Request handled | {method} {path} -> {status}{dur_part}"
if status is not None and status >= 500:
logger.error(msg, extra=fields)
elif status is not None and status >= 400:
logger.warning(msg, extra=fields)
else:
logger.info(msg, extra=fields)
__all__ = [
"_CLIENT_DISCONNECT_ERRORS",
"_CLIENT_DISCONNECT_ERRNO",
"_attach_request_id_header",
"_attach_request_id_header_to_http_exception",
"_client_observability_flags",
"_emit_request_log",
"_emit_request_log_by_status",
"_env_flag",
"_exception_error_fields",
"_get_request_id",
"_http_exception_status_code",
"_is_client_disconnect",
"_is_error_status",
"_is_health_poll_path",
"_is_majoor_route",
"_is_obs_client_opt_in",
"_is_obs_client_opt_out",
"_new_request_id",
"_request_header_lower",
"_request_log_key",
"_request_path_or_empty",
"_reset_request_id_context",
"_response_status_code",
"_sanitize_query_params",
"_set_request_id_context",
"_should_emit_log",
"_should_log",
"build_request_log_fields",
"request_context_middleware",
]