process.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. // process.go is the shared alert-processing pipeline used by
  2. // both the HTTP handler (cmd/ingestd/http.go) and the MQTT
  3. // subscriber (cmd/ingestd/mqtt.go). It does the M0→M4 SPEC §22
  4. // work in one place: parse → validate → look up source → HMAC
  5. // verify → rate-limit (per-source, per-company) → dedupe → stamp
  6. // → publish to NATS → return a result struct the caller maps
  7. // to its own transport-level response.
  8. //
  9. // The function is pure (no global state, no transport types).
  10. // It returns Result{Accepted/Rejected/Error + reason + alert_id
  11. // + dedupe_count} so the HTTP handler can map to a status code
  12. // and the MQTT handler can map to a log line.
  13. package main
  14. import (
  15. "context"
  16. "encoding/json"
  17. "errors"
  18. "fmt"
  19. "log/slog"
  20. "strconv"
  21. "time"
  22. "git3.techno-world.net/lrosales/broad-announce/internal/alert"
  23. "git3.techno-world.net/lrosales/broad-announce/internal/broker"
  24. "git3.techno-world.net/lrosales/broad-announce/internal/circuitbreaker"
  25. "git3.techno-world.net/lrosales/broad-announce/internal/dedupe"
  26. "git3.techno-world.net/lrosales/broad-announce/internal/observability"
  27. "git3.techno-world.net/lrosales/broad-announce/internal/quarantine"
  28. "git3.techno-world.net/lrosales/broad-announce/internal/ratelimit"
  29. "git3.techno-world.net/lrosales/broad-announce/internal/tailhub"
  30. "github.com/nats-io/nats.go"
  31. )
  32. // Result is the outcome of a ProcessAlert call. The caller
  33. // (HTTP/MQTT) maps it to its transport's response shape.
  34. type Result struct {
  35. // Accepted is true if the alert passed all checks and was
  36. // published to NATS.
  37. Accepted bool
  38. // AlertID is the server-assigned id (empty on error paths).
  39. AlertID string
  40. // DedupeCount is the count returned by the dedupe layer.
  41. DedupeCount uint32
  42. // IsNew is true for the first alert in a dedupe window.
  43. IsNew bool
  44. // RejectReason is the SPEC §22 / auth reason; one of:
  45. // "payload_too_large", "bad_request", "invalid_json",
  46. // "invalid", "unknown_source", "bad_signature",
  47. // "rate_limited_source", "rate_limited_company",
  48. // "marshal_failed", "broker_unavailable"
  49. RejectReason string
  50. // HTTPStatus is the suggested HTTP status code (0 for
  51. // accepted / 202).
  52. HTTPStatus int
  53. // Detail is the free-form string the caller can show in
  54. // a response body or a log message.
  55. Detail string
  56. }
  57. // Accept is the canonical "ok" result.
  58. func Accept(id string, count uint32, isNew bool) Result {
  59. return Result{Accepted: true, AlertID: id, DedupeCount: count, IsNew: isNew, HTTPStatus: 202}
  60. }
  61. // Reject is the canonical "no" result.
  62. func Reject(reason string, status int, detail string) Result {
  63. return Result{RejectReason: reason, HTTPStatus: status, Detail: detail}
  64. }
  65. // processDeps is the process-pipeline dependency set. Smaller
  66. // than httpDeps — no HTTP-specific fields. Both httpDeps and
  67. // the MQTT subscriber construct one and call ProcessAlert.
  68. type processDeps struct {
  69. Logger *slog.Logger
  70. Metrics *observability.IngestdMetrics
  71. Limiter *ratelimit.Limiter
  72. Deduper *dedupe.Deduper
  73. JetStream natsPublisher
  74. // Sources is the (company_id, source_id) → SourceConfig map.
  75. // M0 reads it from env; M2 from Postgres. The MQTT subscriber
  76. // uses the same map keyed on the topic-parsed (co, src).
  77. Sources map[string]SourceConfig
  78. // Per-company default rate cap. HTTP and MQTT use the same
  79. // constant; once the per-company cap lives in DB, both
  80. // transports read it.
  81. CompanyRatePerSec int
  82. // Tail is an optional M5 live-tail hub. When non-nil, every
  83. // accepted alert is also published to in-process tail
  84. // subscribers. nil is fine (HTTP/MQTT tests don't need it).
  85. Tail *tailhub.Hub
  86. // Transport is the per-process transport label used in
  87. // structured log lines ("http" | "mqtt" | "ws"). The MQTT
  88. // path overrides this on the receiver's scoped copy.
  89. Transport string
  90. // Now is overridable in tests.
  91. Now func() time.Time
  92. // MaxSeen is the M6 per-source monotonic max tracker for
  93. // dedupe_count. processDeps owns one so all transports
  94. // (HTTP, MQTT, WS) share the same in-process state and
  95. // the same max-observed gauge.
  96. MaxSeen *observability.MaxSeen
  97. // CircuitBreaker is the M9 layer-6 per-component circuit
  98. // breaker wrapping the NATS publish call. Nil is fine (falls
  99. // back to direct publish without circuit protection).
  100. CircuitBreaker *circuitbreaker.Breaker
  101. // Quarantine is the M9 layer-7 per-source error-rate limiter.
  102. // Nil is fine (no quarantine enforcement).
  103. Quarantine *quarantine.Manager
  104. }
  105. // ProcessAlert runs the full SPEC §22 protection chain on one
  106. // alert body. It is the single source of truth for the ingest
  107. // pipeline; both the HTTP POST handler and the MQTT subscriber
  108. // call it.
  109. //
  110. // Layer order (matches SPEC §22):
  111. // 1. payload-size cap (caller does this — http.go via
  112. // MaxBytesReader; mqtt.go via the
  113. // broker-side max-inflight setting)
  114. // 2. quarantine check (M9 layer 7) — per-source ban if error
  115. // rate exceeds threshold
  116. // 3. per-source rate limit
  117. // 4. per-company rate limit
  118. // 5. schema validate
  119. // 5b. parse JSON
  120. // (auth) HMAC verify — see verifyHMAC
  121. // 5c. dedupe
  122. // publish to NATS (M9 layer 6 circuit breaker wraps this)
  123. func (d *processDeps) ProcessAlert(ctx context.Context, body []byte, sigHeader string) Result {
  124. now := d.now()
  125. // 5. Parse + validate. We treat any parse failure as invalid.
  126. var a alert.Alert
  127. if err := json.Unmarshal(body, &a); err != nil {
  128. d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
  129. return Reject("invalid_json", 400, err.Error())
  130. }
  131. if err := a.Validate(); err != nil {
  132. d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
  133. return Reject("invalid", 400, err.Error())
  134. }
  135. // Look up source. M0: in-memory map. M2: DB.
  136. src, ok := d.Sources[a.CompanyID+":"+a.SourceID]
  137. if !ok {
  138. d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
  139. return Reject("unknown_source", 401,
  140. fmt.Sprintf("no such source %s/%s", a.CompanyID, a.SourceID))
  141. }
  142. // M9 layer 7: quarantine check. Check before we spend any CPU
  143. // on a known-bad source.
  144. if d.Quarantine != nil {
  145. if banned, remaining, err := d.Quarantine.IsBanned(ctx, a.SourceID); err == nil && banned {
  146. d.Metrics.AlertsReceived.WithLabelValues("quarantined").Inc()
  147. d.Logger.Warn("source quarantined",
  148. "source_id", a.SourceID,
  149. "company_id", a.CompanyID,
  150. "remaining", remaining,
  151. )
  152. return Reject("quarantined", 429,
  153. fmt.Sprintf("source quarantined for %v; retry after", remaining.Round(time.Second)))
  154. }
  155. }
  156. // Auth. Stripe-style: X-BA-Signature: t=<unix>,v1=<hex>.
  157. // For HTTP it's a header; for MQTT it's a top-level field
  158. // on the envelope — both call sites pass the same string.
  159. if !verifyHMAC(sigHeader, src.HMACSecret, body, now) {
  160. d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
  161. return Reject("bad_signature", 401, "")
  162. }
  163. // M9: quarantine hit tracking. Any rejection after the HMAC
  164. // check (where we know the source is real) indicates a
  165. // problematic source. We use a closure so the defer pattern
  166. // catches every return path without boilerplate at each one.
  167. var hitRecorded bool
  168. defer func() {
  169. if !hitRecorded && d.Quarantine != nil {
  170. _ = d.Quarantine.RecordHit(context.Background(), a.SourceID)
  171. }
  172. }()
  173. // recordHit records a quarantine error hit for a.SourceID.
  174. // Call it before any rejection return.
  175. recordHit := func() {
  176. if d.Quarantine != nil && !hitRecorded {
  177. hitRecorded = true
  178. _ = d.Quarantine.RecordHit(context.Background(), a.SourceID)
  179. }
  180. }
  181. // M6: Dedupe BEFORE rate limit. A duplicate (isNew=false)
  182. // is a Redis INCR + JSON marshal + NATS publish — it does
  183. // not warrant burning a rate-limit token. The rate limit
  184. // exists to backpressure "new alert" volume; the dedupe
  185. // itself is the canonical "do less work for repeats"
  186. // mechanism. We only burn a token on the first arrival
  187. // in a sliding window; the next 999 dupes pass through
  188. // the rate limit gates for free, the recipient sees one
  189. // consolidated message with `(×N)` appended.
  190. isNew, count, err := d.Deduper.Check(ctx, a.SourceID, a.DedupeKey)
  191. if err != nil {
  192. d.Logger.Warn("dedupe redis error (failing open)", "err", err)
  193. isNew, count = true, 1
  194. }
  195. if !isNew {
  196. // M6 metrics: tick the per-source collapse counter and
  197. // bump the max-observed gauge if this hit set a new
  198. // peak. We use a tiny in-process max tracker (Prom's
  199. // Gauge doesn't expose Get() — the canonical pattern
  200. // is to read via .Gauges() and pick out the value, but
  201. // that's a 2-step write+read; cleaner to just remember
  202. // the max in our own map and Set the gauge on growth).
  203. d.Metrics.DedupeCollapsed.WithLabelValues(a.SourceID).Inc()
  204. d.MaxSeen.RecordAndExport(a.SourceID, count,
  205. func(s string, v float64) {
  206. d.Metrics.DedupeCountMax.WithLabelValues(s).Set(v)
  207. })
  208. }
  209. // 3. Per-source rate limit (only charged for new alerts).
  210. if isNew {
  211. if ok, ttl, err := d.Limiter.Allow(ctx, "source:"+a.CompanyID+":"+a.SourceID, src.RateLimitPerSec); err != nil {
  212. d.Logger.Warn("ratelimit redis error (failing open)", "err", err, "scope", "source")
  213. } else if !ok {
  214. d.Metrics.AlertsReceived.WithLabelValues("rate_limited").Inc()
  215. d.Metrics.RateLimitHits.WithLabelValues("source").Inc()
  216. _ = ttl
  217. recordHit()
  218. return Reject("rate_limited_source", 429, strconv.Itoa(int(ttl.Seconds())))
  219. }
  220. }
  221. // 4. Per-company rate limit (only charged for new alerts).
  222. if isNew {
  223. if ok, ttl, _ := d.Limiter.Allow(ctx, "company:"+a.CompanyID, d.CompanyRatePerSec); !ok {
  224. d.Metrics.AlertsReceived.WithLabelValues("rate_limited").Inc()
  225. d.Metrics.RateLimitHits.WithLabelValues("company").Inc()
  226. _ = ttl
  227. recordHit()
  228. return Reject("rate_limited_company", 429, strconv.Itoa(int(ttl.Seconds())))
  229. }
  230. }
  231. // Stamp server-side fields.
  232. a.ID = alert.NewID()
  233. a.ReceivedAt = now.UTC()
  234. a.DedupeCount = count
  235. // Publish to NATS. M9 layer 6: circuit breaker wraps the
  236. // publish call so a sick NATS server doesn't take down ingestd.
  237. subject := broker.AlertsSubject(a.CompanyID)
  238. payload, err := json.Marshal(a)
  239. if err != nil {
  240. d.Metrics.AlertsReceived.WithLabelValues("invalid").Inc()
  241. return Reject("marshal_failed", 500, err.Error())
  242. }
  243. start := time.Now()
  244. var publishErr error
  245. if d.CircuitBreaker != nil {
  246. // Wrap the synchronous publish in the circuit breaker.
  247. // We use Publish (sync) so Do() gets immediate feedback.
  248. publishErr = d.CircuitBreaker.Do(ctx, func() error {
  249. return d.JetStream.Publish(subject, payload)
  250. })
  251. } else {
  252. publishErr = d.JetStream.Publish(subject, payload)
  253. }
  254. if publishErr != nil {
  255. if errors.Is(publishErr, circuitbreaker.ErrCircuitOpen) {
  256. d.Metrics.AlertsReceived.WithLabelValues("circuit_open").Inc()
  257. d.Metrics.CBState.WithLabelValues("nats").Set(circuitbreaker.StateOpen)
  258. d.Logger.Warn("circuit breaker open",
  259. "subject", subject,
  260. "alert_id", a.ID,
  261. "company_id", a.CompanyID,
  262. )
  263. recordHit()
  264. return Reject("circuit_open", 503, "broker circuit breaker open")
  265. }
  266. // Real publish error (network, auth, etc.).
  267. d.Metrics.AlertsReceived.WithLabelValues("broker_unavailable").Inc()
  268. d.Logger.Error("nats publish", "err", publishErr, "subject", subject)
  269. recordHit()
  270. return Reject("broker_unavailable", 503, publishErr.Error())
  271. }
  272. d.Metrics.PublishLatency.WithLabelValues(a.SourceID).Observe(time.Since(start).Seconds())
  273. d.Metrics.PayloadBytes.Observe(float64(len(payload)))
  274. if isNew {
  275. d.Metrics.AlertsReceived.WithLabelValues("accepted").Inc()
  276. } else {
  277. d.Metrics.AlertsReceived.WithLabelValues("deduped").Inc()
  278. }
  279. d.Logger.Info("alert accepted",
  280. "alert_id", a.ID,
  281. "company_id", a.CompanyID,
  282. "source_id", a.SourceID,
  283. "severity", string(a.Severity),
  284. "transport", d.Transport,
  285. "dedupe_count", count,
  286. )
  287. // M5: fan out to the live-tail hub (if configured). This
  288. // is best-effort and never blocks the producer — the hub's
  289. // Publish drops on slow consumers.
  290. if d.Tail != nil {
  291. ev := tailhub.FromAlert(&a, d.Transport)
  292. d.Tail.Publish(ev)
  293. }
  294. // Accepted: mark that we did not get a rejection so the
  295. // defer does not record a spurious quarantine hit.
  296. hitRecorded = true
  297. return Accept(a.ID, count, isNew)
  298. }
  299. func (d *processDeps) now() time.Time {
  300. if d.Now != nil {
  301. return d.Now()
  302. }
  303. return time.Now()
  304. }
  305. // natsPublisher is the minimal NATS interface. The HTTP and
  306. // MQTT paths share it; tests can swap in a fake.
  307. type natsPublisher interface {
  308. PublishAsync(subj string, data []byte) error
  309. // Publish is synchronous. The circuit breaker uses this
  310. // to get immediate success/failure feedback.
  311. Publish(subj string, data []byte) error
  312. }
  313. // jsPublisher adapts a nats.JetStreamContext to the natsPublisher interface.
  314. type jsPublisher struct {
  315. js nats.JetStreamContext
  316. }
  317. // Publish is synchronous (blocks until server ack or timeout). Used
  318. // by the circuit breaker which needs immediate success/failure feedback.
  319. func (j *jsPublisher) Publish(subj string, data []byte) error {
  320. _, err := j.js.Publish(subj, data)
  321. return err
  322. }
  323. func (j *jsPublisher) PublishAsync(subj string, data []byte) error {
  324. _, err := j.js.PublishAsync(subj, data)
  325. return err
  326. }
  327. // newNatsPublisher is the constructor used by main.
  328. func newNatsPublisher(js nats.JetStreamContext) natsPublisher {
  329. return &jsPublisher{js: js}
  330. }