feat(execution,obs): 호가단위 스냅(dcy-030/#28) + 운영 관측성(dcy-029/#29) + REL-007 환류
dcy-030 (issue #28, P0 체결 복구): - REQ-083 KRX 호가단위(tick) 순수 유틸 (domain/pricing.py) - REQ-070 CHG: 슬리피지 실효가 호가단위 스냅 — 상한 준수(매수 floor/매도 ceil) + band<1tick 하한/상한 보장 + 부동소수 epsilon 가드. 147700→148400/54500→54700. - BAT-044 release-candidate dcy-029 (issue #29, 운영 관측성): - REQ-084 구조화 로깅 기반 (runtime/logging_setup.py): ISO8601 KST·레벨·Loki JSON ·correlation id(slot_id) - REQ-085 주문 결과 로깅 (event=order_submit|fill|reject) - REQ-087 거부 ERROR 가시성 (조용한 롤백 제거) - REQ-086 청산 reason+actor 동반 노출 (안전망/전략 구분, 신규 필드 없음) - REQ-088 운영 조회 CLI report (application/operator_report.py, 보유·오늘 체결 ·실현 PnL net·대기, direct-DB) - REQ-089 운영자 런북 docs/operations.md - REQ-090 baseline 정합 (runtime-flows·project-structure·interface-contract) - BAT-045(P0)·BAT-046(P1) release-candidate REL-007: confirmed → feedback-captured (운영 인스턴스 라이브 검증 + #28/#29 환류 정규화). BAT-037~041 released. 검증: 537 passed, 47 skipped, 회귀 0, ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,6 @@ single producer for ledger work items (NFR-3, single record subject).
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Optional
|
||||
@@ -31,7 +30,9 @@ from typing import Callable, Optional
|
||||
from trex_core.application.broker_port import BrokerPort, OrderRequest
|
||||
from trex_core.application.ledger_outbox import LedgerOutbox
|
||||
from trex_core.application.ledger_port import LedgerTrade
|
||||
from trex_core.domain.pricing import ceil_to_tick, floor_to_tick
|
||||
from trex_core.domain.slot import SlotService, SlotState, SlotStateMismatchError
|
||||
from trex_core.runtime.logging_setup import set_correlation_id
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
@@ -137,24 +138,30 @@ class ExecutionService:
|
||||
# limit_price is cleared on entering HOLDING, so a market sell
|
||||
# carries None -> order_type "01".
|
||||
# dcy-022 / REQ-070: slippage_pct adjusts the effective limit price
|
||||
# when entry_price is set. buy: ceil(limit*(1+pct/100)); sell: floor.
|
||||
# No slippage when limit is None (market order — no reference price).
|
||||
# when entry_price is set. No slippage when limit is None (market
|
||||
# order — no reference price).
|
||||
# dcy-030 / REQ-070 CHG-20260616-01: snap the slippage limit to a
|
||||
# valid KRX tick (REQ-083). Direction = cap-respecting: buy floors
|
||||
# to tick (never exceeds the slippage ceiling), sell ceils to tick
|
||||
# (never breaches the floor). A band<1-tick guard keeps the order
|
||||
# fillable at the entry's own valid tick (no under-fill).
|
||||
limit = int(slot.limit_price) if slot.limit_price else None
|
||||
if limit is not None and slot.slippage_pct:
|
||||
factor = (
|
||||
1.0 + slot.slippage_pct / 100.0
|
||||
if side == "buy"
|
||||
else 1.0 - slot.slippage_pct / 100.0
|
||||
)
|
||||
limit = (
|
||||
int(math.ceil(limit * factor))
|
||||
if side == "buy"
|
||||
else int(math.floor(limit * factor))
|
||||
)
|
||||
base = float(slot.limit_price)
|
||||
if side == "buy":
|
||||
raw = base * (1.0 + slot.slippage_pct / 100.0)
|
||||
limit = max(floor_to_tick(raw), ceil_to_tick(base))
|
||||
else:
|
||||
raw = base * (1.0 - slot.slippage_pct / 100.0)
|
||||
limit = min(ceil_to_tick(raw), floor_to_tick(base))
|
||||
_log.debug(
|
||||
"slippage applied: slot=%s side=%s base_limit=%s pct=%.4f effective=%s",
|
||||
slot.slot_id, side, slot.limit_price, slot.slippage_pct, limit,
|
||||
"slippage applied: slot=%s side=%s base_limit=%s pct=%.4f "
|
||||
"raw=%.2f effective=%s (tick-snapped)",
|
||||
slot.slot_id, side, slot.limit_price, slot.slippage_pct, raw, limit,
|
||||
)
|
||||
# REQ-084/085: correlation id (intent→order→fill join key = slot_id)
|
||||
# so submit/fill/reject logs for one slot share a key.
|
||||
set_correlation_id(str(slot.slot_id))
|
||||
ack = self._broker.submit_order(
|
||||
OrderRequest(
|
||||
slot_id=str(slot.slot_id),
|
||||
@@ -168,7 +175,21 @@ class ExecutionService:
|
||||
if ack.accepted and ack.broker_order_id:
|
||||
self._repo.mark_dispatched(slot.slot_id, ack.broker_order_id)
|
||||
r.dispatched += 1
|
||||
# REQ-085: order-result logging — tailable submit line.
|
||||
_log.info(
|
||||
"order submitted event=order_submit side=%s symbol=%s slot_id=%s "
|
||||
"qty=%s limit_price=%s order_type=%s",
|
||||
side, slot.symbol, slot.slot_id, qty, limit,
|
||||
"00" if limit is not None else "01",
|
||||
)
|
||||
else:
|
||||
# REQ-085/087: reject is visible (event key) and ERROR level
|
||||
# (no more silent rollback — issue #28 surfaced too late).
|
||||
_log.error(
|
||||
"order rejected event=order_reject side=%s symbol=%s slot_id=%s "
|
||||
"qty=%s reason=%s",
|
||||
side, slot.symbol, slot.slot_id, qty, ack.reason or "order_rejected",
|
||||
)
|
||||
self._rollback(slot, reason=ack.reason or "order_rejected")
|
||||
r.rolled_back += 1
|
||||
|
||||
@@ -176,6 +197,7 @@ class ExecutionService:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
for slot in self._repo.list_dispatched_pending(self._instance_key):
|
||||
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
|
||||
set_correlation_id(str(slot.slot_id)) # REQ-084: same key as submit
|
||||
fs = self._broker.poll_fill(slot.broker_order_id, side=side, symbol=slot.symbol or "")
|
||||
if fs.is_filled:
|
||||
self._on_filled(slot, fs, side, r)
|
||||
@@ -186,6 +208,12 @@ class ExecutionService:
|
||||
r.filled += 1
|
||||
elif fs.is_terminal_reject:
|
||||
# rejected or full external cancel (dcy-013 FR-1) -> rollback.
|
||||
# REQ-085/087: surface the reject (event key + ERROR level).
|
||||
_log.error(
|
||||
"order rejected event=order_reject side=%s symbol=%s slot_id=%s "
|
||||
"reason=order_%s",
|
||||
side, slot.symbol, slot.slot_id, fs.state,
|
||||
)
|
||||
self._rollback(slot, reason=f"order_{fs.state}")
|
||||
r.rolled_back += 1
|
||||
else:
|
||||
@@ -240,6 +268,14 @@ class ExecutionService:
|
||||
event_type = "slot_filled_sell"
|
||||
except SlotStateMismatchError:
|
||||
return # slot moved concurrently; next tick reconciles
|
||||
# REQ-085: order-result logging — tailable fill line (price/qty/fee/tax).
|
||||
_log.info(
|
||||
"order filled event=order_fill side=%s symbol=%s slot_id=%s "
|
||||
"fill_price=%s filled_qty=%s fee=%s tax=%s",
|
||||
side, slot.symbol, slot.slot_id,
|
||||
fs.avg_price, fs.filled_qty,
|
||||
getattr(fs, "fee", None), getattr(fs, "tax", None),
|
||||
)
|
||||
# REQ-018: enqueue ledger work item AFTER the slot transition is durable.
|
||||
# Slot transition is the critical path; ledger write is async (REQ-019).
|
||||
self._record_ledger(slot, fs, side, broker_order_id, r)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""운영 조회 리포트 — 보유·오늘 체결·실현 PnL·대기 주문 (REQ-088, dcy-029 FR-5).
|
||||
|
||||
CLI(`trex-core report`)가 DB에서 읽은 행을 이 순수 함수들에 넘겨 집계한다(테스트
|
||||
가능). 실현 PnL은 `ledger_outbox`를 단일 권위 출처로 하는 **당일 net 매매 현금흐름**:
|
||||
|
||||
realized_pnl = Σ_sell (qty*price - fee - tax) - Σ_buy (qty*price + fee)
|
||||
|
||||
ledger_outbox에는 매도별 취득원가(cost basis)가 없으므로, 운영자가 수기로 계산하던
|
||||
당일 net(매도 대금 − 매수 대금 − 수수료 − 세금)과 동일한 정의를 사용한다(dcy-029
|
||||
NFR-3, BAT-038 fee/tax 정책 반영). 일중 진입·청산 전략에서는 net ≈ 실현손익.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
_KST = timezone(timedelta(hours=9))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Holding:
|
||||
symbol: str
|
||||
portfolio_id: str
|
||||
qty: float
|
||||
avg_price: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PendingOrder:
|
||||
symbol: str
|
||||
portfolio_id: str
|
||||
status: str # pending_buy | pending_sell
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TradeRow:
|
||||
asset_id: str
|
||||
side: str # buy | sell
|
||||
qty: float
|
||||
price: float
|
||||
fee: float = 0.0
|
||||
tax: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class OperatorReport:
|
||||
holdings: list[Holding] = field(default_factory=list)
|
||||
pending: list[PendingOrder] = field(default_factory=list)
|
||||
fills_count: int = 0
|
||||
realized_pnl: float = 0.0
|
||||
realized_pnl_by_asset: dict[str, float] = field(default_factory=dict)
|
||||
|
||||
|
||||
def kst_day_bounds_utc(now: datetime | None = None) -> tuple[datetime, datetime]:
|
||||
"""오늘(KST trading_date) 경계를 UTC `[start, end)` 로 반환한다."""
|
||||
now = now or datetime.now(tz=timezone.utc)
|
||||
now_kst = now.astimezone(_KST)
|
||||
start_kst = now_kst.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end_kst = start_kst + timedelta(days=1)
|
||||
return start_kst.astimezone(timezone.utc), end_kst.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def realized_pnl(trades: list[TradeRow]) -> tuple[float, dict[str, float]]:
|
||||
"""당일 net 매매 현금흐름(실현 PnL)과 자산별 분해를 계산한다."""
|
||||
total = 0.0
|
||||
by_asset: dict[str, float] = {}
|
||||
for t in trades:
|
||||
gross = float(t.qty) * float(t.price)
|
||||
if t.side == "sell":
|
||||
net = gross - float(t.fee) - float(t.tax)
|
||||
else: # buy
|
||||
net = -(gross + float(t.fee))
|
||||
total += net
|
||||
by_asset[t.asset_id] = by_asset.get(t.asset_id, 0.0) + net
|
||||
return total, by_asset
|
||||
|
||||
|
||||
def build_report(
|
||||
holdings: list[Holding],
|
||||
pending: list[PendingOrder],
|
||||
trades: list[TradeRow],
|
||||
) -> OperatorReport:
|
||||
total, by_asset = realized_pnl(trades)
|
||||
return OperatorReport(
|
||||
holdings=list(holdings),
|
||||
pending=list(pending),
|
||||
fills_count=len(trades),
|
||||
realized_pnl=total,
|
||||
realized_pnl_by_asset=by_asset,
|
||||
)
|
||||
|
||||
|
||||
def render_text(rep: OperatorReport) -> str:
|
||||
"""사람용 표 요약."""
|
||||
lines: list[str] = []
|
||||
lines.append(f"=== 현재 보유 ({len(rep.holdings)}) ===")
|
||||
for h in rep.holdings:
|
||||
lines.append(f" {h.symbol} pf={h.portfolio_id} qty={h.qty} avg={h.avg_price}")
|
||||
lines.append(f"=== 대기 주문 ({len(rep.pending)}) ===")
|
||||
for p in rep.pending:
|
||||
lines.append(f" {p.symbol} pf={p.portfolio_id} status={p.status}")
|
||||
lines.append(f"=== 오늘 체결 {rep.fills_count}건 / 실현 PnL {rep.realized_pnl:,.0f} ===")
|
||||
for asset, pnl in sorted(rep.realized_pnl_by_asset.items()):
|
||||
lines.append(f" {asset}: {pnl:,.0f}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_json(rep: OperatorReport) -> dict:
|
||||
"""머신 파싱용(`--json`)."""
|
||||
return {
|
||||
"holdings": [
|
||||
{"symbol": h.symbol, "portfolio_id": h.portfolio_id,
|
||||
"qty": h.qty, "avg_price": h.avg_price}
|
||||
for h in rep.holdings
|
||||
],
|
||||
"pending": [
|
||||
{"symbol": p.symbol, "portfolio_id": p.portfolio_id, "status": p.status}
|
||||
for p in rep.pending
|
||||
],
|
||||
"fills_count": rep.fills_count,
|
||||
"realized_pnl": rep.realized_pnl,
|
||||
"realized_pnl_by_asset": rep.realized_pnl_by_asset,
|
||||
}
|
||||
@@ -85,10 +85,14 @@ class SafetySellService:
|
||||
"""Visibility for an actual safety-sell fire (dcy-017 FR-1, OQ-2)."""
|
||||
self._count(f"fire:{reason}")
|
||||
level = logging.WARNING if reason in self._WARN_REASONS else logging.INFO
|
||||
# REQ-086 (dcy-029): expose reason AND actor together so an operator can
|
||||
# tell a safety-net autonomous liquidation (actor=trex-safety-sell) apart
|
||||
# from a strategy's own stop-loss. No new reason values / fields.
|
||||
_log.log(
|
||||
level,
|
||||
"safety-sell fired: phase=%s slot=%s portfolio_id=%s reason=%s instance_key=%s",
|
||||
reason, slot.slot_id, slot.portfolio_id, reason, self._instance_key,
|
||||
"safety-sell fired: phase=%s slot=%s portfolio_id=%s reason=%s "
|
||||
"actor=%s instance_key=%s",
|
||||
reason, slot.slot_id, slot.portfolio_id, reason, _ACTOR, self._instance_key,
|
||||
)
|
||||
|
||||
def _update_ownership(self, now: datetime) -> None:
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""KRX 호가단위(tick-size) 산출 — 순수 도메인 함수 (REQ-083, dcy-030).
|
||||
|
||||
slippage 보호 지정가(REQ-070)가 KIS에 제출되기 전에 유효한 KRX 호가단위
|
||||
배수로 스냅되도록 가격→tick 산출과 배수 내림/올림 헬퍼를 제공한다.
|
||||
|
||||
주식 전용이다. ETF/ETN 등 비주식 호가체계는 범위 밖이며(dcy-030 OQ-1),
|
||||
2,000원 미만 비주식은 미지원(known limitation). 종목 유형 분기는 두지 않는다.
|
||||
|
||||
호가단위 표(KRX 주식, 2023-01-25~). 구간은 하한 포함/상한 미만 `[lo, hi)`:
|
||||
|
||||
가격(원) tick(원)
|
||||
< 2,000 1
|
||||
2,000 ~ 5,000 5
|
||||
5,000 ~ 20,000 10
|
||||
20,000 ~ 50,000 50
|
||||
50,000 ~ 200,000 100
|
||||
200,000 ~ 500,000 500
|
||||
>= 500,000 1,000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
# (lower_bound_inclusive, tick). 내림차순으로 평가한다. 단일 변경 지점(dcy-030 R-3).
|
||||
_KRX_STOCK_TICKS: tuple[tuple[int, int], ...] = (
|
||||
(500_000, 1_000),
|
||||
(200_000, 500),
|
||||
(50_000, 100),
|
||||
(20_000, 50),
|
||||
(5_000, 10),
|
||||
(2_000, 5),
|
||||
(0, 1),
|
||||
)
|
||||
|
||||
|
||||
def krx_stock_tick(price: float) -> int:
|
||||
"""`price`(원)가 속한 KRX 주식 호가단위(원)를 반환한다.
|
||||
|
||||
구간 경계는 하한 포함(예: 정확히 50,000원 → 100원 tick).
|
||||
"""
|
||||
p = float(price)
|
||||
for lower, tick in _KRX_STOCK_TICKS:
|
||||
if p >= lower:
|
||||
return tick
|
||||
return 1 # p < 0 방어 (도달하지 않음)
|
||||
|
||||
|
||||
# 부동소수 잡음 흡수용 epsilon. slippage raw = base*(1+pct/100)는 수학적으로
|
||||
# 정수여도 float로는 1004.9999999999999처럼 나올 수 있다(잡음 ~ q*1e-16).
|
||||
# q(=price/tick)는 유계라 1e-9면 잡음을 안전하게 흡수하면서 실제 값은 안 넘긴다.
|
||||
_EPS = 1e-9
|
||||
|
||||
|
||||
def floor_to_tick(price: float) -> int:
|
||||
"""`price`를 해당 가격대 호가단위의 **내림 배수**로 정규화한다 (≤ price)."""
|
||||
tick = krx_stock_tick(price)
|
||||
return int(math.floor(float(price) / tick + _EPS) * tick)
|
||||
|
||||
|
||||
def ceil_to_tick(price: float) -> int:
|
||||
"""`price`를 해당 가격대 호가단위의 **올림 배수**로 정규화한다 (≥ price)."""
|
||||
tick = krx_stock_tick(price)
|
||||
return int(math.ceil(float(price) / tick - _EPS) * tick)
|
||||
@@ -395,9 +395,67 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
_add_intent_guards(p_cancel)
|
||||
_add_common(p_cancel)
|
||||
|
||||
# REQ-088 (dcy-029): operator report — holdings / today fills / realized PnL /
|
||||
# pending. Direct-DB (DB-backed point-in-time aggregate, no new RPC).
|
||||
p_report = sub.add_parser(
|
||||
"report", help="Holdings, today's fills, realized PnL, pending orders (direct-DB)"
|
||||
)
|
||||
_add_common(p_report)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _cmd_report(args, cfg) -> int:
|
||||
"""REQ-088: holdings / today's fills / realized PnL / pending — direct-DB."""
|
||||
import psycopg
|
||||
|
||||
from trex_core.application.operator_report import (
|
||||
Holding,
|
||||
PendingOrder,
|
||||
TradeRow,
|
||||
build_report,
|
||||
kst_day_bounds_utc,
|
||||
render_json,
|
||||
render_text,
|
||||
)
|
||||
|
||||
start_utc, end_utc = kst_day_bounds_utc()
|
||||
holdings: list[Holding] = []
|
||||
pending: list[PendingOrder] = []
|
||||
trades: list[TradeRow] = []
|
||||
with psycopg.connect(cfg.dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT symbol, portfolio_id, qty, avg_price FROM daily_slot "
|
||||
"WHERE status = 'holding' ORDER BY symbol"
|
||||
)
|
||||
for symbol, pid, qty, avg in cur.fetchall():
|
||||
holdings.append(Holding(symbol or "", pid or "",
|
||||
float(qty or 0), float(avg or 0)))
|
||||
cur.execute(
|
||||
"SELECT symbol, portfolio_id, status FROM daily_slot "
|
||||
"WHERE status IN ('pending_buy','pending_sell') ORDER BY symbol"
|
||||
)
|
||||
for symbol, pid, status in cur.fetchall():
|
||||
pending.append(PendingOrder(symbol or "", pid or "", status))
|
||||
cur.execute(
|
||||
"SELECT asset_id, side, qty, price, fee, tax FROM ledger_outbox "
|
||||
"WHERE COALESCE(executed_at, created_at) >= %s "
|
||||
"AND COALESCE(executed_at, created_at) < %s",
|
||||
(start_utc, end_utc),
|
||||
)
|
||||
for asset_id, side, qty, price, fee, tax in cur.fetchall():
|
||||
trades.append(TradeRow(asset_id, side, float(qty), float(price),
|
||||
float(fee or 0), float(tax or 0)))
|
||||
|
||||
rep = build_report(holdings, pending, trades)
|
||||
if getattr(args, "json", False):
|
||||
print(_json.dumps(render_json(rep), ensure_ascii=False))
|
||||
else:
|
||||
print(render_text(rep))
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None, *, rpc=None, confirm=None) -> int:
|
||||
"""CLI entrypoint.
|
||||
|
||||
@@ -417,6 +475,10 @@ def main(argv: list[str] | None = None, *, rpc=None, confirm=None) -> int:
|
||||
cfg = DaemonConfig.from_env()
|
||||
confirm = confirm or _default_confirm
|
||||
|
||||
# REQ-088 (dcy-029): operator report — direct-DB point-in-time aggregate.
|
||||
if args.cmd == "report":
|
||||
return _cmd_report(args, cfg)
|
||||
|
||||
# direct-DB control commands (pause/resume stay direct-DB, OQ-3).
|
||||
if args.cmd in ("pause", "resume", "paused"):
|
||||
store = PostgreSQLPauseStore(cfg.dsn)
|
||||
|
||||
@@ -405,7 +405,8 @@ class Daemon:
|
||||
def main() -> None: # pragma: no cover - process entrypoint
|
||||
import argparse
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
from trex_core.runtime.logging_setup import configure_logging
|
||||
configure_logging(level=logging.INFO) # REQ-084: ISO8601(KST)+level+json+cid
|
||||
parser = argparse.ArgumentParser(prog="trex-core-daemon")
|
||||
parser.add_argument(
|
||||
"--config", required=True,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""구조화 로깅 기반 — 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):
|
||||
"""사람용 한 줄: `<ISO8601 KST> <LEVEL> <logger> [cid=<id>] <msg>`."""
|
||||
|
||||
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)
|
||||
@@ -125,8 +125,9 @@ def main(argv: Optional[list[str]] = None) -> int: # pragma: no cover - CLI she
|
||||
import argparse
|
||||
|
||||
from trex_core.runtime.config import DaemonConfig, resolve_config_toml
|
||||
from trex_core.runtime.logging_setup import configure_logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
configure_logging(level=logging.INFO) # REQ-084
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="trex-core-reset",
|
||||
description="dcy-020/REQ-064: wipe the slot-core tables and reinitialize "
|
||||
|
||||
Reference in New Issue
Block a user