From 222c1dd02273d2a6c5bfaddba9bb3b90e278e115 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:12:47 +0000 Subject: [PATCH 01/14] feat: add Excimer auto-sampling profiler with flamegraph dashboard Agent-Logs-Url: https://github.com/PHPDevsr/php-profiler/sessions/071177e1-7af4-4040-92bc-ad0a180b3efb Co-authored-by: ddevsr <97607754+ddevsr@users.noreply.github.com> --- README.md | 174 +++++- dashboard/index.php | 979 ++++++++++++++++++++++++++++++ phpstan.neon.dist | 4 + profiler.php | 118 ++++ src/Profiler.php | 95 ++- src/Storage/FileStorage.php | 205 +++++++ stubs/ExcimerLog.php | 49 ++ stubs/ExcimerLogEntry.php | 29 + stubs/ExcimerProfiler.php | 57 ++ tests/ProfilerTest.php | 33 + tests/Storage/FileStorageTest.php | 197 ++++++ 11 files changed, 1934 insertions(+), 6 deletions(-) create mode 100644 dashboard/index.php create mode 100644 profiler.php create mode 100644 src/Storage/FileStorage.php create mode 100644 stubs/ExcimerLog.php create mode 100644 stubs/ExcimerLogEntry.php create mode 100644 stubs/ExcimerProfiler.php create mode 100644 tests/Storage/FileStorageTest.php diff --git a/README.md b/README.md index e97e8cc..e52e0aa 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,174 @@ # php-profiler -PHP Profiling with Excimer + +PHP Sampling Profiler using the [Excimer](https://www.mediawiki.org/wiki/Excimer) extension, with automatic per-request collection and a built-in flamegraph dashboard. + +--- + +## Features + +- **Zero-code instrumentation** – add one line to `php.ini` and every request is profiled automatically. +- **Flamegraph dashboard** – interactive SVG flamegraph with zoom, tooltips, and frame highlighting. +- **Endpoint ranking** – requests grouped by URI path, sorted by total sample count. +- **Merged view** – all requests for the same endpoint are merged into a single flamegraph. +- **Export JSON** – download the merged folded-stacks profile for offline analysis. +- **Automatic cleanup** – keeps the newest 10 000 profiles; older files are pruned on each request. + +--- + +## Requirements + +| Requirement | Version | +|---|---| +| PHP | ≥ 8.3 | +| [ext-excimer](https://pecl.php.net/package/excimer) | any | + +--- + +## Installation + +```bash +composer require phpdevsr/php-profiler +``` + +Or clone / install directly to `/opt/php-profiler`: + +```bash +git clone https://github.com/PHPDevsr/php-profiler /opt/php-profiler +``` + +--- + +## Quick-start + +### 1 – Enable auto-profiling in `php.ini` + +```ini +auto_prepend_file = /opt/php-profiler/profiler.php +``` + +Optionally override the data directory: + +```ini +; defaults to /opt/php-profiler/data +auto_prepend_file = /opt/php-profiler/profiler.php +``` + +Or via an environment variable: + +```bash +PHP_PROFILER_DATA_DIR=/var/lib/php-profiler/data +``` + +### 2 – Expose the dashboard (Nginx example) + +```nginx +# Serve the dashboard at /profiler/ +location /profiler/ { + alias /opt/php-profiler/dashboard/; + + # Restrict to localhost only + allow 127.0.0.1; + deny all; + + index index.php; + try_files $uri $uri/ /profiler/index.php?$query_string; + + location ~ \.php$ { + fastcgi_pass unix:/run/php/php8.3-fpm.sock; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $request_filename; + } +} +``` + +Apache example: + +```apache +Alias /profiler /opt/php-profiler/dashboard + + + Options -Indexes + AllowOverride None + Require ip 127.0.0.1 + DirectoryIndex index.php + +``` + +### 3 – Open the dashboard + +``` +http://127.0.0.1/profiler/ +``` + +Make a few HTTP requests to your application, then refresh the dashboard to see endpoint rankings and flamegraphs. + +--- + +## Library API + +You can also use the `Profiler` class directly in your code: + +```php +use PHPDevsr\Profiler\Profiler; + +$profiler = new Profiler(period: 0.01); // 10 ms sampling interval + +$profiler->start(); +// … code to profile … +$profiler->stop(); + +// Raw folded-stacks string (Excimer format, compatible with flamegraph tools) +$folded = $profiler->getFoldedStacks(); + +// Parsed log: array of ['stack' => string[], 'count' => int] +$log = $profiler->getLog(); +``` + +### FileStorage + +```php +use PHPDevsr\Profiler\Storage\FileStorage; + +$storage = new FileStorage('/var/lib/php-profiler/data'); + +$storage->save([ + 'id' => uniqid('', true), + 'timestamp' => microtime(true), + 'endpoint' => '/api/users', + 'method' => 'GET', + 'duration_ms' => 45.2, + 'sample_count' => 12, + 'folded_stacks' => $profiler->getFoldedStacks(), +]); + +// Endpoint statistics (sorted by total samples, descending) +$stats = $storage->getEndpointStats(); + +// Profiles for one endpoint +$profiles = $storage->findByEndpoint('/api/users'); + +// Delete oldest files when total exceeds $maxFiles +$storage->cleanup(maxFiles: 10_000); +``` + +--- + +## Development + +```bash +# Run tests +composer test + +# Static analysis +composer phpstan + +# Rector dry-run +composer rector +``` + +--- + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/dashboard/index.php b/dashboard/index.php new file mode 100644 index 0000000..cfc32ef --- /dev/null +++ b/dashboard/index.php @@ -0,0 +1,979 @@ + + * Require ip 127.0.0.1 + * + */ + +// ── Configuration ───────────────────────────────────────────────────────────── + +$dataDir = (string) (getenv('PHP_PROFILER_DATA_DIR') ?: (dirname(__DIR__) . '/data')); + +// ── Helper functions ────────────────────────────────────────────────────────── + +/** + * Read all profile JSON files and return them as an array. + * + * @return array> + */ +function readAllProfiles(string $dataDir): array +{ + $files = glob($dataDir . '/*.json'); + $profiles = []; + + if ($files === false) { + return $profiles; + } + + foreach ($files as $file) { + $raw = file_get_contents($file); + + if ($raw === false) { + continue; + } + + /** @var array|null $data */ + $data = json_decode($raw, true); + + if (is_array($data)) { + $profiles[] = $data; + } + } + + return $profiles; +} + +/** + * Aggregate profiles by endpoint and return stats sorted by total samples (desc). + * + * @param array> $profiles + * @return array> + */ +function buildEndpointStats(array $profiles): array +{ + /** @var array> $stats */ + $stats = []; + + foreach ($profiles as $p) { + $ep = (string) ($p['endpoint'] ?? ''); + + if (! isset($stats[$ep])) { + $stats[$ep] = [ + 'endpoint' => $ep, + 'request_count' => 0, + 'total_samples' => 0, + 'total_duration_ms' => 0.0, + 'avg_duration_ms' => 0.0, + 'last_seen' => 0.0, + ]; + } + + $stats[$ep]['request_count']++; + $stats[$ep]['total_samples'] += (int) ($p['sample_count'] ?? 0); + $stats[$ep]['total_duration_ms'] += (float) ($p['duration_ms'] ?? 0.0); + + $ts = (float) ($p['timestamp'] ?? 0.0); + + if ($ts > (float) $stats[$ep]['last_seen']) { + $stats[$ep]['last_seen'] = $ts; + } + } + + foreach ($stats as &$s) { + $count = (int) $s['request_count']; + $s['avg_duration_ms'] = $count > 0 + ? round((float) $s['total_duration_ms'] / $count, 2) + : 0.0; + } + + unset($s); + + usort( + $stats, + static fn (array $a, array $b): int => (int) $b['total_samples'] - (int) $a['total_samples'] + ); + + return array_values($stats); +} + +/** + * Merge the folded-stacks strings from all profiles of a given endpoint. + */ +function mergeFoldedStacks(string $endpoint, string $dataDir): string +{ + $files = glob($dataDir . '/*.json'); + + if ($files === false) { + return ''; + } + + $merged = ''; + + foreach ($files as $file) { + $raw = file_get_contents($file); + + if ($raw === false) { + continue; + } + + /** @var array|null $data */ + $data = json_decode($raw, true); + + if (! is_array($data) || ($data['endpoint'] ?? '') !== $endpoint) { + continue; + } + + $stacks = trim((string) ($data['folded_stacks'] ?? '')); + + if ($stacks !== '') { + $merged .= $stacks . "\n"; + } + } + + return trim($merged); +} + +// ── JSON API ────────────────────────────────────────────────────────────────── + +$action = $_GET['action'] ?? ''; + +if ($action !== '') { + header('Content-Type: application/json; charset=utf-8'); + // Prevent caching of API responses + header('Cache-Control: no-store'); + + switch ($action) { + case 'endpoints': + $profiles = readAllProfiles($dataDir); + echo json_encode(buildEndpointStats($profiles), JSON_UNESCAPED_SLASHES); + exit; + + case 'profiles': + $endpoint = (string) ($_GET['endpoint'] ?? ''); + $folded = mergeFoldedStacks($endpoint, $dataDir); + echo json_encode([ + 'endpoint' => $endpoint, + 'folded_stacks' => $folded, + ], JSON_UNESCAPED_SLASHES); + exit; + + case 'export': + $endpoint = (string) ($_GET['endpoint'] ?? ''); + $folded = mergeFoldedStacks($endpoint, $dataDir); + $safe = preg_replace('/[^a-zA-Z0-9._\-]/', '_', ltrim($endpoint, '/')) ?: 'profile'; + header('Content-Type: application/json; charset=utf-8'); + header('Content-Disposition: attachment; filename="profile-' . $safe . '.json"'); + echo json_encode([ + 'endpoint' => $endpoint, + 'folded_stacks' => $folded, + 'exported_at' => date('c'), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + exit; + } + + http_response_code(400); + echo json_encode(['error' => 'Unknown action']); + exit; +} + +// ── HTML Dashboard ──────────────────────────────────────────────────────────── +?> + + + + + +PHP Profiler Dashboard + + + + + + 🔥 PHP Profiler + Excimer + + ↻ Refresh + + + + + + + + + + + ← Select an endpoint to view its flamegraph + + ⬇ Export JSON + ⊙ Reset zoom + + + + + + 📊 + Select an endpoint from the list + + + + + + + + + + + + + + + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 30e829e..add4e6e 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,3 +4,7 @@ parameters: paths: - src/ - tests/ + stubFiles: + - stubs/ExcimerProfiler.php + - stubs/ExcimerLog.php + - stubs/ExcimerLogEntry.php diff --git a/profiler.php b/profiler.php new file mode 100644 index 0000000..c94f326 --- /dev/null +++ b/profiler.php @@ -0,0 +1,118 @@ +setPeriod(0.01); // sample every 10 ms +$_profilerExcimer->setEventType(EXCIMER_REAL); +$_profilerExcimer->setExcludeDepth(2); // exclude the profiler frames themselves +$_profilerExcimer->start(); + +// ── Shutdown handler: save results ─────────────────────────────────────────── + +register_shutdown_function( + static function () use ($_profilerExcimer, $_profilerStartTime, $_profilerDataDir, $_profilerRequestUri): void { + $_profilerExcimer->stop(); + + $log = $_profilerExcimer->getLog(); + $folded = $log->formatFolded(); + + if (trim($folded) === '') { + return; + } + + // Strip query string – group by path only. + $endpoint = $_profilerRequestUri; + $qpos = strpos($endpoint, '?'); + + if ($qpos !== false) { + $endpoint = substr($endpoint, 0, $qpos); + } + + // Count total samples (number of non-empty lines). + $sampleCount = substr_count(trim($folded), "\n") + 1; + + $id = date('YmdHis') . '_' . bin2hex(random_bytes(8)); + $profile = [ + 'id' => $id, + 'timestamp' => $_profilerStartTime, + 'endpoint' => $endpoint, + 'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET', + 'duration_ms' => round((microtime(true) - $_profilerStartTime) * 1000.0, 2), + 'sample_count' => $sampleCount, + 'folded_stacks' => $folded, + ]; + + $filename = $_profilerDataDir . '/' . $id . '.json'; + $encoded = json_encode($profile, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + if ($encoded !== false) { + file_put_contents($filename, $encoded); + } + + // ── Rolling cleanup: keep at most 10 000 profile files ────────────── + $allFiles = glob($_profilerDataDir . '/*.json'); + + if ($allFiles !== false && count($allFiles) > 10_000) { + sort($allFiles); + + foreach (array_slice($allFiles, 0, count($allFiles) - 10_000) as $oldFile) { + unlink($oldFile); + } + } + } +); diff --git a/src/Profiler.php b/src/Profiler.php index 57e3746..5fb0527 100644 --- a/src/Profiler.php +++ b/src/Profiler.php @@ -19,6 +19,8 @@ * PHP Profiler using Excimer extension. * * Wraps the Excimer sampling profiler for convenient use. + * When the excimer extension is not loaded the profiler still tracks + * start/stop state so it can be used in environments without the extension. */ class Profiler { @@ -33,7 +35,7 @@ class Profiler private bool $running = false; /** - * Collected log data. + * Collected log data (parsed folded stacks). * * @var array> */ @@ -44,6 +46,16 @@ class Profiler */ private float $period; + /** + * Raw folded stacks string produced by Excimer after stop(). + */ + private string $foldedStacks = ''; + + /** + * Underlying Excimer profiler instance (null when extension unavailable). + */ + private ?ExcimerProfiler $excimerProfiler = null; + /** * @param float $period Sample period in seconds (default: 0.01) */ @@ -55,8 +67,10 @@ public function __construct(float $period = self::DEFAULT_PERIOD) /** * Start profiling. * + * When the excimer extension is loaded a real sampling profiler is started. * Note: calling start() will clear any previously collected log data. - * Call getLog() before calling start() again if you need to preserve the data. + * Call getLog() / getFoldedStacks() before calling start() again if you + * need to preserve the data. * * @throws RuntimeException if profiling is already running */ @@ -66,8 +80,16 @@ public function start(): void throw new RuntimeException('Profiler is already running.'); } - $this->log = []; - $this->running = true; + $this->log = []; + $this->foldedStacks = ''; + $this->running = true; + + if (extension_loaded('excimer')) { + $this->excimerProfiler = new ExcimerProfiler(); + $this->excimerProfiler->setPeriod($this->period); + $this->excimerProfiler->setEventType(EXCIMER_REAL); + $this->excimerProfiler->start(); + } } /** @@ -81,6 +103,14 @@ public function stop(): void throw new RuntimeException('Profiler is not running.'); } + if ($this->excimerProfiler !== null) { + $this->excimerProfiler->stop(); + $excimerLog = $this->excimerProfiler->getLog(); + $this->foldedStacks = $excimerLog->formatFolded(); + $this->log = $this->parseFoldedStacks($this->foldedStacks); + $this->excimerProfiler = null; + } + $this->running = false; } @@ -117,6 +147,10 @@ public function setPeriod(float $period): void /** * Get collected log data. * + * Each entry is an array with keys: + * - 'stack' => array (call stack, outermost first) + * - 'count' => int (number of samples for this stack) + * * @return array> */ public function getLog(): array @@ -124,6 +158,17 @@ public function getLog(): array return $this->log; } + /** + * Get the raw folded-stacks string produced by Excimer. + * + * Format: one line per unique stack, "frame1;frame2;...;frameN count". + * Returns an empty string when excimer is not available or before stop(). + */ + public function getFoldedStacks(): string + { + return $this->foldedStacks; + } + /** * Reset the profiler state. * @@ -135,6 +180,46 @@ public function reset(): void throw new RuntimeException('Cannot reset while profiler is running.'); } - $this->log = []; + $this->log = []; + $this->foldedStacks = ''; + } + + /** + * Parse a folded-stacks string into a structured array. + * + * @return array> + */ + private function parseFoldedStacks(string $folded): array + { + $result = []; + $folded = trim($folded); + + if ($folded === '') { + return $result; + } + + foreach (explode("\n", $folded) as $line) { + $line = trim($line); + + if ($line === '') { + continue; + } + + $lastSpace = strrpos($line, ' '); + + if ($lastSpace === false) { + continue; + } + + $stack = substr($line, 0, $lastSpace); + $count = (int) substr($line, $lastSpace + 1); + + $result[] = [ + 'stack' => explode(';', $stack), + 'count' => $count, + ]; + } + + return $result; } } diff --git a/src/Storage/FileStorage.php b/src/Storage/FileStorage.php new file mode 100644 index 0000000..691a16a --- /dev/null +++ b/src/Storage/FileStorage.php @@ -0,0 +1,205 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace PHPDevsr\Profiler\Storage; + +use InvalidArgumentException; +use RuntimeException; + +/** + * Stores and retrieves profile records as JSON files on disk. + * + * Each saved profile is written to a single file named + * ".json" inside the configured data directory. + */ +class FileStorage +{ + private string $dataDir; + + public function __construct(string $dataDir) + { + $this->dataDir = rtrim($dataDir, '/\\'); + } + + /** + * Save a profile record to disk. + * + * Required keys: 'id', 'endpoint', 'folded_stacks'. + * + * @param array $profile + * + * @throws InvalidArgumentException if required keys are missing + * @throws RuntimeException if the file cannot be written + */ + public function save(array $profile): void + { + if (! isset($profile['id'], $profile['endpoint'], $profile['folded_stacks'])) { + throw new InvalidArgumentException( + 'Profile must contain at least the keys: id, endpoint, folded_stacks.' + ); + } + + $this->ensureDataDir(); + + $filename = $this->dataDir . '/' . (string) $profile['id'] . '.json'; + $encoded = json_encode($profile, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + if ($encoded === false || file_put_contents($filename, $encoded) === false) { + throw new RuntimeException('Failed to write profile file: ' . $filename); + } + } + + /** + * Return all stored profiles, unsorted. + * + * @return array> + */ + public function findAll(): array + { + $files = glob($this->dataDir . '/*.json'); + $profiles = []; + + if ($files === false) { + return $profiles; + } + + foreach ($files as $file) { + $raw = file_get_contents($file); + + if ($raw === false) { + continue; + } + + /** @var array|null $data */ + $data = json_decode($raw, true); + + if (is_array($data)) { + $profiles[] = $data; + } + } + + return $profiles; + } + + /** + * Return all profiles recorded for a specific endpoint. + * + * @return array> + */ + public function findByEndpoint(string $endpoint): array + { + return array_values( + array_filter( + $this->findAll(), + static fn (array $p): bool => ($p['endpoint'] ?? '') === $endpoint + ) + ); + } + + /** + * Return per-endpoint statistics sorted by total sample count (descending). + * + * Each entry contains: + * - endpoint (string) + * - request_count (int) + * - total_samples (int) + * - total_duration_ms (float) + * - avg_duration_ms (float) + * + * @return array> + */ + public function getEndpointStats(): array + { + /** @var array> $endpoints */ + $endpoints = []; + + foreach ($this->findAll() as $profile) { + $ep = (string) ($profile['endpoint'] ?? ''); + + if (! isset($endpoints[$ep])) { + $endpoints[$ep] = [ + 'endpoint' => $ep, + 'request_count' => 0, + 'total_samples' => 0, + 'total_duration_ms' => 0.0, + 'avg_duration_ms' => 0.0, + ]; + } + + $endpoints[$ep]['request_count']++; + $endpoints[$ep]['total_samples'] += (int) ($profile['sample_count'] ?? 0); + $endpoints[$ep]['total_duration_ms'] += (float) ($profile['duration_ms'] ?? 0.0); + } + + foreach ($endpoints as &$ep) { + $count = (int) $ep['request_count']; + $ep['avg_duration_ms'] = $count > 0 + ? round((float) $ep['total_duration_ms'] / $count, 2) + : 0.0; + } + + unset($ep); + + usort( + $endpoints, + static fn (array $a, array $b): int => (int) $b['total_samples'] - (int) $a['total_samples'] + ); + + return array_values($endpoints); + } + + /** + * Delete the oldest files when more than $maxFiles profiles are stored. + * + * @return int Number of files deleted. + */ + public function cleanup(int $maxFiles = 10_000): int + { + $files = glob($this->dataDir . '/*.json'); + + if ($files === false) { + return 0; + } + + $count = count($files); + + if ($count <= $maxFiles) { + return 0; + } + + sort($files); + $toDelete = array_slice($files, 0, $count - $maxFiles); + + foreach ($toDelete as $file) { + unlink($file); + } + + return count($toDelete); + } + + /** + * Create the data directory if it does not already exist. + * + * @throws RuntimeException if the directory cannot be created + */ + private function ensureDataDir(): void + { + if (is_dir($this->dataDir)) { + return; + } + + if (! mkdir($this->dataDir, 0755, true) && ! is_dir($this->dataDir)) { + throw new RuntimeException('Failed to create data directory: ' . $this->dataDir); + } + } +} diff --git a/stubs/ExcimerLog.php b/stubs/ExcimerLog.php new file mode 100644 index 0000000..8f672a2 --- /dev/null +++ b/stubs/ExcimerLog.php @@ -0,0 +1,49 @@ + + */ +class ExcimerLog implements Countable, IteratorAggregate +{ + /** + * Return the log in "folded stacks" format, suitable for flamegraph tools. + * + * Each line is "frame1;frame2;...;frameN count". + */ + public function formatFolded(): string + { + return ''; + } + + /** + * Return the number of samples in the log. + */ + public function count(): int + { + return 0; + } + + /** + * @return \ArrayIterator + */ + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator([]); + } + + /** + * Aggregate samples by function name. + * + * @return array + */ + public function aggregateByFunction(): array + { + return []; + } +} diff --git a/stubs/ExcimerLogEntry.php b/stubs/ExcimerLogEntry.php new file mode 100644 index 0000000..9f6be5b --- /dev/null +++ b/stubs/ExcimerLogEntry.php @@ -0,0 +1,29 @@ +> + */ + public function getTrace(): array + { + return []; + } +} diff --git a/stubs/ExcimerProfiler.php b/stubs/ExcimerProfiler.php new file mode 100644 index 0000000..ec3c62a --- /dev/null +++ b/stubs/ExcimerProfiler.php @@ -0,0 +1,57 @@ +profiler->stop(); } } + + public function testGetFoldedStacksInitiallyEmpty(): void + { + $this->assertSame('', $this->profiler->getFoldedStacks()); + } + + public function testGetFoldedStacksEmptyAfterStartStop(): void + { + // Without the excimer extension the folded stacks remain empty. + $this->profiler->start(); + $this->profiler->stop(); + $this->assertSame('', $this->profiler->getFoldedStacks()); + } + + public function testResetClearsFoldedStacks(): void + { + $this->profiler->reset(); + $this->assertSame('', $this->profiler->getFoldedStacks()); + } + + public function testStartClearsPreviousData(): void + { + // start() / stop() pair should always reset log and folded stacks. + $this->profiler->start(); + $this->profiler->stop(); + $this->assertSame([], $this->profiler->getLog()); + $this->assertSame('', $this->profiler->getFoldedStacks()); + + // A second start should clear them again. + $this->profiler->start(); + $this->profiler->stop(); + $this->assertSame([], $this->profiler->getLog()); + } } diff --git a/tests/Storage/FileStorageTest.php b/tests/Storage/FileStorageTest.php new file mode 100644 index 0000000..2439be1 --- /dev/null +++ b/tests/Storage/FileStorageTest.php @@ -0,0 +1,197 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Storage; + +use InvalidArgumentException; +use PHPDevsr\Profiler\Storage\FileStorage; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +final class FileStorageTest extends TestCase +{ + private string $tmpDir; + private FileStorage $storage; + + protected function setUp(): void + { + $this->tmpDir = sys_get_temp_dir() . '/php-profiler-test-' . uniqid('', true); + $this->storage = new FileStorage($this->tmpDir); + } + + protected function tearDown(): void + { + // Remove test files + $files = glob($this->tmpDir . '/*.json') ?: []; + + foreach ($files as $file) { + unlink($file); + } + + if (is_dir($this->tmpDir)) { + rmdir($this->tmpDir); + } + } + + // ── save() ──────────────────────────────────────────────────────────────── + + public function testSaveCreatesJsonFile(): void + { + $profile = $this->makeProfile('p1', '/api/users'); + $this->storage->save($profile); + + $this->assertFileExists($this->tmpDir . '/p1.json'); + } + + public function testSaveCreatesDataDirIfMissing(): void + { + $this->assertDirectoryDoesNotExist($this->tmpDir); + $this->storage->save($this->makeProfile('p1', '/api/users')); + $this->assertDirectoryExists($this->tmpDir); + } + + public function testSaveThrowsOnMissingRequiredKey(): void + { + $this->expectException(InvalidArgumentException::class); + $this->storage->save(['id' => 'x', 'endpoint' => '/foo']); + } + + public function testSavedFileContainsExpectedJson(): void + { + $profile = $this->makeProfile('p2', '/api/orders', 'main;foo;bar 5'); + $this->storage->save($profile); + + $raw = file_get_contents($this->tmpDir . '/p2.json'); + $data = json_decode((string) $raw, true); + + $this->assertIsArray($data); + $this->assertSame('/api/orders', $data['endpoint']); + $this->assertSame('main;foo;bar 5', $data['folded_stacks']); + } + + // ── findAll() ───────────────────────────────────────────────────────────── + + public function testFindAllReturnsEmptyForEmptyDir(): void + { + mkdir($this->tmpDir, 0755, true); + $this->assertSame([], $this->storage->findAll()); + } + + public function testFindAllReturnsAllSavedProfiles(): void + { + $this->storage->save($this->makeProfile('a1', '/api/a')); + $this->storage->save($this->makeProfile('b2', '/api/b')); + $this->storage->save($this->makeProfile('c3', '/api/c')); + + $all = $this->storage->findAll(); + $this->assertCount(3, $all); + } + + // ── findByEndpoint() ────────────────────────────────────────────────────── + + public function testFindByEndpointReturnsMatchingProfiles(): void + { + $this->storage->save($this->makeProfile('x1', '/api/users')); + $this->storage->save($this->makeProfile('x2', '/api/users')); + $this->storage->save($this->makeProfile('x3', '/api/orders')); + + $users = $this->storage->findByEndpoint('/api/users'); + $this->assertCount(2, $users); + + foreach ($users as $p) { + $this->assertSame('/api/users', $p['endpoint']); + } + } + + public function testFindByEndpointReturnsEmptyWhenNoMatch(): void + { + $this->storage->save($this->makeProfile('y1', '/api/users')); + $this->assertSame([], $this->storage->findByEndpoint('/api/other')); + } + + // ── getEndpointStats() ──────────────────────────────────────────────────── + + public function testGetEndpointStatsSortsByTotalSamplesDesc(): void + { + // /api/b gets more samples so should rank first + $this->storage->save($this->makeProfile('s1', '/api/a', 'main 3', 3, 100.0)); + $this->storage->save($this->makeProfile('s2', '/api/b', 'main 10', 10, 200.0)); + $this->storage->save($this->makeProfile('s3', '/api/b', 'main 5', 5, 150.0)); + + $stats = $this->storage->getEndpointStats(); + + $this->assertCount(2, $stats); + $this->assertSame('/api/b', $stats[0]['endpoint']); + $this->assertSame(15, $stats[0]['total_samples']); + $this->assertSame(2, $stats[0]['request_count']); + $this->assertSame(175.0, $stats[0]['avg_duration_ms']); + + $this->assertSame('/api/a', $stats[1]['endpoint']); + $this->assertSame(3, $stats[1]['total_samples']); + $this->assertSame(1, $stats[1]['request_count']); + } + + public function testGetEndpointStatsReturnsEmptyForEmptyDir(): void + { + mkdir($this->tmpDir, 0755, true); + $this->assertSame([], $this->storage->getEndpointStats()); + } + + // ── cleanup() ───────────────────────────────────────────────────────────── + + public function testCleanupDeletesOldestFiles(): void + { + for ($i = 1; $i <= 5; $i++) { + $this->storage->save($this->makeProfile('file' . $i, '/api/x')); + } + + $deleted = $this->storage->cleanup(3); + + $this->assertSame(2, $deleted); + $this->assertCount(3, glob($this->tmpDir . '/*.json') ?: []); + } + + public function testCleanupDoesNothingWhenUnderLimit(): void + { + $this->storage->save($this->makeProfile('f1', '/api/x')); + $this->storage->save($this->makeProfile('f2', '/api/x')); + + $this->assertSame(0, $this->storage->cleanup(10)); + $this->assertCount(2, glob($this->tmpDir . '/*.json') ?: []); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + /** + * @return array + */ + private function makeProfile( + string $id, + string $endpoint, + string $foldedStacks = 'main 1', + int $sampleCount = 1, + float $durationMs = 50.0 + ): array { + return [ + 'id' => $id, + 'timestamp' => microtime(true), + 'endpoint' => $endpoint, + 'method' => 'GET', + 'duration_ms' => $durationMs, + 'sample_count' => $sampleCount, + 'folded_stacks' => $foldedStacks, + ]; + } +} From ae0319216a7997996f9e4d8538a06c9005b0b71f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:14:26 +0000 Subject: [PATCH 02/14] fix: address code review feedback (sanitize URI, cache headers, export filename, count validation) Agent-Logs-Url: https://github.com/PHPDevsr/php-profiler/sessions/071177e1-7af4-4040-92bc-ad0a180b3efb Co-authored-by: ddevsr <97607754+ddevsr@users.noreply.github.com> --- dashboard/index.php | 9 ++++++--- profiler.php | 9 ++++++--- src/Profiler.php | 15 +++++++++++++-- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/dashboard/index.php b/dashboard/index.php index cfc32ef..3874fb6 100644 --- a/dashboard/index.php +++ b/dashboard/index.php @@ -163,8 +163,9 @@ function mergeFoldedStacks(string $endpoint, string $dataDir): string if ($action !== '') { header('Content-Type: application/json; charset=utf-8'); - // Prevent caching of API responses - header('Cache-Control: no-store'); + // Prevent caching of API responses by browsers and intermediate proxies. + header('Cache-Control: no-cache, no-store, must-revalidate, private'); + header('Pragma: no-cache'); switch ($action) { case 'endpoints': @@ -184,7 +185,9 @@ function mergeFoldedStacks(string $endpoint, string $dataDir): string case 'export': $endpoint = (string) ($_GET['endpoint'] ?? ''); $folded = mergeFoldedStacks($endpoint, $dataDir); - $safe = preg_replace('/[^a-zA-Z0-9._\-]/', '_', ltrim($endpoint, '/')) ?: 'profile'; + // Build a safe filename: strip path separators and control characters, + // then use basename() to prevent directory traversal. + $safe = preg_replace('/[^a-zA-Z0-9._\-]/', '_', basename($endpoint)) ?: 'profile'; header('Content-Type: application/json; charset=utf-8'); header('Content-Disposition: attachment; filename="profile-' . $safe . '.json"'); echo json_encode([ diff --git a/profiler.php b/profiler.php index c94f326..cb7d3a9 100644 --- a/profiler.php +++ b/profiler.php @@ -35,7 +35,9 @@ } // Skip profiling the dashboard itself to avoid recursion / noise. -$_profilerRequestUri = $_SERVER['REQUEST_URI'] ?? ''; +// Sanitize to printable ASCII only before storing or comparing. +$_profilerRequestUri = preg_replace('/[^\x20-\x7E]/', '', $_SERVER['REQUEST_URI'] ?? '') ?? ''; + if ( str_contains($_profilerRequestUri, '/profiler') || str_contains($_profilerRequestUri, '/__profiler') @@ -83,8 +85,9 @@ static function () use ($_profilerExcimer, $_profilerStartTime, $_profilerDataDi $endpoint = substr($endpoint, 0, $qpos); } - // Count total samples (number of non-empty lines). - $sampleCount = substr_count(trim($folded), "\n") + 1; + // Count total samples (number of non-empty lines in the trimmed folded string). + $trimmedFolded = trim($folded); + $sampleCount = substr_count($trimmedFolded, "\n") + 1; $id = date('YmdHis') . '_' . bin2hex(random_bytes(8)); $profile = [ diff --git a/src/Profiler.php b/src/Profiler.php index 5fb0527..26308a7 100644 --- a/src/Profiler.php +++ b/src/Profiler.php @@ -211,8 +211,19 @@ private function parseFoldedStacks(string $folded): array continue; } - $stack = substr($line, 0, $lastSpace); - $count = (int) substr($line, $lastSpace + 1); + $stack = substr($line, 0, $lastSpace); + $rawCount = substr($line, $lastSpace + 1); + + // Skip malformed lines where the count is not a positive integer. + if (! ctype_digit($rawCount)) { + continue; + } + + $count = (int) $rawCount; + + if ($count <= 0) { + continue; + } $result[] = [ 'stack' => explode(';', $stack), From 37338ca15b38f92849a587f36bd97f96198d22e3 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean Date: Sun, 19 Apr 2026 00:18:35 +0700 Subject: [PATCH 03/14] chore: require ext-excimer --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 6fc72ec..7c8bd5c 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,8 @@ ], "homepage": "https://github.com/PHPDevsr/php-profiler", "require": { - "php": "^8.3 || ^8.4 || ^8.5" + "php": "^8.3 || ^8.4 || ^8.5", + "ext-excimer": "*" }, "require-dev": { "nexusphp/tachycardia": "^2.4", From 53520805e5a7c322c46d242b7f2a749d7f5314ac Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean Date: Sun, 19 Apr 2026 00:22:55 +0700 Subject: [PATCH 04/14] chore: require ext-excimer --- .github/workflows/test-phpstan.yml | 2 +- .github/workflows/test-phpunit.yml | 2 +- .github/workflows/test-rector.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-phpstan.yml b/.github/workflows/test-phpstan.yml index a4e18f4..f798165 100644 --- a/.github/workflows/test-phpstan.yml +++ b/.github/workflows/test-phpstan.yml @@ -44,7 +44,7 @@ jobs: with: php-version: ${{ matrix.php-versions }} tools: phpstan - extensions: intl + extensions: intl, json, mbstring, xml coverage: none env: COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-phpunit.yml b/.github/workflows/test-phpunit.yml index 5ecc63c..721b665 100644 --- a/.github/workflows/test-phpunit.yml +++ b/.github/workflows/test-phpunit.yml @@ -43,7 +43,7 @@ jobs: with: php-version: ${{ matrix.php-versions }} tools: composer, phpunit - extensions: intl + extensions: intl, json, mbstring, xml, excimer coverage: xdebug env: COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-rector.yml b/.github/workflows/test-rector.yml index 771a475..01d6f94 100644 --- a/.github/workflows/test-rector.yml +++ b/.github/workflows/test-rector.yml @@ -40,7 +40,7 @@ jobs: with: php-version: ${{ matrix.php-versions }} tools: phpstan - extensions: intl, json, mbstring, xml + extensions: intl, json, mbstring, xml, excimer coverage: none env: COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 8a17fa0d05177a9fc2f21037a578322b86872943 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean Date: Sun, 19 Apr 2026 00:24:51 +0700 Subject: [PATCH 05/14] chore: require ext-excimer --- .github/workflows/test-phpstan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-phpstan.yml b/.github/workflows/test-phpstan.yml index f798165..4852ec4 100644 --- a/.github/workflows/test-phpstan.yml +++ b/.github/workflows/test-phpstan.yml @@ -44,7 +44,7 @@ jobs: with: php-version: ${{ matrix.php-versions }} tools: phpstan - extensions: intl, json, mbstring, xml + extensions: intl, json, mbstring, xml, excimer coverage: none env: COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 5270c2e6c03e0b6265955a2143a53b644bbea6e0 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean Date: Sun, 19 Apr 2026 00:32:08 +0700 Subject: [PATCH 06/14] refactor: run Rector --- src/Profiler.php | 15 +++++---------- src/Storage/FileStorage.php | 4 ++-- tests/Storage/FileStorageTest.php | 1 + 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/Profiler.php b/src/Profiler.php index 26308a7..e6276f9 100644 --- a/src/Profiler.php +++ b/src/Profiler.php @@ -27,7 +27,7 @@ class Profiler /** * Default sample period in seconds. */ - private const DEFAULT_PERIOD = 0.01; + private const float DEFAULT_PERIOD = 0.01; /** * Whether the profiler is currently running. @@ -41,11 +41,6 @@ class Profiler */ private array $log = []; - /** - * Sample period in seconds. - */ - private float $period; - /** * Raw folded stacks string produced by Excimer after stop(). */ @@ -57,9 +52,9 @@ class Profiler private ?ExcimerProfiler $excimerProfiler = null; /** - * @param float $period Sample period in seconds (default: 0.01) - */ - public function __construct(float $period = self::DEFAULT_PERIOD) + * @param float $period Sample period in seconds (default: 0.01) + */ + public function __construct(private float $period = self::DEFAULT_PERIOD) { $this->period = $period; } @@ -103,7 +98,7 @@ public function stop(): void throw new RuntimeException('Profiler is not running.'); } - if ($this->excimerProfiler !== null) { + if ($this->excimerProfiler instanceof ExcimerProfiler) { $this->excimerProfiler->stop(); $excimerLog = $this->excimerProfiler->getLog(); $this->foldedStacks = $excimerLog->formatFolded(); diff --git a/src/Storage/FileStorage.php b/src/Storage/FileStorage.php index 691a16a..78bdb98 100644 --- a/src/Storage/FileStorage.php +++ b/src/Storage/FileStorage.php @@ -24,7 +24,7 @@ */ class FileStorage { - private string $dataDir; + private readonly string $dataDir; public function __construct(string $dataDir) { @@ -51,7 +51,7 @@ public function save(array $profile): void $this->ensureDataDir(); - $filename = $this->dataDir . '/' . (string) $profile['id'] . '.json'; + $filename = $this->dataDir . '/' . $profile['id'] . '.json'; $encoded = json_encode($profile, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($encoded === false || file_put_contents($filename, $encoded) === false) { diff --git a/tests/Storage/FileStorageTest.php b/tests/Storage/FileStorageTest.php index 2439be1..96047ba 100644 --- a/tests/Storage/FileStorageTest.php +++ b/tests/Storage/FileStorageTest.php @@ -23,6 +23,7 @@ final class FileStorageTest extends TestCase { private string $tmpDir; + private FileStorage $storage; protected function setUp(): void From 3ee2bd0b47e0dd8325323b7793667376b2bd5d26 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean Date: Sun, 19 Apr 2026 00:34:07 +0700 Subject: [PATCH 07/14] refactor: run Rector --- src/Profiler.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Profiler.php b/src/Profiler.php index e6276f9..5bb14c8 100644 --- a/src/Profiler.php +++ b/src/Profiler.php @@ -56,7 +56,6 @@ class Profiler */ public function __construct(private float $period = self::DEFAULT_PERIOD) { - $this->period = $period; } /** From 3a84d248d9c818324f088c0989fa83b3d0ef4ccf Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean Date: Sun, 19 Apr 2026 00:42:46 +0700 Subject: [PATCH 08/14] refactor: PHPUnit use function stub instead --- tests/ProfilerTest.php | 29 ++++++++++--------- tests/Storage/FileStorageTest.php | 48 +++++++++++++++---------------- 2 files changed, 39 insertions(+), 38 deletions(-) diff --git a/tests/ProfilerTest.php b/tests/ProfilerTest.php index 2e73273..fe9d7f2 100644 --- a/tests/ProfilerTest.php +++ b/tests/ProfilerTest.php @@ -31,30 +31,30 @@ protected function setUp(): void public function testDefaultPeriod(): void { - $this->assertSame(0.01, $this->profiler->getPeriod()); + assertSame(0.01, $this->profiler->getPeriod()); } public function testCustomPeriod(): void { $profiler = new Profiler(0.05); - $this->assertSame(0.05, $profiler->getPeriod()); + assertSame(0.05, $profiler->getPeriod()); } public function testSetPeriod(): void { $this->profiler->setPeriod(0.02); - $this->assertSame(0.02, $this->profiler->getPeriod()); + assertSame(0.02, $this->profiler->getPeriod()); } public function testIsNotRunningInitially(): void { - $this->assertFalse($this->profiler->isRunning()); + assertFalse($this->profiler->isRunning()); } public function testStartSetsRunning(): void { $this->profiler->start(); - $this->assertTrue($this->profiler->isRunning()); + assertTrue($this->profiler->isRunning()); $this->profiler->stop(); } @@ -62,7 +62,7 @@ public function testStopSetsNotRunning(): void { $this->profiler->start(); $this->profiler->stop(); - $this->assertFalse($this->profiler->isRunning()); + assertFalse($this->profiler->isRunning()); } public function testStartThrowsIfAlreadyRunning(): void @@ -100,13 +100,13 @@ public function testSetPeriodThrowsIfRunning(): void public function testGetLogInitiallyEmpty(): void { - $this->assertSame([], $this->profiler->getLog()); + assertSame([], $this->profiler->getLog()); } public function testResetClearsLog(): void { $this->profiler->reset(); - $this->assertSame([], $this->profiler->getLog()); + assertSame([], $this->profiler->getLog()); } public function testResetThrowsIfRunning(): void @@ -124,7 +124,7 @@ public function testResetThrowsIfRunning(): void public function testGetFoldedStacksInitiallyEmpty(): void { - $this->assertSame('', $this->profiler->getFoldedStacks()); + assertSame('', $this->profiler->getFoldedStacks()); } public function testGetFoldedStacksEmptyAfterStartStop(): void @@ -132,13 +132,13 @@ public function testGetFoldedStacksEmptyAfterStartStop(): void // Without the excimer extension the folded stacks remain empty. $this->profiler->start(); $this->profiler->stop(); - $this->assertSame('', $this->profiler->getFoldedStacks()); + assertSame('', $this->profiler->getFoldedStacks()); } public function testResetClearsFoldedStacks(): void { $this->profiler->reset(); - $this->assertSame('', $this->profiler->getFoldedStacks()); + assertSame('', $this->profiler->getFoldedStacks()); } public function testStartClearsPreviousData(): void @@ -146,12 +146,13 @@ public function testStartClearsPreviousData(): void // start() / stop() pair should always reset log and folded stacks. $this->profiler->start(); $this->profiler->stop(); - $this->assertSame([], $this->profiler->getLog()); - $this->assertSame('', $this->profiler->getFoldedStacks()); + assertSame([], $this->profiler->getLog()); + assertSame('', $this->profiler->getFoldedStacks()); // A second start should clear them again. $this->profiler->start(); $this->profiler->stop(); - $this->assertSame([], $this->profiler->getLog()); + assertSame([], $this->profiler->getLog()); + assertSame('', $this->profiler->getFoldedStacks()); } } diff --git a/tests/Storage/FileStorageTest.php b/tests/Storage/FileStorageTest.php index 96047ba..55433c9 100644 --- a/tests/Storage/FileStorageTest.php +++ b/tests/Storage/FileStorageTest.php @@ -53,14 +53,14 @@ public function testSaveCreatesJsonFile(): void $profile = $this->makeProfile('p1', '/api/users'); $this->storage->save($profile); - $this->assertFileExists($this->tmpDir . '/p1.json'); + assertFileExists($this->tmpDir . '/p1.json'); } public function testSaveCreatesDataDirIfMissing(): void { - $this->assertDirectoryDoesNotExist($this->tmpDir); + assertDirectoryDoesNotExist($this->tmpDir); $this->storage->save($this->makeProfile('p1', '/api/users')); - $this->assertDirectoryExists($this->tmpDir); + assertDirectoryExists($this->tmpDir); } public function testSaveThrowsOnMissingRequiredKey(): void @@ -77,9 +77,9 @@ public function testSavedFileContainsExpectedJson(): void $raw = file_get_contents($this->tmpDir . '/p2.json'); $data = json_decode((string) $raw, true); - $this->assertIsArray($data); - $this->assertSame('/api/orders', $data['endpoint']); - $this->assertSame('main;foo;bar 5', $data['folded_stacks']); + assertIsArray($data); + assertSame('/api/orders', $data['endpoint']); + assertSame('main;foo;bar 5', $data['folded_stacks']); } // ── findAll() ───────────────────────────────────────────────────────────── @@ -87,7 +87,7 @@ public function testSavedFileContainsExpectedJson(): void public function testFindAllReturnsEmptyForEmptyDir(): void { mkdir($this->tmpDir, 0755, true); - $this->assertSame([], $this->storage->findAll()); + assertSame([], $this->storage->findAll()); } public function testFindAllReturnsAllSavedProfiles(): void @@ -97,7 +97,7 @@ public function testFindAllReturnsAllSavedProfiles(): void $this->storage->save($this->makeProfile('c3', '/api/c')); $all = $this->storage->findAll(); - $this->assertCount(3, $all); + assertCount(3, $all); } // ── findByEndpoint() ────────────────────────────────────────────────────── @@ -109,17 +109,17 @@ public function testFindByEndpointReturnsMatchingProfiles(): void $this->storage->save($this->makeProfile('x3', '/api/orders')); $users = $this->storage->findByEndpoint('/api/users'); - $this->assertCount(2, $users); + assertCount(2, $users); foreach ($users as $p) { - $this->assertSame('/api/users', $p['endpoint']); + assertSame('/api/users', $p['endpoint']); } } public function testFindByEndpointReturnsEmptyWhenNoMatch(): void { $this->storage->save($this->makeProfile('y1', '/api/users')); - $this->assertSame([], $this->storage->findByEndpoint('/api/other')); + assertSame([], $this->storage->findByEndpoint('/api/other')); } // ── getEndpointStats() ──────────────────────────────────────────────────── @@ -133,21 +133,21 @@ public function testGetEndpointStatsSortsByTotalSamplesDesc(): void $stats = $this->storage->getEndpointStats(); - $this->assertCount(2, $stats); - $this->assertSame('/api/b', $stats[0]['endpoint']); - $this->assertSame(15, $stats[0]['total_samples']); - $this->assertSame(2, $stats[0]['request_count']); - $this->assertSame(175.0, $stats[0]['avg_duration_ms']); + assertCount(2, $stats); + assertSame('/api/b', $stats[0]['endpoint']); + assertSame(15, $stats[0]['total_samples']); + assertSame(2, $stats[0]['request_count']); + assertSame(175.0, $stats[0]['avg_duration_ms']); - $this->assertSame('/api/a', $stats[1]['endpoint']); - $this->assertSame(3, $stats[1]['total_samples']); - $this->assertSame(1, $stats[1]['request_count']); + assertSame('/api/a', $stats[1]['endpoint']); + assertSame(3, $stats[1]['total_samples']); + assertSame(1, $stats[1]['request_count']); } public function testGetEndpointStatsReturnsEmptyForEmptyDir(): void { mkdir($this->tmpDir, 0755, true); - $this->assertSame([], $this->storage->getEndpointStats()); + assertSame([], $this->storage->getEndpointStats()); } // ── cleanup() ───────────────────────────────────────────────────────────── @@ -160,8 +160,8 @@ public function testCleanupDeletesOldestFiles(): void $deleted = $this->storage->cleanup(3); - $this->assertSame(2, $deleted); - $this->assertCount(3, glob($this->tmpDir . '/*.json') ?: []); + assertSame(2, $deleted); + assertCount(3, glob($this->tmpDir . '/*.json') ?: []); } public function testCleanupDoesNothingWhenUnderLimit(): void @@ -169,8 +169,8 @@ public function testCleanupDoesNothingWhenUnderLimit(): void $this->storage->save($this->makeProfile('f1', '/api/x')); $this->storage->save($this->makeProfile('f2', '/api/x')); - $this->assertSame(0, $this->storage->cleanup(10)); - $this->assertCount(2, glob($this->tmpDir . '/*.json') ?: []); + assertSame(0, $this->storage->cleanup(10)); + assertCount(2, glob($this->tmpDir . '/*.json') ?: []); } // ── helpers ─────────────────────────────────────────────────────────────── From 7bd6dba8d60700fefab9646e23ce49459200ccf8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:54:28 +0000 Subject: [PATCH 09/14] fix: resolve namespace issues causing PHPUnit test failures Agent-Logs-Url: https://github.com/PHPDevsr/php-profiler/sessions/19e82ae3-7ad7-4e95-aa5b-b5581b73f268 Co-authored-by: ddevsr <97607754+ddevsr@users.noreply.github.com> --- src/Profiler.php | 1 + tests/ProfilerTest.php | 4 ++++ tests/Storage/FileStorageTest.php | 7 +++++++ 3 files changed, 12 insertions(+) diff --git a/src/Profiler.php b/src/Profiler.php index 5bb14c8..bd4b18c 100644 --- a/src/Profiler.php +++ b/src/Profiler.php @@ -13,6 +13,7 @@ namespace PHPDevsr\Profiler; +use ExcimerProfiler; use RuntimeException; /** diff --git a/tests/ProfilerTest.php b/tests/ProfilerTest.php index fe9d7f2..2ac3267 100644 --- a/tests/ProfilerTest.php +++ b/tests/ProfilerTest.php @@ -17,6 +17,10 @@ use PHPUnit\Framework\TestCase; use RuntimeException; +use function PHPUnit\Framework\assertFalse; +use function PHPUnit\Framework\assertSame; +use function PHPUnit\Framework\assertTrue; + /** * @internal */ diff --git a/tests/Storage/FileStorageTest.php b/tests/Storage/FileStorageTest.php index 55433c9..06771bd 100644 --- a/tests/Storage/FileStorageTest.php +++ b/tests/Storage/FileStorageTest.php @@ -17,6 +17,13 @@ use PHPDevsr\Profiler\Storage\FileStorage; use PHPUnit\Framework\TestCase; +use function PHPUnit\Framework\assertCount; +use function PHPUnit\Framework\assertDirectoryDoesNotExist; +use function PHPUnit\Framework\assertDirectoryExists; +use function PHPUnit\Framework\assertFileExists; +use function PHPUnit\Framework\assertIsArray; +use function PHPUnit\Framework\assertSame; + /** * @internal */ From 04928686b77bac96c9e1d5de7e96f29e7efab738 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean Date: Sun, 19 Apr 2026 01:00:23 +0700 Subject: [PATCH 10/14] refactor: ordering --- tests/ProfilerTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/ProfilerTest.php b/tests/ProfilerTest.php index 2ac3267..fa45aca 100644 --- a/tests/ProfilerTest.php +++ b/tests/ProfilerTest.php @@ -71,9 +71,9 @@ public function testStopSetsNotRunning(): void public function testStartThrowsIfAlreadyRunning(): void { - $this->profiler->start(); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Profiler is already running.'); + $this->profiler->start(); try { $this->profiler->start(); @@ -91,9 +91,9 @@ public function testStopThrowsIfNotRunning(): void public function testSetPeriodThrowsIfRunning(): void { - $this->profiler->start(); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Cannot change period while profiler is running.'); + $this->profiler->start(); try { $this->profiler->setPeriod(0.05); @@ -115,9 +115,9 @@ public function testResetClearsLog(): void public function testResetThrowsIfRunning(): void { - $this->profiler->start(); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Cannot reset while profiler is running.'); + $this->profiler->start(); try { $this->profiler->reset(); From 6d4aa6bb278902e5fc3097adbb0d5d0a21780659 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 18:09:05 +0000 Subject: [PATCH 11/14] fix: address all PHPStan level-9 errors in FileStorage and FileStorageTest Agent-Logs-Url: https://github.com/PHPDevsr/php-profiler/sessions/ba342eb1-f3bd-47c9-9374-2b07ab63be83 Co-authored-by: ddevsr <97607754+ddevsr@users.noreply.github.com> --- src/Storage/FileStorage.php | 30 +++++++++++++++++++++--------- tests/Storage/FileStorageTest.php | 9 ++++++--- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/Storage/FileStorage.php b/src/Storage/FileStorage.php index 78bdb98..9b8def4 100644 --- a/src/Storage/FileStorage.php +++ b/src/Storage/FileStorage.php @@ -51,7 +51,9 @@ public function save(array $profile): void $this->ensureDataDir(); - $filename = $this->dataDir . '/' . $profile['id'] . '.json'; + /** @var string $id */ + $id = $profile['id']; + $filename = $this->dataDir . '/' . $id . '.json'; $encoded = json_encode($profile, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($encoded === false || file_put_contents($filename, $encoded) === false) { @@ -120,11 +122,12 @@ public function findByEndpoint(string $endpoint): array */ public function getEndpointStats(): array { - /** @var array> $endpoints */ + /** @var array $endpoints */ $endpoints = []; foreach ($this->findAll() as $profile) { - $ep = (string) ($profile['endpoint'] ?? ''); + $rawEndpoint = $profile['endpoint']; + $ep = is_string($rawEndpoint) ? $rawEndpoint : ''; if (! isset($endpoints[$ep])) { $endpoints[$ep] = [ @@ -137,14 +140,23 @@ public function getEndpointStats(): array } $endpoints[$ep]['request_count']++; - $endpoints[$ep]['total_samples'] += (int) ($profile['sample_count'] ?? 0); - $endpoints[$ep]['total_duration_ms'] += (float) ($profile['duration_ms'] ?? 0.0); + + $rawSampleCount = $profile['sample_count']; + $endpoints[$ep]['total_samples'] += is_int($rawSampleCount) ? $rawSampleCount : 0; + + $rawDurationMs = $profile['duration_ms']; + + if (is_float($rawDurationMs)) { + $endpoints[$ep]['total_duration_ms'] += $rawDurationMs; + } elseif (is_int($rawDurationMs)) { + $endpoints[$ep]['total_duration_ms'] += (float) $rawDurationMs; + } } foreach ($endpoints as &$ep) { - $count = (int) $ep['request_count']; + $count = $ep['request_count']; $ep['avg_duration_ms'] = $count > 0 - ? round((float) $ep['total_duration_ms'] / $count, 2) + ? round($ep['total_duration_ms'] / $count, 2) : 0.0; } @@ -152,10 +164,10 @@ public function getEndpointStats(): array usort( $endpoints, - static fn (array $a, array $b): int => (int) $b['total_samples'] - (int) $a['total_samples'] + static fn (array $a, array $b): int => $b['total_samples'] - $a['total_samples'] ); - return array_values($endpoints); + return $endpoints; } /** diff --git a/tests/Storage/FileStorageTest.php b/tests/Storage/FileStorageTest.php index 06771bd..0f774a7 100644 --- a/tests/Storage/FileStorageTest.php +++ b/tests/Storage/FileStorageTest.php @@ -42,7 +42,8 @@ protected function setUp(): void protected function tearDown(): void { // Remove test files - $files = glob($this->tmpDir . '/*.json') ?: []; + $rawFiles = glob($this->tmpDir . '/*.json'); + $files = $rawFiles !== false ? $rawFiles : []; foreach ($files as $file) { unlink($file); @@ -168,7 +169,8 @@ public function testCleanupDeletesOldestFiles(): void $deleted = $this->storage->cleanup(3); assertSame(2, $deleted); - assertCount(3, glob($this->tmpDir . '/*.json') ?: []); + $jsonFiles3 = glob($this->tmpDir . '/*.json'); + assertCount(3, $jsonFiles3 !== false ? $jsonFiles3 : []); } public function testCleanupDoesNothingWhenUnderLimit(): void @@ -177,7 +179,8 @@ public function testCleanupDoesNothingWhenUnderLimit(): void $this->storage->save($this->makeProfile('f2', '/api/x')); assertSame(0, $this->storage->cleanup(10)); - assertCount(2, glob($this->tmpDir . '/*.json') ?: []); + $jsonFiles2 = glob($this->tmpDir . '/*.json'); + assertCount(2, $jsonFiles2 !== false ? $jsonFiles2 : []); } // ── helpers ─────────────────────────────────────────────────────────────── From 0421bbd9b63c35ce4a8775110d824fc8f26eb073 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 13:59:36 +0000 Subject: [PATCH 12/14] fix: make stop() resilient to old Excimer builds missing formatFolded() Agent-Logs-Url: https://github.com/PHPDevsr/php-profiler/sessions/5ade9e2b-bbee-4df1-946c-0faf79b66be2 Co-authored-by: ddevsr <97607754+ddevsr@users.noreply.github.com> --- src/Profiler.php | 11 +++++++++-- stubs/ExcimerLog.php | 2 ++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Profiler.php b/src/Profiler.php index bd4b18c..a50b60b 100644 --- a/src/Profiler.php +++ b/src/Profiler.php @@ -100,8 +100,15 @@ public function stop(): void if ($this->excimerProfiler instanceof ExcimerProfiler) { $this->excimerProfiler->stop(); - $excimerLog = $this->excimerProfiler->getLog(); - $this->foldedStacks = $excimerLog->formatFolded(); + $excimerLog = $this->excimerProfiler->getLog(); + + try { + $this->foldedStacks = $excimerLog->formatFolded(); + } catch (\Error $e) { + // formatFolded() is absent in some older Excimer builds. + $this->foldedStacks = ''; + } + $this->log = $this->parseFoldedStacks($this->foldedStacks); $this->excimerProfiler = null; } diff --git a/stubs/ExcimerLog.php b/stubs/ExcimerLog.php index 8f672a2..27f9cf5 100644 --- a/stubs/ExcimerLog.php +++ b/stubs/ExcimerLog.php @@ -15,6 +15,8 @@ class ExcimerLog implements Countable, IteratorAggregate * Return the log in "folded stacks" format, suitable for flamegraph tools. * * Each line is "frame1;frame2;...;frameN count". + * + * @throws \Error if the method is absent in the installed Excimer build */ public function formatFolded(): string { From b26c7af142e5abab483228e22329a480b11673a7 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean Date: Sun, 19 Apr 2026 21:53:35 +0700 Subject: [PATCH 13/14] refactor: run rector and default config PHPUnit --- phpunit.xml.dist | 4 ++-- src/Profiler.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 0f5d26a..59c21e0 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -29,13 +29,13 @@ - + - + diff --git a/src/Profiler.php b/src/Profiler.php index a50b60b..2ba62d6 100644 --- a/src/Profiler.php +++ b/src/Profiler.php @@ -104,7 +104,7 @@ public function stop(): void try { $this->foldedStacks = $excimerLog->formatFolded(); - } catch (\Error $e) { + } catch (\Error) { // formatFolded() is absent in some older Excimer builds. $this->foldedStacks = ''; } From 9e6081edd5a656d1a26aa30f180bc0a2b204d521 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean Date: Sun, 19 Apr 2026 21:55:30 +0700 Subject: [PATCH 14/14] refactor: run rector --- src/Profiler.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Profiler.php b/src/Profiler.php index 2ba62d6..a8f4a79 100644 --- a/src/Profiler.php +++ b/src/Profiler.php @@ -13,6 +13,7 @@ namespace PHPDevsr\Profiler; +use Error; use ExcimerProfiler; use RuntimeException; @@ -104,7 +105,7 @@ public function stop(): void try { $this->foldedStacks = $excimerLog->formatFolded(); - } catch (\Error) { + } catch (Error) { // formatFolded() is absent in some older Excimer builds. $this->foldedStacks = ''; }