PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / trunk
Search Atlas SEO – OTTO AI SEO Automation for WordPress vtrunk
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 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / includes / class-metasync-error-logger.php

class-metasync-error-logger.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress trunk, at includes/class-metasync-error-logger.php

449 lines 15.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Enhanced Error Logger with Categories and Hooks
4 *
5 * Implements structured error categorization with business logic categories
6 * for better integration with external monitoring tools.
7 *
8 * @package Metasync
9 * @subpackage Metasync/includes
10 * @since 1.0.0
11 * @author Engineering Team <support@searchatlas.com>
12 */
13
14 // If this file is called directly, abort.
15 if (!defined('WPINC')) {
16 die;
17 }
18
19 /**
20 * Enhanced Error Logger Class
21 *
22 * Provides structured error logging with:
23 * - 8 business logic error categories
24 * - Standardized error codes (MS-XXXX format)
25 * - Structured log format with JSON context
26 * - WordPress action hooks for external monitoring
27 * - Error summary tracking in wp_options
28 */
29 class Metasync_Error_Logger {
30
31 /**
32 * Error category constants
33 */
34 const CATEGORY_API_RATE_LIMIT = 'API_RATE_LIMIT';
35 const CATEGORY_API_BACKOFF = 'API_BACKOFF';
36 const CATEGORY_EXECUTION_TIMEOUT = 'EXECUTION_TIMEOUT';
37 const CATEGORY_MEMORY_EXHAUSTED = 'MEMORY_EXHAUSTED';
38 const CATEGORY_QUEUE_OVERFLOW = 'QUEUE_OVERFLOW';
39 const CATEGORY_AUTHENTICATION_FAILURE = 'AUTHENTICATION_FAILURE';
40 const CATEGORY_DATABASE_ERROR = 'DATABASE_ERROR';
41 const CATEGORY_NETWORK_ERROR = 'NETWORK_ERROR';
42 const CATEGORY_OTTO_RENDER = 'OTTO_RENDER';
43
44 /**
45 * Error codes mapping
46 * Format: MS-XXXX
47 */
48 private static $error_codes = [
49 self::CATEGORY_API_RATE_LIMIT => 'MS-1001',
50 self::CATEGORY_API_BACKOFF => 'MS-1002',
51 self::CATEGORY_EXECUTION_TIMEOUT => 'MS-2001',
52 self::CATEGORY_MEMORY_EXHAUSTED => 'MS-2002',
53 self::CATEGORY_QUEUE_OVERFLOW => 'MS-3001',
54 self::CATEGORY_AUTHENTICATION_FAILURE => 'MS-4001',
55 self::CATEGORY_DATABASE_ERROR => 'MS-5001',
56 self::CATEGORY_NETWORK_ERROR => 'MS-6001',
57 self::CATEGORY_OTTO_RENDER => 'MS-7001',
58 ];
59
60 /**
61 * Categories that only exist while an operator is troubleshooting.
62 *
63 * These record the plugin's OWN client-side throttling, not a site fault:
64 * the OTTO call budget was reached, or a retry was deferred. Both are the
65 * plugin working as designed, but they read as failures in the Error Logs
66 * panel and are a known source of false-alarm bug reports. They are only
67 * recorded while Debug Mode is on, and only shown while Debug Mode is on.
68 */
69 const DEBUG_ONLY_CATEGORIES = [
70 self::CATEGORY_API_RATE_LIMIT,
71 self::CATEGORY_API_BACKOFF,
72 ];
73
74 /**
75 * Non-alarming labels for categories whose raw name overstates severity.
76 *
77 * Presentation only — the stored category is never rewritten, so historical
78 * rows render softly too and no data migration is needed.
79 */
80 private static $display_labels = [
81 self::CATEGORY_API_RATE_LIMIT => 'THROTTLED',
82 self::CATEGORY_API_BACKOFF => 'RETRY_SCHEDULED',
83 ];
84
85 /**
86 * Severity level constants
87 */
88 const SEVERITY_INFO = 'INFO';
89 const SEVERITY_WARNING = 'WARNING';
90 const SEVERITY_ERROR = 'ERROR';
91 const SEVERITY_CRITICAL = 'CRITICAL';
92
93 /**
94 * Option name for error summary storage
95 */
96 const ERROR_SUMMARY_OPTION = 'metasync_error_summary';
97
98 /**
99 * Maximum number of unique errors to keep in summary
100 */
101 const MAX_SUMMARY_ENTRIES = 100;
102
103 /**
104 * Log file name
105 */
106 const LOG_FILE_NAME = 'metasync-errors.log';
107
108 /**
109 * Main logging function
110 *
111 * Formats and writes structured error log with:
112 * - Timestamp
113 * - Category
114 * - Severity
115 * - Message
116 * - JSON context
117 *
118 * Also:
119 * - Fires WordPress action hook
120 * - Updates error summary in wp_options
121 *
122 * @param string $category One of the CATEGORY_* constants
123 * @param string $severity One of the SEVERITY_* constants
124 * @param string $message Human-readable error message
125 * @param array $context Additional context data (optional)
126 * @return bool True on success, false on failure
127 */
128 public static function log($category, $severity, $message, $context = []) {
129 // Validate inputs
130 if (empty($category) || empty($severity) || empty($message)) {
131 return false;
132 }
133
134 // Throttling categories are diagnostics, not faults: skip them entirely
135 // unless an operator has Debug Mode on. Nothing is written, no summary
136 // entry is created, and the metasync_error_logged action does not fire.
137 if (self::is_suppressed_category($category)) {
138 return false;
139 }
140
141 // Get error code for this category
142 $error_code = self::$error_codes[$category] ?? 'MS-0000';
143
144 // Prepare full context with error code
145 $full_context = array_merge([
146 'error_code' => $error_code
147 ], $context);
148
149 // Format timestamp
150 $timestamp = date('Y-m-d H:i:s');
151
152 // Format log line: [YYYY-MM-DD HH:MM:SS] [CATEGORY] [SEVERITY] Message {context_json}
153 $log_line = sprintf(
154 "[%s] [%s] [%s] %s %s\n",
155 $timestamp,
156 $category,
157 $severity,
158 $message,
159 json_encode($full_context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
160 );
161
162 // Write to dedicated log file
163 $log_written = self::write_to_log_file($log_line);
164
165 // Fire WordPress action hook for external monitoring
166 do_action('metasync_error_logged', $category, $error_code, $message, $full_context);
167
168 // Update error summary in database
169 self::update_error_summary($category, $error_code, $message, $full_context);
170
171 return $log_written;
172 }
173
174 /**
175 * Write log line to dedicated error log file
176 *
177 * @param string $log_line Formatted log line
178 * @return bool True on success, false on failure
179 */
180 private static function write_to_log_file($log_line) {
181 // Get log directory (same as Log_Manager uses)
182 $log_directory = WP_CONTENT_DIR . '/metasync_data';
183
184 // Create directory if it doesn't exist
185 if (!is_dir($log_directory)) {
186 if (!@mkdir($log_directory, 0755, true)) {
187 error_log('Metasync_Error_Logger: Failed to create log directory: ' . $log_directory);
188 return false;
189 }
190 }
191
192 // Protect log directory from direct web access
193 $htaccess_file = $log_directory . '/.htaccess';
194 if (!file_exists($htaccess_file)) {
195 @file_put_contents($htaccess_file, "Order deny,allow\nDeny from all\n");
196 }
197 $index_file = $log_directory . '/index.php';
198 if (!file_exists($index_file)) {
199 @file_put_contents($index_file, "<?php\n// Silence is golden\n");
200 }
201
202 // Verify directory is writable
203 if (!is_writable($log_directory)) {
204 @chmod($log_directory, 0755);
205 if (!is_writable($log_directory)) {
206 error_log('Metasync_Error_Logger: Directory not writable: ' . $log_directory);
207 return false;
208 }
209 }
210
211 // Get log file path
212 $log_file = wp_normalize_path($log_directory . '/' . self::LOG_FILE_NAME);
213
214 // Append to log file with file locking
215 $result = @file_put_contents($log_file, $log_line, FILE_APPEND | LOCK_EX);
216
217 if ($result === false) {
218 error_log('Metasync_Error_Logger: Failed to write to log file: ' . $log_file);
219 return false;
220 }
221
222 // Set file permissions if file was just created
223 if (!file_exists($log_file) || filesize($log_file) === strlen($log_line)) {
224 @chmod($log_file, 0644);
225 }
226
227 return true;
228 }
229
230 /**
231 * Update error summary in wp_options
232 *
233 * Tracks last 100 unique errors with counts.
234 * Unique key is based on category + first 50 chars of message.
235 *
236 * @param string $category Error category
237 * @param string $code Error code
238 * @param string $message Error message
239 * @param array $context Error context
240 */
241 private static function update_error_summary($category, $code, $message, $context) {
242 // Get existing summary
243 $summary = get_option(self::ERROR_SUMMARY_OPTION, []);
244
245 if (!is_array($summary)) {
246 $summary = [];
247 }
248
249 // Create unique key from category + first 50 chars of message
250 $message_preview = substr($message, 0, 50);
251 $key = $category . '|' . $message_preview;
252
253 // Initialize entry if it doesn't exist
254 if (!isset($summary[$key])) {
255 $summary[$key] = [
256 'category' => $category,
257 'code' => $code,
258 'message' => $message,
259 'count' => 0,
260 'first_seen' => current_time('mysql'),
261 'last_seen' => current_time('mysql'),
262 'severity' => isset($context['severity']) ? $context['severity'] : 'UNKNOWN'
263 ];
264 }
265
266 // Update count and last seen time
267 $summary[$key]['count']++;
268 $summary[$key]['last_seen'] = current_time('mysql');
269
270 // Keep only last 100 unique errors (prune oldest)
271 if (count($summary) > self::MAX_SUMMARY_ENTRIES) {
272 // Sort by last_seen (newest first)
273 uasort($summary, function($a, $b) {
274 return strtotime($b['last_seen']) - strtotime($a['last_seen']);
275 });
276
277 // Keep only the most recent entries
278 $summary = array_slice($summary, 0, self::MAX_SUMMARY_ENTRIES, true);
279 }
280
281 // Save to database
282 update_option(self::ERROR_SUMMARY_OPTION, $summary);
283 }
284
285 /**
286 * Get error summary from wp_options
287 *
288 * @return array Error summary array
289 */
290 public static function get_error_summary() {
291 $summary = get_option(self::ERROR_SUMMARY_OPTION, []);
292 return is_array($summary) ? $summary : [];
293 }
294
295 /**
296 * Clear error summary
297 *
298 * @return bool True on success
299 */
300 public static function clear_error_summary() {
301 return delete_option(self::ERROR_SUMMARY_OPTION);
302 }
303
304 /**
305 * Whether a category is withheld on this request.
306 *
307 * True for the throttling categories while Debug Mode is off. Used both to
308 * skip recording and to filter what the Error Logs panel shows, so rows
309 * recorded before this behaviour existed are hidden too.
310 *
311 * @param string $category Category name
312 * @return bool True when the category should be withheld
313 */
314 public static function is_suppressed_category($category) {
315 if (!in_array($category, self::DEBUG_ONLY_CATEGORIES, true)) {
316 return false;
317 }
318
319 return !(class_exists('Metasync_Debug_Mode_Manager')
320 && Metasync_Debug_Mode_Manager::is_enabled());
321 }
322
323 /**
324 * Non-alarming label to display for a category.
325 *
326 * @param string $category Stored category name
327 * @return string Label for display
328 */
329 public static function get_display_label($category) {
330 return self::$display_labels[$category] ?? $category;
331 }
332
333 /**
334 * Get error summary for display, with withheld categories removed.
335 *
336 * @return array Error summary entries safe to show on this request
337 */
338 public static function get_visible_error_summary() {
339 // get_error_summary() already guarantees an array.
340 $summary = self::get_error_summary();
341
342 foreach ($summary as $key => $entry) {
343 $category = is_array($entry) ? ($entry['category'] ?? '') : '';
344 if ($category !== '' && self::is_suppressed_category($category)) {
345 unset($summary[$key]);
346 }
347 }
348
349 return $summary;
350 }
351
352 /**
353 * Get error code for a category
354 *
355 * @param string $category Error category
356 * @return string Error code or 'MS-0000' if not found
357 */
358 public static function get_error_code($category) {
359 return self::$error_codes[$category] ?? 'MS-0000';
360 }
361
362 /**
363 * Get all error codes mapping
364 *
365 * @return array Error codes array
366 */
367 public static function get_all_error_codes() {
368 return self::$error_codes;
369 }
370 }
371
372
373 /**
374 * Check Action Scheduler queue for overflow
375 * This checks if pending actions exceed 1000 and logs QUEUE_OVERFLOW error
376 *
377 * @param bool $force_check If true, bypasses transient throttling (for manual testing)
378 * @return int|false Returns pending count if checked, false if skipped
379 */
380 function metasync_check_action_scheduler_queue_overflow($force_check = false) {
381 // Only check if Action Scheduler is available
382 if (!class_exists('ActionScheduler_Store')) {
383 return false;
384 }
385
386 // Only check if Error Logger is available
387 if (!class_exists('Metasync_Error_Logger')) {
388 return false;
389 }
390
391 // Throttle: Only check once per hour to avoid excessive logging (unless forced)
392 if (!$force_check) {
393 $transient_key = 'metasync_queue_overflow_check';
394 $last_check = get_transient($transient_key);
395
396 if ($last_check !== false) {
397 return false; // Already checked recently
398 }
399 }
400
401 try {
402 $store = ActionScheduler_Store::instance();
403
404 // Get count of pending actions
405 $pending_count = (int) $store->query_actions([
406 'status' => ActionScheduler_Store::STATUS_PENDING,
407 'per_page' => 0, // We only need count
408 ], 'count');
409
410 // Check if queue overflow threshold is exceeded (>1000)
411 if ($pending_count > 1000) {
412 Metasync_Error_Logger::log(
413 Metasync_Error_Logger::CATEGORY_QUEUE_OVERFLOW,
414 Metasync_Error_Logger::SEVERITY_WARNING,
415 'Action Scheduler queue overflow - too many pending actions',
416 [
417 'pending_count' => $pending_count,
418 'threshold' => 1000,
419 'queue_system' => 'Action Scheduler',
420 'operation' => 'queue_processing'
421 ]
422 );
423 }
424
425 // Set transient to throttle future checks (1 hour) - only if not forced
426 if (!$force_check) {
427 $transient_key = 'metasync_queue_overflow_check';
428 set_transient($transient_key, time(), HOUR_IN_SECONDS);
429 }
430
431 return $pending_count;
432 } catch (Exception $e) {
433 // Fail silently to prevent breaking queue processing
434 // error_log('MetaSync: Failed to check queue overflow: ' . $e->getMessage());
435 return false;
436 }
437 }
438
439 // Hook into Action Scheduler before processing queue
440 add_action('action_scheduler_before_process_queue', 'metasync_check_action_scheduler_queue_overflow', 10, 0);
441
442 // Also check on shutdown (for immediate detection, throttled)
443 add_action('shutdown', function() {
444 // Only check in admin or if triggered manually
445 if (is_admin() || isset($_GET['metasync_check_queue'])) {
446 metasync_check_action_scheduler_queue_overflow();
447 }
448 }, 999);
449