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:
518
src/Framework/ErrorReporting/Analytics/ErrorAnalyticsEngine.php
Normal file
518
src/Framework/ErrorReporting/Analytics/ErrorAnalyticsEngine.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
448
src/Framework/ErrorReporting/Commands/ErrorReportingCommand.php
Normal file
448
src/Framework/ErrorReporting/Commands/ErrorReportingCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
408
src/Framework/ErrorReporting/ErrorReport.php
Normal file
408
src/Framework/ErrorReporting/ErrorReport.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
251
src/Framework/ErrorReporting/ErrorReportCriteria.php
Normal file
251
src/Framework/ErrorReporting/ErrorReportCriteria.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
278
src/Framework/ErrorReporting/ErrorReporter.php
Normal file
278
src/Framework/ErrorReporting/ErrorReporter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
103
src/Framework/ErrorReporting/ErrorReportingInitializer.php
Normal file
103
src/Framework/ErrorReporting/ErrorReportingInitializer.php
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
260
src/Framework/ErrorReporting/ErrorReportingMiddleware.php
Normal file
260
src/Framework/ErrorReporting/ErrorReportingMiddleware.php
Normal 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;
|
||||
}
|
||||
}
|
||||
304
src/Framework/ErrorReporting/ErrorStatistics.php
Normal file
304
src/Framework/ErrorReporting/ErrorStatistics.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
187
src/Framework/ErrorReporting/Processors/UserContextProcessor.php
Normal file
187
src/Framework/ErrorReporting/Processors/UserContextProcessor.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/Framework/ErrorReporting/RequestContextualReporter.php
Normal file
54
src/Framework/ErrorReporting/RequestContextualReporter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
34
src/Framework/ErrorReporting/UserContextualReporter.php
Normal file
34
src/Framework/ErrorReporting/UserContextualReporter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user