From 0718de62d4963d8ffe731cd7141db0b74e6503ce Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Fri, 16 Jun 2023 18:24:06 +0200 Subject: [PATCH 1/2] add option for raw output in log:watch/log:tail Signed-off-by: Robin Appelman --- lib/Command/Tail.php | 56 ++++++++++++++++++++++++------------------- lib/Command/Watch.php | 17 ++++++++++--- 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/lib/Command/Tail.php b/lib/Command/Tail.php index 0f317e72..ab125a0e 100644 --- a/lib/Command/Tail.php +++ b/lib/Command/Tail.php @@ -29,7 +29,6 @@ use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Input\StringInput; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Terminal; @@ -51,42 +50,49 @@ protected function configure() { ->setName('log:tail') ->setDescription('Tail the nextcloud logfile') ->addArgument('lines', InputArgument::OPTIONAL, 'The number of log entries to print', "10") - ->addOption('follow', 'f', InputOption::VALUE_NONE, 'Output new log entries as they appear'); + ->addOption('follow', 'f', InputOption::VALUE_NONE, 'Output new log entries as they appear') + ->addOption('raw', 'r', InputOption::VALUE_NONE, 'Output raw log json instead of formatted log item'); parent::configure(); } protected function execute(InputInterface $input, OutputInterface $output): int { + $raw = $input->getOption('raw'); $count = (int)$input->getArgument('lines'); - $terminal = new Terminal(); - $totalWidth = $terminal->getWidth(); - // 8 level, 18 for app, 26 for time, 6 for formatting - $messageWidth = $totalWidth - 8 - 18 - 26 - 6; $io = new SymfonyStyle($input, $output); $logIterator = $this->logIteratorFactory->getLogIterator('11111'); - $i = 0; - $tableItems = []; - foreach ($logIterator as $logItem) { - $i++; - if ($i > $count) { - break; + $logIterator = new \LimitIterator($logIterator, 0, $count); + $logItems = iterator_to_array($logIterator); + $logItems = array_reverse($logItems); + + if ($raw) { + foreach ($logItems as $logItem) { + $output->writeln(json_encode($logItem)); } - $tableItems[] = [ - self::LEVELS[$logItem['level']], - wordwrap($logItem['app'], 18), - $this->formatter->formatMessage($logItem, $messageWidth) . "\n", - $logItem['time'] - ]; + } else { + $terminal = new Terminal(); + $totalWidth = $terminal->getWidth(); + // 8 level, 18 for app, 26 for time, 6 for formatting + $messageWidth = $totalWidth - 8 - 18 - 26 - 6; + + $tableItems = array_map(function (array $logItem) use ($messageWidth) { + return [ + self::LEVELS[$logItem['level']], + wordwrap($logItem['app'], 18), + $this->formatter->formatMessage($logItem, $messageWidth) . "\n", + $logItem['time'], + ]; + }, $logItems); + $io->table([ + 'Level', + 'App', + 'Message', + 'Time', + ], $tableItems); } - $io->table([ - 'Level', - 'App', - 'Message', - 'Time' - ], array_reverse($tableItems)); if ($input->getOption('follow')) { $watch = new Watch($this->formatter, $this->logIteratorFactory); - $watch->run(new StringInput(''), $output); + $watch->watch($raw, $output); } return 0; diff --git a/lib/Command/Watch.php b/lib/Command/Watch.php index 2b5048d3..b9e3817c 100644 --- a/lib/Command/Watch.php +++ b/lib/Command/Watch.php @@ -28,6 +28,7 @@ use OCA\LogReader\Log\Formatter; use OCA\LogReader\Log\LogIteratorFactory; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Terminal; @@ -47,7 +48,8 @@ public function __construct(Formatter $formatter, LogIteratorFactory $logIterato protected function configure() { $this ->setName('log:watch') - ->setDescription('Watch the nextcloud logfile'); + ->setDescription('Watch the nextcloud logfile') + ->addOption('raw', 'r', InputOption::VALUE_NONE, 'Output raw log json instead of formatted log item'); parent::configure(); } @@ -60,6 +62,11 @@ private function getLastLogId() { } protected function execute(InputInterface $input, OutputInterface $output): int { + $raw = $input->getOption('raw'); + return $this->watch($raw, $output); + } + + public function watch(bool $raw, OutputInterface $output): int { $terminal = new Terminal(); $totalWidth = $terminal->getWidth(); // 8 level, 18 for app, 26 for time, 6 for formatting @@ -97,8 +104,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int array_reverse($lines); foreach ($lines as $line) { - $this->printItem($line, $output, $messageWidth); - $output->writeln(""); + if ($raw) { + $output->writeln(json_encode($line)); + } else { + $this->printItem($line, $output, $messageWidth); + $output->writeln(""); + } } $lastId = $id; From a66c992215ac738692796823d125db211636ee0c Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Fri, 16 Jun 2023 19:37:05 +0200 Subject: [PATCH 2/2] add option to filter log:tail/log:watch output Signed-off-by: Robin Appelman --- lib/Command/Tail.php | 8 ++- lib/Command/Watch.php | 11 ++-- lib/Log/FilterQuery.php | 137 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 lib/Log/FilterQuery.php diff --git a/lib/Command/Tail.php b/lib/Command/Tail.php index ab125a0e..1bad6596 100644 --- a/lib/Command/Tail.php +++ b/lib/Command/Tail.php @@ -24,6 +24,7 @@ namespace OCA\LogReader\Command; use OC\Core\Command\Base; +use OCA\LogReader\Log\FilterQuery; use OCA\LogReader\Log\Formatter; use OCA\LogReader\Log\LogIteratorFactory; use Symfony\Component\Console\Input\InputArgument; @@ -51,7 +52,8 @@ protected function configure() { ->setDescription('Tail the nextcloud logfile') ->addArgument('lines', InputArgument::OPTIONAL, 'The number of log entries to print', "10") ->addOption('follow', 'f', InputOption::VALUE_NONE, 'Output new log entries as they appear') - ->addOption('raw', 'r', InputOption::VALUE_NONE, 'Output raw log json instead of formatted log item'); + ->addOption('raw', 'r', InputOption::VALUE_NONE, 'Output raw log json instead of formatted log item') + ->addOption('filter', null, InputOption::VALUE_REQUIRED, 'Filter log items according to the provided query'); parent::configure(); } @@ -60,6 +62,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $count = (int)$input->getArgument('lines'); $io = new SymfonyStyle($input, $output); $logIterator = $this->logIteratorFactory->getLogIterator('11111'); + $filter = new FilterQuery((string)$input->getOption('filter')); + $logIterator = new \CallbackFilterIterator($logIterator, function(array $item) use ($filter) { + return $filter->matches($item); + }); $logIterator = new \LimitIterator($logIterator, 0, $count); $logItems = iterator_to_array($logIterator); $logItems = array_reverse($logItems); diff --git a/lib/Command/Watch.php b/lib/Command/Watch.php index b9e3817c..06cb4a61 100644 --- a/lib/Command/Watch.php +++ b/lib/Command/Watch.php @@ -25,6 +25,7 @@ use OC\Core\Command\Base; use OC\Core\Command\InterruptedException; +use OCA\LogReader\Log\FilterQuery; use OCA\LogReader\Log\Formatter; use OCA\LogReader\Log\LogIteratorFactory; use Symfony\Component\Console\Input\InputInterface; @@ -49,7 +50,8 @@ protected function configure() { $this ->setName('log:watch') ->setDescription('Watch the nextcloud logfile') - ->addOption('raw', 'r', InputOption::VALUE_NONE, 'Output raw log json instead of formatted log item'); + ->addOption('raw', 'r', InputOption::VALUE_NONE, 'Output raw log json instead of formatted log item') + ->addOption('filter', null, InputOption::VALUE_REQUIRED, 'Filter log items according to the provided query'); parent::configure(); } @@ -63,10 +65,11 @@ private function getLastLogId() { protected function execute(InputInterface $input, OutputInterface $output): int { $raw = $input->getOption('raw'); - return $this->watch($raw, $output); + $filter = new FilterQuery((string)$input->getOption('filter')); + return $this->watch($raw, $filter, $output); } - public function watch(bool $raw, OutputInterface $output): int { + public function watch(bool $raw, FilterQuery $filter, OutputInterface $output): int { $terminal = new Terminal(); $totalWidth = $terminal->getWidth(); // 8 level, 18 for app, 26 for time, 6 for formatting @@ -95,7 +98,7 @@ public function watch(bool $raw, OutputInterface $output): int { break; } - if (!is_null($line)) { + if (!is_null($line) && $filter->matches($line)) { $lines[] = $line; } $iterator->next(); diff --git a/lib/Log/FilterQuery.php b/lib/Log/FilterQuery.php new file mode 100644 index 00000000..38e62add --- /dev/null +++ b/lib/Log/FilterQuery.php @@ -0,0 +1,137 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\LogReader\Log; + +class FilterQuery { + const CMP_EQ = 1; + const CMP_LT = 2; + const CMP_GT = 3; + const CMP_LET = 4; + const CMP_GET = 5; + const CMP_NEQ = 6; + + const COMPARISONS = [ + self::CMP_NEQ => '!=', + self::CMP_LET => '<=', + self::CMP_GET => '>=', + self::CMP_EQ => '=', + self::CMP_LT => '<', + self::CMP_GT => '>', + ]; + + /** + * @var array{field: string, cmp: int, value: string}[] + */ + private array $comparisons; + + public function __construct(string $query) { + $parts = array_filter(str_getcsv(str_replace("'", '"', $query), ' ')); + $this->comparisons = array_map([$this, 'parsePart'], $parts); + } + + /** + * @param string $part + * @return array{field: string, cmp: int, value: string} + */ + private function parsePart(string $part): array { + $field = 'message'; + $value = $part; + $cmp = self::CMP_EQ; + foreach (self::COMPARISONS as $cmpVal => $cmpStr) { + if (str_contains($part, $cmpStr)) { + [$field, $value] = explode($cmpStr, $part); + switch ($field) { + case "level": + $value = $this->parseLogLevel($value); + break; + case "time": + $value = new \DateTimeImmutable($value); + break; + } + $cmp = $cmpVal; + break; + } + } + return [ + 'field' => $field, + 'cmp' => $cmp, + 'value' => $value, + ]; + } + + public function matches(array $logItem): bool { + foreach ($this->comparisons as $comparison) { + $logValue = $logItem[$comparison['field']] ?? $logItem['data'][$comparison['field']] ?? null; + if (!$this->compare($logValue, $comparison['value'], $comparison['cmp'])) { + return false; + } + } + return true; + } + + private function compare($a, $b, int $cmp): bool { + switch ($cmp) { + case self::CMP_EQ: + if (is_string($a)) { + return str_contains($a, $b); + } else { + return $a == $b; + } + case self::CMP_LT: + return $a < $b; + case self::CMP_GT: + return $a > $b; + case self::CMP_LET: + return $a <= $b; + case self::CMP_GET: + return $a >= $b; + case self::CMP_NEQ: + return !$this->compare($a, $b, self::CMP_EQ); + default: + return false; + } + } + + private static function parseLogLevel(string $level): int { + if (is_numeric($level)) { + return (int)$level; + } + + switch (strtoupper($level)) { + case "DEBUG": + return 0; + case "INFO": + return 1; + case "WARN": + case "WARNING": + return 2; + case "ERROR": + return 3; + case "FATAL": + return 4; + default: + throw new \Exception("Unknown log level $level"); + } + } +}