feat(webui): Sentry/GlitchTip observability console & correlation view (Closes #649)
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user