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