feat: add LOG_FORMAT=json for structured JSON logging (#21747)

* feat: add LOG_FORMAT env var with JSON formatter for early logging

Introduce LOG_FORMAT environment variable (set to "json" to enable).
When active, logging.basicConfig() uses a JSONFormatter that outputs
single-line JSON objects with fields: ts, level, msg, caller, error,
stacktrace. This covers all log messages emitted during module imports
before Loguru's start_logger() takes over.

* feat: add JSON sink for Loguru when LOG_FORMAT=json

Add _json_sink() as a Loguru sink function that writes single-line JSON
to stdout. In start_logger(), conditionally use the JSON sink instead of
the plain-text stdout_format when LOG_FORMAT is set to "json".

* feat: suppress ASCII banner and fix alembic logging in JSON mode

- Wrap the ASCII art banner print in main.py with a LOG_FORMAT != "json"
  guard so JSON output stays machine-parseable.
- Skip alembic's fileConfig() call in migrations/env.py when
  LOG_FORMAT=json to prevent it from replacing the JSON log handlers
  installed during early startup.
This commit is contained in:
Andrei Efanov
2026-02-22 17:40:17 -06:00
committed by GitHub
parent 3ad2ea6f28
commit 9e81e1dda1
4 changed files with 101 additions and 11 deletions
+43 -8
View File
@@ -12,13 +12,15 @@ from open_webui.env import (
AUDIT_LOG_FILE_ROTATION_SIZE,
AUDIT_LOG_LEVEL,
GLOBAL_LOG_LEVEL,
LOG_FORMAT,
AUDIT_UVICORN_LOGGER_NAMES,
ENABLE_OTEL,
ENABLE_OTEL_LOGS,
_LEVEL_MAP,
)
if TYPE_CHECKING:
from loguru import Record
from loguru import Message, Record
def stdout_format(record: "Record") -> str:
@@ -43,6 +45,31 @@ def stdout_format(record: "Record") -> str:
)
def _json_sink(message: "Message") -> None:
"""Write log records as single-line JSON to stdout.
Used as a Loguru sink when LOG_FORMAT is set to "json".
"""
record = message.record
log_entry = {
"ts": record["time"].strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
"level": _LEVEL_MAP.get(record["level"].name, record["level"].name.lower()),
"msg": record["message"],
"caller": f"{record['name']}:{record['function']}:{record['line']}",
}
if record["extra"]:
log_entry["extra"] = record["extra"]
if record["exception"] is not None:
log_entry["error"] = "".join(
record["exception"].format_exception()
).rstrip()
sys.stdout.write(json.dumps(log_entry, ensure_ascii=False, default=str) + "\n")
sys.stdout.flush()
class InterceptHandler(logging.Handler):
"""
Intercepts log records from Python's standard logging module
@@ -127,14 +154,22 @@ def start_logger():
"""
logger.remove()
logger.add(
sys.stdout,
level=GLOBAL_LOG_LEVEL,
format=stdout_format,
filter=lambda record: (
"auditable" not in record["extra"] if ENABLE_AUDIT_STDOUT else True
),
audit_filter = lambda record: (
"auditable" not in record["extra"] if ENABLE_AUDIT_STDOUT else True
)
if LOG_FORMAT == "json":
logger.add(
_json_sink,
level=GLOBAL_LOG_LEVEL,
filter=audit_filter,
)
else:
logger.add(
sys.stdout,
level=GLOBAL_LOG_LEVEL,
format=stdout_format,
filter=audit_filter,
)
if AUDIT_LOG_LEVEL != "NONE" and ENABLE_AUDIT_LOGS_FILE:
try:
logger.add(