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