Event log: show only the events with a failed or a pending delivery (closes #390)
check / check (push) Waiting to run
check / check (push) Waiting to run
The event log gains three links above the list: All, Failed (N) and Pending (N), carried in a `show` query parameter; any other value shows every event. Failed lists the events with a failed delivery, Pending those with one pending or retrying, each once, under the full list's 50-row limit and order. A filtered list finds the matching deliveries through `idx_deliveries_status`, looks up their events by ID and sorts them, and reads the rows of only the events shown; its counts read the deliveries alone. The line beside the heading says what a filter counts, and Replay returns to the list it was pressed in. The README and the comments naming the query parameters the service reads include `show`. Model: opus-5-5
This commit is contained in:
@@ -1989,7 +1989,7 @@ tags, so `AutoMigrate` creates them on a fresh database:
|
||||
|
||||
| Table | Columns | Serves |
|
||||
| ------------------ | --------------------------- | ------ |
|
||||
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics and target list and the webhook list, which count each target's deliveries by status and when they finished |
|
||||
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, the webhook page's statistics and target list and the webhook list, which count each target's deliveries by status and when they finished, and the event log, which lists and counts the events with a failed delivery or one pending or retrying |
|
||||
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events |
|
||||
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
||||
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
|
||||
@@ -2592,8 +2592,9 @@ The query string is never logged; it is replaced by the fixed marker
|
||||
limiter in front of them, so a query on a fixed 200 URL would otherwise
|
||||
buy the same amplification as an invented path. Nothing debuggable is
|
||||
lost: the only query parameters this service reads are the sign-in page's
|
||||
`next`, the page to return to, and `notice`, which names the line a page
|
||||
shows after an action.
|
||||
`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.
|
||||
|
||||
Client-supplied request content does not leave the host by the other
|
||||
route either. The Sentry SDK attaches the request to every event it
|
||||
@@ -3106,7 +3107,7 @@ returns to the page that was asked for.
|
||||
| `GET` | `/hook/{id}/edit` | Edit webhook form |
|
||||
| `POST` | `/hook/{id}/edit` | Edit webhook submission |
|
||||
| `POST` | `/hook/{id}/delete` | Delete webhook |
|
||||
| `GET` | `/hook/{id}/events` | Full Event Log |
|
||||
| `GET` | `/hook/{id}/events` | Full Event Log. `?show=failed` lists only the events with a failed delivery, and `?show=pending` only those with a delivery pending or retrying |
|
||||
| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one the request it copies arrived at), its request headers, its whole body and every delivery of it |
|
||||
| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's stored body. The pages show a body as text, cut at 32 KiB in the recent events and the event log, and leave a binary one out, so this is the only route that serves the stored bytes; it is offered wherever a body is cut or binary |
|
||||
| `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) |
|
||||
|
||||
@@ -149,6 +149,62 @@ 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 newest events with a delivery in
|
||||
// given statuses, and for their counts (eventsWithStatus and
|
||||
// countEventsWithStatus in the handlers). The lists must also reach
|
||||
// the events table only by ID: from the matching deliveries, then from
|
||||
// the newest of those events.
|
||||
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 (
|
||||
rows []struct{ ID string }
|
||||
count int64
|
||||
)
|
||||
|
||||
matching := dry.Model(&database.Delivery{}).
|
||||
Distinct("event_id").Where("status IN ?", pending)
|
||||
newest := dry.Table("(?) AS matching", matching).
|
||||
Joins("CROSS JOIN events ON events.id = matching.event_id").
|
||||
Where(
|
||||
"events.webhook_id = ? AND events.deleted_at IS NULL",
|
||||
webhookID,
|
||||
).
|
||||
Order("events.created_at DESC").Limit(50).
|
||||
Select("events.id AS event_id")
|
||||
|
||||
// Each step of the plan is printed in braces, so these name the
|
||||
// lookup that follows each scan.
|
||||
byID := "{SEARCH events USING INDEX sqlite_autoindex_events_1 (id=?)}"
|
||||
|
||||
assertPlanUses(t, db, dry.Table("(?) AS newest", newest).
|
||||
Joins("CROSS JOIN events ON events.id = newest.event_id").
|
||||
Select("id").Order("created_at DESC").Limit(50).Find(&rows),
|
||||
byStatus, "{SCAN matching} "+byID, "{SCAN newest} "+byID)
|
||||
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
|
||||
|
||||
@@ -329,15 +329,25 @@ func replayBody(body string) *string {
|
||||
}
|
||||
|
||||
// redirectToEventLog redirects a replay or resubmit back to the event
|
||||
// log it was triggered from, carrying the outcome as its notice.
|
||||
// log it was triggered from, carrying the outcome as its notice. A
|
||||
// Replay form carries the list it was pressed in as show, so a replay
|
||||
// returns to the Failed or Pending list; a Resubmit form carries none,
|
||||
// so a resubmit returns to the full log, where its new event is the
|
||||
// newest.
|
||||
func redirectToEventLog(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
webhook database.Webhook,
|
||||
code noticeCode,
|
||||
) {
|
||||
http.Redirect(
|
||||
w, r, withNotice("/hook/"+webhook.ID+"/events", code),
|
||||
http.StatusSeeOther,
|
||||
location := withNotice("/hook/"+webhook.ID+"/events", code)
|
||||
|
||||
show := r.PostFormValue(showParam)
|
||||
if eventLogStatuses(show) != nil {
|
||||
location += "&" + showParam + "=" + show
|
||||
}
|
||||
|
||||
http.Redirect( //nolint:gosec // show is checked by eventLogStatuses
|
||||
w, r, location, http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -471,6 +472,66 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn proves a
|
||||
// Replay pressed in the Failed list carries that list in its form and
|
||||
// returns to it, and that a show value the event log does not know
|
||||
// returns to the full log.
|
||||
func TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
wh := seedWebhook(t, db)
|
||||
tgt := seedConfiguredTarget(
|
||||
t, db, wh.ID, database.TargetTypeHTTP,
|
||||
`{"url":"`+replayTargetURL+`"}`,
|
||||
)
|
||||
|
||||
_, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
|
||||
|
||||
assert.Contains(t, renderSourceLogsPageWithQuery(
|
||||
t, h, sess, wh.ID, "?show=failed",
|
||||
), `name="show" value="failed"`)
|
||||
|
||||
// The second replay is refused, as the first is still queued.
|
||||
for _, tc := range []struct{ show, location string }{
|
||||
{"failed", "/hook/" + wh.ID +
|
||||
"/events?notice=replay-queued&show=failed"},
|
||||
{"made-up", "/hook/" + wh.ID + "/events?notice=replay-in-flight"},
|
||||
} {
|
||||
req := postRequest(
|
||||
"/hook/"+wh.ID+"/deliveries/"+original.ID+"/replay",
|
||||
authenticatedCookies(
|
||||
t, sess, deleteTestUserID, deleteTestUsername,
|
||||
),
|
||||
map[string]string{
|
||||
paramSourceID: wh.ID,
|
||||
paramDeliveryID: original.ID,
|
||||
},
|
||||
)
|
||||
req.Body = io.NopCloser(strings.NewReader("show=" + tc.show))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleDeliveryReplay().ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, w.Code, tc.show)
|
||||
assert.Equal(t, tc.location, w.Header().Get("Location"), tc.show)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleSourceLogs_RendersReplayControlAndBanner proves the action
|
||||
// reaches the page it belongs on: a finished delivery renders a POST
|
||||
// form carrying a CSRF token, and the outcome code a refusal redirects
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -186,6 +187,152 @@ 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, the
|
||||
// Pending list exactly those with a delivery pending or retrying, each
|
||||
// once, and any other show value every event; that each link, and the
|
||||
// line beside the heading, 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()
|
||||
|
||||
const (
|
||||
failed = database.DeliveryStatusFailed
|
||||
delivered = database.DeliveryStatusDelivered
|
||||
pending = database.DeliveryStatusPending
|
||||
retrying = database.DeliveryStatusRetrying
|
||||
)
|
||||
|
||||
// Each event is named by its content type. The first failed and
|
||||
// was then replayed and delivered. The second failed, and so did
|
||||
// its replay, and the fifth has one delivery pending and another
|
||||
// retrying: each must still be listed and counted once.
|
||||
events := []struct {
|
||||
contentType string
|
||||
deliveries []database.DeliveryStatus
|
||||
}{
|
||||
{"application/x-failed", []database.DeliveryStatus{failed, delivered}},
|
||||
{"application/x-failed-twice", []database.DeliveryStatus{failed, failed}},
|
||||
{"application/x-pending", []database.DeliveryStatus{pending}},
|
||||
{"application/x-retrying", []database.DeliveryStatus{retrying}},
|
||||
{"application/x-pending-retrying", []database.DeliveryStatus{pending, retrying}},
|
||||
{"application/x-delivered", []database.DeliveryStatus{delivered}},
|
||||
{"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
|
||||
heading string
|
||||
listed []string
|
||||
}{
|
||||
{"", "All", "7 total events", all},
|
||||
{"?show=failed", "Failed (2)", "2 events with a failed delivery",
|
||||
[]string{"application/x-failed", "application/x-failed-twice"}},
|
||||
{"?show=pending", "Pending (3)",
|
||||
"3 events with a delivery pending or retrying", []string{
|
||||
"application/x-pending", "application/x-retrying",
|
||||
"application/x-pending-retrying",
|
||||
}},
|
||||
{"?show=unknown", "All", "7 total events", all},
|
||||
} {
|
||||
body := renderSourceLogsPageWithQuery(
|
||||
t, f.h, f.sess, f.webhook.ID, tc.query,
|
||||
)
|
||||
|
||||
// One row per listed event, so with each listed event shown
|
||||
// no event is listed twice.
|
||||
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, ">"+tc.heading+"<", tc.query)
|
||||
assert.Contains(t, body, "Failed (2)", tc.query)
|
||||
assert.Contains(t, body, "Pending (3)", 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 with a failed delivery")
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -1110,6 +1110,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 {
|
||||
@@ -1129,17 +1138,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)
|
||||
@@ -1201,9 +1229,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
|
||||
@@ -1213,6 +1242,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
|
||||
@@ -1227,7 +1257,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,
|
||||
@@ -1321,25 +1358,142 @@ 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, 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, or, given statuses,
|
||||
// the recentEventLimit newest of those with at least one delivery in
|
||||
// one of them.
|
||||
//
|
||||
// Given statuses, its cost follows the matching deliveries. SQLite
|
||||
// never reorders a CROSS JOIN, so it reads each join's left side
|
||||
// first: the distinct event IDs of the matching deliveries, through
|
||||
// idx_deliveries_status; then each of those events by ID, sorted to
|
||||
// keep the newest; then the rows of only the events kept, so no other
|
||||
// event's body is read. With a plain "id IN (matching deliveries)"
|
||||
// condition instead, SQLite, which keeps no statistics on these
|
||||
// tables, walks every event newest first.
|
||||
func eventsWithStatus(
|
||||
webhookDB *gorm.DB,
|
||||
webhookID string,
|
||||
statuses []database.DeliveryStatus,
|
||||
) *gorm.DB {
|
||||
if statuses == nil {
|
||||
return webhookDB.Model(&database.Event{}).Where(
|
||||
"webhook_id = ?", webhookID,
|
||||
)
|
||||
}
|
||||
|
||||
matching := webhookDB.Model(&database.Delivery{}).
|
||||
Distinct("event_id").Where("status IN ?", statuses)
|
||||
|
||||
newest := webhookDB.Table("(?) AS matching", matching).
|
||||
Joins("CROSS JOIN events ON events.id = matching.event_id").
|
||||
Where(
|
||||
"events.webhook_id = ? AND events.deleted_at IS NULL",
|
||||
webhookID,
|
||||
).
|
||||
Order("events.created_at DESC").Limit(recentEventLimit).
|
||||
Select("events.id AS event_id")
|
||||
|
||||
return webhookDB.Table("(?) AS newest", newest).
|
||||
Joins("CROSS JOIN events ON events.id = newest.event_id")
|
||||
}
|
||||
|
||||
// countEventsWithStatus counts the webhook's events with at least one
|
||||
// delivery in one of the statuses, or every event when statuses is
|
||||
// nil. 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -947,15 +947,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
|
||||
@@ -963,12 +963,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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,7 +8,14 @@
|
||||
<a href="/hook/{{.Webhook.ID}}" class="btn-small">← Back to {{.Webhook.Name}}</a>
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>
|
||||
<span class="text-sm text-gray-500">{{if gt .TotalEvents (len .Events)}}{{len .Events}} most recent of {{.TotalEvents}} events{{else}}{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}{{end}}</span>
|
||||
<!-- Under a filter, this counts the events the filter lists. -->
|
||||
<span class="text-sm text-gray-500">{{if gt .TotalEvents (len .Events)}}{{len .Events}} most recent of {{.TotalEvents}} events{{else}}{{.TotalEvents}}{{if not .Show}} total{{end}} event{{if ne .TotalEvents 1}}s{{end}}{{end}}{{if eq .Show "failed"}} with a failed delivery{{else if eq .Show "pending"}} with a delivery pending or retrying{{end}}</span>
|
||||
</div>
|
||||
<!-- Plain links, so they work without the page's script library. The current one is marked. -->
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<a href="/hook/{{.Webhook.ID}}/events" class="btn-small{{if eq .Show ""}} bg-primary-50 border-primary-500{{end}}"{{if eq .Show ""}} aria-current="page"{{end}}>All</a>
|
||||
<a href="/hook/{{.Webhook.ID}}/events?show=failed" title="Events with at least one failed delivery" class="btn-small{{if eq .Show "failed"}} bg-primary-50 border-primary-500{{end}}"{{if eq .Show "failed"}} aria-current="page"{{end}}>Failed ({{.FailedEvents}})</a>
|
||||
<a href="/hook/{{.Webhook.ID}}/events?show=pending" title="Events with a delivery still pending or retrying" class="btn-small{{if eq .Show "pending"}} bg-primary-50 border-primary-500{{end}}"{{if eq .Show "pending"}} aria-current="page"{{end}}>Pending ({{.PendingEvents}})</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,6 +83,8 @@
|
||||
{{if and .Status.Terminal (not .Target.Deleted)}}
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<!-- The list to return to. -->
|
||||
<input type="hidden" name="show" value="{{$.Show}}">
|
||||
<button type="submit" class="btn-small" title="Send this event to the target again">Replay</button>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -92,7 +101,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="p-12 text-center text-sm text-gray-500">No events recorded yet.</div>
|
||||
<div class="p-12 text-center text-sm text-gray-500">{{if eq $.Show "failed"}}No event has a failed delivery.{{else if eq $.Show "pending"}}No event has a delivery pending or retrying.{{else}}No events recorded yet.{{end}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user