From af6278eaee60744108f04fb55481581480bb3f05 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Fri, 29 May 2026 09:23:17 +0000 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20enrich=20GlitchTip=20SDK=20ini?= =?UTF-8?q?t=20=E2=80=94=20integrations=20+=20PII=20scrub=20(#396)=20(#643?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/main.py | 22 ++++- .../backend/app/observability/__init__.py | 0 .../backend/app/observability/sentry_scrub.py | 44 +++++++++ .../backend/tests/test_sentry_scrub.py | 90 +++++++++++++++++++ 4 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/backend/app/observability/__init__.py create mode 100644 tradein-mvp/backend/app/observability/sentry_scrub.py create mode 100644 tradein-mvp/backend/tests/test_sentry_scrub.py diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 3043e21c..c46176e1 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -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") diff --git a/tradein-mvp/backend/app/observability/__init__.py b/tradein-mvp/backend/app/observability/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tradein-mvp/backend/app/observability/sentry_scrub.py b/tradein-mvp/backend/app/observability/sentry_scrub.py new file mode 100644 index 00000000..7450f18c --- /dev/null +++ b/tradein-mvp/backend/app/observability/sentry_scrub.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_sentry_scrub.py b/tradein-mvp/backend/tests/test_sentry_scrub.py new file mode 100644 index 00000000..2cbcab57 --- /dev/null +++ b/tradein-mvp/backend/tests/test_sentry_scrub.py @@ -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