MediaWiki  master
MWExceptionHandler.php
Go to the documentation of this file.
1 <?php
23 use Psr\Log\LogLevel;
25 
31  const CAUGHT_BY_HANDLER = 'mwe_handler'; // error reported by this exception handler
32  const CAUGHT_BY_OTHER = 'other'; // error reported by direct logException() call
33 
37  protected static $reservedMemory;
38 
49  protected static $fatalErrorTypes = [
50  E_ERROR,
51  E_PARSE,
52  E_CORE_ERROR,
53  E_COMPILE_ERROR,
54  E_USER_ERROR,
55 
56  // E.g. "Catchable fatal error: Argument X must be Y, null given"
57  E_RECOVERABLE_ERROR,
58  ];
59 
63  public static function installHandler() {
64  // This catches:
65  // * Exception objects that were explicitly thrown but not
66  // caught anywhere in the application. This is rare given those
67  // would normally be caught at a high-level like MediaWiki::run (index.php),
68  // api.php, or ResourceLoader::respond (load.php). These high-level
69  // catch clauses would then call MWExceptionHandler::logException
70  // or MWExceptionHandler::handleException.
71  // If they are not caught, then they are handled here.
72  // * Error objects for issues that would historically
73  // cause fatal errors but may now be caught as Throwable (not Exception).
74  // Same as previous case, but more common to bubble to here instead of
75  // caught locally because they tend to not be safe to recover from.
76  // (e.g. argument TypeError, division by zero, etc.)
77  set_exception_handler( 'MWExceptionHandler::handleUncaughtException' );
78 
79  // This catches non-fatal errors (e.g. PHP Notice, PHP Warning, PHP Error) that do not
80  // interrupt execution in any way. We log these in the background and then continue execution.
81  set_error_handler( 'MWExceptionHandler::handleError' );
82 
83  // This catches fatal errors for which no Throwable is thrown,
84  // including Out-Of-Memory and Timeout fatals.
85  // Reserve 16k of memory so we can report OOM fatals.
86  self::$reservedMemory = str_repeat( ' ', 16384 );
87  register_shutdown_function( 'MWExceptionHandler::handleFatalError' );
88  }
89 
94  protected static function report( $e ) {
95  try {
96  // Try and show the exception prettily, with the normal skin infrastructure
97  if ( $e instanceof MWException ) {
98  // Delegate to MWException until all subclasses are handled by
99  // MWExceptionRenderer and MWException::report() has been
100  // removed.
101  $e->report();
102  } else {
104  }
105  } catch ( Exception $e2 ) {
106  // Exception occurred from within exception handler
107  // Show a simpler message for the original exception,
108  // don't try to invoke report()
110  }
111  }
112 
121  public static function rollbackMasterChangesAndLog( $e ) {
122  $services = MediaWikiServices::getInstance();
123  if ( !$services->isServiceDisabled( 'DBLoadBalancerFactory' ) ) {
124  // Rollback DBs to avoid transaction notices. This might fail
125  // to rollback some databases due to connection issues or exceptions.
126  // However, any sane DB driver will rollback implicitly anyway.
127  try {
128  $services->getDBLoadBalancerFactory()->rollbackMasterChanges( __METHOD__ );
129  } catch ( DBError $e2 ) {
130  // If the DB is unreacheable, rollback() will throw an error
131  // and the error report() method might need messages from the DB,
132  // which would result in an exception loop. PHP may escalate such
133  // errors to "Exception thrown without a stack frame" fatals, but
134  // it's better to be explicit here.
135  self::logException( $e2, self::CAUGHT_BY_HANDLER );
136  }
137  }
138 
139  self::logException( $e, self::CAUGHT_BY_HANDLER );
140  }
141 
148  public static function handleUncaughtException( $e ) {
149  self::handleException( $e );
150 
151  // Make sure we don't claim success on exit for CLI scripts (T177414)
152  if ( wfIsCLI() ) {
153  register_shutdown_function(
154  function () {
155  exit( 255 );
156  }
157  );
158  }
159  }
160 
175  public static function handleException( $e ) {
177  self::report( $e );
178  }
179 
197  public static function handleError(
198  $level, $message, $file = null, $line = null
199  ) {
200  global $wgPropagateErrors;
201 
202  // Map PHP error constant to a PSR-3 severity level.
203  // Avoid use of "DEBUG" or "INFO" levels, unless the
204  // error should evade error monitoring and alerts.
205  //
206  // To decide the log level, ask yourself: "Has the
207  // program's behaviour diverged from what the written
208  // code expected?"
209  //
210  // For example, use of a deprecated method or violating a strict standard
211  // has no impact on functional behaviour (Warning). On the other hand,
212  // accessing an undefined variable makes behaviour diverge from what the
213  // author intended/expected. PHP recovers from an undefined variables by
214  // yielding null and continuing execution, but it remains a change in
215  // behaviour given the null was not part of the code and is likely not
216  // accounted for.
217  switch ( $level ) {
218  case E_WARNING:
219  case E_CORE_WARNING:
220  case E_COMPILE_WARNING:
221  $levelName = 'Warning';
222  $severity = LogLevel::ERROR;
223  break;
224  case E_NOTICE:
225  $levelName = 'Notice';
226  $severity = LogLevel::ERROR;
227  break;
228  case E_USER_NOTICE:
229  // Used by wfWarn(), MWDebug::warning()
230  $levelName = 'Notice';
231  $severity = LogLevel::WARNING;
232  break;
233  case E_USER_WARNING:
234  // Used by wfWarn(), MWDebug::warning()
235  $levelName = 'Warning';
236  $severity = LogLevel::WARNING;
237  break;
238  case E_STRICT:
239  $levelName = 'Strict Standards';
240  $severity = LogLevel::WARNING;
241  break;
242  case E_DEPRECATED:
243  case E_USER_DEPRECATED:
244  $levelName = 'Deprecated';
245  $severity = LogLevel::WARNING;
246  break;
247  default:
248  $levelName = 'Unknown error';
249  $severity = LogLevel::ERROR;
250  break;
251  }
252 
253  $e = new ErrorException( "PHP $levelName: $message", 0, $level, $file, $line );
254  self::logError( $e, 'error', $severity );
255 
256  // If $wgPropagateErrors is true return false so PHP shows/logs the error normally.
257  // Ignore $wgPropagateErrors if track_errors is set
258  // (which means someone is counting on regular PHP error handling behavior).
259  return !( $wgPropagateErrors || ini_get( 'track_errors' ) );
260  }
261 
274  public static function handleFatalError() {
275  // Free reserved memory so that we have space to process OOM
276  // errors
277  self::$reservedMemory = null;
278 
279  $lastError = error_get_last();
280  if ( $lastError !== null ) {
281  $level = $lastError['type'];
282  $message = $lastError['message'];
283  $file = $lastError['file'];
284  $line = $lastError['line'];
285  } else {
286  $level = 0;
287  $message = '';
288  }
289 
290  if ( !in_array( $level, self::$fatalErrorTypes ) ) {
291  // Only interested in fatal errors, others should have been
292  // handled by MWExceptionHandler::handleError
293  return false;
294  }
295 
297  $msgParts = [
298  '[{exception_id}] {exception_url} PHP Fatal Error',
299  ( $line || $file ) ? ' from' : '',
300  $line ? " line $line" : '',
301  ( $line && $file ) ? ' of' : '',
302  $file ? " $file" : '',
303  ": $message",
304  ];
305  $msg = implode( '', $msgParts );
306 
307  // Look at message to see if this is a class not found failure (Class 'foo' not found)
308  if ( preg_match( "/Class '\w+' not found/", $message ) ) {
309  // phpcs:disable Generic.Files.LineLength
310  $msg = <<<TXT
311 {$msg}
312 
313 MediaWiki or an installed extension requires this class but it is not embedded directly in MediaWiki's git repository and must be installed separately by the end user.
314 
315 Please see <a href="https://www.mediawiki.org/wiki/Download_from_Git#Fetch_external_libraries">mediawiki.org</a> for help on installing the required components.
316 TXT;
317  // phpcs:enable
318  }
319 
320  $e = new ErrorException( "PHP Fatal Error: {$message}", 0, $level, $file, $line );
321  $logger = LoggerFactory::getInstance( 'fatal' );
322  $logger->error( $msg, [
323  'exception' => $e,
324  'exception_id' => WebRequest::getRequestId(),
325  'exception_url' => $url,
326  'caught_by' => self::CAUGHT_BY_HANDLER
327  ] );
328 
329  return false;
330  }
331 
342  public static function getRedactedTraceAsString( $e ) {
343  return self::prettyPrintTrace( self::getRedactedTrace( $e ) );
344  }
345 
354  public static function prettyPrintTrace( array $trace, $pad = '' ) {
355  $text = '';
356 
357  $level = 0;
358  foreach ( $trace as $level => $frame ) {
359  if ( isset( $frame['file'] ) && isset( $frame['line'] ) ) {
360  $text .= "{$pad}#{$level} {$frame['file']}({$frame['line']}): ";
361  } else {
362  // 'file' and 'line' are unset for calls via call_user_func
363  // (T57634) This matches behaviour of
364  // Exception::getTraceAsString to instead display "[internal
365  // function]".
366  $text .= "{$pad}#{$level} [internal function]: ";
367  }
368 
369  if ( isset( $frame['class'] ) && isset( $frame['type'] ) && isset( $frame['function'] ) ) {
370  $text .= $frame['class'] . $frame['type'] . $frame['function'];
371  } elseif ( isset( $frame['function'] ) ) {
372  $text .= $frame['function'];
373  } else {
374  $text .= 'NO_FUNCTION_GIVEN';
375  }
376 
377  if ( isset( $frame['args'] ) ) {
378  $text .= '(' . implode( ', ', $frame['args'] ) . ")\n";
379  } else {
380  $text .= "()\n";
381  }
382  }
383 
384  $level = $level + 1;
385  $text .= "{$pad}#{$level} {main}";
386 
387  return $text;
388  }
389 
401  public static function getRedactedTrace( $e ) {
402  return static::redactTrace( $e->getTrace() );
403  }
404 
415  public static function redactTrace( array $trace ) {
416  return array_map( function ( $frame ) {
417  if ( isset( $frame['args'] ) ) {
418  $frame['args'] = array_map( function ( $arg ) {
419  return is_object( $arg ) ? get_class( $arg ) : gettype( $arg );
420  }, $frame['args'] );
421  }
422  return $frame;
423  }, $trace );
424  }
425 
433  public static function getURL() {
434  global $wgRequest;
435  if ( !isset( $wgRequest ) || $wgRequest instanceof FauxRequest ) {
436  return false;
437  }
438  return $wgRequest->getRequestURL();
439  }
440 
448  public static function getLogMessage( $e ) {
449  $id = WebRequest::getRequestId();
450  $type = get_class( $e );
451  $file = $e->getFile();
452  $line = $e->getLine();
453  $message = $e->getMessage();
454  $url = self::getURL() ?: '[no req]';
455 
456  return "[$id] $url $type from line $line of $file: $message";
457  }
458 
468  public static function getLogNormalMessage( $e ) {
469  $type = get_class( $e );
470  $file = $e->getFile();
471  $line = $e->getLine();
472  $message = $e->getMessage();
473 
474  return "[{exception_id}] {exception_url} $type from line $line of $file: $message";
475  }
476 
481  public static function getPublicLogMessage( $e ) {
482  $reqId = WebRequest::getRequestId();
483  $type = get_class( $e );
484  return '[' . $reqId . '] '
485  . gmdate( 'Y-m-d H:i:s' ) . ': '
486  . 'Fatal exception of type "' . $type . '"';
487  }
488 
500  public static function getLogContext( $e, $catcher = self::CAUGHT_BY_OTHER ) {
501  return [
502  'exception' => $e,
503  'exception_id' => WebRequest::getRequestId(),
504  'exception_url' => self::getURL() ?: '[no req]',
505  'caught_by' => $catcher
506  ];
507  }
508 
521  public static function getStructuredExceptionData( $e, $catcher = self::CAUGHT_BY_OTHER ) {
523 
524  $data = [
525  'id' => WebRequest::getRequestId(),
526  'type' => get_class( $e ),
527  'file' => $e->getFile(),
528  'line' => $e->getLine(),
529  'message' => $e->getMessage(),
530  'code' => $e->getCode(),
531  'url' => self::getURL() ?: null,
532  'caught_by' => $catcher
533  ];
534 
535  if ( $e instanceof ErrorException &&
536  ( error_reporting() & $e->getSeverity() ) === 0
537  ) {
538  // Flag surpressed errors
539  $data['suppressed'] = true;
540  }
541 
542  if ( $wgLogExceptionBacktrace ) {
543  $data['backtrace'] = self::getRedactedTrace( $e );
544  }
545 
546  $previous = $e->getPrevious();
547  if ( $previous !== null ) {
548  $data['previous'] = self::getStructuredExceptionData( $previous, $catcher );
549  }
550 
551  return $data;
552  }
553 
608  public static function jsonSerializeException(
609  $e, $pretty = false, $escaping = 0, $catcher = self::CAUGHT_BY_OTHER
610  ) {
611  return FormatJson::encode(
612  self::getStructuredExceptionData( $e, $catcher ),
613  $pretty,
614  $escaping
615  );
616  }
617 
629  public static function logException( $e, $catcher = self::CAUGHT_BY_OTHER, $extraData = [] ) {
630  if ( !( $e instanceof MWException ) || $e->isLoggable() ) {
631  $logger = LoggerFactory::getInstance( 'exception' );
632  $context = self::getLogContext( $e, $catcher );
633  if ( $extraData ) {
634  $context['extraData'] = $extraData;
635  }
636  $logger->error(
637  self::getLogNormalMessage( $e ),
638  $context
639  );
640 
641  $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK, $catcher );
642  if ( $json !== false ) {
643  $logger = LoggerFactory::getInstance( 'exception-json' );
644  $logger->error( $json, [ 'private' => true ] );
645  }
646 
647  Hooks::run( 'LogException', [ $e, false ] );
648  }
649  }
650 
659  protected static function logError(
660  ErrorException $e, $channel, $level = LogLevel::ERROR
661  ) {
662  $catcher = self::CAUGHT_BY_HANDLER;
663  // The set_error_handler callback is independent from error_reporting.
664  // Filter out unwanted errors manually (e.g. when
665  // Wikimedia\suppressWarnings is active).
666  $suppressed = ( error_reporting() & $e->getSeverity() ) === 0;
667  if ( !$suppressed ) {
668  $logger = LoggerFactory::getInstance( $channel );
669  $logger->log(
670  $level,
671  self::getLogNormalMessage( $e ),
672  self::getLogContext( $e, $catcher )
673  );
674  }
675 
676  // Include all errors in the json log (surpressed errors will be flagged)
677  $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK, $catcher );
678  if ( $json !== false ) {
679  $logger = LoggerFactory::getInstance( "{$channel}-json" );
680  $logger->log( $level, $json, [ 'private' => true ] );
681  }
682 
683  Hooks::run( 'LogException', [ $e, $suppressed ] );
684  }
685 }
MWExceptionHandler\handleFatalError
static handleFatalError()
Callback used as a registered shutdown function.
Definition: MWExceptionHandler.php:274
MWExceptionHandler\logError
static logError(ErrorException $e, $channel, $level=LogLevel::ERROR)
Log an exception that wasn't thrown but made to wrap an error.
Definition: MWExceptionHandler.php:659
MWExceptionHandler\getRedactedTrace
static getRedactedTrace( $e)
Return a copy of an exception's backtrace as an array.
Definition: MWExceptionHandler.php:401
FauxRequest
WebRequest clone which takes values from a provided array.
Definition: FauxRequest.php:33
MediaWiki\MediaWikiServices
MediaWikiServices is the service locator for the application scope of MediaWiki.
Definition: MediaWikiServices.php:130
MWExceptionHandler\getLogNormalMessage
static getLogNormalMessage( $e)
Get a normalised message for formatting with PSR-3 log event context.
Definition: MWExceptionHandler.php:468
MWExceptionHandler\getStructuredExceptionData
static getStructuredExceptionData( $e, $catcher=self::CAUGHT_BY_OTHER)
Get a structured representation of an Exception.
Definition: MWExceptionHandler.php:521
MWExceptionHandler
Handler class for MWExceptions.
Definition: MWExceptionHandler.php:30
MWExceptionHandler\CAUGHT_BY_HANDLER
const CAUGHT_BY_HANDLER
Definition: MWExceptionHandler.php:31
MWExceptionHandler\redactTrace
static redactTrace(array $trace)
Redact a stacktrace generated by Exception::getTrace(), debug_backtrace() or similar means.
Definition: MWExceptionHandler.php:415
$file
if(PHP_SAPI !='cli-server') if(!isset( $_SERVER['SCRIPT_FILENAME'])) $file
Item class for a filearchive table row.
Definition: router.php:42
MWExceptionHandler\installHandler
static installHandler()
Install handlers with PHP.
Definition: MWExceptionHandler.php:63
MWExceptionHandler\handleUncaughtException
static handleUncaughtException( $e)
Callback to use with PHP's set_exception_handler.
Definition: MWExceptionHandler.php:148
Wikimedia\Rdbms\DBError
Database error base class.
Definition: DBError.php:30
FormatJson\ALL_OK
const ALL_OK
Skip escaping as many characters as reasonably possible.
Definition: FormatJson.php:55
$wgPropagateErrors
$wgPropagateErrors
If true, the MediaWiki error handler passes errors/warnings to the default error handler after loggin...
Definition: DefaultSettings.php:6325
MWExceptionHandler\getPublicLogMessage
static getPublicLogMessage( $e)
Definition: MWExceptionHandler.php:481
FormatJson\encode
static encode( $value, $pretty=false, $escaping=0)
Returns the JSON representation of a value.
Definition: FormatJson.php:115
MWExceptionHandler\CAUGHT_BY_OTHER
const CAUGHT_BY_OTHER
Definition: MWExceptionHandler.php:32
MWException
MediaWiki exception.
Definition: MWException.php:26
MWExceptionHandler\getLogContext
static getLogContext( $e, $catcher=self::CAUGHT_BY_OTHER)
Get a PSR-3 log event context from an Exception.
Definition: MWExceptionHandler.php:500
MWExceptionHandler\prettyPrintTrace
static prettyPrintTrace(array $trace, $pad='')
Generate a string representation of a stacktrace.
Definition: MWExceptionHandler.php:354
MediaWiki\Logger\LoggerFactory
PSR-3 logger instance factory.
Definition: LoggerFactory.php:45
MWExceptionHandler\getURL
static getURL()
If the exception occurred in the course of responding to a request, returns the requested URL.
Definition: MWExceptionHandler.php:433
MWExceptionHandler\handleException
static handleException( $e)
Exception handler which simulates the appropriate catch() handling:
Definition: MWExceptionHandler.php:175
MWExceptionHandler\getLogMessage
static getLogMessage( $e)
Get a message formatting the exception message and its origin.
Definition: MWExceptionHandler.php:448
MWExceptionHandler\$fatalErrorTypes
static $fatalErrorTypes
Error types that, if unhandled, are fatal to the request.
Definition: MWExceptionHandler.php:49
MWExceptionHandler\rollbackMasterChangesAndLog
static rollbackMasterChangesAndLog( $e)
Roll back any open database transactions and log the stack trace of the exception.
Definition: MWExceptionHandler.php:121
MediaWiki
A helper class for throttling authentication attempts.
$wgLogExceptionBacktrace
$wgLogExceptionBacktrace
If true, send the exception backtrace to the error log.
Definition: DefaultSettings.php:6319
MWExceptionHandler\$reservedMemory
static $reservedMemory
Definition: MWExceptionHandler.php:37
MWExceptionRenderer\AS_PRETTY
const AS_PRETTY
Definition: MWExceptionRenderer.php:32
wfIsCLI
wfIsCLI()
Check if we are running from the commandline.
Definition: GlobalFunctions.php:1934
$line
$line
Definition: mcc.php:119
$context
$context
Definition: load.php:40
MWExceptionRenderer\output
static output( $e, $mode, $eNew=null)
Definition: MWExceptionRenderer.php:39
MWExceptionHandler\report
static report( $e)
Report an exception to the user.
Definition: MWExceptionHandler.php:94
WebRequest\getRequestId
static getRequestId()
Get the unique request ID.
Definition: WebRequest.php:310
WebRequest\getGlobalRequestURL
static getGlobalRequestURL()
Return the path and query string portion of the main request URI.
Definition: WebRequest.php:861
MWExceptionHandler\jsonSerializeException
static jsonSerializeException( $e, $pretty=false, $escaping=0, $catcher=self::CAUGHT_BY_OTHER)
Serialize an Exception object to JSON.
Definition: MWExceptionHandler.php:608
$wgRequest
if(! $wgDBerrorLogTZ) $wgRequest
Definition: Setup.php:713
Hooks\run
static run( $event, array $args=[], $deprecatedVersion=null)
Call hook functions defined in Hooks::register and $wgHooks.
Definition: Hooks.php:200
MWExceptionHandler\handleError
static handleError( $level, $message, $file=null, $line=null)
Handler for set_error_handler() callback notifications.
Definition: MWExceptionHandler.php:197
MWExceptionHandler\getRedactedTraceAsString
static getRedactedTraceAsString( $e)
Generate a string representation of an exception's stack trace.
Definition: MWExceptionHandler.php:342
MWExceptionRenderer\AS_RAW
const AS_RAW
Definition: MWExceptionRenderer.php:31
$type
$type
Definition: testCompression.php:50
MWExceptionHandler\logException
static logException( $e, $catcher=self::CAUGHT_BY_OTHER, $extraData=[])
Log an exception to the exception log (if enabled).
Definition: MWExceptionHandler.php:629