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
+21 -9
View File
@@ -49,14 +49,23 @@ func NewDispatcher(store ChannelStore, cipher Decryptor) *Dispatcher {
}
}
// ChannelResult is the per-channel delivery outcome, so callers can record
// success/failure metrics per channel type instead of one aggregate blob.
type ChannelResult struct {
Type string
Err error
}
// Send delivers ev to every enabled channel of projectID. Errors from
// individual channels are aggregated (via errors.Join) rather than aborting
// delivery to the remaining channels.
func (d *Dispatcher) Send(ctx context.Context, projectID uuid.UUID, ev Event) error {
// delivery to the remaining channels; the per-channel outcome is also
// returned so callers can record accurate per-channel/status metrics.
func (d *Dispatcher) Send(ctx context.Context, projectID uuid.UUID, ev Event) ([]ChannelResult, error) {
channels, err := d.store.ListEnabledChannels(ctx, projectID)
if err != nil {
return err
return nil, err
}
var results []ChannelResult
var errs []error
for _, ch := range channels {
n, ok := d.byType[ch.Type]
@@ -65,18 +74,21 @@ func (d *Dispatcher) Send(ctx context.Context, projectID uuid.UUID, ev Event) er
}
secret := ""
if ch.SecretEnc != "" {
b, err := d.cipher.Decrypt(ch.SecretEnc)
if err != nil {
errs = append(errs, err)
b, derr := d.cipher.Decrypt(ch.SecretEnc)
if derr != nil {
errs = append(errs, derr)
results = append(results, ChannelResult{Type: ch.Type, Err: derr})
continue
}
secret = string(b)
}
if err := n.Send(ctx, ch.Config, secret, ev); err != nil {
errs = append(errs, err)
serr := n.Send(ctx, ch.Config, secret, ev)
if serr != nil {
errs = append(errs, serr)
}
results = append(results, ChannelResult{Type: ch.Type, Err: serr})
}
return errors.Join(errs...)
return results, errors.Join(errs...)
}
// SendTest sends a single synthetic Event directly through the Notifier for