feat(database): 添加用户角色权限系统及相关监控功能

- 创建用户表(users)包含基本信息和认证字段
- 创建角色表(roles)用于权限控制
- 创建权限表(permissions)定义系统权限
- 创建用户角色关联表(user_roles)建立用户与角色关系
- 创建角色权限关联表(role_permissions)建立角色与权限关系
- 创建迁移记录表(migrations)追踪数据库变更
- 添加AdminController提供管理员面板功能
- 实现系统监控、配置管理、缓存清理等功能
- 添加AOP切面编程支持的各种通知类型
- 实现告警管理AlertManager支持多渠道告警
- 添加文档注解接口规范
This commit is contained in:
Lawson
2026-04-08 17:00:28 +08:00
commit 2782d765fb
270 changed files with 107192 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
{
"name": "fendx/monitor",
"description": "FendxPHP Monitor Module - 性能监控、指标收集、系统监控",
"type": "library",
"license": "MIT",
"authors": [
{
"name": "Lawson",
"email": "lawson@fendx.cn"
}
],
"require": {
"php": ">=8.1",
"fendx/common": "^1.0",
"fendx/core": "^1.0"
},
"autoload": {
"psr-4": {
"Fendx\\Monitor\\": "src/"
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

View File

@@ -0,0 +1,408 @@
<?php
declare(strict_types=1);
namespace Fendx\Monitor\Alert;
use Fendx\Log\Logger;
final class AlertManager
{
private static array $alerts = [];
private static array $config = [];
private static array $channels = [];
private static bool $enabled = true;
public static function initialize(array $config): void
{
self::$config = array_merge([
'enabled' => true,
'max_alerts' => 500,
'retention_period' => 7200,
'channels' => ['log'],
'thresholds' => [
'error_rate' => 0.05,
'memory_usage' => 0.9,
'disk_usage' => 0.95,
'response_time' => 2.0,
'critical_errors' => 5
],
'cooldown' => [
'error_rate' => 300,
'memory_usage' => 600,
'disk_usage' => 600,
'response_time' => 300,
'critical_errors' => 1800
]
], $config);
self::$enabled = self::$config['enabled'];
self::initializeChannels();
}
public static function triggerAlert(string $type, string $message, array $context = [], string $severity = 'warning'): void
{
if (!self::$enabled) {
return;
}
$alert = [
'id' => uniqid('alert_', true),
'type' => $type,
'message' => $message,
'context' => $context,
'severity' => $severity,
'timestamp' => microtime(true),
'datetime' => date('Y-m-d H:i:s'),
'status' => 'active',
'acknowledged' => false,
'acknowledged_by' => null,
'acknowledged_at' => null,
'resolved' => false,
'resolved_at' => null
];
self::$alerts[] = $alert;
self::cleanupOldAlerts();
// 发送告警到各个渠道
self::sendToChannels($alert);
// 记录告警
Logger::warning("Alert triggered: {$type} - {$message}", $alert);
}
public static function checkErrorRate(float $errorRate, int $totalErrors, int $timeWindow): void
{
if ($errorRate > self::$config['thresholds']['error_rate']) {
self::triggerAlert('error_rate',
"High error rate detected: " . round($errorRate * 100, 2) . "%",
[
'error_rate' => $errorRate,
'total_errors' => $totalErrors,
'time_window' => $timeWindow,
'threshold' => self::$config['thresholds']['error_rate']
],
$errorRate > 0.1 ? 'critical' : 'warning'
);
}
}
public static function checkMemoryUsage(float $usagePercent, int $usedBytes, int $totalBytes): void
{
if ($usagePercent > self::$config['thresholds']['memory_usage']) {
self::triggerAlert('memory_usage',
"High memory usage: " . round($usagePercent * 100, 2) . "%",
[
'usage_percent' => $usagePercent,
'used_bytes' => $usedBytes,
'total_bytes' => $totalBytes,
'threshold' => self::$config['thresholds']['memory_usage']
],
$usagePercent > 0.95 ? 'critical' : 'warning'
);
}
}
public static function checkDiskUsage(string $path, float $usagePercent, int $usedBytes, int $totalBytes): void
{
if ($usagePercent > self::$config['thresholds']['disk_usage']) {
self::triggerAlert('disk_usage',
"Low disk space on {$path}: " . round($usagePercent * 100, 2) . "% used",
[
'path' => $path,
'usage_percent' => $usagePercent,
'used_bytes' => $usedBytes,
'total_bytes' => $totalBytes,
'threshold' => self::$config['thresholds']['disk_usage']
],
$usagePercent > 0.98 ? 'critical' : 'warning'
);
}
}
public static function checkResponseTime(string $endpoint, float $avgTime, float $threshold = null): void
{
$threshold = $threshold ?? self::$config['thresholds']['response_time'];
if ($avgTime > $threshold) {
self::triggerAlert('response_time',
"Slow response time for {$endpoint}: " . round($avgTime, 3) . "s",
[
'endpoint' => $endpoint,
'avg_time' => $avgTime,
'threshold' => $threshold
],
$avgTime > $threshold * 2 ? 'critical' : 'warning'
);
}
}
public static function checkCriticalErrors(int $criticalCount, int $timeWindow): void
{
if ($criticalCount >= self::$config['thresholds']['critical_errors']) {
self::triggerAlert('critical_errors',
"Multiple critical errors detected: {$criticalCount} in {$timeWindow}s",
[
'critical_count' => $criticalCount,
'time_window' => $timeWindow,
'threshold' => self::$config['thresholds']['critical_errors']
],
'critical'
);
}
}
public static function checkServiceDown(string $service, array $context = []): void
{
self::triggerAlert('service_down',
"Service unavailable: {$service}",
array_merge($context, ['service' => $service]),
'critical'
);
}
public static function acknowledgeAlert(string $alertId, string $acknowledgedBy): bool
{
foreach (self::$alerts as &$alert) {
if ($alert['id'] === $alertId && $alert['status'] === 'active') {
$alert['acknowledged'] = true;
$alert['acknowledged_by'] = $acknowledgedBy;
$alert['acknowledged_at'] = microtime(true);
Logger::info("Alert acknowledged: {$alertId} by {$acknowledgedBy}");
return true;
}
}
return false;
}
public static function resolveAlert(string $alertId): bool
{
foreach (self::$alerts as &$alert) {
if ($alert['id'] === $alertId && $alert['status'] === 'active') {
$alert['status'] = 'resolved';
$alert['resolved_at'] = microtime(true);
Logger::info("Alert resolved: {$alertId}");
return true;
}
}
return false;
}
public static function getActiveAlerts(): array
{
return array_filter(self::$alerts, fn($alert) => $alert['status'] === 'active');
}
public static function getAlerts(array $filters = []): array
{
$alerts = self::$alerts;
// 应用过滤器
if (!empty($filters)) {
$alerts = array_filter($alerts, function($alert) use ($filters) {
foreach ($filters as $key => $value) {
if (isset($alert[$key]) && $alert[$key] !== $value) {
return false;
}
}
return true;
});
}
// 按时间倒序排列
usort($alerts, function($a, $b) {
return $b['timestamp'] <=> $a['timestamp'];
});
return array_values($alerts);
}
public static function getAlertStatistics(): array
{
$stats = [
'total_alerts' => count(self::$alerts),
'active_alerts' => 0,
'acknowledged_alerts' => 0,
'resolved_alerts' => 0,
'by_type' => [],
'by_severity' => [],
'by_hour' => [],
'recent_alerts' => 0
];
$now = time();
$oneHourAgo = $now - 3600;
foreach (self::$alerts as $alert) {
// 状态统计
if ($alert['status'] === 'active') {
$stats['active_alerts']++;
}
if ($alert['acknowledged']) {
$stats['acknowledged_alerts']++;
}
if ($alert['resolved']) {
$stats['resolved_alerts']++;
}
// 按类型统计
$type = $alert['type'];
$stats['by_type'][$type] = ($stats['by_type'][$type] ?? 0) + 1;
// 按严重程度统计
$severity = $alert['severity'];
$stats['by_severity'][$severity] = ($stats['by_severity'][$severity] ?? 0) + 1;
// 按小时统计
$hour = date('Y-m-d H:00', (int)$alert['timestamp']);
$stats['by_hour'][$hour] = ($stats['by_hour'][$hour] ?? 0) + 1;
// 最近告警
if ($alert['timestamp'] > $oneHourAgo) {
$stats['recent_alerts']++;
}
}
return $stats;
}
public static function clearAlerts(): void
{
self::$alerts = [];
}
public static function clearResolvedAlerts(): void
{
self::$alerts = array_filter(self::$alerts, fn($alert) => !$alert['resolved']);
}
public static function addChannel(string $name, callable $handler): void
{
self::$channels[$name] = $handler;
}
public static function removeChannel(string $name): void
{
unset(self::$channels[$name]);
}
private static function initializeChannels(): void
{
// 默认日志渠道
self::$channels['log'] = function($alert) {
$level = match ($alert['severity']) {
'critical' => 'critical',
'warning' => 'warning',
default => 'info'
};
Logger::$level("ALERT [{$alert['type']}]: {$alert['message']}", $alert);
};
// 邮件渠道(如果配置了)
if (isset(self::$config['email']) && self::$config['email']['enabled']) {
self::$channels['email'] = [self::class, 'sendEmailAlert'];
}
// 钉钉渠道(如果配置了)
if (isset(self::$config['dingtalk']) && self::$config['dingtalk']['enabled']) {
self::$channels['dingtalk'] = [self::class, 'sendDingTalkAlert'];
}
// Slack渠道如果配置了
if (isset(self::$config['slack']) && self::$config['slack']['enabled']) {
self::$channels['slack'] = [self::class, 'sendSlackAlert'];
}
}
private static function sendToChannels(array $alert): void
{
foreach (self::$config['channels'] as $channelName) {
if (isset(self::$channels[$channelName])) {
try {
self::$channels[$channelName]($alert);
} catch (\Throwable $e) {
Logger::error("Failed to send alert to channel {$channelName}: " . $e->getMessage());
}
}
}
}
private static function sendEmailAlert(array $alert): void
{
$config = self::$config['email'];
$to = $config['to'] ?? [];
$subject = "[Fendx Alert] {$alert['type']} - {$alert['severity']}";
$message = self::formatAlertMessage($alert, 'email');
// 这里应该使用实际的邮件发送库
Logger::info("Email alert would be sent to: " . implode(', ', $to));
}
private static function sendDingTalkAlert(array $alert): void
{
$config = self::$config['dingtalk'];
$webhook = $config['webhook'] ?? '';
if (empty($webhook)) {
return;
}
$message = self::formatAlertMessage($alert, 'dingtalk');
// 这里应该使用实际的HTTP客户端发送到钉钉
Logger::info("DingTalk alert would be sent: {$message}");
}
private static function sendSlackAlert(array $alert): void
{
$config = self::$config['slack'];
$webhook = $config['webhook'] ?? '';
if (empty($webhook)) {
return;
}
$message = self::formatAlertMessage($alert, 'slack');
// 这里应该使用实际的HTTP客户端发送到Slack
Logger::info("Slack alert would be sent: {$message}");
}
private static function formatAlertMessage(array $alert, string $format): string
{
$timestamp = date('Y-m-d H:i:s', (int)$alert['timestamp']);
$severity = strtoupper($alert['severity']);
return match ($format) {
'email' => "
<h2>[{$severity}] {$alert['type']}</h2>
<p><strong>Message:</strong> {$alert['message']}</p>
<p><strong>Time:</strong> {$timestamp}</p>
<p><strong>Context:</strong> <pre>" . json_encode($alert['context'], JSON_PRETTY_PRINT) . "</pre></p>
",
'dingtalk' => "{$severity}{$alert['type']}\n{$alert['message']}\n时间: {$timestamp}",
'slack' => "*[{$severity}] {$alert['type']}*\n{$alert['message']}\nTime: {$timestamp}",
default => "[{$severity}] {$alert['type']}: {$alert['message']} at {$timestamp}"
};
}
private static function cleanupOldAlerts(): void
{
if (count(self::$alerts) <= self::$config['max_alerts']) {
return;
}
// 按时间排序,保留最新的告警
usort(self::$alerts, function($a, $b) {
return $a['timestamp'] <=> $b['timestamp'];
});
self::$alerts = array_slice(self::$alerts, -self::$config['max_alerts']);
}
}

View File

@@ -0,0 +1,661 @@
<?php
declare(strict_types=1);
namespace Fendx\Monitor\Analyzer;
use Fendx\Log\Logger;
final class LogAnalyzer
{
private static array $config = [];
private static bool $enabled = true;
private static array $logIndex = [];
private static array $patterns = [];
public static function initialize(array $config): void
{
self::$config = array_merge([
'enabled' => true,
'log_paths' => [dirname(__DIR__, 4) . '/runtime/logs'],
'max_file_size' => 50 * 1024 * 1024, // 50MB
'index_cache_ttl' => 300, // 5分钟
'search_limit' => 1000,
'real_time' => true,
'patterns' => [
'error' => '/\b(ERROR|FATAL|CRITICAL)\b/i',
'warning' => '/\b(WARNING|WARN)\b/i',
'exception' => '/\b(Exception|Throwable)\b/i',
'sql' => '/\b(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER)\b/i',
'slow_query' => '/slow.*query|query.*slow/i',
'memory' => '/memory|Memory/i',
'performance' => '/performance|slow|timeout/i',
'security' => '/security|auth|login|logout|unauthorized/i'
]
], $config);
self::$enabled = self::$config['enabled'];
self::$patterns = self::$config['patterns'];
if (self::$enabled) {
self::buildLogIndex();
}
}
public static function search(array $criteria = []): array
{
if (!self::$enabled) {
return [];
}
$results = [];
$limit = $criteria['limit'] ?? self::$config['search_limit'];
$offset = $criteria['offset'] ?? 0;
// 构建搜索条件
$filters = self::buildFilters($criteria);
$sortOrder = $criteria['sort'] ?? 'desc';
$sortBy = $criteria['sort_by'] ?? 'timestamp';
// 搜索日志文件
foreach (self::$config['log_paths'] as $logPath) {
if (!is_dir($logPath)) {
continue;
}
$files = glob($logPath . '/*.log');
foreach ($files as $file) {
if (filesize($file) > self::$config['max_file_size']) {
continue;
}
$fileResults = self::searchFile($file, $filters, $sortBy, $sortOrder, $limit);
$results = array_merge($results, $fileResults);
if (count($results) >= $limit + $offset) {
break 2;
}
}
}
// 应用排序和分页
$results = self::sortResults($results, $sortBy, $sortOrder);
$results = array_slice($results, $offset, $limit);
return [
'logs' => $results,
'total' => count($results),
'criteria' => $criteria,
'search_time' => microtime(true)
];
}
public static function aggregate(array $criteria = []): array
{
if (!self::$enabled) {
return [];
}
$timeRange = $criteria['time_range'] ?? '1h';
$groupBy = $criteria['group_by'] ?? 'level';
$filters = self::buildFilters($criteria);
$aggregation = [
'time_range' => $timeRange,
'group_by' => $groupBy,
'total_logs' => 0,
'groups' => [],
'timeline' => [],
'top_errors' => [],
'patterns' => []
];
// 解析时间范围
$timeRangeSeconds = self::parseTimeRange($timeRange);
$startTime = time() - $timeRangeSeconds;
// 统计日志
foreach (self::$config['log_paths'] as $logPath) {
if (!is_dir($logPath)) {
continue;
}
$files = glob($logPath . '/*.log');
foreach ($files as $file) {
$fileStats = self::analyzeFile($file, $filters, $startTime, $groupBy);
$aggregation['total_logs'] += $fileStats['total_logs'];
$aggregation['groups'] = array_merge_recursive($aggregation['groups'], $fileStats['groups']);
$aggregation['timeline'] = array_merge($aggregation['timeline'], $fileStats['timeline']);
$aggregation['top_errors'] = array_merge($aggregation['top_errors'], $fileStats['top_errors']);
$aggregation['patterns'] = array_merge_recursive($aggregation['patterns'], $fileStats['patterns']);
}
}
// 处理聚合结果
$aggregation['timeline'] = self::processTimeline($aggregation['timeline'], $timeRange);
$aggregation['top_errors'] = self::getTopItems($aggregation['top_errors'], 10);
$aggregation['patterns'] = self::processPatterns($aggregation['patterns']);
return $aggregation;
}
public static function getLogFiles(): array
{
if (!self::$enabled) {
return [];
}
$files = [];
foreach (self::$config['log_paths'] as $logPath) {
if (!is_dir($logPath)) {
continue;
}
$logFiles = glob($logPath . '/*.log');
foreach ($logFiles as $file) {
$stat = stat($file);
$files[] = [
'name' => basename($file),
'path' => $file,
'size' => $stat['size'],
'size_formatted' => self::formatBytes($stat['size']),
'modified' => $stat['mtime'],
'modified_formatted' => date('Y-m-d H:i:s', $stat['mtime']),
'lines' => self::countLines($file)
];
}
}
// 按修改时间排序
usort($files, function($a, $b) {
return $b['modified'] <=> $a['modified'];
});
return $logs;
}
public static function getLogContent(string $file, int $lines = 100, int $offset = 0): array
{
if (!self::$enabled || !file_exists($file)) {
return [];
}
$content = [];
$handle = fopen($file, 'r');
$currentLine = 0;
$lineCount = 0;
if ($handle) {
while (($line = fgets($handle)) !== false && $lineCount < $lines + $offset) {
$currentLine++;
if ($currentLine > $offset) {
$parsed = self::parseLogLine($line);
if ($parsed) {
$content[] = $parsed;
$lineCount++;
}
}
}
fclose($handle);
}
return [
'content' => $content,
'total_lines' => self::countLines($file),
'offset' => $offset,
'limit' => $lines,
'file' => $file
];
}
public static function exportLogs(array $criteria = [], string $format = 'json'): string
{
$results = self::search($criteria);
return match ($format) {
'json' => json_encode($results, JSON_PRETTY_PRINT),
'csv' => self::exportToCsv($results['logs']),
'txt' => self::exportToText($results['logs']),
default => json_encode($results)
};
}
public static function getRealTimeLogs(int $tail = 100): array
{
if (!self::$enabled || !self::$config['real_time']) {
return [];
}
$logs = [];
foreach (self::$config['log_paths'] as $logPath) {
if (!is_dir($logPath)) {
continue;
}
$files = glob($logPath . '/*.log');
foreach ($files as $file) {
$fileLogs = self::tailFile($file, $tail);
$logs = array_merge($logs, $fileLogs);
}
}
// 按时间排序并限制数量
usort($logs, function($a, $b) {
return $b['timestamp'] <=> $a['timestamp'];
});
return array_slice($logs, 0, $tail);
}
private static function buildLogIndex(): void
{
self::$logIndex = [];
foreach (self::$config['log_paths'] as $logPath) {
if (!is_dir($logPath)) {
continue;
}
$files = glob($logPath . '/*.log');
foreach ($files as $file) {
self::$logIndex[] = [
'file' => $file,
'size' => filesize($file),
'modified' => filemtime($file)
];
}
}
}
private static function buildFilters(array $criteria): array
{
$filters = [];
if (isset($criteria['level'])) {
$filters['level'] = $criteria['level'];
}
if (isset($criteria['message'])) {
$filters['message'] = $criteria['message'];
}
if (isset($criteria['trace_id'])) {
$filters['trace_id'] = $criteria['trace_id'];
}
if (isset($criteria['start_time'])) {
$filters['start_time'] = is_string($criteria['start_time'])
? strtotime($criteria['start_time'])
: $criteria['start_time'];
}
if (isset($criteria['end_time'])) {
$filters['end_time'] = is_string($criteria['end_time'])
? strtotime($criteria['end_time'])
: $criteria['end_time'];
}
if (isset($criteria['pattern'])) {
$filters['pattern'] = $criteria['pattern'];
}
return $filters;
}
private static function searchFile(string $file, array $filters, string $sortBy, string $sortOrder, int $limit): array
{
$results = [];
$handle = fopen($file, 'r');
if (!$handle) {
return $results;
}
while (($line = fgets($handle)) !== false) {
$parsed = self::parseLogLine($line);
if (!$parsed) {
continue;
}
if (self::matchesFilters($parsed, $filters)) {
$results[] = $parsed;
if (count($results) >= $limit) {
break;
}
}
}
fclose($handle);
return $results;
}
private static function parseLogLine(string $line): ?array
{
// 基础日志格式解析
$patterns = [
// 标准格式: [2024-01-01 12:00:00] LEVEL.MESSAGE
'/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] (\w+)\.(.+)$/',
// JSON格式
'/^({.*})$/',
// 简单格式: 2024-01-01 12:00:00 LEVEL MESSAGE
'/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (\w+) (.+)$/'
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, trim($line), $matches)) {
if (count($matches) === 2 && json_decode($matches[1])) {
// JSON格式
$data = json_decode($matches[1], true);
return array_merge($data, [
'raw' => $line,
'file' => $file ?? ''
]);
} else {
// 标准格式
return [
'timestamp' => strtotime($matches[1]),
'datetime' => $matches[1],
'level' => strtoupper($matches[2]),
'message' => $matches[3],
'raw' => $line,
'file' => $file ?? ''
];
}
}
}
return null;
}
private static function matchesFilters(array $log, array $filters): bool
{
foreach ($filters as $key => $value) {
switch ($key) {
case 'level':
if (strtoupper($log['level'] ?? '') !== strtoupper($value)) {
return false;
}
break;
case 'message':
if (stripos($log['message'] ?? '', $value) === false) {
return false;
}
break;
case 'trace_id':
if (stripos($log['raw'] ?? '', $value) === false) {
return false;
}
break;
case 'start_time':
if (($log['timestamp'] ?? 0) < $value) {
return false;
}
break;
case 'end_time':
if (($log['timestamp'] ?? 0) > $value) {
return false;
}
break;
case 'pattern':
if (!preg_match($value, $log['raw'] ?? '')) {
return false;
}
break;
}
}
return true;
}
private static function sortResults(array $results, string $sortBy, string $sortOrder): array
{
usort($results, function($a, $b) use ($sortBy, $sortOrder) {
$comparison = 0;
switch ($sortBy) {
case 'timestamp':
$comparison = ($a['timestamp'] ?? 0) <=> ($b['timestamp'] ?? 0);
break;
case 'level':
$levelOrder = ['DEBUG' => 0, 'INFO' => 1, 'WARNING' => 2, 'ERROR' => 3, 'CRITICAL' => 4];
$comparison = ($levelOrder[$a['level'] ?? 'INFO'] ?? 1) <=> ($levelOrder[$b['level'] ?? 'INFO'] ?? 1);
break;
case 'message':
$comparison = strcmp($a['message'] ?? '', $b['message'] ?? '');
break;
}
return $sortOrder === 'desc' ? -$comparison : $comparison;
});
return $results;
}
private static function analyzeFile(string $file, array $filters, int $startTime, string $groupBy): array
{
$stats = [
'total_logs' => 0,
'groups' => [],
'timeline' => [],
'top_errors' => [],
'patterns' => []
];
$handle = fopen($file, 'r');
if (!$handle) {
return $stats;
}
while (($line = fgets($handle)) !== false) {
$parsed = self::parseLogLine($line);
if (!$parsed) {
continue;
}
if (($parsed['timestamp'] ?? 0) < $startTime) {
continue;
}
if (!self::matchesFilters($parsed, $filters)) {
continue;
}
$stats['total_logs']++;
// 按组统计
$groupKey = self::getGroupKey($parsed, $groupBy);
if (!isset($stats['groups'][$groupKey])) {
$stats['groups'][$groupKey] = 0;
}
$stats['groups'][$groupKey]++;
// 时间线统计
$timeKey = date('Y-m-d H:00', $parsed['timestamp']);
if (!isset($stats['timeline'][$timeKey])) {
$stats['timeline'][$timeKey] = 0;
}
$stats['timeline'][$timeKey]++;
// 错误统计
if (in_array($parsed['level'] ?? '', ['ERROR', 'CRITICAL'])) {
$errorMsg = substr($parsed['message'] ?? '', 0, 100);
if (!isset($stats['top_errors'][$errorMsg])) {
$stats['top_errors'][$errorMsg] = 0;
}
$stats['top_errors'][$errorMsg]++;
}
// 模式匹配
foreach (self::$patterns as $patternName => $pattern) {
if (preg_match($pattern, $parsed['raw'] ?? '')) {
if (!isset($stats['patterns'][$patternName])) {
$stats['patterns'][$patternName] = 0;
}
$stats['patterns'][$patternName]++;
}
}
}
fclose($handle);
return $stats;
}
private static function getGroupKey(array $log, string $groupBy): string
{
return match ($groupBy) {
'level' => $log['level'] ?? 'UNKNOWN',
'hour' => date('Y-m-d H:00', $log['timestamp'] ?? time()),
'date' => date('Y-m-d', $log['timestamp'] ?? time()),
default => 'default'
};
}
private static function parseTimeRange(string $range): int
{
return match ($range) {
'5m' => 5 * 60,
'15m' => 15 * 60,
'30m' => 30 * 60,
'1h' => 60 * 60,
'6h' => 6 * 60 * 60,
'12h' => 12 * 60 * 60,
'24h' => 24 * 60 * 60,
'7d' => 7 * 24 * 60 * 60,
'30d' => 30 * 24 * 60 * 60,
default => 60 * 60
};
}
private static function processTimeline(array $timeline, string $timeRange): array
{
$interval = match ($timeRange) {
'5m', '15m', '30m' => '5m',
'1h' => '10m',
'6h', '12h' => '30m',
'24h' => '1h',
'7d' => '6h',
'30d' => '1d',
default => '1h'
};
ksort($timeline);
return $timeline;
}
private static function getTopItems(array $items, int $limit): array
{
arsort($items);
return array_slice($items, 0, $limit, true);
}
private static function processPatterns(array $patterns): array
{
arsort($patterns);
return $patterns;
}
private static function countLines(string $file): int
{
$lines = 0;
$handle = fopen($file, 'r');
if ($handle) {
while (!feof($handle)) {
fgets($handle);
$lines++;
}
fclose($handle);
}
return $lines;
}
private static function tailFile(string $file, int $lines): array
{
$result = [];
$handle = fopen($file, 'r');
if (!$handle) {
return $result;
}
// 移动到文件末尾
fseek($handle, 0, SEEK_END);
$pos = ftell($handle);
$lineCount = 0;
// 向前读取
while ($pos > 0 && $lineCount < $lines) {
$pos--;
fseek($handle, $pos);
$char = fgetc($handle);
if ($char === "\n") {
$lineCount++;
if ($lineCount <= $lines) {
$currentPos = ftell($handle);
$line = fgets($handle);
$parsed = self::parseLogLine($line);
if ($parsed) {
$result[] = $parsed;
}
fseek($handle, $currentPos);
}
}
}
fclose($handle);
return array_reverse($result);
}
private static function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, 2) . ' ' . $units[$pow];
}
private static function exportToCsv(array $logs): string
{
$csv = "Timestamp,Level,Message,File\n";
foreach ($logs as $log) {
$csv .= sprintf(
"%s,%s,%s,%s\n",
$log['datetime'] ?? '',
$log['level'] ?? '',
str_replace(["\n", "\r", ","], [" ", " ", ";"], $log['message'] ?? ''),
basename($log['file'] ?? '')
);
}
return $csv;
}
private static function exportToText(array $logs): string
{
$text = "";
foreach ($logs as $log) {
$text .= sprintf(
"[%s] %s.%s\n",
$log['datetime'] ?? '',
$log['level'] ?? '',
$log['message'] ?? ''
);
}
return $text;
}
}

View File

@@ -0,0 +1,371 @@
<?php
declare(strict_types=1);
namespace Fendx\Monitor\Auth;
use Fendx\Web\Interceptor\Interceptor;
use Fendx\Web\Request\Request;
class PermissionInterceptor implements Interceptor
{
private array $routePermissions = [
'/monitor/health' => ['monitor.view'],
'/monitor/health/*' => ['monitor.view'],
'/monitor/metrics' => ['metrics.view'],
'/monitor/alerts' => ['alerts.view'],
'/monitor/alerts/*' => ['alerts.view'],
'/monitor/errors' => ['errors.view'],
'/monitor/logs/search' => ['logs.search'],
'/monitor/logs/export' => ['logs.export'],
'/monitor/logs/content' => ['logs.view'],
'/monitor/logs/realtime' => ['logs.view'],
'/monitor/logs/charts/*' => ['logs.view'],
'/admin/dashboard' => ['dashboard.view'],
'/admin/system/info' => ['system.view'],
'/admin/system/status' => ['system.view'],
'/admin/config' => ['config.view'],
'/admin/config' => ['config.edit'], // POST
'/admin/cache/clear' => ['system.cache_clear'],
'/admin/logs/clear' => ['logs.clear'],
'/admin/users' => ['users.view'],
'/admin/users/*/ban' => ['users.ban'],
'/admin/users/*/unban' => ['users.ban'],
'/admin/permissions' => ['users.view'],
'/admin/audit' => ['audit.view']
];
private array $publicRoutes = [
'/monitor/health', // 健康检查通常是公开的
'/monitor/metrics/prometheus' // Prometheus监控指标
];
public function preHandle(Request $request): mixed
{
$path = $request->path();
$method = $request->method();
// 检查是否为公开路由
if ($this->isPublicRoute($path)) {
return null;
}
// 获取所需权限
$requiredPermissions = $this->getRequiredPermissions($path, $method);
if (empty($requiredPermissions)) {
return null;
}
// 检查用户是否已登录
$userId = $this->getCurrentUserId();
if (!$userId) {
return $this->createUnauthorizedResponse();
}
// 检查权限
if (!PermissionManager::hasAnyPermission($requiredPermissions, $userId)) {
return $this->createForbiddenResponse($requiredPermissions);
}
return null;
}
public function postHandle(Request $request, mixed $result): mixed
{
return $result;
}
public function afterCompletion(Request $request, ?\Throwable $exception): void
{
// 记录敏感操作的审计日志
$this->logSensitiveOperation($request);
}
private function isPublicRoute(string $path): bool
{
foreach ($this->publicRoutes as $publicRoute) {
if ($this->matchRoute($path, $publicRoute)) {
return true;
}
}
return false;
}
private function getRequiredPermissions(string $path, string $method): array
{
$permissions = [];
// 精确匹配
if (isset($this->routePermissions[$path])) {
$permissions = $this->routePermissions[$path];
}
// 通配符匹配
foreach ($this->routePermissions as $route => $perms) {
if ($this->matchRoute($path, $route)) {
$permissions = array_merge($permissions, $perms);
}
}
// 特殊处理POST请求通常需要编辑权限
if ($method === 'POST') {
$permissions = array_map(function($perm) {
return str_replace('.view', '.edit', $perm);
}, $permissions);
}
return array_unique($permissions);
}
private function matchRoute(string $path, string $pattern): bool
{
// 精确匹配
if ($path === $pattern) {
return true;
}
// 通配符匹配
if (str_contains($pattern, '*')) {
$regex = str_replace('*', '.*', preg_quote($pattern, '/'));
return preg_match('/^' . $regex . '$/', $path);
}
// 参数匹配
if (str_contains($pattern, '/')) {
$patternParts = explode('/', $pattern);
$pathParts = explode('/', $path);
if (count($patternParts) !== count($pathParts)) {
return false;
}
foreach ($patternParts as $i => $part) {
if ($part !== '*' && $part !== $pathParts[$i]) {
return false;
}
}
return true;
}
return false;
}
private function getCurrentUserId(): ?int
{
// 从session获取用户ID
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
return $_SESSION['user_id'] ?? null;
}
private function createUnauthorizedResponse(): array
{
http_response_code(401);
return [
'code' => 401,
'message' => 'Unauthorized - Please login first',
'data' => null
];
}
private function createForbiddenResponse(array $requiredPermissions): array
{
http_response_code(403);
return [
'code' => 403,
'message' => 'Forbidden - Insufficient permissions',
'data' => [
'required_permissions' => $requiredPermissions,
'user_permissions' => PermissionManager::getUserPermissions()
]
];
}
private function logSensitiveOperation(Request $request): void
{
$path = $request->path();
$method = $request->method();
// 定义敏感操作
$sensitiveOperations = [
'POST:/admin/config',
'POST:/admin/cache/clear',
'POST:/admin/logs/clear',
'POST:/admin/users/*/ban',
'POST:/admin/users/*/unban',
'POST:/monitor/alerts/*/acknowledge',
'POST:/monitor/alerts/*/resolve'
];
$operationKey = $method . ':' . $path;
if (in_array($operationKey, $sensitiveOperations) ||
$this->matchesSensitivePattern($operationKey)) {
$this->writeAuditLog($request, $operationKey);
}
}
private function matchesSensitivePattern(string $operationKey): bool
{
$patterns = [
'POST:/admin/users/*',
'POST:/monitor/alerts/*',
'POST:/monitor/logs/clear'
];
foreach ($patterns as $pattern) {
if ($this->matchOperation($operationKey, $pattern)) {
return true;
}
}
return false;
}
private function matchOperation(string $operation, string $pattern): bool
{
if (str_contains($pattern, '*')) {
$regex = str_replace('*', '.*', preg_quote($pattern, '/'));
return preg_match('/^' . $regex . '$/', $operation);
}
return $operation === $pattern;
}
private function writeAuditLog(Request $request, string $operation): void
{
$userId = $this->getCurrentUserId();
if (!$userId) {
return;
}
$log = [
'user_id' => $userId,
'operation' => $operation,
'path' => $request->path(),
'method' => $request->method(),
'ip' => $request->ip(),
'user_agent' => $request->header('User-Agent') ?? 'unknown',
'request_data' => $this->sanitizeRequestData($request->all()),
'timestamp' => microtime(true)
];
// 这里应该保存到审计日志表
error_log('Audit log: ' . json_encode($log));
}
private function sanitizeRequestData(array $data): array
{
$sensitiveKeys = ['password', 'token', 'secret', 'key', 'auth'];
foreach ($data as $key => $value) {
foreach ($sensitiveKeys as $sensitive) {
if (stripos($key, $sensitive) !== false) {
$data[$key] = '[REDACTED]';
break;
}
}
if (is_array($value)) {
$data[$key] = $this->sanitizeRequestData($value);
}
}
return $data;
}
public function addRoutePermission(string $route, array $permissions): void
{
$this->routePermissions[$route] = $permissions;
}
public function removeRoutePermission(string $route): void
{
unset($this->routePermissions[$route]);
}
public function addPublicRoute(string $route): void
{
$this->publicRoutes[] = $route;
}
public function removePublicRoute(string $route): void
{
$key = array_search($route, $this->publicRoutes);
if ($key !== false) {
unset($this->publicRoutes[$key]);
$this->publicRoutes = array_values($this->publicRoutes);
}
}
public function getRoutePermissions(): array
{
return $this->routePermissions;
}
public function getPublicRoutes(): array
{
return $this->publicRoutes;
}
public function checkRoutePermission(string $path, string $method, ?int $userId = null): bool
{
$requiredPermissions = $this->getRequiredPermissions($path, $method);
if (empty($requiredPermissions)) {
return true;
}
if (!$userId) {
return false;
}
return PermissionManager::hasAnyPermission($requiredPermissions, $userId);
}
public function getUserAccessibleRoutes(?int $userId = null): array
{
$accessibleRoutes = [];
foreach ($this->routePermissions as $route => $permissions) {
if ($this->isPublicRoute($route)) {
$accessibleRoutes[] = $route;
} elseif ($userId && PermissionManager::hasAnyPermission($permissions, $userId)) {
$accessibleRoutes[] = $route;
}
}
return $accessibleRoutes;
}
public function exportRoutePermissions(): string
{
return json_encode([
'route_permissions' => $this->routePermissions,
'public_routes' => $this->publicRoutes,
'export_time' => microtime(true)
], JSON_PRETTY_PRINT);
}
public function importRoutePermissions(string $data): bool
{
try {
$import = json_decode($data, true);
if (!isset($import['route_permissions']) || !isset($import['public_routes'])) {
return false;
}
$this->routePermissions = $import['route_permissions'];
$this->publicRoutes = $import['public_routes'];
return true;
} catch (\Exception $e) {
return false;
}
}
}

View File

@@ -0,0 +1,517 @@
<?php
declare(strict_types=1);
namespace Fendx\Monitor\Auth;
final class PermissionManager
{
private static array $permissions = [
// 仪表盘权限
'dashboard.view' => '查看仪表盘',
// 监控权限
'monitor.view' => '查看监控数据',
'monitor.manage' => '管理监控设置',
// 健康检查权限
'health.view' => '查看健康检查',
'health.manage' => '管理健康检查',
// 日志权限
'logs.view' => '查看日志',
'logs.search' => '搜索日志',
'logs.export' => '导出日志',
'logs.clear' => '清理日志',
// 错误追踪权限
'errors.view' => '查看错误',
'errors.manage' => '管理错误设置',
// 告警权限
'alerts.view' => '查看告警',
'alerts.acknowledge' => '确认告警',
'alerts.resolve' => '解决告警',
'alerts.manage' => '管理告警设置',
// 指标权限
'metrics.view' => '查看指标',
'metrics.export' => '导出指标',
'metrics.manage' => '管理指标设置',
// 用户管理权限
'users.view' => '查看用户',
'users.manage' => '管理用户',
'users.ban' => '封禁用户',
// 配置权限
'config.view' => '查看配置',
'config.edit' => '编辑配置',
// 系统权限
'system.view' => '查看系统信息',
'system.cache_clear' => '清理缓存',
'system.restart' => '重启服务',
// 审计权限
'audit.view' => '查看审计日志'
];
private static array $roles = [
'super_admin' => [
'name' => '超级管理员',
'permissions' => ['*'] // 所有权限
],
'admin' => [
'name' => '管理员',
'permissions' => [
'dashboard.view',
'monitor.view', 'monitor.manage',
'health.view', 'health.manage',
'logs.view', 'logs.search', 'logs.export', 'logs.clear',
'errors.view', 'errors.manage',
'alerts.view', 'alerts.acknowledge', 'alerts.resolve', 'alerts.manage',
'metrics.view', 'metrics.export', 'metrics.manage',
'users.view', 'users.manage', 'users.ban',
'config.view', 'config.edit',
'system.view', 'system.cache_clear',
'audit.view'
]
],
'operator' => [
'name' => '运维人员',
'permissions' => [
'dashboard.view',
'monitor.view',
'health.view',
'logs.view', 'logs.search',
'errors.view',
'alerts.view', 'alerts.acknowledge', 'alerts.resolve',
'metrics.view',
'config.view',
'system.view',
'audit.view'
]
],
'developer' => [
'name' => '开发人员',
'permissions' => [
'dashboard.view',
'monitor.view',
'health.view',
'logs.view', 'logs.search', 'logs.export',
'errors.view',
'alerts.view',
'metrics.view', 'metrics.export',
'config.view',
'system.view'
]
],
'viewer' => [
'name' => '只读用户',
'permissions' => [
'dashboard.view',
'monitor.view',
'health.view',
'logs.view',
'errors.view',
'alerts.view',
'metrics.view',
'config.view',
'system.view'
]
]
];
private static array $userPermissions = [];
private static bool $initialized = false;
public static function initialize(): void
{
if (self::$initialized) {
return;
}
// 从数据库或配置文件加载用户权限
self::loadUserPermissions();
self::$initialized = true;
}
public static function hasPermission(string $permission, ?int $userId = null): bool
{
self::initialize();
$userId = $userId ?? self::getCurrentUserId();
if (!$userId) {
return false;
}
// 检查用户权限
$userPerms = self::$userPermissions[$userId] ?? [];
// 超级权限
if (in_array('*', $userPerms)) {
return true;
}
// 直接权限
if (in_array($permission, $userPerms)) {
return true;
}
// 通配符权限
foreach ($userPerms as $perm) {
if (str_ends_with($perm, '*')) {
$prefix = substr($perm, 0, -1);
if (str_starts_with($permission, $prefix)) {
return true;
}
}
}
return false;
}
public static function hasAnyPermission(array $permissions, ?int $userId = null): bool
{
foreach ($permissions as $permission) {
if (self::hasPermission($permission, $userId)) {
return true;
}
}
return false;
}
public static function hasAllPermissions(array $permissions, ?int $userId = null): bool
{
foreach ($permissions as $permission) {
if (!self::hasPermission($permission, $userId)) {
return false;
}
}
return true;
}
public static function getUserPermissions(?int $userId = null): array
{
self::initialize();
$userId = $userId ?? self::getCurrentUserId();
if (!$userId) {
return [];
}
return self::$userPermissions[$userId] ?? [];
}
public static function setUserPermissions(int $userId, array $permissions): void
{
self::$userPermissions[$userId] = $permissions;
self::saveUserPermissions($userId, $permissions);
}
public static function addUserPermission(int $userId, string $permission): void
{
$permissions = self::getUserPermissions($userId);
if (!in_array($permission, $permissions)) {
$permissions[] = $permission;
self::setUserPermissions($userId, $permissions);
}
}
public static function removeUserPermission(int $userId, string $permission): void
{
$permissions = self::getUserPermissions($userId);
$key = array_search($permission, $permissions);
if ($key !== false) {
unset($permissions[$key]);
$permissions = array_values($permissions);
self::setUserPermissions($userId, $permissions);
}
}
public static function assignRole(int $userId, string $role): bool
{
if (!isset(self::$roles[$role])) {
return false;
}
$permissions = self::$roles[$role]['permissions'];
self::setUserPermissions($userId, $permissions);
return true;
}
public static function getUserRole(?int $userId = null): ?string
{
$userPerms = self::getUserPermissions($userId);
foreach (self::$roles as $role => $config) {
if ($config['permissions'] === $userPerms) {
return $role;
}
}
return null;
}
public static function getAllPermissions(): array
{
return self::$permissions;
}
public static function getAllRoles(): array
{
return self::$roles;
}
public static function getPermissionName(string $permission): string
{
return self::$permissions[$permission] ?? $permission;
}
public static function getRoleName(string $role): string
{
return self::$roles[$role]['name'] ?? $role;
}
public static function getRolePermissions(string $role): array
{
return self::$roles[$role]['permissions'] ?? [];
}
public static function createRole(string $role, string $name, array $permissions): bool
{
if (isset(self::$roles[$role])) {
return false;
}
self::$roles[$role] = [
'name' => $name,
'permissions' => $permissions
];
return true;
}
public static function updateRole(string $role, string $name, array $permissions): bool
{
if (!isset(self::$roles[$role])) {
return false;
}
self::$roles[$role] = [
'name' => $name,
'permissions' => $permissions
];
return true;
}
public static function deleteRole(string $role): bool
{
if (!isset(self::$roles[$role]) || $role === 'super_admin') {
return false;
}
unset(self::$roles[$role]);
return true;
}
public static function checkPermission(string $permission): bool
{
if (!self::hasPermission($permission)) {
throw new \RuntimeException("Permission denied: {$permission}");
}
return true;
}
public static function requirePermission(string $permission): void
{
if (!self::hasPermission($permission)) {
http_response_code(403);
echo json_encode([
'code' => 403,
'message' => 'Permission denied',
'data' => ['required_permission' => $permission]
]);
exit;
}
}
public static function requireAnyPermission(array $permissions): void
{
if (!self::hasAnyPermission($permissions)) {
http_response_code(403);
echo json_encode([
'code' => 403,
'message' => 'Permission denied',
'data' => ['required_permissions' => $permissions]
]);
exit;
}
}
public static function requireAllPermissions(array $permissions): void
{
if (!self::hasAllPermissions($permissions)) {
http_response_code(403);
echo json_encode([
'code' => 403,
'message' => 'Permission denied',
'data' => ['required_permissions' => $permissions]
]);
exit;
}
}
public static function filterByPermission(array $data, string $permissionField = 'permission'): array
{
return array_filter($data, function($item) use ($permissionField) {
$permission = $item[$permissionField] ?? '';
return self::hasPermission($permission);
});
}
public static function getPermissionTree(): array
{
$tree = [];
foreach (self::$permissions as $permission => $name) {
$parts = explode('.', $permission);
$current = &$tree;
foreach ($parts as $part) {
if (!isset($current[$part])) {
$current[$part] = [];
}
$current = &$current[$part];
}
$current['_name'] = $name;
$current['_permission'] = $permission;
}
return $tree;
}
private static function getCurrentUserId(): ?int
{
// 从session获取用户ID
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
return $_SESSION['user_id'] ?? null;
}
private static function loadUserPermissions(): void
{
// 这里应该从数据库加载用户权限
// 暂时使用模拟数据
// 模拟数据用户ID为1的是超级管理员
self::$userPermissions[1] = ['*'];
// 模拟数据用户ID为2的是管理员
self::$userPermissions[2] = self::$roles['admin']['permissions'];
// 模拟数据用户ID为3的是运维人员
self::$userPermissions[3] = self::$roles['operator']['permissions'];
}
private static function saveUserPermissions(int $userId, array $permissions): void
{
// 这里应该保存到数据库
// 暂时只保存在内存中
// 记录审计日志
self::logPermissionChange($userId, $permissions);
}
private static function logPermissionChange(int $userId, array $permissions): void
{
$log = [
'user_id' => self::getCurrentUserId(),
'target_user_id' => $userId,
'action' => 'permission_change',
'permissions' => $permissions,
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
'timestamp' => microtime(true)
];
// 这里应该保存到审计日志表
error_log('Permission change: ' . json_encode($log));
}
public static function getAuditLog(array $filters = []): array
{
// 这里应该从数据库获取审计日志
// 暂时返回模拟数据
return [
[
'id' => 1,
'user_id' => 1,
'target_user_id' => 2,
'action' => 'permission_change',
'details' => ['permissions' => ['dashboard.view', 'monitor.view']],
'ip' => '127.0.0.1',
'user_agent' => 'Mozilla/5.0...',
'timestamp' => microtime(true)
]
];
}
public static function validatePermission(string $permission): bool
{
return isset(self::$permissions[$permission]);
}
public static function validateRole(string $role): bool
{
return isset(self::$roles[$role]);
}
public static function getPermissionsByCategory(): array
{
$categories = [];
foreach (self::$permissions as $permission => $name) {
$category = explode('.', $permission)[0];
if (!isset($categories[$category])) {
$categories[$category] = [];
}
$categories[$category][$permission] = $name;
}
return $categories;
}
public static function exportPermissions(): string
{
return json_encode([
'permissions' => self::$permissions,
'roles' => self::$roles,
'export_time' => microtime(true)
], JSON_PRETTY_PRINT);
}
public static function importPermissions(string $data): bool
{
try {
$import = json_decode($data, true);
if (!isset($import['permissions']) || !isset($import['roles'])) {
return false;
}
self::$permissions = $import['permissions'];
self::$roles = $import['roles'];
return true;
} catch (\Exception $e) {
return false;
}
}
}

View File

@@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
namespace Fendx\Monitor\Collector;
use Fendx\Core\Context\Context;
final class MetricsCollector
{
private static array $metrics = [];
private static array $timers = [];
private static array $counters = [];
private static array $gauges = [];
private static array $histograms = [];
public static function startTimer(string $name): void
{
self::$timers[$name] = [
'start_time' => microtime(true),
'start_memory' => memory_get_usage(true),
'trace_id' => Context::getTraceId()
];
}
public static function endTimer(string $name): float
{
if (!isset(self::$timers[$name])) {
return 0.0;
}
$timer = self::$timers[$name];
$duration = microtime(true) - $timer['start_time'];
$memoryUsed = memory_get_usage(true) - $timer['start_memory'];
self::recordMetric($name, [
'type' => 'timer',
'duration' => $duration,
'memory_used' => $memoryUsed,
'trace_id' => $timer['trace_id'],
'timestamp' => microtime(true)
]);
unset(self::$timers[$name]);
return $duration;
}
public static function incrementCounter(string $name, float $value = 1.0, array $tags = []): void
{
$key = self::buildKey($name, $tags);
if (!isset(self::$counters[$key])) {
self::$counters[$key] = 0.0;
}
self::$counters[$key] += $value;
self::recordMetric($name, [
'type' => 'counter',
'value' => self::$counters[$key],
'increment' => $value,
'tags' => $tags,
'trace_id' => Context::getTraceId(),
'timestamp' => microtime(true)
]);
}
public static function setGauge(string $name, float $value, array $tags = []): void
{
$key = self::buildKey($name, $tags);
self::$gauges[$key] = $value;
self::recordMetric($name, [
'type' => 'gauge',
'value' => $value,
'tags' => $tags,
'trace_id' => Context::getTraceId(),
'timestamp' => microtime(true)
]);
}
public static function recordHistogram(string $name, float $value, array $tags = []): void
{
$key = self::buildKey($name, $tags);
if (!isset(self::$histograms[$key])) {
self::$histograms[$key] = [];
}
self::$histograms[$key][] = $value;
self::recordMetric($name, [
'type' => 'histogram',
'value' => $value,
'count' => count(self::$histograms[$key]),
'sum' => array_sum(self::$histograms[$key]),
'tags' => $tags,
'trace_id' => Context::getTraceId(),
'timestamp' => microtime(true)
]);
}
public static function recordHttpRequest(string $method, string $path, int $statusCode, float $duration): void
{
self::recordHistogram('http_request_duration', $duration, [
'method' => $method,
'path' => $path,
'status' => (string)$statusCode
]);
self::incrementCounter('http_requests_total', 1.0, [
'method' => $method,
'path' => $path,
'status' => (string)$statusCode
]);
}
public static function recordDatabaseQuery(string $query, float $duration, bool $success = true): void
{
$queryType = self::extractQueryType($query);
self::recordHistogram('db_query_duration', $duration, [
'query_type' => $queryType,
'success' => $success ? 'true' : 'false'
]);
self::incrementCounter('db_queries_total', 1.0, [
'query_type' => $queryType,
'success' => $success ? 'true' : 'false'
]);
}
public static function recordCacheOperation(string $operation, string $key, bool $hit): void
{
self::incrementCounter('cache_operations_total', 1.0, [
'operation' => $operation,
'hit' => $hit ? 'true' : 'false'
]);
}
public static function recordError(string $type, string $message, array $context = []): void
{
self::incrementCounter('errors_total', 1.0, [
'type' => $type
]);
self::recordMetric('error', [
'type' => 'error',
'error_type' => $type,
'message' => $message,
'context' => $context,
'trace_id' => Context::getTraceId(),
'timestamp' => microtime(true)
]);
}
public static function getMetrics(): array
{
return self::$metrics;
}
public static function getCounters(): array
{
return self::$counters;
}
public static function getGauges(): array
{
return self::$gauges;
}
public static function getHistograms(): array
{
$result = [];
foreach (self::$histograms as $key => $values) {
if (empty($values)) {
continue;
}
sort($values);
$count = count($values);
$sum = array_sum($values);
$result[$key] = [
'count' => $count,
'sum' => $sum,
'min' => $values[0],
'max' => $values[$count - 1],
'mean' => $sum / $count,
'p50' => $values[(int)($count * 0.5)],
'p95' => $values[(int)($count * 0.95)],
'p99' => $values[(int)($count * 0.99)]
];
}
return $result;
}
public static function getSystemMetrics(): array
{
return [
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
'memory_limit' => self::getMemoryLimit(),
'cpu_usage' => self::getCpuUsage(),
'load_average' => sys_getloadavg(),
'uptime' => self::getUptime(),
'timestamp' => microtime(true)
];
}
public static function clear(): void
{
self::$metrics = [];
self::$timers = [];
self::$counters = [];
self::$gauges = [];
self::$histograms = [];
}
private static function recordMetric(string $name, array $data): void
{
if (!isset(self::$metrics[$name])) {
self::$metrics[$name] = [];
}
self::$metrics[$name][] = $data;
// 限制每个指标最多保存1000条记录
if (count(self::$metrics[$name]) > 1000) {
self::$metrics[$name] = array_slice(self::$metrics[$name], -1000);
}
}
private static function buildKey(string $name, array $tags): string
{
if (empty($tags)) {
return $name;
}
ksort($tags);
$tagPairs = [];
foreach ($tags as $key => $value) {
$tagPairs[] = $key . ':' . $value;
}
return $name . '{' . implode(',', $tagPairs) . '}';
}
private static function extractQueryType(string $query): string
{
$query = trim(strtoupper($query));
if (str_starts_with($query, 'SELECT')) {
return 'SELECT';
} elseif (str_starts_with($query, 'INSERT')) {
return 'INSERT';
} elseif (str_starts_with($query, 'UPDATE')) {
return 'UPDATE';
} elseif (str_starts_with($query, 'DELETE')) {
return 'DELETE';
} elseif (str_starts_with($query, 'CREATE')) {
return 'CREATE';
} elseif (str_starts_with($query, 'DROP')) {
return 'DROP';
} elseif (str_starts_with($query, 'ALTER')) {
return 'ALTER';
}
return 'OTHER';
}
private static function getMemoryLimit(): int
{
$limit = ini_get('memory_limit');
if ($limit === '-1') {
return -1;
}
return self::parseMemoryLimit($limit);
}
private static function parseMemoryLimit(string $limit): int
{
$unit = strtoupper(substr($limit, -1));
$value = (int)substr($limit, 0, -1);
return match ($unit) {
'G' => $value * 1024 * 1024 * 1024,
'M' => $value * 1024 * 1024,
'K' => $value * 1024,
default => (int)$limit
};
}
private static function getCpuUsage(): float
{
if (function_exists('sys_getloadavg')) {
$load = sys_getloadavg();
return $load[0] ?? 0.0;
}
return 0.0;
}
private static function getUptime(): int
{
if (function_exists('shell_exec')) {
$uptime = shell_exec('cat /proc/uptime | cut -d" " -f1');
if ($uptime) {
return (int)$uptime;
}
}
return 0;
}
public static function exportPrometheus(): string
{
$output = [];
// 导出计数器
foreach (self::$counters as $key => $value) {
$output[] = "# TYPE " . str_replace(['{', '}', ':', ','], '_', $key) . " counter";
$output[] = str_replace(['{', '}', ':', ','], '_', $key) . " " . $value;
}
// 导出仪表盘
foreach (self::$gauges as $key => $value) {
$output[] = "# TYPE " . str_replace(['{', '}', ':', ','], '_', $key) . " gauge";
$output[] = str_replace(['{', '}', ':', ','], '_', $key) . " " . $value;
}
// 导出直方图
foreach (self::getHistograms() as $key => $stats) {
$cleanKey = str_replace(['{', '}', ':', ','], '_', $key);
$output[] = "# TYPE " . $cleanKey . " histogram";
$output[] = $cleanKey . "_count " . $stats['count'];
$output[] = $cleanKey . "_sum " . $stats['sum'];
$output[] = $cleanKey . "_bucket{le=\"+Inf\"} " . $stats['count'];
}
return implode("\n", $output);
}
}

View File

@@ -0,0 +1,531 @@
<?php
declare(strict_types=1);
namespace Fendx\Monitor\Health;
use Fendx\Db\DB;
use Fendx\Cache\Cache;
use Fendx\Log\Logger;
use Fendx\File\FileManager;
final class HealthChecker
{
private array $checks = [];
private array $config = [];
public function __construct(array $config = [])
{
$this->config = array_merge([
'timeout' => 5.0,
'retries' => 3,
'enabled_checks' => [
'database',
'cache',
'filesystem',
'memory',
'disk',
'external_services'
]
], $config);
$this->initializeChecks();
}
public function check(): array
{
$results = [];
$overallStatus = 'healthy';
$startTime = microtime(true);
foreach ($this->checks as $name => $checker) {
if (!in_array($name, $this->config['enabled_checks'])) {
continue;
}
$checkStart = microtime(true);
try {
$result = $this->executeCheck($checker);
$result['duration'] = microtime(true) - $checkStart;
$results[$name] = $result;
if ($result['status'] === 'critical') {
$overallStatus = 'critical';
} elseif ($result['status'] === 'warning' && $overallStatus !== 'critical') {
$overallStatus = 'warning';
}
} catch (\Throwable $e) {
$results[$name] = [
'status' => 'critical',
'message' => 'Health check failed: ' . $e->getMessage(),
'duration' => microtime(true) - $checkStart,
'timestamp' => microtime(true)
];
$overallStatus = 'critical';
}
}
return [
'status' => $overallStatus,
'timestamp' => microtime(true),
'duration' => microtime(true) - $startTime,
'checks' => $results,
'summary' => $this->generateSummary($results)
];
}
public function checkIndividual(string $name): array
{
if (!isset($this->checks[$name])) {
throw new \InvalidArgumentException("Health check '$name' not found");
}
$checkStart = microtime(true);
try {
$result = $this->executeCheck($this->checks[$name]);
$result['duration'] = microtime(true) - $checkStart;
return $result;
} catch (\Throwable $e) {
return [
'status' => 'critical',
'message' => 'Health check failed: ' . $e->getMessage(),
'duration' => microtime(true) - $checkStart,
'timestamp' => microtime(true)
];
}
}
public function addCheck(string $name, callable $checker): void
{
$this->checks[$name] = $checker;
}
public function removeCheck(string $name): void
{
unset($this->checks[$name]);
}
public function getAvailableChecks(): array
{
return array_keys($this->checks);
}
private function initializeChecks(): void
{
$this->checks['database'] = [$this, 'checkDatabase'];
$this->checks['cache'] = [$this, 'checkCache'];
$this->checks['filesystem'] = [$this, 'checkFilesystem'];
$this->checks['memory'] = [$this, 'checkMemory'];
$this->checks['disk'] = [$this, 'checkDisk'];
$this->checks['external_services'] = [$this, 'checkExternalServices'];
}
private function executeCheck(callable $checker): array
{
$result = $checker();
if (!isset($result['status'])) {
throw new \RuntimeException('Health check must return a status');
}
if (!in_array($result['status'], ['healthy', 'warning', 'critical'])) {
throw new \RuntimeException('Invalid health check status: ' . $result['status']);
}
$result['timestamp'] = microtime(true);
return $result;
}
private function checkDatabase(): array
{
try {
$start = microtime(true);
$pdo = DB::pdo();
$pdo->query('SELECT 1')->fetch();
$duration = microtime(true) - $start;
// 获取数据库连接信息
$status = [
'connected' => true,
'response_time' => $duration,
'version' => $pdo->getAttribute(\PDO::ATTR_SERVER_VERSION) ?? 'unknown',
'driver' => $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME)
];
// 检查连接池状态(如果支持)
try {
$status['active_connections'] = $this->getActiveConnections($pdo);
} catch (\Throwable $e) {
$status['active_connections'] = 'unknown';
}
// 检查数据库大小
try {
$status['database_size'] = $this->getDatabaseSize($pdo);
} catch (\Throwable $e) {
$status['database_size'] = 'unknown';
}
$status['status'] = $duration < 1.0 ? 'healthy' : 'warning';
$status['message'] = $duration < 1.0 ? 'Database connection OK' : 'Database response slow';
return $status;
} catch (\Throwable $e) {
return [
'status' => 'critical',
'message' => 'Database connection failed: ' . $e->getMessage(),
'connected' => false,
'error' => $e->getMessage()
];
}
}
private function checkCache(): array
{
try {
$start = microtime(true);
// 测试写入
$testKey = 'health_check_' . time();
Cache::set($testKey, 'ok', 10);
// 测试读取
$value = Cache::get($testKey);
// 清理测试数据
Cache::delete($testKey);
$duration = microtime(true) - $start;
$status = [
'connected' => true,
'response_time' => $duration,
'read_write_test' => $value === 'ok',
'type' => 'redis' // 可以根据实际配置动态获取
];
// 获取Redis信息如果可用
try {
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);
$info = $redis->info();
$status['memory_usage'] = $info['used_memory_human'] ?? 'unknown';
$status['connected_clients'] = $info['connected_clients'] ?? 'unknown';
$status['uptime'] = $info['uptime_in_seconds'] ?? 'unknown';
} catch (\Throwable $e) {
// Redis信息获取失败不影响基本健康检查
}
$status['status'] = ($value === 'ok' && $duration < 0.5) ? 'healthy' : 'warning';
$status['message'] = ($value === 'ok' && $duration < 0.5) ? 'Cache service OK' : 'Cache service slow';
return $status;
} catch (\Throwable $e) {
return [
'status' => 'critical',
'message' => 'Cache connection failed: ' . $e->getMessage(),
'connected' => false,
'error' => $e->getMessage()
];
}
}
private function checkFilesystem(): array
{
try {
$paths = [
'runtime' => dirname(__DIR__, 4) . '/runtime',
'storage' => dirname(__DIR__, 4) . '/runtime/storage',
'logs' => dirname(__DIR__, 4) . '/runtime/logs',
'cache' => dirname(__DIR__, 4) . '/runtime/cache'
];
$results = [];
$overallStatus = 'healthy';
foreach ($paths as $name => $path) {
$check = $this->checkPathAccess($path);
$results[$name] = $check;
if ($check['status'] === 'critical') {
$overallStatus = 'critical';
} elseif ($check['status'] === 'warning' && $overallStatus !== 'critical') {
$overallStatus = 'warning';
}
}
return [
'status' => $overallStatus,
'message' => $overallStatus === 'healthy' ? 'Filesystem OK' : 'Filesystem issues detected',
'paths' => $results
];
} catch (\Throwable $e) {
return [
'status' => 'critical',
'message' => 'Filesystem check failed: ' . $e->getMessage(),
'error' => $e->getMessage()
];
}
}
private function checkMemory(): array
{
$memoryUsage = memory_get_usage(true);
$memoryLimit = $this->parseMemoryLimit(ini_get('memory_limit'));
$memoryPeak = memory_get_peak_usage(true);
$usagePercent = $memoryLimit > 0 ? ($memoryUsage / $memoryLimit) : 0;
$status = [
'current_usage' => $memoryUsage,
'peak_usage' => $memoryPeak,
'limit' => $memoryLimit,
'usage_percent' => round($usagePercent * 100, 2),
'formatted' => [
'current' => $this->formatBytes($memoryUsage),
'peak' => $this->formatBytes($memoryPeak),
'limit' => $memoryLimit === -1 ? 'unlimited' : $this->formatBytes($memoryLimit)
]
];
$threshold = $this->config['memory_threshold'] ?? 0.8;
if ($usagePercent > 0.95) {
$status['status'] = 'critical';
$status['message'] = 'Memory usage critically high';
} elseif ($usagePercent > $threshold) {
$status['status'] = 'warning';
$status['message'] = 'Memory usage high';
} else {
$status['status'] = 'healthy';
$status['message'] = 'Memory usage OK';
}
return $status;
}
private function checkDisk(): array
{
$paths = [
'runtime' => dirname(__DIR__, 4) . '/runtime',
'root' => dirname(__DIR__, 4)
];
$results = [];
$overallStatus = 'healthy';
foreach ($paths as $name => $path) {
if (!file_exists($path)) {
$results[$name] = [
'status' => 'warning',
'message' => 'Path does not exist',
'path' => $path
];
continue;
}
$freeSpace = disk_free_space($path);
$totalSpace = disk_total_space($path);
$usedSpace = $totalSpace - $freeSpace;
$usagePercent = ($usedSpace / $totalSpace) * 100;
$check = [
'path' => $path,
'total_space' => $totalSpace,
'used_space' => $usedSpace,
'free_space' => $freeSpace,
'usage_percent' => round($usagePercent, 2),
'formatted' => [
'total' => $this->formatBytes($totalSpace),
'used' => $this->formatBytes($usedSpace),
'free' => $this->formatBytes($freeSpace)
]
];
$threshold = $this->config['disk_threshold'] ?? 0.9;
if ($usagePercent > 0.95) {
$check['status'] = 'critical';
$check['message'] = 'Disk space critically low';
} elseif ($usagePercent > $threshold) {
$check['status'] = 'warning';
$check['message'] = 'Disk space low';
} else {
$check['status'] = 'healthy';
$check['message'] = 'Disk space OK';
}
$results[$name] = $check;
if ($check['status'] === 'critical') {
$overallStatus = 'critical';
} elseif ($check['status'] === 'warning' && $overallStatus !== 'critical') {
$overallStatus = 'warning';
}
}
return [
'status' => $overallStatus,
'message' => $overallStatus === 'healthy' ? 'Disk space OK' : 'Disk space issues detected',
'paths' => $results
];
}
private function checkExternalServices(): array
{
// 这里可以检查外部服务如API网关、第三方服务等
// 示例检查Google DNS
try {
$start = microtime(true);
$socket = @fsockopen('8.8.8.8', 53, $errno, $errstr, $this->config['timeout']);
$duration = microtime(true) - $start;
if ($socket) {
fclose($socket);
return [
'status' => $duration < 1.0 ? 'healthy' : 'warning',
'message' => 'External connectivity OK',
'response_time' => $duration,
'service' => 'DNS (8.8.8.8:53)'
];
} else {
return [
'status' => 'critical',
'message' => 'External connectivity failed',
'error' => $errstr,
'service' => 'DNS (8.8.8.8:53)'
];
}
} catch (\Throwable $e) {
return [
'status' => 'critical',
'message' => 'External service check failed',
'error' => $e->getMessage()
];
}
}
private function checkPathAccess(string $path): array
{
if (!file_exists($path)) {
return [
'status' => 'critical',
'message' => 'Path does not exist',
'path' => $path,
'readable' => false,
'writable' => false
];
}
$readable = is_readable($path);
$writable = is_writable($path);
if (!$readable || !$writable) {
return [
'status' => 'critical',
'message' => 'Path not accessible',
'path' => $path,
'readable' => $readable,
'writable' => $writable
];
}
// 测试写入权限
$testFile = $path . '/health_test_' . uniqid();
$writeTest = file_put_contents($testFile, 'test');
if ($writeTest === false) {
return [
'status' => 'critical',
'message' => 'Cannot write to path',
'path' => $path,
'readable' => $readable,
'writable' => false
];
}
unlink($testFile);
return [
'status' => 'healthy',
'message' => 'Path accessible',
'path' => $path,
'readable' => true,
'writable' => true
];
}
private function generateSummary(array $results): array
{
$summary = [
'total_checks' => count($results),
'healthy' => 0,
'warning' => 0,
'critical' => 0
];
foreach ($results as $result) {
$summary[$result['status']]++;
}
return $summary;
}
private function parseMemoryLimit(string $limit): int
{
if ($limit === '-1') {
return -1;
}
$unit = strtoupper(substr($limit, -1));
$value = (int)substr($limit, 0, -1);
return match ($unit) {
'G' => $value * 1024 * 1024 * 1024,
'M' => $value * 1024 * 1024,
'K' => $value * 1024,
default => (int)$limit
};
}
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, 2) . ' ' . $units[$pow];
}
private function getActiveConnections(\PDO $pdo): int
{
// MySQL specific
try {
$stmt = $pdo->query('SHOW STATUS LIKE "Threads_connected"');
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
return (int)($result['Value'] ?? 0);
} catch (\Throwable $e) {
return 0;
}
}
private function getDatabaseSize(\PDO $pdo): string
{
try {
$stmt = $pdo->query('SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 1) AS "size" FROM information_schema.tables');
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
return ($result['size'] ?? 0) . ' MB';
} catch (\Throwable $e) {
return 'unknown';
}
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Fendx\Monitor\Interceptor;
use Fendx\Web\Interceptor\Interceptor;
use Fendx\Web\Request\Request;
use Fendx\Monitor\Service\MonitorService;
final class MonitorInterceptor implements Interceptor
{
private string $requestId;
public function before(Request $request): bool
{
if (!MonitorService::isEnabled()) {
return true;
}
// 记录请求开始
$this->requestId = MonitorService::recordRequestStart();
// 记录请求计数
\Fendx\Monitor\Collector\MetricsCollector::incrementCounter('requests_in_progress');
return true;
}
public function after(Request $request, mixed $result): mixed
{
if (!MonitorService::isEnabled() || !isset($this->requestId)) {
return $result;
}
// 记录请求结束
MonitorService::recordRequestEnd(
$this->requestId,
$request->method(),
$request->path(),
$this->extractStatusCode($result)
);
// 减少进行中的请求计数
\Fendx\Monitor\Collector\MetricsCollector::incrementCounter('requests_in_progress', -1);
return $result;
}
public function afterCompletion(Request $request, ?\Throwable $exception): void
{
if (!MonitorService::isEnabled()) {
return;
}
// 如果有异常,记录错误
if ($exception) {
MonitorService::recordException($exception, [
'request_method' => $request->method(),
'request_path' => $request->path(),
'request_id' => $this->requestId ?? 'unknown'
]);
}
// 确保进行中的请求计数正确
if (isset($this->requestId)) {
\Fendx\Monitor\Collector\MetricsCollector::incrementCounter('requests_in_progress', -1);
}
}
private function extractStatusCode(mixed $result): int
{
// 如果结果是Response对象提取状态码
if (is_object($result) && method_exists($result, 'getStatusCode')) {
return $result->getStatusCode();
}
// 如果是数组且包含code字段
if (is_array($result) && isset($result['code'])) {
$code = $result['code'];
// 将业务代码转换为HTTP状态码
return match (true) {
$code >= 200 && $code < 300 => 200,
$code === 401 => 401,
$code === 403 => 403,
$code === 404 => 404,
$code === 422 => 422,
$code >= 500 => 500,
default => 200
};
}
return 200; // 默认成功
}
}

View File

@@ -0,0 +1,507 @@
<?php
declare(strict_types=1);
namespace Fendx\Monitor\Service;
use Fendx\Monitor\Collector\MetricsCollector;
use Fendx\Monitor\Health\HealthChecker;
use Fendx\Monitor\Tracker\ErrorTracker;
use Fendx\Monitor\Alert\AlertManager;
use Fendx\Monitor\Analyzer\LogAnalyzer;
use Fendx\Monitor\Visualizer\LogVisualizer;
use Fendx\Db\DB;
use Fendx\Cache\Cache;
use Fendx\Log\Logger;
final class MonitorService
{
private static bool $enabled = true;
private static array $config = [];
private static ?HealthChecker $healthChecker = null;
public static function initialize(array $config): void
{
self::$config = array_merge([
'enabled' => true,
'sample_rate' => 1.0,
'retention_period' => 3600,
'export_interval' => 60,
'alert_thresholds' => [
'memory_usage' => 0.8,
'cpu_usage' => 0.8,
'response_time' => 1.0,
'error_rate' => 0.05
]
], $config);
self::$enabled = self::$config['enabled'];
// 初始化健康检查器
if (self::$enabled) {
self::$healthChecker = new HealthChecker([
'timeout' => $config['health_timeout'] ?? 5.0,
'memory_threshold' => self::$config['alert_thresholds']['memory_usage'],
'disk_threshold' => $config['disk_threshold'] ?? 0.9,
'enabled_checks' => $config['enabled_checks'] ?? [
'database', 'cache', 'filesystem', 'memory', 'disk'
]
]);
// 初始化错误追踪器
ErrorTracker::initialize($config['error_tracking'] ?? []);
// 初始化告警管理器
AlertManager::initialize($config['alerts'] ?? []);
// 初始化日志分析器
LogAnalyzer::initialize($config['log_analysis'] ?? []);
// 初始化日志可视化器
LogVisualizer::initialize($config['log_visualization'] ?? []);
self::startSystemMonitoring();
}
}
public static function isEnabled(): bool
{
return self::$enabled;
}
public static function recordRequestStart(): string
{
if (!self::$enabled) {
return '';
}
$requestId = uniqid('req_', true);
MetricsCollector::startTimer('request_' . $requestId);
return $requestId;
}
public static function recordRequestEnd(string $requestId, string $method, string $path, int $statusCode): void
{
if (!self::$enabled || !$requestId) {
return;
}
$duration = MetricsCollector::endTimer('request_' . $requestId);
MetricsCollector::recordHttpRequest($method, $path, $statusCode, $duration);
// 检查性能阈值
self::checkPerformanceThresholds($duration, $statusCode);
}
public static function recordDatabaseQuery(string $query, float $duration, bool $success = true): void
{
if (!self::$enabled) {
return;
}
MetricsCollector::recordDatabaseQuery($query, $duration, $success);
// 记录慢查询
if ($duration > 1.0) {
Logger::warning('Slow query detected', [
'query' => $query,
'duration' => $duration,
'trace_id' => \Fendx\Core\Context\Context::getTraceId()
]);
}
}
public static function recordCacheOperation(string $operation, string $key, bool $hit): void
{
if (!self::$enabled) {
return;
}
MetricsCollector::recordCacheOperation($operation, $key, $hit);
}
public static function recordError(string $type, string $message, array $context = []): void
{
if (!self::$enabled) {
return;
}
MetricsCollector::recordError($type, $message, $context);
ErrorTracker::trackError($type, $message, $context);
// 检查错误率
self::checkErrorRate();
}
public static function recordException(\Throwable $exception, array $context = []): void
{
if (!self::$enabled) {
return;
}
$type = get_class($exception);
$message = $exception->getMessage();
$exceptionContext = array_merge($context, [
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTraceAsString(),
'code' => $exception->getCode()
]);
MetricsCollector::recordError($type, $message, $exceptionContext);
ErrorTracker::trackException($exception, $context);
// 检查错误率
self::checkErrorRate();
}
public static function getMetricsSummary(): array
{
if (!self::$enabled) {
return [];
}
return [
'system' => MetricsCollector::getSystemMetrics(),
'counters' => MetricsCollector::getCounters(),
'gauges' => MetricsCollector::getGauges(),
'histograms' => MetricsCollector::getHistograms(),
'timestamp' => microtime(true)
];
}
public static function getHealthStatus(): array
{
if (!self::$enabled || !self::$healthChecker) {
return [
'status' => 'unknown',
'checks' => [],
'timestamp' => microtime(true)
];
}
return self::$healthChecker->check();
}
public static function checkIndividualHealth(string $component): array
{
if (!self::$enabled || !self::$healthChecker) {
return [
'status' => 'unknown',
'message' => 'Health checking disabled',
'timestamp' => microtime(true)
];
}
return self::$healthChecker->checkIndividual($component);
}
public static function getAvailableHealthChecks(): array
{
if (!self::$healthChecker) {
return [];
}
return self::$healthChecker->getAvailableChecks();
}
public static function getErrors(array $filters = []): array
{
return ErrorTracker::getErrors($filters);
}
public static function getErrorStatistics(): array
{
return ErrorTracker::getErrorStatistics();
}
public static function getErrorTrends(int $hours = 24): array
{
return ErrorTracker::getErrorTrends($hours);
}
public static function getAlerts(array $filters = []): array
{
return AlertManager::getAlerts($filters);
}
public static function getActiveAlerts(): array
{
return AlertManager::getActiveAlerts();
}
public static function getAlertStatistics(): array
{
return AlertManager::getAlertStatistics();
}
public static function acknowledgeAlert(string $alertId, string $acknowledgedBy): bool
{
return AlertManager::acknowledgeAlert($alertId, $acknowledgedBy);
}
public static function resolveAlert(string $alertId): bool
{
return AlertManager::resolveAlert($alertId);
}
public static function searchLogs(array $criteria = []): array
{
return LogAnalyzer::search($criteria);
}
public static function aggregateLogs(array $criteria = []): array
{
return LogAnalyzer::aggregate($criteria);
}
public static function getLogFiles(): array
{
return LogAnalyzer::getLogFiles();
}
public static function getLogContent(string $file, int $lines = 100, int $offset = 0): array
{
return LogAnalyzer::getLogContent($file, $lines, $offset);
}
public static function exportLogs(array $criteria = [], string $format = 'json'): string
{
return LogAnalyzer::exportLogs($criteria, $format);
}
public static function getRealTimeLogs(int $tail = 100): array
{
return LogAnalyzer::getRealTimeLogs($tail);
}
public static function generateLogChart(string $chartType, array $data, string $title = ''): string
{
return match ($chartType) {
'timeline' => LogVisualizer::generateTimelineChart($data),
'pie' => LogVisualizer::generatePieChart($data, $title),
'bar' => LogVisualizer::generateBarChart($data, $title),
'heatmap' => LogVisualizer::generateHeatmap($data, $title),
'level_distribution' => LogVisualizer::generateLogLevelDistribution($data),
'error_trend' => LogVisualizer::generateErrorTrendChart($data),
'top_errors' => LogVisualizer::generateTopErrorsChart($data),
default => LogVisualizer::generateTimelineChart($data)
};
}
public static function exportMetrics(string $format = 'json'): string
{
if (!self::$enabled) {
return '';
}
$metrics = self::getMetricsSummary();
return match ($format) {
'prometheus' => MetricsCollector::exportPrometheus(),
'json' => json_encode($metrics, JSON_PRETTY_PRINT),
default => json_encode($metrics)
};
}
public static function clearMetrics(): void
{
MetricsCollector::clear();
}
private static function startSystemMonitoring(): void
{
// 记录基础系统指标
MetricsCollector::setGauge('system_memory_usage', memory_get_usage(true));
MetricsCollector::setGauge('system_memory_peak', memory_get_peak_usage(true));
if (function_exists('sys_getloadavg')) {
$load = sys_getloadavg();
MetricsCollector::setGauge('system_load_1m', $load[0]);
MetricsCollector::setGauge('system_load_5m', $load[1] ?? 0);
MetricsCollector::setGauge('system_load_15m', $load[2] ?? 0);
}
}
private static function checkPerformanceThresholds(float $duration, int $statusCode): void
{
// 检查响应时间
if ($duration > self::$config['alert_thresholds']['response_time']) {
Logger::warning('Slow response detected', [
'duration' => $duration,
'status_code' => $statusCode,
'threshold' => self::$config['alert_thresholds']['response_time']
]);
}
// 检查错误状态码
if ($statusCode >= 500) {
MetricsCollector::recordError('http_error', "HTTP {$statusCode}", [
'duration' => $duration
]);
}
}
private static function checkErrorRate(): void
{
$metrics = MetricsCollector::getMetrics();
if (!isset($metrics['errors_total'])) {
return;
}
$recentErrors = 0;
$totalRequests = 0;
$now = microtime(true);
$window = 300; // 5分钟窗口
// 计算最近5分钟的错误率
foreach ($metrics['errors_total'] as $error) {
if ($now - $error['timestamp'] < $window) {
$recentErrors++;
}
}
if (isset($metrics['http_requests_total'])) {
foreach ($metrics['http_requests_total'] as $request) {
if ($now - $request['timestamp'] < $window) {
$totalRequests++;
}
}
}
if ($totalRequests > 0) {
$errorRate = $recentErrors / $totalRequests;
if ($errorRate > self::$config['alert_thresholds']['error_rate']) {
Logger::error('High error rate detected', [
'error_rate' => round($errorRate * 100, 2) . '%',
'errors' => $recentErrors,
'total_requests' => $totalRequests,
'window' => $window . 's'
]);
}
}
}
private static function checkDatabaseHealth(): array
{
try {
$start = microtime(true);
$pdo = DB::pdo();
$pdo->query('SELECT 1')->fetch();
$duration = microtime(true) - $start;
return [
'status' => $duration < 1.0 ? 'healthy' : 'warning',
'response_time' => $duration,
'connected' => true
];
} catch (\Exception $e) {
return [
'status' => 'critical',
'error' => $e->getMessage(),
'connected' => false
];
}
}
private static function checkCacheHealth(): array
{
try {
$start = microtime(true);
Cache::set('health_check', 'ok', 10);
$value = Cache::get('health_check');
$duration = microtime(true) - $start;
return [
'status' => ($value === 'ok' && $duration < 0.5) ? 'healthy' : 'warning',
'response_time' => $duration,
'connected' => true
];
} catch (\Exception $e) {
return [
'status' => 'critical',
'error' => $e->getMessage(),
'connected' => false
];
}
}
private static function checkErrorRateHealth(): array
{
$metrics = MetricsCollector::getMetrics();
if (!isset($metrics['errors_total']) || !isset($metrics['http_requests_total'])) {
return [
'status' => 'healthy',
'rate' => 0,
'errors' => 0,
'requests' => 0
];
}
$recentErrors = 0;
$totalRequests = 0;
$now = microtime(true);
$window = 300; // 5分钟窗口
foreach ($metrics['errors_total'] as $error) {
if ($now - $error['timestamp'] < $window) {
$recentErrors++;
}
}
foreach ($metrics['http_requests_total'] as $request) {
if ($now - $request['timestamp'] < $window) {
$totalRequests++;
}
}
$errorRate = $totalRequests > 0 ? $recentErrors / $totalRequests : 0;
$threshold = self::$config['alert_thresholds']['error_rate'];
return [
'status' => $errorRate < $threshold ? 'healthy' : ($errorRate < $threshold * 2 ? 'warning' : 'critical'),
'rate' => round($errorRate * 100, 2) . '%',
'errors' => $recentErrors,
'requests' => $totalRequests,
'threshold' => round($threshold * 100, 2) . '%'
];
}
public static function getAlerts(): array
{
if (!self::$enabled) {
return [];
}
$alerts = [];
$health = self::getHealthStatus();
foreach ($health['checks'] as $component => $check) {
if ($check['status'] === 'warning' || $check['status'] === 'critical') {
$alerts[] = [
'component' => $component,
'severity' => $check['status'],
'message' => self::generateAlertMessage($component, $check),
'timestamp' => microtime(true)
];
}
}
return $alerts;
}
private static function generateAlertMessage(string $component, array $check): string
{
return match ($component) {
'memory' => "High memory usage: {$check['ratio']}",
'cpu' => "High CPU usage: {$check['usage']}",
'database' => "Database issue: " . ($check['error'] ?? 'Slow response'),
'cache' => "Cache issue: " . ($check['error'] ?? 'Slow response'),
'error_rate' => "High error rate: {$check['rate']} (threshold: {$check['threshold']})",
default => "Component {$component} status: {$check['status']}"
};
}
}

View File

@@ -0,0 +1,462 @@
<?php
declare(strict_types=1);
namespace Fendx\Monitor\Tracker;
use Fendx\Core\Context\Context;
use Fendx\Log\Logger;
final class ErrorTracker
{
private static array $errors = [];
private static array $config = [];
private static bool $enabled = true;
public static function initialize(array $config): void
{
self::$config = array_merge([
'enabled' => true,
'max_errors' => 1000,
'retention_period' => 3600,
'notify_threshold' => 10,
'group_similar' => true,
'track_stack_trace' => true,
'track_request_info' => true
], $config);
self::$enabled = self::$config['enabled'];
// 注册错误和异常处理器
if (self::$enabled) {
set_error_handler([self::class, 'handleError']);
set_exception_handler([self::class, 'handleException']);
register_shutdown_function([self::class, 'handleShutdown']);
}
}
public static function trackError(string $type, string $message, array $context = [], ?\Throwable $previous = null): void
{
if (!self::$enabled) {
return;
}
$error = [
'id' => uniqid('error_', true),
'type' => $type,
'message' => $message,
'context' => $context,
'trace_id' => Context::getTraceId(),
'timestamp' => microtime(true),
'datetime' => date('Y-m-d H:i:s'),
'file' => $context['file'] ?? null,
'line' => $context['line'] ?? null,
'severity' => self::determineSeverity($type, $context),
'group_key' => self::generateGroupKey($type, $message, $context)
];
// 添加堆栈跟踪
if (self::$config['track_stack_trace'] && isset($context['trace'])) {
$error['stack_trace'] = $context['trace'];
} elseif ($previous) {
$error['stack_trace'] = $previous->getTraceAsString();
}
// 添加请求信息
if (self::$config['track_request_info']) {
$error['request'] = self::captureRequestInfo();
}
// 添加服务器信息
$error['server'] = self::captureServerInfo();
self::$errors[] = $error;
self::cleanupOldErrors();
// 记录到日志
Logger::error("Error tracked: {$type} - {$message}", $error);
// 检查是否需要发送告警
self::checkAlertThreshold($error);
}
public static function trackException(\Throwable $exception, array $context = []): void
{
if (!self::$enabled) {
return;
}
$errorContext = array_merge($context, [
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTraceAsString(),
'exception_class' => get_class($exception),
'code' => $exception->getCode()
]);
self::trackError(
'exception',
$exception->getMessage(),
$errorContext,
$exception
);
}
public static function handleError(int $severity, string $message, string $file = '', int $line = 0, array $context = []): bool
{
if (!(error_reporting() & $severity)) {
return false;
}
$errorTypes = [
E_ERROR => 'ERROR',
E_WARNING => 'WARNING',
E_PARSE => 'PARSE',
E_NOTICE => 'NOTICE',
E_CORE_ERROR => 'CORE_ERROR',
E_CORE_WARNING => 'CORE_WARNING',
E_COMPILE_ERROR => 'COMPILE_ERROR',
E_COMPILE_WARNING => 'COMPILE_WARNING',
E_USER_ERROR => 'USER_ERROR',
E_USER_WARNING => 'USER_WARNING',
E_USER_NOTICE => 'USER_NOTICE',
E_STRICT => 'STRICT',
E_RECOVERABLE_ERROR => 'RECOVERABLE_ERROR',
E_DEPRECATED => 'DEPRECATED',
E_USER_DEPRECATED => 'USER_DEPRECATED'
];
$type = $errorTypes[$severity] ?? 'UNKNOWN';
self::trackError($type, $message, [
'file' => $file,
'line' => $line,
'severity_code' => $severity,
'context' => $context
]);
return true;
}
public static function handleException(\Throwable $exception): void
{
self::trackException($exception);
}
public static function handleShutdown(): void
{
$error = error_get_last();
if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
self::trackError(
'FATAL_ERROR',
$error['message'],
[
'file' => $error['file'],
'line' => $error['line'],
'type' => $error['type']
]
);
}
}
public static function getErrors(array $filters = []): array
{
$errors = self::$errors;
// 应用过滤器
if (!empty($filters)) {
$errors = array_filter($errors, function($error) use ($filters) {
foreach ($filters as $key => $value) {
if (isset($error[$key]) && $error[$key] !== $value) {
return false;
}
}
return true;
});
}
// 按时间倒序排列
usort($errors, function($a, $b) {
return $b['timestamp'] <=> $a['timestamp'];
});
return array_values($errors);
}
public static function getErrorById(string $id): ?array
{
foreach (self::$errors as $error) {
if ($error['id'] === $id) {
return $error;
}
}
return null;
}
public static function getErrorStatistics(): array
{
$stats = [
'total_errors' => count(self::$errors),
'by_type' => [],
'by_severity' => [],
'by_hour' => [],
'recent_errors' => 0,
'critical_errors' => 0,
'most_common' => []
];
$now = time();
$oneHourAgo = $now - 3600;
foreach (self::$errors as $error) {
// 按类型统计
$type = $error['type'];
$stats['by_type'][$type] = ($stats['by_type'][$type] ?? 0) + 1;
// 按严重程度统计
$severity = $error['severity'];
$stats['by_severity'][$severity] = ($stats['by_severity'][$severity] ?? 0) + 1;
// 按小时统计
$hour = date('Y-m-d H:00', (int)$error['timestamp']);
$stats['by_hour'][$hour] = ($stats['by_hour'][$hour] ?? 0) + 1;
// 最近错误
if ($error['timestamp'] > $oneHourAgo) {
$stats['recent_errors']++;
}
// 严重错误
if ($severity === 'critical') {
$stats['critical_errors']++;
}
// 最常见错误(按分组)
$groupKey = $error['group_key'];
if (!isset($stats['most_common'][$groupKey])) {
$stats['most_common'][$groupKey] = [
'group_key' => $groupKey,
'type' => $type,
'message' => $error['message'],
'count' => 0,
'last_occurred' => $error['timestamp']
];
}
$stats['most_common'][$groupKey]['count']++;
$stats['most_common'][$groupKey]['last_occurred'] = max(
$stats['most_common'][$groupKey]['last_occurred'],
$error['timestamp']
);
}
// 按出现次数排序最常见错误
usort($stats['most_common'], function($a, $b) {
return $b['count'] <=> $a['count'];
});
$stats['most_common'] = array_slice($stats['most_common'], 0, 10);
return $stats;
}
public static function getErrorTrends(int $hours = 24): array
{
$trends = [];
$now = time();
$interval = 3600; // 1小时间隔
for ($i = $hours - 1; $i >= 0; $i--) {
$hourStart = $now - ($i + 1) * $interval;
$hourEnd = $now - $i * $interval;
$hourKey = date('Y-m-d H:00', $hourStart);
$hourErrors = array_filter(self::$errors, function($error) use ($hourStart, $hourEnd) {
return $error['timestamp'] >= $hourStart && $error['timestamp'] < $hourEnd;
});
$trends[] = [
'hour' => $hourKey,
'total' => count($hourErrors),
'critical' => count(array_filter($hourErrors, fn($e) => $e['severity'] === 'critical')),
'by_type' => array_count_values(array_column($hourErrors, 'type'))
];
}
return $trends;
}
public static function clearErrors(): void
{
self::$errors = [];
}
public static function clearErrorsByType(string $type): void
{
self::$errors = array_filter(self::$errors, fn($error) => $error['type'] !== $type);
}
public static function clearOldErrors(int $olderThanSeconds = null): void
{
$cutoff = $olderThanSeconds ?? self::$config['retention_period'];
$cutoffTime = time() - $cutoff;
self::$errors = array_filter(self::$errors, fn($error) => $error['timestamp'] > $cutoffTime);
}
public static function exportErrors(string $format = 'json'): string
{
$data = [
'errors' => self::$errors,
'statistics' => self::getErrorStatistics(),
'trends' => self::getErrorTrends(),
'export_time' => microtime(true)
];
return match ($format) {
'json' => json_encode($data, JSON_PRETTY_PRINT),
'csv' => self::exportToCsv(),
default => json_encode($data)
};
}
private static function determineSeverity(string $type, array $context): string
{
$criticalTypes = ['ERROR', 'FATAL_ERROR', 'CORE_ERROR', 'COMPILE_ERROR'];
$warningTypes = ['WARNING', 'CORE_WARNING', 'COMPILE_WARNING', 'USER_ERROR'];
if (in_array($type, $criticalTypes)) {
return 'critical';
} elseif (in_array($type, $warningTypes)) {
return 'warning';
} elseif (isset($context['exception_class']) && str_ends_with($context['exception_class'], 'Exception')) {
return 'warning';
}
return 'info';
}
private static function generateGroupKey(string $type, string $message, array $context): string
{
if (!self::$config['group_similar']) {
return uniqid('group_', true);
}
// 标准化消息移除时间戳、ID等动态内容
$normalizedMessage = preg_replace('/\d+/', 'N', $message);
$normalizedMessage = preg_replace('/[a-f0-9]{8,}/', 'ID', $normalizedMessage);
// 包含文件位置(如果有)
$location = '';
if (isset($context['file'])) {
$location = basename($context['file']);
if (isset($context['line'])) {
$location .= ':' . $context['line'];
}
}
return md5($type . '|' . $normalizedMessage . '|' . $location);
}
private static function captureRequestInfo(): array
{
$request = [
'method' => $_SERVER['REQUEST_METHOD'] ?? 'CLI',
'uri' => $_SERVER['REQUEST_URI'] ?? '',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'ip' => $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1',
'referer' => $_SERVER['HTTP_REFERER'] ?? '',
'headers' => [],
'get' => [],
'post' => []
];
// 捕获请求头(排除敏感信息)
foreach ($_SERVER as $key => $value) {
if (str_starts_with($key, 'HTTP_') && !str_contains($key, 'AUTH') && !str_contains($key, 'COOKIE')) {
$request['headers'][str_replace('HTTP_', '', $key)] = $value;
}
}
// 捕获GET参数排除敏感信息
foreach ($_GET as $key => $value) {
if (!in_array(strtolower($key), ['password', 'token', 'secret', 'key'])) {
$request['get'][$key] = is_scalar($value) ? $value : '[complex]';
}
}
// 捕获POST参数排除敏感信息
foreach ($_POST as $key => $value) {
if (!in_array(strtolower($key), ['password', 'token', 'secret', 'key'])) {
$request['post'][$key] = is_scalar($value) ? $value : '[complex]';
}
}
return $request;
}
private static function captureServerInfo(): array
{
return [
'php_version' => PHP_VERSION,
'os' => PHP_OS,
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
'load_average' => function_exists('sys_getloadavg') ? sys_getloadavg() : null,
'process_id' => getmypid(),
'hostname' => gethostname() ?? 'unknown'
];
}
private static function cleanupOldErrors(): void
{
if (count(self::$errors) <= self::$config['max_errors']) {
return;
}
// 按时间排序,保留最新的错误
usort(self::$errors, function($a, $b) {
return $a['timestamp'] <=> $b['timestamp'];
});
self::$errors = array_slice(self::$errors, -self::$config['max_errors']);
}
private static function checkAlertThreshold(array $error): void
{
// 检查相同类型错误的频率
$recentSimilar = array_filter(self::$errors, function($e) use ($error) {
return $e['group_key'] === $error['group_key'] &&
(time() - $e['timestamp']) < 300; // 5分钟内
});
if (count($recentSimilar) >= self::$config['notify_threshold']) {
Logger::critical('High error frequency detected', [
'error_type' => $error['type'],
'message' => $error['message'],
'count' => count($recentSimilar),
'time_window' => '5 minutes'
]);
}
}
private static function exportToCsv(): string
{
$csv = "ID,Type,Message,File,Line,Severity,Timestamp,TraceId\n";
foreach (self::$errors as $error) {
$csv .= sprintf(
"%s,%s,%s,%s,%s,%s,%s,%s\n",
$error['id'],
$error['type'],
str_replace(["\n", "\r", ","], [" ", " ", ";"], $error['message']),
$error['file'] ?? '',
$error['line'] ?? '',
$error['severity'],
date('Y-m-d H:i:s', (int)$error['timestamp']),
$error['trace_id']
);
}
return $csv;
}
}

View File

@@ -0,0 +1,379 @@
<?php
declare(strict_types=1);
namespace Fendx\Monitor\Visualizer;
final class LogVisualizer
{
private static array $config = [];
private static array $colors = [
'DEBUG' => '#6c757d',
'INFO' => '#17a2b8',
'WARNING' => '#ffc107',
'ERROR' => '#fd7e14',
'CRITICAL' => '#dc3545'
];
public static function initialize(array $config): void
{
self::$config = array_merge([
'chart_width' => 800,
'chart_height' => 400,
'max_data_points' => 100,
'theme' => 'light'
], $config);
}
public static function generateTimelineChart(array $timelineData): string
{
$width = self::$config['chart_width'];
$height = self::$config['chart_height'];
$maxPoints = self::$config['max_data_points'];
// 准备数据
$labels = array_keys($timelineData);
$values = array_values($timelineData);
if (count($labels) > $maxPoints) {
$labels = array_slice($labels, -$maxPoints);
$values = array_slice($values, -$maxPoints);
}
$maxValue = max($values) ?: 1;
$chartHeight = $height - 60; // 留出空间给标签
$chartWidth = $width - 80; // 留出空间给Y轴标签
$svg = '<svg width="' . $width . '" height="' . $height . '" xmlns="http://www.w3.org/2000/svg">';
$svg .= '<style>';
$svg .= '.chart-line { fill: none; stroke: #007bff; stroke-width: 2; }';
$svg .= '.chart-point { fill: #007bff; }';
$svg .= '.chart-grid { stroke: #e9ecef; stroke-width: 1; }';
$svg .= '.chart-text { font-family: Arial, sans-serif; font-size: 12px; fill: #495057; }';
$svg .= '.chart-label { font-family: Arial, sans-serif; font-size: 10px; fill: #6c757d; }';
$svg .= '</style>';
// 绘制网格
for ($i = 0; $i <= 5; $i++) {
$y = 30 + ($chartHeight / 5) * $i;
$svg .= '<line x1="60" y1="' . $y . '" x2="' . ($width - 20) . '" y2="' . $y . '" class="chart-grid"/>';
$value = round($maxValue * (5 - $i) / 5);
$svg .= '<text x="50" y="' . ($y + 4) . '" text-anchor="end" class="chart-text">' . $value . '</text>';
}
// 绘制数据线
$points = [];
foreach ($values as $index => $value) {
$x = 60 + ($chartWidth / (count($values) - 1)) * $index;
$y = 30 + $chartHeight - ($value / $maxValue) * $chartHeight;
$points[] = $x . ',' . $y;
// 绘制数据点
$svg .= '<circle cx="' . $x . '" cy="' . $y . '" r="3" class="chart-point"/>';
}
if (!empty($points)) {
$svg .= '<polyline points="' . implode(' ', $points) . '" class="chart-line"/>';
}
// 绘制X轴标签
$labelStep = max(1, floor(count($labels) / 10));
foreach ($labels as $index => $label) {
if ($index % $labelStep === 0 || $index === count($labels) - 1) {
$x = 60 + ($chartWidth / (count($labels) - 1)) * $index;
$svg .= '<text x="' . $x . '" y="' . ($height - 10) . '" text-anchor="middle" class="chart-label">' . substr($label, -5) . '</text>';
}
}
$svg .= '</svg>';
return $svg;
}
public static function generatePieChart(array $data, string $title = ''): string
{
$width = self::$config['chart_width'];
$height = self::$config['chart_height'];
$centerX = $width / 2;
$centerY = $height / 2 - 20;
$radius = min($width, $height) / 3;
$total = array_sum($data);
if ($total === 0) {
return '<svg width="' . $width . '" height="' . $height . '"><text x="' . $centerX . '" y="' . $centerY . '" text-anchor="middle" class="chart-text">No Data</text></svg>';
}
$svg = '<svg width="' . $width . '" height="' . $height . '" xmlns="http://www.w3.org/2000/svg">';
$svg .= '<style>';
$svg .= '.chart-text { font-family: Arial, sans-serif; font-size: 12px; fill: #495057; }';
$svg .= '.chart-title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #212529; }';
$svg .= '.chart-legend { font-family: Arial, sans-serif; font-size: 11px; fill: #6c757d; }';
$svg .= '</style>';
if ($title) {
$svg .= '<text x="' . $centerX . '" y="20" text-anchor="middle" class="chart-title">' . $title . '</text>';
}
$colors = self::generateColors(count($data));
$startAngle = -90; // 从顶部开始
$legendY = $height - 60;
foreach ($data as $label => $value) {
$percentage = ($value / $total) * 100;
$endAngle = $startAngle + ($percentage * 3.6);
$startRad = deg2rad($startAngle);
$endRad = deg2rad($endAngle);
$x1 = $centerX + $radius * cos($startRad);
$y1 = $centerY + $radius * sin($startRad);
$x2 = $centerX + $radius * cos($endRad);
$y2 = $centerY + $radius * sin($endRad);
$largeArc = $percentage > 50 ? 1 : 0;
$path = "M {$centerX} {$centerY} L {$x1} {$y1} A {$radius} {$radius} 0 {$largeArc} 1 {$x2} {$y2} Z";
$color = array_shift($colors);
$svg .= '<path d="' . $path . '" fill="' . $color . '" stroke="white" stroke-width="2"/>';
// 添加标签
if ($percentage > 5) {
$labelAngle = deg2rad($startAngle + ($percentage * 1.8));
$labelX = $centerX + ($radius * 0.7) * cos($labelAngle);
$labelY = $centerY + ($radius * 0.7) * sin($labelAngle);
$svg .= '<text x="' . $labelX . '" y="' . $labelY . '" text-anchor="middle" class="chart-text" fill="white">' . round($percentage, 1) . '%</text>';
}
// 添加图例
$svg .= '<rect x="20" y="' . $legendY . '" width="12" height="12" fill="' . $color . '"/>';
$svg .= '<text x="38" y="' . ($legendY + 10) . '" class="chart-legend">' . $label . ' (' . round($percentage, 1) . '%)</text>';
$legendY += 18;
$startAngle = $endAngle;
}
$svg .= '</svg>';
return $svg;
}
public static function generateBarChart(array $data, string $title = ''): string
{
$width = self::$config['chart_width'];
$height = self::$config['chart_height'];
$margin = 60;
$chartWidth = $width - 2 * $margin;
$chartHeight = $height - 2 * $margin;
$labels = array_keys($data);
$values = array_values($data);
$maxValue = max($values) ?: 1;
$barWidth = $chartWidth / count($labels) * 0.8;
$barSpacing = $chartWidth / count($labels) * 0.2;
$svg = '<svg width="' . $width . '" height="' . $height . '" xmlns="http://www.w3.org/2000/svg">';
$svg .= '<style>';
$svg .= '.chart-bar { fill: #007bff; }';
$svg .= '.chart-bar:hover { fill: #0056b3; }';
$svg .= '.chart-grid { stroke: #e9ecef; stroke-width: 1; }';
$svg .= '.chart-text { font-family: Arial, sans-serif; font-size: 12px; fill: #495057; }';
$svg .= '.chart-label { font-family: Arial, sans-serif; font-size: 10px; fill: #6c757d; }';
$svg .= '.chart-title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #212529; }';
$svg .= '</style>';
if ($title) {
$svg .= '<text x="' . ($width / 2) . '" y="20" text-anchor="middle" class="chart-title">' . $title . '</text>';
}
// 绘制网格和Y轴标签
for ($i = 0; $i <= 5; $i++) {
$y = $margin + ($chartHeight / 5) * $i;
$svg .= '<line x1="' . $margin . '" y1="' . $y . '" x2="' . ($width - $margin) . '" y2="' . $y . '" class="chart-grid"/>';
$value = round($maxValue * (5 - $i) / 5);
$svg .= '<text x="' . ($margin - 10) . '" y="' . ($y + 4) . '" text-anchor="end" class="chart-text">' . $value . '</text>';
}
// 绘制柱状图
foreach ($values as $index => $value) {
$x = $margin + ($barWidth + $barSpacing) * $index + $barSpacing / 2;
$barHeight = ($value / $maxValue) * $chartHeight;
$y = $margin + $chartHeight - $barHeight;
$svg .= '<rect x="' . $x . '" y="' . $y . '" width="' . $barWidth . '" height="' . $barHeight . '" class="chart-bar"/>';
// 数值标签
$svg .= '<text x="' . ($x + $barWidth / 2) . '" y="' . ($y - 5) . '" text-anchor="middle" class="chart-text">' . $value . '</text>';
// X轴标签
$label = $labels[$index];
if (strlen($label) > 10) {
$label = substr($label, 0, 10) . '...';
}
$svg .= '<text x="' . ($x + $barWidth / 2) . '" y="' . ($height - $margin + 20) . '" text-anchor="middle" class="chart-label">' . $label . '</text>';
}
$svg .= '</svg>';
return $svg;
}
public static function generateHeatmap(array $data, string $title = ''): string
{
$width = self::$config['chart_width'];
$height = self::$config['chart_height'];
$cellSize = 20;
$margin = 50;
$rows = count($data);
$cols = $rows > 0 ? count(reset($data)) : 0;
if ($rows === 0 || $cols === 0) {
return '<svg width="' . $width . '" height="' . $height . '"><text x="' . ($width / 2) . '" y="' . ($height / 2) . '" text-anchor="middle" class="chart-text">No Data</text></svg>';
}
$svg = '<svg width="' . $width . '" height="' . $height . '" xmlns="http://www.w3.org/2000/svg">';
$svg .= '<style>';
$svg .= '.heatmap-cell { stroke: white; stroke-width: 1; }';
$svg .= '.chart-text { font-family: Arial, sans-serif; font-size: 10px; fill: #495057; }';
$svg .= '.chart-title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #212529; }';
$svg .= '</style>';
if ($title) {
$svg .= '<text x="' . ($width / 2) . '" y="20" text-anchor="middle" class="chart-title">' . $title . '</text>';
}
// 找出最大值用于颜色映射
$maxValue = 0;
foreach ($data as $row) {
foreach ($row as $value) {
$maxValue = max($maxValue, $value);
}
}
// 绘制热力图
foreach ($data as $rowIndex => $row) {
foreach ($row as $colIndex => $value) {
$x = $margin + $colIndex * $cellSize;
$y = $margin + $rowIndex * $cellSize;
$color = self::getHeatmapColor($value, $maxValue);
$svg .= '<rect x="' . $x . '" y="' . $y . '" width="' . $cellSize . '" height="' . $cellSize . '" fill="' . $color . '" class="heatmap-cell"/>';
// 添加数值
if ($value > 0) {
$textColor = $value > $maxValue / 2 ? 'white' : 'black';
$svg .= '<text x="' . ($x + $cellSize / 2) . '" y="' . ($y + $cellSize / 2 + 3) . '" text-anchor="middle" class="chart-text" fill="' . $textColor . '">' . $value . '</text>';
}
}
}
// 添加颜色图例
$legendX = $margin + $cols * $cellSize + 20;
$legendHeight = 100;
for ($i = 0; $i <= $legendHeight; $i++) {
$value = ($maxValue * $i) / $legendHeight;
$color = self::getHeatmapColor($value, $maxValue);
$y = $margin + $legendHeight - $i;
$svg .= '<rect x="' . $legendX . '" y="' . $y . '" width="15" height="1" fill="' . $color . '"/>';
}
$svg .= '<text x="' . ($legendX + 20) . '" y="' . $margin . '" class="chart-text">' . $maxValue . '</text>';
$svg .= '<text x="' . ($legendX + 20) . '" y="' . ($margin + $legendHeight) . '" class="chart-text">0</text>';
$svg .= '</svg>';
return $svg;
}
public static function generateLogLevelDistribution(array $levelData): string
{
// 为日志级别分配特定颜色
$coloredData = [];
foreach ($levelData as $level => $count) {
$coloredData[$level] = [
'count' => $count,
'color' => self::$colors[$level] ?? '#6c757d'
];
}
return self::generatePieChart(array_column($coloredData, 'count'), 'Log Level Distribution');
}
public static function generateErrorTrendChart(array $errorData): string
{
return self::generateTimelineChart($errorData);
}
public static function generateTopErrorsChart(array $topErrors): string
{
// 限制显示前10个错误
$errors = array_slice($topErrors, 0, 10, true);
// 截断长错误消息
$labels = [];
foreach (array_keys($errors) as $error) {
$labels[] = strlen($error) > 30 ? substr($error, 0, 30) . '...' : $error;
}
$data = array_combine($labels, array_values($errors));
return self::generateBarChart($data, 'Top Errors');
}
private static function generateColors(int $count): array
{
$colors = [
'#007bff', '#28a745', '#dc3545', '#ffc107', '#17a2b8',
'#6610f2', '#e83e8c', '#fd7e14', '#20c997', '#6f42c1',
'#343a40', '#6c757d', '#f8f9fa', '#007bff', '#28a745'
];
$result = [];
for ($i = 0; $i < $count; $i++) {
$result[] = $colors[$i % count($colors)];
}
return $result;
}
private static function getHeatmapColor(float $value, float $maxValue): string
{
if ($maxValue === 0) {
return '#f8f9fa';
}
$ratio = $value / $maxValue;
if ($ratio < 0.2) {
return '#e8f5e8'; // 浅绿
} elseif ($ratio < 0.4) {
return '#a8d8a8'; // 中绿
} elseif ($ratio < 0.6) {
return '#ffd700'; // 黄色
} elseif ($ratio < 0.8) {
return '#ff8c00'; // 橙色
} else {
return '#ff4444'; // 红色
}
}
public static function exportChart(string $svg, string $filename, string $format = 'svg'): string
{
$filepath = sys_get_temp_dir() . '/' . $filename . '.' . $format;
switch ($format) {
case 'svg':
file_put_contents($filepath, $svg);
break;
case 'png':
// 这里需要使用SVG到PNG的转换库
// 暂时返回SVG内容
return $svg;
default:
file_put_contents($filepath, $svg);
}
return $filepath;
}
}