PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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
← All changes | includes/class-metasync-error-logger.php +133 -35 2.6.18 → 2.7.2 View file →
@@ -38,8 +38,10 @@
38 38 const CATEGORY_QUEUE_OVERFLOW = 'QUEUE_OVERFLOW';
39 39 const CATEGORY_AUTHENTICATION_FAILURE = 'AUTHENTICATION_FAILURE';
40 40 const CATEGORY_DATABASE_ERROR = 'DATABASE_ERROR';
41 41 const CATEGORY_NETWORK_ERROR = 'NETWORK_ERROR';
42 + const CATEGORY_OTTO_RENDER = 'OTTO_RENDER';
43 + const CATEGORY_HEADLESS_DELIVERY = 'HEADLESS_DELIVERY';
42 44
43 45 /**
44 46 * Error codes mapping
45 47 * Format: MS-XXXX
@@ -52,11 +54,38 @@
52 54 self::CATEGORY_QUEUE_OVERFLOW => 'MS-3001',
53 55 self::CATEGORY_AUTHENTICATION_FAILURE => 'MS-4001',
54 56 self::CATEGORY_DATABASE_ERROR => 'MS-5001',
55 57 self::CATEGORY_NETWORK_ERROR => 'MS-6001',
58 + self::CATEGORY_OTTO_RENDER => 'MS-7001',
59 + self::CATEGORY_HEADLESS_DELIVERY => 'MS-8001',
56 60 ];
57 61
58 62 /**
63 + * Categories that only exist while an operator is troubleshooting.
64 + *
65 + * These record the plugin's OWN client-side throttling, not a site fault:
66 + * the OTTO call budget was reached, or a retry was deferred. Both are the
67 + * plugin working as designed, but they read as failures in the Error Logs
68 + * panel and are a known source of false-alarm bug reports. They are only
69 + * recorded while Debug Mode is on, and only shown while Debug Mode is on.
70 + */
71 + const DEBUG_ONLY_CATEGORIES = [
72 + self::CATEGORY_API_RATE_LIMIT,
73 + self::CATEGORY_API_BACKOFF,
74 + ];
75 +
76 + /**
77 + * Non-alarming labels for categories whose raw name overstates severity.
78 + *
79 + * Presentation only — the stored category is never rewritten, so historical
80 + * rows render softly too and no data migration is needed.
81 + */
82 + private static $display_labels = [
83 + self::CATEGORY_API_RATE_LIMIT => 'THROTTLED',
84 + self::CATEGORY_API_BACKOFF => 'RETRY_SCHEDULED',
85 + ];
86 +
87 + /**
59 88 * Severity level constants
60 89 */
61 90 const SEVERITY_INFO = 'INFO';
62 91 const SEVERITY_WARNING = 'WARNING';
@@ -78,8 +107,14 @@
78 107 */
79 108 const LOG_FILE_NAME = 'metasync-errors.log';
80 109
81 110 /**
111 + * Maximum log file size before rotation (10MB), mirroring the debug-mode
112 + * log policy: current file plus a single .old rotation.
113 + */
114 + const MAX_LOG_SIZE = 10485760; // 10MB in bytes
115 +
116 + /**
82 117 * Main logging function
83 118 *
84 119 * Formats and writes structured error log with:
85 120 * - Timestamp
@@ -102,19 +137,29 @@
102 137 // Validate inputs
103 138 if (empty($category) || empty($severity) || empty($message)) {
104 139 return false;
105 140 }
106 -
141 +
142 + // Throttling categories are diagnostics, not faults: skip them entirely
143 + // unless an operator has Debug Mode on. Nothing is written, no summary
144 + // entry is created, and the metasync_error_logged action does not fire.
145 + if (self::is_suppressed_category($category)) {
146 + return false;
147 + }
148 +
107 149 // Get error code for this category
108 150 $error_code = self::$error_codes[$category] ?? 'MS-0000';
109 151
110 152 // Prepare full context with error code
111 153 $full_context = array_merge([
112 - 'error_code' => $error_code
154 + 'error_code' => $error_code,
155 + 'severity' => $severity
113 156 ], $context);
114 157
115 - // Format timestamp
116 - $timestamp = date('Y-m-d H:i:s');
158 + // Format timestamp — current_time() keeps the log line on the same
159 + // WordPress-local clock as the error summary below, instead of the
160 + // server/UTC clock date() uses.
161 + $timestamp = current_time('Y-m-d H:i:s');
117 162
118 163 // Format log line: [YYYY-MM-DD HH:MM:SS] [CATEGORY] [SEVERITY] Message {context_json}
119 164 $log_line = sprintf(
120 165 "[%s] [%s] [%s] %s %s\n",
@@ -143,52 +188,57 @@
143 188 * @param string $log_line Formatted log line
144 189 * @return bool True on success, false on failure
145 190 */
146 191 private static function write_to_log_file($log_line) {
147 - // Get log directory (same as Log_Manager uses)
148 - $log_directory = WP_CONTENT_DIR . '/metasync_data';
192 + // Directory creation, web-server protection, and writability
193 + // guarantees live in Metasync_Data_Store so every writer agrees on
194 + // the location; a false return means "no writable data directory".
195 + $log_directory = Metasync_Data_Store::base_dir();
196 + if (false === $log_directory) {
197 + return false;
198 + }
199 +
200 + // Get log file path
201 + $log_file = Metasync_Data_Store::file_path(self::LOG_FILE_NAME);
202 + if (false === $log_file) {
203 + return false;
204 + }
149 205
150 - // Create directory if it doesn't exist
151 - if (!is_dir($log_directory)) {
152 - if (!@mkdir($log_directory, 0755, true)) {
153 - error_log('Metasync_Error_Logger: Failed to create log directory: ' . $log_directory);
154 - return false;
206 + // Serialize rotation and append across requests. Locking only the log
207 + // file is insufficient because rename() changes the inode being locked.
208 + $rotation_lock = @fopen($log_file . '.lock', 'c'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streaming file I/O; WP_Filesystem cannot return a raw stream handle
209 + if (false === $rotation_lock || !flock($rotation_lock, LOCK_EX)) {
210 + if (false !== $rotation_lock) {
211 + fclose($rotation_lock); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- operates on a native stream handle
155 212 }
213 + error_log('Metasync_Error_Logger: Failed to lock log file: ' . $log_file); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- plugin logger sink / last-resort fallback
214 + return false;
156 215 }
157 216
158 - // Protect log directory from direct web access
159 - $htaccess_file = $log_directory . '/.htaccess';
160 - if (!file_exists($htaccess_file)) {
161 - @file_put_contents($htaccess_file, "Order deny,allow\nDeny from all\n");
162 - }
163 - $index_file = $log_directory . '/index.php';
164 - if (!file_exists($index_file)) {
165 - @file_put_contents($index_file, "<?php\n// Silence is golden\n");
166 - }
167 -
168 - // Verify directory is writable
169 - if (!is_writable($log_directory)) {
170 - @chmod($log_directory, 0755);
171 - if (!is_writable($log_directory)) {
172 - error_log('Metasync_Error_Logger: Directory not writable: ' . $log_directory);
173 - return false;
217 + // Rotate while holding the stable sidecar lock so concurrent requests
218 + // cannot both unlink/rename the same file or append to the old inode.
219 + $log_size = file_exists($log_file) ? filesize($log_file) : false;
220 + if (false !== $log_size && $log_size >= self::MAX_LOG_SIZE) {
221 + $backup = $log_file . '.old';
222 + if (file_exists($backup)) {
223 + @wp_delete_file($backup);
174 224 }
225 + @rename($log_file, $backup); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- atomic same-filesystem replace; WP_Filesystem::move() needs credential bootstrapping on non-direct hosts
175 226 }
176 -
177 - // Get log file path
178 - $log_file = wp_normalize_path($log_directory . '/' . self::LOG_FILE_NAME);
179 -
227 +
180 228 // Append to log file with file locking
181 229 $result = @file_put_contents($log_file, $log_line, FILE_APPEND | LOCK_EX);
182 -
230 + flock($rotation_lock, LOCK_UN);
231 + fclose($rotation_lock); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- operates on a native stream handle
232 +
183 233 if ($result === false) {
184 - error_log('Metasync_Error_Logger: Failed to write to log file: ' . $log_file);
234 + error_log('Metasync_Error_Logger: Failed to write to log file: ' . $log_file); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- plugin logger sink / last-resort fallback
185 235 return false;
186 236 }
187 237
188 238 // Set file permissions if file was just created
189 239 if (!file_exists($log_file) || filesize($log_file) === strlen($log_line)) {
190 - @chmod($log_file, 0644);
240 + @chmod($log_file, 0644); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- direct POSIX permission change; WP_Filesystem would prompt for FTP credentials on non-direct hosts
191 241 }
192 242
193 243 return true;
194 244 }
@@ -267,8 +317,56 @@
267 317 return delete_option(self::ERROR_SUMMARY_OPTION);
268 318 }
269 319
270 320 /**
321 + * Whether a category is withheld on this request.
322 + *
323 + * True for the throttling categories while Debug Mode is off. Used both to
324 + * skip recording and to filter what the Error Logs panel shows, so rows
325 + * recorded before this behaviour existed are hidden too.
326 + *
327 + * @param string $category Category name
328 + * @return bool True when the category should be withheld
329 + */
330 + public static function is_suppressed_category($category) {
331 + if (!in_array($category, self::DEBUG_ONLY_CATEGORIES, true)) {
332 + return false;
333 + }
334 +
335 + return !(class_exists('Metasync_Debug_Mode_Manager')
336 + && Metasync_Debug_Mode_Manager::is_enabled());
337 + }
338 +
339 + /**
340 + * Non-alarming label to display for a category.
341 + *
342 + * @param string $category Stored category name
343 + * @return string Label for display
344 + */
345 + public static function get_display_label($category) {
346 + return self::$display_labels[$category] ?? $category;
347 + }
348 +
349 + /**
350 + * Get error summary for display, with withheld categories removed.
351 + *
352 + * @return array Error summary entries safe to show on this request
353 + */
354 + public static function get_visible_error_summary() {
355 + // get_error_summary() already guarantees an array.
356 + $summary = self::get_error_summary();
357 +
358 + foreach ($summary as $key => $entry) {
359 + $category = is_array($entry) ? ($entry['category'] ?? '') : '';
360 + if ($category !== '' && self::is_suppressed_category($category)) {
361 + unset($summary[$key]);
362 + }
363 + }
364 +
365 + return $summary;
366 + }
367 +
368 + /**
271 369 * Get error code for a category
272 370 *
273 371 * @param string $category Error category
274 372 * @return string Error code or 'MS-0000' if not found
@@ -359,8 +457,8 @@
359 457
360 458 // Also check on shutdown (for immediate detection, throttled)
361 459 add_action('shutdown', function() {
362 460 // Only check in admin or if triggered manually
363 - if (is_admin() || isset($_GET['metasync_check_queue'])) {
461 + if (is_admin() || isset($_GET['metasync_check_queue'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, throttled queue check
364 462 metasync_check_action_scheduler_queue_overflow();
365 463 }
366 464 }, 999);