MediaWiki  master
MWExceptionHandler.php
Go to the documentation of this file.
1 <?php
23 use Psr\Log\LogLevel;
24 use Wikimedia\NormalizedException\INormalizedException;
27 
34  public const CAUGHT_BY_HANDLER = 'mwe_handler';
36  public const CAUGHT_BY_ENTRYPOINT = 'entrypoint';
38  public const CAUGHT_BY_OTHER = 'other';
39 
41  protected static $reservedMemory;
42 
53  protected static $fatalErrorTypes = [
54  E_ERROR,
55  E_PARSE,
56  E_CORE_ERROR,
57  E_COMPILE_ERROR,
58  E_USER_ERROR,
59 
60  // E.g. "Catchable fatal error: Argument X must be Y, null given"
61  E_RECOVERABLE_ERROR,
62  ];
63 
67  public static function installHandler() {
68  // This catches:
69  // * Exception objects that were explicitly thrown but not
70  // caught anywhere in the application. This is rare given those
71  // would normally be caught at a high-level like MediaWiki::run (index.php),
72  // api.php, or ResourceLoader::respond (load.php). These high-level
73  // catch clauses would then call MWExceptionHandler::logException
74  // or MWExceptionHandler::handleException.
75  // If they are not caught, then they are handled here.
76  // * Error objects for issues that would historically
77  // cause fatal errors but may now be caught as Throwable (not Exception).
78  // Same as previous case, but more common to bubble to here instead of
79  // caught locally because they tend to not be safe to recover from.
80  // (e.g. argument TypeError, division by zero, etc.)
81  set_exception_handler( 'MWExceptionHandler::handleUncaughtException' );
82 
83  // This catches recoverable errors (e.g. PHP Notice, PHP Warning, PHP Error) that do not
84  // interrupt execution in any way. We log these in the background and then continue execution.
85  set_error_handler( 'MWExceptionHandler::handleError' );
86 
87  // This catches fatal errors for which no Throwable is thrown,
88  // including Out-Of-Memory and Timeout fatals.
89  // Reserve 16k of memory so we can report OOM fatals.
90  self::$reservedMemory = str_repeat( ' ', 16384 );
91  register_shutdown_function( 'MWExceptionHandler::handleFatalError' );
92  }
93 
98  protected static function report( Throwable $e ) {
99  try {
100  // Try and show the exception prettily, with the normal skin infrastructure
101  if ( $e instanceof MWException ) {
102  // Delegate to MWException until all subclasses are handled by
103  // MWExceptionRenderer and MWException::report() has been
104  // removed.
105  $e->report();
106  } else {
108  }
109  } catch ( Throwable $e2 ) {
110  // Exception occurred from within exception handler
111  // Show a simpler message for the original exception,
112  // don't try to invoke report()
114  }
115  }
116 
126  public static function rollbackMasterChangesAndLog(
127  Throwable $e,
128  $catcher = self::CAUGHT_BY_OTHER
129  ) {
130  $services = MediaWikiServices::getInstance();
131  if ( !$services->isServiceDisabled( 'DBLoadBalancerFactory' ) ) {
132  // Rollback DBs to avoid transaction notices. This might fail
133  // to rollback some databases due to connection issues or exceptions.
134  // However, any sane DB driver will rollback implicitly anyway.
135  try {
136  $services->getDBLoadBalancerFactory()->rollbackMasterChanges( __METHOD__ );
137  } catch ( DBError $e2 ) {
138  // If the DB is unreacheable, rollback() will throw an error
139  // and the error report() method might need messages from the DB,
140  // which would result in an exception loop. PHP may escalate such
141  // errors to "Exception thrown without a stack frame" fatals, but
142  // it's better to be explicit here.
143  self::logException( $e2, $catcher );
144  }
145  }
146 
147  self::logException( $e, $catcher );
148  }
149 
156  public static function handleUncaughtException( Throwable $e ) {
157  self::handleException( $e, self::CAUGHT_BY_HANDLER );
158 
159  // Make sure we don't claim success on exit for CLI scripts (T177414)
160  if ( wfIsCLI() ) {
161  register_shutdown_function(
162  static function () {
163  exit( 255 );
164  }
165  );
166  }
167  }
168 
184  public static function handleException( Throwable $e, $catcher = self::CAUGHT_BY_OTHER ) {
185  self::rollbackMasterChangesAndLog( $e, $catcher );
186  self::report( $e );
187  }
188 
203  public static function handleError(
204  $level,
205  $message,
206  $file = null,
207  $line = null
208  ) {
209  global $wgPropagateErrors;
210 
211  // Map PHP error constant to a PSR-3 severity level.
212  // Avoid use of "DEBUG" or "INFO" levels, unless the
213  // error should evade error monitoring and alerts.
214  //
215  // To decide the log level, ask yourself: "Has the
216  // program's behaviour diverged from what the written
217  // code expected?"
218  //
219  // For example, use of a deprecated method or violating a strict standard
220  // has no impact on functional behaviour (Warning). On the other hand,
221  // accessing an undefined variable makes behaviour diverge from what the
222  // author intended/expected. PHP recovers from an undefined variables by
223  // yielding null and continuing execution, but it remains a change in
224  // behaviour given the null was not part of the code and is likely not
225  // accounted for.
226  switch ( $level ) {
227  case E_WARNING:
228  case E_CORE_WARNING:
229  case E_COMPILE_WARNING:
230  $prefix = 'PHP Warning: ';
231  $severity = LogLevel::ERROR;
232  break;
233  case E_NOTICE:
234  $prefix = 'PHP Notice: ';
235  $severity = LogLevel::ERROR;
236  break;
237  case E_USER_NOTICE:
238  // Used by wfWarn(), MWDebug::warning()
239  $prefix = 'PHP Notice: ';
240  $severity = LogLevel::WARNING;
241  break;
242  case E_USER_WARNING:
243  // Used by wfWarn(), MWDebug::warning()
244  $prefix = 'PHP Warning: ';
245  $severity = LogLevel::WARNING;
246  break;
247  case E_STRICT:
248  $prefix = 'PHP Strict Standards: ';
249  $severity = LogLevel::WARNING;
250  break;
251  case E_DEPRECATED:
252  $prefix = 'PHP Deprecated: ';
253  $severity = LogLevel::WARNING;
254  break;
255  case E_USER_DEPRECATED:
256  $prefix = 'PHP Deprecated: ';
257  $severity = LogLevel::WARNING;
258  $real = MWDebug::parseCallerDescription( $message );
259  if ( $real ) {
260  // Used by wfDeprecated(), MWDebug::deprecated()
261  // Apply caller offset from wfDeprecated() to the native error.
262  // This makes errors easier to aggregate and find in e.g. Kibana.
263  $file = $real['file'];
264  $line = $real['line'];
265  $message = $real['message'];
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  '[{reqId}] {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, self::getLogContext( $e, self::CAUGHT_BY_HANDLER ) );
345 
346  return false;
347  }
348 
359  public static function getRedactedTraceAsString( Throwable $e ) {
360  $from = 'from ' . $e->getFile() . '(' . $e->getLine() . ')' . "\n";
361  return $from . self::prettyPrintTrace( self::getRedactedTrace( $e ) );
362  }
363 
372  public static function prettyPrintTrace( array $trace, $pad = '' ) {
373  $text = '';
374 
375  $level = 0;
376  foreach ( $trace as $level => $frame ) {
377  if ( isset( $frame['file'] ) && isset( $frame['line'] ) ) {
378  $text .= "{$pad}#{$level} {$frame['file']}({$frame['line']}): ";
379  } else {
380  // 'file' and 'line' are unset for calls from C code
381  // (T57634) This matches behaviour of
382  // Throwable::getTraceAsString to instead display "[internal
383  // function]".
384  $text .= "{$pad}#{$level} [internal function]: ";
385  }
386 
387  if ( isset( $frame['class'] ) && isset( $frame['type'] ) && isset( $frame['function'] ) ) {
388  $text .= $frame['class'] . $frame['type'] . $frame['function'];
389  } elseif ( isset( $frame['function'] ) ) {
390  $text .= $frame['function'];
391  } else {
392  $text .= 'NO_FUNCTION_GIVEN';
393  }
394 
395  if ( isset( $frame['args'] ) ) {
396  $text .= '(' . implode( ', ', $frame['args'] ) . ")\n";
397  } else {
398  $text .= "()\n";
399  }
400  }
401 
402  $level++;
403  $text .= "{$pad}#{$level} {main}";
404 
405  return $text;
406  }
407 
419  public static function getRedactedTrace( Throwable $e ) {
420  return static::redactTrace( $e->getTrace() );
421  }
422 
433  public static function redactTrace( array $trace ) {
434  return array_map( static function ( $frame ) {
435  if ( isset( $frame['args'] ) ) {
436  $frame['args'] = array_map( static function ( $arg ) {
437  return is_object( $arg ) ? get_class( $arg ) : gettype( $arg );
438  }, $frame['args'] );
439  }
440  return $frame;
441  }, $trace );
442  }
443 
451  public static function getURL() {
452  global $wgRequest;
453  if ( !isset( $wgRequest ) || $wgRequest instanceof FauxRequest ) {
454  return false;
455  }
456  return $wgRequest->getRequestURL();
457  }
458 
470  public static function getLogMessage( Throwable $e ) {
471  $id = WebRequest::getRequestId();
472  $type = get_class( $e );
473  $message = $e->getMessage();
474  $url = self::getURL() ?: '[no req]';
475 
476  if ( $e instanceof DBQueryError ) {
477  $message = "A database query error has occurred. Did you forget to run"
478  . " your application's database schema updater after upgrading"
479  . " or after adding a new extension?\n\nPlease see"
480  . " https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Upgrading and"
481  . " https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:How_to_debug"
482  . " for more information.\n\n"
483  . $message;
484  }
485 
486  return "[$id] $url $type: $message";
487  }
488 
498  public static function getLogNormalMessage( Throwable $e ) {
499  if ( $e instanceof INormalizedException ) {
500  $message = $e->getNormalizedMessage();
501  } else {
502  $message = $e->getMessage();
503  }
504  if ( !$e instanceof ErrorException ) {
505  // ErrorException is something we use internally to represent
506  // PHP errors (runtime warnings that aren't thrown or caught),
507  // don't bother putting it in the logs. Let the log message
508  // lead with "PHP Warning: " instead (see ::handleError).
509  $message = get_class( $e ) . ": $message";
510  }
511 
512  return "[{reqId}] {exception_url} $message";
513  }
514 
519  public static function getPublicLogMessage( Throwable $e ) {
520  $reqId = WebRequest::getRequestId();
521  $type = get_class( $e );
522  return '[' . $reqId . '] '
523  . gmdate( 'Y-m-d H:i:s' ) . ': '
524  . 'Fatal exception of type "' . $type . '"';
525  }
526 
539  public static function getLogContext( Throwable $e, $catcher = self::CAUGHT_BY_OTHER ) {
540  $context = [
541  'exception' => $e,
542  'exception_url' => self::getURL() ?: '[no req]',
543  // The reqId context key use the same familiar name and value as the top-level field
544  // provided by LogstashFormatter. However, formatters are configurable at run-time,
545  // and their top-level fields are logically separate from context keys and cannot be,
546  // substituted in a message, hence set explicitly here. For WMF users, these may feel,
547  // like the same thing due to Monolog V0 handling, which transmits "fields" and "context",
548  // in the same JSON object (after message formatting).
549  'reqId' => WebRequest::getRequestId(),
550  'caught_by' => $catcher
551  ];
552  if ( $e instanceof INormalizedException ) {
553  $context += $e->getMessageContext();
554  }
555  return $context;
556  }
557 
570  public static function getStructuredExceptionData(
571  Throwable $e,
572  $catcher = self::CAUGHT_BY_OTHER
573  ) {
575 
576  $data = [
577  'id' => WebRequest::getRequestId(),
578  'type' => get_class( $e ),
579  'file' => $e->getFile(),
580  'line' => $e->getLine(),
581  'message' => $e->getMessage(),
582  'code' => $e->getCode(),
583  'url' => self::getURL() ?: null,
584  'caught_by' => $catcher
585  ];
586 
587  if ( $e instanceof ErrorException &&
588  ( error_reporting() & $e->getSeverity() ) === 0
589  ) {
590  // Flag surpressed errors
591  $data['suppressed'] = true;
592  }
593 
594  if ( $wgLogExceptionBacktrace ) {
595  $data['backtrace'] = self::getRedactedTrace( $e );
596  }
597 
598  $previous = $e->getPrevious();
599  if ( $previous !== null ) {
600  $data['previous'] = self::getStructuredExceptionData( $previous, $catcher );
601  }
602 
603  return $data;
604  }
605 
660  public static function jsonSerializeException(
661  Throwable $e,
662  $pretty = false,
663  $escaping = 0,
664  $catcher = self::CAUGHT_BY_OTHER
665  ) {
666  return FormatJson::encode(
667  self::getStructuredExceptionData( $e, $catcher ),
668  $pretty,
669  $escaping
670  );
671  }
672 
684  public static function logException(
685  Throwable $e,
686  $catcher = self::CAUGHT_BY_OTHER,
687  $extraData = []
688  ) {
689  if ( !( $e instanceof MWException ) || $e->isLoggable() ) {
690  $logger = LoggerFactory::getInstance( 'exception' );
691  $context = self::getLogContext( $e, $catcher );
692  if ( $extraData ) {
693  $context['extraData'] = $extraData;
694  }
695  $logger->error(
696  self::getLogNormalMessage( $e ),
697  $context
698  );
699 
700  $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK, $catcher );
701  if ( $json !== false ) {
702  $logger = LoggerFactory::getInstance( 'exception-json' );
703  $logger->error( $json, [ 'private' => true ] );
704  }
705 
706  Hooks::runner()->onLogException( $e, false );
707  }
708  }
709 
718  private static function logError(
719  ErrorException $e,
720  $channel,
721  $level,
722  $catcher
723  ) {
724  // The set_error_handler callback is independent from error_reporting.
725  // Filter out unwanted errors manually (e.g. when
726  // Wikimedia\suppressWarnings is active).
727  $suppressed = ( error_reporting() & $e->getSeverity() ) === 0;
728  if ( !$suppressed ) {
729  $logger = LoggerFactory::getInstance( $channel );
730  $logger->log(
731  $level,
732  self::getLogNormalMessage( $e ),
733  self::getLogContext( $e, $catcher )
734  );
735  }
736 
737  // Include all errors in the json log (surpressed errors will be flagged)
738  $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK, $catcher );
739  if ( $json !== false ) {
740  $logger = LoggerFactory::getInstance( "{$channel}-json" );
741  // Unlike the 'error' channel, the 'error-json' channel is unfiltered,
742  // and emits messages even if wikimedia/at-ease was used to suppress the
743  // error. To avoid clobbering Logstash dashboards with these, make sure
744  // those have their level casted to DEBUG so that they are excluded by
745  // level-based filteres automatically instead of requiring a dedicated filter
746  // for this channel. To be improved: T193472.
747  $unfilteredLevel = $suppressed ? LogLevel::DEBUG : $level;
748  $logger->log( $unfilteredLevel, $json, [ 'private' => true ] );
749  }
750 
751  Hooks::runner()->onLogException( $e, $suppressed );
752  }
753 }
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:186
$wgRequest
$wgRequest
Definition: Setup.php:681
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:718
MWExceptionHandler\getPublicLogMessage
static getPublicLogMessage(Throwable $e)
Definition: MWExceptionHandler.php:519
MWExceptionHandler
Handler class for MWExceptions.
Definition: MWExceptionHandler.php:32
MWExceptionHandler\redactTrace
static redactTrace(array $trace)
Redact a stacktrace generated by Throwable::getTrace(), debug_backtrace() or similar means.
Definition: MWExceptionHandler.php:433
$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:67
MWExceptionHandler\getStructuredExceptionData
static getStructuredExceptionData(Throwable $e, $catcher=self::CAUGHT_BY_OTHER)
Get a structured representation of a Throwable.
Definition: MWExceptionHandler.php:570
Wikimedia\Rdbms\DBError
Database error base class @newable.
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:7444
MWExceptionHandler\logException
static logException(Throwable $e, $catcher=self::CAUGHT_BY_OTHER, $extraData=[])
Log a throwable to the exception log (if enabled).
Definition: MWExceptionHandler.php:684
MWExceptionHandler\report
static report(Throwable $e)
Report a throwable to the user.
Definition: MWExceptionHandler.php:98
MWExceptionHandler\getRedactedTraceAsString
static getRedactedTraceAsString(Throwable $e)
Generate a string representation of a throwable's stack trace.
Definition: MWExceptionHandler.php:359
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:372
MWExceptionHandler\handleException
static handleException(Throwable $e, $catcher=self::CAUGHT_BY_OTHER)
Exception handler which simulates the appropriate catch() handling:
Definition: MWExceptionHandler.php:184
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:451
MWExceptionHandler\$fatalErrorTypes
static array $fatalErrorTypes
Error types that, if unhandled, are fatal to the request.
Definition: MWExceptionHandler.php:53
MediaWiki
A helper class for throttling authentication attempts.
MWExceptionRenderer\output
static output(Throwable $e, $mode, Throwable $eNew=null)
Definition: MWExceptionRenderer.php:41
$wgLogExceptionBacktrace
$wgLogExceptionBacktrace
If true, send the exception backtrace to the error log.
Definition: DefaultSettings.php:7438
MWExceptionHandler\$reservedMemory
static string $reservedMemory
Definition: MWExceptionHandler.php:37
MWExceptionRenderer\AS_PRETTY
const AS_PRETTY
Definition: MWExceptionRenderer.php:34
Wikimedia\Rdbms\DBQueryError
@newable
Definition: DBQueryError.php:29
wfIsCLI
wfIsCLI()
Check if we are running from the commandline.
Definition: GlobalFunctions.php:1740
Hooks\runner
static runner()
Get a HookRunner instance for calling hooks using the new interfaces.
Definition: Hooks.php:173
MWExceptionHandler\getLogNormalMessage
static getLogNormalMessage(Throwable $e)
Get a normalised message for formatting with PSR-3 log event context.
Definition: MWExceptionHandler.php:498
$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:539
MWExceptionHandler\handleUncaughtException
static handleUncaughtException(Throwable $e)
Callback to use with PHP's set_exception_handler.
Definition: MWExceptionHandler.php:156
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:126
WebRequest\getRequestId
static getRequestId()
Get the current 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:902
MWExceptionHandler\getRedactedTrace
static getRedactedTrace(Throwable $e)
Return a copy of a throwable's backtrace as an array.
Definition: MWExceptionHandler.php:419
MWDebug\parseCallerDescription
static parseCallerDescription( $msg)
Append a caller description to an error message.
Definition: MWDebug.php:474
MWExceptionHandler\handleError
static handleError( $level, $message, $file=null, $line=null)
Handler for set_error_handler() callback notifications.
Definition: MWExceptionHandler.php:203
MWExceptionHandler\jsonSerializeException
static jsonSerializeException(Throwable $e, $pretty=false, $escaping=0, $catcher=self::CAUGHT_BY_OTHER)
Serialize a Throwable object to JSON.
Definition: MWExceptionHandler.php:660
MWExceptionHandler\getLogMessage
static getLogMessage(Throwable $e)
Get a message formatting the throwable message and its origin.
Definition: MWExceptionHandler.php:470
MWExceptionRenderer\AS_RAW
const AS_RAW
Definition: MWExceptionRenderer.php:33
$type
$type
Definition: testCompression.php:52