Skip to content

Instantly share code, notes, and snippets.

@WangYihang
Created March 24, 2025 12:20
Show Gist options
  • Save WangYihang/5cc9c424b85b27add462991315056b56 to your computer and use it in GitHub Desktop.
Save WangYihang/5cc9c424b85b27add462991315056b56 to your computer and use it in GitHub Desktop.
PHP Coverage Collection
<?php
declare(strict_types=1);
/**
* Code Coverage Handler
*
* Handles code coverage collection using PCOV for individual requests
*/
class CoverageHandler
{
/** @var string Directory for storing coverage data */
private const COVERAGE_DIR = '/tmp/coverage';
/** @var string Filename for error logs */
private const ERROR_LOG_FILE = 'coverage_errors.log';
/** @var string[] Required subdirectories */
private const REQUIRED_SUBDIRECTORIES = ['requests', 'logs'];
/** @var string Unique identifier for the current request */
private string $requestId;
/** @var string Path to the current request's coverage file */
private string $requestCoverageFilePath;
/**
* Initialize the coverage handler
*
* @throws RuntimeException If initialization fails
*/
public function __construct()
{
try {
$this->requestId = self::generateUuid();
$this->initializePaths();
$this->ensureDirectoryStructure();
$this->startCoverage();
header("X-Request-ID: {$this->requestId}");
} catch (Exception $e) {
$this->logError($e, 'Initialization failed');
throw $e;
}
}
/**
* Generate a new UUID v4 with optimized performance
*
* @return string UUID v4 string
*/
private static function generateUuid(): string
{
$data = random_bytes(16);
// Set version to 0100
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
// Set bits 6-7 to 10
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
$hex = bin2hex($data);
return substr($hex, 0, 8) . '-' .
substr($hex, 8, 4) . '-' .
substr($hex, 12, 4) . '-' .
substr($hex, 16, 4) . '-' .
substr($hex, 20, 12);
}
/**
* Initialize file paths for coverage data
*/
private function initializePaths(): void
{
// Get CPU ticks for monotonically increasing value
$cpuTicks = hrtime(true); // nanosecond precision as integer
// Include CPU ticks in filename to ensure uniqueness
$this->requestCoverageFilePath = self::COVERAGE_DIR . '/requests/' . $cpuTicks . '_' . $this->requestId . '.json';
}
/**
* Ensure all required coverage directories exist
*
* @throws RuntimeException If directory creation fails
*/
private function ensureDirectoryStructure(): void
{
// Create base directory
self::createDirectoryIfNotExists(self::COVERAGE_DIR);
// Create subdirectories
foreach (self::REQUIRED_SUBDIRECTORIES as $subdir) {
self::createDirectoryIfNotExists(self::COVERAGE_DIR . '/' . $subdir);
}
}
/**
* Create directory if it doesn't exist
*
* @param string $dir Directory path
* @throws RuntimeException If directory creation fails
*/
private static function createDirectoryIfNotExists(string $dir): void
{
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
throw new RuntimeException("Failed to create directory: $dir");
}
}
/**
* Start code coverage collection
*
* @throws RuntimeException If PCOV extension is not loaded
*/
private function startCoverage(): void
{
if (!extension_loaded('pcov')) {
throw new RuntimeException('PCOV extension is not loaded');
}
@\pcov\start();
}
/**
* Collect and store coverage data
*/
public function dumpCoverage(): void
{
try {
@\pcov\stop();
$currentCoverage = @\pcov\collect();
// Save current request coverage
$this->saveRequestCoverage($currentCoverage);
} catch (Exception $e) {
$this->logError($e, 'Failed to dump coverage');
}
}
/**
* Save coverage data for current request with additional metadata
*
* @param array $coverage Raw coverage data from PCOV
* @throws RuntimeException If file writing fails
*/
private function saveRequestCoverage(array $coverage): void
{
$coverageData = [
'timestamp' => (int)(microtime(true) * 1000),
'superglobals' => $this->captureSuperglobals(),
'coverage' => $coverage
];
self::writeJsonFile($this->requestCoverageFilePath, $coverageData);
}
/**
* Capture relevant superglobals for debugging
*
* @return array Captured superglobals
*/
private function captureSuperglobals(): array
{
return [
'server' => $_SERVER,
'get' => $_GET,
'post' => $_POST,
'files' => $_FILES,
'cookie' => $_COOKIE,
'request' => $_REQUEST,
'env' => $_ENV,
];
}
/**
* Get the request ID
*
* @return string Current request ID
*/
public function getRequestId(): string
{
return $this->requestId;
}
/**
* Log error information in JSON format
*
* @param Exception $exception The exception to log
* @param string $context Context information about where the error occurred
*/
private function logError(Exception $exception, string $context = ''): void
{
$logFile = self::COVERAGE_DIR . '/logs/' . self::ERROR_LOG_FILE;
$errorData = [
'timestamp' => date('Y-m-d H:i:s'),
'context' => $context,
'message' => $exception->getMessage(),
'code' => $exception->getCode(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTraceAsString(),
'request_id' => $this->requestId ?? 'unknown',
'request_info' => $this->captureRequestInfo()
];
$json = @json_encode($errorData) . "\n";
@file_put_contents($logFile, $json, FILE_APPEND);
error_log('Coverage handler error: ' . $exception->getMessage());
}
/**
* Capture basic request information for debugging
*
* @return array Request information
*/
private function captureRequestInfo(): array
{
return [
'uri' => $_SERVER['REQUEST_URI'] ?? 'unknown',
'method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
'get' => $_GET,
'post' => $_POST,
];
}
/**
* Write data to a JSON file
*
* @param string $filePath Path to target file
* @param mixed $data Data to encode and write
* @throws RuntimeException If encoding or writing fails
*/
private static function writeJsonFile(string $filePath, $data): void
{
$json = @json_encode($data);
if ($json === false) {
throw new RuntimeException('Failed to encode JSON data: ' . json_last_error_msg());
}
if (@file_put_contents($filePath, $json) === false) {
throw new RuntimeException("Failed to write file: $filePath");
}
}
/**
* Static method to log errors occurring outside the class instance
*
* @param Exception $exception The exception to log
* @param string $context Context information
*/
public static function logStaticError(Exception $exception, string $context = ''): void
{
try {
$logDir = self::COVERAGE_DIR . '/logs';
self::createDirectoryIfNotExists(self::COVERAGE_DIR);
self::createDirectoryIfNotExists($logDir);
$logFile = $logDir . '/' . self::ERROR_LOG_FILE;
$errorData = [
'timestamp' => date('Y-m-d H:i:s'),
'context' => $context,
'message' => $exception->getMessage(),
'code' => $exception->getCode(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTraceAsString(),
'request_info' => [
'uri' => $_SERVER['REQUEST_URI'] ?? 'unknown',
'method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
'get' => $_GET,
'post' => $_POST,
]
];
$json = @json_encode($errorData) . "\n";
@file_put_contents($logFile, $json, FILE_APPEND);
} catch (Exception $e) {
// Last resort error reporting
error_log('Critical coverage handler error: ' . $e->getMessage());
}
error_log('Coverage handler error: ' . $exception->getMessage());
}
}
// Initialize coverage handler and register shutdown function
try {
if (basename($_SERVER['SCRIPT_FILENAME']) === 'coverage-viewer.php') {
return;
}
$coverageHandler = new CoverageHandler();
register_shutdown_function([$coverageHandler, 'dumpCoverage']);
} catch (Exception $e) {
CoverageHandler::logStaticError($e, 'Initialization error');
}
<?php
/**
* Coverage status constants for better readability.
*/
const COVERABLE_UNCOVERED = -1;
const UNCOVERABLE = 0;
const COVERABLE_COVERED = 1;
/**
* Renders coverage information as HTML.
* Refactored to improve readability and reuse of common logic.
*/
class CoverageRenderer
{
/**
* Render breadcrumb links for a given path.
*/
private function renderBreadcrumb(string $path): void
{
echo "<h2>";
$parts = explode(DIRECTORY_SEPARATOR, trim($path, DIRECTORY_SEPARATOR));
$link = '';
foreach ($parts as $index => $part) {
$link .= DIRECTORY_SEPARATOR . $part;
echo "<a href='?path=" . rawurlencode($link) . "'>" . htmlspecialchars($part, ENT_QUOTES) . "</a>";
if ($index < count($parts) - 1) {
echo DIRECTORY_SEPARATOR;
}
}
echo "</h2>";
}
/**
* Render basic coverage stats (lines, coverable, covered, percentage).
*/
private function renderCoverageStats(int $totalLines, int $totalCoverable, int $totalCovered): void
{
$coveragePercentage = $totalCoverable > 0 ? ($totalCovered / $totalCoverable) * 100 : 0;
echo "<p>Total lines: {$totalLines} | Coverable lines: {$totalCoverable} | ";
echo "Covered lines: {$totalCovered} | Coverage: " . number_format($coveragePercentage, 2) . "%</p>";
}
/**
* Return a color based on coverage percentage (red→green).
*/
private function getCoverageColor(float $percentage): string
{
$ratio = $percentage / 100;
$red = round(255 * (1 - $ratio));
$green = round(255 * $ratio);
return "rgb($red, $green, 0)";
}
/**
* Check if path is a directory.
*/
private function isDirectory(string $path): bool
{
return is_dir($path);
}
/**
* Render an entire directory view with coverage stats.
*/
public function renderDirectory(string $directory, array $data, array $folderStats): void
{
$this->renderBreadcrumb($directory);
$this->renderCoverageStats(
$folderStats['num_lines'],
$folderStats['num_coverable_lines'],
$folderStats['num_covered_lines']
);
$folders = [];
$files = [];
foreach ($data as $path => $coverage) {
$this->isDirectory($path) ? $folders[$path] = $coverage : $files[$path] = $coverage;
}
$sortedData = $folders + $files;
echo "<table border='1'>
<tr>
<th>#</th><th>Name</th><th>Total Lines</th><th>Coverable</th><th>Covered</th><th>Coverage</th>
</tr>";
$id = 1;
foreach ($sortedData as $path => $coverage) {
$name = basename($path);
$isDir = $this->isDirectory($path);
$numLines = $coverage["num_lines"];
$numCover = $coverage["num_coverable_lines"];
$numCovLines = $coverage["num_covered_lines"];
$covPercent = $numCover > 0 ? ($numCovLines / $numCover) * 100 : 0;
echo "<tr>
<td>$id</td>
<td>" . ($isDir ? "[DIR] " : "") . "<a href='?path=" . rawurlencode($path) . "'>" .
htmlspecialchars($name, ENT_QUOTES) . "</a></td>";
if ($numCover <= 0) {
echo "<td style='background-color: grey'>N/A</td>
<td style='background-color: grey'>N/A</td>
<td style='background-color: grey'>N/A</td>
<td style='background-color: grey'>N/A</td>";
} else {
echo "<td>$numLines</td>
<td>$numCover</td>
<td>$numCovLines</td>
<td style='background-color: " . $this->getCoverageColor($covPercent) . "'>" .
number_format($covPercent, 2) . "%</td>";
}
echo "</tr>";
$id++;
}
echo "</table>";
}
/**
* Render coverage for a single file.
*/
public function renderFile(string $path, array $coverage): void
{
$this->renderBreadcrumb($path);
$this->renderCoverageStats(
$coverage['num_lines'],
$coverage['num_coverable_lines'],
$coverage['num_covered_lines']
);
$coverageData = $coverage["coverage"];
$lines = explode("\n", file_get_contents($path));
echo "<table border='1'><tr><th>Line #</th><th>Status</th><th>Code</th></tr>";
foreach ($lines as $num => $line) {
$lineNumber = $num + 1;
$status = $coverageData[$lineNumber] ?? UNCOVERABLE;
$bgColor = match ($status) {
COVERABLE_COVERED => '#c8ffc8',
COVERABLE_UNCOVERED => '#ffc8c8',
default => 'white',
};
$statusText = match ($status) {
COVERABLE_COVERED => 'Covered',
COVERABLE_UNCOVERED => 'Uncovered',
default => 'Uncoverable',
};
echo "<tr style='background: $bgColor'>
<td>$lineNumber</td>
<td>$statusText</td>
<td><pre>" . htmlspecialchars($line, ENT_QUOTES) . "</pre></td>
</tr>";
}
echo "</table>";
}
}
/**
* Main class that analyzes and displays file or directory coverage.
*/
class CoverageViewer
{
private const CACHE_DIR = '/tmp/coverage/cache';
private array $fileAnalysisCache = [];
public function __construct(
private readonly string $basePath = '/app/',
private readonly CoverageRenderer $renderer = new CoverageRenderer(),
private readonly PhpdbgAnalyzer $phpdbgAnalyzer = new PhpdbgAnalyzer(),
private readonly FileSystem $fileSystem = new FileSystem()
) {
$this->fileSystem->ensureDirectoryExists(self::CACHE_DIR);
$this->loadCoverageFiles();
$this->saveCoverageFiles();
}
public function loadCoverageFiles() {
foreach ($this->fileSystem->findFiles($this->basePath, 'php') as $path) {
$this->fileAnalysisCache[$path] = $this->analyzePhpFile($path);
}
foreach ($this->fileSystem->findFiles("/tmp/coverage/requests/", "json") as $coverageFilePath) {
$data = json_decode($this->fileSystem->read($coverageFilePath), true);
$coverage = $data["coverage"] ?? [];
foreach ($coverage as $path => $lines) {
foreach ($lines as $line => $val) {
if ($val === COVERABLE_COVERED) {
$this->fileAnalysisCache[$path]["coverage"][$line] = COVERABLE_COVERED;
}
}
$this->fileAnalysisCache[$path]["num_covered_lines"] = count(array_filter(
$this->fileAnalysisCache[$path]["coverage"], fn($l) => $l === COVERABLE_COVERED
));
$this->fileAnalysisCache[$path]["num_uncovered_lines"] = count(array_filter(
$this->fileAnalysisCache[$path]["coverage"], fn($l) => $l === COVERABLE_UNCOVERED
));
}
}
}
public function saveCoverageFiles() {
$this->fileSystem->ensureDirectoryExists(self::CACHE_DIR);
$data = [];
foreach ($this->fileAnalysisCache as $path => $coverage) {
$data[$path] = $coverage["coverage"];
}
$this->fileSystem->write(self::CACHE_DIR . '/coverage.json', json_encode($data));
}
public function display(): void
{
$this->renderResults();
}
/**
* Collect coverage info for a single PHP file.
*/
private function analyzePhpFile(string $file): array
{
$result = [
"num_lines" => 0,
"num_coverable_lines" => 0,
"num_covered_lines" => 0,
"num_uncovered_lines" => 0,
"coverage" => [],
];
if (str_starts_with(realpath($file), "{$this->basePath}/vendor/")) {
return $result;
}
$coverableLines = $this->phpdbgAnalyzer->getCoverableLines($file);
$numLines = count(explode("\n", $this->fileSystem->read($file)));
$coverage = array_fill(1, $numLines, UNCOVERABLE);
foreach ($coverableLines as $line) {
if (isset($coverage[$line])) {
$coverage[$line] = COVERABLE_UNCOVERED;
}
}
$numCovered = count(array_filter($coverage, fn($c) => $c === COVERABLE_COVERED));
$numUncovered = count(array_filter($coverage, fn($c) => $c === COVERABLE_UNCOVERED));
$result["num_lines"] = $numLines;
$result["num_coverable_lines"] = count($coverableLines);
$result["num_covered_lines"] = $numCovered;
$result["num_uncovered_lines"] = $numUncovered;
$result["coverage"] = $coverage;
return $result;
}
/**
* Display coverage for the requested path.
*/
private function renderResults(): void
{
$path = $_GET['path'] ?? $this->basePath;
if (is_file($path)) {
$coverage = $this->fileAnalysisCache[realpath($path)] ?? [];
$this->renderer->renderFile($path, $coverage);
return;
}
if (is_dir($path)) {
$folderStat = ["num_lines"=>0,"num_coverable_lines"=>0,"num_covered_lines"=>0,"num_uncovered_lines"=>0];
$data = [];
foreach ($this->fileSystem->listFiles($path) as $file) {
if (is_file($file)) {
$isPhpFile = strtolower(pathinfo($file, PATHINFO_EXTENSION)) === 'php';
$coverage = $this->fileAnalysisCache[$file] ?? [
"num_lines" => $isPhpFile ? count(explode("\n", file_get_contents($file))) : 0,
"num_coverable_lines" => 0,
"num_covered_lines" => 0,
"num_uncovered_lines" => 0,
"coverage" => [],
];
$data[$file] = $coverage;
}
if (is_dir($file)) {
$analyzed = $this->analysisFolder($file);
$data[$file] = $analyzed;
}
}
// Accumulate stats
foreach ($data as $vc) {
$folderStat["num_lines"] += $vc["num_lines"];
$folderStat["num_coverable_lines"] += $vc["num_coverable_lines"];
$folderStat["num_covered_lines"] += $vc["num_covered_lines"];
$folderStat["num_uncovered_lines"] += $vc["num_uncovered_lines"];
}
$this->renderer->renderDirectory($path, $data, $folderStat);
}
}
/**
* Summarize coverage for all PHP files in a folder.
*/
private function analysisFolder(string $folder): array
{
$sum = ["num_lines"=>0,"num_coverable_lines"=>0,"num_covered_lines"=>0,"num_uncovered_lines"=>0];
foreach ($this->fileSystem->findFiles($folder, "php") as $file) {
if (!isset($this->fileAnalysisCache[$file])) continue;
$fc = $this->fileAnalysisCache[$file];
$sum["num_lines"] += $fc["num_lines"];
$sum["num_coverable_lines"] += $fc["num_coverable_lines"];
$sum["num_covered_lines"] += $fc["num_covered_lines"];
$sum["num_uncovered_lines"] += $fc["num_uncovered_lines"];
}
return $sum;
}
}
/**
* Uses phpdbg to determine coverable lines in a PHP file.
*/
class PhpdbgAnalyzer
{
public function getCoverableLines(string $path): array
{
$output = $this->executePhpdbg($path);
return $this->parsePhpdbgOutput($output);
}
private function executePhpdbg(string $path): string
{
$descriptorspec = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open("phpdbg -p=* $path", $descriptorspec, $pipes);
if (!is_resource($process)) {
throw new RuntimeException("Failed to execute phpdbg");
}
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[2]);
proc_close($process);
return $stderr;
}
private function parsePhpdbgOutput(string $output): array
{
$coverableLines = [];
foreach (explode("\n", $output) as $line) {
if (preg_match('/L(\d+) \d+ /', $line, $m)) {
$coverableLines[] = (int)$m[1];
}
}
return array_unique($coverableLines);
}
}
/**
* Handles basic file operations.
*/
class FileSystem
{
public function ensureDirectoryExists(string $path): void
{
if (!file_exists($path)) {
mkdir($path, 0777, true);
}
}
public function exists(string $path): bool
{
return file_exists($path);
}
public function read(string $path): string
{
return file_get_contents($path);
}
public function write(string $path, string $content): void
{
file_put_contents($path, $content);
}
public function listFiles(string $directory): array
{
$files = [];
$iterator = new DirectoryIterator($directory);
foreach ($iterator as $fileinfo) {
if (!$fileinfo->isDot()) {
$real = realpath($fileinfo->getPathname());
if ($real !== false) $files[] = $real;
}
}
return $files;
}
public function findFiles(string $directory, string $ext): array
{
$files = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS)
);
$ignoreDirs = ['/node_modules/', '/vendor/', '/tests/', '/test/'];
foreach ($iterator as $item) {
if (!$item->isFile() || strtolower($item->getExtension()) !== $ext) {
continue;
}
$path = $item->getPathname();
$skip = false;
foreach ($ignoreDirs as $dir) {
if (str_contains($path, $dir)) {
$skip = true;
break;
}
}
if (!$skip) {
$files[] = $path;
}
}
return $files;
}
}
// Entry point
try {
(new CoverageViewer())->display();
} catch (Exception $e) {
error_log("Coverage viewer error: " . $e->getMessage());
die('Error processing coverage data.');
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment