justinholtweb / craft-controltower
Live operational monitoring dashboard for Craft CMS — site activity, editor tracking, content health, queue watch, and system metrics at a glance.
Package info
github.com/justinholtweb/craft-control-tower
Type:craft-plugin
pkg:composer/justinholtweb/craft-controltower
Requires
- php: ^8.2
- craftcms/cms: ^5.0
Requires (Dev)
- craftcms/ecs: dev-main
- craftcms/phpstan: dev-main
- phpstan/phpstan: ^1.12 || ^2.2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Live operational monitoring dashboard for Craft CMS 5. Know what's happening on your site right now and what needs attention.
Features
- Live Traffic — Active visitors, requests per minute, top URLs, bot vs human breakdown
- Editor Tracking — Who's logged in, what they're editing, collision warnings when two editors work on the same entry
- Content Health — Entries by section, stale content detection, scheduled/expired entries, drafts awaiting attention, asset volume summaries
- Queue Watch — Waiting/running/failed jobs, common failure patterns, queue health status
- System Pulse — CPU, memory, disk, load average, DB response time, PHP info, uptime
- Configurable Alert Rules — Build alerts in the CP, no config files. Each rule has a metric, operator, threshold, severity, and enable toggle. Queue failures, editor collisions, and server resource spikes ship as default rules — edit, disable, or add your own. Eleven built-in metrics cover failed and pending queue jobs, CPU / memory / disk %, database response time, editor collisions, active editors, stale content, failed logins and critical updates — and the metric registry is extensible: other plugins and modules add their own through
MetricRegistryService::EVENT_REGISTER_METRICS, and those appear in the rule editor, alert checks, System Pulse, the widget and webhook payloads exactly like the built-ins (see Custom alert metrics). - Webhook & Email Notifications — Send alerts to Slack, Microsoft Teams (Power Automate / Adaptive Cards), Zapier, or any JSON receiver. Per-rule email recipients with admin-notify toggle. A Send Test button verifies delivery before you ship the rule. Flap throttling (
minNotifyInterval) suppresses noisy repeats, and "notify on resolve" closes the loop. Notifications dispatch asynchronously via a queue job so SMTP / webhook latency never blocks a request. - Health Endpoint & Status Command — Point Oh Dear, Better Stack or Uptime Kuma at a token-protected
/control-tower/health(HTTP 200 when healthy, 503 while an alert fires or the alert checks have stalled), or runphp craft control-tower/statusfrom cron or CI (Nagios exit codes). Off until you set a token (see Health endpoint). - Granular Permissions — Three CP permissions (
viewDashboard,manageAlerts,manageSettings) so editors and ops staff get scoped access without full admin rights. - Overrideable Email Template — Drop a
templates/_cp/_emails/alert.twiginto your project to fully customize alert emails. - Dashboard Widget — Configurable at-a-glance summary card with auto-refresh
- Full CP Section — Seven-tab deep dive (Overview, Live Traffic, Editors, Content Health, Queue Watch, System Pulse, Alerts) plus Alerts → Rules and Webhooks management screens
Requirements
- Craft CMS 5.0 or later
- PHP 8.2 or later
Installation
With Composer
# If developing locally, add as a path repository first:
composer config repositories.control-tower path /path/to/craft-controltower
composer require justinholtweb/craft-controltower
php craft plugin/install control-tower
Manual
- Copy the plugin to your project
- Add the path repository to your
composer.json:"repositories": [ { "type": "path", "url": "./plugins/control-tower" } ]
- Run
composer require justinholtweb/craft-controltower - Install via the CLI (
php craft plugin/install control-tower) or through the CP under Settings > Plugins
Configuration
After installation, visit Control Tower > Settings in the control panel to configure:
Tracking
| Setting | Default | Description |
|---|---|---|
| Track Visitors | On | Enable front-end visitor tracking via request logging |
| Track Editors | On | Track CP user activity and element editing |
| Track Server Metrics | On | Periodically sample CPU, memory, disk, and DB metrics |
| Collision Detection | On | Alert when multiple editors work on the same content |
Refresh & Timeouts
| Setting | Default | Description |
|---|---|---|
| Refresh Interval | 30s | How often the dashboard auto-refreshes |
| Visitor Timeout | 2 min | Minutes before a visitor is considered inactive |
| Editor Timeout | 5 min | Minutes before an editor session is considered inactive |
Data Retention
| Setting | Default | Description |
|---|---|---|
| Visitor Data | 30 days | |
| Editor Data | 90 days | |
| Content Events | 90 days | |
| Metric Samples | 30 days | |
| Alert History | 90 days |
Content
| Setting | Default | Description |
|---|---|---|
| Stale Content | 90 days | Days without update before an entry counts as stale (drives the stale_content_count metric) |
Health Endpoint
| Setting | Default | Description |
|---|---|---|
Health Token (healthToken) |
empty (off) | Token for /control-tower/health; use an env var such as $CONTROL_TOWER_HEALTH_TOKEN. At least 32 characters |
Stalled Check Threshold (healthMaxCheckAge) |
30 minutes | Age of the last alert sweep after which the health report fails as stale. 0 turns it off |
Alert thresholds are set per rule under Control Tower → Alert Rules, not in settings.
Scheduled Jobs
Control Tower includes three queue jobs that should be run on a schedule via cron:
# Collect server metrics (every 1-2 minutes) php craft queue/push justinholtweb\\controltower\\jobs\\CollectMetricsJob # Run alert checks (every 5 minutes) php craft queue/push justinholtweb\\controltower\\jobs\\RunAlertChecksJob # Data retention cleanup (daily) php craft queue/push justinholtweb\\controltower\\jobs\\CleanupJob
Or push them programmatically:
use justinholtweb\controltower\jobs\CollectMetricsJob; use justinholtweb\controltower\jobs\RunAlertChecksJob; use justinholtweb\controltower\jobs\CleanupJob; Craft::$app->getQueue()->push(new CollectMetricsJob()); Craft::$app->getQueue()->push(new RunAlertChecksJob()); Craft::$app->getQueue()->push(new CleanupJob());
Health endpoint and status command
For outside uptime monitors. Both return the same report: firing alerts, a few gauges (queue, CPU, memory, disk, database response time) and when the alert checks last ran.
php craft control-tower/status # exit 0 OK, 1 warning, 2 critical, 3 unknown php craft control-tower/status --fail-on=critical --json php craft control-tower/status --refresh # run the alert checks first
The HTTP endpoint is off until a token is set. Put at least 32 random characters in .env
and point the Health Token setting at it:
CONTROL_TOWER_HEALTH_TOKEN="…" # openssl rand -hex 32
curl -H "Authorization: Bearer $CONTROL_TOWER_HEALTH_TOKEN" https://example.com/control-tower/health
It answers 200 when healthy and 503 while any alert is firing (?failOn=critical to fail only on
critical rules) or when the alert checks haven't run within the Stalled Check Threshold
(30 minutes by default). A wrong or missing token gets a bare 401. The report holds no messages,
hostnames, paths, versions or personal data. Full details in docs/health.md.
Dashboard Widget
Add the Control Tower widget to any user's dashboard. The widget is configurable:
- Toggle visibility for each panel (visitors, editors, queue, server, alerts, content, top URLs)
- Pick any alert metrics, including ones registered by other plugins or modules, to show as stat cards
- Set a custom refresh interval
- Resize to any column span
The widget links to the full CP section for deeper investigation.
Custom alert metrics
Any plugin or module can add metrics for alert rules to watch. Listen for
MetricRegistryService::EVENT_REGISTER_METRICS and append to $event->metrics:
use craft\elements\Entry; use justinholtweb\controltower\events\RegisterMetricsEvent; use justinholtweb\controltower\metrics\CallbackMetric; use justinholtweb\controltower\metrics\MetricPeriod; use justinholtweb\controltower\services\MetricRegistryService; use yii\base\Event; Event::on( MetricRegistryService::class, MetricRegistryService::EVENT_REGISTER_METRICS, function(RegisterMetricsEvent $event) { $event->metrics[] = new CallbackMetric([ 'handle' => 'ops:entries_created', // stored on rules: letters, digits, _ - . : 'label' => 'Content: entries created', 'unit' => 'entries', 'window' => 900, // seconds; omit for a point-in-time gauge 'operators' => ['>', '>=', '<', '<='], // comparisons that make sense 'evaluator' => fn(MetricPeriod $period) => Entry::find() ->status(null) ->dateCreated(['and', '>= ' . $period->start->format(DATE_ATOM), '< ' . $period->end->format(DATE_ATOM)]) ->count(), ]); } );
Every metric, built-in or registered, implements justinholtweb\controltower\metrics\MetricInterface
(handle, label, unit, description, allowed operators, window, getValue(MetricPeriod)). Extend
Metric for a class of your own, or register a CallbackMetric as above. An evaluator that throws
or returns null makes rules on that metric skip the check; it never stops the rest. If the code
that registered a metric goes away, rules on it are shown as Unavailable and skipped rather
than failing.
docs/metrics.md has the full contract, the built-in metrics, and a complete
example module (in docs/examples/opsmetrics).
Architecture
Plugin.php → Event wiring, CP nav, settings
controllers/
DashboardController → 8 CP page actions
ApiController → 8 JSON endpoints for live polling
services/
VisitorTrackingService → Session-hash tracking, bot detection
EditorTrackingService → CP route parsing, collision detection
ContentHealthService → Stale/scheduled/expired content, pipeline
QueueMonitorService → Queue health, failed jobs
MetricsCollectorService → CPU/memory/disk/DB, cross-platform
MetricRegistryService → Alert metrics: built-ins + EVENT_REGISTER_METRICS
AlertService → Alert lifecycle, automated checks
metrics/ → MetricInterface, Metric, CallbackMetric, MetricPeriod
events/ → RegisterMetricsEvent
records/ → ActiveRecord models (6 tables)
migrations/Install.php → Database schema
jobs/ → CleanupJob, CollectMetricsJob, RunAlertChecksJob
widgets/ → Dashboard widget
assets/ → CSS + JS with auto-refresh polling
templates/ → CP section (7 tabs) + widget
Privacy
- Visitor IP addresses are stored as SHA-256 hashes, never in plain text
- User agents are hashed for bot detection grouping
- Session identity uses a daily-rotating hash of IP + user agent
- All tracking data is automatically purged based on retention settings
- Visitor tracking can be fully disabled in settings
Roadmap
v1.5 — Trend charts (15m / 1h / 24h / 7d), per-section content health, 404 and error rate trends, configurable alert thresholds
v2 — Deployment awareness (git SHA, last deploy, environment), cache metrics, database slow query panel, multi-site comparisons
Licensing
Control Tower validates its license key through Craft's built-in Craftnet integration. Craft refreshes the status out-of-band during its own update checks, so no request is made on your site's behalf at runtime.
Enforcement is strict — the plugin unlocks only on a valid or trial status:
| Status | Result |
|---|---|
valid |
Unlocked |
trial |
Unlocked, with a banner in the CP |
unknown |
Locked — no key entered, or Craft hasn't reached Craftnet yet |
invalid / mismatched |
Locked |
astray |
Locked — the installed version is past what the license covers |
Expired licenses
An expired license does not lock the plugin. Control Tower licenses are
perpetual for every version released before they expire, so an install that
lapses its renewal and stays put keeps reporting valid and keeps working
indefinitely. Renewal only matters if you want versions released after the
expiry date.
The gate trips only when an install updates past the last version its license
covers — that's the astray status. Craftnet performs that version comparison
itself, so the plugin never second-guesses it. From astray, both remedies are
legitimate: renew to cover the newer version, or roll back to the last covered
version and carry on without renewing. The license screen says exactly that.
When locked, Control Tower:
- redirects every CP page to Control Tower → License, and collapses its nav to that one item;
- returns
402from its JSON endpoints; - shows a locked notice in place of the dashboard widget;
- stops collecting visitor, editor, content, and metrics data, and stops running alert checks and notifications.
Existing data is never deleted, and retention cleanup keeps running. Everything reappears as soon as the license is valid again.
Saving a license key re-checks it with Craftnet immediately, and admins can force a re-check any time with Refresh status on the license screen — so a correct key unlocks the install right away rather than waiting on Craft's next scheduled update check.
Development and staging environments
Craft's canTestEditions flag (true on domains Craftnet recognises as local or
dev) bypasses enforcement entirely, so local installs are never locked. That
verdict is mirrored into the cache so queue workers and cron reach the same
conclusion as the browser.
For CI, or for staging domains Craft doesn't recognise as testable, switch
enforcement off in config/control-tower.php:
return [ 'disableLicenseEnforcement' => true, ];
This setting is deliberately absent from the settings screen — it belongs in version control, not in the CP.
License
See LICENSE.md.