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}/delete` | Delete webhook |
| `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 |
| `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`) |
+19 -15
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"slices"
"strings"
"time"
"unicode/utf8"
@@ -48,18 +49,20 @@ type EventLogView struct {
Body BodyView
// Entrypoint names the entrypoint the event arrived at, or for a
// resubmitted copy, which did not arrive, the one its original
// arrived at: its description, "Entrypoint" when it has none, or
// "deleted entrypoint". Never its URL, which is the entrypoint's
// secret.
// Entrypoint names the entrypoint the event arrived at. A
// resubmitted copy, even a copy of a copy, did not arrive; it
// names the one the request it copies arrived at. The name is
// the entrypoint's description, "Entrypoint" when it has none,
// or "deleted entrypoint", never its URL, which is the
// entrypoint's secret.
Entrypoint string
// Headers is the event's request headers, one "Name: value"
// per value, sorted by name. HeadersCut reports headers left
// out because they hold more than maxRenderedBodyBytes, stored
// or as lines; only the event log leaves them out.
Headers []string
// Headers is the event's request headers as text, one
// "Name: value" line per value, sorted by name. HeadersCut
// reports headers left out because they hold more than
// maxRenderedBodyBytes, stored or as text; only the event log
// leaves them out.
Headers string
HeadersCut bool
// 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
// 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(
webhookID string, maxHeaderBytes int,
) EventLogView {
@@ -120,7 +123,7 @@ func (r *eventLogRow) view(
Body: newBodyView(
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
),
Headers: headers,
Headers: strings.Join(headers, "\n"),
HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)),
ResubmittedFromID: from,
}
@@ -130,8 +133,9 @@ func (r *eventLogRow) view(
// JSON the receiver writes, into one "Name: value" line per value,
// 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
// the lines would hold more than maxBytes: a header sent many times
// is stored with its name once but shown with it on every line.
// the lines, each with the newline that follows it, would hold more
// 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) {
var headers http.Header
@@ -154,7 +158,7 @@ func requestHeaderLines(headersJSON string, maxBytes int) ([]string, bool) {
for _, value := range headers[name] {
line := name + ": " + value
size += len(line)
size += len(line) + len("\n")
if size > maxBytes {
return nil, false
}
+101 -45
View File
@@ -20,16 +20,19 @@ func arrivedAt(name string) string {
return `Arrived at <span class="text-gray-900">` + name + `</span>`
}
// originalArrivedAt is how a page names the entrypoint a resubmitted
// copy's original arrived at.
func originalArrivedAt(name string) string {
return `Its original arrived at <span class="text-gray-900">` +
// copiedRequestArrivedAt is how a page names, for a resubmitted copy,
// the entrypoint the request it copies arrived at.
func copiedRequestArrivedAt(name string) string {
return `The request it copies arrived at <span class="text-gray-900">` +
name + `</span>`
}
// headerLine is how a page shows one request header line.
func headerLine(line string) string {
return `<div class="whitespace-pre-wrap">` + line + `</div>`
// headerBox is how a page shows an event's request header lines: as
// one block of text in a single box.
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
@@ -111,24 +114,12 @@ func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
olderShows := func(t *testing.T, page string) {
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, headerLine(accept))
assert.Contains(t, page, headerLine(userAgent))
assert.Contains(t, page, headerLine(shopEvent))
assert.Less(t,
strings.Index(page, accept), strings.Index(page, userAgent),
"headers are sorted by name",
)
assert.Less(t,
strings.Index(page, userAgent), strings.Index(page, shopEvent),
"headers are sorted by name",
)
assert.Contains(t, page, headerBox(
"Accept: */*",
"User-Agent: shop/1 build\t7",
"X-Shop-Event: order.created",
), "headers are sorted by name")
assert.NotContains(t, page, "order.paid")
assert.NotContains(t, page, billing.Path)
}
@@ -137,8 +128,10 @@ func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
t.Helper()
assert.Contains(t, page, arrivedAt("Entrypoint"))
assert.Contains(t, page, headerLine("X-Shop-Event: order.paid"))
assert.Contains(t, page, headerLine("X-Note: &lt;b&gt;hi&lt;/b&gt;"))
assert.Contains(t, page, headerBox(
"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, "order.created")
assert.NotContains(t, page, unnamed.Path)
@@ -187,39 +180,57 @@ func TestEventRequest_DeletedEntrypoint(t *testing.T) {
assert.NotContains(t, w.Body.String(), "Retired sender")
}
// TestEventRequest_ResubmittedCopy proves a resubmitted copy says its
// original arrived at the entrypoint, in the event log and on its own
// page, and never that the copy did.
// TestEventRequest_ResubmittedCopy proves a resubmitted copy and a copy
// of that copy each say the request they copy arrived at the
// entrypoint, in the event log and on their own pages, and never that
// they did.
func TestEventRequest_ResubmittedCopy(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
ep := f.entrypoint(t, "Billing sender")
original := f.eventAt(t, ep, `{}`, time.Now().Add(-time.Minute))
copied := f.eventAt(t, ep, `{}`, time.Now())
original := f.eventAt(t, ep, `{}`, time.Now().Add(-2*time.Minute))
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(
"resubmitted_from_id", original.ID,
).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
// comes after all of the copy and before the original's
// entrypoint.
copyPart, originalPart, found := strings.Cut(
// The log lists the newest event first, and each event's Resubmit
// form comes before its entrypoint, so cutting the page at the
// copy's and the original's forms leaves each event's entrypoint
// in its own part.
copyOfCopyPart, rest, found := strings.Cut(
renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
"/events/"+original.ID+"/resubmit",
"/events/"+copied.ID+"/resubmit",
)
require.True(t, found)
assert.Contains(t, copyPart, originalArrivedAt("Billing sender"))
assert.NotContains(t, copyPart, arrivedAt("Billing sender"))
assert.Contains(t, originalPart, arrivedAt("Billing sender"))
assert.NotContains(t, originalPart, originalArrivedAt("Billing sender"))
copyPart, originalPart, found := strings.Cut(
rest, "/events/"+original.ID+"/resubmit",
)
require.True(t, found)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, copied.ID)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), originalArrivedAt("Billing sender"))
assert.NotContains(t, w.Body.String(), arrivedAt("Billing sender"))
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)
assert.Contains(t, w.Body.String(),
copiedRequestArrivedAt("Billing sender"))
assert.NotContains(t, w.Body.String(), arrivedAt("Billing sender"))
}
}
// 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)
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")
})
}
}
// 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
// its deliveries, how many times it has been resubmitted and the
// entrypoint it arrived at, and with its request headers only when
// their lines hold at most maxHeaderBytes. Like
// entrypoint it arrived at (for a resubmitted copy, the one the
// 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
// the request with an error.
func (h *Handlers) eventLogViews(
+4 -6
View File
@@ -2,11 +2,11 @@
<!-- The entrypoint an event arrived at and its request headers, as
handlers.EventLogView carries them: the same in the event log and
the event's own page. The entrypoint's URL is never shown. A
resubmitted copy did not arrive at an entrypoint; its original
did. -->
resubmitted copy, even a copy of a copy, did not arrive at an
entrypoint; the request it copies did. -->
<div class="space-y-2 text-xs">
{{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}}
<p class="text-gray-500">Arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p>
{{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>
{{else if .Headers}}
<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">
{{range .Headers}}<div class="whitespace-pre-wrap">{{.}}</div>{{end}}
</div>
<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>
{{else}}
<p class="text-gray-500">No request headers.</p>
{{end}}