wiki.techinc.nl/tests/phpunit/unit/includes/exception/MWExceptionHandlerTest.php

219 lines
6.4 KiB
PHP
Raw Normal View History

<?php
use Wikimedia\NormalizedException\NormalizedException;
/**
* @author Antoine Musso
* @copyright Copyright © 2013, Antoine Musso
* @copyright Copyright © 2013, Wikimedia Foundation Inc.
* @file
*/
class MWExceptionHandlerTest extends \MediaWikiUnitTestCase {
private $oldSettingValue;
protected function setUp(): void {
parent::setUp();
// We need to make sure the traces have function arguments as we're testing
// their handling.
$this->oldSettingValue = ini_set( 'zend.exception_ignore_args', 0 );
}
protected function tearDown(): void {
ini_set( 'zend.exception_ignore_args', $this->oldSettingValue );
parent::tearDown();
}
/**
* Test end-to-end formatting of an exception, such as used by LogstashFormatter.
*
* @covers MWExceptionHandler
* @see MWExceptionHandler::prettyPrintTrace
*/
public function testTraceFormatting() {
try {
$dummy = new TestThrowerDummy();
$startLine = __LINE__ + 1;
$dummy->main();
} catch ( Exception $e ) {
}
$startFile = __FILE__;
$dummyFile = TestThrowerDummy::getFile();
$dummyClass = TestThrowerDummy::class;
$expected = <<<TEXT
exception: Add the 'from' file/line to the logged exception trace This should make error logs easier to work with through a couple of ways: * The stack trace is now complete, instead of missing the first crucial step, which is often the one used for filtering purposes and for identifying errors within a given deployed version of MediaWiki. (E.g. when filtering out an error that is expected to be fixed by the next release and/or when checking how prominent an error currently is). * Logstash reports that report message + trace will not need to be edited by hand to include the file+line. * The workflow for Logstash generally follows one of two patterns. The default is to filter by exception.file (including line number), which is very sure to catch all possible variants thrown from the same code, regardless of any variables in the message, but has the downside of not matching week-over-week consistency due to file paths (at least for WMF) containing the deployment version. The other option is to filter by message, which has the risk of possibly excluding too much if there are multiple unrelated ways to trigger the issue, but is a sensible second option. This is usually done by filtering on normalized_message for non-exception errors, but doesn't work well for exceptions because they contain the file paths and do so in-between the class and message words, and thus are not compatible with Logstash's default substring/term match. The alternative of exception.message is then considered but is lacking the class/type, which can be fragile. With this change applied, no editing is needed, and no multiple approaches need to be considered with the same option. Either filtering by exception.file as-is, or filtering by normalized_message as-is, regardless of whether it is an exception error or other message in another channel, will both work. Bug: T271496 Change-Id: I5908ed53f9b97b3c9cde126aca89ab6fc197c845
2021-01-08 00:15:01 +00:00
from ${dummyFile}(17)
#0 ${dummyFile}(13): ${dummyClass}->getQuux()
#1 ${dummyFile}(9): ${dummyClass}->getBar()
#2 ${dummyFile}(5): ${dummyClass}->doFoo()
#3 ${startFile}($startLine): ${dummyClass}->main()
TEXT;
// Trim up until our call()
$trace = MWExceptionHandler::getRedactedTraceAsString( $e );
exception: Add the 'from' file/line to the logged exception trace This should make error logs easier to work with through a couple of ways: * The stack trace is now complete, instead of missing the first crucial step, which is often the one used for filtering purposes and for identifying errors within a given deployed version of MediaWiki. (E.g. when filtering out an error that is expected to be fixed by the next release and/or when checking how prominent an error currently is). * Logstash reports that report message + trace will not need to be edited by hand to include the file+line. * The workflow for Logstash generally follows one of two patterns. The default is to filter by exception.file (including line number), which is very sure to catch all possible variants thrown from the same code, regardless of any variables in the message, but has the downside of not matching week-over-week consistency due to file paths (at least for WMF) containing the deployment version. The other option is to filter by message, which has the risk of possibly excluding too much if there are multiple unrelated ways to trigger the issue, but is a sensible second option. This is usually done by filtering on normalized_message for non-exception errors, but doesn't work well for exceptions because they contain the file paths and do so in-between the class and message words, and thus are not compatible with Logstash's default substring/term match. The alternative of exception.message is then considered but is lacking the class/type, which can be fragile. With this change applied, no editing is needed, and no multiple approaches need to be considered with the same option. Either filtering by exception.file as-is, or filtering by normalized_message as-is, regardless of whether it is an exception error or other message in another channel, will both work. Bug: T271496 Change-Id: I5908ed53f9b97b3c9cde126aca89ab6fc197c845
2021-01-08 00:15:01 +00:00
$actual = implode( "\n", array_slice( explode( "\n", trim( $trace ) ), 0, 5 ) );
$this->assertEquals( $expected, $actual );
}
/**
* @covers MWExceptionHandler::getRedactedTrace
*/
public function testGetRedactedTrace() {
$refvar = 'value';
try {
$array = [ 'a', 'b' ];
$object = (object)[];
self::helperThrowForArgs( $array, $object, $refvar );
} catch ( Exception $e ) {
}
// Make sure our stack trace contains an array and an object passed to
// some function in the stacktrace. Else, we can not assert the trace
// redaction achieved its job.
$trace = $e->getTrace();
$hasObject = false;
$hasArray = false;
foreach ( $trace as $frame ) {
if ( !isset( $frame['args'] ) ) {
continue;
}
foreach ( $frame['args'] as $arg ) {
$hasObject = $hasObject || is_object( $arg );
$hasArray = $hasArray || is_array( $arg );
}
if ( $hasObject && $hasArray ) {
break;
}
}
$this->assertTrue( $hasObject, "The stacktrace has a frame with an object parameter" );
$this->assertTrue( $hasArray, "The stacktrace has a frame with an array parameter" );
// Now we redact the trace.. and verify there are no longer any arrays or objects
$redacted = MWExceptionHandler::getRedactedTrace( $e );
foreach ( $redacted as $frame ) {
if ( !isset( $frame['args'] ) ) {
continue;
}
foreach ( $frame['args'] as $arg ) {
$this->assertIsNotArray( $arg );
$this->assertIsNotObject( $arg );
}
}
$this->assertEquals( 'value', $refvar, 'Reference variable' );
}
/**
* @covers MWExceptionHandler::getLogNormalMessage
*/
public function testGetLogNormalMessage() {
$this->assertSame(
'[{reqId}] {exception_url} Exception: message',
MWExceptionHandler::getLogNormalMessage( new Exception( 'message' ) )
);
$this->assertSame(
'[{reqId}] {exception_url} message',
MWExceptionHandler::getLogNormalMessage( new ErrorException( 'message' ) )
);
$this->assertSame(
'[{reqId}] {exception_url} ' . NormalizedException::class . ': {placeholder}',
MWExceptionHandler::getLogNormalMessage(
new NormalizedException( '{placeholder}', [ 'placeholder' => 'message' ] )
)
);
}
/**
* @covers MWExceptionHandler::getLogContext
*/
public function testGetLogContext() {
$e = new Exception( 'message' );
$context = MWExceptionHandler::getLogContext( $e );
$this->assertSame( $e, $context['exception'] );
$e = new NormalizedException( 'message', [ 'param' => 'value' ] );
$context = MWExceptionHandler::getLogContext( $e );
$this->assertSame( $e, $context['exception'] );
$this->assertSame( 'value', $context['param'] );
}
/**
* @dataProvider provideJsonSerializedKeys
* @covers MWExceptionHandler::jsonSerializeException
*
* @param string $expectedKeyType Type expected as returned by gettype()
* @param string $exClass An exception class (ie: Exception, MWException)
* @param string $key Name of the key to validate in the serialized JSON
*/
public function testJsonserializeexceptionKeys( $expectedKeyType, $exClass, $key ) {
// Make sure we log a backtrace:
$GLOBALS['wgLogExceptionBacktrace'] = true;
$json = json_decode(
MWExceptionHandler::jsonSerializeException( new $exClass() )
);
$this->assertObjectHasAttribute( $key, $json );
$this->assertSame( $expectedKeyType, gettype( $json->$key ), "Type of the '$key' key" );
}
/**
* Each case provides: [ type, exception class, key name ]
*/
public static function provideJsonSerializedKeys() {
foreach ( [ Exception::class, MWException::class ] as $exClass ) {
yield [ 'string', $exClass, 'id' ];
yield [ 'string', $exClass, 'file' ];
yield [ 'integer', $exClass, 'line' ];
yield [ 'string', $exClass, 'message' ];
yield [ 'NULL', $exClass, 'url' ];
// Backtrace only enabled with wgLogExceptionBacktrace = true
yield [ 'array', $exClass, 'backtrace' ];
}
}
/**
* Given wgLogExceptionBacktrace is true
* then serialized exception must have a backtrace
*
* @covers MWExceptionHandler::jsonSerializeException
*/
public function testJsonserializeexceptionBacktracingEnabled() {
$GLOBALS['wgLogExceptionBacktrace'] = true;
$json = json_decode(
MWExceptionHandler::jsonSerializeException( new Exception() )
);
$this->assertObjectHasAttribute( 'backtrace', $json );
}
/**
* Given wgLogExceptionBacktrace is false
* then serialized exception must not have a backtrace
*
* @covers MWExceptionHandler::jsonSerializeException
*/
public function testJsonserializeexceptionBacktracingDisabled() {
$GLOBALS['wgLogExceptionBacktrace'] = false;
$json = json_decode(
MWExceptionHandler::jsonSerializeException( new Exception() )
);
$this->assertObjectNotHasAttribute( 'backtrace', $json );
}
/**
* Helper function for testGetRedactedTrace
*
* @param array $a
* phpcs:disable MediaWiki.Commenting.FunctionComment.ObjectTypeHintParam
* @param object $b
* @param mixed &$c
* @throws Exception
*/
protected static function helperThrowForArgs( array $a, object $b, &$c ) {
throw new Exception();
}
}