Files
awoooi/apps/api/src/services/signal_producer.py
OG T 588b0d745b
Some checks failed
CD Pipeline / build-and-deploy (push) Failing after 1m44s
fix(aiops): 修復 sensors=0/0 根因 — MCPToolRegistry 從未在 startup 初始化
三個問題同時修復:

1. main.py: 補上 init_mcp_tool_registry() 呼叫
   - ADR-081 Phase 1 建立了 MCPToolRegistry 但從未在 lifespan startup 被呼叫
   - 導致 PreDecisionInvestigator sensors=0/0,evidence_summary 永遠空白
   - 空白 evidence → Diagnostician 永遠 ABSTAIN

2. signal_producer.py: str(dict) → json.dumps()
   - labels/annotations 用 Python str() 序列化,寫入 Redis 後無法反序列化

3. brain/incident_engine.py: 新增 _parse_dict_field() helper
   - 從 Redis 讀回的 labels/annotations 可能是 JSON 字串
   - isinstance(..., dict) 防禦不足,需先 json.loads()

2026-04-16 ogt + Claude Sonnet 4.6(亞太): 飛輪感官修復

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 15:35:19 +08:00

122 lines
3.3 KiB
Python

"""
Signal Producer Service - Phase 17 P0 Router 層違規修復
======================================================
封裝 Redis Stream 的 Signal 生產邏輯,消除 Router 層直接存取 Redis。
功能:
- XADD 寫入 Redis Stream
- Trace Context 傳遞
符合 leWOOOgo 積木化規範:
- Router -> Service -> Redis
"""
import json
from dataclasses import dataclass
from typing import Any
import structlog
from src.core.redis_client import get_redis
from src.core.telemetry import get_trace_context
from src.utils.timezone import now_taipei
logger = structlog.get_logger(__name__)
# Redis Stream 配置
SIGNAL_STREAM_KEY = "awoooi:signals"
SIGNAL_STREAM_MAXLEN = 10000
@dataclass
class SignalData:
"""Signal 資料結構"""
source: str
alert_name: str
severity: str
namespace: str
target: str
message: str
labels: dict[str, Any] | None = None
annotations: dict[str, Any] | None = None
class SignalProducerService:
"""
Signal 生產者服務
封裝 Redis Stream 的 XADD 操作
"""
async def produce(self, signal: SignalData) -> str:
"""
將 Signal 寫入 Redis Stream
Phase 17: 從 Router 層遷移至 Service 層
使用 Redis Streams (XADD) 實現非同步事件總線:
- MAXLEN ~10000: 限制 Stream 長度,自動裁剪舊訊息
- *: 自動生成 Message ID
Args:
signal: Signal 資料
Returns:
str: Redis Stream Message ID
"""
redis_client = get_redis()
# 組裝 Signal 字典 (所有值必須是字串)
signal_dict = {
"source": signal.source,
"alert_name": signal.alert_name,
"severity": signal.severity,
"namespace": signal.namespace,
"target": signal.target,
"message": signal.message,
"labels": json.dumps(signal.labels or {}),
"annotations": json.dumps(signal.annotations or {}),
"received_at": now_taipei().isoformat(),
}
# Phase 15.2: 注入 Trace Context
trace_ctx = get_trace_context()
if trace_ctx:
signal_dict["_trace_id"] = trace_ctx.get("trace_id", "")
signal_dict["_span_id"] = trace_ctx.get("span_id", "")
# XADD 寫入 Stream
message_id = await redis_client.xadd(
SIGNAL_STREAM_KEY,
signal_dict,
maxlen=SIGNAL_STREAM_MAXLEN,
approximate=True, # ~MAXLEN 近似裁剪,效能更好
)
logger.info(
"signal_produced",
message_id=message_id,
source=signal.source,
alert_name=signal.alert_name,
severity=signal.severity,
trace_id=trace_ctx.get("trace_id") if trace_ctx else None,
)
return message_id
# =============================================================================
# Singleton
# =============================================================================
_signal_producer: SignalProducerService | None = None
def get_signal_producer() -> SignalProducerService:
"""取得 SignalProducerService 實例 (Singleton)"""
global _signal_producer
if _signal_producer is None:
_signal_producer = SignalProducerService()
return _signal_producer