JFIF    $         d d  7    * Z qn )ZP 0   !1 A "#a$2Qr   D8 a Ri [f  \m I ykI w0 c u F c Rı?lO7 к _f˓ [C $殷WF< _W ԣ s Kc ëI zyQy /_LK ℂ ;C",p F A : /] =H  ~ , l s / 9  ć: [=/#f ; ) x { ٛE Q )~     = 𘙲r*2~  a _V = '  ku m F  D } KYYC )( { * g& f `툪 r y ` = ^ c J. I ]( * `wq  1dđ #̩ ͑ 0  ; H ] u 搂 @:~ ו K L  N s h } O I R* 8 :2 ! lD JV o (  3 = M ( z Ȱ+i *N A  r6K n S l ) ! J J Ӂ* %  ݉?|D } d5: eP0 R; {$X' x F @ .ÊB {,WJ uQ ɲRI ;9 Q E琯 6 2fT .DU J ;*c  P A\IL Nj! J ۱+ O\ ͔ ] ޒ S߼ J ȧ c%A N ol  ՎprU L Zԛe r E2= X D Xg VQeӓ k y P7 U* o m Q I s ,K `) 6\G3t?pgjr m ۛجw luGt f  h 9uyP 0D;Uڽ"OXl i f $ ) &|ML 0 Z rm1 [ H XP l P R0  'G=i 2N +0 e 2 ]] 9 VT P  O׮ 7 h(F * 癈 ' = Q VZD F,d ߬ ~ T X G[ `le 69C R(!S2! P  < 0 x <     !1AQ "Raq 02Br #S CTb  ? Ζ "]mH5WR 7 k.ۛ ! } Q~ + yԏ z |@ T20 S~ K e k *zF f^2X *( @ 8 r ? C I uI|֓>^Ex LgNU Y+{.R Ѫ τV׸ YT D I 6 2 '8Y2  7'\T P.6 d &˦ @V q i  |8 -O Ε  ] ʔ U=   T L 8= ;6c |  !qfF3 a ů& ~$ l} 'NW U s$U k^S V : U# 6w +  +s &r+n ڐ   { @ 29 gL u " T Ù  M=6  (^ "7 r}=6Yݾ lC u hquymp Ǧ G jhs ǜNl ɻ} o 7 #S6 a w 4 !  OS  r D  5 7 % | ?x> L |  / nD 6?/8 w # [ ) L 7 +6〼T A T g   !  %5  Mm Z /c  -{1_Je "| ^$ 'O  & ޱմ < H vNqϡQ$ < 6 &Nݛ ֨tǻN (9 n ru~'όN sMG& ,uH Kw *Ī 5@  u6  BԒ  i YN * f 0 _ GhKW u  lT <nj- 0{  M L~ | /a v\ E = ?1 \ɟuq Ђ Y O a K ޡP q  $ H8o Q˼EV ~ k 0 h' L  Pp M -M"  m^ !6l nմ 쮝C U T . ! :vMǨb h ډ Vc r @񄞅~p   #   !1QAa q  ? AL0L  x LU 4 d ; x   " 16ad0 7.\ߋ 0  = P l v/ " c A Ay A|2pO8"d MV #1] = \ " ˡ ] k v I9 d ۓ & q ) ǐq E ꞅ v "P )q X  &Q  З non]я u ג./ ; gZ$ M QР 2M ]h ni 0  P $ێ 3CɏGjώy : m c # S_ l@ '_ \m|C 2`K _  1 cIvGc $? PCQ S+D ? K aײ k Fr b 8 ύ NEدg ĝ U 3  6 #Ik+ v  {ƒ v i ( ވ  ? a e)  X xy/*@Q Ǽ Ծ ) $ IWO l c G;  !i  k{ gOŀ L ('x  T rb $ w) R $ & N1E td U3Uȉ1p M"N* (DNy d96.(jQ)  X 5 c QɎMy W ?  Q *!R > 6=7)Xj 5`J ] e  8%t ! + ' ! 1Q 5   !1 AQaq ё #2 "0BRb ? G t^ ## .l lQ T $  v , ,m   㵜5u bV  =sY+ @d { N ! dn O < .- B;  _w J t 6;Q  Jd. Q c%p{ 1 ,sND dF H I0 Гo X ш  e黅X ۢ F :)[ F G X ƹ / w _c MeD   ,ʡc c .  W D  tA $ j@ : ) - # u c1< @ۗ9F ) KJ- hpP] _ x [ q B lb p ʖ w q " LFGd ƶ * s +ډ_Zc " ? % t[I P  6 J ]# =ɺ V v v CGs Gh1 > )6 |e y ? L ӣ m,4 G W Ui ` ] uJVo VDG< SB 6ϏQ @ TiU l y O U 0 kfV ~ ~}S Z @ *WU Ui ## ; s /[ = ! 7}" W N ] ' (L !  ~ y5 g9T̅J kbM' +s :S +B) v @ M  j e C f j E 0  Y\Qn z G 1 д~ W o{T 9?`  Rm yhsy3 !HAD ] m  c1 ~ 2 L  S u7x T ;j$ `} 4-> L#  vzŏ I L S  ֭  T { r  jGK C ; b  pU= - ` Bs K. S F w4  M q] Z dH S 0 ) tLg Tiny File Manager | class-debug.php
  • File: class-debug.php
  • Full Path: /home/mata8712/public_html/on-me-trouve/wp-content/plugins/burst-statistics/includes/Admin/Debug/class-debug.php
  • File size: 26.82 KB
  • MIME-type: text/x-php
  • Charset: utf-8
<?php
namespace Burst\Admin\Debug;

use Burst\Admin\Admin_Options;
use Burst\Admin\Capability\Capability;
use Burst\Admin\DB_Upgrade\DB_Upgrade;
use Burst\Admin\DomainTypes\Admin_Option_Category;
use Burst\Admin\Search_Console\Diagnostic_Logs;
use Burst\Admin\Search_Console\Sync;
use Burst\Admin\Statistics\Query_Shapes\Session_Grain_Shape;
use Burst\Admin\Statistics\Visitor_Bitmaps;
use Burst\Frontend\Ip\Ip;
use Burst\Frontend\Tracking\Tracking_GeoIp;
use Burst\Traits\Admin_Helper;
use Burst\Traits\Database_Helper;
use Burst\Traits\Helper;

defined( 'ABSPATH' ) || die( 'you do not have access to this page!' );

class Debug {
	use Admin_Helper;
	use Database_Helper;
	use Helper;

	/**
	 * Options that never belong in a pasted Site Health export: the tokens
	 * grant access to the statistics, so they are dropped whole.
	 *
	 * @var string[]
	 */
	private const REDACTED_OPTIONS = [
		'burst_gsc_tokens',
		'burst_share_tokens',
	];


	/**
	 * Initialize the debug class.
	 */
	public function init(): void {
		add_filter( 'debug_information', [ $this, 'add_site_health_info' ] );
	}

	/**
	 * Add Burst debug info to Site Health → Info tab.
	 *
	 * @param array $info Existing debug info.
	 * @return array the updated info.
	 */
	public function add_site_health_info( array $info ): array {
		if ( ! $this->user_can_manage() ) {
			return $info;
		}

		global $wpdb;

		$ip     = Ip::get_ip_address();
		$tables = $wpdb->get_col(
			$wpdb->prepare(
				'SHOW TABLES LIKE %s',
				$wpdb->esc_like( $wpdb->prefix . 'burst_' ) . '%'
			)
		);

		$server_data = $this->sanitize_server_data( $_SERVER );

		$settings = get_option( 'burst_options_settings', [] );
		if ( ! is_array( $settings ) ) {
			$settings = [];
		}
		$settings = $this->format_array_as_string( $settings );
		unset( $settings['license'] );
		unset( $settings['slack_webhook_url'] );
		// Email addresses are personal data and stay out of a pasted Site Health export.
		unset( $settings['alert_email_recipients'] );

		// WordPress burst options. Get all options that start with 'burst_'.
		$wp_options = $wpdb->get_results( $wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( 'burst_' ) . '%' ), ARRAY_A );
		$wp_options = wp_list_pluck( $wp_options, 'option_value', 'option_name' );
		$wp_options = $this->redact_options( $wp_options );
		$wp_options = $this->format_array_as_string( $wp_options );
		unset( $wp_options['burst_options_settings'] );

		// Burst transients.
		$burst_transients = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s",
				$wpdb->esc_like( '_transient_burst_' ) . '%'
			)
		);
		$burst_transients = wp_list_pluck( $burst_transients, 'option_value', 'option_name' );
		// Holds the in-flight PKCE code_verifier and single-use nonce while a
		// Search Console connect is pending; exporting it mid-flow leaks both.
		unset( $burst_transients['_transient_burst_gsc_connect'] );
		$burst_transients = $this->format_array_as_string( $burst_transients );

		$debug_log_lines = $this->get_burst_debug_log_lines();
		if ( empty( $debug_log_lines ) ) {
			$debug_log_lines = [ __( 'No debug log entries found.', 'burst-statistics' ) ];
		}

		$constants = [
			// @phpstan-ignore-next-line
			'WP_DEBUG'                   => defined( 'WP_DEBUG' ) ? ( WP_DEBUG ? 'true' : 'false' ) : 'undefined',
			'BURST_DEBUG'                => defined( 'BURST_DEBUG' ) ? ( BURST_DEBUG ? 'true' : 'false' ) : 'undefined',
			'BURST_VERSION'              => defined( 'BURST_VERSION' ) ? BURST_VERSION : 'undefined',
			'BURST_PRO'                  => defined( 'BURST_PRO' ) ? BURST_PRO : 'undefined',
			'BURST_DO_NOT_UPDATE_GEO_IP' => defined( 'BURST_DO_NOT_UPDATE_GEO_IP' ) ? ( BURST_DO_NOT_UPDATE_GEO_IP ? 'true' : 'false' ) : 'undefined',
			'BURST_HEADLESS_DOMAIN'      => defined( 'BURST_HEADLESS_DOMAIN' ) ? BURST_HEADLESS_DOMAIN : 'undefined',
		];

		$fields = [
			'geo_ip'              => [
				'label' => __( 'Geo IP File', 'burst-statistics' ),
				'value' => $this->get_geo_ip_file_info(),
			],
			'detected_ip'         => [
				'label' => __( 'Detected Geo IP', 'burst-statistics' ),
				'value' => $ip,
			],
			'location_data'       => [
				'label' => __( 'Location Data', 'burst-statistics' ),
				'value' => $this->get_location_data_info(),
			],
			'burst_tables'        => [
				'label' => __( 'Burst Database Tables', 'burst-statistics' ),
				'value' => $this->get_table_row_counts( $tables ) ?: 'No burst_ tables found',
			],
			'burst_settings'      => [
				'label' => __( 'Burst Settings', 'burst-statistics' ),
				'value' => $settings,
			],
			'current_user'        => [
				'label' => __( 'Current user Burst access', 'burst-statistics' ),
				'value' => $this->get_current_user_access_info(),
			],
			'burst_wp_options'    => [
				'label' => __( 'Burst WordPress options', 'burst-statistics' ),
				'value' => "available in 'Copy site info to clipboard'",
				'debug' => $wp_options,
			],
			'burst_wp_transients' => [
				'label' => __( 'Burst WordPress transients', 'burst-statistics' ),
				'value' => "available in 'Copy site info to clipboard'",
				'debug' => $burst_transients,
			],
			'server_data'         => [
				'label' => '$_SERVER',
				'value' => $server_data,
			],
			'burst_debug_log'     => [
				'label' => __( 'Debug Log', 'burst-statistics' ),
				'value' => $debug_log_lines,
			],
			'used_constants'      => [
				'label' => __( 'Used Constants', 'burst-statistics' ),
				'value' => $constants,
			],
		];

		$fields = array_merge( $fields, $this->get_uid_pipeline_fields(), $this->get_visitor_bitmaps_fields() );
		$fields = array_merge( $fields, $this->get_search_console_fields() );

		$info['burst_debug'] = [
			'label'  => __( 'Burst Debug Information', 'burst-statistics' ),
			'fields' => apply_filters( 'burst_debug_fields', $fields ),
		];

		return $info;
	}

	/**
	 * The uid dictionary migration state as one Site Health field. The pipeline
	 * spans four task options plus watermarks and progress transients; support
	 * needs to read a stuck pipeline from a pasted Site Health export instead
	 * of querying options over wp-cli.
	 *
	 * @return array<string, array{label: string, value: array<string, string>}>
	 */
	public function get_uid_pipeline_fields(): array {
		$tasks = [ 'seed_uid_dictionary', 'statistics_uid_id', 'sessions_first_time', 'finalize_uid_id', 'seed_page_urls', 'statistics_page_id', 'sessions_has_pageview', 'pro_sessions_source_category' ];

		// Any other registered task that is still pending (e.g. a stuck
		// backfill_page_urls) disables db_upgrades_complete() just the same —
		// list it, or this field shows every task 'done' while the fast paths
		// stay off, exactly the diagnosis gap it exists to close. Non-blocking
		// cleanups (drop_legacy_columns) are listed too, for support.
		foreach ( ( new DB_Upgrade() )->get_all_upgrade_slugs() as $slug ) {
			if ( ! in_array( $slug, $tasks, true ) && get_option( "burst_db_upgrade_{$slug}" ) ) {
				$tasks[] = $slug;
			}
		}

		$state = [
			'db upgrades'      => $this->db_upgrades_complete() ? 'complete' : 'pending (version behind or tasks running)',
			'integer uid mode' => $this->uid_id_active() ? 'active' : 'inactive (legacy uid strings)',
			'session grain'    => Session_Grain_Shape::session_grain_ready() ? 'ready' : 'not ready (upgrades pending or columns missing)',
			'page grain'       => $this->page_dictionary_ready() ? 'ready' : 'not ready (upgrades pending or index missing)',
			// Page-grain grouping without its covering index is slower than
			// the old page_url grouping — surface the mismatch loudly. Same
			// probe the query switch itself uses (Database_Helper).
			'page grain index' => $this->page_grain_index_exists() ? 'present' : 'MISSING (time, page_id, uid_id) — run the table init',
		];

		$db_upgrade = new DB_Upgrade();
		$stalled    = $db_upgrade->stalled_task();
		if ( ! empty( $stalled['slug'] ) ) {
			$state['stalled task'] = sprintf(
				'%s: %s (%d attempts)',
				(string) $stalled['slug'],
				(string) ( $stalled['error'] ?? 'unknown error' ),
				(int) ( $stalled['attempts'] ?? 0 )
			);
		}

		foreach ( $tasks as $task ) {
			$status = 'done';
			if ( get_option( "burst_db_upgrade_{$task}" ) ) {
				$status    = 'pending';
				$watermark = (int) get_option( "burst_db_upgrade_{$task}_last_id" );
				if ( $watermark > 0 ) {
					$status .= " (watermark {$watermark})";
				}
				$progress = get_transient( "burst_progress_{$task}" );
				if ( false !== $progress ) {
					$status .= ', ' . round( (float) $progress * 100 ) . '%';
				}
				if ( ( $stalled['slug'] ?? '' ) === $task ) {
					$status .= ' [STALLED]';
				} else {
					$attempts = (int) get_option( "burst_db_upgrade_{$task}_attempts", 0 );
					if ( $attempts > 0 ) {
						$status .= " (attempt {$attempts})";
					}
				}
			}
			$state[ "task {$task}" ] = $status;
		}

		return [
			'burst_uid_pipeline' => [
				'label' => __( 'UID dictionary migration', 'burst-statistics' ),
				'value' => $state,
			],
		];
	}

	/**
	 * The visitor bitmap store state plus a live probe as one Site Health
	 * field. The probe runs get_visitors() for the last seven days and reports
	 * whether it serves or falls back and why — the bitmap path bypasses the
	 * query-stats logging, so this is the one place that answers "is the fast
	 * path actually on for this site".
	 *
	 * @return array<string, array{label: string, value: array<string, string>}>
	 */
	public function get_visitor_bitmaps_fields(): array {
		global $wpdb;

		if ( ! $this->table_exists( 'burst_visitor_bitmaps' ) ) {
			return [
				'burst_visitor_bitmaps' => [
					'label' => __( 'Visitor bitmaps', 'burst-statistics' ),
					'value' => [ 'store' => 'table missing' ],
				],
			];
		}

		$state = [
			'built until'    => (string) get_option( 'burst_visitor_bitmaps_built_until' ) ?: 'never',
			'scopes version' => (string) get_option( 'burst_visitor_bitmaps_scopes_version' ) ?: '-',
			'rebuild from'   => (string) get_option( 'burst_visitor_bitmaps_rebuild_from' ) ?: '-',
		];

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- fixed table name, debug context.
		$scope_counts = $wpdb->get_results( "SELECT scope, COUNT(*) AS days FROM {$wpdb->prefix}burst_visitor_bitmaps GROUP BY scope", ARRAY_A );
		foreach ( (array) $scope_counts as $row ) {
			$state[ 'rows: ' . $row['scope'] ] = (string) $row['days'];
		}

		$last_error = get_option( 'burst_visitor_bitmaps_last_error' );
		if ( is_array( $last_error ) && ! empty( $last_error['day'] ) ) {
			$error_text = $last_error['day'] . ' at ' . gmdate( 'Y-m-d H:i:s', (int) ( $last_error['time'] ?? 0 ) ) . ' UTC';
			if ( ! empty( $last_error['message'] ) ) {
				$error_text .= ' (' . $last_error['message'] . ')';
			}
			$state['last builder error'] = $error_text;
		}

		$day_progress = get_option( 'burst_visitor_bitmaps_day_progress' );
		if ( is_array( $day_progress ) && ! empty( $day_progress['day'] ) ) {
			$state['day progress'] = sprintf(
				'%s (%s, cursor %d)',
				$day_progress['day'],
				$day_progress['phase'] ?? 'unknown',
				(int) ( $day_progress['cursor'] ?? 0 )
			);
		}

		$last_serve = get_option( 'burst_visitor_bitmaps_last_serve' );
		if ( is_array( $last_serve ) && ! empty( $last_serve['path'] ) ) {
			$serve = $last_serve['path'];
			if ( '' !== (string) ( $last_serve['reason'] ?? '' ) ) {
				$serve .= ' (' . $last_serve['reason'] . ')';
			}
			$serve                   .= ', ' . (int) ( $last_serve['ms'] ?? 0 ) . 'ms, since ' . gmdate( 'Y-m-d H:i:s', (int) ( $last_serve['time'] ?? 0 ) ) . ' UTC';
			$state['last serve path'] = $serve;
		}

		// Live probe: last 7 days including today, the most common picker range.
		$timezone = wp_timezone();
		$start    = ( new \DateTimeImmutable( 'today -6 days', $timezone ) )->getTimestamp();
		$end      = ( new \DateTimeImmutable( 'today 23:59:59', $timezone ) )->getTimestamp();
		$bitmaps  = new Visitor_Bitmaps();
		$probe_t  = microtime( true );
		$visitors = $bitmaps->get_visitors( $start, $end );
		$probe_ms = (int) round( ( microtime( true ) - $probe_t ) * 1000 );

		$state['probe (last 7 days)'] = null === $visitors
			? 'fallback to SQL path (' . $bitmaps->get_last_miss_reason() . ')'
			: "bitmap: {$visitors} visitors in {$probe_ms}ms";

		return [
			'burst_visitor_bitmaps' => [
				'label' => __( 'Visitor bitmaps', 'burst-statistics' ),
				'value' => $state,
			],
		];
	}

	/**
	 * Roles and Burst capabilities of the user viewing Site Health, so support can
	 * tell a missing capability (e.g. a custom role) apart from a plugin problem.
	 *
	 * @return array<string, string> Roles plus one row per Burst capability.
	 */
	private function get_current_user_access_info(): array {
		$user = wp_get_current_user();
		$info = [
			'roles' => implode( ', ', array_map( 'sanitize_key', (array) $user->roles ) ) ?: 'none',
		];
		foreach ( Capability::get_capability_names() as $capability ) {
			$info[ $capability ] = user_can( $user, $capability ) ? 'true' : 'false';
		}
		return $info;
	}

	/**
	 * Report roughly how much data each Burst table holds, so Site Health shows at
	 * a glance whether a table is populated.
	 *
	 * Deliberately an estimate: a COUNT(*) on a multi-million row statistics table
	 * scans the whole index and would slow down opening Site Health. The optimizer
	 * estimate from information_schema is free, and a single-row probe covers the
	 * case where InnoDB reports 0 for a small table.
	 *
	 * @param array<int, string> $tables Table names as returned by SHOW TABLES.
	 * @return array<string, string> Table name => approximate row count.
	 */
	private function get_table_row_counts( array $tables ): array {
		global $wpdb;

		$estimates = $this->get_estimated_row_counts();

		$counts = [];
		foreach ( $tables as $table ) {
			// Table names can't be parameterized. Only names SHOW TABLES returned for
			// this site's Burst prefix get here; re-validate before interpolating.
			if ( ! preg_match( '/^[A-Za-z0-9_]+$/', $table ) ) {
				continue;
			}

			$estimate = $estimates[ $table ] ?? 0;
			if ( $estimate > 0 ) {
				$counts[ $table ] = $this->sprintf(
					// translators: %s is an approximate number of rows.
					__( '~%s rows (estimate)', 'burst-statistics' ),
					number_format_i18n( $estimate )
				);
				continue;
			}

			// InnoDB reports 0 for small or never-analyzed tables, so a single-row
			// probe is what actually separates "empty" from "a handful of rows".
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$has_rows         = $wpdb->get_var( "SELECT 1 FROM `{$table}` LIMIT 1" );
			$counts[ $table ] = null === $has_rows ? __( 'Empty', 'burst-statistics' ) : __( 'Contains data', 'burst-statistics' );
		}

		return $counts;
	}

	/**
	 * Get the optimizer's row estimate for every Burst table in one query.
	 *
	 * @return array<string, int> Table name => estimated row count.
	 */
	private function get_estimated_row_counts(): array {
		global $wpdb;

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				'SELECT TABLE_NAME AS table_name, TABLE_ROWS AS row_estimate
				FROM information_schema.TABLES
				WHERE TABLE_SCHEMA = %s AND TABLE_NAME LIKE %s',
				$wpdb->dbname,
				$wpdb->esc_like( $wpdb->prefix . 'burst_' ) . '%'
			),
			ARRAY_A
		);

		if ( ! is_array( $rows ) ) {
			return [];
		}

		$estimates = [];
		foreach ( $rows as $row ) {
			$estimates[ (string) $row['table_name'] ] = (int) $row['row_estimate'];
		}

		return $estimates;
	}

	/**
	 * Describe the state of the MaxMind database on disk.
	 *
	 * Country tracking ships with the free plugin, so this reports the actual
	 * database state for both free and Pro. Pro downloads the City database, free
	 * the Country database; the file name tells the two apart.
	 *
	 * @return array<string, string> Site Health field value.
	 */
	private function get_geo_ip_file_info(): array {
		if ( ! apply_filters( 'burst_geo_ip_enabled', true ) ) {
			return [ __( 'Status', 'burst-statistics' ) => __( 'Disabled with the burst_geo_ip_enabled filter', 'burst-statistics' ) ];
		}

		$file   = (string) get_option( 'burst_geo_ip_file' );
		$folder = (string) get_option( 'burst_maxmind_dir', '' );

		if ( '' === $file ) {
			$info = [ __( 'File', 'burst-statistics' ) => __( 'No Geo IP file set', 'burst-statistics' ) ];
		} else {
			$exists      = file_exists( $file );
			$masked_file = '' !== $folder
				? str_replace( [ '/' . $folder . '/', '\\' . $folder . '\\' ], [ '/****/', '\\****\\' ], $file )
				: (string) preg_replace( '#[\\\\/]burst[\\\\/][a-f0-9]{32}[\\\\/]#', '/burst/****/', $file );
			$info        = [
				__( 'File', 'burst-statistics' )        => $masked_file,
				__( 'File exists', 'burst-statistics' ) => $exists ? 'true' : 'false',
				__( 'File size', 'burst-statistics' )   => $exists ? size_format( (int) filesize( $file ) ) : '-',
			];
		}

		$last_update  = (int) get_option( 'burst_last_update_geo_ip' );
		$import_error = (string) get_option( 'burst_geo_ip_import_error' );
		if ( '' !== $import_error && '' !== $folder ) {
			$import_error = str_replace( [ '/' . $folder . '/', '\\' . $folder . '\\', $folder ], [ '/****/', '\\****\\', '****' ], $import_error );
		}

		$info[ __( 'Last update', 'burst-statistics' ) ]      = $last_update > 0 ? wp_date( DATE_ATOM, $last_update ) : __( 'Never', 'burst-statistics' );
		$info[ __( 'Import error', 'burst-statistics' ) ]     = '' !== $import_error ? $import_error : __( 'None', 'burst-statistics' );
		$info[ __( 'Import scheduled', 'burst-statistics' ) ] = (bool) get_option( 'burst_import_geo_ip_on_activation' ) ? 'true' : 'false';

		return $info;
	}

	/**
	 * Resolve the location data for the current visitor as the tracking path does.
	 *
	 * The reader is resolved through the burst_geoip_handler filter, so free
	 * reports the country lookup and Pro the city lookup, without duplicating the
	 * lookup logic here.
	 *
	 * @return array<string, string> Site Health field value.
	 */
	private function get_location_data_info(): array {
		if ( ! apply_filters( 'burst_geo_ip_enabled', true ) ) {
			return [ __( 'Status', 'burst-statistics' ) => __( 'Disabled with the burst_geo_ip_enabled filter', 'burst-statistics' ) ];
		}

		$handler = apply_filters( 'burst_geoip_handler', Tracking_GeoIp::class );

		return $this->format_array_as_string( $handler::get_location_data() );
	}

	/**
	 * Keep only explicitly approved diagnostic data before exposing $_SERVER in
	 * Site Health or its clipboard export. An allowlist prevents host-specific
	 * environment variables from disclosing credentials or infrastructure data.
	 *
	 * @param array $server_data Raw server data.
	 * @return array Safe server data.
	 */
	private function sanitize_server_data( array $server_data ): array {
		$allowed_keys = [
			'APP_ENGINE',
			'APP_ENGINE_VERSION',
			'ENVIRONMENT',
			'GATEWAY_INTERFACE',
			'GEOIP_CITY',
			'GEOIP_COUNTRY_CODE',
			'GEOIP_COUNTRY_NAME',
			'GEOIP_LATITUDE',
			'GEOIP_LONGITUDE',
			'GEOIP_REGION',
			'HTTPS',
			'REMOTE_ADDR',
			'REQUEST_SCHEME',
			'SERVER_PROTOCOL',
			'SERVER_SOFTWARE',
		];

		return array_intersect_key( $server_data, array_flip( $allowed_keys ) );
	}

	/**
	 * Add stored Search Console state and persistent activity to Site Health.
	 * This path performs no Google API calls and writes no diagnostic events.
	 *
	 * @return array Site Health fields.
	 */
	private function get_search_console_fields(): array {
		if ( ! $this->get_option_bool( 'enable_search_console' ) ) {
			return [
				'search_console' => [
					'label' => __( 'Google Search Console', 'burst-statistics' ),
					'value' => __( 'Disabled', 'burst-statistics' ),
				],
			];
		}

		$diagnostics = ( new Sync() )->diagnostics();
		$storage     = is_array( $diagnostics['storage'] ?? null ) ? $diagnostics['storage'] : [];
		$summary     = [
			__( 'Connection', 'burst-statistics' )         => (string) ( $diagnostics['status'] ?? '' ),
			__( 'Site', 'burst-statistics' )               => (string) ( $diagnostics['site_url'] ?? '' ),
			__( 'Site URL source', 'burst-statistics' )    => (string) ( $diagnostics['site_url_source'] ?? '' ),
			__( 'Property selection', 'burst-statistics' ) => (string) ( $diagnostics['property_status'] ?? '' ),
			__( 'Stored property', 'burst-statistics' )    => (string) ( $diagnostics['stored_property'] ?? '' ),
			__( 'Property scope', 'burst-statistics' )     => (string) ( $diagnostics['property_scope'] ?? '' ),
			__( 'Property retry', 'burst-statistics' )     => ! empty( $diagnostics['property_retry'] ) ? wp_date( DATE_ATOM, (int) $diagnostics['property_retry'] ) : __( 'Not scheduled', 'burst-statistics' ),
			__( 'Stored rows', 'burst-statistics' )        => isset( $storage['row_count'] ) ? (string) $storage['row_count'] : __( 'Not available', 'burst-statistics' ),
			__( 'Stored date range', 'burst-statistics' )  => ! empty( $storage['first_date'] ) ? $storage['first_date'] . ' – ' . $storage['last_date'] : __( 'No stored rows', 'burst-statistics' ),
			__( 'Sync state', 'burst-statistics' )         => $this->format_search_console_value( $diagnostics['sync_state'] ?? [] ),
			__( 'Next hourly sync', 'burst-statistics' )   => ! empty( $diagnostics['next_sync'] ) ? wp_date( DATE_ATOM, (int) $diagnostics['next_sync'] ) : __( 'Not scheduled', 'burst-statistics' ),
		];

		return [
			'search_console'          => [
				'label' => __( 'Google Search Console', 'burst-statistics' ),
				'value' => $summary,
			],
			'search_console_activity' => [
				'label' => __( 'Google Search Console Activity', 'burst-statistics' ),
				'value' => $this->format_search_console_activity( ( new Diagnostic_Logs() )->recent() ),
			],
		];
	}

	/**
	 * Format compact diagnostic metadata for Site Health's one-level field value
	 * format and its clipboard export.
	 *
	 * @param array $logs Stored diagnostic events.
	 * @return array<string, string> Timestamped event details.
	 */
	private function format_search_console_activity( array $logs ): array {
		if ( empty( $logs ) ) {
			return [ __( 'Activity', 'burst-statistics' ) => __( 'No Search Console activity has been recorded for this site yet.', 'burst-statistics' ) ];
		}

		$activity = [];
		foreach ( $logs as $log ) {
			$time    = isset( $log['time'] ) ? wp_date( DATE_ATOM, (int) $log['time'] ) : __( 'Unknown time', 'burst-statistics' );
			$event   = (string) ( $log['event'] ?? 'unknown' );
			$status  = (string) ( $log['status'] ?? 'info' );
			$message = (string) ( $log['message'] ?? '' );
			$context = $this->format_search_console_value( $log['context'] ?? [] );

			$activity[ $time . ' — ' . $event ] = sprintf(
				'%1$s: %2$s | %3$s',
				$status,
				$message,
				$context
			);
		}

		return $activity;
	}

	/**
	 * Format nested diagnostics as readable metadata rather than JSON payloads.
	 *
	 * @param mixed $value Diagnostic value.
	 */
	private function format_search_console_value( mixed $value ): string {
		if ( ! is_array( $value ) ) {
			return (string) $value;
		}

		$parts = [];
		$this->flatten_search_console_value( $value, $parts );
		return empty( $parts ) ? __( 'No details', 'burst-statistics' ) : implode( '; ', $parts );
	}

	/**
	 * Flatten diagnostic metadata for the native Site Health table.
	 *
	 * @param array<int|string, mixed> $value  Nested diagnostic value.
	 * @param array<int, string>       $parts  Formatted values.
	 * @param string                   $prefix Parent key.
	 */
	private function flatten_search_console_value( array $value, array &$parts, string $prefix = '' ): void {
		foreach ( $value as $key => $item ) {
			$name = '' === $prefix ? (string) $key : $prefix . '.' . $key;
			if ( is_array( $item ) ) {
				$this->flatten_search_console_value( $item, $parts, $name );
				continue;
			}
			$parts[] = $name . ': ' . ( is_bool( $item ) ? ( $item ? 'true' : 'false' ) : (string) $item );
		}
	}

	/**
	 * Secrets stored inside an option that also holds values worth exporting,
	 * as option name => keys to drop. The consolidated admin option rows
	 * (see Admin_Options) are the reason this exists: the row stays in the
	 * export, the secret key in it does not.
	 *
	 * @return array<string, string[]>
	 */
	private function redacted_option_keys(): array {
		return [
			Admin_Options::option_name( Admin_Option_Category::NOTIFICATIONS ) => [ 'slack_webhook_url' ],
		];
	}

	/**
	 * Drop the secrets from the raw option values before they are formatted.
	 *
	 * Share-link and API tokens are stored in plaintext and grant statistics
	 * access to anyone holding them, and the Slack webhook url posts into a
	 * channel, so a pasted Site Health export must not leak either.
	 *
	 * @param array<string, mixed> $wp_options Raw option values keyed by option name.
	 * @return array<string, mixed>
	 */
	private function redact_options( array $wp_options ): array {
		foreach ( self::REDACTED_OPTIONS as $option_name ) {
			unset( $wp_options[ $option_name ] );
		}

		foreach ( $this->redacted_option_keys() as $option_name => $secret_keys ) {
			if ( ! isset( $wp_options[ $option_name ] ) ) {
				continue;
			}

			$values = maybe_unserialize( $wp_options[ $option_name ] );
			if ( ! is_array( $values ) ) {
				// Not the expected shape, so the secret cannot be removed from
				// it: drop the whole option instead of exporting it blind.
				unset( $wp_options[ $option_name ] );
				continue;
			}

			foreach ( $secret_keys as $secret_key ) {
				unset( $values[ $secret_key ] );
			}
			$wp_options[ $option_name ] = $values;
		}

		return $wp_options;
	}

	/**
	 * Format an array as a string for display.
	 */
	private function format_array_as_string( array $data ): array {
		foreach ( $data as $key => $value ) {
			if ( is_array( $value ) ) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
				$data[ $key ] = print_r( $value, true );
			} else {
				$data[ $key ] = (string) $value;
			}
		}
		return $data;
	}

	/**
	 * Get last 50 lines from debug.log that start with "Burst".
	 *
	 * @return array<string> Lines from log file, or empty array if none.
	 */
	private function get_burst_debug_log_lines(): array {
		if ( ! $this->user_can_manage() ) {
			return [];
		}

		global $wp_filesystem;

		// initialize WP_Filesystem if not loaded.
		if ( ! $wp_filesystem ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
			WP_Filesystem();
		}

		$log_file = WP_CONTENT_DIR . '/debug.log';

		// check if file exists and is readable.
		if ( ! $wp_filesystem->exists( $log_file ) || ! $wp_filesystem->is_readable( $log_file ) ) {
			return [];
		}

		// skip if log is too big (> 10MB).
		if ( $wp_filesystem->size( $log_file ) > 10 * 1024 * 1024 ) {
			return [];
		}

		$content = $wp_filesystem->get_contents( $log_file );
		if ( $content === false ) {
			return [];
		}

		$lines       = [];
		$all_lines   = array_reverse( explode( "\n", $content ) );
		$total_lines = 0;

		foreach ( $all_lines as $line ) {
			++$total_lines;

			if ( stripos( $line, 'burst' ) !== false ) {
				$lines[] = $line;
				if ( count( $lines ) >= 50 ) {
					break;
				}
			}

			// limit scanning to 500 lines from the end.
			if ( $total_lines >= 500 ) {
				break;
			}
		}

		return array_reverse( $lines );
	}
}