Permalink
Browse files

feature #21080 [FrameworkBundle][Monolog] Added a new way to follow l…

…ogs (lyrixx)

This PR was merged into the 3.3-dev branch.

Discussion
----------

[FrameworkBundle][Monolog] Added a new way to follow logs

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | -
| License       | MIT
| Doc PR        | -

----

If you want to try this PR, you can use [my fork](https://github.com/lyrixx/symfony-standard/tree/server-log):
```bash
git clone https://github.com/lyrixx/symfony-standard -b server-log symfony-se-logs
cd symfony-se-logs
composer install
bin/console server:start
bin/console server:log
```
and from anywhere `curl http://0:8000`

---

Basically, it's a new way to view and filter real time logs, from the CLI.

![screenshot13](https://cloud.githubusercontent.com/assets/408368/21898198/52fa8c3c-d8ec-11e6-98db-6b3a6f8fe50d.png)

Commits
-------

ac92375 [FrameworkBundle][Monolog] Added a new way to follow logs
  • Loading branch information...
2 parents dc0eba7 + ac92375 commit 6166260d6e3158af141643809a176c6a3395e7ca @fabpot fabpot committed Mar 14, 2017
@@ -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('<comment>%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('<comment>%s</>', $v);
}
$record['message'] = strtr($message, $replacements);
@@ -0,0 +1,45 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * 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 <lyrixx@lyrixx.info>
+ */
+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;
+ }
+}
@@ -0,0 +1,114 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * 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 <lyrixx@lyrixx.info>
+ */
+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";
+ }
+}
@@ -0,0 +1,122 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * 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 <lyrixx@lyrixx.info>
+ */
+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('<bg=%s> </>', self::$bgColor[$clientId % 8]);
+ $output->write($logBlock);
+ }
+
+ $this->handler->handle($record);
+ }
+}

0 comments on commit 6166260

Please sign in to comment.