feat: add structured JSON logging with configurable format
This commit is contained in:
parent
edeb336cb8
commit
3f0d021b02
@ -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
55
agent/json_logging.py
Normal 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)
|
||||
80
tests/test_json_logging.py
Normal file
80
tests/test_json_logging.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user