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]>
536 lines
19 KiB
Python
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
|