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)