Browse Source

M11 W4: loadgen/cmd/grpc — gRPC StreamAlerts load generator

NEW FILES:
- loadgen/cmd/grpc/main.go (~400 lines):
  * --target (default localhost:9090), --rate (1k/s), --workers (8 parallel streams)
  * --duration, --ramp-up, --metrics (:8892), --cluster-id, --instance
  * --dedupe-pct (30), --dedupe-key, --payload-bytes (256)
  * Each worker: opens one gRPC stream via internal/grpcclient,
    runs producer goroutine (paced via pacer) + consumer goroutine (RTT histogram)
  * Atomic counters: sent, failed, dupes, rlHits
  * AtomicHistogram: lock-free RTT bucketing (<1/5/10/25/50/100/250ms+)
  * Prometheus /metrics endpoint with cluster_id + instance labels
  * Graceful shutdown via signal context

MODIFIED:
- Dockerfile: added CGO_ENABLED=0 GOOS=linux go build -o /out/loadgen-grpc ./loadgen/cmd/grpc
- docker-compose.yml:
  * New profile: loadgen-grpc (not started by default)
  * loadgen-grpc-1: --rate=5000 --workers=8 (5k/s × 2 = 10k/s total)
  * loadgen-grpc-2: --rate=5000 --workers=8
  * Both use ingestd:9090 and distinct api-keys (acme-001/002)
- .gitignore: ignore loadgen/grpc binary

go build ./...  : clean
go test ./...   : all pass
docker compose --profile loadgen-grpc config : validates
Luis Rosales 1 month ago
parent
commit
e8ba05c681
6 changed files with 498 additions and 3 deletions
  1. 1 0
      .gitignore
  2. 1 1
      Dockerfile
  3. 35 0
      docker-compose.yml
  4. 416 0
      loadgen/cmd/grpc/main.go
  5. 9 2
      loadgen/go.mod
  6. 36 0
      loadgen/go.sum

+ 1 - 0
.gitignore

@@ -28,3 +28,4 @@ docker-compose.override.yml
 /admind
 /seed
 /fakefcmd
+loadgen/grpc

+ 1 - 1
Dockerfile

@@ -13,7 +13,7 @@ RUN go mod download
 # Build
 COPY . .
 RUN go mod tidy
-RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/ingestd ./cmd/ingestd && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/routerd ./cmd/routerd && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/deliverd-fcm ./cmd/deliverd-fcm && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/deliverd-telegram ./cmd/deliverd-telegram && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/deliverd-bench ./cmd/deliverd-bench && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/telegramd ./cmd/telegramd && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/admind ./cmd/admind && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/archiverd ./cmd/archiverd && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/seed ./cmd/seed && cd loadgen && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/loadgen-http ./cmd/http && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/loadgen-mqtt ./cmd/mqtt && cd .. && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/fakefcmd ./testfakes/fakefcmd && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/faketgmd ./testfakes/faketgmd
+RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/ingestd ./cmd/ingestd && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/routerd ./cmd/routerd && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/deliverd-fcm ./cmd/deliverd-fcm && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/deliverd-telegram ./cmd/deliverd-telegram && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/deliverd-bench ./cmd/deliverd-bench && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/telegramd ./cmd/telegramd && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/admind ./cmd/admind && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/archiverd ./cmd/archiverd && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/seed ./cmd/seed && cd loadgen && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/loadgen-http ./cmd/http && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/loadgen-mqtt ./cmd/mqtt && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/loadgen-grpc ./cmd/grpc && cd .. && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/fakefcmd ./testfakes/fakefcmd && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/faketgmd ./testfakes/faketgmd
 
 FROM alpine:3.20
 RUN apk add --no-cache ca-certificates

+ 35 - 0
docker-compose.yml

@@ -402,6 +402,41 @@ services:
     depends_on:
       ingestd: { condition: service_started }
 
+  # M11 W4: gRPC loadgen — 2-instance cluster at 10k/s total.
+  # Each instance opens 8 parallel streams (workers) at 5k/s each.
+  # Use: docker compose --profile loadgen-grpc up -d
+  loadgen-grpc-1:
+    build: .
+    command:
+      - /app/loadgen-grpc
+      - --target=ingestd:9090
+      - --api-key=acme-001:prom-prod:s3cret-acme-001
+      - --rate=5000
+      - --workers=8
+      - --duration=15m
+      - --metrics=:8892
+      - --instance=loadgen-grpc-1
+      - --cluster-id=m11
+    profiles: ["loadgen-grpc"]
+    depends_on:
+      ingestd: { condition: service_started }
+
+  loadgen-grpc-2:
+    build: .
+    command:
+      - /app/loadgen-grpc
+      - --target=ingestd:9090
+      - --api-key=acme-002:prom-prod:s3cret-acme-002
+      - --rate=5000
+      - --workers=8
+      - --duration=15m
+      - --metrics=:8892
+      - --instance=loadgen-grpc-2
+      - --cluster-id=m11
+    profiles: ["loadgen-grpc"]
+    depends_on:
+      ingestd: { condition: service_started }
+
   # M10-bench: delivery tier stubbed with deliverd-bench (no-op).
   # No FCM, no Telegram, no Postgres writes — just consume and ACK.
   # Allows broker+router ceiling testing at 50k/s without burning FCM credits.

+ 416 - 0
loadgen/cmd/grpc/main.go

@@ -0,0 +1,416 @@
+// loadgen/cmd/grpc is the gRPC (StreamAlerts) traffic generator for M11.
+// Each instance opens N parallel bidirectional streams (--workers).
+//
+// Example (single instance, 10k/s):
+//
+//	loadgen-grpc --target localhost:9090 \
+//	  --api-key acme-001:prom-prod:s3cret \
+//	  --rate 10000 --workers 8 --duration 10m
+//
+// Example (2-instance cluster, 10k/s total):
+//
+//	loadgen-grpc-1 --rate 5000 --workers 8 --duration 15m --instance loadgen-grpc-1
+//	loadgen-grpc-2 --rate 5000 --workers 8 --duration 15m --instance loadgen-grpc-2
+package main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"log/slog"
+	"math/rand/v2"
+	"net/http"
+	"os"
+	"os/signal"
+	"strconv"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"syscall"
+	"time"
+
+	grpcclient "git3.techno-world.net/lrosales/broad-announce/internal/grpcclient"
+	pbv1 "git3.techno-world.net/lrosales/broad-announce/gen/go/broadannounce/v1"
+	"git3.techno-world.net/lrosales/broad-announce/internal/alert"
+	"git3.techno-world.net/lrosales/broad-announce/loadgen/internal/pacer"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+)
+
+type workerResult struct {
+	sent, failed, dupes, rlHits uint64
+}
+
+func main() {
+	var (
+		target    = flag.String("target", "localhost:9090", "ingestd gRPC address")
+		apiKey    = flag.String("api-key", "", "company_id:source_id:secret")
+		rate      = flag.Int("rate", 1000, "target alerts/sec total (across all workers)")
+		workers   = flag.Int("workers", 8, "parallel gRPC streams")
+		duration  = flag.Duration("duration", 30*time.Second, "total run time")
+		rampUp    = flag.Duration("ramp-up", 0, "linear ramp from 0 to --rate over this duration")
+		metrics   = flag.String("metrics", ":8892", "Prometheus metrics listen addr (empty to disable)")
+		clusterID = flag.String("cluster-id", "default", "tag added as label on all loadgen metrics")
+		instance  = flag.String("instance", "default", "loadgen instance name")
+		dedupePct = flag.Int("dedupe-pct", 30, "percent of alerts sharing a dedupe_key")
+		dedupeKey = flag.String("dedupe-key", "", "force a specific dedupe_key on every alert")
+		payloadB  = flag.Int("payload-bytes", 256, "approximate payload size in bytes (Data map)")
+	)
+
+	flag.Parse()
+	if *apiKey == "" {
+		fmt.Fprintln(os.Stderr, "loadgen-grpc: --api-key is required (company:source:secret)")
+		os.Exit(2)
+	}
+	parts := strings.SplitN(*apiKey, ":", 3)
+	if len(parts) != 3 {
+		fmt.Fprintln(os.Stderr, "loadgen-grpc: --api-key must be company:source:secret")
+		os.Exit(2)
+	}
+	_, source, secret := parts[0], parts[1], parts[2]
+
+	logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
+	logger.Info("starting",
+		"target", *target,
+		"rate", *rate,
+		"workers", *workers,
+		"ramp_up", *rampUp,
+		"duration", *duration,
+		"cluster_id", *clusterID,
+		"instance", *instance,
+	)
+
+	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+	defer stop()
+
+	var (
+		sent    atomic.Uint64
+		failed  atomic.Uint64
+		dupes   atomic.Uint64
+		rlHits  atomic.Uint64
+		rttHist atomicHistogram // lives outside workers
+	)
+
+
+	results := make([]workerResult, *workers)
+	var wg sync.WaitGroup
+
+	// Rate per worker.
+	ratePerWorker := *rate / *workers
+	if ratePerWorker < 1 {
+		ratePerWorker = 1
+	}
+
+	for wid := 0; wid < *workers; wid++ {
+		wg.Add(1)
+		go func(id int) {
+			defer wg.Done()
+			res := &results[id]
+			runWorker(ctx, logger, *target, *apiKey, source, secret,
+				ratePerWorker, *duration, *rampUp, *dedupePct, *dedupeKey, *payloadB, res, &rttHist)
+		}(wid)
+	}
+
+	// Metrics endpoint.
+	if *metrics != "" {
+		go runMetrics(*metrics, *clusterID, *instance, &sent, &failed, &dupes, &rlHits)
+	}
+
+	// Status ticker.
+	ticker := time.NewTicker(5 * time.Second)
+	defer ticker.Stop()
+	for {
+		select {
+		case <-ctx.Done():
+			wg.Wait()
+			printFinal(logger, &sent, &failed, &dupes, &rlHits, &rttHist)
+			return
+		case <-ticker.C:
+			logger.Info("progress",
+				"sent", sent.Load(),
+				"failed", failed.Load(),
+				"dupes", dupes.Load(),
+				"rate_limited", rlHits.Load(),
+			)
+		}
+	}
+}
+
+// runWorker opens one gRPC stream and drives it at the target rate.
+func runWorker(
+	ctx context.Context,
+	logger *slog.Logger,
+	target, apiKey, sourceID, secret string,
+	rate int,
+	duration, rampUp time.Duration,
+	dedupePct int,
+	dedupeKey string,
+	payloadB int,
+	res *workerResult,
+	rttHist *atomicHistogram,
+) {
+	// Connect.
+	c, err := grpcclient.New(target,
+		grpcclient.WithAPIKey(apiKey),
+		grpcclient.WithInsecure(), // loadgen — use mTLS in production
+	)
+	if err != nil {
+		logger.Error("grpcclient.New", "worker", 0, "err", err)
+		return
+	}
+	defer c.Close()
+
+	// Open stream.
+	stream, err := c.Stream(ctx)
+	if err != nil {
+		logger.Error("Client.Stream", "err", err)
+		return
+	}
+	defer stream.CloseSend()
+
+	// Producer goroutine.
+	prodCtx, cancelProd := context.WithTimeout(ctx, duration)
+	defer cancelProd()
+
+	type pendingAck struct {
+		sentAt time.Time
+		dk     string
+	}
+	ackCh := make(chan pendingAck, rate*2)
+
+	p := pacer.New(rate, rampUp)
+	tickCh, stopPacer := p.Tick(prodCtx)
+	defer stopPacer()
+
+	go func() {
+		i := 0
+		for {
+			select {
+			case <-prodCtx.Done():
+				stream.CloseSend()
+				close(ackCh)
+				return
+			case <-tickCh:
+				a := mkAlert("normal", sourceID, dedupePct, dedupeKey, payloadB)
+				if err := stream.Send(prodCtx, alertToProto(companyFromKey(apiKey), sourceID, a)); err != nil {
+					// Check if it's a retriable error.
+					st, _ := status.FromError(err)
+					if st.Code() == codes.ResourceExhausted || st.Code() == codes.Unavailable {
+						// Retry with backoff.
+						time.Sleep(5 * time.Millisecond)
+						if err := stream.Send(prodCtx, alertToProto(companyFromKey(apiKey), sourceID, a)); err != nil {
+							res.failed++
+							return
+						}
+					} else {
+						res.failed++
+						return
+					}
+				}
+				ackCh <- pendingAck{sentAt: time.Now(), dk: a.DedupeKey}
+				i++
+			}
+		}
+	}()
+
+	// Consumer goroutine — reads acks and computes RTT.
+	go func() {
+		for p := range ackCh {
+			ack, err := stream.Recv(prodCtx)
+			if err != nil {
+				continue
+			}
+			rttMs := time.Since(p.sentAt).Milliseconds()
+			rttHist.Record(rttMs)
+
+			if ack.GetError() != nil {
+				if ack.GetError().Code == pbv1.Error_RATE_LIMITED {
+					res.rlHits++
+				} else {
+					res.failed++
+				}
+			} else {
+				res.sent++
+				if ack.DedupeCount > 1 {
+					res.dupes++
+				}
+			}
+		}
+	}()
+
+	<-prodCtx.Done()
+}
+
+// companyFromKey extracts company_id from the api-key "company:source:secret".
+func companyFromKey(apiKey string) string {
+	parts := strings.SplitN(apiKey, ":", 3)
+	if len(parts) >= 1 {
+		return parts[0]
+	}
+	return "acme-001"
+}
+
+func mkAlert(mode, sourceID string, dedupePct int, dedupeKey string, payloadB int) alert.Alert {
+	severity := pickSeverity(mode)
+	category := pickCategory(severity)
+	dk := ""
+	if dedupeKey != "" {
+		dk = dedupeKey
+	} else if rand.IntN(100) < dedupePct {
+		dk = fmt.Sprintf("burst:%s:probe", category)
+	}
+	data := map[string]string{
+		"host":    fmt.Sprintf("host-%d", rand.IntN(100)),
+		"probe":   category,
+		"raw_msg": strings.Repeat("x", max(0, payloadB-64)),
+	}
+	return alert.Alert{
+		CompanyID: "acme-001",
+		SourceID:  sourceID,
+		Severity:  severity,
+		Category:  category,
+		Title:     fmt.Sprintf("%s on %s", category, data["host"]),
+		Body:      "synthetic loadgen alert",
+		Data:      data,
+		DedupeKey: dk,
+	}
+}
+
+func alertToProto(companyID, sourceID string, a alert.Alert) *pbv1.Alert {
+	data := make(map[string]string)
+	for k, v := range a.Data {
+		data[k] = v
+	}
+	return &pbv1.Alert{
+		CompanyId:  companyID,
+		SourceId:   sourceID,
+		Severity:   string(a.Severity),
+		Category:   a.Category,
+		Title:      a.Title,
+		Body:       a.Body,
+		DedupeKey:  a.DedupeKey,
+		ClientTsMs: time.Now().UnixMilli(),
+		Data:       data,
+	}
+}
+
+func pickSeverity(mode string) alert.Severity {
+	r := rand.IntN(100)
+	switch {
+	case r < 70:
+		return alert.SeverityInfo
+	case r < 95:
+		return alert.SeverityWarning
+	case r < 99:
+		return alert.SeverityCritical
+	default:
+		return alert.SeverityInminentColapse
+	}
+}
+
+var categoriesBySev = map[alert.Severity][]string{
+	alert.SeverityInfo:            {"deploy", "schedule", "audit"},
+	alert.SeverityWarning:         {"disk", "memory", "latency", "queue"},
+	alert.SeverityCritical:        {"storage", "network", "process"},
+	alert.SeverityInminentColapse: {"power", "hvac", "rack"},
+}
+
+func pickCategory(s alert.Severity) string {
+	opts := categoriesBySev[s]
+	return opts[rand.IntN(len(opts))]
+}
+
+// atomicHistogram is a lock-free fixed-size histogram for RTT values.
+// Buckets: 0-1ms, 1-5ms, 5-10ms, 10-25ms, 25-50ms, 50-100ms, 100-250ms, 250ms+.
+type atomicHistogram struct {
+	buckets [8]atomic.Uint64
+}
+
+func (h *atomicHistogram) Record(ms int64) {
+	var bucket int
+	switch {
+	case ms < 1:
+		bucket = 0
+	case ms < 5:
+		bucket = 1
+	case ms < 10:
+		bucket = 2
+	case ms < 25:
+		bucket = 3
+	case ms < 50:
+		bucket = 4
+	case ms < 100:
+		bucket = 5
+	case ms < 250:
+		bucket = 6
+	default:
+		bucket = 7
+	}
+	h.buckets[bucket].Add(1)
+}
+
+func (h *atomicHistogram) String() string {
+	var total uint64
+	for i := 0; i < len(h.buckets); i++ {
+		total += h.buckets[i].Load()
+	}
+	if total == 0 {
+		return "no data"
+	}
+	return fmt.Sprintf("total=%d p50=%s p99=%s",
+		total, h.percentile(50), h.percentile(99))
+}
+
+func (h *atomicHistogram) percentile(p int) string {
+	var total uint64
+	for i := 0; i < len(h.buckets); i++ {
+		total += h.buckets[i].Load()
+	}
+	threshold := uint64(float64(total) * float64(p) / 100.0)
+	var cumulative uint64
+	bounds := []string{"<1ms", "1-5ms", "5-10ms", "10-25ms", "25-50ms", "50-100ms", "100-250ms", ">250ms"}
+	for i := 0; i < len(h.buckets); i++ {
+		cumulative += h.buckets[i].Load()
+		if cumulative >= threshold {
+			return bounds[i]
+		}
+	}
+	return ">250ms"
+}
+
+func runMetrics(addr, clusterID, instance string, sent, failed, dupes, rlHits *atomic.Uint64) {
+	mux := http.NewServeMux()
+	mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprintf(w, "# HELP loadgen_alerts_sent_total Alerts successfully accepted.\n")
+		fmt.Fprintf(w, "# TYPE loadgen_alerts_sent_total counter\n")
+		fmt.Fprintf(w, "loadgen_alerts_sent_total{instance=%q,cluster_id=%q} %d\n",
+			instance, clusterID, sent.Load())
+		fmt.Fprintf(w, "loadgen_alerts_failed_total{instance=%q,cluster_id=%q} %d\n",
+			instance, clusterID, failed.Load())
+		fmt.Fprintf(w, "loadgen_dedupe_hits_total{instance=%q,cluster_id=%q} %d\n",
+			instance, clusterID, dupes.Load())
+		fmt.Fprintf(w, "loadgen_rate_limited_total{instance=%q,cluster_id=%q} %d\n",
+			instance, clusterID, rlHits.Load())
+	})
+	srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
+	_ = srv.ListenAndServe()
+}
+
+func printFinal(logger *slog.Logger, sent, failed, dupes, rlHits *atomic.Uint64, rttHist *atomicHistogram) {
+	logger.Info("done",
+		"sent", sent.Load(),
+		"failed", failed.Load(),
+		"dupes", dupes.Load(),
+		"rate_limited", rlHits.Load(),
+		"rtt", rttHist.String(),
+	)
+}
+
+func max(a, b int) int {
+	if a > b {
+		return a
+	}
+	return b
+}
+
+var _ = strconv.FormatInt // for future use

+ 9 - 2
loadgen/go.mod

@@ -4,11 +4,18 @@ go 1.25.0
 
 replace git3.techno-world.net/lrosales/broad-announce => ..
 
-require git3.techno-world.net/lrosales/broad-announce v0.0.0-00010101000000-000000000000
+require (
+	git3.techno-world.net/lrosales/broad-announce v0.0.0-00010101000000-000000000000
+	github.com/gorilla/websocket v1.5.3
+	google.golang.org/grpc v1.81.1
+)
 
 require (
 	github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect
-	github.com/gorilla/websocket v1.5.3 // indirect
 	golang.org/x/net v0.51.0 // indirect
 	golang.org/x/sync v0.20.0 // indirect
+	golang.org/x/sys v0.42.0 // indirect
+	golang.org/x/text v0.35.0 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
+	google.golang.org/protobuf v1.36.11 // indirect
 )

+ 36 - 0
loadgen/go.sum

@@ -1,8 +1,44 @@
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
 github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
 github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
+go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
+go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
+go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
+go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
+go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
+go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
+go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
+go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
+go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
 golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
 golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
 golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
+google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=