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