internal/rest/health.go
Rama main · réplica de lectura del repositorio original.
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
})
}