diff --git a/README.md b/README.md index 669642b..2cf28e3 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,9 @@ shutdown handler: $profiler->start(false); ``` +`start()` is the default integration path for short-lived PHP runtimes such as +FPM or mod_php. + ## Using config file You can create `config/config.php` and load config from there: @@ -103,6 +106,54 @@ $profiler_data = $profiler->disable(); $profiler->save($profiler_data); ``` +For long-lived runtimes, prefer `enable()` + `stop()` around each request so +request context is captured at request start instead of at process shutdown. + +## Request context providers + +By default, the profiler captures request context from `$_SERVER`, `$_GET`, +`$_ENV`, and the CLI `argv` fallback. Long-lived runtimes can provide their own +request-scoped snapshot through `profiler.request_context_provider`. That +snapshot is captured when profiling starts via `enable()` / `start()`, not when +profiling stops. + +Custom providers must implement +`Xhgui\Profiler\RequestContext\Provider\RequestContextProviderInterface` +and return a request-context object for the current profiling run. The request +time and server snapshot should describe the same captured request. +Custom providers are responsible for passing the request URL or CLI command +explicitly when constructing those snapshots, and for making sure the server +snapshot includes `REQUEST_TIME_FLOAT` for the captured request. +Include `REQUEST_TIME` too if you want it preserved in the saved `meta.SERVER` +payload. + +```php +use Xhgui\Profiler\RequestContext\Provider\RequestContextProviderInterface; +use Xhgui\Profiler\RequestContext\RequestContext; + +class AppRequestContextProvider implements RequestContextProviderInterface +{ + public function capture() + { + return RequestContext::fromHttp( + '/example', + array(), + array(), + array( + 'REQUEST_URI' => '/example', + 'REQUEST_METHOD' => 'GET', + 'HTTP_HOST' => 'example.test', + 'PHP_SELF' => '/index.php', + 'DOCUMENT_ROOT' => '/srv/app', + 'REQUEST_TIME_FLOAT' => 1234.56789, + ) + ); + } +} + +$config['profiler.request_context_provider'] = new AppRequestContextProvider(); +``` + ## Autoloader To be able to profile autoloader, this project provides `autoload.php` that diff --git a/autoload.php b/autoload.php index 47f4a64..0d44333 100644 --- a/autoload.php +++ b/autoload.php @@ -9,6 +9,11 @@ require_once __DIR__ . '/src/Exception/ProfilerException.php'; require_once __DIR__ . '/src/Config.php'; +require_once __DIR__ . '/src/RequestContext/RequestContextInterface.php'; +require_once __DIR__ . '/src/RequestContext/RequestContext.php'; +require_once __DIR__ . '/src/RequestContext/Provider/RequestContextProviderInterface.php'; +require_once __DIR__ . '/src/RequestContext/Provider/DefaultProvider.php'; +require_once __DIR__ . '/src/RequestContextFactory.php'; require_once __DIR__ . '/src/Profiler.php'; require_once __DIR__ . '/src/ProfilerFactory.php'; require_once __DIR__ . '/src/Profilers/AbstractProfiler.php'; diff --git a/config/config.default.php b/config/config.default.php index ba0291a..1267218 100644 --- a/config/config.default.php +++ b/config/config.default.php @@ -33,6 +33,11 @@ 'profiler.options' => array(), 'profiler.exclude-env' => array(), 'profiler.exclude-all-env' => false, + // Set this to an implementation of + // Xhgui\Profiler\RequestContext\Provider\RequestContextProviderInterface + // when integrating with long-lived runtimes that must capture + // request-scoped data without relying on mutable globals. + 'profiler.request_context_provider' => null, 'profiler.simple_url' => function ($url) { return preg_replace('/=\d+/', '', $url); }, diff --git a/examples/autoload.php b/examples/autoload.php index e689932..99f60ed 100644 --- a/examples/autoload.php +++ b/examples/autoload.php @@ -82,6 +82,11 @@ // Environment variables to exclude from profiling data 'profiler.exclude-env' => array(), 'profiler.options' => array(), + // Set this to an implementation of + // Xhgui\Profiler\RequestContext\Provider\RequestContextProviderInterface + // when integrating with long-lived runtimes that must capture + // request-scoped data without relying on mutable globals. + 'profiler.request_context_provider' => null, /** * Determine whether the profiler should run. diff --git a/src/Profiler.php b/src/Profiler.php index f80fc53..d6f78d3 100644 --- a/src/Profiler.php +++ b/src/Profiler.php @@ -3,6 +3,8 @@ namespace Xhgui\Profiler; use Xhgui\Profiler\Exception\ProfilerException; +use Xhgui\Profiler\RequestContext\Provider\RequestContextProviderInterface; +use Xhgui\Profiler\RequestContext\RequestContextInterface; use Xhgui\Profiler\Profilers\ProfilerInterface; use Xhgui\Profiler\Saver\SaverInterface; @@ -36,6 +38,16 @@ final class Profiler */ private $profiler; + /** + * @var RequestContextProviderInterface|null + */ + private $requestContextProvider; + + /** + * @var RequestContextInterface|null + */ + private $requestContext; + /** * Simple state variable to hold the value of 'Is the profiler running or not?' * @@ -99,12 +111,6 @@ public function enable($flags = null, $options = null) { $this->running = false; - // 'REQUEST_TIME_FLOAT' isn't available before 5.4.0 - // https://www.php.net/manual/en/reserved.variables.server.php - if (!isset($_SERVER['REQUEST_TIME_FLOAT'])) { - $_SERVER['REQUEST_TIME_FLOAT'] = microtime(true); - } - $profiler = $this->getProfiler(); if (!$profiler) { throw new ProfilerException('Unable to create profiler: No suitable profiler found'); @@ -122,7 +128,9 @@ public function enable($flags = null, $options = null) $options = $this->config['profiler.options']; } + $context = $this->captureRequestContext(); $profiler->enable($flags, $options); + $this->requestContext = $context; $this->running = true; } @@ -144,10 +152,18 @@ public function disable() throw new ProfilerException('Unable to create profiler: No suitable profiler found'); } - $profile = new ProfilingData($this->config); + $context = $this->requestContext; + $this->requestContext = null; $this->running = false; + $data = $profiler->disable(); + + if (!$context instanceof RequestContextInterface) { + throw new ProfilerException('Unable to disable profiler: Request context is missing'); + } + + $profile = new ProfilingData($this->config); - return $profile->getProfilingData($profiler->disable()); + return $profile->getProfilingData($data, $context); } /** @@ -277,4 +293,39 @@ private function getSaver() return $this->saveHandler ?: null; } + + /** + * @return RequestContextProviderInterface + */ + private function getRequestContextProvider() + { + if ($this->requestContextProvider === null) { + $this->requestContextProvider = RequestContextFactory::create($this->config); + } + + return $this->requestContextProvider; + } + + /** + * @return RequestContextInterface + */ + private function captureRequestContext() + { + $context = $this->getRequestContextProvider()->capture(); + + if (!$context instanceof RequestContextInterface) { + throw new ProfilerException('Request context provider must return a RequestContextInterface'); + } + + $server = $context->getServer(); + if (!is_array($server) || !array_key_exists('REQUEST_TIME_FLOAT', $server)) { + throw new ProfilerException('Request context provider must capture REQUEST_TIME_FLOAT in server data'); + } + + if (!is_numeric($server['REQUEST_TIME_FLOAT'])) { + throw new ProfilerException('Request context provider must capture a numeric REQUEST_TIME_FLOAT in server data'); + } + + return $context; + } } diff --git a/src/ProfilingData.php b/src/ProfilingData.php index 798af8f..3b2e8ce 100644 --- a/src/ProfilingData.php +++ b/src/ProfilingData.php @@ -2,6 +2,8 @@ namespace Xhgui\Profiler; +use Xhgui\Profiler\RequestContext\RequestContextInterface; + /** * @internal */ @@ -45,19 +47,21 @@ public function __construct(Config $config) } /** + * @param array $profile + * @param RequestContextInterface $context * @return array */ - public function getProfilingData(array $profile) + public function getProfilingData(array $profile, RequestContextInterface $context) { - $url = $this->getUrl(); - - list($sec, $usec) = $this->getRequestTime($_SERVER['REQUEST_TIME_FLOAT']); + $url = $this->getUrl($context); + $server = $context->getServer(); + list($sec, $usec) = $this->getRequestTime($server['REQUEST_TIME_FLOAT']); $meta = array( 'url' => $url, - 'get' => $_GET, - 'env' => $this->getEnvironment($_ENV), - 'SERVER' => $this->getServer($_SERVER), + 'get' => $context->getQuery(), + 'env' => $this->getEnvironment($context->getEnv()), + 'SERVER' => $this->getServer($server), 'simple_url' => $this->getSimpleUrl($url), 'request_ts_micro' => array('sec' => $sec, 'usec' => $usec), // these are superfluous and should be dropped in the future @@ -109,15 +113,12 @@ private function getSimpleUrl($url) } /** + * @param RequestContextInterface $context * @return string */ - private function getUrl() + private function getUrl(RequestContextInterface $context) { - $url = array_key_exists('REQUEST_URI', $_SERVER) ? $_SERVER['REQUEST_URI'] : null; - if (!$url && isset($_SERVER['argv'])) { - $cmd = basename($_SERVER['argv'][0]); - $url = $cmd . ' ' . implode(' ', array_slice($_SERVER['argv'], 1)); - } + $url = $context->getUrl(); if (is_callable($this->replaceUrl)) { $url = call_user_func($this->replaceUrl, $url); diff --git a/src/RequestContext/Provider/DefaultProvider.php b/src/RequestContext/Provider/DefaultProvider.php new file mode 100644 index 0000000..bcf2b48 --- /dev/null +++ b/src/RequestContext/Provider/DefaultProvider.php @@ -0,0 +1,60 @@ +getCommand(isset($server['argv']) ? $server['argv'] : array()), + $_ENV, + $server + ); + } + + /** + * @param array $argv + * @return string + */ + private function getCommand(array $argv) + { + if (!isset($argv[0])) { + return ''; + } + + $cmd = basename($argv[0]); + $args = array_slice($argv, 1); + + if (!$args) { + return $cmd; + } + + return $cmd . ' ' . implode(' ', $args); + } +} diff --git a/src/RequestContext/Provider/RequestContextProviderInterface.php b/src/RequestContext/Provider/RequestContextProviderInterface.php new file mode 100644 index 0000000..a613e59 --- /dev/null +++ b/src/RequestContext/Provider/RequestContextProviderInterface.php @@ -0,0 +1,18 @@ +url = (string) $url; + $this->query = $query; + $this->env = $env; + $this->server = $server; + } + + /** + * @param string $url + * @param array $get + * @param array $env + * @param array $server + * @return self + */ + public static function fromHttp($url, array $get, array $env, array $server) + { + return new self($url, $get, $env, $server); + } + + /** + * @param string $url + * @param array $env + * @param array $server + * @return self + */ + public static function fromCli($url, array $env, array $server) + { + return new self($url, array(), $env, $server); + } + + public function getUrl() + { + return $this->url; + } + + public function getQuery() + { + return $this->query; + } + + public function getEnv() + { + return $this->env; + } + + public function getServer() + { + return $this->server; + } +} diff --git a/src/RequestContext/RequestContextInterface.php b/src/RequestContext/RequestContextInterface.php new file mode 100644 index 0000000..d6c0c08 --- /dev/null +++ b/src/RequestContext/RequestContextInterface.php @@ -0,0 +1,31 @@ +setPrivateProperty($profiler, 'profiler', $backendProfiler); + $this->setPrivateProperty($profiler, 'running', true); + $this->setPrivateProperty($profiler, 'requestContext', null); + + try { + $profiler->disable(); + $this->fail('Expected missing request context to throw'); + } catch (ProfilerException $exception) { + $this->assertSame( + 'Unable to disable profiler: Request context is missing', + $exception->getMessage() + ); + } + + $this->assertFalse($profiler->isRunning()); + $this->assertNull($this->getPrivateProperty($profiler, 'requestContext')); + $this->assertSame(1, $backendProfiler->disableCalls); + } +} diff --git a/tests/ProfilingDataTest.php b/tests/ProfilingDataTest.php index a08d93e..4c1bb69 100644 --- a/tests/ProfilingDataTest.php +++ b/tests/ProfilingDataTest.php @@ -9,36 +9,30 @@ class ProfilingDataTest extends TestCase { public function testExcludeAllEnv() { - // 'REQUEST_TIME_FLOAT' isn't available before 5.4.0 - // https://www.php.net/manual/en/reserved.variables.server.php - if (!isset($_SERVER['REQUEST_TIME_FLOAT'])) { - $_SERVER['REQUEST_TIME_FLOAT'] = microtime(true); - } - - $_ENV['TEST_EXCLUDE_ENV'] = 'TEST'; - $config = new Config(array( 'profiler.exclude-all-env' => true, )); $profilingData = new ProfilingData($config); $profile = array('example' => 'data'); - $result = $profilingData->getProfilingData($profile); + $result = $profilingData->getProfilingData($profile, $this->createRequestContextObject(array( + 'env' => array('TEST_EXCLUDE_ENV' => 'TEST'), + ))); $this->assertEmpty($result['meta']['env']); } public function testNotExcludeAllEnv() { - $_ENV['TEST_EXCLUDE_ENV'] = 'TEST'; - $config = new Config(array( 'profiler.exclude-all-env' => false, )); $profilingData = new ProfilingData($config); $profile = array('example' => 'data'); - $result = $profilingData->getProfilingData($profile); + $result = $profilingData->getProfilingData($profile, $this->createRequestContextObject(array( + 'env' => array('TEST_EXCLUDE_ENV' => 'TEST'), + ))); $this->assertEquals('TEST', $result['meta']['env']['TEST_EXCLUDE_ENV']); } diff --git a/tests/Resources/TestProfilerStub.php b/tests/Resources/TestProfilerStub.php new file mode 100644 index 0000000..f565599 --- /dev/null +++ b/tests/Resources/TestProfilerStub.php @@ -0,0 +1,26 @@ +disableCalls++; + + return array(); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index cc4ad41..6b0bd7a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,7 +2,10 @@ namespace Xhgui\Profiler\Test; +use ReflectionProperty; use Xhgui\Profiler\Config; +use Xhgui\Profiler\RequestContext\RequestContext; +use Xhgui\Profiler\RequestContext\RequestContextInterface; use Xhgui\Profiler\Profilers\ProfilerInterface; use Xhgui\Profiler\Saver\SaverInterface; use Xhgui\Profiler\SaverFactory; @@ -37,6 +40,37 @@ protected function createSaver($saveHandler, array $config = array()) return $saver; } + /** + * @param array $context + * @return RequestContextInterface + */ + protected function createRequestContextObject(array $context = array()) + { + $defaults = array( + 'url' => '/test?id=42', + 'get' => array('id' => '42'), + 'env' => array(), + 'server' => array( + 'DOCUMENT_ROOT' => '/var/www', + 'PHP_SELF' => '/index.php', + 'REQUEST_METHOD' => 'GET', + 'REQUEST_TIME' => 1234, + 'REQUEST_TIME_FLOAT' => 1234.56789, + ), + ); + $context = array_replace($defaults, $context); + $context['server'] = isset($context['server']) && is_array($context['server']) + ? array_replace($defaults['server'], $context['server']) + : $defaults['server']; + + return RequestContext::fromHttp( + array_key_exists('url', $context) ? $context['url'] : null, + isset($context['get']) && is_array($context['get']) ? $context['get'] : array(), + isset($context['env']) && is_array($context['env']) ? $context['env'] : array(), + isset($context['server']) && is_array($context['server']) ? $context['server'] : array() + ); + } + protected function readJsonFile($filename) { $this->assertFileExists($filename); @@ -55,6 +89,31 @@ protected function skipIfNoXhguiCollector() } } + /** + * @param object $object + * @param string $property + * @param mixed $value + */ + protected function setPrivateProperty($object, $property, $value) + { + $reflectionProperty = new ReflectionProperty($object, $property); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($object, $value); + } + + /** + * @param object $object + * @param string $property + * @return mixed + */ + protected function getPrivateProperty($object, $property) + { + $reflectionProperty = new ReflectionProperty($object, $property); + $reflectionProperty->setAccessible(true); + + return $reflectionProperty->getValue($object); + } + protected function assertExpectedProfilingData(array $data) { $this->assertArrayHasKey('profile', $data);