Event log: show only the events with a failed or a pending delivery (closes #390)
check / check (push) Successful in 3m14s

The event log gains three links above the list: All, Failed (N) and Pending (N), carried in a `show` query parameter (`failed`, `pending`); any other value shows every event. Failed lists the events with at least one failed delivery, Pending those with one pending or retrying, under the same 50-row limit and newest-first order as the full list. Events are picked by their deliveries' status through `idx_deliveries_status`; each count is the number of distinct matching events, read from the deliveries alone, and can exceed the 50 shown. An empty filtered list says that no event matches. The README and the comments that name the query parameters the service reads now include `show`.

Model: opus-5-5
This commit is contained in:
2026-10-03 02:08:35 +00:00
parent ea8384f4a2
commit ae2ce55575
11 changed files with 363 additions and 39 deletions
@@ -149,6 +149,46 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)")
}
// TestEventLogFiltersUseTheStatusIndex does the same for the event log's
// Failed and Pending lists, of the events with a delivery in given
// statuses, and for their counts (eventsWithStatus and
// countEventsWithStatus in the handlers).
func TestEventLogFiltersUseTheStatusIndex(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhookID := uuid.New().String()
db, err := mgr.GetDB(webhookID)
require.NoError(t, err)
dry := db.Session(&gorm.Session{DryRun: true})
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
pending := []database.DeliveryStatus{
database.DeliveryStatusPending,
database.DeliveryStatusRetrying,
}
var (
events []database.Event
count int64
)
assertPlanUses(t, db, dry.Model(&database.Event{}).
Where("webhook_id = ?", webhookID).
Where("id IN (?)", dry.Model(&database.Delivery{}).
Select("event_id").Where("status IN ?", pending)).
Order("created_at DESC").Limit(50).Find(&events), byStatus)
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
Distinct("event_id").Where("status IN ?", pending).Count(&count),
byStatus)
}
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook
// page's statistics (readEventStats in the handlers): deliveries in
// progress, each target's deliveries finished since a time, which must
+1 -1
View File
@@ -75,7 +75,7 @@ func (s *Handlers) LoadEventLogViewsForTest(
webhook database.Webhook,
) []EventLogView {
views, _, _ := s.loadEventsWithDeliveries(
w, newRequestForTest(), webhook, nil,
w, newRequestForTest(), webhook, nil, nil,
)
return views
+136
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
@@ -186,6 +187,141 @@ func TestHandleSourceLogs_ShowsFiftyNewestEvents(t *testing.T) {
assert.Contains(t, body, "50 most recent of 51 events")
}
// TestHandleSourceLogs_ShowsEventsByDeliveryStatus proves that the
// Failed list holds exactly the events with a failed delivery, once
// each, the Pending list exactly those with a delivery pending or
// retrying, and any other show value every event; that each link
// counts the events its list holds; and that the shown link is marked.
func TestHandleSourceLogs_ShowsEventsByDeliveryStatus(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
now := time.Now()
// Each event is named by its content type. The failed one was
// then replayed and delivered, so it has two deliveries.
events := []struct {
contentType string
deliveries []database.DeliveryStatus
}{
{"application/x-failed", []database.DeliveryStatus{
database.DeliveryStatusFailed,
database.DeliveryStatusDelivered,
}},
{"application/x-pending", []database.DeliveryStatus{
database.DeliveryStatusPending,
}},
{"application/x-retrying", []database.DeliveryStatus{
database.DeliveryStatusRetrying,
}},
{"application/x-delivered", []database.DeliveryStatus{
database.DeliveryStatusDelivered,
}},
{"application/x-no-delivery", nil},
}
all := make([]string, len(events))
for i, e := range events {
event := f.event(
t, e.contentType, "{}", now.Add(time.Duration(i)*time.Second),
)
for _, status := range e.deliveries {
f.delivery(t, event, target.ID, status)
}
all[i] = e.contentType
}
for _, tc := range []struct {
query string
current string
listed []string
}{
{"", "All", all},
{"?show=failed", "Failed (1)", []string{"application/x-failed"}},
{"?show=pending", "Pending (2)", []string{
"application/x-pending", "application/x-retrying",
}},
{"?show=unknown", "All", all},
} {
body := renderSourceLogsPageWithQuery(
t, f.h, f.sess, f.webhook.ID, tc.query,
)
assert.Equal(t, len(tc.listed),
strings.Count(body, `role="button"`), tc.query)
for _, contentType := range all {
assert.Equal(t,
slices.Contains(tc.listed, contentType),
strings.Contains(body, contentType),
tc.query+" "+contentType)
}
assert.Contains(t, body, "Failed (1)", tc.query)
assert.Contains(t, body, "Pending (2)", tc.query)
assert.Equal(t, 1, strings.Count(body, "aria-current"), tc.query)
assert.Contains(t, body,
`aria-current="page">`+tc.current+"</a>", tc.query)
}
}
// TestHandleSourceLogs_FilteredListShowsFiftyNewest proves a filtered
// list holds the 50 newest matching events, as the full log does,
// while its link and heading count every matching event.
func TestHandleSourceLogs_FilteredListShowsFiftyNewest(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
base := time.Now().Add(-time.Hour)
for i := range 51 {
event := f.event(
t, fmt.Sprintf("application/x-failed-%02d", i), "{}",
base.Add(time.Duration(i)*time.Second),
)
f.delivery(t, event, target.ID, database.DeliveryStatusFailed)
}
// The newest event has no failed delivery.
f.event(t, "application/x-no-delivery", "{}", time.Now())
body := renderSourceLogsPageWithQuery(
t, f.h, f.sess, f.webhook.ID, "?show=failed",
)
assert.Equal(t, 50, strings.Count(body, `role="button"`))
assert.NotContains(t, body, "application/x-failed-00")
assert.NotContains(t, body, "application/x-no-delivery")
assert.Contains(t, body, "Failed (51)")
assert.Contains(t, body, "50 most recent of 51 events")
}
// TestHandleSourceLogs_EmptyFilteredList proves an empty filtered list
// says that no event matches rather than that none was recorded.
func TestHandleSourceLogs_EmptyFilteredList(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
f.delivery(
t, f.event(t, contentTypeJSON, "{}", time.Now()),
target.ID, database.DeliveryStatusDelivered,
)
assert.Contains(t, renderSourceLogsPageWithQuery(
t, f.h, f.sess, f.webhook.ID, "?show=failed",
), "No event has a failed delivery.")
assert.Contains(t, renderSourceLogsPageWithQuery(
t, f.h, f.sess, f.webhook.ID, "?show=pending",
), "No event has a delivery pending or retrying.")
}
// TestHandleSourceLogs_OnlyNewestStartsExpanded proves that of the
// events in the log only the newest starts expanded.
func TestHandleSourceLogs_OnlyNewestStartsExpanded(t *testing.T) {
+160 -21
View File
@@ -1094,6 +1094,15 @@ func (h *Handlers) ownedWebhook(
return webhook, true
}
// The event log's show query parameter and its two values: the events
// with a failed delivery, and those with a delivery still pending or
// retrying.
const (
showParam = "show"
showFailed = "failed"
showPending = "pending"
)
// HandleSourceLogs shows the request/response logs for a
// webhook.
func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
@@ -1113,17 +1122,36 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
return
}
// Any other value of show lists every event, as no value
// does.
show := r.URL.Query().Get(showParam)
statuses := eventLogStatuses(show)
if statuses == nil {
show = ""
}
evts, total, ok := h.loadEventsWithDeliveries(
w, r, webhook, targets,
w, r, webhook, targets, statuses,
)
if !ok {
return
}
failed, pending, err := h.countFailedAndPendingEvents(webhook.ID)
if err != nil {
h.serverError(w, r, "failed to count events", err)
return
}
data := map[string]any{
tmplKeyWebhook: &webhook,
"Events": evts,
"TotalEvents": total,
tmplKeyWebhook: &webhook,
"Events": evts,
"TotalEvents": total,
"Show": show,
"FailedEvents": failed,
"PendingEvents": pending,
}
h.renderTemplate(w, r, "source_logs.html", data)
@@ -1185,9 +1213,10 @@ func (h *Handlers) loadTargetMap(
// loadEventsWithDeliveries loads the recentEventLimit newest events
// and their deliveries from the per-webhook database, and the total
// number of events stored. Events come back as capped projections
// rather than database.Event rows: see eventLogColumns for why the
// cut happens in SQL.
// number of events stored. Given delivery statuses, both cover only
// the events with a delivery in one of them. Events come back as
// capped projections rather than database.Event rows: see
// eventLogColumns for why the cut happens in SQL.
//
// The bool reports whether the load succeeded. It is false
// once this has answered the request with an error, and the
@@ -1197,6 +1226,7 @@ func (h *Handlers) loadEventsWithDeliveries(
r *http.Request,
webhook database.Webhook,
targetMap map[string]eventLogTarget,
statuses []database.DeliveryStatus,
) ([]EventLogView, int64, bool) {
if !h.dbMgr.DBExists(webhook.ID) {
return nil, 0, true
@@ -1211,7 +1241,14 @@ func (h *Handlers) loadEventsWithDeliveries(
return nil, 0, false
}
rows, totalEvents := loadEventLogRows(webhookDB, webhook.ID)
rows, totalEvents, err := loadEventLogRows(
webhookDB, webhook.ID, statuses,
)
if err != nil {
h.serverError(w, r, "failed to load events", err)
return nil, 0, false
}
result, ok := h.eventLogViews(
w, r, webhookDB, webhook.ID, rows, targetMap,
@@ -1286,25 +1323,127 @@ func (h *Handlers) eventLogViews(
// loadEventLogRows reads the event log projection of the
// recentEventLimit newest events, newest first, and the total number
// of events stored.
// of events stored, both narrowed by statuses as eventsWithStatus
// narrows them.
func loadEventLogRows(
webhookDB *gorm.DB, webhookID string,
) ([]eventLogRow, int64) {
var totalEvents int64
webhookDB.Model(&database.Event{}).Where(
"webhook_id = ?", webhookID,
).Count(&totalEvents)
webhookDB *gorm.DB,
webhookID string,
statuses []database.DeliveryStatus,
) ([]eventLogRow, int64, error) {
totalEvents, err := countEventsWithStatus(
webhookDB, webhookID, statuses,
)
if err != nil {
return nil, 0, err
}
var rows []eventLogRow
webhookDB.Model(&database.Event{}).Select(
err = eventsWithStatus(webhookDB, webhookID, statuses).Select(
eventLogColumns, maxRenderedBodyBytes,
).Where(
"webhook_id = ?", webhookID,
).Order("created_at DESC").Limit(recentEventLimit).Find(&rows)
).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error
return rows, totalEvents
return rows, totalEvents, err
}
// eventLogStatuses returns the delivery statuses the event log's show
// value lists events by, or nil for one that lists every event.
func eventLogStatuses(show string) []database.DeliveryStatus {
switch show {
case showFailed:
return []database.DeliveryStatus{database.DeliveryStatusFailed}
case showPending:
return []database.DeliveryStatus{
database.DeliveryStatusPending,
database.DeliveryStatusRetrying,
}
default:
return nil
}
}
// eventsWithStatus selects the webhook's events that have at least one
// delivery in one of the statuses, or every event when statuses is nil.
// The deliveries are found by status through idx_deliveries_status, so
// no event body is read to decide.
func eventsWithStatus(
webhookDB *gorm.DB,
webhookID string,
statuses []database.DeliveryStatus,
) *gorm.DB {
events := webhookDB.Model(&database.Event{}).Where(
"webhook_id = ?", webhookID,
)
if statuses == nil {
return events
}
return events.Where(
"id IN (?)",
webhookDB.Model(&database.Delivery{}).Select("event_id").Where(
"status IN ?", statuses,
),
)
}
// countEventsWithStatus counts the events eventsWithStatus selects.
// Given statuses, it counts the distinct events of the matching
// deliveries and reads nothing but those deliveries, through
// idx_deliveries_status, where counting the events would read every
// event row. That is the same number, because retention deletes an
// event's deliveries with it.
func countEventsWithStatus(
webhookDB *gorm.DB,
webhookID string,
statuses []database.DeliveryStatus,
) (int64, error) {
var count int64
if statuses == nil {
err := webhookDB.Model(&database.Event{}).Where(
"webhook_id = ?", webhookID,
).Count(&count).Error
return count, err
}
err := webhookDB.Model(&database.Delivery{}).Distinct("event_id").
Where("status IN ?", statuses).Count(&count).Error
return count, err
}
// countFailedAndPendingEvents returns how many of the webhook's events
// the event log lists when it shows only those with a failed delivery,
// and when it shows only those with a delivery pending or retrying.
func (h *Handlers) countFailedAndPendingEvents(
webhookID string,
) (int64, int64, error) {
if !h.dbMgr.DBExists(webhookID) {
return 0, 0, nil
}
webhookDB, err := h.dbMgr.GetDB(webhookID)
if err != nil {
return 0, 0, err
}
failed, err := countEventsWithStatus(
webhookDB, webhookID, eventLogStatuses(showFailed),
)
if err != nil {
return 0, 0, err
}
pending, err := countEventsWithStatus(
webhookDB, webhookID, eventLogStatuses(showPending),
)
if err != nil {
return 0, 0, err
}
return failed, pending, nil
}
// resubmitCounts reports, for each of the page's events, how many
+2 -1
View File
@@ -91,7 +91,8 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
assert.Contains(t, log, "t-http: delivered")
assert.Regexp(t, waiting+cooldownEnds, log)
assert.Contains(t, log, waiting+backoffEnds)
assert.NotContains(t, log, "retrying")
// No delivery shows as retrying; the Pending link's title says it.
assert.NotRegexp(t, `t-http: retrying|>retrying<`, log)
page := eventPage(t, h, sess, wh.ID, retrying.ID)
assert.Regexp(t, waiting+cooldownEnds, page)
+3 -2
View File
@@ -279,8 +279,9 @@ func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
// this branch as bounded as the pattern branches below.
//
// Nothing debuggable is lost. The only query parameters the service
// reads are the login page's `next`, the page to return to, and
// `notice`, which names the line a page shows after an action. The
// reads are the login page's `next`, the page to return to,
// `notice`, which names the line a page shows after an action, and
// the event log's `show`, which picks the events it lists. The
// alternatives that would preserve more (a key count, a key
// allowlist) all require parsing an attacker-sized query on every
// request, which is work an unauthenticated client would then be
+6 -6
View File
@@ -929,15 +929,15 @@ func checkEventSelection(
}
// checkEventKeyboard loads the event log and checks that Tab from the
// page's Back link reaches the row of the newest event, the first after
// it, and that Enter then collapses that event, which starts expanded,
// and Space expands it again.
// page's Pending link, the last link above the list, reaches the row of
// the newest event, the first after it, and that Enter then collapses
// that event, which starts expanded, and Space expands it again.
func checkEventKeyboard(
ctx context.Context, t *testing.T, url, eventID string,
) {
t.Helper()
back := `//a[contains(text(), "Back to")]`
pending := `//a[starts-with(text(), "Pending")]`
expanded := `form[action$="/` + eventID + `/resubmit"]`
var focused string
@@ -945,12 +945,12 @@ func checkEventKeyboard(
require.NoError(t, chromedp.Run(
ctx,
loadPage(url),
chromedp.Focus(back, chromedp.BySearch),
chromedp.Focus(pending, chromedp.BySearch),
chromedp.KeyEvent(kb.Tab),
chromedp.Evaluate(`document.activeElement.textContent`, &focused),
))
require.Contains(t, focused, eventID,
"Tab from the Back link does not reach the event's row")
"Tab from the Pending link does not reach the event's row")
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter)))
assert.True(t, hidden(ctx, expanded), "Enter does not collapse the event")
+2 -2
View File
@@ -219,8 +219,8 @@ func keptSentryHeaders(headers map[string]string) map[string]string {
// sentryKeepsHeader reports whether a request header is routing or
// content metadata rather than client-chosen payload. Referer is kept
// on the reasoning that it is browser-set, that the only query
// parameters in this service's own URLs are the login page's `next`
// and `notice`, and that Referrer-Policy is set to
// parameters in this service's own URLs are the login page's `next`,
// `notice` and the event log's `show`, and that Referrer-Policy is set to
// strict-origin-when-cross-origin. X-Request-Id ties the event to the
// local access log line, which holds the rest of the detail.
func sentryKeepsHeader(name string) bool {