From 7ff87f7c8d0ac9298f6bd67dd7aafca5411b270d Mon Sep 17 00:00:00 2001 From: magdev Date: Sun, 1 Feb 2026 15:31:21 +0100 Subject: [PATCH] Initial plugin setup (v0.0.1) - Create initial WordPress plugin structure - Add Prometheus metrics collector with default metrics - Implement authenticated /metrics endpoint with Bearer token - Add license management integration - Create admin settings page under Settings > Metrics - Set up Gitea CI/CD pipeline for automated releases - Add extensibility via wp_prometheus_collect_metrics hook Co-Authored-By: Claude Opus 4.5 --- .editorconfig | 15 + .gitea/workflows/release.yml | 195 ++++++++++++ .gitignore | 6 + .gitmodules | 3 + CHANGELOG.md | 32 ++ CLAUDE.md | 292 ++++++++++++++++++ PLAN.md | 190 ++++++++++++ README.md | 164 ++++++++++ assets/js/admin.js | 100 +++++++ composer.json | 61 ++++ index.php | 11 + lib/wc-licensed-product-client | 1 + src/Admin/Settings.php | 406 +++++++++++++++++++++++++ src/Endpoint/MetricsEndpoint.php | 148 +++++++++ src/Installer.php | 103 +++++++ src/License/Manager.php | 496 +++++++++++++++++++++++++++++++ src/Metrics/Collector.php | 288 ++++++++++++++++++ src/Plugin.php | 150 ++++++++++ src/index.php | 11 + uninstall.php | 18 ++ wp-prometheus.php | 200 +++++++++++++ 21 files changed, 2890 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 assets/js/admin.js create mode 100644 composer.json create mode 100644 index.php create mode 160000 lib/wc-licensed-product-client create mode 100644 src/Admin/Settings.php create mode 100644 src/Endpoint/MetricsEndpoint.php create mode 100644 src/Installer.php create mode 100644 src/License/Manager.php create mode 100644 src/Metrics/Collector.php create mode 100644 src/Plugin.php create mode 100644 src/index.php create mode 100644 uninstall.php create mode 100644 wp-prometheus.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..dd3fa9c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +indent_size = 2 \ No newline at end of file diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..ed1be41 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,195 @@ +name: Create Release Package + +on: + push: + tags: + - 'v*' + +jobs: + build-release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, xml, zip, intl, gettext + tools: composer:v2 + + - name: Get version from tag + id: version + run: | + VERSION=${GITHUB_REF_NAME#v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Building version: $VERSION" + + - name: Validate composer.json + run: composer validate --strict + + - name: Install Composer dependencies (production) + run: | + composer config platform.php 8.3.0 + composer install --no-dev --optimize-autoloader --no-interaction + + - name: Install gettext + run: apt-get update && apt-get install -y gettext + + - name: Compile translations + run: | + for po in languages/*.po; do + if [ -f "$po" ]; then + mo="${po%.po}.mo" + echo "Compiling $po to $mo" + msgfmt -o "$mo" "$po" + fi + done + + - name: Verify plugin version matches tag + run: | + PLUGIN_VERSION=$(grep -oP "Version:\s*\K[0-9]+\.[0-9]+\.[0-9]+" wp-prometheus.php | head -1) + TAG_VERSION=${{ steps.version.outputs.version }} + if [ "$PLUGIN_VERSION" != "$TAG_VERSION" ]; then + echo "Error: Plugin version ($PLUGIN_VERSION) does not match tag version ($TAG_VERSION)" + exit 1 + fi + echo "Version verified: $PLUGIN_VERSION" + + - name: Create release directory + run: mkdir -p releases + + - name: Build release package + run: | + VERSION=${{ steps.version.outputs.version }} + PLUGIN_NAME="wp-prometheus" + RELEASE_FILE="releases/${PLUGIN_NAME}-${VERSION}.zip" + + # Move to parent directory for proper zip structure + cd .. + + # Create zip with proper WordPress plugin structure + zip -r "${PLUGIN_NAME}/${RELEASE_FILE}" "${PLUGIN_NAME}" \ + -x "${PLUGIN_NAME}/.git/*" \ + -x "${PLUGIN_NAME}/.gitea/*" \ + -x "${PLUGIN_NAME}/.github/*" \ + -x "${PLUGIN_NAME}/.vscode/*" \ + -x "${PLUGIN_NAME}/.claude/*" \ + -x "${PLUGIN_NAME}/CLAUDE.md" \ + -x "${PLUGIN_NAME}/PLAN.md" \ + -x "${PLUGIN_NAME}/wp-core" \ + -x "${PLUGIN_NAME}/wp-core/*" \ + -x "${PLUGIN_NAME}/wp-plugins" \ + -x "${PLUGIN_NAME}/wp-plugins/*" \ + -x "${PLUGIN_NAME}/releases/*" \ + -x "${PLUGIN_NAME}/composer.lock" \ + -x "${PLUGIN_NAME}/*.log" \ + -x "${PLUGIN_NAME}/.gitignore" \ + -x "${PLUGIN_NAME}/.gitmodules" \ + -x "${PLUGIN_NAME}/.editorconfig" \ + -x "${PLUGIN_NAME}/phpcs.xml*" \ + -x "${PLUGIN_NAME}/phpunit.xml*" \ + -x "${PLUGIN_NAME}/tests/*" \ + -x "${PLUGIN_NAME}/*.po~" \ + -x "${PLUGIN_NAME}/*.bak" \ + -x "*.DS_Store" + + cd "${PLUGIN_NAME}" + echo "Created: ${RELEASE_FILE}" + + - name: Generate checksums + run: | + VERSION=${{ steps.version.outputs.version }} + RELEASE_FILE="releases/wp-prometheus-${VERSION}.zip" + + cd releases + sha256sum "wp-prometheus-${VERSION}.zip" > "wp-prometheus-${VERSION}.zip.sha256" + + echo "SHA256:" + cat "wp-prometheus-${VERSION}.zip.sha256" + + - name: Verify package structure + run: | + set +o pipefail + VERSION=${{ steps.version.outputs.version }} + echo "Package contents:" + unzip -l "releases/wp-prometheus-${VERSION}.zip" | head -50 || true + + # Verify main file is at correct location + if unzip -l "releases/wp-prometheus-${VERSION}.zip" | grep -q "wp-prometheus/wp-prometheus.php"; then + echo "✓ Main plugin file at correct location" + else + echo "✗ Error: Main plugin file not found at wp-prometheus/wp-prometheus.php" + exit 1 + fi + + # Verify vendor directory is included + if unzip -l "releases/wp-prometheus-${VERSION}.zip" | grep -q "wp-prometheus/vendor/"; then + echo "✓ Vendor directory included" + else + echo "✗ Error: Vendor directory not found" + exit 1 + fi + + - name: Extract changelog for release notes + id: changelog + run: | + VERSION=${{ steps.version.outputs.version }} + # Extract changelog section for this version + NOTES=$(sed -n "/^## \[${VERSION}\]/,/^## \[/p" CHANGELOG.md | sed '$ d' | tail -n +2) + if [ -z "$NOTES" ]; then + NOTES="Release version ${VERSION}" + fi + # Save to file for multi-line output + echo "$NOTES" > release_notes.txt + echo "Release notes extracted" + + - name: Create Gitea Release + env: + GITEA_TOKEN: ${{ secrets.SRC_GITEA_TOKEN }} + run: | + VERSION=${{ steps.version.outputs.version }} + TAG_NAME=${{ github.ref_name }} + PRERELEASE="false" + if [[ "$TAG_NAME" == *-* ]]; then + PRERELEASE="true" + fi + + # Read release notes + BODY=$(cat release_notes.txt) + + # Create release via Gitea API + RELEASE_RESPONSE=$(curl -s -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\": \"${TAG_NAME}\", \"name\": \"Release ${VERSION}\", \"body\": $(echo "$BODY" | jq -Rs .), \"draft\": false, \"prerelease\": ${PRERELEASE}}" \ + "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases") + + RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id') + + if [ "$RELEASE_ID" == "null" ] || [ -z "$RELEASE_ID" ]; then + echo "Failed to create release:" + echo "$RELEASE_RESPONSE" + exit 1 + fi + + echo "Created release ID: $RELEASE_ID" + + # Upload attachments + for file in "releases/wp-prometheus-${VERSION}.zip" "releases/wp-prometheus-${VERSION}.zip.sha256"; do + if [ -f "$file" ]; then + FILENAME=$(basename "$file") + echo "Uploading $FILENAME..." + curl -s -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$file" \ + "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${FILENAME}" + echo "Uploaded $FILENAME" + fi + done + + echo "Release created successfully: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG_NAME}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..445e2e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# For development purposes +# Linked wordpress core and plugin folder +wp-plugins +wp-core +vendor/ +releases/* diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..406ab93 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/wc-licensed-product-client"] + path = lib/wc-licensed-product-client + url = ../wc-licensed-product-client.git diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e0d7dda --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.0.1] - 2026-02-01 + +### Added + +- Initial plugin structure and bootstrap +- Main plugin class with singleton pattern +- License management integration with wc-licensed-product-client +- Prometheus metrics collector with default WordPress metrics: + - `wordpress_info` - WordPress installation information + - `wordpress_users_total` - Total users by role + - `wordpress_posts_total` - Total posts by type and status + - `wordpress_comments_total` - Total comments by status + - `wordpress_plugins_total` - Total plugins by status +- Authenticated `/metrics` endpoint with Bearer token +- Admin settings page under Settings > Metrics +- Extensibility via `wp_prometheus_collect_metrics` action hook +- Gitea CI/CD pipeline for automated releases +- Comprehensive documentation (README.md, PLAN.md, CLAUDE.md) + +### Security + +- Bearer token authentication for metrics endpoint +- Nonce verification for all AJAX requests +- Capability checks for admin operations +- Input sanitization and output escaping diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3e382e9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,292 @@ +# WP Prometheus + +**Author:** Marco Graetsch +**Author URL:** +**Author Email:** +**Repository URL:** +**Issues URL:** + +## Project Overview + +This plugin provides a Prometheus `/metrics` endpoint and an extensible way to add your own metrics in third-party plugins using hooks. It adds some default metrics like number of active accounts, number of articles, comments, and plugin status. The default metrics can be activated/deactivated in the plugin settings. + +### Features + +- Prometheus compatible authenticated `/metrics` endpoint +- Optional default metrics (users, posts, comments, plugins) +- Dedicated plugin settings under 'Settings/Metrics' menu +- Extensible by other plugins using `wp_prometheus_collect_metrics` action hook +- License management integration + +### Key Fact: 100% AI-Generated + +This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase was created through AI assistance. + +## Temporary Roadmap + +**Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file. + +### Version 0.1.0 (Planned) + +- Add request/response timing metrics +- Add HTTP status code counters +- Add database query metrics + +## Technical Stack + +- **Language:** PHP 8.3.x +- **PHP-Standards:** PSR-4 +- **Framework:** Latest WordPress Plugin API +- **Styling:** Custom CSS +- **Dependency Management:** Composer +- **Internationalization:** WordPress i18n (.pot/.po/.mo files) +- **Canonical Plugin Name:** `wp-prometheus` +- **License Client:** `magdev/wc-licensed-product-client` Have a look at for a working admin integration +- **Prometheus Client:** `promphp/prometheus_client_php` + +### Security Best Practices + +- All user inputs are sanitized (integers for quantities/prices) +- Nonce verification on form submissions +- Output escaping in templates (`esc_attr`, `esc_html`, `esc_js`) +- Direct file access prevention via `ABSPATH` check +- XSS-safe DOM construction in JavaScript (no `innerHTML` with user data) +- SQL injection prevention using `$wpdb->prepare()` throughout + +### Translation Ready + +All user-facing strings use: + +```php +__('Text to translate', 'wp-prometheus') +_e('Text to translate', 'wp-prometheus') +``` + +Text domain: `wp-prometheus` + +#### Translation Template + +- Base `.pot` file created: `languages/wp-prometheus.pot` +- Ready for translation to any locale +- All translatable strings properly marked with text domain + +#### Available Translations + +- `en_US` - English (United States) [base language - .pot template] +- `de_CH` - German (Switzerland, formal) + +To compile translations to .mo files for production: + +```bash +for po in languages/*.po; do msgfmt -o "${po%.po}.mo" "$po"; done +``` + +### Create releases + +- The `vendor/` directory MUST be included in releases (Dependencies required for runtime) +- **CRITICAL**: Build `vendor/` for the MINIMUM supported PHP version, not the development version + - Use `composer config platform.php 8.3.0` before building release packages + - Run `composer update --no-dev --optimize-autoloader` to rebuild dependencies +- **CRITICAL**: WordPress requires plugins in a subdirectory structure + - Run zip from the `plugins/` parent directory, NOT from within the plugin directory + - Package must extract to `wp-prometheus/` subdirectory with main file at `wp-prometheus/wp-prometheus.php` + - Correct command: `cd /wp-content/plugins/ && zip -r wp-prometheus/releases/wp-prometheus-x.x.x.zip wp-prometheus ...` + - Wrong: Running zip from inside the plugin directory creates files at root level +- **CRITICAL**: Exclude symlinks explicitly - zip follows symlinks by default + - Always use `-x "wp-prometheus/wp-core" -x "wp-prometheus/wp-core/*" -x "wp-prometheus/wp-plugins" -x "wp-prometheus/wp-plugins/*"` to exclude development symlinks + - Otherwise the entire linked directory contents will be included in the package +- Exclusion patterns must match the relative path structure used in zip command +- Always verify the package structure with `unzip -l` before distribution + - Check all files are prefixed with `wp-prometheus/` + - Verify main file is at `wp-prometheus/wp-prometheus.php` + - Check for duplicate entries (indicates multiple builds in same archive) +- Test installation on the minimum supported PHP version before final deployment +- Releases are stored in `releases/` including checksums +- Track release changes in a single `CHANGELOG.md` file +- Bump the version number to either bugfix release versions or on new features minor release versions +- **CRITICAL**: WordPress reads version from TWO places - BOTH must be updated: + 1. Plugin header comment `Version: x.x.x` - WordPress uses THIS for admin display + 2. PHP constant `WP_PROMETHEUS_VERSION` (line ~28) - Used internally by the plugin + - If only the constant is updated, WordPress will show the old version in Plugins list + +**Important Git Notes:** + +- Default branch while development is `dev` +- Create releases from branch `main` after merging branch `dev` +- Tags should use format `vX.X.X` (e.g., `v1.1.22`), start with v0.0.1 +- Use annotated tags (`-a`) not lightweight tags +- Commit messages should follow the established format with Claude Code attribution +- `.claude/settings.local.json` changes are typically local-only (stash before rebasing) + +**CRITICAL - Release Workflow:** + +On every new version, ALWAYS execute this complete workflow: + +```bash +# 1. Commit changes to dev branch +git add +git commit -m "Description of changes (vX.X.X)" + +# 2. Merge dev to main +git checkout main +git merge dev --no-edit + +# 3. Create annotated tag +git tag -a vX.X.X -m "Version X.X.X - Brief description" + +# 4. Push everything to origin +git push origin dev main vX.X.X + +# 5. Switch back to dev for continued development +git checkout dev +``` + +Never skip any of these steps. The release is not complete until all branches and the tag are pushed to origin. + +#### What Gets Released + +- All plugin source files +- Compiled vendor dependencies +- Translation files (.mo compiled from .po) +- Assets (CSS, JS) +- Documentation (README, CHANGELOG, etc.) + +#### What's Excluded + +- Git metadata (`.git/`) +- Development files (`.vscode/`, `.claude/`, `CLAUDE.md`, `wp-core`, `wp-plugins`) +- Logs and cache files +- Previous releases +- `composer.lock` (but `vendor/` is included) + +--- + +**For AI Assistants:** + +When starting a new session on this project: + +1. Read this CLAUDE.md file first +2. Semantic versioning follows the `MAJOR.MINOR.BUGFIX` pattern +3. Check git log for recent changes +4. Verify you're on the `dev` branch before making changes +5. Run `composer install` if vendor/ is missing +6. Test changes before committing +7. Follow commit message format with Claude Code attribution +8. Update this session history section with learnings +9. Never commit backup files (`*.po~`, `*.bak`, etc.) - check `git status` before committing +10. Follow markdown linting rules (see below) + +Always refer to this document when starting work on this project. + +### Markdown Linting Rules + +When editing CLAUDE.md or other markdown files, follow these rules to avoid linting errors: + +1. **MD031 - Blank lines around fenced code blocks**: Always add a blank line before and after fenced code blocks, even when they follow list items. Example of correct format: + + - **Item label**: + + (blank line here) + \`\`\`php + code example + \`\`\` + (blank line here) + +2. **MD056 - Table column count**: Table separators must have matching column counts and proper spacing. Use consistent dash lengths that match column header widths. +3. **MD009 - No trailing spaces**: Remove trailing whitespace from lines +4. **MD012 - No multiple consecutive blank lines**: Use only single blank lines between sections +5. **MD040 - Fenced code blocks should have a language specified**: Always add a language identifier to code blocks (e.g., `txt`, `bash`, `php`). For shortcode examples, use `txt`. +6. **MD032 - Lists should be surrounded by blank lines**: Add a blank line before AND after list blocks, including after bold labels like `**Attributes:**`. +7. **MD034 - Bare URLs**: Wrap URLs in angle brackets (e.g., ``) or use markdown link syntax `[text](url)`. +8. **Author section formatting**: Use a heading (`### Name`) instead of bold (`**Name**`) for the author name to maintain consistent document structure. + +## Project Architecture + +### Directory Structure + +```txt +wp-prometheus/ +├── .gitea/workflows/ +│ └── release.yml # CI/CD pipeline +├── assets/ +│ ├── css/ # Admin/Frontend styles +│ └── js/ +│ └── admin.js # Admin JavaScript +├── languages/ # Translation files +├── lib/ +│ └── wc-licensed-product-client/ # Git submodule +├── releases/ # Release packages +├── src/ +│ ├── Admin/ +│ │ └── Settings.php # Settings page +│ ├── Endpoint/ +│ │ └── MetricsEndpoint.php # /metrics endpoint +│ ├── License/ +│ │ └── Manager.php # License management +│ ├── Metrics/ +│ │ └── Collector.php # Prometheus metrics collector +│ ├── Installer.php # Activation/Deactivation +│ ├── Plugin.php # Main plugin class +│ └── index.php +├── CHANGELOG.md +├── CLAUDE.md +├── composer.json +├── index.php +├── PLAN.md +├── README.md +├── uninstall.php +└── wp-prometheus.php # Plugin bootstrap +``` + +### Implementation Details + +#### License Manager (`src/License/Manager.php`) + +- Integration with `SecureLicenseClient` or `LicenseClient` +- Option storage for license key, server URL, server secret +- License validation with domain binding +- License activation with domain +- Status caching (24-hour transient) +- AJAX handlers for admin operations +- Exception handling for all license states + +#### Metrics Endpoint Restriction Logic + +```php +// In Plugin::init_components() +if ( LicenseManager::is_license_valid() ) { + $this->collector = new Collector(); + new MetricsEndpoint( $this->collector ); +} +``` + +Admin settings always work; metrics endpoint requires valid license. + +#### Custom Metrics Extension + +```php +// Third-party plugins can add custom metrics +add_action( 'wp_prometheus_collect_metrics', function( $collector ) { + $gauge = $collector->register_gauge( + 'my_custom_metric', + 'Description of my metric', + array( 'label1', 'label2' ) + ); + $gauge->set( 42, array( 'value1', 'value2' ) ); +} ); +``` + +--- + +## Session History + +### 2026-02-01 - Initial Setup (v0.0.1) + +- Created initial plugin structure based on wp-fedistream blueprint +- Set up composer.json with promphp/prometheus_client_php and wc-licensed-product-client +- Implemented core classes: Plugin, Installer, License/Manager, Metrics/Collector, Endpoint/MetricsEndpoint, Admin/Settings +- Created authenticated /metrics endpoint with Bearer token support +- Added default metrics: wordpress_info, users_total, posts_total, comments_total, plugins_total +- Created extensibility via `wp_prometheus_collect_metrics` action hook +- Set up Gitea CI/CD pipeline for automated releases +- Created documentation: README.md, PLAN.md, CHANGELOG.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..6ad93df --- /dev/null +++ b/PLAN.md @@ -0,0 +1,190 @@ +# WP Prometheus Implementation Plan + +## Overview + +This document outlines the implementation plan for the WP Prometheus plugin, providing a Prometheus-compatible `/metrics` endpoint for WordPress. + +## Architecture + +### Core Components + +1. **Plugin Bootstrap** (`wp-prometheus.php`) + - WordPress plugin header + - Version constants + - PHP/WordPress version checks + - Autoloader initialization + - Activation/Deactivation hooks + +2. **Plugin Class** (`src/Plugin.php`) + - Singleton pattern + - Component initialization + - Hook registration + - Text domain loading + +3. **Installer** (`src/Installer.php`) + - Activation logic + - Default options setup + - Rewrite rules flushing + - Uninstallation cleanup + +4. **License Manager** (`src/License/Manager.php`) + - Integration with wc-licensed-product-client + - License validation/activation + - Status caching (24-hour transient) + - AJAX handlers for admin actions + +5. **Metrics Collector** (`src/Metrics/Collector.php`) + - Prometheus CollectorRegistry wrapper + - Default WordPress metrics + - Custom metric registration hooks + - Extensibility via `wp_prometheus_collect_metrics` action + +6. **Metrics Endpoint** (`src/Endpoint/MetricsEndpoint.php`) + - Custom rewrite rule for `/metrics/` + - Bearer token authentication + - Prometheus text format output + - Cache control headers + +7. **Admin Settings** (`src/Admin/Settings.php`) + - Settings page under Settings > Metrics + - License configuration form + - Auth token management + - Metric toggle checkboxes + +### Directory Structure + +```txt +wp-prometheus/ +├── .gitea/workflows/ +│ └── release.yml # CI/CD pipeline +├── assets/ +│ ├── css/ # Admin/Frontend styles +│ └── js/ +│ └── admin.js # Admin JavaScript +├── languages/ # Translation files +├── lib/ +│ └── wc-licensed-product-client/ # Git submodule +├── releases/ # Release packages +├── src/ +│ ├── Admin/ +│ │ └── Settings.php +│ ├── Endpoint/ +│ │ └── MetricsEndpoint.php +│ ├── License/ +│ │ └── Manager.php +│ ├── Metrics/ +│ │ └── Collector.php +│ ├── Installer.php +│ ├── Plugin.php +│ └── index.php +├── CHANGELOG.md +├── CLAUDE.md +├── composer.json +├── index.php +├── PLAN.md +├── README.md +├── uninstall.php +└── wp-prometheus.php +``` + +## Default Metrics + +The plugin provides the following default metrics (can be toggled in settings): + +| Metric | Type | Labels | Description | +|--------|------|--------|-------------| +| wordpress_info | Gauge | version, php_version, multisite | WordPress installation info | +| wordpress_users_total | Gauge | role | Total users by role | +| wordpress_posts_total | Gauge | post_type, status | Total posts by type and status | +| wordpress_comments_total | Gauge | status | Total comments by status | +| wordpress_plugins_total | Gauge | status | Total plugins (active/inactive) | + +## Extensibility + +### Adding Custom Metrics + +Third-party plugins can add custom metrics using the `wp_prometheus_collect_metrics` action: + +```php +add_action( 'wp_prometheus_collect_metrics', function( $collector ) { + // Register a custom gauge + $gauge = $collector->register_gauge( + 'my_custom_metric', + 'Description of my metric', + array( 'label1', 'label2' ) + ); + + // Set the value + $gauge->set( 42, array( 'value1', 'value2' ) ); +} ); +``` + +### Available Methods + +- `$collector->register_gauge( $name, $help, $labels )` +- `$collector->register_counter( $name, $help, $labels )` +- `$collector->register_histogram( $name, $help, $labels, $buckets )` + +## Authentication + +The `/metrics` endpoint requires authentication using a Bearer token: + +```yaml +# Prometheus configuration +scrape_configs: + - job_name: 'wordpress' + static_configs: + - targets: ['example.com'] + metrics_path: '/metrics/' + scheme: 'https' + authorization: + type: Bearer + credentials: 'your-auth-token' +``` + +Alternatively, the token can be passed as a query parameter (for testing): + +```txt +https://example.com/metrics/?token=your-auth-token +``` + +## Future Enhancements + +### Version 0.1.0 + +- Request/Response timing metrics +- HTTP status code counters +- Database query metrics + +### Version 0.2.0 + +- WooCommerce integration metrics +- Cron job metrics +- Transient cache metrics + +### Version 0.3.0 + +- Custom metric builder in admin +- Metric export/import +- Grafana dashboard templates + +## Dependencies + +- PHP 8.3+ +- WordPress 6.4+ +- Composer packages: + - `promphp/prometheus_client_php` - Prometheus client library + - `magdev/wc-licensed-product-client` - License validation + +## Security Considerations + +1. Auth token stored securely in WordPress options +2. Bearer token authentication for metrics endpoint +3. Admin capability check for settings +4. Nonce verification for AJAX requests +5. Input sanitization and output escaping +6. Direct file access prevention + +## License + +GPL v2 or later diff --git a/README.md b/README.md new file mode 100644 index 0000000..febf916 --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# WP Prometheus + +A WordPress plugin that provides a Prometheus-compatible `/metrics` endpoint with extensible hooks for custom metrics. + +## Features + +- Prometheus-compatible authenticated `/metrics` endpoint +- Default WordPress metrics (users, posts, comments, plugins) +- Extensible by other plugins using hooks +- Settings page under Settings > Metrics +- Bearer token authentication +- License management integration + +## Requirements + +- PHP 8.3 or higher +- WordPress 6.4 or higher +- Composer (for development) + +## Installation + +### From Release Package + +1. Download the latest release from the [releases page](https://src.bundespruefstelle.ch/magdev/wp-prometheus/releases) +2. Upload the zip file via Plugins > Add New > Upload Plugin +3. Activate the plugin +4. Configure settings under Settings > Metrics + +### From Source + +1. Clone the repository to your WordPress plugins directory: + + ```bash + cd wp-content/plugins/ + git clone https://src.bundespruefstelle.ch/magdev/wp-prometheus.git + cd wp-prometheus + git submodule update --init --recursive + composer install + ``` + +2. Activate the plugin in WordPress admin + +## Configuration + +### License + +1. Go to Settings > Metrics +2. Enter your license server URL, license key, and server secret +3. Click "Save License Settings" +4. Click "Validate License" or "Activate License" + +### Authentication Token + +A random auth token is generated on activation. You can view it or regenerate it in Settings > Metrics. + +### Prometheus Configuration + +Add the following to your `prometheus.yml`: + +```yaml +scrape_configs: + - job_name: 'wordpress' + static_configs: + - targets: ['your-wordpress-site.com'] + metrics_path: '/metrics/' + scheme: 'https' + authorization: + type: Bearer + credentials: 'your-auth-token-from-settings' +``` + +## Default Metrics + +| Metric | Type | Labels | Description | +|--------|------|--------|-------------| +| wordpress_info | Gauge | version, php_version, multisite | WordPress installation info | +| wordpress_users_total | Gauge | role | Total users by role | +| wordpress_posts_total | Gauge | post_type, status | Total posts by type and status | +| wordpress_comments_total | Gauge | status | Total comments by status | +| wordpress_plugins_total | Gauge | status | Total plugins (active/inactive) | + +## Extending with Custom Metrics + +Add your own metrics using the `wp_prometheus_collect_metrics` action: + +```php +add_action( 'wp_prometheus_collect_metrics', function( $collector ) { + // Register a gauge + $gauge = $collector->register_gauge( + 'my_custom_metric', + 'Description of my metric', + array( 'label1', 'label2' ) + ); + + // Set value with labels + $gauge->set( 42, array( 'value1', 'value2' ) ); +} ); +``` + +### Available Methods + +```php +// Gauge (can go up and down) +$gauge = $collector->register_gauge( $name, $help, $labels ); +$gauge->set( $value, $labelValues ); + +// Counter (only goes up) +$counter = $collector->register_counter( $name, $help, $labels ); +$counter->inc( $labelValues ); +$counter->incBy( $amount, $labelValues ); + +// Histogram (for distributions) +$histogram = $collector->register_histogram( $name, $help, $labels, $buckets ); +$histogram->observe( $value, $labelValues ); +``` + +## Development + +### Build for Release + +```bash +# Set PHP platform version +composer config platform.php 8.3.0 + +# Install production dependencies +composer install --no-dev --optimize-autoloader + +# Compile translations +for po in languages/*.po; do msgfmt -o "${po%.po}.mo" "$po"; done +``` + +### Create Release Package + +From the plugins directory (parent of wp-prometheus): + +```bash +cd /wp-content/plugins/ +zip -r wp-prometheus/releases/wp-prometheus-x.x.x.zip wp-prometheus \ + -x "wp-prometheus/.git/*" \ + -x "wp-prometheus/.gitea/*" \ + -x "wp-prometheus/.claude/*" \ + -x "wp-prometheus/CLAUDE.md" \ + -x "wp-prometheus/wp-core" \ + -x "wp-prometheus/wp-plugins" \ + -x "wp-prometheus/releases/*" \ + -x "wp-prometheus/composer.lock" \ + -x "*.DS_Store" +``` + +## Author + +**Marco Graetsch** + +- Website: +- Email: magdev3.0@gmail.com + +## License + +This plugin is licensed under the GPL v2 or later. + +## Credits + +- [PromPHP/prometheus_client_php](https://github.com/PromPHP/prometheus_client_php) - Prometheus client library +- Built with Claude AI assistance diff --git a/assets/js/admin.js b/assets/js/admin.js new file mode 100644 index 0000000..eb91125 --- /dev/null +++ b/assets/js/admin.js @@ -0,0 +1,100 @@ +/** + * WP Prometheus Admin JavaScript + * + * @package WP_Prometheus + */ + +(function($) { + 'use strict'; + + $(document).ready(function() { + // Validate license button. + $('#wp-prometheus-validate-license').on('click', function(e) { + e.preventDefault(); + performLicenseAction('wp_prometheus_validate_license', 'Validating...'); + }); + + // Activate license button. + $('#wp-prometheus-activate-license').on('click', function(e) { + e.preventDefault(); + performLicenseAction('wp_prometheus_activate_license', 'Activating...'); + }); + + // Regenerate token button. + $('#wp-prometheus-regenerate-token').on('click', function(e) { + e.preventDefault(); + if (confirm('Are you sure you want to regenerate the auth token? You will need to update your Prometheus configuration.')) { + var newToken = generateToken(32); + $('#wp_prometheus_auth_token').val(newToken); + } + }); + + /** + * Perform a license action via AJAX. + * + * @param {string} action AJAX action name. + * @param {string} message Loading message. + */ + function performLicenseAction(action, message) { + var $spinner = $('#wp-prometheus-license-spinner'); + var $message = $('#wp-prometheus-license-message'); + + $spinner.addClass('is-active'); + $message.hide(); + + $.ajax({ + url: wpPrometheus.ajaxUrl, + type: 'POST', + data: { + action: action, + nonce: wpPrometheus.nonce + }, + success: function(response) { + $spinner.removeClass('is-active'); + + if (response.success) { + $message + .removeClass('notice-error') + .addClass('notice notice-success') + .html('

' + response.data.message + '

') + .show(); + + // Reload page after successful validation/activation. + setTimeout(function() { + location.reload(); + }, 1500); + } else { + $message + .removeClass('notice-success') + .addClass('notice notice-error') + .html('

' + (response.data.message || 'An error occurred.') + '

') + .show(); + } + }, + error: function() { + $spinner.removeClass('is-active'); + $message + .removeClass('notice-success') + .addClass('notice notice-error') + .html('

Connection error. Please try again.

') + .show(); + } + }); + } + + /** + * Generate a random token. + * + * @param {number} length Token length. + * @return {string} Generated token. + */ + function generateToken(length) { + var charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + var token = ''; + for (var i = 0; i < length; i++) { + token += charset.charAt(Math.floor(Math.random() * charset.length)); + } + return token; + } + }); +})(jQuery); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b4a3820 --- /dev/null +++ b/composer.json @@ -0,0 +1,61 @@ +{ + "name": "magdev/wp-prometheus", + "description": "WordPress Prometheus metrics endpoint with extensible hooks for custom metrics", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "authors": [ + { + "name": "Marco Graetsch", + "email": "magdev3.0@gmail.com", + "homepage": "https://src.bundespruefstelle.ch/magdev" + } + ], + "homepage": "https://src.bundespruefstelle.ch/magdev/wp-prometheus", + "support": { + "issues": "https://src.bundespruefstelle.ch/magdev/wp-prometheus/issues" + }, + "repositories": [ + { + "type": "path", + "url": "lib/wc-licensed-product-client" + } + ], + "require": { + "php": ">=8.3", + "magdev/wc-licensed-product-client": "dev-main", + "promphp/prometheus_client_php": "^2.10" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "squizlabs/php_codesniffer": "^3.7", + "wp-coding-standards/wpcs": "^3.0", + "phpcompatibility/phpcompatibility-wp": "*" + }, + "autoload": { + "psr-4": { + "Magdev\\WpPrometheus\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Magdev\\WpPrometheus\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + }, + "optimize-autoloader": true, + "sort-packages": true, + "platform": { + "php": "8.3.0" + } + }, + "scripts": { + "phpcs": "phpcs", + "phpcbf": "phpcbf", + "test": "phpunit" + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..bf9d07a --- /dev/null +++ b/index.php @@ -0,0 +1,11 @@ + 'string', + 'sanitize_callback' => 'sanitize_text_field', + ) ); + + register_setting( 'wp_prometheus_settings', 'wp_prometheus_enabled_metrics', array( + 'type' => 'array', + 'sanitize_callback' => array( $this, 'sanitize_metrics' ), + ) ); + + // Auth token field. + add_settings_field( + 'wp_prometheus_auth_token', + __( 'Auth Token', 'wp-prometheus' ), + array( $this, 'render_auth_token_field' ), + 'wp-prometheus', + 'wp_prometheus_auth_section' + ); + + // Enabled metrics field. + add_settings_field( + 'wp_prometheus_enabled_metrics', + __( 'Enabled Metrics', 'wp-prometheus' ), + array( $this, 'render_enabled_metrics_field' ), + 'wp-prometheus', + 'wp_prometheus_metrics_section' + ); + } + + /** + * Enqueue admin scripts. + * + * @param string $hook_suffix Current admin page. + * @return void + */ + public function enqueue_scripts( string $hook_suffix ): void { + if ( 'settings_page_wp-prometheus' !== $hook_suffix ) { + return; + } + + wp_enqueue_script( + 'wp-prometheus-admin', + WP_PROMETHEUS_URL . 'assets/js/admin.js', + array( 'jquery' ), + WP_PROMETHEUS_VERSION, + true + ); + + wp_localize_script( 'wp-prometheus-admin', 'wpPrometheus', array( + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'wp_prometheus_license_action' ), + ) ); + } + + /** + * Render the settings page. + * + * @return void + */ + public function render_settings_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + // Handle license settings save. + if ( isset( $_POST['wp_prometheus_license_nonce'] ) && wp_verify_nonce( sanitize_key( $_POST['wp_prometheus_license_nonce'] ), 'wp_prometheus_save_license' ) ) { + LicenseManager::save_settings( array( + 'license_key' => isset( $_POST['license_key'] ) ? sanitize_text_field( wp_unslash( $_POST['license_key'] ) ) : '', + 'server_url' => isset( $_POST['license_server_url'] ) ? esc_url_raw( wp_unslash( $_POST['license_server_url'] ) ) : '', + 'server_secret' => isset( $_POST['license_server_secret'] ) ? sanitize_text_field( wp_unslash( $_POST['license_server_secret'] ) ) : '', + ) ); + echo '

' . esc_html__( 'License settings saved.', 'wp-prometheus' ) . '

'; + } + + ?> +
+

+ + render_license_form(); ?> + +
+ +
+ + render_endpoint_info(); ?> +
+ 'notice-success', + 'invalid' => 'notice-error', + 'expired' => 'notice-warning', + 'revoked' => 'notice-error', + 'inactive' => 'notice-warning', + 'unchecked' => 'notice-info', + 'unconfigured' => 'notice-info', + ); + + $status_messages = array( + 'valid' => __( 'License is active and valid.', 'wp-prometheus' ), + 'invalid' => __( 'License is invalid.', 'wp-prometheus' ), + 'expired' => __( 'License has expired.', 'wp-prometheus' ), + 'revoked' => __( 'License has been revoked.', 'wp-prometheus' ), + 'inactive' => __( 'License is inactive.', 'wp-prometheus' ), + 'unchecked' => __( 'License has not been validated yet.', 'wp-prometheus' ), + 'unconfigured' => __( 'License server is not configured.', 'wp-prometheus' ), + ); + + $status_class = $status_classes[ $license_status ] ?? 'notice-info'; + $status_message = $status_messages[ $license_status ] ?? __( 'Unknown status.', 'wp-prometheus' ); + ?> +
+ + +
+ + + + 0 ) : ?> +
+ + + +
+ +
+ + + + + + + + + + + + + + + +
+ + + +
+ + + +
+ + + +

+
+ +

+ + + + +

+
+ + +
+ ' . esc_html__( 'Configure authentication for the /metrics endpoint.', 'wp-prometheus' ) . '

'; + } + + /** + * Render metrics section description. + * + * @return void + */ + public function render_metrics_section(): void { + echo '

' . esc_html__( 'Select which default metrics to expose.', 'wp-prometheus' ) . '

'; + } + + /** + * Render auth token field. + * + * @return void + */ + public function render_auth_token_field(): void { + $token = get_option( 'wp_prometheus_auth_token', '' ); + ?> + + +

+ +

+ __( 'WordPress Info (version, PHP version, multisite)', 'wp-prometheus' ), + 'wordpress_users_total' => __( 'Total Users by Role', 'wp-prometheus' ), + 'wordpress_posts_total' => __( 'Total Posts by Type and Status', 'wp-prometheus' ), + 'wordpress_comments_total' => __( 'Total Comments by Status', 'wp-prometheus' ), + 'wordpress_plugins_total' => __( 'Total Plugins (active/inactive)', 'wp-prometheus' ), + ); + + foreach ( $metrics as $key => $label ) { + ?> + + +
+

+

+
+scrape_configs:
+  - job_name: 'wordpress'
+    static_configs:
+      - targets: ['']
+    metrics_path: '/metrics/'
+    scheme: ''
+    authorization:
+      type: Bearer
+      credentials: ''
+

+ ' . esc_url( $endpoint_url ) . '' + ); + ?> +

+ collector = $collector; + $this->init_hooks(); + } + + /** + * Initialize WordPress hooks. + * + * @return void + */ + private function init_hooks(): void { + add_action( 'init', array( $this, 'register_endpoint' ) ); + add_action( 'template_redirect', array( $this, 'handle_request' ) ); + } + + /** + * Register the metrics endpoint rewrite rule. + * + * @return void + */ + public function register_endpoint(): void { + add_rewrite_rule( + '^metrics/?$', + 'index.php?wp_prometheus_metrics=1', + 'top' + ); + + add_rewrite_tag( '%wp_prometheus_metrics%', '([^&]+)' ); + } + + /** + * Handle the metrics endpoint request. + * + * @return void + */ + public function handle_request(): void { + if ( ! get_query_var( 'wp_prometheus_metrics' ) ) { + return; + } + + // Authenticate the request. + if ( ! $this->authenticate() ) { + status_header( 401 ); + header( 'WWW-Authenticate: Bearer realm="WP Prometheus Metrics"' ); + header( 'Content-Type: text/plain; charset=utf-8' ); + echo 'Unauthorized'; + exit; + } + + // Output metrics. + status_header( 200 ); + header( 'Content-Type: text/plain; version=0.0.4; charset=utf-8' ); + header( 'Cache-Control: no-cache, no-store, must-revalidate' ); + header( 'Pragma: no-cache' ); + header( 'Expires: 0' ); + + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Prometheus format. + echo $this->collector->render(); + exit; + } + + /** + * Authenticate the metrics request. + * + * @return bool + */ + private function authenticate(): bool { + $auth_token = get_option( 'wp_prometheus_auth_token', '' ); + + // If no token is set, deny access. + if ( empty( $auth_token ) ) { + return false; + } + + // Check for Bearer token in Authorization header. + $auth_header = $this->get_authorization_header(); + if ( ! empty( $auth_header ) && preg_match( '/Bearer\s+(.*)$/i', $auth_header, $matches ) ) { + return hash_equals( $auth_token, $matches[1] ); + } + + // Check for token in query parameter (less secure but useful for testing). + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Auth token check. + if ( isset( $_GET['token'] ) && hash_equals( $auth_token, sanitize_text_field( wp_unslash( $_GET['token'] ) ) ) ) { + return true; + } + + return false; + } + + /** + * Get the Authorization header from the request. + * + * @return string + */ + private function get_authorization_header(): string { + if ( isset( $_SERVER['HTTP_AUTHORIZATION'] ) ) { + return sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ); + } + + if ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) { + return sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ); + } + + if ( function_exists( 'apache_request_headers' ) ) { + $headers = apache_request_headers(); + if ( isset( $headers['Authorization'] ) ) { + return sanitize_text_field( $headers['Authorization'] ); + } + } + + return ''; + } +} diff --git a/src/Installer.php b/src/Installer.php new file mode 100644 index 0000000..661bf0e --- /dev/null +++ b/src/Installer.php @@ -0,0 +1,103 @@ +init_hooks(); + } + + /** + * Initialize WordPress hooks. + * + * @return void + */ + private function init_hooks(): void { + add_action( 'wp_ajax_wp_prometheus_validate_license', array( $this, 'ajax_validate_license' ) ); + add_action( 'wp_ajax_wp_prometheus_activate_license', array( $this, 'ajax_activate_license' ) ); + add_action( 'wp_ajax_wp_prometheus_deactivate_license', array( $this, 'ajax_deactivate_license' ) ); + add_action( 'wp_ajax_wp_prometheus_check_license_status', array( $this, 'ajax_check_status' ) ); + } + + /** + * Initialize the license client. + * + * @return bool True if client was initialized successfully. + */ + private function init_client(): bool { + if ( null !== $this->client ) { + return true; + } + + $server_url = self::get_server_url(); + $server_secret = self::get_server_secret(); + + if ( empty( $server_url ) || empty( $server_secret ) ) { + return false; + } + + try { + $this->client = new SecureLicenseClient( + httpClient: HttpClient::create(), + baseUrl: $server_url, + serverSecret: $server_secret, + ); + return true; + } catch ( \Throwable $e ) { + return false; + } + } + + /** + * Validate the current license. + * + * @return array{success: bool, message: string, data?: array} + */ + public function validate(): array { + if ( ! $this->init_client() ) { + return array( + 'success' => false, + 'message' => __( 'License server configuration is incomplete.', 'wp-prometheus' ), + ); + } + + $license_key = self::get_license_key(); + if ( empty( $license_key ) ) { + return array( + 'success' => false, + 'message' => __( 'No license key provided.', 'wp-prometheus' ), + ); + } + + $domain = wp_parse_url( home_url(), PHP_URL_HOST ); + + try { + $result = $this->client->validate( $license_key, $domain ); + + $this->update_cached_status( 'valid', array( + 'product_id' => $result->productId, + 'expires_at' => $result->expiresAt?->format( 'c' ), + 'version_id' => $result->versionId, + ) ); + + return array( + 'success' => true, + 'message' => __( 'License validated successfully.', 'wp-prometheus' ), + 'data' => array( + 'status' => 'valid', + 'product_id' => $result->productId, + 'expires_at' => $result->expiresAt?->format( 'Y-m-d' ), + 'lifetime' => $result->isLifetime(), + ), + ); + } catch ( LicenseNotFoundException $e ) { + $this->update_cached_status( 'invalid' ); + return array( + 'success' => false, + 'message' => __( 'License key not found.', 'wp-prometheus' ), + ); + } catch ( LicenseExpiredException $e ) { + $this->update_cached_status( 'expired' ); + return array( + 'success' => false, + 'message' => __( 'Your license has expired.', 'wp-prometheus' ), + ); + } catch ( LicenseRevokedException $e ) { + $this->update_cached_status( 'revoked' ); + return array( + 'success' => false, + 'message' => __( 'Your license has been revoked.', 'wp-prometheus' ), + ); + } catch ( LicenseInactiveException $e ) { + $this->update_cached_status( 'inactive' ); + return array( + 'success' => false, + 'message' => __( 'License is inactive. Please activate it first.', 'wp-prometheus' ), + ); + } catch ( DomainMismatchException $e ) { + $this->update_cached_status( 'invalid' ); + return array( + 'success' => false, + 'message' => __( 'This license is not activated for this domain.', 'wp-prometheus' ), + ); + } catch ( SignatureException $e ) { + return array( + 'success' => false, + 'message' => __( 'License verification failed. Please check your server secret.', 'wp-prometheus' ), + ); + } catch ( RateLimitExceededException $e ) { + return array( + 'success' => false, + 'message' => __( 'Too many requests. Please try again later.', 'wp-prometheus' ), + ); + } catch ( LicenseException $e ) { + return array( + 'success' => false, + 'message' => sprintf( + /* translators: %s: Error message */ + __( 'License validation failed: %s', 'wp-prometheus' ), + $e->getMessage() + ), + ); + } catch ( \Throwable $e ) { + return array( + 'success' => false, + 'message' => __( 'Unable to verify license. Please try again later.', 'wp-prometheus' ), + ); + } + } + + /** + * Activate the license for this domain. + * + * @return array{success: bool, message: string, data?: array} + */ + public function activate(): array { + if ( ! $this->init_client() ) { + return array( + 'success' => false, + 'message' => __( 'License server configuration is incomplete.', 'wp-prometheus' ), + ); + } + + $license_key = self::get_license_key(); + if ( empty( $license_key ) ) { + return array( + 'success' => false, + 'message' => __( 'No license key provided.', 'wp-prometheus' ), + ); + } + + $domain = wp_parse_url( home_url(), PHP_URL_HOST ); + + try { + $result = $this->client->activate( $license_key, $domain ); + + if ( $result->success ) { + return $this->validate(); + } + + return array( + 'success' => false, + 'message' => $result->message, + ); + } catch ( MaxActivationsReachedException $e ) { + return array( + 'success' => false, + 'message' => __( 'Maximum activations reached. Please deactivate another site.', 'wp-prometheus' ), + ); + } catch ( LicenseNotFoundException $e ) { + return array( + 'success' => false, + 'message' => __( 'License key not found.', 'wp-prometheus' ), + ); + } catch ( LicenseExpiredException $e ) { + return array( + 'success' => false, + 'message' => __( 'Your license has expired.', 'wp-prometheus' ), + ); + } catch ( SignatureException $e ) { + return array( + 'success' => false, + 'message' => __( 'License verification failed. Please check your server secret.', 'wp-prometheus' ), + ); + } catch ( LicenseException $e ) { + return array( + 'success' => false, + 'message' => sprintf( + /* translators: %s: Error message */ + __( 'License activation failed: %s', 'wp-prometheus' ), + $e->getMessage() + ), + ); + } catch ( \Throwable $e ) { + return array( + 'success' => false, + 'message' => __( 'Unable to activate license. Please try again later.', 'wp-prometheus' ), + ); + } + } + + /** + * Check if the license is currently valid. + * + * @return bool + */ + public static function is_license_valid(): bool { + $status = get_option( self::OPTION_LICENSE_STATUS, 'unchecked' ); + return 'valid' === $status; + } + + /** + * Get the license key. + * + * @return string + */ + public static function get_license_key(): string { + return get_option( self::OPTION_LICENSE_KEY, '' ); + } + + /** + * Get the license server URL. + * + * @return string + */ + public static function get_server_url(): string { + return get_option( self::OPTION_SERVER_URL, '' ); + } + + /** + * Get the server secret. + * + * @return string + */ + public static function get_server_secret(): string { + return get_option( self::OPTION_SERVER_SECRET, '' ); + } + + /** + * Get cached license status. + * + * @return string + */ + public static function get_cached_status(): string { + return get_option( self::OPTION_LICENSE_STATUS, 'unchecked' ); + } + + /** + * Get cached license data. + * + * @return array + */ + public static function get_cached_data(): array { + return get_option( self::OPTION_LICENSE_DATA, array() ); + } + + /** + * Get last check timestamp. + * + * @return int + */ + public static function get_last_check(): int { + return (int) get_option( self::OPTION_LAST_CHECK, 0 ); + } + + /** + * Save license settings. + * + * @param array $data Settings data. + * @return bool + */ + public static function save_settings( array $data ): bool { + if ( isset( $data['license_key'] ) ) { + update_option( self::OPTION_LICENSE_KEY, sanitize_text_field( $data['license_key'] ) ); + } + + if ( isset( $data['server_url'] ) ) { + update_option( self::OPTION_SERVER_URL, esc_url_raw( $data['server_url'] ) ); + } + + if ( isset( $data['server_secret'] ) ) { + $secret = sanitize_text_field( $data['server_secret'] ); + if ( ! empty( $secret ) ) { + update_option( self::OPTION_SERVER_SECRET, $secret ); + } + } + + update_option( self::OPTION_LICENSE_STATUS, 'unchecked' ); + delete_transient( self::TRANSIENT_LICENSE_CHECK ); + + return true; + } + + /** + * Update cached license status. + * + * @param string $status Status value. + * @param array $data Additional data. + * @return void + */ + private function update_cached_status( string $status, array $data = array() ): void { + update_option( self::OPTION_LICENSE_STATUS, $status ); + update_option( self::OPTION_LICENSE_DATA, $data ); + update_option( self::OPTION_LAST_CHECK, time() ); + } + + /** + * AJAX handler: Validate license. + * + * @return void + */ + public function ajax_validate_license(): void { + check_ajax_referer( 'wp_prometheus_license_action', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( + 'message' => __( 'You do not have permission to perform this action.', 'wp-prometheus' ), + ) ); + } + + $result = $this->validate(); + + if ( $result['success'] ) { + wp_send_json_success( $result ); + } else { + wp_send_json_error( $result ); + } + } + + /** + * AJAX handler: Activate license. + * + * @return void + */ + public function ajax_activate_license(): void { + check_ajax_referer( 'wp_prometheus_license_action', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( + 'message' => __( 'You do not have permission to perform this action.', 'wp-prometheus' ), + ) ); + } + + $result = $this->activate(); + + if ( $result['success'] ) { + wp_send_json_success( $result ); + } else { + wp_send_json_error( $result ); + } + } + + /** + * AJAX handler: Deactivate license. + * + * @return void + */ + public function ajax_deactivate_license(): void { + check_ajax_referer( 'wp_prometheus_license_action', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( + 'message' => __( 'You do not have permission to perform this action.', 'wp-prometheus' ), + ) ); + } + + update_option( self::OPTION_LICENSE_STATUS, 'unchecked' ); + update_option( self::OPTION_LICENSE_DATA, array() ); + delete_transient( self::TRANSIENT_LICENSE_CHECK ); + + wp_send_json_success( array( + 'success' => true, + 'message' => __( 'License deactivated.', 'wp-prometheus' ), + ) ); + } + + /** + * AJAX handler: Check license status. + * + * @return void + */ + public function ajax_check_status(): void { + check_ajax_referer( 'wp_prometheus_license_action', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( + 'message' => __( 'You do not have permission to perform this action.', 'wp-prometheus' ), + ) ); + } + + $result = $this->validate(); + + if ( $result['success'] ) { + wp_send_json_success( $result ); + } else { + wp_send_json_error( $result ); + } + } +} diff --git a/src/Metrics/Collector.php b/src/Metrics/Collector.php new file mode 100644 index 0000000..5cb8a71 --- /dev/null +++ b/src/Metrics/Collector.php @@ -0,0 +1,288 @@ +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(); + } + + /** + * 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' ) ); + } + + /** + * 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 + ); + } +} diff --git a/src/Plugin.php b/src/Plugin.php new file mode 100644 index 0000000..740ce95 --- /dev/null +++ b/src/Plugin.php @@ -0,0 +1,150 @@ +init_components(); + $this->init_hooks(); + $this->load_textdomain(); + } + + /** + * Prevent cloning. + * + * @return void + */ + private function __clone() {} + + /** + * Prevent unserialization. + * + * @throws \Exception Always throws to prevent unserialization. + * @return void + */ + public function __wakeup(): void { + throw new \Exception( 'Cannot unserialize singleton' ); + } + + /** + * Initialize plugin components. + * + * @return void + */ + private function init_components(): void { + // Initialize license manager. + LicenseManager::get_instance(); + + // Initialize admin settings (always needed). + if ( is_admin() ) { + new Settings(); + } + + // Initialize metrics endpoint (only if licensed). + if ( LicenseManager::is_license_valid() ) { + $this->collector = new Collector(); + new MetricsEndpoint( $this->collector ); + } + } + + /** + * Initialize WordPress hooks. + * + * @return void + */ + private function init_hooks(): void { + // Add settings link to plugins page. + add_filter( 'plugin_action_links_' . WP_PROMETHEUS_BASENAME, array( $this, 'add_plugin_action_links' ) ); + } + + /** + * Add action links to the plugins page. + * + * @param array $links Existing action links. + * @return array Modified action links. + */ + public function add_plugin_action_links( array $links ): array { + $settings_link = sprintf( + '%s', + esc_url( admin_url( 'options-general.php?page=wp-prometheus' ) ), + esc_html__( 'Settings', 'wp-prometheus' ) + ); + + // Add our link at the beginning. + array_unshift( $links, $settings_link ); + + return $links; + } + + /** + * Load plugin textdomain. + * + * @return void + */ + private function load_textdomain(): void { + load_plugin_textdomain( + 'wp-prometheus', + false, + dirname( WP_PROMETHEUS_BASENAME ) . '/languages' + ); + } + + /** + * Get the metrics collector. + * + * @return Collector|null + */ + public function get_collector(): ?Collector { + return $this->collector; + } +} diff --git a/src/index.php b/src/index.php new file mode 100644 index 0000000..bf9d07a --- /dev/null +++ b/src/index.php @@ -0,0 +1,11 @@ +

%s

', esc_html( $message ) ); +} + +/** + * Display WordPress version notice. + * + * @return void + */ +function wp_prometheus_wp_version_notice(): void { + $message = sprintf( + /* translators: 1: Required WordPress version, 2: Current WordPress version */ + __( 'WP Prometheus requires WordPress version %1$s or higher. You are running WordPress %2$s.', 'wp-prometheus' ), + WP_PROMETHEUS_MIN_WP_VERSION, + get_bloginfo( 'version' ) + ); + printf( '

%s

', esc_html( $message ) ); +} + +/** + * Display autoloader notice. + * + * @return void + */ +function wp_prometheus_autoloader_notice(): void { + $message = __( 'WP Prometheus requires Composer dependencies to be installed. Please run "composer install" in the plugin directory.', 'wp-prometheus' ); + printf( '

%s

', esc_html( $message ) ); +} + +/** + * Plugin activation hook. + * + * @return void + */ +function wp_prometheus_activate(): void { + // Check requirements before activation. + if ( version_compare( PHP_VERSION, WP_PROMETHEUS_MIN_PHP_VERSION, '<' ) ) { + deactivate_plugins( WP_PROMETHEUS_BASENAME ); + wp_die( + sprintf( + /* translators: %s: Required PHP version */ + esc_html__( 'WP Prometheus requires PHP version %s or higher.', 'wp-prometheus' ), + WP_PROMETHEUS_MIN_PHP_VERSION + ) + ); + } + + $autoloader = WP_PROMETHEUS_PATH . 'vendor/autoload.php'; + if ( file_exists( $autoloader ) ) { + require_once $autoloader; + \Magdev\WpPrometheus\Installer::activate(); + } +} + +/** + * Plugin deactivation hook. + * + * @return void + */ +function wp_prometheus_deactivate(): void { + $autoloader = WP_PROMETHEUS_PATH . 'vendor/autoload.php'; + if ( file_exists( $autoloader ) ) { + require_once $autoloader; + \Magdev\WpPrometheus\Installer::deactivate(); + } +} + +/** + * Plugin uninstall hook. + * + * @return void + */ +function wp_prometheus_uninstall(): void { + $autoloader = WP_PROMETHEUS_PATH . 'vendor/autoload.php'; + if ( file_exists( $autoloader ) ) { + require_once $autoloader; + \Magdev\WpPrometheus\Installer::uninstall(); + } +} + +// Register activation/deactivation hooks. +register_activation_hook( __FILE__, 'wp_prometheus_activate' ); +register_deactivation_hook( __FILE__, 'wp_prometheus_deactivate' ); + +// Initialize plugin after all plugins are loaded. +add_action( 'plugins_loaded', 'wp_prometheus_init' );