Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.41% covered (success)
93.41%
156 / 167
80.77% covered (warning)
80.77%
21 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
Analytics
93.41% covered (success)
93.41%
156 / 167
80.77% covered (warning)
80.77%
21 / 26
61.03
0.00% covered (danger)
0.00%
0 / 1
 init
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 load_dashboard_surface
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 renders_admin_chrome
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 init_wpcom_simple
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 apply_options
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 boot_shared_services
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 dashboard_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_script_data
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add_script_data
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 site_timezone
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 format_gmt_offset
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 register_sync_bootstrap
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 register_local_api
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 load_dashboard_components
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
6.01
 register_dashboard_support_routes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 load_build
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 remove_full_page_interceptor
25.00% covered (danger)
25.00%
2 / 8
0.00% covered (danger)
0.00%
0 / 1
6.80
 widget_manifest_path
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 register_admin_page
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 is_dashboard_request
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 register_admin_menu
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
5
 render_missing_build_notice
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 menu_title
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 enqueue_i18n_loader
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 enqueue_tracks_transport
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 add_tracks_identity_script_data
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * Analytics package main class.
4 *
5 * @package automattic/jetpack-premium-analytics
6 */
7
8namespace Automattic\Jetpack\PremiumAnalytics;
9
10use Automattic\Jetpack\Connection\Manager as Connection_Manager;
11use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Export;
12use Automattic\Jetpack\PremiumAnalytics\REST\Api_Proxy_Controller;
13use Automattic\Jetpack\PremiumAnalytics\REST\Notices_Controller;
14use Automattic\Jetpack\PremiumAnalytics\Sync\Configuration as Sync_Configuration;
15use Automattic\Jetpack\PremiumAnalytics\Sync\Sync_Status_Tracker;
16use Automattic\Jetpack\Status\Host;
17use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
18
19/**
20 * Main Analytics class.
21 *
22 * Loads the wp-build output and registers the dashboard's admin page.
23 */
24class Analytics {
25
26    const PACKAGE_VERSION = '0.6.1';
27
28    /**
29     * Whether the class has been initialized.
30     *
31     * @var bool
32     */
33    private static $initialized = false;
34
35    /**
36     * Menu title override for the admin page. Null falls back to the package's own
37     * translated label, resolved on admin_menu — init runs far too early to translate.
38     *
39     * @var string|\Closure|null
40     */
41    private static $menu_title = null;
42
43    /**
44     * The menu label once resolved, so the menu and the missing-build notice can't
45     * disagree if a caller hands us a closure that returns something different
46     * each call. Reset whenever $menu_title is assigned.
47     *
48     * @var string|null
49     */
50    private static $resolved_menu_title = null;
51
52    /**
53     * Path to the wp-build entry point. Null uses the generated build.
54     *
55     * A test seam: `build/` is gitignored and test-php runs no build step, so tests redirect this
56     * instead. Private, so — unlike the widget manifest's path — it needs no filter to stay out of reach.
57     *
58     * @var string|null
59     */
60    private static $build_entry = null;
61
62    /**
63     * Initialize the Analytics app on a connected Jetpack site.
64     *
65     * Registers the full local surface: the site serves the WPCOM data proxy,
66     * notices, sync bootstrap, and the dashboard support routes itself.
67     *
68     * Hosts call this on every request once the flag is on, never only on admin ones: the
69     * store-event tracker listens on the front end. {@see self::load_dashboard_surface()} is what
70     * keeps the admin-only work off those requests.
71     *
72     * @param array $options Optional configuration options.
73     *                       Supported keys:
74     *                       - menu_title (string|\Closure): Admin menu label. Defaults to
75     *                         the package's own translated label. Pass a closure to supply
76     *                         a translated label of your own: it runs on admin_menu, where
77     *                         a textdomain can load, unlike init time.
78     * @return void
79     */
80    public static function init( $options = array() ) {
81        if ( self::$initialized ) {
82            return;
83        }
84        self::$initialized = true;
85        self::apply_options( $options );
86
87        self::register_sync_bootstrap();
88        self::register_local_api();
89
90        // Piggybacks on the Jetpack Stats module; checks Jetpack connection state.
91        Jetpack_Stats_Tracker::configure();
92
93        self::boot_shared_services();
94        self::register_dashboard_support_routes();
95        self::load_dashboard_surface();
96    }
97
98    /**
99     * Load the dashboard render surface, on the requests that can render it.
100     *
101     * With the rollout flag on, init() runs on every request — including every WPCOM public-api
102     * request on Simple — so this stays gated for visitors who never use it (REST excluded; see load_build()).
103     *
104     * @return void
105     */
106    private static function load_dashboard_surface() {
107        if ( ! self::renders_admin_chrome() ) {
108            return;
109        }
110
111        self::load_dashboard_components();
112        self::load_build();
113        self::remove_full_page_interceptor();
114        self::register_admin_page();
115    }
116
117    /**
118     * Whether this request can render an admin screen.
119     *
120     * Core also sets is_admin() on admin-ajax.php and admin-post.php, which render no dashboard
121     * and get no handlers from this package, so neither needs the build parsed.
122     *
123     * @return bool
124     */
125    private static function renders_admin_chrome() {
126        if ( ! is_admin() || wp_doing_ajax() ) {
127            return false;
128        }
129
130        // wp-includes/vars.php sets $pagenow before plugins load.
131        return 'admin-post.php' !== ( $GLOBALS['pagenow'] ?? '' );
132    }
133
134    /**
135     * Initialize the Analytics app on WordPress.com Simple.
136     *
137     * Simple reaches public-api.wordpress.com directly via WPCOM's apiFetch bridge, registering
138     * no local REST surface (no proxy, notices, sync bootstrap, or dashboard routes) — WPCOM handles those.
139     *
140     * @param array $options Optional configuration options.
141     *                       Supported keys:
142     *                       - menu_title (string|\Closure): Admin menu label. Defaults to
143     *                         the package's own translated label. Pass a closure to supply
144     *                         a translated label of your own: it runs on admin_menu, where
145     *                         a textdomain can load, unlike init time.
146     * @return void
147     */
148    public static function init_wpcom_simple( $options = array() ) {
149        if ( self::$initialized ) {
150            return;
151        }
152        self::$initialized = true;
153        self::apply_options( $options );
154
155        self::boot_shared_services();
156        self::load_dashboard_surface();
157    }
158
159    /**
160     * Apply init-time configuration options.
161     *
162     * @param array $options Options passed to the init entry points.
163     * @return void
164     */
165    private static function apply_options( $options ) {
166        if ( ! empty( $options['menu_title'] ) ) {
167            self::$menu_title          = $options['menu_title'];
168            self::$resolved_menu_title = null;
169        }
170    }
171
172    /**
173     * Boot the services every platform needs, whether or not the site serves the
174     * dashboard support routes itself.
175     *
176     * @return void
177     */
178    private static function boot_shared_services() {
179        // On every request: flags are read and toggled outside the admin too.
180        if ( ! function_exists( __NAMESPACE__ . '\\register_dashboard_feature_flags' ) ) {
181            require_once __DIR__ . '/dashboard-policy.php';
182        }
183        register_dashboard_feature_flags();
184
185        // Must be hooked before admin_menu and rest_api_init check the capability.
186        Capabilities::register();
187
188        // Emit WooCommerce store events into the Woo pipeline (ClickHouse + proxy).
189        WooCommerce_Analytics_Tracker::configure();
190
191        // CSV report export pipeline (WOOA7S-1581): hooks rest_api_init, so it must
192        // register on all requests. Self-gates on WooCommerce + Jetpack connection.
193        Export::configure();
194
195        self::register_script_data();
196
197        // The posts and pages list tables link their views column here.
198        Post_List_Link::register();
199    }
200
201    /**
202     * URL of a dashboard route on this site.
203     *
204     * The SPA path travels in `p`, encoded here since add_query_arg() leaves values alone and a
205     * raw `?` inside it would read as an outer query param.
206     *
207     * @since 0.4.0
208     *
209     * @param string $path Route path, e.g. `/post/123`.
210     * @return string
211     */
212    public static function dashboard_url( $path = '/' ) {
213        return admin_url( 'admin.php?page=' . self::MENU_PAGE_SLUG . '&p=' . rawurlencode( $path ) );
214    }
215
216    /**
217     * Announce to Jetpack's other surfaces that this dashboard is the site's analytics UI,
218     * so they link here instead of the Stats page.
219     *
220     * @return void
221     */
222    private static function register_script_data() {
223        add_filter( 'jetpack_admin_js_script_data', array( static::class, 'add_script_data' ) );
224    }
225
226    /**
227     * Runs on nearly every admin page load, so the payload stays to two strings,
228     * a bool, and one capability check.
229     *
230     * @param array $data The script data.
231     * @return array The script data with the analytics key added.
232     */
233    public static function add_script_data( $data ) {
234        $data['analytics'] = array(
235            'enabled'   => true,
236            'page_slug' => self::MENU_PAGE_SLUG,
237            'can_view'  => current_user_can( Capabilities::VIEW_ANALYTICS ),
238            'timezone'  => self::site_timezone(),
239        );
240
241        return $data;
242    }
243
244    /**
245     * Prefers `timezone_string` over `gmt_offset`, matching the dashboard's own `siteTimeZone()`:
246     * analytics links point at past dates, so a fixed offset applied to the far side of a
247     * daylight-saving transition shifts the day.
248     *
249     * @return string An IANA timezone name, or a `+HH:MM` UTC offset.
250     */
251    private static function site_timezone() {
252        $timezone_string = get_option( 'timezone_string' );
253
254        if ( is_string( $timezone_string ) && $timezone_string !== '' ) {
255            return $timezone_string;
256        }
257
258        return self::format_gmt_offset( (float) get_option( 'gmt_offset' ) );
259    }
260
261    /**
262     * Format a GMT offset in hours as `+HH:MM`.
263     *
264     * @param float $offset The offset in hours, e.g. 5.5 or -8.
265     * @return string The formatted offset.
266     */
267    private static function format_gmt_offset( $offset ) {
268        $sign     = $offset < 0 ? '-' : '+';
269        $absolute = abs( $offset );
270        $hours    = (int) floor( $absolute );
271        $minutes  = (int) round( ( $absolute - $hours ) * 60 );
272
273        return sprintf( '%s%02d:%02d', $sign, $hours, $minutes );
274    }
275
276    /**
277     * Register the sync services that feed the local data pipeline.
278     *
279     * @return void
280     */
281    private static function register_sync_bootstrap() {
282        // Keep the shared connection available when another connection-owning plugin is deactivated.
283        Connection_Configuration::configure();
284
285        Sync_Status_Tracker::configure();
286
287        // TEMPORARY (WOOA7S-1550): register the interim woocommerce_analytics sync module so
288        // Sync_Status_Tracker has a full sync to observe. Remove when the shared sync-modules package lands.
289        Sync_Configuration::register();
290    }
291
292    /**
293     * Register the site-served REST API: the WPCOM data proxy and notices.
294     *
295     * Both self-gate on their own rest_api_init hooks.
296     *
297     * @return void
298     */
299    private static function register_local_api() {
300        Api_Proxy_Controller::register();
301        Notices_Controller::register();
302    }
303
304    /**
305     * Load the dashboard components every platform renders with.
306     *
307     * Admin-only, via load_dashboard_surface(); boot_routes() requires these
308     * again for REST.
309     *
310     * @return void
311     */
312    private static function load_dashboard_components() {
313        /*
314         * Every include below is guarded on a symbol the target file declares.
315         *
316         * Two copies of this package can be loaded in one request — WPCOM Simple ships
317         * one under jetpack-plugin and another under jetpack-mu-wpcom-plugin. The
318         * autoloader dedupes classes by version, but these files declare functions and
319         * constants at file scope, so they are absent from the classmap entirely and
320         * reach us through `require_once`, which dedupes by path and not by symbol.
321         * Once a class from one copy and a class from the other both run their
322         * includes, PHP fatals on the redeclared functions. The guards make the second
323         * copy's include a no-op, which also keeps the files' file-scope side effects
324         * (add_filter() calls, registry bootstrapping) from running twice.
325         */
326
327        // Widget modules for the client's dynamic import() map.
328        if ( ! function_exists( __NAMESPACE__ . '\\register_widget_modules_rest_route' ) ) {
329            require_once __DIR__ . '/widget-modules.php';
330        }
331
332        // Default layout's first-load preference injection.
333        if ( ! function_exists( __NAMESPACE__ . '\\register_dashboard_default_layout_route' ) ) {
334            require_once __DIR__ . '/dashboard-layout.php';
335        }
336
337        // Dashboard sections and their default layout seeding.
338        if ( ! function_exists( __NAMESPACE__ . '\\register_dashboard_section' ) ) {
339            require_once __DIR__ . '/dashboard-sections.php';
340        }
341        configure_dashboard_preview_scope();
342
343        // Default-on CSV export settings and server-side disable filter.
344        if ( ! function_exists( __NAMESPACE__ . '\\configure_csv_exports' ) ) {
345            require_once __DIR__ . '/csv-exports.php';
346        }
347        configure_csv_exports();
348
349        // VideoPress availability for the client's video routes. The widget layer
350        // reads the same signal through widget-type-support.php.
351        if ( ! function_exists( __NAMESPACE__ . '\\configure_videopress_availability' ) ) {
352            require_once __DIR__ . '/videopress-availability.php';
353        }
354        configure_videopress_availability();
355
356        // The composition flag's answer, read by the dashboard policy; the file is
357        // already loaded by boot_shared_services().
358        configure_dashboard_policy();
359    }
360
361    /**
362     * Serve the dashboard support routes from the site. Simple skips this —
363     * WPCOM calls Dashboard_Support_Routes::register() itself instead.
364     *
365     * @return void
366     */
367    private static function register_dashboard_support_routes() {
368        Dashboard_Support_Routes::register();
369    }
370
371    /**
372     * Load the wp-build output (interceptor, modules, routes, page render).
373     *
374     * Admin-only, via load_dashboard_surface(). REST does not need it:
375     * boot_routes() and ensure_widget_registry_ready() load what they use.
376     *
377     * @return void
378     */
379    private static function load_build() {
380        $build_entry = self::$build_entry ?? __DIR__ . '/../build/build.php';
381        if ( file_exists( $build_entry ) ) {
382            require_once $build_entry;
383        }
384    }
385
386    /**
387     * Unhook wp-build's full-page render interceptor — security-relevant: it renders
388     * `?page=jetpack-premium-analytics` from admin_init with no capability check, and only
389     * renders_admin_chrome() gates the admin-post.php/admin-ajax.php paths that reach admin_init
390     * without Core's own slug check.
391     *
392     * Because remove_action() no-ops on a callback name it can't find, a wp-build rename would
393     * silently restore this entry point — hence the _doing_it_wrong() below when that happens.
394     *
395     * @return void
396     */
397    private static function remove_full_page_interceptor() {
398        if ( remove_action( 'admin_init', 'jpa_jetpack_premium_analytics_intercept_render' ) ) {
399            return;
400        }
401
402        if ( function_exists( 'jpa_jetpack_premium_analytics_intercept_render' ) ) {
403            _doing_it_wrong(
404                __METHOD__,
405                'The Premium Analytics full-page interceptor could not be unhooked: wp-build changed the generated callback name or its admin_init priority.',
406                ''
407            );
408        }
409    }
410
411    /**
412     * Absolute path to the generated widget manifest.
413     *
414     * On the class, not beside its readers in widget-modules.php: two copies of this package can
415     * load in one request, and only classes get the autoloader's version dedupe (see load_dashboard_components()).
416     *
417     * @return string
418     */
419    public static function widget_manifest_path() {
420        /**
421         * Filters the path to the generated widget manifest.
422         *
423         * @param string $path Absolute path to the generated widget manifest.
424         */
425        return apply_filters(
426            'jetpack_premium_analytics_widgets_manifest_path',
427            __DIR__ . '/../build/widgets.php'
428        );
429    }
430
431    /**
432     * Register the admin-only render path: polyfills, menu, and page hooks.
433     *
434     * @return void
435     */
436    private static function register_admin_page() {
437        // Polyfills force-replace core handles (wp-private-apis) on wp_default_scripts;
438        // scope to the dashboard page so no other admin page (e.g. block editor) is hit.
439        if ( self::is_dashboard_request() ) {
440            WP_Build_Polyfills::register(
441                'jetpack-premium-analytics',
442                array_merge(
443                    WP_Build_Polyfills::SCRIPT_HANDLES,
444                    WP_Build_Polyfills::MODULE_IDS
445                )
446            );
447
448            add_action( 'admin_enqueue_scripts', array( static::class, 'enqueue_i18n_loader' ) );
449            add_action( 'admin_enqueue_scripts', array( static::class, 'enqueue_tracks_transport' ) );
450            add_filter( 'jetpack_admin_js_script_data', array( static::class, 'add_tracks_identity_script_data' ), 20 );
451        }
452
453        add_action( 'admin_menu', array( static::class, 'register_admin_menu' ) );
454    }
455
456    /**
457     * The admin page slug the dashboard menu registers. Published in script data
458     * so no caller has to hard-code it.
459     */
460    const MENU_PAGE_SLUG = 'jetpack-premium-analytics-wp-admin';
461
462    /**
463     * Whether the current request is rendering the Premium Analytics dashboard.
464     *
465     * Scopes the wp-build polyfill registration (which force-replaces core script handles) to
466     * this dashboard; reads the menu slug directly, not current_screen, to stay safe at plugin-load time.
467     *
468     * @return bool True when serving the dashboard page in wp-admin.
469     */
470    public static function is_dashboard_request() {
471        if ( ! is_admin() ) {
472            return false;
473        }
474
475        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the menu page slug to scope asset loading; no state is changed.
476        $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
477
478        return self::MENU_PAGE_SLUG === $page;
479    }
480
481    /**
482     * Register the admin menu page.
483     *
484     * Uses wp-build's `-wp-admin` variant so Core applies the menu capability check. Reports the
485     * page and widget artifacts independently since the build loader includes each conditionally.
486     *
487     * @return void
488     */
489    public static function register_admin_menu() {
490        $can_render          = function_exists( 'jpa_jetpack_premium_analytics_wp_admin_render_page' );
491        $has_widget_manifest = file_exists( self::widget_manifest_path() );
492
493        $missing = array();
494        if ( ! $can_render ) {
495            // Named by symbol, not by file: build/pages.php is only a loader, and the
496            // callback can also go missing to a renamed page slug or an absent build entry.
497            $missing[] = 'the jpa_jetpack_premium_analytics_wp_admin_render_page() callback, generated under build/pages/';
498        }
499        if ( ! $has_widget_manifest ) {
500            $missing[] = 'build/widgets.php (the widget manifest)';
501        }
502
503        if ( $missing ) {
504            // Surfaced here rather than only on the page itself, so a partial deploy shows up on
505            // the first admin request instead of waiting for someone to open the dashboard.
506            _doing_it_wrong(
507                __METHOD__,
508                // esc_html() only to satisfy WordPress.Security.EscapeOutput, which treats
509                // this argument as output; every entry is a literal from just above.
510                'The Premium Analytics build output is incomplete: ' . esc_html( implode( ', ', $missing ) ) . '. The package build did not run, or ran only partially, for this deploy.',
511                ''
512            );
513        }
514
515        $render_callback = $can_render
516            ? 'jpa_jetpack_premium_analytics_wp_admin_render_page'
517            : array( __CLASS__, 'render_missing_build_notice' );
518
519        $menu_title = self::menu_title();
520
521        add_menu_page(
522            esc_html( $menu_title ),
523            esc_html( $menu_title ),
524            Capabilities::VIEW_ANALYTICS,
525            self::MENU_PAGE_SLUG,
526            $render_callback,
527            'dashicons-chart-bar',
528            2
529        );
530    }
531
532    /**
533     * Stand-in for the generated render callback when the build output is absent.
534     *
535     * The PHP classes come from Composer and the build output from pnpm, so a
536     * partial deploy can leave the class loadable with nothing to render.
537     *
538     * @return void
539     */
540    public static function render_missing_build_notice() {
541        printf(
542            '<div class="wrap"><h1>%s</h1><p>%s</p></div>',
543            esc_html( self::menu_title() ),
544            esc_html__( 'The Premium Analytics assets are missing. The package build did not run for this deploy.', 'jetpack-premium-analytics-pkg' )
545        );
546    }
547
548    /**
549     * The caller's menu label override, or the package's own translated label.
550     *
551     * Call only once translations can load — admin_menu or later — and memoize so every call site
552     * agrees. Deliberately not is_callable(): PHP function names are case-insensitive, so a plain
553     * label like "Analytics" could match a stray analytics() function and get called.
554     *
555     * @return string
556     */
557    private static function menu_title() {
558        if ( null !== self::$resolved_menu_title ) {
559            return self::$resolved_menu_title;
560        }
561
562        $title = self::$menu_title instanceof \Closure
563            ? ( self::$menu_title )()
564            : self::$menu_title;
565
566        // A positive check rather than a null coalesce: a closure may return an empty string, or
567        // something that isn't a string at all, and either would reach esc_html() as a broken label.
568        self::$resolved_menu_title = is_string( $title ) && '' !== $title
569            ? $title
570            : __( 'Stats v2', 'jetpack-premium-analytics-pkg' );
571
572        return self::$resolved_menu_title;
573    }
574
575    /**
576     * Enqueue the i18n loader so the wp-build init module can download its JS
577     * translation catalogs. It's registered on every admin page by jetpack-assets
578     * but only enqueued when depended on; the esbuild bundles don't pull it in.
579     *
580     * @return void
581     */
582    public static function enqueue_i18n_loader() {
583        if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
584            wp_enqueue_script( 'wp-jp-i18n-loader' );
585        }
586    }
587
588    /**
589     * Load the Tracks transport for the dashboard.
590     *
591     * `@automattic/jetpack-analytics` only queues events into `window._tkq` — its own w.js
592     * loader is disabled — so without this handle no `jetpack_premium_analytics_*` event
593     * ever flushes. Simple is skipped because stats.php already prints the same script.
594     *
595     * @return void
596     */
597    public static function enqueue_tracks_transport() {
598        if ( ( new Host() )->is_wpcom_simple() ) {
599            return;
600        }
601
602        wp_enqueue_script( 'jp-tracks', '//stats.wp.com/w.js', array(), gmdate( 'YW' ), true );
603    }
604
605    /**
606     * Publish the WPCOM identity the dashboard attributes its Tracks events to.
607     *
608     * Core's script data carries only the local user. Publicize is the one package that fills
609     * `current_user.wpcom` in, and the standalone plugin does not bundle it, so without this
610     * every event would land anonymous there.
611     *
612     * @param array $data The script data.
613     * @return array The script data with the WPCOM identity added.
614     */
615    public static function add_tracks_identity_script_data( $data ) {
616        if ( ( new Host() )->is_wpcom_simple() ) {
617            $wpcom_user = array(
618                'ID'    => get_current_user_id(),
619                'login' => wp_get_current_user()->user_login,
620            );
621        } else {
622            $connected = ( new Connection_Manager() )->get_connected_user_data();
623
624            if ( empty( $connected['ID'] ) || empty( $connected['login'] ) ) {
625                return $data;
626            }
627
628            // Only the two fields `identifyUser` needs: the rest of the connected-user payload
629            // is profile data the dashboard never reads.
630            $wpcom_user = array(
631                'ID'    => $connected['ID'],
632                'login' => $connected['login'],
633            );
634        }
635
636        $data['user']['current_user']['wpcom'] = array_merge(
637            $data['user']['current_user']['wpcom'] ?? array(),
638            $wpcom_user
639        );
640
641        return $data;
642    }
643}