feat(alarm): trex-core 알람 emitter + trex.alerts exchange (dcy-033 BAT-049, Implemented)
AlarmEmitter(application, best-effort 비차단)+trex.alerts topic exchange 가산·격리+8 CRITICAL code 배선. 608 passed, opus QC PASS, 팀장 e2e(텔레그램 HTTP200) 통과. REQ-098~101 Implemented. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
"""Alarm emitter — best-effort CRITICAL alarm publisher (dcy-033 / REQ-100).
|
||||
|
||||
Publishes REQ-098 alarm envelopes to the `trex.alerts` exchange via the
|
||||
MessagingTransport port. No pika import here — dependency inversion
|
||||
preserved (NFR-1). Domain layer is never touched.
|
||||
|
||||
Caller contract:
|
||||
- Inject into execution / reconciliation / killswitch / ledger_outbox / etc.
|
||||
- Call publish_alarm(code, title, fields=...) at each alarm point.
|
||||
- publish_alarm() NEVER re-raises. Alarm failure is ERROR-logged and swallowed
|
||||
so the core business path (slot transition / order execution / ledger) is
|
||||
never blocked by alarm delivery.
|
||||
|
||||
transport=None is explicitly supported for CLI entry points (e.g. reset.py)
|
||||
where no broker connection exists. In that case publish_alarm() logs and skips.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from trex_core.application.messaging import MessagingTransport, TransportUnavailableError
|
||||
from trex_core.adapters.messaging.topology import alarm_routing_key
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_KST = timezone(timedelta(hours=9))
|
||||
|
||||
# Wire constants (REQ-098)
|
||||
_SOURCE = "trex-core"
|
||||
_SCHEMA_VERSION = "1"
|
||||
|
||||
|
||||
class AlarmEmitter:
|
||||
"""Application-layer alarm publisher (dcy-033 / REQ-100).
|
||||
|
||||
Wraps MessagingTransport.publish_alarm_event() with:
|
||||
- envelope construction (source/severity/occurred_at/schema_version fixed)
|
||||
- exception swallowing + ERROR log on failure
|
||||
- None-transport guard for non-daemon entry points
|
||||
"""
|
||||
|
||||
def __init__(self, transport: Optional[MessagingTransport]) -> None:
|
||||
self._transport = transport
|
||||
|
||||
def publish_alarm(
|
||||
self,
|
||||
code: str,
|
||||
title: str,
|
||||
*,
|
||||
fields: Optional[dict] = None,
|
||||
severity: str = "critical",
|
||||
) -> None:
|
||||
"""Publish a single alarm envelope. Best-effort: never re-raises.
|
||||
|
||||
Args:
|
||||
code: Alarm identifier code (see ALARM-INVENTORY.md §A).
|
||||
title: Human-readable 1-line summary. No credentials or raw
|
||||
exception bodies — callers must sanitize.
|
||||
fields: Optional structured context dict (opaque, default {}).
|
||||
severity: 'critical' | 'warn' | 'info'. Default 'critical'.
|
||||
"""
|
||||
if self._transport is None:
|
||||
_log.info(
|
||||
"alarm skipped (no transport): code=%s title=%s",
|
||||
code, title,
|
||||
)
|
||||
return
|
||||
|
||||
routing_key = alarm_routing_key(severity, _SOURCE)
|
||||
occurred_at = datetime.now(tz=_KST).isoformat()
|
||||
payload = {
|
||||
"source": _SOURCE,
|
||||
"severity": severity,
|
||||
"code": code,
|
||||
"title": title,
|
||||
"fields": fields or {},
|
||||
"occurred_at": occurred_at,
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
}
|
||||
try:
|
||||
self._transport.publish_alarm_event(routing_key, payload)
|
||||
except TransportUnavailableError as exc:
|
||||
_log.error(
|
||||
"alarm publish skipped (transport unavailable): code=%s: %s",
|
||||
code, exc,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — alarm must never crash the caller
|
||||
_log.error(
|
||||
"alarm publish failed: code=%s title=%s",
|
||||
code, title, exc_info=True,
|
||||
)
|
||||
Reference in New Issue
Block a user