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