BrowserConsoleHandler.php 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. <?php declare(strict_types=1);
  2. /*
  3. * This file is part of the Monolog package.
  4. *
  5. * (c) Jordi Boggiano <j.boggiano@seld.be>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Monolog\Handler;
  11. use Monolog\Formatter\LineFormatter;
  12. use Monolog\Formatter\FormatterInterface;
  13. /**
  14. * Handler sending logs to browser's javascript console with no browser extension required
  15. *
  16. * @author Olivier Poitrey <rs@dailymotion.com>
  17. */
  18. class BrowserConsoleHandler extends AbstractProcessingHandler
  19. {
  20. protected static $initialized = false;
  21. protected static $records = [];
  22. /**
  23. * {@inheritDoc}
  24. *
  25. * Formatted output may contain some formatting markers to be transferred to `console.log` using the %c format.
  26. *
  27. * Example of formatted string:
  28. *
  29. * You can do [[blue text]]{color: blue} or [[green background]]{background-color: green; color: white}
  30. */
  31. protected function getDefaultFormatter(): FormatterInterface
  32. {
  33. return new LineFormatter('[[%channel%]]{macro: autolabel} [[%level_name%]]{font-weight: bold} %message%');
  34. }
  35. /**
  36. * {@inheritDoc}
  37. */
  38. protected function write(array $record): void
  39. {
  40. // Accumulate records
  41. static::$records[] = $record;
  42. // Register shutdown handler if not already done
  43. if (!static::$initialized) {
  44. static::$initialized = true;
  45. $this->registerShutdownFunction();
  46. }
  47. }
  48. /**
  49. * Convert records to javascript console commands and send it to the browser.
  50. * This method is automatically called on PHP shutdown if output is HTML or Javascript.
  51. */
  52. public static function send()
  53. {
  54. $format = static::getResponseFormat();
  55. if ($format === 'unknown') {
  56. return;
  57. }
  58. if (count(static::$records)) {
  59. if ($format === 'html') {
  60. static::writeOutput('<script>' . static::generateScript() . '</script>');
  61. } elseif ($format === 'js') {
  62. static::writeOutput(static::generateScript());
  63. }
  64. static::reset();
  65. }
  66. }
  67. /**
  68. * Forget all logged records
  69. */
  70. public static function reset()
  71. {
  72. static::$records = [];
  73. }
  74. /**
  75. * Wrapper for register_shutdown_function to allow overriding
  76. */
  77. protected function registerShutdownFunction()
  78. {
  79. if (PHP_SAPI !== 'cli') {
  80. register_shutdown_function(['Monolog\Handler\BrowserConsoleHandler', 'send']);
  81. }
  82. }
  83. /**
  84. * Wrapper for echo to allow overriding
  85. */
  86. protected static function writeOutput(string $str)
  87. {
  88. echo $str;
  89. }
  90. /**
  91. * Checks the format of the response
  92. *
  93. * If Content-Type is set to application/javascript or text/javascript -> js
  94. * If Content-Type is set to text/html, or is unset -> html
  95. * If Content-Type is anything else -> unknown
  96. *
  97. * @return string One of 'js', 'html' or 'unknown'
  98. */
  99. protected static function getResponseFormat()
  100. {
  101. // Check content type
  102. foreach (headers_list() as $header) {
  103. if (stripos($header, 'content-type:') === 0) {
  104. // This handler only works with HTML and javascript outputs
  105. // text/javascript is obsolete in favour of application/javascript, but still used
  106. if (stripos($header, 'application/javascript') !== false || stripos($header, 'text/javascript') !== false) {
  107. return 'js';
  108. }
  109. if (stripos($header, 'text/html') === false) {
  110. return 'unknown';
  111. }
  112. break;
  113. }
  114. }
  115. return 'html';
  116. }
  117. private static function generateScript()
  118. {
  119. $script = [];
  120. foreach (static::$records as $record) {
  121. $context = static::dump('Context', $record['context']);
  122. $extra = static::dump('Extra', $record['extra']);
  123. if (empty($context) && empty($extra)) {
  124. $script[] = static::call_array('log', static::handleStyles($record['formatted']));
  125. } else {
  126. $script = array_merge(
  127. $script,
  128. [static::call_array('groupCollapsed', static::handleStyles($record['formatted']))],
  129. $context,
  130. $extra,
  131. [static::call('groupEnd')]
  132. );
  133. }
  134. }
  135. return "(function (c) {if (c && c.groupCollapsed) {\n" . implode("\n", $script) . "\n}})(console);";
  136. }
  137. private static function handleStyles($formatted)
  138. {
  139. $args = [static::quote('font-weight: normal')];
  140. $format = '%c' . $formatted;
  141. preg_match_all('/\[\[(.*?)\]\]\{([^}]*)\}/s', $format, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
  142. foreach (array_reverse($matches) as $match) {
  143. $args[] = static::quote(static::handleCustomStyles($match[2][0], $match[1][0]));
  144. $args[] = '"font-weight: normal"';
  145. $pos = $match[0][1];
  146. $format = substr($format, 0, $pos) . '%c' . $match[1][0] . '%c' . substr($format, $pos + strlen($match[0][0]));
  147. }
  148. array_unshift($args, static::quote($format));
  149. return $args;
  150. }
  151. private static function handleCustomStyles($style, $string)
  152. {
  153. static $colors = ['blue', 'green', 'red', 'magenta', 'orange', 'black', 'grey'];
  154. static $labels = [];
  155. return preg_replace_callback('/macro\s*:(.*?)(?:;|$)/', function ($m) use ($string, &$colors, &$labels) {
  156. if (trim($m[1]) === 'autolabel') {
  157. // Format the string as a label with consistent auto assigned background color
  158. if (!isset($labels[$string])) {
  159. $labels[$string] = $colors[count($labels) % count($colors)];
  160. }
  161. $color = $labels[$string];
  162. return "background-color: $color; color: white; border-radius: 3px; padding: 0 2px 0 2px";
  163. }
  164. return $m[1];
  165. }, $style);
  166. }
  167. private static function dump($title, array $dict)
  168. {
  169. $script = [];
  170. $dict = array_filter($dict);
  171. if (empty($dict)) {
  172. return $script;
  173. }
  174. $script[] = static::call('log', static::quote('%c%s'), static::quote('font-weight: bold'), static::quote($title));
  175. foreach ($dict as $key => $value) {
  176. $value = json_encode($value);
  177. if (empty($value)) {
  178. $value = static::quote('');
  179. }
  180. $script[] = static::call('log', static::quote('%s: %o'), static::quote($key), $value);
  181. }
  182. return $script;
  183. }
  184. private static function quote($arg)
  185. {
  186. return '"' . addcslashes($arg, "\"\n\\") . '"';
  187. }
  188. private static function call()
  189. {
  190. $args = func_get_args();
  191. $method = array_shift($args);
  192. return static::call_array($method, $args);
  193. }
  194. private static function call_array($method, array $args)
  195. {
  196. return 'c.' . $method . '(' . implode(', ', $args) . ');';
  197. }
  198. }