Bounding Spatial Metric Tag Cardinality

Every label attached to a spatial metric multiplies the number of time series a database has to index, and geospatial pipelines are unusually good at exploding that product: a single counter tagged by layer, geometry_type, and source across a few hundred vendor feeds can mint tens of thousands of active series from one instrument, quietly starving the time-series database that every other spatial alert depends on. This page sets cardinality budgets for the gis.spatial.* and gis.etl.* families and shows how an OpenTelemetry transform and filter processor drops, buckets, and aggregates the labels that cause the blow-up. It extends the geospatial metric taxonomy for ETL and the parent geospatial observability architecture, for the data engineers and SREs who pay the storage bill.

Bounding spatial metric series count in the collector A raw spatial metric carrying layer, geometry_type, and source labels produces a series count equal to the product of their distinct values, which exceeds the budget. An OpenTelemetry transform and filter processor deletes the source label, buckets layer into a coarse layer_class, and aggregates the datapoints, yielding a series count well under budget before export to the time-series database. Raw spatial metric labels: layer · geometry_type · source |series| = 40 × 6 × 130 = 31,200 (over budget) OTel transform + filter processor delete source · bucket layer · aggregate |series| = 6 × 6 = 36 ≤ budget 500 · to TSDB

Where spatial cardinality comes from

Cardinality is multiplicative, and spatial pipelines carry labels whose value spaces multiply badly. A metric’s active-series count is the product of the distinct values each of its labels can take:

Cmetric=LVBC_{\text{metric}} = \prod_{\ell \in L} |V_\ell| \le B

The moment one label in LL is effectively unbounded, the product is unbounded too, no matter how tame the others are. Three offenders recur in geospatial telemetry. source is the classic one: a vendor or feed identifier grows every time a new provider is onboarded, and it never shrinks. layer looks bounded but frequently is not — pipelines that mint a layer name per tile batch (parcels_20260714_z14_x8) turn a “layer” label into a timestamp in disguise. And any attempt to attach a raw bounding box, feature id, or file path as a label is instant catastrophe, because the value is unique per feature.

The labels that are safe are the small, stable ones the taxonomy already blesses: geometry_type (a closed set of a half-dozen OGC types), pipeline.stage (a fixed list of ingest, transform, validate, index, publish), and expected_srid (a short authorized list). The design goal is to keep those and tame everything else, a discipline the observability scoping rules for vector data apply at the geometry level and this page applies at the label level.

Setting a cardinality budget

Give every metric a written budget before you instrument it, expressed as a maximum active-series count. A practical starting envelope: keep any single gis.spatial.* or gis.etl.* metric under a few hundred series, and the whole spatial namespace under low tens of thousands, so a TSDB scrape stays cheap and the retention footprint is predictable. The budget is not a suggestion — it is the number the collector configuration is engineered to satisfy.

Turning a budget into label rules is mechanical. For each candidate label, ask what its value space is at steady state and where it goes in a year. geometry_type is fixed forever, so it stays. source grows without bound, so it is dropped from metrics and preserved only on spans, where it costs nothing per-series. layer is bucketed into a coarse layer_class (cadastral, imagery, transport, hydrography) with an other catch-all, converting an unbounded value space into a fixed one. The result is a metric whose series count is the product of a handful of small, provably-bounded sets.

Because dropping a label at the collector is an aggregation, decide before you drop whether any alert needs the granularity. If an alert genuinely routes by source — the SRID re-tag rules described in the sibling material do — that detail belongs on a separate, deliberately narrower metric, not smeared across the whole namespace. The decision about which failure modes deserve full validation versus sampled telemetry is set upstream by the spatial data trust boundaries, and cardinality budgeting inherits it.

Dropping and bucketing labels in the collector

Do the reduction in the collector, not the application, so a new noisy feed cannot blow the budget without a config change reviewed by whoever owns the TSDB. The contrib transform processor runs OTTL statements against each datapoint’s attributes — the right tool for deleting a label outright and for rewriting a high-cardinality value into a coarse bucket:

# otel-collector-config.yaml  (contrib build)
processors:
  transform/cardinality:
    metric_statements:
      - context: datapoint
        statements:
          # 1. Drop the unbounded vendor id from metrics — it survives on spans only.
          - delete_key(attributes, "source")
          # 2. Collapse per-batch layer names (parcels_20260714_z14) to a stable stem.
          - replace_pattern(attributes["layer"], "_[0-9]{8}.*$", "")
          # 3. Bucket the stem into a fixed layer_class, with an 'other' catch-all.
          - set(attributes["layer_class"], "cadastral") where IsMatch(attributes["layer"], "parcel|cadastr")
          - set(attributes["layer_class"], "imagery")   where IsMatch(attributes["layer"], "ortho|raster|tile")
          - set(attributes["layer_class"], "transport") where IsMatch(attributes["layer"], "road|rail|transit")
          - set(attributes["layer_class"], "other")     where attributes["layer_class"] == nil
          - delete_key(attributes, "layer")             # remove the raw stem once bucketed

  # Belt-and-braces: keep only the spatial families so a stray debug metric can't leak in.
  filter/spatial_only:
    metrics:
      metric:
        - 'not (IsMatch(name, "gis\\.spatial\\..*") or IsMatch(name, "gis\\.etl\\..*"))'

The filter/spatial_only block drops any metric whose name is outside the two blessed families, so a misconfigured instrument that emits a high-cardinality debug counter never reaches the exporter. Combined with the label rewrites above, the metric that entered with 31,200 potential series leaves with a few dozen — the reduction the diagram traces.

Aggregating over a dropped label

Deleting a label from a counter without summing over it produces duplicate series that the backend must reconcile, which can itself be expensive. The metricstransform processor aggregates explicitly, folding all the per-source datapoints into a single sum per surviving label set so the export is both bounded and arithmetically correct:

  metricstransform/aggregate:
    transforms:
      - include: gis.spatial.topology_error_total
        action: update
        operations:
          - action: aggregate_labels
            # Keep only the bounded labels; sum away everything else.
            label_set: [layer_class, geometry_type]
            aggregation_type: sum
      - include: gis.etl.transform_duration
        action: update
        operations:
          - action: aggregate_label_values
            label: layer_class
            # Fold the long tail of rare classes into one bucket.
            aggregated_values: [hydrography, landuse, admin]
            new_value: other
            aggregation_type: sum

Order the processors so the transform rewrites labels first and the metricstransform aggregates second; aggregating before the rewrite would sum over labels that are about to change. The companion guide on setting up spatial pipeline health checks in Airflow reads the aggregated layer_class series directly, so its gate queries stay cheap even as new feeds are onboarded.

Verifying the series count

Measure, do not assume. Prometheus can report exactly how many series each spatial metric produces after the collector has done its work, which is the number to compare against the budget:

# Active series per spatial metric — compare each against its written budget.
sort_desc(
  count by (__name__) ({__name__=~"gis_(spatial|etl)_.*"})
)

# Which label is the current cardinality bomb on one metric?
count(count by (layer_class) (gis_spatial_topology_error_total))   # expect a small, fixed number
count(count by (source)      (gis_spatial_topology_error_total))   # expect 0 — source must be gone from metrics

The second query is the regression test that matters: if source still produces any series on a gis.spatial.* metric, a label rule was skipped or the processor order is wrong. Wire a low-effort alert on the total spatial series count so a new feed that reintroduces unbounded labels trips a warning long before it exhausts TSDB memory — the same philosophy of pre-empting silent resource exhaustion that governs the sampling of high-vertex polygons, applied to labels instead of vertices.

FAQ

Which spatial label is usually the cardinality bomb?

source first, layer a close second. source grows with every onboarded vendor and never shrinks, and layer is often secretly unbounded because pipelines mint a fresh layer name per tile batch or per date. geometry_type, pipeline.stage, and expected_srid are safe — their value spaces are small closed sets. Audit any label whose distinct-value count rises over time before it reaches production.

transform processor or metricstransform processor — which drops a label?

Both can remove a label, but they serve different roles. Use the transform processor’s OTTL delete_key and replace_pattern to delete an attribute or rewrite it into a coarse bucket per datapoint. Use metricstransform when you need to aggregate — sum the datapoints over the removed label so you do not emit duplicate series. In practice you run transform first to reshape labels, then metricstransform to fold them.

How do I keep per-source detail without paying for it in the TSDB?

Keep source on spans and drop it from metrics. Traces are stored by trace id, not by a label cross-product, so a high-cardinality source attribute on a span costs nothing comparable to a metric series. When an incident needs per-vendor detail, you pivot from the bounded metric to the trace that carries the full attribute set, rather than paying for that cardinality on every counter continuously.

What is a safe cardinality budget per metric?

A workable default is a few hundred active series per individual gis.spatial.* or gis.etl.* metric and low tens of thousands across the whole namespace, but the real number is whatever your TSDB memory and retention can hold at your scrape interval. Write the budget down per metric, engineer the collector to meet it, and alert when the measured series count approaches it.

Does dropping a label break my SRID or topology alerts?

Only if that alert routed on the dropped label. Before you drop, list the alerts that read the metric and check their by (...) clauses. If one genuinely needs source — an SRID re-tag alert that pages per vendor, for instance — give it a dedicated, deliberately narrow metric that keeps the label, rather than keeping source on every counter in the namespace.