Files
wp-prometheus/src/Metrics/Collector.php

823 lines
27 KiB
PHP
Raw Normal View History

<?php
/**
* Metrics collector class.
*
* @package WP_Prometheus
*/
namespace Magdev\WpPrometheus\Metrics;
use Prometheus\CollectorRegistry;
use Prometheus\Storage\InMemory;
use Prometheus\RenderTextFormat;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Collector class.
*
* Collects and manages Prometheus metrics.
*/
class Collector {
/**
* Prometheus collector registry.
*
* @var CollectorRegistry
*/
private CollectorRegistry $registry;
/**
* Metric namespace.
*
* @var string
*/
private string $namespace = 'wordpress';
/**
* Constructor.
*/
public function __construct() {
$this->registry = new CollectorRegistry( new InMemory() );
}
/**
* Get the collector registry.
*
* @return CollectorRegistry
*/
public function get_registry(): CollectorRegistry {
return $this->registry;
}
/**
* Get the metric namespace.
*
* @return string
*/
public function get_namespace(): string {
return $this->namespace;
}
/**
* Collect all enabled metrics.
*
* @return void
*/
public function collect(): void {
$enabled_metrics = get_option( 'wp_prometheus_enabled_metrics', array() );
// Always collect WordPress info.
if ( in_array( 'wordpress_info', $enabled_metrics, true ) ) {
$this->collect_wordpress_info();
}
// Collect user metrics.
if ( in_array( 'wordpress_users_total', $enabled_metrics, true ) ) {
$this->collect_users_total();
}
// Collect posts metrics.
if ( in_array( 'wordpress_posts_total', $enabled_metrics, true ) ) {
$this->collect_posts_total();
}
// Collect comments metrics.
if ( in_array( 'wordpress_comments_total', $enabled_metrics, true ) ) {
$this->collect_comments_total();
}
// Collect plugins metrics.
if ( in_array( 'wordpress_plugins_total', $enabled_metrics, true ) ) {
$this->collect_plugins_total();
}
// Collect cron metrics.
if ( in_array( 'wordpress_cron_events_total', $enabled_metrics, true ) ) {
$this->collect_cron_metrics();
}
// Collect transient metrics.
if ( in_array( 'wordpress_transients_total', $enabled_metrics, true ) ) {
$this->collect_transient_metrics();
}
// Collect WooCommerce metrics (if WooCommerce is active).
if ( $this->is_woocommerce_active() ) {
$this->collect_woocommerce_metrics( $enabled_metrics );
}
// Collect runtime metrics (HTTP requests, DB queries).
$this->collect_runtime_metrics( $enabled_metrics );
/**
* Fires after default metrics are collected.
*
* @param Collector $collector The metrics collector instance.
*/
do_action( 'wp_prometheus_collect_metrics', $this );
}
/**
* Render metrics in Prometheus text format.
*
* @return string
*/
public function render(): string {
$this->collect();
$renderer = new RenderTextFormat();
return $renderer->render( $this->registry->getMetricFamilySamples() );
}
/**
* Collect WordPress info metric.
*
* @return void
*/
private function collect_wordpress_info(): void {
$gauge = $this->registry->getOrRegisterGauge(
$this->namespace,
'info',
'WordPress installation information',
array( 'version', 'php_version', 'multisite' )
);
$gauge->set(
1,
array(
get_bloginfo( 'version' ),
PHP_VERSION,
is_multisite() ? 'yes' : 'no',
)
);
}
/**
* Collect total users metric.
*
* @return void
*/
private function collect_users_total(): void {
$gauge = $this->registry->getOrRegisterGauge(
$this->namespace,
'users_total',
'Total number of WordPress users',
array( 'role' )
);
$user_count = count_users();
foreach ( $user_count['avail_roles'] as $role => $count ) {
$gauge->set( $count, array( $role ) );
}
}
/**
* Collect total posts metric.
*
* @return void
*/
private function collect_posts_total(): void {
$gauge = $this->registry->getOrRegisterGauge(
$this->namespace,
'posts_total',
'Total number of posts by type and status',
array( 'post_type', 'status' )
);
$post_types = get_post_types( array( 'public' => true ) );
foreach ( $post_types as $post_type ) {
$counts = wp_count_posts( $post_type );
foreach ( get_object_vars( $counts ) as $status => $count ) {
if ( $count > 0 ) {
$gauge->set( (int) $count, array( $post_type, $status ) );
}
}
}
}
/**
* Collect total comments metric.
*
* @return void
*/
private function collect_comments_total(): void {
$gauge = $this->registry->getOrRegisterGauge(
$this->namespace,
'comments_total',
'Total number of comments by status',
array( 'status' )
);
$comments = wp_count_comments();
$statuses = array(
'approved' => $comments->approved,
'moderated' => $comments->moderated,
'spam' => $comments->spam,
'trash' => $comments->trash,
'total_comments' => $comments->total_comments,
);
foreach ( $statuses as $status => $count ) {
$gauge->set( (int) $count, array( $status ) );
}
}
/**
* Collect total plugins metric.
*
* @return void
*/
private function collect_plugins_total(): void {
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$gauge = $this->registry->getOrRegisterGauge(
$this->namespace,
'plugins_total',
'Total number of plugins by status',
array( 'status' )
);
$all_plugins = get_plugins();
$active_plugins = get_option( 'active_plugins', array() );
$gauge->set( count( $all_plugins ), array( 'installed' ) );
$gauge->set( count( $active_plugins ), array( 'active' ) );
$gauge->set( count( $all_plugins ) - count( $active_plugins ), array( 'inactive' ) );
}
/**
* Collect cron metrics.
*
* @return void
*/
private function collect_cron_metrics(): void {
$cron_array = _get_cron_array();
if ( ! is_array( $cron_array ) ) {
return;
}
// Events total gauge.
$events_gauge = $this->registry->getOrRegisterGauge(
$this->namespace,
'cron_events_total',
'Total number of scheduled cron events',
array( 'hook' )
);
// Count events by hook.
$hook_counts = array();
$total_events = 0;
$overdue_count = 0;
$current_time = time();
$next_run = PHP_INT_MAX;
foreach ( $cron_array as $timestamp => $cron ) {
if ( $timestamp < $next_run ) {
$next_run = $timestamp;
}
foreach ( $cron as $hook => $events ) {
$event_count = count( $events );
$total_events += $event_count;
if ( ! isset( $hook_counts[ $hook ] ) ) {
$hook_counts[ $hook ] = 0;
}
$hook_counts[ $hook ] += $event_count;
// Check if overdue.
if ( $timestamp < $current_time ) {
$overdue_count += $event_count;
}
}
}
// Set events by hook (limit to top 20 to avoid cardinality explosion).
arsort( $hook_counts );
$hook_counts = array_slice( $hook_counts, 0, 20, true );
foreach ( $hook_counts as $hook => $count ) {
$events_gauge->set( $count, array( $hook ) );
}
// Overdue events gauge.
$overdue_gauge = $this->registry->getOrRegisterGauge(
$this->namespace,
'cron_overdue_total',
'Number of overdue cron events',
array()
);
$overdue_gauge->set( $overdue_count, array() );
// Next run timestamp.
$next_run_gauge = $this->registry->getOrRegisterGauge(
$this->namespace,
'cron_next_run_timestamp',
'Unix timestamp of next scheduled cron event',
array()
);
if ( $next_run !== PHP_INT_MAX ) {
$next_run_gauge->set( $next_run, array() );
}
}
/**
* Collect transient metrics.
*
* @return void
*/
private function collect_transient_metrics(): void {
global $wpdb;
// Count all transients.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$transient_count = $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE '_transient_%' AND option_name NOT LIKE '_transient_timeout_%'"
);
// Count transients with expiration.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$expiring_count = $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_%'"
);
// Count expired transients.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$expired_count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_%%' AND option_value < %d",
time()
)
);
// Transients total gauge.
$transients_gauge = $this->registry->getOrRegisterGauge(
$this->namespace,
'transients_total',
'Total number of transients in database',
array( 'type' )
);
$transients_gauge->set( (int) $transient_count, array( 'total' ) );
$transients_gauge->set( (int) $expiring_count, array( 'with_expiration' ) );
$transients_gauge->set( (int) $transient_count - (int) $expiring_count, array( 'persistent' ) );
$transients_gauge->set( (int) $expired_count, array( 'expired' ) );
// Site transients (for multisite).
if ( is_multisite() ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$site_transient_count = $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->sitemeta} WHERE meta_key LIKE '_site_transient_%' AND meta_key NOT LIKE '_site_transient_timeout_%'"
);
$transients_gauge->set( (int) $site_transient_count, array( 'site_transients' ) );
}
}
/**
* Check if WooCommerce is active.
*
* @return bool
*/
private function is_woocommerce_active(): bool {
return class_exists( 'WooCommerce' );
}
/**
* Collect WooCommerce metrics.
*
* @param array $enabled_metrics List of enabled metrics.
* @return void
*/
private function collect_woocommerce_metrics( array $enabled_metrics ): void {
// Products total.
if ( in_array( 'wordpress_woocommerce_products_total', $enabled_metrics, true ) ) {
$this->collect_woocommerce_products();
}
// Orders total.
if ( in_array( 'wordpress_woocommerce_orders_total', $enabled_metrics, true ) ) {
$this->collect_woocommerce_orders();
}
// Revenue.
if ( in_array( 'wordpress_woocommerce_revenue_total', $enabled_metrics, true ) ) {
$this->collect_woocommerce_revenue();
}
// Customers.
if ( in_array( 'wordpress_woocommerce_customers_total', $enabled_metrics, true ) ) {
$this->collect_woocommerce_customers();
}
}
/**
* Collect WooCommerce products metrics.
*
* @return void
*/
private function collect_woocommerce_products(): void {
$gauge = $this->registry->getOrRegisterGauge(
$this->namespace,
'woocommerce_products_total',
'Total number of WooCommerce products by status and type',
array( 'status', 'type' )
);
// Get product counts by status.
$product_counts = wp_count_posts( 'product' );
$product_types = wc_get_product_types();
foreach ( get_object_vars( $product_counts ) as $status => $count ) {
if ( (int) $count > 0 ) {
$gauge->set( (int) $count, array( $status, 'all' ) );
}
}
// Count by product type (for published products only).
foreach ( array_keys( $product_types ) as $type ) {
$args = array(
'status' => 'publish',
'type' => $type,
'limit' => -1,
'return' => 'ids',
);
$products = wc_get_products( $args );
$gauge->set( count( $products ), array( 'publish', $type ) );
}
}
/**
* Collect WooCommerce orders metrics.
*
* @return void
*/
private function collect_woocommerce_orders(): void {
$gauge = $this->registry->getOrRegisterGauge(
$this->namespace,
'woocommerce_orders_total',
'Total number of WooCommerce orders by status',
array( 'status' )
);
// Get all registered order statuses and count each.
$statuses = wc_get_order_statuses();
foreach ( array_keys( $statuses ) as $status ) {
// Remove 'wc-' prefix for the label.
$status_label = str_replace( 'wc-', '', $status );
$count = wc_orders_count( $status );
$gauge->set( (int) $count, array( $status_label ) );
}
}
/**
* Collect WooCommerce revenue metrics.
*
* @return void
*/
private function collect_woocommerce_revenue(): void {
global $wpdb;
$gauge = $this->registry->getOrRegisterGauge(
$this->namespace,
'woocommerce_revenue_total',
'Total WooCommerce revenue',
array( 'period', 'currency' )
);
$currency = get_woocommerce_currency();
// Check if HPOS (High-Performance Order Storage) is enabled.
$hpos_enabled = class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' )
&& \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled();
if ( $hpos_enabled ) {
$orders_table = $wpdb->prefix . 'wc_orders';
// Total revenue (all time) - completed and processing orders.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$total_revenue = $wpdb->get_var(
"SELECT SUM(total_amount) FROM {$orders_table} WHERE status IN ('wc-completed', 'wc-processing')"
);
// Today's revenue.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$today_revenue = $wpdb->get_var(
$wpdb->prepare(
"SELECT SUM(total_amount) FROM {$orders_table} WHERE status IN ('wc-completed', 'wc-processing') AND DATE(date_created_gmt) = %s",
gmdate( 'Y-m-d' )
)
);
// This month's revenue.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$month_revenue = $wpdb->get_var(
$wpdb->prepare(
"SELECT SUM(total_amount) FROM {$orders_table} WHERE status IN ('wc-completed', 'wc-processing') AND YEAR(date_created_gmt) = %d AND MONTH(date_created_gmt) = %d",
gmdate( 'Y' ),
gmdate( 'm' )
)
);
} else {
// Legacy post-based orders.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$total_revenue = $wpdb->get_var(
"SELECT SUM(meta_value) FROM {$wpdb->postmeta} pm
JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE pm.meta_key = '_order_total'
AND p.post_type = 'shop_order'
AND p.post_status IN ('wc-completed', 'wc-processing')"
);
// Today's revenue.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$today_revenue = $wpdb->get_var(
$wpdb->prepare(
"SELECT SUM(meta_value) FROM {$wpdb->postmeta} pm
JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE pm.meta_key = '_order_total'
AND p.post_type = 'shop_order'
AND p.post_status IN ('wc-completed', 'wc-processing')
AND DATE(p.post_date_gmt) = %s",
gmdate( 'Y-m-d' )
)
);
// This month's revenue.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$month_revenue = $wpdb->get_var(
$wpdb->prepare(
"SELECT SUM(meta_value) FROM {$wpdb->postmeta} pm
JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE pm.meta_key = '_order_total'
AND p.post_type = 'shop_order'
AND p.post_status IN ('wc-completed', 'wc-processing')
AND YEAR(p.post_date_gmt) = %d
AND MONTH(p.post_date_gmt) = %d",
gmdate( 'Y' ),
gmdate( 'm' )
)
);
}
$gauge->set( (float) ( $total_revenue ?? 0 ), array( 'all_time', $currency ) );
$gauge->set( (float) ( $today_revenue ?? 0 ), array( 'today', $currency ) );
$gauge->set( (float) ( $month_revenue ?? 0 ), array( 'month', $currency ) );
}
/**
* Collect WooCommerce customers metrics.
*
* @return void
*/
private function collect_woocommerce_customers(): void {
$gauge = $this->registry->getOrRegisterGauge(
$this->namespace,
'woocommerce_customers_total',
'Total number of WooCommerce customers',
array( 'type' )
);
// Count users with customer role.
$customer_count = count_users();
$customers = $customer_count['avail_roles']['customer'] ?? 0;
$gauge->set( $customers, array( 'registered' ) );
// Count guest orders (orders without user_id).
global $wpdb;
// Check if HPOS is enabled.
$hpos_enabled = class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' )
&& \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled();
if ( $hpos_enabled ) {
$orders_table = $wpdb->prefix . 'wc_orders';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$guest_orders = $wpdb->get_var(
"SELECT COUNT(DISTINCT billing_email) FROM {$orders_table} WHERE customer_id = 0 AND billing_email != ''"
);
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$guest_orders = $wpdb->get_var(
"SELECT COUNT(DISTINCT pm.meta_value) FROM {$wpdb->postmeta} pm
JOIN {$wpdb->posts} p ON p.ID = pm.post_id
LEFT JOIN {$wpdb->postmeta} pm2 ON pm2.post_id = p.ID AND pm2.meta_key = '_customer_user'
WHERE pm.meta_key = '_billing_email'
AND p.post_type = 'shop_order'
AND (pm2.meta_value = '0' OR pm2.meta_value IS NULL)"
);
}
$gauge->set( (int) $guest_orders, array( 'guest' ) );
}
/**
* Collect runtime metrics from stored data.
*
* @param array $enabled_metrics List of enabled metrics.
* @return void
*/
private function collect_runtime_metrics( array $enabled_metrics ): void {
$runtime_collector = RuntimeCollector::get_instance();
$stored_metrics = $runtime_collector->get_stored_metrics();
// HTTP requests total counter.
if ( in_array( 'wordpress_http_requests_total', $enabled_metrics, true ) && ! empty( $stored_metrics['counters'] ) ) {
foreach ( $stored_metrics['counters'] as $counter_data ) {
if ( 'http_requests_total' !== $counter_data['name'] ) {
continue;
}
$counter = $this->registry->getOrRegisterCounter(
$this->namespace,
'http_requests_total',
'Total number of HTTP requests',
array( 'method', 'status', 'endpoint' )
);
$counter->incBy(
(int) $counter_data['value'],
array(
$counter_data['labels']['method'] ?? 'GET',
$counter_data['labels']['status'] ?? '200',
$counter_data['labels']['endpoint'] ?? 'unknown',
)
);
}
}
// HTTP request duration histogram.
if ( in_array( 'wordpress_http_request_duration_seconds', $enabled_metrics, true ) && ! empty( $stored_metrics['histograms'] ) ) {
foreach ( $stored_metrics['histograms'] as $histogram_data ) {
if ( 'http_request_duration_seconds' !== $histogram_data['name'] ) {
continue;
}
// For histograms, we expose as a gauge with pre-aggregated bucket counts.
// This is a workaround since we can't directly populate histogram buckets.
$this->expose_histogram_as_gauges(
'http_request_duration_seconds',
'HTTP request duration in seconds',
$histogram_data,
array( 'method', 'endpoint' )
);
}
}
// Database queries total counter.
if ( in_array( 'wordpress_db_queries_total', $enabled_metrics, true ) && ! empty( $stored_metrics['counters'] ) ) {
foreach ( $stored_metrics['counters'] as $counter_data ) {
if ( 'db_queries_total' !== $counter_data['name'] ) {
continue;
}
$counter = $this->registry->getOrRegisterCounter(
$this->namespace,
'db_queries_total',
'Total number of database queries',
array( 'endpoint' )
);
$counter->incBy(
(int) $counter_data['value'],
array(
$counter_data['labels']['endpoint'] ?? 'unknown',
)
);
}
}
// Database query duration histogram (if SAVEQUERIES is enabled).
if ( in_array( 'wordpress_db_queries_total', $enabled_metrics, true ) && ! empty( $stored_metrics['histograms'] ) ) {
foreach ( $stored_metrics['histograms'] as $histogram_data ) {
if ( 'db_query_duration_seconds' !== $histogram_data['name'] ) {
continue;
}
$this->expose_histogram_as_gauges(
'db_query_duration_seconds',
'Database query duration in seconds',
$histogram_data,
array( 'endpoint' )
);
}
}
}
/**
* Expose pre-aggregated histogram data as gauge metrics.
*
* Since we store histogram data externally, we expose it using gauges
* that follow Prometheus histogram naming conventions.
*
* @param string $name Metric name.
* @param string $help Metric description.
* @param array $histogram_data Stored histogram data.
* @param array $label_names Label names.
* @return void
*/
private function expose_histogram_as_gauges( string $name, string $help, array $histogram_data, array $label_names ): void {
$label_values = array();
foreach ( $label_names as $label_name ) {
$label_values[] = $histogram_data['labels'][ $label_name ] ?? 'unknown';
}
// Expose bucket counts.
$bucket_gauge = $this->registry->getOrRegisterGauge(
$this->namespace,
$name . '_bucket',
$help . ' (bucket)',
array_merge( $label_names, array( 'le' ) )
);
$cumulative_count = 0;
foreach ( $histogram_data['buckets'] as $le => $count ) {
$cumulative_count += $count;
$bucket_gauge->set(
$cumulative_count,
array_merge( $label_values, array( $le ) )
);
}
// Expose sum.
$sum_gauge = $this->registry->getOrRegisterGauge(
$this->namespace,
$name . '_sum',
$help . ' (sum)',
$label_names
);
$sum_gauge->set( $histogram_data['sum'], $label_values );
// Expose count.
$count_gauge = $this->registry->getOrRegisterGauge(
$this->namespace,
$name . '_count',
$help . ' (count)',
$label_names
);
$count_gauge->set( $histogram_data['count'], $label_values );
}
/**
* Register a custom gauge metric.
*
* @param string $name Metric name.
* @param string $help Metric description.
* @param array $labels Label names.
* @return \Prometheus\Gauge
*/
public function register_gauge( string $name, string $help, array $labels = array() ): \Prometheus\Gauge {
return $this->registry->getOrRegisterGauge(
$this->namespace,
$name,
$help,
$labels
);
}
/**
* Register a custom counter metric.
*
* @param string $name Metric name.
* @param string $help Metric description.
* @param array $labels Label names.
* @return \Prometheus\Counter
*/
public function register_counter( string $name, string $help, array $labels = array() ): \Prometheus\Counter {
return $this->registry->getOrRegisterCounter(
$this->namespace,
$name,
$help,
$labels
);
}
/**
* Register a custom histogram metric.
*
* @param string $name Metric name.
* @param string $help Metric description.
* @param array $labels Label names.
* @param array|null $buckets Histogram buckets.
* @return \Prometheus\Histogram
*/
public function register_histogram( string $name, string $help, array $labels = array(), ?array $buckets = null ): \Prometheus\Histogram {
return $this->registry->getOrRegisterHistogram(
$this->namespace,
$name,
$help,
$labels,
$buckets
);
}
}