Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
26.47% |
54 / 204 |
|
23.81% |
5 / 21 |
CRAP | |
0.00% |
0 / 1 |
MWExceptionHandler | |
26.60% |
54 / 203 |
|
23.81% |
5 / 21 |
2360.01 | |
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 |
210 | |||
handleFatalError | |
0.00% |
0 / 27 |
|
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 |
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 | |
21 | namespace MediaWiki\Exception; |
22 | |
23 | use ErrorException; |
24 | use MediaWiki\Debug\MWDebug; |
25 | use MediaWiki\HookContainer\HookRunner; |
26 | use MediaWiki\Logger\LoggerFactory; |
27 | use MediaWiki\MediaWikiServices; |
28 | use MediaWiki\Request\WebRequest; |
29 | use Psr\Log\LogLevel; |
30 | use Throwable; |
31 | use Wikimedia\NormalizedException\INormalizedException; |
32 | use Wikimedia\Rdbms\DBError; |
33 | use Wikimedia\Rdbms\DBQueryError; |
34 | use Wikimedia\Rdbms\LBFactory; |
35 | use Wikimedia\Services\RecursiveServiceDependencyException; |
36 | |
37 | /** |
38 | * Handler class for MWExceptions |
39 | * @ingroup Exception |
40 | */ |
41 | class 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 | |
390 | 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. |
391 | |
392 | 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. |
393 | TXT; |
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 */ |
744 | class_alias( MWExceptionHandler::class, 'MWExceptionHandler' ); |