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