package rest import ( "context" "net/http" "github.com/danielgtaylor/huma/v2" "github.com/sarathsp06/sparrow/internal/webhooks" "github.com/sarathsp06/sparrow/internal/webhooks/store" ) type webhookHealthOutput struct { Body struct { WebhookID string `json:"webhook_id" doc:"Webhook id (UUID)."` Health string `json:"health" enum:"healthy,degraded,unhealthy,unknown" doc:"Health label from the last health evaluation: healthy, degraded, unhealthy, or unknown (too few recent attempts). Updated every SPARROW_HEALTH_EVAL_INTERVAL (default 1m)."` TotalDeliveries int `json:"total_deliveries" doc:"Delivery attempts in the last rules.window_hours hours (each retry counts)."` SuccessfulDeliveries int `json:"successful_deliveries" doc:"Successful attempts in the window."` FailedDeliveries int `json:"failed_deliveries" doc:"Failed attempts in the window."` ConsecutiveFailures int `json:"consecutive_failures" doc:"Current run of consecutive failed attempts; resets on a success."` SuccessRate float64 `json:"success_rate" doc:"successful_deliveries / total_deliveries over the window, 0.0 to 1.0."` AvgResponseTime int `json:"avg_response_time" doc:"Average endpoint response time over the window, in milliseconds."` ClientErrors int `json:"client_errors" doc:"Failed attempts in the window with a 4xx response (client_error)."` ServerErrors int `json:"server_errors" doc:"Failed attempts in the window with a 5xx response (server_error)."` TimeoutErrors int `json:"timeout_errors" doc:"Failed attempts in the window that timed out."` NetworkErrors int `json:"network_errors" doc:"Failed attempts in the window with a network-level error (DNS, TLS, connection refused, reset)."` UnexpectedStatusErrors int `json:"unexpected_status_errors" doc:"Attempts in the window whose 2xx/3xx response was not in expected_status_codes."` Rules HealthRules `json:"rules" doc:"Thresholds the health status is derived from."` } } // HealthRules are the thresholds behind the healthy/degraded/unhealthy // labels, served so clients can explain them without copying the numbers. type HealthRules struct { WindowHours int `json:"window_hours" doc:"Lookback window, in hours, of the attempt counts and success rate."` UnhealthyConsecutiveFailures int `json:"unhealthy_consecutive_failures" doc:"Failed attempts in a row that make a webhook unhealthy whatever its success rate."` UnhealthySuccessRate float64 `json:"unhealthy_success_rate" doc:"A success rate below this (0.0 to 1.0), over at least unhealthy_min_attempts attempts, is unhealthy."` UnhealthyMinAttempts int `json:"unhealthy_min_attempts" doc:"Attempts in the window needed before a low success rate counts as unhealthy."` DegradedSuccessRate float64 `json:"degraded_success_rate" doc:"A success rate below this (0.0 to 1.0), over at least degraded_min_attempts attempts, is degraded."` DegradedMinAttempts int `json:"degraded_min_attempts" doc:"Attempts in the window needed before a low success rate counts as degraded."` HealthyMinAttempts int `json:"healthy_min_attempts" doc:"Attempts at or above degraded_success_rate needed to call a webhook healthy; with fewer it stays unknown."` } func healthRules() HealthRules { r := store.DefaultHealthRules return HealthRules{ WindowHours: r.WindowHours, UnhealthyConsecutiveFailures: r.UnhealthyConsecutiveFailures, UnhealthySuccessRate: r.UnhealthySuccessRate, UnhealthyMinAttempts: r.UnhealthyMinAttempts, DegradedSuccessRate: r.DegradedSuccessRate, DegradedMinAttempts: r.DegradedMinAttempts, HealthyMinAttempts: r.HealthyMinAttempts, } } type healthSummaryOutput struct { Body struct { HealthyCount int `json:"healthy_count" doc:"Webhooks currently labelled healthy."` DegradedCount int `json:"degraded_count" doc:"Webhooks currently labelled degraded."` UnhealthyCount int `json:"unhealthy_count" doc:"Webhooks currently labelled unhealthy."` UnknownCount int `json:"unknown_count" doc:"Webhooks labelled unknown: too few recent attempts to judge."` Rules HealthRules `json:"rules" doc:"Thresholds the health statuses are derived from."` } } type healthSummaryInput struct { Consumer string `query:"consumer,omitempty" doc:"Count one consumer's webhooks; omit to count across all consumers."` } // listWebhooksGlobalInput is global (no consumer) — used both for // cross-consumer health-filtered dashboards and for looking up a specific // webhook by id when its consumer is unknown (ListWebhooks supports empty // consumer = search all consumers). type listWebhooksGlobalInput struct { Consumer string `query:"consumer,omitempty" doc:"Filter to one consumer; omit to list webhooks across all consumers."` Health string `query:"health,omitempty" enum:"healthy,degraded,unhealthy,unknown" doc:"Filter to webhooks with this computed health status."` WebhookID string `query:"webhook_id,omitempty" doc:"Look up a single webhook by id when its consumer is unknown."` Limit int32 `query:"limit" default:"50" doc:"Maximum items to return; values above 1000 are capped at 1000."` Offset int32 `query:"offset" default:"0" doc:"Number of items to skip, for pagination."` } // healthRouteService is the service slice health routes consume: health // queries plus the cross-consumer webhook listing and its conversions. type healthRouteService interface { webhooks.HealthManager webhooks.WebhookManager webhooks.SubscriptionManager webhooks.SecretRevealer } func registerHealthRoutes(api huma.API, svc healthRouteService) { huma.Register(api, huma.Operation{ OperationID: "getWebhookHealth", Method: http.MethodGet, Path: "/v1/consumers/{consumer}/webhooks/{webhook_id}/health", Summary: "Get health status and metrics for a webhook", Description: "Returns one webhook's health label plus attempt counts and per-category error counts (client, server, timeout, network, unexpected-status) over the last rules.window_hours hours, and the thresholds the label is derived from. The label lags by up to SPARROW_HEALTH_EVAL_INTERVAL. Counts are zero before the first attempt. 404 when the webhook is not in this consumer.\n\nPortal tokens: allowed, as GET /portal/api/webhooks/{webhook_id}/health.", Errors: []int{404}, Tags: []string{"Health"}, }, func(ctx context.Context, in *webhookIDInput) (*webhookHealthOutput, error) { h, err := svc.GetWebhookHealth(ctx, in.WebhookID, in.Consumer) if err != nil { return nil, mapError(ctx, err, "failed to get webhook health") } out := &webhookHealthOutput{} out.Body.WebhookID = h.WebhookID out.Body.Health = string(h.Health) out.Body.TotalDeliveries = h.TotalDeliveries out.Body.SuccessfulDeliveries = h.SuccessfulDeliveries out.Body.FailedDeliveries = h.FailedDeliveries out.Body.ConsecutiveFailures = h.ConsecutiveFailures out.Body.SuccessRate = h.SuccessRate out.Body.AvgResponseTime = h.AvgResponseTime out.Body.ClientErrors = h.ClientErrors out.Body.ServerErrors = h.ServerErrors out.Body.TimeoutErrors = h.TimeoutErrors out.Body.NetworkErrors = h.NetworkErrors out.Body.UnexpectedStatusErrors = h.UnexpectedStatusErrors out.Body.Rules = healthRules() return out, nil }) huma.Register(api, huma.Operation{ OperationID: "getHealthSummary", Method: http.MethodGet, Path: "/v1/health-summary", Summary: "Get aggregate webhook health counts", Description: "Returns how many webhooks are currently healthy, degraded, unhealthy, or unknown, in one consumer or across every consumer — for a top-level dashboard tile, plus the thresholds behind those statuses. Deleted webhooks are not counted.\n\nPortal tokens: not allowed.", Tags: []string{"Health"}, }, func(ctx context.Context, in *healthSummaryInput) (*healthSummaryOutput, error) { s, err := svc.GetHealthSummary(ctx, in.Consumer) if err != nil { return nil, mapError(ctx, err, "failed to get health summary") } out := &healthSummaryOutput{} out.Body.HealthyCount = s.HealthyCount out.Body.DegradedCount = s.DegradedCount out.Body.UnhealthyCount = s.UnhealthyCount out.Body.UnknownCount = s.UnknownCount out.Body.Rules = healthRules() return out, nil }) huma.Register(api, huma.Operation{ OperationID: "listWebhooksByHealth", Method: http.MethodGet, Path: "/v1/webhooks", Summary: "List webhooks across all consumers", Description: "Lists webhooks across every consumer, newest first, with masked secrets. Pass consumer to scope to one consumer, health to filter by label, or webhook_id for an id-only lookup when the consumer is not known. Offset paginated with total_count.\n\nPortal tokens: not allowed.", Tags: []string{"Health"}, }, func(ctx context.Context, in *listWebhooksGlobalInput) (*listWebhooksOutput, error) { regs, total, err := svc.ListWebhooks(ctx, in.Consumer, in.WebhookID, "", false, in.Health, in.Limit, in.Offset) if err != nil { return nil, mapError(ctx, err, "failed to list webhooks") } eventsMap := getWebhookEventsMap(ctx, svc, regs) out := &listWebhooksOutput{} out.Body.Items = make([]WebhookOut, len(regs)) for i, r := range regs { out.Body.Items[i] = toWebhookOut(r, eventsMap[r.ID.String()], svc) } out.Body.Pagination = newPagination(in.Limit, in.Offset, total) return out, nil }) }