BrowserConsoleHandler.php 8.6 KB

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