check / check (push) Waiting to run
Each webhook's event database keeps running totals: one row for its events, and one row per target for that target's deliveries, delivered and failed, each with what retention removed. Every write to them shares the transaction of the rows it counts. Deliveries get a finished_at column; it and target_id end the status index, so each target's deliveries finished in a window come from one index-range query grouped by target. Retention deletes 1000 expired events per transaction. The pane is its own template, its figures in tables. The schema changes in place with nothing back-filled, so an existing database must be recreated. Model: opus-5-5
272 lines
6.5 KiB
Go
272 lines
6.5 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 stored event arrived, or nil when
|
|
// none is stored.
|
|
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)
|
|
}
|
|
|
|
var newest []time.Time
|
|
|
|
err = db.Model(&database.Event{}).
|
|
Order("created_at DESC").
|
|
Limit(1).
|
|
Pluck("created_at", &newest).Error
|
|
if err != nil {
|
|
return fmt.Errorf("reading newest event time: %w", err)
|
|
}
|
|
|
|
if len(newest) > 0 {
|
|
stats.LastEventAt = &newest[0]
|
|
}
|
|
|
|
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 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
|
|
|
|
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
|
|
}
|