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( '', 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( '', 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' );