feat(Phase 3.5 + Phase 4): AI 學習成果持久化到 PostgreSQL — 修正「AI 失憶」架構缺陷
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
ADR-085: AI 學習成果不可存在 Cache 架構鐵律確立: - PostgreSQL = System of Record(AI 的永久記憶) - Redis = Warm Cache(加速讀取,TTL 到期從 PG 復原) 核心變更: 1. models.py: 新增 PlaybookRecord / DynamicBaselineRecord / LogClusterRecord ORM 2. base.py: ALTER TABLE playbooks 補加 trust_score / requires_approval_level 等欄位 3. playbook_repository.py: 完整雙寫實作(PG upsert + Redis cache) 4. dynamic_baseline_service.py: Holt-Winters 訓練結果寫入 PG,Redis 只作 24h warm cache 5. log_anomaly_detector.py: Drain3 cluster template 寫入 PG(UPSERT on cluster_id) 6. main.py: 啟動時執行 backfill_redis_to_pg()(Redis → PG 冪等補救) 修正的問題: - Playbook 7天 Redis TTL 到期 → AI 失去所有修復知識 - trust_score EWMA 隨 Redis TTL 歸零 → AI 重新回到初始信任度 0.3 - Holt-Winters 基線 24h TTL → AI 每天重新學習「正常」的定義 - Drain3 cluster 沒有持久化 → AI 把已知 log pattern 反覆當新 pattern Phase 4 新服務(requirements.txt 已加入 statsmodels + drain3 + numpy) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
489
apps/api/src/services/dynamic_baseline_service.py
Normal file
489
apps/api/src/services/dynamic_baseline_service.py
Normal file
@@ -0,0 +1,489 @@
|
||||
"""
|
||||
AWOOOI AIOps Phase 4 — Dynamic Baseline Service(動態基線服務)
|
||||
=============================================================
|
||||
職責:Holt-Winters 指數平滑,偵測 Prometheus metric 異常偏離
|
||||
|
||||
核心 API:
|
||||
is_anomaly(metric_name, current_value) -> AnomalyResult
|
||||
update_baseline(metric_name, datapoints)
|
||||
|
||||
設計原則:
|
||||
- Shadow Mode(AIOPS_P4_SHADOW_MODE=True):只記錄,不觸發 Alert
|
||||
- 熔斷保護:statsmodels 失敗 → fallback 到滑動平均
|
||||
- 7 天歷史資料最少訓練量(低於此閾值 → skip,不誤判)
|
||||
- 基線持久化到 Redis(key: baseline:{metric_name},TTL 24h)
|
||||
- 訓練在 background worker 執行,not in webhook handler
|
||||
|
||||
ADR-084: Phase 4 動態異常偵測源頭升級
|
||||
2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 4 初始建立
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from src.utils.timezone import now_taipei
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# ── 常數 ────────────────────────────────────────────────────────────────────
|
||||
MIN_DATAPOINTS = 168 # 7 天 × 24h 最少樣本數(才能訓練季節性模型)
|
||||
SIGMA_THRESHOLD = 3.0 # 偏差 ≥ 3σ → 異常
|
||||
REDIS_TTL_SEC = 86400 # 基線 Redis TTL = 24h
|
||||
REDIS_KEY_PREFIX = "baseline:"
|
||||
HISTORY_WINDOW_HOURS = 336 # 保留 14 天歷史
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Data Types
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class MetricDatapoint:
|
||||
"""單一 metric 時序資料點"""
|
||||
timestamp: float # Unix epoch
|
||||
value: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaselineState:
|
||||
"""Holt-Winters 訓練後的基線狀態(Redis 持久化)"""
|
||||
metric_name: str
|
||||
mean: float
|
||||
std: float
|
||||
seasonal_factors: list[float] = field(default_factory=list) # 24h 週期
|
||||
last_trained_at: str = ""
|
||||
datapoint_count: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"metric_name": self.metric_name,
|
||||
"mean": self.mean,
|
||||
"std": self.std,
|
||||
"seasonal_factors": self.seasonal_factors,
|
||||
"last_trained_at": self.last_trained_at,
|
||||
"datapoint_count": self.datapoint_count,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> "BaselineState":
|
||||
return cls(
|
||||
metric_name=d["metric_name"],
|
||||
mean=d["mean"],
|
||||
std=d["std"],
|
||||
seasonal_factors=d.get("seasonal_factors", []),
|
||||
last_trained_at=d.get("last_trained_at", ""),
|
||||
datapoint_count=d.get("datapoint_count", 0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnomalyResult:
|
||||
"""is_anomaly() 回傳結果"""
|
||||
metric_name: str
|
||||
current_value: float
|
||||
is_anomaly: bool
|
||||
deviation_sigma: float # 偏差 σ 數(>3 = 異常)
|
||||
expected_mean: float
|
||||
expected_std: float
|
||||
direction: str = "none" # "up" / "down" / "none"
|
||||
shadow_mode: bool = True # True = 只記錄,不觸發
|
||||
reason: str = ""
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main Service
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class DynamicBaselineService:
|
||||
"""
|
||||
動態基線服務
|
||||
|
||||
兩大功能:
|
||||
1. train_baseline() — 從 Prometheus 抓歷史資料,用 Holt-Winters 訓練
|
||||
2. is_anomaly() — 即時判斷當前值是否偏離基線 ≥ 3σ
|
||||
"""
|
||||
|
||||
async def train_baseline(
|
||||
self,
|
||||
metric_name: str,
|
||||
promql: str,
|
||||
lookback_hours: int = HISTORY_WINDOW_HOURS,
|
||||
) -> BaselineState | None:
|
||||
"""
|
||||
從 Prometheus 抓取歷史資料並訓練基線。
|
||||
|
||||
Args:
|
||||
metric_name: 基線識別名(e.g. "cpu_usage_node_mon")
|
||||
promql: Prometheus query(e.g. "avg(rate(node_cpu_seconds_total[5m]))")
|
||||
lookback_hours: 歷史視窗(預設 14 天)
|
||||
|
||||
Returns:
|
||||
BaselineState(已存 Redis);資料不足 → None
|
||||
"""
|
||||
try:
|
||||
datapoints = await self._fetch_prometheus_history(promql, lookback_hours)
|
||||
if len(datapoints) < MIN_DATAPOINTS:
|
||||
logger.info(
|
||||
"baseline_insufficient_data",
|
||||
metric=metric_name,
|
||||
count=len(datapoints),
|
||||
required=MIN_DATAPOINTS,
|
||||
)
|
||||
return None
|
||||
|
||||
state = self._fit_holt_winters(metric_name, datapoints)
|
||||
await self._save_baseline(state)
|
||||
logger.info(
|
||||
"baseline_trained",
|
||||
metric=metric_name,
|
||||
mean=f"{state.mean:.4f}",
|
||||
std=f"{state.std:.4f}",
|
||||
datapoints=len(datapoints),
|
||||
)
|
||||
return state
|
||||
|
||||
except Exception:
|
||||
logger.exception("baseline_train_failed", metric=metric_name)
|
||||
return None
|
||||
|
||||
async def is_anomaly(
|
||||
self,
|
||||
metric_name: str,
|
||||
current_value: float,
|
||||
hour_of_day: int | None = None,
|
||||
) -> AnomalyResult:
|
||||
"""
|
||||
即時異常判斷。
|
||||
|
||||
Args:
|
||||
metric_name: 基線識別名
|
||||
current_value: 當前觀測值
|
||||
hour_of_day: 當前小時(0-23),用於套用 seasonal factor;None = 不套用
|
||||
|
||||
Returns:
|
||||
AnomalyResult
|
||||
"""
|
||||
from src.core.feature_flags import aiops_flags
|
||||
|
||||
shadow_mode = aiops_flags.AIOPS_P4_SHADOW_MODE
|
||||
|
||||
try:
|
||||
state = await self._load_baseline(metric_name)
|
||||
if state is None:
|
||||
return AnomalyResult(
|
||||
metric_name=metric_name,
|
||||
current_value=current_value,
|
||||
is_anomaly=False,
|
||||
deviation_sigma=0.0,
|
||||
expected_mean=current_value,
|
||||
expected_std=0.0,
|
||||
reason="no_baseline_available",
|
||||
shadow_mode=shadow_mode,
|
||||
)
|
||||
|
||||
# 套用 seasonal factor(如果有 24h 週期資料)
|
||||
expected_mean = state.mean
|
||||
if hour_of_day is not None and len(state.seasonal_factors) == 24:
|
||||
expected_mean *= state.seasonal_factors[hour_of_day]
|
||||
|
||||
expected_std = state.std if state.std > 0 else 1e-9
|
||||
deviation = abs(current_value - expected_mean)
|
||||
sigma = deviation / expected_std
|
||||
|
||||
anomaly = sigma >= SIGMA_THRESHOLD
|
||||
direction = "none"
|
||||
if anomaly:
|
||||
direction = "up" if current_value > expected_mean else "down"
|
||||
|
||||
result = AnomalyResult(
|
||||
metric_name=metric_name,
|
||||
current_value=current_value,
|
||||
is_anomaly=anomaly,
|
||||
deviation_sigma=round(sigma, 2),
|
||||
expected_mean=round(expected_mean, 4),
|
||||
expected_std=round(expected_std, 4),
|
||||
direction=direction,
|
||||
shadow_mode=shadow_mode,
|
||||
reason=f"deviation {sigma:.1f}σ from baseline" if anomaly else "within_normal_range",
|
||||
)
|
||||
|
||||
if anomaly:
|
||||
logger.info(
|
||||
"dynamic_anomaly_detected",
|
||||
metric=metric_name,
|
||||
value=current_value,
|
||||
expected=expected_mean,
|
||||
sigma=sigma,
|
||||
direction=direction,
|
||||
shadow_mode=shadow_mode,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("baseline_anomaly_check_failed", metric=metric_name, error=str(e))
|
||||
return AnomalyResult(
|
||||
metric_name=metric_name,
|
||||
current_value=current_value,
|
||||
is_anomaly=False,
|
||||
deviation_sigma=0.0,
|
||||
expected_mean=0.0,
|
||||
expected_std=0.0,
|
||||
reason=f"check_error:{e}",
|
||||
shadow_mode=shadow_mode,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Private Helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _fetch_prometheus_history(
|
||||
self,
|
||||
promql: str,
|
||||
lookback_hours: int,
|
||||
) -> list[MetricDatapoint]:
|
||||
"""從 Prometheus query_range API 抓取歷史資料(1h 步進)。"""
|
||||
import httpx
|
||||
from src.core.config import settings
|
||||
|
||||
end_ts = now_taipei().timestamp()
|
||||
start_ts = end_ts - lookback_hours * 3600
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.get(
|
||||
f"{settings.PROMETHEUS_URL}/api/v1/query_range",
|
||||
params={
|
||||
"query": promql,
|
||||
"start": start_ts,
|
||||
"end": end_ts,
|
||||
"step": "3600", # 1h 步進
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = data.get("data", {}).get("result", [])
|
||||
if not results:
|
||||
return []
|
||||
|
||||
# 取第一個 time series
|
||||
values = results[0].get("values", [])
|
||||
return [
|
||||
MetricDatapoint(timestamp=float(ts), value=float(v))
|
||||
for ts, v in values
|
||||
if v != "NaN"
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning("prometheus_history_fetch_failed", error=str(e))
|
||||
return []
|
||||
|
||||
def _fit_holt_winters(
|
||||
self,
|
||||
metric_name: str,
|
||||
datapoints: list[MetricDatapoint],
|
||||
) -> BaselineState:
|
||||
"""
|
||||
用 statsmodels Holt-Winters 訓練基線。
|
||||
Fallback:若 statsmodels 不可用 → 滑動統計。
|
||||
"""
|
||||
values = [dp.value for dp in datapoints]
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
from statsmodels.tsa.holtwinters import ExponentialSmoothing
|
||||
|
||||
arr = np.array(values, dtype=float)
|
||||
# 確保無 NaN / Inf
|
||||
arr = arr[np.isfinite(arr)]
|
||||
|
||||
if len(arr) < MIN_DATAPOINTS:
|
||||
return self._fit_simple_stats(metric_name, values)
|
||||
|
||||
# Holt-Winters:加法趨勢 + 加法季節性(24h 週期)
|
||||
seasonal_periods = min(24, len(arr) // 2)
|
||||
model = ExponentialSmoothing(
|
||||
arr,
|
||||
trend="add",
|
||||
seasonal="add" if len(arr) >= seasonal_periods * 2 else None,
|
||||
seasonal_periods=seasonal_periods,
|
||||
initialization_method="estimated",
|
||||
).fit(optimized=True, disp=False)
|
||||
|
||||
fitted = model.fittedvalues
|
||||
residuals = arr - fitted
|
||||
mean_val = float(np.mean(fitted))
|
||||
std_val = float(np.std(residuals))
|
||||
|
||||
# 24h seasonal factors(正規化為相對倍數)
|
||||
seasonal_factors = [1.0] * 24
|
||||
if hasattr(model, "season") and model.season is not None:
|
||||
s = model.season
|
||||
if len(s) >= 24:
|
||||
s_arr = np.array(s[-24:])
|
||||
# 轉為乘法因子(mean-centered)
|
||||
s_mean = abs(np.mean(s_arr)) or 1.0
|
||||
sf = (s_arr / s_mean).tolist()
|
||||
seasonal_factors = [max(0.1, min(10.0, f)) for f in sf]
|
||||
|
||||
return BaselineState(
|
||||
metric_name=metric_name,
|
||||
mean=mean_val,
|
||||
std=max(std_val, mean_val * 0.01), # 最小 std = 1% mean
|
||||
seasonal_factors=seasonal_factors,
|
||||
last_trained_at=now_taipei().isoformat(),
|
||||
datapoint_count=len(arr),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("holt_winters_failed_fallback_to_stats", error=str(e))
|
||||
return self._fit_simple_stats(metric_name, values)
|
||||
|
||||
def _fit_simple_stats(
|
||||
self,
|
||||
metric_name: str,
|
||||
values: list[float],
|
||||
) -> BaselineState:
|
||||
"""Fallback:純滑動平均 + 標準差基線。"""
|
||||
if not values:
|
||||
return BaselineState(metric_name=metric_name, mean=0.0, std=1.0)
|
||||
|
||||
n = len(values)
|
||||
mean_val = sum(values) / n
|
||||
variance = sum((v - mean_val) ** 2 for v in values) / n
|
||||
std_val = math.sqrt(variance)
|
||||
|
||||
return BaselineState(
|
||||
metric_name=metric_name,
|
||||
mean=mean_val,
|
||||
std=max(std_val, mean_val * 0.01),
|
||||
seasonal_factors=[1.0] * 24,
|
||||
last_trained_at=now_taipei().isoformat(),
|
||||
datapoint_count=n,
|
||||
)
|
||||
|
||||
async def _save_baseline(self, state: BaselineState, promql: str = "", lookback_hours: int = HISTORY_WINDOW_HOURS) -> None:
|
||||
"""
|
||||
儲存基線狀態:
|
||||
1. 先寫 PostgreSQL(永久保存,source of truth)
|
||||
2. 再寫 Redis(24h warm cache,加速讀取)
|
||||
|
||||
Phase 4 ADR-084 架構鐵律:訓練好的 Holt-Winters 模型不能只存 Redis。
|
||||
Redis 24h TTL 到期 = AI 每天重新學習「正常」的定義 = 不是在學習。
|
||||
2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 4 改為 PG source of truth
|
||||
"""
|
||||
# 1. 寫入 PostgreSQL(主要持久化)
|
||||
await self._pg_upsert_baseline(state, promql, lookback_hours)
|
||||
|
||||
# 2. 寫入 Redis warm cache(加速讀取,到期後從 PG 復原)
|
||||
try:
|
||||
from src.core.redis_client import get_redis
|
||||
r = get_redis()
|
||||
key = f"{REDIS_KEY_PREFIX}{state.metric_name}"
|
||||
await r.set(key, json.dumps(state.to_dict()), ex=REDIS_TTL_SEC)
|
||||
except Exception as e:
|
||||
logger.warning("baseline_redis_cache_failed", metric=state.metric_name, error=str(e))
|
||||
|
||||
async def _load_baseline(self, metric_name: str) -> BaselineState | None:
|
||||
"""
|
||||
載入基線:Redis-first → miss 時從 PG 載入並回填 Redis。
|
||||
|
||||
Phase 4 ADR-084: Redis 只是 warm cache,PG 才是 source of truth。
|
||||
"""
|
||||
# 1. Redis warm cache hit
|
||||
try:
|
||||
from src.core.redis_client import get_redis
|
||||
r = get_redis()
|
||||
key = f"{REDIS_KEY_PREFIX}{metric_name}"
|
||||
data = await r.get(key)
|
||||
if data is not None:
|
||||
return BaselineState.from_dict(json.loads(data))
|
||||
except Exception as e:
|
||||
logger.warning("baseline_redis_load_failed", metric=metric_name, error=str(e))
|
||||
|
||||
# 2. PG fallback(cache miss)
|
||||
state = await self._pg_load_latest_baseline(metric_name)
|
||||
if state is not None:
|
||||
# 回填 Redis cache
|
||||
try:
|
||||
from src.core.redis_client import get_redis
|
||||
r = get_redis()
|
||||
key = f"{REDIS_KEY_PREFIX}{metric_name}"
|
||||
await r.set(key, json.dumps(state.to_dict()), ex=REDIS_TTL_SEC)
|
||||
except Exception:
|
||||
pass # cache 回填失敗不影響讀取
|
||||
return state
|
||||
|
||||
async def _pg_upsert_baseline(self, state: BaselineState, promql: str, lookback_hours: int) -> None:
|
||||
"""寫入 DynamicBaselineRecord 到 PostgreSQL(INSERT,不更新舊記錄)"""
|
||||
try:
|
||||
from src.db.base import get_session_factory
|
||||
from src.db.models import DynamicBaselineRecord
|
||||
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
record = DynamicBaselineRecord(
|
||||
metric_name=state.metric_name,
|
||||
mean=state.mean,
|
||||
std=state.std,
|
||||
seasonal_factors=state.seasonal_factors,
|
||||
datapoint_count=state.datapoint_count,
|
||||
promql=promql,
|
||||
lookback_hours=lookback_hours,
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
logger.info("baseline_pg_saved", metric=state.metric_name, datapoints=state.datapoint_count)
|
||||
except Exception as e:
|
||||
logger.warning("baseline_pg_save_failed", metric=state.metric_name, error=str(e))
|
||||
|
||||
async def _pg_load_latest_baseline(self, metric_name: str) -> BaselineState | None:
|
||||
"""從 PostgreSQL 載入最新一筆基線記錄"""
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
from src.db.base import get_session_factory
|
||||
from src.db.models import DynamicBaselineRecord
|
||||
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
stmt = (
|
||||
select(DynamicBaselineRecord)
|
||||
.where(DynamicBaselineRecord.metric_name == metric_name)
|
||||
.order_by(DynamicBaselineRecord.trained_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return None
|
||||
return BaselineState(
|
||||
metric_name=record.metric_name,
|
||||
mean=record.mean,
|
||||
std=record.std,
|
||||
seasonal_factors=record.seasonal_factors,
|
||||
last_trained_at=record.trained_at.isoformat(),
|
||||
datapoint_count=record.datapoint_count,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("baseline_pg_load_failed", metric=metric_name, error=str(e))
|
||||
return None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Singleton
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_baseline_service: DynamicBaselineService | None = None
|
||||
|
||||
|
||||
def get_dynamic_baseline_service() -> DynamicBaselineService:
|
||||
global _baseline_service
|
||||
if _baseline_service is None:
|
||||
_baseline_service = DynamicBaselineService()
|
||||
return _baseline_service
|
||||
385
apps/api/src/services/log_anomaly_detector.py
Normal file
385
apps/api/src/services/log_anomaly_detector.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
AWOOOI AIOps Phase 4 — Log Anomaly Detector(日誌異常偵測)
|
||||
==========================================================
|
||||
職責:Drain3 log clustering,即時偵測新 pattern
|
||||
|
||||
核心 API:
|
||||
process_log_line(line) -> LogAnomalyEvent | None
|
||||
get_new_patterns(since_ts) -> list[LogCluster]
|
||||
|
||||
設計原則:
|
||||
- Shadow Mode:新 pattern 只記錄 logger.info,不觸發 Alert
|
||||
- 狀態持久化到 Redis:cluster tree 序列化(JSON)
|
||||
- 熔斷:Drain3 失敗 → 僅記錄,不 raise
|
||||
- 非同步:所有 Redis I/O 非同步;Drain3 計算在同步 helper 中執行
|
||||
|
||||
ADR-084: Phase 4 動態異常偵測源頭升級
|
||||
2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 4 初始建立
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from src.utils.timezone import now_taipei
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# ── 常數 ────────────────────────────────────────────────────────────────────
|
||||
REDIS_KEY_CLUSTERS = "log_anomaly:clusters" # hash: cluster_id → cluster data
|
||||
REDIS_KEY_NEW_PATTERNS = "log_anomaly:new" # list: 新 pattern 事件(最新在前)
|
||||
REDIS_TTL_CLUSTERS = 86400 * 7 # 7 天
|
||||
MAX_NEW_PATTERNS = 200 # 保留最近 200 個新 pattern 事件
|
||||
DRAIN_DEPTH = 4 # Drain3 tree depth
|
||||
DRAIN_SIM_THRESHOLD = 0.4 # 相似度 < 此值 → 新 cluster
|
||||
DRAIN_MAX_CHILDREN = 100 # max children per node
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Data Types
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class LogCluster:
|
||||
"""Drain3 log cluster"""
|
||||
cluster_id: str
|
||||
template: str # 模板(e.g. "ERROR <*> connection failed to <*>")
|
||||
size: int = 1 # 命中次數
|
||||
first_seen_at: str = ""
|
||||
last_seen_at: str = ""
|
||||
is_new: bool = False # 首次出現 → True
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"cluster_id": self.cluster_id,
|
||||
"template": self.template,
|
||||
"size": self.size,
|
||||
"first_seen_at": self.first_seen_at,
|
||||
"last_seen_at": self.last_seen_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> "LogCluster":
|
||||
return cls(
|
||||
cluster_id=d["cluster_id"],
|
||||
template=d["template"],
|
||||
size=d.get("size", 1),
|
||||
first_seen_at=d.get("first_seen_at", ""),
|
||||
last_seen_at=d.get("last_seen_at", ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogAnomalyEvent:
|
||||
"""新 pattern 偵測事件"""
|
||||
cluster_id: str
|
||||
template: str
|
||||
sample_log: str
|
||||
detected_at: str
|
||||
shadow_mode: bool = True
|
||||
source: str = "k8s_pod" # k8s_pod | host_syslog | app_log
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main Service
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class LogAnomalyDetector:
|
||||
"""
|
||||
Drain3 日誌異常偵測服務
|
||||
|
||||
工作流程:
|
||||
1. process_log_line() — 即時 clustering
|
||||
2. 新 cluster → 記錄到 Redis list
|
||||
3. ProactiveInspector 定期呼叫 get_new_patterns() 聚合
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._drain: Any = None # lazy-init Drain3 instance
|
||||
self._initialized = False
|
||||
|
||||
def _get_drain(self) -> Any:
|
||||
"""Lazy-init Drain3(避免 import 在啟動時失敗)。"""
|
||||
if self._drain is not None:
|
||||
return self._drain
|
||||
|
||||
try:
|
||||
from drain3 import TemplateMiner
|
||||
from drain3.template_miner_config import TemplateMinerConfig
|
||||
|
||||
config = TemplateMinerConfig()
|
||||
config.drain_depth = DRAIN_DEPTH
|
||||
config.drain_sim_th = DRAIN_SIM_THRESHOLD
|
||||
config.drain_max_children = DRAIN_MAX_CHILDREN
|
||||
config.parametrize_numeric_tokens = True
|
||||
|
||||
self._drain = TemplateMiner(config=config)
|
||||
self._initialized = True
|
||||
logger.info("drain3_initialized")
|
||||
return self._drain
|
||||
|
||||
except ImportError:
|
||||
logger.warning("drain3_not_available", reason="package not installed")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("drain3_init_failed", error=str(e))
|
||||
return None
|
||||
|
||||
async def process_log_line(
|
||||
self,
|
||||
log_line: str,
|
||||
source: str = "k8s_pod",
|
||||
) -> LogAnomalyEvent | None:
|
||||
"""
|
||||
處理單行日誌,若為新 pattern 回傳 LogAnomalyEvent。
|
||||
|
||||
Args:
|
||||
log_line: 原始日誌行
|
||||
source: 來源標籤
|
||||
|
||||
Returns:
|
||||
LogAnomalyEvent(新 pattern)或 None(已知 pattern)
|
||||
"""
|
||||
from src.core.feature_flags import aiops_flags
|
||||
|
||||
if not aiops_flags.AIOPS_P4_LOG_ANOMALY:
|
||||
return None
|
||||
|
||||
shadow_mode = aiops_flags.AIOPS_P4_SHADOW_MODE
|
||||
|
||||
try:
|
||||
drain = self._get_drain()
|
||||
if drain is None:
|
||||
return None
|
||||
|
||||
# Drain3 clustering(同步計算,輕量)
|
||||
result = drain.add_log_message(log_line)
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
cluster = result.get("cluster", None)
|
||||
if cluster is None:
|
||||
return None
|
||||
|
||||
change_type = result.get("change_type", "none")
|
||||
is_new = change_type in ("cluster_created", "template_created")
|
||||
|
||||
if not is_new:
|
||||
# 已知 pattern:更新 last_seen
|
||||
await self._update_cluster_hit(str(cluster.cluster_id))
|
||||
return None
|
||||
|
||||
# 新 pattern!
|
||||
template = cluster.get_template()
|
||||
cluster_id = self._make_cluster_id(template)
|
||||
|
||||
now_str = now_taipei().isoformat()
|
||||
log_cluster = LogCluster(
|
||||
cluster_id=cluster_id,
|
||||
template=template,
|
||||
size=1,
|
||||
first_seen_at=now_str,
|
||||
last_seen_at=now_str,
|
||||
is_new=True,
|
||||
)
|
||||
await self._save_new_cluster(log_cluster)
|
||||
|
||||
event = LogAnomalyEvent(
|
||||
cluster_id=cluster_id,
|
||||
template=template,
|
||||
sample_log=log_line[:500], # 限制長度
|
||||
detected_at=now_str,
|
||||
shadow_mode=shadow_mode,
|
||||
source=source,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"log_new_pattern_detected",
|
||||
cluster_id=cluster_id,
|
||||
template=template[:200],
|
||||
shadow_mode=shadow_mode,
|
||||
source=source,
|
||||
)
|
||||
return event
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("log_anomaly_process_failed", error=str(e))
|
||||
return None
|
||||
|
||||
async def process_pod_logs(
|
||||
self,
|
||||
namespace: str = "awoooi-prod",
|
||||
tail_lines: int = 100,
|
||||
) -> list[LogAnomalyEvent]:
|
||||
"""
|
||||
批次掃描 K8s Pod 日誌(供 ProactiveInspector 呼叫)。
|
||||
|
||||
Returns:
|
||||
新 pattern 事件列表(Shadow Mode 時只記錄不觸發)
|
||||
"""
|
||||
from src.core.feature_flags import aiops_flags
|
||||
if not aiops_flags.AIOPS_P4_LOG_ANOMALY:
|
||||
return []
|
||||
|
||||
events: list[LogAnomalyEvent] = []
|
||||
try:
|
||||
logs = await self._fetch_pod_logs(namespace, tail_lines)
|
||||
for line in logs:
|
||||
if len(line.strip()) < 10:
|
||||
continue
|
||||
event = await self.process_log_line(line)
|
||||
if event:
|
||||
events.append(event)
|
||||
except Exception as e:
|
||||
logger.warning("pod_log_scan_failed", error=str(e))
|
||||
|
||||
return events
|
||||
|
||||
async def get_recent_new_patterns(
|
||||
self,
|
||||
limit: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""取得最近偵測到的新 pattern(供 ProactiveInspector 聚合報告)。"""
|
||||
try:
|
||||
from src.core.redis_client import get_redis
|
||||
r = get_redis()
|
||||
raw = await r.lrange(REDIS_KEY_NEW_PATTERNS, 0, limit - 1)
|
||||
return [json.loads(item) for item in raw]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Private Helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_cluster_id(self, template: str) -> str:
|
||||
"""根據模板產生穩定 ID。"""
|
||||
return hashlib.md5(template.encode()).hexdigest()[:8].upper()
|
||||
|
||||
async def _save_new_cluster(self, cluster: LogCluster, sample_log: str = "") -> None:
|
||||
"""
|
||||
儲存新 cluster:
|
||||
1. 先寫 PostgreSQL(永久保存,AI 的 log 語意理解庫)
|
||||
2. 推送到 Redis list(短期工作記憶,供 ProactiveInspector 聚合)
|
||||
|
||||
Phase 4 ADR-084 架構鐵律:Drain3 學到的模板不能只存 Redis。
|
||||
Redis TTL 到期 = AI 把已知 pattern 再次當成新 pattern = 永遠不會學習。
|
||||
2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 4 改為 PG source of truth
|
||||
"""
|
||||
# 1. 寫入 PostgreSQL(主要持久化,UPSERT 防重複)
|
||||
await self._pg_upsert_cluster(cluster, sample_log)
|
||||
|
||||
# 2. 推送到 Redis list(短期工作記憶)
|
||||
try:
|
||||
from src.core.redis_client import get_redis
|
||||
r = get_redis()
|
||||
payload = json.dumps({
|
||||
**cluster.to_dict(),
|
||||
"detected_at": cluster.first_seen_at,
|
||||
})
|
||||
await r.lpush(REDIS_KEY_NEW_PATTERNS, payload)
|
||||
await r.ltrim(REDIS_KEY_NEW_PATTERNS, 0, MAX_NEW_PATTERNS - 1)
|
||||
await r.expire(REDIS_KEY_NEW_PATTERNS, REDIS_TTL_CLUSTERS)
|
||||
except Exception as e:
|
||||
logger.warning("log_cluster_redis_push_failed", error=str(e))
|
||||
|
||||
async def _pg_upsert_cluster(self, cluster: LogCluster, sample_log: str) -> None:
|
||||
"""
|
||||
寫入或更新 LogClusterRecord(UPSERT on cluster_id)。
|
||||
|
||||
同一 cluster_id 再次出現時只更新 last_seen_at 和 size,不重複 INSERT。
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from src.db.base import get_session_factory
|
||||
from src.db.models import LogClusterRecord
|
||||
from src.utils.timezone import now_taipei
|
||||
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
stmt = pg_insert(LogClusterRecord).values(
|
||||
cluster_id=cluster.cluster_id,
|
||||
template=cluster.template,
|
||||
size=cluster.size,
|
||||
source="k8s_pod",
|
||||
sample_log=sample_log[:500] if sample_log else None,
|
||||
).on_conflict_do_update(
|
||||
index_elements=["cluster_id"],
|
||||
set_={
|
||||
"size": LogClusterRecord.size + 1,
|
||||
"last_seen_at": now_taipei(),
|
||||
},
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
logger.warning("log_cluster_pg_upsert_failed", cluster_id=cluster.cluster_id, error=str(e))
|
||||
|
||||
async def _update_cluster_hit(self, cluster_id: str) -> None:
|
||||
"""更新已知 cluster 的命中次數(best-effort)。"""
|
||||
try:
|
||||
from src.core.redis_client import get_redis
|
||||
r = get_redis()
|
||||
key = f"{REDIS_KEY_CLUSTERS}:{cluster_id}"
|
||||
await r.hincrby(key, "size", 1)
|
||||
await r.hset(key, "last_seen_at", now_taipei().isoformat())
|
||||
except Exception:
|
||||
pass # best-effort
|
||||
|
||||
async def _fetch_pod_logs(
|
||||
self,
|
||||
namespace: str,
|
||||
tail_lines: int,
|
||||
) -> list[str]:
|
||||
"""
|
||||
透過 kubectl API server 抓取 Pod 日誌。
|
||||
|
||||
使用 K8s in-cluster config(API server: https://kubernetes.default.svc)
|
||||
或本地 kubeconfig。
|
||||
"""
|
||||
import asyncio
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
# 抓取 awoooi-api deploy 的日誌(最新 Pod)
|
||||
result = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: subprocess.run(
|
||||
[
|
||||
"kubectl", "logs",
|
||||
f"deploy/awoooi-api",
|
||||
"-n", namespace,
|
||||
f"--tail={tail_lines}",
|
||||
"--timestamps=false",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.splitlines()
|
||||
logger.warning("kubectl_logs_failed", stderr=result.stderr[:200])
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.warning("pod_log_fetch_failed", error=str(e))
|
||||
return []
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Singleton
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_detector: LogAnomalyDetector | None = None
|
||||
|
||||
|
||||
def get_log_anomaly_detector() -> LogAnomalyDetector:
|
||||
global _detector
|
||||
if _detector is None:
|
||||
_detector = LogAnomalyDetector()
|
||||
return _detector
|
||||
Reference in New Issue
Block a user