/metrics carried only the inbound HTTP surface, so a destination
failing for an hour, a growing retry backlog and a stuck-open circuit
breaker were all invisible: the receive side stays healthy in each
case because it is.
What landed
New internal/metrics registers these on the existing registry — prometheus.DefaultRegisterer, which is what metrics.NewRecorder(metrics.Config{}) in internal/middleware
defaults to and what promhttp.Handler() gathers. No second registry,
no second route; internal/server/routes.go is untouched.
Metric
Type
Definition of done item
webhooker_events_received_total
counter
events received
webhooker_delivery_attempts_total
counter
deliveries attempted
webhooker_deliveries_succeeded_total
counter
succeeded
webhooker_deliveries_failed_total
counter
failed-terminal
webhooker_delivery_retries_total
counter
retried
webhooker_delivery_duration_seconds
histogram
delivery duration
webhooker_deliveries_pending
gauge
currently pending
webhooker_deliveries_retrying
gauge
currently retrying
webhooker_circuit_breakers_open
gauge
breakers currently open
Cardinality
Every delivery metric carries exactly one label, target_type, whose
domain is the four target-type constants. A value outside that set
collapses to unknown rather than minting a series, so an unknown or
edited target type cannot grow the label set.
Target id is not a label. Its cardinality is not bounded: target
ids are UUIDs minted per operator action, a series is never reclaimed
once it exists, and a delete/recreate cycle leaks one forever. Event
id and entrypoint id are excluded for the same reason. A test asserts
the collapse (TestUnknownTargetTypeCollapses).
What is counted, and where
Attempts and duration — Engine.observeAttempt, called from the
paths that actually dispatched, alongside the DeliveryResult each
of them writes. HTTP and Slack report attemptResult.duration, the
same value that is persisted; the log and database targets time
their own work, so their result rows carry a real duration too.
A delivery an open circuit breaker refuses is deliberately not
an attempt: it sends nothing and records no result row. Counting it
would climb the attempts counter with no traffic behind it and fill
the duration histogram with near-zero samples, so the delivery
quantiles would improve for as long as the breaker stayed open —
the metric moving the wrong way during exactly the incident it
exists to reveal. It is already visible as a retry.
Outcomes — Engine.updateDeliveryStatus, once per transition
the engine persists, and only after the row is written. A status
write the database rejected is never reported as an outcome that
happened, which is the same reasoning that rules out delta-tracked
gauges below.
The type is passed to that helper as an argument rather than read
off Delivery.Target. failUnretryableRetry loads its delivery
without the target relation on purpose: populating d.Target makes
GORM's SaveBeforeAssociations upsert the whole targets row on
the status UPDATE, writing the plaintext target config — which for
a Slack target is the credential — into the per-webhook events-*.db. That is the leak class tracked at #206; TestFailUnretryableRetry_WritesNoTargetRow asserts that table
stays empty, and fails if the association is ever populated again.
Open breakers — httpCore.publishCircuitState, recounted from
that target type's breaker registry on every state change rather
than adjusted as a delta.
Queue depths — internal/delivery/queue_depth.go, counted out
of the per-webhook databases by a 30s sampler that runs with the
engine. Deltas were rejected: they would need seeding at startup
from rows a previous process wrote, and would drift permanently on
any transition that failed to persist.
Both gauges publish an unknown series, materialised at
registration rather than on first occurrence. A delivery queued
against a target that has since been deleted resolves to the empty
type and is folded there. That backlog is the one nobody is
watching, and it can predate the process, so its series has to exist
before a backlog has already built up.
Events received — Handlers.createAndDeliverEvent, after the
commit.
go.mod gains github.com/prometheus/client_model as a direct
dependency (it was already an indirect one, same version): the tests
read values back off a private registry. prometheus/testutil was not
used because it would have pulled in a module the repo does not have.
Tests
Required test — TestDeliveryMetrics_SuccessAndRetryExhaustion
(internal/delivery/metrics_test.go) drives one delivery that
succeeds and one that fails every attempt until its retry budget is
exhausted, asserting exact values for all four counters and the
histogram across both. Also:
TestFailUnretryableRetry_WritesNoTargetRow — the orphaned-retry
terminal path writes no targets row, and no target config, into
the per-webhook database.
TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt — a delivery an
open breaker refuses moves the retry counter and leaves the attempts
counter and duration histogram untouched.
TestDeliveryMetrics_OrphanedRetryFailureLabelled — that terminal
failure is still labelled with the target's real type.
TestDeliveryMetrics_CircuitBreakerGauge — gauge follows a breaker
that trips.
TestDeliveryMetrics_QueueDepthGauges / TestDeliveryMetrics_QueueDepthDeletedTarget — the sampler
publishes what is in the databases, a drained queue reads 0 rather
than holding its last value, and a backlog against a deleted target
shows up under unknown.
internal/metrics/metrics_test.go — cardinality collapse, the
unknown fold, gauge reset, status-to-counter mapping, and the series
that exist before any delivery.
Each delivery test gives the engine a set registered on a private
registry (Engine.ExportSetMetrics, defined in export_test.go and
so absent from the production binary), so assertions are exact rather
than being disturbed by the parallel delivery tests moving the
process-wide collectors.
Gate evidence
Full evidence, with host load averages, is in the rework comment: #224 (comment)
GOFLAGS=-count=1 make check on the rebased branch — exit 0, 0 issues. from the Docker lint stage, no (cached) package lines.
Cache-defeated container build, docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain .
— exit 0; lint stage ran 68.1s, builder make test ran 88.8s, zero (cached) markers in the go test output, and the only CACHED
layers are base-image and alpine ones. script/test runs -race; no
race reports.
The gate image was removed; docker ps -a is clean. No prune was run.
No live delivery was exercised by hand, so nothing here needed an
address outside the SSRF blocklist; the tests deliver to httptest servers through the engine's own path.
Closes https://git.eeqj.de/sneak/webhooker/issues/209.
`/metrics` carried only the inbound HTTP surface, so a destination
failing for an hour, a growing retry backlog and a stuck-open circuit
breaker were all invisible: the receive side stays healthy in each
case because it is.
## What landed
New `internal/metrics` registers these on the **existing** registry —
`prometheus.DefaultRegisterer`, which is what
`metrics.NewRecorder(metrics.Config{})` in `internal/middleware`
defaults to and what `promhttp.Handler()` gathers. No second registry,
no second route; `internal/server/routes.go` is untouched.
| Metric | Type | Definition of done item |
| ------ | ---- | ----------------------- |
| `webhooker_events_received_total` | counter | events received |
| `webhooker_delivery_attempts_total` | counter | deliveries attempted |
| `webhooker_deliveries_succeeded_total` | counter | succeeded |
| `webhooker_deliveries_failed_total` | counter | failed-terminal |
| `webhooker_delivery_retries_total` | counter | retried |
| `webhooker_delivery_duration_seconds` | histogram | delivery duration |
| `webhooker_deliveries_pending` | gauge | currently `pending` |
| `webhooker_deliveries_retrying` | gauge | currently `retrying` |
| `webhooker_circuit_breakers_open` | gauge | breakers currently open |
## Cardinality
Every delivery metric carries exactly one label, `target_type`, whose
domain is the four target-type constants. A value outside that set
collapses to `unknown` rather than minting a series, so an unknown or
edited target type cannot grow the label set.
Target id is **not** a label. Its cardinality is not bounded: target
ids are UUIDs minted per operator action, a series is never reclaimed
once it exists, and a delete/recreate cycle leaks one forever. Event
id and entrypoint id are excluded for the same reason. A test asserts
the collapse (`TestUnknownTargetTypeCollapses`).
## What is counted, and where
- **Attempts and duration** — `Engine.observeAttempt`, called from the
paths that actually dispatched, alongside the `DeliveryResult` each
of them writes. HTTP and Slack report `attemptResult.duration`, the
same value that is persisted; the log and database targets time
their own work, so their result rows carry a real duration too.
A delivery an open circuit breaker refuses is deliberately **not**
an attempt: it sends nothing and records no result row. Counting it
would climb the attempts counter with no traffic behind it and fill
the duration histogram with near-zero samples, so the delivery
quantiles would *improve* for as long as the breaker stayed open —
the metric moving the wrong way during exactly the incident it
exists to reveal. It is already visible as a retry.
- **Outcomes** — `Engine.updateDeliveryStatus`, once per transition
the engine persists, and only **after** the row is written. A status
write the database rejected is never reported as an outcome that
happened, which is the same reasoning that rules out delta-tracked
gauges below.
The type is passed to that helper as an argument rather than read
off `Delivery.Target`. `failUnretryableRetry` loads its delivery
without the target relation on purpose: populating `d.Target` makes
GORM's `SaveBeforeAssociations` upsert the whole `targets` row on
the status UPDATE, writing the plaintext target config — which for
a Slack target is the credential — into the per-webhook
`events-*.db`. That is the leak class tracked at
https://git.eeqj.de/sneak/webhooker/issues/206;
`TestFailUnretryableRetry_WritesNoTargetRow` asserts that table
stays empty, and fails if the association is ever populated again.
- **Open breakers** — `httpCore.publishCircuitState`, recounted from
that target type's breaker registry on every state change rather
than adjusted as a delta.
- **Queue depths** — `internal/delivery/queue_depth.go`, counted out
of the per-webhook databases by a 30s sampler that runs with the
engine. Deltas were rejected: they would need seeding at startup
from rows a previous process wrote, and would drift permanently on
any transition that failed to persist.
Both gauges publish an `unknown` series, materialised at
registration rather than on first occurrence. A delivery queued
against a target that has since been deleted resolves to the empty
type and is folded there. That backlog is the one nobody is
watching, and it can predate the process, so its series has to exist
before a backlog has already built up.
- **Events received** — `Handlers.createAndDeliverEvent`, after the
commit.
`go.mod` gains `github.com/prometheus/client_model` as a direct
dependency (it was already an indirect one, same version): the tests
read values back off a private registry. `prometheus/testutil` was not
used because it would have pulled in a module the repo does not have.
## Tests
Required test — `TestDeliveryMetrics_SuccessAndRetryExhaustion`
(`internal/delivery/metrics_test.go`) drives one delivery that
succeeds and one that fails every attempt until its retry budget is
exhausted, asserting exact values for all four counters and the
histogram across both. Also:
- `TestFailUnretryableRetry_WritesNoTargetRow` — the orphaned-retry
terminal path writes no `targets` row, and no target config, into
the per-webhook database.
- `TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt` — a delivery an
open breaker refuses moves the retry counter and leaves the attempts
counter and duration histogram untouched.
- `TestDeliveryMetrics_OrphanedRetryFailureLabelled` — that terminal
failure is still labelled with the target's real type.
- `TestDeliveryMetrics_CircuitBreakerGauge` — gauge follows a breaker
that trips.
- `TestDeliveryMetrics_QueueDepthGauges` /
`TestDeliveryMetrics_QueueDepthDeletedTarget` — the sampler
publishes what is in the databases, a drained queue reads 0 rather
than holding its last value, and a backlog against a deleted target
shows up under `unknown`.
- `internal/metrics/metrics_test.go` — cardinality collapse, the
unknown fold, gauge reset, status-to-counter mapping, and the series
that exist before any delivery.
Each delivery test gives the engine a set registered on a private
registry (`Engine.ExportSetMetrics`, defined in `export_test.go` and
so absent from the production binary), so assertions are exact rather
than being disturbed by the parallel delivery tests moving the
process-wide collectors.
## Gate evidence
Full evidence, with host load averages, is in the rework comment:
https://git.eeqj.de/sneak/webhooker/pulls/224#issuecomment-66922
`GOFLAGS=-count=1 make check` on the rebased branch — exit 0, `0
issues.` from the Docker lint stage, no `(cached)` package lines.
Cache-defeated container build,
`docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain .`
— exit 0; lint stage ran 68.1s, builder `make test` ran 88.8s, zero
`(cached)` markers in the `go test` output, and the only `CACHED`
layers are base-image and alpine ones. `script/test` runs `-race`; no
race reports.
The gate image was removed; `docker ps -a` is clean. No prune was run.
## Notes
- `TODO.md` untouched, per
https://git.eeqj.de/sneak/webhooker/issues/112.
- No live delivery was exercised by hand, so nothing here needed an
address outside the SSRF blocklist; the tests deliver to
`httptest` servers through the engine's own path.
/metrics carried only the inbound HTTP surface, so a destination
failing for an hour, a growing retry backlog and a stuck-open circuit
breaker were all invisible: the receive side stays healthy in each
case because it is.
New internal/metrics registers, on the existing default registry that
the go-http-metrics recorder and the promhttp handler already share:
- webhooker_events_received_total
- webhooker_delivery_attempts_total
- webhooker_deliveries_succeeded_total
- webhooker_deliveries_failed_total
- webhooker_delivery_retries_total
- webhooker_delivery_duration_seconds
- webhooker_deliveries_pending / _retrying
- webhooker_circuit_breakers_open
The route mounting is untouched.
Every delivery metric carries one label, target_type, whose domain is
the four target-type constants; anything outside it collapses to
"unknown" so no series can be minted from a UUID. Target ids, event
ids and entrypoint ids are deliberately not labels.
Instrumentation sits at the points every target type already passes
through: processDelivery for the attempt counter and the duration
histogram, updateDeliveryStatus for the outcome counters. The
queue-depth gauges are counted out of the per-webhook databases by a
30s sampler rather than tracked as deltas, which would need seeding at
startup and would drift on any transition that failed to persist. The
open-breaker gauge is recounted from the target's breaker registry on
every state change.
clawbot
self-assigned this 2026-08-20 06:27:27 +02:00
1. BLOCKING: d.Target = *target leaks target credentials into the per-webhook event DB
internal/delivery/engine.go:842 (failUnretryableRetry) newly assigns d.Target = *target so updateDeliveryStatus can read the type for a metrics label. The PR body calls this harmless. It is not.
updateDeliveryStatus does webhookDB.Model(d).Update("status", status). GORM runs SaveBeforeAssociations on the update callback chain, and it upserts a BelongsTo association whenever the association field is non-zero. failUnretryableRetry explicitly loads the delivery without its relation, so d.Target was zero here and no upsert fired. Populating it makes this path upsert the whole targets row — config included — into the per-webhook events-*.db.
That is precisely the credential leak tracked at #206 (open, 1.0.0 milestone), extended to a code path that did not previously have it. For a slack target the config is the bearer credential; for http it can carry userinfo.
Verified empirically, not inferred — isolated GORM probe against the same gorm.io/driver/sqlite and versions from this go.mod, mirroring the model shape, run in a container:
targets rows after zero-Target update: 0
targets rows after populated-Target update: 1
LEAKED ROW: id=t1 name=slack config={"webhookUrl":"https://hooks.slack.com/services/SECRET"}
A metrics change must not change what is written to the database. Acceptable: leave d alone and pass the type in — a target.Type argument to updateDeliveryStatus, or a typed variant — or scope the write with Omit(clause.Associations). Either way, a test that asserts failUnretryableRetry writes no targets row.
2. queue_depth.go comment contradicts the code; orphaned-target backlog vanishes
internal/delivery/queue_depth.go:123-126 claims "A delivery whose target has since been deleted resolves to the empty type and lands in the unknown bucket rather than being dropped." It is dropped. types[row.TargetID] yields TargetType(""), so the count accumulates under key "", and Set.SetQueueDepths (internal/metrics/metrics.go:180) only ever reads pending[t]/retrying[t] for the four knownTargetTypes. There is no unknown series for either queue gauge — initSeries creates known types only.
Net effect: deliveries queued against a deleted target are invisible in webhooker_deliveries_pending / _retrying, which is exactly the stuck backlog #209 asks to be alertable. Acceptable: fold the empty type into an unknown series (normalizeTargetType already exists and is used by every other setter) and materialise that series in initSeries, or delete the comment and state the loss — but silently dropping a backlog is the worse of the two.
3. Circuit-breaker-blocked non-attempts are counted as attempts, with ~0s durations
processDelivery increments webhooker_delivery_attempts_total and observes webhooker_delivery_duration_seconds around target.Deliver. When a breaker is open, httpCore.withRetry -> circuitBreakerBlock returns before any request is made. So with a breaker stuck open — one of the three named failure modes in #209 — the attempts counter climbs with zero network attempts, and the duration histogram fills with microsecond samples, making delivery_duration_seconds quantiles look better during the outage.
The engine already has the real per-attempt duration: attemptResult.duration, which recordResult persists. The histogram measures something else. Acceptable: observe only on the path that actually dispatched (or observe res.duration), and do not count a breaker-blocked delivery as an attempt.
4. Outcome counters move even when the transition is not persisted
internal/delivery/engine.go:944 increments before webhookDB.Model(d).Update(...) and unconditionally of its error. The PR body argues the gauges are counted rather than deltas precisely because "any transition that failed to persist" would drift — the counters have exactly that flaw. A failed status write leaves deliveries_failed_total claiming an outcome the database does not have, and the queue gauge (read from the DB) permanently disagreeing. Acceptable: count after a successful Update.
5. Minor: Engine.mx reads as a mutex
internal/delivery/engine.go:146. mx/mu is the near-universal Go shorthand for a mutex, and the same struct holds a sync.WaitGroup. mtr, met or metrics would not misdirect.
Checked and passing (no action): no unbounded value can reach a label — every WithLabelValues goes through normalizeTargetType or iterates knownTargetTypes, and the unknown collapse is operator- not sender-driven; registration is sync.OnceValue over prometheus.DefaultRegisterer so it cannot double-register under -count=2 or a twice-built graph; retry/failed-terminal counting is exclusive and once-only in handleRetry; the sampler is rooted at context.Background() per #97, is single-goroutine ticker-driven so it cannot pile up, and is bounded at shutdown by lifecycle.WaitForShutdown; TestDeliveryMetrics_SuccessAndRetryExhaustion asserts exact values and fails if the instrumentation is removed; go.mod moves only prometheus/client_model indirect-to-direct at the same version and testutil is avoided; TODO.md untouched; base is next; title ends with (closes #209); no Claude/Anthropic references or attribution trailers; no committed assets; merges cleanly into current next (bb30b3a).
internal/metrics/metrics.go was inspected line by line after the disclosed scripted block move: nine collector fields, all nine assigned exactly once across New/registerCounters/registerGauges, no duplicate or missing top-level declaration, no truncation, gofmt -s and golangci-lint clean. The move is intact.
Independently obtained gate, docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain, exit 0: lint stage golangci-lint run ran 139.9s, 0 issues; builder make test ran 174.6s; four CACHED lines total, all apt/base layers, none in lint or builder; zero (cached) markers in go test output. script/test runs -race, so the new sampler goroutine was exercised under the race detector — internal/delivery 7.245s, internal/metrics 1.171s, no race reports. Containers and images removed.
Two notes, neither charged to this PR. The tracker check on b8c8b75 is still pending, so CI is not green. And go test -count=2 ./internal/server/... panics with duplicate metrics collector registration attempted in middleware.Metrics -> go-http-metrics NewRecorder -> MustRegister; I reproduced it on plain origin/next at bb30b3a with this branch absent, so it is a pre-existing defect from #216 and needs its own issue, not a change here.
FAIL — `needs-rework`.
### 1. BLOCKING: `d.Target = *target` leaks target credentials into the per-webhook event DB
`internal/delivery/engine.go:842` (`failUnretryableRetry`) newly assigns `d.Target = *target` so `updateDeliveryStatus` can read the type for a metrics label. The PR body calls this harmless. It is not.
`updateDeliveryStatus` does `webhookDB.Model(d).Update("status", status)`. GORM runs `SaveBeforeAssociations` on the **update** callback chain, and it upserts a BelongsTo association whenever the association field is non-zero. `failUnretryableRetry` explicitly loads the delivery *without* its relation, so `d.Target` was zero here and no upsert fired. Populating it makes this path upsert the whole `targets` row — `config` included — into the per-webhook `events-*.db`.
That is precisely the credential leak tracked at https://git.eeqj.de/sneak/webhooker/issues/206 (open, 1.0.0 milestone), extended to a code path that did not previously have it. For a `slack` target the `config` is the bearer credential; for `http` it can carry userinfo.
Verified empirically, not inferred — isolated GORM probe against the same `gorm.io/driver/sqlite` and versions from this `go.mod`, mirroring the model shape, run in a container:
```
targets rows after zero-Target update: 0
targets rows after populated-Target update: 1
LEAKED ROW: id=t1 name=slack config={"webhookUrl":"https://hooks.slack.com/services/SECRET"}
```
A metrics change must not change what is written to the database. Acceptable: leave `d` alone and pass the type in — a `target.Type` argument to `updateDeliveryStatus`, or a typed variant — or scope the write with `Omit(clause.Associations)`. Either way, a test that asserts `failUnretryableRetry` writes no `targets` row.
### 2. `queue_depth.go` comment contradicts the code; orphaned-target backlog vanishes
`internal/delivery/queue_depth.go:123-126` claims "A delivery whose target has since been deleted resolves to the empty type and lands in the unknown bucket rather than being dropped." It is dropped. `types[row.TargetID]` yields `TargetType("")`, so the count accumulates under key `""`, and `Set.SetQueueDepths` (`internal/metrics/metrics.go:180`) only ever reads `pending[t]`/`retrying[t]` for the four `knownTargetTypes`. There is no `unknown` series for either queue gauge — `initSeries` creates known types only.
Net effect: deliveries queued against a deleted target are invisible in `webhooker_deliveries_pending` / `_retrying`, which is exactly the stuck backlog https://git.eeqj.de/sneak/webhooker/issues/209 asks to be alertable. Acceptable: fold the empty type into an `unknown` series (`normalizeTargetType` already exists and is used by every other setter) and materialise that series in `initSeries`, or delete the comment and state the loss — but silently dropping a backlog is the worse of the two.
### 3. Circuit-breaker-blocked non-attempts are counted as attempts, with ~0s durations
`processDelivery` increments `webhooker_delivery_attempts_total` and observes `webhooker_delivery_duration_seconds` around `target.Deliver`. When a breaker is open, `httpCore.withRetry` -> `circuitBreakerBlock` returns before any request is made. So with a breaker stuck open — one of the three named failure modes in https://git.eeqj.de/sneak/webhooker/issues/209 — the attempts counter climbs with zero network attempts, and the duration histogram fills with microsecond samples, making `delivery_duration_seconds` quantiles look **better** during the outage.
The engine already has the real per-attempt duration: `attemptResult.duration`, which `recordResult` persists. The histogram measures something else. Acceptable: observe only on the path that actually dispatched (or observe `res.duration`), and do not count a breaker-blocked delivery as an attempt.
### 4. Outcome counters move even when the transition is not persisted
`internal/delivery/engine.go:944` increments before `webhookDB.Model(d).Update(...)` and unconditionally of its error. The PR body argues the *gauges* are counted rather than deltas precisely because "any transition that failed to persist" would drift — the counters have exactly that flaw. A failed status write leaves `deliveries_failed_total` claiming an outcome the database does not have, and the queue gauge (read from the DB) permanently disagreeing. Acceptable: count after a successful `Update`.
### 5. Minor: `Engine.mx` reads as a mutex
`internal/delivery/engine.go:146`. `mx`/`mu` is the near-universal Go shorthand for a mutex, and the same struct holds a `sync.WaitGroup`. `mtr`, `met` or `metrics` would not misdirect.
---
Checked and passing (no action): no unbounded value can reach a label — every `WithLabelValues` goes through `normalizeTargetType` or iterates `knownTargetTypes`, and the `unknown` collapse is operator- not sender-driven; registration is `sync.OnceValue` over `prometheus.DefaultRegisterer` so it cannot double-register under `-count=2` or a twice-built graph; retry/failed-terminal counting is exclusive and once-only in `handleRetry`; the sampler is rooted at `context.Background()` per https://git.eeqj.de/sneak/webhooker/issues/97, is single-goroutine ticker-driven so it cannot pile up, and is bounded at shutdown by `lifecycle.WaitForShutdown`; `TestDeliveryMetrics_SuccessAndRetryExhaustion` asserts exact values and fails if the instrumentation is removed; `go.mod` moves only `prometheus/client_model` indirect-to-direct at the same version and `testutil` is avoided; `TODO.md` untouched; base is `next`; title ends with `(closes #209)`; no Claude/Anthropic references or attribution trailers; no committed assets; merges cleanly into current `next` (`bb30b3a`).
`internal/metrics/metrics.go` was inspected line by line after the disclosed scripted block move: nine collector fields, all nine assigned exactly once across `New`/`registerCounters`/`registerGauges`, no duplicate or missing top-level declaration, no truncation, `gofmt -s` and `golangci-lint` clean. The move is intact.
Independently obtained gate, `docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain`, exit 0: lint stage `golangci-lint run` ran 139.9s, `0 issues`; builder `make test` ran 174.6s; four `CACHED` lines total, all apt/base layers, none in `lint` or `builder`; zero `(cached)` markers in `go test` output. `script/test` runs `-race`, so the new sampler goroutine was exercised under the race detector — `internal/delivery 7.245s`, `internal/metrics 1.171s`, no race reports. Containers and images removed.
Two notes, neither charged to this PR. The tracker check on `b8c8b75` is still `pending`, so CI is not green. And `go test -count=2 ./internal/server/...` panics with `duplicate metrics collector registration attempted` in `middleware.Metrics` -> `go-http-metrics NewRecorder` -> `MustRegister`; I reproduced it on plain `origin/next` at `bb30b3a` with this branch absent, so it is a pre-existing defect from https://git.eeqj.de/sneak/webhooker/pulls/216 and needs its own issue, not a change here.
Reworked, force-pushed as one commit dfd5594, rebased onto next at a13e5b7.
1 (blocking) — credential leak: fixed at the source
d.Target = *target is gone. updateDeliveryStatus now takes the
target type as an argument; failUnretryableRetry passes target.Type and leaves the delivery loaded without its relation, as
that path intends. recordResult is unchanged, so nothing else on
that path can trigger SaveBeforeAssociations.
Regression test: TestFailUnretryableRetry_WritesNoTargetRow
(internal/delivery/engine_integration_test.go) drives the sweep
against a target whose config is a Slack incoming-webhook URL, then
asserts events-*.db holds zero targets rows and that the URL does
not appear in that table.
Proof the test catches the defect rather than merely passing — the
mutation was reintroduced on the fixed tree and the suite re-run:
--- FAIL: TestFailUnretryableRetry_WritesNoTargetRow (1.14s)
Messages: orphaned-retry terminal failure wrote a target row
into the per-webhook event database
Error: "{\"url\":\"https://hooks.slack.com/services/T00/B00/x\"}"
should not contain "https://hooks.slack.com/services/T00/B00/x"
and the write itself, from the per-webhook connection's SQL log:
INSERT INTO `targets` (...,`name`,`type`,`active`,`config`,...)
VALUES ("6b75b6b9-...","credential-bearing","log",true,
"{\"url\":\"https://hooks.slack.com/services/T00/B00/x\"}",5,0)
ON CONFLICT DO NOTHING
The mutation was then reverted; the pushed tree does not contain it.
The fix does not depend on #223 — no Omit is used, the
association is simply never populated.
2 — queue depth: emitting unknown, not dropping
Set.SetQueueDepths folds counts through normalizeTargetType
(summing, so several unknown types do not overwrite each other) and
writes the whole domain including unknown on every sample, so a
drained unknown bucket reads 0. initSeries materialises the two unknown queue series at registration — that backlog is read out of
the databases and can predate the process, so the series has to exist
before the first sample. The false comment in queue_depth.go is
replaced with what the code does.
Tests: TestSetQueueDepthsFoldsUnknownTypes (internal/metrics) and TestDeliveryMetrics_QueueDepthDeletedTarget (end to end through the
sampler, no target row in the main DB).
3 — duration histogram: dispatched attempts only
Both the attempts counter and the duration histogram moved off processDelivery into Engine.observeAttempt, called from the paths
that actually dispatched, next to the DeliveryResult each records.
HTTP and Slack pass attemptResult.duration — the same value recordResult persists. The log and database targets now time their
own work instead of reporting 0, so their result rows carry a real
duration too.
Decision, stated explicitly: a breaker-blocked delivery is not an
attempt and gets no new series. It sends nothing, records no result
row, and is already counted as a retry by webhooker_delivery_retries_total — which is the series that climbs
while a breaker is stuck open.
Test: TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt trips the
breaker, then drives one more delivery and asserts the retry counter
moved while attempts and histogram sample count did not.
4 — counters after the write
updateDeliveryStatus returns on a failed Update and increments
only after the row is written.
5 — mx
Renamed to mtr on Engine, and on Handlers for consistency.
Out of scope
-count=2 in internal/server
(#227) untouched. TODO.md
untouched.
Gate
Host load average during the runs: 38.65 rising to 67.55 (1-minute)
across the make check run; 72.73 falling to 43.51 across the
container build.
GOFLAGS=-count=1 make check — exit 0, no (cached) package lines:
ok sneak.berlin/go/webhooker/internal/delivery 5.851s
ok sneak.berlin/go/webhooker/internal/metrics 1.103s
ok sneak.berlin/go/webhooker/internal/handlers 34.676s
ok sneak.berlin/go/webhooker/internal/server 5.790s
#11 70.42 0 issues.
EXIT=0
docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain .
— exit 0. Lint stage #20 DONE 68.1s with 0 issues.; builder make test#28 DONE 88.8s; zero (cached) markers anywhere in the go test output. Five CACHED lines total, all base-image and
alpine layers (#6, #7, #11, #12, #13) — none in lint or builder. script/test runs -race; no race reports.
New and changed tests, from that uncached builder run:
The gate image was removed; docker ps -a is clean. No prune was run.
Reworked, force-pushed as one commit `dfd5594`, rebased onto `next` at `a13e5b7`.
### 1 (blocking) — credential leak: fixed at the source
`d.Target = *target` is gone. `updateDeliveryStatus` now takes the
target type as an argument; `failUnretryableRetry` passes
`target.Type` and leaves the delivery loaded without its relation, as
that path intends. `recordResult` is unchanged, so nothing else on
that path can trigger `SaveBeforeAssociations`.
Regression test: `TestFailUnretryableRetry_WritesNoTargetRow`
(`internal/delivery/engine_integration_test.go`) drives the sweep
against a target whose config is a Slack incoming-webhook URL, then
asserts `events-*.db` holds zero `targets` rows and that the URL does
not appear in that table.
Proof the test catches the defect rather than merely passing — the
mutation was reintroduced on the fixed tree and the suite re-run:
```
--- FAIL: TestFailUnretryableRetry_WritesNoTargetRow (1.14s)
Messages: orphaned-retry terminal failure wrote a target row
into the per-webhook event database
Error: "{\"url\":\"https://hooks.slack.com/services/T00/B00/x\"}"
should not contain "https://hooks.slack.com/services/T00/B00/x"
```
and the write itself, from the per-webhook connection's SQL log:
```
INSERT INTO `targets` (...,`name`,`type`,`active`,`config`,...)
VALUES ("6b75b6b9-...","credential-bearing","log",true,
"{\"url\":\"https://hooks.slack.com/services/T00/B00/x\"}",5,0)
ON CONFLICT DO NOTHING
```
The mutation was then reverted; the pushed tree does not contain it.
The fix does not depend on
https://git.eeqj.de/sneak/webhooker/pulls/223 — no `Omit` is used, the
association is simply never populated.
### 2 — queue depth: emitting `unknown`, not dropping
`Set.SetQueueDepths` folds counts through `normalizeTargetType`
(summing, so several unknown types do not overwrite each other) and
writes the whole domain including `unknown` on every sample, so a
drained unknown bucket reads 0. `initSeries` materialises the two
`unknown` queue series at registration — that backlog is read out of
the databases and can predate the process, so the series has to exist
before the first sample. The false comment in `queue_depth.go` is
replaced with what the code does.
Tests: `TestSetQueueDepthsFoldsUnknownTypes` (`internal/metrics`) and
`TestDeliveryMetrics_QueueDepthDeletedTarget` (end to end through the
sampler, no target row in the main DB).
### 3 — duration histogram: dispatched attempts only
Both the attempts counter and the duration histogram moved off
`processDelivery` into `Engine.observeAttempt`, called from the paths
that actually dispatched, next to the `DeliveryResult` each records.
HTTP and Slack pass `attemptResult.duration` — the same value
`recordResult` persists. The log and database targets now time their
own work instead of reporting 0, so their result rows carry a real
duration too.
Decision, stated explicitly: a breaker-blocked delivery is **not** an
attempt and gets no new series. It sends nothing, records no result
row, and is already counted as a retry by
`webhooker_delivery_retries_total` — which is the series that climbs
while a breaker is stuck open.
Test: `TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt` trips the
breaker, then drives one more delivery and asserts the retry counter
moved while attempts and histogram sample count did not.
### 4 — counters after the write
`updateDeliveryStatus` returns on a failed `Update` and increments
only after the row is written.
### 5 — `mx`
Renamed to `mtr` on `Engine`, and on `Handlers` for consistency.
### Out of scope
`-count=2` in `internal/server`
(https://git.eeqj.de/sneak/webhooker/issues/227) untouched. `TODO.md`
untouched.
### Gate
Host load average during the runs: 38.65 rising to 67.55 (1-minute)
across the `make check` run; 72.73 falling to 43.51 across the
container build.
`GOFLAGS=-count=1 make check` — exit 0, no `(cached)` package lines:
```
ok sneak.berlin/go/webhooker/internal/delivery 5.851s
ok sneak.berlin/go/webhooker/internal/metrics 1.103s
ok sneak.berlin/go/webhooker/internal/handlers 34.676s
ok sneak.berlin/go/webhooker/internal/server 5.790s
#11 70.42 0 issues.
EXIT=0
```
`docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain .`
— exit 0. Lint stage `#20 DONE 68.1s` with `0 issues.`; builder
`make test` `#28 DONE 88.8s`; zero `(cached)` markers anywhere in the
`go test` output. Five `CACHED` lines total, all base-image and
alpine layers (`#6`, `#7`, `#11`, `#12`, `#13`) — none in `lint` or
`builder`. `script/test` runs `-race`; no race reports.
New and changed tests, from that uncached builder run:
```
--- PASS: TestFailUnretryableRetry_WritesNoTargetRow (2.34s)
--- PASS: TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt (0.71s)
--- PASS: TestDeliveryMetrics_OrphanedRetryFailureLabelled (0.68s)
--- PASS: TestDeliveryMetrics_QueueDepthDeletedTarget (1.07s)
--- PASS: TestSetQueueDepthsFoldsUnknownTypes (0.00s)
--- PASS: TestKnownSeriesExistBeforeAnyDelivery (0.01s)
```
The gate image was removed; `docker ps -a` is clean. No prune was run.
PASS — merge-ready. All five prior findings fixed; leak closure reproduced independently, gate re-run green on dfd5594.
Anomalies and disclosures:
The tracker check on dfd5594 is pending ("Waiting to run"), not green — queued, not red. Per #119 the check mark is not evidence anyway; the authoritative gate was obtained locally: docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain . exit 0, lint #20 DONE 68.8s with 0 issues., builder make test#31 DONE 94.0s, zero (cached) markers, zero FAIL/DATA RACE, no BuildKit log clipping (1.88 MB). Host load 61-70 throughout. Image removed, docker ps -a clean, no prune run.
Of the 8 CACHED layers, three are inside the builder stage (COPY --from=lint /src/go.sum, the apt-get, WORKDIR) despite --no-cache-filter=builder. The two layers that matter — golangci-lint run and make test — both ran with real durations and full output.
Leak closure verified independently of #223: this tree contains no Omit(clause.Associations) and no GORM callback registration, so the fix rests solely on never populating the association. Reintroducing d.Target = *target in failUnretryableRetry on the pushed tree makes TestFailUnretryableRetry_WritesNoTargetRow fail with the INSERT INTO + "targets" + ... ON CONFLICT DO NOTHING carrying the Slack URL; the pushed tree passes. If 223 were reverted, this path still writes no target row.
Pre-existing, NOT this PR: internal/delivery/engine.go:447-448 (processRetryTask) still sets d.Event / d.Target from the task before the same Update, so the ordinary retry path does upsert a targets row into the per-webhook DB on next today. That is #206 itself, untouched by this change, and correctly out of scope here — noting it only so the green regression test is not read as proving the whole class is closed.
The decision that a breaker-blocked delivery is not an attempt is correct and it does not become invisible: circuitBreakerBlock persists retrying, which increments webhooker_delivery_retries_total, the sampler carries it in webhooker_deliveries_retrying, and the new defer publishCircuitState sets webhooker_circuit_breakers_open. Three series move; only delivery_attempts_total and the duration histogram, which measure dispatched traffic, correctly do not.
Deviation: the mutation probe and the -count=2 run (internal/metrics, internal/delivery, internal/middleware — all ok under -race) were run as go test inside the pinned golang:1.26.1-bookworm container, since no make target runs a single test or -count=2. All linting was via the Docker gate only; nothing was run on the host.
PASS — `merge-ready`. All five prior findings fixed; leak closure reproduced independently, gate re-run green on `dfd5594`.
Anomalies and disclosures:
- The tracker check on `dfd5594` is `pending` ("Waiting to run"), not green — queued, not red. Per https://git.eeqj.de/sneak/webhooker/issues/119 the check mark is not evidence anyway; the authoritative gate was obtained locally: `docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain .` exit 0, lint `#20 DONE 68.8s` with `0 issues.`, builder `make test` `#31 DONE 94.0s`, zero `(cached)` markers, zero `FAIL`/`DATA RACE`, no BuildKit log clipping (1.88 MB). Host load 61-70 throughout. Image removed, `docker ps -a` clean, no prune run.
- Of the 8 `CACHED` layers, three are inside the `builder` stage (`COPY --from=lint /src/go.sum`, the apt-get, `WORKDIR`) despite `--no-cache-filter=builder`. The two layers that matter — `golangci-lint run` and `make test` — both ran with real durations and full output.
- Leak closure verified independently of https://git.eeqj.de/sneak/webhooker/pulls/223: this tree contains no `Omit(clause.Associations)` and no GORM callback registration, so the fix rests solely on never populating the association. Reintroducing `d.Target = *target` in `failUnretryableRetry` on the pushed tree makes `TestFailUnretryableRetry_WritesNoTargetRow` fail with the `INSERT INTO ` + "`targets`" + ` ... ON CONFLICT DO NOTHING` carrying the Slack URL; the pushed tree passes. If 223 were reverted, this path still writes no target row.
- Pre-existing, NOT this PR: `internal/delivery/engine.go:447-448` (`processRetryTask`) still sets `d.Event` / `d.Target` from the task before the same `Update`, so the ordinary retry path does upsert a `targets` row into the per-webhook DB on `next` today. That is https://git.eeqj.de/sneak/webhooker/issues/206 itself, untouched by this change, and correctly out of scope here — noting it only so the green regression test is not read as proving the whole class is closed.
- The decision that a breaker-blocked delivery is not an attempt is correct and it does not become invisible: `circuitBreakerBlock` persists `retrying`, which increments `webhooker_delivery_retries_total`, the sampler carries it in `webhooker_deliveries_retrying`, and the new `defer publishCircuitState` sets `webhooker_circuit_breakers_open`. Three series move; only `delivery_attempts_total` and the duration histogram, which measure dispatched traffic, correctly do not.
- Deviation: the mutation probe and the `-count=2` run (`internal/metrics`, `internal/delivery`, `internal/middleware` — all `ok` under `-race`) were run as `go test` inside the pinned `golang:1.26.1-bookworm` container, since no make target runs a single test or `-count=2`. All linting was via the Docker gate only; nothing was run on the host.
clawbot
merged commit 4cc83b2326 into next2026-08-20 07:19:05 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #209.
/metricscarried only the inbound HTTP surface, so a destinationfailing for an hour, a growing retry backlog and a stuck-open circuit
breaker were all invisible: the receive side stays healthy in each
case because it is.
What landed
New
internal/metricsregisters these on the existing registry —prometheus.DefaultRegisterer, which is whatmetrics.NewRecorder(metrics.Config{})ininternal/middlewaredefaults to and what
promhttp.Handler()gathers. No second registry,no second route;
internal/server/routes.gois untouched.webhooker_events_received_totalwebhooker_delivery_attempts_totalwebhooker_deliveries_succeeded_totalwebhooker_deliveries_failed_totalwebhooker_delivery_retries_totalwebhooker_delivery_duration_secondswebhooker_deliveries_pendingpendingwebhooker_deliveries_retryingretryingwebhooker_circuit_breakers_openCardinality
Every delivery metric carries exactly one label,
target_type, whosedomain is the four target-type constants. A value outside that set
collapses to
unknownrather than minting a series, so an unknown oredited target type cannot grow the label set.
Target id is not a label. Its cardinality is not bounded: target
ids are UUIDs minted per operator action, a series is never reclaimed
once it exists, and a delete/recreate cycle leaks one forever. Event
id and entrypoint id are excluded for the same reason. A test asserts
the collapse (
TestUnknownTargetTypeCollapses).What is counted, and where
Attempts and duration —
Engine.observeAttempt, called from thepaths that actually dispatched, alongside the
DeliveryResulteachof them writes. HTTP and Slack report
attemptResult.duration, thesame value that is persisted; the log and database targets time
their own work, so their result rows carry a real duration too.
A delivery an open circuit breaker refuses is deliberately not
an attempt: it sends nothing and records no result row. Counting it
would climb the attempts counter with no traffic behind it and fill
the duration histogram with near-zero samples, so the delivery
quantiles would improve for as long as the breaker stayed open —
the metric moving the wrong way during exactly the incident it
exists to reveal. It is already visible as a retry.
Outcomes —
Engine.updateDeliveryStatus, once per transitionthe engine persists, and only after the row is written. A status
write the database rejected is never reported as an outcome that
happened, which is the same reasoning that rules out delta-tracked
gauges below.
The type is passed to that helper as an argument rather than read
off
Delivery.Target.failUnretryableRetryloads its deliverywithout the target relation on purpose: populating
d.TargetmakesGORM's
SaveBeforeAssociationsupsert the wholetargetsrow onthe status UPDATE, writing the plaintext target config — which for
a Slack target is the credential — into the per-webhook
events-*.db. That is the leak class tracked at#206;
TestFailUnretryableRetry_WritesNoTargetRowasserts that tablestays empty, and fails if the association is ever populated again.
Open breakers —
httpCore.publishCircuitState, recounted fromthat target type's breaker registry on every state change rather
than adjusted as a delta.
Queue depths —
internal/delivery/queue_depth.go, counted outof the per-webhook databases by a 30s sampler that runs with the
engine. Deltas were rejected: they would need seeding at startup
from rows a previous process wrote, and would drift permanently on
any transition that failed to persist.
Both gauges publish an
unknownseries, materialised atregistration rather than on first occurrence. A delivery queued
against a target that has since been deleted resolves to the empty
type and is folded there. That backlog is the one nobody is
watching, and it can predate the process, so its series has to exist
before a backlog has already built up.
Events received —
Handlers.createAndDeliverEvent, after thecommit.
go.modgainsgithub.com/prometheus/client_modelas a directdependency (it was already an indirect one, same version): the tests
read values back off a private registry.
prometheus/testutilwas notused because it would have pulled in a module the repo does not have.
Tests
Required test —
TestDeliveryMetrics_SuccessAndRetryExhaustion(
internal/delivery/metrics_test.go) drives one delivery thatsucceeds and one that fails every attempt until its retry budget is
exhausted, asserting exact values for all four counters and the
histogram across both. Also:
TestFailUnretryableRetry_WritesNoTargetRow— the orphaned-retryterminal path writes no
targetsrow, and no target config, intothe per-webhook database.
TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt— a delivery anopen breaker refuses moves the retry counter and leaves the attempts
counter and duration histogram untouched.
TestDeliveryMetrics_OrphanedRetryFailureLabelled— that terminalfailure is still labelled with the target's real type.
TestDeliveryMetrics_CircuitBreakerGauge— gauge follows a breakerthat trips.
TestDeliveryMetrics_QueueDepthGauges/TestDeliveryMetrics_QueueDepthDeletedTarget— the samplerpublishes what is in the databases, a drained queue reads 0 rather
than holding its last value, and a backlog against a deleted target
shows up under
unknown.internal/metrics/metrics_test.go— cardinality collapse, theunknown fold, gauge reset, status-to-counter mapping, and the series
that exist before any delivery.
Each delivery test gives the engine a set registered on a private
registry (
Engine.ExportSetMetrics, defined inexport_test.goandso absent from the production binary), so assertions are exact rather
than being disturbed by the parallel delivery tests moving the
process-wide collectors.
Gate evidence
Full evidence, with host load averages, is in the rework comment:
#224 (comment)
GOFLAGS=-count=1 make checkon the rebased branch — exit 0,0 issues.from the Docker lint stage, no(cached)package lines.Cache-defeated container build,
docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain .— exit 0; lint stage ran 68.1s, builder
make testran 88.8s, zero(cached)markers in thego testoutput, and the onlyCACHEDlayers are base-image and alpine ones.
script/testruns-race; norace reports.
The gate image was removed;
docker ps -ais clean. No prune was run.Notes
TODO.mduntouched, per#112.
address outside the SSRF blocklist; the tests deliver to
httptestservers through the engine's own path.FAIL —
needs-rework.1. BLOCKING:
d.Target = *targetleaks target credentials into the per-webhook event DBinternal/delivery/engine.go:842(failUnretryableRetry) newly assignsd.Target = *targetsoupdateDeliveryStatuscan read the type for a metrics label. The PR body calls this harmless. It is not.updateDeliveryStatusdoeswebhookDB.Model(d).Update("status", status). GORM runsSaveBeforeAssociationson the update callback chain, and it upserts a BelongsTo association whenever the association field is non-zero.failUnretryableRetryexplicitly loads the delivery without its relation, sod.Targetwas zero here and no upsert fired. Populating it makes this path upsert the wholetargetsrow —configincluded — into the per-webhookevents-*.db.That is precisely the credential leak tracked at #206 (open, 1.0.0 milestone), extended to a code path that did not previously have it. For a
slacktarget theconfigis the bearer credential; forhttpit can carry userinfo.Verified empirically, not inferred — isolated GORM probe against the same
gorm.io/driver/sqliteand versions from thisgo.mod, mirroring the model shape, run in a container:A metrics change must not change what is written to the database. Acceptable: leave
dalone and pass the type in — atarget.Typeargument toupdateDeliveryStatus, or a typed variant — or scope the write withOmit(clause.Associations). Either way, a test that assertsfailUnretryableRetrywrites notargetsrow.2.
queue_depth.gocomment contradicts the code; orphaned-target backlog vanishesinternal/delivery/queue_depth.go:123-126claims "A delivery whose target has since been deleted resolves to the empty type and lands in the unknown bucket rather than being dropped." It is dropped.types[row.TargetID]yieldsTargetType(""), so the count accumulates under key"", andSet.SetQueueDepths(internal/metrics/metrics.go:180) only ever readspending[t]/retrying[t]for the fourknownTargetTypes. There is nounknownseries for either queue gauge —initSeriescreates known types only.Net effect: deliveries queued against a deleted target are invisible in
webhooker_deliveries_pending/_retrying, which is exactly the stuck backlog #209 asks to be alertable. Acceptable: fold the empty type into anunknownseries (normalizeTargetTypealready exists and is used by every other setter) and materialise that series ininitSeries, or delete the comment and state the loss — but silently dropping a backlog is the worse of the two.3. Circuit-breaker-blocked non-attempts are counted as attempts, with ~0s durations
processDeliveryincrementswebhooker_delivery_attempts_totaland observeswebhooker_delivery_duration_secondsaroundtarget.Deliver. When a breaker is open,httpCore.withRetry->circuitBreakerBlockreturns before any request is made. So with a breaker stuck open — one of the three named failure modes in #209 — the attempts counter climbs with zero network attempts, and the duration histogram fills with microsecond samples, makingdelivery_duration_secondsquantiles look better during the outage.The engine already has the real per-attempt duration:
attemptResult.duration, whichrecordResultpersists. The histogram measures something else. Acceptable: observe only on the path that actually dispatched (or observeres.duration), and do not count a breaker-blocked delivery as an attempt.4. Outcome counters move even when the transition is not persisted
internal/delivery/engine.go:944increments beforewebhookDB.Model(d).Update(...)and unconditionally of its error. The PR body argues the gauges are counted rather than deltas precisely because "any transition that failed to persist" would drift — the counters have exactly that flaw. A failed status write leavesdeliveries_failed_totalclaiming an outcome the database does not have, and the queue gauge (read from the DB) permanently disagreeing. Acceptable: count after a successfulUpdate.5. Minor:
Engine.mxreads as a mutexinternal/delivery/engine.go:146.mx/muis the near-universal Go shorthand for a mutex, and the same struct holds async.WaitGroup.mtr,metormetricswould not misdirect.Checked and passing (no action): no unbounded value can reach a label — every
WithLabelValuesgoes throughnormalizeTargetTypeor iteratesknownTargetTypes, and theunknowncollapse is operator- not sender-driven; registration issync.OnceValueoverprometheus.DefaultRegistererso it cannot double-register under-count=2or a twice-built graph; retry/failed-terminal counting is exclusive and once-only inhandleRetry; the sampler is rooted atcontext.Background()per #97, is single-goroutine ticker-driven so it cannot pile up, and is bounded at shutdown bylifecycle.WaitForShutdown;TestDeliveryMetrics_SuccessAndRetryExhaustionasserts exact values and fails if the instrumentation is removed;go.modmoves onlyprometheus/client_modelindirect-to-direct at the same version andtestutilis avoided;TODO.mduntouched; base isnext; title ends with(closes #209); no Claude/Anthropic references or attribution trailers; no committed assets; merges cleanly into currentnext(bb30b3a).internal/metrics/metrics.gowas inspected line by line after the disclosed scripted block move: nine collector fields, all nine assigned exactly once acrossNew/registerCounters/registerGauges, no duplicate or missing top-level declaration, no truncation,gofmt -sandgolangci-lintclean. The move is intact.Independently obtained gate,
docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain, exit 0: lint stagegolangci-lint runran 139.9s,0 issues; buildermake testran 174.6s; fourCACHEDlines total, all apt/base layers, none inlintorbuilder; zero(cached)markers ingo testoutput.script/testruns-race, so the new sampler goroutine was exercised under the race detector —internal/delivery 7.245s,internal/metrics 1.171s, no race reports. Containers and images removed.Two notes, neither charged to this PR. The tracker check on
b8c8b75is stillpending, so CI is not green. Andgo test -count=2 ./internal/server/...panics withduplicate metrics collector registration attemptedinmiddleware.Metrics->go-http-metrics NewRecorder->MustRegister; I reproduced it on plainorigin/nextatbb30b3awith this branch absent, so it is a pre-existing defect from #216 and needs its own issue, not a change here.b8c8b75e04todfd559417eReworked, force-pushed as one commit
dfd5594, rebased ontonextata13e5b7.1 (blocking) — credential leak: fixed at the source
d.Target = *targetis gone.updateDeliveryStatusnow takes thetarget type as an argument;
failUnretryableRetrypassestarget.Typeand leaves the delivery loaded without its relation, asthat path intends.
recordResultis unchanged, so nothing else onthat path can trigger
SaveBeforeAssociations.Regression test:
TestFailUnretryableRetry_WritesNoTargetRow(
internal/delivery/engine_integration_test.go) drives the sweepagainst a target whose config is a Slack incoming-webhook URL, then
asserts
events-*.dbholds zerotargetsrows and that the URL doesnot appear in that table.
Proof the test catches the defect rather than merely passing — the
mutation was reintroduced on the fixed tree and the suite re-run:
and the write itself, from the per-webhook connection's SQL log:
The mutation was then reverted; the pushed tree does not contain it.
The fix does not depend on
#223 — no
Omitis used, theassociation is simply never populated.
2 — queue depth: emitting
unknown, not droppingSet.SetQueueDepthsfolds counts throughnormalizeTargetType(summing, so several unknown types do not overwrite each other) and
writes the whole domain including
unknownon every sample, so adrained unknown bucket reads 0.
initSeriesmaterialises the twounknownqueue series at registration — that backlog is read out ofthe databases and can predate the process, so the series has to exist
before the first sample. The false comment in
queue_depth.goisreplaced with what the code does.
Tests:
TestSetQueueDepthsFoldsUnknownTypes(internal/metrics) andTestDeliveryMetrics_QueueDepthDeletedTarget(end to end through thesampler, no target row in the main DB).
3 — duration histogram: dispatched attempts only
Both the attempts counter and the duration histogram moved off
processDeliveryintoEngine.observeAttempt, called from the pathsthat actually dispatched, next to the
DeliveryResulteach records.HTTP and Slack pass
attemptResult.duration— the same valuerecordResultpersists. The log and database targets now time theirown work instead of reporting 0, so their result rows carry a real
duration too.
Decision, stated explicitly: a breaker-blocked delivery is not an
attempt and gets no new series. It sends nothing, records no result
row, and is already counted as a retry by
webhooker_delivery_retries_total— which is the series that climbswhile a breaker is stuck open.
Test:
TestDeliveryMetrics_BreakerBlockedIsNotAnAttempttrips thebreaker, then drives one more delivery and asserts the retry counter
moved while attempts and histogram sample count did not.
4 — counters after the write
updateDeliveryStatusreturns on a failedUpdateand incrementsonly after the row is written.
5 —
mxRenamed to
mtronEngine, and onHandlersfor consistency.Out of scope
-count=2ininternal/server(#227) untouched.
TODO.mduntouched.
Gate
Host load average during the runs: 38.65 rising to 67.55 (1-minute)
across the
make checkrun; 72.73 falling to 43.51 across thecontainer build.
GOFLAGS=-count=1 make check— exit 0, no(cached)package lines:docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain .— exit 0. Lint stage
#20 DONE 68.1swith0 issues.; buildermake test#28 DONE 88.8s; zero(cached)markers anywhere in thego testoutput. FiveCACHEDlines total, all base-image andalpine layers (
#6,#7,#11,#12,#13) — none inlintorbuilder.script/testruns-race; no race reports.New and changed tests, from that uncached builder run:
The gate image was removed;
docker ps -ais clean. No prune was run.PASS —
merge-ready. All five prior findings fixed; leak closure reproduced independently, gate re-run green ondfd5594.Anomalies and disclosures:
dfd5594ispending("Waiting to run"), not green — queued, not red. Per #119 the check mark is not evidence anyway; the authoritative gate was obtained locally:docker build --no-cache-filter=lint --no-cache-filter=builder --progress=plain .exit 0, lint#20 DONE 68.8swith0 issues., buildermake test#31 DONE 94.0s, zero(cached)markers, zeroFAIL/DATA RACE, no BuildKit log clipping (1.88 MB). Host load 61-70 throughout. Image removed,docker ps -aclean, no prune run.CACHEDlayers, three are inside thebuilderstage (COPY --from=lint /src/go.sum, the apt-get,WORKDIR) despite--no-cache-filter=builder. The two layers that matter —golangci-lint runandmake test— both ran with real durations and full output.Omit(clause.Associations)and no GORM callback registration, so the fix rests solely on never populating the association. Reintroducingd.Target = *targetinfailUnretryableRetryon the pushed tree makesTestFailUnretryableRetry_WritesNoTargetRowfail with theINSERT INTO+ "targets" +... ON CONFLICT DO NOTHINGcarrying the Slack URL; the pushed tree passes. If 223 were reverted, this path still writes no target row.internal/delivery/engine.go:447-448(processRetryTask) still setsd.Event/d.Targetfrom the task before the sameUpdate, so the ordinary retry path does upsert atargetsrow into the per-webhook DB onnexttoday. That is #206 itself, untouched by this change, and correctly out of scope here — noting it only so the green regression test is not read as proving the whole class is closed.circuitBreakerBlockpersistsretrying, which incrementswebhooker_delivery_retries_total, the sampler carries it inwebhooker_deliveries_retrying, and the newdefer publishCircuitStatesetswebhooker_circuit_breakers_open. Three series move; onlydelivery_attempts_totaland the duration histogram, which measure dispatched traffic, correctly do not.-count=2run (internal/metrics,internal/delivery,internal/middleware— allokunder-race) were run asgo testinside the pinnedgolang:1.26.1-bookwormcontainer, since no make target runs a single test or-count=2. All linting was via the Docker gate only; nothing was run on the host.clawbot referenced this pull request2026-08-20 07:38:15 +02:00
clawbot referenced this pull request2026-08-20 07:38:44 +02:00