* * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Bridge\Monolog\Command; use Monolog\Formatter\FormatterInterface; use Monolog\Handler\HandlerInterface; use Monolog\Level; use Monolog\LogRecord; use Symfony\Bridge\Monolog\Formatter\ConsoleFormatter; use Symfony\Bridge\Monolog\Handler\ConsoleHandler; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\LogicException; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; /** * @author Grégoire Pineau */ #[AsCommand(name: 'server:log', description: 'Start a log server that displays logs in real time')] class ServerLogCommand extends Command { private const BG_COLOR = ['black', 'blue', 'cyan', 'green', 'magenta', 'red', 'white', 'yellow']; private ExpressionLanguage $el; private HandlerInterface $handler; public function isEnabled(): bool { if (!class_exists(ConsoleFormatter::class)) { return false; } // based on a symfony/symfony package, it crashes due a missing FormatterInterface from monolog/monolog if (!interface_exists(FormatterInterface::class)) { return false; } return parent::isEnabled(); } protected function configure(): void { if (!class_exists(ConsoleFormatter::class)) { return; } $this ->addOption('host', null, InputOption::VALUE_REQUIRED, 'The server host', '0.0.0.0:9911') ->addOption('format', null, InputOption::VALUE_REQUIRED, 'The line format', ConsoleFormatter::SIMPLE_FORMAT) ->addOption('date-format', null, InputOption::VALUE_REQUIRED, 'The date format', ConsoleFormatter::SIMPLE_DATE) ->addOption('filter', null, InputOption::VALUE_REQUIRED, 'An expression to filter log. Example: "level > 200 or channel in [\'app\', \'doctrine\']"') ->setHelp(<<<'EOF' %command.name% starts a log server to display in real time the log messages generated by your application: php %command.full_name% To filter the log messages using any ExpressionLanguage compatible expression, use the --filter option: php %command.full_name% --filter="level > 200 or channel in ['app', 'doctrine']" EOF ) ; } protected function execute(InputInterface $input, OutputInterface $output): int { $filter = $input->getOption('filter'); if ($filter) { if (!class_exists(ExpressionLanguage::class)) { throw new LogicException('Package "symfony/expression-language" is required to use the "filter" option. Try running "composer require symfony/expression-language".'); } $this->el = new ExpressionLanguage(); } $this->handler = new ConsoleHandler($output, true, [ OutputInterface::VERBOSITY_NORMAL => Level::Debug, ]); $this->handler->setFormatter(new ConsoleFormatter([ 'format' => str_replace('\n', "\n", $input->getOption('format')), 'date_format' => $input->getOption('date-format'), 'colors' => $output->isDecorated(), 'multiline' => OutputInterface::VERBOSITY_DEBUG <= $output->getVerbosity(), ])); if (!str_contains($host = $input->getOption('host'), '://')) { $host = 'tcp://'.$host; } if (!$socket = stream_socket_server($host, $errno, $errstr)) { throw new RuntimeException(sprintf('Server start failed on "%s": ', $host).$errstr.' '.$errno); } foreach ($this->getLogs($socket) as $clientId => $message) { $record = unserialize(base64_decode($message)); // Impossible to decode the message, give up. if (false === $record) { continue; } if ($filter && !$this->el->evaluate($filter, $record)) { continue; } $this->displayLog($output, $clientId, $record); } return 0; } private function getLogs($socket): iterable { $sockets = [(int) $socket => $socket]; $write = []; while (true) { $read = $sockets; stream_select($read, $write, $write, null); foreach ($read as $stream) { if ($socket === $stream) { $stream = stream_socket_accept($socket); $sockets[(int) $stream] = $stream; } elseif (feof($stream)) { unset($sockets[(int) $stream]); fclose($stream); } else { yield (int) $stream => fgets($stream); } } } } private function displayLog(OutputInterface $output, int $clientId, array $record): void { if (isset($record['log_id'])) { $clientId = unpack('H*', $record['log_id'])[1]; } $logBlock = sprintf(' ', self::BG_COLOR[$clientId % 8]); $output->write($logBlock); $record = new LogRecord( $record['datetime'], $record['channel'], Level::fromValue($record['level']), $record['message'], // We wrap context and extra, because they have been already dumped. // So they are instance of Symfony\Component\VarDumper\Cloner\Data // But LogRecord expects array ['data' => $record['context']], ['data' => $record['extra']], ); $this->handler->handle($record); } }