<?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;
}
}