diff --git a/README.mdown b/README.mdown index 3919776a..ba5a9731 100644 --- a/README.mdown +++ b/README.mdown @@ -142,6 +142,8 @@ Handlers inline `console` messages within [FireBug](http://getfirebug.com/). - _ChromePHPHandler_: Handler for [ChromePHP](http://www.chromephp.com/), providing inline `console` messages within Chrome. +- _BrowserConsoleHandler_: Handler to send logs to browser's Javascript `console` with + no browser extension required. Most browsers supporting `console` API are supported. ### Log to databases diff --git a/src/Monolog/Handler/BrowserConsoleHandler.php b/src/Monolog/Handler/BrowserConsoleHandler.php new file mode 100644 index 00000000..2d37695e --- /dev/null +++ b/src/Monolog/Handler/BrowserConsoleHandler.php @@ -0,0 +1,188 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Handler; + +use Monolog\Logger; +use Monolog\Formatter\LineFormatter; + +/** + * Handler sending logs to browser's javascript console with no browser extension required + * + * @author Olivier Poitrey + */ +class BrowserConsoleHandler extends AbstractProcessingHandler +{ + static protected $initialized = false; + static protected $records = array(); + + /** + * {@inheritDoc} + * + * Formatted output may contain some formatting markers to be transfered to `console.log` using the %c format. + * + * Example of formatted string: + * + * You can do [[blue text]]{color: blue} or [[green background]]{background-color: green; color: white} + * + */ + protected function getDefaultFormatter() + { + return new LineFormatter('[[%channel%]]{macro: autolabel} [[%level_name%]]{font-weight: bold} %message%'); + } + + /** + * {@inheritDoc} + */ + protected function write(array $record) + { + // Accumulate records + self::$records[] = $record; + + // Register shutdown handler if not already done + if (PHP_SAPI !== 'cli' && !self::$initialized) { + self::$initialized = true; + register_shutdown_function(array('Monolog\Handler\BrowserConsoleHandler', 'send')); + } + } + + /** + * Convert records to javascript console commands and send it to the browser. + * This method is automatically called on PHP shutdown if output is HTML. + */ + static public function send() + { + // Check content type + foreach (headers_list() as $header) { + if (strpos(strtolower($header), 'content-type:') === 0) { + if (strpos(strtolower($header), 'text/html') === false) { + // This handler does only work with HTML outputs + return; + } + break; + } + } + + if (count(self::$records)) { + print ''; + self::reset(); + } + } + + /** + * Forget all logged records + */ + static public function reset() + { + self::$records = array(); + } + + static public function generateScript() + { + $script = array(); + foreach (self::$records as $record) { + $context = self::dump('Context', $record['context']); + $extra = self::dump('Extra', $record['extra']); + + if (empty($context) && empty($extra)) { + $script[] = self::call_array('log', self::handleStyles($record['formatted'])); + } else { + $script = array_merge($script, + array(self::call_array('groupCollapsed', self::handleStyles($record['formatted']))), + $context, + $extra, + array(self::call('groupEnd')) + ); + } + } + return "(function(c){if (c && c.groupCollapsed) {\n" . implode("\n", $script) . "\n}})(console);"; + } + + static public function handleStyles($formatted) + { + $args = array(self::quote('font-weight: normal')); + $format = '%c' . $formatted; + $self = 'Monolog\Handler\BrowserConsoleHandler'; + $format = preg_replace_callback('/\[\[(.*?)\]\]\{([^}]*)\}/s', function($m) use(&$args, $self) { + $args[] = $self::quote($self::handleCustomStyles($m[2], $m[1])); + $args[] = $self::quote('font-weight: normal'); + return '%c' . $m[1] . '%c'; + }, $format); + + array_unshift($args, self::quote($format)); + return $args; + } + + static public function handleCustomStyles($style, $string) + { + $self = 'Monolog\Handler\BrowserConsoleHandler'; + return preg_replace_callback('/macro\s*:(.*?)(?:;|$)/', function($m) use($string, $self) { + switch (trim($m[1])) { + case 'autolabel': + return $self::macroAutolabel($string); + break; + default: + return $m[1]; + } + }, $style); + } + + /** + * Format the string as a label with consistent auto assigned background color + */ + static public function macroAutolabel($string) + { + static $colors = array('blue', 'green', 'red', 'magenta', 'orange', 'black', 'grey'); + static $labels = array(); + + if (!isset($labels[$string])) { + $labels[$string] = $colors[count($labels) % count($colors)]; + } + $color = $labels[$string]; + + return "background-color: $color; color: white; border-radius: 3px; padding: 0 2px 0 2px"; + } + + static public function dump($title, array $dict) + { + $script = array(); + $dict = array_filter($dict); + if (empty($dict)) { + return $script; + } + $script[] = self::call('log', self::quote('%c%s'), self::quote('font-weight: bold'), self::quote($title)); + foreach ($dict as $key => $value) { + $value = json_encode($value); + if (empty($value)) { + $value = self::quote(''); + } + $script[] = self::call('log', self::quote('%s: %o'), self::quote($key), $value); + } + return $script; + } + + static public function quote($arg) + { + return '"' . addcslashes($arg, "\"\n") . '"'; + } + + static public function call() + { + $args = func_get_args(); + $method = array_shift($args); + return self::call_array($method, $args); + } + + static public function call_array($method, array $args) + { + return 'c.' . $method . '(' . implode(', ', $args) . ');'; + } +} diff --git a/tests/Monolog/Handler/BrowserConsoleHandlerTest.php b/tests/Monolog/Handler/BrowserConsoleHandlerTest.php new file mode 100644 index 00000000..b3c4c66e --- /dev/null +++ b/tests/Monolog/Handler/BrowserConsoleHandlerTest.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Handler; + +use Monolog\TestCase; +use Monolog\Logger; + +/** + * @covers Monolog\Handler\BrowserConsoleHandlerTest + */ +class BrowserConsoleHandlerTest extends TestCase +{ + protected function setUp() + { + BrowserConsoleHandler::reset(); + } + + public function testStyling() + { + $handler = new BrowserConsoleHandler(); + $handler->setFormatter($this->getIdentityFormatter()); + + $handler->handle($this->getRecord(Logger::DEBUG, 'foo[[bar]]{color: red}')); + + $expected = <<assertEquals($expected, BrowserConsoleHandler::generateScript()); + } + + public function testEscaping() + { + $handler = new BrowserConsoleHandler(); + $handler->setFormatter($this->getIdentityFormatter()); + + $handler->handle($this->getRecord(Logger::DEBUG, "[foo] [[\"bar\n[baz]\"]]{color: red}")); + + $expected = <<assertEquals($expected, BrowserConsoleHandler::generateScript()); + } + + + public function testAutolabel() + { + $handler = new BrowserConsoleHandler(); + $handler->setFormatter($this->getIdentityFormatter()); + + $handler->handle($this->getRecord(Logger::DEBUG, '[[foo]]{macro: autolabel}')); + $handler->handle($this->getRecord(Logger::DEBUG, '[[bar]]{macro: autolabel}')); + $handler->handle($this->getRecord(Logger::DEBUG, '[[foo]]{macro: autolabel}')); + + $expected = <<assertEquals($expected, BrowserConsoleHandler::generateScript()); + } + + public function testContext() + { + $handler = new BrowserConsoleHandler(); + $handler->setFormatter($this->getIdentityFormatter()); + + $handler->handle($this->getRecord(Logger::DEBUG, 'test', array('foo' => 'bar'))); + + $expected = <<assertEquals($expected, BrowserConsoleHandler::generateScript()); + } + + public function testConcurrentHandlers() + { + $handler1 = new BrowserConsoleHandler(); + $handler1->setFormatter($this->getIdentityFormatter()); + + $handler2 = new BrowserConsoleHandler(); + $handler2->setFormatter($this->getIdentityFormatter()); + + $handler1->handle($this->getRecord(Logger::DEBUG, 'test1')); + $handler2->handle($this->getRecord(Logger::DEBUG, 'test2')); + $handler1->handle($this->getRecord(Logger::DEBUG, 'test3')); + $handler2->handle($this->getRecord(Logger::DEBUG, 'test4')); + + $expected = <<assertEquals($expected, BrowserConsoleHandler::generateScript()); + } +} \ No newline at end of file