main.go 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. // loadgen/cmd/http is the HTTP POST traffic generator for M0.
  2. // --mode normal only for now; other profiles land as the protocols
  3. // come online.
  4. //
  5. // Example:
  6. //
  7. // loadgen-http --target http://localhost:8080 \
  8. // --api-key acme-001:prom-prod:s3cret \
  9. // --mode normal --rate 100 --duration 30s
  10. package main
  11. import (
  12. "bytes"
  13. "context"
  14. "crypto/hmac"
  15. "crypto/sha256"
  16. "encoding/hex"
  17. "encoding/json"
  18. "flag"
  19. "fmt"
  20. "io"
  21. "log/slog"
  22. "math/rand/v2"
  23. "net/http"
  24. "os"
  25. "os/signal"
  26. "strconv"
  27. "strings"
  28. "sync"
  29. "sync/atomic"
  30. "syscall"
  31. "time"
  32. "git3.techno-world.net/lrosales/broad-announce/internal/alert"
  33. )
  34. func main() {
  35. var (
  36. target = flag.String("target", "http://localhost:8080", "ingestd base URL")
  37. apiKey = flag.String("api-key", "", "company_id:source_id:secret")
  38. mode = flag.String("mode", "normal", "profile: normal|burst|stress")
  39. rate = flag.Int("rate", 100, "target alerts/sec per instance")
  40. duration = flag.Duration("duration", 30*time.Second, "total run time")
  41. conc = flag.Int("concurrency", 16, "concurrent HTTP requests in flight")
  42. metrics = flag.String("metrics", ":9091", "Prometheus metrics listen addr (empty to disable)")
  43. dedupePct = flag.Int("dedupe-pct", 30, "percent of alerts sharing a dedupe_key (normal mode)")
  44. companyN = flag.Int("companies", 1, "how many fake companies to cycle through")
  45. payloadB = flag.Int("payload-bytes", 256, "approximate payload size in bytes (Data map)")
  46. )
  47. flag.Parse()
  48. if *apiKey == "" {
  49. fmt.Fprintln(os.Stderr, "loadgen-http: --api-key is required (company:source:secret)")
  50. os.Exit(2)
  51. }
  52. parts := strings.SplitN(*apiKey, ":", 3)
  53. if len(parts) != 3 {
  54. fmt.Fprintln(os.Stderr, "loadgen-http: --api-key must be company:source:secret")
  55. os.Exit(2)
  56. }
  57. company, source, secret := parts[0], parts[1], parts[2]
  58. _ = company
  59. _ = source
  60. _ = secret
  61. logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
  62. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  63. defer stop()
  64. limiter := newRateLimiter(*rate)
  65. var (
  66. sent atomic.Uint64
  67. failed atomic.Uint64
  68. dupes atomic.Uint64
  69. rlHits atomic.Uint64
  70. )
  71. // Spawn workers.
  72. var wg sync.WaitGroup
  73. jobs := make(chan struct{}, *conc*4)
  74. for i := 0; i < *conc; i++ {
  75. wg.Add(1)
  76. go func(id int) {
  77. defer wg.Done()
  78. client := &http.Client{Timeout: 10 * time.Second}
  79. for range jobs {
  80. if ctx.Err() != nil {
  81. return
  82. }
  83. if err := limiter.Wait(ctx); err != nil {
  84. return
  85. }
  86. a := mkAlert(*mode, *companyN, *dedupePct, *payloadB)
  87. if err := sendOne(ctx, client, *target, company, source, []byte(secret), a); err != nil {
  88. failed.Add(1)
  89. if isRateLimited(err) {
  90. rlHits.Add(1)
  91. }
  92. logger.Debug("send failed", "err", err)
  93. } else {
  94. sent.Add(1)
  95. if a.DedupeCount > 1 {
  96. dupes.Add(1)
  97. }
  98. }
  99. }
  100. }(i)
  101. }
  102. // Producer.
  103. producerCtx, cancelProducer := context.WithTimeout(ctx, *duration)
  104. defer cancelProducer()
  105. go func() {
  106. ticker := time.NewTicker(time.Second / time.Duration(*rate+1))
  107. defer ticker.Stop()
  108. for {
  109. select {
  110. case <-producerCtx.Done():
  111. close(jobs)
  112. return
  113. case <-ticker.C:
  114. select {
  115. case jobs <- struct{}{}:
  116. default:
  117. // backpressure: drop rather than block
  118. }
  119. }
  120. }
  121. }()
  122. // Metrics endpoint.
  123. if *metrics != "" {
  124. go runMetrics(*metrics, &sent, &failed, &dupes, &rlHits)
  125. }
  126. // Status ticker.
  127. go func() {
  128. t := time.NewTicker(2 * time.Second)
  129. defer t.Stop()
  130. for {
  131. select {
  132. case <-ctx.Done():
  133. return
  134. case <-t.C:
  135. logger.Info("progress",
  136. "sent", sent.Load(),
  137. "failed", failed.Load(),
  138. "dupes", dupes.Load(),
  139. "rate_limited", rlHits.Load(),
  140. )
  141. }
  142. }
  143. }()
  144. wg.Wait()
  145. logger.Info("done",
  146. "sent", sent.Load(),
  147. "failed", failed.Load(),
  148. "dupes", dupes.Load(),
  149. "rate_limited", rlHits.Load(),
  150. )
  151. }
  152. func mkAlert(mode string, companies, dedupePct, payloadB int) alert.Alert {
  153. // Cycle through a few company_ids; the source is fixed for the
  154. // run (loadgen is one source).
  155. companyID := fmt.Sprintf("acme-%03d", (rand.IntN(companies) + 1))
  156. severity := pickSeverity(mode)
  157. category := pickCategory(severity)
  158. // Dedupe: dedupe_pct % of alerts share a fixed "burst" key
  159. // within a minute. For the simpler "normal" mode, we just
  160. // include a stable dedupe key on a fraction of alerts.
  161. dk := ""
  162. if rand.IntN(100) < dedupePct {
  163. dk = fmt.Sprintf("burst:%s:probe", category)
  164. }
  165. // Synthetic data roughly `payloadB` bytes.
  166. data := map[string]string{
  167. "host": fmt.Sprintf("host-%d", rand.IntN(100)),
  168. "probe": category,
  169. "raw_msg": strings.Repeat("x", max(0, payloadB-64)),
  170. }
  171. return alert.Alert{
  172. CompanyID: companyID,
  173. SourceID: "prom-prod",
  174. Severity: severity,
  175. Category: category,
  176. Title: fmt.Sprintf("%s on %s", category, data["host"]),
  177. Body: "synthetic loadgen alert",
  178. Data: data,
  179. DedupeKey: dk,
  180. }
  181. }
  182. func pickSeverity(mode string) alert.Severity {
  183. // 70/25/4/1 split for normal, even for stress
  184. r := rand.IntN(100)
  185. switch {
  186. case r < 70:
  187. return alert.SeverityInfo
  188. case r < 95:
  189. return alert.SeverityWarning
  190. case r < 99:
  191. return alert.SeverityCritical
  192. default:
  193. return alert.SeverityInminentColapse
  194. }
  195. }
  196. var categoriesBySev = map[alert.Severity][]string{
  197. alert.SeverityInfo: {"deploy", "schedule", "audit"},
  198. alert.SeverityWarning: {"disk", "memory", "latency", "queue"},
  199. alert.SeverityCritical: {"storage", "network", "process"},
  200. alert.SeverityInminentColapse: {"power", "hvac", "rack"},
  201. }
  202. func pickCategory(s alert.Severity) string {
  203. opts := categoriesBySev[s]
  204. return opts[rand.IntN(len(opts))]
  205. }
  206. func sendOne(ctx context.Context, c *http.Client, target, company, source string, secret []byte, a alert.Alert) error {
  207. body, err := json.Marshal(a)
  208. if err != nil {
  209. return err
  210. }
  211. ts := strconv.FormatInt(time.Now().Unix(), 10)
  212. mac := hmac.New(sha256.New, secret)
  213. mac.Write([]byte(ts))
  214. mac.Write([]byte("."))
  215. mac.Write(body)
  216. sig := "t=" + ts + ",v1=" + hex.EncodeToString(mac.Sum(nil))
  217. req, err := http.NewRequestWithContext(ctx, "POST", target+"/v1/ingest", bytes.NewReader(body))
  218. if err != nil {
  219. return err
  220. }
  221. req.Header.Set("Content-Type", "application/json")
  222. req.Header.Set("X-BA-Signature", sig)
  223. resp, err := c.Do(req)
  224. if err != nil {
  225. return err
  226. }
  227. defer resp.Body.Close()
  228. if resp.StatusCode == http.StatusAccepted {
  229. var ar struct {
  230. DedupeCount uint32 `json:"dedupe_count"`
  231. }
  232. _ = json.NewDecoder(resp.Body).Decode(&ar)
  233. a.DedupeCount = ar.DedupeCount
  234. return nil
  235. }
  236. b, _ := io.ReadAll(resp.Body)
  237. if resp.StatusCode == http.StatusTooManyRequests {
  238. return &rateLimitedErr{status: resp.StatusCode, body: string(b)}
  239. }
  240. return fmt.Errorf("status %d: %s", resp.StatusCode, string(b))
  241. }
  242. type rateLimitedErr struct {
  243. status int
  244. body string
  245. }
  246. func (e *rateLimitedErr) Error() string { return fmt.Sprintf("rate limited: %s", e.body) }
  247. func isRateLimited(err error) bool {
  248. _, ok := err.(*rateLimitedErr)
  249. return ok
  250. }
  251. // rateLimiter is a simple per-second pacer. Not a token bucket; for
  252. // loadgen we want "send at most N per second" averaged, not bursty.
  253. type rateLimiter struct {
  254. perSec int
  255. last time.Time
  256. mu sync.Mutex
  257. }
  258. func newRateLimiter(perSec int) *rateLimiter { return &rateLimiter{perSec: perSec} }
  259. func (r *rateLimiter) Wait(ctx context.Context) error {
  260. if r.perSec <= 0 {
  261. return nil
  262. }
  263. r.mu.Lock()
  264. defer r.mu.Unlock()
  265. now := time.Now()
  266. interval := time.Second / time.Duration(r.perSec)
  267. if d := interval - now.Sub(r.last); d > 0 {
  268. t := time.NewTimer(d)
  269. defer t.Stop()
  270. r.mu.Unlock()
  271. select {
  272. case <-ctx.Done():
  273. r.mu.Lock()
  274. return ctx.Err()
  275. case <-t.C:
  276. }
  277. r.mu.Lock()
  278. }
  279. r.last = time.Now()
  280. return nil
  281. }
  282. func runMetrics(addr string, sent, failed, dupes, rlHits *atomic.Uint64) {
  283. mux := http.NewServeMux()
  284. mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
  285. fmt.Fprintf(w, "# HELP loadgen_alerts_sent_total Alerts successfully accepted.\n")
  286. fmt.Fprintf(w, "# TYPE loadgen_alerts_sent_total counter\n")
  287. fmt.Fprintf(w, "loadgen_alerts_sent_total %d\n", sent.Load())
  288. fmt.Fprintf(w, "loadgen_alerts_failed_total %d\n", failed.Load())
  289. fmt.Fprintf(w, "loadgen_dedupe_hits_total %d\n", dupes.Load())
  290. fmt.Fprintf(w, "loadgen_rate_limited_total %d\n", rlHits.Load())
  291. })
  292. srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
  293. _ = srv.ListenAndServe()
  294. }
  295. func max(a, b int) int {
  296. if a > b {
  297. return a
  298. }
  299. return b
  300. }