Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.22% covered (warning)
72.22%
39 / 54
33.33% covered (danger)
33.33%
2 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
PHP_Error_Handler
72.22% covered (warning)
72.22%
39 / 54
33.33% covered (danger)
33.33%
2 / 6
19.82
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 init
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 plugin_error_handler
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
3.00
 return_error_handler_result
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 is_related_error
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 errno_to_psr3
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Catches PHP warning, deprecated etc. which are then checked to see if they were raised by the
4 * plugin the logger is for, in which case they are logged to its log file.
5 *
6 * Chains and calls previously set_error_handler handlers.
7 *
8 * @package brianhenryie/bh-wp-logger
9 *
10 * phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
11 */
12
13namespace BrianHenryIE\WP_Logger\PHP;
14
15use BrianHenryIE\WP_Logger\API_Interface;
16use BrianHenryIE\WP_Logger\Logger_Settings_Interface;
17use Psr\Log\LoggerAwareTrait;
18use Psr\Log\LoggerInterface;
19use Psr\Log\LogLevel;
20
21/**
22 * Class PHP_Error_Handler
23 */
24class PHP_Error_Handler {
25
26    use LoggerAwareTrait;
27
28    /**
29     * Used here for backtrace functions.
30     *
31     * @var API_Interface
32     */
33    protected API_Interface $api;
34
35    /**
36     * Used here for slug and basename to name and to compare data.
37     *
38     * @var Logger_Settings_Interface
39     */
40    protected Logger_Settings_Interface $settings;
41
42    /**
43     * Only one error handler can be added at a time, so we chain them.
44     *
45     * @var ?callable with signature ( int $errno, string $errstr, ?string $errfile, ?int $errline  )
46     */
47    protected $previous_error_handler = null;
48
49    /**
50     * Constructor.
51     *
52     * @param API_Interface             $api The class that determines if the errors are relevant.
53     * @param Logger_Settings_Interface $settings The settings, used here for identifiers (slug,basename).
54     * @param LoggerInterface           $logger The logger for actually recording the errors as we wish.
55     */
56    public function __construct( API_Interface $api, Logger_Settings_Interface $settings, LoggerInterface $logger ) {
57        $this->setLogger( $logger );
58        $this->api      = $api;
59        $this->settings = $settings;
60    }
61
62    /**
63     * Since this is hooked on plugins_loaded, it won't display errors that occur as plugins' constructors run.
64     *
65     * But since it's hooked on plugins_loaded (as distinct from just initializing), it allows other plugins to
66     * hook error handlers in before or after this one.
67     *
68     * @hooked plugins_loaded
69     */
70    public function init(): void {
71
72        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
73        $this->previous_error_handler = set_error_handler(
74            array( $this, 'plugin_error_handler' ),
75            E_ALL
76        );
77    }
78
79    /**
80     * The error handler itself.
81     *
82     * @see set_error_handler()
83     *
84     * @param int    $errno The error code (the level of the error raised, as an integer).
85     * @param string $errstr A string describing the error.
86     * @param string $errfile The filename in which the error occurred.
87     * @param int    $errline The line number in which the error occurred.
88     *
89     * @return bool True if error had been handled and no more handling to do, false to pass the error on.
90     */
91    public function plugin_error_handler( int $errno, string $errstr, string $errfile, int $errline ) {
92
93        $func_args = func_get_args();
94
95        $plugin_related_error = $this->is_related_error( $errno, $errstr, $errfile, $errline );
96
97        if ( ! $plugin_related_error ) {
98            // If there is another handler, return its result, otherwise indicate the error was not handled.
99            return $this->return_error_handler_result( false, $func_args );
100        }
101
102        // e.g. my-plugin-slug-logged-error-4f6ead5467acd...
103        $transient_key = "{$this->settings->get_plugin_slug()}-logged-{$this->errno_to_psr3( $errno )}-" . md5( $errstr );
104
105        $transient_value = get_transient( $transient_key );
106
107        // We've already logged this error recently, don't bother logging it again.
108        if ( ! empty( $transient_value ) ) {
109            return $this->return_error_handler_result( true, $func_args );
110        }
111
112        // func_get_args shows extra parameters: `["queue_conn":false,"oauth2_refresh":false]`.
113        $context          = array();
114        $context['error'] = array_combine( array( 'errno', 'errstr', 'errfile', 'errline' ), array_slice( $func_args, 0, 4 ) );
115
116        $backtrace_cache_hash = sanitize_key( implode( ',', array_slice( $func_args, 0, 4 ) ) );
117
118        // This would be added anyway in some cases, but for PHP errors, let's always have a little backtrace.
119        $backtrace_frames           = $this->api->get_backtrace( $backtrace_cache_hash, 3 );
120        $context['debug_backtrace'] = $backtrace_frames;
121
122        $log_level = $this->errno_to_psr3( $errno );
123
124        $this->logger->$log_level( $errstr, $context );
125
126        set_transient( $transient_key, wp_json_encode( $func_args ), DAY_IN_SECONDS );
127
128        /* Don't execute PHP internal error handler */
129        return $this->return_error_handler_result( true, $func_args );
130    }
131
132    /**
133     * Call the chain of other registered error handlers before returning the result.
134     *
135     * @param bool              $handled Flag to indicate has the error already been handled.
136     * @param array<int|string> $args    The arguments passed by PHP to our own registered error handler.
137     *
138     * @return bool True if the error has been handled, false if PHP error handler should still run.
139     */
140    protected function return_error_handler_result( bool $handled, array $args ): bool {
141
142        if ( is_null( $this->previous_error_handler ) ) {
143            return $handled;
144        }
145
146        // If null is returned from the previous handler, treat that as if the error has not been handled by them.
147        $handled_in_chain = call_user_func_array( $this->previous_error_handler, $args ) ?? false;
148        return $handled_in_chain || $handled;
149    }
150
151    /**
152     * Logic to check is the error relevant to this plugin.
153     *
154     * Check:
155     * * is the source file path in the plugin directory
156     * * is the plugin string mentioned in the error message
157     * * is any file in the backtrace part of this plugin
158     *
159     * @param int    $errno The error code (the level of the error raised, as an integer).
160     * @param string $errstr A string describing the error.
161     * @param string $errfile The filename in which the error occurred.
162     * @param int    $errline The line number in which the error occurred.
163     *
164     * @return bool
165     */
166    protected function is_related_error( int $errno, string $errstr, string $errfile, int $errline ): bool {
167
168        // If the source file has the plugin dir in it.
169        // Prepend the WP_PLUGINS_DIR so a subdir with the same name (e.g. my-plugin/integrations/your-plugin) does not match.
170        $plugin_dir          = WP_PLUGIN_DIR . DIRECTORY_SEPARATOR . $this->settings->get_plugin_slug();
171        $plugin_dir_realpath = realpath( $plugin_dir );
172
173        if ( false !== strpos( $errfile, $plugin_dir ) || ( false !== $plugin_dir_realpath && false !== strpos( $errfile, $plugin_dir_realpath ) ) ) {
174            return true;
175        }
176
177        if ( false !== strpos( $errstr, $this->settings->get_plugin_slug() ) ) {
178            // If the plugin slug is outright named in the error message.
179            return true;
180        }
181
182        // e.g. WooCommerce Admin could be the $errfile of a problem caused by another plugin, so we need to... trace back.
183        return $this->api->is_backtrace_contains_plugin( implode( '', func_get_args() ) );
184    }
185
186    /**
187     * Maps PHP's error types to PSR-3's error levels.
188     *
189     * Some of these will never occur at runtime.
190     *
191     * @see trigger_error()
192     * @see https://www.php.net/manual/en/errorfunc.constants.php
193     *
194     * @param int $errno The PHP error type.
195     *
196     * @return string
197     */
198    protected function errno_to_psr3( int $errno ): string {
199
200        $error_types = array(
201            E_ERROR             => LogLevel::ERROR,
202            E_CORE_ERROR        => LogLevel::ERROR,
203            E_COMPILE_ERROR     => LogLevel::ERROR,
204            E_USER_ERROR        => LogLevel::ERROR, // User-generated error message  – trigger_error().
205            E_RECOVERABLE_ERROR => LogLevel::ERROR,
206            E_WARNING           => LogLevel::WARNING,
207            E_CORE_WARNING      => LogLevel::WARNING, // Warnings (non-fatal errors) that occur during PHP's initial startup.
208            E_COMPILE_WARNING   => LogLevel::WARNING, // Compile-time warnings.
209            E_USER_WARNING      => LogLevel::WARNING, // User-generated warning message – trigger_error().
210            E_NOTICE            => LogLevel::NOTICE,
211            E_USER_NOTICE       => LogLevel::NOTICE,
212            E_DEPRECATED        => LogLevel::NOTICE,
213            E_USER_DEPRECATED   => LogLevel::DEBUG, // User-generated warning message – trigger_error().
214            E_PARSE             => LogLevel::ERROR, // Compile-time parse errors.
215        );
216
217        if ( array_key_exists( $errno, $error_types ) ) {
218            return $error_types[ $errno ];
219        } else {
220            return LogLevel::ERROR;
221        }
222    }
223}