Checkmk’s synthetic monitoring1 runs a real Robot Framework Browser test against your site on a schedule and reports pass/fail straight into Checkmk
- not just “is the port open,” an actual headless-browser page load with whatever assertions you write. The code only ships in Checkmk’s commercial editions (Pro, Ultimate, Cloud) - it’s simply not part of the open-source Raw/Community edition at all. But within a commercial edition, you don’t need to actually pay for the Synthetic Monitoring add-on to use it: it runs in a built-in free mode by default, capped at 3 monitored test services with no time limit, until you license it for more. This is the full setup path, end to end, including the defaults that need changing before it’ll work and the one error message it throws that isn’t self-explanatory.
If you need a Rocky Linux host that can actually run Playwright/Chromium first, see the companion post2 - this one assumes that part is done and focuses on the Checkmk side.
What you need #
- Checkmk 2.5+ on a commercial edition (Robotmk is bundled, not a separate package)
- A dedicated Linux host to run the test - 4+ CPU cores, 8GB RAM is the
documented minimum. I’m using
robotmk01.example.orgthroughout below; substitute your own host. - Outbound internet access from that host, at least for the first RCC environment build (it downloads Python/Node/browser packages)
Register the test host as a normal Checkmk host first #
Robotmk rides on the regular Checkmk agent - get that working before touching anything synthetic-monitoring specific. Install the agent package built for your site and register it3:
sudo dnf install -y /path/to/check-mk-agent-<version>.rpm
sudo cmk-agent-ctl register \
--hostname robotmk01.example.org \
--server monitoring.example.org \
--site mysite \
--user automation \
--password '<automation-secret>' \
--trust-certAdd the host in Setup, run a discovery, confirm the normal checks (CPU, memory, filesystem) come back green before moving on.
Package the test as a Managed Robot #
This is the part with no REST API - it’s a GUI-only workflow under
Setup → Synthetic monitoring → Managed Robots. A “Managed Robot” is a
.tar.gz (or .zip) archive containing your test suite, stored server-side
and distributed to hosts by the bakery.
The archive needs at minimum three files. For a simple reachability check:
conda.yaml - the RCC environment (dependencies plus the
rccPostInstall hook that actually downloads the browser binary):
channels:
- conda-forge
dependencies:
- python=3.14.7
- nodejs=24.21.0
- pip=26.2.1
- pip:
- robotframework==7.5
- robotframework-browser==20.5.0
rccPostInstall:
- rfbrowser initrobot.yaml - RCC’s manifest, pointing at conda.yaml. Its own
tasks: section is required to exist but never actually runs under
Robotmk - the RHEL package list and the rccPostInstall key-naming gotcha
are covered in the companion post2 if any of this needs unpacking:
tasks:
Test:
shell: rfbrowser init && robot --outputdir output tests.robot
condaConfigFile: conda.yaml
artifactsDir: output
PATH:
- .
PYTHONPATH:
- .tests.robot - the actual suite:
*** Settings ***
Library Browser
Suite Teardown Close Browser ALL
*** Test Cases ***
Website Is Reachable
New Browser chromium headless=True
New Page https://example.com
Get Title == Example DomainPackage the three into the archive Managed Robots expects:
tar -czf robot_suite.tar.gz conda.yaml robot.yaml tests.robotIn the Add robot form:
- Name / Description: whatever identifies this test
- Robot package: upload the archive
- Plan settings → Application name: a label, shows up in the service name
- Relative path to test suite file or folder:
tests.robot - Execution configuration → Limit per attempt: the 60-second default is too short if the environment needs to download a browser on first run - bump it to at least 5 minutes
- Automated environment setup: RCC, Relative path to robot.yaml:
robot.yaml, Environment dependency handling: Download (internet connection)
Saving creates version 1 of the robot. Re-uploading the archive later (to fix a bug in the test itself) bumps the version number - the bakery rule below always deploys whatever the current version is.
The bakery rule: Robotmk scheduler (Linux) #
Under Setup → Agents → Agent rules → Robotmk scheduler (Linux), add a rule and scope its Conditions to just the test host (explicit host condition) - this shouldn’t apply fleet-wide.
- Deploy Robotmk scheduler and agent plug-in
- Base directory of Robot Framework projects: e.g.
/var/robot- doesn’t need to exist, the scheduler takes ownership of it - Parallel running sequences of plans → Sequence execution interval: must be longer than the sum of all plan runtime limits in that sequence (limit-per-attempt × (1 + re-executions)) - Checkmk validates this and refuses to save otherwise. If your plan’s limit is 5 minutes, set the interval to something like 15.
- Sequence of plans → Add new plan: choose Managed Robot, pick the one you just created, leave it on “Use default managed plan settings” unless you specifically need to override something per-host
- RCC configuration → Allow agent deployment as root: leave this unchecked first - see the gotcha below before deciding
- Conda configuration: defaults are fine for a single dedicated host with direct internet access (no proxy)
NOTE: if your Checkmk agent is deployed running as root on this host (the default for most Linux agent installs), baking will fail with:
Synthetic Monitoring is configured on Linux hosts with root agent deployment and RCC for the environment creation. This combination is not supported(werk #194604). The underlying reason: RCC running as root can end up changing permissions on/optthe first time it runs, which on a Linux distro that also installs Checkmk itself under/opt/omdis exactly as bad as it sounds. The message itself names three ways out: switch the base agent to non-root deployment via Customize agent package (Linux), use Conda instead of RCC for the environment, or - if this host exists solely for synthetic monitoring, which is the common case - check Allow agent deployment as root in this same rule. For a dedicated test host, that third option is the simplest and is what the error message itself points to for exactly this situation.
Bake and deploy #
Bake the agent package (Setup → Agents → Baking, or the
/domain-types/agent/actions/bake/invoke endpoint of the Checkmk REST
API5). Then get the new package onto the host. Two ways:
Signed auto-update6 (if you’ve set up an agent signing key and registered the updater):
sudo cmk-update-agent register -s monitoring.example.org -i mysite \
-H robotmk01.example.org -U automation -P '<automation-secret>' -t
sudo cmk-update-agent -vManual reinstall (no signing key needed, works everywhere):
curl -o agent.rpm \
"https://monitoring.example.org/mysite/check_mk/api/1.0/domain-types/agent/actions/download_by_host/invoke?os_type=linux_rpm&host_name=robotmk01.example.org" \
-H "Authorization: Bearer automation <secret>"
sudo dnf install -y agent.rpmEither way, the install restarts robotmk-scheduler-daemon, which triggers
a fresh RCC environment build on that host.
Verify it actually ran #
Don’t wait out the schedule while debugging - journalctl -u robotmk-scheduler-daemon shows environment build progress in real time, and
the documented way to run the suite directly, bypassing the schedule
entirely, is in the companion post.
Once it’s working, run a discovery and reload the core:
cmk -v -I robotmk01.example.org
cmk -RThree new services should appear: RMK Scheduler Status, RMK <plan> Plan,
and RMK <plan> Test: <test name>. Checkmk also ships a “Synthetic
Monitoring” dashboard (Monitor menu) that summarizes test nodes, OK tests,
and per-test runtime across every Robotmk-managed host at once.
Example: checking every link on the site #
The reachability check above proves the homepage loads. A more useful next
step: check that every link on the site actually resolves. Hugo generates a
sitemap.xml automatically, which makes a natural source of truth for
“every page on the site” - fetch it, visit each page, collect every
<a href>, and check that each one responds:
${sitemap} GET ${BASE_URL}/sitemap.xml
@{pages} Get Regexp Matches ${sitemap.text} <loc>(.*?)</loc> 1
New Browser chromium headless=True
New Context
@{found_links} Create List
FOR ${page} IN @{pages}
New Page ${page}
@{link_elements} Get Elements a
FOR ${element} IN @{link_elements}
${href} Get Attribute ${element} href
${absolute} Evaluate urllib.parse.urljoin($page, $href) modules=urllib.parse
Append To List ${found_links} ${absolute}
END
Close Page
END
${unique_links} Remove Duplicates ${found_links}Get Elements finds every <a> tag on the page and returns a handle for
each one; Get Attribute then reads one attribute off one handle at a
time, which is why the code above loops over @{link_elements} to pull the
href out of each.
requests vs. a real browser
#
There are two ways to check whether a URL is actually reachable, and they have genuinely different failure modes.
RequestsLibrary’s GET keyword is a plain HTTP client - fast, no
browser overhead, the right default for most links:
${response} GET ${link} expected_status=any timeout=15
IF ${response.status_code} >= 400
Append To List ${broken} ${link} -> ${response.status_code}
ENDThe catch: some sites do bot detection below the HTTP-header layer - TLS
fingerprinting, request timing, navigator.webdriver-style checks - that a
plain requests call can’t get past no matter what User-Agent header you
spoof. LinkedIn returns a non-standard 999 status to anything it suspects
isn’t a real browser; freedesktop.org and readthedocs.io-hosted docs go
further and just don’t respond at all. Neither means the page is actually
gone.
The Browser library’s real Chromium instance is indistinguishable from an actual visitor, so it gets past that class of detection - worth it for domains you know do this, at the cost of a real page load per link instead of a lightweight request. The pattern: create a promise to wait for a response, trigger navigation, then wait for the promise to resolve:
${promise} Promise To Wait For Response response => response.request().resourceType() == 'document' timeout=15s
${nav_status} ${nav_result} Run Keyword And Ignore Error Go To ${link}
IF '${nav_status}' == 'FAIL'
Append To List ${broken} ${link} -> navigation failed
ELSE
${resp_status} ${response} Run Keyword And Ignore Error Wait For ${promise}
IF '${resp_status}' == 'FAIL'
Append To List ${broken} ${link} -> no response received
ELSE IF ${response}[status] >= 400
Append To List ${broken} ${link} -> ${response}[status]
END
ENDNOTE:
Promise To Wait For Responseneeds a page already open to attach its listener to. Open one page before the loop and reuse it withGo Tofor every link, rather than opening and closing a page each iteration - with no page open at the exact moment the promise is created, every single check times out instead of just the genuinely broken ones.
NOTE: the matcher matters too. An empty matcher (the keyword’s default) waits for whichever response arrives first after navigation - not necessarily the main document. A real page loads sub-resources (analytics, fonts, redirects) alongside it, and if one of those responds first or briefly errors, that status gets misattributed to the whole link. This showed up as a real false alert on this exact suite: a Checkmk docs page came back CRIT for one run, then recovered on its own an hour later with nothing changed on either end - a sub-resource hiccup, not a dead page. Matching the literal URL string looks like the fix, but isn’t quite right either: a fragment (
#section) is never sent to the server at all, and Chromium can silently normalize a bare origin (https://x.com→.../) before the request goes out, so the response’s real URL doesn’t always equal what you navigated to. CheckingresourceType() == 'document'sidesteps all of that - it identifies the main navigation response directly, regardless of its exact URL.
Two more things worth skipping outright, regardless of which method checks the rest:
- Same-site
#fragmentlinks. Jumping to a heading anchor on the page you’re already on never fires a new network request, so there’s nothing for either method to observe - it isn’t broken, there’s just no response to wait for. Skip when the link’s host matches your own site and it has a fragment. localhost/ private-network links. A link that’s only meaningful on a reader’s own machine (documenting how to reach a local dev server, say) isn’t a real destination from your monitoring host’s point of view.
The full suite below uses the browser method for every link, not just the
ones on bot-protected domains - on a site this size the extra page-load
overhead is still only a few seconds, and one code path is easier to keep
correct than two. If your site links to enough places that runtime becomes
a real constraint, requests for everything except a short list of known
bot-protected domains is the natural optimization.
Full suite #
*** Settings ***
Library Browser
Library RequestsLibrary
Library Collections
Library String
Suite Teardown Close Browser ALL
*** Variables ***
${BASE_URL} https://example.com
*** Test Cases ***
Website Is Reachable
New Browser chromium headless=True
New Page ${BASE_URL}
Get Title == Example Domain
All Site Links Are Working
${sitemap} GET ${BASE_URL}/sitemap.xml
@{pages} Get Regexp Matches ${sitemap.text} <loc>(.*?)</loc> 1
New Browser chromium headless=True
New Context
@{found_links} Create List
FOR ${page} IN @{pages}
New Page ${page}
@{link_elements} Get Elements a
FOR ${element} IN @{link_elements}
${href} Get Attribute ${element} href
${absolute} Evaluate urllib.parse.urljoin($page, $href) modules=urllib.parse
Append To List ${found_links} ${absolute}
END
Close Page
END
${unique_links} Remove Duplicates ${found_links}
New Page
@{broken} Create List
FOR ${link} IN @{unique_links}
${scheme} Evaluate urllib.parse.urlparse($link).scheme modules=urllib.parse
Continue For Loop If '${scheme}' not in ('http', 'https')
${hostname} Evaluate urllib.parse.urlparse($link).hostname modules=urllib.parse
${is_local} Set Variable ${False}
IF '${hostname}' == 'localhost'
${is_local} Set Variable ${True}
ELSE
${ip_status} ${ip_result} Run Keyword And Ignore Error
... Evaluate
... __import__('ipaddress').ip_address($hostname).is_private or __import__('ipaddress').ip_address($hostname).is_loopback
IF '${ip_status}' == 'PASS'
${is_local} Set Variable ${ip_result}
END
END
Continue For Loop If ${is_local}
${fragment} Evaluate urllib.parse.urlparse($link).fragment modules=urllib.parse
${base_hostname} Evaluate urllib.parse.urlparse($BASE_URL).hostname modules=urllib.parse
${is_same_site_anchor} Evaluate bool($fragment) and $hostname == $base_hostname modules=urllib.parse
Continue For Loop If ${is_same_site_anchor}
${is_bot_blocked} Evaluate
... $hostname in ('linkedin.com', 'www.linkedin.com', 'freedesktop.org', 'www.freedesktop.org') or $hostname == 'readthedocs.io' or $hostname.endswith('.readthedocs.io')
${promise} Promise To Wait For Response response => response.request().resourceType() == 'document' timeout=15s
${nav_status} ${nav_result} Run Keyword And Ignore Error Go To ${link}
IF '${nav_status}' == 'FAIL'
Append To List ${broken} ${link} -> navigation failed
ELSE
${resp_status} ${response} Run Keyword And Ignore Error Wait For ${promise}
IF '${resp_status}' == 'FAIL'
IF not ${is_bot_blocked}
Append To List ${broken} ${link} -> no response received
END
ELSE IF ${is_bot_blocked} and ${response}[status] >= 400
No Operation
ELSE IF ${response}[status] >= 400
Append To List ${broken} ${link} -> ${response}[status]
END
END
END
Close Page
Should Be Empty ${broken} msg=Broken links found: ${broken}On a small blog, this comfortably finishes in under 30 seconds - well inside
a 5-minute plan limit, and worth double-checking with journalctl -u robotmk-scheduler-daemon the first time you deploy it: RCC only re-runs
rccPostInstall and re-resolves dependencies on a genuine cache miss, so if
you’re iterating on the suite itself (not conda.yaml), a stale holotree
environment can otherwise make a broken change look like it built fine.