Watchdog: Technical Details
Architecture, containers, the flow from scheduling to alert, timings, data model and agent API of Watchdog. Stack: Symfony, FrankenPHP, SQLite, Messenger and Scheduler.
Watchdog: Technical Details
Stack
| Component | Use |
|---|---|
| PHP 8.4 and Symfony 8.1 | Application |
| FrankenPHP (Caddy) | Web server, in worker mode on live |
| SQLite via Doctrine ORM | Database and queue |
| Symfony Messenger | Asynchronous execution of checks and email delivery |
| Symfony Scheduler | Once-a-minute due check and daily cleanup |
| Twig and Tailwind CSS | UI |
Architecture
graph LR
U["Admin in the browser"] -->|"HTTP Basic Auth"| APP["app: UI and API"]
APP --> DB["SQLite: data.db"]
SCH["scheduler"] -->|"every minute"| Q["Queue in SQLite"]
Q --> W["worker"]
W -->|"HTTP, TCP, DNS, Docker"| T["Monitored targets"]
W -->|"SMTP"| M["Mail server"]
W --> DB
AG["agent"] -->|"Bearer token"| APP
Containers
| Service | Command | Role |
|---|---|---|
app |
FrankenPHP | UI and agent API |
worker |
php bin/console messenger:consume async notifications --time-limit=3600 --memory-limit=128M |
Runs dashboard checks and sends emails |
scheduler |
php bin/console messenger:consume scheduler_checks --time-limit=3600 --memory-limit=128M --no-interaction |
Determines every minute which checks are due and cleans up daily |
agent |
php bin/console watchdog:agent:run |
Runs agent checks and reports the results |
mailpit |
axllent/mailpit |
Mail catcher, local and stage only |
All containers except agent and mailpit share the volume with /app/var and thereby the SQLite file. A single worker processes messages one after another. Checks with long time limits therefore delay other checks.
Flow
sequenceDiagram
participant S as scheduler
participant Q as queue
participant W as worker
participant D as SQLite
participant M as SMTP
S->>Q: RunSiteChecks per due check
Q->>W: message
W->>D: store result, compare status
W->>Q: MailNotification on status change
Q->>W: message
W->>M: one email per contact
- Every minute the scheduler examines all active dashboard checks of active clients. A check is due when no result exists yet or at least
interval - 30 secondshas passed since the last result. With a daily time it is due once that time of day has been reached and the last result is older than that time. - For each due check a
RunSiteChecksMessagelands in theasyncqueue. - The worker runs the check, stores the result and compares it with the previous status (
alert_states). - On a change it puts a
MailNotificationMessageinto thenotificationsqueue, which the same worker processes. - Agent results enter at step 3 through the API.
Timings
| Value | Duration |
|---|---|
| Due check for dashboard checks | every minute |
| Cleanup of old results | daily at 03:00 |
| Agent tick | 30 seconds |
| Agent fetch of the check list | every 300 seconds |
| Agent counts as online | contact within the last 10 minutes |
| Retries of failed messages | 3, with doubled delay |
| Limit of container processes | 3600 seconds or 128 MB, then restart by Docker |
Data model
| Table | Content |
|---|---|
clients |
Clients with name and active flag |
contacts |
Contacts with name and unique email address |
client_contacts |
Assignment of client and contact |
client_urls |
URLs per client with label and optional BasicAuth credentials |
site_checks |
Checks: type, configuration (JSON), interval, daily time, retention, runner, agent, active flag, run-now flag |
check_results |
Results: status, HTTP code, response time, message, time |
alert_states |
Current status per check, time of the last status change, counter of consecutive warnings and failures |
agents |
Agents with name, token hash and last contact |
messenger_messages |
Queue and the failed transport |
Agent API
All paths live under /api/v1/agent and require Authorization: Bearer <token>. Responses are JSON. Without a valid token Watchdog answers 401 with {"error": "Unauthorized"}, and with 429 when the limit of 60 requests per minute and IP is exceeded.
| Method | Path | Purpose |
|---|---|---|
GET |
/config |
The agent's check list. Updates the time of the last contact |
GET |
/run-now |
Checks with the run-now flag set, also for inactive clients. Resets the flag |
POST |
/results |
Accept the results of a tick |
Response of /config:
{
"agent": {"id": 1, "name": "host-server"},
"checks": [
{"id": 7, "type": "disk_space", "config": {"path": "/"}, "check_interval_minutes": 5, "run_at_time": null, "run_now": false}
]
}
Request to /results:
{
"results": [
{"site_check_id": 7, "status": "ok", "message": "42% used (58 GB free of 100 GB)", "response_time_ms": 12, "checked_at": "2026-01-01T12:00:00+01:00"}
]
}
| Response | Meaning |
|---|---|
200 |
{"accepted": <n>, "skipped": [{"site_check_id": <id>, "reason": "..."}]} |
422 |
Invalid JSON, missing results field or more than 500 results |
401 |
Token missing or invalid |
A result is skipped (skipped) when site_check_id is missing, the check does not exist or is not assigned to this agent, or the status is none of ok, warn, fail, unknown.
Quality assurance
| Tool | Call |
|---|---|
| PHPUnit | make test |
| PHPStan level 9 | make stan |
| PHP-CS-Fixer | make cs (check), make fix (fix) |
| Lint and smoke test | make lint, make smoke |
CI runs code style, PHPStan and the tests, and a separate workflow builds the agent image.