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]>
413 lines
14 KiB
Python
413 lines
14 KiB
Python
"""Tests for optional self-hosted Sentry observability (#606).
|
|
|
|
Covers the pure module (config, redaction, event/check-in builders) and the
|
|
runtime capture paths using a fake ``sentry_sdk``, so nothing ever touches the
|
|
network. Critically proves the feature is a no-op when disabled or DSN-less
|
|
(acceptance criterion 1) and that secrets/paths/session-state are never sent
|
|
(criterion 5).
|
|
"""
|
|
|
|
import sys
|
|
import contextlib
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
import sentry_observability as so # noqa: E402
|
|
|
|
|
|
# ── Fake SDK ────────────────────────────────────────────────────────────────
|
|
class _FakeScope:
|
|
def __init__(self):
|
|
self.tags = {}
|
|
self.extras = {}
|
|
|
|
def set_tag(self, k, v):
|
|
self.tags[k] = v
|
|
|
|
def set_extra(self, k, v):
|
|
self.extras[k] = v
|
|
|
|
|
|
class FakeSentrySDK:
|
|
"""Minimal stand-in exposing the SDK surface sentry_observability uses."""
|
|
|
|
def __init__(self):
|
|
self.init_kwargs = None
|
|
self.events = []
|
|
self.exceptions = []
|
|
self.checkins = []
|
|
self.last_scope = None
|
|
|
|
def init(self, **kwargs):
|
|
self.init_kwargs = kwargs
|
|
|
|
def capture_event(self, event):
|
|
self.events.append(event)
|
|
|
|
def capture_exception(self, exc):
|
|
self.exceptions.append(exc)
|
|
|
|
def capture_checkin(self, **payload):
|
|
self.checkins.append(payload)
|
|
|
|
@contextlib.contextmanager
|
|
def push_scope(self):
|
|
self.last_scope = _FakeScope()
|
|
yield self.last_scope
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_state():
|
|
"""Isolate module init state between tests."""
|
|
so.reset_for_tests()
|
|
yield
|
|
so.reset_for_tests()
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_sdk(monkeypatch):
|
|
sdk = FakeSentrySDK()
|
|
monkeypatch.setattr(so, "sentry_sdk", sdk)
|
|
return sdk
|
|
|
|
|
|
# ── Config / gating ─────────────────────────────────────────────────────────
|
|
def test_disabled_by_default_empty_env():
|
|
cfg = so.load_config(env={})
|
|
assert cfg.enabled is False
|
|
assert cfg.active is False
|
|
|
|
|
|
def test_enabled_flag_without_dsn_is_not_active():
|
|
cfg = so.load_config(env={"MCP_SENTRY_ENABLED": "1"})
|
|
assert cfg.enabled is True
|
|
assert cfg.dsn is None
|
|
assert cfg.active is False # DSN required
|
|
|
|
|
|
def test_dsn_without_enabled_flag_is_not_active():
|
|
cfg = so.load_config(env={"SENTRY_DSN": "https://[email protected]/1"})
|
|
assert cfg.active is False
|
|
|
|
|
|
def test_active_requires_enabled_and_dsn():
|
|
cfg = so.load_config(
|
|
env={"MCP_SENTRY_ENABLED": "true", "SENTRY_DSN": "https://[email protected]/1"}
|
|
)
|
|
assert cfg.active is True
|
|
|
|
|
|
def test_truthy_variants():
|
|
for val in ("1", "true", "YES", "On"):
|
|
cfg = so.load_config(env={"MCP_SENTRY_ENABLED": val})
|
|
assert cfg.enabled is True
|
|
for val in ("0", "false", "no", "", "off"):
|
|
cfg = so.load_config(env={"MCP_SENTRY_ENABLED": val})
|
|
assert cfg.enabled is False
|
|
|
|
|
|
def test_traces_sample_rate_parsed_and_clamped():
|
|
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "0.25"}).traces_sample_rate == 0.25
|
|
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "5"}).traces_sample_rate == 1.0
|
|
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "-1"}).traces_sample_rate == 0.0
|
|
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "junk"}).traces_sample_rate == 0.0
|
|
|
|
|
|
def test_safe_summary_has_no_dsn_value():
|
|
cfg = so.load_config(
|
|
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
|
)
|
|
summary = cfg.safe_summary()
|
|
assert summary["dsn_present"] is True
|
|
assert "secret" not in repr(summary)
|
|
assert "dsn" not in summary # only presence, never the value
|
|
|
|
|
|
# ── init_sentry ─────────────────────────────────────────────────────────────
|
|
def test_init_noop_when_disabled(fake_sdk):
|
|
status = so.init_sentry(so.load_config(env={}))
|
|
assert status["initialized"] is False
|
|
assert fake_sdk.init_kwargs is None # no SDK init
|
|
assert so.is_initialized() is False
|
|
|
|
|
|
def test_init_noop_when_enabled_but_missing_dsn(fake_sdk):
|
|
status = so.init_sentry(so.load_config(env={"MCP_SENTRY_ENABLED": "1"}))
|
|
assert status["initialized"] is False
|
|
assert fake_sdk.init_kwargs is None
|
|
|
|
|
|
def test_init_reports_missing_sdk(monkeypatch):
|
|
monkeypatch.setattr(so, "sentry_sdk", None)
|
|
status = so.init_sentry(
|
|
so.load_config(
|
|
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
|
)
|
|
)
|
|
assert status["initialized"] is False
|
|
assert "not installed" in status["reason"]
|
|
|
|
|
|
def test_init_configures_sdk_with_scrubber(fake_sdk):
|
|
status = so.init_sentry(
|
|
so.load_config(
|
|
env={
|
|
"MCP_SENTRY_ENABLED": "1",
|
|
"SENTRY_DSN": "https://[email protected]/1",
|
|
"SENTRY_ENVIRONMENT": "prod",
|
|
"MCP_SENTRY_TRACES_SAMPLE_RATE": "0.1",
|
|
}
|
|
)
|
|
)
|
|
assert status["initialized"] is True
|
|
assert so.is_initialized() is True
|
|
kw = fake_sdk.init_kwargs
|
|
assert kw["dsn"] == "https://[email protected]/1"
|
|
assert kw["environment"] == "prod"
|
|
assert kw["traces_sample_rate"] == 0.1
|
|
assert kw["before_send"] is so.scrub_event
|
|
assert kw["send_default_pii"] is False
|
|
|
|
|
|
def test_init_enable_logs_wires_log_scrubber(fake_sdk):
|
|
so.init_sentry(
|
|
so.load_config(
|
|
env={
|
|
"MCP_SENTRY_ENABLED": "1",
|
|
"SENTRY_DSN": "https://[email protected]/1",
|
|
"MCP_SENTRY_ENABLE_LOGS": "1",
|
|
}
|
|
)
|
|
)
|
|
exp = fake_sdk.init_kwargs["_experiments"]
|
|
assert exp["enable_logs"] is True
|
|
assert exp["before_send_log"] is so.scrub_event
|
|
|
|
|
|
def test_init_never_raises_on_sdk_failure(monkeypatch):
|
|
class Boom:
|
|
def init(self, **kwargs):
|
|
raise RuntimeError("sentry down")
|
|
|
|
monkeypatch.setattr(so, "sentry_sdk", Boom())
|
|
status = so.init_sentry(
|
|
so.load_config(
|
|
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
|
)
|
|
)
|
|
assert status["initialized"] is False
|
|
assert "init failed" in status["reason"]
|
|
|
|
|
|
# ── Redaction (fail closed) ─────────────────────────────────────────────────
|
|
def test_build_tags_allowlist_only():
|
|
tags = so.build_tags(role="author", secret_thing="leak", pid=123)
|
|
assert tags["role"] == "author"
|
|
assert tags["pid"] == "123"
|
|
assert "secret_thing" not in tags
|
|
|
|
|
|
def test_build_tags_hashes_session_id():
|
|
tags = so.build_tags(session_id="prgs-author-20479-cf9ac178")
|
|
assert "session_id" not in tags
|
|
assert "session_id_hash" in tags
|
|
assert tags["session_id_hash"] != "prgs-author-20479-cf9ac178"
|
|
assert len(tags["session_id_hash"]) == 12
|
|
|
|
|
|
def test_build_tags_collapses_worktree_path():
|
|
tags = so.build_tags(
|
|
worktree_path="/Users/x/Development/Gitea-Tools/branches/issue-606-sentry-observability"
|
|
)
|
|
assert "worktree_path" not in tags
|
|
assert tags["worktree_category"] == "author"
|
|
|
|
|
|
def test_build_tags_drops_value_that_scrubs_to_redacted():
|
|
# A tag value that is itself a token gets scrubbed then dropped.
|
|
tags = so.build_tags(capability="token abcdef1234567890")
|
|
assert "capability" not in tags
|
|
|
|
|
|
def test_sanitize_path_categories():
|
|
assert so.sanitize_path("/repo/branches/review-pr-654") == "reviewer"
|
|
assert so.sanitize_path("/repo/branches/merge-pr-1") == "merger"
|
|
assert so.sanitize_path("/repo/branches/reconcile-pr-1") == "reconciler"
|
|
assert so.sanitize_path("/repo/branches/feat-issue-606") == "author"
|
|
assert so.sanitize_path("/x/y/Gitea-Tools") == "root"
|
|
|
|
|
|
def test_redact_value_scrubs_secrets_and_paths():
|
|
out = so.redact_value(
|
|
{
|
|
"token": "abc123",
|
|
"note": "Authorization: Bearer sk_live_abcdefgh12345",
|
|
"path": "/Users/jasonwalker/Development/Gitea-Tools/secret",
|
|
"dsn": "https://[email protected]/1",
|
|
"safe": "hello",
|
|
}
|
|
)
|
|
assert out["token"] == so.REDACTED
|
|
assert out["dsn"] == so.REDACTED
|
|
assert "sk_live" not in out["note"]
|
|
assert so.REDACTED_PATH in out["path"]
|
|
assert "/Users/" not in out["path"]
|
|
assert out["safe"] == "hello"
|
|
|
|
|
|
def test_redact_value_forbidden_prompt_and_session_state():
|
|
out = so.redact_value(
|
|
{"prompt": "full body", "session_state": "{...}", "keep": "ok"}
|
|
)
|
|
assert out["prompt"] == so.REDACTED
|
|
assert out["session_state"] == so.REDACTED
|
|
assert out["keep"] == "ok"
|
|
|
|
|
|
def test_scrub_event_redacts_nested_and_drops_server_name():
|
|
event = {
|
|
"server_name": "some-host",
|
|
"message": "boom",
|
|
"extra": {"token": "leak", "ok": "1"},
|
|
}
|
|
scrubbed = so.scrub_event(event)
|
|
assert "server_name" not in scrubbed
|
|
assert scrubbed["extra"]["token"] == so.REDACTED
|
|
assert scrubbed["extra"]["ok"] == "1"
|
|
|
|
|
|
def test_scrub_event_drops_non_dict():
|
|
assert so.scrub_event("not a dict") is None
|
|
assert so.scrub_event(None) is None
|
|
|
|
|
|
# ── Event builders ──────────────────────────────────────────────────────────
|
|
def test_build_blocker_event_structure_and_next_action():
|
|
event = so.build_blocker_event(
|
|
"active_foreign_lease",
|
|
message="blocked by foreign lease",
|
|
next_action="wait or adopt via allocator",
|
|
level="warning",
|
|
tags={"pr_number": 606, "session_id": "s-123"},
|
|
)
|
|
assert event["tags"]["blocker_type"] == "active_foreign_lease"
|
|
assert event["tags"]["pr_number"] == "606"
|
|
assert "session_id" not in event["tags"]
|
|
assert event["tags"]["session_id_hash"]
|
|
assert event["extra"]["canonical_next_action"] == "wait or adopt via allocator"
|
|
assert event["fingerprint"] == ["workflow-blocker", "active_foreign_lease"]
|
|
|
|
|
|
def test_build_blocker_event_invalid_level_defaults_warning():
|
|
event = so.build_blocker_event("x", level="nonsense")
|
|
assert event["level"] == "warning"
|
|
|
|
|
|
def test_build_checkin_payload_maps_slugs():
|
|
for key, slug in so.MONITOR_SLUGS.items():
|
|
payload = so.build_checkin_payload(key, "ok")
|
|
assert payload["monitor_slug"] == slug
|
|
assert payload["status"] == "ok"
|
|
|
|
|
|
def test_build_checkin_payload_explicit_slug_passthrough():
|
|
payload = so.build_checkin_payload("custom-slug", "in_progress", duration=1.5)
|
|
assert payload["monitor_slug"] == "custom-slug"
|
|
assert payload["duration"] == 1.5
|
|
|
|
|
|
def test_build_checkin_payload_rejects_bad_status():
|
|
with pytest.raises(ValueError):
|
|
so.build_checkin_payload("allocator_health", "bogus")
|
|
|
|
|
|
def test_all_six_monitors_registered():
|
|
assert set(so.MONITOR_SLUGS) == {
|
|
"stale_lease_scan",
|
|
"terminal_lock_scan",
|
|
"allocator_health",
|
|
"namespace_health",
|
|
"dashboard_freshness",
|
|
"reconciler_cleanup",
|
|
}
|
|
|
|
|
|
# ── Capture paths (fail open) ───────────────────────────────────────────────
|
|
def test_capture_workflow_blocker_noop_when_disabled(fake_sdk):
|
|
# not initialised
|
|
event = so.capture_workflow_blocker("some_blocker", message="x")
|
|
assert isinstance(event, dict) # still returns redacted event
|
|
assert fake_sdk.events == [] # but nothing sent
|
|
|
|
|
|
def test_capture_workflow_blocker_sends_when_initialised(fake_sdk):
|
|
so.init_sentry(
|
|
so.load_config(
|
|
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
|
)
|
|
)
|
|
so.capture_workflow_blocker("terminal_lock_occupied", message="held")
|
|
assert len(fake_sdk.events) == 1
|
|
assert fake_sdk.events[0]["tags"]["blocker_type"] == "terminal_lock_occupied"
|
|
|
|
|
|
def test_capture_exception_noop_when_disabled(fake_sdk):
|
|
assert so.capture_exception(ValueError("x")) is False
|
|
assert fake_sdk.exceptions == []
|
|
|
|
|
|
def test_capture_exception_sends_scrubbed_tags(fake_sdk):
|
|
so.init_sentry(
|
|
so.load_config(
|
|
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
|
)
|
|
)
|
|
ok = so.capture_exception(
|
|
RuntimeError("bad"), tags={"mutation_tool": "gitea_merge_pr", "leaky": "x"}
|
|
)
|
|
assert ok is True
|
|
assert len(fake_sdk.exceptions) == 1
|
|
assert fake_sdk.last_scope.tags["mutation_tool"] == "gitea_merge_pr"
|
|
assert "leaky" not in fake_sdk.last_scope.tags
|
|
|
|
|
|
def test_capture_exception_never_raises(monkeypatch, fake_sdk):
|
|
so.init_sentry(
|
|
so.load_config(
|
|
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
|
)
|
|
)
|
|
|
|
def boom(exc):
|
|
raise RuntimeError("sdk exploded")
|
|
|
|
monkeypatch.setattr(fake_sdk, "capture_exception", boom)
|
|
# Must swallow the SDK failure (fail open).
|
|
assert so.capture_exception(ValueError("y")) is False
|
|
|
|
|
|
def test_monitor_checkin_noop_when_disabled(fake_sdk):
|
|
payload = so.monitor_checkin("allocator_health", "ok")
|
|
assert payload["monitor_slug"] == "gitea-mcp-allocator-health"
|
|
assert fake_sdk.checkins == [] # not sent while disabled
|
|
|
|
|
|
def test_monitor_checkin_sends_when_initialised(fake_sdk):
|
|
so.init_sentry(
|
|
so.load_config(
|
|
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
|
)
|
|
)
|
|
so.monitor_checkin("stale_lease_scan", "ok")
|
|
assert fake_sdk.checkins == [
|
|
{"monitor_slug": "gitea-mcp-stale-lease-scan", "status": "ok"}
|
|
]
|
|
|
|
|
|
def test_monitor_checkin_invalid_status_returns_none(fake_sdk):
|
|
assert so.monitor_checkin("allocator_health", "bogus") is None
|
|
assert fake_sdk.checkins == []
|