Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
26.60% |
54 / 203 |
|
23.81% |
5 / 21 |
CRAP | |
0.00% |
0 / 1 |
| MWExceptionHandler | |
26.73% |
54 / 202 |
|
23.81% |
5 / 21 |
2347.74 | |
0.00% |
0 / 1 |
| installHandler | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
| report | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
20 | |||
| rollbackPrimaryChanges | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
| rollbackPrimaryChangesAndLog | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| handleUncaughtException | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
| handleException | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| handleError | |
0.00% |
0 / 39 |
|
0.00% |
0 / 1 |
182 | |||
| handleFatalError | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
110 | |||
| getRedactedTraceAsString | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| prettyPrintTrace | |
80.00% |
12 / 15 |
|
0.00% |
0 / 1 |
8.51 | |||
| getRedactedTrace | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| redactTrace | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| getURL | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| getLogMessage | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
| getLogNormalMessage | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| getPublicLogMessage | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
| getLogContext | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
| getStructuredExceptionData | |
89.47% |
17 / 19 |
|
0.00% |
0 / 1 |
6.04 | |||
| logException | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
| logError | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
6 | |||
| callLogExceptionHook | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
20 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\Exception; |
| 8 | |
| 9 | use ErrorException; |
| 10 | use MediaWiki\Debug\MWDebug; |
| 11 | use MediaWiki\HookContainer\HookRunner; |
| 12 | use MediaWiki\Logger\LoggerFactory; |
| 13 | use MediaWiki\MediaWikiServices; |
| 14 | use MediaWiki\Request\WebRequest; |
| 15 | use Psr\Log\LogLevel; |
| 16 | use Throwable; |
| 17 | use Wikimedia\NormalizedException\INormalizedException; |
| 18 | use Wikimedia\Rdbms\DBError; |
| 19 | use Wikimedia\Rdbms\DBQueryError; |
| 20 | use Wikimedia\Rdbms\LBFactory; |
| 21 | use Wikimedia\Services\RecursiveServiceDependencyException; |
| 22 | |
| 23 | /** |
| 24 | * Handler class for MWExceptions |
| 25 | * @ingroup Exception |
| 26 | */ |
| 27 | class MWExceptionHandler { |
| 28 | /** Error caught and reported by this exception handler */ |
| 29 | public const CAUGHT_BY_HANDLER = 'mwe_handler'; |
| 30 | /** Error caught and reported by a script entry point */ |
| 31 | public const CAUGHT_BY_ENTRYPOINT = 'entrypoint'; |
| 32 | /** Error reported by direct logException() call */ |
| 33 | public const CAUGHT_BY_OTHER = 'other'; |
| 34 | |
| 35 | /** @var string|null */ |
| 36 | protected static $reservedMemory; |
| 37 | |
| 38 | /** |
| 39 | * Error types that, if unhandled, are fatal to the request. |
| 40 | * These error types may be thrown as Error objects, which implement Throwable (but not Exception). |
| 41 | * |
| 42 | * The user will be shown an HTTP 500 Internal Server Error. |
| 43 | * As such, these should be sent to MediaWiki's "exception" channel. |
| 44 | * Normally, the error handler logs them to the "error" channel. |
| 45 | */ |
| 46 | private const FATAL_ERROR_TYPES = [ |
| 47 | E_ERROR, |
| 48 | E_PARSE, |
| 49 | E_CORE_ERROR, |
| 50 | E_COMPILE_ERROR, |
| 51 | E_USER_ERROR, |
| 52 | |
| 53 | // E.g. "Catchable fatal error: Argument X must be Y, null given" |
| 54 | E_RECOVERABLE_ERROR, |
| 55 | ]; |
| 56 | |
| 57 | /** |
| 58 | * Whether exception data should include a backtrace. |
| 59 | * |
| 60 | * @var bool |
| 61 | */ |
| 62 | private static $logExceptionBacktrace = true; |
| 63 | |
| 64 | /** |
| 65 | * Whether to propagate errors to PHP's built-in handler. |
| 66 | * |
| 67 | * @var bool |
| 68 | */ |
| 69 | private static $propagateErrors; |
| 70 | |
| 71 | /** |
| 72 | * Install handlers with PHP. |
| 73 | * @internal |
| 74 | * @param bool $logExceptionBacktrace Whether error handlers should include a backtrace |
| 75 | * in the log. |
| 76 | * @param bool $propagateErrors Whether errors should be propagated to PHP's built-in handler. |
| 77 | */ |
| 78 | public static function installHandler( |
| 79 | bool $logExceptionBacktrace = true, |
| 80 | bool $propagateErrors = true |
| 81 | ) { |
| 82 | self::$logExceptionBacktrace = $logExceptionBacktrace; |
| 83 | self::$propagateErrors = $propagateErrors; |
| 84 | |
| 85 | // This catches: |
| 86 | // * Exception objects that were explicitly thrown but not |
| 87 | // caught anywhere in the application. This is rare given those |
| 88 | // would normally be caught at a high-level like MediaWiki::run (index.php), |
| 89 | // api.php, or ResourceLoader::respond (load.php). These high-level |
| 90 | // catch clauses would then call MWExceptionHandler::logException |
| 91 | // or MWExceptionHandler::handleException. |
| 92 | // If they are not caught, then they are handled here. |
| 93 | // * Error objects for issues that would historically |
| 94 | // cause fatal errors but may now be caught as Throwable (not Exception). |
| 95 | // Same as previous case, but more common to bubble to here instead of |
| 96 | // caught locally because they tend to not be safe to recover from. |
| 97 | // (e.g. argument TypeError, division by zero, etc.) |
| 98 | set_exception_handler( self::handleUncaughtException( ... ) ); |
| 99 | |
| 100 | // This catches recoverable errors (e.g. PHP Notice, PHP Warning, PHP Error) that do not |
| 101 | // interrupt execution in any way. We log these in the background and then continue execution. |
| 102 | set_error_handler( self::handleError( ... ) ); |
| 103 | |
| 104 | // This catches fatal errors for which no Throwable is thrown, |
| 105 | // including Out-Of-Memory and Timeout fatals. |
| 106 | // Reserve 16k of memory so we can report OOM fatals. |
| 107 | self::$reservedMemory = str_repeat( ' ', 16384 ); |
| 108 | register_shutdown_function( self::handleFatalError( ... ) ); |
| 109 | } |
| 110 | |
| 111 | /** |
| 112 | * Report a throwable to the user |
| 113 | */ |
| 114 | protected static function report( Throwable $e ) { |
| 115 | try { |
| 116 | // Try and show the exception prettily, with the normal skin infrastructure |
| 117 | if ( $e instanceof MWException && $e->hasOverriddenHandler() ) { |
| 118 | // Delegate to MWException until all subclasses are handled by |
| 119 | // MWExceptionRenderer and MWException::report() has been |
| 120 | // removed. |
| 121 | $e->report(); |
| 122 | } else { |
| 123 | MWExceptionRenderer::output( $e, MWExceptionRenderer::AS_PRETTY ); |
| 124 | } |
| 125 | } catch ( Throwable $e2 ) { |
| 126 | // Exception occurred from within exception handler |
| 127 | // Show a simpler message for the original exception, |
| 128 | // don't try to invoke report() |
| 129 | MWExceptionRenderer::output( $e, MWExceptionRenderer::AS_RAW, $e2 ); |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | /** |
| 134 | * Roll back any open database transactions |
| 135 | * |
| 136 | * This method is used to attempt to recover from exceptions |
| 137 | */ |
| 138 | private static function rollbackPrimaryChanges() { |
| 139 | if ( !MediaWikiServices::hasInstance() ) { |
| 140 | // MediaWiki isn't fully initialized yet, it's not safe to access services. |
| 141 | // This also means that there's nothing to roll back yet. |
| 142 | return; |
| 143 | } |
| 144 | |
| 145 | $services = MediaWikiServices::getInstance(); |
| 146 | $lbFactory = $services->peekService( 'DBLoadBalancerFactory' ); |
| 147 | '@phan-var LBFactory $lbFactory'; /* @var LBFactory $lbFactory */ |
| 148 | if ( !$lbFactory ) { |
| 149 | // There's no need to roll back transactions if the LBFactory is |
| 150 | // disabled or hasn't been created yet |
| 151 | return; |
| 152 | } |
| 153 | |
| 154 | // Roll back DBs to avoid transaction notices. This might fail |
| 155 | // to roll back some databases due to connection issues or exceptions. |
| 156 | // However, any sensible DB driver will roll back implicitly anyway. |
| 157 | try { |
| 158 | $lbFactory->rollbackPrimaryChanges( __METHOD__ ); |
| 159 | $lbFactory->flushPrimarySessions( __METHOD__ ); |
| 160 | } catch ( DBError $e ) { |
| 161 | // If the DB is unreachable, rollback() will throw an error |
| 162 | // and the error report() method might need messages from the DB, |
| 163 | // which would result in an exception loop. PHP may escalate such |
| 164 | // errors to "Exception thrown without a stack frame" fatals, but |
| 165 | // it's better to be explicit here. |
| 166 | self::logException( $e, self::CAUGHT_BY_HANDLER ); |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | /** |
| 171 | * Roll back any open database transactions and log the stack trace of the throwable |
| 172 | * |
| 173 | * This method is used to attempt to recover from exceptions |
| 174 | * |
| 175 | * @since 1.37 |
| 176 | * @param Throwable $e |
| 177 | * @param string $catcher CAUGHT_BY_* class constant indicating what caught the error |
| 178 | */ |
| 179 | public static function rollbackPrimaryChangesAndLog( |
| 180 | Throwable $e, |
| 181 | $catcher = self::CAUGHT_BY_OTHER |
| 182 | ) { |
| 183 | self::rollbackPrimaryChanges(); |
| 184 | |
| 185 | self::logException( $e, $catcher ); |
| 186 | } |
| 187 | |
| 188 | /** |
| 189 | * Callback to use with PHP's set_exception_handler. |
| 190 | * |
| 191 | * @since 1.31 |
| 192 | * @param Throwable $e |
| 193 | */ |
| 194 | public static function handleUncaughtException( Throwable $e ) { |
| 195 | self::handleException( $e, self::CAUGHT_BY_HANDLER ); |
| 196 | |
| 197 | // Make sure we don't claim success on exit for CLI scripts (T177414) |
| 198 | if ( wfIsCLI() ) { |
| 199 | register_shutdown_function( |
| 200 | static function (): never { |
| 201 | exit( 255 ); |
| 202 | } |
| 203 | ); |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | /** |
| 208 | * Exception handler which simulates the appropriate catch() handling: |
| 209 | * |
| 210 | * try { |
| 211 | * ... |
| 212 | * } catch ( Exception $e ) { |
| 213 | * $e->report(); |
| 214 | * } catch ( Exception $e ) { |
| 215 | * echo $e->__toString(); |
| 216 | * } |
| 217 | * |
| 218 | * @since 1.25 |
| 219 | * @param Throwable $e |
| 220 | * @param string $catcher CAUGHT_BY_* class constant indicating what caught the error |
| 221 | */ |
| 222 | public static function handleException( Throwable $e, $catcher = self::CAUGHT_BY_OTHER ) { |
| 223 | self::rollbackPrimaryChangesAndLog( $e, $catcher ); |
| 224 | self::report( $e ); |
| 225 | } |
| 226 | |
| 227 | /** |
| 228 | * Handler for set_error_handler() callback notifications. |
| 229 | * |
| 230 | * Receive a callback from the interpreter for a raised error, create an |
| 231 | * ErrorException, and log the exception to the 'error' logging |
| 232 | * channel(s). |
| 233 | * |
| 234 | * @since 1.25 |
| 235 | * @param int $level Error level raised |
| 236 | * @param string $message |
| 237 | * @param string|null $file |
| 238 | * @param int|null $line |
| 239 | * @return bool |
| 240 | */ |
| 241 | public static function handleError( |
| 242 | $level, |
| 243 | $message, |
| 244 | $file = null, |
| 245 | $line = null |
| 246 | ) { |
| 247 | // E_STRICT is deprecated since PHP 8.4 (T375707). |
| 248 | // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged |
| 249 | if ( defined( 'E_STRICT' ) && $level == @constant( 'E_STRICT' ) ) { |
| 250 | $level = E_USER_NOTICE; |
| 251 | } |
| 252 | |
| 253 | // Map PHP error constant to a PSR-3 severity level. |
| 254 | // Avoid use of "DEBUG" or "INFO" levels, unless the |
| 255 | // error should evade error monitoring and alerts. |
| 256 | // |
| 257 | // To decide the log level, ask yourself: "Has the |
| 258 | // program's behaviour diverged from what the written |
| 259 | // code expected?" |
| 260 | // |
| 261 | // For example, use of a deprecated method or violating a strict standard |
| 262 | // has no impact on functional behaviour (Warning). On the other hand, |
| 263 | // accessing an undefined variable makes behaviour diverge from what the |
| 264 | // author intended/expected. PHP recovers from an undefined variables by |
| 265 | // yielding null and continuing execution, but it remains a change in |
| 266 | // behaviour given the null was not part of the code and is likely not |
| 267 | // accounted for. |
| 268 | switch ( $level ) { |
| 269 | case E_WARNING: |
| 270 | case E_CORE_WARNING: |
| 271 | case E_COMPILE_WARNING: |
| 272 | $prefix = 'PHP Warning: '; |
| 273 | $severity = LogLevel::ERROR; |
| 274 | break; |
| 275 | case E_NOTICE: |
| 276 | $prefix = 'PHP Notice: '; |
| 277 | $severity = LogLevel::ERROR; |
| 278 | break; |
| 279 | case E_USER_NOTICE: |
| 280 | // Used by wfWarn(), MWDebug::warning() |
| 281 | $prefix = 'PHP Notice: '; |
| 282 | $severity = LogLevel::WARNING; |
| 283 | break; |
| 284 | case E_USER_WARNING: |
| 285 | // Used by wfWarn(), MWDebug::warning() |
| 286 | $prefix = 'PHP Warning: '; |
| 287 | $severity = LogLevel::WARNING; |
| 288 | break; |
| 289 | case E_DEPRECATED: |
| 290 | $prefix = 'PHP Deprecated: '; |
| 291 | $severity = LogLevel::WARNING; |
| 292 | break; |
| 293 | case E_USER_DEPRECATED: |
| 294 | $prefix = 'PHP Deprecated: '; |
| 295 | $severity = LogLevel::WARNING; |
| 296 | $real = MWDebug::parseCallerDescription( $message ); |
| 297 | if ( $real ) { |
| 298 | // Used by wfDeprecated(), MWDebug::deprecated() |
| 299 | // Apply caller offset from wfDeprecated() to the native error. |
| 300 | // This makes errors easier to aggregate and find in e.g. Kibana. |
| 301 | $file = $real['file']; |
| 302 | $line = $real['line']; |
| 303 | $message = $real['message']; |
| 304 | } |
| 305 | break; |
| 306 | default: |
| 307 | $prefix = 'PHP Unknown error: '; |
| 308 | $severity = LogLevel::ERROR; |
| 309 | break; |
| 310 | } |
| 311 | |
| 312 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal False positive |
| 313 | $e = new ErrorException( $prefix . $message, 0, $level, $file, $line ); |
| 314 | self::logError( $e, $severity, self::CAUGHT_BY_HANDLER ); |
| 315 | |
| 316 | // If $propagateErrors is true return false so PHP shows/logs the error normally. |
| 317 | return !self::$propagateErrors; |
| 318 | } |
| 319 | |
| 320 | /** |
| 321 | * Callback used as a registered shutdown function. |
| 322 | * |
| 323 | * This is used as callback from the interpreter at system shutdown. |
| 324 | * If the last error was not a recoverable error that we already reported, |
| 325 | * and log as fatal exception. |
| 326 | * |
| 327 | * Special handling is included for missing class errors as they may |
| 328 | * indicate that the user needs to install 3rd-party libraries via |
| 329 | * Composer or other means. |
| 330 | * |
| 331 | * @since 1.25 |
| 332 | */ |
| 333 | public static function handleFatalError(): void { |
| 334 | // Free reserved memory so that we have space to process OOM |
| 335 | // errors |
| 336 | self::$reservedMemory = null; |
| 337 | |
| 338 | $lastError = error_get_last(); |
| 339 | if ( $lastError === null ) { |
| 340 | return; |
| 341 | } |
| 342 | |
| 343 | $level = $lastError['type']; |
| 344 | $message = $lastError['message']; |
| 345 | $file = $lastError['file']; |
| 346 | $line = $lastError['line']; |
| 347 | |
| 348 | if ( !in_array( $level, self::FATAL_ERROR_TYPES ) ) { |
| 349 | // Only interested in fatal errors, others should have been |
| 350 | // handled by MWExceptionHandler::handleError |
| 351 | return; |
| 352 | } |
| 353 | |
| 354 | $msgParts = [ |
| 355 | '[{reqId}] {exception_url} PHP Fatal Error', |
| 356 | ( $line || $file ) ? ' from' : '', |
| 357 | $line ? " line $line" : '', |
| 358 | ( $line && $file ) ? ' of' : '', |
| 359 | $file ? " $file" : '', |
| 360 | ": $message", |
| 361 | ]; |
| 362 | $msg = implode( '', $msgParts ); |
| 363 | |
| 364 | // Look at message to see if this is a class not found failure (Class 'foo' not found) |
| 365 | if ( preg_match( "/Class '\w+' not found/", $message ) ) { |
| 366 | // phpcs:disable Generic.Files.LineLength |
| 367 | $msg = <<<TXT |
| 368 | {$msg} |
| 369 | |
| 370 | 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. |
| 371 | |
| 372 | 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. |
| 373 | TXT; |
| 374 | // phpcs:enable |
| 375 | } |
| 376 | |
| 377 | $e = new ErrorException( "PHP Fatal Error: {$message}", 0, $level, $file, $line ); |
| 378 | $logger = LoggerFactory::getInstance( 'exception' ); |
| 379 | $logger->error( $msg, self::getLogContext( $e, self::CAUGHT_BY_HANDLER ) ); |
| 380 | } |
| 381 | |
| 382 | /** |
| 383 | * Generate a string representation of a throwable's stack trace |
| 384 | * |
| 385 | * Like Throwable::getTraceAsString, but replaces argument values with |
| 386 | * their type or class name, and prepends the start line of the throwable. |
| 387 | * |
| 388 | * @param Throwable $e |
| 389 | * @return string |
| 390 | * @see prettyPrintTrace() |
| 391 | */ |
| 392 | public static function getRedactedTraceAsString( Throwable $e ) { |
| 393 | $from = 'from ' . $e->getFile() . '(' . $e->getLine() . ')' . "\n"; |
| 394 | return $from . self::prettyPrintTrace( self::getRedactedTrace( $e ) ); |
| 395 | } |
| 396 | |
| 397 | /** |
| 398 | * Generate a string representation of a stacktrace. |
| 399 | * |
| 400 | * @since 1.26 |
| 401 | * @param array $trace |
| 402 | * @param string $pad Constant padding to add to each line of trace |
| 403 | * @return string |
| 404 | */ |
| 405 | public static function prettyPrintTrace( array $trace, $pad = '' ) { |
| 406 | $text = ''; |
| 407 | |
| 408 | $level = 0; |
| 409 | foreach ( $trace as $level => $frame ) { |
| 410 | if ( isset( $frame['file'] ) && isset( $frame['line'] ) ) { |
| 411 | $text .= "{$pad}#{$level} {$frame['file']}({$frame['line']}): "; |
| 412 | } else { |
| 413 | // 'file' and 'line' are unset for calls from C code |
| 414 | // (T57634) This matches behaviour of |
| 415 | // Throwable::getTraceAsString to instead display "[internal |
| 416 | // function]". |
| 417 | $text .= "{$pad}#{$level} [internal function]: "; |
| 418 | } |
| 419 | |
| 420 | if ( isset( $frame['class'] ) && isset( $frame['type'] ) && isset( $frame['function'] ) ) { |
| 421 | $text .= $frame['class'] . $frame['type'] . $frame['function']; |
| 422 | } else { |
| 423 | $text .= $frame['function'] ?? 'NO_FUNCTION_GIVEN'; |
| 424 | } |
| 425 | |
| 426 | if ( isset( $frame['args'] ) ) { |
| 427 | $text .= '(' . implode( ', ', $frame['args'] ) . ")\n"; |
| 428 | } else { |
| 429 | $text .= "()\n"; |
| 430 | } |
| 431 | } |
| 432 | |
| 433 | $level++; |
| 434 | $text .= "{$pad}#{$level} {main}"; |
| 435 | |
| 436 | return $text; |
| 437 | } |
| 438 | |
| 439 | /** |
| 440 | * Return a copy of a throwable's backtrace as an array. |
| 441 | * |
| 442 | * Like Throwable::getTrace, but replaces each element in each frame's |
| 443 | * argument array with the name of its class (if the element is an object) |
| 444 | * or its type (if the element is a PHP primitive). |
| 445 | * |
| 446 | * @since 1.22 |
| 447 | * @param Throwable $e |
| 448 | * @return array |
| 449 | */ |
| 450 | public static function getRedactedTrace( Throwable $e ) { |
| 451 | return static::redactTrace( $e->getTrace() ); |
| 452 | } |
| 453 | |
| 454 | /** |
| 455 | * Redact a stacktrace generated by Throwable::getTrace(), |
| 456 | * debug_backtrace() or similar means. Replaces each element in each |
| 457 | * frame's argument array with the name of its class (if the element is an |
| 458 | * object) or its type (if the element is a PHP primitive). |
| 459 | * |
| 460 | * @since 1.26 |
| 461 | * @param array $trace Stacktrace |
| 462 | * @return array Stacktrace with argument values converted to data types |
| 463 | */ |
| 464 | public static function redactTrace( array $trace ) { |
| 465 | return array_map( static function ( $frame ) { |
| 466 | if ( isset( $frame['args'] ) ) { |
| 467 | $frame['args'] = array_map( 'get_debug_type', $frame['args'] ); |
| 468 | } |
| 469 | return $frame; |
| 470 | }, $trace ); |
| 471 | } |
| 472 | |
| 473 | /** |
| 474 | * If the exception occurred in the course of responding to a request, |
| 475 | * returns the requested URL. Otherwise, returns false. |
| 476 | * |
| 477 | * @since 1.23 |
| 478 | * @return string|false |
| 479 | */ |
| 480 | public static function getURL() { |
| 481 | if ( MW_ENTRY_POINT === 'cli' ) { |
| 482 | return false; |
| 483 | } |
| 484 | return WebRequest::getGlobalRequestURL(); |
| 485 | } |
| 486 | |
| 487 | /** |
| 488 | * Get a message formatting the throwable message and its origin. |
| 489 | * |
| 490 | * Despite the method name, this is not used for logging. |
| 491 | * It is only used for HTML or CLI output by MWExceptionRenderer. |
| 492 | * |
| 493 | * @since 1.22 |
| 494 | * @param Throwable $e |
| 495 | * @return string |
| 496 | */ |
| 497 | public static function getLogMessage( Throwable $e ) { |
| 498 | $id = WebRequest::getRequestId(); |
| 499 | $type = get_class( $e ); |
| 500 | $message = $e->getMessage(); |
| 501 | $url = self::getURL() ?: '[no req]'; |
| 502 | |
| 503 | if ( $e instanceof DBQueryError ) { |
| 504 | $message = "A database query error has occurred. Did you forget to run" |
| 505 | . " your application's database schema updater after upgrading" |
| 506 | . " or after adding a new extension?\n\nPlease see" |
| 507 | . " https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Upgrading and" |
| 508 | . " https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:How_to_debug" |
| 509 | . " for more information.\n\n" |
| 510 | . $message; |
| 511 | } |
| 512 | |
| 513 | return "[$id] $url $type: $message"; |
| 514 | } |
| 515 | |
| 516 | /** |
| 517 | * Get a normalised message for formatting with PSR-3 log event context. |
| 518 | * |
| 519 | * Must be used together with `getLogContext()` to be useful. |
| 520 | * |
| 521 | * @since 1.30 |
| 522 | * @param Throwable $e |
| 523 | * @return string |
| 524 | */ |
| 525 | public static function getLogNormalMessage( Throwable $e ) { |
| 526 | if ( $e instanceof INormalizedException ) { |
| 527 | $message = $e->getNormalizedMessage(); |
| 528 | } else { |
| 529 | $message = $e->getMessage(); |
| 530 | } |
| 531 | if ( !$e instanceof ErrorException ) { |
| 532 | // ErrorException is something we use internally to represent |
| 533 | // PHP errors (runtime warnings that aren't thrown or caught), |
| 534 | // don't bother putting it in the logs. Let the log message |
| 535 | // lead with "PHP Warning: " instead (see ::handleError). |
| 536 | $message = get_class( $e ) . ": $message"; |
| 537 | } |
| 538 | |
| 539 | return "[{reqId}] {exception_url} $message"; |
| 540 | } |
| 541 | |
| 542 | /** |
| 543 | * @param Throwable $e |
| 544 | * @return string |
| 545 | */ |
| 546 | public static function getPublicLogMessage( Throwable $e ) { |
| 547 | $reqId = WebRequest::getRequestId(); |
| 548 | $type = get_class( $e ); |
| 549 | return '[' . $reqId . '] ' |
| 550 | . gmdate( 'Y-m-d H:i:s' ) . ': ' |
| 551 | . 'Fatal exception of type "' . $type . '"'; |
| 552 | } |
| 553 | |
| 554 | /** |
| 555 | * Get a PSR-3 log event context from a Throwable. |
| 556 | * |
| 557 | * Creates a structured array containing information about the provided |
| 558 | * throwable that can be used to augment a log message sent to a PSR-3 |
| 559 | * logger. |
| 560 | * |
| 561 | * @since 1.26 |
| 562 | * @param Throwable $e |
| 563 | * @param string $catcher CAUGHT_BY_* class constant indicating what caught the error |
| 564 | * @return array |
| 565 | */ |
| 566 | public static function getLogContext( Throwable $e, $catcher = self::CAUGHT_BY_OTHER ) { |
| 567 | $context = [ |
| 568 | 'exception' => $e, |
| 569 | 'exception_url' => self::getURL() ?: '[no req]', |
| 570 | // The reqId context key use the same familiar name and value as the top-level field |
| 571 | // provided by LogstashFormatter. However, formatters are configurable at run-time, |
| 572 | // and their top-level fields are logically separate from context keys and cannot be, |
| 573 | // substituted in a message, hence set explicitly here. For WMF users, these may feel, |
| 574 | // like the same thing due to Monolog V0 handling, which transmits "fields" and "context", |
| 575 | // in the same JSON object (after message formatting). |
| 576 | 'reqId' => WebRequest::getRequestId(), |
| 577 | 'caught_by' => $catcher |
| 578 | ]; |
| 579 | if ( $e instanceof INormalizedException ) { |
| 580 | $context += $e->getMessageContext(); |
| 581 | } |
| 582 | return $context; |
| 583 | } |
| 584 | |
| 585 | /** |
| 586 | * Get a structured representation of a Throwable. |
| 587 | * |
| 588 | * Returns an array of structured data (class, message, code, file, |
| 589 | * backtrace) derived from the given throwable. The backtrace information |
| 590 | * will be redacted as per getRedactedTraceAsArray(). |
| 591 | * |
| 592 | * @param Throwable $e |
| 593 | * @param string $catcher CAUGHT_BY_* class constant indicating what caught the error |
| 594 | * @return array |
| 595 | * @since 1.26 |
| 596 | */ |
| 597 | public static function getStructuredExceptionData( |
| 598 | Throwable $e, |
| 599 | $catcher = self::CAUGHT_BY_OTHER |
| 600 | ) { |
| 601 | $data = [ |
| 602 | 'id' => WebRequest::getRequestId(), |
| 603 | 'type' => get_class( $e ), |
| 604 | 'file' => $e->getFile(), |
| 605 | 'line' => $e->getLine(), |
| 606 | 'message' => $e->getMessage(), |
| 607 | 'code' => $e->getCode(), |
| 608 | 'url' => self::getURL() ?: null, |
| 609 | 'caught_by' => $catcher |
| 610 | ]; |
| 611 | |
| 612 | if ( $e instanceof ErrorException && |
| 613 | ( error_reporting() & $e->getSeverity() ) === 0 |
| 614 | ) { |
| 615 | // Flag suppressed errors |
| 616 | $data['suppressed'] = true; |
| 617 | } |
| 618 | |
| 619 | if ( self::$logExceptionBacktrace ) { |
| 620 | $data['backtrace'] = self::getRedactedTrace( $e ); |
| 621 | } |
| 622 | |
| 623 | $previous = $e->getPrevious(); |
| 624 | if ( $previous !== null ) { |
| 625 | $data['previous'] = self::getStructuredExceptionData( $previous, $catcher ); |
| 626 | } |
| 627 | |
| 628 | return $data; |
| 629 | } |
| 630 | |
| 631 | /** |
| 632 | * Log a throwable to the exception log (if enabled). |
| 633 | * |
| 634 | * This method must not assume the throwable is an MWException, |
| 635 | * it is also used to handle PHP exceptions or exceptions from other libraries. |
| 636 | * |
| 637 | * @since 1.22 |
| 638 | * @param Throwable $e |
| 639 | * @param string $catcher CAUGHT_BY_* class constant indicating what caught the error |
| 640 | * @param array $extraData (since 1.34) Additional data to log |
| 641 | */ |
| 642 | public static function logException( |
| 643 | Throwable $e, |
| 644 | $catcher = self::CAUGHT_BY_OTHER, |
| 645 | $extraData = [] |
| 646 | ) { |
| 647 | if ( !( $e instanceof MWException ) || $e->isLoggable() ) { |
| 648 | $logger = LoggerFactory::getInstance( 'exception' ); |
| 649 | $context = self::getLogContext( $e, $catcher ); |
| 650 | if ( $extraData ) { |
| 651 | $context['extraData'] = $extraData; |
| 652 | } |
| 653 | $logger->error( |
| 654 | self::getLogNormalMessage( $e ), |
| 655 | $context |
| 656 | ); |
| 657 | |
| 658 | self::callLogExceptionHook( $e, false ); |
| 659 | } |
| 660 | } |
| 661 | |
| 662 | /** |
| 663 | * Log an exception that wasn't thrown but made to wrap an error. |
| 664 | * |
| 665 | * @param ErrorException $e |
| 666 | * @param string $level |
| 667 | * @param string $catcher CAUGHT_BY_* class constant indicating what caught the error |
| 668 | */ |
| 669 | private static function logError( |
| 670 | ErrorException $e, |
| 671 | $level, |
| 672 | $catcher |
| 673 | ) { |
| 674 | // The set_error_handler callback is independent from error_reporting. |
| 675 | $suppressed = ( error_reporting() & $e->getSeverity() ) === 0; |
| 676 | if ( $suppressed ) { |
| 677 | // Instead of discarding these entirely, give some visibility (but only |
| 678 | // when debugging) to errors that were intentionally silenced via |
| 679 | // the error silencing operator (@) or Wikimedia\AtEase. |
| 680 | // To avoid clobbering Logstash results, set the level to DEBUG |
| 681 | // and also send them to a dedicated channel (T193472). |
| 682 | $channel = 'silenced-error'; |
| 683 | $level = LogLevel::DEBUG; |
| 684 | } else { |
| 685 | $channel = 'error'; |
| 686 | } |
| 687 | $logger = LoggerFactory::getInstance( $channel ); |
| 688 | $logger->log( |
| 689 | $level, |
| 690 | self::getLogNormalMessage( $e ), |
| 691 | self::getLogContext( $e, $catcher ) |
| 692 | ); |
| 693 | |
| 694 | self::callLogExceptionHook( $e, $suppressed ); |
| 695 | } |
| 696 | |
| 697 | /** |
| 698 | * Call the LogException hook, suppressing some exceptions. |
| 699 | * |
| 700 | * @param Throwable $e |
| 701 | * @param bool $suppressed |
| 702 | */ |
| 703 | private static function callLogExceptionHook( Throwable $e, bool $suppressed ) { |
| 704 | try { |
| 705 | // It's possible for the exception handler to be triggered during service container |
| 706 | // initialization, e.g. if an autoloaded file triggers deprecation warnings. |
| 707 | // To avoid a difficult-to-debug autoload loop, avoid attempting to initialize the service |
| 708 | // container here. (T380456). |
| 709 | // The exception handler is also triggered when autoloading of HookRunner class fails, |
| 710 | // > Uncaught Error: Class "MediaWiki\HookContainer\HookRunner" not found |
| 711 | // Avoid use of the not-loaded class here, as that override the real error. |
| 712 | if ( !MediaWikiServices::hasInstance() || !class_exists( HookRunner::class, false ) ) { |
| 713 | return; |
| 714 | } |
| 715 | |
| 716 | ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) ) |
| 717 | ->onLogException( $e, $suppressed ); |
| 718 | } catch ( RecursiveServiceDependencyException ) { |
| 719 | // An error from the HookContainer wiring will lead here (T379125) |
| 720 | } |
| 721 | } |
| 722 | } |
| 723 | |
| 724 | /** @deprecated class alias since 1.44 */ |
| 725 | class_alias( MWExceptionHandler::class, 'MWExceptionHandler' ); |