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:
2026-07-04 15:56:15 +07:00
parent e9a100ab4a
commit f14916396c
4 changed files with 130 additions and 20 deletions
+51 -3
View File
@@ -330,7 +330,7 @@ func TestDispatcherSendsToAllChannelsAndAggregatesErrors(t *testing.T) {
d.byType["webhook"] = &Webhook{HTTP: whSrv.Client(), allowPrivate: true}
ev := Event{Project: "proj", Domain: "example.com", Status: "drift", Summary: "changed", At: time.Now()}
err := d.Send(context.Background(), projectID, ev)
results, err := d.Send(context.Background(), projectID, ev)
if !tgCalled {
t.Error("expected telegram notifier to be called")
@@ -344,6 +344,54 @@ func TestDispatcherSendsToAllChannelsAndAggregatesErrors(t *testing.T) {
if tgSecret != "decrypted-enc-token" {
t.Fatalf("expected decrypted secret to be passed to telegram, got %q", tgSecret)
}
if len(results) != 2 {
t.Fatalf("results = %d, want 2", len(results))
}
byType := make(map[string]ChannelResult, len(results))
for _, r := range results {
byType[r.Type] = r
}
if tg, ok := byType["telegram"]; !ok || tg.Err != nil {
t.Fatalf("telegram result = %+v, want ok result", tg)
}
if wh, ok := byType["webhook"]; !ok || wh.Err == nil {
t.Fatalf("webhook result = %+v, want error result", wh)
}
}
// TestDispatcherSendReturnsPerChannelResults exercises the exact scenario
// from the plan: one telegram channel succeeding, one webhook channel
// failing at the Notifier — the metric consumer (scheduler) needs a result
// per channel, not one aggregate blob, to record accurate per-channel/status
// metrics.
func TestDispatcherSendReturnsPerChannelResults(t *testing.T) {
projectID := uuid.New()
channels := []store.Channel{
{ID: uuid.New(), ProjectID: projectID, Type: "telegram", Config: json.RawMessage(`{"chat_id":"1"}`), Enabled: true},
{ID: uuid.New(), ProjectID: projectID, Type: "webhook", Config: json.RawMessage(`{"url":"http://x"}`), Enabled: true},
}
d := NewDispatcher(&mockChannelStore{channels: channels}, &mockDecryptor{})
d.byType["telegram"] = notifierFunc(func(ctx context.Context, cfg json.RawMessage, secret string, ev Event) error {
return nil
})
d.byType["webhook"] = notifierFunc(func(ctx context.Context, cfg json.RawMessage, secret string, ev Event) error {
return errBoom
})
results, err := d.Send(context.Background(), projectID, Event{Project: "p", Domain: "d", Status: "drift"})
if err == nil {
t.Fatal("expected aggregated error because webhook failed")
}
if len(results) != 2 {
t.Fatalf("results = %d, want 2", len(results))
}
if results[0].Type != "telegram" || results[0].Err != nil {
t.Fatalf("results[0] = %+v, want telegram/nil", results[0])
}
if results[1].Type != "webhook" || results[1].Err == nil {
t.Fatalf("results[1] = %+v, want webhook/error", results[1])
}
}
func TestDispatcherSkipsUnknownChannelType(t *testing.T) {
@@ -352,7 +400,7 @@ func TestDispatcherSkipsUnknownChannelType(t *testing.T) {
{ID: uuid.New(), ProjectID: projectID, Type: "carrier-pigeon", Config: json.RawMessage(`{}`), Enabled: true},
}
d := NewDispatcher(&mockChannelStore{channels: channels}, &mockDecryptor{})
if err := d.Send(context.Background(), projectID, Event{Project: "p", Domain: "d", Status: "drift"}); err != nil {
if _, err := d.Send(context.Background(), projectID, Event{Project: "p", Domain: "d", Status: "drift"}); err != nil {
t.Fatalf("unexpected error for unknown channel type: %v", err)
}
}
@@ -375,7 +423,7 @@ func TestDispatcherDecryptFailureIsAggregatedNotFatal(t *testing.T) {
// default; swap in an allowPrivate webhook so this test can still hit it.
d.byType["webhook"] = &Webhook{HTTP: whSrv.Client(), allowPrivate: true}
err := d.Send(context.Background(), projectID, Event{Project: "p", Domain: "d", Status: "drift"})
_, err := d.Send(context.Background(), projectID, Event{Project: "p", Domain: "d", Status: "drift"})
if err == nil {
t.Fatal("expected error due to decrypt failure")
}