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