1 Commits

Author SHA1 Message Date
73ba7fb929 Add Prometheus metrics integration (v0.7.4)
All checks were successful
Create Release Package / build-release (push) Successful in 1m8s
- New Metrics settings tab with enable/disable toggle
- PrometheusController for wp_prometheus_collect_metrics hook
- License gauges: total by status, lifetime, expiring, expiring soon
- Download gauges: total downloads, active versions
- API counters: requests, rate limits, validation errors
- Metric tracking in RestApiController and UpdateController

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 10:52:50 +01:00
10 changed files with 3722 additions and 3353 deletions

View File

@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.7.4] - 2026-02-03
### Added
- **Prometheus Metrics Integration**: Expose license and API metrics for monitoring
- New "Metrics" settings tab with enable/disable toggle
- License gauges: total by status, lifetime, expiring, expiring soon
- Download gauges: total downloads, active versions count
- API counters: requests by endpoint/result, rate limit exceeded events, validation errors by type
- Requires [WP Prometheus](https://src.bundespruefstelle.ch/magdev/wp-prometheus) plugin
### New Files
- `src/Metrics/PrometheusController.php` - Prometheus metrics collection and registration
### Technical Details
- Hooks into `wp_prometheus_collect_metrics` action for metric collection
- API counters stored persistently in WordPress options (`wclp_prometheus_counters`)
- Static methods for incrementing counters from API controllers
- Metrics only collected when enabled in settings
## [0.7.3] - 2026-02-01 ## [0.7.3] - 2026-02-01
### Fixed ### Fixed

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -65,6 +65,7 @@ final class SettingsController
'auto-updates' => __('Auto-Updates', 'wc-licensed-product'), 'auto-updates' => __('Auto-Updates', 'wc-licensed-product'),
'defaults' => __('Default Settings', 'wc-licensed-product'), 'defaults' => __('Default Settings', 'wc-licensed-product'),
'notifications' => __('Notifications', 'wc-licensed-product'), 'notifications' => __('Notifications', 'wc-licensed-product'),
'metrics' => __('Metrics', 'wc-licensed-product'),
]; ];
} }
@@ -116,6 +117,7 @@ final class SettingsController
'auto-updates' => $this->getAutoUpdatesSettings(), 'auto-updates' => $this->getAutoUpdatesSettings(),
'defaults' => $this->getDefaultsSettings(), 'defaults' => $this->getDefaultsSettings(),
'notifications' => $this->getNotificationsSettings(), 'notifications' => $this->getNotificationsSettings(),
'metrics' => $this->getMetricsSettings(),
default => $this->getPluginLicenseSettings(), default => $this->getPluginLicenseSettings(),
}; };
} }
@@ -314,6 +316,32 @@ final class SettingsController
]; ];
} }
/**
* Get metrics settings
*/
private function getMetricsSettings(): array
{
return [
'metrics_section_title' => [
'name' => __('Prometheus Metrics', 'wc-licensed-product'),
'type' => 'title',
'desc' => __('Expose license and API metrics for Prometheus monitoring. Requires the WP Prometheus plugin to be installed and active.', 'wc-licensed-product'),
'id' => 'wc_licensed_product_section_metrics',
],
'metrics_enabled' => [
'name' => __('Enable Prometheus Metrics', 'wc-licensed-product'),
'type' => 'checkbox',
'desc' => __('Expose license statistics, API usage, and download metrics via Prometheus.', 'wc-licensed-product'),
'id' => 'wc_licensed_product_metrics_enabled',
'default' => 'no',
],
'metrics_section_end' => [
'type' => 'sectionend',
'id' => 'wc_licensed_product_section_metrics_end',
],
];
}
/** /**
* Render settings tab content * Render settings tab content
*/ */
@@ -575,4 +603,12 @@ final class SettingsController
wp_send_json_error(['message' => $error]); wp_send_json_error(['message' => $error]);
} }
} }
/**
* Check if Prometheus metrics are enabled
*/
public static function isMetricsEnabled(): bool
{
return get_option('wc_licensed_product_metrics_enabled', 'no') === 'yes';
}
} }

View File

@@ -10,6 +10,7 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Api; namespace Jeremias\WcLicensedProduct\Api;
use Jeremias\WcLicensedProduct\License\LicenseManager; use Jeremias\WcLicensedProduct\License\LicenseManager;
use Jeremias\WcLicensedProduct\Metrics\PrometheusController;
use WP_REST_Request; use WP_REST_Request;
use WP_REST_Response; use WP_REST_Response;
use WP_REST_Server; use WP_REST_Server;
@@ -108,6 +109,10 @@ final class RestApiController
'retry_after' => $retryAfter, 'retry_after' => $retryAfter,
], 429); ], 429);
$response->header('Retry-After', (string) $retryAfter); $response->header('Retry-After', (string) $retryAfter);
// Track rate limit event for metrics
PrometheusController::incrementRateLimitExceeded('api');
return $response; return $response;
} }
@@ -209,6 +214,16 @@ final class RestApiController
$statusCode = $this->getStatusCodeForResult($result); $statusCode = $this->getStatusCodeForResult($result);
// Track metrics
if ($result['valid']) {
PrometheusController::incrementApiRequest('validate', 'success');
} else {
PrometheusController::incrementApiRequest('validate', 'error');
if (!empty($result['error'])) {
PrometheusController::incrementValidationError($result['error']);
}
}
return new WP_REST_Response($result, $statusCode); return new WP_REST_Response($result, $statusCode);
} }
@@ -247,6 +262,9 @@ final class RestApiController
$license = $this->licenseManager->getLicenseByKey($licenseKey); $license = $this->licenseManager->getLicenseByKey($licenseKey);
if (!$license) { if (!$license) {
PrometheusController::incrementApiRequest('status', 'error');
PrometheusController::incrementValidationError('license_not_found');
return new WP_REST_Response([ return new WP_REST_Response([
'valid' => false, 'valid' => false,
'error' => 'license_not_found', 'error' => 'license_not_found',
@@ -254,6 +272,8 @@ final class RestApiController
], 404); ], 404);
} }
PrometheusController::incrementApiRequest('status', 'success');
return new WP_REST_Response([ return new WP_REST_Response([
'valid' => $license->isValid(), 'valid' => $license->isValid(),
'status' => $license->getStatus(), 'status' => $license->getStatus(),
@@ -280,6 +300,9 @@ final class RestApiController
$license = $this->licenseManager->getLicenseByKey($licenseKey); $license = $this->licenseManager->getLicenseByKey($licenseKey);
if (!$license) { if (!$license) {
PrometheusController::incrementApiRequest('activate', 'error');
PrometheusController::incrementValidationError('license_not_found');
return new WP_REST_Response([ return new WP_REST_Response([
'success' => false, 'success' => false,
'error' => 'license_not_found', 'error' => 'license_not_found',
@@ -288,6 +311,9 @@ final class RestApiController
} }
if (!$license->isValid()) { if (!$license->isValid()) {
PrometheusController::incrementApiRequest('activate', 'error');
PrometheusController::incrementValidationError('license_invalid');
return new WP_REST_Response([ return new WP_REST_Response([
'success' => false, 'success' => false,
'error' => 'license_invalid', 'error' => 'license_invalid',
@@ -299,6 +325,8 @@ final class RestApiController
// Check if already activated on this domain // Check if already activated on this domain
if ($license->getDomain() === $normalizedDomain) { if ($license->getDomain() === $normalizedDomain) {
PrometheusController::incrementApiRequest('activate', 'success');
return new WP_REST_Response([ return new WP_REST_Response([
'success' => true, 'success' => true,
'message' => __('License is already activated for this domain.', 'wc-licensed-product'), 'message' => __('License is already activated for this domain.', 'wc-licensed-product'),
@@ -307,6 +335,9 @@ final class RestApiController
// Check if can activate on another domain // Check if can activate on another domain
if (!$license->canActivate()) { if (!$license->canActivate()) {
PrometheusController::incrementApiRequest('activate', 'error');
PrometheusController::incrementValidationError('max_activations_reached');
return new WP_REST_Response([ return new WP_REST_Response([
'success' => false, 'success' => false,
'error' => 'max_activations_reached', 'error' => 'max_activations_reached',
@@ -318,6 +349,9 @@ final class RestApiController
$success = $this->licenseManager->updateLicenseDomain($license->getId(), $domain); $success = $this->licenseManager->updateLicenseDomain($license->getId(), $domain);
if (!$success) { if (!$success) {
PrometheusController::incrementApiRequest('activate', 'error');
PrometheusController::incrementValidationError('activation_failed');
return new WP_REST_Response([ return new WP_REST_Response([
'success' => false, 'success' => false,
'error' => 'activation_failed', 'error' => 'activation_failed',
@@ -325,6 +359,8 @@ final class RestApiController
], 500); ], 500);
} }
PrometheusController::incrementApiRequest('activate', 'success');
return new WP_REST_Response([ return new WP_REST_Response([
'success' => true, 'success' => true,
'message' => __('License activated successfully.', 'wc-licensed-product'), 'message' => __('License activated successfully.', 'wc-licensed-product'),

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Api; namespace Jeremias\WcLicensedProduct\Api;
use Jeremias\WcLicensedProduct\License\LicenseManager; use Jeremias\WcLicensedProduct\License\LicenseManager;
use Jeremias\WcLicensedProduct\Metrics\PrometheusController;
use Jeremias\WcLicensedProduct\Product\VersionManager; use Jeremias\WcLicensedProduct\Product\VersionManager;
use Jeremias\WcLicensedProduct\Product\ProductVersion; use Jeremias\WcLicensedProduct\Product\ProductVersion;
use WP_REST_Request; use WP_REST_Request;
@@ -113,6 +114,10 @@ final class UpdateController
'retry_after' => $retryAfter, 'retry_after' => $retryAfter,
], 429); ], 429);
$response->header('Retry-After', (string) $retryAfter); $response->header('Retry-After', (string) $retryAfter);
// Track rate limit event for metrics
PrometheusController::incrementRateLimitExceeded('update-check');
return $response; return $response;
} }
@@ -179,10 +184,14 @@ final class UpdateController
$validationResult = $this->licenseManager->validateLicense($licenseKey, $domain); $validationResult = $this->licenseManager->validateLicense($licenseKey, $domain);
if (!$validationResult['valid']) { if (!$validationResult['valid']) {
$errorType = $validationResult['error'] ?? 'license_invalid';
PrometheusController::incrementApiRequest('update-check', 'error');
PrometheusController::incrementValidationError($errorType);
return new WP_REST_Response([ return new WP_REST_Response([
'success' => false, 'success' => false,
'update_available' => false, 'update_available' => false,
'error' => $validationResult['error'] ?? 'license_invalid', 'error' => $errorType,
'message' => $validationResult['message'] ?? __('License validation failed.', 'wc-licensed-product'), 'message' => $validationResult['message'] ?? __('License validation failed.', 'wc-licensed-product'),
], $validationResult['error'] === 'license_not_found' ? 404 : 403); ], $validationResult['error'] === 'license_not_found' ? 404 : 403);
} }
@@ -190,6 +199,9 @@ final class UpdateController
// Get license to access product ID // Get license to access product ID
$license = $this->licenseManager->getLicenseByKey($licenseKey); $license = $this->licenseManager->getLicenseByKey($licenseKey);
if (!$license) { if (!$license) {
PrometheusController::incrementApiRequest('update-check', 'error');
PrometheusController::incrementValidationError('license_not_found');
return new WP_REST_Response([ return new WP_REST_Response([
'success' => false, 'success' => false,
'update_available' => false, 'update_available' => false,
@@ -202,6 +214,9 @@ final class UpdateController
$product = wc_get_product($productId); $product = wc_get_product($productId);
if (!$product) { if (!$product) {
PrometheusController::incrementApiRequest('update-check', 'error');
PrometheusController::incrementValidationError('product_not_found');
return new WP_REST_Response([ return new WP_REST_Response([
'success' => false, 'success' => false,
'update_available' => false, 'update_available' => false,
@@ -214,6 +229,8 @@ final class UpdateController
$latestVersion = $this->getLatestVersionForLicense($license); $latestVersion = $this->getLatestVersionForLicense($license);
if (!$latestVersion) { if (!$latestVersion) {
PrometheusController::incrementApiRequest('update-check', 'success');
return new WP_REST_Response([ return new WP_REST_Response([
'success' => true, 'success' => true,
'update_available' => false, 'update_available' => false,
@@ -230,6 +247,8 @@ final class UpdateController
// Build response // Build response
$response = $this->buildUpdateResponse($product, $latestVersion, $license, $updateAvailable); $response = $this->buildUpdateResponse($product, $latestVersion, $license, $updateAvailable);
PrometheusController::incrementApiRequest('update-check', 'success');
return new WP_REST_Response($response); return new WP_REST_Response($response);
} }

View File

@@ -0,0 +1,259 @@
<?php
/**
* Prometheus Metrics Controller
*
* @package Jeremias\WcLicensedProduct\Metrics
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Metrics;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
use Jeremias\WcLicensedProduct\License\LicenseManager;
use Jeremias\WcLicensedProduct\Product\VersionManager;
/**
* Exposes license and API metrics for Prometheus monitoring
*/
final class PrometheusController
{
/**
* Option name for storing API counters
*/
private const COUNTERS_OPTION = 'wclp_prometheus_counters';
private LicenseManager $licenseManager;
private VersionManager $versionManager;
public function __construct(LicenseManager $licenseManager, VersionManager $versionManager)
{
$this->licenseManager = $licenseManager;
$this->versionManager = $versionManager;
}
/**
* Register hooks for Prometheus metrics collection
*/
public function register(): void
{
// Only register if metrics are enabled
if (!SettingsController::isMetricsEnabled()) {
return;
}
add_action('wp_prometheus_collect_metrics', [$this, 'collectMetrics']);
}
/**
* Collect and register all metrics
*
* @param object $collector The Prometheus collector object
*/
public function collectMetrics(object $collector): void
{
$this->collectLicenseMetrics($collector);
$this->collectDownloadMetrics($collector);
$this->collectApiMetrics($collector);
}
/**
* Collect license-related metrics
*/
private function collectLicenseMetrics(object $collector): void
{
$stats = $this->licenseManager->getStatistics();
// License count by status (gauge)
$licensesByStatus = $collector->register_gauge(
'wclp_licenses_total',
'Total number of licenses by status',
['status']
);
foreach ($stats['by_status'] as $status => $count) {
$licensesByStatus->set($count, [$status]);
}
// Lifetime licenses (gauge)
$lifetimeLicenses = $collector->register_gauge(
'wclp_licenses_lifetime_total',
'Total number of lifetime licenses'
);
$lifetimeLicenses->set($stats['lifetime']);
// Expiring licenses (gauge)
$expiringLicenses = $collector->register_gauge(
'wclp_licenses_expiring_total',
'Total number of licenses with expiration date'
);
$expiringLicenses->set($stats['expiring']);
// Licenses expiring soon - next 30 days (gauge)
$expiringSoon = $collector->register_gauge(
'wclp_licenses_expiring_soon',
'Licenses expiring within 30 days'
);
$expiringSoon->set($stats['expiring_soon']);
}
/**
* Collect download-related metrics
*/
private function collectDownloadMetrics(object $collector): void
{
$stats = $this->versionManager->getDownloadStatistics();
// Total downloads (gauge)
$totalDownloads = $collector->register_gauge(
'wclp_downloads_total',
'Total number of file downloads'
);
$totalDownloads->set($stats['total']);
// Active versions count (gauge)
$activeVersions = $collector->register_gauge(
'wclp_versions_active_total',
'Total number of active product versions'
);
$activeVersions->set($this->countActiveVersions());
}
/**
* Collect API-related metrics (counters)
*/
private function collectApiMetrics(object $collector): void
{
$counters = $this->getCounters();
// API requests by endpoint and result (counter)
$apiRequests = $collector->register_counter(
'wclp_api_requests_total',
'Total API requests by endpoint and result',
['endpoint', 'result']
);
foreach ($counters['api_requests'] ?? [] as $key => $count) {
[$endpoint, $result] = explode(':', $key);
$apiRequests->incBy($count, [$endpoint, $result]);
}
// Rate limit exceeded events (counter)
$rateLimitExceeded = $collector->register_counter(
'wclp_rate_limit_exceeded_total',
'Total rate limit exceeded events by endpoint',
['endpoint']
);
foreach ($counters['rate_limit'] ?? [] as $endpoint => $count) {
$rateLimitExceeded->incBy($count, [$endpoint]);
}
// Validation errors by type (counter)
$validationErrors = $collector->register_counter(
'wclp_validation_errors_total',
'Total validation errors by error type',
['error_type']
);
foreach ($counters['validation_errors'] ?? [] as $errorType => $count) {
$validationErrors->incBy($count, [$errorType]);
}
}
/**
* Count active product versions
*/
private function countActiveVersions(): int
{
global $wpdb;
$tableName = \Jeremias\WcLicensedProduct\Installer::getVersionsTable();
return (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$tableName} WHERE is_active = 1"
);
}
/**
* Get stored counters
*/
private function getCounters(): array
{
$counters = get_option(self::COUNTERS_OPTION, []);
return is_array($counters) ? $counters : [];
}
/**
* Increment an API request counter
*
* @param string $endpoint The API endpoint (validate, status, activate, update-check)
* @param string $result The result (success or error)
*/
public static function incrementApiRequest(string $endpoint, string $result): void
{
if (!SettingsController::isMetricsEnabled()) {
return;
}
$counters = get_option(self::COUNTERS_OPTION, []);
if (!is_array($counters)) {
$counters = [];
}
$key = "{$endpoint}:{$result}";
$counters['api_requests'][$key] = ($counters['api_requests'][$key] ?? 0) + 1;
update_option(self::COUNTERS_OPTION, $counters, false);
}
/**
* Increment rate limit exceeded counter
*
* @param string $endpoint The API endpoint
*/
public static function incrementRateLimitExceeded(string $endpoint): void
{
if (!SettingsController::isMetricsEnabled()) {
return;
}
$counters = get_option(self::COUNTERS_OPTION, []);
if (!is_array($counters)) {
$counters = [];
}
$counters['rate_limit'][$endpoint] = ($counters['rate_limit'][$endpoint] ?? 0) + 1;
update_option(self::COUNTERS_OPTION, $counters, false);
}
/**
* Increment validation error counter
*
* @param string $errorType The error type (license_not_found, domain_mismatch, etc.)
*/
public static function incrementValidationError(string $errorType): void
{
if (!SettingsController::isMetricsEnabled()) {
return;
}
$counters = get_option(self::COUNTERS_OPTION, []);
if (!is_array($counters)) {
$counters = [];
}
$counters['validation_errors'][$errorType] = ($counters['validation_errors'][$errorType] ?? 0) + 1;
update_option(self::COUNTERS_OPTION, $counters, false);
}
/**
* Reset all counters (useful for testing or maintenance)
*/
public static function resetCounters(): void
{
delete_option(self::COUNTERS_OPTION);
}
}

View File

@@ -26,6 +26,7 @@ use Jeremias\WcLicensedProduct\Frontend\AccountController;
use Jeremias\WcLicensedProduct\Frontend\DownloadController; use Jeremias\WcLicensedProduct\Frontend\DownloadController;
use Jeremias\WcLicensedProduct\License\LicenseManager; use Jeremias\WcLicensedProduct\License\LicenseManager;
use Jeremias\WcLicensedProduct\License\PluginLicenseChecker; use Jeremias\WcLicensedProduct\License\PluginLicenseChecker;
use Jeremias\WcLicensedProduct\Metrics\PrometheusController;
use Jeremias\WcLicensedProduct\Product\LicensedProductType; use Jeremias\WcLicensedProduct\Product\LicensedProductType;
use Jeremias\WcLicensedProduct\Product\VersionManager; use Jeremias\WcLicensedProduct\Product\VersionManager;
use Jeremias\WcLicensedProduct\Update\PluginUpdateChecker; use Jeremias\WcLicensedProduct\Update\PluginUpdateChecker;
@@ -171,6 +172,9 @@ final class Plugin
if (!empty($serverUrl) && !$licenseChecker->isSelfLicensing()) { if (!empty($serverUrl) && !$licenseChecker->isSelfLicensing()) {
PluginUpdateChecker::getInstance()->register(); PluginUpdateChecker::getInstance()->register();
} }
// Initialize Prometheus metrics if enabled
(new PrometheusController($this->licenseManager, $this->versionManager))->register();
} }
/** /**

View File

@@ -3,7 +3,7 @@
* Plugin Name: WooCommerce Licensed Product * Plugin Name: WooCommerce Licensed Product
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product * Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product
* Description: WooCommerce plugin to sell software products using license keys with domain-based validation. * Description: WooCommerce plugin to sell software products using license keys with domain-based validation.
* Version: 0.7.3 * Version: 0.7.4
* Author: Marco Graetsch * Author: Marco Graetsch
* Author URI: https://src.bundespruefstelle.ch/magdev * Author URI: https://src.bundespruefstelle.ch/magdev
* License: GPL-2.0-or-later * License: GPL-2.0-or-later
@@ -28,7 +28,7 @@ if (!defined('ABSPATH')) {
} }
// Plugin constants // Plugin constants
define('WC_LICENSED_PRODUCT_VERSION', '0.7.3'); define('WC_LICENSED_PRODUCT_VERSION', '0.7.4');
define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__); define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__);
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__)); define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));