feat: add privacy-safe structured logging

This commit is contained in:
ik
2026-09-04 07:49:14 +07:00
parent 3b26c59219
commit 398b35e843
11 changed files with 137 additions and 12 deletions
+1
View File
@@ -14,3 +14,4 @@ OFFICIAL_RECORDS_CATEGORY=records
OFFICIAL_IMPORT_REQUIRED=false OFFICIAL_IMPORT_REQUIRED=false
IMPORT_INTERVAL_SECONDS=3600 IMPORT_INTERVAL_SECONDS=3600
RATE_LIMIT_SECRET=change-rate-limit-secret RATE_LIMIT_SECRET=change-rate-limit-secret
LOG_LEVEL=INFO
+2
View File
@@ -52,6 +52,8 @@ docker compose up --build
Контейнер API сам выполняет `alembic upgrade head`, затем идемпотентный seed. PostgreSQL хранит данные в именованном volume `postgres_data`, а MinIO — в `minio_data`. Compose ожидает readiness PostgreSQL и MinIO перед API, а API-контейнер проверяет `/ready`. Официальный импорт по умолчанию необязателен; при включённом scheduler установите `OFFICIAL_IMPORT_REQUIRED=true`, тогда отсутствующий, неуспешный или просроченный запуск сделает readiness отрицательным. Контейнер API сам выполняет `alembic upgrade head`, затем идемпотентный seed. PostgreSQL хранит данные в именованном volume `postgres_data`, а MinIO — в `minio_data`. Compose ожидает readiness PostgreSQL и MinIO перед API, а API-контейнер проверяет `/ready`. Официальный импорт по умолчанию необязателен; при включённом scheduler установите `OFFICIAL_IMPORT_REQUIRED=true`, тогда отсутствующий, неуспешный или просроченный запуск сделает readiness отрицательным.
API и scheduler пишут по одной JSON-записи на событие. HTTP-лог содержит только сгенерированный `request_id`, метод, путь без query string, статус и длительность; IP, заголовок авторизации и пользовательский payload не журналируются. `X-Request-ID` возвращается клиенту. Стандартный access-log Uvicorn отключён. Уровень управляется `LOG_LEVEL`.
Остановка: Остановка:
```bash ```bash
+1 -1
View File
@@ -6,4 +6,4 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY apps/api . COPY apps/api .
COPY rf4_research ./rf4_research COPY rf4_research ./rf4_research
EXPOSE 8000 EXPOSE 8000
CMD ["sh", "-c", "alembic upgrade head && python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000"] CMD ["sh", "-c", "alembic upgrade head && python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log"]
+1
View File
@@ -17,6 +17,7 @@ class Settings(BaseSettings):
official_import_required: bool = False official_import_required: bool = False
import_interval_seconds: int = Field(default=3600, ge=3600) import_interval_seconds: int = Field(default=3600, ge=3600)
rate_limit_secret: str = "change-rate-limit-secret" rate_limit_secret: str = "change-rate-limit-secret"
log_level: str = "INFO"
model_config = SettingsConfigDict(env_file=".env", extra="ignore") model_config = SettingsConfigDict(env_file=".env", extra="ignore")
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
import json
import logging
import re
from datetime import datetime, timezone
SAFE_FIELDS = (
"request_id", "method", "path", "status_code", "duration_ms",
"component", "event", "result", "rows_seen", "rows_created",
"rows_updated", "not_modified", "interval_seconds", "error_type",
)
SENSITIVE = re.compile(
r"(?i)(bearer\s+)[^\s]+|((?:token|password|secret|authorization)\s*[=:]\s*)[^\s,;]+"
)
def redact(value: str) -> str:
return SENSITIVE.sub(lambda match: f"{match.group(1) or match.group(2)}[REDACTED]", value)
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, object] = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"level": record.levelname.lower(),
"logger": record.name,
"message": redact(record.getMessage()),
}
for field in SAFE_FIELDS:
value = getattr(record, field, None)
if value is not None:
payload[field] = value
if record.exc_info and "error_type" not in payload:
payload["error_type"] = record.exc_info[0].__name__
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"), default=str)
def configure_logging(level: str = "INFO") -> None:
root = logging.getLogger()
if not any(getattr(handler, "_rf4_json", False) for handler in root.handlers):
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
handler._rf4_json = True # type: ignore[attr-defined]
root.handlers.clear()
root.addHandler(handler)
root.setLevel(getattr(logging, level.upper(), logging.INFO))
+31
View File
@@ -4,8 +4,11 @@ from collections import Counter
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import hashlib import hashlib
import hmac import hmac
import logging
import time as time_module
from typing import Annotated, Literal from typing import Annotated, Literal
from uuid import UUID from uuid import UUID
import uuid
import httpx import httpx
from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, Response, UploadFile from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, Response, UploadFile
@@ -19,12 +22,15 @@ from .database import get_session
from .config import settings from .config import settings
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation
from .importer import ImportSourceError, import_records, normalize from .importer import ImportSourceError, import_records, normalize
from .logging_config import configure_logging
from .models import Bait, BaitKind, CatchReport, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody from .models import Bait, BaitKind, CatchReport, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
from .readiness import readiness_report from .readiness import readiness_report
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
configure_logging(settings.log_level)
logger = logging.getLogger("rf4.api")
app = FastAPI(title="RF4 Spotter API", version="0.1.0") app = FastAPI(title="RF4 Spotter API", version="0.1.0")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -35,6 +41,31 @@ app.add_middleware(
Db = Annotated[Session, Depends(get_session)] Db = Annotated[Session, Depends(get_session)]
@app.middleware("http")
async def structured_request_log(request: Request, call_next):
request_id = uuid.uuid4().hex
started = time_module.perf_counter()
status_code = 500
try:
response = await call_next(request)
status_code = response.status_code
response.headers["X-Request-ID"] = request_id
return response
except Exception as exc:
logger.error("request failed", extra={"request_id": request_id, "error_type": type(exc).__name__})
raise
finally:
logger.log(
logging.DEBUG if request.url.path in {"/health", "/ready"} else logging.INFO,
"request completed",
extra={
"request_id": request_id, "method": request.method,
"path": request.url.path, "status_code": status_code,
"duration_ms": round((time_module.perf_counter() - started) * 1000, 2),
},
)
@app.get("/health") @app.get("/health")
def health() -> dict[str, str]: def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
+10 -5
View File
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from .config import settings from .config import settings
from .database import SessionLocal from .database import SessionLocal
from .importer import import_records from .importer import import_records
from .logging_config import configure_logging
from .models import OfficialRecordImport from .models import OfficialRecordImport
@@ -42,20 +43,24 @@ def run_due_import() -> bool:
category=settings.official_records_category, category=settings.official_records_category,
) )
logger.info( logger.info(
"official import completed status=%s seen=%d created=%d updated=%d not_modified=%s", "official import completed",
run.status.value, run.rows_seen, run.rows_created, run.rows_updated, run.not_modified, extra={
"event": "official_import_completed", "result": run.status.value,
"rows_seen": run.rows_seen, "rows_created": run.rows_created,
"rows_updated": run.rows_updated, "not_modified": run.not_modified,
},
) )
return True return True
def main() -> None: def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") configure_logging(settings.log_level)
logger.info("scheduler started interval_seconds=%d", settings.import_interval_seconds) logger.info("scheduler started", extra={"event": "scheduler_started", "interval_seconds": settings.import_interval_seconds})
while True: while True:
try: try:
run_due_import() run_due_import()
except Exception: except Exception:
logger.exception("scheduled official import failed") logger.exception("scheduled official import failed", extra={"event": "official_import_failed"})
time.sleep(settings.import_interval_seconds) time.sleep(settings.import_interval_seconds)
+3 -1
View File
@@ -56,7 +56,9 @@ def test_invalid_period_is_rejected() -> None:
def test_liveness_does_not_probe_dependencies() -> None: def test_liveness_does_not_probe_dependencies() -> None:
assert client.get("/health").json() == {"status": "ok"} response = client.get("/health?token=must-not-be-logged")
assert response.json() == {"status": "ok"}
assert len(response.headers["X-Request-ID"]) == 32
def test_spot_detail_and_catches() -> None: def test_spot_detail_and_catches() -> None:
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import json
import logging
from app.logging_config import JsonFormatter, redact
def test_json_formatter_whitelists_fields_and_redacts_secrets() -> None:
record = logging.LogRecord(
name="rf4.test", level=logging.INFO, pathname=__file__, lineno=1,
msg="authorization=Bearer-secret token=top-secret Bearer abc.def",
args=(), exc_info=None,
)
record.request_id = "safe-request-id"
record.method = "GET"
record.path = "/api/v1/activity"
record.player_name = "Must Not Leak"
payload = json.loads(JsonFormatter().format(record))
assert payload["request_id"] == "safe-request-id"
assert payload["method"] == "GET"
assert payload["path"] == "/api/v1/activity"
assert "player_name" not in payload
assert "top-secret" not in payload["message"]
assert "abc.def" not in payload["message"]
assert payload["message"].count("[REDACTED]") == 3
def test_redact_covers_common_credential_forms() -> None:
cleaned = redact("password=hunter2 secret: swordfish Authorization: token-value")
assert "hunter2" not in cleaned
assert "swordfish" not in cleaned
assert "token-value" not in cleaned
+2
View File
@@ -47,6 +47,7 @@ services:
OFFICIAL_RECORDS_CATEGORY: ${OFFICIAL_RECORDS_CATEGORY:-records} OFFICIAL_RECORDS_CATEGORY: ${OFFICIAL_RECORDS_CATEGORY:-records}
OFFICIAL_IMPORT_REQUIRED: ${OFFICIAL_IMPORT_REQUIRED:-false} OFFICIAL_IMPORT_REQUIRED: ${OFFICIAL_IMPORT_REQUIRED:-false}
RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:-change-rate-limit-secret} RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:-change-rate-limit-secret}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -100,6 +101,7 @@ services:
OFFICIAL_RECORDS_REGION: ${OFFICIAL_RECORDS_REGION:-RU} OFFICIAL_RECORDS_REGION: ${OFFICIAL_RECORDS_REGION:-RU}
OFFICIAL_RECORDS_CATEGORY: ${OFFICIAL_RECORDS_CATEGORY:-records} OFFICIAL_RECORDS_CATEGORY: ${OFFICIAL_RECORDS_CATEGORY:-records}
IMPORT_INTERVAL_SECONDS: ${IMPORT_INTERVAL_SECONDS:-3600} IMPORT_INTERVAL_SECONDS: ${IMPORT_INTERVAL_SECONDS:-3600}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
depends_on: depends_on:
api: api:
condition: service_healthy condition: service_healthy
+5 -5
View File
@@ -54,7 +54,7 @@
## Подготовка MVP к пилоту ## Подготовка MVP к пилоту
- [x] Добавить health/readiness-проверки PostgreSQL, MinIO, API и импорта; отразить их в Compose (`/health` без зависимостей, `/ready` с компонентами и режимом обязательного импорта). - [x] Добавить health/readiness-проверки PostgreSQL, MinIO, API и импорта; отразить их в Compose (`/health` без зависимостей, `/ready` с компонентами и режимом обязательного импорта).
- [ ] Добавить структурированные логи без пользовательских секретов и персональных технических данных. - [x] Добавить структурированные JSON-логи без пользовательских секретов и персональных технических данных (whitelist полей, redaction, request ID; Uvicorn access-log отключён).
- [ ] Добавить резервное копирование и документированное восстановление PostgreSQL и MinIO. - [ ] Добавить резервное копирование и документированное восстановление PostgreSQL и MinIO.
- [ ] Провести security-проверку admin-аутентификации, CORS, заголовков, загрузок и управления секретами. - [ ] Провести security-проверку admin-аутентификации, CORS, заголовков, загрузок и управления секретами.
- [ ] Добавить CI: backend tests, Astro check/build, E2E и проверка миграций на чистой БД. - [ ] Добавить CI: backend tests, Astro check/build, E2E и проверка миграций на чистой БД.
@@ -91,10 +91,10 @@
Лёгкий пакет по парсерам и фильтрам завершён. Следующий пакет готовит MVP к пилоту: Лёгкий пакет по парсерам и фильтрам завершён. Следующий пакет готовит MVP к пилоту:
1. структурированные логи без пользовательских секретов; 1. CI для тестов, Astro build, E2E и миграций;
2. CI для тестов, Astro build, E2E и миграций; 2. backup/restore PostgreSQL и MinIO;
3. backup/restore PostgreSQL и MinIO; 3. security-проверка admin-аутентификации, CORS, загрузок и секретов;
4. security-проверка admin-аутентификации, CORS, загрузок и секретов. 4. проверка доступности и Lighthouse основных страниц.
После каждого пункта необходимо: После каждого пункта необходимо: