hipdevteam / ion-mu
ION MU — WordPress mu-plugin that fire-and-forgets activity events to Site Intelligence
Requires
- php: >=8.1
- composer/installers: ^1.0 || ^2.0
- hipdevteam/ion-mu-installer: ^1.0
Requires (Dev)
- brain/monkey: ^2.6
- php-stubs/wordpress-stubs: ^6.9
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.5
- squizlabs/php_codesniffer: ^3.11
- szepeviktor/phpstan-wordpress: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-11 11:06:01 UTC
README
A WordPress must-use plugin that records what happens on a site — logins, content edits, templates, CSS and JavaScript, plugin and theme changes, user and role changes, settings — and forwards each event to the Agency Framework Site Intelligence backend.
It is a sensor, not a dashboard. There is no activity-log screen in wp-admin; events are read in the web app. The only wp-admin UI is a settings page for the API connection, plus a banner that appears when the connection is not working.
Built and maintained by ION. Released under the MIT licence — free to use, modify and redistribute, with no warranty.
Contents
- Feature index
- What it records
- Requirements and dependencies
- Installation
- Configuration
- How it works
- Reference
- Development
- Releasing
- Known blind spots
Feature index
Fourteen handlers, one row each. A handler owns exactly one domain and nothing
else, so this table is also the file map: every feature below is one class in
Handlers/, constructed in
Plugin.php and registered in the same
isolated loop. There are no per-feature toggles — everything runs once the API
connection is configured.
When it runs has two values: synchronous (the record is built inside the
hook) and deferred (built during the request, emitted on shutdown after the
response is already sent).
| # | Feature | Owner | When it runs | Emits |
|---|---|---|---|---|
| 1 | Authentication | ION_AuthHandler | synchronous | auth — login, logout, login_failed (plus a brute-force summary), password_reset, password_changed, app_password_created / renamed / revoked |
| 2 | Admin code editor, new admins | ION_SecurityHandler | file edits deferred (confirmed by hash at shutdown), new admins synchronous | security — file_edited, admin_created |
| 3 | Content, media, templates, CSS/JS | ION_ContentHandlerGate + 3 builder handlers + 3 collectors (post fields, post meta, ACF) | deferred | content / post / page / media — created, published, updated, trashed, restored, deleted, uploaded, auto_published |
| 4 | Comments | ION_CommentHandler | synchronous | comment — approved, unapproved, spam, trashed, deleted, edited (text before → after) |
| 5 | Plugins | ION_PluginHandler | synchronous | plugin — installed, updated, activated, deactivated, deleted |
| 6 | Themes | ION_ThemeHandler | synchronous | theme — installed, updated, switched, deleted |
| 7 | WordPress core | ION_CoreHandler | synchronous | core — updated |
| 8 | Nav menus | ION_MenuHandler | deferred | menu — created, updated (item-level diff, locations, Max Mega Menu item settings), deleted |
| 9 | Users | ION_UserHandler | synchronous | user — created, deleted, role_changed, profile_updated, exported (+ roles added/removed, 2FA, pending email change, sessions, super admin, privacy requests) |
| 10 | Site settings | ION_SettingsHandler | deferred | option — updated, created, deleted |
| 11 | Cache clears | ION_CacheHandler | synchronous | plugin — updated: "WP Rocket cache cleared (all pages / one post / …)", "WP Rocket used CSS cleared" — manual clears only |
| 12 | Site health | ION_SiteHealthHandler | on a signed backend call | none — answers the backend's reachability probe |
| 13 | Capability escalation | ION_CapabilityHandler | deferred | security |
| 14 | User mirror | ION_WpUserMirrorHandler | deferred | none — feeds the backend's read model directly rather than the audit trail |
Two entries are handlers by shape rather than by output. #14 registers
WordPress hooks like the rest but pushes no ActivityRecord — it feeds the
backend's read model, which is kept separate from the audit trail on purpose,
by pushing per-user deltas outbound; it has no REST routes of its own to
register. #12 logs nothing either: it owns the health-ping route, which is
not an activity feature but still has to be registered somewhere.
New event kinds reuse existing object_type / action values — the backend
validates both against a fixed enum — so the description and context carry
the specific meaning.
Per-feature runtime detail is in Per-feature lifecycle; what each one records in operator terms is in What it records.
What it records
Every item below is logged automatically once the API connection is configured. There are no per-feature toggles.
Fourteen handlers are registered on every request. Each one owns a domain and nothing else — see the list in Plugin.php.
Authentication and security
| Event | Detail |
|---|---|
| Login / logout | With user, roles and client IP. Both resolve the actor from the hook's own argument, never from the current user: at wp_login the auth cookies are set for the next request so there is no current user yet, and at wp_logout there is no longer one. Login also stamps ion_mu_last_login usermeta, which core does not track |
| Failed login | Throttled — one entry per user+IP per 5 min, plus an IP-only cap of 10 per 15 min that a spray cannot sidestep by varying the username. Throttled attempts are counted, not lost: they arrive as one brute-force summary. IPv6 addresses are counted per /64 network. The typed username is sent only when it names a real account; otherwise the entry says "an unknown username", because it may be a password typed into the wrong box |
| Password reset requested / changed | Passwords are compared by hash; only a password_changed flag is sent |
| Application password created / renamed / revoked | With the label the user gave it |
| Admin code editor use | wp_ajax_edit-theme-plugin-file and the no-JS editor screens, gated on edit_plugins/edit_themes so a subscriber cannot forge entries. Logged only once the file's hash shows the save happened, with lines added/removed and size before/after — a rejected or rolled-back save is never logged |
| New administrator created | Fires only for the administrator role |
| Direct capability change | Catches $user->add_cap('administrator'), which fires no dedicated hook — the privilege-escalation pattern a role change would not show |
Content
One entry per post per request, emitted at shutdown after the editor's save has already returned to the browser.
- Post/page/custom post type created, published, updated, trashed, restored, permanently deleted; revision restored. WordPress's own auto-drafts (made on every Dashboard view by the Quick Draft widget and on every "Add New", and deleted by its cleanup) are not logged; the first real save — to a draft or straight to published — is the creation
- Scheduled posts auto-publishing (
future→publish) - Field-level before → after: title, status, slug (with the old URL), excerpt, publish date, parent, menu order, author, comment status, password protection (set / changed / removed — never the password) and categories, tags and custom terms
- Post meta that means something to a person: SEO title, meta description, focus keyphrase, canonical, noindex; Beaver Themer and Astra Pro location rules. A save that changed only meta still produces one event
- ACF fields, named by their labels; repeater and flexible-content rows compared row by row; ACF options pages as one settings event
- Block theme templates and template parts (by area), global styles, font families and faces
- CSS and JavaScript: Customizer Additional CSS, WPCode snippets and Elementor Pro Custom Code, diffed line by line. Any new external domain is named. The change is high severity, and critical only when site-wide code starts loading script from a new host (
<script src>,<iframe src>, a URL inside inline script, or any URL in a JavaScript/WPCode snippet); links, images, fonts and stylesheets do not raise it - Media uploaded, updated, deleted — with dimensions, file size, asset URL, title, caption, alt text, and which pages use it
- Package uploads (
.zip) filed under Plugin rather than Media - Comments approved, unapproved, spammed, trashed, deleted, edited — edits show the text before and after. The commenter's email is sent as its domain only (
@example.com). Records plugins store as comments (WooCommerce order notes, Action Scheduler logs) are not logged - A post created by an anonymous request (a contact-form entry, a guest order) is not logged: it is a visitor's personal data, not a change to the site. An anonymous request that updates an existing post is logged — that is what a plugin vulnerability looks like. Saves by editors, WP-CLI and cron are always logged
Content edits are diffed at component level, not as a text blob, by three handlers tried in order:
- Elementor — diffs
_elementor_dataper widget, by widget ID, across both the classic and atomic settings shapes. Container-only edits (layout, background) are reported, and so are Site Settings (the Elementor kit: global colours, fonts, layout) - Beaver Builder — diffs per module, rows and columns included, against a session snapshot taken on the first draft write; one record per publish. Beaver Themer layouts report the header, footer or part they belong to
- Default editor —
parse_blocks(), covering Gutenberg and classic alike (classic HTML parses to a single freeform block). Blocks have no stable ID, so they are matched first by exact markup, then paired by type in document order — a reorder alone never shows as a change. Also covers block templates, template parts and Astra Pro custom layouts
Every record names the area of the site it touched — page body, header, footer, template, site-wide CSS, site-wide code, site styles.
Descriptions come out like Page 'Pricing' updated — 3 widgets added, 1 widget modified.
Internal post types (revisions, autosaves, nav-menu items, changesets, …) are never logged as content — see ION_MU_POST_TYPE_BLOCKLIST. Nav-menu items are reported by the menu handler instead. WooCommerce orders (shop_order and related types) are never logged: a purchase is a customer's personal data, not a change to the site. Neither are post types a plugin registers without an admin screen (show_ui false) to store its own records — logs, queued jobs, form entries — except the ones that change the site: Additional CSS, WPCode, global styles, fonts, templates, template parts, navigation, synced patterns, Beaver Themer and Astra Pro layouts.
Plain custom fields (keys without a leading _) are logged only when a person made the change, as for unknown options below; known fields (SEO, layout rules) are logged whoever writes them.
Plugins, themes, core
Installed, updated, activated, deactivated, deleted, switched — for plugins and themes — plus WordPress core auto-updates and manual updates, with before/after versions taken from a snapshot captured at upgrader_pre_install, while the old files are still on disk.
Uploaded .zip reinstalls are distinguished from fresh installs by comparing against a pre-install inventory, so re-uploading a plugin reads as an update, not a first install.
Users, settings, menus
- User created, deleted (with who inherited the content), role changed, role added, role removed, profile updated (including REST profile edits), site content exported (Tools › Export). A role change is one event per user per request (WordPress fires up to three hooks for it), and a new user's roles are part of "User created"
- Pending email change, two-factor methods changed, all sessions logged out, super admin granted or revoked, privacy export and erase requests
- Profile diffs name changed fields explicitly; password and biography are recorded as flags only, never as values
- Site settings — every option, not a whitelist. 54 known settings get readable labels (
ION_MU_OPTION_LABELS); any other option is reported under its own name at low severity, throttled per option. A noise blocklist (ION_MU_OPTION_NOISE_BLOCKLIST) drops transients, caches, counters and cron arrays, locks (*_lock,*.lock,*_pid,*_last_start), notice state (*_notices), term-hierarchy caches (*_children), core update bookkeeping and vendor state (cleantalk_*,edd_sl_*,elementor-*,jetpack_*, …), plugin-private transients (_<plugin>_transient_*) and downloaded notification feeds (*_notifications); a labelled option is never treated as noise. A changed value that is a time stamp of the request itself (± 2 minutes) is a plugin recording when it did something — it is ignored, and an option where nothing else changed is not reported, labelled or not (exceptrecovery_mode_email_last_sent, the crash signal). WP Rocket's random minify keys are ignored, so a cache clear is not a settings change. Switching this plugin's own change-details format (ion_mu_change_format) is itself logged. Array options are diffed key by key; Customizer changes use the control labels people see.wp_user_roles,sidebars_widgetsandwp_rocket_settingsget readable diffs instead of[array]. Secrets are masked by key name or by value format — see What, who, how, where. An unlabelled option is logged only when a person made the change — a logged-in user (wp-admin, logged-in REST or AJAX, an application password) or WP-CLI; the same write from cron, an anonymous visitor or REST call, or the backend is a plugin's own bookkeeping and is skipped. Labelled settings are logged whoever writes them - Nav menus created, updated, deleted — item-level before → after (added, removed, renamed, re-linked, moved), the theme locations they occupy, and Max Mega Menu per-item settings (
_megamenu). One record per menu per request
Event descriptions
Every event's description is one plain sentence a person can read without knowing WordPress internals: subject first, then what happened, then the detail. The subject names the kind of thing by the label people see in wp-admin (the post type's registered singular name, never its slug), then the item in single quotes; an item with no title and no slug reads '(no title)'.
| Area | Example |
|---|---|
| Content | Page 'Pricing' updated — 3 widgets added, 1 widget modified · Themer layout 'Site header' updated · Post 'Hello' restored from trash (now draft) · Post 'Launch' published on schedule |
| Templates | Template part 'Header' customised from the theme · Template 'Home' reverted to the theme version |
| Media | Image 'team' uploaded · Image 'team' updated (edited in the image editor) · Image 'Team photo' permanently deleted — it was used on 2 pages |
| Comments | Comment by 'Jane' on 'Hello world' approved |
| Menus | Navigation menu 'Main' updated: 1 item added, 2 other changes |
| Users | Capabilities changed directly for 'jdoe', not through a role: added edit_posts · Personal data erasure request #44 completed · Site content exported (Tools › Export) |
| Security | File 'header.php' edited in the admin code editor · Code editor save attempted for '…' — file not found, change not confirmed |
| Lifecycle | Plugin 'ION MU' connected to the backend for the first time |
The full before → after lives in context.diff, not in the sentence; the sentence says only enough to scan a list of events. URLs in a description pass the same secret scrubbing as the rest of the event.
What, who, how, where
In change-details format 2 — the default since 3.3.1; 1 is a per-site rollback (see Configuration) — every record also carries:
| Field | Where | Meaning |
|---|---|---|
| What | context.diff | The itemised change list, before → after, capped at 12 KB per record; anything beyond is summarised with counts (context.diff_truncated) |
| Who | actor + context.meta.actor_role | The person, plus their role at the time |
| How | context.meta.source, via, user_agent | Channel (admin, front end, REST, WP-CLI, cron, auto-update, import, XML-RPC, system) and tool (block editor, classic editor, Elementor, Beaver Builder, site editor, Customizer, quick edit, bulk edit, file editor, …) |
| Where | context.meta.where | The area of the site: page body, header, footer, template, menu, site settings, site-wide CSS, … |
| Severity | context.meta.severity | low / normal / high / critical — see Severity. Only critical raises a Slack alert |
New fields live inside context because the backend strips unknown top-level fields. Format 2 is the default. Format 1 (a per-site rollback, selected only by an explicit 1) keeps the legacy payload: no context.meta, so no severity and no alerts.
Secrets never leave the site. One redactor (ION_Redactor) runs on every diff: a value is reported only as changed when its key looks secret (password, token, api_key, …) or the value itself looks like a credential (Stripe, AWS, Slack, GitHub, Google, SendGrid, Mailgun, JWT, PEM private keys, long random strings). Names are checked as written and as labels ("Api key", "License Key"), so a field reported under its display label is still masked; arrays are walked, so a nested api_key is masked too. In code (WPCode, header scripts, custom HTML) a quoted value is masked when the name before it looks secret — define('DB_PASSWORD', '…'), $api_key = '…', apiKey: "…" — or when the value looks random. Webhook URLs (Slack, Discord, Zapier, Teams, Make, IFTTT) are masked whole; other URLs keep their host and path with secret query values (?api_key=, &token=) and passwords masked. Every event's context passes this redaction once more at the single point it leaves the site, so a secret a handler missed still never reaches the backend; fields meant to carry file hashes are left alone.
Diffing is bounded. Comparisons have size caps and a 1.5-second per-request budget; past either, a change is summarised rather than diffed, so a save is never slowed by a large page.
Severity
Rules live in one place, ION_Severity. They travel in context.meta.severity, so they exist only in format 2. Only critical raises a Slack alert (at most one per site, per kind of event, per hour); high is shown and filterable on the dashboard.
| Severity | When |
|---|---|
| critical | siteurl, home, permalink_structure or whl_page changed; search engines discouraged (blog_public → 0); registration opened; default role set to administrator; a site crash (recovery_mode_email_last_sent set — WordPress emailed the recovery-mode link); an administrator created; a file saved in the plugin/theme editor; a role or capability raised to admin level; site-wide code that starts loading script from a new outside host |
| high | WordPress core updated; a plugin activated or deactivated; an active plugin, or the active theme or its parent, updated; a theme switched; plugins switched off by a core update (wp_force_deactivated_plugins); wp_rocket_settings, admin_email, template / stylesheet, wp_user_roles, header/footer script options; any code change; noindex set on a page; a page URL changed; ION MU turned off |
| normal | Everything else a person changes |
| low | An option with no readable label |
"Site-wide code" means an option, Additional CSS, a WPCode snippet, Elementor Pro Custom Code, or content in a header, footer, template, template part, Astra hook or the Elementor kit. A new link, image, font or stylesheet host is high, not critical — only a host that now loads and runs code counts.
The plugin's own lifecycle
- Self-updates. ION MU logs its own version bumps by comparing
ION_MU_VERSIONagainst a stored value. As an mu-plugin it never passes throughPlugin_Upgrader, so the normalupgrader_*hooks structurally cannot fire for it - First connection. The first time the endpoint answers a healthy ping, one "connected for the first time" event is logged, once per site
Requirements and dependencies
Runtime
| Requirement | Detail |
|---|---|
| PHP 8.1+ | Enums, readonly promoted properties, never return type. Declared in the plugin header (Requires PHP: 8.1) and in composer.json. Tested on 8.1, 8.2 and 8.3 |
| WordPress with mu-plugin support | Any standard install. No minimum version is enforced in code; the hooks used are core APIs present since 5.x, and wp_create_application_password since 5.6 |
| Runtime PHP packages | None. composer.json requires only php >=8.1 plus the Composer-time installers (composer/installers, hipdevteam/ion-mu-installer) |
Composer is therefore optional at runtime. ion-mu.php loads classes with a built-in directory walk that always runs; if vendor/autoload.php happens to exist it is loaded first, but it is never relied on for coverage. A missing or stale vendor/ cannot break class loading.
Optional integrations, detected at runtime and never required: Elementor, Beaver Builder / Beaver Themer, Astra Pro, ACF, WPCode, Max Mega Menu, Yoast SEO, WP-CLI.
External services contacted at runtime
Only the Site Intelligence backend. The plugin calls no third party — not WordPress.org, not Google, not Instagram — and nothing at all while it is switched off or not configured.
| Host | Purpose | Credential | Blocking? |
|---|---|---|---|
| Site Intelligence API (configurable) | Event ingest | X-Api-Key header | No — 2 s timeout, response never read |
| Site Intelligence API (configurable) | Health ping for the admin banner | X-Api-Key header | Yes — 3 s, no redirects followed; at most once per 5 min |
| Site Intelligence API (configurable) | User mirror — per-user deltas | X-Api-Key header | After the response — 20 s, no redirects followed; stops at the first network failure |
| Site Intelligence API (configurable) | Download the backend's Ed25519 public key | X-Api-Key header | Yes — 8 s, no redirects followed, floored to one fetch per 5 min |
Development
Dev tooling is require-dev in the root composer.json, and .gitattributes export-ignores tests, bin/, CI and docs, so none of it reaches a deployed site.
| Package | Purpose |
|---|---|
phpunit/phpunit ^10.5 | Test runner |
brain/monkey ^2.6 | Mocks WordPress functions and hooks — no WordPress, no database |
phpstan/phpstan ^2.1 | Static analysis, level 5 |
szepeviktor/phpstan-wordpress ^2.0 | Teaches PHPStan WordPress's types |
php-stubs/wordpress-stubs ^6.9 | Function/class signatures for analysis |
squizlabs/php_codesniffer ^3.11 | Coding standard |
Installation
WordPress only auto-loads top-level .php files in mu-plugins/, never subdirectories. That is why this ships as a stub plus a folder — copy both together:
wp-content/mu-plugins/
├── ion-mu-loader.php ← the stub WordPress actually reads
└── ion-mu/ ← the real plugin
cp -r ion-mu-loader.php ion-mu/ /path/to/wp-content/mu-plugins/
Installing with Composer
Composer installs a package as one directory, and WordPress only auto-loads
top-level files in mu-plugins/ — so composer require alone leaves
everything a level too deep and the plugin never runs:
wp-content/mu-plugins/
├── load-mu-plugins.php ← WordPress reads this
└── ion-mu/ ← the Composer package
├── ion-mu-loader.php ← requires ./ion-mu/ion-mu.php
└── ion-mu/ion-mu.php
load-mu-plugins.php is the missing piece. The consuming project allows the
installer once, and every install and update wires it automatically:
{
"config": {
"allow-plugins": {
"composer/installers": true,
"hipdevteam/ion-mu-installer": true
}
}
}
hipdevteam/ion-mu-installer exists as its own package because a Composer
package has exactly one type: this one must be wordpress-muplugin so
composer/installers puts it in mu-plugins/ rather than vendor/, which
leaves no way for it to also be composer-plugin. A package's own scripts
never run when it is installed as a dependency — only the root project's do —
so ION MU cannot wire itself up. It arrives as a dependency of this package;
nothing extra to require.
The entry is guarded (is_file() before require_once) because
load-mu-plugins.php outlives the package it points at. Deleting
mu-plugins/ion-mu/ with an unguarded require would fatal on every request,
with no Deactivate button and no recovery mode.
Without Composer plugins
For sites that decline allow-plugins, install-loader.php does the same job
from the command line, using the same code:
{
"scripts": {
"post-install-cmd": "@php wp-content/mu-plugins/ion-mu/install-loader.php",
"post-update-cmd": "@php wp-content/mu-plugins/ion-mu/install-loader.php"
}
}
Both paths call ion_mu_write_mu_loader() in loader-installer.php, so they
produce a byte-identical file. Safe to re-run: an entry is never duplicated,
entries belonging to other packages are never rewritten, and it exits non-zero
on failure so a deploy stops instead of quietly leaving a site unmonitored.
Upgrading from ≤ 1.2.1 — earlier versions generated a standalone
mu-plugins/ion-mu-loader.phpstub. Both routesrequire_oncethe same file, so nothing double-loads, but the old stub still appears in the Must-Use list. It is reported on the next run and is safe to delete.
There is no activation step. Must-use plugins load on every request with no activate/deactivate lifecycle and no wp-admin controls. First-run setup happens automatically by comparing a stored DB version.
Then set the API credentials — see below. A site with no key configured logs nothing and fails quietly, by design; it never falls back to a shared credential.
Configuration
Eight settings, each resolved through the same four-tier chain:
1. wp-config.php constant ← most secure, survives DB clones
2. Server environment var ← same name; set in the Kinsta dashboard
3. WordPress option (DB) ← Settings > ION MU, or wp ion-mu configure
4. Baked-in default ← constants.php
Turning ION MU off. A must-use plugin has no Deactivate button, so ION MU has its own switch: the ION MU: On / Off field at the top of the settings page, wp ion-mu enable / wp ion-mu disable, or WP_ION_MU_ENABLED (constant or env var, which locks it). While it is off — and also while the API URL or the API key is empty — only the settings page, WP-CLI and the one-off migrations load: no handler runs on any request, no REST route exists (the backend's calls get 404), nothing is scheduled, and the plugin makes no outbound call of any kind, the connection ping included. Turning it off or on is itself logged once, with who did it (ION_PluginSwitch); turning it off is high severity.
Nothing is ever copied from tier 4 into tier 3. Because the default is read at call time, changing it in code propagates to every site that has not set its own value, while leaving overridden sites untouched.
Every tier-4 default is now empty, deliberately: shipping a production endpoint in source meant any site that lost its override silently started sending activity data — including actor login, email and IP — to whatever host happened to be baked into that release. With the defaults blank, an unprovisioned site logs nothing and fails quietly instead. Every site must therefore be provisioned explicitly, by constant, env var, or wp ion-mu configure.
| Setting | Constant / env var | Option | Default |
|---|---|---|---|
| On / off | WP_ION_MU_ENABLED | ion_mu_enabled | 1 (on) — 0, off, false or no turns it off |
| API URL | WP_ION_MU_API_URL | ion_mu_api_url | (empty) |
| API key | WP_ION_MU_API_KEY | ion_mu_api_key | (empty) |
| API base URL | WP_ION_MU_API_BASE_URL | ion_mu_api_base_url | (derived from the API URL) |
| Web-app base URL | WP_ION_MU_APP_BASE_URL | ion_mu_app_base_url | (empty) |
| Allowed IP | WP_ION_MU_ALLOWED_IP | ion_mu_allowed_ip | (empty — meaning no restriction) |
| Allowed domain | WP_ION_MU_ALLOWED_DOMAIN | ion_mu_allowed_domain | (empty — meaning no restriction) |
| Change-details format (settings page: Change Details) | WP_ION_MU_CHANGE_FORMAT | ion_mu_change_format | 2 |
There is no inbound credential to configure. Inbound authentication is an Ed25519 signature the backend produces and the site verifies with a public key it downloads by itself — no field, no constant, no paste. See Internal API.
The API base URL is where the user-mirror endpoints and the public-key fetch live. Left unset it is derived from the API URL by stripping the exact /wp-activity/add-event suffix, and derivation returning nothing means "do not sync" rather than a guessed host.
Recommended for production — credentials never touch the database, so staging clones do not carry them:
// wp-config.php
define('WP_ION_MU_API_KEY', '<key>');
define('WP_ION_MU_APP_BASE_URL', 'https://<web-app-host>');
Or scripted, writing to the DB:
wp ion-mu configure --url=https://<api-host>/api/v2/site-intelligence/wp-activity/add-event --key=<key>
wp ion-mu configure --allowed-ip=<backend-egress-ip> --allowed-domain=<api-host> # optional pin
wp ion-mu status # shows resolved values and which tier each came from
Notes that will otherwise cost you time:
APP_BASE_URLandAPI_BASE_URLhave no admin field and no CLI flag. They are constant/env/option only. WhileAPP_BASE_URLis empty the "View Activity Logs" buttons are hidden rather than pointing somewhere wrong.- An empty Allowed IP / Allowed Domain means no restriction, not deny-all. A new setting that defaulted to deny would lock the backend out of every site on upgrade. The signature is what authorises a call; these fields only narrow where a valid one may be presented from — and, for outbound calls, where this site's own events may be sent (see Outbound endpoint safety). Each is a single value, not a list or CIDR range: exactly one IP and/or one domain, or neither. A constant or env var makes the field read-only in wp-admin.
- Change-details format
2is the default. It addscontext.meta(who/how/where, severity) andcontext.diff(the itemised before → after list) — see What, who, how, where. Set1(the settings page's Change Details field,WP_ION_MU_CHANGE_FORMAT, or the option) only to roll one site back: format1sends no severity, so that site never raises a critical alert. Any value other than1means2;wp ion-mu statusshows the current value and its tier. - The API URL is not the web-app URL. The API host serves JSON to the plugin; the web-app host is a browser URL a human opens. Different hosts.
How it works
Load and boot
wp-content/mu-plugins/ion-mu-loader.php
└── require ion-mu/ion-mu.php ← wrapped in try/catch
├── require constants.php
├── require vendor/autoload.php ← if present, not required to exist
├── walk 13 directories (loader-dirs.php), require_once every class ← always runs
└── add_action('plugins_loaded', boot) ← priority 5
boot() then runs, each step isolated by safeCall():
ION_LegacyMigration::run() ← MUST be first; everything below reads ion_mu_* options
ION_IpAllowMigration::run() ← one-time move to the single allowed IP / domain
maybeInstall() ← bumps ion_mu_db_version when it changes
not configured or switched off → settings-only boot (below) and stop
ION_RequestContext::register() ← who/how/where for format 2
maybeLogSelfUpdate() ← logs this plugin's own version bump
InternalApiGate::registerResponseHeaders() ← one Retry-After filter for every gated route
foreach (14 handlers) register()
settings-only boot: on/off switch; if (is_admin()) settings page + connection notice; if (WP_CLI) wp ion-mu commands
Every layer is guarded. A syntax error or fatal anywhere inside disables the plugin and logs one line; WordPress serves the request normally. This matters more than usual here: mu-plugins load on every request, are not covered by WordPress's fatal-error recovery mode, and have no Deactivate button — an uncaught error would mean a site-wide outage fixable only over SFTP.
Event flow
WordPress hook
└── handler callback ← variadic + Throwable-contained wrapper
└── build ActivityRecord ← immutable value object
└── ApiRepository::save()
├── refuse if URL or key unset (not queued)
├── SSRF + pin check on the endpoint (cached; not queued)
├── event rate limit — 600/min per site; over it, count
│ by type and send one notice + one summary instead
├── build + clamp payload (strings, array size, depth;
│ format 2 adds context.meta + context.diff)
└── hold the payload in memory
───────────────────────── response is sent to the browser ─────────────────────
shutdown, priority 999 ApiRepository::flushPending()
├── connection check — webhook_connected?
│ └── if not: one rate-limited live re-check may run
│ (see Outbound reliability below), then either
│ proceed or queue the whole batch
└── wp_remote_post(blocking: false), one per event
└── a transport-level failure queues that event and
the rest of the batch without trying them
Events are sent after the response. With WordPress's curl transport a blocking: false request still waits for the backend's reply (up to the 2-second timeout), so sending inline made a login, logout or plugin activation wait on the backend. save() therefore does only what must happen at the moment of the event — config and endpoint checks, the rate limit, and capturing the actor — and holds the payload; one flush at shutdown, after fastcgi_finish_request(), sends them all. An unreachable backend costs at most one timeout per request, not one per event.
Delivery is fire-and-forget with a 2-second timeout. The response is never read, so an HTTP-level rejection (401, 500, …) is invisible to this code — only a transport-level failure (DNS, connection refused, timeout) is. A slow or down backend cannot slow the site to answer, and a failed delivery for a genuinely temporary reason is queued and retried automatically rather than lost outright — see Outbound reliability.
The JSON body carries site_domain, a UTC logged_at, object_type, action, description, object_name, page_url, before/after versions, a context object, and an actor object. Null fields are dropped entirely, so a system event has no actor key at all rather than a half-populated one.
Outbound reliability
Three pieces work together so a temporary connectivity problem does not mean permanently lost events, without ever making a real page load wait on the backend:
1. The connection gate. ApiRepository::save() checks ION_ConnectionChecker::isWebhookConnected() — a cheap, cached read (never a live call) of the same status the Settings page and health-ping's webhook_connected field already report — before attempting to send. Two signals, checked in order: a 5-minute-cached result of the most recent real ping (refreshed after the response by any wp-admin page load once the cached result has expired — the banner itself only reads the cached state, so no admin page waits on the backend), falling back to "has this site ever connected, at any point in its history" if nothing pinged recently.
2. The reconnect probe. If the cached signal says "not connected," ION_ConnectionChecker::maybeReconnectProbe() may run one real, live ping right there — but only if no other probe has run in the last hour (a claimed transient lock, not a counter, so a burst of simultaneous events can only ever produce one real check, not one each). Worst case during a sustained outage: at most one real WordPress action per hour pays the cost of a live check; everything else queues instantly. This is also the one thing that can detect a revoked/wrong API key automatically — unlike the event POST itself (see below), the probe reuses the real ping, which reads the actual response.
3. The retry queue. ION_SendQueue — a capped, non-autoloaded database option, not a custom table. An event is queued only for temporary reasons: the connection check failing, or the send itself returning a transport-level WP_Error (DNS, connection refused, TLS failure, full timeout — the one thing blocking: false can still see, because the underlying curl_exec() runs to completion either way). It is not queued for permanent config problems (missing credentials, an unsafe URL) — waiting does not fix those. Replay is automatic and needs no admin action: it runs, up to 10 events at a time and after the response, whenever a send succeeds (the backend is answering again), and also when a connection check confirms the connection is healthy. A batch that cannot be sent is written to the queue in one write, and a replay stops at the first network failure, so an outage costs one timeout per request.
Overflow criteria. Adding an entry overflows the queue when it would break one of three storage limits, all checked in code before every add:
| Limit | Value | Measured as |
|---|---|---|
| Entries | 50 (ION_MU_SEND_QUEUE_MAX) | number of stored entries |
| Size per entry | 16 KB (ION_MU_SEND_QUEUE_MAX_ENTRY_BYTES) | the payload JSON-encoded exactly as it would be sent |
| Total size | 256 KB (ION_MU_SEND_QUEUE_MAX_TOTAL_BYTES) | every entry's encoded size, summed |
enqueue() runs: fit the new entry to 16 KB → while the queue holds 50 entries or its total plus the new entry would exceed 256 KB, remove the oldest → append. It removes only as many entries as the new one needs — exactly one when the count is full, more only when a larger entry needs the byte room — so the stored queue is within every limit after every write, and overflow always costs the oldest events first (FIFO).
A single event over 16 KB is not handled by FIFO — evicting good events to make room for one abnormal one would let one bad event wipe out the queue. Its context, the only field with no fixed bound, is replaced with a _truncated marker — keeping context.meta, so the severity survives and a critical event still alerts when it is replayed — and who/what/when still reaches the backend; in the practically impossible case that it is still too big, it is dropped with an error_log line.
Max age: 24 hours (ION_MU_SEND_QUEUE_MAX_AGE_SEC) means "never sent after 24 hours", not "deleted after 24 hours". The queue is always ordered oldest first (appended at the end, removed and replayed from the front), so expired entries always sit at the front. Nothing sweeps them — no cron job, and nothing on page loads:
| Where | What happens to an expired entry |
|---|---|
enqueue() | It is the first removed whenever room is needed; nothing is removed when room isn't needed |
replay() | Skipped and dropped — never sent late into the audit trail |
count() / wp ion-mu status | Not counted |
| A connection check | Untouched |
So during a long, quiet outage an expired entry can stay stored until an enqueue needs its room or a replay drops it. Storage stays bounded by the three limits above either way — but that entry still holds the actor's login, email and IP in wp_options until then.
None of this can detect an HTTP-level rejection (401 revoked key, 500, …) from the event POST itself — blocking: false never reads a response's status or body, by design, so that failure mode looks identical to success to this code. Only the reconnect probe's real ping (point 2) can catch it, and only within an hour.
Request lifecycle
A page save is the busiest path, and shows why the work is deferred:
pre_post_update snapshot status, title, and the builder's own pre-save state
save_post (×N) build ONE deferred record per post; repeat firings merge
(Created outranks Published outranks Updated)
builder save hooks Elementor / Beaver Builder record their own snapshots
───────────────────────── response is sent to the browser here ─────────────────────
shutdown, priority 1 fastcgi_finish_request() — client is now disconnected
shutdown, priority 5 ask each builder handles($postId); first match diffs
emit one record per post, then queued media uploads
clear all per-request state
The editor never waits on diffing. Under a SAPI without fastcgi_finish_request() the work simply runs inline, as it did before.
Scheduled lifecycle
The plugin schedules nothing and runs no background checks. No
wp_schedule_event, no wp_schedule_single_event, no custom cron interval,
no spawn_cron() call. Everything happens inside a request WordPress is
already serving:
boot, every request clear any legacy ion_mu_* schedule still armed
shutdown, after response deferred records, user mirror deltas, event sends
The reason WP-Cron is avoided is worth keeping in mind for anything added
later: when its non-blocking dispatch fails — common on hosts that block
loopback HTTP — WordPress runs the due job inline on a random visitor's page
load. The history is in Part 1 of
docs/ION_MU_GUIDE.md.
The one exception worth naming even though it is not a schedule: the outbound reconnect probe (see Outbound reliability) can run a real, blocking check after the response — but only reactively, triggered by a real event finding the connection unhealthy, at most once an hour via a claimed lock. Nothing about it is time-based or registered with WordPress.
Hook registration
Handlers never call add_action() directly. They use $this->on() / $this->onFilter(), which wrap every callback in a variadic closure plus a Throwable guard. This buys two structural guarantees:
- Arity safety. WordPress core genuinely fires the same hook with different argument counts (
wp_update_nav_menu— Trac #50208, open since 2020). A variadic closure cannot raiseArgumentCountErrorat the hook boundary. This class of bug previously produced a guaranteed white screen on Save Menu. - Containment. A throw inside a callback costs one log line, not the request.
bin/smoke-test.php asserts that no handler bypasses these wrappers, so the guarantee cannot quietly erode.
Per-feature lifecycle
One block per handler, in the order Plugin.php
constructs them. Every one of them shares the same spine — register() at
plugins_loaded priority 5, wrapped in safeCall(); callbacks bound through
on() / onFilter(); delivery via ApiRepository::save(), non-blocking — so
only what is specific to each is written out below.
1. Authentication · ION_AuthHandler
wp_login / wp_logout / wp_login_failed / after_password_reset /
retrieve_password_key / wp_create_application_password /
wp_update_application_password / wp_delete_application_password. The actor
is resolved from the hook's own argument, never from wp_get_current_user(): at
wp_login the auth cookies are set for the next request, and at wp_logout
there is no longer a current user. Failed logins are throttled before a record
is built — 1 per user+IP per 5 min, plus an IP-only cap of 10 per 15 min — and
the throttled ones are counted into a brute-force summary. wp_login also stamps
ion_mu_last_login usermeta, which core does not track.
2. Admin code editor, new admins · ION_SecurityHandler
wp_ajax_edit-theme-plugin-file (priority 1, before core's handler) and
load-theme-editor.php / load-plugin-editor.php for the no-JS form, gated on
edit_plugins / edit_themes so a subscriber cannot forge entries. The file is
hashed before core writes it and again at shutdown; only a changed hash is
logged. edit_user_created_user fires only for the administrator role.
3. Content and media · ION_ContentHandlerGate · deferred
The busiest path, and the one the Request lifecycle
diagram traces. pre_post_update snapshots status and title; save_post builds
one deferred record per post, merging repeat firings (Created outranks
Published outranks Updated). Three collectors add to the same record:
PostFieldsCollector
(post_updated hands over the row before and after; set_object_terms),
MetaWatcher (snapshot
on add/update/delete_post_meta, compare at shutdown) and
AcfCollector
(acf/save_post at priority 1 and 20, around ACF's own save at 10). Then on
shutdown priority 5 the gate asks each builder handles($postId) in order —
Elementor,
Beaver Builder,
default editor
last because its handles() always returns true — and the first match diffs at
component level. Code posts (custom_css, wpcode) go through
ION_CodeDiff and JSON posts
(global styles, fonts) through
ION_SettingDiff. "Where is this
image used" is looked up at most 3 times per request and skipped above 50,000
posts.
4. Comments · ION_CommentHandler
transition_comment_status covers approve / unapprove / spam / trash;
wp_update_comment_data reads the text before an edit, edit_comment and
deleted_comment cover the rest.
5. Plugins · ION_PluginHandler
upgrader_pre_install captures an inventory while the old files are still on
disk, so upgrader_process_complete can report real before/after versions and
tell an uploaded .zip reinstall from a first install. add_attachment is here,
not in content: a .zip upload files under Plugin rather than Media. Updates
record whether the plugin was active (context.active), which decides their
severity — see Severity.
6. Themes · ION_ThemeHandler
Same pre-install snapshot pattern as plugins, plus switch_theme and
deleted_theme.
7. WordPress core · ION_CoreHandler
_core_updated_successfully and upgrader_process_complete, covering both
auto-updates and manual ones.
8. Nav menus · ION_MenuHandler · deferred
Snapshots a menu's items before the first write in a request (edit_terms,
wp_add_nav_menu_item, pre_post_update, before_delete_post, and
update_post_meta / add_post_meta for _megamenu) and compares at shutdown
— one record per menu. Locations come from updated_option on theme mods.
wp_update_nav_menu is the hook WordPress fires with an inconsistent argument
count (Trac #50208) — the variadic wrapper described under
Hook registration exists largely because of it.
9. Users · ION_UserHandler
user_register, delete_user (before the row goes, so the content-inheritor is
still readable), set_user_role, add_user_role, remove_user_role,
profile_update, personal_options_update, edit_user_profile_update,
export_wp, user-meta hooks (pending email change, two-factor),
wp_ajax_destroy-sessions, granted_super_admin / revoked_super_admin and the
privacy-request hooks. Profile diffs name the changed fields; password and
biography are recorded as flags only, never as values.
10. Site settings · ION_SettingsHandler · deferred
updated_option, added_option, delete_option and customize_save_after.
Every option is considered; the noise blocklist and a per-option throttle keep
the log readable. Collected during the request and sent at shutdown. Menu
locations inside theme mods are left to the menu handler, and a theme switch is
not also logged as two option changes.
11. Cache clears · ION_CacheHandler
rocket_purge_cache fires only from WP Rocket's "Clear cache" admin action,
after its nonce and capability checks; rocket_after_clean_used_css also fires
on automatic cleans, so it counts only inside the "Clear Used CSS" request
(admin_post_rocket_clean_saas). Automatic purges are not recorded.
12. Site health · ION_SiteHealthHandler
Registers /health-ping. Nothing on init, no cron call. Pushes no record of its own.
13. Capability escalation · ION_CapabilityHandler · deferred
update_user_meta catches $user->add_cap('administrator'), which fires no
dedicated hook; the record is emitted on shutdown so the settled state is read.
14. User mirror · ION_WpUserMirrorHandler · deferred
Delta hooks bind at priority 20 — after ION_UserHandler and
ION_CapabilityHandler at 10 — so the state read is the settled one and they
cannot perturb what those two see. deleted_user, not delete_user: the former
fires after the row is gone, and a deletion that then failed would leave the
mirror claiming a user is deleted while they are still logging in. Work is
queued and flushed on shutdown behind fastcgi_finish_request().
Payload limits
All caps are enforced in one place — ApiRepository::save(), the only point an event leaves the site — so every handler inherits them, including ones added later.
| Cap | Value |
|---|---|
description | 255 chars |
object_name | 191 chars |
| Context string values | 1000 chars |
| Context array entries | 50 |
| Context nesting depth | 8 |
With format 2, the itemised diff (context.diff) has its own 12 KB budget (ION_ChangeSet) and is not clamped again, so a change list is never cut mid-item; past the budget the rest is summarised with counts.
Truncation is always marked (… on strings, a _truncated entry on arrays) rather than silent. For an audit trail, a partial record that looks complete is worse than one that is obviously partial.
The depth cap is a runaway-recursion backstop, not a size control: a builder diff legitimately nests five levels (context → changes → elementor_widgets → removed → [widget]), and a tighter cap silently replaced the widget itself with a marker while the description still claimed a count.
Outbound endpoint safety
The API URL is admin-settable, and the key travels with the request, so ION_Utils::isSafeApiEndpoint() is enforced at four independent points — on save, on a queued retry replay, and on the derived ping URL, twice (the direct ping and the reconnect probe's own ping, which is the same code path). It requires https, rejects embedded credentials, and requires every resolved A and AAAA record to be public, blocking RFC1918, loopback, link-local/cloud-metadata, IPv6 ULA and CGNAT. Stricter than core's wp_http_validate_url(), which permits plain http and misses 169.254/16 entirely.
Verdicts are cached (1 h positive, 5 min negative) because save() runs on every logged event and per-event DNS resolution would be worse than the problem being solved.
The ping additionally sets redirection => 0, because nothing strips a custom header across a redirect: a validated host answering 302 → http://169.254.169.254/ would otherwise hand the fleet ingest key to the metadata endpoint in cleartext.
The Allowed IP / Allowed Domain fields (see Configuration) add a further, optional restriction on top of the checks above. When either is set, isSafeApiEndpoint() additionally requires the destination to match it — the configured IP against any of the host's resolved addresses, or the configured domain against the host literally — before calling it safe. Folded into the same validateApiEndpoint() check isSafeApiEndpoint() already runs, so every one of its callers (event delivery, the ping, the retry queue's replay, the user-mirror sync, the public-key fetch, and admin save-time validation) is covered automatically. The pin is part of the verdict cache's key (url|allowed-ip|allowed-domain), so changing it from any path — the settings page, its no-JS fallback, WP-CLI, or a wp-config.php edit — takes effect immediately rather than being answered from a verdict cached under the old pin.
Internal API (backend → site)
Everything above is outbound: the site pushes to the backend and proves "I am this site" with X-Api-Key. A handful of routes run the other way, and they need the opposite proof — "I am the backend" — which a fleet-wide shared key cannot give. Full detail in Part 3 of docs/ION_MU_GUIDE.md — repository only, it is not shipped in the package; the shape is:
OUTBOUND site → backend X-Api-Key proves "I am this site"
INBOUND backend → site Ed25519 sig proves "I am the backend"
The backend holds the private key; every site holds only the public half and cannot produce a signature. That is what makes one fleet-wide keypair safe: reading every file off a compromised site yields nothing usable against any other site.
The key is never configured by hand. On its first successful ping the site fetches the current public key from the API host over https (redirection => 0, since the request carries the ingest key) and caches it with its version. When the backend rotates, the next request carries a higher X-Key-Version; the site notices, re-fetches once and retries within the same request, keeping the previous key so in-flight requests still verify. Re-fetching is floored to one per 5 minutes — that header arrives on an as-yet-unverified request, so without the floor it would be an attacker-driven amplification primitive.
What gets signed is a fixed ten-line canonical string (ION_CanonicalRequest): format version, method, route path, host, millisecond timestamp, nonce, scope, key version, operator IP, and the sha256 of the body. Four of those close specific attacks — host stops a signature for site A being replayed at site B, scope stops a read signature triggering a full export, key version is signed rather than merely sent so a forged header cannot select a different key, and body hash stops a valid signature being reused with altered content.
Verification order (ION_SignedRequestVerifier) is cheapest-rejection-first: headers present and well-formed → timestamp is milliseconds and within ±60 s → scope matches the route → signature against each held key (claimed version first, host and its www/non-www sibling) → nonce unseen. The nonce is claimed last, only after verification succeeds, so an attacker who observes a nonce in flight cannot pre-claim it with a garbage signature and have the real request rejected as a replay. Nonce TTL is 180 s — 3× the skew window, because a nonce first seen at the early edge of a request's validity must not expire while that request is still acceptable.
The gate (ION_InternalApiGate) is the single permission_callback for every one of these routes, in this order:
- Allowed IP / allowed domain — free, no-ops entirely when neither is configured,
403 - Authentication — the signature, unconditionally,
401 - Record — who called, kept only for calls that got past step 2
- Rate limit — token bucket, subject + global, both must pass,
429
Rate limiting is deliberately last. Limiting before authentication looks more defensive but lets anyone on the internet drain the bucket for a route and deny it to the backend; after authentication, the limit applies to authorised traffic only. A Throwable anywhere inside the gate returns 401 — the one case where we genuinely do not know whether the caller is authorised, and the only safe answer to that is no.
The audit record is written after authentication and before the rate limit: a 429 is still an authentic backend call and belongs in the record, while letting an unauthenticated caller write their own address there would poison the very field the settings page's lockout warning offers to trust.
Core's rest_send_allow_header() re-invokes every matched permission_callback after dispatch and discards the answer, assuming they are pure. This one is not — it consumes a nonce, spends a token and writes a record — so the verdict is memoised per request. One HTTP request gets exactly one verification, one nonce, one token and one record.
The user mirror
The backend keeps a read model of every site's users, fed by this plugin exactly
one way: per-user deltas at shutdown on create/update/role-change/delete. This
plugin exposes no bulk push, no on-demand resync trigger, and — since the
/users and /users/lookup reads were removed (see REST) — no way for
the backend to pull current user data from a site at all. A user removed by a
path that fires no WordPress hook (a direct SQL delete, a database restore) is
therefore invisible to this plugin with no independent path left to reconcile
it; see Known blind spots.
No password hashes, ever. ION_WpUserProjection names the columns it reads and user_pass / user_activation_key are not among them. They are dropped at the point the array is built rather than filtered downstream: a denylist elsewhere is one forgotten update away from leaking, a projection that never reads the column cannot.
Legacy migration
A one-time migration carries a pre-rename "HIP WP Activity Logger" install across to the ION MU names: 11 legacy options handled (5 copied, 6 left by removed features deleted), 3 orphaned cron events unscheduled, 3 transients and one abandoned post-meta key removed. It runs first in boot(), is guarded by a flag, and never overwrites a value already written under the new name. Without it an existing install would look brand new — no credentials, and three cron events firing into hooks nothing listens to, forever.
Two further one-shot cleanups run on their own flags, for features removed from the plugin: the connection monitor's old WP-Cron event and options, and the file integrity / plugin watch monitors' two old WP-Cron events — see ION_LegacyMigration.
A separate one-shot migration, ION_IpAllowMigration, runs alongside it and converts the old multi-entry/CIDR ion_mu_ip_allow_list option to the new single-value ion_mu_allowed_ip. It carries the old value over only when unambiguous — exactly one entry that denotes exactly one host (a bare address, or an explicit /32//128); anything wider (multiple entries, or a real CIDR range) is cleared rather than guessed at, since guessing wrong risks locking the real backend out silently. A cleared list sets ion_mu_ip_allow_needs_attention, surfaced once as a notice on the settings page. A list set through the old WP_ION_MU_IP_ALLOW_LIST constant or env var cannot be migrated by code at all — nothing here can edit wp-config.php or the server environment — so it is simply no longer read, and the same notice instead tells the admin to replace it with WP_ION_MU_ALLOWED_IP / WP_ION_MU_ALLOWED_DOMAIN.
Reference
Cron
Nothing here is scheduled. The plugin registers no recurring WP-Cron event, no single event and no custom interval; see Scheduled lifecycle.
| Old WP-Cron hook | Status now |
|---|---|
ion_mu_plugin_connection_check | Cleared once by ION_LegacyMigration, behind its own flag. The plugin connection monitor it drove has been removed |
ion_mu_file_integrity_check | Cleared the same way. The file integrity monitor has been removed |
ion_mu_plugin_file_watch_check | Cleared the same way. The plugin file watch has been removed |
The clears exist so a site that ran an older version does not keep a recurring WP-Cron event armed forever — WP-Cron re-arms a recurring event until something explicitly clears it, and an mu-plugin has no deactivation hook to do that at removal time.
Cron otherwise appears only as detection: wp_doing_cron() sets
context.meta.source = cron on records made inside a cron run (a scheduled post
publishing, a scheduled Customizer changeset).
REST
One backend-facing route, under ion-mu/v1, gated by ION_InternalApiGate — optional IP allowlist, then an Ed25519 signature, then a rate limit. There is no shared secret, no bearer path, and no setting that can create one. See Internal API.
| Route | Method | Scope | Backend schedule | Purpose |
|---|---|---|---|---|
/health-ping | GET | ping.check | on demand | O(1) reachability probe for the backend's circuit breaker — no query, no transient, no outbound call |
/run-connection-check, /run-file-integrity-check, /run-plugin-watch-check, /users and /users/lookup have been removed; they 404. *The backend must not schedule calls to the three `run- routes**: its circuit breaker counts two 404s in a row as "site inactive" (see Part 2 of [docs/ION_MU_GUIDE.md`](docs/ION_MU_GUIDE.md)).
Signed routes deliberately have no dynamic path segments — get_route() returns the registered pattern, so a route with a captured id segment would sign as a regex and one captured signature would work for every value. Any identifier a future route needs would travel as a query arg instead, which is not signed (proxies reorder them); the authority comes from the signature, the args only select the record.
Every rejection returns the byte-identical 401 Invalid credentials. (or 403 Forbidden. from the allowlist, which leaks nothing a caller does not already know), so an anonymous caller cannot tell a provisioned site from an unprovisioned one. Rate-limit refusals return 429 with a real Retry-After header.
The wp-admin Recheck Ping button does not use this route — it is a separate AJAX handler with nonce and capability checks.
WP-CLI
wp ion-mu configure --url=<url> --key=<key> # any flag alone is fine
wp ion-mu configure --allowed-ip=<ip> --allowed-domain=<domain> # the optional pin
wp ion-mu status # on/off + resolved values + source tier + pin + connection + queue
wp ion-mu disable # turn ION MU off on this site (logged)
wp ion-mu enable # turn it back on (logged)
configure runs the same validation as the settings page and warns when a constant or env var would override what you just saved. --key=, --allowed-ip= and --allowed-domain= with an empty value clear that setting. The pin flags are applied before --url, so a URL passed in the same command is validated against the new pin; a rejected value stops the command before anything after it is saved.
status is a pure read of already-existing state — no live network call, so it is safe to run as often as needed while diagnosing a site over SSH:
$ wp ion-mu status
ION MU: on [default]
API URL: https://api.example.com/... [db]
API Key: ••••••••ab12 [db]
Allowed IP: 203.0.113.6 [db]
Allowed Domain: (any) [none]
Webhook connected: No
Events queued for retry: 7
Change format: 2 [default]
Webhook connected and the queue count come from ION_ConnectionChecker::isWebhookConnected() and ION_SendQueue::count() — the exact same cached signal and the exact same queue the outbound gate itself reads, not a separate check invented for this command. The count is events that can still be sent: expired entries (over 24 hours old) are not counted, even while they are still stored.
Settings page
manage_options only. Appears in three places, all the same page: a top-level ION MU menu, its Settings submenu, and Settings → ION MU.
Two sections. Connection Settings: the ION MU On / Off switch, API Endpoint URL, API Key, Change Details (format 2 recommended / format 1 legacy). Internal API Security: Allowed IP, Allowed Domain — each a single plain-text field, not a list. Fields resolved from a higher tier render disabled with a CONSTANT / ENV VAR badge — the disabled input carries no name, so it is never posted back. Saving goes through AJAX (spinner + toast) with the native options.php POST kept as a no-JS fallback.
The API key field is write-only: it renders empty, shows a masked fingerprint (•••••••• plus the last four characters) as its placeholder, and a blank submission means "keep what is stored". Clearing it is a separate deliberate act via the checkbox beside it. The stored key never reaches the browser.
Both allowlist fields save through the same Save Settings AJAX action as the URL and key — there is no separate per-field save any more (the old multi-entry list's modal, with its own dedicated AJAX action, is gone along with the list itself). The response carries backend_refused, computed server-side against ION_InboundAllowList, which toggles the inline lockout warning shown above the two fields — whether the backend's last-seen address is covered requires the same DNS resolution the gate itself uses for a domain pin, and re-implementing that in JavaScript would be a second resolver to keep in step forever.
There is deliberately no public-key field and no key-version field. Asking an operator to paste the same base64 blob into a thousand settings pages is the provisioning failure this design removes: whatever fraction is mistyped becomes sites rejecting every backend request with the same 401 a forged request produces.
One button runs a check on demand: Recheck Ping (the API endpoint). A How it works? modal documents what the plugin tracks, in language aimed at whoever inherits the site.
Everywhere else in wp-admin, a dismissible banner appears while the connection is unhealthy, so an admin who never opens Settings still finds out that nothing is being logged. The banner reads only the cached connection state; when that has expired, the real check runs after the response.
Options written
| Option | Autoload | Purpose |
|---|---|---|
ion_mu_enabled | yes | On / off switch (tier 3) — 0 turns ION MU off on this site |
ion_mu_api_url, ion_mu_api_key | default | Settings (tier 3) |
ion_mu_db_version, ion_mu_logged_version, ion_mu_first_connect_logged, ion_mu_legacy_migrated, ion_mu_connection_monitor_removed, ion_mu_file_integrity_removed, ion_mu_ip_allow_migrated | yes | Lifecycle bookkeeping |
ion_mu_allowed_ip, ion_mu_allowed_domain | default | Single allowed IP / domain for the backend-facing routes and outbound destination pinning (tier 3) — see Internal API and Outbound endpoint safety |
ion_mu_ip_allow_needs_attention | yes | Set once (cleared or legacy_constant) if the old allowlist could not be carried over on upgrade — see Legacy migration |
ion_mu_backend_public_key, ion_mu_backend_public_key_version | yes | The backend's current signing key — read on every signed request |
ion_mu_backend_public_key_prev, ion_mu_backend_public_key_prev_version | no | The key rotated away from, so in-flight requests still verify |
ion_mu_last_verified_inbound | no | Last verified inbound call: backend IP, operator IP, scope, time |
ion_mu_change_format | default | Change-details format, 2 (default since 3.3.1 — carries severity, so critical alerts) or 1 (legacy rollback); set on the settings page (tier 3) — see Configuration |
ion_mu_send_queue | no | Events queued for retry after a temporary send failure — at most 50 entries, 16 KB each, 256 KB total; entries over 24 hours old are never sent; see Outbound reliability |
Transients: ping result (5 min), SSRF verdicts, login-failure counters, "log out everywhere" throttle (ion_mu_thr_*), event rate-limit counters (ion_mu_evrate_*, per minute), unknown-option throttle (ion_mu_opt_*), reconnect-probe lock (1 hour), rate-limit buckets (ion_mu_rl_*, subject hashed), signature nonces (ion_mu_sig_nonce_*, 180 s, nonce hashed), public-key refetch floor (300 s).
Subjects and nonces are hashed into their key names because without a persistent object cache a transient name is an option_name — readable by every other plugin on the site and present in every backup. An unhashed key would publish which email address was looked up.
No custom tables. One post meta key is used transiently: _ion_mu_bb_draft_snapshot, written at the start of a Beaver Builder editing session and deleted at the end.
Debug logging
ION_Debug::log() writes to the PHP error log only on a local or development environment — WP_ENVIRONMENT_TYPE, a loopback REMOTE_ADDR, a .local/.test/.localhost site URL, WP-CLI, or an explicit define('ION_MU_DEBUG', true). On any live host it is a guaranteed no-op.
The gate is deliberately never influenced by the client: it reads the siteurl option, never the Host header, because a catch-all vhost would otherwise let a remote caller flip debug logging on in production — and one call site logs actor user_login and client IP for every event.
.dev is treated as production: it is a real public gTLD with HSTS preloading.
Development
php and composer must be on your PATH. If you develop against a Local (Flywheel) site and have no system PHP, Local's bundled binary works:
export PATH="$HOME/.config/Local/lightning-services/php-8.2.27+1/bin/linux/bin:$PATH"
export LD_LIBRARY_PATH="$HOME/.config/Local/lightning-services/php-8.2.27+1/bin/linux/shared-libs"
Running the checks
composer install # dev tooling only; never deployed
composer check # everything CI runs, in the same order
Or individually:
composer smoke # load + BIND every class, then boot
composer test # PHPUnit — no WordPress, no database
composer phpstan # static analysis, level 5
composer phpcs # coding standard
Run the smoke test before every deploy. php -l parses without binding classes, so it reports "No syntax errors detected" for an entire category of fatal:
Fatal error: Class ION_ApiRepository contains 1 abstract method and must
therefore be declared abstract or implement the remaining methods
That is raised during class-declaration binding, before any userland code runs. It is not a Throwable, so none of the plugin's guards can contain it — in an mu-plugin that means a site-wide outage on every site it reaches, from a change that passes a lint-only pipeline. The smoke test catches it in about a second, with no database. It also asserts arity safety and that no handler bypasses the hook wrappers.
The smoke test has its own class loader and does not exercise
ion-mu.php. Changes to the real loader are not covered by it.
CI runs all four checks on PHP 8.1, 8.2 and 8.3 — see .github/workflows/ci.yml.
Test suite
1059 tests, no WordPress and no database: Brain Monkey stands in for WordPress functions and hooks, FilesystemFixture gives the loader-installer tests a throwaway tree under the system temp directory that is created and removed per process, and $wpdb is a fake that records the SQL it was handed.
tests/
├── bootstrap.php points the WP path constants at a temp tree, loads every class
├── TestCase.php Brain Monkey lifecycle, in-memory options/transients, static-state reset
├── FilesystemFixture.php real files for the loader-installer tests
├── Doubles/ SpyRepository, FakeWpdb, SpyTransport
├── fixtures/ sib-signed-vectors.json — a shared throwaway Ed25519 keypair
│ used to self-sign requests in BackendKeyRotationE2ETest
├── stubs/wp-classes.php WP_Post, WP_User, WP_Error, … as thin as the code allows
└── Unit/ 47 files, one per unit under test
Two conventions worth keeping:
- Nothing is stubbed globally. A test that calls a WordPress function it did not stub fails loudly instead of silently exercising a fake.
- Every test carries its reason. Assertions state the behaviour and the comment states what breaks without it — several of these pin regressions that cost real incidents (a known-issues list cleared by a scan that could not run, a baseline advanced during a suppression window, a
strict_typesmismatch that silently disabled capability-change detection).
Adding a handler
- Create
wp-activity/Handlers/YourHandler.php— file name without theION_prefix, class name with it (ION_YourHandler),final, extendingION_AbstractHandler. - Start with
defined('ABSPATH') || exit;. - Register hooks in
register()using$this->on(...)— neveradd_action()directly, or the smoke test will fail. - Add it to the handler list in
Plugin.php. - Add a test under
tests/Unit/. - Run
composer check.
Prefer reusing existing ION_ObjectType / ION_Action cases over adding new ones. The backend validates action against a fixed Prisma enum, so a new case needs a matching backend migration; the description string carries the specific meaning instead.
To add a page builder, extend ION_AbstractBuilderHandler and add one line to the $builders array in ION_ContentHandlerGate::register(). ION_DefaultEditorHandler must stay last — its handles() always returns true.
Releasing
Bump both version sites. They must stay in sync:
ION_MU_VERSIONinion-mu/wp-activity/constants.phpVersion:inion-mu-loader.php
They are currently in sync at 3.3.2. There used to be a third — a duplicated plugin header inside
install-loader.php— which is gone since the loader is required in place instead of regenerated.- Run
composer check. - Tag the release:
git tag v3.3.2 && git push origin v3.3.2. Thepublishjob in.gitlab-ci.ymlruns on tags only and pushes the package to the GitLab Composer registry. Packagist picks the tag up separately. - Copy
ion-mu-loader.phpandion-mu/towp-content/mu-plugins/— or, on a site wired to the registry,composer require hipdevteam/ion-mu.
The version constant drives self-update logging — if it is not bumped, the deploy is invisible in the activity log. The header is what WordPress displays in the Must-Use list.
Bump ION_MU_DB_VERSION only when first-run setup needs to re-run. Version 2 stores the On/Off switch (autoloaded, so reading it costs no query per request) and deletes the options left by removed features (file integrity, plugin watch, plugin connection check).
Known blind spots
Documented deliberately. In a monitoring product, an operator assuming coverage that does not exist is worse than a gap they know about.
- A site that never allowed the installer looks installed and reports nothing. If
hipdevteam/ion-mu-installeris missing from the site'sallow-plugins, Composer prints a warning, skips it, and finishes successfully.load-mu-plugins.phpis never written, so WordPress never loads the plugin — and because the plugin never loads, it cannot report its own absence.composer showlists it; the site is dark. Belt and braces: keep thepost-update-cmdentry as well, so the loader is written even if the allow-plugins line is forgotten. - Changes made outside WordPress are not seen. Files changed on disk (SFTP, rsync, a deploy), raw SQL, a database restore, or a plugin writing its own tables without hooks produce no event: this plugin only observes WordPress hooks. Category and tag management (creating, renaming or deleting terms — assigning them to a post is still recorded), form plugins, redirect plugins, bulk tools, automatic cache purges (manual WP Rocket "Clear cache" / "Clear Used CSS" are recorded) and failed emails are not tracked either.
- Over 600 events a minute, events are counted rather than sent. The rate limit sends one notice and one per-minute summary with counts by type; the individual events above the limit are not recoverable. Security, plugin, theme, core and user events have their own allowance per action, so flooding the log with one kind of event cannot hide a file edit, a new administrator or a role change in the same minute.
- A request that changes more than 200 posts (a bulk action, an import, a WP-CLI script) describes the first 200 and reports the rest as one summary with their count and ids.
- Multisite: changes made while a request is switched to another blog (
switch_to_blog()) are looked up on the original blog at shutdown, so on a multisite network those entries can name the wrong object. - A failed event delivery is only recoverable when it fails for a reason this plugin can detect at all. The retry queue (see Outbound reliability) catches a down/unreachable connection and a transport-level send failure — but
blocking => falsemeans an HTTP-level rejection (401 revoked key, 500, …) is invisible to the event POST itself; that failure mode looks identical to success and is never queued. Only the hourly reconnect probe's real ping can catch a revoked key, and only within an hour of the next refused event. - The retry queue caps at 50 events, 256 KB and 24 hours. An outage longer or busier than that loses whatever falls outside the caps — oldest evicted first, anything past 24 hours never sent, and an event over 16 KB queued with only
context.meta(its severity) left of itscontext. See Outbound reliability for the exact criteria. - Expired queue entries are not deleted on a timer. An entry past 24 hours is never sent, but it stays in
wp_options— with the actor's login, email and IP — until an enqueue needs its room or a replay drops it. On a site that stays quiet through a long outage, that can be well past 24 hours. - Two events queued at the same instant can lose one. WordPress options have no atomic append, so the queue's read-modify-write can race; the loser's event is dropped. The limits still hold — a race can lose an entry but never overflow or corrupt the queue.
- Widget content edits are not diffed.
sidebars_widgetstracks which widget instances moved between sidebars; each widget's own settings live in a separate option and are out of scope. - The user mirror is delta-only from this plugin's side, with no reconciliation path at all. There is no bulk push, no resync trigger, and no live read route this plugin exposes any more — the mirror is kept current entirely by real-time deltas on create/update/role-change/delete. A user removed by a path that fires no WordPress hook (a direct SQL delete, a database restore) is invisible to this plugin, and there is no longer any way — from this plugin's side or the backend's — to pull a corrected read from the site to catch it.
- A site that has never connected rejects every inbound call. It has no public key yet, so it cannot verify anything, and "I cannot verify this" must fail closed. Those sites were unreachable regardless; the key arrives on the site's own first successful outbound ping.
- A site upgrading from a multi-entry/CIDR-range IP allowlist loses that restriction on upgrade unless it converts unambiguously.
ION_IpAllowMigrationonly carries the old list over when it names exactly one host; a real range or multiple entries is cleared instead of guessed at, so every address is accepted on the internal routes (still gated by the signature) until an admin sets the new Allowed IP / Allowed Domain fields. The same applies to a list set by the oldWP_ION_MU_IP_ALLOW_LISTconstant or env var, which is no longer read at all. A notice on the settings page names this once, the first time it is viewed. - The rate limiter is exact on sustained rate and approximate on burst. Transients have no compare-and-swap, so simultaneous requests can read the same token count and both spend it — under C parallel callers the effective allowance is closer to C× the configured number. The real ceiling on simultaneous work is the PHP-FPM worker pool; this bounds one caller monopolising it over time, which it does exactly.
- There is no cross-repo canonical-string byte-parity test any more.
tests/fixtures/sib-signed-vectors.json's recorded backend-signed vectors were removed — every one referenced a route this plugin no longer has — andBackendKeyRotationE2ETest's only test that compared this plugin's canonical string byte-for-byte against a real signatureSiteIntelligenceBackend's own signer produced went with them. A canonicalisation change on either side (ION_CanonicalRequesthere,wp-canonical-request.tsthere) will now surface as a fleet-wide 401 in production rather than a local test failure, until a fresh vector is recorded against a currently-live route (health-ping) and the parity test is rebuilt from it.