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 +218 -17 2.6.10 → 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 }
@@ -259,9 +310,15 @@
259 310 }
260 311
261 312 # comment out for fixing pagination issues
262 313 # $route = rtrim($route, '/');
263 -
314 +
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.)
320 +
264 321 # Analyze what OTTO is providing for SEO plugin blocking
265 322 $blocking_flags = $this->analyze_otto_blocking($suggestions);
266 323
267 324 # Get cache status for headers
@@ -323,8 +380,32 @@
323 380 }
324 381 }
325 382
326 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 + /**
327 408 * Render page using WP Rocket's rocket_buffer filter (PREFERRED for WP Rocket sites)
328 409 *
329 410 * When WP Rocket is active, hooking into rocket_buffer ensures WP Rocket
330 411 * caches the OTTO-modified HTML. This means:
@@ -345,12 +426,12 @@
345 426 # Hook into WP Rocket's HTML buffer at priority 1 so other rocket_buffer
346 427 # filters (minifiers, CDN rewriters, etc.) run on already-OTTO-modified HTML.
347 428 add_filter('rocket_buffer', function($html) use ($o_html, $suggestions, $blocking_flags) {
348 429 if (empty($html) || strlen($html) < 100) {
349 - // Tell WP Rocket not to cache this empty/broken response.
350 - if (!defined('DONOTCACHEPAGE')) {
351 - define('DONOTCACHEPAGE', true);
352 - }
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();
353 434 return $html;
354 435 }
355 436
356 437 # Only process full HTML documents — skip partials, JSON, error responses
@@ -364,8 +445,23 @@
364 445 if (!empty($blocking_flags)) {
365 446 $data['_otto_blocking'] = $blocking_flags;
366 447 }
367 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.
368 464 try {
369 465 $modified = $o_html->process_html_directly($html, $data);
370 466 # Sanity check: modified HTML must be at least 50% the size of original
371 467 if ($modified && strlen($modified) > strlen($html) * 0.5) {
@@ -370,12 +466,64 @@
370 466 # Sanity check: modified HTML must be at least 50% the size of original
371 467 if ($modified && strlen($modified) > strlen($html) * 0.5) {
372 468 return $modified;
373 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 + }
374 498 } catch (Exception $e) {
375 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 + }
376 512 } catch (Error $e) {
377 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 + }
378 526 }
379 527
380 528 return $html;
381 529 }, 1);
@@ -515,9 +663,9 @@
515 663 private function render_via_http($route, $suggestions, $cache_track_key, $cache_status) {
516 664 # Set method indicator
517 665 Metasync_Otto_Render_Strategy::set_current_method(Metasync_Otto_Render_Strategy::METHOD_HTTP);
518 666
519 - # 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
520 668 # per 24h. OTTO's internal wp_remote_get creates corrupted CSS cache
521 669 # files in et-cache/{post_id}/ (wrong server context). By returning
522 670 # false once, WordPress renders the page normally — building correct
523 671 # Divi CSS caches. Subsequent OTTO renders within 24h use those caches.
@@ -528,8 +676,14 @@
528 676 $activated = get_option('metasync_activated_at', '');
529 677 $cached = get_transient($cache_key);
530 678 if ($cached === false || $cached !== $activated) {
531 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 + }
532 686 return false;
533 687 }
534 688 }
535 689
@@ -537,14 +691,22 @@
537 691 $route_html = $this->get_route_html($route, $cache_track_key, $suggestions);
538 692
539 693 # check that route html is valid
540 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 + }
541 703 return false;
542 704 }
543 705
544 706 $route_html_string = is_string($route_html) ? $route_html : $route_html->__toString();
545 707
546 - # WP-315: Detect incomplete Divi rendering in OTTO's HTTP render output.
708 + # Detect incomplete Divi rendering in OTTO's HTTP render output.
547 709 # OTTO's internal wp_remote_get runs in a different server context than the
548 710 # normal page load. This can cause Divi to produce incomplete output:
549 711 #
550 712 # 1. Cold CSS cache: Divi outputs inline <style> blocks instead of external
@@ -556,11 +718,25 @@
556 718 # breaking CSS for all visitors until manual cache purge.
557 719 #
558 720 # Fix: skip OTTO for this request (return false), letting WordPress render
559 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.
560 733 if (defined('ET_CORE_VERSION')) {
561 734 # Check 1: Cold TB CSS cache (inline styles instead of external link)
562 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 + }
563 739 return false;
564 740 }
565 741 # Check 2: Missing Google Fonts CSS (Divi enqueues this on every page)
566 742 # Normal render has: <link ... id='et-builder-googlefonts-cached-css' ...>
@@ -568,8 +744,11 @@
568 744 # If neither is present, the internal fetch didn't load fonts properly.
569 745 if (strpos($route_html_string, 'et-builder-googlefonts') === false
570 746 && strpos($route_html_string, 'fonts.googleapis.com') === false
571 747 ) {
748 + if (function_exists('metasync_otto_limit_response_cache')) {
749 + metasync_otto_limit_response_cache('DIVI_MISSING_FONTS');
750 + }
572 751 return false;
573 752 }
574 753 }
575 754
@@ -613,12 +792,13 @@
613 792 $discarded_output = ob_get_clean();
614 793
615 794 }
616 795
617 - # 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,
618 797 # fetch it directly and inject it. This handles the case where the
619 798 # internal wp_remote_get response was truncated by SG Optimizer's parser.
620 799 if (strpos($route_html_string, 'et-builder-module-design') === false
800 + && function_exists('et_theme_builder_decorate_page_resource_slug')
621 801 && function_exists('et_core_page_resource_get')
622 802 ) {
623 803 # Try to get Divi's inline CSS from its page resource manager
624 804 $post_id = get_the_ID();
@@ -635,11 +815,32 @@
635 815 }
636 816 }
637 817 }
638 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 +
639 836 # Send response headers
640 837 Metasync_Otto_Render_Strategy::send_headers($cache_status);
641 838
839 + if ($must_not_cache) {
840 + Metasync_Otto_Render_Strategy::block_http_cache();
841 + }
842 +
642 843 # continue to render the html
643 844 echo $route_html_string;
644 845
645 846 # prevent further wp execution
@@ -645,5 +846,5 @@
645 846 # prevent further wp execution
646 847 exit();
647 848 }
648 849
649 -}
850 +}