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