m11_lib.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. """
  2. m11_lib.py — shared assertion library for M11 smoke scripts.
  3. Used by m11_smoke.py.
  4. All Prometheus queries use URL-encoding-safe Python urllib.
  5. """
  6. import urllib.request
  7. import urllib.parse
  8. import json
  9. PROM = "http://localhost:9090"
  10. PROM_TIMEOUT = 10 # seconds
  11. def scrape(query: str) -> list[dict]:
  12. """
  13. Run a Prometheus query and return the result vector.
  14. Returns [] on error or no data.
  15. """
  16. url = f"{PROM}/api/v1/query?query={urllib.parse.quote(query, safe='')}"
  17. try:
  18. with urllib.request.urlopen(url, timeout=PROM_TIMEOUT) as r:
  19. data = r.read()
  20. d = json.loads(data)
  21. if d.get("status") != "success":
  22. return []
  23. return d.get("data", {}).get("result", [])
  24. except Exception:
  25. return []
  26. def assert_grpc_rate_near(target: float, tolerance: float = 0.10,
  27. window_seconds: int = 30) -> float:
  28. """
  29. Assert the gRPC ingest rate is within tolerance of target.
  30. target: expected alerts/sec cluster-wide
  31. tolerance: fraction (0.10 = ±10%)
  32. Returns the actual rate, or raises AssertionError.
  33. Query: sum(rate(ba_ingestd_alerts_received_total{transport="grpc",result="accepted"}[window]))
  34. """
  35. query = (
  36. f'sum(rate(ba_ingestd_alerts_received_total{{transport="grpc",result="accepted"}}[{window_seconds}s]))'
  37. )
  38. results = scrape(query)
  39. if not results:
  40. raise AssertionError(
  41. f"gRPC rate query returned no data. Is ingestd gRPC server up and receiving load? "
  42. f"(query: {query})"
  43. )
  44. rate = float(results[0]["value"][1])
  45. min_rate = target * (1 - tolerance)
  46. max_rate = target * (1 + tolerance)
  47. if rate < min_rate:
  48. raise AssertionError(
  49. f"gRPC rate {rate:.0f}/s is below target {target:.0f}/s "
  50. f"(tolerance ±{tolerance*100:.0f}%, min allowed: {min_rate:.0f}/s, "
  51. f"window={window_seconds}s)"
  52. )
  53. return rate
  54. def assert_grpc_p99_under(threshold_ms: float, window_seconds: int = 60) -> float:
  55. """
  56. Assert gRPC ack p99 latency is under threshold (in milliseconds).
  57. Uses histogram_quantile over the given window.
  58. Returns the actual p99 in milliseconds, or raises AssertionError.
  59. Query: histogram_quantile(0.99, rate(ba_ingestd_grpc_ack_latency_seconds_bucket[window]))
  60. """
  61. query = (
  62. f'histogram_quantile(0.99, '
  63. f'rate(ba_ingestd_grpc_ack_latency_seconds_bucket[{window_seconds}s]))'
  64. )
  65. results = scrape(query)
  66. if not results:
  67. raise AssertionError(
  68. f"gRPC p99 query returned no data. Is ingestd gRPC server up? "
  69. f"(query: {query})"
  70. )
  71. p99_seconds = float(results[0]["value"][1])
  72. p99_ms = p99_seconds * 1000
  73. if p99_ms > threshold_ms:
  74. raise AssertionError(
  75. f"gRPC ack p99 {p99_ms:.1f}ms exceeds threshold {threshold_ms:.1f}ms "
  76. f"(window={window_seconds}s)"
  77. )
  78. return p99_ms
  79. def assert_nats_publish_rate_near(target: float, tolerance: float = 0.10,
  80. window_seconds: int = 30) -> float:
  81. """
  82. Assert the NATS publish OK rate is within tolerance of target.
  83. F2 (M11 NATS investigation): receive rate alone is not enough — a broken
  84. publish path can hide behind a healthy receive metric. The M11 10-min
  85. soak that "shipped" M11 was a false positive: ba_ingestd_alerts_received_total
  86. counts gRPC receive, but PublishAsync to NATS was failing because the
  87. server hit its max_storage cap. The smoke stayed green while the publish
  88. path was broken end-to-end.
  89. This assertion catches the next class of this bug by querying
  90. ba_ingestd_nats_publish_total{result="ok"} and checking it tracks the
  91. receive rate.
  92. Query: sum(rate(ba_ingestd_nats_publish_total{result="ok"}[window]))
  93. """
  94. query = (
  95. f'sum(rate(ba_ingestd_nats_publish_total{{result="ok"}}[{window_seconds}s]))'
  96. )
  97. results = scrape(query)
  98. if not results:
  99. raise AssertionError(
  100. f"NATS publish OK rate query returned no data. "
  101. f"Is the F2 metric (ba_ingestd_nats_publish_total) exposed by ingestd? "
  102. f"(query: {query})"
  103. )
  104. rate = float(results[0]["value"][1])
  105. min_rate = target * (1 - tolerance)
  106. if rate < min_rate:
  107. raise AssertionError(
  108. f"NATS publish OK rate {rate:.0f}/s is below target {target:.0f}/s "
  109. f"(tolerance ±{tolerance*100:.0f}%, min allowed: {min_rate:.0f}/s, "
  110. f"window={window_seconds}s). This means the publish path is broken "
  111. f"even though gRPC receive may be green. See M11_NATS_INVESTIGATION.md."
  112. )
  113. return rate
  114. def assert_dlq_count_equals(expected: int, window_seconds: int) -> int:
  115. """
  116. Assert the DLQ row count (delta over window) equals expected.
  117. Returns the actual count.
  118. """
  119. query = (
  120. f'increase(ba_deliverd_dlq_total[{window_seconds}s])'
  121. )
  122. results = scrape(query)
  123. if not results:
  124. return 0 # No data → assume 0
  125. count = float(results[0]["value"][1])
  126. if abs(count - expected) > 0.5:
  127. raise AssertionError(
  128. f"DLQ count {count:.0f} over last {window_seconds}s does not equal expected {expected}"
  129. )
  130. return int(count)
  131. """
  132. Assert the DLQ row count (delta over window) equals expected.
  133. Returns the actual count.
  134. """
  135. query = (
  136. f'increase(ba_deliverd_dlq_total[{window_seconds}s])'
  137. )
  138. results = scrape(query)
  139. if not results:
  140. return 0 # No data → assume 0
  141. count = float(results[0]["value"][1])
  142. if abs(count - expected) > 0.5:
  143. raise AssertionError(
  144. f"DLQ count {count:.0f} over last {window_seconds}s does not equal expected {expected}"
  145. )
  146. return int(count)
  147. def get_prometheus_targets() -> dict[str, str]:
  148. """
  149. Return a dict mapping service name → health status from Prometheus target health API.
  150. "1" = healthy, "0" = down, other = unknown state.
  151. """
  152. url = f"{PROM}/api/v1/targets?state=active"
  153. try:
  154. with urllib.request.urlopen(url, timeout=PROM_TIMEOUT) as r:
  155. data = json.loads(r.read())
  156. out = {}
  157. for t in data.get("data", {}).get("activeTargets", []):
  158. labels = t.get("labels", {})
  159. job = labels.get("job", "?")
  160. # Use the job name (e.g. "ingestd", "loadgen-grpc-1") as the key
  161. out[job] = "1" if t.get("health") == "up" else str(t.get("health", "0"))
  162. return out
  163. except Exception:
  164. return {}
  165. def get_grpc_streams_active() -> int:
  166. """
  167. Return the current number of active gRPC streams on ingestd
  168. (ba_ingestd_grpc_streams_active{service="ingestd"} gauge).
  169. Returns 0 if no data.
  170. NB: this metric is exported by every service that uses the grpcserver
  171. package (deliverd-fcm, deliverd-telegram, admind, routerd, ingestd),
  172. all with value 0 except ingestd under load. An unfiltered query returns
  173. 5 series and the first happens to be 0, so we filter by service="ingestd".
  174. """
  175. query = 'ba_ingestd_grpc_streams_active{service="ingestd"}'
  176. results = scrape(query)
  177. if not results:
  178. return 0
  179. return int(float(results[0]["value"][1]))
  180. def get_grpc_rate_limited_total() -> int:
  181. """
  182. Return the cumulative ba_ingestd_grpc_rate_limited_total counter on ingestd.
  183. Returns 0 if no data.
  184. NB: same multi-service issue as get_grpc_streams_active — filter by service.
  185. """
  186. query = 'ba_ingestd_grpc_rate_limited_total{service="ingestd"}'
  187. results = scrape(query)
  188. if not results:
  189. return 0
  190. return int(float(results[0]["value"][1]))