MediaWiki  master
MWExceptionHandler.php
Go to the documentation of this file.
1 <?php
23 use Psr\Log\LogLevel;
26 
33  public const CAUGHT_BY_HANDLER = 'mwe_handler';
35  public const CAUGHT_BY_ENTRYPOINT = 'entrypoint';
37  public const CAUGHT_BY_OTHER = 'other';
38 
40  protected static $reservedMemory;
41 
52  protected static $fatalErrorTypes = [
53  E_ERROR,
54  E_PARSE,
55  E_CORE_ERROR,
56  E_COMPILE_ERROR,
57  E_USER_ERROR,
58 
59  // E.g. "Catchable fatal error: Argument X must be Y, null given"
60  E_RECOVERABLE_ERROR,
61  ];
62 
66  public static function installHandler() {
67  // This catches:
68  // * Exception objects that were explicitly thrown but not
69  // caught anywhere in the application. This is rare given those
70  // would normally be caught at a high-level like MediaWiki::run (index.php),
71  // api.php, or ResourceLoader::respond (load.php). These high-level
72  // catch clauses would then call MWExceptionHandler::logException
73  // or MWExceptionHandler::handleException.
74  // If they are not caught, then they are handled here.
75  // * Error objects for issues that would historically
76  // cause fatal errors but may now be caught as Throwable (not Exception).
77  // Same as previous case, but more common to bubble to here instead of
78  // caught locally because they tend to not be safe to recover from.
79  // (e.g. argument TypeError, division by zero, etc.)
80  set_exception_handler( 'MWExceptionHandler::handleUncaughtException' );
81 
82  // This catches recoverable errors (e.g. PHP Notice, PHP Warning, PHP Error) that do not
83  // interrupt execution in any way. We log these in the background and then continue execution.
84  set_error_handler( 'MWExceptionHandler::handleError' );
85 
86  // This catches fatal errors for which no Throwable is thrown,
87  // including Out-Of-Memory and Timeout fatals.
88  // Reserve 16k of memory so we can report OOM fatals.
89  self::$reservedMemory = str_repeat( ' ', 16384 );
90  register_shutdown_function( 'MWExceptionHandler::handleFatalError' );
91  }
92 
97  protected static function report( Throwable $e ) {
98  try {
99  // Try and show the exception prettily, with the normal skin infrastructure
100  if ( $e instanceof MWException ) {
101  // Delegate to MWException until all subclasses are handled by
102  // MWExceptionRenderer and MWException::report() has been
103  // removed.
104  $e->report();
105  } else {
107  }
108  } catch ( Exception $e2 ) {
109  // Exception occurred from within exception handler
110  // Show a simpler message for the original exception,
111  // don't try to invoke report()
113  }
114  }
115 
125  public static function rollbackMasterChangesAndLog(
126  Throwable $e,
127  $catcher = self::CAUGHT_BY_OTHER
128  ) {
129  $services = MediaWikiServices::getInstance();
130  if ( !$services->isServiceDisabled( 'DBLoadBalancerFactory' ) ) {
131  // Rollback DBs to avoid transaction notices. This might fail
132  // to rollback some databases due to connection issues or exceptions.
133  // However, any sane DB driver will rollback implicitly anyway.
134  try {
135  $services->getDBLoadBalancerFactory()->rollbackMasterChanges( __METHOD__ );
136  } catch ( DBError $e2 ) {
137  // If the DB is unreacheable, rollback() will throw an error
138  // and the error report() method might need messages from the DB,
139  // which would result in an exception loop. PHP may escalate such
140  // errors to "Exception thrown without a stack frame" fatals, but
141  // it's better to be explicit here.
142  self::logException( $e2, $catcher );
143  }
144  }
145 
146  self::logException( $e, $catcher );
147  }
148 
155  public static function handleUncaughtException( Throwable $e ) {
156  self::handleException( $e, self::CAUGHT_BY_HANDLER );
157 
158  // Make sure we don't claim success on exit for CLI scripts (T177414)
159  if ( wfIsCLI() ) {
160  register_shutdown_function(
161  function () {
162  exit( 255 );
163  }
164  );
165  }
166  }
167 
183  public static function handleException( Throwable $e, $catcher = self::CAUGHT_BY_OTHER ) {
184  self::rollbackMasterChangesAndLog( $e, $catcher );
185  self::report( $e );
186  }
187 
202  public static function handleError(
203  $level,
204  $message,
205  $file = null,
206  $line = null
207  ) {
208  global $wgPropagateErrors;
209 
210  // Map PHP error constant to a PSR-3 severity level.
211  // Avoid use of "DEBUG" or "INFO" levels, unless the
212  // error should evade error monitoring and alerts.
213  //
214  // To decide the log level, ask yourself: "Has the
215  // program's behaviour diverged from what the written
216  // code expected?"
217  //
218  // For example, use of a deprecated method or violating a strict standard
219  // has no impact on functional behaviour (Warning). On the other hand,
220  // accessing an undefined variable makes behaviour diverge from what the
221  // author intended/expected. PHP recovers from an undefined variables by
222  // yielding null and continuing execution, but it remains a change in
223  // behaviour given the null was not part of the code and is likely not
224  // accounted for.
225  switch ( $level ) {
226  case E_WARNING:
227  case E_CORE_WARNING:
228  case E_COMPILE_WARNING:
229  $prefix = 'PHP Warning: ';
230  $severity = LogLevel::ERROR;
231  break;
232  case E_NOTICE:
233  $prefix = 'PHP Notice: ';
234  $severity = LogLevel::ERROR;
235  break;
236  case E_USER_NOTICE:
237  // Used by wfWarn(), MWDebug::warning()
238  $prefix = 'PHP Notice: ';
239  $severity = LogLevel::WARNING;
240  break;
241  case E_USER_WARNING:
242  // Used by wfWarn(), MWDebug::warning()
243  $prefix = 'PHP Warning: ';
244  $severity = LogLevel::WARNING;
245  break;
246  case E_STRICT:
247  $prefix = 'PHP Strict Standards: ';
248  $severity = LogLevel::WARNING;
249  break;
250  case E_DEPRECATED:
251  $prefix = 'PHP Deprecated: ';
252  $severity = LogLevel::WARNING;
253  break;
254  case E_USER_DEPRECATED:
255  $prefix = 'PHP Deprecated: ';
256  $severity = LogLevel::WARNING;
257  $real = MWDebug::parseCallerDescription( $message );
258  if ( $real ) {
259  // Used by wfDeprecated(), MWDebug::deprecated()
260  // Apply caller offset from wfDeprecated() to the native error.
261  // This makes errors easier to aggregate and find in e.g. Kibana.
262  $file = $real['file'];
263  $line = $real['line'];
264  $message = $real['message'];
265  $prefix = '';
266  }
267  break;
268  default:
269  $prefix = 'PHP Unknown error: ';
270  $severity = LogLevel::ERROR;
271  break;
272  }
273 
274  $e = new ErrorException( $prefix . $message, 0, $level, $file, $line );
275  self::logError( $e, 'error', $severity, self::CAUGHT_BY_HANDLER );
276 
277  // If $wgPropagateErrors is true return false so PHP shows/logs the error normally.
278  // Ignore $wgPropagateErrors if track_errors is set
279  // (which means someone is counting on regular PHP error handling behavior).
280  return !( $wgPropagateErrors || ini_get( 'track_errors' ) );
281  }
282 
297  public static function handleFatalError() {
298  // Free reserved memory so that we have space to process OOM
299  // errors
300  self::$reservedMemory = null;
301 
302  $lastError = error_get_last();
303  if ( $lastError === null ) {
304  return false;
305  }
306 
307  $level = $lastError['type'];
308  $message = $lastError['message'];
309  $file = $lastError['file'];
310  $line = $lastError['line'];
311 
312  if ( !in_array( $level, self::$fatalErrorTypes ) ) {
313  // Only interested in fatal errors, others should have been
314  // handled by MWExceptionHandler::handleError
315  return false;
316  }
317 
319  $msgParts = [
320  '[{exception_id}] {exception_url} PHP Fatal Error',
321  ( $line || $file ) ? ' from' : '',
322  $line ? " line $line" : '',
323  ( $line && $file ) ? ' of' : '',
324  $file ? " $file" : '',
325  ": $message",
326  ];
327  $msg = implode( '', $msgParts );
328 
329  // Look at message to see if this is a class not found failure (Class 'foo' not found)
330  if ( preg_match( "/Class '\w+' not found/", $message ) ) {
331  // phpcs:disable Generic.Files.LineLength
332  $msg = <<<TXT
333 {$msg}
334 
335 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.
336 
337 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.
338 TXT;
339  // phpcs:enable
340  }
341 
342  $e = new ErrorException( "PHP Fatal Error: {$message}", 0, $level, $file, $line );
343  $logger = LoggerFactory::getInstance( 'exception' );
344  $logger->error( $msg, [
345  'exception' => $e,
346  'exception_id' => WebRequest::getRequestId(),
347  'exception_url' => $url,
348  'caught_by' => self::CAUGHT_BY_HANDLER
349  ] );
350 
351  return false;
352  }
353 
364  public static function getRedactedTraceAsString( Throwable $e ) {
365  return self::prettyPrintTrace( self::getRedactedTrace( $e ) );
366  }
367 
376  public static function prettyPrintTrace( array $trace, $pad = '' ) {
377  $text = '';
378 
379  $level = 0;
380  foreach ( $trace as $level => $frame ) {
381  if ( isset( $frame['file'] ) && isset( $frame['line'] ) ) {
382  $text .= "{$pad}#{$level} {$frame['file']}({$frame['line']}): ";
383  } else {
384  // 'file' and 'line' are unset for calls from C code
385  // (T57634) This matches behaviour of
386  // Throwable::getTraceAsString to instead display "[internal
387  // function]".
388  $text .= "{$pad}#{$level} [internal function]: ";
389  }
390 
391  if ( isset( $frame['class'] ) && isset( $frame['type'] ) && isset( $frame['function'] ) ) {
392  $text .= $frame['class'] . $frame['type'] . $frame['function'];
393  } elseif ( isset( $frame['function'] ) ) {
394  $text .= $frame['function'];
395  } else {
396  $text .= 'NO_FUNCTION_GIVEN';
397  }
398 
399  if ( isset( $frame['args'] ) ) {
400  $text .= '(' . implode( ', ', $frame['args'] ) . ")\n";
401  } else {
402  $text .= "()\n";
403  }
404  }
405 
406  $level++;
407  $text .= "{$pad}#{$level} {main}";
408 
409  return $text;
410  }
411 
423  public static function getRedactedTrace( Throwable $e ) {
424  return static::redactTrace( $e->getTrace() );
425  }
426 
437  public static function redactTrace( array $trace ) {
438  return array_map( function ( $frame ) {
439  if ( isset( $frame['args'] ) ) {
440  $frame['args'] = array_map( function ( $arg ) {
441  return is_object( $arg ) ? get_class( $arg ) : gettype( $arg );
442  }, $frame['args'] );
443  }
444  return $frame;
445  }, $trace );
446  }
447 
455  public static function getURL() {
456  global $wgRequest;
457  if ( !isset( $wgRequest ) || $wgRequest instanceof FauxRequest ) {
458  return false;
459  }
460  return $wgRequest->getRequestURL();
461  }
462 
474  public static function getLogMessage( Throwable $e ) {
475  $id = WebRequest::getRequestId();
476  $type = get_class( $e );
477  $file = $e->getFile();
478  $line = $e->getLine();
479  $message = $e->getMessage();
480  $url = self::getURL() ?: '[no req]';
481 
482  if ( $e instanceof DBQueryError ) {
483  $message = "A database query error has occurred. Did you forget to run"
484  . " your application's database schema updater after upgrading?\n\n"
485  . $message;
486  }
487 
488  return "[$id] $url $type from line $line of $file: $message";
489  }
490 
500  public static function getLogNormalMessage( Throwable $e ) {
501  $type = get_class( $e );
502  $file = $e->getFile();
503  $line = $e->getLine();
504  $message = $e->getMessage();
505 
506  return "[{exception_id}] {exception_url} $type from line $line of $file: $message";
507  }
508 
513  public static function getPublicLogMessage( Throwable $e ) {
514  $reqId = WebRequest::getRequestId();
515  $type = get_class( $e );
516  return '[' . $reqId . '] '
517  . gmdate( 'Y-m-d H:i:s' ) . ': '
518  . 'Fatal exception of type "' . $type . '"';
519  }
520 
533  public static function getLogContext( Throwable $e, $catcher = self::CAUGHT_BY_OTHER ) {
534  return [
535  'exception' => $e,
536  'exception_id' => WebRequest::getRequestId(),
537  'exception_url' => self::getURL() ?: '[no req]',
538  'caught_by' => $catcher
539  ];
540  }
541 
554  public static function getStructuredExceptionData(
555  Throwable $e,
556  $catcher = self::CAUGHT_BY_OTHER
557  ) {
559 
560  $data = [
561  'id' => WebRequest::getRequestId(),
562  'type' => get_class( $e ),
563  'file' => $e->getFile(),
564  'line' => $e->getLine(),
565  'message' => $e->getMessage(),
566  'code' => $e->getCode(),
567  'url' => self::getURL() ?: null,
568  'caught_by' => $catcher
569  ];
570 
571  if ( $e instanceof ErrorException &&
572  ( error_reporting() & $e->getSeverity() ) === 0
573  ) {
574  // Flag surpressed errors
575  $data['suppressed'] = true;
576  }
577 
578  if ( $wgLogExceptionBacktrace ) {
579  $data['backtrace'] = self::getRedactedTrace( $e );
580  }
581 
582  $previous = $e->getPrevious();
583  if ( $previous !== null ) {
584  $data['previous'] = self::getStructuredExceptionData( $previous, $catcher );
585  }
586 
587  return $data;
588  }
589 
644  public static function jsonSerializeException(
645  Throwable $e,
646  $pretty = false,
647  $escaping = 0,
648  $catcher = self::CAUGHT_BY_OTHER
649  ) {
650  return FormatJson::encode(
651  self::getStructuredExceptionData( $e, $catcher ),
652  $pretty,
653  $escaping
654  );
655  }
656 
668  public static function logException(
669  Throwable $e,
670  $catcher = self::CAUGHT_BY_OTHER,
671  $extraData = []
672  ) {
673  if ( !( $e instanceof MWException ) || $e->isLoggable() ) {
674  $logger = LoggerFactory::getInstance( 'exception' );
675  $context = self::getLogContext( $e, $catcher );
676  if ( $extraData ) {
677  $context['extraData'] = $extraData;
678  }
679  $logger->error(
680  self::getLogNormalMessage( $e ),
681  $context
682  );
683 
684  $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK, $catcher );
685  if ( $json !== false ) {
686  $logger = LoggerFactory::getInstance( 'exception-json' );
687  $logger->error( $json, [ 'private' => true ] );
688  }
689 
690  Hooks::runner()->onLogException( $e, false );
691  }
692  }
693 
702  private static function logError(
703  ErrorException $e,
704  $channel,
705  $level,
706  $catcher
707  ) {
708  // The set_error_handler callback is independent from error_reporting.
709  // Filter out unwanted errors manually (e.g. when
710  // Wikimedia\suppressWarnings is active).
711  $suppressed = ( error_reporting() & $e->getSeverity() ) === 0;
712  if ( !$suppressed ) {
713  $logger = LoggerFactory::getInstance( $channel );
714  $logger->log(
715  $level,
716  self::getLogNormalMessage( $e ),
717  self::getLogContext( $e, $catcher )
718  );
719  }
720 
721  // Include all errors in the json log (surpressed errors will be flagged)
722  $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK, $catcher );
723  if ( $json !== false ) {
724  $logger = LoggerFactory::getInstance( "{$channel}-json" );
725  // Unlike the 'error' channel, the 'error-json' channel is unfiltered,
726  // and emits messages even if wikimedia/at-ease was used to suppress the
727  // error. To avoid clobbering Logstash dashboards with these, make sure
728  // those have their level casted to DEBUG so that they are excluded by
729  // level-based filteres automatically instead of requiring a dedicated filter
730  // for this channel. To be improved: T193472.
731  $unfilteredLevel = $suppressed ? LogLevel::DEBUG : $level;
732  $logger->log( $unfilteredLevel, $json, [ 'private' => true ] );
733  }
734 
735  Hooks::runner()->onLogException( $e, $suppressed );
736  }
737 }
MWExceptionHandler\handleFatalError
static handleFatalError()
Callback used as a registered shutdown function.
Definition: MWExceptionHandler.php:297
FauxRequest
WebRequest clone which takes values from a provided array.
Definition: FauxRequest.php:35
MediaWiki\MediaWikiServices
MediaWikiServices is the service locator for the application scope of MediaWiki.
Definition: MediaWikiServices.php:154
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:702
MWExceptionHandler\getPublicLogMessage
static getPublicLogMessage(Throwable $e)
Definition: MWExceptionHandler.php:513
MWExceptionHandler
Handler class for MWExceptions.
Definition: MWExceptionHandler.php:31
MWExceptionHandler\redactTrace
static redactTrace(array $trace)
Redact a stacktrace generated by Throwable::getTrace(), debug_backtrace() or similar means.
Definition: MWExceptionHandler.php:437
$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:66
MWExceptionHandler\getStructuredExceptionData
static getStructuredExceptionData(Throwable $e, $catcher=self::CAUGHT_BY_OTHER)
Get a structured representation of a Throwable.
Definition: MWExceptionHandler.php:554
Wikimedia\Rdbms\DBError
Database error base class @newable Stable to extend.
Definition: DBError.php:32
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:6763
MWExceptionHandler\logException
static logException(Throwable $e, $catcher=self::CAUGHT_BY_OTHER, $extraData=[])
Log a throwable to the exception log (if enabled).
Definition: MWExceptionHandler.php:668
MWExceptionHandler\report
static report(Throwable $e)
Report a throwable to the user.
Definition: MWExceptionHandler.php:97
MWExceptionHandler\getRedactedTraceAsString
static getRedactedTraceAsString(Throwable $e)
Generate a string representation of a throwable's stack trace.
Definition: MWExceptionHandler.php:364
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:29
MWExceptionHandler\prettyPrintTrace
static prettyPrintTrace(array $trace, $pad='')
Generate a string representation of a stacktrace.
Definition: MWExceptionHandler.php:376
MWExceptionHandler\handleException
static handleException(Throwable $e, $catcher=self::CAUGHT_BY_OTHER)
Exception handler which simulates the appropriate catch() handling:
Definition: MWExceptionHandler.php:183
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:455
MWExceptionHandler\$fatalErrorTypes
static $fatalErrorTypes
Error types that, if unhandled, are fatal to the request.
Definition: MWExceptionHandler.php:52
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:6757
MWExceptionHandler\$reservedMemory
static $reservedMemory
Definition: MWExceptionHandler.php:40
MWExceptionRenderer\AS_PRETTY
const AS_PRETTY
Definition: MWExceptionRenderer.php:33
Wikimedia\Rdbms\DBQueryError
Definition: DBQueryError.php:29
wfIsCLI
wfIsCLI()
Check if we are running from the commandline.
Definition: GlobalFunctions.php:1856
Hooks\runner
static runner()
Get a HookRunner instance for calling hooks using the new interfaces.
Definition: Hooks.php:172
MWExceptionHandler\getLogNormalMessage
static getLogNormalMessage(Throwable $e)
Get a normalised message for formatting with PSR-3 log event context.
Definition: MWExceptionHandler.php:500
$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:533
MWExceptionHandler\handleUncaughtException
static handleUncaughtException(Throwable $e)
Callback to use with PHP's set_exception_handler.
Definition: MWExceptionHandler.php:155
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:125
WebRequest\getRequestId
static getRequestId()
Get the unique request ID.
Definition: WebRequest.php:327
WebRequest\getGlobalRequestURL
static getGlobalRequestURL()
Return the path and query string portion of the main request URI.
Definition: WebRequest.php:907
$wgRequest
if(! $wgDBerrorLogTZ) $wgRequest
Definition: Setup.php:644
MWExceptionHandler\getRedactedTrace
static getRedactedTrace(Throwable $e)
Return a copy of a throwable's backtrace as an array.
Definition: MWExceptionHandler.php:423
MWDebug\parseCallerDescription
static parseCallerDescription( $msg)
Append a caller description to an error message.
Definition: MWDebug.php:427
MWExceptionHandler\handleError
static handleError( $level, $message, $file=null, $line=null)
Handler for set_error_handler() callback notifications.
Definition: MWExceptionHandler.php:202
MWExceptionHandler\jsonSerializeException
static jsonSerializeException(Throwable $e, $pretty=false, $escaping=0, $catcher=self::CAUGHT_BY_OTHER)
Serialize a Throwable object to JSON.
Definition: MWExceptionHandler.php:644
MWExceptionHandler\getLogMessage
static getLogMessage(Throwable $e)
Get a message formatting the throwable message and its origin.
Definition: MWExceptionHandler.php:474
MWExceptionRenderer\AS_RAW
const AS_RAW
Definition: MWExceptionRenderer.php:32
$type
$type
Definition: testCompression.php:52