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