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