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 }