Spatial Observability & Cost Control

Spatial models degrade differently from ordinary ones. A model that joins two tables on an integer key gets slower in proportion to its inputs; a model that joins on geometry can get ten times slower because one boundary file was reissued with more vertices, or because a table was rebuilt without its index, while its row counts barely move. Cost behaves the same way — geometry columns are wide, tile pyramids multiply, and a single unbounded predicate can outspend a month of ordinary queries in an afternoon.

This topic covers the instrumentation that makes those changes visible: a per-model run-time baseline built from dbt’s own artifacts, storage accounting that attributes bytes to geometry rather than to tables, drift detection that compares the shape of the data between builds, and the small set of alerts worth waking someone for. It is the operational counterpart to the design work in spatial data architecture and governance, and it is what stops a healthy pipeline from becoming an expensive one without anyone noticing.

Prerequisites checklist

  • dbt artifacts retained between runs. run_results.json and manifest.json from every build, landed somewhere queryable; they are the cheapest telemetry available and most projects throw them away.
  • A place to store history — one schema holding run history, storage snapshots and drift metrics, rebuilt by dbt like any other model.
  • Warehouse metadata access: INFORMATION_SCHEMA.JOBS on BigQuery, QUERY_HISTORY on Snowflake, pg_stat_statements and pg_class on PostGIS.
  • An owner per model. An alert that names a model but not a person becomes a filtered notification within two weeks.
  • A cost ceiling already in place on consumption-priced engines, as described in warehouse-native GIS adapters. Observability tells you what happened; the ceiling stops it happening.

Architecture context: what to instrument

The observability loop around a spatial dbt project A cycle with four stations. The nightly build emits run results and manifest artifacts. Those are loaded into a history model alongside warehouse metadata and storage snapshots. Metrics are computed from the history: run time against baseline, bytes per model, vertices per feature and row-count drift. Breaches raise an alert naming the model and its owner, which feeds back into the next build as a fix. nightly build run_results.json manifest.json history models artifacts + warehouse metadata + storage metrics run time vs baseline bytes · vertices · drift alert with an owner names the model, the metric and the person threshold breached fix lands in the next build

Nothing in that loop is spatial-specific except the metrics, and that is the point: the mechanism is ordinary dbt, and the judgement is in choosing what to measure. Four measurements carry almost all the value.

Metric Where it comes from What it catches
Model run time against a rolling baseline run_results.json A join that lost its index or its predicate
Bytes scanned or credits per model warehouse metadata An unbounded query or a widened geometry column
Total vertices per model sum(ST_NPoints(geom)) A reissued boundary file with ten times the detail
Row-count and null-rate drift the models themselves A grain change or a silently failing source

Row counts alone are the metric people reach for first and the least informative of the four here: a spatial pipeline can double its cost with identical row counts, because the change is inside the geometry.

Configuration walkthrough

Land the artifacts first. The dbt_artifacts package does this well, but a twenty-line seed of your own is enough to start and has no version constraints to manage.

bash
# In the orchestration job, after every build
dbt build --target prod
python scripts/load_artifacts.py \
    --run-results target/run_results.json \
    --manifest    target/manifest.json \
    --table       ops.dbt_run_history
sql
-- models/ops/int_model_runtime_baseline.sql
{{ config(materialized = 'table') }}

with runs as (
    select
        node_id,
        model_name,
        run_started_at::date as run_date,
        execution_time_seconds
    from {{ source('ops', 'dbt_run_history') }}
    where status = 'success'
      and run_started_at > current_date - interval '30 days'
)

select
    model_name,
    count(*)                                    as runs_observed,
    round(avg(execution_time_seconds)::numeric, 1)  as mean_seconds,
    round(stddev_samp(execution_time_seconds)::numeric, 1) as stddev_seconds,
    round(max(execution_time_seconds)::numeric, 1)  as worst_seconds,
    round((avg(execution_time_seconds) + 3 * coalesce(stddev_samp(execution_time_seconds), 0))::numeric, 1)
        as alert_threshold_seconds
from runs
group by model_name

A three-sigma threshold derived from the model’s own history is better than a fixed number for one reason: spatial models have wildly different natural run times, and a single global “alert above ten minutes” rule either misses a staging model that tripled or fires nightly on the one join that legitimately takes twenty.

Geometry storage needs its own accounting, because a table’s total size hides where the bytes are:

sql
-- models/ops/int_geometry_storage.sql
select
    table_name,
    column_name,
    count(*)                                         as rows,
    round(sum(st_memsize(geom)) / 1024.0 / 1024, 1)  as geometry_mb,
    round(avg(st_npoints(geom)))                     as avg_vertices,
    max(st_npoints(geom))                            as max_vertices
from {{ ref('mart_zones') }}
group by 1, 2

Validation and testing

Observability models deserve tests as much as the models they watch — a broken metric is worse than no metric, because it is trusted.

yaml
models:
  - name: int_model_runtime_baseline
    tests:
      - dbt_utils.expression_is_true:
          expression: "runs_observed >= 5"
          config:
            severity: warn
    columns:
      - name: alert_threshold_seconds
        tests:
          - not_null
          - dbt_utils.accepted_range:
              min_value: 1

The runs_observed check is the one that matters: a threshold computed from two runs is noise, and it will fire on the third. Warn rather than error, so a newly added model does not fail the build while its history accumulates.

Then assert the thing the metrics exist to catch, directly:

sql
-- tests/assert_no_model_exceeded_baseline.sql
select
    r.model_name,
    r.execution_time_seconds,
    b.alert_threshold_seconds
from {{ source('ops', 'dbt_run_history') }} r
join {{ ref('int_model_runtime_baseline') }} b using (model_name)
where r.run_started_at > current_date - interval '1 day'
  and b.runs_observed >= 5
  and r.execution_time_seconds > b.alert_threshold_seconds
A spatial model's run time against its rolling baseline, with the regression visible before anyone complained A run-time series over thirty days. The line sits near four minutes with small variation, inside a shaded three-sigma band, then steps up to eleven minutes on day twenty-two and stays there. An annotation marks the step as the day a boundary file was reissued with far more vertices, and notes that row counts did not change, so a row-count monitor would have stayed silent. 3σ baseline band boundary file reissued 14× the vertices, same row count minutes per run 30 days of nightly builds → A row-count monitor sees nothing here. A run-time baseline sees it on the first night.

Deciding what deserves an alert

Most observability projects fail by measuring well and alerting badly. The useful filter is to ask what a person would do in the next hour if the alert fired at three in the morning. For a spatial pipeline, only three answers survive that question.

The first is a build that failed or a data-quality test that went red, because a downstream consumer is about to read something wrong; this is the only category that belongs in a paging channel. The second is a cost event large enough that waiting until morning costs real money — a single query that blew through a byte ceiling, or a job whose credits are already several times the daily average. The third is a freshness breach on a feed that something operational depends on, which is a source problem rather than a model problem and is covered by source freshness for geometry feeds.

Everything else — a model 40 per cent slower than baseline, storage up 5 per cent this month, vertex counts creeping — belongs in a weekly review, where the trend is visible and the response is a piece of planned work rather than an interrupt. Splitting the two channels on that basis is what keeps the paging channel credible, and a paging channel that people trust is worth more than any individual metric in it.

Reading the metrics together

The four measurements are most useful read as a set, because their combinations identify the cause without further investigation. Run time up with vertices up is a source-detail change, and the fix is upstream simplification or a subdivision step. Run time up with vertices flat and bytes flat is almost always a physical-layout change — an index that was not recreated after a rebuild, or a clustering key that a full refresh did not reapply. Bytes up with run time flat points away from the DAG entirely and toward a consumer querying the models directly. And row counts flat with null rates up is a source feed degrading in place, which no performance signal will ever surface because the build gets marginally faster as the real data thins out.

Writing those four combinations into the runbook next to the dashboard turns a chart into a diagnosis. It also sets a useful bar for adding a fifth metric: a new measurement earns its place when it distinguishes two causes the existing four cannot tell apart, not when it is merely interesting.

Advanced patterns

Track vertices, not just rows. A single query summing ST_NPoints per model, stored per build, is the earliest warning of a source-data change that will make everything downstream slower. It costs a scan and it explains the run-time chart above.

Attribute cost to consumers, not just to models. A query tag per consumer — set in the profile or per model — turns “the warehouse costs X” into “the map costs X and the exports cost Y”, which is the only form in which the number is actionable. The serving-tier split in serving spatial data to consumers exists partly to make this attribution possible.

Alert on the derivative, not the level. “This model took eleven minutes” is not actionable; “this model takes three times as long as its own thirty-day baseline” is. The same applies to bytes scanned and to storage.

Keep a monthly snapshot of geometry storage per table. Storage grows quietly and nobody notices until a bill or a disk does. A snapshot model with one row per table per month makes the trend visible and the projection trivial — see estimating storage cost of geometry columns.

Compare the shape of the data, not only its size. Bounding-box extent, centroid distribution and null rates change when a source feed changes upstream, often before the row counts do; that comparison is developed in alerting on geometry drift between runs.

Which signal fires first for four common spatial incidents A grid of four incidents against four signals. A dropped index is caught first by run time. A reissued boundary file with more detail is caught first by vertex count. An unbounded ad-hoc query is caught first by bytes scanned. A silently failing source feed is caught first by row-count and null-rate drift. The grid shows that no single signal covers more than one incident first. incident first signal silent to index dropped by a rebuild run time vs baseline rows, vertices, bytes boundary file reissued in detail vertices per model row counts unbounded ad-hoc query bytes scanned per tag every model metric source feed quietly emptying row-count and null drift run time — it gets faster The last row is the trap: a broken feed makes the build faster, which no performance alert will ever flag.

Keeping the history honest across environments

One detail decides whether a baseline stays trustworthy for more than a few weeks: what counts as a comparable run. A model built by a full refresh, a model built in a development target against a sampled dataset, and a model built by the nightly production job all appear in the artifacts as the same node with the same name, and averaging them produces a threshold that means nothing. Record the invocation context — target name, whether the run was a full refresh, and the selection string — alongside every row of run history, and compute baselines only within one context.

The same applies to backfills. A one-off rebuild of two years of history legitimately takes an hour and will inflate a thirty-day mean enough to hide a genuine regression for a month afterwards. Excluding full refreshes from the baseline is the simplest rule that works; a stricter version excludes any run whose selected node count differs materially from the nightly build’s, which also filters out the ad-hoc single-model runs that people do while debugging.

Retention deserves a decision rather than a default. Ninety days of per-model run history is small — a few thousand rows for most projects — and it is what lets you answer “was this always slow, or did it change in March?” Storage snapshots can be monthly and kept for years at negligible cost. Raw warehouse query history is the expensive one, and it is usually best summarized into per-model, per-day aggregates on ingest and then discarded at the source’s own retention period, since nobody re-reads individual query rows from six months ago.

Troubleshooting

Symptom Root cause Fix
Alerts fire nightly and get ignored Fixed thresholds instead of per-model baselines Compute thresholds from each model’s own history and require a minimum number of observations
A model got slower but no metric changed Only row counts are tracked Add vertices per model and bytes scanned
Cost rose with no build change An ad-hoc or downstream consumer, not the DAG Tag queries by consumer and attribute cost outside the build
Baseline is polluted by a one-off backfill Full refreshes mixed into the same history Record the invocation type and exclude full refreshes from the baseline
Storage grows steadily with stable row counts Geometry precision or bloat, or an un-vacuumed table Snapshot geometry bytes per table monthly; see the clustering and vacuum guidance for large tables
Alert names a model but nobody acts No owner on the model Add an owner meta field and route the alert to it

FAQ

Is the dbt_artifacts package worth adopting, or should I roll my own?

Adopt it if you want dashboards quickly and are comfortable tracking a package’s version against your dbt version. Roll your own — a loader script and two models — if you need only run time and status, which is enough for the baseline above. The important decision is not which, but that the artifacts stop being deleted at the end of every job.

How many observations before a baseline is trustworthy?

Five is enough to catch a threefold regression; twenty gives a usable standard deviation. Until then, warn rather than alert. The failure mode of an eager baseline is an alert on the third run of a new model, which trains everyone to ignore the channel.

Should cost alerts go to the same place as data-quality alerts?

No. Data-quality failures block a release and belong in the build’s own failure path; cost regressions rarely need action within the hour and belong in a review that happens weekly. Mixing them means the urgent ones get filtered along with the routine ones.

What is worth measuring on PostGIS, where there is no per-query bill?

Run time and storage, plus index health. Without a bill, the constraints are the build window and the disk, so the equivalent of a cost alert is a run-time baseline and a table-size trend. pg_stat_user_tables also reveals sequential scans on tables that should be index-served, which is the PostGIS-specific signal worth adding.

How do I stop observability models from becoming expensive themselves?

Aggregate at write time and never scan raw history from a dashboard. One row per model per run, one row per table per month, and a materialized summary the dashboards read. A metrics layer that costs a noticeable fraction of what it measures has stopped being worth it.

Up: Part of Spatial Data Architecture & Governance.

Explore this section