feat: add structured JSON logging with configurable format

This commit is contained in:
머니페니 2026-03-20 18:41:00 +09:00
parent edeb336cb8
commit 3f0d021b02
3 changed files with 138 additions and 0 deletions

View File

@ -62,4 +62,7 @@ class Settings(BaseSettings):
DAILY_COST_LIMIT_USD: float = 10.0
PER_TASK_COST_LIMIT_USD: float = 3.0
# Logging
LOG_FORMAT: str = "json"
model_config = {"env_file": ".env", "extra": "ignore"}

55
agent/json_logging.py Normal file
View File

@ -0,0 +1,55 @@
"""구조화 JSON 로깅.
LOG_FORMAT 환경변수로 json | text 선택 가능.
"""
from __future__ import annotations
import json
import logging
import traceback
from datetime import datetime, timezone
_BUILTIN_ATTRS = {
"args", "created", "exc_info", "exc_text", "filename", "funcName",
"levelname", "levelno", "lineno", "module", "msecs", "message", "msg",
"name", "pathname", "process", "processName", "relativeCreated",
"stack_info", "thread", "threadName", "taskName",
}
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
log_data = {
"timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
for key, value in record.__dict__.items():
if key not in _BUILTIN_ATTRS and not key.startswith("_"):
try:
json.dumps(value)
log_data[key] = value
except (TypeError, ValueError):
log_data[key] = str(value)
if record.exc_info and record.exc_info[0] is not None:
log_data["exception"] = "".join(traceback.format_exception(*record.exc_info))
return json.dumps(log_data, ensure_ascii=False)
def setup_logging(
log_format: str = "json",
level: int = logging.INFO,
logger: logging.Logger | None = None,
) -> None:
target = logger or logging.getLogger()
if log_format == "json":
formatter = JsonFormatter()
else:
formatter = logging.Formatter(
"%(asctime)s %(levelname)s %(name)s: %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
for handler in target.handlers:
handler.setFormatter(formatter)
target.setLevel(level)

View File

@ -0,0 +1,80 @@
import json
import logging
import io
from agent.json_logging import JsonFormatter, setup_logging
def test_json_formatter_basic():
formatter = JsonFormatter()
record = logging.LogRecord(
name="test", level=logging.INFO, pathname="test.py",
lineno=1, msg="테스트 메시지", args=(), exc_info=None,
)
output = formatter.format(record)
parsed = json.loads(output)
assert parsed["message"] == "테스트 메시지"
assert parsed["level"] == "INFO"
assert "timestamp" in parsed
def test_json_formatter_with_extra():
formatter = JsonFormatter()
record = logging.LogRecord(
name="test", level=logging.INFO, pathname="test.py",
lineno=1, msg="작업 시작", args=(), exc_info=None,
)
record.thread_id = "uuid-123"
record.issue = 42
output = formatter.format(record)
parsed = json.loads(output)
assert parsed["thread_id"] == "uuid-123"
assert parsed["issue"] == 42
def test_json_formatter_with_exception():
formatter = JsonFormatter()
try:
raise ValueError("test error")
except ValueError:
import sys
record = logging.LogRecord(
name="test", level=logging.ERROR, pathname="test.py",
lineno=1, msg="에러 발생", args=(), exc_info=sys.exc_info(),
)
output = formatter.format(record)
parsed = json.loads(output)
assert "exception" in parsed
assert "ValueError" in parsed["exception"]
def test_setup_logging_json():
test_logger = logging.getLogger("test_json_setup")
test_logger.handlers.clear()
test_logger.setLevel(logging.DEBUG)
stream = io.StringIO()
handler = logging.StreamHandler(stream)
test_logger.addHandler(handler)
setup_logging(log_format="json", logger=test_logger)
test_logger.info("hello")
output = stream.getvalue().strip()
parsed = json.loads(output)
assert parsed["message"] == "hello"
def test_setup_logging_text():
test_logger = logging.getLogger("test_text_setup")
test_logger.handlers.clear()
test_logger.setLevel(logging.DEBUG)
stream = io.StringIO()
handler = logging.StreamHandler(stream)
test_logger.addHandler(handler)
setup_logging(log_format="text", logger=test_logger)
test_logger.info("hello")
output = stream.getvalue().strip()
assert "hello" in output
try:
json.loads(output)
assert False, "Should not be valid JSON"
except json.JSONDecodeError:
pass