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 |
- = $date ?> |
-
-
-
-
- = $channel ?>
-
-
-
- = $body ?>
-
-
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);
}