PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-premium-analytics / src / class-analytics.php

class-analytics.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.7, at jetpack_vendor/automattic/jetpack-premium-analytics/src/class-analytics.php

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