Monitoring Vector Query p95 Latency

Measuring the p95 latency of PostGIS and vector-tile queries requires histogram instrumentation, not a naive average, because spatial query cost is heavy-tailed: a handful of large-bbox or high-vertex requests dominate the experience that consumers actually feel. This operation wires bucketed latency histograms into a Prometheus SLI, computes the 95th percentile with histogram_quantile, and pages when a per-domain p95 breaches its published ceiling. It implements the latency indicator specified in SLA Monitoring for Spatial Data Products within the Spatial Pipeline Orchestration & Observability practice, and sits beside Prometheus Alerting Rules for Tile Availability so a domain covers both latency and availability of its serving layer.

The path a single vector query takes from execution to a pageA vector query is timed in the service, observed into a labelled histogram bucket, scraped by Prometheus over mTLS, reduced to a recorded p95 SLI every 30 seconds, and compared against the 500ms ceiling. Breaching the ceiling for a sustained five minutes routes a page to on-call; any step that loses the domain or le label drops the query out of the SLI entirely.PostGIS querytimed in the serviceHistogram observedomain · layer · leScrapemTLS, 30sRecorded p95histogram_quantileAlertp95 > 500ms for 5mdurationbucketsrate[5m]comparelabel droppedle lost → NaNQuery invisible to the SLIno page, no evidence

Prerequisites

Requirement Value / Assumption Notes
Prometheus >= 2.45 Native histogram_quantile support
Instrumentation prometheus_client (Python) or equivalent Emits a Histogram metric
Query backend PostGIS >= 3.3 behind a vector-tile service EPSG:4326 storage, EPSG:3857 tiles
Histogram metric vector_query_duration_seconds_bucket Labelled by domain, layer
Bucket layout le boundaries spanning 5ms5s Must bracket the SLA ceiling
SLO target p95 < 300ms per domain Alert ceiling 500ms
Access role prometheus-rules-editor (RBAC) Apply recording/alert rules
Scrape security mTLS to the exporter Zero-trust scrape path

Step-by-Step Implementation

Each step yields a working fragment and a command that proves it before the next.

1. Instrument the query path with a histogram

A histogram pre-buckets observations so a percentile can be estimated server-side. Choose le boundaries that bracket the SLA ceiling tightly — clustered buckets around 300ms give an accurate p95 where it matters.

Why a bucketed histogram is the only metric type that yields a fleet-wide p95Three candidate instrumentations compared on four properties. A mean is cheap and aggregates across replicas but cannot express a percentile at all. A Prometheus summary computes quantiles accurately per instance but those quantiles cannot be aggregated, so a fleet p95 is unrecoverable. A histogram aggregates across every replica and supports percentiles, at the cost of accuracy that depends on where the bucket boundaries sit.Mean latencySummaryHistogramExpresses a percentilenoyesyesAggregates across replicasyesnoyesSurvives a heavy tailhiddenper-pod onlyvisibleAccuracy depends onnothingsliding windowbucket placement

python
# vector_metrics.py — histogram instrumentation on the spatial query path.
from prometheus_client import Histogram
import time

VECTOR_QUERY = Histogram(
    "vector_query_duration_seconds",
    "PostGIS/vector-tile query latency",
    labelnames=("domain", "layer"),
    # Buckets clustered around the 300ms SLA so the p95 estimate is sharp.
    buckets=(0.005, 0.025, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 1.0, 2.5, 5.0),
)

def run_vector_query(domain: str, layer: str, execute) -> object:
    start = time.perf_counter()
    try:
        return execute()             # runs the PostGIS / MVT query
    finally:
        # Idempotent observation: one sample per query, always recorded.
        VECTOR_QUERY.labels(domain=domain, layer=layer).observe(
            time.perf_counter() - start
        )

Verify: confirm the bucket series is exposed on the metrics endpoint.

bash
curl -s http://localhost:8000/metrics | grep vector_query_duration_seconds_bucket | head

2. Record the p95 as an SLI

Compute the 95th percentile from the bucket rates, grouped by domain and preserving the le label the quantile function needs. Recording it keeps the alert expression cheap.

yaml
# vector-latency-rules.yaml
groups:
  - name: vector_latency_recording
    interval: 30s
    rules:
      - record: vector:query_latency:p95_5m
        expr: |
          histogram_quantile(
            0.95,
            sum(rate(vector_query_duration_seconds_bucket[5m])) by (le, domain)
          )

Verify: parse and validate the rule file.

bash
promtool check rules vector-latency-rules.yaml

3. Alert when p95 breaches the ceiling

Page when the recorded p95 exceeds 500ms for a sustained window, keeping the objective (300ms) and the alert ceiling (500ms) distinct so normal variance does not page.

Per-domain p95 against the 300ms objective and the 500ms alert ceilingFive domains measured over the same five-minute window. Parcels sits at 612 milliseconds and imagery at 540, both past the 500 millisecond alert ceiling drawn as a dashed line. Basemap at 288, hydrology at 194 and admin boundaries at 96 are inside the 300 millisecond objective. The gap between the two breaching domains and the rest is the heavy tail of large bounding-box requests.parcels612mslarge-bbox tailimagery540msreprojection costbasemap288msnear objectivehydrology194msadmin-boundaries96msceiling 500ms

yaml
  - name: vector_latency_alerts
    rules:
      - alert: VectorQueryP95Breach
        expr: vector:query_latency:p95_5m > 0.5
        for: 5m
        labels:
          severity: page
          slo: vector_p95
        annotations:
          summary: "p95 latency breach on {{ $labels.domain }} vector queries"
          description: "p95 above 500ms ceiling (300ms objective) for 5m."

Verify: confirm the combined file still validates.

bash
promtool check rules vector-latency-rules.yaml

4. Prove the alert with a unit test

Drive a synthetic bucket series whose mass sits above the ceiling and assert the page fires.

yaml
# vector-latency-tests.yaml
rule_files:
  - vector-latency-rules.yaml
evaluation_interval: 30s
tests:
  - interval: 30s
    input_series:
      - series: 'vector_query_duration_seconds_bucket{le="0.5",domain="parcels"}'
        values: '0+2x40'
      - series: 'vector_query_duration_seconds_bucket{le="+Inf",domain="parcels"}'
        values: '0+20x40'     # most mass beyond 500ms → p95 > 0.5
    alert_rule_test:
      - eval_time: 6m
        alertname: VectorQueryP95Breach
        exp_alerts:
          - exp_labels:
              severity: page
              slo: vector_p95
              domain: parcels

Verify: run the behavioural test.

bash
promtool test rules vector-latency-tests.yaml

5. Confirm the SLI in the query console

Evaluate the recorded percentile directly so you see the live per-domain p95, not just the alert state.

bash
curl -s 'http://localhost:9090/api/v1/query?query=vector:query_latency:p95_5m' \
  | jq '.data.result[] | {domain: .metric.domain, p95: .value[1]}'

Configuration Reference

Field Scope Required value Effect
buckets histogram span 5ms5s clustered near ceiling Determines p95 estimate accuracy
quantile arg recording expr 0.95 The percentile computed
by (le, domain) recording expr preserve le histogram_quantile requires the le label
[5m] window recording expr 5m Smoothing window for the rate
ceiling alert expr 0.5 (seconds) p95 threshold that pages
objective published SLA 0.3 (seconds) Target the ceiling protects
for alert 5m Debounce against transient spikes
severity alert label page Alertmanager routing key

Common Failure Modes & Fixes

histogram_quantile returns NaN. Root cause: the le label was dropped in the by () aggregation, so the function has no buckets to interpolate. Fix: always include le in the recording rule’s by (le, domain) clause.

p95 reads suspiciously round, like exactly 0.3. Root cause: the estimate is being clamped to a bucket boundary because too few buckets straddle the ceiling. Fix: add le boundaries around 300ms (for example 0.2, 0.3, 0.4) so interpolation has resolution where the SLA lives.

p95 looks healthy but consumers report slowness. Root cause: a heavy-tail of large-bbox queries lives beyond your largest finite bucket, collapsing into +Inf and dragging the true p99 out of view. Fix: extend the top buckets and consider alerting on p99 as well; large-bbox jobs may belong on the async path from Optimizing Async Execution for Spatial Joins.

Alert flaps around the ceiling. Root cause: p95 hovers near 500ms and the for duration is too short. Fix: widen for to 5m and confirm the objective/ceiling gap is wide enough to absorb normal variance.

Per-domain series missing after a deploy. Root cause: the instrumentation was called without the domain/layer labels on one code path. Fix: make the labelled observe mandatory in the query wrapper so no path emits an unlabelled sample.

FAQ

Why a histogram instead of a summary?

A Prometheus summary computes quantiles client-side per instance and cannot be aggregated across replicas, so a fleet-wide p95 is impossible to recover from summaries. A histogram exposes raw buckets that histogram_quantile aggregates across every replica of a domain’s service, which is exactly what a per-domain SLI needs. The trade-off is that accuracy depends on bucket placement, so buckets are clustered around the SLA ceiling.

How do I pick bucket boundaries for spatial queries?

Bracket the SLA ceiling densely and let the tails be coarse. With a 300ms objective, boundaries at 0.2, 0.3, 0.4, 0.5 give sharp interpolation where decisions are made, while 1.0, 2.5, 5.0 capture the heavy-tail large-bbox queries without wasting resolution. Always include a boundary at or just above the alert ceiling so the p95 estimate near it is not clamped.

Should latency be scoped per layer or per domain?

Record per domain for the pageable SLI so on-call is not fragmented, but keep a layer label available for drill-down. A single high-vertex layer can dominate a domain’s p95, and the layer label lets a responder localise it without a separate alert per layer.

Does this replace request tracing?

No — the two are complementary. The p95 SLI tells you that a domain’s latency contract is breaching over a window; a distributed trace tells you why one request was slow. When the p95 alert fires, follow it into Distributed Tracing for Spatial Request Flows to find the span — the PostGIS scan, the reprojection, the network hop — that consumed the budget.

How should the SLI treat queries the service rejected?

Exclude rejections from the latency SLI and count them in the availability SLI instead, or the two indicators will lie to each other. A request rejected at the contract gate in three milliseconds is fast and unsuccessful; leaving it in the histogram drags p95 downward exactly when a bad deploy is rejecting everything, so latency looks superb during an outage. The rule is that the latency SLI measures only requests the service genuinely attempted to serve — successes and server-side errors — while client-side rejections belong to availability and to the contract-violation counter. Instrumenting the wrapper so that the histogram observation happens only on the served path, rather than in a blanket finally, is what enforces this in practice.

Does p95 stay meaningful when one layer dominates a domain’s traffic?

Only if you keep the layer label available for drill-down. A domain whose basemap layer carries ninety percent of requests has a p95 that is effectively the basemap’s p95, and a serious regression in a low-volume but high-value layer — the parcels layer an internal application depends on — can be entirely invisible in the aggregate. Keep the pageable SLI at domain scope so on-call is not fragmented across dozens of alerts, but record the per-layer percentile as a non-paging recording rule so a responder can immediately see whether the breach is spread across the domain or concentrated in one layer. The first question after a p95 page is always “which layer”, and the answer should already be recorded rather than needing an ad-hoc query.