Tracking Webhook Delivery Latency Percentiles from Event Creation to Acknowledgement

A support ticket that says “your webhooks were slow yesterday” is unanswerable until you can point at a percentile over a defined interval, which is the instrumentation this guide builds on top of Webhook Observability & Monitoring. The scenario is a dispatcher that already emits spans from instrumenting webhooks with OpenTelemetry but reports a single average delivery time — a number that stayed at 900 ms all through an incident where the slowest 1% of events took four minutes. We will separate the clocks that actually matter, emit them as Prometheus histograms with deliberately chosen buckets, compute p50/p95/p99 correctly across a fleet of dispatchers, and keep the label set small enough that the time series database survives. The resulting series are exactly what the targets in defining SLOs for webhook delivery are measured against.

Prerequisites

Step 1: Record the four clocks that bound delivery latency

Delivery latency is not one duration. Four timestamps bound it — the moment the event was created in the producing transaction, the moment it landed on the delivery queue, the moment the first HTTP attempt began, and the moment a terminal 2xx was recorded — and each gap has a different owner and a different fix. Collapsing them into a single “delivery time” is how a queue that is 90 seconds behind hides behind endpoints that respond in 40 ms.

The four clocks of webhook delivery A timeline marks event creation, enqueue, first attempt, retry and acknowledgement, with each interval labelled as outbox lag, queue wait, backoff wait and attempt duration. end-to-end delivery latency is what the integrator feels created enqueued attempt 1 fails attempt 2 acked outbox lag queue wait backoff wait attempt duration Timing only the HTTP call reports the last segment and hides every second of the first three
Each segment has a different owner, so a single delivery timer cannot tell you whether to scale workers, tune backoff, or call the consumer.
Interval Clock pair What a spike means Where the fix lives
Outbox lag created_at to enqueued_at The producing transaction commits faster than the relay drains it Outbox poll frequency, relay batch size
Queue wait enqueued_at to first_attempt_at Not enough dispatch workers for the arrival rate Worker pool sizing, per-tenant partitioning
Attempt duration attempt start to response The consumer endpoint itself is slow Consumer capacity, connect and read timeouts
Retry wait failed attempt to next attempt Endpoints are failing and backoff is doing its job Backoff schedule and retry budget
End-to-end created_at to terminal ack The number that belongs in the SLO All of the above

Capture the timestamps in one small object that the dispatcher threads through the delivery path, and observe each interval as its own histogram.

# delivery_metrics.py
from __future__ import annotations

import time
from dataclasses import dataclass
from prometheus_client import Counter, Histogram

# Buckets straddle the thresholds the SLO cares about: 1s, 15s, 60s, 15m.
E2E_BUCKETS = (0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 900, 1800, 3600)
ATTEMPT_BUCKETS = (0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30)

outbox_lag = Histogram(
    "webhook_outbox_lag_seconds",
    "Seconds from event creation to enqueue on the delivery queue.",
    ["event_type"],
    buckets=E2E_BUCKETS,
)
queue_wait = Histogram(
    "webhook_queue_wait_seconds",
    "Seconds from enqueue to the start of the first delivery attempt.",
    ["endpoint_group"],
    buckets=E2E_BUCKETS,
)
attempt_duration = Histogram(
    "webhook_attempt_duration_seconds",
    "Seconds spent inside a single HTTP delivery attempt.",
    ["endpoint_group", "outcome"],
    buckets=ATTEMPT_BUCKETS,
)
delivery_e2e = Histogram(
    "webhook_delivery_seconds",
    "Seconds from event creation to terminal acknowledgement.",
    ["endpoint_group", "event_type"],
    buckets=E2E_BUCKETS,
)
attempts_total = Counter(
    "webhook_attempts_total",
    "Delivery attempts by outcome.",
    ["endpoint_group", "outcome"],
)


@dataclass
class DeliveryClock:
    """Wall-clock timestamps for one event, carried with the delivery job."""

    event_type: str
    endpoint_group: str
    created_at: float          # set by the producer, stored with the event
    enqueued_at: float | None = None
    first_attempt_at: float | None = None

    def mark_enqueued(self) -> None:
        self.enqueued_at = time.time()
        outbox_lag.labels(self.event_type).observe(
            self.enqueued_at - self.created_at
        )

    def start_attempt(self) -> float:
        now = time.time()
        if self.first_attempt_at is None:
            self.first_attempt_at = now
            queue_wait.labels(self.endpoint_group).observe(
                now - (self.enqueued_at or self.created_at)
            )
        return time.monotonic()

    def end_attempt(self, started: float, outcome: str) -> None:
        # Durations use the monotonic clock; NTP steps must not create
        # negative observations.
        attempt_duration.labels(self.endpoint_group, outcome).observe(
            time.monotonic() - started
        )
        attempts_total.labels(self.endpoint_group, outcome).inc()

    def mark_acked(self) -> None:
        delivery_e2e.labels(self.endpoint_group, self.event_type).observe(
            time.time() - self.created_at
        )

Observe delivery_e2e exactly once per event, on the terminal acknowledgement — never per attempt. An event that succeeds on retry 4 contributes one sample of its full lifetime, which is what the integrator actually experienced and what the delivery guarantee level promises.

Step 2: Choose histogram buckets instead of summary quantiles

An average of 900 ms tells you nothing about the tail because it is dominated by the many fast deliveries: nine hundred events at 100 ms and one hundred events at 8 s average to 890 ms, and no consumer ever experienced 890 ms. Percentiles are the only honest summary, and how you compute them is a fork in the road. A Prometheus summary computes quantiles inside each process; a histogram exports bucket counts and lets the server compute quantiles at query time. For a fleet of dispatchers, only the histogram is aggregatable — you can add bucket counts across ten instances, but you cannot average ten p95 values into a fleet p95.

Histogram versus summary for delivery latency Five criteria compared between Prometheus histograms and client-side summary quantiles, with the histogram column highlighted. Criterion Histogram Summary (quantile) Aggregatable across instances yes, sum the buckets no, cannot be merged Quantile accuracy bounded by bucket width exact per instance Series per label set one per bucket one per objective Decision made up front bucket boundaries quantiles and error Feeds SLO burn rate yes, le ratios divide no usable numerator
Summaries are more accurate per process and useless across a fleet, which is why delivery latency is a histogram everywhere it matters.

Bucket choice is the one decision you cannot change retroactively, so place boundaries on the numbers you will be asked about. If the SLO says “acknowledged within 15 minutes”, you need an le="900" bucket, because the ratio of that bucket to the total is the SLI. Add a boundary at each threshold you might alert on — 1 s, 15 s, 60 s, 900 s — and accept coarse resolution between them. A missing boundary means the answer to “what fraction landed inside the objective” has to be interpolated, and interpolation across a wide bucket is a guess.

Step 3: Compute p50, p95, and p99 across every instance

The correct incantation aggregates bucket rates by le before applying histogram_quantile. Any query that computes a quantile per instance and then averages is arithmetically meaningless, no matter how reasonable the dashboard looks.

groups:
  - name: webhook_latency
    interval: 30s
    rules:
      # Correct: sum bucket rates by le first, then compute the quantile.
      - record: webhook:delivery_latency_seconds:p50
        expr: |
          histogram_quantile(
            0.50,
            sum by (le) (rate(webhook_delivery_seconds_bucket[5m]))
          )
      - record: webhook:delivery_latency_seconds:p95
        expr: |
          histogram_quantile(
            0.95,
            sum by (le) (rate(webhook_delivery_seconds_bucket[5m]))
          )
      - record: webhook:delivery_latency_seconds:p99
        expr: |
          histogram_quantile(
            0.99,
            sum by (le) (rate(webhook_delivery_seconds_bucket[5m]))
          )
      # Per-endpoint-group p95: keep le plus the one label you group by.
      - record: webhook:delivery_latency_seconds:p95_by_group
        expr: |
          histogram_quantile(
            0.95,
            sum by (le, endpoint_group) (rate(webhook_delivery_seconds_bucket[5m]))
          )

Two details decide whether these numbers are trustworthy. The range in rate() must cover at least four scrape intervals, or a restarted instance produces gaps that read as a latency drop. And histogram_quantile returns the upper bound of the bucket containing the quantile, linearly interpolated within it, so a p99 that lands in the top finite bucket is reported as that boundary and a p99 beyond it is +Inf. If p99 pins to your largest boundary, the tail is longer than your buckets can see, not exactly that value.

You can reproduce the server’s arithmetic locally, which is how you convince yourself a suspicious dashboard is right:

# quantile_check.py
from __future__ import annotations

import math


def quantile_from_buckets(
    cumulative: list[tuple[float, float]], q: float
) -> float:
    """Mirror of PromQL histogram_quantile over cumulative bucket counts.

    `cumulative` is [(upper_bound, count_le_bound), ...] sorted ascending,
    ending with (inf, total).
    """
    total = cumulative[-1][1]
    if total == 0:
        return float("nan")

    rank = q * total
    lower_bound = 0.0
    lower_count = 0.0

    for upper, count in cumulative:
        if count >= rank:
            if math.isinf(upper):
                return lower_bound  # tail exceeds the largest finite bucket
            if count == lower_count:
                return upper
            fraction = (rank - lower_count) / (count - lower_count)
            return lower_bound + (upper - lower_bound) * fraction
        lower_bound, lower_count = upper, count

    return cumulative[-2][0]


if __name__ == "__main__":
    observed = [
        (1.0, 900.0),
        (15.0, 980.0),
        (60.0, 995.0),
        (900.0, 999.0),
        (float("inf"), 1000.0),
    ]
    for q in (0.50, 0.95, 0.99):
        print(f"p{int(q * 100)} = {quantile_from_buckets(observed, q):.2f}s")

That sample distribution prints p50 = 0.56s, p95 = 9.75s, and p99 = 45.00s — a mean well under one second sitting on top of a 45-second p99, which is precisely the shape that averages conceal and that drives alerting on webhook delivery failures to fire on burn rate instead.

Step 4: Bound label cardinality on endpoint and tenant

A histogram costs one series per bucket per label combination, and it is trivially easy to build a metric that outnumbers your event volume. With 13 buckets plus +Inf, _sum and _count, a single label pair of 500 endpoints and 2,000 tenants produces millions of active series — enough to evict the rest of your monitoring.

Label set anatomy and cardinality The metric family plus endpoint, tenant and le labels multiply into millions of series until the label set is reduced. Every label value multiplies the number of stored series webhook_delivery_seconds metric family endpoint_id 500 values tenant 2,000 values le 14 buckets 500 endpoints x 2,000 tenants x 14 buckets = 14,000,000 series Drop tenant, keep a top-20 endpoint allowlist plus other 21 groups x 14 buckets = 294 series, tenant moves to logs
Cardinality is multiplicative, so removing one high-cardinality label is worth more than any amount of bucket tuning.

The fix is not to abandon per-endpoint visibility but to bound it. Keep a periodically refreshed allowlist of the endpoints that carry most of the traffic, map everything else to other, and push per-tenant identity into exemplars and structured logs where high cardinality is cheap.

# label_policy.py
from __future__ import annotations

import threading
from collections import Counter as FreqCounter

_TOP_N = 20
_lock = threading.Lock()
_allowlist: frozenset[str] = frozenset()
_recent = FreqCounter()


def endpoint_group(endpoint_id: str) -> str:
    """Collapse endpoint ids to a bounded label value."""
    with _lock:
        _recent[endpoint_id] += 1
        return endpoint_id if endpoint_id in _allowlist else "other"


def refresh_allowlist() -> frozenset[str]:
    """Promote the busiest endpoints; call on a timer, e.g. every 10 minutes."""
    global _allowlist
    with _lock:
        top = {name for name, _ in _recent.most_common(_TOP_N)}
        _recent.clear()
        _allowlist = frozenset(top)
        return _allowlist


def observe_with_exemplar(histogram, seconds: float, trace_id: str, tenant: str):
    """Attach the identifying detail as an exemplar, not as a label."""
    histogram.observe(seconds, exemplar={"trace_id": trace_id, "tenant": tenant})

Exemplars keep the drill-down path intact: the p99 bar on a dashboard still links to a specific trace, but the tenant identity lives on one sample rather than on a whole series. Anything you truly need to slice by — event type, or a coarse endpoint group — must stay a small, enumerable set, the same discipline applied to queue partitioning in webhooks vs polling vs WebSockets when consumer counts grow.

Verification and testing

Assert the histogram behaviour against an isolated registry so a refactor cannot silently move an observation to the wrong metric or drop a bucket boundary.

# test_delivery_metrics.py — pytest -q
import time
from prometheus_client import CollectorRegistry, Histogram

E2E_BUCKETS = (0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 900, 1800, 3600)


def _bucket(registry, name, le, **labels):
    return registry.get_sample_value(
        f"{name}_bucket", {**labels, "le": str(le)}
    )


def test_slo_boundary_bucket_exists_and_counts():
    registry = CollectorRegistry()
    h = Histogram(
        "webhook_delivery_seconds",
        "test",
        ["endpoint_group"],
        buckets=E2E_BUCKETS,
        registry=registry,
    )

    for seconds in (0.4, 3.0, 12.0, 240.0, 1500.0):
        h.labels("other").observe(seconds)

    # The 900s SLO boundary must exist, or the SLI cannot be computed.
    assert _bucket(registry, "webhook_delivery_seconds", "900.0",
                   endpoint_group="other") == 4.0
    assert registry.get_sample_value(
        "webhook_delivery_seconds_count", {"endpoint_group": "other"}
    ) == 5.0


def test_end_to_end_observed_once_per_event():
    registry = CollectorRegistry()
    h = Histogram(
        "webhook_delivery_seconds", "test", buckets=E2E_BUCKETS,
        registry=registry,
    )
    created_at = time.time() - 42.0
    # Three attempts, one terminal acknowledgement.
    h.observe(time.time() - created_at)
    assert registry.get_sample_value("webhook_delivery_seconds_count") == 1.0

Then check the live series on the wire. curl -s localhost:9100/metrics | grep webhook_delivery_seconds_bucket | wc -l should return a number in the low hundreds, not the tens of thousands — that single count is the fastest cardinality regression test you have. Finally, run promtool query instant against the recording rules and confirm p95 tracks a synthetic event you timed by hand, and that count(count by (endpoint_group) (webhook_delivery_seconds_count)) stays at or below your allowlist size plus one.

Failure modes and gotchas

Frequently Asked Questions

What happens to the percentile when an event is never acknowledged at all?

Nothing, and that is the trap: an event that exhausts its retries never reaches the terminal observation, so it contributes no sample and the tail quietly improves as delivery gets worse. Observe a terminal sample on retry exhaustion as well, labelled by outcome, or keep an exhausted-events counter that every latency dashboard displays beside the quantiles. A p99 read without that denominator is measuring only the events that eventually succeeded.

Does clock skew between the producer and the dispatcher corrupt these numbers?

It corrupts the two intervals that span processes. Outbox lag and queue wait have to compare wall clocks on different hosts, so a machine drifting by a second can report a negative duration, which lands in the lowest bucket and understates the tail rather than raising an error. Clamp negative values at zero and count how often you clamp, because a rising clamp rate is a clock problem masquerading as excellent latency. Attempt duration is immune, since it is measured monotonically inside one process.

Should time spent waiting between retries count toward end-to-end latency?

Yes. The integrator waited through it, so excluding it produces a number only the dispatcher team believes. Keep retry wait as its own histogram alongside the end-to-end one so you can still separate a genuinely slow consumer from a conservatively spaced backoff schedule — the two demand completely different remediation, and the combined figure alone cannot distinguish them.

How much error does bucket interpolation actually introduce?

More than most dashboards imply, because the interpolation assumes samples are spread evenly inside the bucket while real latencies bunch up near its lower edge. Across a boundary pair like 300 and 900 seconds, the reported quantile can sit minutes away from the true value, and it will usually read high. That is acceptable for spotting a trend and unacceptable for a figure you put in a contract, which is the practical argument for a boundary on every number you will be held to.

Can I change bucket boundaries after the metric is in production?

You can deploy the change, but it is not retroactive and the two series are not comparable. Prometheus stores counts per boundary rather than the raw observations, so history keeps the old buckets forever and any query spanning the deploy mixes two incompatible resolutions. Ship the new boundaries under a new metric name, run both for as long as you need overlapping comparison, then retire the old one — and treat the original choice as an interface decision rather than a tunable.

Do native histograms remove the bucket-sizing problem?

Largely, yes. Native histograms store exponentially spaced buckets at a configurable resolution, so the boundaries adapt to the observed range instead of being fixed in code, and the series count collapses to roughly one per label combination rather than one per boundary. They aggregate on the same principle, so the discipline of summing before quantiling survives unchanged. Verify support end to end first — long-term storage, alerting, and dashboard tooling still lag the server on this feature.

How do I answer a single tenant's complaint if tenant is not a metric label?

In two steps, which is exactly why the label was dropped. Query the quantile for that tenant's endpoint group to establish whether the platform itself was degraded during the window, then pull that tenant's individual deliveries from structured logs or from the exemplar's trace to see what their events actually did. The metric answers whether the problem is systemic; the log answers what happened to one customer, and conflating the two is what produces an unaffordable time series database.