"""Sentry/GlitchTip observability and incident correlation loader for the console (#649, Phase 4). Operators need to inspect provider connection status (Sentry/GlitchTip), error correlations, and durable Gitea issue linkage — without treating raw incidents as allocator work. ADR authority model: * Gitea owns work. * Providers (Sentry/GlitchTip) observe incidents. * Control-plane DB coordinates incident links. * The #612 bridge reconciles observations into durable Gitea issues. * The web console projects read-only state and gates mutations. Redaction boundary: * Provider auth tokens, DSNs, Authorization headers, and sensitive local file paths are ALWAYS redacted before leaving this module. """ from __future__ import annotations import os from dataclasses import dataclass from typing import Any from control_plane_db import ControlPlaneDB import sentry_incident_bridge from webui import console_redaction OBSERVABILITY_SCHEMA_VERSION = 1 @dataclass(frozen=True) class ProviderHealth: """Connection and health status of an observability provider.""" provider: str base_url: str org: str project: str configured: bool status: str bridge_enabled: bool lookback: str min_events_for_issue: int self_hosted: bool environment: str | None = None credentials_present: bool = False def to_dict(self) -> dict[str, Any]: data = { "provider": self.provider, "base_url": self.base_url, "org": self.org, "project": self.project, "configured": self.configured, "status": self.status, "bridge_enabled": self.bridge_enabled, "lookback": self.lookback, "min_events_for_issue": self.min_events_for_issue, "self_hosted": self.self_hosted, "environment": self.environment, "credentials_present": self.credentials_present, } return console_redaction.redact_payload(data) @dataclass(frozen=True) class CorrelatedIncidentLink: """One linked provider incident ↔ Gitea issue correlation record.""" link_id: int provider: str provider_base_url: str provider_org: str provider_project: str provider_issue_id: str provider_short_id: str | None provider_permalink: str | None fingerprint: str | None gitea_org: str gitea_repo: str gitea_issue_number: int linked_pr_numbers: list[int] last_seen: str | None event_count: int created_at: str | None updated_at: str | None def to_dict(self) -> dict[str, Any]: data = { "link_id": self.link_id, "provider": self.provider, "provider_base_url": self.provider_base_url, "provider_org": self.provider_org, "provider_project": self.provider_project, "provider_issue_id": self.provider_issue_id, "provider_short_id": self.provider_short_id, "provider_permalink": self.provider_permalink, "fingerprint": self.fingerprint, "gitea_org": self.gitea_org, "gitea_repo": self.gitea_repo, "gitea_issue_number": self.gitea_issue_number, "linked_pr_numbers": self.linked_pr_numbers, "last_seen": self.last_seen, "event_count": self.event_count, "created_at": self.created_at, "updated_at": self.updated_at, } return console_redaction.redact_payload(data) @dataclass(frozen=True) class ObservabilitySnapshot: """Read-only snapshot of observability provider status and incident correlations.""" schema_version: int providers: list[ProviderHealth] links: list[CorrelatedIncidentLink] total_links: int sentry_links_count: int glitchtip_links_count: int bridge_active: bool def to_dict(self) -> dict[str, Any]: return { "schema_version": self.schema_version, "providers": [p.to_dict() for p in self.providers], "links": [link.to_dict() for link in self.links], "metrics": { "total_links": self.total_links, "sentry_links_count": self.sentry_links_count, "glitchtip_links_count": self.glitchtip_links_count, "bridge_active": self.bridge_active, }, } def load_provider_health( provider_name: str = "sentry", env: dict[str, str] | None = None, ) -> ProviderHealth: """Inspect configuration and connection health for an observability provider.""" source_env = dict(env if env is not None else os.environ) if provider_name.lower() == "sentry": config = sentry_incident_bridge.load_bridge_config(source_env) token = sentry_incident_bridge.resolve_token(source_env) has_token = bool(token) configured = bool(config.org and config.project and has_token) if not config.org or not config.project: status = "not_configured" elif not has_token: status = "missing_token" elif not config.bridge_enabled: status = "disabled" else: status = "healthy" return ProviderHealth( provider="sentry", base_url=config.base_url, org=config.org or "unconfigured", project=config.project or "unconfigured", configured=configured, status=status, bridge_enabled=config.bridge_enabled, lookback=config.lookback, min_events_for_issue=config.min_events_for_issue, self_hosted=not config.base_url.rstrip("/").endswith("sentry.io"), environment=config.environment, credentials_present=has_token, ) # GlitchTip or fallback provider configuration glitchtip_url = (source_env.get("GLITCHTIP_BASE_URL") or "https://glitchtip.prgs.cc").strip() glitchtip_org = (source_env.get("GLITCHTIP_ORG") or "").strip() glitchtip_proj = (source_env.get("GLITCHTIP_PROJECT") or "").strip() glitchtip_token = (source_env.get("GLITCHTIP_AUTH_TOKEN") or "").strip() has_token = bool(glitchtip_token) configured = bool(glitchtip_org and glitchtip_proj and has_token) status = "healthy" if configured else ("missing_token" if glitchtip_org and glitchtip_proj else "not_configured") return ProviderHealth( provider="glitchtip", base_url=glitchtip_url, org=glitchtip_org or "unconfigured", project=glitchtip_proj or "unconfigured", configured=configured, status=status, bridge_enabled=configured, lookback="24h", min_events_for_issue=2, self_hosted=True, environment=source_env.get("GLITCHTIP_ENVIRONMENT"), credentials_present=has_token, ) def _parse_pr_numbers(raw: Any) -> list[int]: if isinstance(raw, list): return [int(x) for x in raw if str(x).isdigit()] if isinstance(raw, str) and raw.strip(): import json try: parsed = json.loads(raw) if isinstance(parsed, list): return [int(x) for x in parsed if str(x).isdigit()] except Exception: pass return [] def load_observability_snapshot( db: ControlPlaneDB | None = None, env: dict[str, str] | None = None, ) -> ObservabilitySnapshot: """Build a read-only snapshot of observability connection health and incident links.""" sentry_health = load_provider_health("sentry", env) glitchtip_health = load_provider_health("glitchtip", env) providers = [sentry_health, glitchtip_health] target_db = db or ControlPlaneDB() raw_links = target_db.list_incident_links(limit=100) links: list[CorrelatedIncidentLink] = [] sentry_cnt = 0 glitchtip_cnt = 0 for r in raw_links: prov = (r.get("provider") or "sentry").lower() if prov == "sentry": sentry_cnt += 1 elif prov == "glitchtip": glitchtip_cnt += 1 pr_nums = _parse_pr_numbers(r.get("linked_pr_numbers")) links.append( CorrelatedIncidentLink( link_id=int(r.get("link_id", 0)), provider=prov, provider_base_url=r.get("provider_base_url") or "", provider_org=r.get("provider_org") or "", provider_project=r.get("provider_project") or "", provider_issue_id=str(r.get("provider_issue_id") or ""), provider_short_id=r.get("provider_short_id"), provider_permalink=r.get("provider_permalink"), fingerprint=r.get("fingerprint"), gitea_org=r.get("gitea_org") or "Scaled-Tech-Consulting", gitea_repo=r.get("gitea_repo") or "Gitea-Tools", gitea_issue_number=int(r.get("gitea_issue_number", 0)), linked_pr_numbers=pr_nums, last_seen=r.get("last_seen"), event_count=int(r.get("event_count", 1)), created_at=r.get("created_at"), updated_at=r.get("updated_at"), ) ) bridge_active = any(p.bridge_enabled for p in providers) return ObservabilitySnapshot( schema_version=OBSERVABILITY_SCHEMA_VERSION, providers=providers, links=links, total_links=len(links), sentry_links_count=sentry_cnt, glitchtip_links_count=glitchtip_cnt, bridge_active=bridge_active, ) def snapshot_to_dict(snapshot: ObservabilitySnapshot) -> dict[str, Any]: return snapshot.to_dict()