Created
April 12, 2022 15:38
-
-
Save kandy/7d5716389463c406beb5f9f24d2de3e3 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Xhgui\Profiler; | |
class UploadSaver | |
{ | |
/** @var string */ | |
private $url; | |
/** @var int */ | |
private $timeout; | |
public function __construct($url, $token, $timeout) | |
{ | |
$this->url = $url; | |
if ($token) { | |
$this->url .= '?&token=' . $token; | |
} | |
$this->timeout = $timeout; | |
} | |
public function isSupported() | |
{ | |
return $this->url && function_exists('curl_init'); | |
} | |
public function save(array $data) | |
{ | |
$json = json_encode($data); | |
$this->submit($this->url, $json); | |
return true; | |
} | |
/** | |
* @param string $url | |
* @param string $payload | |
*/ | |
private function submit($url, $payload) | |
{ | |
$ch = curl_init($url); | |
if (!$ch) { | |
throw new \RuntimeException('Failed to create cURL resource'); | |
} | |
$headers = array( | |
// Prefer to receive JSON back | |
'Accept: application/json', | |
// The sent data is JSON | |
'Content-Type: application/json', | |
); | |
$res = curl_setopt_array($ch, array( | |
CURLOPT_CUSTOMREQUEST => 'POST', | |
CURLOPT_RETURNTRANSFER => true, | |
CURLOPT_POSTFIELDS => $payload, | |
CURLOPT_FOLLOWLOCATION => 1, | |
CURLOPT_HTTPHEADER => $headers, | |
CURLOPT_TIMEOUT => $this->timeout, | |
)); | |
if (!$res) { | |
$error = curl_errno($ch) ? curl_error($ch) : ''; | |
throw new \RuntimeException('Failed to set cURL options'.($error ? ': '.$error : '')); | |
} | |
$result = curl_exec($ch); | |
if ($result === false) { | |
$error = curl_errno($ch) ? curl_error($ch) : ''; | |
throw new \RuntimeException('Failed to submit data'.($error ? ': '.$error : '')); | |
} | |
curl_close($ch); | |
$response = json_decode($result, true); | |
if (!$response) { | |
$error = json_last_error() ? (PHP_VERSION_ID >= 50500 ? json_last_error_msg() : 'Error '.json_last_error()) : ''; | |
throw new \RuntimeException('Failed to decode response'.($error ? ': '.$error : '')); | |
} | |
if (isset($response['error']) && $response['error']) { | |
$message = isset($response['message']) ? $response['message'] : 'Error in response'; | |
throw new \RuntimeException($message); | |
} | |
} | |
} | |
class XHProf | |
{ | |
const EXTENSION_NAME = 'xhprof'; | |
/** | |
* Combines flags using "bitwise-OR". | |
* | |
* Map generic Profiler\Profiler to {SPECIFIC_PROFILER_NAME_HERE} implementation | |
* | |
* @param array $flags | |
* @param array $flagMap an array with the structure [generic_flag => specific_profiler_flag], e.g. [Profiler::CPU => XHPROF_FLAGS_CPU] | |
* @return int | |
*/ | |
protected function combineFlags(array $flags, array $flagMap) | |
{ | |
$combinedFlag = 0; | |
foreach ($flags as $flag) { | |
$mappedFlag = array_key_exists($flag, $flagMap) ? $flagMap[$flag] : $flag; | |
$combinedFlag |= $mappedFlag; | |
} | |
return $combinedFlag; | |
} | |
public function isSupported() | |
{ | |
return extension_loaded(self::EXTENSION_NAME); | |
} | |
/** | |
* @see https://www.php.net/manual/en/function.xhprof-enable.php | |
*/ | |
public function enable($flags = array(), $options = array()) | |
{ | |
xhprof_enable($this->combineFlags($flags, $this->getProfileFlagMap()), $options); | |
} | |
public function disable() | |
{ | |
return xhprof_disable(); | |
} | |
/** | |
* @see https://www.php.net/manual/en/xhprof.constants.php | |
*/ | |
private function getProfileFlagMap() | |
{ | |
/* | |
* This is disabled on PHP 5.5+ as it causes a segfault | |
* | |
* @see https://github.com/perftools/xhgui-collector/commit/d1236d6422bfc42ac212befd0968036986885ccd | |
*/ | |
$noBuiltins = PHP_MAJOR_VERSION === 5 && PHP_MINOR_VERSION > 4 ? 0 : XHPROF_FLAGS_NO_BUILTINS; | |
return array( | |
Profiler::CPU => XHPROF_FLAGS_CPU, | |
Profiler::MEMORY => XHPROF_FLAGS_MEMORY, | |
Profiler::NO_BUILTINS => $noBuiltins, | |
Profiler::NO_SPANS => 0, | |
); | |
} | |
} | |
class ProfilingData | |
{ | |
/** @var array */ | |
private $includeEnv; | |
/** @var callable|null */ | |
private $simpleUrl; | |
/** @var callable|null */ | |
private $replaceUrl; | |
public function __construct(array $config = array()) | |
{ | |
$this->includeEnv = isset($config['profiler.include-env']) ? (array)$config['profiler.include-env'] : array(); | |
$this->simpleUrl = isset($config['profiler.simple_url']) ? $config['profiler.simple_url'] : null; | |
$this->replaceUrl = isset($config['profiler.replace_url']) ? $config['profiler.replace_url'] : null; | |
} | |
/** | |
* @return array | |
*/ | |
public function getProfilingData(array $profile) | |
{ | |
$url = $this->getUrl(); | |
$requestTimeFloat = explode('.', sprintf('%.6F', $_SERVER['REQUEST_TIME_FLOAT'])); | |
if (!isset($requestTimeFloat[1])) { | |
$requestTimeFloat[1] = 0; | |
} | |
$allowedServerKeys = array( | |
'DOCUMENT_ROOT', | |
'HTTPS', | |
'HTTP_HOST', | |
'HTTP_USER_AGENT', | |
'PATH_INFO', | |
'PHP_AUTH_USER', | |
'PHP_SELF', | |
'QUERY_STRING', | |
'REMOTE_ADDR', | |
'REMOTE_USER', | |
'REQUEST_METHOD', | |
'REQUEST_TIME', | |
'REQUEST_TIME_FLOAT', | |
'SERVER_ADDR', | |
'SERVER_NAME', | |
'UNIQUE_ID', | |
); | |
$serverMeta = array_intersect_key($_SERVER, array_flip($allowedServerKeys)); | |
$meta = array( | |
'url' => $url, | |
'get' => $_GET, | |
'env' => array_intersect_key(array_flip($this->includeEnv), $_ENV), | |
'SERVER' => $serverMeta, | |
'simple_url' => $this->getSimpleUrl($url), | |
'request_ts_micro' => array('sec' => $requestTimeFloat[0], 'usec' => $requestTimeFloat[1]), | |
// these are superfluous and should be dropped in the future | |
'request_ts' => array('sec' => $requestTimeFloat[0], 'usec' => 0), | |
'request_date' => date('Y-m-d', $requestTimeFloat[0]), | |
); | |
$data = array( | |
'profile' => $profile, | |
'meta' => $meta, | |
); | |
return $data; | |
} | |
/** | |
* Creates a simplified URL given a standard URL. | |
* Does the following transformations: | |
* | |
* - Remove numeric values after =. | |
* | |
* @param string $url | |
* @return string | |
*/ | |
private function getSimpleUrl($url) | |
{ | |
if (is_callable($this->simpleUrl)) { | |
return call_user_func($this->simpleUrl, $url); | |
} | |
return preg_replace('/=\d+/', '', $url); | |
} | |
/** | |
* @return string | |
*/ | |
private function getUrl() | |
{ | |
$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)); | |
} | |
if (is_callable($this->replaceUrl)) { | |
$url = call_user_func($this->replaceUrl, $url); | |
} | |
return $url; | |
} | |
} | |
class Profiler | |
{ | |
const CPU = 'PROFILER_CPU_PROFILING'; | |
const MEMORY = 'PROFILER_MEMORY_PROFILING'; | |
const NO_BUILTINS = 'NO_BUILTINS'; | |
const NO_SPANS = 'NO_SPANS'; | |
const SAVER_UPLOAD = 'upload'; | |
const SAVER_FILE = 'file'; | |
/** | |
* Profiler configuration. | |
* | |
* @var array | |
*/ | |
private $config; | |
/** | |
* @var UploadSaver | |
*/ | |
private $saveHandler; | |
/** | |
* @var ProfilerInterface | |
*/ | |
private $profiler; | |
/** | |
* Simple state variable to hold the value of 'Is the profiler running or not?' | |
* | |
* @var bool | |
*/ | |
private $running; | |
/** | |
* If true, session is closed, buffers are flushed and fcgi request is finished on shutdown handler | |
* Disable this if this conflicts with your framework. | |
* | |
* @var bool | |
*/ | |
private $flush; | |
/** | |
* Profiler constructor. | |
* | |
* @param array $config | |
*/ | |
public function __construct(array $config = []) | |
{ | |
$this->config = array_replace($this->getDefaultConfig(), $config); | |
} | |
/** | |
* Evaluate profiler.enable condition, and start profiling if that returned true. | |
*/ | |
public function start($flush = true) | |
{ | |
if (!$this->shouldRun()) { | |
return; | |
} | |
$this->enable(); | |
$this->flush = $flush; | |
// shutdown handler collects and stores the data. | |
$this->registerShutdownHandler(); | |
} | |
/** | |
* Stop profiling. Get currently collected data and save it | |
*/ | |
public function stop() | |
{ | |
$data = $this->disable(); | |
$this->save($data); | |
return $data; | |
} | |
/** | |
* Enables profiling for the current request / CLI execution | |
*/ | |
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 \RuntimeException('Unable to create profiler: No suitable profiler found'); | |
} | |
$saver = $this->getSaver(); | |
if (!$saver) { | |
throw new \RuntimeException('Unable to create saver'); | |
} | |
if ($flags === null) { | |
$flags = $this->config['profiler.flags']; | |
} | |
if ($options === null) { | |
$options = $this->config['profiler.options']; | |
} | |
$profiler->enable($flags, $options); | |
$this->running = true; | |
} | |
/** | |
* Stop profiling. Return currently collected data | |
* | |
* @return array | |
*/ | |
public function disable() | |
{ | |
if (!$this->running) { | |
return array(); | |
} | |
$profiler = $this->getProfiler(); | |
if (!$profiler) { | |
// error for unable to create profiler already thrown in enable() method | |
// but this can also happen if methods are called out of sync | |
throw new \RuntimeException('Unable to create profiler: No suitable profiler found'); | |
} | |
$profile = new ProfilingData($this->config); | |
$this->running = false; | |
return $profile->getProfilingData($profiler->disable()); | |
} | |
/** | |
* Saves collected profiling data | |
* | |
* @param array $data | |
*/ | |
public function save(array $data = array()) | |
{ | |
if (!$data) { | |
return; | |
} | |
$saver = $this->getSaver(); | |
if (!$saver) { | |
// error for unable to create saver already thrown in enable() method | |
// but this can also happen if methods are called out of sync | |
throw new \RuntimeException('Unable to create profiler: Unable to create saver'); | |
} | |
$saver->save($data); | |
} | |
/** | |
* Tells, if profiler is running or not | |
* | |
* @return bool | |
*/ | |
public function isRunning() | |
{ | |
return $this->running; | |
} | |
/** | |
* Returns value of `profiler.enable` function evaluation | |
* | |
* @return bool | |
*/ | |
private function shouldRun() | |
{ | |
$closure = $this->config['profiler.enable']; | |
return is_callable($closure) ? $closure() : false; | |
} | |
/** | |
* Calls register_shutdown_function . | |
* Registers this class' shutDown method as the shutdown handler | |
* | |
* @see Profiler::shutDown | |
*/ | |
private function registerShutdownHandler() | |
{ | |
// do not register shutdown function if the profiler isn't running | |
if (!$this->running) { | |
return; | |
} | |
register_shutdown_function(array($this, 'shutDown')); | |
} | |
/** | |
* @internal | |
*/ | |
public function shutDown() | |
{ | |
if ($this->flush) { | |
$this->flush(); | |
} | |
try { | |
$this->stop(); | |
} catch (\RuntimeException $e) { | |
return; | |
} | |
} | |
private function flush() | |
{ | |
// ignore_user_abort(true) allows your PHP script to continue executing, even if the user has terminated their request. | |
// Further Reading: http://blog.preinheimer.com/index.php?/archives/248-When-does-a-user-abort.html | |
// flush() asks PHP to send any data remaining in the output buffers. This is normally done when the script completes, but | |
// since we're delaying that a bit by dealing with the xhprof stuff, we'll do it now to avoid making the user wait. | |
ignore_user_abort(true); | |
if (function_exists('session_write_close')) { | |
session_write_close(); | |
} | |
flush(); | |
if (function_exists('fastcgi_finish_request')) { | |
fastcgi_finish_request(); | |
} | |
} | |
/** | |
* @return XHProf | |
*/ | |
private function getProfiler() | |
{ | |
if ($this->profiler === null) { | |
$this->profiler = new XHProf(); | |
} | |
return $this->profiler; | |
} | |
/** | |
* @return SaverInterface|null | |
*/ | |
private function getSaver() | |
{ | |
if ($this->saveHandler === null) { | |
$this->saveHandler = new UploadSaver( | |
getenv('XHGUI_UPLOAD_URL') ?? '', | |
getenv('XHGUI_UPLOAD_TOKEN') ?? '', | |
5); | |
} | |
return $this->saveHandler; | |
} | |
/** | |
* @return array | |
*/ | |
private function getDefaultConfig() | |
{ | |
return [ | |
'profiler.options' => [], | |
'profiler.enable' => function () { | |
return !empty($_REQUEST["XHPROF"]) || !empty(getenv('XHPROF')); | |
}, | |
'profiler.flags' => [ | |
Profiler::CPU, | |
Profiler::MEMORY, | |
Profiler::NO_BUILTINS, | |
Profiler::NO_SPANS, | |
], | |
'profiler.simple_url' => function ($url) { | |
return preg_replace('/=\d+/', '', $url); | |
}, | |
'profiler.replace_url' => function ($url) { | |
return preg_replace('/(\\?|&)?XHPROF=1/', '', $url); | |
}, | |
]; | |
} | |
} | |
try { | |
(new Profiler())->start(); | |
} catch (\Exception $e) { | |
// throw away or log error about profiling instantiation failure | |
error_log($e->getMessage()); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment