metrics.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. package observability
  2. import (
  3. "github.com/prometheus/client_golang/prometheus"
  4. )
  5. // NewRegistry returns a fresh Prometheus registry. Each service gets
  6. // its own so the metric labels are scoped correctly.
  7. func NewRegistry(serviceName string) (*prometheus.Registry, *IngestdMetrics) {
  8. reg := prometheus.NewRegistry()
  9. reg.MustRegister(
  10. prometheus.NewGoCollector(),
  11. prometheus.NewProcessCollector(prometheus.ProcessCollectorOpts{}),
  12. )
  13. return reg, NewIngestdMetrics(reg, serviceName)
  14. }
  15. // IngestdMetrics groups the counters/histograms declared in SPEC §22
  16. // for the ingest tier. Other tiers get their own metric groups.
  17. type IngestdMetrics struct {
  18. AlertsReceived *prometheus.CounterVec // result=accepted|invalid|rate_limited|payload_too_large|quarantined|circuit_open
  19. PayloadBytes prometheus.Histogram
  20. RateLimitHits *prometheus.CounterVec // scope=source|company
  21. Quarantines *prometheus.CounterVec
  22. CBState *prometheus.GaugeVec
  23. PublishLatency *prometheus.HistogramVec // labels: source_id
  24. // MQTTMessages is the M4 per-message counter; labels mirror
  25. // the same result taxonomy as AlertsReceived (accepted,
  26. // deduped, bad_topic, bad_signature, unknown_source,
  27. // rate_limited_source, rate_limited_company, invalid,
  28. // invalid_json, broker_unavailable) plus a "received" label
  29. // for every message that survived parseIncomingTopic.
  30. MQTTMessages *prometheus.CounterVec
  31. // WSMessages is the M5 per-message counter; same result
  32. // taxonomy as MQTTMessages plus a "received" label for every
  33. // message that survived the post-auth frame read.
  34. WSMessages *prometheus.CounterVec
  35. // WSConnections tracks WS endpoint lifecycle. state ∈
  36. // {open, closed_clean, closed_protocol_error, closed_unauth,
  37. // closed_rate_limited, closed_per_ip_cap}.
  38. WSConnections *prometheus.CounterVec
  39. // ConnectionRejected tracks per-IP concurrency cap rejections.
  40. // transport ∈ {http, ws}.
  41. ConnectionRejected *prometheus.CounterVec
  42. // TailSubscribers is the current number of /v1/tail/ws
  43. // clients (gauge, not counter).
  44. TailSubscribers prometheus.Gauge
  45. // TailDropped tracks tail events dropped because a
  46. // subscriber's channel was full. The hub increments this.
  47. TailDropped *prometheus.CounterVec
  48. // DedupeCollapsed is the M6 per-message counter for
  49. // duplicate observations (isNew=false). Incremented on
  50. // every dedupe hit that finds an existing key. Lets
  51. // operators answer "how loud is the dupe noise?" without
  52. // parsing logs.
  53. DedupeCollapsed *prometheus.CounterVec
  54. // DedupeCountMax is the M6 per-source gauge of the
  55. // highest dedupe_count ever observed since process start.
  56. // source label is the source_id. Helps dashboards alert
  57. // on multi-hundred duplicates (e.g. a misconfigured
  58. // Prometheus rule that loops every 100ms).
  59. DedupeCountMax *prometheus.GaugeVec
  60. // --- gRPC transport (M11) ---
  61. StreamsActive prometheus.Gauge // ba_ingestd_grpc_streams_active
  62. GRPCInflight *prometheus.HistogramVec // ba_ingestd_grpc_inflight_per_stream{source_id}
  63. GRPCRateLimited *prometheus.CounterVec // ba_ingestd_grpc_rate_limited_total{source_id}
  64. GRPCAckLatency *prometheus.HistogramVec // ba_ingestd_grpc_ack_latency_seconds{source_id}
  65. // --- F2: NATS publish outcome (M11 NATS investigation) ---
  66. // Receivers of ba_ingestd_alerts_received_total cannot tell whether
  67. // a received alert was successfully published to NATS. The receive
  68. // path (gRPC server) and publish path (pipeline) are decoupled, and
  69. // the M11 2026-06-16 finding showed a broken publish path that
  70. // looked healthy on the receive metric. This counter tags every
  71. // publish attempt as ok / error so the smoke can assert that the
  72. // publish path is healthy too.
  73. // See M11_NATS_INVESTIGATION.md, "Medium-term (prevent recurrence)".
  74. NATSPublishTotal *prometheus.CounterVec // ba_ingestd_nats_publish_total{result}
  75. }
  76. // NewIngestdMetrics registers and returns the ingestd metrics.
  77. func NewIngestdMetrics(reg prometheus.Registerer, serviceName string) *IngestdMetrics {
  78. m := &IngestdMetrics{
  79. AlertsReceived: prometheus.NewCounterVec(prometheus.CounterOpts{
  80. Namespace: "ba",
  81. Subsystem: "ingestd",
  82. Name: "alerts_received_total",
  83. Help: "Number of inbound alerts by result and transport.",
  84. ConstLabels: prometheus.Labels{"service": serviceName},
  85. }, []string{"transport", "result"}),
  86. PayloadBytes: prometheus.NewHistogram(prometheus.HistogramOpts{
  87. Namespace: "ba",
  88. Subsystem: "ingestd",
  89. Name: "payload_bytes",
  90. Help: "Accepted alert payload size in bytes.",
  91. Buckets: prometheus.ExponentialBuckets(64, 4, 8), // 64..1MB
  92. ConstLabels: prometheus.Labels{"service": serviceName},
  93. }),
  94. RateLimitHits: prometheus.NewCounterVec(prometheus.CounterOpts{
  95. Namespace: "ba",
  96. Subsystem: "ingestd",
  97. Name: "rate_limited_total",
  98. Help: "Rate-limit rejections by scope.",
  99. ConstLabels: prometheus.Labels{"service": serviceName},
  100. }, []string{"scope"}),
  101. Quarantines: prometheus.NewCounterVec(prometheus.CounterOpts{
  102. Namespace: "ba",
  103. Subsystem: "ingestd",
  104. Name: "source_quarantined_total",
  105. Help: "Source quarantines triggered.",
  106. ConstLabels: prometheus.Labels{"service": serviceName},
  107. }, []string{"source_id", "company_id"}),
  108. CBState: prometheus.NewGaugeVec(prometheus.GaugeOpts{
  109. Namespace: "ba",
  110. Subsystem: "ingestd",
  111. Name: "circuit_breaker_state",
  112. Help: "0=closed, 1=half_open, 2=open.",
  113. ConstLabels: prometheus.Labels{"service": serviceName},
  114. }, []string{"component"}),
  115. PublishLatency: prometheus.NewHistogramVec(prometheus.HistogramOpts{
  116. Namespace: "ba",
  117. Subsystem: "ingestd",
  118. Name: "publish_latency_seconds",
  119. Help: "Time to publish an accepted alert to NATS.",
  120. Buckets: prometheus.DefBuckets,
  121. ConstLabels: prometheus.Labels{"service": serviceName},
  122. }, []string{"source_id"}),
  123. NATSPublishTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
  124. Namespace: "ba",
  125. Subsystem: "ingestd",
  126. Name: "nats_publish_total",
  127. Help: "NATS JetStream publish attempts by result (ok/error). F2: catches publish-path failures that the receive metric misses.",
  128. ConstLabels: prometheus.Labels{"service": serviceName},
  129. }, []string{"result"}),
  130. MQTTMessages: prometheus.NewCounterVec(prometheus.CounterOpts{
  131. Namespace: "ba",
  132. Subsystem: "ingestd",
  133. Name: "mqtt_messages_total",
  134. Help: "Inbound MQTT messages by result (M4).",
  135. ConstLabels: prometheus.Labels{"service": serviceName},
  136. }, []string{"result"}),
  137. WSMessages: prometheus.NewCounterVec(prometheus.CounterOpts{
  138. Namespace: "ba",
  139. Subsystem: "ingestd",
  140. Name: "ws_messages_total",
  141. Help: "Inbound WebSocket messages by result (M5).",
  142. ConstLabels: prometheus.Labels{"service": serviceName},
  143. }, []string{"result"}),
  144. WSConnections: prometheus.NewCounterVec(prometheus.CounterOpts{
  145. Namespace: "ba",
  146. Subsystem: "ingestd",
  147. Name: "ws_connections_total",
  148. Help: "WebSocket connection lifecycle events (M5).",
  149. ConstLabels: prometheus.Labels{"service": serviceName},
  150. }, []string{"state"}),
  151. ConnectionRejected: prometheus.NewCounterVec(prometheus.CounterOpts{
  152. Namespace: "ba",
  153. Subsystem: "ingestd",
  154. Name: "connection_rejected_total",
  155. Help: "Per-IP concurrency cap rejections (M5, SPEC §22 layer 2).",
  156. ConstLabels: prometheus.Labels{"service": serviceName},
  157. }, []string{"transport"}),
  158. TailSubscribers: prometheus.NewGauge(prometheus.GaugeOpts{
  159. Namespace: "ba",
  160. Subsystem: "ingestd",
  161. Name: "tail_subscribers",
  162. Help: "Current number of /v1/tail/ws clients.",
  163. ConstLabels: prometheus.Labels{"service": serviceName},
  164. }),
  165. TailDropped: prometheus.NewCounterVec(prometheus.CounterOpts{
  166. Namespace: "ba",
  167. Subsystem: "ingestd",
  168. Name: "tail_dropped_total",
  169. Help: "Tail events dropped because a subscriber was too slow.",
  170. ConstLabels: prometheus.Labels{"service": serviceName},
  171. }, []string{"reason"}),
  172. DedupeCollapsed: prometheus.NewCounterVec(prometheus.CounterOpts{
  173. Namespace: "ba",
  174. Subsystem: "ingestd",
  175. Name: "dedupe_collapsed_total",
  176. Help: "M6: alert messages that hit an existing dedupe key (isNew=false).",
  177. ConstLabels: prometheus.Labels{"service": serviceName},
  178. }, []string{"source"}),
  179. DedupeCountMax: prometheus.NewGaugeVec(prometheus.GaugeOpts{
  180. Namespace: "ba",
  181. Subsystem: "ingestd",
  182. Name: "dedupe_count_max_observed",
  183. Help: "M6: highest dedupe_count ever observed since process start, per source.",
  184. ConstLabels: prometheus.Labels{"service": serviceName},
  185. }, []string{"source"}),
  186. // gRPC transport (M11)
  187. StreamsActive: prometheus.NewGauge(prometheus.GaugeOpts{
  188. Namespace: "ba",
  189. Subsystem: "ingestd",
  190. Name: "grpc_streams_active",
  191. Help: "Number of currently open gRPC StreamAlerts streams.",
  192. ConstLabels: prometheus.Labels{"service": serviceName},
  193. }),
  194. GRPCInflight: prometheus.NewHistogramVec(prometheus.HistogramOpts{
  195. Namespace: "ba",
  196. Subsystem: "ingestd",
  197. Name: "grpc_inflight_per_stream",
  198. Help: "Messages currently being processed per gRPC stream.",
  199. Buckets: []float64{1, 8, 16, 32, 64, 128, 256, 512},
  200. ConstLabels: prometheus.Labels{"service": serviceName},
  201. }, []string{"source_id"}),
  202. GRPCRateLimited: prometheus.NewCounterVec(prometheus.CounterOpts{
  203. Namespace: "ba",
  204. Subsystem: "ingestd",
  205. Name: "grpc_rate_limited_total",
  206. Help: "RATE_LIMITED Acks sent to gRPC streams.",
  207. ConstLabels: prometheus.Labels{"service": serviceName},
  208. }, []string{"source_id"}),
  209. GRPCAckLatency: prometheus.NewHistogramVec(prometheus.HistogramOpts{
  210. Namespace: "ba",
  211. Subsystem: "ingestd",
  212. Name: "grpc_ack_latency_seconds",
  213. Help: "Server-side Ack latency for gRPC StreamAlerts (seconds).",
  214. Buckets: []float64{0.001, 0.005, 0.010, 0.025, 0.050, 0.100, 0.250, 0.500, 1.0},
  215. ConstLabels: prometheus.Labels{"service": serviceName},
  216. }, []string{"source_id"}),
  217. }
  218. reg.MustRegister(
  219. m.AlertsReceived,
  220. m.PayloadBytes,
  221. m.RateLimitHits,
  222. m.Quarantines,
  223. m.CBState,
  224. m.PublishLatency,
  225. m.MQTTMessages,
  226. m.WSMessages,
  227. m.WSConnections,
  228. m.ConnectionRejected,
  229. m.TailSubscribers,
  230. m.TailDropped,
  231. m.DedupeCollapsed,
  232. m.DedupeCountMax,
  233. // gRPC transport (M11)
  234. m.StreamsActive,
  235. m.GRPCInflight,
  236. m.GRPCRateLimited,
  237. m.GRPCAckLatency,
  238. // F2: NATS publish outcome (M11 NATS investigation)
  239. m.NATSPublishTotal,
  240. )
  241. m.AlertsReceived.WithLabelValues("internal", "accepted")
  242. return m
  243. }
  244. // DeliverdMetrics groups the Prometheus counters/histograms for
  245. // the deliverd tier (SPEC §22 L3: delivery attempts + DLQ).
  246. // Both deliverd-fcm and deliverd-telegram share this type.
  247. type DeliverdMetrics struct {
  248. // DeliveryAttempts records each per-attempt delivery row.
  249. // channel=fcm|telegram, status=sent|failed.
  250. DeliveryAttempts *prometheus.CounterVec
  251. // DLQTotal records each time an alert is parked in the DLQ.
  252. // channel=fcm|telegram.
  253. DLQTotal *prometheus.CounterVec
  254. // DLQLatency records how long the retry budget lasted before
  255. // the alert hit the DLQ (wall-clock time from first attempt
  256. // to DLQ insert).
  257. DLQLatency prometheus.Histogram
  258. // RetryAttempts is the total number of retry loop iterations
  259. // across all alerts (sum of the attempts column on deliveries
  260. // rows that ended in DLQ).
  261. RetryAttempts *prometheus.CounterVec
  262. }
  263. // NewDeliverdMetrics registers and returns deliverd metrics.
  264. func NewDeliverdMetrics(reg prometheus.Registerer, serviceName string) *DeliverdMetrics {
  265. m := &DeliverdMetrics{
  266. DeliveryAttempts: prometheus.NewCounterVec(prometheus.CounterOpts{
  267. Namespace: "ba",
  268. Subsystem: "deliverd",
  269. Name: "delivery_attempts_total",
  270. Help: "Per-channel delivery attempt rows (one row per attempt).",
  271. ConstLabels: prometheus.Labels{"service": serviceName},
  272. }, []string{"channel", "status"}),
  273. DLQTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
  274. Namespace: "ba",
  275. Subsystem: "deliverd",
  276. Name: "dlq_total",
  277. Help: "Alerts parked in the DLQ (one per alert that exhausted retries).",
  278. ConstLabels: prometheus.Labels{"service": serviceName},
  279. }, []string{"channel"}),
  280. DLQLatency: prometheus.NewHistogram(prometheus.HistogramOpts{
  281. Namespace: "ba",
  282. Subsystem: "deliverd",
  283. Name: "dlq_latency_seconds",
  284. Help: "Wall-clock time from first delivery attempt to DLQ insert.",
  285. ConstLabels: prometheus.Labels{"service": serviceName},
  286. Buckets: prometheus.ExponentialBuckets(0.1, 2, 10), // 100ms → ~100s
  287. }),
  288. RetryAttempts: prometheus.NewCounterVec(prometheus.CounterOpts{
  289. Namespace: "ba",
  290. Subsystem: "deliverd",
  291. Name: "retry_attempts_total",
  292. Help: "Total retry loop iterations across all DLQ'd alerts.",
  293. ConstLabels: prometheus.Labels{"service": serviceName},
  294. }, []string{"channel"}),
  295. }
  296. reg.MustRegister(m.DeliveryAttempts, m.DLQTotal, m.DLQLatency, m.RetryAttempts)
  297. return m
  298. }
  299. // RouterdMetrics groups the Prometheus histogram for the routerd tier
  300. // (SPEC §22 L1: recipient resolution latency).
  301. type RouterdMetrics struct {
  302. // RecipientExpansionLatency is the wall-clock time for
  303. // routing.Resolver.ResolveTargets to complete (DB call).
  304. RecipientExpansionLatency prometheus.Histogram
  305. }
  306. // NewRouterdMetrics registers and returns routerd metrics.
  307. func NewRouterdMetrics(reg prometheus.Registerer, serviceName string) *RouterdMetrics {
  308. m := &RouterdMetrics{
  309. RecipientExpansionLatency: prometheus.NewHistogram(prometheus.HistogramOpts{
  310. Namespace: "ba",
  311. Subsystem: "routerd",
  312. Name: "recipient_expansion_seconds",
  313. Help: "Time to resolve recipients for an alert (DB call).",
  314. ConstLabels: prometheus.Labels{"service": serviceName},
  315. Buckets: prometheus.DefBuckets,
  316. }),
  317. }
  318. reg.MustRegister(m.RecipientExpansionLatency)
  319. return m
  320. }