Structured JSON Logging for Geospatial Pipelines

Free-text log lines are hostile to auditing: they cannot be filtered, aggregated, or verified at scale. This guide shows how to configure Python’s standard logging module to emit one machine-parseable JSON object per event, attach a correlation id to every record in a batch, and produce per-batch structured entries that a compliance reviewer can query directly. The result plugs straight into the schema laid out in the parent module, Validation Log Design for Compliance Pipelines.

Prerequisites

Step-by-step

Step 1: Build a JSON formatter

Subclass logging.Formatter so that every emitted line is a complete JSON object. Merge any structured fields passed through the extra argument, and always include a UTC timestamp and the level.

import json
import logging
from datetime import datetime, timezone

RESERVED = set(logging.LogRecord("", 0, "", 0, "", (), None).__dict__)

class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "ts": datetime.now(timezone.utc).isoformat(),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        # Fold any structured fields passed via logger.info(..., extra={...}).
        for key, value in record.__dict__.items():
            if key not in RESERVED and not key.startswith("_"):
                payload[key] = value
        return json.dumps(payload, sort_keys=True, default=str)

Using sort_keys=True makes each line canonical, which matters when records are later hashed for tamper-evidence.

Step 2: Attach the formatter to a handler

Wire the formatter into a handler and logger once, at pipeline startup. Disable propagation so records are not double-emitted by the root logger.

def configure_audit_logger(sink_path: str) -> logging.Logger:
    logger = logging.getLogger("compliance.audit")
    logger.setLevel(logging.INFO)
    logger.propagate = False
    handler = logging.FileHandler(sink_path)   # swap for an object-store handler in prod
    handler.setFormatter(JsonFormatter())
    logger.handlers.clear()                    # idempotent: avoid duplicate handlers
    logger.addHandler(handler)
    return logger

Step 3: Propagate a correlation id

A correlation id ties every record from one run together. Store it in a contextvars.ContextVar so it survives across function boundaries and worker threads without being threaded through every signature manually. A logging.Filter injects it into each record automatically.

import contextvars
import uuid

batch_id_var = contextvars.ContextVar("batch_id", default="unset")

class CorrelationFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        record.batch_id = batch_id_var.get()   # stamped onto every emitted record
        return True

def start_batch() -> str:
    batch_id = uuid.uuid4().hex
    batch_id_var.set(batch_id)
    return batch_id

Add the filter to the handler with handler.addFilter(CorrelationFilter()). Every subsequent record now carries batch_id with no extra arguments at the call site.

Step 4: Emit a per-batch structured record

At the end of each stage, emit a single aggregate record. Always read the CRS from the data and project to a metric CRS before any distance summary, so logged metrics are measured in meters rather than degrees.

import geopandas as gpd

def log_stage_summary(logger: logging.Logger, stage: str,
                      gdf: gpd.GeoDataFrame, metric_crs: str = "EPSG:32610") -> None:
    # Project to a linear CRS before summarizing any area metric.
    projected = gdf.to_crs(metric_crs) if gdf.crs else gdf
    logger.info(
        "stage complete",
        extra={
            "stage": stage,
            "epsg_in": gdf.crs.to_epsg() if gdf.crs else None,
            "epsg_metric": 32610,
            "feature_count": int(len(gdf)),
            "empty_geometry_count": int(projected.geometry.is_empty.sum()),
            "total_area_sqm": float(projected.geometry.area.sum()),
        },
    )

Verification

Confirm that each emitted line is valid JSON, carries the correlation id, and shares one batch_id across the run. Parse the sink back and assert structure rather than eyeballing it.

import json

with open("audit.log") as fh:
    records = [json.loads(line) for line in fh if line.strip()]

batch_ids = {r["batch_id"] for r in records}
assert len(batch_ids) == 1, f"expected one batch id, saw {batch_ids}"
assert all("stage" in r and "feature_count" in r for r in records)
print(f"{len(records)} records verified for batch {batch_ids.pop()}")

If every record parses and the assertions hold, the log is queryable and ready for the hash-chaining and retention steps described in the parent module.

Common Pitfalls

  • Logging geometry objects directly. Shapely geometries are not JSON-serializable and will bloat the log if coerced to WKT. Log counts, areas, and identifiers instead of raw geometry; the default=str fallback should be a safety net, not the plan.
  • Summarizing area in a geographic CRS. Calling .area on EPSG:4326 data yields square degrees, which are meaningless. Always reproject to a metric CRS first, as in Step 4, and record which EPSG the metric was computed in.
  • Duplicate handlers on re-import. Calling the configuration function twice stacks handlers and emits every line multiple times. Clear existing handlers first, as shown, so the logger is idempotent.

Fields Every Event Carries

Structured logging pays off only when the structure is consistent, and consistency comes from a small set of fields present on every event regardless of type.

Fields every event carriesUTC timestamp, run identifier, event type from a closed vocabulary, schema version and an honest severity.ts — UTC, fixed formatordering and range queries without ambiguityrun_idties a run’s events togetherevent — a closed vocabularyso readers can dispatchschemaold records stay readableseverity, kept honesterror means work stopped
Common fields flat and indexable; everything type-specific nested, so it can evolve.

A timestamp in UTC, in a fixed format, so ordering and range queries work without parsing ambiguity. A run identifier tying every event of a run together. An event type from a closed vocabulary, so a reader can dispatch. A schema version, so old records stay readable when the shape changes. And a severity, kept honest: informational for the normal course, warning for something a human should see eventually, error for something that stopped work.

Everything else is type-specific and belongs in a nested object rather than at the top level, which keeps the common fields easy to index and the type-specific ones easy to evolve.

def event(kind: str, run_id: str, severity: str = "info", **payload) -> dict:
    """One shape for every event: common fields flat, type-specific fields nested."""
    return {
        "ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
        "run_id": run_id,
        "event": kind,                 # from a closed vocabulary
        "schema": EVENT_SCHEMA,
        "severity": severity,
        "data": payload,               # everything type-specific lives here
    }

Avoiding the Two Common Failure Modes

Structured logs fail in two opposite directions, and both are avoidable with a rule rather than with discipline.

The two opposite ways structured logging failsFree text smuggled into structure, and unbounded per-feature cardinality — each with a rule that prevents it.Looks likePrevented byFree text in a JSONwrapperOne useful field: a formatted messageAnything filterable gets its own fieldUnbounded cardinalityAn event per parcel per stageLayer-level unless it genuinely differsReason codes assentencesUngroupable free textA closed vocabulary of codesSeverity inflationEverything is a warningError means work stopped
A new event type should arrive with the query someone will run against it. If there is no query, there is no event.

Free text smuggled into structure. An event whose only useful content is a formatted message string is a text log with JSON overhead. The fix is to require that anything a query might filter on is its own field: parcel identifier, rule identifier, counts, reasons as codes rather than sentences. A human-readable message can accompany them, but it must not be the only place the information exists.

Unbounded cardinality. Emitting an event per parcel per stage produces tens of millions of records for a county run, most carrying nothing that varies. The fix is the granularity rule from lineage work: layer-level for anything applied uniformly, feature-level only for things that genuinely differ. The verdict events are the legitimate high-cardinality stream, and they are the ones worth paying to keep.

Both failure modes are cheap to catch in review: a new event type should be accompanied by the query someone will run against it. If no such query exists, the event probably should not either.

Where the Records Go

The destination shapes what the log can be used for, and compliance logs have different requirements from application logs.

Where compliance events should be writtenObject storage with newline JSON and a Parquet roll-up, compared against a hosted logging service billed by ingest.Newline JSON to object storageHosted logging serviceCost shapeStorage only, negligiblePer ingested eventRetention controlYours, set by the appeals windowThe vendor’s tiersQueryParquet roll-up, columnarGood, until retention expiresSchema evolutionTolerated by constructionDepends on the vendor
Cost pressure on an ingest-billed service eventually reduces what gets captured — and the dropped fields are the ones later needed.

Newline-delimited JSON to object storage, partitioned by run and date, is the pragmatic default: cheap, durable, immune to schema migration, and directly queryable by most analytical tools. Rolling completed partitions into Parquet gives fast columnar queries over history without changing what was written.

What to avoid for the compliance stream is a hosted logging service billed by ingest volume, since the cost pressure eventually reduces what gets captured — and the fields dropped are usually the ones that seemed redundant until they were needed. Keep operational logging there if it helps, and keep the compliance stream where its retention is under your control.

Part of: Validation log design

Frequently Asked Questions

Why use the standard logging module instead of just writing JSON files myself?

The standard library gives you handler routing, log levels, thread-safety, and filters for free. A correlation-id filter stamps every record automatically, and swapping a file sink for an object-store or syslog handler becomes a one-line change rather than a rewrite. Rolling your own file writer usually reinvents these features less reliably.

How do correlation ids work across multiprocessing worker pools?

A contextvars.ContextVar is inherited by child threads but not by separate processes. When you fan out with multiprocessing, pass the batch id explicitly to each worker and call the setter inside the worker before logging. Each process then stamps the same id, so records from all workers still collate into one run in the audit store.

Should I log every feature or only aggregates?

Log per-batch aggregates by default and reserve per-feature records for failures and quarantined geometries. At municipal scale, per-feature logging inflates storage and slows queries, while aggregate records plus targeted failure entries give auditors everything they need without the volume.

How does this connect to capturing coordinate system details?

The formatter here carries whatever fields you pass in extra, including EPSG codes. To record the full source-to-target transformation pipeline rather than just a single EPSG code, follow Capturing CRS Provenance in Validation Logs, which extends these same structured records with pyproj pipeline detail.