feat: Add custom metric builder, export/import, and Grafana dashboards (v0.3.0)
All checks were successful
Create Release Package / build-release (push) Successful in 59s

- Custom Metrics Builder with admin UI for gauge metrics
- Support for static values and WordPress option-based values
- JSON-based export/import with skip/overwrite/rename modes
- Three Grafana dashboard templates (overview, runtime, WooCommerce)
- New tabs: Custom Metrics and Dashboards
- Reset runtime metrics button

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-02 15:27:16 +01:00
parent da6d5081f7
commit bad977bef0
16 changed files with 6239 additions and 620 deletions

View File

@@ -0,0 +1,151 @@
<?php
/**
* Dashboard provider class.
*
* @package WP_Prometheus
*/
namespace Magdev\WpPrometheus\Admin;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* DashboardProvider class.
*
* Provides Grafana dashboard templates for download.
*/
class DashboardProvider {
/**
* Dashboard directory path.
*
* @var string
*/
private string $dashboard_dir;
/**
* Available dashboard definitions.
*
* @var array
*/
private array $dashboards = array();
/**
* Constructor.
*/
public function __construct() {
$this->dashboard_dir = WP_PROMETHEUS_PATH . 'assets/dashboards/';
$this->dashboards = array(
'wordpress-overview' => array(
'title' => __( 'WordPress Overview', 'wp-prometheus' ),
'description' => __( 'General WordPress metrics including users, posts, comments, and plugins.', 'wp-prometheus' ),
'file' => 'wordpress-overview.json',
'icon' => 'dashicons-wordpress',
),
'wordpress-runtime' => array(
'title' => __( 'Runtime Performance', 'wp-prometheus' ),
'description' => __( 'HTTP request metrics, database query performance, and response times.', 'wp-prometheus' ),
'file' => 'wordpress-runtime.json',
'icon' => 'dashicons-performance',
),
'wordpress-woocommerce' => array(
'title' => __( 'WooCommerce Store', 'wp-prometheus' ),
'description' => __( 'WooCommerce metrics including products, orders, revenue, and customers.', 'wp-prometheus' ),
'file' => 'wordpress-woocommerce.json',
'icon' => 'dashicons-cart',
),
);
}
/**
* Get list of available dashboards.
*
* @return array
*/
public function get_available(): array {
$available = array();
foreach ( $this->dashboards as $slug => $dashboard ) {
$file_path = $this->dashboard_dir . $dashboard['file'];
if ( file_exists( $file_path ) ) {
$available[ $slug ] = $dashboard;
}
}
return $available;
}
/**
* Get dashboard content by slug.
*
* @param string $slug Dashboard slug.
* @return string|null JSON content or null if not found.
*/
public function get_dashboard( string $slug ): ?string {
// Validate slug to prevent directory traversal.
$slug = sanitize_file_name( $slug );
if ( ! isset( $this->dashboards[ $slug ] ) ) {
return null;
}
$file_path = $this->dashboard_dir . $this->dashboards[ $slug ]['file'];
// Security: Ensure file is within dashboard directory.
$real_path = realpath( $file_path );
$real_dir = realpath( $this->dashboard_dir );
if ( false === $real_path || false === $real_dir || strpos( $real_path, $real_dir ) !== 0 ) {
return null;
}
if ( ! file_exists( $file_path ) || ! is_readable( $file_path ) ) {
return null;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$content = file_get_contents( $file_path );
if ( false === $content ) {
return null;
}
return $content;
}
/**
* Get dashboard metadata by slug.
*
* @param string $slug Dashboard slug.
* @return array|null Dashboard metadata or null if not found.
*/
public function get_metadata( string $slug ): ?array {
$slug = sanitize_file_name( $slug );
if ( ! isset( $this->dashboards[ $slug ] ) ) {
return null;
}
return $this->dashboards[ $slug ];
}
/**
* Get filename for download.
*
* @param string $slug Dashboard slug.
* @return string|null Filename or null if not found.
*/
public function get_filename( string $slug ): ?string {
$slug = sanitize_file_name( $slug );
if ( ! isset( $this->dashboards[ $slug ] ) ) {
return null;
}
return $this->dashboards[ $slug ]['file'];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,7 @@ final class Installer {
'wp_prometheus_enable_default_metrics',
'wp_prometheus_enabled_metrics',
'wp_prometheus_runtime_metrics',
'wp_prometheus_custom_metrics',
);
foreach ( $options as $option ) {

View File

@@ -10,6 +10,7 @@ namespace Magdev\WpPrometheus\Metrics;
use Prometheus\CollectorRegistry;
use Prometheus\Storage\InMemory;
use Prometheus\RenderTextFormat;
use Magdev\WpPrometheus\Metrics\CustomMetricBuilder;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
@@ -113,6 +114,10 @@ class Collector {
// Collect runtime metrics (HTTP requests, DB queries).
$this->collect_runtime_metrics( $enabled_metrics );
// Collect custom user-defined metrics.
$custom_builder = new CustomMetricBuilder();
$custom_builder->register_with_collector( $this );
/**
* Fires after default metrics are collected.
*

View File

@@ -0,0 +1,496 @@
<?php
/**
* Custom metric builder class.
*
* @package WP_Prometheus
*/
namespace Magdev\WpPrometheus\Metrics;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* CustomMetricBuilder class.
*
* Manages custom user-defined Prometheus metrics.
*/
class CustomMetricBuilder {
/**
* Option name for storing custom metrics.
*
* @var string
*/
const OPTION_NAME = 'wp_prometheus_custom_metrics';
/**
* Export format version.
*
* @var string
*/
const EXPORT_VERSION = '1.0.0';
/**
* Maximum number of labels per metric.
*
* @var int
*/
const MAX_LABELS = 5;
/**
* Maximum number of label value combinations per metric.
*
* @var int
*/
const MAX_LABEL_VALUES = 50;
/**
* Get all custom metrics.
*
* @return array
*/
public function get_all(): array {
$metrics = get_option( self::OPTION_NAME, array() );
return is_array( $metrics ) ? $metrics : array();
}
/**
* Get a single metric by ID.
*
* @param string $id Metric ID.
* @return array|null
*/
public function get( string $id ): ?array {
$metrics = $this->get_all();
return $metrics[ $id ] ?? null;
}
/**
* Save a metric (create or update).
*
* @param array $metric Metric data.
* @return string Metric ID.
* @throws \InvalidArgumentException If validation fails.
*/
public function save( array $metric ): string {
$errors = $this->validate( $metric );
if ( ! empty( $errors ) ) {
throw new \InvalidArgumentException( implode( ', ', $errors ) );
}
$metrics = $this->get_all();
// Generate ID if not provided.
if ( empty( $metric['id'] ) ) {
$metric['id'] = wp_generate_uuid4();
$metric['created_at'] = time();
}
$metric['updated_at'] = time();
// Sanitize and normalize the metric data.
$metric = $this->sanitize_metric( $metric );
$metrics[ $metric['id'] ] = $metric;
update_option( self::OPTION_NAME, $metrics );
return $metric['id'];
}
/**
* Delete a metric.
*
* @param string $id Metric ID.
* @return bool True if deleted, false if not found.
*/
public function delete( string $id ): bool {
$metrics = $this->get_all();
if ( ! isset( $metrics[ $id ] ) ) {
return false;
}
unset( $metrics[ $id ] );
update_option( self::OPTION_NAME, $metrics );
return true;
}
/**
* Validate a Prometheus metric name.
*
* @param string $name Metric name.
* @return bool True if valid.
*/
public function validate_name( string $name ): bool {
// Prometheus metric names must match: [a-zA-Z_:][a-zA-Z0-9_:]*
return (bool) preg_match( '/^[a-zA-Z_:][a-zA-Z0-9_:]*$/', $name );
}
/**
* Validate a Prometheus label name.
*
* @param string $name Label name.
* @return bool True if valid.
*/
public function validate_label_name( string $name ): bool {
// Prometheus label names must match: [a-zA-Z_][a-zA-Z0-9_]*
// Labels starting with __ are reserved.
if ( strpos( $name, '__' ) === 0 ) {
return false;
}
return (bool) preg_match( '/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name );
}
/**
* Validate a complete metric definition.
*
* @param array $metric Metric data.
* @return array Array of error messages (empty if valid).
*/
public function validate( array $metric ): array {
$errors = array();
// Name is required.
if ( empty( $metric['name'] ) ) {
$errors[] = __( 'Metric name is required.', 'wp-prometheus' );
} elseif ( ! $this->validate_name( $metric['name'] ) ) {
$errors[] = __( 'Metric name must start with a letter, underscore, or colon, and contain only letters, numbers, underscores, and colons.', 'wp-prometheus' );
}
// Check for reserved prefixes.
if ( ! empty( $metric['name'] ) ) {
$reserved_prefixes = array( 'wordpress_', 'go_', 'process_', 'promhttp_' );
foreach ( $reserved_prefixes as $prefix ) {
if ( strpos( $metric['name'], $prefix ) === 0 ) {
$errors[] = sprintf(
/* translators: %s: Reserved prefix */
__( 'Metric name cannot start with reserved prefix "%s".', 'wp-prometheus' ),
$prefix
);
break;
}
}
}
// Check for duplicate names (excluding current metric if editing).
if ( ! empty( $metric['name'] ) ) {
$existing = $this->get_all();
foreach ( $existing as $id => $existing_metric ) {
if ( $existing_metric['name'] === $metric['name'] && ( empty( $metric['id'] ) || $metric['id'] !== $id ) ) {
$errors[] = __( 'A metric with this name already exists.', 'wp-prometheus' );
break;
}
}
}
// Help text is required.
if ( empty( $metric['help'] ) ) {
$errors[] = __( 'Help text is required.', 'wp-prometheus' );
}
// Validate type.
$valid_types = array( 'gauge' );
if ( empty( $metric['type'] ) || ! in_array( $metric['type'], $valid_types, true ) ) {
$errors[] = __( 'Invalid metric type. Only gauge is supported.', 'wp-prometheus' );
}
// Validate labels.
if ( ! empty( $metric['labels'] ) ) {
if ( ! is_array( $metric['labels'] ) ) {
$errors[] = __( 'Labels must be an array.', 'wp-prometheus' );
} elseif ( count( $metric['labels'] ) > self::MAX_LABELS ) {
$errors[] = sprintf(
/* translators: %d: Maximum labels */
__( 'Maximum %d labels allowed per metric.', 'wp-prometheus' ),
self::MAX_LABELS
);
} else {
foreach ( $metric['labels'] as $label ) {
if ( ! $this->validate_label_name( $label ) ) {
$errors[] = sprintf(
/* translators: %s: Label name */
__( 'Invalid label name: %s', 'wp-prometheus' ),
$label
);
}
}
}
}
// Validate value type.
$valid_value_types = array( 'static', 'option' );
if ( empty( $metric['value_type'] ) || ! in_array( $metric['value_type'], $valid_value_types, true ) ) {
$errors[] = __( 'Invalid value type. Must be "static" or "option".', 'wp-prometheus' );
}
// Validate value config based on type.
if ( ! empty( $metric['value_type'] ) ) {
if ( 'static' === $metric['value_type'] ) {
// Static values validated in label_values.
} elseif ( 'option' === $metric['value_type'] ) {
if ( empty( $metric['value_config']['option_name'] ) ) {
$errors[] = __( 'Option name is required for option-based metrics.', 'wp-prometheus' );
}
}
}
// Validate label values count.
if ( ! empty( $metric['label_values'] ) && is_array( $metric['label_values'] ) ) {
if ( count( $metric['label_values'] ) > self::MAX_LABEL_VALUES ) {
$errors[] = sprintf(
/* translators: %d: Maximum label combinations */
__( 'Maximum %d label value combinations allowed.', 'wp-prometheus' ),
self::MAX_LABEL_VALUES
);
}
// Validate each row has correct number of values.
$label_count = count( $metric['labels'] ?? array() );
foreach ( $metric['label_values'] as $row ) {
if ( is_array( $row ) && count( $row ) !== $label_count + 1 ) { // +1 for value.
$errors[] = __( 'Each label value row must have values for all labels plus a metric value.', 'wp-prometheus' );
break;
}
}
}
return $errors;
}
/**
* Sanitize metric data.
*
* @param array $metric Raw metric data.
* @return array Sanitized metric data.
*/
private function sanitize_metric( array $metric ): array {
$sanitized = array(
'id' => sanitize_key( $metric['id'] ?? '' ),
'name' => sanitize_key( $metric['name'] ?? '' ),
'help' => sanitize_text_field( $metric['help'] ?? '' ),
'type' => sanitize_key( $metric['type'] ?? 'gauge' ),
'labels' => array(),
'value_type' => sanitize_key( $metric['value_type'] ?? 'static' ),
'value_config' => array(),
'label_values' => array(),
'enabled' => ! empty( $metric['enabled'] ),
'created_at' => absint( $metric['created_at'] ?? time() ),
'updated_at' => absint( $metric['updated_at'] ?? time() ),
);
// Sanitize labels.
if ( ! empty( $metric['labels'] ) && is_array( $metric['labels'] ) ) {
foreach ( $metric['labels'] as $label ) {
$sanitized['labels'][] = sanitize_key( $label );
}
}
// Sanitize value config.
if ( 'static' === $sanitized['value_type'] ) {
$sanitized['value_config'] = array();
} elseif ( 'option' === $sanitized['value_type'] ) {
$sanitized['value_config'] = array(
'option_name' => sanitize_key( $metric['value_config']['option_name'] ?? '' ),
'default' => floatval( $metric['value_config']['default'] ?? 0 ),
);
}
// Sanitize label values.
if ( ! empty( $metric['label_values'] ) && is_array( $metric['label_values'] ) ) {
foreach ( $metric['label_values'] as $row ) {
if ( is_array( $row ) ) {
$sanitized_row = array();
foreach ( $row as $index => $value ) {
// Last value is the metric value (numeric).
if ( $index === count( $row ) - 1 ) {
$sanitized_row[] = floatval( $value );
} else {
$sanitized_row[] = sanitize_text_field( $value );
}
}
$sanitized['label_values'][] = $sanitized_row;
}
}
}
return $sanitized;
}
/**
* Export all metrics to JSON.
*
* @return string JSON string.
*/
public function export(): string {
$metrics = $this->get_all();
$export_data = array(
'version' => self::EXPORT_VERSION,
'plugin_version' => WP_PROMETHEUS_VERSION,
'exported_at' => gmdate( 'c' ),
'site_url' => home_url(),
'metrics' => array_values( $metrics ),
);
return wp_json_encode( $export_data, JSON_PRETTY_PRINT );
}
/**
* Import metrics from JSON.
*
* @param string $json JSON string.
* @param string $mode Import mode: 'skip', 'overwrite', or 'rename'.
* @return array Result with 'imported', 'skipped', 'errors' counts.
* @throws \InvalidArgumentException If JSON is invalid.
*/
public function import( string $json, string $mode = 'skip' ): array {
$data = json_decode( $json, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
throw new \InvalidArgumentException( __( 'Invalid JSON format.', 'wp-prometheus' ) );
}
if ( empty( $data['metrics'] ) || ! is_array( $data['metrics'] ) ) {
throw new \InvalidArgumentException( __( 'No metrics found in import file.', 'wp-prometheus' ) );
}
$result = array(
'imported' => 0,
'skipped' => 0,
'errors' => 0,
'messages' => array(),
);
$existing_metrics = $this->get_all();
$existing_names = array_column( $existing_metrics, 'name', 'id' );
foreach ( $data['metrics'] as $metric ) {
if ( empty( $metric['name'] ) ) {
$result['errors']++;
continue;
}
// Check for name collision.
$name_exists = in_array( $metric['name'], $existing_names, true );
if ( $name_exists ) {
if ( 'skip' === $mode ) {
$result['skipped']++;
$result['messages'][] = sprintf(
/* translators: %s: Metric name */
__( 'Skipped "%s" (already exists).', 'wp-prometheus' ),
$metric['name']
);
continue;
} elseif ( 'rename' === $mode ) {
// Generate unique name.
$base_name = $metric['name'];
$counter = 1;
while ( in_array( $metric['name'], $existing_names, true ) ) {
$metric['name'] = $base_name . '_imported_' . $counter;
$counter++;
}
}
// 'overwrite' mode: continue with same name, will overwrite below.
}
// Clear ID to create new metric (unless overwriting).
if ( 'overwrite' === $mode && $name_exists ) {
// Find existing ID by name.
$metric['id'] = array_search( $metric['name'], $existing_names, true );
} else {
unset( $metric['id'] );
}
try {
$this->save( $metric );
$result['imported']++;
// Update existing names for subsequent collision checks.
$existing_names = array_column( $this->get_all(), 'name', 'id' );
} catch ( \InvalidArgumentException $e ) {
$result['errors']++;
$result['messages'][] = sprintf(
/* translators: 1: Metric name, 2: Error message */
__( 'Error importing "%1$s": %2$s', 'wp-prometheus' ),
$metric['name'],
$e->getMessage()
);
}
}
return $result;
}
/**
* Register custom metrics with the Collector.
*
* @param Collector $collector The metrics collector instance.
* @return void
*/
public function register_with_collector( Collector $collector ): void {
$metrics = $this->get_all();
foreach ( $metrics as $metric ) {
if ( empty( $metric['enabled'] ) ) {
continue;
}
try {
$gauge = $collector->register_gauge(
$metric['name'],
$metric['help'],
$metric['labels'] ?? array()
);
// Set values based on value type.
if ( 'option' === $metric['value_type'] ) {
// Option-based metric: read from WordPress option.
$option_name = $metric['value_config']['option_name'] ?? '';
$default = $metric['value_config']['default'] ?? 0;
if ( ! empty( $option_name ) ) {
$value = get_option( $option_name, $default );
$value = is_numeric( $value ) ? floatval( $value ) : $default;
// For option-based, use empty labels if no labels defined.
$label_values = array();
if ( ! empty( $metric['labels'] ) && ! empty( $metric['label_values'][0] ) ) {
// Use first row of labels (without the value).
$label_values = array_slice( $metric['label_values'][0], 0, count( $metric['labels'] ) );
}
$gauge->set( $value, $label_values );
}
} elseif ( 'static' === $metric['value_type'] ) {
// Static metric: use predefined label values.
if ( ! empty( $metric['label_values'] ) ) {
foreach ( $metric['label_values'] as $row ) {
if ( ! is_array( $row ) || count( $row ) < 1 ) {
continue;
}
// Last element is the value.
$value = array_pop( $row );
// Remaining elements are label values.
$gauge->set( floatval( $value ), $row );
}
} else {
// No labels, single value.
$gauge->set( 0, array() );
}
}
} catch ( \Exception $e ) {
// Log error but don't break metric collection.
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( sprintf( 'WP Prometheus: Failed to register custom metric "%s": %s', $metric['name'], $e->getMessage() ) );
}
}
}
}
}