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        $func_args = func_get_args();
86
87        $plugin_related_error = $this->is_related_error( $errno, $errstr, $errfile, $errline );
88
89        if ( ! $plugin_related_error ) {
90            // If there is another handler, return its result, otherwise indicate the error was not handled.
91            return $this->return_error_handler_result( false, $func_args );
92        }
93
94        // e.g. my-plugin-slug-logged-error-4f6ead5467acd...
95        $transient_key = "{$this->settings->get_plugin_slug()}-logged-{$this->errno_to_psr3( $errno )}-" . md5( $errstr );
96
97        $transient_value = get_transient( $transient_key );
98
99        // We've already logged this error recently, don't bother logging it again.
100        if ( ! empty( $transient_value ) ) {
101            return $this->return_error_handler_result( true, $func_args );
102        }
103
104        // func_get_args shows extra parameters: `["queue_conn":false,"oauth2_refresh":false]`.
105        $context          = array();
106        $context['error'] = array_combine( array( 'errno', 'errstr', 'errfile', 'errline' ), array_slice( $func_args, 0, 4 ) );
107
108        $backtrace_cache_hash = sanitize_key( implode( ',', array_slice( $func_args, 0, 4 ) ) );
109
110        // This would be added anyway in some cases, but for PHP errors, let's always have a little backtrace.
111        $backtrace_frames           = $this->api->get_backtrace( $backtrace_cache_hash, 3 );
112        $context['debug_backtrace'] = $backtrace_frames;
113
114        $log_level = $this->errno_to_psr3( $errno );
115
116        $this->logger->$log_level( $errstr, $context );
117
118        set_transient( $transient_key, wp_json_encode( $func_args ), DAY_IN_SECONDS );
119
120        /* Don't execute PHP internal error handler */
121        return $this->return_error_handler_result( true, $func_args );
122    }
123
124    /**
125     * Call the chain of other registered error handlers before returning the result.
126     *
127     * @param bool                          $handled Flag to indicate has the error already been handled.
128     * @param array<int|string>|list<mixed> $args    The arguments passed by PHP to our own registered error handler.
129     *
130     * @return bool True if the error has been handled, false if PHP error handler should still run.
131     */
132    protected function return_error_handler_result( bool $handled, array $args ): bool {
133
134        if ( is_null( $this->previous_error_handler ) ) {
135            return $handled;
136        }
137
138        // If null is returned from the previous handler, treat that as if the error has not been handled by them.
139        $handled_in_chain = call_user_func_array( $this->previous_error_handler, $args ) ?? false;
140        return $handled_in_chain || $handled;
141    }
142
143    /**
144     * Logic to check is the error relevant to this plugin.
145     *
146     * Check:
147     * * is the source file path in the plugin directory
148     * * is the plugin string mentioned in the error message
149     * * is any file in the backtrace part of this plugin
150     *
151     * @param int    $errno The error code (the level of the error raised, as an integer).
152     * @param string $errstr A string describing the error.
153     * @param string $errfile The filename in which the error occurred.
154     * @param int    $errline The line number in which the error occurred.
155     *
156     * @return bool
157     */
158    protected function is_related_error( int $errno, string $errstr, string $errfile, int $errline ): bool {
159
160        // If the source file has the plugin dir in it.
161        // Prepend the WP_PLUGINS_DIR so a subdir with the same name (e.g. my-plugin/integrations/your-plugin) does not match.
162        $plugin_dir          = WP_PLUGIN_DIR . DIRECTORY_SEPARATOR . $this->settings->get_plugin_slug();
163        $plugin_dir_realpath = realpath( $plugin_dir );
164
165        if ( str_contains( $errfile, $plugin_dir ) || ( false !== $plugin_dir_realpath && str_contains( $errfile, $plugin_dir_realpath ) ) ) {
166            return true;
167        }
168
169        if ( str_contains( $errstr, $this->settings->get_plugin_slug() ) ) {
170            // If the plugin slug is outright named in the error message.
171            return true;
172        }
173
174        // e.g. WooCommerce Admin could be the $errfile of a problem caused by another plugin, so we need to... trace back.
175        return $this->api->is_backtrace_contains_plugin( implode( '', func_get_args() ) );
176    }
177
178    /**
179     * Maps PHP's error types to PSR-3's error levels.
180     *
181     * Some of these will never occur at runtime.
182     *
183     * @see trigger_error()
184     * @see https://www.php.net/manual/en/errorfunc.constants.php
185     *
186     * @param int $errno The PHP error type.
187     *
188     * @return string
189     */
190    protected function errno_to_psr3( int $errno ): string {
191
192        $error_types = array(
193            E_ERROR             => LogLevel::ERROR,
194            E_CORE_ERROR        => LogLevel::ERROR,
195            E_COMPILE_ERROR     => LogLevel::ERROR,
196            E_USER_ERROR        => LogLevel::ERROR, // User-generated error message  – trigger_error().
197            E_RECOVERABLE_ERROR => LogLevel::ERROR,
198            E_WARNING           => LogLevel::WARNING,
199            E_CORE_WARNING      => LogLevel::WARNING, // Warnings (non-fatal errors) that occur during PHP's initial startup.
200            E_COMPILE_WARNING   => LogLevel::WARNING, // Compile-time warnings.
201            E_USER_WARNING      => LogLevel::WARNING, // User-generated warning message – trigger_error().
202            E_NOTICE            => LogLevel::NOTICE,
203            E_USER_NOTICE       => LogLevel::NOTICE,
204            E_DEPRECATED        => LogLevel::NOTICE,
205            E_USER_DEPRECATED   => LogLevel::DEBUG, // User-generated warning message – trigger_error().
206            E_PARSE             => LogLevel::ERROR, // Compile-time parse errors.
207        );
208
209        if ( array_key_exists( $errno, $error_types ) ) {
210            return $error_types[ $errno ];
211        } else {
212            return LogLevel::ERROR;
213        }
214    }
215}