Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions grafana-alertcheck/.changeset/v0.1.8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- Add `--exclude-alerts <file|->`: an enumerated list subtracted from the selected alert set, combinable with both `--alerts` and label selection. A name that does not resolve is an error, and excluding the whole selection exits `2`.
- Rename `--no-fail-fast` to `--fail-fast`, on by default. Pass `--fail-fast=false` to wait for the full window and its coverage proof.
- When alerts are selected by labels, print each matched rule on its own line before the planned run time, so it is clear what was actually selected.
- Round an observation window with a subsecond part up to the next whole second (by extending `to`), so a plan never reads `window 9m59.99445781s`.
- Drop the warning that `transitionGrace` exceeds a quarter of the window; it was noise, not an actionable condition.
3 changes: 2 additions & 1 deletion grafana-alertcheck/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ grafana-alertcheck check --in /tmp/run.jsonl --from "$deployed_at" --to "$finish
```

Or select alerts by label instead of a file: `--include-labels team=bcm,env=stage` (optionally
`--exclude-labels`). See the [CLI reference](./docs/reference/cli.md#selecting-alerts-by-labels).
`--exclude-labels`). Either selection can be refined with `--exclude-alerts <file>`. See the
[CLI reference](./docs/reference/cli.md#selecting-alerts-by-labels).

Requires Grafana >= 13.0.0 and < 14.0.0. Connection details come from the environment only — the token is
never a flag.
Expand Down
25 changes: 20 additions & 5 deletions grafana-alertcheck/cmd/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import (
)

const checkUsage = "usage: grafana-alertcheck check [--in <file>] [--pidfile F] --from RFC3339 --to RFC3339 " +
"[--alerts ... [--folder F] | --include-labels k=v,... [--exclude-labels k=v,...]] " +
"[--alerts ... [--folder F] | --include-labels k=v,... [--exclude-labels k=v,...]] [--exclude-alerts ...] " +
"[--states ...] [--preexisting ...] [--min-observed N] [--allow-paused] " +
"[--nodata-is-unobservable] [--no-fail-fast] [--concurrency N] [--output json]"
"[--nodata-is-unobservable] [--fail-fast=false] [--concurrency N] [--output json]"

// runCheck is the classify step's CLI surface: parse flags into a gate.Config,
// run gate.Check, and translate its (Result, error) into output and an exit
Expand All @@ -39,7 +39,7 @@ func runCheck(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
minObserved := fs.Int("min-observed", 0, "minimum rules that must be observed (default: every resolved rule)")
allowPaused := fs.Bool("allow-paused", false, "do not count a rule paused before the window against --min-observed")
nodataIsUnobservable := fs.Bool("nodata-is-unobservable", false, "treat a sustained health=nodata as unobservable rather than a note")
noFailFast := fs.Bool("no-fail-fast", false, "collect to to+transitionGrace even after a certain failure, for a full-window coverage proof instead of the fastest feedback")
failFast := fs.Bool("fail-fast", true, "stop as soon as a failure that cannot become a pass is observed; --fail-fast=false waits for the full window and its coverage proof")
output := fs.String("output", "", `"json" writes the machine-readable Result to stdout in addition to the table; default is the table alone`)

if err := fs.Parse(args); err != nil {
Expand All @@ -60,6 +60,15 @@ func runCheck(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "check: --alerts cannot be combined with label selection")
return 2
}
if *common.alerts == "-" && *common.excludeAlerts == "-" {
fmt.Fprintln(stderr, "check: --alerts and --exclude-alerts cannot both read from stdin")
return 2
}
// Refused with a log before reading: `--exclude-alerts -` would block on stdin.
if *in != "" && *common.excludeAlerts != "" {
fmt.Fprintf(stderr, "check: --exclude-alerts is refused with a recorded log: %s already names the alert set it recorded\n", *in)
return 2
}
includeLabels, err := parseLabelPairs("--include-labels", *common.includeLabels)
if err != nil {
fmt.Fprintln(stderr, err)
Expand All @@ -76,7 +85,12 @@ func runCheck(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, err)
return 2
}
alerts, err := readAlerts(stdin, *common.alerts)
alerts, err := readAlerts(stdin, "--alerts", *common.alerts)
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
excludeAlerts, err := readAlerts(stdin, "--exclude-alerts", *common.excludeAlerts)
if err != nil {
fmt.Fprintln(stderr, err)
return 2
Expand All @@ -99,12 +113,13 @@ func runCheck(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
Folder: *common.folder,
IncludeLabels: includeLabels,
ExcludeLabels: excludeLabels,
ExcludeAlerts: excludeAlerts,
States: stateList,
Preexisting: preexistingPolicy,
MinObserved: *minObserved,
AllowPaused: *allowPaused,
NodataIsUnobservable: *nodataIsUnobservable,
NoFailFast: *noFailFast,
NoFailFast: !*failFast,
Log: *in,
PidFile: *pidfile,
Concurrency: *common.concurrency,
Expand Down
9 changes: 9 additions & 0 deletions grafana-alertcheck/cmd/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,21 @@ func TestRunCheck_FlagValidation(t *testing.T) {
{"alerts with in", true, func(t *testing.T) []string {
return []string{"--to", "2026-01-01T00:00:00Z", "--in", "some.jsonl", "--alerts", writeTempAlerts(t)}
}, "refused"},
{"excluded alerts with in", true, func(t *testing.T) []string {
return []string{"--to", "2026-01-01T00:00:00Z", "--in", "some.jsonl", "--exclude-alerts", t.TempDir() + "/missing.txt"}
}, "refused with a recorded log"},
{"no alerts no in", true, func(t *testing.T) []string {
return []string{"--to", "2026-01-01T00:00:00Z"}
}, "no alert names"},
{"alerts and labels", true, func(t *testing.T) []string {
return []string{"--to", "2026-01-01T00:00:00Z", "--alerts", writeTempAlerts(t), "--include-labels", "team=bcm"}
}, "cannot be combined with label selection"},
{"alerts and excluded alerts both stdin", true, func(t *testing.T) []string {
return []string{"--to", "2026-01-01T00:00:00Z", "--alerts", "-", "--exclude-alerts", "-"}
}, "cannot both read from stdin"},
{"removed no-fail-fast flag", true, func(t *testing.T) []string {
return []string{"--to", "2026-01-01T00:00:00Z", "--alerts", writeTempAlerts(t), "--no-fail-fast"}
}, "flag provided but not defined"},
{"bad label pair", true, func(t *testing.T) []string {
return []string{"--to", "2026-01-01T00:00:00Z", "--include-labels", "team"}
}, "--include-labels"},
Expand Down
16 changes: 9 additions & 7 deletions grafana-alertcheck/cmd/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type commonFlags struct {
folder *string
concurrency *int
alerts *string
excludeAlerts *string
includeLabels *string
excludeLabels *string
}
Expand All @@ -30,6 +31,8 @@ func registerCommon(fs *flag.FlagSet) *commonFlags {
folder: fs.String("folder", "", "default folder to scope an unqualified alert name to"),
concurrency: fs.Int("concurrency", 1, "maximum concurrent requests to Grafana"),
alerts: fs.String("alerts", "", "path to a file of alert names, one per line, or - for stdin"),
excludeAlerts: fs.String("exclude-alerts", "",
"path to a file of alert names to subtract from the selected set, one per line, or - for stdin"),
Comment thread
Tofel marked this conversation as resolved.
includeLabels: fs.String("include-labels", "",
"comma-separated key=value pairs selecting rules by label, e.g. team=bcm,env=stage (cannot be combined with --alerts)"),
excludeLabels: fs.String("exclude-labels", "",
Expand Down Expand Up @@ -66,11 +69,10 @@ func parseLabelPairs(flagName, s string) ([]gate.LabelMatcher, error) {
return out, nil
}

// readAlerts reads alert names, one per line, from a file or from
// stdin when path is "-". An empty path is not an error here — watch and
// check each decide for themselves whether an empty list is allowed
// (log mode never wants one; single-step / record mode always does).
func readAlerts(stdin io.Reader, path string) ([]string, error) {
// readAlerts reads alert names, one per line, from a file or stdin ("-").
// flagName names the caller's flag so errors point at the right input. An
// empty path is not an error: callers decide whether an empty list is allowed.
func readAlerts(stdin io.Reader, flagName, path string) ([]string, error) {
if path == "" {
return nil, nil
}
Expand All @@ -80,7 +82,7 @@ func readAlerts(stdin io.Reader, path string) ([]string, error) {
} else {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read --alerts %s: %w", path, err)
return nil, fmt.Errorf("read %s %s: %w", flagName, path, err)
}
defer f.Close()
r = f
Expand All @@ -91,7 +93,7 @@ func readAlerts(stdin io.Reader, path string) ([]string, error) {
lines = append(lines, sc.Text())
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("read --alerts %s: %w", path, err)
return nil, fmt.Errorf("read %s %s: %w", flagName, path, err)
}
return lines, nil
}
Expand Down
11 changes: 11 additions & 0 deletions grafana-alertcheck/cmd/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ import (
"github.com/smartcontractkit/chainlink-testing-framework/grafana-alertcheck/internal/gate"
)

func TestReadAlerts_ErrorNamesTheFlag(t *testing.T) {
missing := t.TempDir() + "/missing.txt"
for _, flagName := range []string{"--alerts", "--exclude-alerts"} {
t.Run(flagName, func(t *testing.T) {
_, err := readAlerts(nil, flagName, missing)
require.Error(t, err)
require.Contains(t, err.Error(), flagName)
})
}
}

func TestParseLabelPairs(t *testing.T) {
tests := []struct {
name string
Expand Down
14 changes: 12 additions & 2 deletions grafana-alertcheck/cmd/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
)

const watchUsage = "usage: grafana-alertcheck watch --out <file> [--pidfile F] [--daemon-log F] " +
"(--alerts <file|-> [--folder F] | --include-labels k=v,... [--exclude-labels k=v,...]) " +
"(--alerts <file|-> [--folder F] | --include-labels k=v,... [--exclude-labels k=v,...]) [--exclude-alerts <file|->] " +
"[--poll-interval D] [--concurrency N] [--until RFC3339]"

// runWatch is the record step's entire CLI surface, split in two by one flag
Expand Down Expand Up @@ -69,6 +69,10 @@ func runWatch(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "watch: --alerts cannot be combined with label selection")
return 2
}
if *common.alerts == "-" && *common.excludeAlerts == "-" {
fmt.Fprintln(stderr, "watch: --alerts and --exclude-alerts cannot both read from stdin")
return 2
}
includeLabels, err := parseLabelPairs("--include-labels", *common.includeLabels)
if err != nil {
fmt.Fprintln(stderr, err)
Expand All @@ -86,7 +90,12 @@ func runWatch(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 2
}

alerts, err := readAlerts(stdin, *common.alerts)
alerts, err := readAlerts(stdin, "--alerts", *common.alerts)
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
excludeAlerts, err := readAlerts(stdin, "--exclude-alerts", *common.excludeAlerts)
if err != nil {
fmt.Fprintln(stderr, err)
return 2
Expand All @@ -99,6 +108,7 @@ func runWatch(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
Folder: *common.folder,
IncludeLabels: includeLabels,
ExcludeLabels: excludeLabels,
ExcludeAlerts: excludeAlerts,
Out: *out,
PidFile: *pidfile,
DaemonLog: *daemonLog,
Expand Down
3 changes: 3 additions & 0 deletions grafana-alertcheck/cmd/watch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ func TestRunWatch_FlagValidation(t *testing.T) {
{"alerts and labels", true, func(t *testing.T) []string {
return []string{"--out", t.TempDir() + "/log.jsonl", "--alerts", writeTempAlerts(t), "--include-labels", "team=bcm"}
}, "cannot be combined with label selection"},
{"alerts and excluded alerts both stdin", true, func(t *testing.T) []string {
return []string{"--out", t.TempDir() + "/log.jsonl", "--alerts", "-", "--exclude-alerts", "-"}
}, "cannot both read from stdin"},
{"bad label pair", true, func(t *testing.T) []string {
return []string{"--out", t.TempDir() + "/log.jsonl", "--include-labels", "team"}
}, "--include-labels"},
Expand Down
4 changes: 2 additions & 2 deletions grafana-alertcheck/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ The gate must fail if it cannot get an answer. Every rule below is a specific in
- **Absent never means normal.** An instance that leaves the bad set is looked up in the *same* response: present as `normal` → cleared; absent (or `MissingSeries`) → vanished (a discontinuity, not a recovery).
- **Staleness is absolute.** `grafana_now − lastEvaluation` is compared against a threshold, never "did it increase since the last poll" — a delta check reports stale on ~half the polls of a healthy rule (we poll at half of `intervalSeconds` of each rule).
- **`grafana_now` is the response `Date` header.** Never the runner clock, in any comparison against a Grafana timestamp.
- **An early exit can never be a pass.** `check` may stop collecting before `to + transitionGrace` (fail-fast), but only on a *monotone* terminal verdict: an inability that has already happened, or a post-`from` bad onset (which the full classifier would call `new_failure`/`unstable`). The one outcome that forgives an observed bad state, `recovered`, is reserved for bad-at-`from`, so a preexisting condition is never terminal. `--no-fail-fast` removes the guard entirely.
- **An early exit can never be a pass.** `check` may stop collecting before `to + transitionGrace` (fail-fast), but only on a *monotone* terminal verdict: an inability that has already happened, or a post-`from` bad onset (which the full classifier would call `new_failure`/`unstable`). The one outcome that forgives an observed bad state, `recovered`, is reserved for bad-at-`from`, so a preexisting condition is never terminal. `--fail-fast=false` removes the guard entirely.
- **No replay.** No run-id key, no artifact download, no state between attempts. A retry is a new piece of work and observation.

## The pure-function seam
Expand All @@ -43,7 +43,7 @@ By default `check` stops as soon as it knows the run cannot pass, rather than ho
- In single-step mode `check` evaluates the guard after each in-process poll batch.
- In recorder mode the evidence lives in another process, so `check` **tails the recorder's log** while it waits, consuming complete newline-terminated records only. This is the one place the project reads a log a writer can still append to, and only because fail-fast wants an early answer, not the authoritative one — the strict, whole-file `ReadLog` still runs after the writer exits, and only its result is classified.

On a terminal verdict the run is classified over `[from, At]` by the **same `decide`**, with the policy window clamped to `At` and the transition grace zeroed; `decide` is not forked. The requested window and the real thresholds are restored on the `Result`, which carries a `TerminatedEarly` marker. The drain wait is skipped — its question no longer applies. `--no-fail-fast` leaves the guard unset and restores the full-window behavior exactly.
On a terminal verdict the run is classified over `[from, At]` by the **same `decide`**, with the policy window clamped to `At` and the transition grace zeroed; `decide` is not forked. The requested window and the real thresholds are restored on the `Result`, which carries a `TerminatedEarly` marker. The drain wait is skipped — its question no longer applies. `--fail-fast=false` leaves the guard unset and restores the full-window behavior exactly.

## Strict parsing as the version guard

Expand Down
4 changes: 2 additions & 2 deletions grafana-alertcheck/docs/how-alerts-are-evaluated.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,10 @@ Before classifying, `check` must **prove** continuous coverage of `[from, to]` f

A preexisting bad instance is deliberately **not** terminal: if it clears before `to` the full run would call it `recovered`, which passes.

Fail-fast is on by default and always preserves the failure: an early run can exit `1` or `2`, never `0`. The one difference from a full run is that an early exit may report `1` before an inability surfaces that would have made it `2`. `--no-fail-fast` disables the guard and always waits for the full window and its coverage proof.
Fail-fast is on by default and always preserves the failure: an early run can exit `1` or `2`, never `0`. The one difference from a full run is that an early exit may report `1` before an inability surfaces that would have made it `2`. `--fail-fast=false` disables the guard and always waits for the full window and its coverage proof.

## The drain wait and `transitionGrace`

A condition that arises just before `to` becomes `firing` only at the first evaluation after its `for` elapses. `transitionGrace` (derived from the watched rules' `for` values) extends the classification bound past `to` so such a surfacing condition is caught. After collection, a **drain wait** polls until each rule has evaluated through `to + transitionGrace` (bounded by `drainTimeout`); a rule that never does is `not_verified`.

Run time = `(to − from) + transitionGrace + drainTimeout`. This is printed at start, and the grace is warned about when it exceeds a quarter of the window — the window may be too short for the alert's `for`.
Run time = `(to − from) + transitionGrace + drainTimeout`. This is printed at start. A requested window with a subsecond part is rounded up to the next whole second by extending `to`, so the plan never reads a window like `9m59.99445781s`.
4 changes: 2 additions & 2 deletions grafana-alertcheck/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ grafana-alertcheck watch --out /tmp/run.jsonl --alerts alerts.txt
grafana-alertcheck check --in /tmp/run.jsonl --from "$deployed_at" --to "$finished_at"
```

`alerts.txt` holds one alert name per line. See [Naming alerts](./reference/cli#naming-alerts). Alerts can also be selected by label instead of by name: `--include-labels team=bcm,env=stage` (optionally `--exclude-labels`).
`alerts.txt` holds one alert name per line. See [Naming alerts](./reference/cli#naming-alerts). Alerts can also be selected by label instead of by name: `--include-labels team=bcm,env=stage` (optionally `--exclude-labels`). Either selection can be refined with `--exclude-alerts <file>`, an enumerated list subtracted from what was selected.

`watch` returns only after the recorder has observed every selected, non-paused alert once and reported ready — so auth, alert-selection, and parse failures surface **before** your deploy runs.

Expand Down Expand Up @@ -80,7 +80,7 @@ An error is never a pass: `2` wins over any violation found alongside it.
- `recovered` has **no deadline** — a bad-at-`from` alert that clears by `to` passes; set `--preexisting fail` to forbid it.
- If you retry the check, then the work also needs to be retried - there is no way to check the past.
- `watch` and `check` must run in **one job, one runner, one filesystem** — nothing persists across jobs or attempts.
- The gate **stops early on a certain failure** — as soon as a post-`from` bad onset or an inability is observed, `check` returns instead of holding the runner to `to + transitionGrace + drainTimeout`. This can never turn into a pass, but it can report exit `1` where a full run would have reported exit `2` (inability beats violation only when the inability is observed). Pass `--no-fail-fast` to always wait for the full window and its coverage proof; size the job timeout to the planned run time the gate prints at start either way.
- The gate **stops early on a certain failure** — as soon as a post-`from` bad onset or an inability is observed, `check` returns instead of holding the runner to `to + transitionGrace + drainTimeout`. This can never turn into a pass, but it can report exit `1` where a full run would have reported exit `2` (inability beats violation only when the inability is observed). Pass `--fail-fast=false` to always wait for the full window and its coverage proof; size the job timeout to the planned run time the gate prints at start either way.

## More

Expand Down
Loading
Loading