main.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. // loadgen/cmd/ws is the M5 WebSocket publisher for
  2. // broad-announce. Same data shape as loadgen/cmd/http and
  3. // loadgen/cmd/mqtt, but talks the WS transport at
  4. // ws://ingestd:8800/v1/ingest/ws. The auth model is the same
  5. // as the HTTP path: the api-key is `company:source:secret`,
  6. // sent as a JSON frame on connect. The per-message HMAC is the
  7. // same X-BA-Signature as the HTTP path, embedded in the
  8. // envelope's `auth` field.
  9. //
  10. // Example:
  11. //
  12. // loadgen-ws --target ws://localhost:8800/v1/ingest/ws \
  13. // --api-key acme-001:prom-prod:s3cret-acme \
  14. // --count 10 --rate 5
  15. package main
  16. import (
  17. "context"
  18. "crypto/hmac"
  19. "crypto/sha256"
  20. "encoding/hex"
  21. "encoding/json"
  22. "flag"
  23. "fmt"
  24. "log/slog"
  25. "math/rand/v2"
  26. "net/http"
  27. "os"
  28. "os/signal"
  29. "strings"
  30. "sync"
  31. "sync/atomic"
  32. "syscall"
  33. "time"
  34. "git3.techno-world.net/lrosales/broad-announce/internal/wsclient"
  35. )
  36. func main() {
  37. var (
  38. target = flag.String("target", "ws://localhost:8800/v1/ingest/ws", "WebSocket endpoint URL")
  39. apiKey = flag.String("api-key", "", "company:source:secret")
  40. count = flag.Int("count", 10, "total alerts to send")
  41. rate = flag.Int("rate", 10, "target alerts/sec")
  42. rampUp = flag.Duration("ramp-up", 0, "linear ramp from 0 to --rate over this duration (default 0 = instant)")
  43. mode = flag.String("mode", "normal", "profile: normal|burst")
  44. dedupePct = flag.Int("dedupe-pct", 30, "percent sharing a dedupe_key (normal)")
  45. dedupeKey = flag.String("dedupe-key", "", "force a specific dedupe_key on every alert (M6 ×N smoke test)")
  46. timeout = flag.Duration("duration", 30*time.Second, "max run time")
  47. concurrencyFlag = flag.Int("concurrency", 1, "parallel WS connections (each one is one source)")
  48. clusterID = flag.String("cluster-id", "default", "tag added as label on all loadgen metrics")
  49. instance = flag.String("instance", "default", "loadgen instance name (e.g. loadgen-ws-1)")
  50. metricsAddr = flag.String("metrics", "", "Prometheus metrics listen addr (empty to disable)")
  51. )
  52. flag.Parse()
  53. if *apiKey == "" {
  54. fmt.Fprintln(os.Stderr, "loadgen-ws: --api-key is required (company:source:secret)")
  55. os.Exit(2)
  56. }
  57. parts := strings.SplitN(*apiKey, ":", 3)
  58. if len(parts) != 3 {
  59. fmt.Fprintln(os.Stderr, "loadgen-ws: --api-key must be company:source:secret")
  60. os.Exit(2)
  61. }
  62. company, source, secret := parts[0], parts[1], parts[2]
  63. logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
  64. logger.Info("starting",
  65. "target", *target,
  66. "rate", *rate,
  67. "ramp_up", *rampUp,
  68. "count", *count,
  69. "concurrency", *concurrencyFlag,
  70. "cluster_id", *clusterID,
  71. "instance", *instance,
  72. )
  73. _, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  74. defer stop()
  75. logger.Info("publishing",
  76. "target", *target,
  77. "count", *count, "rate", *rate, "mode", *mode,
  78. "concurrency", *concurrencyFlag,
  79. )
  80. var (
  81. sent atomic.Uint64
  82. failed atomic.Uint64
  83. dupes atomic.Uint64
  84. idxCh = make(chan int, *count)
  85. )
  86. for i := 0; i < *count; i++ {
  87. idxCh <- i
  88. }
  89. close(idxCh)
  90. limiter := time.NewTicker(time.Second / time.Duration(*rate))
  91. defer limiter.Stop()
  92. deadline := time.Now().Add(*timeout)
  93. var wg sync.WaitGroup
  94. for w := 0; w < *concurrencyFlag; w++ {
  95. wg.Add(1)
  96. go func(workerID int) {
  97. defer wg.Done()
  98. c, err := wsclient.Connect(wsclient.Config{
  99. URL: *target,
  100. APIKey: *apiKey,
  101. })
  102. if err != nil {
  103. logger.Error("ws connect", "worker", workerID, "err", err)
  104. failed.Add(1)
  105. return
  106. }
  107. defer c.Close()
  108. logger.Info("ws connected", "worker", workerID, "auth_reply", string(c.AuthReply()))
  109. for i := range idxCh {
  110. if time.Now().After(deadline) {
  111. return
  112. }
  113. <-limiter.C
  114. a := makeAlert(i, *mode, *dedupePct, *dedupeKey, company, source)
  115. body, _ := json.Marshal(a)
  116. ts := time.Now().Unix()
  117. mac := hmac.New(sha256.New, []byte(secret))
  118. mac.Write([]byte(fmt.Sprintf("%d", ts)))
  119. mac.Write([]byte("."))
  120. mac.Write(body)
  121. sig := hex.EncodeToString(mac.Sum(nil))
  122. env := map[string]json.RawMessage{
  123. "alert": body,
  124. }
  125. env["auth"] = json.RawMessage(fmt.Sprintf("%q", fmt.Sprintf("t=%d,v1=%s", ts, sig)))
  126. envelope, _ := json.Marshal(env)
  127. ack, err := c.SendAlert(envelope)
  128. if err != nil {
  129. failed.Add(1)
  130. logger.Warn("ws send", "err", err, "i", i)
  131. continue
  132. }
  133. if !isUnique(*dedupePct, i) {
  134. dupes.Add(1)
  135. }
  136. sent.Add(1)
  137. if i == 0 || (i+1)%(*count/10+1) == 0 {
  138. logger.Info("progress", "worker", workerID, "sent", i+1, "total", *count, "ack", string(ack))
  139. }
  140. }
  141. }(w)
  142. }
  143. wg.Wait()
  144. logger.Info("done",
  145. "sent", sent.Load(),
  146. "failed", failed.Load(),
  147. "dupes", dupes.Load(),
  148. )
  149. if failed.Load() > 0 {
  150. os.Exit(1)
  151. }
  152. if *metricsAddr != "" {
  153. go runWSMetrics(*metricsAddr, *clusterID, *instance, &sent, &failed, &dupes)
  154. }
  155. }
  156. // makeAlert is the same shape as loadgen/cmd/http and
  157. // loadgen/cmd/mqtt.
  158. func makeAlert(idx int, mode string, dedupePct int, forceKey, company, source string) map[string]any {
  159. severity := pickSeverity(mode)
  160. // M6: --dedupe-key forces a specific key on every alert,
  161. // which is what the ×N smoke test needs. Otherwise we
  162. // use the M5 distribution: 30% share a key, 70% get a
  163. // unique one.
  164. dedupeKey := fmt.Sprintf("lg-m5-%d", idx)
  165. if forceKey != "" {
  166. dedupeKey = forceKey
  167. } else if dedupePct > 0 && idx > 0 && rand.IntN(100) < dedupePct {
  168. dedupeKey = "lg-m5-shared"
  169. }
  170. title := fmt.Sprintf("LG M5 #%d", idx)
  171. if forceKey != "" {
  172. // M6.5 smoke: include the forced key in the title so
  173. // the tail subscriber can filter on a unique string
  174. // (the tail event includes title but not dedupe_key).
  175. title = fmt.Sprintf("LG M5 burst %s #%d", forceKey, idx)
  176. }
  177. return map[string]any{
  178. "company_id": company,
  179. "source_id": source,
  180. "severity": severity,
  181. "category": "loadgen",
  182. "title": title,
  183. "body": "ws smoke",
  184. "data": map[string]string{"host": "lg-host", "idx": fmt.Sprintf("%d", idx)},
  185. "dedupe_key": dedupeKey,
  186. }
  187. }
  188. func pickSeverity(mode string) string {
  189. r := rand.IntN(100)
  190. switch mode {
  191. case "burst":
  192. switch {
  193. case r < 70:
  194. return "critical"
  195. case r < 95:
  196. return "inminent_colapse"
  197. default:
  198. return "warning"
  199. }
  200. default:
  201. switch {
  202. case r < 70:
  203. return "info"
  204. case r < 95:
  205. return "warning"
  206. case r < 99:
  207. return "critical"
  208. default:
  209. return "inminent_colapse"
  210. }
  211. }
  212. }
  213. func isUnique(dedupePct, idx int) bool {
  214. if dedupePct == 0 || idx == 0 {
  215. return true
  216. }
  217. return rand.IntN(100) >= dedupePct
  218. }
  219. func runWSMetrics(addr, clusterID, instance string, sent, failed, dupes *atomic.Uint64) {
  220. mux := http.NewServeMux()
  221. mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
  222. fmt.Fprintf(w, "# HELP loadgen_alerts_sent_total Alerts successfully accepted.\n")
  223. fmt.Fprintf(w, "# TYPE loadgen_alerts_sent_total counter\n")
  224. fmt.Fprintf(w, "loadgen_alerts_sent_total{instance=%q,cluster_id=%q} %d\n",
  225. instance, clusterID, sent.Load())
  226. fmt.Fprintf(w, "loadgen_alerts_failed_total{instance=%q,cluster_id=%q} %d\n",
  227. instance, clusterID, failed.Load())
  228. fmt.Fprintf(w, "loadgen_dedupe_hits_total{instance=%q,cluster_id=%q} %d\n",
  229. instance, clusterID, dupes.Load())
  230. })
  231. srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
  232. _ = srv.ListenAndServe()
  233. }