Building a Nightly Integrity Report
Read the last fourteen manifests, compare the newest against the trend rather than against yesterday alone, and emit a plain-text summary with the exceptions first. Everything the report needs already exists if the pipeline emits a manifest per run — the work is presentation, and the presentation is what decides whether anyone reads it.
This page belongs to the Data Quality Gates & Monitoring guide.
Why This Matters
A gate stops bad data. It says nothing about slow degradation: a layer whose growth rate has doubled, an index that has been stale for three nights running, a free-page fraction climbing week over week. None of those trips a threshold on any single night, and all of them matter.
The report is also where the gate’s own behaviour becomes visible. A gate that has fired four times this fortnight is telling you something about the upstream data that no individual failure message conveys, and a gate that has never fired is telling you something about the thresholds.
Prerequisites
- A manifest per run, per How to Write a Data Quality Gate for a Field Dataset
- Somewhere those manifests accumulate — a directory is entirely sufficient
- Python 3.9+; no monitoring stack, database or agent is required
- A delivery route that works without a network, if the pipeline runs somewhere isolated
Primary Method
# report.py — a readable summary from the manifest history
import json
import statistics
from pathlib import Path
HISTORY = Path("/srv/manifests")
WINDOW = 14
def load_history(limit: int = WINDOW) -> list[dict]:
files = sorted(HISTORY.glob("*.manifest.json"))[-limit:]
return [json.loads(f.read_text()) for f in files]
def layer_trend(history: list[dict], layer: str) -> dict | None:
counts = [
m["layers"][layer]["rows"] for m in history if layer in m.get("layers", {})
]
if len(counts) < 3:
return None
prior, latest = counts[:-1], counts[-1]
median = statistics.median(prior)
return {
"latest": latest,
"median": median,
"deviation": (latest - median) / median if median else 0.0,
"run_to_run": statistics.median(
abs(b - a) / a for a, b in zip(prior, prior[1:]) if a
) if len(prior) > 2 else 0.0,
}
Comparing against a median of the window rather than against yesterday is what makes the report useful for trends. A single previous run is the right comparison for a gate, where the question is “is this publishable”; a window is the right comparison for a report, where the question is “is anything drifting”.
Step-by-Step Walkthrough
1. Put the exceptions first
The single most consequential decision about a report is its ordering. Anything requiring attention goes at the top; the routine confirmation goes underneath, where it can be skimmed or ignored.
def render(history: list[dict]) -> str:
latest = history[-1]
exceptions, routine = [], []
for layer in sorted(latest.get("layers", {})):
t = layer_trend(history, layer)
if t is None:
routine.append(f" {layer}: {latest['layers'][layer]['rows']:,} rows (new)")
continue
# Flag when the deviation is large relative to normal run-to-run movement
normal = max(t["run_to_run"], 0.02)
if abs(t["deviation"]) > normal * 4:
exceptions.append(
f" {layer}: {t['latest']:,} rows, {t['deviation']:+.1%} against a "
f"median of {t['median']:,.0f} (normal movement ±{normal:.1%})"
)
else:
routine.append(f" {layer}: {t['latest']:,} rows ({t['deviation']:+.1%})")
out = [f"Integrity report — {latest['container']}", ""]
if exceptions:
out += ["NEEDS ATTENTION", *exceptions, ""]
out += ["Steady", *routine]
return "\n".join(out)
Scaling the threshold to each layer’s own normal movement is what stops a bursty layer producing an exception every night while a steady one has to move enormously before anything is said.
2. Include the things a gate cannot express
The gate is a pass or fail on one run. The report can carry state that only makes sense over time.
def durable_conditions(history: list[dict]) -> list[str]:
latest, notes = history[-1], []
# An index stale for several consecutive nights is a process problem
stale_runs = 0
for m in reversed(history):
states = {v.get("state") for v in m.get("index_health", {}).values()}
if states & {"count mismatch", "stale extents"}:
stale_runs += 1
else:
break
if stale_runs >= 2:
notes.append(f"spatial index stale for {stale_runs} consecutive runs")
# A free-page fraction climbing week over week
frac = [
m["size"]["free_bytes"] / max(m["size"]["bytes_on_disk"], 1)
for m in history if "size" in m
]
if len(frac) >= 7 and frac[-1] > frac[-7] * 1.5 and frac[-1] > 0.15:
notes.append(f"free-page fraction rising: {frac[-7]:.0%} -> {frac[-1]:.0%}")
# How often the gate has intervened
overrides = sum(1 for m in history if m.get("override_reason"))
if overrides:
notes.append(f"{overrides} gate override(s) in the last {len(history)} runs")
return notes
The override count is the one people find most useful in practice. A gate overridden three times in a fortnight is either mistuned or is repeatedly being told to ignore something real, and neither is visible from any single run.
3. Emit plain text
if __name__ == "__main__":
history = load_history()
if not history:
raise SystemExit("no manifests found")
body = render(history)
notes = durable_conditions(history)
if notes:
body += "\n\nTrends\n" + "\n".join(f" {n}" for n in notes)
print(body)
Path("/srv/reports/latest.txt").write_text(body)
Plain text reads in a terminal, in an email, in a chat message and in a log, and it survives being copied into a ticket. Anything richer buys formatting and costs reach — and reach is what decides whether the report is read.
4. Say when nothing happened
if not exceptions and not notes:
out.append("\nNothing requiring attention.")
A report that is silent on a quiet night is indistinguishable from a report that failed to run. One that says “nothing requiring attention” confirms both that the pipeline ran and that it found nothing — which is genuinely useful information and costs one line.
5. Deliver it where it will be seen
# Whatever the environment supports, in order of preference
python3 report.py > /srv/reports/$(date -I).txt
command -v mail >/dev/null && \
mail -s "Integrity report $(date -I)" ops@example.org < /srv/reports/$(date -I).txt
# Isolated environments: leave it beside the artefact, where an operator will find it
cp /srv/reports/$(date -I).txt /srv/artifacts/latest.report.txt
For a pipeline that runs somewhere without a network — which is common in this domain — writing the report next to the artefact is the delivery mechanism. The operator collecting the container gets the report with it.
Verification
The report must be shown to surface a condition it should.
def test_report_flags_a_count_collapse(tmp_path):
"""Fourteen steady runs then a collapse must appear under NEEDS ATTENTION."""
history = [
{"container": "field.gpkg", "layers": {"parcels": {"rows": 12_000 + i * 30}}}
for i in range(13)
]
history.append({"container": "field.gpkg", "layers": {"parcels": {"rows": 600}}})
body = render(history)
assert "NEEDS ATTENTION" in body
assert "parcels" in body.split("NEEDS ATTENTION")[1].split("Steady")[0]
def test_report_is_quiet_on_a_steady_history():
history = [
{"container": "field.gpkg", "layers": {"parcels": {"rows": 12_000 + i * 30}}}
for i in range(14)
]
body = render(history)
assert "NEEDS ATTENTION" not in body
Both directions matter. A report that flags everything is ignored within a week, and one that flags nothing was never doing anything — the second test is what catches the second failure.
Alternative Approaches or Edge Cases
Feeding a monitoring system. Where one exists, emitting the manifest figures as metrics gives dashboards and alerting for free. It does not replace the report: a dashboard answers “what is the value of this number” and a report answers “what should I do this morning”, and in a field-data context the second question is usually the one being asked.
Per-deployment reports. A fleet producing one container per region wants one report per region, plus a roll-up naming the regions with exceptions. The per-region reports are the same code with a different manifest directory; the roll-up is a few more lines and is what somebody actually opens.
Retaining manifests. Fourteen runs is enough for the comparisons here and small enough to keep indefinitely — a manifest is a few kilobytes. Keeping a year costs almost nothing and makes a question like “when did this layer start growing” answerable, which is worth more than the storage.
Troubleshooting
The report flags something every night
Cause: The threshold is not scaled to each layer’s normal movement, so a bursty layer trips it constantly. Fix: Use the run-to-run median as the baseline, as in step 1, rather than a fixed percentage across all layers.
The report is empty
Cause: Fewer than three manifests, so layer_trend returns nothing for every layer. Fix: Fall back to printing the raw figures with a note that no trend is available yet. An empty report is indistinguishable from a broken one.
Nobody reads it
Cause: Almost always the ordering — routine figures first, exceptions buried. Fix: Exceptions at the top, and an explicit “nothing requiring attention” when there are none. A report whose first line is unchanged numbers teaches the reader that the first line is not worth reading.
Frequently Asked Questions
Is a report worth building if a gate already exists?
Yes, because they answer different questions. The gate is binary and per-run: it stops something. The report is comparative and cumulative: it tells you what is drifting, how often the gate has intervened, and whether a condition has persisted. Most of the incidents worth preventing in a field pipeline are visible in the second long before they trip the first.
How long should the history window be?
Long enough to establish what normal looks like and short enough to reflect current behaviour — two weeks is a good default for a nightly pipeline. For a weekly one, use a quarter. The number that matters is how many runs, not how many days, because the comparison is against the distribution of runs.
Should the report include the gate's failures?
Yes, and the overrides especially. A run that failed the gate produced no artefact and no manifest, so the report should note the absence rather than silently skipping it — a gap in the history is itself information. Recording gate outcomes in a small separate log, keyed by date, is enough to make that visible.
Related
- Data Quality Gates & Monitoring — parent guide: the manifests this report reads
- How to Write a Data Quality Gate for a Field Dataset — where the manifests come from
- Monitoring GeoPackage File Growth Over Time — the size profile the trend section reads
- Alerting on Stale Spatial Indexes — the condition the consecutive-runs check watches