Alerting on Pipeline Failures Without Leaking Protected Data
This guide, part of Orchestrating Spatial Surveillance Pipelines, addresses a disclosure route that almost every automated surveillance system has and almost no threat model covers: the alerting channel.
Problem Context & Constraints
An unhandled exception in a geocoding stage produces a traceback containing local variables, and those variables contain addresses. A validation error from a suppression stage prints the cell that failed, and that cell is a count of one. A monitoring system that ingests application logs then stores both outside the protected boundary, indexes them, and shows them to whoever can read the dashboard.
This is a disclosure incident with no attacker in it. It happens because the default behaviour of every logging and alerting stack is to be as informative as possible, and because the people configuring it are thinking about operability rather than confidentiality.
The constraint that resolves it is a strict separation between the fact of a failure and its detail. The fact — run identifier, stage, failure class, counts — is not protected and can travel anywhere an operator needs it. The detail — which record, which address, which cell — stays inside the protected environment and is retrieved by someone with access, using the run identifier as the key.
Two secondary constraints matter. The alert must still be actionable: “stage failed” with no class of failure sends the operator to the logs, which defeats the purpose. And the redaction must be structural rather than a filter, because a regular expression that strips things that look like addresses will miss some and will give false confidence about the rest.
Prerequisites
python3.11pandas2.2.2- A protected store for detail records, keyed by run identifier
- An alerting transport that accepts structured fields rather than a free-text body
- A defined failure taxonomy — the small set of classes an operator can act on
- Agreement on which counts are safe to transmit, from the disclosure policy
Step-by-Step Solution
Raise structured exceptions that carry a class and a safe summary separately from their detail, write the detail inside the boundary, and emit only the safe summary.
# Structured failure handling: safe summaries out, detail inside the boundary.
# Pinned: pandas==2.2.2
import json
import logging
import os
import traceback
from dataclasses import dataclass, field
import pandas as pd
log = logging.getLogger("pipeline.alert")
# The operator can act on exactly these. A class outside the taxonomy is itself
# a defect: it means a failure mode nobody has decided how to respond to.
FAILURE_CLASSES = {
"upstream_missing", "upstream_implausible", "schema_mismatch",
"geometry_invalid", "reconciliation_failed", "suppression_failed",
"service_unavailable", "quota_exhausted", "timeout", "internal_error",
}
# Counts below this are not transmitted even as counts, per the disclosure policy.
MIN_SAFE_COUNT = 11
@dataclass
class StageFailure(Exception):
"""A failure that knows what is safe to say about itself."""
failure_class: str
stage: str
affected: int = 0
detail: dict = field(default_factory=dict) # NEVER transmitted
def __post_init__(self):
if self.failure_class not in FAILURE_CLASSES:
raise ValueError(f"undeclared failure class: {self.failure_class!r}")
def safe_summary(self, run_id: str) -> dict:
"""The payload that may cross the boundary. Nothing else does."""
return {
"run_id": run_id,
"stage": self.stage,
"failure_class": self.failure_class,
# Small counts are themselves disclosive; report a band instead.
"affected": (int(self.affected) if self.affected >= MIN_SAFE_COUNT
else f"<{MIN_SAFE_COUNT}"),
"detail_ref": f"run://{run_id}/failures/{self.stage}",
}
def record_failure(exc: BaseException, run_id: str, stage: str,
protected_dir: str) -> dict:
"""Write full detail inside the boundary; return only the safe summary."""
if isinstance(exc, StageFailure):
failure, summary = exc, exc.safe_summary(run_id)
else:
# An unexpected exception is internal_error; its message may contain
# anything, so it is never used in the summary.
failure = StageFailure(failure_class="internal_error", stage=stage,
detail={"message": str(exc)})
summary = failure.safe_summary(run_id)
os.makedirs(protected_dir, exist_ok=True)
path = os.path.join(protected_dir, f"{run_id}.{stage}.failure.json")
with open(path, "w", encoding="utf-8") as fh:
json.dump({"summary": summary,
"detail": failure.detail,
"traceback": traceback.format_exc()}, fh, default=str)
log.error("stage=%s class=%s run=%s detail_written=%s",
summary["stage"], summary["failure_class"], run_id, path)
return summary
def emit_alert(summary: dict, send_fn) -> None:
"""Transmit the summary. The transport never sees anything else."""
allowed = {"run_id", "stage", "failure_class", "affected", "detail_ref"}
extra = set(summary) - allowed
if extra:
# Fail rather than transmit an unexpected field that somebody added.
raise ValueError(f"alert payload contains undeclared fields: {sorted(extra)}")
send_fn(summary)
log.info("alert emitted run=%s stage=%s class=%s",
summary["run_id"], summary["stage"], summary["failure_class"])
The whitelist in emit_alert is the structural control. Redaction by pattern matching fails eventually because somebody adds a helpful field; a whitelist fails safely, because the unexpected field raises rather than transmits. It is the difference between a policy that depends on everyone remembering and one that depends on the code.
Banding small counts matters more than it first appears. “Suppression failed for 3 cells” is a statement about three cells containing small numbers, and in a sparse disease that is close to a statement about three individuals. Reporting “fewer than 11” costs the operator nothing and removes the question entirely.
Validation & Edge Cases
Third-party libraries logging at debug level. A spatial or HTTP library configured verbosely will log request bodies containing addresses. Set third-party logger levels explicitly rather than inheriting the root level, and review what a debug-enabled run actually emits.
Log aggregation configured after the fact. An agent shipping application logs to an external platform exports everything the application ever logged. Decide what the application logs before deciding where the logs go, and confirm the destination is inside the compliance boundary.
Exception messages constructed from data. A validation library that raises “value ‘JOHN SMITH, 14 ELM ST’ failed pattern” has put the value in the message, and the message goes everywhere. Wrap such libraries so their exceptions are caught and re-raised as a declared class with the value in the detail field.
Metrics that are themselves disclosive. A monitoring dashboard that graphs cases per tract per night is a live small-area count series outside the protected boundary. Operational metrics should be pipeline metrics — rows processed, stage duration, match rate, gate outcomes — and not epidemiological ones. Where a count must be graphed for operational reasons, aggregate it to a level where no cell can be small, and review the dashboard against the same disclosure policy that governs a published map.
On-call handover without access. A content-free alert is only workable if the person receiving it can actually reach the detail. An operator rota that includes people without access to the protected system will, predictably, lead to detail being copied out to them. Either grant the access or route those classes to someone who has it, and record which classes each rota member can action.
Alert transports with retention. A paging service stores message history. Content that seemed transient is retained on a third-party system for years. Treat the alert payload as permanently published.
Screenshots and pasted logs. The most common leak is human: an operator pastes a traceback into a chat channel to ask for help. A content-free alert makes that harmless, which is another reason the redaction has to be at the source rather than at the transport.
ERROR pipeline.alert stage=suppress class=suppression_failed run=20260918-4f2a1c8e detail_written=/protected/failures/20260918-4f2a1c8e.suppress.failure.json
INFO pipeline.alert alert emitted run=20260918-4f2a1c8e stage=suppress class=suppression_failed
The difference between the two payloads is easiest to judge side by side, reading each as if it had already been forwarded to someone outside the agency:
Compliance Notes
Record the failure taxonomy and the action associated with each class, the alert payload schema with its whitelist, the minimum safe count, the location and retention of detail records, the logger configuration for third-party libraries, and the destination and retention of the alerting transport.
Treat the alerting channel as an export route in the system’s data flow documentation, because that is what it is. An agency that maps its disclosure paths through published products and access requests, and omits the monitoring stack, has an unmapped route that carries data every night. Reviewing a week of real alert payloads against the whitelist is a cheap and unusually effective audit — it finds both the helpful extra field somebody added and the library that is logging more than anyone intended.
Related Topics
- Orchestrating Spatial Surveillance Pipelines — the run identity that makes a content-free alert actionable.
- Building a HIPAA-Compliant Spatial Metadata Schema — the policy that sets the minimum safe count used above.
- Building a Data Quality Scorecard for Surveillance Geography — the reporting surface for everything that did not warrant an alert.