From 392c2bab19e27c9ddce6eadfb160dac86a19cecf Mon Sep 17 00:00:00 2001 From: Dave Newson Date: Fri, 10 Apr 2026 12:51:54 +1000 Subject: [PATCH] Match thrown exception type onPhpErrorCapturedByNativePart, to ensure all Uncaught throwables are accurately captured if they emit to the native handler. --- .../TransactionForExtensionRequest.php | 2 +- .../ComponentTests/ErrorComponentTest.php | 112 ++++++++++++++++++ ...ppCodeForTestPhpErrorUncaughtException.php | 30 +++++ 3 files changed, 143 insertions(+), 1 deletion(-) diff --git a/agent/php/ElasticApm/Impl/AutoInstrument/TransactionForExtensionRequest.php b/agent/php/ElasticApm/Impl/AutoInstrument/TransactionForExtensionRequest.php index 007371f97..37f4eda90 100644 --- a/agent/php/ElasticApm/Impl/AutoInstrument/TransactionForExtensionRequest.php +++ b/agent/php/ElasticApm/Impl/AutoInstrument/TransactionForExtensionRequest.php @@ -383,7 +383,7 @@ public function onPhpErrorCapturedByNativePart(PhpErrorData $phpErrorData): void if ( $this->lastThrown !== null && $phpErrorData->message !== null - && TextUtil::isPrefixOf('Uncaught Exception: ', $phpErrorData->message, /* isCaseSensitive: */ false) + && TextUtil::isPrefixOf('Uncaught ' . get_class($this->lastThrown) . ':', $phpErrorData->message, /* isCaseSensitive: */ false) ) { $relatedThrowable = $this->lastThrown; $this->lastThrown = null; diff --git a/tests/ElasticApmTests/ComponentTests/ErrorComponentTest.php b/tests/ElasticApmTests/ComponentTests/ErrorComponentTest.php index 4f6392c78..daacf3547 100644 --- a/tests/ElasticApmTests/ComponentTests/ErrorComponentTest.php +++ b/tests/ElasticApmTests/ComponentTests/ErrorComponentTest.php @@ -539,4 +539,116 @@ function () use ($testArgs): void { } ); } + + public static function appCodeForTestPhpErrorUncaughtExceptionSubclassWrapper(bool $justReturnLineNumber = false): int + { + if (!$justReturnLineNumber) { + ini_set('display_errors', '0'); + } + + return $justReturnLineNumber ? __LINE__ : appCodeForTestPhpErrorUncaughtExceptionSubclass(); + } + + private function implTestPhpErrorUncaughtExceptionSubclass(MixedMap $testArgs): void + { + AssertMessageStack::newScope(/* out */ $dbgCtx, AssertMessageStack::funcArgs()); + + $captureErrorsOptVal = $testArgs->getBool(OptionNames::CAPTURE_ERRORS); + $captureErrorsWithPhpPartOptVal = $testArgs->getBool(OptionNames::CAPTURE_ERRORS_WITH_PHP_PART); + $captureExceptionsOptVal = $testArgs->getNullableBool(OptionNames::CAPTURE_EXCEPTIONS); + $shouldCaptureExceptionsDerivedCfg = $captureExceptionsOptVal ?? $captureErrorsOptVal; + $devInternalCaptureErrorsOnlyToLogOptVal = $testArgs->getBool(OptionNames::DEV_INTERNAL_CAPTURE_ERRORS_ONLY_TO_LOG); + + $testCaseHandle = $this->getTestCaseHandle(); + $appCodeHost = self::ensureMainAppCodeHost($testCaseHandle, $testArgs); + + $appCodeHost->sendRequest( + AppCodeTarget::asRouted([__CLASS__, 'appCodeForTestPhpErrorUncaughtExceptionSubclassWrapper']), + function (AppCodeRequestParams $appCodeRequestParams): void { + if ($appCodeRequestParams instanceof HttpAppCodeRequestParams) { + $appCodeRequestParams->expectedHttpResponseStatusCode = HttpConstantsForTests::STATUS_INTERNAL_SERVER_ERROR; + } + } + ); + + if ($captureErrorsWithPhpPartOptVal) { + $isErrorExpected = $shouldCaptureExceptionsDerivedCfg; + } else { + if (self::isMainAppCodeHostHttp()) { + $isErrorExpected = $captureErrorsOptVal || $shouldCaptureExceptionsDerivedCfg; + } else { + $isErrorExpected = $captureErrorsOptVal; + } + } + $isErrorExpected = $isErrorExpected && !$devInternalCaptureErrorsOnlyToLogOptVal; + + $expectedErrorCount = $isErrorExpected ? 1 : 0; + $dataFromAgent = $testCaseHandle->waitForDataFromAgent((new ExpectedEventCounts())->transactions(1)->errors($expectedErrorCount)); + $dbgCtx->add(compact('dataFromAgent')); + + if (self::isMainAppCodeHostHttp()) { + self::assertSame(Constants::OUTCOME_FAILURE, $dataFromAgent->singleTransaction()->outcome); + } + + self::assertCount($expectedErrorCount, $dataFromAgent->idToError); + if (!$isErrorExpected) { + return; + } + + $err = $this->verifyError($dataFromAgent); + + $appCodeFile = FileUtilForTests::listToPath([dirname(__FILE__), 'appCodeForTestPhpErrorUncaughtException.php']); + self::assertNotNull($err->exception); + self::assertNull($err->exception->module); + if ($shouldCaptureExceptionsDerivedCfg) { + $culpritFunction = __NAMESPACE__ . '\\appCodeForTestPhpErrorUncaughtExceptionSubclassImpl'; + self::assertSame($culpritFunction, $err->culprit); + + $defaultCode = (new \RuntimeException(''))->getCode(); + self::assertSame($defaultCode, $err->exception->code); + self::assertSame(APP_CODE_FOR_TEST_PHP_ERROR_UNCAUGHT_EXCEPTION_SUBCLASS_MESSAGE, $err->exception->message); + self::assertSame('RuntimeException', $err->exception->type); + $expectedStackTraceTop = [ + [ + self::STACK_TRACE_FILE_NAME => $appCodeFile, + self::STACK_TRACE_FUNCTION => null, + self::STACK_TRACE_LINE_NUMBER => APP_CODE_FOR_TEST_PHP_ERROR_UNCAUGHT_EXCEPTION_SUBCLASS_ERROR_LINE_NUMBER, + ], + [ + self::STACK_TRACE_FILE_NAME => $appCodeFile, + self::STACK_TRACE_FUNCTION => __NAMESPACE__ . '\\appCodeForTestPhpErrorUncaughtExceptionSubclassImpl', + self::STACK_TRACE_LINE_NUMBER => APP_CODE_FOR_TEST_PHP_ERROR_UNCAUGHT_EXCEPTION_SUBCLASS_CALL_TO_IMPL_LINE_NUMBER, + ], + [ + self::STACK_TRACE_FILE_NAME => __FILE__, + self::STACK_TRACE_FUNCTION => __NAMESPACE__ . '\\appCodeForTestPhpErrorUncaughtExceptionSubclass', + self::STACK_TRACE_LINE_NUMBER => self::appCodeForTestPhpErrorUncaughtExceptionSubclassWrapper(/* justReturnLineNumber */ true), + ], + [ + self::STACK_TRACE_FUNCTION => __CLASS__ . '::appCodeForTestPhpErrorUncaughtExceptionSubclassWrapper', + ], + ]; + self::verifyAppCodeStackTraceTop($expectedStackTraceTop, $err); + } else { // if ($shouldCaptureExceptionsDerivedCfg) + self::assertNull($err->culprit); + + self::assertNotNull($err->exception->message); + self::assertStringContainsString(APP_CODE_FOR_TEST_PHP_ERROR_UNCAUGHT_EXCEPTION_SUBCLASS_MESSAGE, $err->exception->message); + self::assertNotNull($err->exception->stacktrace); + self::assertCount(0, $err->exception->stacktrace); + } + } + + /** + * @dataProvider dataProviderForTestsForErrorCausedByException + */ + public function testPhpErrorUncaughtExceptionSubclass(MixedMap $testArgs): void + { + self::runAndEscalateLogLevelOnFailure( + self::buildDbgDescForTestWithArtgs(__CLASS__, __FUNCTION__, $testArgs), + function () use ($testArgs): void { + $this->implTestPhpErrorUncaughtExceptionSubclass($testArgs); + } + ); + } } diff --git a/tests/ElasticApmTests/ComponentTests/appCodeForTestPhpErrorUncaughtException.php b/tests/ElasticApmTests/ComponentTests/appCodeForTestPhpErrorUncaughtException.php index 07993a4fe..95a4fbb91 100644 --- a/tests/ElasticApmTests/ComponentTests/appCodeForTestPhpErrorUncaughtException.php +++ b/tests/ElasticApmTests/ComponentTests/appCodeForTestPhpErrorUncaughtException.php @@ -71,3 +71,33 @@ function appCodeForTestPhpErrorUncaughtException(): int TestCase::assertSame(APP_CODE_FOR_TEST_PHP_ERROR_UNCAUGHT_EXCEPTION_CALL_TO_IMPL_LINE_NUMBER, __LINE__ + 1); appCodeForTestPhpErrorUncaughtExceptionImpl(); // <- APP_CODE_FOR_TEST_PHP_ERROR_UNCAUGHT_EXCEPTION_CALL_TO_IMPL_LINE_NUMBER } + +const APP_CODE_FOR_TEST_PHP_ERROR_UNCAUGHT_EXCEPTION_SUBCLASS_MESSAGE = 'Message for uncaught RuntimeException subclass'; + +const APP_CODE_FOR_TEST_PHP_ERROR_UNCAUGHT_EXCEPTION_SUBCLASS_ERROR_LINE_NUMBER = 89; + +/** + * @return never + * + * @throws \RuntimeException + * + * @noinspection PhpReturnDocTypeMismatchInspection + */ +function appCodeForTestPhpErrorUncaughtExceptionSubclassImpl() +{ + TestCase::assertSame(APP_CODE_FOR_TEST_PHP_ERROR_UNCAUGHT_EXCEPTION_SUBCLASS_ERROR_LINE_NUMBER, __LINE__ + 1); + throw new \RuntimeException(APP_CODE_FOR_TEST_PHP_ERROR_UNCAUGHT_EXCEPTION_SUBCLASS_MESSAGE); // <- APP_CODE_FOR_TEST_PHP_ERROR_UNCAUGHT_EXCEPTION_SUBCLASS_ERROR_LINE_NUMBER +} + +const APP_CODE_FOR_TEST_PHP_ERROR_UNCAUGHT_EXCEPTION_SUBCLASS_CALL_TO_IMPL_LINE_NUMBER = 102; + +/** + * @return never + * + * @throws \RuntimeException + */ +function appCodeForTestPhpErrorUncaughtExceptionSubclass(): int +{ + TestCase::assertSame(APP_CODE_FOR_TEST_PHP_ERROR_UNCAUGHT_EXCEPTION_SUBCLASS_CALL_TO_IMPL_LINE_NUMBER, __LINE__ + 1); + appCodeForTestPhpErrorUncaughtExceptionSubclassImpl(); // <- APP_CODE_FOR_TEST_PHP_ERROR_UNCAUGHT_EXCEPTION_SUBCLASS_CALL_TO_IMPL_LINE_NUMBER +}