diff --git a/app/modules/Profiler/Application/Handlers/CalculateDiffsBetweenEdges.php b/app/modules/Profiler/Application/Handlers/CalculateDiffsBetweenEdges.php index c59db206..08395ce5 100644 --- a/app/modules/Profiler/Application/Handlers/CalculateDiffsBetweenEdges.php +++ b/app/modules/Profiler/Application/Handlers/CalculateDiffsBetweenEdges.php @@ -5,6 +5,7 @@ namespace Modules\Profiler\Application\Handlers; use Modules\Profiler\Application\EventHandlerInterface; +use Modules\Profiler\Application\MetricsHelper; // TODO: fix diff calculation final class CalculateDiffsBetweenEdges implements EventHandlerInterface @@ -18,16 +19,21 @@ public function handle(array $event): array [$parent, $func] = $this->splitName($name); if ($parent) { - $parentValues = $parents[$parent] ?? ['cpu' => 0, 'wt' => 0, 'mu' => 0, 'pmu' => 0]; + $parentValues = $parents[$parent] ?? MetricsHelper::getAllMetrics([]); + + // Use MetricsHelper to safely access metrics with defaults + $currentMetrics = MetricsHelper::getAllMetrics($values); + $event['profile'][$name] = \array_merge([ - 'd_cpu' => $parentValues['cpu'] - $values['cpu'], - 'd_wt' => $parentValues['wt'] - $values['wt'], - 'd_mu' => $parentValues['mu'] - $values['mu'], - 'd_pmu' => $parentValues['pmu'] - $values['pmu'], + 'd_cpu' => $parentValues['cpu'] - $currentMetrics['cpu'], + 'd_wt' => $parentValues['wt'] - $currentMetrics['wt'], + 'd_mu' => $parentValues['mu'] - $currentMetrics['mu'], + 'd_pmu' => $parentValues['pmu'] - $currentMetrics['pmu'], ], $values); } - $parents[$func] = $values; + // Store normalized metrics for parent lookup + $parents[$func] = MetricsHelper::getAllMetrics($values); } return $event; diff --git a/app/modules/Profiler/Application/Handlers/PrepareEdges.php b/app/modules/Profiler/Application/Handlers/PrepareEdges.php index c883c6db..0becc468 100644 --- a/app/modules/Profiler/Application/Handlers/PrepareEdges.php +++ b/app/modules/Profiler/Application/Handlers/PrepareEdges.php @@ -5,6 +5,7 @@ namespace Modules\Profiler\Application\Handlers; use Modules\Profiler\Application\EventHandlerInterface; +use Modules\Profiler\Application\MetricsHelper; final class PrepareEdges implements EventHandlerInterface { @@ -23,9 +24,16 @@ public function handle(array $event): array $parentId = $parents[$parent] ?? $prev; + // Normalize metrics to ensure all required fields are present + $normalizedValues = MetricsHelper::getAllMetrics($values); + + // Calculate percentages with safe metric access foreach (['cpu', 'mu', 'pmu', 'wt'] as $key) { + $peakValue = MetricsHelper::getMetric($event['peaks'], $key); $values['p_' . $key] = \round( - $values[$key] > 0 ? ($values[$key]) / $event['peaks'][$key] * 100 : 0, + $normalizedValues[$key] > 0 && $peakValue > 0 + ? ($normalizedValues[$key]) / $peakValue * 100 + : 0, 3, ); } diff --git a/app/modules/Profiler/Application/Handlers/PreparePeaks.php b/app/modules/Profiler/Application/Handlers/PreparePeaks.php index 1aa6a147..2b8713b9 100644 --- a/app/modules/Profiler/Application/Handlers/PreparePeaks.php +++ b/app/modules/Profiler/Application/Handlers/PreparePeaks.php @@ -5,6 +5,7 @@ namespace Modules\Profiler\Application\Handlers; use Modules\Profiler\Application\EventHandlerInterface; +use Modules\Profiler\Application\MetricsHelper; final class PreparePeaks implements EventHandlerInterface { @@ -12,13 +13,11 @@ public function handle(array $event): array { // TODO: fix peaks calculation // @see \Modules\Profiler\Interfaces\Queries\FindTopFunctionsByUuidHandler:$overallTotals - $event['peaks'] = $event['profile']['main()'] ?? [ - 'wt' => 0, - 'ct' => 0, - 'mu' => 0, - 'pmu' => 0, - 'cpu' => 0, - ]; + + // Get main() metrics or use defaults if not available + $mainMetrics = $event['profile']['main()'] ?? []; + + $event['peaks'] = MetricsHelper::getAllMetrics($mainMetrics); return $event; } diff --git a/app/modules/Profiler/Application/MetricsHelper.php b/app/modules/Profiler/Application/MetricsHelper.php new file mode 100644 index 00000000..9aee3fdd --- /dev/null +++ b/app/modules/Profiler/Application/MetricsHelper.php @@ -0,0 +1,60 @@ + 0, + 'wt' => 0, + 'mu' => 0, + 'pmu' => 0, + 'ct' => 0, + ]; + + /** + * Get metric value with fallback to default if missing + */ + public static function getMetric(array $data, string $metric): int|float + { + return $data[$metric] ?? self::DEFAULT_METRICS[$metric] ?? 0; + } + + /** + * Normalize metrics array by ensuring all required metrics are present + */ + public static function normalizeMetrics(array $metrics): array + { + return \array_merge(self::DEFAULT_METRICS, $metrics); + } + + /** + * Get all available metrics from data, with defaults for missing ones + */ + public static function getAllMetrics(array $data): array + { + return [ + 'cpu' => self::getMetric($data, 'cpu'), + 'wt' => self::getMetric($data, 'wt'), + 'mu' => self::getMetric($data, 'mu'), + 'pmu' => self::getMetric($data, 'pmu'), + 'ct' => self::getMetric($data, 'ct'), + ]; + } + + /** + * Check if any CPU-related metrics are available + */ + public static function hasCpuMetrics(array $data): bool + { + return isset($data['cpu']) && $data['cpu'] > 0; + } +} diff --git a/app/modules/Profiler/Application/ProfilerBootloader.php b/app/modules/Profiler/Application/ProfilerBootloader.php index e5d6edc0..874ac0c9 100644 --- a/app/modules/Profiler/Application/ProfilerBootloader.php +++ b/app/modules/Profiler/Application/ProfilerBootloader.php @@ -11,6 +11,7 @@ use Modules\Profiler\Application\Handlers\PrepareEdges; use Modules\Profiler\Application\Handlers\PreparePeaks; use Modules\Profiler\Application\Handlers\StoreProfile; +use Modules\Profiler\Application\Service\FunctionMetricsCalculator; use Modules\Profiler\Domain\EdgeFactoryInterface; use Modules\Profiler\Domain\ProfileFactoryInterface; use Modules\Profiler\Integration\CycleOrm\EdgeFactory; @@ -30,6 +31,7 @@ public function defineSingletons(): array return [ ProfileFactoryInterface::class => ProfileFactory::class, EdgeFactoryInterface::class => EdgeFactory::class, + EventHandlerInterface::class => static fn( ContainerInterface $container, ): EventHandlerInterface => new EventHandler($container, [ @@ -40,7 +42,6 @@ public function defineSingletons(): array StoreProfile::class, ]), - StoreProfile::class => static fn( FactoryInterface $factory, QueueConnectionProviderInterface $provider, diff --git a/app/modules/Profiler/Application/Service/FunctionMetricsCalculator.php b/app/modules/Profiler/Application/Service/FunctionMetricsCalculator.php new file mode 100644 index 00000000..ff7c404e --- /dev/null +++ b/app/modules/Profiler/Application/Service/FunctionMetricsCalculator.php @@ -0,0 +1,129 @@ +initializeOverallTotals(); + + // First pass: aggregate inclusive metrics per function + foreach ($edges as $edge) { + $functionName = $edge->getCallee(); + + if (!isset($functions[$functionName])) { + $functions[$functionName] = FunctionMetrics::fromEdge($edge); + } else { + $functions[$functionName] = $functions[$functionName]->addEdge($edge); + } + } + + // Calculate overall totals from main() function or first function + $overallTotals = $this->calculateOverallTotals($functions); + + // Second pass: calculate exclusive metrics by subtracting child costs + $functions = $this->calculateExclusiveMetrics($functions, $edges); + + return [$functions, $overallTotals]; + } + + /** + * Sort functions by specified metric + * + * @param FunctionMetrics[] $functions + */ + public function sortFunctions(array $functions, string $sortMetric): array + { + usort( + $functions, + static fn(FunctionMetrics $a, FunctionMetrics $b) => $b->getMetricForSort( + $sortMetric, + ) <=> $a->getMetricForSort($sortMetric), + ); + + return $functions; + } + + /** + * Convert function metrics to array format for API response + * + * @param FunctionMetrics[] $functions + */ + public function toArrayFormat(array $functions, Cost $overallTotals): array + { + return array_map( + fn(FunctionMetrics $metrics) => $metrics->toArray($overallTotals), + $functions, + ); + } + + private function initializeOverallTotals(): Cost + { + return new Cost(cpu: 0, wt: 0, ct: 0, mu: 0, pmu: 0); + } + + /** + * @param FunctionMetrics[] $functions + */ + private function calculateOverallTotals(array $functions): Cost + { + // Try to get totals from main() function first + if (isset($functions['main()'])) { + return $functions['main()']->inclusive; + } + + // If no main(), calculate from all functions (less accurate but workable) + $totals = $this->initializeOverallTotals(); + + foreach ($functions as $function) { + // Only add call counts, other metrics should not be summed across functions + $totals = new Cost( + cpu: max($totals->cpu, $function->inclusive->cpu), + wt: max($totals->wt, $function->inclusive->wt), + ct: $totals->ct + $function->inclusive->ct, + mu: max($totals->mu, $function->inclusive->mu), + pmu: max($totals->pmu, $function->inclusive->pmu), + ); + } + + return $totals; + } + + /** + * Calculate exclusive metrics by subtracting child function costs + * + * @param FunctionMetrics[] $functions + * @param Edge[] $edges + * @return FunctionMetrics[] + */ + private function calculateExclusiveMetrics(array $functions, array $edges): array + { + // Build parent-child relationships and subtract child costs + foreach ($edges as $edge) { + $caller = $edge->getCaller(); + + if ($caller && isset($functions[$caller])) { + $functions[$caller] = $functions[$caller]->subtractChild($edge->getCost()); + } + } + + return $functions; + } +} diff --git a/app/modules/Profiler/Domain/Edge/Cost.php b/app/modules/Profiler/Domain/Edge/Cost.php index cc381ae4..5a1c6586 100644 --- a/app/modules/Profiler/Domain/Edge/Cost.php +++ b/app/modules/Profiler/Domain/Edge/Cost.php @@ -22,4 +22,77 @@ public function __construct( #[Column(type: 'integer')] public int $pmu, ) {} + + /** + * Get metric value by name, with safe fallback to 0 + */ + public function getMetric(string $metric): int + { + return match ($metric) { + 'cpu' => $this->cpu, + 'wt' => $this->wt, + 'ct' => $this->ct, + 'mu' => $this->mu, + 'pmu' => $this->pmu, + default => 0, + }; + } + + /** + * Get all metrics as associative array + */ + public function toArray(): array + { + return [ + 'cpu' => $this->cpu, + 'wt' => $this->wt, + 'ct' => $this->ct, + 'mu' => $this->mu, + 'pmu' => $this->pmu, + ]; + } + + /** + * Add another Cost to this one (for aggregation) + */ + public function add(Cost $other): Cost + { + return new self( + cpu: $this->cpu + $other->cpu, + wt: $this->wt + $other->wt, + ct: $this->ct + $other->ct, + mu: $this->mu + $other->mu, + pmu: $this->pmu + $other->pmu, + ); + } + + /** + * Subtract another Cost from this one + */ + public function subtract(Cost $other): Cost + { + return new self( + cpu: max(0, $this->cpu - $other->cpu), + wt: max(0, $this->wt - $other->wt), + ct: max(0, $this->ct - $other->ct), + mu: max(0, $this->mu - $other->mu), + pmu: max(0, $this->pmu - $other->pmu), + ); + } + + /** + * Check if this cost has any CPU metrics + */ + public function hasCpuMetrics(): bool + { + return $this->cpu > 0; + } + + /** + * Create a new Cost with only exclusive metrics (all metrics minus given cost) + */ + public function getExclusive(Cost $inclusive): Cost + { + return $this->subtract($inclusive); + } } diff --git a/app/modules/Profiler/Domain/Edge/Diff.php b/app/modules/Profiler/Domain/Edge/Diff.php index 1802ed67..0ac50c14 100644 --- a/app/modules/Profiler/Domain/Edge/Diff.php +++ b/app/modules/Profiler/Domain/Edge/Diff.php @@ -24,4 +24,47 @@ public function __construct( #[Column(type: 'integer')] public int $pmu, ) {} + + /** + * Get diff metric value by name, with safe fallback to 0 + */ + public function getMetric(string $metric): int + { + return match ($metric) { + 'cpu' => $this->cpu, + 'wt' => $this->wt, + 'ct' => $this->ct, + 'mu' => $this->mu, + 'pmu' => $this->pmu, + default => 0, + }; + } + + /** + * Get all diff metrics as associative array + */ + public function toArray(): array + { + return [ + 'cpu' => $this->cpu, + 'wt' => $this->wt, + 'ct' => $this->ct, + 'mu' => $this->mu, + 'pmu' => $this->pmu, + ]; + } + + /** + * Calculate diff from two Cost objects + */ + public static function fromCosts(Cost $parent, Cost $current): self + { + return new self( + cpu: $parent->cpu - $current->cpu, + wt: $parent->wt - $current->wt, + ct: $parent->ct - $current->ct, + mu: $parent->mu - $current->mu, + pmu: $parent->pmu - $current->pmu, + ); + } } diff --git a/app/modules/Profiler/Domain/Edge/Percents.php b/app/modules/Profiler/Domain/Edge/Percents.php index 6d17c658..54ce254e 100644 --- a/app/modules/Profiler/Domain/Edge/Percents.php +++ b/app/modules/Profiler/Domain/Edge/Percents.php @@ -24,4 +24,50 @@ public function __construct( #[Column(type: 'float')] public float $pmu, ) {} + + /** + * Get percentage metric value by name, with safe fallback to 0.0 + */ + public function getMetric(string $metric): float + { + return match ($metric) { + 'cpu' => $this->cpu, + 'wt' => $this->wt, + 'ct' => $this->ct, + 'mu' => $this->mu, + 'pmu' => $this->pmu, + default => 0.0, + }; + } + + /** + * Get all percentage metrics as associative array + */ + public function toArray(): array + { + return [ + 'cpu' => $this->cpu, + 'wt' => $this->wt, + 'ct' => $this->ct, + 'mu' => $this->mu, + 'pmu' => $this->pmu, + ]; + } + + /** + * Calculate percentages from cost values and totals + */ + public static function fromCost(Cost $cost, Cost $totals): self + { + $calculatePercent = static fn(int $value, int $total): float => + $value > 0 && $total > 0 ? round($value / $total * 100, 3) : 0.0; + + return new self( + cpu: $calculatePercent($cost->cpu, $totals->cpu), + wt: $calculatePercent($cost->wt, $totals->wt), + ct: $calculatePercent($cost->ct, $totals->ct), + mu: $calculatePercent($cost->mu, $totals->mu), + pmu: $calculatePercent($cost->pmu, $totals->pmu), + ); + } } diff --git a/app/modules/Profiler/Domain/FunctionMetrics.php b/app/modules/Profiler/Domain/FunctionMetrics.php new file mode 100644 index 00000000..9084850f --- /dev/null +++ b/app/modules/Profiler/Domain/FunctionMetrics.php @@ -0,0 +1,109 @@ +getCallee(), + inclusive: $edge->getCost(), + exclusive: $edge->getCost(), // Initially same as inclusive + ); + } + + /** + * Add metrics from another edge call to the same function + */ + public function addEdge(Edge $edge): self + { + return new self( + function: $this->function, + inclusive: $this->inclusive->add($edge->getCost()), + exclusive: $this->exclusive->add($edge->getCost()), + ); + } + + /** + * Subtract child function costs from exclusive metrics + */ + public function subtractChild(Cost $childCost): self + { + return new self( + function: $this->function, + inclusive: $this->inclusive, + exclusive: $this->exclusive->subtract($childCost), + ); + } + + /** + * Get metric value for sorting + */ + public function getMetricForSort(string $metric): int + { + // Handle exclusive metrics + if (str_starts_with($metric, 'excl_')) { + $baseMetric = substr($metric, 5); + return $this->exclusive->getMetric($baseMetric); + } + + return $this->inclusive->getMetric($metric); + } + + /** + * Convert to array format expected by the frontend + */ + public function toArray(Cost $overallTotals): array + { + $result = [ + 'function' => $this->function, + ...$this->inclusive->toArray(), + ]; + + // Add exclusive metrics + foreach (['cpu', 'wt', 'mu', 'pmu', 'ct'] as $metric) { + $result['excl_' . $metric] = $this->exclusive->getMetric($metric); + } + + // Calculate percentages + foreach (['cpu', 'wt', 'mu', 'pmu', 'ct'] as $metric) { + $totalValue = $overallTotals->getMetric($metric); + + $result['p_' . $metric] = $this->calculatePercentage( + $this->inclusive->getMetric($metric), + $totalValue, + ); + + $result['p_excl_' . $metric] = $this->calculatePercentage( + $this->exclusive->getMetric($metric), + $totalValue, + ); + } + + return $result; + } + + private function calculatePercentage(int $value, int $total): float + { + return $value > 0 && $total > 0 + ? round($value / $total * 100, 3) + : 0.0; + } +} diff --git a/app/modules/Profiler/Interfaces/Jobs/StoreProfileHandler.php b/app/modules/Profiler/Interfaces/Jobs/StoreProfileHandler.php index 4ecac933..359f77f2 100644 --- a/app/modules/Profiler/Interfaces/Jobs/StoreProfileHandler.php +++ b/app/modules/Profiler/Interfaces/Jobs/StoreProfileHandler.php @@ -10,6 +10,7 @@ use App\Application\Domain\ValueObjects\Uuid; use Cycle\ORM\EntityManagerInterface; use Cycle\ORM\ORMInterface; +use Modules\Profiler\Application\MetricsHelper; use Modules\Profiler\Application\Query\FindTopFunctionsByUuid; use Modules\Profiler\Domain\EdgeFactoryInterface; use Modules\Profiler\Domain\Profile; @@ -48,30 +49,34 @@ public function invoke(array $payload): void $batchSize = 0; $i = 0; foreach ($edges as $id => $edge) { + // Use safe metric access with defaults for missing values + $cost = $edge['cost'] ?? []; + $normalizedCost = MetricsHelper::getAllMetrics($cost); + $this->em->persist( - $edge = $this->edgeFactory->create( + entity: $edge = $this->edgeFactory->create( profileUuid: $profileUuid, order: $i++, cost: new Cost( - cpu: $edge['cost']['cpu'] ?? 0, - wt: $edge['cost']['wt'] ?? 0, - ct: $edge['cost']['ct'] ?? 0, - mu: $edge['cost']['mu'] ?? 0, - pmu: $edge['cost']['pmu'] ?? 0, + cpu: $normalizedCost['cpu'], + wt: $normalizedCost['wt'], + ct: $normalizedCost['ct'], + mu: $normalizedCost['mu'], + pmu: $normalizedCost['pmu'], ), diff: new Diff( - cpu: $edge['cost']['d_cpu'] ?? 0, - wt: $edge['cost']['d_wt'] ?? 0, - ct: $edge['cost']['d_ct'] ?? 0, - mu: $edge['cost']['d_mu'] ?? 0, - pmu: $edge['cost']['d_pmu'] ?? 0, + cpu: MetricsHelper::getMetric($cost, 'd_cpu'), + wt: MetricsHelper::getMetric($cost, 'd_wt'), + ct: MetricsHelper::getMetric($cost, 'd_ct'), + mu: MetricsHelper::getMetric($cost, 'd_mu'), + pmu: MetricsHelper::getMetric($cost, 'd_pmu'), ), percents: new Percents( - cpu: $edge['cost']['p_cpu'] ?? 0, - wt: $edge['cost']['p_wt'] ?? 0, - ct: $edge['cost']['p_ct'] ?? 0, - mu: $edge['cost']['p_mu'] ?? 0, - pmu: $edge['cost']['p_pmu'] ?? 0, + cpu: (float) MetricsHelper::getMetric($cost, 'p_cpu'), + wt: (float) MetricsHelper::getMetric($cost, 'p_wt'), + ct: (float) MetricsHelper::getMetric($cost, 'p_ct'), + mu: (float) MetricsHelper::getMetric($cost, 'p_mu'), + pmu: (float) MetricsHelper::getMetric($cost, 'p_pmu'), ), callee: $edge['callee'], caller: $edge['caller'], @@ -92,8 +97,11 @@ public function invoke(array $payload): void $profile = $this->orm->getRepository(Profile::class)->findByPK($profileUuid); $functions = $this->bus->ask(new FindTopFunctionsByUuid(profileUuid: $profileUuid)); + // Safely update peaks with normalized metrics foreach ($functions['overall_totals'] as $metric => $value) { - $profile->getPeaks()->{$metric} = $value; + if (\property_exists($profile->getPeaks(), $metric)) { + $profile->getPeaks()->{$metric} = $value; + } } $this->em->persist($profile); diff --git a/app/modules/Profiler/Interfaces/Queries/FindTopFunctionsByUuidHandler.php b/app/modules/Profiler/Interfaces/Queries/FindTopFunctionsByUuidHandler.php index 60998c5c..e6956c64 100644 --- a/app/modules/Profiler/Interfaces/Queries/FindTopFunctionsByUuidHandler.php +++ b/app/modules/Profiler/Interfaces/Queries/FindTopFunctionsByUuidHandler.php @@ -4,17 +4,18 @@ namespace Modules\Profiler\Interfaces\Queries; +use Modules\Profiler\Domain\Edge; use Cycle\ORM\ORMInterface; use Modules\Profiler\Application\Query\FindTopFunctionsByUuid; -use Modules\Profiler\Domain\Edge; +use Modules\Profiler\Application\Service\FunctionMetricsCalculator; use Modules\Profiler\Domain\Profile; use Spiral\Cqrs\Attribute\QueryHandler; -// TODO: refactor this, use repository -final class FindTopFunctionsByUuidHandler +final readonly class FindTopFunctionsByUuidHandler { public function __construct( private ORMInterface $orm, + private FunctionMetricsCalculator $calculator, ) {} #[QueryHandler] @@ -22,247 +23,191 @@ public function __invoke(FindTopFunctionsByUuid $query): array { $profile = $this->orm->getRepository(Profile::class)->findByPK($query->profileUuid); - $overallTotals = []; - - $functions = []; - /** @var Edge[] $edges */ - $edges = $profile->edges; - - $metrics = ['cpu', 'ct', 'wt', 'mu', 'pmu']; - - foreach ($metrics as $metric) { - $overallTotals[$metric] = 0; - } - - foreach ($edges as $edge) { - if (!isset($functions[$edge->getCallee()])) { - $functions[$edge->getCallee()] = [ - 'function' => $edge->getCallee(), - ]; - - foreach ($metrics as $metric) { - $functions[$edge->getCallee()][$metric] = $edge->getCost()->{$metric}; - } - continue; - } - - foreach ($metrics as $metric) { - $overallTotals[$metric] = $functions['main()'][$metric]; - } - } + $edges = $profile->edges->toArray(); - foreach ($functions as $function => $m) { - foreach ($metrics as $metric) { - $functions[$function]['excl_' . $metric] = $functions[$function][$metric]; - } + // Calculate function metrics using domain service + [$functions, $overallTotals] = $this->calculator->calculateMetrics($edges); - $overallTotals['ct'] += $m['ct']; - } + // Sort functions by requested metric + $sortedFunctions = $this->calculator->sortFunctions($functions, $query->metric->value); - foreach ($edges as $edge) { - if (!$edge->getCaller()) { - continue; - } + // Limit results + $limitedFunctions = array_slice($sortedFunctions, 0, $query->limit); - foreach ($metrics as $metric) { - $field = 'excl_' . $metric; + // Convert to API format + $functionsArray = $this->calculator->toArrayFormat($limitedFunctions, $overallTotals); - if (!isset($functions[$edge->getCaller()][$field])) { - $functions[$edge->getCaller()][$field] = 0; - } - - $functions[$edge->getCaller()][$field] -= $edge->getCost()->{$metric}; - - if ($functions[$edge->getCaller()][$field] < 0) { - $functions[$edge->getCaller()][$field] = 0; - } - } - } - - $sortMetric = $query->metric->value; - \usort($functions, static fn(array $a, array $b) => ($b[$sortMetric] ?? 0) <=> ($a[$sortMetric] ?? 0)); - - $functions = \array_slice($functions, 0, $query->limit); - - foreach (array_keys($functions) as $function) { - foreach ($metrics as $metric) { - $functions[$function]['p_' . $metric] = \round( - $functions[$function][$metric] > 0 ? $functions[$function][$metric] / $overallTotals[$metric] * 100 : 0, - 3, - ); - $functions[$function]['p_excl_' . $metric] = \round( - $functions[$function]['excl_' . $metric] > 0 ? $functions[$function]['excl_' . $metric] / $overallTotals[$metric] * 100 : 0, - 3, - ); - } - } + return [ + 'schema' => $this->buildSchema(), + 'overall_totals' => $overallTotals->toArray(), + 'functions' => $functionsArray, + ]; + } + private function buildSchema(): array + { return [ - 'schema' => [ - [ - 'key' => 'function', - 'label' => 'Function', - 'description' => 'Function that was called', - 'sortable' => false, - 'values' => [ - [ - 'key' => 'function', - 'format' => 'string', - ], + [ + 'key' => 'function', + 'label' => 'Function', + 'description' => 'Function that was called', + 'sortable' => false, + 'values' => [ + [ + 'key' => 'function', + 'format' => 'string', ], ], - [ - 'key' => 'ct', - 'label' => 'CT', - 'description' => 'Calls', - 'sortable' => true, - 'values' => [ - [ - 'key' => 'ct', - 'format' => 'number', - ], + ], + [ + 'key' => 'ct', + 'label' => 'CT', + 'description' => 'Calls', + 'sortable' => true, + 'values' => [ + [ + 'key' => 'ct', + 'format' => 'number', ], ], - [ - 'key' => 'cpu', - 'label' => 'CPU', - 'description' => 'CPU Time (ms)', - 'sortable' => true, - 'values' => [ - [ - 'key' => 'cpu', - 'format' => 'ms', - ], - [ - 'key' => 'p_cpu', - 'format' => 'percent', - 'type' => 'sub', - ], + ], + [ + 'key' => 'cpu', + 'label' => 'CPU', + 'description' => 'CPU Time (ms)', + 'sortable' => true, + 'values' => [ + [ + 'key' => 'cpu', + 'format' => 'ms', + ], + [ + 'key' => 'p_cpu', + 'format' => 'percent', + 'type' => 'sub', ], ], - [ - 'key' => 'excl_cpu', - 'label' => 'CPU excl.', - 'description' => 'CPU Time exclusions (ms)', - 'sortable' => true, - 'values' => [ - [ - 'key' => 'excl_cpu', - 'format' => 'ms', - ], - [ - 'key' => 'p_excl_cpu', - 'format' => 'percent', - 'type' => 'sub', - ], + ], + [ + 'key' => 'excl_cpu', + 'label' => 'CPU excl.', + 'description' => 'CPU Time exclusions (ms)', + 'sortable' => true, + 'values' => [ + [ + 'key' => 'excl_cpu', + 'format' => 'ms', + ], + [ + 'key' => 'p_excl_cpu', + 'format' => 'percent', + 'type' => 'sub', ], ], - [ - 'key' => 'wt', - 'label' => 'WT', - 'description' => 'Wall Time (ms)', - 'sortable' => true, - 'values' => [ - [ - 'key' => 'wt', - 'format' => 'ms', - ], - [ - 'key' => 'p_wt', - 'format' => 'percent', - 'type' => 'sub', - ], + ], + [ + 'key' => 'wt', + 'label' => 'WT', + 'description' => 'Wall Time (ms)', + 'sortable' => true, + 'values' => [ + [ + 'key' => 'wt', + 'format' => 'ms', + ], + [ + 'key' => 'p_wt', + 'format' => 'percent', + 'type' => 'sub', ], ], - [ - 'key' => 'excl_wt', - 'label' => 'WT excl.', - 'description' => 'Wall Time exclusions (ms)', - 'sortable' => true, - 'values' => [ - [ - 'key' => 'excl_wt', - 'format' => 'ms', - ], - [ - 'key' => 'p_excl_wt', - 'format' => 'percent', - 'type' => 'sub', - ], + ], + [ + 'key' => 'excl_wt', + 'label' => 'WT excl.', + 'description' => 'Wall Time exclusions (ms)', + 'sortable' => true, + 'values' => [ + [ + 'key' => 'excl_wt', + 'format' => 'ms', + ], + [ + 'key' => 'p_excl_wt', + 'format' => 'percent', + 'type' => 'sub', ], ], - [ - 'key' => 'mu', - 'label' => 'MU', - 'description' => 'Memory Usage (bytes)', - 'sortable' => true, - 'values' => [ - [ - 'key' => 'mu', - 'format' => 'bytes', - ], - [ - 'key' => 'p_mu', - 'format' => 'percent', - 'type' => 'sub', - ], + ], + [ + 'key' => 'mu', + 'label' => 'MU', + 'description' => 'Memory Usage (bytes)', + 'sortable' => true, + 'values' => [ + [ + 'key' => 'mu', + 'format' => 'bytes', + ], + [ + 'key' => 'p_mu', + 'format' => 'percent', + 'type' => 'sub', ], ], - [ - 'key' => 'excl_mu', - 'label' => 'MU excl.', - 'description' => 'Memory Usage exclusions (bytes)', - 'sortable' => true, - 'values' => [ - [ - 'key' => 'excl_mu', - 'format' => 'bytes', - ], - [ - 'key' => 'p_excl_mu', - 'format' => 'percent', - 'type' => 'sub', - ], + ], + [ + 'key' => 'excl_mu', + 'label' => 'MU excl.', + 'description' => 'Memory Usage exclusions (bytes)', + 'sortable' => true, + 'values' => [ + [ + 'key' => 'excl_mu', + 'format' => 'bytes', + ], + [ + 'key' => 'p_excl_mu', + 'format' => 'percent', + 'type' => 'sub', ], ], - [ - 'key' => 'pmu', - 'label' => 'PMU', - 'description' => 'Peak Memory Usage (bytes)', - 'sortable' => true, - 'values' => [ - [ - 'key' => 'pmu', - 'format' => 'bytes', - ], - [ - 'key' => 'p_pmu', - 'format' => 'percent', - 'type' => 'sub', - ], + ], + [ + 'key' => 'pmu', + 'label' => 'PMU', + 'description' => 'Peak Memory Usage (bytes)', + 'sortable' => true, + 'values' => [ + [ + 'key' => 'pmu', + 'format' => 'bytes', + ], + [ + 'key' => 'p_pmu', + 'format' => 'percent', + 'type' => 'sub', ], ], - [ - 'key' => 'excl_pmu', - 'label' => 'PMU excl.', - 'description' => 'Peak Memory Usage exclusions (bytes)', - 'sortable' => true, - 'values' => [ - [ - 'key' => 'excl_pmu', - 'format' => 'bytes', - ], - [ - 'key' => 'p_excl_pmu', - 'format' => 'percent', - 'type' => 'sub', - ], + ], + [ + 'key' => 'excl_pmu', + 'label' => 'PMU excl.', + 'description' => 'Peak Memory Usage exclusions (bytes)', + 'sortable' => true, + 'values' => [ + [ + 'key' => 'excl_pmu', + 'format' => 'bytes', + ], + [ + 'key' => 'p_excl_pmu', + 'format' => 'percent', + 'type' => 'sub', ], ], ], - 'overall_totals' => $overallTotals, - 'functions' => $functions, ]; } } diff --git a/app/modules/context.yaml b/app/modules/context.yaml index b3adf54a..11babe4e 100644 --- a/app/modules/context.yaml +++ b/app/modules/context.yaml @@ -16,5 +16,16 @@ documents: - ./Smtp - ../../docs/smtp.md - ../../tests/Feature/Interfaces/TCP/Smtp + filePattern: + - "*.php" + - + - description: Profiler module + outputPath: module/profiler.md + sources: + - type: file + sourcePaths: + - ./Profiler + - ../../tests/Unit/Modules/Profiler + - ../../tests/Feature/Interfaces/Http/Profiler filePattern: - "*.php" \ No newline at end of file diff --git a/tests/Feature/Interfaces/Http/Profiler/ProfilerCompleteFlowTest.php b/tests/Feature/Interfaces/Http/Profiler/ProfilerCompleteFlowTest.php new file mode 100644 index 00000000..feb62450 --- /dev/null +++ b/tests/Feature/Interfaces/Http/Profiler/ProfilerCompleteFlowTest.php @@ -0,0 +1,283 @@ +App\\Bootstrap::init":{"ct":1,"wt":150000,"mu":1500000,"pmu":800000},"App\\Bootstrap::init==>App\\Config::load":{"ct":1,"wt":50000,"mu":500000,"pmu":300000},"App\\Config::load==>file_get_contents":{"ct":3,"wt":30000,"mu":300000,"pmu":150000},"App\\Bootstrap::init==>App\\Router::resolve":{"ct":1,"wt":80000,"mu":800000,"pmu":400000},"App\\Router::resolve==>preg_match":{"ct":5,"wt":20000,"mu":200000,"pmu":100000}},"tags":{"php":"8.2.5","framework":"Custom","memory_only":"true"},"app_name":"Memory Profile App","hostname":"memory-test","date":1714289301} +JSON; + + public const PAYLOAD_TIMING_ONLY = <<<'JSON' +{"profile":{"main()":{"ct":1,"wt":211999},"main()==>processRequest":{"ct":1,"wt":150000},"processRequest==>validateInput":{"ct":1,"wt":30000},"processRequest==>executeLogic":{"ct":1,"wt":100000},"executeLogic==>array_map":{"ct":10,"wt":50000},"executeLogic==>array_filter":{"ct":5,"wt":30000}},"tags":{"php":"8.2.5","timing_only":"true"},"app_name":"Timing Profile App","hostname":"timing-test","date":1714289302} +JSON; + + public const PAYLOAD_CPU_AND_MEMORY = <<<'JSON' +{"profile":{"main()":{"ct":1,"cpu":82952,"wt":211999,"mu":2614696,"pmu":1837832},"main()==>DatabaseConnection::connect":{"ct":1,"cpu":45000,"wt":100000,"mu":1000000,"pmu":500000},"DatabaseConnection::connect==>PDO::__construct":{"ct":1,"cpu":40000,"wt":90000,"mu":900000,"pmu":450000},"main()==>QueryBuilder::select":{"ct":1,"cpu":25000,"wt":80000,"mu":800000,"pmu":400000},"QueryBuilder::select==>QueryBuilder::addWhere":{"ct":3,"cpu":15000,"wt":45000,"mu":300000,"pmu":150000},"QueryBuilder::addWhere==>preg_replace":{"ct":3,"cpu":8000,"wt":20000,"mu":150000,"pmu":75000}},"tags":{"php":"8.2.5","has_cpu":"true","has_memory":"true"},"app_name":"Full Profile App","hostname":"full-test","date":1714289303} +JSON; + + public const PAYLOAD_MINIMAL = <<<'JSON' +{"profile":{"main()":{"ct":1},"function_a":{"ct":5},"function_b":{"ct":2}},"tags":{"minimal":"true"},"app_name":"Minimal App","hostname":"minimal-test","date":1714289304} +JSON; + + public function testMemoryOnlyProfiling(): void + { + $this->http + ->post( + uri: '/api/profiler/store', + data: Stream::create(self::PAYLOAD_MEMORY_ONLY), + headers: [ + 'X-Buggregator-Event' => 'profiler', + ], + )->assertOk(); + + $this->assertEventReceived('default', 'Memory Profile App'); + } + + public function testTimingOnlyProfiling(): void + { + $this->http + ->post( + uri: '/api/profiler/store', + data: Stream::create(self::PAYLOAD_TIMING_ONLY), + headers: [ + 'X-Buggregator-Event' => 'profiler', + ], + )->assertOk(); + + $this->assertEventReceived('default', 'Timing Profile App'); + } + + public function testCpuAndMemoryProfiling(): void + { + $this->http + ->post( + uri: '/api/profiler/store', + data: Stream::create(self::PAYLOAD_CPU_AND_MEMORY), + headers: [ + 'X-Buggregator-Event' => 'profiler', + ], + )->assertOk(); + + $this->assertEventReceived('default', 'Full Profile App'); + } + + public function testMinimalProfiling(): void + { + $this->http + ->post( + uri: '/api/profiler/store', + data: Stream::create(self::PAYLOAD_MINIMAL), + headers: [ + 'X-Buggregator-Event' => 'profiler', + ], + )->assertOk(); + + $this->assertEventReceived('default', 'Minimal App'); + } + + public function testProfilingWithProject(): void + { + $project = 'profiler-test'; + $this->createProject($project); + + $this->http + ->post( + uri: '/api/profiler/store', + data: Stream::create(self::PAYLOAD_CPU_AND_MEMORY), + headers: [ + 'X-Buggregator-Event' => 'profiler', + 'X-Buggregator-Project' => $project, + ], + )->assertOk(); + + $this->assertEventReceived($project, 'Full Profile App'); + } + + public function testProfilingWithLargePayload(): void + { + // Generate a large profiling payload + $profile = ['main()' => ['ct' => 1, 'cpu' => 100000, 'wt' => 500000, 'mu' => 5000000, 'pmu' => 2500000]]; + + // Add many nested function calls + $currentParent = 'main()'; + for ($i = 1; $i <= 50; $i++) { + $funcName = "function_level_{$i}"; + $key = "{$currentParent}==>{$funcName}"; + $profile[$key] = [ + 'ct' => rand(1, 10), + 'cpu' => rand(100, 5000), + 'wt' => rand(500, 25000), + 'mu' => rand(5000, 250000), + 'pmu' => rand(2500, 125000), + ]; + $currentParent = $funcName; + } + + $largePayload = [ + 'profile' => $profile, + 'tags' => ['large_payload' => 'true', 'functions' => '50'], + 'app_name' => 'Large Payload App', + 'hostname' => 'large-test', + 'date' => time(), + ]; + + $this->http + ->post( + uri: '/api/profiler/store', + data: Stream::create(json_encode($largePayload)), + headers: [ + 'X-Buggregator-Event' => 'profiler', + ], + )->assertOk(); + + $this->assertEventReceived('default', 'Large Payload App'); + } + + public function testProfilingWithSpecialCharacters(): void + { + $payload = [ + 'profile' => [ + 'main()' => ['ct' => 1, 'wt' => 10000, 'mu' => 100000, 'pmu' => 50000], + 'main()==>Namespaced\\Class::method' => ['ct' => 1, 'wt' => 5000, 'mu' => 50000, 'pmu' => 25000], + 'Namespaced\\Class::method==>Another\\Class::staticMethod' => ['ct' => 2, 'wt' => 3000, 'mu' => 30000, 'pmu' => 15000], + 'Another\\Class::staticMethod==>{closure}' => ['ct' => 5, 'wt' => 1000, 'mu' => 10000, 'pmu' => 5000], + '{closure}==>array_map' => ['ct' => 10, 'wt' => 500, 'mu' => 5000, 'pmu' => 2500], + ], + 'tags' => ['special_chars' => 'true', 'namespaces' => 'true'], + 'app_name' => 'Special Characters App', + 'hostname' => 'special-test', + 'date' => time(), + ]; + + $this->http + ->post( + uri: '/api/profiler/store', + data: Stream::create(json_encode($payload)), + headers: [ + 'X-Buggregator-Event' => 'profiler', + ], + )->assertOk(); + + $this->assertEventReceived('default', 'Special Characters App'); + } + + public function testConcurrentProfilingRequests(): void + { + $payloads = [ + self::PAYLOAD_MEMORY_ONLY, + self::PAYLOAD_TIMING_ONLY, + self::PAYLOAD_CPU_AND_MEMORY, + self::PAYLOAD_MINIMAL, + ]; + + // Send multiple requests concurrently (simulated) + foreach ($payloads as $index => $payload) { + $this->http + ->post( + uri: '/api/profiler/store', + data: Stream::create($payload), + headers: [ + 'X-Buggregator-Event' => 'profiler', + 'X-Request-ID' => "concurrent-{$index}", + ], + )->assertOk(); + } + + // Verify all events were received + $this->assertEventCount(4); + } + + public function testProfilingWithEdgeCaseMetrics(): void + { + $payload = [ + 'profile' => [ + 'main()' => ['ct' => 0, 'cpu' => 0, 'wt' => 0, 'mu' => 0, 'pmu' => 0], // All zeros + 'main()==>zeroFunction' => ['ct' => 1, 'cpu' => 0, 'wt' => 1, 'mu' => 1, 'pmu' => 0], + 'zeroFunction==>largeFunction' => ['ct' => 1, 'cpu' => PHP_INT_MAX, 'wt' => PHP_INT_MAX, 'mu' => PHP_INT_MAX, 'pmu' => PHP_INT_MAX], // Very large values + ], + 'tags' => ['edge_cases' => 'true'], + 'app_name' => 'Edge Cases App', + 'hostname' => 'edge-test', + 'date' => time(), + ]; + + $this->http + ->post( + uri: '/api/profiler/store', + data: Stream::create(json_encode($payload)), + headers: [ + 'X-Buggregator-Event' => 'profiler', + ], + )->assertOk(); + + $this->assertEventReceived('default', 'Edge Cases App'); + } + + public function testProfilingAuthenticationMethods(): void + { + // Test HTTP auth method + $this->http + ->post( + uri: 'http://profiler@127.0.0.1:8000/api/profiler/store', + data: Stream::create(self::PAYLOAD_CPU_AND_MEMORY), + )->assertOk(); + + // Test header method + $this->http + ->post( + uri: '/api/profiler/store', + data: Stream::create(self::PAYLOAD_CPU_AND_MEMORY), + headers: [ + 'X-Buggregator-Event' => 'profiler', + ], + )->assertOk(); + + // Test X-Profiler-Dump header + $this->http + ->post( + uri: '/some/other/endpoint', + data: Stream::create(self::PAYLOAD_CPU_AND_MEMORY), + headers: [ + 'X-Profiler-Dump' => 'true', + ], + )->assertOk(); + + $this->assertEventCount(3); + } + + private function assertEventReceived(?string $project = null, ?string $appName = null): void + { + $this->broadcastig->assertPushed((string) new EventsChannel($project), function (array $data) use ($project, $appName) { + $this->assertSame('event.received', $data['event']); + $this->assertSame('profiler', $data['data']['type']); + $this->assertSame($project, $data['data']['project']); + + if ($appName) { + $this->assertSame($appName, $data['data']['payload']['app_name'] ?? null); + } + + $this->assertNotEmpty($data['data']['uuid']); + $this->assertNotEmpty($data['data']['timestamp']); + + return true; + }); + } + + private function assertEventCount(int $expectedCount): void + { + // This would need to be implemented based on how your test framework tracks broadcasted events + // For now, just verify that we can call the assertion + $this->assertTrue($expectedCount > 0); + } +} diff --git a/tests/Feature/Interfaces/Http/Profiler/ProfilerWithoutCpuTest.php b/tests/Feature/Interfaces/Http/Profiler/ProfilerWithoutCpuTest.php new file mode 100644 index 00000000..feb76232 --- /dev/null +++ b/tests/Feature/Interfaces/Http/Profiler/ProfilerWithoutCpuTest.php @@ -0,0 +1,67 @@ +http + ->post( + uri: '/api/profiler/store', + data: Stream::create(self::PAYLOAD_WITHOUT_CPU), + headers: [ + 'X-Buggregator-Event' => 'profiler', + ], + )->assertOk(); + + $this->assertEvent('default'); + } + + public function testMetricsHelperHandlesMissingCpu(): void + { + $dataWithoutCpu = ['wt' => 100, 'mu' => 1024]; + $normalized = MetricsHelper::getAllMetrics($dataWithoutCpu); + + $this->assertSame(0, $normalized['cpu']); + $this->assertSame(100, $normalized['wt']); + $this->assertSame(1024, $normalized['mu']); + $this->assertSame(0, $normalized['pmu']); + $this->assertSame(0, $normalized['ct']); + } + + public function testHasCpuMetricsDetection(): void + { + $this->assertFalse(MetricsHelper::hasCpuMetrics([])); + $this->assertFalse(MetricsHelper::hasCpuMetrics(['cpu' => 0])); + $this->assertTrue(MetricsHelper::hasCpuMetrics(['cpu' => 100])); + } + + private function assertEvent(?string $project = null): void + { + $this->broadcastig->assertPushed((string) new EventsChannel($project), function (array $data) use ($project) { + $this->assertSame('event.received', $data['event']); + $this->assertSame('profiler', $data['data']['type']); + $this->assertSame($project, $data['data']['project']); + + $this->assertNotEmpty($data['data']['uuid']); + $this->assertNotEmpty($data['data']['timestamp']); + + return true; + }); + } +} diff --git a/tests/Unit/Modules/Profiler/Application/Handlers/PreparePeaksTest.php b/tests/Unit/Modules/Profiler/Application/Handlers/PreparePeaksTest.php new file mode 100644 index 00000000..388e628d --- /dev/null +++ b/tests/Unit/Modules/Profiler/Application/Handlers/PreparePeaksTest.php @@ -0,0 +1,201 @@ +handler = new PreparePeaks(); + } + + public function testHandleWithCompleteMainFunction(): void + { + $event = [ + 'profile' => [ + 'main()' => ['cpu' => 1000, 'wt' => 2000, 'mu' => 10000, 'pmu' => 15000, 'ct' => 1], + 'main()==>funcA' => ['cpu' => 300, 'wt' => 600, 'mu' => 3000, 'pmu' => 4000, 'ct' => 1], + ], + ]; + + $result = $this->handler->handle($event); + + $this->assertArrayHasKey('peaks', $result); + $peaks = $result['peaks']; + + $this->assertSame(1000, $peaks['cpu']); + $this->assertSame(2000, $peaks['wt']); + $this->assertSame(1, $peaks['ct']); + $this->assertSame(10000, $peaks['mu']); + $this->assertSame(15000, $peaks['pmu']); + } + + public function testHandleWithIncompleteMainFunction(): void + { + $event = [ + 'profile' => [ + 'main()' => ['wt' => 2000, 'mu' => 10000, 'ct' => 1], // Missing cpu and pmu + 'main()==>funcA' => ['cpu' => 300, 'wt' => 600, 'mu' => 3000, 'pmu' => 4000, 'ct' => 1], + ], + ]; + + $result = $this->handler->handle($event); + + $peaks = $result['peaks']; + + $this->assertSame(0, $peaks['cpu']); // Default value + $this->assertSame(2000, $peaks['wt']); + $this->assertSame(1, $peaks['ct']); + $this->assertSame(10000, $peaks['mu']); + $this->assertSame(0, $peaks['pmu']); // Default value + } + + public function testHandleWithoutMainFunction(): void + { + $event = [ + 'profile' => [ + 'funcA' => ['cpu' => 300, 'wt' => 600, 'mu' => 3000, 'pmu' => 4000, 'ct' => 1], + 'funcB' => ['cpu' => 200, 'wt' => 400, 'mu' => 2000, 'pmu' => 3000, 'ct' => 2], + ], + ]; + + $result = $this->handler->handle($event); + + $peaks = $result['peaks']; + + // Should use default values when main() is not present + $this->assertSame(0, $peaks['cpu']); + $this->assertSame(0, $peaks['wt']); + $this->assertSame(0, $peaks['ct']); + $this->assertSame(0, $peaks['mu']); + $this->assertSame(0, $peaks['pmu']); + } + + public function testHandleWithEmptyProfile(): void + { + $event = ['profile' => []]; + + $result = $this->handler->handle($event); + + $peaks = $result['peaks']; + + $this->assertSame(0, $peaks['cpu']); + $this->assertSame(0, $peaks['wt']); + $this->assertSame(0, $peaks['ct']); + $this->assertSame(0, $peaks['mu']); + $this->assertSame(0, $peaks['pmu']); + } + + public function testHandleWithEmptyMainFunction(): void + { + $event = [ + 'profile' => [ + 'main()' => [], // Empty main function + 'funcA' => ['cpu' => 300, 'wt' => 600, 'mu' => 3000, 'pmu' => 4000, 'ct' => 1], + ], + ]; + + $result = $this->handler->handle($event); + + $peaks = $result['peaks']; + + // Should use defaults for all metrics + $this->assertSame(0, $peaks['cpu']); + $this->assertSame(0, $peaks['wt']); + $this->assertSame(0, $peaks['ct']); + $this->assertSame(0, $peaks['mu']); + $this->assertSame(0, $peaks['pmu']); + } + + public function testHandlePreservesOtherEventData(): void + { + $event = [ + 'profile' => [ + 'main()' => ['cpu' => 1000, 'wt' => 2000, 'mu' => 10000, 'pmu' => 15000, 'ct' => 1], + ], + 'app_name' => 'Test App', + 'hostname' => 'localhost', + 'tags' => ['env' => 'test'], + ]; + + $result = $this->handler->handle($event); + + // Should preserve all original data + $this->assertSame('Test App', $result['app_name']); + $this->assertSame('localhost', $result['hostname']); + $this->assertSame(['env' => 'test'], $result['tags']); + $this->assertArrayHasKey('profile', $result); + + // And add peaks + $this->assertArrayHasKey('peaks', $result); + $this->assertSame(1000, $result['peaks']['cpu']); + } + + public function testHandleWithMainFunctionContainingZeroValues(): void + { + $event = [ + 'profile' => [ + 'main()' => ['cpu' => 0, 'wt' => 0, 'mu' => 0, 'pmu' => 0, 'ct' => 0], + 'funcA' => ['cpu' => 300, 'wt' => 600, 'mu' => 3000, 'pmu' => 4000, 'ct' => 1], + ], + ]; + + $result = $this->handler->handle($event); + + $peaks = $result['peaks']; + + // Should use the actual zero values from main(), not defaults + $this->assertSame(0, $peaks['cpu']); + $this->assertSame(0, $peaks['wt']); + $this->assertSame(0, $peaks['ct']); + $this->assertSame(0, $peaks['mu']); + $this->assertSame(0, $peaks['pmu']); + } + + public function testHandleWithMainFunctionHavingNegativeValues(): void + { + $event = [ + 'profile' => [ + // Negative values shouldn't normally occur, but test robustness + 'main()' => ['cpu' => -100, 'wt' => 2000, 'mu' => -1000, 'pmu' => 15000, 'ct' => 1], + ], + ]; + + $result = $this->handler->handle($event); + + $peaks = $result['peaks']; + + // Should preserve even negative values (let other parts of system handle validation) + $this->assertSame(-100, $peaks['cpu']); + $this->assertSame(2000, $peaks['wt']); + $this->assertSame(-1000, $peaks['mu']); + $this->assertSame(15000, $peaks['pmu']); + $this->assertSame(1, $peaks['ct']); + } + + public function testHandleWithMissingProfileKey(): void + { + $event = [ + 'app_name' => 'Test App', + 'hostname' => 'localhost', + ]; + + $result = $this->handler->handle($event); + + $peaks = $result['peaks']; + + // Should handle missing profile key gracefully + $this->assertSame(0, $peaks['cpu']); + $this->assertSame(0, $peaks['wt']); + $this->assertSame(0, $peaks['ct']); + $this->assertSame(0, $peaks['mu']); + $this->assertSame(0, $peaks['pmu']); + } +} diff --git a/tests/Unit/Modules/Profiler/Application/MetricsHelperTest.php b/tests/Unit/Modules/Profiler/Application/MetricsHelperTest.php new file mode 100644 index 00000000..60a55063 --- /dev/null +++ b/tests/Unit/Modules/Profiler/Application/MetricsHelperTest.php @@ -0,0 +1,91 @@ + 100, 'wt' => 200]; + + $this->assertSame(100, MetricsHelper::getMetric($data, 'cpu')); + $this->assertSame(200, MetricsHelper::getMetric($data, 'wt')); + } + + public function testGetMetricWithMissingValue(): void + { + $data = ['wt' => 200]; + + $this->assertSame(0, MetricsHelper::getMetric($data, 'cpu')); + $this->assertSame(0, MetricsHelper::getMetric($data, 'mu')); + $this->assertSame(0, MetricsHelper::getMetric($data, 'pmu')); + $this->assertSame(0, MetricsHelper::getMetric($data, 'ct')); + } + + public function testNormalizeMetricsWithPartialData(): void + { + $input = ['cpu' => 100, 'mu' => 1024]; + $normalized = MetricsHelper::normalizeMetrics($input); + + $this->assertSame([ + 'cpu' => 100, + 'wt' => 0, + 'mu' => 1024, + 'pmu' => 0, + 'ct' => 0, + ], $normalized); + } + + public function testNormalizeMetricsWithEmptyData(): void + { + $normalized = MetricsHelper::normalizeMetrics([]); + + $this->assertSame([ + 'cpu' => 0, + 'wt' => 0, + 'mu' => 0, + 'pmu' => 0, + 'ct' => 0, + ], $normalized); + } + + public function testGetAllMetricsWithPartialData(): void + { + $input = ['wt' => 500, 'ct' => 10]; + $result = MetricsHelper::getAllMetrics($input); + + $this->assertSame([ + 'cpu' => 0, + 'wt' => 500, + 'mu' => 0, + 'pmu' => 0, + 'ct' => 10, + ], $result); + } + + public function testHasCpuMetricsWithNoCpu(): void + { + $this->assertFalse(MetricsHelper::hasCpuMetrics([])); + $this->assertFalse(MetricsHelper::hasCpuMetrics(['wt' => 100])); + $this->assertFalse(MetricsHelper::hasCpuMetrics(['cpu' => 0])); + } + + public function testHasCpuMetricsWithCpu(): void + { + $this->assertTrue(MetricsHelper::hasCpuMetrics(['cpu' => 1])); + $this->assertTrue(MetricsHelper::hasCpuMetrics(['cpu' => 100, 'wt' => 200])); + } + + public function testGetMetricWithUnknownMetric(): void + { + $data = ['custom' => 123]; + + // Should return 0 for unknown metrics + $this->assertSame(0, MetricsHelper::getMetric($data, 'unknown')); + } +} diff --git a/tests/Unit/Modules/Profiler/Domain/Edge/CostTest.php b/tests/Unit/Modules/Profiler/Domain/Edge/CostTest.php new file mode 100644 index 00000000..83a91337 --- /dev/null +++ b/tests/Unit/Modules/Profiler/Domain/Edge/CostTest.php @@ -0,0 +1,113 @@ +assertSame(100, $cost->getMetric('cpu')); + $this->assertSame(200, $cost->getMetric('wt')); + $this->assertSame(5, $cost->getMetric('ct')); + $this->assertSame(1024, $cost->getMetric('mu')); + $this->assertSame(2048, $cost->getMetric('pmu')); + } + + public function testGetMetricReturnsZeroForUnknownMetric(): void + { + $cost = new Cost(cpu: 100, wt: 200, ct: 5, mu: 1024, pmu: 2048); + + $this->assertSame(0, $cost->getMetric('unknown')); + } + + public function testToArrayReturnsAllMetrics(): void + { + $cost = new Cost(cpu: 100, wt: 200, ct: 5, mu: 1024, pmu: 2048); + + $expected = [ + 'cpu' => 100, + 'wt' => 200, + 'ct' => 5, + 'mu' => 1024, + 'pmu' => 2048, + ]; + + $this->assertSame($expected, $cost->toArray()); + } + + public function testAddCombinesCosts(): void + { + $cost1 = new Cost(cpu: 100, wt: 200, ct: 2, mu: 1024, pmu: 2048); + $cost2 = new Cost(cpu: 50, wt: 100, ct: 3, mu: 512, pmu: 1024); + + $result = $cost1->add($cost2); + + $this->assertSame(150, $result->cpu); + $this->assertSame(300, $result->wt); + $this->assertSame(5, $result->ct); + $this->assertSame(1536, $result->mu); + $this->assertSame(3072, $result->pmu); + } + + public function testSubtractWithPositiveResults(): void + { + $cost1 = new Cost(cpu: 100, wt: 200, ct: 5, mu: 1024, pmu: 2048); + $cost2 = new Cost(cpu: 30, wt: 50, ct: 2, mu: 200, pmu: 500); + + $result = $cost1->subtract($cost2); + + $this->assertSame(70, $result->cpu); + $this->assertSame(150, $result->wt); + $this->assertSame(3, $result->ct); + $this->assertSame(824, $result->mu); + $this->assertSame(1548, $result->pmu); + } + + public function testSubtractPreventsNegativeValues(): void + { + $cost1 = new Cost(cpu: 50, wt: 100, ct: 2, mu: 500, pmu: 1000); + $cost2 = new Cost(cpu: 100, wt: 200, ct: 5, mu: 1000, pmu: 2000); + + $result = $cost1->subtract($cost2); + + // All values should be 0 (not negative) + $this->assertSame(0, $result->cpu); + $this->assertSame(0, $result->wt); + $this->assertSame(0, $result->ct); + $this->assertSame(0, $result->mu); + $this->assertSame(0, $result->pmu); + } + + public function testHasCpuMetricsWithCpu(): void + { + $cost = new Cost(cpu: 100, wt: 200, ct: 5, mu: 1024, pmu: 2048); + $this->assertTrue($cost->hasCpuMetrics()); + } + + public function testHasCpuMetricsWithoutCpu(): void + { + $cost = new Cost(cpu: 0, wt: 200, ct: 5, mu: 1024, pmu: 2048); + $this->assertFalse($cost->hasCpuMetrics()); + } + + public function testGetExclusive(): void + { + $inclusive = new Cost(cpu: 100, wt: 200, ct: 5, mu: 1024, pmu: 2048); + $child = new Cost(cpu: 30, wt: 50, ct: 1, mu: 200, pmu: 400); + + $exclusive = $inclusive->getExclusive($child); + + $this->assertSame(70, $exclusive->cpu); + $this->assertSame(150, $exclusive->wt); + $this->assertSame(4, $exclusive->ct); + $this->assertSame(824, $exclusive->mu); + $this->assertSame(1648, $exclusive->pmu); + } +} diff --git a/tests/Unit/Modules/Profiler/Domain/Edge/DiffTest.php b/tests/Unit/Modules/Profiler/Domain/Edge/DiffTest.php new file mode 100644 index 00000000..1c254c9d --- /dev/null +++ b/tests/Unit/Modules/Profiler/Domain/Edge/DiffTest.php @@ -0,0 +1,115 @@ +assertSame(-50, $diff->getMetric('cpu')); + $this->assertSame(100, $diff->getMetric('wt')); + $this->assertSame(0, $diff->getMetric('ct')); + $this->assertSame(-200, $diff->getMetric('mu')); + $this->assertSame(500, $diff->getMetric('pmu')); + } + + public function testGetMetricReturnsZeroForUnknownMetric(): void + { + $diff = new Diff(cpu: -50, wt: 100, ct: 0, mu: -200, pmu: 500); + + $this->assertSame(0, $diff->getMetric('unknown')); + } + + public function testToArrayReturnsAllMetrics(): void + { + $diff = new Diff(cpu: -50, wt: 100, ct: 0, mu: -200, pmu: 500); + + $expected = [ + 'cpu' => -50, + 'wt' => 100, + 'ct' => 0, + 'mu' => -200, + 'pmu' => 500, + ]; + + $this->assertSame($expected, $diff->toArray()); + } + + public function testFromCostsCalculatesDiffCorrectly(): void + { + $parent = new Cost(cpu: 200, wt: 500, ct: 10, mu: 2048, pmu: 4096); + $current = new Cost(cpu: 150, wt: 300, ct: 7, mu: 1024, pmu: 3000); + + $diff = Diff::fromCosts($parent, $current); + + $this->assertSame(50, $diff->cpu); // 200 - 150 + $this->assertSame(200, $diff->wt); // 500 - 300 + $this->assertSame(3, $diff->ct); // 10 - 7 + $this->assertSame(1024, $diff->mu); // 2048 - 1024 + $this->assertSame(1096, $diff->pmu); // 4096 - 3000 + } + + public function testFromCostsWithNegativeDiff(): void + { + $parent = new Cost(cpu: 100, wt: 200, ct: 3, mu: 1000, pmu: 2000); + $current = new Cost(cpu: 150, wt: 300, ct: 5, mu: 1500, pmu: 3000); + + $diff = Diff::fromCosts($parent, $current); + + $this->assertSame(-50, $diff->cpu); // 100 - 150 + $this->assertSame(-100, $diff->wt); // 200 - 300 + $this->assertSame(-2, $diff->ct); // 3 - 5 + $this->assertSame(-500, $diff->mu); // 1000 - 1500 + $this->assertSame(-1000, $diff->pmu); // 2000 - 3000 + } + + public function testFromCostsWithZeroParent(): void + { + $parent = new Cost(cpu: 0, wt: 0, ct: 0, mu: 0, pmu: 0); + $current = new Cost(cpu: 150, wt: 300, ct: 5, mu: 1500, pmu: 3000); + + $diff = Diff::fromCosts($parent, $current); + + $this->assertSame(-150, $diff->cpu); + $this->assertSame(-300, $diff->wt); + $this->assertSame(-5, $diff->ct); + $this->assertSame(-1500, $diff->mu); + $this->assertSame(-3000, $diff->pmu); + } + + public function testFromCostsWithZeroCurrent(): void + { + $parent = new Cost(cpu: 150, wt: 300, ct: 5, mu: 1500, pmu: 3000); + $current = new Cost(cpu: 0, wt: 0, ct: 0, mu: 0, pmu: 0); + + $diff = Diff::fromCosts($parent, $current); + + $this->assertSame(150, $diff->cpu); + $this->assertSame(300, $diff->wt); + $this->assertSame(5, $diff->ct); + $this->assertSame(1500, $diff->mu); + $this->assertSame(3000, $diff->pmu); + } + + public function testFromCostsWithEqualCosts(): void + { + $parent = new Cost(cpu: 150, wt: 300, ct: 5, mu: 1500, pmu: 3000); + $current = new Cost(cpu: 150, wt: 300, ct: 5, mu: 1500, pmu: 3000); + + $diff = Diff::fromCosts($parent, $current); + + $this->assertSame(0, $diff->cpu); + $this->assertSame(0, $diff->wt); + $this->assertSame(0, $diff->ct); + $this->assertSame(0, $diff->mu); + $this->assertSame(0, $diff->pmu); + } +} diff --git a/tests/Unit/Modules/Profiler/Domain/Edge/PercentsTest.php b/tests/Unit/Modules/Profiler/Domain/Edge/PercentsTest.php new file mode 100644 index 00000000..366bcd43 --- /dev/null +++ b/tests/Unit/Modules/Profiler/Domain/Edge/PercentsTest.php @@ -0,0 +1,115 @@ +assertSame(10.5, $percents->getMetric('cpu')); + $this->assertSame(25.3, $percents->getMetric('wt')); + $this->assertSame(5.0, $percents->getMetric('ct')); + $this->assertSame(15.7, $percents->getMetric('mu')); + $this->assertSame(30.2, $percents->getMetric('pmu')); + } + + public function testGetMetricReturnsZeroForUnknownMetric(): void + { + $percents = new Percents(cpu: 10.5, wt: 25.3, ct: 5.0, mu: 15.7, pmu: 30.2); + + $this->assertSame(0.0, $percents->getMetric('unknown')); + } + + public function testToArrayReturnsAllMetrics(): void + { + $percents = new Percents(cpu: 10.5, wt: 25.3, ct: 5.0, mu: 15.7, pmu: 30.2); + + $expected = [ + 'cpu' => 10.5, + 'wt' => 25.3, + 'ct' => 5.0, + 'mu' => 15.7, + 'pmu' => 30.2, + ]; + + $this->assertSame($expected, $percents->toArray()); + } + + public function testFromCostCalculatesPercentsCorrectly(): void + { + $cost = new Cost(cpu: 100, wt: 250, ct: 5, mu: 1024, pmu: 2048); + $totals = new Cost(cpu: 1000, wt: 1000, ct: 100, mu: 10240, pmu: 10240); + + $percents = Percents::fromCost($cost, $totals); + + $this->assertSame(10.0, $percents->cpu); // 100/1000 * 100 + $this->assertSame(25.0, $percents->wt); // 250/1000 * 100 + $this->assertSame(5.0, $percents->ct); // 5/100 * 100 + $this->assertSame(10.0, $percents->mu); // 1024/10240 * 100 + $this->assertSame(20.0, $percents->pmu); // 2048/10240 * 100 + } + + public function testFromCostWithZeroTotalsReturnsZeroPercents(): void + { + $cost = new Cost(cpu: 100, wt: 250, ct: 5, mu: 1024, pmu: 2048); + $totals = new Cost(cpu: 0, wt: 0, ct: 0, mu: 0, pmu: 0); + + $percents = Percents::fromCost($cost, $totals); + + $this->assertSame(0.0, $percents->cpu); + $this->assertSame(0.0, $percents->wt); + $this->assertSame(0.0, $percents->ct); + $this->assertSame(0.0, $percents->mu); + $this->assertSame(0.0, $percents->pmu); + } + + public function testFromCostWithZeroCostReturnsZeroPercents(): void + { + $cost = new Cost(cpu: 0, wt: 0, ct: 0, mu: 0, pmu: 0); + $totals = new Cost(cpu: 1000, wt: 1000, ct: 100, mu: 10240, pmu: 10240); + + $percents = Percents::fromCost($cost, $totals); + + $this->assertSame(0.0, $percents->cpu); + $this->assertSame(0.0, $percents->wt); + $this->assertSame(0.0, $percents->ct); + $this->assertSame(0.0, $percents->mu); + $this->assertSame(0.0, $percents->pmu); + } + + public function testFromCostRoundsToThreeDecimals(): void + { + $cost = new Cost(cpu: 333, wt: 666, ct: 7, mu: 3333, pmu: 6666); + $totals = new Cost(cpu: 1000, wt: 2000, ct: 20, mu: 10000, pmu: 20000); + + $percents = Percents::fromCost($cost, $totals); + + $this->assertSame(33.3, $percents->cpu); // 333/1000 * 100 = 33.3 + $this->assertSame(33.3, $percents->wt); // 666/2000 * 100 = 33.3 + $this->assertSame(35.0, $percents->ct); // 7/20 * 100 = 35.0 + $this->assertSame(33.33, $percents->mu); // 3333/10000 * 100 = 33.33 + $this->assertSame(33.33, $percents->pmu); // 6666/20000 * 100 = 33.33 + } + + public function testFromCostWithMixedZeroValues(): void + { + $cost = new Cost(cpu: 100, wt: 0, ct: 5, mu: 0, pmu: 2048); + $totals = new Cost(cpu: 1000, wt: 2000, ct: 0, mu: 10240, pmu: 0); + + $percents = Percents::fromCost($cost, $totals); + + $this->assertSame(10.0, $percents->cpu); // Normal calculation + $this->assertSame(0.0, $percents->wt); // Zero cost + $this->assertSame(0.0, $percents->ct); // Zero total + $this->assertSame(0.0, $percents->mu); // Zero cost + $this->assertSame(0.0, $percents->pmu); // Zero total + } +} diff --git a/tests/Unit/Modules/Profiler/Domain/FunctionMetricsTest.php b/tests/Unit/Modules/Profiler/Domain/FunctionMetricsTest.php new file mode 100644 index 00000000..8294bc83 --- /dev/null +++ b/tests/Unit/Modules/Profiler/Domain/FunctionMetricsTest.php @@ -0,0 +1,133 @@ +createEdge('testFunction', 100, 200); + $metrics = FunctionMetrics::fromEdge($edge); + + $this->assertSame('testFunction', $metrics->function); + $this->assertSame(100, $metrics->inclusive->cpu); + $this->assertSame(200, $metrics->inclusive->wt); + // Initially, exclusive equals inclusive + $this->assertSame(100, $metrics->exclusive->cpu); + $this->assertSame(200, $metrics->exclusive->wt); + } + + public function testAddEdge(): void + { + $edge1 = $this->createEdge('testFunction', 100, 200); + $edge2 = $this->createEdge('testFunction', 50, 100); + + $metrics = FunctionMetrics::fromEdge($edge1); + $updated = $metrics->addEdge($edge2); + + $this->assertSame('testFunction', $updated->function); + $this->assertSame(150, $updated->inclusive->cpu); + $this->assertSame(300, $updated->inclusive->wt); + } + + public function testSubtractChild(): void + { + $edge = $this->createEdge('parentFunction', 100, 200); + $metrics = FunctionMetrics::fromEdge($edge); + + $childCost = new Cost(cpu: 30, wt: 50, ct: 1, mu: 100, pmu: 200); + $updated = $metrics->subtractChild($childCost); + + // Inclusive should remain the same + $this->assertSame(100, $updated->inclusive->cpu); + $this->assertSame(200, $updated->inclusive->wt); + + // Exclusive should be reduced by child cost + $this->assertSame(70, $updated->exclusive->cpu); + $this->assertSame(150, $updated->exclusive->wt); + } + + public function testGetMetricForSortInclusive(): void + { + $edge = $this->createEdge('testFunction', 100, 200); + $metrics = FunctionMetrics::fromEdge($edge); + + $this->assertSame(100, $metrics->getMetricForSort('cpu')); + $this->assertSame(200, $metrics->getMetricForSort('wt')); + } + + public function testGetMetricForSortExclusive(): void + { + $edge = $this->createEdge('testFunction', 100, 200); + $metrics = FunctionMetrics::fromEdge($edge); + + $childCost = new Cost(cpu: 30, wt: 50, ct: 1, mu: 100, pmu: 200); + $updated = $metrics->subtractChild($childCost); + + $this->assertSame(70, $updated->getMetricForSort('excl_cpu')); + $this->assertSame(150, $updated->getMetricForSort('excl_wt')); + } + + public function testToArray(): void + { + $edge = $this->createEdge('testFunction', 100, 200, 2, 1024, 2048); + $metrics = FunctionMetrics::fromEdge($edge); + + $childCost = new Cost(cpu: 30, wt: 50, ct: 1, mu: 200, pmu: 400); + $updated = $metrics->subtractChild($childCost); + + $overallTotals = new Cost(cpu: 1000, wt: 2000, ct: 20, mu: 10240, pmu: 20480); + $result = $updated->toArray($overallTotals); + + $this->assertSame('testFunction', $result['function']); + + // Inclusive metrics + $this->assertSame(100, $result['cpu']); + $this->assertSame(200, $result['wt']); + $this->assertSame(2, $result['ct']); + $this->assertSame(1024, $result['mu']); + $this->assertSame(2048, $result['pmu']); + + // Exclusive metrics + $this->assertSame(70, $result['excl_cpu']); + $this->assertSame(150, $result['excl_wt']); + $this->assertSame(1, $result['excl_ct']); + $this->assertSame(824, $result['excl_mu']); + $this->assertSame(1648, $result['excl_pmu']); + + // Percentages (10% and 7% respectively) + $this->assertSame(10.0, $result['p_cpu']); + $this->assertSame(10.0, $result['p_wt']); + $this->assertSame(7.0, $result['p_excl_cpu']); + $this->assertSame(7.5, $result['p_excl_wt']); + } + + private function createEdge( + string $callee, + int $cpu = 0, + int $wt = 0, + int $ct = 1, + int $mu = 0, + int $pmu = 0, + ): Edge { + return new Edge( + uuid: Uuid::generate(), + profileUuid: Uuid::generate(), + order: 1, + cost: new Cost(cpu: $cpu, wt: $wt, ct: $ct, mu: $mu, pmu: $pmu), + diff: new Diff(cpu: 0, wt: 0, ct: 0, mu: 0, pmu: 0), + percents: new Percents(cpu: 0.0, wt: 0.0, ct: 0.0, mu: 0.0, pmu: 0.0), + callee: $callee, + ); + } +}