Source code for core.structured_logger

"""Structured (JSON) logging for the microservices.

:class:`JSONFormatter` renders each log record as a single-line JSON
object enriched with service name, instance id, and version (plus a
formatted traceback for exceptions), so logs from every service
aggregate cleanly in journald or a downstream log pipeline.
"""

import logging
import json
import sys
import datetime
import traceback
from typing import TextIO

[docs] class JSONFormatter(logging.Formatter): """Render standard logging records as single-line JSON with service context. A :class:`logging.Formatter` subclass that turns every log record into one JSON object enriched with the static service name, instance id, and version bound at construction, so that logs from all five microservices aggregate and correlate cleanly in journald or a downstream log pipeline instead of being free-form text. Instances are installed as a handler's formatter by :func:`configure_logging`; once installed, the logging framework calls :meth:`format` for every emitted record. Constructed by :func:`configure_logging` in this module and directly in tests. """
[docs] def __init__(self, service_name: str, instance_id: str, version: str): """Bind the static service context attached to every formatted record. Calls the base :class:`logging.Formatter` initialiser, then stores the service name, instance id, and version that :meth:`format` injects into each emitted JSON line so logs from every microservice can be told apart and version-correlated in journald or a downstream pipeline. Instantiated by :func:`configure_logging` in this module (and directly in tests); there are no other internal callers. Args: service_name: Logical service name (e.g. ``"gateway"``) emitted under the ``service`` key. instance_id: Unique per-process identifier emitted under ``instance_id``. version: Build or release version string emitted under ``version``. """ super().__init__() self.service_name = service_name self.instance_id = instance_id self.version = version
[docs] def format(self, record: logging.LogRecord) -> str: """Render a log record as a single-line JSON object with service context. Assembles a dictionary with an ISO-8601 timestamp derived from ``record.created``, the level, the bound ``service`` / ``instance_id`` / ``version`` captured in ``__init__``, the logger name, and the rendered message. If the record carries exception info, the full formatted traceback is added under ``exc_info`` via ``traceback.format_exception``. Any custom fields supplied through ``logger.x(..., extra={...})`` are then merged in, skipping the standard :class:`logging.LogRecord` attributes so only caller-provided context survives. The dict is serialised with ``json.dumps``. This is called implicitly by the logging framework for every record once an instance is installed as a handler's formatter (done by :func:`configure_logging`); there are no direct internal callers. Args: record: The log record produced by the standard logging machinery. Returns: A JSON-encoded string containing the standard context fields plus any non-standard extra fields and, when present, the exception traceback. """ log_data = { "timestamp": datetime.datetime.fromtimestamp(record.created).isoformat(), "level": record.levelname, "service": self.service_name, "instance_id": self.instance_id, "version": self.version, "logger": record.name, "message": record.getMessage(), } # Include exception traceback if available if record.exc_info: log_data["exc_info"] = "".join(traceback.format_exception(*record.exc_info)) # Include custom extra fields if they don't collide for key, val in record.__dict__.items(): # Filter out standard LogRecord attributes if key not in { "args", "asctime", "created", "exc_info", "exc_text", "filename", "funcName", "id", "levelname", "levelno", "lineno", "module", "msecs", "message", "msg", "name", "pathname", "process", "processName", "relativeCreated", "stack_info", "thread", "threadName", "taskName" }: log_data[key] = val return json.dumps(log_data)
[docs] def configure_logging( service_name: str, instance_id: str, version: str, level: int = logging.INFO, stream: TextIO = sys.stdout ): """Install a JSON formatter on the root logger for the whole process. Reconfigures Python's root logger so every record emitted anywhere in the service is rendered as a single-line JSON object carrying the supplied service identity, giving the microservice uniform machine-parseable logs. It removes any pre-existing root handlers first (preventing duplicate output if called more than once), then attaches a fresh :class:`logging.StreamHandler` on ``stream`` whose formatter is a :class:`JSONFormatter` bound to the given service name, instance id, and version, and sets the root level. Mutates global logging state and writes to ``stream`` (stdout by default); performs no Redis or network I/O. Called once at service startup by each microservice entrypoint and exercised by the migration tests in ``tests/core/migration/test_structured_logger.py``. Args: service_name: Logical service name (e.g. ``"gateway"``) stamped on every record under the ``service`` key. instance_id: Unique per-process identifier emitted under ``instance_id``. version: Build or release version string emitted under ``version``. level: Root logger threshold; defaults to ``logging.INFO``. stream: Output stream the handler writes to; defaults to ``sys.stdout``. """ # Clear existing handlers to prevent duplicate logs root_logger = logging.getLogger() for handler in root_logger.handlers[:]: root_logger.removeHandler(handler) handler = logging.StreamHandler(stream) formatter = JSONFormatter( service_name=service_name, instance_id=instance_id, version=version ) handler.setFormatter(formatter) root_logger.addHandler(handler) root_logger.setLevel(level)