// Package tailhub is the in-process fan-out for ingestd's // "live tail" WebSocket endpoint (M5). It is the seam between // the producers (HTTP, MQTT, WS ingest paths, all of which call // Publish after a successful ProcessAlert) and the consumers // (one or more /v1/tail/ws clients). // // Why in-process and not NATS: the tail is an operator's // debugging tool, not a feature for end users. The event rate // is at most the ingest rate (~k/s in dev), and a single // ingestd replica can handle thousands of subscribers. M9 // promotes this to NATS KV if the fan-out ever needs to span // replicas. package tailhub import ( "sync" "sync/atomic" "git3.techno-world.net/lrosales/broad-announce/internal/alert" ) // Event is the compact representation of an accepted alert that // the tail endpoint streams to subscribers. The full alert // payload (which can be 256 KB) is intentionally NOT included — // the operator sees enough metadata to filter, then drills into // the per-alert GET endpoint (M9) for the body. type Event struct { AlertID string `json:"alert_id"` CompanyID string `json:"company_id"` SourceID string `json:"source_id"` Severity string `json:"severity"` Title string `json:"title"` ReceivedAt string `json:"received_at"` Transport string `json:"transport"` DedupeCount uint32 `json:"dedupe_count"` } // FromAlert is the constructor used by ingestd after a // successful ProcessAlert. We pull the title out of the data // field; the alert package doesn't currently expose a top-level // Title, so we use the first 120 chars of body if available. func FromAlert(a *alert.Alert, transport string) *Event { title := a.Title if title == "" { title = a.Category + " alert" } if len(title) > 120 { title = title[:120] } recv := "" if !a.ReceivedAt.IsZero() { recv = a.ReceivedAt.UTC().Format("2006-01-02T15:04:05.000000000Z") } return &Event{ AlertID: a.ID, CompanyID: a.CompanyID, SourceID: a.SourceID, Severity: string(a.Severity), Title: title, ReceivedAt: recv, Transport: transport, DedupeCount: a.DedupeCount, } } // Filter is the subscription filter. Empty CompanyID matches all. type Filter struct { CompanyID string } // Hub is the in-process pub/sub for tail events. Producers call // Publish; consumers call Subscribe and read from the returned // channel until they Close it. type Hub struct { mu sync.RWMutex subs map[*Subscription]struct{} nextID atomic.Uint64 droppedTotal atomic.Uint64 publishedTotal atomic.Uint64 } // Subscription is a per-consumer handle. The consumer reads from // C until closed; missed events while C is full are counted in // Hub.Stats() and logged by the consumer (which can choose to // disconnect on its own). type Subscription struct { id uint64 hub *Hub filter Filter C chan *Event // Drops is the per-subscription counter; bumped when the // producer side can't write to C (consumer too slow). Drops atomic.Uint64 } const subscriberBuffer = 64 // NewHub creates an empty hub. func NewHub() *Hub { return &Hub{subs: make(map[*Subscription]struct{})} } // Subscribe registers a new consumer with the given filter. // The returned channel is closed when the hub is closed or the // caller invokes Unsubscribe. func (h *Hub) Subscribe(filter Filter) *Subscription { s := &Subscription{ id: h.nextID.Add(1), hub: h, filter: filter, C: make(chan *Event, subscriberBuffer), } h.mu.Lock() h.subs[s] = struct{}{} h.mu.Unlock() return s } // Unsubscribe removes the subscription and closes its channel. // Safe to call multiple times. func (s *Subscription) Unsubscribe() { s.hub.mu.Lock() if _, ok := s.hub.subs[s]; ok { delete(s.hub.subs, s) close(s.C) } s.hub.mu.Unlock() } // Publish fans out the event to every matching subscriber. // A subscriber whose channel is full has the event dropped (and // the per-subscription Drops counter incremented). The producer // never blocks. func (h *Hub) Publish(ev *Event) { if ev == nil { return } h.publishedTotal.Add(1) h.mu.RLock() defer h.mu.RUnlock() for s := range h.subs { if s.filter.CompanyID != "" && s.filter.CompanyID != ev.CompanyID { continue } select { case s.C <- ev: default: s.Drops.Add(1) h.droppedTotal.Add(1) } } } // Stats is a snapshot of hub-level counters (for /v1/debug/tail // and the Prometheus gauge). type Stats struct { Subscribers int PublishedTotal uint64 DroppedTotal uint64 } // Stats returns a snapshot of the hub's counters. func (h *Hub) Stats() Stats { h.mu.RLock() n := len(h.subs) h.mu.RUnlock() return Stats{ Subscribers: n, PublishedTotal: h.publishedTotal.Load(), DroppedTotal: h.droppedTotal.Load(), } }