From 051698403cc5edba6cfd9e72120729e52185930b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Sat, 9 May 2026 02:16:33 +0300 Subject: [PATCH 01/14] Add request context interface --- .../RequestContextInterface.php | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/RequestContext/RequestContextInterface.php 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 @@ + Date: Sat, 9 May 2026 01:58:59 +0300 Subject: [PATCH 02/14] Add request context value object Provide a concrete request snapshot for HTTP and CLI contexts --- src/RequestContext/RequestContext.php | 75 +++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/RequestContext/RequestContext.php diff --git a/src/RequestContext/RequestContext.php b/src/RequestContext/RequestContext.php new file mode 100644 index 0000000..c888fdf --- /dev/null +++ b/src/RequestContext/RequestContext.php @@ -0,0 +1,75 @@ +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; + } +} From fa24094c97a73a607ed975c3154254a838291f58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Sat, 9 May 2026 02:16:33 +0300 Subject: [PATCH 03/14] Add request context provider interface --- .../RequestContextProviderInterface.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/RequestContext/Provider/RequestContextProviderInterface.php 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 @@ + Date: Sat, 9 May 2026 01:57:33 +0300 Subject: [PATCH 04/14] Add default request context provider Capture HTTP and CLI request metadata from superglobals --- .../Provider/DefaultProvider.php | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/RequestContext/Provider/DefaultProvider.php 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); + } +} From 0294991d1d53799dac17f47ecc44ea3fa220d293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Sat, 9 May 2026 01:57:33 +0300 Subject: [PATCH 05/14] Add request context factory Instantiate the default provider when config omits one --- src/RequestContextFactory.php | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/RequestContextFactory.php diff --git a/src/RequestContextFactory.php b/src/RequestContextFactory.php new file mode 100644 index 0000000..c41c6ea --- /dev/null +++ b/src/RequestContextFactory.php @@ -0,0 +1,34 @@ + Date: Sat, 9 May 2026 02:34:16 +0300 Subject: [PATCH 06/14] Add request context provider hooks --- config/config.default.php | 5 +++++ src/Profiler.php | 47 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) 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/src/Profiler.php b/src/Profiler.php index f80fc53..2573de4 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?' * @@ -277,4 +289,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; + } } From 21fbf02a140800d1b2b0d18382b2f7a1a8ef2224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Sat, 9 May 2026 03:45:49 +0300 Subject: [PATCH 07/14] Use request context in profiling data --- src/Profiler.php | 20 ++++++++++++-------- src/ProfilingData.php | 27 ++++++++++++++------------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/Profiler.php b/src/Profiler.php index 2573de4..d6f78d3 100644 --- a/src/Profiler.php +++ b/src/Profiler.php @@ -111,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'); @@ -134,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; } @@ -156,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); } /** 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); From 1b3b28439b3613ebaafb66ca86054a9a5fea7a1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Sat, 9 May 2026 02:26:26 +0300 Subject: [PATCH 08/14] Update autoload for request context classes --- autoload.php | 5 +++++ 1 file changed, 5 insertions(+) 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'; From 040484b761079b3de1d10fee500faaa5820d12d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Sat, 9 May 2026 00:29:45 +0300 Subject: [PATCH 09/14] Document request context providers - Explain how long-lived runtimes should capture request data - Show the new provider hook in the example configuration --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++ examples/autoload.php | 5 +++++ 2 files changed, 56 insertions(+) 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/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. From 122942bf67664d311d527fd25b84d3af02a6d089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Sat, 9 May 2026 03:30:55 +0300 Subject: [PATCH 10/14] Add createRequestContextObject helper to TestCase --- tests/TestCase.php | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/TestCase.php b/tests/TestCase.php index cc4ad41..dc07836 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,6 +3,8 @@ namespace Xhgui\Profiler\Test; 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 +39,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); From 26eec2987af99eed483da6349a2b827e6115c120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Sat, 9 May 2026 03:33:08 +0300 Subject: [PATCH 11/14] Tests: Adapt env tests to request context Stop mutating global env and server state in profiling tests --- tests/ProfilingDataTest.php | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) 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']); } From f6445caef5fa28f731b53e7214b3a00e393e4be8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Sat, 9 May 2026 10:03:31 +0300 Subject: [PATCH 12/14] Test: Add TestProfilerStub stub --- tests/Resources/TestProfilerStub.php | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/Resources/TestProfilerStub.php 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(); + } +} From 24d12eceafe3c53a29ebd74ebe734c0cd05ef0c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Sat, 9 May 2026 10:04:27 +0300 Subject: [PATCH 13/14] Test: Add setPrivateProperty, getPrivateProperty helpers --- tests/TestCase.php | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/TestCase.php b/tests/TestCase.php index dc07836..6b0bd7a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,6 +2,7 @@ namespace Xhgui\Profiler\Test; +use ReflectionProperty; use Xhgui\Profiler\Config; use Xhgui\Profiler\RequestContext\RequestContext; use Xhgui\Profiler\RequestContext\RequestContextInterface; @@ -88,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); From d692834197f760e5fa17037f69637ab9b00382c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elan=20Ruusam=C3=A4e?= Date: Sat, 9 May 2026 09:51:56 +0300 Subject: [PATCH 14/14] Test: Add disable regression test Verify disable still clears state after the backend profiler stops --- tests/ProfilerTest.php | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/ProfilerTest.php diff --git a/tests/ProfilerTest.php b/tests/ProfilerTest.php new file mode 100644 index 0000000..f00f095 --- /dev/null +++ b/tests/ProfilerTest.php @@ -0,0 +1,34 @@ +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); + } +}