bkdotcom/PHPDebugConsole

View on GitHub
src/Debug/Route/ChromeLogger.php

Summary

Maintainability
A
1 hr
Test Coverage
A
100%
<?php

/**
 * This file is part of PHPDebugConsole
 *
 * @package   PHPDebugConsole
 * @author    Brad Kent <bkfake-github@yahoo.com>
 * @license   http://opensource.org/licenses/MIT MIT
 * @copyright 2014-2024 Brad Kent
 * @since     2.0
 *
 * @see https://craig.is/writing/chrome-logger/techspecs
 */

namespace bdk\Debug\Route;

use bdk\Debug;
use bdk\Debug\Abstraction\Abstracter;
use bdk\Debug\Abstraction\Type;
use bdk\Debug\LogEntry;
use bdk\PubSub\Event;

/**
 * Output log as via ChromeLogger headers
 *
 * ChromeLogger supports the following methods/log-types:
 * log, warn, error, info, group, groupEnd, groupCollapsed, and table
 */
class ChromeLogger extends AbstractRoute
{
    const HEADER_NAME = 'X-ChromeLogger-Data';

    /** @var bool */
    protected $appendsHeaders = true;

    /** @var array<string,mixed> */
    protected $cfg = array(
        'channels' => ['*'],
        'channelsExclude' => [
            'events',
            'files',
        ],
        'group' => true, // contain/wrap log in a group?
    );

    /** @var list<string> */
    protected $consoleMethods = [
        'assert',
        // 'count',    // output as log
        'error',
        'group',
        'groupCollapsed',
        'groupEnd',
        'info',
        'log',
        'table',
        // 'time',     // output as log
        'timeEnd',  // PHPDebugConsole never generates a timeEnd entry
        'trace',
        'warn',
    ];

    /** @var int Current group depth  */
    protected $depth = 0;

    /** @var bool Whether we're only collecting groups due to header size limit */
    protected $groupOnly = false;

    /**
     * @var array header data
     */
    // phpcs:ignore SlevomatCodingStandard.Arrays.AlphabeticallySortedByKeys.IncorrectKeyOrder
    protected $jsonData = array(
        'version' => Debug::VERSION,
        'columns' => ['log', 'backtrace', 'type'],
        'rows' => [],
    );

    /** @var int Maximum header length */
    protected $max = 0;

    /**
     * Constructor
     *
     * @param Debug $debug debug instance
     */
    public function __construct(Debug $debug)
    {
        parent::__construct($debug);
        $this->dumper = $debug->getDump('base');
    }

    /**
     * Output the log as chromelogger headers
     *
     * @param Event|null $event Debug::EVENT_OUTPUT Event object
     *
     * @return void
     */
    public function processLogEntries($event = null)
    {
        $this->debug->utility->assertType($event, 'bdk\PubSub\Event');

        $this->dumper->crateRaw = false;
        $this->data = $this->debug->data->get();
        $this->data['log']  = \array_values($this->data['log']);
        $this->buildJsonData();
        $this->max = $this->getMaxLength();
        $encoded = $this->encode($this->jsonData);
        if ($this->max && \strlen($encoded) > $this->max) {
            $this->reduceData();
            $this->buildJsonData();
            $encoded = $this->encode($this->jsonData);
            $encoded = $this->assertEncodedLength($encoded);
        }
        if ($this->jsonData['rows']) {
            $event['headers'][] = [self::HEADER_NAME, $encoded];
        }
        $this->data = array();
        $this->jsonData['rows'] = [];
        $this->dumper->crateRaw = true;
    }

    /**
     * {@inheritDoc}
     */
    public function processLogEntry(LogEntry $logEntry)
    {
        $this->dumper->processLogEntry($logEntry);
        $method = $logEntry['method'];
        $args = $logEntry['args'];
        $meta = $logEntry['meta'];
        if ($method === 'assert') {
            \array_unshift($args, false);
        } elseif (\in_array($method, $this->consoleMethods, true) === false) {
            $method = 'log';
        }
        $this->jsonData['rows'][] = [
            $args,
            isset($meta['file']) ? $meta['file'] . ': ' . $meta['line'] : null,
            $method === 'log' ? '' : $method,
        ];
    }

    /**
     * Test that our header's length is less than max
     *
     * @param string $encoded ChromeLogger header value
     *
     * @return string header
     */
    private function assertEncodedLength($encoded)
    {
        if (\strlen($encoded) <= $this->max) {
            return $encoded;
        }
        $this->jsonData['rows'] = [
            [
                ['chromeLogger: unable to abridge log to ' . $this->debug->utility->getBytes($this->max)],
                null,
                'warn',
            ],
        ];
        return $this->encode($this->jsonData);
    }

    /**
     * Build Chromelogger JSON
     *
     * @return void
     */
    protected function buildJsonData()
    {
        $this->jsonData['rows'] = [];
        $this->processAlerts();
        $this->processSummary();
        $this->processLog();
        $heading = ['PHP', $this->getRequestMethodUri()];
        if (!$this->cfg['group']) {
            \array_unshift($this->jsonData['rows'], [$heading, null, 'info']);
            return;
        }
        \array_unshift($this->jsonData['rows'], [$heading, null, 'groupCollapsed']);
        \array_push($this->jsonData['rows'], [[], null, 'groupEnd']);
    }

    /**
     * Calculate header size
     *
     * @return int
     */
    protected function calcHeaderSize()
    {
        $this->buildJsonData();
        $encoded = $this->encode($this->jsonData);
        return \strlen(self::HEADER_NAME . ': ') + \strlen($encoded);
    }

    /**
     * Encode data for header
     *
     * @param array $data log data
     *
     * @return string encoded data for header
     */
    protected function encode($data)
    {
        $data = \json_encode($data, JSON_UNESCAPED_SLASHES);
        $data = $this->translateJsonValues($data);
        return \base64_encode($data);
    }

    /**
     * Get maximum allowed header length
     *
     * @return int
     */
    protected function getMaxLength()
    {
        $maxVals = \array_filter([
            $this->debug->utility->getBytes($this->debug->getCfg('headerMaxAll', Debug::CONFIG_DEBUG), true),
            $this->debug->utility->getBytes($this->debug->getCfg('headerMaxPer', Debug::CONFIG_DEBUG), true),
        ]);
        return \min($maxVals);
    }

    /**
     * Attempt to remove log entries to get header length < max
     *
     * @return void
     */
    protected function reduceData()
    {
        \array_unshift($this->data['alerts'], new LogEntry(
            $this->debug,
            'alert',
            ['Log abridged due to header size constraint'],
            array(
                'level' => 'info',
            )
        ));
        $this->reduceDataSummary();
        /*
            Remove all log entries sans assert, error, & warn
        */
        $logBack = array();
        foreach ($this->data['log'] as $i => $logEntry) {
            if (\in_array($logEntry['method'], ['assert', 'error', 'warn'], true) === false) {
                unset($this->data['log'][$i]);
                $logBack[$i] = $logEntry;
            }
        }
        /*
            Data is now just alerts, summary, and errors
        */
        $strlen = $this->calcHeaderSize();
        $avail = $this->max - $strlen;
        if ($avail > 128) {
            // we've got enough room to fill with additional entries
            $this->reduceDataFill($logBack);
        }
    }

    /**
     * Abridge the summary
     *
     * @return void
     */
    private function reduceDataSummary()
    {
        /*
            Remove non-essential summary entries
        */
        $summaryRemove = [
            '$_COOKIE',
            '$_POST',
            'Built In',
            'ini location',
            'git branch',
            'memory_limit',
            'Peak Memory Usage',
            'PHP Version',
            'php://input',
            'session.cache_limiter',
            'session_save_path',
        ];
        $summaryRemoveRegex = '/^(' . \implode('|', \array_map(static function ($val) {
            return \preg_quote($val, '/');
        }, $summaryRemove)) . ')/';
        foreach ($this->data['logSummary'] as $priority => $logEntries) {
            foreach ($logEntries as $i => $logEntry) {
                if ($logEntry['args'] && \preg_match($summaryRemoveRegex, $logEntry['args'][0])) {
                    unset($logEntries[$i]);
                }
            }
            $this->data['logSummary'][$priority] = \array_values($logEntries);
        }
    }

    /**
     * Add back log entries until we're out of space
     *
     * @param array $logBack logEntries removed in initial pass
     *
     * @return void
     */
    protected function reduceDataFill($logBack = array())
    {
        $indexes = \array_reverse(\array_keys($logBack));
        $this->depth = 0;
        $this->groupOnly = false;
        /*
            work our way backwards through the log until we fill the avail header length
        */
        foreach ($indexes as $i) {
            $logEntry = $logBack[$i];
            $continue = $this->reduceDataFillWalk($logEntry, $i);
            if ($continue === false) {
                break;
            }
        }
        \ksort($this->data['log']);
        $this->data['log'] = \array_values($this->data['log']);
    }

    /**
     * Add back log entries until we're out of space
     *
     * @param LogEntry   $logEntry LogEntry instance
     * @param int|string $index    LogEntry index
     *
     * @return bool whether to continue
     */
    private function reduceDataFillWalk(LogEntry $logEntry, $index)
    {
        $method = $logEntry['method'];
        if ($method === 'groupEnd') {
            $this->depth++;
            // https://bugs.xdebug.org/view.php?id=2095
            // phpcs:ignore SlevomatCodingStandard.Namespaces.FullyQualifiedGlobalFunctions.NonFullyQualified
        } elseif (in_array($method, ['group', 'groupCollapsed'], true)) {
            $this->depth--;
        } elseif ($this->groupOnly) {
            return true;
        }
        $this->data['log'][$index] = $logEntry;
        $strlen = $this->calcHeaderSize();
        if ($this->groupOnly && $this->depth === 0) {
            return false;
        }
        if ($strlen + (40 * $this->depth) > $this->max) {
            unset($this->data['log'][$index]);
            $this->groupOnly = true;
        }
        return true;
    }

    /**
     * Handle INF, Nan, & "undefined"
     *
     * @param string $json Json string
     *
     * @return string
     */
    protected function translateJsonValues($json)
    {
        return \str_replace(
            [
                \json_encode(Type::TYPE_FLOAT_INF),
                \json_encode(Type::TYPE_FLOAT_NAN),
                \json_encode(Abstracter::UNDEFINED),
            ],
            [
                '"INF"',
                '"NaN"',
                'null',
            ],
            $json
        );
    }
}