Ctrl K
ComunidadGuías…

internal/rest/health.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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
	})
}