PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.1
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.1
2.7.1 2.7.0 2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 All 130 releases
wdesignkit / includes / posimyth-sdk / class-posimyth-tracker-base.php

class-posimyth-tracker-base.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.1, at includes/posimyth-sdk/class-posimyth-tracker-base.php

1,298 lines 54.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * POSIMYTH Analytics — shared client tracker base.
4 *
5 * Ship this file UNCHANGED inside every POSIMYTH plugin's `include/posimyth-sdk/`
6 * folder. Each plugin provides a tiny subclass (Posimyth_Tracker_NE / _TPAE / _TPGB)
7 * that only declares plugin-specific keys. All collection, scanning, consent-gating
8 * and transport lives here so the three plugins never drift apart.
9 *
10 * Privacy: this payload carries no email, no IP, no user names and no post content.
11 *
12 * It DOES carry the site URL, and the hub stores that URL in plaintext so support can tell which
13 * install a report came from. Earlier revisions of this comment said the hub only kept a one-way
14 * SHA-256 hash of it; that was wrong — the hub's hash is a row-dedup key that sits alongside the
15 * raw value, not a substitute for it. Do not describe this data as anonymous.
16 *
17 * The one exception to "no email" is the deactivation dialog's "I agree to be contacted" opt-in,
18 * which attaches the admin email — and only when the user ticks that box. See
19 * Posimyth_Deactivation_Survey::handle_ajax().
20 *
21 * @package POSIMYTH\Analytics\SDK
22 */
23
24 if ( ! defined( 'ABSPATH' ) ) {
25 exit;
26 }
27
28 if ( ! class_exists( 'Posimyth_Tracker_Base' ) ) {
29
30 /**
31 * Shared analytics tracker. Owns the hooks, the payload, the environment/content scanners and
32 * the transport; each product subclasses it to supply its own identity and feature toggles.
33 */
34 abstract class Posimyth_Tracker_Base {
35
36 /**
37 * Guard so each concrete subclass boots its hooks only once.
38 *
39 * @var array<string,bool>
40 */
41 protected static $booted = array();
42
43 // Per-plugin configuration — implemented by each subclass.
44
45 /** Short unique id, used for cron + option names. e.g. 'ne', 'tpae', 'tpgb'. */
46 abstract protected static function id(): string;
47
48 /** WordPress plugin folder slug, e.g. 'nexter-extension'. */
49 abstract protected static function slug(): string;
50
51 /** Option key that stores the user's opt-in (true = consent given). */
52 abstract protected static function opt_in_option(): string;
53
54 /** Current plugin version string. */
55 abstract protected static function version(): string;
56
57 /** Whether the Pro/premium build is active. */
58 abstract protected static function is_pro(): bool;
59
60 /**
61 * Map of feature/widget/block slug => bool, representing which features
62 * are ENABLED in settings (toggled on). Counts of actual usage come from
63 * used_features().
64 *
65 * @return array<string,bool>
66 */
67 abstract protected static function enabled_features(): array;
68
69 /* ----- Optional config: subclasses override only when relevant ----- */
70
71 /**
72 * Map of feature slug => integer usage count, scanned from real content.
73 * Default empty (e.g. Nexter Extension: extensions are toggle-only).
74 *
75 * @return array<string,int>
76 */
77 protected static function used_features(): array {
78 return array();
79 }
80
81 /** License status + plan. Default empty for free-only products. */
82 protected static function license(): array {
83 return array(
84 'status' => '',
85 'plan' => '',
86 );
87 }
88
89 /** Onboarding state string ('' when the plugin has no onboarding flow). */
90 protected static function onboarding_status(): string {
91 return '';
92 }
93
94 /** Human-readable product name, used in the privacy-policy suggestion. */
95 protected static function display_name(): string {
96 return static::slug();
97 }
98
99 // Bootstrap + WordPress hooks.
100
101 /**
102 * Registers this product's hooks. Safe to call more than once.
103 */
104 public static function init(): void {
105 $class = static::class;
106 if ( ! empty( self::$booted[ $class ] ) ) {
107 return;
108 }
109 self::$booted[ $class ] = true;
110
111 add_action( 'activated_plugin', array( static::class, 'on_activate' ), 10, 2 );
112 add_action( 'deactivated_plugin', array( static::class, 'on_deactivate' ), 10, 2 );
113 add_action( static::cron_hook(), array( static::class, 'heartbeat' ) );
114
115 // Listener for events queued by do_request() on hosts where the response cannot be flushed.
116 // Registered unconditionally and before the multisite bail below: a queued event has to be
117 // runnable on whichever blog the cron request lands on, and an unregistered hook would leave
118 // the event firing against nothing forever.
119 add_action( static::async_hook(), array( static::class, 'run_async_request' ), 10, 3 );
120 add_action( 'admin_init', array( static::class, 'register_privacy_policy_content' ) );
121 add_action( 'admin_init', array( static::class, 'maybe_catchup_heartbeat' ) );
122
123 // One heartbeat per INSTALL, not per blog (A6): on a network-activated multisite this
124 // scheduled a weekly event on every single blog — N crons and N pings for one install.
125 // The network reports once, from the main site.
126 if ( is_multisite() && ! is_main_site() ) {
127 return;
128 }
129
130 if ( ! wp_next_scheduled( static::cron_hook() ) ) {
131 wp_schedule_event( time(), 'weekly', static::cron_hook() );
132 }
133 }
134
135 /**
136 * Runs an overdue heartbeat from an admin visit when WP-Cron is not firing (E7).
137 *
138 * On DISABLE_WP_CRON hosts, aggressive page caches or dormant sites, the weekly event can sit
139 * past due forever and the install goes silent. A day past due means cron is not coming; an
140 * admin page load then sends the ping (still consent-gated, still deferred to shutdown) and
141 * reschedules. The transient stops this from probing more than once a day.
142 */
143 public static function maybe_catchup_heartbeat(): void {
144 if ( is_multisite() && ! is_main_site() ) {
145 return;
146 }
147 if ( ! static::has_consent() ) {
148 return;
149 }
150
151 $next = wp_next_scheduled( static::cron_hook() );
152 if ( $next && $next > time() - DAY_IN_SECONDS ) {
153 return; // Scheduled and not meaningfully overdue.
154 }
155
156 $lock = 'posimyth_' . static::id() . '_hb_catchup';
157 if ( get_transient( $lock ) ) {
158 return;
159 }
160 set_transient( $lock, 1, DAY_IN_SECONDS );
161
162 wp_clear_scheduled_hook( static::cron_hook() );
163 wp_schedule_event( time() + WEEK_IN_SECONDS, 'weekly', static::cron_hook() );
164
165 static::heartbeat();
166 }
167
168 /**
169 * Feeds suggested text into WordPress's own Privacy Policy generator (Tools → Privacy), so a
170 * site owner writing their policy is told about this plugin's data sharing instead of having to
171 * discover it. Registered from init(), which is already behind the white-label gate — a
172 * rebranded install must not surface POSIMYTH's name here either.
173 */
174 public static function register_privacy_policy_content(): void {
175 if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) {
176 return;
177 }
178
179 $name = static::display_name();
180
181 /*
182 * This paragraph used to end with "nothing is sent unless an administrator turns on Share
183 * Non-Sensitive Details", which is not true: submitting the deactivation feedback form sends
184 * that one submission regardless of the setting, because pressing Submit there IS the
185 * consent for it. The wording now states the exception instead of contradicting it.
186 */
187 $content = '<p>' . sprintf(
188 /* translators: %s: product name */
189 esc_html__( '%s can send non-sensitive information about this website to POSIMYTH Innovation (api.posimyth.com) to help diagnose conflicts and prioritise fixes. This is off by default, and while it is off nothing is collected or sent automatically.', 'wdesignkit' ),
190 esc_html( $name )
191 ) . '</p>';
192
193 $content .= '<p>' . esc_html__( 'When enabled, the information sent is: the site URL, the plugin version, PHP / MySQL / WordPress versions, server software, timezone, locale, memory limit, maximum upload size, permalink structure, whether HTTPS / multisite / debug mode are on, the environment type, the number of registered users, the active theme and its version, the detected page builder, the list of active plugin folders and their count, which of the plugin\'s features are enabled and how often they are used, Pro/Free and licence status, and the install date. It is sent when the plugin is activated, when it is deactivated, and once a week in the background.', 'wdesignkit' ) . '</p>';
194
195 $content .= '<p>' . esc_html__( 'There is one case where information is sent even while the setting is off: the feedback form shown when the plugin is deactivated. If an administrator fills it in and presses Submit, that submission is sent - the reason chosen, any comment typed, and the same non-sensitive setup information listed above - because pressing Submit is the consent for it. Choosing Skip, pressing Esc or clicking outside the form sends nothing at all.', 'wdesignkit' ) . '</p>';
196
197 $content .= '<p>' . esc_html__( 'No post or page content, visitor data, user names or email addresses are included. The one exception is that same feedback form: if an administrator ticks "I agree to be contacted via email" there, the site administration email address is sent with the feedback so POSIMYTH can reply. That box is off by default.', 'wdesignkit' ) . '</p>';
198
199 wp_add_privacy_policy_content( $name, wp_kses_post( $content ) );
200 }
201
202 /**
203 * Name of this product's heartbeat cron hook.
204 *
205 * @return string
206 */
207 protected static function cron_hook(): string {
208 return 'posimyth_heartbeat_' . static::id();
209 }
210
211 /**
212 * Hook a deferred send runs on.
213 *
214 * Only used where fastcgi_finish_request() / litespeed_finish_request() are unavailable and the
215 * current request is user-facing — see do_request() for why. Product-specific, like the
216 * heartbeat hook, so two POSIMYTH plugins on one site never consume each other's events.
217 *
218 * @return string
219 */
220 protected static function async_hook(): string {
221 return 'posimyth_track_' . static::id();
222 }
223
224 /**
225 * Runs an event that do_request() queued rather than sending inline.
226 *
227 * Public because WordPress calls it through the cron hook. It re-enters do_request(), which is
228 * safe: by then this is a cron or CLI request, so the "nobody is waiting" branch sends it
229 * properly instead of queueing it a second time.
230 *
231 * @param string $event Event name.
232 * @param array $extra Extra payload fields.
233 * @param bool $bypass_consent Whether the original call bypassed the consent check.
234 * @return void
235 */
236 public static function run_async_request( $event = 'heartbeat', $extra = array(), $bypass_consent = false ): void {
237 static::do_request( (string) $event, is_array( $extra ) ? $extra : array(), (bool) $bypass_consent );
238 }
239
240 /**
241 * Stops the weekly heartbeat. Call from the plugin's deactivation hook.
242 *
243 * Init() schedules this cron but nothing ever cleared it, so a deactivated plugin left a weekly
244 * event behind in WordPress forever — firing against a hook with no listener, and reappearing in
245 * every cron listing.
246 *
247 * @return void
248 */
249 public static function unschedule(): void {
250 wp_clear_scheduled_hook( static::cron_hook() );
251
252 /*
253 * Also drop any event do_request() queued but that has not run yet.
254 *
255 * These are one-off events carrying their own arguments; wp_clear_scheduled_hook() clears
256 * every argument set for the hook, which is what is wanted. Without this, deactivating left
257 * a pending `posimyth_track_*` event behind — firing against a hook with no listener once
258 * the plugin is gone, and, worse, reporting a stale activation or heartbeat if the plugin
259 * were reactivated before it fired.
260 */
261 wp_clear_scheduled_hook( static::async_hook() );
262 }
263
264 /**
265 * Removes everything this product stored. Call from uninstall, never from deactivation.
266 *
267 * Consent used to survive uninstalling: the opt-in option stayed in wp_options, so reinstalling
268 * silently resumed sending without ever asking again. Someone who removes the plugin has
269 * withdrawn from the arrangement, and reinstalling has to start from a clean slate.
270 *
271 * @param bool $include_suite_wide Also delete the options shared across the Nexter family.
272 * Pass false while a sibling product is still installed,
273 * otherwise removing one plugin silently resets the other's
274 * consent and re-prompts a user who already decided.
275 * @param string $suite_key Suite key those shared options are named after.
276 * @return void
277 */
278 public static function purge_state( bool $include_suite_wide = false, string $suite_key = 'nexter_suite' ): void {
279 static::unschedule();
280
281 $id = static::id();
282 delete_option( 'posimyth_' . $id . '_install_time' );
283 delete_option( 'posimyth_' . $id . '_usage' );
284 delete_option( 'posimyth_' . $id . '_first_use_at' );
285 delete_option( 'posimyth_' . $id . '_activate_reported' );
286 delete_transient( 'posimyth_' . $id . '_deact_reported' );
287
288 if ( $include_suite_wide ) {
289 // These are stored as SITE options (A6: one consent per install, not per blog), so they
290 // have to be removed as site options too — delete_option alone would leave the network
291 // value behind and a reinstall would silently resume with the old answer.
292 delete_site_option( static::opt_in_option() );
293 delete_site_option( 'posi_consent_dismissed_' . $suite_key );
294 delete_site_option( 'posi_consent_snoozed_until_' . $suite_key );
295 // Start of the notice's post-install quiet period. Left behind, a reinstall would inherit
296 // an expired window and be asked on the first admin page load instead of getting the
297 // couple of quiet days a fresh install is meant to get.
298 delete_site_option( 'posi_consent_grace_start_' . $suite_key );
299
300 /*
301 * These two belong to the Nexter suite specifically, not to whichever suite is being
302 * purged, and their names are fixed rather than derived from $suite_key.
303 *
304 * That was harmless while `nexter_suite` was the only suite in existence. It stopped being
305 * harmless when The Plus Addons for Elementor moved to its own `tpae_suite`: deleting TPAE
306 * then reached across and wiped Nexter's onboarding-completed flag and its legacy-consent
307 * migration marker, so Nexter's setup wizard could reopen and the migration could run a
308 * second time — on a site where Nexter had not been touched at all.
309 */
310 if ( 'nexter_suite' === $suite_key ) {
311 delete_site_option( 'posimyth_ne_legacy_consent_migrated' );
312 delete_option( 'nxt_onboarding_done' );
313 }
314
315 // Older builds wrote these per-blog; clear that shape too so nothing lingers.
316 delete_option( static::opt_in_option() );
317 delete_option( 'posi_consent_dismissed_' . $suite_key );
318 delete_option( 'posi_consent_snoozed_until_' . $suite_key );
319 delete_option( 'posi_consent_grace_start_' . $suite_key );
320 }
321 }
322
323 /**
324 * Is this the plugin file belonging to THIS product?
325 *
326 * Using strpos( $plugin, slug() ) was wrong: `nexter-extension` is a substring of
327 * `nexter-extension-pro/...` and of `my-nexter-extension/...`, so activating or deactivating a
328 * differently-named plugin reported an event against this one. Compare the folder exactly.
329 *
330 * @param string $plugin Plugin file as WordPress passes it, e.g. "slug/slug.php".
331 * @return bool
332 */
333 protected static function is_own_plugin_file( string $plugin ): bool {
334 $folder = ( false !== strpos( $plugin, '/' ) ) ? dirname( $plugin ) : $plugin;
335 return static::slug() === $folder;
336 }
337
338 /**
339 * Reports an activation, but only for THIS product and only with consent.
340 *
341 * @param string $plugin Plugin file that was activated.
342 * @param bool $network_wide Unused; part of the activated_plugin hook signature.
343 */
344 public static function on_activate( string $plugin, bool $network_wide ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- required by the hook signature.
345 if ( ! static::is_own_plugin_file( $plugin ) ) {
346 return;
347 }
348 static::record_install_time();
349 if ( ! static::has_consent() ) {
350 return;
351 }
352 static::report_activation();
353 }
354
355 /**
356 * Reports a deactivation, but only for THIS product and only with consent.
357 *
358 * @param string $plugin Plugin file that was deactivated.
359 * @param bool $network_wide Unused; part of the deactivated_plugin hook signature.
360 */
361 public static function on_deactivate( string $plugin, bool $network_wide ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- required by the hook signature.
362 if ( ! static::is_own_plugin_file( $plugin ) ) {
363 return;
364 }
365 delete_option( 'posimyth_' . static::id() . '_activate_reported' );
366
367 if ( ! static::has_consent() ) {
368 return;
369 }
370
371 /*
372 * The deactivation dialog may have just reported this same deactivation, with the reason
373 * attached. WordPress then fires `deactivated_plugin` regardless, and this listener cannot
374 * see what the user clicked — so every opted-in deactivation was counted twice on the hub:
375 * once with a reason and once without, inflating churn for precisely the opted-in cohort.
376 * The dialog leaves a short-lived marker; if it is there, the richer event has already gone.
377 */
378 if ( get_transient( 'posimyth_' . static::id() . '_deact_reported' ) ) {
379 delete_transient( 'posimyth_' . static::id() . '_deact_reported' );
380 return;
381 }
382
383 static::do_request( 'deactivate' );
384 }
385
386 /**
387 * Marks this deactivation as already reported, so on_deactivate() does not send a second event.
388 *
389 * Called by the deactivation dialog right before it submits. Short-lived on purpose: it only
390 * has to survive the hop from the dialog's AJAX call to the deactivation request that follows.
391 *
392 * @return void
393 */
394 public static function mark_deactivation_reported(): void {
395 set_transient( 'posimyth_' . static::id() . '_deact_reported', 1, 5 * MINUTE_IN_SECONDS );
396 }
397
398 /**
399 * Sends `activate` at most once per active period.
400 *
401 * Four different places legitimately want to announce an activation — the notice's Allow, the
402 * Dashboard toggle, onboarding, and the plugin's own activation hook — and each called
403 * do_request('activate') directly, so one install could report several activations and the
404 * hub's activation count drifted away from the install count. The flag is cleared on
405 * deactivation, so a genuine reactivation is still counted.
406 *
407 * @return void
408 */
409 protected static function report_activation(): void {
410 $key = 'posimyth_' . static::id() . '_activate_reported';
411 if ( get_option( $key ) ) {
412 return;
413 }
414 update_option( $key, 1, false );
415 static::do_request( 'activate' );
416 }
417
418 /**
419 * Weekly background check-in, so long-lived installs keep reporting current state.
420 */
421 public static function heartbeat(): void {
422 if ( ! static::has_consent() ) {
423 return;
424 }
425 static::do_request( 'heartbeat' );
426 }
427
428 /**
429 * Activation ping for the plugin's OWN activation request.
430 *
431 * The `activated_plugin` hook in init() cannot help here: during a plugin's own activation
432 * request WordPress has already fired `plugins_loaded` before it includes the plugin file, so
433 * init() never runs and nothing is listening when `activated_plugin` fires. The result was
434 * that activations were never recorded, while deactivations (where the plugin IS already
435 * loaded) worked fine. Call this from register_activation_hook() instead.
436 *
437 * Consent-gated.
438 */
439 public static function on_self_activate(): void {
440 static::record_install_time();
441 if ( ! static::has_consent() ) {
442 return;
443 }
444 static::report_activation();
445 }
446
447 /**
448 * Called by the consent notice and the Dashboard toggle immediately after the user opts in.
449 *
450 * Both callers write the consent option before calling, so this guard is normally satisfied —
451 * it is here so the function is safe on its own terms rather than relying on every present and
452 * future caller remembering to set consent first.
453 */
454 public static function send_first_ping(): void {
455 static::record_install_time();
456 if ( ! static::has_consent() ) {
457 return;
458 }
459 static::report_activation();
460 }
461
462 /**
463 * Whether the user has opted in to sharing. Nothing is ever sent without this.
464 *
465 * Read as a SITE option (A6): on multisite the consent is one answer for the whole network —
466 * per-blog options meant N notices and N independent consents for a single install, and no
467 * way for the network admin to answer once. On a single site, get_site_option() falls back
468 * to the ordinary option of the same name, so nothing changes there.
469 *
470 * @return bool
471 */
472 protected static function has_consent(): bool {
473 return (bool) get_site_option( static::opt_in_option(), false );
474 }
475
476 /**
477 * Stores the first-seen timestamp once, so install age can be reported.
478 */
479 protected static function record_install_time(): void {
480 $key = 'posimyth_' . static::id() . '_install_time';
481 if ( ! get_option( $key ) ) {
482 add_option( $key, gmdate( 'Y-m-d H:i:s' ), '', false );
483 }
484 }
485
486 // Payload.
487
488 /**
489 * Assembles the non-sensitive payload for an event. Contains no personal data.
490 *
491 * @param string $event One of activate|deactivate|heartbeat.
492 * @return array
493 */
494 public static function build_payload( string $event ): array {
495 global $wpdb;
496
497 $theme = wp_get_theme();
498 $parent = $theme->parent();
499
500 // Network-activated plugins live in the sitewide option, not in the per-blog list (B3):
501 // on multisite, reading only active_plugins missed every network-activated plugin — this
502 // SDK's own products included — so plugin_count, page_builder and posimyth_products were
503 // all wrong there. Single site: get_site_option returns the default, merge is a no-op.
504 $active_slugs = (array) get_option( 'active_plugins', array() );
505 if ( is_multisite() ) {
506 $network = (array) get_site_option( 'active_sitewide_plugins', array() );
507 $active_slugs = array_values( array_unique( array_merge( $active_slugs, array_keys( $network ) ) ) );
508 }
509
510 $license = static::license();
511
512 /*
513 * Persist the first-seen date instead of substituting "now".
514 *
515 * When the option was missing — legacy installs that predate it, and subsites, where
516 * record_install_time() never ran — this reported today's date and then threw it away, so
517 * every weekly heartbeat claimed the site was installed today and install age was
518 * permanently zero. Write it once, then keep reporting that same value.
519 */
520 $install_time = (string) get_option( 'posimyth_' . static::id() . '_install_time', '' );
521 if ( '' === $install_time ) {
522 static::record_install_time();
523 $install_time = (string) get_option( 'posimyth_' . static::id() . '_install_time', gmdate( 'Y-m-d H:i:s' ) );
524 }
525
526 return array(
527 'plugin_slug' => static::slug(),
528 'site_url' => home_url(),
529 'event' => $event,
530
531 // Server & environment.
532 'php_version' => phpversion(),
533 // Server version string; no WP API exposes it and caching a constant makes no sense.
534 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
535 'mysql_version' => $wpdb->get_var( 'SELECT VERSION()' ),
536 'server_software' => isset( $_SERVER['SERVER_SOFTWARE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) : '',
537 'timezone' => wp_timezone_string(),
538 'debug_mode' => (int) ( defined( 'WP_DEBUG' ) && WP_DEBUG ),
539 'max_upload_size' => size_format( wp_max_upload_size() ),
540 'memory_limit' => defined( 'WP_MEMORY_LIMIT' ) ? WP_MEMORY_LIMIT : '',
541 'permalink' => static::permalink_label(),
542 'is_ssl' => (int) is_ssl(),
543 'is_local' => static::is_local_site(),
544 'environment' => function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : 'production',
545
546 // WordPress.
547 'wp_version' => get_bloginfo( 'version' ),
548 'locale' => get_locale(),
549 'is_multisite' => (int) is_multisite(),
550 'user_count' => static::user_count(),
551
552 // Theme.
553 'theme' => $theme->get_stylesheet(),
554 'theme_version' => (string) $theme->get( 'Version' ),
555 'parent_theme' => $parent ? $parent->get_stylesheet() : '',
556
557 // Plugins / ecosystem.
558 'page_builder' => static::detect_page_builder( $active_slugs ),
559 'theme_builder' => static::detect_theme_builder( $active_slugs ),
560 'active_plugins' => array_values( $active_slugs ),
561 'plugin_count' => count( $active_slugs ),
562 'posimyth_products' => static::detect_posimyth_products( $active_slugs ),
563
564 // Identity (Astra-style: admin email for Free + Pro, sent only under
565 // consent). Raw email is PII — the opt-in notice + readme must disclose it.
566 // NOTE: no admin email / no PII is sent — the consent copy promises "no personal data,
567 // ever", so the raw admin_email older builds included is intentionally omitted. Do not
568 // re-add email or any personal identifier here without changing that copy + the docs.
569
570 // This product.
571 'plugin_version' => static::version(),
572 'is_pro' => (int) static::is_pro(),
573 'license_status' => (string) ( $license['status'] ?? '' ),
574 'license_plan' => (string) ( $license['plan'] ?? '' ),
575 'onboarding' => static::onboarding_status(),
576 'install_time' => $install_time,
577
578 /*
579 * Feature adoption. Cast to objects so the JSON TYPE of each of these three keys is the
580 * same on every site.
581 *
582 * These are string-keyed maps, and wp_json_encode() renders an EMPTY PHP array as `[]`
583 * while a populated one becomes `{...}`. So a site with widgets installed but none
584 * placed in content sent `"enabled_widgets":{…}` alongside `"widget_usage":[]` — two
585 * different JSON types for one key, on one payload, for keys the hub joins against each
586 * other. plugin_meta has the same problem: the base returns array() and a product that
587 * does not override it sent `[]` where every other product sent an object.
588 *
589 * Casting an associative array to an object is a no-op for the populated case and turns
590 * the empty case into `{}`, so the hub sees one shape per key regardless of the site.
591 */
592 'enabled_widgets' => (object) static::enabled_features(),
593 'widget_usage' => (object) static::used_features_cached( $event ),
594 'plugin_meta' => (object) static::plugin_meta(),
595 );
596 }
597
598 /**
599 * Product-specific settings that do not deserve a column of their own.
600 *
601 * The hub has accepted a `plugin_meta` object since 1.5 (nested up to 8 levels, capped at 64 KB)
602 * and nothing has ever filled it. This is the place for the handful of settings that matter for
603 * one product only — an asset-loading mode, a compatibility toggle — without adding a shared
604 * column that stays NULL for every other product and without smuggling them into
605 * enabled_widgets, which is joined against widget_usage and must stay a widget map.
606 *
607 * Keep it small and keep it non-personal: the same rules as the rest of the payload. Report the
608 * stored value rather than the label shown in the UI, so a wording change in the dashboard does
609 * not split one setting into two series on the hub.
610 *
611 * @return array<string,mixed>
612 */
613 protected static function plugin_meta(): array {
614 return array();
615 }
616
617 /**
618 * Heavy content scans run only on the weekly background heartbeat and are
619 * cached. Activate / deactivate (user-facing actions) read the cache so the
620 * admin never waits on a scan.
621 *
622 * @param string $event Current event; only the heartbeat refreshes the cache.
623 * @return array<string,int>
624 */
625 protected static function used_features_cached( string $event ): array {
626 $key = 'posimyth_' . static::id() . '_usage';
627
628 if ( 'heartbeat' === $event ) {
629 $usage = static::used_features();
630 update_option( $key, $usage, false );
631 return $usage;
632 }
633
634 return (array) get_option( $key, array() );
635 }
636
637 // Environment helpers.
638
639 /**
640 * Permalink structure as a short label rather than the raw pattern.
641 *
642 * @return string
643 */
644 protected static function permalink_label(): string {
645 $structure = get_option( 'permalink_structure', '' );
646 if ( '' === $structure ) {
647 return 'plain';
648 }
649 if ( '/%postname%/' === $structure ) {
650 return 'post-name';
651 }
652 if ( false !== strpos( $structure, '%postname%' ) ) {
653 return 'custom-postname';
654 }
655 if ( false !== strpos( $structure, '%year%' ) || false !== strpos( $structure, '%post_id%' ) ) {
656 return 'date-or-numeric';
657 }
658 return 'custom';
659 }
660
661 /**
662 * Whether this looks like a local/dev site, so hub stats can exclude it.
663 *
664 * @return int
665 */
666 protected static function is_local_site(): int {
667 if ( function_exists( 'wp_get_environment_type' ) ) {
668 $env = wp_get_environment_type();
669 if ( in_array( $env, array( 'local', 'development' ), true ) ) {
670 return 1;
671 }
672 }
673
674 $host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
675 if ( '' === $host ) {
676 return 0;
677 }
678 $host = strtolower( $host );
679
680 $exact = array( 'localhost', '127.0.0.1', '::1' );
681 if ( in_array( $host, $exact, true ) ) {
682 return 1;
683 }
684
685 // NOT '.dev': it has been a real, publicly registered gTLD since 2019 (with HSTS preload,
686 // so these are HTTPS production sites) — treating it as local silently excluded them (E5).
687 $suffixes = array( '.local', '.test', '.localhost', '.example', '.invalid' );
688 foreach ( $suffixes as $suffix ) {
689 if ( substr( $host, -strlen( $suffix ) ) === $suffix ) {
690 return 1;
691 }
692 }
693 return 0;
694 }
695
696 /**
697 * Total registered users — a size signal only, no user data.
698 *
699 * @return int
700 */
701 protected static function user_count(): int {
702 /*
703 * count_users() is not cached by WordPress and walks the whole user table, which is
704 * genuinely slow on a membership site with hundreds of thousands of users. This value only
705 * has to be roughly right — it is a size band, not an exact figure — so a day of cache
706 * costs nothing and stops a weekly ping from turning into a table scan.
707 */
708 $cache_key = 'posimyth_' . static::id() . '_user_count';
709 $cached = get_transient( $cache_key );
710 if ( false !== $cached ) {
711 return (int) $cached;
712 }
713
714 $counts = count_users();
715 $total = isset( $counts['total_users'] ) ? (int) $counts['total_users'] : 0;
716 set_transient( $cache_key, $total, DAY_IN_SECONDS );
717
718 return $total;
719 }
720
721 /**
722 * Which sibling POSIMYTH products are active alongside this one.
723 *
724 * @param array $plugins Active plugin files.
725 * @return array<int,string>
726 */
727 protected static function detect_posimyth_products( array $plugins ): array {
728 $known = array(
729 'nexter-extension' => 'nexter-extension',
730 'the-plus-addons-for-elementor-page-builder' => 'the-plus-addons-for-elementor',
731 'the-plus-addons-for-block-editor' => 'the-plus-addons-for-block-editor',
732 'nexter' => 'nexter-theme',
733 'uichemy' => 'uichemy',
734 'wdesignkit' => 'wdesignkit',
735 // Sticky Header Effects for Elementor. Added to SHE's own copy in SDK 2.7.0 and merged
736 // back here in 2.9.0 — while it was only in SHE's copy, any site where an older sibling
737 // won the loader reported no SHE at all.
738 'sticky-header-effects-for-elementor' => 'sticky-header-effects',
739 );
740
741 $found = array();
742 foreach ( $plugins as $file ) {
743 $dir = explode( '/', $file )[0];
744 if ( isset( $known[ $dir ] ) ) {
745 $found[ $known[ $dir ] ] = true;
746 }
747 }
748 return array_keys( $found );
749 }
750
751 /**
752 * Page builder in use, so conflicts can be reproduced on the right stack.
753 *
754 * @param array $plugins Active plugin files.
755 * @return string
756 */
757 protected static function detect_page_builder( array $plugins ): string {
758 $known = array(
759 'elementor' => 'elementor',
760 'beaver-builder-plugin' => 'beaver-builder',
761 'brizy' => 'brizy',
762 'bricks' => 'bricks',
763 'divi-builder' => 'divi',
764 'js_composer' => 'wpbakery',
765 'oxygen' => 'oxygen',
766 'siteorigin-panels' => 'siteorigin',
767 );
768
769 foreach ( $plugins as $file ) {
770 $dir = explode( '/', $file )[0];
771 if ( isset( $known[ $dir ] ) ) {
772 return $known[ $dir ];
773 }
774 }
775 // Bricks ships as a theme, not a plugin.
776 if ( 'bricks' === strtolower( (string) wp_get_theme()->get_template() ) ) {
777 return 'bricks';
778 }
779 return 'gutenberg';
780 }
781
782 /**
783 * Which theme builder the site actually BUILDS with.
784 *
785 * Deliberately separate from detect_page_builder() and from active_plugins. Those answer "what
786 * is installed"; this answers "what did they build their header, footer and archive templates
787 * with", which is the question you cannot get from a plugin list — Elementor Pro being active
788 * says nothing about whether its Theme Builder was ever opened.
789 *
790 * Each candidate is proved by PUBLISHED template content, not by the plugin being present. The
791 * first match wins and the rest are skipped, so a typical site costs one or two cached counts.
792 * Order is intentional: our own builders first (a Nexter site that also has Elementor Pro is a
793 * Nexter theme-builder site), then the common third parties.
794 *
795 * Returns a single slug so the hub can chart a distribution the same way it does page_builder.
796 * A site using two builders reports the higher-priority one; co-presence is still visible in
797 * active_plugins if it ever needs cross-checking.
798 *
799 * @param array $plugins Active plugin files, used to decide who owns Elementor theme templates.
800 * @return string Slug, or 'none' when no template content exists anywhere.
801 */
802 protected static function detect_theme_builder( array $plugins = array() ): string {
803 /**
804 * Theme builders to test, in the order they should win.
805 *
806 * Each value is the post type whose published entries prove that builder is in use, except
807 * `elementor-pro`, whose marker is the literal 'elementor-pro' — its library is shared with
808 * page and section templates, so it needs the template-type check below rather than a count.
809 *
810 * @param array<string,string> $map slug => post type, or slug => 'elementor-pro'.
811 */
812 $map = apply_filters(
813 'posimyth_theme_builder_post_types',
814 array(
815 // Nexter Extension's Theme Builder. Nexter Blocks has NO theme builder of its own —
816 // it registers no post type at all and consumes this same `nxt_builder` CPT, which is
817 // why there is no 'nexter-blocks' entry here. An earlier revision listed one against
818 // `tpgb_template`; nothing ever registers that post type, so the entry could only
819 // ever be skipped. A Blocks-only site therefore falls through to whatever it really
820 // uses, or to 'none'.
821 'nexter' => 'nxt_builder',
822
823 /*
824 * Third-party builders below. A wrong post-type name here does not break anything —
825 * post_type_exists() simply never matches — but it does under-report that competitor
826 * for as long as it is wrong, which is worse than leaving it out, because the data
827 * looks complete. Confirm each against a real install before trusting its share; the
828 * `posimyth_theme_builder_post_types` filter is the no-deploy way to correct one.
829 */
830
831 // Elementor ecosystem.
832 'elementor-pro' => 'elementor-pro',
833 'jet-theme-core' => 'jet-theme-core',
834
835 // Other page builders with a theme-building layer.
836 'beaver-themer' => 'fl-theme-layout',
837 'divi' => 'et_theme_builder',
838 'oxygen' => 'ct_template',
839 'bricks' => 'bricks_template',
840 'breakdance' => 'breakdance_template',
841 'zion' => 'zion_template',
842 'avada' => 'fusion_tb_section',
843 'themify' => 'tbuilder_layout',
844 'toolset' => 'dd_layouts',
845
846 // Theme-supplied builders, mostly block based.
847 'generatepress' => 'gp_elements',
848 'astra' => 'astra-advanced-hook',
849 'blocksy' => 'ct_content_block',
850 'kadence' => 'kadence_element',
851 'neve' => 'neve_custom_layouts',
852
853 /*
854 * WordPress's own Site Editor, last because it is the least specific answer: a site
855 * running a dedicated theme builder usually has customised block templates as well,
856 * and the dedicated tool is the more useful attribution.
857 *
858 * Note what this actually measures. A block theme's templates live as FILES until the
859 * user edits one, at which point WordPress saves a `wp_template` post. So a published
860 * row here means they opened the Site Editor and changed something — which is exactly
861 * the "in use" signal wanted, not merely "runs a block theme".
862 */
863 'wp-site-editor' => 'wp_template',
864 )
865 );
866
867 $dirs = array();
868 foreach ( $plugins as $plugin_file ) {
869 $dirs[ explode( '/', (string) $plugin_file )[0] ] = true;
870 }
871
872 foreach ( $map as $slug => $post_type ) {
873 if ( 'elementor-pro' === $post_type ) {
874 if ( ! static::elementor_theme_builder_in_use() ) {
875 continue;
876 }
877
878 /*
879 * Theme templates exist in elementor_library — but that post type is shared, so
880 * finding them there does not say who built them. The Plus Addons registers its own
881 * theme locations through `elementor/theme/register_locations` and stores its
882 * templates in exactly the same place, so attributing them to Elementor Pro was wrong
883 * whenever Pro was not even installed. Credit whichever plugin present can actually
884 * serve them, checking Pro first because it owns the feature when both are there.
885 */
886 if ( isset( $dirs['elementor-pro'] ) ) {
887 return 'elementor-pro';
888 }
889 if ( isset( $dirs['the-plus-addons-for-elementor-page-builder'] ) || isset( $dirs['theplus_elementor_addon'] ) ) {
890 return 'tpae';
891 }
892
893 // Templates are there and nothing active can render them — most likely built by a
894 // plugin since removed. Say that, rather than crediting a plugin that is absent.
895 return 'elementor-library-orphaned';
896 }
897
898 if ( ! post_type_exists( $post_type ) ) {
899 continue;
900 }
901
902 $counts = wp_count_posts( $post_type );
903 if ( isset( $counts->publish ) && (int) $counts->publish > 0 ) {
904 return $slug;
905 }
906 }
907
908 return 'none';
909 }
910
911 /**
912 * Whether Elementor Pro's Theme Builder has published templates.
913 *
914 * `elementor_library` is shared by page, section and container templates, none of which are
915 * theme building, so the template type has to be checked. One prepared query rather than a
916 * WP_Query with a meta clause, because this runs while a payload is being assembled.
917 *
918 * @return bool
919 */
920 protected static function elementor_theme_builder_in_use(): bool {
921 if ( ! post_type_exists( 'elementor_library' ) ) {
922 return false;
923 }
924
925 global $wpdb;
926
927 $types = array( 'header', 'footer', 'single', 'single-post', 'single-page', 'archive', 'search-results', 'error-404' );
928
929 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one bounded existence check, no cache layer available for it.
930 $found = $wpdb->get_var(
931 $wpdb->prepare(
932 "SELECT p.ID FROM {$wpdb->posts} p
933 INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID
934 WHERE p.post_type = 'elementor_library'
935 AND p.post_status = 'publish'
936 AND m.meta_key = '_elementor_template_type'
937 AND m.meta_value IN (" . implode( ', ', array_fill( 0, count( $types ), '%s' ) ) . ')
938 LIMIT 1',
939 $types
940 )
941 );
942 // phpcs:enable
943
944 return ! empty( $found );
945 }
946
947 // Content scanners (reused by TPAE / TPGB subclasses).
948 //
949 // Bounded + batched: capped at `posimyth_scan_post_cap` rows (default
950 // 2000) and run only inside the weekly cron, so large sites stay safe.
951
952 /**
953 * Count Elementor widget usage by widgetType prefix (e.g. 'tp-').
954 *
955 * @param string $prefix Widget name prefix to match, e.g. 'tp-'.
956 * @return array<string,int>
957 */
958 protected static function scan_elementor_widgets( string $prefix ): array {
959 global $wpdb;
960
961 $counts = array();
962 $cap = (int) apply_filters( 'posimyth_scan_post_cap', 2000 );
963 $batch = 100;
964 $last_id = 0;
965 $scanned = 0;
966 $like = '%"widgetType":"' . $wpdb->esc_like( $prefix ) . '%';
967 $regex = '/"widgetType":"(' . preg_quote( $prefix, '/' ) . '[a-z0-9_-]+)"/';
968
969 while ( $scanned < $cap ) {
970 // Clamp the batch to what the cap still allows, so `posimyth_scan_post_cap` is an exact
971 // bound rather than a between-batches check that could overshoot by a whole batch.
972 $take = min( $batch, $cap - $scanned );
973
974 // Batched scan of Elementor's own meta blob; no core API can query inside it. Runs only
975 // on the weekly cron and the result is cached in an option, so per-query caching would
976 // add a second cache layer for a job that already runs at most once a week.
977 //
978 // Keyset pagination (meta_id > last, ORDER BY meta_id), not LIMIT/OFFSET (B5): with no
979 // ORDER BY the batches had no defined order at all, so rows could repeat or be skipped
980 // between them; and the batch is 100 rows because these are longtext blobs — 200 of
981 // them at once was a real memory spike on page-heavy sites.
982 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
983 $rows = $wpdb->get_results(
984 $wpdb->prepare(
985 "SELECT meta_id, meta_value FROM {$wpdb->postmeta}
986 WHERE meta_key = '_elementor_data' AND meta_value LIKE %s AND meta_id > %d
987 ORDER BY meta_id ASC LIMIT %d",
988 $like,
989 $last_id,
990 $take
991 ),
992 ARRAY_A
993 );
994
995 if ( empty( $rows ) ) {
996 break;
997 }
998
999 foreach ( $rows as $row ) {
1000 $last_id = (int) $row['meta_id'];
1001 if ( preg_match_all( $regex, $row['meta_value'], $m ) ) {
1002 foreach ( $m[1] as $widget ) {
1003 $counts[ $widget ] = ( $counts[ $widget ] ?? 0 ) + 1;
1004 }
1005 }
1006 }
1007
1008 $scanned += count( $rows );
1009 if ( count( $rows ) < $take ) {
1010 break;
1011 }
1012 }
1013
1014 return $counts;
1015 }
1016
1017 /**
1018 * Count Gutenberg block usage by namespace (e.g. 'tpgb/').
1019 *
1020 * @param string $block_namespace Block namespace with trailing slash, e.g. 'tpgb/'.
1021 * @return array<string,int>
1022 */
1023 protected static function scan_gutenberg_blocks( string $block_namespace ): array {
1024 global $wpdb;
1025
1026 $counts = array();
1027 $cap = (int) apply_filters( 'posimyth_scan_post_cap', 2000 );
1028 $batch = 100;
1029 $last_id = 0;
1030 $scanned = 0;
1031 $like = '%wp:' . $wpdb->esc_like( $block_namespace ) . '%';
1032 // Negative lookbehind skips the closing "/wp:tpgb/..." comment so each
1033 // block instance is counted once (opening tag only).
1034 $regex = '#(?<!/)wp:' . preg_quote( $block_namespace, '#' ) . '([a-z0-9-]+)#';
1035
1036 while ( $scanned < $cap ) {
1037 // Clamp to the remaining allowance — see the note in scan_elementor_widgets().
1038 $take = min( $batch, $cap - $scanned );
1039
1040 // Batched LIKE over post_content; WP_Query has no equivalent and would load full post
1041 // objects for rows we only pattern-match. Weekly cron only, result cached in an option.
1042 // Keyset pagination and a 100-row batch for the same reasons as the Elementor scan
1043 // above (B5): OFFSET with no ORDER BY repeats/skips rows, and these are full posts.
1044 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1045 $rows = $wpdb->get_results(
1046 $wpdb->prepare(
1047 "SELECT ID, post_content FROM {$wpdb->posts}
1048 WHERE post_status = 'publish' AND post_content LIKE %s AND ID > %d
1049 ORDER BY ID ASC LIMIT %d",
1050 $like,
1051 $last_id,
1052 $take
1053 ),
1054 ARRAY_A
1055 );
1056
1057 if ( empty( $rows ) ) {
1058 break;
1059 }
1060
1061 foreach ( $rows as $row ) {
1062 $last_id = (int) $row['ID'];
1063 if ( preg_match_all( $regex, $row['post_content'], $m ) ) {
1064 // Store the bare block slug (e.g. 'tp-accordion') — no namespace,
1065 // so it survives sanitize_key on the hub and matches the enabled key.
1066 foreach ( $m[1] as $block ) {
1067 $counts[ $block ] = ( $counts[ $block ] ?? 0 ) + 1;
1068 }
1069 }
1070 }
1071
1072 $scanned += count( $rows );
1073 if ( count( $rows ) < $take ) {
1074 break;
1075 }
1076 }
1077
1078 return $counts;
1079 }
1080
1081 // Transport — never blocks a request someone is waiting on.
1082
1083 /**
1084 * Sends one event to the hub, off the critical path of whatever request called it.
1085 *
1086 * Three routes, chosen in the shutdown handler below by what the host can actually do:
1087 * flush the response and then send (FastCGI / LiteSpeed); send blocking because nothing is
1088 * waiting (cron, WP-CLI); or queue a one-off cron event because the response cannot be flushed
1089 * and a browser IS waiting. "Fire and forget" is deliberately not one of them — see the long
1090 * comment in the closure for why 'blocking' => false does not mean what it looks like.
1091 *
1092 * @param string $event One of activate|deactivate|heartbeat.
1093 * @param array $extra Extra fields merged over the payload (e.g. a deactivation reason).
1094 * @param bool $bypass_consent Only the deactivation dialog's own Submit may pass true.
1095 */
1096 public static function do_request( string $event, array $extra = array(), bool $bypass_consent = false ): void {
1097 // Refuse to send without consent unless the caller explicitly says this is the one
1098 // legitimate exception. That exception is the deactivation dialog: pressing "Submit" there
1099 // IS the consent for that single submission, so it must work even when the persistent
1100 // sharing toggle is off. Everything else — activate, deactivate, heartbeat, first ping —
1101 // leaves this false and is refused if consent is missing, so a future call site cannot
1102 // bypass consent by simply forgetting to check it.
1103 if ( ! $bypass_consent && ! static::has_consent() ) {
1104 return;
1105 }
1106
1107 $endpoint = defined( 'POSIMYTH_ANALYTICS_ENDPOINT' )
1108 ? POSIMYTH_ANALYTICS_ENDPOINT
1109 : 'https://api.posimyth.com/wp-json/posimyth/v1/track';
1110
1111 // No ingest key and no request signature.
1112 //
1113 // The hub's endpoint is intentionally open. A shared key would have to be baked
1114 // identically into every shipped plugin, and those plugins are publicly downloadable, so
1115 // the key could be read straight out of a ZIP — it authenticated nothing while making
1116 // rotation impossible. Abuse is bounded hub-side instead (per-IP and per-site rate
1117 // limits, a plugin_slug allowlist, and strict payload sanitisation).
1118 $headers = array(
1119 'Content-Type' => 'application/json',
1120 );
1121
1122 // Send AFTER the response is flushed, with a timeout long enough to actually finish.
1123 //
1124 // This previously fired inline with 'timeout' => 0.01 and 'blocking' => false. 10ms is
1125 // far less than a real round trip to the hub (measured ~4s), so cURL aborted during the
1126 // DNS/TLS phase and the request never arrived — activate / deactivate / heartbeat all
1127 // silently recorded nothing, with no error anywhere because the response was discarded.
1128 //
1129 // Deferring to 'shutdown' keeps the admin request just as fast (the user never waits),
1130 // while giving the request the time it genuinely needs.
1131 //
1132 // An earlier version of this comment talked about an HMAC timestamp staying inside the hub's
1133 // replay window. There is no HMAC and no replay window — see the note above the headers.
1134 // Nothing here is signed or timestamped.
1135 $timeout = (int) apply_filters( 'posimyth_analytics_request_timeout', 15 );
1136
1137 $class = static::class;
1138
1139 /*
1140 * Everything expensive happens INSIDE this closure.
1141 *
1142 * build_payload() used to run inline, before the response was flushed, which made the claim
1143 * that this "never blocks the admin" untrue: it counts users, reads the plugin list, resolves
1144 * the theme and (in other products) scans content, all while the admin waits. Only the HTTP
1145 * call was deferred. Now the payload is assembled after the response has gone out too.
1146 */
1147 // Resolved out here rather than inside the closure: slug() is protected, and the debug log
1148 // below is the only thing that needs it.
1149 $slug = static::slug();
1150
1151 $async_hook = static::async_hook();
1152
1153 $dispatch = static function () use ( $class, $slug, $event, $extra, $bypass_consent, $endpoint, $headers, $timeout, $async_hook ) {
1154 /*
1155 * Flush the response first so nothing below adds latency to the page.
1156 *
1157 * LiteSpeed's PHP handler exposes litespeed_finish_request() and not
1158 * fastcgi_finish_request(); it was previously missed, so every LiteSpeed site — a large
1159 * share of shared hosting — took the slow path below for no reason.
1160 */
1161 $flushed = false;
1162 if ( function_exists( 'fastcgi_finish_request' ) ) {
1163 fastcgi_finish_request();
1164 $flushed = true;
1165 } elseif ( function_exists( 'litespeed_finish_request' ) ) {
1166 litespeed_finish_request();
1167 $flushed = true;
1168 }
1169
1170 /*
1171 * Is anyone actually waiting for this request?
1172 *
1173 * On WP-Cron and WP-CLI there is no browser at the other end, so the send can block for
1174 * as long as it needs and the response can be read (which is what makes the diagnostic
1175 * at the bottom of this function work). REST and AJAX are NOT in this list: a user is
1176 * waiting on both.
1177 */
1178 $unattended = ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() )
1179 || ( defined( 'WP_CLI' ) && WP_CLI )
1180 || ( defined( 'DOING_CRON' ) && DOING_CRON );
1181
1182 /*
1183 * Where the response cannot be flushed, hand the event to cron instead of sending it on
1184 * a user-facing request.
1185 *
1186 * The old code set 'blocking' => false here and its comment claimed "WordPress still
1187 * opens the connection and writes the request, it simply does not wait for the reply".
1188 * That is not what WordPress does. WP_Http_Curl::request() still calls curl_exec()
1189 * synchronously when blocking is false — it only skips the header and body write
1190 * callbacks (see the `if ( ! $parsed_args['blocking'] )` branch in
1191 * wp-includes/class-wp-http-curl.php, which runs curl_exec() and then reads
1192 * curl_getinfo()). So under mod_php, LiteSpeed's non-flushing handler or php-cgi the
1193 * worker and the browser waited for the FULL hub round trip — measured at ~4s, capped
1194 * only by the 5s timeout — on every activate, deactivate and catch-up heartbeat.
1195 * Measured on the QA container: a dashboard load went from 0.13s to 5.96s and a
1196 * deactivate from ~1s to 5.27s.
1197 *
1198 * Queueing moves both the HTTP call and the payload assembly (build_payload() runs
1199 * inside this closure, and on the heartbeat that includes the full content scan — 1.64s
1200 * on 4,313 posts) onto a cron request where nothing is waiting.
1201 *
1202 * `deactivate` is the one event that cannot be queued: by the time cron next runs the
1203 * plugin is inactive, so nothing would be listening on the hook, the event would sit in
1204 * the cron array indefinitely, and reactivating before it fired would report a
1205 * deactivation that had already been superseded. Same for the deactivation dialog's own
1206 * submission ($bypass_consent), which is the user's one-shot feedback and has to leave
1207 * now or never. Those two send inline with a hard 2s ceiling: enough to resolve, connect
1208 * and write the ~4 KB body — which is all the hub needs to receive it — without holding
1209 * the Plugins screen for the hub's processing time.
1210 */
1211 $deferrable = ( 'deactivate' !== $event ) && ! $bypass_consent;
1212
1213 if ( ! $flushed && ! $unattended && $deferrable ) {
1214 // time() rather than time()+N: this should run on the very next cron pass. WordPress
1215 // de-duplicates identical (hook, args) events scheduled within ten minutes, so a
1216 // burst of admin loads cannot queue the same ping repeatedly.
1217 if ( ! wp_next_scheduled( $async_hook, array( $event, $extra, $bypass_consent ) ) ) {
1218 wp_schedule_single_event( time(), $async_hook, array( $event, $extra, $bypass_consent ) );
1219 }
1220 return;
1221 }
1222
1223 $body = wp_json_encode( array_merge( call_user_func( array( $class, 'build_payload' ), $event ), $extra ) );
1224
1225 // Block — and therefore be able to read the response — whenever nothing is waiting on
1226 // this request. The remaining case is the un-queueable deactivate on a non-flushing
1227 // host, where the send is abandoned after 2s and the response is not read.
1228 $blocking = $flushed || $unattended;
1229
1230 $response = wp_remote_post(
1231 $endpoint,
1232 array(
1233 'headers' => $headers,
1234 'body' => $body,
1235 'timeout' => $blocking ? $timeout : 2,
1236 'blocking' => $blocking,
1237 'data_format' => 'body',
1238 'sslverify' => true,
1239 )
1240 );
1241
1242 /*
1243 * Say something when the hub refuses a ping.
1244 *
1245 * Until this existed the response was discarded whole, so a hub that accepted the
1246 * request and then failed to store it was completely invisible: every product kept
1247 * sending, every ping was dropped, and the only symptom was a dashboard that quietly
1248 * stopped gaining rows. That is a failure mode which can run for months. A single line
1249 * in the debug log turns it into something findable in minutes.
1250 *
1251 * Two deliberate limits:
1252 *
1253 * - WP_DEBUG only. This is a diagnostic, not a user-facing error, and a site that has
1254 * debugging off must not accumulate log lines because a remote service is unwell.
1255 * - Blocking requests only. On the one remaining non-blocking path the request is
1256 * abandoned mid-flight, so $response carries no status at all — checking it there
1257 * would report a failure on every single ping regardless of what the hub did.
1258 *
1259 * This used to be gated on $flushed, which silently disabled the diagnostic on exactly
1260 * the hosts that needed it most: under mod_php — where a large share of installs run —
1261 * a hub rejecting every ping logged nothing at all. It now follows $blocking, so cron
1262 * runs on those hosts report just like a flushed request does.
1263 *
1264 * The body is truncated and the payload is never logged: the point is the status, not a
1265 * copy of what was sent.
1266 */
1267 if ( $blocking && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1268 $code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
1269
1270 if ( $code < 200 || $code > 299 ) {
1271 $detail = is_wp_error( $response )
1272 ? $response->get_error_message()
1273 : trim( (string) wp_remote_retrieve_body( $response ) );
1274
1275 error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnostic, WP_DEBUG only.
1276 sprintf(
1277 'POSIMYTH analytics: %s "%s" ping was not accepted (HTTP %d) — %s',
1278 $slug,
1279 $event,
1280 $code,
1281 substr( $detail, 0, 300 )
1282 )
1283 );
1284 }
1285 }
1286 };
1287
1288 // If we are already inside shutdown (e.g. called from another shutdown handler), adding
1289 // the action would never fire — send immediately instead.
1290 if ( did_action( 'shutdown' ) ) {
1291 $dispatch();
1292 return;
1293 }
1294 add_action( 'shutdown', $dispatch, 99 );
1295 }
1296 }
1297 }
1298