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() ) ); } } } } }