Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
29.50% |
59 / 200 |
|
28.57% |
6 / 21 |
CRAP | |
0.00% |
0 / 1 |
MWExceptionHandler | |
29.50% |
59 / 200 |
|
28.57% |
6 / 21 |
1992.80 | |
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 / 32 |
|
0.00% |
0 / 1 |
182 | |||
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 | |||
jsonSerializeException | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
logException | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
30 | |||
logError | |
0.00% |
0 / 12 |
|
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 | |
21 | use MediaWiki\Debug\MWDebug; |
22 | use MediaWiki\HookContainer\HookRunner; |
23 | use MediaWiki\Json\FormatJson; |
24 | use MediaWiki\Logger\LoggerFactory; |
25 | use MediaWiki\MediaWikiServices; |
26 | use MediaWiki\Request\WebRequest; |
27 | use Psr\Log\LogLevel; |
28 | use Wikimedia\NormalizedException\INormalizedException; |
29 | use Wikimedia\Rdbms\DBError; |
30 | use Wikimedia\Rdbms\DBQueryError; |
31 | |
32 | /** |
33 | * Handler class for MWExceptions |
34 | * @ingroup Exception |
35 | */ |
36 | class 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 | |
384 | 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. |
385 | |
386 | 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. |
387 | TXT; |
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 | } |