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