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 | otto/otto_pixel.php +1430 -304 2.6.9 → 2.7.2 View file →
@@ -16,18 +16,67 @@
16 16 # uses the simple html dom library
17 17 use simplehtmldom\HtmlDocument;
18 18
19 19 # include the otto class file
20 -require_once plugin_dir_path( __FILE__ ) . '/vendor/autoload.php';
20 +#
21 +# The vendor tree is generated content, unlike the plugin files beside it: an
22 +# update that copies the plugin directory piece by piece can briefly leave this
23 +# file beside an otto/vendor that is missing, half-written or holding a stale
24 +# classmap. A bare require on a missing autoloader is a fatal during plugin
25 +# bootstrap — the whole site, not just OTTO. Load it only when it is actually
26 +# readable, and catch Throwable while doing so: the autoloader is a trampoline
27 +# of bare requires into otto/vendor/composer/, so a tree that is present but
28 +# still mid-copy (or a truncated autoload.php, which parses only to a
29 +# ParseError) would otherwise fatal here too. Either way the parser class
30 +# stays unloaded, and the DOM-parser guard in metasync_start_otto() degrades
31 +# the request to OTTO-less rendering instead of fataling.
32 +$metasync_otto_vendor_autoload = plugin_dir_path( __FILE__ ) . '/vendor/autoload.php';
33 +if ( is_readable( $metasync_otto_vendor_autoload ) ) {
34 + try {
35 + require_once $metasync_otto_vendor_autoload;
36 + } catch ( \Throwable $e ) {
37 + # Deliberately swallowed: any exception raised while loading generated
38 + # autoloader code means this request runs without the DOM parser. The
39 + # guard below turns that into one OTTO-less request rather than a
40 + # bootstrap fatal on the whole site.
41 + }
42 +}
21 43 require_once plugin_dir_path( __FILE__ ) . '/Otto_html_class.php';
22 44 require_once plugin_dir_path( __FILE__ ) . '/Otto_pixel_class.php';
23 45 require_once plugin_dir_path( __FILE__ ) . '/metasync-otto-seo-functions.php';
24 46 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-transient-cache.php';
47 +require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-job-status.php';
48 +require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-webhook-audit.php';
25 49 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-render-strategy.php';
26 50 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-config.php';
27 51 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-bot-detector.php';
28 52 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-bot-statistics-database.php';
29 53
54 +/**
55 + * Is $method actually available on $class in this process right now?
56 + *
57 + * A partially updated install can leave a newer copy of one plugin file beside
58 + * an older copy of another — stale opcache bytecode for a single file is enough.
59 + * class_exists() is satisfied by the older copy, and calling a method it does
60 + * not declare is a fatal. Everything in this file runs on the front end, so that
61 + * fatal is a white screen on every page view rather than a degraded feature.
62 + *
63 + * Defined here rather than pulled from a shared utility class on purpose: a
64 + * helper loaded from another file could itself be the stale one.
65 + *
66 + * $class and $method are parameters rather than literals at the call site so the
67 + * check survives static analysis, which would otherwise narrow a literal
68 + * method_exists() on a known class to a constant true — the skew this guards
69 + * against exists only at runtime.
70 + *
71 + * @param string $class Class about to be called.
72 + * @param string $method Method about to be called on it.
73 + * @return bool
74 + */
75 +function metasync_otto_class_provides($class, $method) {
76 + return class_exists($class) && method_exists($class, $method);
77 +}
78 +
30 79 # OPTIMIZED: get the metasync options (cached in static class)
31 80 $metasync_options = Metasync_Otto_Config::get_options();
32 81
33 82 # OTTO SSR is always enabled by default
@@ -51,9 +100,9 @@
51 100 # Use endpoint manager to get the correct domain
52 101 $otto_domain = 'sa.searchatlas.com'; # default
53 102 if (class_exists('Metasync_Endpoint_Manager')) {
54 103 $otto_api_domain = Metasync_Endpoint_Manager::get_endpoint('OTTO_API_DOMAIN');
55 - $parsed = parse_url($otto_api_domain);
104 + $parsed = wp_parse_url($otto_api_domain);
56 105 if (!empty($parsed['host'])) {
57 106 $otto_domain = $parsed['host'];
58 107 }
59 108 }
@@ -65,12 +114,54 @@
65 114 # OPTIMIZED: use cached uuid
66 115 $otto_tag = '<meta name="otto" content="uuid='.esc_attr(Metasync_Otto_Config::get_otto_uuid()).'; type=wordpress; enabled='.esc_attr($string_enabled).'; version='.esc_attr($plugin_version).'">';
67 116
68 117 # out the otto tag
118 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- meta tag assembled on the line above with esc_attr() on every interpolated value
69 119 echo $otto_tag;
70 120 }, 1); # Priority 1 to output early in head
71 121
72 122 /**
123 + * Scheduling seam for the OTTO pipeline.
124 + *
125 + * wp_schedule_single_event() returns false on failure and the old pipeline
126 + * ignored it, so a broken cron table acknowledged URLs that were never
127 + * queued. Every schedule in this pipeline goes through this seam so the
128 + * return value is honored (and tests can observe scheduling).
129 + */
130 +function metasync_otto_schedule_single_event($timestamp, $hook, $args = array()) {
131 + return wp_schedule_single_event($timestamp, $hook, $args) === true;
132 +}
133 +
134 +/**
135 + * Dedupe seam: does this URL already have live per-URL work in flight?
136 + *
137 + * Combines the durable status store with the raw cron table so a re-delivered
138 + * webhook cannot stack a second job behind a live one.
139 + */
140 +function metasync_otto_job_already_scheduled($route) {
141 + if (class_exists('Metasync_Otto_Job_Status', false)
142 + && Metasync_Otto_Job_Status::has_active($route)) {
143 + return true;
144 + }
145 + return wp_next_scheduled('metasync_process_otto_crawl_url_job', array($route)) !== false;
146 +}
147 +
148 +/**
149 + * Cron-table dedupe seam: does a per-URL cron event already exist for this URL?
150 + *
151 + * The pending-queue drainer must dedupe against the scheduler table only.
152 + * Every URL it takes off the durable queue carries a fresh webhook
153 + * accepted/batch_overflow ledger row; that row exists so a re-delivered
154 + * webhook cannot stack a second job behind live work, and consulting it
155 + * from the drainer — after take_pending() has already removed the URL
156 + * from the queue — silently dropped every overflow URL instead of
157 + * draining it. Only a real, still-pending cron event may veto here.
158 + */
159 +function metasync_otto_job_event_pending($route) {
160 + return wp_next_scheduled('metasync_process_otto_crawl_url_job', array($route)) !== false;
161 +}
162 +
163 +/**
73 164 * Start end point to handle requests on page updates
74 165 * The Otto Crawler will call this end point once a page is updated
75 166 **/
76 167
@@ -76,8 +167,13 @@
76 167
77 168 # function to register the route
78 169 function metasync_otto_crawl_notify($request){
79 170
171 + # receipt time is captured before validation and scheduling, so the audit
172 + # record describes when WordPress received the delivery, not when its work
173 + # happened to finish.
174 + $received_at = time();
175 +
80 176 # get request data params
81 177 $data = $request->get_json_params();
82 178
83 179 # fields
@@ -97,36 +193,48 @@
97 193 }
98 194
99 195 }
100 196
101 - # load otto pixel
102 - $otto_pixel = new Metasync_otto_pixel(false);
197 + # The audit trail is deliberately after payload validation: invalid requests
198 + # receive the existing 400 response and are not recorded as deliveries.
199 + # It is also before any scheduling so a valid delivery is captured even if
200 + # later work is deferred or rejected. The failure guard is intentionally
201 + # broader than Exception: a broken option adapter or stale plugin copy must
202 + # never turn an otherwise acknowledged webhook into a 500 response.
203 + try {
204 + if (metasync_otto_class_provides('Metasync_Otto_Webhook_Audit', 'record')) {
205 + Metasync_Otto_Webhook_Audit::record(
206 + $received_at,
207 + $data['domain'],
208 + $data['urls']
209 + );
210 + }
211 + } catch (Throwable $metasync_otto_audit_failure) {
212 + error_log(
213 + 'MetaSync OTTO: failed to record crawl-notify webhook audit entry: '
214 + . $metasync_otto_audit_failure->getMessage()
215 + ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- audit failure must not break webhook acknowledgement
216 + }
103 217
104 - # save the otto data first (cheap local DB write — kept synchronous)
105 - $otto_pixel->save_crawl_data($data);
106 -
107 218 # Defer the expensive per-URL work (OTTO API fetch, post meta sync,
108 219 # per-URL cache purge) to background jobs. The webhook caller enforces a
109 - # strict response-time budget; doing this work inline previously blocked
110 - # the response for up to 30s × N URLs and timed the caller out.
111 - # Each URL gets its own scheduled event so failures are isolated.
220 + # strict response-time budget, so the response acknowledges SCHEDULING,
221 + # never execution. The per-URL status store records what actually
222 + # happened to each URL once the jobs run.
112 223 #
113 - # WP-299: Cap the number of per-URL cron jobs scheduled per webhook batch.
114 - # Large crawl batches (100+ URLs) previously created hundreds of cron events
115 - # that overwhelmed WP-Cron on shared/managed hosts like WP Engine. Excess
116 - # URLs beyond the cap are silently skipped — OTTO will re-crawl them on the
117 - # next webhook cycle.
224 + # Cap the number of per-URL cron jobs scheduled per webhook batch so a
225 + # large crawl cannot overwhelm WP-Cron on shared hosts. URLs past the cap
226 + # are queued durably and worked off by a self-rescheduling drainer job —
227 + # never silently dropped.
118 228 $max_jobs_per_batch = defined('METASYNC_MAX_JOBS_PER_BATCH') ? METASYNC_MAX_JOBS_PER_BATCH : 25;
119 229 $now = time();
120 230 $routes_to_process = array();
121 - $scheduled_count = 0;
231 + $accepted = 0;
232 + $deferred = 0;
233 + $rejected = 0;
234 + $scheduled_this_batch = 0;
122 235
123 236 foreach($data['urls'] AS $key => $url){
124 - # Enforce per-batch cap to prevent cron overload
125 - if ($scheduled_count >= $max_jobs_per_batch) {
126 - break;
127 - }
128 -
129 237 # prepare the route
130 238 $route = $data['domain'] . $url;
131 239
132 240 # validate the route
@@ -145,39 +253,71 @@
145 253 if (metasync_is_otto_url_excluded($route)) {
146 254 continue;
147 255 }
148 256
257 + # Every valid URL belongs to the batched cache-purge set even when its
258 + # per-URL meta-sync job is deduped or deferred: the crawler hit all of
259 + # them, so the caches for all of them must be refreshed.
260 + $routes_to_process[] = $route;
261 +
262 + # Duplicate delivery: an active job already owns this URL. Idempotent
263 + # no-op — not re-scheduled, not queued, not counted as new work.
264 + if (metasync_otto_job_already_scheduled($route)) {
265 + continue;
266 + }
267 +
268 + if ($scheduled_this_batch >= $max_jobs_per_batch) {
269 + # Batch overflow: queue durably for the drainer instead of dropping.
270 + if (Metasync_Otto_Job_Status::enqueue_pending($route)) {
271 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', 0, 'batch_overflow');
272 + $deferred++;
273 + } else {
274 + $rejected++;
275 + }
276 + continue;
277 + }
278 +
149 279 # Queue the per-URL processing for background execution. The handler
150 280 # (metasync_handle_otto_crawl_url_job) performs transient warming,
151 281 # SEO meta sync, and per-URL host cache purge.
152 282 # Offset each event by $key seconds to avoid wp_schedule_single_event()
153 283 # silently dropping duplicates when timestamp + hook + args collide.
154 - wp_schedule_single_event($now + $key, 'metasync_process_otto_crawl_url_job', array($route));
155 -
156 - $routes_to_process[] = $route;
157 - $scheduled_count++;
284 + # A false return means cron refused the event — fall back to the
285 + # durable queue rather than acknowledging a URL nobody will process.
286 + if (metasync_otto_schedule_single_event($now + $key, 'metasync_process_otto_crawl_url_job', array($route))) {
287 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', 0, '');
288 + $accepted++;
289 + $scheduled_this_batch++;
290 + } elseif (Metasync_Otto_Job_Status::enqueue_pending($route)) {
291 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', 0, 'cron_refused');
292 + $deferred++;
293 + } else {
294 + $rejected++;
295 + }
158 296 }
159 297
160 - # Schedule a single batch job for cache warming and edge CDN purge.
161 - # These operations benefit from batching: warm_urls() uses concurrent
162 - # fire-and-forget requests, and edge providers (Cloudflare, Fastly)
163 - # support multi-URL purge in a single API call.
298 + # Schedule a single batch job for the edge CDN purge.
299 + # The batch waits for per-URL stragglers (retries) before purging, so a
300 + # retried URL's fresh content is what reaches the edge. Multi-URL purge
301 + # in a single API call keeps edge-provider request counts low.
164 302 if (!empty($routes_to_process)) {
165 303 $batch_time = $now + count($data['urls']) + 5; // run after all per-URL jobs
166 - wp_schedule_single_event($batch_time, 'metasync_process_otto_batch_cache_job', array($routes_to_process));
304 + metasync_otto_schedule_single_event($batch_time, 'metasync_process_otto_batch_cache_job', array($routes_to_process));
167 305 }
168 306
169 - # Track OTTO optimization event in GA4 (local, non-blocking)
170 - try {
171 - Metasync_GA4::get_instance()->track_otto_optimization($data);
172 - } catch (Exception $e) {
173 - // Analytics tracking failed, continue
307 + # Work off any overflow (or cron-refused URLs) from the durable queue.
308 + if ($deferred > 0) {
309 + metasync_otto_schedule_single_event($now + 120, 'metasync_otto_pending_drainer', array());
174 310 }
175 311
176 - # Return 200 immediately so the webhook caller does not time out.
312 + # Return 200 immediately so the webhook caller does not time out, with an
313 + # honest accounting of what happened to the batch.
177 314 return new WP_REST_Response(array(
178 315 'success' => true,
179 316 'message' => 'OTTO crawl notification received',
317 + 'accepted' => $accepted,
318 + 'deferred' => $deferred,
319 + 'rejected' => $rejected,
180 320 ), 200);
181 321 }
182 322
183 323 /**
@@ -182,119 +322,320 @@
182 322
183 323 /**
184 324 * Background handler for a single OTTO crawl-notify URL.
185 325 *
186 - * Runs the per-URL sequence that was previously inline in metasync_otto_crawl_notify():
187 - * warm the OTTO transient cache, sync SEO post meta, and clear the per-URL host cache.
326 + * Pipeline per URL:
327 + * 1. Warm the OTTO transient cache — this is the ONE live API call the job
328 + * makes; the warmed suggestions are handed to the meta sync so the
329 + * endpoint is never fetched twice per URL.
330 + * 2. Sync SEO post meta (metasync_process_otto_seo_data) and branch on its
331 + * outcome: only SUCCESS / NO_CHANGE reach the purge step.
332 + * 3. Clear the per-URL host cache and re-warm it.
188 333 *
189 - * Cache warming and edge CDN purge are handled separately by
190 - * metasync_handle_otto_batch_cache_job() in a single batched event, because
191 - * those operations benefit from multi-URL batching (fewer API calls).
334 + * Retryable outcomes (API timeout / 5xx / 429 / lock contention / CPU deferral)
335 + * retry with exponential backoff up to METASYNC_OTTO_JOB_MAX_RETRIES; the
336 + * exhaustion event is recorded via metasync_record_failed_action() for
337 + * Site Health. Permanent outcomes (not connected, excluded/404 URL, API auth
338 + * or input error) never retry and never purge.
192 339 *
193 - * On failure, retries up to METASYNC_OTTO_JOB_MAX_RETRIES times with 60s backoff.
194 - * After all retries are exhausted the failure is recorded via
195 - * metasync_record_failed_action() for surfacing in Site Health.
196 - *
197 340 * @param string $route Fully-qualified URL to process.
198 341 * @param int $retry_count Current retry attempt (0 = first run).
199 342 */
200 -function metasync_handle_otto_crawl_url_job($route, $retry_count = 0) {
343 +function metasync_handle_otto_crawl_url_job($route = '', $retry_count = 0) {
344 + // WP-Cron events persist independently of plugin code. A stale or malformed
345 + // event may therefore invoke this callback without its required route.
346 + // Docblock type isn't enforced at runtime; a stale cron record can pass a non-string.
347 + // @phpstan-ignore-next-line function.alreadyNarrowedType
348 + if (!is_string($route) || $route === '') {
349 + error_log('MetaSync OTTO: skipping crawl-url job without a valid route.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
350 + return;
351 + }
352 +
353 + $max_retries = defined('METASYNC_OTTO_JOB_MAX_RETRIES') ? METASYNC_OTTO_JOB_MAX_RETRIES : 3;
354 +
355 + # Shared retry/terminal bookkeeping so every failure path stays consistent.
356 + $schedule_retry = function ($reason) use ($route, $retry_count, $max_retries) {
357 + if ($retry_count < $max_retries) {
358 + # Exponential backoff (60s, 120s, 240s) plus jitter so a batch of
359 + # failures cannot stampede the API on the same tick.
360 + $delay = 60 * pow(2, $retry_count) + wp_rand(0, 15);
361 + $scheduled = metasync_otto_schedule_single_event(time() + $delay, 'metasync_process_otto_crawl_url_job', array($route, $retry_count + 1));
362 + if (!$scheduled && Metasync_Otto_Job_Status::enqueue_pending($route)) {
363 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', $retry_count + 1, $reason . '/cron_refused');
364 + } else {
365 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_RETRYING, 'job', $retry_count + 1, $reason);
366 + }
367 + error_log('MetaSync OTTO: retrying crawl-url job for ' . $route . ' (attempt ' . ($retry_count + 1) . '/' . $max_retries . ') reason=' . $reason . ' in ' . $delay . 's'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
368 + } else {
369 + metasync_record_failed_action('metasync_process_otto_crawl_url_job');
370 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'job', $retry_count, 'exhausted:' . $reason);
371 + error_log('MetaSync OTTO: background crawl-url job permanently failed for ' . $route . ' after ' . $max_retries . ' retries, last reason=' . $reason); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
372 + }
373 + };
374 +
201 375 try {
202 - # Step 1: Warm OTTO transient cache (fetch fresh suggestions from OTTO API into WP transient)
376 + # Not connected: retrying cannot help, and there is nothing to purge.
203 377 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
204 - if (!empty($otto_uuid)) {
205 - $transient_cache = new Metasync_Otto_Transient_Cache($otto_uuid);
206 - $transient_cache->warm_cache($route);
378 + # A corrupted option row can hold a non-string UUID; the docblock
379 + # contract alone does not enforce that at runtime.
380 + # @phpstan-ignore-next-line function.alreadyNarrowedType
381 + if (!is_string($otto_uuid) || $otto_uuid === '') {
382 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'job', $retry_count, 'not_connected');
383 + return;
207 384 }
208 385
386 + # Step 1: Warm OTTO transient cache (fetch fresh suggestions from OTTO
387 + # API into WP transient — the rate limiter, circuit breaker, and
388 + # request lock all live inside this call).
389 + $transient_cache = new Metasync_Otto_Transient_Cache($otto_uuid);
390 + $warmed = $transient_cache->warm_cache($route);
391 + if ($warmed === false) {
392 + # A 4xx refusal (bad key, revoked project) is permanent: retrying
393 + # cannot change the answer, so fail fast instead of burning the
394 + # attempt budget on warm calls that will keep being rejected.
395 + if ($transient_cache->last_failure_is_permanent()) {
396 + metasync_record_failed_action('metasync_process_otto_crawl_url_job');
397 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'job', $retry_count, 'permanent:warm_rejected');
398 + error_log('MetaSync OTTO: crawl-url job failed permanently for ' . $route . ' (warm request rejected by the API)'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
399 + return;
400 + }
401 + # Transient API failure (timeout / 5xx / breaker open). No meta
402 + # write, no purge — straight to the retry path.
403 + $schedule_retry('warm_failed');
404 + return;
405 + }
406 +
209 407 # Step 2: Write SEO post meta synchronously BEFORE the cache purge.
210 - # This ensures the DB is fully up-to-date when Kinsta (or any host) re-populates
211 - # the cache on the very next request — eliminating the race condition where the
212 - # 1-second scheduled job hadn't run yet and stale meta got cached.
213 - # allow_defer=false: do NOT reschedule a new metasync_process_seo_job cron
214 - # event from this synchronous path — the crawl_url_job retry mechanism already
215 - # handles failures, and rescheduling here caused an unbounded cron pile-up (WP-299).
216 - metasync_process_otto_seo_data($route, false);
408 + # This ensures the DB is fully up-to-date when the host re-populates
409 + # the cache on the very next request. The warmed suggestions are
410 + # passed through so the sync makes NO second API call.
411 + # allow_defer=false: the crawl_url_job retry mechanism owns failure
412 + # handling here; letting the sync self-reschedule caused an
413 + # unbounded cron pile-up.
414 + # warm_cache() may hand back a non-array truthy on contract drift;
415 + # the sync treats that as "no prefetched suggestions".
416 + # @phpstan-ignore-next-line function.alreadyNarrowedType
417 + $prefetched = is_array($warmed) ? $warmed : null;
418 + $outcome = metasync_process_otto_seo_data($route, false, 0, $prefetched);
217 419
218 - # Step 3: Clear the per-URL cache entry
420 + if ($outcome === Metasync_Otto_Job_Status::OUTCOME_RETRYABLE
421 + || $outcome === Metasync_Otto_Job_Status::OUTCOME_DEFERRED) {
422 + $schedule_retry('outcome_' . $outcome);
423 + return;
424 + }
425 +
426 + if ($outcome !== Metasync_Otto_Job_Status::OUTCOME_SUCCESS
427 + && $outcome !== Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE) {
428 + # Permanent failure: no retry, no purge — caches must keep serving
429 + # the last good content.
430 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'meta', $retry_count, 'permanent:' . $outcome);
431 + return;
432 + }
433 +
434 + # Step 3: meta is current (changed or confirmed unchanged) — clear the
435 + # per-URL host cache entry and re-warm it so OTTO-modified output is
436 + # what gets stored.
219 437 $otto_pixel = new Metasync_otto_pixel(false);
220 438 $otto_pixel->refresh_cache($route);
221 439 Metasync_Cache_Purge::purge_single_url($route);
440 + Metasync_Cache_Purge::warm_urls(array($route));
222 441
223 - } catch (Exception $e) {
224 - $max_retries = defined('METASYNC_OTTO_JOB_MAX_RETRIES') ? METASYNC_OTTO_JOB_MAX_RETRIES : 3;
442 + # A URL that completed on a retry missed its batch's edge purge (the
443 + # batch only waits so long). Late completers purge the edge themselves.
444 + if ($retry_count > 0) {
445 + Metasync_Edge_Cache_Purge::purge(array($route));
446 + }
225 447
226 - if ($retry_count < $max_retries) {
227 - # Schedule a retry with exponential backoff: 60s, 120s, 240s
228 - $delay = 60 * pow(2, $retry_count);
229 - wp_schedule_single_event(time() + $delay, 'metasync_process_otto_crawl_url_job', array($route, $retry_count + 1));
230 - error_log('MetaSync OTTO: retrying crawl-url job for ' . $route . ' (attempt ' . ($retry_count + 1) . '/' . $max_retries . ') in ' . $delay . 's: ' . $e->getMessage());
231 - } else {
232 - metasync_record_failed_action('metasync_process_otto_crawl_url_job');
233 - error_log('MetaSync OTTO: background crawl-url job permanently failed for ' . $route . ' after ' . $max_retries . ' retries: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
448 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_COMPLETED, 'job', $retry_count, $outcome);
449 +
450 + } catch (\Throwable $e) {
451 + # Cron must survive both Exception and PHP 7+ Error failures. Always use
452 + # the same bounded retry path, and emit structured context without the URL
453 + # payload or credentials. The retry is booked BEFORE the log attempt so a
454 + # failing logger can never swallow the retry; logging is best-effort.
455 + $schedule_retry('throwable:' . get_class($e));
456 + if (class_exists('Metasync_Error_Logger')) {
457 + try {
458 + Metasync_Error_Logger::log(
459 + Metasync_Error_Logger::CATEGORY_OTTO_RENDER,
460 + Metasync_Error_Logger::SEVERITY_ERROR,
461 + 'OTTO crawl-url job threw; retry policy applied',
462 + [
463 + 'operation' => 'crawl_url_job',
464 + 'outcome' => 'throwable',
465 + 'retry_count' => (int) $retry_count,
466 + 'exception' => get_class($e),
467 + ]
468 + );
469 + } catch (\Throwable $logger_error) {
470 + # Logging must never kill the handler after the retry is booked.
471 + error_log('MetaSync OTTO: crawl-url job error log failed: ' . $logger_error->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
472 + }
234 473 }
235 474 }
236 475 }
237 476
238 477 /**
239 - * Batch handler for cache warming and edge CDN purge.
478 + * Batch handler for the edge CDN purge.
240 479 *
241 - * Runs after all per-URL jobs have had time to complete. Batching these
242 - * operations avoids N separate API calls to edge providers (Cloudflare
243 - * supports up to 30 tags per request) and lets warm_urls() issue concurrent
244 - * fire-and-forget requests.
480 + * Runs after all per-URL jobs have had time to complete. Per-URL host caches
481 + * are cleared and re-warmed by the individual jobs; this batched event only
482 + * handles the edge CDN purge, which benefits from multi-URL API calls
483 + * (Cloudflare, Fastly, Akamai, Sucuri, Sevella, etc.).
245 484 *
246 - * @param array $routes List of fully-qualified URLs to warm and purge.
485 + * URLs still retrying are stragglers: the batch waits (up to
486 + * METASYNC_OTTO_BATCH_MAX_WAITS reschedules 60s apart) so their freshly
487 + * synced content — not stale content — reaches the edge. URLs that complete
488 + * after the wait budget purge the edge themselves from the retry path.
489 + *
490 + * @param array $routes List of fully-qualified URLs in this batch.
491 + * @param int $wait_count How many times this batch has already waited.
247 492 */
248 -function metasync_handle_otto_batch_cache_job($routes) {
493 +function metasync_handle_otto_batch_cache_job($routes = array(), $wait_count = 0) {
494 + // WP-Cron events persist independently of plugin code. A stale or malformed
495 + // event may therefore invoke this callback without its required route list.
496 + // Docblock type isn't enforced at runtime; a stale cron record can pass a non-array.
497 + // @phpstan-ignore-next-line function.alreadyNarrowedType
498 + if (!is_array($routes) || empty($routes)) {
499 + error_log('MetaSync OTTO: skipping batch cache job without valid routes.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
500 + return;
501 + }
502 +
503 + $max_waits = defined('METASYNC_OTTO_BATCH_MAX_WAITS') ? METASYNC_OTTO_BATCH_MAX_WAITS : 3;
504 + // Docblock type isn't enforced at runtime; a stale cron record can pass a non-numeric.
505 + // @phpstan-ignore-next-line function.alreadyNarrowedType
506 + $wait_count = is_numeric($wait_count) ? (int) $wait_count : 0;
507 +
508 + # Split the batch: terminally-done URLs vs. URLs with work still in
509 + # flight. Unknown-state URLs (legacy events scheduled before the status
510 + # store existed, or deduped duplicates whose entry expired) count as done
511 + # — their per-URL job has either finished or never will, and both mean
512 + # the edge purge should not stall behind them.
513 + $ready = array();
514 + $stragglers = 0;
515 + foreach ($routes as $route) {
516 + $entry = Metasync_Otto_Job_Status::get($route);
517 + $state = is_array($entry) && isset($entry['state']) ? $entry['state'] : null;
518 + if ($state === Metasync_Otto_Job_Status::STATE_RETRYING
519 + || $state === Metasync_Otto_Job_Status::STATE_ACCEPTED) {
520 + $stragglers++;
521 + } else {
522 + # completed / failed / unknown: nothing left to wait for.
523 + $ready[] = $route;
524 + }
525 + }
526 +
527 + # Still live work in the batch and wait budget left: hold the edge purge.
528 + if ($stragglers > 0 && $wait_count < $max_waits) {
529 + metasync_otto_schedule_single_event(time() + 60, 'metasync_process_otto_batch_cache_job', array($routes, $wait_count + 1));
530 + return;
531 + }
532 +
533 + if (empty($ready)) {
534 + return;
535 + }
536 +
249 537 try {
250 - # Re-populate the cache so OTTO-modified output is what gets stored.
251 - # Per-URL host cache was already cleared by individual jobs.
252 - # warm_urls() hits each URL with a non-blocking request so our code —
253 - # with fresh transients and post meta — is first to populate the host cache.
254 - Metasync_Cache_Purge::warm_urls($routes);
255 -
256 538 # Purge edge CDN caches (Cloudflare, Fastly, Akamai, Sucuri, Sevalla, etc.)
257 539 # Tag-based providers purge only the affected posts; full-flush providers fire once per batch.
258 - Metasync_Edge_Cache_Purge::purge($routes);
540 + Metasync_Edge_Cache_Purge::purge($ready);
259 541 } catch (Exception $e) {
260 542 metasync_record_failed_action('metasync_process_otto_batch_cache_job');
261 - error_log('MetaSync OTTO: batch cache job failed for ' . count($routes) . ' URLs: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
543 + error_log('MetaSync OTTO: batch cache job failed for ' . count($ready) . ' URLs: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
262 544 }
263 545 }
264 546
265 -# NOTE: Cache system removed - these functions are no longer needed
266 -# Kept for backward compatibility in case old cache directories need cleanup
267 -function metasync_deleteDir($dir) {
268 - if (!is_dir($dir)) {
269 - return false;
547 +/**
548 + * Drainer: work off URLs that could not be scheduled inline (batch overflow
549 + * or a cron that refused the event) from the durable pending queue.
550 + *
551 + * Processes at most 25 URLs per run to stay friendly to WP-Cron, schedules
552 + * the same batched edge purge as the webhook path for the chunk it handled,
553 + * and reschedules itself while the queue is non-empty. URLs it takes off
554 + * the queue are scheduled unconditionally unless a real cron event already
555 + * exists for them — the webhook's overflow bookkeeping must never veto,
556 + * because by then the URL has already left the durable queue.
557 + */
558 +function metasync_handle_otto_pending_drainer() {
559 + $chunk = Metasync_Otto_Job_Status::take_pending(25);
560 + if (empty($chunk)) {
561 + return;
270 562 }
271 - $files = array_diff(scandir($dir), array('.', '..'));
272 - foreach ($files as $file) {
273 - $filePath = $dir . DIRECTORY_SEPARATOR . $file;
274 - if (is_dir($filePath)) {
275 - metasync_deleteDir($filePath);
563 +
564 + $now = time();
565 + $scheduled = 0;
566 + foreach ($chunk as $key => $route) {
567 + # This run owns the chunk: take_pending() already removed these URLs
568 + # from the durable queue, so skipping here would drop them. Only a
569 + # real cron event vetoes — the webhook's overflow ledger row must
570 + # not (see metasync_otto_job_event_pending).
571 + if (metasync_otto_job_event_pending($route)) {
572 + continue;
573 + }
574 + if (metasync_otto_schedule_single_event($now + $key, 'metasync_process_otto_crawl_url_job', array($route))) {
575 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'drain', 0, '');
576 + $scheduled++;
276 577 } else {
277 - unlink($filePath);
578 + # Cron still refusing: put it back at the end of the queue.
579 + Metasync_Otto_Job_Status::enqueue_pending($route);
278 580 }
279 581 }
280 - return rmdir($dir);
582 +
583 + if ($scheduled > 0) {
584 + metasync_otto_schedule_single_event($now + count($chunk) + 5, 'metasync_process_otto_batch_cache_job', array($chunk));
585 + }
586 +
587 + if (Metasync_Otto_Job_Status::pending_count() > 0) {
588 + metasync_otto_schedule_single_event($now + 60, 'metasync_otto_pending_drainer', array());
589 + }
281 590 }
282 591
283 -# Cleanup function for removing old cache directories (if they exist)
284 -function metasync_invalidate_all_caches($folder = ''){
285 - # Cache system removed - this function only exists to clean up old cache directories
286 - if(!defined('WP_CONTENT_DIR')){
287 - return false;
592 +// metasync_is_custom_or_lps_page() now lives in includes/metasync-helpers.php
593 +// (loaded unconditionally before this file) so all SEO surfaces share one rule.
594 +
595 +/**
596 + * Detect an Elementor editor / preview request that OTTO must never process.
597 + *
598 + * Elementor renders its editing canvas in a front-end <iframe> — a logged-in
599 + * GET request that is NOT is_admin(), so the admin/ajax/REST guards in
600 + * metasync_start_otto() do not catch it. If OTTO output-buffers and rewrites
601 + * that response (SimpleHtmlDom in Otto_html_class), the markup the editor's
602 + * JavaScript expects is altered and the canvas stays stuck on "loading" —
603 + * the editor never finishes initialising. The editor surface is for authoring
604 + * only; it is never crawled, so there is nothing for OTTO to optimise there.
605 + *
606 + * Locally this is masked because the OTTO API is unreachable (API_ERROR ⇒ no
607 + * suggestions ⇒ no rewrite); on a live site with real suggestions the rewrite
608 + * happens and the editor breaks. See the "Elementor editor fails to load while
609 + * the plugin is active" report.
610 + *
611 + * @return bool True when the current request is an Elementor editor/preview.
612 + */
613 +function metasync_is_elementor_editor_request() {
614 + # The preview iframe loads the front end with ?elementor-preview=POST_ID.
615 + # This is the request OTTO would otherwise buffer and corrupt.
616 + if (isset($_GET['elementor-preview'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
617 + return true;
288 618 }
289 - $wp_content_dir = WP_CONTENT_DIR;
290 - $cache_dir = $wp_content_dir . '/metasync_caches';
291 - if(in_array($folder, ['posts', 'pages'])){
292 - $cache_dir = $cache_dir . '/' . $folder;
619 +
620 + # Elementor editor / app entry points carried as an action on the front end.
621 + if (isset($_REQUEST['action']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
622 + && in_array($_REQUEST['action'], array('elementor', 'elementor_ajax'), true) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
623 + ) {
624 + return true;
293 625 }
294 - if(is_dir($cache_dir)){
295 - metasync_deleteDir($cache_dir);
626 +
627 + # Authoritative check when Elementor is loaded: its own preview-mode flag.
628 + if (class_exists('\Elementor\Plugin')
629 + && isset(\Elementor\Plugin::$instance->preview)
630 + && is_object(\Elementor\Plugin::$instance->preview)
631 + && method_exists(\Elementor\Plugin::$instance->preview, 'is_preview_mode')
632 + && \Elementor\Plugin::$instance->preview->is_preview_mode()
633 + ) {
634 + return true;
296 635 }
636 +
637 + return false;
297 638 }
298 639
299 640 function metasync_start_otto(){
300 641
@@ -307,13 +648,97 @@
307 648 # SSR is now ENABLED for: single products, product categories, product tags
308 649 # SSR is SKIPPED for: cart, checkout
309 650 # Note: title/description filters (pre_get_document_title, wp_head meta desc)
310 651 # run on ALL pages regardless — they are hooked unconditionally in seo-functions.php
652 +
653 + # ── WooCommerce-independent cart/checkout protection ──────────
654 + # The is_cart()/is_checkout() guards below only recognize WooCommerce. Carts
655 + # from other systems — e.g. the Point of Rental "Catalog" plugin on
656 + # venturarental.com — are invisible to them, so OTTO would process those
657 + # pages and let caching layers store them. A cached cart page corrupts the
658 + # live cart (added items vanish because a stale page is served). The checks
659 + # here do not depend on WooCommerce being active.
660 +
661 + # Never run OTTO on non-GET requests. SSR exists for crawlers, which only
662 + # issue GET; POST/PUT/etc. are form or cart submissions that must pass
663 + # through untouched. (OTTO's own internal fetches use GET.)
664 + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
665 + return;
666 + }
667 +
668 + # Never run OTTO on WordPress core draft/pending previews
669 + # (?p=<ID>&preview=true, with preview_id/preview_nonce on the full link).
670 + # Previews are per-user, non-cacheable, and routed purely by query string —
671 + # and OTTO's route identity ignores the query string, so a processed preview
672 + # is answered with the route's public page (the homepage for /?p=…) instead
673 + # of the draft the editor asked for. Skip early and let WP render it.
311 674 if (
675 + (isset($_GET['preview']) && $_GET['preview'] === 'true') || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
676 + !empty($_GET['preview_id']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
677 + ) {
678 + if (!defined('DONOTCACHEPAGE')) {
679 + define('DONOTCACHEPAGE', true);
680 + }
681 + return;
682 + }
683 +
684 + # Never run OTTO on the Elementor editor/preview iframe. It is a logged-in
685 + # front-end GET (not is_admin()), so it slips past the admin guard below;
686 + # buffering and rewriting it breaks the editor canvas ("fails to load").
687 + if (metasync_is_elementor_editor_request()) {
688 + return;
689 + }
690 +
691 + # WP core file-editor self-check. When an admin saves a THEME file
692 + # via the Plugin/Theme Editor, wp_edit_theme_plugin_file() fires an internal
693 + # loopback GET to home_url('/') carrying wp_scrape_key/wp_scrape_nonce to
694 + # detect a white-screen. That request is NOT is_admin(), so — exactly like
695 + # the Elementor case above — it slips past the admin/AJAX/REST guards below.
696 + # OTTO must not output-buffer or rewrite it: the SimpleHtmlDom pass (and any
697 + # fatal thrown inside the ob_start() callback, e.g. under memory pressure)
698 + # corrupts the scrape and surfaces as the misleading
699 + # "preg_match(): Cannot use output buffering in output buffering display
700 + # handlers" fatal. Skipping OTTO lets WP render the page normally so the
701 + # scrape works. Returning here also avoids the HTTP-fallback path firing a
702 + # second loopback for an already-internal self-request.
703 + if (metasync_is_scrape_request()) {
704 + return;
705 + }
706 +
707 + # Skip OTTO on cart/checkout-type paths regardless of the cart plugin, and
708 + # signal caching plugins (WP Rocket/Kinsta/etc.) to never cache them — a
709 + # cached cart/checkout page is precisely what corrupts cart state.
710 + # Matched against the FIRST path segment (relative to the WP home path) so
711 + # subdirectory installs (/shop/cart) still work, while unrelated content pages
712 + # like /blog/cart or /features/checkout do NOT false-match.
713 + # Drop the query string with explode() (no shared internal pointer like strtok),
714 + # then lower-case and strip surrounding slashes.
715 + $otto_req_path = explode('?', (string) wp_unslash($_SERVER['REQUEST_URI'] ?? ''), 2)[0];
716 + $otto_req_path = strtolower(trim($otto_req_path, '/'));
717 + # Strip the site's base path so first-segment matching works on subdir installs.
718 + $otto_home_path = trim((string) wp_parse_url(home_url(), PHP_URL_PATH), '/');
719 + if ($otto_home_path !== '' && strpos($otto_req_path, $otto_home_path . '/') === 0) {
720 + $otto_req_path = substr($otto_req_path, strlen($otto_home_path) + 1);
721 + }
722 + $otto_first_segment = explode('/', $otto_req_path, 2)[0];
723 + # Filterable so sites can add/remove transactional slugs without code changes.
724 + $otto_cart_segments = apply_filters('metasync_otto_cart_paths', array(
725 + 'cart', 'checkout', 'basket', 'request-a-quote', 'quote-request',
726 + ));
727 + $otto_cart_segments = array_map('strtolower', (array) $otto_cart_segments);
728 + if ($otto_first_segment !== '' && in_array($otto_first_segment, $otto_cart_segments, true)) {
729 + if (!defined('DONOTCACHEPAGE')) {
730 + define('DONOTCACHEPAGE', true);
731 + }
732 + return;
733 + }
734 + # ──────────────────────────────────────────────────────────────────────
735 +
736 + if (
312 737 # disable ajax calls
313 - isset($_GET['ucfrontajaxaction']) ||
738 + isset($_GET['ucfrontajaxaction']) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
314 739 # OTTO Preview mode - skip OTTO when previewing original content
315 - (isset($_GET['otto_preview']) && $_GET['otto_preview'] === '1') ||
740 + (isset($_GET['otto_preview']) && $_GET['otto_preview'] === '1') || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
316 741 # WooCommerce shop archive page only (products and categories now use SSR)
317 742 //(function_exists('is_shop') && is_shop()) ||
318 743 # Cart page
319 744 (function_exists('is_cart') && is_cart()) ||
@@ -325,32 +750,32 @@
325 750 (function_exists('wp_doing_ajax') && wp_doing_ajax()) ||
326 751 # check by constant
327 752 (defined('DOING_AJAX') && DOING_AJAX) ||
328 753 # WooCommerce AJAX endpoint (e.g., ?wc-ajax=update_cart)
329 - (isset($_REQUEST['wc-ajax']) && !empty($_REQUEST['wc-ajax'])) ||
754 + (isset($_REQUEST['wc-ajax']) && !empty($_REQUEST['wc-ajax'])) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
330 755 # AJAX requests via X-Requested-With header
331 756 (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') ||
332 757 # Gravity Forms submission detection - skip OTTO to allow form processing
333 - (isset($_POST['gform_submit']) && (
334 - is_array($_POST['gform_submit']) ||
335 - (is_string($_POST['gform_submit']) && isset($_POST['is_submit_' . $_POST['gform_submit']]) && !empty($_POST['gform_submit']))
758 + (isset($_POST['gform_submit']) && ( // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
759 + is_array($_POST['gform_submit']) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
760 + (is_string($_POST['gform_submit']) && isset($_POST['is_submit_' . $_POST['gform_submit']]) && !empty($_POST['gform_submit'])) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
336 761 )) ||
337 762 # Gravity Forms AJAX submission
338 - (isset($_POST['gform_ajax']) && isset($_POST['gform_submit']) && (
339 - is_array($_POST['gform_submit']) || !empty($_POST['gform_submit'])
763 + (isset($_POST['gform_ajax']) && isset($_POST['gform_submit']) && ( // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
764 + is_array($_POST['gform_submit']) || !empty($_POST['gform_submit']) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
340 765 )) ||
341 766 # Gravity Forms file upload
342 - (isset($_POST['gform_uploaded_files'])) ||
767 + (isset($_POST['gform_uploaded_files'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
343 768 # Any Gravity Forms POST parameter
344 - (isset($_POST['gform_submit']) || isset($_POST['gform_unique_id']) || isset($_POST['gform_field_values'])) ||
769 + (isset($_POST['gform_submit']) || isset($_POST['gform_unique_id']) || isset($_POST['gform_field_values'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
345 770 # Formidable Forms AJAX submission detection - skip OTTO to allow form processing
346 - (isset($_POST['action']) && $_POST['action'] === 'frm_entries_create') ||
771 + (isset($_POST['action']) && $_POST['action'] === 'frm_entries_create') || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
347 772 # Formidable Forms POST parameters
348 - (isset($_POST['form_id']) && !empty($_POST['form_id'])) ||
773 + (isset($_POST['form_id']) && !empty($_POST['form_id'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
349 774 # Formidable Forms action parameter
350 - (isset($_POST['frm_action']) && !empty($_POST['frm_action'])) ||
775 + (isset($_POST['frm_action']) && !empty($_POST['frm_action'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
351 776 # Formidable Forms item_key (used in form submissions)
352 - (isset($_POST['item_key']) && !empty($_POST['item_key']))
777 + (isset($_POST['item_key']) && !empty($_POST['item_key'])) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
353 778 ) {
354 779 return;
355 780 }
356 781
@@ -379,8 +804,39 @@
379 804 if (metasync_is_otto_url_manually_excluded($current_url)) {
380 805 return;
381 806 }
382 807
808 + # Skip OTTO for XML endpoints (sitemaps, RSS-as-xml, etc.). OTTO has no
809 + # suggestions for machine-readable XML, and the upstream API returns
810 + # API_ERROR for these URLs — which then stamps misleading
811 + # X-MetaSync-OTTO-Cache: API_ERROR / X-MetaSync-OTTO-Method: NONE headers
812 + # onto otherwise-healthy sitemap responses and causes false-alarm bug
813 + # reports from customers.
814 + $request_path = strtok($request_uri, '?');
815 + if ($request_path && preg_match('#\.xml$#i', $request_path)) {
816 + return;
817 + }
818 +
819 + # Machine-readable core endpoints. Using WordPress's own conditionals rather
820 + # than matching the path: they are exact, and a path pattern here would also
821 + # match ordinary content whose slug merely starts with the same characters
822 + # (e.g. /robots.txt-explained/, /favicon.ico-vs-svg/).
823 + # is_favicon() is WP 5.4+; this plugin supports 5.2.
824 + if (is_robots() || (function_exists('is_favicon') && is_favicon())) {
825 + return;
826 + }
827 +
828 + # Protocol paths and static assets that are never OTTO-able content.
829 + # The guard fails open — if the strategy is unavailable we let the request
830 + # proceed rather than fatal. metasync_otto_class_provides() rather than a
831 + # bare class_exists(): a partially updated install can leave an older copy of
832 + # the strategy class loaded that predates is_non_content_path(), which
833 + # satisfies class_exists() and then fatals on every front-end request.
834 + if (metasync_otto_class_provides('Metasync_Otto_Render_Strategy', 'is_non_content_path')
835 + && Metasync_Otto_Render_Strategy::is_non_content_path($request_path)) {
836 + return;
837 + }
838 +
383 839 # BOT DETECTION: Always detect bots so crawl data reaches the SA backend.
384 840 # Real bots don't execute JS, so otto-tracker.js never fires for them.
385 841 # push_crawl_log_to_sa() fires a non-blocking wp_remote_post here instead.
386 842 $bot_detector = Metasync_Otto_Bot_Detector::get_instance();
@@ -388,8 +844,85 @@
388 844 if ( $detection['is_bot'] ) {
389 845 $bot_detector->push_crawl_log_to_sa( $detection, $current_url );
390 846 }
391 847
848 + # A headless frontend renders the public HTML, so stop after the cheap cache
849 + # protections and crawl telemetry, before any OTTO rendering work begins.
850 + #
851 + # Routed through metasync_headless_is_active() rather than calling the class
852 + # directly: Metasync_Headless_Config resolves only through the committed
853 + # Composer classmap, so a partially updated install can leave this newer
854 + # caller beside a missing or older copy with no fallback lookup path. This
855 + # function is hooked to `wp`, so an unguarded static call is a white screen
856 + # on every front-end page view. The helper treats an unresolvable class as
857 + # "not headless", which keeps OTTO behaving as it did before headless mode.
858 + # @phpstan-ignore-next-line function.alreadyNarrowedType
859 + $metasync_is_headless = function_exists('metasync_headless_is_active')
860 + ? metasync_headless_is_active()
861 + : (class_exists('Metasync_Headless_Config') && Metasync_Headless_Config::is_active());
862 +
863 + if ($metasync_is_headless) {
864 + return;
865 + }
866 +
867 + # Throttle OTTO rendering for bots that are not exempt: at most one render
868 + # per URL+bot every 5 minutes. Humans, search engines (Googlebot, Bingbot,
869 + # Yahoo's Slurp, Sogou, Exabot, Applebot, ...), host cache warmers and
870 + # synthetic performance auditors are never throttled — their hits always get
871 + # full OTTO content, so indexing stays current, the page cache can actually
872 + # be filled, and audit tools measure what a real visitor sees. SEO tools,
873 + # social crawlers, archivers, AI scrapers, uptime monitors and unrecognized
874 + # crawlers are de-duplicated so repeat hits don't re-trigger the expensive
875 + # DOM-rewriting path.
876 + #
877 + # Two complementary gates, deliberately kept separate:
878 + # - is_unthrottled_category() consults the classifier's explicit per-bot
879 + # category map, so the exempt set is defined in exactly one place.
880 + # - metasync_otto_is_unthrottled_infrastructure_agent() matches the raw
881 + # user-agent (plus a query marker and a site filter), which catches
882 + # preload agents that have no entry in the map at all.
883 + if ( $detection['is_bot']
884 + && ! Metasync_Otto_Bot_Detector::is_unthrottled_category( $detection['bot_type'] )
885 + && ! metasync_otto_is_unthrottled_infrastructure_agent( $detection ) ) {
886 + $normalized_url = strtok( $current_url, '?' );
887 + if ( $normalized_url === false ) {
888 + $normalized_url = $current_url;
889 + }
890 + // Lowercase + length cap so attacker-controlled bot_name variations
891 + // (EvilBota, EvilBotB, EvilBotc, ...) collapse to a bounded key space
892 + // and cannot flood wp_options with unique transient rows.
893 + $bot_name = substr( strtolower( $detection['bot_name'] ?? 'unknown' ), 0, 32 );
894 + $render_throttle_key = 'metasync_otto_rendered_' . md5( $normalized_url . '|' . $bot_name );
895 + if ( get_transient( $render_throttle_key ) ) {
896 + // Prevent page caches (WP Rocket, W3TC, etc.) from saving the
897 + // un-OTTO'd response and serving it to subsequent human visitors.
898 + if ( ! defined( 'DONOTCACHEPAGE' ) ) {
899 + define( 'DONOTCACHEPAGE', true );
900 + }
901 + return;
902 + }
903 + set_transient( $render_throttle_key, 1, 5 * MINUTE_IN_SECONDS );
904 + }
905 +
906 + # Skip OTTO on 404s.
907 + #
908 + # OTTO suggestions are keyed to real, crawled pages. Asking the API about a URL
909 + # that returns 404 spends a full API_TIMEOUT of PHP-FPM worker time on a
910 + # guaranteed-empty answer. Not theoretical: in one production sample, 21 of 54
911 + # crawler requests were 404s on URLs that never existed on the site, each
912 + # costing ~2s of a worker.
913 + #
914 + # Deliberately placed AFTER bot detection so push_crawl_log_to_sa() still
915 + # reports the crawl to SearchAtlas — that telemetry is how the crawler side
916 + # can learn to stop requesting these URLs. Only the expensive suggestions
917 + # lookup is skipped.
918 + #
919 + # Filterable because "should a 404 page get OTTO treatment" is arguably a site
920 + # decision, even though there is no known case where it should.
921 + if (is_404() && apply_filters('metasync_otto_skip_on_404', true)) {
922 + return;
923 + }
924 +
392 925 # Optionally skip OTTO processing for bot traffic (when the setting is enabled)
393 926 if ($bot_detector->should_skip_otto()) {
394 927 // Log the bot locally and count the saved API call
395 928 $bot_detector->log_detection($detection);
@@ -400,9 +933,9 @@
400 933 return;
401 934 }
402 935
403 936 # OPTIMIZED: Check if Otto should be disabled for WP Rocket compatibility
404 - if (class_exists('WP_Rocket')) {
937 + if (defined('WP_ROCKET_VERSION') || class_exists('WP_Rocket')) {
405 938 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
406 939
407 940 if ($wp_rocket_compat_mode === 'disable_otto') {
408 941 return; # Exit early, Otto is disabled when WP Rocket is active
@@ -408,13 +941,55 @@
408 941 return; # Exit early, Otto is disabled when WP Rocket is active
409 942 }
410 943 }
411 944
945 + # Skip OTTO for Divi AJAX pagination, and for paginated archive requests
946 + # on Divi-built sites.
947 + # ?et_blog = Divi AJAX pagination callback — always skipped, on every
948 + # theme, because Divi fires it regardless of the site layout.
949 + # /page/N/ = paginated blog/archive pages — OTTO's buffer/HTTP render causes
950 + # module numbering mismatch between page 1 (with TB template) and page N
951 + # (without TB template), breaking Divi's JS pagination selector matching.
952 + # That mismatch is a Divi rendering bug, so the /page/N/ skip is gated on
953 + # Divi detection: non-Divi sites (Elementor + the7, ...) keep OTTO on real
954 + # paginated archives. Detection covers the Divi theme and its child themes
955 + # (get_template() returns the PARENT template dir, "Divi" either way), the
956 + # Extra theme and the Divi Builder plugin (both define ET_BUILDER_VERSION
957 + # / et_setup_theme — same TB machinery, same bug).
958 + $is_divi = defined('ET_BUILDER_VERSION')
959 + || function_exists('et_setup_theme')
960 + || wp_get_theme()->get_template() === 'Divi';
961 + if (isset($_GET['et_blog']) || ($is_divi && is_paged() && !is_singular())) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
962 + return;
963 + }
964 +
965 + # Skip OTTO on search-results pages. A search request carries its
966 + # meaning entirely in the query string (e.g. /?s=term, or FiboSearch's
967 + # /?s=term&post_type=product&dgwt_wcas=1). get_route() intentionally strips
968 + # the query string to build a canonical, cache-key-stable route — which
969 + # collapses a query-only search URL to the site root ('/'). OTTO then finds
970 + # the HOME page's suggestions for that route and, on the HTTP render path,
971 + # serves the home page in place of the search results (the address bar keeps
972 + # the search URL — it is a silent content swap, not a redirect). This only
973 + # surfaces for logged-in / uncached visitors, since page caches serve the
974 + # correct pre-rendered search page to everyone else. Search-results pages
975 + # have no per-canonical-URL OTTO suggestions of their own, so OTTO must never
976 + # run here. The raw $_GET['s'] check is a defensive fallback for setups where
977 + # the main query is altered before this point.
978 + if (is_search() || !empty($_GET['s'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
979 + return;
980 + }
981 +
412 982 # Handle cache plugin compatibility early - before any caching happens
413 983 metasync_otto_handle_cache_compatibility();
414 984
415 985 # check if OTTO is disabled for this specific page/post
416 - $post_id = get_the_ID();
986 + # Gate on is_singular() for the same reason the custom/LPS check below does:
987 + # on an archive, search or term query get_the_ID() returns the first post of
988 + # the loop — WP::register_globals() seeds $GLOBALS['post'] with it — not the
989 + # page being rendered. Without the gate, one excluded or per-page-disabled
990 + # post silently takes OTTO off every archive that happens to list it first.
991 + $post_id = is_singular() ? ( get_queried_object_id() ?: get_the_ID() ) : 0;
417 992 if ($post_id && class_exists('Metasync_Otto_Frontend_Toolbar')) {
418 993 if (Metasync_Otto_Frontend_Toolbar::is_otto_disabled($post_id)) {
419 994 return;
420 995 }
@@ -419,16 +994,51 @@
419 994 return;
420 995 }
421 996 }
422 997
998 + # Skip OTTO on MetaSync custom HTML / LPS-imported pages — they ship their
999 + # own complete, self-contained SEO and OTTO must not inject or overwrite it
1000 + # with a different/older project's SEO. Resolve the queried object
1001 + # id (with get_the_ID() fallback) so the static-front-page case — an LPS home
1002 + # set as the WP front page, where is_page() is false — is still detected.
1003 + # Applying the skip here, upstream of the single render_route_html() entry,
1004 + # covers all three render paths (Rocket buffer, output buffer, HTTP fallback).
1005 + # Only singular views (posts/pages, incl. a static front page) can be a
1006 + # custom/LPS page; gate on is_singular() so an archive/search/term query
1007 + # can never have its object id mistaken for a custom page's post id.
1008 + $custom_page_id = is_singular() ? ( get_queried_object_id() ?: get_the_ID() ) : 0;
1009 + if (metasync_is_custom_or_lps_page($custom_page_id)) {
1010 + if (!headers_sent() && Metasync_Otto_Render_Strategy::diagnostics_enabled()) {
1011 + header('X-MetaSync-OTTO-Method: EXCLUDED');
1012 + }
1013 + return;
1014 + }
1015 +
423 1016 # check if we are having an otto request
424 - if(!empty($_GET['is_otto_page_fetch'])){
1017 + #
1018 + # Recognise our own internal fetch by header/User-Agent as well as by the
1019 + # query parameter. A redirect that rebuilds the URL strips the parameter,
1020 + # and without this the redirected request re-enters OTTO and fires another
1021 + # internal fetch - an unbounded self-request loop.
1022 + # method_exists() is redundant to static analysis - this MR adds the method,
1023 + # so PHPStan proves the call always true. It is kept for the upgrade window:
1024 + # during a plugin update an opcache can still hold the previous
1025 + # Metasync_Otto_Render_Strategy, where class_exists() passes but the method
1026 + # is absent, and an unguarded static call would fatal the page.
1027 + # @phpstan-ignore-next-line function.alreadyNarrowedType
1028 + $otto_has_fetch_detector = class_exists('Metasync_Otto_Render_Strategy') && method_exists('Metasync_Otto_Render_Strategy', 'is_internal_fetch');
425 1029
1030 + $otto_is_internal_fetch = $otto_has_fetch_detector
1031 + ? Metasync_Otto_Render_Strategy::is_internal_fetch()
1032 + : !empty($_GET['is_otto_page_fetch']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only loopback marker, request-scoped filters only
1033 +
1034 + if($otto_is_internal_fetch){
1035 +
426 1036 # Block SEO plugins NOW for this internal fetch request
427 1037 # metasync_otto_block_seo_plugins();
428 1038 # $_SERVER['REQUEST_URI'] = remove_query_arg('is_otto_page_fetch', $_SERVER['REQUEST_URI']);
429 - $block_title = !empty($_GET['otto_block_title']) && $_GET['otto_block_title'] === '1';
430 - $block_description = !empty($_GET['otto_block_desc']) && $_GET['otto_block_desc'] === '1';
1039 + $block_title = !empty($_GET['otto_block_title']) && $_GET['otto_block_title'] === '1'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only loopback marker, request-scoped filters only
1040 + $block_description = !empty($_GET['otto_block_desc']) && $_GET['otto_block_desc'] === '1'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only loopback marker, request-scoped filters only
431 1041
432 1042 # Block SEO plugins conditionally based on what Otto has
433 1043 if ($block_title || $block_description) {
434 1044 metasync_otto_block_seo_plugins($block_title, $block_description);
@@ -464,8 +1074,19 @@
464 1074
465 1075 # get the otto uuid
466 1076 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
467 1077
1078 + # Upgrade-window guard: the bundled DOM parser resolves only through the
1079 + # otto/vendor autoloader required at the top of this file. While an update
1080 + # is mid-copy (or a stale opcache classmap is still resident), that
1081 + # dependency can briefly be undefined here, and constructing the render
1082 + # stack below would throw a class-not-found Error and take the whole page
1083 + # down with it. Degrade to an OTTO-less page for this request instead —
1084 + # the next request, on the settled tree, renders normally.
1085 + if ( ! class_exists( 'simplehtmldom\HtmlDocument' ) ) {
1086 + return;
1087 + }
1088 +
468 1089 # start the class
469 1090 $otto = new Metasync_otto_pixel($otto_uuid);
470 1091
471 1092 # call render
@@ -477,12 +1098,13 @@
477 1098 * Controls DONOTCACHEPAGE constant based on active plugins and configuration
478 1099 * This function is called early in the WordPress lifecycle
479 1100 */
480 1101 function metasync_otto_handle_cache_compatibility() {
481 - # Detect active plugins
1102 + # Detect active plugins. SG Optimizer detection now lives in
1103 + # metasync_otto_disable_sg_page_cache(), which is invoked later
1104 + # once OTTO confirms suggestions for the URL.
482 1105 $brizy_active = class_exists('Brizy_Editor') || defined('BRIZY_VERSION');
483 - $wp_rocket_active = class_exists('WP_Rocket');
484 - $sg_optimizer_active = is_plugin_active('sg-cachepress/sg-cachepress.php');
1106 + $wp_rocket_active = defined('WP_ROCKET_VERSION') || class_exists('WP_Rocket');
485 1107
486 1108 # OPTIMIZED: Get configuration option using cached config
487 1109 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
488 1110
@@ -496,9 +1118,9 @@
496 1118 if ($cached !== false) {
497 1119 $has_brizy_posts = ($cached === 'yes');
498 1120 } else {
499 1121 # Query database only if cache missed
500 - $has_brizy_posts = $wpdb->get_var(
1122 + $has_brizy_posts = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- feature-detection probe for Brizy posts — postmeta flag scan has no WordPress API
501 1123 "SELECT COUNT(*) FROM {$wpdb->postmeta}
502 1124 WHERE meta_key = 'brizy_post_uid'
503 1125 AND meta_value != ''
504 1126 LIMIT 1"
@@ -512,51 +1134,383 @@
512 1134
513 1135 # Determine if DONOTCACHEPAGE should be set
514 1136 $should_set_donotcachepage = false;
515 1137
516 - # Case 1: Brizy is active with posts - always needed
1138 + # Case 1: Brizy is active with posts - always needed. Brizy pages are
1139 + # dynamically rendered regardless of OTTO, so caching must be disabled
1140 + # whenever Brizy posts exist.
517 1141 if ($brizy_active && !empty($has_brizy_posts)) {
518 1142 $should_set_donotcachepage = true;
519 1143 }
520 1144
521 - # Case 2: SG Optimizer without WP Rocket - prevent conflicts
522 - elseif ($sg_optimizer_active && !$wp_rocket_active) {
523 - $should_set_donotcachepage = true;
524 - }
525 -
526 - # Case 3: User explicitly disabled Otto for WP Rocket compatibility
1145 + # Case 2: User explicitly disabled Otto for WP Rocket compatibility
527 1146 elseif ($wp_rocket_active && $wp_rocket_compat_mode === 'disable_otto') {
528 1147 $should_set_donotcachepage = true;
529 1148 return; # Exit early, Otto won't run
530 1149 }
531 1150
532 - # Case 4: WP Rocket active with auto/buffer mode - DON'T set DONOTCACHEPAGE
1151 + # Case 3: WP Rocket active with auto/buffer mode - DON'T set DONOTCACHEPAGE
533 1152 # This allows WP Rocket optimizations to continue working
534 1153
1154 + # NOTE: SiteGround SG Optimizer cache bypass is intentionally NOT
1155 + # handled here. Emitting no-cache headers / disabling SG cache on this hook
1156 + # fired on EVERY front-end page — including pages OTTO never modifies —
1157 + # forcing SG to bypass its page cache site-wide and spiking CPU. The SG
1158 + # bypass is now deferred to metasync_otto_disable_sg_page_cache(), called
1159 + # from render_route_html() only after OTTO confirms it has suggestions for
1160 + # the current URL.
1161 +
535 1162 # Only set DONOTCACHEPAGE if needed
536 1163 if ($should_set_donotcachepage && !defined('DONOTCACHEPAGE')) {
537 1164 define('DONOTCACHEPAGE', true);
538 1165 }
1166 +}
539 1167
540 - # Apply SG Optimizer specific filters only if SG is active and no WP Rocket
541 - if ($sg_optimizer_active && !$wp_rocket_active && $should_set_donotcachepage) {
542 - if (!defined('SG_CachePress_SUPERCACHER')) {
543 - define('SG_CachePress_SUPERCACHER', false);
1168 +/**
1169 + * Disable SiteGround SG Optimizer page caching for the CURRENT request only.
1170 + *
1171 + * Called from Metasync_otto_pixel::render_route_html() once OTTO has confirmed
1172 + * it has suggestions to apply to the requested URL. Scoping the no-cache
1173 + * override to pages OTTO actually modifies — rather than emitting it on every
1174 + * front-end page via the unconditional `wp` hook — keeps SG's page cache
1175 + * working site-wide and avoids the CPU spike reported in.
1176 + *
1177 + * Only meaningful on SiteGround sites without WP Rocket; a no-op otherwise.
1178 + */
1179 +function metasync_otto_disable_sg_page_cache() {
1180 + # Only relevant when SG Optimizer is active and WP Rocket is not present.
1181 + if (defined('WP_ROCKET_VERSION') || class_exists('WP_Rocket')) {
1182 + return;
1183 + }
1184 + if (!is_plugin_active('sg-cachepress/sg-cachepress.php')) {
1185 + return;
1186 + }
1187 +
1188 + if (!defined('DONOTCACHEPAGE')) {
1189 + define('DONOTCACHEPAGE', true);
1190 + }
1191 + if (!defined('SG_CachePress_SUPERCACHER')) {
1192 + define('SG_CachePress_SUPERCACHER', false);
1193 + }
1194 +
1195 + add_filter('sgo_html_cache_disable', '__return_true', 999);
1196 + add_filter('sgo_css_combine_exclude', '__return_true', 999);
1197 + add_filter('sgo_js_combine_exclude', '__return_true', 999);
1198 + add_filter('sgo_cache_this_page', '__return_false', 999);
1199 +
1200 + if (!headers_sent()) {
1201 + header('Cache-Control: no-cache, must-revalidate, max-age=0');
1202 + header('X-Accel-Expires: 0');
1203 + }
1204 +}
1205 +
1206 +/**
1207 + * How long a response that OTTO could not optimize may be cached, in seconds.
1208 + *
1209 + * Deliberately short rather than zero — see
1210 + * metasync_otto_limit_response_cache() for why.
1211 + */
1212 +if (!defined('METASYNC_OTTO_FALLBACK_CACHE_TTL')) {
1213 + define('METASYNC_OTTO_FALLBACK_CACHE_TTL', 60);
1214 +}
1215 +
1216 +/**
1217 + * Cap how long the current response may be cached, because OTTO could not apply
1218 + * its suggestions to it.
1219 + *
1220 + * OTTO fails open: when suggestions are unavailable, or when the HTML rewrite
1221 + * fails, the site's own un-optimized markup is served instead. That markup is
1222 + * valid — the harm is that a caching layer then stores it and serves it to
1223 + * everyone for the rest of its TTL (a host edge cache with s-maxage=86400 pins
1224 + * it for a day), which surfaces to customers as the OTTO title "reverting".
1225 + *
1226 + * A short TTL is used rather than `no-store`, which is the obvious choice but the
1227 + * wrong one. Some failures look transient and are not: a page that always breaks
1228 + * the rewriter, or a host that cannot fetch its own pages, fails identically on
1229 + * every request. `no-store` would make those permanently uncacheable, sending
1230 + * 100% of their traffic to the origin forever in exchange for markup that will
1231 + * never improve. A one-minute ceiling fixes the reported bug just as completely —
1232 + * a day-long pin becomes a minute — while capping the cost of being wrong about
1233 + * which failures recover.
1234 + *
1235 + * Different layers read different signals, so all of them are emitted:
1236 + *
1237 + * - Cache-Control CDNs, host edge caches, browsers. `max-age=0` keeps
1238 + * browsers revalidating while `s-maxage` lets shared
1239 + * caches absorb a burst for the ceiling's duration.
1240 + * - X-Accel-Expires Nginx's own directive. Evaluated with higher
1241 + * precedence than Cache-Control, and not covered by
1242 + * the `fastcgi_ignore_headers Cache-Control Expires`
1243 + * recipe common on managed hosts — so it can survive
1244 + * a config that discards Cache-Control. Nginx strips
1245 + * X-Accel-* before the response reaches the client,
1246 + * so there is no visitor-facing side effect.
1247 + * - Surrogate-Control Varnish and Fastly. Note that on stock Varnish the
1248 + * mere presence of this header suppresses its
1249 + * Cache-Control check, so it must carry a real
1250 + * lifetime rather than be left at some other value.
1251 + * - DONOTCACHEPAGE WordPress-level page caches (WP Rocket, W3TC,
1252 + * LiteSpeed, SG Optimizer). These run inside PHP and
1253 + * never see response headers. They have no notion of a
1254 + * short TTL — it is cache or do not — so they skip
1255 + * entirely. That is safe here because callers only
1256 + * invoke this for failures expected to resolve; a
1257 + * permanent one (an oversized page, a host that cannot
1258 + * reach itself) is left fully cacheable by the caller
1259 + * and never reaches this function.
1260 + *
1261 + * Cache-tagging headers (Surrogate-Key, Cache-Tag, Edge-Cache-Tag) are
1262 + * deliberately left in place. They are purge-targeting labels, not cacheability
1263 + * directives — no CDN stores a response *because* a tag is present. On a layer
1264 + * that stores this response anyway, those tags are the only handle a later purge
1265 + * has on it, so removing them would discard the recovery path exactly where it
1266 + * is needed most.
1267 + *
1268 + * Some layers may honour none of this; a managed host that overrides origin
1269 + * cache headers will cache the response regardless. This bounds the damage where
1270 + * it can and is inert where it cannot.
1271 + *
1272 + * @param string $reason Short machine-readable reason, surfaced as a response
1273 + * header for debugging (e.g. 'API_ERROR', 'RENDER_FAILED').
1274 + */
1275 +/**
1276 + * Build the response headers that cap how long an un-optimized response may be
1277 + * cached.
1278 + *
1279 + * Thin wrapper over Metasync_Otto_Render_Strategy::fallback_cache_headers(), which
1280 + * owns the header set so it can be unit-tested (this file cannot be loaded in a
1281 + * test context). Returns an empty array if the strategy class is somehow absent,
1282 + * so a caller emits nothing rather than an untested divergent copy.
1283 + *
1284 + * @param int $ttl Cache ceiling in seconds.
1285 + * @param string $reason Optional machine-readable reason for the debug header.
1286 + * @return array Header name => value.
1287 + */
1288 +function metasync_otto_fallback_cache_headers($ttl, $reason = '') {
1289 + if (!class_exists('Metasync_Otto_Render_Strategy')) {
1290 + return [];
1291 + }
1292 +
1293 + return Metasync_Otto_Render_Strategy::fallback_cache_headers_for_response($ttl, $reason);
1294 +}
1295 +
1296 +function metasync_otto_limit_response_cache($reason = '') {
1297 + # This function must only ever make the response LESS cacheable. header()
1298 + # replaces by default and the capped set carries X-Accel-Expires, which nginx
1299 + # evaluates ahead of Cache-Control — so emitting it unconditionally could
1300 + # override an upstream no-store and grant edge caching that nothing had
1301 + # granted. The strategy reconciles existing cache directives before emitting
1302 + # a capped set.
1303 +
1304 + $strategy_available = class_exists('Metasync_Otto_Render_Strategy');
1305 +
1306 + # 1. Logged-in users. OTTO renders for them unless the "disable on logged in"
1307 + # setting is switched on, which is off by default — so this is the common
1308 + # case, not an edge case. Their page may carry session-specific or
1309 + # membership-gated content, and capping at 60 seconds would still let a
1310 + # shared cache store and re-serve it to somebody else.
1311 + if (function_exists('is_user_logged_in') && is_user_logged_in()) {
1312 + if (!headers_sent() && $strategy_available) {
1313 + foreach (Metasync_Otto_Render_Strategy::no_store_headers($reason) as $name => $value) {
1314 + header($name . ': ' . $value);
1315 + }
544 1316 }
1317 + if (!defined('DONOTCACHEPAGE')) {
1318 + define('DONOTCACHEPAGE', true);
1319 + }
1320 + return;
1321 + }
545 1322
546 - add_filter('sgo_html_cache_disable', '__return_true', 999);
547 - add_filter('sgo_css_combine_exclude', '__return_true', 999);
548 - add_filter('sgo_js_combine_exclude', '__return_true', 999);
549 - add_filter('sgo_cache_this_page', '__return_false', 999);
1323 + # 2. Something upstream already restricted this response. Leave its headers
1324 + # alone — a page deliberately marked no-store must not be handed a 60
1325 + # second shared-cache lifetime by us.
1326 + # The strategy turns an existing no-store into a consistent no-store set and
1327 + # uses the strictest known existing lifetime for every cache layer.
1328 + $ttl = (int) METASYNC_OTTO_FALLBACK_CACHE_TTL;
550 1329
551 - if (!headers_sent()) {
552 - header('Cache-Control: no-cache, must-revalidate, max-age=0');
553 - header('X-Accel-Expires: 0');
1330 + if (!headers_sent()) {
1331 + foreach (metasync_otto_fallback_cache_headers($ttl, $reason) as $name => $value) {
1332 + header($name . ': ' . $value);
554 1333 }
555 1334 }
1335 +
1336 + # Reaches page-cache plugins, which do not see the headers above.
1337 + if (!defined('DONOTCACHEPAGE')) {
1338 + define('DONOTCACHEPAGE', true);
1339 + }
556 1340 }
557 1341
558 1342 /**
1343 + * Report an OTTO render failure to the local error log and to Sentry.
1344 + *
1345 + * These failures were invisible: the render path swallowed them and returned the
1346 + * original HTML, so nobody knew how often they fired. Reporting them is what makes
1347 + * a persistently-failing URL discoverable instead of silently costing origin work.
1348 + *
1349 + * Two constraints shape this:
1350 + *
1351 + * 1. It runs on the visitor render path, so it must not add latency. The Sentry
1352 + * report is therefore queued here and sent by
1353 + * metasync_otto_flush_render_failure_reports() after the response has been
1354 + * handed to the visitor, so the cost is invisible rather than merely small.
1355 + *
1356 + * Two alternatives were tried and rejected. Sending inline with a blocking
1357 + * transport — cURL with a 5s connect and 5s read timeout — could add ten
1358 + * seconds to a page load, precisely when the request is already degraded.
1359 + * Sending inline non-blocking is far cheaper but still not free: opening the
1360 + * connection and writing the body costs the visitor something.
1361 + *
1362 + * A WP-Cron single event was the other obvious option and is worse than both:
1363 + * every scheduled event is stored in the `cron` option, which is autoloaded on
1364 + * every request site-wide, and WP's own 10-minute duplicate suppression keys on
1365 + * the serialized args — which carry per-request byte counts here, so it would
1366 + * never engage. A site with many failing URLs would grow that option
1367 + * unboundedly and pay for it on every page load, which is the cost the deferral
1368 + * was meant to avoid in the first place.
1369 + *
1370 + * The local log stays synchronous. It is cheaper than Sentry but not free —
1371 + * Metasync_Error_Logger::log() rewrites a summary option on each call — which is
1372 + * the second reason for the throttle below.
1373 + *
1374 + * 2. A failing page can be requested thousands of times an hour, so reports are
1375 + * throttled per reason+URL.
1376 + *
1377 + * @param string $reason Machine-readable failure reason (e.g. 'RENDER_EXCEPTION').
1378 + * @param string $message Human-readable description.
1379 + * @param array $context Extra key/value context.
1380 + * @param string $level Sentry level: 'error' or 'warning'.
1381 + */
1382 +function metasync_otto_report_render_failure($reason, $message, $context = [], $level = 'error') {
1383 + $url = '';
1384 + if (!empty($_SERVER['HTTP_HOST']) && !empty($_SERVER['REQUEST_URI'])) {
1385 + $url = (is_ssl() ? 'https://' : 'http://')
1386 + . $_SERVER['HTTP_HOST']
1387 + . strtok(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), '?');
1388 + }
1389 +
1390 + # Throttle: at most one report per reason+URL per 5 minutes. Requests without a
1391 + # resolvable URL (CLI, odd SAPIs) collapse onto one key, which is intended —
1392 + # they cannot be attributed to a page anyway.
1393 + $throttle_key = 'metasync_otto_fail_' . md5($reason . '|' . $url);
1394 + if (get_transient($throttle_key)) {
1395 + return;
1396 + }
1397 + set_transient($throttle_key, 1, 5 * MINUTE_IN_SECONDS);
1398 +
1399 + $context = array_merge([
1400 + 'reason' => $reason,
1401 + 'url' => $url,
1402 + ], $context);
1403 +
1404 + # Local structured log. Stays on the customer's site, so it is useful to
1405 + # support but not visible to us — hence the Sentry dispatch below.
1406 + if (class_exists('Metasync_Error_Logger')) {
1407 + Metasync_Error_Logger::log(
1408 + Metasync_Error_Logger::CATEGORY_OTTO_RENDER,
1409 + $level === 'warning'
1410 + ? Metasync_Error_Logger::SEVERITY_WARNING
1411 + : Metasync_Error_Logger::SEVERITY_ERROR,
1412 + $message,
1413 + $context
1414 + );
1415 + }
1416 +
1417 + # Sentry. Queued rather than sent here: even a non-blocking dispatch costs the
1418 + # visitor something (opening the connection and writing the body), and this runs
1419 + # on a request that is already degraded. The queue is flushed after the response
1420 + # has been handed to the visitor — see metasync_otto_flush_render_failure_reports().
1421 + if (!function_exists('metasync_sentry_capture_message_nonblocking')) {
1422 + return;
1423 + }
1424 +
1425 + $GLOBALS['metasync_otto_pending_failure_reports'][] = [
1426 + 'message' => $message,
1427 + 'level' => $level,
1428 + 'context' => $context,
1429 + ];
1430 +
1431 + if (empty($GLOBALS['metasync_otto_failure_flush_hooked'])) {
1432 + $GLOBALS['metasync_otto_failure_flush_hooked'] = true;
1433 + # PHP_INT_MAX so this runs after everything else registered on shutdown —
1434 + # WordPress flushes its own output buffers and closes the object cache at the
1435 + # default priority, and this handler ends output for the request.
1436 + add_action('shutdown', 'metasync_otto_flush_render_failure_reports', PHP_INT_MAX);
1437 + }
1438 +}
1439 +
1440 +/**
1441 + * Send any queued render-failure reports after the response has been delivered.
1442 + *
1443 + * fastcgi_finish_request() (PHP-FPM) and litespeed_finish_request() (LiteSpeed)
1444 + * flush the response and close the connection to the visitor while leaving PHP
1445 + * running. Calling one of them here means the page is already on its way before
1446 + * any telemetry work starts, so the reporting cost is invisible to the visitor
1447 + * rather than merely small.
1448 + *
1449 + * Neither exists under mod_php or the CLI SAPI. There the reports are still sent
1450 + * at the very end of the request, after all page output has been generated and
1451 + * flushed — the same ordering, just without the connection being closed first.
1452 + *
1453 + * The dispatch itself stays non-blocking. Now that the visitor is served, the only
1454 + * remaining cost is PHP worker occupancy, and a blocking transport with a 5s
1455 + * connect and 5s read timeout could tie up a worker for ten seconds per report on
1456 + * a site that is failing often.
1457 + */
1458 +function metasync_otto_flush_render_failure_reports() {
1459 + $reports = $GLOBALS['metasync_otto_pending_failure_reports'] ?? [];
1460 + $GLOBALS['metasync_otto_pending_failure_reports'] = [];
1461 +
1462 + if (empty($reports)) {
1463 + return;
1464 + }
1465 +
1466 + if (function_exists('fastcgi_finish_request')) {
1467 + fastcgi_finish_request();
1468 + } elseif (function_exists('litespeed_finish_request')) {
1469 + litespeed_finish_request();
1470 + }
1471 +
1472 + if (!function_exists('metasync_sentry_capture_message_nonblocking')) {
1473 + return;
1474 + }
1475 +
1476 + foreach ($reports as $report) {
1477 + try {
1478 + metasync_sentry_capture_message_nonblocking(
1479 + $report['message'],
1480 + $report['level'],
1481 + $report['context']
1482 + );
1483 + } catch (Exception $e) {
1484 + # Telemetry must never be able to break a request.
1485 + } catch (Error $e) {
1486 + # Same.
1487 + }
1488 + }
1489 +}
1490 +
1491 +/**
1492 + * Whether MetaSync is still emitting Open Graph / Twitter tags.
1493 + *
1494 + * The Social Media & Open Graph switch stops MetaSync writing those tags on
1495 + * every render path, so the suppression below has to stand down with it —
1496 + * removing another plugin's og:/twitter: tag while contributing none of our own
1497 + * would leave the page with no social tags at all.
1498 + *
1499 + * The class_exists() guard keeps this usable if the flags file has not loaded
1500 + * yet; treating that window as "enabled" preserves the previous behaviour.
1501 + *
1502 + * @return bool
1503 + */
1504 +function metasync_otto_social_output_enabled() {
1505 + if (!class_exists('Metasync_Feature_Flags')) {
1506 + return true;
1507 + }
1508 +
1509 + return Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::SOCIAL_OG);
1510 +}
1511 +
1512 +/**
559 1513 * Block SEO plugins conditionally based on what Otto is providing
560 1514 * Only blocks title if Otto has title, only blocks description if Otto has description
561 1515 * This prevents duplicate SEO tags while allowing fallback to SEO plugins when Otto has no data.
562 1516 * Supports Yoast SEO, Rank Math, and AIOSEO (free + pro).
@@ -596,11 +1550,19 @@
596 1550
597 1551
598 1552 # Remove description presenters only when OTTO has a description
599 1553 if ($block_description) {
1554 + # The plain meta description belongs to the SEO title/description
1555 + # feature, which has its own setting.
600 1556 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Meta_Description_Presenter';
601 - $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Open_Graph\Description_Presenter';
602 - $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Twitter\Description_Presenter';
1557 +
1558 + # The OG/Twitter descriptions are social tags. With that feature
1559 + # switched off MetaSync writes none of its own, so Yoast's must be
1560 + # left alone rather than removed with nothing to replace them.
1561 + if (metasync_otto_social_output_enabled()) {
1562 + $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Open_Graph\Description_Presenter';
1563 + $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Twitter\Description_Presenter';
1564 + }
603 1565 }
604 1566
605 1567 foreach ($presenters as $key => $presenter) {
606 1568 // Safely get class name, suppressing autoload errors
@@ -618,11 +1580,13 @@
618 1580 # Disable Rank Math
619 1581 if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
620 1582 is_plugin_active('seo-by-rankmath/rank-math.php')) {
621 1583
622 - if ($block_title) {
623 - add_filter('rank_math/frontend/title', '__return_empty_string', 999);
624 - }
1584 + # TITLE: Never blank Rank Math's title output during SSR fetch.
1585 + # rank_math/frontend/title also gates Rank Math's og:title/twitter:title
1586 + # output, so forcing it empty blanks those social tags along with the
1587 + # document title. Let Rank Math render its own title/og/twitter tags;
1588 + # OTTO's replace_title() and the dedup/precedence pass clean up duplicates.
625 1589
626 1590 if ($block_description) {
627 1591 add_filter('rank_math/frontend/description', '__return_false', 999);
628 1592 add_filter('rank_math/frontend/show_keywords', '__return_false', 999);
@@ -633,17 +1597,22 @@
633 1597 if (is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
634 1598 is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
635 1599
636 1600 if ($block_title) {
1601 + # The document title belongs to the SEO title feature, which has its
1602 + # own setting; only the og:/twitter: unsets below are social.
637 1603 add_filter('aioseo_title', '__return_empty_string', 999);
638 - add_filter('aioseo_facebook_tags', function($meta) {
639 - if (is_array($meta)) { unset($meta['og:title']); }
640 - return $meta;
641 - }, 999);
642 - add_filter('aioseo_twitter_tags', function($meta) {
643 - if (is_array($meta)) { unset($meta['twitter:title']); }
644 - return $meta;
645 - }, 999);
1604 +
1605 + if (metasync_otto_social_output_enabled()) {
1606 + add_filter('aioseo_facebook_tags', function($meta) {
1607 + if (is_array($meta)) { unset($meta['og:title']); }
1608 + return $meta;
1609 + }, 999);
1610 + add_filter('aioseo_twitter_tags', function($meta) {
1611 + if (is_array($meta)) { unset($meta['twitter:title']); }
1612 + return $meta;
1613 + }, 999);
1614 + }
646 1615 }
647 1616
648 1617 if ($block_description) {
649 1618 # Use granular tag list when available to only block what OTTO provides
@@ -648,10 +1617,12 @@
648 1617 if ($block_description) {
649 1618 # Use granular tag list when available to only block what OTTO provides
650 1619 $tags = !empty($description_tags) ? $description_tags : [];
651 1620 $block_standard = empty($tags) || in_array('meta[name=description]', $tags);
652 - $block_og_desc = empty($tags) || in_array('meta[property=og:description]', $tags);
653 - $block_tw_desc = empty($tags) || in_array('meta[name=twitter:description]', $tags);
1621 + # Social tags only get blocked while MetaSync is still emitting them.
1622 + $social_enabled = metasync_otto_social_output_enabled();
1623 + $block_og_desc = $social_enabled && (empty($tags) || in_array('meta[property=og:description]', $tags));
1624 + $block_tw_desc = $social_enabled && (empty($tags) || in_array('meta[name=twitter:description]', $tags));
654 1625
655 1626 if ($block_standard) {
656 1627 add_filter('aioseo_description', '__return_empty_string', 999);
657 1628 }
@@ -669,13 +1640,21 @@
669 1640 }
670 1641 }
671 1642 }
672 1643 }
673 -# check that otto is not added via js to the site
1644 +# Check whether OTTO is ALSO injected via JavaScript on the site (a misconfiguration
1645 +# we warn admins about).
1646 +#
1647 +# The actual detection makes a loopback HTTP request to the site's OWN url. On hosts
1648 +# that disallow same-server loopback (e.g. SiteGround), that request blocks for the
1649 +# full timeout and then fails — and when it runs inline on admin_notices it makes
1650 +# every wp-admin page hang. It must therefore NEVER run inline on an admin request.
1651 +#
1652 +# metasync_check_otto_js() is now read-only: it returns the cached result and, on a
1653 +# cache miss, schedules a one-off BACKGROUND job (WP-Cron) to compute it. The notice
1654 +# simply shows nothing until the background result is available.
674 1655 function metasync_check_otto_js(){
675 1656
676 - # Cache result to avoid a blocking HTTP self-request (~2.4s)
677 - # on every single admin page load.
678 1657 $cache_key = 'metasync_otto_js_detected';
679 1658 $cached = get_transient($cache_key);
680 1659
681 1660 if ($cached !== false) {
@@ -681,43 +1660,81 @@
681 1660 if ($cached !== false) {
682 1661 return $cached === 'yes';
683 1662 }
684 1663
685 - # get the site url
1664 + # No cached result yet — compute it off the request so we never block admin
1665 + # with a loopback call. Show nothing this load.
1666 + metasync_schedule_otto_js_check();
1667 + return false;
1668 +};
1669 +
1670 +# Queue the background loopback detection if it isn't already scheduled.
1671 +function metasync_schedule_otto_js_check(){
1672 + if (!wp_next_scheduled('metasync_otto_js_check_event')) {
1673 + wp_schedule_single_event(time() + 5, 'metasync_otto_js_check_event');
1674 + }
1675 +}
1676 +
1677 +# Background worker (runs in WP-Cron context, NOT on the admin request): performs the
1678 +# blocking loopback request and caches the result. Hooked to metasync_otto_js_check_event.
1679 +function metasync_run_otto_js_check(){
1680 + $cache_key = 'metasync_otto_js_detected';
1681 +
1682 + # the site url with the internal-fetch marker so OTTO does not re-process it
686 1683 $site_url = site_url() . '?is_otto_page_fetch=1';
687 1684
688 - # get the html — short timeout so admin doesn't hang
689 1685 $page_data = wp_remote_get($site_url, array('timeout' => 5, 'sslverify' => false));
690 1686
691 1687 if (is_wp_error($page_data)) {
1688 + # Loopback failed (host likely blocks same-server requests). Cache "no" so we
1689 + # don't keep re-queuing the check on every admin page load.
692 1690 set_transient($cache_key, 'no', HOUR_IN_SECONDS);
693 - return false;
1691 + return;
694 1692 }
695 1693
696 - # now get the html body
697 1694 $body = wp_remote_retrieve_body($page_data);
698 1695
699 - # now load the body into html
700 - $dom = new HtmlDocument($body);
1696 + # Detect the OTTO script via a lightweight regex instead of parsing
1697 + # the entire fetched page into a SimpleHtmlDom tree (which exhausted memory
1698 + # on large pages — this check runs on admin page loads). We only need to
1699 + # confirm a <script id="sa-dynamic-optimization" ... data-uuid="..."> exists.
1700 + $found = preg_match(
1701 + '/<script\b[^>]*\bid=["\']sa-dynamic-optimization["\'][^>]*\bdata-uuid=["\'][^"\']+["\']/i',
1702 + (string) $body
1703 + )
1704 + || preg_match(
1705 + # Attribute order may vary (data-uuid before id)
1706 + '/<script\b[^>]*\bdata-uuid=["\'][^"\']+["\'][^>]*\bid=["\']sa-dynamic-optimization["\']/i',
1707 + (string) $body
1708 + );
701 1709
702 - # now check the dom for a meta tag with
703 - $script = $dom->find('script#sa-dynamic-optimization', 0);
704 -
705 - # check script
706 - if($script AND !empty($script->getAttribute('data-uuid'))){
1710 + if ($found) {
707 1711 set_transient($cache_key, 'yes', 12 * HOUR_IN_SECONDS);
708 - return true;
1712 + return;
709 1713 }
710 1714
711 1715 set_transient($cache_key, 'no', 12 * HOUR_IN_SECONDS);
712 - return false;
713 -};
1716 +}
714 1717
1718 +# Register the background worker hook.
1719 +add_action('metasync_otto_js_check_event', 'metasync_run_otto_js_check');
1720 +
715 1721 # Handle AJAX Clear Cache request
716 1722 # NOTE: Cache system removed - this is now a no-op
717 1723 function metasync_clear_otto_cache_handler() {
1724 + # State-changing admin action: verify the nonce sent by admin/js
1725 + # (clear_otto_caches) and confirm plugin access before clearing the
1726 + # detection transient or queueing the loopback re-check.
1727 + check_ajax_referer('metasync_clear_otto_cache', 'nonce');
1728 +
1729 + if (!Metasync::current_user_has_plugin_access()) {
1730 + wp_send_json_error(['message' => 'Not authorized']);
1731 + }
1732 +
718 1733 if (!empty($_GET['clear_otto_cache'])) {
719 1734 delete_transient('metasync_otto_js_detected');
1735 + # Re-run the JS detection in the background so the notice refreshes.
1736 + metasync_schedule_otto_js_check();
720 1737 # Cache system has been removed - no cache to clear
721 1738 wp_send_json_success(['message' => 'Cache system removed - all pages processed in real-time']);
722 1739 }
723 1740 else {
@@ -750,22 +1767,28 @@
750 1767 }
751 1768
752 1769 add_action('admin_notices', 'metasync_show_otto_ssr_notice');
753 1770
1771 +
754 1772 # staging dummy change
755 -# load otto in the wp hook
1773 +# load otto in the wp hook
756 1774 add_action('wp', 'metasync_start_otto');
757 1775
758 -
759 1776 # ENHANCED OTTO SEO INTEGRATION
760 1777 # Register async SEO processing hook
761 - add_action('metasync_process_seo_job', 'metasync_process_otto_seo_data', 10, 3);
1778 + add_action('metasync_process_seo_job', 'metasync_process_otto_seo_data', 10, 4);
762 1779 add_action('metasync_process_otto_crawl_url_job', 'metasync_handle_otto_crawl_url_job', 10, 2);
763 - add_action('metasync_process_otto_batch_cache_job', 'metasync_handle_otto_batch_cache_job');
764 -
1780 + add_action('metasync_process_otto_batch_cache_job', 'metasync_handle_otto_batch_cache_job', 10, 2);
1781 + add_action('metasync_otto_pending_drainer', 'metasync_handle_otto_pending_drainer');
1782 +
765 1783 # Process OTTO SEO data and update WordPress meta fields for SEO plugins
766 1784 # This function now runs asynchronously via WordPress cron system
767 1785 #
1786 + # Returns one of the Metasync_Otto_Job_Status::OUTCOME_* strings so callers
1787 + # can distinguish success, confirmed no-change, retryable failure, permanent
1788 + # failure, and CPU deferral — a bare boolean cannot drive correct retry and
1789 + # purge decisions.
1790 + #
768 1791 # @param string $route Fully-qualified URL to process.
769 1792 # @param bool $allow_defer When false, skip CPU-deferral rescheduling (used when
770 1793 # called synchronously from crawl_url_job whose own retry
771 1794 # mechanism already handles failures).
@@ -770,10 +1793,14 @@
770 1793 # called synchronously from crawl_url_job whose own retry
771 1794 # mechanism already handles failures).
772 1795 # @param int $deferral_count How many times this job has already been deferred for
773 1796 # CPU load. Prevents infinite reschedule loops.
1797 + # @param array|null $prefetched_seo_data Suggestions already fetched by the transient
1798 + # warm step. When an array it is used as-is so the job
1799 + # makes exactly ONE API call per URL; the endpoint is
1800 + # only fetched here when no prefetch was provided.
774 1801
775 -function metasync_process_otto_seo_data($route, $allow_defer = true, $deferral_count = 0) {
1802 +function metasync_process_otto_seo_data($route, $allow_defer = true, $deferral_count = 0, $prefetched_seo_data = null) {
776 1803 # Maximum number of times a job can be deferred before it is dropped.
777 1804 $max_deferrals = defined('METASYNC_SEO_JOB_MAX_DEFERRALS') ? METASYNC_SEO_JOB_MAX_DEFERRALS : 5;
778 1805 $lock_key = null;
779 1806 $lock_acquired = false;
@@ -780,9 +1807,9 @@
780 1807
781 1808 try {
782 1809 # Validate input
783 1810 if (empty($route) || !is_string($route)) {
784 - return false;
1811 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
785 1812 }
786 1813
787 1814 # CPU load check — defer if server is under load.
788 1815 # Only reschedule when called from the cron hook (allow_defer=true) and
@@ -789,24 +1816,28 @@
789 1816 # we haven't exceeded the maximum deferral count.
790 1817 # Checked BEFORE acquiring the lock so that deferred jobs don't
791 1818 # acquire-then-immediately-release it (which defeats concurrency protection).
792 1819 if (!Metasync_CPU_Monitor::is_load_safe()) {
793 - if ($allow_defer && $deferral_count < $max_deferrals) {
794 - wp_schedule_single_event(
795 - time() + 60,
796 - 'metasync_process_seo_job',
797 - array($route, true, $deferral_count + 1)
798 - );
799 - return false;
1820 + if ($allow_defer) {
1821 + if ($deferral_count < $max_deferrals) {
1822 + metasync_otto_schedule_single_event(
1823 + time() + 60,
1824 + 'metasync_process_seo_job',
1825 + array($route, true, $deferral_count + 1)
1826 + );
1827 + return Metasync_Otto_Job_Status::OUTCOME_DEFERRED;
1828 + }
1829 + # deferral budget exhausted — RUN the job late instead
1830 + # of dropping it. The write is idempotent and bounded, and a
1831 + # late sync is strictly better than SEO data that never
1832 + # arrives. No new cron events are created, so the
1833 + # anti-pile-up guarantee is preserved. Fall through.
1834 + } else {
1835 + # allow_defer=false (sync path): report retryable without creating
1836 + # cron events — the crawl-url job's retry mechanism (or OTTO's
1837 + # next crawl) re-triggers this URL.
1838 + return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
800 1839 }
801 - # Max deferrals reached or not allowed to defer — drop the job to
802 - # prevent unbounded cron accumulation that causes server overload.
803 - if ($deferral_count >= $max_deferrals) {
804 - metasync_record_failed_action('metasync_process_seo_job');
805 - return false;
806 - }
807 - # allow_defer=false (sync path): just return false, parent handles retry.
808 - return false;
809 1840 }
810 1841
811 1842 # Concurrency lock: prevent multiple SEO jobs from running simultaneously.
812 1843 # Uses a per-URL transient lock with a 120s TTL as a safety net (the lock is
@@ -814,15 +1845,15 @@
814 1845 # processing this URL — reschedule once with a short delay instead of stacking.
815 1846 $lock_key = 'metasync_seo_lock_' . md5($route);
816 1847 if (get_transient($lock_key) !== false) {
817 1848 if ($allow_defer && $deferral_count < $max_deferrals) {
818 - wp_schedule_single_event(
1849 + metasync_otto_schedule_single_event(
819 1850 time() + 30,
820 1851 'metasync_process_seo_job',
821 1852 array($route, true, $deferral_count + 1)
822 1853 );
823 1854 }
824 - return false;
1855 + return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
825 1856 }
826 1857 set_transient($lock_key, true, 120);
827 1858 $lock_acquired = true;
828 1859
@@ -831,16 +1862,15 @@
831 1862
832 1863 # Skip excluded URLs - don't process SEO data for them
833 1864 if (metasync_is_otto_url_excluded($route)) {
834 1865 //error_log('MetaSync OTTO: Skipping SEO processing for excluded URL: ' . $route);
835 - return false;
1866 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
836 1867 }
837 1868
838 1869 # Pre-flight 404 check: exclude URLs that would return 404 before making API call
839 1870 if (!metasync_otto_is_url_available($route)) {
840 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route}");
841 1871 metasync_otto_auto_exclude_404_url($route);
842 - return false;
1872 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
843 1873 }
844 1874
845 1875 # Get OTTO UUID from settings
846 1876 # OPTIMIZED: Use cached options
@@ -846,37 +1876,37 @@
846 1876 # OPTIMIZED: Use cached options
847 1877 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
848 1878
849 1879 if (empty($otto_uuid)) {
850 - return false;
1880 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
851 1881 }
852 1882
853 1883 # Meta descriptions are always enabled by default - no check needed
854 1884
855 - # Fetch SEO data from OTTO API
856 - $seo_data = metasync_fetch_otto_seo_data($route, $otto_uuid);
857 -
858 - if (!$seo_data) {
859 - metasync_record_failed_action( 'metasync_process_seo_job' );
860 - return false;
1885 + # Obtain the OTTO suggestions for this URL. When the caller already
1886 + # warmed the transient cache (crawl-url job), those suggestions are
1887 + # handed in and NO second API call is made — the endpoint, rate
1888 + # limiter, and breaker are only hit once per URL.
1889 + # An empty array is a legitimate answer ("OTTO holds nothing for this
1890 + # URL") and must not be treated as a fetch failure; only a strict
1891 + # false from the fetcher is a failure.
1892 + if (is_array($prefetched_seo_data)) {
1893 + $seo_data = $prefetched_seo_data;
1894 + } else {
1895 + $failure_class = null;
1896 + $seo_data = metasync_fetch_otto_seo_data($route, $otto_uuid, $failure_class);
1897 + if ($seo_data === false) {
1898 + if ($failure_class === Metasync_Otto_Job_Status::OUTCOME_PERMANENT) {
1899 + # Auth/input-level rejection from the API — retrying the
1900 + # same request cannot help.
1901 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1902 + }
1903 + # Timeout / 5xx / 429 / empty body / bad JSON: retryable.
1904 + metasync_record_failed_action( 'metasync_process_seo_job' );
1905 + return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
1906 + }
861 1907 }
862 1908
863 - # Mark this URL as crawled by OTTO for SSR
864 - # Extract domain and path from route
865 - $parsed_url = parse_url($route);
866 - $domain_with_scheme = ($parsed_url['scheme'] ?? 'https') . '://' . ($parsed_url['host'] ?? '');
867 - $url_path = ($parsed_url['path'] ?? '/');
868 -
869 - # Create crawl data structure
870 - $crawl_data = array(
871 - 'domain' => $domain_with_scheme,
872 - 'urls' => array($url_path)
873 - );
874 -
875 - # Load Otto pixel class and save crawl data
876 - $otto_pixel = new Metasync_otto_pixel($otto_uuid);
877 - $otto_pixel->save_crawl_data($crawl_data);
878 -
879 1909 # Get WordPress post ID from URL
880 1910 $post_id = url_to_postid($route);
881 1911
882 1912 # Special handling for WooCommerce shop page (url_to_postid doesn't work for it)
@@ -896,9 +1926,9 @@
896 1926
897 1927 # Try to find WooCommerce product by URL if url_to_postid failed
898 1928 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
899 1929 # Extract product slug from URL
900 - $product_slug = basename(parse_url($route, PHP_URL_PATH));
1930 + $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
901 1931
902 1932 # Try to get product by slug
903 1933 $products = wc_get_products(array(
904 1934 'name' => $product_slug,
@@ -929,17 +1959,16 @@
929 1959 if (!$post_id || $post_id <= 0) {
930 1960 # Check if this is a category page
931 1961 if (strpos($route, '/category/') !== false) {
932 1962 # Extract category slug from URL
933 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
1963 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
934 1964 $category = get_category_by_slug($category_slug);
935 1965
936 1966 if ($category) {
937 1967 # Check if category would return 404 before applying OTTO changes
938 1968 if (metasync_would_term_return_404($category->term_id, 'category', $route)) {
939 - error_log("MetaSync OTTO: Skipping SEO processing for category that would return 404: {$route} (Category ID: {$category->term_id})");
940 1969 metasync_otto_auto_exclude_404_url($route);
941 - return false;
1970 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
942 1971 }
943 1972
944 1973 # Update comprehensive category SEO meta fields
945 1974 $update_result = metasync_update_comprehensive_category_seo_fields($category->term_id, $seo_data);
@@ -1002,8 +2031,12 @@
1002 2031 $title = 'Category Structured Data Update';
1003 2032 break;
1004 2033 }
1005 2034
2035 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2036 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2037 + }
2038 +
1006 2039 if (!empty($title)) {
1007 2040 metasync_log_sync_history([
1008 2041 'title' => $title,
1009 2042 'source' => 'OTTO SEO',
@@ -1019,12 +2052,12 @@
1019 2052 ]);
1020 2053 }
1021 2054 }
1022 2055
1023 - return true;
2056 + return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
1024 2057 }
1025 2058
1026 - return false;
2059 + return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
1027 2060 }
1028 2061 }
1029 2062
1030 2063 # Check if this is a WooCommerce product category
@@ -1029,17 +2062,16 @@
1029 2062
1030 2063 # Check if this is a WooCommerce product category
1031 2064 if (strpos($route, '/product-category/') !== false) {
1032 2065 # Extract product category slug from URL
1033 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2066 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1034 2067 $term = get_term_by('slug', $category_slug, 'product_cat');
1035 2068
1036 2069 if ($term && !is_wp_error($term)) {
1037 2070 # Check if product category would return 404 before applying OTTO changes
1038 2071 if (metasync_would_term_return_404($term->term_id, 'product_cat', $route)) {
1039 - error_log("MetaSync OTTO: Skipping SEO processing for product category that would return 404: {$route} (Term ID: {$term->term_id})");
1040 2072 metasync_otto_auto_exclude_404_url($route);
1041 - return false;
2073 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1042 2074 }
1043 2075
1044 2076 # Update comprehensive taxonomy SEO meta fields
1045 2077 $update_result = metasync_update_comprehensive_taxonomy_seo_fields($term->term_id, 'product_cat', $seo_data);
@@ -1102,8 +2134,12 @@
1102 2134 $title = 'Product Category Structured Data Update';
1103 2135 break;
1104 2136 }
1105 2137
2138 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2139 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2140 + }
2141 +
1106 2142 if (!empty($title)) {
1107 2143 metasync_log_sync_history([
1108 2144 'title' => $title,
1109 2145 'source' => 'OTTO SEO',
@@ -1120,12 +2156,12 @@
1120 2156 ]);
1121 2157 }
1122 2158 }
1123 2159
1124 - return true;
2160 + return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
1125 2161 }
1126 2162
1127 - return false;
2163 + return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
1128 2164 }
1129 2165 }
1130 2166
1131 2167 # Check if this is the home page (landing page)
@@ -1146,11 +2182,10 @@
1146 2182
1147 2183 if ($home_page) {
1148 2184 # Check if home page would return 404 before applying OTTO changes
1149 2185 if (metasync_would_page_return_404($home_page->ID, $route)) {
1150 - error_log("MetaSync OTTO: Skipping SEO processing for home page that would return 404: {$route} (Post ID: {$home_page->ID}, Status: {$home_page->post_status})");
1151 2186 metasync_otto_auto_exclude_404_url($route);
1152 - return false;
2187 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1153 2188 }
1154 2189
1155 2190 # Update comprehensive home page SEO meta fields
1156 2191 $update_result = metasync_update_comprehensive_seo_fields($home_page->ID, $seo_data);
@@ -1216,8 +2251,12 @@
1216 2251 $title = 'Home Page Structured Data Update';
1217 2252 break;
1218 2253 }
1219 2254
2255 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2256 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2257 + }
2258 +
1220 2259 if (!empty($title)) {
1221 2260 metasync_log_sync_history([
1222 2261 'title' => $title,
1223 2262 'source' => 'OTTO SEO',
@@ -1232,12 +2271,12 @@
1232 2271 ]);
1233 2272 }
1234 2273 }
1235 2274
1236 - return true;
2275 + return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
1237 2276 }
1238 2277
1239 - return false;
2278 + return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
1240 2279 }
1241 2280 }
1242 2281
1243 2282 # Check if this is the blog/posts page (page_for_posts)
@@ -1248,11 +2287,10 @@
1248 2287
1249 2288 if ($posts_page && $route_clean === $posts_page_url) {
1250 2289 # Check if blog page would return 404
1251 2290 if (metasync_would_page_return_404($posts_page->ID, $route)) {
1252 - error_log("MetaSync OTTO: Skipping SEO processing for blog page that would return 404: {$route} (Post ID: {$posts_page->ID}, Status: {$posts_page->post_status})");
1253 2291 metasync_otto_auto_exclude_404_url($route);
1254 - return false;
2292 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1255 2293 }
1256 2294
1257 2295 # Update comprehensive blog page SEO meta fields
1258 2296 $update_result = metasync_update_comprehensive_seo_fields($posts_page->ID, $seo_data);
@@ -1318,8 +2356,12 @@
1318 2356 $title = 'Blog Page Structured Data Update';
1319 2357 break;
1320 2358 }
1321 2359
2360 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2361 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2362 + }
2363 +
1322 2364 if (!empty($title)) {
1323 2365 metasync_log_sync_history([
1324 2366 'title' => $title,
1325 2367 'source' => 'OTTO SEO',
@@ -1334,12 +2376,12 @@
1334 2376 ]);
1335 2377 }
1336 2378 }
1337 2379
1338 - return true;
2380 + return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
1339 2381 }
1340 2382
1341 - return false;
2383 + return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
1342 2384 }
1343 2385 }
1344 2386
1345 2387 # URL didn't resolve to any supported entity (post, category, home page, blog page)
@@ -1344,12 +2386,11 @@
1344 2386
1345 2387 # URL didn't resolve to any supported entity (post, category, home page, blog page)
1346 2388 # Treat as 404 and auto-exclude (e.g. deleted post, non-existent page)
1347 2389 if (!metasync_otto_is_url_available($route)) {
1348 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404 (no matching entity): {$route}");
1349 2390 metasync_otto_auto_exclude_404_url($route);
1350 2391 }
1351 - return false;
2392 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1352 2393 }
1353 2394
1354 2395 # Verify this is actually a post, page, or WooCommerce product
1355 2396 $post = get_post($post_id);
@@ -1358,16 +2399,15 @@
1358 2399 $supported_post_types = metasync_get_supported_post_types();
1359 2400
1360 2401 if (!$post || !in_array($post->post_type, $supported_post_types)) {
1361 2402 # Skip unsupported post types
1362 - return false;
2403 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1363 2404 }
1364 2405
1365 2406 # Check if page would return 404 before applying OTTO changes
1366 2407 if (metasync_would_page_return_404($post_id, $route)) {
1367 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route} (Post ID: {$post_id}, Status: {$post->post_status})");
1368 2408 metasync_otto_auto_exclude_404_url($route);
1369 - return false;
2409 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1370 2410 }
1371 2411
1372 2412 # Update comprehensive SEO meta fields
1373 2413 $update_result = metasync_update_comprehensive_seo_fields($post_id, $seo_data);
@@ -1433,8 +2473,12 @@
1433 2473 $title = 'Structured Data Update';
1434 2474 break;
1435 2475 }
1436 2476
2477 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2478 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2479 + }
2480 +
1437 2481 if (!empty($title)) {
1438 2482 metasync_log_sync_history([
1439 2483 'title' => $title,
1440 2484 'source' => 'OTTO SEO',
@@ -1449,16 +2493,16 @@
1449 2493 ]);
1450 2494 }
1451 2495 }
1452 2496
1453 - return true;
2497 + return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
1454 2498 }
1455 2499
1456 - return false;
2500 + return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
1457 2501
1458 2502 } catch (Exception $e) {
1459 2503 metasync_record_failed_action( 'metasync_process_seo_job' );
1460 - return false;
2504 + return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
1461 2505 } finally {
1462 2506 # Release the concurrency lock only if we actually acquired it.
1463 2507 # Early returns (CPU deferral, lock contention) must NOT delete a lock
1464 2508 # that another process may be holding.
@@ -1479,11 +2523,10 @@
1479 2523
1480 2524 // Minimal duplicate prevention within short time window
1481 2525 if (!empty($data['title']) && !empty($data['source'])) {
1482 2526 global $wpdb;
1483 - $table = $wpdb->prefix . Metasync_Sync_History_Database::$table_name;
1484 - $recent = $wpdb->get_var($wpdb->prepare(
1485 - "SELECT COUNT(*) FROM `$table` WHERE title = %s AND source = %s AND created_at >= %s",
2527 + $recent = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- sync-history duplicate guard over the plugin custom table — write-path freshness required
2528 + "SELECT COUNT(*) FROM `{$wpdb->prefix}metasync_sync_history` WHERE title = %s AND source = %s AND created_at >= %s",
1486 2529 $data['title'],
1487 2530 $data['source'],
1488 2531 gmdate('Y-m-d H:i:s', time() - 60)
1489 2532 ));
@@ -1494,9 +2537,9 @@
1494 2537
1495 2538 $sync_db->add($data);
1496 2539
1497 2540 } catch (Exception $e) {
1498 - error_log("MetaSync: Failed to log sync history: " . $e->getMessage());
2541 + error_log("MetaSync: Failed to log sync history: " . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1499 2542 }
1500 2543 }
1501 2544
1502 2545 /**
@@ -1572,9 +2615,9 @@
1572 2615 $db = new Metasync_Redirection_Database();
1573 2616 $redirect = new Metasync_Redirection($db);
1574 2617 return $redirect->resolve_url_to_final_destination($url, 10);
1575 2618 } catch (Exception $e) {
1576 - error_log('MetaSync OTTO: Redirect resolution failed for ' . $url . ' - ' . $e->getMessage());
2619 + error_log('MetaSync OTTO: Redirect resolution failed for ' . $url . ' - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1577 2620 return $url;
1578 2621 }
1579 2622 }
1580 2623
@@ -1605,9 +2648,9 @@
1605 2648 'status' => 'active',
1606 2649 'auto_excluded' => 1,
1607 2650 ]);
1608 2651 } catch (Exception $e) {
1609 - error_log('MetaSync OTTO: Failed to auto-exclude 404 URL: ' . $url . ' - ' . $e->getMessage());
2652 + error_log('MetaSync OTTO: Failed to auto-exclude 404 URL: ' . $url . ' - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1610 2653 return false;
1611 2654 }
1612 2655 }
1613 2656
@@ -1627,14 +2670,13 @@
1627 2670 try {
1628 2671 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1629 2672 $db = new Metasync_Otto_Excluded_URLs_Database();
1630 2673 global $wpdb;
1631 - $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
1632 2674 # Normalize the same way is_url_excluded() does
1633 2675 $url_normalized = rtrim(trim($url), '/');
1634 - $records = $wpdb->get_results(
2676 + $records = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- webhook-path cleanup of auto-exclusions over the plugin custom table
1635 2677 $wpdb->prepare(
1636 - "SELECT id FROM `{$table}` WHERE url_pattern = %s AND auto_excluded = 1 AND status = 'active'",
2678 + "SELECT id FROM `{$wpdb->prefix}metasync_otto_excluded_urls` WHERE url_pattern = %s AND auto_excluded = 1 AND status = 'active'",
1637 2679 $url_normalized
1638 2680 )
1639 2681 );
1640 2682 if (!empty($records)) {
@@ -1664,24 +2706,26 @@
1664 2706 }
1665 2707 try {
1666 2708 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1667 2709 global $wpdb;
1668 - $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
1669 2710 $url_normalized = rtrim(trim($url), '/');
1670 2711
1671 2712 $records = get_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY);
1672 2713
1673 2714 if ($records === false) {
1674 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- no user input, table name from $wpdb->prefix
1675 - $records = $wpdb->get_results(
1676 - "SELECT url_pattern, pattern_type FROM `{$table}` WHERE status = 'active' AND (auto_excluded = 0 OR auto_excluded IS NULL) ORDER BY created_at DESC"
2715 + $records = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- render-path read IS cached via get/set_transient with invalidation on every write — wp_cache_* is not used because the payload must survive object-cache-only setups
2716 + "SELECT url_pattern, pattern_type FROM `{$wpdb->prefix}metasync_otto_excluded_urls` WHERE status = 'active' AND (auto_excluded = 0 OR auto_excluded IS NULL) ORDER BY created_at DESC"
1677 2717 );
1678 2718
1679 2719 // Graceful recovery: auto_excluded column missing on pre-v2.7.4 installs.
1680 - // Run ALTER TABLE to add it and treat URL as not excluded so OTTO continues rendering.
2720 + // Schema changes belong to the upgrade routines, never the render path:
2721 + // degrade to "not excluded" so OTTO keeps rendering, and schedule a
2722 + // one-shot background repair that runs the same column migration off
2723 + // the visitor request (handled by metasync_repair_otto_excluded_schema).
1681 2724 if ($wpdb->last_error && strpos($wpdb->last_error, 'auto_excluded') !== false) {
1682 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1683 - $wpdb->query("ALTER TABLE `{$table}` ADD COLUMN `auto_excluded` TINYINT(1) NOT NULL DEFAULT 0");
2725 + if (false === wp_next_scheduled('metasync_repair_otto_excluded_schema')) {
2726 + wp_schedule_single_event(time() + MINUTE_IN_SECONDS, 'metasync_repair_otto_excluded_schema');
2727 + }
1684 2728 return false;
1685 2729 }
1686 2730
1687 2731 set_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY, $records ?: [], METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL);
@@ -1691,27 +2735,16 @@
1691 2735 return false;
1692 2736 }
1693 2737
1694 2738 foreach ($records as $excluded) {
1695 - $pattern = rtrim(trim($excluded->url_pattern), '/');
1696 - $pattern_type = $excluded->pattern_type;
1697 -
1698 - switch ($pattern_type) {
1699 - case 'exact':
1700 - if ($url_normalized === $pattern) {
1701 - return true;
1702 - }
1703 - break;
1704 - case 'contain':
1705 - if (strpos($url_normalized, $pattern) !== false) {
1706 - return true;
1707 - }
1708 - break;
1709 - case 'start':
1710 - if (strpos($url_normalized, $pattern) === 0) {
1711 - return true;
1712 - }
1713 - break;
2739 + # Delegate to the shared matcher so this gate supports every pattern type the
2740 + # add handler accepts (exact|contain|start|end|regex). Keeping a second switch
2741 + # here is what let `end` and `regex` exclusions be silently ignored on the
2742 + # visitor render path while still applying on the queue/SEO-write path.
2743 + # The raw pattern is passed through - the matcher normalizes it per type,
2744 + # because a regex must keep the trailing slash that closes its delimiters.
2745 + if (Metasync_Otto_Excluded_URLs_Database::pattern_matches($url_normalized, $excluded->url_pattern, $excluded->pattern_type)) {
2746 + return true;
1714 2747 }
1715 2748 }
1716 2749 return false;
1717 2750 } catch (Exception $e) {
@@ -1768,9 +2801,9 @@
1768 2801 }
1769 2802
1770 2803 # WooCommerce product by slug
1771 2804 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
1772 - $product_slug = basename(parse_url($route, PHP_URL_PATH));
2805 + $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1773 2806 $products = wc_get_products(array('name' => $product_slug, 'limit' => 1, 'status' => 'publish'));
1774 2807 if (!empty($products)) {
1775 2808 $post_id = $products[0]->get_id();
1776 2809 } else {
@@ -1797,9 +2830,9 @@
1797 2830 }
1798 2831
1799 2832 # Category
1800 2833 if (strpos($route, '/category/') !== false) {
1801 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2834 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1802 2835 $category = get_category_by_slug($category_slug);
1803 2836 if ($category) {
1804 2837 return !metasync_would_term_return_404($category->term_id, 'category', $route);
1805 2838 }
@@ -1806,9 +2839,9 @@
1806 2839 }
1807 2840
1808 2841 # WooCommerce product category
1809 2842 if (strpos($route, '/product-category/') !== false) {
1810 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2843 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1811 2844 $term = get_term_by('slug', $category_slug, 'product_cat');
1812 2845 if ($term && !is_wp_error($term)) {
1813 2846 return !metasync_would_term_return_404($term->term_id, 'product_cat', $route);
1814 2847 }
@@ -1850,9 +2883,9 @@
1850 2883
1851 2884 $removed = 0;
1852 2885 $marked_permanent = 0;
1853 2886 $thirty_days_ago = strtotime('-30 days');
1854 - $next_recheck = date('Y-m-d H:i:s', current_time('timestamp') + 7 * DAY_IN_SECONDS);
2887 + $next_recheck = gmdate('Y-m-d H:i:s', current_time('timestamp') + 7 * DAY_IN_SECONDS);
1855 2888
1856 2889 foreach ($records as $record) {
1857 2890 $url = trim($record->url_pattern);
1858 2891 if (empty($url)) {
@@ -1873,16 +2906,10 @@
1873 2906 }
1874 2907 }
1875 2908 }
1876 2909
1877 - if ($removed > 0) {
1878 - error_log("MetaSync OTTO: Recheck 404 exclusions - removed {$removed} URL(s) that are now available");
1879 - }
1880 - if ($marked_permanent > 0) {
1881 - error_log("MetaSync OTTO: Recheck 404 exclusions - marked {$marked_permanent} URL(s) as permanent (still 404 after 30 days)");
1882 - }
1883 2910 } catch (Exception $e) {
1884 - error_log('MetaSync OTTO: Recheck 404 exclusions failed - ' . $e->getMessage());
2911 + error_log('MetaSync OTTO: Recheck 404 exclusions failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1885 2912 }
1886 2913 }
1887 2914
1888 2915 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
@@ -1887,8 +2914,24 @@
1887 2914
1888 2915 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
1889 2916
1890 2917 /**
2918 + * Background repair of the metasync_otto_excluded_urls schema (cron).
2919 + *
2920 + * Scheduled by metasync_is_otto_url_manually_excluded() when a render-path
2921 + * read fails because the auto_excluded column is missing (pre-2.7.4 install
2922 + * whose version migration did not run). Runs the DB layer's self-healing
2923 + * column migration off the visitor request instead of ALTERing inline.
2924 + */
2925 +function metasync_repair_otto_excluded_schema()
2926 +{
2927 + require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2928 + Metasync_Otto_Excluded_URLs_Database::ensure_schema();
2929 +}
2930 +
2931 +add_action('metasync_repair_otto_excluded_schema', 'metasync_repair_otto_excluded_schema');
2932 +
2933 +/**
1891 2934 * Check if a post/page would return 404 without making HTTP request
1892 2935 * Uses WordPress database checks for fast validation
1893 2936 *
1894 2937 * @param int $post_id WordPress post ID
@@ -1996,5 +3039,88 @@
1996 3039 # Check if this post has Brizy metadata
1997 3040 if (get_post_meta($post_id, 'brizy_post_uid', true)) {
1998 3041 delete_transient('metasync_has_brizy_posts');
1999 3042 }
2000 -}, 10, 1);
3043 +}, 10, 1);
3044 +
3045 +/**
3046 + * Identify infrastructure agents that must never hit the render throttle.
3047 + *
3048 + * Two classes of agent are covered:
3049 + *
3050 + * - Page-cache warmers. A host preloader (WP Cloud PageCacheBot, WP Rocket preload,
3051 + * LiteSpeed, SG Optimizer, ...) exists purely to populate the page cache. Throttling
3052 + * one returns un-OTTO'd HTML *and* defines DONOTCACHEPAGE, so the cache can never be
3053 + * filled and every subsequent human visitor pays a full uncached render. Measured on
3054 + * the reporting site: desktop cache MISS on 17/17 requests at ~2.8s TTFB, versus
3055 + * ~0.56s with OTTO disabled.
3056 + *
3057 + * - Synthetic performance auditors (Lighthouse / PageSpeed Insights / GTmetrix).
3058 + * These are what customers measure with, so they must receive the same fully
3059 + * rendered OTTO output a real visitor gets. Currently `/lighthouse/i` and
3060 + * `/pagespeed/i` are generic-bot patterns, so a second PSI run inside the 5-minute
3061 + * window is served the throttled, un-OTTO'd, uncacheable page.
3062 + *
3063 + * Matching is done on the raw user-agent, deliberately independent of the bot-name
3064 + * categorisation in Metasync_Otto_Bot_Detector.
3065 + *
3066 + * @param mixed $detection Result of Metasync_Otto_Bot_Detector::detect(), or malformed input.
3067 + * @return bool True when this request must bypass the render throttle.
3068 + */
3069 +function metasync_otto_is_unthrottled_infrastructure_agent( $detection = array() ) {
3070 +
3071 + $ua = '';
3072 + if ( is_array( $detection ) && ! empty( $detection['user_agent'] ) ) {
3073 + $ua = (string) $detection['user_agent'];
3074 + } elseif ( isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
3075 + $ua = (string) $_SERVER['HTTP_USER_AGENT'];
3076 + }
3077 +
3078 + $needles = array(
3079 + // Page-cache preloaders.
3080 + 'pagecachebot', // WP Cloud / Automattic (PageCacheBotDesktop|Mobile)
3081 + 'wp rocket', // WP Rocket preload
3082 + 'wprocket',
3083 + 'lscache_runner', // LiteSpeed Cache crawler
3084 + 'sg-optimizer', // SiteGround
3085 + 'sgoptimizer',
3086 + 'nitropack',
3087 + 'cache-warmer',
3088 + 'cachewarmer',
3089 + 'cache warmer',
3090 + // Synthetic performance auditors.
3091 + 'lighthouse',
3092 + 'pagespeed',
3093 + 'gtmetrix',
3094 + 'pingdom',
3095 + 'webpagetest',
3096 + );
3097 +
3098 + $is_infra = false;
3099 +
3100 + if ( '' !== $ua ) {
3101 + $ua_lower = strtolower( $ua );
3102 + foreach ( $needles as $needle ) {
3103 + if ( false !== strpos( $ua_lower, $needle ) ) {
3104 + $is_infra = true;
3105 + break;
3106 + }
3107 + }
3108 + }
3109 +
3110 + // WP Cloud's warmer also tags its requests with a query marker.
3111 + if ( ! $is_infra && isset( $_GET['x-cache-engine'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only flag for render throttling
3112 + $is_infra = true;
3113 + }
3114 +
3115 + /**
3116 + * Allow a site or host to declare additional preload agents.
3117 + *
3118 + * @param bool $is_infra Whether this request bypasses the OTTO render throttle.
3119 + * @param string $ua The request user-agent.
3120 + */
3121 + if ( function_exists( 'apply_filters' ) ) {
3122 + $is_infra = (bool) apply_filters( 'metasync_otto_unthrottled_agent', $is_infra, $ua );
3123 + }
3124 +
3125 + return $is_infra;
3126 +}