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\ChromePHPFormatter; 15use Monolog\Formatter\FormatterInterface; 16use Monolog\Logger; 17use Monolog\Utils; 18 19/** 20 * Handler sending logs to the ChromePHP extension (http://www.chromephp.com/) 21 * 22 * This also works out of the box with Firefox 43+ 23 * 24 * @author Christophe Coevoet <stof@notk.org> 25 * 26 * @phpstan-import-type Record from \Monolog\Logger 27 */ 28class ChromePHPHandler extends AbstractProcessingHandler 29{ 30 use WebRequestRecognizerTrait; 31 32 /** 33 * Version of the extension 34 */ 35 protected const VERSION = '4.0'; 36 37 /** 38 * Header name 39 */ 40 protected const HEADER_NAME = 'X-ChromeLogger-Data'; 41 42 /** 43 * Regular expression to detect supported browsers (matches any Chrome, or Firefox 43+) 44 */ 45 protected const USER_AGENT_REGEX = '{\b(?:Chrome/\d+(?:\.\d+)*|HeadlessChrome|Firefox/(?:4[3-9]|[5-9]\d|\d{3,})(?:\.\d)*)\b}'; 46 47 /** @var bool */ 48 protected static $initialized = false; 49 50 /** 51 * Tracks whether we sent too much data 52 * 53 * Chrome limits the headers to 4KB, so when we sent 3KB we stop sending 54 * 55 * @var bool 56 */ 57 protected static $overflowed = false; 58 59 /** @var mixed[] */ 60 protected static $json = [ 61 'version' => self::VERSION, 62 'columns' => ['label', 'log', 'backtrace', 'type'], 63 'rows' => [], 64 ]; 65 66 /** @var bool */ 67 protected static $sendHeaders = true; 68 69 public function __construct($level = Logger::DEBUG, bool $bubble = true) 70 { 71 parent::__construct($level, $bubble); 72 if (!function_exists('json_encode')) { 73 throw new \RuntimeException('PHP\'s json extension is required to use Monolog\'s ChromePHPHandler'); 74 } 75 } 76 77 /** 78 * {@inheritDoc} 79 */ 80 public function handleBatch(array $records): void 81 { 82 if (!$this->isWebRequest()) { 83 return; 84 } 85 86 $messages = []; 87 88 foreach ($records as $record) { 89 if ($record['level'] < $this->level) { 90 continue; 91 } 92 /** @var Record $message */ 93 $message = $this->processRecord($record); 94 $messages[] = $message; 95 } 96 97 if (!empty($messages)) { 98 $messages = $this->getFormatter()->formatBatch($messages); 99 self::$json['rows'] = array_merge(self::$json['rows'], $messages); 100 $this->send(); 101 } 102 } 103 104 /** 105 * {@inheritDoc} 106 */ 107 protected function getDefaultFormatter(): FormatterInterface 108 { 109 return new ChromePHPFormatter(); 110 } 111 112 /** 113 * Creates & sends header for a record 114 * 115 * @see sendHeader() 116 * @see send() 117 */ 118 protected function write(array $record): void 119 { 120 if (!$this->isWebRequest()) { 121 return; 122 } 123 124 self::$json['rows'][] = $record['formatted']; 125 126 $this->send(); 127 } 128 129 /** 130 * Sends the log header 131 * 132 * @see sendHeader() 133 */ 134 protected function send(): void 135 { 136 if (self::$overflowed || !self::$sendHeaders) { 137 return; 138 } 139 140 if (!self::$initialized) { 141 self::$initialized = true; 142 143 self::$sendHeaders = $this->headersAccepted(); 144 if (!self::$sendHeaders) { 145 return; 146 } 147 148 self::$json['request_uri'] = $_SERVER['REQUEST_URI'] ?? ''; 149 } 150 151 $json = Utils::jsonEncode(self::$json, Utils::DEFAULT_JSON_FLAGS & ~JSON_UNESCAPED_UNICODE, true); 152 $data = base64_encode(utf8_encode($json)); 153 if (strlen($data) > 3 * 1024) { 154 self::$overflowed = true; 155 156 $record = [ 157 'message' => 'Incomplete logs, chrome header size limit reached', 158 'context' => [], 159 'level' => Logger::WARNING, 160 'level_name' => Logger::getLevelName(Logger::WARNING), 161 'channel' => 'monolog', 162 'datetime' => new \DateTimeImmutable(), 163 'extra' => [], 164 ]; 165 self::$json['rows'][count(self::$json['rows']) - 1] = $this->getFormatter()->format($record); 166 $json = Utils::jsonEncode(self::$json, null, true); 167 $data = base64_encode(utf8_encode($json)); 168 } 169 170 if (trim($data) !== '') { 171 $this->sendHeader(static::HEADER_NAME, $data); 172 } 173 } 174 175 /** 176 * Send header string to the client 177 */ 178 protected function sendHeader(string $header, string $content): void 179 { 180 if (!headers_sent() && self::$sendHeaders) { 181 header(sprintf('%s: %s', $header, $content)); 182 } 183 } 184 185 /** 186 * Verifies if the headers are accepted by the current user agent 187 */ 188 protected function headersAccepted(): bool 189 { 190 if (empty($_SERVER['HTTP_USER_AGENT'])) { 191 return false; 192 } 193 194 return preg_match(static::USER_AGENT_REGEX, $_SERVER['HTTP_USER_AGENT']) === 1; 195 } 196} 197