Site health, and the plugin that was quietly failing

WordPress has never reported on itself. A site could be running PHP 5.6, missing four extensions, with a plugin whose scheduled task had been failing since 2017, and nothing anywhere would say so — you found out by looking. 5.2 in May adds a screen that looks, and the interesting part is what it does with what it finds.

The symptom

# the audit that preceded the release, done by hand across six sites
$ wp --skip-plugins eval 'echo PHP_VERSION;'
5.6.40

$ wp cron event list --fields=hook,next_run_relative | grep 'ago'
old_importer_sync    14 months ago
mailer_queue_flush   3 years ago

$ wp plugin list --field=name --status=active | wc -l
11
$ wp plugin list --status=active --update=available --field=name | wc -l
7

Two scheduled hooks whose callbacks no longer exist, firing on every page load and failing silently since before anyone on the current team joined. Seven plugins behind. A PHP version that stopped receiving security fixes in December. None of it visible anywhere in the admin.

Why it happens

WordPress’s design position has always been that the site owner is not necessarily technical, and the consequence is an admin that reports on content and says almost nothing about the installation. That is defensible for the audience and it means the people who could act on the information are the ones who never see it.

The scheduled-event case is structural rather than accidental: wp_cron stores a hook name and arguments, and a plugin that is deleted leaves its entries behind. Nothing prunes them, and a hook with no listener is not an error — it is a hook with no listener.

The fix

What the built-in checks actually test

direct (run during the page load)
  php version, sql version, utf8mb4 support
  required and recommended php extensions
  https status, and whether the site urls agree
  file permissions on the update directories
  plugin and theme update status, inactive counts

async (run over the REST API afterwards)
  loopback request — can wp-cron actually fire?
  the update API — can this site reach wordpress.org?
  background updates — is the mechanism working?

the loopback check is the valuable one. it is the reason
scheduled tasks silently stop on a site behind basic auth.

The loopback test is the check worth reading first on any inherited site. WordPress fires its scheduler by making an HTTP request to itself, and anything that blocks that — basic auth on staging, a firewall rule, a DNS split — stops every scheduled task with no error anywhere. It is the single most common cause of “the reports have not run since March”.

A check for something only this site cares about

add_filter( 'site_status_tests', function ( $tests ) {
    $tests['async']['turkerdev_queue'] = array(
        'label'     => __( 'Queue worker', 'turkerdev' ),
        'test'      => rest_url( 'turkerdev/v1/health/queue' ),
        'has_rest'  => true,
        'async_direct_test' => 'turkerdev_check_queue',
    );

    return $tests;
} );

function turkerdev_check_queue(): array {
    $last  = (int) get_option( 'turkerdev_last_job_at' );
    $stale = $last < time() - 600;

    return array(
        'label'       => $stale
            ? __( 'No job has completed in ten minutes', 'turkerdev' )
            : __( 'The queue is running', 'turkerdev' ),
        'status'      => $stale ? 'critical' : 'good',
        'badge'       => array( 'label' => __( 'Performance', 'turkerdev' ), 'color' => 'blue' ),
        'description' => '<p>' . esc_html__( 'Orders will not be exported.', 'turkerdev' ) . '</p>',
        'actions'     => sprintf(
            '<a href="%s">%s</a>',
            esc_url( admin_url( 'admin.php?page=turkerdev-queue' ) ),
            esc_html__( 'View the queue', 'turkerdev' )
        ),
        'test'        => 'turkerdev_queue',
    );
}

The actions field is what makes a check useful rather than merely informative — a failing test that links to the screen where somebody can act on it is a different artefact from one that states a fact. Using critical for something that is merely untidy is how the screen becomes noise, and the three statuses only stay meaningful if recommended is used for most things.

Registering in the async group rather than direct matters for anything that touches the database or the network: direct tests run during the page load, and four of them at half a second each is a two-second admin screen that nobody opens twice.

The fatal error protection, and the email nobody receives

5.2 also catches a fatal error during a plugin load, puts the site into recovery mode, deactivates the offending plugin for the current user and emails a recovery link. The mechanism is genuinely good and its default delivery is where it falls down.

// where it goes by default: an option set during install, in 2014
get_option( 'admin_email' );

add_filter( 'recovery_mode_email', function ( $email ) {
    $email['to']      = '[email protected]';
    $email['subject'] = sprintf( '[%s] recovery mode', wp_parse_url( home_url(), PHP_URL_HOST ) );

    return $email;
} );

// and the constant for a site with real error tracking
// define( 'WP_DISABLE_FATAL_ERROR_HANDLER', true );

A site that white-screens sends one email to an unmonitored inbox and then presents a degraded site to everyone else — with the plugin deactivated, so the symptom is now missing functionality rather than an error, which is harder to diagnose. Redirecting the email is two lines and is the minimum.

Turning the handler off entirely is the right call for a site with an error tracker, because a stack trace in Sentry is more actionable than a silently deactivated plugin. That is a decision to make deliberately per site rather than a default to accept.

Reading it from the command line

# the screen is an admin page; the data is in an option
$ wp option get health-check-site-status-result --format=json | jq
{ "good": 14, "recommended": 3, "critical": 1 }

# which makes it monitorable
$ wp option get health-check-site-status-result --format=json 
  | jq -e '.critical == 0'

# and the debug data, which is the whole info tab as an array
$ wp eval 'print_r( ( new WP_Debug_Data() )::debug_data() );' | head -30

The result is cached in an option, so reading it tells you what the last visitor to the screen saw rather than the current state — which is a limitation worth knowing before wiring it to a monitor. Running the async tests from WP-CLI is not supported directly, so a monitoring check either scrapes the option or calls the REST endpoints itself.

The debug data array is the more useful half for an inherited site: it is the entire Info tab, including the active theme, every plugin version, the constants and the server configuration, as one structure that can be diffed between two sites.

Verifying it worked

# break something on purpose
$ wp eval 'delete_option( "turkerdev_last_job_at" );'
# the screen shows: Queue worker — No job has completed in ten minutes

# and the fatal error path, which needs a real fatal
$ echo '<?php nonexistent_function();' > wp-content/plugins/test/test.php
$ curl -s -o /dev/null -w '%{http_code}n' https://staging.example/
200                          ← the site is up, the plugin is off
$ mail -f /var/mail/oncall | head -3
Subject: [staging.example] recovery mode

Causing a real fatal in a plugin is the only way to test the recovery path, and it is worth doing once on staging because the email delivery is the part most likely to be broken. A site that cannot send mail has the protection and none of the notification, which is the worst of both — the plugin is off and nobody knows why.

What this costs

A screen that is green and a site that is slow. The checks cover configuration and connectivity and say nothing at all about whether the site is fast, whether the queries are sensible or whether a plugin is doing something expensive on every page load. That is the correct scope and it is worth stating internally, because “site health says we are fine” will be offered as an answer to a performance question within about a month.

The other cost is that the recommendations are aimed at a general audience and some of them are wrong for a managed installation. It will recommend enabling automatic background updates on a site deployed from a repository, and flag a missing extension that the application does not use. Filtering out the checks that do not apply — rather than leaving a permanently amber screen — is what keeps anybody looking at it, and a screen with three warnings that are all deliberate is a screen people stop reading.