Files
Gitea-Tools/sentry_observability.py
sysadminandClaude Opus 4.8 78cc37a977 feat(observability): optional self-hosted Sentry instrumentation for MCP workflow failures (Closes #606)
Add an env-var-gated, off-by-default Sentry SDK integration so MCP 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/. Gitea stays the source of truth; Sentry is
observe-only.

New module `sentry_observability.py` mirrors the `gitea_audit` conventions
(env-gated, best-effort, redacting):

- Config from MCP_SENTRY_ENABLED / SENTRY_DSN / SENTRY_ENVIRONMENT /
  SENTRY_RELEASE / MCP_SENTRY_TRACES_SAMPLE_RATE / MCP_SENTRY_ENABLE_LOGS.
  Active only when enabled AND a DSN is present; otherwise a hard no-op.
- Fail OPEN for observability (never blocks a tool success path) and fail
  CLOSED for redaction (drop a field rather than risk leaking it).
- `scrub_event` before_send/before_send_log hook + allowlisted tags: no
  tokens/passwords/keychain ids/DSNs/cookies, no raw session-state or full
  prompt bodies (session_id -> 12-char hash), no full filesystem paths
  (worktree path -> coarse category). Reuses incident_bridge + gitea_audit
  scrubbers.
- capture_exception, capture_workflow_blocker (with canonical next action),
  and monitor_checkin with six stable cron slugs (stale lease scan, terminal
  lock scan, allocator health, namespace health, dashboard freshness,
  reconciler cleanup).
- `sentry_sdk` is a lazily-imported optional dependency; the module imports
  and no-ops cleanly when the package is absent.

Wiring in gitea_mcp_server.py (additive, guarded, best-effort):
- init_sentry() in __main__ before mcp.run.
- capture_exception in the `_audited` failure path; capture_workflow_blocker
  in `_audit_pr_result` BLOCKED/FAILED path.
- allocator + namespace-health watchdog check-ins at their MCP tool sites
  (domain modules left pure).

Also: pin `sentry-sdk==2.20.0` (optional), document the six env vars in
`.env.example`, and add `docs/observability/sentry-integration.md` covering
project creation in https://sentry.prgs.cc/, DSN handling, local/dev/prod
config, redaction guarantees, and coexistence with the #612 incident bridge.

Tests: tests/test_sentry_observability.py (36 cases) cover disabled / enabled /
missing-DSN / missing-SDK, redaction, exception capture, workflow-blocker
capture, and cron check-in behaviour. Full suite: 2632 passed, 6 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-12 02:35:47 -04:00

536 lines
19 KiB
Python

"""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 <value>`` /
# ``Bearer <value>`` / ``Basic <value>`` 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