diff --git a/CHANGELOG.md b/CHANGELOG.md index 62fe3a96..844b74b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ - New `\Scoutapm\Config\ConfigKey` class containing `public const`s for configuration key names (#83) - Added config key `log_level` which overrides Scout APM's minimum log level (#83) + - Added more new config keys (#88): + - `application_root` (defaults to `composer.json` location, or `$_SERVER['DOCUMENT_ROOT']` + - `scm_subdirectory` (defaults to `.git` location, or `application_root` value) + - `revision_sha` (defaults to version detected by `ocramius/package-versions`) + - `hostname` (defaults to value of `gethostname()`) + - `core_agent_permissions` (defaults to `0777`) + - Added warning when `name` or `key` configurations are not set ### Fixed diff --git a/src/Agent.php b/src/Agent.php index 3e276a33..c032becf 100644 --- a/src/Agent.php +++ b/src/Agent.php @@ -26,6 +26,8 @@ use Scoutapm\Extension\ExtentionCapabilities; use Scoutapm\Extension\PotentiallyAvailableExtensionCapabilities; use Scoutapm\Logger\FilteredLogLevelDecorator; +use function is_string; +use function sprintf; final class Agent implements ScoutApmAgent { @@ -72,11 +74,27 @@ public function __construct(Config $configuration, Connector $connector, LoggerI ); } + if ($this->config->get(ConfigKey::MONITORING_ENABLED)) { + $this->warnIfConfigValueIsNotSet(ConfigKey::APPLICATION_NAME); + $this->warnIfConfigValueIsNotSet(ConfigKey::APPLICATION_KEY); + } + $this->request = new Request(); $this->ignoredEndpoints = new IgnoredEndpoints($configuration->get(ConfigKey::IGNORED_ENDPOINTS)); } + private function warnIfConfigValueIsNotSet(string $configKey) : void + { + $configValue = $this->config->get($configKey); + + if ($configValue !== null && (! is_string($configValue) || $configValue !== '')) { + return; + } + + $this->logger->warning(sprintf('Config key "%s" should be set, but it was empty', $configKey)); + } + private static function createConnectorFromConfig(Config $config) : SocketConnector { return new SocketConnector($config->get(ConfigKey::CORE_AGENT_SOCKET_PATH)); @@ -120,7 +138,8 @@ public function connect() : void $this->config->get(ConfigKey::CORE_AGENT_DIRECTORY) . '/' . $this->config->get(ConfigKey::CORE_AGENT_FULL_NAME), $this->config->get(ConfigKey::CORE_AGENT_FULL_NAME), $this->logger, - $this->config->get(ConfigKey::CORE_AGENT_DOWNLOAD_URL) + $this->config->get(ConfigKey::CORE_AGENT_DOWNLOAD_URL), + $this->config->get(ConfigKey::CORE_AGENT_PERMISSIONS) ) ); $manager->launch(); @@ -275,7 +294,8 @@ public function send() : bool } if (! $this->connector->sendCommand(new Metadata( - new DateTimeImmutable('now', new DateTimeZone('UTC')) + new DateTimeImmutable('now', new DateTimeZone('UTC')), + $this->config ))) { return false; } diff --git a/src/Config/ConfigKey.php b/src/Config/ConfigKey.php index 9fa929dd..d503a71e 100644 --- a/src/Config/ConfigKey.php +++ b/src/Config/ConfigKey.php @@ -12,6 +12,10 @@ abstract class ConfigKey public const LOG_LEVEL = 'log_level'; public const API_VERSION = 'api_version'; public const IGNORED_ENDPOINTS = 'ignore'; + public const APPLICATION_ROOT = 'application_root'; + public const SCM_SUBDIRECTORY = 'scm_subdirectory'; + public const REVISION_SHA = 'revision_sha'; + public const HOSTNAME = 'hostname'; public const CORE_AGENT_LOG_LEVEL = 'core_agent_log_level'; public const CORE_AGENT_LOG_FILE = 'core_agent_log_file'; public const CORE_AGENT_CONFIG_FILE = 'core_agent_config_file'; @@ -23,4 +27,5 @@ abstract class ConfigKey public const CORE_AGENT_DOWNLOAD_ENABLED = 'core_agent_download'; public const CORE_AGENT_VERSION = 'core_agent_version'; public const CORE_AGENT_TRIPLE = 'core_agent_triple'; + public const CORE_AGENT_PERMISSIONS = 'core_agent_permissions'; } diff --git a/src/Config/Source/DefaultSource.php b/src/Config/Source/DefaultSource.php index 39e6de5e..e98bea6d 100644 --- a/src/Config/Source/DefaultSource.php +++ b/src/Config/Source/DefaultSource.php @@ -16,7 +16,7 @@ /** @internal */ class DefaultSource { - /** @var array)> */ + /** @var array|int)> */ private $defaults; public function __construct() @@ -37,7 +37,7 @@ public function hasKey(string $key) : bool * * Only valid if the Source has previously returned "true" to `hasKey` * - * @return string|bool|array|null + * @return string|bool|array|int|null */ public function get(string $key) { @@ -49,7 +49,7 @@ public function get(string $key) * * Only valid if the Source has previously returned "true" to `hasKey` * - * @return array)> + * @return array|int)> */ private function getDefaultConfig() : array { @@ -60,6 +60,7 @@ private function getDefaultConfig() : array ConfigKey::CORE_AGENT_LAUNCH_ENABLED => true, ConfigKey::CORE_AGENT_VERSION => 'v1.2.2', ConfigKey::CORE_AGENT_DOWNLOAD_URL => 'https://s3-us-west-1.amazonaws.com/scout-public-downloads/apm_core_agent/release', + ConfigKey::CORE_AGENT_PERMISSIONS => 0777, ConfigKey::MONITORING_ENABLED => false, ConfigKey::IGNORED_ENDPOINTS => [], ConfigKey::LOG_LEVEL => 'debug', diff --git a/src/CoreAgent/Downloader.php b/src/CoreAgent/Downloader.php index 1dda9df1..b234d2a1 100644 --- a/src/CoreAgent/Downloader.php +++ b/src/CoreAgent/Downloader.php @@ -54,8 +54,16 @@ class Downloader /** @var string */ private $downloadUrl; - public function __construct(string $coreAgentDir, string $coreAgentFullName, LoggerInterface $logger, string $downloadUrl) - { + /** @var int */ + private $coreAgentPermissions; + + public function __construct( + string $coreAgentDir, + string $coreAgentFullName, + LoggerInterface $logger, + string $downloadUrl, + int $coreAgentPermissions + ) { $this->logger = $logger; $this->coreAgentDir = $coreAgentDir; @@ -77,9 +85,10 @@ public function __construct(string $coreAgentDir, string $coreAgentFullName, Log * * @link https://bugs.php.net/bug.php?id=58852 */ - $this->package_location = $coreAgentDir . '/' . str_replace('.', '_', $coreAgentFullName) . '.tgz'; - $this->download_lock_path = $coreAgentDir . '/download.lock'; - $this->downloadUrl = $downloadUrl; + $this->package_location = $coreAgentDir . '/' . str_replace('.', '_', $coreAgentFullName) . '.tgz'; + $this->download_lock_path = $coreAgentDir . '/download.lock'; + $this->downloadUrl = $downloadUrl; + $this->coreAgentPermissions = $coreAgentPermissions; } public function download() : void @@ -104,12 +113,13 @@ public function download() : void private function createCoreAgentDir() : void { try { - $permissions = 0777; // TODO: AgentContext.instance.config.core_agent_permissions() $recursive = true; $destination = $this->coreAgentDir; - if (! is_dir($destination)) { - mkdir($destination, $permissions, $recursive); + if (! is_dir($destination) + && ! mkdir($destination, $this->coreAgentPermissions, $recursive) + && ! is_dir($destination)) { + throw new RuntimeException(sprintf('Directory "%s" was not created', $destination)); } } catch (Throwable $e) { $this->logger->error('Failed to create directory: ' . $destination); diff --git a/src/Events/Metadata.php b/src/Events/Metadata.php index eefa21c7..f7044ba4 100644 --- a/src/Events/Metadata.php +++ b/src/Events/Metadata.php @@ -6,13 +6,22 @@ use DateTimeImmutable; use PackageVersions\Versions; +use Scoutapm\Config; +use Scoutapm\Config\ConfigKey; use Scoutapm\Connector\Command; use Scoutapm\Helper\Timer; use const PHP_VERSION; +use function array_key_exists; use function array_keys; use function array_map; +use function dirname; use function explode; +use function file_exists; +use function getenv; use function gethostname; +use function is_readable; +use function is_string; +use function realpath; /** * Also called AppServerLoad in other agents @@ -24,11 +33,15 @@ final class Metadata implements Command /** @var Timer */ private $timer; - public function __construct(DateTimeImmutable $now) + /** @var Config */ + private $config; + + public function __construct(DateTimeImmutable $now, Config $config) { // Construct and stop the timer to use its timestamp logic. This event // is a single point in time, not a range. - $this->timer = new Timer((float) $now->format('U.u')); + $this->timer = new Timer((float) $now->format('U.u')); + $this->config = $config; } /** @@ -44,20 +57,79 @@ private function data() : array 'framework_version' => '', 'environment' => '', 'app_server' => '', - 'hostname' => gethostname(), + 'hostname' => $this->config->get(ConfigKey::HOSTNAME) ?? gethostname(), 'database_engine' => '', 'database_adapter' => '', - 'application_name' => '', + 'application_name' => $this->config->get(ConfigKey::APPLICATION_NAME) ?? '', 'libraries' => $this->getLibraries(), 'paas' => '', - 'application_root' => '', - 'scm_subdirectory' => '', + 'application_root' => $this->applicationRoot(), + 'scm_subdirectory' => $this->scmSubdirectory(), 'git_sha' => $this->rootPackageGitSha(), ]; } + /** + * Try to locate a file or folder in any parent directory (upwards of this library itself) + */ + private function locateFileOrFolder(string $fileOrFolder) : ?string + { + // Starting 3 levels up will avoid finding scout-apm-php's own contents + $dir = dirname(__DIR__, 3); + $rootOrHome = '/'; + + while (dirname($dir) !== $dir && $dir !== $rootOrHome) { + $fileOrFolderAttempted = $dir . '/' . $fileOrFolder; + if (file_exists($fileOrFolderAttempted) && is_readable($fileOrFolderAttempted)) { + return realpath($dir); + } + $dir = dirname($dir); + } + + return null; + } + + private function applicationRoot() : string + { + $applicationRootConfiguration = $this->config->get(ConfigKey::APPLICATION_ROOT); + if (is_string($applicationRootConfiguration) && $applicationRootConfiguration !== '') { + return $applicationRootConfiguration; + } + + $composerJsonLocation = $this->locateFileOrFolder('composer.json'); + if ($composerJsonLocation !== null) { + return $composerJsonLocation; + } + + if (! array_key_exists('DOCUMENT_ROOT', $_SERVER)) { + return ''; + } + + return $_SERVER['DOCUMENT_ROOT']; + } + + private function scmSubdirectory() : string + { + $scmSubdirectoryConfiguration = $this->config->get(ConfigKey::SCM_SUBDIRECTORY); + if (is_string($scmSubdirectoryConfiguration) && $scmSubdirectoryConfiguration !== '') { + return $scmSubdirectoryConfiguration; + } + + return $this->locateFileOrFolder('.git') ?? $this->applicationRoot(); + } + private function rootPackageGitSha() : string { + $revisionShaConfiguration = $this->config->get(ConfigKey::REVISION_SHA); + if (is_string($revisionShaConfiguration) && $revisionShaConfiguration !== '') { + return $revisionShaConfiguration; + } + + $herokuSlugCommit = getenv('HEROKU_SLUG_COMMIT'); + if (is_string($herokuSlugCommit) && $herokuSlugCommit !== '') { + return $herokuSlugCommit; + } + return explode('@', Versions::getVersion(Versions::ROOT_PACKAGE_NAME))[1]; } diff --git a/tests/Unit/AgentTest.php b/tests/Unit/AgentTest.php index c3dfeac2..6a114c9f 100644 --- a/tests/Unit/AgentTest.php +++ b/tests/Unit/AgentTest.php @@ -13,11 +13,82 @@ use Scoutapm\Config\ConfigKey; use Scoutapm\Events\Span\Span; use Scoutapm\Events\Tag\TagRequest; +use function array_map; +use function count; use function end; +use function sprintf; /** @covers \Scoutapm\Agent */ final class AgentTest extends TestCase { + /** + * @return Config[][]|string[][][] + * + * @psalm-return array}> + */ + public function invalidConfigurationProvider() : array + { + return [ + 'withoutName' => [ + 'config' => Config::fromArray([ + ConfigKey::MONITORING_ENABLED => true, + ConfigKey::APPLICATION_KEY => 'abc123', + ]), + 'missingKeys' => [ + ConfigKey::APPLICATION_NAME, + ], + ], + 'withoutKey' => [ + 'config' => Config::fromArray([ + ConfigKey::MONITORING_ENABLED => true, + ConfigKey::APPLICATION_NAME => 'My Application', + ]), + 'missingKeys' => [ + ConfigKey::APPLICATION_KEY, + ], + ], + 'withoutAnything' => [ + 'config' => Config::fromArray([ConfigKey::MONITORING_ENABLED => true]), + 'missingKeys' => [ + ConfigKey::APPLICATION_NAME, + ConfigKey::APPLICATION_KEY, + ], + ], + 'withoutAnythingButMonitoringIsDisabled' => [ + 'config' => Config::fromArray([]), + 'missingKeys' => [], + ], + ]; + } + + /** + * @param string[]|array $missingKeys + * + * @dataProvider invalidConfigurationProvider + */ + public function testCreatingAgentWithoutRequiredConfigKeysLogsWarning(Config $config, array $missingKeys) : void + { + /** @var LoggerInterface&MockObject $logger */ + $logger = $this->createMock(LoggerInterface::class); + + $logger->expects(self::exactly(count($missingKeys))) + ->method('log') + ->withConsecutive(...array_map( + static function (string $missingKey) : array { + return [ + 'warning', + self::stringContains(sprintf( + 'Config key "%s" should be set, but it was empty', + $missingKey + )), + ]; + }, + $missingKeys + )); + + Agent::fromConfig($config, $logger); + } + public function testMinimumLogLevelCanBeSetOnConfigurationToSquelchNoisyLogMessages() : void { /** @var LoggerInterface&MockObject $logger */ @@ -26,11 +97,16 @@ public function testMinimumLogLevelCanBeSetOnConfigurationToSquelchNoisyLogMessa $logger->expects(self::never()) ->method('log'); - $config = new Config(); - $config->set(ConfigKey::LOG_LEVEL, LogLevel::WARNING); - $config->set(ConfigKey::MONITORING_ENABLED, 'false'); + $agent = Agent::fromConfig( + Config::fromArray([ + ConfigKey::APPLICATION_NAME => 'My Application', + ConfigKey::APPLICATION_KEY => 'abc123', + ConfigKey::LOG_LEVEL => LogLevel::WARNING, + ConfigKey::MONITORING_ENABLED => false, + ]), + $logger + ); - $agent = Agent::fromConfig($config, $logger); $agent->connect(); } @@ -47,10 +123,15 @@ public function testLogMessagesAreLoggedWhenUsingDefaultConfiguration() : void [] ); - $config = new Config(); - $config->set(ConfigKey::MONITORING_ENABLED, 'false'); + $agent = Agent::fromConfig( + Config::fromArray([ + ConfigKey::APPLICATION_NAME => 'My Application', + ConfigKey::APPLICATION_KEY => 'abc123', + ConfigKey::MONITORING_ENABLED => false, + ]), + $logger + ); - $agent = Agent::fromConfig($config, $logger); $agent->connect(); } diff --git a/tests/Unit/Events/MetadataTest.php b/tests/Unit/Events/MetadataTest.php index dfffa484..a8814b7f 100644 --- a/tests/Unit/Events/MetadataTest.php +++ b/tests/Unit/Events/MetadataTest.php @@ -9,6 +9,8 @@ use Exception; use PackageVersions\Versions; use PHPUnit\Framework\TestCase; +use Scoutapm\Config; +use Scoutapm\Config\ConfigKey; use Scoutapm\Events\Metadata; use Scoutapm\Helper\Timer; use const PHP_VERSION; @@ -18,16 +20,69 @@ use function gethostname; use function json_decode; use function json_encode; +use function putenv; +use function uniqid; /** @covers \Scoutapm\Events\Metadata */ final class MetadataTest extends TestCase { /** @throws Exception */ - public function testMetadataSerializesToJson() : void + public function testMetadataFromConfigurationSerializesToJson() : void { + $config = Config::fromArray([ + ConfigKey::APPLICATION_ROOT => '/fake/app/root', + ConfigKey::SCM_SUBDIRECTORY => '/fake/scm/subdirectory', + ConfigKey::APPLICATION_NAME => 'My amazing application', + ConfigKey::REVISION_SHA => 'abc123', + ConfigKey::HOSTNAME => 'fake-hostname.scoutapm.com', + ]); + + $time = new DateTimeImmutable('now', new DateTimeZone('UTC')); + + self::assertEquals( + [ + 'ApplicationEvent' => [ + 'timestamp' => $time->format(Timer::FORMAT_FOR_CORE_AGENT), + 'event_value' => [ + 'language' => 'php', + 'version' => PHP_VERSION, + 'server_time' => $time->format(Timer::FORMAT_FOR_CORE_AGENT), + 'framework' => 'laravel', + 'framework_version' => '', + 'environment' => '', + 'app_server' => '', + 'hostname' => 'fake-hostname.scoutapm.com', + 'database_engine' => '', + 'database_adapter' => '', + 'application_name' => 'My amazing application', + 'libraries' => array_map( + static function ($package, $version) { + return [$package, $version]; + }, + array_keys(Versions::VERSIONS), + Versions::VERSIONS + ), + 'paas' => '', + 'application_root' => '/fake/app/root', + 'scm_subdirectory' => '/fake/scm/subdirectory', + 'git_sha' => 'abc123', + ], + 'event_type' => 'scout.metadata', + 'source' => 'php', + ], + ], + json_decode(json_encode(new Metadata($time, $config)), true) + ); + } + + /** @throws Exception */ + public function testAutoDetectedMetadataSerializesToJson() : void + { + $config = Config::fromArray([]); + $time = new DateTimeImmutable('now', new DateTimeZone('UTC')); - $serialized = json_encode(new Metadata($time)); + $_SERVER['DOCUMENT_ROOT'] = '/fake/document/root'; self::assertEquals( [ @@ -53,15 +108,33 @@ static function ($package, $version) { Versions::VERSIONS ), 'paas' => '', - 'application_root' => '', - 'scm_subdirectory' => '', + 'application_root' => '/fake/document/root', + 'scm_subdirectory' => '/fake/document/root', 'git_sha' => explode('@', Versions::getVersion(Versions::ROOT_PACKAGE_NAME))[1], ], 'event_type' => 'scout.metadata', 'source' => 'php', ], ], - json_decode($serialized, true) + json_decode(json_encode(new Metadata($time, $config)), true) ); } + + /** @throws Exception */ + public function testHerokuSlugCommitOverridesTheGitSha() : void + { + $testHerokuSlugCommit = uniqid('testHerokuSlugCommit', true); + + putenv('HEROKU_SLUG_COMMIT=' . $testHerokuSlugCommit); + + self::assertSame( + $testHerokuSlugCommit, + json_decode(json_encode(new Metadata( + new DateTimeImmutable('now', new DateTimeZone('UTC')), + Config::fromArray([]) + )), true)['ApplicationEvent']['event_value']['git_sha'] + ); + + putenv('HEROKU_SLUG_COMMIT'); + } }