| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179 |
- // 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 <token>
- // - X-BA-Tail-Token: <token>
- // - ?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 <token>
- // 2. X-BA-Tail-Token: <token>
- // 3. ?token=<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
- }
|