Functional Weave
Code in Rust

monitor.alert-rule

Evaluate a threshold alert rule over a metric series: inactive, pending, firing or no-data, Prometheus style.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 25 tests, run in TypeScript, Python and Rust.

What it does

Evaluates one threshold alert rule ("CPU above 90% for 5 minutes", "free disk below 10 GB") over a stored metric series at a moment `now`, and says whether it is `inactive`, `pending`, `firing` or `no-data`.

## How it decides

For example

  • evaluate_alert_rule(samples ×4, op gt, threshold 90, clear threshold —, for seconds 120, stale after seconds —, 180) → state firing, since 60, value 99, held seconds 120 a breach held for the whole for-duration is firing
  • evaluate_alert_rule(samples ×4, op gt, threshold 90, clear threshold —, for seconds 120, stale after seconds —, 179) → state pending, since 60, value 97, held seconds 119 one second short of the for-duration is still pending
  • evaluate_alert_rule(samples ×2, op gt, threshold 90, clear threshold —, for seconds 0, stale after seconds —, 60) → state firing, since 60, value 95, held seconds 0 forSeconds 0 fires on the first breaching sample

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

pub fn evaluate_alert_rule(samples: &[MetricSample], rule: &ThresholdRule, now: i64) -> RuleEvaluation
samplesMetricSample[]in strictly ascending time order; samples after now are ignored
ruleThresholdRule
nowintUnix seconds of this evaluation
returnsRuleEvaluation

The types it declares, generated into your project

// CompareOp is a string in Rust, one of: "gt", "gte", "lt", "lte".
// Parameters take it as &str and results hold it as String.

/// When a metric is breaching, and for how long before it fires.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThresholdRule {
    /// how the value is compared with the threshold (gt: value > threshold)
    pub op: String,
    /// the value that starts a breach
    pub threshold: i64,
    /// once breaching, the value must pass this the other way to clear; null = threshold
    pub clear_threshold: Option<i64>,
    /// how long a breach must last before it fires (Prometheus `for`), 0 = at once
    pub for_seconds: i64,
    /// a latest sample older than this is no data; null = never stale
    pub stale_after_seconds: Option<i64>,
}

// RuleState is a string in Rust, one of: "inactive", "pending", "firing", "no-data".
// Parameters take it as &str and results hold it as String.

/// The rule's state now, and the breach behind it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuleEvaluation {
    pub state: String,
    /// at of the sample that started the current breach; null when not breaching
    pub since: Option<i64>,
    /// the latest value at or before now; null when there is none
    pub value: Option<i64>,
    /// now - since while breaching, else 0
    pub held_seconds: i64,
}

Your code names it in one line, in the file that uses it

fune!(monitor.alert-rule@^1);  // then call evaluate_alert_rule(…)
impl/rust.rs · 121 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::monitor_series_window::{samples_from_value, series_window, MetricSample};  ← from monitor.series-window ^1.0.0 · built alongside by fune

fn beyond(op: &str, value: i64, limit: i64) -> bool {
    match op {
        "gt" => value > limit,
        "gte" => value >= limit,
        "lt" => value < limit,
        "lte" => value <= limit,
        other => panic!("unknown comparison: {}", other),
    }
}

/// The state of a threshold rule at `now`, Prometheus style: a breach must
/// hold for `for_seconds` before it fires, and until then it is pending.
///
/// With a clear_threshold a breach clears only once the value passes it the
/// other way, so a metric wobbling around the threshold does not flap between
/// firing and inactive. The breach clock (`since`) restarts after a clear.
///
/// # Panics
/// Panics on an unknown comparison, a clear threshold on the breaching side,
/// negative durations, or samples out of time order.
pub fn evaluate_alert_rule(samples: &[MetricSample], rule: &ThresholdRule, now: i64) -> RuleEvaluation {
    let op = rule.op.as_str();
    let threshold = rule.threshold;
    let clear = rule.clear_threshold.unwrap_or(threshold);
    beyond(op, 0, 0);
    let upward = op == "gt" || op == "gte";
    if if upward { clear > threshold } else { clear < threshold } {
        panic!(
            "clearThreshold must be on the non-breaching side of threshold: {} {}, clear {}",
            op, threshold, clear
        );
    }
    if rule.for_seconds < 0 {
        panic!("forSeconds must not be negative, received {}", rule.for_seconds);
    }
    if let Some(stale) = rule.stale_after_seconds {
        if stale < 0 {
            panic!("staleAfterSeconds must not be negative, received {}", stale);
        }
    }

    // series_window checks the order of the whole series and keeps at <= now.
    let from = if samples.is_empty() { now + 1 } else { samples[0].at.min(now + 1) };
    let seen = series_window(samples, from, now + 1);

    let mut since: Option<i64> = None;
    for s in &seen {
        match since {
            None => {
                if beyond(op, s.value, threshold) {
                    since = Some(s.at);
                }
            }
            Some(_) => {
                if !beyond(op, s.value, clear) {
                    since = None;
                }
            }
        }
    }
    let latest = match seen.last() {
        None => return RuleEvaluation { state: "no-data".to_string(), since: None, value: None, held_seconds: 0 },
        Some(s) => s,
    };
    if let Some(stale) = rule.stale_after_seconds {
        if now - latest.at > stale {
            return RuleEvaluation { state: "no-data".to_string(), since: None, value: Some(latest.value), held_seconds: 0 };
        }
    }
    match since {
        None => RuleEvaluation { state: "inactive".to_string(), since: None, value: Some(latest.value), held_seconds: 0 },
        Some(start) => {
            let held = now - start;
            let state = if held >= rule.for_seconds { "firing" } else { "pending" };
            RuleEvaluation { state: state.to_string(), since: Some(start), value: Some(latest.value), held_seconds: held }
        }
    }
}

fn opt_i64(v: &Value) -> Option<i64> {
    if v.is_null() { None } else { Some(v.as_i64()) }
}

fn opt_to_value(v: Option<i64>) -> Value {
    match v {
        Some(i) => Value::Int(i),
        None => Value::Null,
    }
}

pub fn threshold_rule_from_value(v: &Value) -> ThresholdRule {
    ThresholdRule {
        op: v.get("op").as_str().to_string(),
        threshold: v.get("threshold").as_i64(),
        clear_threshold: opt_i64(v.get("clearThreshold")),
        for_seconds: v.get("forSeconds").as_i64(),
        stale_after_seconds: opt_i64(v.get("staleAfterSeconds")),
    }
}

pub fn rule_evaluation_to_value(r: &RuleEvaluation) -> Value {
    Value::obj(vec![
        ("state", Value::str(&r.state)),
        ("since", opt_to_value(r.since)),
        ("value", opt_to_value(r.value)),
        ("heldSeconds", Value::Int(r.held_seconds)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let samples = samples_from_value(&args[0]);
    let rule = threshold_rule_from_value(&args[1]);
    let now = match &args[2] {
        Value::Int(i) => *i,
        _ => panic!("now must be a whole number of seconds"),
    };
    rule_evaluation_to_value(&evaluate_alert_rule(&samples, &rule, now))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add monitor.alert-rule
Download for Rust monitor.alert-rule-1.0.0-rust.fune · 19,808 bytes sha256 0863f502f2bb3693029d4c82c3724e324dbfaa0e376163925b0bc7b3e634c9db

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./monitor.alert-rule-1.0.0-rust.fune, or fetch it from a terminal with fune pull monitor.alert-rule@1.0.0:rust.

The whole function, every language, is one file too: monitor.alert-rule-1.0.0.fune, 25,566 bytes, sha256 2269259d07d83cc2869899f3d1fabd2898ed5f870612b99233b4bb3593b8a2f3. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before monitor.alert-rule

after — your function gets the result and the arguments, and returns the final result.

// fune: after monitor.alert-rule

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace monitor.series-window in monitor.alert-rule

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show monitor.alert-rule --steps.

// fune: step monitor.alert-rule after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
a breach held for the whole for-duration is firing samples ×4, op gt, threshold 90, clear threshold —, for seconds 120, stale after seconds —, 180 → state firing, since 60, value 99, held seconds 120
one second short of the for-duration is still pending samples ×4, op gt, threshold 90, clear threshold —, for seconds 120, stale after seconds —, 179 → state pending, since 60, value 97, held seconds 119
forSeconds 0 fires on the first breaching sample samples ×2, op gt, threshold 90, clear threshold —, for seconds 0, stale after seconds —, 60 → state firing, since 60, value 95, held seconds 0
below the threshold is inactive samples ×2, op gt, threshold 90, clear threshold —, for seconds 60, stale after seconds —, 60 → state inactive, since —, value 80, held seconds 0
gt: a value exactly at the threshold is not a breach samples ×1, op gt, threshold 90, clear threshold —, for seconds 0, stale after seconds —, 0 → state inactive, since —, value 90, held seconds 0
gte: a value exactly at the threshold is a breach samples ×1, op gte, threshold 90, clear threshold —, for seconds 0, stale after seconds —, 0 → state firing, since 0, value 90, held seconds 0
hysteresis: dipping below the threshold but above the clear level keeps the breach samples ×3, op gt, threshold 90, clear threshold 80, for seconds 60, stale after seconds —, 120 → state firing, since 0, value 85, held seconds 120
hysteresis: reaching the clear level clears, and a value under the threshold does not re-breach samples ×3, op gt, threshold 90, clear threshold 80, for seconds 60, stale after seconds —, 120 → state inactive, since —, value 85, held seconds 0
a new breach after a clear restarts the clock, it does not reuse the old start samples ×4, op gt, threshold 90, clear threshold —, for seconds 120, stale after seconds —, 200 → state pending, since 120, value 96, held seconds 80
lt with a clear level above: low disk space stays breaching until it recovers past 20 samples ×3, op lt, threshold 10, clear threshold 20, for seconds 60, stale after seconds —, 150 → state firing, since 60, value 12, held seconds 90
Show the other 15 tests
CaseArgumentsExpected
lt: recovering past the clear level is inactive samples ×4, op lt, threshold 10, clear threshold 20, for seconds 60, stale after seconds —, 180 → state inactive, since —, value 25, held seconds 0
lte: a value at the threshold breaches and waits out the for-duration samples ×1, op lte, threshold 10, clear threshold —, for seconds 30, stale after seconds —, 20 → state pending, since 0, value 10, held seconds 20
no samples at all is no data , op gt, threshold 90, clear threshold —, for seconds 0, stale after seconds —, 100 → state no-data, since —, value —, held seconds 0
samples only after now are ignored, so there is no data yet samples ×1, op gt, threshold 90, clear threshold —, for seconds 0, stale after seconds —, 50 → state no-data, since —, value —, held seconds 0
samples after now are ignored samples ×2, op gt, threshold 90, clear threshold —, for seconds 0, stale after seconds —, 30 → state firing, since 0, value 95, held seconds 30
a latest sample older than staleAfterSeconds is no data, reporting the stale value samples ×1, op gt, threshold 90, clear threshold —, for seconds 0, stale after seconds 60, 61 → state no-data, since —, value 99, held seconds 0
a latest sample exactly staleAfterSeconds old still counts samples ×1, op gt, threshold 90, clear threshold —, for seconds 0, stale after seconds 60, 60 → state firing, since 0, value 99, held seconds 60
negative values breach an lt rule samples ×1, op lt, threshold 0, clear threshold —, for seconds 0, stale after seconds —, 0 → state firing, since 0, value -5, held seconds 0
samples out of time order are an error samples ×2, op gt, threshold 90, clear threshold —, for seconds 0, stale after seconds —, 100 → error: samples must be in strictly ascending time order
a clear level above a gt threshold is an error samples ×1, op gt, threshold 90, clear threshold 95, for seconds 0, stale after seconds —, 0 → error: clearThreshold must be on the non-breaching side of threshold: gt 90, clear 95
a clear level below an lt threshold is an error samples ×1, op lt, threshold 10, clear threshold 5, for seconds 0, stale after seconds —, 0 → error: clearThreshold must be on the non-breaching side of threshold: lt 10, clear 5
a negative for-duration is an error samples ×1, op gt, threshold 90, clear threshold —, for seconds -1, stale after seconds —, 0 → error: forSeconds must not be negative
a negative stale limit is an error samples ×1, op gt, threshold 90, clear threshold —, for seconds 0, stale after seconds -1, 0 → error: staleAfterSeconds must not be negative
an unknown comparison is an error samples ×1, op eq, threshold 90, clear threshold —, for seconds 0, stale after seconds —, 0 → error: unknown comparison: eq
a fractional now is an error samples ×1, op gt, threshold 90, clear threshold —, for seconds 0, stale after seconds —, 1.5 → error: now must be a whole number of seconds

More from the author

1. Only samples with `at <= now` are looked at (a series stored ahead of an evaluation time, as in a replay, does not leak the future). The whole series must still be in strictly ascending time order. 2. The samples are walked in order, carrying a breach: - not breaching: a sample *beyond* `threshold` (by `op`: `gt` is `value > threshold`, `gte` `>=`, `lt` `<`, `lte` `<=`) starts a breach; `since` is that sample's `at`. - breaching: the breach continues while the value is still beyond `clearThreshold` by the same comparison, and clears on the first sample that is not. With `clearThreshold` null it is the threshold itself, so there is no hysteresis. 3. No sample at or before `now`: **no-data**. A `staleAfterSeconds` limit and a latest sample more than that old (`now - at > staleAfterSeconds`): **no-data** too, because a silent exporter is not a healthy one; `value` then reports that stale latest value, and `since` is null. 4. Latest sample breaching: **firing** when `now - since >= forSeconds`, otherwise **pending**. Not breaching: **inactive**.

`heldSeconds` is `now - since` while breaching (pending or firing), else 0.

## Why this shape

The `for` duration follows Prometheus alerting rules: "The optional `for` clause causes Prometheus to wait for a certain duration between first encountering a new expression output vector element and counting an alert as firing for this element", and "Elements that are active, but not firing yet, are in the pending state." Prometheus fires once the alert has been active for at least `for` (`>=`), and with no `for` it fires on the first evaluation; `forSeconds: 0` does the same here.

Hysteresis (a separate clear level) is what stops an alert from flapping when a metric hovers around its threshold: "above 90 to fire, back under 80 to clear". `clearThreshold` must be on the non-breaching side: at or below the threshold for `gt`/`gte`, at or above it for `lt`/`lte`.

A new breach after a clear restarts `since`: the for-duration is about one continuous breach, not the sum of several. Prometheus behaves the same way: an alert whose expression stops returning goes back to inactive and starts over.

Unlike Prometheus this looks at the stored samples, not at a query result per evaluation, so the breach is judged sample by sample; between samples the last value holds.

## Errors

- `samples must be in strictly ascending time order` (from monitor.series-window) - `clearThreshold must be on the non-breaching side of threshold: gt 90, clear 95` - `forSeconds must not be negative` - `staleAfterSeconds must not be negative` - `unknown comparison: eq` - `now must be a whole number of seconds`

## Sources

- Prometheus, "Alerting rules", https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/ (the `for` clause, pending and firing states).

Files

PathBytes
README.md3,129
impl/python.py2,817
impl/rust.rs4,383
impl/typescript.ts2,721
vectors.json7,547