Skip to content

Commit 332a168

Browse files
authored
feat(logging): Add option for json logging (#44)
* Add json logging * . * Add json logging * ruff fix
1 parent f5201db commit 332a168

6 files changed

Lines changed: 288 additions & 5 deletions

File tree

code-interpreter/app/app_configs.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@
3939
HOST = os.environ.get("HOST") or "0.0.0.0" # noqa: S104
4040
PORT = int(os.environ.get("PORT") or "8000")
4141

42+
# Logging configuration
43+
# LOG_LEVEL controls verbosity (e.g. DEBUG, INFO, WARNING).
44+
# LOG_FORMAT selects the output style: "plain" (default human-readable text) or
45+
# "json" (structured single-line JSON suitable for container log aggregators).
46+
LOG_LEVEL = (os.environ.get("LOG_LEVEL") or "INFO").upper()
47+
LOG_FORMAT = (os.environ.get("LOG_FORMAT") or "plain").lower()
48+
JSON_LOGGING = LOG_FORMAT == "json"
49+
4250
# File storage configuration
4351
FILE_STORAGE_DIR = (
4452
os.environ.get("FILE_STORAGE_DIR") or "/tmp/code-interpreter-files" # noqa: S108
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from typing import Final
5+
6+
from app.app_configs import JSON_LOGGING, LOG_LEVEL
7+
8+
PLAIN_FORMAT: Final[str] = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
9+
10+
# Uvicorn installs its own handlers on these loggers. We clear them so records
11+
# propagate to the root logger and are emitted through our single handler/format.
12+
_UVICORN_LOGGERS: Final[tuple[str, ...]] = ("uvicorn", "uvicorn.error", "uvicorn.access")
13+
14+
15+
class _DropColorMessageFilter(logging.Filter):
16+
"""Drop uvicorn's ``color_message`` record attribute.
17+
18+
Uvicorn attaches an ANSI-colored duplicate of the message as
19+
``record.color_message``. The JSON formatter would otherwise emit it as a
20+
field carrying raw escape codes, polluting the structured output.
21+
"""
22+
23+
def filter(self, record: logging.LogRecord) -> bool:
24+
if hasattr(record, "color_message"):
25+
del record.color_message
26+
return True
27+
28+
29+
def get_json_formatter() -> logging.Formatter:
30+
"""Return a structured single-line JSON formatter.
31+
32+
Standard record attributes are emitted as discrete top-level fields and any
33+
``extra`` keys passed to a logging call are merged in alongside them, which
34+
makes the output suitable for container log aggregators.
35+
36+
The ``pythonjsonlogger`` import is deferred to this call site (only reached
37+
when ``LOG_FORMAT=json``) so importing this module never hard-fails in
38+
environments where the optional dependency is absent.
39+
"""
40+
from pythonjsonlogger.json import JsonFormatter
41+
42+
return JsonFormatter(
43+
"%(asctime)s %(levelname)s %(name)s %(filename)s %(lineno)d %(message)s",
44+
rename_fields={
45+
"asctime": "timestamp",
46+
"levelname": "level",
47+
"name": "logger",
48+
},
49+
datefmt="%Y-%m-%dT%H:%M:%S%z",
50+
)
51+
52+
53+
def get_formatter() -> logging.Formatter:
54+
"""Return the configured formatter (JSON when ``LOG_FORMAT=json``)."""
55+
if JSON_LOGGING:
56+
return get_json_formatter()
57+
return logging.Formatter(PLAIN_FORMAT)
58+
59+
60+
def _resolve_level() -> tuple[int, bool]:
61+
"""Resolve LOG_LEVEL to a numeric level, falling back to INFO.
62+
63+
Returns ``(level, was_valid)``. ``setLevel`` would raise ``ValueError`` on a
64+
typo'd, operator-supplied level (e.g. ``INFOO``); we fall back to INFO and
65+
let the caller warn, rather than crash the service at startup.
66+
"""
67+
level = logging.getLevelNamesMapping().get(LOG_LEVEL)
68+
if level is None:
69+
return logging.INFO, False
70+
return level, True
71+
72+
73+
def setup_logging() -> None:
74+
"""Configure root and uvicorn logging from the environment settings.
75+
76+
Idempotent: the root logger's handlers are replaced (not appended) so that
77+
repeated calls — e.g. module import plus an explicit startup call — do not
78+
stack duplicate handlers.
79+
80+
Note: this consolidates *all* logging (including uvicorn's access/error
81+
logs) onto a single handler and format. In the default ``plain`` mode that
82+
replaces uvicorn's own colorized access-log format with ``PLAIN_FORMAT``.
83+
"""
84+
formatter = get_formatter()
85+
86+
handler = logging.StreamHandler()
87+
handler.setFormatter(formatter)
88+
handler.addFilter(_DropColorMessageFilter())
89+
90+
level, level_valid = _resolve_level()
91+
92+
root = logging.getLogger()
93+
root.handlers = [handler]
94+
root.setLevel(level)
95+
96+
# Route uvicorn's loggers through the root handler to keep one consistent
97+
# format, and let them propagate rather than emitting via their own handlers.
98+
for name in _UVICORN_LOGGERS:
99+
uvicorn_logger = logging.getLogger(name)
100+
uvicorn_logger.handlers = []
101+
uvicorn_logger.setLevel(level)
102+
uvicorn_logger.propagate = True
103+
104+
if not level_valid:
105+
logging.getLogger(__name__).warning("Unknown LOG_LEVEL %r; falling back to INFO", LOG_LEVEL)

code-interpreter/app/main.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,14 @@
1414
from app.api.routes import router as api_router
1515
from app.app_configs import EXECUTOR_BACKEND, HOST, PORT, PYTHON_EXECUTOR_DOCKER_IMAGE
1616
from app.image_ref import normalize_image_ref
17+
from app.logging_config import setup_logging
1718
from app.models.schemas import HealthResponse
1819
from app.services.executor_factory import get_executor
1920

2021
SESSION_REAPER_INTERVAL_SEC = 30
2122

2223
# Configure logging
23-
logging.basicConfig(
24-
level=logging.INFO,
25-
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
26-
)
24+
setup_logging()
2725

2826
logger = logging.getLogger(__name__)
2927

@@ -159,4 +157,6 @@ def run() -> None:
159157
"""
160158
import uvicorn
161159

162-
uvicorn.run("app.main:app", host=HOST, port=PORT, log_level="info")
160+
# log_config=None keeps the logging configured by setup_logging(); otherwise
161+
# uvicorn would install its own handlers/formatters and bypass our format.
162+
uvicorn.run("app.main:app", host=HOST, port=PORT, log_config=None)

code-interpreter/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies = [
1616
"python-multipart>=0.0.20",
1717
"uvicorn==0.30.6",
1818
"kubernetes>=31.0.0",
19+
"python-json-logger>=3.1.0",
1920
]
2021

2122
[project.scripts]
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
from __future__ import annotations
2+
3+
import importlib
4+
import json
5+
import logging
6+
import os
7+
from collections.abc import Iterator
8+
from types import ModuleType
9+
10+
import pytest
11+
12+
_LOG_ENV_VARS = ("LOG_FORMAT", "LOG_LEVEL")
13+
14+
15+
@pytest.fixture(autouse=True)
16+
def _restore_logging() -> Iterator[None]:
17+
"""Snapshot and restore global state mutated by these tests.
18+
19+
Each test reloads app.app_configs / app.logging_config under custom env and
20+
calls setup_logging(), which mutates both module-level globals and the
21+
process-wide logging configuration. We restore the logging handlers, the
22+
LOG_* env vars, and — critically — reload both modules back to their
23+
baseline so leftover module attributes (e.g. JSON_LOGGING=True) cannot leak
24+
into the rest of the pytest session, which shares this process.
25+
"""
26+
root = logging.getLogger()
27+
saved_root_handlers = root.handlers[:]
28+
saved_root_level = root.level
29+
saved: dict[str, tuple[list[logging.Handler], int, bool]] = {}
30+
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
31+
lg = logging.getLogger(name)
32+
saved[name] = (lg.handlers[:], lg.level, lg.propagate)
33+
saved_env = {var: os.environ.get(var) for var in _LOG_ENV_VARS}
34+
try:
35+
yield
36+
finally:
37+
root.handlers = saved_root_handlers
38+
root.setLevel(saved_root_level)
39+
for name, (handlers, level, propagate) in saved.items():
40+
lg = logging.getLogger(name)
41+
lg.handlers = handlers
42+
lg.setLevel(level)
43+
lg.propagate = propagate
44+
# Restore the env first, then reload so module globals reflect baseline.
45+
for var, value in saved_env.items():
46+
if value is None:
47+
os.environ.pop(var, None)
48+
else:
49+
os.environ[var] = value
50+
import app.app_configs as app_configs
51+
import app.logging_config as logging_config
52+
53+
importlib.reload(app_configs)
54+
importlib.reload(logging_config)
55+
56+
57+
def _reload_logging_config(
58+
monkeypatch: pytest.MonkeyPatch, log_format: str, level: str = "INFO"
59+
) -> ModuleType:
60+
"""Reload app.app_configs and app.logging_config under the given env."""
61+
monkeypatch.setenv("LOG_FORMAT", log_format)
62+
monkeypatch.setenv("LOG_LEVEL", level)
63+
import app.app_configs as app_configs
64+
import app.logging_config as logging_config
65+
66+
importlib.reload(app_configs)
67+
return importlib.reload(logging_config)
68+
69+
70+
def test_plain_format_is_default(monkeypatch: pytest.MonkeyPatch) -> None:
71+
monkeypatch.delenv("LOG_FORMAT", raising=False)
72+
import app.app_configs as app_configs
73+
74+
importlib.reload(app_configs)
75+
assert app_configs.JSON_LOGGING is False
76+
77+
78+
def test_plain_logging_output(
79+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
80+
) -> None:
81+
logging_config = _reload_logging_config(monkeypatch, "plain")
82+
logging_config.setup_logging()
83+
84+
logging.getLogger("app.test").info("hello plain")
85+
86+
err = capsys.readouterr().err
87+
assert "hello plain" in err
88+
assert "app.test" in err
89+
# Plain output is not JSON.
90+
with pytest.raises(json.JSONDecodeError):
91+
json.loads(err.strip().splitlines()[-1])
92+
93+
94+
def test_json_logging_output_and_extra_fields(
95+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
96+
) -> None:
97+
logging_config = _reload_logging_config(monkeypatch, "json")
98+
logging_config.setup_logging()
99+
100+
logging.getLogger("app.test").info("hello json", extra={"session_id": "abc123"})
101+
102+
line = capsys.readouterr().err.strip().splitlines()[-1]
103+
payload = json.loads(line)
104+
105+
assert payload["message"] == "hello json"
106+
assert payload["level"] == "INFO"
107+
assert payload["logger"] == "app.test"
108+
# extra fields are promoted to discrete top-level keys.
109+
assert payload["session_id"] == "abc123"
110+
# renamed standard fields are present.
111+
assert "timestamp" in payload
112+
113+
114+
def test_json_logging_strips_color_message(
115+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
116+
) -> None:
117+
"""Uvicorn's ANSI-coded color_message must not leak into JSON output."""
118+
logging_config = _reload_logging_config(monkeypatch, "json")
119+
logging_config.setup_logging()
120+
121+
logging.getLogger("uvicorn.error").info(
122+
"Started server", extra={"color_message": "\x1b[36mStarted server\x1b[0m"}
123+
)
124+
125+
line = capsys.readouterr().err.strip().splitlines()[-1]
126+
payload = json.loads(line)
127+
128+
assert payload["message"] == "Started server"
129+
assert "color_message" not in payload
130+
131+
132+
def test_invalid_log_level_falls_back_to_info(monkeypatch: pytest.MonkeyPatch) -> None:
133+
"""A typo'd LOG_LEVEL must not crash startup; it falls back to INFO."""
134+
logging_config = _reload_logging_config(monkeypatch, "plain", level="INFOO")
135+
logging_config.setup_logging() # must not raise
136+
137+
assert logging.getLogger().level == logging.INFO
138+
139+
140+
def test_setup_logging_is_idempotent(monkeypatch: pytest.MonkeyPatch) -> None:
141+
"""Repeated calls must not stack duplicate handlers on the root logger."""
142+
logging_config = _reload_logging_config(monkeypatch, "plain")
143+
logging_config.setup_logging()
144+
logging_config.setup_logging()
145+
logging_config.setup_logging()
146+
147+
assert len(logging.getLogger().handlers) == 1
148+
149+
150+
def test_uvicorn_loggers_propagate_to_root(monkeypatch: pytest.MonkeyPatch) -> None:
151+
"""Uvicorn loggers should propagate to root and own no handlers of their own."""
152+
logging_config = _reload_logging_config(monkeypatch, "json")
153+
logging_config.setup_logging()
154+
155+
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
156+
lg = logging.getLogger(name)
157+
assert lg.handlers == []
158+
assert lg.propagate is True

code-interpreter/uv.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)