feat: Add JSON structured logging and fix mariadb-devel package name
All checks were successful
CI / build (push) Successful in 13s
CI / release (release) Successful in 11m54s

This commit is contained in:
2026-02-21 21:04:08 +13:00
parent 6d5cfc3110
commit 41d0f13ba8
3 changed files with 91 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
FROM almalinux:9.5-minimal FROM almalinux:9.5-minimal
ARG DEV_DEPENDENCIES="gcc python3-devel postgresql-devel mariadb-devel" ARG DEV_DEPENDENCIES="gcc python3-devel postgresql-devel mariadb-connector-c-devel"
# Install only the runtime packages we need, including cronie for cron support # Install only the runtime packages we need, including cronie for cron support
# tini is used as PID 1 to reap zombie processes spawned by crond and forward # tini is used as PID 1 to reap zombie processes spawned by crond and forward
@@ -24,6 +24,7 @@ RUN microdnf install -y ${DEV_DEPENDENCIES} \
&& microdnf clean all && microdnf clean all
COPY docker/entrypoint.sh /entrypoint.sh COPY docker/entrypoint.sh /entrypoint.sh
COPY docker/json_logger.py /json_logger.py
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -33,6 +34,7 @@ RUN chmod +x /entrypoint.sh
# Core # Core
ENV ARA_BASE_DIR=/opt/ara ENV ARA_BASE_DIR=/opt/ara
ENV PYTHONPATH=/
# ENV ARA_SECRET_KEY=changeme # set a stable secret in production # ENV ARA_SECRET_KEY=changeme # set a stable secret in production
# ENV ARA_ALLOWED_HOSTS="['*']" # restrict to your hostname(s) # ENV ARA_ALLOWED_HOSTS="['*']" # restrict to your hostname(s)
# ENV ARA_TIME_ZONE=UTC # ARA display/storage timezone # ENV ARA_TIME_ZONE=UTC # ARA display/storage timezone

View File

@@ -47,6 +47,6 @@ crond -n &
exec python3 -m gunicorn \ exec python3 -m gunicorn \
--workers="${ARA_GUNICORN_WORKERS:-4}" \ --workers="${ARA_GUNICORN_WORKERS:-4}" \
--access-logfile - \ --access-logfile - \
--logger-class json_logger.JsonLogger \
--bind "[::]:${ARA_PORT:-8000}" \ --bind "[::]:${ARA_PORT:-8000}" \
--access-logformat '%({x-forwarded-for}i)s %l %u %t "%r" %s %b "%f" "%a"' \
ara.server.wsgi ara.server.wsgi

87
docker/json_logger.py Normal file
View File

@@ -0,0 +1,87 @@
"""
Minimal JSON logger for gunicorn.
Replaces gunicorn's default text logger with structured JSON output,
one JSON object per line. Both access and error logs are covered.
Usage:
gunicorn --logger-class json_logger.JsonLogger ...
"""
import json
import logging
import time
from gunicorn.glogging import Logger
class JsonLogger(Logger):
"""Gunicorn logger that emits one JSON object per log line."""
# ------------------------------------------------------------------ #
# Error / application log records #
# ------------------------------------------------------------------ #
def setup(self, cfg):
super().setup(cfg)
# Replace every handler's formatter on both error and access loggers
for logger_name in ("error_log", "access_log"):
lgr = getattr(self, logger_name)
for handler in lgr.handlers:
handler.setFormatter(_JsonFormatter())
# ------------------------------------------------------------------ #
# Access log records #
# ------------------------------------------------------------------ #
def access(self, resp, req, environ, request_time):
"""Emit a structured JSON access log record."""
if not self.access_log.handlers or not self.cfg.accesslog:
return
status = resp.status
if isinstance(status, str):
status_code = int(status.split(None, 1)[0])
else:
status_code = status
record = {
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"level": "INFO",
"logger": "gunicorn.access",
"method": environ.get("REQUEST_METHOD", "-"),
"path": environ.get("PATH_INFO", "-"),
"query": environ.get("QUERY_STRING", "") or None,
"status": status_code,
"response_bytes": getattr(resp, "sent", None),
"duration_ms": round(request_time.seconds * 1000
+ request_time.microseconds / 1000, 2),
"remote_addr": environ.get("REMOTE_ADDR", "-"),
"x_forwarded_for": environ.get("HTTP_X_FORWARDED_FOR") or None,
"user_agent": environ.get("HTTP_USER_AGENT", "-"),
"referer": environ.get("HTTP_REFERER") or None,
"http_version": environ.get("SERVER_PROTOCOL", "-"),
}
# Drop None values for cleaner output
record = {k: v for k, v in record.items() if v is not None}
# Write directly to handler stream to avoid double-formatting
line = json.dumps(record)
for handler in self.access_log.handlers:
stream = getattr(handler, "stream", None)
if stream:
stream.write(line + "\n")
stream.flush()
class _JsonFormatter(logging.Formatter):
"""Formatter that converts a LogRecord to a single JSON line."""
def format(self, record: logging.LogRecord) -> str:
obj = {
"timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if record.exc_info:
obj["exception"] = self.formatException(record.exc_info)
return json.dumps(obj)