| """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__) |
|
|
| |
| DEFAULT_DSN = "http://rmi-glitchtip-web:8000/1" |
| DEFAULT_ENV = "production" |
| DEFAULT_SAMPLE_RATE = 0.1 |
|
|
| |
| 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, |
| 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: |
| |
| event["user"] = {"id": event["user"].get("id", "anon")} |
| return event |
| except Exception as e: |
| log.warning(f"sentry_scrub_fail err={e}") |
| return event |
|
|
|
|
| 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 |
|
|