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 ( Throwable $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  '[{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( function ( $frame ) {
435  if ( isset( $frame['args'] ) ) {
436  $frame['args'] = array_map( 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?\n\n"
479  . $message;
480  }
481 
482  return "[$id] $url $type: $message";
483  }
484 
494  public static function getLogNormalMessage( Throwable $e ) {
495  $type = get_class( $e );
496  $message = $e->getMessage();
497 
498  return "[{reqId}] {exception_url} $type: $message";
499  }
500 
505  public static function getPublicLogMessage( Throwable $e ) {
506  $reqId = WebRequest::getRequestId();
507  $type = get_class( $e );
508  return '[' . $reqId . '] '
509  . gmdate( 'Y-m-d H:i:s' ) . ': '
510  . 'Fatal exception of type "' . $type . '"';
511  }
512 
525  public static function getLogContext( Throwable $e, $catcher = self::CAUGHT_BY_OTHER ) {
526  return [
527  'exception' => $e,
528  'exception_url' => self::getURL() ?: '[no req]',
529  // The reqId context key use the same familiar name and value as the top-level field
530  // provided by LogstashFormatter. However, formatters are configurable at run-time,
531  // and their top-level fields are logically separate from context keys and cannot be,
532  // substituted in a message, hence set explicitly here. For WMF users, these may feel,
533  // like the same thing due to Monolog V0 handling, which transmits "fields" and "context",
534  // in the same JSON object (after message formatting).
535  'reqId' => WebRequest::getRequestId(),
536  'caught_by' => $catcher
537  ];
538  }
539 
552  public static function getStructuredExceptionData(
553  Throwable $e,
554  $catcher = self::CAUGHT_BY_OTHER
555  ) {
557 
558  $data = [
559  'id' => WebRequest::getRequestId(),
560  'type' => get_class( $e ),
561  'file' => $e->getFile(),
562  'line' => $e->getLine(),
563  'message' => $e->getMessage(),
564  'code' => $e->getCode(),
565  'url' => self::getURL() ?: null,
566  'caught_by' => $catcher
567  ];
568 
569  if ( $e instanceof ErrorException &&
570  ( error_reporting() & $e->getSeverity() ) === 0
571  ) {
572  // Flag surpressed errors
573  $data['suppressed'] = true;
574  }
575 
576  if ( $wgLogExceptionBacktrace ) {
577  $data['backtrace'] = self::getRedactedTrace( $e );
578  }
579 
580  $previous = $e->getPrevious();
581  if ( $previous !== null ) {
582  $data['previous'] = self::getStructuredExceptionData( $previous, $catcher );
583  }
584 
585  return $data;
586  }
587 
642  public static function jsonSerializeException(
643  Throwable $e,
644  $pretty = false,
645  $escaping = 0,
646  $catcher = self::CAUGHT_BY_OTHER
647  ) {
648  return FormatJson::encode(
649  self::getStructuredExceptionData( $e, $catcher ),
650  $pretty,
651  $escaping
652  );
653  }
654 
666  public static function logException(
667  Throwable $e,
668  $catcher = self::CAUGHT_BY_OTHER,
669  $extraData = []
670  ) {
671  if ( !( $e instanceof MWException ) || $e->isLoggable() ) {
672  $logger = LoggerFactory::getInstance( 'exception' );
673  $context = self::getLogContext( $e, $catcher );
674  if ( $extraData ) {
675  $context['extraData'] = $extraData;
676  }
677  $logger->error(
678  self::getLogNormalMessage( $e ),
679  $context
680  );
681 
682  $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK, $catcher );
683  if ( $json !== false ) {
684  $logger = LoggerFactory::getInstance( 'exception-json' );
685  $logger->error( $json, [ 'private' => true ] );
686  }
687 
688  Hooks::runner()->onLogException( $e, false );
689  }
690  }
691 
700  private static function logError(
701  ErrorException $e,
702  $channel,
703  $level,
704  $catcher
705  ) {
706  // The set_error_handler callback is independent from error_reporting.
707  // Filter out unwanted errors manually (e.g. when
708  // Wikimedia\suppressWarnings is active).
709  $suppressed = ( error_reporting() & $e->getSeverity() ) === 0;
710  if ( !$suppressed ) {
711  $logger = LoggerFactory::getInstance( $channel );
712  $logger->log(
713  $level,
714  self::getLogNormalMessage( $e ),
715  self::getLogContext( $e, $catcher )
716  );
717  }
718 
719  // Include all errors in the json log (surpressed errors will be flagged)
720  $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK, $catcher );
721  if ( $json !== false ) {
722  $logger = LoggerFactory::getInstance( "{$channel}-json" );
723  // Unlike the 'error' channel, the 'error-json' channel is unfiltered,
724  // and emits messages even if wikimedia/at-ease was used to suppress the
725  // error. To avoid clobbering Logstash dashboards with these, make sure
726  // those have their level casted to DEBUG so that they are excluded by
727  // level-based filteres automatically instead of requiring a dedicated filter
728  // for this channel. To be improved: T193472.
729  $unfilteredLevel = $suppressed ? LogLevel::DEBUG : $level;
730  $logger->log( $unfilteredLevel, $json, [ 'private' => true ] );
731  }
732 
733  Hooks::runner()->onLogException( $e, $suppressed );
734  }
735 }
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:166
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:700
MWExceptionHandler\getPublicLogMessage
static getPublicLogMessage(Throwable $e)
Definition: MWExceptionHandler.php:505
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: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:66
MWExceptionHandler\getStructuredExceptionData
static getStructuredExceptionData(Throwable $e, $catcher=self::CAUGHT_BY_OTHER)
Get a structured representation of a Throwable.
Definition: MWExceptionHandler.php:552
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:6778
MWExceptionHandler\logException
static logException(Throwable $e, $catcher=self::CAUGHT_BY_OTHER, $extraData=[])
Log a throwable to the exception log (if enabled).
Definition: MWExceptionHandler.php:666
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: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: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:451
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:6772
MWExceptionHandler\$reservedMemory
static $reservedMemory
Definition: MWExceptionHandler.php:40
MWExceptionRenderer\AS_PRETTY
const AS_PRETTY
Definition: MWExceptionRenderer.php:33
Wikimedia\Rdbms\DBQueryError
@newable Stable to extend
Definition: DBQueryError.php:29
wfIsCLI
wfIsCLI()
Check if we are running from the commandline.
Definition: GlobalFunctions.php:1879
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:494
$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:525
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:328
WebRequest\getGlobalRequestURL
static getGlobalRequestURL()
Return the path and query string portion of the main request URI.
Definition: WebRequest.php:908
$wgRequest
if(! $wgDBerrorLogTZ) $wgRequest
Definition: Setup.php:645
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: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:642
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:32
$type
$type
Definition: testCompression.php:52