// WebSocket live-tail endpoint for ingestd (M5). The // server-sent companion to /v1/ingest/ws: the operator opens // this socket and receives a stream of accepted alert events. // // Endpoint: // // GET /v1/tail/ws[?company_id=acme-001&token=***] HTTP Upgrade → WebSocket // // Auth: a static token. The token is supplied as // - Authorization: Bearer // - X-BA-Tail-Token: // - ?token= query param (for wscat) // // The static token is set via BA_INGESTD_TAIL_TOKEN. M11 will // swap this for a JWT signed by admind. // // Protocol (server → client only): // // {"alert_id":"...","company_id":"...","source_id":"...", // "severity":"...","title":"...","received_at":"...", // "transport":"http|mqtt|ws","dedupe_count":N} // // One frame per accepted alert. The full alert payload is NOT // included (could be 256 KB); the operator gets the metadata // needed to filter, then drills in via the per-alert GET // endpoint (M9) for the body. // // Backpressure: a slow client has events dropped (counter // ticks); the producer never blocks. The client may keep the // connection open and catch up; we don't disconnect on drops. package main import ( "log/slog" "net/http" "strings" "time" "git3.techno-world.net/lrosales/broad-announce/internal/observability" "git3.techno-world.net/lrosales/broad-announce/internal/tailhub" ) // wsTailDeps is the WS tail handler's dependency set. type wsTailDeps struct { // Token is the static auth token (env: BA_INGESTD_TAIL_TOKEN). // Empty disables the endpoint (the route is still wired but // every request gets 503). This keeps the safety property // "if the env var is unset, the endpoint is a no-op". Token string // Hub is the in-process tail hub. Subscriptions are // created on each upgrade and released on close. Hub *tailhub.Hub // Metrics is the shared observability bundle. We update // tail_subscribers (gauge) and tail_dropped_total on // disconnect / slow consumer. Metrics *observability.IngestdMetrics // Logger is the tail's per-handler logger. Set by main. Logger *slog.Logger } // handleTail is the /v1/tail/ws upgrade handler. It runs in // its own goroutine per connection (the gorilla default). func (d *wsTailDeps) handleTail(w http.ResponseWriter, r *http.Request) { if d.Token == "" { http.Error(w, "tail endpoint disabled (BA_INGESTD_TAIL_TOKEN unset)", http.StatusServiceUnavailable) return } if !d.checkToken(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if d.Hub == nil { http.Error(w, "tail hub not configured", http.StatusServiceUnavailable) return } // Company filter (optional) companyID := strings.TrimSpace(r.URL.Query().Get("company_id")) // Subscribe BEFORE the upgrade so events that arrive in // the window between dial-completion and the per-goroutine // stream-loop startup are not lost. The hub's buffered // channel (64) absorbs the burst; if the client is too // slow, the drops counter ticks. sub := d.Hub.Subscribe(tailhub.Filter{CompanyID: companyID}) // Defer Unsubscribe BEFORE the deferred metric update so // the gauge observes the post-Unsubscribe subscriber count // (defers run LIFO). defer func() { sub.Unsubscribe() if d.Metrics != nil { d.Metrics.TailSubscribers.Set(float64(d.Hub.Stats().Subscribers)) } }() if d.Metrics != nil { d.Metrics.TailSubscribers.Set(float64(d.Hub.Stats().Subscribers)) } conn, err := upgrader.Upgrade(w, r, nil) if err != nil { return } // Stream loop. We hold the conn write-locked while sending // each frame; gorilla's NextWriter handles that. We also // poll for client-initiated close (close frame or read // error) to break the loop promptly. conn.SetReadLimit(512) // we don't read frames from the // client, but gorilla requires a non-zero read limit. // 512 bytes is enough for an empty close frame. stop := make(chan struct{}) go func() { for { if _, _, err := conn.NextReader(); err != nil { close(stop) return } } }() for { select { case <-stop: return case ev, ok := <-sub.C: if !ok { return } _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := conn.WriteJSON(ev); err != nil { // Could be a slow client; the drop is already // counted in sub.Drops. if d.Metrics != nil { d.Metrics.TailDropped.WithLabelValues("write_error").Inc() } return } // Per-subscription drops that happened in the hub // (channel full). Tick our tail_dropped metric so // the operator sees the backpressure in Grafana. // // We use Add(Drops.Load()) and then reset Drops to // 0 so each tick reflects the events that have // happened since the last successful write. The // counter still increments monotonically (Add is // monotonic), which is what dashboards want. if d.Metrics != nil && sub.Drops.Load() > 0 { dropped := sub.Drops.Load() sub.Drops.Store(0) d.Metrics.TailDropped.WithLabelValues("slow_consumer").Add(float64(dropped)) } } } } // checkToken validates the per-request token. The order is: // 1. Authorization: Bearer // 2. X-BA-Tail-Token: // 3. ?token= func (d *wsTailDeps) checkToken(r *http.Request) bool { if h := r.Header.Get("Authorization"); h != "" { const p = "Bearer " if strings.HasPrefix(h, p) && strings.TrimPrefix(h, p) == d.Token { return true } } if h := r.Header.Get("X-BA-Tail-Token"); h != "" && h == d.Token { return true } if q := r.URL.Query().Get("token"); q != "" && q == d.Token { return true } return false }