test_on_demand_metrics.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. from collections.abc import Sequence
  2. from typing import Any
  3. from unittest import mock
  4. import pytest
  5. from sentry.models.dashboard_widget import DashboardWidgetQueryOnDemand
  6. from sentry.models.organization import Organization
  7. from sentry.models.project import Project
  8. from sentry.tasks import on_demand_metrics
  9. from sentry.tasks.on_demand_metrics import (
  10. get_field_cardinality_cache_key,
  11. process_widget_specs,
  12. schedule_on_demand_check,
  13. )
  14. from sentry.testutils.factories import Factories
  15. from sentry.testutils.helpers import Feature, override_options
  16. from sentry.testutils.helpers.on_demand import create_widget
  17. from sentry.testutils.pytest.fixtures import django_db_all
  18. from sentry.users.models.user import User
  19. from sentry.utils.cache import cache
  20. _WIDGET_EXTRACTION_FEATURES = {"organizations:on-demand-metrics-extraction-widgets": True}
  21. _SNQL_DATA_LOW_CARDINALITY = {"data": [{"count_unique(custom-tag)": 10000}]}
  22. _SNQL_DATA_HIGH_CARDINALITY = {"data": [{"count_unique(custom-tag)": 10001}]}
  23. OnDemandExtractionState = DashboardWidgetQueryOnDemand.OnDemandExtractionState
  24. @pytest.fixture
  25. def owner() -> None:
  26. return Factories.create_user()
  27. @pytest.fixture
  28. def organization(owner: User) -> None:
  29. return Factories.create_organization(owner=owner)
  30. @pytest.fixture
  31. def project(organization: Organization) -> Project:
  32. return Factories.create_project(organization=organization)
  33. @pytest.mark.parametrize(
  34. [
  35. "feature_flags",
  36. "option_enable",
  37. "option_rollout",
  38. "option_batch_size",
  39. "option_total_batches",
  40. "option_max_widget_cardinality",
  41. "columns",
  42. "previous_batch",
  43. "expected_number_of_child_tasks_run",
  44. "expected_discover_queries_run",
  45. "cached_columns",
  46. ],
  47. [
  48. # Testing options and task batching
  49. pytest.param(
  50. {},
  51. False,
  52. 0.0,
  53. 1,
  54. 1,
  55. 100,
  56. [
  57. ["foo"],
  58. ["bar"],
  59. ["baz"],
  60. ["ast"],
  61. ["bars"],
  62. ],
  63. 0,
  64. 0,
  65. 0,
  66. [],
  67. id="nothing_enabled",
  68. ),
  69. pytest.param(
  70. {},
  71. True,
  72. 0.0,
  73. 1,
  74. 1,
  75. 100,
  76. [
  77. ["foo"],
  78. ["bar"],
  79. ["baz"],
  80. ["ast"],
  81. ["bars"],
  82. ],
  83. 0,
  84. 0,
  85. 0,
  86. [],
  87. id="option_enabled_no_rollout",
  88. ),
  89. pytest.param(
  90. {},
  91. True,
  92. 1.0,
  93. 1,
  94. 1,
  95. 100,
  96. [
  97. ["foo"],
  98. ["bar"],
  99. ["baz"],
  100. ["ast"],
  101. ["bars"],
  102. ],
  103. 0,
  104. 5,
  105. 0,
  106. [],
  107. id="option_enabled_rollout_max",
  108. ),
  109. pytest.param(
  110. {},
  111. True,
  112. 0.0,
  113. 1,
  114. 1,
  115. 100,
  116. [[], [], [], [], []],
  117. 0,
  118. 0,
  119. 0,
  120. [],
  121. id="option_enabled_rollout_max_no_columns",
  122. ),
  123. pytest.param(
  124. {},
  125. True,
  126. 0.001,
  127. 1,
  128. 1,
  129. 100,
  130. [
  131. ["foo"],
  132. ["bar"],
  133. ["baz"],
  134. ["ast"],
  135. ["bars"],
  136. ],
  137. 0,
  138. 1,
  139. 0,
  140. [],
  141. id="option_enabled_rollout_only_one",
  142. ),
  143. pytest.param(
  144. {},
  145. True,
  146. 0.002,
  147. 1,
  148. 1,
  149. 100,
  150. [
  151. ["foo"],
  152. ["bar"],
  153. ["baz"],
  154. ["ast"],
  155. ["bars"],
  156. ],
  157. 0,
  158. 2,
  159. 0,
  160. [],
  161. id="option_enabled_rollout_two",
  162. ),
  163. pytest.param(
  164. {},
  165. True,
  166. 1.0,
  167. 2,
  168. 1,
  169. 100,
  170. [
  171. ["foo"],
  172. ["bar"],
  173. ["baz"],
  174. ["ast"],
  175. ["bars"],
  176. ],
  177. 0,
  178. 3,
  179. 0,
  180. [],
  181. id="fully_enabled_batch_size_remainder",
  182. ), # Should be 3 calls since 5 / 2 has remainder
  183. pytest.param(
  184. {},
  185. True,
  186. 1.0,
  187. 1,
  188. 2,
  189. 100,
  190. [
  191. ["foo"],
  192. ["bar"],
  193. ["baz"],
  194. ["ast"],
  195. ["bars"],
  196. ],
  197. 1,
  198. 2,
  199. 0,
  200. [],
  201. id="test_offset_batch_0",
  202. ), # first batch of two (previous batch 1 rolls over), with batch size 1. Widgets [2,4]
  203. pytest.param(
  204. {},
  205. True,
  206. 1.0,
  207. 1,
  208. 2,
  209. 100,
  210. [
  211. ["foo"],
  212. ["bar"],
  213. ["baz"],
  214. ["ast"],
  215. ["bars"],
  216. ],
  217. 0,
  218. 3,
  219. 0,
  220. [],
  221. id="test_offset_batch_1",
  222. ), # second batch of two, with batch size 1. Widgets [1,3,5]
  223. pytest.param(
  224. {},
  225. True,
  226. 1.0,
  227. 5,
  228. 1,
  229. 100,
  230. [
  231. ["foo"],
  232. ["bar"],
  233. ["baz"],
  234. ["ast"],
  235. ["bars"],
  236. ],
  237. 0,
  238. 1,
  239. 0,
  240. [],
  241. id="fully_enabled_batch_size_all",
  242. ),
  243. pytest.param(
  244. {},
  245. True,
  246. 1.0,
  247. 10,
  248. 1,
  249. 100,
  250. [
  251. ["foo"],
  252. ["bar"],
  253. ["baz"],
  254. ["ast"],
  255. ["bars"],
  256. ],
  257. 0,
  258. 1,
  259. 0,
  260. [],
  261. id="fully_enabled_batch_size_larger_batch",
  262. ),
  263. # Testing cardinality checks
  264. pytest.param(
  265. _WIDGET_EXTRACTION_FEATURES,
  266. True,
  267. 1.0,
  268. 10,
  269. 1,
  270. 100,
  271. [
  272. ["foo"],
  273. ["bar"],
  274. ["baz"],
  275. ["ast"],
  276. ["bars"],
  277. ],
  278. 0,
  279. 1,
  280. 5,
  281. [],
  282. id="fully_enabled_with_features",
  283. ), # 1 task, 5 queries.
  284. pytest.param(
  285. _WIDGET_EXTRACTION_FEATURES,
  286. True,
  287. 1.0,
  288. 10,
  289. 1,
  290. 49,
  291. [
  292. ["foo"],
  293. ["bar"],
  294. ["baz"],
  295. ["ast"],
  296. ["bars"],
  297. ],
  298. 0,
  299. 1,
  300. 5,
  301. [],
  302. id="fully_enabled_with_features_high_cardinality",
  303. ), # Below cardinality limit.
  304. pytest.param(
  305. _WIDGET_EXTRACTION_FEATURES,
  306. True,
  307. 1.0,
  308. 2,
  309. 4,
  310. 100,
  311. [
  312. ["foo"],
  313. ["bar"],
  314. ["baz"],
  315. ["ast"],
  316. ["bars"],
  317. ],
  318. 0,
  319. 1,
  320. 2,
  321. [],
  322. id="test_offset_batch_larger_size_with_features",
  323. ), # Checking 2nd batch of 4. Widgets[1, 4], 1 child task, 2 queries made (batch size 2)
  324. pytest.param(
  325. _WIDGET_EXTRACTION_FEATURES,
  326. True,
  327. 1.0,
  328. 10,
  329. 1,
  330. 1000,
  331. [
  332. ["foo"],
  333. ["bar"],
  334. ["baz"],
  335. ["ast"],
  336. ["bars"],
  337. ],
  338. 0,
  339. 1,
  340. 5,
  341. [
  342. "foo",
  343. "bar",
  344. "baz",
  345. "ast",
  346. "bars",
  347. ],
  348. id="test_snuba_cardinality_call_is_cached",
  349. ), # Below cardinality limit.
  350. ],
  351. )
  352. @django_db_all
  353. def test_schedule_on_demand_check(
  354. feature_flags: dict[str, bool],
  355. option_enable: bool,
  356. option_rollout: bool,
  357. option_batch_size: float,
  358. option_total_batches: int,
  359. option_max_widget_cardinality: int,
  360. previous_batch: int,
  361. columns: list[list[str]],
  362. expected_number_of_child_tasks_run: int,
  363. expected_discover_queries_run: int,
  364. cached_columns: list[str],
  365. project: Project,
  366. ) -> None:
  367. cache.clear()
  368. options = {
  369. "on_demand_metrics.check_widgets.enable": option_enable,
  370. "on_demand_metrics.check_widgets.rollout": option_rollout,
  371. "on_demand_metrics.check_widgets.query.batch_size": option_batch_size,
  372. "on_demand_metrics.check_widgets.query.total_batches": option_total_batches,
  373. "on_demand.max_widget_cardinality.count": option_max_widget_cardinality,
  374. }
  375. on_demand_metrics._set_currently_processing_batch(previous_batch)
  376. # Reuse the same dashboard to speed up fixture calls and avoid managing dashboard unique title constraint.
  377. _, __, dashboard = create_widget(
  378. ["count()"], "transaction.duration:>=1", project, columns=columns[0], id=1
  379. )
  380. for i in range(2, 6):
  381. create_widget(
  382. ["count()"],
  383. f"transaction.duration:>={i}",
  384. project,
  385. columns=columns[i - 1],
  386. id=i,
  387. dashboard=dashboard,
  388. )
  389. with (
  390. mock.patch(
  391. "sentry.tasks.on_demand_metrics._query_cardinality",
  392. return_value=(
  393. {"data": [{f"count_unique({col[0]})": 50 for col in columns if col}]},
  394. [col[0] for col in columns if col],
  395. ),
  396. ) as _query_cardinality,
  397. mock.patch.object(
  398. process_widget_specs, "delay", wraps=process_widget_specs
  399. ) as process_widget_specs_spy,
  400. override_options(options),
  401. Feature(feature_flags),
  402. ):
  403. assert not process_widget_specs_spy.called
  404. schedule_on_demand_check()
  405. assert process_widget_specs_spy.call_count == expected_number_of_child_tasks_run
  406. assert _query_cardinality.call_count == expected_discover_queries_run
  407. for column in cached_columns:
  408. assert cache.get(
  409. get_field_cardinality_cache_key(column, project.organization, "task-cache")
  410. )
  411. @pytest.mark.parametrize(
  412. [
  413. "feature_flags",
  414. "option_enable",
  415. "widget_query_ids",
  416. "set_high_cardinality",
  417. "expected_discover_queries_run",
  418. "expected_low_cardinality",
  419. ],
  420. [
  421. pytest.param({}, False, [], False, 0, False, id="nothing_enabled"),
  422. pytest.param(_WIDGET_EXTRACTION_FEATURES, True, [1], False, 1, True, id="enabled_low_card"),
  423. pytest.param({}, True, [1], False, 0, True, id="enabled_low_card_no_features"),
  424. pytest.param(
  425. _WIDGET_EXTRACTION_FEATURES, True, [1], True, 1, False, id="enabled_high_card"
  426. ),
  427. pytest.param(
  428. _WIDGET_EXTRACTION_FEATURES,
  429. True,
  430. [1, 2, 3],
  431. False,
  432. 1,
  433. True,
  434. id="enabled_low_card_all_widgets",
  435. ), # Only 2 widgets are on-demand
  436. ],
  437. )
  438. @mock.patch("sentry.tasks.on_demand_metrics._set_cardinality_cache")
  439. @mock.patch("sentry.search.events.builder.base.raw_snql_query")
  440. @django_db_all
  441. def test_process_widget_specs(
  442. raw_snql_query: Any,
  443. _set_cardinality_cache: Any,
  444. feature_flags: dict[str, bool],
  445. option_enable: bool,
  446. widget_query_ids: Sequence[int],
  447. set_high_cardinality: bool,
  448. expected_discover_queries_run: int,
  449. expected_low_cardinality: bool,
  450. project: Project,
  451. ) -> None:
  452. cache.clear()
  453. raw_snql_query.return_value = (
  454. _SNQL_DATA_HIGH_CARDINALITY if set_high_cardinality else _SNQL_DATA_LOW_CARDINALITY
  455. )
  456. options = {
  457. "on_demand_metrics.check_widgets.enable": option_enable,
  458. }
  459. query_columns = ["custom-tag"]
  460. # Reuse the same dashboard to speed up fixture calls and avoid managing dashboard unique title constraint.
  461. _, __, dashboard = create_widget(
  462. ["count()"], "transaction.duration:>=1", project, columns=query_columns, id=1
  463. )
  464. create_widget(
  465. ["count()"],
  466. "transaction.duration:>=2",
  467. project,
  468. columns=query_columns,
  469. id=2,
  470. dashboard=dashboard,
  471. )
  472. create_widget(
  473. ["count()"],
  474. "", # Not a on-demand widget
  475. project,
  476. columns=[],
  477. id=3,
  478. dashboard=dashboard,
  479. )
  480. with override_options(options), Feature(feature_flags):
  481. process_widget_specs(widget_query_ids)
  482. assert raw_snql_query.call_count == expected_discover_queries_run
  483. expected_state = ""
  484. if not feature_flags:
  485. expected_state = OnDemandExtractionState.DISABLED_PREROLLOUT
  486. else:
  487. expected_state = (
  488. OnDemandExtractionState.ENABLED_ENROLLED
  489. if expected_low_cardinality
  490. else OnDemandExtractionState.DISABLED_HIGH_CARDINALITY
  491. )
  492. if 1 in widget_query_ids:
  493. widget_models = DashboardWidgetQueryOnDemand.objects.filter(dashboard_widget_query_id=1)
  494. for widget_model in widget_models:
  495. assert_on_demand_model(
  496. widget_model,
  497. has_features=bool(feature_flags),
  498. expected_state=expected_state,
  499. expected_hashes={1: ["43adeb86"], 2: ["851922a4"]},
  500. )
  501. if 2 in widget_query_ids:
  502. widget_models = DashboardWidgetQueryOnDemand.objects.filter(dashboard_widget_query_id=2)
  503. for widget_model in widget_models:
  504. assert_on_demand_model(
  505. widget_model,
  506. has_features=bool(feature_flags),
  507. expected_state=expected_state,
  508. expected_hashes={1: ["8f74e5da"], 2: ["581c3968"]},
  509. )
  510. if 3 in widget_query_ids:
  511. widget_models = DashboardWidgetQueryOnDemand.objects.filter(dashboard_widget_query_id=3)
  512. for widget_model in widget_models:
  513. assert_on_demand_model(
  514. widget_model,
  515. has_features=bool(feature_flags),
  516. expected_state=OnDemandExtractionState.DISABLED_NOT_APPLICABLE,
  517. expected_hashes=None,
  518. )
  519. def assert_on_demand_model(
  520. model: DashboardWidgetQueryOnDemand,
  521. has_features: bool,
  522. expected_state: str,
  523. expected_hashes: dict[int, list[str]] | None,
  524. ) -> None:
  525. assert model.spec_version
  526. assert model.extraction_state == expected_state
  527. if expected_state == OnDemandExtractionState.DISABLED_NOT_APPLICABLE:
  528. # This forces the caller to explicitly set the expectations
  529. assert expected_hashes is None
  530. assert model.spec_hashes == []
  531. return
  532. assert expected_hashes is not None
  533. if not has_features:
  534. assert model.spec_hashes == expected_hashes[model.spec_version] # Still include hashes
  535. return
  536. assert model.spec_hashes == expected_hashes[model.spec_version] # Still include hashes