1
0
Fork 0
mirror of https://we.phorge.it/source/arcanist.git synced 2024-11-26 00:32:41 +01:00
phorge-arcanist/src/error/PhutilErrorHandler.php
Aviv Eyal 0c0b9644a6 Rebrand: Change Server name
Summary:
Use the name "Phorge" as the defined platform.

Also prepare to rename the core library "phorge" rather then "phabricator" - see next diff.

T15006

Test Plan: Deployed change, tooltip for "Config" shows "Configure Phorge"

Reviewers: O1 Blessed Committers, speck

Reviewed By: O1 Blessed Committers, speck

Subscribers: speck, tobiaswiese, valerio.bozzolan, Matthew

Maniphest Tasks: T15006

Differential Revision: https://we.phorge.it/D25046
2022-08-25 01:24:41 -07:00

612 lines
18 KiB
PHP

<?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 = null) {
foreach (self::$traps as $trap) {
$trap->addError($num, $str, $file, $line);
}
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;
}
// See T13499. If this is a user error arising from "trigger_error()" or
// similar, route it through normal error handling: this is probably the
// best match to authorial intent, since the code could choose to throw
// an exception instead if it wanted that behavior. Phabricator does not
// use "trigger_error()" so we never normally expect to reach this
// block in first-party code.
if (($num === E_USER_ERROR) ||
($num === E_USER_WARNING) ||
($num === E_USER_NOTICE)) {
$trace = debug_backtrace();
array_shift($trace);
self::dispatchErrorMessage(
self::ERROR,
$str,
array(
'file' => $file,
'line' => $line,
'error_code' => $num,
'trace' => $trace,
));
return;
}
// 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);
}
// Convert undefined indexes into exceptions.
if (preg_match('/^Undefined index: /', $str)) {
throw new RuntimeException($str);
}
// Convert undefined offsets into exceptions.
if (preg_match('/^Undefined offset: /', $str)) {
throw new RuntimeException($str);
}
// See T13499. Convert all other runtime errors not handled in a more
// specific way into runtime exceptions.
throw new RuntimeException($str);
}
/**
* 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 = date('Y-m-d H:i: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;
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 'phorge':
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));
}
}