Skip to content
Closed
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
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'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": <emoji>, ...})` 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

```json
Expand All @@ -92,7 +94,8 @@ Notifications are **edge-triggered**: a message is sent when a rule transitions
"key": "<string>",
"operator": "<string>",
"threshold": <number>,
"message": "<string>"
"message": "<string>",
"resolve_reaction": "<string>"
}
]
}
Expand All @@ -114,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 (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

Expand All @@ -128,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",
Expand Down
58 changes: 53 additions & 5 deletions resources/sensormonitor/sensormonitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ 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 (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
ResolveReaction string `json:"resolve_reaction,omitempty"`
}

// Config is the configuration for the sensor-monitor model.
Expand Down Expand Up @@ -92,6 +97,9 @@ type ruleState struct {
triggered bool
lastNotified time.Time
lastValue float64
// 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{}
}

type sensorMonitor struct {
Expand Down Expand Up @@ -225,6 +233,7 @@ func (m *sensorMonitor) poll(ctx context.Context) {
fired := cmp(value, rule.Threshold)

notify := false
var reactTo map[string]interface{}
m.mu.Lock()
st := &m.ruleStates[i]
st.lastValue = value
Expand All @@ -237,28 +246,67 @@ func (m *sensorMonitor) poll(ctx context.Context) {
st.lastNotified = now
notify = true
case !fired:
// Edge from triggered to cleared: the rule just resolved. When a resolve
// 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.sentMessage = nil
}
m.mu.Unlock()

if notify {
m.sendNotification(ctx, rule, value)
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].sentMessage = resp
m.mu.Unlock()
}
}
if reactTo != nil {
m.sendResolveReaction(ctx, rule, reactTo)
}
}
}

// 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 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{}{
"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 nil
}
m.logger.Infof("notification sent: %s", msg)
return resp
}

// 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 {
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
}
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
Expand Down
125 changes: 122 additions & 3 deletions resources/sensormonitor/sensormonitor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"sync"
"testing"
"time"

sensor "go.viam.com/rdk/components/sensor"
"go.viam.com/rdk/logging"
Expand Down Expand Up @@ -45,28 +46,51 @@ 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.
// 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{}
sendResp map[string]interface{}
}

func newFakeNotifier(name string) *fakeNotifier {
return &fakeNotifier{Named: generic.Named(name).AsNamed()}
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) {
f.mu.Lock()
defer f.mu.Unlock()
f.received = append(f.received, cmd)
return map[string]interface{}{}, nil
if cmd["command"] == "send" {
resp := map[string]interface{}{}
for k, v := range f.sendResp {
resp[k] = v
}
return resp, 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()
Expand Down Expand Up @@ -277,6 +301,101 @@ 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, ResolveReaction: "white_check_mark"}},
}, src, notifier)

// 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 {
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)
}

// 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()
if len(reactions) != 1 {
t.Fatalf("expected 1 reaction on resolve, got %d: %v", len(reactions), reactions)
}
r := reactions[0]
if r["name"] != "white_check_mark" || r["id"] != "m1" {
t.Fatalf("reaction payload not as expected: %v", r)
}

// 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)
}
}

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",
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.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 resolve_reaction is unset, 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, ResolveReaction: "white_check_mark"}},
}, src, notifier)

src.set(map[string]interface{}{"usage": 20.0})
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.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]["id"] != "m2" {
t.Fatalf("expected reaction to latest message m2, got %v", reactions)
}
}

func TestReadingsExposesTriggerState(t *testing.T) {
ctx := context.Background()
src := newFakeSensor("src")
Expand Down