diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
index 2e8713dca..7c2238b41 100644
--- a/.php-cs-fixer.dist.php
+++ b/.php-cs-fixer.dist.php
@@ -13,6 +13,7 @@
->setRules([
'@PER-CS' => true,
'@PHP84Migration' => true,
+ 'no_unused_imports' => true,
])
->setFinder($finder)
;
diff --git a/src/Command/Metrics/AbstractMetricsCommandBase.php b/src/Command/Metrics/AbstractMetricsCommandBase.php
new file mode 100644
index 000000000..ac888a5c2
--- /dev/null
+++ b/src/Command/Metrics/AbstractMetricsCommandBase.php
@@ -0,0 +1,455 @@
+api = $api;
+ $this->config = $config;
+ $this->propertyFormatter = $propertyFormatter;
+ $this->io = $io;
+ }
+
+ public function isEnabled(): bool
+ {
+ if (!$this->config->getBool('api.metrics')) {
+ return false;
+ }
+
+ return parent::isEnabled();
+ }
+
+ protected function addMetricsOptions(): self
+ {
+ $duration = new Duration();
+ $this->addOption(
+ 'range',
+ 'r',
+ InputOption::VALUE_REQUIRED,
+ 'The time range. Metrics will be loaded for this duration until the end time (--to).'
+ . "\n" . 'You can specify units: hours (h), minutes (m), or seconds (s).'
+ . "\n" . \sprintf(
+ 'Minimum %s, maximum 8h or more (depending on the project), default %s.',
+ $duration->humanize(self::MIN_RANGE),
+ $duration->humanize(self::DEFAULT_RANGE),
+ ),
+ );
+ // The $default is left at null so the lack of input can be detected.
+ $this->addOption(
+ 'interval',
+ 'i',
+ InputOption::VALUE_REQUIRED,
+ 'The time interval. Defaults to a division of the range.'
+ . "\n" . 'You can specify units: hours (h), minutes (m), or seconds (s).'
+ . "\n" . \sprintf('Minimum %s.', $duration->humanize(self::MIN_INTERVAL)),
+ );
+ $this->addOption('to', null, InputOption::VALUE_REQUIRED, 'The end time. Defaults to now.');
+ $this->addOption('latest', '1', InputOption::VALUE_NONE, 'Show only the latest single data point');
+ $this->addOption('service', 's', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter by service or application name' . "\n" . Wildcard::HELP);
+ $this->addOption('type', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter by service type (if --service is not provided). The version is not required.' . "\n" . Wildcard::HELP);
+
+ return $this;
+ }
+
+ /**
+ * Returns the metrics URL and collection information for the selected environment.
+ *
+ * @return string|false The link data or false on failure
+ */
+ protected function getMetricsLink(Environment $environment): false|string
+ {
+ $environmentData = $environment->getData();
+
+ $link = $environmentData['_links']['#observability-pipeline']['href'];
+
+ $data = json_decode($this->api->getClient()->getConnector()->getClient()->request('GET', $link)->getBody()->getContents(), true);
+
+ return $data['_links']['resources_overview']['href'] ?? false;
+ }
+
+ /**
+ * @param InputInterface $input
+ * @param array $metricTypes
+ * @param array $metricAggs
+ * @return array
+ * @throws \GuzzleHttp\Exception\GuzzleException
+ */
+ protected function processQuery(InputInterface $input, array $metricTypes, array $metricAggs): array
+ {
+ // Common
+ $timeSpec = $this->validateTimeInput($input);
+ if (false === $timeSpec) {
+ throw new \InvalidArgumentException('Invalid time input. Please check the --range, --to, and --interval options.');
+ }
+
+ // Common
+ $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true, chooseEnvFilter: $this->getChooseEnvFilter()));
+ $environment = $selection->getEnvironment();
+
+ // Common
+ if (!$this->table->formatIsMachineReadable()) {
+ $this->selector->ensurePrintedSelection($selection);
+ }
+
+ if (!$environment->getCurrentDeployment()) {
+ throw new \RuntimeException('The environment does not have a current deployment.');
+ }
+
+ if (!$link = $this->getMetricsLink($environment)) {
+ throw new \InvalidArgumentException('Metrics API link not found for the environment.');
+ }
+
+ $query = Query::fromTimeSpec($timeSpec);
+
+ $metricsQueryUrl = $link;
+
+ $selectedServiceNames = $this->getServices($input, $environment);
+ if (!empty($selectedServiceNames)) {
+ $this->io->debug('Selected service(s): ' . implode(', ', $selectedServiceNames));
+ $query->setServices($selectedServiceNames);
+ }
+ $this->io->debug('Selected type(s): ' . implode(', ', $metricTypes));
+ $query->setTypes($metricTypes);
+ $this->io->debug('Selected agg(s): ' . implode(', ', $metricAggs));
+ $query->setAggs($metricAggs);
+
+ if ($this->stdErr->isDebug()) {
+ $this->io->debug('Metrics query: ' . json_encode($query->asArray(), JSON_PRETTY_PRINT, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
+ }
+
+ // Perform the metrics query.
+ $client = $this->api->getHttpClient();
+ $request = new Request('GET', $metricsQueryUrl . $query->asString());
+
+ try {
+ $result = $client->send($request);
+ } catch (BadResponseException $e) {
+ throw ApiResponseException::create($request, $e->getResponse(), $e);
+ }
+
+ // Decode the response.
+ $content = $result->getBody()->__toString();
+ $items = json_decode($content, true);
+
+ if (empty($items)) {
+ $this->stdErr->writeln('No data points found.');
+
+ throw new \RuntimeException('No data points were found in the metrics response.');
+ }
+
+ // Filter to only the latest timestamp if --latest is given.
+ if ($input->getOption('latest')) {
+ foreach (array_reverse($items['data']) as $item) {
+ if (isset($item['services'])) {
+ $items['data'] = [$item];
+ break;
+ }
+ }
+ }
+
+ // It's possible that there is nothing to display, e.g. if the router
+ // has been filtered out and no metrics were available for the other
+ // services, perhaps because the environment was paused.
+ if (empty($items['data'])) {
+ $this->stdErr->writeln('No values were found to display.');
+
+ if ('paused' === $environment->status) {
+ $this->stdErr->writeln('');
+ $this->stdErr->writeln('The environment is currently paused.');
+ $this->stdErr->writeln('Metrics collection will start when the environment is redeployed.');
+ }
+
+ throw new \RuntimeException('No data points were found in the metrics response.');
+ }
+
+ return [$items, $environment];
+ }
+
+ /** @return array */
+ private function getServices(InputInterface $input, Environment $environment): array
+ {
+ // Select services based on the --service or --type options.
+ $deployment = $this->api->getCurrentDeployment($environment);
+ $allServices = array_merge($deployment->webapps, $deployment->services, $deployment->workers);
+ $servicesInput = ArrayArgument::getOption($input, 'service');
+ $selectedServiceNames = [];
+ if (!empty($servicesInput)) {
+ $selectedServiceNames = Wildcard::select(array_merge(array_keys($allServices), ['router']), $servicesInput);
+ if (!$selectedServiceNames) {
+ $this->stdErr->writeln('No services were found matching the name(s): ' . implode(', ', $servicesInput) . '');
+
+ throw new \RuntimeException('No services were found matching the name(s): ' . implode(', ', $servicesInput));
+ }
+ } elseif ($typeInput = ArrayArgument::getOption($input, 'type')) {
+ $byType = [];
+ foreach ($allServices as $name => $service) {
+ $type = $service->type;
+ [$prefix] = explode(':', $service->type, 2);
+ $byType[$type][] = $name;
+ $byType[$prefix][] = $name;
+ }
+ $selectedKeys = Wildcard::select(array_merge(array_keys($byType), ['router']), $typeInput);
+ if (!$selectedKeys) {
+ $this->stdErr->writeln('No services were found matching the type(s): ' . implode(', ', $typeInput) . '');
+
+ throw new \RuntimeException('No services were found matching the type(s): ' . implode(', ', $typeInput));
+ }
+ foreach ($selectedKeys as $selectedKey) {
+ $selectedServiceNames = array_merge($selectedServiceNames, $byType[$selectedKey]);
+ }
+ $selectedServiceNames = array_unique($selectedServiceNames);
+ }
+
+ return $selectedServiceNames;
+ }
+
+ protected function getChooseEnvFilter(): ?callable
+ {
+ return null;
+ }
+
+ /**
+ * Validates the interval and range input, and finds defaults.
+ *
+ * Sets the startTime, endTime, and interval properties.
+ *
+ * @see self::startTime, self::$endTime, self::$interval
+ */
+ protected function validateTimeInput(InputInterface $input): false|TimeSpec
+ {
+ if ($input->getOption('interval')) {
+ $this->stdErr->writeln('The --interval option is deprecated, it will be removed in future versions.');
+ }
+
+ if ($to = $input->getOption('to')) {
+ $endTime = \strtotime((string) $to);
+ if (!$endTime) {
+ $this->stdErr->writeln('Failed to parse --to time: ' . $to);
+
+ return false;
+ }
+ } else {
+ $endTime = time();
+ }
+ if ($rangeStr = $input->getOption('range')) {
+ $rangeSeconds = (new Duration())->toSeconds($rangeStr);
+ if (empty($rangeSeconds)) {
+ $this->stdErr->writeln('Invalid --range: ' . $rangeStr . '');
+
+ return false;
+ } elseif ($rangeSeconds < self::MIN_RANGE) {
+ $this->stdErr->writeln(\sprintf('The --range %s is too short: it must be at least %d seconds (%s).', $rangeStr, self::MIN_RANGE, (new Duration())->humanize(self::MIN_RANGE)));
+
+ return false;
+ }
+ $rangeSeconds = (int) $rangeSeconds;
+ } else {
+ $rangeSeconds = self::DEFAULT_RANGE;
+ }
+
+ $startTime = $endTime - $rangeSeconds;
+
+ return new TimeSpec($startTime, $endTime);
+ }
+
+ /**
+ * @param array $values
+ * @param array $fieldMapping
+ * @param Environment $environment
+ * @return array
+ * @throws \Exception
+ */
+ protected function buildRows(array $values, array $fieldMapping, Environment $environment): array
+ {
+ $sortServices = $this->getSortedServices($environment);
+ $serviceTypes = [];
+
+ $rows = [];
+ $lastCountPerTimestamp = 0;
+ foreach ($values['data'] as $point) {
+ $timestamp = $point['timestamp'];
+
+ if (!isset($point['services'])) {
+ continue;
+ }
+
+ $byService = $point['services'];
+ // Add a separator if there was more than one row for the previous timestamp.
+ if ($lastCountPerTimestamp > 1) {
+ $rows[] = new TableSeparator();
+ }
+ $startCount = count($rows);
+ $formattedTimestamp = $this->propertyFormatter->formatDate($timestamp);
+
+ uksort($byService, $sortServices);
+ foreach ($byService as $service => $byDimension) {
+ if (!isset($serviceTypes[$service])) {
+ $serviceTypes[$service] = $this->getServiceType($environment, $service);
+ }
+
+ $row = [];
+ $row['timestamp'] = new AdaptiveTableCell($formattedTimestamp, ['wrap' => false]);
+ $row['service'] = $service;
+ $row['type'] = $this->propertyFormatter->format($serviceTypes[$service], 'service_type');
+ foreach ($fieldMapping as $field => $fieldDefinition) {
+ /* @var Field $fieldDefinition */
+ $row[$field] = $fieldDefinition->format->format($this->getValueFromSource($byDimension, $fieldDefinition->value), $fieldDefinition->warn);
+ }
+ $rows[] = $row;
+ }
+ $lastCountPerTimestamp = count($rows) - $startCount;
+ }
+
+ return $rows;
+ }
+
+ private function getServiceType(Environment $environment, string $service): string
+ {
+ $deployment = $this->api->getCurrentDeployment($environment);
+
+ if (isset($deployment->services[$service])) {
+ $type = $deployment->services[$service]->type;
+ } elseif (isset($deployment->webapps[$service])) {
+ $type = $deployment->webapps[$service]->type;
+ } elseif (isset($deployment->workers[$service])) {
+ $type = $deployment->workers[$service]->type;
+ } else {
+ $type = '';
+ }
+
+ return $type;
+ }
+
+ private function getSortedServices(Environment $environment): \Closure
+ {
+ $deployment = $this->api->getCurrentDeployment($environment);
+
+ // Create a closure which can sort services by name, putting apps and
+ // workers first.
+ $appAndWorkerNames = array_keys(array_merge($deployment->webapps, $deployment->workers));
+ sort($appAndWorkerNames, SORT_NATURAL);
+ $serviceNames = array_keys($deployment->services);
+ sort($serviceNames, SORT_NATURAL);
+ $nameOrder = array_flip(array_merge($appAndWorkerNames, $serviceNames, ['router']));
+ $sortServices = function ($a, $b) use ($nameOrder): int {
+ $aPos = $nameOrder[$a] ?? 1000;
+ $bPos = $nameOrder[$b] ?? 1000;
+
+ return $aPos > $bPos ? 1 : ($aPos < $bPos ? -1 : 0);
+ };
+
+ return $sortServices;
+ }
+
+ /**
+ * @param array $point
+ * @param SourceField|SourceFieldPercentage $fieldDefinition
+ * @return float|null
+ */
+ private function getValueFromSource(array $point, SourceField|SourceFieldPercentage $fieldDefinition): ?float
+ {
+ if ($fieldDefinition instanceof SourceFieldPercentage) {
+ $value = $this->extractValue($point, $fieldDefinition->value);
+ $limit = $this->extractValue($point, $fieldDefinition->limit);
+
+ return $limit > 0 ? $value / $limit * 100 : null;
+ }
+
+ return $this->extractValue($point, $fieldDefinition);
+ }
+
+ /**
+ * @param array $point
+ * @param SourceField $sourceField
+ * @return float|null
+ */
+ private function extractValue(array $point, SourceField $sourceField): ?float
+ {
+ if (isset($sourceField->mountpoint)) {
+ if (!isset($point['mountpoints'][$sourceField->mountpoint])) {
+ return null;
+ }
+ if (!isset($point['mountpoints'][$sourceField->mountpoint][$sourceField->source->value])) {
+ throw new \RuntimeException(\sprintf('Source "%s" not found in the mountpoint "%s".', $sourceField->source->value, $sourceField->mountpoint));
+ }
+ if (!isset($point['mountpoints'][$sourceField->mountpoint][$sourceField->source->value][$sourceField->aggregation->value])) {
+ throw new \RuntimeException(\sprintf('Aggregation "%s" not found for source "%s" in mountpoint "%s".', $sourceField->aggregation->value, $sourceField->source->value, $sourceField->mountpoint));
+ }
+
+ return $point['mountpoints'][$sourceField->mountpoint][$sourceField->source->value][$sourceField->aggregation->value];
+ }
+
+ if (!isset($point[$sourceField->source->value])) {
+ throw new \RuntimeException(\sprintf('Source "%s" not found in the data point.', $sourceField->source->value));
+ }
+ if (!isset($point[$sourceField->source->value][$sourceField->aggregation->value])) {
+ throw new \RuntimeException(\sprintf('Aggregation "%s" not found for source "%s".', $sourceField->aggregation->value, $sourceField->source->value));
+ }
+
+ return $point[$sourceField->source->value][$sourceField->aggregation->value];
+ }
+
+ /**
+ * Shows an explanation if services were found that use high memory.
+ */
+ protected function explainHighMemoryServices(): void
+ {
+ if ($this->foundHighMemoryServices) {
+ $this->stdErr->writeln('');
+ $this->stdErr->writeln('Note: it is possible for service memory usage to appear high even in normal circumstances.');
+ }
+ }
+}
diff --git a/src/Command/Metrics/AllMetricsCommand.php b/src/Command/Metrics/AllMetricsCommand.php
index 2bffc6a01..989ec2335 100644
--- a/src/Command/Metrics/AllMetricsCommand.php
+++ b/src/Command/Metrics/AllMetricsCommand.php
@@ -4,10 +4,15 @@
namespace Platformsh\Cli\Command\Metrics;
+use Platformsh\Cli\Model\Metrics\Aggregation;
+use Platformsh\Cli\Model\Metrics\Field;
+use Platformsh\Cli\Model\Metrics\Format;
+use Platformsh\Cli\Model\Metrics\MetricKind;
+use Platformsh\Cli\Model\Metrics\SourceField;
+use Platformsh\Cli\Model\Metrics\SourceFieldPercentage;
use Platformsh\Cli\Selector\SelectorConfig;
use Platformsh\Cli\Selector\Selector;
use Khill\Duration\Duration;
-use Platformsh\Cli\Model\Metrics\Field;
use Platformsh\Cli\Service\PropertyFormatter;
use Platformsh\Cli\Service\Table;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -16,10 +21,10 @@
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'metrics:all', description: 'Show CPU, disk and memory metrics for an environment', aliases: ['metrics', 'met'])]
-class AllMetricsCommand extends MetricsCommandBase
+class AllMetricsCommand extends AbstractMetricsCommandBase
{
/** @var array */
- private array $tableHeader = [
+ private const TABLE_HEADER = [
'timestamp' => 'Timestamp',
'service' => 'Service',
'type' => 'Type',
@@ -50,91 +55,167 @@ class AllMetricsCommand extends MetricsCommandBase
];
/** @var string[] */
- private array $defaultColumns = ['timestamp', 'service', 'cpu_percent', 'mem_percent', 'disk_percent', 'tmp_disk_percent'];
- public function __construct(private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table)
- {
- parent::__construct();
+ private array $defaultColumns = [
+ 'timestamp',
+ 'service',
+
+ 'cpu_percent',
+ 'mem_percent',
+ 'disk_percent',
+ 'inodes_percent',
+
+ 'tmp_disk_percent',
+ 'tmp_inodes_percent',
+ ];
+
+ public function __construct(
+ private readonly PropertyFormatter $propertyFormatter,
+ Selector $selector,
+ Table $table
+ ) {
+ parent::__construct($selector, $table);
}
protected function configure(): void
{
$this->addOption('bytes', 'B', InputOption::VALUE_NONE, 'Show sizes in bytes');
$this->addExample('Show metrics for the last ' . (new Duration())->humanize(self::DEFAULT_RANGE));
- $this->addExample('Show metrics in five-minute intervals over the last hour', '-i 5m -r 1h');
+ $this->addExample('Show metrics over the last hour', ' -r 1h');
$this->addExample('Show metrics for all SQL services', '--type mariadb,%sql');
$this->addMetricsOptions();
$this->selector->addProjectOption($this->getDefinition());
$this->selector->addEnvironmentOption($this->getDefinition());
$this->addCompleter($this->selector);
- Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns);
+ Table::configureInput($this->getDefinition(), self::TABLE_HEADER, $this->defaultColumns);
PropertyFormatter::configureInput($this->getDefinition());
}
- /**
- * {@inheritdoc}
- */
- protected function execute(InputInterface $input, OutputInterface $output): int
+ protected function getChooseEnvFilter(): ?callable
{
- $timeSpec = $this->validateTimeInput($input);
- if ($timeSpec === false) {
- return 1;
- }
-
- $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true, chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive()));
-
- if (!$this->table->formatIsMachineReadable()) {
- $this->selector->ensurePrintedSelection($selection);
- }
+ return SelectorConfig::filterEnvsMaybeActive();
+ }
- // Only request the metrics fields that will be displayed.
- //
- // The fields are the selected column names (according to the $table
- // service), filtered to only those that contain an underscore.
- $fieldNames = array_filter($this->table->columnsToDisplay($this->tableHeader, $this->defaultColumns), fn($c): bool => str_contains((string) $c, '_'));
- $values = $this->fetchMetrics($input, $timeSpec, $selection->getEnvironment(), $fieldNames);
- if ($values === false) {
- return 1;
- }
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ [$values, $environment] = $this->processQuery($input, [
+ MetricKind::API_TYPE_CPU,
+ MetricKind::API_TYPE_DISK,
+ MetricKind::API_TYPE_MEMORY,
+ MetricKind::API_TYPE_INODES,
+ ], [MetricKind::API_AGG_AVG]);
$bytes = $input->getOption('bytes');
$rows = $this->buildRows($values, [
- 'cpu_used' => new Field('cpu_used', Field::FORMAT_ROUNDED_2DP),
- 'cpu_limit' => new Field('cpu_limit', Field::FORMAT_ROUNDED_2DP),
- 'cpu_percent' => new Field('cpu_percent', Field::FORMAT_PERCENT),
-
- 'mem_used' => new Field('mem_used', $bytes ? Field::FORMAT_ROUNDED : Field::FORMAT_MEMORY),
- 'mem_limit' => new Field('mem_limit', $bytes ? Field::FORMAT_ROUNDED : Field::FORMAT_MEMORY),
- 'mem_percent' => new Field('mem_percent', Field::FORMAT_PERCENT),
-
- 'disk_used' => new Field('disk_used', $bytes ? Field::FORMAT_ROUNDED : Field::FORMAT_DISK),
- 'disk_limit' => new Field('disk_limit', $bytes ? Field::FORMAT_ROUNDED : Field::FORMAT_DISK),
- 'disk_percent' => new Field('disk_percent', Field::FORMAT_PERCENT),
-
- 'tmp_disk_used' => new Field('tmp_disk_used', $bytes ? Field::FORMAT_ROUNDED : Field::FORMAT_DISK),
- 'tmp_disk_limit' => new Field('tmp_disk_limit', $bytes ? Field::FORMAT_ROUNDED : Field::FORMAT_DISK),
- 'tmp_disk_percent' => new Field('tmp_disk_percent', Field::FORMAT_PERCENT),
-
- 'inodes_used' => new Field('inodes_used', Field::FORMAT_ROUNDED),
- 'inodes_limit' => new Field('inodes_used', Field::FORMAT_ROUNDED),
- 'inodes_percent' => new Field('inodes_percent', Field::FORMAT_PERCENT),
-
- 'tmp_inodes_used' => new Field('tmp_inodes_used', Field::FORMAT_ROUNDED),
- 'tmp_inodes_limit' => new Field('tmp_inodes_used', Field::FORMAT_ROUNDED),
- 'tmp_inodes_percent' => new Field('tmp_inodes_percent', Field::FORMAT_PERCENT),
- ], $selection->getEnvironment());
+ 'cpu_used' => new Field(
+ Format::Rounded2p,
+ new SourceField(MetricKind::CpuUsed, Aggregation::Avg),
+ ),
+ 'cpu_limit' => new Field(
+ Format::Rounded2p,
+ new SourceField(MetricKind::CpuLimit, Aggregation::Max),
+ ),
+ 'cpu_percent' => new Field(
+ Format::Percent,
+ new SourceFieldPercentage(
+ new SourceField(MetricKind::CpuUsed, Aggregation::Avg),
+ new SourceField(MetricKind::CpuLimit, Aggregation::Max)
+ ),
+ ),
+
+ 'mem_used' => new Field(
+ $bytes ? Format::Rounded : Format::Memory,
+ new SourceField(MetricKind::MemoryUsed, Aggregation::Avg),
+ ),
+ 'mem_limit' => new Field(
+ $bytes ? Format::Rounded : Format::Memory,
+ new SourceField(MetricKind::MemoryLimit, Aggregation::Max),
+ ),
+ 'mem_percent' => new Field(
+ Format::Percent,
+ new SourceFieldPercentage(
+ new SourceField(MetricKind::MemoryUsed, Aggregation::Avg),
+ new SourceField(MetricKind::MemoryLimit, Aggregation::Max)
+ ),
+ false,
+ ),
+
+ 'disk_used' => new Field(
+ $bytes ? Format::Rounded : Format::Disk,
+ new SourceField(MetricKind::DiskUsed, Aggregation::Avg, '/mnt'),
+ ),
+ 'disk_limit' => new Field(
+ $bytes ? Format::Rounded : Format::Disk,
+ new SourceField(MetricKind::DiskLimit, Aggregation::Max, '/mnt'),
+ ),
+ 'disk_percent' => new Field(
+ Format::Percent,
+ new SourceFieldPercentage(
+ new SourceField(MetricKind::DiskUsed, Aggregation::Avg, '/mnt'),
+ new SourceField(MetricKind::DiskLimit, Aggregation::Max, '/mnt')
+ ),
+ ),
+
+ 'tmp_disk_used' => new Field(
+ $bytes ? Format::Rounded : Format::Disk,
+ new SourceField(MetricKind::DiskUsed, Aggregation::Avg, '/tmp'),
+ ),
+ 'tmp_disk_limit' => new Field(
+ $bytes ? Format::Rounded : Format::Disk,
+ new SourceField(MetricKind::DiskLimit, Aggregation::Max, '/tmp'),
+ ),
+ 'tmp_disk_percent' => new Field(
+ Format::Percent,
+ new SourceFieldPercentage(
+ new SourceField(MetricKind::DiskUsed, Aggregation::Avg, '/tmp'),
+ new SourceField(MetricKind::DiskLimit, Aggregation::Max, '/tmp')
+ ),
+ ),
+
+ 'inodes_used' => new Field(
+ Format::Rounded,
+ new SourceField(MetricKind::InodesUsed, Aggregation::Avg, '/mnt'),
+ ),
+ 'inodes_limit' => new Field(
+ Format::Rounded,
+ new SourceField(MetricKind::InodesLimit, Aggregation::Max, '/mnt'),
+ ),
+ 'inodes_percent' => new Field(
+ Format::Percent,
+ new SourceFieldPercentage(
+ new SourceField(MetricKind::InodesUsed, Aggregation::Avg, '/mnt'),
+ new SourceField(MetricKind::InodesLimit, Aggregation::Max, '/mnt')
+ ),
+ ),
+
+ 'tmp_inodes_used' => new Field(
+ Format::Rounded,
+ new SourceField(MetricKind::InodesUsed, Aggregation::Avg, '/tmp'),
+ ),
+ 'tmp_inodes_limit' => new Field(
+ Format::Rounded,
+ new SourceField(MetricKind::InodesLimit, Aggregation::Max, '/tmp'),
+ ),
+ 'tmp_inodes_percent' => new Field(
+ Format::Percent,
+ new SourceFieldPercentage(
+ new SourceField(MetricKind::InodesUsed, Aggregation::Avg, '/tmp'),
+ new SourceField(MetricKind::InodesLimit, Aggregation::Max, '/tmp')
+ ),
+ ),
+ ], $environment);
if (!$this->table->formatIsMachineReadable()) {
$formatter = $this->propertyFormatter;
$this->stdErr->writeln(\sprintf(
'Metrics at %s intervals from %s to %s:',
- (new Duration())->humanize($timeSpec->getInterval()),
- $formatter->formatDate($timeSpec->getStartTime()),
- $formatter->formatDate($timeSpec->getEndTime()),
+ (new Duration())->humanize($values['_grain']),
+ $formatter->formatDate($values['_from']),
+ $formatter->formatDate($values['_to']),
));
}
- $this->table->render($rows, $this->tableHeader, $this->defaultColumns);
+ $this->table->render($rows, self::TABLE_HEADER, $this->defaultColumns);
if (!$this->table->formatIsMachineReadable()) {
$this->explainHighMemoryServices();
diff --git a/src/Command/Metrics/CpuCommand.php b/src/Command/Metrics/CpuCommand.php
index 97f4e7c7f..ee469ea7d 100644
--- a/src/Command/Metrics/CpuCommand.php
+++ b/src/Command/Metrics/CpuCommand.php
@@ -4,10 +4,14 @@
namespace Platformsh\Cli\Command\Metrics;
-use Platformsh\Cli\Selector\SelectorConfig;
+use Platformsh\Cli\Model\Metrics\Aggregation;
+use Platformsh\Cli\Model\Metrics\Field;
+use Platformsh\Cli\Model\Metrics\Format;
+use Platformsh\Cli\Model\Metrics\MetricKind;
+use Platformsh\Cli\Model\Metrics\SourceField;
+use Platformsh\Cli\Model\Metrics\SourceFieldPercentage;
use Platformsh\Cli\Selector\Selector;
use Khill\Duration\Duration;
-use Platformsh\Cli\Model\Metrics\Field;
use Platformsh\Cli\Service\PropertyFormatter;
use Platformsh\Cli\Service\Table;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -15,10 +19,10 @@
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'metrics:cpu', description: 'Show CPU usage of an environment', aliases: ['cpu'])]
-class CpuCommand extends MetricsCommandBase
+class CpuCommand extends AbstractMetricsCommandBase
{
/** @var array */
- private array $tableHeader = [
+ private const TABLE_HEADER = [
'timestamp' => 'Timestamp',
'service' => 'Service',
'type' => 'Type',
@@ -29,9 +33,13 @@ class CpuCommand extends MetricsCommandBase
/** @var string[] */
private array $defaultColumns = ['timestamp', 'service', 'used', 'limit', 'percent'];
- public function __construct(private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table)
- {
- parent::__construct();
+
+ public function __construct(
+ private readonly PropertyFormatter $propertyFormatter,
+ Selector $selector,
+ Table $table
+ ) {
+ parent::__construct($selector, $table);
}
protected function configure(): void
@@ -40,48 +48,43 @@ protected function configure(): void
$this->selector->addProjectOption($this->getDefinition());
$this->selector->addEnvironmentOption($this->getDefinition());
$this->addCompleter($this->selector);
- Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns);
+ Table::configureInput($this->getDefinition(), self::TABLE_HEADER, $this->defaultColumns);
PropertyFormatter::configureInput($this->getDefinition());
}
- /**
- * {@inheritdoc}
- */
protected function execute(InputInterface $input, OutputInterface $output): int
{
- $timeSpec = $this->validateTimeInput($input);
- if ($timeSpec === false) {
- return 1;
- }
-
- $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true));
-
- if (!$this->table->formatIsMachineReadable()) {
- $this->selector->ensurePrintedSelection($selection);
- }
-
- $values = $this->fetchMetrics($input, $timeSpec, $selection->getEnvironment(), ['cpu_used', 'cpu_percent', 'cpu_limit']);
- if ($values === false) {
- return 1;
- }
+ [$values, $environment] = $this->processQuery($input, [MetricKind::API_TYPE_CPU], [MetricKind::API_AGG_AVG]);
$rows = $this->buildRows($values, [
- 'used' => new Field('cpu_used', Field::FORMAT_ROUNDED_2DP),
- 'limit' => new Field('cpu_limit', Field::FORMAT_ROUNDED_2DP),
- 'percent' => new Field('cpu_percent', Field::FORMAT_PERCENT),
- ], $selection->getEnvironment());
+ 'used' => new Field(
+ Format::Rounded2p,
+ new SourceField(MetricKind::CpuUsed, Aggregation::Avg),
+ ),
+ 'limit' => new Field(
+ Format::Rounded2p,
+ new SourceField(MetricKind::CpuLimit, Aggregation::Max),
+ ),
+ 'percent' => new Field(
+ Format::Percent,
+ new SourceFieldPercentage(
+ new SourceField(MetricKind::CpuUsed, Aggregation::Avg),
+ new SourceField(MetricKind::CpuLimit, Aggregation::Max)
+ ),
+ ),
+ ], $environment);
if (!$this->table->formatIsMachineReadable()) {
$formatter = $this->propertyFormatter;
$this->stdErr->writeln(\sprintf(
'Average CPU usage at %s intervals from %s to %s:',
- (new Duration())->humanize($timeSpec->getInterval()),
- $formatter->formatDate($timeSpec->getStartTime()),
- $formatter->formatDate($timeSpec->getEndTime()),
+ (new Duration())->humanize($values['_grain']),
+ $formatter->formatDate($values['_from']),
+ $formatter->formatDate($values['_to']),
));
}
- $this->table->render($rows, $this->tableHeader, $this->defaultColumns);
+ $this->table->render($rows, self::TABLE_HEADER, $this->defaultColumns);
return 0;
}
diff --git a/src/Command/Metrics/CurlCommand.php b/src/Command/Metrics/CurlCommand.php
index 063dbeec3..c8a034e0b 100644
--- a/src/Command/Metrics/CurlCommand.php
+++ b/src/Command/Metrics/CurlCommand.php
@@ -7,17 +7,21 @@
use Platformsh\Cli\Selector\SelectorConfig;
use Platformsh\Cli\Selector\Selector;
use Platformsh\Cli\Service\CurlCli;
+use Platformsh\Cli\Service\Table;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'metrics:curl', description: "Run an authenticated cURL request on an environment's metrics API")]
-class CurlCommand extends MetricsCommandBase
+class CurlCommand extends AbstractMetricsCommandBase
{
protected bool $hiddenInList = true;
- public function __construct(private readonly CurlCli $curlCli, private readonly Selector $selector)
- {
- parent::__construct();
+
+ public function __construct(
+ Selector $selector,
+ Table $table
+ ) {
+ parent::__construct($selector, $table);
}
protected function configure(): void
@@ -31,13 +35,8 @@ protected function configure(): void
protected function execute(InputInterface $input, OutputInterface $output): int
{
- $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true));
-
- $link = $this->getMetricsLink($selection->getEnvironment());
- if (!$link) {
- return 1;
- }
+ $output->writeln('metric:curl command is deprecated and will be removed in future versions');
- return $this->curlCli->run($link['href'], $input, $output);
+ return 1;
}
}
diff --git a/src/Command/Metrics/DiskUsageCommand.php b/src/Command/Metrics/DiskUsageCommand.php
index 841795382..402ff79b6 100644
--- a/src/Command/Metrics/DiskUsageCommand.php
+++ b/src/Command/Metrics/DiskUsageCommand.php
@@ -4,10 +4,14 @@
namespace Platformsh\Cli\Command\Metrics;
-use Platformsh\Cli\Selector\SelectorConfig;
+use Platformsh\Cli\Model\Metrics\Aggregation;
+use Platformsh\Cli\Model\Metrics\Field;
+use Platformsh\Cli\Model\Metrics\Format;
+use Platformsh\Cli\Model\Metrics\MetricKind;
+use Platformsh\Cli\Model\Metrics\SourceField;
+use Platformsh\Cli\Model\Metrics\SourceFieldPercentage;
use Platformsh\Cli\Selector\Selector;
use Khill\Duration\Duration;
-use Platformsh\Cli\Model\Metrics\Field;
use Platformsh\Cli\Service\PropertyFormatter;
use Platformsh\Cli\Service\Table;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -16,10 +20,10 @@
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'metrics:disk-usage', description: 'Show disk usage of an environment', aliases: ['disk'])]
-class DiskUsageCommand extends MetricsCommandBase
+class DiskUsageCommand extends AbstractMetricsCommandBase
{
/** @var array */
- private array $tableHeader = [
+ private const TABLE_HEADER = [
'timestamp' => 'Timestamp',
'service' => 'Service',
'type' => 'Type',
@@ -41,9 +45,12 @@ class DiskUsageCommand extends MetricsCommandBase
/** @var string[] */
private array $tmpReportColumns = ['timestamp', 'service', 'tmp_used', 'tmp_limit', 'tmp_percent', 'tmp_ipercent'];
- public function __construct(private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table)
- {
- parent::__construct();
+ public function __construct(
+ private readonly PropertyFormatter $propertyFormatter,
+ Selector $selector,
+ Table $table
+ ) {
+ parent::__construct($selector, $table);
}
protected function configure(): void
@@ -54,68 +61,103 @@ protected function configure(): void
$this->selector->addProjectOption($this->getDefinition());
$this->selector->addEnvironmentOption($this->getDefinition());
$this->addCompleter($this->selector);
- Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns);
+ Table::configureInput($this->getDefinition(), self::TABLE_HEADER, $this->defaultColumns);
PropertyFormatter::configureInput($this->getDefinition());
}
- /**
- * {@inheritdoc}
- */
protected function execute(InputInterface $input, OutputInterface $output): int
{
- $timeSpec = $this->validateTimeInput($input);
- if ($timeSpec === false) {
- return 1;
- }
-
if ($input->getOption('tmp')) {
$input->setOption('columns', $this->tmpReportColumns);
}
$this->table->removeDeprecatedColumns(['interval'], '', $input, $output);
- $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true));
-
- if (!$this->table->formatIsMachineReadable()) {
- $this->selector->ensurePrintedSelection($selection);
- }
-
- $values = $this->fetchMetrics($input, $timeSpec, $selection->getEnvironment(), ['disk_used', 'disk_percent', 'disk_limit', 'inodes_used', 'inodes_percent', 'inodes_limit']);
- if ($values === false) {
+ try {
+ [$values, $environment] = $this->processQuery($input, [MetricKind::API_TYPE_DISK, MetricKind::API_TYPE_INODES], [MetricKind::API_AGG_AVG]);
+ } catch (\Throwable) {
return 1;
}
$bytes = $input->getOption('bytes');
$rows = $this->buildRows($values, [
- 'used' => new Field('disk_used', $bytes ? Field::FORMAT_ROUNDED : Field::FORMAT_DISK),
- 'limit' => new Field('disk_limit', $bytes ? Field::FORMAT_ROUNDED : Field::FORMAT_DISK),
- 'percent' => new Field('disk_percent', Field::FORMAT_PERCENT),
-
- 'iused' => new Field('inodes_used', FIELD::FORMAT_ROUNDED),
- 'ilimit' => new Field('inodes_limit', FIELD::FORMAT_ROUNDED),
- 'ipercent' => new Field('inodes_percent', Field::FORMAT_PERCENT),
-
- 'tmp_used' => new Field('tmp_disk_used', $bytes ? Field::FORMAT_ROUNDED : Field::FORMAT_DISK),
- 'tmp_limit' => new Field('tmp_disk_limit', $bytes ? Field::FORMAT_ROUNDED : Field::FORMAT_DISK),
- 'tmp_percent' => new Field('tmp_disk_percent', Field::FORMAT_PERCENT),
-
- 'tmp_iused' => new Field('tmp_inodes_used', Field::FORMAT_ROUNDED),
- 'tmp_ilimit' => new Field('tmp_inodes_used', Field::FORMAT_ROUNDED),
- 'tmp_ipercent' => new Field('tmp_inodes_percent', Field::FORMAT_PERCENT),
- ], $selection->getEnvironment());
+ 'used' => new Field(
+ $bytes ? Format::Rounded : Format::Disk,
+ new SourceField(MetricKind::DiskUsed, Aggregation::Avg, '/mnt'),
+ ),
+ 'limit' => new Field(
+ $bytes ? Format::Rounded : Format::Disk,
+ new SourceField(MetricKind::DiskLimit, Aggregation::Max, '/mnt'),
+ ),
+ 'percent' => new Field(
+ Format::Percent,
+ new SourceFieldPercentage(
+ new SourceField(MetricKind::DiskUsed, Aggregation::Avg, '/mnt'),
+ new SourceField(MetricKind::DiskLimit, Aggregation::Max, '/mnt')
+ ),
+ ),
+
+ 'iused' => new Field(
+ Format::Rounded,
+ new SourceField(MetricKind::InodesUsed, Aggregation::Avg, '/mnt'),
+ ),
+ 'ilimit' => new Field(
+ Format::Rounded,
+ new SourceField(MetricKind::InodesLimit, Aggregation::Max, '/mnt'),
+ ),
+ 'ipercent' => new Field(
+ Format::Percent,
+ new SourceFieldPercentage(
+ new SourceField(MetricKind::InodesUsed, Aggregation::Avg, '/mnt'),
+ new SourceField(MetricKind::InodesLimit, Aggregation::Max, '/mnt')
+ ),
+ ),
+
+ 'tmp_used' => new Field(
+ $bytes ? Format::Rounded : Format::Disk,
+ new SourceField(MetricKind::DiskUsed, Aggregation::Avg, '/tmp'),
+ ),
+ 'tmp_limit' => new Field(
+ $bytes ? Format::Rounded : Format::Disk,
+ new SourceField(MetricKind::DiskLimit, Aggregation::Max, '/tmp'),
+ ),
+ 'tmp_percent' => new Field(
+ Format::Percent,
+ new SourceFieldPercentage(
+ new SourceField(MetricKind::DiskUsed, Aggregation::Avg, '/tmp'),
+ new SourceField(MetricKind::DiskLimit, Aggregation::Max, '/tmp')
+ ),
+ ),
+
+ 'tmp_iused' => new Field(
+ Format::Rounded,
+ new SourceField(MetricKind::InodesUsed, Aggregation::Avg, '/tmp'),
+ ),
+ 'tmp_ilimit' => new Field(
+ Format::Rounded,
+ new SourceField(MetricKind::InodesLimit, Aggregation::Max, '/tmp'),
+ ),
+ 'tmp_ipercent' => new Field(
+ Format::Percent,
+ new SourceFieldPercentage(
+ new SourceField(MetricKind::InodesUsed, Aggregation::Avg, '/tmp'),
+ new SourceField(MetricKind::InodesLimit, Aggregation::Max, '/tmp')
+ ),
+ ),
+ ], $environment);
if (!$this->table->formatIsMachineReadable()) {
$formatter = $this->propertyFormatter;
$this->stdErr->writeln(\sprintf(
'Average %s at %s intervals from %s to %s:',
$input->getOption('tmp') ? 'temporary disk usage' : 'disk usage',
- (new Duration())->humanize($timeSpec->getInterval()),
- $formatter->formatDate($timeSpec->getStartTime()),
- $formatter->formatDate($timeSpec->getEndTime()),
+ (new Duration())->humanize($values['_grain']),
+ $formatter->formatDate($values['_from']),
+ $formatter->formatDate($values['_to']),
));
}
- $this->table->render($rows, $this->tableHeader, $this->defaultColumns);
+ $this->table->render($rows, self::TABLE_HEADER, $this->defaultColumns);
return 0;
}
diff --git a/src/Command/Metrics/MemCommand.php b/src/Command/Metrics/MemCommand.php
index 007434c2f..7074ed3b5 100644
--- a/src/Command/Metrics/MemCommand.php
+++ b/src/Command/Metrics/MemCommand.php
@@ -4,10 +4,14 @@
namespace Platformsh\Cli\Command\Metrics;
-use Platformsh\Cli\Selector\SelectorConfig;
+use Platformsh\Cli\Model\Metrics\Aggregation;
+use Platformsh\Cli\Model\Metrics\Field;
+use Platformsh\Cli\Model\Metrics\Format;
+use Platformsh\Cli\Model\Metrics\MetricKind;
+use Platformsh\Cli\Model\Metrics\SourceField;
+use Platformsh\Cli\Model\Metrics\SourceFieldPercentage;
use Platformsh\Cli\Selector\Selector;
use Khill\Duration\Duration;
-use Platformsh\Cli\Model\Metrics\Field;
use Platformsh\Cli\Service\PropertyFormatter;
use Platformsh\Cli\Service\Table;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -16,10 +20,10 @@
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'metrics:memory', description: 'Show memory usage of an environment', aliases: ['mem', 'memory'])]
-class MemCommand extends MetricsCommandBase
+class MemCommand extends AbstractMetricsCommandBase
{
/** @var array */
- private array $tableHeader = [
+ private const TABLE_HEADER = [
'timestamp' => 'Timestamp',
'service' => 'Service',
'type' => 'Type',
@@ -30,9 +34,13 @@ class MemCommand extends MetricsCommandBase
/** @var string[] */
private array $defaultColumns = ['timestamp', 'service', 'used', 'limit', 'percent'];
- public function __construct(private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table)
- {
- parent::__construct();
+
+ public function __construct(
+ private readonly PropertyFormatter $propertyFormatter,
+ Selector $selector,
+ Table $table
+ ) {
+ parent::__construct($selector, $table);
}
protected function configure(): void
@@ -42,50 +50,46 @@ protected function configure(): void
$this->selector->addProjectOption($this->getDefinition());
$this->selector->addEnvironmentOption($this->getDefinition());
$this->addCompleter($this->selector);
- Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns);
+ Table::configureInput($this->getDefinition(), self::TABLE_HEADER, $this->defaultColumns);
PropertyFormatter::configureInput($this->getDefinition());
}
- /**
- * {@inheritdoc}
- */
protected function execute(InputInterface $input, OutputInterface $output): int
{
- $timeSpec = $this->validateTimeInput($input);
- if ($timeSpec === false) {
- return 1;
- }
-
- $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true));
-
- if (!$this->table->formatIsMachineReadable()) {
- $this->selector->ensurePrintedSelection($selection);
- }
-
- $values = $this->fetchMetrics($input, $timeSpec, $selection->getEnvironment(), ['mem_used', 'mem_percent', 'mem_limit']);
- if ($values === false) {
- return 1;
- }
+ [$values, $environment] = $this->processQuery($input, [MetricKind::API_TYPE_MEMORY], [MetricKind::API_AGG_AVG]);
$bytes = $input->getOption('bytes');
$rows = $this->buildRows($values, [
- 'used' => new Field('mem_used', $bytes ? Field::FORMAT_ROUNDED : Field::FORMAT_MEMORY),
- 'limit' => new Field('mem_limit', $bytes ? Field::FORMAT_ROUNDED : Field::FORMAT_MEMORY),
- 'percent' => new Field('mem_percent', Field::FORMAT_PERCENT),
- ], $selection->getEnvironment());
+ 'used' => new Field(
+ $bytes ? Format::Rounded : Format::Memory,
+ new SourceField(MetricKind::MemoryUsed, Aggregation::Avg),
+ ),
+ 'limit' => new Field(
+ $bytes ? Format::Rounded : Format::Memory,
+ new SourceField(MetricKind::MemoryLimit, Aggregation::Max),
+ ),
+ 'percent' => new Field(
+ Format::Percent,
+ new SourceFieldPercentage(
+ new SourceField(MetricKind::MemoryUsed, Aggregation::Avg),
+ new SourceField(MetricKind::MemoryLimit, Aggregation::Max)
+ ),
+ false,
+ ),
+ ], $environment);
if (!$this->table->formatIsMachineReadable()) {
$formatter = $this->propertyFormatter;
$this->stdErr->writeln(\sprintf(
'Average memory usage at %s intervals from %s to %s:',
- (new Duration())->humanize($timeSpec->getInterval()),
- $formatter->formatDate($timeSpec->getStartTime()),
- $formatter->formatDate($timeSpec->getEndTime()),
+ (new Duration())->humanize($values['_grain']),
+ $formatter->formatDate($values['_from']),
+ $formatter->formatDate($values['_to']),
));
}
- $this->table->render($rows, $this->tableHeader, $this->defaultColumns);
+ $this->table->render($rows, self::TABLE_HEADER, $this->defaultColumns);
if (!$this->table->formatIsMachineReadable()) {
$this->explainHighMemoryServices();
diff --git a/src/Command/Metrics/MetricsCommandBase.php b/src/Command/Metrics/MetricsCommandBase.php
deleted file mode 100644
index 51521e746..000000000
--- a/src/Command/Metrics/MetricsCommandBase.php
+++ /dev/null
@@ -1,590 +0,0 @@
-> */
- private array $fields = [
- // Grid.
- 'local' => [
- 'cpu_used' => "AVG(SUM((`cpu.user` + `cpu.kernel`) / `interval`, 'service', 'instance'), 'service')",
- 'cpu_percent' => "AVG(100 * SUM((`cpu.user` + `cpu.kernel`) / (`interval` * `cpu.cores`), 'service', 'instance'), 'service')",
- 'cpu_limit' => "SUM(`cpu.cores`, 'service')",
-
- 'mem_used' => "AVG(SUM(`memory.apps` + `memory.kernel` + `memory.buffers`, 'service', 'instance'), 'service')",
- 'mem_percent' => "AVG(100 * SUM((`memory.apps` + `memory.kernel` + `memory.buffers`) / `memory.limit`, 'service', 'instance'), 'service')",
- 'mem_limit' => "AVG(`memory.limit`, 'service')",
-
- 'disk_used' => "AVG(`disk.space.used`, 'mountpoint', 'service')",
- 'inodes_used' => "AVG(`disk.inodes.used`, 'mountpoint', 'service')",
- 'disk_percent' => "AVG((`disk.space.used`/`disk.space.limit`)*100, 'mountpoint', 'service')",
- 'inodes_percent' => "AVG((`disk.inodes.used`/`disk.inodes.limit`)*100, 'mountpoint', 'service')",
- 'disk_limit' => "AVG(`disk.space.limit`, 'mountpoint', 'service')",
- 'inodes_limit' => "AVG(`disk.inodes.limit`, 'mountpoint', 'service')",
- ],
- // Dedicated Generation 3 (DG3).
- 'dedicated' => [
- 'cpu_used' => "AVG(SUM((`cpu.user` + `cpu.kernel`) / `interval`, 'hostname', 'service', 'instance'), 'service')",
- 'cpu_percent' => "AVG(100 * SUM((`cpu.user` + `cpu.kernel`) / (`interval` * `cpu.cores`), 'hostname', 'service', 'instance'), 'service')",
- 'cpu_limit' => "AVG(`cpu.cores`, 'service')",
-
- 'disk_used' => "AVG(`disk.space.used`, 'mountpoint', 'service')",
- 'inodes_used' => "AVG(`disk.inodes.used`, 'mountpoint', 'service')",
- 'disk_percent' => "AVG((`disk.space.used`/`disk.space.limit`)*100, 'mountpoint', 'service')",
- 'inodes_percent' => "AVG((`disk.inodes.used`/`disk.inodes.limit`)*100, 'mountpoint', 'service')",
- 'disk_limit' => "AVG(`disk.space.limit`, 'mountpoint', 'service')",
- 'inodes_limit' => "AVG(`disk.inodes.limit`, 'mountpoint', 'service')",
-
- 'mem_used' => "AVG(SUM(`memory.apps` + `memory.kernel` + `memory.buffers`, 'hostname', 'service', 'instance'), 'service')",
- 'mem_percent' => "AVG(SUM(100 * (`memory.apps` + `memory.kernel` + `memory.buffers`) / `memory.limit`, 'hostname', 'service', 'instance'), 'service')",
- 'mem_limit' => "AVG(`memory.limit`, 'service')",
- ],
- // Dedicated Generation 2 (DG2), formerly known as "Enterprise".
- 'enterprise' => [
- 'cpu_used' => "AVG(SUM((`cpu.user` + `cpu.kernel`) / `interval`, 'hostname'))",
- 'cpu_percent' => "AVG(100 * SUM((`cpu.user` + `cpu.kernel`) / (`interval` * `cpu.cores`), 'hostname'))",
- 'cpu_limit' => "AVG(`cpu.cores`, 'service')",
-
- 'mem_used' => "AVG(SUM(`memory.apps` + `memory.kernel` + `memory.buffers`, 'hostname'))",
- 'mem_percent' => "AVG(SUM(100 * (`memory.apps` + `memory.kernel` + `memory.buffers`) / `memory.limit`, 'hostname'))",
- 'mem_limit' => "AVG(`memory.limit`, 'service')",
-
- 'disk_used' => "AVG(`disk.space.used`, 'mountpoint')",
- 'inodes_used' => "AVG(`disk.inodes.used`, 'mountpoint')",
- 'disk_percent' => "AVG((`disk.space.used`/`disk.space.limit`)*100, 'mountpoint')",
- 'inodes_percent' => "AVG((`disk.inodes.used`/`disk.inodes.limit`)*100, 'mountpoint')",
- 'disk_limit' => "AVG(`disk.space.limit`, 'mountpoint')",
- 'inodes_limit' => "AVG(`disk.inodes.limit`, 'mountpoint')",
- ],
- ];
- #[Required]
- public function autowire(Api $api, Config $config, Io $io, PropertyFormatter $propertyFormatter): void
- {
- $this->api = $api;
- $this->config = $config;
- $this->propertyFormatter = $propertyFormatter;
- $this->io = $io;
- }
-
- public function isEnabled(): bool
- {
- if (!$this->config->getBool('api.metrics')) {
- return false;
- }
- return parent::isEnabled();
- }
-
- protected function addMetricsOptions(): self
- {
- $duration = new Duration();
- $this->addOption(
- 'range',
- 'r',
- InputOption::VALUE_REQUIRED,
- 'The time range. Metrics will be loaded for this duration until the end time (--to).'
- . "\n" . 'You can specify units: hours (h), minutes (m), or seconds (s).'
- . "\n" . \sprintf(
- 'Minimum %s, maximum 8h or more (depending on the project), default %s.',
- $duration->humanize(self::MIN_RANGE),
- $duration->humanize(self::DEFAULT_RANGE),
- ),
- );
- // The $default is left at null so the lack of input can be detected.
- $this->addOption(
- 'interval',
- 'i',
- InputOption::VALUE_REQUIRED,
- 'The time interval. Defaults to a division of the range.'
- . "\n" . 'You can specify units: hours (h), minutes (m), or seconds (s).'
- . "\n" . \sprintf('Minimum %s.', $duration->humanize(self::MIN_INTERVAL)),
- );
- $this->addOption('to', null, InputOption::VALUE_REQUIRED, 'The end time. Defaults to now.');
- $this->addOption('latest', '1', InputOption::VALUE_NONE, 'Show only the latest single data point');
- $this->addOption('service', 's', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter by service or application name' . "\n" . Wildcard::HELP);
- $this->addOption('type', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter by service type (if --service is not provided). The version is not required.' . "\n" . Wildcard::HELP);
- return $this;
- }
-
- /**
- * Returns the metrics URL and collection information for the selected environment.
- *
- * @return array{'href': string, 'collection': string}|false
- * The link data or false on failure.
- */
- protected function getMetricsLink(Environment $environment): false|array
- {
- $environmentData = $environment->getData();
- if (!isset($environmentData['_links']['#metrics'])) {
- $this->stdErr->writeln(\sprintf('The metrics API is not currently available on the environment: %s', $this->api->getEnvironmentLabel($environment, 'error')));
-
- return false;
- }
- if (!isset($environmentData['_links']['#metrics'][0]['href'], $environmentData['_links']['#metrics'][0]['collection'])) {
- $this->stdErr->writeln(\sprintf('Unable to find metrics URLs for the environment: %s', $this->api->getEnvironmentLabel($environment, 'error')));
-
- return false;
- }
-
- return $environmentData['_links']['#metrics'][0];
- }
-
- /**
- * Splits a dimension string into fields.
- *
- * @param string $dimension
- * @return array
- */
- private function dimensionFields(string $dimension): array
- {
- $fields = ['service' => '', 'mountpoint' => '', 'instance' => ''];
- foreach (explode('/', $dimension) as $field) {
- $parts = explode('=', $field, 2);
- if (count($parts) === 2) {
- $fields[urldecode($parts[0])] = urldecode($parts[1]);
- }
- }
- return $fields;
- }
-
- /**
- * Validates input and fetches metrics.
- *
- * @param InputInterface $input
- * @param TimeSpec $timeSpec
- * @param Environment $environment
- * @param string[] $fieldNames
- * An array of field names, which map to queries in $this->fields.
- *
- * @return false|array>>>
- * False on failure, or an array of sketch values, keyed by: time, service, dimension, and name.
- */
- protected function fetchMetrics(InputInterface $input, TimeSpec $timeSpec, Environment $environment, array $fieldNames): array|false
- {
- $link = $this->getMetricsLink($environment);
- if (!$link) {
- return false;
- }
-
- $query = (new Query())
- ->setStartTime($timeSpec->getStartTime())
- ->setEndTime($timeSpec->getEndTime())
- ->setInterval($timeSpec->getInterval());
-
- $metricsQueryUrl = $link['href'] . '/v1/metrics/query';
- $query->setCollection($link['collection']);
-
- $deploymentType = $this->getDeploymentType($environment);
- if (!isset($this->fields[$deploymentType])) {
- $fallback = key($this->fields);
- $this->stdErr->writeln(sprintf(
- 'No query fields are defined for the deployment type: %s. Falling back to: %s',
- $deploymentType,
- $fallback,
- ));
- $deploymentType = $fallback;
- }
-
- // Add fields and expressions to the query based on the requested $fieldNames.
- $fieldNames = array_map(function ($f): string {
- if (str_starts_with($f, 'tmp_')) {
- return substr($f, 4);
- }
- return $f;
- }, $fieldNames);
- foreach ($this->fields[$deploymentType] as $name => $expression) {
- if (in_array($name, $fieldNames)) {
- $query->addField($name, $expression);
- }
- }
-
- // Select services based on the --service or --type options.
- $deployment = $this->api->getCurrentDeployment($environment);
- $allServices = array_merge($deployment->webapps, $deployment->services, $deployment->workers);
- $servicesInput = ArrayArgument::getOption($input, 'service');
- $selectedServiceNames = [];
- if (!empty($servicesInput)) {
- $selectedServiceNames = Wildcard::select(array_merge(array_keys($allServices), ['router']), $servicesInput);
- if (!$selectedServiceNames) {
- $this->stdErr->writeln('No services were found matching the name(s): ' . implode(', ', $servicesInput) . '');
- return false;
- }
- } elseif ($typeInput = ArrayArgument::getOption($input, 'type')) {
- $byType = [];
- foreach ($allServices as $name => $service) {
- $type = $service->type;
- [$prefix] = explode(':', $service->type, 2);
- $byType[$type][] = $name;
- $byType[$prefix][] = $name;
- }
- $selectedKeys = Wildcard::select(array_merge(array_keys($byType), ['router']), $typeInput);
- if (!$selectedKeys) {
- $this->stdErr->writeln('No services were found matching the type(s): ' . implode(', ', $typeInput) . '');
- return false;
- }
- foreach ($selectedKeys as $selectedKey) {
- $selectedServiceNames = array_merge($selectedServiceNames, $byType[$selectedKey]);
- }
- $selectedServiceNames = array_unique($selectedServiceNames);
- }
- if (!empty($selectedServiceNames)) {
- $this->io->debug('Selected service(s): ' . implode(', ', $selectedServiceNames));
- if (count($selectedServiceNames) === 1) {
- $query->addFilter('service', reset($selectedServiceNames));
- }
- }
-
- if ($this->stdErr->isDebug()) {
- $this->io->debug('Metrics query: ' . json_encode($query->asArray(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
- }
-
- // Perform the metrics query.
- $client = $this->api->getHttpClient();
- $request = new Request('POST', $metricsQueryUrl, [
- 'Content-Type' => 'application/json',
- ], json_encode($query->asArray(), JSON_THROW_ON_ERROR));
- try {
- $result = $client->send($request);
- } catch (BadResponseException $e) {
- throw ApiResponseException::create($request, $e->getResponse(), $e);
- }
-
- // Decode the response.
- $content = $result->getBody()->__toString();
- $items = JsonLines::decode($content);
- if (empty($items)) {
- $this->stdErr->writeln('No data points found.');
- return false;
- }
-
- // Group the returned values by time, service, dimension, and field name.
- // Filter by the selected services.
- $values = [];
- foreach ($items as $item) {
- $time = $item['point']['timestamp'];
- $dimension = $item['point']['dimension'] ?? '';
- $dimensionFields = $this->dimensionFields($dimension);
- $service = $dimensionFields['service'];
- // Skip the router service by default (if no services are selected).
- if (empty($servicesInput) && $service === 'router') {
- continue;
- }
- if (!empty($selectedServiceNames) && !in_array($service, $selectedServiceNames, true)) {
- continue;
- }
- $fieldPrefix = $dimensionFields['mountpoint'] === '/tmp' ? 'tmp_' : '';
- foreach ($item['point']['values'] as $value) {
- $name = $value['info']['name'];
- if (isset($values[$time][$service][$dimension][$fieldPrefix . $name])) {
- $this->stdErr->writeln(\sprintf(
- 'Warning: duplicate value found for time %s, service %s, dimension %s, field %s',
- $time,
- $service,
- $dimension,
- $fieldPrefix . $name,
- ));
- } else {
- $values[$time][$service][$dimension][$fieldPrefix . $name] = Sketch::fromApiValue($value);
- }
- }
- }
-
- // Filter to only the latest timestamp if --latest is given.
- if ($input->getOption('latest')) {
- \ksort($values, SORT_NATURAL);
- $values = \array_slice($values, -1, null, true);
- }
-
- // It's possible that there is nothing to display, e.g. if the router
- // has been filtered out and no metrics were available for the other
- // services, perhaps because the environment was paused.
- if (empty($values)) {
- $this->stdErr->writeln('No values were found to display.');
-
- if ($environment->status === 'paused') {
- $this->stdErr->writeln('');
- $this->stdErr->writeln('The environment is currently paused.');
- $this->stdErr->writeln('Metrics collection will start when the environment is redeployed.');
- }
-
- return false;
- }
-
- return $values;
- }
-
- /**
- * Validates the interval and range input, and finds defaults.
- *
- * Sets the startTime, endTime, and interval properties.
- *
- * @see self::startTime, self::$endTime, self::$interval
- *
- * @param InputInterface $input
- *
- * @return TimeSpec|false
- */
- protected function validateTimeInput(InputInterface $input): false|TimeSpec
- {
- $interval = null;
- if ($intervalStr = $input->getOption('interval')) {
- $duration = new Duration();
- $interval = $duration->toSeconds($intervalStr);
- if (empty($interval)) {
- $this->stdErr->writeln('Invalid --interval: ' . $intervalStr . '');
- return false;
- } elseif ($interval < self::MIN_INTERVAL) {
- $this->stdErr->writeln(\sprintf('The --interval %s is too short: it must be at least %d seconds.', $intervalStr, self::MIN_INTERVAL));
- return false;
- }
- $interval = \intval($interval);
- }
-
- if ($to = $input->getOption('to')) {
- $endTime = \strtotime((string) $to);
- if (!$endTime) {
- $this->stdErr->writeln('Failed to parse --to time: ' . $to);
- return false;
- }
- } else {
- $endTime = time();
- }
- if ($rangeStr = $input->getOption('range')) {
- $rangeSeconds = (new Duration())->toSeconds($rangeStr);
- if (empty($rangeSeconds)) {
- $this->stdErr->writeln('Invalid --range: ' . $rangeStr . '');
- return false;
- } elseif ($rangeSeconds < self::MIN_RANGE) {
- $this->stdErr->writeln(\sprintf('The --range %s is too short: it must be at least %d seconds (%s).', $rangeStr, self::MIN_RANGE, (new Duration())->humanize(self::MIN_RANGE)));
- return false;
- }
- $rangeSeconds = \intval($rangeSeconds);
- } else {
- $rangeSeconds = self::DEFAULT_RANGE;
- }
-
- if ($interval === null) {
- $interval = $this->defaultInterval($rangeSeconds);
- } elseif ($interval > 0 && ($rangeSeconds / $interval) > self::MAX_INTERVALS) {
- $this->stdErr->writeln(\sprintf(
- 'The --interval %s is too short relative to the --range (%s): the maximum number of intervals is %d.',
- (new Duration())->humanize($interval),
- (new Duration())->humanize($rangeSeconds),
- self::MAX_INTERVALS,
- ));
- return false;
- }
-
- if ($input->getOption('latest')) {
- $rangeSeconds = $interval;
- }
-
- $startTime = $endTime - $rangeSeconds;
-
- return new TimeSpec($startTime, $endTime, $interval);
- }
-
- /**
- * Determines a default interval based on the range.
- *
- * @param int $range The range in seconds.
- *
- * @return int
- */
- private function defaultInterval(int $range): int
- {
- $divisor = 5; // Number of points per time range.
- // Number of seconds to round to:
- $granularity = 10;
- foreach ([3600 * 24, 3600 * 6, 3600 * 3, 3600, 600, 300, 60, 30] as $level) {
- if ($range >= $level * $divisor) {
- $granularity = $level;
- break;
- }
- }
- $interval = \round($range / ($divisor * $granularity)) * $granularity;
- if ($interval <= self::MIN_INTERVAL) {
- return self::MIN_INTERVAL;
- }
-
- return (int) $interval;
- }
-
- /**
- * Returns the deployment type of an environment (needed for differing queries).
- */
- private function getDeploymentType(Environment $environment): string
- {
- if (in_array($environment->deployment_target, ['local', 'enterprise', 'dedicated'])) {
- return $environment->deployment_target;
- }
- $data = $environment->getData();
- if (isset($data['_embedded']['deployments'][0]['type'])) {
- return $data['_embedded']['deployments'][0]['type'];
- }
- throw new \RuntimeException('Failed to determine the deployment type');
- }
-
- /**
- * Builds metrics table rows.
- *
- * @param array>>> $values
- * An array of values from fetchMetrics().
- * @param array $fields
- * An array of fields keyed by column name.
- *
- * @return array|TableSeparator>
- * Table rows.
- */
- protected function buildRows(array $values, array $fields, Environment $environment): array
- {
- $deployment = $this->api->getCurrentDeployment($environment);
-
- // Create a closure which can sort services by name, putting apps and
- // workers first.
- $appAndWorkerNames = array_keys(array_merge($deployment->webapps, $deployment->workers));
- sort($appAndWorkerNames, SORT_NATURAL);
- $serviceNames = array_keys($deployment->services);
- sort($serviceNames, SORT_NATURAL);
- $nameOrder = array_flip(array_merge($appAndWorkerNames, $serviceNames, ['router']));
- $sortServices = function ($a, $b) use ($nameOrder): int {
- $aPos = $nameOrder[$a] ?? 1000;
- $bPos = $nameOrder[$b] ?? 1000;
- return $aPos > $bPos ? 1 : ($aPos < $bPos ? -1 : 0);
- };
-
- $rows = [];
- $lastCountPerTimestamp = 0;
- foreach ($values as $timestamp => $byService) {
- // Add a separator if there was more than one row for the previous timestamp.
- if ($lastCountPerTimestamp > 1) {
- $rows[] = new TableSeparator();
- }
- $startCount = count($rows);
- $formattedTimestamp = $this->propertyFormatter->formatDate($timestamp);
- uksort($byService, $sortServices);
- foreach ($byService as $service => $byDimension) {
- if (isset($deployment->services[$service])) {
- $type = $deployment->services[$service]->type;
- } elseif (isset($deployment->webapps[$service])) {
- $type = $deployment->webapps[$service]->type;
- } elseif (isset($deployment->workers[$service])) {
- $type = $deployment->workers[$service]->type;
- } else {
- $type = '';
- }
-
- $serviceRows = [];
- foreach ($byDimension as $values) {
- $row = [];
- $row['timestamp'] = new AdaptiveTableCell($formattedTimestamp, ['wrap' => false]);
- $row['service'] = $service;
- $row['type'] = $this->propertyFormatter->format($type, 'service_type');
- foreach ($fields as $columnName => $field) {
- /** @var Field $field */
- $fieldName = $field->getName();
- if (isset($values[$fieldName])) {
- /** @var Sketch $value */
- $value = $values[$fieldName];
- if ($fieldName === 'mem_percent' && isset($deployment->services[$service])) {
- if ($value->average() > 90) {
- $this->foundHighMemoryServices = true;
- }
- $row[$columnName] = $field->format($values[$fieldName], false);
- } elseif ($fieldName === 'mem_limit' && $service === 'router' && $value->average() == 0) {
- $row[$columnName] = '';
- } elseif ($fieldName === 'mem_percent' && $service === 'router' && $value->isInfinite()) {
- $row[$columnName] = '';
- } else {
- $row[$columnName] = $field->format($values[$fieldName]);
- }
- }
- }
- $serviceRows[] = $row;
- }
- $rows = array_merge($rows, $this->mergeRows($serviceRows));
- }
- $lastCountPerTimestamp = count($rows) - $startCount;
- }
- return $rows;
- }
-
- /**
- * Merges table rows per service to reduce unnecessary empty cells.
- *
- * @param array|TableSeparator> $rows
- * @return array|TableSeparator>
- */
- private function mergeRows(array $rows): array
- {
- $infoKeys = array_flip(['service', 'timestamp', 'instance', 'type']);
- $previous = $previousKey = null;
- foreach (array_keys($rows) as $key) {
- // Merge rows if they do not have any keys in common except for
- // $infoKeys, and if their values are the same for those keys.
- if ($previous !== null
- && !array_intersect_key(array_diff_key($rows[$key], $infoKeys), array_diff_key($previous, $infoKeys))
- && array_intersect_key($rows[$key], $infoKeys) == array_intersect_key($previous, $infoKeys)) {
- $rows[$key] += $previous;
- unset($rows[$previousKey]);
- }
- $previous = $rows[$key];
- $previousKey = $key;
- }
- return $rows;
- }
-
- /**
- * Shows an explanation if services were found that use high memory.
- */
- protected function explainHighMemoryServices(): void
- {
- if ($this->foundHighMemoryServices) {
- $this->stdErr->writeln('');
- $this->stdErr->writeln('Note: it is possible for service memory usage to appear high even in normal circumstances.');
- }
- }
-}
diff --git a/src/Model/Metrics/Aggregation.php b/src/Model/Metrics/Aggregation.php
new file mode 100644
index 000000000..879b86baa
--- /dev/null
+++ b/src/Model/Metrics/Aggregation.php
@@ -0,0 +1,9 @@
+name;
- }
-
- /**
- * Formats a float as a percentage.
- *
- * @param float $pc
- * @param bool $warn
- *
- * @return string
- */
- private function formatPercent(float $pc, bool $warn = true): string
- {
- if ($warn) {
- if ($pc >= self::RED_WARNING_THRESHOLD) {
- return \sprintf('%.1f%%>', $pc);
- }
- if ($pc >= self::YELLOW_WARNING_THRESHOLD) {
- return \sprintf('%.1f%%>', $pc);
- }
- }
- return \sprintf('%.1f%%', $pc);
- }
-
- /**
- * Formats a value according to the field format.
- *
- * @param Sketch $value
- * @param bool $warn
- * Adds colors if the value is over a threshold.
- *
- * @return string
- */
- public function format(Sketch $value, bool $warn = true): string
- {
- if ($value->isInfinite()) {
- return '∞';
- }
- return match ($this->format) {
- self::FORMAT_ROUNDED => (string) round($value->average()),
- self::FORMAT_ROUNDED_2DP => (string) round($value->average(), 2),
- self::FORMAT_PERCENT => $this->formatPercent($value->average(), $warn),
- self::FORMAT_DISK, self::FORMAT_MEMORY => FormatterHelper::formatMemory((int) $value->average()),
- default => throw new \InvalidArgumentException('Formatter not found: ' . $this->format),
- };
- }
+ public function __construct(
+ public readonly Format $format,
+ public readonly SourceField|SourceFieldPercentage $value,
+ public readonly bool $warn = true,
+ ) {}
}
diff --git a/src/Model/Metrics/Format.php b/src/Model/Metrics/Format.php
new file mode 100644
index 000000000..476f46e7b
--- /dev/null
+++ b/src/Model/Metrics/Format.php
@@ -0,0 +1,55 @@
+= self::RED_WARNING_THRESHOLD) {
+ return \sprintf('%.1f%%>', $pc);
+ }
+ if ($pc >= self::YELLOW_WARNING_THRESHOLD) {
+ return \sprintf('%.1f%%>', $pc);
+ }
+ }
+
+ return \sprintf('%.1f%%', $pc);
+ }
+
+ public function format(?float $value, bool $warn = true): string
+ {
+ if (null === $value) {
+ return '';
+ }
+
+ if (PHP_INT_MAX === (int) $value) {
+ return '∞';
+ }
+
+ return match ($this) {
+ Format::Rounded => (string) round($value),
+ Format::Rounded2p => (string) round($value, 2),
+ Format::Percent => $this->formatPercent($value, $warn),
+ Format::Disk, Format::Memory => FormatterHelper::formatMemory((int) $value),
+ };
+ }
+}
diff --git a/src/Model/Metrics/MetricKind.php b/src/Model/Metrics/MetricKind.php
new file mode 100644
index 000000000..1adccfb1a
--- /dev/null
+++ b/src/Model/Metrics/MetricKind.php
@@ -0,0 +1,25 @@
+ */
- private array $fields = [];
- /** @var array */
- private array $filters = [];
-
- public function setInterval(int $interval): self
- {
- $this->interval = $interval;
- return $this;
- }
+ /** @var array|null */
+ private ?array $services = null;
+ /** @var array */
+ private array $types = [];
+ /** @var array */
+ private array $aggs = [];
- public function setStartTime(int $startTime): self
- {
- $this->startTime = $startTime;
- return $this;
- }
+ public function __construct(
+ private int $startTime,
+ private int $endTime,
+ ) {}
- public function setEndTime(int $endTime): self
+ public static function fromTimeSpec(TimeSpec $timeSpec): self
{
- $this->endTime = $endTime;
- return $this;
+ return new self($timeSpec->getStartTime(), $timeSpec->getEndTime());
}
- public function setCollection(string $collection): self
+ /** @param array|null $services */
+ public function setServices(?array $services): self
{
- $this->collection = $collection;
+ $this->services = $services;
+
return $this;
}
- public function addField(string $name, string $expression): self
+ /** @param array $types */
+ public function setTypes(array $types): self
{
- $this->fields[$name] = $expression;
+ $this->types = $types;
+
return $this;
}
- public function addFilter(string $key, string $value): self
+ /** @param array $aggs */
+ public function setAggs(array $aggs): self
{
- $this->filters[$key] = $value;
+ $this->aggs = $aggs;
+
return $this;
}
- /**
- * Returns the query as an array.
- * @return array
- */
+ /** @return array|int|string> */
public function asArray(): array
{
$query = [
- 'stream' => [
- 'stream' => 'metrics',
- 'collection' => $this->collection,
- ],
- 'interval' => $this->interval . 's',
- 'fields' => [],
- 'range' => [
- 'from' => date('Y-m-d\TH:i:s.uP', $this->startTime),
- 'to' => date('Y-m-d\TH:i:s.uP', $this->endTime),
- ],
+ 'from' => $this->startTime,
+ 'to' => $this->endTime,
];
- foreach ($this->fields as $name => $expr) {
- $query['fields'][] = ['name' => $name, 'expr' => $expr];
+
+ if (!empty($this->services)) {
+ $query['services_mode'] = '1';
+ $query['services'] = $this->services;
}
- foreach ($this->filters as $key => $value) {
- $query['filters'][] = ['key' => $key, 'value' => $value];
+
+ if (!empty($this->types)) {
+ $query['types'] = $this->types;
+ }
+
+ if (!empty($this->aggs)) {
+ $query['aggs'] = $this->aggs;
}
+
return $query;
}
+
+ public function asString(): string
+ {
+ return '?' . http_build_query($this->asArray(), '', '&', PHP_QUERY_RFC3986);
+ }
}
diff --git a/src/Model/Metrics/Sketch.php b/src/Model/Metrics/Sketch.php
deleted file mode 100644
index a1e579589..000000000
--- a/src/Model/Metrics/Sketch.php
+++ /dev/null
@@ -1,47 +0,0 @@
-, info: array} $value
- * @return self
- */
- public static function fromApiValue(array $value): self
- {
- return new Sketch(
- $value['value']['sum'] ?? null,
- $value['value']['count'] ?? 1,
- $value['info']['name'],
- );
- }
-
- public function name(): string
- {
- return $this->name;
- }
-
- public function isInfinite(): bool
- {
- return $this->sum === 'Infinity' || $this->count === 'Infinity';
- }
-
- public function average(): float
- {
- if ($this->isInfinite()) {
- throw new \RuntimeException('Cannot find the average of an infinite value');
- }
- if ($this->sum === null) {
- return 0;
- }
- if (is_string($this->sum)) {
- throw new \RuntimeException('Cannot find the average of a string "sum": ' . $this->sum);
- }
- return $this->sum / (float) $this->count;
- }
-}
diff --git a/src/Model/Metrics/SourceField.php b/src/Model/Metrics/SourceField.php
new file mode 100644
index 000000000..0c8753099
--- /dev/null
+++ b/src/Model/Metrics/SourceField.php
@@ -0,0 +1,14 @@
+endTime;
}
-
- public function getInterval(): int
- {
- return $this->interval;
- }
}
diff --git a/src/Model/ProjectRoles.php b/src/Model/ProjectRoles.php
index 691c39d5e..a97465350 100644
--- a/src/Model/ProjectRoles.php
+++ b/src/Model/ProjectRoles.php
@@ -10,6 +10,7 @@ class ProjectRoles
* Formats project-related permissions.
*
* @param string[] $permissions
+ *
* @throws \JsonException
*/
public function formatPermissions(array $permissions, bool $machineReadable): string
@@ -26,7 +27,7 @@ public function formatPermissions(array $permissions, bool $machineReadable): st
$byType = ['production' => '', 'staging' => '', 'development' => ''];
foreach ($permissions as $permission) {
$parts = explode(':', $permission, 2);
- if (count($parts) === 2) {
+ if (2 === count($parts)) {
[$environmentType, $role] = $parts;
$byType[$environmentType] = $role;
}