|
| 1 | +<?php |
| 2 | + |
| 3 | +/* |
| 4 | + * This file is part of the Symfony package. |
| 5 | + * |
| 6 | + * (c) Fabien Potencier <[email protected]> |
| 7 | + * |
| 8 | + * For the full copyright and license information, please view the LICENSE |
| 9 | + * file that was distributed with this source code. |
| 10 | + */ |
| 11 | + |
| 12 | +namespace Symfony\Bundle\WebServerBundle\Command; |
| 13 | + |
| 14 | +use Symfony\Component\Console\Command\Command; |
| 15 | +use Symfony\Component\Console\Input\InputInterface; |
| 16 | +use Symfony\Component\Console\Input\InputOption; |
| 17 | +use Symfony\Component\Console\Output\OutputInterface; |
| 18 | +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; |
| 19 | +use Symfony\Component\VarDumper\Cloner\Data; |
| 20 | +use Symfony\Component\VarDumper\Dumper\CliDumper; |
| 21 | + |
| 22 | +/** |
| 23 | + * @author Grégoire Pineau <[email protected]> |
| 24 | + */ |
| 25 | +class ServerLogCommand extends Command |
| 26 | +{ |
| 27 | + private $dumper; |
| 28 | + private $output; |
| 29 | + private $el; |
| 30 | + |
| 31 | + private static $levelColorMap = array( |
| 32 | + 100 => 'fg=white', |
| 33 | + 200 => 'fg=green', |
| 34 | + 250 => 'fg=blue', |
| 35 | + 300 => 'fg=cyan', |
| 36 | + 400 => 'fg=yellow', |
| 37 | + 500 => 'fg=red', |
| 38 | + 550 => 'fg=red', |
| 39 | + 600 => 'fg=white;bg=red', |
| 40 | + ); |
| 41 | + |
| 42 | + private static $bgColor = array( |
| 43 | + 'black', |
| 44 | + 'blue', |
| 45 | + 'cyan', |
| 46 | + 'green', |
| 47 | + 'magenta', |
| 48 | + 'red', |
| 49 | + 'white', |
| 50 | + 'yellow', |
| 51 | + ); |
| 52 | + |
| 53 | + protected function configure() |
| 54 | + { |
| 55 | + $this |
| 56 | + ->setName('server:log') |
| 57 | + ->setDescription('Start a log server that displays logs in real time') |
| 58 | + ->addOption('host', null, InputOption::VALUE_REQUIRED, 'The server host', '0:9911') |
| 59 | + ->addOption('date-format', null, InputOption::VALUE_REQUIRED, 'The date format', 'H:i:s') |
| 60 | + ->addOption('format', null, InputOption::VALUE_REQUIRED, 'The line format', '%s <%s>%-9s</> <options=bold>%-8.8s</> %s') |
| 61 | + ->addOption('show-context', null, InputOption::VALUE_NONE, 'Display the context') |
| 62 | + ->addOption('show-extra', null, InputOption::VALUE_NONE, 'Display the extra') |
| 63 | + ->addOption('filter', null, InputOption::VALUE_REQUIRED, 'An expression to filter log. Example: record[\'level\'] > 200 or record[\'channel\'] in [\'app\', \'doctrine\']"') |
| 64 | + ; |
| 65 | + } |
| 66 | + |
| 67 | + protected function initialize(InputInterface $input, OutputInterface $output) |
| 68 | + { |
| 69 | + $this->dumper = new CliDumper(); |
| 70 | + $this->dumper->setOutput($this->output = fopen('php://memory', 'r+b')); |
| 71 | + |
| 72 | + if ($input->getOption('filter')) { |
| 73 | + if (!class_exists(ExpressionLanguage::class)) { |
| 74 | + throw new \LogicException('Package "symfony/expression-language" is required to use the "filter" option.'); |
| 75 | + } |
| 76 | + $this->el = new ExpressionLanguage(); |
| 77 | + } |
| 78 | + } |
| 79 | + |
| 80 | + protected function execute(InputInterface $input, OutputInterface $output) |
| 81 | + { |
| 82 | + if (false === strpos($host = $input->getOption('host'), '://')) { |
| 83 | + $host = 'tcp://'.$host; |
| 84 | + } |
| 85 | + |
| 86 | + if (!$socket = stream_socket_server($host, $errno, $errstr)) { |
| 87 | + throw new \RuntimeException(sprintf('Server start failed on "%s": %s %s.', $host, $errstr, $errno)); |
| 88 | + } |
| 89 | + |
| 90 | + $this->displayLog($input, $output, 0, array( |
| 91 | + 'datetime' => new \DateTime(), |
| 92 | + 'level' => 100, |
| 93 | + 'level_name' => 'info', |
| 94 | + 'channel' => 'app', |
| 95 | + 'message' => sprintf('Listening on <comment>%s</comment>', $host).PHP_EOL, |
| 96 | + 'context' => array(), |
| 97 | + 'extra' => array(), |
| 98 | + )); |
| 99 | + |
| 100 | + $logs = $this->getLogs($socket); |
| 101 | + $filter = $input->getOption('filter'); |
| 102 | + |
| 103 | + foreach ($logs as $clientId => $message) { |
| 104 | + $record = unserialize(base64_decode($message)); |
| 105 | + |
| 106 | + // Impossible to decode the message, give up. |
| 107 | + if (false === $record) { |
| 108 | + continue; |
| 109 | + } |
| 110 | + |
| 111 | + if ($filter && !$this->el->evaluate($filter, $record)) { |
| 112 | + continue; |
| 113 | + } |
| 114 | + |
| 115 | + $this->displayLog($input, $output, $clientId, $record); |
| 116 | + } |
| 117 | + } |
| 118 | + |
| 119 | + private function getLogs($socket) |
| 120 | + { |
| 121 | + $sockets = array((int) $socket => $socket); |
| 122 | + $write = array(); |
| 123 | + |
| 124 | + while (true) { |
| 125 | + $read = $sockets; |
| 126 | + stream_select($read, $write, $write, null); |
| 127 | + |
| 128 | + foreach ($read as $stream) { |
| 129 | + if ($socket === $stream) { |
| 130 | + $stream = stream_socket_accept($socket); |
| 131 | + $sockets[(int) $stream] = $stream; |
| 132 | + } elseif (feof($stream)) { |
| 133 | + unset($sockets[(int) $stream]); |
| 134 | + fclose($stream); |
| 135 | + } else { |
| 136 | + yield (int) $stream => fgets($stream); |
| 137 | + } |
| 138 | + } |
| 139 | + } |
| 140 | + } |
| 141 | + |
| 142 | + private function displayLog(InputInterface $input, OutputInterface $output, $clientId, array $record) |
| 143 | + { |
| 144 | + $record = $this->replacePlaceHolder($output, $record); |
| 145 | + |
| 146 | + $format = $input->getOption('format'); |
| 147 | + $date = $record['datetime']->format($input->getOption('date-format')); |
| 148 | + if (isset($record['log_id'])) { |
| 149 | + $clientId = unpack('H*', $record['log_id'])[1]; |
| 150 | + } |
| 151 | + $logBlock = sprintf('<bg=%s> </>', self::$bgColor[$clientId % 8]); |
| 152 | + $levelColor = self::$levelColorMap[$record['level']]; |
| 153 | + $message = $logBlock.sprintf($format, $date, $levelColor, $record['level_name'], $record['channel'], $record['message']); |
| 154 | + |
| 155 | + $output->writeln($message); |
| 156 | + |
| 157 | + if ($record['context'] && $input->getOption('show-context') && $record['context']->getRawData()[0][0]) { |
| 158 | + $output->writeln(sprintf('%sContext %s', $logBlock, $this->dumpData($output, $record['context']))); |
| 159 | + } |
| 160 | + if ($record['extra'] && $input->getOption('show-extra') && $record['extra']->getRawData()[0][0]) { |
| 161 | + $output->writeln(sprintf('%sExtra %s', $logBlock, $this->dumpData($output, $record['extra']))); |
| 162 | + } |
| 163 | + } |
| 164 | + |
| 165 | + private function replacePlaceHolder(OutputInterface $output, array $record) |
| 166 | + { |
| 167 | + $message = $record['message']; |
| 168 | + |
| 169 | + if (false === strpos($message, '{')) { |
| 170 | + return $record; |
| 171 | + } |
| 172 | + |
| 173 | + $context = $record['context']; |
| 174 | + |
| 175 | + $replacements = array(); |
| 176 | + foreach ($context->getRawData()[1] as $k => $v) { |
| 177 | + $replacements['"{'.$k.'}"'] = sprintf('<comment>%s</>', $this->dumpData($output, $context->seek($k), false)); |
| 178 | + } |
| 179 | + |
| 180 | + $record['message'] = strtr($message, $replacements); |
| 181 | + |
| 182 | + return $record; |
| 183 | + } |
| 184 | + |
| 185 | + private function dumpData(OutputInterface $output, Data $data, $colors = true, $maxDepth = 0) |
| 186 | + { |
| 187 | + if ($output->isDecorated()) { |
| 188 | + $this->dumper->setColors($colors); |
| 189 | + } |
| 190 | + |
| 191 | + $this->dumper->dump($data); |
| 192 | + |
| 193 | + $dump = stream_get_contents($this->output, -1, 0); |
| 194 | + rewind($this->output); |
| 195 | + ftruncate($this->output, 0); |
| 196 | + |
| 197 | + return rtrim($dump); |
| 198 | + } |
| 199 | +} |
0 commit comments