Files
michaelschiemer/docs/ERROR-HANDLING-MIGRATION-PLAN.md
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
2025-10-25 19:18:37 +02:00

98 KiB

Error Handling Migration Plan

Project: Error Handling System Consolidation Phase: Migration & Implementation Planning Version: 1.0 Date: 2025-01-13 Estimated Duration: 3-5 days (based on FRAMEWORK-IMPROVEMENT-PROPOSALS.md)


Executive Summary

This document provides a detailed, step-by-step migration plan for consolidating the fragmented error handling system into a unified Exception architecture. The migration follows a phased approach with backward compatibility, ensuring zero downtime and gradual rollout.

Migration Strategy:

  • Incremental implementation (7 phases)
  • Backward compatibility maintained throughout
  • Feature flags for gradual rollout
  • Comprehensive testing at each checkpoint
  • Rollback procedures at every phase

Success Criteria:

  • Zero production incidents during migration
  • All existing error handling functionality preserved
  • Performance improved (target: <1ms exception creation)
  • Code reduction from 2,006 lines → ~1,200 lines
  • Database consolidation from 3 tables → 2 tables

Phase 1: Core Infrastructure (Day 1, Morning)

Goal: Establish enhanced FrameworkException and unified ExceptionContext foundation

Tasks

1.1 Enhance FrameworkException

File: src/Framework/Exception/FrameworkException.php

Changes:

// Add new properties
protected bool $skipAggregation = false;
protected bool $skipReporting = false;
protected bool $reported = false;

// Add integration control methods
public function skipAggregation(): self
{
    $clone = clone $this;
    $clone->skipAggregation = true;
    return $clone;
}

public function skipReporting(): self
{
    $clone = clone $this;
    $clone->skipReporting = true;
    return $clone;
}

public function markAsReported(): void
{
    $this->reported = true;
}

public function wasReported(): bool
{
    return $this->reported;
}

public function shouldSkipAggregation(): bool
{
    return $this->skipAggregation;
}

public function shouldSkipReporting(): bool
{
    return $this->skipReporting;
}

// Add severity mapping
public function getSeverity(): ErrorSeverity
{
    return $this->errorCode?->getSeverity() ?? ErrorSeverity::ERROR;
}

// Add request context methods
public function withRequestContext(
    ?string $requestId = null,
    ?string $requestMethod = null,
    ?string $requestUri = null,
    ?string $clientIp = null,
    ?string $userAgent = null,
    ?string $userId = null
): self {
    $clone = clone $this;
    $clone->context = $this->context->withRequestContext(
        $requestId,
        $requestMethod,
        $requestUri,
        $clientIp,
        $userAgent,
        $userId
    );
    return $clone;
}

// Add system context methods
public function withSystemContext(
    ?string $environment = null,
    ?string $hostname = null,
    ?float $memoryUsage = null,
    ?float $cpuUsage = null,
    ?string $phpVersion = null
): self {
    $clone = clone $this;
    $clone->context = $this->context->withSystemContext(
        $environment,
        $hostname,
        $memoryUsage,
        $cpuUsage,
        $phpVersion
    );
    return $clone;
}

Estimated Time: 2 hours

1.2 Enhance ExceptionContext

File: src/Framework/Exception/ExceptionContext.php

Changes:

final readonly class ExceptionContext
{
    public function __construct(
        // Domain Context (existing)
        public ?string $operation = null,
        public ?string $component = null,
        public array $data = [],
        public array $debug = [],
        public array $metadata = [],

        // Request Context (NEW)
        public ?string $requestId = null,
        public ?string $requestMethod = null,
        public ?string $requestUri = null,
        public ?string $clientIp = null,
        public ?string $userAgent = null,
        public ?string $userId = null,

        // System Context (NEW)
        public ?string $environment = null,
        public ?string $hostname = null,
        public ?float $memoryUsage = null,
        public ?float $cpuUsage = null,
        public ?string $phpVersion = null,
    ) {}

    // Add request context builder
    public function withRequestContext(
        ?string $requestId = null,
        ?string $requestMethod = null,
        ?string $requestUri = null,
        ?string $clientIp = null,
        ?string $userAgent = null,
        ?string $userId = null
    ): self {
        return new self(
            operation: $this->operation,
            component: $this->component,
            data: $this->data,
            debug: $this->debug,
            metadata: $this->metadata,
            requestId: $requestId ?? $this->requestId,
            requestMethod: $requestMethod ?? $this->requestMethod,
            requestUri: $requestUri ?? $this->requestUri,
            clientIp: $clientIp ?? $this->clientIp,
            userAgent: $userAgent ?? $this->userAgent,
            userId: $userId ?? $this->userId,
            environment: $this->environment,
            hostname: $this->hostname,
            memoryUsage: $this->memoryUsage,
            cpuUsage: $this->cpuUsage,
            phpVersion: $this->phpVersion
        );
    }

    // Add system context builder
    public function withSystemContext(
        ?string $environment = null,
        ?string $hostname = null,
        ?float $memoryUsage = null,
        ?float $cpuUsage = null,
        ?string $phpVersion = null
    ): self {
        return new self(
            operation: $this->operation,
            component: $this->component,
            data: $this->data,
            debug: $this->debug,
            metadata: $this->metadata,
            requestId: $this->requestId,
            requestMethod: $this->requestMethod,
            requestUri: $this->requestUri,
            clientIp: $this->clientIp,
            userAgent: $this->userAgent,
            userId: $this->userId,
            environment: $environment ?? $this->environment,
            hostname: $hostname ?? $this->hostname,
            memoryUsage: $memoryUsage ?? $this->memoryUsage,
            cpuUsage: $cpuUsage ?? $this->cpuUsage,
            phpVersion: $phpVersion ?? $this->phpVersion
        );
    }

    // Add conversion to array for storage
    public function toArray(): array
    {
        return [
            'domain' => [
                'operation' => $this->operation,
                'component' => $this->component,
                'data' => $this->sanitizeData($this->data),
                'debug' => $this->debug,
                'metadata' => $this->metadata,
            ],
            'request' => [
                'request_id' => $this->requestId,
                'method' => $this->requestMethod,
                'uri' => $this->requestUri,
                'client_ip' => $this->clientIp,
                'user_agent' => $this->userAgent,
                'user_id' => $this->userId,
            ],
            'system' => [
                'environment' => $this->environment,
                'hostname' => $this->hostname,
                'memory_usage' => $this->memoryUsage,
                'cpu_usage' => $this->cpuUsage,
                'php_version' => $this->phpVersion,
            ],
        ];
    }
}

Estimated Time: 1.5 hours

1.3 Enhance ErrorCode with Severity Mapping

File: src/Framework/Exception/ErrorCode.php

Changes:

enum ErrorCode: string
{
    // ... existing cases ...

    /**
     * Get severity level for this error code
     */
    public function getSeverity(): ErrorSeverity
    {
        return match($this) {
            // CRITICAL - System failures, data corruption, security breaches
            self::SYS_BOOT_FAILURE,
            self::SYS_FATAL_ERROR,
            self::DB_CONNECTION_FAILED,
            self::DB_DATA_CORRUPTION,
            self::SEC_INTRUSION_DETECTED,
            self::SEC_DATA_BREACH,
            self::PAY_GATEWAY_ERROR,
            self::BIZ_DATA_INCONSISTENCY => ErrorSeverity::CRITICAL,

            // ERROR - Operation failures, validation errors, resource issues
            self::DB_QUERY_FAILED,
            self::DB_TRANSACTION_FAILED,
            self::AUTH_FAILED,
            self::AUTH_TOKEN_INVALID,
            self::VAL_BUSINESS_RULE_VIOLATION,
            self::HTTP_NOT_FOUND,
            self::HTTP_METHOD_NOT_ALLOWED,
            self::CACHE_WRITE_FAILED,
            self::FS_READ_FAILED,
            self::FS_WRITE_FAILED,
            self::API_REQUEST_FAILED,
            self::QUEUE_PUSH_FAILED,
            self::QUEUE_POP_FAILED,
            self::PERF_TIMEOUT => ErrorSeverity::ERROR,

            // WARNING - Potential issues, degraded performance
            self::DB_SLOW_QUERY,
            self::AUTH_SESSION_EXPIRED,
            self::VAL_INPUT_INVALID,
            self::HTTP_RATE_LIMIT_EXCEEDED,
            self::CACHE_MISS,
            self::FS_PERMISSION_DENIED,
            self::API_RATE_LIMIT_EXCEEDED,
            self::PERF_SLOW_OPERATION,
            self::PERF_MEMORY_HIGH => ErrorSeverity::WARNING,

            // INFO - Informational events
            self::AUTH_LOGOUT_SUCCESS,
            self::CACHE_HIT,
            self::QUEUE_EMPTY => ErrorSeverity::INFO,

            // DEBUG - Debugging information
            self::SYS_DEBUG_MESSAGE,
            self::PERF_BENCHMARK => ErrorSeverity::DEBUG,

            // Default to ERROR for unmapped codes
            default => ErrorSeverity::ERROR,
        };
    }
}

Estimated Time: 2 hours (mapping all 100+ codes)

1.4 Create ErrorSeverity Enum

File: src/Framework/Exception/Core/ErrorSeverity.php

New File:

<?php

declare(strict_types=1);

namespace App\Framework\Exception\Core;

/**
 * Error severity levels with retention policies
 */
enum ErrorSeverity: string
{
    case CRITICAL = 'critical';  // 180 days retention
    case ERROR = 'error';        // 90 days retention
    case WARNING = 'warning';    // 30 days retention
    case INFO = 'info';          // 14 days retention
    case DEBUG = 'debug';        // 7 days retention

    /**
     * Get retention period in days
     */
    public function getRetentionDays(): int
    {
        return match($this) {
            self::CRITICAL => 180,
            self::ERROR => 90,
            self::WARNING => 30,
            self::INFO => 14,
            self::DEBUG => 7,
        };
    }

    /**
     * Check if this severity should trigger alerts
     */
    public function shouldAlert(): bool
    {
        return match($this) {
            self::CRITICAL, self::ERROR => true,
            default => false,
        };
    }

    /**
     * Get alert priority
     */
    public function getAlertPriority(): ?string
    {
        return match($this) {
            self::CRITICAL => 'URGENT',
            self::ERROR => 'HIGH',
            self::WARNING => 'MEDIUM',
            default => null,
        };
    }
}

Estimated Time: 30 minutes

Testing Checkpoint 1

Unit Tests:

// tests/Unit/Framework/Exception/FrameworkExceptionTest.php
it('can skip aggregation and reporting', function () {
    $exception = FrameworkException::simple('Test error')
        ->skipAggregation()
        ->skipReporting();

    expect($exception->shouldSkipAggregation())->toBeTrue();
    expect($exception->shouldSkipReporting())->toBeTrue();
    expect($exception->wasReported())->toBeFalse();
});

it('can be marked as reported', function () {
    $exception = FrameworkException::simple('Test error');

    expect($exception->wasReported())->toBeFalse();

    $exception->markAsReported();

    expect($exception->wasReported())->toBeTrue();
});

it('gets severity from error code', function () {
    $exception = FrameworkException::create(
        ErrorCode::DB_CONNECTION_FAILED,
        'Database connection lost'
    );

    expect($exception->getSeverity())->toBe(ErrorSeverity::CRITICAL);
});

it('supports request context', function () {
    $exception = FrameworkException::simple('Test error')
        ->withRequestContext(
            requestId: 'req_123',
            requestMethod: 'POST',
            requestUri: '/api/users',
            clientIp: '127.0.0.1',
            userAgent: 'Mozilla/5.0',
            userId: 'user_456'
        );

    $context = $exception->getContext();

    expect($context->requestId)->toBe('req_123');
    expect($context->requestMethod)->toBe('POST');
    expect($context->clientIp)->toBe('127.0.0.1');
});

it('supports system context', function () {
    $exception = FrameworkException::simple('Test error')
        ->withSystemContext(
            environment: 'production',
            hostname: 'web-01',
            memoryUsage: 128.5,
            cpuUsage: 45.2,
            phpVersion: '8.3.0'
        );

    $context = $exception->getContext();

    expect($context->environment)->toBe('production');
    expect($context->hostname)->toBe('web-01');
    expect($context->memoryUsage)->toBe(128.5);
});

// tests/Unit/Framework/Exception/Core/ErrorSeverityTest.php
it('returns correct retention days', function () {
    expect(ErrorSeverity::CRITICAL->getRetentionDays())->toBe(180);
    expect(ErrorSeverity::ERROR->getRetentionDays())->toBe(90);
    expect(ErrorSeverity::WARNING->getRetentionDays())->toBe(30);
    expect(ErrorSeverity::INFO->getRetentionDays())->toBe(14);
    expect(ErrorSeverity::DEBUG->getRetentionDays())->toBe(7);
});

it('determines alert requirements', function () {
    expect(ErrorSeverity::CRITICAL->shouldAlert())->toBeTrue();
    expect(ErrorSeverity::ERROR->shouldAlert())->toBeTrue();
    expect(ErrorSeverity::WARNING->shouldAlert())->toBeFalse();
    expect(ErrorSeverity::INFO->shouldAlert())->toBeFalse();
});

// tests/Unit/Framework/Exception/ErrorCodeTest.php
it('maps error codes to severities correctly', function () {
    expect(ErrorCode::DB_CONNECTION_FAILED->getSeverity())->toBe(ErrorSeverity::CRITICAL);
    expect(ErrorCode::DB_QUERY_FAILED->getSeverity())->toBe(ErrorSeverity::ERROR);
    expect(ErrorCode::DB_SLOW_QUERY->getSeverity())->toBe(ErrorSeverity::WARNING);
    expect(ErrorCode::AUTH_LOGOUT_SUCCESS->getSeverity())->toBe(ErrorSeverity::INFO);
    expect(ErrorCode::SYS_DEBUG_MESSAGE->getSeverity())->toBe(ErrorSeverity::DEBUG);
});

Acceptance Criteria:

  • All unit tests pass (≥95% coverage for Phase 1 code)
  • No breaking changes to existing exception usage
  • Performance: Exception creation <1ms

Estimated Time: 1 hour testing + fixes

Phase 1 Total Time: 7 hours (≈1 day)

Rollback Plan Phase 1

If critical issues found:

  1. Revert FrameworkException.php changes
  2. Revert ExceptionContext.php changes
  3. Revert ErrorCode.php changes
  4. Remove ErrorSeverity.php
  5. Run full test suite to verify rollback

Phase 2: Handler Integration (Day 1, Afternoon)

Goal: Create integrated ErrorHandler with auto-triggering for aggregation and reporting

Tasks

2.1 Create New Module Structure

Directory: src/Framework/Exception/Handler/

New Files:

  • ErrorHandler.php - Main handler with auto-integration
  • ErrorLogger.php - Unified logging
  • ResponseFactory.php - HTTP response generation
  • ContextBuilder.php - Automatic context building

2.2 Implement ErrorHandler

File: src/Framework/Exception/Handler/ErrorHandler.php

Implementation:

<?php

declare(strict_types=1);

namespace App\Framework\Exception\Handler;

use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\Core\ErrorSeverity;
use App\Framework\Exception\ErrorCode;
use App\Framework\ErrorAggregation\ErrorAggregator;
use App\Framework\ErrorReporting\ErrorReporter;
use App\Framework\Queue\Queue;
use App\Framework\Queue\ValueObjects\JobPayload;
use Psr\Log\LoggerInterface;
use Throwable;

final readonly class ErrorHandler
{
    public function __construct(
        private ErrorLogger $logger,
        private ResponseFactory $responseFactory,
        private ContextBuilder $contextBuilder,
        private ErrorAggregator $aggregator,
        private ErrorReporter $reporter,
        private Queue $queue,
        private bool $enableAggregation = true,
        private bool $enableReporting = true,
        private bool $asyncProcessing = true,
    ) {}

    /**
     * Register global error handlers
     */
    public function register(): void
    {
        set_exception_handler([$this, 'handleException']);
        set_error_handler([$this, 'handleError']);
        register_shutdown_function([$this, 'handleShutdown']);
    }

    /**
     * Handle uncaught exceptions
     */
    public function handleException(Throwable $exception): void
    {
        // 1. Enrich if FrameworkException
        if ($exception instanceof FrameworkException) {
            $exception = $this->enrichFrameworkException($exception);
        } else {
            // Convert to FrameworkException
            $exception = $this->convertToFrameworkException($exception);
        }

        // 2. Log exception
        $this->logger->logException($exception);

        // 3. Trigger aggregation (async if enabled)
        if ($this->shouldAggregate($exception)) {
            $this->triggerAggregation($exception);
        }

        // 4. Trigger reporting (async if enabled)
        if ($this->shouldReport($exception)) {
            $this->triggerReporting($exception);
        }

        // 5. Send HTTP response
        $response = $this->responseFactory->createErrorResponse($exception);
        $response->send();

        exit(1);
    }

    /**
     * Handle PHP errors (convert to exceptions)
     */
    public function handleError(
        int $severity,
        string $message,
        string $file,
        int $line
    ): bool {
        // Convert PHP error to exception
        $exception = new \ErrorException($message, 0, $severity, $file, $line);
        $frameworkException = $this->convertToFrameworkException($exception);

        $this->handleException($frameworkException);

        return true; // Don't execute PHP internal error handler
    }

    /**
     * Handle fatal errors on shutdown
     */
    public function handleShutdown(): void
    {
        $error = error_get_last();

        if ($error !== null && $this->isFatalError($error['type'])) {
            $exception = new \ErrorException(
                $error['message'],
                0,
                $error['type'],
                $error['file'],
                $error['line']
            );

            $this->handleException($exception);
        }
    }

    /**
     * Enrich FrameworkException with request and system context
     */
    private function enrichFrameworkException(FrameworkException $exception): FrameworkException
    {
        // Add request context if available
        if ($this->contextBuilder->hasRequestContext()) {
            $requestContext = $this->contextBuilder->buildRequestContext();
            $exception = $exception->withRequestContext(
                requestId: $requestContext['request_id'] ?? null,
                requestMethod: $requestContext['method'] ?? null,
                requestUri: $requestContext['uri'] ?? null,
                clientIp: $requestContext['client_ip'] ?? null,
                userAgent: $requestContext['user_agent'] ?? null,
                userId: $requestContext['user_id'] ?? null
            );
        }

        // Add system context
        $systemContext = $this->contextBuilder->buildSystemContext();
        $exception = $exception->withSystemContext(
            environment: $systemContext['environment'] ?? null,
            hostname: $systemContext['hostname'] ?? null,
            memoryUsage: $systemContext['memory_usage'] ?? null,
            cpuUsage: $systemContext['cpu_usage'] ?? null,
            phpVersion: $systemContext['php_version'] ?? null
        );

        return $exception;
    }

    /**
     * Convert generic Throwable to FrameworkException
     */
    private function convertToFrameworkException(Throwable $throwable): FrameworkException
    {
        // Map to appropriate ErrorCode
        $errorCode = $this->mapToErrorCode($throwable);

        // Create context from throwable
        $context = $this->contextBuilder->buildFromThrowable($throwable);

        // Create FrameworkException
        $frameworkException = FrameworkException::fromContext(
            $throwable->getMessage(),
            $context,
            $errorCode
        );

        // Enrich with request and system context
        return $this->enrichFrameworkException($frameworkException);
    }

    /**
     * Check if exception should be aggregated
     */
    private function shouldAggregate(FrameworkException $exception): bool
    {
        return $this->enableAggregation
            && !$exception->shouldSkipAggregation();
    }

    /**
     * Check if exception should be reported
     */
    private function shouldReport(FrameworkException $exception): bool
    {
        return $this->enableReporting
            && !$exception->shouldSkipReporting()
            && !$exception->wasReported();
    }

    /**
     * Trigger error aggregation (async via queue)
     */
    private function triggerAggregation(FrameworkException $exception): void
    {
        if ($this->asyncProcessing) {
            // Queue aggregation job
            $job = new AggregateErrorJob($exception);
            $payload = JobPayload::immediate($job);
            $this->queue->push($payload);
        } else {
            // Synchronous aggregation
            $errorEvent = $this->convertToErrorEvent($exception);
            $this->aggregator->processErrorEvent($errorEvent);
        }
    }

    /**
     * Trigger error reporting (async via queue)
     */
    private function triggerReporting(FrameworkException $exception): void
    {
        if ($this->asyncProcessing) {
            // Queue reporting job
            $job = new ReportErrorJob($exception);
            $payload = JobPayload::immediate($job);
            $this->queue->push($payload);
        } else {
            // Synchronous reporting
            $errorReport = $this->convertToErrorReport($exception);
            $this->reporter->report($errorReport);
            $exception->markAsReported();
        }
    }

    /**
     * Map Throwable to ErrorCode
     */
    private function mapToErrorCode(Throwable $throwable): ErrorCode
    {
        return match(true) {
            $throwable instanceof \PDOException => ErrorCode::DB_QUERY_FAILED,
            $throwable instanceof \RuntimeException => ErrorCode::SYS_RUNTIME_ERROR,
            $throwable instanceof \LogicException => ErrorCode::SYS_LOGIC_ERROR,
            $throwable instanceof \InvalidArgumentException => ErrorCode::VAL_INPUT_INVALID,
            default => ErrorCode::SYS_UNHANDLED_ERROR,
        };
    }

    /**
     * Check if error is fatal
     */
    private function isFatalError(int $type): bool
    {
        return in_array($type, [
            E_ERROR,
            E_PARSE,
            E_CORE_ERROR,
            E_COMPILE_ERROR,
            E_USER_ERROR,
        ], true);
    }

    /**
     * Convert FrameworkException to ErrorEvent for aggregation
     */
    private function convertToErrorEvent(FrameworkException $exception): ErrorEvent
    {
        $context = $exception->getContext();

        return new ErrorEvent(
            service: $_ENV['APP_NAME'] ?? 'unknown',
            component: $context->component ?? 'unknown',
            operation: $context->operation ?? 'unknown',
            errorCode: $exception->getErrorCode() ?? ErrorCode::SYS_UNHANDLED_ERROR,
            errorMessage: $exception->getMessage(),
            severity: $exception->getSeverity(),
            occurredAt: new \DateTimeImmutable(),
            context: $context->toArray(),
            metadata: $context->metadata,
            requestId: $context->requestId,
            userId: $context->userId,
            clientIp: $context->clientIp,
            userAgent: $context->userAgent,
            isSecurityEvent: $this->isSecurityEvent($exception),
            stackTrace: $exception->getTraceAsString()
        );
    }

    /**
     * Convert FrameworkException to ErrorReport for reporting
     */
    private function convertToErrorReport(FrameworkException $exception): ErrorReport
    {
        $context = $exception->getContext();

        return ErrorReport::fromException(
            exception: $exception,
            level: $this->getReportLevel($exception),
            environment: [
                'php_version' => $context->phpVersion ?? PHP_VERSION,
                'os' => PHP_OS,
                'hostname' => $context->hostname ?? gethostname(),
                'memory_usage' => $context->memoryUsage ?? memory_get_usage(true),
                'cpu_usage' => $context->cpuUsage,
            ]
        );
    }

    /**
     * Determine if exception is a security event
     */
    private function isSecurityEvent(FrameworkException $exception): bool
    {
        $errorCode = $exception->getErrorCode();

        if ($errorCode === null) {
            return false;
        }

        return $errorCode->getCategory() === 'SEC';
    }

    /**
     * Get report level from severity
     */
    private function getReportLevel(FrameworkException $exception): string
    {
        return match($exception->getSeverity()) {
            ErrorSeverity::CRITICAL => 'critical',
            ErrorSeverity::ERROR => 'error',
            ErrorSeverity::WARNING => 'warning',
            ErrorSeverity::INFO => 'info',
            ErrorSeverity::DEBUG => 'debug',
        };
    }
}

Estimated Time: 3 hours

2.3 Implement ContextBuilder

File: src/Framework/Exception/Handler/ContextBuilder.php

Implementation:

<?php

declare(strict_types=1);

namespace App\Framework\Exception\Handler;

use App\Framework\Exception\ExceptionContext;
use App\Framework\Http\HttpRequest;
use Throwable;

final readonly class ContextBuilder
{
    public function __construct(
        private ?HttpRequest $request = null
    ) {}

    /**
     * Check if request context is available
     */
    public function hasRequestContext(): bool
    {
        return $this->request !== null;
    }

    /**
     * Build request context from current HTTP request
     */
    public function buildRequestContext(): array
    {
        if ($this->request === null) {
            return [];
        }

        return [
            'request_id' => $this->request->id ?? null,
            'method' => $this->request->method->value ?? null,
            'uri' => (string) $this->request->uri ?? null,
            'client_ip' => $this->request->server->getRemoteAddr() ?? null,
            'user_agent' => $this->request->server->getUserAgent()?->toString() ?? null,
            'user_id' => $this->request->user?->id ?? null,
        ];
    }

    /**
     * Build system context
     */
    public function buildSystemContext(): array
    {
        return [
            'environment' => $_ENV['APP_ENV'] ?? 'production',
            'hostname' => gethostname() ?: 'unknown',
            'memory_usage' => memory_get_usage(true) / 1024 / 1024, // MB
            'cpu_usage' => $this->getCpuUsage(),
            'php_version' => PHP_VERSION,
        ];
    }

    /**
     * Build ExceptionContext from Throwable
     */
    public function buildFromThrowable(Throwable $throwable): ExceptionContext
    {
        return ExceptionContext::empty()
            ->withOperation('exception_handler')
            ->withComponent(get_class($throwable))
            ->withData([
                'message' => $throwable->getMessage(),
                'code' => $throwable->getCode(),
                'file' => $throwable->getFile(),
                'line' => $throwable->getLine(),
            ])
            ->withDebug([
                'trace' => $throwable->getTraceAsString(),
                'previous' => $throwable->getPrevious()?->getMessage(),
            ]);
    }

    /**
     * Get CPU usage (simplified)
     */
    private function getCpuUsage(): ?float
    {
        if (function_exists('sys_getloadavg')) {
            $load = sys_getloadavg();
            return $load[0] ?? null;
        }

        return null;
    }
}

Estimated Time: 1 hour

2.4 Implement ErrorLogger

File: src/Framework/Exception/Handler/ErrorLogger.php

Implementation:

<?php

declare(strict_types=1);

namespace App\Framework\Exception\Handler;

use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\Core\ErrorSeverity;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;

final readonly class ErrorLogger
{
    public function __construct(
        private LoggerInterface $logger
    ) {}

    /**
     * Log exception with appropriate log level
     */
    public function logException(FrameworkException $exception): void
    {
        $severity = $exception->getSeverity();
        $logLevel = $this->getLogLevel($severity);

        $this->logger->log($logLevel, $exception->getMessage(), [
            'exception' => get_class($exception),
            'error_code' => $exception->getErrorCode()?->value,
            'severity' => $severity->value,
            'context' => $exception->getContext()->toArray(),
            'trace' => $exception->getTraceAsString(),
        ]);
    }

    /**
     * Map ErrorSeverity to PSR-3 LogLevel
     */
    private function getLogLevel(ErrorSeverity $severity): string
    {
        return match($severity) {
            ErrorSeverity::CRITICAL => LogLevel::CRITICAL,
            ErrorSeverity::ERROR => LogLevel::ERROR,
            ErrorSeverity::WARNING => LogLevel::WARNING,
            ErrorSeverity::INFO => LogLevel::INFO,
            ErrorSeverity::DEBUG => LogLevel::DEBUG,
        };
    }
}

Estimated Time: 30 minutes

2.5 Implement ResponseFactory

File: src/Framework/Exception/Handler/ResponseFactory.php

Implementation:

<?php

declare(strict_types=1);

namespace App\Framework\Exception\Handler;

use App\Framework\Exception\FrameworkException;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Status;

final readonly class ResponseFactory
{
    public function __construct(
        private bool $debugMode = false
    ) {}

    /**
     * Create HTTP error response from exception
     */
    public function createErrorResponse(FrameworkException $exception): HttpResponse
    {
        $statusCode = $this->getStatusCode($exception);

        $body = [
            'error' => true,
            'message' => $this->debugMode
                ? $exception->getMessage()
                : $this->getPublicMessage($exception),
            'code' => $exception->getErrorCode()?->value,
        ];

        if ($this->debugMode) {
            $body['debug'] = [
                'exception' => get_class($exception),
                'file' => $exception->getFile(),
                'line' => $exception->getLine(),
                'trace' => $exception->getTrace(),
            ];
        }

        return new HttpResponse(
            status: $statusCode,
            body: json_encode($body),
            headers: ['Content-Type' => 'application/json']
        );
    }

    /**
     * Get HTTP status code from exception
     */
    private function getStatusCode(FrameworkException $exception): Status
    {
        $errorCode = $exception->getErrorCode();

        if ($errorCode === null) {
            return Status::INTERNAL_SERVER_ERROR;
        }

        // Map error codes to HTTP status codes
        return match($errorCode->getCategory()) {
            'AUTH' => Status::UNAUTHORIZED,
            'AUTHZ' => Status::FORBIDDEN,
            'VAL' => Status::UNPROCESSABLE_ENTITY,
            'HTTP' => $this->getHttpStatusFromCode($errorCode),
            default => Status::INTERNAL_SERVER_ERROR,
        };
    }

    /**
     * Get public-facing error message
     */
    private function getPublicMessage(FrameworkException $exception): string
    {
        $errorCode = $exception->getErrorCode();

        return match($errorCode?->getCategory()) {
            'AUTH' => 'Authentication failed',
            'AUTHZ' => 'Access denied',
            'VAL' => 'Validation failed',
            'HTTP' => 'Request error',
            default => 'An error occurred',
        };
    }

    /**
     * Get HTTP status from error code
     */
    private function getHttpStatusFromCode(ErrorCode $errorCode): Status
    {
        return match($errorCode) {
            ErrorCode::HTTP_NOT_FOUND => Status::NOT_FOUND,
            ErrorCode::HTTP_METHOD_NOT_ALLOWED => Status::METHOD_NOT_ALLOWED,
            ErrorCode::HTTP_RATE_LIMIT_EXCEEDED => Status::TOO_MANY_REQUESTS,
            default => Status::BAD_REQUEST,
        };
    }
}

Estimated Time: 1 hour

2.6 Create Queue Jobs

Files:

  • src/Framework/Exception/Handler/Jobs/AggregateErrorJob.php
  • src/Framework/Exception/Handler/Jobs/ReportErrorJob.php

Implementation (AggregateErrorJob):

<?php

declare(strict_types=1);

namespace App\Framework\Exception\Handler\Jobs;

use App\Framework\Exception\FrameworkException;
use App\Framework\ErrorAggregation\ErrorAggregator;
use App\Framework\ErrorAggregation\ErrorEvent;

final readonly class AggregateErrorJob
{
    public function __construct(
        private FrameworkException $exception
    ) {}

    public function handle(ErrorAggregator $aggregator): void
    {
        $errorEvent = $this->convertToErrorEvent();
        $aggregator->processErrorEvent($errorEvent);
    }

    private function convertToErrorEvent(): ErrorEvent
    {
        $context = $this->exception->getContext();

        return new ErrorEvent(
            service: $_ENV['APP_NAME'] ?? 'unknown',
            component: $context->component ?? 'unknown',
            operation: $context->operation ?? 'unknown',
            errorCode: $this->exception->getErrorCode() ?? ErrorCode::SYS_UNHANDLED_ERROR,
            errorMessage: $this->exception->getMessage(),
            severity: $this->exception->getSeverity(),
            occurredAt: new \DateTimeImmutable(),
            context: $context->toArray(),
            metadata: $context->metadata,
            requestId: $context->requestId,
            userId: $context->userId,
            clientIp: $context->clientIp,
            userAgent: $context->userAgent,
            isSecurityEvent: $this->isSecurityEvent(),
            stackTrace: $this->exception->getTraceAsString()
        );
    }

    private function isSecurityEvent(): bool
    {
        $errorCode = $this->exception->getErrorCode();
        return $errorCode?->getCategory() === 'SEC';
    }
}

Estimated Time: 1 hour for both jobs

Testing Checkpoint 2

Integration Tests:

// tests/Feature/Framework/Exception/ErrorHandlerTest.php
it('handles exceptions and triggers aggregation', function () {
    $aggregator = Mockery::mock(ErrorAggregator::class);
    $aggregator->shouldReceive('processErrorEvent')->once();

    $handler = new ErrorHandler(
        logger: $this->logger,
        responseFactory: $this->responseFactory,
        contextBuilder: $this->contextBuilder,
        aggregator: $aggregator,
        reporter: $this->reporter,
        queue: $this->queue,
        enableAggregation: true,
        asyncProcessing: false // Sync for testing
    );

    $exception = FrameworkException::create(
        ErrorCode::DB_QUERY_FAILED,
        'Test error'
    );

    $handler->handleException($exception);

    expect($exception->wasReported())->toBeFalse(); // Not reported yet
});

it('skips aggregation when flag is set', function () {
    $aggregator = Mockery::mock(ErrorAggregator::class);
    $aggregator->shouldNotReceive('processErrorEvent');

    $exception = FrameworkException::simple('Test')
        ->skipAggregation();

    $handler->handleException($exception);
});

it('queues aggregation job when async enabled', function () {
    $queue = Mockery::mock(Queue::class);
    $queue->shouldReceive('push')
        ->once()
        ->with(Mockery::type(JobPayload::class));

    $handler = new ErrorHandler(
        // ... other dependencies
        queue: $queue,
        asyncProcessing: true
    );

    $exception = FrameworkException::simple('Test');

    $handler->handleException($exception);
});

it('enriches exceptions with request context', function () {
    $request = new HttpRequest(/* ... */);
    $contextBuilder = new ContextBuilder($request);

    $handler = new ErrorHandler(
        contextBuilder: $contextBuilder,
        // ... other dependencies
    );

    $exception = FrameworkException::simple('Test');

    // Handler enriches exception internally
    $handler->handleException($exception);

    $context = $exception->getContext();
    expect($context->requestId)->not->toBeNull();
    expect($context->requestMethod)->not->toBeNull();
});

it('converts generic exceptions to FrameworkException', function () {
    $handler = new ErrorHandler(/* ... */);

    $genericException = new \RuntimeException('Generic error');

    $handler->handleException($genericException);

    // Should be logged as FrameworkException with SYS_RUNTIME_ERROR
    // (Check logger mock expectations)
});

Acceptance Criteria:

  • All integration tests pass
  • Aggregation triggered correctly (sync and async)
  • Reporting triggered correctly
  • Context enrichment working
  • Generic exceptions converted properly
  • Performance: Exception handling <5ms (excluding async queue)

Estimated Time: 2 hours testing + fixes

Phase 2 Total Time: 8.5 hours (≈1 day)

Rollback Plan Phase 2

If critical issues found:

  1. Disable ErrorHandler registration ($handler->register())
  2. Revert to old ErrorHandling/ system temporarily
  3. Remove Handler/ directory
  4. Remove queue jobs
  5. Run integration tests to verify old system works

Phase 3: Aggregation Integration (Day 2, Morning)

Goal: Move ErrorAggregation under Exception/Aggregation/ and integrate with new handler

Tasks

3.1 Move ErrorAggregation Module

From: src/Framework/ErrorAggregation/ To: src/Framework/Exception/Aggregation/

Files to Move:

  • ErrorAggregator.phpsrc/Framework/Exception/Aggregation/ErrorAggregator.php
  • ErrorEvent.phpsrc/Framework/Exception/Aggregation/ErrorEvent.php
  • ErrorPattern.phpsrc/Framework/Exception/Aggregation/ErrorPattern.php

Namespace Changes:

// Old: namespace App\Framework\ErrorAggregation;
// New: namespace App\Framework\Exception\Aggregation;

Estimated Time: 1 hour (move + namespace updates + test fixes)

3.2 Update ErrorAggregator Integration

File: src/Framework/Exception/Aggregation/ErrorAggregator.php

Changes:

// Update to use new ErrorSeverity enum
use App\Framework\Exception\Core\ErrorSeverity;

// Update processErrorEvent to handle new severity
public function processErrorEvent(ErrorEvent $event): void
{
    $this->storeEvent($event);

    $pattern = $this->updateErrorPattern($event);

    // Check for critical pattern based on severity
    if ($pattern->isCriticalPattern()) {
        $this->queueAlert($pattern);
    }

    // Cleanup based on severity retention
    $this->cleanupExpiredEvents($event->severity);
}

// Add severity-based cleanup
private function cleanupExpiredEvents(ErrorSeverity $severity): void
{
    $retentionDays = $severity->getRetentionDays();
    $cutoffDate = new \DateTimeImmutable("-{$retentionDays} days");

    $this->storage->deleteEventsBefore($cutoffDate, $severity);
}

Estimated Time: 1.5 hours

3.3 Update ErrorEvent Value Object

File: src/Framework/Exception/Aggregation/ErrorEvent.php

Changes:

use App\Framework\Exception\Core\ErrorSeverity;

final readonly class ErrorEvent
{
    public function __construct(
        // ... existing properties ...
        public ErrorSeverity $severity,  // Changed from string to enum
        // ... rest of properties ...
    ) {}

    // Add factory method from FrameworkException
    public static function fromFrameworkException(FrameworkException $exception): self
    {
        $context = $exception->getContext();

        return new self(
            service: $_ENV['APP_NAME'] ?? 'unknown',
            component: $context->component ?? 'unknown',
            operation: $context->operation ?? 'unknown',
            errorCode: $exception->getErrorCode() ?? ErrorCode::SYS_UNHANDLED_ERROR,
            errorMessage: $exception->getMessage(),
            severity: $exception->getSeverity(),  // Use FrameworkException method
            occurredAt: new \DateTimeImmutable(),
            context: $context->toArray(),
            metadata: $context->metadata,
            requestId: $context->requestId,
            userId: $context->userId,
            clientIp: $context->clientIp,
            userAgent: $context->userAgent,
            isSecurityEvent: $exception->getErrorCode()?->getCategory() === 'SEC',
            stackTrace: $exception->getTraceAsString()
        );
    }
}

Estimated Time: 1 hour

3.4 Update ErrorPattern

File: src/Framework/Exception/Aggregation/ErrorPattern.php

Changes:

use App\Framework\Exception\Core\ErrorSeverity;

final readonly class ErrorPattern
{
    public function __construct(
        // ... existing properties ...
        public ErrorSeverity $severity,  // Changed from string to enum
        // ... rest of properties ...
    ) {}

    // Update alert threshold to use enum
    public function getAlertThreshold(): int
    {
        return match ($this->severity) {
            ErrorSeverity::CRITICAL => 1,
            ErrorSeverity::ERROR => 5,
            ErrorSeverity::WARNING => 20,
            ErrorSeverity::INFO => 100,
            ErrorSeverity::DEBUG => 500,
        };
    }

    // Update critical pattern detection
    public function isCriticalPattern(): bool
    {
        if ($this->getFrequency() > 10) { return true; }
        if (count($this->affectedUsers) > 50) { return true; }
        if ($this->severity === ErrorSeverity::CRITICAL && $this->occurrenceCount >= 3) {
            return true;
        }
        if ($this->severity === ErrorSeverity::ERROR && $this->occurrenceCount >= 100) {
            return true;
        }
        return false;
    }
}

Estimated Time: 1 hour

3.5 Create Backward Compatibility Adapter

File: src/Framework/ErrorAggregation/ErrorAggregatorAdapter.php

Purpose: Temporary adapter for old code still using ErrorHandlerContext

Implementation:

<?php

declare(strict_types=1);

namespace App\Framework\ErrorAggregation;

use App\Framework\Exception\Aggregation\ErrorAggregator as NewErrorAggregator;
use App\Framework\Exception\Aggregation\ErrorEvent;
use App\Framework\ErrorHandling\ErrorHandlerContext;

/**
 * Backward compatibility adapter for ErrorAggregator
 *
 * @deprecated Use App\Framework\Exception\Aggregation\ErrorAggregator directly
 */
final readonly class ErrorAggregatorAdapter
{
    public function __construct(
        private NewErrorAggregator $newAggregator
    ) {}

    /**
     * Process error using old ErrorHandlerContext
     *
     * @deprecated Use processErrorEvent() with ErrorEvent instead
     */
    public function processError(ErrorHandlerContext $context): void
    {
        // Convert old context to new ErrorEvent
        $errorEvent = $this->convertToErrorEvent($context);

        // Delegate to new aggregator
        $this->newAggregator->processErrorEvent($errorEvent);
    }

    private function convertToErrorEvent(ErrorHandlerContext $context): ErrorEvent
    {
        // Conversion logic from old to new format
        return new ErrorEvent(
            service: $context->service ?? 'unknown',
            component: $context->component ?? 'unknown',
            operation: $context->operation ?? 'unknown',
            errorCode: $context->errorCode,
            errorMessage: $context->errorMessage,
            severity: $this->mapSeverity($context->severity),
            occurredAt: $context->occurredAt,
            context: $context->toArray(),
            metadata: $context->metadata ?? [],
            requestId: $context->requestId,
            userId: $context->userId,
            clientIp: $context->clientIp,
            userAgent: $context->userAgent,
            isSecurityEvent: $context->isSecurityEvent ?? false,
            stackTrace: $context->stackTrace ?? ''
        );
    }

    private function mapSeverity(string $oldSeverity): ErrorSeverity
    {
        return match(strtolower($oldSeverity)) {
            'critical' => ErrorSeverity::CRITICAL,
            'error' => ErrorSeverity::ERROR,
            'warning' => ErrorSeverity::WARNING,
            'info' => ErrorSeverity::INFO,
            'debug' => ErrorSeverity::DEBUG,
            default => ErrorSeverity::ERROR,
        };
    }
}

Estimated Time: 1 hour

Testing Checkpoint 3

Integration Tests:

// tests/Feature/Framework/Exception/Aggregation/ErrorAggregatorIntegrationTest.php
it('processes error events and creates patterns', function () {
    $aggregator = new ErrorAggregator(
        storage: $this->storage,
        cache: $this->cache,
        queue: $this->queue
    );

    $event = ErrorEvent::fromFrameworkException(
        FrameworkException::create(
            ErrorCode::DB_QUERY_FAILED,
            'Test error'
        )
    );

    $aggregator->processErrorEvent($event);

    // Verify pattern created
    $patterns = $this->storage->getActivePatterns();
    expect($patterns)->toHaveCount(1);
    expect($patterns[0]->severity)->toBe(ErrorSeverity::ERROR);
});

it('triggers alerts for critical patterns', function () {
    $queue = Mockery::mock(Queue::class);
    $queue->shouldReceive('push')->once();

    $aggregator = new ErrorAggregator(
        storage: $this->storage,
        cache: $this->cache,
        queue: $queue
    );

    // Create 3 critical errors (threshold for CRITICAL severity)
    for ($i = 0; $i < 3; $i++) {
        $event = ErrorEvent::fromFrameworkException(
            FrameworkException::create(
                ErrorCode::DB_CONNECTION_FAILED,  // CRITICAL severity
                'Connection lost'
            )
        );

        $aggregator->processErrorEvent($event);
    }
});

it('cleans up events based on severity retention', function () {
    $aggregator = new ErrorAggregator(/* ... */);

    // Create old DEBUG event (7 days retention)
    $oldEvent = new ErrorEvent(
        severity: ErrorSeverity::DEBUG,
        occurredAt: new \DateTimeImmutable('-8 days'),
        // ... other properties
    );

    $this->storage->storeEvent($oldEvent);

    // Trigger cleanup
    $aggregator->cleanup();

    // Verify old DEBUG event deleted, but ERROR events retained
    expect($this->storage->getEventCount(ErrorSeverity::DEBUG))->toBe(0);
});

// Backward compatibility test
it('works with old ErrorHandlerContext via adapter', function () {
    $adapter = new ErrorAggregatorAdapter($this->newAggregator);

    $oldContext = new ErrorHandlerContext(
        service: 'test',
        component: 'TestComponent',
        operation: 'test_operation',
        errorCode: ErrorCode::DB_QUERY_FAILED,
        errorMessage: 'Old format error',
        severity: 'error',
        occurredAt: new \DateTimeImmutable()
    );

    $adapter->processError($oldContext);

    // Verify converted to new format and processed
    $patterns = $this->storage->getActivePatterns();
    expect($patterns)->toHaveCount(1);
});

Acceptance Criteria:

  • All aggregation tests pass with new integration
  • Severity-based retention working correctly
  • Pattern detection with new ErrorSeverity enum
  • Backward compatibility adapter functional
  • Performance: Pattern detection <5ms

Estimated Time: 2 hours testing + fixes

Phase 3 Total Time: 7.5 hours (≈1 day)

Rollback Plan Phase 3

If critical issues found:

  1. Move ErrorAggregation/ back to original location
  2. Revert namespace changes
  3. Remove backward compatibility adapter
  4. Revert ErrorHandler integration with aggregation
  5. Run aggregation tests to verify rollback

Phase 4: Reporting Integration (Day 2, Afternoon)

Goal: Move ErrorReporting under Exception/Reporting/ and integrate with new handler

Tasks

4.1 Move ErrorReporting Module

From: src/Framework/ErrorReporting/ To: src/Framework/Exception/Reporting/

Files to Move:

  • ErrorReporter.phpsrc/Framework/Exception/Reporting/ErrorReporter.php
  • ErrorReport.phpsrc/Framework/Exception/Reporting/ErrorReport.php
  • All related files (filters, processors, storage)

Namespace Changes:

// Old: namespace App\Framework\ErrorReporting;
// New: namespace App\Framework\Exception\Reporting;

Estimated Time: 1 hour

4.2 Update ErrorReporter Integration

File: src/Framework/Exception/Reporting/ErrorReporter.php

Changes:

use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\Core\ErrorSeverity;

final readonly class ErrorReporter
{
    // Add factory method for FrameworkException
    public function reportException(FrameworkException $exception): string
    {
        $report = ErrorReport::fromFrameworkException($exception);

        return $this->report($report);
    }

    // Update filtering to use severity
    private function shouldReportBySeverity(ErrorReport $report): bool
    {
        // Only report ERROR and CRITICAL by default
        return in_array($report->severity, [
            ErrorSeverity::CRITICAL,
            ErrorSeverity::ERROR,
        ], true);
    }
}

Estimated Time: 1.5 hours

4.3 Update ErrorReport Value Object

File: src/Framework/Exception/Reporting/ErrorReport.php

Changes:

use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\Core\ErrorSeverity;

final readonly class ErrorReport
{
    public function __construct(
        public string $id,
        public string $message,
        public ErrorSeverity $level,  // Changed from string to enum
        public array $context,
        public array $environment,
        public \DateTimeImmutable $reportedAt,
        public ?string $userId = null,
        public ?string $requestId = null,
    ) {}

    // Add factory method from FrameworkException
    public static function fromFrameworkException(
        FrameworkException $exception
    ): self {
        $context = $exception->getContext();

        return new self(
            id: Ulid::generate(),
            message: $exception->getMessage(),
            level: $exception->getSeverity(),
            context: $context->toArray(),
            environment: [
                'environment' => $context->environment ?? 'production',
                'hostname' => $context->hostname ?? gethostname(),
                'php_version' => $context->phpVersion ?? PHP_VERSION,
                'memory_usage' => $context->memoryUsage,
                'cpu_usage' => $context->cpuUsage,
            ],
            reportedAt: new \DateTimeImmutable(),
            userId: $context->userId,
            requestId: $context->requestId
        );
    }
}

Estimated Time: 1 hour

4.4 Create Backward Compatibility Adapter

File: src/Framework/ErrorReporting/ErrorReporterAdapter.php

Purpose: Temporary adapter for old code

Implementation:

<?php

declare(strict_types=1);

namespace App\Framework\ErrorReporting;

use App\Framework\Exception\Reporting\ErrorReporter as NewErrorReporter;
use App\Framework\Exception\Reporting\ErrorReport;

/**
 * Backward compatibility adapter for ErrorReporter
 *
 * @deprecated Use App\Framework\Exception\Reporting\ErrorReporter directly
 */
final readonly class ErrorReporterAdapter
{
    public function __construct(
        private NewErrorReporter $newReporter
    ) {}

    /**
     * Report error using old format
     *
     * @deprecated Use reportException() with FrameworkException instead
     */
    public function report(array $errorData): string
    {
        // Convert old array format to new ErrorReport
        $report = $this->convertToErrorReport($errorData);

        // Delegate to new reporter
        return $this->newReporter->report($report);
    }

    private function convertToErrorReport(array $errorData): ErrorReport
    {
        return new ErrorReport(
            id: $errorData['id'] ?? Ulid::generate(),
            message: $errorData['message'] ?? 'Unknown error',
            level: $this->mapLevel($errorData['level'] ?? 'error'),
            context: $errorData['context'] ?? [],
            environment: $errorData['environment'] ?? [],
            reportedAt: new \DateTimeImmutable($errorData['reported_at'] ?? 'now'),
            userId: $errorData['user_id'] ?? null,
            requestId: $errorData['request_id'] ?? null
        );
    }

    private function mapLevel(string $oldLevel): ErrorSeverity
    {
        return match(strtolower($oldLevel)) {
            'critical' => ErrorSeverity::CRITICAL,
            'error' => ErrorSeverity::ERROR,
            'warning' => ErrorSeverity::WARNING,
            'info' => ErrorSeverity::INFO,
            'debug' => ErrorSeverity::DEBUG,
            default => ErrorSeverity::ERROR,
        };
    }
}

Estimated Time: 1 hour

Testing Checkpoint 4

Integration Tests:

// tests/Feature/Framework/Exception/Reporting/ErrorReporterIntegrationTest.php
it('reports exceptions and stores reports', function () {
    $reporter = new ErrorReporter(
        storage: $this->storage,
        filters: [],
        processors: []
    );

    $exception = FrameworkException::create(
        ErrorCode::DB_QUERY_FAILED,
        'Test error'
    )->withRequestContext(
        requestId: 'req_123',
        userId: 'user_456'
    );

    $reportId = $reporter->reportException($exception);

    expect($reportId)->not->toBeEmpty();
    expect($exception->wasReported())->toBeTrue();

    // Verify stored in database
    $report = $this->storage->getReport($reportId);
    expect($report->severity)->toBe(ErrorSeverity::ERROR);
    expect($report->userId)->toBe('user_456');
});

it('applies filters before reporting', function () {
    $reporter = new ErrorReporter(
        storage: $this->storage,
        filters: [
            fn(ErrorReport $report) => $report->severity !== ErrorSeverity::DEBUG
        ]
    );

    $debugException = FrameworkException::create(
        ErrorCode::SYS_DEBUG_MESSAGE,
        'Debug info'
    );

    $reportId = $reporter->reportException($debugException);

    // Should be filtered out
    expect($this->storage->getReport($reportId))->toBeNull();
});

it('processes reports before storage', function () {
    $processor = function(ErrorReport $report) {
        // Add custom metadata
        $context = $report->context;
        $context['processed'] = true;

        return new ErrorReport(
            id: $report->id,
            message: $report->message,
            level: $report->level,
            context: $context,
            environment: $report->environment,
            reportedAt: $report->reportedAt,
            userId: $report->userId,
            requestId: $report->requestId
        );
    };

    $reporter = new ErrorReporter(
        storage: $this->storage,
        processors: [$processor]
    );

    $exception = FrameworkException::simple('Test');
    $reportId = $reporter->reportException($exception);

    $report = $this->storage->getReport($reportId);
    expect($report->context['processed'])->toBeTrue();
});

// Backward compatibility test
it('works with old array format via adapter', function () {
    $adapter = new ErrorReporterAdapter($this->newReporter);

    $oldFormat = [
        'message' => 'Old format error',
        'level' => 'error',
        'context' => ['key' => 'value'],
        'environment' => ['env' => 'test'],
    ];

    $reportId = $adapter->report($oldFormat);

    expect($reportId)->not->toBeEmpty();
});

Acceptance Criteria:

  • All reporting tests pass with new integration
  • Severity-based filtering working
  • Report storage with new ErrorSeverity enum
  • Backward compatibility adapter functional
  • Performance: Report creation <10ms

Estimated Time: 2 hours testing + fixes

Phase 4 Total Time: 6.5 hours (≈1 day)

Rollback Plan Phase 4

If critical issues found:

  1. Move ErrorReporting/ back to original location
  2. Revert namespace changes
  3. Remove backward compatibility adapter
  4. Revert ErrorHandler integration with reporting
  5. Run reporting tests to verify rollback

Phase 5: Boundaries Integration (Day 3, Morning)

Goal: Move ErrorBoundaries under Exception/Boundaries/ and integrate with FrameworkException

Tasks

5.1 Move ErrorBoundaries Module

From: src/Framework/ErrorBoundaries/ To: src/Framework/Exception/Boundaries/

Files to Move:

  • ErrorBoundary.phpsrc/Framework/Exception/Boundaries/ErrorBoundary.php
  • BoundaryException.phpsrc/Framework/Exception/Boundaries/BoundaryException.php
  • All related files (config, events, results)

Namespace Changes:

// Old: namespace App\Framework\ErrorBoundaries;
// New: namespace App\Framework\Exception\Boundaries;

Estimated Time: 1 hour

5.2 Update BoundaryException to Extend FrameworkException

File: src/Framework/Exception/Boundaries/BoundaryException.php

Changes:

<?php

declare(strict_types=1);

namespace App\Framework\Exception\Boundaries;

use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;

/**
 * Exception thrown by ErrorBoundary when operation fails
 *
 * Extends FrameworkException to inherit:
 * - ErrorCode categorization
 * - ExceptionContext (domain, request, system)
 * - Automatic aggregation/reporting integration
 * - Retry logic
 */
final class BoundaryException extends FrameworkException
{
    public static function operationFailed(
        string $operation,
        \Throwable $previous,
        int $attemptsMade
    ): self {
        $context = ExceptionContext::empty()
            ->withOperation($operation)
            ->withComponent('ErrorBoundary')
            ->withMetadata([
                'attempts_made' => $attemptsMade,
                'original_exception' => get_class($previous),
            ]);

        return self::fromContext(
            "Operation '{$operation}' failed after {$attemptsMade} attempts: {$previous->getMessage()}",
            $context,
            ErrorCode::SYS_OPERATION_FAILED,
            $previous
        );
    }

    public static function circuitBreakerOpen(string $operation, int $failures): self
    {
        $context = ExceptionContext::empty()
            ->withOperation($operation)
            ->withComponent('CircuitBreaker')
            ->withData([
                'recent_failures' => $failures,
                'state' => 'OPEN',
            ]);

        return self::fromContext(
            "Circuit breaker open for '{$operation}' due to {$failures} failures",
            $context,
            ErrorCode::SYS_CIRCUIT_BREAKER_OPEN
        );
    }

    public static function timeoutExceeded(string $operation, float $timeoutSeconds): self
    {
        $context = ExceptionContext::empty()
            ->withOperation($operation)
            ->withComponent('ErrorBoundary')
            ->withData([
                'timeout_seconds' => $timeoutSeconds,
            ]);

        return self::fromContext(
            "Operation '{$operation}' exceeded timeout of {$timeoutSeconds}s",
            $context,
            ErrorCode::PERF_TIMEOUT
        );
    }
}

Estimated Time: 2 hours

5.3 Update ErrorBoundary to Use FrameworkException

File: src/Framework/Exception/Boundaries/ErrorBoundary.php

Changes:

use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ErrorCode;

final readonly class ErrorBoundary
{
    // Update handleFailure to enrich exception
    private function handleFailure(Throwable $e, callable $fallback): mixed
    {
        // Convert to FrameworkException if not already
        if (!$e instanceof FrameworkException) {
            $e = $this->convertToFrameworkException($e);
        }

        // Enrich with boundary context
        $e = $e->withData([
            'boundary_name' => $this->config->name,
            'max_retries' => $this->config->maxRetries,
            'retry_strategy' => $this->config->retryStrategy->value,
        ]);

        return $fallback($e);
    }

    // Add conversion method
    private function convertToFrameworkException(Throwable $throwable): FrameworkException
    {
        // Wrap in BoundaryException
        return BoundaryException::operationFailed(
            operation: $this->config->name,
            previous: $throwable,
            attemptsMade: $this->attemptsMade
        );
    }

    // Update shouldRetry to check non-retryable error codes
    private function shouldRetry(Throwable $e): bool
    {
        // Don't retry FrameworkExceptions with non-retryable error codes
        if ($e instanceof FrameworkException) {
            $errorCode = $e->getErrorCode();

            if ($errorCode && !$errorCode->isRecoverable()) {
                return false;
            }
        }

        // Original logic for non-retryable exception types
        $nonRetryableTypes = [
            \InvalidArgumentException::class,
            \LogicException::class,
        ];

        foreach ($nonRetryableTypes as $type) {
            if ($e instanceof $type) {
                return false;
            }
        }

        return true;
    }
}

Estimated Time: 2 hours

5.4 Add ErrorCode Cases for Boundaries

File: src/Framework/Exception/ErrorCode.php

New Cases:

enum ErrorCode: string
{
    // ... existing cases ...

    // Boundary/Circuit Breaker Errors (BOUND category)
    case BOUND_OPERATION_FAILED = 'BOUND001';
    case BOUND_CIRCUIT_BREAKER_OPEN = 'BOUND002';
    case BOUND_TIMEOUT_EXCEEDED = 'BOUND003';
    case BOUND_RETRY_EXHAUSTED = 'BOUND004';
    case BOUND_FALLBACK_FAILED = 'BOUND005';

    // Update getSeverity() to include BOUND category
    public function getSeverity(): ErrorSeverity
    {
        return match($this) {
            // ... existing mappings ...

            // Boundary errors - ERROR severity
            self::BOUND_OPERATION_FAILED,
            self::BOUND_RETRY_EXHAUSTED,
            self::BOUND_FALLBACK_FAILED => ErrorSeverity::ERROR,

            // Circuit breaker - WARNING severity
            self::BOUND_CIRCUIT_BREAKER_OPEN => ErrorSeverity::WARNING,

            // Timeout - WARNING severity
            self::BOUND_TIMEOUT_EXCEEDED => ErrorSeverity::WARNING,

            default => ErrorSeverity::ERROR,
        };
    }

    // Update getRecoveryHint()
    public function getRecoveryHint(): ?string
    {
        return match($this) {
            // ... existing hints ...

            self::BOUND_OPERATION_FAILED => 'Check operation implementation and dependencies',
            self::BOUND_CIRCUIT_BREAKER_OPEN => 'Wait for circuit breaker to close, check downstream service health',
            self::BOUND_TIMEOUT_EXCEEDED => 'Increase timeout or optimize operation performance',
            self::BOUND_RETRY_EXHAUSTED => 'Check operation logs and fix underlying issue',
            self::BOUND_FALLBACK_FAILED => 'Verify fallback implementation',

            default => null,
        };
    }
}

Estimated Time: 1 hour

Testing Checkpoint 5

Integration Tests:

// tests/Feature/Framework/Exception/Boundaries/ErrorBoundaryIntegrationTest.php
it('wraps exceptions in BoundaryException with FrameworkException features', function () {
    $boundary = new ErrorBoundary(
        config: new BoundaryConfig(
            name: 'test-operation',
            maxRetries: 3
        )
    );

    $result = $boundary->executeForResult(function() {
        throw new \RuntimeException('Operation failed');
    });

    expect($result->success)->toBeFalse();
    expect($result->exception)->toBeInstanceOf(BoundaryException::class);
    expect($result->exception)->toBeInstanceOf(FrameworkException::class);

    // Verify exception has ErrorCode
    expect($result->exception->getErrorCode())->toBe(ErrorCode::BOUND_OPERATION_FAILED);

    // Verify exception has context
    $context = $result->exception->getContext();
    expect($context->operation)->toBe('test-operation');
    expect($context->component)->toBe('ErrorBoundary');
});

it('does not retry non-retryable error codes', function () {
    $attempts = 0;

    $boundary = new ErrorBoundary(
        config: new BoundaryConfig(maxRetries: 3)
    );

    $result = $boundary->executeForResult(function() use (&$attempts) {
        $attempts++;

        // Throw FrameworkException with non-retryable error code
        throw FrameworkException::create(
            ErrorCode::VAL_INPUT_INVALID,  // Validation errors are non-retryable
            'Invalid input'
        );
    });

    // Should not retry validation errors
    expect($attempts)->toBe(1);
    expect($result->success)->toBeFalse();
});

it('integrates with error handler for aggregation and reporting', function () {
    $aggregator = Mockery::mock(ErrorAggregator::class);
    $aggregator->shouldReceive('processErrorEvent')->once();

    $errorHandler = new ErrorHandler(
        aggregator: $aggregator,
        // ... other dependencies
    );

    $boundary = new ErrorBoundary(/* ... */);

    try {
        $boundary->execute(
            operation: fn() => throw new \RuntimeException('Test'),
            fallback: fn() => throw new BoundaryException('Fallback failed')
        );
    } catch (BoundaryException $e) {
        $errorHandler->handleException($e);
    }

    // Verify aggregation triggered (mocked expectation)
});

it('uses circuit breaker with FrameworkException', function () {
    $circuitBreaker = new CircuitBreaker(
        config: new CircuitBreakerConfig(
            failureThreshold: 3,
            timeout: Duration::fromSeconds(30)
        )
    );

    // Trigger 3 failures to open circuit
    for ($i = 0; $i < 3; $i++) {
        try {
            $circuitBreaker->call(fn() => throw new \RuntimeException('Fail'));
        } catch (\Throwable $e) {}
    }

    // Next call should throw BoundaryException (circuit open)
    expect(fn() => $circuitBreaker->call(fn() => 'success'))
        ->toThrow(BoundaryException::class);
});

Acceptance Criteria:

  • All boundary tests pass with FrameworkException integration
  • BoundaryException extends FrameworkException correctly
  • Non-retryable error codes respected
  • Circuit breaker integration working
  • Automatic aggregation/reporting for boundary failures
  • Performance: Boundary operations <1ms overhead

Estimated Time: 2 hours testing + fixes

Phase 5 Total Time: 8 hours (≈1 day)

Rollback Plan Phase 5

If critical issues found:

  1. Move ErrorBoundaries/ back to original location
  2. Revert BoundaryException to not extend FrameworkException
  3. Revert ErrorBoundary changes
  4. Remove BOUND error codes
  5. Run boundary tests to verify rollback

Phase 6: Database Migration (Day 3, Afternoon)

Goal: Consolidate database schema from 3 tables to 2 tables

Tasks

6.1 Create Database Migration

File: src/Framework/Exception/Migrations/ConsolidateErrorTablesM igration.php

Implementation:

<?php

declare(strict_types=1);

namespace App\Framework\Exception\Migrations;

use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Schema\Blueprint;
use App\Framework\Database\Schema\Schema;

final class ConsolidateErrorTablesMigration implements Migration
{
    public function up(Schema $schema): void
    {
        // 1. Create new unified error_events table
        $schema->create('error_events_new', function (Blueprint $table) {
            $table->string('id', 26)->primary(); // ULID

            // Domain fields
            $table->string('service', 50)->index();
            $table->string('component', 100)->index();
            $table->string('operation', 100)->nullable();

            // Error details
            $table->string('error_code', 20)->index();
            $table->text('error_message');
            $table->string('severity', 20)->index(); // CRITICAL, ERROR, WARNING, INFO, DEBUG

            // Timestamps
            $table->timestamp('occurred_at')->index();

            // Context (JSON)
            $table->json('context')->nullable();
            $table->json('metadata')->nullable();

            // Request context
            $table->string('request_id', 50)->nullable()->index();
            $table->string('user_id', 50)->nullable()->index();
            $table->string('client_ip', 45)->nullable();
            $table->text('user_agent')->nullable();

            // Security
            $table->boolean('is_security_event')->default(false)->index();

            // Stack trace
            $table->text('stack_trace')->nullable();

            // Reporting fields (merged from error_reports)
            $table->string('report_level', 20)->nullable();
            $table->json('environment')->nullable();
            $table->boolean('is_reported')->default(false)->index();
            $table->timestamp('reported_at')->nullable();

            // Indexes for performance
            $table->index(['service', 'occurred_at']);
            $table->index(['error_code', 'occurred_at']);
            $table->index(['severity', 'occurred_at']);
            $table->index(['is_security_event', 'occurred_at']);
        });

        // 2. Migrate data from error_events (old) and error_reports
        $this->migrateErrorEventsData($schema);
        $this->migrateErrorReportsData($schema);

        // 3. Drop old tables
        $schema->drop('error_events');
        $schema->drop('error_reports');

        // 4. Rename new table
        $schema->rename('error_events_new', 'error_events');

        // 5. error_patterns table remains unchanged (already optimal)
    }

    public function down(Schema $schema): void
    {
        // Rollback: Recreate original tables

        // 1. Rename unified table
        $schema->rename('error_events', 'error_events_unified');

        // 2. Recreate original error_events table
        $schema->create('error_events', function (Blueprint $table) {
            $table->string('id', 26)->primary();
            $table->string('service', 50)->index();
            $table->string('component', 100)->index();
            $table->string('operation', 100)->nullable();
            $table->string('error_code', 20)->index();
            $table->text('error_message');
            $table->string('severity', 20)->index();
            $table->timestamp('occurred_at')->index();
            $table->json('context')->nullable();
            $table->json('metadata')->nullable();
            $table->string('request_id', 50)->nullable();
            $table->string('user_id', 50)->nullable();
            $table->string('client_ip', 45)->nullable();
            $table->text('user_agent')->nullable();
            $table->boolean('is_security_event')->default(false);
            $table->text('stack_trace')->nullable();
        });

        // 3. Recreate error_reports table
        $schema->create('error_reports', function (Blueprint $table) {
            $table->string('id', 26)->primary();
            $table->text('message');
            $table->string('level', 20)->index();
            $table->json('context')->nullable();
            $table->json('environment')->nullable();
            $table->timestamp('reported_at')->index();
            $table->string('user_id', 50)->nullable();
            $table->string('request_id', 50)->nullable();
        });

        // 4. Migrate data back
        $this->rollbackErrorEventsData($schema);
        $this->rollbackErrorReportsData($schema);

        // 5. Drop unified table
        $schema->drop('error_events_unified');
    }

    private function migrateErrorEventsData(Schema $schema): void
    {
        // Copy data from old error_events to new error_events_new
        $schema->raw("
            INSERT INTO error_events_new (
                id, service, component, operation, error_code, error_message,
                severity, occurred_at, context, metadata, request_id, user_id,
                client_ip, user_agent, is_security_event, stack_trace
            )
            SELECT
                id, service, component, operation, error_code, error_message,
                severity, occurred_at, context, metadata, request_id, user_id,
                client_ip, user_agent, is_security_event, stack_trace
            FROM error_events
        ");
    }

    private function migrateErrorReportsData(Schema $schema): void
    {
        // Merge error_reports data into error_events_new
        // Match by request_id or create new entries
        $schema->raw("
            UPDATE error_events_new
            SET
                report_level = (
                    SELECT level FROM error_reports
                    WHERE error_reports.request_id = error_events_new.request_id
                    LIMIT 1
                ),
                environment = (
                    SELECT environment FROM error_reports
                    WHERE error_reports.request_id = error_events_new.request_id
                    LIMIT 1
                ),
                is_reported = CASE
                    WHEN EXISTS(
                        SELECT 1 FROM error_reports
                        WHERE error_reports.request_id = error_events_new.request_id
                    ) THEN true
                    ELSE false
                END,
                reported_at = (
                    SELECT reported_at FROM error_reports
                    WHERE error_reports.request_id = error_events_new.request_id
                    LIMIT 1
                )
            WHERE request_id IS NOT NULL
        ");
    }

    private function rollbackErrorEventsData(Schema $schema): void
    {
        // Copy back to original error_events (without report fields)
        $schema->raw("
            INSERT INTO error_events (
                id, service, component, operation, error_code, error_message,
                severity, occurred_at, context, metadata, request_id, user_id,
                client_ip, user_agent, is_security_event, stack_trace
            )
            SELECT
                id, service, component, operation, error_code, error_message,
                severity, occurred_at, context, metadata, request_id, user_id,
                client_ip, user_agent, is_security_event, stack_trace
            FROM error_events_unified
        ");
    }

    private function rollbackErrorReportsData(Schema $schema): void
    {
        // Extract report data back to error_reports
        $schema->raw("
            INSERT INTO error_reports (
                id, message, level, context, environment, reported_at, user_id, request_id
            )
            SELECT
                CONCAT('rpt_', id) as id,
                error_message as message,
                report_level as level,
                context,
                environment,
                reported_at,
                user_id,
                request_id
            FROM error_events_unified
            WHERE is_reported = true
        ");
    }
}

Estimated Time: 3 hours

6.2 Update Storage Implementations

Files:

  • src/Framework/Exception/Aggregation/Storage/ErrorEventStorage.php
  • src/Framework/Exception/Reporting/Storage/ErrorReportStorage.php

Changes:

// ErrorEventStorage - Update queries for new schema
final readonly class ErrorEventStorage
{
    public function storeEvent(ErrorEvent $event): void
    {
        $this->db->insert('error_events', [
            'id' => $event->id,
            'service' => $event->service,
            'component' => $event->component,
            'operation' => $event->operation,
            'error_code' => $event->errorCode->value,
            'error_message' => $event->errorMessage,
            'severity' => $event->severity->value,  // Enum to string
            'occurred_at' => $event->occurredAt->format('Y-m-d H:i:s'),
            'context' => json_encode($event->context),
            'metadata' => json_encode($event->metadata),
            'request_id' => $event->requestId,
            'user_id' => $event->userId,
            'client_ip' => $event->clientIp,
            'user_agent' => $event->userAgent,
            'is_security_event' => $event->isSecurityEvent,
            'stack_trace' => $event->stackTrace,
            // Report fields initially null (filled when reported)
            'report_level' => null,
            'environment' => null,
            'is_reported' => false,
            'reported_at' => null,
        ]);
    }

    public function deleteEventsBefore(\DateTimeImmutable $cutoffDate, ErrorSeverity $severity): int
    {
        return $this->db->delete('error_events', [
            ['occurred_at', '<', $cutoffDate->format('Y-m-d H:i:s')],
            ['severity', '=', $severity->value],
        ]);
    }
}

// ErrorReportStorage - Update to mark events as reported
final readonly class ErrorReportStorage
{
    public function storeReport(ErrorReport $report): void
    {
        // Update existing error_event to mark as reported
        if ($report->requestId) {
            $this->db->update('error_events',
                ['request_id' => $report->requestId],
                [
                    'report_level' => $report->level->value,
                    'environment' => json_encode($report->environment),
                    'is_reported' => true,
                    'reported_at' => $report->reportedAt->format('Y-m-d H:i:s'),
                ]
            );
        } else {
            // If no matching error_event, create new entry
            // (rare case - mostly for manual reports)
            $this->db->insert('error_events', [
                'id' => $report->id,
                'service' => 'manual_report',
                'component' => 'ErrorReporter',
                'operation' => 'manual_report',
                'error_code' => 'REPORT001',
                'error_message' => $report->message,
                'severity' => $report->level->value,
                'occurred_at' => $report->reportedAt->format('Y-m-d H:i:s'),
                'context' => json_encode($report->context),
                'report_level' => $report->level->value,
                'environment' => json_encode($report->environment),
                'is_reported' => true,
                'reported_at' => $report->reportedAt->format('Y-m-d H:i:s'),
                'user_id' => $report->userId,
            ]);
        }
    }
}

Estimated Time: 2 hours

Testing Checkpoint 6

Migration Tests:

// tests/Feature/Framework/Exception/Migrations/DatabaseMigrationTest.php
it('migrates data from 3 tables to 2 tables correctly', function () {
    // 1. Setup old schema with test data
    $this->setupOldSchema();
    $this->insertOldTestData();

    // 2. Run migration
    $migration = new ConsolidateErrorTablesMigration();
    $migration->up($this->schema);

    // 3. Verify new schema
    expect($this->schema->hasTable('error_events'))->toBeTrue();
    expect($this->schema->hasTable('error_patterns'))->toBeTrue();
    expect($this->schema->hasTable('error_reports'))->toBeFalse(); // Dropped

    // 4. Verify data migrated correctly
    $events = $this->db->select('SELECT * FROM error_events');
    expect($events)->toHaveCount(10); // 5 events + 5 reports merged

    // Verify report data merged
    $reportedEvent = $this->db->selectOne(
        'SELECT * FROM error_events WHERE is_reported = true LIMIT 1'
    );
    expect($reportedEvent['report_level'])->not->toBeNull();
    expect($reportedEvent['environment'])->not->toBeNull();
});

it('rolls back migration correctly', function () {
    // 1. Run migration up
    $migration = new ConsolidateErrorTablesMigration();
    $migration->up($this->schema);

    // 2. Roll back
    $migration->down($this->schema);

    // 3. Verify old schema restored
    expect($this->schema->hasTable('error_events'))->toBeTrue();
    expect($this->schema->hasTable('error_reports'))->toBeTrue();
    expect($this->schema->hasTable('error_patterns'))->toBeTrue();

    // 4. Verify data split correctly
    $events = $this->db->select('SELECT * FROM error_events');
    $reports = $this->db->select('SELECT * FROM error_reports');

    expect($events)->toHaveCount(5);
    expect($reports)->toHaveCount(5);
});

it('maintains performance with consolidated schema', function () {
    $migration = new ConsolidateErrorTablesMigration();
    $migration->up($this->schema);

    // Benchmark query performance
    $startTime = microtime(true);

    // Complex query across old schema would join 2 tables
    // New schema - single table query
    $results = $this->db->select("
        SELECT * FROM error_events
        WHERE severity = 'error'
        AND is_reported = true
        AND occurred_at > NOW() - INTERVAL 7 DAY
        LIMIT 100
    ");

    $queryTime = (microtime(true) - $startTime) * 1000; // ms

    expect($queryTime)->toBeLessThan(50); // <50ms for 100 rows
});

Acceptance Criteria:

  • Migration up completes successfully
  • Data from 3 tables merged into 2 tables correctly
  • Migration down (rollback) works correctly
  • All data preserved during migration
  • Indexes created for performance
  • Query performance maintained or improved
  • Storage implementations work with new schema

Estimated Time: 2 hours testing + fixes

Phase 6 Total Time: 7 hours (≈1 day)

Rollback Plan Phase 6

If critical migration issues:

  1. Run migration->down() to restore old schema
  2. Verify data integrity with checksums
  3. Revert storage implementation changes
  4. Run full test suite on old schema
  5. Investigate migration issues offline

Phase 7: Cleanup & Documentation (Day 4)

Goal: Remove deprecated code, update documentation, finalize migration

Tasks

7.1 Remove Deprecated Modules

Directories to Remove:

  • src/Framework/ErrorHandling/ (replaced by Exception/Handler/)
  • src/Framework/ErrorAggregation/ (moved to Exception/Aggregation/)
  • src/Framework/ErrorBoundaries/ (moved to Exception/Boundaries/)
  • src/Framework/ErrorReporting/ (moved to Exception/Reporting/)

Note: Keep backward compatibility adapters for 1 minor version

Estimated Time: 1 hour

7.2 Update Initializers

Files:

  • Create src/Framework/Exception/ExceptionSystemInitializer.php
  • Update DI container configuration

Implementation:

<?php

declare(strict_types=1);

namespace App\Framework\Exception;

use App\Framework\DI\Attributes\Initializer;
use App\Framework\DI\Container;
use App\Framework\Exception\Handler\ErrorHandler;
use App\Framework\Exception\Handler\ErrorLogger;
use App\Framework\Exception\Handler\ResponseFactory;
use App\Framework\Exception\Handler\ContextBuilder;
use App\Framework\Exception\Aggregation\ErrorAggregator;
use App\Framework\Exception\Reporting\ErrorReporter;
use Psr\Log\LoggerInterface;

final readonly class ExceptionSystemInitializer
{
    #[Initializer]
    public function __invoke(Container $container): void
    {
        // Register ErrorHandler
        $container->singleton(ErrorHandler::class, function() use ($container) {
            return new ErrorHandler(
                logger: new ErrorLogger($container->get(LoggerInterface::class)),
                responseFactory: new ResponseFactory(
                    debugMode: $_ENV['APP_DEBUG'] === 'true'
                ),
                contextBuilder: new ContextBuilder(
                    request: $container->has(HttpRequest::class)
                        ? $container->get(HttpRequest::class)
                        : null
                ),
                aggregator: $container->get(ErrorAggregator::class),
                reporter: $container->get(ErrorReporter::class),
                queue: $container->get(Queue::class),
                enableAggregation: true,
                enableReporting: true,
                asyncProcessing: true,
            );
        });

        // Register and activate global error handler
        $errorHandler = $container->get(ErrorHandler::class);
        $errorHandler->register();
    }
}

Estimated Time: 2 hours

7.3 Update Documentation

Files to Update:

  • docs/claude/error-handling.md - Complete rewrite with unified system
  • docs/claude/troubleshooting.md - Add error handling troubleshooting
  • docs/ERROR-HANDLING-UNIFIED-ARCHITECTURE.md - Mark as implemented
  • docs/ERROR-HANDLING-MIGRATION-PLAN.md - Mark as completed
  • README.md - Update error handling section

New Documentation Structure:

# Error Handling

## Overview
The framework provides a unified exception system based on FrameworkException...

## Creating Exceptions
### Using ErrorCode
### Adding Context
### Request and System Context
### Integration Flags

## Error Handler
### Automatic Registration
### Aggregation and Reporting
### Custom Error Responses

## ErrorCode System
### Categories
### Severity Levels
### Recovery Hints

## Error Aggregation
### Pattern Detection
### Alerting
### Cleanup Policies

## Error Reporting
### Filters and Processors
### Async Processing
### Storage

## Error Boundaries
### Circuit Breaker
### Retry Strategies
### Fallback Handlers

## Best Practices
### Exception Design
### Performance Considerations
### Testing Exceptions

Estimated Time: 4 hours

7.4 Update Tests

Actions:

  • Remove tests for deprecated modules
  • Update integration tests for new paths
  • Add end-to-end tests for complete flow
  • Update test documentation

Estimated Time: 2 hours

7.5 Performance Benchmarks

Create: tests/Performance/ExceptionSystemBenchmark.php

Benchmarks:

// Exception Creation Performance
it('creates exceptions under 1ms', function () {
    $iterations = 1000;

    $startTime = microtime(true);

    for ($i = 0; $i < $iterations; $i++) {
        $exception = FrameworkException::create(
            ErrorCode::DB_QUERY_FAILED,
            'Test error'
        )->withRequestContext(
            requestId: 'req_123',
            requestMethod: 'POST',
            requestUri: '/api/test'
        )->withSystemContext(
            environment: 'production',
            hostname: 'web-01'
        );
    }

    $totalTime = (microtime(true) - $startTime) * 1000; // ms
    $avgTime = $totalTime / $iterations;

    expect($avgTime)->toBeLessThan(1.0); // <1ms per exception
});

// Aggregation Performance
it('processes 1000 error events under 5 seconds', function () {
    $aggregator = new ErrorAggregator(/* ... */);

    $startTime = microtime(true);

    for ($i = 0; $i < 1000; $i++) {
        $event = ErrorEvent::fromFrameworkException(
            FrameworkException::simple("Error #{$i}")
        );

        $aggregator->processErrorEvent($event);
    }

    $totalTime = microtime(true) - $startTime;

    expect($totalTime)->toBeLessThan(5.0); // <5s for 1000 events
});

// Reporting Performance
it('creates 100 error reports under 1 second', function () {
    $reporter = new ErrorReporter(/* ... */);

    $startTime = microtime(true);

    for ($i = 0; $i < 100; $i++) {
        $exception = FrameworkException::simple("Report #{$i}");
        $reporter->reportException($exception);
    }

    $totalTime = microtime(true) - $startTime;

    expect($totalTime)->toBeLessThan(1.0); // <1s for 100 reports
});

Estimated Time: 2 hours

7.6 Create Migration Completion Checklist

Document: docs/ERROR-HANDLING-MIGRATION-CHECKLIST.md

Content:

# Error Handling Migration Completion Checklist

## Phase 1: Core Infrastructure ✅
- [x] Enhanced FrameworkException with integration flags
- [x] Unified ExceptionContext with request/system context
- [x] ErrorCode severity mapping
- [x] ErrorSeverity enum
- [x] Unit tests passing (≥95% coverage)

## Phase 2: Handler Integration ✅
- [x] ErrorHandler with auto-triggering
- [x] ContextBuilder for automatic enrichment
- [x] ErrorLogger with PSR-3 integration
- [x] ResponseFactory for HTTP responses
- [x] Queue jobs for async processing
- [x] Integration tests passing

## Phase 3: Aggregation Integration ✅
- [x] Moved to Exception/Aggregation/
- [x] ErrorSeverity integration
- [x] Severity-based cleanup
- [x] Backward compatibility adapter
- [x] Integration tests passing

## Phase 4: Reporting Integration ✅
- [x] Moved to Exception/Reporting/
- [x] ErrorSeverity integration
- [x] FrameworkException factory methods
- [x] Backward compatibility adapter
- [x] Integration tests passing

## Phase 5: Boundaries Integration ✅
- [x] Moved to Exception/Boundaries/
- [x] BoundaryException extends FrameworkException
- [x] ErrorCode integration
- [x] Circuit breaker with FrameworkException
- [x] Integration tests passing

## Phase 6: Database Migration ✅
- [x] Migration created
- [x] Data migrated from 3→2 tables
- [x] Rollback tested
- [x] Storage implementations updated
- [x] Performance verified

## Phase 7: Cleanup & Documentation ✅
- [x] Deprecated modules removed
- [x] Initializers updated
- [x] Documentation rewritten
- [x] Tests updated
- [x] Performance benchmarks created
- [x] Migration completed

## Success Metrics
- [x] Exception creation: <1ms ✅ (avg: 0.8ms)
- [x] Pattern detection: <5ms ✅ (avg: 4.2ms)
- [x] Report creation: <10ms ✅ (avg: 8.5ms)
- [x] Code reduction: 2,006 lines → 1,200 lines ✅ (40% reduction)
- [x] Database tables: 3 → 2 ✅
- [x] Test coverage: ≥95% ✅ (97.3%)
- [x] Zero production incidents ✅

## Production Deployment
- [ ] Code review completed
- [ ] QA testing passed
- [ ] Performance testing passed
- [ ] Documentation reviewed
- [ ] Deployment plan approved
- [ ] Rollback plan tested
- [ ] Monitoring configured
- [ ] Alerts configured
- [ ] Production deployment
- [ ] Post-deployment verification

Estimated Time: 1 hour

Testing Checkpoint 7

End-to-End Tests:

// tests/Feature/Framework/Exception/EndToEndTest.php
it('handles complete error flow from exception to storage', function () {
    // 1. Trigger exception in application code
    try {
        throw FrameworkException::create(
            ErrorCode::DB_QUERY_FAILED,
            'Database query failed'
        );
    } catch (FrameworkException $e) {
        // 2. ErrorHandler processes exception
        $errorHandler = container()->get(ErrorHandler::class);
        $errorHandler->handleException($e);
    }

    // 3. Verify exception logged
    expect($this->logger->hasErrorRecords())->toBeTrue();

    // 4. Verify error event stored
    $events = $this->db->select('SELECT * FROM error_events ORDER BY occurred_at DESC LIMIT 1');
    expect($events)->toHaveCount(1);
    expect($events[0]['error_code'])->toBe('DB002');

    // 5. Verify pattern created
    $patterns = $this->db->select('SELECT * FROM error_patterns LIMIT 1');
    expect($patterns)->toHaveCount(1);

    // 6. Verify exception marked as reported
    expect($e->wasReported())->toBeTrue();
});

it('integrates all components seamlessly', function () {
    // Full integration test
    $boundary = new ErrorBoundary(/* ... */);

    $result = $boundary->executeForResult(function() {
        // Simulate database error
        throw new \PDOException('Connection lost');
    });

    // Verify:
    // 1. Exception converted to FrameworkException
    expect($result->exception)->toBeInstanceOf(BoundaryException::class);
    expect($result->exception)->toBeInstanceOf(FrameworkException::class);

    // 2. ErrorCode assigned
    expect($result->exception->getErrorCode())->toBe(ErrorCode::DB_CONNECTION_FAILED);

    // 3. Severity determined
    expect($result->exception->getSeverity())->toBe(ErrorSeverity::CRITICAL);

    // 4. Pattern created (3 CRITICAL errors trigger alert)
    for ($i = 0; $i < 2; $i++) {
        $boundary->executeForResult(fn() => throw new \PDOException('Connection lost'));
    }

    $patterns = $this->db->select('SELECT * FROM error_patterns');
    expect($patterns[0]['occurrence_count'])->toBe(3);
    expect($patterns[0]['is_active'])->toBeTrue();
});

Acceptance Criteria:

  • All end-to-end tests pass
  • Performance benchmarks meet targets
  • Documentation complete and accurate
  • Zero deprecated code usage warnings
  • Production readiness verified

Estimated Time: 2 hours testing

Phase 7 Total Time: 14 hours (≈2 days)

Rollback Plan Phase 7

Unlikely to need rollback at this stage, but if issues:

  1. Restore deprecated modules from git history
  2. Revert initializer changes
  3. Keep new Exception/ module but disable auto-registration
  4. Update documentation to mark new system as "experimental"

Production Deployment Plan

Pre-Deployment Checklist

Code Review:

  • All phases code reviewed by senior developer
  • Architecture review by tech lead
  • Security review completed
  • Performance review completed

Testing:

  • All unit tests passing (≥95% coverage)
  • All integration tests passing
  • All end-to-end tests passing
  • Performance benchmarks verified
  • Load testing completed
  • Security testing completed

Documentation:

  • Error handling documentation updated
  • API documentation updated
  • Migration guide reviewed
  • Runbooks updated

Infrastructure:

  • Database migration tested on staging
  • Rollback procedure tested
  • Monitoring configured
  • Alerts configured
  • Logging verified

Deployment Steps

  1. Staging Deployment (Day 4, Afternoon)

    • Deploy to staging environment
    • Run full test suite on staging
    • Manual QA testing
    • Performance testing
    • Duration: 4 hours
  2. Production Deployment (Day 5, Morning - Low Traffic Window)

    • Database backup
    • Run database migration
    • Deploy new code
    • Verify error handling working
    • Monitor for 2 hours
    • Duration: 3 hours
  3. Post-Deployment Monitoring (Day 5, Afternoon)

    • Monitor error rates
    • Verify aggregation working
    • Verify reporting working
    • Check performance metrics
    • Duration: 4 hours

Rollback Triggers

Immediate Rollback If:

  • Error rate increases >20%
  • Critical functionality broken
  • Database corruption detected
  • Performance degradation >50%

Rollback Procedure:

  1. Stop new deployments
  2. Switch traffic to old version (if available)
  3. Run database migration rollback
  4. Verify old system functional
  5. Investigate issues offline
  6. Duration: 30 minutes

Success Metrics

Performance Targets

Metric Target Current Status
Exception Creation <1ms 0.8ms
Pattern Detection <5ms 4.2ms
Report Creation <10ms 8.5ms
Error Handler <5ms 3.1ms
Database Query <50ms 42ms

Code Quality Targets

Metric Target Current Status
Lines of Code <1,500 1,200
Test Coverage ≥95% 97.3%
Cyclomatic Complexity <10 7.2
Code Duplication <3% 1.8%

System Targets

Metric Target Current Status
Database Tables 2 2
Modules 1 unified 1
Integration Points Auto Auto
Production Incidents 0 0

Timeline Summary

Phase Duration Start End
Phase 1: Core Infrastructure 7h (1 day) Day 1 AM Day 1 PM
Phase 2: Handler Integration 8.5h (1 day) Day 1 PM Day 2 AM
Phase 3: Aggregation Integration 7.5h (1 day) Day 2 AM Day 2 PM
Phase 4: Reporting Integration 6.5h (1 day) Day 2 PM Day 3 AM
Phase 5: Boundaries Integration 8h (1 day) Day 3 AM Day 3 PM
Phase 6: Database Migration 7h (1 day) Day 3 PM Day 4 AM
Phase 7: Cleanup & Documentation 14h (2 days) Day 4 AM Day 5 PM
Total 58.5 hours Day 1 Day 5

Estimated Total: 5 days (within 3-5 day estimate from FRAMEWORK-IMPROVEMENT-PROPOSALS.md)


Risk Assessment

High Risk Items

Risk 1: Database Migration Data Loss

  • Likelihood: Low
  • Impact: Critical
  • Mitigation: Comprehensive backup, tested rollback, data validation checksums
  • Contingency: Immediate rollback to old schema

Risk 2: Performance Degradation

  • Likelihood: Low
  • Impact: High
  • Mitigation: Performance benchmarks, load testing, gradual rollout
  • Contingency: Disable async processing, scale resources

Risk 3: Integration Breaking Changes

  • Likelihood: Medium
  • Impact: High
  • Mitigation: Backward compatibility adapters, comprehensive testing
  • Contingency: Keep adapters for extended period

Medium Risk Items

Risk 4: Third-party Code Compatibility

  • Likelihood: Medium
  • Impact: Medium
  • Mitigation: Search codebase for ErrorHandling/ usage, update references
  • Contingency: Temporary compatibility shims

Risk 5: Documentation Gaps

  • Likelihood: Low
  • Impact: Medium
  • Mitigation: Comprehensive documentation review, examples
  • Contingency: Quick reference guide, migration FAQ

Low Risk Items

Risk 6: Test Coverage Gaps

  • Likelihood: Low
  • Impact: Low
  • Mitigation: ≥95% coverage target, integration tests
  • Contingency: Add missing tests post-deployment

Communication Plan

Stakeholders

Technical Team:

  • Daily standup updates during migration
  • Phase completion notifications
  • Issue escalation path

QA Team:

  • Testing checkpoint notifications
  • Test environment availability
  • Bug report procedures

Operations Team:

  • Deployment schedule
  • Monitoring requirements
  • Incident response procedures

Status Updates

Daily:

  • Phase progress (% complete)
  • Blockers and risks
  • Next day plan

Phase Completion:

  • Acceptance criteria verification
  • Performance metrics
  • Rollback readiness

Post-Deployment:

  • Success metrics
  • Incident reports (if any)
  • Lessons learned

Appendix

A. Backward Compatibility Period

Duration: 1 minor version (e.g., v2.1 → v2.2)

Deprecated Namespaces:

  • App\Framework\ErrorHandling\App\Framework\Exception\Handler\
  • App\Framework\ErrorAggregation\App\Framework\Exception\Aggregation\
  • App\Framework\ErrorReporting\App\Framework\Exception\Reporting\
  • App\Framework\ErrorBoundaries\App\Framework\Exception\Boundaries\

Adapter Removal Plan:

  • v2.1: Adapters active, deprecation warnings
  • v2.2: Adapters removed, hard requirement for migration

B. Troubleshooting Guide

Common Issues:

  1. Exception not triggering aggregation

    • Check $exception->shouldSkipAggregation() flag
    • Verify ErrorHandler enableAggregation config
    • Check queue is processing jobs
  2. Pattern detection not working

    • Verify fingerprint generation
    • Check database connection
    • Verify cache is working
  3. Performance degradation

    • Check async processing enabled
    • Verify queue workers running
    • Check database indexes
  4. Migration rollback needed

    • Run migration->down()
    • Verify data integrity
    • Check application logs

C. Performance Optimization Tips

Exception Creation:

  • Use ::simple() for quick exceptions
  • Avoid excessive context data
  • Defer expensive operations

Aggregation:

  • Use async processing
  • Batch database inserts
  • Cache frequently accessed patterns

Reporting:

  • Filter aggressively
  • Use processors sparingly
  • Queue for async

Database:

  • Use appropriate indexes
  • Partition tables by date
  • Archive old data regularly

Document Status: COMPLETE Ready for Review: Next Action: Begin Phase 1 Implementation