Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
8.86% covered (danger)
8.86%
39 / 440
8.11% covered (danger)
8.11%
3 / 37
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Mu_Wpcom
8.88% covered (danger)
8.88%
39 / 439
8.11% covered (danger)
8.11%
3 / 37
16943.19
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
56
 schedule_translation_updates
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_update_translations
0.00% covered (danger)
0.00%
0 / 60
0.00% covered (danger)
0.00%
0 / 1
420
 clear_translation_destination
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 get_all_active_locales
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 load_features
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
12
 load_wpcom_user_features
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
90
 load_wpcom_sites_features
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 load_podcast
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load_etk_features_flags
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
20
 load_etk_features
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
90
 load_newspack_blocks
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
30
 load_coming_soon
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
90
 load_launchpad
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load_wpcom_rest_api_endpoints
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 load_jetpack_mu_wpcom_settings
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
2
 load_map_block_settings
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 load_newsletter_categories_settings
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 unbind_focusout_on_wp_admin_bar_menu_toggle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 should_disable_comment_experience
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
90
 load_verbum_comments
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 load_verbum_comments_admin
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 load_verbum_moderate
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 load_wpcom_simple_odyssey_stats
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 should_load_wpcom_simple_premium_analytics
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 load_wpcom_simple_premium_analytics
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 load_custom_css
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 load_plugin_state
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load_wpcom_random_redirect
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load_social_links
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 set_wpcom_blog_id_script_data
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 enable_gutenberg_classic_block_deprecation_experiment
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 enable_gutenberg_react_19_experiment
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 add_jetpack_script_data_for_p2
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 log2logstash
71.43% covered (warning)
71.43%
15 / 21
0.00% covered (danger)
0.00%
0 / 1
9.49
 resolve_logstash_blog_id
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
5.93
 queue_logstash_http
42.11% covered (danger)
42.11%
8 / 19
0.00% covered (danger)
0.00%
0 / 1
7.10
1<?php
2/**
3 * Enhances your site with features powered by WordPress.com
4 * This package is intended for internal use on WordPress.com sites only (simple and Atomic).
5 * Internal PT Reference: p9dueE-6jY-p2
6 *
7 * @package automattic/jetpack-mu-wpcom
8 */
9
10namespace Automattic\Jetpack;
11
12use Automattic\Jetpack\PremiumAnalytics\Analytics as Premium_Analytics;
13
14define( 'WPCOM_ADMIN_BAR_UNIFICATION', true );
15/**
16 * Jetpack_Mu_Wpcom main class.
17 */
18class Jetpack_Mu_Wpcom {
19    const PACKAGE_VERSION = '6.10.1';
20    const PKG_DIR         = __DIR__ . '/../';
21    const BASE_DIR        = __DIR__ . '/';
22    const BASE_FILE       = __FILE__;
23
24    /**
25     * Initialize the class.
26     */
27    public static function init() {
28        if ( did_action( 'jetpack_mu_wpcom_initialized' ) ) {
29            return;
30        }
31
32        // Shared code for src/features.
33        require_once self::PKG_DIR . 'src/common/index.php'; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath
34        require_once __DIR__ . '/common/fatal-error-signature.php';
35        require_once __DIR__ . '/utils.php';
36
37        // PCG confirmation probe wires its `pre_option_active_plugins`
38        // filter at mu-plugin time, before WP loads active plugins.
39        require_once __DIR__ . '/features/plugin-conflicts-guardian/probe-confirm-bootstrap.php';
40
41        // Load features that don't need any special loading considerations.
42        add_action( 'plugins_loaded', array( __CLASS__, 'load_features' ) );
43
44        // Load features that only apply to WordPress.com-connected users.
45        add_action( 'plugins_loaded', array( __CLASS__, 'load_wpcom_user_features' ) );
46        add_action( 'plugins_loaded', array( __CLASS__, 'load_etk_features' ) );
47
48        // Load features that only apply to WordPress.com sites, regardless of whether the users are connected.
49        add_action( 'plugins_loaded', array( __CLASS__, 'load_wpcom_sites_features' ) );
50
51        // Load ETK features flag to turn off the features in the ETK plugin.
52        // It needs higher priority than the ETK plugin.
53        add_action( 'plugins_loaded', array( __CLASS__, 'load_etk_features_flags' ), 0 );
54
55        /*
56         * Please double-check whether you really need to load your feature separately.
57         * Chances are you can just add it to the `load_features` method.
58         */
59        add_action( 'plugins_loaded', array( __CLASS__, 'load_launchpad' ), 0 );
60        add_action( 'plugins_loaded', array( __CLASS__, 'load_coming_soon' ) );
61        add_action( 'plugins_loaded', array( __CLASS__, 'load_wpcom_rest_api_endpoints' ) );
62        add_action( 'plugins_loaded', array( __CLASS__, 'load_newspack_blocks' ) );
63
64        // These features run only on simple sites.
65        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
66            add_action( 'plugins_loaded', array( __CLASS__, 'load_verbum_comments' ) );
67            add_action( 'plugins_loaded', array( __CLASS__, 'load_verbum_moderate' ) );
68            add_action( 'wp_loaded', array( __CLASS__, 'load_verbum_comments_admin' ) );
69            add_action( 'plugins_loaded', array( __CLASS__, 'load_wpcom_simple_premium_analytics' ) );
70            add_action( 'admin_menu', array( __CLASS__, 'load_wpcom_simple_odyssey_stats' ) );
71            add_action( 'plugins_loaded', array( __CLASS__, 'load_wpcom_random_redirect' ) );
72            add_action( 'plugins_loaded', array( __CLASS__, 'load_podcast' ) );
73        }
74
75        // These features run only on atomic sites.
76        if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) {
77            add_action( 'plugins_loaded', array( __CLASS__, 'load_custom_css' ) );
78            add_action( 'init', array( __CLASS__, 'schedule_translation_updates' ) );
79            add_action( 'plugins_loaded', array( __CLASS__, 'load_plugin_state' ) );
80        }
81
82        // Unified navigation fix for changes in WordPress 6.2.
83        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'unbind_focusout_on_wp_admin_bar_menu_toggle' ) );
84
85        // Load the Map block settings.
86        add_action( 'enqueue_block_assets', array( __CLASS__, 'load_jetpack_mu_wpcom_settings' ), 999 );
87
88        // Load the Map block settings.
89        add_action( 'enqueue_block_assets', array( __CLASS__, 'load_map_block_settings' ), 999 );
90
91        // Load the Newsletter category settings.
92        add_action( 'enqueue_block_assets', array( __CLASS__, 'load_newsletter_categories_settings' ), 999 );
93
94        // Load the Social Links feature.
95        add_action( 'init', array( __CLASS__, 'load_social_links' ), 30 );
96
97        // Filter to ensure JetpackScriptData.site.host and is_wpcom_platform is set, to ensure Jetpack blocks work as expected via P2.
98        add_filter( 'jetpack_public_js_script_data', array( __CLASS__, 'add_jetpack_script_data_for_p2' ), 10, 1 );
99
100        // Filter to populate JetpackScriptData.site.wpcom.blog_id with the actual WP.com blog ID.
101        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'set_wpcom_blog_id_script_data' ), 10, 1 );
102
103        // Allow sites with the `classic-block-inserter-support` blog sticker to insert the Classic block.
104        if ( wpcom_has_blog_sticker( 'classic-block-inserter-support', get_wpcom_blog_id() ) ) {
105            add_filter( 'wp_classic_block_supports_inserter', '__return_true' );
106        }
107
108        // Enable the `gutenberg-classic-block-deprecation` Gutenberg experiment for all sites, with an opt-out via the `disable-classic-block-deprecation` blog sticker.
109        // Both filters are needed: `default_option_` fires when the option doesn't exist in the DB, `option_` fires when it does.
110        add_filter( 'option_gutenberg-experiments', array( __CLASS__, 'enable_gutenberg_classic_block_deprecation_experiment' ) );
111        add_filter( 'default_option_gutenberg-experiments', array( __CLASS__, 'enable_gutenberg_classic_block_deprecation_experiment' ) );
112
113        // Enable the `gutenberg-react-19` Gutenberg experiment for sites with the `gutenberg-react-19` blog sticker.
114        add_filter( 'option_gutenberg-experiments', array( __CLASS__, 'enable_gutenberg_react_19_experiment' ) );
115        add_filter( 'default_option_gutenberg-experiments', array( __CLASS__, 'enable_gutenberg_react_19_experiment' ) );
116
117        /**
118         * Runs right after the Jetpack_Mu_Wpcom package is initialized.
119         *
120         * @since 0.1.2
121         */
122        do_action( 'jetpack_mu_wpcom_initialized' );
123    }
124
125    /**
126     * Schedules translation updates for Jetpack MU WPCOM.
127     *
128     * This function sets up the necessary cron jobs to ensure that translation files
129     * are regularly updated.
130     *
131     * @return void
132     */
133    public static function schedule_translation_updates() {
134        add_action( 'wpcomsh_translation_update', array( __CLASS__, 'maybe_update_translations' ) );
135
136        if ( ! wp_next_scheduled( 'wpcomsh_translation_update' ) ) {
137            wp_schedule_event( time(), 'twicedaily', 'wpcomsh_translation_update' );
138        }
139    }
140
141    /**
142     * Fetches and installs Jetpack-mu-wpcom package translations when needed.
143     */
144    public static function maybe_update_translations() {
145        global $wp_filesystem;
146        if ( ! $wp_filesystem ) {
147            require_once ABSPATH . 'wp-admin/includes/file.php';
148            WP_Filesystem();
149        }
150
151        $locales = self::get_all_active_locales();
152        if ( empty( $locales ) ) {
153            return;
154        }
155
156        $plugins_request_data              = array();
157        $plugin_language_pack_destinations = array(
158            'jetpack-mu-wpcom' => WP_LANG_DIR . '/mu-plugins/',
159            'wpcomsh'          => WP_LANG_DIR . '/mu-plugins/',
160        );
161
162        foreach ( array_keys( $plugin_language_pack_destinations ) as $plugin_slug ) {
163            $plugins_request_data[ $plugin_slug ] = array( 'version' => 'latest' );
164        }
165
166        $response = wp_remote_post(
167            'https://translate.wordpress.com/api/translations-updates/wpcom/plugins',
168            array(
169                'body'    => wp_json_encode(
170                    array(
171                        'locales' => $locales,
172                        'plugins' => $plugins_request_data,
173                    ),
174                    JSON_UNESCAPED_SLASHES
175                ),
176                'headers' => array( 'Content-Type' => 'application/json' ),
177                'timeout' => 10,
178            )
179        );
180
181        if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) {
182            return;
183        }
184
185        $data = json_decode( wp_remote_retrieve_body( $response ), true );
186
187        // API error, api returned but something was wrong.
188        if ( array_key_exists( 'success', $data ) && false === $data['success'] ) {
189            return;
190        }
191
192        if ( ! is_array( $data ) || ! is_array( $data['data'] ) ) {
193            return;
194        }
195
196        foreach ( $data['data'] as $plugin_name => $language_packs ) {
197            if ( ! isset( $plugin_language_pack_destinations[ $plugin_name ] ) ) {
198                continue;
199            }
200
201            $destination = $plugin_language_pack_destinations[ $plugin_name ];
202
203            foreach ( $language_packs as $translation ) {
204                $locale        = $translation['wp_locale'] ?? '';
205                $package_url   = $translation['package'] ?? '';
206                $last_modified = $translation['last_modified'] ?? '';
207
208                if ( ! $locale || ! $package_url || ! $last_modified ) {
209                    continue;
210                }
211
212                $local_po_file = "{$destination}/$plugin_name-{$locale}.po";
213                if ( file_exists( $local_po_file ) ) {
214                    $local_po_data                       = wp_get_pomo_file_data( $local_po_file );
215                    $installed_translation_revision_time = new \DateTime( $local_po_data['PO-Revision-Date'] );
216                    $new_translation_revision_time       = new \DateTime( $last_modified );
217
218                    // Skip if translation language pack is not newer than what is installed already.
219                    if ( $new_translation_revision_time <= $installed_translation_revision_time ) {
220                        continue;
221                    }
222                }
223
224                $translation_zip_file = download_url( $package_url );
225                if ( is_wp_error( $translation_zip_file ) ) {
226                    continue;
227                }
228
229                static::clear_translation_destination( $destination, $plugin_name, $locale );
230
231                $unzip_result = unzip_file( $translation_zip_file, $destination );
232                if ( is_wp_error( $unzip_result ) ) {
233                    wp_delete_file( $translation_zip_file );
234                    continue;
235                }
236
237                wp_delete_file( $translation_zip_file );
238
239            }
240        }
241    }
242
243    /**
244     * Clears the translation destination by deleting existing translation files.
245     *
246     * @param string $local_destination The local destination path.
247     * @param string $plugin_slug The plugin slug.
248     * @param string $locale The locale.
249     */
250    public static function clear_translation_destination( $local_destination, $plugin_slug, $locale ) {
251        global $wp_filesystem;
252
253        if ( ! $wp_filesystem ) {
254            require_once ABSPATH . 'wp-admin/includes/file.php';
255            WP_Filesystem();
256        }
257
258        $files = array(
259            "{$local_destination}{$plugin_slug}-{$locale}.po",
260            "{$local_destination}{$plugin_slug}-{$locale}.mo",
261            "{$local_destination}{$plugin_slug}-{$locale}.l10n.php",
262        );
263
264        $json_files = glob( "{$local_destination}{$plugin_slug}-{$locale}-*.json" );
265        if ( $json_files ) {
266            $files = array_merge( $files, $json_files );
267        }
268
269        foreach ( $files as $file ) {
270            if ( $wp_filesystem->exists( $file ) ) {
271                $wp_filesystem->delete( $file );
272            }
273        }
274    }
275
276    /**
277     * Retrieves all active locales for the site.
278     */
279    public static function get_all_active_locales() {
280        $locales = array( get_locale() );
281
282        $available_languages = get_available_languages();
283        if ( ! empty( $available_languages ) ) {
284            $locales = array_merge( $locales, $available_languages );
285        }
286        return array_values( array_unique( $locales ) );
287    }
288
289    /**
290     * Load features that don't need any special loading considerations.
291     */
292    public static function load_features() {
293        \Automattic\Jetpack\ExPlat::init();
294
295        // Please keep the features in alphabetical order.
296        require_once __DIR__ . '/features/100-year-plan/enhanced-ownership.php';
297        require_once __DIR__ . '/features/100-year-plan/locked-mode.php';
298        require_once __DIR__ . '/features/admin-color-schemes/admin-color-schemes.php';
299        require_once __DIR__ . '/features/ai-launchpad/ai-launchpad.php';
300        require_once __DIR__ . '/features/block-patterns/block-patterns.php';
301        require_once __DIR__ . '/features/blog-privacy/blog-privacy.php';
302        require_once __DIR__ . '/features/cloudflare-analytics/cloudflare-analytics.php';
303        require_once __DIR__ . '/features/code-editor/class-code-editor.php';
304        require_once __DIR__ . '/features/wpcom-blocks/code/class-code-block.php';
305        require_once __DIR__ . '/features/css-monkey-patches/index.php';
306        require_once __DIR__ . '/features/error-reporting/error-reporting.php';
307        require_once __DIR__ . '/features/first-posts-stream/first-posts-stream-helpers.php';
308        require_once __DIR__ . '/features/font-smoothing-antialiased/font-smoothing-antialiased.php';
309        require_once __DIR__ . '/features/google-analytics/google-analytics.php';
310        require_once __DIR__ . '/features/holiday-snow/class-holiday-snow.php';
311        require_once __DIR__ . '/features/launch-button/index.php';
312        require_once __DIR__ . '/features/layout-grid-usage-tracking/layout-grid-usage-tracking.php';
313        require_once __DIR__ . '/features/logo-tool/logo-tool.php';
314        require_once __DIR__ . '/features/marketplace-products-updater/class-marketplace-products-updater.php';
315        require_once __DIR__ . '/features/media/heif-support.php';
316        require_once __DIR__ . '/features/plugin-conflicts-guardian/plugin-conflicts-guardian.php';
317        require_once __DIR__ . '/features/post-categories/quick-actions.php';
318        require_once __DIR__ . '/features/post-like-from-email/post-like-from-email.php';
319        require_once __DIR__ . '/features/site-editor-dashboard-link/site-editor-dashboard-link.php';
320        require_once __DIR__ . '/features/wpcom-attachment-pages/wpcom-attachment-pages.php';
321        require_once __DIR__ . '/features/wpcom-block-editor/class-jetpack-wpcom-block-editor.php';
322        require_once __DIR__ . '/features/wpcom-block-editor/functions.editor-type.php';
323        require_once __DIR__ . '/features/wpcom-dashboard/class-wpcom-dashboard.php';
324        require_once __DIR__ . '/features/wpcom-logout/wpcom-logout.php';
325        require_once __DIR__ . '/features/wpcom-themes/wpcom-theme-fixes.php';
326        require_once __DIR__ . '/features/wpcom-post-list/wpcom-post-types-tracking.php';
327        require_once __DIR__ . '/features/wpcom-widgets/wpcom-widgets.php';
328        require_once __DIR__ . '/features/wpcom-wpadmin-page-view/wpcom-wpadmin-page-view.php';
329
330        require_once __DIR__ . '/features/write/write.php';
331
332        /*
333         * Temporarily disable client-side media processing.
334         *
335         * Client-side media processing enables cross-origin isolation (COEP/COOP headers)
336         * which can break authenticated API requests. This should be removed once client-side
337         * media processing is compatible with Dotcom's infrastructure.
338         *
339         * @see gutenberg_set_up_cross_origin_isolation() in Gutenberg's lib/media/load.php
340         * @see https://a8c.slack.com/archives/CBTN58FTJ/p1771950744814189
341         */
342        add_filter( 'wp_client_side_media_processing_enabled', '__return_false' );
343
344        // Initializers, if needed.
345        $activity_log_event_class = 'Automattic\\Jetpack\\Sync\\Activity_Log_Event';
346        if ( class_exists( $activity_log_event_class ) ) {
347            $activity_log_event_class::init();
348        }
349
350        \Marketplace_Products_Updater::init();
351        \Automattic\Jetpack\Code_Editor::setup();
352        \Automattic\Jetpack\Code_Block::setup();
353        \Automattic\Jetpack\Classic_Theme_Helper\Main::init();
354        \Automattic\Jetpack\Classic_Theme_Helper\Featured_Content::setup();
355
356        \Automattic\Jetpack\Jetpack_Mu_Wpcom\AI_Launchpad::init();
357        \Automattic\Jetpack\Jetpack_Mu_Wpcom\Holiday_Snow::init();
358        \Automattic\Jetpack\Jetpack_Mu_Wpcom\Wpcom_Dashboard::init();
359
360        // Gets autoloaded from the Scheduled_Updates package.
361        if ( class_exists( 'Automattic\Jetpack\Scheduled_Updates' ) ) {
362            Scheduled_Updates::init();
363        }
364    }
365
366    /**
367     * Load features that only apply to WordPress.com-connected users.
368     */
369    public static function load_wpcom_user_features() {
370        // To avoid potential collisions with ETK.
371        if ( ! class_exists( 'A8C\FSE\Help_Center' ) ) {
372            require_once __DIR__ . '/features/help-center/class-help-center.php';
373        }
374
375        if ( ! is_wpcom_user() ) {
376            require_once __DIR__ . '/features/replace-site-visibility/hide-site-visibility.php';
377            return;
378        }
379        if ( ! class_exists( 'A8C\FSE\Survicate' ) ) {
380            require_once __DIR__ . '/features/survicate/class-survicate.php';
381        }
382        require_once __DIR__ . '/features/ai-assistant-banner/ai-assistant-banner.php';
383        require_once __DIR__ . '/features/html-block-restricted-tags/html-block-restricted-tags.php';
384        require_once __DIR__ . '/features/marketing/marketing.php';
385        require_once __DIR__ . '/features/pages/pages.php';
386        require_once __DIR__ . '/features/replace-site-visibility/replace-site-visibility.php';
387        require_once __DIR__ . '/features/stats/stats.php';
388        require_once __DIR__ . '/features/wpcom-admin-bar/wpcom-admin-bar.php';
389        require_once __DIR__ . '/features/wpcom-admin-interface/wpcom-admin-interface.php';
390        require_once __DIR__ . '/features/wpcom-admin-menu/wpcom-admin-menu.php';
391        require_once __DIR__ . '/features/wpcom-comments/wpcom-comments.php';
392        require_once __DIR__ . '/features/wpcom-dashboard-widgets/wpcom-dashboard-widgets.php';
393        require_once __DIR__ . '/features/wpcom-imports/wpcom-imports.php';
394        require_once __DIR__ . '/features/wpcom-locale/sync-locale-from-calypso-to-atomic.php';
395        require_once __DIR__ . '/features/wpcom-media/wpcom-media-url-upload.php';
396        require_once __DIR__ . '/features/wpcom-media/wpcom-export-media-files.php';
397        require_once __DIR__ . '/features/wpcom-options-general/options-general.php';
398        require_once __DIR__ . '/features/wpcom-plugins/wpcom-plugins.php';
399        require_once __DIR__ . '/features/wpcom-profile-settings/profile-settings-link-to-wpcom.php';
400        require_once __DIR__ . '/features/wpcom-profile-settings/profile-settings-notices.php';
401        require_once __DIR__ . '/features/wpcom-sidebar-notice/wpcom-sidebar-notice.php';
402        require_once __DIR__ . '/features/wpcom-smart-dictation/class-wpcom-smart-dictation.php';
403        require_once __DIR__ . '/features/wpcom-content-research/class-wpcom-content-research.php';
404        require_once __DIR__ . '/features/wpcom-themes/wpcom-theme-tracking.php';
405        require_once __DIR__ . '/features/wpcom-themes/wpcom-themes.php';
406        require_once __DIR__ . '/features/wpcom-user-edit/wpcom-user-edit.php';
407        require_once __DIR__ . '/features/wpcom-videopress/wpcom-videopress.php';
408
409        // Initialize Newsletter Settings so hooks like the Reading page notice
410        // are registered on Simple sites (where load-jetpack.php doesn't run).
411        // Guarded with class_exists since mu-wpcom no longer composer-requires
412        // the jetpack-newsletter package: the class is provided by the standalone
413        // Jetpack plugin on Atomic, or by the wpcom platform's bundled Jetpack
414        // source on Simple.
415        if ( class_exists( '\Automattic\Jetpack\Newsletter\Settings' ) ) {
416            // @phan-suppress-next-line PhanUndeclaredClassMethod -- class_exists guarded above; provided by sibling autoloader.
417            \Automattic\Jetpack\Newsletter\Settings::init();
418        }
419
420        // Register the Daily Writing Prompt dashboard widget, which now lives in
421        // the jetpack-newsletter package. Guarded with class_exists for the same
422        // reason as Settings above: mu-wpcom doesn't composer-require the package.
423        if ( class_exists( '\Automattic\Jetpack\Newsletter\Writing_Prompt_Widget' ) ) {
424            // @phan-suppress-next-line PhanUndeclaredClassMethod -- class_exists guarded above; provided by sibling autoloader.
425            \Automattic\Jetpack\Newsletter\Writing_Prompt_Widget::init();
426        }
427
428        // Only load the Masterbar features on WoA sites.
429        if ( class_exists( '\Automattic\Jetpack\Status\Host' ) && ( new \Automattic\Jetpack\Status\Host() )->is_woa_site() ) {
430            // This is temporary. After we cleanup Masterbar on WPCOM we should load Masterbar for Simple sites too.
431            \Automattic\Jetpack\Masterbar\Main::init();
432        }
433
434        if ( class_exists( 'Automattic\Jetpack\Agents_Manager\Agents_Manager' ) ) {
435            \Automattic\Jetpack\Agents_Manager\Agents_Manager::init();
436        }
437    }
438
439    /**
440     * Load features that only apply to WordPress.com sites, regardless of whether the users are connected.
441     */
442    public static function load_wpcom_sites_features() {
443        if ( is_fully_managed_agency_site() ) {
444            return;
445        }
446
447        require_once __DIR__ . '/features/gutenberg-rtc/gutenberg-rtc.php';
448        require_once __DIR__ . '/features/wpcom-contact-form-flags/wpcom-contact-form-flags.php';
449    }
450
451    /**
452     * Load the Podcast module on Simple sites.
453     *
454     * Atomic and self-hosted load Podcast through the Jetpack module system
455     * (Jetpack::late_initialization). Simple doesn't boot that Jetpack class, so
456     * initialize the module directly here.
457     */
458    public static function load_podcast() {
459        \Automattic\Jetpack\Podcast\Podcast::init();
460    }
461
462    /**
463     * Define the flags to turn off features in the ETK plugin.
464     * Can be removed once the feature no longer exists in the ETK plugin.
465     */
466    public static function load_etk_features_flags() {
467        // Don't load on agency sites.
468        if ( is_fully_managed_agency_site() ) {
469            return;
470        }
471
472        // Don't load if the user is not a wpcom user on WP Admin.
473        // The features is still required on the frontend page regardless of the user.
474        if ( is_admin() && ! is_wpcom_user() ) {
475            return;
476        }
477
478        define( 'MU_WPCOM_COBLOCKS_GALLERY', true );
479        define( 'MU_WPCOM_CUSTOM_LINE_HEIGHT', true );
480        define( 'MU_WPCOM_BLOCK_INSERTER_MODIFICATIONS', true );
481        define( 'MU_WPCOM_HOMEPAGE_TITLE_HIDDEN', true );
482        define( 'MU_WPCOM_JETPACK_GLOBAL_STYLES', true );
483        define( 'A8C_USE_FONT_SMOOTHING_ANTIALIASED', false );
484        define( 'MU_WPCOM_MAILERLITE_WIDGET', true );
485        define( 'MU_WPCOM_OVERRIDE_PREVIEW_BUTTON_URL', true );
486        define( 'MU_WPCOM_PARAGRAPH_BLOCK', true );
487        define( 'MU_WPCOM_STARTER_PAGE_TEMPLATES', true );
488        define( 'MU_WPCOM_TAGS_EDUCATION', true );
489        define( 'MU_WPCOM_BLOCK_DESCRIPTION_LINKS', true );
490        define( 'MU_WPCOM_BLOCK_EDITOR_NUX', true );
491        define( 'MU_WPCOM_POSTS_LIST_BLOCK', true );
492        define( 'MU_WPCOM_JETPACK_COUNTDOWN_BLOCK', true );
493        define( 'MU_WPCOM_JETPACK_TIMELINE_BLOCK', true );
494        define( 'MU_WPCOM_DOCUMENTATION_LINKS', true );
495        define( 'MU_WPCOM_GLOBAL_STYLES', true );
496        define( 'MU_WPCOM_FSE', true );
497        define( 'MU_WPCOM_TEMPLATE_INSERTER', true );
498        define( 'MU_WPCOM_WHATS_NEW', true );
499    }
500
501    /**
502     * Load ETK features.
503     * Can be moved back to load_features() once the feature no longer exists in the ETK plugin.
504     */
505    public static function load_etk_features() {
506        // Don't load on agency sites.
507        if ( is_fully_managed_agency_site() ) {
508            return;
509        }
510
511        // Don't load if the user is not a wpcom user on WP Admin.
512        // The features is still required on the frontend page regardless of the user.
513        if ( is_admin() && ! is_wpcom_user() ) {
514            return;
515        }
516
517        require_once __DIR__ . '/features/jetpack-global-styles/class-global-styles.php';
518        require_once __DIR__ . '/features/mailerlite/subscriber-popup.php';
519        require_once __DIR__ . '/features/wpcom-fse/wpcom-fse.php';
520
521        /**
522         * Load features for the editor and the frontend pages.
523         */
524        global $pagenow;
525        $allowed_pages = array( 'post.php', 'post-new.php', 'site-editor.php' );
526        if ( ( isset( $pagenow ) && in_array( $pagenow, $allowed_pages, true ) ) || ! is_admin() ) {
527            require_once __DIR__ . '/features/block-editor/custom-line-height.php';
528            require_once __DIR__ . '/features/block-inserter-modifications/block-inserter-modifications.php';
529            require_once __DIR__ . '/features/hide-homepage-title/hide-homepage-title.php';
530            require_once __DIR__ . '/features/override-preview-button-url/override-preview-button-url.php';
531            require_once __DIR__ . '/features/paragraph-block-placeholder/paragraph-block-placeholder.php';
532            require_once __DIR__ . '/features/tags-education/tags-education.php';
533            require_once __DIR__ . '/features/wpcom-block-description-links/wpcom-block-description-links.php';
534            require_once __DIR__ . '/features/wpcom-block-editor-nux/class-wpcom-block-editor-nux.php';
535            require_once __DIR__ . '/features/wpcom-blocks/a8c-posts-list/a8c-posts-list.php';
536            require_once __DIR__ . '/features/wpcom-blocks/event-countdown/event-countdown.php';
537            require_once __DIR__ . '/features/wpcom-blocks/timeline/timeline.php';
538            require_once __DIR__ . '/features/wpcom-documentation-links/wpcom-documentation-links.php';
539            require_once __DIR__ . '/features/wpcom-global-styles/index.php';
540            require_once __DIR__ . '/features/wpcom-legacy-fse/wpcom-legacy-fse.php';
541        } elseif ( isset( $pagenow ) && 'customize.php' === $pagenow ) {
542            // Load wpcom-global-styles on the customizer so access to additional css can be checked there.
543            require_once __DIR__ . '/features/wpcom-global-styles/index.php';
544        }
545    }
546
547    /**
548     * Load the newspack blocks feature for the editor and the frontend pages.
549     */
550    public static function load_newspack_blocks() {
551        /**
552         * Avoid potential collisions with newspack-blocks plugin.
553         */
554        if ( class_exists( '\Newspack_Blocks', false ) ) {
555            return;
556        }
557
558        global $pagenow;
559        $allowed_pages = array( 'post.php', 'post-new.php', 'site-editor.php' );
560        if ( ( isset( $pagenow ) && in_array( $pagenow, $allowed_pages, true ) ) || ! is_admin() ) {
561            define( 'MU_WPCOM_NEWSPACK_BLOCKS', true );
562            require_once __DIR__ . '/features/newspack-blocks/index.php';
563        }
564    }
565
566    /**
567     * Load the Coming Soon feature.
568     */
569    public static function load_coming_soon() {
570        /**
571         * On WoA sites, users may be using non-symlinked older versions of the FSE plugin.
572         * If they are, check the active version to avoid redeclaration errors.
573         */
574        if ( ! function_exists( 'is_plugin_active' ) ) {
575            require_once ABSPATH . 'wp-admin/includes/plugin.php';
576        }
577
578        /**
579         * Explicitly pass $markup = false in get_plugin_data to avoid indirectly calling wptexturize that could cause unintended side effects.
580         * See: https://developer.wordpress.org/reference/functions/get_plugin_data/
581         */
582        $fse_plugin                 = 'full-site-editing/full-site-editing-plugin.php';
583        $fse_plugin_path            = WP_PLUGIN_DIR . '/' . $fse_plugin;
584        $invalid_fse_version_active =
585            file_exists( $fse_plugin_path ) &&
586            is_file( $fse_plugin_path ) &&
587            is_plugin_active( $fse_plugin ) &&
588            version_compare( get_plugin_data( $fse_plugin_path, false )['Version'], '3.56084', '<' );
589
590        if ( $invalid_fse_version_active ) {
591            return;
592        }
593
594        if (
595            ( defined( 'WPCOM_PUBLIC_COMING_SOON' ) && WPCOM_PUBLIC_COMING_SOON ) ||
596            apply_filters( 'a8c_enable_public_coming_soon', false )
597        ) {
598            require_once __DIR__ . '/features/coming-soon/coming-soon.php';
599        }
600    }
601
602    /**
603     * Load the Launchpad feature.
604     */
605    public static function load_launchpad() {
606        require_once __DIR__ . '/features/launchpad/launchpad.php';
607    }
608
609    /**
610     * Load WP REST API plugins for wpcom.
611     */
612    public static function load_wpcom_rest_api_endpoints() {
613        if ( ! function_exists( 'wpcom_rest_api_v2_load_plugin' ) ) {
614            return;
615        }
616
617        // We don't use `wpcom_rest_api_v2_load_plugin_files` because it operates inconsisently.
618        $plugins = glob( __DIR__ . '/features/wpcom-endpoints/*.php' );
619
620        if ( ! is_array( $plugins ) ) {
621            return;
622        }
623
624        foreach ( array_filter( $plugins, 'is_file' ) as $plugin ) {
625            require_once $plugin;
626        }
627    }
628
629    /**
630     * Adds a global variable containing the config of the plugin to the window object.
631     */
632    public static function load_jetpack_mu_wpcom_settings() {
633        $handle = 'jetpack-mu-wpcom-settings';
634
635        // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.NotInFooter
636        wp_register_script(
637            $handle,
638            false,
639            array(),
640            true
641        );
642
643        $data = wp_json_encode(
644            array(
645                'assetsUrl' => plugins_url( 'build/', self::BASE_FILE ),
646            ),
647            JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
648        );
649
650        wp_add_inline_script(
651            $handle,
652            "var JETPACK_MU_WPCOM_SETTINGS = $data;",
653            'before'
654        );
655
656        wp_enqueue_script( $handle );
657    }
658
659    /**
660     * Adds a global variable containing the map provider in a map_block_settings object to the window object.
661     */
662    public static function load_map_block_settings() {
663        if (
664            ! function_exists( 'get_current_screen' )
665            || \get_current_screen() === null
666        ) {
667            return;
668        }
669
670        // Return early if we are not in the block editor.
671        if ( ! wp_should_load_block_editor_scripts_and_styles() ) {
672            return;
673        }
674
675        $map_provider = apply_filters( 'wpcom_map_block_map_provider', 'mapbox' );
676        wp_localize_script( 'jetpack-blocks-editor', 'Jetpack_Maps', array( 'provider' => $map_provider ) );
677    }
678
679    /**
680     * Adds a global variable containing where the newsletter categories should be shown.
681     */
682    public static function load_newsletter_categories_settings() {
683        if (
684            ! function_exists( 'get_current_screen' )
685            || \get_current_screen() === null
686        ) {
687            return;
688        }
689
690        // Return early if we are not in the block editor.
691        if ( ! wp_should_load_block_editor_scripts_and_styles() ) {
692            return;
693        }
694
695        $newsletter_categories_location = apply_filters( 'wpcom_newsletter_categories_location', 'block' );
696        wp_localize_script( 'jetpack-blocks-editor', 'Jetpack_Subscriptions', array( 'newsletter_categories_location' => $newsletter_categories_location ) );
697    }
698
699    /**
700     * Unbinds focusout event handler on #wp-admin-bar-menu-toggle introduced in WordPress 6.2.
701     *
702     * The focusout event handler is preventing the unified navigation from being closed on mobile.
703     */
704    public static function unbind_focusout_on_wp_admin_bar_menu_toggle() {
705        wp_add_inline_script( 'common', '(function($){ $(document).on("wp-responsive-activate", function(){ $(".is-nav-unification #wp-admin-bar-menu-toggle, .is-nav-unification #adminmenumain").off("focusout"); } ); }(jQuery) );' );
706    }
707
708    /**
709     * Determine whether to disable the comment experience.
710     *
711     * @param int $blog_id The blog ID.
712     * @return boolean
713     */
714    private static function should_disable_comment_experience( $blog_id ) {
715        $path_wp_for_teams = WP_CONTENT_DIR . '/lib/wpforteams/functions.php';
716
717        if ( file_exists( $path_wp_for_teams ) ) {
718            require_once $path_wp_for_teams;
719        }
720
721        // This covers both P2 and P2020 themes.
722        $is_p2     = str_contains( get_stylesheet(), 'pub/p2' ) || function_exists( '\WPForTeams\is_wpforteams_site' ) && is_wpforteams_site( $blog_id );
723        $is_forums = str_contains( get_stylesheet(), 'a8c/supportforums' ); // Not in /forums.
724
725        $verbum_option_enabled = get_blog_option( $blog_id, 'enable_verbum_commenting', true );
726
727        if ( empty( $verbum_option_enabled ) ) {
728            return true;
729        }
730
731        // Don't load any comment experience in the Reader, GlotPress, wp-admin, or P2.
732        return ( 1 === $blog_id || TRANSLATE_BLOG_ID === $blog_id || is_admin() || $is_p2 || $is_forums );
733    }
734
735    /**
736     * Load Verbum Comments.
737     */
738    public static function load_verbum_comments() {
739        if ( class_exists( 'Verbum_Comments' ) ) {
740            return;
741        } else {
742            $blog_id = get_current_blog_id();
743            // Jetpack loads Verbum though an iframe from jetpack.wordpress.com.
744            // So we need to check the GET request for the blogid.
745            // phpcs:ignore WordPress.Security.NonceVerification.Recommended
746            if ( isset( $_GET['blogid'] ) ) {
747                $blog_id = intval( $_GET['blogid'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
748            }
749            if ( self::should_disable_comment_experience( $blog_id ) ) {
750                return;
751            }
752            require_once __DIR__ . '/features/verbum-comments/class-verbum-comments.php';
753            new \Automattic\Jetpack\Verbum_Comments();
754        }
755    }
756
757    /**
758     * Load Verbum Comments Settings.
759     */
760    public static function load_verbum_comments_admin() {
761        require_once __DIR__ . '/features/verbum-comments/assets/class-verbum-admin.php';
762        new \Automattic\Jetpack\Verbum_Admin();
763    }
764
765    /**
766     * Load Verbum Moderate.
767     */
768    public static function load_verbum_moderate() {
769        require_once __DIR__ . '/features/verbum-comments/assets/class-verbum-moderate.php';
770        new \Automattic\Jetpack\Verbum_Moderate();
771    }
772
773    /**
774     * Load Odyssey Stats in Simple sites.
775     */
776    public static function load_wpcom_simple_odyssey_stats() {
777        require_once __DIR__ . '/features/wpcom-simple-odyssey-stats/wpcom-simple-odyssey-stats.php';
778    }
779
780    /**
781     * Whether Premium Analytics should be loaded on WordPress.com Simple.
782     *
783     * @return bool True when the Simple rollout gate is enabled.
784     */
785    public static function should_load_wpcom_simple_premium_analytics() {
786        $blog_id = (int) get_wpcom_blog_id();
787        $enabled = $blog_id > 0 && wpcom_has_blog_sticker( 'jetpack-premium-analytics', $blog_id );
788
789        /**
790         * Filters whether Premium Analytics loads on WordPress.com Simple.
791         *
792         * @since $$next-version$$
793         *
794         * @param bool $enabled Whether the Simple rollout gate is enabled.
795         * @param int  $blog_id WPCOM blog ID.
796         */
797        return (bool) apply_filters( 'jetpack_premium_analytics_wpcom_simple_enabled', $enabled, $blog_id );
798    }
799
800    /**
801     * Load Premium Analytics on WordPress.com Simple sites behind the rollout gate.
802     */
803    public static function load_wpcom_simple_premium_analytics() {
804        if ( ! self::should_load_wpcom_simple_premium_analytics() ) {
805            return;
806        }
807
808        Premium_Analytics::init_wpcom_simple(
809            array(
810                'menu_title' => 'Premium Analytics',
811            )
812        );
813    }
814
815    /**
816     * Load the Jetpack Custom CSS feature.
817     */
818    public static function load_custom_css() {
819        require_once __DIR__ . '/features/custom-css/custom-css/preprocessors.php';
820        require_once __DIR__ . '/features/custom-css/custom-css.php';
821    }
822
823    /**
824     * Load the Plugin State feature.
825     */
826    public static function load_plugin_state() {
827        require_once __DIR__ . '/features/plugin-state/plugin-state.php';
828    }
829
830    /**
831     * Load the Random Redirect feature.
832     */
833    public static function load_wpcom_random_redirect() {
834        require_once __DIR__ . '/features/random-redirect/random-redirect.php';
835    }
836
837    /**
838     * Load the Social Links feature.
839     */
840    public static function load_social_links() {
841        if ( class_exists( 'Automattic\Jetpack\Classic_Theme_Helper\Social_Links' ) ) {
842            new \Automattic\Jetpack\Classic_Theme_Helper\Social_Links();
843        }
844    }
845
846    /**
847     * Populate JetpackScriptData.site.wpcom.blog_id with the actual WP.com blog ID.
848     *
849     * @param array $data The script data.
850     * @return array
851     */
852    public static function set_wpcom_blog_id_script_data( $data ) {
853        $blog_id = get_wpcom_blog_id();
854        if ( $blog_id ) {
855            $data['site']['wpcom']['blog_id'] = $blog_id;
856        }
857        return $data;
858    }
859
860    /**
861     * Add `gutenberg-classic-block-deprecation` to the list of enabled Gutenberg experiments.
862     * Skip sites that have the `disable-classic-block-deprecation` sticker enabled.
863     *
864     * @param mixed $experiments The current value of the gutenberg-experiments option.
865     * @return mixed Original option value or the filtered experiments.
866     */
867    public static function enable_gutenberg_classic_block_deprecation_experiment( $experiments ) {
868        if ( wpcom_has_blog_sticker( 'disable-classic-block-deprecation', get_wpcom_blog_id() ) ) {
869            return $experiments;
870        }
871
872        if ( ! is_array( $experiments ) ) {
873            $experiments = array();
874        }
875
876        $experiments['gutenberg-classic-block-deprecation'] = true;
877        return $experiments;
878    }
879
880    /**
881     * Add `gutenberg-react-19` to the list of enabled Gutenberg experiments.
882     * Only sites with the `gutenberg-react-19` blog sticker are opted in.
883     *
884     * @param mixed $experiments The current value of the gutenberg-experiments option.
885     * @return mixed Original option value or the filtered experiments.
886     */
887    public static function enable_gutenberg_react_19_experiment( $experiments ) {
888        if ( ! wpcom_has_blog_sticker( 'gutenberg-react-19', get_wpcom_blog_id() ) ) {
889            return $experiments;
890        }
891
892        if ( ! is_array( $experiments ) ) {
893            $experiments = array();
894        }
895
896        $experiments['gutenberg-react-19'] = true;
897        return $experiments;
898    }
899
900    /**
901     * Add Jetpack script data with host information on P2
902     *
903     * @param array $data - The Jetpack script data.
904     * @return array - The modified Jetpack script data.
905     */
906    public static function add_jetpack_script_data_for_p2( $data ) {
907        if (
908        str_contains( get_stylesheet(), 'pub/p2' ) ||
909        ( function_exists( '\WPForTeams\is_wpforteams_site' ) && is_wpforteams_site( get_current_blog_id() ) )
910        ) {
911            $host = new \Automattic\Jetpack\Status\Host();
912            if ( ! isset( $data['site']['host'] ) ) {
913                $data['site']['host'] = $host->get_known_host_guess();
914            }
915            if ( ! isset( $data['site']['is_wpcom_platform'] ) ) {
916                $data['site']['is_wpcom_platform'] = $host->is_wpcom_platform();
917            }
918        }
919        return $data;
920    }
921
922    /**
923     * Emit an event to the wpcom logstash cluster.
924     *
925     * Uses the in-process `log2logstash()` on WP.com Simple, and falls back to
926     * the public-api `/rest/v1.1/logstash` endpoint (fire-and-forget) on
927     * Atomic, where `log2logstash()` isn't available.
928     *
929     * Best-effort: a logging failure must never escalate into a fatal for the caller.
930     *
931     * @param string $feature Logstash `feature` bucket; should start with the `atomic_` prefix (e.g. "atomic_plugin_conflicts_guardian").
932     * @param string $message Event message slug.
933     * @param array  $extra   Event-specific properties; JSON-encoded into the `extra` field.
934     * @return void
935     */
936    public static function log2logstash( $feature, $message, array $extra = array() ) {
937        // Resolve the dispatch path once per request — on upgrade flows this
938        // can be called several times in a row (one per plugin) and the path
939        // doesn't change mid-request.
940        static $dispatch = null;
941        if ( null === $dispatch ) {
942            try {
943                if ( ! function_exists( 'log2logstash' ) ) {
944                    $log2logstash_path = WP_CONTENT_DIR . '/lib/log2logstash/log2logstash.php';
945                    if ( is_readable( $log2logstash_path ) ) {
946                        require_once $log2logstash_path;
947                    }
948                }
949            } catch ( \Throwable $e ) { // require_once can still throw (parse error / top-level fatal in the included file); fall through to the HTTP dispatch.
950                unset( $e );
951            }
952            $dispatch = function_exists( 'log2logstash' ) ? 'native' : 'http';
953        }
954
955        try {
956            $payload = array(
957                'blog_id' => self::resolve_logstash_blog_id(),
958                'feature' => (string) $feature,
959                'message' => (string) $message,
960                'extra'   => wp_json_encode( $extra, JSON_UNESCAPED_SLASHES ),
961            );
962
963            if ( 'native' === $dispatch ) {
964                log2logstash( $payload );
965                return;
966            }
967
968            // Defer the HTTP POST to shutdown. Dispatching inline as a
969            // non-blocking request loses the event when the caller `exit`s
970            // or `wp_safe_redirect`s right after (e.g. the activation-guard
971            // block path), because the cURL handle is torn down before the
972            // TLS handshake completes. Draining at shutdown with a blocking
973            // POST guarantees delivery without adding latency to the
974            // user-visible response.
975            self::queue_logstash_http( $payload );
976        } catch ( \Throwable $e ) { // best-effort: a logging failure must never escalate into a fatal for the caller.
977            unset( $e );
978        }
979    }
980
981    /**
982     * Resolve the WP.com blog ID for a logstash record.
983     *
984     * `get_wpcom_blog_id()` falls back to `get_current_blog_id()` on Atomic
985     * when `jetpack_options['id']` isn't readable — that returns `1` on a
986     * single-site install, which is a valid-looking but wrong WP.com blog ID
987     * and makes log records impossible to attribute. Emit `0` instead when
988     * the real WP.com blog ID is unknown, so the gap is obvious in Kibana.
989     *
990     * @return int WP.com blog ID, or 0 when it can't be determined.
991     */
992    private static function resolve_logstash_blog_id() {
993        // WP.com Simple: the current blog ID *is* the WP.com blog ID.
994        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
995            return (int) get_current_blog_id();
996        }
997        // Atomic / connected Jetpack: the WP.com blog ID lives in the
998        // `jetpack_options` option. Read it directly (no `Jetpack_Options`
999        // dependency) and return 0 — never the local blog ID — when absent.
1000        $jetpack_options = get_option( 'jetpack_options' );
1001        if ( is_array( $jetpack_options ) && ! empty( $jetpack_options['id'] ) ) {
1002            return (int) $jetpack_options['id'];
1003        }
1004        return 0;
1005    }
1006
1007    /**
1008     * Append a logstash payload to the shutdown drain queue, registering
1009     * the drain hook on first enqueue. See `log2logstash()` for why
1010     * dispatch is deferred.
1011     *
1012     * @param array $payload Logstash record (`blog_id`, `feature`, `message`, `extra`).
1013     * @return void
1014     */
1015    private static function queue_logstash_http( array $payload ) {
1016        static $queue = null;
1017        if ( null === $queue ) {
1018            $queue = array();
1019            register_shutdown_function(
1020                static function () use ( &$queue ) {
1021                    foreach ( $queue as $entry ) {
1022                        try {
1023                            wp_remote_post(
1024                                'https://public-api.wordpress.com/rest/v1.1/logstash',
1025                                array(
1026                                    'body'    => array( 'params' => wp_json_encode( $entry, JSON_UNESCAPED_SLASHES ) ),
1027                                    'timeout' => 5,
1028                                )
1029                            );
1030                        } catch ( \Throwable $e ) { // best-effort: a logging failure must never escalate into a fatal at shutdown.
1031                            unset( $e );
1032                        }
1033                    }
1034                    $queue = array();
1035                }
1036            );
1037        }
1038        $queue[] = $payload;
1039    }
1040}