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,518 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting\Analytics;
use App\Framework\DateTime\Clock;
use App\Framework\ErrorReporting\Analytics\ValueObjects\ErrorAnomaly;
use App\Framework\ErrorReporting\Analytics\ValueObjects\ErrorVelocity;
use App\Framework\ErrorReporting\ErrorReportCriteria;
use App\Framework\ErrorReporting\Storage\ErrorReportStorageInterface;
use DateTimeImmutable;
/**
* Advanced analytics engine for error reporting
*/
final readonly class ErrorAnalyticsEngine
{
public function __construct(
private ErrorReportStorageInterface $storage,
private Clock $clock
) {
}
/**
* Detect error anomalies and spikes
* @return array<ErrorAnomaly>
*/
public function detectAnomalies(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$trends = $this->storage->getTrends($from, $to, 'hour');
if (count($trends) < 3) {
return [];
}
$values = array_column($trends, 'count');
$mean = array_sum($values) / count($values);
$stdDev = $this->calculateStandardDeviation($values, $mean);
$anomalies = [];
foreach ($trends as $trend) {
$zScore = $stdDev > 0 ? abs($trend['count'] - $mean) / $stdDev : 0;
if ($zScore > 2.5) { // More than 2.5 standard deviations
$anomalies[] = ErrorAnomaly::create(
period: $this->clock->fromString($trend['period']),
count: (int) $trend['count'],
mean: $mean,
zScore: $zScore
);
}
}
return $anomalies;
}
/**
* Calculate error velocity (rate of change)
* @return array<ErrorVelocity>
*/
public function calculateErrorVelocity(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$trends = $this->storage->getTrends($from, $to, 'hour');
if (count($trends) < 2) {
return [];
}
$velocities = [];
for ($i = 1; $i < count($trends); $i++) {
$previous = $trends[$i - 1];
$current = $trends[$i];
$velocities[] = ErrorVelocity::calculate(
period: $this->clock->fromString($current['period']),
currentCount: (int) $current['count'],
previousCount: (int) $previous['count']
);
}
return $velocities;
}
/**
* Identify error patterns and correlations
*/
public function identifyPatterns(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$criteria = ErrorReportCriteria::recent()->withTimeRange($from, $to);
$reports = $this->storage->findByCriteria($criteria);
$patterns = [
'route_correlations' => $this->findRouteCorrelations($reports),
'time_patterns' => $this->findTimePatterns($reports),
'user_patterns' => $this->findUserPatterns($reports),
'cascade_patterns' => $this->findCascadePatterns($reports),
];
return $patterns;
}
/**
* Generate predictive insights
*/
public function generatePredictiveInsights(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$trends = $this->storage->getTrends($from, $to, 'day');
if (count($trends) < 7) {
return [
'prediction' => 'insufficient_data',
'message' => 'Need at least 7 days of data for predictions',
];
}
// Simple linear regression for trend prediction
$prediction = $this->predictTrend($trends);
// Seasonal pattern detection
$seasonal = $this->detectSeasonalPatterns($trends);
// Risk assessment
$risk = $this->assessRisk($trends);
return [
'trend_prediction' => $prediction,
'seasonal_patterns' => $seasonal,
'risk_assessment' => $risk,
'recommendations' => $this->generateRecommendations($prediction, $seasonal, $risk),
];
}
/**
* Calculate error impact metrics
*/
public function calculateImpactMetrics(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$criteria = ErrorReportCriteria::recent()->withTimeRange($from, $to);
$reports = $this->storage->findByCriteria($criteria);
$impact = [
'user_impact' => $this->calculateUserImpact($reports),
'business_impact' => $this->calculateBusinessImpact($reports),
'system_impact' => $this->calculateSystemImpact($reports),
'availability_impact' => $this->calculateAvailabilityImpact($reports),
];
return $impact;
}
/**
* Generate error health report
*/
public function generateHealthReport(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$statistics = $this->storage->getStatistics($from, $to);
$anomalies = $this->detectAnomalies($from, $to);
$velocity = $this->calculateErrorVelocity($from, $to);
$patterns = $this->identifyPatterns($from, $to);
$predictions = $this->generatePredictiveInsights($from, $to);
$impact = $this->calculateImpactMetrics($from, $to);
$healthScore = $this->calculateOverallHealthScore([
'statistics' => $statistics,
'anomalies' => $anomalies,
'velocity' => $velocity,
'impact' => $impact,
]);
return [
'period' => [
'from' => $from->format('c'),
'to' => $to->format('c'),
'duration' => $from->diff($to)->format('%d days, %h hours'),
],
'health_score' => $healthScore,
'statistics' => $statistics->toArray(),
'anomalies' => $anomalies,
'velocity' => $velocity,
'patterns' => $patterns,
'predictions' => $predictions,
'impact' => $impact,
'generated_at' => $this->clock->now()->format('c'),
];
}
private function calculateStandardDeviation(array $values, float $mean): float
{
if (count($values) <= 1) {
return 0.0;
}
$variance = array_sum(array_map(fn ($value) => pow($value - $mean, 2), $values)) / count($values);
return sqrt($variance);
}
private function findRouteCorrelations(array $reports): array
{
$routeErrors = [];
foreach ($reports as $report) {
if ($report->route) {
$routeErrors[$report->route][] = $report;
}
}
$correlations = [];
foreach ($routeErrors as $route => $errors) {
if (count($errors) < 2) {
continue;
}
$timeWindows = [];
foreach ($errors as $error) {
$window = $error->timestamp->format('Y-m-d H:i'); // Minute precision
$timeWindows[$window] = ($timeWindows[$window] ?? 0) + 1;
}
$clustered = array_filter($timeWindows, fn ($count) => $count > 1);
if (! empty($clustered)) {
$correlations[] = [
'route' => $route,
'total_errors' => count($errors),
'clustered_periods' => count($clustered),
'max_cluster_size' => max($clustered),
'pattern' => 'temporal_clustering',
];
}
}
return $correlations;
}
private function findTimePatterns(array $reports): array
{
$hourly = [];
$daily = [];
foreach ($reports as $report) {
$hour = (int) $report->timestamp->format('H');
$dayOfWeek = (int) $report->timestamp->format('N'); // 1-7
$hourly[$hour] = ($hourly[$hour] ?? 0) + 1;
$daily[$dayOfWeek] = ($daily[$dayOfWeek] ?? 0) + 1;
}
// Find peak hours and days
arsort($hourly);
arsort($daily);
return [
'peak_hour' => array_key_first($hourly),
'peak_day' => array_key_first($daily),
'hourly_distribution' => $hourly,
'daily_distribution' => $daily,
'patterns' => $this->identifyTimePatterns($hourly, $daily),
];
}
private function findUserPatterns(array $reports): array
{
$userErrors = [];
foreach ($reports as $report) {
if ($report->userId) {
$userErrors[$report->userId][] = $report;
}
}
$patterns = [];
foreach ($userErrors as $userId => $errors) {
if (count($errors) > 5) { // Users with many errors
$patterns[] = [
'user_id' => $userId,
'error_count' => count($errors),
'pattern' => 'high_error_user',
'first_error' => min(array_map(fn ($e) => $e->timestamp->getTimestamp(), $errors)),
'last_error' => max(array_map(fn ($e) => $e->timestamp->getTimestamp(), $errors)),
];
}
}
return $patterns;
}
private function findCascadePatterns(array $reports): array
{
// Group errors by timestamp windows
$windows = [];
foreach ($reports as $report) {
$window = floor($report->timestamp->getTimestamp() / 60) * 60; // 1-minute windows
$windows[$window][] = $report;
}
$cascades = [];
foreach ($windows as $timestamp => $errors) {
if (count($errors) > 3) { // Multiple errors in same minute
$cascades[] = [
'timestamp' => $timestamp,
'error_count' => count($errors),
'unique_exceptions' => count(array_unique(array_map(fn ($e) => $e->exception, $errors))),
'pattern' => 'error_cascade',
];
}
}
return $cascades;
}
private function predictTrend(array $trends): array
{
$n = count($trends);
$values = array_column($trends, 'count');
// Simple linear regression
$sumX = array_sum(range(1, $n));
$sumY = array_sum($values);
$sumXY = 0;
$sumX2 = 0;
for ($i = 0; $i < $n; $i++) {
$x = $i + 1;
$y = $values[$i];
$sumXY += $x * $y;
$sumX2 += $x * $x;
}
$slope = ($n * $sumXY - $sumX * $sumY) / ($n * $sumX2 - $sumX * $sumX);
$intercept = ($sumY - $slope * $sumX) / $n;
// Predict next 3 periods
$predictions = [];
for ($i = 1; $i <= 3; $i++) {
$nextX = $n + $i;
$prediction = $slope * $nextX + $intercept;
$predictions[] = [
'period' => $i,
'predicted_count' => max(0, round($prediction)),
];
}
return [
'slope' => round($slope, 4),
'trend' => $slope > 0.1 ? 'increasing' : ($slope < -0.1 ? 'decreasing' : 'stable'),
'predictions' => $predictions,
];
}
private function detectSeasonalPatterns(array $trends): array
{
// Simple day-of-week seasonality
$dayOfWeek = [];
foreach ($trends as $trend) {
$date = new DateTimeImmutable($trend['period']);
$dow = (int) $date->format('N');
$dayOfWeek[$dow] = ($dayOfWeek[$dow] ?? 0) + $trend['count'];
}
return [
'weekly_pattern' => $dayOfWeek,
'peak_day' => array_key_first($dayOfWeek),
];
}
private function assessRisk(array $trends): array
{
$recent = array_slice($trends, -3); // Last 3 periods
$recentAvg = array_sum(array_column($recent, 'count')) / count($recent);
$overall = array_column($trends, 'count');
$overallAvg = array_sum($overall) / count($overall);
$riskLevel = 'low';
if ($recentAvg > $overallAvg * 1.5) {
$riskLevel = 'high';
} elseif ($recentAvg > $overallAvg * 1.2) {
$riskLevel = 'medium';
}
return [
'level' => $riskLevel,
'recent_average' => round($recentAvg, 2),
'overall_average' => round($overallAvg, 2),
'risk_factor' => round($recentAvg / $overallAvg, 2),
];
}
private function generateRecommendations(array $prediction, array $seasonal, array $risk): array
{
$recommendations = [];
if ($prediction['trend'] === 'increasing') {
$recommendations[] = [
'type' => 'trend',
'priority' => 'high',
'message' => 'Error trend is increasing. Investigate root causes and implement fixes.',
];
}
if ($risk['level'] === 'high') {
$recommendations[] = [
'type' => 'risk',
'priority' => 'critical',
'message' => 'High risk detected. Recent error rate is significantly above normal.',
];
}
return $recommendations;
}
private function calculateUserImpact(array $reports): array
{
$uniqueUsers = array_unique(array_filter(array_map(fn ($r) => $r->userId, $reports)));
$userSessions = array_unique(array_filter(array_map(fn ($r) => $r->sessionId, $reports)));
return [
'affected_users' => count($uniqueUsers),
'affected_sessions' => count($userSessions),
'user_error_ratio' => count($uniqueUsers) > 0 ? count($reports) / count($uniqueUsers) : 0,
];
}
private function calculateBusinessImpact(array $reports): array
{
$criticalErrors = array_filter($reports, fn ($r) => $r->isCritical());
$routes = array_unique(array_filter(array_map(fn ($r) => $r->route, $reports)));
return [
'critical_errors' => count($criticalErrors),
'affected_endpoints' => count($routes),
'business_critical_routes' => $this->identifyBusinessCriticalRoutes($reports),
];
}
private function calculateSystemImpact(array $reports): array
{
$avgMemory = array_filter(array_map(fn ($r) => $r->memoryUsage, $reports));
$avgExecutionTime = array_filter(array_map(fn ($r) => $r->executionTime?->toMilliseconds(), $reports));
return [
'average_memory_usage' => ! empty($avgMemory) ? array_sum($avgMemory) / count($avgMemory) : 0,
'average_execution_time' => ! empty($avgExecutionTime) ? array_sum($avgExecutionTime) / count($avgExecutionTime) : 0,
'system_errors' => count(array_filter($reports, fn ($r) => str_contains($r->exception, 'System'))),
];
}
private function calculateAvailabilityImpact(array $reports): array
{
// This would need additional metrics to be accurate
return [
'estimated_downtime' => 0,
'availability_score' => 99.9, // Placeholder
];
}
private function identifyBusinessCriticalRoutes(array $reports): array
{
$criticalRoutes = ['login', 'checkout', 'payment', 'api/payment'];
$affected = [];
foreach ($reports as $report) {
if ($report->route && array_filter($criticalRoutes, fn ($cr) => str_contains($report->route, $cr))) {
$affected[] = $report->route;
}
}
return array_unique($affected);
}
private function identifyTimePatterns(array $hourly, array $daily): array
{
$patterns = [];
// Night time errors (00:00-06:00)
$nightErrors = array_sum(array_intersect_key($hourly, array_flip(range(0, 6))));
if ($nightErrors > array_sum($hourly) * 0.3) {
$patterns[] = 'high_nighttime_activity';
}
// Weekend errors
$weekendErrors = ($daily[6] ?? 0) + ($daily[7] ?? 0); // Saturday + Sunday
$weekdayErrors = array_sum(array_intersect_key($daily, array_flip(range(1, 5))));
if ($weekendErrors > $weekdayErrors * 0.4) {
$patterns[] = 'high_weekend_activity';
}
return $patterns;
}
private function calculateOverallHealthScore(array $data): int
{
$score = 100;
// Statistics impact
$stats = $data['statistics'];
if ($stats->getCriticalErrorCount() > 0) {
$score -= min(30, $stats->getCriticalErrorCount() * 5);
}
// Anomalies impact
$anomalies = $data['anomalies'];
foreach ($anomalies as $anomaly) {
$penalty = $anomaly['severity'] === 'high' ? 15 : 10;
$score -= $penalty;
}
// Velocity impact
$velocity = end($data['velocity']);
if ($velocity && $velocity['velocity_percent'] > 50) {
$score -= 20;
}
return max(0, min(100, $score));
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting\Analytics\ValueObjects;
/**
* Severity level of detected anomaly
*/
enum AnomalySeverity: string
{
case HIGH = 'high';
case MEDIUM = 'medium';
case LOW = 'low';
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting\Analytics\ValueObjects;
/**
* Type of error anomaly detected
*/
enum AnomalyType: string
{
case SPIKE = 'spike';
case DROP = 'drop';
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting\Analytics\ValueObjects;
use App\Framework\Core\ValueObjects\Score;
use DateTimeImmutable;
/**
* Represents a detected error anomaly with statistical significance
*/
final readonly class ErrorAnomaly
{
public function __construct(
public DateTimeImmutable $period,
public int $count,
public int $expected,
public Score $zScore,
public AnomalyType $type,
public AnomalySeverity $severity
) {
}
public static function create(
DateTimeImmutable $period,
int $count,
float $mean,
float $zScore
): self {
return new self(
period: $period,
count: $count,
expected: (int) round($mean),
zScore: Score::fromFloat($zScore),
type: $count > $mean ? AnomalyType::SPIKE : AnomalyType::DROP,
severity: $zScore > 3 ? AnomalySeverity::HIGH : AnomalySeverity::MEDIUM
);
}
public function isSpike(): bool
{
return $this->type === AnomalyType::SPIKE;
}
public function isDrop(): bool
{
return $this->type === AnomalyType::DROP;
}
public function isHighSeverity(): bool
{
return $this->severity === AnomalySeverity::HIGH;
}
public function isStatisticallySignificant(): bool
{
return $this->zScore->value() > 2.5;
}
public function isCriticalAnomaly(): bool
{
return $this->zScore->value() > 3.0;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'period' => $this->period->format('Y-m-d H:i:s'),
'count' => $this->count,
'expected' => $this->expected,
'z_score' => $this->zScore->value(),
'type' => $this->type->value,
'severity' => $this->severity->value,
];
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting\Analytics\ValueObjects;
use App\Framework\Core\ValueObjects\Percentage;
use DateTimeImmutable;
/**
* Represents error velocity (rate of change over time)
*/
final readonly class ErrorVelocity
{
public function __construct(
public DateTimeImmutable $period,
public int $count,
public int $previousCount,
public int $change,
public Percentage $velocityPercentage,
public VelocityDirection $direction
) {
}
public static function calculate(
DateTimeImmutable $period,
int $currentCount,
int $previousCount
): self {
$change = $currentCount - $previousCount;
$velocityPercent = $previousCount > 0 ? ($change / $previousCount) * 100 : 0;
return new self(
period: $period,
count: $currentCount,
previousCount: $previousCount,
change: $change,
velocityPercentage: Percentage::from(abs($velocityPercent)),
direction: $change > 0 ? VelocityDirection::INCREASING :
($change < 0 ? VelocityDirection::DECREASING : VelocityDirection::STABLE)
);
}
public function isIncreasing(): bool
{
return $this->direction === VelocityDirection::INCREASING;
}
public function isDecreasing(): bool
{
return $this->direction === VelocityDirection::DECREASING;
}
public function isStable(): bool
{
return $this->direction === VelocityDirection::STABLE;
}
public function isSignificantChange(): bool
{
return $this->velocityPercentage->getValue() > 10.0;
}
public function isCriticalChange(): bool
{
return $this->velocityPercentage->getValue() > 50.0;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'period' => $this->period->format('Y-m-d H:i:s'),
'count' => $this->count,
'previous_count' => $this->previousCount,
'change' => $this->change,
'velocity_percentage' => $this->velocityPercentage->getValue(),
'direction' => $this->direction->value,
];
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting\Analytics\ValueObjects;
/**
* Direction of error velocity change
*/
enum VelocityDirection: string
{
case INCREASING = 'increasing';
case DECREASING = 'decreasing';
case STABLE = 'stable';
}

View File

@@ -0,0 +1,448 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting\Commands;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\ExitCode;
use App\Framework\ErrorReporting\Analytics\ErrorAnalyticsEngine;
use App\Framework\ErrorReporting\ErrorReportCriteria;
use App\Framework\ErrorReporting\ErrorReporter;
use DateTimeImmutable;
/**
* Console commands for error reporting and analytics
*/
final readonly class ErrorReportingCommand
{
public function __construct(
private ErrorReporter $reporter,
private ErrorAnalyticsEngine $analytics
) {
}
#[ConsoleCommand(
name: 'errors:stats',
description: 'Show error statistics and analytics'
)]
public function stats(ConsoleInput $input, ConsoleOutput $output): int
{
$args = $input->getArguments();
$hours = (int) ($args[0] ?? 24);
$to = new DateTimeImmutable();
$from = $to->modify("-{$hours} hours");
$output->writeLine("<info>Error Statistics (last {$hours} hours)</info>");
$output->writeLine('');
$statistics = $this->reporter->getStatistics($from, $to);
// Summary
$output->writeLine('<comment>Summary:</comment>');
$output->writeLine(" Total Errors: {$statistics->totalErrors}");
$output->writeLine(" Unique Errors: {$statistics->uniqueErrors}");
$output->writeLine(" Critical Errors: {$statistics->getCriticalErrorCount()}");
$output->writeLine(" Error Rate: {$statistics->getErrorRatePercentage()}%");
$output->writeLine(" Health Score: {$statistics->getHealthScore()}/100");
$output->writeLine('');
// Errors by level
if (! empty($statistics->errorsByLevel)) {
$output->writeLine('<comment>Errors by Level:</comment>');
foreach ($statistics->errorsByLevel as $level => $count) {
$icon = match ($level) {
'emergency', 'alert', 'critical' => '🔴',
'error' => '🟠',
'warning' => '🟡',
default => '🔵',
};
$output->writeLine(" {$icon} {$level}: {$count}");
}
$output->writeLine('');
}
// Top exceptions
if (! empty($statistics->errorsByException)) {
$output->writeLine('<comment>Top Exceptions:</comment>');
$count = 0;
foreach ($statistics->errorsByException as $exception => $errorCount) {
if (++$count > 5) {
break;
}
$output->writeLine(" {$count}. " . basename($exception) . ": {$errorCount}");
}
$output->writeLine('');
}
// Top routes
if (! empty($statistics->errorsByRoute)) {
$output->writeLine('<comment>Most Problematic Routes:</comment>');
$count = 0;
foreach ($statistics->errorsByRoute as $route => $errorCount) {
if (++$count > 5) {
break;
}
$output->writeLine(" {$count}. {$route}: {$errorCount}");
}
$output->writeLine('');
}
// Insights
$insights = $statistics->getInsights();
if (! empty($insights)) {
$output->writeLine('<comment>Insights:</comment>');
foreach ($insights as $insight) {
$icon = match ($insight['priority']) {
'high', 'critical' => '⚠️',
'medium' => '',
default => '💡',
};
$output->writeLine(" {$icon} {$insight['message']}");
}
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'errors:analytics',
description: 'Generate advanced error analytics report'
)]
public function analytics(ConsoleInput $input, ConsoleOutput $output): int
{
$args = $input->getArguments();
$hours = (int) ($args[0] ?? 24);
$to = new DateTimeImmutable();
$from = $to->modify("-{$hours} hours");
$output->writeLine("<info>Advanced Error Analytics (last {$hours} hours)</info>");
$output->writeLine('');
// Anomaly detection
$output->writeLine('<comment>🔍 Anomaly Detection</comment>');
$anomalies = $this->analytics->detectAnomalies($from, $to);
if (empty($anomalies)) {
$output->writeLine(' ✅ No anomalies detected');
} else {
foreach ($anomalies as $anomaly) {
$icon = $anomaly['type'] === 'spike' ? '📈' : '📉';
$output->writeLine(" {$icon} {$anomaly['period']}: {$anomaly['count']} errors ({$anomaly['type']}, z-score: {$anomaly['z_score']})");
}
}
$output->writeLine('');
// Error velocity
$output->writeLine('<comment>⚡ Error Velocity</comment>');
$velocity = $this->analytics->calculateErrorVelocity($from, $to);
if (empty($velocity)) {
$output->writeLine(' Insufficient data for velocity calculation');
} else {
$latest = end($velocity);
$icon = match ($latest['direction']) {
'increasing' => '📈',
'decreasing' => '📉',
default => '➡️',
};
$output->writeLine(" {$icon} Latest trend: {$latest['direction']} ({$latest['velocity_percent']}% change)");
// Show recent velocity changes
$recentVelocity = array_slice($velocity, -5);
foreach ($recentVelocity as $v) {
$direction = match ($v['direction']) {
'increasing' => '↗️',
'decreasing' => '↘️',
default => '➡️',
};
$output->writeLine(" {$v['period']}: {$v['count']} errors {$direction} {$v['velocity_percent']}%");
}
}
$output->writeLine('');
// Patterns
$output->writeLine('<comment>🔄 Pattern Analysis</comment>');
$patterns = $this->analytics->identifyPatterns($from, $to);
if (! empty($patterns['route_correlations'])) {
$output->writeLine(' Route Correlations:');
foreach ($patterns['route_correlations'] as $correlation) {
$output->writeLine("{$correlation['route']}: {$correlation['total_errors']} errors, {$correlation['clustered_periods']} clusters");
}
}
if (! empty($patterns['time_patterns']['patterns'])) {
$output->writeLine(' Time Patterns: ' . implode(', ', $patterns['time_patterns']['patterns']));
}
if (! empty($patterns['user_patterns'])) {
$output->writeLine(' User Patterns:');
foreach (array_slice($patterns['user_patterns'], 0, 3) as $pattern) {
$output->writeLine(" • User {$pattern['user_id']}: {$pattern['error_count']} errors");
}
}
$output->writeLine('');
// Predictions
$output->writeLine('<comment>🔮 Predictive Insights</comment>');
$predictions = $this->analytics->generatePredictiveInsights($from, $to);
if ($predictions['prediction'] === 'insufficient_data') {
$output->writeLine(' ' . $predictions['message']);
} else {
$trend = $predictions['trend_prediction'];
$trendIcon = match ($trend['trend']) {
'increasing' => '📈',
'decreasing' => '📉',
default => '➡️',
};
$output->writeLine(" {$trendIcon} Trend: {$trend['trend']} (slope: {$trend['slope']})");
$output->writeLine(' Predictions:');
foreach ($trend['predictions'] as $pred) {
$output->writeLine(" Period +{$pred['period']}: ~{$pred['predicted_count']} errors");
}
$risk = $predictions['risk_assessment'];
$riskIcon = match ($risk['level']) {
'high' => '🔴',
'medium' => '🟡',
default => '🟢',
};
$output->writeLine(" {$riskIcon} Risk Level: {$risk['level']} (factor: {$risk['risk_factor']})");
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'errors:health',
description: 'Generate comprehensive error health report'
)]
public function health(ConsoleInput $input, ConsoleOutput $output): int
{
$args = $input->getArguments();
$hours = (int) ($args[0] ?? 24);
$to = new DateTimeImmutable();
$from = $to->modify("-{$hours} hours");
$output->writeLine("<info>Error Health Report (last {$hours} hours)</info>");
$output->writeLine('');
$healthReport = $this->analytics->generateHealthReport($from, $to);
// Health Score
$healthScore = $healthReport['health_score'];
$scoreIcon = match (true) {
$healthScore >= 90 => '🟢',
$healthScore >= 70 => '🟡',
$healthScore >= 50 => '🟠',
default => '🔴',
};
$output->writeLine("<comment>Overall Health Score: {$scoreIcon} {$healthScore}/100</comment>");
$output->writeLine('');
// Key Metrics
$stats = $healthReport['statistics'];
$output->writeLine('<comment>📊 Key Metrics</comment>');
$output->writeLine(" Total Errors: {$stats['total_errors']}");
$output->writeLine(" Critical Errors: {$stats['critical_error_count']}");
$output->writeLine(" Error Rate: {$stats['error_rate_percentage']}%");
$output->writeLine(" Trend: {$stats['error_trend']}");
$output->writeLine('');
// Impact Analysis
$impact = $healthReport['impact'];
$output->writeLine('<comment>💥 Impact Analysis</comment>');
$output->writeLine(" Affected Users: {$impact['user_impact']['affected_users']}");
$output->writeLine(" Affected Sessions: {$impact['user_impact']['affected_sessions']}");
$output->writeLine(" Critical Errors: {$impact['business_impact']['critical_errors']}");
$output->writeLine(" Affected Endpoints: {$impact['business_impact']['affected_endpoints']}");
if (! empty($impact['business_impact']['business_critical_routes'])) {
$output->writeLine(' Business Critical Routes Affected:');
foreach ($impact['business_impact']['business_critical_routes'] as $route) {
$output->writeLine("{$route}");
}
}
$output->writeLine('');
// Recommendations
if (! empty($healthReport['predictions']['recommendations'])) {
$output->writeLine('<comment>💡 Recommendations</comment>');
foreach ($healthReport['predictions']['recommendations'] as $rec) {
$icon = match ($rec['priority']) {
'critical' => '🚨',
'high' => '⚠️',
'medium' => '',
default => '💡',
};
$output->writeLine(" {$icon} {$rec['message']}");
}
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'errors:search',
description: 'Search and filter error reports'
)]
public function search(ConsoleInput $input, ConsoleOutput $output): int
{
$args = $input->getArguments();
$searchTerm = $args[0] ?? null;
if (! $searchTerm) {
$output->writeLine('<error>Please provide a search term</error>');
return ExitCode::FAILURE;
}
$criteria = ErrorReportCriteria::search($searchTerm)->withPagination(20);
$reports = $this->reporter->findReports($criteria);
$output->writeLine("<info>Search Results for: \"{$searchTerm}\"</info>");
$output->writeLine('Found ' . count($reports) . ' reports');
$output->writeLine('');
foreach ($reports as $report) {
$levelIcon = match ($report->level) {
'emergency', 'alert', 'critical' => '🔴',
'error' => '🟠',
'warning' => '🟡',
default => '🔵',
};
$output->writeLine("{$levelIcon} {$report->timestamp->format('Y-m-d H:i:s')} [{$report->level}]");
$output->writeLine(" Exception: " . basename($report->exception));
$output->writeLine(" Message: {$report->message}");
$output->writeLine(" File: {$report->file}:{$report->line}");
if ($report->route) {
$output->writeLine(" Route: {$report->method} {$report->route}");
}
if ($report->userId) {
$output->writeLine(" User: {$report->userId}");
}
$output->writeLine(" ID: {$report->id}");
$output->writeLine('');
}
return ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'errors:cleanup',
description: 'Clean up old error reports'
)]
public function cleanup(ConsoleInput $input, ConsoleOutput $output): int
{
$args = $input->getArguments();
$days = (int) ($args[0] ?? 30);
$before = (new DateTimeImmutable())->modify("-{$days} days");
$output->writeLine("<info>Cleaning up error reports older than {$days} days...</info>");
$deleted = $this->reporter->cleanup($before);
$output->writeLine("<info>Deleted {$deleted} error reports</info>");
return ExitCode::SUCCESS;
}
#[ConsoleCommand(
name: 'errors:show',
description: 'Show detailed error report'
)]
public function show(ConsoleInput $input, ConsoleOutput $output): int
{
$args = $input->getArguments();
$reportId = $args[0] ?? null;
if (! $reportId) {
$output->writeLine('<error>Please provide an error report ID</error>');
return ExitCode::FAILURE;
}
$report = $this->reporter->getReport($reportId);
if (! $report) {
$output->writeLine('<error>Error report not found</error>');
return ExitCode::FAILURE;
}
$output->writeLine("<info>Error Report: {$report->id}</info>");
$output->writeLine('');
$output->writeLine("<comment>Basic Information</comment>");
$output->writeLine(" Timestamp: {$report->timestamp->format('Y-m-d H:i:s')}");
$output->writeLine(" Level: {$report->level}");
$output->writeLine(" Environment: {$report->environment}");
$output->writeLine(" Fingerprint: {$report->getFingerprint()}");
$output->writeLine('');
$output->writeLine("<comment>Exception Details</comment>");
$output->writeLine(" Exception: {$report->exception}");
$output->writeLine(" Message: {$report->message}");
$output->writeLine(" File: {$report->file}:{$report->line}");
$output->writeLine('');
if ($report->route) {
$output->writeLine("<comment>Request Context</comment>");
$output->writeLine(" Route: {$report->method} {$report->route}");
$output->writeLine(" Request ID: {$report->requestId}");
$output->writeLine(" IP Address: {$report->ipAddress}");
$output->writeLine(" User Agent: {$report->userAgent}");
$output->writeLine('');
}
if ($report->userId) {
$output->writeLine("<comment>User Context</comment>");
$output->writeLine(" User ID: {$report->userId}");
$output->writeLine(" Session ID: {$report->sessionId}");
$output->writeLine('');
}
if ($report->executionTime || $report->memoryUsage) {
$output->writeLine("<comment>Performance Context</comment>");
if ($report->executionTime) {
$output->writeLine(" Execution Time: {$report->executionTime->toMilliseconds()}ms");
}
if ($report->memoryUsage) {
$output->writeLine(" Memory Usage: " . number_format($report->memoryUsage / 1024 / 1024, 2) . "MB");
}
$output->writeLine('');
}
if (! empty($report->tags)) {
$output->writeLine("<comment>Tags</comment>");
$output->writeLine(" " . implode(', ', $report->tags));
$output->writeLine('');
}
$output->writeLine("<comment>Stack Trace</comment>");
$traceLines = explode("\n", $report->trace);
foreach (array_slice($traceLines, 0, 10) as $line) {
$output->writeLine(" {$line}");
}
if (count($traceLines) > 10) {
$output->writeLine(" ... and " . (count($traceLines) - 10) . " more lines");
}
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,408 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting;
use App\Framework\Core\ValueObjects\Duration;
use DateTimeImmutable;
use Throwable;
/**
* Structured error report with comprehensive context
*/
final readonly class ErrorReport
{
public function __construct(
public string $id,
public DateTimeImmutable $timestamp,
public string $level,
public string $message,
public string $exception,
public string $file,
public int $line,
public string $trace,
public array $context,
public ?string $userId = null,
public ?string $sessionId = null,
public ?string $requestId = null,
public ?string $userAgent = null,
public ?string $ipAddress = null,
public ?string $route = null,
public ?string $method = null,
public ?array $requestData = null,
public ?Duration $executionTime = null,
public ?int $memoryUsage = null,
public array $tags = [],
public array $breadcrumbs = [],
public ?string $release = null,
public ?string $environment = null,
public array $serverInfo = [],
public array $customData = []
) {
}
/**
* Create from Throwable
*/
public static function fromThrowable(
Throwable $throwable,
string $level = 'error',
array $context = []
): self {
return new self(
id: self::generateId(),
timestamp: new DateTimeImmutable(),
level: $level,
message: $throwable->getMessage(),
exception: $throwable::class,
file: $throwable->getFile(),
line: $throwable->getLine(),
trace: $throwable->getTraceAsString(),
context: $context,
environment: $_ENV['APP_ENV'] ?? 'production',
serverInfo: self::getServerInfo()
);
}
/**
* Create from manual report
*/
public static function create(
string $level,
string $message,
array $context = [],
?Throwable $exception = null
): self {
return new self(
id: self::generateId(),
timestamp: new DateTimeImmutable(),
level: $level,
message: $message,
exception: $exception?->class ?? 'N/A',
file: $exception?->getFile() ?? debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1)[0]['file'] ?? 'unknown',
line: $exception?->getLine() ?? debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1)[0]['line'] ?? 0,
trace: $exception?->getTraceAsString() ?? self::getCurrentTrace(),
context: $context,
environment: $_ENV['APP_ENV'] ?? 'production',
serverInfo: self::getServerInfo()
);
}
/**
* Add user context
*/
public function withUser(string $userId, ?string $sessionId = null): self
{
return new self(
id: $this->id,
timestamp: $this->timestamp,
level: $this->level,
message: $this->message,
exception: $this->exception,
file: $this->file,
line: $this->line,
trace: $this->trace,
context: $this->context,
userId: $userId,
sessionId: $sessionId ?? $this->sessionId,
requestId: $this->requestId,
userAgent: $this->userAgent,
ipAddress: $this->ipAddress,
route: $this->route,
method: $this->method,
requestData: $this->requestData,
executionTime: $this->executionTime,
memoryUsage: $this->memoryUsage,
tags: $this->tags,
breadcrumbs: $this->breadcrumbs,
release: $this->release,
environment: $this->environment,
serverInfo: $this->serverInfo,
customData: $this->customData
);
}
/**
* Add HTTP request context
*/
public function withRequest(
string $method,
string $route,
?string $requestId = null,
?string $userAgent = null,
?string $ipAddress = null,
?array $requestData = null
): self {
return new self(
id: $this->id,
timestamp: $this->timestamp,
level: $this->level,
message: $this->message,
exception: $this->exception,
file: $this->file,
line: $this->line,
trace: $this->trace,
context: $this->context,
userId: $this->userId,
sessionId: $this->sessionId,
requestId: $requestId,
userAgent: $userAgent,
ipAddress: $ipAddress,
route: $route,
method: $method,
requestData: $requestData,
executionTime: $this->executionTime,
memoryUsage: $this->memoryUsage,
tags: $this->tags,
breadcrumbs: $this->breadcrumbs,
release: $this->release,
environment: $this->environment,
serverInfo: $this->serverInfo,
customData: $this->customData
);
}
/**
* Add performance context
*/
public function withPerformance(Duration $executionTime, int $memoryUsage): self
{
return new self(
id: $this->id,
timestamp: $this->timestamp,
level: $this->level,
message: $this->message,
exception: $this->exception,
file: $this->file,
line: $this->line,
trace: $this->trace,
context: $this->context,
userId: $this->userId,
sessionId: $this->sessionId,
requestId: $this->requestId,
userAgent: $this->userAgent,
ipAddress: $this->ipAddress,
route: $this->route,
method: $this->method,
requestData: $this->requestData,
executionTime: $executionTime,
memoryUsage: $memoryUsage,
tags: $this->tags,
breadcrumbs: $this->breadcrumbs,
release: $this->release,
environment: $this->environment,
serverInfo: $this->serverInfo,
customData: $this->customData
);
}
/**
* Add tags
*/
public function withTags(array $tags): self
{
return new self(
id: $this->id,
timestamp: $this->timestamp,
level: $this->level,
message: $this->message,
exception: $this->exception,
file: $this->file,
line: $this->line,
trace: $this->trace,
context: $this->context,
userId: $this->userId,
sessionId: $this->sessionId,
requestId: $this->requestId,
userAgent: $this->userAgent,
ipAddress: $this->ipAddress,
route: $this->route,
method: $this->method,
requestData: $this->requestData,
executionTime: $this->executionTime,
memoryUsage: $this->memoryUsage,
tags: array_unique(array_merge($this->tags, $tags)),
breadcrumbs: $this->breadcrumbs,
release: $this->release,
environment: $this->environment,
serverInfo: $this->serverInfo,
customData: $this->customData
);
}
/**
* Add breadcrumbs
*/
public function withBreadcrumbs(array $breadcrumbs): self
{
return new self(
id: $this->id,
timestamp: $this->timestamp,
level: $this->level,
message: $this->message,
exception: $this->exception,
file: $this->file,
line: $this->line,
trace: $this->trace,
context: $this->context,
userId: $this->userId,
sessionId: $this->sessionId,
requestId: $this->requestId,
userAgent: $this->userAgent,
ipAddress: $this->ipAddress,
route: $this->route,
method: $this->method,
requestData: $this->requestData,
executionTime: $this->executionTime,
memoryUsage: $this->memoryUsage,
tags: $this->tags,
breadcrumbs: array_merge($this->breadcrumbs, $breadcrumbs),
release: $this->release,
environment: $this->environment,
serverInfo: $this->serverInfo,
customData: $this->customData
);
}
/**
* Add custom data
*/
public function withCustomData(array $customData): self
{
return new self(
id: $this->id,
timestamp: $this->timestamp,
level: $this->level,
message: $this->message,
exception: $this->exception,
file: $this->file,
line: $this->line,
trace: $this->trace,
context: $this->context,
userId: $this->userId,
sessionId: $this->sessionId,
requestId: $this->requestId,
userAgent: $this->userAgent,
ipAddress: $this->ipAddress,
route: $this->route,
method: $this->method,
requestData: $this->requestData,
executionTime: $this->executionTime,
memoryUsage: $this->memoryUsage,
tags: $this->tags,
breadcrumbs: $this->breadcrumbs,
release: $this->release,
environment: $this->environment,
serverInfo: $this->serverInfo,
customData: array_merge($this->customData, $customData)
);
}
/**
* Get severity level as integer
*/
public function getSeverityLevel(): int
{
return match (strtolower($this->level)) {
'emergency' => 0,
'alert' => 1,
'critical' => 2,
'error' => 3,
'warning' => 4,
'notice' => 5,
'info' => 6,
'debug' => 7,
default => 3,
};
}
/**
* Check if error is critical
*/
public function isCritical(): bool
{
return $this->getSeverityLevel() <= 2;
}
/**
* Get fingerprint for grouping similar errors
*/
public function getFingerprint(): string
{
$key = $this->exception . '|' . $this->file . '|' . $this->line;
return hash('xxh64', $key);
}
/**
* Convert to array for storage/transmission
*/
public function toArray(): array
{
return [
'id' => $this->id,
'timestamp' => $this->timestamp->format('c'),
'level' => $this->level,
'message' => $this->message,
'exception' => $this->exception,
'file' => $this->file,
'line' => $this->line,
'trace' => $this->trace,
'context' => $this->context,
'user_id' => $this->userId,
'session_id' => $this->sessionId,
'request_id' => $this->requestId,
'user_agent' => $this->userAgent,
'ip_address' => $this->ipAddress,
'route' => $this->route,
'method' => $this->method,
'request_data' => $this->requestData,
'execution_time' => $this->executionTime?->toMilliseconds(),
'memory_usage' => $this->memoryUsage,
'tags' => $this->tags,
'breadcrumbs' => $this->breadcrumbs,
'release' => $this->release,
'environment' => $this->environment,
'server_info' => $this->serverInfo,
'custom_data' => $this->customData,
'fingerprint' => $this->getFingerprint(),
'severity_level' => $this->getSeverityLevel(),
];
}
private static function generateId(): string
{
return bin2hex(random_bytes(16));
}
private static function getCurrentTrace(): string
{
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
array_shift($trace); // Remove current method
$result = [];
foreach ($trace as $frame) {
$file = $frame['file'] ?? 'unknown';
$line = $frame['line'] ?? 0;
$function = $frame['function'] ?? 'unknown';
$class = isset($frame['class']) ? $frame['class'] . '::' : '';
$result[] = "#{$file}({$line}): {$class}{$function}()";
}
return implode("\n", $result);
}
private static function getServerInfo(): array
{
return [
'php_version' => PHP_VERSION,
'server_software' => $_SERVER['SERVER_SOFTWARE'] ?? 'unknown',
'server_name' => $_SERVER['SERVER_NAME'] ?? 'unknown',
'request_time' => $_SERVER['REQUEST_TIME'] ?? time(),
'memory_limit' => ini_get('memory_limit'),
'max_execution_time' => ini_get('max_execution_time'),
];
}
}

View File

@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting;
use DateTimeImmutable;
/**
* Criteria for searching error reports
*/
final readonly class ErrorReportCriteria
{
public function __construct(
public ?DateTimeImmutable $from = null,
public ?DateTimeImmutable $to = null,
public ?array $levels = null,
public ?array $exceptions = null,
public ?array $routes = null,
public ?array $methods = null,
public ?string $userId = null,
public ?string $environment = null,
public ?array $tags = null,
public ?string $search = null,
public ?string $fingerprint = null,
public ?int $minSeverity = null,
public ?int $maxSeverity = null,
public int $limit = 100,
public int $offset = 0,
public string $orderBy = 'timestamp',
public string $orderDir = 'DESC'
) {
}
/**
* Create criteria for recent errors
*/
public static function recent(int $hours = 24): self
{
$from = (new DateTimeImmutable())->modify("-{$hours} hours");
return new self(from: $from);
}
/**
* Create criteria for critical errors
*/
public static function critical(): self
{
return new self(
levels: ['emergency', 'alert', 'critical'],
maxSeverity: 2
);
}
/**
* Create criteria for errors by user
*/
public static function byUser(string $userId): self
{
return new self(userId: $userId);
}
/**
* Create criteria for errors by route
*/
public static function byRoute(string $route): self
{
return new self(routes: [$route]);
}
/**
* Create criteria for errors by exception type
*/
public static function byException(string $exceptionClass): self
{
return new self(exceptions: [$exceptionClass]);
}
/**
* Create criteria for production errors
*/
public static function production(): self
{
return new self(environment: 'production');
}
/**
* Create criteria with search term
*/
public static function search(string $term): self
{
return new self(search: $term);
}
/**
* Add time range filter
*/
public function withTimeRange(DateTimeImmutable $from, DateTimeImmutable $to): self
{
return new self(
from: $from,
to: $to,
levels: $this->levels,
exceptions: $this->exceptions,
routes: $this->routes,
methods: $this->methods,
userId: $this->userId,
environment: $this->environment,
tags: $this->tags,
search: $this->search,
fingerprint: $this->fingerprint,
minSeverity: $this->minSeverity,
maxSeverity: $this->maxSeverity,
limit: $this->limit,
offset: $this->offset,
orderBy: $this->orderBy,
orderDir: $this->orderDir
);
}
/**
* Add level filter
*/
public function withLevels(array $levels): self
{
return new self(
from: $this->from,
to: $this->to,
levels: $levels,
exceptions: $this->exceptions,
routes: $this->routes,
methods: $this->methods,
userId: $this->userId,
environment: $this->environment,
tags: $this->tags,
search: $this->search,
fingerprint: $this->fingerprint,
minSeverity: $this->minSeverity,
maxSeverity: $this->maxSeverity,
limit: $this->limit,
offset: $this->offset,
orderBy: $this->orderBy,
orderDir: $this->orderDir
);
}
/**
* Add environment filter
*/
public function withEnvironment(string $environment): self
{
return new self(
from: $this->from,
to: $this->to,
levels: $this->levels,
exceptions: $this->exceptions,
routes: $this->routes,
methods: $this->methods,
userId: $this->userId,
environment: $environment,
tags: $this->tags,
search: $this->search,
fingerprint: $this->fingerprint,
minSeverity: $this->minSeverity,
maxSeverity: $this->maxSeverity,
limit: $this->limit,
offset: $this->offset,
orderBy: $this->orderBy,
orderDir: $this->orderDir
);
}
/**
* Add pagination
*/
public function withPagination(int $limit, int $offset = 0): self
{
return new self(
from: $this->from,
to: $this->to,
levels: $this->levels,
exceptions: $this->exceptions,
routes: $this->routes,
methods: $this->methods,
userId: $this->userId,
environment: $this->environment,
tags: $this->tags,
search: $this->search,
fingerprint: $this->fingerprint,
minSeverity: $this->minSeverity,
maxSeverity: $this->maxSeverity,
limit: $limit,
offset: $offset,
orderBy: $this->orderBy,
orderDir: $this->orderDir
);
}
/**
* Add ordering
*/
public function withOrdering(string $orderBy, string $orderDir = 'DESC'): self
{
return new self(
from: $this->from,
to: $this->to,
levels: $this->levels,
exceptions: $this->exceptions,
routes: $this->routes,
methods: $this->methods,
userId: $this->userId,
environment: $this->environment,
tags: $this->tags,
search: $this->search,
fingerprint: $this->fingerprint,
minSeverity: $this->minSeverity,
maxSeverity: $this->maxSeverity,
limit: $this->limit,
offset: $this->offset,
orderBy: $orderBy,
orderDir: $orderDir
);
}
/**
* Convert to array for storage layer
*/
public function toArray(): array
{
return [
'from' => $this->from?->format('c'),
'to' => $this->to?->format('c'),
'levels' => $this->levels,
'exceptions' => $this->exceptions,
'routes' => $this->routes,
'methods' => $this->methods,
'user_id' => $this->userId,
'environment' => $this->environment,
'tags' => $this->tags,
'search' => $this->search,
'fingerprint' => $this->fingerprint,
'min_severity' => $this->minSeverity,
'max_severity' => $this->maxSeverity,
'limit' => $this->limit,
'offset' => $this->offset,
'order_by' => $this->orderBy,
'order_dir' => $this->orderDir,
];
}
}

View File

@@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting;
use App\Framework\DateTime\Clock;
use App\Framework\ErrorReporting\Storage\ErrorReportStorageInterface;
use App\Framework\Logging\Logger;
use App\Framework\Queue\Queue;
use Throwable;
/**
* Central error reporting service
*/
final readonly class ErrorReporter
{
public function __construct(
private ErrorReportStorageInterface $storage,
private Clock $clock,
private ?Logger $logger = null,
private ?Queue $queue = null,
private bool $asyncProcessing = true,
private array $processors = [],
private array $filters = []
) {
}
/**
* Report an error from Throwable
*/
public function reportThrowable(
Throwable $throwable,
string $level = 'error',
array $context = []
): string {
$report = ErrorReport::fromThrowable($throwable, $level, $context);
return $this->report($report);
}
/**
* Report a manual error
*/
public function reportError(
string $level,
string $message,
array $context = [],
?Throwable $exception = null
): string {
$report = ErrorReport::create($level, $message, $context, $exception);
return $this->report($report);
}
/**
* Report an error with full context
*/
public function report(ErrorReport $report): string
{
try {
// Apply filters - skip reporting if any filter returns false
foreach ($this->filters as $filter) {
if (! $filter($report)) {
$this->logDebug("Error report filtered out", ['report_id' => $report->id]);
return $report->id;
}
}
// Apply processors to enrich the report
$enrichedReport = $this->applyProcessors($report);
// Store or queue the report
if ($this->asyncProcessing && $this->queue) {
$this->queueReport($enrichedReport);
} else {
$this->storeReport($enrichedReport);
}
$this->logInfo("Error report created", [
'report_id' => $enrichedReport->id,
'level' => $enrichedReport->level,
'exception' => $enrichedReport->exception,
]);
return $enrichedReport->id;
} catch (Throwable $e) {
// Fallback logging if reporting system fails
$this->logError("Failed to report error", [
'original_message' => $report->message,
'reporting_error' => $e->getMessage(),
]);
return $report->id;
}
}
/**
* Report multiple errors in batch
*/
public function reportBatch(array $reports): array
{
$reportIds = [];
foreach ($reports as $report) {
if ($report instanceof ErrorReport) {
$reportIds[] = $this->report($report);
} else {
$this->logError("Invalid report in batch", ['report' => gettype($report)]);
}
}
return $reportIds;
}
/**
* Add request context to current thread
*/
public function withRequestContext(
string $method,
string $route,
?string $requestId = null,
?string $userAgent = null,
?string $ipAddress = null,
?array $requestData = null
): RequestContextualReporter {
return new RequestContextualReporter(
reporter: $this,
method: $method,
route: $route,
requestId: $requestId,
userAgent: $userAgent,
ipAddress: $ipAddress,
requestData: $requestData
);
}
/**
* Add user context to current thread
*/
public function withUserContext(string $userId, ?string $sessionId = null): UserContextualReporter
{
return new UserContextualReporter(
reporter: $this,
userId: $userId,
sessionId: $sessionId
);
}
/**
* Get error report by ID
*/
public function getReport(string $reportId): ?ErrorReport
{
try {
return $this->storage->find($reportId);
} catch (Throwable $e) {
$this->logError("Failed to retrieve error report", [
'report_id' => $reportId,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Get recent error reports
*/
public function getRecentReports(int $limit = 100, int $offset = 0): array
{
try {
return $this->storage->findRecent($limit, $offset);
} catch (Throwable $e) {
$this->logError("Failed to retrieve recent error reports", [
'error' => $e->getMessage(),
]);
return [];
}
}
/**
* Get error reports by criteria
*/
public function findReports(ErrorReportCriteria $criteria): array
{
try {
return $this->storage->findByCriteria($criteria);
} catch (Throwable $e) {
$this->logError("Failed to find error reports", [
'error' => $e->getMessage(),
]);
return [];
}
}
/**
* Get error statistics
*/
public function getStatistics(\DateTimeImmutable $from, \DateTimeImmutable $to): ErrorStatistics
{
try {
return $this->storage->getStatistics($from, $to);
} catch (Throwable $e) {
$this->logError("Failed to get error statistics", [
'error' => $e->getMessage(),
]);
return ErrorStatistics::empty();
}
}
/**
* Clean up old error reports
*/
public function cleanup(\DateTimeImmutable $before): int
{
try {
$deleted = $this->storage->deleteOlderThan($before);
$this->logInfo("Cleaned up old error reports", ['deleted_count' => $deleted]);
return $deleted;
} catch (Throwable $e) {
$this->logError("Failed to cleanup error reports", [
'error' => $e->getMessage(),
]);
return 0;
}
}
private function applyProcessors(ErrorReport $report): ErrorReport
{
$processedReport = $report;
foreach ($this->processors as $processor) {
try {
$processedReport = $processor($processedReport);
} catch (Throwable $e) {
$this->logError("Error processor failed", [
'processor' => $processor::class ?? 'closure',
'error' => $e->getMessage(),
]);
}
}
return $processedReport;
}
private function storeReport(ErrorReport $report): void
{
$this->storage->store($report);
}
private function queueReport(ErrorReport $report): void
{
$this->queue->push('error_report_processing', $report->toArray());
}
private function logDebug(string $message, array $context = []): void
{
$this->logger?->debug("[ErrorReporter] {$message}", $context);
}
private function logInfo(string $message, array $context = []): void
{
$this->logger?->info("[ErrorReporter] {$message}", $context);
}
private function logError(string $message, array $context = []): void
{
$this->logger?->error("[ErrorReporter] {$message}", $context);
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting;
use App\Framework\Database\ConnectionInterface;
use App\Framework\DateTime\Clock;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\ErrorReporting\Analytics\ErrorAnalyticsEngine;
use App\Framework\ErrorReporting\Processors\RequestContextProcessor;
use App\Framework\ErrorReporting\Processors\UserContextProcessor;
use App\Framework\ErrorReporting\Storage\DatabaseErrorReportStorage;
use App\Framework\ErrorReporting\Storage\ErrorReportStorageInterface;
use App\Framework\Http\Session\Session;
use App\Framework\Logging\Logger;
use App\Framework\Queue\Queue;
/**
* Initializer for Error Reporting system
*/
final readonly class ErrorReportingInitializer
{
#[Initializer]
public function initialize(Container $container): void
{
// Storage
$container->bind(ErrorReportStorageInterface::class, function (Container $container) {
return new DatabaseErrorReportStorage(
connection: $container->get(ConnectionInterface::class)
);
});
// Analytics Engine
$container->bind(ErrorAnalyticsEngine::class, function (Container $container) {
return new ErrorAnalyticsEngine(
storage: $container->get(ErrorReportStorageInterface::class),
clock: $container->get(Clock::class)
);
});
// Error Reporter
$container->bind(ErrorReporter::class, function (Container $container) {
$processors = [];
$filters = [];
// Add built-in processors
if ($container->has(RequestContextProcessor::class)) {
$processors[] = $container->get(RequestContextProcessor::class);
}
if ($container->has(UserContextProcessor::class)) {
$processors[] = $container->get(UserContextProcessor::class);
}
// Add environment-based filters
if (($_ENV['ERROR_REPORTING_FILTER_LEVELS'] ?? null)) {
$allowedLevels = explode(',', $_ENV['ERROR_REPORTING_FILTER_LEVELS']);
$filters[] = function (ErrorReport $report) use ($allowedLevels) {
return in_array($report->level, $allowedLevels);
};
}
// Add environment filter for production
if (($_ENV['APP_ENV'] ?? 'production') === 'production') {
$filters[] = function (ErrorReport $report) {
// Don't report debug/info in production
return ! in_array($report->level, ['debug', 'info']);
};
}
return new ErrorReporter(
storage: $container->get(ErrorReportStorageInterface::class),
clock: $container->get(Clock::class),
logger: $container->has(Logger::class) ? $container->get(Logger::class) : null,
queue: $container->has(Queue::class) ? $container->get(Queue::class) : null,
asyncProcessing: (bool) ($_ENV['ERROR_REPORTING_ASYNC'] ?? true),
processors: $processors,
filters: $filters
);
});
// Middleware
$container->bind(ErrorReportingMiddleware::class, function (Container $container) {
return new ErrorReportingMiddleware(
reporter: $container->get(ErrorReporter::class),
enabled: (bool) ($_ENV['ERROR_REPORTING_ENABLED'] ?? true)
);
});
// Processors
$container->bind(RequestContextProcessor::class, function (Container $container) {
return new RequestContextProcessor();
});
$container->bind(UserContextProcessor::class, function (Container $container) {
// Session is not a singleton service - it's created per-request by SessionManager
// Don't try to resolve it here as it may not exist or trigger circular dependencies
return new UserContextProcessor(null);
});
}
}

View File

@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\Next;
use App\Framework\Http\Request;
use App\Framework\Http\RequestStateManager;
use Throwable;
/**
* Middleware for automatic error reporting with request context enrichment
*/
final readonly class ErrorReportingMiddleware implements HttpMiddleware
{
private const int MAX_RECURSION_DEPTH = 5;
private const int MAX_ARRAY_SIZE = 50;
private const int MAX_STRING_LENGTH = 1000;
private const array PROXY_HEADERS = [
'X-Forwarded-For',
'X-Real-IP',
'Client-IP',
'X-Forwarded',
'Forwarded-For',
'Forwarded',
];
private const array SENSITIVE_KEYS = [
'password', 'passwd', 'pwd', 'secret', 'token', 'key', 'api_key',
'authorization', 'auth', 'csrf', 'csrf_token', 'access_token',
'refresh_token', 'private_key', 'credit_card', 'card_number',
'cvv', 'cvc', 'ssn', 'social_security',
];
public function __construct(
private ErrorReporter $reporter,
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) {
$this->reportError($e, $context);
throw $e; // Re-throw for proper error handling chain
}
}
/**
* Report error with enriched request context
*/
private function reportError(Throwable $error, MiddlewareContext $context): void
{
try {
$request = $context->request;
$requestContext = $this->buildRequestContext($request);
$contextualReporter = $this->reporter->withRequestContext(
method: $requestContext['method'],
route: $requestContext['route'],
requestId: $requestContext['requestId'],
userAgent: $requestContext['userAgent'],
ipAddress: $requestContext['ipAddress'],
requestData: $requestContext['requestData']
);
$contextualReporter->reportThrowable($error);
} catch (Throwable) {
// Silently fail to prevent error reporting from breaking the application
}
}
/**
* Build comprehensive request context for error reporting
*/
private function buildRequestContext(Request $request): array
{
return [
'method' => $request->method->value,
'route' => $request->path,
'requestId' => $request->id->toString(),
'userAgent' => $request->server->getUserAgent()->value,
'ipAddress' => $this->extractClientIpAddress($request),
'requestData' => $this->extractRequestData($request),
];
}
/**
* Extract client IP address from request headers and server environment
*/
private function extractClientIpAddress(Request $request): ?string
{
// Check proxy headers for real client IP
foreach (self::PROXY_HEADERS as $header) {
$ip = $this->extractIpFromHeader($request, $header);
if ($ip !== null) {
return $ip;
}
}
// Fallback to direct connection IP
return $request->server->get('REMOTE_ADDR');
}
/**
* Extract and validate IP address from specific header
*/
private function extractIpFromHeader(Request $request, string $header): ?string
{
$headerValue = $request->headers->get($header);
if (empty($headerValue)) {
return null;
}
// Handle comma-separated list (X-Forwarded-For)
if (str_contains($headerValue, ',')) {
$headerValue = trim(explode(',', $headerValue)[0]);
}
// Validate as public IP address
return filter_var($headerValue, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)
? $headerValue
: null;
}
/**
* Extract and sanitize request data from multiple sources
*/
private function extractRequestData(Request $request): ?array
{
$data = [];
// Query parameters
if (! empty($request->queryParams)) {
$data['query'] = $this->sanitizeData($request->queryParams);
}
// Request body (could be form data or JSON)
if (! empty($request->body)) {
$data['body'] = $this->sanitizeRequestBody($request);
}
return ! empty($data) ? $data : null;
}
/**
* Sanitize request body based on content type
*/
private function sanitizeRequestBody(Request $request): mixed
{
$contentType = $request->headers->get('Content-Type') ?? '';
if (str_contains($contentType, 'application/json')) {
return $this->sanitizeJsonBody($request->body);
}
// For other content types, just sanitize as string
return $this->sanitizeData($request->body);
}
/**
* Parse and sanitize JSON body
*/
private function sanitizeJsonBody(string $body): mixed
{
try {
$jsonData = json_decode($body, true);
return json_last_error() === JSON_ERROR_NONE
? $this->sanitizeData($jsonData)
: '[invalid_json]';
} catch (Throwable) {
return '[json_parse_error]';
}
}
/**
* Recursively sanitize data to remove sensitive information
*/
private function sanitizeData(mixed $data, int $depth = 0): mixed
{
if ($depth > self::MAX_RECURSION_DEPTH) {
return '[max_depth_reached]';
}
return match (true) {
is_array($data) => $this->sanitizeArray($data, $depth),
is_string($data) => $this->sanitizeString($data),
default => $data
};
}
/**
* Sanitize array data with size limits and sensitive key filtering
*/
private function sanitizeArray(array $data, int $depth): array
{
// Limit array size to prevent memory issues
if (count($data) > self::MAX_ARRAY_SIZE) {
$data = array_slice($data, 0, self::MAX_ARRAY_SIZE, true);
$data['[truncated]'] = 'Array truncated - too many items';
}
$sanitized = [];
foreach ($data as $key => $value) {
$sanitized[$key] = $this->isSensitiveKey((string)$key)
? '[redacted]'
: $this->sanitizeData($value, $depth + 1);
}
return $sanitized;
}
/**
* Sanitize string data with length limits and pattern removal
*/
private function sanitizeString(string $data): string
{
// Limit string length
if (strlen($data) > self::MAX_STRING_LENGTH) {
$data = substr($data, 0, self::MAX_STRING_LENGTH) . '[truncated]';
}
// Remove sensitive patterns
$patterns = [
'/\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/' => '[card_number]',
'/\b\d{3}-?\d{2}-?\d{4}\b/' => '[ssn]',
'/Bearer\s+[A-Za-z0-9\-._~+\/]+=*/' => 'Bearer [redacted]',
];
return preg_replace(array_keys($patterns), array_values($patterns), $data);
}
/**
* Check if a key contains sensitive information that should be redacted
*/
private function isSensitiveKey(string $key): bool
{
$lowerKey = strtolower($key);
foreach (self::SENSITIVE_KEYS as $sensitiveKey) {
if (str_contains($lowerKey, $sensitiveKey)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,304 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting;
use DateTimeImmutable;
/**
* Error statistics and analytics data
*/
final readonly class ErrorStatistics
{
public function __construct(
public int $totalErrors,
public int $uniqueErrors,
public array $errorsByLevel,
public array $errorsByException,
public array $errorsByRoute,
public array $errorsByUser,
public array $errorsByHour,
public array $errorsByDay,
public array $topErrors,
public array $trendingErrors,
public float $errorRate,
public array $responseTimeImpact,
public array $environmentBreakdown,
public DateTimeImmutable $periodStart,
public DateTimeImmutable $periodEnd
) {
}
/**
* Create empty statistics
*/
public static function empty(): self
{
return new self(
totalErrors: 0,
uniqueErrors: 0,
errorsByLevel: [],
errorsByException: [],
errorsByRoute: [],
errorsByUser: [],
errorsByHour: [],
errorsByDay: [],
topErrors: [],
trendingErrors: [],
errorRate: 0.0,
responseTimeImpact: [],
environmentBreakdown: [],
periodStart: new DateTimeImmutable(),
periodEnd: new DateTimeImmutable()
);
}
/**
* Get total critical errors (emergency, alert, critical levels)
*/
public function getCriticalErrorCount(): int
{
return ($this->errorsByLevel['emergency'] ?? 0) +
($this->errorsByLevel['alert'] ?? 0) +
($this->errorsByLevel['critical'] ?? 0);
}
/**
* Get error rate as percentage
*/
public function getErrorRatePercentage(): float
{
return round($this->errorRate * 100, 2);
}
/**
* Get most problematic route
*/
public function getMostProblematicRoute(): ?array
{
if (empty($this->errorsByRoute)) {
return null;
}
$route = array_key_first($this->errorsByRoute);
$count = reset($this->errorsByRoute);
return [
'route' => $route,
'count' => $count,
'percentage' => $this->totalErrors > 0 ? round(($count / $this->totalErrors) * 100, 2) : 0,
];
}
/**
* Get most frequent exception
*/
public function getMostFrequentException(): ?array
{
if (empty($this->errorsByException)) {
return null;
}
$exception = array_key_first($this->errorsByException);
$count = reset($this->errorsByException);
return [
'exception' => $exception,
'count' => $count,
'percentage' => $this->totalErrors > 0 ? round(($count / $this->totalErrors) * 100, 2) : 0,
];
}
/**
* Get peak error hour
*/
public function getPeakErrorHour(): ?array
{
if (empty($this->errorsByHour)) {
return null;
}
$hour = array_key_first($this->errorsByHour);
$count = reset($this->errorsByHour);
return [
'hour' => $hour,
'count' => $count,
'time' => sprintf('%02d:00', $hour),
];
}
/**
* Get error trend direction
*/
public function getErrorTrend(): string
{
if (count($this->errorsByDay) < 2) {
return 'stable';
}
$days = array_values($this->errorsByDay);
$recent = array_slice($days, -3); // Last 3 days
$previous = array_slice($days, -6, 3); // Previous 3 days
if (empty($recent) || empty($previous)) {
return 'stable';
}
$recentAvg = array_sum($recent) / count($recent);
$previousAvg = array_sum($previous) / count($previous);
$change = $previousAvg > 0 ? (($recentAvg - $previousAvg) / $previousAvg) * 100 : 0;
if ($change > 20) {
return 'increasing';
} elseif ($change < -20) {
return 'decreasing';
} else {
return 'stable';
}
}
/**
* Get error distribution health score (0-100)
*/
public function getHealthScore(): int
{
$score = 100;
// Penalize high error rates
if ($this->errorRate > 0.05) { // > 5%
$score -= 30;
} elseif ($this->errorRate > 0.02) { // > 2%
$score -= 15;
} elseif ($this->errorRate > 0.01) { // > 1%
$score -= 5;
}
// Penalize critical errors
$criticalCount = $this->getCriticalErrorCount();
if ($criticalCount > 0) {
$score -= min(40, $criticalCount * 5);
}
// Penalize trending up
if ($this->getErrorTrend() === 'increasing') {
$score -= 15;
}
// Penalize concentrated errors (single route causing many errors)
$topRoute = $this->getMostProblematicRoute();
if ($topRoute && $topRoute['percentage'] > 50) {
$score -= 10;
}
return max(0, $score);
}
/**
* Get insights and recommendations
*/
public function getInsights(): array
{
$insights = [];
// Critical error insight
$criticalCount = $this->getCriticalErrorCount();
if ($criticalCount > 0) {
$insights[] = [
'type' => 'critical',
'message' => "Found {$criticalCount} critical errors that require immediate attention.",
'priority' => 'high',
];
}
// Error rate insight
if ($this->errorRate > 0.05) {
$insights[] = [
'type' => 'error_rate',
'message' => sprintf('Error rate is %.2f%%, which is above the recommended threshold of 2%%.', $this->getErrorRatePercentage()),
'priority' => 'high',
];
}
// Route concentration insight
$topRoute = $this->getMostProblematicRoute();
if ($topRoute && $topRoute['percentage'] > 40) {
$insights[] = [
'type' => 'route_concentration',
'message' => sprintf('Route "%s" accounts for %.1f%% of all errors. Consider reviewing this endpoint.', $topRoute['route'], $topRoute['percentage']),
'priority' => 'medium',
];
}
// Exception concentration insight
$topException = $this->getMostFrequentException();
if ($topException && $topException['percentage'] > 50) {
$insights[] = [
'type' => 'exception_concentration',
'message' => sprintf('Exception "%s" accounts for %.1f%% of all errors. This indicates a systemic issue.', basename($topException['exception']), $topException['percentage']),
'priority' => 'medium',
];
}
// Trend insight
$trend = $this->getErrorTrend();
if ($trend === 'increasing') {
$insights[] = [
'type' => 'trend',
'message' => 'Error trend is increasing over the last few days. Monitor closely.',
'priority' => 'medium',
];
} elseif ($trend === 'decreasing') {
$insights[] = [
'type' => 'trend',
'message' => 'Error trend is decreasing. Good progress!',
'priority' => 'low',
];
}
// No errors insight
if ($this->totalErrors === 0) {
$insights[] = [
'type' => 'no_errors',
'message' => 'No errors recorded in this period. System is operating smoothly.',
'priority' => 'low',
];
}
return $insights;
}
/**
* Convert to array
*/
public function toArray(): array
{
return [
'total_errors' => $this->totalErrors,
'unique_errors' => $this->uniqueErrors,
'errors_by_level' => $this->errorsByLevel,
'errors_by_exception' => $this->errorsByException,
'errors_by_route' => $this->errorsByRoute,
'errors_by_user' => $this->errorsByUser,
'errors_by_hour' => $this->errorsByHour,
'errors_by_day' => $this->errorsByDay,
'top_errors' => $this->topErrors,
'trending_errors' => $this->trendingErrors,
'error_rate' => $this->errorRate,
'error_rate_percentage' => $this->getErrorRatePercentage(),
'response_time_impact' => $this->responseTimeImpact,
'environment_breakdown' => $this->environmentBreakdown,
'period_start' => $this->periodStart->format('c'),
'period_end' => $this->periodEnd->format('c'),
'critical_error_count' => $this->getCriticalErrorCount(),
'most_problematic_route' => $this->getMostProblematicRoute(),
'most_frequent_exception' => $this->getMostFrequentException(),
'peak_error_hour' => $this->getPeakErrorHour(),
'error_trend' => $this->getErrorTrend(),
'health_score' => $this->getHealthScore(),
'insights' => $this->getInsights(),
];
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationVersion;
use App\Framework\Database\Schema\Schema;
/**
* Create error_reports table for structured error reporting
*/
final class Migration_2024_01_25_100000_CreateErrorReportsTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->create('error_reports', function ($table) {
// Primary identification
$table->string('id', 32)->primary();
$table->timestamp('timestamp')->index();
// Error details
$table->string('level', 20)->index();
$table->text('message');
$table->string('exception', 255)->index();
$table->text('file');
$table->integer('line');
$table->longText('trace');
$table->json('context');
// User context
$table->string('user_id', 36)->nullable()->index();
$table->string('session_id', 128)->nullable()->index();
// Request context
$table->string('request_id', 64)->nullable()->index();
$table->text('user_agent')->nullable();
$table->string('ip_address', 45)->nullable()->index();
$table->string('route', 255)->nullable()->index();
$table->string('method', 10)->nullable()->index();
$table->json('request_data')->nullable();
// Performance context
$table->float('execution_time')->nullable()->index();
$table->integer('memory_usage')->nullable();
// Metadata
$table->json('tags');
$table->json('breadcrumbs');
$table->string('release', 50)->nullable()->index();
$table->string('environment', 20)->nullable()->index();
$table->json('server_info');
$table->json('custom_data');
// Analytics
$table->string('fingerprint', 16)->index();
$table->tinyInteger('severity_level')->index();
// Timestamps
$table->timestamp('created_at')->default('CURRENT_TIMESTAMP');
// Indexes for common queries
$table->index(['timestamp', 'level']);
$table->index(['fingerprint', 'timestamp']);
$table->index(['environment', 'timestamp']);
$table->index(['route', 'timestamp']);
$table->index(['user_id', 'timestamp']);
$table->index(['severity_level', 'timestamp']);
});
$schema->execute();
}
public function down(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->dropIfExists('error_reports');
$schema->execute();
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromTimestamp("2024_01_25_100000");
}
public function getDescription(): string
{
return 'Create error_reports table for structured error reporting and analytics';
}
}

View File

@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting\Processors;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\ErrorReporting\ErrorReport;
/**
* Processor that enriches error reports with HTTP request context
*/
final readonly class RequestContextProcessor
{
public function __invoke(ErrorReport $report): ErrorReport
{
// If already has request context, don't override
if ($report->route || $report->method) {
return $report;
}
// Extract from $_SERVER if available
$method = $_SERVER['REQUEST_METHOD'] ?? null;
$uri = $_SERVER['REQUEST_URI'] ?? null;
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null;
$ipAddress = $this->getClientIpAddress();
$requestId = $_SERVER['HTTP_X_REQUEST_ID'] ?? null;
// Get request data (sanitized)
$requestData = $this->getRequestData();
// Calculate execution time if request start time is available
$executionTime = null;
if (isset($_SERVER['REQUEST_TIME_FLOAT'])) {
$startTime = $_SERVER['REQUEST_TIME_FLOAT'];
$currentTime = microtime(true);
$executionTime = Duration::fromSeconds($currentTime - $startTime);
}
// Get memory usage
$memoryUsage = memory_get_usage(true);
$enrichedReport = $report;
if ($method && $uri) {
$enrichedReport = $enrichedReport->withRequest(
method: $method,
route: $this->normalizeRoute($uri),
requestId: $requestId,
userAgent: $userAgent,
ipAddress: $ipAddress,
requestData: $requestData
);
}
if ($executionTime || $memoryUsage) {
$enrichedReport = $enrichedReport->withPerformance(
executionTime: $executionTime ?? Duration::zero(),
memoryUsage: $memoryUsage
);
}
// Add request-specific tags
$tags = [];
if ($method) {
$tags[] = "method:{$method}";
}
if ($this->isApiRequest($uri)) {
$tags[] = 'api';
}
if ($this->isAjaxRequest()) {
$tags[] = 'ajax';
}
if (! empty($tags)) {
$enrichedReport = $enrichedReport->withTags($tags);
}
return $enrichedReport;
}
private function getClientIpAddress(): ?string
{
// Check for various headers that might contain the real IP
$headers = [
'HTTP_X_FORWARDED_FOR',
'HTTP_X_REAL_IP',
'HTTP_CLIENT_IP',
'HTTP_X_FORWARDED',
'HTTP_FORWARDED_FOR',
'HTTP_FORWARDED',
'REMOTE_ADDR',
];
foreach ($headers as $header) {
if (! empty($_SERVER[$header])) {
$ip = $_SERVER[$header];
// Handle comma-separated list (X-Forwarded-For)
if (str_contains($ip, ',')) {
$ip = trim(explode(',', $ip)[0]);
}
// Validate IP address
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return $ip;
}
}
}
return $_SERVER['REMOTE_ADDR'] ?? null;
}
private function getRequestData(): ?array
{
$data = [];
// GET parameters (limit and sanitize)
if (! empty($_GET)) {
$data['get'] = $this->sanitizeData($_GET);
}
// POST parameters (limit and sanitize)
if (! empty($_POST)) {
$data['post'] = $this->sanitizeData($_POST);
}
// JSON body (if present)
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (str_contains($contentType, 'application/json')) {
$input = file_get_contents('php://input');
if ($input) {
$jsonData = json_decode($input, true);
if (json_last_error() === JSON_ERROR_NONE) {
$data['json'] = $this->sanitizeData($jsonData);
}
}
}
return ! empty($data) ? $data : null;
}
private function sanitizeData(mixed $data, int $depth = 0): mixed
{
// Prevent infinite recursion
if ($depth > 5) {
return '[max_depth_reached]';
}
if (is_array($data)) {
// Limit array size
if (count($data) > 50) {
$data = array_slice($data, 0, 50, true);
$data['[truncated]'] = 'Array truncated - too many items';
}
$sanitized = [];
foreach ($data as $key => $value) {
// Skip sensitive keys
if ($this->isSensitiveKey($key)) {
$sanitized[$key] = '[redacted]';
} else {
$sanitized[$key] = $this->sanitizeData($value, $depth + 1);
}
}
return $sanitized;
}
if (is_string($data)) {
// Limit string length
if (strlen($data) > 1000) {
return substr($data, 0, 1000) . '[truncated]';
}
// Remove sensitive patterns
$data = preg_replace('/\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/', '[card_number]', $data);
$data = preg_replace('/\b\d{3}-?\d{2}-?\d{4}\b/', '[ssn]', $data);
return $data;
}
return $data;
}
private function isSensitiveKey(string $key): bool
{
$sensitiveKeys = [
'password', 'passwd', 'pwd', 'secret', 'token', 'key', 'api_key',
'authorization', 'auth', 'csrf', 'csrf_token', 'access_token',
'refresh_token', 'private_key', 'credit_card', 'card_number',
'cvv', 'cvc', 'ssn', 'social_security',
];
$lowerKey = strtolower($key);
foreach ($sensitiveKeys as $sensitiveKey) {
if (str_contains($lowerKey, $sensitiveKey)) {
return true;
}
}
return false;
}
private function normalizeRoute(string $uri): string
{
// Remove query string
$route = parse_url($uri, PHP_URL_PATH) ?? $uri;
// Remove trailing slash
$route = rtrim($route, '/');
// Replace dynamic segments with placeholders
$route = preg_replace('/\/\d+/', '/{id}', $route);
$route = preg_replace('/\/[a-f0-9-]{36}/', '/{uuid}', $route);
$route = preg_replace('/\/[a-f0-9]{32}/', '/{hash}', $route);
return $route ?: '/';
}
private function isApiRequest(?string $uri): bool
{
if (! $uri) {
return false;
}
return str_starts_with($uri, '/api/') ||
str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json');
}
private function isAjaxRequest(): bool
{
return (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') ||
str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json');
}
}

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting\Processors;
use App\Framework\ErrorReporting\ErrorReport;
use App\Framework\Http\Session\Session;
/**
* Processor that enriches error reports with user context
*/
final readonly class UserContextProcessor
{
public function __construct(
private ?Session $session = null
) {
}
public function __invoke(ErrorReport $report): ErrorReport
{
// If already has user context, don't override
if ($report->userId) {
return $report;
}
// Skip if no session available
if (! $this->session) {
return $report;
}
// Try to extract user information from session
$userId = $this->getUserIdFromSession();
$sessionId = $this->getSessionId();
if (! $userId && ! $sessionId) {
return $report;
}
$enrichedReport = $report;
if ($userId || $sessionId) {
$enrichedReport = $enrichedReport->withUser($userId, $sessionId);
}
// Add user-specific tags
$tags = [];
if ($userId) {
$tags[] = 'authenticated';
} else {
$tags[] = 'anonymous';
}
// Add session-specific information
if ($sessionId) {
$tags[] = 'has_session';
}
if (! empty($tags)) {
$enrichedReport = $enrichedReport->withTags($tags);
}
// Add breadcrumbs with user actions
$breadcrumbs = $this->getUserBreadcrumbs();
if (! empty($breadcrumbs)) {
$enrichedReport = $enrichedReport->withBreadcrumbs($breadcrumbs);
}
return $enrichedReport;
}
private function getUserIdFromSession(): ?string
{
// Try various common session keys for user ID
$userIdKeys = ['user_id', 'id', 'user', 'auth_user_id', 'logged_in_user'];
foreach ($userIdKeys as $key) {
if ($this->session->has($key)) {
$userId = $this->session->get($key);
// Handle user objects
if (is_object($userId) && isset($userId->id)) {
return (string) $userId->id;
}
// Handle arrays
if (is_array($userId) && isset($userId['id'])) {
return (string) $userId['id'];
}
// Handle simple values
if (is_scalar($userId)) {
return (string) $userId;
}
}
}
return null;
}
private function getSessionId(): ?string
{
// Get session ID from framework's session
return $this->session->getId();
}
private function getUserBreadcrumbs(): array
{
$breadcrumbs = [];
// Get from session if available
if ($this->session->has('user_breadcrumbs')) {
$sessionBreadcrumbs = $this->session->get('user_breadcrumbs');
if (is_array($sessionBreadcrumbs)) {
foreach ($sessionBreadcrumbs as $breadcrumb) {
if (is_array($breadcrumb) && isset($breadcrumb['message'])) {
$breadcrumbs[] = [
'message' => $breadcrumb['message'],
'category' => $breadcrumb['category'] ?? 'user_action',
'level' => $breadcrumb['level'] ?? 'info',
'timestamp' => $breadcrumb['timestamp'] ?? date('c'),
'data' => $breadcrumb['data'] ?? null,
];
}
}
}
}
// Add current request as breadcrumb
if (isset($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'])) {
$breadcrumbs[] = [
'message' => "HTTP Request: {$_SERVER['REQUEST_METHOD']} {$_SERVER['REQUEST_URI']}",
'category' => 'navigation',
'level' => 'info',
'timestamp' => date('c'),
'data' => [
'method' => $_SERVER['REQUEST_METHOD'],
'uri' => $_SERVER['REQUEST_URI'],
'referer' => $_SERVER['HTTP_REFERER'] ?? null,
],
];
}
// Limit breadcrumbs to prevent excessive data
return array_slice($breadcrumbs, -10);
}
/**
* Helper method to add user breadcrumb (can be called from application code)
*/
public function addBreadcrumb(
string $message,
string $category = 'user_action',
string $level = 'info',
?array $data = null
): void {
if (! $this->session) {
return;
}
$breadcrumbs = $this->session->get('user_breadcrumbs', []);
$breadcrumbs[] = [
'message' => $message,
'category' => $category,
'level' => $level,
'timestamp' => date('c'),
'data' => $data,
];
// Keep only last 20 breadcrumbs
$breadcrumbs = array_slice($breadcrumbs, -20);
$this->session->set('user_breadcrumbs', $breadcrumbs);
}
/**
* Helper method to clear user breadcrumbs
*/
public function clearBreadcrumbs(): void
{
if ($this->session) {
$this->session->remove('user_breadcrumbs');
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting;
use Throwable;
/**
* Contextual reporter with request information
*/
final readonly class RequestContextualReporter
{
public function __construct(
private ErrorReporter $reporter,
private string $method,
private string $route,
private ?string $requestId = null,
private ?string $userAgent = null,
private ?string $ipAddress = null,
private ?array $requestData = null
) {
}
public function reportThrowable(Throwable $throwable, string $level = 'error', array $context = []): string
{
$report = ErrorReport::fromThrowable($throwable, $level, $context)
->withRequest(
method : $this->method,
route : $this->route,
requestId : $this->requestId,
userAgent : $this->userAgent,
ipAddress : $this->ipAddress,
requestData: $this->requestData
);
return $this->reporter->report($report);
}
public function reportError(string $level, string $message, array $context = [], ?Throwable $exception = null): string
{
$report = ErrorReport::create($level, $message, $context, $exception)
->withRequest(
method : $this->method,
route : $this->route,
requestId : $this->requestId,
userAgent : $this->userAgent,
ipAddress : $this->ipAddress,
requestData: $this->requestData
);
return $this->reporter->report($report);
}
}

View File

@@ -0,0 +1,468 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting\Storage;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Database\ConnectionInterface;
use App\Framework\ErrorReporting\ErrorReport;
use App\Framework\ErrorReporting\ErrorReportCriteria;
use App\Framework\ErrorReporting\ErrorStatistics;
use DateTimeImmutable;
/**
* Database storage implementation for error reports
*/
final readonly class DatabaseErrorReportStorage implements ErrorReportStorageInterface
{
public function __construct(
private ConnectionInterface $connection
) {
}
public function store(ErrorReport $report): void
{
$sql = '
INSERT INTO error_reports (
id, timestamp, level, message, exception, file, line, trace, context,
user_id, session_id, request_id, user_agent, ip_address, route, method,
request_data, execution_time, memory_usage, tags, breadcrumbs,
release, environment, server_info, custom_data, fingerprint, severity_level
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
';
$this->connection->execute($sql, [
$report->id,
$report->timestamp->format('Y-m-d H:i:s'),
$report->level,
$report->message,
$report->exception,
$report->file,
$report->line,
$report->trace,
json_encode($report->context),
$report->userId,
$report->sessionId,
$report->requestId,
$report->userAgent,
$report->ipAddress,
$report->route,
$report->method,
$report->requestData ? json_encode($report->requestData) : null,
$report->executionTime?->toMilliseconds(),
$report->memoryUsage,
json_encode($report->tags),
json_encode($report->breadcrumbs),
$report->release,
$report->environment,
json_encode($report->serverInfo),
json_encode($report->customData),
$report->getFingerprint(),
$report->getSeverityLevel(),
]);
}
public function storeBatch(array $reports): void
{
if (empty($reports)) {
return;
}
$this->connection->transaction(function () use ($reports) {
foreach ($reports as $report) {
$this->store($report);
}
});
}
public function find(string $reportId): ?ErrorReport
{
$sql = 'SELECT * FROM error_reports WHERE id = ?';
$result = $this->connection->query($sql, [$reportId]);
if (empty($result)) {
return null;
}
return $this->hydrate($result[0]);
}
public function findRecent(int $limit = 100, int $offset = 0): array
{
$sql = '
SELECT * FROM error_reports
ORDER BY timestamp DESC
LIMIT ? OFFSET ?
';
$results = $this->connection->query($sql, [$limit, $offset]);
return array_map([$this, 'hydrate'], $results);
}
public function findByCriteria(ErrorReportCriteria $criteria): array
{
[$sql, $params] = $this->buildCriteriaQuery($criteria);
$results = $this->connection->query($sql, $params);
return array_map([$this, 'hydrate'], $results);
}
public function countByCriteria(ErrorReportCriteria $criteria): int
{
[$sql, $params] = $this->buildCriteriaQuery($criteria, true);
$result = $this->connection->query($sql, $params);
return (int) ($result[0]['count'] ?? 0);
}
public function getStatistics(DateTimeImmutable $from, DateTimeImmutable $to): ErrorStatistics
{
// Total and unique errors
$totalSql = 'SELECT COUNT(*) as total, COUNT(DISTINCT fingerprint) as unique FROM error_reports WHERE timestamp BETWEEN ? AND ?';
$totalResult = $this->connection->query($totalSql, [$from->format('Y-m-d H:i:s'), $to->format('Y-m-d H:i:s')]);
$totalErrors = (int) ($totalResult[0]['total'] ?? 0);
$uniqueErrors = (int) ($totalResult[0]['unique'] ?? 0);
// Errors by level
$levelSql = 'SELECT level, COUNT(*) as count FROM error_reports WHERE timestamp BETWEEN ? AND ? GROUP BY level ORDER BY count DESC';
$levelResults = $this->connection->query($levelSql, [$from->format('Y-m-d H:i:s'), $to->format('Y-m-d H:i:s')]);
$errorsByLevel = [];
foreach ($levelResults as $row) {
$errorsByLevel[$row['level']] = (int) $row['count'];
}
// Errors by exception
$exceptionSql = 'SELECT exception, COUNT(*) as count FROM error_reports WHERE timestamp BETWEEN ? AND ? GROUP BY exception ORDER BY count DESC LIMIT 10';
$exceptionResults = $this->connection->query($exceptionSql, [$from->format('Y-m-d H:i:s'), $to->format('Y-m-d H:i:s')]);
$errorsByException = [];
foreach ($exceptionResults as $row) {
$errorsByException[$row['exception']] = (int) $row['count'];
}
// Errors by route
$routeSql = 'SELECT route, COUNT(*) as count FROM error_reports WHERE timestamp BETWEEN ? AND ? AND route IS NOT NULL GROUP BY route ORDER BY count DESC LIMIT 10';
$routeResults = $this->connection->query($routeSql, [$from->format('Y-m-d H:i:s'), $to->format('Y-m-d H:i:s')]);
$errorsByRoute = [];
foreach ($routeResults as $row) {
$errorsByRoute[$row['route']] = (int) $row['count'];
}
// Errors by user
$userSql = 'SELECT user_id, COUNT(*) as count FROM error_reports WHERE timestamp BETWEEN ? AND ? AND user_id IS NOT NULL GROUP BY user_id ORDER BY count DESC LIMIT 10';
$userResults = $this->connection->query($userSql, [$from->format('Y-m-d H:i:s'), $to->format('Y-m-d H:i:s')]);
$errorsByUser = [];
foreach ($userResults as $row) {
$errorsByUser[$row['user_id']] = (int) $row['count'];
}
// Errors by hour
$hourSql = 'SELECT HOUR(timestamp) as hour, COUNT(*) as count FROM error_reports WHERE timestamp BETWEEN ? AND ? GROUP BY HOUR(timestamp) ORDER BY hour';
$hourResults = $this->connection->query($hourSql, [$from->format('Y-m-d H:i:s'), $to->format('Y-m-d H:i:s')]);
$errorsByHour = [];
foreach ($hourResults as $row) {
$errorsByHour[(int) $row['hour']] = (int) $row['count'];
}
// Errors by day
$daySql = 'SELECT DATE(timestamp) as day, COUNT(*) as count FROM error_reports WHERE timestamp BETWEEN ? AND ? GROUP BY DATE(timestamp) ORDER BY day';
$dayResults = $this->connection->query($daySql, [$from->format('Y-m-d H:i:s'), $to->format('Y-m-d H:i:s')]);
$errorsByDay = [];
foreach ($dayResults as $row) {
$errorsByDay[$row['day']] = (int) $row['count'];
}
// Top errors by fingerprint
$topErrorsSql = '
SELECT fingerprint, exception, file, line, COUNT(*) as count, MAX(timestamp) as last_seen
FROM error_reports
WHERE timestamp BETWEEN ? AND ?
GROUP BY fingerprint, exception, file, line
ORDER BY count DESC
LIMIT 10
';
$topErrorsResults = $this->connection->query($topErrorsSql, [$from->format('Y-m-d H:i:s'), $to->format('Y-m-d H:i:s')]);
$topErrors = [];
foreach ($topErrorsResults as $row) {
$topErrors[] = [
'fingerprint' => $row['fingerprint'],
'exception' => $row['exception'],
'file' => $row['file'],
'line' => (int) $row['line'],
'count' => (int) $row['count'],
'last_seen' => $row['last_seen'],
];
}
// Environment breakdown
$envSql = 'SELECT environment, COUNT(*) as count FROM error_reports WHERE timestamp BETWEEN ? AND ? GROUP BY environment ORDER BY count DESC';
$envResults = $this->connection->query($envSql, [$from->format('Y-m-d H:i:s'), $to->format('Y-m-d H:i:s')]);
$environmentBreakdown = [];
foreach ($envResults as $row) {
$environmentBreakdown[$row['environment'] ?? 'unknown'] = (int) $row['count'];
}
// Calculate error rate (simplified - would need request metrics for accurate rate)
$errorRate = 0.0; // This would need additional request tracking
return new ErrorStatistics(
totalErrors: $totalErrors,
uniqueErrors: $uniqueErrors,
errorsByLevel: $errorsByLevel,
errorsByException: $errorsByException,
errorsByRoute: $errorsByRoute,
errorsByUser: $errorsByUser,
errorsByHour: $errorsByHour,
errorsByDay: $errorsByDay,
topErrors: $topErrors,
trendingErrors: [], // Would need more complex calculation
errorRate: $errorRate,
responseTimeImpact: [], // Would need response time tracking
environmentBreakdown: $environmentBreakdown,
periodStart: $from,
periodEnd: $to
);
}
public function getTrends(DateTimeImmutable $from, DateTimeImmutable $to, string $groupBy = 'hour'): array
{
$groupFormat = match ($groupBy) {
'minute' => '%Y-%m-%d %H:%i:00',
'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(timestamp, '{$groupFormat}') as period, COUNT(*) as count
FROM error_reports
WHERE timestamp BETWEEN ? AND ?
GROUP BY DATE_FORMAT(timestamp, '{$groupFormat}')
ORDER BY period
";
$results = $this->connection->query($sql, [$from->format('Y-m-d H:i:s'), $to->format('Y-m-d H:i:s')]);
$trends = [];
foreach ($results as $row) {
$trends[] = [
'period' => $row['period'],
'count' => (int) $row['count'],
];
}
return $trends;
}
public function getTopErrors(DateTimeImmutable $from, DateTimeImmutable $to, int $limit = 10): array
{
$sql = '
SELECT fingerprint, exception, message, file, line, COUNT(*) as count, MAX(timestamp) as last_seen
FROM error_reports
WHERE timestamp BETWEEN ? AND ?
GROUP BY fingerprint, exception, message, file, line
ORDER BY count DESC
LIMIT ?
';
$results = $this->connection->query($sql, [$from->format('Y-m-d H:i:s'), $to->format('Y-m-d H:i:s'), $limit]);
$topErrors = [];
foreach ($results as $row) {
$topErrors[] = [
'fingerprint' => $row['fingerprint'],
'exception' => $row['exception'],
'message' => $row['message'],
'file' => $row['file'],
'line' => (int) $row['line'],
'count' => (int) $row['count'],
'last_seen' => $row['last_seen'],
];
}
return $topErrors;
}
public function findByFingerprint(string $fingerprint, int $limit = 100): array
{
$sql = '
SELECT * FROM error_reports
WHERE fingerprint = ?
ORDER BY timestamp DESC
LIMIT ?
';
$results = $this->connection->query($sql, [$fingerprint, $limit]);
return array_map([$this, 'hydrate'], $results);
}
public function deleteOlderThan(DateTimeImmutable $before): int
{
$sql = 'DELETE FROM error_reports WHERE timestamp < ?';
return $this->connection->execute($sql, [$before->format('Y-m-d H:i:s')]);
}
public function deleteByCriteria(ErrorReportCriteria $criteria): int
{
[$sql, $params] = $this->buildCriteriaQuery($criteria, false, true);
return $this->connection->execute($sql, $params);
}
public function getHealthInfo(): array
{
// Total records
$totalSql = 'SELECT COUNT(*) as total FROM error_reports';
$totalResult = $this->connection->query($totalSql);
$totalRecords = (int) ($totalResult[0]['total'] ?? 0);
// Recent activity (last 24 hours)
$recentSql = 'SELECT COUNT(*) as recent FROM error_reports WHERE timestamp > DATE_SUB(NOW(), INTERVAL 24 HOUR)';
$recentResult = $this->connection->query($recentSql);
$recentRecords = (int) ($recentResult[0]['recent'] ?? 0);
// Table size
$sizeSql = "
SELECT
ROUND(((data_length + index_length) / 1024 / 1024), 2) as size_mb
FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'error_reports'
";
$sizeResult = $this->connection->query($sizeSql);
$sizeMb = (float) ($sizeResult[0]['size_mb'] ?? 0);
// Oldest record
$oldestSql = 'SELECT MIN(timestamp) as oldest FROM error_reports';
$oldestResult = $this->connection->query($oldestSql);
$oldest = $oldestResult[0]['oldest'] ?? null;
return [
'total_records' => $totalRecords,
'recent_records_24h' => $recentRecords,
'storage_size_mb' => $sizeMb,
'oldest_record' => $oldest,
'storage_type' => 'database',
];
}
private function buildCriteriaQuery(ErrorReportCriteria $criteria, bool $isCount = false, bool $isDelete = false): array
{
$select = $isCount ? 'SELECT COUNT(*) as count' : 'SELECT *';
$operation = $isDelete ? 'DELETE' : $select;
$sql = "{$operation} FROM error_reports WHERE 1=1";
$params = [];
if ($criteria->from) {
$sql .= ' AND timestamp >= ?';
$params[] = $criteria->from->format('Y-m-d H:i:s');
}
if ($criteria->to) {
$sql .= ' AND timestamp <= ?';
$params[] = $criteria->to->format('Y-m-d H:i:s');
}
if ($criteria->levels) {
$placeholders = str_repeat('?,', count($criteria->levels) - 1) . '?';
$sql .= " AND level IN ({$placeholders})";
$params = array_merge($params, $criteria->levels);
}
if ($criteria->exceptions) {
$placeholders = str_repeat('?,', count($criteria->exceptions) - 1) . '?';
$sql .= " AND exception IN ({$placeholders})";
$params = array_merge($params, $criteria->exceptions);
}
if ($criteria->routes) {
$placeholders = str_repeat('?,', count($criteria->routes) - 1) . '?';
$sql .= " AND route IN ({$placeholders})";
$params = array_merge($params, $criteria->routes);
}
if ($criteria->methods) {
$placeholders = str_repeat('?,', count($criteria->methods) - 1) . '?';
$sql .= " AND method IN ({$placeholders})";
$params = array_merge($params, $criteria->methods);
}
if ($criteria->userId) {
$sql .= ' AND user_id = ?';
$params[] = $criteria->userId;
}
if ($criteria->environment) {
$sql .= ' AND environment = ?';
$params[] = $criteria->environment;
}
if ($criteria->fingerprint) {
$sql .= ' AND fingerprint = ?';
$params[] = $criteria->fingerprint;
}
if ($criteria->minSeverity !== null) {
$sql .= ' AND severity_level >= ?';
$params[] = $criteria->minSeverity;
}
if ($criteria->maxSeverity !== null) {
$sql .= ' AND severity_level <= ?';
$params[] = $criteria->maxSeverity;
}
if ($criteria->search) {
$sql .= ' AND (message LIKE ? OR exception LIKE ? OR file LIKE ?)';
$searchTerm = '%' . $criteria->search . '%';
$params[] = $searchTerm;
$params[] = $searchTerm;
$params[] = $searchTerm;
}
if (! $isCount && ! $isDelete) {
$sql .= " ORDER BY {$criteria->orderBy} {$criteria->orderDir}";
$sql .= " LIMIT {$criteria->limit} OFFSET {$criteria->offset}";
}
return [$sql, $params];
}
private function hydrate(array $row): ErrorReport
{
return new ErrorReport(
id: $row['id'],
timestamp: new DateTimeImmutable($row['timestamp']),
level: $row['level'],
message: $row['message'],
exception: $row['exception'],
file: $row['file'],
line: (int) $row['line'],
trace: $row['trace'],
context: json_decode($row['context'] ?? '[]', true),
userId: $row['user_id'],
sessionId: $row['session_id'],
requestId: $row['request_id'],
userAgent: $row['user_agent'],
ipAddress: $row['ip_address'],
route: $row['route'],
method: $row['method'],
requestData: $row['request_data'] ? json_decode($row['request_data'], true) : null,
executionTime: $row['execution_time'] ? Duration::fromMilliseconds((float) $row['execution_time']) : null,
memoryUsage: $row['memory_usage'] ? (int) $row['memory_usage'] : null,
tags: json_decode($row['tags'] ?? '[]', true),
breadcrumbs: json_decode($row['breadcrumbs'] ?? '[]', true),
release: $row['release'],
environment: $row['environment'],
serverInfo: json_decode($row['server_info'] ?? '[]', true),
customData: json_decode($row['custom_data'] ?? '[]', true)
);
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting\Storage;
use App\Framework\ErrorReporting\ErrorReport;
use App\Framework\ErrorReporting\ErrorReportCriteria;
use App\Framework\ErrorReporting\ErrorStatistics;
use DateTimeImmutable;
/**
* Interface for error report storage implementations
*/
interface ErrorReportStorageInterface
{
/**
* Store an error report
*/
public function store(ErrorReport $report): void;
/**
* Store multiple error reports in batch
*/
public function storeBatch(array $reports): void;
/**
* Find error report by ID
*/
public function find(string $reportId): ?ErrorReport;
/**
* Find recent error reports
*/
public function findRecent(int $limit = 100, int $offset = 0): array;
/**
* Find error reports by criteria
*/
public function findByCriteria(ErrorReportCriteria $criteria): array;
/**
* Count error reports by criteria
*/
public function countByCriteria(ErrorReportCriteria $criteria): int;
/**
* Get error statistics for a time period
*/
public function getStatistics(DateTimeImmutable $from, DateTimeImmutable $to): ErrorStatistics;
/**
* Get error trends over time
*/
public function getTrends(DateTimeImmutable $from, DateTimeImmutable $to, string $groupBy = 'hour'): array;
/**
* Get most frequent errors
*/
public function getTopErrors(DateTimeImmutable $from, DateTimeImmutable $to, int $limit = 10): array;
/**
* Get error reports by fingerprint (similar errors)
*/
public function findByFingerprint(string $fingerprint, int $limit = 100): array;
/**
* Delete error reports older than specified date
*/
public function deleteOlderThan(DateTimeImmutable $before): int;
/**
* Delete error reports by criteria
*/
public function deleteByCriteria(ErrorReportCriteria $criteria): int;
/**
* Get storage health information
*/
public function getHealthInfo(): array;
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Framework\ErrorReporting;
/**
* Contextual reporter with user information
*/
final readonly class UserContextualReporter
{
public function __construct(
private ErrorReporter $reporter,
private string $userId,
private ?string $sessionId = null
) {
}
public function reportThrowable(Throwable $throwable, string $level = 'error', array $context = []): string
{
$report = ErrorReport::fromThrowable($throwable, $level, $context)
->withUser($this->userId, $this->sessionId);
return $this->reporter->report($report);
}
public function reportError(string $level, string $message, array $context = [], ?Throwable $exception = null): string
{
$report = ErrorReport::create($level, $message, $context, $exception)
->withUser($this->userId, $this->sessionId);
return $this->reporter->report($report);
}
}