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:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View 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
};
}
}

View 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;
}

View 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),
};
}
}
}

View 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";
}
}
}

View 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;
}
}

View File

@@ -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;
}
}

View 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;
}
}
}

View File

@@ -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)
);
});
}
}

View File

@@ -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;
}
}
}

View 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);
}
}
}

View 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;
}
}

View 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'] ?? [],
);
}
}

View 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
};
}
}

View File

@@ -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';
}
}

View File

@@ -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';
}
}

View 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'],
);
}
}

View File

@@ -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;
}