feat(tradein): enrich GlitchTip SDK init — integrations + PII scrub (#396) #643
4 changed files with 155 additions and 1 deletions
|
|
@ -8,6 +8,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
|
|
@ -16,6 +17,11 @@ import sentry_sdk
|
|||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
||||
from sentry_sdk.integrations.httpx import HttpxIntegration
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||
from sentry_sdk.integrations.starlette import StarletteIntegration
|
||||
|
||||
from app.api.v1 import admin, brand, geocode, me, search, trade_in
|
||||
from app.core.auth import get_role
|
||||
|
|
@ -23,6 +29,7 @@ from app.core.config import settings
|
|||
from app.core.db import SessionLocal
|
||||
from app.core.fdw import ensure_fdw_user_mapping
|
||||
from app.core.ratelimit import RateLimitMiddleware
|
||||
from app.observability.sentry_scrub import scrub_pii_event
|
||||
from app.services.scheduler import scheduler_loop
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -33,13 +40,26 @@ logging.basicConfig(
|
|||
)
|
||||
|
||||
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
|
||||
# DSN из env GLITCHTIP_DSN; пусто (dev) → мониторинг не инициализируется.
|
||||
# DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP.
|
||||
# Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs),
|
||||
# Logging (logger.error → events). БЕЗ CeleryIntegration — prod не гоняет celery
|
||||
# worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/
|
||||
# frontend), отдельного broker нет → мониторить нечего.
|
||||
if settings.glitchtip_dsn:
|
||||
sentry_sdk.init(
|
||||
dsn=settings.glitchtip_dsn,
|
||||
environment=settings.environment,
|
||||
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
|
||||
traces_sample_rate=0.0, # только ошибки, без performance-трейсов
|
||||
send_default_pii=False, # не шлём client_name / client_phone в отчёты
|
||||
before_send=scrub_pii_event, # дочищаем consumer-PII из тела error events
|
||||
integrations=[
|
||||
StarletteIntegration(),
|
||||
FastApiIntegration(),
|
||||
SqlalchemyIntegration(),
|
||||
HttpxIntegration(),
|
||||
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
|
||||
],
|
||||
)
|
||||
logging.getLogger("app.main").info("GlitchTip monitoring enabled")
|
||||
|
||||
|
|
|
|||
0
tradein-mvp/backend/app/observability/__init__.py
Normal file
0
tradein-mvp/backend/app/observability/__init__.py
Normal file
44
tradein-mvp/backend/app/observability/sentry_scrub.py
Normal file
44
tradein-mvp/backend/app/observability/sentry_scrub.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""Хук before_send для GlitchTip/Sentry SDK (tradein-local, #396).
|
||||
|
||||
Redact-ит consumer-PII (client_name / client_phone / client_email и пр.)
|
||||
из error events до отправки в GlitchTip — estimator/trade-in flow таскает
|
||||
эти поля, а send_default_pii=False их не покрывает (это user-data в
|
||||
request.data / extra / contexts, не PII-заголовки).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sentry_sdk.types import Event
|
||||
|
||||
_REDACTED = "[REDACTED]"
|
||||
# Ключи consumer-PII (нижний регистр; сверка case-insensitive).
|
||||
_PII_KEYS = frozenset(
|
||||
{"client_name", "client_phone", "client_email", "phone", "email", "name"}
|
||||
)
|
||||
|
||||
|
||||
def _scrub(obj: Any) -> None:
|
||||
"""Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place)."""
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
if isinstance(key, str) and key.lower() in _PII_KEYS:
|
||||
obj[key] = _REDACTED
|
||||
else:
|
||||
_scrub(value)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
_scrub(item)
|
||||
|
||||
|
||||
def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
|
||||
"""Redact consumer-PII из error event перед отправкой. Возвращает event (не None)."""
|
||||
if not isinstance(event, dict):
|
||||
return event
|
||||
request = event.get("request")
|
||||
if isinstance(request, dict):
|
||||
_scrub(request.get("data"))
|
||||
_scrub(event.get("extra"))
|
||||
_scrub(event.get("contexts"))
|
||||
return event
|
||||
90
tradein-mvp/backend/tests/test_sentry_scrub.py
Normal file
90
tradein-mvp/backend/tests/test_sentry_scrub.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Unit-тесты consumer-PII scrubber для GlitchTip (#396)."""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.observability.sentry_scrub import scrub_pii_event
|
||||
|
||||
|
||||
def test_redacts_pii_in_request_data() -> None:
|
||||
event = {
|
||||
"request": {
|
||||
"data": {
|
||||
"client_name": "Иван Иванов",
|
||||
"client_phone": "+79991234567",
|
||||
"client_email": "ivan@example.com",
|
||||
"address": "Екатеринбург, ул. Ленина 1",
|
||||
}
|
||||
}
|
||||
}
|
||||
out = scrub_pii_event(event, {})
|
||||
data = out["request"]["data"]
|
||||
assert data["client_name"] == "[REDACTED]"
|
||||
assert data["client_phone"] == "[REDACTED]"
|
||||
assert data["client_email"] == "[REDACTED]"
|
||||
# non-PII поле не трогаем
|
||||
assert data["address"] == "Екатеринбург, ул. Ленина 1"
|
||||
|
||||
|
||||
def test_redacts_pii_in_extra() -> None:
|
||||
event = {
|
||||
"extra": {
|
||||
"phone": "+79990000000",
|
||||
"email": "x@y.ru",
|
||||
"name": "Пётр",
|
||||
"estimate_id": 42,
|
||||
}
|
||||
}
|
||||
out = scrub_pii_event(event, {})
|
||||
extra = out["extra"]
|
||||
assert extra["phone"] == "[REDACTED]"
|
||||
assert extra["email"] == "[REDACTED]"
|
||||
assert extra["name"] == "[REDACTED]"
|
||||
assert extra["estimate_id"] == 42
|
||||
|
||||
|
||||
def test_redact_is_case_insensitive() -> None:
|
||||
event = {"extra": {"Client_Name": "Анна", "CLIENT_PHONE": "+7900"}}
|
||||
out = scrub_pii_event(event, {})
|
||||
assert out["extra"]["Client_Name"] == "[REDACTED]"
|
||||
assert out["extra"]["CLIENT_PHONE"] == "[REDACTED]"
|
||||
|
||||
|
||||
def test_redacts_nested_pii_in_contexts() -> None:
|
||||
event = {"contexts": {"trace": {"op": "http"}, "consumer": {"client_email": "z@z.ru"}}}
|
||||
out = scrub_pii_event(event, {})
|
||||
assert out["contexts"]["consumer"]["client_email"] == "[REDACTED]"
|
||||
# вложенный non-PII контекст не трогаем
|
||||
assert out["contexts"]["trace"]["op"] == "http"
|
||||
|
||||
|
||||
def test_leaves_non_pii_untouched() -> None:
|
||||
event = {
|
||||
"request": {"data": {"region": "66", "area_sqm": 50}},
|
||||
"extra": {"job": "geocode"},
|
||||
"level": "error",
|
||||
}
|
||||
out = scrub_pii_event(event, {})
|
||||
assert out["request"]["data"] == {"region": "66", "area_sqm": 50}
|
||||
assert out["extra"] == {"job": "geocode"}
|
||||
assert out["level"] == "error"
|
||||
|
||||
|
||||
def test_handles_missing_sections() -> None:
|
||||
out = scrub_pii_event({}, {})
|
||||
assert out == {}
|
||||
|
||||
|
||||
def test_handles_none_and_non_dict_sections() -> None:
|
||||
event = {"request": None, "extra": None, "contexts": "not-a-dict"}
|
||||
# не должно бросать исключений
|
||||
out = scrub_pii_event(event, {})
|
||||
assert out is event
|
||||
|
||||
|
||||
def test_returns_event_not_none() -> None:
|
||||
"""before_send должен вернуть event (не None) — иначе SDK дропнет отчёт."""
|
||||
event = {"request": {"data": {"client_name": "X"}}}
|
||||
out = scrub_pii_event(event, {})
|
||||
assert out is not None
|
||||
assert out is event
|
||||
Loading…
Add table
Reference in a new issue