Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.18% covered (success)
91.18%
62 / 68
81.82% covered (warning)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Sync_Status_Tracker
91.18% covered (success)
91.18%
62 / 68
81.82% covered (warning)
81.82%
9 / 11
37.94
0.00% covered (danger)
0.00%
0 / 1
 configure
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 enrich_sync_status_response
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 on_sync_processed_actions
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
5.67
 get_analytics_sync_modules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 maybe_set_milestone
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 maybe_backfill_milestone
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
11.24
 set_milestone
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 inject_script_data
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 milestone
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 milestone_reached
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 find_full_sync_end_action
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * Analytics-aware sync milestone tracker.
4 *
5 * @package automattic/jetpack-premium-analytics
6 */
7
8namespace Automattic\Jetpack\PremiumAnalytics\Sync;
9
10use Automattic\Jetpack\Sync\Modules;
11
12/**
13 * Listens for the end of the analytics full sync and persists a one-time milestone option.
14 *
15 * /jetpack/v4/sync/status reports current sync state but not whether the analytics full sync
16 * has completed at least once — which is what tells the dashboard's store section its numbers are complete.
17 */
18class Sync_Status_Tracker {
19
20    /**
21     * Milestone (unix ts) for the analytics-module initial full sync. Marks the
22     * dashboard's store data as complete.
23     */
24    const INITIAL_ANALYTICS_SYNC_OPTION = 'jetpack_premium_analytics_initial_analytics_sync_finished';
25
26    /**
27     * Default sync-module names whose end-of-sync event flips the milestone. Provided by
28     * WooCommerce Analytics, which registers a custom full-sync module under this key.
29     *
30     * @var string[]
31     */
32    const ANALYTICS_SYNC_MODULES = array( 'woocommerce_analytics' );
33
34    /**
35     * Action hook fired once when the analytics milestone flips. Consumer plugins
36     * use this to fire one-time side-effects keyed to store data (emails, tracking
37     * events, etc.).
38     *
39     * @var string
40     */
41    const MILESTONE_ACTION = 'jetpack_premium_analytics_initial_full_sync_finished';
42
43    /**
44     * Jetpack core's sync-status REST route, enriched with the milestone.
45     */
46    const SYNC_STATUS_ROUTE = '/jetpack/v4/sync/status';
47
48    /**
49     * Wire up the listener, the script-data filter, and the sync-status enricher.
50     *
51     * Idempotent: safe to call more than once.
52     *
53     * @return void
54     */
55    public static function configure() {
56        add_action( 'jetpack_sync_processed_actions', array( self::class, 'on_sync_processed_actions' ) );
57        add_filter( 'jetpack_admin_js_script_data', array( self::class, 'inject_script_data' ) );
58        add_filter( 'rest_post_dispatch', array( self::class, 'enrich_sync_status_response' ), 10, 3 );
59    }
60
61    /**
62     * Append the milestone to Jetpack core's GET /jetpack/v4/sync/status response.
63     *
64     * Unlike the one-time script-data snapshot ({@see inject_script_data()}), this stays live for
65     * in-session completion — but touches only the already-authorized, successful status payload.
66     *
67     * @param mixed $response Result to send to the client. Usually a WP_REST_Response.
68     * @param mixed $server   The REST server instance (unused).
69     * @param mixed $request  The request used to generate the response.
70     * @return mixed
71     */
72    public static function enrich_sync_status_response( $response, $server, $request ) {
73        if ( ! $request instanceof \WP_REST_Request
74            || self::SYNC_STATUS_ROUTE !== $request->get_route()
75            || ! $response instanceof \WP_REST_Response
76            || $response->is_error() ) {
77            return $response;
78        }
79
80        $data = $response->get_data();
81        if ( is_array( $data ) ) {
82            self::maybe_backfill_milestone();
83            $data['initial_full_sync_finished'] = self::milestone();
84            $response->set_data( $data );
85        }
86
87        return $response;
88    }
89
90    /**
91     * On every batch of processed sync actions, look for the gating
92     * jetpack_full_sync_end and flip the milestone if it hasn't already fired.
93     *
94     * @param array $actions Processed sync actions.
95     * @return void
96     */
97    public static function on_sync_processed_actions( array $actions ): void {
98        // Bail before the per-batch full-sync lookup ($module->get_status() bypasses
99        // the status cache) once the milestone is set.
100        if ( self::milestone_reached() ) {
101            return;
102        }
103
104        $module = Modules::get_module( 'full-sync' );
105        if ( ! $module ) {
106            return;
107        }
108        '@phan-var \Automattic\Jetpack\Sync\Modules\Full_Sync_Immediately|\Automattic\Jetpack\Sync\Modules\Full_Sync $module';
109
110        self::maybe_set_milestone( $module->get_status(), $actions );
111    }
112
113    /**
114     * Resolve the configured sync-module names for analytics.
115     *
116     * @return string[]
117     */
118    public static function get_analytics_sync_modules(): array {
119        /**
120         * Filter the sync-module names whose end-of-sync flips the analytics
121         * milestone. Consumer plugins that register custom full-sync modules
122         * can add their module keys here.
123         *
124         * @param string[] $module_names Default: array( 'woocommerce_analytics' ).
125         */
126        return (array) apply_filters( 'jetpack_premium_analytics_sync_modules', self::ANALYTICS_SYNC_MODULES );
127    }
128
129    /**
130     * Decide whether the supplied full-sync status and actions represent the analytics sync ending.
131     *
132     * Only a full sync whose config includes an analytics module counts — a generic sync can't mark
133     * store data complete; split out so tests can exercise it without the sync module registry.
134     *
135     * @param array $full_status Result of Full_Sync_Immediately::get_status().
136     * @param array $actions     Processed sync actions.
137     * @return void
138     */
139    public static function maybe_set_milestone( array $full_status, array $actions ): void {
140        if ( self::milestone_reached() ) {
141            return;
142        }
143
144        $config = isset( $full_status['config'] ) ? (array) $full_status['config'] : array();
145        $active = array_filter(
146            self::get_analytics_sync_modules(),
147            static function ( $module_name ) use ( $config ) {
148                return ! empty( $config[ $module_name ] );
149            }
150        );
151        if ( ! $active ) {
152            return;
153        }
154
155        $end_action = self::find_full_sync_end_action( $actions );
156        if ( ! $end_action ) {
157            return;
158        }
159
160        // The last update_status() call in Full_Sync_Immediately::send() runs after jetpack_full_sync_end
161        // fires, so the action's own timestamp is the most reliable "finished at" value (Year 2038 problem aside).
162        $finished_at = isset( $end_action[3] ) ? (int) $end_action[3] : 0;
163        if ( $finished_at <= 0 ) {
164            // Defensive: avoid persisting a zero timestamp, which would equal the "not yet set" sentinel
165            // and cause the listener to re-trigger on the next batch.
166            return;
167        }
168        $full_status['finished'] = $finished_at;
169        self::set_milestone( $full_status );
170    }
171
172    /**
173     * Set the milestone from a stored full-sync status whose analytics module already finished.
174     *
175     * Covers a full sync that ended before this listener ran (an older Jetpack, or before Premium Analytics was enabled).
176     *
177     * @return void
178     */
179    private static function maybe_backfill_milestone(): void {
180        if ( self::milestone_reached() ) {
181            return;
182        }
183
184        $module = Modules::get_module( 'full-sync' );
185        if ( ! $module ) {
186            return;
187        }
188        '@phan-var \Automattic\Jetpack\Sync\Modules\Full_Sync_Immediately|\Automattic\Jetpack\Sync\Modules\Full_Sync $module';
189
190        $full_status = $module->get_status();
191        $finished_at = isset( $full_status['finished'] ) ? (int) $full_status['finished'] : 0;
192        if ( $finished_at <= 0 || empty( $full_status['progress'] ) || ! is_array( $full_status['progress'] ) ) {
193            return;
194        }
195
196        $progress = $full_status['progress'];
197        foreach ( self::get_analytics_sync_modules() as $module_name ) {
198            if ( isset( $progress[ $module_name ]['finished'] ) && true === $progress[ $module_name ]['finished'] ) {
199                // The sender sets the milestone before it stores `finished`, so re-read it past the
200                // request's option cache to avoid firing the action a second time.
201                if ( (int) \Jetpack_Options::get_raw_option( self::INITIAL_ANALYTICS_SYNC_OPTION, 0 ) > 0 ) {
202                    return;
203                }
204                self::set_milestone( $full_status );
205                return;
206            }
207        }
208    }
209
210    /**
211     * Persist the milestone and fire the one-time action.
212     *
213     * @param array $full_status Final full-sync status, with a positive `finished` timestamp.
214     * @return void
215     */
216    private static function set_milestone( array $full_status ): void {
217        update_option( self::INITIAL_ANALYTICS_SYNC_OPTION, (int) $full_status['finished'] );
218
219        /**
220         * Fires once when the analytics-relevant initial full sync completes.
221         *
222         * @param array $full_status Final full-sync status (with `finished` timestamp).
223         */
224        do_action( self::MILESTONE_ACTION, $full_status );
225    }
226
227    /**
228     * Inject the milestone into JetpackScriptData so the dashboard can read it at
229     * page load without an extra HTTP roundtrip.
230     *
231     * @param array $data The script data passed by the assets package.
232     * @return array
233     */
234    public static function inject_script_data( array $data ): array {
235        $data['premium_analytics'] = array(
236            'initial_full_sync_finished' => self::milestone(),
237        );
238
239        return $data;
240    }
241
242    /**
243     * The milestone timestamp (0 if not yet reached).
244     *
245     * @return int
246     */
247    private static function milestone(): int {
248        return (int) get_option( self::INITIAL_ANALYTICS_SYNC_OPTION, 0 );
249    }
250
251    /**
252     * Whether the milestone has fired.
253     *
254     * @return bool
255     */
256    private static function milestone_reached(): bool {
257        return self::milestone() > 0;
258    }
259
260    /**
261     * Find the jetpack_full_sync_end action in a processed-actions list.
262     *
263     * @param array $actions Actions list.
264     * @return array|null
265     */
266    private static function find_full_sync_end_action( array $actions ): ?array {
267        foreach ( $actions as $action ) {
268            if ( isset( $action[0] ) && 'jetpack_full_sync_end' === $action[0] ) {
269                return $action;
270            }
271        }
272        return null;
273    }
274}