hub.go 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. // Package tailhub is the in-process fan-out for ingestd's
  2. // "live tail" WebSocket endpoint (M5). It is the seam between
  3. // the producers (HTTP, MQTT, WS ingest paths, all of which call
  4. // Publish after a successful ProcessAlert) and the consumers
  5. // (one or more /v1/tail/ws clients).
  6. //
  7. // Why in-process and not NATS: the tail is an operator's
  8. // debugging tool, not a feature for end users. The event rate
  9. // is at most the ingest rate (~k/s in dev), and a single
  10. // ingestd replica can handle thousands of subscribers. M9
  11. // promotes this to NATS KV if the fan-out ever needs to span
  12. // replicas.
  13. package tailhub
  14. import (
  15. "sync"
  16. "sync/atomic"
  17. "git3.techno-world.net/lrosales/broad-announce/internal/alert"
  18. )
  19. // Event is the compact representation of an accepted alert that
  20. // the tail endpoint streams to subscribers. The full alert
  21. // payload (which can be 256 KB) is intentionally NOT included —
  22. // the operator sees enough metadata to filter, then drills into
  23. // the per-alert GET endpoint (M9) for the body.
  24. type Event struct {
  25. AlertID string `json:"alert_id"`
  26. CompanyID string `json:"company_id"`
  27. SourceID string `json:"source_id"`
  28. Severity string `json:"severity"`
  29. Title string `json:"title"`
  30. ReceivedAt string `json:"received_at"`
  31. Transport string `json:"transport"`
  32. DedupeCount uint32 `json:"dedupe_count"`
  33. }
  34. // FromAlert is the constructor used by ingestd after a
  35. // successful ProcessAlert. We pull the title out of the data
  36. // field; the alert package doesn't currently expose a top-level
  37. // Title, so we use the first 120 chars of body if available.
  38. func FromAlert(a *alert.Alert, transport string) *Event {
  39. title := a.Title
  40. if title == "" {
  41. title = a.Category + " alert"
  42. }
  43. if len(title) > 120 {
  44. title = title[:120]
  45. }
  46. recv := ""
  47. if !a.ReceivedAt.IsZero() {
  48. recv = a.ReceivedAt.UTC().Format("2006-01-02T15:04:05.000000000Z")
  49. }
  50. return &Event{
  51. AlertID: a.ID,
  52. CompanyID: a.CompanyID,
  53. SourceID: a.SourceID,
  54. Severity: string(a.Severity),
  55. Title: title,
  56. ReceivedAt: recv,
  57. Transport: transport,
  58. DedupeCount: a.DedupeCount,
  59. }
  60. }
  61. // Filter is the subscription filter. Empty CompanyID matches all.
  62. type Filter struct {
  63. CompanyID string
  64. }
  65. // Hub is the in-process pub/sub for tail events. Producers call
  66. // Publish; consumers call Subscribe and read from the returned
  67. // channel until they Close it.
  68. type Hub struct {
  69. mu sync.RWMutex
  70. subs map[*Subscription]struct{}
  71. nextID atomic.Uint64
  72. droppedTotal atomic.Uint64
  73. publishedTotal atomic.Uint64
  74. }
  75. // Subscription is a per-consumer handle. The consumer reads from
  76. // C until closed; missed events while C is full are counted in
  77. // Hub.Stats() and logged by the consumer (which can choose to
  78. // disconnect on its own).
  79. type Subscription struct {
  80. id uint64
  81. hub *Hub
  82. filter Filter
  83. C chan *Event
  84. // Drops is the per-subscription counter; bumped when the
  85. // producer side can't write to C (consumer too slow).
  86. Drops atomic.Uint64
  87. }
  88. const subscriberBuffer = 64
  89. // NewHub creates an empty hub.
  90. func NewHub() *Hub {
  91. return &Hub{subs: make(map[*Subscription]struct{})}
  92. }
  93. // Subscribe registers a new consumer with the given filter.
  94. // The returned channel is closed when the hub is closed or the
  95. // caller invokes Unsubscribe.
  96. func (h *Hub) Subscribe(filter Filter) *Subscription {
  97. s := &Subscription{
  98. id: h.nextID.Add(1),
  99. hub: h,
  100. filter: filter,
  101. C: make(chan *Event, subscriberBuffer),
  102. }
  103. h.mu.Lock()
  104. h.subs[s] = struct{}{}
  105. h.mu.Unlock()
  106. return s
  107. }
  108. // Unsubscribe removes the subscription and closes its channel.
  109. // Safe to call multiple times.
  110. func (s *Subscription) Unsubscribe() {
  111. s.hub.mu.Lock()
  112. if _, ok := s.hub.subs[s]; ok {
  113. delete(s.hub.subs, s)
  114. close(s.C)
  115. }
  116. s.hub.mu.Unlock()
  117. }
  118. // Publish fans out the event to every matching subscriber.
  119. // A subscriber whose channel is full has the event dropped (and
  120. // the per-subscription Drops counter incremented). The producer
  121. // never blocks.
  122. func (h *Hub) Publish(ev *Event) {
  123. if ev == nil {
  124. return
  125. }
  126. h.publishedTotal.Add(1)
  127. h.mu.RLock()
  128. defer h.mu.RUnlock()
  129. for s := range h.subs {
  130. if s.filter.CompanyID != "" && s.filter.CompanyID != ev.CompanyID {
  131. continue
  132. }
  133. select {
  134. case s.C <- ev:
  135. default:
  136. s.Drops.Add(1)
  137. h.droppedTotal.Add(1)
  138. }
  139. }
  140. }
  141. // Stats is a snapshot of hub-level counters (for /v1/debug/tail
  142. // and the Prometheus gauge).
  143. type Stats struct {
  144. Subscribers int
  145. PublishedTotal uint64
  146. DroppedTotal uint64
  147. }
  148. // Stats returns a snapshot of the hub's counters.
  149. func (h *Hub) Stats() Stats {
  150. h.mu.RLock()
  151. n := len(h.subs)
  152. h.mu.RUnlock()
  153. return Stats{
  154. Subscribers: n,
  155. PublishedTotal: h.publishedTotal.Load(),
  156. DroppedTotal: h.droppedTotal.Load(),
  157. }
  158. }