diff --git a/agent/config.py b/agent/config.py index 8be8dcb..a4a12dd 100644 --- a/agent/config.py +++ b/agent/config.py @@ -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"} diff --git a/agent/json_logging.py b/agent/json_logging.py new file mode 100644 index 0000000..69adc76 --- /dev/null +++ b/agent/json_logging.py @@ -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) diff --git a/tests/test_json_logging.py b/tests/test_json_logging.py new file mode 100644 index 0000000..5ddfaa3 --- /dev/null +++ b/tests/test_json_logging.py @@ -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