MediaWiki REL1_37
MWExceptionHandler.php
Go to the documentation of this file.
1<?php
23use Psr\Log\LogLevel;
24use 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 rollbackPrimaryChangesAndLog(
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()->rollbackPrimaryChanges( __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
155 public static function rollbackMasterChangesAndLog(
156 Throwable $e,
157 $catcher = self::CAUGHT_BY_OTHER
158 ) {
159 wfDeprecated( __METHOD__, '1.37' );
160 self::rollbackPrimaryChangesAndLog( $e, $catcher );
161 }
162
169 public static function handleUncaughtException( Throwable $e ) {
170 self::handleException( $e, self::CAUGHT_BY_HANDLER );
171
172 // Make sure we don't claim success on exit for CLI scripts (T177414)
173 if ( wfIsCLI() ) {
174 register_shutdown_function(
178 static function () {
179 exit( 255 );
180 }
181 );
182 }
183 }
184
200 public static function handleException( Throwable $e, $catcher = self::CAUGHT_BY_OTHER ) {
201 self::rollbackPrimaryChangesAndLog( $e, $catcher );
202 self::report( $e );
203 }
204
219 public static function handleError(
220 $level,
221 $message,
222 $file = null,
223 $line = null
224 ) {
225 global $wgPropagateErrors;
226
227 // Map PHP error constant to a PSR-3 severity level.
228 // Avoid use of "DEBUG" or "INFO" levels, unless the
229 // error should evade error monitoring and alerts.
230 //
231 // To decide the log level, ask yourself: "Has the
232 // program's behaviour diverged from what the written
233 // code expected?"
234 //
235 // For example, use of a deprecated method or violating a strict standard
236 // has no impact on functional behaviour (Warning). On the other hand,
237 // accessing an undefined variable makes behaviour diverge from what the
238 // author intended/expected. PHP recovers from an undefined variables by
239 // yielding null and continuing execution, but it remains a change in
240 // behaviour given the null was not part of the code and is likely not
241 // accounted for.
242 switch ( $level ) {
243 case E_WARNING:
244 case E_CORE_WARNING:
245 case E_COMPILE_WARNING:
246 $prefix = 'PHP Warning: ';
247 $severity = LogLevel::ERROR;
248 break;
249 case E_NOTICE:
250 $prefix = 'PHP Notice: ';
251 $severity = LogLevel::ERROR;
252 break;
253 case E_USER_NOTICE:
254 // Used by wfWarn(), MWDebug::warning()
255 $prefix = 'PHP Notice: ';
256 $severity = LogLevel::WARNING;
257 break;
258 case E_USER_WARNING:
259 // Used by wfWarn(), MWDebug::warning()
260 $prefix = 'PHP Warning: ';
261 $severity = LogLevel::WARNING;
262 break;
263 case E_STRICT:
264 $prefix = 'PHP Strict Standards: ';
265 $severity = LogLevel::WARNING;
266 break;
267 case E_DEPRECATED:
268 $prefix = 'PHP Deprecated: ';
269 $severity = LogLevel::WARNING;
270 break;
271 case E_USER_DEPRECATED:
272 $prefix = 'PHP Deprecated: ';
273 $severity = LogLevel::WARNING;
274 $real = MWDebug::parseCallerDescription( $message );
275 if ( $real ) {
276 // Used by wfDeprecated(), MWDebug::deprecated()
277 // Apply caller offset from wfDeprecated() to the native error.
278 // This makes errors easier to aggregate and find in e.g. Kibana.
279 $file = $real['file'];
280 $line = $real['line'];
281 $message = $real['message'];
282 }
283 break;
284 default:
285 $prefix = 'PHP Unknown error: ';
286 $severity = LogLevel::ERROR;
287 break;
288 }
289
290 $e = new ErrorException( $prefix . $message, 0, $level, $file, $line );
291 self::logError( $e, 'error', $severity, self::CAUGHT_BY_HANDLER );
292
293 // If $wgPropagateErrors is true return false so PHP shows/logs the error normally.
294 // Ignore $wgPropagateErrors if track_errors is set
295 // (which means someone is counting on regular PHP error handling behavior).
296 return !( $wgPropagateErrors || ini_get( 'track_errors' ) );
297 }
298
313 public static function handleFatalError() {
314 // Free reserved memory so that we have space to process OOM
315 // errors
316 self::$reservedMemory = null;
317
318 $lastError = error_get_last();
319 if ( $lastError === null ) {
320 return false;
321 }
322
323 $level = $lastError['type'];
324 $message = $lastError['message'];
325 $file = $lastError['file'];
326 $line = $lastError['line'];
327
328 if ( !in_array( $level, self::$fatalErrorTypes ) ) {
329 // Only interested in fatal errors, others should have been
330 // handled by MWExceptionHandler::handleError
331 return false;
332 }
333
334 $url = WebRequest::getGlobalRequestURL();
335 $msgParts = [
336 '[{reqId}] {exception_url} PHP Fatal Error',
337 ( $line || $file ) ? ' from' : '',
338 $line ? " line $line" : '',
339 ( $line && $file ) ? ' of' : '',
340 $file ? " $file" : '',
341 ": $message",
342 ];
343 $msg = implode( '', $msgParts );
344
345 // Look at message to see if this is a class not found failure (Class 'foo' not found)
346 if ( preg_match( "/Class '\w+' not found/", $message ) ) {
347 // phpcs:disable Generic.Files.LineLength
348 $msg = <<<TXT
349{$msg}
350
351MediaWiki 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.
352
353Please see <a href="https://www.mediawiki.org/wiki/Download_from_Git#Fetch_external_libraries">mediawiki.org</a> for help on installing the required components.
354TXT;
355 // phpcs:enable
356 }
357
358 $e = new ErrorException( "PHP Fatal Error: {$message}", 0, $level, $file, $line );
359 $logger = LoggerFactory::getInstance( 'exception' );
360 $logger->error( $msg, self::getLogContext( $e, self::CAUGHT_BY_HANDLER ) );
361
362 return false;
363 }
364
375 public static function getRedactedTraceAsString( Throwable $e ) {
376 $from = 'from ' . $e->getFile() . '(' . $e->getLine() . ')' . "\n";
377 return $from . self::prettyPrintTrace( self::getRedactedTrace( $e ) );
378 }
379
388 public static function prettyPrintTrace( array $trace, $pad = '' ) {
389 $text = '';
390
391 $level = 0;
392 foreach ( $trace as $level => $frame ) {
393 if ( isset( $frame['file'] ) && isset( $frame['line'] ) ) {
394 $text .= "{$pad}#{$level} {$frame['file']}({$frame['line']}): ";
395 } else {
396 // 'file' and 'line' are unset for calls from C code
397 // (T57634) This matches behaviour of
398 // Throwable::getTraceAsString to instead display "[internal
399 // function]".
400 $text .= "{$pad}#{$level} [internal function]: ";
401 }
402
403 if ( isset( $frame['class'] ) && isset( $frame['type'] ) && isset( $frame['function'] ) ) {
404 $text .= $frame['class'] . $frame['type'] . $frame['function'];
405 } elseif ( isset( $frame['function'] ) ) {
406 $text .= $frame['function'];
407 } else {
408 $text .= 'NO_FUNCTION_GIVEN';
409 }
410
411 if ( isset( $frame['args'] ) ) {
412 $text .= '(' . implode( ', ', $frame['args'] ) . ")\n";
413 } else {
414 $text .= "()\n";
415 }
416 }
417
418 $level++;
419 $text .= "{$pad}#{$level} {main}";
420
421 return $text;
422 }
423
435 public static function getRedactedTrace( Throwable $e ) {
436 return static::redactTrace( $e->getTrace() );
437 }
438
449 public static function redactTrace( array $trace ) {
450 return array_map( static function ( $frame ) {
451 if ( isset( $frame['args'] ) ) {
452 $frame['args'] = array_map( static function ( $arg ) {
453 return is_object( $arg ) ? get_class( $arg ) : gettype( $arg );
454 }, $frame['args'] );
455 }
456 return $frame;
457 }, $trace );
458 }
459
467 public static function getURL() {
468 global $wgRequest;
469 if ( !isset( $wgRequest ) || $wgRequest instanceof FauxRequest ) {
470 return false;
471 }
472 return $wgRequest->getRequestURL();
473 }
474
486 public static function getLogMessage( Throwable $e ) {
487 $id = WebRequest::getRequestId();
488 $type = get_class( $e );
489 $message = $e->getMessage();
490 $url = self::getURL() ?: '[no req]';
491
492 if ( $e instanceof DBQueryError ) {
493 $message = "A database query error has occurred. Did you forget to run"
494 . " your application's database schema updater after upgrading"
495 . " or after adding a new extension?\n\nPlease see"
496 . " https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Upgrading and"
497 . " https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:How_to_debug"
498 . " for more information.\n\n"
499 . $message;
500 }
501
502 return "[$id] $url $type: $message";
503 }
504
514 public static function getLogNormalMessage( Throwable $e ) {
515 if ( $e instanceof INormalizedException ) {
516 $message = $e->getNormalizedMessage();
517 } else {
518 $message = $e->getMessage();
519 }
520 if ( !$e instanceof ErrorException ) {
521 // ErrorException is something we use internally to represent
522 // PHP errors (runtime warnings that aren't thrown or caught),
523 // don't bother putting it in the logs. Let the log message
524 // lead with "PHP Warning: " instead (see ::handleError).
525 $message = get_class( $e ) . ": $message";
526 }
527
528 return "[{reqId}] {exception_url} $message";
529 }
530
535 public static function getPublicLogMessage( Throwable $e ) {
536 $reqId = WebRequest::getRequestId();
537 $type = get_class( $e );
538 return '[' . $reqId . '] '
539 . gmdate( 'Y-m-d H:i:s' ) . ': '
540 . 'Fatal exception of type "' . $type . '"';
541 }
542
555 public static function getLogContext( Throwable $e, $catcher = self::CAUGHT_BY_OTHER ) {
556 $context = [
557 'exception' => $e,
558 'exception_url' => self::getURL() ?: '[no req]',
559 // The reqId context key use the same familiar name and value as the top-level field
560 // provided by LogstashFormatter. However, formatters are configurable at run-time,
561 // and their top-level fields are logically separate from context keys and cannot be,
562 // substituted in a message, hence set explicitly here. For WMF users, these may feel,
563 // like the same thing due to Monolog V0 handling, which transmits "fields" and "context",
564 // in the same JSON object (after message formatting).
565 'reqId' => WebRequest::getRequestId(),
566 'caught_by' => $catcher
567 ];
568 if ( $e instanceof INormalizedException ) {
569 $context += $e->getMessageContext();
570 }
571 return $context;
572 }
573
586 public static function getStructuredExceptionData(
587 Throwable $e,
588 $catcher = self::CAUGHT_BY_OTHER
589 ) {
591
592 $data = [
593 'id' => WebRequest::getRequestId(),
594 'type' => get_class( $e ),
595 'file' => $e->getFile(),
596 'line' => $e->getLine(),
597 'message' => $e->getMessage(),
598 'code' => $e->getCode(),
599 'url' => self::getURL() ?: null,
600 'caught_by' => $catcher
601 ];
602
603 if ( $e instanceof ErrorException &&
604 ( error_reporting() & $e->getSeverity() ) === 0
605 ) {
606 // Flag surpressed errors
607 $data['suppressed'] = true;
608 }
609
611 $data['backtrace'] = self::getRedactedTrace( $e );
612 }
613
614 $previous = $e->getPrevious();
615 if ( $previous !== null ) {
616 $data['previous'] = self::getStructuredExceptionData( $previous, $catcher );
617 }
618
619 return $data;
620 }
621
676 public static function jsonSerializeException(
677 Throwable $e,
678 $pretty = false,
679 $escaping = 0,
680 $catcher = self::CAUGHT_BY_OTHER
681 ) {
682 return FormatJson::encode(
683 self::getStructuredExceptionData( $e, $catcher ),
684 $pretty,
685 $escaping
686 );
687 }
688
700 public static function logException(
701 Throwable $e,
702 $catcher = self::CAUGHT_BY_OTHER,
703 $extraData = []
704 ) {
705 if ( !( $e instanceof MWException ) || $e->isLoggable() ) {
706 $logger = LoggerFactory::getInstance( 'exception' );
707 $context = self::getLogContext( $e, $catcher );
708 if ( $extraData ) {
709 $context['extraData'] = $extraData;
710 }
711 $logger->error(
712 self::getLogNormalMessage( $e ),
713 $context
714 );
715
716 $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK, $catcher );
717 if ( $json !== false ) {
718 $logger = LoggerFactory::getInstance( 'exception-json' );
719 $logger->error( $json, [ 'private' => true ] );
720 }
721
722 Hooks::runner()->onLogException( $e, false );
723 }
724 }
725
734 private static function logError(
735 ErrorException $e,
736 $channel,
737 $level,
738 $catcher
739 ) {
740 // The set_error_handler callback is independent from error_reporting.
741 // Filter out unwanted errors manually (e.g. when
742 // Wikimedia\suppressWarnings is active).
743 $suppressed = ( error_reporting() & $e->getSeverity() ) === 0;
744 if ( !$suppressed ) {
745 $logger = LoggerFactory::getInstance( $channel );
746 $logger->log(
747 $level,
748 self::getLogNormalMessage( $e ),
749 self::getLogContext( $e, $catcher )
750 );
751 }
752
753 // Include all errors in the json log (surpressed errors will be flagged)
754 $json = self::jsonSerializeException( $e, false, FormatJson::ALL_OK, $catcher );
755 if ( $json !== false ) {
756 $logger = LoggerFactory::getInstance( "{$channel}-json" );
757 // Unlike the 'error' channel, the 'error-json' channel is unfiltered,
758 // and emits messages even if wikimedia/at-ease was used to suppress the
759 // error. To avoid clobbering Logstash dashboards with these, make sure
760 // those have their level casted to DEBUG so that they are excluded by
761 // level-based filteres automatically instead of requiring a dedicated filter
762 // for this channel. To be improved: T193472.
763 $unfilteredLevel = $suppressed ? LogLevel::DEBUG : $level;
764 $logger->log( $unfilteredLevel, $json, [ 'private' => true ] );
765 }
766
767 Hooks::runner()->onLogException( $e, $suppressed );
768 }
769}
$wgLogExceptionBacktrace
If true, send the exception backtrace to the error log.
$wgPropagateErrors
If true, the MediaWiki error handler passes errors/warnings to the default error handler after loggin...
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that a deprecated feature was used.
wfIsCLI()
Check if we are running from the commandline.
$wgRequest
Definition Setup.php:702
WebRequest clone which takes values from a provided array.
Handler class for MWExceptions.
static getLogContext(Throwable $e, $catcher=self::CAUGHT_BY_OTHER)
Get a PSR-3 log event context from a Throwable.
static handleError( $level, $message, $file=null, $line=null)
Handler for set_error_handler() callback notifications.
static rollbackPrimaryChangesAndLog(Throwable $e, $catcher=self::CAUGHT_BY_OTHER)
Roll back any open database transactions and log the stack trace of the throwable.
static getStructuredExceptionData(Throwable $e, $catcher=self::CAUGHT_BY_OTHER)
Get a structured representation of a Throwable.
static rollbackMasterChangesAndLog(Throwable $e, $catcher=self::CAUGHT_BY_OTHER)
static getRedactedTraceAsString(Throwable $e)
Generate a string representation of a throwable's stack trace.
static report(Throwable $e)
Report a throwable to the user.
static logException(Throwable $e, $catcher=self::CAUGHT_BY_OTHER, $extraData=[])
Log a throwable to the exception log (if enabled).
static array $fatalErrorTypes
Error types that, if unhandled, are fatal to the request.
static getPublicLogMessage(Throwable $e)
static getRedactedTrace(Throwable $e)
Return a copy of a throwable's backtrace as an array.
static handleUncaughtException(Throwable $e)
Callback to use with PHP's set_exception_handler.
static prettyPrintTrace(array $trace, $pad='')
Generate a string representation of a stacktrace.
static jsonSerializeException(Throwable $e, $pretty=false, $escaping=0, $catcher=self::CAUGHT_BY_OTHER)
Serialize a Throwable object to JSON.
static logError(ErrorException $e, $channel, $level, $catcher)
Log an exception that wasn't thrown but made to wrap an error.
static getLogMessage(Throwable $e)
Get a message formatting the throwable message and its origin.
static redactTrace(array $trace)
Redact a stacktrace generated by Throwable::getTrace(), debug_backtrace() or similar means.
static installHandler()
Install handlers with PHP.
static string $reservedMemory
static handleFatalError()
Callback used as a registered shutdown function.
static getLogNormalMessage(Throwable $e)
Get a normalised message for formatting with PSR-3 log event context.
static getURL()
If the exception occurred in the course of responding to a request, returns the requested URL.
static handleException(Throwable $e, $catcher=self::CAUGHT_BY_OTHER)
Exception handler which simulates the appropriate catch() handling:
static output(Throwable $e, $mode, Throwable $eNew=null)
MediaWiki exception.
PSR-3 logger instance factory.
MediaWikiServices is the service locator for the application scope of MediaWiki.
Database error base class @newable.
Definition DBError.php:32
$line
Definition mcc.php:119
A helper class for throttling authentication attempts.
if(PHP_SAPI !='cli-server') if(!isset( $_SERVER['SCRIPT_FILENAME'])) $file
Item class for a filearchive table row.
Definition router.php:42