diff --git a/src/Symfony/Bridge/Monolog/Formatter/ConsoleFormatter.php b/src/Symfony/Bridge/Monolog/Formatter/ConsoleFormatter.php index 80e15d5752c81..9b372d6e22749 100644 --- a/src/Symfony/Bridge/Monolog/Formatter/ConsoleFormatter.php +++ b/src/Symfony/Bridge/Monolog/Formatter/ConsoleFormatter.php @@ -13,6 +13,7 @@ use Monolog\Formatter\FormatterInterface; use Monolog\Logger; +use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\VarDumper\Cloner\Data; use Symfony\Component\VarDumper\Cloner\Stub; use Symfony\Component\VarDumper\Cloner\VarCloner; @@ -67,6 +68,9 @@ public function __construct($options = array()) if (isset($args[1])) { $options['date_format'] = $args[1]; } + if (isset($args[2])) { + $options['multiline'] = $args[2]; + } } $this->options = array_replace(array( @@ -175,7 +179,10 @@ private function replacePlaceHolder(array $record) $replacements = array(); foreach ($context as $k => $v) { - $replacements['{'.$k.'}'] = sprintf('%s', $this->dumpData($v, false)); + // Remove quotes added by the dumper around string. + $v = trim($this->dumpData($v, false), '"'); + $v = OutputFormatter::escape($v); + $replacements['{'.$k.'}'] = sprintf('%s', $v); } $record['message'] = strtr($message, $replacements); diff --git a/src/Symfony/Bridge/Monolog/Formatter/VarDumperFormatter.php b/src/Symfony/Bridge/Monolog/Formatter/VarDumperFormatter.php new file mode 100644 index 0000000000000..e96b510a8bb3f --- /dev/null +++ b/src/Symfony/Bridge/Monolog/Formatter/VarDumperFormatter.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Monolog\Formatter; + +use Monolog\Formatter\FormatterInterface; +use Symfony\Component\VarDumper\Cloner\VarCloner; + +/** + * @author Grégoire Pineau + */ +class VarDumperFormatter implements FormatterInterface +{ + private $cloner; + + public function __construct(VarCloner $cloner = null) + { + $this->cloner = $cloner ?: new VarCloner(); + } + + public function format(array $record) + { + $record['context'] = $this->cloner->cloneVar($record['context']); + $record['extra'] = $this->cloner->cloneVar($record['extra']); + + return $record; + } + + public function formatBatch(array $records) + { + foreach ($records as $k => $record) { + $record[$k] = $this->format($record); + } + + return $records; + } +} diff --git a/src/Symfony/Bridge/Monolog/Handler/ServerLogHandler.php b/src/Symfony/Bridge/Monolog/Handler/ServerLogHandler.php new file mode 100644 index 0000000000000..599940e8d7714 --- /dev/null +++ b/src/Symfony/Bridge/Monolog/Handler/ServerLogHandler.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Monolog\Handler; + +use Monolog\Handler\AbstractHandler; +use Monolog\Logger; +use Symfony\Bridge\Monolog\Formatter\VarDumperFormatter; + +/** + * @author Grégoire Pineau + */ +class ServerLogHandler extends AbstractHandler +{ + private $host; + private $context; + private $socket; + + public function __construct($host, $level = Logger::DEBUG, $bubble = true, $context = array()) + { + parent::__construct($level, $bubble); + + if (false === strpos($host, '://')) { + $host = 'tcp://'.$host; + } + + $this->host = $host; + $this->context = stream_context_create($context); + } + + /** + * {@inheritdoc} + */ + public function handle(array $record) + { + if (!$this->isHandling($record)) { + return false; + } + + set_error_handler(self::class.'::nullErrorHandler'); + + try { + if (!$this->socket = $this->socket ?: $this->createSocket()) { + return false === $this->bubble; + } + + $recordFormatted = $this->formatRecord($record); + + if (!fwrite($this->socket, $recordFormatted)) { + fclose($this->socket); + + // Let's retry: the persistent connection might just be stale + if ($this->socket = $this->createSocket()) { + fwrite($this->socket, $recordFormatted); + } + } + } finally { + restore_error_handler(); + } + + return false === $this->bubble; + } + + /** + * {@inheritdoc} + */ + protected function getDefaultFormatter() + { + return new VarDumperFormatter(); + } + + private static function nullErrorHandler() + { + } + + private function createSocket() + { + $socket = stream_socket_client($this->host, $errno, $errstr, 0, STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT | STREAM_CLIENT_PERSISTENT, $this->context); + + if ($socket) { + stream_set_blocking($socket, false); + } + + return $socket; + } + + private function formatRecord(array $record) + { + if ($this->processors) { + foreach ($this->processors as $processor) { + $record = call_user_func($processor, $record); + } + } + + $recordFormatted = $this->getFormatter()->format($record); + + foreach (array('log_uuid', 'uuid', 'uid') as $key) { + if (isset($record['extra'][$key])) { + $recordFormatted['log_id'] = $record['extra'][$key]; + break; + } + } + + return base64_encode(serialize($recordFormatted))."\n"; + } +} diff --git a/src/Symfony/Bundle/WebServerBundle/Command/ServerLogCommand.php b/src/Symfony/Bundle/WebServerBundle/Command/ServerLogCommand.php new file mode 100644 index 0000000000000..a8ce2f953b7f1 --- /dev/null +++ b/src/Symfony/Bundle/WebServerBundle/Command/ServerLogCommand.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\WebServerBundle\Command; + +use Symfony\Bridge\Monolog\Formatter\ConsoleFormatter; +use Symfony\Bridge\Monolog\Handler\ConsoleHandler; +use Symfony\Component\Console\Command\Command; +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 + */ +class ServerLogCommand extends Command +{ + private static $bgColor = array('black', 'blue', 'cyan', 'green', 'magenta', 'red', 'white', 'yellow'); + + private $el; + private $handler; + + protected function configure() + { + $this + ->setName('server:log') + ->setDescription('Start a log server that displays logs in real time') + ->addOption('host', null, InputOption::VALUE_REQUIRED, 'The server host', '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\']"') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $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.'); + } + $this->el = new ExpressionLanguage(); + } + + $this->handler = new ConsoleHandler($output); + + $this->handler->setFormatter(new ConsoleFormatter(array( + 'format' => str_replace('\n', "\n", $input->getOption('format')), + 'date_format' => $input->getOption('date-format'), + 'colors' => $output->isDecorated(), + 'multiline' => OutputInterface::VERBOSITY_DEBUG <= $output->getVerbosity(), + ))); + + if (false === strpos($host = $input->getOption('host'), '://')) { + $host = 'tcp://'.$host; + } + + if (!$socket = stream_socket_server($host, $errno, $errstr)) { + throw new \RuntimeException(sprintf('Server start failed on "%s": %s %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($input, $output, $clientId, $record); + } + } + + private function getLogs($socket) + { + $sockets = array((int) $socket => $socket); + $write = array(); + + 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(InputInterface $input, OutputInterface $output, $clientId, array $record) + { + if ($this->handler->isHandling($record)) { + if (isset($record['log_id'])) { + $clientId = unpack('H*', $record['log_id'])[1]; + } + $logBlock = sprintf(' ', self::$bgColor[$clientId % 8]); + $output->write($logBlock); + } + + $this->handler->handle($record); + } +}