466 lines
15 KiB
Python
466 lines
15 KiB
Python
"""Structured logging helpers shared by HTTP, WebSocket and Celery code."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextvars
|
|
import json
|
|
import logging
|
|
import re
|
|
from ipaddress import ip_address
|
|
from time import monotonic
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Mapping
|
|
from uuid import uuid4
|
|
|
|
from django.conf import settings
|
|
|
|
|
|
_LOG_CONTEXT: contextvars.ContextVar[dict[str, Any]] = contextvars.ContextVar(
|
|
"eusrr_log_context", default={}
|
|
)
|
|
|
|
_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
|
_SENSITIVE_KEY_RE = re.compile(
|
|
r"(?:authorization|proxy-authorization|password|passwd|token|secret|"
|
|
r"cookie|set-cookie|api[-_]?key|session)",
|
|
re.IGNORECASE,
|
|
)
|
|
_BEARER_RE = re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+")
|
|
_EMAIL_RE = re.compile(r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b")
|
|
_LDAP_DN_RE = re.compile(
|
|
r"(?i)\b(?:cn|ou|uid|dc)=[^,\r\n]+"
|
|
r"(?:,(?:cn|ou|uid)=[^,\r\n]+)*"
|
|
r",dc=[^,\s\r\n]+(?:,dc=[^,\s\r\n]+)*"
|
|
)
|
|
_PHONE_RE = re.compile(
|
|
r"(?<!\d)(?:\+7|8)[\s()-]*\d{3}[\s()-]*\d{3}[\s-]*\d{2}[\s-]*\d{2}(?!\d)"
|
|
)
|
|
_ANSI_ESCAPE_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
_LOGGER_NAME_RE = re.compile(r"[^a-z0-9_]+")
|
|
_SENSITIVE_VALUE_RE = re.compile(
|
|
r"(?i)(authorization|password|passwd|token|secret|cookie|api[-_]?key)"
|
|
r"(\s*[=:]\s*)([^\s,;&]+)"
|
|
)
|
|
_MAX_LOG_VALUE_LENGTH = 2048
|
|
|
|
_STANDARD_RECORD_FIELDS = {
|
|
"args",
|
|
"asctime",
|
|
"created",
|
|
"exc_info",
|
|
"exc_text",
|
|
"filename",
|
|
"funcName",
|
|
"levelname",
|
|
"levelno",
|
|
"lineno",
|
|
"message",
|
|
"module",
|
|
"msecs",
|
|
"msg",
|
|
"name",
|
|
"pathname",
|
|
"process",
|
|
"processName",
|
|
"relativeCreated",
|
|
"stack_info",
|
|
"thread",
|
|
"threadName",
|
|
"taskName",
|
|
}
|
|
_OUTPUT_RECORD_FIELDS = {
|
|
"timestamp",
|
|
"level",
|
|
"logger",
|
|
"message",
|
|
"source",
|
|
"exception",
|
|
}
|
|
|
|
_CONSOLE_CONTEXT_FIELDS = (
|
|
"request_id",
|
|
"connection_id",
|
|
"task_id",
|
|
"user_id",
|
|
"client_ip",
|
|
)
|
|
_CONSOLE_HTTP_FIELDS = (
|
|
"http_method",
|
|
"path",
|
|
"status_code",
|
|
"duration_ms",
|
|
"response_bytes",
|
|
)
|
|
_CONSOLE_WEBSOCKET_FIELDS = (
|
|
"connection_id",
|
|
"path",
|
|
"accepted",
|
|
"close_code",
|
|
"duration_ms",
|
|
)
|
|
_CONSOLE_CELERY_FIELDS = (
|
|
"task_id",
|
|
"task_name",
|
|
"task_state",
|
|
"duration_ms",
|
|
)
|
|
_CONSOLE_DOMAIN_LABELS = {
|
|
"celery": "CELERY",
|
|
"framework": "SYSTEM",
|
|
"http": "HTTP",
|
|
"websocket": "WS",
|
|
}
|
|
_CONSOLE_CONTEXT_ALIASES = {
|
|
"request_id": "req",
|
|
"connection_id": "conn",
|
|
"task_id": "task",
|
|
"user_id": "user",
|
|
"client_ip": "ip",
|
|
}
|
|
|
|
|
|
def new_request_id(candidate: str | None = None) -> str:
|
|
"""Return a safe external request ID or generate a new one."""
|
|
if candidate and _REQUEST_ID_RE.fullmatch(candidate):
|
|
return candidate
|
|
return uuid4().hex
|
|
|
|
|
|
def resolve_client_ip(
|
|
remote_addr: str | None,
|
|
*,
|
|
forwarded_for: str | None = None,
|
|
real_ip: str | None = None,
|
|
trust_proxy: bool = False,
|
|
) -> str | None:
|
|
"""Resolve a validated client address, trusting proxy headers only by opt-in."""
|
|
|
|
candidates = []
|
|
if trust_proxy:
|
|
if forwarded_for:
|
|
candidates.append(forwarded_for.split(",", 1)[0].strip())
|
|
if real_ip:
|
|
candidates.append(real_ip.strip())
|
|
if remote_addr:
|
|
candidates.append(remote_addr.strip())
|
|
|
|
for candidate in candidates:
|
|
try:
|
|
return str(ip_address(candidate))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
return None
|
|
|
|
|
|
def bind_log_context(**values: Any) -> contextvars.Token:
|
|
"""Merge values into the current async/thread-local log context."""
|
|
context = dict(_LOG_CONTEXT.get())
|
|
context.update({key: value for key, value in values.items() if value is not None})
|
|
return _LOG_CONTEXT.set(context)
|
|
|
|
|
|
def update_log_context(**values: Any) -> None:
|
|
"""Update the current context when information becomes available later."""
|
|
context = dict(_LOG_CONTEXT.get())
|
|
for key, value in values.items():
|
|
if value is None:
|
|
context.pop(key, None)
|
|
else:
|
|
context[key] = value
|
|
_LOG_CONTEXT.set(context)
|
|
|
|
|
|
def reset_log_context(token: contextvars.Token) -> None:
|
|
_LOG_CONTEXT.reset(token)
|
|
|
|
|
|
def get_log_context() -> dict[str, Any]:
|
|
"""Expose a copy for diagnostics and unit tests."""
|
|
return dict(_LOG_CONTEXT.get())
|
|
|
|
|
|
def redact(value: Any, *, key: str = "") -> Any:
|
|
"""Mask secrets and make arbitrary values safe for log serialization."""
|
|
if _SENSITIVE_KEY_RE.search(key):
|
|
return "[REDACTED]"
|
|
if value is None or isinstance(value, (bool, int, float)):
|
|
return value
|
|
if isinstance(value, Mapping):
|
|
return {str(k): redact(v, key=str(k)) for k, v in value.items()}
|
|
if isinstance(value, (list, tuple, set)):
|
|
return [redact(item) for item in value]
|
|
|
|
text = _ANSI_ESCAPE_RE.sub("", str(value))
|
|
text = _BEARER_RE.sub("Bearer [REDACTED]", text)
|
|
text = _SENSITIVE_VALUE_RE.sub(r"\1\2[REDACTED]", text)
|
|
text = _EMAIL_RE.sub("[REDACTED_EMAIL]", text)
|
|
text = _LDAP_DN_RE.sub("[REDACTED_DN]", text)
|
|
text = _PHONE_RE.sub("[REDACTED_PHONE]", text)
|
|
text = text.replace("\r", "\\r").replace("\n", "\\n")
|
|
if len(text) > _MAX_LOG_VALUE_LENGTH:
|
|
return f"{text[:_MAX_LOG_VALUE_LENGTH]}...[TRUNCATED]"
|
|
return text
|
|
|
|
|
|
def _record_fields(record: logging.LogRecord) -> dict[str, Any]:
|
|
fields = dict(get_log_context())
|
|
for key, value in record.__dict__.items():
|
|
if key not in _STANDARD_RECORD_FIELDS and not key.startswith("_"):
|
|
fields[key] = value
|
|
fields.pop("event", None)
|
|
for key in _OUTPUT_RECORD_FIELDS:
|
|
fields.pop(key, None)
|
|
safe_fields = {}
|
|
for key, value in fields.items():
|
|
if value is None:
|
|
continue
|
|
if key == "request" and hasattr(value, "path"):
|
|
value = {
|
|
"method": getattr(value, "method", None),
|
|
"path": getattr(value, "path", None),
|
|
}
|
|
safe_fields[key] = redact(value, key=key)
|
|
return safe_fields
|
|
|
|
|
|
def _exception_text(formatter: logging.Formatter, record: logging.LogRecord):
|
|
if not record.exc_info:
|
|
return None
|
|
try:
|
|
return redact(formatter.formatException(record.exc_info))
|
|
except (AttributeError, TypeError, ValueError):
|
|
# Celery/billiard may occasionally provide a serialized traceback where
|
|
# stdlib logging expects a traceback object. Never hide the real failure
|
|
# behind a formatter exception.
|
|
if isinstance(record.exc_info, tuple):
|
|
error_type, error, traceback_value = (*record.exc_info, None, None)[:3]
|
|
return redact(
|
|
f"{getattr(error_type, '__name__', error_type)}: {error}; "
|
|
f"traceback={traceback_value}"
|
|
)
|
|
return redact(record.exc_info)
|
|
|
|
|
|
class ContextFilter(logging.Filter):
|
|
"""Attach context fields for third-party and project log records alike."""
|
|
|
|
def filter(self, record: logging.LogRecord) -> bool:
|
|
if not hasattr(record, "event"):
|
|
logger_name = (
|
|
_LOGGER_NAME_RE.sub("_", record.name.lower().replace(".", "_")).strip(
|
|
"_"
|
|
)
|
|
or "unknown"
|
|
)
|
|
record.event = f"framework.{logger_name}.{record.levelname.lower()}"
|
|
for key, value in get_log_context().items():
|
|
if not hasattr(record, key):
|
|
setattr(record, key, value)
|
|
return True
|
|
|
|
|
|
class ConsoleFormatter(logging.Formatter):
|
|
"""Human-readable grouped output for local development, without ANSI."""
|
|
|
|
@staticmethod
|
|
def _take_group(fields: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any]:
|
|
return {key: fields.pop(key) for key in keys if key in fields}
|
|
|
|
@staticmethod
|
|
def _format_pairs(
|
|
fields: Mapping[str, Any], aliases: Mapping[str, str] | None = None
|
|
) -> str | None:
|
|
if not fields:
|
|
return None
|
|
aliases = aliases or {}
|
|
values = " ".join(
|
|
f"{aliases.get(key, key)}={json.dumps(value, ensure_ascii=False)}"
|
|
for key, value in fields.items()
|
|
)
|
|
return f"[{values}]"
|
|
|
|
@staticmethod
|
|
def _format_http(fields: Mapping[str, Any]) -> str | None:
|
|
if not fields:
|
|
return None
|
|
values = []
|
|
if method := fields.get("http_method"):
|
|
values.append(str(method))
|
|
if path := fields.get("path"):
|
|
values.append(json.dumps(path, ensure_ascii=False))
|
|
if "status_code" in fields:
|
|
values.extend(("→", str(fields["status_code"])))
|
|
if "duration_ms" in fields:
|
|
values.append(f"{fields['duration_ms']}ms")
|
|
if "response_bytes" in fields:
|
|
values.append(f"bytes={fields['response_bytes']}")
|
|
return f"[{' '.join(values)}]" if values else None
|
|
|
|
@classmethod
|
|
def _format_domain(cls, event_domain: str, fields: Mapping[str, Any]) -> str | None:
|
|
if event_domain == "http":
|
|
return cls._format_http(fields)
|
|
aliases = {
|
|
"connection_id": "conn",
|
|
"task_id": "task",
|
|
"task_name": "name",
|
|
"task_state": "state",
|
|
"close_code": "close",
|
|
"duration_ms": "duration",
|
|
}
|
|
return cls._format_pairs(fields, aliases)
|
|
|
|
def format(self, record: logging.LogRecord) -> str:
|
|
timestamp = datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(
|
|
timespec="milliseconds"
|
|
)
|
|
event = redact(getattr(record, "event", "log.message"))
|
|
event_domain = str(event).split(".", 1)[0].lower()
|
|
domain_label = _CONSOLE_DOMAIN_LABELS.get(
|
|
event_domain, event_domain.upper() or "LOG"
|
|
)
|
|
fields = _record_fields(record)
|
|
parts = [
|
|
timestamp,
|
|
record.levelname,
|
|
f"[{domain_label}]",
|
|
str(event),
|
|
f"@{redact(record.name)}",
|
|
]
|
|
|
|
domain_fields = ()
|
|
if event_domain == "http":
|
|
domain_fields = _CONSOLE_HTTP_FIELDS
|
|
elif event_domain == "websocket":
|
|
domain_fields = _CONSOLE_WEBSOCKET_FIELDS
|
|
elif event_domain == "celery":
|
|
domain_fields = _CONSOLE_CELERY_FIELDS
|
|
|
|
domain_values = self._take_group(fields, domain_fields)
|
|
request_values = {}
|
|
if event_domain != "http" and ("http_method" in fields or "path" in fields):
|
|
request_values = self._take_group(
|
|
fields, ("http_method", "path", "status_code", "duration_ms")
|
|
)
|
|
context_values = self._take_group(fields, _CONSOLE_CONTEXT_FIELDS)
|
|
if event_domain == "websocket" and domain_values.get(
|
|
"connection_id"
|
|
) == context_values.get("request_id"):
|
|
context_values.pop("request_id", None)
|
|
|
|
groups = [
|
|
self._format_domain(event_domain, domain_values),
|
|
self._format_http(request_values),
|
|
self._format_pairs(context_values, _CONSOLE_CONTEXT_ALIASES),
|
|
self._format_pairs(dict(sorted(fields.items()))),
|
|
]
|
|
parts.extend(group for group in groups if group)
|
|
parts.append(json.dumps(redact(record.getMessage()), ensure_ascii=False))
|
|
exception = _exception_text(self, record)
|
|
if exception:
|
|
parts.append(f"[exception {json.dumps(exception, ensure_ascii=False)}]")
|
|
return " ".join(parts)
|
|
|
|
|
|
class JsonFormatter(logging.Formatter):
|
|
"""One JSON object per line for production log aggregation."""
|
|
|
|
def format(self, record: logging.LogRecord) -> str:
|
|
payload = {
|
|
"timestamp": datetime.fromtimestamp(
|
|
record.created, tz=timezone.utc
|
|
).isoformat(timespec="milliseconds"),
|
|
"level": record.levelname,
|
|
"logger": redact(record.name),
|
|
"event": redact(getattr(record, "event", "log.message")),
|
|
"message": redact(record.getMessage()),
|
|
"source": f"{record.filename}:{record.lineno}",
|
|
**_record_fields(record),
|
|
}
|
|
exception = _exception_text(self, record)
|
|
if exception:
|
|
payload["exception"] = exception
|
|
return json.dumps(payload, ensure_ascii=False, default=str)
|
|
|
|
|
|
class WebSocketLoggingMiddleware:
|
|
"""Bind correlation context and log the WebSocket connection lifecycle."""
|
|
|
|
def __init__(self, app):
|
|
self.app = app
|
|
self.logger = logging.getLogger("eusrr.websocket")
|
|
|
|
@staticmethod
|
|
def _header(scope, name: bytes) -> str | None:
|
|
for header_name, value in scope.get("headers", []):
|
|
if header_name.lower() == name:
|
|
return value.decode("utf-8", errors="replace")
|
|
return None
|
|
|
|
async def __call__(self, scope, receive, send):
|
|
if scope.get("type") != "websocket":
|
|
return await self.app(scope, receive, send)
|
|
|
|
request_id = new_request_id(self._header(scope, b"x-request-id"))
|
|
scope["request_id"] = request_id
|
|
token = bind_log_context(
|
|
request_id=request_id,
|
|
connection_id=request_id,
|
|
path=scope.get("path"),
|
|
client_ip=resolve_client_ip(
|
|
(scope.get("client") or (None,))[0],
|
|
forwarded_for=self._header(scope, b"x-forwarded-for"),
|
|
real_ip=self._header(scope, b"x-real-ip"),
|
|
trust_proxy=getattr(settings, "LOG_TRUST_X_FORWARDED_FOR", False),
|
|
),
|
|
)
|
|
started = monotonic()
|
|
accepted = False
|
|
close_code = None
|
|
|
|
async def logged_send(message):
|
|
nonlocal accepted, close_code
|
|
message_type = message.get("type")
|
|
if message_type == "websocket.accept":
|
|
accepted = True
|
|
self.logger.info(
|
|
"WebSocket connection accepted",
|
|
extra={"event": "websocket.connection.accepted"},
|
|
)
|
|
elif message_type == "websocket.close":
|
|
close_code = message.get("code", 1000)
|
|
await send(message)
|
|
|
|
async def logged_receive():
|
|
nonlocal close_code
|
|
message = await receive()
|
|
if message.get("type") == "websocket.disconnect":
|
|
close_code = message.get("code", 1000)
|
|
return message
|
|
|
|
try:
|
|
return await self.app(scope, logged_receive, logged_send)
|
|
except Exception:
|
|
self.logger.exception(
|
|
"WebSocket connection failed",
|
|
extra={
|
|
"event": "websocket.connection.failed",
|
|
"duration_ms": round((monotonic() - started) * 1000, 2),
|
|
},
|
|
)
|
|
raise
|
|
finally:
|
|
duration_ms = round((monotonic() - started) * 1000, 2)
|
|
level = logging.WARNING if close_code not in (None, 1000) else logging.INFO
|
|
self.logger.log(
|
|
level,
|
|
"WebSocket connection closed",
|
|
extra={
|
|
"event": "websocket.connection.closed",
|
|
"accepted": accepted,
|
|
"close_code": close_code,
|
|
"duration_ms": duration_ms,
|
|
},
|
|
)
|
|
reset_log_context(token)
|