PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / telemetry / wordpress-error-handler.php

wordpress-error-handler.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.1, at telemetry/wordpress-error-handler.php

528 lines 19.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WordPress Error Handler for Sentry Integration
4 *
5 * Automatically captures WordPress errors and sends them to Sentry
6 * Includes WP URL and Plugin Version as required
7 */
8
9 // If this file is called directly, abort.
10 if (!defined('WPINC')) {
11 die;
12 }
13
14 require_once __DIR__ . '/privacy.php';
15
16 /**
17 * WordPress Error Handler Class
18 */
19 class MetaSync_WordPress_Error_Handler {
20
21 /**
22 * Plugin directory for filtering errors
23 */
24 private $plugin_dir;
25
26 /**
27 * Plugin slug for filtering
28 */
29 private $plugin_slug = 'metasync';
30
31 /**
32 * Previous error handler to restore
33 */
34 private $previous_error_handler;
35
36 /**
37 * Previous exception handler to restore
38 */
39 private $previous_exception_handler;
40
41 /**
42 * Track sent errors to prevent duplicates (in-memory cache)
43 */
44 private $sent_errors = array();
45
46 /**
47 * Maximum number of errors to track in memory
48 */
49 private $max_tracked_errors = 100;
50
51 /**
52 * Transient key for persistent error tracking
53 */
54 private $transient_key = 'metasync_sent_errors';
55
56 /**
57 * How long to remember sent errors (in seconds)
58 * 15 minutes — same error won't be re-sent within this window
59 */
60 private $error_memory_duration = 900;
61
62 /**
63 * Constructor
64 */
65 public function __construct() {
66 $this->plugin_dir = dirname(__DIR__); // Parent directory of telemetry folder
67 $this->load_sent_errors_from_cache();
68 $this->setup_error_handlers();
69 }
70
71 /**
72 * Load previously sent errors from persistent cache
73 */
74 private function load_sent_errors_from_cache() {
75 $cached_errors = get_transient($this->transient_key);
76 if (is_array($cached_errors)) {
77 $this->sent_errors = $cached_errors;
78 }
79 }
80
81 /**
82 * Save sent errors to persistent cache
83 */
84 private function save_sent_errors_to_cache() {
85 set_transient($this->transient_key, $this->sent_errors, $this->error_memory_duration);
86 }
87
88 /**
89 * Setup WordPress error handlers - Only for plugin-specific errors
90 */
91 private function setup_error_handlers() {
92 // Store previous handlers to chain them properly
93 $this->previous_error_handler = set_error_handler(array($this, 'capture_php_error'), E_ALL); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- plugin logger sink / last-resort fallback
94 $this->previous_exception_handler = set_exception_handler(array($this, 'capture_exception'));
95
96 // Hook into WordPress fatal error handler (only for plugin errors)
97 add_filter('wp_fatal_error_handler', array($this, 'capture_fatal_error'));
98
99 // Hook into plugin activation/deactivation errors (only for our plugin)
100 add_action('activated_plugin', array($this, 'capture_plugin_activation'), 10, 2);
101 add_action('deactivated_plugin', array($this, 'capture_plugin_deactivation'), 10, 2);
102
103 // Hook into WordPress shutdown to catch fatal errors (only plugin-related)
104 register_shutdown_function(array($this, 'capture_shutdown_error'));
105
106 // Remove wp_die handler as it captures too many system errors
107 // add_action('wp_die_handler', array($this, 'capture_wp_die'), 10, 1);
108 }
109
110 /**
111 * Capture WordPress die events - REMOVED
112 * This method was too broad and captured system-wide wp_die events
113 * We now only capture plugin-specific errors through other handlers
114 */
115 // public function capture_wp_die($message) - REMOVED TO PREVENT SYSTEM-WIDE ERROR CAPTURE
116
117 /**
118 * Capture PHP errors - Only fatal-level plugin-specific errors
119 */
120 public function capture_php_error($severity, $message, $file, $line) {
121 // First, call the previous error handler if it exists
122 $handled = false;
123 if ($this->previous_error_handler && is_callable($this->previous_error_handler)) {
124 $handled = call_user_func($this->previous_error_handler, $severity, $message, $file, $line);
125 }
126
127 // Respect error_reporting() and the @ suppression operator — never capture masked errors
128 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting, PluginCheck.CodeAnalysis.PHPErrorReporting.DirectErrorReportingCall -- reads the current mask, does not print paths; reading the mask is the only way a custom handler can respect the @ operator
129 if (!(error_reporting() & $severity)) {
130 return $handled;
131 }
132
133 // Only capture fatal-level errors — skip warnings, notices, deprecated, strict
134 $fatal_severities = [E_USER_ERROR, E_RECOVERABLE_ERROR];
135 if (!in_array($severity, $fatal_severities, true)) {
136 return $handled;
137 }
138
139 // Only capture errors from our plugin or directly related to our plugin
140 if ($this->should_capture_error($file)) {
141 $this->send_to_sentry('php_error', $message, array(
142 'error_type' => 'php_error',
143 'severity' => $severity,
144 'file' => $file,
145 'line' => $line,
146 'error_level' => $this->get_error_level($severity),
147 'backtrace' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10) // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- genuine failure path, bounded, no secrets
148 ));
149 }
150
151 // Return the result from the previous handler, or false to continue normal error handling
152 return $handled;
153 }
154
155 /**
156 * Capture uncaught exceptions - Only plugin-specific exceptions
157 */
158 public function capture_exception($exception) {
159 // First, call the previous exception handler if it exists
160 $chained = false;
161 if ($this->previous_exception_handler && is_callable($this->previous_exception_handler)) {
162 call_user_func($this->previous_exception_handler, $exception);
163 $chained = true;
164 }
165
166 // Only capture exceptions from our plugin or directly related to our plugin
167 if ($this->should_capture_error($exception->getFile())) {
168 $this->send_to_sentry('exception', $exception->getMessage(), array(
169 'error_type' => 'uncaught_exception',
170 'exception_class' => get_class($exception),
171 'file' => $exception->getFile(),
172 'line' => $exception->getLine(),
173 'trace' => $exception->getTraceAsString(),
174 'exception_object' => $exception
175 ));
176 return;
177 }
178
179 // Not our exception and nothing else handled it: restore PHP's default
180 // uncaught-exception handling instead of swallowing it silently.
181 if (!$chained) {
182 throw $exception;
183 }
184 }
185
186 /**
187 * Capture fatal errors
188 */
189 public function capture_fatal_error($error) {
190 if (is_array($error) && $this->should_capture_error($error['file'] ?? '')) {
191 $this->send_to_sentry('fatal_error', $error['message'] ?? 'Fatal error', array(
192 'error_type' => 'fatal_error',
193 'error_details' => $error,
194 'file' => $error['file'] ?? 'unknown',
195 'line' => $error['line'] ?? 0
196 ));
197 }
198 return $error;
199 }
200
201 /**
202 * Capture shutdown errors
203 */
204 public function capture_shutdown_error() {
205 $error = error_get_last();
206 if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
207 if ($this->should_capture_error($error['file'])) {
208 $this->send_to_sentry('shutdown_error', $error['message'], array(
209 'error_type' => 'shutdown_error',
210 'error_details' => $error,
211 'file' => $error['file'],
212 'line' => $error['line']
213 ));
214 }
215 }
216 }
217
218 /**
219 * Capture plugin activation events
220 */
221 public function capture_plugin_activation($plugin, $network_wide) {
222 // Only track our plugin
223 if (strpos($plugin, 'metasync') !== false) {
224 $this->send_to_sentry('plugin_activation', 'Plugin activated successfully', array(
225 'event_type' => 'plugin_lifecycle',
226 'action' => 'activation',
227 'plugin' => $plugin,
228 'network_wide' => $network_wide,
229 'wp_version' => get_bloginfo('version'),
230 'php_version' => PHP_VERSION
231 ));
232 }
233 }
234
235 /**
236 * Capture plugin deactivation events
237 */
238 public function capture_plugin_deactivation($plugin) {
239 // Only track our plugin
240 if (strpos($plugin, 'metasync') !== false) {
241 $this->send_to_sentry('plugin_deactivation', 'Plugin deactivated', array(
242 'event_type' => 'plugin_lifecycle',
243 'action' => 'deactivation',
244 'plugin' => $plugin,
245 'reason' => 'user_action'
246 ));
247 }
248 }
249
250 /**
251 * Determine if we should capture this error - Much more restrictive filtering
252 */
253 private function should_capture_error($file = '') {
254 # Don't capture if no file specified - this prevents capturing system-wide errors
255 if (empty($file)) {
256 return false;
257 }
258
259 # Primary check: error must be directly from our plugin directory
260 if (strpos($file, $this->plugin_dir) !== false) {
261 return true;
262 }
263
264 # Secondary check: error must be in a file that contains our plugin slug
265 if (strpos($file, $this->plugin_slug) !== false) {
266 return true;
267 }
268
269 # REMOVED: Tertiary backtrace check that was too broad
270 # The previous backtrace logic was capturing errors from other plugins
271 # that happened to be called during MetaSync execution. We now only
272 # capture errors that directly originate from MetaSync files.
273
274 # Additional check: Only capture if the error file path contains 'metasync'
275 # This is a more conservative approach to avoid false positives
276 $file_lower = strtolower($file);
277 if (strpos($file_lower, 'metasync') !== false) {
278 return true;
279 }
280
281 return false;
282 }
283
284 /**
285 * Check if the current environment is localhost/development
286 */
287 private function is_localhost() {
288 $host = wp_parse_url(home_url(), PHP_URL_HOST);
289
290 // Check for common localhost patterns
291 $localhost_patterns = [
292 'localhost',
293 '127.0.0.1',
294 '::1',
295 '0.0.0.0',
296 '.local',
297 '.test',
298 '.dev',
299 '.localhost'
300 ];
301
302 foreach ($localhost_patterns as $pattern) {
303 if ($host !== false && $host !== null && strpos($host, $pattern) !== false) {
304 return true;
305 }
306 }
307
308 // Check if host is an IP address in private ranges
309 if (filter_var($host, FILTER_VALIDATE_IP)) {
310 $ip = ip2long($host);
311 if ($ip !== false) {
312 // Private IP ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
313 if (($ip >= ip2long('10.0.0.0') && $ip <= ip2long('10.255.255.255')) ||
314 ($ip >= ip2long('172.16.0.0') && $ip <= ip2long('172.31.255.255')) ||
315 ($ip >= ip2long('192.168.0.0') && $ip <= ip2long('192.168.255.255'))) {
316 return true;
317 }
318 }
319 }
320
321 return false;
322 }
323
324 /**
325 * Send error to Sentry with required metadata
326 */
327 private function send_to_sentry($error_type, $message, $context = array()) {
328 // Skip sending to Sentry if running on localhost/development environment
329 if ($this->is_localhost()) {
330 return;
331 }
332
333 // Generate error fingerprint for deduplication
334 $error_fingerprint = $this->generate_error_fingerprint($error_type, $message, $context);
335
336 // Check if this error has already been sent
337 if ($this->is_error_already_sent($error_fingerprint)) {
338 return; // Skip sending duplicate error
339 }
340
341 // Add required metadata
342 $context = array_merge($context, array(
343 'wp_url' => home_url(),
344 'plugin_version' => defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0',
345 'plugin_name' => 'Search Engine Labs SEO (MetaSync)',
346 'error_timestamp' => gmdate('Y-m-d H:i:s'),
347 'site_info' => array(
348 'site_title' => get_bloginfo('name'),
349 'wp_version' => get_bloginfo('version'),
350 'admin_email' => get_bloginfo('admin_email'),
351 'active_theme' => get_template(),
352 'php_version' => PHP_VERSION,
353 'memory_limit' => ini_get('memory_limit'),
354 'multisite' => is_multisite()
355 ),
356 'request_info' => array(
357 'url' => metasync_telemetry_request_path(),
358 'method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
359 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
360 'referer' => isset($_SERVER['HTTP_REFERER'])
361 ? metasync_telemetry_request_path($_SERVER['HTTP_REFERER'])
362 : 'none'
363 )
364 ));
365
366 // Determine severity level
367 $level = $this->get_sentry_level($error_type);
368
369 // Only send fatal/error-level events — drop info, warning, debug
370 if (!in_array($level, ['error', 'fatal'], true)) {
371 return;
372 }
373
374 // Send directly to Sentry (wordpress-error-handler is the primary/only error capture point)
375 $sentry_success = false;
376
377 if (function_exists('metasync_sentry_capture_exception') && isset($context['exception_object'])) {
378 $sentry_success = metasync_sentry_capture_exception($context['exception_object'], $context);
379 } elseif (function_exists('metasync_sentry_capture_message')) {
380 $sentry_success = metasync_sentry_capture_message($message, $level, $context);
381 }
382
383 // Only mark as sent if Sentry call was successful
384 if ($sentry_success) {
385 $this->mark_error_as_sent($error_fingerprint);
386 }
387 }
388
389 /**
390 * Get error level name
391 */
392 private function get_error_level($severity) {
393 $levels = array(
394 E_ERROR => 'E_ERROR',
395 E_WARNING => 'E_WARNING',
396 E_PARSE => 'E_PARSE',
397 E_NOTICE => 'E_NOTICE',
398 E_CORE_ERROR => 'E_CORE_ERROR',
399 E_CORE_WARNING => 'E_CORE_WARNING',
400 E_COMPILE_ERROR => 'E_COMPILE_ERROR',
401 E_COMPILE_WARNING => 'E_COMPILE_WARNING',
402 E_USER_ERROR => 'E_USER_ERROR',
403 E_USER_WARNING => 'E_USER_WARNING',
404 E_USER_NOTICE => 'E_USER_NOTICE',
405 E_STRICT => 'E_STRICT',
406 E_RECOVERABLE_ERROR => 'E_RECOVERABLE_ERROR',
407 E_DEPRECATED => 'E_DEPRECATED',
408 E_USER_DEPRECATED => 'E_USER_DEPRECATED'
409 );
410
411 return $levels[$severity] ?? 'UNKNOWN';
412 }
413
414 /**
415 * Get Sentry level from error type
416 */
417 private function get_sentry_level($error_type) {
418 $levels = array(
419 'php_error' => 'error',
420 'exception' => 'error',
421 'uncaught_exception' => 'error',
422 'fatal_error' => 'fatal',
423 'shutdown_error' => 'fatal',
424 'wp_die' => 'error',
425 'plugin_activation' => 'info',
426 'plugin_deactivation' => 'info'
427 );
428
429 return $levels[$error_type] ?? 'error';
430 }
431
432 /**
433 * Generate a unique fingerprint for an error to detect duplicates
434 *
435 * @param string $error_type Type of error
436 * @param string $message Error message
437 * @param array $context Error context
438 * @return string Unique fingerprint
439 */
440 private function generate_error_fingerprint($error_type, $message, $context = array()) {
441 // Create a fingerprint based on key error characteristics
442 $fingerprint_data = array(
443 'error_type' => $error_type,
444 'message' => $message,
445 'file' => $context['file'] ?? '',
446 'line' => $context['line'] ?? 0,
447 'exception_class' => $context['exception_class'] ?? '',
448 'severity' => $context['severity'] ?? 0
449 );
450
451 // Create a hash of the fingerprint data
452 return md5(serialize($fingerprint_data));
453 }
454
455 /**
456 * Check if an error has already been sent
457 *
458 * @param string $error_fingerprint Error fingerprint
459 * @return bool True if already sent
460 */
461 private function is_error_already_sent($error_fingerprint) {
462 // Check if error exists and hasn't expired
463 if (isset($this->sent_errors[$error_fingerprint])) {
464 $sent_time = $this->sent_errors[$error_fingerprint];
465 $time_elapsed = time() - $sent_time;
466
467 // If error was sent recently (within memory duration), skip it
468 if ($time_elapsed < $this->error_memory_duration) {
469 return true;
470 } else {
471 // Error has expired, remove it from cache
472 unset($this->sent_errors[$error_fingerprint]);
473 $this->save_sent_errors_to_cache();
474 }
475 }
476
477 return false;
478 }
479
480 /**
481 * Mark an error as sent to prevent duplicates
482 *
483 * @param string $error_fingerprint Error fingerprint
484 */
485 private function mark_error_as_sent($error_fingerprint) {
486 // Add to sent errors array
487 $this->sent_errors[$error_fingerprint] = time();
488
489 // Clean up old entries to prevent memory bloat
490 $this->cleanup_old_errors();
491
492 // Persist to cache
493 $this->save_sent_errors_to_cache();
494 }
495
496 /**
497 * Clean up old error entries to prevent memory bloat
498 */
499 private function cleanup_old_errors() {
500 $current_time = time();
501
502 // Remove expired errors
503 foreach ($this->sent_errors as $fingerprint => $timestamp) {
504 if ($current_time - $timestamp > $this->error_memory_duration) {
505 unset($this->sent_errors[$fingerprint]);
506 }
507 }
508
509 // If we still have too many errors tracked, remove the oldest ones
510 if (count($this->sent_errors) > $this->max_tracked_errors) {
511 // Sort by timestamp (oldest first)
512 asort($this->sent_errors);
513
514 // Remove oldest entries, keeping only the most recent ones
515 $errors_to_remove = count($this->sent_errors) - $this->max_tracked_errors;
516 $this->sent_errors = array_slice($this->sent_errors, $errors_to_remove, null, true);
517 }
518 }
519 }
520
521 // Initialize the error handler immediately when this file is loaded
522 // This ensures we catch errors that happen during plugin initialization
523 // Must be initialized early to catch parse errors and fatal errors
524 if (!metasync_telemetry_is_disabled() && class_exists('MetaSync_WordPress_Error_Handler')) {
525 new MetaSync_WordPress_Error_Handler();
526 }
527 ?>
528