report(); } else { MWExceptionRenderer::output( $e, MWExceptionRenderer::AS_PRETTY ); } } catch ( Exception $e2 ) { // Exception occurred from within exception handler // Show a simpler message for the original exception, // don't try to invoke report() MWExceptionRenderer::output( $e, MWExceptionRenderer::AS_RAW, $e2 ); } } /** * Roll back any open database transactions and log the stack trace of the exception * * This method is used to attempt to recover from exceptions * * @since 1.23 * @param Exception|Throwable $e */ public static function rollbackMasterChangesAndLog( $e ) { $services = MediaWikiServices::getInstance(); if ( !$services->isServiceDisabled( 'DBLoadBalancerFactory' ) ) { // Rollback DBs to avoid transaction notices. This might fail // to rollback some databases due to connection issues or exceptions. // However, any sane DB driver will rollback implicitly anyway. try { $services->getDBLoadBalancerFactory()->rollbackMasterChanges( __METHOD__ ); } catch ( DBError $e2 ) { // If the DB is unreacheable, rollback() will throw an error // and the error report() method might need messages from the DB, // which would result in an exception loop. PHP may escalate such // errors to "Exception thrown without a stack frame" fatals, but // it's better to be explicit here. self::logException( $e2, self::CAUGHT_BY_HANDLER ); } } self::logException( $e, self::CAUGHT_BY_HANDLER ); } /** * Callback to use with PHP's set_exception_handler. * * @since 1.31 * @param Exception|Throwable $e */ public static function handleUncaughtException( $e ) { self::handleException( $e ); // Make sure we don't claim success on exit for CLI scripts (T177414) if ( wfIsCLI() ) { register_shutdown_function( function () { exit( 255 ); } ); } } /** * Exception handler which simulates the appropriate catch() handling: * * try { * ... * } catch ( Exception $e ) { * $e->report(); * } catch ( Exception $e ) { * echo $e->__toString(); * } * * @since 1.25 * @param Exception|Throwable $e */ public static function handleException( $e ) { self::rollbackMasterChangesAndLog( $e ); self::report( $e ); } /** * Handler for set_error_handler() callback notifications. * * Receive a callback from the interpreter for a raised error, create an * ErrorException, and log the exception to the 'error' logging * channel(s). If the raised error is a fatal error type (only under HHVM) * delegate to handleFatalError() instead. * * @since 1.25 * * @param int $level Error level raised * @param string $message * @param string $file * @param int $line * @return bool * * @see logError() */ public static function handleError( $level, $message, $file = null, $line = null ) { global $wgPropagateErrors; if ( in_array( $level, self::$fatalErrorTypes ) ) { return call_user_func_array( 'MWExceptionHandler::handleFatalError', func_get_args() ); } // Map error constant to error name (reverse-engineer PHP error // reporting) switch ( $level ) { case E_RECOVERABLE_ERROR: $levelName = 'Error'; $severity = LogLevel::ERROR; break; case E_WARNING: case E_CORE_WARNING: case E_COMPILE_WARNING: case E_USER_WARNING: $levelName = 'Warning'; $severity = LogLevel::WARNING; break; case E_NOTICE: case E_USER_NOTICE: $levelName = 'Notice'; $severity = LogLevel::INFO; break; case E_STRICT: $levelName = 'Strict Standards'; $severity = LogLevel::DEBUG; break; case E_DEPRECATED: case E_USER_DEPRECATED: $levelName = 'Deprecated'; $severity = LogLevel::INFO; break; default: $levelName = 'Unknown error'; $severity = LogLevel::ERROR; break; } $e = new ErrorException( "PHP $levelName: $message", 0, $level, $file, $line ); self::logError( $e, 'error', $severity ); // If $wgPropagateErrors is true return false so PHP shows/logs the error normally. // Ignore $wgPropagateErrors if the error should break execution, or track_errors is set // (which means someone is counting on regular PHP error handling behavior). return !( $wgPropagateErrors || $level == E_RECOVERABLE_ERROR || ini_get( 'track_errors' ) ); } /** * Dual purpose callback used as both a set_error_handler() callback and * a registered shutdown function. Receive a callback from the interpreter * for a raised error or system shutdown, check for a fatal error, and log * to the 'fatal' logging channel. * * Special handling is included for missing class errors as they may * indicate that the user needs to install 3rd-party libraries via * Composer or other means. * * @since 1.25 * * @param int $level Error level raised * @param string $message Error message * @param string $file File that error was raised in * @param int $line Line number error was raised at * @param array $context Active symbol table point of error * @param array $trace Backtrace at point of error (undocumented HHVM * feature) * @return bool Always returns false */ public static function handleFatalError( $level = null, $message = null, $file = null, $line = null, $context = null, $trace = null ) { // Free reserved memory so that we have space to process OOM // errors self::$reservedMemory = null; if ( $level === null ) { // Called as a shutdown handler, get data from error_get_last() if ( static::$handledFatalCallback ) { // Already called once (probably as an error handler callback // under HHVM) so don't log again. return false; } $lastError = error_get_last(); if ( $lastError !== null ) { $level = $lastError['type']; $message = $lastError['message']; $file = $lastError['file']; $line = $lastError['line']; } else { $level = 0; $message = ''; } } if ( !in_array( $level, self::$fatalErrorTypes ) ) { // Only interested in fatal errors, others should have been // handled by MWExceptionHandler::handleError return false; } $url = WebRequest::getGlobalRequestURL(); $msgParts = [ '[{exception_id}] {exception_url} PHP Fatal Error', ( $line || $file ) ? ' from' : '', $line ? " line $line" : '', ( $line && $file ) ? ' of' : '', $file ? " $file" : '', ": $message", ]; $msg = implode( '', $msgParts ); // Look at message to see if this is a class not found failure // HHVM: Class undefined: foo // PHP5: Class 'foo' not found if ( preg_match( "/Class (undefined: \w+|'\w+' not found)/", $message ) ) { // phpcs:disable Generic.Files.LineLength $msg = <<mediawiki.org for help on installing the required components. TXT; // phpcs:enable } // We can't just create an exception and log it as it is likely that // the interpreter has unwound the stack already. If that is true the // stacktrace we would get would be functionally empty. If however we // have been called as an error handler callback *and* HHVM is in use // we will have been provided with a useful stacktrace that we can // log. $trace = $trace ?: debug_backtrace(); $logger = LoggerFactory::getInstance( 'fatal' ); $logger->error( $msg, [ 'fatal_exception' => [ 'class' => ErrorException::class, 'message' => "PHP Fatal Error: {$message}", 'code' => $level, 'file' => $file, 'line' => $line, 'trace' => self::prettyPrintTrace( self::redactTrace( $trace ) ), ], 'exception_id' => WebRequest::getRequestId(), 'exception_url' => $url, 'caught_by' => self::CAUGHT_BY_HANDLER ] ); // Remember call so we don't double process via HHVM's fatal // notifications and the shutdown hook behavior static::$handledFatalCallback = true; return false; } /** * Generate a string representation of an exception's stack trace * * Like Exception::getTraceAsString, but replaces argument values with * argument type or class name. * * @param Exception|Throwable $e * @return string * @see prettyPrintTrace() */ public static function getRedactedTraceAsString( $e ) { return self::prettyPrintTrace( self::getRedactedTrace( $e ) ); } /** * Generate a string representation of a stacktrace. * * @param array $trace * @param string $pad Constant padding to add to each line of trace * @return string * @since 1.26 */ public static function prettyPrintTrace( array $trace, $pad = '' ) { $text = ''; $level = 0; foreach ( $trace as $level => $frame ) { if ( isset( $frame['file'] ) && isset( $frame['line'] ) ) { $text .= "{$pad}#{$level} {$frame['file']}({$frame['line']}): "; } else { // 'file' and 'line' are unset for calls via call_user_func // (T57634) This matches behaviour of // Exception::getTraceAsString to instead display "[internal // function]". $text .= "{$pad}#{$level} [internal function]: "; } if ( isset( $frame['class'] ) && isset( $frame['type'] ) && isset( $frame['function'] ) ) { $text .= $frame['class'] . $frame['type'] . $frame['function']; } elseif ( isset( $frame['function'] ) ) { $text .= $frame['function']; } else { $text .= 'NO_FUNCTION_GIVEN'; } if ( isset( $frame['args'] ) ) { $text .= '(' . implode( ', ', $frame['args'] ) . ")\n"; } else { $text .= "()\n"; } } $level = $level + 1; $text .= "{$pad}#{$level} {main}"; return $text; } /** * Return a copy of an exception's backtrace as an array. * * Like Exception::getTrace, but replaces each element in each frame's * argument array with the name of its class (if the element is an object) * or its type (if the element is a PHP primitive). * * @since 1.22 * @param Exception|Throwable $e * @return array */ public static function getRedactedTrace( $e ) { return static::redactTrace( $e->getTrace() ); } /** * Redact a stacktrace generated by Exception::getTrace(), * debug_backtrace() or similar means. Replaces each element in each * frame's argument array with the name of its class (if the element is an * object) or its type (if the element is a PHP primitive). * * @since 1.26 * @param array $trace Stacktrace * @return array Stacktrace with arugment values converted to data types */ public static function redactTrace( array $trace ) { return array_map( function ( $frame ) { if ( isset( $frame['args'] ) ) { $frame['args'] = array_map( function ( $arg ) { return is_object( $arg ) ? get_class( $arg ) : gettype( $arg ); }, $frame['args'] ); } return $frame; }, $trace ); } /** * Get the ID for this exception. * * The ID is saved so that one can match the one output to the user (when * $wgShowExceptionDetails is set to false), to the entry in the debug log. * * @since 1.22 * @deprecated since 1.27: Exception IDs are synonymous with request IDs. * @param Exception|Throwable $e * @return string */ public static function getLogId( $e ) { wfDeprecated( __METHOD__, '1.27' ); return WebRequest::getRequestId(); } /** * If the exception occurred in the course of responding to a request, * returns the requested URL. Otherwise, returns false. * * @since 1.23 * @return string|false */ public static function getURL() { global $wgRequest; if ( !isset( $wgRequest ) || $wgRequest instanceof FauxRequest ) { return false; } return $wgRequest->getRequestURL(); } /** * Get a message formatting the exception message and its origin. * * @since 1.22 * @param Exception|Throwable $e * @return string */ public static function getLogMessage( $e ) { $id = WebRequest::getRequestId(); $type = get_class( $e ); $file = $e->getFile(); $line = $e->getLine(); $message = $e->getMessage(); $url = self::getURL() ?: '[no req]'; return "[$id] $url $type from line $line of $file: $message"; } /** * Get a normalised message for formatting with PSR-3 log event context. * * Must be used together with `getLogContext()` to be useful. * * @since 1.30 * @param Exception|Throwable $e * @return string */ public static function getLogNormalMessage( $e ) { $type = get_class( $e ); $file = $e->getFile(); $line = $e->getLine(); $message = $e->getMessage(); return "[{exception_id}] {exception_url} $type from line $line of $file: $message"; } /** * @param Exception|Throwable $e * @return string */ public static function getPublicLogMessage( $e ) { $reqId = WebRequest::getRequestId(); $type = get_class( $e ); return '[' . $reqId . '] ' . gmdate( 'Y-m-d H:i:s' ) . ': ' . 'Fatal exception of type "' . $type . '"'; } /** * Get a PSR-3 log event context from an Exception. * * Creates a structured array containing information about the provided * exception that can be used to augment a log message sent to a PSR-3 * logger. * * @param Exception|Throwable $e * @param string $catcher CAUGHT_BY_* class constant indicating what caught the error * @return array */ public static function getLogContext( $e, $catcher = self::CAUGHT_BY_OTHER ) { return [ 'exception' => $e, 'exception_id' => WebRequest::getRequestId(), 'exception_url' => self::getURL() ?: '[no req]', 'caught_by' => $catcher ]; } /** * Get a structured representation of an Exception. * * Returns an array of structured data (class, message, code, file, * backtrace) derived from the given exception. The backtrace information * will be redacted as per getRedactedTraceAsArray(). * * @param Exception|Throwable $e * @param string $catcher CAUGHT_BY_* class constant indicating what caught the error * @return array * @since 1.26 */ public static function getStructuredExceptionData( $e, $catcher = self::CAUGHT_BY_OTHER ) { global $wgLogExceptionBacktrace; $data = [ 'id' => WebRequest::getRequestId(), 'type' => get_class( $e ), 'file' => $e->getFile(), 'line' => $e->getLine(), 'message' => $e->getMessage(), 'code' => $e->getCode(), 'url' => self::getURL() ?: null, 'caught_by' => $catcher ]; if ( $e instanceof ErrorException && ( error_reporting() & $e->getSeverity() ) === 0 ) { // Flag surpressed errors $data['suppressed'] = true; } if ( $wgLogExceptionBacktrace ) { $data['backtrace'] = self::getRedactedTrace( $e ); } $previous = $e->getPrevious(); if ( $previous !== null ) { $data['previous'] = self::getStructuredExceptionData( $previous, $catcher ); } return $data; } /** * Serialize an Exception object to JSON. * * The JSON object will have keys 'id', 'file', 'line', 'message', and * 'url'. These keys map to string values, with the exception of 'line', * which is a number, and 'url', which may be either a string URL or or * null if the exception did not occur in the context of serving a web * request. * * If $wgLogExceptionBacktrace is true, it will also have a 'backtrace' * key, mapped to the array return value of Exception::getTrace, but with * each element in each frame's "args" array (if set) replaced with the * argument's class name (if the argument is an object) or type name (if * the argument is a PHP primitive). * * @par Sample JSON record ($wgLogExceptionBacktrace = false): * @code * { * "id": "c41fb419", * "type": "MWException", * "file": "/var/www/mediawiki/includes/cache/MessageCache.php", * "line": 704, * "message": "Non-string key given", * "url": "/wiki/Main_Page" * } * @endcode * * @par Sample JSON record ($wgLogExceptionBacktrace = true): * @code * { * "id": "dc457938", * "type": "MWException", * "file": "/vagrant/mediawiki/includes/cache/MessageCache.php", * "line": 704, * "message": "Non-string key given", * "url": "/wiki/Main_Page", * "backtrace": [{ * "file": "/vagrant/mediawiki/extensions/VisualEditor/VisualEditor.hooks.php", * "line": 80, * "function": "get", * "class": "MessageCache", * "type": "->", * "args": ["array"] * }] * } * @endcode * * @since 1.23 * @param Exception|Throwable $e * @param bool $pretty Add non-significant whitespace to improve readability (default: false). * @param int $escaping Bitfield consisting of FormatJson::.*_OK class constants. * @param string $catcher CAUGHT_BY_* class constant indicating what caught the error * @return string|false JSON string if successful; false upon failure */ public static function jsonSerializeException( $e, $pretty = false, $escaping = 0, $catcher = self::CAUGHT_BY_OTHER ) { return FormatJson::encode( self::getStructuredExceptionData( $e, $catcher ), $pretty, $escaping ); } /** * Log an exception to the exception log (if enabled). * * This method must not assume the exception is an MWException, * it is also used to handle PHP exceptions or exceptions from other libraries. * * @since 1.22 * @param Exception|Throwable $e * @param string $catcher CAUGHT_BY_* class constant indicating what caught the error */ public static function logException( $e, $catcher = self::CAUGHT_BY_OTHER ) { if ( !( $e instanceof MWException ) || $e->isLoggable() ) { $logger = LoggerFactory::getInstance( 'exception' ); $logger->error( self::getLogNormalMessage( $e ), self::getLogContext( $e, $catcher ) ); $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK, $catcher ); if ( $json !== false ) { $logger = LoggerFactory::getInstance( 'exception-json' ); $logger->error( $json, [ 'private' => true ] ); } Hooks::run( 'LogException', [ $e, false ] ); } } /** * Log an exception that wasn't thrown but made to wrap an error. * * @since 1.25 * @param ErrorException $e * @param string $channel * @param string $level */ protected static function logError( ErrorException $e, $channel, $level = LogLevel::ERROR ) { $catcher = self::CAUGHT_BY_HANDLER; // The set_error_handler callback is independent from error_reporting. // Filter out unwanted errors manually (e.g. when // Wikimedia\suppressWarnings is active). $suppressed = ( error_reporting() & $e->getSeverity() ) === 0; if ( !$suppressed ) { $logger = LoggerFactory::getInstance( $channel ); $logger->log( $level, self::getLogNormalMessage( $e ), self::getLogContext( $e, $catcher ) ); } // Include all errors in the json log (surpressed errors will be flagged) $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK, $catcher ); if ( $json !== false ) { $logger = LoggerFactory::getInstance( "{$channel}-json" ); $logger->log( $level, $json, [ 'private' => true ] ); } Hooks::run( 'LogException', [ $e, $suppressed ] ); } }