Render delivery attempt detail in the event log (closes #202)
All checks were successful
check / check (push) Successful in 3m35s
All checks were successful
check / check (push) Successful in 3m35s
Expanding a delivery on the event log page now shows each recorded attempt: attempt number, outcome, status code, duration, error and response body. Previously a failure rendered as "target: failed" and diagnosing it meant opening the per-webhook SQLite file by hand. The response body is cut by SQLite via substr over a blob cast, the same projection the event body uses, so an oversized stored response never becomes a Go string. The page reports the cut with a marker. Response bodies and errors are remote content, so both go through a new delivery.Redactor that strips the target's own destination URL, path, query and userinfo, plus the values of credential-shaped request headers, before rendering. Target configuration keeps reaching the template only as a TargetView. A body that reaches the cap is treated as cut whether or not SQLite is what cut it. The delivery engine stops reading a response at its own cap, which is the same number of bytes this page renders, and the row it writes records that cut length as the whole length, so nothing in the row separates a response that ended at the cap from one severed there. Such a body goes through RedactCut, which drops any tail that is a proper prefix of a secret: the remote chooses the padding in front of a credential it echoes, so it chooses where the cut falls inside that credential. Its marker says the response reached the recording limit rather than quoting a total the row does not know. Redactors are built from an unscoped target load. Deleting a target only soft deletes the row while its deliveries survive, and a scoped load would leave exactly those deliveries rendering unredacted. The views the page lists stay scoped. Attempt loading is chunked so the IN clause cannot exceed SQLite's bound-parameter limit, and a chunk that fails fails the page rather than rendering the deliveries it covered as never having run. The page renders at most 20 attempts per delivery, counting what it leaves out. static/css/tailwind.css is regenerated with tailwindcss for the utility classes the new markup uses.
This commit is contained in:
@@ -4,11 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/signature"
|
||||
@@ -101,6 +103,32 @@ type DeliveryView struct {
|
||||
ID string
|
||||
Status database.DeliveryStatus
|
||||
Target delivery.TargetView
|
||||
|
||||
// Results is this delivery's attempts in attempt order,
|
||||
// bounded by maxRenderedAttempts. Without them a failure
|
||||
// renders as the status word alone and says nothing about
|
||||
// why.
|
||||
Results []DeliveryResultView
|
||||
|
||||
// AttemptCount is how many attempts were recorded, which
|
||||
// is more than len(Results) once the middle was dropped.
|
||||
AttemptCount int
|
||||
|
||||
// AttemptsOmitted is how many attempts were dropped from
|
||||
// the middle of Results. The page must show it, or the
|
||||
// bound would hide history rather than fold it.
|
||||
AttemptsOmitted int
|
||||
}
|
||||
|
||||
// eventLogTarget is what the event log needs to know about
|
||||
// one target: the display-safe view its template renders, and
|
||||
// the redactor that keeps that target's own credential out of
|
||||
// the text its remote peer chose. The two are kept together
|
||||
// so a caller cannot pick up one without the other, and apart
|
||||
// from TargetView so the secrets never reach a template.
|
||||
type eventLogTarget struct {
|
||||
View delivery.TargetView
|
||||
Redactor delivery.Redactor
|
||||
}
|
||||
|
||||
// HandleSourceList shows a list of user's webhooks.
|
||||
@@ -769,12 +797,24 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
targets := h.loadTargetMap(webhook.ID)
|
||||
targets, err := h.loadTargetMap(webhook.ID)
|
||||
if err != nil {
|
||||
// Without the map every delivery renders through a
|
||||
// zero redactor, so failing the page is the only
|
||||
// safe answer.
|
||||
h.serverError(w, "failed to load targets", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
page := h.parsePage(r)
|
||||
|
||||
evts, total := h.loadEventsWithDeliveries(
|
||||
evts, total, ok := h.loadEventsWithDeliveries(
|
||||
w, webhook, targets, page,
|
||||
)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
totalPages := int(total) / paginationPerPage
|
||||
if int(total)%paginationPerPage != 0 {
|
||||
@@ -807,29 +847,54 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
||||
}
|
||||
|
||||
// loadTargetMap loads targets into a map of display-safe
|
||||
// views keyed by target ID. The projection happens here so
|
||||
// that no caller can hand a raw target, configuration blob
|
||||
// and all, to a template.
|
||||
// views keyed by target ID, each paired with its redactor.
|
||||
// The projection happens here so that no caller can hand a
|
||||
// raw target, configuration blob and all, to a template: the
|
||||
// raw rows do not leave this function.
|
||||
//
|
||||
// The load is Unscoped because deleting a target only soft
|
||||
// deletes the row while its deliveries survive in the
|
||||
// per-webhook database: a scoped load leaves those deliveries
|
||||
// with a zero redactor, which renders their response bodies
|
||||
// unredacted. Only the redactor half of the map is built from
|
||||
// deleted rows. The view half, which is what the page lists,
|
||||
// stays scoped.
|
||||
func (h *Handlers) loadTargetMap(
|
||||
webhookID string,
|
||||
) map[string]delivery.TargetView {
|
||||
) (map[string]eventLogTarget, error) {
|
||||
var targets []database.Target
|
||||
|
||||
h.db.DB().Where(
|
||||
err := h.db.DB().Unscoped().Where(
|
||||
"webhook_id = ?", webhookID,
|
||||
).Find(&targets)
|
||||
|
||||
views := delivery.NewTargetViews(targets)
|
||||
|
||||
targetMap := make(
|
||||
map[string]delivery.TargetView, len(views),
|
||||
)
|
||||
|
||||
for _, v := range views {
|
||||
targetMap[v.ID] = v
|
||||
).Find(&targets).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return targetMap
|
||||
targetMap := make(
|
||||
map[string]eventLogTarget, len(targets),
|
||||
)
|
||||
live := make([]database.Target, 0, len(targets))
|
||||
|
||||
for i := range targets {
|
||||
targetMap[targets[i].ID] = eventLogTarget{
|
||||
Redactor: delivery.NewRedactor(&targets[i]),
|
||||
}
|
||||
|
||||
if !targets[i].DeletedAt.Valid {
|
||||
live = append(live, targets[i])
|
||||
}
|
||||
}
|
||||
|
||||
// The views come from NewTargetViews rather than being
|
||||
// rebuilt here, so the masking rules stay in one place.
|
||||
for _, v := range delivery.NewTargetViews(live) {
|
||||
entry := targetMap[v.ID]
|
||||
entry.View = v
|
||||
targetMap[v.ID] = entry
|
||||
}
|
||||
|
||||
return targetMap, nil
|
||||
}
|
||||
|
||||
// parsePage extracts a page number from the query string.
|
||||
@@ -850,18 +915,22 @@ func (h *Handlers) parsePage(r *http.Request) int {
|
||||
// deliveries from the per-webhook database. 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
|
||||
// caller must then render nothing further.
|
||||
func (h *Handlers) loadEventsWithDeliveries(
|
||||
w http.ResponseWriter,
|
||||
webhook database.Webhook,
|
||||
targetMap map[string]delivery.TargetView,
|
||||
targetMap map[string]eventLogTarget,
|
||||
page int,
|
||||
) ([]EventLogView, int64) {
|
||||
) ([]EventLogView, int64, bool) {
|
||||
var totalEvents int64
|
||||
|
||||
var result []EventLogView
|
||||
|
||||
if !h.dbMgr.DBExists(webhook.ID) {
|
||||
return result, totalEvents
|
||||
return result, totalEvents, true
|
||||
}
|
||||
|
||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||
@@ -870,7 +939,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
||||
w, "failed to get webhook database", err,
|
||||
)
|
||||
|
||||
return nil, 0
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
webhookDB.Model(&database.Event{}).Where(
|
||||
@@ -890,43 +959,170 @@ func (h *Handlers) loadEventsWithDeliveries(
|
||||
).Find(&rows)
|
||||
|
||||
result = make([]EventLogView, len(rows))
|
||||
eventDeliveries := make([][]database.Delivery, len(rows))
|
||||
|
||||
var deliveryIDs []string
|
||||
|
||||
for i := range rows {
|
||||
result[i] = rows[i].view()
|
||||
|
||||
var deliveries []database.Delivery
|
||||
|
||||
webhookDB.Where(
|
||||
"event_id = ?", rows[i].ID,
|
||||
).Find(&deliveries)
|
||||
).Find(&eventDeliveries[i])
|
||||
|
||||
for j := range eventDeliveries[i] {
|
||||
deliveryIDs = append(
|
||||
deliveryIDs, eventDeliveries[i][j].ID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
attempts, err := h.loadDeliveryResults(
|
||||
webhookDB, deliveryIDs,
|
||||
)
|
||||
if err != nil {
|
||||
h.serverError(
|
||||
w, "failed to load delivery attempts", err,
|
||||
)
|
||||
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
for i := range rows {
|
||||
result[i].Deliveries = newDeliveryViews(
|
||||
deliveries, targetMap,
|
||||
eventDeliveries[i], targetMap, attempts,
|
||||
)
|
||||
}
|
||||
|
||||
return result, totalEvents
|
||||
return result, totalEvents, true
|
||||
}
|
||||
|
||||
// deliveryIDChunkSize bounds how many delivery IDs go into one
|
||||
// IN clause. SQLite refuses a statement carrying more than
|
||||
// SQLITE_MAX_VARIABLE_NUMBER (32766) bound parameters, and a
|
||||
// page holds one delivery per target per event, so a webhook
|
||||
// with enough targets would turn the whole query into an error
|
||||
// and the page into zero attempts.
|
||||
const deliveryIDChunkSize = 500
|
||||
|
||||
// loadDeliveryResults loads the recorded attempts for the
|
||||
// page's deliveries, keyed by delivery ID.
|
||||
//
|
||||
// Each response body is cut by SQLite rather than in Go, for
|
||||
// the reason deliveryResultColumns gives. How many attempts a
|
||||
// delivery has is the target's MaxRetries, which the
|
||||
// authenticated operator sets; how many of them reach the page
|
||||
// is bounded again by maxRenderedAttempts.
|
||||
func (h *Handlers) loadDeliveryResults(
|
||||
webhookDB *gorm.DB,
|
||||
deliveryIDs []string,
|
||||
) (map[string][]deliveryResultRow, error) {
|
||||
byDelivery := make(map[string][]deliveryResultRow)
|
||||
|
||||
for chunk := range slices.Chunk(
|
||||
deliveryIDs, deliveryIDChunkSize,
|
||||
) {
|
||||
var rows []deliveryResultRow
|
||||
|
||||
err := webhookDB.Model(
|
||||
&database.DeliveryResult{},
|
||||
).Select(
|
||||
deliveryResultColumns, maxRenderedResponseBytes,
|
||||
).Where(
|
||||
"delivery_id IN ?", chunk,
|
||||
).Order("attempt_num ASC").Find(&rows).Error
|
||||
if err != nil {
|
||||
// Returning what was loaded so far renders the
|
||||
// deliveries in the failed chunk as never having run,
|
||||
// which is indistinguishable from ones that really
|
||||
// never ran. The page fails instead.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range rows {
|
||||
byDelivery[rows[i].DeliveryID] = append(
|
||||
byDelivery[rows[i].DeliveryID], rows[i],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return byDelivery, nil
|
||||
}
|
||||
|
||||
// newDeliveryViews projects deliveries for rendering,
|
||||
// resolving each one's target to its display-safe view.
|
||||
// resolving each one's target to its display-safe view and
|
||||
// each one's attempts through that target's redactor.
|
||||
func newDeliveryViews(
|
||||
deliveries []database.Delivery,
|
||||
targetMap map[string]delivery.TargetView,
|
||||
targetMap map[string]eventLogTarget,
|
||||
attempts map[string][]deliveryResultRow,
|
||||
) []DeliveryView {
|
||||
views := make([]DeliveryView, len(deliveries))
|
||||
|
||||
for i := range deliveries {
|
||||
target := targetMap[deliveries[i].TargetID]
|
||||
rows := attempts[deliveries[i].ID]
|
||||
|
||||
results, omitted := renderedAttempts(
|
||||
rows, target.Redactor,
|
||||
)
|
||||
|
||||
views[i] = DeliveryView{
|
||||
ID: deliveries[i].ID,
|
||||
Status: deliveries[i].Status,
|
||||
Target: targetMap[deliveries[i].TargetID],
|
||||
ID: deliveries[i].ID,
|
||||
Status: deliveries[i].Status,
|
||||
Target: target.View,
|
||||
Results: results,
|
||||
AttemptCount: len(rows),
|
||||
AttemptsOmitted: omitted,
|
||||
}
|
||||
}
|
||||
|
||||
return views
|
||||
}
|
||||
|
||||
// maxRenderedAttempts bounds how many of one delivery's
|
||||
// attempts the page renders. Past it the middle is dropped and
|
||||
// counted, keeping the first attempts and the last ones: how
|
||||
// the delivery started failing and how it ended are what a
|
||||
// reader needs, and the count says plainly that the rest was
|
||||
// dropped rather than never recorded.
|
||||
const (
|
||||
renderedAttemptsHead = 10
|
||||
renderedAttemptsTail = 10
|
||||
maxRenderedAttempts = renderedAttemptsHead +
|
||||
renderedAttemptsTail
|
||||
)
|
||||
|
||||
// renderedAttempts projects a delivery's attempts through the
|
||||
// target's redactor, at most maxRenderedAttempts of them, and
|
||||
// reports how many it dropped.
|
||||
func renderedAttempts(
|
||||
rows []deliveryResultRow,
|
||||
redactor delivery.Redactor,
|
||||
) ([]DeliveryResultView, int) {
|
||||
omitted := 0
|
||||
|
||||
if len(rows) > maxRenderedAttempts {
|
||||
omitted = len(rows) - maxRenderedAttempts
|
||||
|
||||
kept := make(
|
||||
[]deliveryResultRow, 0, maxRenderedAttempts,
|
||||
)
|
||||
kept = append(kept, rows[:renderedAttemptsHead]...)
|
||||
kept = append(
|
||||
kept, rows[len(rows)-renderedAttemptsTail:]...,
|
||||
)
|
||||
rows = kept
|
||||
}
|
||||
|
||||
views := make([]DeliveryResultView, len(rows))
|
||||
for i := range rows {
|
||||
views[i] = rows[i].view(redactor)
|
||||
}
|
||||
|
||||
return views, omitted
|
||||
}
|
||||
|
||||
// HandleEntrypointCreate handles adding a new entrypoint.
|
||||
func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user