BrowserConsoleHandler.php 7.4 KB

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