diff --git a/composer.json b/composer.json index 0d66da1..a2b3913 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,8 @@ "ext-json": "*", "ext-simplexml": "*", "symfony/console": "~2.8|~3.4", - "psr/container": "^1.0" + "psr/container": "^1.0", + "psr/log": "^1.0 || ^2.0" }, "require-dev": { "yoast/phpunit-polyfills": "^0.2.0" diff --git a/src/xPDO/xPDO.php b/src/xPDO/xPDO.php index a5bfd22..f3a4774 100644 --- a/src/xPDO/xPDO.php +++ b/src/xPDO/xPDO.php @@ -20,6 +20,9 @@ use Composer\Autoload\ClassLoader; use Psr\Container\ContainerInterface; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Psr\Log\LoggerInterface; use xPDO\Om\xPDOCriteria; use xPDO\Om\xPDOQuery; @@ -55,7 +58,10 @@ * * @package xpdo */ -class xPDO { +class xPDO implements LoggerAwareInterface +{ + use LoggerAwareTrait; + /**#@+ * Constants */ @@ -96,10 +102,25 @@ class xPDO { const OPT_VALIDATE_ON_SAVE = 'validate_on_save'; const OPT_VALIDATOR_CLASS = 'validator_class'; + /** + * @deprecated Prefer Psr\Log\LogLevel::EMERGENCY + */ const LOG_LEVEL_FATAL = 0; + /** + * @deprecated Prefer Psr\Log\LogLevel::ERROR + */ const LOG_LEVEL_ERROR = 1; + /** + * @deprecated Prefer Psr\Log\LogLevel::WARNING + */ const LOG_LEVEL_WARN = 2; + /** + * @deprecated Prefer Psr\Log\LogLevel::INFO + */ const LOG_LEVEL_INFO = 3; + /** + * @deprecated Prefer Psr\Log\LogLevel::DEBUG + */ const LOG_LEVEL_DEBUG = 4; const SCHEMA_VERSION = '3.0'; @@ -277,8 +298,20 @@ public function __construct($dsn, $username= '', $password= '', $options= array( if ($this->services === null) { $this->services = new xPDOContainer(); } - $this->setLogLevel($this->getOption('log_level', null, xPDO::LOG_LEVEL_FATAL, true)); - $this->setLogTarget($this->getOption('log_target', null, php_sapi_name() === 'cli' ? 'ECHO' : 'HTML', true)); + $this->logLevel = $this->getOption('log_level', null, xPDO::LOG_LEVEL_FATAL, true); + $this->logTarget = $this->getOption('log_target', null, php_sapi_name() === 'cli' ? 'ECHO' : 'HTML', true); + + if (!$this->services->has(LoggerInterface::class)) { + $this->services->add(LoggerInterface::class, function() { + return new xPDOLogger( + $this->getCacheManager(), + $this->logTarget, + $this->logLevel + ); + }); + } + $this->logger = $this->services->get(LoggerInterface::class); + if (!empty($dsn)) { $this->addConnection($dsn, $username, $password, $this->config, $driverOptions); } @@ -1964,15 +1997,23 @@ public function setDebug($v= true) { * * @param integer $level The logging level to switch to. * @return integer The previous log level. + * @deprecated To change the log settings, provide a new logger via setLogger(LoggerInterface $logger). Will be removed in v4. */ public function setLogLevel($level= xPDO::LOG_LEVEL_FATAL) { $oldLevel = $this->logLevel; - $this->logLevel= intval($level); + if ($this->logger instanceof xPDOLogger) { + $this->logger->setLogLevel($level); + $this->logLevel= intval($level); + } + else { + $this->logger->error('Trying to change the log level on a custom logger implementation of type ' . get_class($this->logger)); + } return $oldLevel; } /** * @return integer The current log level. + * @deprecated The log level is only available with the native xPDOLogger, not with custom loggers. Will be removed in v4. */ public function getLogLevel() { return $this->logLevel; @@ -1983,27 +2024,34 @@ public function getLogLevel() { * * Valid target values include: * * * @param string $target An identifier indicating the target of the logging. * @return mixed The previous log target. + * @deprecated To change the log settings, provide a new logger via setLogger(LoggerInterface $logger). Will be removed in v4. */ - public function setLogTarget($target= 'ECHO') { + public function setLogTarget($target = 'ECHO') + { $oldTarget = $this->logTarget; - $this->logTarget= $target; + if ($this->logger instanceof xPDOLogger) { + $this->logger->setLogTarget($target); + $this->logTarget = $target; + } + else { + $this->logger->error('Trying to change the log target on a custom logger implementation of type ' . get_class($this->logger)); + } return $oldTarget; } /** - * @return integer The current log level. + * @return string|array The current log target. + * @deprecated The log target is only available with the native xPDOLogger, not with custom loggers. Will be removed in v4. */ public function getLogTarget() { return $this->logTarget; @@ -2020,6 +2068,7 @@ public function getLogTarget() { * @param string $file A filename in which the log event occurred. * @param string $line A line number to help locate the source of the event * within the indicated file. + * @deprecated Prefer using the installed Logger instance through $this->logger or the DI container. */ public function log($level, $msg, $target= '', $def= '', $file= '', $line= '') { $this->_log($level, $msg, $target, $def, $file, $line); @@ -2037,76 +2086,39 @@ public function log($level, $msg, $target= '', $def= '', $file= '', $line= '') { * @param string $line A line number to help locate the source of the event * within the indicated file. */ - protected function _log($level, $msg, $target= '', $def= '', $file= '', $line= '') { - if ($level !== xPDO::LOG_LEVEL_FATAL && $level > $this->logLevel && $this->_debug !== true) { - return; - } - if (empty ($target)) { - $target = $this->logTarget; - } - $targetOptions = array(); - if (is_array($target)) { - if (isset($target['options'])) $targetOptions =& $target['options']; - $target = isset($target['target']) ? $target['target'] : 'ECHO'; - } - if (empty($file)) { - if (version_compare(phpversion(), '5.4.0', '>=')) { - $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3); - } elseif (version_compare(phpversion(), '5.3.6', '>=')) { - $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); - } else { - $backtrace = debug_backtrace(); - } + protected function _log($level, $msg, $target= '', $def= '', $file= '', $line= '') + { + $context = array_filter([ + 'def' => $def, + 'file' => $file, + 'line' => $line, + ]); + + if (empty($context['file'])) { + $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3); if ($backtrace && isset($backtrace[2])) { - $file = $backtrace[2]['file']; - $line = $backtrace[2]['line']; + $context['file'] = $backtrace[2]['file']; + $context['line'] = $backtrace[2]['line']; } } - if (empty($file) && isset($_SERVER['SCRIPT_NAME'])) { - $file = $_SERVER['SCRIPT_NAME']; + + if (empty($context['file']) && isset($_SERVER['SCRIPT_NAME'])) { + $context['file'] = $_SERVER['SCRIPT_NAME']; } - if ($level === xPDO::LOG_LEVEL_FATAL) { - while (ob_get_level() && @ob_end_flush()) {} - exit ('[' . strftime('%Y-%m-%d %H:%M:%S') . '] (' . $this->_getLogLevel($level) . $def . $file . $line . ') ' . $msg . "\n" . ($this->getDebug() === true ? '
' . "\n" . print_r(debug_backtrace(), true) . "\n" . '
' : '')); + + // If the target is different, adjust it + $previousLogger = $this->logger; + $oldTarget = $this->logTarget; + if (!empty($target) && $this->logger instanceof xPDOLogger) { + $this->logger->setLogTarget($target); } - if ($this->_debug === true || $level <= $this->logLevel) { - @ob_start(); - if (!empty ($def)) { - $def= " in {$def}"; - } - if (!empty ($file)) { - $file= " @ {$file}"; - } - if (!empty ($line)) { - $line= " : {$line}"; - } - switch ($target) { - case 'HTML' : - echo '
[' . strftime('%Y-%m-%d %H:%M:%S') . '] (' . $this->_getLogLevel($level) . $def . $file . $line . ')
' . $msg . '
' . "\n"; - break; - default : - echo '[' . strftime('%Y-%m-%d %H:%M:%S') . '] (' . $this->_getLogLevel($level) . $def . $file . $line . ') ' . $msg . "\n"; - } - $content= @ob_get_contents(); - @ob_end_clean(); - if ($target=='FILE' && $this->getCacheManager()) { - $filename = isset($targetOptions['filename']) ? $targetOptions['filename'] : 'error.log'; - $filepath = isset($targetOptions['filepath']) ? $targetOptions['filepath'] : $this->getCachePath() . Cache\xPDOCacheManager::LOG_DIR; - $this->cacheManager->writeFile($filepath . $filename, $content, 'a'); - } elseif ($target=='ARRAY' && isset($targetOptions['var']) && is_array($targetOptions['var'])) { - $targetOptions['var'][] = $content; - } elseif ($target=='ARRAY_EXTENDED' && isset($targetOptions['var']) && is_array($targetOptions['var'])) { - $targetOptions['var'][] = array( - 'content' => $content, - 'level' => $this->_getLogLevel($level), - 'msg' => $msg, - 'def' => $def, - 'file' => $file, - 'line' => $line - ); - } else { - echo $content; - } + + // Pass the message on to the PSR-3 logger + $this->logger->log($level, $msg, $context); + + // Restore the target if it was changed + if (!empty($oldTarget) && $this->logger instanceof xPDOLogger) { + $this->logger->setLogTarget($oldTarget); } } @@ -2770,4 +2782,9 @@ protected function sanitizePKCriteria($className, &$criteria) { } } } + + public function getLogger(): LoggerInterface + { + return $this->logger; + } } diff --git a/src/xPDO/xPDOLogger.php b/src/xPDO/xPDOLogger.php new file mode 100644 index 0000000..8633433 --- /dev/null +++ b/src/xPDO/xPDOLogger.php @@ -0,0 +1,329 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace xPDO; + +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; + +/** + * Implements a minimal PSR-3 compatible logger if none are provided. + * + * @package xPDO + */ +class xPDOLogger implements LoggerInterface +{ + /** + * @var \xPDO\Cache\xPDOCacheManager + */ + protected $cacheManager; + /** + * @var array|string + */ + protected $target; + /** + * @var int|string + */ + protected $level; + + /** + * + * Valid target values include: + * + * + * @param \xPDOCacheManager $cacheManager + * @param string|array $target String values: + * @param int|string $level Either an xPDO::LOG_LEVEL_* constant or a LogLevel::* constant + */ + public function __construct(\xPDO\Cache\xPDOCacheManager $cacheManager, $target = 'ECHO', $level = LogLevel::ERROR) + { + $this->cacheManager = $cacheManager; + $this->target = $target; + $this->level = is_int($level) ? $this->translateLevel($level) : $level; + } + + /** + * Logs with an arbitrary level. + * + * @param mixed $level + * @param string|\Stringable $message + * @param mixed[] $context + * + * @return void + * + * @throws \Psr\Log\InvalidArgumentException + */ + public function log($level, $message, array $context = []): void + { + // Convert xPDO log constants (which resolves to integers 0-4 inclusive) to LogLevel constants + if (is_int($level)) { + $level = $this->translateLevel($level); + } + + // Only process log levels when the provided severity exceeds the configured minimum + if (!$this->isHigherThanMinimum($level)) { + return; + } + + // Handle target options for FILE and ARRAY/ARRAY_EXTENDED + $targetOptions = array(); + $target = $this->target; + if (is_array($target)) { + if (isset($target['options'])) { + $targetOptions =& $target['options']; + } + $target = isset($target['target']) ? $target['target'] : 'ECHO'; + } + + // Automatically identify the file and line if not set + if (empty($context['file'])) { + $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3); + if ($backtrace && isset($backtrace[1])) { + $context['file'] = $backtrace[1]['file']; + $context['line'] = $backtrace[1]['line']; + } + } + + if (empty($context['file']) && isset($_SERVER['SCRIPT_NAME'])) { + $context['file'] = $_SERVER['SCRIPT_NAME']; + } + + $def = strtoupper($level); + + if (!empty($context['def'])) { + $def .= " in {$context['def']}"; + unset($context['def']); + } + if (!empty($context['file'])) { + $def .= " @ {$context['file']}"; + unset($context['file']); + } + if (!empty($context['line'])) { + $def .= " : {$context['line']}"; + unset($context['line']); + } + + // If an emergency was triggered, end immediately. + if ($level === LogLevel::EMERGENCY) { + while (ob_get_level() && @ob_end_flush()) {} + exit ('[' . strftime('%Y-%m-%d %H:%M:%S') . '] (' . $def . ') ' . $message . "\n" . json_encode($context, JSON_PRETTY_PRINT) . "\n" . ($this->getDebug() === true ? '
' . "\n" . print_r(debug_backtrace(), true) . "\n" . '
' : '')); + } + + // Process into format: [timestamp] (SEVERITY) msg {context} + $content = ($target === 'HTML') + ? '
[' . strftime('%Y-%m-%d %H:%M:%S') . '] (' . $def . ')
' . $message . "\n" . json_encode($context, JSON_PRETTY_PRINT)  . '
' . "\n" + : '[' . strftime('%Y-%m-%d %H:%M:%S') . '] (' . $def . ') ' . $message . ' ' . json_encode($context) . "\n"; + + if ($target === 'FILE') { + $filename = isset($targetOptions['filename']) ? $targetOptions['filename'] : 'error.log'; + $filepath = isset($targetOptions['filepath']) ? $targetOptions['filepath'] : $this->cacheManager->getCachePath() . Cache\xPDOCacheManager::LOG_DIR; + $this->cacheManager->writeFile($filepath . $filename, $content, 'a'); + } + elseif ($target === 'ARRAY' && isset($targetOptions['var']) && is_array($targetOptions['var'])) { + $targetOptions['var'][] = $content; + } + elseif ($target === 'ARRAY_EXTENDED' && isset($targetOptions['var']) && is_array($targetOptions['var'])) { + $targetOptions['var'][] = [ + 'content' => $content, + 'level' => strtoupper($level), + 'msg' => $message, + 'def' => $def, + ] + $context; + } + else { + echo $content; + } + } + + /** + * System is unusable. + * + * @param string|\Stringable $message + * @param mixed[] $context + * + * @return void + */ + public function emergency($message, array $context = []): void + { + $this->log(LogLevel::EMERGENCY, $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|\Stringable $message + * @param mixed[] $context + * + * @return void + */ + public function alert($message, array $context = []): void + { + $this->log(LogLevel::ALERT, $message, $context); + } + + /** + * Critical conditions. + * + * Example: Application component unavailable, unexpected exception. + * + * @param string|\Stringable $message + * @param mixed[] $context + * + * @return void + */ + public function critical($message, array $context = []): void + { + $this->log(LogLevel::CRITICAL, $message, $context); + } + + /** + * Runtime errors that do not require immediate action but should typically + * be logged and monitored. + * + * @param string|\Stringable $message + * @param mixed[] $context + * + * @return void + */ + public function error($message, array $context = []): void + { + $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|\Stringable $message + * @param mixed[] $context + * + * @return void + */ + public function warning($message, array $context = []): void + { + $this->log(LogLevel::WARNING, $message, $context); + } + + /** + * Normal but significant events. + * + * @param string|\Stringable $message + * @param mixed[] $context + * + * @return void + */ + public function notice($message, array $context = []): void + { + $this->log(LogLevel::NOTICE, $message, $context); + } + + /** + * Interesting events. + * + * Example: User logs in, SQL logs. + * + * @param string|\Stringable $message + * @param mixed[] $context + * + * @return void + */ + public function info($message, array $context = []): void + { + $this->log(LogLevel::INFO, $message, $context); + } + + /** + * Detailed debug information. + * + * @param string|\Stringable $message + * @param mixed[] $context + * + * @return void + */ + public function debug($message, array $context = []): void + { + $this->log(LogLevel::DEBUG, $message, $context); + } + + private function translateLevel(int $level) + { + switch ($level) { + case xPDO::LOG_LEVEL_FATAL: + return LogLevel::EMERGENCY; + case xPDO::LOG_LEVEL_ERROR: + return LogLevel::ERROR; + case xPDO::LOG_LEVEL_WARN: + return LogLevel::WARNING; + case xPDO::LOG_LEVEL_INFO: + return LogLevel::INFO; + case xPDO::LOG_LEVEL_DEBUG: + return LogLevel::DEBUG; + } + + return $level; + } + + private function isHigherThanMinimum($level): bool + { + switch ($this->level) { + case LogLevel::EMERGENCY: + return $level === LogLevel::EMERGENCY; + case LogLevel::ALERT: + return in_array($level, [LogLevel::EMERGENCY, LogLevel::ALERT], true); + case LogLevel::CRITICAL: + return in_array($level, [LogLevel::EMERGENCY, LogLevel::ALERT, LogLevel::CRITICAL], true); + case LogLevel::ERROR: + return in_array($level, [LogLevel::EMERGENCY, LogLevel::ALERT, LogLevel::CRITICAL, LogLevel::ERROR], true); + case LogLevel::WARNING: + return in_array($level, [LogLevel::EMERGENCY, LogLevel::ALERT, LogLevel::CRITICAL, LogLevel::ERROR, LogLevel::WARNING], true); + case LogLevel::NOTICE: + return in_array($level, [LogLevel::EMERGENCY, LogLevel::ALERT, LogLevel::CRITICAL, LogLevel::ERROR, LogLevel::WARNING, LogLevel::NOTICE], true); + case LogLevel::INFO: + return in_array($level, [LogLevel::EMERGENCY, LogLevel::ALERT, LogLevel::CRITICAL, LogLevel::ERROR, LogLevel::WARNING, LogLevel::NOTICE, LogLevel::INFO], true); + case LogLevel::DEBUG: + return in_array($level, [LogLevel::EMERGENCY, LogLevel::ALERT, LogLevel::CRITICAL, LogLevel::ERROR, LogLevel::WARNING, LogLevel::NOTICE, LogLevel::INFO, LogLevel::DEBUG], true); + default: + // If the level is unrecognised, always log everything. + return true; + } + } + + public function getLogTarget() + { + return $this->target; + } + + public function setLogTarget($target) + { + $this->target = $target; + } + + public function getLogLevel() + { + return $this->target; + } + + public function setLogLevel($level) + { + $this->level = is_int($level) ? $this->translateLevel($level) : $level; + } +}