Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.78% covered (warning)
88.78%
87 / 98
64.71% covered (warning)
64.71%
11 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Sync_Status_Tracker
88.78% covered (warning)
88.78%
87 / 98
64.71% covered (warning)
64.71%
11 / 17
61.59
0.00% covered (danger)
0.00%
0 / 1
 configure
100.00% covered (success)
100.00%
4 / 4
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%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 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%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 on_full_sync_start
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
6.04
 has_module_full_sync_started
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
6.13
 get_full_sync_status
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 is_legacy_start
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 includes_analytics_module
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 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
 has_initial_sync_finished
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 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, and
14 * records when each analytics module's first full sync starts.
15 *
16 * /jetpack/v4/sync/status reports current sync state but not whether the analytics full sync
17 * has completed at least once — which is what tells the dashboard's store section its numbers are complete.
18 */
19class Sync_Status_Tracker {
20
21    /**
22     * Milestone (unix ts) for the analytics-module initial full sync. Marks the
23     * dashboard's store data as complete.
24     */
25    const INITIAL_ANALYTICS_SYNC_OPTION = 'jetpack_premium_analytics_initial_analytics_sync_finished';
26
27    /**
28     * First full-sync start timestamps, keyed by analytics module name.
29     *
30     * @since 0.14.0
31     */
32    const ANALYTICS_SYNC_STARTED_OPTION = 'jetpack_premium_analytics_analytics_sync_started';
33
34    /**
35     * Consumer packages register their analytics module names through the sync-module filter.
36     *
37     * @var string[]
38     */
39    const ANALYTICS_SYNC_MODULES = array();
40
41    /**
42     * Action hook fired once when the analytics milestone flips. Consumer plugins
43     * use this to fire one-time side-effects keyed to store data (emails, tracking
44     * events, etc.).
45     *
46     * @var string
47     */
48    const MILESTONE_ACTION = 'jetpack_premium_analytics_initial_full_sync_finished';
49
50    /**
51     * Jetpack core's sync-status REST route, enriched with the milestone.
52     */
53    const SYNC_STATUS_ROUTE = '/jetpack/v4/sync/status';
54
55    /**
56     * Wire up the milestone listener, the full-sync start record, the script-data filter, and the sync-status enricher.
57     *
58     * Idempotent: safe to call more than once.
59     *
60     * @return void
61     */
62    public static function configure() {
63        add_action( 'jetpack_sync_processed_actions', array( self::class, 'on_sync_processed_actions' ) );
64        add_action( 'jetpack_full_sync_start', array( self::class, 'on_full_sync_start' ) );
65        add_filter( 'jetpack_admin_js_script_data', array( self::class, 'inject_script_data' ) );
66        add_filter( 'rest_post_dispatch', array( self::class, 'enrich_sync_status_response' ), 10, 3 );
67    }
68
69    /**
70     * Append the milestone to Jetpack core's GET /jetpack/v4/sync/status response.
71     *
72     * Unlike the one-time script-data snapshot ({@see inject_script_data()}), this stays live for
73     * in-session completion — but touches only the already-authorized, successful status payload.
74     *
75     * @param mixed $response Result to send to the client. Usually a WP_REST_Response.
76     * @param mixed $server   The REST server instance (unused).
77     * @param mixed $request  The request used to generate the response.
78     * @return mixed
79     */
80    public static function enrich_sync_status_response( $response, $server, $request ) {
81        if ( ! $request instanceof \WP_REST_Request
82            || self::SYNC_STATUS_ROUTE !== $request->get_route()
83            || ! $response instanceof \WP_REST_Response
84            || $response->is_error() ) {
85            return $response;
86        }
87
88        $data = $response->get_data();
89        if ( is_array( $data ) ) {
90            self::maybe_backfill_milestone();
91            $data['initial_full_sync_finished'] = self::milestone();
92            $response->set_data( $data );
93        }
94
95        return $response;
96    }
97
98    /**
99     * On every batch of processed sync actions, look for the gating
100     * jetpack_full_sync_end and flip the milestone if it hasn't already fired.
101     *
102     * @param array $actions Processed sync actions.
103     * @return void
104     */
105    public static function on_sync_processed_actions( array $actions ): void {
106        // Bail before the per-batch full-sync lookup ($module->get_status() bypasses
107        // the status cache) once the milestone is set.
108        if ( self::milestone_reached() ) {
109            return;
110        }
111
112        $module = Modules::get_module( 'full-sync' );
113        if ( ! $module ) {
114            return;
115        }
116        '@phan-var \Automattic\Jetpack\Sync\Modules\Full_Sync_Immediately|\Automattic\Jetpack\Sync\Modules\Full_Sync $module';
117
118        self::maybe_set_milestone( $module->get_status(), $actions );
119    }
120
121    /**
122     * Resolve the configured sync-module names for analytics.
123     *
124     * @return string[]
125     */
126    public static function get_analytics_sync_modules(): array {
127        /**
128         * Filter the analytics module names whose starts are tracked and whose completion sets the milestone.
129         *
130         * @param string[] $module_names Default: empty array.
131         */
132        return (array) apply_filters( 'jetpack_premium_analytics_sync_modules', self::ANALYTICS_SYNC_MODULES );
133    }
134
135    /**
136     * Decide whether 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        if ( self::milestone_reached() ) {
147            return;
148        }
149
150        if ( ! self::includes_analytics_module( $full_status['config'] ?? array() ) ) {
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
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        // Record the finishing sync's modules first, so a milestone set after the update never reads as a legacy one.
218        if ( false === get_option( self::ANALYTICS_SYNC_STARTED_OPTION ) ) {
219            self::on_full_sync_start( $full_status['config'] ?? array() );
220        }
221
222        update_option( self::INITIAL_ANALYTICS_SYNC_OPTION, (int) $full_status['finished'] );
223
224        /**
225         * Fires once when the analytics-relevant initial full sync completes.
226         *
227         * @param array $full_status Final full-sync status (with `finished` timestamp).
228         */
229        do_action( self::MILESTONE_ACTION, $full_status );
230    }
231
232    /**
233     * Record each analytics module's first full-sync start.
234     *
235     * A milestone set before start records existed counts as a start of every module registered then.
236     *
237     * @since 0.14.0
238     *
239     * @param array|mixed $config Sync configuration of the full sync that began.
240     * @return void
241     */
242    public static function on_full_sync_start( $config ): void {
243        $config   = (array) $config;
244        $previous = (array) get_option( self::ANALYTICS_SYNC_STARTED_OPTION, array() );
245        $started  = $previous;
246        if ( self::is_legacy_start() ) {
247            $started = array_fill_keys( self::get_analytics_sync_modules(), self::milestone() );
248        }
249        foreach ( self::get_analytics_sync_modules() as $module_name ) {
250            if ( ! empty( $config[ $module_name ] ) && empty( $started[ $module_name ] ) ) {
251                $started[ $module_name ] = time();
252            }
253        }
254
255        if ( $started !== $previous ) {
256            update_option( self::ANALYTICS_SYNC_STARTED_OPTION, $started );
257        }
258    }
259
260    /**
261     * Whether the given module's full sync has ever started on this site.
262     *
263     * @since 0.14.0
264     *
265     * @param string $module_name Sync module name.
266     * @return bool
267     */
268    public static function has_module_full_sync_started( string $module_name ): bool {
269        if ( self::is_legacy_start() ) {
270            return true;
271        }
272
273        $started = (array) get_option( self::ANALYTICS_SYNC_STARTED_OPTION, array() );
274        if ( (int) ( $started[ $module_name ] ?? 0 ) > 0 ) {
275            return true;
276        }
277
278        // A cached miss can predate another request's start, and a sync begun before start records existed never records one.
279        $started = (array) \Jetpack_Options::get_raw_option( self::ANALYTICS_SYNC_STARTED_OPTION, array() );
280        if ( (int) ( $started[ $module_name ] ?? 0 ) > 0 ) {
281            return true;
282        }
283
284        $full_status = self::get_full_sync_status();
285        if ( empty( $full_status['started'] ) || empty( $full_status['config'][ $module_name ] ) ) {
286            return false;
287        }
288
289        self::on_full_sync_start( $full_status['config'] );
290        return true;
291    }
292
293    /**
294     * The stored full-sync status, read past the option cache.
295     *
296     * @return array
297     */
298    private static function get_full_sync_status(): array {
299        $module = Modules::get_module( 'full-sync' );
300        if ( ! $module ) {
301            return array();
302        }
303        '@phan-var \Automattic\Jetpack\Sync\Modules\Full_Sync_Immediately|\Automattic\Jetpack\Sync\Modules\Full_Sync $module';
304
305        return (array) $module->get_status();
306    }
307
308    /**
309     * Whether the milestone predates the start records, so it stands in for them.
310     *
311     * @return bool
312     */
313    private static function is_legacy_start(): bool {
314        return false === get_option( self::ANALYTICS_SYNC_STARTED_OPTION ) && self::milestone_reached();
315    }
316
317    /**
318     * Whether a full-sync configuration covers an analytics module.
319     *
320     * @param array|mixed $config Full-sync configuration, keyed by module name.
321     * @return bool
322     */
323    private static function includes_analytics_module( $config ): bool {
324        $config = (array) $config;
325        foreach ( self::get_analytics_sync_modules() as $module_name ) {
326            if ( ! empty( $config[ $module_name ] ) ) {
327                return true;
328            }
329        }
330        return false;
331    }
332
333    /**
334     * Inject the milestone into JetpackScriptData so the dashboard can read it at
335     * page load without an extra HTTP roundtrip.
336     *
337     * @param array $data The script data passed by the assets package.
338     * @return array
339     */
340    public static function inject_script_data( array $data ): array {
341        $data['premium_analytics'] = array(
342            'initial_full_sync_finished' => self::milestone(),
343        );
344
345        return $data;
346    }
347
348    /**
349     * The milestone timestamp (0 if not yet reached).
350     *
351     * @return int
352     */
353    private static function milestone(): int {
354        return (int) get_option( self::INITIAL_ANALYTICS_SYNC_OPTION, 0 );
355    }
356
357    /**
358     * Whether the milestone has fired.
359     *
360     * @return bool
361     */
362    private static function milestone_reached(): bool {
363        return self::milestone() > 0;
364    }
365
366    /**
367     * Whether the first analytics full sync has finished.
368     *
369     * @since 0.14.0
370     *
371     * @return bool
372     */
373    public static function has_initial_sync_finished(): bool {
374        return self::milestone_reached();
375    }
376
377    /**
378     * Find the jetpack_full_sync_end action in a processed-actions list.
379     *
380     * @param array $actions Actions list.
381     * @return array|null
382     */
383    private static function find_full_sync_end_action( array $actions ): ?array {
384        foreach ( $actions as $action ) {
385            if ( isset( $action[0] ) && 'jetpack_full_sync_end' === $action[0] ) {
386                return $action;
387            }
388        }
389        return null;
390    }
391}