MediaWiki  master
MWExceptionHandler.php
Go to the documentation of this file.
1 <?php
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 ) {
176  self::rollbackMasterChangesAndLog( $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  // We can't just create an exception and log it, as it is likely that
321  // the interpreter has unwound the stack already. If that is true the
322  // stacktrace we would get would be functionally empty.
323  $trace = debug_backtrace();
324  $logger = LoggerFactory::getInstance( 'fatal' );
325  $logger->error( $msg, [
326  'fatal_exception' => [
327  'class' => ErrorException::class,
328  'message' => "PHP Fatal Error: {$message}",
329  'code' => $level,
330  'file' => $file,
331  'line' => $line,
332  'trace' => self::prettyPrintTrace( self::redactTrace( $trace ) ),
333  ],
334  'exception_id' => WebRequest::getRequestId(),
335  'exception_url' => $url,
336  'caught_by' => self::CAUGHT_BY_HANDLER
337  ] );
338 
339  return false;
340  }
341 
352  public static function getRedactedTraceAsString( $e ) {
353  return self::prettyPrintTrace( self::getRedactedTrace( $e ) );
354  }
355 
364  public static function prettyPrintTrace( array $trace, $pad = '' ) {
365  $text = '';
366 
367  $level = 0;
368  foreach ( $trace as $level => $frame ) {
369  if ( isset( $frame['file'] ) && isset( $frame['line'] ) ) {
370  $text .= "{$pad}#{$level} {$frame['file']}({$frame['line']}): ";
371  } else {
372  // 'file' and 'line' are unset for calls via call_user_func
373  // (T57634) This matches behaviour of
374  // Exception::getTraceAsString to instead display "[internal
375  // function]".
376  $text .= "{$pad}#{$level} [internal function]: ";
377  }
378 
379  if ( isset( $frame['class'] ) && isset( $frame['type'] ) && isset( $frame['function'] ) ) {
380  $text .= $frame['class'] . $frame['type'] . $frame['function'];
381  } elseif ( isset( $frame['function'] ) ) {
382  $text .= $frame['function'];
383  } else {
384  $text .= 'NO_FUNCTION_GIVEN';
385  }
386 
387  if ( isset( $frame['args'] ) ) {
388  $text .= '(' . implode( ', ', $frame['args'] ) . ")\n";
389  } else {
390  $text .= "()\n";
391  }
392  }
393 
394  $level = $level + 1;
395  $text .= "{$pad}#{$level} {main}";
396 
397  return $text;
398  }
399 
411  public static function getRedactedTrace( $e ) {
412  return static::redactTrace( $e->getTrace() );
413  }
414 
425  public static function redactTrace( array $trace ) {
426  return array_map( function ( $frame ) {
427  if ( isset( $frame['args'] ) ) {
428  $frame['args'] = array_map( function ( $arg ) {
429  return is_object( $arg ) ? get_class( $arg ) : gettype( $arg );
430  }, $frame['args'] );
431  }
432  return $frame;
433  }, $trace );
434  }
435 
443  public static function getURL() {
444  global $wgRequest;
445  if ( !isset( $wgRequest ) || $wgRequest instanceof FauxRequest ) {
446  return false;
447  }
448  return $wgRequest->getRequestURL();
449  }
450 
458  public static function getLogMessage( $e ) {
459  $id = WebRequest::getRequestId();
460  $type = get_class( $e );
461  $file = $e->getFile();
462  $line = $e->getLine();
463  $message = $e->getMessage();
464  $url = self::getURL() ?: '[no req]';
465 
466  return "[$id] $url $type from line $line of $file: $message";
467  }
468 
478  public static function getLogNormalMessage( $e ) {
479  $type = get_class( $e );
480  $file = $e->getFile();
481  $line = $e->getLine();
482  $message = $e->getMessage();
483 
484  return "[{exception_id}] {exception_url} $type from line $line of $file: $message";
485  }
486 
491  public static function getPublicLogMessage( $e ) {
492  $reqId = WebRequest::getRequestId();
493  $type = get_class( $e );
494  return '[' . $reqId . '] '
495  . gmdate( 'Y-m-d H:i:s' ) . ': '
496  . 'Fatal exception of type "' . $type . '"';
497  }
498 
510  public static function getLogContext( $e, $catcher = self::CAUGHT_BY_OTHER ) {
511  return [
512  'exception' => $e,
513  'exception_id' => WebRequest::getRequestId(),
514  'exception_url' => self::getURL() ?: '[no req]',
515  'caught_by' => $catcher
516  ];
517  }
518 
531  public static function getStructuredExceptionData( $e, $catcher = self::CAUGHT_BY_OTHER ) {
533 
534  $data = [
535  'id' => WebRequest::getRequestId(),
536  'type' => get_class( $e ),
537  'file' => $e->getFile(),
538  'line' => $e->getLine(),
539  'message' => $e->getMessage(),
540  'code' => $e->getCode(),
541  'url' => self::getURL() ?: null,
542  'caught_by' => $catcher
543  ];
544 
545  if ( $e instanceof ErrorException &&
546  ( error_reporting() & $e->getSeverity() ) === 0
547  ) {
548  // Flag surpressed errors
549  $data['suppressed'] = true;
550  }
551 
552  if ( $wgLogExceptionBacktrace ) {
553  $data['backtrace'] = self::getRedactedTrace( $e );
554  }
555 
556  $previous = $e->getPrevious();
557  if ( $previous !== null ) {
558  $data['previous'] = self::getStructuredExceptionData( $previous, $catcher );
559  }
560 
561  return $data;
562  }
563 
618  public static function jsonSerializeException(
619  $e, $pretty = false, $escaping = 0, $catcher = self::CAUGHT_BY_OTHER
620  ) {
621  return FormatJson::encode(
622  self::getStructuredExceptionData( $e, $catcher ),
623  $pretty,
624  $escaping
625  );
626  }
627 
639  public static function logException( $e, $catcher = self::CAUGHT_BY_OTHER, $extraData = [] ) {
640  if ( !( $e instanceof MWException ) || $e->isLoggable() ) {
641  $logger = LoggerFactory::getInstance( 'exception' );
642  $context = self::getLogContext( $e, $catcher );
643  if ( $extraData ) {
644  $context['extraData'] = $extraData;
645  }
646  $logger->error(
647  self::getLogNormalMessage( $e ),
648  $context
649  );
650 
651  $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK, $catcher );
652  if ( $json !== false ) {
653  $logger = LoggerFactory::getInstance( 'exception-json' );
654  $logger->error( $json, [ 'private' => true ] );
655  }
656 
657  Hooks::run( 'LogException', [ $e, false ] );
658  }
659  }
660 
669  protected static function logError(
670  ErrorException $e, $channel, $level = LogLevel::ERROR
671  ) {
672  $catcher = self::CAUGHT_BY_HANDLER;
673  // The set_error_handler callback is independent from error_reporting.
674  // Filter out unwanted errors manually (e.g. when
675  // Wikimedia\suppressWarnings is active).
676  $suppressed = ( error_reporting() & $e->getSeverity() ) === 0;
677  if ( !$suppressed ) {
678  $logger = LoggerFactory::getInstance( $channel );
679  $logger->log(
680  $level,
681  self::getLogNormalMessage( $e ),
682  self::getLogContext( $e, $catcher )
683  );
684  }
685 
686  // Include all errors in the json log (surpressed errors will be flagged)
687  $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK, $catcher );
688  if ( $json !== false ) {
689  $logger = LoggerFactory::getInstance( "{$channel}-json" );
690  $logger->log( $level, $json, [ 'private' => true ] );
691  }
692 
693  Hooks::run( 'LogException', [ $e, $suppressed ] );
694  }
695 }
$wgLogExceptionBacktrace
If true, send the exception backtrace to the error log.
static getLogMessage( $e)
Get a message formatting the exception message and its origin.
if(PHP_SAPI !='cli-server') if(!isset( $_SERVER['SCRIPT_FILENAME'])) $file
Item class for a filearchive table row.
Definition: router.php:42
static jsonSerializeException( $e, $pretty=false, $escaping=0, $catcher=self::CAUGHT_BY_OTHER)
Serialize an Exception object to JSON.
static getRequestId()
Get the unique request ID.
Definition: WebRequest.php:309
$context
Definition: load.php:45
static getRedactedTrace( $e)
Return a copy of an exception&#39;s backtrace as an array.
const ALL_OK
Skip escaping as many characters as reasonably possible.
Definition: FormatJson.php:55
static encode( $value, $pretty=false, $escaping=0)
Returns the JSON representation of a value.
Definition: FormatJson.php:115
static rollbackMasterChangesAndLog( $e)
Roll back any open database transactions and log the stack trace of the exception.
A helper class for throttling authentication attempts.
static getRedactedTraceAsString( $e)
Generate a string representation of an exception&#39;s stack trace.
static handleFatalError()
Callback used as a registered shutdown function.
static handleError( $level, $message, $file=null, $line=null)
Handler for set_error_handler() callback notifications.
static logException( $e, $catcher=self::CAUGHT_BY_OTHER, $extraData=[])
Log an exception to the exception log (if enabled).
static getURL()
If the exception occurred in the course of responding to a request, returns the requested URL...
wfIsCLI()
Check if we are running from the commandline.
static redactTrace(array $trace)
Redact a stacktrace generated by Exception::getTrace(), debug_backtrace() or similar means...
static logError(ErrorException $e, $channel, $level=LogLevel::ERROR)
Log an exception that wasn&#39;t thrown but made to wrap an error.
static handleException( $e)
Exception handler which simulates the appropriate catch() handling:
static report( $e)
Report an exception to the user.
static output( $e, $mode, $eNew=null)
static getGlobalRequestURL()
Return the path and query string portion of the main request URI.
Definition: WebRequest.php:860
static installHandler()
Install handlers with PHP.
$line
Definition: cdb.php:59
static prettyPrintTrace(array $trace, $pad='')
Generate a string representation of a stacktrace.
static handleUncaughtException( $e)
Callback to use with PHP&#39;s set_exception_handler.
if(! $wgDBerrorLogTZ) $wgRequest
Definition: Setup.php:727
static getLogNormalMessage( $e)
Get a normalised message for formatting with PSR-3 log event context.
$wgPropagateErrors
If true, the MediaWiki error handler passes errors/warnings to the default error handler after loggin...
static getStructuredExceptionData( $e, $catcher=self::CAUGHT_BY_OTHER)
Get a structured representation of an Exception.
static $fatalErrorTypes
Error types that, if unhandled, are fatal to the request.
static getPublicLogMessage( $e)
Database error base class.
Definition: DBError.php:30
static getLogContext( $e, $catcher=self::CAUGHT_BY_OTHER)
Get a PSR-3 log event context from an Exception.
static run( $event, array $args=[], $deprecatedVersion=null)
Call hook functions defined in Hooks::register and $wgHooks.
Definition: Hooks.php:200