Skip to content

Commit e1377aa

Browse files
Add structured logging and API_LOG_PATH
Emit JSON log lines to stdout and optionally to a file via API_LOG_PATH. Wire uvicorn logs to the same handlers. Add a basic log file test and update README.
1 parent c004417 commit e1377aa

3 files changed

Lines changed: 65 additions & 5 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ Graceful shutdown is supported via:
4545
POST http://127.0.0.1:54321/shutdown/
4646
```
4747

48-
Logs are written to stdout/stderr. Optionally, set `API_LOG_PATH` to also write
49-
logs to a file (if/when this is enabled).
48+
Logs are written to stdout/stderr as JSON lines. Optionally, set `API_LOG_PATH`
49+
to also write logs to a file.
5050

5151
### Sidecar packaging (CI)
5252

src/harmonization_framework/api/sidecar.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77

88
import os
99
import sys
10+
import json
1011
import logging
11-
from typing import Optional
12+
from datetime import datetime, timezone
13+
from typing import Optional, List
1214

1315
import uvicorn
1416

@@ -17,6 +19,7 @@
1719
DEFAULT_HOST = "127.0.0.1"
1820
ENV_PORT = "API_PORT"
1921
ENV_HOST = "API_HOST"
22+
ENV_LOG_PATH = "API_LOG_PATH"
2023

2124

2225
def _parse_port(value: str) -> int:
@@ -37,6 +40,51 @@ def _resolve_host(value: Optional[str]) -> str:
3740
return "127.0.0.1" if host == "localhost" else host
3841

3942

43+
class _JsonLogFormatter(logging.Formatter):
44+
"""Format log records as compact JSON lines for stdout/stderr capture."""
45+
def format(self, record: logging.LogRecord) -> str:
46+
payload = {
47+
"ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
48+
"level": record.levelname,
49+
"logger": record.name,
50+
"message": record.getMessage(),
51+
}
52+
return json.dumps(payload, separators=(",", ":"))
53+
54+
55+
def _configure_logging(log_path: Optional[str]) -> List[logging.Handler]:
56+
"""
57+
Configure structured logging to stdout and optionally to a log file.
58+
"""
59+
formatter = _JsonLogFormatter()
60+
handlers: List[logging.Handler] = []
61+
62+
stream_handler = logging.StreamHandler(stream=sys.stdout)
63+
stream_handler.setFormatter(formatter)
64+
handlers.append(stream_handler)
65+
66+
if log_path:
67+
try:
68+
file_handler = logging.FileHandler(log_path)
69+
except OSError as exc:
70+
raise ValueError(f"{ENV_LOG_PATH} is not writable: {log_path}") from exc
71+
file_handler.setFormatter(formatter)
72+
handlers.append(file_handler)
73+
74+
root_logger = logging.getLogger()
75+
root_logger.setLevel(logging.INFO)
76+
root_logger.handlers = handlers
77+
78+
# Ensure uvicorn loggers use the same handlers/format.
79+
for logger_name in ("uvicorn.error", "uvicorn.access"):
80+
logger = logging.getLogger(logger_name)
81+
logger.handlers = handlers
82+
logger.setLevel(logging.INFO)
83+
logger.propagate = False
84+
85+
return handlers
86+
87+
4088
def main() -> None:
4189
"""
4290
Start the FastAPI sidecar using environment configuration.
@@ -46,7 +94,12 @@ def main() -> None:
4694
Optional:
4795
API_HOST: host to bind, defaults to 127.0.0.1.
4896
"""
49-
logging.basicConfig(level=logging.INFO, format="%(message)s")
97+
log_path = os.getenv(ENV_LOG_PATH)
98+
try:
99+
_configure_logging(log_path)
100+
except ValueError as exc:
101+
logging.error(str(exc))
102+
sys.exit(2)
50103

51104
port_raw = os.getenv(ENV_PORT)
52105
if not port_raw:

tests/test_sidecar_config.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import pytest
22

33
from harmonization_framework.api import sidecar
4-
from harmonization_framework.api.sidecar import _parse_port, _resolve_host
4+
from harmonization_framework.api.sidecar import _parse_port, _resolve_host, _configure_logging
55

66

77
def test_parse_port_accepts_valid_range():
@@ -77,3 +77,10 @@ def fake_run(app, host, port, log_level):
7777
sidecar.main()
7878

7979
assert calls == {"host": "127.0.0.1", "port": 54321, "log_level": "info"}
80+
81+
82+
def test_configure_logging_with_file(tmp_path):
83+
log_path = tmp_path / "sidecar.log"
84+
handlers = _configure_logging(str(log_path))
85+
assert len(handlers) == 2
86+
assert log_path.exists()

0 commit comments

Comments
 (0)