Skip to content

Instantly share code, notes, and snippets.

@kandy
Created April 12, 2022 15:38
Show Gist options
  • Save kandy/7d5716389463c406beb5f9f24d2de3e3 to your computer and use it in GitHub Desktop.
Save kandy/7d5716389463c406beb5f9f24d2de3e3 to your computer and use it in GitHub Desktop.
<?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