Sampling Telemetry for High-Vertex Polygons
A telemetry pipeline that treats every geometry the same will bankrupt itself on the tail: a single administrative-boundary multipolygon with 400,000 vertices, or a LiDAR-derived point cloud tile, generates more per-feature spans and attribute payload than ten thousand simple points combined, and emitting full-fidelity telemetry for it is both wasteful and self-defeating because the collector back-pressures and drops the very signals you most need. This page shows how to bound telemetry cost on high-vertex polygons and dense point clouds with complexity-aware sampling keyed on gis.spatial.vertex_count. It sits under Observability Scoping Rules for Vector Data and within Geospatial Observability Architecture & Fundamentals, and is written for the data engineers, GIS platform admins, and SREs who own telemetry cost during heavy spatial joins.
The cost is in the tail, not the mean
Vector telemetry cost is dominated by a handful of pathological geometries. In most parcel or boundary datasets the vertex-count distribution is heavily right-skewed: the median feature is a few dozen vertices, but the 99.9th percentile is tens or hundreds of thousands. When an observability agent attaches a per-feature span carrying the geometry envelope, vertex count, validity result, and CRS to every feature, the payload for those tail geometries dwarfs everything else. Worse, the expensive spans arrive in bursts — a bulk load of dense boundaries — precisely when the collector is already busy, so the export buffer saturates and the batch processor sheds datapoints indiscriminately. The features you can least afford to lose telemetry for are the ones a naive uniform sampler drops first.
This is the resource-scope problem the Observability Scoping Rules for Vector Data guide frames as capping telemetry at roughly 15% CPU and 256 MB RSS per worker: an over-eager agent that samples every vertex becomes back-pressure during heavy spatial joins. The fix is not a flat sample rate — a flat 10% either keeps the tail too expensive or throws away too much of the simple-feature signal. It is stratified, complexity-aware sampling: make the keep-probability a function of gis.spatial.vertex_count, so a feature’s telemetry cost is roughly constant regardless of its size. Simple geometries are cheap, so keep them all; high-vertex geometries are expensive, so keep a thin per-feature sample and rely on an always-on aggregate for the rest.
Complexity-aware sampling only works if the complexity is known before the sampling decision. That means stamping vertex count at ingestion, from the runtime that already has the geometry in hand, rather than re-parsing it in the collector. The canonical measure is ST_NPoints in PostGIS or GetPointCount walking the OGR geometry; both are cheap relative to the join or reprojection the feature is about to undergo. Attach the count as a span attribute at the ingestion zone, where architecting fallback routing for spatial feature servers already draws the admission boundary, and every downstream sampler can read it without touching the geometry again.
Stamping vertex count at ingestion
The producer computes the vertex count once and records it as an attribute on the processing span, then applies the band’s keep-probability locally so an already-decided-drop feature never even builds its heavy attribute payload. Emitting the aggregate histogram unconditionally is what keeps the dropped tail observable.
# ingestion_sampler.py — runs at the vector ingestion boundary
import random
from opentelemetry import trace, metrics
from osgeo import ogr
tracer = trace.get_tracer("gis.spatial.vector_ingest")
meter = metrics.get_meter("gis.spatial.vector_ingest")
# Aggregate histogram is ALWAYS recorded, even for dropped-from-tracing features.
vertex_hist = meter.create_histogram(
"gis.spatial.vertex_count",
unit="{vertex}",
description="Vertex count per ingested feature, bucketed by complexity",
)
# keep-probability as a function of complexity band
def keep_probability(vertex_count: int) -> float:
if vertex_count < 1_000:
return 1.0 # simple: cheap, keep everything
if vertex_count < 50_000:
return 0.10 # medium: representative 10%
return 0.01 # high-vertex tail: thin per-feature sample
def ingest_feature(feature: ogr.Feature, layer: str) -> None:
geom = feature.GetGeometryRef()
n = geom.GetPointCount() if geom.GetGeometryCount() == 0 else _walk_points(geom)
# 1) Aggregate signal first — this is never sampled away.
vertex_hist.record(n, {"layer": layer})
# 2) Per-feature span only when the band's dice roll says keep.
if random.random() > keep_probability(n):
return
with tracer.start_as_current_span("feature_ingest") as span:
span.set_attribute("gis.spatial.vertex_count", n)
span.set_attribute("gis.spatial.layer", layer)
span.set_attribute("gis.spatial.sampled_band", _band(n))
def _walk_points(geom: ogr.Geometry) -> int:
total = 0
for i in range(geom.GetGeometryCount()):
total += geom.GetGeometryRef(i).GetPointCount()
return total
def _band(n: int) -> str:
return "simple" if n < 1_000 else "medium" if n < 50_000 else "high"
The split between the two instruments is the whole design: the histogram is a lightweight aggregate that costs the same whether a feature has ten vertices or a million, so it stays unconditional, while the span carries the heavy per-feature attributes and is gated by the band probability. Even when the 1% roll drops a 400k-vertex polygon from tracing, its magnitude still lands in the histogram bucket, so a shift in the tail distribution — a vendor suddenly shipping denser geometries — is visible in aggregate.
Sampling in the collector by attribute
Pushing the decision to the producer is ideal, but a collector-side backstop lets you re-tune sampling without redeploying every worker. The OpenTelemetry contrib collector’s tail_sampling processor can key on the gis.spatial.vertex_count span attribute directly, so a policy set keeps all high-complexity traces that also errored and probabilistically samples the rest. This is the same tail-sampling discipline documented for OTel tail-sampling policy for topology spans, applied here to complexity rather than to error class.
# otel-collector-config.yaml — contrib build (tail_sampling by vertex_count)
processors:
tail_sampling:
decision_wait: 10s
num_traces: 50000
policies:
# Always keep a high-vertex feature whose span errored — the expensive tail
# that fails is the single most valuable trace to retain.
- name: high-vertex-errors
type: and
and:
and_sub_policy:
- name: is-high-vertex
type: numeric_attribute
numeric_attribute: { key: gis.spatial.vertex_count, min_value: 50000 }
- name: errored
type: status_code
status_code: { status_codes: [ERROR] }
# Thin probabilistic sample of the remaining high-vertex traces.
- name: high-vertex-sample
type: and
and:
and_sub_policy:
- name: is-high-vertex
type: numeric_attribute
numeric_attribute: { key: gis.spatial.vertex_count, min_value: 50000 }
- name: one-percent
type: probabilistic
probabilistic: { sampling_percentage: 1 }
# Keep a fuller sample of cheap simple/medium features.
- name: simple-sample
type: probabilistic
probabilistic: { sampling_percentage: 25 }
Order matters: the collector evaluates policies as an OR, so the error-keep policy has to precede the probabilistic thin-out, or an errored high-vertex trace can be dropped by the 1% roll before the error policy ever sees it. Keep the num_traces buffer large enough to hold decision_wait seconds of a bulk-load burst, or the buffer itself becomes the drop point you were trying to avoid.
Histogram bucketing for the vertex distribution
A histogram is only useful if its buckets straddle the skew. Default linear buckets waste resolution on the dense low end and collapse the entire tail into a single overflow bucket, so a boundary layer going from 60k to 400k median vertices looks identical. Use exponential boundaries that give the tail its own buckets. Choose boundaries on a log scale so each order of magnitude of complexity gets comparable resolution:
which yields boundaries near 100, 400, 1.6k, 6.4k, 26k, 100k, 400k, and 1.6M vertices — enough separation that a shift in the tail moves a bucket you can alert on. In PromQL, watch the high-complexity quantile and the fraction of features landing above the medium band:
# p99 vertex count per layer — a jump here means denser inbound geometry.
histogram_quantile(0.99, sum by (le, layer) (rate(gis_spatial_vertex_count_bucket[10m])))
# Fraction of features in the high-vertex tail (> 50k). A rising ratio means
# your 1% sample is covering a growing share of real volume — re-tune upward.
sum by (layer) (rate(gis_spatial_vertex_count_bucket{le="+Inf"}[10m])
- ignoring(le) rate(gis_spatial_vertex_count_bucket{le="65536"}[10m]))
/ sum by (layer) (rate(gis_spatial_vertex_count_count[10m])) > 0.05
When that tail fraction climbs, the aggregate stayed honest even though per-feature tracing thinned — which is exactly the guarantee complexity-aware sampling is supposed to give. Align the band thresholds with the topology-complexity limits in geometry validity and topology checks, since the same high-vertex geometries that dominate telemetry cost are the ones whose ST_IsValid checks time out.
FAQ
Won’t sampling high-vertex features hide a problem specific to one of them?
No, if you keep the aggregate unconditional and always retain errored high-vertex traces. Complexity-aware sampling thins the routine per-feature spans of expensive geometries while guaranteeing that (a) every feature’s magnitude lands in the histogram and (b) any high-vertex feature whose processing errored is kept in full. A problem that manifests as an error or as a distribution shift stays visible; only redundant healthy spans are dropped.
Why key on vertex count rather than byte size?
Vertex count is the cost driver for the operations telemetry cares about — validity, reprojection, simplification, and spatial joins all scale with vertices, not with serialized bytes, and a compressed WKB payload hides the real cost. ST_NPoints is also cheap and stable across formats, whereas byte size varies with encoding and precision. Use bytes only as a secondary guard on payload egress.
Should point clouds use the same bands as polygons?
Use the same mechanism but recalibrate the boundaries. A dense LiDAR tile routinely carries millions of points, so the “high” band that starts at 50k vertices for polygons might start at 500k for point data. Keep the band function per layer type rather than global, and let the histogram’s exponential buckets absorb the different scales without a separate metric.
Does the producer-side sampler make traces non-deterministic across a pipeline?
It can, if each stage rolls the dice independently — a feature kept at ingestion might be dropped at transformation, breaking the trace. Make the keep decision once, at ingestion, and propagate it as OTel baggage (or a sampled flag on the trace context) so downstream stages honour the original decision. That keeps a sampled feature traceable end to end through the wiring described in OpenTelemetry integration for GIS pipelines.
How do I choose the keep-probabilities?
Start from your CPU and RSS budget per worker and the observed vertex-count distribution, then set each band’s probability so the expected telemetry cost per band is roughly equal. If simple features are 95% of volume at 100%, and high-vertex features are 0.1% of volume, a 1% keep on the tail still gives you samples while capping their share of the buffer. Re-tune whenever the tail-fraction PromQL rule shows the distribution drifting.
Related
- Observability Scoping Rules for Vector Data — the parent guide to what a vector pipeline emits and where the resource budget is drawn.
- Architecting Fallback Routing for Spatial Feature Servers — the ingestion admission boundary where vertex count is first stamped.
- OTel tail-sampling policy for topology spans — collector-side sampling policy this complexity keying extends.
- Geometry Validity & Topology Checks — the high-vertex geometries whose validity checks time out are the same ones that dominate telemetry cost.
- Geospatial Observability Architecture & Fundamentals — the program these scoping and sampling rules belong to.