OpenTelemetry vs Prometheus-Native Instrumentation for PostGIS
Instrumenting a PostGIS pipeline forces an early fork that is awkward to reverse: route everything through an OpenTelemetry collector so a slow reprojection carries a trace with its source CRS and vertex count attached, or stand up a postgres_exporter and let Prometheus scrape pre-aggregated gauges and counters. The first gives you per-request causality and rich spatial span attributes at the cost of cardinality and moving parts; the second gives you a dead-simple pull model at the cost of ever seeing why one particular geometry stalled. This page compares the two approaches for spatial pipelines specifically, with configuration for both and guidance on when each fits. It sits under choosing spatial observability tooling inside the wider spatial incident response and observability tooling program.
The diagram makes the split concrete: both paths start from the same PostGIS workload, but one pushes richly-attributed spans and one exposes a scrape endpoint. The choice is not which is better in the abstract — it is which stage of the incident lifecycle you are optimizing for. Prometheus-native excels at cheap continuous detection; OpenTelemetry excels at the triage that follows. Many mature stacks run both, and this comparison is really about which to build first and where each earns its cost. It builds directly on the collector patterns in OpenTelemetry integration for GIS pipelines.
What Each Approach Actually Sees
Prometheus-native instrumentation sees aggregate state at scrape time. The postgres_exporter, extended with custom spatial queries, exposes counters and gauges — rows inserted, index bloat, active spatial queries — that Prometheus samples every fifteen seconds. It is blind to any individual statement’s journey; it cannot tell you that feature 88421 took 2.3 seconds to reproject because it never modeled that feature as anything but a tick on a counter. What it gives up in detail it recovers in economy: a fixed, predictable series count regardless of traffic, and a pull model that survives a workload restarting because the next scrape simply resumes.
OpenTelemetry instrumentation sees causal per-operation detail. A reprojection span carries spatial.source_crs, spatial.target_crs, and spatial.vertex_count as attributes, so when the p99 transform latency alert fires you can pull the exact spans in the tail and read off that they were all EPSG:4326 to EPSG:3857 transforms on polygons above 100k vertices. That is triage gold. The cost is cardinality — every distinct attribute combination is a potential series — and operational surface area, since you now run a collector with sampling policies that themselves need monitoring. The right mental model is that Prometheus answers “how many and how bad” while OpenTelemetry answers “which one and why”.
Comparison Table
| Dimension | OpenTelemetry (collector) | Prometheus-native (exporter) |
|---|---|---|
| Collection model | Push (OTLP) | Pull (scrape) |
| Primary signal | Traces + spatial span attributes | Pre-aggregated metrics |
| Per-request causality | Yes | No |
| Cardinality risk | High (per-attribute series) | Low (fixed series) |
| Ephemeral job capture | Native | Needs pushgateway |
| Operational complexity | Higher (collector + sampling) | Lower (one exporter) |
| Cost driver | Span volume + attributes | Scrape interval x series |
| Best lifecycle stage | Triage | Detection |
| Storage-engine metrics | Via SQL receiver | Via custom exporter queries |
| Failure signature | Collector drops under load | Stale scrape / target down |
Configuring the OpenTelemetry Path
On the OpenTelemetry side, the application attaches spatial attributes to each PostGIS operation span, and the collector keeps every slow or failed span while sampling the fast majority so the trace backend is not overwhelmed:
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
import psycopg
tracer = trace.get_tracer("gis.etl.postgis")
def reproject_layer(conn: psycopg.Connection, layer: str, target_srid: int):
with tracer.start_as_current_span("postgis.reproject") as span:
span.set_attribute("spatial.layer", layer)
span.set_attribute("spatial.target_crs", f"EPSG:{target_srid}")
with conn.cursor() as cur:
cur.execute(
"SELECT count(*), max(ST_NPoints(geom)) "
"FROM %s WHERE ST_SRID(geom) <> %s" % (layer, target_srid))
n, max_pts = cur.fetchone()
span.set_attribute("spatial.features_out_of_srid", n)
span.set_attribute("spatial.max_vertex_count", max_pts or 0)
try:
cur.execute(
"UPDATE %s SET geom = ST_Transform(geom, %s) "
"WHERE ST_SRID(geom) <> %s" % (layer, target_srid, target_srid))
except Exception as exc: # noqa: BLE001
span.record_exception(exc)
span.set_status(Status(StatusCode.ERROR))
raise
The collector then applies a tail-sampling policy that guarantees retention of the interesting tail — errors and slow transforms — while sampling the rest, keeping the spatial detail that triage needs without paying full freight:
# otel-collector-config.yaml (contrib) — keep the tail that matters for triage
processors:
tail_sampling/postgis:
decision_wait: 10s
policies:
- name: keep-errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: keep-slow-reprojections
type: latency
latency: { threshold_ms: 1500 }
- name: sample-the-rest
type: probabilistic
probabilistic: { sampling_percentage: 5 }
exporters:
prometheus:
endpoint: "0.0.0.0:9464"
service:
pipelines:
traces:
receivers: [otlp]
processors: [tail_sampling/postgis]
exporters: [otlp/tracebackend]
Configuring the Prometheus-Native Path
On the Prometheus side, you extend postgres_exporter with custom spatial queries that turn PostGIS catalog and geometry state into gauges. This is where storage-engine visibility comes from without any tracing:
# queries.yaml for postgres_exporter — spatial gauges from catalog + geometry state
gis_spatial_index_bloat:
query: >
SELECT c.relname AS index,
am.amname AS access_method,
(pgstattuple(c.oid)).dead_tuple_percent / 100.0 AS ratio
FROM pg_class c
JOIN pg_am am ON am.oid = c.relam
WHERE am.amname IN ('gist','brin')
metrics:
- index: { usage: "LABEL" }
- access_method: { usage: "LABEL" }
- ratio: { usage: "GAUGE", description: "GiST/BRIN dead-tuple bloat ratio" }
gis_spatial_out_of_srid:
query: >
SELECT 'parcels' AS layer,
count(*) FILTER (WHERE ST_SRID(geom) <> 3857) AS out_of_srid
FROM parcels
metrics:
- layer: { usage: "LABEL" }
- out_of_srid: { usage: "GAUGE", description: "Features with unexpected SRID" }
Prometheus scrapes it on a fixed interval, and detection alerts run directly against the resulting series — cheap, continuous, and immune to traffic spikes because the series count is fixed regardless of how many geometries flowed:
scrape_configs:
- job_name: postgis-exporter
scrape_interval: 15s
static_configs:
- targets: ["postgres-exporter:9187"]
Decision Guidance
Start with Prometheus-native if your immediate need is detection and your team is small: it is the cheaper, simpler path, it gives you storage-engine gauges like index bloat out of the box, and its fixed cardinality will not surprise you during an incident. Add OpenTelemetry when detection is solid but triage is slow — when alerts fire and nobody can say which layer, CRS, or vertex range is responsible without ad-hoc SQL. The span attributes are what collapse triage from minutes of guessing to seconds of reading the tail. A workload dominated by ephemeral batch jobs tilts toward OpenTelemetry earlier, because scraping a job that exits in seconds is a losing game that the push versus pull comparison for spatial pipelines covers in full. Whichever you lead with, bound your attribute and label sets from day one, because both paths melt under unbounded cardinality — the discipline is identical to bounding spatial metric tag cardinality. The index-health gauges these paths emit are also what feed the GiST versus BRIN index comparison.
FAQ
Can OpenTelemetry replace Prometheus entirely for PostGIS?
Technically yes — the collector can emit metrics as well as traces — but doing so for cheap continuous detection wastes its strengths and inherits its cardinality risk. Most teams export a small set of aggregated metrics from the collector for detection and reserve spans for triage. Using traces where a counter would do is the most common way to make an OpenTelemetry deployment expensive.
Does the postgres_exporter add load to my spatial database?
Custom spatial queries can, especially pgstattuple on a large index, which scans it. Schedule the heavier introspection queries on a longer interval than the cheap gauges, or run them against a read replica. A naive fifteen-second pgstattuple scan on a multi-gigabyte GiST index is itself a small recurring load you should account for.
How do I correlate a Prometheus alert with an OpenTelemetry trace?
Carry a consistent set of labels and attributes across both — layer, target_crs, operation — so that when a Prometheus burn-rate alert fires on a layer you can filter the trace backend to the same layer and read the offending spans. Without shared naming the two systems are islands, which defeats running both.
Which path handles index bloat better?
Both surface it, since both ultimately run the same catalog SQL; the difference is delivery. Prometheus scrapes it as a steady gauge ideal for a bloat alert, while OpenTelemetry would model it as a periodic metric datapoint. For a slow-moving signal like bloat, the pull model is the more natural fit, which is one reason detection-first stacks lean Prometheus-native.
Related
- Choosing spatial observability tooling — the parent framework that treats this as the signal-layer fork.
- OpenTelemetry integration for GIS pipelines — the collector and span-attribute patterns this comparison builds on.
- Push vs pull metric collection for spatial pipelines — the collection-model fork that interacts with this signal-model choice.
- GiST vs BRIN indexes for spatial observability — the storage-engine signals these instrumentation paths emit.