OpenTelemetry Tracing for Spatial Tasks

In short: a trace answers “where did the time go for this unit of work”, which is exactly the question metrics cannot answer and logs answer only by hand. In a spatial pipeline the unit is a tile or a partition, the spans are the phases that have genuinely different failure modes — fetch, warp, write, register — and the attributes are the spatial facts that explain an outlier: zoom, extent, CRS, pixel count, source scheme.

Metrics tell you that p99 tile latency doubled. Traces tell you that it doubled because the source fetch went from two seconds to ninety on tiles whose imagery moved to a different bucket. That is the division of labour, and it is worth respecting: a pipeline that tries to answer “where did the time go” from metrics ends up with a cardinality problem, and one that tries to answer “how is throughput trending” from traces ends up sampling away the answer.

There is a specifically geospatial reason traces earn their keep here, beyond the general one. A spatial pipeline’s slow cases are usually correlated with where the work was, and a trace is the only signal that carries location alongside timing. Once geo.bbox is on the span, “show me the slow traces” becomes “show me the slow area”, and the answer to a performance question is frequently a map — a mountain range, a coastline, one municipality’s denser imagery. No metric can express that, because the location is precisely the high-cardinality attribute metrics must exclude, and no log query will draw it without a lot of work.

Prerequisites & Architecture Baseline

Core Principles

1. The trace’s root is the unit people ask about. For a tiling pipeline that is usually one scene, with a span per tile beneath it — small enough to be readable and large enough to show the fan-out. Making the whole nightly run one trace produces something with forty thousand spans that no UI will render; making each tile its own root loses the relationship between them.

2. Span boundaries follow failure modes, not function calls. Fetch, warp, write and register fail differently, are owned differently and are fixed differently. Those are the spans worth having. A span per Python function produces a trace that is technically complete and practically unreadable.

3. Attributes carry the spatial facts. tile.z, tile.x, tile.y, raster.pixels, crs.source, crs.target, resampling, source.scheme. Traces are where high cardinality belongs — this is precisely the data that must stay out of metric labels, and putting it here is what makes the two systems complementary rather than redundant.

4. Sampling must be decided by the time it matters. Head-based sampling decides at the root, before anything interesting has happened, so a one-per-cent rate discards ninety-nine per cent of the slow tiles too. Tail-based sampling decides after the trace completes and can keep every error and every outlier — at the cost of a collector that buffers.

5. Context does not propagate itself. A Prefect task boundary, a process-pool submission and a subprocess invocation all lose the current span unless you carry the context across explicitly. A trace that stops at the first boundary is a span, not a trace.

6. Record the failure on the span, not only in the log. span.record_exception() and a status of ERROR make the failure searchable alongside its timing and attributes. That is what lets a query find “every trace where the warp failed on a lanczos tile above zoom 14”, which no log search will do as cleanly.

One scene, four phases, one obvious culpritA root span covering the whole scene contains fetch, warp, write and register spans. The fetch span occupies most of the duration, which the aggregate latency metric could not have shown.trace: scene S2_20260807_T33WXP · 118 sprocess_scenefetch_source · 77 s · source.scheme=vsis3warp · 25 shead_object 11 s · read 66 sthe object store is slow, not GDALwhat the metrics said: “p95 tile latency is up 3×”what the trace says: “the source moved to a bucket in another region”Both are true. Only the second one tells you what to change.
The phase split is what makes this readable. With one span per scene, the same trace is a single bar 118 seconds long and says nothing at all.

Choosing the span boundaries is the decision that determines whether anyone ever looks at these traces again. Too coarse and the trace repeats what the orchestrator already showed. Too fine and it becomes a call graph: technically complete, hundreds of spans deep, and unreadable in the ninety seconds someone has during an incident. The useful test is ownership — if two adjacent operations would be investigated by different people or fixed in different systems, they are different spans; if they would always be looked at together, they are one.

Three granularities, one usefulOne span per tile repeats what the orchestrator already knows. Four spans by phase localises the problem. Forty spans per function call produces a call graph nobody reads during an incident.TOO COARSE — one spantile · 118 s — says nothing the orchestrator did notRIGHT — one span per phasefetch · 77 swarp · 25 sTOO FINE — one span per call…and 28 more, none of them the answer
The middle row took four context managers to produce and answers the question in one glance. That ratio is what makes tracing worth the instrumentation.

Production Implementation

The instrumentation below is a decorator plus a small set of attribute helpers, so the spatial vocabulary is defined once rather than at every call site.

from __future__ import annotations

import functools
from contextlib import contextmanager
from typing import Any, Callable, Iterator, Optional

from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode

tracer = trace.get_tracer("geospatial.pipeline")


# One place where the attribute names are decided, so nothing else invents its own.
def tile_attributes(item) -> dict[str, Any]:
    return {
        "tile.z": item.z,
        "tile.x": item.x,
        "tile.y": item.y,
        "tile.layer": item.layer,
        "crs.source": item.src_crs,
        "crs.target": item.dst_crs,
        "raster.resampling": item.resampling,
        "source.scheme": item.source_uri.split(":", 1)[0],
        # The extent is high-cardinality and exactly the kind of thing a trace
        # should carry: it is what makes a slow tile findable on a map afterwards.
        "geo.bbox": ",".join(f"{v:.5f}" for v in item.bounds),
    }


@contextmanager
def phase(name: str, **attributes: Any) -> Iterator[trace.Span]:
    """One span per phase, with the failure recorded on the span itself."""
    with tracer.start_as_current_span(name) as span:
        for key, value in attributes.items():
            span.set_attribute(key, value)
        try:
            yield span
        except Exception as exc:
            span.record_exception(exc)
            span.set_status(Status(StatusCode.ERROR, str(exc)[:200]))
            raise


def traced_tile(fn: Callable) -> Callable:
    @functools.wraps(fn)
    def wrapper(item, *args, **kwargs):
        with tracer.start_as_current_span("tile") as span:
            for key, value in tile_attributes(item).items():
                span.set_attribute(key, value)
            result = fn(item, *args, **kwargs)
            # Attributes known only after the work: put them on the same span.
            if isinstance(result, dict) and "pixels" in result:
                span.set_attribute("raster.pixels", result["pixels"])
            return result

    return wrapper


@traced_tile
def render_tile(item, scratch) -> dict:
    with phase("fetch", **{"source.scheme": item.source_uri.split(":", 1)[0]}):
        local = fetch_source(item.source_uri, scratch)

    with phase("warp", **{"raster.resampling": item.resampling}) as span:
        width, height = warp_window(local, item, scratch)
        span.set_attribute("raster.output_width", width)
        span.set_attribute("raster.output_height", height)

    with phase("write"):
        dest = promote_output(item, scratch)

    with phase("register"):
        mark_done(item.work_key)

    return {"pixels": width * height, "path": dest}

Step-by-Step Walkthrough

  1. Define the attribute vocabulary in one function. tile_attributes is the only place that decides whether the key is tile.z or zoom. Without it, three modules invent three conventions and no query matches everything.
  2. Make the root the scene, not the run. A scene’s trace has hundreds of spans, which every backend renders. A run’s trace has tens of thousands, which none of them do usefully.
  3. Split spans by failure mode. Fetch, warp, write and register can each fail independently and are each owned by someone different. That is the test for whether a boundary deserves a span.
  4. Set the attributes you know at the start, and the rest at the end. Output dimensions are only known after the warp; attaching them to the same span rather than a child keeps the trace flat and readable.
  5. Record exceptions on the span. record_exception plus an ERROR status makes failures searchable with their timing and spatial attributes attached — the query that finds “failed warps at high zoom” depends on it.
  6. Carry the extent as an attribute. It is high-cardinality by nature, which is fine here and forbidden in metrics, and it is what lets a slow-trace query become a map.
  7. Keep the decorator thin. Everything it does is bookkeeping; the work stays in the function. A decorator that also handles retries or timeouts becomes the place bugs hide.

Edge Cases & Failure Recovery

Context lost at a task boundary. Prefect and Dagster run tasks in separate contexts, and a naive instrumentation produces one root span per task with no parent. The fix is to inject the trace context into the task’s parameters and extract it on the other side — covered in propagating trace context across Prefect tasks.

Context lost at a process-pool boundary. The same problem with a different mechanism: pool workers are separate processes with their own context. Pass the serialised context as an argument, since anything relying on a thread-local will silently produce orphans.

Traces that dwarf the work. A fan-out of four hundred tiles across three hundred scenes is 120 000 spans a night. Without sampling that is a real cost in collector CPU and storage, and the signal-to-noise ratio is poor — most of those spans are identical and uninteresting. Sampling traces for high-volume tile pipelines covers the strategies.

Attributes that should have been metrics. Recording every tile’s duration as a span attribute and then trying to compute a p95 from traces is slow, expensive and — with sampling — wrong. Durations belong in a histogram; traces explain individual cases.

A collector that drops silently. OTLP exporters buffer and drop when the collector is unreachable, usually with a log line nobody reads. Monitor the exporter’s own dropped-span counter, or the first sign of trouble is an investigation that finds no traces for the interesting hour.

Clock skew across workers. Spans carry timestamps from the machine that produced them, so a worker whose clock is a second fast produces a child span that appears to start before its parent. Most backends render this as a negative duration or a span floating outside its parent, and it is confusing enough to derail an investigation. Run NTP on workers and treat a negative duration in a trace as an infrastructure signal rather than an application bug.

Spans without an end. A start_span without a matching end — easy with manual span management — produces a trace that never completes and is discarded by tail-based samplers. Use the context-manager form everywhere; the explicit API is for cases the context manager genuinely cannot express.

Three signals, three questionsMetrics answer how the pipeline is trending. Traces answer where the time went for one tile. Logs answer what happened in detail for one tile. Each is poor at the other two.metricsis it getting worse?bounded labelsaggregates onlycheap at any volumealerts live heretraceswhere did the time go?high cardinality welcomeone unit at a timesampledinvestigations live herelogswhat exactly happened?arbitrary detailone unit at a timeexpensive at volumethe last resort
Most observability trouble in spatial pipelines is a question asked of the wrong column — a per-tile duration in a metric label, or a throughput trend computed from sampled traces.

Configuration Reference

Setting Default Spatial context
trace root one scene Hundreds of spans render; tens of thousands do not.
span boundaries phase Fetch, warp, write, register — the units that fail independently.
attribute prefix tile. / raster. / crs. / geo. One vocabulary, defined once, so queries match across services.
geo.bbox on the tile span High cardinality is fine here and forbidden in metrics.
exception recording on the span Makes failures searchable with timing and spatial context attached.
exporter OTLP over gRPC Batch processor, with the dropped-span counter monitored.
sampling tail-based Head-based sampling discards the slow tiles along with everything else.

Two of those rows are worth revisiting periodically rather than setting once. The attribute vocabulary tends to drift as services are added, and a fortnightly grep for attribute keys across the codebase costs nothing and catches zoom creeping in beside tile.z. And the sampling configuration should be revisited whenever the fan-out width changes materially, because a rate tuned for four hundred tiles per scene is either wasteful or blind at four thousand.

Frequently Asked Questions

What is the smallest instrumentation worth shipping?

One root span per unit of work with the tile attributes on it, and one child span per phase. That is roughly thirty lines and it answers the two questions that matter most: which phase consumed the time, and which spatial characteristics the slow units share. Everything else on this page — sampling strategy, database spans, log correlation — is a refinement that becomes worth adding once those two questions stop being enough. Shipping the minimum early also means the attribute vocabulary is established before three teams have invented their own.

Is tracing worth it for a batch pipeline?

Yes, though for a different reason than for a request-serving system. There is no user waiting, so latency is not urgent — but a batch pipeline has a deadline, and a trace is the only artefact that shows how the deadline was consumed. When a nightly run starts finishing at 06:30 instead of 04:00, a trace answers “by what” in minutes, where metrics narrow it to a phase and logs require reading.

Should traces be enabled in every environment?

In production and in whatever environment you use for load testing, yes. In local development they are usually more trouble than they are worth — an unreachable collector adds start-up latency and log noise for no benefit — so a no-op exporter by default and an opt-in environment variable is the arrangement that survives. What matters is that the instrumentation itself is always present in the code, so a trace can be turned on without a deploy when someone needs one.

How does this relate to the orchestrator's own timings?

The orchestrator records task start and end, which is a coarse trace with one span per task. That is genuinely useful and it stops at the task boundary: it cannot show that eighty per cent of a task was one slow head_object. Treat the orchestrator’s view as the outline and the trace as the detail, and make sure they share an identifier so moving between them is a click.

How do I find the trace I want, out of thousands?

By attribute, which is why the vocabulary matters so much. The queries that actually get used are shaped like “spans named warp with tile.z >= 14 and duration over sixty seconds”, or “traces with an error status and crs.source not equal to crs.target”. Both are one expression in any modern backend and neither is possible if the attributes were named inconsistently. Practising those two queries when the instrumentation lands — rather than during the first incident — is what reveals a missing attribute while adding it is still cheap.

What should never go in a span attribute?

Anything large or secret. A full WKT geometry, a stack trace, a source’s contents — attributes are indexed and shipped, and a megabyte-sized attribute will be truncated somewhere unhelpful. Bounding boxes, identifiers and counts are the right size; the geometry itself belongs in the dead-letter queue.

Do I need a collector, or can workers export directly?

A collector, in almost all cases. It buffers when the backend is slow, applies tail-based sampling that individual workers cannot, and gives one place to change the export configuration for a fleet. Direct export is fine for a single-worker development setup and becomes a liability the moment there are ten workers and a backend that occasionally rate-limits.

How do traces interact with the dead-letter queue?

Store the trace id on the dead-letter row. A failed tile then has both its payload — enough to re-run it — and its trace, showing exactly where it was when it failed. That pairing turns triage from “reproduce it and see” into “read what happened”, and it costs one text column. See dead-letter queues for failed geotasks.

Observability & Monitoring for Geospatial Pipelines