hipdevteam / ion-mu
ION MU — WordPress mu-plugin that fire-and-forgets activity events to Site Intelligence
Package info
Type:wordpress-muplugin
pkg:composer/hipdevteam/ion-mu
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
This package is auto-updated.
Last update: 2026-07-28 12:56:48 UTC
README
A WordPress must-use plugin that records what happens on a site — logins, content edits, plugin and theme changes, user and role changes, file changes on disk, third-party integration health — 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
- What it records
- Requirements and dependencies
- Installation
- Configuration
- How it works
- Reference
- Development
- Releasing
- Known blind spots
What it records
Every item below is logged automatically once the API connection is configured. There are no per-feature toggles.
Fifteen 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. Logout resolves the actor from the user ID, because wp_logout() clears the current user before the hook fires |
| 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 |
| Password reset requested / changed | Passwords are compared by hash; only a password_changed flag is sent |
| Application password created / revoked | With the label the user gave it |
| Admin code editor use | wp_ajax_edit-theme-plugin-file, gated on edit_plugins/edit_themes so a subscriber cannot forge entries |
| 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 |
| Outbound email failure | Recipient, subject and error. Silently failing SMTP can mask the alerts this plugin's own backend sends |
Content
One entry per post per request, emitted at shutdown after the editor's save has already returned to the browser.
- Post/page created, published, updated, trashed, restored, permanently deleted
- Scheduled posts auto-publishing (
future→publish) - Media uploaded, updated, deleted — with dimensions, file size and asset URL
- Package uploads (
.zip) filed under Plugin rather than Media - Comments approved, unapproved, spammed, trashed, deleted, edited
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 - Beaver Builder — diffs per module, against a session snapshot taken on the first draft write
- 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
Descriptions come out like 'Pricing' (page) updated — 3 widgets added, 1 widget modified.
Lorem Ipsum detection runs on every content change: 14 placeholder markers matched against all changed components, including repeater items past the first. A hit appends (lorem ipsum detected) and sets a flag on the record, letting the backend run expensive checks on demand instead of polling every site.
Thirteen internal post types (revisions, autosaves, nav-menu items, template parts, …) are never logged — see ION_MU_POST_TYPE_BLOCKLIST.
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, cache
- User created, deleted (with who inherited the content), role changed, profile updated, site data exported
- Profile diffs name changed fields explicitly; password and biography are recorded as flags only, never as values
- Site settings changed — restricted to a 21-option whitelist (siteurl, home, admin_email, permalinks, roles, comment settings, timezone, …).
wp_user_rolesandsidebars_widgetsget readable diffs instead of[array], and a 10-key sensitive list is scrubbed - Nav menus created, updated, deleted, with the theme locations they occupy
- Full-site cache clears from WP Rocket and Kinsta
File integrity
Catches changes that bypass WordPress entirely — SFTP, rsync, git deploy, CI/CD — which fire no hooks at all.
| Scope | Method | Cadence |
|---|---|---|
| WP core files | md5 vs. official checksums from api.wordpress.org | Daily |
wp-config.php | md5 of contents (checked in ABSPATH and one level above) | Daily |
| Themes | md5 of .php/.js/.css/.html | Daily |
| Uploads | Executable extensions (.php, .phar, .phtml, …) | Daily |
| Plugins + mu-plugins | Filesize fingerprint | Every 15 min |
The plugin watch ignores mtime deliberately, so a CI deploy that rewrites timestamps does not produce false positives. After a wp-admin plugin update it defers for 30 minutes without advancing its baseline — deferring reporting, never discarding it, so a backdoor uploaded during that window is still caught on the next clean tick, and the resulting entry says an update also occurred rather than claiming none did.
Core and uploads use a sticky known-issues list (a finding is reported once, not every day), and findings owned by a checker that could not run — WordPress.org unreachable — are carried forward rather than cleared. Config, themes and plugins use a rolling baseline, where only new deltas report. Both scans cap at 20,000 files per scope; a capped run disables removal detection and merges rather than replaces its baseline, so files past the cap do not churn.
Integration health
Monitors three client-facing plugins and reports whether their API connections still work — so a dead Instagram feed is caught before the client notices:
- Instagram Feed and Instagram Feed Pro (Smash Balloon) — reads the
sbi_sourcestable, falling back to the legacy options row. Tokens stored encrypted are classified passively; plain-text legacy tokens are probed live against the Graph API. Reports valid, invalid, expired, or expiring within 7 days - Business Reviews Bundle — probes the Google Places API when a local key and a
ChIJ…place ID exist; OAuth-only connections are reported as connected without a probe, since the tokens live at the vendor
Runs hourly by default (configurable 15 min – 24 h). Inactive plugins are skipped entirely; absence of a row is the signal. A checker that throws is reported as unknown rather than dropped, and never stops the next checker.
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 both the plugin header and ion-mu/composer.json. Verified on 8.2 |
| 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. ion-mu/composer.json requires only php >=8.1 and has an empty packages lock |
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, WP Rocket, Kinsta's mu-plugin, Instagram Feed / Instagram Feed Pro, Business Reviews Bundle, WP-CLI.
External services contacted at runtime
| 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 — 8 s, no redirects followed |
api.wordpress.org | Core file checksums | none | Yes — daily cron only |
maps.googleapis.com | Business Reviews health | the site's own Google key | Yes — 5 s |
graph.facebook.com / graph.instagram.com | Instagram health | the site's own token | Yes — 5 s |
Development
Dev tooling lives in the repository root composer.json; the plugin's own ion-mu/composer.json stays a clean shipping manifest, so nothing in vendor/ ever 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
Five 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
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 |
|---|---|---|---|
| API URL | WP_ION_MU_API_URL | ion_mu_api_url | (empty) |
| API key | WP_ION_MU_API_KEY | ion_mu_api_key | (empty) |
| Web-app base URL | WP_ION_MU_APP_BASE_URL | ion_mu_app_base_url | (empty) |
| Inbound webhook secret | WP_ION_MU_WEBHOOK_SECRET | ion_mu_webhook_secret | (empty) |
| Connection check interval | WP_ION_MU_CONNECTION_CHECK_INTERVAL_SEC | ion_mu_connection_check_interval_sec | 3600 |
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 status # shows resolved values and which tier each came from
Three notes that will otherwise cost you time:
- The interval inverts the chain — DB is checked first for it, so an admin's saved choice in the UI keeps winning even if a constant or env var is added later.
APP_BASE_URLandWEBHOOK_SECREThave no admin field and no CLI flag. They are constant/env only. WhileAPP_BASE_URLis empty the "View Activity Logs" buttons are hidden rather than pointing somewhere wrong.- 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 14 directories, 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
maybeInstall() ← bumps ion_mu_db_version when it changes
maybeLogSelfUpdate() ← logs this plugin's own version bump
foreach (15 handlers) register()
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
├── SSRF check on the endpoint (cached)
├── clamp payload (strings, array size, depth)
└── wp_remote_post(blocking: false)
Delivery is fire-and-forget with a 2-second timeout. The response is never read. A slow or down backend cannot slow the site or surface an error to a user — and equally, a failed delivery is silently lost. That trade is deliberate.
The JSON body carries site_domain, a UTC logged_at, object_type, action, description, object_name, page_url, before/after versions, a context object, an actor object, and the lorem-ipsum flag. Null fields are dropped entirely, so a system event has no actor key at all rather than a half-populated one.
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
scan the diff for lorem ipsum
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
init (≤ once per 5 min) spawn_cron() so low-traffic sites still run overdue events
every 15 min plugin + mu-plugin file watch (size fingerprints)
hourly (configurable) integration health checks (Instagram, Google)
daily core checksums, wp-config, themes, uploads
Cron callbacks are bound through safeCall() at the binding itself, not just inside the monitor — a throw escaping into wp-cron.php would kill every event queued behind it, including core's own wp_scheduled_delete.
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.
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 |
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 three independent points — on save, on send, and on the derived ping URL. 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.
Legacy migration
A one-time migration carries a pre-rename "HIP WP Activity Logger" install across to the ION MU names: 12 options copied, 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 every historic file-integrity finding re-alerting at once.
Reference
Cron
| Hook | Interval | Runs |
|---|---|---|
ion_mu_plugin_connection_check | Configurable, default 1 h (15 min – 24 h) | Integration health checks |
ion_mu_file_integrity_check | Daily | Core, wp-config, themes, uploads |
ion_mu_plugin_file_watch_check | 15 min, fixed | Plugin + mu-plugin file watch |
The connection scheduler re-checks itself on every boot: WP-Cron freezes an event's interval when scheduled and never re-reads cron_schedules, so a changed setting would otherwise never take effect.
REST
POST /wp-json/ion-mu/v1/run-connection-check
Authorization: Bearer <WP_ION_MU_WEBHOOK_SECRET>
Triggers a connection check on demand. Takes no parameters. Compared with hash_equals(); an unset secret can never be matched, and every failure mode returns one identical 401 so an anonymous caller cannot tell a provisioned site from an unprovisioned one. Throttled to one real run per 60 s — repeat calls return 200 with status: throttled, not 429, since the backend treats 2xx as success and the check genuinely did not need to run.
When
WP_ION_MU_WEBHOOK_SECRETis unset this route falls back to accepting the ingest API key. That is a migration affordance, not the intended end state — the ingest key is fleet-wide, so reading it from any one site would confer the right to trigger checks on all of them. Set a real webhook secret, or if the backend never calls this route, consider not exposing it at all.
The wp-admin "Recheck" buttons do not use this route — they are separate AJAX handlers with nonce and capability checks.
WP-CLI
wp ion-mu configure --url=<url> --key=<key> # either flag alone is fine
wp ion-mu status # resolved values + source tier
configure runs the same validation as the settings page and warns when a constant or env var would override what you just saved. Note that --key= with an empty value clears the key.
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.
Fields: API Endpoint URL, API Key, and Plugin Monitor Cron Interval. The first two render disabled with a CONSTANT / ENV VAR badge when resolved from a higher tier — 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.
Two buttons run checks on demand: Recheck Ping (the API endpoint) and Recheck Plugin Connection (the integration monitor). 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.
Options written
| Option | Autoload | Purpose |
|---|---|---|
ion_mu_api_url, ion_mu_api_key, ion_mu_connection_check_interval_sec | default | Settings (tier 3) |
ion_mu_db_version, ion_mu_logged_version, ion_mu_first_connect_logged, ion_mu_legacy_migrated | yes | Lifecycle bookkeeping |
ion_mu_integrity_known_issues | no | Sticky findings (core + uploads) |
ion_mu_config_baseline, ion_mu_theme_baseline, ion_mu_plugin_watch_baseline | no | Rolling baselines |
ion_mu_plugin_watch_deferred | no | Set while an update suppresses watch reporting |
Transients: ping result (5 min), SSRF verdicts, login-failure counters, cron spawn lock (5 min), remote-check lock (60 s), plugin-change marker (30 min).
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
composer phpcbf # auto-fix what is safely fixable
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
396 tests, no WordPress and no database: Brain Monkey stands in for WordPress functions and hooks, and the file-integrity scanners run against a throwaway tree under the system temp directory that is created and removed per process.
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 scanners
├── Doubles/ SpyRepository, FakeChecker
├── stubs/wp-classes.php WP_Post, WP_User, WP_Error, … as thin as the code allows
└── Unit/ 24 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 cleared known-issues list after a WordPress.org outage, 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.
To add an integration check, implement ION_CheckerInterface and pass it to ION_PluginConnectionMonitor; nothing in the monitor changes.
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 1.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 v1.3.2 && git push origin v1.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. install-loader.php embeds that header in the stub it writes, so a missed bump there ships a loader advertising the wrong version.
Bump ION_MU_DB_VERSION only when first-run setup needs to re-run.
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. - MyKinsta dashboard cache purges are invisible. They run on Kinsta's infrastructure and never execute WordPress PHP, so no hook fires. Kinsta exposes no webhook or purge-history API. "No cache-clear entry" does not mean no cache was cleared.
- File scans cap at 20,000 files per scope. Large sites can exceed that; files past the cap are unmonitored, and truncation is reported only to the PHP error log, not to the backend.
- Failed event deliveries are lost.
blocking => falsemeans no retry and no error surface. - Baselines live in
wp_options— in the same database whose credentials sit in the file being monitored. An attacker with DB write access can pre-seed a fingerprint to hide a backdoor. - A size-only fingerprint cannot see a plugin-file edit that preserves the exact byte count. That is the deliberate price of a check cheap enough to run every 15 minutes; the daily theme scan uses full md5, and core files are compared against WordPress.org.
- 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.