1 Commits
Author SHA1 Message Date
clawbot 7287e163e7 Add a statistics pane to the webhook page (closes #368)
check / check (push) Successful in 3m58s
Each webhook's event database keeps one row of running totals: events,
deliveries and failures, and how many of each retention removed.
Storing an event, creating a delivery, a delivery becoming failed and
the retention sweep each update it in the transaction that writes or
deletes the rows it counts. Deliveries get a finished_at column, the
last column of the status index, so the last-10-minutes and
last-24-hours figures are index-range counts. The pane is its own
template, included at the top of the page.

The schema changes in place with nothing back-filled, so an existing
database must be recreated.

Model: opus-5-5
2026-10-01 19:49:18 +00:00
31 changed files with 700 additions and 846 deletions
+4 -2
View File
@@ -3,8 +3,10 @@
# stage of the Dockerfile. # stage of the Dockerfile.
.git/ .git/
bin/ bin/
# Extracted from 3p/ by `make assets` inside the build; a host copy is not # Third-party browser assets are fetched and hash-verified inside the build by
# needed. The tarball in 3p/ must stay in the context. # script/fetch-assets. Excluding any host copy keeps a developer's working tree
# from supplying the bytes that get shipped. The script and its
# static/vendor.sha256 manifest stay in the context.
static/js/alpine.min.js static/js/alpine.min.js
*.md *.md
LICENSE LICENSE
+3 -2
View File
@@ -46,6 +46,7 @@ temp/
# CI cache barrier, written into the build context by the check workflow # CI cache barrier, written into the build context by the check workflow
.ci-fingerprint .ci-fingerprint
# Alpine.js, extracted by `make assets` from its tarball in 3p/, which is # Third-party browser assets, fetched and hash-verified by
# what is committed. # script/fetch-assets against static/vendor.sha256. Not committed:
# REPO_POLICIES.md forbids minified bundles in version control.
/static/js/alpine.min.js /static/js/alpine.min.js
Binary file not shown.
+11 -4
View File
@@ -51,8 +51,15 @@ RUN go mod download
# the lint stage above. # the lint stage above.
COPY . . COPY . .
# Run tests and build. Both first run script/assets, which extracts Alpine.js # Fetch the third-party browser assets the UI serves. They are not committed
# from its tarball in 3p/. # (REPO_POLICIES.md forbids minified bundles in version control) and
# .dockerignore keeps any host copy out of the build context, so this step is
# the only way they enter the image. Each download is checked against a
# hardcoded sha256 and the build fails on mismatch; make test re-checks the
# hashes against the bytes go:embed actually put in the binary.
RUN script/fetch-assets
# Run tests and build
RUN make test RUN make test
# Version stamped into the binary. .dockerignore excludes .git/, so # Version stamped into the binary. .dockerignore excludes .git/, so
@@ -60,8 +67,8 @@ RUN make test
# host and passes it in. The default is what a bare `docker build .` # host and passes it in. The default is what a bare `docker build .`
# with no --build-arg gets, and it names no tag the tree may not be at. # with no --build-arg gets, and it names no tag the tree may not be at.
# #
# Declared here, below the test step, so a changed version does not # Declared here, below the test and asset steps, so a changed version
# invalidate its cached layer. # does not invalidate their cached layers.
ARG VERSION=unknown ARG VERSION=unknown
RUN make build VERSION="$VERSION" RUN make build VERSION="$VERSION"
+3 -3
View File
@@ -28,7 +28,7 @@ setup:
@script/setup @script/setup
assets: assets:
@script/assets @script/fetch-assets
test: test:
@script/test @script/test
@@ -45,13 +45,13 @@ fmt-check:
check: check:
@script/check @script/check
build: assets build:
go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
run: build run: build
./bin/webhooker ./bin/webhooker
dev: assets dev:
go run ./cmd/webhooker go run ./cmd/webhooker
deps: deps:
+79 -90
View File
@@ -21,6 +21,9 @@ before deploying one.
- Go 1.26.1+ (the version in `go.mod`) - Go 1.26.1+ (the version in `go.mod`)
- Docker (for linting, for the test stage of the CI gate, and for - Docker (for linting, for the test stage of the CI gate, and for
containerized deployment) containerized deployment)
- `curl`, used by `script/fetch-assets` to download the third-party
browser assets, which are not committed (`make bootstrap` installs
it if missing)
golangci-lint is not a prerequisite and must not be installed on the golangci-lint is not a prerequisite and must not be installed on the
host: `script/bootstrap` does not install it, and `make lint` runs the host: `script/bootstrap` does not install it, and `make lint` runs the
@@ -33,7 +36,9 @@ digest-pinned linter image via `Dockerfile.lint`.
git clone https://git.eeqj.de/sneak/webhooker.git git clone https://git.eeqj.de/sneak/webhooker.git
cd webhooker cd webhooker
# Install the Go toolchain if missing, and the Go dependencies # Install Go dependencies and the third-party browser assets.
# `make deps` alone is not enough: it only runs go mod download/tidy,
# and the checks below need the fetched assets.
make bootstrap make bootstrap
# Run all checks (test, lint, format check) # Run all checks (test, lint, format check)
@@ -53,7 +58,7 @@ make docker
```bash ```bash
make bootstrap # Install all dependencies (idempotent) make bootstrap # Install all dependencies (idempotent)
make setup # Bootstrap + install git pre-commit hook make setup # Bootstrap + install git pre-commit hook
make assets # Extract Alpine.js from 3p/ (test, check, build, dev run it) make assets # Fetch + verify third-party browser assets
make fmt # Format code (gofmt + goimports) make fmt # Format code (gofmt + goimports)
make fmt-check # Fail if gofmt would change anything (writes nothing) make fmt-check # Fail if gofmt would change anything (writes nothing)
make lint # Run golangci-lint in Docker (Dockerfile.lint) make lint # Run golangci-lint in Docker (Dockerfile.lint)
@@ -1065,7 +1070,7 @@ unconditionally against whatever files it finds:
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`, - the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
`Entrypoint`, `Target` `Entrypoint`, `Target`
- each event database when it is lazily opened — `Event`, `Delivery`, - each event database when it is lazily opened — `Event`, `Delivery`,
`DeliveryResult`, `EventTotals`, `TargetTotals` `DeliveryResult`, `Totals`
- each archive database on every open and reopen - each archive database on every open and reopen
There is no schema version table, no migration ledger, and no down There is no schema version table, no migration ledger, and no down
@@ -1216,15 +1221,14 @@ This repository adheres to the
standard: normalized scripts in `script/` are the entrypoints for the standard: normalized scripts in `script/` are the entrypoints for the
development workflow. Ten of the Makefile's seventeen targets are thin development workflow. Ten of the Makefile's seventeen targets are thin
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
`version` are inline commands with no script behind them, though `build`, `version` are inline commands with no script behind them, though
`run` and `dev` first run `script/assets`, and `build` and `version` both `build` and `version` both take their value from `script/version`.
take their value from `script/version`.
`script/test`, `make build` and `make dev` each run `script/assets` `make check` needs the third-party browser assets in `static/`, which
first, which writes the ignored `static/js/alpine.min.js` (see are not committed, so run `make bootstrap` (or just `make assets`) once
[Third-party browser assets](#third-party-browser-assets)), so after cloning. Without them the tests fail with a message naming that
`make test`, `make check` and the pre-commit hook work on a fresh clone remedy. `make check` does not fetch them itself because it must not
without a separate step. change any files in the repo.
We provide: We provide:
@@ -1232,8 +1236,8 @@ We provide:
- `script/setup` — make a fresh clone ready for development - `script/setup` — make a fresh clone ready for development
(bootstrap, then install-precommit) (bootstrap, then install-precommit)
- `script/projectname` — output the project name ("webhooker") - `script/projectname` — output the project name ("webhooker")
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see - `script/fetch-assets` — download the third-party browser assets into
[Third-party browser assets](#third-party-browser-assets)) `static/`, verifying each against its pinned sha256
- `script/test` — run the test suite - `script/test` — run the test suite
- `script/lint` — run golangci-lint in Docker (see Linting below) - `script/lint` — run golangci-lint in Docker (see Linting below)
- `script/fmt` — format all code (writes) - `script/fmt` — format all code (writes)
@@ -1255,25 +1259,24 @@ We provide:
## Third-party browser assets ## Third-party browser assets
The web UI serves one third-party script, Alpine.js. Its npm package tarball The web UI serves one third-party script, Alpine.js. It is **not** committed:
is committed as `3p/alpinejs-3.14.9.tgz`, byte for byte as the npm registry a minified bundle in the tree is unreviewable, and `REPO_POLICIES.md` bars
publishes it. It is a dependency, not this repo's build output, so both committed build artifacts and unpinned external references.
`REPO_POLICIES.md`'s rule against committed build artifacts does not apply.
The directory is `3p/` rather than `vendor/` because Go treats a root
`vendor/` directory as its module vendor directory.
`script/assets` (`make assets`) extracts the browser build, Instead `script/fetch-assets` downloads it from a pinned URL, checks the
`package/dist/cdn.min.js`, from the tarball to `static/js/alpine.min.js`, download against a hardcoded sha256, and installs it under `static/`. The
where `go:embed` picks it up. `script/test`, `make build` and `make dev` run sha256 of every installed asset is recorded in `static/vendor.sha256`, and
it first, and the Dockerfile builds through `make test` and `make build`, so `static/vendor_test.go` re-hashes the bytes `go:embed` put in the binary
nothing downloads Alpine.js. The extracted file is not committed, and against that manifest — so the pin is enforced on what actually ships, not
`.dockerignore` keeps any host copy out of the build context. merely written down. Any mismatch fails the build.
To move to a new version: download `make bootstrap` runs the fetch for local development, and the Dockerfile
`https://registry.npmjs.org/alpinejs/-/alpinejs-<version>.tgz`, check it runs it in the build stage; `.gitignore` and `.dockerignore` keep the
against the `dist.integrity` hash listed at artifact out of both the repo and the build context.
`https://registry.npmjs.org/alpinejs/<version>`, replace the tarball in `3p/`
with it, update its file name in `script/assets`, and run `make check`. To move to a new version: update the version, URL, and tarball sha256 in
`script/fetch-assets` and the asset sha256 in `static/vendor.sha256`, then
run `make assets && make check`.
## Rationale ## Rationale
@@ -1381,7 +1384,7 @@ The codebase uses consistent naming throughout (rename completed in
### Data Model ### Data Model
webhooker's data model has eleven entities organized into two tiers: the webhooker's data model has ten entities organized into two tiers: the
**application tier** (user and webhook configuration) and the **event **application tier** (user and webhook configuration) and the **event
tier** (event ingestion, delivery, and logging). tier** (event ingestion, delivery, and logging).
@@ -1411,12 +1414,9 @@ tier** (event ingestion, delivery, and logging).
│ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │ │ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │
│ └──────────┘ └──────────┘ └─────────────────┘ │ │ └──────────┘ └──────────┘ └─────────────────┘ │
│ │ │ │
│ ┌──────────────┐ (one row: running counts of events) │ │ ┌──────────┐ │
│ │ EventTotals │ │ │ │ Totals │ (one row of running counts) │
│ └──────────────┘ │ │ └──────────┘ │
│ ┌──────────────┐ (one row per target: running counts │
│ │ TargetTotals │ of its deliveries) │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
``` ```
@@ -1737,65 +1737,57 @@ retries) is individually logged for full observability.
**Relations:** Belongs to Delivery. **Relations:** Belongs to Delivery.
#### EventTotals and TargetTotals #### Totals
Running counts in each event database, read by the statistics pane at the The one row of running counts in each event database, read by the
top of the webhook page. `EventTotals` is one row: statistics pane at the top of the webhook page.
| Field | Type | Description |
| ---------------- | ------- | ----------- |
| `events` | integer | Events ever stored, resubmitted copies included |
| `events_removed` | integer | Events retention has deleted |
`TargetTotals` is one row per target, created by the first delivery to it:
| Field | Type | Description | | Field | Type | Description |
| -------------------- | ------- | ----------- | | -------------------- | ------- | ----------- |
| `target_id` | UUID | The target (primary key) | | `events` | integer | Events ever stored, resubmitted copies included |
| `deliveries` | integer | Deliveries to it ever created, replays included | | `deliveries` | integer | Deliveries ever created, replays included |
| `delivered` | integer | Of those, how many became `delivered` | | `failures` | integer | Deliveries that ever became `failed` |
| `failed` | integer | Of those, how many became `failed` | | `events_removed` | integer | Events retention has deleted |
| `deliveries_removed` | integer | Its deliveries retention has deleted | | `deliveries_removed` | integer | Deliveries retention has deleted |
| `failed_removed` | integer | Its failed deliveries retention has deleted | | `failures_removed` | integer | Failed deliveries retention has deleted |
Each count changes in the transaction that writes or deletes the rows it Each count changes in the transaction that writes or deletes the rows it
counts. The pane's lifetime events are `events`, and its lifetime counts. The pane shows each of the first three as a lifetime figure, and
deliveries and failures are `deliveries` and `failed` summed over the less what retention removed as the figure within retention, so neither
targets; each figure within retention is the same less what retention needs the rows themselves. Its last-10-minutes and last-24-hours figures
removed, so neither needs the rows themselves. Its last-10-minutes and are counted from the `events` and `deliveries` indexes over just that
last-24-hours figures are counted from the `events` and `deliveries` window. Its failure percentage for a window is the deliveries that became
indexes over just that window, the deliveries in one query grouped by
target. Its failure percentage for a window is the deliveries that became
`failed` in it out of all that became `delivered` or `failed` in it, and `failed` in it out of all that became `delivered` or `failed` in it, and
a dash when none did. a dash when none did.
#### Event-tier indexes #### Event-tier indexes
These indexes on the per-webhook event databases are declared in the model These indexes on the per-webhook event databases are declared in the model
tags, so `AutoMigrate` creates them on a fresh database: tags, so `AutoMigrate` creates them on a fresh and on an existing database:
| Table | Columns | Serves | | Table | Columns | Serves |
| ------------------ | --------------------------- | ------ | | ------------------ | --------------------------- | ------ |
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics, which count each target's deliveries by status and when they finished | | `deliveries` | `status`, `deleted_at`, `finished_at` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics, which count deliveries by status and when they finished |
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events | | `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which selects and deletes the deliveries of expired events |
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events | | `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events and find the newest | | `events` | `deleted_at`, `created_at` | Retention, which selects expired events by age, and the webhook page's statistics, which count recent events and find the newest |
| `events` | `created_at` | Retention, which selects expired events by age | | `events` | `created_at` | Retention's delete of the expired events themselves |
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention GORM's soft delete adds `deleted_at IS NULL` to these queries; retention's
leaves it out. SQLite keeps no statistics on these tables, and without them it deletes leave it out, but their lookups of expired rows keep it. SQLite keeps
rates the `deleted_at` index, which every live row matches, above an index on no statistics on these tables, and without them it rates the `deleted_at`
a column matched against several values or compared with `<`. So every index index, which every live row matches, above an index on a column matched
but the last also covers `deleted_at`. It comes second, so that retention can against several values or compared with `<`. So every index but the last also
use the index without it, except in `events`, where `created_at` is compared covers `deleted_at`. It comes second, so that retention's deletes can use the
with `<` and SQLite narrows by a `<` only on the last column it uses. index without it, except in `events`, where `created_at` is compared with `<`
and SQLite narrows by a `<` only on the last column it uses.
#### Common Fields #### Common Fields
Every entity except `Setting`, `EventTotals` and `TargetTotals` includes Every entity except `Setting` and `Totals` includes these fields from
these fields from `BaseModel`. `Setting` is a bare key-value row with no `BaseModel`. `Setting` is a bare key-value row with no `id`, no
`id`, no timestamps and no soft delete, and the two totals tables hold timestamps and no soft delete, and `Totals` is a single row of counts
only counts, keyed by a numeric `id` and by `target_id`: with only a numeric `id`:
| Field | Type | Description | | Field | Type | Description |
| ------------ | --------- | ----------- | | ------------ | --------- | ----------- |
@@ -1837,8 +1829,7 @@ encryption key is generated and stored, and an `admin` user is created.
- **Events** — captured incoming webhook payloads - **Events** — captured incoming webhook payloads
- **Deliveries** — event-to-target pairings and their status - **Deliveries** — event-to-target pairings and their status
- **DeliveryResults** — individual delivery attempt logs - **DeliveryResults** — individual delivery attempt logs
- **EventTotals** and **TargetTotals** — running counts of the above, - **Totals** — running counts of the above, kept through retention
the deliveries per target, kept through retention
Per-webhook databases are created automatically when a webhook is Per-webhook databases are created automatically when a webhook is
created (and lazily on first access for webhooks that predate this created (and lazily on first access for webhooks that predate this
@@ -2794,8 +2785,6 @@ imports. The entry point is `cmd/webhooker/main.go`.
``` ```
webhooker/ webhooker/
├── 3p/
│ └── alpinejs-3.14.9.tgz # Alpine.js npm package, extracted by make assets
├── cmd/webhooker/ ├── cmd/webhooker/
│ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx │ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx
├── internal/ ├── internal/
@@ -2819,7 +2808,6 @@ webhooker/
│ │ ├── model_event.go # Event entity (per-webhook DB) │ │ ├── model_event.go # Event entity (per-webhook DB)
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB) │ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB) │ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
│ │ ├── model_totals.go # EventTotals and TargetTotals (per-webhook DB)
│ │ ├── model_apikey.go # APIKey entity │ │ ├── model_apikey.go # APIKey entity
│ │ ├── password.go # Argon2id hashing and verification │ │ ├── password.go # Argon2id hashing and verification
│ │ ├── retention.go # Retention reaper (per-webhook event expiry) │ │ ├── retention.go # Retention reaper (per-webhook event expiry)
@@ -2890,7 +2878,8 @@ webhooker/
│ ├── css/tailwind.css # Generated stylesheet the pages load │ ├── css/tailwind.css # Generated stylesheet the pages load
│ ├── css/style.css # Older hand-written stylesheet, no longer loaded │ ├── css/style.css # Older hand-written stylesheet, no longer loaded
│ ├── js/app.js # Progressive-enhancement copy-to-clipboard │ ├── js/app.js # Progressive-enhancement copy-to-clipboard
│ └── js/alpine.min.js # Alpine.js, extracted from 3p/ by make assets, not committed │ ├── js/alpine.min.js # Alpine.js, fetched by script/fetch-assets, not committed
│ └── vendor.sha256 # Pinned hashes the fetched assets are verified against
├── templates/ # Go HTML templates (base, login, sources, etc.) ├── templates/ # Go HTML templates (base, login, sources, etc.)
├── script/ # Scripts to Rule Them All entrypoints ├── script/ # Scripts to Rule Them All entrypoints
├── Dockerfile # Three stages: lint, test+build, Alpine runtime ├── Dockerfile # Three stages: lint, test+build, Alpine runtime
@@ -3202,14 +3191,14 @@ version is fixed independently of the compiler's:
`make fmt-check`, then `golangci-lint config verify` and `make fmt-check`, then `golangci-lint config verify` and
`golangci-lint run`, both with `--network=none`. `golangci-lint run`, both with `--network=none`.
2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint 2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
stage passing (it copies a file from it), runs `make test` and stage passing (it copies a file from it), runs `script/fetch-assets`
`make build` (both extract Alpine.js from `3p/` first), and finally to download and verify the third-party browser assets, then runs
rebuilds the binary with `CGO_ENABLED=1` and static linking so it `make test` and `make build`, and finally rebuilds the binary with
runs on musl. Both builds go through `make build`, the relink adding `CGO_ENABLED=1` and static linking so it runs on musl. Both builds
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that go through `make build`, the relink adding its `-extldflags` via
stamps the version. The version arrives as the `VERSION` build arg, `GO_LDFLAGS`, so neither can drop the `-X` that stamps the version.
since the context has no `.git` (see The version arrives as the `VERSION` build arg, since the context
[Version stamping](#version-stamping)). has no `.git` (see [Version stamping](#version-stamping)).
3. **Runtime stage** (`alpine:3.21`) — copies the static binary and 3. **Runtime stage** (`alpine:3.21`) — copies the static binary and
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` `deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
directory for all SQLite databases, exposes port 8080, and includes directory for all SQLite databases, exposes port 8080, and includes
+28 -37
View File
@@ -93,11 +93,12 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
deliveries []database.Delivery deliveries []database.Delivery
results []database.DeliveryResult results []database.DeliveryResult
depths []struct{ Depth int } depths []struct{ Depth int }
removed []database.TargetTotals failed struct{ Count int64 }
) )
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)" byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)" byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)"
byAge := "idx_events_deleted_at_created_at (deleted_at=? AND created_at<?)"
// The delivery engine: recovery and the retry sweep, the sweep for // The delivery engine: recovery and the retry sweep, the sweep for
// stranded pending deliveries, and the queue depth count. // stranded pending deliveries, and the queue depth count.
@@ -123,38 +124,38 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
Order("attempt_num ASC").Find(&results), Order("attempt_num ASC").Find(&results),
"idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)") "idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)")
// Retention (reapExpired, deleteEvents): one batch of expired // Retention's deletes (deleteExpired), whose subqueries are built
// events, then their attempts, deliveries and the events. // afresh for each statement as it builds them.
var expired []string expiredEventIDs := func() *gorm.DB {
return dry.Model(&database.Event{}).Select("id").
Where("created_at < ?", cutoff)
}
assertPlanUses(t, db, dry.Unscoped().Model(&database.Event{}).
Where("created_at < ?", cutoff).
Limit(database.ExportReapBatchSize).Pluck("id", &expired),
"idx_events_created_at (created_at<?)")
assertPlanUses(t, db, dry.Unscoped().Where( assertPlanUses(t, db, dry.Unscoped().Where(
"delivery_id IN (?)", dry.Unscoped().Model(&database.Delivery{}). "delivery_id IN (?)", dry.Model(&database.Delivery{}).
Select("id").Where("event_id IN ?", ids), Select("id").Where("event_id IN (?)", expiredEventIDs()),
).Delete(&database.DeliveryResult{}), ).Delete(&database.DeliveryResult{}),
"idx_delivery_results_delivery_id (delivery_id=?)", "idx_delivery_results_delivery_id (delivery_id=?)", byEvent, byAge)
"idx_deliveries_event_id (event_id=?)")
assertPlanUses(t, db, dry.Unscoped().Model(&database.Delivery{}). assertPlanUses(t, db, dry.Unscoped().Model(&database.Delivery{}).
Select("target_id, count(*) AS deliveries_removed, "+ Select("count(CASE WHEN status = ? THEN 1 END) AS count",
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
database.DeliveryStatusFailed). database.DeliveryStatusFailed).
Where("event_id IN ?", ids).Group("target_id").Find(&removed), Where("event_id IN (?)", expiredEventIDs()).
"idx_deliveries_event_id (event_id=?)") Take(&failed),
assertPlanUses(t, db, dry.Unscoped().Where("event_id IN ?", ids). "idx_deliveries_event_id (event_id=?)", byAge)
Delete(&database.Delivery{}), "idx_deliveries_event_id (event_id=?)") assertPlanUses(t, db, dry.Unscoped().Where(
assertPlanUses(t, db, dry.Unscoped().Where("id IN ?", ids). "event_id IN (?)", expiredEventIDs(),
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)") ).Delete(&database.Delivery{}),
"idx_deliveries_event_id (event_id=?)", byAge)
assertPlanUses(t, db, dry.Unscoped().Where(
"created_at < ?", cutoff,
).Delete(&database.Event{}), "idx_events_created_at (created_at<?)")
} }
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook // TestStatisticsQueriesUseTheirIndexes does the same for the webhook
// page's statistics (readEventStats in the handlers): deliveries in // page's statistics (readEventStats in the handlers): deliveries in
// progress, each target's deliveries finished since a time, which must // progress, deliveries finished and events received since a time, and
// come from the index alone, events received since a time, and the // the newest event, which must come straight off an index rather than
// newest event, which must come straight off an index rather than from // from sorting every event.
// sorting every event.
func TestStatisticsQueriesUseTheirIndexes(t *testing.T) { func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
t.Parallel() t.Parallel()
@@ -173,7 +174,6 @@ func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
var ( var (
count int64 count int64
newest []time.Time newest []time.Time
byTarget []struct{ TargetID string }
) )
assertPlanUses(t, db, dry.Model(&database.Delivery{}). assertPlanUses(t, db, dry.Model(&database.Delivery{}).
@@ -183,18 +183,9 @@ func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
}).Count(&count), }).Count(&count),
"idx_deliveries_status (status=? AND deleted_at=?)") "idx_deliveries_status (status=? AND deleted_at=?)")
assertPlanUses(t, db, dry.Model(&database.Delivery{}). assertPlanUses(t, db, dry.Model(&database.Delivery{}).
Select("target_id, "+ Where("status = ? AND finished_at >= ?",
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+ database.DeliveryStatusFailed, since).Count(&count),
"count(CASE WHEN status = ? THEN 1 END) AS failed", "idx_deliveries_status "+
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed).
Where("status IN ? AND finished_at >= ?",
[]database.DeliveryStatus{
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed,
}, since).
Group("target_id").Find(&byTarget),
"COVERING INDEX idx_deliveries_status "+
"(status=? AND deleted_at=? AND finished_at>?)") "(status=? AND deleted_at=? AND finished_at>?)")
assertPlanUses(t, db, dry.Model(&database.Event{}). assertPlanUses(t, db, dry.Model(&database.Event{}).
Where("created_at >= ?", since).Count(&count), Where("created_at >= ?", since).Count(&count),
-4
View File
@@ -28,10 +28,6 @@ func NewTestRetentionReaper(
} }
} }
// ExportReapBatchSize exposes how many expired events one retention
// transaction deletes.
const ExportReapBatchSize = reapBatchSize
// ExportSweep runs a single retention sweep synchronously for tests. // ExportSweep runs a single retention sweep synchronously for tests.
func (r *RetentionReaper) ExportSweep(ctx context.Context) { func (r *RetentionReaper) ExportSweep(ctx context.Context) {
r.sweep(ctx) r.sweep(ctx)
+4 -5
View File
@@ -41,7 +41,7 @@ type Delivery struct {
BaseModel BaseModel
EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"` EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"`
TargetID string `gorm:"type:uuid;not null;index:idx_deliveries_status,priority:4" json:"targetId"` TargetID string `gorm:"type:uuid;not null" json:"targetId"`
Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"` Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"`
// DeletedAt repeats the BaseModel field only to be the second column // DeletedAt repeats the BaseModel field only to be the second column
@@ -50,10 +50,9 @@ type Delivery struct {
DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"` DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"`
// FinishedAt is when the delivery became delivered or failed, and // FinishedAt is when the delivery became delivered or failed, and
// nil while it is pending or retrying. It and then TargetID end the // nil while it is pending or retrying. It ends the status index,
// status index, so the webhook page counts each target's deliveries // so the webhook page counts the deliveries that finished in a
// that finished in a recent window by reading just that window from // recent window by reading that window from the index.
// the index.
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"` FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
// Relations // Relations
+47 -65
View File
@@ -6,85 +6,67 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// The running totals in a webhook's event database keep the webhook // Totals is the single row of running totals in a webhook's event
// page's lifetime figures right after retention has removed the rows // database. It is what keeps the webhook page's lifetime figures right
// they count, and let the page show them without counting every row. // after retention has removed the rows they count, and what lets the
// Each total changes in the transaction that writes or deletes the // page show them without counting every row.
// rows it counts. //
// Storing an event, creating a delivery and failing a delivery each
// EventTotals is the single row counting a webhook's events: every // add one, and retention adds what it deletes to the Removed columns.
// event ever stored, and how many of them retention has deleted. // Every addition goes through AddTotals, in the transaction that
type EventTotals struct { // writes or deletes the rows it counts.
type Totals struct {
ID int64 `gorm:"primaryKey"` ID int64 `gorm:"primaryKey"`
Events int64 `gorm:"not null"` Events int64 `gorm:"not null"`
EventsRemoved int64 `gorm:"not null"`
}
// TableName names the table AddEventTotals updates.
func (EventTotals) TableName() string {
return "event_totals"
}
// TargetTotals is one row per target counting its deliveries: every
// delivery ever created, how many became delivered and how many
// failed, and how many deliveries and failed deliveries retention has
// deleted. The webhook's delivery figures are these rows summed.
type TargetTotals struct {
TargetID string `gorm:"type:uuid;primaryKey"`
Deliveries int64 `gorm:"not null"` Deliveries int64 `gorm:"not null"`
Delivered int64 `gorm:"not null"` Failures int64 `gorm:"not null"`
Failed int64 `gorm:"not null"`
EventsRemoved int64 `gorm:"not null"`
DeliveriesRemoved int64 `gorm:"not null"` DeliveriesRemoved int64 `gorm:"not null"`
FailedRemoved int64 `gorm:"not null"` FailuresRemoved int64 `gorm:"not null"`
} }
// TableName names the table AddTargetTotals updates. // TableName names the table AddTotals updates.
func (TargetTotals) TableName() string { func (Totals) TableName() string {
return "target_totals" return "totals"
} }
// AddEventTotals adds each count in add to the webhook's event totals. // EventsWithinRetention is how many of the webhook's events are still
// Call it on the transaction that writes or deletes the events it // stored.
// counts. func (t Totals) EventsWithinRetention() int64 {
func AddEventTotals(tx *gorm.DB, add EventTotals) error { return t.Events - t.EventsRemoved
}
// DeliveriesWithinRetention is how many of the webhook's deliveries
// are still stored.
func (t Totals) DeliveriesWithinRetention() int64 {
return t.Deliveries - t.DeliveriesRemoved
}
// FailuresWithinRetention is how many of the webhook's failed
// deliveries are still stored.
func (t Totals) FailuresWithinRetention() int64 {
return t.Failures - t.FailuresRemoved
}
// AddTotals adds each count in add to the webhook's running totals.
// Call it on the transaction that writes or deletes the rows it
// counts, so the totals change exactly when those rows do.
func AddTotals(tx *gorm.DB, add Totals) error {
err := tx.Exec( err := tx.Exec(
`UPDATE event_totals SET `UPDATE totals SET
events = events + ?, events = events + ?,
events_removed = events_removed + ?`, deliveries = deliveries + ?,
add.Events, add.EventsRemoved, failures = failures + ?,
events_removed = events_removed + ?,
deliveries_removed = deliveries_removed + ?,
failures_removed = failures_removed + ?`,
add.Events, add.Deliveries, add.Failures,
add.EventsRemoved, add.DeliveriesRemoved, add.FailuresRemoved,
).Error ).Error
if err != nil { if err != nil {
return fmt.Errorf("adding to event totals: %w", err) return fmt.Errorf("adding to running totals: %w", err)
}
return nil
}
// AddTargetTotals adds each count in add to the totals of the target
// add.TargetID names, creating its row the first time. Call it on the
// transaction that writes or deletes the deliveries it counts.
func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
err := tx.Exec(
`INSERT INTO target_totals (target_id, deliveries, delivered,
failed, deliveries_removed, failed_removed)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (target_id) DO UPDATE SET
deliveries = deliveries + excluded.deliveries,
delivered = delivered + excluded.delivered,
failed = failed + excluded.failed,
deliveries_removed =
deliveries_removed + excluded.deliveries_removed,
failed_removed = failed_removed + excluded.failed_removed`,
add.TargetID, add.Deliveries, add.Delivered,
add.Failed, add.DeliveriesRemoved, add.FailedRemoved,
).Error
if err != nil {
return fmt.Errorf(
"adding to totals of target %s: %w", add.TargetID, err,
)
} }
return nil return nil
+1 -2
View File
@@ -2,8 +2,7 @@ package database
// Migrate runs database migrations for the main application database. // Migrate runs database migrations for the main application database.
// Only configuration-tier models are stored in the main database. // Only configuration-tier models are stored in the main database.
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals, // Event-tier models (Event, Delivery, DeliveryResult, Totals) live in
// TargetTotals) live in
// per-webhook dedicated databases managed by WebhookDBManager. // per-webhook dedicated databases managed by WebhookDBManager.
func (d *Database) Migrate() error { func (d *Database) Migrate() error {
return d.db.AutoMigrate( return d.db.AutoMigrate(
+90 -91
View File
@@ -18,13 +18,6 @@ import (
// computation. // computation.
const hoursPerDay = 24 const hoursPerDay = 24
// reapBatchSize is how many expired events one retention transaction
// deletes. A transaction holds the event database's write lock, which
// the receiver and the delivery workers wait for, so a large prune is
// split into transactions each short enough to finish well inside the
// busy timeout.
const reapBatchSize = 1000
// RetentionReaperParams holds the fx dependencies for the // RetentionReaperParams holds the fx dependencies for the
// RetentionReaper. // RetentionReaper.
type RetentionReaperParams struct { type RetentionReaperParams struct {
@@ -272,97 +265,103 @@ func retentionCutoff(
), true ), true
} }
// reapExpired hard-deletes the events older than cutoff, with their // reapExpired hard-deletes, in foreign-key-safe order, the delivery
// deliveries and delivery results, reapBatchSize events per // results, deliveries, and events associated with events older than
// transaction until none is left. It returns the number of events // cutoff, and adds what it deleted to the running totals, all in one
// deleted. // transaction. Deletes are unscoped so rows are physically removed
// rather than soft-deleted, reclaiming disk. It returns the number of
// events deleted.
func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) { func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) {
var total int64 var removed Totals
for {
var eventIDs []string
err := db.Transaction(func(tx *gorm.DB) error { err := db.Transaction(func(tx *gorm.DB) error {
err := tx.Unscoped().Model(&Event{}). var err error
Where("created_at < ?", cutoff).
Limit(reapBatchSize).
Pluck("id", &eventIDs).Error
if err != nil {
return fmt.Errorf("selecting expired events: %w", err)
}
if len(eventIDs) == 0 { removed, err = deleteExpired(tx, cutoff)
return nil
}
return deleteEvents(tx, eventIDs)
})
if err != nil {
return total, err
}
total += int64(len(eventIDs))
if len(eventIDs) < reapBatchSize {
return total, nil
}
}
}
// deleteEvents hard-deletes the given events and, in foreign-key-safe
// order before them, their delivery results and deliveries, then adds
// what it deleted to the running totals. It runs on reapExpired's
// transaction, so the totals change exactly when the rows do. Deletes
// are unscoped so rows are physically removed rather than
// soft-deleted, reclaiming disk.
func deleteEvents(tx *gorm.DB, eventIDs []string) error {
// 1. The delivery results of the events' deliveries.
err := tx.Unscoped().
Where("delivery_id IN (?)", tx.Unscoped().Model(&Delivery{}).
Select("id").
Where("event_id IN ?", eventIDs)).
Delete(&DeliveryResult{}).Error
if err != nil {
return fmt.Errorf("deleting expired delivery results: %w", err)
}
// 2. The events' deliveries, after counting them, and the failed
// ones among them, per target. The status is tested in the select
// list rather than the WHERE clause: there, SQLite would read every
// failed delivery the webhook has through the status index,
// instead of only these through the event_id index.
var removed []TargetTotals
err = tx.Unscoped().Model(&Delivery{}).
Select("target_id, count(*) AS deliveries_removed, "+
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
DeliveryStatusFailed).
Where("event_id IN ?", eventIDs).
Group("target_id").
Find(&removed).Error
if err != nil {
return fmt.Errorf("counting expired deliveries: %w", err)
}
err = tx.Unscoped().
Where("event_id IN ?", eventIDs).
Delete(&Delivery{}).Error
if err != nil {
return fmt.Errorf("deleting expired deliveries: %w", err)
}
// 3. The events themselves.
ev := tx.Unscoped().Where("id IN ?", eventIDs).Delete(&Event{})
if ev.Error != nil {
return fmt.Errorf("deleting expired events: %w", ev.Error)
}
for i := range removed {
err = AddTargetTotals(tx, removed[i])
if err != nil { if err != nil {
return err return err
} }
return AddTotals(tx, removed)
})
if err != nil {
return 0, err
} }
return AddEventTotals(tx, EventTotals{EventsRemoved: ev.RowsAffected}) return removed.EventsRemoved, nil
}
// deleteExpired runs reapExpired's deletes and returns how many
// events, deliveries and failed deliveries they removed.
func deleteExpired(tx *gorm.DB, cutoff time.Time) (Totals, error) {
var removed Totals
// Fresh subqueries are built per statement to avoid reusing a
// mutated builder across executions.
expiredEventIDs := func() *gorm.DB {
return tx.Model(&Event{}).
Select("id").
Where("created_at < ?", cutoff)
}
expiredDeliveryIDs := func() *gorm.DB {
return tx.Model(&Delivery{}).
Select("id").
Where("event_id IN (?)", expiredEventIDs())
}
// 1. Delivery results whose delivery belongs to an expired event.
res := tx.Unscoped().
Where("delivery_id IN (?)", expiredDeliveryIDs()).
Delete(&DeliveryResult{})
if res.Error != nil {
return removed, fmt.Errorf(
"deleting expired delivery results: %w",
res.Error,
)
}
// 2. Deliveries belonging to an expired event, after counting the
// failed ones among them. The status is tested in the select list
// rather than the WHERE clause: there, SQLite would read every
// failed delivery the webhook has through the status index,
// instead of only the expired ones through the event_id index.
var failed struct{ Count int64 }
err := tx.Unscoped().Model(&Delivery{}).
Select("count(CASE WHEN status = ? THEN 1 END) AS count",
DeliveryStatusFailed).
Where("event_id IN (?)", expiredEventIDs()).
Take(&failed).Error
if err != nil {
return removed, fmt.Errorf(
"counting expired failed deliveries: %w", err,
)
}
del := tx.Unscoped().
Where("event_id IN (?)", expiredEventIDs()).
Delete(&Delivery{})
if del.Error != nil {
return removed, fmt.Errorf(
"deleting expired deliveries: %w",
del.Error,
)
}
// 3. The expired events themselves.
ev := tx.Unscoped().
Where("created_at < ?", cutoff).
Delete(&Event{})
if ev.Error != nil {
return removed, fmt.Errorf(
"deleting expired events: %w",
ev.Error,
)
}
removed.EventsRemoved = ev.RowsAffected
removed.DeliveriesRemoved = del.RowsAffected
removed.FailuresRemoved = failed.Count
return removed, nil
} }
+49 -138
View File
@@ -2,7 +2,6 @@ package database_test
import ( import (
"context" "context"
"net/http"
"testing" "testing"
"time" "time"
@@ -13,12 +12,12 @@ import (
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
// readEventTotals reads a webhook database's row of event totals, // readTotals reads a webhook database's row of running totals,
// asserting that it has exactly one. // asserting that it has exactly one.
func readEventTotals(t *testing.T, db *gorm.DB) database.EventTotals { func readTotals(t *testing.T, db *gorm.DB) database.Totals {
t.Helper() t.Helper()
var rows []database.EventTotals var rows []database.Totals
require.NoError(t, db.Find(&rows).Error) require.NoError(t, db.Find(&rows).Error)
require.Len(t, rows, 1) require.Len(t, rows, 1)
@@ -26,30 +25,10 @@ func readEventTotals(t *testing.T, db *gorm.DB) database.EventTotals {
return rows[0] return rows[0]
} }
// readTargetTotals reads a webhook database's target totals, keyed by // TestWebhookDBManager_TotalsRowSurvivesReopen verifies that a new
// target. // event database starts with one row of zero totals, and that opening
func readTargetTotals( // it again keeps that row and what was added to it.
t *testing.T, db *gorm.DB, func TestWebhookDBManager_TotalsRowSurvivesReopen(t *testing.T) {
) map[string]database.TargetTotals {
t.Helper()
var rows []database.TargetTotals
require.NoError(t, db.Find(&rows).Error)
byTarget := make(map[string]database.TargetTotals, len(rows))
for _, row := range rows {
byTarget[row.TargetID] = row
}
return byTarget
}
// TestWebhookDBManager_TotalsSurviveReopen verifies that a new event
// database starts with one row of zero event totals and no target
// totals, that adding to a target twice adds to the one row, and that
// opening the database again keeps everything added.
func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) {
t.Parallel() t.Parallel()
mgr, lc := setupTestWebhookDBManager(t) mgr, lc := setupTestWebhookDBManager(t)
@@ -63,23 +42,11 @@ func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) {
db, err := mgr.GetDB(webhookID) db, err := mgr.GetDB(webhookID)
require.NoError(t, err) require.NoError(t, err)
fresh := readEventTotals(t, db) fresh := readTotals(t, db)
assert.Equal(t, database.EventTotals{ID: fresh.ID}, fresh) assert.Equal(t, database.Totals{ID: fresh.ID}, fresh)
assert.Empty(t, readTargetTotals(t, db))
first, second := uuid.New().String(), uuid.New().String() require.NoError(t, database.AddTotals(db, database.Totals{
Events: 2, Deliveries: 3, Failures: 1,
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
Events: 2,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: first, Deliveries: 2, Delivered: 1,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: first, Failed: 1,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: second, Deliveries: 1,
})) }))
// Drop the cached connection so the next open reopens the file, // Drop the cached connection so the next open reopens the file,
@@ -89,22 +56,16 @@ func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) {
db, err = mgr.GetDB(webhookID) db, err = mgr.GetDB(webhookID)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, database.EventTotals{ID: fresh.ID, Events: 2}, assert.Equal(t, database.Totals{
readEventTotals(t, db)) ID: fresh.ID, Events: 2, Deliveries: 3, Failures: 1,
assert.Equal(t, map[string]database.TargetTotals{ }, readTotals(t, db))
first: {
TargetID: first, Deliveries: 2, Delivered: 1, Failed: 1,
},
second: {TargetID: second, Deliveries: 1},
}, readTargetTotals(t, db))
} }
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune // TestRetentionReaper_AddsWhatItRemovesToTotals verifies that a sweep
// larger than one transaction's batch removes every expired event with // leaves the lifetime totals alone and adds the events, deliveries and
// its deliveries and delivery results, keeps the recent event, and // failed deliveries it deletes to the removed totals, so the totals
// adds what it removed to the event and target totals, so the totals
// within retention match the rows still stored. // within retention match the rows still stored.
func TestRetentionReaper_PrunesMoreThanOneBatch(t *testing.T) { func TestRetentionReaper_AddsWhatItRemovesToTotals(t *testing.T) {
t.Parallel() t.Parallel()
env := setupRetentionTest(t) env := setupRetentionTest(t)
@@ -114,102 +75,52 @@ func TestRetentionReaper_PrunesMoreThanOneBatch(t *testing.T) {
db, err := env.mgr.GetDB(webhookID) db, err := env.mgr.GetDB(webhookID)
require.NoError(t, err) require.NoError(t, err)
// Every expired event has a delivered delivery to one target and a now := time.Now()
// failed one to the other, each with one attempt. expired := now.Add(-40 * 24 * time.Hour)
expired := database.ExportReapBatchSize + 1
delivered, failed := uuid.New().String(), uuid.New().String()
old := time.Now().Add(-40 * 24 * time.Hour)
events := make([]database.Event, expired) seedEventChain(t, db, webhookID, expired)
deliveries := make([]database.Delivery, 0, 2*expired) expiredFailure := seedEventChain(t, db, webhookID, expired)
recentFailure := seedEventChain(
for i := range events { t, db, webhookID, now.Add(-24*time.Hour),
events[i] = database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
}
events[i].ID = uuid.New().String()
events[i].CreatedAt = old
deliveries = append(deliveries,
database.Delivery{
EventID: events[i].ID,
TargetID: delivered,
Status: database.DeliveryStatusDelivered,
},
database.Delivery{
EventID: events[i].ID,
TargetID: failed,
Status: database.DeliveryStatusFailed,
},
) )
}
require.NoError(t, db.CreateInBatches(events, 500).Error) for _, id := range []string{
require.NoError(t, db.CreateInBatches(deliveries, 500).Error) expiredFailure.deliveryID, recentFailure.deliveryID,
} {
results := make([]database.DeliveryResult, len(deliveries))
for i := range deliveries {
results[i] = database.DeliveryResult{
DeliveryID: deliveries[i].ID, AttemptNum: 1,
}
}
require.NoError(t, db.CreateInBatches(results, 500).Error)
// One recent event, delivered to the first target.
recent := seedEventChain(t, db, webhookID, time.Now())
require.NoError(t, db.Model(&database.Delivery{}). require.NoError(t, db.Model(&database.Delivery{}).
Where("id = ?", recent.deliveryID). Where("id = ?", id).
Update("target_id", delivered).Error) Update("status", database.DeliveryStatusFailed).Error)
}
// The totals storing those rows would have left. // The totals storing those rows would have left.
n := int64(expired) require.NoError(t, database.AddTotals(db, database.Totals{
require.NoError(t, database.AddEventTotals(db, database.EventTotals{ Events: 3, Deliveries: 3, Failures: 2,
Events: n + 1,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: failed, Deliveries: n, Failed: n,
})) }))
env.reaper.ExportSweep(context.Background()) env.reaper.ExportSweep(context.Background())
// Only the recent event's rows are left. totals := readTotals(t, db)
for _, model := range []any{ assert.Equal(t, database.Totals{
&database.Event{}, &database.Delivery{}, &database.DeliveryResult{}, ID: totals.ID,
} { Events: 3, Deliveries: 3, Failures: 2,
var count int64 EventsRemoved: 2, DeliveriesRemoved: 2, FailuresRemoved: 1,
}, totals)
require.NoError(t, db.Model(model).Count(&count).Error) var events, deliveries, failures int64
assert.Equal(t, int64(1), count, "%T rows left", model)
}
assertChainPresent(t, db, recent) require.NoError(t, db.Model(&database.Event{}).Count(&events).Error)
require.NoError(t, db.Model(&database.Delivery{}).
Count(&deliveries).Error)
require.NoError(t, db.Model(&database.Delivery{}).
Where("status = ?", database.DeliveryStatusFailed).
Count(&failures).Error)
eventTotals := readEventTotals(t, db) assert.Equal(t, events, totals.EventsWithinRetention())
assert.Equal(t, database.EventTotals{ assert.Equal(t, deliveries, totals.DeliveriesWithinRetention())
ID: eventTotals.ID, Events: n + 1, EventsRemoved: n, assert.Equal(t, failures, totals.FailuresWithinRetention())
}, eventTotals)
targetTotals := readTargetTotals(t, db)
assert.Equal(t, map[string]database.TargetTotals{
delivered: {
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
DeliveriesRemoved: n,
},
failed: {
TargetID: failed, Deliveries: n, Failed: n,
DeliveriesRemoved: n, FailedRemoved: n,
},
}, targetTotals)
// A sweep with nothing left to remove changes nothing. // A sweep with nothing left to remove changes nothing.
env.reaper.ExportSweep(context.Background()) env.reaper.ExportSweep(context.Background())
assert.Equal(t, eventTotals, readEventTotals(t, db)) assert.Equal(t, totals, readTotals(t, db))
assert.Equal(t, targetTotals, readTargetTotals(t, db))
} }
+5 -7
View File
@@ -36,7 +36,7 @@ var errInvalidCachedDBType = errors.New(
// WebhookDBManager manages per-webhook SQLite database files // WebhookDBManager manages per-webhook SQLite database files
// for event storage. Each webhook gets its own dedicated // for event storage. Each webhook gets its own dedicated
// database containing Events, Deliveries, DeliveryResults and the // database containing Events, Deliveries, DeliveryResults and the
// running totals of them (EventTotals, TargetTotals). // running Totals of them.
// Database connections are opened lazily and cached. // Database connections are opened lazily and cached.
type WebhookDBManager struct { type WebhookDBManager struct {
dataDir string dataDir string
@@ -295,8 +295,7 @@ func (m *WebhookDBManager) openDB(
// Run migrations for event-tier models only // Run migrations for event-tier models only
err = db.AutoMigrate( err = db.AutoMigrate(
&Event{}, &Delivery{}, &DeliveryResult{}, &Event{}, &Delivery{}, &DeliveryResult{}, &Totals{},
&EventTotals{}, &TargetTotals{},
) )
if err != nil { if err != nil {
_ = sqlDB.Close() _ = sqlDB.Close()
@@ -307,14 +306,13 @@ func (m *WebhookDBManager) openDB(
) )
} }
// A new database gets its row of event totals, all zero. Target // A new database gets its row of running totals, all zero.
// totals rows are created by the first delivery to each target. err = db.FirstOrCreate(&Totals{}).Error
err = db.FirstOrCreate(&EventTotals{}).Error
if err != nil { if err != nil {
_ = sqlDB.Close() _ = sqlDB.Close()
return nil, fmt.Errorf( return nil, fmt.Errorf(
"creating event totals for webhook database %s: %w", "creating running totals for webhook database %s: %w",
webhookID, err, webhookID, err,
) )
} }
+16 -32
View File
@@ -11,41 +11,32 @@ import (
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
// targetTotals reads one target's totals from a webhook database, all // failureTotal reads the running failure total of a webhook database.
// zero when it has no row. func failureTotal(t *testing.T, db *gorm.DB) int64 {
func targetTotals(
t *testing.T, db *gorm.DB, targetID string,
) database.TargetTotals {
t.Helper() t.Helper()
var rows []database.TargetTotals var totals database.Totals
require.NoError(t, db.Where("target_id = ?", targetID). require.NoError(t, db.Take(&totals).Error)
Find(&rows).Error)
if len(rows) == 0 { return totals.Failures
return database.TargetTotals{TargetID: targetID}
} }
return rows[0] // TestUpdateDeliveryStatus_FinishTimeAndFailureTotal pins what a status
}
// TestUpdateDeliveryStatus_FinishTimeAndTargetTotals pins what a status
// write records for the webhook page's statistics: the time a delivery // write records for the webhook page's statistics: the time a delivery
// finished, set only when it becomes delivered or failed, and one more // finished, set only when it becomes delivered or failed, and one more
// on its target's delivered or failed total. // on the failure total when it fails.
func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) { func TestUpdateDeliveryStatus_FinishTimeAndFailureTotal(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
status database.DeliveryStatus status database.DeliveryStatus
finished bool finished bool
delivered int64 failures int64
failed int64
}{ }{
{database.DeliveryStatusRetrying, false, 0, 0}, {database.DeliveryStatusRetrying, false, 0},
{database.DeliveryStatusDelivered, true, 1, 0}, {database.DeliveryStatusDelivered, true, 0},
{database.DeliveryStatusFailed, true, 0, 1}, {database.DeliveryStatusFailed, true, 1},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -55,9 +46,8 @@ func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
db := testWebhookDB(t) db := testWebhookDB(t)
e := testEngine(t, 1) e := testEngine(t, 1)
event := seedEvent(t, db, `{}`) event := seedEvent(t, db, `{}`)
targetID := uuid.New().String()
d := seedDelivery( d := seedDelivery(
t, db, event.ID, targetID, t, db, event.ID, uuid.New().String(),
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
@@ -79,11 +69,7 @@ func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
assert.Nil(t, stored.FinishedAt) assert.Nil(t, stored.FinishedAt)
} }
assert.Equal(t, database.TargetTotals{ assert.Equal(t, tt.failures, failureTotal(t, db))
TargetID: targetID,
Delivered: tt.delivered,
Failed: tt.failed,
}, targetTotals(t, db, targetID))
}) })
} }
} }
@@ -98,9 +84,8 @@ func TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted(t *testing.T) {
db := testWebhookDB(t) db := testWebhookDB(t)
e := testEngine(t, 1) e := testEngine(t, 1)
event := seedEvent(t, db, `{}`) event := seedEvent(t, db, `{}`)
targetID := uuid.New().String()
d := seedDelivery( d := seedDelivery(
t, db, event.ID, targetID, t, db, event.ID, uuid.New().String(),
database.DeliveryStatusRetrying, database.DeliveryStatusRetrying,
) )
@@ -111,6 +96,5 @@ func TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted(t *testing.T) {
db, &d, database.DeliveryStatusFailed, db, &d, database.DeliveryStatusFailed,
)) ))
assert.Equal(t, database.TargetTotals{TargetID: targetID}, assert.Zero(t, failureTotal(t, db))
targetTotals(t, db, targetID))
} }
+12 -15
View File
@@ -1576,33 +1576,30 @@ func (e *Engine) updateDeliveryStatus(
} }
// writeDeliveryStatus writes a delivery's new status. A delivery that // writeDeliveryStatus writes a delivery's new status. A delivery that
// becomes delivered or failed also gets the time it finished, and is // becomes delivered or failed also gets the time it finished, and a
// added to its target's delivered or failed total. It is counted only // failed one is added to the webhook's running failure total. The
// if the row was still there to update: retention may have deleted it // failure is counted only if the row was still there to update:
// while the engine was working on it. // retention may have deleted it while the engine was working on it.
func writeDeliveryStatus( func writeDeliveryStatus(
tx *gorm.DB, tx *gorm.DB,
d *database.Delivery, d *database.Delivery,
status database.DeliveryStatus, status database.DeliveryStatus,
) error { ) error {
if !status.Terminal() { columns := map[string]any{"status": status}
return tx.Model(d).Update("status", status).Error if status.Terminal() {
columns["finished_at"] = time.Now()
} }
res := tx.Model(d).Updates(map[string]any{ res := tx.Model(d).Updates(columns)
"status": status, if res.Error != nil {
"finished_at": time.Now(),
})
if res.Error != nil || res.RowsAffected == 0 {
return res.Error return res.Error
} }
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1} if status == database.DeliveryStatusFailed && res.RowsAffected > 0 {
if status == database.DeliveryStatusFailed { return database.AddTotals(tx, database.Totals{Failures: 1})
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
} }
return database.AddTargetTotals(tx, add) return nil
} }
// settleStatus moves a delivery to its outcome status and reports a // settleStatus moves a delivery to its outcome status and reports a
+2 -3
View File
@@ -57,10 +57,9 @@ func testWebhookDB(t *testing.T) *gorm.DB {
&database.Event{}, &database.Event{},
&database.Delivery{}, &database.Delivery{},
&database.DeliveryResult{}, &database.DeliveryResult{},
&database.EventTotals{}, &database.Totals{},
&database.TargetTotals{},
)) ))
require.NoError(t, db.Create(&database.EventTotals{}).Error) require.NoError(t, db.Create(&database.Totals{}).Error)
return db return db
} }
+3 -5
View File
@@ -300,8 +300,8 @@ func countInFlightDeliveries(
} }
// createReplayDelivery writes the new pending delivery row, adds it to // createReplayDelivery writes the new pending delivery row, adds it to
// its target's totals in the same transaction, and returns the task // the webhook's running totals in the same transaction, and returns
// that carries it to the delivery engine. // the task that carries it to the delivery engine.
// //
// The row is written with associations omitted, and neither Event nor // The row is written with associations omitted, and neither Event nor
// Target is populated on it: GORM's SaveBeforeAssociations would // Target is populated on it: GORM's SaveBeforeAssociations would
@@ -326,9 +326,7 @@ func createReplayDelivery(
return err return err
} }
return database.AddTargetTotals(tx, database.TargetTotals{ return database.AddTotals(tx, database.Totals{Deliveries: 1})
TargetID: dlv.TargetID, Deliveries: 1,
})
}) })
if err != nil { if err != nil {
return delivery.Task{}, err return delivery.Task{}, err
-10
View File
@@ -4,9 +4,7 @@ import (
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"time"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
@@ -86,14 +84,6 @@ func (s *Handlers) WebhookStatsForTest(webhookID string) *WebhookStats {
return s.loadWebhookStats(webhookID, entrypoints, targets) return s.loadWebhookStats(webhookID, entrypoints, targets)
} }
// FinishedByTargetForTest exposes finishedByTarget for use in the
// handlers_test package.
func FinishedByTargetForTest(
webhookDB *gorm.DB, since time.Time,
) ([]TargetFinished, error) {
return finishedByTarget(webhookDB, since)
}
// AddTemplateForTest registers a template under a page name so that // AddTemplateForTest registers a template under a page name so that
// the handlers_test package can drive the render path with a // the handlers_test package can drive the render path with a
// template of its own. // template of its own.
+6 -11
View File
@@ -297,7 +297,10 @@ func (h *Handlers) createAndFanOut(
return nil, nil, err return nil, nil, err
} }
err = database.AddEventTotals(tx, database.EventTotals{Events: 1}) err = database.AddTotals(tx, database.Totals{
Events: 1,
Deliveries: int64(len(tasks)),
})
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
@@ -362,9 +365,8 @@ func (h *Handlers) finishWebhookResponse(
} }
// buildDeliveryTasks creates one pending delivery per target in the // buildDeliveryTasks creates one pending delivery per target in the
// transaction, adds each to its target's totals, and returns the tasks // transaction and returns the tasks for the delivery engine. The
// for the delivery engine. The caller owns the transaction and rolls // caller owns the transaction and rolls it back on error.
// it back on error.
func buildDeliveryTasks( func buildDeliveryTasks(
tx *gorm.DB, tx *gorm.DB,
event *database.Event, event *database.Event,
@@ -388,13 +390,6 @@ func buildDeliveryTasks(
) )
} }
err = database.AddTargetTotals(tx, database.TargetTotals{
TargetID: targets[i].ID, Deliveries: 1,
})
if err != nil {
return nil, err
}
tasks = append(tasks, delivery.Task{ tasks = append(tasks, delivery.Task{
DeliveryID: dlv.ID, DeliveryID: dlv.ID,
EventID: event.ID, EventID: event.ID,
+26 -85
View File
@@ -26,10 +26,9 @@ type WebhookStats struct {
Targets int Targets int
ActiveTargets int ActiveTargets int
// Lifetime counts every event, delivery and failure the webhook // Totals holds the lifetime counts of events, deliveries and
// has had, and WithinRetention those still stored. // failures, and how many of each retention has removed.
Lifetime Counts Totals database.Totals
WithinRetention Counts
// InProgress counts the deliveries still pending or retrying. // InProgress counts the deliveries still pending or retrying.
InProgress int64 InProgress int64
@@ -42,14 +41,6 @@ type WebhookStats struct {
Last24Hours RecentWindow Last24Hours RecentWindow
} }
// Counts holds a number of events, of deliveries and of failed
// deliveries.
type Counts struct {
Events int64
Deliveries int64
Failures int64
}
// RecentWindow holds what happened in one recent window: the events // RecentWindow holds what happened in one recent window: the events
// received in it, and the deliveries that became delivered or failed in // received in it, and the deliveries that became delivered or failed in
// it. // it.
@@ -59,14 +50,6 @@ type RecentWindow struct {
Failed int64 Failed int64
} }
// TargetFinished is how many of one target's deliveries became
// delivered, and how many failed, in a recent window.
type TargetFinished struct {
TargetID string
Delivered int64
Failed int64
}
// FailurePercent is the share of the deliveries finished in the window // FailurePercent is the share of the deliveries finished in the window
// that failed, or a dash when none finished. Deliveries still pending // that failed, or a dash when none finished. Deliveries still pending
// or retrying are not counted either way. // or retrying are not counted either way.
@@ -133,15 +116,14 @@ func (h *Handlers) loadWebhookStats(
// readEventStats fills in the figures that come from the webhook's // readEventStats fills in the figures that come from the webhook's
// event database. None of them reads every stored row: the totals are // event database. None of them reads every stored row: the totals are
// one row for the events and one per target for the deliveries, and // one row, and every other figure is read from an index, over only the
// every other figure is read from an index, over only the rows it // rows it counts.
// counts.
func readEventStats( func readEventStats(
db *gorm.DB, now time.Time, stats *WebhookStats, db *gorm.DB, now time.Time, stats *WebhookStats,
) error { ) error {
err := readTotals(db, stats) err := db.Take(&stats.Totals).Error
if err != nil { if err != nil {
return err return fmt.Errorf("reading running totals: %w", err)
} }
err = db.Model(&database.Delivery{}). err = db.Model(&database.Delivery{}).
@@ -182,36 +164,6 @@ func readEventStats(
return err return err
} }
// readTotals fills in the lifetime and within-retention figures from
// the running totals: the events' row, and the targets' rows summed.
func readTotals(db *gorm.DB, stats *WebhookStats) error {
var events database.EventTotals
err := db.Take(&events).Error
if err != nil {
return fmt.Errorf("reading event totals: %w", err)
}
var targets []database.TargetTotals
err = db.Find(&targets).Error
if err != nil {
return fmt.Errorf("reading target totals: %w", err)
}
stats.Lifetime.Events = events.Events
stats.WithinRetention.Events = events.Events - events.EventsRemoved
for _, t := range targets {
stats.Lifetime.Deliveries += t.Deliveries
stats.Lifetime.Failures += t.Failed
stats.WithinRetention.Deliveries += t.Deliveries - t.DeliveriesRemoved
stats.WithinRetention.Failures += t.Failed - t.FailedRemoved
}
return nil
}
// readRecentWindow counts the events received, and the deliveries that // readRecentWindow counts the events received, and the deliveries that
// became delivered or failed, since the given time. // became delivered or failed, since the given time.
func readRecentWindow( func readRecentWindow(
@@ -226,46 +178,35 @@ func readRecentWindow(
return w, fmt.Errorf("counting recent events: %w", err) return w, fmt.Errorf("counting recent events: %w", err)
} }
byTarget, err := finishedByTarget(db, since) w.Delivered, err = countFinishedSince(
db, database.DeliveryStatusDelivered, since,
)
if err != nil { if err != nil {
return w, err return w, err
} }
for _, f := range byTarget { w.Failed, err = countFinishedSince(
w.Delivered += f.Delivered db, database.DeliveryStatusFailed, since,
w.Failed += f.Failed )
return w, err
} }
return w, nil // countFinishedSince counts the deliveries that reached the given
} // final status since the given time.
func countFinishedSince(
// finishedByTarget counts, for each target, the deliveries that became db *gorm.DB, status database.DeliveryStatus, since time.Time,
// delivered and those that failed since the given time, in one query ) (int64, error) {
// over just that window of the deliveries' status index. A target with var n int64
// neither is left out.
func finishedByTarget(
db *gorm.DB, since time.Time,
) ([]TargetFinished, error) {
var byTarget []TargetFinished
err := db.Model(&database.Delivery{}). err := db.Model(&database.Delivery{}).
Select("target_id, "+ Where("status = ? AND finished_at >= ?", status, since).
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+ Count(&n).Error
"count(CASE WHEN status = ? THEN 1 END) AS failed",
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed).
Where("status IN ? AND finished_at >= ?",
[]database.DeliveryStatus{
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed,
}, since).
Group("target_id").
Find(&byTarget).Error
if err != nil { if err != nil {
return nil, fmt.Errorf( return 0, fmt.Errorf(
"counting deliveries finished by target: %w", err, "counting deliveries %s recently: %w", status, err,
) )
} }
return byTarget, nil return n, nil
} }
+49 -153
View File
@@ -2,7 +2,6 @@ package handlers_test
import ( import (
"net/http" "net/http"
"strings"
"testing" "testing"
"time" "time"
@@ -36,10 +35,10 @@ func statsEntrypoint(
return ep.Path return ep.Path
} }
// statsDelivery returns an event's delivery to a target. // statsDelivery returns the id of an event's delivery to a target.
func statsDelivery( func statsDelivery(
t *testing.T, webhookDB *gorm.DB, eventID, targetID string, t *testing.T, webhookDB *gorm.DB, eventID, targetID string,
) database.Delivery { ) string {
t.Helper() t.Helper()
var d database.Delivery var d database.Delivery
@@ -48,36 +47,31 @@ func statsDelivery(
"event_id = ? AND target_id = ?", eventID, targetID, "event_id = ? AND target_id = ?", eventID, targetID,
).First(&d).Error) ).First(&d).Error)
return d return d.ID
} }
// statsFinish settles a delivery as the delivery engine does: its // statsFinish settles a delivery as the delivery engine does: its
// final status and the time it finished, and one more on its target's // final status and the time it finished, and for a failure one more on
// delivered or failed total, in one transaction. // the webhook's failure total, in one transaction.
func statsFinish( func statsFinish(
t *testing.T, t *testing.T,
webhookDB *gorm.DB, webhookDB *gorm.DB,
d database.Delivery, deliveryID string,
status database.DeliveryStatus, status database.DeliveryStatus,
at time.Time, at time.Time,
) { ) {
t.Helper() t.Helper()
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
if status == database.DeliveryStatusFailed {
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
}
require.NoError(t, webhookDB.Transaction(func(tx *gorm.DB) error { require.NoError(t, webhookDB.Transaction(func(tx *gorm.DB) error {
err := tx.Model(&database.Delivery{}). err := tx.Model(&database.Delivery{}).
Where("id = ?", d.ID). Where("id = ?", deliveryID).
Updates(map[string]any{"status": status, "finished_at": at}). Updates(map[string]any{"status": status, "finished_at": at}).
Error Error
if err != nil { if err != nil || status != database.DeliveryStatusFailed {
return err return err
} }
return database.AddTargetTotals(tx, add) return database.AddTotals(tx, database.Totals{Failures: 1})
})) }))
} }
@@ -92,48 +86,21 @@ func statsAge(
Update("created_at", at).Error) Update("created_at", at).Error)
} }
// statsTargetTotals reads a webhook database's target totals, keyed by
// target.
func statsTargetTotals(
t *testing.T, webhookDB *gorm.DB,
) map[string]database.TargetTotals {
t.Helper()
var rows []database.TargetTotals
require.NoError(t, webhookDB.Find(&rows).Error)
byTarget := make(map[string]database.TargetTotals, len(rows))
for _, row := range rows {
byTarget[row.TargetID] = row
}
return byTarget
}
// statsHistory is the webhook seedStatsHistory builds: its event
// database, its newest event, and its two active targets.
type statsHistory struct {
webhook *database.Webhook
webhookDB *gorm.DB
newest database.Event
first, second string
}
// seedStatsHistory builds the webhook the statistics test checks: one // seedStatsHistory builds the webhook the statistics test checks: one
// day of retention, two entrypoints (one inactive) and three targets // day of retention, two entrypoints (one inactive) and three targets
// (one inactive). Three events arrive through the receiver, and so // (one inactive). Three events arrive through the receiver, and so
// each has a delivery to the two active targets. The oldest event is // each has a delivery to the two active targets. The oldest event is
// past retention, the middle one six hours old, the newest just in. // past retention, the middle one six hours old, the newest just in.
// Their deliveries are settled as the delivery engine would, and a // Their deliveries are settled as the delivery engine would, and a
// replay adds a pending delivery to the oldest event. // replay adds a pending delivery to the oldest event. It returns the
// webhook, its event database and the newest event.
func seedStatsHistory( func seedStatsHistory(
t *testing.T, t *testing.T,
h *handlers.Handlers, h *handlers.Handlers,
sess *session.Session, sess *session.Session,
db *database.Database, db *database.Database,
dbMgr *database.WebhookDBManager, dbMgr *database.WebhookDBManager,
) statsHistory { ) (*database.Webhook, *gorm.DB, database.Event) {
t.Helper() t.Helper()
wh := &database.Webhook{ wh := &database.Webhook{
@@ -188,15 +155,9 @@ func seedStatsHistory(
database.DeliveryStatusDelivered, now.Add(-2*time.Minute)) database.DeliveryStatusDelivered, now.Add(-2*time.Minute))
require.Equal(t, http.StatusSeeOther, require.Equal(t, http.StatusSeeOther,
postReplay(t, h, sess, wh.ID, oldestFailure.ID).Code) postReplay(t, h, sess, wh.ID, oldestFailure).Code)
return statsHistory{ return wh, webhookDB, newest
webhook: wh,
webhookDB: webhookDB,
newest: newest,
first: first.ID,
second: second.ID,
}
} }
// statsPrune runs the real retention reaper until it has removed one // statsPrune runs the real retention reaper until it has removed one
@@ -223,7 +184,7 @@ func statsPrune(
lc.RequireStart() lc.RequireStart()
require.Eventually(t, func() bool { require.Eventually(t, func() bool {
var totals database.EventTotals var totals database.Totals
err := webhookDB.Take(&totals).Error err := webhookDB.Take(&totals).Error
@@ -233,23 +194,31 @@ func statsPrune(
lc.RequireStop() lc.RequireStop()
} }
// statsPane returns the statistics pane from a rendered webhook page: // assertStatsTotals checks the lifetime events, deliveries and
// everything from its heading to the next heading on the page. // failures, and those within retention.
func statsPane(t *testing.T, page string) string { func assertStatsTotals(
t *testing.T, totals database.Totals, lifetime, within [3]int64,
) {
t.Helper() t.Helper()
_, pane, found := strings.Cut(page, ">Statistics</h2>") gotLifetime := [3]int64{
require.True(t, found, "the page has no statistics pane") totals.Events, totals.Deliveries, totals.Failures,
}
gotWithin := [3]int64{
totals.EventsWithinRetention(),
totals.DeliveriesWithinRetention(),
totals.FailuresWithinRetention(),
}
pane, _, _ = strings.Cut(pane, "<h2") assert.Equal(t, lifetime, gotLifetime,
"lifetime events, deliveries, failures")
return pane assert.Equal(t, within, gotWithin,
"events, deliveries, failures within retention")
} }
// TestWebhookStats_EveryFigureAcrossRetentionPrune checks every figure // TestWebhookStats_EveryFigureAcrossRetentionPrune checks every figure
// the statistics pane shows for the history seedStatsHistory builds, // the statistics pane shows for the history seedStatsHistory builds,
// and each target's totals and recent figures, before and after the // before and after the real retention reaper removes the oldest event.
// real retention reaper removes the oldest event.
func TestWebhookStats_EveryFigureAcrossRetentionPrune(t *testing.T) { func TestWebhookStats_EveryFigureAcrossRetentionPrune(t *testing.T) {
t.Parallel() t.Parallel()
@@ -266,22 +235,19 @@ func TestWebhookStats_EveryFigureAcrossRetentionPrune(t *testing.T) {
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
hist := seedStatsHistory(t, h, sess, db, dbMgr) wh, webhookDB, newest := seedStatsHistory(t, h, sess, db, dbMgr)
first, second := hist.first, hist.second
stats := h.WebhookStatsForTest(hist.webhook.ID) stats := h.WebhookStatsForTest(wh.ID)
require.NotNil(t, stats) require.NotNil(t, stats)
assert.Equal(t, 2, stats.Entrypoints) assert.Equal(t, 2, stats.Entrypoints)
assert.Equal(t, 1, stats.ActiveEntrypoints) assert.Equal(t, 1, stats.ActiveEntrypoints)
assert.Equal(t, 3, stats.Targets) assert.Equal(t, 3, stats.Targets)
assert.Equal(t, 2, stats.ActiveTargets) assert.Equal(t, 2, stats.ActiveTargets)
assert.Equal(t, handlers.Counts{Events: 3, Deliveries: 7, Failures: 3}, assertStatsTotals(t, stats.Totals, [3]int64{3, 7, 3}, [3]int64{3, 7, 3})
stats.Lifetime)
assert.Equal(t, stats.Lifetime, stats.WithinRetention)
assert.Equal(t, int64(2), stats.InProgress) assert.Equal(t, int64(2), stats.InProgress)
require.NotNil(t, stats.LastEventAt) require.NotNil(t, stats.LastEventAt)
assert.True(t, hist.newest.CreatedAt.Equal(*stats.LastEventAt)) assert.True(t, newest.CreatedAt.Equal(*stats.LastEventAt))
assert.Equal(t, handlers.RecentWindow{ assert.Equal(t, handlers.RecentWindow{
Events: 1, Delivered: 1, Failed: 1, Events: 1, Delivered: 1, Failed: 1,
}, stats.Last10Minutes) }, stats.Last10Minutes)
@@ -291,95 +257,24 @@ func TestWebhookStats_EveryFigureAcrossRetentionPrune(t *testing.T) {
assert.Equal(t, "50.0%", stats.Last10Minutes.FailurePercent()) assert.Equal(t, "50.0%", stats.Last10Minutes.FailurePercent())
assert.Equal(t, "66.7%", stats.Last24Hours.FailurePercent()) assert.Equal(t, "66.7%", stats.Last24Hours.FailurePercent())
// The first target has three deliveries and the replay, the second // Retention removes the oldest event with its three deliveries,
// three; the inactive target has none and so no row. // one of them failed and one the pending replay.
assert.Equal(t, map[string]database.TargetTotals{ statsPrune(t, db, dbMgr, log, webhookDB)
first: {TargetID: first, Deliveries: 4, Delivered: 1, Failed: 2},
second: {
TargetID: second, Deliveries: 3, Delivered: 1, Failed: 1,
},
}, statsTargetTotals(t, hist.webhookDB))
lastDay, err := handlers.FinishedByTargetForTest( after := h.WebhookStatsForTest(wh.ID)
hist.webhookDB, time.Now().Add(-24*time.Hour),
)
require.NoError(t, err)
assert.ElementsMatch(t, []handlers.TargetFinished{
{TargetID: first, Delivered: 1, Failed: 1},
{TargetID: second, Failed: 1},
}, lastDay)
// Retention removes the oldest event with its three deliveries:
// the first target's failed one and the pending replay, and the
// second target's delivered one.
statsPrune(t, db, dbMgr, log, hist.webhookDB)
after := h.WebhookStatsForTest(hist.webhook.ID)
require.NotNil(t, after) require.NotNil(t, after)
assert.Equal(t, stats.Lifetime, after.Lifetime) assertStatsTotals(t, after.Totals, [3]int64{3, 7, 3}, [3]int64{2, 4, 2})
assert.Equal(t, handlers.Counts{Events: 2, Deliveries: 4, Failures: 2},
after.WithinRetention)
assert.Equal(t, int64(1), after.InProgress) assert.Equal(t, int64(1), after.InProgress)
assert.Equal(t, stats.LastEventAt, after.LastEventAt) assert.Equal(t, stats.LastEventAt, after.LastEventAt)
assert.Equal(t, stats.Last10Minutes, after.Last10Minutes) assert.Equal(t, stats.Last10Minutes, after.Last10Minutes)
assert.Equal(t, stats.Last24Hours, after.Last24Hours) assert.Equal(t, stats.Last24Hours, after.Last24Hours)
assert.Equal(t, map[string]database.TargetTotals{ body := renderSourceDetailPage(t, h, sess, wh.ID)
first: { assert.Contains(t, body, "Statistics")
TargetID: first, Deliveries: 4, Delivered: 1, Failed: 2, assert.Contains(t, body, "Within retention")
DeliveriesRemoved: 2, FailedRemoved: 1, assert.Contains(t, body, "50.0%")
}, assert.Contains(t, body, "66.7%")
second: {
TargetID: second, Deliveries: 3, Delivered: 1, Failed: 1,
DeliveriesRemoved: 1,
},
}, statsTargetTotals(t, hist.webhookDB))
pane := statsPane(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
assert.Contains(t, pane, "Within retention")
assert.Contains(t, pane, "50.0%")
assert.Contains(t, pane, "66.7%")
}
// TestWebhookStats_PaneShowsRetentionPeriod checks that the statistics
// pane itself, not only the line at the foot of the page, shows the
// webhook's retention period, for a finite one and for forever.
func TestWebhookStats_PaneShowsRetentionPeriod(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
)
app := newTestApp(t, &h, &sess, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
tests := []struct {
retentionDays int
want string
}{
{30, "30 days"},
{database.RetentionForeverDays, "forever"},
}
for _, tt := range tests {
wh := &database.Webhook{
UserID: deleteTestUserID,
Name: "retention",
RetentionDays: tt.retentionDays,
}
require.NoError(t,
db.DB().Omit(clause.Associations).Create(wh).Error)
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.Contains(t, pane, "Retention", tt.want)
assert.Contains(t, pane, tt.want)
}
} }
// TestWebhookStats_WebhookWithNoEvents covers a webhook whose event // TestWebhookStats_WebhookWithNoEvents covers a webhook whose event
@@ -406,7 +301,8 @@ func TestWebhookStats_WebhookWithNoEvents(t *testing.T) {
assert.Equal(t, &handlers.WebhookStats{}, h.WebhookStatsForTest(wh.ID)) assert.Equal(t, &handlers.WebhookStats{}, h.WebhookStatsForTest(wh.ID))
assert.Equal(t, "—", handlers.RecentWindow{}.FailurePercent()) assert.Equal(t, "—", handlers.RecentWindow{}.FailurePercent())
statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID)) body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(t, body, "Statistics")
assert.False(t, dbMgr.DBExists(wh.ID)) assert.False(t, dbMgr.DBExists(wh.ID))
} }
+3 -3
View File
@@ -13,9 +13,9 @@ import (
// TestBaseTemplateScriptsAreServed walks every /s/ script the base // TestBaseTemplateScriptsAreServed walks every /s/ script the base
// template loads on each page and fetches it through the real router. // template loads on each page and fetches it through the real router.
// Alpine.js is extracted from its tarball in 3p/ at build time, so the // Alpine.js is fetched at build time rather than committed, so nothing
// file is not in the tree: this is the check that the page still gets // in the repo guarantees it is present: this is the check that the page
// the JavaScript it asks for. // still gets the JavaScript it asks for.
func TestBaseTemplateScriptsAreServed(t *testing.T) { func TestBaseTemplateScriptsAreServed(t *testing.T) {
t.Parallel() t.Parallel()
-16
View File
@@ -1,16 +0,0 @@
#!/bin/sh
# script/assets: extract Alpine.js from its npm package tarball, committed
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The
# extracted file is not committed. script/test, make build and make dev run
# this first.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
tar -xzOf 3p/alpinejs-3.14.9.tgz package/dist/cdn.min.js \
>static/js/alpine.min.js
}
main "$@"
+8 -1
View File
@@ -4,7 +4,9 @@
# installed tools are skipped. Base tooling comes from nix, apt, brew, # installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes NOTHING is present (not git, # or apk (detected in that order); assumes NOTHING is present (not git,
# make, or go). golangci-lint is deliberately not installed: linting runs # make, or go). golangci-lint is deliberately not installed: linting runs
# only in docker, via script/lint and Dockerfile.lint. # only in docker, via script/lint and Dockerfile.lint. Finishes by running
# script/fetch-assets, which installs the hash-pinned third-party browser
# assets the repo does not commit.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -67,6 +69,11 @@ main() {
go mod download go mod download
# Third-party browser assets are not committed; fetch and verify them
# so a fresh clone can build and test.
if missing curl; then pkg_install curl curl curl curl; fi
"$ROOT/script/fetch-assets"
echo "bootstrap complete" echo "bootstrap complete"
} }
+1 -2
View File
@@ -1,7 +1,6 @@
#!/bin/sh #!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own # script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. # extension to scripts-to-rule-them-all. Must not modify any files.
# Writes only the ignored static/js/alpine.min.js, through script/test.
# Generic: usually needs no adaptation. # Generic: usually needs no adaptation.
set -eu set -eu
+104
View File
@@ -0,0 +1,104 @@
#!/bin/sh
# script/fetch-assets: download the third-party browser assets the web UI
# ships and install them under static/. Minified bundles are not committed
# (REPO_POLICIES.md: no build artifacts in version control), so the build
# fetches them here. Every download is verified against a hardcoded sha256
# before it is installed, and any mismatch aborts. Idempotent: an asset
# already present with its pinned hash is left alone.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# The sha256 of each installed asset lives in static/vendor.sha256, in
# sha256sum(1) format, with paths relative to static/. That file is the
# single source of truth: this script verifies against it, and
# static/vendor_test.go asserts the bytes embedded into the binary match
# it, so the hash cannot rot into a value nothing checks.
MANIFEST="static/vendor.sha256"
# Alpine.js 3.14.9, 2026-08-17. Fetched from registry.npmjs.org, the
# publisher of record; the jsDelivr and unpkg copies are mirrors of this
# same tarball. dist/cdn.min.js is the browser build Alpine publishes for
# a <script> tag.
ALPINE_VERSION="3.14.9"
ALPINE_URL="https://registry.npmjs.org/alpinejs/-/alpinejs-${ALPINE_VERSION}.tgz"
# sha256 of alpinejs-3.14.9.tgz
ALPINE_TARBALL_SHA256="97dad7c0c81e659cfc8e7700055da9770f8186187cb9a8a76efb57e00d5ce52a"
ALPINE_MEMBER="package/dist/cdn.min.js"
ALPINE_DEST="js/alpine.min.js"
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | cut -d' ' -f1
else
shasum -a 256 "$1" | cut -d' ' -f1
fi
}
# expected_sha256 <path-relative-to-static>
expected_sha256() {
awk -v want="$1" '$2 == want { print $1; found = 1 }
END { if (!found) exit 1 }' "$ROOT/$MANIFEST"
}
# verify <file> <expected-sha256> <what>
verify() {
actual="$(sha256_of "$1")"
if [ "$actual" != "$2" ]; then
echo "fetch-assets: sha256 mismatch for $3" >&2
echo " expected: $2" >&2
echo " actual: $actual" >&2
exit 1
fi
}
# up_to_date <path-relative-to-static> <expected-sha256>
up_to_date() {
[ -f "$ROOT/static/$1" ] || return 1
[ "$(sha256_of "$ROOT/static/$1")" = "$2" ]
}
fetch_alpine() {
want="$(expected_sha256 "$ALPINE_DEST")"
if up_to_date "$ALPINE_DEST" "$want"; then
echo "fetch-assets: static/$ALPINE_DEST already at $want"
return 0
fi
echo "fetch-assets: fetching Alpine.js $ALPINE_VERSION from $ALPINE_URL"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT INT TERM
curl -fsSL -o "$tmp/alpine.tgz" "$ALPINE_URL"
verify "$tmp/alpine.tgz" "$ALPINE_TARBALL_SHA256" "alpinejs-${ALPINE_VERSION}.tgz"
tar -xzOf "$tmp/alpine.tgz" "$ALPINE_MEMBER" >"$tmp/alpine.min.js"
verify "$tmp/alpine.min.js" "$want" "$ALPINE_MEMBER from alpinejs-${ALPINE_VERSION}.tgz"
mkdir -p "$(dirname "$ROOT/static/$ALPINE_DEST")"
cp "$tmp/alpine.min.js" "$ROOT/static/$ALPINE_DEST"
rm -rf "$tmp"
trap - EXIT INT TERM
echo "fetch-assets: installed static/$ALPINE_DEST ($want)"
}
# Re-check every manifest entry against what is now on disk, so an entry
# no script installs fails loudly instead of passing silently.
verify_manifest() {
while read -r want path; do
case "$want" in '' | '#'*) continue ;; esac
if [ ! -f "$ROOT/static/$path" ]; then
echo "fetch-assets: $MANIFEST lists static/$path, which is missing" >&2
exit 1
fi
verify "$ROOT/static/$path" "$want" "static/$path"
done <"$ROOT/$MANIFEST"
}
main() {
cd "$ROOT"
fetch_alpine
verify_manifest
echo "fetch-assets: all assets in $MANIFEST verified"
}
main "$@"
-1
View File
@@ -28,7 +28,6 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$ROOT/script/assets"
go test -v -race -timeout 90s ./... go test -v -race -timeout 90s ./...
} }
+1
View File
@@ -0,0 +1 @@
3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad3 js/alpine.min.js
+92
View File
@@ -0,0 +1,92 @@
package static_test
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"os"
"strings"
"testing"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/static"
)
const manifestPath = "vendor.sha256"
// fetchHint is appended to every failure here: the assets the manifest
// covers are fetched by the build, not committed, so a fresh clone that
// has not run script/fetch-assets fails this test and should be told why.
const fetchHint = "run `script/fetch-assets` (or `make assets`) to install " +
"the pinned third-party assets"
// TestVendoredAssetsMatchManifest asserts that every asset listed in
// static/vendor.sha256 is embedded in the binary with exactly the pinned
// bytes. script/fetch-assets verifies the same hashes at download time;
// this test verifies them again on what actually ships, so a build that
// skipped, cached, or subverted the fetch cannot produce a binary serving
// unpinned third-party JavaScript.
func TestVendoredAssetsMatchManifest(t *testing.T) {
t.Parallel()
entries := readManifest(t)
require.NotEmpty(t, entries, "%s lists no assets", manifestPath)
for path, want := range entries {
t.Run(path, func(t *testing.T) {
t.Parallel()
data, err := static.Static.ReadFile(path)
require.NoErrorf(
t, err,
"%s is listed in %s but is not embedded; %s",
path, manifestPath, fetchHint,
)
sum := sha256.Sum256(data)
got := hex.EncodeToString(sum[:])
require.Equalf(
t, want, got,
"embedded %s does not match its pinned sha256 in %s; %s",
path, manifestPath, fetchHint,
)
})
}
}
// readManifest parses static/vendor.sha256, which is in sha256sum(1)
// format with paths relative to static/.
func readManifest(t *testing.T) map[string]string {
t.Helper()
f, err := os.Open(manifestPath)
require.NoError(t, err, "opening %s", manifestPath)
defer func() { require.NoError(t, f.Close()) }()
entries := make(map[string]string)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
require.Lenf(
t, fields, 2,
"%s: malformed entry %q, want \"<sha256> <path>\"",
manifestPath, line,
)
sum, path := fields[0], fields[1]
require.Lenf(t, sum, 64, "%s: %q is not a sha256", manifestPath, sum)
entries[path] = sum
}
require.NoError(t, scanner.Err(), "reading %s", manifestPath)
return entries
}
+46 -52
View File
@@ -30,59 +30,53 @@
</div> </div>
</div> </div>
<div class="p-4 grid grid-cols-1 lg:grid-cols-2 gap-6 text-sm"> <div class="p-4 grid grid-cols-1 lg:grid-cols-2 gap-6 text-sm">
<table class="w-full text-center text-gray-900">
<thead>
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide">
<th></th>
<th class="py-2 font-medium">Lifetime</th>
<th class="py-2 font-medium">Within retention</th>
</tr>
</thead>
<tbody>
<tr>
<td class="py-2 text-left text-gray-600">Events</td>
<td class="py-2">{{.Lifetime.Events}}</td>
<td class="py-2">{{.WithinRetention.Events}}</td>
</tr>
<tr>
<td class="py-2 text-left text-gray-600">Deliveries</td>
<td class="py-2">{{.Lifetime.Deliveries}}</td>
<td class="py-2">{{.WithinRetention.Deliveries}}</td>
</tr>
<tr>
<td class="py-2 text-left text-gray-600">Failures</td>
<td class="py-2">{{.Lifetime.Failures}}</td>
<td class="py-2">{{.WithinRetention.Failures}}</td>
</tr>
</tbody>
</table>
<div> <div>
<table class="w-full text-center text-gray-900"> <div class="flex py-2 border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide">
<thead> <span class="flex-1"></span>
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide"> <span class="w-32 text-center">Lifetime</span>
<th></th> <span class="w-32 text-center">Within retention</span>
<th class="py-2 font-medium">Last 10 minutes</th> </div>
<th class="py-2 font-medium">Last 24 hours</th> <div class="divide-y divide-gray-100">
</tr> <div class="flex py-2">
</thead> <span class="flex-1 text-gray-600">Events</span>
<tbody> <span class="w-32 text-center text-gray-900">{{.Totals.Events}}</span>
<tr> <span class="w-32 text-center text-gray-900">{{.Totals.EventsWithinRetention}}</span>
<td class="py-2 text-left text-gray-600">Events</td> </div>
<td class="py-2">{{.Last10Minutes.Events}}</td> <div class="flex py-2">
<td class="py-2">{{.Last24Hours.Events}}</td> <span class="flex-1 text-gray-600">Deliveries</span>
</tr> <span class="w-32 text-center text-gray-900">{{.Totals.Deliveries}}</span>
<tr> <span class="w-32 text-center text-gray-900">{{.Totals.DeliveriesWithinRetention}}</span>
<td class="py-2 text-left text-gray-600">Failures</td> </div>
<td class="py-2">{{.Last10Minutes.Failed}}</td> <div class="flex py-2">
<td class="py-2">{{.Last24Hours.Failed}}</td> <span class="flex-1 text-gray-600">Failures</span>
</tr> <span class="w-32 text-center text-gray-900">{{.Totals.Failures}}</span>
<tr> <span class="w-32 text-center text-gray-900">{{.Totals.FailuresWithinRetention}}</span>
<td class="py-2 text-left text-gray-600">Failure percentage</td> </div>
<td class="py-2">{{.Last10Minutes.FailurePercent}}</td> </div>
<td class="py-2">{{.Last24Hours.FailurePercent}}</td> </div>
</tr> <div>
</tbody> <div class="flex py-2 border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide">
</table> <span class="flex-1"></span>
<span class="w-32 text-center">Last 10 minutes</span>
<span class="w-32 text-center">Last 24 hours</span>
</div>
<div class="divide-y divide-gray-100">
<div class="flex py-2">
<span class="flex-1 text-gray-600">Events</span>
<span class="w-32 text-center text-gray-900">{{.Last10Minutes.Events}}</span>
<span class="w-32 text-center text-gray-900">{{.Last24Hours.Events}}</span>
</div>
<div class="flex py-2">
<span class="flex-1 text-gray-600">Failures</span>
<span class="w-32 text-center text-gray-900">{{.Last10Minutes.Failed}}</span>
<span class="w-32 text-center text-gray-900">{{.Last24Hours.Failed}}</span>
</div>
<div class="flex py-2">
<span class="flex-1 text-gray-600">Failure percentage</span>
<span class="w-32 text-center text-gray-900">{{.Last10Minutes.FailurePercent}}</span>
<span class="w-32 text-center text-gray-900">{{.Last24Hours.FailurePercent}}</span>
</div>
</div>
<p class="mt-2 text-xs text-gray-500">Failure percentage is the failed deliveries out of all deliveries that finished in the window. Deliveries still pending or retrying are not counted.</p> <p class="mt-2 text-xs text-gray-500">Failure percentage is the failed deliveries out of all deliveries that finished in the window. Deliveries still pending or retrying are not counted.</p>
</div> </div>
</div> </div>