rmi-backend/app/core/observability.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

151 lines
5.3 KiB
Python

"""T07 GlitchTip - Sentry SDK integration.
Per v4.0 §T07. Self-hosted Sentry-compatible error tracking at
glitchtip.rugmunch.io (Sentry SDK pointed at our own instance).
Key principle: NEVER leak secrets. The before_send hook strips
authorization headers, X-API-Key, passwords, tokens, etc.
Per v3 unfuck rule #7: SDK init must be at module level, not in lifespan.
But the SDK itself uses lazy init - setup_sentry() is called once at startup.
"""
from __future__ import annotations
import logging
import os
from typing import Any
log = logging.getLogger(__name__)
# Config - defaults to local GlitchTip; override via env
DEFAULT_DSN = "http://rmi-glitchtip-web:8000/1"
DEFAULT_ENV = "production"
DEFAULT_SAMPLE_RATE = 0.1 # 10% of transactions traced
# Sensitive field names that must never be sent to error tracking
SENSITIVE_KEYS = {
"authorization", "x-api-key", "x-payment", "x-agent-id",
"password", "secret", "token", "cookie", "set-cookie",
"api_key", "apikey", "access_token", "refresh_token",
"private_key", "credit_card", "ssn",
}
def _scrub_secrets(data: Any) -> Any:
"""Recursively replace sensitive values with '[REDACTED]'.
Walks dicts and lists, checking each key against SENSITIVE_KEYS.
"""
if isinstance(data, dict):
return {
k: "[REDACTED]" if k.lower() in SENSITIVE_KEYS else _scrub_secrets(v)
for k, v in data.items()
}
if isinstance(data, list):
return [_scrub_secrets(x) for x in data]
return data
def setup_sentry() -> bool:
"""Initialize the Sentry SDK pointed at our self-hosted GlitchTip.
Returns True if initialized, False if DSN not configured or
sentry_sdk is not installed (graceful - backend still works).
"""
dsn = os.getenv("GLITCHTIP_DSN") or os.getenv("SENTRY_DSN")
if not dsn:
log.info("sentry_disabled no_dsn")
return False
try:
import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.redis import RedisIntegration
except ImportError as exc:
log.info(f"sentry_sdk_not_installed err={exc}")
return False
env = os.getenv("ENVIRONMENT", DEFAULT_ENV)
traces_sample_rate = float(os.getenv("SENTRY_TRACES_SAMPLE_RATE", DEFAULT_SAMPLE_RATE))
sentry_sdk.init(
dsn=dsn,
environment=env,
traces_sample_rate=traces_sample_rate,
send_default_pii=False, # PII stays in our infra
integrations=[
FastApiIntegration(transaction_style="endpoint"),
HttpxIntegration(),
RedisIntegration(),
],
before_send=_before_send,
before_send_transaction=_before_send_transaction,
)
log.info(f"sentry_initialized dsn={dsn[:30]}... env={env} sample_rate={traces_sample_rate}")
return True
def _before_send(event: dict, hint: dict) -> dict | None:
"""Strip secrets before sending to GlitchTip.
Per v4.0 §T07: secrets scrubbed before send. This is a hard
requirement - never log full request bodies with credentials.
"""
try:
if "request" in event:
if "headers" in event["request"]:
event["request"]["headers"] = _scrub_secrets(event["request"]["headers"])
if "data" in event["request"]:
event["request"]["data"] = _scrub_secrets(event["request"]["data"])
if "extra" in event:
event["extra"] = _scrub_secrets(event["extra"])
if "user" in event:
# Strip email/IP - keep only id
event["user"] = {"id": event["user"].get("id", "anon")}
return event
except Exception as e:
log.warning(f"sentry_scrub_fail err={e}")
return event # Better to log it than to drop it
def _before_send_transaction(event: dict, hint: dict) -> dict | None:
"""Drop transactions that are health checks / metrics scrapes (high volume, low value)."""
url = event.get("transaction") or event.get("request", {}).get("url", "")
if any(s in url for s in ("/health", "/ready", "/live", "/metrics", "/openapi.json")):
return None
return event
def capture_exception(error: BaseException, **extra: Any) -> None:
"""Manually capture an exception. For use in catch blocks where
we want to log to Sentry but continue execution."""
try:
import sentry_sdk
with sentry_sdk.push_scope() as scope:
for k, v in extra.items():
scope.set_extra(k, v)
sentry_sdk.capture_exception(error)
except Exception as e:
log.warning(f"sentry_capture_fail err={e}")
def capture_message(message: str, level: str = "info", **extra: Any) -> None:
"""Manually capture a message. For non-exception events."""
try:
import sentry_sdk
with sentry_sdk.push_scope() as scope:
for k, v in extra.items():
scope.set_extra(k, v)
sentry_sdk.capture_message(message, level=level)
except Exception as e:
log.warning(f"sentry_message_fail err={e}")
def flush_sentry(timeout: float = 2.0) -> None:
"""Flush pending events. Call before shutdown."""
try:
import sentry_sdk
sentry_sdk.flush(timeout=timeout)
except Exception:
pass