Building Parity Drift Alerts with Prometheus

Parity drift is a duration problem, not an instant one: a channel that dips 3% below the authoritative rate for one scrape is noise, while the same 3% held for twenty minutes across peak-season dates is an incident. Prometheus is built for exactly this distinction — its for clause fires an alert only when a condition holds for a sustained window — which makes it the natural place to turn the deviation signal from parity monitoring and alerting into pages that respect the on-call’s attention. This page builds the Python exporter that publishes parity deviation as Prometheus metrics and the alerting rules that page on sustained drift while staying silent on blips.

The result is two moving parts: a prometheus_client exporter that maps each observed parity deviation onto a labelled gauge and a histogram, and a set of Alertmanager rules with for durations, severity labels, deduplication, and routing that send criticals to paging and warnings to Slack. The design goal throughout is that a single bad scrape never pages anyone and a real markdown always does.

Prerequisites & environment

The exporter runs as a sidecar to the parity scorer, scraped by Prometheus on a fixed interval. Pin these versions so metric names, label cardinality, and rule evaluation behave identically across environments:

Parity deviation exported to Prometheus and evaluated into routed alerts Scored parity deviation feeds a Python exporter that publishes a gauge and a histogram on a metrics endpoint. Prometheus scrapes that endpoint on an interval and evaluates alerting rules with a for-duration clause. Rules that hold long enough fire into Alertmanager, which deduplicates and routes by severity: critical alerts go to PagerDuty and warnings go to Slack. Deviation to exporter to routed alert A for-duration clause turns a noisy gauge into a page only when drift is sustained. Scored deviation_pct Python exporter gauge + histogram Prometheus scrape + rule eval Alertmanager dedup + route PagerDuty Slack
Figure 1: the alerting path — the exporter publishes deviation as a gauge and histogram, Prometheus scrapes and evaluates a for-duration rule, and Alertmanager deduplicates and routes criticals to paging and warnings to Slack.

Step-by-step implementation

The exporter defines a small, bounded set of metrics, updates them each scoring cycle, and serves them for Prometheus to scrape. The rules then encode the duration and severity logic. Build them in that order.

Step 1 — Define bounded gauges and a deviation histogram

Define a gauge for the current per-channel worst deviation and a histogram for the distribution of absolute deviations. Keep labels to low-cardinality dimensions — property_id, channel, and direction — and never label by stay_date or room_type_code, which would create millions of series.

python
from prometheus_client import Gauge, Histogram

# Worst (most negative) current deviation per channel — a gauge, because it is a
# point-in-time level Prometheus samples, not an accumulating counter.
PARITY_DEVIATION = Gauge(
    "parity_deviation_pct",
    "Current worst signed parity deviation percent per channel",
    ["property_id", "channel", "direction"],   # direction = undercut | overcharge
)

# Distribution of absolute deviation magnitudes, for percentile alerting and dashboards.
PARITY_DEVIATION_HIST = Histogram(
    "parity_deviation_abs_pct",
    "Distribution of absolute parity deviation percent",
    ["property_id", "channel"],
    buckets=(0.5, 1.0, 2.0, 3.0, 5.0, 8.0, 13.0),   # tuned around the tolerance band
)

Choosing histogram buckets clustered around the tolerance band (the 2.05.0 region) rather than Prometheus’s linear defaults is what lets an alert rule ask “what fraction of cells breached 5%” with useful resolution — default buckets would smear the signal across ranges nobody alerts on.

Step 2 — Update the metrics each scoring cycle

After each scoring pass, push the per-channel worst deviation to the gauge and observe every cell’s absolute deviation into the histogram. Set the gauge explicitly rather than incrementing, because a gauge represents the current level, and observe each cell so the histogram reflects the true distribution.

python
import polars as pl
import structlog

log = structlog.get_logger()

def export_cycle(scored: pl.DataFrame, property_id: str) -> None:
    # scored has: channel, deviation_pct (signed), abs_deviation_pct
    worst = (scored.group_by("channel")
                   .agg(pl.col("deviation_pct").min().alias("worst_dev")))
    for row in worst.iter_rows(named=True):
        direction = "undercut" if row["worst_dev"] < 0 else "overcharge"
        PARITY_DEVIATION.labels(property_id, row["channel"], direction).set(row["worst_dev"])
        log.info("parity_gauge_set", property_id=property_id,
                 channel=row["channel"], worst_dev=row["worst_dev"])
    for row in scored.iter_rows(named=True):
        PARITY_DEVIATION_HIST.labels(property_id, row["channel"]).observe(
            abs(row["deviation_pct"] or 0.0)
        )

Reporting the minimum signed deviation as the per-channel gauge value is deliberate: the most negative number is the worst undercut, which is the parity-clause exposure that matters, so the gauge tracks the single number a rule should alert on rather than an average that a few overcharges could mask.

Step 3 — Serve the metrics endpoint

Expose the metrics on an HTTP port for Prometheus to scrape. Run the server once at process start and let the scoring loop update the metric objects in place; prometheus_client handles the concurrency between the scrape handler and your updates.

python
from prometheus_client import start_http_server
import time

def run_exporter(port: int = 9188) -> None:
    start_http_server(port)                 # serves /metrics on all interfaces
    log.info("parity_exporter_started", port=port)
    while True:
        scored = load_latest_scores()       # your scorer output for this cycle
        export_cycle(scored, property_id="LON-STJ-01")
        time.sleep(60)                       # align with the Prometheus scrape_interval

Aligning the update loop’s sleep with the Prometheus scrape_interval avoids a subtle aliasing bug: if the exporter refreshes far faster than Prometheus scrapes, brief sub-scrape breaches vanish between samples, and if it refreshes far slower, the gauge goes stale and a rule fires on old data — matching the two cadences keeps every real breach visible to exactly one scrape.

Step 4 — Write the alerting rules with a for duration

The rule is where blip suppression lives. Fire only when the deviation gauge breaches the band and holds for a sustained window via for, add keep_firing_for so a flapping recovery does not resolve-and-refire, and attach a severity label that Alertmanager routes on.

yaml
groups:
  - name: parity_drift
    rules:
      - alert: ParityUndercutSustained
        # undercut worse than 2% held for 15 minutes — a real markdown, not a cache blip
        expr: parity_deviation_pct{direction="undercut"} < -2.0
        for: 15m
        keep_firing_for: 5m
        labels:
          severity: warning
          team: revenue
        annotations:
          summary: "{{ $labels.channel }} undercutting parity on {{ $labels.property_id }}"
          description: "Sustained undercut of {{ $value | printf \"%.2f\" }}% for 15m."

      - alert: ParityUndercutCritical
        # a large, visible undercut escalates faster
        expr: parity_deviation_pct{direction="undercut"} < -5.0
        for: 5m
        keep_firing_for: 5m
        labels:
          severity: critical
          team: revenue
        annotations:
          summary: "Severe parity breach on {{ $labels.channel }}"
          description: "Undercut of {{ $value | printf \"%.2f\" }}% held 5m — page on-call."

Setting keep_firing_for: 5m on top of the for clause is the non-obvious detail that stops alert flapping: without it, a deviation that oscillates around the threshold resolves and re-fires repeatedly, spamming the on-call, whereas keeping the alert firing for a grace period after it recovers collapses that oscillation into one continuous incident.

Step 5 — Deduplicate and route in Alertmanager

Group alerts so one channel-wide breach is a single notification, and route by the severity label so criticals page and warnings go to Slack. Grouping on property_id and channel is what turns hundreds of per-cell breaches into one actionable incident.

yaml
route:
  receiver: slack_revenue
  group_by: ['alertname', 'property_id', 'channel']   # one incident per channel breach
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    - matchers: [severity="critical"]
      receiver: pagerduty_oncall
      continue: false                       # criticals page and do NOT also spam Slack

receivers:
  - name: slack_revenue
    slack_configs:
      - channel: '#parity-alerts'
        send_resolved: true
  - name: pagerduty_oncall
    pagerduty_configs:
      - routing_key: '<integration-key>'

Grouping on alertname, property_id, and channel rather than leaving alerts ungrouped is what makes a season-wide misconfiguration arrive as a single notification naming the channel, instead of one Slack message per breaching date cell — the same deduplication principle the parity monitoring loop applies with its cell-identity dedup key.

Gotchas & production notes

Verification snippet

Confirm the exporter publishes the series a rule depends on and that the gauge reports the worst undercut, not an average. Use prometheus_client’s in-process registry to assert on metric values without standing up a full Prometheus.

python
from prometheus_client import REGISTRY
import polars as pl

def test_gauge_reports_worst_undercut() -> None:
    scored = pl.DataFrame({
        "channel": ["booking_com", "booking_com", "expedia"],
        "deviation_pct": [-1.0, -4.0, 2.0],       # booking worst is -4.0, not the -1.0 mean
        "abs_deviation_pct": [1.0, 4.0, 2.0],
    })
    export_cycle(scored, property_id="LON-STJ-01")
    booking = REGISTRY.get_sample_value(
        "parity_deviation_pct",
        {"property_id": "LON-STJ-01", "channel": "booking_com", "direction": "undercut"},
    )
    assert booking == -4.0                          # the worst undercut, not an average

def test_histogram_counts_every_observation() -> None:
    count = REGISTRY.get_sample_value(
        "parity_deviation_abs_pct_count",
        {"property_id": "LON-STJ-01", "channel": "booking_com"},
    )
    assert count == 2.0                             # both booking_com cells observed

test_gauge_reports_worst_undercut()
test_histogram_counts_every_observation()

The first assertion is the one that matters: it proves the gauge surfaces the single worst undercut a channel is running rather than an average that a mild overcharge could soften, which is precisely what the ParityUndercutCritical rule keys on. Run it in CI so a refactor that accidentally reverts the gauge to a mean is caught before it silences a real page. The metrics themselves then flow into the same dashboards as the rest of revenue KPI observability.

← Back to Parity Monitoring & Alerting