Markus Michalski FAQ

Watchdog: Writing Custom Checks

Implement your own Watchdog check type in PHP: CheckInterface, configuration schema, runner mode, status logic, shipping through an image rebuild, and a unit test.

Deutsche Version

Back to overview

Watchdog: Writing Custom Checks

The check system is extensible. Any PHP class that implements CheckInterface and carries the attribute #[AutoconfigureTag('watchdog.check')] is picked up by the CheckRegistry automatically. The type then appears in the Check type dropdown, without YAML configuration.

Custom checks are code inside the image. After adding the class you must rebuild and roll out the image, for example with make stage-update or make live-update. If you run agents, they need a new image as well when the type is to run there.

The contract

interface CheckInterface
{
    public function getType(): string;               // unique identifier, e.g. "my_custom"
    public function getLabel(): string;              // display name in the selection, dashboard, email
    public function getDefaultConfig(): array;       // defaults, merged with the configuration
    public function getConfigSchema(): array;        // form fields
    public function runnerMode(): RunnerMode;        // AgentOnly, DashboardOnly or Both
    public function run(SiteCheck $check): CheckResult;
    public function getEmailTargetLabel(): ?string;  // column name for the target in the email
    public function resolveEmailTarget(array $config): ?string;
}

Step 1: Create the class

Place the class in src/Check/:

<?php

declare(strict_types=1);

namespace App\Check;

use App\Entity\CheckResult;
use App\Entity\SiteCheck;
use App\Enum\CheckStatus;
use App\Enum\RunnerMode;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag('watchdog.check')]
final class MyCustomCheck implements CheckInterface
{
    public function getType(): string
    {
        return 'my_custom';
    }

    public function getLabel(): string
    {
        return 'My Custom Check';
    }

    public function runnerMode(): RunnerMode
    {
        return RunnerMode::Both;
    }

    public function getDefaultConfig(): array
    {
        return ['threshold' => 100];
    }

    public function getConfigSchema(): array
    {
        return [
            [
                'name' => 'threshold',
                'label' => 'Threshold',
                'type' => 'number',
                'required' => false,
                'default' => 100,
                'placeholder' => '100',
                'help' => 'Fail if the measured value exceeds this threshold.',
            ],
        ];
    }

    public function getEmailTargetLabel(): ?string
    {
        return 'Threshold';
    }

    public function resolveEmailTarget(array $config): ?string
    {
        return isset($config['threshold']) ? (string) $config['threshold'] : null;
    }

    public function run(SiteCheck $check): CheckResult
    {
        $config = array_merge($this->getDefaultConfig(), $check->getConfig());
        $result = new CheckResult();
        $result->setCheck($check);

        $value = 42; // your measurement here
        $threshold = (int) $config['threshold'];

        if ($value > $threshold) {
            $result->setStatus(CheckStatus::Fail);
            $result->setMessage(sprintf('Value %d exceeds threshold %d', $value, $threshold));
        } else {
            $result->setStatus(CheckStatus::Ok);
            $result->setMessage(sprintf('Value %d (threshold: %d)', $value, $threshold));
        }

        return $result;
    }
}

Step 2: Choose the runner mode

Mode When
RunnerMode::DashboardOnly The check needs a URL from the client record (like HTTP or SSL). Agents skip it
RunnerMode::AgentOnly The check needs direct access to the machine (filesystem, /proc, processes)
RunnerMode::Both Network-level check that can run from either side

When the check runs on an agent, Watchdog hands it an empty SiteCheck object that holds only type and configuration. For AgentOnly and Both, access only getConfig() in run(), not client or ID.

Step 3: Configuration schema

Each entry of getConfigSchema() is a form field:

Key Meaning
name Key in the stored configuration
label Label in the form
type Field type, see the table
required Marks the field with * and sets required in the browser
default Prefilled value
placeholder Placeholder text
help Help text below the field
Field type Rendered as Stored as
------------ ------------- -----------
text Text input text
number Number input integer
float Number input with decimals float
password Password input text, unencrypted
duration Days, hours, minutes minutes as integer
client_url_select Select one of the client's URLs ID of the URL as integer
client_url_multiselect Multi-select of the client's URLs list of host names
checkbox Checkbox see the note

required is enforced in the browser only, the server does not validate the configuration. When saving, Watchdog skips empty fields. Only if all fields are empty does it store getDefaultConfig() as a whole. Missing individual values are filled in by the check itself, because run() merges the stored configuration with getDefaultConfig() via array_merge. Validate values in run() and return CheckStatus::Unknown when configuration is missing. An empty checkbox value is not stored, so the checkbox is not reliably usable. Use a number field with 0 and 1 instead.

An empty schema ([]) means the check needs no configuration.

Step 4: Status and result

run() returns an unsaved CheckResult. Watchdog stores and evaluates it.

Status Meaning Email
CheckStatus::Ok All good Recovery email after WARN or FAIL
CheckStatus::Warn Threshold reached Failure email on the change from OK or UNKNOWN
CheckStatus::Fail Failed Failure email on the change from OK or UNKNOWN
CheckStatus::Unknown Cannot run (missing configuration, unavailable resource) none

The message (setMessage, up to 1024 characters is sensible) appears in the dashboard, the history and the email. Use setResponseTimeMs() to set a response time. If the check runs on an agent and sets none, the agent records the measured run time. On the dashboard the field stays empty without an explicit value.

Step 5: Dependencies

Checks are regular Symfony services with autowiring. Wrap access to the system and network in interfaces so you can replace them in tests. All built-in checks follow this pattern.

public function __construct(
    private readonly HttpClientInterface $httpClient,
) {
}

Step 6: Test

Tests live in tests/Unit/Check/ and run with make test:

final class MyCustomCheckTest extends TestCase
{
    public function testReturnsOkBelowThreshold(): void
    {
        $siteCheck = new SiteCheck();
        $siteCheck->setType('my_custom');
        $siteCheck->setConfig(['threshold' => 100]);

        $result = (new MyCustomCheck())->run($siteCheck);

        self::assertSame(CheckStatus::Ok, $result->getStatus());
    }
}

Contributing

If your check is generally useful you can open a pull request. Workflow and code style are in CONTRIBUTING.md. Contributions require the Contributor License Agreement.