Logs were enriched with data.
This commit is contained in:
parent
962e06e1e2
commit
afe1aebf9d
5 changed files with 368 additions and 3 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
180
src/contract_check/core/http_logging.py
Normal file
180
src/contract_check/core/http_logging.py
Normal 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]}
|
||||||
130
tests/unit/test_http_logging.py
Normal file
130
tests/unit/test_http_logging.py
Normal 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 == []
|
||||||
Loading…
Add table
Reference in a new issue