Designing a Log Schema for a Service Fleet

A log schema for one service is a formatter. A log schema for forty services is an agreement, a package, a migration and an enforcement mechanism, and most attempts fail at one of those rather than at the field names. This page covers the process end to end: starting from what services already emit, keeping the shared core small, publishing it as code, migrating without breaking dashboards, and keeping it alive after the first release. It is a task article under JSON log schemas and conventions, part of the modern Python logging libraries deep dive section.

From inventory to enforcement A fleet log schema rollout is drawn as five stages. Inventory collects one representative record from each service and produces a table of fields, names and types currently in use, including the conflicts. Design selects the shared core — the fields that fleet-wide queries actually use — and produces a short field list with types and units. Publish packages the core as a shared library containing the formatter, field constants and a JSON Schema. Migrate runs each service on the package with legacy field names emitted alongside standard ones until dashboards have moved, then removes the legacy names on an announced date. Enforce validates every service's snapshot against the schema in continuous integration and monitors the store for new top-level fields and type conflicts. The note records that most failed schema efforts stop after the design stage, producing a document that nobody's code references. five stages, each producing something concrete inventory what exists and conflicts design small core, types, units publish a package, not a document migrate both names, a removal date enforce CI + store monitoring most efforts stop here — a document the schema only exists once code references it the first two stages are discussion; the last three are what make it real a schema that no formatter enforces is followed by whoever happened to read it
The field list is the easy part. The package, the migration and the enforcement are what turn a document into a schema that services actually follow.

Prerequisites

pip install "jsonschema>=4.21.0,<5.0.0" \
            "python-json-logger>=2.0.7,<4.0.0"

Implementation

Step 1 — Inventory what already exists. Before designing anything, collect one representative record from each service — an ordinary request log line and an error — and list every field with its name and type. The inventory reveals the real conflicts: three spellings of service name, two units for duration, a field that is a string in one service and a number in another. Designing from this list produces a schema that fixes actual problems rather than hypothetical ones.

# inventory.py — fields across services from one sample record each
import collections, json, pathlib

fields = collections.defaultdict(lambda: collections.defaultdict(set))
for sample in pathlib.Path("samples").glob("*.json"):
    service = sample.stem
    for key, value in json.loads(sample.read_text()).items():
        fields[key][type(value).__name__].add(service)

for key in sorted(fields):
    types = {t: len(s) for t, s in fields[key].items()}
    flag = "  <- TYPE CONFLICT" if len(types) > 1 else ""
    print(f"{key:32s} {types}{flag}")

Expected Output: the conflicts that justify the work, stated plainly.

duration_ms                      {'float': 11, 'str': 2}  <- TYPE CONFLICT
elapsed                          {'str': 4}
latency                          {'float': 6}
order_id                         {'str': 9, 'int': 3}  <- TYPE CONFLICT
service                          {'str': 14}
service_name                     {'str': 9}
trace_id                         {'str': 17}
traceId                          {'str': 4}

Step 2 — Define a small core. The shared schema should contain only fields that queries spanning services use: identity, severity, timestamp, correlation, error, HTTP and duration. Everything else goes under a namespace per service. A core of fifteen to twenty-five fields can be agreed in a meeting; one of two hundred becomes a standing committee. The field names are best taken from an existing convention, as discussed in JSON log schemas and conventions.

Step 3 — Publish it as code. The schema is a Python package: a formatter that enforces the core names and types, a module of field-name constants so call sites never spell them by hand, a namespace helper, a one-call configuration function, and the JSON Schema used for validation. Services adopt the schema by importing the package, which is far more reliable than asking them to follow a document.

# fleetlog/fields.py
SERVICE_NAME = "service.name"
TRACE_ID = "trace_id"
SPAN_ID = "span_id"
DURATION_MS = "duration_ms"
HTTP_ROUTE = "http.route"
HTTP_STATUS = "http.response.status_code"
ERROR_TYPE = "error.type"

SCHEMA_VERSION = "1.3.0"

def app(**fields) -> dict:
    """Service-specific fields, always under the namespace."""
    return {"app": fields}
# in a service
from fleetlog import configure, fields as F
configure(service="orders")
log.info("order accepted", extra={F.DURATION_MS: 41.2, **F.app(order_id="ord_7")})

Step 4 — Migrate with both names for a fixed period. Existing dashboards and alerts use the legacy names. The shared formatter can emit both the legacy and the standard name for each renamed field during a transition, so nothing breaks while dashboards move. A removal date, announced when the transition starts, is what stops the dual emission becoming permanent.

LEGACY_ALIASES = {                       # standard name -> legacy names still emitted
    "service.name": ["service", "service_name"],
    "duration_ms": ["latency_ms"],
}
REMOVE_LEGACY_AFTER = "2026-12-31"

Step 5 — Enforce in CI. Each service's snapshot tests produce representative records; validating them against the published JSON Schema in continuous integration turns a renamed or retyped core field into a failing build. This is the single most effective mechanism for keeping a schema alive, because it catches divergence in the pull request that introduces it.

Step 6 — Watch the store. Some divergence bypasses CI — a service not yet on the package, a third-party component, a collector rule that changed. Monitoring the store for new top-level field names and for type conflicts on core fields catches it within a day, and each alert names the service responsible.

Migrating without breaking dashboards A timeline spans a migration window of about ten weeks. Before the window, services emit only legacy names such as service_name and latency, and dashboards are built on them. At the start of the window, the shared formatter begins emitting both the legacy and the standard names for every renamed field; storage rises slightly and nothing breaks. During the window, dashboard and alert owners move their queries to the standard names, and a usage report shows how many saved queries still reference each legacy name. On the announced removal date, the legacy names stop being emitted. Any query still using them breaks at a known time, having been warned, rather than at an unknown time in the middle of an incident. The note records that the removal date is what makes the migration finish. a ten-week migration window legacy names only both names emitted · dashboards move · usage report tracked standard names only announced removal date storage rises slightly · nothing breaks the removal date is what makes the migration finish without it, dual emission becomes permanent and the legacy names are never retired with it, any query still on a legacy name breaks at a known time, having been warned
Dual emission removes the breakage; the removal date removes the legacy. Without the second, the first becomes the permanent state.

Keeping it alive

A schema's first release is the easy part. The difficult part is the second year, when the people who designed it have moved on, new services are being written by people who never saw the design discussion, and requests for new fields arrive weekly.

Make additions cheap and changes deliberate. A new shared field that fleet-wide queries need should take a small pull request and a day, not a meeting. A rename or type change should require notice, a migration window and a removal date. If adding a field is hard, teams stop asking and put everything in their namespace, including things that should be shared; if changing a field is easy, dashboards break without warning.

Keep the package worth using for its own sake. Services adopt a shared logging package when it solves their problems — correct configuration in one call, trace correlation, redaction, queue handling — not because a schema is attached. Investing in those features is what keeps new services on the package without being told to be.

Publish the store's view, not only the specification. A generated page listing every core field, which services emit it and with what type — built from the store's own field statistics — shows the schema as it actually is. Discrepancies between the specification and that page are the work queue.

Name an owner. Somebody must be able to say yes to an addition and no to an unannounced rename. A schema without an owner accumulates exceptions until it is not a schema.

The most useful signal that a schema is healthy is boring: new services adopt it without discussion, dashboards spanning services keep working across releases, and the store's type conflict alert almost never fires.

The schema and the other two signals

A log schema designed in isolation from traces and metrics tends to reinvent names that the other signals already use, and the result is three vocabularies for the same concepts. Designing it alongside them avoids that.

The resource attributes that identify a service — its name, version and environment — should be literally the same values in logs, spans and metrics, drawn from the same configuration. When they are, a dashboard can move from a latency metric to the traces behind it to the logs within those traces using a single filter, and the three signals behave as one dataset. When they differ even slightly — checkout in metrics, checkout-api in logs — every cross-signal query needs a translation that somebody has to remember.

The same applies to request-level attributes. A route template recorded as http.route on spans, as a route label on metrics and as endpoint in logs is the same concept in three places, and a schema that adopts the tracing convention's name for logs removes one of those translations. The practical rule is to take shared names from OpenTelemetry semantic conventions wherever a convention exists, because the tracing side of the fleet will already be using them.

Metric labels are the one place where the names may legitimately differ, because label naming rules in some metrics systems forbid dots. There the convention is a mechanical translation — dots to underscores — applied consistently, and the shared package is a good place to provide it so that http.route in logs and http_route in metrics are recognisably the same field.

Required, recommended, or free A table of field tiers in a fleet log schema. Required fields, which every record from every service must carry: timestamp, level, message, service name, environment. Recommended fields, present whenever they apply: trace and span identifiers, request identifier, route, user or tenant identifier. Domain fields, owned by each team under its own namespace: order identifiers, payment outcomes, job names. Free fields, anything else, allowed but never relied on by shared dashboards. The note says a schema that tries to standardise everything is ignored; one that standardises the few fields every query uses is followed. tier examples rule required timestamp, level, message, service every record recommended trace_id, request_id, route whenever it applies domain orders.id, payment.outcome team-owned namespace free anything else never used by shared dashboards standardise the few fields every query uses — not everything a schema that tries to cover every field is ignored
A small required core and team-owned namespaces keep a schema followed. Trying to name everything centrally does not.

Configuration options

Element Recommendation Why
Starting point inventory of real records design from actual conflicts
Core size 15–25 fields agreeable and enforceable
Distribution a Python package adoption by import
Field names in code constants from the package no hand-spelled names
Migration dual emission with a removal date nothing breaks; it still finishes
CI enforcement JSON Schema over snapshots divergence caught in review
Store monitoring new top-level fields, type conflicts catches what bypasses CI
Versioning semantic, with a changelog services upgrade deliberately

Verification

Measure adoption directly from the store, per service.

# share of each service's records that carry every core field with the right type
for service, stats in store_field_stats(window="1d").items():
    ok = all(stats.get(f, {}).get("type") == t for f, t in CORE_FIELDS.items() if f in stats)
    coverage = sum(1 for f in CORE_FIELDS if f in stats) / len(CORE_FIELDS)
    print(f"{service:14s} coverage {coverage:5.0%}  types {'ok' if ok else 'CONFLICT'}")

Expected Output: a list that shrinks towards complete coverage as the migration proceeds.

orders         coverage  100%  types ok
billing        coverage  100%  types ok
search         coverage   73%  types CONFLICT

Common mistakes

Designing from a blank page. Error signature: a schema that fixes imagined problems and misses real ones. Root cause: no inventory. Remediation: start from one real record per service.

A large core. Error signature: a schema process that never finishes. Root cause: trying to standardise every field. Remediation: only what cross-service queries use.

A document instead of a package. Error signature: compliance that depends on who read it. Root cause: nothing in code references the schema. Remediation: ship the formatter and constants.

Dual emission with no end. Error signature: legacy names still emitted years later. Root cause: no removal date. Remediation: announce one when the migration starts.

No store monitoring. Error signature: a conflict discovered through a broken dashboard. Root cause: relying on CI alone. Remediation: alert on new top-level fields and core type conflicts in the store.

Frequently Asked Questions

How big should a fleet log schema be?

Small. Fifteen to twenty-five shared fields covers identity, severity, correlation, errors, HTTP and duration. Everything else belongs in each service's namespace. A schema that tries to name every field any service might want becomes a committee process and stops being followed.

Who should own the schema?

A platform or observability team, with a lightweight change process. Additions should be quick; renames and type changes should require notice. Ownership matters less than having one named owner who can say yes.

How do we migrate services with established field names?

Emit both the legacy and the standard name for a fixed period, move dashboards and alerts to the standard names, then remove the legacy ones on an announced date. For services that cannot change quickly, a collector rename rule bridges the gap.

How do we stop the schema drifting after adoption?

Validate every service's representative records against it in continuous integration, and monitor the log store for new top-level fields and type conflicts. Drift that is caught in the pull request is cheap; drift discovered through a broken dashboard is not.