main.go 8.6 KB

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