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. const ( tenMinutes = 10 * time.Minute twentyFourHours = 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 // Totals holds the lifetime counts of events, deliveries and // failures, and how many of each retention has removed. Totals database.Totals // 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 } // 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 } // 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, 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 := db.Take(&stats.Totals).Error if err != nil { return fmt.Errorf("reading running totals: %w", 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(-tenMinutes), ) if err != nil { return err } stats.Last24Hours, err = readRecentWindow( db, now.Add(-twentyFourHours), ) return err } // 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) } w.Delivered, err = countFinishedSince( db, database.DeliveryStatusDelivered, since, ) if err != nil { return w, err } w.Failed, err = countFinishedSince( db, database.DeliveryStatusFailed, since, ) return w, err } // countFinishedSince counts the deliveries that reached the given // final status since the given time. func countFinishedSince( db *gorm.DB, status database.DeliveryStatus, since time.Time, ) (int64, error) { var n int64 err := db.Model(&database.Delivery{}). Where("status = ? AND finished_at >= ?", status, since). Count(&n).Error if err != nil { return 0, fmt.Errorf( "counting deliveries %s recently: %w", status, err, ) } return n, nil }