Compare commits

1 Commits
Author SHA1 Message Date
sneak 69587febfc Show an event's entrypoint and request headers (closes #389)
check / check (push) Successful in 3m26s
An expanded event in the event log and the event's own page now show
the entrypoint the event arrived at (its description, "Entrypoint"
when it has none, or "deleted entrypoint"; never its URL), or for a
resubmitted copy the one the request it copies arrived at, and the
request headers as one block of text, one line per value, sorted by
name, whitespace kept. The event log leaves out headers that hold
more than the body's 32 KiB limit, stored or as text, and links to the
event's page, which shows them all. Both pages draw them through one
shared template, event_request.html, and read the webhook's
entrypoints once per page.

Model: opus-5-5
2026-10-03 02:59:46 +00:00
5 changed files with 128 additions and 69 deletions
+1 -1
View File
@@ -3104,7 +3104,7 @@ returns to the page that was asked for.
| `POST` | `/hook/{id}/edit` | Edit webhook submission | | `POST` | `/hook/{id}/edit` | Edit webhook submission |
| `POST` | `/hook/{id}/delete` | Delete webhook | | `POST` | `/hook/{id}/delete` | Delete webhook |
| `GET` | `/hook/{id}/events` | Full Event Log | | `GET` | `/hook/{id}/events` | Full Event Log |
| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one its original arrived at), its request headers, its whole body and every delivery of it | | `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 | | `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`) | | `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`) |
| `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) | | `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) |
+19 -15
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"slices" "slices"
"strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
@@ -48,18 +49,20 @@ type EventLogView struct {
Body BodyView Body BodyView
// Entrypoint names the entrypoint the event arrived at, or for a // Entrypoint names the entrypoint the event arrived at. A
// resubmitted copy, which did not arrive, the one its original // resubmitted copy, even a copy of a copy, did not arrive; it
// arrived at: its description, "Entrypoint" when it has none, or // names the one the request it copies arrived at. The name is
// "deleted entrypoint". Never its URL, which is the entrypoint's // the entrypoint's description, "Entrypoint" when it has none,
// secret. // or "deleted entrypoint", never its URL, which is the
// entrypoint's secret.
Entrypoint string Entrypoint string
// Headers is the event's request headers, one "Name: value" // Headers is the event's request headers as text, one
// per value, sorted by name. HeadersCut reports headers left // "Name: value" line per value, sorted by name. HeadersCut
// out because they hold more than maxRenderedBodyBytes, stored // reports headers left out because they hold more than
// or as lines; only the event log leaves them out. // maxRenderedBodyBytes, stored or as text; only the event log
Headers []string // leaves them out.
Headers string
HeadersCut bool HeadersCut bool
// ResubmittedFromID names the event this one was copied // ResubmittedFromID names the event this one was copied
@@ -100,7 +103,7 @@ type eventLogRow struct {
// view projects a loaded row of the webhook's events for // view projects a loaded row of the webhook's events for
// rendering. It shows the request headers when the row holds them // rendering. It shows the request headers when the row holds them
// whole and their lines hold at most maxHeaderBytes. // whole and their text holds at most maxHeaderBytes.
func (r *eventLogRow) view( func (r *eventLogRow) view(
webhookID string, maxHeaderBytes int, webhookID string, maxHeaderBytes int,
) EventLogView { ) EventLogView {
@@ -120,7 +123,7 @@ func (r *eventLogRow) view(
Body: newBodyView( Body: newBodyView(
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes, "/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
), ),
Headers: headers, Headers: strings.Join(headers, "\n"),
HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)), HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)),
ResubmittedFromID: from, ResubmittedFromID: from,
} }
@@ -130,8 +133,9 @@ func (r *eventLogRow) view(
// JSON the receiver writes, into one "Name: value" line per value, // JSON the receiver writes, into one "Name: value" line per value,
// sorted by name. Headers that do not parse, as when the event log // sorted by name. Headers that do not parse, as when the event log
// has cut them, show as none. It reports false, with no lines, when // has cut them, show as none. It reports false, with no lines, when
// the lines would hold more than maxBytes: a header sent many times // the lines, each with the newline that follows it, would hold more
// is stored with its name once but shown with it on every line. // than maxBytes: a header sent many times is stored with its name
// once but shown with it on every line.
func requestHeaderLines(headersJSON string, maxBytes int) ([]string, bool) { func requestHeaderLines(headersJSON string, maxBytes int) ([]string, bool) {
var headers http.Header var headers http.Header
@@ -154,7 +158,7 @@ func requestHeaderLines(headersJSON string, maxBytes int) ([]string, bool) {
for _, value := range headers[name] { for _, value := range headers[name] {
line := name + ": " + value line := name + ": " + value
size += len(line) size += len(line) + len("\n")
if size > maxBytes { if size > maxBytes {
return nil, false return nil, false
} }
+99 -43
View File
@@ -20,16 +20,19 @@ func arrivedAt(name string) string {
return `Arrived at <span class="text-gray-900">` + name + `</span>` return `Arrived at <span class="text-gray-900">` + name + `</span>`
} }
// originalArrivedAt is how a page names the entrypoint a resubmitted // copiedRequestArrivedAt is how a page names, for a resubmitted copy,
// copy's original arrived at. // the entrypoint the request it copies arrived at.
func originalArrivedAt(name string) string { func copiedRequestArrivedAt(name string) string {
return `Its original arrived at <span class="text-gray-900">` + return `The request it copies arrived at <span class="text-gray-900">` +
name + `</span>` name + `</span>`
} }
// headerLine is how a page shows one request header line. // headerBox is how a page shows an event's request header lines: as
func headerLine(line string) string { // one block of text in a single box.
return `<div class="whitespace-pre-wrap">` + line + `</div>` func headerBox(lines ...string) string {
return `<pre class="rounded-md border border-gray-200 bg-white p-2 ` +
`text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap ` +
`break-all">` + strings.Join(lines, "\n") + `</pre>`
} }
// showHeadersLink is the event log's link to an event's own page for // showHeadersLink is the event log's link to an event's own page for
@@ -111,24 +114,12 @@ func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
olderShows := func(t *testing.T, page string) { olderShows := func(t *testing.T, page string) {
t.Helper() t.Helper()
const (
accept = "Accept: */*"
userAgent = "User-Agent: shop/1 build\t7"
shopEvent = "X-Shop-Event: order.created"
)
assert.Contains(t, page, arrivedAt("Billing sender")) assert.Contains(t, page, arrivedAt("Billing sender"))
assert.Contains(t, page, headerLine(accept)) assert.Contains(t, page, headerBox(
assert.Contains(t, page, headerLine(userAgent)) "Accept: */*",
assert.Contains(t, page, headerLine(shopEvent)) "User-Agent: shop/1 build\t7",
assert.Less(t, "X-Shop-Event: order.created",
strings.Index(page, accept), strings.Index(page, userAgent), ), "headers are sorted by name")
"headers are sorted by name",
)
assert.Less(t,
strings.Index(page, userAgent), strings.Index(page, shopEvent),
"headers are sorted by name",
)
assert.NotContains(t, page, "order.paid") assert.NotContains(t, page, "order.paid")
assert.NotContains(t, page, billing.Path) assert.NotContains(t, page, billing.Path)
} }
@@ -137,8 +128,10 @@ func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
t.Helper() t.Helper()
assert.Contains(t, page, arrivedAt("Entrypoint")) assert.Contains(t, page, arrivedAt("Entrypoint"))
assert.Contains(t, page, headerLine("X-Shop-Event: order.paid")) assert.Contains(t, page, headerBox(
assert.Contains(t, page, headerLine("X-Note: &lt;b&gt;hi&lt;/b&gt;")) "X-Note: &lt;b&gt;hi&lt;/b&gt;",
"X-Shop-Event: order.paid",
))
assert.NotContains(t, page, "<b>hi</b>") assert.NotContains(t, page, "<b>hi</b>")
assert.NotContains(t, page, "order.created") assert.NotContains(t, page, "order.created")
assert.NotContains(t, page, unnamed.Path) assert.NotContains(t, page, unnamed.Path)
@@ -187,39 +180,57 @@ func TestEventRequest_DeletedEntrypoint(t *testing.T) {
assert.NotContains(t, w.Body.String(), "Retired sender") assert.NotContains(t, w.Body.String(), "Retired sender")
} }
// TestEventRequest_ResubmittedCopy proves a resubmitted copy says its // TestEventRequest_ResubmittedCopy proves a resubmitted copy and a copy
// original arrived at the entrypoint, in the event log and on its own // of that copy each say the request they copy arrived at the
// page, and never that the copy did. // entrypoint, in the event log and on their own pages, and never that
// they did.
func TestEventRequest_ResubmittedCopy(t *testing.T) { func TestEventRequest_ResubmittedCopy(t *testing.T) {
t.Parallel() t.Parallel()
f := newRecentEventsFixture(t) f := newRecentEventsFixture(t)
ep := f.entrypoint(t, "Billing sender") ep := f.entrypoint(t, "Billing sender")
original := f.eventAt(t, ep, `{}`, time.Now().Add(-time.Minute)) original := f.eventAt(t, ep, `{}`, time.Now().Add(-2*time.Minute))
copied := f.eventAt(t, ep, `{}`, time.Now()) copied := f.eventAt(t, ep, `{}`, time.Now().Add(-time.Minute))
copyOfCopy := f.eventAt(t, ep, `{}`, time.Now())
require.NoError(t, f.webhookDB.Model(copied).Update( require.NoError(t, f.webhookDB.Model(copied).Update(
"resubmitted_from_id", original.ID, "resubmitted_from_id", original.ID,
).Error) ).Error)
require.NoError(t, f.webhookDB.Model(copyOfCopy).Update(
"resubmitted_from_id", copied.ID,
).Error)
// The log lists the copy first, and the original's Resubmit form // The log lists the newest event first, and each event's Resubmit
// comes after all of the copy and before the original's // form comes before its entrypoint, so cutting the page at the
// entrypoint. // copy's and the original's forms leaves each event's entrypoint
copyPart, originalPart, found := strings.Cut( // in its own part.
copyOfCopyPart, rest, found := strings.Cut(
renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID), renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
"/events/"+original.ID+"/resubmit", "/events/"+copied.ID+"/resubmit",
) )
require.True(t, found) require.True(t, found)
assert.Contains(t, copyPart, originalArrivedAt("Billing sender")) copyPart, originalPart, found := strings.Cut(
assert.NotContains(t, copyPart, arrivedAt("Billing sender")) rest, "/events/"+original.ID+"/resubmit",
assert.Contains(t, originalPart, arrivedAt("Billing sender")) )
assert.NotContains(t, originalPart, originalArrivedAt("Billing sender")) require.True(t, found)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, copied.ID) for _, part := range []string{copyOfCopyPart, copyPart} {
assert.Contains(t, part, copiedRequestArrivedAt("Billing sender"))
assert.NotContains(t, part, arrivedAt("Billing sender"))
}
assert.Contains(t, originalPart, arrivedAt("Billing sender"))
assert.NotContains(t, originalPart,
copiedRequestArrivedAt("Billing sender"))
for _, event := range []*database.Event{copied, copyOfCopy} {
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), originalArrivedAt("Billing sender")) assert.Contains(t, w.Body.String(),
copiedRequestArrivedAt("Billing sender"))
assert.NotContains(t, w.Body.String(), arrivedAt("Billing sender")) assert.NotContains(t, w.Body.String(), arrivedAt("Billing sender"))
}
} }
// TestEventRequest_HeadersOverTheLimit proves the event log leaves out // TestEventRequest_HeadersOverTheLimit proves the event log leaves out
@@ -269,8 +280,53 @@ func TestEventRequest_HeadersOverTheLimit(t *testing.T) {
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID) w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), headerLine(tc.line)) assert.Contains(t, w.Body.String(), tc.line)
assert.NotContains(t, w.Body.String(), "Show the request headers") assert.NotContains(t, w.Body.String(), "Show the request headers")
}) })
} }
} }
// TestEventRequest_ManyShortHeaderLines proves that for many short
// request header lines the event log writes no more than its limit,
// apart from escaping: lines that fill the limit show as one block of
// text, and one line more is left out with a link to the event's own
// page.
func TestEventRequest_ManyShortHeaderLines(t *testing.T) {
t.Parallel()
// Each "A: " line and the newline after it hold four bytes, so
// this many lines fill the limit exactly. Each line in its own
// element would make the page many times the limit.
const fill = bodyCap / len("A: \n")
tests := map[string]struct {
lines int
shown bool
}{
"filling the limit": {lines: fill, shown: true},
"one over the limit": {lines: fill + 1, shown: false},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
headersJSON, err := json.Marshal(http.Header{
"A": slices.Repeat([]string{""}, tc.lines),
})
require.NoError(t, err)
f := newRecentEventsFixture(t)
ep := f.entrypoint(t, "Billing sender")
event := f.eventAt(t, ep, string(headersJSON), time.Now())
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
box := headerBox(slices.Repeat([]string{"A: "}, tc.lines)...)
link := showHeadersLink(f.webhook.ID, event.ID)
assert.Equal(t, tc.shown, strings.Contains(page, box))
assert.Equal(t, !tc.shown, strings.Contains(page, link))
assert.Less(t, len(page), 4*bodyCap)
})
}
}
+3 -2
View File
@@ -1239,8 +1239,9 @@ func (h *Handlers) loadEventsWithDeliveries(
// eventLogViews projects loaded events for rendering, each with // eventLogViews projects loaded events for rendering, each with
// its deliveries, how many times it has been resubmitted and the // its deliveries, how many times it has been resubmitted and the
// entrypoint it arrived at, and with its request headers only when // entrypoint it arrived at (for a resubmitted copy, the one the
// their lines hold at most maxHeaderBytes. Like // request it copies arrived at), and with its request headers only
// when their text holds at most maxHeaderBytes. Like
// loadEventsWithDeliveries, it reports false once it has answered // loadEventsWithDeliveries, it reports false once it has answered
// the request with an error. // the request with an error.
func (h *Handlers) eventLogViews( func (h *Handlers) eventLogViews(
+4 -6
View File
@@ -2,11 +2,11 @@
<!-- The entrypoint an event arrived at and its request headers, as <!-- The entrypoint an event arrived at and its request headers, as
handlers.EventLogView carries them: the same in the event log and handlers.EventLogView carries them: the same in the event log and
the event's own page. The entrypoint's URL is never shown. A the event's own page. The entrypoint's URL is never shown. A
resubmitted copy did not arrive at an entrypoint; its original resubmitted copy, even a copy of a copy, did not arrive at an
did. --> entrypoint; the request it copies did. -->
<div class="space-y-2 text-xs"> <div class="space-y-2 text-xs">
{{if .ResubmittedFrom}} {{if .ResubmittedFrom}}
<p class="text-gray-500">Its original arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p> <p class="text-gray-500">The request it copies arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p>
{{else}} {{else}}
<p class="text-gray-500">Arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p> <p class="text-gray-500">Arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p>
{{end}} {{end}}
@@ -14,9 +14,7 @@
<p class="text-gray-500">The request headers are larger than the event log shows. <a href="{{.Body.EventURL}}" class="btn-small">Show the request headers</a></p> <p class="text-gray-500">The request headers are larger than the event log shows. <a href="{{.Body.EventURL}}" class="btn-small">Show the request headers</a></p>
{{else if .Headers}} {{else if .Headers}}
<p class="text-gray-500">Request headers</p> <p class="text-gray-500">Request headers</p>
<div class="rounded-md border border-gray-200 bg-white p-2 font-mono text-gray-700 break-all"> <pre class="rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Headers}}</pre>
{{range .Headers}}<div class="whitespace-pre-wrap">{{.}}</div>{{end}}
</div>
{{else}} {{else}}
<p class="text-gray-500">No request headers.</p> <p class="text-gray-500">No request headers.</p>
{{end}} {{end}}