Files
webhooker/internal/handlers/webhook_stats.go
T
clawbot 19978c6324
check / check (push) Canceled after 0s
Show the last event as a relative time with a UTC tooltip (closes #567)
The "Last event" figure in the webhook page's Statistics block now shows how long ago the newest event arrived, as its two largest units of d, h, m and s with no spaces ("59s", "1m1s", "3d4h"; a zero second unit is left off). It is worked out when the page is served, and a time ahead of the server clock shows "0s". Its tooltip is the full time in UTC. With no event it still shows "none", without a tooltip. A unit test pins each unit boundary, and the headless-browser test checks the figure and its tooltip.

Model: opus-5-5
2026-10-26 19:18:38 +01:00

309 lines
7.9 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
)
// day is the largest unit relativeTime writes.
const day = 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. LastEvent is how long ago
// that was, as relativeTime writes it, and LastEventUTC the full
// time; both are empty when no event has arrived.
LastEventAt *time.Time
LastEvent string
LastEventUTC string
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),
)
}
// relativeTime is how long before now the time at was, in whole
// seconds, written as its two largest units out of d, h, m and s:
// "59s", "1m1s", "23h59m", "3d4h". A second unit of zero is left off,
// as in "5m". A time after now is "0s".
func relativeTime(at, now time.Time) string {
elapsed := max(now.Sub(at), 0)
days := int64(elapsed / day)
hours := int64(elapsed % day / time.Hour)
minutes := int64(elapsed % time.Hour / time.Minute)
seconds := int64(elapsed % time.Minute / time.Second)
switch {
case days > 0:
return twoUnits(days, "d", hours, "h")
case hours > 0:
return twoUnits(hours, "h", minutes, "m")
case minutes > 0:
return twoUnits(minutes, "m", seconds, "s")
default:
return fmt.Sprintf("%ds", seconds)
}
}
// twoUnits writes a count of a larger unit followed by a count of the
// next smaller one, leaving the smaller off when it is zero.
func twoUnits(
larger int64, largerUnit string, smaller int64, smallerUnit string,
) string {
if smaller == 0 {
return fmt.Sprintf("%d%s", larger, largerUnit)
}
return fmt.Sprintf("%d%s%d%s", larger, largerUnit, smaller, smallerUnit)
}
// 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, now, 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 and how long before now that was, from
// the running totals: the events' row, and the targets' rows summed.
func readTotals(db *gorm.DB, now time.Time, 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
if events.LastEventAt != nil {
stats.LastEvent = relativeTime(*events.LastEventAt, now)
stats.LastEventUTC =
events.LastEventAt.UTC().Format(time.DateTime) + " UTC"
}
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
}