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, persists a one-time milestone
14 * option, and exposes that milestone to the frontend both at page load (via
15 * `JetpackScriptData.premium_analytics`) and live on Jetpack's
16 * `/jetpack/v4/sync/status` REST response.
17 *
18 * Why this exists: /jetpack/v4/sync/status reports current sync state, but not
19 * whether the analytics full sync has completed at least once, which tells the
20 * dashboard's store section whether its numbers are complete. The milestone
21 * also lets consumer plugins (e.g. WooCommerce Analytics) fire one-time
22 * side-effects like the full-sync-complete email, via the action hook below.
23 */
24class Sync_Status_Tracker {
25
26    /**
27     * Milestone (unix ts) for the analytics-module initial full sync. Marks the
28     * dashboard's store data as complete.
29     */
30    const INITIAL_ANALYTICS_SYNC_OPTION = 'jetpack_premium_analytics_initial_analytics_sync_finished';
31
32    /**
33     * Default sync-module names whose end-of-sync event flips the milestone.
34     * Currently provided by WooCommerce Analytics, which registers a custom
35     * full-sync module under this key. Consumers can extend or override the set
36     * via the `jetpack_premium_analytics_sync_modules` filter — see
37     * {@see get_analytics_sync_modules()}.
38     *
39     * @var string[]
40     */
41    const ANALYTICS_SYNC_MODULES = array( 'woocommerce_analytics' );
42
43    /**
44     * Action hook fired once when the analytics milestone flips. Consumer plugins
45     * use this to fire one-time side-effects keyed to store data (emails, tracking
46     * events, etc.).
47     *
48     * @var string
49     */
50    const MILESTONE_ACTION = 'jetpack_premium_analytics_initial_full_sync_finished';
51
52    /**
53     * Jetpack core's sync-status REST route. We enrich this existing response with
54     * the milestone rather than registering a dedicated endpoint.
55     */
56    const SYNC_STATUS_ROUTE = '/jetpack/v4/sync/status';
57
58    /**
59     * Wire up the listener, the script-data filter, and the sync-status enricher.
60     *
61     * Idempotent: safe to call more than once.
62     *
63     * @return void
64     */
65    public static function configure() {
66        add_action( 'jetpack_sync_processed_actions', array( self::class, 'on_sync_processed_actions' ) );
67        add_filter( 'jetpack_admin_js_script_data', array( self::class, 'inject_script_data' ) );
68        add_filter( 'rest_post_dispatch', array( self::class, 'enrich_sync_status_response' ), 10, 3 );
69    }
70
71    /**
72     * Append the milestone to Jetpack core's GET /jetpack/v4/sync/status response
73     * so the dashboard can read it on every poll, not just at page load.
74     *
75     * Page-load script-data ({@see inject_script_data()}) is a one-time snapshot;
76     * reading the milestone here keeps it live for in-session completion without a
77     * dedicated endpoint. Only the already-authorized, successful status payload is
78     * touched — other routes and error responses pass through untouched.
79     *
80     * @param mixed $response Result to send to the client. Usually a WP_REST_Response.
81     * @param mixed $server   The REST server instance (unused).
82     * @param mixed $request  The request used to generate the response.
83     * @return mixed
84     */
85    public static function enrich_sync_status_response( $response, $server, $request ) {
86        if ( ! $request instanceof \WP_REST_Request
87            || self::SYNC_STATUS_ROUTE !== $request->get_route()
88            || ! $response instanceof \WP_REST_Response
89            || $response->is_error() ) {
90            return $response;
91        }
92
93        $data = $response->get_data();
94        if ( is_array( $data ) ) {
95            $data['initial_full_sync_finished'] = self::milestone();
96            $response->set_data( $data );
97        }
98
99        return $response;
100    }
101
102    /**
103     * On every batch of processed sync actions, look for the gating
104     * jetpack_full_sync_end and flip the milestone if it hasn't already fired.
105     *
106     * @param array $actions Processed sync actions.
107     * @return void
108     */
109    public static function on_sync_processed_actions( array $actions ): void {
110        // Bail before the per-batch full-sync lookup ($module->get_status() bypasses
111        // the status cache) once the milestone is set.
112        if ( self::milestone_reached() ) {
113            return;
114        }
115
116        $module = Modules::get_module( 'full-sync' );
117        if ( ! $module ) {
118            return;
119        }
120        '@phan-var \Automattic\Jetpack\Sync\Modules\Full_Sync_Immediately|\Automattic\Jetpack\Sync\Modules\Full_Sync $module';
121
122        self::maybe_set_milestone( $module->get_status(), $actions );
123    }
124
125    /**
126     * Resolve the configured sync-module names for analytics.
127     *
128     * @return string[]
129     */
130    public static function get_analytics_sync_modules(): array {
131        /**
132         * Filter the sync-module names whose end-of-sync flips the analytics
133         * milestone. Consumer plugins that register custom full-sync modules
134         * can add their module keys here.
135         *
136         * @param string[] $module_names Default: array( 'woocommerce_analytics' ).
137         */
138        return (array) apply_filters( 'jetpack_premium_analytics_sync_modules', self::ANALYTICS_SYNC_MODULES );
139    }
140
141    /**
142     * Pure-logic counterpart to on_sync_processed_actions(): decide whether the
143     * supplied full-sync status + actions list represents the analytics sync
144     * ending, and flip the milestone if so.
145     *
146     * Only a full sync whose config includes an analytics module counts, so a
147     * generic sync can't mark the store data complete. Split out so unit
148     * tests can exercise the decision without the Jetpack sync module registry.
149     *
150     * @param array $full_status Result of Full_Sync_Immediately::get_status().
151     * @param array $actions     Processed sync actions.
152     * @return void
153     */
154    public static function maybe_set_milestone( array $full_status, array $actions ): void {
155        if ( self::milestone_reached() ) {
156            return;
157        }
158
159        $config = isset( $full_status['config'] ) ? (array) $full_status['config'] : array();
160        $active = array_filter(
161            self::get_analytics_sync_modules(),
162            static function ( $module_name ) use ( $config ) {
163                return ! empty( $config[ $module_name ] );
164            }
165        );
166        if ( ! $active ) {
167            return;
168        }
169
170        $end_action = self::find_full_sync_end_action( $actions );
171        if ( ! $end_action ) {
172            return;
173        }
174
175        // The last update_status() call in Full_Sync_Immediately::send() runs
176        // after jetpack_full_sync_end fires, so the action's own timestamp is
177        // the most reliable "finished at" value. Note: Year 2038 problem.
178        $finished_at = isset( $end_action[3] ) ? (int) $end_action[3] : 0;
179        if ( $finished_at <= 0 ) {
180            // Defensive: avoid persisting a zero timestamp, which would equal
181            // the "not yet set" sentinel and cause the listener to re-trigger
182            // on the next batch.
183            return;
184        }
185        $full_status['finished'] = $finished_at;
186        update_option( self::INITIAL_ANALYTICS_SYNC_OPTION, $finished_at );
187
188        /**
189         * Fires once when the analytics-relevant initial full sync completes.
190         *
191         * @param array $full_status Final full-sync status (with `finished` timestamp).
192         */
193        do_action( self::MILESTONE_ACTION, $full_status );
194    }
195
196    /**
197     * Inject the milestone into JetpackScriptData so the dashboard can read it at
198     * page load without an extra HTTP roundtrip.
199     *
200     * @param array $data The script data passed by the assets package.
201     * @return array
202     */
203    public static function inject_script_data( array $data ): array {
204        $data['premium_analytics'] = array(
205            'initial_full_sync_finished' => self::milestone(),
206        );
207
208        return $data;
209    }
210
211    /**
212     * The milestone timestamp (0 if not yet reached).
213     *
214     * @return int
215     */
216    private static function milestone(): int {
217        return (int) get_option( self::INITIAL_ANALYTICS_SYNC_OPTION, 0 );
218    }
219
220    /**
221     * Whether the milestone has fired.
222     *
223     * @return bool
224     */
225    private static function milestone_reached(): bool {
226        return self::milestone() > 0;
227    }
228
229    /**
230     * Find the jetpack_full_sync_end action in a processed-actions list.
231     *
232     * @param array $actions Actions list.
233     * @return array|null
234     */
235    private static function find_full_sync_end_action( array $actions ): ?array {
236        foreach ( $actions as $action ) {
237            if ( isset( $action[0] ) && 'jetpack_full_sync_end' === $action[0] ) {
238                return $action;
239            }
240        }
241        return null;
242    }
243}