Files
mcphub/core/health.py
airano-ir 1615daf1a7
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
chore(F.15): FastMCP 3.x upgrade + legacy cleanup — v3.5.0
- Upgrade fastmcp from >=2.14.0,<3.0.0 to >=3.0.0,<4.0.0
- Remove server_multi.py (legacy multi-endpoint server)
- Remove core/project_manager.py (legacy project manager)
- Refactor HealthMonitor to use SiteManager exclusively
- Replace _tool_manager._tools with tracked _tool_counts dict
- Clean core/__init__.py exports
- Update CHANGELOG, CLAUDE.md, tests
- Auto-format fixes (black + ruff)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 03:29:38 +02:00

869 lines
30 KiB
Python

"""
Enhanced Health Monitoring System for MCP Server
This module provides comprehensive health monitoring capabilities including:
- Response time tracking
- Error rate monitoring
- Historical metrics storage
- Alert thresholds
- Dependency health checks
- System uptime tracking
Author: MCP Hub Team
"""
import asyncio
import json
import logging
import time
from collections import defaultdict, deque
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from core.audit_log import AuditLogger
from core.site_manager import SiteManager
logger = logging.getLogger(__name__)
@dataclass
class HealthMetric:
"""Individual health metric data point."""
timestamp: datetime
project_id: str
response_time_ms: float
success: bool
error_message: str | None = None
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
"timestamp": self.timestamp.isoformat(),
"project_id": self.project_id,
"response_time_ms": self.response_time_ms,
"success": self.success,
"error_message": self.error_message,
}
@dataclass
class SystemMetrics:
"""System-wide metrics."""
uptime_seconds: float
total_requests: int
successful_requests: int
failed_requests: int
average_response_time_ms: float
error_rate_percent: float
requests_per_minute: float
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
return asdict(self)
@dataclass
class ProjectHealthStatus:
"""Comprehensive health status for a project."""
project_id: str
healthy: bool
last_check: datetime
response_time_ms: float
error_rate_percent: float
recent_errors: list[str] = field(default_factory=list)
alerts: list[str] = field(default_factory=list)
details: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
"project_id": self.project_id,
"healthy": self.healthy,
"last_check": self.last_check.isoformat(),
"response_time_ms": self.response_time_ms,
"error_rate_percent": self.error_rate_percent,
"recent_errors": self.recent_errors,
"alerts": self.alerts,
"details": self.details,
}
@dataclass
class AlertThreshold:
"""Alert threshold configuration."""
name: str
metric: str # "response_time_ms", "error_rate_percent", etc.
threshold: float
comparison: str # "gt" (greater than), "lt" (less than), "eq" (equal)
severity: str = "warning" # "info", "warning", "critical"
def check(self, value: float) -> bool:
"""Check if value exceeds threshold."""
if self.comparison == "gt":
return value > self.threshold
elif self.comparison == "lt":
return value < self.threshold
elif self.comparison == "eq":
return value == self.threshold
return False
class HealthMonitor:
"""
Enhanced health monitoring system with metrics tracking and alerting.
Features:
- Real-time health checks
- Response time tracking
- Error rate monitoring
- Historical metrics (last 24 hours)
- Alert thresholds
- System uptime tracking
"""
def __init__(
self,
audit_logger: AuditLogger | None = None,
metrics_retention_hours: int = 24,
max_metrics_per_project: int = 1000,
site_manager: SiteManager | None = None,
):
"""
Initialize health monitor.
Args:
audit_logger: Optional audit logger for logging health events
metrics_retention_hours: Hours to retain historical metrics
max_metrics_per_project: Maximum metrics to store per project
site_manager: SiteManager for site discovery
"""
self.site_manager = site_manager
self.audit_logger = audit_logger
self.metrics_retention_hours = metrics_retention_hours
self.max_metrics_per_project = max_metrics_per_project
# Metrics storage (in-memory)
# Using deque for efficient FIFO operations
self.metrics_history: dict[str, deque] = defaultdict(
lambda: deque(maxlen=max_metrics_per_project)
)
# Request counters
self.total_requests = 0
self.successful_requests = 0
self.failed_requests = 0
# Response time tracking
self.response_times: deque = deque(maxlen=1000) # Last 1000 requests
# System start time
self.start_time = time.time()
# Alert thresholds (configurable)
self.alert_thresholds: dict[str, list[AlertThreshold]] = defaultdict(list)
self._setup_default_thresholds()
# Request rate tracking (for requests per minute)
self.request_timestamps: deque = deque(maxlen=1000)
# Active background checks
self.latest_health_status: dict[str, ProjectHealthStatus] = {}
self._bg_task: asyncio.Task | None = None
self._is_running = False
logger.info("HealthMonitor initialized")
def _setup_default_thresholds(self):
"""Setup default alert thresholds."""
# Response time threshold: > 5000ms (5 seconds) is critical
self.alert_thresholds["global"].append(
AlertThreshold(
name="High Response Time",
metric="response_time_ms",
threshold=5000.0,
comparison="gt",
severity="critical",
)
)
# Error rate threshold: > 10% is warning, > 25% is critical
self.alert_thresholds["global"].append(
AlertThreshold(
name="High Error Rate",
metric="error_rate_percent",
threshold=10.0,
comparison="gt",
severity="warning",
)
)
self.alert_thresholds["global"].append(
AlertThreshold(
name="Critical Error Rate",
metric="error_rate_percent",
threshold=25.0,
comparison="gt",
severity="critical",
)
)
def add_alert_threshold(
self,
project_id: str,
name: str,
metric: str,
threshold: float,
comparison: str = "gt",
severity: str = "warning",
):
"""
Add a custom alert threshold for a project.
Args:
project_id: Project ID or "global" for all projects
name: Alert name
metric: Metric to check
threshold: Threshold value
comparison: Comparison operator ("gt", "lt", "eq")
severity: Alert severity ("info", "warning", "critical")
"""
alert = AlertThreshold(name, metric, threshold, comparison, severity)
self.alert_thresholds[project_id].append(alert)
logger.info(f"Added alert threshold '{name}' for {project_id}")
def record_request(
self,
project_id: str,
response_time_ms: float,
success: bool,
error_message: str | None = None,
):
"""
Record a request metric.
Args:
project_id: Project that handled the request
response_time_ms: Response time in milliseconds
success: Whether request succeeded
error_message: Error message if failed
"""
# Create metric
metric = HealthMetric(
timestamp=datetime.now(UTC),
project_id=project_id,
response_time_ms=response_time_ms,
success=success,
error_message=error_message,
)
# Store in history
self.metrics_history[project_id].append(metric)
# Update counters
self.total_requests += 1
if success:
self.successful_requests += 1
else:
self.failed_requests += 1
# Track response time
self.response_times.append(response_time_ms)
# Track request timestamp for rate calculation
self.request_timestamps.append(time.time())
# Log to audit if available
if self.audit_logger:
self.audit_logger.log_system_event(
event="health_metric_recorded",
details={
"project_id": project_id,
"response_time_ms": response_time_ms,
"success": success,
"error_message": error_message,
},
)
def _cleanup_old_metrics(self, project_id: str):
"""Remove metrics older than retention period."""
if project_id not in self.metrics_history:
return
cutoff_time = datetime.now(UTC) - timedelta(hours=self.metrics_retention_hours)
metrics = self.metrics_history[project_id]
# Remove old metrics from the front of deque
while metrics and metrics[0].timestamp < cutoff_time:
metrics.popleft()
def get_project_metrics(self, project_id: str, hours: int = 1) -> dict[str, Any]:
"""
Get metrics for a specific project.
Args:
project_id: Project ID
hours: Number of hours of history to analyze
Returns:
Dictionary with metrics
"""
self._cleanup_old_metrics(project_id)
if project_id not in self.metrics_history:
return {"project_id": project_id, "total_requests": 0, "error": "No metrics available"}
# Filter metrics by time window
cutoff_time = datetime.now(UTC) - timedelta(hours=hours)
metrics = [m for m in self.metrics_history[project_id] if m.timestamp >= cutoff_time]
if not metrics:
return {"project_id": project_id, "total_requests": 0, "time_window_hours": hours}
# Calculate statistics
total_requests = len(metrics)
successful = sum(1 for m in metrics if m.success)
failed = total_requests - successful
error_rate = (failed / total_requests * 100) if total_requests > 0 else 0.0
# Response time statistics
response_times = [m.response_time_ms for m in metrics]
avg_response = sum(response_times) / len(response_times) if response_times else 0.0
min_response = min(response_times) if response_times else 0.0
max_response = max(response_times) if response_times else 0.0
# Recent errors (last 5)
recent_errors = [m.error_message for m in metrics if not m.success and m.error_message][-5:]
return {
"project_id": project_id,
"time_window_hours": hours,
"total_requests": total_requests,
"successful_requests": successful,
"failed_requests": failed,
"error_rate_percent": round(error_rate, 2),
"response_time": {
"average_ms": round(avg_response, 2),
"min_ms": round(min_response, 2),
"max_ms": round(max_response, 2),
},
"recent_errors": recent_errors,
}
def _check_alerts(self, project_id: str, metrics: dict[str, Any]) -> list[str]:
"""
Check if any alert thresholds are exceeded.
Args:
project_id: Project ID
metrics: Current metrics
Returns:
List of alert messages
"""
alerts = []
# Check global thresholds
for threshold in self.alert_thresholds["global"]:
if threshold.metric in metrics:
value = metrics[threshold.metric]
if threshold.check(value):
alerts.append(
f"[{threshold.severity.upper()}] {threshold.name}: "
f"{threshold.metric}={value} (threshold: {threshold.threshold})"
)
# Check project-specific thresholds
for threshold in self.alert_thresholds.get(project_id, []):
if threshold.metric in metrics:
value = metrics[threshold.metric]
if threshold.check(value):
alerts.append(
f"[{threshold.severity.upper()}] {threshold.name}: "
f"{threshold.metric}={value} (threshold: {threshold.threshold})"
)
return alerts
async def check_project_health(
self, project_id: str, include_metrics: bool = True
) -> ProjectHealthStatus:
"""
Perform comprehensive health check on a project.
Args:
project_id: Project ID to check
include_metrics: Whether to include historical metrics
Returns:
ProjectHealthStatus object
"""
start_time = time.time()
try:
if self.site_manager:
health_result = await self._site_manager_health_check(project_id)
else:
return ProjectHealthStatus(
project_id=project_id,
healthy=False,
last_check=datetime.now(UTC),
response_time_ms=0.0,
error_rate_percent=100.0,
recent_errors=["Project not found"],
alerts=["CRITICAL: Project not found"],
)
response_time_ms = (time.time() - start_time) * 1000
# Handle both dict and string (JSON) responses
if isinstance(health_result, str):
try:
import json
health_result = json.loads(health_result)
except (json.JSONDecodeError, TypeError):
# If not valid JSON, treat as error message
health_result = {"healthy": False, "message": health_result}
# Ensure health_result is a dict
if not isinstance(health_result, dict):
health_result = {"healthy": False, "message": str(health_result)}
# Record this health check
is_healthy = health_result.get("healthy", False) or health_result.get("success", False)
self.record_request(
project_id=project_id,
response_time_ms=response_time_ms,
success=is_healthy,
error_message=(
health_result.get("message") or health_result.get("error")
if not is_healthy
else None
),
)
# Get metrics if requested
metrics_data = {}
error_rate = 0.0
recent_errors = []
if include_metrics:
metrics_data = self.get_project_metrics(project_id, hours=1)
error_rate = metrics_data.get("error_rate_percent", 0.0)
recent_errors = metrics_data.get("recent_errors", [])
# Check alerts
alert_check_data = {
"response_time_ms": response_time_ms,
"error_rate_percent": error_rate,
}
alerts = self._check_alerts(project_id, alert_check_data)
status = ProjectHealthStatus(
project_id=project_id,
healthy=is_healthy,
last_check=datetime.now(UTC),
response_time_ms=response_time_ms,
error_rate_percent=error_rate,
recent_errors=recent_errors,
alerts=alerts,
details=health_result,
)
self.latest_health_status[project_id] = status
return status
except Exception as e:
response_time_ms = (time.time() - start_time) * 1000
error_msg = str(e)
# Record failed health check
self.record_request(
project_id=project_id,
response_time_ms=response_time_ms,
success=False,
error_message=error_msg,
)
status = ProjectHealthStatus(
project_id=project_id,
healthy=False,
last_check=datetime.now(UTC),
response_time_ms=response_time_ms,
error_rate_percent=100.0,
recent_errors=[error_msg],
alerts=[f"CRITICAL: Health check failed - {error_msg}"],
)
self.latest_health_status[project_id] = status
return status
def _find_site_info(self, project_id: str) -> dict[str, Any] | None:
"""Find site info from SiteManager by full_id."""
if not self.site_manager:
return None
for info in self.site_manager.list_all_sites():
if info["full_id"] == project_id:
return info
return None
async def _site_manager_health_check(self, project_id: str) -> dict[str, Any]:
"""
Health check for a site via SiteManager.
Creates a temporary plugin instance and calls its health_check() method,
falling back to a basic HTTP check if plugin instantiation fails.
"""
if not self.site_manager:
return {"healthy": False, "message": "SiteManager not available"}
# Look up site info by full_id (handles multi-word plugin types like wordpress_advanced)
site_info = self._find_site_info(project_id)
if not site_info:
return {"healthy": False, "message": f"Site not found: {project_id}"}
plugin_type = site_info["plugin_type"]
site_id = site_info["site_id"]
try:
config = self.site_manager.get_site_config(plugin_type, site_id)
except (KeyError, ValueError):
return {"healthy": False, "message": f"Site config not found: {project_id}"}
# Try to create a temporary plugin instance for a proper health check
try:
from plugins import registry as plugin_registry
config_dict = config.to_dict()
plugin_instance = plugin_registry.create_instance(plugin_type, site_id, config_dict)
return await plugin_instance.health_check()
except Exception as e:
logger.warning(
f"Could not create plugin instance for {project_id}, "
f"falling back to authenticated HTTP check: {e}"
)
# Fallback: authenticated health check for WordPress-based plugins
if plugin_type in ("wordpress", "wordpress_advanced", "woocommerce"):
try:
import aiohttp
if plugin_type == "woocommerce":
# WooCommerce uses consumer_key/consumer_secret
ck = getattr(config, "consumer_key", None) or ""
cs = getattr(config, "consumer_secret", None) or ""
# consumer_key/consumer_secret may be in model_extra
if not ck and hasattr(config, "model_extra"):
ck = (config.model_extra or {}).get("consumer_key", "")
cs = (config.model_extra or {}).get("consumer_secret", "")
auth = aiohttp.BasicAuth(ck, cs)
auth_check_url = f"{config.url}/wp-json/wc/v3/system_status"
else:
auth = aiohttp.BasicAuth(
config.username or "",
config.app_password or "",
)
auth_check_url = f"{config.url}/wp-json/wp/v2/users/me"
async with aiohttp.ClientSession(auth=auth) as session:
async with session.get(
auth_check_url,
timeout=aiohttp.ClientTimeout(total=10),
ssl=False,
) as resp:
auth_valid = resp.status == 200
basic_result = await self._basic_http_health_check(config.url, project_id)
basic_result["auth_valid"] = auth_valid
if not auth_valid:
basic_result["auth_warning"] = "Site accessible but credentials may be invalid"
return basic_result
except Exception:
pass
# Last resort: basic HTTP check
return await self._basic_http_health_check(config.url, project_id)
async def _basic_http_health_check(self, url: str | None, project_id: str) -> dict[str, Any]:
"""Basic HTTP health check as a last-resort fallback."""
import aiohttp
if not url:
return {"healthy": False, "message": "No URL configured for site"}
try:
async with aiohttp.ClientSession() as session:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=10), ssl=False
) as resp:
return {
"healthy": resp.status < 500,
"status_code": resp.status,
"message": f"HTTP {resp.status} from {url}",
}
except TimeoutError:
return {
"healthy": False,
"error_type": "timeout",
"message": f"Site at {url} did not respond within 10 seconds.",
}
except aiohttp.ClientConnectorDNSError:
host = url.split("://")[-1].split("/")[0]
return {
"healthy": False,
"error_type": "dns_failure",
"message": f"DNS resolution failed for '{host}'. Check the URL.",
}
except aiohttp.ClientConnectorError:
return {
"healthy": False,
"error_type": "connection_refused",
"message": f"Cannot connect to {url}. Server unreachable.",
}
except Exception as e:
return {"healthy": False, "error_type": "unknown", "message": f"Connection failed: {e}"}
async def check_all_projects_health(self, include_metrics: bool = True) -> dict[str, Any]:
"""
Check health of all projects.
Args:
include_metrics: Whether to include historical metrics
Returns:
Dictionary with overall health status
"""
health_statuses = {}
# Collect all known site IDs from SiteManager
all_project_ids = set()
if self.site_manager:
for site_info in self.site_manager.list_all_sites():
all_project_ids.add(site_info["full_id"])
# Check each project
for project_id in sorted(all_project_ids):
status = await self.check_project_health(project_id, include_metrics)
health_statuses[project_id] = status.to_dict()
# Calculate summary
total_projects = len(health_statuses)
healthy_projects = sum(1 for s in health_statuses.values() if s["healthy"])
unhealthy_projects = total_projects - healthy_projects
# Collect all alerts
all_alerts = []
for status in health_statuses.values():
all_alerts.extend(status.get("alerts", []))
return {
"timestamp": datetime.now(UTC).isoformat(),
"status": (
"healthy"
if unhealthy_projects == 0
else ("degraded" if healthy_projects > 0 else "unhealthy")
),
"summary": {
"total_projects": total_projects,
"healthy": healthy_projects,
"unhealthy": unhealthy_projects,
},
"alerts": all_alerts,
"projects": health_statuses,
}
def get_system_metrics(self) -> SystemMetrics:
"""
Get overall system metrics.
Returns:
SystemMetrics object
"""
# Calculate uptime
uptime_seconds = time.time() - self.start_time
# Calculate average response time
avg_response_time = (
sum(self.response_times) / len(self.response_times) if self.response_times else 0.0
)
# Calculate error rate
error_rate = (
(self.failed_requests / self.total_requests * 100) if self.total_requests > 0 else 0.0
)
# Calculate requests per minute
now = time.time()
one_minute_ago = now - 60
recent_requests = sum(1 for ts in self.request_timestamps if ts >= one_minute_ago)
return SystemMetrics(
uptime_seconds=uptime_seconds,
total_requests=self.total_requests,
successful_requests=self.successful_requests,
failed_requests=self.failed_requests,
average_response_time_ms=round(avg_response_time, 2),
error_rate_percent=round(error_rate, 2),
requests_per_minute=recent_requests,
)
def get_uptime(self) -> dict[str, Any]:
"""
Get system uptime information.
Returns:
Dictionary with uptime details
"""
uptime_seconds = time.time() - self.start_time
uptime_minutes = uptime_seconds / 60
uptime_hours = uptime_minutes / 60
uptime_days = uptime_hours / 24
return {
"start_time": datetime.fromtimestamp(self.start_time, tz=UTC).isoformat(),
"current_time": datetime.now(UTC).isoformat(),
"uptime_seconds": round(uptime_seconds, 2),
"uptime_minutes": round(uptime_minutes, 2),
"uptime_hours": round(uptime_hours, 2),
"uptime_days": round(uptime_days, 2),
"uptime_formatted": self._format_uptime(uptime_seconds),
}
def _format_uptime(self, seconds: float) -> str:
"""Format uptime as human-readable string."""
days = int(seconds // 86400)
hours = int((seconds % 86400) // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
parts = []
if days > 0:
parts.append(f"{days}d")
if hours > 0:
parts.append(f"{hours}h")
if minutes > 0:
parts.append(f"{minutes}m")
parts.append(f"{secs}s")
return " ".join(parts)
def export_metrics(self, output_path: str | None = None, format: str = "json") -> str:
"""
Export all metrics to file.
Args:
output_path: Output file path (default: logs/metrics_export.json)
format: Export format ("json" only for now)
Returns:
Path to exported file
"""
if output_path is None:
output_path = "logs/metrics_export.json"
# Prepare export data
export_data = {
"export_time": datetime.now(UTC).isoformat(),
"system_metrics": self.get_system_metrics().to_dict(),
"uptime": self.get_uptime(),
"projects": {},
}
# Add per-project metrics
for project_id in self.metrics_history.keys():
export_data["projects"][project_id] = {
"metrics": self.get_project_metrics(project_id, hours=24),
"history": [m.to_dict() for m in self.metrics_history[project_id]],
}
# Write to file
output_file = Path(output_path)
output_file.parent.mkdir(parents=True, exist_ok=True)
with open(output_file, "w", encoding="utf-8") as f:
json.dump(export_data, f, indent=2, ensure_ascii=False)
logger.info(f"Metrics exported to {output_path}")
return str(output_file)
def reset_metrics(self):
"""Reset all metrics (use with caution)."""
self.metrics_history.clear()
self.total_requests = 0
self.successful_requests = 0
self.failed_requests = 0
self.response_times.clear()
self.request_timestamps.clear()
self.latest_health_status.clear()
logger.warning("All metrics have been reset")
async def start_background_checks(self, interval_seconds: int = 60):
"""Start background health checks for all projects."""
if self._is_running:
return
self._is_running = True
logger.info(f"Starting background health checks every {interval_seconds} seconds")
async def _loop():
# Initial wait to let server start up fully
await asyncio.sleep(5)
while self._is_running:
try:
await self.check_all_projects_health(include_metrics=True)
except Exception as e:
logger.error(f"Error in background health check loop: {e}")
# Sleep interval, check _is_running periodically
for _ in range(interval_seconds):
if not self._is_running:
break
await asyncio.sleep(1)
self._bg_task = asyncio.create_task(_loop())
async def stop_background_checks(self):
"""Stop background health checks."""
self._is_running = False
if self._bg_task:
self._bg_task.cancel()
try:
await self._bg_task
except asyncio.CancelledError:
pass
self._bg_task = None
logger.info("Background health checks stopped")
# Singleton instance
_health_monitor: HealthMonitor | None = None
def get_health_monitor() -> HealthMonitor | None:
"""Get the global health monitor instance."""
return _health_monitor
def initialize_health_monitor(
audit_logger: AuditLogger | None = None,
site_manager: SiteManager | None = None,
**kwargs,
) -> HealthMonitor:
"""
Initialize the global health monitor.
Args:
audit_logger: Optional audit logger
site_manager: SiteManager for site discovery
**kwargs: Additional configuration options
Returns:
HealthMonitor instance
"""
global _health_monitor
_health_monitor = HealthMonitor(audit_logger=audit_logger, site_manager=site_manager, **kwargs)
return _health_monitor