Show an event's entrypoint and request headers (closes #389)
check / check (push) Successful in 3m19s

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) and the
full request headers, one per line, sorted by name. Both pages draw
them through one shared template, event_request.html. The event
columns now load entrypoint_id and headers, and the webhook's
entrypoints are read once per page.

Model: opus-5-5
This commit is contained in:
2026-10-03 01:19:09 +00:00
parent bcdd4791ec
commit 5bfe5e74bd
8 changed files with 287 additions and 6 deletions
+1 -1
View File
@@ -3051,7 +3051,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, 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, 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`) |
+77 -2
View File
@@ -1,8 +1,13 @@
package handlers
import (
"encoding/json"
"net/http"
"slices"
"time"
"unicode/utf8"
"sneak.berlin/go/webhooker/internal/database"
)
// eventLogColumns is the event log's projection. The casts to
@@ -12,14 +17,14 @@ import (
// rather than in Go is the point of the projection — an
// oversized body never becomes a Go string at all.
const eventLogColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, " +
"resubmitted_from_id, entrypoint_id, headers, " +
"substr(cast(body as blob), 1, ?) AS body, " +
"length(cast(body as blob)) AS body_bytes"
// eventColumns is eventLogColumns for the event's own page, which
// shows the whole body.
const eventColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, " +
"resubmitted_from_id, entrypoint_id, headers, " +
"cast(body as blob) AS body, " +
"length(cast(body as blob)) AS body_bytes"
@@ -34,6 +39,15 @@ type EventLogView struct {
Body BodyView
// Entrypoint names the entrypoint the event arrived at: its
// 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.
Headers []string
// ResubmittedFromID names the event this one was copied
// from, empty for an event that arrived on the receiver.
ResubmittedFromID string
@@ -63,6 +77,8 @@ type eventLogRow struct {
Method string
ContentType string
ResubmittedFromID *string
EntrypointID string
Headers string
Body []byte
BodyBytes int64
}
@@ -83,10 +99,69 @@ func (r *eventLogRow) view(webhookID string) EventLogView {
Body: newBodyView(
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
),
Headers: requestHeaderLines(r.Headers),
ResubmittedFromID: from,
}
}
// requestHeaderLines turns an event's stored request headers, the
// JSON the receiver writes, into one "Name: value" line per value,
// sorted by name. Headers that do not parse show as none.
func requestHeaderLines(headersJSON string) []string {
var headers http.Header
if json.Unmarshal([]byte(headersJSON), &headers) != nil {
return nil
}
names := make([]string, 0, len(headers))
for name := range headers {
names = append(names, name)
}
slices.Sort(names)
var lines []string
for _, name := range names {
for _, value := range headers[name] {
lines = append(lines, name+": "+value)
}
}
return lines
}
// entrypointNames maps each of the webhook's entrypoints to the name
// an event that arrived at it shows: its description, or "Entrypoint"
// when it has none, as the webhook page names it. A deleted
// entrypoint is left out.
func (h *Handlers) entrypointNames(
webhookID string,
) (map[string]string, error) {
var entrypoints []database.Entrypoint
err := h.db.DB().Where(
"webhook_id = ?", webhookID,
).Find(&entrypoints).Error
if err != nil {
return nil, err
}
names := make(map[string]string, len(entrypoints))
for i := range entrypoints {
name := entrypoints[i].Description
if name == "" {
name = "Entrypoint"
}
names[entrypoints[i].ID] = name
}
return names, nil
}
// trimPartialRune drops a trailing UTF-8 sequence that the
// byte-wise cut left incomplete, so a multi-byte rune severed
// at the cap does not surface as a mojibake tail.
+161
View File
@@ -0,0 +1,161 @@
package handlers_test
import (
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
)
// arrivedAt is how a page names the entrypoint an event arrived at.
func arrivedAt(name string) string {
return `Arrived at <span class="text-gray-900">` + name + `</span>`
}
// entrypoint records one of the fixture webhook's entrypoints.
func (f *recentEventsFixture) entrypoint(
t *testing.T, description string,
) *database.Entrypoint {
t.Helper()
ep := &database.Entrypoint{
WebhookID: f.webhook.ID,
Path: uuid.NewString(),
Description: description,
Active: true,
}
require.NoError(t, f.db.DB().Omit(clause.Associations).Create(ep).Error)
return ep
}
// eventAt records an event that arrived at the entrypoint with the
// given request headers, stored as the receiver stores them.
func (f *recentEventsFixture) eventAt(
t *testing.T,
ep *database.Entrypoint,
headers http.Header,
receivedAt time.Time,
) *database.Event {
t.Helper()
headersJSON, err := json.Marshal(headers)
require.NoError(t, err)
event := &database.Event{
WebhookID: f.webhook.ID,
EntrypointID: ep.ID,
Method: http.MethodPost,
Headers: string(headersJSON),
Body: "{}",
BodyBytes: 2,
ContentType: contentTypeJSON,
}
event.CreatedAt = receivedAt
require.NoError(t, f.webhookDB.Omit(
clause.Associations,
).Create(event).Error)
return event
}
// TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders proves two
// events that arrived at two entrypoints each show their own
// entrypoint and request headers, in the event log and on their own
// pages, with the headers sorted by name and escaped, and never the
// entrypoint's URL.
func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
t *testing.T,
) {
t.Parallel()
f := newRecentEventsFixture(t)
billing := f.entrypoint(t, "Billing sender")
unnamed := f.entrypoint(t, "")
older := f.eventAt(t, billing, http.Header{
"X-Shop-Event": {"order.created"},
"User-Agent": {"shop/1"},
}, time.Now().Add(-time.Minute))
newer := f.eventAt(t, unnamed, http.Header{
"X-Shop-Event": {"order.paid"},
"X-Note": {"<b>hi</b>"},
}, time.Now())
olderShows := func(t *testing.T, page string) {
t.Helper()
assert.Contains(t, page, arrivedAt("Billing sender"))
assert.Contains(t, page, "<div>User-Agent: shop/1</div>")
assert.Contains(t, page, "<div>X-Shop-Event: order.created</div>")
assert.Less(t,
strings.Index(page, "User-Agent: shop/1"),
strings.Index(page, "X-Shop-Event: order.created"),
"headers are sorted by name",
)
assert.NotContains(t, page, "order.paid")
assert.NotContains(t, page, billing.Path)
}
newerShows := func(t *testing.T, page string) {
t.Helper()
assert.Contains(t, page, arrivedAt("Entrypoint"))
assert.Contains(t, page, "<div>X-Shop-Event: order.paid</div>")
assert.Contains(t, page, "<div>X-Note: &lt;b&gt;hi&lt;/b&gt;</div>")
assert.NotContains(t, page, "<b>hi</b>")
assert.NotContains(t, page, "order.created")
assert.NotContains(t, page, unnamed.Path)
}
// The log lists the newer event first, so everything between
// the two events' first mentions belongs to the newer one.
_, rest, found := strings.Cut(renderSourceLogsPage(
t, f.h, f.sess, f.webhook.ID,
), newer.ID)
require.True(t, found)
newerPart, olderPart, found := strings.Cut(rest, older.ID)
require.True(t, found)
newerShows(t, newerPart)
olderShows(t, olderPart)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, newer.ID)
require.Equal(t, http.StatusOK, w.Code)
newerShows(t, w.Body.String())
w = serveEventPage(t, f.h, f.sess, f.webhook.ID, older.ID)
require.Equal(t, http.StatusOK, w.Code)
olderShows(t, w.Body.String())
}
// TestEventRequest_DeletedEntrypoint proves an event whose entrypoint
// has since been deleted says so in the event log and on its own page.
func TestEventRequest_DeletedEntrypoint(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
ep := f.entrypoint(t, "Retired sender")
event := f.eventAt(t, ep, nil, time.Now())
require.NoError(t, f.db.DB().Delete(ep).Error)
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
assert.Contains(t, page, arrivedAt("deleted entrypoint"))
assert.NotContains(t, page, "Retired sender")
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(), arrivedAt("deleted entrypoint"))
assert.NotContains(t, w.Body.String(), "Retired sender")
}
+4 -2
View File
@@ -166,10 +166,12 @@ func New(
),
"source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate(
"source_logs.html", "event_body.html", "delivery_attempts.html",
"source_logs.html", "event_request.html", "event_body.html",
"delivery_attempts.html",
),
"event_detail.html": parsePageTemplate(
"event_detail.html", "event_body.html", "delivery_attempts.html",
"event_detail.html", "event_request.html", "event_body.html",
"delivery_attempts.html",
),
"target_edit.html": parsePageTemplate("target_edit.html"),
"error.html": parsePageTemplate("error.html"),
+16 -1
View File
@@ -1240,7 +1240,8 @@ func (h *Handlers) loadEventsWithDeliveries(
}
// eventLogViews projects loaded events for rendering, each with
// its deliveries and how many times it has been resubmitted. Like
// its deliveries, how many times it has been resubmitted and the
// entrypoint it arrived at. Like
// loadEventsWithDeliveries, it reports false once it has answered
// the request with an error.
func (h *Handlers) eventLogViews(
@@ -1293,11 +1294,25 @@ func (h *Handlers) eventLogViews(
return nil, false
}
entrypoints, err := h.entrypointNames(webhookID)
if err != nil {
h.serverError(w, r, "failed to load entrypoints", err)
return nil, false
}
for i := range rows {
result[i].Deliveries = h.newDeliveryViews(
eventDeliveries[i], targetMap, attempts,
)
result[i].ResubmitCount = resubmits[rows[i].ID]
name, ok := entrypoints[rows[i].EntrypointID]
if !ok {
name = "deleted entrypoint"
}
result[i].Entrypoint = name
}
return result, true
+9
View File
@@ -50,6 +50,15 @@
</dl>
</div>
<div class="card mt-6">
<div class="p-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Request</h2>
</div>
<div class="p-4">
{{template "event_request" .}}
</div>
</div>
<div class="card mt-6">
<div class="p-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Body</h2>
+16
View File
@@ -0,0 +1,16 @@
{{define "event_request"}}
<!-- 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. -->
<div class="space-y-2 text-xs">
<p class="text-gray-500">Arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p>
{{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>{{.}}</div>{{end}}
</div>
{{else}}
<p class="text-gray-500">No request headers.</p>
{{end}}
</div>
{{end}}
+3
View File
@@ -55,6 +55,9 @@
<button type="submit" class="btn-small" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
</form>
</div>
<div class="mb-3">
{{template "event_request" .}}
</div>
{{template "event_body" .Body}}
{{if .Deliveries}}