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
1 change: 1 addition & 0 deletions grafana-alertcheck/.changeset/v0.1.4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `watch` and single-step `check` can now select alerts by labels instead of names: `--include-labels team=bcm,env=stage`, optionally refined with `--exclude-labels severity=info`. Matches are exact; label selection cannot be combined with `--alerts` or `--folder`, and is refused with `--in`. A selection that matches no rules exits `2`.
10 changes: 7 additions & 3 deletions grafana-alertcheck/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ A CD quality gate for Grafana alerts. It bookends a release with two commands
watch → your work → check
```

`watch` starts a background recorder that polls each named alert into a JSONL log. After the work emits a
`from`/`to` pair, `check` proves continuous coverage of that window, classifies each alert's state
timeline, and exits `0`, `1`, or `2`. If the work fails first, `stop` reaps the recorder.
`watch` starts a background recorder that polls each watched alert into a JSONL log. Alerts are selected by
name or by labels. After the work emits a `from`/`to` pair, `check` proves continuous coverage of that
window, classifies each alert's state timeline, and exits `0`, `1`, or `2`. If the work fails first, `stop`
reaps the recorder.

It **fails closed**: if it cannot get an answer, it stops the release — never a pass on an unproven window.

Expand All @@ -25,6 +26,9 @@ grafana-alertcheck watch --out /tmp/run.jsonl --alerts alerts.txt
grafana-alertcheck check --in /tmp/run.jsonl --from "$deployed_at" --to "$finished_at"
```

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).

Requires Grafana >= 13.0.0 and < 14.0.0. Connection details come from the environment only — the token is
never a flag.

Expand Down
19 changes: 18 additions & 1 deletion grafana-alertcheck/cmd/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import (
)

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

// runCheck is the classify step's CLI surface: parse flags into a gate.Config,
Expand Down Expand Up @@ -55,6 +56,20 @@ func runCheck(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "--output: unknown value %q (only \"json\" is supported)\n", *output)
return 2
}
if *common.alerts != "" && (*common.includeLabels != "" || *common.excludeLabels != "") {
fmt.Fprintln(stderr, "check: --alerts cannot be combined with label selection")
return 2
}
includeLabels, err := parseLabelPairs("--include-labels", *common.includeLabels)
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
excludeLabels, err := parseLabelPairs("--exclude-labels", *common.excludeLabels)
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}

url, token, err := grafanaEnv()
if err != nil {
Expand Down Expand Up @@ -82,6 +97,8 @@ func runCheck(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
Token: token,
Alerts: alerts,
Folder: *common.folder,
IncludeLabels: includeLabels,
ExcludeLabels: excludeLabels,
States: stateList,
Preexisting: preexistingPolicy,
MinObserved: *minObserved,
Expand Down
15 changes: 15 additions & 0 deletions grafana-alertcheck/cmd/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,21 @@ func TestRunCheck_FlagValidation(t *testing.T) {
{"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"},
{"bad label pair", true, func(t *testing.T) []string {
return []string{"--to", "2026-01-01T00:00:00Z", "--include-labels", "team"}
}, "--include-labels"},
{"exclude without include", true, func(t *testing.T) []string {
return []string{"--to", "2026-01-01T00:00:00Z", "--exclude-labels", "severity=info"}
}, "requires --include-labels"},
{"labels with in", true, func(t *testing.T) []string {
return []string{"--to", "2026-01-01T00:00:00Z", "--in", "some.jsonl", "--include-labels", "team=bcm"}
}, "refused with a recorded log"},
{"labels with folder", true, func(t *testing.T) []string {
return []string{"--to", "2026-01-01T00:00:00Z", "--include-labels", "team=bcm", "--folder", "F"}
}, "--folder"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
47 changes: 41 additions & 6 deletions grafana-alertcheck/cmd/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,61 @@ import (
"github.com/smartcontractkit/chainlink-testing-framework/grafana-alertcheck/internal/gate"
)

// commonFlags is registerCommon's result: the exactly three flags watch and
// check share. Connection details are never flags, and states / poll-interval
// are deliberately NOT here — states is check-only because recording is
// commonFlags is registerCommon's result: the flags watch and check share.
// Connection details are never flags, and states / poll-interval are
// deliberately NOT here — states is check-only because recording is
// unfiltered, and poll-interval is watch-only because check reads the cadence
// from the log header. Putting either here would give both commands an opinion
// about a value only one of them may set.
type commonFlags struct {
folder *string
concurrency *int
alerts *string
folder *string
concurrency *int
alerts *string
includeLabels *string
excludeLabels *string
}

func registerCommon(fs *flag.FlagSet) *commonFlags {
return &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"),
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", "",
"comma-separated key=value pairs; rules carrying any of them are dropped (requires --include-labels)"),
}
}

// parseLabelPairs parses a comma-separated list of exact-match key=value label
// pairs. An empty string means the flag was not given.
func parseLabelPairs(flagName, s string) ([]gate.LabelMatcher, error) {
if strings.TrimSpace(s) == "" {
return nil, nil
}
seen := make(map[string]bool)
var out []gate.LabelMatcher
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
return nil, fmt.Errorf("%s: empty label pair in %q", flagName, s)
}
// An empty value is legal: it selects rules that carry the label with
// an empty value (a missing label never matches).
key, value, ok := strings.Cut(part, "=")
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
if !ok || key == "" {
return nil, fmt.Errorf("%s: %q is not a key=value pair with a non-empty key", flagName, part)
}
if seen[key] {
return nil, fmt.Errorf("%s: duplicate label %q", flagName, key)
}
seen[key] = true
out = append(out, gate.LabelMatcher{Key: key, Value: value})
}
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
Expand Down
45 changes: 45 additions & 0 deletions grafana-alertcheck/cmd/common_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package main

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/smartcontractkit/chainlink-testing-framework/grafana-alertcheck/internal/gate"
)

func TestParseLabelPairs(t *testing.T) {
tests := []struct {
name string
in string
want []gate.LabelMatcher
wantErr string
}{
{"empty means not given", "", nil, ""},
{"whitespace means not given", " ", nil, ""},
{"single pair", "team=bcm", []gate.LabelMatcher{{Key: "team", Value: "bcm"}}, ""},
{"spaces are trimmed", " team = bcm , env = stage ", []gate.LabelMatcher{
{Key: "team", Value: "bcm"},
{Key: "env", Value: "stage"},
}, ""},
{"value may contain equals", "query=a=b", []gate.LabelMatcher{{Key: "query", Value: "a=b"}}, ""},
{"empty value selects the empty value", "team=", []gate.LabelMatcher{{Key: "team"}}, ""},
{"empty and set values mix", "env=,team=bcm", []gate.LabelMatcher{{Key: "env"}, {Key: "team", Value: "bcm"}}, ""},
{"missing equals", "team", nil, "not a key=value pair with a non-empty key"},
{"empty key", "=bcm", nil, "not a key=value pair with a non-empty key"},
{"empty segment", "team=bcm,", nil, "empty label pair"},
{"duplicate key", "team=a,team=b", nil, "duplicate label"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseLabelPairs("--include-labels", tt.in)
if tt.wantErr != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
require.Equal(t, tt.want, got)
})
}
}
40 changes: 29 additions & 11 deletions grafana-alertcheck/cmd/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import (
)

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

// runWatch is the record step's entire CLI surface, split in two by one flag
// set — gate.DaemonChildFlag ("--daemon-child") and gate.ReadyFDFlag
Expand Down Expand Up @@ -64,6 +65,21 @@ func runWatch(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return runDaemonChild(*out, *until, *common.concurrency, *readyFD, stderr)
}

if *common.alerts != "" && (*common.includeLabels != "" || *common.excludeLabels != "") {
fmt.Fprintln(stderr, "watch: --alerts cannot be combined with label selection")
return 2
}
includeLabels, err := parseLabelPairs("--include-labels", *common.includeLabels)
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
excludeLabels, err := parseLabelPairs("--exclude-labels", *common.excludeLabels)
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}

url, token, err := grafanaEnv()
if err != nil {
fmt.Fprintln(stderr, err)
Expand All @@ -77,16 +93,18 @@ func runWatch(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
}

cfg := gate.WatchConfig{
URL: url,
Token: token,
Alerts: alerts,
Folder: *common.folder,
Out: *out,
PidFile: *pidfile,
DaemonLog: *daemonLog,
Concurrency: *common.concurrency,
Clock: gate.SystemClock{},
Notes: newNoteStyler(stderr),
URL: url,
Token: token,
Alerts: alerts,
Folder: *common.folder,
IncludeLabels: includeLabels,
ExcludeLabels: excludeLabels,
Out: *out,
PidFile: *pidfile,
DaemonLog: *daemonLog,
Concurrency: *common.concurrency,
Clock: gate.SystemClock{},
Notes: newNoteStyler(stderr),
}
if *until != "" {
t, err := time.Parse(time.RFC3339, *until)
Expand Down
12 changes: 12 additions & 0 deletions grafana-alertcheck/cmd/watch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ func TestRunWatch_FlagValidation(t *testing.T) {
{"bad poll-interval", true, func(t *testing.T) []string {
return []string{"--out", t.TempDir() + "/log.jsonl", "--alerts", writeTempAlerts(t), "--poll-interval", "not-a-duration"}
}, "--poll-interval"},
{"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"},
{"bad label pair", true, func(t *testing.T) []string {
return []string{"--out", t.TempDir() + "/log.jsonl", "--include-labels", "team"}
}, "--include-labels"},
{"exclude without include", true, func(t *testing.T) []string {
return []string{"--out", t.TempDir() + "/log.jsonl", "--exclude-labels", "severity=info"}
}, "requires --include-labels"},
{"labels with folder", true, func(t *testing.T) []string {
return []string{"--out", t.TempDir() + "/log.jsonl", "--include-labels", "team=bcm", "--folder", "F"}
}, "--folder"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion grafana-alertcheck/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ This, plus the declared supported range (Grafana >= 13.0.0, < 14.0.0), is how a

`watch` detaches a background recorder so observation survives the step boundary:

1. Parent resolves names, writes the header, observes every non-paused rule once, checks the budget.
1. Parent resolves the alert set (names or labels), writes the header, observes every non-paused rule once, checks the budget.
2. Parent re-execs itself as the child (`--daemon-child`) under a new session/process group, stdout/stderr to the daemon log.
3. Child re-reads the header, reopens the log `O_APPEND`, takes the exclusive `flock`, and writes one readiness byte on `--ready-fd`.
4. Parent writes the pidfile **after** the readiness report, then returns.
Expand Down
6 changes: 3 additions & 3 deletions grafana-alertcheck/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ It **fails closed**: if it cannot get an answer, it stops the release. It never

## How it works, in one paragraph

`watch` starts a background recorder that polls each named alert and appends snapshots to a JSONL log. Your work then emits two RFC3339 timestamps — `from` (when the change landed) and `to` (when the work ended). `check` proves continuous coverage of `[from, to]`, builds a state timeline per alert, classifies it, and exits `0`, `1`, or `2`.
`watch` starts a background recorder that polls each watched alert and appends snapshots to a JSONL log. Your work then emits two RFC3339 timestamps — `from` (when the change landed) and `to` (when the work ended). `check` proves continuous coverage of `[from, to]`, builds a state timeline per alert, classifies it, and exits `0`, `1`, or `2`.

## Install

Expand Down Expand Up @@ -46,9 +46,9 @@ 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.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`).

`watch` returns only after the recorder has observed every named, non-paused alert once and reported ready — so auth, name-resolution, and parse failures surface **before** your deploy runs.
`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.

If your work fails before `check` runs and the alert verdict no longer matters, reap the recorder with `grafana-alertcheck stop --out /tmp/run.jsonl`. It is idempotent, so it is safe as an `if: always()` step: after `check` has already stopped the recorder it is a no-op.

Expand Down
Loading
Loading