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