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.

OpenTelemetry versus Prometheus-native instrumentation paths for PostGIS Top row: a PostGIS workload emits OTLP spans carrying spatial attributes into a contrib OpenTelemetry collector, which forwards to a trace-and-metric backend, labelled causal triage with per-request detail. Bottom row: the same PostGIS workload exposes a postgres_exporter endpoint that Prometheus scrapes on an interval into pre-aggregated series, labelled cheap pull-based detection with no per-request causality. PostGIS spatial workload OTLP spans crs · vertex_count Contrib collector tail_sampling Trace backend causal triage postgres_exporter /metrics endpoint Prometheus scrape 15s TSDB cheap detection push pull

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.