Skip to content

Writing a Custom Evaluator

Check the lighter option first: if what you actually need is "threshold one number inside a jsonb payload", "match a text value against a pattern", or "alert on the change since the last sample", those are plain threshold parameters now — p_value_path, p_text_pass_pattern, p_delta_kind — no code required; see Adding a Threshold. A custom evaluator is for the conditions those can't express: several fields judged at once, per-entity compound logic, rolling baselines over history.

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 the 8 states pghf._severity_rank() ranks: 'ok', 'info', 'warning', 'critical', 'not_evaluable', and — normally assigned automatically by the engine rather than returned by your own evaluator — 'skipped', 'invalid', 'error_in_performing_check'. An exception raised inside your evaluator is caught by the engine and logged as 'invalid' automatically; you don't need to catch it yourself. 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 check's last 12 raw results — one history stream per check, from any suite that ran it, the same rule the engine's own debounce lookbacks follow — 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 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.