Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.00% covered (success)
92.00%
46 / 50
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Sync_Status_Tracker
92.00% covered (success)
92.00%
46 / 50
88.89% covered (warning)
88.89%
8 / 9
25.32
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%
10 / 10
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%
20 / 20
100.00% covered (success)
100.00%
1 / 1
7
 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            $data['initial_full_sync_finished'] = self::milestone();
83            $response->set_data( $data );
84        }
85
86        return $response;
87    }
88
89    /**
90     * On every batch of processed sync actions, look for the gating
91     * jetpack_full_sync_end and flip the milestone if it hasn't already fired.
92     *
93     * @param array $actions Processed sync actions.
94     * @return void
95     */
96    public static function on_sync_processed_actions( array $actions ): void {
97        // Bail before the per-batch full-sync lookup ($module->get_status() bypasses
98        // the status cache) once the milestone is set.
99        if ( self::milestone_reached() ) {
100            return;
101        }
102
103        $module = Modules::get_module( 'full-sync' );
104        if ( ! $module ) {
105            return;
106        }
107        '@phan-var \Automattic\Jetpack\Sync\Modules\Full_Sync_Immediately|\Automattic\Jetpack\Sync\Modules\Full_Sync $module';
108
109        self::maybe_set_milestone( $module->get_status(), $actions );
110    }
111
112    /**
113     * Resolve the configured sync-module names for analytics.
114     *
115     * @return string[]
116     */
117    public static function get_analytics_sync_modules(): array {
118        /**
119         * Filter the sync-module names whose end-of-sync flips the analytics
120         * milestone. Consumer plugins that register custom full-sync modules
121         * can add their module keys here.
122         *
123         * @param string[] $module_names Default: array( 'woocommerce_analytics' ).
124         */
125        return (array) apply_filters( 'jetpack_premium_analytics_sync_modules', self::ANALYTICS_SYNC_MODULES );
126    }
127
128    /**
129     * Decide whether the supplied full-sync status and actions represent the analytics sync ending.
130     *
131     * Only a full sync whose config includes an analytics module counts — a generic sync can't mark
132     * store data complete; split out so tests can exercise it without the sync module registry.
133     *
134     * @param array $full_status Result of Full_Sync_Immediately::get_status().
135     * @param array $actions     Processed sync actions.
136     * @return void
137     */
138    public static function maybe_set_milestone( array $full_status, array $actions ): void {
139        if ( self::milestone_reached() ) {
140            return;
141        }
142
143        $config = isset( $full_status['config'] ) ? (array) $full_status['config'] : array();
144        $active = array_filter(
145            self::get_analytics_sync_modules(),
146            static function ( $module_name ) use ( $config ) {
147                return ! empty( $config[ $module_name ] );
148            }
149        );
150        if ( ! $active ) {
151            return;
152        }
153
154        $end_action = self::find_full_sync_end_action( $actions );
155        if ( ! $end_action ) {
156            return;
157        }
158
159        // The last update_status() call in Full_Sync_Immediately::send() runs after jetpack_full_sync_end
160        // fires, so the action's own timestamp is the most reliable "finished at" value (Year 2038 problem aside).
161        $finished_at = isset( $end_action[3] ) ? (int) $end_action[3] : 0;
162        if ( $finished_at <= 0 ) {
163            // Defensive: avoid persisting a zero timestamp, which would equal the "not yet set" sentinel
164            // and cause the listener to re-trigger on the next batch.
165            return;
166        }
167        $full_status['finished'] = $finished_at;
168        update_option( self::INITIAL_ANALYTICS_SYNC_OPTION, $finished_at );
169
170        /**
171         * Fires once when the analytics-relevant initial full sync completes.
172         *
173         * @param array $full_status Final full-sync status (with `finished` timestamp).
174         */
175        do_action( self::MILESTONE_ACTION, $full_status );
176    }
177
178    /**
179     * Inject the milestone into JetpackScriptData so the dashboard can read it at
180     * page load without an extra HTTP roundtrip.
181     *
182     * @param array $data The script data passed by the assets package.
183     * @return array
184     */
185    public static function inject_script_data( array $data ): array {
186        $data['premium_analytics'] = array(
187            'initial_full_sync_finished' => self::milestone(),
188        );
189
190        return $data;
191    }
192
193    /**
194     * The milestone timestamp (0 if not yet reached).
195     *
196     * @return int
197     */
198    private static function milestone(): int {
199        return (int) get_option( self::INITIAL_ANALYTICS_SYNC_OPTION, 0 );
200    }
201
202    /**
203     * Whether the milestone has fired.
204     *
205     * @return bool
206     */
207    private static function milestone_reached(): bool {
208        return self::milestone() > 0;
209    }
210
211    /**
212     * Find the jetpack_full_sync_end action in a processed-actions list.
213     *
214     * @param array $actions Actions list.
215     * @return array|null
216     */
217    private static function find_full_sync_end_action( array $actions ): ?array {
218        foreach ( $actions as $action ) {
219            if ( isset( $action[0] ) && 'jetpack_full_sync_end' === $action[0] ) {
220                return $action;
221            }
222        }
223        return null;
224    }
225}