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
← All changes | otto/Otto_pixel_class.php +215 -23 2.6.15 → 2.7.1 View file →
@@ -100,10 +100,10 @@
100 100 return false;
101 101 }
102 102
103 103 # Parse saved domain + incoming URL
104 - $saved_domain = parse_url($saved['domain'], PHP_URL_HOST);
105 - $incoming_domain = parse_url($url, PHP_URL_HOST);
104 + $saved_domain = wp_parse_url($saved['domain'], PHP_URL_HOST);
105 + $incoming_domain = wp_parse_url($url, PHP_URL_HOST);
106 106
107 107 # Domain mismatch - not crawled
108 108 if (empty($saved_domain) || empty($incoming_domain) || strcasecmp($saved_domain, $incoming_domain) !== 0) {
109 109 return false;
@@ -109,9 +109,9 @@
109 109 return false;
110 110 }
111 111
112 112 # Parse incoming path (ignore query string)
113 - $parsed_url = parse_url($url);
113 + $parsed_url = wp_parse_url($url);
114 114 $url_path = $parsed_url['path'] ?? '/';
115 115
116 116 # Ensure path starts with / but don't modify trailing slashes
117 117 # Otto stores paths exactly as they appear in URLs
@@ -196,8 +196,36 @@
196 196
197 197 # render route html
198 198 function render_route_html(){
199 199
200 + # Headless frontends do not consume WordPress-rendered HTML, so avoid
201 + # suggestion lookups, buffering and internal fetches altogether.
202 + # Falls back to a guarded direct read rather than skipping the check:
203 + # silently skipping would render OTTO markup on a headless site.
204 + # @phpstan-ignore-next-line function.alreadyNarrowedType
205 + $headless = function_exists('metasync_headless_is_active')
206 + ? metasync_headless_is_active()
207 + : (class_exists('Metasync_Headless_Config') && Metasync_Headless_Config::is_active());
208 +
209 + if ($headless) {
210 + return;
211 + }
212 +
213 + # Clear per-request statics before any path runs. Harmless under PHP-FPM
214 + # (the process ends with the request) but required under persistent-worker
215 + # SAPIs, where a latched flag would silently disable cache capping and
216 + # failure reporting for every later request in that worker.
217 + #
218 + # method_exists() is redundant to static analysis — this MR adds the method,
219 + # so PHPStan proves the call always true. It is kept for the upgrade window:
220 + # during a plugin update an opcache can still hold the previous
221 + # Metasync_Otto_Render_Strategy, where class_exists() passes but the method
222 + # is absent, and an unguarded static call would fatal the page.
223 + # @phpstan-ignore-next-line function.alreadyNarrowedType
224 + if (class_exists('Metasync_Otto_Render_Strategy') && method_exists('Metasync_Otto_Render_Strategy', 'reset_request_state')) {
225 + Metasync_Otto_Render_Strategy::reset_request_state();
226 + }
227 +
200 228 # Disable SG Cache for Brizy pages FIRST - before any other processing
201 229 # Using global function defined in otto_pixel.php
202 230 if (function_exists('metasync_otto_disable_sg_cache_for_brizy')) {
203 231 metasync_otto_disable_sg_cache_for_brizy();
@@ -247,13 +275,36 @@
247 275 # Use get_suggestions directly to track cache status
248 276 $suggestions = $transient_cache->get_suggestions($route, $cache_track_key);
249 277
250 278 if (!$suggestions || !$transient_cache->has_payload($suggestions)) {
251 - # No suggestions available for this URL
252 - # Set header to indicate cache status
253 - if (!headers_sent()) {
254 - $cache_status = Metasync_Otto_Transient_Cache::get_cache_status($cache_track_key);
255 - header('X-MetaSync-OTTO-Cache: ' . ($cache_status ?: 'NO_SUGGESTIONS'));
279 + # No suggestions available for this URL.
280 + $cache_status = Metasync_Otto_Transient_Cache::get_cache_status($cache_track_key);
281 +
282 + # Two very different situations reach this point, and they must be
283 + # treated differently.
284 + #
285 + # A transient failure means the suggestions exist upstream but could
286 + # not be retrieved on this request — the API errored or timed out, the
287 + # per-minute budget was spent, or another worker held the lock. The
288 + # next request will probably succeed. Serving the un-optimized page
289 + # with normal cache headers lets a caching layer store it and hand it
290 + # to everyone until its TTL expires, so cap how long it may be kept.
291 + #
292 + # Everything else here is a legitimate answer: OTTO simply has nothing
293 + # for this URL (NO_SUGGESTIONS, including the 404 case). Most URLs on
294 + # most sites are in that state permanently, so capping those would
295 + # strip caching from the bulk of the site and move that load onto the
296 + # origin. Those stay fully cacheable.
297 + $transient_failures = ['RATE_LIMITED', 'LOCKED', 'API_ERROR'];
298 +
299 + if (in_array($cache_status, $transient_failures, true)
300 + && function_exists('metasync_otto_limit_response_cache')) {
301 + metasync_otto_limit_response_cache($cache_status);
302 + }
303 +
304 + # Set diagnostic headers only while Debug Mode is enabled.
305 + if (!headers_sent() && Metasync_Otto_Render_Strategy::diagnostics_enabled()) {
306 + header('X-MetaSync-OTTO-Cache: ' . Metasync_Otto_Render_Strategy::display_cache_status($cache_status ?: 'NO_SUGGESTIONS'));
256 307 header('X-MetaSync-OTTO-Method: NONE');
257 308 }
258 309 return;
259 310 }
@@ -260,15 +311,13 @@
260 311
261 312 # comment out for fixing pagination issues
262 313 # $route = rtrim($route, '/');
263 314
264 - # WP-498: Now that OTTO has confirmed suggestions to apply to this URL,
265 - # disable SiteGround SG Optimizer page caching for THIS request only.
266 - # Doing it here (instead of unconditionally on the `wp` hook) ensures
267 - # pages without OTTO suggestions keep being served from SG's page cache.
268 - if (function_exists('metasync_otto_disable_sg_page_cache')) {
269 - metasync_otto_disable_sg_page_cache();
270 - }
315 + # SiteGround's page cache is no longer disabled up-front for every OTTO page.
316 + # The cacheability decision now happens on the HTTP path in render_via_http(),
317 + # AFTER the internal fetch — only session/form pages and incomplete renders are
318 + # blocked, so plain content pages are finally cacheable. (SiteGround always uses
319 + # the HTTP path; the disable is a no-op off SiteGround, so nothing else loses it.)
271 320
272 321 # Analyze what OTTO is providing for SEO plugin blocking
273 322 $blocking_flags = $this->analyze_otto_blocking($suggestions);
274 323
@@ -331,8 +380,32 @@
331 380 }
332 381 }
333 382
334 383 /**
384 + * Stop WP Rocket writing the current response to its disk cache.
385 + *
386 + * DONOTCACHEPAGE is not sufficient from inside the rocket_buffer filter: WP
387 + * Rocket reads that constant when it decides whether to buffer the request,
388 + * which has already happened by the time the filter runs. It evaluates
389 + * do_rocket_generate_caching_files immediately before writing the file, so
390 + * that is the only lever still effective at this point.
391 + *
392 + * This matters more than the response headers do. A page served from WP
393 + * Rocket's disk cache is delivered by rewrite rules without invoking PHP, so
394 + * once a bad file is on disk no header can override it — only deleting the
395 + * file or waiting out Rocket's own expiry will clear it.
396 + *
397 + * PHP_INT_MAX priority so a site's own filters cannot re-enable the write.
398 + */
399 + private static function block_rocket_cache_write() {
400 + if (!defined('DONOTCACHEPAGE')) {
401 + define('DONOTCACHEPAGE', true);
402 + }
403 +
404 + add_filter('do_rocket_generate_caching_files', '__return_false', PHP_INT_MAX);
405 + }
406 +
407 + /**
335 408 * Render page using WP Rocket's rocket_buffer filter (PREFERRED for WP Rocket sites)
336 409 *
337 410 * When WP Rocket is active, hooking into rocket_buffer ensures WP Rocket
338 411 * caches the OTTO-modified HTML. This means:
@@ -353,12 +426,12 @@
353 426 # Hook into WP Rocket's HTML buffer at priority 1 so other rocket_buffer
354 427 # filters (minifiers, CDN rewriters, etc.) run on already-OTTO-modified HTML.
355 428 add_filter('rocket_buffer', function($html) use ($o_html, $suggestions, $blocking_flags) {
356 429 if (empty($html) || strlen($html) < 100) {
357 - // Tell WP Rocket not to cache this empty/broken response.
358 - if (!defined('DONOTCACHEPAGE')) {
359 - define('DONOTCACHEPAGE', true);
360 - }
430 + // Tell WP Rocket not to cache this empty/broken response. The
431 + // constant alone is too late here (see block_rocket_cache_write),
432 + // so the write-time filter is applied as well.
433 + self::block_rocket_cache_write();
361 434 return $html;
362 435 }
363 436
364 437 # Only process full HTML documents — skip partials, JSON, error responses
@@ -372,8 +445,23 @@
372 445 if (!empty($blocking_flags)) {
373 446 $data['_otto_blocking'] = $blocking_flags;
374 447 }
375 448
449 + # From here on we hold usable suggestions, so any exit that returns
450 + # $html unmodified is a genuine failure: WP Rocket would write the
451 + # un-optimized markup to its cache file, and whatever sits in front of
452 + # it would then serve that copy. This is worse than the plain buffer
453 + # path, because the bad page is persisted on disk as well as at the
454 + # edge — and this route exists specifically to make the cached copy the
455 + # optimized one.
456 + #
457 + # Note that DONOTCACHEPAGE alone does NOT prevent that write. WP Rocket
458 + # evaluates the constant when it decides whether to buffer the page,
459 + # which has already happened by the time this filter runs. The
460 + # do_rocket_generate_caching_files filter is evaluated immediately
461 + # before the file is written, so it is the only lever still available
462 + # from here. A disk-cached page is served by rewrite rules without
463 + # invoking PHP, so no response header can undo it afterwards.
376 464 try {
377 465 $modified = $o_html->process_html_directly($html, $data);
378 466 # Sanity check: modified HTML must be at least 50% the size of original
379 467 if ($modified && strlen($modified) > strlen($html) * 0.5) {
@@ -378,12 +466,64 @@
378 466 # Sanity check: modified HTML must be at least 50% the size of original
379 467 if ($modified && strlen($modified) > strlen($html) * 0.5) {
380 468 return $modified;
381 469 }
470 +
471 + # An unprocessable document (oversized / over the memory budget) is
472 + # a permanent property of the page, not a failure to retry. Leave it
473 + # fully cacheable — capping it would make the most expensive page on
474 + # the site uncacheable forever for no gain.
475 + if (class_exists('Metasync_Otto_Render_Strategy')
476 + && Metasync_Otto_Render_Strategy::document_was_unprocessable()) {
477 + return $html;
478 + }
479 +
480 + # Either the rewrite returned nothing usable, or the result was so
481 + # much smaller than the input that it is assumed to be mangled.
482 + self::block_rocket_cache_write();
483 + if (function_exists('metasync_otto_limit_response_cache')) {
484 + metasync_otto_limit_response_cache('RENDER_DISCARDED_ROCKET');
485 + }
486 + if (function_exists('metasync_otto_report_render_failure')) {
487 + metasync_otto_report_render_failure(
488 + 'RENDER_DISCARDED_ROCKET',
489 + 'OTTO rewrite produced no usable output on the WP Rocket path',
490 + [
491 + 'original_bytes' => strlen($html),
492 + 'result_bytes' => is_string($modified) ? strlen($modified) : 0,
493 + 'render_method' => 'wp_rocket',
494 + ],
495 + 'warning'
496 + );
497 + }
382 498 } catch (Exception $e) {
383 499 // Fall through — return original HTML on any failure
500 + self::block_rocket_cache_write();
501 + if (function_exists('metasync_otto_limit_response_cache')) {
502 + metasync_otto_limit_response_cache('RENDER_EXCEPTION_ROCKET');
503 + }
504 + if (function_exists('metasync_otto_report_render_failure')) {
505 + metasync_otto_report_render_failure(
506 + 'RENDER_EXCEPTION_ROCKET',
507 + 'OTTO rewrite threw on the WP Rocket path: ' . $e->getMessage(),
508 + ['render_method' => 'wp_rocket', 'exception' => get_class($e)],
509 + 'error'
510 + );
511 + }
384 512 } catch (Error $e) {
385 513 // Fall through — return original HTML on any failure
514 + self::block_rocket_cache_write();
515 + if (function_exists('metasync_otto_limit_response_cache')) {
516 + metasync_otto_limit_response_cache('RENDER_EXCEPTION_ROCKET');
517 + }
518 + if (function_exists('metasync_otto_report_render_failure')) {
519 + metasync_otto_report_render_failure(
520 + 'RENDER_EXCEPTION_ROCKET',
521 + 'OTTO rewrite errored on the WP Rocket path: ' . $e->getMessage(),
522 + ['render_method' => 'wp_rocket', 'error' => get_class($e)],
523 + 'error'
524 + );
525 + }
386 526 }
387 527
388 528 return $html;
389 529 }, 1);
@@ -523,9 +663,9 @@
523 663 private function render_via_http($route, $suggestions, $cache_track_key, $cache_status) {
524 664 # Set method indicator
525 665 Metasync_Otto_Render_Strategy::set_current_method(Metasync_Otto_Render_Strategy::METHOD_HTTP);
526 666
527 - # WP-315: On Divi sites, skip OTTO for the first HTTP render per page
667 + # On Divi sites, skip OTTO for the first HTTP render per page
528 668 # per 24h. OTTO's internal wp_remote_get creates corrupted CSS cache
529 669 # files in et-cache/{post_id}/ (wrong server context). By returning
530 670 # false once, WordPress renders the page normally — building correct
531 671 # Divi CSS caches. Subsequent OTTO renders within 24h use those caches.
@@ -536,8 +676,14 @@
536 676 $activated = get_option('metasync_activated_at', '');
537 677 $cached = get_transient($cache_key);
538 678 if ($cached === false || $cached !== $activated) {
539 679 set_transient($cache_key, $activated, DAY_IN_SECONDS);
680 + # Deliberate one-off skip, and the next request renders correctly —
681 + # so cap how long this un-optimized copy may be cached rather than
682 + # letting a caching layer pin it for its full TTL.
683 + if (function_exists('metasync_otto_limit_response_cache')) {
684 + metasync_otto_limit_response_cache('DIVI_FIRST_RENDER_SKIP');
685 + }
540 686 return false;
541 687 }
542 688 }
543 689
@@ -545,14 +691,22 @@
545 691 $route_html = $this->get_route_html($route, $cache_track_key, $suggestions);
546 692
547 693 # check that route html is valid
548 694 if(empty($route_html)){
695 + # Fetch failed / empty (e.g. SG Optimizer truncated the internal response) —
696 + # WordPress will serve the un-optimized page. Cap how long that copy may be
697 + # cached, using the same mechanism as the deliberate skips above rather than a
698 + # second, competing one: it reconciles existing directives, keeps logged-in
699 + # responses fully no-store, and never grants caching nothing had granted.
700 + if (function_exists('metasync_otto_limit_response_cache')) {
701 + metasync_otto_limit_response_cache('HTTP_FETCH_FAILED');
702 + }
549 703 return false;
550 704 }
551 705
552 706 $route_html_string = is_string($route_html) ? $route_html : $route_html->__toString();
553 707
554 - # WP-315: Detect incomplete Divi rendering in OTTO's HTTP render output.
708 + # Detect incomplete Divi rendering in OTTO's HTTP render output.
555 709 # OTTO's internal wp_remote_get runs in a different server context than the
556 710 # normal page load. This can cause Divi to produce incomplete output:
557 711 #
558 712 # 1. Cold CSS cache: Divi outputs inline <style> blocks instead of external
@@ -564,11 +718,25 @@
564 718 # breaking CSS for all visitors until manual cache purge.
565 719 #
566 720 # Fix: skip OTTO for this request (return false), letting WordPress render
567 721 # normally. This builds Divi's caches so the next OTTO request works correctly.
722 + #
723 + # Both checks below are self-resolving — the next render finds warm caches —
724 + # so the un-optimized copy served now gets a capped cache lifetime rather
725 + # than being pinned for a caching layer's full TTL.
726 + #
727 + # The remaining fail-open exits on this path are deliberately NOT capped.
728 + # They mean the site could not fetch its own page (blocked loopback request,
729 + # HTTP Basic Auth, a firewall rejecting the internal user-agent) or that the
730 + # document cannot be processed at all. Those are whole-site, permanent
731 + # conditions: OTTO cannot work on any page, so capping cache lifetime would
732 + # rescue nothing while moving the entire site's traffic to the origin.
568 733 if (defined('ET_CORE_VERSION')) {
569 734 # Check 1: Cold TB CSS cache (inline styles instead of external link)
570 735 if (preg_match('/<style\s[^>]*id=["\']et-core-unified-tb-[^"\']*-cached-inline-styles["\']/', $route_html_string)) {
736 + if (function_exists('metasync_otto_limit_response_cache')) {
737 + metasync_otto_limit_response_cache('DIVI_COLD_CSS_CACHE');
738 + }
571 739 return false;
572 740 }
573 741 # Check 2: Missing Google Fonts CSS (Divi enqueues this on every page)
574 742 # Normal render has: <link ... id='et-builder-googlefonts-cached-css' ...>
@@ -576,8 +744,11 @@
576 744 # If neither is present, the internal fetch didn't load fonts properly.
577 745 if (strpos($route_html_string, 'et-builder-googlefonts') === false
578 746 && strpos($route_html_string, 'fonts.googleapis.com') === false
579 747 ) {
748 + if (function_exists('metasync_otto_limit_response_cache')) {
749 + metasync_otto_limit_response_cache('DIVI_MISSING_FONTS');
750 + }
580 751 return false;
581 752 }
582 753 }
583 754
@@ -621,9 +792,9 @@
621 792 $discarded_output = ob_get_clean();
622 793
623 794 }
624 795
625 - # WP-315: If Divi's module-design CSS is missing from OTTO output,
796 + # If Divi's module-design CSS is missing from OTTO output,
626 797 # fetch it directly and inject it. This handles the case where the
627 798 # internal wp_remote_get response was truncated by SG Optimizer's parser.
628 799 if (strpos($route_html_string, 'et-builder-module-design') === false
629 800 && function_exists('et_theme_builder_decorate_page_resource_slug')
@@ -644,11 +815,32 @@
644 815 }
645 816 }
646 817 }
647 818
819 + # Decide cacheability from the internal fetch + the final output, then serve.
820 + # 1) Hand the visitor the session their page was built around (fixes forms that
821 + # were silently failing on the HTTP path because the fetch cookie was dropped).
822 + # 2) Block shared caching when the fetched page set a cookie, when its own render
823 + # declared itself uncacheable (PHP's session cache-limiter — true for resumed
824 + # sessions too), when the visitor carries cart/auth cookies that change the
825 + # rendered content, or when the render came back incomplete.
826 + # 3) A plain, complete page gets no cache header at all → the host finally caches
827 + # it (the SiteGround/Oxygen relief this ticket is about).
828 + Metasync_Otto_Render_Strategy::passthrough_http_fetch_cookies();
829 +
830 + $must_not_cache =
831 + Metasync_Otto_Render_Strategy::http_fetch_had_cookie()
832 + || Metasync_Otto_Render_Strategy::http_fetch_no_cache()
833 + || Metasync_Otto_Render_Strategy::request_has_visitor_specific_cookie()
834 + || !Metasync_Otto_Render_Strategy::http_output_is_complete($route_html_string);
835 +
648 836 # Send response headers
649 837 Metasync_Otto_Render_Strategy::send_headers($cache_status);
650 838
839 + if ($must_not_cache) {
840 + Metasync_Otto_Render_Strategy::block_http_cache();
841 + }
842 +
651 843 # continue to render the html
652 844 echo $route_html_string;
653 845
654 846 # prevent further wp execution
@@ -654,5 +846,5 @@
654 846 # prevent further wp execution
655 847 exit();
656 848 }
657 849
658 -}
850 +}