diff --git a/composer.lock b/composer.lock index 8bd01d12..e4a115fc 100644 --- a/composer.lock +++ b/composer.lock @@ -2290,16 +2290,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.57.2", + "version": "v3.58.1", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "22f7f3145606df92b02fb1bd22c30abfce956d3c" + "reference": "04e9424025677a86914b9a4944dbbf4060bb0aff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/22f7f3145606df92b02fb1bd22c30abfce956d3c", - "reference": "22f7f3145606df92b02fb1bd22c30abfce956d3c", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/04e9424025677a86914b9a4944dbbf4060bb0aff", + "reference": "04e9424025677a86914b9a4944dbbf4060bb0aff", "shasum": "" }, "require": { @@ -2378,7 +2378,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.57.2" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.58.1" }, "funding": [ { @@ -2386,7 +2386,7 @@ "type": "github" } ], - "time": "2024-05-20T20:41:57+00:00" + "time": "2024-05-29T16:39:07+00:00" }, { "name": "google/protobuf", @@ -3341,16 +3341,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "1.29.0", + "version": "1.29.1", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "536889f2b340489d328f5ffb7b02bb6b183ddedc" + "reference": "fcaefacf2d5c417e928405b71b400d4ce10daaf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/536889f2b340489d328f5ffb7b02bb6b183ddedc", - "reference": "536889f2b340489d328f5ffb7b02bb6b183ddedc", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fcaefacf2d5c417e928405b71b400d4ce10daaf4", + "reference": "fcaefacf2d5c417e928405b71b400d4ce10daaf4", "shasum": "" }, "require": { @@ -3382,22 +3382,22 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.29.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.29.1" }, - "time": "2024-05-06T12:04:23+00:00" + "time": "2024-05-31T08:52:43+00:00" }, { "name": "phpstan/phpstan", - "version": "1.11.2", + "version": "1.11.3", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "0d5d4294a70deb7547db655c47685d680e39cfec" + "reference": "e64220a05c1209fc856d58e789c3b7a32c0bb9a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/0d5d4294a70deb7547db655c47685d680e39cfec", - "reference": "0d5d4294a70deb7547db655c47685d680e39cfec", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e64220a05c1209fc856d58e789c3b7a32c0bb9a5", + "reference": "e64220a05c1209fc856d58e789c3b7a32c0bb9a5", "shasum": "" }, "require": { @@ -3442,7 +3442,7 @@ "type": "github" } ], - "time": "2024-05-24T13:23:04+00:00" + "time": "2024-05-31T13:53:37+00:00" }, { "name": "phpstan/phpstan-deprecation-rules", @@ -6379,16 +6379,16 @@ }, { "name": "wayofdev/cs-fixer-config", - "version": "v1.4.5", + "version": "v1.5.0", "source": { "type": "git", "url": "https://github.com/wayofdev/php-cs-fixer-config.git", - "reference": "d38222297a12344cb968b85213878534ffffbefc" + "reference": "1300d46e72b7893b038c429585206981820fb4e8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wayofdev/php-cs-fixer-config/zipball/d38222297a12344cb968b85213878534ffffbefc", - "reference": "d38222297a12344cb968b85213878534ffffbefc", + "url": "https://api.github.com/repos/wayofdev/php-cs-fixer-config/zipball/1300d46e72b7893b038c429585206981820fb4e8", + "reference": "1300d46e72b7893b038c429585206981820fb4e8", "shasum": "" }, "require": { @@ -6453,7 +6453,7 @@ "type": "github" } ], - "time": "2024-05-28T13:37:07+00:00" + "time": "2024-05-29T08:43:41+00:00" }, { "name": "webmozart/assert", diff --git a/psalm-baseline.xml b/psalm-baseline.xml index c2612d42..438f66cf 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -222,18 +222,4 @@ - - - - - - - - - - - - - - diff --git a/resources/templates/plain.php b/resources/templates/plain.php deleted file mode 100644 index f3be719d..00000000 --- a/resources/templates/plain.php +++ /dev/null @@ -1,16 +0,0 @@ -
- - - - - -
date
- -

- -

- -
- -
-
diff --git a/src/Application.php b/src/Application.php index 17b6efad..dd041d12 100644 --- a/src/Application.php +++ b/src/Application.php @@ -4,6 +4,9 @@ namespace Buggregator\Trap; +use Buggregator\Trap\Config\Server\Files\SPX as SPXFileConfig; +use Buggregator\Trap\Config\Server\Files\XDebug as XDebugFileConfig; +use Buggregator\Trap\Config\Server\Files\XHProf as XHProfFileConfig; use Buggregator\Trap\Config\Server\Frontend as FrontendConfig; use Buggregator\Trap\Config\Server\SocketServer; use Buggregator\Trap\Handler\Http\Handler\Websocket; @@ -68,6 +71,7 @@ public function __construct( $this->processors[] = $inspector; $withFrontend and $this->configureFrontend(8000); + $this->configureFileObserver(); foreach ($map as $config) { $this->prepareServerFiber($config, $inspector, $this->logger); @@ -143,6 +147,11 @@ function (): void { } }, ); + foreach ($this->processors as $processor) { + if ($processor instanceof Cancellable) { + $processor->cancel(); + } + } } /** @@ -220,4 +229,13 @@ private function createServer(SocketServer $config, Inspector $inspector): Serve logger: $this->logger, ); } + + private function configureFileObserver(): void + { + $this->processors[] = $this->container->make(Service\FilesObserver::class, [ + $this->container->get(XHProfFileConfig::class), + $this->container->get(XDebugFileConfig::class), + $this->container->get(SPXFileConfig::class), + ]); + } } diff --git a/src/Client/TrapHandle.php b/src/Client/TrapHandle.php index 24041b36..f4ba6edf 100644 --- a/src/Client/TrapHandle.php +++ b/src/Client/TrapHandle.php @@ -41,6 +41,7 @@ public static function fromArray(array $array): self * * @param int<0, max> $number The tick number. * @param float $delta The time delta between the current and previous tick. + * @param int<0, max> $memory The memory usage. * * @internal */ diff --git a/src/Client/TrapHandle/StackTrace.php b/src/Client/TrapHandle/StackTrace.php index a8a9bb06..4072bee2 100644 --- a/src/Client/TrapHandle/StackTrace.php +++ b/src/Client/TrapHandle/StackTrace.php @@ -47,11 +47,19 @@ public static function stackTrace(string $baseDir = '', bool $provideObjects = f $cwdLen = \strlen($dir); $stack = []; $internal = false; - foreach ( - \debug_backtrace( - ($provideObjects ? \DEBUG_BACKTRACE_PROVIDE_OBJECT : 0) | \DEBUG_BACKTRACE_IGNORE_ARGS, - ) as $frame - ) { + + /** @var array{ + * function: non-empty-string, + * line?: int, + * file?: string, + * class?: class-string, + * type?: string, + * object?: object, + * args?: list + * } $frame */ + foreach (\debug_backtrace( + ($provideObjects ? \DEBUG_BACKTRACE_PROVIDE_OBJECT : 0) | \DEBUG_BACKTRACE_IGNORE_ARGS, + ) as $frame) { $class = $frame['class'] ?? ''; if (\str_starts_with($class, 'Buggregator\\Trap\\Client\\')) { $internal = true; diff --git a/src/Command/Run.php b/src/Command/Run.php index 69d430c0..a303e48c 100644 --- a/src/Command/Run.php +++ b/src/Command/Run.php @@ -95,7 +95,9 @@ public function createRegistry(OutputInterface $output): Sender\SenderRegistry public function getSubscribedSignals(): array { $result = []; + /** @psalm-suppress MixedAssignment */ \defined('SIGINT') and $result[] = \SIGINT; + /** @psalm-suppress MixedAssignment */ \defined('SIGTERM') and $result[] = \SIGTERM; return $result; diff --git a/src/Config/Server/Files/ObserverConfig.php b/src/Config/Server/Files/ObserverConfig.php new file mode 100644 index 00000000..0d7e7a5e --- /dev/null +++ b/src/Config/Server/Files/ObserverConfig.php @@ -0,0 +1,33 @@ +|null */ + public ?string $converterClass = null; + + /** @var float Scan interval in seconds */ + public float $scanInterval = 5.0; + + /** + * @psalm-assert-if-true non-empty-string $this->path + * @psalm-assert-if-true class-string $this->converterClass + */ + public function isValid(): bool + { + /** @psalm-suppress RedundantCondition */ + return $this->path !== null && $this->converterClass !== null && $this->path !== '' + && \is_a($this->converterClass, FrameConverter::class, true) && $this->scanInterval > 0.0; + } +} diff --git a/src/Config/Server/Files/SPX.php b/src/Config/Server/Files/SPX.php new file mode 100644 index 00000000..01ca54ef --- /dev/null +++ b/src/Config/Server/Files/SPX.php @@ -0,0 +1,17 @@ + Edges sorting algorithm + * Where: + * 0 - Deep-first + * 1 - Deep-first with sorting by WT + * 2 - Level-by-level + * 3 - Level-by-level with sorting by WT + */ + #[Env('TRAP_XHPROF_SORT')] + public int $algorithm = 3; + + /** @var non-empty-string|null Path to XHProf files */ + #[Env('TRAP_XHPROF_PATH')] + #[PhpIni('xhprof.output_dir')] + public ?string $path = null; + + /** @var class-string|null */ + public ?string $converterClass = Converter::class; +} diff --git a/src/Processable.php b/src/Processable.php index 682c999f..a38e9bd2 100644 --- a/src/Processable.php +++ b/src/Processable.php @@ -5,7 +5,7 @@ namespace Buggregator\Trap; /** - * Must be processed in a main loop. + * Must be processed in a main loop outside a Fiber * * @internal */ diff --git a/src/Proto/Frame/Profiler.php b/src/Proto/Frame/Profiler.php new file mode 100644 index 00000000..df15912d --- /dev/null +++ b/src/Proto/Frame/Profiler.php @@ -0,0 +1,42 @@ +payload->jsonSerialize() + ['']); + } +} diff --git a/src/Proto/Frame/Profiler/Payload.php b/src/Proto/Frame/Profiler/Payload.php new file mode 100644 index 00000000..685d1798 --- /dev/null +++ b/src/Proto/Frame/Profiler/Payload.php @@ -0,0 +1,102 @@ +metadata['type'] = $type->value; + } + + /** + * @param PayloadType $type + * @param Metadata $metadata + * @param \Closure(): Calls $callsProvider + */ + public static function new( + PayloadType $type, + array $metadata, + \Closure $callsProvider, + ): self { + return new self($type, $metadata, $callsProvider); + } + + /** + * @param array{type: non-empty-string}&Calls&Metadata $data + * @param PayloadType|null $type + */ + public static function fromArray(array $data, ?Type $type = null): static + { + $metadata = $data; + unset($metadata['edges'], $metadata['peaks']); + + /** @var \Closure(): Calls $provider */ + $provider = static fn(): array => $data; + + return new self( + $type ?? PayloadType::from($data['type']), + $metadata, + $provider, + ); + } + + /** + * @return Calls + */ + public function getCalls(): array + { + return ($this->callsProvider)(); + } + + /** + * @return Metadata + */ + public function getMetadata(): array + { + return $this->metadata; + } + + /** + * @return array{type: non-empty-string}&Calls&Metadata + */ + public function toArray(): array + { + return ['type' => $this->type->value] + $this->getCalls() + $this->getMetadata(); + } + + /** + * @return array{type: non-empty-string}&Calls&Metadata + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Proto/Frame/Profiler/Type.php b/src/Proto/Frame/Profiler/Type.php new file mode 100644 index 00000000..6e95a466 --- /dev/null +++ b/src/Proto/Frame/Profiler/Type.php @@ -0,0 +1,16 @@ +value => Frame\VarDumper::fromString($payload, $date), ProtoType::HTTP->value => Frame\Http::fromString($payload, $date), ProtoType::Sentry->value => Frame\Sentry::fromString($payload, $date), + ProtoType::Profiler->value => Frame\Profiler::fromString($payload, $date), default => throw new \RuntimeException('Invalid type.'), }; }, diff --git a/src/ProtoType.php b/src/ProtoType.php index d9aaa2dd..12fa3b2d 100644 --- a/src/ProtoType.php +++ b/src/ProtoType.php @@ -15,4 +15,5 @@ enum ProtoType: string case Monolog = 'monolog'; case Binary = 'binary'; case Sentry = 'sentry'; + case Profiler = 'profiler'; } diff --git a/src/Sender/Console/Renderer/Plain.php b/src/Sender/Console/Renderer/Plain.php index 80cd881d..13eaa272 100644 --- a/src/Sender/Console/Renderer/Plain.php +++ b/src/Sender/Console/Renderer/Plain.php @@ -6,6 +6,7 @@ use Buggregator\Trap\Proto\Frame; use Buggregator\Trap\Sender\Console\Renderer; +use Buggregator\Trap\Sender\Console\Support\Common; use Symfony\Component\Console\Output\OutputInterface; /** @@ -15,10 +16,6 @@ */ final class Plain implements Renderer { - public function __construct( - private readonly TemplateRenderer $renderer, - ) {} - public function isSupport(Frame $frame): bool { return true; @@ -26,13 +23,14 @@ public function isSupport(Frame $frame): bool public function render(OutputInterface $output, Frame $frame): void { - $this->renderer->render( - 'plain', - [ - 'date' => $frame->time->format('Y-m-d H:i:s.u'), - 'channel' => \strtoupper($frame->type->value), - 'body' => \htmlspecialchars((string) $frame), - ], - ); + Common::renderHeader1($output, $frame->type->value); + + Common::renderMetadata($output, [ + 'Time' => $frame->time->format('Y-m-d H:i:s.u'), + 'Frame' => $frame::class, + ]); + + Common::renderHeader2($output, 'Payload:'); + $output->writeln((string) $frame); } } diff --git a/src/Sender/Console/Renderer/Profiler.php b/src/Sender/Console/Renderer/Profiler.php new file mode 100644 index 00000000..2ebaba68 --- /dev/null +++ b/src/Sender/Console/Renderer/Profiler.php @@ -0,0 +1,41 @@ + + * + * @internal + */ +final class Profiler implements Renderer +{ + public function isSupport(Frame $frame): bool + { + return $frame->type === ProtoType::Profiler; + } + + public function render(OutputInterface $output, Frame $frame): void + { + \assert($frame instanceof Frame\Profiler); + + $subtitle = $frame->payload->type->value; + Common::renderHeader1($output, 'PROFILER', $subtitle); + + $metadata = $frame->payload->getMetadata(); + $data = []; + isset($metadata['date']) && \is_numeric($metadata['date']) + and $data['Time'] = new \DateTimeImmutable('@' . $metadata['date']); + isset($metadata['hostname']) and $data['Hostname'] = $metadata['hostname']; + isset($metadata['filename']) and $data['File name'] = $metadata['filename']; + + Common::renderMetadata($output, $data); + } +} diff --git a/src/Sender/ConsoleSender.php b/src/Sender/ConsoleSender.php index 074410d0..a5d9ed0d 100644 --- a/src/Sender/ConsoleSender.php +++ b/src/Sender/ConsoleSender.php @@ -41,8 +41,9 @@ public static function create(OutputInterface $output): self $renderer->register(new Renderer\Monolog($templateRenderer)); $renderer->register(new Renderer\Smtp()); $renderer->register(new Renderer\Http()); + $renderer->register(new Renderer\Profiler()); $renderer->register(new Renderer\Binary()); - $renderer->register(new Renderer\Plain($templateRenderer)); + $renderer->register(new Renderer\Plain()); return new self($renderer); } diff --git a/src/Sender/Frontend/FrameMapper.php b/src/Sender/Frontend/FrameMapper.php index c69d1f7d..da83fe1c 100644 --- a/src/Sender/Frontend/FrameMapper.php +++ b/src/Sender/Frontend/FrameMapper.php @@ -20,6 +20,7 @@ public function map(Frame $frame): Event Frame\Sentry\SentryStore::class => (new Mapper\SentryStore())->map($frame), Frame\Sentry\SentryEnvelope::class => (new Mapper\SentryEnvelope())->map($frame), Frame\Monolog::class => (new Mapper\Monolog())->map($frame), + Frame\Profiler::class => (new Mapper\Profiler())->map($frame), default => throw new \InvalidArgumentException('Unknown frame type ' . $frame::class), }; } diff --git a/src/Sender/Frontend/Mapper/Profiler.php b/src/Sender/Frontend/Mapper/Profiler.php new file mode 100644 index 00000000..c0ec77df --- /dev/null +++ b/src/Sender/Frontend/Mapper/Profiler.php @@ -0,0 +1,24 @@ +payload->toArray(), + timestamp: (float) $frame->time->format('U.u'), + ); + } +} diff --git a/src/Service/Config/ConfigLoader.php b/src/Service/Config/ConfigLoader.php index 5990d382..9fde86c8 100644 --- a/src/Service/Config/ConfigLoader.php +++ b/src/Service/Config/ConfigLoader.php @@ -59,10 +59,18 @@ private function injectValue(object $config, \ReflectionProperty $property, arra /** @var mixed $value */ $value = match (true) { - $attribute instanceof XPath => @$this->xml?->xpath($attribute->path)[$attribute->key], + $attribute instanceof XPath => (static fn(array|false|null $value, int $key): mixed + => \is_array($value) && \array_key_exists($key, $value) + ? $value[$key] + : null)($this->xml?->xpath($attribute->path), $attribute->key), $attribute instanceof Env => $this->env[$attribute->name] ?? null, $attribute instanceof InputOption => $this->inputOptions[$attribute->name] ?? null, $attribute instanceof InputArgument => $this->inputArguments[$attribute->name] ?? null, + $attribute instanceof PhpIni => (static fn(string|false $value): ?string => match ($value) { + // Option does not exist or set to null + '', false => null, + default => $value, + })(\ini_get($attribute->option)), default => null, }; diff --git a/src/Service/Config/PhpIni.php b/src/Service/Config/PhpIni.php new file mode 100644 index 00000000..bf8efd8a --- /dev/null +++ b/src/Service/Config/PhpIni.php @@ -0,0 +1,16 @@ +injector->make($class, \array_merge((array) $binding, $arguments)); } catch (\Throwable $e) { - throw new class(previous: $e) extends \RuntimeException implements NotFoundExceptionInterface {}; + throw new class("Unable to create object of class $class.", previous: $e, ) extends \RuntimeException implements NotFoundExceptionInterface {}; } } diff --git a/src/Service/FilesObserver.php b/src/Service/FilesObserver.php new file mode 100644 index 00000000..25ceb30f --- /dev/null +++ b/src/Service/FilesObserver.php @@ -0,0 +1,78 @@ +isValid()) { + continue; + } + + $this->fibers[] = new \Fiber(function () use ($config): void { + foreach ($this->container->make(Handler::class, [$config]) as $frame) { + $this->propagateFrame($frame); + } + }); + } + } + + public function process(): void + { + if ($this->cancelled) { + return; + } + + foreach ($this->fibers as $key => $fiber) { + try { + $fiber->isStarted() ? $fiber->resume() : $fiber->start(); + + if ($fiber->isTerminated()) { + unset($this->fibers[$key]); + } + } catch (\Throwable $e) { + $this->logger->exception($e); + unset($this->fibers[$key]); + } + } + } + + public function cancel(): void + { + $this->cancelled = true; + $this->fibers = []; + } + + private function propagateFrame(Frame $frame): void + { + $this->buffer->addFrame($frame); + } +} diff --git a/src/Service/FilesObserver/Converter/Branch.php b/src/Service/FilesObserver/Converter/Branch.php new file mode 100644 index 00000000..6c23e4f9 --- /dev/null +++ b/src/Service/FilesObserver/Converter/Branch.php @@ -0,0 +1,33 @@ +> $children + * @param Branch|null $parent + */ + public function __construct( + public object $item, + public readonly string $id, + public readonly ?string $parentId, + public array $children = [], + public ?Branch $parent = null, + ) {} + + public function __destruct() + { + unset($this->item, $this->children, $this->parent); + } +} diff --git a/src/Service/FilesObserver/Converter/Cost.php b/src/Service/FilesObserver/Converter/Cost.php new file mode 100644 index 00000000..d16de636 --- /dev/null +++ b/src/Service/FilesObserver/Converter/Cost.php @@ -0,0 +1,133 @@ + */ + public int $d_cpu = 0; + + /** @var int */ + public int $d_ct = 0; + + /** @var int */ + public int $d_mu = 0; + + /** @var int */ + public int $d_pmu = 0; + + /** @var int */ + public int $d_wt = 0; + + /** + * @param int<0, max> $ct + * @param int<0, max> $wt + * @param int<0, max> $cpu + * @param int<0, max> $mu + * @param int<0, max> $pmu + */ + public function __construct( + public readonly int $ct, + public readonly int $wt, + public readonly int $cpu, + public readonly int $mu, + public readonly int $pmu, + ) {} + + /** + * @param array{ + * ct: int<0, max>, + * wt: int<0, max>, + * cpu: int<0, max>, + * mu: int<0, max>, + * pmu: int<0, max>, + * p_ct?: float, + * p_wt?: float, + * p_cpu?: float, + * p_mu?: float, + * p_pmu?: float, + * d_ct?: int, + * d_wt?: int, + * d_cpu?: int, + * d_mu?: int, + * d_pmu?: int + * } $data + */ + public static function fromArray(array $data): self + { + $self = new self( + $data['ct'], + $data['wt'], + $data['cpu'], + $data['mu'], + $data['pmu'], + ); + $self->p_ct = $data['p_ct'] ?? 0; + $self->p_wt = $data['p_wt'] ?? 0; + $self->p_cpu = $data['p_cpu'] ?? 0; + $self->p_mu = $data['p_mu'] ?? 0; + $self->p_pmu = $data['p_pmu'] ?? 0; + $self->d_ct = $data['d_ct'] ?? 0; + $self->d_wt = $data['d_wt'] ?? 0; + $self->d_cpu = $data['d_cpu'] ?? 0; + $self->d_mu = $data['d_mu'] ?? 0; + $self->d_pmu = $data['d_pmu'] ?? 0; + + return $self; + } + + /** + * @return array{ + * ct: int<0, max>, + * wt: int<0, max>, + * cpu: int<0, max>, + * mu: int<0, max>, + * pmu: int<0, max>, + * p_ct: float, + * p_wt: float, + * p_cpu: float, + * p_mu: float, + * p_pmu: float, + * d_ct: int, + * d_wt: int, + * d_cpu: int, + * d_mu: int, + * d_pmu: int + * } + */ + public function jsonSerialize(): array + { + return [ + 'ct' => $this->ct, + 'wt' => $this->wt, + 'cpu' => $this->cpu, + 'mu' => $this->mu, + 'pmu' => $this->pmu, + 'p_ct' => $this->p_ct, + 'p_wt' => $this->p_wt, + 'p_cpu' => $this->p_cpu, + 'p_mu' => $this->p_mu, + 'p_pmu' => $this->p_pmu, + 'd_ct' => $this->d_ct, + 'd_wt' => $this->d_wt, + 'd_cpu' => $this->d_cpu, + 'd_mu' => $this->d_mu, + 'd_pmu' => $this->d_pmu, + ]; + } +} diff --git a/src/Service/FilesObserver/Converter/Edge.php b/src/Service/FilesObserver/Converter/Edge.php new file mode 100644 index 00000000..7d30b748 --- /dev/null +++ b/src/Service/FilesObserver/Converter/Edge.php @@ -0,0 +1,30 @@ + $this->caller, + 'callee' => $this->callee, + 'cost' => $this->cost, + ]; + } +} diff --git a/src/Service/FilesObserver/Converter/Tree.php b/src/Service/FilesObserver/Converter/Tree.php new file mode 100644 index 00000000..53731262 --- /dev/null +++ b/src/Service/FilesObserver/Converter/Tree.php @@ -0,0 +1,145 @@ +> + * + * @internal + */ +final class Tree implements \IteratorAggregate +{ + /** @var array> */ + private array $root = []; + + /** @var array> */ + private array $all = []; + + /** @var array> */ + private array $lostChildren = []; + + /** + * @template T of object + * + * @param array $edges + * @param callable(T): non-empty-string $getCurrent Get current node id + * @param callable(T): (non-empty-string|null) $getParent Get parent node id + * + * @return self + */ + public static function fromEdgesList(array $edges, callable $getCurrent, callable $getParent): self + { + /** @var self $tree */ + $tree = new self(); + + foreach ($edges as $edge) { + $id = $getCurrent($edge); + $parentId = $getParent($edge); + + $tree->addItem($edge, $id, $parentId); + } + + return $tree; + } + + /** + * @param non-empty-string $id + * @param non-empty-string|null $parentId + */ + public function addItem(object $item, string $id, ?string $parentId): void + { + /** @var TItem $item */ + $branch = new Branch($item, $id, $parentId); + $this->all[$id] = $branch; + + if ($parentId === null) { + $this->root[$id] = $branch; + } else { + $branch->parent = $this->all[$parentId] ?? null; + + $branch->parent === null + ? $this->lostChildren[$id] = $branch + : $branch->parent->children[] = $branch; + } + + foreach ($this->lostChildren as $lostChild) { + if ($lostChild->parentId === $id) { + $branch->children[] = $lostChild; + unset($this->lostChildren[$lostChild->id]); + } + } + } + + /** + * Iterate all the branches without sorting and hierarchy. + * + * @return \Traversable> + */ + public function getIterator(): \Traversable + { + yield from $this->all; + } + + /** + * Yield items by the level in the hierarchy with custom sorting in level scope + * + * @param callable(Branch, Branch): int $sorter + * + * @return \Traversable + */ + public function getItemsSortedV1(?callable $sorter): \Traversable + { + $level = 0; + /** @var array, list>> $queue */ + $queue = [$level => $this->root]; + processLevel: + while ($queue[$level] !== []) { + $branch = \array_shift($queue[$level]); + yield $branch->item; + + // Fill the next level + $queue[$level + 1] ??= []; + \array_unshift($queue[$level + 1], ...$branch->children); + } + + if (\array_key_exists(++$level, $queue)) { + $sorter === null or \usort($queue[$level], $sorter); + + goto processLevel; + } + } + + /** + * Yield items deep-first. + * + * @param callable(Branch, Branch): int $sorter + * + * @return \Traversable + */ + public function getItemsSortedV0(?callable $sorter): \Traversable + { + $queue = $this->root; + while (\count($queue) > 0) { + $branch = \array_shift($queue); + yield $branch->item; + + $children = $branch->children; + $sorter === null or \usort($children, $sorter); + + \array_unshift($queue, ...$children); + } + } + + public function __destruct() + { + foreach ($this->all as $branch) { + $branch->__destruct(); + } + + unset($this->all, $this->root, $this->lostChildren); + } +} diff --git a/src/Service/FilesObserver/Converter/XHProf.php b/src/Service/FilesObserver/Converter/XHProf.php new file mode 100644 index 00000000..8a0ab73e --- /dev/null +++ b/src/Service/FilesObserver/Converter/XHProf.php @@ -0,0 +1,150 @@ +, + * wt: int<0, max>, + * cpu: int<0, max>, + * mu: int<0, max>, + * pmu: int<0, max> + * }> + * + * @psalm-import-type Metadata from \Buggregator\Trap\Proto\Frame\Profiler\Payload + * @psalm-import-type Calls from \Buggregator\Trap\Proto\Frame\Profiler\Payload + * + * @internal + */ +final class XHProf implements FileFilterInterface +{ + public function __construct( + private readonly Logger $logger, + private readonly XHProfConfig $config, + ) {} + + public function validate(FileInfo $file): bool + { + return $file->getExtension() === 'xhprof'; + } + + /** + * @return \Traversable + */ + public function convert(FileInfo $file): \Traversable + { + try { + /** @var Metadata $metadata */ + $metadata = [ + 'date' => $file->mtime, + 'hostname' => \explode('.', $file->getName(), 2)[0], + 'filename' => $file->getName(), + ]; + + yield new ProfilerFrame( + ProfilerFrame\Payload::new( + type: ProfilerFrame\Type::XHProf, + metadata: $metadata, + callsProvider: function () use ($file): array { + $content = \file_get_contents($file->path); + /** @var RawData $data */ + $data = \unserialize($content, ['allowed_classes' => false]); + return $this->dataToPayload($data); + }, + ), + ); + } catch (\Throwable $e) { + $this->logger->exception($e); + } + } + + /** + * @param RawData $data + * @return Calls + */ + private function dataToPayload(array $data): array + { + $peaks = [ + 'cpu' => 0, + 'ct' => 0, + 'mu' => 0, + 'pmu' => 0, + 'wt' => 0, + ]; + + /** @var Tree $tree */ + $tree = new Tree(); + + foreach ($data as $key => $value) { + [$caller, $callee] = \explode('==>', $key, 2) + [1 => '']; + if ($callee === '') { + [$caller, $callee] = [null, $caller]; + } + $caller === '' and $caller = null; + \assert($callee !== ''); + + $edge = new Edge( + caller: $caller, + callee: $callee, + cost: Cost::fromArray($value), + ); + + $peaks['cpu'] = \max($peaks['cpu'], $edge->cost->cpu); + $peaks['ct'] = \max($peaks['ct'], $edge->cost->ct); + $peaks['mu'] = \max($peaks['mu'], $edge->cost->mu); + $peaks['pmu'] = \max($peaks['pmu'], $edge->cost->pmu); + $peaks['wt'] = \max($peaks['wt'], $edge->cost->wt); + + $tree->addItem($edge, $edge->callee, $edge->caller); + } + + /** + * Calc percentages and delta + * @var Branch $branch Needed for IDE + */ + foreach ($tree->getIterator() as $branch) { + $cost = $branch->item->cost; + $cost->p_cpu = $peaks['cpu'] > 0 ? \round($cost->cpu / $peaks['cpu'] * 100, 3) : 0; + $cost->p_ct = $peaks['ct'] > 0 ? \round($cost->ct / $peaks['ct'] * 100, 3) : 0; + $cost->p_mu = $peaks['mu'] > 0 ? \round($cost->mu / $peaks['mu'] * 100, 3) : 0; + $cost->p_pmu = $peaks['pmu'] > 0 ? \round($cost->pmu / $peaks['pmu'] * 100, 3) : 0; + $cost->p_wt = $peaks['wt'] > 0 ? \round($cost->wt / $peaks['wt'] * 100, 3) : 0; + + if ($branch->parent !== null) { + $parentCost = $branch->parent->item->cost; + $cost->d_cpu = $cost->cpu - $parentCost->cpu; + $cost->d_ct = $cost->ct - $parentCost->ct; + $cost->d_mu = $cost->mu - $parentCost->mu; + $cost->d_pmu = $cost->pmu - $parentCost->pmu; + $cost->d_wt = $cost->wt - $parentCost->wt; + } + } + + return [ + 'edges' => \iterator_to_array(match ($this->config->algorithm) { + // Deep-first + 0 => $tree->getItemsSortedV0(null), + // Deep-first with sorting by WT + 1 => $tree->getItemsSortedV0( + static fn(Branch $a, Branch $b): int => $b->item->cost->wt <=> $a->item->cost->wt, + ), + // Level-by-level + 2 => $tree->getItemsSortedV1(null), + // Level-by-level with sorting by WT + 3 => $tree->getItemsSortedV1( + static fn(Branch $a, Branch $b): int => $b->item->cost->wt <=> $a->item->cost->wt, + ), + default => throw new \LogicException('Unknown XHProf sorting algorithm.'), + }), + 'peaks' => $peaks, + ]; + } +} diff --git a/src/Service/FilesObserver/FileInfo.php b/src/Service/FilesObserver/FileInfo.php new file mode 100644 index 00000000..020064c2 --- /dev/null +++ b/src/Service/FilesObserver/FileInfo.php @@ -0,0 +1,73 @@ + $size + * @param int<0, max> $ctime + * @param int<0, max> $mtime + */ + public function __construct( + public readonly string $path, + public readonly int $size, + public readonly int $ctime, + public readonly int $mtime, + ) {} + + public static function fromSplFileInfo(\SplFileInfo $fileInfo): self + { + /** @psalm-suppress ArgumentTypeCoercion */ + return new self( + $fileInfo->getRealPath(), + $fileInfo->getSize(), + $fileInfo->getCTime(), + $fileInfo->getMTime(), + ); + } + + /** + * @param array{ + * path: non-empty-string, + * size: int<0, max>, + * ctime: int<0, max>, + * mtime: int<0, max> + * } $data + */ + public static function fromArray(array $data): self + { + return new self( + $data['path'], + $data['size'], + $data['ctime'], + $data['mtime'], + ); + } + + public function toArray(): array + { + return [ + 'path' => $this->path, + 'size' => $this->size, + 'ctime' => $this->ctime, + 'mtime' => $this->mtime, + ]; + } + + public function getExtension(): string + { + return \pathinfo($this->path, PATHINFO_EXTENSION); + } + + public function getName(): string + { + return \pathinfo($this->path, PATHINFO_FILENAME); + } +} diff --git a/src/Service/FilesObserver/FrameConverter.php b/src/Service/FilesObserver/FrameConverter.php new file mode 100644 index 00000000..d8e8d261 --- /dev/null +++ b/src/Service/FilesObserver/FrameConverter.php @@ -0,0 +1,25 @@ + + */ + public function convert(FileInfo $file): iterable; +} diff --git a/src/Service/FilesObserver/Handler.php b/src/Service/FilesObserver/Handler.php new file mode 100644 index 00000000..bda3f919 --- /dev/null +++ b/src/Service/FilesObserver/Handler.php @@ -0,0 +1,107 @@ + + */ +final class Handler implements \IteratorAggregate +{ + private readonly Timer $timer; + + /** @var array */ + private array $cache = []; + + /** @var non-empty-string */ + private readonly string $path; + + private FrameConverter $converter; + + public function __construct( + Config $config, + private readonly Logger $logger, + Container $container, + ) { + $config->isValid() or throw new \InvalidArgumentException('Invalid configuration.'); + + $this->path = $config->path; + $this->timer = new Timer($config->scanInterval); + $this->converter = $container->make($config->converterClass, [$config]); + } + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable + { + do { + foreach ($this->syncFiles() as $info) { + yield from $this->converter->convert($info); + } + + $this->timer->wait()->reset(); + } while (true); + } + + /** + * @return list + */ + private function syncFiles(): array + { + $files = $this->getFiles(); + $newFiles = []; + $newState = []; + + foreach ($files as $info) { + $path = $info->path; + if (\array_key_exists($path, $this->cache)) { + $newState[$path] = $this->cache[$path]; + continue; + } + + $newState[$path] = $info; + $newFiles[] = $info; + } + + $this->cache = $newState; + return $newFiles; + } + + /** + * @return \Traversable + */ + private function getFiles(): \Traversable + { + try { + /** @var \Iterator<\SplFileInfo> $iterator */ + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->path, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST, + ); + + foreach ($iterator as $fileInfo) { + if ($fileInfo->isFile() && $this->converter->validate($info = FileInfo::fromSplFileInfo($fileInfo))) { + yield $info; + } + } + } catch (\Throwable $e) { + $this->logger->info('Failed to read files from path `%s`', $this->path); + $this->logger->exception($e); + } + } +} diff --git a/src/Socket/Client.php b/src/Socket/Client.php index 9b1a2692..61d0ec55 100644 --- a/src/Socket/Client.php +++ b/src/Socket/Client.php @@ -112,7 +112,9 @@ public function process(): void */ public function setOnPayload(callable $callable): void { - $this->onPayload = @\Closure::bind($callable(...), $this) ?? $callable(...); + $closure = $callable(...); + /** @psalm-suppress PossiblyNullPropertyAssignmentValue, InvalidArgument */ + $this->onPayload = @\Closure::bind($closure, $this) ?? $closure; } /** @@ -121,7 +123,9 @@ public function setOnPayload(callable $callable): void */ public function setOnClose(callable $callable): void { - $this->onClose = @\Closure::bind($callable(...), $this) ?? $callable(...); + $closure = $callable(...); + /** @psalm-suppress PossiblyNullPropertyAssignmentValue, InvalidArgument */ + $this->onClose = @\Closure::bind($closure, $this) ?? $closure; } public function send(string $payload): void diff --git a/src/functions.php b/src/functions.php index 88f0f5fa..97a75873 100644 --- a/src/functions.php +++ b/src/functions.php @@ -54,11 +54,13 @@ function tr(mixed ...$values): mixed $mem = $time = \microtime(true); try { if ($values === []) { + /** @var int<0, max> $memory */ + $memory = \memory_get_usage(); /** @psalm-suppress InternalMethod */ return TrapHandle::fromTicker( $counter, $counter === 0 ? 0 : $mem - $previous, - \memory_get_usage(), + $memory, )->return(); } @@ -95,16 +97,19 @@ function td(mixed ...$values): never * Register the var-dump caster for protobuf messages */ if (\class_exists(AbstractCloner::class)) { - /** @psalm-suppress MixedAssignment */ - AbstractCloner::$defaultCasters[Message::class] ??= [ProtobufCaster::class, 'cast']; - /** @psalm-suppress MixedAssignment */ - AbstractCloner::$defaultCasters[RepeatedField::class] ??= [ProtobufCaster::class, 'castRepeated']; - /** @psalm-suppress MixedAssignment */ - AbstractCloner::$defaultCasters[MapField::class] ??= [ProtobufCaster::class, 'castMap']; - /** @psalm-suppress MixedAssignment */ - AbstractCloner::$defaultCasters[EnumValue::class] ??= [ProtobufCaster::class, 'castEnum']; - /** @psalm-suppress MixedAssignment */ - AbstractCloner::$defaultCasters[Trace::class] = [TraceCaster::class, 'cast']; - /** @psalm-suppress MixedAssignment */ - AbstractCloner::$defaultCasters[TraceFile::class] = [TraceCaster::class, 'castLine']; + /** @psalm-suppress UnsupportedPropertyReferenceUsage */ + $casters = &AbstractCloner::$defaultCasters; + /** + * Define var-dump related casters for protobuf messages and traces. + * + * @var array $casters + */ + $casters[Message::class] ??= [ProtobufCaster::class, 'cast']; + $casters[RepeatedField::class] ??= [ProtobufCaster::class, 'castRepeated']; + $casters[MapField::class] ??= [ProtobufCaster::class, 'castMap']; + $casters[EnumValue::class] ??= [ProtobufCaster::class, 'castEnum']; + $casters[Trace::class] = [TraceCaster::class, 'cast']; + $casters[TraceFile::class] = [TraceCaster::class, 'castLine']; + + unset($casters); }