Changeset View
Changeset View
Standalone View
Standalone View
src/error/PhutilErrorHandler.php
- This file was added.
| <?php | |||||
| /** | |||||
| * Improve PHP error logs and optionally route errors, exceptions and debugging | |||||
| * information to a central listener. | |||||
| * | |||||
| * This class takes over the PHP error and exception handlers when you call | |||||
| * ##PhutilErrorHandler::initialize()## and forwards all debugging information | |||||
| * to a listener you install with ##PhutilErrorHandler::setErrorListener()##. | |||||
| * | |||||
| * To use PhutilErrorHandler, which will enhance the messages printed to the | |||||
| * PHP error log, just initialize it: | |||||
| * | |||||
| * PhutilErrorHandler::initialize(); | |||||
| * | |||||
| * To additionally install a custom listener which can print error information | |||||
| * to some other file or console, register a listener: | |||||
| * | |||||
| * PhutilErrorHandler::setErrorListener($some_callback); | |||||
| * | |||||
| * For information on writing an error listener, see | |||||
| * @{function:phutil_error_listener_example}. Providing a listener is optional, | |||||
| * you will benefit from improved error logs even without one. | |||||
| * | |||||
| * Phabricator uses this class to drive the DarkConsole "Error Log" plugin. | |||||
| * | |||||
| * @task config Configuring Error Dispatch | |||||
| * @task exutil Exception Utilities | |||||
| * @task trap Error Traps | |||||
| * @task internal Internals | |||||
| */ | |||||
| final class PhutilErrorHandler extends Phobject { | |||||
| private static $errorListener = null; | |||||
| private static $initialized = false; | |||||
| private static $traps = array(); | |||||
| const EXCEPTION = 'exception'; | |||||
| const ERROR = 'error'; | |||||
| const PHLOG = 'phlog'; | |||||
| const DEPRECATED = 'deprecated'; | |||||
| /* -( Configuring Error Dispatch )----------------------------------------- */ | |||||
| /** | |||||
| * Registers this class as the PHP error and exception handler. This will | |||||
| * overwrite any previous handlers! | |||||
| * | |||||
| * @return void | |||||
| * @task config | |||||
| */ | |||||
| public static function initialize() { | |||||
| self::$initialized = true; | |||||
| set_error_handler(array(__CLASS__, 'handleError')); | |||||
| set_exception_handler(array(__CLASS__, 'handleException')); | |||||
| } | |||||
| /** | |||||
| * Provide an optional listener callback which will receive all errors, | |||||
| * exceptions and debugging messages. It can then print them to a web console, | |||||
| * for example. | |||||
| * | |||||
| * See @{function:phutil_error_listener_example} for details about the | |||||
| * callback parameters and operation. | |||||
| * | |||||
| * @return void | |||||
| * @task config | |||||
| */ | |||||
| public static function setErrorListener($listener) { | |||||
| self::$errorListener = $listener; | |||||
| } | |||||
| /* -( Exception Utilities )------------------------------------------------ */ | |||||
| /** | |||||
| * Gets the previous exception of a nested exception. Prior to PHP 5.3 you | |||||
| * can use @{class:PhutilProxyException} to nest exceptions; after PHP 5.3 | |||||
| * all exceptions are nestable. | |||||
| * | |||||
| * @param Exception|Throwable Exception to unnest. | |||||
| * @return Exception|Throwable|null Previous exception, if one exists. | |||||
| * @task exutil | |||||
| */ | |||||
| public static function getPreviousException($ex) { | |||||
| if (method_exists($ex, 'getPrevious')) { | |||||
| return $ex->getPrevious(); | |||||
| } | |||||
| if (method_exists($ex, 'getPreviousException')) { | |||||
| return $ex->getPreviousException(); | |||||
| } | |||||
| return null; | |||||
| } | |||||
| /** | |||||
| * Find the most deeply nested exception from a possibly-nested exception. | |||||
| * | |||||
| * @param Exception|Throwable A possibly-nested exception. | |||||
| * @return Exception|Throwable Deepest exception in the nest. | |||||
| * @task exutil | |||||
| */ | |||||
| public static function getRootException($ex) { | |||||
| $root = $ex; | |||||
| while (self::getPreviousException($root)) { | |||||
| $root = self::getPreviousException($root); | |||||
| } | |||||
| return $root; | |||||
| } | |||||
| /* -( Trapping Errors )---------------------------------------------------- */ | |||||
| /** | |||||
| * Adds an error trap. Normally you should not invoke this directly; | |||||
| * @{class:PhutilErrorTrap} registers itself on construction. | |||||
| * | |||||
| * @param PhutilErrorTrap Trap to add. | |||||
| * @return void | |||||
| * @task trap | |||||
| */ | |||||
| public static function addErrorTrap(PhutilErrorTrap $trap) { | |||||
| $key = $trap->getTrapKey(); | |||||
| self::$traps[$key] = $trap; | |||||
| } | |||||
| /** | |||||
| * Removes an error trap. Normally you should not invoke this directly; | |||||
| * @{class:PhutilErrorTrap} deregisters itself on destruction. | |||||
| * | |||||
| * @param PhutilErrorTrap Trap to remove. | |||||
| * @return void | |||||
| * @task trap | |||||
| */ | |||||
| public static function removeErrorTrap(PhutilErrorTrap $trap) { | |||||
| $key = $trap->getTrapKey(); | |||||
| unset(self::$traps[$key]); | |||||
| } | |||||
| /* -( Internals )---------------------------------------------------------- */ | |||||
| /** | |||||
| * Determine if PhutilErrorHandler has been initialized. | |||||
| * | |||||
| * @return bool True if initialized. | |||||
| * @task internal | |||||
| */ | |||||
| public static function hasInitialized() { | |||||
| return self::$initialized; | |||||
| } | |||||
| /** | |||||
| * Handles PHP errors and dispatches them forward. This is a callback for | |||||
| * ##set_error_handler()##. You should not call this function directly; use | |||||
| * @{function:phlog} to print debugging messages or ##trigger_error()## to | |||||
| * trigger PHP errors. | |||||
| * | |||||
| * This handler converts E_RECOVERABLE_ERROR messages from violated typehints | |||||
| * into @{class:InvalidArgumentException}s. | |||||
| * | |||||
| * This handler converts other E_RECOVERABLE_ERRORs into | |||||
| * @{class:RuntimeException}s. | |||||
| * | |||||
| * This handler converts E_NOTICE messages from uses of undefined variables | |||||
| * into @{class:RuntimeException}s. | |||||
| * | |||||
| * @param int Error code. | |||||
| * @param string Error message. | |||||
| * @param string File where the error occurred. | |||||
| * @param int Line on which the error occurred. | |||||
| * @param wild Error context information. | |||||
| * @return void | |||||
| * @task internal | |||||
| */ | |||||
| public static function handleError($num, $str, $file, $line, $ctx) { | |||||
| foreach (self::$traps as $trap) { | |||||
| $trap->addError($num, $str, $file, $line, $ctx); | |||||
| } | |||||
| if ((error_reporting() & $num) == 0) { | |||||
| // Respect the use of "@" to silence warnings: if this error was | |||||
| // emitted from a context where "@" was in effect, the | |||||
| // value returned by error_reporting() will be 0. This is the | |||||
| // recommended way to check for this, see set_error_handler() docs | |||||
| // on php.net. | |||||
| return false; | |||||
| } | |||||
| // Convert typehint failures into exceptions. | |||||
| if (preg_match('/^Argument (\d+) passed to (\S+) must be/', $str)) { | |||||
| throw new InvalidArgumentException($str); | |||||
| } | |||||
| // Convert other E_RECOVERABLE_ERRORs into generic runtime exceptions. | |||||
| if ($num == E_RECOVERABLE_ERROR) { | |||||
| throw new RuntimeException($str); | |||||
| } | |||||
| // Convert uses of undefined variables into exceptions. | |||||
| if (preg_match('/^Undefined variable: /', $str)) { | |||||
| throw new RuntimeException($str); | |||||
| } | |||||
| // Convert uses of undefined properties into exceptions. | |||||
| if (preg_match('/^Undefined property: /', $str)) { | |||||
| throw new RuntimeException($str); | |||||
| } | |||||
| // Convert undefined constants into exceptions. Usually this means there | |||||
| // is a missing `$` and the program is horribly broken. | |||||
| if (preg_match('/^Use of undefined constant /', $str)) { | |||||
| throw new RuntimeException($str); | |||||
| } | |||||
| $trace = debug_backtrace(); | |||||
| array_shift($trace); | |||||
| self::dispatchErrorMessage( | |||||
| self::ERROR, | |||||
| $str, | |||||
| array( | |||||
| 'file' => $file, | |||||
| 'line' => $line, | |||||
| 'context' => $ctx, | |||||
| 'error_code' => $num, | |||||
| 'trace' => $trace, | |||||
| )); | |||||
| } | |||||
| /** | |||||
| * Handles PHP exceptions and dispatches them forward. This is a callback for | |||||
| * ##set_exception_handler()##. You should not call this function directly; | |||||
| * to print exceptions, pass the exception object to @{function:phlog}. | |||||
| * | |||||
| * @param Exception|Throwable Uncaught exception object. | |||||
| * @return void | |||||
| * @task internal | |||||
| */ | |||||
| public static function handleException($ex) { | |||||
| self::dispatchErrorMessage( | |||||
| self::EXCEPTION, | |||||
| $ex, | |||||
| array( | |||||
| 'file' => $ex->getFile(), | |||||
| 'line' => $ex->getLine(), | |||||
| 'trace' => self::getExceptionTrace($ex), | |||||
| 'catch_trace' => debug_backtrace(), | |||||
| )); | |||||
| // Normally, PHP exits with code 255 after an uncaught exception is thrown. | |||||
| // However, if we install an exception handler (as we have here), it exits | |||||
| // with code 0 instead. Script execution terminates after this function | |||||
| // exits in either case, so exit explicitly with the correct exit code. | |||||
| exit(255); | |||||
| } | |||||
| /** | |||||
| * Output a stacktrace to the PHP error log. | |||||
| * | |||||
| * @param trace A stacktrace, e.g. from debug_backtrace(); | |||||
| * @return void | |||||
| * @task internal | |||||
| */ | |||||
| public static function outputStacktrace($trace) { | |||||
| $lines = explode("\n", self::formatStacktrace($trace)); | |||||
| foreach ($lines as $line) { | |||||
| error_log($line); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * Format a stacktrace for output. | |||||
| * | |||||
| * @param trace A stacktrace, e.g. from debug_backtrace(); | |||||
| * @return string Human-readable trace. | |||||
| * @task internal | |||||
| */ | |||||
| public static function formatStacktrace($trace) { | |||||
| $result = array(); | |||||
| $libinfo = self::getLibraryVersions(); | |||||
| if ($libinfo) { | |||||
| foreach ($libinfo as $key => $dict) { | |||||
| $info = array(); | |||||
| foreach ($dict as $dkey => $dval) { | |||||
| $info[] = $dkey.'='.$dval; | |||||
| } | |||||
| $libinfo[$key] = $key.'('.implode(', ', $info).')'; | |||||
| } | |||||
| $result[] = implode(', ', $libinfo); | |||||
| } | |||||
| foreach ($trace as $key => $entry) { | |||||
| $line = ' #'.$key.' '; | |||||
| if (!empty($entry['xid'])) { | |||||
| if ($entry['xid'] != 1) { | |||||
| $line .= '<#'.$entry['xid'].'> '; | |||||
| } | |||||
| } | |||||
| if (isset($entry['class'])) { | |||||
| $line .= $entry['class'].'::'; | |||||
| } | |||||
| $line .= idx($entry, 'function', ''); | |||||
| if (isset($entry['args'])) { | |||||
| $args = array(); | |||||
| foreach ($entry['args'] as $arg) { | |||||
| // NOTE: Print out object types, not values. Values sometimes contain | |||||
| // sensitive information and are usually not particularly helpful | |||||
| // for debugging. | |||||
| $type = (gettype($arg) == 'object') | |||||
| ? get_class($arg) | |||||
| : gettype($arg); | |||||
| $args[] = $type; | |||||
| } | |||||
| $line .= '('.implode(', ', $args).')'; | |||||
| } | |||||
| if (isset($entry['file'])) { | |||||
| $file = self::adjustFilePath($entry['file']); | |||||
| $line .= ' called at ['.$file.':'.$entry['line'].']'; | |||||
| } | |||||
| $result[] = $line; | |||||
| } | |||||
| return implode("\n", $result); | |||||
| } | |||||
| /** | |||||
| * All different types of error messages come here before they are | |||||
| * dispatched to the listener; this method also prints them to the PHP error | |||||
| * log. | |||||
| * | |||||
| * @param const Event type constant. | |||||
| * @param wild Event value. | |||||
| * @param dict Event metadata. | |||||
| * @return void | |||||
| * @task internal | |||||
| */ | |||||
| public static function dispatchErrorMessage($event, $value, $metadata) { | |||||
| $timestamp = strftime('%Y-%m-%d %H:%M:%S'); | |||||
| switch ($event) { | |||||
| case self::ERROR: | |||||
| $default_message = sprintf( | |||||
| '[%s] ERROR %d: %s at [%s:%d]', | |||||
| $timestamp, | |||||
| $metadata['error_code'], | |||||
| $value, | |||||
| $metadata['file'], | |||||
| $metadata['line']); | |||||
| $metadata['default_message'] = $default_message; | |||||
| error_log($default_message); | |||||
| self::outputStacktrace($metadata['trace']); | |||||
| break; | |||||
| case self::EXCEPTION: | |||||
| $messages = array(); | |||||
| $current = $value; | |||||
| do { | |||||
| $messages[] = '('.get_class($current).') '.$current->getMessage(); | |||||
| } while ($current = self::getPreviousException($current)); | |||||
| $messages = implode(' {>} ', $messages); | |||||
| if (strlen($messages) > 4096) { | |||||
| $messages = substr($messages, 0, 4096).'...'; | |||||
| } | |||||
| $default_message = sprintf( | |||||
| '[%s] EXCEPTION: %s at [%s:%d]', | |||||
| $timestamp, | |||||
| $messages, | |||||
| self::adjustFilePath(self::getRootException($value)->getFile()), | |||||
| self::getRootException($value)->getLine()); | |||||
| $metadata['default_message'] = $default_message; | |||||
| error_log($default_message); | |||||
| self::outputStacktrace($metadata['trace']); | |||||
| break; | |||||
| case self::PHLOG: | |||||
| $default_message = sprintf( | |||||
| '[%s] PHLOG: %s at [%s:%d]', | |||||
| $timestamp, | |||||
| PhutilReadableSerializer::printShort($value), | |||||
| $metadata['file'], | |||||
| $metadata['line']); | |||||
| $metadata['default_message'] = $default_message; | |||||
| error_log($default_message); | |||||
| break; | |||||
| case self::DEPRECATED: | |||||
| $default_message = sprintf( | |||||
| '[%s] DEPRECATED: %s is deprecated; %s', | |||||
| $timestamp, | |||||
| $value, | |||||
| $metadata['why']); | |||||
| $metadata['default_message'] = $default_message; | |||||
| error_log($default_message); | |||||
| break; | |||||
| default: | |||||
| error_log(pht('Unknown event %s', $event)); | |||||
| break; | |||||
| } | |||||
| if (self::$errorListener) { | |||||
| static $handling_error; | |||||
| if ($handling_error) { | |||||
| error_log( | |||||
| 'Error handler was reentered, some errors were not passed to the '. | |||||
| 'listener.'); | |||||
| return; | |||||
| } | |||||
| $handling_error = true; | |||||
| call_user_func(self::$errorListener, $event, $value, $metadata); | |||||
| $handling_error = false; | |||||
| } | |||||
| } | |||||
| public static function adjustFilePath($path) { | |||||
| // Compute known library locations so we can emit relative paths if the | |||||
| // file resides inside a known library. This is a little cleaner to read, | |||||
| // and limits the number of false positives we get about full path | |||||
| // disclosure via HackerOne. | |||||
| $bootloader = PhutilBootloader::getInstance(); | |||||
| $libraries = $bootloader->getAllLibraries(); | |||||
| $roots = array(); | |||||
| foreach ($libraries as $library) { | |||||
| $root = $bootloader->getLibraryRoot($library); | |||||
| // For these libraries, the effective root is one level up. | |||||
| switch ($library) { | |||||
| case 'arcanist': | |||||
| case 'phabricator': | |||||
| $root = dirname($root); | |||||
| break; | |||||
| } | |||||
| if (!strncmp($root, $path, strlen($root))) { | |||||
| return '<'.$library.'>'.substr($path, strlen($root)); | |||||
| } | |||||
| } | |||||
| return $path; | |||||
| } | |||||
| public static function getLibraryVersions() { | |||||
| $libinfo = array(); | |||||
| $bootloader = PhutilBootloader::getInstance(); | |||||
| foreach ($bootloader->getAllLibraries() as $library) { | |||||
| $root = phutil_get_library_root($library); | |||||
| $try_paths = array( | |||||
| $root, | |||||
| dirname($root), | |||||
| ); | |||||
| $libinfo[$library] = array(); | |||||
| $get_refs = array('master'); | |||||
| foreach ($try_paths as $try_path) { | |||||
| // Try to read what the HEAD of the repository is pointed at. This is | |||||
| // normally the name of a branch ("ref"). | |||||
| $try_file = $try_path.'/.git/HEAD'; | |||||
| if (@file_exists($try_file)) { | |||||
| $head = @file_get_contents($try_file); | |||||
| $matches = null; | |||||
| if (preg_match('(^ref: refs/heads/(.*)$)', trim($head), $matches)) { | |||||
| $libinfo[$library]['head'] = trim($matches[1]); | |||||
| $get_refs[] = trim($matches[1]); | |||||
| } else { | |||||
| $libinfo[$library]['head'] = trim($head); | |||||
| } | |||||
| break; | |||||
| } | |||||
| } | |||||
| // Try to read which commit relevant branch heads are at. | |||||
| foreach (array_unique($get_refs) as $ref) { | |||||
| foreach ($try_paths as $try_path) { | |||||
| $try_file = $try_path.'/.git/refs/heads/'.$ref; | |||||
| if (@file_exists($try_file)) { | |||||
| $hash = @file_get_contents($try_file); | |||||
| if ($hash) { | |||||
| $libinfo[$library]['ref.'.$ref] = substr(trim($hash), 0, 12); | |||||
| break; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| // Look for extension files. | |||||
| $custom = @scandir($root.'/extensions/'); | |||||
| if ($custom) { | |||||
| $count = 0; | |||||
| foreach ($custom as $custom_path) { | |||||
| if (preg_match('/\.php$/', $custom_path)) { | |||||
| $count++; | |||||
| } | |||||
| } | |||||
| if ($count) { | |||||
| $libinfo[$library]['custom'] = $count; | |||||
| } | |||||
| } | |||||
| } | |||||
| ksort($libinfo); | |||||
| return $libinfo; | |||||
| } | |||||
| /** | |||||
| * Get a full trace across all proxied and aggregated exceptions. | |||||
| * | |||||
| * This attempts to build a set of stack frames which completely represent | |||||
| * all of the places an exception came from, even if it came from multiple | |||||
| * origins and has been aggregated or proxied. | |||||
| * | |||||
| * @param Exception|Throwable Exception to retrieve a trace for. | |||||
| * @return list<wild> List of stack frames. | |||||
| */ | |||||
| public static function getExceptionTrace($ex) { | |||||
| $id = 1; | |||||
| // Keep track of discovered exceptions which we need to build traces for. | |||||
| $stack = array( | |||||
| array($id, $ex), | |||||
| ); | |||||
| $frames = array(); | |||||
| while ($info = array_shift($stack)) { | |||||
| list($xid, $ex) = $info; | |||||
| // We're going from top-level exception down in bredth-first order, but | |||||
| // want to build a trace in approximately standard order (deepest part of | |||||
| // the call stack to most shallow) so we need to reverse each list of | |||||
| // frames and then reverse everything at the end. | |||||
| $ex_frames = array_reverse($ex->getTrace()); | |||||
| $ex_frames = array_values($ex_frames); | |||||
| $last_key = (count($ex_frames) - 1); | |||||
| foreach ($ex_frames as $frame_key => $frame) { | |||||
| $frame['xid'] = $xid; | |||||
| // If this is a child/previous exception and we're on the deepest frame | |||||
| // and missing file/line data, fill it in from the exception itself. | |||||
| if ($xid > 1 && ($frame_key == $last_key)) { | |||||
| if (empty($frame['file'])) { | |||||
| $frame['file'] = $ex->getFile(); | |||||
| $frame['line'] = $ex->getLine(); | |||||
| } | |||||
| } | |||||
| // Since the exceptions are likely to share the most shallow frames, | |||||
| // try to add those to the trace only once. | |||||
| if (isset($frame['file']) && isset($frame['line'])) { | |||||
| $signature = $frame['file'].':'.$frame['line']; | |||||
| if (empty($frames[$signature])) { | |||||
| $frames[$signature] = $frame; | |||||
| } | |||||
| } else { | |||||
| $frames[] = $frame; | |||||
| } | |||||
| } | |||||
| // If this is a proxy exception, add the proxied exception. | |||||
| $prev = self::getPreviousException($ex); | |||||
| if ($prev) { | |||||
| $stack[] = array(++$id, $prev); | |||||
| } | |||||
| // If this is an aggregate exception, add the child exceptions. | |||||
| if ($ex instanceof PhutilAggregateException) { | |||||
| foreach ($ex->getExceptions() as $child) { | |||||
| $stack[] = array(++$id, $child); | |||||
| } | |||||
| } | |||||
| } | |||||
| return array_values(array_reverse($frames)); | |||||
| } | |||||
| } | |||||