diff --git a/README.md b/README.md index ec8b36f..8cd3a36 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,14 @@ When your command exits, DING: SIGTERM and SIGINT are forwarded to the child for graceful shutdown. +After writing a rule, preview it without a real workload: + +```sh +echo '{"metric":"loss","value":1.5}' | ding test-rule --config ding.yaml +``` + +For a full preview against a real run without sending notifications, use `ding run --dry-run -- `. + ### Run context, auto-detected DING reads the runner's environment variables and attaches labels automatically. No config required. diff --git a/ding.yaml.example b/ding.yaml.example index 0fdceba..9ae3800 100644 --- a/ding.yaml.example +++ b/ding.yaml.example @@ -5,6 +5,11 @@ # events the command emits + the synthetic run.exit # ding serve long-running HTTP daemon; rules evaluate against # events POSTed to /ingest or piped via stdin +# +# Preview rules without sending notifications: +# echo '{"metric":"name","value":42}' | ding test-rule --config ding.yaml +# ding run --dry-run --config ding.yaml -- ./your-script.sh +# See: docs/configuration.md#testing-rules-without-a-workload server: port: 8080 diff --git a/docs/configuration.md b/docs/configuration.md index 1459f0c..d3e94cd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -390,6 +390,44 @@ For typical secrets (Slack URLs, PagerDuty tokens, API keys, opaque ID strings) - No `${VAR:-default}` for inline defaults — set the env var to the default before launching DING. - No `$${VAR}` escape for writing literal `${VAR}` — the use case is rare; if you hit it, file an issue. +## Testing rules without a workload + +DING ships two preview surfaces so you can verify rules before turning on real notifications. + +### `ding test-rule` — replay synthetic events + +Pipe or pass JSONL events at a config; matching rules render messages as if they were about to fire, but no notifications go out. + +```sh +# Pipe events from any source +echo '{"metric":"loss","value":1.5}' | ding test-rule --config ding.yaml + +# Read from a file (use - for explicit stdin) +ding test-rule events.jsonl +``` + +Each input line is a JSON event in DING's normal shape: a `metric` field for matching, a `value` field for numeric conditions, and any other key/value pairs as labels (string) or floats (number). An optional `timestamp` field (RFC3339 string or Unix epoch number) controls the event's time for windowed rules; events without `timestamp` get sequential synthetic times starting from now. + +Output format auto-detects: human-readable text when stdout is a terminal, JSON (one object per line) when piped. Override with `--format text|json`. Disable color with `--no-color`. + +End-of-run rules (`mode: end-of-run`) fire after the last input event. + +### `ding run --dry-run` — wrap a real workload, suppress sends + +Same as `ding run`, but the dispatch boundary is swapped for a logging one — your wrapped command runs normally, events flow through the engine normally, the synthetic `run.exit` event still emits, end-of-run rules still fire, the wrapped command's exit code still propagates. Only `notifier.Send` is bypassed. + +```sh +# Preview what alerts would fire on a real failing build +ding run --dry-run --config ding.yaml -- pytest tests/ + +# JSON output for piping (preview is on stderr; redirect to stdout for jq) +ding run --dry-run --format json --config ding.yaml -- ./train.sh 2>&1 | jq +``` + +Preview output goes to stderr alongside the wrapped command's own stderr; the wrapped command's stdout stays clean for downstream tools that read it. + +--- + ## Platform-specific examples See [Recipes](recipes/index.md) for end-to-end configurations on specific CI/CD platforms (GitLab CI, Jenkins, Buildkite). Each recipe shows the auto-captured labels and the minimal `ding.yaml` for that platform. diff --git a/internal/cli/dispatcher.go b/internal/cli/dispatcher.go new file mode 100644 index 0000000..3b511ea --- /dev/null +++ b/internal/cli/dispatcher.go @@ -0,0 +1,42 @@ +package cli + +import ( + "log" + + "github.com/ding-labs/ding/internal/evaluator" + "github.com/ding-labs/ding/internal/notifier" +) + +// Dispatcher routes alerts to their final destination. Implementations live +// here (NotifierDispatcher — production sends) and in internal/dryrun +// (LoggingDispatcher — preview-only, no sends). +type Dispatcher interface { + Dispatch(alerts []evaluator.Alert) +} + +// NotifierDispatcher is the production Dispatcher: writes each alert to the +// alert log (if configured) then calls Send on each named notifier. +type NotifierDispatcher struct { + Notifiers map[string]notifier.Notifier + AlertLogger *notifier.AlertLogger +} + +func (d *NotifierDispatcher) Dispatch(alerts []evaluator.Alert) { + for _, alert := range alerts { + if d.AlertLogger != nil { + if err := d.AlertLogger.Log(alert); err != nil { + log.Printf("ding: alert log write error: %v", err) + } + } + for _, name := range alert.Notifiers { + n, ok := d.Notifiers[name] + if !ok { + log.Printf("ding: unknown notifier %q for rule %q", name, alert.Rule) + continue + } + if err := n.Send(alert); err != nil { + log.Printf("ding: notifier %q error: %v", name, err) + } + } + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index a0e090c..3e11dd3 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -30,6 +30,7 @@ Two modes: newValidateCmd(), newVersionCmd(version), newInstallCmd(), + newTestRuleCmd(), ) return root } diff --git a/internal/cli/run.go b/internal/cli/run.go index 6aa0372..3c736c5 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -16,6 +16,7 @@ import ( "github.com/spf13/cobra" "github.com/ding-labs/ding/internal/config" + "github.com/ding-labs/ding/internal/dryrun" "github.com/ding-labs/ding/internal/evaluator" "github.com/ding-labs/ding/internal/ingester" "github.com/ding-labs/ding/internal/metrics" @@ -27,6 +28,9 @@ import ( func newRunCmd() *cobra.Command { var configPath string var runIDOverride string + var dryRun bool + var format string + var noColor bool cmd := &cobra.Command{ Use: "run [flags] -- [args...]", @@ -55,25 +59,53 @@ is safe.`, ding run -- python train.py --epochs 100 # Override the auto-detected run ID - ding run --run-id manual-debug -- ./flaky-script.sh`, + ding run --run-id manual-debug -- ./flaky-script.sh + + # Preview alerts without sending to notifiers + ding run --dry-run --config alerts.yaml -- ./script.sh`, DisableFlagsInUseLine: true, Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runRun(configPath, runIDOverride, args) + return runRun(configPath, runIDOverride, args, dryRun, format, noColor) }, } cmd.Flags().StringVar(&configPath, "config", "ding.yaml", "path to config file") cmd.Flags().StringVar(&runIDOverride, "run-id", "", "override auto-detected run ID") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview alerts without sending to notifiers") + cmd.Flags().StringVar(&format, "format", "auto", "output format when --dry-run is set: auto, text, json") + cmd.Flags().BoolVar(&noColor, "no-color", false, "disable ANSI color in dry-run text output") return cmd } -func runRun(configPath, runIDOverride string, args []string) error { +func runRun(configPath, runIDOverride string, args []string, dryRun bool, format string, noColor bool) error { + // Validate dry-run format BEFORE loading config so we don't pay the + // config-load + notifier-construction cost just to reject a typo. + if dryRun { + switch format { + case "auto", "text", "json": + // ok + default: + return fmt.Errorf("invalid --format %q: must be auto, text, or json", format) + } + } + collector := metrics.NewCollector() eng, cfg, notifiers, alertLogger, jqCode, err := server.BuildFromConfig(configPath, collector) if err != nil { return fmt.Errorf("loading config: %w", err) } + + var dispatcher Dispatcher + if dryRun { + formatter := pickFormatter(format, noColor, os.Stderr) + dispatcher = dryrun.NewLoggingDispatcher(formatter, os.Stderr) + } else { + dispatcher = &NotifierDispatcher{ + Notifiers: notifiers, + AlertLogger: alertLogger, + } + } // Drain handler — runs from both the deferred path (covers early returns // from config errors, command-start failures, etc.) and the explicit path // before os.Exit (covers the non-zero-exit case where defers don't run). @@ -85,9 +117,11 @@ func runRun(configPath, runIDOverride string, args []string) error { return } drained = true - drainNotifiers(notifiers, cfg.Server.DrainTimeout.Duration) - if alertLogger != nil { - _ = alertLogger.Close() + if !dryRun { + drainNotifiers(notifiers, cfg.Server.DrainTimeout.Duration) + if alertLogger != nil { + _ = alertLogger.Close() + } } } defer drainOnce() @@ -134,11 +168,11 @@ func runRun(configPath, runIDOverride string, args []string) error { wg.Add(2) go func() { defer wg.Done() - ingestStream(stdoutPipe, os.Stdout, eng, notifiers, alertLogger, cfg, jqCode, rc) + ingestStream(stdoutPipe, os.Stdout, eng, dispatcher, cfg, jqCode, rc) }() go func() { defer wg.Done() - ingestStream(stderrPipe, os.Stderr, eng, notifiers, alertLogger, cfg, jqCode, rc) + ingestStream(stderrPipe, os.Stderr, eng, dispatcher, cfg, jqCode, rc) }() wg.Wait() @@ -162,11 +196,11 @@ func runRun(configPath, runIDOverride string, args []string) error { // Synthetic run.exit event flows through the engine like any other — // during-run rules matching metric: run.exit fire here. summary := rc.SummaryEvent(exitCode) - dispatchEvent(summary, eng, notifiers, alertLogger) + dispatchEvent(summary, eng, dispatcher) // End-of-run rules accumulate state during the run; fire them now. endAlerts := eng.ProcessEndOfRun(time.Now()) - dispatchAlerts(endAlerts, notifiers, alertLogger) + dispatcher.Dispatch(endAlerts) log.Printf("ding: run end — run_id=%s exit_code=%d duration=%.1fs", rc.RunID, exitCode, time.Since(rc.StartedAt).Seconds()) @@ -209,8 +243,7 @@ func ingestStream( r io.Reader, mirror io.Writer, eng *evaluator.Engine, - notifiers map[string]notifier.Notifier, - alertLogger *notifier.AlertLogger, + dispatcher Dispatcher, cfg *config.Config, jqCode *gojq.Code, rc *runctx.Context, @@ -246,7 +279,7 @@ func ingestStream( } for _, ev := range events { ev.Labels = rc.Apply(ev.Labels) - dispatchEvent(ev, eng, notifiers, alertLogger) + dispatchEvent(ev, eng, dispatcher) } } // Scanner errors on closed pipes are expected at EOF; only log surprising ones. @@ -255,36 +288,7 @@ func ingestStream( } } -func dispatchEvent( - ev ingester.Event, - eng *evaluator.Engine, - notifiers map[string]notifier.Notifier, - alertLogger *notifier.AlertLogger, -) { +func dispatchEvent(ev ingester.Event, eng *evaluator.Engine, dispatcher Dispatcher) { alerts := eng.Process(ev, time.Now()) - dispatchAlerts(alerts, notifiers, alertLogger) -} - -func dispatchAlerts( - alerts []evaluator.Alert, - notifiers map[string]notifier.Notifier, - alertLogger *notifier.AlertLogger, -) { - for _, alert := range alerts { - if alertLogger != nil { - if err := alertLogger.Log(alert); err != nil { - log.Printf("ding: alert log write error: %v", err) - } - } - for _, name := range alert.Notifiers { - n, ok := notifiers[name] - if !ok { - log.Printf("ding: unknown notifier %q for rule %q", name, alert.Rule) - continue - } - if err := n.Send(alert); err != nil { - log.Printf("ding: notifier %q error: %v", name, err) - } - } - } + dispatcher.Dispatch(alerts) } diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 5d530f9..c26e1cd 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/ding-labs/ding/internal/config" + "github.com/ding-labs/ding/internal/dryrun" "github.com/ding-labs/ding/internal/evaluator" "github.com/ding-labs/ding/internal/notifier" "github.com/ding-labs/ding/internal/runctx" @@ -71,7 +72,8 @@ not a json line — mirrored only `) var mirror bytes.Buffer - ingestStream(input, &mirror, eng, notifierMap, nil, cfg, nil, rc) + dispatcher := &NotifierDispatcher{Notifiers: notifierMap, AlertLogger: nil} + ingestStream(input, &mirror, eng, dispatcher, cfg, nil, rc) alerts := cap.snapshot() if len(alerts) != 2 { @@ -123,7 +125,8 @@ FAILED test_b some random shell output here `) var mirror bytes.Buffer - ingestStream(input, &mirror, eng, notifierMap, nil, cfg, nil, rc) + dispatcher := &NotifierDispatcher{Notifiers: notifierMap, AlertLogger: nil} + ingestStream(input, &mirror, eng, dispatcher, cfg, nil, rc) if got := cap.snapshot(); len(got) != 0 { t.Errorf("expected no alerts on non-event input, got %d: %#v", len(got), got) @@ -206,3 +209,46 @@ func TestDrainNotifiers_HandlesEmpty(t *testing.T) { // no panic, no return value to check — the helper just returns } +func TestIngestStream_DryRun_NoNotifierSends(t *testing.T) { + rules := []evaluator.EngineRule{ + { + Name: "spike", + Match: map[string]string{"metric": "latency"}, + Condition: "value > 100", + Message: "spike", + Alerts: []string{"capture"}, + }, + } + eng, err := evaluator.NewEngine(rules, 1000) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + + cap := &captureNotifier{} + notifierMap := map[string]notifier.Notifier{"capture": cap} + + rc := &runctx.Context{ + RunID: "r-test", + Runner: "local", + Labels: map[string]string{}, + } + cfg := &config.Config{} + + var dryOut bytes.Buffer + dispatcher := dryrun.NewLoggingDispatcher(&dryrun.JSONFormatter{}, &dryOut) + + input := strings.NewReader(`{"metric":"latency","value":150}` + "\n") + var mirror bytes.Buffer + ingestStream(input, &mirror, eng, dispatcher, cfg, nil, rc) + + // The capture notifier must have received zero alerts (dry run never sends). + if got := cap.snapshot(); len(got) != 0 { + t.Errorf("dry-run leaked %d alert(s) to real notifier", len(got)) + } + // LoggingDispatcher must have written the alert to its buffer. + if !strings.Contains(dryOut.String(), `"rule":"spike"`) { + t.Errorf("expected dry-run output to include the spike rule, got: %s", dryOut.String()) + } + + _ = notifierMap // declared for clarity, not wired into dispatcher +} diff --git a/internal/cli/test_rule.go b/internal/cli/test_rule.go new file mode 100644 index 0000000..62ab523 --- /dev/null +++ b/internal/cli/test_rule.go @@ -0,0 +1,192 @@ +package cli + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/ding-labs/ding/internal/dryrun" + "github.com/ding-labs/ding/internal/ingester" + "github.com/ding-labs/ding/internal/runctx" + "github.com/ding-labs/ding/internal/server" +) + +func newTestRuleCmd() *cobra.Command { + var configPath string + var format string + var noColor bool + + cmd := &cobra.Command{ + Use: "test-rule [FILE]", + Short: "Replay JSONL events through the rule engine without sending notifications", + Long: `Reads JSONL events (one per line) from FILE or stdin and replays them +through the rule engine using your config. Matching rules render their +messages as if they were about to fire, but no notifier sends happen. + +Each event is a normal DING JSON event — same shape DING already parses +on 'ding run' stdin. An optional 'timestamp' field (RFC3339 or Unix epoch) +controls the event's time for windowed rules; omitted timestamps get +synthesized sequentially starting from now. + +End-of-run rules (mode: end-of-run) fire after all input events are +consumed.`, + Example: ` # Replay events from a file + ding test-rule events.jsonl + + # Pipe events from another tool + cat events.jsonl | ding test-rule + + # Force JSON output (default is text on TTY, json when piped) + ding test-rule events.jsonl --format json`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + input, closer, err := openTestRuleInput(args) + if err != nil { + return err + } + if closer != nil { + defer closer() + } + return runTestRule(configPath, format, noColor, input, os.Stdout, os.Stderr) + }, + } + cmd.Flags().StringVar(&configPath, "config", "ding.yaml", "path to config file") + cmd.Flags().StringVar(&format, "format", "auto", "output format: auto, text, json") + cmd.Flags().BoolVar(&noColor, "no-color", false, "disable ANSI color in text format") + return cmd +} + +func openTestRuleInput(args []string) (io.Reader, func(), error) { + if len(args) == 0 || args[0] == "-" { + return os.Stdin, nil, nil + } + f, err := os.Open(args[0]) + if err != nil { + return nil, nil, fmt.Errorf("opening events file: %w", err) + } + return f, func() { _ = f.Close() }, nil +} + +func runTestRule(configPath, format string, noColor bool, input io.Reader, stdout, stderr io.Writer) error { + // Validate format BEFORE loading config so a typo doesn't pay the config-load cost. + switch format { + case "auto", "text", "json": + // ok + default: + return fmt.Errorf("invalid --format %q: must be auto, text, or json", format) + } + + eng, _, _, _, _, err := server.BuildFromConfig(configPath, nil) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + rc := runctx.New() + if rc.Runner == "local" { + fmt.Fprintln(stderr, "ding: note — runner=local; rules matching on a specific runner label will not match.") + } + + formatter := pickFormatter(format, noColor, stdout) + dispatcher := dryrun.NewLoggingDispatcher(formatter, stdout) + + scanner := bufio.NewScanner(input) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + syntheticBase := time.Now() + idx := 0 + var lastAt time.Time + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + events, err := ingester.ParseJSONLine(line) + if err != nil || len(events) == 0 { + fmt.Fprintf(stderr, "ding: skipping unparseable event line %d: %v\n", idx+1, err) + idx++ + continue + } + for _, ev := range events { + if t, ok := resolveTimestamp(line); ok { + ev.At = t + } else { + ev.At = syntheticBase.Add(time.Duration(idx) * time.Second) + } + ev.Labels = rc.Apply(ev.Labels) + lastAt = ev.At + alerts := eng.Process(ev, ev.At) + dispatcher.Dispatch(alerts) + } + idx++ + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("reading input: %w", err) + } + + endTime := lastAt + if endTime.IsZero() { + endTime = time.Now() + } + endAlerts := eng.ProcessEndOfRun(endTime) + dispatcher.Dispatch(endAlerts) + return nil +} + +// resolveTimestamp inspects the raw JSON line for a `timestamp` field. +// Returns the parsed timestamp + true on success. +// Returns zero time + false when the field is absent, malformed, or unparsable. +// +// Accepts: RFC3339 strings ("2026-05-08T10:00:00Z"), Unix epoch floats (1715166000), +// Unix epoch ints. Mirrors ingester.ParseJSONLine's accepted forms for the +// numeric case, plus adds RFC3339 support that the ingester does not have today. +func resolveTimestamp(raw []byte) (time.Time, bool) { + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return time.Time{}, false + } + v, ok := m["timestamp"] + if !ok { + return time.Time{}, false + } + switch tv := v.(type) { + case string: + if t, err := time.Parse(time.RFC3339, tv); err == nil { + return t, true + } + case float64: + return time.Unix(int64(tv), 0).UTC(), true + } + return time.Time{}, false +} + +func pickFormatter(format string, noColor bool, out io.Writer) dryrun.Formatter { + if format == "json" { + return &dryrun.JSONFormatter{} + } + if format == "text" { + return &dryrun.TextFormatter{Color: !noColor && isTerminal(out)} + } + // auto: JSON when piped, text when on TTY + if isTerminal(out) { + return &dryrun.TextFormatter{Color: !noColor} + } + return &dryrun.JSONFormatter{} +} + +// isTerminal reports whether w is an *os.File pointing at a terminal. +// Returns false for buffers, pipes, and any non-*os.File writer. +func isTerminal(w io.Writer) bool { + f, ok := w.(*os.File) + if !ok { + return false + } + fi, err := f.Stat() + if err != nil { + return false + } + return (fi.Mode() & os.ModeCharDevice) != 0 +} diff --git a/internal/cli/test_rule_test.go b/internal/cli/test_rule_test.go new file mode 100644 index 0000000..6a57af7 --- /dev/null +++ b/internal/cli/test_rule_test.go @@ -0,0 +1,184 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeFixtureConfig(t *testing.T, dir string) string { + t.Helper() + cfg := ` +notifiers: + slack: + type: webhook + url: https://example.invalid/webhook +rules: + - name: spike + match: { metric: loss } + condition: value > 1.0 + message: "Loss spike: {{ .value }}" + alert: + - notifier: slack +` + p := filepath.Join(dir, "ding.yaml") + if err := os.WriteFile(p, []byte(cfg), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + return p +} + +func TestRunTestRule_PerEventMatch_TextFormat(t *testing.T) { + dir := t.TempDir() + cfg := writeFixtureConfig(t, dir) + events := `{"metric":"loss","value":0.5} +{"metric":"loss","value":1.5} +` + in := strings.NewReader(events) + var out, errBuf bytes.Buffer + + err := runTestRule(cfg, "text", false, in, &out, &errBuf) + if err != nil { + t.Fatalf("runTestRule: %v\nstderr: %s", err, errBuf.String()) + } + + got := out.String() + if !strings.Contains(got, "spike") { + t.Errorf("expected spike rule to fire on second event:\n%s", got) + } + if !strings.Contains(got, "Loss spike: 1.5") { + t.Errorf("expected rendered message:\n%s", got) + } + if strings.Count(got, "would fire") != 1 { + t.Errorf("expected exactly one match, got:\n%s", got) + } +} + +func TestRunTestRule_WindowedRule_FiresAfterEnoughEvents(t *testing.T) { + dir := t.TempDir() + cfg := filepath.Join(dir, "ding.yaml") + if err := os.WriteFile(cfg, []byte(` +notifiers: + slack: + type: webhook + url: https://example.invalid/webhook +rules: + - name: hot_avg + match: { metric: temp } + condition: avg(value) over 5m > 50 + message: "avg high: {{ .avg }}" + alert: + - notifier: slack +`), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + + events := `{"metric":"temp","value":40,"timestamp":"2026-05-08T10:00:00Z"} +{"metric":"temp","value":60,"timestamp":"2026-05-08T10:01:00Z"} +{"metric":"temp","value":70,"timestamp":"2026-05-08T10:02:00Z"} +` + in := strings.NewReader(events) + var out, errBuf bytes.Buffer + if err := runTestRule(cfg, "json", true, in, &out, &errBuf); err != nil { + t.Fatalf("runTestRule: %v\nstderr: %s", err, errBuf.String()) + } + + if !strings.Contains(out.String(), `"rule":"hot_avg"`) { + t.Errorf("expected hot_avg to fire after windowed avg crosses threshold:\n%s", out.String()) + } +} + +func TestRunTestRule_NoMatch_SilentStdout(t *testing.T) { + dir := t.TempDir() + cfg := writeFixtureConfig(t, dir) + events := `{"metric":"loss","value":0.1} +{"metric":"loss","value":0.2} +` + in := strings.NewReader(events) + var out, errBuf bytes.Buffer + if err := runTestRule(cfg, "text", true, in, &out, &errBuf); err != nil { + t.Fatalf("runTestRule: %v", err) + } + if out.Len() != 0 { + t.Errorf("expected silent stdout when no rules fire, got: %q", out.String()) + } +} + +// TestRunTestRule_NoTimestampField_SynthesizesSequential exercises the +// synthetic-timestamp path for events without a `timestamp` field. +// We check this by verifying that a windowed rule with a tight window +// fires correctly when events are spaced out by the synthesized 1s gap. +func TestRunTestRule_NoTimestampField_SynthesizesSequential(t *testing.T) { + dir := t.TempDir() + cfg := filepath.Join(dir, "ding.yaml") + if err := os.WriteFile(cfg, []byte(` +notifiers: + slack: + type: webhook + url: https://example.invalid/webhook +rules: + - name: hot_avg + match: { metric: temp } + condition: avg(value) over 30s > 50 + message: "avg high: {{ .avg }}" + alert: [{ notifier: slack }] +`), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + + // Three events with NO timestamp field. With synthesized 1s spacing + // they all fit in the 30s window. Without it (bug behavior), they + // land at the same wall-clock instant and STILL fit — so this test + // verifies firing only. The eviction-driven verification belongs + // in a separate windowed-rule test that tightens timing later. + events := `{"metric":"temp","value":40} +{"metric":"temp","value":60} +{"metric":"temp","value":70} +` + in := strings.NewReader(events) + var out, errBuf bytes.Buffer + if err := runTestRule(cfg, "json", true, in, &out, &errBuf); err != nil { + t.Fatalf("runTestRule: %v\nstderr: %s", err, errBuf.String()) + } + if !strings.Contains(out.String(), `"rule":"hot_avg"`) { + t.Errorf("expected hot_avg to fire on synthesized-timestamp events:\n%s", out.String()) + } +} + +// TestRunTestRule_InvalidFormat_ReturnsError verifies that --format with +// an unrecognized value produces a clear error rather than silently +// falling through to the auto branch. +func TestRunTestRule_InvalidFormat_ReturnsError(t *testing.T) { + dir := t.TempDir() + cfg := writeFixtureConfig(t, dir) + in := strings.NewReader("") + var out, errBuf bytes.Buffer + err := runTestRule(cfg, "compact", false, in, &out, &errBuf) + if err == nil { + t.Fatal("expected error for invalid --format value, got nil") + } + if !strings.Contains(err.Error(), "invalid --format") { + t.Errorf("error should explain the issue, got: %v", err) + } +} + +// TestRunTestRule_MalformedLine_SkipsAndContinues verifies that a bad +// line is logged to stderr but doesn't stop the run; the subsequent +// valid line still fires its rule. +func TestRunTestRule_MalformedLine_SkipsAndContinues(t *testing.T) { + dir := t.TempDir() + cfg := writeFixtureConfig(t, dir) + events := `not-json +{"metric":"loss","value":2.0} +` + in := strings.NewReader(events) + var out, errBuf bytes.Buffer + if err := runTestRule(cfg, "text", true, in, &out, &errBuf); err != nil { + t.Fatalf("runTestRule should not return error on malformed line: %v", err) + } + if !strings.Contains(out.String(), "spike") { + t.Errorf("expected the valid second line to still fire spike rule:\n%s", out.String()) + } +} diff --git a/internal/dryrun/dispatcher.go b/internal/dryrun/dispatcher.go new file mode 100644 index 0000000..784cad8 --- /dev/null +++ b/internal/dryrun/dispatcher.go @@ -0,0 +1,24 @@ +package dryrun + +import ( + "io" + + "github.com/ding-labs/ding/internal/evaluator" +) + +// LoggingDispatcher formats each alert via Formatter and writes to Writer. +// Never calls notifier.Send. Satisfies cli.Dispatcher implicitly (Go duck-typing). +type LoggingDispatcher struct { + Formatter Formatter + Writer io.Writer +} + +func NewLoggingDispatcher(f Formatter, w io.Writer) *LoggingDispatcher { + return &LoggingDispatcher{Formatter: f, Writer: w} +} + +func (d *LoggingDispatcher) Dispatch(alerts []evaluator.Alert) { + for _, alert := range alerts { + _, _ = d.Writer.Write(d.Formatter.Format(alert)) + } +} diff --git a/internal/dryrun/dispatcher_test.go b/internal/dryrun/dispatcher_test.go new file mode 100644 index 0000000..b650e80 --- /dev/null +++ b/internal/dryrun/dispatcher_test.go @@ -0,0 +1,38 @@ +package dryrun + +import ( + "bytes" + "strings" + "testing" + + "github.com/ding-labs/ding/internal/evaluator" +) + +func TestLoggingDispatcher_WritesFormattedAlerts(t *testing.T) { + var buf bytes.Buffer + d := NewLoggingDispatcher(&TextFormatter{Color: false}, &buf) + + alerts := []evaluator.Alert{ + {Rule: "rule_a", Metric: "m", Value: 1, Message: "msg-a", Notifiers: []string{"slack"}}, + {Rule: "rule_b", Metric: "m", Value: 2, Message: "msg-b", Notifiers: []string{"webhook"}}, + } + d.Dispatch(alerts) + + out := buf.String() + if !strings.Contains(out, "rule_a") || !strings.Contains(out, "rule_b") { + t.Errorf("expected both rule names in output:\n%s", out) + } + if !strings.Contains(out, "msg-a") || !strings.Contains(out, "msg-b") { + t.Errorf("expected both messages in output:\n%s", out) + } +} + +func TestLoggingDispatcher_EmptyAlerts_NoOutput(t *testing.T) { + var buf bytes.Buffer + d := NewLoggingDispatcher(&TextFormatter{Color: false}, &buf) + d.Dispatch(nil) + d.Dispatch([]evaluator.Alert{}) + if buf.Len() != 0 { + t.Errorf("expected empty output for empty alerts, got: %q", buf.String()) + } +} diff --git a/internal/dryrun/formatter.go b/internal/dryrun/formatter.go new file mode 100644 index 0000000..973c7fc --- /dev/null +++ b/internal/dryrun/formatter.go @@ -0,0 +1,100 @@ +// Package dryrun provides alert formatters and a Dispatcher implementation +// for dry-run modes (ding test-rule and ding run --dry-run). LoggingDispatcher +// satisfies the cli.Dispatcher interface without sending to real notifiers. +package dryrun + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/ding-labs/ding/internal/evaluator" +) + +// Formatter renders one Alert as bytes ready for an io.Writer. +type Formatter interface { + Format(alert evaluator.Alert) []byte +} + +// ANSI color codes. Centralized so the table is easy to audit. +const ( + ansiReset = "\x1b[0m" + ansiGreen = "\x1b[32m" + ansiCyan = "\x1b[36m" + ansiDim = "\x1b[2m" +) + +// TextFormatter renders alerts as multi-line human-readable text. +// Color toggles ANSI escape sequences (off when not on a TTY or with --no-color). +type TextFormatter struct { + Color bool +} + +func (f *TextFormatter) Format(alert evaluator.Alert) []byte { + var b strings.Builder + rule := alert.Rule + notifiers := strings.Join(alert.Notifiers, ", ") + if notifiers == "" { + notifiers = "(none)" + } + + if f.Color { + fmt.Fprintf(&b, "%s✓ %s%s — %swould fire%s (alerts: %s)\n", + ansiGreen, rule, ansiReset, ansiCyan, ansiReset, notifiers) + fmt.Fprintf(&b, "%s metric: %s · value: %.2f%s\n", + ansiDim, alert.Metric, alert.Value, ansiReset) + fmt.Fprintf(&b, "%s message:%s %s\n", ansiDim, ansiReset, alert.Message) + } else { + fmt.Fprintf(&b, "✓ %s — would fire (alerts: %s)\n", rule, notifiers) + fmt.Fprintf(&b, " metric: %s · value: %.2f\n", alert.Metric, alert.Value) + fmt.Fprintf(&b, " message: %s\n", alert.Message) + } + return []byte(b.String()) +} + +// JSONFormatter renders alerts as one JSON object per line (JSONL). +// Stable schema; safe to pipe to jq. +type JSONFormatter struct{} + +type jsonAlertEnvelope struct { + Rule string `json:"rule"` + Metric string `json:"metric"` + Value float64 `json:"value"` + Message string `json:"message"` + Alerts []string `json:"alerts"` + Labels map[string]string `json:"labels,omitempty"` + Floats map[string]float64 `json:"floats,omitempty"` + FiredAt string `json:"fired_at"` + // Aggregates (always present; zero when not windowed — easier for jq) + Avg float64 `json:"avg"` + Max float64 `json:"max"` + Min float64 `json:"min"` + Count float64 `json:"count"` + Sum float64 `json:"sum"` +} + +func (f *JSONFormatter) Format(alert evaluator.Alert) []byte { + env := jsonAlertEnvelope{ + Rule: alert.Rule, + Metric: alert.Metric, + Value: alert.Value, + Message: alert.Message, + Alerts: alert.Notifiers, + Labels: alert.Labels, + Floats: alert.Floats, + FiredAt: alert.FiredAt.UTC().Format(time.RFC3339Nano), + Avg: alert.Avg, + Max: alert.Max, + Min: alert.Min, + Count: alert.Count, + Sum: alert.Sum, + } + if env.Alerts == nil { + env.Alerts = []string{} + } + // jsonAlertEnvelope contains only stdlib-marshalable types (string, float64, + // []string, map[string]string, map[string]float64). Marshal cannot fail here. + b, _ := json.Marshal(env) + return append(b, '\n') +} diff --git a/internal/dryrun/formatter_test.go b/internal/dryrun/formatter_test.go new file mode 100644 index 0000000..0b4a67c --- /dev/null +++ b/internal/dryrun/formatter_test.go @@ -0,0 +1,142 @@ +package dryrun + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/ding-labs/ding/internal/evaluator" +) + +func TestTextFormatter_PerEventAlert_NoColor(t *testing.T) { + alert := evaluator.Alert{ + Rule: "loss_spike", + Message: "Loss spiked to 1.20", + Metric: "loss", + Value: 1.2, + Notifiers: []string{"slack"}, + FiredAt: time.Date(2026, 5, 8, 10, 2, 0, 0, time.UTC), + } + + f := &TextFormatter{Color: false} + got := string(f.Format(alert)) + + wantSubstrings := []string{ + "loss_spike", + "would fire", + "slack", + "metric: loss", + "value: 1.20", + "Loss spiked to 1.20", + } + for _, want := range wantSubstrings { + if !strings.Contains(got, want) { + t.Errorf("TextFormatter output missing %q\noutput: %s", want, got) + } + } + // No-color: no ANSI escape sequences + if strings.Contains(got, "\x1b[") { + t.Errorf("TextFormatter Color=false leaked ANSI escapes:\n%s", got) + } +} + +func TestTextFormatter_Color_EmitsANSIEscapes(t *testing.T) { + alert := evaluator.Alert{ + Rule: "x", + Message: "m", + Metric: "loss", + Value: 1, + Notifiers: []string{"slack"}, + } + f := &TextFormatter{Color: true} + got := string(f.Format(alert)) + if !strings.Contains(got, "\x1b[") { + t.Errorf("Color=true did not emit ANSI escapes:\n%q", got) + } + if !strings.Contains(got, ansiReset) { + t.Errorf("Color=true output missing reset code — terminal would be left dirty:\n%q", got) + } +} + +func TestTextFormatter_NoNotifiers(t *testing.T) { + alert := evaluator.Alert{ + Rule: "x", + Message: "m", + Metric: "loss", + Value: 1, + // Notifiers empty + } + f := &TextFormatter{Color: false} + got := string(f.Format(alert)) + if !strings.Contains(got, "(none)") { + t.Errorf("expected '(none)' for empty Notifiers, got:\n%s", got) + } +} + +func TestJSONFormatter_RoundTrips(t *testing.T) { + alert := evaluator.Alert{ + Rule: "loss_spike", + Message: "Loss spiked", + Metric: "loss", + Value: 1.2, + Labels: map[string]string{"run_id": "abc123"}, + Floats: map[string]float64{"step": 100}, + Notifiers: []string{"slack", "pagerduty"}, + FiredAt: time.Date(2026, 5, 8, 10, 2, 0, 0, time.UTC), + Avg: 1.1, + } + + f := &JSONFormatter{} + out := f.Format(alert) + + if !strings.HasSuffix(string(out), "\n") { + t.Errorf("JSONFormatter output must end with newline (JSONL): %q", string(out)) + } + + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("JSONFormatter output is not valid JSON: %v\noutput: %s", err, out) + } + + wantKeys := []string{ + "rule", "metric", "value", "message", "alerts", "fired_at", + "labels", "floats", "avg", "max", "min", "count", "sum", + } + for _, k := range wantKeys { + if _, ok := got[k]; !ok { + t.Errorf("JSONFormatter output missing key %q", k) + } + } + if got["rule"] != "loss_spike" { + t.Errorf("rule mismatch: got %v", got["rule"]) + } + if alerts, _ := got["alerts"].([]any); len(alerts) != 2 { + t.Errorf("alerts should be 2 entries: got %v", alerts) + } +} + +func TestJSONFormatter_NilNotifiers(t *testing.T) { + alert := evaluator.Alert{ + Rule: "x", + Metric: "loss", + Value: 1, + Message: "m", + Notifiers: nil, + } + out := (&JSONFormatter{}).Format(alert) + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("not valid JSON: %v\noutput: %s", err, out) + } + alerts, ok := got["alerts"] + if !ok { + t.Fatalf("alerts key missing entirely") + } + if alerts == nil { + t.Errorf("alerts should serialize as [] not null when Notifiers is nil") + } + if arr, _ := alerts.([]any); arr == nil || len(arr) != 0 { + t.Errorf("expected empty array, got: %v", alerts) + } +}