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\LineFormatter;
15use Monolog\Formatter\FormatterInterface;
16use Monolog\Logger;
17use Monolog\Utils;
18use PhpConsole\Connector;
19use PhpConsole\Handler as VendorPhpConsoleHandler;
20use PhpConsole\Helper;
21
22/**
23 * Monolog handler for Google Chrome extension "PHP Console"
24 *
25 * Display PHP error/debug log messages in Google Chrome console and notification popups, executes PHP code remotely
26 *
27 * Usage:
28 * 1. Install Google Chrome extension https://chrome.google.com/webstore/detail/php-console/nfhmhhlpfleoednkpnnnkolmclajemef
29 * 2. See overview https://github.com/barbushin/php-console#overview
30 * 3. Install PHP Console library https://github.com/barbushin/php-console#installation
31 * 4. Example (result will looks like http://i.hizliresim.com/vg3Pz4.png)
32 *
33 *      $logger = new \Monolog\Logger('all', array(new \Monolog\Handler\PHPConsoleHandler()));
34 *      \Monolog\ErrorHandler::register($logger);
35 *      echo $undefinedVar;
36 *      $logger->debug('SELECT * FROM users', array('db', 'time' => 0.012));
37 *      PC::debug($_SERVER); // PHP Console debugger for any type of vars
38 *
39 * @author Sergey Barbushin https://www.linkedin.com/in/barbushin
40 *
41 * @phpstan-import-type Record from \Monolog\Logger
42 */
43class PHPConsoleHandler extends AbstractProcessingHandler
44{
45    /** @var array<string, mixed> */
46    private $options = [
47        'enabled' => true, // bool Is PHP Console server enabled
48        'classesPartialsTraceIgnore' => ['Monolog\\'], // array Hide calls of classes started with...
49        'debugTagsKeysInContext' => [0, 'tag'], // bool Is PHP Console server enabled
50        'useOwnErrorsHandler' => false, // bool Enable errors handling
51        'useOwnExceptionsHandler' => false, // bool Enable exceptions handling
52        'sourcesBasePath' => null, // string Base path of all project sources to strip in errors source paths
53        'registerHelper' => true, // bool Register PhpConsole\Helper that allows short debug calls like PC::debug($var, 'ta.g.s')
54        'serverEncoding' => null, // string|null Server internal encoding
55        'headersLimit' => null, // int|null Set headers size limit for your web-server
56        'password' => null, // string|null Protect PHP Console connection by password
57        'enableSslOnlyMode' => false, // bool Force connection by SSL for clients with PHP Console installed
58        'ipMasks' => [], // array Set IP masks of clients that will be allowed to connect to PHP Console: array('192.168.*.*', '127.0.0.1')
59        'enableEvalListener' => false, // bool Enable eval request to be handled by eval dispatcher(if enabled, 'password' option is also required)
60        'dumperDetectCallbacks' => false, // bool Convert callback items in dumper vars to (callback SomeClass::someMethod) strings
61        'dumperLevelLimit' => 5, // int Maximum dumped vars array or object nested dump level
62        'dumperItemsCountLimit' => 100, // int Maximum dumped var same level array items or object properties number
63        'dumperItemSizeLimit' => 5000, // int Maximum length of any string or dumped array item
64        'dumperDumpSizeLimit' => 500000, // int Maximum approximate size of dumped vars result formatted in JSON
65        'detectDumpTraceAndSource' => false, // bool Autodetect and append trace data to debug
66        'dataStorage' => null, // \PhpConsole\Storage|null Fixes problem with custom $_SESSION handler(see http://goo.gl/Ne8juJ)
67    ];
68
69    /** @var Connector */
70    private $connector;
71
72    /**
73     * @param  array<string, mixed> $options   See \Monolog\Handler\PHPConsoleHandler::$options for more details
74     * @param  Connector|null       $connector Instance of \PhpConsole\Connector class (optional)
75     * @throws \RuntimeException
76     */
77    public function __construct(array $options = [], ?Connector $connector = null, $level = Logger::DEBUG, bool $bubble = true)
78    {
79        if (!class_exists('PhpConsole\Connector')) {
80            throw new \RuntimeException('PHP Console library not found. See https://github.com/barbushin/php-console#installation');
81        }
82        parent::__construct($level, $bubble);
83        $this->options = $this->initOptions($options);
84        $this->connector = $this->initConnector($connector);
85    }
86
87    /**
88     * @param array<string, mixed> $options
89     *
90     * @return array<string, mixed>
91     */
92    private function initOptions(array $options): array
93    {
94        $wrongOptions = array_diff(array_keys($options), array_keys($this->options));
95        if ($wrongOptions) {
96            throw new \RuntimeException('Unknown options: ' . implode(', ', $wrongOptions));
97        }
98
99        return array_replace($this->options, $options);
100    }
101
102    private function initConnector(?Connector $connector = null): Connector
103    {
104        if (!$connector) {
105            if ($this->options['dataStorage']) {
106                Connector::setPostponeStorage($this->options['dataStorage']);
107            }
108            $connector = Connector::getInstance();
109        }
110
111        if ($this->options['registerHelper'] && !Helper::isRegistered()) {
112            Helper::register();
113        }
114
115        if ($this->options['enabled'] && $connector->isActiveClient()) {
116            if ($this->options['useOwnErrorsHandler'] || $this->options['useOwnExceptionsHandler']) {
117                $handler = VendorPhpConsoleHandler::getInstance();
118                $handler->setHandleErrors($this->options['useOwnErrorsHandler']);
119                $handler->setHandleExceptions($this->options['useOwnExceptionsHandler']);
120                $handler->start();
121            }
122            if ($this->options['sourcesBasePath']) {
123                $connector->setSourcesBasePath($this->options['sourcesBasePath']);
124            }
125            if ($this->options['serverEncoding']) {
126                $connector->setServerEncoding($this->options['serverEncoding']);
127            }
128            if ($this->options['password']) {
129                $connector->setPassword($this->options['password']);
130            }
131            if ($this->options['enableSslOnlyMode']) {
132                $connector->enableSslOnlyMode();
133            }
134            if ($this->options['ipMasks']) {
135                $connector->setAllowedIpMasks($this->options['ipMasks']);
136            }
137            if ($this->options['headersLimit']) {
138                $connector->setHeadersLimit($this->options['headersLimit']);
139            }
140            if ($this->options['detectDumpTraceAndSource']) {
141                $connector->getDebugDispatcher()->detectTraceAndSource = true;
142            }
143            $dumper = $connector->getDumper();
144            $dumper->levelLimit = $this->options['dumperLevelLimit'];
145            $dumper->itemsCountLimit = $this->options['dumperItemsCountLimit'];
146            $dumper->itemSizeLimit = $this->options['dumperItemSizeLimit'];
147            $dumper->dumpSizeLimit = $this->options['dumperDumpSizeLimit'];
148            $dumper->detectCallbacks = $this->options['dumperDetectCallbacks'];
149            if ($this->options['enableEvalListener']) {
150                $connector->startEvalRequestsListener();
151            }
152        }
153
154        return $connector;
155    }
156
157    public function getConnector(): Connector
158    {
159        return $this->connector;
160    }
161
162    /**
163     * @return array<string, mixed>
164     */
165    public function getOptions(): array
166    {
167        return $this->options;
168    }
169
170    public function handle(array $record): bool
171    {
172        if ($this->options['enabled'] && $this->connector->isActiveClient()) {
173            return parent::handle($record);
174        }
175
176        return !$this->bubble;
177    }
178
179    /**
180     * Writes the record down to the log of the implementing handler
181     */
182    protected function write(array $record): void
183    {
184        if ($record['level'] < Logger::NOTICE) {
185            $this->handleDebugRecord($record);
186        } elseif (isset($record['context']['exception']) && $record['context']['exception'] instanceof \Throwable) {
187            $this->handleExceptionRecord($record);
188        } else {
189            $this->handleErrorRecord($record);
190        }
191    }
192
193    /**
194     * @phpstan-param Record $record
195     */
196    private function handleDebugRecord(array $record): void
197    {
198        $tags = $this->getRecordTags($record);
199        $message = $record['message'];
200        if ($record['context']) {
201            $message .= ' ' . Utils::jsonEncode($this->connector->getDumper()->dump(array_filter($record['context'])), null, true);
202        }
203        $this->connector->getDebugDispatcher()->dispatchDebug($message, $tags, $this->options['classesPartialsTraceIgnore']);
204    }
205
206    /**
207     * @phpstan-param Record $record
208     */
209    private function handleExceptionRecord(array $record): void
210    {
211        $this->connector->getErrorsDispatcher()->dispatchException($record['context']['exception']);
212    }
213
214    /**
215     * @phpstan-param Record $record
216     */
217    private function handleErrorRecord(array $record): void
218    {
219        $context = $record['context'];
220
221        $this->connector->getErrorsDispatcher()->dispatchError(
222            $context['code'] ?? null,
223            $context['message'] ?? $record['message'],
224            $context['file'] ?? null,
225            $context['line'] ?? null,
226            $this->options['classesPartialsTraceIgnore']
227        );
228    }
229
230    /**
231     * @phpstan-param Record $record
232     * @return string
233     */
234    private function getRecordTags(array &$record)
235    {
236        $tags = null;
237        if (!empty($record['context'])) {
238            $context = & $record['context'];
239            foreach ($this->options['debugTagsKeysInContext'] as $key) {
240                if (!empty($context[$key])) {
241                    $tags = $context[$key];
242                    if ($key === 0) {
243                        array_shift($context);
244                    } else {
245                        unset($context[$key]);
246                    }
247                    break;
248                }
249            }
250        }
251
252        return $tags ?: strtolower($record['level_name']);
253    }
254
255    /**
256     * {@inheritDoc}
257     */
258    protected function getDefaultFormatter(): FormatterInterface
259    {
260        return new LineFormatter('%message%');
261    }
262}
263