<?php
/*
* This file is part of the SensioLabsProfiler SDK package.
*
* (c) SensioLabs <contact@sensiolabs.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
/**
* This is a PHP 5.2 compatible fallback implementation of the Sprofiler extension.
* The interfaces and behavior are the same, or as close as possible.
* It uses xhprof or uprofiler to gather profiling metrics and push them to SensioLabsProfiler.
* When the extension is loaded, this PHP fallback is not loaded.
*
* A general rule of design is that this fallback (as the extension) does not generate any exception
* nor any PHP notice/warning/etc. Instead, a log facility is provided where all messages shall be written.
*/
class SprofilerProbe
{
private $fileFormat = 'SensioLabs\Profiler\Probe';
private $profiler;
private $outputUrl;
private $outputTimeout;
private $outputStream;
private $logLevel = 1;
private $logUrl = 'php://stderr';
private $isEnabled = false;
private $responseLine = '';
private $challenge;
private $signature;
private $flags;
private $options = array(
'server_keys' => array(
'HTTP_HOST',
'HTTP_USER_AGENT',
'HTTPS',
'REQUEST_METHOD',
'REQUEST_URI',
'SERVER_ADDR',
'SERVER_SOFTWARE',
'_',
'argv',
),
'ignored_functions' => array(
'array_map',
'array_filter',
'array_reduce',
'array_walk',
'array_walk_recursive',
'call_user_func',
'call_user_func_array',
'call_user_method',
'call_user_method_array',
'forward_static_call',
'forward_static_call_array',
'iterator_apply',
),
);
private static $probe;
private static $profilerIsEnabled = false;
private static $defaultOutputUrl = 'unix:///var/run/sprofiler/agent.sock';
private static $urlEncMap = array(
'%21' => '!', '%22' => '"', '%23' => '#', '%24' => '$', '%27' => "'",
'%28' => '(', '%29' => ')', '%2A' => '*', '%2C' => ',', '%2F' => '/',
'%3A' => ':', '%3B' => ';', '%3C' => '<', '%3D' => '=', '%3E' => '>',
'%40' => '@', '%5B' => '[', '%5C' => '\\','%5D' => ']', '%5E' => '^',
'%60' => '`', '%7B' => '{', '%7C' => '|', '%7D' => '}', '%7E' => '~',
);
/**
* Returns a global singleton and enables it by default.
*
* Uses an HTTP header or an env vars to create this singleton on its first use:
* 1. the value of the X-SensioLabsProfiler-Query HTTP header (when applicable)
* 2. or the SPROFILER_QUERY environment var
* 3. or the empty string otherwise
* is used as the first argument of the constructor below to instantiate the main probe.
*
* Additionally, this function enables the probe, except when the just said string
* contains an auto_enable=0 URL parameter.
*
* @return self
*
* @api
*/
public static function getMainInstance()
{
if (isset($_SERVER['HTTP_X_SENSIOLABSPROFILER_QUERY'])) {
$query = $_SERVER['HTTP_X_SENSIOLABSPROFILER_QUERY'];
} elseif (isset($_SERVER['SPROFILER_QUERY'])) {
$query = $_SERVER['SPROFILER_QUERY'];
} else {
$query = '';
}
if (null !== self::$probe) {
return self::$probe;
}
if (!file_exists(substr(self::$defaultOutputUrl, 7))) {
self::$defaultOutputUrl = null;
}
$probe = new self($query);
parse_str($query, $query);
if (!isset($query['auto_enable']) || $query['auto_enable']) {
if ($probe->isVerified()) {
self::boxPostEnable($probe, $probe->enable());
$probe->debug($probe->getResponseLine());
}
}
return self::$probe = $probe;
}
/**
* Instantiate a probe object.
*
* @param string $query An URL-encoded string that configures the probe. Part of the string is signed.
* @param string $probeId An id that is given to the agent for signature impersonation.
* @param string $probeToken The token associated to $probeId.
* @param string $outputUrl The URL where profiles will be written (directory, socket or TCP destination).
*
* @api
*/
public function __construct($query, $probeId = null, $probeToken = null, $outputUrl = null)
{
$query = preg_split('/(?:^|&)signature=(.+?)(?:&|$)/', $query, 2, PREG_SPLIT_DELIM_CAPTURE);
list($this->challenge, $this->signature, $args) = $query + array(1 => '', '');
$this->signature = rawurldecode($this->signature);
parse_str($args, $args);
$query = array(
'SPROFILER_PROBE_ID' => null,
'SPROFILER_PROBE_TOKEN' => null,
'SPROFILER_OUTPUT_URL' => null,
'SPROFILER_OUTPUT_TIMEOUT' => null,
'SPROFILER_LOG_LEVEL' => null,
'SPROFILER_LOG_URL' => null,
);
foreach ($query as $k => $v) {
if (isset($_ENV[$k])) {
$query[$k] = $_ENV[$k];
} elseif (isset($_SERVER[$k])) {
$query[$k] = $_SERVER[$k];
}
}
$this->probeId = $probeId ?: $query['SPROFILER_PROBE_ID'];
$this->probeToken = $probeToken ?: $query['SPROFILER_PROBE_TOKEN'];
$this->outputUrl = $outputUrl ?: $query['SPROFILER_OUTPUT_URL'] ?: self::$defaultOutputUrl ?: ini_get('uprofiler.output_dir') ?: ini_get('xhprof.output_dir');
$this->outputTimeout = 1000000 * ($query['SPROFILER_OUTPUT_TIMEOUT'] ?: 0.25);
$this->logLevel = $query['SPROFILER_LOG_LEVEL'] ?: $this->logLevel;
$this->logUrl = $query['SPROFILER_LOG_URL'] ?: $this->logUrl;
$this->aggregSamples = isset($args['aggreg_samples']) && is_string($args['aggreg_samples']) ? max((int) $args['aggreg_samples'], 1) : 1;
if (!$this->logUrl || 'stderr' === $this->logUrl) {
$this->logUrl = 'php://stderr';
}
empty($args['flag_cpu']) or $this->flags |= UPROFILER_FLAGS_CPU;
empty($args['flag_memory']) or $this->flags |= UPROFILER_FLAGS_MEMORY;
empty($args['flag_no_builtins']) or $this->flags |= UPROFILER_FLAGS_NO_BUILTINS;
if (function_exists('uprofiler_enable')) {
$this->profiler = 'uprofiler';
} elseif (function_exists('xhprof_enable')) {
$this->profiler = 'xhprof';
}
if ($this->logLevel >= 4) {
$this->debug('New probe instanciated');
foreach ($this as $k => $v) {
if ('options' !== $k) {
if ('' !== $v = (string) $v) {
$this->debug(' '.$k.': '.$v);
}
}
}
}
}
/**
* Tells if the probe is cryptographically verified, i.e. if the signature in $query is valid.
*
* @return bool
*
* @api
*/
public function isVerified()
{
return $this->box('doVerify', false);
}
/**
* Gets the response message/status/line
*
* This lines gives details about the status of the probe. That can be:
* - an error: `SensioLabsProfiler-Error: $errNumber $urlEncodedErrorMessage`
* - or not: `SensioLabsProfiler-Response: $rfc1738EncodedMessage`
*
* @return string The response line
*
* @api
*/
public function getResponseLine()
{
return $this->responseLine;
}
/**
* Enables profiling instrumentation and data aggregation.
*
* One and only one probe can be enabled at the same time.
*
* @see getResponseLine() for error/status reporting
*
* @return bool False if enabling failed.
*
* @api
*/
public function enable()
{
return $this->box('doEnable', false,
$this->getErrorHandler('error', array(__CLASS__, 'onError'))
.$this->getErrorHandler('exception', array($this, 'onException'))
);
}
/**
* Disables profiling instrumentation and data aggregation.
*
* As a side-effect, flushes the collected profile to the output.
*
* @param bool $close Not closing allows to re-enable the probe later and aggregate data in the same profile. Closing means that a later enable() will create a new profile on the output.
*
* @return bool False if the probe was not enabled.
*
* @api
*/
public function disable($close = false)
{
return $this->box('doDisable', true, $close);
}
// XXX
// XXX - END OF PUBLIC API - XXX
// XXX
/**
* @internal
*/
private static function boxPostEnable($probe, $isEnabled) {
if ($isEnabled) {
register_shutdown_function(array($probe, 'onShutdown'));
}
$probe->box('sendHeaderLine', null);
}
/**
* @internal
*/
private static function restoreErrorHandler() {
restore_error_handler();
}
/**
* @internal
*/
public function __destruct()
{
$this->disable(true);
}
/**
* Wraps internal functions and handles any error/exception.
*
* @internal
*/
private function box($method, $returnValue)
{
set_error_handler(__CLASS__.'::onInternalError');
try {
$args = func_get_args();
unset($args[0], $args[1]);
$this->debug('Boxing '.$method);
$returnValue = call_user_func_array(array($this, $method), $args);
} catch (Exception $e) {
$this->warn(get_class($e).': '.$e->getMessage().' in '.$e->getFile().':'.$e->getLine());
restore_error_handler();
$this->profilerDisable();
$this->responseLine = 'SensioLabsProfiler-Error: 101 '.rawurlencode($e->getMessage().' in '.$e->getFile().':'.$e->getLine());
}
self::restoreErrorHandler();
return $returnValue;
}
/**
* @internal
*/
private function doVerify()
{
if (null === $this->outputStream) {
$signature = strtr($this->signature, '-_', '+/');
$signature = base64_decode($signature, true);
// XXX Crypto checks are done here in the C version.
// In the PHP version, this is delegated to the agent,
// no verification occurs when the output is a directory.
if ($signature) {
$this->debug('Signature looks OK');
$this->openOutput();
} else {
$this->info('Invalid signature');
}
}
return (bool) $this->outputStream;
}
/**
* @internal
*/
private function doEnable($extra)
{
if ($this->isEnabled) {
return true;
}
if (self::$profilerIsEnabled) {
$this->responseLine = "SensioLabsProfiler-Error: 101 An other probe is already profiling";
return false;
}
if ($this->doVerify()) {
$this->writeChunkProlog($extra);
$this->profilerEnable();
$this->isEnabled = true;
}
return $this->isEnabled;
}
/**
* @internal
*/
private function doDisable($close = false)
{
if (!$this->isEnabled) {
return false;
}
$this->isEnabled = false;
$this->profilerWrite(true);
if ($close && $this->outputStream) {
$this->debug('Closing output stream');
flock($this->outputStream, LOCK_UN);
fclose($this->outputStream);
$this->outputStream = null;
}
return true;
}
/**
* @internal
*/
private function sendHeaderLine()
{
header('X-'.$this->getResponseLine());
}
/**
* @internal
*/
private function openOutput()
{
if (null !== $this->outputStream) {
return $this->outputStream;
}
$this->outputStream = false;
$url = $this->outputUrl;
if (($i = strpos($url, '://')) && in_array(substr($url, 0, $i), stream_get_transports(), true)) {
$this->debug('Lets open '.$url);
if ($h = stream_socket_client($url, $errno, $errstr, 0, STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT)) {
stream_set_timeout($h, 0, $this->outputTimeout);
stream_set_write_buffer($h, 0);
$i = array(null, array($h), null);
if (stream_select($i[0], $i[1], $i[2], 0, $this->outputTimeout)) {
$this->writeHelloProlog($h);
$response = rtrim(fgets($h, 4096));
while ('' !== rtrim(fgets($h, 4096))) {
// No-op
}
if (0 !== strpos($response, 'SensioLabsProfiler-Response: ')) {
fclose($h);
$h = false;
if (0 !== strpos($response, 'SensioLabsProfiler-Error: ')) {
$response = "SensioLabsProfiler-Error: 102 Invalid agent response ($response)";
}
}
} else {
fclose($h);
$h = false;
$response = "SensioLabsProfiler-Error: 101 Agent connection timeout";
}
} else {
$response = "SensioLabsProfiler-Error: 101 $errstr ($errno)";
}
} else {
$i = sprintf('%019.6F', microtime(true)).'-';
$i .= substr(str_replace(array('+', '/'), array('', ''), base64_encode(md5(mt_rand(), true))), 0, 6);
$url .= '/'.$i.'.log';
$this->debug('Lets open '.$url);
$h = fopen($url, 'wb');
if (stream_is_local($h)) {
flock($h, LOCK_SH); // This shared lock allows readers to wait for the end of the stream
stream_set_write_buffer($h, 0);
} else {
$this->writeHelloProlog($h);
}
$response = "SensioLabsProfiler-Response: continue=false";
}
$this->responseLine = $response;
$this->outputStream = $h;
if ($h) {
$this->writeMainProlog();
}
return $h;
}
/**
* @internal
*/
private function writeHelloProlog($h)
{
$hello = '';
if ($this->probeId && $this->probeToken) {
$line = $this->probeId.':'.$this->probeToken;
if (strlen($line) !== strcspn($line, "\r\n") || 1 < substr_count($line, ':')) {
$this->warn('Invalid probe_id/probe_token');
} else {
$hello .= 'SensioLabsProfiler-Auth: '.$line."\n";
}
}
$line = 'signature='.$this->signature.'&aggreg_samples='.$this->aggregSamples."\n";
isset($this->challenge[0]) and $line = $this->challenge.'&'.$line;
$hello .= 'SensioLabsProfiler-Query: '.$line."\n";
self::fwrite($h, $hello);
}
/**
* @internal
*/
private function writeMainProlog()
{
// Loaded extensions list helps understanding runtime behavior
$extensions = array();
foreach (get_loaded_extensions() as $e) {
$extensions[$e] = phpversion($e);
}
// Keep only keys from $_COOKIE
$cookies = array_keys($_COOKIE);
// Keep selected keys from $_SERVER
$servers = array();
foreach ($this->options['server_keys'] as $e) {
if (isset($_SERVER[$e])) {
$servers[$e] = $_SERVER[$e];
}
}
// Get request's URI
if (isset($_SERVER['HTTP_X_ORIGINAL_URL'])) {
$e = $_SERVER['HTTP_X_ORIGINAL_URL'];
} elseif (isset($_SERVER['HTTP_X_REWRITE_URL'])) {
$e = $_SERVER['HTTP_X_REWRITE_URL'];
} elseif (!empty($_SERVER['IIS_WasUrlRewritten']) && !empty($_SERVER['UNENCODED_URL'])) {
$e = $_SERVER['UNENCODED_URL'];
} elseif (isset($_SERVER['REQUEST_URI'][0])) {
$e = $_SERVER['REQUEST_URI'];
if ('/' !== $e[0]) {
$e = preg_replace('#^https?://[^/]+#', '', $e);
}
} elseif (isset($_SERVER['ORIG_PATH_INFO'])) {
$e = $_SERVER['ORIG_PATH_INFO'];
if (!empty($_SERVER['QUERY_STRING'])) {
$e .= '?'.$_SERVER['QUERY_STRING'];
}
} else {
$e = '';
}
if (!empty($e)) {
$servers['REQUEST_URI'] = $e;
}
self::fwrite($this->outputStream, 'file-format: '.$this->fileFormat."\n"
.'php-os: '.PHP_OS."\n"
.'php-sapi: '.PHP_SAPI."\n"
.'php-version: '.PHP_VERSION_ID."\n"
.'php-extensions: '.strtr(http_build_query($extensions, '', '&'), self::$urlEncMap)."\n"
.'_COOKIE: '.strtr(http_build_query($cookies, '', '&'), self::$urlEncMap)."\n"
.'_SERVER: '.strtr(http_build_query($servers, '', '&'), self::$urlEncMap)."\n"
."\nmain()//1 0 0 0 0\n\n"
);
$this->debug('Main prolog pushed');
}
/**
* @internal
*/
private function writeChunkProlog($extra)
{
$data = 'request-mu: '.memory_get_usage(true)."\n"
.'request-pmu: '.memory_get_peak_usage(true)."\n"
.'request-start: '.microtime(true)."\n"
.$extra;
if (function_exists('sys_getloadavg')) {
$data .= 'sys-load-avg: '.implode(' ', sys_getloadavg())."\n";
}
self::fwrite($this->outputStream, $data);
}
/**
* @internal
*/
private function getErrorHandler($type, $default = 'var_dump')
{
$s = "set_{$type}_handler";
if ($h = $s($default)) {
$s = "restore_{$type}_handler";
$s();
} elseif ('var_dump' !== $default) {
$h = $default;
}
$type .= '-handler: ';
if ($h instanceof Closure) {
$h = new ReflectionFunction($h);
if (PHP_VERSION_ID >= 50400 && $s = $h->getClosureScopeClass()) {
$h = $s->name.'::{closure}/'.$h->getStartLine().'-'.$h->getEndLine();
} else {
$h = $h->name.'::'.implode('/', array_slice(explode('/', $h->getFileName()), -2)).'/'.$h->getStartLine().'-'.$h->getEndLine();
}
} else {
if (!is_array($h)) {
if (is_object($h)) {
$h = array($h, '__invoke');
} else {
$h = explode('::', $h, 2);
}
}
if (isset($h[1])) {
$h = new ReflectionMethod($h[0], $h[1]);
$h = $h->getDeclaringClass()->name.'::'.$h->name;
} else {
$h = $h[0];
}
}
$type .= $h;
$this->debug('Extracted '.$type);
return $type."\n";
}
/**
* @internal
*/
private function profilerEnable()
{
self::$profilerIsEnabled = true;
if (is_string($this->profiler)) {
$p = $this->profiler.'_enable';
$this->debug($p);
$p($this->flags, $this->options);
} else {
$this->info('No profiler to enable');
}
}
/**
* @internal
*/
private function profilerDisable()
{
self::$profilerIsEnabled = false;
if (is_string($this->profiler)) {
$p = $this->profiler.'_disable';
$this->debug($p);
return $p();
} elseif (is_array($this->profiler)) {
$this->debug('data array profiler_disable');
return $this->profiler;
} else {
$this->info('No profiler to disable');
return array();
}
}
/**
* @internal
*/
private function profilerWrite($disable, $chunk = '')
{
$data = $this->profilerDisable();
$chunk .= "request-end: ".microtime(true)
."\nrequest-mu: ".memory_get_usage(true)
."\nrequest-pmu: ".memory_get_peak_usage(true)
."\n\n";
$this->debug('Pushing '.count($data).' call pairs');
if (!$disable) {
$this->profilerEnable();
}
$h = $this->outputStream;
$i = 50; // 50 ~= 4Ko chunks
// Speed optimized paths
if (!$data) {
// No-op
} elseif ((UPROFILER_FLAGS_CPU & $this->flags) && (UPROFILER_FLAGS_MEMORY & $this->flags)) {
foreach ($data as $k => $v) {
$chunk .= "{$k}//{$v['ct']} {$v['wt']} {$v['cpu']} {$v['mu']} {$v['pmu']}\n";
if (0 === --$i) {
self::fwrite($h, $chunk);
$chunk = '';
$i = 50;
}
}
} elseif (UPROFILER_FLAGS_MEMORY & $this->flags) {
foreach ($data as $k => $v) {
$chunk .= "{$k}//{$v['ct']} {$v['wt']} 0 {$v['mu']} {$v['pmu']}\n";
if (0 === --$i) {
self::fwrite($h, $chunk);
$chunk = '';
$i = 50;
}
}
} elseif (UPROFILER_FLAGS_CPU & $this->flags) {
foreach ($data as $k => $v) {
$chunk .= "{$k}//{$v['ct']} {$v['wt']} {$v['cpu']} 0 0\n";
if (0 === --$i) {
self::fwrite($h, $chunk);
$chunk = '';
$i = 50;
}
}
} else {
foreach ($data as $k => $v) {
$chunk .= "{$k}//{$v['ct']} {$v['wt']} 0 0 0\n";
if (0 === --$i) {
self::fwrite($h, $chunk);
$chunk = '';
$i = 50;
}
}
}
if (isset($data['main()'])) {
$chunk .= "main()//-{$data['main()']['ct']} 0 0 0 0\n";
}
$chunk .= "\n";
return self::fwrite($h, $chunk);
}
/**
* @internal
*/
private static function fwrite($stream, $data)
{
$len = strlen($data);
$written = fwrite($stream, $data);
if (false !== $written) {
while ($written < $len) {
fflush($stream);
$w = fwrite($stream, substr($data, $written));
$written += $w ?: $len + 1;
}
if ($written === $len) {
return true;
}
}
}
/**
* @internal
*/
public static function onInternalError($type, $message, $file, $line)
{
throw new ErrorException($message, 0, $type, $file, $line);
}
/**
* @internal
*/
public static function onError()
{
return false; // Delegate error handling to the internal handler, but adds a line in profiler's data
}
/**
* @internal
*/
public function onException($e)
{
// Rethrow only, but adds a line in profiler's data
$this->box('profilerWrite', null, true); // Prevents a crash with XHProf
throw $e;
}
/**
* @internal
*/
public function onShutdown()
{
$this->box('doShutdown', null,
$this->getErrorHandler('error')
.$this->getErrorHandler('exception')
);
}
/**
* @internal
*/
private function doShutdown($extra)
{
// Get and write data now so that any later fatal error
// does not prevent collecting what we already have.
if (!$this->isEnabled) {
return;
}
$e = error_get_last();
if (function_exists('http_response_code')) {
$extra .= 'response-code: '.http_response_code()."\n";
}
if (isset($e['type'])) {
switch ($e['type']) {
case E_ERROR:
case E_PARSE:
case E_USER_ERROR:
case E_CORE_ERROR:
case E_COMPILE_ERROR:
case E_RECOVERABLE_ERROR:
$h = explode("\r", $e['message'], 2);
$h = explode("\n", $h[0], 2);
$h[1] = " in {$e['file']}:{$e['line']}";
$h[0] = str_replace($h[1], '', $h[0]);
$h = "fatal-error: {$h[0]}{$h[1]}\n";
$this->info('Got '.$h);
self::fwrite($this->outputStream, $h);
break;
}
}
$this->profilerWrite(false, $extra);
}
/**
* @internal
*/
private function warn($msg)
{
if ($this->logLevel >= 2) {
file_put_contents($this->logUrl, 'WARN: '.$msg."\n", FILE_APPEND);
}
}
/**
* @internal
*/
private function info($msg)
{
if ($this->logLevel >= 3) {
file_put_contents($this->logUrl, 'Info: '.$msg."\n", FILE_APPEND);
}
}
/**
* @internal
*/
private function debug($msg)
{
if ($this->logLevel >= 4) {
file_put_contents($this->logUrl, 'dbug: '.$msg."\n", FILE_APPEND);
}
}
}
preferences:
38.67 ms | 402 KiB | 5 Q