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.
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.
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.
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.