feat(Phase 3.5 + Phase 4): AI 學習成果持久化到 PostgreSQL — 修正「AI 失憶」架構缺陷
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:
OG T
2026-04-15 15:33:52 +08:00
parent 9126c594a4
commit bf45b80bd2
8 changed files with 1490 additions and 238 deletions

View 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 ModeAIOPS_P4_SHADOW_MODE=True只記錄不觸發 Alert
- 熔斷保護statsmodels 失敗 → fallback 到滑動平均
- 7 天歷史資料最少訓練量(低於此閾值 → skip不誤判
- 基線持久化到 Rediskey: 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 querye.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 factorNone = 不套用
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. 再寫 Redis24h 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 cachePG 才是 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 fallbackcache 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 到 PostgreSQLINSERT不更新舊記錄"""
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

View 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
- 狀態持久化到 Rediscluster 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:
"""
寫入或更新 LogClusterRecordUPSERT 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 configAPI 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