Assertions
An assertion turns “the target answered” into “the target answered correctly”. An HTTP monitor
with no status_code assertion is up only when its probe completes and the status is 200–399;
adding a status_code assertion replaces that default. That is a real signal, and often not the one
you wanted: a login page that returns 200 while serving an error is up by that definition.
Assertions are declarative, evaluated by the probe that ran the check, and stored on the monitor as
assertions. All of them must hold; one failure makes the check down.
Which types take assertions
Section titled “Which types take assertions”| Type | Assertions |
|---|---|
http |
yes |
tcp |
none |
tls |
none |
dns |
none |
ping |
none |
heartbeat |
none |
multistep |
none |
“None” here is enforced, not merely unimplemented: sending a non-empty assertions array on any of
those types is a validation error, so a config that would have been silently ignored is refused
instead.
That is not a gap. For most types the probe is the assertion, and there is nothing left over to compare:
tcp— the connection either completed or it did not.tls— the handshake either verified or it did not. (Certificate pinning and SAN checks are not implemented;warn_daysis a dashboard threshold, not an assertion — see Monitor types.)ping— loss is either withinloss_threshold_pctor it is not.heartbeat— a beat arrived, or none did.dns— the answer check exists, but it lives in the config asexpectedrather than in the assertions array, because there is exactly one thing to compare.multistep— assertions exist, per step, insideconfig.steps[]. The monitor-level array stays empty; see below.
HTTP assertions
Section titled “HTTP assertions”Each assertion is { field, op, value }. The API accepts four families, each with its own operators:
| Field | Operators the API accepts | Evaluated by the probe in WireWatch regions |
|---|---|---|
status_code |
eq, ne, in, lt, gt |
all |
response_time_ms |
lt, lte, gt |
all |
body |
contains, not_contains, matches |
all |
header.<name> |
eq, contains |
all |
status_codetakes a number, except within, which takes a list of numbers. That pairing is enforced:inwith a single number, oreqwith a list, is a validation error rather than a quietly-never-true assertion.response_time_mstakes a non-negative whole number of milliseconds. This is what turns a monitor into a latency SLO —response_time_ms lt 800is down when the target is merely slow.body containsandbody not_containstake a string and test whether it appears in the first 64 KiB of the response body, case-sensitively. The probe reads no further, so text past 64 KiB is never seen:containsfails on it andnot_containsholds.body matchestakes a regular expression and holds if it matches anywhere in that same 64 KiB. It has rules of its own — see Body patterns.header.<name>tests a response header. The name may use only letters, digits and-(the API refuses anything else with a400) and is case-insensitive (header.content-typereadsContent-Type); the value is case-sensitive.eqcompares the whole value,containslooks for a substring. When the response carries the header on several lines, the assertion holds if any one line satisfies it; lines are never joined, socontainscannot match across two of them. A header the response does not carry fails both operators.
A monitor may carry up to 50 assertions.
Body patterns
Section titled “Body patterns”body matches is evaluated with RE2, the regular-expression engine of Go, as an unanchored search:
the pattern holds if it matches anywhere in the first 64 KiB. Use ^ to anchor at the start of the
body. $ anchors at the end of what the probe read, which is the end of the body only when the body
is no longer than 64 KiB.
The API accepts a pattern only if it is valid in both RE2 and JavaScript syntax, and refuses the
monitor otherwise with a 400 whose reason is body_pattern_unsupported. Among what it refuses:
- lookahead and lookbehind;
- backreferences such as
\1or\k<name>, and named groups; - inline flags such as
(?i)— write[Oo][Kk]for a case-insensitiveok; - Unicode property classes
\p{…}and\P{…}, and POSIX classes such as[[:alpha:]]; \A,\zand\Q…\E.
A pattern both engines accept can still match differently in each, and RE2’s meaning is the one that counts: nothing in WireWatch evaluates your pattern as JavaScript. Two differences to know before you test a pattern in a browser console:
.matches\rand U+2028 in RE2, and does not in JavaScript. In both, it does not match\n.\sin RE2 is only space,\t,\n,\fand\r. It does not match\vor a non-breaking space, which JavaScript’s\sdoes.
Each pattern also has a complexity cost, driven mostly by its length and its repetition counts —
{1,1000} costs far more than +. The probe gives every check one complexity limit, shared by all
the matches assertions on the monitor, so the API refuses:
- a pattern too complex on its own:
400,reasonbody_pattern_too_complex; - patterns too complex together:
400,reasonbody_patterns_over_budget.
Patterns stored before these rules
Section titled “Patterns stored before these rules”A monitor saved before these rules keeps its patterns, including ones the API now refuses. The probe
does not run a pattern that uses (?…) flags or groups other than (?:…), \p or \P, that is too
complex, that would exceed the monitor’s shared limit, or that does not compile: that assertion fails,
so every check of the monitor is down, and the probe reports the assertion as
unsupported pattern, pattern too complex, pattern budget exceeded or invalid pattern.
Whether or not the probe runs it, a stored pattern the API now refuses blocks editing: any PATCH to
that monitor answers 400 with one of the reasons above until the pattern is gone. A PATCH that
sends assertions replaces the whole array, so send the corrected array in that same request.
Compressed responses
Section titled “Compressed responses”By default the probe offers gzip and decompresses a gzip answer itself, and the decompressed response
no longer carries Content-Encoding or Content-Length: an assertion on either header is down for a
target that answers in gzip. Setting Accept-Encoding in the monitor’s headers keeps both headers,
but the probe then leaves the body compressed, so body assertions read the compressed bytes.
Accept-Encoding: identity asks the target not to compress; if it honours that, body and
Content-Length assertions both work, and there is no Content-Encoding to assert on. Against a
target that compresses regardless, no setting makes both work.
Transfer-Encoding is never visible to an assertion: the probe removes it from the response headers,
so header.transfer-encoding is down on every check.
An example
Section titled “An example”{ "assertions": [ { "field": "status_code", "op": "in", "value": [200, 204] }, { "field": "response_time_ms", "op": "lt", "value": 1500 }, { "field": "body", "op": "contains", "value": "\"status\":\"ok\"" } ]}Down if the status is anything other than 200 or 204, or the response took a second and a half,
or the payload lost its ok.
Multi-step assertions
Section titled “Multi-step assertions”A multistep monitor asserts per step, over status_code, response_time_ms and body_contains.
They sit inside each step of config.steps, alongside that step’s request and its extractions, and a
failing assertion fails the whole transaction at that step.
The vocabulary is deliberately narrower than the HTTP one above: a synthetic transaction is checked for “did this step do what it should so the next one can run”, and the interesting per-step output is usually extracted into a variable rather than asserted on.
Where assertions are evaluated
Section titled “Where assertions are evaluated”At the probe, not in the control plane. Whichever location ran the check evaluated the assertions and reported the verdict, so two locations can legitimately disagree — one may have reached a stale cache node, or been served a different response entirely. That disagreement is preserved rather than averaged away; it is what the quorum in Probe locations is deciding over.