feat(tradein): enrich GlitchTip SDK init — integrations + PII scrub (#396) #643

Merged
Light1YT merged 1 commit from feat/396-glitchtip-integrations into main 2026-05-29 09:23:17 +00:00
4 changed files with 155 additions and 1 deletions

View file

@ -8,6 +8,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os
import re import re
from collections.abc import AsyncGenerator, Awaitable, Callable from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -16,6 +17,11 @@ import sentry_sdk
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, Response 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.api.v1 import admin, brand, geocode, me, search, trade_in
from app.core.auth import get_role 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.db import SessionLocal
from app.core.fdw import ensure_fdw_user_mapping from app.core.fdw import ensure_fdw_user_mapping
from app.core.ratelimit import RateLimitMiddleware from app.core.ratelimit import RateLimitMiddleware
from app.observability.sentry_scrub import scrub_pii_event
from app.services.scheduler import scheduler_loop from app.services.scheduler import scheduler_loop
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -33,13 +40,26 @@ logging.basicConfig(
) )
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396). # Мониторинг ошибок — 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: if settings.glitchtip_dsn:
sentry_sdk.init( sentry_sdk.init(
dsn=settings.glitchtip_dsn, dsn=settings.glitchtip_dsn,
environment=settings.environment, environment=settings.environment,
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
traces_sample_rate=0.0, # только ошибки, без performance-трейсов traces_sample_rate=0.0, # только ошибки, без performance-трейсов
send_default_pii=False, # не шлём client_name / client_phone в отчёты 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") logging.getLogger("app.main").info("GlitchTip monitoring enabled")

View 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

View 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