main.go 8.7 KB

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