package observability import ( "context" "fmt" "net/http" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/redis/go-redis/v9" "github.com/ultisuite/ulti-backend/internal/config" ) type DependencyHealth struct { Name string `json:"name"` Status string `json:"status"` LatencyMS int64 `json:"latency_ms"` Error string `json:"error,omitempty"` } type HealthReport struct { Status string `json:"status"` TimestampUTC string `json:"timestamp_utc"` Checks []DependencyHealth `json:"checks"` } type HealthChecker struct { db *pgxpool.Pool redis *redis.Client httpClient *http.Client nextcloudEnabled bool nextcloudURL string immichEnabled bool immichURL string jitsiEnabled bool jitsiURL string } func NewHealthChecker(cfg *config.Config, db *pgxpool.Pool, redisClient *redis.Client) *HealthChecker { timeout := cfg.HealthHTTPTimeout if timeout <= 0 { timeout = 3 * time.Second } return &HealthChecker{ db: db, redis: redisClient, httpClient: &http.Client{Timeout: timeout}, nextcloudEnabled: cfg.NextcloudEnabled, nextcloudURL: cfg.HealthNextcloudURL, immichEnabled: cfg.ImmichEnabled, immichURL: cfg.HealthImmichURL, jitsiEnabled: cfg.JitsiEnabled, jitsiURL: cfg.HealthJitsiURL, } } func (h *HealthChecker) Check(ctx context.Context) HealthReport { checks := []DependencyHealth{ h.checkDB(ctx), h.checkRedis(ctx), } checks = append(checks, h.checkHTTP(ctx, "nextcloud", h.nextcloudEnabled, h.nextcloudURL)) checks = append(checks, h.checkHTTP(ctx, "immich", h.immichEnabled, h.immichURL)) checks = append(checks, h.checkHTTP(ctx, "jitsi", h.jitsiEnabled, h.jitsiURL)) status := "ok" for _, check := range checks { if check.Status == "down" { status = "degraded" break } } return HealthReport{ Status: status, TimestampUTC: time.Now().UTC().Format(time.RFC3339), Checks: checks, } } func (h *HealthChecker) checkDB(ctx context.Context) DependencyHealth { start := time.Now() err := h.db.Ping(ctx) return toDependencyHealth("postgres", start, err) } func (h *HealthChecker) checkRedis(ctx context.Context) DependencyHealth { start := time.Now() err := h.redis.Ping(ctx).Err() return toDependencyHealth("keydb", start, err) } func (h *HealthChecker) checkHTTP(ctx context.Context, name string, enabled bool, url string) DependencyHealth { if !enabled { return DependencyHealth{Name: name, Status: "disabled"} } start := time.Now() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return toDependencyHealth(name, start, err) } resp, err := h.httpClient.Do(req) if err != nil { return toDependencyHealth(name, start, err) } defer resp.Body.Close() if resp.StatusCode >= http.StatusBadRequest { return toDependencyHealth(name, start, fmt.Errorf("unexpected status %d", resp.StatusCode)) } return toDependencyHealth(name, start, nil) } func toDependencyHealth(name string, start time.Time, err error) DependencyHealth { item := DependencyHealth{ Name: name, LatencyMS: time.Since(start).Milliseconds(), } if err != nil { item.Status = "down" item.Error = err.Error() return item } item.Status = "up" return item }