Extending

A plugin knows better than Microscope does what “misconfigured” looks like for it, and its findings belong in the same report — scored the same way, printed on the same page, emailed in the same digest.

Registering a check

use justinholtweb\microscope\events\RegisterChecksEvent;
use justinholtweb\microscope\services\Checks;
use yii\base\Event;

Event::on(Checks::class, Checks::EVENT_REGISTER_CHECKS, function(RegisterChecksEvent $event) {
    $event->checks[] = new MyCheck();
});

Writing one

A check implements CheckInterface: it describes itself, says whether it can run in this environment, and returns findings. Extending BaseCheck gives you sensible defaults plus critical(), warning(), notice() and pass().

use justinholtweb\microscope\checks\BaseCheck;
use justinholtweb\microscope\checks\CheckContext;
use justinholtweb\microscope\models\Category;

class MyCheck extends BaseCheck
{
    public function handle(): string
    {
        return 'myPluginIndexFreshness';
    }

    public function category(): string
    {
        return Category::DATABASE;
    }

    public function label(): string
    {
        return 'Search index freshness';
    }

    public function description(): string
    {
        return 'Checks how far behind the search index has fallen.';
    }

    public function isAvailable(CheckContext $context): bool
    {
        return MyPlugin::getInstance()->getSettings()->indexingEnabled;
    }

    public function unavailableReason(CheckContext $context): string
    {
        return 'Indexing is switched off, so there is nothing to measure.';
    }

    public function run(CheckContext $context): array
    {
        $lag = MyPlugin::getInstance()->index->secondsBehind();

        if ($lag < 300) {
            return [$this->pass('Search index is current', sprintf('%ds behind', $lag))];
        }

        return [
            $this->warning([
                'title' => 'Search index has fallen behind',
                'summary' => 'Queries are being answered from stale data.',
                'observed' => sprintf('%d minutes behind', $lag / 60),
                'expected' => 'under 5 minutes',
                'impact' => 'Visitors see results that no longer match the content...',
                'remediation' => "Run `php craft my-plugin/index/rebuild`, then make sure the queue...",
            ]),
        ];
    }
}

What a good check looks like

  • Return passes, not silence. A report that says “the index is current” is more useful than one that merely fails to mention it.
  • Skip rather than guess. If isAvailable() returns false, the check is recorded as skipped with your reason and excluded from the score — which is the honest outcome when you can’t measure something. See Scores and findings.
  • Write the remediation you’d want to receive. impact and remediation are Markdown and can carry fenced code blocks. The steps, the snippet and how to verify it worked are the whole point.
  • Pick a stable handle. It goes into settings, into the finding’s fingerprint and onto the CLI. Changing it later makes every existing finding look new.
  • Use the probes. Checks are given a CheckContext carrying php, database, server and craft probes rather than reaching for Craft::$app or ini_get(). The probes memoize, so ten checks reading SHOW VARIABLES cost one query between them — and a probe can be substituted in a test, so your thresholds can be tested against a fabricated server without one existing.
  • Respect the environment. $context->isProduction() tells you whether to hold the site to production standards. devMode is supposed to be on locally, and telling a developer off for it on every scan trains them to ignore the report.

Severities

Findings cost the site score: a critical 25, a warning 10, a notice 3. Be proportionate — a check that returns a critical for something recoverable will drag a site to an F and make the whole report less credible. BaseCheck::severityForRatio() is there for the common case of grading a measured value against a target.

Cancelling a scan

Scans::EVENT_BEFORE_SCAN is cancellable, which is the hook for suppressing scheduled scans during a deploy window — when every finding would describe a site mid-deploy:

use justinholtweb\microscope\events\ScanEvent;
use justinholtweb\microscope\services\Scans;
use yii\base\Event;

Event::on(Scans::class, Scans::EVENT_BEFORE_SCAN, function(ScanEvent $event) {
    if (App::env('DEPLOY_IN_PROGRESS')) {
        $event->isValid = false;
    }
});

Scans::EVENT_AFTER_SCAN fires once the scan is stored, carrying the ScanResult and the saved scan’s ID — the place to post a score to Slack, or to fail your own pipeline on a condition --fail-on doesn’t express.