diff --git a/.github/workflows/test-phpstan.yml b/.github/workflows/test-phpstan.yml index a4e18f4..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 + extensions: intl, json, mbstring, xml, excimer 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 }} 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/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", diff --git a/dashboard/index.php b/dashboard/index.php new file mode 100644 index 0000000..3874fb6 --- /dev/null +++ b/dashboard/index.php @@ -0,0 +1,982 @@ + + * 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 by browsers and intermediate proxies. + header('Cache-Control: no-cache, no-store, must-revalidate, private'); + header('Pragma: no-cache'); + + 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); + // 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([ + '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 +
+ +
+ +
+ + + + + +
+
+
← Select an endpoint to view its flamegraph
+ + + +
+
+ +
+
+
📊
+ 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/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/profiler.php b/profiler.php new file mode 100644 index 0000000..cb7d3a9 --- /dev/null +++ b/profiler.php @@ -0,0 +1,121 @@ +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 in the trimmed folded string). + $trimmedFolded = trim($folded); + $sampleCount = substr_count($trimmedFolded, "\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..a8f4a79 100644 --- a/src/Profiler.php +++ b/src/Profiler.php @@ -13,19 +13,23 @@ namespace PHPDevsr\Profiler; +use Error; +use ExcimerProfiler; use RuntimeException; /** * 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 { /** * Default sample period in seconds. */ - private const DEFAULT_PERIOD = 0.01; + private const float DEFAULT_PERIOD = 0.01; /** * Whether the profiler is currently running. @@ -33,30 +37,36 @@ class Profiler private bool $running = false; /** - * Collected log data. + * Collected log data (parsed folded stacks). * * @var array> */ private array $log = []; /** - * Sample period in seconds. + * Raw folded stacks string produced by Excimer after stop(). */ - private float $period; + private string $foldedStacks = ''; /** - * @param float $period Sample period in seconds (default: 0.01) + * Underlying Excimer profiler instance (null when extension unavailable). */ - public function __construct(float $period = self::DEFAULT_PERIOD) + private ?ExcimerProfiler $excimerProfiler = null; + + /** + * @param float $period Sample period in seconds (default: 0.01) + */ + public function __construct(private float $period = self::DEFAULT_PERIOD) { - $this->period = $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 +76,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 +99,21 @@ public function stop(): void throw new RuntimeException('Profiler is not running.'); } + if ($this->excimerProfiler instanceof ExcimerProfiler) { + $this->excimerProfiler->stop(); + $excimerLog = $this->excimerProfiler->getLog(); + + try { + $this->foldedStacks = $excimerLog->formatFolded(); + } catch (Error) { + // formatFolded() is absent in some older Excimer builds. + $this->foldedStacks = ''; + } + + $this->log = $this->parseFoldedStacks($this->foldedStacks); + $this->excimerProfiler = null; + } + $this->running = false; } @@ -117,6 +150,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 +161,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 +183,57 @@ 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); + $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), + 'count' => $count, + ]; + } + + return $result; } } diff --git a/src/Storage/FileStorage.php b/src/Storage/FileStorage.php new file mode 100644 index 0000000..9b8def4 --- /dev/null +++ b/src/Storage/FileStorage.php @@ -0,0 +1,217 @@ + + * + * 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 readonly 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(); + + /** @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) { + 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) { + $rawEndpoint = $profile['endpoint']; + $ep = is_string($rawEndpoint) ? $rawEndpoint : ''; + + 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']++; + + $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 = $ep['request_count']; + $ep['avg_duration_ms'] = $count > 0 + ? round($ep['total_duration_ms'] / $count, 2) + : 0.0; + } + + unset($ep); + + usort( + $endpoints, + static fn (array $a, array $b): int => $b['total_samples'] - $a['total_samples'] + ); + + return $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..27f9cf5 --- /dev/null +++ b/stubs/ExcimerLog.php @@ -0,0 +1,51 @@ + + */ +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 + { + 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 @@ +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,14 +66,14 @@ public function testStopSetsNotRunning(): void { $this->profiler->start(); $this->profiler->stop(); - $this->assertFalse($this->profiler->isRunning()); + assertFalse($this->profiler->isRunning()); } public function testStartThrowsIfAlreadyRunning(): void { - $this->profiler->start(); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Profiler is already running.'); + $this->profiler->start(); try { $this->profiler->start(); @@ -87,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); @@ -100,20 +104,20 @@ 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 { - $this->profiler->start(); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Cannot reset while profiler is running.'); + $this->profiler->start(); try { $this->profiler->reset(); @@ -121,4 +125,38 @@ public function testResetThrowsIfRunning(): void $this->profiler->stop(); } } + + public function testGetFoldedStacksInitiallyEmpty(): void + { + assertSame('', $this->profiler->getFoldedStacks()); + } + + public function testGetFoldedStacksEmptyAfterStartStop(): void + { + // Without the excimer extension the folded stacks remain empty. + $this->profiler->start(); + $this->profiler->stop(); + assertSame('', $this->profiler->getFoldedStacks()); + } + + public function testResetClearsFoldedStacks(): void + { + $this->profiler->reset(); + assertSame('', $this->profiler->getFoldedStacks()); + } + + public function testStartClearsPreviousData(): void + { + // start() / stop() pair should always reset log and folded stacks. + $this->profiler->start(); + $this->profiler->stop(); + assertSame([], $this->profiler->getLog()); + assertSame('', $this->profiler->getFoldedStacks()); + + // A second start should clear them again. + $this->profiler->start(); + $this->profiler->stop(); + assertSame([], $this->profiler->getLog()); + assertSame('', $this->profiler->getFoldedStacks()); + } } diff --git a/tests/Storage/FileStorageTest.php b/tests/Storage/FileStorageTest.php new file mode 100644 index 0000000..0f774a7 --- /dev/null +++ b/tests/Storage/FileStorageTest.php @@ -0,0 +1,208 @@ + + * + * 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; + +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 + */ +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 + $rawFiles = glob($this->tmpDir . '/*.json'); + $files = $rawFiles !== false ? $rawFiles : []; + + 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); + + assertFileExists($this->tmpDir . '/p1.json'); + } + + public function testSaveCreatesDataDirIfMissing(): void + { + assertDirectoryDoesNotExist($this->tmpDir); + $this->storage->save($this->makeProfile('p1', '/api/users')); + 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); + + assertIsArray($data); + assertSame('/api/orders', $data['endpoint']); + assertSame('main;foo;bar 5', $data['folded_stacks']); + } + + // ── findAll() ───────────────────────────────────────────────────────────── + + public function testFindAllReturnsEmptyForEmptyDir(): void + { + mkdir($this->tmpDir, 0755, true); + 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(); + 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'); + assertCount(2, $users); + + foreach ($users as $p) { + assertSame('/api/users', $p['endpoint']); + } + } + + public function testFindByEndpointReturnsEmptyWhenNoMatch(): void + { + $this->storage->save($this->makeProfile('y1', '/api/users')); + 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(); + + 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']); + + 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); + 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); + + assertSame(2, $deleted); + $jsonFiles3 = glob($this->tmpDir . '/*.json'); + assertCount(3, $jsonFiles3 !== false ? $jsonFiles3 : []); + } + + public function testCleanupDoesNothingWhenUnderLimit(): void + { + $this->storage->save($this->makeProfile('f1', '/api/x')); + $this->storage->save($this->makeProfile('f2', '/api/x')); + + assertSame(0, $this->storage->cleanup(10)); + $jsonFiles2 = glob($this->tmpDir . '/*.json'); + assertCount(2, $jsonFiles2 !== false ? $jsonFiles2 : []); + } + + // ── 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, + ]; + } +}