diff --git a/docker/Dockerfile b/docker/Dockerfile index d1f8293..b2a8323 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,6 +1,6 @@ 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 # 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 COPY docker/entrypoint.sh /entrypoint.sh +COPY docker/json_logger.py /json_logger.py RUN chmod +x /entrypoint.sh # --------------------------------------------------------------------------- @@ -33,6 +34,7 @@ RUN chmod +x /entrypoint.sh # Core ENV ARA_BASE_DIR=/opt/ara +ENV PYTHONPATH=/ # ENV ARA_SECRET_KEY=changeme # set a stable secret in production # ENV ARA_ALLOWED_HOSTS="['*']" # restrict to your hostname(s) # ENV ARA_TIME_ZONE=UTC # ARA display/storage timezone diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 39ed5cc..ea78f7a 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -47,6 +47,6 @@ crond -n & exec python3 -m gunicorn \ --workers="${ARA_GUNICORN_WORKERS:-4}" \ --access-logfile - \ + --logger-class json_logger.JsonLogger \ --bind "[::]:${ARA_PORT:-8000}" \ - --access-logformat '%({x-forwarded-for}i)s %l %u %t "%r" %s %b "%f" "%a"' \ ara.server.wsgi diff --git a/docker/json_logger.py b/docker/json_logger.py new file mode 100644 index 0000000..f7512a9 --- /dev/null +++ b/docker/json_logger.py @@ -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)