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.
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.
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
- Define the attribute vocabulary in one function.
tile_attributesis the only place that decides whether the key istile.zorzoom. Without it, three modules invent three conventions and no query matches everything. - 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.
- 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.
- 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.
- Record exceptions on the span.
record_exceptionplus anERRORstatus makes failures searchable with their timing and spatial attributes attached — the query that finds “failed warps at high zoom” depends on it. - 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.
- 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.
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.
Related
- Propagating trace context across Prefect tasks — keeping one trace across task boundaries
- Tracing PostGIS query spans in spatial ETL — instrumenting the database side
- Sampling traces for high-volume tile pipelines — keeping the interesting traces and dropping the rest
- Exporting per-tile metrics without cardinality blowups — the division of labour with metrics
- Structured logging for geospatial flows — the third signal, and how to join it to these