Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.26% covered (warning)
78.26%
54 / 69
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
BH_WP_PSR_Logger
78.26% covered (warning)
78.26%
54 / 69
0.00% covered (danger)
0.00%
0 / 4
24.11
0.00% covered (danger)
0.00%
0 / 1
 __construct
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 get_logger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 error
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
1.12
 log
80.65% covered (warning)
80.65%
50 / 62
0.00% covered (danger)
0.00%
0 / 1
15.42
1<?php
2/**
3 * Facade over a real PSR logger.
4 *
5 * Uses the provided settings to determine which logger to use.
6 *
7 * @package brianhenryie/bh-wp-logger
8 */
9
10namespace BrianHenryIE\WP_Logger\API;
11
12use BrianHenryIE\WP_CLI_Logger\WP_CLI_Logger;
13use BrianHenryIE\WP_Logger\Logger_Settings_Interface;
14use Exception;
15use Psr\Log\LoggerAwareTrait;
16use Psr\Log\LoggerInterface;
17use Psr\Log\LoggerTrait;
18use Psr\Log\LogLevel;
19use Psr\Log\NullLogger;
20use ReflectionClass;
21use ReflectionException;
22use Stringable;
23use WP_CLI;
24use WP_CLI\Runner;
25
26/**
27 * Functions to add context to logs and to record the time of logs.
28 */
29class BH_WP_PSR_Logger extends API implements LoggerInterface {
30    use LoggerTrait;
31    use LoggerAwareTrait; // To allow swapping out the logger at runtime.
32
33    /**
34     * @see WP_CLI_Logger
35     */
36    protected LoggerInterface $cli_logger;
37
38    /**
39     * Protect against infinite loops when delegated loggers themselves cause errors.
40     */
41    protected bool $is_looping = false;
42
43    /**
44     * Construct
45     *
46     * @param Logger_Settings_Interface $settings The configured settings.
47     * @param LoggerInterface           $logger The true logger that logging is delegated to.
48     */
49    public function __construct(
50        Logger_Settings_Interface $settings,
51        LoggerInterface $logger
52    ) {
53        parent::__construct( $settings, $logger );
54
55        /**
56         * When WP CLI commands are appended with `--debug` or more specifically `--debug=plugin-slug` all messages will be output.
57         *
58         * @see https://wordpress.stackexchange.com/questions/226152/detect-if-wp-is-running-under-wp-cli
59         */
60        $this->cli_logger = ( defined( 'WP_CLI' ) && constant( 'WP_CLI' ) && class_exists( WP_CLI_Logger::class ) ) /** @phpstan-ignore booleanAnd.rightAlwaysTrue */
61                        ? new WP_CLI_Logger()
62                        : new NullLogger();
63    }
64
65    /**
66     * Return the true (proxied) logger.
67     */
68    public function get_logger(): LoggerInterface {
69        return $this->logger;
70    }
71
72    /**
73     * When an error is being logged, record the time of the last error, so later, an admin notice can be displayed,
74     * to inform them of the new problem.
75     *
76     * TODO: This always displays the admin notice even when the log itself is filtered. i.e. this function runs before
77     * the filter, so the code needs to be moved.
78     * TODO: Allow configuring which log levels result in the admin notice.
79     *
80     * TODO: include a link to the log url so the last file with an error will be linked, rather than the most recent log file.
81     *
82     * @param string|Stringable    $message The message to be logged.
83     * @param array<string, mixed> $context Data to record the system state at the time of the log.
84     */
85    public function error( string|Stringable $message, array $context = array() ): void {
86
87        $this->log( LogLevel::ERROR, $message, $context );
88    }
89
90
91    /**
92     * The last function in this plugin before the actual logging is delegated to KLogger/WC_Logger...
93     * * If WP_CLI is available, log to console.
94     * * If logger is not available (presumably WC_Logger not yet initialized), enqueue the log to retry on plugins_loaded.
95     * * Set WC_Logger 'source'.
96     * * Execute the actual logging command.
97     * * Record in wp_options the time of the last log.
98     *
99     * TODO: Add a filter on level.
100     *
101     * @param string                   $level The log severity.
102     * @param string|Stringable        $message The message to log.
103     * @param array<int|string, mixed> $context Additional information to be logged (not saved at all log levels).
104     * @see LogLevel
105     */
106    public function log( $level, string|Stringable $message, array $context = array() ): void {
107        if ( $this->is_looping ) {
108            return;
109        }
110
111        $this->is_looping = true;
112
113        try {
114
115            $message = (string) $message;
116
117            $context = array_merge( $context, $this->get_common_context() );
118
119            $settings_log_level = $this->settings->get_log_level();
120
121            if ( LogLevel::ERROR === $level ) {
122
123                $debug_backtrace            = $this->get_backtrace();
124                $context['debug_backtrace'] = $debug_backtrace;
125
126                // TODO: This could be useful on all logs.
127                global $wp_current_filter;
128                $context['filters'] = $wp_current_filter;
129
130            } elseif ( LogLevel::WARNING === $level || LogLevel::DEBUG === $settings_log_level ) {
131
132                $debug_backtrace            = $this->get_backtrace( null, 3 );
133                $context['debug_backtrace'] = $debug_backtrace;
134
135                global $wp_current_filter;
136                $context['filters'] = $wp_current_filter;
137            }
138
139            if ( isset( $context['exception'] ) && $context['exception'] instanceof Exception ) {
140                $exception                    = $context['exception'];
141                $exception_details            = array();
142                $exception_details['class']   = $exception::class;
143                $exception_details['message'] = $exception->getMessage();
144
145                $props = array();
146                try {
147                    $reflect = new ReflectionClass( $exception::class );
148                    foreach ( $reflect->getProperties() as $property ) {
149                        PHP_VERSION_ID < 80100 && $property->setAccessible( true );
150                        $props[ $property->getName() ] = $property->getValue( $exception );
151                    }
152                    // phpcs:disable Generic.CodeAnalysis.EmptyStatement.DetectedCatch
153                } catch ( ReflectionException $_exception ) {
154                    // Ironic.
155                }
156                $exception_details['properties'] = $props;
157
158                $exception_details['backtrace'] = $context['exception']->getTrace();
159
160                $context['exception'] = $exception_details;
161            }
162
163            /**
164             * TODO: regex to replace email addresses with b**********e@gmail.com, credit card numbers etc.
165             * There's a PHP proposal for omitting info from logs.
166             *
167             * @see https://wiki.php.net/rfc/redact_parameters_in_back_traces
168             */
169
170            $log_data         = array(
171                'level'   => $level,
172                'message' => $message,
173                'context' => $context,
174            );
175            $settings         = $this->settings;
176            $bh_wp_psr_logger = $this;
177
178            /**
179             * Filter to modify the log data.
180             * Return null to cancel logging this message.
181             *
182             * @param array{level:string,message:string,context:array} $log_data
183             * @param Logger_Settings_Interface $settings
184             * @param BH_WP_PSR_Logger $bh_wp_psr_logger
185             */
186            $log_data = apply_filters( $this->settings->get_plugin_slug() . '_bh_wp_logger_log', $log_data, $settings, $bh_wp_psr_logger );
187
188            if ( empty( $log_data ) ) {
189                return;
190            }
191
192            [ $level, $message, $context ] = array_values( $log_data );
193
194            if ( LogLevel::ERROR === $level ) {
195                update_option(
196                    $this->settings->get_plugin_slug() . '-recent-error-data',
197                    array(
198                        'message'   => $message,
199                        'timestamp' => time(),
200                    )
201                );
202            }
203
204            // Add WP CLI command to context.
205            if ( defined( 'WP_CLI' ) && constant( 'WP_CLI' ) ) {
206                /** @phpstan-ignore booleanAnd.rightAlwaysTrue */
207                /** @var Runner $runner */
208                $runner            = WP_CLI::get_runner();
209                $context['wp_cli'] = array(
210                    array(
211                        'command'    => 'wp ' . implode( ' ', $runner->arguments ),
212                        'assoc_args' => $runner->assoc_args,
213                    ),
214                );
215            }
216
217            $this->logger->$level( $message, $context );
218            $this->cli_logger->$level( $message, $context );
219
220            // We store the last log time in a transient to avoid reading the file from disk. When a new log is written,
221            // that transient is expired. TODO: We're deleting here on the assumption deleting is more performant than writing
222            // the new value. This could also be run only in WordPress's 'shutdown' action.
223            delete_transient( $this->get_last_log_time_transient_name() );
224        } finally {
225            $this->is_looping = false;
226        }
227    }
228}