Logs were enriched with data.

This commit is contained in:
febux 2026-08-26 20:00:41 +03:00
parent 962e06e1e2
commit afe1aebf9d
5 changed files with 368 additions and 3 deletions

View file

@ -1,4 +1,10 @@
"""FastAPI middleware — correlation_id, request timing/metrics, Sentry errors.""" """FastAPI middleware — correlation_id, request timing/metrics, Sentry errors.
Every request emits an INFO access-log line (method, path, status, duration).
With LOG_LEVEL=DEBUG, request and response payloads are logged too
(redacted headers, secret-bearing JSON fields masked, bodies truncated)
see ``core/http_logging.py``.
"""
from __future__ import annotations from __future__ import annotations
@ -7,15 +13,23 @@ from typing import Any
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse, Response
from src.contract_check.core.config import get_settings from src.contract_check.core.config import get_settings
from src.contract_check.core.logging import get_logger, new_correlation_id, set_correlation_id from src.contract_check.core.http_logging import redact_headers, safe_body
from src.contract_check.core.logging import (
get_logger,
is_debug_enabled,
new_correlation_id,
set_correlation_id,
)
from src.contract_check.core.metrics import http_request_duration from src.contract_check.core.metrics import http_request_duration
from src.contract_check.core.sentry import init_sentry from src.contract_check.core.sentry import init_sentry
log = get_logger(__name__) log = get_logger(__name__)
_QUIET_PATHS = frozenset({"/healthz", "/readyz", "/metrics"})
def add_middleware(app: FastAPI) -> None: def add_middleware(app: FastAPI) -> None:
origins = get_settings().cors_origins origins = get_settings().cors_origins
@ -34,10 +48,39 @@ def add_middleware(app: FastAPI) -> None:
set_correlation_id(cid) set_correlation_id(cid)
start = time.perf_counter() start = time.perf_counter()
status = 200 status = 200
path = request.url.path
debug_payloads = is_debug_enabled() and path not in _QUIET_PATHS
if debug_payloads:
request_body = await request.body()
log.debug(
"http_request_payload",
method=request.method,
path=path,
query=request.url.query or None,
headers=redact_headers(request.headers),
body=safe_body(request_body, request.headers.get("content-type")),
)
try: try:
response = await call_next(request) response = await call_next(request)
status = response.status_code status = response.status_code
response.headers["x-correlation-id"] = cid response.headers["x-correlation-id"] = cid
if debug_payloads:
response_body = b"".join([chunk async for chunk in response.body_iterator])
log.debug(
"http_response_payload",
method=request.method,
path=path,
status_code=status,
headers=redact_headers(response.headers),
body=safe_body(response_body, response.headers.get("content-type")),
)
response = Response(
content=response_body,
status_code=status,
headers=dict(response.headers),
background=response.background,
)
response.headers["x-correlation-id"] = cid
return response return response
except Exception as exc: except Exception as exc:
status = 500 status = 500
@ -50,6 +93,14 @@ def add_middleware(app: FastAPI) -> None:
path=request.url.path, path=request.url.path,
status=str(status), status=str(status),
).observe(duration) ).observe(duration)
if path not in _QUIET_PATHS:
log.info(
"http_request",
method=request.method,
path=path,
status=str(status),
duration_ms=round(duration * 1000, 2),
)
@app.exception_handler(Exception) @app.exception_handler(Exception)
async def _exception_handler(_request: Request, exc: Exception) -> JSONResponse: async def _exception_handler(_request: Request, exc: Exception) -> JSONResponse:

View file

@ -13,6 +13,7 @@ from dataclasses import dataclass
import httpx import httpx
from src.contract_check.bot.config import BotSettings from src.contract_check.bot.config import BotSettings
from src.contract_check.core.http_logging import http_log_event_hooks
from src.contract_check.core.logging import get_logger from src.contract_check.core.logging import get_logger
log = get_logger(__name__) log = get_logger(__name__)
@ -95,6 +96,7 @@ class ApiClient:
self._client = httpx.AsyncClient( self._client = httpx.AsyncClient(
base_url=self._settings.api_url, base_url=self._settings.api_url,
timeout=httpx.Timeout(30.0, connect=5.0), timeout=httpx.Timeout(30.0, connect=5.0),
event_hooks=http_log_event_hooks(service="api"),
) )
async def aclose(self) -> None: async def aclose(self) -> None:

View file

@ -26,6 +26,7 @@ from src.contract_check.core.billing.port import (
RefundResult, RefundResult,
) )
from src.contract_check.core.config import Settings from src.contract_check.core.config import Settings
from src.contract_check.core.http_logging import http_log_event_hooks
from src.contract_check.core.logging import get_logger from src.contract_check.core.logging import get_logger
log = get_logger(__name__) log = get_logger(__name__)
@ -58,6 +59,7 @@ class YookassaProvider:
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
timeout=httpx.Timeout(timeout), timeout=httpx.Timeout(timeout),
event_hooks=http_log_event_hooks(service="yookassa"),
) )
async def aclose(self) -> None: async def aclose(self) -> None:

View file

@ -0,0 +1,180 @@
"""Shared HTTP payload logging — redacted request/response bodies for httpx.
Every event here is DEBUG-level: payloads may carry PII (contract text, report
markdown) and secrets (tokens, passwords), so they stay out of the default
INFO stream and are opt-in via LOG_LEVEL=DEBUG. Headers and JSON fields whose
names look secret are redacted, and every body is truncated to a fixed char
budget so a single log line can never dominate the log stream.
"""
from __future__ import annotations
import json
import time
from collections.abc import Mapping
from typing import Any
from urllib.parse import parse_qsl
import httpx
from src.contract_check.core.logging import BoundLogger, get_logger, is_debug_enabled
DEFAULT_MAX_PAYLOAD_CHARS = 2000
_SENSITIVE_HEADERS = frozenset(
{
"authorization",
"proxy-authorization",
"cookie",
"set-cookie",
"x-api-key",
"api-key",
}
)
_SENSITIVE_JSON_KEYS = frozenset(
{
"password",
"current_password",
"new_password",
"token",
"access_token",
"refresh_token",
"id_token",
"api_key",
"apikey",
"secret",
"secret_key",
"client_secret",
"authorization",
"session",
"credential",
"credentials",
"private_key",
}
)
_TEXT_CONTENT_MARKERS = ("json", "text", "xml", "urlencoded", "graphql")
def redact_headers(headers: Mapping[str, str] | httpx.Headers) -> dict[str, str]:
"""Copy headers with secret-bearing ones reduced to a short prefix."""
redacted: dict[str, str] = {}
for key, value in headers.items():
lowered = key.lower()
if lowered in _SENSITIVE_HEADERS:
redacted[lowered] = value[:4] + "***" if len(value) > 4 else "***"
else:
redacted[lowered] = value
return redacted
def redact_json(value: Any) -> Any:
"""Recursively replace values of secret-looking keys in parsed JSON."""
if isinstance(value, dict):
return {
key: "***" if str(key).lower() in _SENSITIVE_JSON_KEYS else redact_json(item)
for key, item in value.items()
}
if isinstance(value, list):
return [redact_json(item) for item in value]
return value
def safe_body(
body: bytes | str | Mapping[str, Any] | list[Any] | None,
content_type: str | None = None,
max_chars: int = DEFAULT_MAX_PAYLOAD_CHARS,
) -> str | None:
"""Render a body safe for logging: redacted if parseable, truncated, binary-summarized."""
if body is None:
return None
content_type_lower = (content_type or "").lower()
if isinstance(body, Mapping | list):
try:
text = json.dumps(redact_json(body), ensure_ascii=False, default=str)
except (TypeError, ValueError):
text = str(body)
return _truncate(text, max_chars)
if isinstance(body, bytes):
if not any(marker in content_type_lower for marker in _TEXT_CONTENT_MARKERS):
return f"<binary {len(body)} bytes>"
return _truncate(
_redact_text(body.decode("utf-8", errors="replace"), content_type_lower), max_chars
)
return _truncate(_redact_text(str(body), content_type_lower), max_chars)
def _redact_text(text: str, content_type_lower: str) -> str:
if "json" in content_type_lower:
try:
return json.dumps(redact_json(json.loads(text)), ensure_ascii=False)
except ValueError:
return text
if "urlencoded" in content_type_lower:
pairs = parse_qsl(text, keep_blank_values=True)
if pairs:
return "&".join(
f"{key}={'***' if key.lower() in _SENSITIVE_JSON_KEYS else value}"
for key, value in pairs
)
return text
def _truncate(text: str, max_chars: int) -> str:
if len(text) <= max_chars:
return text
return text[:max_chars] + f"…<truncated +{len(text) - max_chars} chars>"
def http_log_event_hooks(
logger: BoundLogger | None = None,
*,
service: str = "http",
max_payload_chars: int = DEFAULT_MAX_PAYLOAD_CHARS,
) -> dict[str, list[Any]]:
"""Event hooks for ``httpx.AsyncClient(event_hooks=...)``.
Emits ``http_request`` before send and ``http_response`` after receive,
each at DEBUG with method, url, redacted headers, duration and a
redacted/truncated payload. No-op (beyond a timestamp stamp) unless
DEBUG logging is enabled.
"""
log = logger or get_logger(f"http.{service}")
started_key = f"{service}_http_log_started_at"
async def _log_request(request: httpx.Request) -> None:
request.extensions[started_key] = time.perf_counter()
if not is_debug_enabled():
return
log.debug(
"http_request",
service=service,
method=request.method,
url=str(request.url),
headers=redact_headers(request.headers),
body=safe_body(request.content, request.headers.get("content-type"), max_payload_chars),
)
async def _log_response(response: httpx.Response) -> None:
if not is_debug_enabled():
return
request = response.request
started = request.extensions.get(started_key)
duration_ms = round((time.perf_counter() - started) * 1000, 2) if started else None
try:
body = response.text
except Exception:
body = None
log.debug(
"http_response",
service=service,
method=request.method,
url=str(request.url),
status_code=response.status_code,
duration_ms=duration_ms,
headers=redact_headers(response.headers),
body=safe_body(body, response.headers.get("content-type"), max_payload_chars),
)
return {"request": [_log_request], "response": [_log_response]}

View file

@ -0,0 +1,130 @@
"""Unit tests for shared HTTP payload logging (core/http_logging.py).
Covers header redaction, JSON secret masking, truncation, binary
summarization, and that the httpx event hooks emit payload-bearing
http_request/http_response events at DEBUG.
"""
from __future__ import annotations
import json
import httpx
import pytest
from structlog.testing import capture_logs
from contract_check.core import http_logging as hhttp
from contract_check.core.http_logging import (
http_log_event_hooks,
redact_headers,
redact_json,
safe_body,
)
def test_redact_headers_masks_secret_bearers() -> None:
headers = {
"Authorization": "Basic abcdefgh",
"X-API-Key": "short",
"Content-Type": "application/json",
}
redacted = redact_headers(headers)
assert redacted["authorization"] == "Basi***"
assert redacted["x-api-key"] == "shor***"
assert redacted["content-type"] == "application/json"
def test_redact_json_masks_secret_keys_recursively() -> None:
payload = {
"username": "san",
"password": "hunter2",
"nested": {"access_token": "jwt", "items": [{"client_secret": "s"}]},
"keep": "value",
}
redacted = redact_json(payload)
assert redacted["password"] == "***"
assert redacted["nested"]["access_token"] == "***"
assert redacted["nested"]["items"][0]["client_secret"] == "***"
assert redacted["username"] == "san"
assert redacted["keep"] == "value"
def test_safe_body_redacts_json_bodies() -> None:
body = json.dumps({"grant_type": "password", "password": "hunter2"}).encode()
rendered = safe_body(body, "application/json; charset=utf-8")
assert rendered is not None
assert "hunter2" not in rendered
assert json.loads(rendered)["password"] == "***"
def test_safe_body_redacts_urlencoded_bodies() -> None:
rendered = safe_body(b"username=san&password=hunter2", "application/x-www-form-urlencoded")
assert rendered == "username=san&password=***"
def test_safe_body_summarizes_binary_payloads() -> None:
assert safe_body(b"%PDF-1.7 fake", "application/pdf") == "<binary 13 bytes>"
assert safe_body(b"--boundary\r\n...", "multipart/form-data; boundary=boundary") == (
"<binary 15 bytes>"
)
def test_safe_body_truncates_long_text() -> None:
rendered = safe_body("x" * 500, "text/plain", max_chars=100)
assert rendered is not None
assert rendered.startswith("x" * 100)
assert "+400 chars" in rendered
def test_safe_body_handles_none_and_plain_text() -> None:
assert safe_body(None, "application/json") is None
assert safe_body("hello", "text/plain") == "hello"
async def test_event_hooks_log_request_and_response_payloads(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(hhttp, "is_debug_enabled", lambda: True)
with capture_logs() as logs:
transport = httpx.MockTransport(
lambda request: httpx.Response(200, json={"ok": True, "token": "jwt-secret"}) # noqa: S106
)
async with httpx.AsyncClient(
transport=transport,
headers={"Authorization": "Bearer supersecretjwt"},
event_hooks=http_log_event_hooks(service="test-svc"),
) as client:
response = await client.post(
"https://api.test/v1/things",
json={"name": "thing", "password": "hunter2"},
)
assert response.status_code == 200
events = {entry.get("event"): entry for entry in logs}
request_entry = events["http_request"]
assert request_entry["service"] == "test-svc"
assert request_entry["method"] == "POST"
assert request_entry["url"] == "https://api.test/v1/things"
assert request_entry["headers"]["authorization"] == "Bear***"
assert request_entry["headers"]["content-type"] == "application/json"
assert "hunter2" not in request_entry["body"]
assert json.loads(request_entry["body"])["password"] == "***"
response_entry = events["http_response"]
assert response_entry["status_code"] == 200
assert response_entry["duration_ms"] is not None
assert "jwt-secret" not in response_entry["body"]
assert json.loads(response_entry["body"])["token"] == "***"
async def test_event_hooks_silent_without_debug(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(hhttp, "is_debug_enabled", lambda: False)
with capture_logs() as logs:
transport = httpx.MockTransport(lambda request: httpx.Response(204))
async with httpx.AsyncClient(
transport=transport,
event_hooks=http_log_event_hooks(service="test-svc"),
) as client:
await client.get("https://api.test/v1/things")
assert logs == []