| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172 |
- // 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(),
- }
- }
|