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; }