Functional Weave
Code in Rust

monitor.burn-rate-alert

Multiwindow, multi-burn-rate SLO alerting: which rules fire, from event counts per window (Google SRE workbook).

1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra

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

What it does

SLO alerting the way the Google SRE workbook recommends: multiwindow, multi-burn-rate. Pass the SLO target, the event counts you have for each window length, and the rules (or null for the workbook's), and it says which rules fire and at what severity.

A rule fires when **both** its long window and its short window burn at or above its threshold (see `monitor.burn-rate`). The long window proves enough budget has gone to matter; the short window proves it is still going, so the alert stops soon after the problem does instead of paging for an hour after a five-minute outage.

For example

  • burn_rate_alert(99.9%, windows ×5, —) → firing false, severity —, rules ×3 all quiet: no errors in any window, nothing fires
  • burn_rate_alert(99.9%, windows ×5, —) → firing true, severity page, rules ×3 fast burn: 15x over the last hour and 20x in the last 5 minutes pages; the ticket rule fires too, the page wins
  • burn_rate_alert(99.9%, windows ×5, —) → firing false, severity —, rules ×3 the outage is over: the hour still burns 15x but the last 5 minutes are clean, so nothing pages

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 burn_rate_alert(target_basis_points: i64, windows: &[WindowCount], rules: Option<&[BurnRule]>) -> BurnAlert
target_basis_pointsintthe SLO: 9990 = 99.9%; 1 to 9999
windowsWindowCount[]event counts for each window length the rules use, once each
rulesBurnRule[]?null for the SRE workbook's three rules (Table 5-8)
returnsBurnAlert

The types it declares, generated into your project

/// Events seen over the last windowSeconds.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WindowCount {
    /// at least 1
    pub window_seconds: i64,
    pub total_events: i64,
    pub bad_events: i64,
}

/// Fire when both windows burn at or above the threshold.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BurnRule {
    /// e.g. page or ticket
    pub severity: String,
    pub long_window_seconds: i64,
    /// at most longWindowSeconds; the workbook uses 1/12 of it
    pub short_window_seconds: i64,
    /// threshold, thousandths: 14400 = 14.4x
    pub burn_rate_milli: i64,
}

/// One rule, its threshold and both burn rates.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BurnRuleResult {
    pub severity: String,
    pub long_window_seconds: i64,
    pub short_window_seconds: i64,
    pub threshold_milli: i64,
    /// half-up; firing compares exactly, not this rounded value
    pub long_burn_milli: i64,
    pub short_burn_milli: i64,
    pub firing: bool,
}

/// Whether anything fires, the first firing rule's severity, and every rule.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BurnAlert {
    pub firing: bool,
    /// the first firing rule in rule order; null when none fires
    pub severity: Option<String>,
    /// in rule order
    pub rules: Vec<BurnRuleResult>,
}

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

fune!(monitor.burn-rate-alert@^1);  // then call burn_rate_alert(…)
impl/rust.rs · 145 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_burn_rate::burn_rate;  ← from monitor.burn-rate ^1.0.0 · built alongside by fune
use super::monitor_burn_rate_alert_data::SRE_WORKBOOK;  ← this capability’s own data, compiled from data/sre-workbook.json into the same file by fune build

// bad/total >= threshold/1000 x (10000 - target)/10000, cross-multiplied so a
// burn of 14.3995x never fires a 14.4x rule by rounding up.
fn at_or_above(w: &WindowCount, threshold_milli: i64, target_basis_points: i64) -> bool {
    if w.total_events == 0 {
        return false;
    }
    w.bad_events as i128 * 10_000_000
        >= threshold_milli as i128 * w.total_events as i128 * (10000 - target_basis_points) as i128
}

/// Multiwindow, multi-burn-rate SLO alerting, as the Google SRE workbook
/// recommends: a rule fires only when both its long window (enough budget
/// spent to matter) and its short window (still happening now) burn at or
/// above its threshold. With rules None it uses the workbook's Table 5-8.
///
/// # Panics
/// Panics on a bad target, window, count or rule, or a rule whose window has no counts.
pub fn burn_rate_alert(target_basis_points: i64, windows: &[WindowCount], rules: Option<&[BurnRule]>) -> BurnAlert {
    if !(1..=9999).contains(&target_basis_points) {
        panic!(
            "targetBasisPoints must be a whole number from 1 to 9999 (10000 leaves no error budget), received {}",
            target_basis_points
        );
    }
    let mut counts: Vec<(&WindowCount, i64)> = Vec::new();
    for w in windows {
        if w.window_seconds < 1 {
            panic!("windowSeconds must be at least 1, received {}", w.window_seconds);
        }
        if counts.iter().any(|(c, _)| c.window_seconds == w.window_seconds) {
            panic!("duplicate counts for a {}-second window", w.window_seconds);
        }
        // burn_rate checks the counts, so every window is checked, used or not.
        counts.push((w, burn_rate(target_basis_points, w.total_events, w.bad_events)));
    }
    let workbook: Vec<BurnRule>;
    let chosen: &[BurnRule] = match rules {
        Some(r) => r,
        None => {
            workbook = SRE_WORKBOOK
                .iter()
                .map(|r| BurnRule {
                    severity: r.severity.to_string(),
                    long_window_seconds: r.long_window_seconds,
                    short_window_seconds: r.short_window_seconds,
                    burn_rate_milli: r.burn_rate_milli,
                })
                .collect();
            &workbook
        }
    };
    if chosen.is_empty() {
        panic!("rules must not be empty; pass null for the SRE workbook rules");
    }
    let find = |seconds: i64| -> (&WindowCount, i64) {
        match counts.iter().find(|(c, _)| c.window_seconds == seconds) {
            Some((c, burn)) => (*c, *burn),
            None => panic!("no counts for a {}-second window", seconds),
        }
    };
    let mut results: Vec<BurnRuleResult> = Vec::new();
    let mut severity: Option<String> = None;
    for rule in chosen {
        if rule.severity.is_empty() {
            panic!("severity must not be empty");
        }
        if rule.short_window_seconds < 1 {
            panic!("shortWindowSeconds must be at least 1, received {}", rule.short_window_seconds);
        }
        if rule.short_window_seconds > rule.long_window_seconds {
            panic!(
                "shortWindowSeconds must not exceed longWindowSeconds: {} > {}",
                rule.short_window_seconds, rule.long_window_seconds
            );
        }
        if rule.burn_rate_milli < 1 {
            panic!("burnRateMilli must be at least 1, received {}", rule.burn_rate_milli);
        }
        let (long_count, long_burn) = find(rule.long_window_seconds);
        let (short_count, short_burn) = find(rule.short_window_seconds);
        let firing = at_or_above(long_count, rule.burn_rate_milli, target_basis_points)
            && at_or_above(short_count, rule.burn_rate_milli, target_basis_points);
        if firing && severity.is_none() {
            severity = Some(rule.severity.clone());
        }
        results.push(BurnRuleResult {
            severity: rule.severity.clone(),
            long_window_seconds: rule.long_window_seconds,
            short_window_seconds: rule.short_window_seconds,
            threshold_milli: rule.burn_rate_milli,
            long_burn_milli: long_burn,
            short_burn_milli: short_burn,
            firing,
        });
    }
    BurnAlert { firing: severity.is_some(), severity, rules: results }
}

pub fn window_count_from_value(v: &Value) -> WindowCount {
    WindowCount {
        window_seconds: v.get("windowSeconds").as_i64(),
        total_events: v.get("totalEvents").as_i64(),
        bad_events: v.get("badEvents").as_i64(),
    }
}

pub fn burn_rule_from_value(v: &Value) -> BurnRule {
    BurnRule {
        severity: v.get("severity").as_str().to_string(),
        long_window_seconds: v.get("longWindowSeconds").as_i64(),
        short_window_seconds: v.get("shortWindowSeconds").as_i64(),
        burn_rate_milli: v.get("burnRateMilli").as_i64(),
    }
}

pub fn burn_rule_result_to_value(r: &BurnRuleResult) -> Value {
    Value::obj(vec![
        ("severity", Value::str(&r.severity)),
        ("longWindowSeconds", Value::Int(r.long_window_seconds)),
        ("shortWindowSeconds", Value::Int(r.short_window_seconds)),
        ("thresholdMilli", Value::Int(r.threshold_milli)),
        ("longBurnMilli", Value::Int(r.long_burn_milli)),
        ("shortBurnMilli", Value::Int(r.short_burn_milli)),
        ("firing", Value::Bool(r.firing)),
    ])
}

pub fn burn_alert_to_value(a: &BurnAlert) -> Value {
    Value::obj(vec![
        ("firing", Value::Bool(a.firing)),
        ("severity", match &a.severity { Some(s) => Value::str(s), None => Value::Null }),
        ("rules", Value::Arr(a.rules.iter().map(burn_rule_result_to_value).collect())),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let windows: Vec<WindowCount> = args[1].as_arr().iter().map(window_count_from_value).collect();
    let rules: Option<Vec<BurnRule>> =
        if args[2].is_null() { None } else { Some(args[2].as_arr().iter().map(burn_rule_from_value).collect()) };
    burn_alert_to_value(&burn_rate_alert(args[0].as_i64(), &windows, rules.as_deref()))
}

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.burn-rate-alert
Download for Rust monitor.burn-rate-alert-1.0.0-rust.fune · 28,190 bytes sha256 0011991bfa4a7bf0de8daf5959636d795381c6e20949f99a0a01f73c50982888

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

The whole function, every language, is one file too: monitor.burn-rate-alert-1.0.0.fune, 36,710 bytes, sha256 cb79d96eae45df07f1b3036b2566d85833b6bb124aef7b4581d67c7b86abf29b. 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.burn-rate-alert

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

// fune: after monitor.burn-rate-alert

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.burn-rate in monitor.burn-rate-alert

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.burn-rate-alert --steps.

// fune: step monitor.burn-rate-alert 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
all quiet: no errors in any window, nothing fires 99.9%, windows ×5, — → firing false, severity —, rules ×3
fast burn: 15x over the last hour and 20x in the last 5 minutes pages; the ticket rule fires too, the page wins 99.9%, windows ×5, — → firing true, severity page, rules ×3
the outage is over: the hour still burns 15x but the last 5 minutes are clean, so nothing pages 99.9%, windows ×5, — → firing false, severity —, rules ×3
slow burn: 1.2x over three days and 1.5x over six hours raises a ticket, not a page 99.9%, windows ×5, — → firing true, severity ticket, rules ×3
7x for six hours fires the second page rule; windows may come in any order 99.9%, windows ×5, — → firing true, severity page, rules ×3
exactly at the threshold fires: 1.44% errors is 14.4x at 99.9% 99.9%, windows ×2, rules ×1 → firing true, severity page, rules ×1
14.3995x rounds to 14400 for display but is below 14.4x, so it does not fire 99.9%, windows ×2, rules ×1 → firing false, severity —, rules ×1
no traffic burns nothing and fires nothing 99.9%, windows ×2, rules ×1 → firing false, severity —, rules ×1
when several rules fire, the first in rule order gives the severity 99.9%, windows ×2, rules ×2 → firing true, severity ticket, rules ×2
a 99% SLO allows ten times the errors: 2% errors is only 2x 99%, windows ×2, rules ×1 → firing false, severity —, rules ×1
Show the other 11 tests
CaseArgumentsExpected
a single-window rule: short equal to long 99.9%, windows ×1, rules ×1 → firing true, severity ticket, rules ×1
the workbook rules with no counts at all name the first missing window 99.9%, , — → error: no counts for a 3600-second window
a rule's short window missing from the counts is an error 99.9%, windows ×1, rules ×1 → error: no counts for a 300-second window
two counts for one window length is an error 99.9%, windows ×3, rules ×1 → error: duplicate counts for a 300-second window
a 100% target is an error 100%, windows ×2, rules ×1 → error: targetBasisPoints must be a whole number from 1 to 9999 (10000 leaves no error budget), received 10000
a zero-length window is an error 99.9%, windows ×1, rules ×1 → error: windowSeconds must be at least 1, received 0
bad counts are checked even in a window no rule uses 99.9%, windows ×3, rules ×1 → error: badEvents must not exceed totalEvents: 5 > 4
an empty rule list is an error; null means the workbook rules 99.9%, windows ×1, → error: rules must not be empty; pass null for the SRE workbook rules
a short window longer than the long window is an error 99.9%, windows ×2, rules ×1 → error: shortWindowSeconds must not exceed longWindowSeconds: 3600 > 300
a zero threshold is an error 99.9%, windows ×2, rules ×1 → error: burnRateMilli must be at least 1, received 0
an empty severity is an error 99.9%, windows ×2, rules ×1 → error: severity must not be empty

More from the author

## The default rules (rules = null)

Shipped as `data/sre-workbook.json`, from Table 5-8 of the workbook's "Alerting on SLOs" chapter, for a 30-day SLO period:

| Severity | Long window | Short window | Burn rate | Budget consumed | |----------|-------------|--------------|-----------|-----------------| | page | 1 hour | 5 minutes | 14.4 | 2% | | page | 6 hours | 30 minutes | 6 | 5% | | ticket | 3 days | 6 hours | 1 | 10% |

Budget consumed = burn rate x long window / 30 days; the short window is 1/12 of the long one, as the workbook suggests. So the windows to count are 300, 1800, 3600, 21600 and 259200 seconds. The table has no `effective` dates: it is a published recommendation, not a rule that changes in force.

## Decisions

- **Firing is exact.** It compares bad x 10,000,000 against threshold x total x (10000 - target), not the rounded `longBurnMilli`: a burn of 14.3995x shows as `14400` but does not fire a 14.4x rule. - **Severity is the first firing rule in rule order.** List pages before tickets (the workbook table already does), and a fast burn that also trips the ticket rule still pages. - **An empty window (total 0) never fires.** - **Every window is checked**, including ones no rule uses: bad counts in any of them are a bug upstream. - Each rule needs counts for exactly its two window lengths, so the caller counts once per distinct length; a rule with short = long is allowed (a single-window alert). - `rules` = `[]` is an error, since it is far more likely a config mistake than a wish never to alert; null means the workbook rules.

## Errors

- `targetBasisPoints must be a whole number from 1 to 9999 (10000 leaves no error budget), received X` - `windowSeconds must be at least 1, received X` - `duplicate counts for a 3600-second window` - the count errors of `monitor.burn-rate` (`badEvents must not exceed totalEvents: B > T`, ...) - `rules must not be empty; pass null for the SRE workbook rules` - `severity must not be empty` - `shortWindowSeconds must be at least 1, received X` - `shortWindowSeconds must not exceed longWindowSeconds: S > L` - `burnRateMilli must be at least 1, received X` - `no counts for a 3600-second window`

## Sources

Google SRE Workbook, chapter 5 "Alerting on SLOs", the multiwindow, multi-burn-rate alerts section and Table 5-8 (https://sre.google/workbook/alerting-on-slos/): page 1h / 5m / 14.4 / 2%; page 6h / 30m / 6 / 5%; ticket 3d / 6h / 1 / 10%; "make the short window 1/12 the duration of the long window".

Files

PathBytes
README.md3,210
data/sre-workbook.json1,025
impl/python.py4,332
impl/rust.rs6,185
impl/typescript.ts3,971
vectors.json11,788