Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
88
src/Framework/ErrorAggregation/AlertUrgency.php
Normal file
88
src/Framework/ErrorAggregation/AlertUrgency.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorAggregation;
|
||||
|
||||
/**
|
||||
* Alert urgency levels for notification prioritization
|
||||
*/
|
||||
enum AlertUrgency: string
|
||||
{
|
||||
case URGENT = 'urgent'; // Immediate attention required (SMS, call)
|
||||
case HIGH = 'high'; // High priority (push notification, email)
|
||||
case MEDIUM = 'medium'; // Medium priority (email)
|
||||
case LOW = 'low'; // Low priority (daily digest)
|
||||
|
||||
/**
|
||||
* Gets timeout in seconds for escalation
|
||||
*/
|
||||
public function getEscalationTimeout(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::URGENT => 300, // 5 minutes
|
||||
self::HIGH => 900, // 15 minutes
|
||||
self::MEDIUM => 3600, // 1 hour
|
||||
self::LOW => 86400, // 24 hours
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets notification channels for this urgency
|
||||
*/
|
||||
public function getNotificationChannels(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::URGENT => ['sms', 'call', 'slack', 'email'],
|
||||
self::HIGH => ['slack', 'email', 'push'],
|
||||
self::MEDIUM => ['email', 'slack'],
|
||||
self::LOW => ['email'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets retry strategy for failed notifications
|
||||
*/
|
||||
public function getRetryStrategy(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::URGENT => [
|
||||
'attempts' => 5,
|
||||
'delays' => [30, 60, 120, 300, 600], // seconds
|
||||
],
|
||||
self::HIGH => [
|
||||
'attempts' => 3,
|
||||
'delays' => [60, 300, 900],
|
||||
],
|
||||
self::MEDIUM => [
|
||||
'attempts' => 2,
|
||||
'delays' => [300, 1800],
|
||||
],
|
||||
self::LOW => [
|
||||
'attempts' => 1,
|
||||
'delays' => [3600],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this urgency requires immediate processing
|
||||
*/
|
||||
public function requiresImmediateProcessing(): bool
|
||||
{
|
||||
return in_array($this, [self::URGENT, self::HIGH]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets maximum delay before alert must be sent
|
||||
*/
|
||||
public function getMaxDelay(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::URGENT => 60, // 1 minute
|
||||
self::HIGH => 300, // 5 minutes
|
||||
self::MEDIUM => 1800, // 30 minutes
|
||||
self::LOW => 86400, // 24 hours
|
||||
};
|
||||
}
|
||||
}
|
||||
40
src/Framework/ErrorAggregation/Alerting/AlertChannel.php
Normal file
40
src/Framework/ErrorAggregation/Alerting/AlertChannel.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorAggregation\Alerting;
|
||||
|
||||
use App\Framework\ErrorAggregation\AlertUrgency;
|
||||
use App\Framework\ErrorAggregation\ErrorEvent;
|
||||
use App\Framework\ErrorAggregation\ErrorPattern;
|
||||
|
||||
/**
|
||||
* Interface for alert notification channels
|
||||
*/
|
||||
interface AlertChannel
|
||||
{
|
||||
/**
|
||||
* Gets the channel name (email, slack, sms, etc.)
|
||||
*/
|
||||
public function getName(): string;
|
||||
|
||||
/**
|
||||
* Sends an alert for an error pattern
|
||||
*/
|
||||
public function sendAlert(ErrorPattern $pattern, ErrorEvent $triggeringEvent, array $context = []): bool;
|
||||
|
||||
/**
|
||||
* Checks if this channel can handle the given urgency level
|
||||
*/
|
||||
public function canHandle(AlertUrgency $urgency): bool;
|
||||
|
||||
/**
|
||||
* Gets the delivery status of the last sent alert
|
||||
*/
|
||||
public function getLastDeliveryStatus(): array;
|
||||
|
||||
/**
|
||||
* Tests the channel connectivity
|
||||
*/
|
||||
public function test(): bool;
|
||||
}
|
||||
371
src/Framework/ErrorAggregation/Alerting/AlertManager.php
Normal file
371
src/Framework/ErrorAggregation/Alerting/AlertManager.php
Normal file
@@ -0,0 +1,371 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorAggregation\Alerting;
|
||||
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\ErrorAggregation\AlertUrgency;
|
||||
use App\Framework\ErrorAggregation\ErrorEvent;
|
||||
use App\Framework\ErrorAggregation\ErrorPattern;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Queue\Queue;
|
||||
|
||||
/**
|
||||
* Manages alert distribution across multiple channels
|
||||
*/
|
||||
final readonly class AlertManager
|
||||
{
|
||||
private const THROTTLE_CACHE_PREFIX = 'alert_throttle:';
|
||||
private const ESCALATION_CACHE_PREFIX = 'alert_escalation:';
|
||||
|
||||
/**
|
||||
* @var array<string, AlertChannel>
|
||||
*/
|
||||
private array $channels;
|
||||
|
||||
/**
|
||||
* @var array<AlertUrgency, array<string>>
|
||||
*/
|
||||
private array $channelsByUrgency;
|
||||
|
||||
public function __construct(
|
||||
private Cache $cache,
|
||||
private Clock $clock,
|
||||
private Queue $retryQueue,
|
||||
private ?Logger $logger = null,
|
||||
array $channels = [],
|
||||
private array $throttleConfig = [],
|
||||
private array $escalationConfig = []
|
||||
) {
|
||||
$this->channels = [];
|
||||
$this->channelsByUrgency = [];
|
||||
|
||||
foreach ($channels as $channel) {
|
||||
$this->addChannel($channel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an alert channel
|
||||
*/
|
||||
public function addChannel(AlertChannel $channel): void
|
||||
{
|
||||
$this->channels[$channel->getName()] = $channel;
|
||||
|
||||
// Map channels to urgency levels they can handle
|
||||
foreach (AlertUrgency::cases() as $urgency) {
|
||||
if ($channel->canHandle($urgency)) {
|
||||
$this->channelsByUrgency[$urgency->value][] = $channel->getName();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends alert for error pattern
|
||||
*/
|
||||
public function sendAlert(ErrorPattern $pattern, ErrorEvent $triggeringEvent, array $context = []): array
|
||||
{
|
||||
$urgency = $pattern->getAlertUrgency();
|
||||
$results = [];
|
||||
|
||||
// Check throttling
|
||||
if ($this->isThrottled($pattern, $urgency)) {
|
||||
$this->log('info', "Alert throttled for pattern {$pattern->id->toString()}", [
|
||||
'pattern_id' => $pattern->id->toString(),
|
||||
'urgency' => $urgency->value,
|
||||
]);
|
||||
|
||||
return ['status' => 'throttled', 'channels' => []];
|
||||
}
|
||||
|
||||
// Get channels for this urgency level
|
||||
$channelNames = $this->channelsByUrgency[$urgency->value] ?? [];
|
||||
|
||||
if (empty($channelNames)) {
|
||||
$this->log('warning', "No channels configured for urgency level {$urgency->value}");
|
||||
|
||||
return ['status' => 'no_channels', 'channels' => []];
|
||||
}
|
||||
|
||||
$sentChannels = [];
|
||||
$failedChannels = [];
|
||||
|
||||
foreach ($channelNames as $channelName) {
|
||||
$channel = $this->channels[$channelName];
|
||||
|
||||
try {
|
||||
$success = $channel->sendAlert($pattern, $triggeringEvent, $context);
|
||||
|
||||
if ($success) {
|
||||
$sentChannels[] = $channelName;
|
||||
$this->log('info', "Alert sent via {$channelName}", [
|
||||
'pattern_id' => $pattern->id->toString(),
|
||||
'channel' => $channelName,
|
||||
]);
|
||||
} else {
|
||||
$failedChannels[] = $channelName;
|
||||
$this->scheduleRetry($pattern, $triggeringEvent, $channelName, $context);
|
||||
}
|
||||
|
||||
$results[$channelName] = [
|
||||
'status' => $success ? 'sent' : 'failed',
|
||||
'delivery_status' => $channel->getLastDeliveryStatus(),
|
||||
];
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$failedChannels[] = $channelName;
|
||||
$this->scheduleRetry($pattern, $triggeringEvent, $channelName, $context);
|
||||
|
||||
$results[$channelName] = [
|
||||
'status' => 'error',
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
|
||||
$this->log('error', "Alert failed for channel {$channelName}: {$e->getMessage()}", [
|
||||
'pattern_id' => $pattern->id->toString(),
|
||||
'channel' => $channelName,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Update throttling
|
||||
$this->updateThrottle($pattern, $urgency);
|
||||
|
||||
// Schedule escalation if needed
|
||||
if (! empty($failedChannels) && $urgency->requiresImmediateProcessing()) {
|
||||
$this->scheduleEscalation($pattern, $triggeringEvent, $failedChannels, $context);
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => empty($failedChannels) ? 'success' : 'partial',
|
||||
'channels' => $results,
|
||||
'sent' => $sentChannels,
|
||||
'failed' => $failedChannels,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes alert retry from queue
|
||||
*/
|
||||
public function processRetry(array $retryData): bool
|
||||
{
|
||||
$patternData = $retryData['pattern'];
|
||||
$eventData = $retryData['event'];
|
||||
$channelName = $retryData['channel'];
|
||||
$context = $retryData['context'] ?? [];
|
||||
$attempt = $retryData['attempt'] ?? 1;
|
||||
|
||||
if (! isset($this->channels[$channelName])) {
|
||||
$this->log('error', "Channel {$channelName} not found for retry");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$pattern = ErrorPattern::fromArray($patternData);
|
||||
$event = ErrorEvent::fromArray($eventData);
|
||||
$channel = $this->channels[$channelName];
|
||||
|
||||
try {
|
||||
$success = $channel->sendAlert($pattern, $event, $context);
|
||||
|
||||
if ($success) {
|
||||
$this->log('info', "Retry successful for channel {$channelName}", [
|
||||
'pattern_id' => $pattern->id->toString(),
|
||||
'attempt' => $attempt,
|
||||
]);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
$urgency = $pattern->getAlertUrgency();
|
||||
$retryStrategy = $urgency->getRetryStrategy();
|
||||
|
||||
if ($attempt < $retryStrategy['attempts']) {
|
||||
// Schedule next retry
|
||||
$nextDelay = $retryStrategy['delays'][$attempt] ?? 300;
|
||||
$this->scheduleRetry($pattern, $event, $channelName, $context, $attempt + 1, $nextDelay);
|
||||
|
||||
$this->log('info', "Retry {$attempt} failed, scheduling attempt " . ($attempt + 1), [
|
||||
'pattern_id' => $pattern->id->toString(),
|
||||
'channel' => $channelName,
|
||||
'next_delay' => $nextDelay,
|
||||
]);
|
||||
} else {
|
||||
$this->log('error', "All retry attempts exhausted for channel {$channelName}", [
|
||||
'pattern_id' => $pattern->id->toString(),
|
||||
'total_attempts' => $attempt,
|
||||
]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->log('error', "Retry failed with exception: {$e->getMessage()}", [
|
||||
'pattern_id' => $pattern->id->toString(),
|
||||
'channel' => $channelName,
|
||||
'attempt' => $attempt,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests all configured channels
|
||||
*/
|
||||
public function testChannels(): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($this->channels as $name => $channel) {
|
||||
try {
|
||||
$success = $channel->test();
|
||||
$results[$name] = [
|
||||
'status' => $success ? 'ok' : 'failed',
|
||||
'test_time' => $this->clock->now()->format('c'),
|
||||
];
|
||||
|
||||
$this->log('info', "Channel {$name} test: " . ($success ? 'OK' : 'FAILED'));
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$results[$name] = [
|
||||
'status' => 'error',
|
||||
'error' => $e->getMessage(),
|
||||
'test_time' => $this->clock->now()->format('c'),
|
||||
];
|
||||
|
||||
$this->log('error', "Channel {$name} test error: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets alert statistics
|
||||
*/
|
||||
public function getStatistics(\DateTimeImmutable $from, \DateTimeImmutable $to): array
|
||||
{
|
||||
// This would typically query a database or metrics store
|
||||
// For now, return basic info
|
||||
return [
|
||||
'period' => [
|
||||
'from' => $from->format('c'),
|
||||
'to' => $to->format('c'),
|
||||
],
|
||||
'channels' => array_keys($this->channels),
|
||||
'urgency_mapping' => $this->channelsByUrgency,
|
||||
];
|
||||
}
|
||||
|
||||
private function isThrottled(ErrorPattern $pattern, AlertUrgency $urgency): bool
|
||||
{
|
||||
$throttleKey = self::THROTTLE_CACHE_PREFIX . $pattern->fingerprint;
|
||||
$lastAlert = $this->cache->get($throttleKey);
|
||||
|
||||
if ($lastAlert === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$throttleWindow = $this->getThrottleWindow($urgency);
|
||||
$now = $this->clock->now()->getTimestamp();
|
||||
|
||||
return ($now - $lastAlert) < $throttleWindow;
|
||||
}
|
||||
|
||||
private function updateThrottle(ErrorPattern $pattern, AlertUrgency $urgency): void
|
||||
{
|
||||
$throttleKey = self::THROTTLE_CACHE_PREFIX . $pattern->fingerprint;
|
||||
$throttleWindow = $this->getThrottleWindow($urgency);
|
||||
|
||||
$this->cache->set($throttleKey, $this->clock->now()->getTimestamp(), $throttleWindow);
|
||||
}
|
||||
|
||||
private function getThrottleWindow(AlertUrgency $urgency): int
|
||||
{
|
||||
return $this->throttleConfig[$urgency->value] ?? match ($urgency) {
|
||||
AlertUrgency::URGENT => 300, // 5 minutes
|
||||
AlertUrgency::HIGH => 900, // 15 minutes
|
||||
AlertUrgency::MEDIUM => 3600, // 1 hour
|
||||
AlertUrgency::LOW => 86400, // 24 hours
|
||||
};
|
||||
}
|
||||
|
||||
private function scheduleRetry(
|
||||
ErrorPattern $pattern,
|
||||
ErrorEvent $event,
|
||||
string $channelName,
|
||||
array $context,
|
||||
int $attempt = 1,
|
||||
?int $delay = null
|
||||
): void {
|
||||
$urgency = $pattern->getAlertUrgency();
|
||||
$retryStrategy = $urgency->getRetryStrategy();
|
||||
|
||||
$delay ??= $retryStrategy['delays'][$attempt - 1] ?? 300;
|
||||
|
||||
$retryData = [
|
||||
'pattern' => $pattern->toArray(),
|
||||
'event' => $event->toArray(),
|
||||
'channel' => $channelName,
|
||||
'context' => $context,
|
||||
'attempt' => $attempt,
|
||||
'scheduled_at' => $this->clock->now()->format('c'),
|
||||
];
|
||||
|
||||
$this->retryQueue->pushDelayed('alert_retries', $retryData, $delay);
|
||||
}
|
||||
|
||||
private function scheduleEscalation(
|
||||
ErrorPattern $pattern,
|
||||
ErrorEvent $event,
|
||||
array $failedChannels,
|
||||
array $context
|
||||
): void {
|
||||
$escalationKey = self::ESCALATION_CACHE_PREFIX . $pattern->id->toString();
|
||||
|
||||
// Don't escalate if already escalated recently
|
||||
if ($this->cache->get($escalationKey) !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$urgency = $pattern->getAlertUrgency();
|
||||
$escalationTimeout = $urgency->getEscalationTimeout();
|
||||
|
||||
$escalationData = [
|
||||
'pattern' => $pattern->toArray(),
|
||||
'event' => $event->toArray(),
|
||||
'failed_channels' => $failedChannels,
|
||||
'context' => $context,
|
||||
'scheduled_at' => $this->clock->now()->format('c'),
|
||||
];
|
||||
|
||||
$this->retryQueue->pushDelayed('alert_escalations', $escalationData, $escalationTimeout);
|
||||
|
||||
// Mark as escalated
|
||||
$this->cache->set($escalationKey, true, $escalationTimeout * 2);
|
||||
|
||||
$this->log('warning', "Alert escalation scheduled", [
|
||||
'pattern_id' => $pattern->id->toString(),
|
||||
'failed_channels' => $failedChannels,
|
||||
'escalation_timeout' => $escalationTimeout,
|
||||
]);
|
||||
}
|
||||
|
||||
private function log(string $level, string $message, array $context = []): void
|
||||
{
|
||||
if ($this->logger) {
|
||||
match ($level) {
|
||||
'debug' => $this->logger->debug("[AlertManager] {$message}", $context),
|
||||
'info' => $this->logger->info("[AlertManager] {$message}", $context),
|
||||
'warning' => $this->logger->warning("[AlertManager] {$message}", $context),
|
||||
'error' => $this->logger->error("[AlertManager] {$message}", $context),
|
||||
default => $this->logger->info("[AlertManager] {$message}", $context),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
240
src/Framework/ErrorAggregation/Alerting/EmailAlertChannel.php
Normal file
240
src/Framework/ErrorAggregation/Alerting/EmailAlertChannel.php
Normal file
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorAggregation\Alerting;
|
||||
|
||||
use App\Framework\ErrorAggregation\AlertUrgency;
|
||||
use App\Framework\ErrorAggregation\ErrorEvent;
|
||||
use App\Framework\ErrorAggregation\ErrorPattern;
|
||||
use App\Framework\Mail\Message;
|
||||
use App\Framework\Mail\Transport\TransportInterface;
|
||||
|
||||
/**
|
||||
* Email alert channel implementation
|
||||
*/
|
||||
final readonly class EmailAlertChannel implements AlertChannel
|
||||
{
|
||||
private array $lastDeliveryStatus;
|
||||
|
||||
public function __construct(
|
||||
private TransportInterface $transport,
|
||||
private array $recipients = [],
|
||||
private string $fromEmail = 'alerts@example.com',
|
||||
private string $fromName = 'Error Alert System'
|
||||
) {
|
||||
$this->lastDeliveryStatus = ['status' => 'pending'];
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'email';
|
||||
}
|
||||
|
||||
public function sendAlert(ErrorPattern $pattern, ErrorEvent $triggeringEvent, array $context = []): bool
|
||||
{
|
||||
try {
|
||||
$subject = $this->buildSubject($pattern, $triggeringEvent);
|
||||
$body = $this->buildBody($pattern, $triggeringEvent, $context);
|
||||
|
||||
$message = new Message(
|
||||
to: $this->recipients,
|
||||
from: $this->fromEmail,
|
||||
fromName: $this->fromName,
|
||||
subject: $subject,
|
||||
body: $body,
|
||||
isHtml: true
|
||||
);
|
||||
|
||||
$result = $this->transport->send($message);
|
||||
|
||||
$this->lastDeliveryStatus = [
|
||||
'status' => $result ? 'sent' : 'failed',
|
||||
'timestamp' => date('c'),
|
||||
'pattern_id' => $pattern->id->toString(),
|
||||
'recipients' => $this->recipients,
|
||||
];
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->lastDeliveryStatus = [
|
||||
'status' => 'error',
|
||||
'timestamp' => date('c'),
|
||||
'error' => $e->getMessage(),
|
||||
'pattern_id' => $pattern->id->toString(),
|
||||
];
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function canHandle(AlertUrgency $urgency): bool
|
||||
{
|
||||
// Email can handle all urgency levels
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getLastDeliveryStatus(): array
|
||||
{
|
||||
return $this->lastDeliveryStatus;
|
||||
}
|
||||
|
||||
public function test(): bool
|
||||
{
|
||||
try {
|
||||
$testMessage = new Message(
|
||||
to: $this->recipients,
|
||||
from: $this->fromEmail,
|
||||
fromName: $this->fromName,
|
||||
subject: 'Alert System Test',
|
||||
body: 'This is a test message from the error alert system.',
|
||||
isHtml: false
|
||||
);
|
||||
|
||||
return $this->transport->send($testMessage);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildSubject(ErrorPattern $pattern, ErrorEvent $triggeringEvent): string
|
||||
{
|
||||
$urgency = $pattern->getAlertUrgency();
|
||||
$urgencyPrefix = match ($urgency) {
|
||||
AlertUrgency::URGENT => '[URGENT]',
|
||||
AlertUrgency::HIGH => '[HIGH]',
|
||||
AlertUrgency::MEDIUM => '[MEDIUM]',
|
||||
AlertUrgency::LOW => '[LOW]',
|
||||
};
|
||||
|
||||
$subject = "{$urgencyPrefix} Error Alert: {$pattern->service}";
|
||||
|
||||
if ($pattern->isCriticalPattern()) {
|
||||
$subject = "[CRITICAL] {$subject}";
|
||||
}
|
||||
|
||||
return $subject;
|
||||
}
|
||||
|
||||
private function buildBody(ErrorPattern $pattern, ErrorEvent $triggeringEvent, array $context): string
|
||||
{
|
||||
$urgency = $pattern->getAlertUrgency();
|
||||
$urgencyColor = match ($urgency) {
|
||||
AlertUrgency::URGENT => '#dc2626',
|
||||
AlertUrgency::HIGH => '#ea580c',
|
||||
AlertUrgency::MEDIUM => '#ca8a04',
|
||||
AlertUrgency::LOW => '#2563eb',
|
||||
};
|
||||
|
||||
$html = "
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset='UTF-8'>
|
||||
<title>Error Alert</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.header { background: {$urgencyColor}; color: white; padding: 20px; border-radius: 5px 5px 0 0; }
|
||||
.content { background: #f9f9f9; padding: 20px; border: 1px solid #ddd; border-top: none; }
|
||||
.details { background: white; padding: 15px; margin: 10px 0; border-radius: 5px; border-left: 4px solid {$urgencyColor}; }
|
||||
.metric { display: inline-block; margin-right: 20px; }
|
||||
.metric strong { color: {$urgencyColor}; }
|
||||
.footer { background: #333; color: white; padding: 10px; text-align: center; border-radius: 0 0 5px 5px; font-size: 12px; }
|
||||
.critical-banner { background: #dc2626; color: white; padding: 10px; text-align: center; font-weight: bold; margin-bottom: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
";
|
||||
|
||||
if ($pattern->isCriticalPattern()) {
|
||||
$html .= "<div class='critical-banner'>🚨 CRITICAL PATTERN DETECTED 🚨</div>";
|
||||
}
|
||||
|
||||
$html .= "
|
||||
<div class='header'>
|
||||
<h2>{$pattern->severity->getIcon()} Error Alert - {$pattern->service}</h2>
|
||||
<p>Urgency: <strong>{$urgency->value}</strong> | Pattern ID: {$pattern->id->toString()}</p>
|
||||
</div>
|
||||
|
||||
<div class='content'>
|
||||
<div class='details'>
|
||||
<h3>Error Pattern Details</h3>
|
||||
<p><strong>Service:</strong> {$pattern->service}</p>
|
||||
<p><strong>Component:</strong> {$pattern->component}</p>
|
||||
<p><strong>Operation:</strong> {$pattern->operation}</p>
|
||||
<p><strong>Error Code:</strong> {$pattern->errorCode}</p>
|
||||
<p><strong>Message:</strong> {$pattern->normalizedMessage}</p>
|
||||
</div>
|
||||
|
||||
<div class='details'>
|
||||
<h3>Occurrence Statistics</h3>
|
||||
<div class='metric'><strong>{$pattern->occurrenceCount}</strong> Total Occurrences</div>
|
||||
<div class='metric'><strong>" . number_format($pattern->getFrequency(), 2) . "</strong> Errors/min</div>
|
||||
<div class='metric'><strong>" . count($pattern->affectedUsers) . "</strong> Affected Users</div>
|
||||
<div class='metric'><strong>" . count($pattern->affectedIps) . "</strong> Affected IPs</div>
|
||||
</div>
|
||||
|
||||
<div class='details'>
|
||||
<h3>Timeline</h3>
|
||||
<p><strong>First Seen:</strong> {$pattern->firstOccurrence->format('Y-m-d H:i:s T')}</p>
|
||||
<p><strong>Last Seen:</strong> {$pattern->lastOccurrence->format('Y-m-d H:i:s T')}</p>
|
||||
<p><strong>Duration:</strong> " . $this->formatDuration($pattern->firstOccurrence, $pattern->lastOccurrence) . "</p>
|
||||
</div>
|
||||
|
||||
<div class='details'>
|
||||
<h3>Triggering Event</h3>
|
||||
<p><strong>Event ID:</strong> {$triggeringEvent->id->toString()}</p>
|
||||
<p><strong>Request ID:</strong> {$triggeringEvent->requestId}</p>
|
||||
<p><strong>User ID:</strong> " . ($triggeringEvent->userId ?? 'N/A') . "</p>
|
||||
<p><strong>Client IP:</strong> {$triggeringEvent->clientIp}</p>
|
||||
<p><strong>Occurred At:</strong> {$triggeringEvent->occurredAt->format('Y-m-d H:i:s T')}</p>
|
||||
</div>
|
||||
";
|
||||
|
||||
if ($triggeringEvent->isSecurityEvent) {
|
||||
$html .= "
|
||||
<div class='details' style='border-left-color: #dc2626; background-color: #fef2f2;'>
|
||||
<h3>🛡️ Security Event</h3>
|
||||
<p><strong>This is a security-related error that requires immediate attention.</strong></p>
|
||||
</div>
|
||||
";
|
||||
}
|
||||
|
||||
if (! empty($context['dashboard_url'])) {
|
||||
$html .= "
|
||||
<div class='details'>
|
||||
<h3>Actions</h3>
|
||||
<p><a href='{$context['dashboard_url']}' style='background: {$urgencyColor}; color: white; padding: 10px 15px; text-decoration: none; border-radius: 3px;'>View in Dashboard</a></p>
|
||||
</div>
|
||||
";
|
||||
}
|
||||
|
||||
$html .= "
|
||||
</div>
|
||||
|
||||
<div class='footer'>
|
||||
Error Alert System | Pattern Fingerprint: {$pattern->fingerprint}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
";
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function formatDuration(\DateTimeImmutable $start, \DateTimeImmutable $end): string
|
||||
{
|
||||
$diff = $end->getTimestamp() - $start->getTimestamp();
|
||||
|
||||
if ($diff < 60) {
|
||||
return "{$diff} seconds";
|
||||
} elseif ($diff < 3600) {
|
||||
return round($diff / 60) . " minutes";
|
||||
} elseif ($diff < 86400) {
|
||||
return round($diff / 3600, 1) . " hours";
|
||||
} else {
|
||||
return round($diff / 86400, 1) . " days";
|
||||
}
|
||||
}
|
||||
}
|
||||
108
src/Framework/ErrorAggregation/Commands/AlertCommand.php
Normal file
108
src/Framework/ErrorAggregation/Commands/AlertCommand.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorAggregation\Commands;
|
||||
|
||||
use App\Framework\Console\ConsoleCommand;
|
||||
use App\Framework\Console\ConsoleInput;
|
||||
use App\Framework\Console\ConsoleOutput;
|
||||
use App\Framework\Console\ExitCode;
|
||||
use App\Framework\ErrorAggregation\Alerting\AlertManager;
|
||||
|
||||
/**
|
||||
* Console commands for alert management
|
||||
*/
|
||||
final readonly class AlertCommand
|
||||
{
|
||||
public function __construct(
|
||||
private AlertManager $alertManager
|
||||
) {
|
||||
}
|
||||
|
||||
#[ConsoleCommand(
|
||||
name: 'alerts:test',
|
||||
description: 'Test all configured alert channels'
|
||||
)]
|
||||
public function test(ConsoleInput $input, ConsoleOutput $output): int
|
||||
{
|
||||
$output->writeLine('<info>Testing alert channels...</info>');
|
||||
$output->writeLine('');
|
||||
|
||||
$results = $this->alertManager->testChannels();
|
||||
|
||||
$allPassed = true;
|
||||
|
||||
foreach ($results as $channel => $result) {
|
||||
$statusIcon = match ($result['status']) {
|
||||
'ok' => '✅',
|
||||
'failed' => '❌',
|
||||
'error' => '💥',
|
||||
default => '❓',
|
||||
};
|
||||
|
||||
$output->writeLine("{$statusIcon} <comment>{$channel}</comment>: {$result['status']}");
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$output->writeLine(" <error>Error:</error> {$result['error']}");
|
||||
$allPassed = false;
|
||||
}
|
||||
|
||||
$output->writeLine(" <comment>Tested at:</comment> {$result['test_time']}");
|
||||
$output->writeLine('');
|
||||
|
||||
if ($result['status'] !== 'ok') {
|
||||
$allPassed = false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($allPassed) {
|
||||
$output->writeLine('<info>All alert channels are working correctly!</info>');
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
} else {
|
||||
$output->writeLine('<error>Some alert channels have issues. Please check the configuration.</error>');
|
||||
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
#[ConsoleCommand(
|
||||
name: 'alerts:stats',
|
||||
description: 'Show alert system statistics'
|
||||
)]
|
||||
public function stats(ConsoleInput $input, ConsoleOutput $output): int
|
||||
{
|
||||
$args = $input->getArguments();
|
||||
$hours = (int) ($args[0] ?? 24);
|
||||
|
||||
$from = (new \DateTimeImmutable())->sub(new \DateInterval("PT{$hours}H"));
|
||||
$to = new \DateTimeImmutable();
|
||||
|
||||
$stats = $this->alertManager->getStatistics($from, $to);
|
||||
|
||||
$output->writeLine("<info>Alert Statistics (last {$hours} hours)</info>");
|
||||
$output->writeLine('');
|
||||
|
||||
$output->writeLine('<comment>Configured Channels:</comment>');
|
||||
foreach ($stats['channels'] as $channel) {
|
||||
$output->writeLine(" • {$channel}");
|
||||
}
|
||||
$output->writeLine('');
|
||||
|
||||
$output->writeLine('<comment>Urgency Mapping:</comment>');
|
||||
foreach ($stats['urgency_mapping'] as $urgency => $channels) {
|
||||
$urgencyIcon = match ($urgency) {
|
||||
'urgent' => '🚨',
|
||||
'high' => '⚠️',
|
||||
'medium' => 'ℹ️',
|
||||
'low' => '📝',
|
||||
default => '❓',
|
||||
};
|
||||
|
||||
$output->writeLine(" {$urgencyIcon} <comment>{$urgency}:</comment> " . implode(', ', $channels));
|
||||
}
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorAggregation\Commands;
|
||||
|
||||
use App\Framework\Console\ConsoleCommand;
|
||||
use App\Framework\Console\ConsoleInput;
|
||||
use App\Framework\Console\ConsoleOutput;
|
||||
use App\Framework\Console\ExitCode;
|
||||
use App\Framework\ErrorAggregation\ErrorAggregator;
|
||||
use App\Framework\ErrorAggregation\ErrorSeverity;
|
||||
|
||||
/**
|
||||
* Console command for error aggregation statistics
|
||||
*/
|
||||
final readonly class ErrorAggregationStatsCommand
|
||||
{
|
||||
public function __construct(
|
||||
private ErrorAggregator $errorAggregator
|
||||
) {
|
||||
}
|
||||
|
||||
#[ConsoleCommand(
|
||||
name: 'error-aggregation:stats',
|
||||
description: 'Show error aggregation statistics'
|
||||
)]
|
||||
public function stats(ConsoleInput $input, ConsoleOutput $output): int
|
||||
{
|
||||
$hours = (int) ($input->getArguments()[0] ?? 24);
|
||||
|
||||
$from = (new \DateTimeImmutable())->sub(new \DateInterval("PT{$hours}H"));
|
||||
$to = new \DateTimeImmutable();
|
||||
|
||||
$stats = $this->errorAggregator->getStatistics($from, $to);
|
||||
|
||||
$output->writeLine("<info>Error Aggregation Statistics (last {$hours} hours)</info>");
|
||||
$output->writeLine('');
|
||||
|
||||
$output->writeLine(sprintf('📊 <comment>Total Events:</comment> %d', $stats['total_events'] ?? 0));
|
||||
$output->writeLine(sprintf('🏢 <comment>Services Affected:</comment> %d', $stats['services_affected'] ?? 0));
|
||||
$output->writeLine(sprintf('👥 <comment>Users Affected:</comment> %d', $stats['users_affected'] ?? 0));
|
||||
$output->writeLine(sprintf('🌐 <comment>IPs Affected:</comment> %d', $stats['ips_affected'] ?? 0));
|
||||
$output->writeLine('');
|
||||
|
||||
if (! empty($stats['by_severity'])) {
|
||||
$output->writeLine('<comment>By Severity:</comment>');
|
||||
foreach ($stats['by_severity'] as $severity => $count) {
|
||||
$severityEnum = ErrorSeverity::from($severity);
|
||||
$icon = $severityEnum->getIcon();
|
||||
$output->writeLine(" {$icon} {$severity}: {$count}");
|
||||
}
|
||||
$output->writeLine('');
|
||||
}
|
||||
|
||||
// Active patterns
|
||||
$activePatterns = $this->errorAggregator->getActivePatterns(10);
|
||||
|
||||
if (! empty($activePatterns)) {
|
||||
$output->writeLine('<comment>Top Active Patterns:</comment>');
|
||||
foreach ($activePatterns as $pattern) {
|
||||
$icon = $pattern->severity->getIcon();
|
||||
$status = $pattern->isAcknowledged ? '✅ ACK' : '⚠️ NEW';
|
||||
|
||||
$output->writeLine(sprintf(
|
||||
" %s [%s] %s.%s (%d occurrences, %.1f/min)",
|
||||
$icon,
|
||||
$status,
|
||||
$pattern->service,
|
||||
$pattern->component,
|
||||
$pattern->occurrenceCount,
|
||||
$pattern->getFrequency()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
#[ConsoleCommand(
|
||||
name: 'error-aggregation:health',
|
||||
description: 'Check error aggregation system health'
|
||||
)]
|
||||
public function health(ConsoleInput $input, ConsoleOutput $output): int
|
||||
{
|
||||
$health = $this->errorAggregator->getHealthStatus();
|
||||
|
||||
$output->writeLine('<info>Error Aggregation Health Status</info>');
|
||||
$output->writeLine('');
|
||||
|
||||
$statusIcon = $health['status'] === 'healthy' ? '✅' : '❌';
|
||||
$output->writeLine("Status: {$statusIcon} {$health['status']}");
|
||||
|
||||
if (isset($health['last_hour_events'])) {
|
||||
$output->writeLine("Events (last hour): {$health['last_hour_events']}");
|
||||
}
|
||||
|
||||
if (isset($health['active_patterns'])) {
|
||||
$output->writeLine("Active patterns: {$health['active_patterns']}");
|
||||
}
|
||||
|
||||
if (isset($health['storage_health'])) {
|
||||
$storageHealth = $health['storage_health'];
|
||||
$storageIcon = $storageHealth['status'] === 'healthy' ? '✅' : '❌';
|
||||
$output->writeLine("Storage: {$storageIcon} {$storageHealth['status']}");
|
||||
|
||||
if (isset($storageHealth['event_count'])) {
|
||||
$output->writeLine(" Total events: {$storageHealth['event_count']}");
|
||||
}
|
||||
|
||||
if (isset($storageHealth['pattern_count'])) {
|
||||
$output->writeLine(" Total patterns: {$storageHealth['pattern_count']}");
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($health['error'])) {
|
||||
$output->writeLine("<error>Error: {$health['error']}</error>");
|
||||
}
|
||||
|
||||
return $health['status'] === 'healthy' ? ExitCode::SUCCESS : ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
#[ConsoleCommand(
|
||||
name: 'error-aggregation:cleanup',
|
||||
description: 'Clean up old error data based on retention policy'
|
||||
)]
|
||||
public function cleanup(ConsoleInput $input, ConsoleOutput $output): int
|
||||
{
|
||||
$output->writeLine('<info>Running error aggregation cleanup...</info>');
|
||||
|
||||
$deletedCount = $this->errorAggregator->cleanup();
|
||||
|
||||
$output->writeLine("<info>Cleanup completed. Deleted {$deletedCount} records.</info>");
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
}
|
||||
157
src/Framework/ErrorAggregation/Commands/ErrorPatternsCommand.php
Normal file
157
src/Framework/ErrorAggregation/Commands/ErrorPatternsCommand.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorAggregation\Commands;
|
||||
|
||||
use App\Framework\Console\ConsoleCommand;
|
||||
use App\Framework\Console\ConsoleInput;
|
||||
use App\Framework\Console\ConsoleOutput;
|
||||
use App\Framework\Console\ExitCode;
|
||||
use App\Framework\ErrorAggregation\ErrorAggregator;
|
||||
|
||||
/**
|
||||
* Console commands for error pattern management
|
||||
*/
|
||||
final readonly class ErrorPatternsCommand
|
||||
{
|
||||
public function __construct(
|
||||
private ErrorAggregator $errorAggregator
|
||||
) {
|
||||
}
|
||||
|
||||
#[ConsoleCommand(
|
||||
name: 'error-patterns:list',
|
||||
description: 'List active error patterns'
|
||||
)]
|
||||
public function list(ConsoleInput $input, ConsoleOutput $output): int
|
||||
{
|
||||
$args = $input->getArguments();
|
||||
$service = $args[0] ?? null;
|
||||
$limit = (int) ($args[1] ?? 20);
|
||||
|
||||
if ($service) {
|
||||
$patterns = $this->errorAggregator->getPatternsByService($service, $limit);
|
||||
$output->writeLine("<info>Error Patterns for service: {$service}</info>");
|
||||
} else {
|
||||
$patterns = $this->errorAggregator->getActivePatterns($limit);
|
||||
$output->writeLine('<info>Active Error Patterns</info>');
|
||||
}
|
||||
|
||||
$output->writeLine('');
|
||||
|
||||
if (empty($patterns)) {
|
||||
$output->writeLine('<comment>No patterns found</comment>');
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
$icon = $pattern->severity->getIcon();
|
||||
$status = $pattern->isAcknowledged ? '✅' : ($pattern->isCriticalPattern() ? '🚨' : '⚠️');
|
||||
|
||||
$output->writeLine(sprintf(
|
||||
"%s <comment>%s</comment> [%s] %s.%s.%s",
|
||||
$icon,
|
||||
$pattern->id->toString(),
|
||||
$status,
|
||||
$pattern->service,
|
||||
$pattern->component,
|
||||
$pattern->operation
|
||||
));
|
||||
|
||||
$output->writeLine(" Message: {$pattern->normalizedMessage}");
|
||||
$output->writeLine(sprintf(
|
||||
" Count: <comment>%d</comment> | Frequency: <comment>%.1f/min</comment> | Users: <comment>%d</comment>",
|
||||
$pattern->occurrenceCount,
|
||||
$pattern->getFrequency(),
|
||||
count($pattern->affectedUsers)
|
||||
));
|
||||
|
||||
$output->writeLine(sprintf(
|
||||
" First: %s | Last: %s",
|
||||
$pattern->firstOccurrence->format('M j H:i'),
|
||||
$pattern->lastOccurrence->format('M j H:i')
|
||||
));
|
||||
|
||||
if ($pattern->isAcknowledged) {
|
||||
$output->writeLine(" <info>Acknowledged by: {$pattern->acknowledgedBy} at {$pattern->acknowledgedAt->format('M j H:i')}</info>");
|
||||
if ($pattern->resolution) {
|
||||
$output->writeLine(" Resolution: {$pattern->resolution}");
|
||||
}
|
||||
}
|
||||
|
||||
$output->writeLine('');
|
||||
}
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
#[ConsoleCommand(
|
||||
name: 'error-patterns:acknowledge',
|
||||
description: 'Acknowledge an error pattern'
|
||||
)]
|
||||
public function acknowledge(ConsoleInput $input, ConsoleOutput $output): int
|
||||
{
|
||||
$args = $input->getArguments();
|
||||
$patternId = $args[0] ?? null;
|
||||
$acknowledgedBy = $args[1] ?? 'console';
|
||||
$resolution = $args[2] ?? null;
|
||||
|
||||
if (! $patternId) {
|
||||
$output->writeLine('<error>Pattern ID is required</error>');
|
||||
$output->writeLine('Usage: error-patterns:acknowledge <pattern-id> [acknowledged-by] [resolution]');
|
||||
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
$success = $this->errorAggregator->acknowledgePattern($patternId, $acknowledgedBy, $resolution);
|
||||
|
||||
if ($success) {
|
||||
$output->writeLine("<info>Pattern {$patternId} acknowledged successfully</info>");
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
} else {
|
||||
$output->writeLine("<error>Failed to acknowledge pattern {$patternId} (pattern not found)</error>");
|
||||
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
#[ConsoleCommand(
|
||||
name: 'error-patterns:resolve',
|
||||
description: 'Resolve an error pattern'
|
||||
)]
|
||||
public function resolve(ConsoleInput $input, ConsoleOutput $output): int
|
||||
{
|
||||
$args = $input->getArguments();
|
||||
$patternId = $args[0] ?? null;
|
||||
$resolution = $args[1] ?? null;
|
||||
|
||||
if (! $patternId) {
|
||||
$output->writeLine('<error>Pattern ID is required</error>');
|
||||
$output->writeLine('Usage: error-patterns:resolve <pattern-id> <resolution>');
|
||||
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
if (! $resolution) {
|
||||
$output->writeLine('<error>Resolution description is required</error>');
|
||||
$output->writeLine('Usage: error-patterns:resolve <pattern-id> <resolution>');
|
||||
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
$success = $this->errorAggregator->resolvePattern($patternId, $resolution);
|
||||
|
||||
if ($success) {
|
||||
$output->writeLine("<info>Pattern {$patternId} resolved successfully</info>");
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
} else {
|
||||
$output->writeLine("<error>Failed to resolve pattern {$patternId} (pattern not found)</error>");
|
||||
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorAggregation;
|
||||
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\DI\Initializer;
|
||||
use App\Framework\ErrorAggregation\Alerting\AlertManager;
|
||||
use App\Framework\ErrorAggregation\Alerting\EmailAlertChannel;
|
||||
use App\Framework\ErrorAggregation\Storage\DatabaseErrorStorage;
|
||||
use App\Framework\ErrorAggregation\Storage\ErrorStorageInterface;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Mail\Transport\TransportInterface;
|
||||
use App\Framework\Queue\Queue;
|
||||
|
||||
/**
|
||||
* Initializer for Error Aggregation services
|
||||
*/
|
||||
final readonly class ErrorAggregationInitializer
|
||||
{
|
||||
#[Initializer]
|
||||
public function initialize(Container $container): void
|
||||
{
|
||||
// Storage
|
||||
$container->bind(ErrorStorageInterface::class, function (Container $container) {
|
||||
return new DatabaseErrorStorage(
|
||||
connection: $container->get(ConnectionInterface::class)
|
||||
);
|
||||
});
|
||||
|
||||
// Error Aggregator
|
||||
$container->bind(ErrorAggregator::class, function (Container $container) {
|
||||
return new ErrorAggregator(
|
||||
storage: $container->get(ErrorStorageInterface::class),
|
||||
cache: $container->get(Cache::class),
|
||||
clock: $container->get(Clock::class),
|
||||
alertQueue: $container->get(Queue::class),
|
||||
logger: $container->get(Logger::class),
|
||||
batchSize: (int) ($_ENV['ERROR_AGGREGATION_BATCH_SIZE'] ?? 100),
|
||||
maxRetentionDays: (int) ($_ENV['ERROR_AGGREGATION_MAX_RETENTION_DAYS'] ?? 90)
|
||||
);
|
||||
});
|
||||
|
||||
// Alert Manager
|
||||
$container->bind(AlertManager::class, function (Container $container) {
|
||||
$channels = [];
|
||||
|
||||
// Email channel (if configured)
|
||||
if (! empty($_ENV['ALERT_EMAIL_RECIPIENTS'])) {
|
||||
$recipients = explode(',', $_ENV['ALERT_EMAIL_RECIPIENTS']);
|
||||
$channels[] = new EmailAlertChannel(
|
||||
transport: $container->get(TransportInterface::class),
|
||||
recipients: array_map('trim', $recipients),
|
||||
fromEmail: $_ENV['ALERT_FROM_EMAIL'] ?? 'alerts@example.com',
|
||||
fromName: $_ENV['ALERT_FROM_NAME'] ?? 'Error Alert System'
|
||||
);
|
||||
}
|
||||
|
||||
return new AlertManager(
|
||||
cache: $container->get(Cache::class),
|
||||
clock: $container->get(Clock::class),
|
||||
retryQueue: $container->get(Queue::class),
|
||||
logger: $container->get(Logger::class),
|
||||
channels: $channels,
|
||||
throttleConfig: [
|
||||
'urgent' => (int) ($_ENV['ALERT_THROTTLE_URGENT'] ?? 300),
|
||||
'high' => (int) ($_ENV['ALERT_THROTTLE_HIGH'] ?? 900),
|
||||
'medium' => (int) ($_ENV['ALERT_THROTTLE_MEDIUM'] ?? 3600),
|
||||
'low' => (int) ($_ENV['ALERT_THROTTLE_LOW'] ?? 86400),
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
// Middleware
|
||||
$container->bind(ErrorAggregationMiddleware::class, function (Container $container) {
|
||||
return new ErrorAggregationMiddleware(
|
||||
errorAggregator: $container->get(ErrorAggregator::class),
|
||||
enabled: (bool) ($_ENV['ERROR_AGGREGATION_ENABLED'] ?? true)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorAggregation;
|
||||
|
||||
use App\Framework\Exception\ErrorHandlerContext;
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Middleware that captures errors for aggregation
|
||||
*/
|
||||
final readonly class ErrorAggregationMiddleware implements HttpMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private ErrorAggregator $errorAggregator,
|
||||
private bool $enabled = true
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
if (! $this->enabled) {
|
||||
return $next($context);
|
||||
}
|
||||
|
||||
try {
|
||||
return $next($context);
|
||||
} catch (Throwable $e) {
|
||||
// If we have an ErrorHandlerContext available, aggregate the error
|
||||
$errorHandlerContext = $stateManager->get('error_handler_context');
|
||||
|
||||
if ($errorHandlerContext instanceof ErrorHandlerContext) {
|
||||
try {
|
||||
$this->errorAggregator->processError($errorHandlerContext);
|
||||
} catch (Throwable $aggregateException) {
|
||||
// Don't let aggregation errors break the application
|
||||
// Could log this separately if needed
|
||||
}
|
||||
}
|
||||
|
||||
// Re-throw the original exception
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
338
src/Framework/ErrorAggregation/ErrorAggregator.php
Normal file
338
src/Framework/ErrorAggregation/ErrorAggregator.php
Normal file
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorAggregation;
|
||||
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Cache\CacheKey;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\ErrorAggregation\Storage\ErrorStorageInterface;
|
||||
use App\Framework\Exception\ErrorHandlerContext;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Queue\Queue;
|
||||
|
||||
/**
|
||||
* Central error aggregation engine
|
||||
* Collects, analyzes, and patterns errors for alerting and monitoring
|
||||
*/
|
||||
final readonly class ErrorAggregator
|
||||
{
|
||||
private const string PATTERN_CACHE_PREFIX = 'error_pattern:';
|
||||
private const int PATTERN_CACHE_TTL = 3600; // 1 hour
|
||||
|
||||
public function __construct(
|
||||
private ErrorStorageInterface $storage,
|
||||
private Cache $cache,
|
||||
private Clock $clock,
|
||||
private Queue $alertQueue,
|
||||
private ?Logger $logger = null,
|
||||
private int $batchSize = 100,
|
||||
private int $maxRetentionDays = 90,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a new error from ErrorHandlerContext
|
||||
*/
|
||||
public function processError(ErrorHandlerContext $context): void
|
||||
{
|
||||
try {
|
||||
$errorEvent = ErrorEvent::fromErrorHandlerContext($context);
|
||||
$this->processErrorEvent($errorEvent);
|
||||
} catch (\Throwable $e) {
|
||||
// Don't let error aggregation break the application
|
||||
$this->logError("Failed to process error: " . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'context' => $context->toArray(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes an ErrorEvent
|
||||
*/
|
||||
public function processErrorEvent(ErrorEvent $event): void
|
||||
{
|
||||
// Store the individual error event
|
||||
$this->storage->storeEvent($event);
|
||||
|
||||
// Update or create error pattern
|
||||
$pattern = $this->updateErrorPattern($event);
|
||||
|
||||
// Check if pattern should trigger alert
|
||||
if ($pattern->shouldAlert()) {
|
||||
$this->queueAlert($pattern, $event);
|
||||
}
|
||||
|
||||
// Log for debugging
|
||||
$this->logError("Error processed", [
|
||||
'event_id' => $event->id->toString(),
|
||||
'fingerprint' => $event->getFingerprint(),
|
||||
'pattern_id' => $pattern->id->toString(),
|
||||
'occurrence_count' => $pattern->occurrenceCount,
|
||||
'should_alert' => $pattern->shouldAlert(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates or creates error pattern for the event
|
||||
*/
|
||||
private function updateErrorPattern(ErrorEvent $event): ErrorPattern
|
||||
{
|
||||
$fingerprint = $event->getFingerprint();
|
||||
$cacheKey = self::PATTERN_CACHE_PREFIX . $fingerprint;
|
||||
|
||||
// Try to get existing pattern from cache
|
||||
$cachedPattern = $this->cache->get(CacheKey::fromString($cacheKey));
|
||||
if ($cachedPattern) {
|
||||
$pattern = ErrorPattern::fromArray($cachedPattern);
|
||||
} else {
|
||||
// Try to get from storage
|
||||
$pattern = $this->storage->getPatternByFingerprint($fingerprint);
|
||||
}
|
||||
|
||||
if ($pattern === null) {
|
||||
// Create new pattern
|
||||
$pattern = ErrorPattern::fromErrorEvent($event);
|
||||
} else {
|
||||
// Update existing pattern
|
||||
$pattern = $pattern->withNewOccurrence($event);
|
||||
}
|
||||
|
||||
// Store updated pattern
|
||||
$this->storage->storePattern($pattern);
|
||||
|
||||
// Cache the pattern
|
||||
$this->cache->set(CacheKey::fromString($cacheKey), $pattern->toArray(), Duration::fromSeconds(self::PATTERN_CACHE_TTL));
|
||||
|
||||
return $pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues alert for pattern
|
||||
*/
|
||||
private function queueAlert(ErrorPattern $pattern, ErrorEvent $triggeringEvent): void
|
||||
{
|
||||
$alertData = [
|
||||
'type' => 'error_pattern_alert',
|
||||
'pattern_id' => $pattern->id->toString(),
|
||||
'event_id' => $triggeringEvent->id->toString(),
|
||||
'urgency' => $pattern->getAlertUrgency()->value,
|
||||
'created_at' => $this->clock->now()->format('c'),
|
||||
'pattern_data' => $pattern->toArray(),
|
||||
'triggering_event' => $triggeringEvent->toArray(),
|
||||
];
|
||||
|
||||
// Queue with priority based on urgency
|
||||
$priority = match ($pattern->getAlertUrgency()) {
|
||||
AlertUrgency::URGENT => 100,
|
||||
AlertUrgency::HIGH => 75,
|
||||
AlertUrgency::MEDIUM => 50,
|
||||
AlertUrgency::LOW => 25,
|
||||
};
|
||||
|
||||
$this->alertQueue->push('error_alerts', $alertData, $priority);
|
||||
|
||||
$this->logError("Alert queued", [
|
||||
'pattern_id' => $pattern->id->toString(),
|
||||
'urgency' => $pattern->getAlertUrgency()->value,
|
||||
'occurrence_count' => $pattern->occurrenceCount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets error statistics for a time period
|
||||
*/
|
||||
public function getStatistics(\DateTimeImmutable $from, \DateTimeImmutable $to): array
|
||||
{
|
||||
return $this->storage->getStatistics($from, $to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets active error patterns
|
||||
*/
|
||||
public function getActivePatterns(int $limit = 50, int $offset = 0): array
|
||||
{
|
||||
return $this->storage->getActivePatterns($limit, $offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets error patterns by service
|
||||
*/
|
||||
public function getPatternsByService(string $service, int $limit = 50): array
|
||||
{
|
||||
return $this->storage->getPatternsByService($service, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets recent error events
|
||||
*/
|
||||
public function getRecentEvents(int $limit = 100, ?ErrorSeverity $severity = null): array
|
||||
{
|
||||
return $this->storage->getRecentEvents($limit, $severity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledges an error pattern
|
||||
*/
|
||||
public function acknowledgePattern(string $patternId, string $acknowledgedBy, ?string $resolution = null): bool
|
||||
{
|
||||
$pattern = $this->storage->getPatternById($patternId);
|
||||
if ($pattern === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$acknowledgedPattern = $pattern->acknowledge($acknowledgedBy, $resolution);
|
||||
$this->storage->storePattern($acknowledgedPattern);
|
||||
|
||||
// Clear from cache
|
||||
$cacheKey = self::PATTERN_CACHE_PREFIX . $pattern->fingerprint;
|
||||
$this->cache->delete(CacheKey::fromString($cacheKey));
|
||||
|
||||
$this->logError("Pattern acknowledged", [
|
||||
'pattern_id' => $patternId,
|
||||
'acknowledged_by' => $acknowledgedBy,
|
||||
'resolution' => $resolution,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an error pattern
|
||||
*/
|
||||
public function resolvePattern(string $patternId, string $resolution): bool
|
||||
{
|
||||
$pattern = $this->storage->getPatternById($patternId);
|
||||
if ($pattern === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resolvedPattern = $pattern->resolve($resolution);
|
||||
$this->storage->storePattern($resolvedPattern);
|
||||
|
||||
// Clear from cache
|
||||
$cacheKey = self::PATTERN_CACHE_PREFIX . $pattern->fingerprint;
|
||||
$this->cache->delete(CacheKey::fromString($cacheKey));
|
||||
|
||||
$this->logError("Pattern resolved", [
|
||||
'pattern_id' => $patternId,
|
||||
'resolution' => $resolution,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes errors in batch for performance
|
||||
*/
|
||||
public function processBatch(array $errorEvents): void
|
||||
{
|
||||
$batches = array_chunk($errorEvents, $this->batchSize);
|
||||
|
||||
foreach ($batches as $batch) {
|
||||
$this->storage->storeEventsBatch($batch);
|
||||
|
||||
foreach ($batch as $event) {
|
||||
$this->updateErrorPattern($event);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logError("Batch processed", [
|
||||
'total_events' => count($errorEvents),
|
||||
'batches' => count($batches),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up old data based on retention policy
|
||||
*/
|
||||
public function cleanup(): int
|
||||
{
|
||||
$deletedEvents = 0;
|
||||
$deletedPatterns = 0;
|
||||
|
||||
foreach (ErrorSeverity::cases() as $severity) {
|
||||
$retentionDays = $severity->getRetentionDays();
|
||||
$cutoffDate = $this->clock->now()->sub(new \DateInterval("P{$retentionDays}D"));
|
||||
|
||||
$deletedEvents += $this->storage->deleteOldEvents($cutoffDate, $severity);
|
||||
}
|
||||
|
||||
// Delete inactive patterns older than max retention
|
||||
$maxCutoff = $this->clock->now()->sub(new \DateInterval("P{$this->maxRetentionDays}D"));
|
||||
$deletedPatterns = $this->storage->deleteOldPatterns($maxCutoff);
|
||||
|
||||
$this->logError("Cleanup completed", [
|
||||
'deleted_events' => $deletedEvents,
|
||||
'deleted_patterns' => $deletedPatterns,
|
||||
]);
|
||||
|
||||
return $deletedEvents + $deletedPatterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets error trends for analysis
|
||||
*/
|
||||
public function getErrorTrends(
|
||||
\DateTimeImmutable $from,
|
||||
\DateTimeImmutable $to,
|
||||
string $groupBy = 'hour'
|
||||
): array {
|
||||
return $this->storage->getErrorTrends($from, $to, $groupBy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets top error patterns by occurrence count
|
||||
*/
|
||||
public function getTopPatterns(int $limit = 10, ?string $service = null): array
|
||||
{
|
||||
return $this->storage->getTopPatterns($limit, $service);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports error data for external analysis
|
||||
*/
|
||||
public function exportData(
|
||||
\DateTimeImmutable $from,
|
||||
\DateTimeImmutable $to,
|
||||
array $filters = []
|
||||
): \Generator {
|
||||
return $this->storage->exportEvents($from, $to, $filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets health status of error aggregation system
|
||||
*/
|
||||
public function getHealthStatus(): array
|
||||
{
|
||||
try {
|
||||
$stats = $this->getStatistics(
|
||||
$this->clock->now()->sub(new \DateInterval('PT1H')),
|
||||
$this->clock->now()
|
||||
);
|
||||
|
||||
return [
|
||||
'status' => 'healthy',
|
||||
'last_hour_events' => $stats['total_events'] ?? 0,
|
||||
'active_patterns' => count($this->getActivePatterns(100)),
|
||||
'storage_health' => $this->storage->getHealthStatus(),
|
||||
'cache_status' => $this->cache->get(CacheKey::fromString('health_check')) !== null ? 'healthy' : 'degraded',
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'status' => 'unhealthy',
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function logError(string $message, array $context = []): void
|
||||
{
|
||||
if ($this->logger) {
|
||||
$this->logger->info("[ErrorAggregator] {$message}", $context);
|
||||
}
|
||||
}
|
||||
}
|
||||
289
src/Framework/ErrorAggregation/ErrorEvent.php
Normal file
289
src/Framework/ErrorAggregation/ErrorEvent.php
Normal file
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorAggregation;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ErrorHandlerContext;
|
||||
use App\Framework\Ulid\Ulid;
|
||||
|
||||
/**
|
||||
* Represents a single error event for aggregation and analysis
|
||||
*/
|
||||
final readonly class ErrorEvent
|
||||
{
|
||||
public function __construct(
|
||||
public Ulid $id,
|
||||
public string $service,
|
||||
public string $component,
|
||||
public string $operation,
|
||||
public ErrorCode $errorCode,
|
||||
public string $errorMessage,
|
||||
public ErrorSeverity $severity,
|
||||
public \DateTimeImmutable $occurredAt,
|
||||
public array $context = [],
|
||||
public array $metadata = [],
|
||||
public ?string $requestId = null,
|
||||
public ?string $userId = null,
|
||||
public ?string $clientIp = null,
|
||||
public bool $isSecurityEvent = false,
|
||||
public ?string $stackTrace = null,
|
||||
public ?string $userAgent = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates ErrorEvent from ErrorHandlerContext
|
||||
*/
|
||||
public static function fromErrorHandlerContext(ErrorHandlerContext $context): self
|
||||
{
|
||||
return new self(
|
||||
id: Ulid::generate(),
|
||||
service: self::extractServiceName($context),
|
||||
component: $context->exception->component ?? 'unknown',
|
||||
operation: $context->exception->operation ?? 'unknown',
|
||||
errorCode: self::extractErrorCode($context),
|
||||
errorMessage: self::extractUserMessage($context),
|
||||
severity: self::determineSeverity($context),
|
||||
occurredAt: new \DateTimeImmutable(),
|
||||
context: $context->exception->data,
|
||||
metadata: $context->exception->metadata,
|
||||
requestId: $context->request->requestId,
|
||||
userId: $context->request->userId ?? null,
|
||||
clientIp: $context->request->clientIp,
|
||||
isSecurityEvent: $context->exception->metadata['security_event'] ?? false,
|
||||
stackTrace: self::extractStackTrace($context),
|
||||
userAgent: $context->request->userAgent,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts to array for storage/transmission
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id->toString(),
|
||||
'service' => $this->service,
|
||||
'component' => $this->component,
|
||||
'operation' => $this->operation,
|
||||
'error_code' => $this->errorCode->value,
|
||||
'error_message' => $this->errorMessage,
|
||||
'severity' => $this->severity->value,
|
||||
'occurred_at' => $this->occurredAt->format('c'),
|
||||
'context' => $this->context,
|
||||
'metadata' => $this->metadata,
|
||||
'request_id' => $this->requestId,
|
||||
'user_id' => $this->userId,
|
||||
'client_ip' => $this->clientIp,
|
||||
'is_security_event' => $this->isSecurityEvent,
|
||||
'stack_trace' => $this->stackTrace,
|
||||
'user_agent' => $this->userAgent,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates from array (for deserialization)
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
id: Ulid::fromString($data['id']),
|
||||
service: $data['service'],
|
||||
component: $data['component'],
|
||||
operation: $data['operation'],
|
||||
errorCode: ErrorCode::from($data['error_code']),
|
||||
errorMessage: $data['error_message'],
|
||||
severity: ErrorSeverity::from($data['severity']),
|
||||
occurredAt: new \DateTimeImmutable($data['occurred_at']),
|
||||
context: $data['context'] ?? [],
|
||||
metadata: $data['metadata'] ?? [],
|
||||
requestId: $data['request_id'],
|
||||
userId: $data['user_id'],
|
||||
clientIp: $data['client_ip'],
|
||||
isSecurityEvent: $data['is_security_event'] ?? false,
|
||||
stackTrace: $data['stack_trace'],
|
||||
userAgent: $data['user_agent'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets fingerprint for grouping similar errors
|
||||
*/
|
||||
public function getFingerprint(): string
|
||||
{
|
||||
$components = [
|
||||
$this->service,
|
||||
$this->component,
|
||||
$this->operation,
|
||||
$this->errorCode->value,
|
||||
// Normalize error message to group similar errors
|
||||
$this->normalizeErrorMessage($this->errorMessage),
|
||||
];
|
||||
|
||||
return hash('sha256', implode('|', $components));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this error should trigger an alert
|
||||
*/
|
||||
public function shouldTriggerAlert(): bool
|
||||
{
|
||||
// Critical and error severity always trigger alerts
|
||||
if (in_array($this->severity, [ErrorSeverity::CRITICAL, ErrorSeverity::ERROR])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Security events always trigger alerts
|
||||
if ($this->isSecurityEvent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check metadata for explicit alert requirement
|
||||
return $this->metadata['requires_alert'] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets alert urgency level
|
||||
*/
|
||||
public function getAlertUrgency(): AlertUrgency
|
||||
{
|
||||
if ($this->severity === ErrorSeverity::CRITICAL || $this->isSecurityEvent) {
|
||||
return AlertUrgency::URGENT;
|
||||
}
|
||||
|
||||
if ($this->severity === ErrorSeverity::ERROR) {
|
||||
return AlertUrgency::HIGH;
|
||||
}
|
||||
|
||||
if ($this->severity === ErrorSeverity::WARNING) {
|
||||
return AlertUrgency::MEDIUM;
|
||||
}
|
||||
|
||||
return AlertUrgency::LOW;
|
||||
}
|
||||
|
||||
private static function extractServiceName(ErrorHandlerContext $context): string
|
||||
{
|
||||
// Try to extract service from request URI
|
||||
$uri = $context->request->requestUri;
|
||||
|
||||
if (str_starts_with($uri, '/api/')) {
|
||||
return 'api';
|
||||
}
|
||||
|
||||
if (str_starts_with($uri, '/admin/')) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
// Extract from component if available
|
||||
if ($context->exception->component) {
|
||||
return strtolower($context->exception->component);
|
||||
}
|
||||
|
||||
return 'web';
|
||||
}
|
||||
|
||||
private static function extractErrorCode(ErrorHandlerContext $context): ErrorCode
|
||||
{
|
||||
// Try to get from exception metadata
|
||||
if (isset($context->exception->metadata['error_code'])) {
|
||||
return ErrorCode::from($context->exception->metadata['error_code']);
|
||||
}
|
||||
|
||||
// Try to get from exception data
|
||||
if (isset($context->exception->data['error_code'])) {
|
||||
return ErrorCode::from($context->exception->data['error_code']);
|
||||
}
|
||||
|
||||
// Fallback based on HTTP status
|
||||
$httpStatus = $context->metadata['http_status'] ?? 500;
|
||||
|
||||
return match (true) {
|
||||
$httpStatus >= 500 => ErrorCode::SYSTEM_RESOURCE_EXHAUSTED,
|
||||
$httpStatus === 404 => ErrorCode::HTTP_NOT_FOUND,
|
||||
$httpStatus === 401 => ErrorCode::AUTH_CREDENTIALS_INVALID,
|
||||
$httpStatus === 403 => ErrorCode::AUTH_INSUFFICIENT_PRIVILEGES,
|
||||
$httpStatus === 429 => ErrorCode::HTTP_RATE_LIMIT_EXCEEDED,
|
||||
default => ErrorCode::SYSTEM_RESOURCE_EXHAUSTED,
|
||||
};
|
||||
}
|
||||
|
||||
private static function extractUserMessage(ErrorHandlerContext $context): string
|
||||
{
|
||||
// Try user_message first
|
||||
if (isset($context->exception->data['user_message'])) {
|
||||
return $context->exception->data['user_message'];
|
||||
}
|
||||
|
||||
// Try exception_message
|
||||
if (isset($context->exception->data['exception_message'])) {
|
||||
return $context->exception->data['exception_message'];
|
||||
}
|
||||
|
||||
// Fallback to operation and component
|
||||
$operation = $context->exception->operation ?? 'unknown_operation';
|
||||
$component = $context->exception->component ?? 'unknown_component';
|
||||
|
||||
return "Error in {$component} during {$operation}";
|
||||
}
|
||||
|
||||
private static function determineSeverity(ErrorHandlerContext $context): ErrorSeverity
|
||||
{
|
||||
// Security events are always critical
|
||||
if ($context->exception->metadata['security_event'] ?? false) {
|
||||
return ErrorSeverity::CRITICAL;
|
||||
}
|
||||
|
||||
// Check explicit severity in metadata
|
||||
if (isset($context->exception->metadata['severity'])) {
|
||||
return ErrorSeverity::tryFrom($context->exception->metadata['severity']) ?? ErrorSeverity::ERROR;
|
||||
}
|
||||
|
||||
// Determine from HTTP status
|
||||
$httpStatus = $context->metadata['http_status'] ?? 500;
|
||||
|
||||
return match (true) {
|
||||
$httpStatus >= 500 => ErrorSeverity::ERROR,
|
||||
$httpStatus >= 400 => ErrorSeverity::WARNING,
|
||||
default => ErrorSeverity::INFO,
|
||||
};
|
||||
}
|
||||
|
||||
private static function extractStackTrace(ErrorHandlerContext $context): ?string
|
||||
{
|
||||
// Don't include stack traces for security events in production
|
||||
if (($context->exception->metadata['security_event'] ?? false) && ! ($_ENV['APP_DEBUG'] ?? false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract from exception debug data
|
||||
if (isset($context->exception->debug['stack_trace'])) {
|
||||
return $context->exception->debug['stack_trace'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeErrorMessage(string $message): string
|
||||
{
|
||||
// Remove specific details to group similar errors
|
||||
$normalized = $message;
|
||||
|
||||
// Remove file paths
|
||||
$normalized = preg_replace('#/[^\s]+#', '/path/to/file', $normalized);
|
||||
|
||||
// Remove specific IDs/numbers
|
||||
$normalized = preg_replace('#\b\d+\b#', 'N', $normalized);
|
||||
|
||||
// Remove timestamps
|
||||
$normalized = preg_replace('#\d{4}-\d{2}-\d{2}[\sT]\d{2}:\d{2}:\d{2}#', 'TIMESTAMP', $normalized);
|
||||
|
||||
// Remove ULIDs/UUIDs
|
||||
$normalized = preg_replace('#[0-9A-HJ-NP-TV-Z]{26}#', 'ULID', $normalized);
|
||||
$normalized = preg_replace('#[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}#i', 'UUID', $normalized);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
299
src/Framework/ErrorAggregation/ErrorPattern.php
Normal file
299
src/Framework/ErrorAggregation/ErrorPattern.php
Normal file
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorAggregation;
|
||||
|
||||
use App\Framework\Ulid\Ulid;
|
||||
|
||||
/**
|
||||
* Represents a pattern of similar errors for analysis and alerting
|
||||
*/
|
||||
final readonly class ErrorPattern
|
||||
{
|
||||
public function __construct(
|
||||
public Ulid $id,
|
||||
public string $fingerprint,
|
||||
public string $service,
|
||||
public string $component,
|
||||
public string $operation,
|
||||
public string $errorCode,
|
||||
public string $normalizedMessage,
|
||||
public ErrorSeverity $severity,
|
||||
public int $occurrenceCount,
|
||||
public \DateTimeImmutable $firstOccurrence,
|
||||
public \DateTimeImmutable $lastOccurrence,
|
||||
public array $affectedUsers = [],
|
||||
public array $affectedIps = [],
|
||||
public bool $isActive = true,
|
||||
public bool $isAcknowledged = false,
|
||||
public ?string $acknowledgedBy = null,
|
||||
public ?\DateTimeImmutable $acknowledgedAt = null,
|
||||
public ?string $resolution = null,
|
||||
public array $metadata = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates new pattern from first error event
|
||||
*/
|
||||
public static function fromErrorEvent(ErrorEvent $event): self
|
||||
{
|
||||
return new self(
|
||||
id: Ulid::generate(),
|
||||
fingerprint: $event->getFingerprint(),
|
||||
service: $event->service,
|
||||
component: $event->component,
|
||||
operation: $event->operation,
|
||||
errorCode: $event->errorCode->value,
|
||||
normalizedMessage: $event->errorMessage,
|
||||
severity: $event->severity,
|
||||
occurrenceCount: 1,
|
||||
firstOccurrence: $event->occurredAt,
|
||||
lastOccurrence: $event->occurredAt,
|
||||
affectedUsers: $event->userId ? [$event->userId] : [],
|
||||
affectedIps: $event->clientIp ? [$event->clientIp] : [],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates pattern with new error occurrence
|
||||
*/
|
||||
public function withNewOccurrence(ErrorEvent $event): self
|
||||
{
|
||||
$affectedUsers = $this->affectedUsers;
|
||||
if ($event->userId && ! in_array($event->userId, $affectedUsers)) {
|
||||
$affectedUsers[] = $event->userId;
|
||||
}
|
||||
|
||||
$affectedIps = $this->affectedIps;
|
||||
if ($event->clientIp && ! in_array($event->clientIp, $affectedIps)) {
|
||||
$affectedIps[] = $event->clientIp;
|
||||
}
|
||||
|
||||
return new self(
|
||||
id: $this->id,
|
||||
fingerprint: $this->fingerprint,
|
||||
service: $this->service,
|
||||
component: $this->component,
|
||||
operation: $this->operation,
|
||||
errorCode: $this->errorCode,
|
||||
normalizedMessage: $this->normalizedMessage,
|
||||
severity: $this->severity,
|
||||
occurrenceCount: $this->occurrenceCount + 1,
|
||||
firstOccurrence: $this->firstOccurrence,
|
||||
lastOccurrence: $event->occurredAt,
|
||||
affectedUsers: $affectedUsers,
|
||||
affectedIps: $affectedIps,
|
||||
isActive: $this->isActive,
|
||||
isAcknowledged: $this->isAcknowledged,
|
||||
acknowledgedBy: $this->acknowledgedBy,
|
||||
acknowledgedAt: $this->acknowledgedAt,
|
||||
resolution: $this->resolution,
|
||||
metadata: $this->metadata,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks pattern as acknowledged
|
||||
*/
|
||||
public function acknowledge(string $acknowledgedBy, ?string $resolution = null): self
|
||||
{
|
||||
return new self(
|
||||
id: $this->id,
|
||||
fingerprint: $this->fingerprint,
|
||||
service: $this->service,
|
||||
component: $this->component,
|
||||
operation: $this->operation,
|
||||
errorCode: $this->errorCode,
|
||||
normalizedMessage: $this->normalizedMessage,
|
||||
severity: $this->severity,
|
||||
occurrenceCount: $this->occurrenceCount,
|
||||
firstOccurrence: $this->firstOccurrence,
|
||||
lastOccurrence: $this->lastOccurrence,
|
||||
affectedUsers: $this->affectedUsers,
|
||||
affectedIps: $this->affectedIps,
|
||||
isActive: $this->isActive,
|
||||
isAcknowledged: true,
|
||||
acknowledgedBy: $acknowledgedBy,
|
||||
acknowledgedAt: new \DateTimeImmutable(),
|
||||
resolution: $resolution,
|
||||
metadata: $this->metadata,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks pattern as resolved (inactive)
|
||||
*/
|
||||
public function resolve(string $resolution): self
|
||||
{
|
||||
return new self(
|
||||
id: $this->id,
|
||||
fingerprint: $this->fingerprint,
|
||||
service: $this->service,
|
||||
component: $this->component,
|
||||
operation: $this->operation,
|
||||
errorCode: $this->errorCode,
|
||||
normalizedMessage: $this->normalizedMessage,
|
||||
severity: $this->severity,
|
||||
occurrenceCount: $this->occurrenceCount,
|
||||
firstOccurrence: $this->firstOccurrence,
|
||||
lastOccurrence: $this->lastOccurrence,
|
||||
affectedUsers: $this->affectedUsers,
|
||||
affectedIps: $this->affectedIps,
|
||||
isActive: false,
|
||||
isAcknowledged: true,
|
||||
acknowledgedBy: $this->acknowledgedBy,
|
||||
acknowledgedAt: $this->acknowledgedAt ?? new \DateTimeImmutable(),
|
||||
resolution: $resolution,
|
||||
metadata: $this->metadata,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets error frequency (errors per minute)
|
||||
*/
|
||||
public function getFrequency(): float
|
||||
{
|
||||
$duration = $this->lastOccurrence->getTimestamp() - $this->firstOccurrence->getTimestamp();
|
||||
|
||||
if ($duration <= 0) {
|
||||
return $this->occurrenceCount; // All errors in same second
|
||||
}
|
||||
|
||||
return $this->occurrenceCount / ($duration / 60); // errors per minute
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if pattern indicates a critical issue
|
||||
*/
|
||||
public function isCriticalPattern(): bool
|
||||
{
|
||||
// High frequency errors
|
||||
if ($this->getFrequency() > 10) { // More than 10 errors per minute
|
||||
return true;
|
||||
}
|
||||
|
||||
// Many affected users
|
||||
if (count($this->affectedUsers) > 50) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Critical severity with multiple occurrences
|
||||
if ($this->severity === ErrorSeverity::CRITICAL && $this->occurrenceCount >= 3) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Error severity with very high occurrence count
|
||||
if ($this->severity === ErrorSeverity::ERROR && $this->occurrenceCount >= 100) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets alert threshold based on pattern characteristics
|
||||
*/
|
||||
public function getAlertThreshold(): int
|
||||
{
|
||||
return match ($this->severity) {
|
||||
ErrorSeverity::CRITICAL => 1, // Alert on first occurrence
|
||||
ErrorSeverity::ERROR => 5, // Alert after 5 occurrences
|
||||
ErrorSeverity::WARNING => 20, // Alert after 20 occurrences
|
||||
ErrorSeverity::INFO => 100, // Alert after 100 occurrences
|
||||
ErrorSeverity::DEBUG => 500, // Alert after 500 occurrences
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if pattern should trigger alert
|
||||
*/
|
||||
public function shouldAlert(): bool
|
||||
{
|
||||
if ($this->isAcknowledged) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->occurrenceCount >= $this->getAlertThreshold();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets urgency for alerting
|
||||
*/
|
||||
public function getAlertUrgency(): AlertUrgency
|
||||
{
|
||||
if ($this->isCriticalPattern()) {
|
||||
return AlertUrgency::URGENT;
|
||||
}
|
||||
|
||||
return match ($this->severity) {
|
||||
ErrorSeverity::CRITICAL => AlertUrgency::URGENT,
|
||||
ErrorSeverity::ERROR => AlertUrgency::HIGH,
|
||||
ErrorSeverity::WARNING => AlertUrgency::MEDIUM,
|
||||
default => AlertUrgency::LOW,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts to array for storage/transmission
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id->toString(),
|
||||
'fingerprint' => $this->fingerprint,
|
||||
'service' => $this->service,
|
||||
'component' => $this->component,
|
||||
'operation' => $this->operation,
|
||||
'error_code' => $this->errorCode,
|
||||
'normalized_message' => $this->normalizedMessage,
|
||||
'severity' => $this->severity->value,
|
||||
'occurrence_count' => $this->occurrenceCount,
|
||||
'first_occurrence' => $this->firstOccurrence->format('c'),
|
||||
'last_occurrence' => $this->lastOccurrence->format('c'),
|
||||
'affected_users' => $this->affectedUsers,
|
||||
'affected_ips' => $this->affectedIps,
|
||||
'is_active' => $this->isActive,
|
||||
'is_acknowledged' => $this->isAcknowledged,
|
||||
'acknowledged_by' => $this->acknowledgedBy,
|
||||
'acknowledged_at' => $this->acknowledgedAt?->format('c'),
|
||||
'resolution' => $this->resolution,
|
||||
'metadata' => $this->metadata,
|
||||
'frequency' => $this->getFrequency(),
|
||||
'is_critical' => $this->isCriticalPattern(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates from array (for deserialization)
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
id: Ulid::fromString($data['id']),
|
||||
fingerprint: $data['fingerprint'],
|
||||
service: $data['service'],
|
||||
component: $data['component'],
|
||||
operation: $data['operation'],
|
||||
errorCode: $data['error_code'],
|
||||
normalizedMessage: $data['normalized_message'],
|
||||
severity: ErrorSeverity::from($data['severity']),
|
||||
occurrenceCount: $data['occurrence_count'],
|
||||
firstOccurrence: new \DateTimeImmutable($data['first_occurrence']),
|
||||
lastOccurrence: new \DateTimeImmutable($data['last_occurrence']),
|
||||
affectedUsers: $data['affected_users'] ?? [],
|
||||
affectedIps: $data['affected_ips'] ?? [],
|
||||
isActive: $data['is_active'] ?? true,
|
||||
isAcknowledged: $data['is_acknowledged'] ?? false,
|
||||
acknowledgedBy: $data['acknowledged_by'],
|
||||
acknowledgedAt: $data['acknowledged_at'] ? new \DateTimeImmutable($data['acknowledged_at']) : null,
|
||||
resolution: $data['resolution'],
|
||||
metadata: $data['metadata'] ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
81
src/Framework/ErrorAggregation/ErrorSeverity.php
Normal file
81
src/Framework/ErrorAggregation/ErrorSeverity.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorAggregation;
|
||||
|
||||
/**
|
||||
* Error severity levels for aggregation and alerting
|
||||
*/
|
||||
enum ErrorSeverity: string
|
||||
{
|
||||
case CRITICAL = 'critical';
|
||||
case ERROR = 'error';
|
||||
case WARNING = 'warning';
|
||||
case INFO = 'info';
|
||||
case DEBUG = 'debug';
|
||||
|
||||
/**
|
||||
* Gets numeric priority for sorting (higher = more severe)
|
||||
*/
|
||||
public function getPriority(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::CRITICAL => 50,
|
||||
self::ERROR => 40,
|
||||
self::WARNING => 30,
|
||||
self::INFO => 20,
|
||||
self::DEBUG => 10,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets color code for UI display
|
||||
*/
|
||||
public function getColor(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::CRITICAL => '#dc2626', // red-600
|
||||
self::ERROR => '#ea580c', // orange-600
|
||||
self::WARNING => '#ca8a04', // yellow-600
|
||||
self::INFO => '#2563eb', // blue-600
|
||||
self::DEBUG => '#6b7280', // gray-500
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets icon for UI display
|
||||
*/
|
||||
public function getIcon(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::CRITICAL => '🚨',
|
||||
self::ERROR => '❌',
|
||||
self::WARNING => '⚠️',
|
||||
self::INFO => 'ℹ️',
|
||||
self::DEBUG => '🔍',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this severity should trigger immediate alerts
|
||||
*/
|
||||
public function requiresImmediateAlert(): bool
|
||||
{
|
||||
return in_array($this, [self::CRITICAL, self::ERROR]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets default retention period in days
|
||||
*/
|
||||
public function getRetentionDays(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::CRITICAL => 365, // Keep critical errors for 1 year
|
||||
self::ERROR => 90, // Keep errors for 3 months
|
||||
self::WARNING => 30, // Keep warnings for 1 month
|
||||
self::INFO => 7, // Keep info for 1 week
|
||||
self::DEBUG => 1, // Keep debug for 1 day
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorAggregation\Migrations;
|
||||
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\Migration\Migration;
|
||||
use App\Framework\Database\Migration\MigrationVersion;
|
||||
use App\Framework\Database\Schema\Schema;
|
||||
|
||||
/**
|
||||
* Creates error_events table for error aggregation
|
||||
*/
|
||||
final class Migration_2024_01_24_140000_CreateErrorEventsTable implements Migration
|
||||
{
|
||||
public function up(ConnectionInterface $connection): void
|
||||
{
|
||||
$schema = new Schema($connection);
|
||||
|
||||
$schema->create('error_events', function ($table) {
|
||||
// Primary key
|
||||
$table->string('id', 26)->primary()->comment('ULID identifier');
|
||||
|
||||
// Classification
|
||||
$table->string('service', 100)->index()->comment('Service name (api, admin, web)');
|
||||
$table->string('component', 100)->index()->comment('Component that generated error');
|
||||
$table->string('operation', 100)->index()->comment('Operation being performed');
|
||||
$table->string('error_code', 10)->index()->comment('Framework error code');
|
||||
|
||||
// Error details
|
||||
$table->text('error_message')->comment('User-facing error message');
|
||||
$table->string('severity', 20)->index()->comment('Error severity level');
|
||||
$table->timestamp('occurred_at')->index()->comment('When error occurred');
|
||||
|
||||
// Context data
|
||||
$table->json('context')->nullable()->comment('Error context data');
|
||||
$table->json('metadata')->nullable()->comment('Error metadata');
|
||||
|
||||
// Request information
|
||||
$table->string('request_id', 50)->nullable()->index()->comment('Request ID for tracing');
|
||||
$table->string('user_id', 50)->nullable()->index()->comment('User ID if available');
|
||||
$table->string('client_ip', 45)->nullable()->index()->comment('Client IP address');
|
||||
$table->boolean('is_security_event')->default(false)->index()->comment('Is security-related event');
|
||||
|
||||
// Debug information
|
||||
$table->longText('stack_trace')->nullable()->comment('Stack trace if available');
|
||||
$table->text('user_agent')->nullable()->comment('User agent string');
|
||||
|
||||
// Indexes for performance
|
||||
$table->index(['service', 'occurred_at']);
|
||||
$table->index(['severity', 'occurred_at']);
|
||||
$table->index(['is_security_event', 'occurred_at']);
|
||||
$table->index(['error_code', 'occurred_at']);
|
||||
});
|
||||
|
||||
$schema->execute();
|
||||
}
|
||||
|
||||
public function down(ConnectionInterface $connection): void
|
||||
{
|
||||
$schema = new Schema($connection);
|
||||
$schema->dropIfExists('error_events');
|
||||
$schema->execute();
|
||||
}
|
||||
|
||||
public function getVersion(): MigrationVersion
|
||||
{
|
||||
return MigrationVersion::fromTimestamp("2024_01_24_140000");
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create error_events table for error aggregation system';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorAggregation\Migrations;
|
||||
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\Migration\Migration;
|
||||
use App\Framework\Database\Migration\MigrationVersion;
|
||||
use App\Framework\Database\Schema\Schema;
|
||||
|
||||
/**
|
||||
* Creates error_patterns table for error pattern aggregation
|
||||
*/
|
||||
final class Migration_2024_01_24_141000_CreateErrorPatternsTable implements Migration
|
||||
{
|
||||
public function up(ConnectionInterface $connection): void
|
||||
{
|
||||
$schema = new Schema($connection);
|
||||
|
||||
$schema->create('error_patterns', function ($table) {
|
||||
// Primary key
|
||||
$table->string('id', 26)->primary()->comment('ULID identifier');
|
||||
|
||||
// Pattern identification
|
||||
$table->string('fingerprint', 64)->unique()->comment('SHA256 hash for grouping similar errors');
|
||||
|
||||
// Classification (same as events for filtering)
|
||||
$table->string('service', 100)->index()->comment('Service name');
|
||||
$table->string('component', 100)->index()->comment('Component name');
|
||||
$table->string('operation', 100)->index()->comment('Operation name');
|
||||
$table->string('error_code', 10)->index()->comment('Framework error code');
|
||||
|
||||
// Pattern details
|
||||
$table->text('normalized_message')->comment('Normalized error message');
|
||||
$table->string('severity', 20)->index()->comment('Error severity level');
|
||||
|
||||
// Occurrence tracking
|
||||
$table->integer('occurrence_count')->default(1)->index()->comment('Number of times this pattern occurred');
|
||||
$table->timestamp('first_occurrence')->index()->comment('First time this pattern was seen');
|
||||
$table->timestamp('last_occurrence')->index()->comment('Last time this pattern was seen');
|
||||
|
||||
// Impact tracking
|
||||
$table->json('affected_users')->nullable()->comment('List of affected user IDs');
|
||||
$table->json('affected_ips')->nullable()->comment('List of affected IP addresses');
|
||||
|
||||
// Management
|
||||
$table->boolean('is_active')->default(true)->index()->comment('Is pattern still active');
|
||||
$table->boolean('is_acknowledged')->default(false)->index()->comment('Has been acknowledged by team');
|
||||
$table->string('acknowledged_by', 100)->nullable()->comment('Who acknowledged the pattern');
|
||||
$table->timestamp('acknowledged_at')->nullable()->comment('When pattern was acknowledged');
|
||||
$table->text('resolution')->nullable()->comment('Resolution description');
|
||||
|
||||
// Additional metadata
|
||||
$table->json('metadata')->nullable()->comment('Additional pattern metadata');
|
||||
|
||||
// Indexes for performance
|
||||
$table->index(['service', 'is_active']);
|
||||
$table->index(['severity', 'is_active']);
|
||||
$table->index(['occurrence_count', 'is_active']);
|
||||
$table->index(['last_occurrence', 'is_active']);
|
||||
$table->index(['is_acknowledged', 'is_active']);
|
||||
});
|
||||
|
||||
$schema->execute();
|
||||
}
|
||||
|
||||
public function down(ConnectionInterface $connection): void
|
||||
{
|
||||
$schema = new Schema($connection);
|
||||
$schema->dropIfExists('error_patterns');
|
||||
$schema->execute();
|
||||
}
|
||||
|
||||
public function getVersion(): MigrationVersion
|
||||
{
|
||||
return MigrationVersion::fromTimestamp("2024_01_24_141000");
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create error_patterns table for error pattern aggregation';
|
||||
}
|
||||
}
|
||||
447
src/Framework/ErrorAggregation/Storage/DatabaseErrorStorage.php
Normal file
447
src/Framework/ErrorAggregation/Storage/DatabaseErrorStorage.php
Normal file
@@ -0,0 +1,447 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorAggregation\Storage;
|
||||
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\ErrorAggregation\ErrorEvent;
|
||||
use App\Framework\ErrorAggregation\ErrorPattern;
|
||||
use App\Framework\ErrorAggregation\ErrorSeverity;
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Ulid\Ulid;
|
||||
|
||||
/**
|
||||
* Database-based error storage implementation
|
||||
*/
|
||||
final readonly class DatabaseErrorStorage implements ErrorStorageInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ConnectionInterface $connection
|
||||
) {
|
||||
}
|
||||
|
||||
public function storeEvent(ErrorEvent $event): void
|
||||
{
|
||||
$sql = "
|
||||
INSERT INTO error_events (
|
||||
id, service, component, operation, error_code, error_message,
|
||||
severity, occurred_at, context, metadata, request_id, user_id,
|
||||
client_ip, is_security_event, stack_trace, user_agent
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
";
|
||||
|
||||
$this->connection->execute($sql, [
|
||||
$event->id->toString(),
|
||||
$event->service,
|
||||
$event->component,
|
||||
$event->operation,
|
||||
$event->errorCode->value,
|
||||
$event->errorMessage,
|
||||
$event->severity->value,
|
||||
$event->occurredAt->format('Y-m-d H:i:s'),
|
||||
json_encode($event->context),
|
||||
json_encode($event->metadata),
|
||||
$event->requestId,
|
||||
$event->userId,
|
||||
$event->clientIp,
|
||||
$event->isSecurityEvent ? 1 : 0,
|
||||
$event->stackTrace,
|
||||
$event->userAgent,
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeEventsBatch(array $events): void
|
||||
{
|
||||
if (empty($events)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sql = "
|
||||
INSERT INTO error_events (
|
||||
id, service, component, operation, error_code, error_message,
|
||||
severity, occurred_at, context, metadata, request_id, user_id,
|
||||
client_ip, is_security_event, stack_trace, user_agent
|
||||
) VALUES " . str_repeat('(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?),', count($events));
|
||||
|
||||
$sql = rtrim($sql, ',');
|
||||
|
||||
$params = [];
|
||||
foreach ($events as $event) {
|
||||
$params = array_merge($params, [
|
||||
$event->id->toString(),
|
||||
$event->service,
|
||||
$event->component,
|
||||
$event->operation,
|
||||
$event->errorCode->value,
|
||||
$event->errorMessage,
|
||||
$event->severity->value,
|
||||
$event->occurredAt->format('Y-m-d H:i:s'),
|
||||
json_encode($event->context),
|
||||
json_encode($event->metadata),
|
||||
$event->requestId,
|
||||
$event->userId,
|
||||
$event->clientIp,
|
||||
$event->isSecurityEvent ? 1 : 0,
|
||||
$event->stackTrace,
|
||||
$event->userAgent,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->connection->execute($sql, $params);
|
||||
}
|
||||
|
||||
public function storePattern(ErrorPattern $pattern): void
|
||||
{
|
||||
$sql = "
|
||||
INSERT INTO error_patterns (
|
||||
id, fingerprint, service, component, operation, error_code,
|
||||
normalized_message, severity, occurrence_count, first_occurrence,
|
||||
last_occurrence, affected_users, affected_ips, is_active,
|
||||
is_acknowledged, acknowledged_by, acknowledged_at, resolution, metadata
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
occurrence_count = VALUES(occurrence_count),
|
||||
last_occurrence = VALUES(last_occurrence),
|
||||
affected_users = VALUES(affected_users),
|
||||
affected_ips = VALUES(affected_ips),
|
||||
is_active = VALUES(is_active),
|
||||
is_acknowledged = VALUES(is_acknowledged),
|
||||
acknowledged_by = VALUES(acknowledged_by),
|
||||
acknowledged_at = VALUES(acknowledged_at),
|
||||
resolution = VALUES(resolution),
|
||||
metadata = VALUES(metadata)
|
||||
";
|
||||
|
||||
$this->connection->execute($sql, [
|
||||
$pattern->id->toString(),
|
||||
$pattern->fingerprint,
|
||||
$pattern->service,
|
||||
$pattern->component,
|
||||
$pattern->operation,
|
||||
$pattern->errorCode,
|
||||
$pattern->normalizedMessage,
|
||||
$pattern->severity->value,
|
||||
$pattern->occurrenceCount,
|
||||
$pattern->firstOccurrence->format('Y-m-d H:i:s'),
|
||||
$pattern->lastOccurrence->format('Y-m-d H:i:s'),
|
||||
json_encode($pattern->affectedUsers),
|
||||
json_encode($pattern->affectedIps),
|
||||
$pattern->isActive ? 1 : 0,
|
||||
$pattern->isAcknowledged ? 1 : 0,
|
||||
$pattern->acknowledgedBy,
|
||||
$pattern->acknowledgedAt?->format('Y-m-d H:i:s'),
|
||||
$pattern->resolution,
|
||||
json_encode($pattern->metadata),
|
||||
]);
|
||||
}
|
||||
|
||||
public function getPatternById(string $patternId): ?ErrorPattern
|
||||
{
|
||||
$sql = "SELECT * FROM error_patterns WHERE id = ?";
|
||||
$result = $this->connection->query($sql, [$patternId]);
|
||||
|
||||
if (empty($result)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hydratePattern($result[0]);
|
||||
}
|
||||
|
||||
public function getPatternByFingerprint(string $fingerprint): ?ErrorPattern
|
||||
{
|
||||
$sql = "SELECT * FROM error_patterns WHERE fingerprint = ?";
|
||||
$result = $this->connection->query($sql, [$fingerprint]);
|
||||
|
||||
if (empty($result)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hydratePattern($result[0]);
|
||||
}
|
||||
|
||||
public function getActivePatterns(int $limit = 50, int $offset = 0): array
|
||||
{
|
||||
$sql = "
|
||||
SELECT * FROM error_patterns
|
||||
WHERE is_active = 1
|
||||
ORDER BY last_occurrence DESC, occurrence_count DESC
|
||||
LIMIT ? OFFSET ?
|
||||
";
|
||||
|
||||
$results = $this->connection->query($sql, [$limit, $offset]);
|
||||
|
||||
return array_map([$this, 'hydratePattern'], $results);
|
||||
}
|
||||
|
||||
public function getPatternsByService(string $service, int $limit = 50): array
|
||||
{
|
||||
$sql = "
|
||||
SELECT * FROM error_patterns
|
||||
WHERE service = ? AND is_active = 1
|
||||
ORDER BY last_occurrence DESC, occurrence_count DESC
|
||||
LIMIT ?
|
||||
";
|
||||
|
||||
$results = $this->connection->query($sql, [$service, $limit]);
|
||||
|
||||
return array_map([$this, 'hydratePattern'], $results);
|
||||
}
|
||||
|
||||
public function getRecentEvents(int $limit = 100, ?ErrorSeverity $severity = null): array
|
||||
{
|
||||
$sql = "SELECT * FROM error_events";
|
||||
$params = [];
|
||||
|
||||
if ($severity) {
|
||||
$sql .= " WHERE severity = ?";
|
||||
$params[] = $severity->value;
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY occurred_at DESC LIMIT ?";
|
||||
$params[] = $limit;
|
||||
|
||||
$results = $this->connection->query($sql, $params);
|
||||
|
||||
return array_map([$this, 'hydrateEvent'], $results);
|
||||
}
|
||||
|
||||
public function getStatistics(\DateTimeImmutable $from, \DateTimeImmutable $to): array
|
||||
{
|
||||
$sql = "
|
||||
SELECT
|
||||
COUNT(*) as total_events,
|
||||
COUNT(DISTINCT service) as services_affected,
|
||||
COUNT(DISTINCT user_id) as users_affected,
|
||||
COUNT(DISTINCT client_ip) as ips_affected,
|
||||
severity,
|
||||
COUNT(*) as severity_count
|
||||
FROM error_events
|
||||
WHERE occurred_at BETWEEN ? AND ?
|
||||
GROUP BY severity
|
||||
";
|
||||
|
||||
$results = $this->connection->query($sql, [
|
||||
$from->format('Y-m-d H:i:s'),
|
||||
$to->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$stats = [
|
||||
'total_events' => 0,
|
||||
'services_affected' => 0,
|
||||
'users_affected' => 0,
|
||||
'ips_affected' => 0,
|
||||
'by_severity' => [],
|
||||
];
|
||||
|
||||
foreach ($results as $row) {
|
||||
$stats['total_events'] += $row['severity_count'];
|
||||
$stats['services_affected'] = max($stats['services_affected'], $row['services_affected']);
|
||||
$stats['users_affected'] = max($stats['users_affected'], $row['users_affected']);
|
||||
$stats['ips_affected'] = max($stats['ips_affected'], $row['ips_affected']);
|
||||
$stats['by_severity'][$row['severity']] = $row['severity_count'];
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
public function getErrorTrends(
|
||||
\DateTimeImmutable $from,
|
||||
\DateTimeImmutable $to,
|
||||
string $groupBy = 'hour'
|
||||
): array {
|
||||
$dateFormat = match ($groupBy) {
|
||||
'hour' => '%Y-%m-%d %H:00:00',
|
||||
'day' => '%Y-%m-%d',
|
||||
'week' => '%Y-%u',
|
||||
'month' => '%Y-%m',
|
||||
default => '%Y-%m-%d %H:00:00',
|
||||
};
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
DATE_FORMAT(occurred_at, ?) as time_bucket,
|
||||
severity,
|
||||
COUNT(*) as count
|
||||
FROM error_events
|
||||
WHERE occurred_at BETWEEN ? AND ?
|
||||
GROUP BY time_bucket, severity
|
||||
ORDER BY time_bucket ASC
|
||||
";
|
||||
|
||||
$results = $this->connection->query($sql, [
|
||||
$dateFormat,
|
||||
$from->format('Y-m-d H:i:s'),
|
||||
$to->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$trends = [];
|
||||
foreach ($results as $row) {
|
||||
$trends[] = [
|
||||
'time' => $row['time_bucket'],
|
||||
'severity' => $row['severity'],
|
||||
'count' => (int) $row['count'],
|
||||
];
|
||||
}
|
||||
|
||||
return $trends;
|
||||
}
|
||||
|
||||
public function getTopPatterns(int $limit = 10, ?string $service = null): array
|
||||
{
|
||||
$sql = "
|
||||
SELECT * FROM error_patterns
|
||||
WHERE is_active = 1
|
||||
";
|
||||
$params = [];
|
||||
|
||||
if ($service) {
|
||||
$sql .= " AND service = ?";
|
||||
$params[] = $service;
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY occurrence_count DESC LIMIT ?";
|
||||
$params[] = $limit;
|
||||
|
||||
$results = $this->connection->query($sql, $params);
|
||||
|
||||
return array_map([$this, 'hydratePattern'], $results);
|
||||
}
|
||||
|
||||
public function deleteOldEvents(\DateTimeImmutable $cutoffDate, ErrorSeverity $severity): int
|
||||
{
|
||||
$sql = "DELETE FROM error_events WHERE occurred_at < ? AND severity = ?";
|
||||
|
||||
$affectedRows = $this->connection->execute($sql, [
|
||||
$cutoffDate->format('Y-m-d H:i:s'),
|
||||
$severity->value,
|
||||
]);
|
||||
|
||||
return $affectedRows;
|
||||
}
|
||||
|
||||
public function deleteOldPatterns(\DateTimeImmutable $cutoffDate): int
|
||||
{
|
||||
$sql = "DELETE FROM error_patterns WHERE is_active = 0 AND last_occurrence < ?";
|
||||
|
||||
$affectedRows = $this->connection->execute($sql, [
|
||||
$cutoffDate->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return $affectedRows;
|
||||
}
|
||||
|
||||
public function exportEvents(
|
||||
\DateTimeImmutable $from,
|
||||
\DateTimeImmutable $to,
|
||||
array $filters = []
|
||||
): \Generator {
|
||||
$sql = "SELECT * FROM error_events WHERE occurred_at BETWEEN ? AND ?";
|
||||
$params = [$from->format('Y-m-d H:i:s'), $to->format('Y-m-d H:i:s')];
|
||||
|
||||
// Add filters
|
||||
if (isset($filters['service'])) {
|
||||
$sql .= " AND service = ?";
|
||||
$params[] = $filters['service'];
|
||||
}
|
||||
|
||||
if (isset($filters['severity'])) {
|
||||
$sql .= " AND severity = ?";
|
||||
$params[] = $filters['severity'];
|
||||
}
|
||||
|
||||
if (isset($filters['security_events_only']) && $filters['security_events_only']) {
|
||||
$sql .= " AND is_security_event = 1";
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY occurred_at ASC";
|
||||
|
||||
// Use cursor-based pagination for large datasets
|
||||
$batchSize = 1000;
|
||||
$offset = 0;
|
||||
|
||||
do {
|
||||
$batchSql = $sql . " LIMIT ? OFFSET ?";
|
||||
$batchParams = array_merge($params, [$batchSize, $offset]);
|
||||
|
||||
$results = $this->connection->query($batchSql, $batchParams);
|
||||
|
||||
foreach ($results as $row) {
|
||||
yield $this->hydrateEvent($row);
|
||||
}
|
||||
|
||||
$offset += $batchSize;
|
||||
} while (count($results) === $batchSize);
|
||||
}
|
||||
|
||||
public function getHealthStatus(): array
|
||||
{
|
||||
try {
|
||||
// Test connection
|
||||
$this->connection->query('SELECT 1');
|
||||
|
||||
// Get table stats
|
||||
$eventCount = $this->connection->query('SELECT COUNT(*) as count FROM error_events')[0]['count'] ?? 0;
|
||||
$patternCount = $this->connection->query('SELECT COUNT(*) as count FROM error_patterns')[0]['count'] ?? 0;
|
||||
|
||||
return [
|
||||
'status' => 'healthy',
|
||||
'event_count' => (int) $eventCount,
|
||||
'pattern_count' => (int) $patternCount,
|
||||
'connection' => 'ok',
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'status' => 'unhealthy',
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function hydratePattern(array $row): ErrorPattern
|
||||
{
|
||||
return new ErrorPattern(
|
||||
id: Ulid::fromString($row['id']),
|
||||
fingerprint: $row['fingerprint'],
|
||||
service: $row['service'],
|
||||
component: $row['component'],
|
||||
operation: $row['operation'],
|
||||
errorCode: $row['error_code'],
|
||||
normalizedMessage: $row['normalized_message'],
|
||||
severity: ErrorSeverity::from($row['severity']),
|
||||
occurrenceCount: (int) $row['occurrence_count'],
|
||||
firstOccurrence: new \DateTimeImmutable($row['first_occurrence']),
|
||||
lastOccurrence: new \DateTimeImmutable($row['last_occurrence']),
|
||||
affectedUsers: json_decode($row['affected_users'], true) ?? [],
|
||||
affectedIps: json_decode($row['affected_ips'], true) ?? [],
|
||||
isActive: (bool) $row['is_active'],
|
||||
isAcknowledged: (bool) $row['is_acknowledged'],
|
||||
acknowledgedBy: $row['acknowledged_by'],
|
||||
acknowledgedAt: $row['acknowledged_at'] ? new \DateTimeImmutable($row['acknowledged_at']) : null,
|
||||
resolution: $row['resolution'],
|
||||
metadata: json_decode($row['metadata'], true) ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
private function hydrateEvent(array $row): ErrorEvent
|
||||
{
|
||||
return new ErrorEvent(
|
||||
id: Ulid::fromString($row['id']),
|
||||
service: $row['service'],
|
||||
component: $row['component'],
|
||||
operation: $row['operation'],
|
||||
errorCode: ErrorCode::from($row['error_code']),
|
||||
errorMessage: $row['error_message'],
|
||||
severity: ErrorSeverity::from($row['severity']),
|
||||
occurredAt: new \DateTimeImmutable($row['occurred_at']),
|
||||
context: json_decode($row['context'], true) ?? [],
|
||||
metadata: json_decode($row['metadata'], true) ?? [],
|
||||
requestId: $row['request_id'],
|
||||
userId: $row['user_id'],
|
||||
clientIp: $row['client_ip'],
|
||||
isSecurityEvent: (bool) $row['is_security_event'],
|
||||
stackTrace: $row['stack_trace'],
|
||||
userAgent: $row['user_agent'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorAggregation\Storage;
|
||||
|
||||
use App\Framework\ErrorAggregation\ErrorEvent;
|
||||
use App\Framework\ErrorAggregation\ErrorPattern;
|
||||
use App\Framework\ErrorAggregation\ErrorSeverity;
|
||||
|
||||
/**
|
||||
* Interface for error storage backends
|
||||
*/
|
||||
interface ErrorStorageInterface
|
||||
{
|
||||
/**
|
||||
* Stores a single error event
|
||||
*/
|
||||
public function storeEvent(ErrorEvent $event): void;
|
||||
|
||||
/**
|
||||
* Stores multiple error events in batch
|
||||
*/
|
||||
public function storeEventsBatch(array $events): void;
|
||||
|
||||
/**
|
||||
* Stores or updates an error pattern
|
||||
*/
|
||||
public function storePattern(ErrorPattern $pattern): void;
|
||||
|
||||
/**
|
||||
* Gets error pattern by ID
|
||||
*/
|
||||
public function getPatternById(string $patternId): ?ErrorPattern;
|
||||
|
||||
/**
|
||||
* Gets error pattern by fingerprint
|
||||
*/
|
||||
public function getPatternByFingerprint(string $fingerprint): ?ErrorPattern;
|
||||
|
||||
/**
|
||||
* Gets active error patterns
|
||||
*/
|
||||
public function getActivePatterns(int $limit = 50, int $offset = 0): array;
|
||||
|
||||
/**
|
||||
* Gets error patterns by service
|
||||
*/
|
||||
public function getPatternsByService(string $service, int $limit = 50): array;
|
||||
|
||||
/**
|
||||
* Gets recent error events
|
||||
*/
|
||||
public function getRecentEvents(int $limit = 100, ?ErrorSeverity $severity = null): array;
|
||||
|
||||
/**
|
||||
* Gets error statistics for a time period
|
||||
*/
|
||||
public function getStatistics(\DateTimeImmutable $from, \DateTimeImmutable $to): array;
|
||||
|
||||
/**
|
||||
* Gets error trends grouped by time period
|
||||
*/
|
||||
public function getErrorTrends(
|
||||
\DateTimeImmutable $from,
|
||||
\DateTimeImmutable $to,
|
||||
string $groupBy = 'hour'
|
||||
): array;
|
||||
|
||||
/**
|
||||
* Gets top error patterns by occurrence count
|
||||
*/
|
||||
public function getTopPatterns(int $limit = 10, ?string $service = null): array;
|
||||
|
||||
/**
|
||||
* Deletes old events based on cutoff date and severity
|
||||
*/
|
||||
public function deleteOldEvents(\DateTimeImmutable $cutoffDate, ErrorSeverity $severity): int;
|
||||
|
||||
/**
|
||||
* Deletes old inactive patterns
|
||||
*/
|
||||
public function deleteOldPatterns(\DateTimeImmutable $cutoffDate): int;
|
||||
|
||||
/**
|
||||
* Exports events for external analysis
|
||||
*/
|
||||
public function exportEvents(
|
||||
\DateTimeImmutable $from,
|
||||
\DateTimeImmutable $to,
|
||||
array $filters = []
|
||||
): \Generator;
|
||||
|
||||
/**
|
||||
* Gets health status of storage backend
|
||||
*/
|
||||
public function getHealthStatus(): array;
|
||||
}
|
||||
Reference in New Issue
Block a user