Wiring Health Scores into Dashboards & Alerts
A composite health score that exists only in a Parquet file has never changed a decision. Getting it in front of people means two very different consumers: a dashboard, which a human reads when they choose to, and an alert, which chooses to interrupt a human. Both must be reading the same number, computed once, or the first ten minutes of every incident are spent establishing which of the two was telling the truth. This page is part of Metric Scoring & Data Normalization and covers the serving layer that sits between the scoring artifact and both consumers.
Prerequisites & environment setup
| Tool / library | Pinned version | Purpose |
|---|---|---|
| Python | 3.11.9 | Serving-layer refresh job |
| pyarrow | 16.0.0 | Reading the partitioned score artifact |
| prometheus-client | 0.20.0 | Exposing scores as metrics for the alert evaluator |
| PyYAML | 6.0.1 | Reading the shared threshold config |
export SCORE_ARTIFACT_DIR="/data/scored"
export SERVING_DB_URL="postgresql://audit@warehouse/serving"
export THRESHOLD_CONFIG="/opt/alert-routing/thresholds.json" # the ONE copy
export SERVING_STALE_AFTER_S=7200 # alert on staleness past this
export SERVING_REFRESH_LOCK="/var/run/serving-refresh.lock"
THRESHOLD_CONFIG pointing at the alert-routing config rather than a dashboard-local copy is the single most consequential line here. The dashboard should render the thresholds it reads from that file, not restate them — a duplicated number is a number that will eventually differ.
Step 1 — Initialization: build the serving layer
The serving layer is a narrow, denormalised table refreshed once per scoring run. It exists so that neither consumer ever queries the raw artifact and neither ever recomputes anything.
#!/usr/bin/env python3
# /opt/audit/serving/refresh.py — rebuild the read layer from one artifact.
from __future__ import annotations
import os
import datetime as dt
import pyarrow.parquet as pq
import pandas as pd
import sqlalchemy as sa
ART = os.environ["SCORE_ARTIFACT_DIR"]
DB = sa.create_engine(os.environ["SERVING_DB_URL"], future=True)
COLUMNS = ["url", "section", "template", "device", "composite_score",
"coverage", "imputed_share", "scoring_version", "scored_at"]
def refresh(run_date: str) -> int:
table = pq.read_table(f"{ART}/date={run_date}", columns=COLUMNS)
df = table.to_pandas()
# Freshness is a column, not an inference from the partition name.
df["served_at"] = dt.datetime.now(dt.timezone.utc)
with DB.begin() as conn:
conn.exec_driver_sql("TRUNCATE serving.scores_current")
df.to_sql("scores_current", conn, schema="serving",
if_exists="append", index=False, method="multi")
return len(df)
Three things are deliberate. COLUMNS is an explicit allowlist, so a new column appearing in the scoring artifact does not silently widen the serving contract. served_at is written as data rather than inferred from a partition name, because a partition named for today can be several hours old and nothing in the name says so. And the refresh runs inside a single transaction with a truncate, so a reader never sees a half-replaced table.
Step 2 — Core configuration
| Parameter | Type | Default | Purpose |
|---|---|---|---|
SERVING_STALE_AFTER_S |
int | 7200 |
Age past which the layer itself is alerted on |
THRESHOLD_CONFIG |
path | — | The single source of severity floors, shared with routing |
SERVING_SCOPE_LABEL |
string | all |
Which population this layer covers; rendered on every tile |
SERVING_REFRESH_LOCK |
path | — | flock guard so two runs never rebuild concurrently |
SERVING_RETAIN_RUNS |
int | 90 |
History kept in the trend table behind the current one |
SERVING_SCOPE_LABEL looks cosmetic and is not. A dashboard filtered to one section while alerts evaluate the entire estate produces the most confusing failure in this whole area: a green wall and a firing pager, describing the same site, both correct.
Step 3 — Execution & scheduling: refresh on the run, not on a timer
Trigger the refresh from the scoring run's completion, not from a schedule of its own. A timer-based refresh drifts relative to scoring and eventually rebuilds the layer from yesterday's artifact for an hour every day.
#!/usr/bin/env bash
# /opt/audit/serving/refresh.sh — called by the scoring job on success.
set -euo pipefail
RUN_DATE="${1:?run date required}"
exec 9>"${SERVING_REFRESH_LOCK}"
flock --nonblock 9 || { echo "refresh already running; skipping"; exit 0; }
/opt/audit/.venv/bin/python3 /opt/audit/serving/refresh.py "$RUN_DATE"
The staleness alert is the counterpart, and it has to be evaluated against the serving layer rather than against the pipeline's own logs — a pipeline that fails silently still writes a log, but it does not update served_at.
-- Alerting query: has the serving layer gone stale?
SELECT EXTRACT(EPOCH FROM (now() - MAX(served_at))) AS age_seconds
FROM serving.scores_current;
Step 4 — Artifact capture & storage
Keep a trend table behind the current one, appended rather than truncated, holding the same columns plus the run date. It is what a dashboard's trend tile reads and what a release comparison joins against, and it is far cheaper than re-reading partitioned Parquet for every chart. Retention of ninety runs matches the scoring artifact retention described in storing and versioning crawl artifacts in cloud storage; past that, the artifact remains the archive of record.
Common mistakes
- Treating the dashboard as the product and the alert as an afterthought. The dashboard is read when somebody chooses to look; the alert is read when it interrupts somebody. The alert path is the one that has to be correct at three in the morning, so it should be built first and the dashboard should be a second reader of the same layer — not the other way round, with alerting bolted onto a panel definition afterwards.
- Putting business logic in the visualisation tool. Panel-level expressions that combine metrics, apply weights, or reclassify severity are a second scoring implementation living in a tool with no tests, no review process and no version history that anyone reads. Every such expression is a place where the wall and the pager will eventually disagree. If a number needs computing, it belongs in the scoring pipeline where it can be asserted on.
- Rendering an average where the pipeline computed a percentile. A tile that shows the mean composite across a section is not a summary of the section's score, it is a different statistic with different behaviour — it moves for reasons the percentile does not, and it will disagree with an alert evaluated on the percentile. Render exactly the statistic the threshold is evaluated against.
- Leaving the scope of a tile implicit. A panel filtered to one device class, environment or section is answering a narrower question than its title suggests. The filter belongs in the title, not only in the query, so that a green tile cannot be mistaken for a green site.
- Alerting on the score and not on the pipeline. A scoring run that fails produces no new rows, no new alerts, and a dashboard that keeps rendering the last good snapshot indefinitely. Staleness has to be its own alert with its own threshold, evaluated against
served_at, or the most common failure mode of the whole system is also its quietest. - Letting the trend chart span a scoring-version boundary without saying so. A weight change produces a step in the series that looks exactly like a site regression.
scoring_versiontravels with every served row precisely so the chart can draw that boundary as an annotation, and so a comparison that crosses it can be refused rather than silently produced. - Refreshing the serving layer on a timer. A timer drifts relative to the scoring run it depends on, and eventually spends part of every day serving yesterday's artifact while reporting today's date. Trigger the refresh from the scoring job's success path, keep the flock guard, and let the staleness alert catch the case where the scoring job did not run at all.
Verification checklist
- The serving layer is fresher than the staleness threshold.
SELECT now() - MAX(served_at) FROM serving.scores_currentis underSERVING_STALE_AFTER_S. - Row count matches the artifact. The count in
scores_currentequals the row count of the source partition. A shortfall means the truncate-and-append raced with a reader or a partial write. - No consumer recomputes a score. Grep the dashboard definitions for arithmetic on component metrics; any expression combining two metrics is a re-derivation and must be replaced with a read of
composite_score. - Thresholds resolve to one file. The dashboard config and the routing config both reference
THRESHOLD_CONFIG; neither contains a literal floor. - Scope labels agree.
SERVING_SCOPE_LABELmatches the filter used by the alert evaluator, and appears on every dashboard tile. - Staleness itself alerts. Stop the refresh job deliberately and confirm a staleness alert fires within one evaluation interval — a dashboard that cannot tell you it is stale is worse than no dashboard.
Troubleshooting
The dashboard is fast and the numbers are hours old
The refresh is running on a timer that has drifted relative to scoring, so it rebuilds from whichever artifact happens to be latest. Move the trigger into the scoring job's success path and let the staleness alert catch any gap.
Two tiles on the same dashboard disagree
They are querying the warehouse independently and ran either side of a refresh. This is exactly what the serving layer exists to prevent; a tile that still points at the warehouse should be migrated rather than cached.
An alert fires for a section that the dashboard shows as healthy
Compare the populations before comparing the numbers. A dashboard filtered to device = 'desktop' while the alert evaluates all devices is not a disagreement about the score, it is two different questions. Put the filter in the tile title and mirror it in the alert scope, as described in configuring alert thresholds and routing.
A threshold change does not take effect on the dashboard
The dashboard is rendering a cached copy of the threshold file. Reference it at read time and let the dashboard's own refresh interval pick up the change, rather than baking values into a panel definition at deploy time.
The trend chart has a step change with no deploy behind it
The scoring version changed. scoring_version is carried through the serving layer precisely so a chart can annotate this rather than present it as a site regression; render it as a marker and treat comparisons across the boundary as invalid, exactly as tracking metric trends across release cycles describes.
The refresh occasionally leaves the table empty
TRUNCATE followed by an append is atomic only inside one transaction. If the append fails after the truncate commits, the table is empty and every tile renders zero. Confirm both statements share a transaction, and add a post-refresh row-count assertion that rolls back rather than reporting success.
FAQ
Why should a dashboard never compute its own composite score?
Because the alert evaluator computes one too, and the moment a weight or a threshold changes the two versions drift apart. Once they differ, every incident review opens with an argument about which number was correct rather than about what to do. Compute the score exactly once, in the scoring pipeline, and let both consumers read the result — a dashboard is allowed to aggregate and format, never to re-derive.
Why alert on staleness rather than only on the score?
Because a stale dashboard renders exactly like a live one. If the scoring pipeline stops, the last good snapshot keeps displaying healthy numbers indefinitely, and the absence of alerts is indistinguishable from a healthy site. Recording served_at as a column and evaluating its age turns "nothing has fired" into a statement you can trust, which is the only condition under which a green dashboard means anything.
Should the dashboard query the warehouse directly?
Not for anything on the default view. Direct queries mean cost scales with the number of viewers rather than the volume of data, two tiles that run seconds apart can disagree because a refresh landed between them, and every warehouse schema change breaks panels. A serving layer refreshed once per scoring run gives every tile the same snapshot at a fixed cost and puts a stable contract between the dashboard and the underlying tables.
What causes a green dashboard and a firing pager at the same time?
Almost always different populations rather than different numbers. A dashboard filtered to one device class, section or environment while the alert evaluates the whole estate produces two correct answers to two different questions. Render the scope on every tile and mirror it in the alert configuration, so the two can be compared at a glance instead of reconciled during an incident.
Related
- Metric Scoring & Data Normalization — the parent section covering ingestion, normalisation and scoring upstream of this layer
- Configuring Alert Thresholds and Routing — the routing layer that reads the same threshold file this dashboard renders
- Building Score Aggregation Pipelines — the rollup that produces the scores the serving layer exposes
- Tracking Metric Trends Across Release Cycles — why scoring_version has to travel with every served row