check / check (push) Waiting to run
The webhook page opens with a statistics pane: entrypoints and targets, deliveries in progress, the last arrival, the retention period; events, deliveries and failures, lifetime and within retention; and events, failures and failure percentage over 10 minutes and 24 hours. Running totals, one row per target plus one for events, sit in each webhook's event database and are written in the same transaction as the rows they count; retention prunes in paused, stoppable batches and subtracts what it removes. Window figures are index-range counts on a new finished_at column. An existing event database must be recreated. 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
|
|
}
|