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-updateormake live-update. If you run agents, they need a new image as well when the type is to run there.
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;
}
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;
}
}
| 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.
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 |
requiredis 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 storegetDefaultConfig()as a whole. Missing individual values are filled in by the check itself, becauserun()merges the stored configuration withgetDefaultConfig()viaarray_merge. Validate values inrun()and returnCheckStatus::Unknownwhen configuration is missing. An empty checkbox value is not stored, so the checkbox is not reliably usable. Use anumberfield with 0 and 1 instead.
An empty schema ([]) means the check needs no configuration.
run() returns an unsaved CheckResult. Watchdog stores and evaluates it.
| Status | Meaning | |
|---|---|---|
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.
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,
) {
}
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());
}
}
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.