Compare commits

1 Commits
Author SHA1 Message Date
sneak ce13e42eda Show an event's entrypoint and request headers (closes #389)
check / check (push) Successful in 3m13s
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 its original arrived at, and the request
headers, one per line, sorted by name, whitespace kept. The event log
leaves out headers that hold more than the body's 32 KiB limit, stored
or as lines, 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:29:51 +00:00
5 changed files with 68 additions and 127 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 the request it copies 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 its original 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`) |
+15 -19
View File
@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"slices" "slices"
"strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
@@ -49,20 +48,18 @@ type EventLogView struct {
Body BodyView Body BodyView
// Entrypoint names the entrypoint the event arrived at. A // Entrypoint names the entrypoint the event arrived at, or for a
// resubmitted copy, even a copy of a copy, did not arrive; it // resubmitted copy, which did not arrive, the one its original
// names the one the request it copies arrived at. The name is // arrived at: its description, "Entrypoint" when it has none, or
// the entrypoint's description, "Entrypoint" when it has none, // "deleted entrypoint". Never its URL, which is the entrypoint's
// or "deleted entrypoint", never its URL, which is the // secret.
// entrypoint's secret.
Entrypoint string Entrypoint string
// Headers is the event's request headers as text, one // Headers is the event's request headers, one "Name: value"
// "Name: value" line per value, sorted by name. HeadersCut // per value, sorted by name. HeadersCut reports headers left
// reports headers left out because they hold more than // out because they hold more than maxRenderedBodyBytes, stored
// maxRenderedBodyBytes, stored or as text; only the event log // or as lines; only the event log leaves them out.
// leaves them out. Headers []string
Headers string
HeadersCut bool HeadersCut bool
// ResubmittedFromID names the event this one was copied // ResubmittedFromID names the event this one was copied
@@ -103,7 +100,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 text holds at most maxHeaderBytes. // whole and their lines hold at most maxHeaderBytes.
func (r *eventLogRow) view( func (r *eventLogRow) view(
webhookID string, maxHeaderBytes int, webhookID string, maxHeaderBytes int,
) EventLogView { ) EventLogView {
@@ -123,7 +120,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: strings.Join(headers, "\n"), Headers: headers,
HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)), HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)),
ResubmittedFromID: from, ResubmittedFromID: from,
} }
@@ -133,9 +130,8 @@ 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, each with the newline that follows it, would hold more // the lines would hold more than maxBytes: a header sent many times
// than maxBytes: a header sent many times is stored with its name // is stored with its name once but shown with it on every line.
// 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
@@ -158,7 +154,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) + len("\n") size += len(line)
if size > maxBytes { if size > maxBytes {
return nil, false return nil, false
} }
+42 -98
View File
@@ -20,19 +20,16 @@ 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>`
} }
// copiedRequestArrivedAt is how a page names, for a resubmitted copy, // originalArrivedAt is how a page names the entrypoint a resubmitted
// the entrypoint the request it copies arrived at. // copy's original arrived at.
func copiedRequestArrivedAt(name string) string { func originalArrivedAt(name string) string {
return `The request it copies arrived at <span class="text-gray-900">` + return `Its original arrived at <span class="text-gray-900">` +
name + `</span>` name + `</span>`
} }
// headerBox is how a page shows an event's request header lines: as // headerLine is how a page shows one request header line.
// one block of text in a single box. func headerLine(line string) string {
func headerBox(lines ...string) string { return `<div class="whitespace-pre-wrap">` + line + `</div>`
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
@@ -114,12 +111,24 @@ 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, headerBox( assert.Contains(t, page, headerLine(accept))
"Accept: */*", assert.Contains(t, page, headerLine(userAgent))
"User-Agent: shop/1 build\t7", assert.Contains(t, page, headerLine(shopEvent))
"X-Shop-Event: order.created", assert.Less(t,
), "headers are sorted by name") 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.NotContains(t, page, "order.paid") assert.NotContains(t, page, "order.paid")
assert.NotContains(t, page, billing.Path) assert.NotContains(t, page, billing.Path)
} }
@@ -128,10 +137,8 @@ func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
t.Helper() t.Helper()
assert.Contains(t, page, arrivedAt("Entrypoint")) assert.Contains(t, page, arrivedAt("Entrypoint"))
assert.Contains(t, page, headerBox( assert.Contains(t, page, headerLine("X-Shop-Event: order.paid"))
"X-Note: &lt;b&gt;hi&lt;/b&gt;", assert.Contains(t, page, headerLine("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)
@@ -180,57 +187,39 @@ 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 and a copy // TestEventRequest_ResubmittedCopy proves a resubmitted copy says its
// of that copy each say the request they copy arrived at the // original arrived at the entrypoint, in the event log and on its own
// entrypoint, in the event log and on their own pages, and never that // page, and never that the copy did.
// 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(-2*time.Minute)) original := f.eventAt(t, ep, `{}`, time.Now().Add(-time.Minute))
copied := f.eventAt(t, ep, `{}`, time.Now().Add(-time.Minute)) copied := f.eventAt(t, ep, `{}`, time.Now())
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 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/"+copied.ID+"/resubmit",
)
require.True(t, found)
// 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( copyPart, originalPart, found := strings.Cut(
rest, "/events/"+original.ID+"/resubmit", renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
"/events/"+original.ID+"/resubmit",
) )
require.True(t, found) require.True(t, found)
for _, part := range []string{copyOfCopyPart, copyPart} { assert.Contains(t, copyPart, originalArrivedAt("Billing sender"))
assert.Contains(t, part, copiedRequestArrivedAt("Billing sender")) assert.NotContains(t, copyPart, arrivedAt("Billing sender"))
assert.NotContains(t, part, arrivedAt("Billing sender"))
}
assert.Contains(t, originalPart, arrivedAt("Billing sender")) assert.Contains(t, originalPart, arrivedAt("Billing sender"))
assert.NotContains(t, originalPart, assert.NotContains(t, originalPart, originalArrivedAt("Billing sender"))
copiedRequestArrivedAt("Billing sender"))
for _, event := range []*database.Event{copied, copyOfCopy} { w := serveEventPage(t, f.h, f.sess, f.webhook.ID, copied.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(), assert.Contains(t, w.Body.String(), originalArrivedAt("Billing sender"))
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
@@ -280,53 +269,8 @@ 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(), tc.line) assert.Contains(t, w.Body.String(), headerLine(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)
})
}
}
+2 -3
View File
@@ -1239,9 +1239,8 @@ 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 (for a resubmitted copy, the one the // entrypoint it arrived at, and with its request headers only when
// request it copies arrived at), and with its request headers only // their lines hold at most maxHeaderBytes. Like
// 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(
+6 -4
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, even a copy of a copy, did not arrive at an resubmitted copy did not arrive at an entrypoint; its original
entrypoint; the request it copies did. --> did. -->
<div class="space-y-2 text-xs"> <div class="space-y-2 text-xs">
{{if .ResubmittedFrom}} {{if .ResubmittedFrom}}
<p class="text-gray-500">The request it copies arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p> <p class="text-gray-500">Its original 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,7 +14,9 @@
<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>
<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> <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>
{{else}} {{else}}
<p class="text-gray-500">No request headers.</p> <p class="text-gray-500">No request headers.</p>
{{end}} {{end}}