release: 1.6.0: 30-day send stats and optional auto level-2 rate limits

30-day sending statistics per domain and application, plus optional auto level-2 rate limits derived from average send rate.
Added:
  - Domain page **Sending statistics** card: total messages, peak and average
  - Level-2 rate limit **Manual** / **Auto** mode on domain and application
  - Domain export JSON now includes rate limits (`mode`, `auto_multiplier`,
Changed:
  - Auto mode uses the level-1 window (`RATE_LIMIT_WINDOW_SECONDS`); manual

Full detail: CHANGELOG.md (1.6.0).
This commit is contained in:
mix
2026-09-22 15:24:52 +03:00
parent dca7d99c1b
commit 60b743e6c2
27 changed files with 1092 additions and 140 deletions
+101
View File
@@ -0,0 +1,101 @@
package store
import (
"fmt"
"math"
"time"
)
// StatsWindowDays is the rolling window for send statistics on the domain page.
const StatsWindowDays = 30
// SendStats holds message-volume metrics over a stats window (guide § Rate
// limiting — same counting rules as CountMessages).
type SendStats struct {
Total int64
PeakPerHour int64
AvgPerHour float64
WindowDays int // actual days queried (min(30, retention))
WindowHours float64 // hours used for the average-rate denominator
}
// StatsWindow computes the since timestamp and hours denominator for send
// statistics. The query window is min(30, retention) days; hours_in_window is
// min(720, entity age in hours, retention hours).
func StatsWindow(retentionDays int, entityCreated time.Time) (since time.Time, hours float64, windowDays int) {
if retentionDays <= 0 {
retentionDays = SendLogRetentionDaysDefault
}
windowDays = StatsWindowDays
if retentionDays < StatsWindowDays {
windowDays = retentionDays
}
since = time.Now().UTC().AddDate(0, 0, -windowDays)
retentionHours := float64(retentionDays) * 24
ageHours := time.Since(entityCreated).Hours()
if ageHours < 0 {
ageHours = 0
}
hours = math.Min(720, math.Min(ageHours, retentionHours))
if hours < 1 {
hours = 1
}
return since, hours, windowDays
}
// DomainSendStats returns 30-day (or shorter when retention is lower) send
// statistics for a domain name.
func (s *Store) DomainSendStats(name string, retentionDays int, createdAt time.Time) (SendStats, error) {
since, hours, windowDays := StatsWindow(retentionDays, createdAt)
stats, err := s.sendStats("domain", name, since, hours)
if err != nil {
return SendStats{}, err
}
stats.WindowDays = windowDays
stats.WindowHours = hours
return stats, nil
}
// AppSendStats returns send statistics for an application login.
func (s *Store) AppSendStats(login string, retentionDays int, createdAt time.Time) (SendStats, error) {
since, hours, windowDays := StatsWindow(retentionDays, createdAt)
stats, err := s.sendStats("app_login", login, since, hours)
if err != nil {
return SendStats{}, err
}
stats.WindowDays = windowDays
stats.WindowHours = hours
return stats, nil
}
func (s *Store) sendStats(column, ref string, since time.Time, hours float64) (SendStats, error) {
sinceStr := since.UTC().Format(time.RFC3339)
var total int64
err := s.db.QueryRow(
`SELECT COUNT(DISTINCT queue_id) FROM send_log
WHERE `+column+` = ? AND status != ? AND created_at >= ?`,
ref, StatusRejected, sinceStr,
).Scan(&total)
if err != nil {
return SendStats{}, fmt.Errorf("send stats total %s %q: %w", column, ref, err)
}
var peak int64
err = s.db.QueryRow(
`SELECT COALESCE(MAX(bucket_count), 0) FROM (
SELECT COUNT(DISTINCT queue_id) AS bucket_count FROM send_log
WHERE `+column+` = ? AND status != ? AND created_at >= ?
GROUP BY substr(created_at, 1, 13)
)`,
ref, StatusRejected, sinceStr,
).Scan(&peak)
if err != nil {
return SendStats{}, fmt.Errorf("send stats peak %s %q: %w", column, ref, err)
}
avg := float64(total) / hours
return SendStats{Total: total, PeakPerHour: peak, AvgPerHour: avg}, nil
}