Markus Michalski FAQ

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.

Deutsche Version

Back to overview

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
  1. 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 seconds has 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.
  2. For each due check a RunSiteChecksMessage lands in the async queue.
  3. The worker runs the check, stores the result and compares it with the previous status (alert_states).
  4. On a change it puts a MailNotificationMessage into the notifications queue, which the same worker processes.
  5. 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.