Initial profiler POC

This commit is contained in:
Martijn de Boer 2025-07-04 14:13:13 +02:00
commit 511d7be7a9

270
furnace.php Normal file
View 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();