agent/php/ElasticApm/Impl/AutoInstrument/WordPressAutoInstrumentation.php (369 lines of code) (raw):
<?php
/** @noinspection PhpFullyQualifiedNameUsageInspection */
/** @noinspection PhpUndefinedConstantInspection */
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
declare(strict_types=1);
namespace Elastic\Apm\Impl\AutoInstrument;
use Closure;
use Elastic\Apm\ElasticApm;
use Elastic\Apm\Impl\AutoInstrument\Util\AutoInstrumentationUtil;
use Elastic\Apm\Impl\Log\Level;
use Elastic\Apm\Impl\Log\LogCategory;
use Elastic\Apm\Impl\Log\LoggableToString;
use Elastic\Apm\Impl\Log\Logger;
use Elastic\Apm\Impl\Log\LoggerFactory;
use Elastic\Apm\Impl\Log\LogStreamInterface;
use Elastic\Apm\Impl\NameVersionData;
use Elastic\Apm\Impl\Tracer;
use Elastic\Apm\Impl\Util\ArrayUtil;
use Elastic\Apm\Impl\Util\ClassNameUtil;
use Elastic\Apm\Impl\Util\DbgUtil;
use ReflectionClass;
use ReflectionException;
use ReflectionFunction;
use Throwable;
/**
* Code in this file is part of implementation internals and thus it is not covered by the backward compatibility.
*
* @internal
*/
final class WordPressAutoInstrumentation extends AutoInstrumentationBase
{
private const SERVICE_FRAMEWORK_NAME = 'WordPress';
public const SPAN_NAME_PART_FOR_CORE = 'WordPress core';
public const THEME_KEYWORD = 'wordpress_theme';
public const CALLBACK_GROUP_KIND_CORE = 'wordpress_core';
public const CALLBACK_GROUP_KIND_PLUGIN = 'wordpress_plugin';
public const CALLBACK_GROUP_KIND_THEME = self::THEME_KEYWORD;
public const LABEL_KEY_FOR_WORDPRESS_THEME = self::THEME_KEYWORD;
private const WORDPRESS_PLUGINS_SUBDIR_SUBPATH = '/wp-content/plugins/';
private const WORDPRESS_MU_PLUGINS_SUBDIR_SUBPATH = '/wp-content/mu-plugins/';
private const WORDPRESS_THEMES_SUBDIR_SUBPATH = '/wp-content/themes/';
private const CALLBACK_SUBDIR_SUBPATH_TO_GROUP_KIND = [
self::WORDPRESS_PLUGINS_SUBDIR_SUBPATH => self::CALLBACK_GROUP_KIND_PLUGIN,
self::WORDPRESS_MU_PLUGINS_SUBDIR_SUBPATH => self::CALLBACK_GROUP_KIND_PLUGIN,
self::WORDPRESS_THEMES_SUBDIR_SUBPATH => self::CALLBACK_GROUP_KIND_THEME,
];
/**
* \ELASTIC_APM_* constants are provided by the elastic_apm extension
*
* @phpstan-ignore-next-line
*/
private const DIRECT_CALL_METHOD_SET_READY_TO_WRAP_FILTER_CALLBACKS = \ELASTIC_APM_WORDPRESS_DIRECT_CALL_METHOD_SET_READY_TO_WRAP_FILTER_CALLBACKS;
/** @var Logger */
private $logger;
/** @var AutoInstrumentationUtil */
private $util;
/** @var bool */
private $isInFailedMode = false;
/** @var bool */
private $isReadyToWrapFilterCallbacks = false;
/** @var bool */
private $isServiceFrameworkSet = false;
/** @var ?NameVersionData */
private $serviceFramework = null;
public function __construct(Tracer $tracer)
{
parent::__construct($tracer);
$this->logger = $tracer->loggerFactory()->loggerForClass(LogCategory::AUTO_INSTRUMENTATION, __NAMESPACE__, __CLASS__, __FILE__)->addContext('this', $this);
$this->util = new AutoInstrumentationUtil($tracer->loggerFactory());
}
/** @inheritDoc */
public function name(): string
{
return InstrumentationNames::WORDPRESS;
}
/** @inheritDoc */
public function keywords(): array
{
return [];
}
/** @inheritDoc */
public function register(RegistrationContextInterface $ctx): void
{
$this->tracer->getMetadataDiscoverer()->addServiceFrameworkDiscoverer(
ClassNameUtil::fqToShort(__CLASS__) /* <- dbgDiscovererName */,
function (): ?NameVersionData {
return $this->discoverServiceFramework();
}
);
}
/** @inheritDoc */
public function requiresUserlandCodeInstrumentation(): bool
{
return true;
}
private function switchToFailedMode(): void
{
if ($this->isInFailedMode) {
return;
}
($loggerProxy = $this->logger->ifErrorLevelEnabled(__LINE__, __FUNCTION__))
&& $loggerProxy->includeStackTrace()->log('Switching to FAILED mode');
$this->isInFailedMode = true;
}
private function setReadyToWrapFilterCallbacks(): void
{
$this->isReadyToWrapFilterCallbacks = true;
}
/**
* @param string $filePath
* @param LoggerFactory $loggerFactory
* @param ?string $groupKind
* @param ?string $groupName
*
* @return void
*
* @param-out string $groupKind
* @param-out ?string $groupName
*/
public static function findAddonInfoFromFilePath(string $filePath, LoggerFactory $loggerFactory, /* out */ ?string &$groupKind, /* out */ ?string &$groupName): void
{
$logger = null;
$loggerProxyTrace = null;
if ($loggerFactory->isEnabledForLevel(Level::TRACE)) {
$logger = $loggerFactory->loggerForClass(LogCategory::AUTO_INSTRUMENTATION, __NAMESPACE__, __CLASS__, __FILE__)->addContext('filePath', $filePath);
$loggerProxyTrace = $logger->ifTraceLevelEnabledNoLine(__FUNCTION__);
}
$loggerProxyTrace && $loggerProxyTrace->log(__LINE__, 'Entered');
$adaptedFilePath = str_replace('\\', '/', $filePath);
$logger && $logger->addContext('adaptedFilePath', $adaptedFilePath);
$groupKind = self::CALLBACK_GROUP_KIND_CORE;
$groupName = null;
$currentGroupKind = null;
/** @var ?int $posAfterAddonsSubDir */
$posAfterAddonsSubDir = null;
foreach (self::CALLBACK_SUBDIR_SUBPATH_TO_GROUP_KIND as $subDirSubPath => $currentGroupKind) {
$pluginsSubDirPos = strpos($adaptedFilePath, $subDirSubPath);
if ($pluginsSubDirPos !== false) {
$posAfterAddonsSubDir = $pluginsSubDirPos + strlen($subDirSubPath);
break;
}
}
if ($posAfterAddonsSubDir === null) {
$loggerProxyTrace && $loggerProxyTrace->log(__LINE__, 'Not found any of the known sub-paths in the given path');
return;
}
$logger && $logger->addContext('posAfterAddonsSubDir', $posAfterAddonsSubDir);
$dirSeparatorAfterPluginPos = strpos($adaptedFilePath, '/', $posAfterAddonsSubDir);
if ($dirSeparatorAfterPluginPos !== false && $dirSeparatorAfterPluginPos > $posAfterAddonsSubDir) {
$groupKind = $currentGroupKind;
$groupName = substr($adaptedFilePath, $posAfterAddonsSubDir, $dirSeparatorAfterPluginPos - $posAfterAddonsSubDir);
return;
}
$fileExtAfterPluginPos = strpos($adaptedFilePath, '.php', $posAfterAddonsSubDir);
if ($fileExtAfterPluginPos !== false && $fileExtAfterPluginPos > $posAfterAddonsSubDir) {
$groupKind = $currentGroupKind;
$groupName = substr($adaptedFilePath, $posAfterAddonsSubDir, $fileExtAfterPluginPos - $posAfterAddonsSubDir);
return;
}
$loggerProxyTrace && $loggerProxyTrace->log(__LINE__, 'Found one of the known sub-paths but the suffix is not as expected');
}
/**
* @param Closure|string $callback
* @param Logger $logger
*
* @return ?string
*
* @throws ReflectionException
*/
private static function getCallbackSourceFilePathImplForFunc($callback, Logger $logger): ?string
{
$reflectFunc = new ReflectionFunction($callback);
if (($srcFilePath = $reflectFunc->getFileName()) === false) {
($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('Failed to get file name from ReflectionFunction of captured callback');
return null;
}
return $srcFilePath;
}
/**
* @param object|string $classInstanceOrName
* @param Logger $logger
*
* @return ?string
*
* @throws ReflectionException
*/
private static function getCallbackSourceFilePathImplForClass($classInstanceOrName, Logger $logger): ?string
{
/** @var object|class-string $classInstanceOrName */
$reflectClass = new ReflectionClass($classInstanceOrName);
if (($srcFilePath = $reflectClass->getFileName()) === false) {
($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
&& $loggerProxy->log('Failed to get file name from ReflectionClass of captured callback', ['classInstanceOrName' => $classInstanceOrName]);
return null;
}
return $srcFilePath;
}
/**
* @param mixed $callback
* @param Logger $logger
*
* @return ?string
*
* @throws ReflectionException
*/
private static function getCallbackSourceFilePathImpl($callback, Logger $logger): ?string
{
// If callback is a Closure or string but not 'Class::method'
if ($callback instanceof Closure) {
return self::getCallbackSourceFilePathImplForFunc($callback, $logger);
}
// If callback is a string but not 'Class::method'
if (is_string($callback)) {
if (($afterClassNamePos = strpos($callback, '::')) === false) {
return self::getCallbackSourceFilePathImplForFunc($callback, $logger);
}
$className = substr($callback, /* offset */ 0, /* length */ $afterClassNamePos);
return self::getCallbackSourceFilePathImplForClass($className, $logger);
}
if (!is_array($callback)) {
($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('callback of unexpected type');
return null;
}
if (ArrayUtil::isEmpty($callback)) {
($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('callback is an empty array');
return null;
}
$firstElement = $callback[0];
if (is_string($firstElement) || is_object($firstElement)) {
return self::getCallbackSourceFilePathImplForClass($firstElement, $logger);
}
($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
&& $loggerProxy->log('callback is an array but its first element is of unexpected type', ['firstElement type' => DbgUtil::getType($firstElement), 'firstElement' => $firstElement]);
return null;
}
/**
* @param mixed $callback
* @param LoggerFactory $loggerFactory
*
* @return ?string
*/
public static function getCallbackSourceFilePath($callback, LoggerFactory $loggerFactory): ?string
{
$logger = $loggerFactory->loggerForClass(LogCategory::AUTO_INSTRUMENTATION, __NAMESPACE__, __CLASS__, __FILE__)
->addAllContext(['callback type' => DbgUtil::getType($callback), 'callback' => $callback]);
try {
return self::getCallbackSourceFilePathImpl($callback, $logger);
} catch (ReflectionException $e) {
($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->logThrowable($e, 'Failed to reflect captured callback');
return null;
}
}
/**
* @param mixed $callback
* @param-out string $groupKind
* @param-out ?string $groupName
*/
private function findCallbackInfo($callback, /* out */ ?string &$groupKind, /* out */ ?string &$groupName): void
{
if (($srcFilePath = self::getCallbackSourceFilePath($callback, $this->tracer->loggerFactory())) === null) {
$groupKind = self::CALLBACK_GROUP_KIND_CORE;
$groupName = null;
return;
}
self::findAddonInfoFromFilePath($srcFilePath, $this->tracer->loggerFactory(), /* out */ $groupKind, /* out */ $groupName);
}
private function discoverServiceFramework(): ?NameVersionData
{
if ($this->isServiceFrameworkSet) {
return $this->serviceFramework;
}
$loggerProxyDebug = $this->logger->ifDebugLevelEnabledNoLine(__FUNCTION__);
if (!$this->isReadyToWrapFilterCallbacks) {
$loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Did not discover because expected WordPress code (_wp_filter_build_unique_id function) was not loaded');
$this->isServiceFrameworkSet = true;
return $this->serviceFramework;
}
global $wp_version;
/** @var ?string $reasonNotDiscovered */
$reasonNotDiscovered = null;
if (isset($wp_version)) {
if (is_string($wp_version)) {
$this->serviceFramework = new NameVersionData(self::SERVICE_FRAMEWORK_NAME, $wp_version);
} else {
$reasonNotDiscovered = '$wp_version global variable is set but it is not a string; $wp_version: '
. LoggableToString::convert(['type' => DbgUtil::getType($wp_version), 'value' => $wp_version]);
}
} else {
$reasonNotDiscovered = '$wp_version global variable is not set';
}
if ($reasonNotDiscovered === null) {
$loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Discovered', ['serviceFramework' => $this->serviceFramework]);
} else {
$loggerProxyDebug && $loggerProxyDebug->log(__LINE__, 'Did not discover because ' . $reasonNotDiscovered);
}
$this->isServiceFrameworkSet = true;
return $this->serviceFramework;
}
public function directCall(string $method): void
{
if ($this->isInFailedMode) {
return;
}
$logger = $this->logger->inherit()->addAllContext(['method' => $method]);
switch ($method) {
case self::DIRECT_CALL_METHOD_SET_READY_TO_WRAP_FILTER_CALLBACKS:
$this->setReadyToWrapFilterCallbacks();
return;
default:
($loggerProxy = $logger->ifErrorLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('Unexpected method');
$this->switchToFailedMode();
}
}
/**
* @param ?string $instrumentedClassFullName
* @param string $instrumentedFunction
* @param mixed[] $capturedArgs
*
* @return null|callable(?Throwable $thrown, mixed $returnValue): void
*/
public function preHook(?string $instrumentedClassFullName, string $instrumentedFunction, array $capturedArgs): ?callable
{
if ($this->isInFailedMode) {
return null /* <- null means there is no post-hook */;
}
$logger = $this->logger->inherit()->addAllContext(
['instrumentedClassFullName' => $instrumentedClassFullName, 'instrumentedFunction' => $instrumentedFunction, 'capturedArgs' => $capturedArgs]
);
// We should cover all the function instrumented in src/ext/WordPress_instrumentation.c
if ($instrumentedClassFullName !== null) {
if ($instrumentedClassFullName !== 'WP_Hook') {
($loggerProxy = $logger->ifErrorLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('Unexpected instrumentedClassFullName');
$this->switchToFailedMode();
return null /* <- null means there is no post-hook */;
}
if ($instrumentedFunction !== 'add_filter') {
($loggerProxy = $logger->ifErrorLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('Unexpected instrumentedFunction');
$this->switchToFailedMode();
return null /* <- null means there is no post-hook */;
}
$this->preHookAddFilter($capturedArgs);
return null /* <- null means there is no post-hook */;
}
switch ($instrumentedFunction) {
case '_wp_filter_build_unique_id':
$this->preHookWpFilterBuildUniqueId($capturedArgs);
return null /* <- null means there is no post-hook */;
case 'get_template':
/**
* @param ?Throwable $thrown
* @param mixed $returnValue
*/
return function (?Throwable $thrown, $returnValue): void {
$this->postHookGetTemplate($thrown, $returnValue);
};
default:
($loggerProxy = $logger->ifErrorLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('Unexpected instrumentedFunction');
$this->switchToFailedMode();
}
return null /* <- null means there is no post-hook */;
}
/**
* @param mixed[] $capturedArgs
*
* @return void
*/
private function preHookAddFilter(array $capturedArgs): void
{
if (!$this->isReadyToWrapFilterCallbacks) {
static $isFirstTime = true;
if ($isFirstTime) {
($loggerProxy = $this->logger->ifWarningLevelEnabled(__LINE__, __FUNCTION__))
&& $loggerProxy->log('First attempt to wrap callback but it is not ready yet');
$isFirstTime = false;
}
return;
}
if (!$this->preHookAddFilterImpl($capturedArgs)) {
$this->switchToFailedMode();
}
}
/**
* @param mixed[] $capturedArgs
*
* @return void
*/
private function preHookWpFilterBuildUniqueId(array $capturedArgs): void
{
if (!$this->preHookWpFilterBuildUniqueIdImpl($capturedArgs)) {
$this->switchToFailedMode();
}
}
/**
* @param mixed[] $capturedArgs
*
* @return bool
*/
private function preHookAddFilterImpl(array $capturedArgs): bool
{
if (!$this->verifyHookNameCallbackArgs($capturedArgs)) {
return false;
}
/** @var string $hookName */
$hookName = $capturedArgs[0];
$callback =& $capturedArgs[1];
if ($callback instanceof WordPressFilterCallbackWrapper) {
return true;
}
$originalCallback = $callback;
$this->findCallbackInfo($originalCallback, /* out */ $callbackGroupKind, /* out */ $callbackGroupName);
$wrapper = new WordPressFilterCallbackWrapper($hookName, $originalCallback, $callbackGroupKind, $callbackGroupName);
$callback = $wrapper;
($loggerProxy = $this->logger->ifTraceLevelEnabled(__LINE__, __FUNCTION__))
&& $loggerProxy->log('Callback has been wrapped', ['original callback' => $originalCallback, 'wrapper' => $wrapper]);
return true;
}
/**
* @param mixed[] $capturedArgs
*
* @return bool
*/
private function preHookWpFilterBuildUniqueIdImpl(array $capturedArgs): bool
{
if (!$this->verifyHookNameCallbackArgs($capturedArgs)) {
return false;
}
$callback =& $capturedArgs[1];
if (!($callback instanceof WordPressFilterCallbackWrapper)) {
return true;
}
$wrapper = $callback;
$originalCallback = $wrapper->getWrappedCallback();
$callback = $originalCallback;
($loggerProxy = $this->logger->ifTraceLevelEnabled(__LINE__, __FUNCTION__))
&& $loggerProxy->log('Callback has been unwrapped', ['original callback' => $originalCallback, 'wrapper' => $wrapper]);
return true;
}
/**
* @param mixed[] $capturedArgs
*
* @return bool
*/
private function verifyHookNameCallbackArgs(array $capturedArgs): bool
{
//
// We should get (see src/ext/WordPress_instrumentation.c):
// [0] $hook_name parameter by value
// [1] $callback parameter by reference
//
// function add_filter($hook_name, $callback, $priority = 10, $accepted_args = 1)
// function _wp_filter_build_unique_id($hook_name, $callback, $priority)
return $this->util->verifyExactArgsCount(2, $capturedArgs)
&& $this->util->verifyIsString($capturedArgs[0], 'hook_name')
&& $this->util->verifyIsCallable($capturedArgs[1], /* shouldCheckSyntaxOnly */ true, '$callback');
}
/**
* @param ?Throwable $thrown
* @param mixed $returnValue
*/
private function postHookGetTemplate(?Throwable $thrown, $returnValue): void
{
$logger = $this->logger->inherit()->addAllContext(['thrown' => $thrown, 'returnValue type' => DbgUtil::getType($returnValue), 'returnValue' => $returnValue]);
if ($thrown !== null) {
($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('Instrumented function has thrown so there is no return value');
return;
}
if ($returnValue === null) {
($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('Return value is null');
return;
}
if (!is_string($returnValue)) {
($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('Return value is not a string');
return;
}
($loggerProxy = $logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
&& $loggerProxy->log('Recording WordPress theme as a label on transaction', ['theme' => $returnValue, 'label key' => self::LABEL_KEY_FOR_WORDPRESS_THEME]);
ElasticApm::getCurrentTransaction()->context()->setLabel(self::LABEL_KEY_FOR_WORDPRESS_THEME, $returnValue);
}
public function toLog(LogStreamInterface $stream): void
{
$stream->toLogAs(
[
'isInFailedMode' => $this->isInFailedMode,
'isReadyToWrapFilterCallbacks' => $this->isReadyToWrapFilterCallbacks,
'isServiceFrameworkSet' => $this->isServiceFrameworkSet,
'serviceFramework' => $this->serviceFramework,
'wrapper ctor calls count' => WordPressFilterCallbackWrapper::$ctorCalls,
'wrapper dtor calls count' => WordPressFilterCallbackWrapper::$dtorCalls,
]
);
}
}