Writing a Custom Evaluator¶
A flat threshold can't express everything — a rolling rate condition ("N events in M minutes"), compound logic across several fields at once, or a categorical judgment on text. For those, register a function instead:
SELECT pghf.set_threshold('X01-001', p_evaluator_routine => 'pghf.evaluate_x01_001(uuid)'::regprocedure);
Setting an evaluator function replaces the default comparator entirely for that threshold — the numeric tiers are ignored once one is set. The function must match this shape:
CREATE OR REPLACE FUNCTION pghf.evaluate_x01_001(p_run_id uuid)
RETURNS pghf.evaluation_result
LANGUAGE plpgsql
AS $$
BEGIN
-- your logic: query the raw-results or runs tables however you need —
-- you have full SQL access, including looking back across prior runs
-- of the same suite for windowed/rate conditions.
RETURN ROW('warning', 'why this fired', jsonb_build_object('detail', 'whatever you want'))::pghf.evaluation_result;
END;
$$;
pghf.evaluation_result has three fields — severity, message, and detail — and severity must be one of 'ok', 'info', 'warning', 'critical', 'unknown', or 'not_evaluable'. Like check functions, this only needs the one run-ID parameter — the function is registered against one specific check, so it can hardcode which data it looks at, the same way a check function hardcodes what it collects.
Worked example — a self-adjusting baseline instead of a fixed number¶
The framework's built-in evaluator for WAL generation rate is a real, shipped example. Instead of a static threshold, it queries the last 12 raw results for that check within the same suite, computes their average, and flags the current run only if it exceeds three times that trailing average:
SELECT count(*), avg(v.value) INTO v_sample_count, v_avg
FROM (
SELECT (rr.observed ->> 'value')::numeric AS value
FROM pghf.raw_results rr
JOIN pghf.runs r ON r.run_id = rr.run_id
WHERE rr.check_id = 'PGHF14-002' AND rr.status = 'COLLECTED'
AND r.check_run_id = v_check_run_id AND rr.run_id <> p_run_id
ORDER BY r.started_at DESC
LIMIT 12
) v;
Notice there's no external state to maintain — the raw-results history already is durable, cross-run data, so "look back at recent samples" is just an ordinary query. This is the pattern for any check where "is this bad?" depends on recent history rather than a fixed number: a database that already keeps that history means you never need to bolt on your own separate storage to answer the question.
Worked example — a jsonb check with per-entity, compound logic¶
Some checks report a structured breakdown (jsonb) rather than a single number, because they cover a fleet — every standby, every replication peer — rather than one scalar answer. There's no single "value" to threshold, and the pass/fail condition itself is often compound. Two of the framework's built-in evaluators show this pattern; both unpack the array and reduce it to the worst case before deciding.
The replication-lag evaluator's breach condition depends on which entity is worst, not just the raw number: replay lag over 30 seconds on any standby is a warning, but over 5 seconds specifically on a synchronous standby is a critical — a stricter threshold that only applies in one particular mode, which no single numeric tier could express:
SELECT max((elem ->> 'replay_lag_seconds')::int) INTO v_max_any
FROM jsonb_array_elements(v_observed) elem;
SELECT max((elem ->> 'replay_lag_seconds')::int) INTO v_max_sync
FROM jsonb_array_elements(v_observed) elem
WHERE elem ->> 'sync_state' = 'sync';
IF v_max_sync IS NOT NULL AND v_max_sync > 5 THEN
RETURN ROW('critical', format('a synchronous standby has replay lag of %s seconds (> 5s)', v_max_sync), ...)::pghf.evaluation_result;
END IF;
IF v_max_any IS NOT NULL AND v_max_any > 30 THEN
RETURN ROW('warning', format('a standby has replay lag of %s seconds (> 30s)', v_max_any), ...)::pghf.evaluation_result;
END IF;
The Spock apply-lag evaluator's breach condition is an OR across two entirely different units (lag over 300 seconds, or over 100MB, on any peer), which a single-column comparator can't express since there's no shared scale between seconds and bytes:
SELECT max((elem ->> 'lag_bytes')::bigint), max((elem ->> 'lag_seconds')::bigint)
INTO v_max_bytes, v_max_secs
FROM jsonb_array_elements(v_observed) elem;
IF (v_max_secs IS NOT NULL AND v_max_secs > 300) OR (v_max_bytes IS NOT NULL AND v_max_bytes > 104857600) THEN
RETURN ROW('warning', format('Spock apply lag exceeds threshold (max %s seconds, max %s bytes)', COALESCE(v_max_secs, 0), COALESCE(v_max_bytes, 0)), ...)::pghf.evaluation_result;
END IF;
Both start with the same guard — an empty or missing array means there's nothing to judge, not a breach:
IF v_status IS DISTINCT FROM 'COLLECTED' OR v_observed IS NULL OR jsonb_array_length(v_observed) = 0 THEN
RETURN ROW('not_evaluable', 'no connected standbys to evaluate replication lag for', NULL)::pghf.evaluation_result;
END IF;
Both also reduce to the worst case across the unpacked array elements, rather than comparing each row individually — the same "reduce a fleet to its worst offender" idea as the percentage-of-a-ceiling checks' approach in the previous chapter, just applied across a JSON array's elements instead of table rows. A field that's simply absent on every peer never spuriously satisfies a condition either — taking the maximum across a set of values naturally ignores anything missing, rather than treating it as zero.
Continue to Firing an Event on a Severity Change.