check / check (push) Waiting to run
Replaces #559, which conflicted. Model: opus-5-5
260 lines
6.3 KiB
Go
260 lines
6.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// The spans of the two recent windows the statistics pane reports on:
|
|
// the last 10 minutes and the last 24 hours.
|
|
const (
|
|
shortWindow = 10 * time.Minute
|
|
longWindow = 24 * time.Hour
|
|
)
|
|
|
|
// percent turns a fraction into a percentage.
|
|
const percent = 100
|
|
|
|
// WebhookStats holds the figures in the statistics pane at the top of
|
|
// the webhook page.
|
|
type WebhookStats struct {
|
|
Entrypoints int
|
|
ActiveEntrypoints int
|
|
Targets int
|
|
ActiveTargets int
|
|
|
|
// Lifetime counts every event, delivery and failure the webhook
|
|
// has had, and WithinRetention those still stored.
|
|
Lifetime Counts
|
|
WithinRetention Counts
|
|
|
|
// InProgress counts the deliveries still pending or retrying.
|
|
InProgress int64
|
|
|
|
// LastEventAt is when the newest event arrived, or nil when none
|
|
// has. Retention does not change it.
|
|
LastEventAt *time.Time
|
|
|
|
Last10Minutes RecentWindow
|
|
Last24Hours RecentWindow
|
|
}
|
|
|
|
// Counts holds a number of events, of deliveries and of failed
|
|
// deliveries.
|
|
type Counts struct {
|
|
Events int64
|
|
Deliveries int64
|
|
Failures int64
|
|
}
|
|
|
|
// RecentWindow holds what happened in one recent window: the events
|
|
// received in it, and the deliveries that became delivered or failed in
|
|
// it.
|
|
type RecentWindow struct {
|
|
Events int64
|
|
Delivered int64
|
|
Failed int64
|
|
}
|
|
|
|
// TargetFinished is how many of one target's deliveries became
|
|
// delivered, and how many failed, in a recent window.
|
|
type TargetFinished struct {
|
|
TargetID string
|
|
Delivered int64
|
|
Failed int64
|
|
}
|
|
|
|
// FailurePercent is the share of the deliveries finished in the window
|
|
// that failed, or a dash when none finished. Deliveries still pending
|
|
// or retrying are not counted either way.
|
|
func (w RecentWindow) FailurePercent() string {
|
|
finished := w.Delivered + w.Failed
|
|
if finished == 0 {
|
|
return "—"
|
|
}
|
|
|
|
return fmt.Sprintf(
|
|
"%.1f%%", percent*float64(w.Failed)/float64(finished),
|
|
)
|
|
}
|
|
|
|
// loadWebhookStats gathers the figures for the statistics pane from the
|
|
// webhook's entrypoints and targets, as the page has already loaded
|
|
// them, and from its event database. It returns nil, and logs why, when
|
|
// the event database cannot be read.
|
|
func (h *Handlers) loadWebhookStats(
|
|
webhookID string,
|
|
entrypoints []database.Entrypoint,
|
|
targets []database.Target,
|
|
) *WebhookStats {
|
|
stats := &WebhookStats{
|
|
Entrypoints: len(entrypoints),
|
|
Targets: len(targets),
|
|
}
|
|
|
|
for i := range entrypoints {
|
|
if entrypoints[i].Active {
|
|
stats.ActiveEntrypoints++
|
|
}
|
|
}
|
|
|
|
for i := range targets {
|
|
if targets[i].Active {
|
|
stats.ActiveTargets++
|
|
}
|
|
}
|
|
|
|
// Opening an event database that does not exist would create it,
|
|
// and it would hold nothing to count.
|
|
if !h.dbMgr.DBExists(webhookID) {
|
|
return stats
|
|
}
|
|
|
|
webhookDB, err := h.dbMgr.GetDB(webhookID)
|
|
if err == nil {
|
|
err = readEventStats(webhookDB, time.Now(), stats)
|
|
}
|
|
|
|
if err != nil {
|
|
h.log.Error(
|
|
"failed to read webhook statistics",
|
|
"webhook_id", webhookID,
|
|
"error", err,
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
return stats
|
|
}
|
|
|
|
// readEventStats fills in the figures that come from the webhook's
|
|
// event database. None of them reads every stored row: the totals are
|
|
// one row for the events and one per target for the deliveries, and
|
|
// every other figure is read from an index, over only the rows it
|
|
// counts.
|
|
func readEventStats(
|
|
db *gorm.DB, now time.Time, stats *WebhookStats,
|
|
) error {
|
|
err := readTotals(db, stats)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = db.Model(&database.Delivery{}).
|
|
Where("status IN ?", []database.DeliveryStatus{
|
|
database.DeliveryStatusPending,
|
|
database.DeliveryStatusRetrying,
|
|
}).
|
|
Count(&stats.InProgress).Error
|
|
if err != nil {
|
|
return fmt.Errorf("counting deliveries in progress: %w", err)
|
|
}
|
|
|
|
stats.Last10Minutes, err = readRecentWindow(
|
|
db, now.Add(-shortWindow),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
stats.Last24Hours, err = readRecentWindow(
|
|
db, now.Add(-longWindow),
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// readTotals fills in the lifetime and within-retention figures, and
|
|
// when the last event arrived, from the running totals: the events'
|
|
// row, and the targets' rows summed.
|
|
func readTotals(db *gorm.DB, stats *WebhookStats) error {
|
|
var events database.EventTotals
|
|
|
|
err := db.Take(&events).Error
|
|
if err != nil {
|
|
return fmt.Errorf("reading event totals: %w", err)
|
|
}
|
|
|
|
var targets []database.TargetTotals
|
|
|
|
err = db.Find(&targets).Error
|
|
if err != nil {
|
|
return fmt.Errorf("reading target totals: %w", err)
|
|
}
|
|
|
|
stats.Lifetime.Events = events.Events
|
|
stats.WithinRetention.Events = events.Events - events.EventsRemoved
|
|
stats.LastEventAt = events.LastEventAt
|
|
|
|
for _, t := range targets {
|
|
stats.Lifetime.Deliveries += t.Deliveries
|
|
stats.Lifetime.Failures += t.Failed
|
|
stats.WithinRetention.Deliveries += t.Deliveries - t.DeliveriesRemoved
|
|
stats.WithinRetention.Failures += t.Failed - t.FailedRemoved
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// readRecentWindow counts the events received, and the deliveries that
|
|
// became delivered or failed, since the given time.
|
|
func readRecentWindow(
|
|
db *gorm.DB, since time.Time,
|
|
) (RecentWindow, error) {
|
|
var w RecentWindow
|
|
|
|
err := db.Model(&database.Event{}).
|
|
Where("created_at >= ?", since).
|
|
Count(&w.Events).Error
|
|
if err != nil {
|
|
return w, fmt.Errorf("counting recent events: %w", err)
|
|
}
|
|
|
|
byTarget, err := finishedByTarget(db, since)
|
|
if err != nil {
|
|
return w, err
|
|
}
|
|
|
|
for _, f := range byTarget {
|
|
w.Delivered += f.Delivered
|
|
w.Failed += f.Failed
|
|
}
|
|
|
|
return w, nil
|
|
}
|
|
|
|
// finishedByTarget counts, for each target, the deliveries that became
|
|
// delivered and those that failed since the given time, in one query
|
|
// over just that window of the deliveries' status index. A target with
|
|
// neither is left out.
|
|
func finishedByTarget(
|
|
db *gorm.DB, since time.Time,
|
|
) ([]TargetFinished, error) {
|
|
var byTarget []TargetFinished
|
|
|
|
err := db.Model(&database.Delivery{}).
|
|
Select("target_id, "+
|
|
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+
|
|
"count(CASE WHEN status = ? THEN 1 END) AS failed",
|
|
database.DeliveryStatusDelivered,
|
|
database.DeliveryStatusFailed).
|
|
Where("status IN ? AND finished_at >= ?",
|
|
[]database.DeliveryStatus{
|
|
database.DeliveryStatusDelivered,
|
|
database.DeliveryStatusFailed,
|
|
}, since).
|
|
Group("target_id").
|
|
Find(&byTarget).Error
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"counting deliveries finished by target: %w", err,
|
|
)
|
|
}
|
|
|
|
return byTarget, nil
|
|
}
|