Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
92.98% |
53 / 57 |
|
90.00% |
9 / 10 |
CRAP | |
0.00% |
0 / 1 |
| Sync_Status_Tracker | |
92.98% |
53 / 57 |
|
90.00% |
9 / 10 |
29.29 | |
0.00% |
0 / 1 |
| configure | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| enrich_sync_status_response | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
6 | |||
| on_sync_processed_actions | |
42.86% |
3 / 7 |
|
0.00% |
0 / 1 |
4.68 | |||
| get_analytics_sync_modules | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| maybe_set_milestone | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
9 | |||
| inject_script_data | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| gating_option | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| gating_milestone | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| milestone_reached | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| find_full_sync_end_action | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Analytics-aware sync milestone tracker. |
| 4 | * |
| 5 | * @package automattic/jetpack-premium-analytics |
| 6 | */ |
| 7 | |
| 8 | namespace Automattic\Jetpack\PremiumAnalytics\Sync; |
| 9 | |
| 10 | use Automattic\Jetpack\Sync\Modules; |
| 11 | |
| 12 | /** |
| 13 | * Listens for the end of the dashboard-gating Jetpack full sync, persists a |
| 14 | * one-time milestone option, and exposes that milestone to the frontend both at |
| 15 | * page load (via `JetpackScriptData.premium_analytics`) and live on Jetpack's |
| 16 | * `/jetpack/v4/sync/status` REST response. |
| 17 | * |
| 18 | * Which sync gates the dashboard is resolved live from `has_store_data`: the |
| 19 | * analytics-module sync ({@see INITIAL_ANALYTICS_SYNC_OPTION}) for store sites, |
| 20 | * Jetpack's generic initial full sync ({@see INITIAL_SITE_SYNC_OPTION}) otherwise. |
| 21 | * The two are tracked separately so connecting without WooCommerce and activating |
| 22 | * it later still waits for store data. |
| 23 | * |
| 24 | * Why this exists: the extracted dashboard needs to distinguish "first-time |
| 25 | * sync, please wait" from "we have data, render it." Jetpack's |
| 26 | * /jetpack/v4/sync/status reports current sync state, but not whether the |
| 27 | * gating initial sync has completed at least once. The analytics milestone also |
| 28 | * lets consumer plugins (e.g. WooCommerce Analytics) fire one-time side-effects |
| 29 | * like the full-sync-complete email, via the action hook below. |
| 30 | */ |
| 31 | class Sync_Status_Tracker { |
| 32 | |
| 33 | /** |
| 34 | * Milestone (unix ts) for the analytics-module initial full sync. Gates the |
| 35 | * dashboard when the site has store data. Separate from |
| 36 | * {@see INITIAL_SITE_SYNC_OPTION} so activating WooCommerce after a storeless |
| 37 | * connect still waits for store data. |
| 38 | */ |
| 39 | const INITIAL_ANALYTICS_SYNC_OPTION = 'jetpack_premium_analytics_initial_analytics_sync_finished'; |
| 40 | |
| 41 | /** |
| 42 | * Milestone (unix ts) for Jetpack's generic initial full sync. Gates the |
| 43 | * dashboard when the site has no store data. |
| 44 | */ |
| 45 | const INITIAL_SITE_SYNC_OPTION = 'jetpack_premium_analytics_initial_site_sync_finished'; |
| 46 | |
| 47 | /** |
| 48 | * Default sync-module names whose end-of-sync event flips the milestone. |
| 49 | * Currently provided by WooCommerce Analytics, which registers a custom |
| 50 | * full-sync module under this key. Consumers can extend or override the set |
| 51 | * via the `jetpack_premium_analytics_sync_modules` filter — see |
| 52 | * {@see get_analytics_sync_modules()}. |
| 53 | * |
| 54 | * @var string[] |
| 55 | */ |
| 56 | const ANALYTICS_SYNC_MODULES = array( 'woocommerce_analytics' ); |
| 57 | |
| 58 | /** |
| 59 | * Action hook fired once when the analytics milestone flips (not the storeless |
| 60 | * site milestone). Consumer plugins use this to fire one-time side-effects keyed |
| 61 | * to store data (emails, tracking events, etc.). |
| 62 | * |
| 63 | * @var string |
| 64 | */ |
| 65 | const MILESTONE_ACTION = 'jetpack_premium_analytics_initial_full_sync_finished'; |
| 66 | |
| 67 | /** |
| 68 | * Jetpack core's sync-status REST route. We enrich this existing response with |
| 69 | * the milestone rather than registering a dedicated endpoint. |
| 70 | */ |
| 71 | const SYNC_STATUS_ROUTE = '/jetpack/v4/sync/status'; |
| 72 | |
| 73 | /** |
| 74 | * Wire up the listener, the script-data filter, and the sync-status enricher. |
| 75 | * |
| 76 | * Idempotent: safe to call more than once. |
| 77 | * |
| 78 | * @return void |
| 79 | */ |
| 80 | public static function configure() { |
| 81 | add_action( 'jetpack_sync_processed_actions', array( self::class, 'on_sync_processed_actions' ) ); |
| 82 | add_filter( 'jetpack_admin_js_script_data', array( self::class, 'inject_script_data' ) ); |
| 83 | add_filter( 'rest_post_dispatch', array( self::class, 'enrich_sync_status_response' ), 10, 3 ); |
| 84 | } |
| 85 | |
| 86 | /** |
| 87 | * Append the milestone to Jetpack core's GET /jetpack/v4/sync/status response |
| 88 | * so the dashboard can read it on every poll, not just at page load. |
| 89 | * |
| 90 | * Page-load script-data ({@see inject_script_data()}) is a one-time snapshot; |
| 91 | * reading the milestone here keeps it live for in-session completion without a |
| 92 | * dedicated endpoint. Only the already-authorized, successful status payload is |
| 93 | * touched — other routes and error responses pass through untouched. |
| 94 | * |
| 95 | * @param mixed $response Result to send to the client. Usually a WP_REST_Response. |
| 96 | * @param mixed $server The REST server instance (unused). |
| 97 | * @param mixed $request The request used to generate the response. |
| 98 | * @return mixed |
| 99 | */ |
| 100 | public static function enrich_sync_status_response( $response, $server, $request ) { |
| 101 | if ( ! $request instanceof \WP_REST_Request |
| 102 | || self::SYNC_STATUS_ROUTE !== $request->get_route() |
| 103 | || ! $response instanceof \WP_REST_Response |
| 104 | || $response->is_error() ) { |
| 105 | return $response; |
| 106 | } |
| 107 | |
| 108 | $data = $response->get_data(); |
| 109 | if ( is_array( $data ) ) { |
| 110 | $data['initial_full_sync_finished'] = self::gating_milestone( Configuration::is_woocommerce_active() ); |
| 111 | $response->set_data( $data ); |
| 112 | } |
| 113 | |
| 114 | return $response; |
| 115 | } |
| 116 | |
| 117 | /** |
| 118 | * On every batch of processed sync actions, look for the gating |
| 119 | * jetpack_full_sync_end and flip the milestone if it hasn't already fired. |
| 120 | * |
| 121 | * @param array $actions Processed sync actions. |
| 122 | * @return void |
| 123 | */ |
| 124 | public static function on_sync_processed_actions( array $actions ): void { |
| 125 | $has_store_data = Configuration::is_woocommerce_active(); |
| 126 | |
| 127 | // Bail before the per-batch full-sync lookup ($module->get_status() bypasses |
| 128 | // the status cache) once the current mode's milestone is set. Resolving the |
| 129 | // mode live means activating WooCommerce later resumes the store milestone. |
| 130 | if ( self::milestone_reached( $has_store_data ) ) { |
| 131 | return; |
| 132 | } |
| 133 | |
| 134 | $module = Modules::get_module( 'full-sync' ); |
| 135 | if ( ! $module ) { |
| 136 | return; |
| 137 | } |
| 138 | '@phan-var \Automattic\Jetpack\Sync\Modules\Full_Sync_Immediately|\Automattic\Jetpack\Sync\Modules\Full_Sync $module'; |
| 139 | |
| 140 | self::maybe_set_milestone( $module->get_status(), $actions, $has_store_data ); |
| 141 | } |
| 142 | |
| 143 | /** |
| 144 | * Resolve the configured sync-module names for analytics. |
| 145 | * |
| 146 | * @return string[] |
| 147 | */ |
| 148 | public static function get_analytics_sync_modules(): array { |
| 149 | /** |
| 150 | * Filter the sync-module names whose end-of-sync flips the analytics |
| 151 | * milestone. Consumer plugins that register custom full-sync modules |
| 152 | * can add their module keys here. |
| 153 | * |
| 154 | * @param string[] $module_names Default: array( 'woocommerce_analytics' ). |
| 155 | */ |
| 156 | return (array) apply_filters( 'jetpack_premium_analytics_sync_modules', self::ANALYTICS_SYNC_MODULES ); |
| 157 | } |
| 158 | |
| 159 | /** |
| 160 | * Pure-logic counterpart to on_sync_processed_actions(): decide whether the |
| 161 | * supplied full-sync status + actions list represents the sync that gates the |
| 162 | * dashboard ending, and flip the milestone if so. |
| 163 | * |
| 164 | * Store site: only a full sync whose config includes an analytics module flips |
| 165 | * the milestone, so a generic sync can't open the gate before store data lands. |
| 166 | * Storeless site: any initial full sync ending flips it. The milestone written |
| 167 | * tracks `$has_store_data`, in lockstep with the option the frontend reads. |
| 168 | * |
| 169 | * Split out so unit tests can exercise the decision without the Jetpack sync |
| 170 | * module registry (the caller resolves `$has_store_data` live). |
| 171 | * |
| 172 | * @param array $full_status Result of Full_Sync_Immediately::get_status(). |
| 173 | * @param array $actions Processed sync actions. |
| 174 | * @param bool $has_store_data Whether the site has store data to sync (WooCommerce |
| 175 | * active). Defaults to true: require the analytics module. |
| 176 | * @return void |
| 177 | */ |
| 178 | public static function maybe_set_milestone( array $full_status, array $actions, bool $has_store_data = true ): void { |
| 179 | if ( self::milestone_reached( $has_store_data ) ) { |
| 180 | return; |
| 181 | } |
| 182 | |
| 183 | if ( $has_store_data ) { |
| 184 | $config = isset( $full_status['config'] ) ? (array) $full_status['config'] : array(); |
| 185 | $active = array_filter( |
| 186 | self::get_analytics_sync_modules(), |
| 187 | static function ( $module_name ) use ( $config ) { |
| 188 | return ! empty( $config[ $module_name ] ); |
| 189 | } |
| 190 | ); |
| 191 | if ( ! $active ) { |
| 192 | return; |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | $end_action = self::find_full_sync_end_action( $actions ); |
| 197 | if ( ! $end_action ) { |
| 198 | return; |
| 199 | } |
| 200 | |
| 201 | // The last update_status() call in Full_Sync_Immediately::send() runs |
| 202 | // after jetpack_full_sync_end fires, so the action's own timestamp is |
| 203 | // the most reliable "finished at" value. Note: Year 2038 problem. |
| 204 | $finished_at = isset( $end_action[3] ) ? (int) $end_action[3] : 0; |
| 205 | if ( $finished_at <= 0 ) { |
| 206 | // Defensive: avoid persisting a zero timestamp, which would equal |
| 207 | // the "not yet set" sentinel and cause the listener to re-trigger |
| 208 | // on the next batch. |
| 209 | return; |
| 210 | } |
| 211 | $full_status['finished'] = $finished_at; |
| 212 | update_option( self::gating_option( $has_store_data ), $finished_at ); |
| 213 | |
| 214 | if ( ! $has_store_data ) { |
| 215 | // No store data, so store-keyed consumer side-effects (e.g. WooCommerce's |
| 216 | // full-sync-complete email) must not fire — only the analytics milestone does. |
| 217 | return; |
| 218 | } |
| 219 | |
| 220 | /** |
| 221 | * Fires once when the analytics-relevant initial full sync completes. |
| 222 | * |
| 223 | * @param array $full_status Final full-sync status (with `finished` timestamp). |
| 224 | */ |
| 225 | do_action( self::MILESTONE_ACTION, $full_status ); |
| 226 | } |
| 227 | |
| 228 | /** |
| 229 | * Inject the milestone and store-data flag into JetpackScriptData so the |
| 230 | * dashboard can read them at page load without an extra HTTP roundtrip. |
| 231 | * |
| 232 | * `has_store_data` tells the frontend which sync gates the dashboard: the |
| 233 | * analytics module when WooCommerce is active, or Jetpack's generic initial |
| 234 | * full sync when it is not. Computed live so activating WooCommerce later is |
| 235 | * picked up on the next page load. |
| 236 | * |
| 237 | * @param array $data The script data passed by the assets package. |
| 238 | * @return array |
| 239 | */ |
| 240 | public static function inject_script_data( array $data ): array { |
| 241 | $has_store_data = Configuration::is_woocommerce_active(); |
| 242 | |
| 243 | $data['premium_analytics'] = array( |
| 244 | 'initial_full_sync_finished' => self::gating_milestone( $has_store_data ), |
| 245 | 'has_store_data' => $has_store_data, |
| 246 | ); |
| 247 | |
| 248 | return $data; |
| 249 | } |
| 250 | |
| 251 | /** |
| 252 | * The option name whose timestamp gates the dashboard for the given mode. |
| 253 | * |
| 254 | * @param bool $has_store_data Whether the site has store data (WooCommerce active). |
| 255 | * @return string |
| 256 | */ |
| 257 | private static function gating_option( bool $has_store_data ): string { |
| 258 | return $has_store_data ? self::INITIAL_ANALYTICS_SYNC_OPTION : self::INITIAL_SITE_SYNC_OPTION; |
| 259 | } |
| 260 | |
| 261 | /** |
| 262 | * The gating milestone timestamp for the given mode (0 if not yet reached). |
| 263 | * |
| 264 | * @param bool $has_store_data Whether the site has store data (WooCommerce active). |
| 265 | * @return int |
| 266 | */ |
| 267 | private static function gating_milestone( bool $has_store_data ): int { |
| 268 | return (int) get_option( self::gating_option( $has_store_data ), 0 ); |
| 269 | } |
| 270 | |
| 271 | /** |
| 272 | * Whether the milestone gating the given mode has fired. |
| 273 | * |
| 274 | * @param bool $has_store_data Whether the site has store data (WooCommerce active). |
| 275 | * @return bool |
| 276 | */ |
| 277 | private static function milestone_reached( bool $has_store_data ): bool { |
| 278 | return self::gating_milestone( $has_store_data ) > 0; |
| 279 | } |
| 280 | |
| 281 | /** |
| 282 | * Find the jetpack_full_sync_end action in a processed-actions list. |
| 283 | * |
| 284 | * @param array $actions Actions list. |
| 285 | * @return array|null |
| 286 | */ |
| 287 | private static function find_full_sync_end_action( array $actions ): ?array { |
| 288 | foreach ( $actions as $action ) { |
| 289 | if ( isset( $action[0] ) && 'jetpack_full_sync_end' === $action[0] ) { |
| 290 | return $action; |
| 291 | } |
| 292 | } |
| 293 | return null; |
| 294 | } |
| 295 | } |