bkdotcom/PHPDebugConsole

View on GitHub
src/ErrorHandler/AbstractErrorHandler.php

Summary

Maintainability
A
55 mins
Test Coverage
C
77%
<?php

/**
 * @package   bdk\ErrorHandler
 * @author    Brad Kent <bkfake-github@yahoo.com>
 * @license   http://opensource.org/licenses/MIT MIT
 * @copyright 2014-2024 Brad Kent
 * @since     v3.3
 */

namespace bdk\ErrorHandler;

use bdk\Backtrace;
use bdk\ErrorHandler\AbstractComponent;
use bdk\ErrorHandler\Error;
use bdk\ErrorHandler\Plugin\Emailer;
use bdk\ErrorHandler\Plugin\Stats;

/**
 * Serves as base class for ErrorHandler
 *
 * Able to register multiple onError "callback" functions
 *
 * @property \bdk\Backtrace                 $backtrace Backtrace instance
 * @property \bdk\ErrorHandler\Plugin\Stats $stats     Stats instance
 */
abstract class AbstractErrorHandler extends AbstractComponent
{
    const EVENT_ERROR = 'errorHandler.error';

    /** @var array<string,mixed> */
    protected $data = array(
        'errorCaller'   => array(),
        'errorReportingInitial' => 0,   // Initial error reporting level. to compare against value when handling error
        'errors'        => array(),
        'lastErrors'    => array(),     // contains up to two errors: suppressed & unsuppressed
                                        // lastError[0] is the most recent error
        'uncaughtException' => null,    // error constructor will pull this
    );

    /** @var callable|null */
    protected $prevErrorHandler = null;

    /** @var callable|null */
    protected $prevExceptionHandler = null;

    /** @var Backtrace */
    private $backtrace;

    /** @var Emailer */
    private $emailer;

    /** @var Stats */
    private $stats;

    /**
     * Temp store error exception caught/triggered inside __toString
     *
     * @var \Exception|\Throwable|null
     */
    private $toStringException = null;

    /**
     * Set data value
     *
     * @param string $key   what
     * @param mixed  $value value
     *
     * @return void
     */
    public function setData($key, $value)
    {
        $this->data[$key] = $value;
    }

    /**
     * Conditionally pass error or exception to previously defined handler
     *
     * @param Error $error Error instance
     *
     * @return bool
     * @throws \Exception
     */
    protected function continueToPrevHandler(Error $error)
    {
        $this->handleUserError($error);
        if ($error['continueToPrevHandler'] === false || $error->isPropagationStopped()) {
            return $error['continueToNormal'] === false;
        }
        if ($error['exception']) {
            $this->continueToPrevHandlerException($error);
            return $error['continueToNormal'] === false;
        }
        if (!$this->prevErrorHandler) {
            return $error['continueToNormal'] === false;
        }
        return \call_user_func(
            $this->prevErrorHandler,
            $error['type'],
            $error['message'],
            $error['file'],
            $error['line'],
            $error['vars']
        );
    }

    /**
     * Restore previous exception handler and re-throw or log exception
     *
     * @param Error $error Error instance
     *
     * @return void
     * @throws \Exception
     */
    private function continueToPrevHandlerException(Error $error)
    {
        if ($this->prevExceptionHandler) {
            /*
                re-throw exception vs calling handler directly
            */
            \restore_exception_handler();
            $this->data['uncaughtException'] = null;
            throw $error['exception'];
        }
        if ($error['continueToNormal']) {
            $error->log();
        }
    }

    /**
     * Check enableEmailer & enableStats cfg values and enable
     *
     * Called
     *   * on first error (passes haveError = true)
     *   * postSetCfg
     *
     * @param bool $haveError true when called via onFirstError
     *
     * @return void
     */
    protected function enableStatsEmailer($haveError = false)
    {
        if ($haveError === false && empty($this->data['errors'])) {
            // no reason to instantiate or subscribe
            return;
        }
        $callables = \array_map(static function ($subscriberInfo) {
            return $subscriberInfo['callable'];
        }, $this->eventManager->getSubscribers(self::EVENT_ERROR));
        if ($this->cfg['enableEmailer'] && \in_array([$this->getEmailer(), 'onErrorHighPri'], $callables, true) === false) {
            $this->cfg['enableStats'] = true;
            $this->eventManager->addSubscriberInterface($this->emailer);
        }
        if ($this->cfg['enableStats'] && \in_array([$this->getStats(), 'onErrorHighPri'], $callables, true) === false) {
            $this->eventManager->addSubscriberInterface($this->stats);
        }
    }

    /**
     * Get Backtrace instance
     *
     * @return Backtrace
     *
     * @SuppressWarnings(PHPMD.UnusedPrivateMethod)
     */
    protected function getBacktrace()
    {
        if (!$this->backtrace) {
            $this->backtrace = new Backtrace();
            $this->backtrace->addInternalClass([
                'bdk\\ErrorHandler',
                'bdk\\PubSub\\',
            ]);
        }
        return $this->backtrace;
    }

    /**
     * Get current registered error handler
     *
     * @return callable|null
     */
    protected function getErrorHandler()
    {
        /*
            set and restore error handler to determine the current error handler
        */
        $errHandlerCur = \set_error_handler([$this, 'handleError']);
        \restore_error_handler();
        return $errHandlerCur;
    }

    /**
     * Get current registered exception handler
     *
     * @return callable|null
     */
    protected function getExceptionHandler()
    {
        /*
            set and restore exception handler to determine the current error handler
        */
        $exHandlerCur = \set_exception_handler([$this, 'handleException']);
        \restore_exception_handler();
        return $exHandlerCur;
    }

    /**
     * Get Emailer instance
     *
     * @return Emailer
     *
     * @SuppressWarnings(PHPMD.UnusedPrivateMethod)
     */
    protected function getEmailer()
    {
        if ($this->emailer === null) {
            $this->emailer = new Emailer($this->cfg['emailer']);
        }
        return $this->emailer;
    }

    /**
     * Get Stats instance
     *
     * @return Stats
     *
     * @SuppressWarnings(PHPMD.UnusedPrivateMethod)
     */
    protected function getStats()
    {
        if ($this->stats === null) {
            $this->stats = new Stats($this->cfg['stats']);
        }
        return $this->stats;
    }

    /**
     * Handle updated onError
     *
     * @param callable|null $onError new onError value
     * @param callable|null $prev    previous onError value
     *
     * @return void
     */
    protected function onCfgOnError($onError, $prev)
    {
        /*
            Replace - not append - subscriber set via setCfg
        */
        if ($prev !== null) {
            $this->eventManager->unsubscribe(self::EVENT_ERROR, $prev);
        }
        if ($onError) {
            $this->eventManager->subscribe(self::EVENT_ERROR, $onError);
        }
    }

    /**
     * Handle updated cfg values
     *
     * @param array $cfg  new config values
     * @param array $prev previous config values
     *
     * @return void
     */
    protected function postSetCfg($cfg = array(), $prev = array())
    {
        if (isset($this->emailer) && isset($cfg['emailer'])) {
            $this->emailer->setCfg($cfg['emailer']);
        }
        if (isset($this->stats) && isset($cfg['stats'])) {
            $this->stats->setCfg($cfg['stats']);
        }
        $this->enableStatsEmailer();
        if (\array_key_exists('onError', $cfg)) {
            $this->onCfgOnError($cfg['onError'], $prev['onError']);
        }
    }

    /**
     * Store last error
     *
     * We store up to two errors...  so that we can return last suppressed error (if desired)
     *
     * @param Error $error error instance
     *
     * @return void
     */
    protected function storeLastError(Error $error)
    {
        $this->data['lastErrors'] = \array_filter($this->data['lastErrors'], static function (Error $error) {
            return !$error['isSuppressed'];
        });
        $this->data['lastErrors'] = \array_slice($this->data['lastErrors'], 0, 1);
        \array_unshift($this->data['lastErrors'], $error);
    }

    /**
     * Throw ErrorException if $error['throw'] === true
     * Fatal or Suppressed errors will never be thrown
     *
     * @param Error $error error exception
     *
     * @return void
     *
     * @throws \ErrorException
     */
    protected function throwError(Error $error)
    {
        if ($error['isSuppressed'] || $error->isFatal()) {
            return;
        }
        if ($error['throw']) {
            throw $error->asException();
        }
    }

    /**
     * Handle  Fatal Error 'Method __toString() must not throw an exception'
     *
     * PHP < 7.4 does not allow an exception to be thrown from __toString
     * A work around
     *    try {
     *        // code
     *    } catch (\Exception $e) {
     *        return trigger_error ($e, E_USER_ERROR);
     *    }
     *
     * @param Error $error Error instance
     *
     * @return void
     * @throws \Exception re-throws caught exception
     */
    protected function toStringCheck(Error $error)
    {
        if (PHP_VERSION_ID >= 70400) {
            return;
        }
        if ($this->toStringException) {
            $exception = $this->toStringException;
            $this->toStringException = null;
            throw $exception;
        }
        if ($error['type'] !== E_USER_ERROR) {
            return;
        }
        $errMsg = $error['message'];
        /*
            Find exception in context
            if found, check if error via __toString -> trigger_error
        */
        foreach ($error['vars'] as $val) {
            if ($val instanceof \Exception && ($val->getMessage() === $errMsg || (string) $val === $errMsg)) {
                $this->toStringCheckTrigger($error, $val);
                break;
            }
        }
    }

    /**
     * Look through backtrace to see if error via __toString -> trigger_error
     *
     * Only utilized by PHP < 7.4
     *
     * @param Error                 $error     Error instance
     * @param \Throwable|\Exception $exception Exception
     *
     * @return void
     */
    private function toStringCheckTrigger(Error $error, $exception)
    {
        $backtrace = $error->getTrace();
        if ($backtrace === false) {
            return;
        }
        $count = \count($backtrace);
        for ($i = 1; $i < $count; $i++) {
            if (
                isset($backtrace[$i - 1]['function'])
                && \in_array($backtrace[$i - 1]['function'], ['trigger_error', 'user_error'], true)
                && \strpos($backtrace[$i]['function'], '->__toString') !== false
            ) {
                $error->stopPropagation();
                $error['continueToNormal'] = false;
                $this->toStringException = $exception;
                return;
            }
        }
    }
}