feat(notify): per-channel delivery results + accurate notification metrics
Dispatcher.Send now returns []ChannelResult{Type, Err} alongside the
aggregated error, and scheduler.checkDomain increments
NotificationsTotal per channel type/status instead of a single
unconditional IncNotification("dispatch", newStatus) placeholder that
ignored per-channel delivery outcome.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
@@ -49,7 +49,7 @@ type Checker interface {
|
||||
// NotifySender delivers a status-change event to a project's notification
|
||||
// channels. internal/notify.Dispatcher satisfies this.
|
||||
type NotifySender interface {
|
||||
Send(ctx context.Context, projectID uuid.UUID, ev notify.Event) error
|
||||
Send(ctx context.Context, projectID uuid.UUID, ev notify.Event) ([]notify.ChannelResult, error)
|
||||
}
|
||||
|
||||
// Scheduler drives periodic domain checks for every due project schedule.
|
||||
@@ -172,10 +172,17 @@ func (s *Scheduler) checkDomain(ctx context.Context, projectID uuid.UUID, d stor
|
||||
Summary: summarize(newStatus, cs, checkErr),
|
||||
At: now,
|
||||
}
|
||||
if err := s.notifier.Send(ctx, projectID, ev); err != nil {
|
||||
results, err := s.notifier.Send(ctx, projectID, ev)
|
||||
if err != nil {
|
||||
log.Printf("scheduler: notify send for project %s domain %s failed: %v", projectID, d.ID, err)
|
||||
}
|
||||
s.metrics.IncNotification("dispatch", newStatus)
|
||||
for _, r := range results {
|
||||
status := "sent"
|
||||
if r.Err != nil {
|
||||
status = "failed"
|
||||
}
|
||||
s.metrics.IncNotification(r.Type, status)
|
||||
}
|
||||
}
|
||||
|
||||
return newStatus
|
||||
|
||||
@@ -94,17 +94,21 @@ func (c *mockChecker) Check(ctx context.Context, projectID, domainID uuid.UUID)
|
||||
return c.results[domainID], nil
|
||||
}
|
||||
|
||||
// mockNotifier records every Event it is asked to Send.
|
||||
// mockNotifier records every Event it is asked to Send, and returns a
|
||||
// canned set of per-channel results (results defaults to nil, i.e. no
|
||||
// channels) so tests can assert on scheduler.checkDomain's per-channel
|
||||
// metric recording.
|
||||
type mockNotifier struct {
|
||||
mu sync.Mutex
|
||||
events []notify.Event
|
||||
mu sync.Mutex
|
||||
events []notify.Event
|
||||
results []notify.ChannelResult
|
||||
}
|
||||
|
||||
func (n *mockNotifier) Send(ctx context.Context, projectID uuid.UUID, ev notify.Event) error {
|
||||
func (n *mockNotifier) Send(ctx context.Context, projectID uuid.UUID, ev notify.Event) ([]notify.ChannelResult, error) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.events = append(n.events, ev)
|
||||
return nil
|
||||
return n.results, nil
|
||||
}
|
||||
|
||||
func (n *mockNotifier) count() int {
|
||||
@@ -291,6 +295,45 @@ func TestRunOnce_SkipsDomainWithoutTemplate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunOnce_RecordsPerChannelNotificationMetrics exercises the fix for the
|
||||
// "IncNotification('dispatch', newStatus) unconditionally" bug: the
|
||||
// scheduler must record one NotificationsTotal increment per channel result,
|
||||
// labeled by that channel's actual type and its actual sent/failed outcome
|
||||
// — not a single "dispatch" placeholder blind to per-channel delivery.
|
||||
func TestRunOnce_RecordsPerChannelNotificationMetrics(t *testing.T) {
|
||||
projectID := uuid.New()
|
||||
templateID := uuid.New()
|
||||
domainA := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: &templateID}
|
||||
|
||||
st := newMockStore()
|
||||
st.schedules = []store.Schedule{{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: 3600, Enabled: true}}
|
||||
st.domains[projectID] = []store.Domain{domainA}
|
||||
|
||||
checker := &mockChecker{
|
||||
results: map[uuid.UUID]diff.Changeset{domainA.ID: driftChangeset()},
|
||||
}
|
||||
notifier := &mockNotifier{
|
||||
results: []notify.ChannelResult{
|
||||
{Type: "telegram"},
|
||||
{Type: "webhook", Err: errors.New("x")},
|
||||
},
|
||||
}
|
||||
m := metrics.New()
|
||||
sched := New(st, checker, notifier, m)
|
||||
|
||||
// unknown -> drift: shouldNotify is true, so notifier.Send fires once.
|
||||
if err := sched.RunOnce(context.Background(), time.Now()); err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(m.NotificationsTotal.WithLabelValues("telegram", "sent")); got != 1 {
|
||||
t.Fatalf("NotificationsTotal{telegram,sent} = %v, want 1", got)
|
||||
}
|
||||
if got := testutil.ToFloat64(m.NotificationsTotal.WithLabelValues("webhook", "failed")); got != 1 {
|
||||
t.Fatalf("NotificationsTotal{webhook,failed} = %v, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldNotify(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
Reference in New Issue
Block a user