276 lines
9.4 KiB
Python
276 lines
9.4 KiB
Python
"""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()
|