Files
webhooker/internal/handlers/delivery_result_view.go
T
sneak 7199fcfa0b
check / check (push) Successful in 3m23s
Event log: show attempt and delivery times, label replays, zone event times (closes #386)
Each attempt shows when it was recorded and each delivery when it was
created, in the event log and on the event's page. The event log's
event times read as the recent events list does: how long ago, with
the full UTC time on hover.

A delivery records whether Replay created it, in a new replay column
added to the delivery model in place. Such a delivery is labelled a
replay in the event's summary line and in both pages' delivery lists.
Both pages draw a delivery's row from one shared template.

Model: opus-5-5
2026-10-03 01:56:43 +00:00

181 lines
6.3 KiB
Go

package handlers
import (
"time"
"github.com/dustin/go-humanize"
"sneak.berlin/go/webhooker/internal/delivery"
)
// maxRenderedResponseBytes caps how many bytes of one stored
// delivery response body reach the event log page.
//
// The bound is enforced here and in SQL, because this page's
// memory profile must not depend on a constant in another
// package staying where it is, and because rows predating the
// delivery engine's own cap or restored from an archive are
// not covered by it at all.
//
// It happens to equal that engine cap, so a row written by the
// current engine reaches this one exactly and is never cut
// twice. Nothing here may assume the two differ: see view.
const maxRenderedResponseBytes = 4096
// deliveryResultColumns is the delivery attempt projection.
// The casts to blob are load-bearing for the same reason they
// are in eventLogColumns: they make substr and length count
// bytes rather than characters, and they make SQLite do the
// cut, so an oversized stored response never becomes a Go
// string at all.
const deliveryResultColumns = "delivery_id, attempt_num, created_at, " +
"success, status_code, error, duration, " +
"substr(cast(response_body as blob), 1, ?) AS response_body, " +
"length(cast(response_body as blob)) AS response_bytes"
// DeliveryResultView is the display-safe projection of one
// delivery attempt for the event log page. It carries a
// capped response body plus the true stored size, so the page
// can mark a response as truncated without holding the whole
// thing.
//
// Both Error and ResponseBody have been through the target's
// Redactor. The engine already masks the URL out of the
// errors it stores, so for errors this is a second line
// covering rows written before it did; for response bodies it
// is the only line, and its reach is what
// delivery.Redactor documents.
type DeliveryResultView struct {
AttemptNum int
Success bool
// Ran is how long ago the attempt was recorded, and RanUTC the
// full timestamp the page shows on hover.
Ran string
RanUTC string
// StatusCode is 0 when the attempt never got a response,
// which is why the page asks HasStatusCode rather than
// printing the number.
StatusCode int
// Error is the stored failure message, redacted.
Error string
// DurationMS is how long the attempt took.
DurationMS int64
// ResponseBody holds at most maxRenderedResponseBytes
// bytes of the stored response, redacted. It is remote
// content and must only ever be rendered escaped.
ResponseBody string
// ResponseBytes is the size of the stored response body,
// before the cut and before redaction. It is what the
// remote sent only when ResponseSizeKnown is set.
ResponseBytes int64
// ResponseShownBytes is how much of that the page is
// showing. It is the size of the cut, taken before
// redaction, so the truncation marker reports what SQLite
// returned rather than how much the marker substitution
// then changed the length.
ResponseShownBytes int
// ResponseTruncated reports that the body shown may be
// incomplete, so the page owes the reader a marker. Every
// body that reaches the cap counts, because one the
// delivery engine cut at its own equal cap is
// indistinguishable from a complete one.
ResponseTruncated bool
// ResponseSizeKnown reports that ResponseBytes is the whole
// response the remote sent, so the marker may quote it. It
// is false for a body that only reaches the cap, where how
// much came after it was never recorded.
ResponseSizeKnown bool
}
// HasStatusCode reports whether the attempt got as far as an
// HTTP response. A transport failure stores no status code,
// and rendering that as "0" would read as a real status.
func (v DeliveryResultView) HasStatusCode() bool {
return v.StatusCode != 0
}
// deliveryResultRow is one row of the delivery attempt
// projection. Its response body arrives already cut to the
// cap by SQLite, with the true size beside it.
type deliveryResultRow struct {
DeliveryID string
AttemptNum int
CreatedAt time.Time
Success bool
StatusCode int
Error string
Duration int64
ResponseBody []byte
ResponseBytes int64
}
// view projects a loaded row for rendering, stripping the
// target's own credential out of the two fields a remote peer
// gets to influence.
func (r *deliveryResultRow) view(
redactor delivery.Redactor,
) DeliveryResultView {
body := r.ResponseBody
// Two different cuts can have shortened this body, and the
// row records only one of them. SQLite cuts here, whenever
// the stored value is larger than the cap. The delivery
// engine cut earlier, whenever the remote sent more than
// its own maxBodyLog — which is this same number, so such a
// row stores the cut length as its whole length and nothing
// in it separates a response that ended at the cap from one
// severed there.
//
// So a body that reaches the cap is treated as cut either
// way. Gating on ResponseBytes alone would assume the two
// caps differ, and they do not: under the current engine
// that gate never opens.
cut := r.ResponseBytes > int64(len(body)) ||
len(body) >= maxRenderedResponseBytes
// The row holds more than the page shows only in the first
// of those cases. In the second the stored row is all there
// is, and its size is a floor rather than the true one.
sizeKnown := r.ResponseBytes > int64(len(body))
// Only a cut response can have been left mid-sequence,
// exactly as with an event body.
if cut {
body = trimPartialRune(body)
}
// A cut body goes through RedactCut: the remote controls
// the padding ahead of a credential it echoes, so it
// controls where the cut falls inside that credential, and
// the severed prefix left behind matches no secret whole.
rendered := string(body)
if cut {
rendered = redactor.RedactCut(rendered)
} else {
rendered = redactor.Redact(rendered)
}
return DeliveryResultView{
AttemptNum: r.AttemptNum,
Success: r.Success,
Ran: humanize.Time(r.CreatedAt),
RanUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
StatusCode: r.StatusCode,
Error: redactor.Redact(r.Error),
DurationMS: r.Duration,
ResponseBody: rendered,
ResponseBytes: r.ResponseBytes,
ResponseShownBytes: len(body),
ResponseTruncated: cut,
ResponseSizeKnown: sizeKnown,
}
}