# jetpack/16.3-a.3/jetpack_vendor/automattic/jetpack-premium-analytics/src/class-analytics.php

Jetpack – WP Security, Backup, Speed, &amp; Growth, version 16.3-a.3. 653 lines.

- Page: https://pluginprobe.com/plugins/jetpack/16.3-a.3/code/jetpack_vendor/automattic/jetpack-premium-analytics/src/class-analytics.php
- Raw: https://pluginprobe.com/plugins/jetpack/16.3-a.3/raw/jetpack_vendor/automattic/jetpack-premium-analytics/src/class-analytics.php
- Modified: 2026-09-21T19:42:08+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/jetpack/16.3-a.3/code/jetpack_vendor/automattic/jetpack-premium-analytics/src/class-analytics.php#L10-L20`.

```php
<?php
/**
 * Analytics package main class.
 *
 * @package automattic/jetpack-premium-analytics
 */

namespace Automattic\Jetpack\PremiumAnalytics;

use Automattic\Jetpack\Admin_UI\Admin_Menu;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Export;
use Automattic\Jetpack\PremiumAnalytics\REST\Api_Proxy_Controller;
use Automattic\Jetpack\PremiumAnalytics\REST\Notices_Controller;
use Automattic\Jetpack\PremiumAnalytics\Sync\Configuration as Sync_Configuration;
use Automattic\Jetpack\PremiumAnalytics\Sync\Sync_Status_Tracker;
use Automattic\Jetpack\Status\Host;
use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;

/**
 * Main Analytics class.
 *
 * Loads the wp-build output and registers the dashboard's admin page.
 */
class Analytics {

	const PACKAGE_VERSION = '0.8.0';

	/**
	 * Whether the class has been initialized.
	 *
	 * @var bool
	 */
	private static $initialized = false;

	/**
	 * Menu title override for the admin page. Null falls back to the package's own
	 * translated label, resolved on admin_menu — init runs far too early to translate.
	 *
	 * @var string|\Closure|null
	 */
	private static $menu_title = null;

	/**
	 * The menu label once resolved, so the menu and the missing-build notice can't
	 * disagree if a caller hands us a closure that returns something different
	 * each call. Reset whenever $menu_title is assigned.
	 *
	 * @var string|null
	 */
	private static $resolved_menu_title = null;

	/**
	 * Path to the wp-build entry point. Null uses the generated build.
	 *
	 * A test seam: `build/` is gitignored and test-php runs no build step, so tests redirect this
	 * instead. Private, so — unlike the widget manifest's path — it needs no filter to stay out of reach.
	 *
	 * @var string|null
	 */
	private static $build_entry = null;

	/**
	 * Initialize the Analytics app on a connected Jetpack site.
	 *
	 * Registers the full local surface: the site serves the WPCOM data proxy,
	 * notices, sync bootstrap, and the dashboard support routes itself.
	 *
	 * Hosts call this on every request once the flag is on, never only on admin ones: the
	 * store-event tracker listens on the front end. {@see self::load_dashboard_surface()} is what
	 * keeps the admin-only work off those requests.
	 *
	 * @param array $options Optional configuration options.
	 *                       Supported keys:
	 *                       - menu_title (string|\Closure): Admin menu label. Defaults to
	 *                         the package's own translated label. Pass a closure to supply
	 *                         a translated label of your own: it runs on admin_menu, where
	 *                         a textdomain can load, unlike init time.
	 * @return void
	 */
	public static function init( $options = array() ) {
		if ( self::$initialized ) {
			return;
		}
		self::$initialized = true;
		self::apply_options( $options );

		self::register_sync_bootstrap();
		self::register_local_api();

		// Piggybacks on the Jetpack Stats module; checks Jetpack connection state.
		Jetpack_Stats_Tracker::configure();

		self::boot_shared_services();
		self::register_dashboard_support_routes();
		self::load_dashboard_surface();
	}

	/**
	 * Load the dashboard render surface, on the requests that can render it.
	 *
	 * With the rollout flag on, init() runs on every request — including every WPCOM public-api
	 * request on Simple — so this stays gated for visitors who never use it (REST excluded; see load_build()).
	 *
	 * @return void
	 */
	private static function load_dashboard_surface() {
		if ( ! self::renders_admin_chrome() ) {
			return;
		}

		self::load_dashboard_components();
		self::load_build();
		self::remove_full_page_interceptor();
		self::register_admin_page();
	}

	/**
	 * Whether this request can render an admin screen.
	 *
	 * Core also sets is_admin() on admin-ajax.php and admin-post.php, which render no dashboard
	 * and get no handlers from this package, so neither needs the build parsed.
	 *
	 * @return bool
	 */
	private static function renders_admin_chrome() {
		if ( ! is_admin() || wp_doing_ajax() ) {
			return false;
		}

		// wp-includes/vars.php sets $pagenow before plugins load.
		return 'admin-post.php' !== ( $GLOBALS['pagenow'] ?? '' );
	}

	/**
	 * Initialize the Analytics app on WordPress.com Simple.
	 *
	 * Simple reaches public-api.wordpress.com directly via WPCOM's apiFetch bridge, registering
	 * no local REST surface (no proxy, notices, sync bootstrap, or dashboard routes) — WPCOM handles those.
	 *
	 * @param array $options Optional configuration options.
	 *                       Supported keys:
	 *                       - menu_title (string|\Closure): Admin menu label. Defaults to
	 *                         the package's own translated label. Pass a closure to supply
	 *                         a translated label of your own: it runs on admin_menu, where
	 *                         a textdomain can load, unlike init time.
	 * @return void
	 */
	public static function init_wpcom_simple( $options = array() ) {
		if ( self::$initialized ) {
			return;
		}
		self::$initialized = true;
		self::apply_options( $options );

		self::boot_shared_services();
		self::load_dashboard_surface();
	}

	/**
	 * Apply init-time configuration options.
	 *
	 * @param array $options Options passed to the init entry points.
	 * @return void
	 */
	private static function apply_options( $options ) {
		if ( ! empty( $options['menu_title'] ) ) {
			self::$menu_title          = $options['menu_title'];
			self::$resolved_menu_title = null;
		}
	}

	/**
	 * Boot the services every platform needs, whether or not the site serves the
	 * dashboard support routes itself.
	 *
	 * @return void
	 */
	private static function boot_shared_services() {
		// On every request: flags are read and toggled outside the admin too.
		if ( ! function_exists( __NAMESPACE__ . '\\register_dashboard_feature_flags' ) ) {
			require_once __DIR__ . '/dashboard-policy.php';
		}
		register_dashboard_feature_flags();

		// Must be hooked before admin_menu and rest_api_init check the capability.
		Capabilities::register();

		// Emit WooCommerce store events into the Woo pipeline (ClickHouse + proxy).
		WooCommerce_Analytics_Tracker::configure();

		// CSV report export pipeline (WOOA7S-1581): hooks rest_api_init, so it must
		// register on all requests. Self-gates on WooCommerce + Jetpack connection.
		Export::configure();

		self::register_script_data();

		// The posts and pages list tables link their views column here.
		Post_List_Link::register();
	}

	/**
	 * URL of a dashboard route on this site.
	 *
	 * The SPA path travels in `p`, encoded here since add_query_arg() leaves values alone and a
	 * raw `?` inside it would read as an outer query param.
	 *
	 * @since 0.4.0
	 *
	 * @param string $path Route path, e.g. `/post/123`.
	 * @return string
	 */
	public static function dashboard_url( $path = '/' ) {
		return admin_url( 'admin.php?page=' . self::MENU_PAGE_SLUG . '&p=' . rawurlencode( $path ) );
	}

	/**
	 * Announce to Jetpack's other surfaces that this dashboard is the site's analytics UI,
	 * so they link here instead of the Stats page.
	 *
	 * @return void
	 */
	private static function register_script_data() {
		add_filter( 'jetpack_admin_js_script_data', array( static::class, 'add_script_data' ) );
	}

	/**
	 * Runs on nearly every admin page load, so the payload stays to two strings,
	 * a bool, and one capability check.
	 *
	 * @param array $data The script data.
	 * @return array The script data with the analytics key added.
	 */
	public static function add_script_data( $data ) {
		$data['analytics'] = array(
			'enabled'   => true,
			'page_slug' => self::MENU_PAGE_SLUG,
			'can_view'  => current_user_can( Capabilities::VIEW_ANALYTICS ),
			'timezone'  => self::site_timezone(),
		);

		return $data;
	}

	/**
	 * Prefers `timezone_string` over `gmt_offset`, matching the dashboard's own `siteTimeZone()`:
	 * analytics links point at past dates, so a fixed offset applied to the far side of a
	 * daylight-saving transition shifts the day.
	 *
	 * @return string An IANA timezone name, or a `+HH:MM` UTC offset.
	 */
	private static function site_timezone() {
		$timezone_string = get_option( 'timezone_string' );

		if ( is_string( $timezone_string ) && $timezone_string !== '' ) {
			return $timezone_string;
		}

		return self::format_gmt_offset( (float) get_option( 'gmt_offset' ) );
	}

	/**
	 * Format a GMT offset in hours as `+HH:MM`.
	 *
	 * @param float $offset The offset in hours, e.g. 5.5 or -8.
	 * @return string The formatted offset.
	 */
	private static function format_gmt_offset( $offset ) {
		$sign     = $offset < 0 ? '-' : '+';
		$absolute = abs( $offset );
		$hours    = (int) floor( $absolute );
		$minutes  = (int) round( ( $absolute - $hours ) * 60 );

		return sprintf( '%s%02d:%02d', $sign, $hours, $minutes );
	}

	/**
	 * Register the sync services that feed the local data pipeline.
	 *
	 * @return void
	 */
	private static function register_sync_bootstrap() {
		// Keep the shared connection available when another connection-owning plugin is deactivated.
		Connection_Configuration::configure();

		Sync_Status_Tracker::configure();

		// TEMPORARY (WOOA7S-1550): register the interim woocommerce_analytics sync module so
		// Sync_Status_Tracker has a full sync to observe. Remove when the shared sync-modules package lands.
		Sync_Configuration::register();
	}

	/**
	 * Register the site-served REST API: the WPCOM data proxy and notices.
	 *
	 * Both self-gate on their own rest_api_init hooks.
	 *
	 * @return void
	 */
	private static function register_local_api() {
		Api_Proxy_Controller::register();
		Notices_Controller::register();
	}

	/**
	 * Load the dashboard components every platform renders with.
	 *
	 * Admin-only, via load_dashboard_surface(); boot_routes() requires these
	 * again for REST.
	 *
	 * @return void
	 */
	private static function load_dashboard_components() {
		/*
		 * Every include below is guarded on a symbol the target file declares.
		 *
		 * Two copies of this package can be loaded in one request — WPCOM Simple ships
		 * one under jetpack-plugin and another under jetpack-mu-wpcom-plugin. The
		 * autoloader dedupes classes by version, but these files declare functions and
		 * constants at file scope, so they are absent from the classmap entirely and
		 * reach us through `require_once`, which dedupes by path and not by symbol.
		 * Once a class from one copy and a class from the other both run their
		 * includes, PHP fatals on the redeclared functions. The guards make the second
		 * copy's include a no-op, which also keeps the files' file-scope side effects
		 * (add_filter() calls, registry bootstrapping) from running twice.
		 */

		// Widget modules for the client's dynamic import() map.
		if ( ! function_exists( __NAMESPACE__ . '\\register_widget_modules_rest_route' ) ) {
			require_once __DIR__ . '/widget-modules.php';
		}

		// Default layout primitives and the bundled defaults' seed.
		if ( ! function_exists( __NAMESPACE__ . '\\get_dashboard_default_widget_instance' ) ) {
			require_once __DIR__ . '/dashboard-layout.php';
		}

		// Dashboard section API, then the package's own sections registered through it.
		if ( ! function_exists( __NAMESPACE__ . '\\register_dashboard_section' ) ) {
			require_once __DIR__ . '/dashboard-sections.php';
		}
		if ( ! function_exists( __NAMESPACE__ . '\\register_default_dashboard_sections' ) ) {
			require_once __DIR__ . '/default-dashboard-sections.php';
		}
		configure_dashboard_preview_scope();

		// Default-on CSV export settings and server-side disable filter.
		if ( ! function_exists( __NAMESPACE__ . '\\configure_csv_exports' ) ) {
			require_once __DIR__ . '/csv-exports.php';
		}
		configure_csv_exports();

		// VideoPress availability for the client's video routes. The widget layer
		// reads the same signal through widget-type-support.php.
		if ( ! function_exists( __NAMESPACE__ . '\\configure_videopress_availability' ) ) {
			require_once __DIR__ . '/videopress-availability.php';
		}
		configure_videopress_availability();

		// The composition flag's answer, read by the dashboard policy; the file is
		// already loaded by boot_shared_services().
		configure_dashboard_policy();
	}

	/**
	 * Serve the dashboard support routes from the site. Simple skips this —
	 * WPCOM calls Dashboard_Support_Routes::register() itself instead.
	 *
	 * @return void
	 */
	private static function register_dashboard_support_routes() {
		Dashboard_Support_Routes::register();
	}

	/**
	 * Load the wp-build output (interceptor, modules, routes, page render).
	 *
	 * Admin-only, via load_dashboard_surface(). REST does not need it:
	 * boot_routes() and ensure_widget_registry_ready() load what they use.
	 *
	 * @return void
	 */
	private static function load_build() {
		$build_entry = self::$build_entry ?? __DIR__ . '/../build/build.php';
		if ( file_exists( $build_entry ) ) {
			require_once $build_entry;
		}
	}

	/**
	 * Unhook wp-build's full-page render interceptor — security-relevant: it renders
	 * `?page=jetpack-premium-analytics` from admin_init with no capability check, and only
	 * renders_admin_chrome() gates the admin-post.php/admin-ajax.php paths that reach admin_init
	 * without Core's own slug check.
	 *
	 * Because remove_action() no-ops on a callback name it can't find, a wp-build rename would
	 * silently restore this entry point — hence the _doing_it_wrong() below when that happens.
	 *
	 * @return void
	 */
	private static function remove_full_page_interceptor() {
		if ( remove_action( 'admin_init', 'jpa_jetpack_premium_analytics_intercept_render' ) ) {
			return;
		}

		if ( function_exists( 'jpa_jetpack_premium_analytics_intercept_render' ) ) {
			_doing_it_wrong(
				__METHOD__,
				'The Premium Analytics full-page interceptor could not be unhooked: wp-build changed the generated callback name or its admin_init priority.',
				''
			);
		}
	}

	/**
	 * Absolute path to the generated widget manifest.
	 *
	 * On the class, not beside its readers in widget-modules.php: two copies of this package can
	 * load in one request, and only classes get the autoloader's version dedupe (see load_dashboard_components()).
	 *
	 * @return string
	 */
	public static function widget_manifest_path() {
		/**
		 * Filters the path to the generated widget manifest.
		 *
		 * @param string $path Absolute path to the generated widget manifest.
		 */
		return apply_filters(
			'jetpack_premium_analytics_widgets_manifest_path',
			__DIR__ . '/../build/widgets.php'
		);
	}

	/**
	 * Register the admin-only render path: polyfills, menu, and page hooks.
	 *
	 * @return void
	 */
	private static function register_admin_page() {
		// Polyfills force-replace core handles (wp-private-apis) on wp_default_scripts;
		// scope to the dashboard page so no other admin page (e.g. block editor) is hit.
		if ( self::is_dashboard_request() ) {
			WP_Build_Polyfills::register(
				'jetpack-premium-analytics',
				array_merge(
					WP_Build_Polyfills::SCRIPT_HANDLES,
					WP_Build_Polyfills::MODULE_IDS
				)
			);

			add_action( 'admin_enqueue_scripts', array( static::class, 'enqueue_i18n_loader' ) );
			add_action( 'admin_enqueue_scripts', array( static::class, 'enqueue_tracks_transport' ) );
			add_filter( 'jetpack_admin_js_script_data', array( static::class, 'add_tracks_identity_script_data' ), 20 );
		}

		add_action( 'admin_menu', array( static::class, 'register_admin_menu' ) );
	}

	/**
	 * The admin page slug the dashboard menu registers. Published in script data
	 * so no caller has to hard-code it.
	 */
	const MENU_PAGE_SLUG = 'jetpack-premium-analytics-wp-admin';

	/**
	 * Whether the current request is rendering the Premium Analytics dashboard.
	 *
	 * Scopes the wp-build polyfill registration (which force-replaces core script handles) to
	 * this dashboard; reads the menu slug directly, not current_screen, to stay safe at plugin-load time.
	 *
	 * @return bool True when serving the dashboard page in wp-admin.
	 */
	public static function is_dashboard_request() {
		if ( ! is_admin() ) {
			return false;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the menu page slug to scope asset loading; no state is changed.
		$page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';

		return self::MENU_PAGE_SLUG === $page;
	}

	/**
	 * Register the admin menu page.
	 *
	 * Uses wp-build's `-wp-admin` variant so Core applies the menu capability check. Reports the
	 * page and widget artifacts independently since the build loader includes each conditionally.
	 *
	 * Queued through Admin_Menu rather than registered here, so the entry is reachable by the
	 * `jetpack_admin_menu_visibility` filter.
	 *
	 * @return void
	 */
	public static function register_admin_menu() {
		$can_render          = function_exists( 'jpa_jetpack_premium_analytics_wp_admin_render_page' );
		$has_widget_manifest = file_exists( self::widget_manifest_path() );

		$missing = array();
		if ( ! $can_render ) {
			// Named by symbol, not by file: build/pages.php is only a loader, and the
			// callback can also go missing to a renamed page slug or an absent build entry.
			$missing[] = 'the jpa_jetpack_premium_analytics_wp_admin_render_page() callback, generated under build/pages/';
		}
		if ( ! $has_widget_manifest ) {
			$missing[] = 'build/widgets.php (the widget manifest)';
		}

		if ( $missing ) {
			// Surfaced here rather than only on the page itself, so a partial deploy shows up on
			// the first admin request instead of waiting for someone to open the dashboard.
			_doing_it_wrong(
				__METHOD__,
				// esc_html() only to satisfy WordPress.Security.EscapeOutput, which treats
				// this argument as output; every entry is a literal from just above.
				'The Premium Analytics build output is incomplete: ' . esc_html( implode( ', ', $missing ) ) . '. The package build did not run, or ran only partially, for this deploy.',
				''
			);
		}

		$render_callback = $can_render
			? 'jpa_jetpack_premium_analytics_wp_admin_render_page'
			: array( __CLASS__, 'render_missing_build_notice' );

		$menu_title = self::menu_title();

		$menu_title = esc_html( $menu_title );

		// An older admin-ui, loaded first by another plugin, may predate add_top_level_menu().
		if ( ! method_exists( Admin_Menu::class, 'add_top_level_menu' ) ) {
			add_menu_page( $menu_title, $menu_title, Capabilities::VIEW_ANALYTICS, self::MENU_PAGE_SLUG, $render_callback, 'dashicons-chart-bar', 2 );
			return;
		}

		// A fixed key rather than the slug, which carries a build-specific suffix. No gate:
		// the dashboard has no My Jetpack product class and no module to name.
		Admin_Menu::add_top_level_menu( $menu_title, $menu_title, Capabilities::VIEW_ANALYTICS, self::MENU_PAGE_SLUG, $render_callback, 'dashicons-chart-bar', 2, array( 'key' => 'jetpack-premium-analytics' ) );
	}

	/**
	 * Stand-in for the generated render callback when the build output is absent.
	 *
	 * The PHP classes come from Composer and the build output from pnpm, so a
	 * partial deploy can leave the class loadable with nothing to render.
	 *
	 * @return void
	 */
	public static function render_missing_build_notice() {
		printf(
			'<div class="wrap"><h1>%s</h1><p>%s</p></div>',
			esc_html( self::menu_title() ),
			esc_html__( 'The Premium Analytics assets are missing. The package build did not run for this deploy.', 'jetpack-premium-analytics-pkg' )
		);
	}

	/**
	 * The caller's menu label override, or the package's own translated label.
	 *
	 * Call only once translations can load — admin_menu or later — and memoize so every call site
	 * agrees. Deliberately not is_callable(): PHP function names are case-insensitive, so a plain
	 * label like "Analytics" could match a stray analytics() function and get called.
	 *
	 * @return string
	 */
	private static function menu_title() {
		if ( null !== self::$resolved_menu_title ) {
			return self::$resolved_menu_title;
		}

		$title = self::$menu_title instanceof \Closure
			? ( self::$menu_title )()
			: self::$menu_title;

		// A positive check rather than a null coalesce: a closure may return an empty string, or
		// something that isn't a string at all, and either would reach esc_html() as a broken label.
		self::$resolved_menu_title = is_string( $title ) && '' !== $title
			? $title
			: __( 'Stats v2', 'jetpack-premium-analytics-pkg' );

		return self::$resolved_menu_title;
	}

	/**
	 * Enqueue the i18n loader so the wp-build init module can download its JS
	 * translation catalogs. It's registered on every admin page by jetpack-assets
	 * but only enqueued when depended on; the esbuild bundles don't pull it in.
	 *
	 * @return void
	 */
	public static function enqueue_i18n_loader() {
		if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
			wp_enqueue_script( 'wp-jp-i18n-loader' );
		}
	}

	/**
	 * Load the Tracks transport for the dashboard.
	 *
	 * `@automattic/jetpack-analytics` only queues events into `window._tkq` — its own w.js
	 * loader is disabled — so without this handle no `jetpack_premium_analytics_*` event
	 * ever flushes. Simple is skipped because stats.php already prints the same script.
	 *
	 * @return void
	 */
	public static function enqueue_tracks_transport() {
		if ( ( new Host() )->is_wpcom_simple() ) {
			return;
		}

		wp_enqueue_script( 'jp-tracks', '//stats.wp.com/w.js', array(), gmdate( 'YW' ), true );
	}

	/**
	 * Publish the WPCOM identity the dashboard attributes its Tracks events to.
	 *
	 * Core's script data carries only the local user. Publicize is the one package that fills
	 * `current_user.wpcom` in, and the standalone plugin does not bundle it, so without this
	 * every event would land anonymous there.
	 *
	 * @param array $data The script data.
	 * @return array The script data with the WPCOM identity added.
	 */
	public static function add_tracks_identity_script_data( $data ) {
		if ( ( new Host() )->is_wpcom_simple() ) {
			$wpcom_user = array(
				'ID'    => get_current_user_id(),
				'login' => wp_get_current_user()->user_login,
			);
		} else {
			$connected = ( new Connection_Manager() )->get_connected_user_data();

			if ( empty( $connected['ID'] ) || empty( $connected['login'] ) ) {
				return $data;
			}

			// Only the two fields `identifyUser` needs: the rest of the connected-user payload
			// is profile data the dashboard never reads.
			$wpcom_user = array(
				'ID'    => $connected['ID'],
				'login' => $connected['login'],
			);
		}

		$data['user']['current_user']['wpcom'] = array_merge(
			$data['user']['current_user']['wpcom'] ?? array(),
			$wpcom_user
		);

		return $data;
	}
}

```
