MediaWiki  master
MWExceptionHandler.php
Go to the documentation of this file.
1 <?php
23 use Psr\Log\LogLevel;
25 
32  public const CAUGHT_BY_HANDLER = 'mwe_handler';
34  public const CAUGHT_BY_ENTRYPOINT = 'entrypoint';
36  public const CAUGHT_BY_OTHER = 'other';
37 
39  protected static $reservedMemory;
40 
51  protected static $fatalErrorTypes = [
52  E_ERROR,
53  E_PARSE,
54  E_CORE_ERROR,
55  E_COMPILE_ERROR,
56  E_USER_ERROR,
57 
58  // E.g. "Catchable fatal error: Argument X must be Y, null given"
59  E_RECOVERABLE_ERROR,
60  ];
61 
65  public static function installHandler() {
66  // This catches:
67  // * Exception objects that were explicitly thrown but not
68  // caught anywhere in the application. This is rare given those
69  // would normally be caught at a high-level like MediaWiki::run (index.php),
70  // api.php, or ResourceLoader::respond (load.php). These high-level
71  // catch clauses would then call MWExceptionHandler::logException
72  // or MWExceptionHandler::handleException.
73  // If they are not caught, then they are handled here.
74  // * Error objects for issues that would historically
75  // cause fatal errors but may now be caught as Throwable (not Exception).
76  // Same as previous case, but more common to bubble to here instead of
77  // caught locally because they tend to not be safe to recover from.
78  // (e.g. argument TypeError, division by zero, etc.)
79  set_exception_handler( 'MWExceptionHandler::handleUncaughtException' );
80 
81  // This catches recoverable errors (e.g. PHP Notice, PHP Warning, PHP Error) that do not
82  // interrupt execution in any way. We log these in the background and then continue execution.
83  set_error_handler( 'MWExceptionHandler::handleError' );
84 
85  // This catches fatal errors for which no Throwable is thrown,
86  // including Out-Of-Memory and Timeout fatals.
87  // Reserve 16k of memory so we can report OOM fatals.
88  self::$reservedMemory = str_repeat( ' ', 16384 );
89  register_shutdown_function( 'MWExceptionHandler::handleFatalError' );
90  }
91 
96  protected static function report( Throwable $e ) {
97  try {
98  // Try and show the exception prettily, with the normal skin infrastructure
99  if ( $e instanceof MWException ) {
100  // Delegate to MWException until all subclasses are handled by
101  // MWExceptionRenderer and MWException::report() has been
102  // removed.
103  $e->report();
104  } else {
106  }
107  } catch ( Exception $e2 ) {
108  // Exception occurred from within exception handler
109  // Show a simpler message for the original exception,
110  // don't try to invoke report()
112  }
113  }
114 
124  public static function rollbackMasterChangesAndLog(
125  Throwable $e,
126  $catcher = self::CAUGHT_BY_OTHER
127  ) {
128  $services = MediaWikiServices::getInstance();
129  if ( !$services->isServiceDisabled( 'DBLoadBalancerFactory' ) ) {
130  // Rollback DBs to avoid transaction notices. This might fail
131  // to rollback some databases due to connection issues or exceptions.
132  // However, any sane DB driver will rollback implicitly anyway.
133  try {
134  $services->getDBLoadBalancerFactory()->rollbackMasterChanges( __METHOD__ );
135  } catch ( DBError $e2 ) {
136  // If the DB is unreacheable, rollback() will throw an error
137  // and the error report() method might need messages from the DB,
138  // which would result in an exception loop. PHP may escalate such
139  // errors to "Exception thrown without a stack frame" fatals, but
140  // it's better to be explicit here.
141  self::logException( $e2, $catcher );
142  }
143  }
144 
145  self::logException( $e, $catcher );
146  }
147 
154  public static function handleUncaughtException( Throwable $e ) {
155  self::handleException( $e, self::CAUGHT_BY_HANDLER );
156 
157  // Make sure we don't claim success on exit for CLI scripts (T177414)
158  if ( wfIsCLI() ) {
159  register_shutdown_function(
160  function () {
161  exit( 255 );
162  }
163  );
164  }
165  }
166 
182  public static function handleException( Throwable $e, $catcher = self::CAUGHT_BY_OTHER ) {
183  self::rollbackMasterChangesAndLog( $e, $catcher );
184  self::report( $e );
185  }
186 
201  public static function handleError(
202  $level,
203  $message,
204  $file = null,
205  $line = null
206  ) {
207  global $wgPropagateErrors;
208 
209  // Map PHP error constant to a PSR-3 severity level.
210  // Avoid use of "DEBUG" or "INFO" levels, unless the
211  // error should evade error monitoring and alerts.
212  //
213  // To decide the log level, ask yourself: "Has the
214  // program's behaviour diverged from what the written
215  // code expected?"
216  //
217  // For example, use of a deprecated method or violating a strict standard
218  // has no impact on functional behaviour (Warning). On the other hand,
219  // accessing an undefined variable makes behaviour diverge from what the
220  // author intended/expected. PHP recovers from an undefined variables by
221  // yielding null and continuing execution, but it remains a change in
222  // behaviour given the null was not part of the code and is likely not
223  // accounted for.
224  switch ( $level ) {
225  case E_WARNING:
226  case E_CORE_WARNING:
227  case E_COMPILE_WARNING:
228  $levelName = 'Warning';
229  $severity = LogLevel::ERROR;
230  break;
231  case E_NOTICE:
232  $levelName = 'Notice';
233  $severity = LogLevel::ERROR;
234  break;
235  case E_USER_NOTICE:
236  // Used by wfWarn(), MWDebug::warning()
237  $levelName = 'Notice';
238  $severity = LogLevel::WARNING;
239  break;
240  case E_USER_WARNING:
241  // Used by wfWarn(), MWDebug::warning()
242  $levelName = 'Warning';
243  $severity = LogLevel::WARNING;
244  break;
245  case E_STRICT:
246  $levelName = 'Strict Standards';
247  $severity = LogLevel::WARNING;
248  break;
249  case E_DEPRECATED:
250  case E_USER_DEPRECATED:
251  $levelName = 'Deprecated';
252  $severity = LogLevel::WARNING;
253  break;
254  default:
255  $levelName = 'Unknown error';
256  $severity = LogLevel::ERROR;
257  break;
258  }
259 
260  $e = new ErrorException( "PHP $levelName: $message", 0, $level, $file, $line );
261  self::logError( $e, 'error', $severity, self::CAUGHT_BY_HANDLER );
262 
263  // If $wgPropagateErrors is true return false so PHP shows/logs the error normally.
264  // Ignore $wgPropagateErrors if track_errors is set
265  // (which means someone is counting on regular PHP error handling behavior).
266  return !( $wgPropagateErrors || ini_get( 'track_errors' ) );
267  }
268 
283  public static function handleFatalError() {
284  // Free reserved memory so that we have space to process OOM
285  // errors
286  self::$reservedMemory = null;
287 
288  $lastError = error_get_last();
289  if ( $lastError !== null ) {
290  $level = $lastError['type'];
291  $message = $lastError['message'];
292  $file = $lastError['file'];
293  $line = $lastError['line'];
294  } else {
295  $level = 0;
296  $message = '';
297  }
298 
299  if ( !in_array( $level, self::$fatalErrorTypes ) ) {
300  // Only interested in fatal errors, others should have been
301  // handled by MWExceptionHandler::handleError
302  return false;
303  }
304 
306  $msgParts = [
307  '[{exception_id}] {exception_url} PHP Fatal Error',
308  ( $line || $file ) ? ' from' : '',
309  $line ? " line $line" : '',
310  ( $line && $file ) ? ' of' : '',
311  $file ? " $file" : '',
312  ": $message",
313  ];
314  $msg = implode( '', $msgParts );
315 
316  // Look at message to see if this is a class not found failure (Class 'foo' not found)
317  if ( preg_match( "/Class '\w+' not found/", $message ) ) {
318  // phpcs:disable Generic.Files.LineLength
319  $msg = <<<TXT
320 {$msg}
321 
322 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.
323 
324 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.
325 TXT;
326  // phpcs:enable
327  }
328 
329  $e = new ErrorException( "PHP Fatal Error: {$message}", 0, $level, $file, $line );
330  $logger = LoggerFactory::getInstance( 'exception' );
331  $logger->error( $msg, [
332  'exception' => $e,
333  'exception_id' => WebRequest::getRequestId(),
334  'exception_url' => $url,
335  'caught_by' => self::CAUGHT_BY_HANDLER
336  ] );
337 
338  return false;
339  }
340 
351  public static function getRedactedTraceAsString( Throwable $e ) {
352  return self::prettyPrintTrace( self::getRedactedTrace( $e ) );
353  }
354 
363  public static function prettyPrintTrace( array $trace, $pad = '' ) {
364  $text = '';
365 
366  $level = 0;
367  foreach ( $trace as $level => $frame ) {
368  if ( isset( $frame['file'] ) && isset( $frame['line'] ) ) {
369  $text .= "{$pad}#{$level} {$frame['file']}({$frame['line']}): ";
370  } else {
371  // 'file' and 'line' are unset for calls from C code
372  // (T57634) This matches behaviour of
373  // Throwable::getTraceAsString to instead display "[internal
374  // function]".
375  $text .= "{$pad}#{$level} [internal function]: ";
376  }
377 
378  if ( isset( $frame['class'] ) && isset( $frame['type'] ) && isset( $frame['function'] ) ) {
379  $text .= $frame['class'] . $frame['type'] . $frame['function'];
380  } elseif ( isset( $frame['function'] ) ) {
381  $text .= $frame['function'];
382  } else {
383  $text .= 'NO_FUNCTION_GIVEN';
384  }
385 
386  if ( isset( $frame['args'] ) ) {
387  $text .= '(' . implode( ', ', $frame['args'] ) . ")\n";
388  } else {
389  $text .= "()\n";
390  }
391  }
392 
393  $level = $level + 1;
394  $text .= "{$pad}#{$level} {main}";
395 
396  return $text;
397  }
398 
410  public static function getRedactedTrace( Throwable $e ) {
411  return static::redactTrace( $e->getTrace() );
412  }
413 
424  public static function redactTrace( array $trace ) {
425  return array_map( function ( $frame ) {
426  if ( isset( $frame['args'] ) ) {
427  $frame['args'] = array_map( function ( $arg ) {
428  return is_object( $arg ) ? get_class( $arg ) : gettype( $arg );
429  }, $frame['args'] );
430  }
431  return $frame;
432  }, $trace );
433  }
434 
442  public static function getURL() {
443  global $wgRequest;
444  if ( !isset( $wgRequest ) || $wgRequest instanceof FauxRequest ) {
445  return false;
446  }
447  return $wgRequest->getRequestURL();
448  }
449 
457  public static function getLogMessage( Throwable $e ) {
458  $id = WebRequest::getRequestId();
459  $type = get_class( $e );
460  $file = $e->getFile();
461  $line = $e->getLine();
462  $message = $e->getMessage();
463  $url = self::getURL() ?: '[no req]';
464 
465  return "[$id] $url $type from line $line of $file: $message";
466  }
467 
477  public static function getLogNormalMessage( Throwable $e ) {
478  $type = get_class( $e );
479  $file = $e->getFile();
480  $line = $e->getLine();
481  $message = $e->getMessage();
482 
483  return "[{exception_id}] {exception_url} $type from line $line of $file: $message";
484  }
485 
490  public static function getPublicLogMessage( Throwable $e ) {
491  $reqId = WebRequest::getRequestId();
492  $type = get_class( $e );
493  return '[' . $reqId . '] '
494  . gmdate( 'Y-m-d H:i:s' ) . ': '
495  . 'Fatal exception of type "' . $type . '"';
496  }
497 
510  public static function getLogContext( Throwable $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(
532  Throwable $e,
533  $catcher = self::CAUGHT_BY_OTHER
534  ) {
536 
537  $data = [
538  'id' => WebRequest::getRequestId(),
539  'type' => get_class( $e ),
540  'file' => $e->getFile(),
541  'line' => $e->getLine(),
542  'message' => $e->getMessage(),
543  'code' => $e->getCode(),
544  'url' => self::getURL() ?: null,
545  'caught_by' => $catcher
546  ];
547 
548  if ( $e instanceof ErrorException &&
549  ( error_reporting() & $e->getSeverity() ) === 0
550  ) {
551  // Flag surpressed errors
552  $data['suppressed'] = true;
553  }
554 
555  if ( $wgLogExceptionBacktrace ) {
556  $data['backtrace'] = self::getRedactedTrace( $e );
557  }
558 
559  $previous = $e->getPrevious();
560  if ( $previous !== null ) {
561  $data['previous'] = self::getStructuredExceptionData( $previous, $catcher );
562  }
563 
564  return $data;
565  }
566 
621  public static function jsonSerializeException(
622  Throwable $e,
623  $pretty = false,
624  $escaping = 0,
625  $catcher = self::CAUGHT_BY_OTHER
626  ) {
627  return FormatJson::encode(
628  self::getStructuredExceptionData( $e, $catcher ),
629  $pretty,
630  $escaping
631  );
632  }
633 
645  public static function logException(
646  Throwable $e,
647  $catcher = self::CAUGHT_BY_OTHER,
648  $extraData = []
649  ) {
650  if ( !( $e instanceof MWException ) || $e->isLoggable() ) {
651  $logger = LoggerFactory::getInstance( 'exception' );
652  $context = self::getLogContext( $e, $catcher );
653  if ( $extraData ) {
654  $context['extraData'] = $extraData;
655  }
656  $logger->error(
657  self::getLogNormalMessage( $e ),
658  $context
659  );
660 
661  $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK, $catcher );
662  if ( $json !== false ) {
663  $logger = LoggerFactory::getInstance( 'exception-json' );
664  $logger->error( $json, [ 'private' => true ] );
665  }
666 
667  Hooks::runner()->onLogException( $e, false );
668  }
669  }
670 
679  private static function logError(
680  ErrorException $e,
681  $channel,
682  $level,
683  $catcher
684  ) {
685  // The set_error_handler callback is independent from error_reporting.
686  // Filter out unwanted errors manually (e.g. when
687  // Wikimedia\suppressWarnings is active).
688  $suppressed = ( error_reporting() & $e->getSeverity() ) === 0;
689  if ( !$suppressed ) {
690  $logger = LoggerFactory::getInstance( $channel );
691  $logger->log(
692  $level,
693  self::getLogNormalMessage( $e ),
694  self::getLogContext( $e, $catcher )
695  );
696  }
697 
698  // Include all errors in the json log (surpressed errors will be flagged)
699  $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK, $catcher );
700  if ( $json !== false ) {
701  $logger = LoggerFactory::getInstance( "{$channel}-json" );
702  // Unlike the 'error' channel, the 'error-json' channel is unfiltered,
703  // and emits messages even if wikimedia/at-ease was used to suppress the
704  // error. To avoid clobbering Logstash dashboards with these, make sure
705  // those have their level casted to DEBUG so that they are excluded by
706  // level-based filteres automatically instead of requiring a dedicated filter
707  // for this channel. To be improved: T193472.
708  $unfilteredLevel = $suppressed ? LogLevel::DEBUG : $level;
709  $logger->log( $unfilteredLevel, $json, [ 'private' => true ] );
710  }
711 
712  Hooks::runner()->onLogException( $e, $suppressed );
713  }
714 }
MWExceptionHandler\handleFatalError
static handleFatalError()
Callback used as a registered shutdown function.
Definition: MWExceptionHandler.php:283
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:146
MWExceptionHandler\logError
static logError(ErrorException $e, $channel, $level, $catcher)
Log an exception that wasn't thrown but made to wrap an error.
Definition: MWExceptionHandler.php:679
MWExceptionHandler\getPublicLogMessage
static getPublicLogMessage(Throwable $e)
Definition: MWExceptionHandler.php:490
MWExceptionHandler
Handler class for MWExceptions.
Definition: MWExceptionHandler.php:30
MWExceptionHandler\redactTrace
static redactTrace(array $trace)
Redact a stacktrace generated by Throwable::getTrace(), debug_backtrace() or similar means.
Definition: MWExceptionHandler.php:424
$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:65
MWExceptionHandler\getStructuredExceptionData
static getStructuredExceptionData(Throwable $e, $catcher=self::CAUGHT_BY_OTHER)
Get a structured representation of a Throwable.
Definition: MWExceptionHandler.php:531
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:6687
MWExceptionHandler\logException
static logException(Throwable $e, $catcher=self::CAUGHT_BY_OTHER, $extraData=[])
Log a throwable to the exception log (if enabled).
Definition: MWExceptionHandler.php:645
MWExceptionHandler\report
static report(Throwable $e)
Report a throwable to the user.
Definition: MWExceptionHandler.php:96
MWExceptionHandler\getRedactedTraceAsString
static getRedactedTraceAsString(Throwable $e)
Generate a string representation of a throwable's stack trace.
Definition: MWExceptionHandler.php:351
FormatJson\encode
static encode( $value, $pretty=false, $escaping=0)
Returns the JSON representation of a value.
Definition: FormatJson.php:115
MWException
MediaWiki exception.
Definition: MWException.php:26
MWExceptionHandler\prettyPrintTrace
static prettyPrintTrace(array $trace, $pad='')
Generate a string representation of a stacktrace.
Definition: MWExceptionHandler.php:363
MWExceptionHandler\handleException
static handleException(Throwable $e, $catcher=self::CAUGHT_BY_OTHER)
Exception handler which simulates the appropriate catch() handling:
Definition: MWExceptionHandler.php:182
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:442
MWExceptionHandler\$fatalErrorTypes
static $fatalErrorTypes
Error types that, if unhandled, are fatal to the request.
Definition: MWExceptionHandler.php:51
MediaWiki
A helper class for throttling authentication attempts.
MWExceptionRenderer\output
static output(Throwable $e, $mode, Throwable $eNew=null)
Definition: MWExceptionRenderer.php:40
$wgLogExceptionBacktrace
$wgLogExceptionBacktrace
If true, send the exception backtrace to the error log.
Definition: DefaultSettings.php:6681
MWExceptionHandler\$reservedMemory
static $reservedMemory
Definition: MWExceptionHandler.php:39
MWExceptionRenderer\AS_PRETTY
const AS_PRETTY
Definition: MWExceptionRenderer.php:33
wfIsCLI
wfIsCLI()
Check if we are running from the commandline.
Definition: GlobalFunctions.php:1858
Hooks\runner
static runner()
Get a HookRunner instance for calling hooks using the new interfaces.
Definition: Hooks.php:171
MWExceptionHandler\getLogNormalMessage
static getLogNormalMessage(Throwable $e)
Get a normalised message for formatting with PSR-3 log event context.
Definition: MWExceptionHandler.php:477
$line
$line
Definition: mcc.php:119
MWExceptionHandler\getLogContext
static getLogContext(Throwable $e, $catcher=self::CAUGHT_BY_OTHER)
Get a PSR-3 log event context from a Throwable.
Definition: MWExceptionHandler.php:510
MWExceptionHandler\handleUncaughtException
static handleUncaughtException(Throwable $e)
Callback to use with PHP's set_exception_handler.
Definition: MWExceptionHandler.php:154
$context
$context
Definition: load.php:43
MWExceptionHandler\rollbackMasterChangesAndLog
static rollbackMasterChangesAndLog(Throwable $e, $catcher=self::CAUGHT_BY_OTHER)
Roll back any open database transactions and log the stack trace of the throwable.
Definition: MWExceptionHandler.php:124
WebRequest\getRequestId
static getRequestId()
Get the unique request ID.
Definition: WebRequest.php:330
WebRequest\getGlobalRequestURL
static getGlobalRequestURL()
Return the path and query string portion of the main request URI.
Definition: WebRequest.php:878
$wgRequest
if(! $wgDBerrorLogTZ) $wgRequest
Definition: Setup.php:643
MWExceptionHandler\getRedactedTrace
static getRedactedTrace(Throwable $e)
Return a copy of a throwable's backtrace as an array.
Definition: MWExceptionHandler.php:410
MWExceptionHandler\handleError
static handleError( $level, $message, $file=null, $line=null)
Handler for set_error_handler() callback notifications.
Definition: MWExceptionHandler.php:201
MWExceptionHandler\jsonSerializeException
static jsonSerializeException(Throwable $e, $pretty=false, $escaping=0, $catcher=self::CAUGHT_BY_OTHER)
Serialize a Throwable object to JSON.
Definition: MWExceptionHandler.php:621
MWExceptionHandler\getLogMessage
static getLogMessage(Throwable $e)
Get a message formatting the throwable message and its origin.
Definition: MWExceptionHandler.php:457
MWExceptionRenderer\AS_RAW
const AS_RAW
Definition: MWExceptionRenderer.php:32
$type
$type
Definition: testCompression.php:52