"""Optional self-hosted Sentry observability for the Gitea MCP server (#606). Adds env-var-gated Sentry SDK instrumentation so runtime errors, fail-closed workflow blockers, lease/terminal-lock/stale-runtime collisions, and recurring watchdog check-ins are visible in a *self-hosted* Sentry at ``https://sentry.prgs.cc/`` — never Sentry Cloud, and never as the workflow source of truth (Gitea stays canonical). Design constraints (mirror ``gitea_audit`` and the #612 incident bridge): - **Off by default.** With ``MCP_SENTRY_ENABLED`` false/unset *or* ``SENTRY_DSN`` empty, ``init_sentry`` is a no-op and no events are ever sent — existing tool behaviour and API-call patterns are unchanged (acceptance criterion 1). - **Fail *open* for observability.** A Sentry outage, a missing ``sentry_sdk`` package, or any capture error must never break an MCP tool success path. Every public entry point swallows its own exceptions. - **Fail *closed* for redaction.** If a field cannot be proven safe it is dropped rather than sent. Tokens, passwords, keychain IDs, DSNs, private config, raw session-state, full prompt bodies, and full filesystem paths never leave here. - **No hard dependency.** ``sentry_sdk`` is imported lazily; the module is fully importable and testable without it installed. Sentry is observe-only: it must not approve, merge, close, or otherwise mutate Gitea workflow state, nor bypass leases, #332, or MCP gates. Alerts may only feed the sanctioned Gitea issue/comment path via the #612 incident bridge. """ from __future__ import annotations import hashlib import os import re from dataclasses import dataclass from typing import Any # Reuse the most comprehensive existing scrubber so redaction stays consistent # with the #612 incident bridge (tokens, DSNs, cookies, bearer/basic, keychain # ids, session ids, user:pass@host). from incident_bridge import redact_text as _redact_text # Second, complementary scrubber: catches bare ``token `` / # ``Bearer `` / ``Basic `` prefixes and raw URLs that the # incident-bridge delimiter patterns miss. from gitea_audit import _redact_str as _redact_prefixes # ── Optional SDK (lazy, never a hard dependency) ──────────────────────────── try: # pragma: no cover - trivial import guard import sentry_sdk # type: ignore except Exception: # pragma: no cover - absence is a supported state sentry_sdk = None # type: ignore # ── Env var names (single source of truth) ────────────────────────────────── ENV_ENABLED = "MCP_SENTRY_ENABLED" ENV_DSN = "SENTRY_DSN" ENV_ENVIRONMENT = "SENTRY_ENVIRONMENT" ENV_RELEASE = "SENTRY_RELEASE" ENV_TRACES_SAMPLE_RATE = "MCP_SENTRY_TRACES_SAMPLE_RATE" ENV_ENABLE_LOGS = "MCP_SENTRY_ENABLE_LOGS" _TRUTHY = frozenset({"1", "true", "yes", "on"}) REDACTED = "[REDACTED]" REDACTED_PATH = "[REDACTED_PATH]" # ── Cron / watchdog monitor slugs (acceptance criterion 6) ────────────────── # Stable slugs for the recurring/watchdog jobs #606 wants check-ins for. The # slug is the durable monitor identity in Sentry; the wiring call sites pass one # of these keys (or an explicit slug) to ``monitor_checkin``. MONITOR_SLUGS: dict[str, str] = { "stale_lease_scan": "gitea-mcp-stale-lease-scan", "terminal_lock_scan": "gitea-mcp-terminal-lock-scan", "allocator_health": "gitea-mcp-allocator-health", "namespace_health": "gitea-mcp-namespace-health", "dashboard_freshness": "gitea-mcp-dashboard-freshness", "reconciler_cleanup": "gitea-mcp-reconciler-cleanup", } _CHECKIN_STATUSES = frozenset({"in_progress", "ok", "error"}) # ── Tag allowlist (issue "Suggested Sentry tags/context") ─────────────────── # Only these keys are ever attached as Sentry tags. Anything else is dropped so # a caller cannot accidentally leak a sensitive value through a tag. ALLOWED_TAG_KEYS = frozenset({ "role", "profile", "namespace", "repo", "org", "issue_number", "pr_number", "blocker_type", "workflow_hash", "session_id_hash", # hash only — never the raw session id "pid", "worktree_category", # category, never the full sensitive path "lease_comment_id", "expected_head_sha", "current_head_sha", "terminal_lock_state", "capability", "mutation_tool", }) # Absolute-path shapes that must never be sent verbatim (macOS/Linux + temp). _PATH_RE = re.compile(r"(?:/private)?/(?:Users|home|tmp|var|opt|Volumes)/[^\s\"']*") # ``extra`` keys whose *full* contents are forbidden by the redaction rules # (raw session-state, full prompt/comment bodies, private config blobs, raw # headers). _FORBIDDEN_EXTRA_KEYS = frozenset({ "prompt", "prompt_body", "next_prompt", "body", "raw_body", "session_state", "session_state_contents", "config", "config_contents", "private_config", "headers", "authorization", }) # ── Configuration ─────────────────────────────────────────────────────────── @dataclass(frozen=True) class SentryConfig: """Immutable snapshot of the Sentry env configuration.""" enabled: bool = False dsn: str | None = None environment: str = "development" release: str | None = None traces_sample_rate: float = 0.0 enable_logs: bool = False @property def active(self) -> bool: """True only when the operator both opted in *and* supplied a DSN. This is the single gate that keeps the feature off by default: enabling the flag without a DSN (or vice versa) sends nothing. """ return bool(self.enabled and self.dsn) def safe_summary(self) -> dict[str, Any]: """Operator-facing status with **no** DSN value (only presence).""" return { "enabled": self.enabled, "dsn_present": bool(self.dsn), "environment": self.environment, "release": self.release, "traces_sample_rate": self.traces_sample_rate, "enable_logs": self.enable_logs, "active": self.active, } def _env_bool(name: str, env: dict[str, str]) -> bool: return (env.get(name) or "").strip().lower() in _TRUTHY def _env_float(name: str, default: float, env: dict[str, str]) -> float: raw = (env.get(name) or "").strip() if not raw: return default try: val = float(raw) except (TypeError, ValueError): return default # Clamp to Sentry's valid [0.0, 1.0] sample-rate range. if val < 0.0: return 0.0 if val > 1.0: return 1.0 return val def load_config(env: dict[str, str] | None = None) -> SentryConfig: """Build a :class:`SentryConfig` from the environment (read at call time).""" env = dict(os.environ if env is None else env) dsn = (env.get(ENV_DSN) or "").strip() or None return SentryConfig( enabled=_env_bool(ENV_ENABLED, env), dsn=dsn, environment=(env.get(ENV_ENVIRONMENT) or "").strip() or "development", release=(env.get(ENV_RELEASE) or "").strip() or None, traces_sample_rate=_env_float(ENV_TRACES_SAMPLE_RATE, 0.0, env), enable_logs=_env_bool(ENV_ENABLE_LOGS, env), ) def sdk_available() -> bool: """True when the optional ``sentry_sdk`` package is importable.""" return sentry_sdk is not None # ── Redaction (fail closed) ───────────────────────────────────────────────── def sanitize_path(value: Any) -> str: """Reduce a filesystem path to a non-sensitive *category* token. Full local paths must never be sent. We keep only a coarse worktree category derived from the path shape (author/reviewer/merger/reconciler/ branches/root/other). """ text = "" if value is None else str(value) low = text.lower() if not text: return "unknown" # Order matters: more specific role markers before the generic "branches". if "reconcile" in low: return "reconciler" if "review" in low: return "reviewer" if "merge" in low or "merger" in low: return "merger" if "author" in low or re.search(r"/branches/(?:feat|fix|docs|chore|issue)", low): return "author" if "/branches/" in low: return "branches" if low.rstrip("/").endswith("gitea-tools"): return "root" return "other" def redact_value(value: Any) -> Any: """Recursively redact a JSON-able value: secret text, absolute paths, and known-sensitive dict keys are removed. Fail closed — any error drops the value entirely rather than risk leaking it.""" try: if isinstance(value, dict): out: dict[str, Any] = {} for k, v in value.items(): key = str(k) low = key.lower() if low in _FORBIDDEN_EXTRA_KEYS or any( s in low for s in ("token", "secret", "password", "cookie", "auth", "dsn", "keychain") ): out[key] = REDACTED continue out[key] = redact_value(v) return out if isinstance(value, (list, tuple)): return [redact_value(v) for v in value] if isinstance(value, str): scrubbed = _redact_text(value) scrubbed = _redact_prefixes(scrubbed) scrubbed = _PATH_RE.sub(REDACTED_PATH, scrubbed) return scrubbed return value except Exception: return REDACTED def hash_session_id(session_id: Any) -> str: """Short, stable, non-reversible fingerprint of a session id.""" digest = hashlib.sha256(str(session_id).encode("utf-8", "replace")).hexdigest() return digest[:12] def build_tags(**kwargs: Any) -> dict[str, str]: """Return a scrubbed, allowlisted tag dict. ``session_id`` is accepted but only ever surfaced as ``session_id_hash``. ``worktree_path`` collapses to ``worktree_category``. Any non-allowlisted key, or a value that still contains redacted material after scrubbing, is dropped. """ raw: dict[str, Any] = dict(kwargs) # Hash the session id — never emit it raw. session_id = raw.pop("session_id", None) if session_id and "session_id_hash" not in raw: raw["session_id_hash"] = hash_session_id(session_id) # A full worktree path collapses to a category tag. wt = raw.pop("worktree_path", None) if wt and "worktree_category" not in raw: raw["worktree_category"] = sanitize_path(wt) out: dict[str, str] = {} for key, val in raw.items(): if key not in ALLOWED_TAG_KEYS: continue if val is None: continue scrubbed = redact_value(val) text = str(scrubbed) if not text or REDACTED in text or REDACTED_PATH in text: continue if len(text) > 200: text = text[:200] + "…" out[key] = text return out def scrub_event(event: Any, hint: Any = None) -> dict[str, Any] | None: """Sentry ``before_send`` / ``before_send_log`` hook. Recursively redacts the outgoing event. On *any* failure it returns ``None`` so the event is dropped rather than sent unscrubbed (fail closed for redaction). """ try: if not isinstance(event, dict): return None scrubbed = redact_value(event) # Drop server_name if it leaked a hostname/path; PID is kept via tags. scrubbed.pop("server_name", None) return scrubbed except Exception: return None # ── Event builders (pure, independently testable) ─────────────────────────── def build_blocker_event( blocker_type: str, *, message: str | None = None, next_action: str | None = None, level: str = "warning", tags: dict[str, Any] | None = None, extra: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build a redacted, structured Sentry event for a workflow blocker. ``next_action`` maps the issue's "canonical next action when available" requirement (acceptance criterion 7). """ merged_tags = dict(tags or {}) merged_tags.setdefault("blocker_type", blocker_type) safe_tags = build_tags(**merged_tags) safe_extra = redact_value(dict(extra or {})) if next_action: # A short canonical next action is allowed (it is not a full prompt). safe_extra["canonical_next_action"] = redact_value(str(next_action)[:500]) event: dict[str, Any] = { "message": redact_value(message or blocker_type), "level": level if level in ("debug", "info", "warning", "error", "fatal") else "warning", "logger": "gitea-mcp.workflow", "tags": safe_tags, "extra": safe_extra, "fingerprint": ["workflow-blocker", blocker_type], } return event def build_checkin_payload( monitor: str, status: str, *, check_in_id: str | None = None, duration: float | None = None, ) -> dict[str, Any]: """Build a Sentry cron check-in payload for one of :data:`MONITOR_SLUGS`. ``monitor`` may be a registry key (e.g. ``"stale_lease_scan"``) or an explicit slug. Raises ``ValueError`` on an unknown status so callers cannot silently send a malformed check-in. """ if status not in _CHECKIN_STATUSES: raise ValueError( f"invalid check-in status {status!r}; expected one of {sorted(_CHECKIN_STATUSES)}" ) slug = MONITOR_SLUGS.get(monitor, monitor) payload: dict[str, Any] = {"monitor_slug": slug, "status": status} if check_in_id: payload["check_in_id"] = str(check_in_id) if duration is not None: try: payload["duration"] = float(duration) except (TypeError, ValueError): pass return payload # ── Runtime init + capture (fail open) ────────────────────────────────────── _STATE: dict[str, Any] = {"initialized": False, "config": None} def is_initialized() -> bool: return bool(_STATE.get("initialized")) def active_config() -> SentryConfig | None: return _STATE.get("config") def reset_for_tests() -> None: """Clear module init state. Test-only helper (never called in production).""" _STATE["initialized"] = False _STATE["config"] = None def init_sentry(config: SentryConfig | None = None) -> dict[str, Any]: """Initialise the Sentry SDK if (and only if) enabled + DSN + SDK present. Idempotent and never raises. Returns an operator-safe status dict (no DSN value). Behaviour is unchanged when the feature is off. """ cfg = config or load_config() status: dict[str, Any] = {"initialized": False, **cfg.safe_summary()} try: if not cfg.active: status["reason"] = "disabled (MCP_SENTRY_ENABLED false or SENTRY_DSN empty)" _STATE["config"] = cfg return status if not sdk_available(): status["reason"] = "sentry_sdk not installed" _STATE["config"] = cfg return status init_kwargs: dict[str, Any] = { "dsn": cfg.dsn, "environment": cfg.environment, "release": cfg.release, "traces_sample_rate": cfg.traces_sample_rate, "before_send": scrub_event, "send_default_pii": False, } if cfg.enable_logs: # sentry-sdk 2.x captures Python logs as structured logs when the # experimental logs feature is enabled; scrub those too. init_kwargs["_experiments"] = { "enable_logs": True, "before_send_log": scrub_event, } sentry_sdk.init(**init_kwargs) # type: ignore[union-attr] _STATE["initialized"] = True _STATE["config"] = cfg status["initialized"] = True status["reason"] = "sentry initialised" except Exception as exc: # fail open: observability must not block startup status["reason"] = f"init failed (ignored): {type(exc).__name__}" _STATE["initialized"] = False return status def _set_scope_tags(scope: Any, tags: dict[str, str]) -> None: for key, val in tags.items(): try: scope.set_tag(key, val) except Exception: pass def capture_workflow_blocker( blocker_type: str, *, message: str | None = None, next_action: str | None = None, level: str = "warning", tags: dict[str, Any] | None = None, extra: dict[str, Any] | None = None, ) -> dict[str, Any]: """Capture a fail-closed workflow blocker as a structured Sentry event. Always returns the redacted event dict (so callers/tests can inspect it), and sends it to Sentry only when initialised. Fail open. """ event = build_blocker_event( blocker_type, message=message, next_action=next_action, level=level, tags=tags, extra=extra, ) try: if is_initialized() and sdk_available(): sentry_sdk.capture_event(event) # type: ignore[union-attr] except Exception: pass return event def capture_exception( exc: BaseException, *, tags: dict[str, Any] | None = None, extra: dict[str, Any] | None = None, ) -> bool: """Capture a runtime exception with scrubbed tags. Fail open. Returns True only when the event was handed to an initialised SDK. """ try: if not (is_initialized() and sdk_available()): return False safe_tags = build_tags(**(tags or {})) safe_extra = redact_value(dict(extra or {})) with sentry_sdk.push_scope() as scope: # type: ignore[union-attr] _set_scope_tags(scope, safe_tags) for key, val in safe_extra.items(): try: scope.set_extra(key, val) except Exception: pass sentry_sdk.capture_exception(exc) # type: ignore[union-attr] return True except Exception: return False def monitor_checkin( monitor: str, status: str, *, check_in_id: str | None = None, duration: float | None = None, ) -> dict[str, Any] | None: """Send a Sentry cron check-in for a watchdog job. Fail open. Returns the payload (for inspection/tests), or ``None`` if the status was invalid. Only transmits when initialised. """ try: payload = build_checkin_payload( monitor, status, check_in_id=check_in_id, duration=duration ) except ValueError: return None try: if is_initialized() and sdk_available() and hasattr(sentry_sdk, "capture_checkin"): sentry_sdk.capture_checkin(**payload) # type: ignore[union-attr] except Exception: pass return payload