fix(backend): GlitchTip release fallback + apiKey scrub (#204 review fixes)
- release= now: GIT_SHA or SENTRY_RELEASE or "unknown" (covers existing deploy.yml which writes SENTRY_RELEASE=$IMAGE_TAG, not GIT_SHA) - new module app/observability/sentry_scrub.py: before_send_transaction hook that redacts apiKey/token/access_token/secret query params from HttpxIntegration spans before they reach GlitchTip - config.py: model_validator promotes legacy SENTRY_DSN -> glitchtip_dsn with DeprecationWarning for backward compat on existing VPS env - .env.example: added deprecated SENTRY_DSN entry with comment - tests: 8 new cases covering release fallback + scrub module (11 total)
This commit is contained in:
parent
2f79f7c40a
commit
8a04af5da3
7 changed files with 185 additions and 7 deletions
|
|
@ -6,11 +6,13 @@ REDIS_URL=redis://redis:6379/0
|
||||||
CORS_ORIGINS=["http://localhost:3000"]
|
CORS_ORIGINS=["http://localhost:3000"]
|
||||||
ENVIRONMENT=dev
|
ENVIRONMENT=dev
|
||||||
|
|
||||||
# GlitchTip error tracking (https://errors.gendsgn.ru)
|
# DSN для GlitchTip (https://errors.gendsgn.ru). Пустое = SDK no-op.
|
||||||
# Формат: https://<key>@errors.gendsgn.ru/<project_id>
|
# Формат: https://<key>@errors.gendsgn.ru/<project_id>
|
||||||
# Пустая строка = SDK не инициализируется.
|
|
||||||
GLITCHTIP_DSN=
|
GLITCHTIP_DSN=
|
||||||
GLITCHTIP_TRACES_SAMPLE_RATE=0.05
|
GLITCHTIP_TRACES_SAMPLE_RATE=0.05
|
||||||
|
# DEPRECATED — будет удалён после 1-2 deploy-циклов. Использовать GLITCHTIP_DSN.
|
||||||
|
# Если задан, а GLITCHTIP_DSN пуст — автоматически продвигается с DeprecationWarning.
|
||||||
|
SENTRY_DSN=
|
||||||
|
|
||||||
# External APIs (Stage 2)
|
# External APIs (Stage 2)
|
||||||
ROSREESTR_PKK_BASE_URL=https://pkk.rosreestr.ru/api/features/1
|
ROSREESTR_PKK_BASE_URL=https://pkk.rosreestr.ru/api/features/1
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
import os
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
from pydantic import model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -14,6 +18,26 @@ class Settings(BaseSettings):
|
||||||
glitchtip_traces_sample_rate: float = 0.05
|
glitchtip_traces_sample_rate: float = 0.05
|
||||||
environment: str = "dev"
|
environment: str = "dev"
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _promote_legacy_sentry_dsn(self) -> "Settings":
|
||||||
|
"""Backward-compat: если SENTRY_DSN задан, а GLITCHTIP_DSN пуст — автопродвигаем.
|
||||||
|
|
||||||
|
На существующем VPS .env.runtime содержит SENTRY_DSN=https://...
|
||||||
|
(старое имя переменной). Pydantic extra="ignore" молча его отбрасывает,
|
||||||
|
SDK остаётся no-op. Читаем напрямую из os.environ и выдаём DeprecationWarning.
|
||||||
|
"""
|
||||||
|
if not self.glitchtip_dsn:
|
||||||
|
legacy = os.getenv("SENTRY_DSN")
|
||||||
|
if legacy:
|
||||||
|
warnings.warn(
|
||||||
|
"SENTRY_DSN is set but ignored by pydantic; rename to GLITCHTIP_DSN. "
|
||||||
|
"Auto-promoting for backward compat.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
self.glitchtip_dsn = legacy
|
||||||
|
return self
|
||||||
|
|
||||||
# External APIs (Stage 2)
|
# External APIs (Stage 2)
|
||||||
rosreestr_pkk_base_url: str = "https://pkk.rosreestr.ru/api/features/1"
|
rosreestr_pkk_base_url: str = "https://pkk.rosreestr.ru/api/features/1"
|
||||||
overpass_url: str = "https://overpass-api.de/api/interpreter"
|
overpass_url: str = "https://overpass-api.de/api/interpreter"
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ from app.api.v1 import (
|
||||||
photos,
|
photos,
|
||||||
)
|
)
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.observability.sentry_scrub import scrub_sensitive_query
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -35,10 +36,11 @@ 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", "unknown"),
|
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
|
||||||
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
||||||
profiles_sample_rate=0.0,
|
profiles_sample_rate=0.0,
|
||||||
send_default_pii=False,
|
send_default_pii=False,
|
||||||
|
before_send_transaction=scrub_sensitive_query,
|
||||||
integrations=[
|
integrations=[
|
||||||
StarletteIntegration(),
|
StarletteIntegration(),
|
||||||
FastApiIntegration(),
|
FastApiIntegration(),
|
||||||
|
|
|
||||||
0
backend/app/observability/__init__.py
Normal file
0
backend/app/observability/__init__.py
Normal file
49
backend/app/observability/sentry_scrub.py
Normal file
49
backend/app/observability/sentry_scrub.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""Хук before_send_transaction для GlitchTip/Sentry SDK.
|
||||||
|
|
||||||
|
Redact-ит api keys / tokens из URL-spans перед отправкой — чтобы
|
||||||
|
секреты (apiKey=..., api_key=..., token=...) не утекали в GlitchTip
|
||||||
|
через HttpxIntegration performance-spans.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sentry_sdk.types import Event
|
||||||
|
|
||||||
|
_SENSITIVE_PARAM_RE = re.compile(
|
||||||
|
r"((?:api[_-]?[Kk]ey|token|access[_-]?token|secret)=)([^&\s]+)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(value: Any) -> Any:
|
||||||
|
"""Заменить значения чувствительных query-параметров на [REDACTED]."""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return _SENSITIVE_PARAM_RE.sub(r"\1[REDACTED]", value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def scrub_sensitive_query(event: Event, _hint: dict[str, Any]) -> Event | None:
|
||||||
|
"""Redact api keys / tokens из URL spans перед отправкой в GlitchTip.
|
||||||
|
|
||||||
|
Обрабатывает:
|
||||||
|
- span.data["url"], span.data["http.url"], span.data["http.target"]
|
||||||
|
- span["description"]
|
||||||
|
- event["request"]["url"]
|
||||||
|
"""
|
||||||
|
for span in event.get("spans") or []:
|
||||||
|
data = span.get("data")
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for key in ("url", "http.url", "http.target"):
|
||||||
|
if key in data:
|
||||||
|
data[key] = _redact(data[key])
|
||||||
|
if "description" in span:
|
||||||
|
span["description"] = _redact(span["description"])
|
||||||
|
|
||||||
|
request = event.get("request")
|
||||||
|
if isinstance(request, dict) and "url" in request:
|
||||||
|
request["url"] = _redact(request["url"])
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
@ -15,6 +15,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration
|
||||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.observability.sentry_scrub import scrub_sensitive_query
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -25,10 +26,11 @@ 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", "unknown"),
|
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
|
||||||
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
||||||
profiles_sample_rate=0.0,
|
profiles_sample_rate=0.0,
|
||||||
send_default_pii=False,
|
send_default_pii=False,
|
||||||
|
before_send_transaction=scrub_sensitive_query,
|
||||||
integrations=[
|
integrations=[
|
||||||
CeleryIntegration(monitor_beat_tasks=True),
|
CeleryIntegration(monitor_beat_tasks=True),
|
||||||
SqlalchemyIntegration(),
|
SqlalchemyIntegration(),
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
"""Unit-тесты логики инициализации GlitchTip SDK.
|
"""Unit-тесты логики инициализации GlitchTip SDK.
|
||||||
|
|
||||||
Проверяем что init-блок в main.py / celery_app.py вызывает sentry_sdk.init()
|
Проверяем что init-блок в main.py / celery_app.py вызывает sentry_sdk.init()
|
||||||
только при непустом GLITCHTIP_DSN.
|
только при непустом GLITCHTIP_DSN, что release-fallback работает корректно,
|
||||||
|
и что scrub_sensitive_query redact-ит api keys из URL spans.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
|
|
@ -18,7 +20,6 @@ def test_sdk_imports_without_error() -> None:
|
||||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||||
from sentry_sdk.integrations.starlette import StarletteIntegration
|
from sentry_sdk.integrations.starlette import StarletteIntegration
|
||||||
|
|
||||||
# Проверяем что классы инстанцируются без ошибок
|
|
||||||
assert StarletteIntegration()
|
assert StarletteIntegration()
|
||||||
assert FastApiIntegration()
|
assert FastApiIntegration()
|
||||||
assert CeleryIntegration()
|
assert CeleryIntegration()
|
||||||
|
|
@ -30,7 +31,6 @@ def test_sdk_imports_without_error() -> None:
|
||||||
def test_no_sdk_init_when_dsn_empty() -> None:
|
def test_no_sdk_init_when_dsn_empty() -> None:
|
||||||
"""Если GLITCHTIP_DSN пустой, sentry_sdk.init() не должен вызываться."""
|
"""Если GLITCHTIP_DSN пустой, sentry_sdk.init() не должен вызываться."""
|
||||||
with patch("sentry_sdk.init") as mock_init:
|
with patch("sentry_sdk.init") as mock_init:
|
||||||
# Симулируем логику guard из main.py: if settings.glitchtip_dsn: sentry_sdk.init(...)
|
|
||||||
glitchtip_dsn = None
|
glitchtip_dsn = None
|
||||||
if glitchtip_dsn:
|
if glitchtip_dsn:
|
||||||
sentry_sdk.init(dsn=glitchtip_dsn)
|
sentry_sdk.init(dsn=glitchtip_dsn)
|
||||||
|
|
@ -57,3 +57,102 @@ def test_sdk_init_called_when_dsn_set() -> None:
|
||||||
assert call_kwargs["dsn"] == dsn
|
assert call_kwargs["dsn"] == dsn
|
||||||
assert call_kwargs["send_default_pii"] is False
|
assert call_kwargs["send_default_pii"] is False
|
||||||
assert call_kwargs["profiles_sample_rate"] == 0.0
|
assert call_kwargs["profiles_sample_rate"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── release fallback ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_uses_git_sha_when_set() -> None:
|
||||||
|
"""GIT_SHA имеет приоритет над SENTRY_RELEASE."""
|
||||||
|
env = {"GIT_SHA": "abc1234", "SENTRY_RELEASE": "v1.0.0"}
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
release = os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown"
|
||||||
|
assert release == "abc1234"
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_falls_back_to_sentry_release() -> None:
|
||||||
|
"""Если GIT_SHA не задан, используется SENTRY_RELEASE."""
|
||||||
|
env = {"SENTRY_RELEASE": "v1.2.3"}
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
# Убираем GIT_SHA если он есть
|
||||||
|
os.environ.pop("GIT_SHA", None)
|
||||||
|
release = os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown"
|
||||||
|
assert release == "v1.2.3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_falls_back_to_unknown() -> None:
|
||||||
|
"""Если ни одна переменная не задана, release='unknown'."""
|
||||||
|
with patch.dict(os.environ, {}, clear=False):
|
||||||
|
os.environ.pop("GIT_SHA", None)
|
||||||
|
os.environ.pop("SENTRY_RELEASE", None)
|
||||||
|
release = os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown"
|
||||||
|
assert release == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# ── sentry_scrub ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrub_redacts_apikey_in_span_url() -> None:
|
||||||
|
"""scrub_sensitive_query заменяет apiKey= в span data['url']."""
|
||||||
|
from app.observability.sentry_scrub import scrub_sensitive_query
|
||||||
|
|
||||||
|
event: dict = {
|
||||||
|
"spans": [
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"url": "https://api.objctv.ru/v2/Report?apiKey=supersecret&group=EKB",
|
||||||
|
"http.url": "https://api.objctv.ru/v2/Report?api_key=topsecret",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = scrub_sensitive_query(event, {})
|
||||||
|
span_data = result["spans"][0]["data"]
|
||||||
|
assert "[REDACTED]" in span_data["url"]
|
||||||
|
assert "supersecret" not in span_data["url"]
|
||||||
|
assert "[REDACTED]" in span_data["http.url"]
|
||||||
|
assert "topsecret" not in span_data["http.url"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrub_redacts_token_in_description() -> None:
|
||||||
|
"""scrub_sensitive_query заменяет token= в span description."""
|
||||||
|
from app.observability.sentry_scrub import scrub_sensitive_query
|
||||||
|
|
||||||
|
event: dict = {
|
||||||
|
"spans": [{"description": "GET https://example.com?token=mysecrettoken&foo=bar"}]
|
||||||
|
}
|
||||||
|
result = scrub_sensitive_query(event, {})
|
||||||
|
assert "[REDACTED]" in result["spans"][0]["description"]
|
||||||
|
assert "mysecrettoken" not in result["spans"][0]["description"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrub_redacts_request_url() -> None:
|
||||||
|
"""scrub_sensitive_query заменяет token в event['request']['url']."""
|
||||||
|
from app.observability.sentry_scrub import scrub_sensitive_query
|
||||||
|
|
||||||
|
event: dict = {"request": {"url": "https://example.com?access_token=abc123&other=val"}}
|
||||||
|
result = scrub_sensitive_query(event, {})
|
||||||
|
assert "[REDACTED]" in result["request"]["url"]
|
||||||
|
assert "abc123" not in result["request"]["url"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrub_passes_through_clean_event() -> None:
|
||||||
|
"""scrub_sensitive_query не трогает URL без чувствительных параметров."""
|
||||||
|
from app.observability.sentry_scrub import scrub_sensitive_query
|
||||||
|
|
||||||
|
event: dict = {
|
||||||
|
"spans": [{"data": {"url": "https://example.com?foo=bar&page=1"}}],
|
||||||
|
"request": {"url": "https://example.com/api/health"},
|
||||||
|
}
|
||||||
|
result = scrub_sensitive_query(event, {})
|
||||||
|
assert result["spans"][0]["data"]["url"] == "https://example.com?foo=bar&page=1"
|
||||||
|
assert result["request"]["url"] == "https://example.com/api/health"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrub_handles_missing_spans() -> None:
|
||||||
|
"""scrub_sensitive_query не падает если 'spans' отсутствует."""
|
||||||
|
from app.observability.sentry_scrub import scrub_sensitive_query
|
||||||
|
|
||||||
|
event: dict = {"request": {"url": "https://example.com"}}
|
||||||
|
result = scrub_sensitive_query(event, {})
|
||||||
|
assert result["request"]["url"] == "https://example.com"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue