diff --git a/composer.json b/composer.json index 770472dce61194c6f44ba1a8a72568e19e2066b7..b35e00f55b75ce7dbeed89b86490c657e3c6d05a 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,8 @@ ], "require": { "php": ">=5.6", - "psr/log": "^1.0.1" + "psr/log": "^1.0.1", + "monolog/monolog": "^1.23" }, "require-dev": { "phpunit/phpunit": "<7", diff --git a/src/BasicLoggerFactory.php b/src/BasicLoggerFactory.php new file mode 100644 index 0000000000000000000000000000000000000000..e9d1dd3ef2102cd36a8f69a6780ff6fc09dcac70 --- /dev/null +++ b/src/BasicLoggerFactory.php @@ -0,0 +1,50 @@ +<?php + +namespace WPDesk\Logger; + +use Monolog\Handler\HandlerInterface; +use Monolog\Logger; +use Monolog\Registry; + +/** + * Manages and facilitates creation of logger + * + * @package WPDesk\Logger + */ +class BasicLoggerFactory implements LoggerFactory +{ + /** @var string Last created logger name/channel */ + private static $lastLoggerChannel; + + /** + * Creates logger for plugin + * + * @param string $channel The logging channel + * @param HandlerInterface[] $handlers Optional stack of handlers, the first one in the array is called first, etc. + * @param callable[] $processors Optional array of processors + * @return Logger + */ + public function createLogger($channel, $handlers = array(), array $processors = array()) + { + if (Registry::hasLogger($channel)) { + return Registry::getInstance($channel); + } + self::$lastLoggerChannel = $channel; + + $logger = new Logger($channel, $handlers, $processors); + + Registry::addLogger($logger); + + return $logger; + } + + /** + * Returns created Logger + * + * @return Logger + */ + public function getLogger() + { + return Registry::getInstance(self::$lastLoggerChannel); + } +} diff --git a/src/LoggerFacade.php b/src/LoggerFacade.php new file mode 100644 index 0000000000000000000000000000000000000000..a7e867984460b669facff0bac1a1bd12a1183367 --- /dev/null +++ b/src/LoggerFacade.php @@ -0,0 +1,104 @@ +<?php + +namespace WPDesk\Logger; + +use Monolog\Logger; +use Psr\Log\LogLevel; +use WP_Error; +use Exception; + +/** + * Facilitates creation of logger with default WPDesk settings + * + * @package WPDesk\Logger + */ +class LoggerFacade +{ + const BACKTRACE_FILENAME_KEY = 'file'; + + /** @var LoggerFactory */ + private static $factory; + + /** + * @return Logger + */ + public static function getLogger() + { + if (self::$factory === null) { + self::$factory = new WPDeskLoggerFactory(); + } + return self::$factory->createWPDeskLogger(); + } + + /** + * Snake case alias for getLogger + * + * @return Logger + */ + public static function get_logger() + { + return self::getLogger(); + } + + /** + * Log this exception into WPDesk logger + * + * @param WP_Error $e Error to log. + * @param array $backtrace Backtrace information with snapshot of error env. + * @param string $level Level of error. + * + * @see http://php.net/manual/en/function.debug-backtrace.php + */ + public static function log_wp_error(WP_Error $e, array $backtrace, $level = LogLevel::ERROR) + { + $message = 'Error: ' . get_class($e) . ' Code: ' . $e->get_error_code() . ' Message: ' . $e->get_error_message(); + + self::log_message_backtrace($message, $backtrace, $level); + } + + /** + * Log this exception into WPDesk logger + * + * @param Exception $e Exception to log. + * @param string $level Level of error. + */ + public static function log_exception(Exception $e, $level = LogLevel::ERROR) + { + $message = 'Exception: ' . get_class($e) . ' Code: ' . $e->getCode() . ' Message: ' . $e->getMessage() . ' Stack: ' . $e->getTraceAsString(); + + self::log_message($message, ['exception' => $e], $e->getFile(), $level); + } + + /** + * Log message into WPDesk logger + * + * @param string $message Message to log. + * @param array $context Context to log + * @param string $source Source of the message - can be file name, class name or whatever. + * @param string $level Level of error. + */ + public static function log_message( + $message, + array $context = array(), + $source = 'unknown', + $level = LogLevel::DEBUG + ) { + $logger = self::getLogger(); + + $logger->log($level, $message, array_merge($context, ['source' => $source])); + } + + /** + * Log message into WPDesk logger + * + * @param string $message Message to log. + * @param array $backtrace Backtrace information with snapshot of error env. + * @param string $level Level of error. + */ + public static function log_message_backtrace($message, array $backtrace, $level = LogLevel::DEBUG) + { + $message .= ' Backtrace: ' . json_encode($backtrace); + + self::log_message($message, $backtrace[self::BACKTRACE_FILENAME_KEY], $level); + } +} diff --git a/src/LoggerFactory.php b/src/LoggerFactory.php new file mode 100644 index 0000000000000000000000000000000000000000..653099085080f98df6ac49fd424d39945442e5f9 --- /dev/null +++ b/src/LoggerFactory.php @@ -0,0 +1,18 @@ +<?php + +namespace WPDesk\Logger; + +use Monolog\Logger; + +/* + * @package WPDesk\Logger + */ +interface LoggerFactory +{ + /** + * Returns created Logger + * + * @return Logger + */ + public function getLogger(); +} diff --git a/src/WC/Exception/WCLoggerAlreadyCaptured.php b/src/WC/Exception/WCLoggerAlreadyCaptured.php new file mode 100644 index 0000000000000000000000000000000000000000..a1826f7f255b147a59520f26111b84f2c0b3d661 --- /dev/null +++ b/src/WC/Exception/WCLoggerAlreadyCaptured.php @@ -0,0 +1,10 @@ +<?php + + +namespace WPDesk\Logger\WP\Exception; + + +class WCLoggerAlreadyCaptured extends \RuntimeException +{ + +} \ No newline at end of file diff --git a/src/WC/WooCommerceCapture.php b/src/WC/WooCommerceCapture.php new file mode 100644 index 0000000000000000000000000000000000000000..e8d4fd6d930287317976e4979c5718597aaa8a56 --- /dev/null +++ b/src/WC/WooCommerceCapture.php @@ -0,0 +1,135 @@ +<?php + +namespace WPDesk\Logger\WP; + +use Monolog\Logger; +use WPDesk\Logger\WP\Exception\WCLoggerAlreadyCaptured; + +/** + * Can capture default WooCommerce logger + * + * @package WPDesk\Logger + */ +class WooCommerceCapture +{ + const WOOCOMMERCE_LOGGER_FILTER = 'woocommerce_logging_class'; + const WOOCOMMERCE_AFTER_IS_LOADED_ACTION = 'woocommerce_loaded'; + + /** + * Is logger filter captured by library. + * + * @var bool + */ + private static $WCLoggerCaptured = false; + + /** + * Our monolog + * + * @var Logger + */ + private $monolog; + + /** + * Original WC Logger + * + * @var \WC_Logger_Interface + */ + private $originalWCLogger; + + /** + * WordPress hook function to return our logger + * + * @var ?callable + */ + private $captureHookFunction; + + /** + * WordPress hook function to return original wc logger + * + * @var ?callable + */ + private $freeHookFunction; + + public function __construct(Logger $monolog) + { + $this->monolog = $monolog; + } + + /** + * Prepares callable property captureHookFunction. + * For it to work WC have to be loaded + */ + private function prepareCaptureHookCallable() + { + $monolog = $this->monolog; + + if ($this->captureHookFunction === null) { + $this->captureHookFunction = function () use ($monolog) { + return new WooCommerceMonologPlugin($monolog); + }; + $this->monolog->pushHandler(new WooCommerceHandler($this->originalWCLogger)); + } + } + + /** + * Capture WooCommerce logger and inject our decorated Logger + */ + public function captureWcLogger() + { + if (self::$WCLoggerCaptured) { + throw new WCLoggerAlreadyCaptured('Try to free wc logger first.'); + } + + if ($this->isWooCommerceLoggerAvailable()) { + $this->prepareFreeHookCallable(); + $this->prepareCaptureHookCallable(); + + remove_filter(self::WOOCOMMERCE_LOGGER_FILTER, $this->freeHookFunction); + add_filter(self::WOOCOMMERCE_LOGGER_FILTER, $this->captureHookFunction); + + self::$WCLoggerCaptured = true; + } elseif (function_exists('add_action')) { + add_action(self::WOOCOMMERCE_AFTER_IS_LOADED_ACTION, [$this, 'captureWcLogger']); + } else { + $this->monolog->alert('Cannot capture WC - WordPress is not available.'); + } + } + + /** + * Can i fetch WC Logger? + * + * @return bool + */ + private function isWooCommerceLoggerAvailable() + { + return function_exists('wc_get_logger'); + } + + /** + * Prepares callable property freeHookFunction. + * For it to work WC have to be loaded + */ + private function prepareFreeHookCallable() + { + if ($this->freeHookFunction === null) { + $this->originalWCLogger = $logger = wc_get_logger(); + + $this->freeHookFunction = function () use ($logger) { + return $logger; + }; + } + } + + /** + * Remove WooCommerce logger injection + */ + public function freeWcLogger() + { + if (self::$WCLoggerCaptured) { + remove_filter(self::WOOCOMMERCE_LOGGER_FILTER, $this->captureHookFunction); + add_filter(self::WOOCOMMERCE_LOGGER_FILTER, $this->freeHookFunction); + + self::$WCLoggerCaptured = false; + } + } +} diff --git a/src/WC/WooCommerceHandler.php b/src/WC/WooCommerceHandler.php new file mode 100644 index 0000000000000000000000000000000000000000..35a31da47a37363fa60129641ad3c217cd59dbd1 --- /dev/null +++ b/src/WC/WooCommerceHandler.php @@ -0,0 +1,35 @@ +<?php + +namespace WPDesk\Logger\WP; + +use Monolog\Handler\AbstractProcessingHandler; + +/** + * Class WooCommerceFactory + */ +class WooCommerceHandler extends AbstractProcessingHandler { + const DEFAULT_WC_SOURCE = 'wpdesk-logger'; + + /** @var \WC_Logger_Interface */ + private $wc_logger; + + /** + * Writes the record down to the log of the implementing handler + * + * @param array $record + * @return void + */ + protected function write(array $record) + { + $context = array_merge([ + 'source' => self::DEFAULT_WC_SOURCE + ], $record['extra'], $record['context']); + + $this->wc_logger->log($record['level'], $record['message'], $context); + } + + public function __construct(\WC_Logger_Interface $originalWcLogger) { + parent::__construct(); + $this->wc_logger = $originalWcLogger; + } +} diff --git a/src/WC/WooCommerceMonologPlugin.php b/src/WC/WooCommerceMonologPlugin.php new file mode 100644 index 0000000000000000000000000000000000000000..0996d53fb526b797b9c6917cc9007a8fd8bf7f35 --- /dev/null +++ b/src/WC/WooCommerceMonologPlugin.php @@ -0,0 +1,157 @@ +<?php + +namespace WPDesk\Logger\WP; + +use Monolog\Logger; +use Psr\Log\LogLevel; +use WC_Log_Levels; + + +/** + * Can decorate monolog with WC_Logger_Interface + * + * @package WPDesk\Logger + */ +class WooCommerceMonologPlugin implements \WC_Logger_Interface { + + /** @var Logger */ + private $monolog; + + public function __construct( Logger $monolog ) { + $this->monolog = $monolog; + } + + /** + * Method for compatibility reason. Do not use. + * + * @param string $handle + * @param string $message + * @param string $level + * @return bool|void + * + * @deprecated + */ + public function add( $handle, $message, $level = WC_Log_Levels::NOTICE ) { + $this->log($message, $level); + } + + /** + * System is unusable. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function emergency($message, $context = array()) + { + $this->log(LogLevel::EMERGENCY, $message, $context); + } + + public function log( $level, $message, $context = [] ) { + $this->monolog->log($level, $message, $context); + } + + /** + * Action must be taken immediately. + * + * Example: Entire website down, database unavailable, etc. This should + * trigger the SMS alerts and wake you up. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function alert($message, $context = array()) + { + $this->log(LogLevel::ALERT, $message, $context); + } + + /** + * Critical conditions. + * + * Example: Application component unavailable, unexpected exception. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function critical($message, $context = array()) + { + $this->log(LogLevel::CRITICAL, $message, $context); + } + + /** + * Runtime errors that do not require immediate action but should typically + * be logged and monitored. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function error($message, $context = array()) + { + $this->log(LogLevel::ERROR, $message, $context); + } + + /** + * Exceptional occurrences that are not errors. + * + * Example: Use of deprecated APIs, poor use of an API, undesirable things + * that are not necessarily wrong. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function warning($message, $context = array()) + { + $this->log(LogLevel::WARNING, $message, $context); + } + + /** + * Normal but significant events. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function notice($message, $context = array()) + { + $this->log(LogLevel::NOTICE, $message, $context); + } + + /** + * Interesting events. + * + * Example: User logs in, SQL logs. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function info($message, $context = array()) + { + $this->log(LogLevel::INFO, $message, $context); + } + + /** + * Detailed debug information. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function debug($message, $context = array()) + { + $this->log(LogLevel::DEBUG, $message, $context); + } + +} diff --git a/src/WPDeskLoggerFactory.php b/src/WPDeskLoggerFactory.php new file mode 100644 index 0000000000000000000000000000000000000000..8f7b1195e897ba5d87a039d2f76cb3d220e55a24 --- /dev/null +++ b/src/WPDeskLoggerFactory.php @@ -0,0 +1,144 @@ +<?php + +namespace WPDesk\Logger; + +use Monolog\Logger; +use Monolog\Registry; +use Monolog\ErrorHandler; +use Monolog\Handler\StreamHandler; +use Psr\Log\LogLevel; +use WPDesk\Logger\WP\WooCommerceCapture; + +/** + * Manages and facilitates creation of logger + * + * @package WPDesk\Logger + */ +class WPDeskLoggerFactory extends BasicLoggerFactory +{ + const WPDESK_LOGGER_CHANNEL_NAME = 'wpdesk'; + + /** @var string Log to file when level is */ + const LEVEL_WPDESK_FILE = LogLevel::DEBUG; + + /** @var string Log to wc logger when level is */ + const LEVEL_WC = LogLevel::ERROR; + + /** @var bool */ + private static $isWpdeskLogWorking = false; + + /** + * Creates default WPDesk logger. + * + * Requirements: + * - get_option, add/remove_action, add/remove filter and WP_CONTENT_DIR should be available for logger. + * + * Assumptions: + * - logger is actively working when 'wpdesk_helper_options' has 'debug_log' set to '1'; + * - fatal errors, exception and standard errors are recorded but in a transparent way; + * - WooCommerce logger is captured and returns this logger; + * - logs are still correctly written to WooCommerce subsystem in a transparent way; + * - all recorded errors are written to WPDesk file. + * + * @return Logger + */ + public function createWPDeskLogger() + { + if (Registry::hasLogger(self::WPDESK_LOGGER_CHANNEL_NAME)) { + return Registry::getInstance(self::WPDESK_LOGGER_CHANNEL_NAME); + } + $logger = $this->createLogger(self::WPDESK_LOGGER_CHANNEL_NAME); + if ($this->shouldInitializeLoggerHandles()) { + $this->captureWooCommerce($logger); + $this->captureWordPressHandle($logger); + try { + $this->appendWPDeskHandle($logger); + $logger->debug('WPDesk handle is active'); + self::$isWpdeskLogWorking = true; + } catch (\InvalidArgumentException $e) { + $logger->emergency('WPDesk log could not be created - invalid filename.'); + } catch (\Exception $e) { + $logger->emergency('WPDesk log could not be written.'); + } + } + + return $logger; + } + + /** + * is WPDesk file log is working(writable, exists, connected). + * + * @return bool + */ + public function isWPDeskLogWorking() + { + return self::$isWpdeskLogWorking; + } + + /** + * Returns WPDesk filename with path. + * + * @return string + */ + public function getWPDeskFileName() + { + return WP_CONTENT_DIR . '/uploads/wpdesk-logs/wpdesk_debug.log'; + } + + /** + * According to WPDesk - use logger only when debug mode is enabled. + * + * @return bool + */ + private function shouldInitializeLoggerHandles() + { + if (function_exists('get_option')) { + $options = get_option('wpdesk_helper_options'); + return is_array($options) && isset($options['debug_log']) && $options['debug_log'] === '1'; + } + return false; + } + + /** + * Capture WooCommerce and add handle + * + * @param Logger $logger + */ + private function captureWooCommerce(Logger $logger) + { + if (!defined('WC_LOG_THRESHOLD')) { + define('WC_LOG_THRESHOLD', self::LEVEL_WC); + } + + $wcIntegration = new WooCommerceCapture($logger); + $wcIntegration->captureWcLogger(); + } + + /** + * Add WordPress(standard PHP) errors handle + * + * @param Logger $logger + */ + private function captureWordPressHandle(Logger $logger) + { + $errorHandler = new ErrorHandler($logger); + + $errorHandler->registerErrorHandler(); + $errorHandler->registerFatalHandler(); + $errorHandler->registerExceptionHandler(); + } + + /** + * Add WPDesk log file handle + * + * @param Logger $logger + * + * @throws \Exception If a missing directory is not buildable + * @throws \InvalidArgumentException If stream is not a resource or string + */ + private function appendWPDeskHandle(Logger $logger) + { + $filename = $this->getWPDeskFileName(); + $logger->pushHandler(new StreamHandler($filename, self::LEVEL_WPDESK_FILE)); + } +}