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' ) ); // Use WooCommerce's built-in order count function. $order_counts = wc_orders_count(); foreach ( $order_counts as $status => $count ) { $gauge->set( (int) $count, array( $status ) ); } } /** * 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 ); } }