This guide is part of Monitoring & Observability for Spatial Pipelines, within the broader Orchestrating Spatial ETL Pipelines reference.
Structured Logging for Geospatial ETL Jobs
A log line that reads Processing tile... tells a person almost nothing and a query engine nothing at all. The difference between logs that get used during an incident and logs that get skipped is entirely in whether the context travels with the message.
Why Unstructured Logs Fail Spatial Investigations
- The context is in the wrong place. Which tile, which run, which source — usually in a line printed twenty minutes earlier, if at all.
- Nothing can be grouped. “How many tiles hit the retry path last night” requires parsing free text with a regular expression that breaks when the message wording changes.
- Correlation is manual. Joining a warning to the metric it explains means reading timestamps by eye.
- Volume hides signal. A per-feature log line at scale buries the four lines that mattered under four million that did not.
Version and Environment Compatibility
| Component | Version | Note |
|---|---|---|
| Python | 3.10+ | logging with contextvars-backed filters |
| structlog | >=24.1 |
Optional; the recipe uses the standard library only |
| Any log store | — | Requires one JSON object per line |
pip install "python-json-logger>=2.0" # optional; a hand-rolled formatter is shown belowThe Field Set Every Line Should Carry
The Logging Setup Recipe
from __future__ import annotations
import contextvars
import json
import logging
import sys
from datetime import datetime, timezone
# One context per task; contextvars survive across async boundaries and threads.
_context: contextvars.ContextVar[dict] = contextvars.ContextVar("log_context", default={})
def bind_context(**fields: str) -> None:
"""Attach fields to every subsequent log record in this execution context."""
_context.set(_context.get() | fields)
class ContextFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
for key, value in _context.get().items():
setattr(record, key, value)
return True
class JsonFormatter(logging.Formatter):
RESERVED = set(logging.LogRecord("", 0, "", 0, "", (), None).__dict__) | {"message", "asctime"}
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": datetime.fromtimestamp(record.created, timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"event": record.getMessage(),
}
# Anything the caller passed through `extra=` becomes a queryable field.
payload |= {k: v for k, v in record.__dict__.items() if k not in self.RESERVED}
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, default=str)
def configure_logging(level: int = logging.INFO) -> None:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
handler.addFilter(ContextFilter())
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(level)
# GDAL and rasterio are chatty at DEBUG and rarely useful there.
logging.getLogger("rasterio").setLevel(logging.WARNING)
logging.getLogger("fiona").setLevel(logging.WARNING)Usage at a call site stays short, because the context is already bound:
configure_logging()
bind_context(run_id=run_id, dataset="parcels", stage="clean",
partition=tile_id, code_version="4.2.0")
logger = logging.getLogger(__name__)
logger.info("stage_complete", extra={
"rows_in": 12_400, "rows_out": 12_180, "quarantined": 220,
"duration_ms": 4_182,
})Key Implementation Notes
- The message is an event name, not a sentence.
stage_completegroups; “Finished processing the parcels stage” does not, and it changes wording every time someone edits it. - Context is injected, never typed. A filter that adds the run id to every record removes the possibility of a line missing it, which is the failure that breaks correlation exactly when it is needed.
contextvarsrather than a global. Two tiles processed concurrently in the same process each keep their own partition value, which a module-level dictionary would not.- Third-party loggers are quietened explicitly. GDAL and Fiona emit large volumes at debug level, and an unfiltered debug run can produce gigabytes from one task.
- Exceptions are formatted into a field. A traceback spread across forty lines is unqueryable; as one field it stays attached to its event.
default=stron the dump. Geometry objects,Decimal,datetimeandPathall appear in spatial code and none serialise natively; failing to log because a value was exotic is the worst possible outcome.
What to Log at Each Level
DEBUG is for the developer and off in production: request URLs, intermediate shapes, the arguments to a warp. Useful when reproducing a problem locally, ruinous at volume.
INFO records decisions and outcomes: a stage completed with counts, a source was unchanged and skipped, a partition was materialised, a cache was hit. One or a few lines per unit of work.
WARNING records anomalies the job handled: a retry after a 429, a fallback to a mirror, a quarantine rate above the usual band, a geometry repaired. Someone should look at these eventually; nobody should be woken by them.
ERROR records something that failed and needs a human: an exhausted retry budget, a blocking gate, an unwritable sink. If ERROR lines routinely appear in successful runs, the level is wrong and the signal is being eroded.
Troubleshooting Log Pipelines
| Symptom | Likely cause | Fix |
|---|---|---|
| Fields missing on some lines | Context bound after the first log call | Bind at task entry, before any logging |
| Log store rejects records | Same field name with different types across events | Fix the field’s type at the source; never reuse a name |
| Records not JSON in production | A library configured the root logger first | Clear handlers in configure_logging, as the recipe does |
| Enormous debug output from GDAL | Third-party loggers inherit the root level | Set them to WARNING explicitly |
| Concurrency mixes partitions | Context stored in a module global | Use contextvars |
Integration Note
Call configure_logging and bind_context at the top of each task, using the orchestrator’s own run and partition identifiers so that logs join to run metadata without translation. Emit the same counts that go to the metrics path in emitting metrics for spatial record counts — the log line explains one run, the metric shows the trend, and having both keyed identically is what makes moving between them instant.
Keeping the Field Vocabulary Stable
Structured logs stop being queryable the moment the same concept appears under three names. A pipeline where one task logs tile, another tile_id and a third partition_key forces every query to know all three, and nobody remembers the third.
The fix is a small shared module that defines the field names as constants and a helper for each common event. It costs a few dozen lines and it turns the field vocabulary into something reviewable: adding a name is a diff, and a reviewer can ask whether the concept already has one.
Type stability matters as much as name stability. A field that is an integer in one event and a string in another will be rejected or silently coerced by most log stores, and the coercion is usually discovered during an incident. Fixing the type at the point of definition — rows_in is always an int, duration_ms always a float — avoids a class of problem that is tedious to diagnose after the fact.