Initial profiler POC
This commit is contained in:
commit
511d7be7a9
1 changed files with 270 additions and 0 deletions
270
furnace.php
Normal file
270
furnace.php
Normal file
|
@ -0,0 +1,270 @@
|
|||
<?php
|
||||
/**
|
||||
* PHP-Furnace: OOP PHP-FPM Profiler + SSE Streamer with HTML Viewer
|
||||
* PHP 8.1+ required. No dependencies.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
set_time_limit(0);
|
||||
ob_implicit_flush(true);
|
||||
|
||||
// ────── Configuration ──────
|
||||
const LOG_DIR = '/tmp/php-furnace';
|
||||
const STREAM_FILE = LOG_DIR . '/furnace-stream.log';
|
||||
const PORT = 8123;
|
||||
const INTERVAL = 1;
|
||||
const MAX_FRAMES = 10;
|
||||
const PROC_NAME = 'php-fpm';
|
||||
|
||||
@mkdir(LOG_DIR, 0777, true);
|
||||
|
||||
// ────── Core Classes ──────
|
||||
class Profiler {
|
||||
private array $lastCpuTimes = [];
|
||||
private array $callCounts = [];
|
||||
private int $lastTimestamp;
|
||||
|
||||
public function __construct(private int $interval) {
|
||||
$this->lastTimestamp = time();
|
||||
}
|
||||
|
||||
public function run(): void {
|
||||
Logger::debug("Profiler started");
|
||||
echo "\n[Profiler] Started at " . date('c') . "\n";
|
||||
while (true) {
|
||||
$now = time();
|
||||
$delta = $now - $this->lastTimestamp;
|
||||
$this->lastTimestamp = $now;
|
||||
|
||||
$pids = ProcessFinder::findPhpFpmPids(PROC_NAME);
|
||||
foreach (/*array_slice($pids, 0, 5)*/$pids as $pid) {
|
||||
Logger::debug("Profiling PID {$pid}");
|
||||
$data = $this->collectStats($pid, $delta);
|
||||
if ($data) {
|
||||
Logger::log($data);
|
||||
}
|
||||
}
|
||||
if (empty($pids)) {
|
||||
Logger::debug("No PHP-FPM worker processes found");
|
||||
}
|
||||
sleep($this->interval);
|
||||
}
|
||||
}
|
||||
|
||||
private function collectStats(int $pid, int $delta): ?array {
|
||||
$stat = @file_get_contents("/proc/{$pid}/stat");
|
||||
$status = @file_get_contents("/proc/{$pid}/status");
|
||||
$environ = @file_get_contents("/proc/{$pid}/environ");
|
||||
if (!$stat || !$status) return null;
|
||||
|
||||
$fields = explode(' ', $stat);
|
||||
$utime = (int)$fields[13];
|
||||
$stime = (int)$fields[14];
|
||||
$totalCpu = $utime + $stime;
|
||||
$cpuDelta = isset($this->lastCpuTimes[$pid]) ? $totalCpu - $this->lastCpuTimes[$pid] : 0;
|
||||
$this->lastCpuTimes[$pid] = $totalCpu;
|
||||
|
||||
$memory = (int)ProcessParser::parseKeyValue($status, 'VmRSS');
|
||||
$threads = (int)ProcessParser::parseKeyValue($status, 'Threads');
|
||||
$fdCount = count(glob("/proc/{$pid}/fd/*"));
|
||||
$uri = ProcessParser::parseEnv($environ, 'REQUEST_URI');
|
||||
$stack = GDB::getStack($pid);
|
||||
|
||||
$this->callCounts[$pid] = ($this->callCounts[$pid] ?? 0) + 1;
|
||||
|
||||
return [
|
||||
'time' => time(),
|
||||
'delta' => $delta,
|
||||
'pid' => $pid,
|
||||
'calls' => $this->callCounts[$pid],
|
||||
'cpu_time_ticks' => $totalCpu,
|
||||
'cpu_delta' => $cpuDelta,
|
||||
'memory_kb' => $memory,
|
||||
'threads' => $threads,
|
||||
'fds' => $fdCount,
|
||||
'request_uri' => $uri,
|
||||
'stack' => $stack,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class Logger {
|
||||
public static function log(array $data): void {
|
||||
$json = json_encode($data, JSON_UNESCAPED_SLASHES);
|
||||
file_put_contents(STREAM_FILE, "data: $json\n\n", FILE_APPEND);
|
||||
//echo "[Log] " . date('c') . " - PID {$data['pid']} - Calls: {$data['calls']} - Memory: {$data['memory_kb']} KB - CPU Δ: {$data['cpu_delta']} - FDs: {$data['fds']}, Threads: {$data['threads']}\n";
|
||||
}
|
||||
|
||||
public static function debug(string $msg): void {
|
||||
$json = json_encode(['debug' => $msg, 'time' => time()]);
|
||||
file_put_contents(STREAM_FILE, "data: $json\n\n", FILE_APPEND);
|
||||
//echo "[Debug] " . date('c') . " - $msg\n";
|
||||
}
|
||||
}
|
||||
|
||||
class GDB {
|
||||
public static function getStack(int $pid): array {
|
||||
$cmdFile = tempnam(sys_get_temp_dir(), 'gdb');
|
||||
file_put_contents($cmdFile, "set pagination off\nthread apply all bt " . MAX_FRAMES . "\nquit\n");
|
||||
|
||||
$output = [];
|
||||
$cmd = "timeout 5s sudo gdb -q -p $pid -x $cmdFile 2>&1";
|
||||
exec($cmd, $output, $code);
|
||||
unlink($cmdFile);
|
||||
|
||||
if ($code !== 0) {
|
||||
Logger::debug("GDB failed for PID $pid with exit code $code");
|
||||
return ["[gdb failed or timed out]"];
|
||||
}
|
||||
|
||||
return array_filter($output, fn($l) => str_starts_with(trim($l), '#'));
|
||||
}
|
||||
}
|
||||
|
||||
class ProcessFinder
|
||||
{
|
||||
public static function findPhpFpmPids(string $name): array
|
||||
{
|
||||
$pids = [];
|
||||
foreach (glob('/proc/[0-9]*/status') as $statusFile) {
|
||||
$pid = (int)basename(dirname($statusFile));
|
||||
$status = @file_get_contents($statusFile);
|
||||
if ($status && preg_match('/^Name:\s+(.*)$/m', $status, $match)) {
|
||||
$procName = trim($match[1]);
|
||||
if (stripos($procName, 'php-fpm') !== false) {
|
||||
$pids[] = $pid;
|
||||
//Logger::debug("Matched PHP-FPM process: PID {$pid}");
|
||||
}
|
||||
}
|
||||
}
|
||||
return $pids;
|
||||
}
|
||||
}
|
||||
|
||||
class ProcessParser {
|
||||
public static function parseKeyValue(string $text, string $key): ?string {
|
||||
if (preg_match("/^$key:\\s+(\\d+)/m", $text, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function parseEnv(string $env, string $key): ?string {
|
||||
if (preg_match("/$key=([^\\x00]+)/", $env, $m)) {
|
||||
return urldecode($m[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class StreamServer {
|
||||
public static function start(): void {
|
||||
$server = stream_socket_server("tcp://0.0.0.0:" . PORT, $errno, $errstr);
|
||||
if (!$server) die("Server error: $errstr\n");
|
||||
echo "[StreamServer] Listening on http://localhost:" . PORT . "\n";
|
||||
|
||||
while ($conn = @stream_socket_accept($server)) {
|
||||
stream_set_blocking($conn, false);
|
||||
$request = fread($conn, 1024);
|
||||
if (preg_match('#GET /stream#', $request)) {
|
||||
fwrite($conn, "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\n\r\n");
|
||||
fwrite($conn, ": stream initialized\n\n");
|
||||
fflush($conn);
|
||||
|
||||
$pos = 0;
|
||||
$lastPing = time();
|
||||
while (!feof($conn)) {
|
||||
clearstatcache();
|
||||
$len = file_exists(STREAM_FILE) ? filesize(STREAM_FILE) : 0;
|
||||
if ($len > $pos) {
|
||||
$f = fopen(STREAM_FILE, 'r');
|
||||
fseek($f, $pos);
|
||||
while (!feof($f)) {
|
||||
fwrite($conn, fgets($f));
|
||||
}
|
||||
$pos = ftell($f);
|
||||
fclose($f);
|
||||
}
|
||||
|
||||
if ((time() - $lastPing) >= 10) {
|
||||
fwrite($conn, ": keep-alive\n\n");
|
||||
fflush($conn);
|
||||
$lastPing = time();
|
||||
}
|
||||
|
||||
usleep(250_000);
|
||||
}
|
||||
fclose($conn);
|
||||
} else {
|
||||
fwrite($conn, "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n");
|
||||
fwrite($conn, <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>PHP-Furnace Viewer</title>
|
||||
<style>
|
||||
body { font-family: monospace; background: #111; color: #eee; padding: 1em; }
|
||||
pre { background: #222; padding: 1em; overflow-x: auto; }
|
||||
.log-entry { border-bottom: 1px solid #333; margin-bottom: 1em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔥 PHP-Furnace Live Stream</h1>
|
||||
<div id="log"><em>Waiting for profiler data...</em></div>
|
||||
<script>
|
||||
const log = document.getElementById('log');
|
||||
const stream = new EventSource('/stream');
|
||||
stream.onmessage = e => {
|
||||
const data = JSON.parse(e.data);
|
||||
const div = document.createElement('div');
|
||||
div.className = 'log-entry';
|
||||
|
||||
if (data.debug) {
|
||||
div.innerHTML = `<pre>[debug] \${new Date(data.time * 1000).toLocaleTimeString()} - \${data.debug}</pre>`;
|
||||
} else {
|
||||
div.innerHTML = `<pre>
|
||||
Time: \${new Date(data.time * 1000).toLocaleTimeString()}
|
||||
PID: \${data.pid}
|
||||
Calls: \${data.calls}
|
||||
Memory: \${data.memory_kb} KB
|
||||
CPU Δ: \${data.cpu_delta}
|
||||
FDs: \${data.fds}, Threads: \${data.threads}
|
||||
URI: \${data.request_uri || '-'}
|
||||
Stack:
|
||||
\${(data.stack || []).join('
|
||||
')}
|
||||
</pre>`;
|
||||
}
|
||||
|
||||
if (log.querySelector('em')) log.innerHTML = '';
|
||||
log.prepend(div);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
HTML
|
||||
);
|
||||
fclose($conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ────── Fork to Run Stream Server in Background ──────
|
||||
if (function_exists('pcntl_fork')) {
|
||||
$pid = pcntl_fork();
|
||||
if ($pid === -1) {
|
||||
die("Failed to fork\n");
|
||||
} elseif ($pid === 0) {
|
||||
// Child process: Stream server
|
||||
StreamServer::start();
|
||||
exit(0);
|
||||
}
|
||||
} else {
|
||||
die("PCNTL extension is required to run this script.\n");
|
||||
}
|
||||
|
||||
// ────── Start Profiler ──────
|
||||
(new Profiler(INTERVAL))->run();
|
Loading…
Add table
Reference in a new issue