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