Structured Logging for Geospatial Flows

In short: a log line should be a record with fields, not a sentence with numbers embedded in it. {"event": "tile_written", "tile.z": 12, "tile.x": 2100, "features": 4183, "crs": "EPSG:25832", "trace_id": "4bf9…"} can be filtered, aggregated and joined to a trace. "Wrote 4183 features for tile 12/2100/1300" can be grepped, which is not the same thing and stops working at the first change to the message.

Logs are the third observability signal and the one spatial pipelines most often get wrong, because the interesting facts are numerous — a tile index, two coordinate reference systems, a feature count, an extent, a source version — and prose absorbs them invisibly. The moment those facts are fields, questions like “which tiles wrote fewer features than yesterday” become queries rather than archaeology, and the log stops being a last resort.

The cost of getting this wrong is not that logs are unavailable; it is that they are available and useless, which is worse because it looks like coverage. A pipeline emitting a million prose lines a night has a log bill, a retention policy and a search box, and still cannot answer “which tiles wrote fewer features than last week” without someone writing a parser. The same volume in structured form answers it with a sum by (tile) over a field that was already there. The difference in effort at the call site is zero.

Prerequisites & Architecture Baseline

Core Principles

1. Events have names, not sentences. event="tile_written" is a stable identifier that survives rewording; "Successfully wrote tile" is a string that somebody will improve next quarter, silently breaking every saved query built on it.

2. Facts are fields. Every number, identifier and classification in the message becomes a field. The message then contains no information at all, which is the goal — it exists for humans skimming, and the fields exist for everything else.

3. Share the trace’s vocabulary. If the span attribute is tile.z, the log field is tile.z. Two vocabularies mean two sets of saved queries and a permanent translation cost, and the translation is exactly what fails at 3 a.m.

4. Bind context once, at the top of the unit of work. A logger bound with the tile, the layer and the trace id carries them into every subsequent line without repetition. That is what makes concurrent work legible — without it, eight interleaved tiles produce a stream where consecutive lines are unrelated.

5. Log decisions and transitions, not progress. “Started”, “finished”, “skipped because the digest matched”, “repaired the geometry”, “fell back to cache” are all worth a line. “Processing tile 47 of 412” is worth none: it is a metric, it produces four hundred lines per scene, and nobody reads it.

6. Volume is a design constraint. A pipeline logging ten lines per tile at four hundred tiles per scene and three hundred scenes a night is 1.2 million lines. That is affordable if the lines are structured and sampled thoughtfully, and ruinous if every one of them is a paragraph of prose.

The same event, twiceA prose line embeds the tile index and feature count in a sentence and supports only text search. A structured record exposes them as fields and supports filtering, aggregation and joining to a trace.PROSE“Wrote 4183 features for tile 12/2100/1300 in EPSG:25832 (took 8.4s)”supports: text search, and nothing elseSTRUCTUREDevent=tile_written tile.z=12 tile.x=2100 tile.y=1300 features=4183 crs=EPSG:25832 seconds=8.4 trace_id=4bf9…filter by any fieldaggregate over featuresjoin straight to the trace
The same characters, arranged differently. The lower form costs nothing extra to produce and answers questions the upper one cannot express.

The concurrency point deserves emphasis because it is what makes spatial logs uniquely unreadable when they go wrong. A pipeline processing sixteen tiles at once produces a stream in which no two consecutive lines belong to the same tile, so the human habit of reading downward to follow a story simply fails. Bound context turns that from a problem into a filter: every line carries its tile, so “show me this tile’s story” is a query and the interleaving stops mattering. Without it, the only recourse is to reduce concurrency to one and re-run, which is both slow and often does not reproduce the problem.

Sixteen tiles at once, one log streamWithout bound context, consecutive log lines belong to different tiles and cannot be followed. With the tile index on every record, one filter recovers a single tile's story.WITHOUT BOUND CONTEXTfive consecutive lines, five different tiles — the story cannot be followedWITH tile.x=2100 BOUND — one filter
The colours are the tiles. Bound context does not reduce the volume; it makes the volume navigable, which is the only thing that helps at sixteen-way concurrency.

Production Implementation

structlog with a JSON renderer and a processor that injects the trace context gives every line the fields and the join key without any per-call-site effort.

from __future__ import annotations

import logging
from typing import Any, MutableMapping

import structlog
from opentelemetry import trace


def add_trace_context(_, __, event_dict: MutableMapping[str, Any]) -> MutableMapping[str, Any]:
    """Put the current trace and span ids on every record.

    This one processor is what makes logs and traces joinable. Without it the
    two systems describe the same run and cannot be connected.
    """
    span = trace.get_current_span()
    ctx = span.get_span_context()
    if ctx.is_valid:
        event_dict["trace_id"] = format(ctx.trace_id, "032x")
        event_dict["span_id"] = format(ctx.span_id, "016x")
    return event_dict


def redact_protected(_, __, event_dict: MutableMapping[str, Any]) -> MutableMapping[str, Any]:
    """Coarsen coordinates for layers where the exact location is sensitive."""
    if event_dict.get("layer") in PROTECTED_LAYERS and "geo.bbox" in event_dict:
        event_dict["geo.bbox"] = coarsen_to_grid(event_dict["geo.bbox"], km=10)
        event_dict["geo.redacted"] = True
    return event_dict


structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,   # bound context, per task
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso", utc=True),
        add_trace_context,
        redact_protected,
        structlog.processors.StackInfoRenderer(),
        structlog.processors.format_exc_info,
        structlog.processors.JSONRenderer(),
    ],
    wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
    cache_logger_on_first_use=True,
)

log = structlog.get_logger()

Binding the context once, at the top of the unit of work, is what keeps the rest of the code free of repetition:

def render_tile(item, scratch) -> None:
    # Everything logged inside this task carries these fields automatically.
    structlog.contextvars.bind_contextvars(
        layer=item.layer, **{"tile.z": item.z, "tile.x": item.x, "tile.y": item.y},
    )
    try:
        if already_done(item.work_key):
            # A decision, with its reason as a field rather than in the sentence.
            log.info("tile_skipped", reason="digest_unchanged", digest=item.source_digest)
            return

        source = fetch_source(item.source_uri)
        log.info("source_fetched", scheme=scheme_of(item.source_uri),
                 bytes=source.stat().st_size)

        features, crs = warp_window(source, item, scratch)
        log.info("tile_written", features=features, crs=crs,
                 seconds=round(item.elapsed, 2))
    except Exception as exc:
        # exc_info gives the traceback as a field, not as twenty extra lines.
        log.error("tile_failed", failure_class=classify(exc), exc_info=exc)
        raise
    finally:
        structlog.contextvars.clear_contextvars()

Step-by-Step Walkthrough

  1. Configure once, at process start. The processor chain is the whole design; call sites should never format anything. A codebase where half the logs are structured and half are f-strings has the costs of both approaches.
  2. Inject the trace context in a processor. Doing it per call site guarantees it will be forgotten somewhere, and the one line missing the trace id is the one you will want.
  3. Bind the unit of work’s identity, then clear it. bind_contextvars at the top and clear_contextvars in a finally keeps context from leaking into the next tile on a reused worker, which produces logs that are wrong rather than merely missing.
  4. Name the event and put everything else in fields. log.info("tile_skipped", reason="digest_unchanged") rather than log.info("Skipping tile because the digest was unchanged"). The reason is a value you can group by.
  5. Log decisions, not progress. Every branch taken, every fallback used, every repair applied. Those are the lines that reconstruct a run; a progress counter is a metric wearing a log’s clothing.
  6. Pass exceptions as exc_info. The traceback becomes a field on one record rather than twenty interleaved lines that are unattributable under concurrency.
  7. Redact in a processor, not at the call site. A rule enforced centrally is a rule that holds; one applied by remembering is one that fails in the module written under time pressure.

Edge Cases & Failure Recovery

Context leaking between units of work. A worker that binds a tile’s context and never clears it will attribute the next tile’s logs to the previous tile. The finally clause is not optional, and the failure is subtle enough — plausible fields, wrong values — that it can survive for months.

A field whose type changes. Logging features as an integer in one place and a string in another breaks aggregation in most backends, sometimes silently. Treat field types as part of the vocabulary, and prefer a small helper over ad-hoc dictionaries where a field is used in several modules.

Logging a full geometry. A single coastline in WKT can be several megabytes, and a log pipeline will either truncate it unhelpfully or fall over. Log the bounding box and the vertex count; if the geometry itself matters, it belongs in the dead-letter queue.

Volume that outgrows the budget. Structured logs are cheap per line and expensive per million. Sample the routine events — one in a hundred tile_written records is plenty for spot-checking — and never sample decisions, errors or transitions, which are the ones with information density.

Sensitive coordinates in a widely-readable index. Logs are usually visible to more people than the database is. Where a layer’s locations are protected, coarsening in a processor is the enforceable control; see masking sensitive coordinates in task logs.

Timestamps from the worker’s clock. Log records are ordered by their timestamp, so a worker whose clock drifts by seconds produces a stream where events appear out of order relative to other workers — and a reconstruction that reads “written” before “started” is confusing enough to send an investigation in the wrong direction. Use UTC everywhere, run NTP, and prefer the trace’s span ordering over log timestamps when the two disagree.

A logging call that is itself expensive. Computing a feature count purely to log it turns an observation into work. Log what the code already knows; if a value requires a query to produce, it is a metric or a trace attribute, not a log field.

Where the log volume goesProgress lines account for the great majority of log volume per scene while carrying no information. Decisions, transitions and errors are a small fraction and carry nearly all of it.lines per scene, by event typeprogress3 296tile_written412 — sample thesedecisions128 — never sampleerrors14 — never sampleDeleting the progress lines removes 85% of the volume and none of the ability to reconstruct a run.
The top bar is the one every pipeline has and nobody reads. Removing it is usually the single largest log-cost reduction available, and it costs nothing.

A last operational note on that breakdown: the progress lines are usually not written deliberately. They accumulate from debugging sessions, from a library’s own INFO-level chatter, and from a loop that once needed watching. Auditing what a single scene actually emits — one run, one wc -l, one sum by (event) — is a fifteen-minute exercise that most pipelines have never done, and it reliably finds several thousand lines per scene that nobody chose.

Configuration Reference

Setting Value Spatial context
renderer JSON Anything else defeats field indexing. Pretty-print in development only.
field vocabulary shared with traces tile.z, crs, layer, geo.bbox. One vocabulary, defined once.
trace injection processor Never per call site. It is the join key and it must be on every record.
context binding per unit of work Bound at the top, cleared in a finally. Leaks produce plausible, wrong logs.
level INFO in production DEBUG for a spatial pipeline is a volume decision, not a verbosity one.
sampling routine events only Decisions, transitions and errors are never sampled.
redaction processor Centrally enforced, so it cannot be forgotten in a new module.

Retention is the setting most worth revisiting, because logs are the cheapest signal to produce and the most expensive to keep. A fortnight of full-fidelity logs covers essentially every investigation; anything older is almost always answered by metrics, traces or the pipeline’s own tables, all of which are far more compact. Where a longer record is genuinely needed — an audit of what was published and when — that belongs in a table designed for it rather than in a log index that happens to have a long retention.

Frequently Asked Questions

How do I migrate an existing pipeline without a big rewrite?

Configure the JSON renderer and the trace processor first, which immediately makes every existing line a structured record with a message field and a trace id — already a large improvement, and it changes no call sites. Then convert call sites opportunistically: whenever a module is touched for another reason, its log lines become events with fields. The vocabulary module makes that mechanical. A full migration in one change is rarely worth the review burden, and the partial state is genuinely useful rather than merely half-done.

`structlog` or the standard library?

Either works; structlog’s advantages are the processor chain and context binding, both of which are exactly what this pattern needs. With the standard library you can achieve the same with a JSON formatter and a LoggerAdapter, at the cost of more code and a weaker story for binding context across a call stack. If the codebase already uses one, keep it.

What does a good set of event names look like?

Small, verb-shaped and past tense: tile_written, tile_skipped, tile_failed, source_fetched, geometry_repaired, breaker_opened, cache_hit. Twenty or so names cover a substantial pipeline, and because they are values rather than prose you can count them — sum by (event) over a day is a remarkably good summary of what the pipeline actually did. Prose messages cannot be counted, which is why a pipeline that logs sentences has no cheap way to answer “what happened last night”.

How does this interact with the orchestrator's own logs?

Prefect and Dagster capture what a task writes to its logger, so structured records flow through into the run’s log view — though they may be rendered as JSON strings there, which is ugly and harmless. The important part is that the same records also reach your log backend, where the fields are indexed. Configure both sinks rather than choosing between them.

Is there a case for keeping some prose?

Yes, in the message itself, for the small number of events a human will read directly during an incident — a breaker opening, a batch abandoned, a run refusing to publish. A short sentence there costs nothing and saves the reader assembling the fields mentally. What matters is that the sentence carries no information the fields lack, so a query never has to parse it. Prose as decoration is fine; prose as the only representation is the problem.

What is the single most valuable field?

The trace id, by a distance. It turns two independent systems into one, so a log line found by searching leads directly to the timing of the run it belongs to and vice versa. Everything else on the record describes the event; the trace id describes where the event sits in the world.

Should log lines be emitted for successful tiles at all?

One per tile, sampled, is worth having as a spot-check that the fields look right and the counts are plausible. Beyond that, success is a metric. The rule that scales is: if the answer to “how many?” is what you want, it is a metric; if the answer to “what happened to this one?” is what you want, it is a log.

How do I stop the vocabulary drifting?

Define the field names in one module and import them, so a typo is an import error rather than a new field. A weekly grep for distinct field keys in the index catches whatever slips through, and it takes a minute. Drift is slow, unglamorous and the main reason log queries stop working.

Observability & Monitoring for Geospatial Pipelines