Source: includes/classes/Stats.php

<?php
/**
 * ElasticPress index health stats page handler
 *
 * @since  3.0
 * @package elasticpress
 */

namespace ElasticPress;

use ElasticPress\Utils;

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

/**
 * Class Stats
 *
 * @package ElasticPress
 */
class Stats {
	/**
	 * Index list with health data of current cluster
	 *
	 * @var array
	 * @since  3.0
	 */
	protected $health = [];

	/**
	 * Stats retrieved directly from the current cluster
	 *
	 * @var array
	 * @since 3.x
	 */
	protected $stats = [];

	/**
	 * Overall stats of the cluster
	 *
	 * @var array
	 * @since  3.2
	 */
	protected $totals = [
		'size'   => 0,
		'memory' => 0,
		'docs'   => 0,
	];

	/**
	 * Later localized data.
	 *
	 * Used for chart building purposes
	 *
	 * @var array
	 * @since 3.2
	 */
	protected $localized = [
		'index_total'   => 0,
		'query_total'   => 0,
		'suggest_total' => 0,
		'indices_data'  => [],
	];

	/**
	 * Cluster node data.
	 *
	 * Used to determine cluster health
	 *
	 * @var int
	 * @since 3.2
	 */
	protected $nodes = 0;

	/**
	 * Failed queries and their errors.
	 *
	 * @since 5.0.1
	 * @var string
	 */
	protected $failed_queries = [];

	/**
	 * Makes an api call to elasticsearch endpoint
	 *
	 * @param  string $path Endpoint path to query
	 * @since 3.2
	 * @return array|mixed|object
	 */
	protected function remote_request_helper( $path ) {
		$request = Elasticsearch::factory()->remote_request( $path );

		if ( empty( $request ) ) {
			return false;
		}

		if ( is_wp_error( $request ) ) {
			$this->failed_queries[] = [
				'path'  => $path,
				'error' => $request->get_error_message(),
			];
			return false;
		}

		$body   = wp_remote_retrieve_body( $request );
		$return = json_decode( $body, true );

		if ( ! empty( $return['errors'] ) ) {
			$this->failed_queries[] = [
				'path'  => $path,
				'error' => wp_json_encode( $return['errors'] ),
			];
		}

		return $return;
	}

	/**
	 * Makes api calls and organizes data depending on the specified context.
	 *
	 * @param  boolean $force Force stats to be built even if cached
	 * @since 3.2
	 */
	public function build_stats( $force = false ) {
		static $stats_built = false;

		if ( $stats_built && ! $force ) {
			return;
		}

		$stats_built = true;

		$this->stats = $this->remote_request_helper( '_stats?format=json' );

		if ( empty( $this->stats ) || empty( $this->stats['_all'] ) || empty( $this->stats['_all']['total'] ) ) {
			return;
		}

		$this->populate_indices_stats();

		if ( Utils\is_epio() ) {
			$node_stats = $this->remote_request_helper( '_nodes/stats/discovery?format=json' );
		} else {
			$node_stats = $this->remote_request_helper( '_nodes/stats?format=json' );
		}

		if ( ! empty( $node_stats ) ) {
			$this->nodes = $node_stats['_nodes']['total'];
		}
	}

	/**
	 * Populate the instantiated object with the correct indices, based on context
	 *
	 * @since 3.x
	 */
	private function populate_indices_stats() {
		$network_activated = defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK;
		$blog_id           = get_current_blog_id();
		$site_indices      = $this->get_indices_for_site( $blog_id );

		$indices = $this->remote_request_helper( '_cat/indices?format=json' );

		if ( empty( $indices ) ) {
			return;
		}

		// If the plugin is network activated we only want the data from the indexable WP indexes, not any others.
		if ( $network_activated ) {
			$indexable_sites = Utils\get_sites();
			foreach ( $indexable_sites as $site ) {
				$indexables   = $this->get_indices_for_site( $site['blog_id'] );
				$site_indices = array_merge( $site_indices, $indexables );
			}
		}

		// Filter the general list of indices to contain only the ones we care about.
		$filtered_indices = array_filter(
			$indices,
			function ( $index ) use ( $site_indices ) {
				return in_array( $index['index'], $site_indices, true );
			}
		);

		/**
		 * Allow sites to select which indices will be displayed in the Index Health page
		 *
		 * @param   {array} $filtered_indices Indices filtered to the site(s) being queried.
		 * @param   {array} $indices          All indices returned from Elasticsearch
		 *
		 * @return  {array} List of indices to use
		 *
		 * @since   3.x
		 * @hook    ep_index_health_stats_indices
		 */
		$filtered_indices = apply_filters( 'ep_index_health_stats_indices', $filtered_indices, $indices );

		foreach ( $filtered_indices as $index ) {
			$this->populate_index_stats( $index['index'], $index['health'] );
		}
	}

	/**
	 * Get all registered index names for a given site ID
	 *
	 * @param int $site_id the site id
	 *
	 * @return array
	 * @since 3.x
	 */
	public function get_indices_for_site( $site_id ) {
		$indexables = Indexables::factory()->get_all();
		$indices    = array();

		foreach ( $indexables as $indexable ) {
			$indices[] = $indexable->get_index_name( $site_id );
		}

		return $indices;
	}

	/**
	 * Populate index storage capacity and metrics
	 * Note: in the numbers below, those using the total key are counting values across all primary and replica shards
	 * while those using the primaries key are reading only from the primary shards
	 *
	 * @param string $index_name index name
	 * @param string $health     index health status
	 *
	 * @since 3.x
	 */
	private function populate_index_stats( $index_name, $health ) {

		if ( empty( $this->stats['indices'][ $index_name ] ) ) {
			return;
		}

		// Index-specific data
		$this->health[ $index_name ]['name']   = $index_name;
		$this->health[ $index_name ]['health'] = $health;

		$this->localized['indices_data'][ $index_name ]['name'] = $index_name;
		$this->localized['indices_data'][ $index_name ]['docs'] = $this->stats['indices'][ $index_name ]['primaries']['docs']['count'];

		// General data counts
		$this->localized['index_total']   += absint( $this->stats['indices'][ $index_name ]['primaries']['indexing']['index_total'] );
		$this->localized['query_total']   += absint( $this->stats['indices'][ $index_name ]['total']['search']['query_total'] );
		$this->localized['suggest_total'] += absint( $this->stats['indices'][ $index_name ]['total']['search']['suggest_total'] );

		$this->totals['docs']   += absint( $this->stats['indices'][ $index_name ]['primaries']['docs']['count'] );
		$this->totals['size']   += absint( $this->stats['indices'][ $index_name ]['total']['store']['size_in_bytes'] );
		$this->totals['memory'] += absint( $this->stats['indices'][ $index_name ]['total']['segments']['memory_in_bytes'] );
	}

	/**
	 * Get index list and health data of an elasticsearch endpoint
	 *
	 * @return array
	 * @since 3.2
	 */
	public function get_health() {
		$this->build_stats();
		return $this->health;
	}

	/**
	 * Get number of nodes in the current cluster
	 *
	 * @since 3.2
	 * @return int
	 */
	public function get_nodes() {
		$this->build_stats();
		return $this->nodes;
	}

	/**
	 * Gets relevant total data of an elasticsearch endpoint
	 *
	 * @since 3.2
	 * @return array
	 */
	public function get_totals() {
		$this->build_stats();
		return $this->totals;
	}

	/**
	 * Gets localized data
	 *
	 * @since 3.2
	 * @return mixed Data used in localization for chart creation.
	 */
	public function get_localized() {
		$this->build_stats();
		return $this->localized;
	}

	/**
	 * Converts a number to a readable size format.
	 *
	 * @param  int $size Desired number to convert
	 * @since 3.2
	 * @return string Size with appended unit
	 */
	public function convert_to_readable_size( $size ) {
		if ( empty( $size ) ) {
			return 0;
		}

		$base   = log( $size ) / log( 1024 );
		$suffix = array( '', 'KB', 'MB', 'GB', 'TB' );
		$f_base = floor( $base );

		return round( pow( 1024, $base - floor( $base ) ), 1 ) . $suffix[ $f_base ];
	}

	/**
	 * Return all failed queries.
	 *
	 * @return array
	 * @since 5.0.1
	 */
	public function get_failed_queries() {
		return $this->failed_queries;
	}

	/**
	 * Clear all failed queries registered.
	 *
	 * @since 5.0.1
	 */
	public function clear_failed_queries() {
		$this->failed_queries = [];
	}

	/**
	 * Return singleton instance of class
	 *
	 * @return self
	 * @since 3.2
	 */
	public static function factory() {
		static $instance = false;

		if ( ! $instance ) {
			$instance = new self();
		}

		return $instance;
	}
}