"""구조화 로깅 기반 — ISO8601(KST) 타임스탬프 + 레벨 + Loki JSON + correlation id. (BAT-045 / REQ-084, dcy-029 FR-1·FR-7) 기존 `logging.basicConfig(level=INFO)`는 format 미지정이라 타임스탬프·레벨 prefix가 없었다(타임라인 재구성 불가). 이 모듈은 단일 진입점 `configure_logging()`으로: - 모든 로그 라인에 ISO8601(KST, Asia/Seoul, 환경 TZ 비의존) 타임스탬프 + 레벨 부여 - `TREX_LOG_FORMAT` env 로 `text`(기본, 사람용) / `json`(Loki 적재용) 선택 - correlation id(intent→order→fill join key = slot_id) 를 contextvar 로 보관하고 로깅 필터가 모든 레코드에 주입 — 한 거래의 로그를 단일 키로 묶는다 호출부(`_log.info(...)`)는 메시지를 바꾸지 않아도 포맷·correlation id 가 자동 적용된다. """ from __future__ import annotations import contextlib import json import logging import os from contextvars import ContextVar from datetime import datetime, timezone, timedelta from typing import Iterator, Optional _KST = timezone(timedelta(hours=9)) # 현재 처리의 correlation id (slot_id 등). 미설정이면 빈 문자열. _correlation_id: ContextVar[str] = ContextVar("trex_correlation_id", default="") # JSON 출력에서 제외할 표준 LogRecord 속성. _RESERVED = { "name", "msg", "args", "levelname", "levelno", "pathname", "filename", "module", "exc_info", "exc_text", "stack_info", "lineno", "funcName", "created", "msecs", "relativeCreated", "thread", "threadName", "processName", "process", "correlation_id", "taskName", } def set_correlation_id(value: Optional[str]) -> None: """현재 컨텍스트의 correlation id 를 설정한다(빈 값 허용).""" _correlation_id.set(value or "") def get_correlation_id() -> str: return _correlation_id.get() @contextlib.contextmanager def correlation_id(value: Optional[str]) -> Iterator[None]: """`with correlation_id(slot_id):` 블록 동안 correlation id 를 설정/복원한다.""" token = _correlation_id.set(value or "") try: yield finally: _correlation_id.reset(token) class _CorrelationFilter(logging.Filter): """모든 레코드에 `correlation_id` 속성을 주입한다(미설정 시 빈 문자열).""" def filter(self, record: logging.LogRecord) -> bool: if not hasattr(record, "correlation_id"): record.correlation_id = _correlation_id.get() return True def _kst_ts(record: logging.LogRecord) -> str: return datetime.fromtimestamp(record.created, tz=_KST).isoformat(timespec="milliseconds") class TextFormatter(logging.Formatter): """사람용 한 줄: ` [cid=] `.""" def format(self, record: logging.LogRecord) -> str: cid = getattr(record, "correlation_id", "") or "" cid_part = f" [cid={cid}]" if cid else "" base = ( f"{_kst_ts(record)} {record.levelname} {record.name}{cid_part} " f"{record.getMessage()}" ) if record.exc_info: base += "\n" + self.formatException(record.exc_info) return base class JsonFormatter(logging.Formatter): """Loki 적재용 구조화 한 줄 JSON.""" def format(self, record: logging.LogRecord) -> str: payload = { "ts": _kst_ts(record), "level": record.levelname, "logger": record.name, "msg": record.getMessage(), "correlation_id": getattr(record, "correlation_id", "") or "", } # 호출부가 extra= 로 넘긴 구조화 필드를 그대로 포함. for k, v in record.__dict__.items(): if k not in _RESERVED and not k.startswith("_"): payload.setdefault(k, v) if record.exc_info: payload["exc"] = self.formatException(record.exc_info) return json.dumps(payload, ensure_ascii=False, default=str) def _make_formatter() -> logging.Formatter: fmt = os.getenv("TREX_LOG_FORMAT", "text").strip().lower() return JsonFormatter() if fmt == "json" else TextFormatter() def configure_logging(level: int = logging.INFO) -> None: """루트 로거를 단일 핸들러(포맷터 + correlation 필터)로 구성한다. `logging.basicConfig` 를 대체한다. 재호출 시 기존 핸들러를 정리해 중복 출력을 막는다(데몬/reset 양쪽에서 안전). """ root = logging.getLogger() for h in list(root.handlers): root.removeHandler(h) handler = logging.StreamHandler() handler.setFormatter(_make_formatter()) handler.addFilter(_CorrelationFilter()) root.addHandler(handler) root.setLevel(level)