alert_service.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671
  1. """Alert service for managing alert rules and checking thresholds using Redis."""
  2. import json
  3. import logging
  4. import uuid
  5. from datetime import datetime, timedelta, timezone
  6. from typing import Any, Optional
  7. import sqlalchemy as sa
  8. from extensions.ext_database import db
  9. from extensions.ext_redis import redis_client, redis_fallback
  10. from models.model import App
  11. from services.notification_service import NotificationService
  12. logger = logging.getLogger(__name__)
  13. class AlertService:
  14. """Service for managing application alert rules stored in Redis."""
  15. # Redis Key prefixes
  16. RULE_PREFIX = "alert:rule" # Alert rule data
  17. RULE_LIST_PREFIX = "alert:rules" # List of rule IDs per app
  18. LOG_PREFIX = "alert:log" # Alert logs
  19. LOG_LIST_PREFIX = "alert:logs" # List of log IDs per rule
  20. ENABLED_RULES_PREFIX = "alert:enabled" # Set of enabled rule IDs
  21. CONFIG_PREFIX = "alert:config" # Notification channel config per app
  22. # Data retention time (90 days)
  23. RETENTION_DAYS = 90
  24. RETENTION_SECONDS = RETENTION_DAYS * 24 * 60 * 60
  25. # Maximum log entries per rule
  26. MAX_LOG_ENTRIES = 1000
  27. @classmethod
  28. def _get_rule_key(cls, rule_id: str) -> str:
  29. """Get Redis key for rule data."""
  30. return f"{cls.RULE_PREFIX}:{rule_id}"
  31. @classmethod
  32. def _get_rule_list_key(cls, app_id: str) -> str:
  33. """Get Redis key for app's rule list."""
  34. return f"{cls.RULE_LIST_PREFIX}:{app_id}"
  35. @classmethod
  36. def _get_log_key(cls, log_id: str) -> str:
  37. """Get Redis key for log data."""
  38. return f"{cls.LOG_PREFIX}:{log_id}"
  39. @classmethod
  40. def _get_config_key(cls, app_id: str) -> str:
  41. """Get Redis key for notification config."""
  42. return f"{cls.CONFIG_PREFIX}:{app_id}"
  43. @classmethod
  44. def _get_log_list_key(cls, rule_id: str) -> str:
  45. """Get Redis key for rule's log list."""
  46. return f"{cls.LOG_LIST_PREFIX}:{rule_id}"
  47. @classmethod
  48. @redis_fallback(default_return=[])
  49. def get_alert_rules(cls, app_id: str) -> list[dict[str, Any]]:
  50. """Get all alert rules for an app.
  51. Args:
  52. app_id: Application ID
  53. Returns:
  54. List of alert rule dictionaries
  55. """
  56. rule_list_key = cls._get_rule_list_key(app_id)
  57. rule_ids = redis_client.smembers(rule_list_key)
  58. rules = []
  59. for rule_id_bytes in rule_ids:
  60. rule_id = rule_id_bytes.decode("utf-8") if isinstance(rule_id_bytes, bytes) else rule_id_bytes
  61. rule = cls.get_alert_rule(rule_id)
  62. if rule:
  63. rules.append(rule)
  64. return rules
  65. @classmethod
  66. @redis_fallback(default_return=[])
  67. def get_all_enabled_rules(cls) -> list[dict[str, Any]]:
  68. """Get all enabled alert rules across all apps.
  69. Returns:
  70. List of enabled alert rule dictionaries
  71. """
  72. enabled_rule_ids = redis_client.smembers(cls.ENABLED_RULES_PREFIX)
  73. rules = []
  74. for rule_id_bytes in enabled_rule_ids:
  75. rule_id = rule_id_bytes.decode("utf-8") if isinstance(rule_id_bytes, bytes) else rule_id_bytes
  76. rule = cls.get_alert_rule(rule_id)
  77. if rule and rule.get("is_enabled"):
  78. rules.append(rule)
  79. return rules
  80. @classmethod
  81. @redis_fallback(default_return=None)
  82. def get_alert_rule(cls, rule_id: str) -> Optional[dict[str, Any]]:
  83. """Get a single alert rule.
  84. Args:
  85. rule_id: Alert rule ID
  86. Returns:
  87. Alert rule dictionary or None if not found
  88. """
  89. rule_key = cls._get_rule_key(rule_id)
  90. rule_data = redis_client.hgetall(rule_key)
  91. if not rule_data:
  92. return None
  93. # Decode and parse rule data
  94. rule = {}
  95. for key, value in rule_data.items():
  96. key_str = key.decode("utf-8") if isinstance(key, bytes) else key
  97. value_str = value.decode("utf-8") if isinstance(value, bytes) else value
  98. # Parse JSON fields
  99. if key_str in ["notification_channels"]:
  100. try:
  101. rule[key_str] = json.loads(value_str) if value_str else {}
  102. except json.JSONDecodeError:
  103. rule[key_str] = {}
  104. elif key_str in ["is_enabled"]:
  105. rule[key_str] = value_str.lower() == "true"
  106. elif key_str in ["threshold_value", "check_interval_minutes", "consecutive_triggers", "trigger_count", "consecutive_trigger_count"]:
  107. try:
  108. rule[key_str] = float(value_str) if "." in value_str else int(value_str)
  109. except (ValueError, AttributeError):
  110. rule[key_str] = 0
  111. else:
  112. rule[key_str] = value_str
  113. return rule
  114. @classmethod
  115. @redis_fallback(default_return=None)
  116. def create_alert_rule(cls, app_id: str, data: dict[str, Any], created_by: str) -> Optional[dict[str, Any]]:
  117. """Create a new alert rule.
  118. Args:
  119. app_id: Application ID
  120. data: Alert rule data
  121. created_by: User ID who created the rule
  122. Returns:
  123. Created alert rule dictionary
  124. """
  125. # Get app to validate and get tenant_id
  126. app = db.session.query(App).filter(App.id == app_id).first()
  127. if not app:
  128. raise ValueError(f"App {app_id} not found")
  129. # Generate rule ID
  130. rule_id = str(uuid.uuid4())
  131. # Build rule data
  132. now = datetime.now(timezone.utc).isoformat()
  133. rule = {
  134. "id": rule_id,
  135. "tenant_id": app.tenant_id,
  136. "app_id": app_id,
  137. "name": data["name"],
  138. "description": data.get("description", ""),
  139. "is_enabled": data.get("is_enabled", True),
  140. "metric_type": data["metric_type"],
  141. "operator": data["operator"],
  142. "threshold_value": data["threshold_value"],
  143. "check_interval_minutes": data.get("check_interval_minutes", 5),
  144. "consecutive_triggers": data.get("consecutive_triggers", 2),
  145. "notification_channels": json.dumps(data["notification_channels"]),
  146. "created_by": created_by,
  147. "created_at": now,
  148. "updated_at": now,
  149. "last_triggered_at": "",
  150. "trigger_count": 0,
  151. "consecutive_trigger_count": 0,
  152. }
  153. # Store rule data
  154. rule_key = cls._get_rule_key(rule_id)
  155. redis_client.hset(
  156. rule_key,
  157. mapping={k: str(v) if not isinstance(v, str) else v for k, v in rule.items()},
  158. )
  159. redis_client.expire(rule_key, cls.RETENTION_SECONDS)
  160. # Add to app's rule list
  161. rule_list_key = cls._get_rule_list_key(app_id)
  162. redis_client.sadd(rule_list_key, rule_id)
  163. redis_client.expire(rule_list_key, cls.RETENTION_SECONDS)
  164. # Add to enabled rules set if enabled
  165. if rule["is_enabled"]:
  166. redis_client.sadd(cls.ENABLED_RULES_PREFIX, rule_id)
  167. redis_client.expire(cls.ENABLED_RULES_PREFIX, cls.RETENTION_SECONDS)
  168. logger.info(f"Created alert rule {rule_id} for app {app_id}")
  169. # Return parsed rule
  170. return cls.get_alert_rule(rule_id)
  171. @classmethod
  172. @redis_fallback(default_return=None)
  173. def update_alert_rule(cls, rule_id: str, data: dict[str, Any]) -> Optional[dict[str, Any]]:
  174. """Update an existing alert rule.
  175. Args:
  176. rule_id: Alert rule ID
  177. data: Updated alert rule data
  178. Returns:
  179. Updated alert rule dictionary
  180. """
  181. rule = cls.get_alert_rule(rule_id)
  182. if not rule:
  183. raise ValueError(f"Alert rule {rule_id} not found")
  184. # Update fields
  185. rule_key = cls._get_rule_key(rule_id)
  186. updates = {}
  187. if "name" in data:
  188. updates["name"] = data["name"]
  189. if "description" in data:
  190. updates["description"] = data["description"]
  191. if "is_enabled" in data:
  192. updates["is_enabled"] = str(data["is_enabled"])
  193. # Update enabled rules set
  194. if data["is_enabled"]:
  195. redis_client.sadd(cls.ENABLED_RULES_PREFIX, rule_id)
  196. else:
  197. redis_client.srem(cls.ENABLED_RULES_PREFIX, rule_id)
  198. if "metric_type" in data:
  199. updates["metric_type"] = data["metric_type"]
  200. if "operator" in data:
  201. updates["operator"] = data["operator"]
  202. if "threshold_value" in data:
  203. updates["threshold_value"] = str(data["threshold_value"])
  204. if "check_interval_minutes" in data:
  205. updates["check_interval_minutes"] = str(data["check_interval_minutes"])
  206. if "consecutive_triggers" in data:
  207. updates["consecutive_triggers"] = str(data["consecutive_triggers"])
  208. if "notification_channels" in data:
  209. updates["notification_channels"] = json.dumps(data["notification_channels"])
  210. updates["updated_at"] = datetime.now(timezone.utc).isoformat()
  211. # Apply updates
  212. if updates:
  213. redis_client.hset(rule_key, mapping=updates)
  214. redis_client.expire(rule_key, cls.RETENTION_SECONDS)
  215. logger.info(f"Updated alert rule {rule_id}")
  216. return cls.get_alert_rule(rule_id)
  217. @classmethod
  218. @redis_fallback(default_return=None)
  219. def delete_alert_rule(cls, rule_id: str) -> None:
  220. """Delete an alert rule.
  221. Args:
  222. rule_id: Alert rule ID
  223. """
  224. rule = cls.get_alert_rule(rule_id)
  225. if not rule:
  226. raise ValueError(f"Alert rule {rule_id} not found")
  227. # Remove from app's rule list
  228. rule_list_key = cls._get_rule_list_key(rule["app_id"])
  229. redis_client.srem(rule_list_key, rule_id)
  230. # Remove from enabled rules set
  231. redis_client.srem(cls.ENABLED_RULES_PREFIX, rule_id)
  232. # Delete rule data
  233. rule_key = cls._get_rule_key(rule_id)
  234. redis_client.delete(rule_key)
  235. # Delete associated logs
  236. log_list_key = cls._get_log_list_key(rule_id)
  237. log_ids = redis_client.smembers(log_list_key)
  238. for log_id_bytes in log_ids:
  239. log_id = log_id_bytes.decode("utf-8") if isinstance(log_id_bytes, bytes) else log_id_bytes
  240. log_key = cls._get_log_key(log_id)
  241. redis_client.delete(log_key)
  242. redis_client.delete(log_list_key)
  243. logger.info(f"Deleted alert rule {rule_id}")
  244. @staticmethod
  245. def check_threshold(value: float | None, operator: str, threshold: float) -> bool:
  246. """Check if a value exceeds a threshold.
  247. Args:
  248. value: Current metric value (None means no data)
  249. operator: Comparison operator ('>', '<', '>=', '<=', '=')
  250. threshold: Threshold value
  251. Returns:
  252. True if threshold is exceeded, False otherwise
  253. """
  254. if value is None:
  255. return False
  256. if operator == ">":
  257. return value > threshold
  258. elif operator == "<":
  259. return value < threshold
  260. elif operator == ">=":
  261. return value >= threshold
  262. elif operator == "<=":
  263. return value <= threshold
  264. elif operator == "=":
  265. return value == threshold
  266. else:
  267. logger.warning(f"Unknown operator: {operator}")
  268. return False
  269. @staticmethod
  270. def get_metric_value(app_id: str, metric_type: str, time_range_minutes: int = 5) -> float | None:
  271. """Get current metric value for an app.
  272. Args:
  273. app_id: Application ID
  274. metric_type: Type of metric ('error_rate', 'response_time_p95', 'response_time_p99', 'avg_response_time')
  275. time_range_minutes: Time range to calculate metric (default 5 minutes)
  276. Returns:
  277. Metric value or None if no data
  278. """
  279. try:
  280. # Calculate time range
  281. end_time = datetime.now(timezone.utc)
  282. start_time = end_time - timedelta(minutes=time_range_minutes)
  283. # Build base query
  284. from models.model import Message
  285. base_query = db.session.query(Message).filter(
  286. Message.app_id == app_id, Message.created_at >= start_time, Message.created_at <= end_time
  287. )
  288. if metric_type == "error_rate":
  289. # Calculate error rate
  290. total_count = base_query.count()
  291. if total_count == 0:
  292. return None
  293. error_count = base_query.filter(
  294. sa.or_(
  295. Message.status == "error",
  296. Message.status == "stopped",
  297. )
  298. ).count()
  299. return (error_count / total_count) * 100.0
  300. elif metric_type == "avg_response_time":
  301. # Calculate average response time from message_metadata JSON
  302. # Since message_metadata is stored as Text, we need to parse it
  303. messages = base_query.all()
  304. if not messages:
  305. return None
  306. latencies = []
  307. for msg in messages:
  308. metadata = msg.message_metadata_dict
  309. if metadata and "latency" in metadata:
  310. try:
  311. latencies.append(float(metadata["latency"]))
  312. except (ValueError, TypeError):
  313. continue
  314. if not latencies:
  315. return None
  316. # Return average latency in milliseconds
  317. return sum(latencies) / len(latencies) * 1000
  318. elif metric_type == "response_time_p95":
  319. # Calculate P95 response time from message_metadata JSON
  320. messages = base_query.all()
  321. if not messages:
  322. return None
  323. latencies = []
  324. for msg in messages:
  325. metadata = msg.message_metadata_dict
  326. if metadata and "latency" in metadata:
  327. try:
  328. latencies.append(float(metadata["latency"]) * 1000) # Convert to ms
  329. except (ValueError, TypeError):
  330. continue
  331. if not latencies:
  332. return None
  333. # Calculate P95
  334. latencies.sort()
  335. p95_index = int(len(latencies) * 0.95)
  336. return latencies[p95_index] if p95_index < len(latencies) else latencies[-1]
  337. elif metric_type == "response_time_p99":
  338. # Calculate P99 response time from message_metadata JSON
  339. messages = base_query.all()
  340. if not messages:
  341. return None
  342. latencies = []
  343. for msg in messages:
  344. metadata = msg.message_metadata_dict
  345. if metadata and "latency" in metadata:
  346. try:
  347. latencies.append(float(metadata["latency"]) * 1000) # Convert to ms
  348. except (ValueError, TypeError):
  349. continue
  350. if not latencies:
  351. return None
  352. # Calculate P99
  353. latencies.sort()
  354. p99_index = int(len(latencies) * 0.99)
  355. return latencies[p99_index] if p99_index < len(latencies) else latencies[-1]
  356. else:
  357. logger.warning(f"Unknown metric type: {metric_type}")
  358. return None
  359. except Exception:
  360. logger.exception(f"Error getting metric value for app {app_id}, metric {metric_type}")
  361. return None
  362. @classmethod
  363. @redis_fallback(default_return=None)
  364. def trigger_alert(cls, rule: dict[str, Any], metric_value: float) -> None:
  365. """Trigger an alert and send notifications.
  366. Args:
  367. rule: Alert rule dictionary
  368. metric_value: Current value of the metric
  369. """
  370. try:
  371. # Get app name
  372. app = db.session.query(App).filter(App.id == rule["app_id"]).first()
  373. app_name = app.name if app else "Unknown App"
  374. # Send notifications
  375. notification_results = NotificationService.send_alert_notification(
  376. channels=rule["notification_channels"],
  377. app_name=app_name,
  378. metric_name=rule["metric_type"],
  379. metric_value=metric_value,
  380. threshold_value=rule["threshold_value"],
  381. operator=rule["operator"],
  382. )
  383. # Create alert log
  384. log_id = str(uuid.uuid4())
  385. now = datetime.now(timezone.utc).isoformat()
  386. log_data = {
  387. "id": log_id,
  388. "alert_rule_id": rule["id"],
  389. "metric_value": metric_value,
  390. "threshold_value": rule["threshold_value"],
  391. "notification_status": json.dumps(notification_results),
  392. "error_message": "",
  393. "triggered_at": now,
  394. }
  395. # Store log data
  396. log_key = cls._get_log_key(log_id)
  397. redis_client.hset(
  398. log_key,
  399. mapping={k: str(v) if not isinstance(v, str) else v for k, v in log_data.items()},
  400. )
  401. redis_client.expire(log_key, cls.RETENTION_SECONDS)
  402. # Add to rule's log list (sorted set by timestamp)
  403. log_list_key = cls._get_log_list_key(rule["id"])
  404. timestamp = datetime.now(timezone.utc).timestamp()
  405. redis_client.zadd(log_list_key, {log_id: timestamp})
  406. redis_client.expire(log_list_key, cls.RETENTION_SECONDS)
  407. # Cleanup old logs
  408. cls._cleanup_old_logs(rule["id"])
  409. # Update rule statistics
  410. rule_key = cls._get_rule_key(rule["id"])
  411. redis_client.hset(
  412. rule_key,
  413. mapping={
  414. "last_triggered_at": now,
  415. "trigger_count": str(int(rule.get("trigger_count", 0)) + 1),
  416. },
  417. )
  418. redis_client.expire(rule_key, cls.RETENTION_SECONDS)
  419. logger.info(
  420. f"Alert triggered for rule {rule['id']}, app {rule['app_id']}, "
  421. f"metric {rule['metric_type']}={metric_value}, threshold {rule['operator']} {rule['threshold_value']}"
  422. )
  423. except Exception as e:
  424. logger.exception(f"Error triggering alert for rule {rule['id']}")
  425. # Try to log the error
  426. try:
  427. log_id = str(uuid.uuid4())
  428. now = datetime.now(timezone.utc).isoformat()
  429. log_data = {
  430. "id": log_id,
  431. "alert_rule_id": rule["id"],
  432. "metric_value": metric_value,
  433. "threshold_value": rule["threshold_value"],
  434. "notification_status": json.dumps({"error": "failed"}),
  435. "error_message": str(e),
  436. "triggered_at": now,
  437. }
  438. log_key = cls._get_log_key(log_id)
  439. redis_client.hset(
  440. log_key,
  441. mapping={k: str(v) if not isinstance(v, str) else v for k, v in log_data.items()},
  442. )
  443. redis_client.expire(log_key, cls.RETENTION_SECONDS)
  444. except Exception:
  445. logger.exception("Failed to log alert error")
  446. @classmethod
  447. @redis_fallback(default_return=[])
  448. def get_alert_logs(cls, rule_id: str, limit: int = 100) -> list[dict[str, Any]]:
  449. """Get alert logs for a rule.
  450. Args:
  451. rule_id: Alert rule ID
  452. limit: Maximum number of logs to return
  453. Returns:
  454. List of alert log dictionaries
  455. """
  456. log_list_key = cls._get_log_list_key(rule_id)
  457. # Get log IDs sorted by timestamp (descending)
  458. log_ids = redis_client.zrevrange(log_list_key, 0, limit - 1)
  459. logs = []
  460. for log_id_bytes in log_ids:
  461. log_id = log_id_bytes.decode("utf-8") if isinstance(log_id_bytes, bytes) else log_id_bytes
  462. log_key = cls._get_log_key(log_id)
  463. log_data = redis_client.hgetall(log_key)
  464. if not log_data:
  465. continue
  466. # Decode and parse log data
  467. log = {}
  468. for key, value in log_data.items():
  469. key_str = key.decode("utf-8") if isinstance(key, bytes) else key
  470. value_str = value.decode("utf-8") if isinstance(value, bytes) else value
  471. if key_str == "notification_status":
  472. try:
  473. log[key_str] = json.loads(value_str) if value_str else {}
  474. except json.JSONDecodeError:
  475. log[key_str] = {}
  476. elif key_str in ["metric_value", "threshold_value"]:
  477. try:
  478. log[key_str] = float(value_str)
  479. except (ValueError, AttributeError):
  480. log[key_str] = 0.0
  481. else:
  482. log[key_str] = value_str
  483. logs.append(log)
  484. return logs
  485. @classmethod
  486. @redis_fallback(default_return=None)
  487. def _cleanup_old_logs(cls, rule_id: str) -> None:
  488. """Clean up old logs for a rule, keeping only the most recent MAX_LOG_ENTRIES.
  489. Args:
  490. rule_id: Alert rule ID
  491. """
  492. log_list_key = cls._get_log_list_key(rule_id)
  493. # Get count of logs
  494. log_count = redis_client.zcard(log_list_key)
  495. if log_count > cls.MAX_LOG_ENTRIES:
  496. # Remove oldest logs
  497. logs_to_remove = log_count - cls.MAX_LOG_ENTRIES
  498. # Get oldest log IDs
  499. old_log_ids = redis_client.zrange(log_list_key, 0, logs_to_remove - 1)
  500. # Delete old log entries
  501. for log_id_bytes in old_log_ids:
  502. log_id = log_id_bytes.decode("utf-8") if isinstance(log_id_bytes, bytes) else log_id_bytes
  503. log_key = cls._get_log_key(log_id)
  504. redis_client.delete(log_key)
  505. # Remove from sorted set
  506. redis_client.zremrangebyrank(log_list_key, 0, logs_to_remove - 1)
  507. @classmethod
  508. @redis_fallback(default_return={})
  509. def get_notification_config(cls, app_id: str) -> dict:
  510. """Get notification channel configuration for an app.
  511. Args:
  512. app_id: Application ID
  513. Returns:
  514. Dictionary with channel configurations
  515. """
  516. config_key = cls._get_config_key(app_id)
  517. config_data = redis_client.get(config_key)
  518. if not config_data:
  519. return {"email": {}, "dingtalk": {}, "wecom": {}}
  520. config_str = config_data.decode("utf-8") if isinstance(config_data, bytes) else config_data
  521. return json.loads(config_str)
  522. @classmethod
  523. @redis_fallback(default_return={})
  524. def save_notification_config(cls, app_id: str, config: dict) -> dict:
  525. """Save notification channel configuration for an app.
  526. Args:
  527. app_id: Application ID
  528. config: Configuration dictionary with channels
  529. Returns:
  530. Saved configuration
  531. Raises:
  532. ValueError: If config contains invalid channels
  533. """
  534. config_key = cls._get_config_key(app_id)
  535. # Validate config structure
  536. valid_channels = ["email", "dingtalk", "wecom"]
  537. for channel in config.keys():
  538. if channel not in valid_channels:
  539. raise ValueError(f"Invalid channel: {channel}")
  540. # Save config with 90 days expiration
  541. redis_client.setex(
  542. config_key,
  543. cls.RETENTION_SECONDS,
  544. json.dumps(config),
  545. )
  546. return config