From 4399de6e781a7a74002726d8f153402c292e1dc5 Mon Sep 17 00:00:00 2001 From: danielbotros Date: Wed, 24 Jun 2026 10:35:47 -0400 Subject: [PATCH 1/4] sensor-monitor: react to alert message when a rule resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a rule clears (its reading returns to the non-triggered side of the threshold), add an emoji reaction — :white_check_mark: by default — to the original alert message so operators get an at-a-glance "handled" marker without a follow-up notification. The monitor captures each alert's channel/ts from the notifier's send response and, on the clearing edge, calls DoCommand({"command": "react", ...}) on the notifier, then clears the stored identity so it reacts exactly once. A cooldown re-send reacts to the latest message. Requires a notifier that supports the react command and returns a message ts (the viam:notifications:slack bot-token path); when no ts is available (e.g. a Slack webhook) the step is silently skipped. Configurable via resolve_reaction (set "-" to disable). Best-effort: failures are logged and never affect monitoring. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 + resources/sensormonitor/sensormonitor.go | 117 ++++++++++++--- resources/sensormonitor/sensormonitor_test.go | 138 +++++++++++++++++- 3 files changed, 239 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index bf2bdb4..95e02d1 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ When a rule fires, the monitor calls `DoCommand` on the notifier with the payloa Notifications are **edge-triggered**: a message is sent when a rule transitions from not-triggered to triggered. While the rule stays triggered no further message is sent, unless `cooldown_seconds` is set, in which case the message repeats at most once per cooldown window. When the reading clears the threshold and crosses it again, a new notification is sent. +**Reacting on resolve.** When a rule clears (its reading returns to the non-triggered side of the threshold), the monitor adds an emoji reaction — `:white_check_mark:` by default — to the original alert message, giving operators an at-a-glance "handled" marker without a follow-up message. This calls `DoCommand({"command": "react", "channel": ..., "timestamp": ..., "name": ...})` on the notifier, so it requires a notifier that supports the `react` command and returns a message `ts` on send (the [`viam:notifications:slack`](https://github.com/viam-modules/notifications) **bot-token** path). On a notifier that returns no `ts` (e.g. a Slack incoming webhook) there is nothing to react to and the step is silently skipped. Set `resolve_reaction` to a different emoji name, or to `"-"` to disable reacting entirely. The reaction is best-effort: a failure is logged and never affects monitoring. + ### Configuration ```json @@ -87,6 +89,7 @@ Notifications are **edge-triggered**: a message is sent when a rule transitions "notifier": "", "poll_interval_seconds": , "cooldown_seconds": , + "resolve_reaction": "", "rules": [ { "key": "", @@ -105,6 +108,7 @@ Notifications are **edge-triggered**: a message is sent when a rule transitions | `rules` | array | **Yes** | One or more numeric trigger rules (see below). At least one rule is required. | | `poll_interval_seconds` | number | No | How often the sensor is polled, in seconds. Defaults to `10`. | | `cooldown_seconds` | number | No | Minimum time between repeat notifications while a rule stays triggered. `0` (default) means no repeat until the rule clears and fires again. | +| `resolve_reaction` | string | No | Emoji name (without colons) added as a reaction to the original alert message when a rule clears. Defaults to `white_check_mark`. Set to `-` to disable. Requires a notifier that supports the `react` command and returns a message `ts` on send. | Each entry in `rules`: diff --git a/resources/sensormonitor/sensormonitor.go b/resources/sensormonitor/sensormonitor.go index e91f601..f3a836d 100644 --- a/resources/sensormonitor/sensormonitor.go +++ b/resources/sensormonitor/sensormonitor.go @@ -34,6 +34,14 @@ func init() { // defaultPollInterval is used when poll_interval_seconds is not set. const defaultPollInterval = 10 * time.Second +// defaultResolveReaction is the emoji added to the original alert message when a +// rule clears, unless resolve_reaction overrides it. +const defaultResolveReaction = "white_check_mark" + +// reactionDisabled is the resolve_reaction sentinel that turns off reacting on +// resolve. +const reactionDisabled = "-" + // Rule describes a single numeric trigger on one reading key. type Rule struct { // Key is the reading key to watch, e.g. "temperature". @@ -63,6 +71,13 @@ type Config struct { // stays triggered, in seconds. 0 (default) means do not re-notify until the // rule clears and fires again. CooldownSec float64 `json:"cooldown_seconds,omitempty"` + // ResolveReaction is the emoji name (without colons) added as a reaction to + // the original alert message when a rule clears (value returns to the + // non-triggered side of the threshold). Requires the notifier to support a + // {"command": "react", ...} DoCommand and to have returned a message "ts" on + // send (the Slack bot-token path). Defaults to "white_check_mark". Set to + // "-" to disable reacting on resolve. + ResolveReaction string `json:"resolve_reaction,omitempty"` } // Validate checks the config and returns the required dependency names. @@ -92,6 +107,12 @@ type ruleState struct { triggered bool lastNotified time.Time lastValue float64 + // msgChannel and msgTS identify the most recent alert message sent for this + // rule, captured from the notifier's send response so the message can be + // reacted to when the rule clears. Empty when no alert is outstanding or the + // notifier did not return a ts (e.g. the Slack webhook path). + msgChannel string + msgTS string } type sensorMonitor struct { @@ -104,8 +125,9 @@ type sensorMonitor struct { sensorDep sensor.Sensor notifierDep generic.Service - pollInterval time.Duration - cooldown time.Duration + pollInterval time.Duration + cooldown time.Duration + resolveReaction string cancelCtx context.Context cancelFunc func() @@ -156,20 +178,26 @@ func newMonitor(deps resource.Dependencies, name resource.Name, conf *Config, lo } cooldown := time.Duration(conf.CooldownSec * float64(time.Second)) + resolveReaction := conf.ResolveReaction + if resolveReaction == "" { + resolveReaction = defaultResolveReaction + } + cancelCtx, cancelFunc := context.WithCancel(context.Background()) return &sensorMonitor{ - Named: name.AsNamed(), - logger: logger, - cfg: conf, - sensorDep: sensorDep, - notifierDep: notifierDep, - pollInterval: pollInterval, - cooldown: cooldown, - cancelCtx: cancelCtx, - cancelFunc: cancelFunc, - lastReadings: map[string]interface{}{}, - ruleStates: make([]ruleState, len(conf.Rules)), + Named: name.AsNamed(), + logger: logger, + cfg: conf, + sensorDep: sensorDep, + notifierDep: notifierDep, + pollInterval: pollInterval, + cooldown: cooldown, + resolveReaction: resolveReaction, + cancelCtx: cancelCtx, + cancelFunc: cancelFunc, + lastReadings: map[string]interface{}{}, + ruleStates: make([]ruleState, len(conf.Rules)), }, nil } @@ -225,6 +253,8 @@ func (m *sensorMonitor) poll(ctx context.Context) { fired := cmp(value, rule.Threshold) notify := false + resolved := false + var reactChannel, reactTS string m.mu.Lock() st := &m.ruleStates[i] st.lastValue = value @@ -237,28 +267,79 @@ func (m *sensorMonitor) poll(ctx context.Context) { st.lastNotified = now notify = true case !fired: + // Edge from triggered to cleared: the rule just resolved. If we have a + // message to react to, capture it now and clear the stored identity so + // we react exactly once. + if st.triggered && st.msgTS != "" { + resolved = true + reactChannel = st.msgChannel + reactTS = st.msgTS + } st.triggered = false + st.msgChannel = "" + st.msgTS = "" } m.mu.Unlock() if notify { - m.sendNotification(ctx, rule, value) + channel, ts := m.sendNotification(ctx, rule, value) + // Record the message so it can be reacted to on resolve. A re-notify + // (cooldown) overwrites the previous identity, so we react to the most + // recent alert. Empty ts (webhook path, or send failure) leaves nothing + // to react to. + if ts != "" { + m.mu.Lock() + m.ruleStates[i].msgChannel = channel + m.ruleStates[i].msgTS = ts + m.mu.Unlock() + } + } + if resolved { + m.sendResolveReaction(ctx, rule, reactChannel, reactTS) } } } -// sendNotification renders the rule's message and calls DoCommand on the notifier. -func (m *sensorMonitor) sendNotification(ctx context.Context, rule Rule, value float64) { +// sendNotification renders the rule's message and calls DoCommand on the +// notifier, returning the message's channel and ts when the notifier reports +// them (the Slack bot-token path). Both are empty on failure or when the +// notifier does not return them. +func (m *sensorMonitor) sendNotification(ctx context.Context, rule Rule, value float64) (channel, ts string) { msg := renderMessage(rule, value) cmd := map[string]interface{}{ "command": "send", "text": msg, } - if _, err := m.notifierDep.DoCommand(ctx, cmd); err != nil { + resp, err := m.notifierDep.DoCommand(ctx, cmd) + if err != nil { m.logger.Errorf("failed to notify via %q: %v", m.cfg.Notifier, err) - return + return "", "" } + channel, _ = resp["channel"].(string) + ts, _ = resp["ts"].(string) m.logger.Infof("notification sent: %s", msg) + return channel, ts +} + +// sendResolveReaction asks the notifier to add the configured emoji reaction to +// a previously-sent alert message once its rule has cleared. Best-effort: a +// failure (e.g. a notifier that doesn't support "react", or a webhook notifier) +// is logged and never affects monitoring. +func (m *sensorMonitor) sendResolveReaction(ctx context.Context, rule Rule, channel, ts string) { + if m.resolveReaction == reactionDisabled { + return + } + cmd := map[string]interface{}{ + "command": "react", + "name": m.resolveReaction, + "channel": channel, + "timestamp": ts, + } + if _, err := m.notifierDep.DoCommand(ctx, cmd); err != nil { + m.logger.Warnf("failed to react to resolved alert for %q via %q: %v", rule.Key, m.cfg.Notifier, err) + return + } + m.logger.Infof("reacted :%s: to resolved alert for %q", m.resolveReaction, rule.Key) } // Readings returns the most recent sensor readings plus, per rule, a diff --git a/resources/sensormonitor/sensormonitor_test.go b/resources/sensormonitor/sensormonitor_test.go index d4d279f..6296444 100644 --- a/resources/sensormonitor/sensormonitor_test.go +++ b/resources/sensormonitor/sensormonitor_test.go @@ -4,6 +4,7 @@ import ( "context" "sync" "testing" + "time" sensor "go.viam.com/rdk/components/sensor" "go.viam.com/rdk/logging" @@ -46,27 +47,48 @@ func (f *fakeSensor) Readings(ctx context.Context, extra map[string]interface{}) func (f *fakeSensor) Close(context.Context) error { return nil } // fakeNotifier is a generic.Service that records the DoCommands it receives. +// On a "send" it returns a synthetic ts/channel (the Slack bot-token shape) so +// the monitor can capture a message identity to react to; an empty sendTS +// simulates the webhook path that returns no ts. type fakeNotifier struct { resource.Named resource.AlwaysRebuild mu sync.Mutex received []map[string]interface{} + sendTS string + sendCh string } func newFakeNotifier(name string) *fakeNotifier { - return &fakeNotifier{Named: generic.Named(name).AsNamed()} + return &fakeNotifier{Named: generic.Named(name).AsNamed(), sendTS: "100.1", sendCh: "C0ALERTS"} } func (f *fakeNotifier) DoCommand(ctx context.Context, cmd map[string]interface{}) (map[string]interface{}, error) { f.mu.Lock() defer f.mu.Unlock() f.received = append(f.received, cmd) - return map[string]interface{}{}, nil + if cmd["command"] == "send" && f.sendTS != "" { + return map[string]interface{}{"ok": true, "ts": f.sendTS, "channel": f.sendCh}, nil + } + return map[string]interface{}{"ok": true}, nil } func (f *fakeNotifier) Close(context.Context) error { return nil } +// reactions returns the "react" DoCommands received so far. +func (f *fakeNotifier) reactions() []map[string]interface{} { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]map[string]interface{}, 0) + for _, cmd := range f.received { + if cmd["command"] == "react" { + out = append(out, cmd) + } + } + return out +} + // texts returns the notification message strings received so far. func (f *fakeNotifier) texts() []string { f.mu.Lock() @@ -277,6 +299,118 @@ func TestEdgeTriggeredNotifies(t *testing.T) { } } +func TestReactsOnResolve(t *testing.T) { + ctx := context.Background() + src := newFakeSensor("src") + notifier := newFakeNotifier("notify") + m := newTestMonitor(t, &Config{ + Sensor: "src", + Notifier: "notify", + Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15}}, + }, src, notifier) + + // Cross the threshold: one alert, captured ts, no reaction yet. + src.set(map[string]interface{}{"usage": 15.0}) + m.poll(ctx) + if got := len(notifier.reactions()); got != 0 { + t.Fatalf("expected no reaction while triggered, got %d", got) + } + + // Still triggered: no reaction. + m.poll(ctx) + if got := len(notifier.reactions()); got != 0 { + t.Fatalf("expected no reaction while still triggered, got %d", got) + } + + // Counter reset below threshold (the streamdeck refill): react exactly once. + src.set(map[string]interface{}{"usage": 0.0}) + m.poll(ctx) + reactions := notifier.reactions() + if len(reactions) != 1 { + t.Fatalf("expected 1 reaction on resolve, got %d: %v", len(reactions), reactions) + } + r := reactions[0] + if r["name"] != defaultResolveReaction || r["timestamp"] != "100.1" || r["channel"] != "C0ALERTS" { + t.Fatalf("reaction payload not as expected: %v", r) + } + + // Stays resolved: no further reactions (identity cleared). + m.poll(ctx) + if got := len(notifier.reactions()); got != 1 { + t.Fatalf("expected no repeat reaction, got %d", got) + } +} + +func TestNoReactionWithoutTS(t *testing.T) { + ctx := context.Background() + src := newFakeSensor("src") + notifier := newFakeNotifier("notify") + notifier.sendTS = "" // simulate the webhook path: send returns no ts + m := newTestMonitor(t, &Config{ + Sensor: "src", + Notifier: "notify", + Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15}}, + }, src, notifier) + + src.set(map[string]interface{}{"usage": 20.0}) + m.poll(ctx) + src.set(map[string]interface{}{"usage": 0.0}) + m.poll(ctx) + + if got := len(notifier.reactions()); got != 0 { + t.Fatalf("expected no reaction when no ts was captured, got %d", got) + } +} + +func TestResolveReactionDisabled(t *testing.T) { + ctx := context.Background() + src := newFakeSensor("src") + notifier := newFakeNotifier("notify") + m := newTestMonitor(t, &Config{ + Sensor: "src", + Notifier: "notify", + ResolveReaction: "-", + Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15}}, + }, src, notifier) + + src.set(map[string]interface{}{"usage": 20.0}) + m.poll(ctx) + src.set(map[string]interface{}{"usage": 0.0}) + m.poll(ctx) + + if got := len(notifier.reactions()); got != 0 { + t.Fatalf("expected no reaction when disabled, got %d", got) + } +} + +func TestReactsToLatestMessageAfterCooldownRenotify(t *testing.T) { + ctx := context.Background() + src := newFakeSensor("src") + notifier := newFakeNotifier("notify") + m := newTestMonitor(t, &Config{ + Sensor: "src", + Notifier: "notify", + CooldownSec: 60, // long cooldown; we backdate lastNotified to force a re-notify + Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15}}, + }, src, notifier) + + src.set(map[string]interface{}{"usage": 20.0}) + m.poll(ctx) // first alert: ts 100.1 + + // Backdate so the cooldown has deterministically elapsed, then re-notify. + m.ruleStates[0].lastNotified = m.ruleStates[0].lastNotified.Add(-time.Hour) + notifier.sendTS = "200.2" // a re-notify will return a new ts + m.poll(ctx) // cooldown elapsed: re-notify, ts 200.2 + + src.set(map[string]interface{}{"usage": 0.0}) + m.poll(ctx) // resolve: should react to the latest message + + reactions := notifier.reactions() + if len(reactions) != 1 || reactions[0]["timestamp"] != "200.2" { + t.Fatalf("expected reaction to latest ts 200.2, got %v", reactions) + } +} + func TestReadingsExposesTriggerState(t *testing.T) { ctx := context.Background() src := newFakeSensor("src") From 80c656b66e954f450afa6e80a8473d16b933f8b0 Mon Sep 17 00:00:00 2001 From: danielbotros Date: Wed, 24 Jun 2026 12:02:33 -0400 Subject: [PATCH 2/4] sensor-monitor: make resolve_reaction per-rule, no default Move resolve_reaction from the top-level config onto Rule, next to the message it reacts to, and drop the default/disable sentinel: when a rule's resolve_reaction is set the monitor reacts with that emoji on resolve, and when it's unset nothing is added. Each rule opts in independently, and the sensorMonitor no longer needs a resolveReaction field. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 11 +-- resources/sensormonitor/sensormonitor.go | 79 ++++++++----------- resources/sensormonitor/sensormonitor_test.go | 23 +++--- 3 files changed, 50 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 95e02d1..1f700f3 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ When a rule fires, the monitor calls `DoCommand` on the notifier with the payloa Notifications are **edge-triggered**: a message is sent when a rule transitions from not-triggered to triggered. While the rule stays triggered no further message is sent, unless `cooldown_seconds` is set, in which case the message repeats at most once per cooldown window. When the reading clears the threshold and crosses it again, a new notification is sent. -**Reacting on resolve.** When a rule clears (its reading returns to the non-triggered side of the threshold), the monitor adds an emoji reaction — `:white_check_mark:` by default — to the original alert message, giving operators an at-a-glance "handled" marker without a follow-up message. This calls `DoCommand({"command": "react", "channel": ..., "timestamp": ..., "name": ...})` on the notifier, so it requires a notifier that supports the `react` command and returns a message `ts` on send (the [`viam:notifications:slack`](https://github.com/viam-modules/notifications) **bot-token** path). On a notifier that returns no `ts` (e.g. a Slack incoming webhook) there is nothing to react to and the step is silently skipped. Set `resolve_reaction` to a different emoji name, or to `"-"` to disable reacting entirely. The reaction is best-effort: a failure is logged and never affects monitoring. +**Reacting on resolve.** When a rule's `resolve_reaction` is set and that rule clears (its reading returns to the non-triggered side of the threshold), the monitor adds that emoji as a reaction to the rule's alert message, giving operators an at-a-glance "handled" marker without a follow-up message. This calls `DoCommand({"command": "react", "channel": ..., "timestamp": ..., "name": ...})` on the notifier, so it requires a notifier that supports the `react` command and returns a message `ts` on send (the [`viam:notifications:slack`](https://github.com/viam-modules/notifications) **bot-token** path). On a notifier that returns no `ts` (e.g. a Slack incoming webhook) there is nothing to react to and the step is silently skipped. When `resolve_reaction` is unset, no reaction is added. The reaction is best-effort: a failure is logged and never affects monitoring. ### Configuration @@ -89,13 +89,13 @@ Notifications are **edge-triggered**: a message is sent when a rule transitions "notifier": "", "poll_interval_seconds": , "cooldown_seconds": , - "resolve_reaction": "", "rules": [ { "key": "", "operator": "", "threshold": , - "message": "" + "message": "", + "resolve_reaction": "" } ] } @@ -108,7 +108,6 @@ Notifications are **edge-triggered**: a message is sent when a rule transitions | `rules` | array | **Yes** | One or more numeric trigger rules (see below). At least one rule is required. | | `poll_interval_seconds` | number | No | How often the sensor is polled, in seconds. Defaults to `10`. | | `cooldown_seconds` | number | No | Minimum time between repeat notifications while a rule stays triggered. `0` (default) means no repeat until the rule clears and fires again. | -| `resolve_reaction` | string | No | Emoji name (without colons) added as a reaction to the original alert message when a rule clears. Defaults to `white_check_mark`. Set to `-` to disable. Requires a notifier that supports the `react` command and returns a message `ts` on send. | Each entry in `rules`: @@ -118,6 +117,7 @@ Each entry in `rules`: | `operator` | string | **Yes** | Comparison applied between the reading and `threshold`. One of `>`, `>=`, `<`, `<=`, `==`, `!=` (aliases: `gt`, `gte`, `lt`, `lte`, `eq`, `ne`). | | `threshold` | number | **Yes** | The value the reading is compared against. | | `message` | string | No | Notification template. Supports placeholders `{key}`, `{value}`, `{threshold}`, `{operator}`. If omitted, a message like `temperature is 95 (> 90)` is used. | +| `resolve_reaction` | string | No | Emoji name (without colons, e.g. `white_check_mark`) added as a reaction to this rule's alert message when the rule clears. When unset, no reaction is added. Requires a notifier that supports the `react` command and returns a message `ts` on send. | ### Example Configuration @@ -132,7 +132,8 @@ Each entry in `rules`: "key": "temperature", "operator": ">", "threshold": 90, - "message": "High temperature alert: {key} is {value} (limit {threshold})" + "message": "High temperature alert: {key} is {value} (limit {threshold})", + "resolve_reaction": "white_check_mark" }, { "key": "humidity", diff --git a/resources/sensormonitor/sensormonitor.go b/resources/sensormonitor/sensormonitor.go index f3a836d..7c3819d 100644 --- a/resources/sensormonitor/sensormonitor.go +++ b/resources/sensormonitor/sensormonitor.go @@ -34,14 +34,6 @@ func init() { // defaultPollInterval is used when poll_interval_seconds is not set. const defaultPollInterval = 10 * time.Second -// defaultResolveReaction is the emoji added to the original alert message when a -// rule clears, unless resolve_reaction overrides it. -const defaultResolveReaction = "white_check_mark" - -// reactionDisabled is the resolve_reaction sentinel that turns off reacting on -// resolve. -const reactionDisabled = "-" - // Rule describes a single numeric trigger on one reading key. type Rule struct { // Key is the reading key to watch, e.g. "temperature". @@ -54,6 +46,13 @@ type Rule struct { // Message is an optional notification template. It supports the placeholders // {key}, {value}, {threshold} and {operator}. If empty, a message is generated. Message string `json:"message,omitempty"` + // ResolveReaction is the emoji name (without colons, e.g. "white_check_mark") + // to add as a reaction to this rule's alert message when the rule clears (its + // reading returns to the non-triggered side of the threshold). When unset, no + // reaction is added. Requires the notifier to support a {"command": "react", + // ...} DoCommand and to have returned a message "ts" on send (the Slack + // bot-token path). + ResolveReaction string `json:"resolve_reaction,omitempty"` } // Config is the configuration for the sensor-monitor model. @@ -71,13 +70,6 @@ type Config struct { // stays triggered, in seconds. 0 (default) means do not re-notify until the // rule clears and fires again. CooldownSec float64 `json:"cooldown_seconds,omitempty"` - // ResolveReaction is the emoji name (without colons) added as a reaction to - // the original alert message when a rule clears (value returns to the - // non-triggered side of the threshold). Requires the notifier to support a - // {"command": "react", ...} DoCommand and to have returned a message "ts" on - // send (the Slack bot-token path). Defaults to "white_check_mark". Set to - // "-" to disable reacting on resolve. - ResolveReaction string `json:"resolve_reaction,omitempty"` } // Validate checks the config and returns the required dependency names. @@ -125,9 +117,8 @@ type sensorMonitor struct { sensorDep sensor.Sensor notifierDep generic.Service - pollInterval time.Duration - cooldown time.Duration - resolveReaction string + pollInterval time.Duration + cooldown time.Duration cancelCtx context.Context cancelFunc func() @@ -178,26 +169,20 @@ func newMonitor(deps resource.Dependencies, name resource.Name, conf *Config, lo } cooldown := time.Duration(conf.CooldownSec * float64(time.Second)) - resolveReaction := conf.ResolveReaction - if resolveReaction == "" { - resolveReaction = defaultResolveReaction - } - cancelCtx, cancelFunc := context.WithCancel(context.Background()) return &sensorMonitor{ - Named: name.AsNamed(), - logger: logger, - cfg: conf, - sensorDep: sensorDep, - notifierDep: notifierDep, - pollInterval: pollInterval, - cooldown: cooldown, - resolveReaction: resolveReaction, - cancelCtx: cancelCtx, - cancelFunc: cancelFunc, - lastReadings: map[string]interface{}{}, - ruleStates: make([]ruleState, len(conf.Rules)), + Named: name.AsNamed(), + logger: logger, + cfg: conf, + sensorDep: sensorDep, + notifierDep: notifierDep, + pollInterval: pollInterval, + cooldown: cooldown, + cancelCtx: cancelCtx, + cancelFunc: cancelFunc, + lastReadings: map[string]interface{}{}, + ruleStates: make([]ruleState, len(conf.Rules)), }, nil } @@ -267,10 +252,10 @@ func (m *sensorMonitor) poll(ctx context.Context) { st.lastNotified = now notify = true case !fired: - // Edge from triggered to cleared: the rule just resolved. If we have a - // message to react to, capture it now and clear the stored identity so - // we react exactly once. - if st.triggered && st.msgTS != "" { + // Edge from triggered to cleared: the rule just resolved. When a resolve + // reaction is configured and we have a message to react to, capture it + // now and clear the stored identity so we react exactly once. + if st.triggered && st.msgTS != "" && rule.ResolveReaction != "" { resolved = true reactChannel = st.msgChannel reactTS = st.msgTS @@ -321,17 +306,15 @@ func (m *sensorMonitor) sendNotification(ctx context.Context, rule Rule, value f return channel, ts } -// sendResolveReaction asks the notifier to add the configured emoji reaction to -// a previously-sent alert message once its rule has cleared. Best-effort: a -// failure (e.g. a notifier that doesn't support "react", or a webhook notifier) -// is logged and never affects monitoring. +// sendResolveReaction asks the notifier to add the rule's resolve_reaction emoji +// to a previously-sent alert message once the rule has cleared. Only called when +// the rule's resolve_reaction is set. Best-effort: a failure (e.g. a notifier +// that doesn't support "react", or a webhook notifier) is logged and never +// affects monitoring. func (m *sensorMonitor) sendResolveReaction(ctx context.Context, rule Rule, channel, ts string) { - if m.resolveReaction == reactionDisabled { - return - } cmd := map[string]interface{}{ "command": "react", - "name": m.resolveReaction, + "name": rule.ResolveReaction, "channel": channel, "timestamp": ts, } @@ -339,7 +322,7 @@ func (m *sensorMonitor) sendResolveReaction(ctx context.Context, rule Rule, chan m.logger.Warnf("failed to react to resolved alert for %q via %q: %v", rule.Key, m.cfg.Notifier, err) return } - m.logger.Infof("reacted :%s: to resolved alert for %q", m.resolveReaction, rule.Key) + m.logger.Infof("reacted :%s: to resolved alert for %q", rule.ResolveReaction, rule.Key) } // Readings returns the most recent sensor readings plus, per rule, a diff --git a/resources/sensormonitor/sensormonitor_test.go b/resources/sensormonitor/sensormonitor_test.go index 6296444..c5e1999 100644 --- a/resources/sensormonitor/sensormonitor_test.go +++ b/resources/sensormonitor/sensormonitor_test.go @@ -306,7 +306,7 @@ func TestReactsOnResolve(t *testing.T) { m := newTestMonitor(t, &Config{ Sensor: "src", Notifier: "notify", - Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15}}, + Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15, ResolveReaction: "white_check_mark"}}, }, src, notifier) // Cross the threshold: one alert, captured ts, no reaction yet. @@ -330,7 +330,7 @@ func TestReactsOnResolve(t *testing.T) { t.Fatalf("expected 1 reaction on resolve, got %d: %v", len(reactions), reactions) } r := reactions[0] - if r["name"] != defaultResolveReaction || r["timestamp"] != "100.1" || r["channel"] != "C0ALERTS" { + if r["name"] != "white_check_mark" || r["timestamp"] != "100.1" || r["channel"] != "C0ALERTS" { t.Fatalf("reaction payload not as expected: %v", r) } @@ -349,7 +349,7 @@ func TestNoReactionWithoutTS(t *testing.T) { m := newTestMonitor(t, &Config{ Sensor: "src", Notifier: "notify", - Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15}}, + Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15, ResolveReaction: "white_check_mark"}}, }, src, notifier) src.set(map[string]interface{}{"usage": 20.0}) @@ -362,15 +362,15 @@ func TestNoReactionWithoutTS(t *testing.T) { } } -func TestResolveReactionDisabled(t *testing.T) { +func TestNoReactionWhenUnconfigured(t *testing.T) { ctx := context.Background() src := newFakeSensor("src") notifier := newFakeNotifier("notify") + // resolve_reaction unset: alerts still fire, but nothing is reacted to. m := newTestMonitor(t, &Config{ - Sensor: "src", - Notifier: "notify", - ResolveReaction: "-", - Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15}}, + Sensor: "src", + Notifier: "notify", + Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15}}, }, src, notifier) src.set(map[string]interface{}{"usage": 20.0}) @@ -378,8 +378,11 @@ func TestResolveReactionDisabled(t *testing.T) { src.set(map[string]interface{}{"usage": 0.0}) m.poll(ctx) + if got := len(notifier.texts()); got != 1 { + t.Fatalf("expected the alert to still be sent, got %d", got) + } if got := len(notifier.reactions()); got != 0 { - t.Fatalf("expected no reaction when disabled, got %d", got) + t.Fatalf("expected no reaction when resolve_reaction is unset, got %d", got) } } @@ -391,7 +394,7 @@ func TestReactsToLatestMessageAfterCooldownRenotify(t *testing.T) { Sensor: "src", Notifier: "notify", CooldownSec: 60, // long cooldown; we backdate lastNotified to force a re-notify - Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15}}, + Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15, ResolveReaction: "white_check_mark"}}, }, src, notifier) src.set(map[string]interface{}{"usage": 20.0}) From 5e780a1b85e54117f0c1b0ef557f3fcdb111b5f0 Mon Sep 17 00:00:00 2001 From: danielbotros Date: Wed, 24 Jun 2026 12:17:22 -0400 Subject: [PATCH 3/4] sensor-monitor: decouple resolve reaction from the notifier's message identity Replace the Slack-specific msgChannel/msgTS fields on ruleState with a single opaque sentMessage map holding the notifier's send response. On resolve the monitor copies that response back and adds command/name, so it never has to know how a notifier identifies a message. A test injects an opaque field into the send response and asserts it is echoed verbatim on react. Co-Authored-By: Claude Opus 4.8 (1M context) --- resources/sensormonitor/sensormonitor.go | 91 +++++++++---------- resources/sensormonitor/sensormonitor_test.go | 54 ++++++++--- 2 files changed, 81 insertions(+), 64 deletions(-) diff --git a/resources/sensormonitor/sensormonitor.go b/resources/sensormonitor/sensormonitor.go index 7c3819d..48dcae4 100644 --- a/resources/sensormonitor/sensormonitor.go +++ b/resources/sensormonitor/sensormonitor.go @@ -46,11 +46,10 @@ type Rule struct { // Message is an optional notification template. It supports the placeholders // {key}, {value}, {threshold} and {operator}. If empty, a message is generated. Message string `json:"message,omitempty"` - // ResolveReaction is the emoji name (without colons, e.g. "white_check_mark") - // to add as a reaction to this rule's alert message when the rule clears (its - // reading returns to the non-triggered side of the threshold). When unset, no - // reaction is added. Requires the notifier to support a {"command": "react", - // ...} DoCommand and to have returned a message "ts" on send (the Slack + // ResolveReaction is the emoji name (e.g. "white_check_mark") to add as a + // reaction to this rule's alert message when the rule reading returns to + // the non-triggered side of the threshold. When unset, no reaction is added. + // Requires the notifier to support a {"command": "react", ...} DoCommand and to have returned a message "ts" on send (the Slack // bot-token path). ResolveReaction string `json:"resolve_reaction,omitempty"` } @@ -99,12 +98,13 @@ type ruleState struct { triggered bool lastNotified time.Time lastValue float64 - // msgChannel and msgTS identify the most recent alert message sent for this - // rule, captured from the notifier's send response so the message can be - // reacted to when the rule clears. Empty when no alert is outstanding or the - // notifier did not return a ts (e.g. the Slack webhook path). - msgChannel string - msgTS string + // sentMessage is the notifier's response to this rule's most recent alert + // send, retained opaquely and handed back to the notifier to react to that + // message when the rule clears. nil when no alert is outstanding. The monitor + // does not interpret its contents — they are notifier-specific (e.g. Slack + // returns a channel and message ts), which keeps the monitor decoupled from + // any particular notifier. + sentMessage map[string]interface{} } type sensorMonitor struct { @@ -238,8 +238,7 @@ func (m *sensorMonitor) poll(ctx context.Context) { fired := cmp(value, rule.Threshold) notify := false - resolved := false - var reactChannel, reactTS string + var reactTo map[string]interface{} m.mu.Lock() st := &m.ruleStates[i] st.lastValue = value @@ -253,43 +252,37 @@ func (m *sensorMonitor) poll(ctx context.Context) { notify = true case !fired: // Edge from triggered to cleared: the rule just resolved. When a resolve - // reaction is configured and we have a message to react to, capture it - // now and clear the stored identity so we react exactly once. - if st.triggered && st.msgTS != "" && rule.ResolveReaction != "" { - resolved = true - reactChannel = st.msgChannel - reactTS = st.msgTS + // reaction is configured and we captured the alert message, hand it back + // to react, then clear it so we react exactly once. + if st.triggered && st.sentMessage != nil && rule.ResolveReaction != "" { + reactTo = st.sentMessage } st.triggered = false - st.msgChannel = "" - st.msgTS = "" + st.sentMessage = nil } m.mu.Unlock() if notify { - channel, ts := m.sendNotification(ctx, rule, value) - // Record the message so it can be reacted to on resolve. A re-notify - // (cooldown) overwrites the previous identity, so we react to the most - // recent alert. Empty ts (webhook path, or send failure) leaves nothing - // to react to. - if ts != "" { + resp := m.sendNotification(ctx, rule, value) + // Retain the notifier's response so the message can be reacted to on + // resolve. A re-notify (cooldown) overwrites it, so we react to the most + // recent alert. nil (send failure) leaves nothing to react to. + if resp != nil { m.mu.Lock() - m.ruleStates[i].msgChannel = channel - m.ruleStates[i].msgTS = ts + m.ruleStates[i].sentMessage = resp m.mu.Unlock() } } - if resolved { - m.sendResolveReaction(ctx, rule, reactChannel, reactTS) + if reactTo != nil { + m.sendResolveReaction(ctx, rule, reactTo) } } } // sendNotification renders the rule's message and calls DoCommand on the -// notifier, returning the message's channel and ts when the notifier reports -// them (the Slack bot-token path). Both are empty on failure or when the -// notifier does not return them. -func (m *sensorMonitor) sendNotification(ctx context.Context, rule Rule, value float64) (channel, ts string) { +// notifier, returning the notifier's response so the sent message can be reacted +// to later. Returns nil on failure. +func (m *sensorMonitor) sendNotification(ctx context.Context, rule Rule, value float64) map[string]interface{} { msg := renderMessage(rule, value) cmd := map[string]interface{}{ "command": "send", @@ -298,26 +291,26 @@ func (m *sensorMonitor) sendNotification(ctx context.Context, rule Rule, value f resp, err := m.notifierDep.DoCommand(ctx, cmd) if err != nil { m.logger.Errorf("failed to notify via %q: %v", m.cfg.Notifier, err) - return "", "" + return nil } - channel, _ = resp["channel"].(string) - ts, _ = resp["ts"].(string) m.logger.Infof("notification sent: %s", msg) - return channel, ts + return resp } // sendResolveReaction asks the notifier to add the rule's resolve_reaction emoji -// to a previously-sent alert message once the rule has cleared. Only called when -// the rule's resolve_reaction is set. Best-effort: a failure (e.g. a notifier -// that doesn't support "react", or a webhook notifier) is logged and never -// affects monitoring. -func (m *sensorMonitor) sendResolveReaction(ctx context.Context, rule Rule, channel, ts string) { - cmd := map[string]interface{}{ - "command": "react", - "name": rule.ResolveReaction, - "channel": channel, - "timestamp": ts, +// to a previously-sent alert message once the rule has cleared. It hands the +// notifier's own send response (sentMessage) back with the reaction name added, +// so the monitor never has to know how the notifier identifies a message. Only +// called when the rule's resolve_reaction is set. Best-effort: a failure (e.g. a +// notifier that doesn't support "react", or one that can't react such as a Slack +// webhook) is logged and never affects monitoring. +func (m *sensorMonitor) sendResolveReaction(ctx context.Context, rule Rule, sentMessage map[string]interface{}) { + cmd := make(map[string]interface{}, len(sentMessage)+2) + for k, v := range sentMessage { + cmd[k] = v } + cmd["command"] = "react" + cmd["name"] = rule.ResolveReaction if _, err := m.notifierDep.DoCommand(ctx, cmd); err != nil { m.logger.Warnf("failed to react to resolved alert for %q via %q: %v", rule.Key, m.cfg.Notifier, err) return diff --git a/resources/sensormonitor/sensormonitor_test.go b/resources/sensormonitor/sensormonitor_test.go index c5e1999..59ba6a9 100644 --- a/resources/sensormonitor/sensormonitor_test.go +++ b/resources/sensormonitor/sensormonitor_test.go @@ -47,17 +47,19 @@ func (f *fakeSensor) Readings(ctx context.Context, extra map[string]interface{}) func (f *fakeSensor) Close(context.Context) error { return nil } // fakeNotifier is a generic.Service that records the DoCommands it receives. -// On a "send" it returns a synthetic ts/channel (the Slack bot-token shape) so -// the monitor can capture a message identity to react to; an empty sendTS -// simulates the webhook path that returns no ts. +// On a "send" it returns a synthetic response (the Slack bot-token shape: ts + +// channel) so the monitor can capture a message identity to react to. sendExtra +// adds arbitrary opaque fields to that response, used to prove the monitor hands +// the whole response back on react without interpreting it. type fakeNotifier struct { resource.Named resource.AlwaysRebuild - mu sync.Mutex - received []map[string]interface{} - sendTS string - sendCh string + mu sync.Mutex + received []map[string]interface{} + sendTS string + sendCh string + sendExtra map[string]interface{} } func newFakeNotifier(name string) *fakeNotifier { @@ -68,8 +70,18 @@ func (f *fakeNotifier) DoCommand(ctx context.Context, cmd map[string]interface{} f.mu.Lock() defer f.mu.Unlock() f.received = append(f.received, cmd) - if cmd["command"] == "send" && f.sendTS != "" { - return map[string]interface{}{"ok": true, "ts": f.sendTS, "channel": f.sendCh}, nil + if cmd["command"] == "send" { + resp := map[string]interface{}{"ok": true} + if f.sendTS != "" { + resp["ts"] = f.sendTS + } + if f.sendCh != "" { + resp["channel"] = f.sendCh + } + for k, v := range f.sendExtra { + resp[k] = v + } + return resp, nil } return map[string]interface{}{"ok": true}, nil } @@ -330,7 +342,7 @@ func TestReactsOnResolve(t *testing.T) { t.Fatalf("expected 1 reaction on resolve, got %d: %v", len(reactions), reactions) } r := reactions[0] - if r["name"] != "white_check_mark" || r["timestamp"] != "100.1" || r["channel"] != "C0ALERTS" { + if r["name"] != "white_check_mark" || r["ts"] != "100.1" || r["channel"] != "C0ALERTS" { t.Fatalf("reaction payload not as expected: %v", r) } @@ -341,11 +353,14 @@ func TestReactsOnResolve(t *testing.T) { } } -func TestNoReactionWithoutTS(t *testing.T) { +// TestResolveReactionEchoesSendResponse proves the monitor hands the notifier's +// entire send response back on react — including an opaque field it has no +// knowledge of — rather than cherry-picking Slack-specific keys. +func TestResolveReactionEchoesSendResponse(t *testing.T) { ctx := context.Background() src := newFakeSensor("src") notifier := newFakeNotifier("notify") - notifier.sendTS = "" // simulate the webhook path: send returns no ts + notifier.sendExtra = map[string]interface{}{"thread_id": "T-9"} // a field the monitor knows nothing about m := newTestMonitor(t, &Config{ Sensor: "src", Notifier: "notify", @@ -357,8 +372,17 @@ func TestNoReactionWithoutTS(t *testing.T) { src.set(map[string]interface{}{"usage": 0.0}) m.poll(ctx) - if got := len(notifier.reactions()); got != 0 { - t.Fatalf("expected no reaction when no ts was captured, got %d", got) + reactions := notifier.reactions() + if len(reactions) != 1 { + t.Fatalf("expected 1 reaction, got %d: %v", len(reactions), reactions) + } + r := reactions[0] + if r["command"] != "react" || r["name"] != "white_check_mark" { + t.Fatalf("react command/name not set: %v", r) + } + // The whole send response is forwarded verbatim, opaque field included. + if r["ts"] != "100.1" || r["channel"] != "C0ALERTS" || r["thread_id"] != "T-9" { + t.Fatalf("send response not echoed verbatim: %v", r) } } @@ -409,7 +433,7 @@ func TestReactsToLatestMessageAfterCooldownRenotify(t *testing.T) { m.poll(ctx) // resolve: should react to the latest message reactions := notifier.reactions() - if len(reactions) != 1 || reactions[0]["timestamp"] != "200.2" { + if len(reactions) != 1 || reactions[0]["ts"] != "200.2" { t.Fatalf("expected reaction to latest ts 200.2, got %v", reactions) } } From 1c1b0678bb462679b77a9052aafae469eced92b2 Mon Sep 17 00:00:00 2001 From: danielbotros Date: Wed, 24 Jun 2026 12:33:58 -0400 Subject: [PATCH 4/4] sensor-monitor: decouple resolve-reaction comments, tests, and docs from Slack Reword the sentMessage/sendNotification/sendResolveReaction comments to treat the stored value as opaque message metadata rather than a notifier-specific send response, make the test fake return an arbitrary opaque map instead of a Slack-shaped ts/channel (folding the redundant echo test into the main one), and drop the Slack bot-token / ts / webhook specifics from the README. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 +- resources/sensormonitor/sensormonitor.go | 27 +++---- resources/sensormonitor/sensormonitor_test.go | 80 +++++-------------- 3 files changed, 30 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 1f700f3..969cda4 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ When a rule fires, the monitor calls `DoCommand` on the notifier with the payloa Notifications are **edge-triggered**: a message is sent when a rule transitions from not-triggered to triggered. While the rule stays triggered no further message is sent, unless `cooldown_seconds` is set, in which case the message repeats at most once per cooldown window. When the reading clears the threshold and crosses it again, a new notification is sent. -**Reacting on resolve.** When a rule's `resolve_reaction` is set and that rule clears (its reading returns to the non-triggered side of the threshold), the monitor adds that emoji as a reaction to the rule's alert message, giving operators an at-a-glance "handled" marker without a follow-up message. This calls `DoCommand({"command": "react", "channel": ..., "timestamp": ..., "name": ...})` on the notifier, so it requires a notifier that supports the `react` command and returns a message `ts` on send (the [`viam:notifications:slack`](https://github.com/viam-modules/notifications) **bot-token** path). On a notifier that returns no `ts` (e.g. a Slack incoming webhook) there is nothing to react to and the step is silently skipped. When `resolve_reaction` is unset, no reaction is added. The reaction is best-effort: a failure is logged and never affects monitoring. +**Reacting on resolve.** When a rule's `resolve_reaction` is set and that rule clears (its reading returns to the non-triggered side of the threshold), the monitor adds that emoji as a reaction to the rule's alert message, giving operators an at-a-glance "handled" marker without a follow-up message. To do this it calls `DoCommand({"command": "react", "name": , ...})` on the notifier, passing back the metadata the notifier returned when it sent the message, so it requires a notifier that supports the `react` command. When `resolve_reaction` is unset, no reaction is added. The reaction is best-effort: a failure is logged and never affects monitoring. ### Configuration @@ -117,7 +117,7 @@ Each entry in `rules`: | `operator` | string | **Yes** | Comparison applied between the reading and `threshold`. One of `>`, `>=`, `<`, `<=`, `==`, `!=` (aliases: `gt`, `gte`, `lt`, `lte`, `eq`, `ne`). | | `threshold` | number | **Yes** | The value the reading is compared against. | | `message` | string | No | Notification template. Supports placeholders `{key}`, `{value}`, `{threshold}`, `{operator}`. If omitted, a message like `temperature is 95 (> 90)` is used. | -| `resolve_reaction` | string | No | Emoji name (without colons, e.g. `white_check_mark`) added as a reaction to this rule's alert message when the rule clears. When unset, no reaction is added. Requires a notifier that supports the `react` command and returns a message `ts` on send. | +| `resolve_reaction` | string | No | Emoji name (e.g. `white_check_mark`) added as a reaction to this rule's alert message when the rule clears. When unset, no reaction is added. Requires a notifier that supports the `react` command. | ### Example Configuration diff --git a/resources/sensormonitor/sensormonitor.go b/resources/sensormonitor/sensormonitor.go index 48dcae4..c21dc51 100644 --- a/resources/sensormonitor/sensormonitor.go +++ b/resources/sensormonitor/sensormonitor.go @@ -49,8 +49,7 @@ type Rule struct { // ResolveReaction is the emoji name (e.g. "white_check_mark") to add as a // reaction to this rule's alert message when the rule reading returns to // the non-triggered side of the threshold. When unset, no reaction is added. - // Requires the notifier to support a {"command": "react", ...} DoCommand and to have returned a message "ts" on send (the Slack - // bot-token path). + // Requires the notifier to support a {"command": "react", ...} DoCommand ResolveReaction string `json:"resolve_reaction,omitempty"` } @@ -98,12 +97,8 @@ type ruleState struct { triggered bool lastNotified time.Time lastValue float64 - // sentMessage is the notifier's response to this rule's most recent alert - // send, retained opaquely and handed back to the notifier to react to that - // message when the rule clears. nil when no alert is outstanding. The monitor - // does not interpret its contents — they are notifier-specific (e.g. Slack - // returns a channel and message ts), which keeps the monitor decoupled from - // any particular notifier. + // sentMessage is opaque metadata about the most recently sent message, kept + // as-is for later use. nil when no alert is outstanding. sentMessage map[string]interface{} } @@ -279,9 +274,8 @@ func (m *sensorMonitor) poll(ctx context.Context) { } } -// sendNotification renders the rule's message and calls DoCommand on the -// notifier, returning the notifier's response so the sent message can be reacted -// to later. Returns nil on failure. +// sendNotification renders the rule's message and sends it via the notifier, +// returning the notifier's response (nil on failure). func (m *sensorMonitor) sendNotification(ctx context.Context, rule Rule, value float64) map[string]interface{} { msg := renderMessage(rule, value) cmd := map[string]interface{}{ @@ -297,13 +291,10 @@ func (m *sensorMonitor) sendNotification(ctx context.Context, rule Rule, value f return resp } -// sendResolveReaction asks the notifier to add the rule's resolve_reaction emoji -// to a previously-sent alert message once the rule has cleared. It hands the -// notifier's own send response (sentMessage) back with the reaction name added, -// so the monitor never has to know how the notifier identifies a message. Only -// called when the rule's resolve_reaction is set. Best-effort: a failure (e.g. a -// notifier that doesn't support "react", or one that can't react such as a Slack -// webhook) is logged and never affects monitoring. +// sendResolveReaction reacts to the rule's earlier alert message with the +// configured emoji once the rule has cleared, passing the stored sentMessage +// metadata back through unchanged. Only called when resolve_reaction is set. +// Best-effort: a failure is logged and never affects monitoring. func (m *sensorMonitor) sendResolveReaction(ctx context.Context, rule Rule, sentMessage map[string]interface{}) { cmd := make(map[string]interface{}, len(sentMessage)+2) for k, v := range sentMessage { diff --git a/resources/sensormonitor/sensormonitor_test.go b/resources/sensormonitor/sensormonitor_test.go index 59ba6a9..37c6a35 100644 --- a/resources/sensormonitor/sensormonitor_test.go +++ b/resources/sensormonitor/sensormonitor_test.go @@ -46,24 +46,20 @@ func (f *fakeSensor) Readings(ctx context.Context, extra map[string]interface{}) func (f *fakeSensor) Close(context.Context) error { return nil } -// fakeNotifier is a generic.Service that records the DoCommands it receives. -// On a "send" it returns a synthetic response (the Slack bot-token shape: ts + -// channel) so the monitor can capture a message identity to react to. sendExtra -// adds arbitrary opaque fields to that response, used to prove the monitor hands -// the whole response back on react without interpreting it. +// fakeNotifier is a generic.Service that records the DoCommands it receives. A +// "send" returns sendResp, an opaque map the monitor stores and later passes +// back; the contents are arbitrary so the tests don't assume any notifier shape. type fakeNotifier struct { resource.Named resource.AlwaysRebuild - mu sync.Mutex - received []map[string]interface{} - sendTS string - sendCh string - sendExtra map[string]interface{} + mu sync.Mutex + received []map[string]interface{} + sendResp map[string]interface{} } func newFakeNotifier(name string) *fakeNotifier { - return &fakeNotifier{Named: generic.Named(name).AsNamed(), sendTS: "100.1", sendCh: "C0ALERTS"} + return &fakeNotifier{Named: generic.Named(name).AsNamed(), sendResp: map[string]interface{}{"id": "m1"}} } func (f *fakeNotifier) DoCommand(ctx context.Context, cmd map[string]interface{}) (map[string]interface{}, error) { @@ -71,14 +67,8 @@ func (f *fakeNotifier) DoCommand(ctx context.Context, cmd map[string]interface{} defer f.mu.Unlock() f.received = append(f.received, cmd) if cmd["command"] == "send" { - resp := map[string]interface{}{"ok": true} - if f.sendTS != "" { - resp["ts"] = f.sendTS - } - if f.sendCh != "" { - resp["channel"] = f.sendCh - } - for k, v := range f.sendExtra { + resp := map[string]interface{}{} + for k, v := range f.sendResp { resp[k] = v } return resp, nil @@ -321,7 +311,7 @@ func TestReactsOnResolve(t *testing.T) { Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15, ResolveReaction: "white_check_mark"}}, }, src, notifier) - // Cross the threshold: one alert, captured ts, no reaction yet. + // Cross the threshold: one alert, no reaction yet. src.set(map[string]interface{}{"usage": 15.0}) m.poll(ctx) if got := len(notifier.reactions()); got != 0 { @@ -334,7 +324,8 @@ func TestReactsOnResolve(t *testing.T) { t.Fatalf("expected no reaction while still triggered, got %d", got) } - // Counter reset below threshold (the streamdeck refill): react exactly once. + // Reading returns below threshold: react exactly once, carrying the configured + // emoji plus the opaque metadata from the send (passed back unchanged). src.set(map[string]interface{}{"usage": 0.0}) m.poll(ctx) reactions := notifier.reactions() @@ -342,50 +333,17 @@ func TestReactsOnResolve(t *testing.T) { t.Fatalf("expected 1 reaction on resolve, got %d: %v", len(reactions), reactions) } r := reactions[0] - if r["name"] != "white_check_mark" || r["ts"] != "100.1" || r["channel"] != "C0ALERTS" { + if r["name"] != "white_check_mark" || r["id"] != "m1" { t.Fatalf("reaction payload not as expected: %v", r) } - // Stays resolved: no further reactions (identity cleared). + // Stays resolved: no further reactions (metadata cleared). m.poll(ctx) if got := len(notifier.reactions()); got != 1 { t.Fatalf("expected no repeat reaction, got %d", got) } } -// TestResolveReactionEchoesSendResponse proves the monitor hands the notifier's -// entire send response back on react — including an opaque field it has no -// knowledge of — rather than cherry-picking Slack-specific keys. -func TestResolveReactionEchoesSendResponse(t *testing.T) { - ctx := context.Background() - src := newFakeSensor("src") - notifier := newFakeNotifier("notify") - notifier.sendExtra = map[string]interface{}{"thread_id": "T-9"} // a field the monitor knows nothing about - m := newTestMonitor(t, &Config{ - Sensor: "src", - Notifier: "notify", - Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15, ResolveReaction: "white_check_mark"}}, - }, src, notifier) - - src.set(map[string]interface{}{"usage": 20.0}) - m.poll(ctx) - src.set(map[string]interface{}{"usage": 0.0}) - m.poll(ctx) - - reactions := notifier.reactions() - if len(reactions) != 1 { - t.Fatalf("expected 1 reaction, got %d: %v", len(reactions), reactions) - } - r := reactions[0] - if r["command"] != "react" || r["name"] != "white_check_mark" { - t.Fatalf("react command/name not set: %v", r) - } - // The whole send response is forwarded verbatim, opaque field included. - if r["ts"] != "100.1" || r["channel"] != "C0ALERTS" || r["thread_id"] != "T-9" { - t.Fatalf("send response not echoed verbatim: %v", r) - } -} - func TestNoReactionWhenUnconfigured(t *testing.T) { ctx := context.Background() src := newFakeSensor("src") @@ -422,19 +380,19 @@ func TestReactsToLatestMessageAfterCooldownRenotify(t *testing.T) { }, src, notifier) src.set(map[string]interface{}{"usage": 20.0}) - m.poll(ctx) // first alert: ts 100.1 + m.poll(ctx) // first alert: metadata id m1 // Backdate so the cooldown has deterministically elapsed, then re-notify. m.ruleStates[0].lastNotified = m.ruleStates[0].lastNotified.Add(-time.Hour) - notifier.sendTS = "200.2" // a re-notify will return a new ts - m.poll(ctx) // cooldown elapsed: re-notify, ts 200.2 + notifier.sendResp = map[string]interface{}{"id": "m2"} // the re-notify returns new metadata + m.poll(ctx) // cooldown elapsed: re-notify, id m2 src.set(map[string]interface{}{"usage": 0.0}) m.poll(ctx) // resolve: should react to the latest message reactions := notifier.reactions() - if len(reactions) != 1 || reactions[0]["ts"] != "200.2" { - t.Fatalf("expected reaction to latest ts 200.2, got %v", reactions) + if len(reactions) != 1 || reactions[0]["id"] != "m2" { + t.Fatalf("expected reaction to latest message m2, got %v", reactions) } }