Correlating Tile Spans with PostGIS Query Plans

A trace tells you a tile request spent 118 milliseconds in PostGIS. It does not tell you whether that was an index scan over a dense extent or a sequential scan because a predicate stopped being index-eligible — and those have entirely different fixes. This guide closes the gap: stamp each query with its trace identifiers, capture the plan for slow executions, and join the two so a slow span resolves to the plan that produced it. It extends the tracing model in Distributed Tracing for Spatial Request Flows within Spatial Pipeline Orchestration & Observability, and it is the drill-down a breached p95 from Monitoring Vector Query p95 Latency leads into.

Prerequisites

Requirement Value / Assumption Notes
Tools PostgreSQL ≥ 15 with auto_explain and pg_stat_statements, PostGIS ≥ 3.3 Plan capture is server-side
Tracing W3C traceparent propagated to the tile service The span must exist before it can be joined
Log pipeline Postgres logs shipped with structured fields preserved The join happens in the log store
Access roles platform-engineer (server config), domain-owner (reads own plans) Plans can reveal data shape
Environment PG_DSN, TRACE_BACKEND Exported before running

Step-by-Step Implementation

1. Capture plans automatically for slow executions only

Capturing every plan is expensive and produces noise; capturing none leaves the trace unexplained.

Choosing the plan-capture threshold from the span duration distributionTile query durations at five percentiles in milliseconds, against the 150 millisecond capture threshold drawn as a dashed line. The median at 18 milliseconds, p75 at 34 and p90 at 88 all sit below the threshold and are fully explained by the span duration alone. The p95 at 210 and p99 at 640 sit above it and get a plan. Setting the threshold in the gap between p90 and p95 captures the executions a trace cannot explain while leaving the overwhelming majority uninstrumented.p5018 msp7534 msp9088 msspan duration sufficesp95210 msplan capturedp99640 msplan capturedcapture above

ini
# postgresql.conf — plan capture for the executions that matter.
shared_preload_libraries = 'auto_explain,pg_stat_statements'

# Only executions above the tile service's own budget. Below this, the trace's
# span duration is sufficient and a plan adds nothing.
auto_explain.log_min_duration = '150ms'
auto_explain.log_analyze = on              # actual rows and timing, not estimates
auto_explain.log_buffers = on              # distinguishes a cold cache from a bad plan
auto_explain.log_timing = off              # per-node timing is costly; omit at first
auto_explain.log_format = 'json'           # parseable in the log pipeline
auto_explain.log_nested_statements = on

# The application_name carries the trace context into every log line.
log_line_prefix = '%m [%p] app=%a '

Verify capture fires at the threshold and not below it:

bash
psql "$PG_DSN" -c "SELECT pg_sleep(0.05);"     # no plan expected
psql "$PG_DSN" -c "SELECT pg_sleep(0.20);"     # plan expected
grep -c 'auto_explain' /var/log/postgresql/postgresql.log

2. Stamp the trace context onto every query

application_name is the cheapest reliable channel: it appears in the log prefix and in pg_stat_activity without any query rewriting.

How a span and a plan become one row, and where the join key can be lostThe tile service sets application_name from the current trace and span before running the query. Postgres writes that value into every log line the session emits, and auto_explain captures a plan for executions above the threshold. The log pipeline parses the trace and span identifiers back out, and the log store joins them to the span record. Two failure rails leave the flow: application_name truncated at sixty-three characters loses the span identifier, and a plan captured for an execution with no trace context cannot be joined at all.Span activetrace + span idapplication_nameset at checkoutauto_explainplan above 150msJoined rowspan + planstampedloggedparsedtruncated at 63 charsno trace contextTwo unrelated factsa slow span and a slow plan

python
# traced_pool.py — trace identifiers travel with the connection checkout.
from opentelemetry import trace


def checkout(pool, ctx):
    """Set application_name to the current trace and span before running the tile
    query. This is what lets a plan in the Postgres log be joined to the span that
    caused it — without it, a slow plan and a slow span are two unrelated facts."""
    span = trace.get_current_span().get_span_context()
    app_name = f"tile|{span.trace_id:032x}|{span.span_id:016x}|{ctx.product}"
    conn = pool.getconn()
    with conn.cursor() as cur:
        # Truncated by Postgres at NAMEDATALEN; keep the trace id first so it survives.
        cur.execute("SET application_name = %s", (app_name[:63],))
    return conn


def add_span_attributes(span, ctx, rows: int, plan_captured: bool) -> None:
    """Mirror the join key onto the span, so the correlation works from either end."""
    span.set_attribute("db.system", "postgresql")
    span.set_attribute("mesh.product", ctx.product)
    span.set_attribute("mesh.crs", ctx.crs)
    span.set_attribute("mesh.bbox", ctx.bbox_wkt)
    span.set_attribute("mesh.rows_returned", rows)
    span.set_attribute("db.plan_captured", plan_captured)

Verify the identifiers reach the server intact — truncation at 63 characters is the usual surprise:

bash
psql "$PG_DSN" -c "SELECT application_name FROM pg_stat_activity WHERE application_name LIKE 'tile|%' LIMIT 3;"

3. Join spans to plans in the log store

sql
-- correlate.sql — one row per slow tile query, with its plan and its span.
-- Run against the log store, where Postgres logs and span records both land.
WITH plans AS (
    SELECT
        split_part(split_part(app_name, '|', 2), '|', 1) AS trace_id,
        split_part(app_name, '|', 3)                     AS span_id,
        duration_ms,
        plan_json,
        -- The single most useful derived field: did the planner reach the index?
        (plan_json::text LIKE '%"Node Type": "Seq Scan"%')          AS had_seq_scan,
        (plan_json::text LIKE '%"Index Cond"%')                     AS used_index
    FROM postgres_logs
    WHERE source = 'auto_explain' AND app_name LIKE 'tile|%'
)
SELECT
    s.trace_id, s.name AS span_name, s.duration_ms AS span_ms,
    p.duration_ms AS query_ms,
    s.attributes ->> 'mesh.product' AS product,
    s.attributes ->> 'mesh.bbox'    AS bbox,
    p.had_seq_scan, p.used_index,
    p.plan_json
FROM spans s
JOIN plans p ON p.trace_id = s.trace_id AND p.span_id = s.span_id
WHERE s.duration_ms > 300
ORDER BY s.duration_ms DESC;

Verify the join actually matches — an empty result usually means the identifier formatting differs between the two sides:

bash
psql "$LOGSTORE_DSN" -f correlate.sql | head -20

4. Turn the correlation into a signal, not a manual query

yaml
# plan-quality-rules.yaml — sequential scans on the tile path are always a defect.
groups:
  - name: tile_plan_quality
    rules:
      - record: tile:seq_scan_ratio:30m
        expr: |
          sum(rate(tile_query_plans_total{had_seq_scan="true"}[30m])) by (domain, product)
          / sum(rate(tile_query_plans_total[30m])) by (domain, product)

  - name: tile_plan_alerts
    rules:
      - alert: TileQueryLostItsIndex
        expr: tile:seq_scan_ratio:30m > 0.01
        for: 15m
        labels: { severity: ticket }
        annotations:
          summary: "{{ $labels.product }} tile queries are sequential-scanning"
          description: >-
            A predicate has stopped being index-eligible — usually a function wrapping
            the geometry column, or an SRID mismatch forcing an implicit transform.

What each plan shape means for a tile query, and what actually fixes itFour plan shapes against the cause and the fix. An index scan with a bounding-box condition is the healthy shape. A sequential scan means a predicate stopped being index-eligible, usually a function wrapping the geometry column, and the fix is to transform the envelope rather than the column. An index scan with high buffer reads means a cold cache rather than a bad plan. A bitmap heap scan with a lossy recheck means the extent is too large for the index to be selective, and the fix is a tighter bound.MeansFixIndex Scan + Index CondhealthynothingSeq Scanpredicate not index-eligibletransform the envelopeIndex Scan, high bufferscold cachewarm, or more memoryBitmap heap, lossy recheckextent too largetighter bbox

Configuration Reference

Setting Value Effect
auto_explain.log_min_duration 150ms Capture only what the trace cannot explain
auto_explain.log_analyze on Actual rows; estimates alone mislead
auto_explain.log_buffers on Distinguishes a cold cache from a bad plan
auto_explain.log_timing off initially Per-node timing is costly on a hot path
application_name tile|<trace>|<span>|<product> The join key; trace id first to survive truncation
Span attributes product, CRS, bbox, rows Makes a span diagnosable on its own
seq_scan_ratio alert > 1% A sequential scan here is never correct

Common Failure Modes & Fixes

The join returns nothing. Root cause: application_name truncated at 63 characters, losing the span id. Fix: put the trace id first and keep the product last; verify from pg_stat_activity rather than from the client’s intent.

Plans are captured for everything and the log volume explodes. Root cause: log_min_duration set at or near zero. Fix: set it above the tile budget; the trace already covers the fast path.

A slow span has no plan. Root cause: the time was not spent in the query — it was connection acquisition, serialization, or the encoder. Fix: this is a useful result. Add spans around pool checkout and MVT encoding so the gap is attributable.

seq_scan_ratio alerts and the query looks unchanged. Root cause: an SRID mismatch introduced upstream, forcing an implicit transform that makes the index unusable. Fix: assert the SRID at ingest; the query text is identical, which is why the plan rather than the query is the evidence.

Plans reveal data volumes to consumers. Root cause: plan output surfaced in a consumer-visible error. Fix: plans belong in the log store behind domain access controls, never in a response.

FAQ

Why application_name rather than a query comment?

Because it survives the whole execution and appears in places a comment does not. A comment embedded in the SQL reaches the log if the statement is logged, but application_name also appears in pg_stat_activity, in pg_stat_statements attribution, and in the log prefix of every line the session emits — including lock waits and errors that are not statement logs. That means a session blocked on a lock is attributable to a trace even though its query never completed, which is exactly the case where correlation is most valuable.

Should per-node timing be enabled?

Not initially. log_timing instruments every plan node and can add meaningful overhead on a query-heavy path, which is a poor trade when the first question is usually “did it use the index” — answerable from the plan shape alone. Enable it temporarily when investigating a specific product whose plan shape looks correct and whose duration does not, then turn it off again. Treating it as a diagnostic tool rather than a standing configuration keeps the hot path fast.

What does a sequential scan on a tile query usually mean?

Almost always that a predicate has stopped being index-eligible, and there are two common causes. A function wrapping the geometry column — ST_Transform(geom, 3857) && envelope — makes the GiST index unusable, and the fix is to transform the envelope into the column’s CRS instead. An SRID mismatch between the column and the literal forces an implicit transform with the same effect. Both leave the query text looking reasonable, which is why the plan is the evidence and the query is not.

How long should plans be retained?

Long enough to compare against the same product’s plans before a regression, which in practice means weeks rather than days. Plans are small relative to trace volume, and the question they answer — “what did this query’s plan look like before the change” — cannot be reconstructed later. Retaining them under the same access controls as the domain’s other telemetry, and pruning by age rather than volume, keeps the comparison available without the store growing without bound.

Does this work for queries issued by the pipeline rather than the tile service?

Yes, and the correlation is arguably more valuable there because pipeline queries run longer and less often. The mechanism is identical — set application_name from the DAG run’s trace context at connection checkout — and the join key becomes the run and task identifiers rather than a request span. A pipeline stage whose duration doubled between two runs, with both plans captured, is diagnosable in a way that a duration alone never is.