Monitoring & Observability for Marine Pipelines

Operational intent: this topic covers what to measure in a running marine spatial pipeline so that a failure is noticed by a machine rather than by a reader of the published product. It sits inside Deployment & Cloud Infrastructure for Marine Pipelines, alongside the orchestration and Kubernetes scheduling topics, and it exists because those two together still leave the most important class of failure invisible.

Marine pipelines fail green. Every failure mode named across this site — an implicit datum assumption, a flipped separation sign, a missing PROJ grid, an axis-order swap, nodata read as depth, a dedup key that merges two vessels — produces a run in which every process exits zero, every pod completes, every dashboard is calm, and the output is wrong. Infrastructure monitoring is built to answer “is it running”, and the answer is yes. Nothing in a standard observability stack asks “is it right”.

What Each Monitoring Layer Can Actually See Two columns of failure modes. The left column, covered by ordinary infrastructure monitoring, lists process crash, out-of-memory kill, job timeout, node preemption and object-store 5xx errors; each is marked as self-announcing, producing a non-zero exit or an error log line, and detected within seconds. The right column lists implicit datum assumption, flipped offset sign, missing PROJ transformation grid, nodata read as a real depth, and a deduplication key that merges two vessels; each is marked as producing a successful run, no error anywhere, and a detection latency measured in weeks or months, with the usual discovery route being a downstream consumer noticing that a number looks wrong. A caption states that the second column is the entire subject of this topic and that no amount of infrastructure instrumentation reaches it. infrastructure layer sees these process crashexit ≠ 0 OOMKilledexit 137 job timeoutdeadline node preemptionpod event object store 5xxerror log detection latency: seconds every one of these announces itself nothing sees these but the data NAD83 read as WGS84exit 0 offset sign flippedexit 0 PROJ grid missingexit 0 nodata as depth 0exit 0 two vessels mergedexit 0 detection latency: weeks to months usual route: a reader notices a number looks odd A green dashboard is evidence that the pipeline ran. It is not evidence about anything the pipeline produced, and the right-hand column is where every expensive incident on a marine pipeline comes from. The instrumentation below is aimed entirely at the right-hand column.

Reference Metric Set

Metric Type Emitted by What it catches
pipeline_dataset_age_seconds gauge every publisher a stalled input, a stopped feed
pipeline_rows_in_total / _out_total counter every stage silent row loss, duplication
pipeline_rows_rejected_total{reason} counter every filter a threshold that started rejecting everything
pipeline_gate_residual{gate} gauge every validation gate slow degradation before a threshold trips
pipeline_coverage_fraction gauge ingestion a receiver or survey line that stopped
pipeline_proj_grid_count gauge container start a rebuild that dropped the datum grids
pipeline_run_manifest_written_total counter publication a product published without provenance
pipeline_output_bytes gauge publication an output an order of magnitude off

Two of these deserve emphasis because they are unusual. pipeline_gate_residual publishes the measured value at each validation gate rather than the pass/fail — the actual control-point drift in metres, the actual mean residual against benchmarks. A gate that passes at 0.048 m against a 0.05 m threshold, three runs in a row after a year at 0.006 m, is a system about to fail, and the boolean cannot say so. And pipeline_proj_grid_count is a one-line check at container start that catches the missing-grid failure which otherwise changes coordinates by a metre with no other symptom.

Freshness Is the Cheapest High-Value Metric

Most silent marine pipeline failures are, in the end, a stopped input: a feed whose connector half-opened, a survey upload that stopped arriving, a tide-gauge API that started returning an empty array with a 200 status. In all of these the pipeline runs on schedule, processes what it has, and publishes something. The product is stale rather than wrong, and staleness is invisible in every graph except one.

A freshness gauge is the age of the newest record in each dataset, published continuously rather than computed on request:

"""Dataset freshness as a Prometheus gauge, one series per product."""
from __future__ import annotations

import logging
import time

from prometheus_client import Gauge

logger = logging.getLogger(__name__)

DATASET_AGE = Gauge(
    "pipeline_dataset_age_seconds",
    "Age of the newest record in a published dataset",
    ["dataset", "environment"],
)


def publish_freshness(dataset: str, newest_epoch_s: float, *, environment: str) -> float:
    """Record how stale a dataset is. Returns the age for logging or assertion."""
    if newest_epoch_s <= 0:
        raise ValueError(f"{dataset}: no records found; cannot compute freshness")
    age = time.time() - newest_epoch_s
    if age < 0:
        # A record from the future means a clock or a timestamp-parsing problem,
        # which is itself worth alerting on rather than clamping away.
        raise ValueError(f"{dataset}: newest record is {-age:.0f} s in the future")
    DATASET_AGE.labels(dataset=dataset, environment=environment).set(age)
    logger.info("freshness %s: %.0f s", dataset, age)
    return age

The rule that makes this work is that the gauge is published by the consumer of the dataset, not by its producer. A producer that has stopped running publishes nothing at all, and a metric that stops being reported is easy to miss; a consumer that is still running publishes a steadily rising age, which is unmissable. Alert on the age crossing a multiple of the dataset’s expected update interval — three times is a reasonable default — rather than on a fixed number of hours, so the same rule works for a two-second AIS feed and a monthly bathymetric mosaic.

Alert on Change, Not on Absolutes

Marine data is seasonal, weather-dependent and fleet-dependent. Traffic falls in winter storms. Survey volumes fall when the vessel is in port. Message rates fall when a satellite pass ends. A static threshold on any of these produces alerts that correlate with the weather, and a team that has learned to ignore them.

The alternative is to compare each metric against its own recent history. A simple and robust form is the ratio of the current value to the median of the same window on the previous n days, which handles both the daily cycle and the seasonal drift without any modelling:

# Prometheus rule: the feed is delivering far less than it usually does at
# this hour, regardless of what "usual" means this month.
- alert: AISIngestRateCollapsed
  expr: |
    rate(pipeline_rows_in_total{stage="ais_ingest"}[15m])
      /
    quantile_over_time(0.5,
      rate(pipeline_rows_in_total{stage="ais_ingest"}[15m])[7d:1h] offset 1d
    ) < 0.4
  for: 20m
  labels: {severity: page, team: data}
  annotations:
    summary: "AIS ingest at {{ $value | humanizePercentage }} of its 7-day norm"

The for: 20m clause matters as much as the expression. Satellite feeds are bursty by nature and a fifteen-minute window will dip below any threshold occasionally; requiring the condition to hold for twenty minutes removes essentially all of that without delaying a real outage meaningfully.

Static Thresholds Alert on the Weather A week of AIS message-rate data showing a strong daily cycle with overnight lulls, a three-day depression during a storm, and a sharp genuine outage on the final day. A horizontal static threshold line crosses the trace during every overnight lull and throughout the storm, producing eleven alerts of which one is real. A second panel shows the ratio of the same series to the median of the same hour over the previous seven days; the daily cycle and the storm both flatten out near one, and only the genuine outage drops below the 0.4 ratio threshold, producing one alert. A caption notes that the second rule needs no seasonal tuning and works unchanged for a monthly product. raw rate with a static threshold — 11 alerts, 1 real static threshold storm real outage ratio to the 7-day median for the same hour — 1 alert, 1 real ratio < 0.4 The same rule works unchanged for a two-second feed and a monthly mosaic, because it never mentions an absolute rate.

Structured Logs Are the Second Half

Metrics say that something changed; logs say what. The two are complementary and the join between them is the run identifier. Every log line emitted anywhere in a marine pipeline should carry the run id, the stage name and the dataset it concerns, as structured fields rather than as English embedded in a message:

import json
import logging


class RunContextFilter(logging.Filter):
    """Attach the run identity to every record, so logs join to metrics."""

    def __init__(self, run_id: str, stage: str) -> None:
        super().__init__()
        self.run_id, self.stage = run_id, stage

    def filter(self, record: logging.LogRecord) -> bool:
        record.run_id = self.run_id
        record.stage = self.stage
        return True


class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
            "level": record.levelname,
            "run_id": getattr(record, "run_id", None),
            "stage": getattr(record, "stage", None),
            "msg": record.getMessage(),
        }
        if record.exc_info:
            payload["exc"] = self.formatException(record.exc_info)
        return json.dumps(payload)

The specific thing this buys on a marine pipeline is the ability to answer “which survey lines went through the version of the code that had the wrong grid” — a question that comes up after every incident, and that is unanswerable if the logs record only what happened and not which run it happened in. The run manifest carries the same identifier, so a metric anomaly leads to a run, the run leads to its logs and its manifest, and the manifest names the inputs and the image digest.

Tracing a Batch Pipeline Without a Request

Distributed tracing was designed for request-response systems: a user request enters, fans out across services, and the trace is the tree of spans under it. A marine pipeline has no request. It has a nightly run that touches four hundred survey tiles across three stages over ninety minutes, and the useful unit of causality is the tile, not a call.

The adaptation that works is to treat the run as the trace and the tile as the span. Each tile’s processing emits one span carrying the tile identifier, the stage, the duration, and the attributes that matter for diagnosis — input row count, output row count, the CRS in and out, the number of rejections. A trace then shows four hundred parallel spans and, crucially, shows which ones were slow and which ones were anomalous in a data sense rather than a timing sense.

That last part is what makes it worth the effort on a batch system. Timing outliers on a tile pipeline are usually uninteresting — a tile is slow because it is dense. Data outliers are the whole game: the one tile out of four hundred whose output row count is a tenth of its input, or whose CRS attribute reads EPSG:4326 when every other tile reads EPSG:32618. Sorting spans by a data attribute rather than by duration surfaces those immediately, and nothing in a metrics dashboard can, because per-tile detail is exactly the cardinality that metrics cannot carry.

def tile_span_attributes(tile_id: str, result) -> dict[str, object]:
    """Attributes worth attaching to a per-tile span. Diagnosis, not timing."""
    return {
        "tile.id": tile_id,
        "tile.rows_in": result.rows_in,
        "tile.rows_out": result.rows_out,
        "tile.rows_rejected": result.rows_rejected,
        "tile.retention": (result.rows_out / result.rows_in) if result.rows_in else 0.0,
        "tile.crs_in": str(result.crs_in),
        "tile.crs_out": str(result.crs_out),
        "tile.drift_p95_m": result.drift_p95_m,
        "tile.coverage_fraction": result.coverage_fraction,
    }

Where a full tracing backend is more weight than the deployment justifies, the same value is available for far less: write one structured log line per tile with these fields, and query it. The point is not the tracing system; it is that per-tile attributes exist somewhere queryable, so that “which tile went wrong” is a filter rather than an investigation.

Sort the Spans by a Data Attribute, Not by Duration Two sorted bar charts over the same four hundred per-tile spans from one pipeline run. The left chart sorts by span duration; the distribution is smooth, the longest tiles take about three times the median, and an annotation explains that these are simply the densest tiles and that nothing is wrong with any of them. The right chart sorts by retention ratio, defined as output rows divided by input rows; almost every tile sits between 0.96 and 1.0 in a tight band, and a single tile sits at 0.09, standing far outside the distribution. An annotation identifies that tile as the one whose CRS attribute differs from every other tile in the run, which is the actual failure and is invisible in the duration view. sorted by duration — nothing is wrong a smooth tail: the densest tiles take ~3× the median no threshold here separates anything meaningful sorted by rows_out / rows_in — one tile tile 217 — retention 0.09 crs_out = EPSG:4326, unlike all 399 others 399 tiles, 0.96–1.00 Per-tile data attributes are exactly the cardinality a metrics backend cannot hold — which is why they belong in spans or structured logs, and why a run with only metrics can tell you something is wrong but never which tile. The metric said the run’s retention fell to 0.9977. The span said which of four hundred tiles to open.

Validation Gates and Quality Control

Gate 1 — every published dataset has a live freshness series. A dataset with no pipeline_dataset_age_seconds series is unmonitored, and the check is a query against the metrics backend rather than a code review. Run it in CI against the list of published products.

Gate 2 — rows in equals rows out plus rows rejected. For every stage, per run. A discrepancy is an unaccounted row, which is either loss or duplication; both matter and neither shows up anywhere else.

Gate 3 — the alert rules themselves are tested. An alert that has never fired is an alert that may not work. Replay a recorded outage against the rule set in CI — Prometheus’s promtool test rules does exactly this — and assert that each rule fires on the incident it was written for. This is the observability equivalent of the fleet-wide habit of feeding a checker input it must reject.

Retention: keep the summary far longer than the samples

Metric storage is sized for operations — days to weeks of high-resolution samples, which is right for answering “why is the cluster slow now”. Marine data questions arrive on a completely different timescale: a hydrographic office comparing this season’s survey against one from three years ago wants to know whether the two pipelines were in the same state, and the metrics that would answer it expired long ago.

The resolution is to separate the two retention needs rather than to extend one of them. Keep raw samples at their default retention for operational debugging. Separately, at the end of every run, write a small, permanent summary — the gate residuals, row counts, coverage fraction, library versions and image digest — into the run manifest that ships with the product. That record is a few hundred bytes, it lives with the data rather than in the monitoring system, and it survives every migration the monitoring stack will go through in the years the product is in use.

The rule of thumb worth adopting: if a number would be needed to defend a published product rather than to operate the pipeline, it belongs in the manifest, not only in the time-series database. Operational metrics answer questions about now; a marine data product is asked questions about a run that finished years ago, and the two storage systems have almost nothing in common.

A related trap is aggregation. Long-retention tiers usually downsample by averaging, which is exactly wrong for the gate residuals: the useful statistic is the maximum drift observed, and averaging it away turns a run with one bad tile into a run that looks clean. Record the maximum and the p95 explicitly as their own series, so the downsampler has nothing to destroy.

Common Failure Modes

Monitoring the orchestrator instead of the data. Prefect’s own dashboard shows flow-run success, which is the infrastructure question again. A flow that ran perfectly on empty input is a green flow run and an empty product.

Cardinality explosion from per-vessel labels. Labelling a metric by MMSI produces tens of thousands of series and will take down a Prometheus instance. Aggregate to fleet, class or region; keep per-vessel detail in logs, which are built for high cardinality.

Alerts routed to whoever is on call. A datum error is not actionable by the infrastructure team and a node failure is not actionable by the data team. Route by team label from the start; alerts sent to people who cannot act on them are how a team learns to ignore alerts.

Pipeline Integration and Downstream Handoff

The metrics endpoint is scraped per pod; the freshness gauges are published by consumers; the structured logs ship to whatever aggregator the organisation already runs. The one artefact this topic adds to the pipeline’s outputs is the per-run metric snapshot, written into the run manifest alongside the parameters: the gate residuals, row counts and coverage fractions as they stood at publication.

That snapshot is what makes an old product diagnosable. Two years later, the metrics backend has long since expired its retention, but the manifest still records that this particular mosaic was published with a control-point drift of 0.041 m and 96.2% coverage — which is exactly the information needed to decide whether it can be compared with a new one.

Up: Deployment & Cloud Infrastructure for Marine Pipelines