test_organization_events_histogram.py 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158
  1. import random
  2. from collections import namedtuple
  3. from copy import deepcopy
  4. from datetime import timedelta
  5. import pytest
  6. from django.urls import reverse
  7. from rest_framework.exceptions import ErrorDetail
  8. from sentry.testutils import APITestCase, MetricsEnhancedPerformanceTestCase, SnubaTestCase
  9. from sentry.testutils.helpers.datetime import before_now, iso_format
  10. from sentry.testutils.silo import region_silo_test
  11. from sentry.utils.samples import load_data
  12. from sentry.utils.snuba import get_array_column_alias
  13. pytestmark = pytest.mark.sentry_metrics
  14. HistogramSpec = namedtuple("HistogramSpec", ["start", "end", "fields"])
  15. ARRAY_COLUMNS = ["measurements", "span_op_breakdowns"]
  16. @region_silo_test
  17. class OrganizationEventsHistogramEndpointTest(APITestCase, SnubaTestCase):
  18. def setUp(self):
  19. super().setUp()
  20. self.min_ago = iso_format(before_now(minutes=1))
  21. self.data = load_data("transaction")
  22. self.features = {}
  23. def populate_events(self, specs):
  24. start = before_now(minutes=5)
  25. for spec in specs:
  26. spec = HistogramSpec(*spec)
  27. for suffix_key, count in spec.fields:
  28. for i in range(count):
  29. data = deepcopy(self.data)
  30. measurement_name = suffix_key
  31. breakdown_name = f"ops.{suffix_key}"
  32. data["timestamp"] = iso_format(start)
  33. data["start_timestamp"] = iso_format(start - timedelta(seconds=i))
  34. value = random.random() * (spec.end - spec.start) + spec.start
  35. data["transaction"] = f"/measurement/{measurement_name}/value/{value}"
  36. data["measurements"] = {measurement_name: {"value": value}}
  37. data["breakdowns"] = {
  38. "span_ops": {
  39. breakdown_name: {"value": value},
  40. }
  41. }
  42. self.store_event(data, self.project.id)
  43. def as_response_data(self, specs):
  44. data = {}
  45. for spec in specs:
  46. spec = HistogramSpec(*spec)
  47. for measurement, count in sorted(spec.fields):
  48. if measurement not in data:
  49. data[measurement] = []
  50. data[measurement].append({"bin": spec.start, "count": count})
  51. return data
  52. def do_request(self, query, features=None):
  53. if features is None:
  54. features = {"organizations:performance-view": True}
  55. features.update(self.features)
  56. self.login_as(user=self.user)
  57. url = reverse(
  58. "sentry-api-0-organization-events-histogram",
  59. kwargs={"organization_slug": self.organization.slug},
  60. )
  61. with self.feature(features):
  62. return self.client.get(url, query, format="json")
  63. def test_no_projects(self):
  64. response = self.do_request({})
  65. assert response.status_code == 200, response.content
  66. assert response.data == {}
  67. def test_good_params(self):
  68. for array_column in ARRAY_COLUMNS:
  69. alias = get_array_column_alias(array_column)
  70. query = {
  71. "query": "event.type:transaction",
  72. "project": [self.project.id],
  73. "field": [f"{alias}.foo", f"{alias}.bar"],
  74. "numBuckets": 10,
  75. }
  76. response = self.do_request(query)
  77. assert response.status_code == 200, f"failing for {array_column}"
  78. def test_good_params_with_optionals(self):
  79. for array_column in ARRAY_COLUMNS:
  80. alias = get_array_column_alias(array_column)
  81. query = {
  82. "query": "event.type:transaction",
  83. "project": [self.project.id],
  84. "field": [f"{alias}.foo", f"{alias}.bar"],
  85. "numBuckets": 10,
  86. "precision": 0,
  87. "min": 0,
  88. "max": 10,
  89. }
  90. response = self.do_request(query)
  91. assert response.status_code == 200, f"failing for {array_column}"
  92. def test_bad_params_reverse_min_max(self):
  93. for array_column in ARRAY_COLUMNS:
  94. alias = get_array_column_alias(array_column)
  95. query = {
  96. "query": "event.type:transaction",
  97. "project": [self.project.id],
  98. "field": [f"{alias}.foo", f"{alias}.bar"],
  99. "numBuckets": 10,
  100. "precision": 0,
  101. "min": 10,
  102. "max": 5,
  103. }
  104. response = self.do_request(query)
  105. assert response.data == {"non_field_errors": ["min cannot be greater than max."]}
  106. def test_bad_params_missing_fields(self):
  107. query = {
  108. "project": [self.project.id],
  109. "numBuckets": 10,
  110. }
  111. response = self.do_request(query)
  112. assert response.status_code == 400
  113. assert response.data == {
  114. "field": [ErrorDetail(string="This field is required.", code="required")],
  115. }
  116. def test_bad_params_too_many_fields(self):
  117. query = {
  118. "project": [self.project.id],
  119. "field": ["foo", "bar", "baz", "qux", "quux"],
  120. "numBuckets": 10,
  121. "min": 0,
  122. "max": 100,
  123. "precision": 0,
  124. }
  125. response = self.do_request(query)
  126. assert response.status_code == 400
  127. assert response.data == {
  128. "field": ["Ensure this field has no more than 4 elements."],
  129. }
  130. def test_bad_params_mixed_fields(self):
  131. for array_column in ARRAY_COLUMNS:
  132. for other_array_column in ARRAY_COLUMNS:
  133. query = {
  134. "project": [self.project.id],
  135. "field": [
  136. "foo",
  137. f"{get_array_column_alias(array_column)}.foo",
  138. f"{get_array_column_alias(other_array_column)}.bar",
  139. ],
  140. "numBuckets": 10,
  141. "min": 0,
  142. "max": 100,
  143. "precision": 0,
  144. }
  145. response = self.do_request(query)
  146. assert response.status_code == 400, f"failing for {array_column}"
  147. assert response.data == {
  148. "field": [
  149. "You can only generate histogram for one column at a time unless they are all measurements or all span op breakdowns."
  150. ],
  151. }, f"failing for {array_column}"
  152. def test_bad_params_missing_num_buckets(self):
  153. query = {
  154. "project": [self.project.id],
  155. "field": ["foo"],
  156. }
  157. response = self.do_request(query)
  158. assert response.status_code == 400
  159. assert response.data == {
  160. "numBuckets": ["This field is required."],
  161. }
  162. def test_bad_params_invalid_num_buckets(self):
  163. for array_column in ARRAY_COLUMNS:
  164. alias = get_array_column_alias(array_column)
  165. query = {
  166. "project": [self.project.id],
  167. "field": [f"{alias}.foo", f"{alias}.bar"],
  168. "numBuckets": "baz",
  169. }
  170. response = self.do_request(query)
  171. assert response.status_code == 400, f"failing for {array_column}"
  172. assert response.data == {
  173. "numBuckets": ["A valid integer is required."],
  174. }, f"failing for {array_column}"
  175. def test_bad_params_invalid_negative_num_buckets(self):
  176. for array_column in ARRAY_COLUMNS:
  177. alias = get_array_column_alias(array_column)
  178. query = {
  179. "project": [self.project.id],
  180. "field": [f"{alias}.foo", f"{alias}.bar"],
  181. "numBuckets": -1,
  182. }
  183. response = self.do_request(query)
  184. assert response.status_code == 400, f"failing for {array_column}"
  185. assert response.data == {
  186. "numBuckets": ["Ensure this value is greater than or equal to 1."],
  187. }, f"failing for {array_column}"
  188. def test_bad_params_num_buckets_too_large(self):
  189. for array_column in ARRAY_COLUMNS:
  190. alias = get_array_column_alias(array_column)
  191. query = {
  192. "project": [self.project.id],
  193. "field": [f"{alias}.foo", f"{alias}.bar"],
  194. "numBuckets": 150,
  195. }
  196. response = self.do_request(query)
  197. assert response.status_code == 400, f"failing for {array_column}"
  198. assert response.data == {
  199. "numBuckets": ["Ensure this value is less than or equal to 100."],
  200. }, f"failing for {array_column}"
  201. def test_bad_params_invalid_precision_too_small(self):
  202. for array_column in ARRAY_COLUMNS:
  203. alias = get_array_column_alias(array_column)
  204. query = {
  205. "project": [self.project.id],
  206. "field": [f"{alias}.foo", f"{alias}.bar"],
  207. "numBuckets": 10,
  208. "precision": -1,
  209. }
  210. response = self.do_request(query)
  211. assert response.status_code == 400, f"failing for {array_column}"
  212. assert response.data == {
  213. "precision": ["Ensure this value is greater than or equal to 0."],
  214. }, f"failing for {array_column}"
  215. def test_bad_params_invalid_precision_too_big(self):
  216. for array_column in ARRAY_COLUMNS:
  217. alias = get_array_column_alias(array_column)
  218. query = {
  219. "project": [self.project.id],
  220. "field": [f"{alias}.foo", f"{alias}.bar"],
  221. "numBuckets": 10,
  222. "precision": 100,
  223. }
  224. response = self.do_request(query)
  225. assert response.status_code == 400, f"failing for {array_column}"
  226. assert response.data == {
  227. "precision": ["Ensure this value is less than or equal to 4."],
  228. }, f"failing for {array_column}"
  229. def test_bad_params_invalid_min(self):
  230. for array_column in ARRAY_COLUMNS:
  231. alias = get_array_column_alias(array_column)
  232. query = {
  233. "project": [self.project.id],
  234. "field": [f"{alias}.foo", f"{alias}.bar"],
  235. "numBuckets": 10,
  236. "min": "qux",
  237. }
  238. response = self.do_request(query)
  239. assert response.status_code == 400, f"failing for {array_column}"
  240. assert response.data == {
  241. "min": ["A valid number is required."],
  242. }, f"failing for {array_column}"
  243. def test_bad_params_invalid_max(self):
  244. for array_column in ARRAY_COLUMNS:
  245. alias = get_array_column_alias(array_column)
  246. query = {
  247. "project": [self.project.id],
  248. "field": [f"{alias}.foo", f"{alias}.bar"],
  249. "numBuckets": 10,
  250. "max": "qux",
  251. }
  252. response = self.do_request(query)
  253. assert response.status_code == 400, f"failing for {array_column}"
  254. assert response.data == {
  255. "max": ["A valid number is required."],
  256. }, f"failing for {array_column}"
  257. def test_histogram_empty(self):
  258. for array_column in ARRAY_COLUMNS:
  259. alias = get_array_column_alias(array_column)
  260. query = {
  261. "project": [self.project.id],
  262. "field": [f"{alias}.foo", f"{alias}.bar"],
  263. "numBuckets": 5,
  264. }
  265. response = self.do_request(query)
  266. assert response.status_code == 200, f"failing for {array_column}"
  267. expected = [(i, i + 1, [(f"{alias}.foo", 0), (f"{alias}.bar", 0)]) for i in range(5)]
  268. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  269. def test_histogram_simple(self):
  270. # range is [0, 5), so it is divided into 5 buckets of width 1
  271. specs = [
  272. (0, 1, [("foo", 1)]),
  273. (1, 2, [("foo", 1)]),
  274. (2, 3, [("foo", 1)]),
  275. (4, 5, [("foo", 1)]),
  276. ]
  277. self.populate_events(specs)
  278. for array_column in ARRAY_COLUMNS:
  279. alias = get_array_column_alias(array_column)
  280. query = {
  281. "project": [self.project.id],
  282. "field": [f"{alias}.foo"],
  283. "numBuckets": 5,
  284. }
  285. response = self.do_request(query)
  286. assert response.status_code == 200, f"failing for {array_column}"
  287. expected = [
  288. (0, 1, [(f"{alias}.foo", 1)]),
  289. (1, 2, [(f"{alias}.foo", 1)]),
  290. (2, 3, [(f"{alias}.foo", 1)]),
  291. (3, 4, [(f"{alias}.foo", 0)]),
  292. (4, 5, [(f"{alias}.foo", 1)]),
  293. ]
  294. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  295. def test_histogram_simple_using_min_max(self):
  296. # range is [0, 5), so it is divided into 5 buckets of width 1
  297. specs = [
  298. (0, 1, [("foo", 1)]),
  299. (1, 2, [("foo", 1)]),
  300. (2, 3, [("foo", 1)]),
  301. (4, 5, [("foo", 1)]),
  302. ]
  303. self.populate_events(specs)
  304. for array_column in ARRAY_COLUMNS:
  305. alias = get_array_column_alias(array_column)
  306. query = {
  307. "project": [self.project.id],
  308. "field": [f"{alias}.foo"],
  309. "numBuckets": 5,
  310. "min": 0,
  311. "max": 5,
  312. }
  313. response = self.do_request(query)
  314. assert response.status_code == 200, f"failing for {array_column}"
  315. expected = [
  316. (0, 1, [(f"{alias}.foo", 1)]),
  317. (1, 2, [(f"{alias}.foo", 1)]),
  318. (2, 3, [(f"{alias}.foo", 1)]),
  319. (3, 4, [(f"{alias}.foo", 0)]),
  320. (4, 5, [(f"{alias}.foo", 1)]),
  321. ]
  322. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  323. def test_histogram_simple_using_given_min_above_queried_max(self):
  324. # All these events are out of range of the query parameters,
  325. # and should not appear in the results.
  326. specs = [
  327. (0, 1, [("foo", 1)]),
  328. (1, 2, [("foo", 1)]),
  329. (2, 3, [("foo", 1)]),
  330. (4, 5, [("foo", 1)]),
  331. ]
  332. self.populate_events(specs)
  333. for array_column in ARRAY_COLUMNS:
  334. alias = get_array_column_alias(array_column)
  335. query = {
  336. "project": [self.project.id],
  337. "field": [f"{alias}.foo"],
  338. "numBuckets": 5,
  339. "min": 6,
  340. }
  341. response = self.do_request(query)
  342. assert response.status_code == 200, f"failing for {array_column}"
  343. expected = [
  344. (6, 7, [(f"{alias}.foo", 0)]),
  345. ]
  346. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  347. def test_histogram_simple_using_given_max_below_queried_min(self):
  348. # All these events are out of range of the query parameters,
  349. # and should not appear in the results.
  350. specs = [
  351. (6, 7, [("foo", 1)]),
  352. (8, 9, [("foo", 1)]),
  353. (10, 11, [("foo", 1)]),
  354. (12, 13, [("foo", 1)]),
  355. ]
  356. self.populate_events(specs)
  357. for array_column in ARRAY_COLUMNS:
  358. alias = get_array_column_alias(array_column)
  359. query = {
  360. "project": [self.project.id],
  361. "field": [f"{alias}.foo"],
  362. "numBuckets": 5,
  363. "max": 6,
  364. }
  365. response = self.do_request(query)
  366. assert response.status_code == 200, f"failing for {array_column}"
  367. expected = [
  368. (5, 6, [(f"{alias}.foo", 0)]),
  369. ]
  370. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  371. def test_histogram_large_buckets(self):
  372. # make sure that it works for large width buckets
  373. # range is [0, 99], so it is divided into 5 buckets of width 20
  374. specs = [
  375. (0, 0, [("foo", 2)]),
  376. (99, 99, [("foo", 2)]),
  377. ]
  378. self.populate_events(specs)
  379. for array_column in ARRAY_COLUMNS:
  380. alias = get_array_column_alias(array_column)
  381. query = {
  382. "project": [self.project.id],
  383. "field": [f"{alias}.foo"],
  384. "numBuckets": 5,
  385. }
  386. response = self.do_request(query)
  387. assert response.status_code == 200, f"failing for {array_column}"
  388. expected = [
  389. (0, 20, [(f"{alias}.foo", 2)]),
  390. (20, 40, [(f"{alias}.foo", 0)]),
  391. (40, 60, [(f"{alias}.foo", 0)]),
  392. (60, 80, [(f"{alias}.foo", 0)]),
  393. (80, 100, [(f"{alias}.foo", 2)]),
  394. ]
  395. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  396. def test_histogram_non_zero_offset(self):
  397. # range is [10, 15), so it is divided into 5 buckets of width 1
  398. specs = [
  399. (10, 11, [("foo", 1)]),
  400. (12, 13, [("foo", 1)]),
  401. (13, 14, [("foo", 1)]),
  402. (14, 15, [("foo", 1)]),
  403. ]
  404. self.populate_events(specs)
  405. for array_column in ARRAY_COLUMNS:
  406. alias = get_array_column_alias(array_column)
  407. query = {
  408. "project": [self.project.id],
  409. "field": [f"{alias}.foo"],
  410. "numBuckets": 5,
  411. }
  412. response = self.do_request(query)
  413. assert response.status_code == 200, f"failing for {array_column}"
  414. expected = [
  415. (10, 11, [(f"{alias}.foo", 1)]),
  416. (11, 12, [(f"{alias}.foo", 0)]),
  417. (12, 13, [(f"{alias}.foo", 1)]),
  418. (13, 14, [(f"{alias}.foo", 1)]),
  419. (14, 15, [(f"{alias}.foo", 1)]),
  420. ]
  421. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  422. def test_histogram_extra_data(self):
  423. # range is [11, 16), so it is divided into 5 buckets of width 1
  424. # make sure every bin has some value
  425. specs = [
  426. (10, 11, [("foo", 1)]),
  427. (11, 12, [("foo", 1)]),
  428. (12, 13, [("foo", 1)]),
  429. (13, 14, [("foo", 1)]),
  430. (14, 15, [("foo", 1)]),
  431. (15, 16, [("foo", 1)]),
  432. (16, 17, [("foo", 1)]),
  433. ]
  434. self.populate_events(specs)
  435. for array_column in ARRAY_COLUMNS:
  436. alias = get_array_column_alias(array_column)
  437. query = {
  438. "project": [self.project.id],
  439. "field": [f"{alias}.foo"],
  440. "numBuckets": 5,
  441. "min": 11,
  442. "max": 16,
  443. }
  444. response = self.do_request(query)
  445. assert response.status_code == 200, f"failing for {array_column}"
  446. expected = [
  447. (11, 12, [(f"{alias}.foo", 1)]),
  448. (12, 13, [(f"{alias}.foo", 1)]),
  449. (13, 14, [(f"{alias}.foo", 1)]),
  450. (14, 15, [(f"{alias}.foo", 1)]),
  451. (15, 16, [(f"{alias}.foo", 1)]),
  452. ]
  453. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  454. def test_histogram_non_zero_min_large_buckets(self):
  455. # range is [10, 59], so it is divided into 5 buckets of width 10
  456. specs = [
  457. (10, 10, [("foo", 1)]),
  458. (40, 50, [("foo", 1)]),
  459. (59, 59, [("foo", 2)]),
  460. ]
  461. self.populate_events(specs)
  462. for array_column in ARRAY_COLUMNS:
  463. alias = get_array_column_alias(array_column)
  464. query = {
  465. "project": [self.project.id],
  466. "field": [f"{alias}.foo"],
  467. "numBuckets": 5,
  468. }
  469. response = self.do_request(query)
  470. assert response.status_code == 200, f"failing for {array_column}"
  471. expected = [
  472. (10, 20, [(f"{alias}.foo", 1)]),
  473. (20, 30, [(f"{alias}.foo", 0)]),
  474. (30, 40, [(f"{alias}.foo", 0)]),
  475. (40, 50, [(f"{alias}.foo", 1)]),
  476. (50, 60, [(f"{alias}.foo", 2)]),
  477. ]
  478. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  479. @pytest.mark.xfail(reason="snuba does not allow - in alias names")
  480. def test_histogram_negative_values(self):
  481. # range is [-9, -4), so it is divided into 5 buckets of width 1
  482. specs = [
  483. (-9, -8, [("foo", 3)]),
  484. (-5, -4, [("foo", 1)]),
  485. ]
  486. self.populate_events(specs)
  487. for array_column in ARRAY_COLUMNS:
  488. alias = get_array_column_alias(array_column)
  489. query = {
  490. "project": [self.project.id],
  491. "field": [f"{alias}.foo"],
  492. "numBuckets": 5,
  493. }
  494. response = self.do_request(query)
  495. assert response.status_code == 200, f"failing for {array_column}"
  496. expected = [
  497. (-9, -8, [(f"{alias}.foo", 3)]),
  498. (-8, -7, [(f"{alias}.foo", 0)]),
  499. (-7, -6, [(f"{alias}.foo", 0)]),
  500. (-6, -5, [(f"{alias}.foo", 0)]),
  501. (-5, -4, [(f"{alias}.foo", 1)]),
  502. ]
  503. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  504. @pytest.mark.xfail(reason="snuba does not allow - in alias names")
  505. def test_histogram_positive_and_negative_values(self):
  506. # range is [-50, 49], so it is divided into 5 buckets of width 10
  507. specs = [
  508. (-50, -50, [("foo", 1)]),
  509. (-10, 10, [("foo", 2)]),
  510. (49, 49, [("foo", 1)]),
  511. ]
  512. self.populate_events(specs)
  513. for array_column in ARRAY_COLUMNS:
  514. alias = get_array_column_alias(array_column)
  515. query = {
  516. "project": [self.project.id],
  517. "field": [f"{alias}.foo"],
  518. "numBuckets": 5,
  519. }
  520. response = self.do_request(query)
  521. assert response.status_code == 200, f"failing for {array_column}"
  522. expected = [
  523. (-50, -30, [(f"{alias}.foo", 1)]),
  524. (-30, -10, [(f"{alias}.foo", 0)]),
  525. (-10, 10, [(f"{alias}.foo", 2)]),
  526. (10, 30, [(f"{alias}.foo", 0)]),
  527. (30, 50, [(f"{alias}.foo", 1)]),
  528. ]
  529. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  530. def test_histogram_increased_precision(self):
  531. # range is [1.00, 2.24], so it is divided into 5 buckets of width 0.25
  532. specs = [
  533. (1.00, 1.00, [("foo", 3)]),
  534. (2.24, 2.24, [("foo", 1)]),
  535. ]
  536. self.populate_events(specs)
  537. for array_column in ARRAY_COLUMNS:
  538. alias = get_array_column_alias(array_column)
  539. query = {
  540. "project": [self.project.id],
  541. "field": [f"{alias}.foo"],
  542. "numBuckets": 5,
  543. "precision": 2,
  544. }
  545. response = self.do_request(query)
  546. assert response.status_code == 200, f"failing for {array_column}"
  547. expected = [
  548. (1.00, 1.25, [(f"{alias}.foo", 3)]),
  549. (1.25, 1.50, [(f"{alias}.foo", 0)]),
  550. (1.50, 1.75, [(f"{alias}.foo", 0)]),
  551. (1.75, 2.00, [(f"{alias}.foo", 0)]),
  552. (2.00, 2.25, [(f"{alias}.foo", 1)]),
  553. ]
  554. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  555. def test_histogram_increased_precision_with_min_max(self):
  556. # range is [1.25, 2.24], so it is divided into 5 buckets of width 0.25
  557. specs = [
  558. (1.00, 1.25, [("foo", 3)]),
  559. (2.00, 2.25, [("foo", 1)]),
  560. ]
  561. self.populate_events(specs)
  562. for array_column in ARRAY_COLUMNS:
  563. alias = get_array_column_alias(array_column)
  564. query = {
  565. "project": [self.project.id],
  566. "field": [f"{alias}.foo"],
  567. "numBuckets": 3,
  568. "precision": 2,
  569. "min": 1.25,
  570. "max": 2.00,
  571. }
  572. response = self.do_request(query)
  573. assert response.status_code == 200, f"failing for {array_column}"
  574. expected = [
  575. (1.25, 1.50, [(f"{alias}.foo", 0)]),
  576. (1.50, 1.75, [(f"{alias}.foo", 0)]),
  577. (1.75, 2.00, [(f"{alias}.foo", 0)]),
  578. ]
  579. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  580. def test_histogram_increased_precision_large_buckets(self):
  581. # range is [10.0000, 59.9999] so it is divided into 5 buckets of width 10
  582. specs = [
  583. (10.0000, 10.0000, [("foo", 1)]),
  584. (30.0000, 40.0000, [("foo", 1)]),
  585. (59.9999, 59.9999, [("foo", 2)]),
  586. ]
  587. self.populate_events(specs)
  588. for array_column in ARRAY_COLUMNS:
  589. alias = get_array_column_alias(array_column)
  590. query = {
  591. "project": [self.project.id],
  592. "field": [f"{alias}.foo"],
  593. "numBuckets": 5,
  594. "precision": 4,
  595. }
  596. response = self.do_request(query)
  597. assert response.status_code == 200, f"failing for {array_column}"
  598. expected = [
  599. (10.0000, 20.0000, [(f"{alias}.foo", 1)]),
  600. (20.0000, 30.0000, [(f"{alias}.foo", 0)]),
  601. (30.0000, 40.0000, [(f"{alias}.foo", 1)]),
  602. (40.0000, 50.0000, [(f"{alias}.foo", 0)]),
  603. (50.0000, 60.0000, [(f"{alias}.foo", 2)]),
  604. ]
  605. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  606. def test_histogram_multiple_measures(self):
  607. # range is [10, 59] so it is divided into 5 buckets of width 10
  608. specs = [
  609. (10, 10, [("bar", 0), ("baz", 0), ("foo", 1)]),
  610. (30, 40, [("bar", 2), ("baz", 0), ("foo", 0)]),
  611. (59, 59, [("bar", 0), ("baz", 1), ("foo", 0)]),
  612. ]
  613. self.populate_events(specs)
  614. for array_column in ARRAY_COLUMNS:
  615. alias = get_array_column_alias(array_column)
  616. query = {
  617. "project": [self.project.id],
  618. "field": [f"{alias}.bar", f"{alias}.baz", f"{alias}.foo"],
  619. "numBuckets": 5,
  620. }
  621. response = self.do_request(query)
  622. assert response.status_code == 200, f"failing for {array_column}"
  623. expected = [
  624. (
  625. 10,
  626. 20,
  627. [
  628. (f"{alias}.bar", 0),
  629. (f"{alias}.baz", 0),
  630. (f"{alias}.foo", 1),
  631. ],
  632. ),
  633. (
  634. 20,
  635. 30,
  636. [
  637. (f"{alias}.bar", 0),
  638. (f"{alias}.baz", 0),
  639. (f"{alias}.foo", 0),
  640. ],
  641. ),
  642. (
  643. 30,
  644. 40,
  645. [
  646. (f"{alias}.bar", 2),
  647. (f"{alias}.baz", 0),
  648. (f"{alias}.foo", 0),
  649. ],
  650. ),
  651. (
  652. 40,
  653. 50,
  654. [
  655. (f"{alias}.bar", 0),
  656. (f"{alias}.baz", 0),
  657. (f"{alias}.foo", 0),
  658. ],
  659. ),
  660. (
  661. 50,
  662. 60,
  663. [
  664. (f"{alias}.bar", 0),
  665. (f"{alias}.baz", 1),
  666. (f"{alias}.foo", 0),
  667. ],
  668. ),
  669. ]
  670. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  671. def test_histogram_max_value_on_edge(self):
  672. # range is [11, 21] so it is divided into 5 buckets of width 5
  673. # because using buckets of width 2 will exclude 21, and the next
  674. # nice number is 5
  675. specs = [
  676. (11, 11, [("bar", 0), ("baz", 0), ("foo", 1)]),
  677. (21, 21, [("bar", 1), ("baz", 1), ("foo", 1)]),
  678. ]
  679. self.populate_events(specs)
  680. for array_column in ARRAY_COLUMNS:
  681. alias = get_array_column_alias(array_column)
  682. query = {
  683. "project": [self.project.id],
  684. "field": [f"{alias}.bar", f"{alias}.baz", f"{alias}.foo"],
  685. "numBuckets": 5,
  686. }
  687. response = self.do_request(query)
  688. assert response.status_code == 200, f"failing for {array_column}"
  689. expected = [
  690. (
  691. 10,
  692. 15,
  693. [
  694. (f"{alias}.bar", 0),
  695. (f"{alias}.baz", 0),
  696. (f"{alias}.foo", 1),
  697. ],
  698. ),
  699. (
  700. 15,
  701. 20,
  702. [
  703. (f"{alias}.bar", 0),
  704. (f"{alias}.baz", 0),
  705. (f"{alias}.foo", 0),
  706. ],
  707. ),
  708. (
  709. 20,
  710. 25,
  711. [
  712. (f"{alias}.bar", 1),
  713. (f"{alias}.baz", 1),
  714. (f"{alias}.foo", 1),
  715. ],
  716. ),
  717. ]
  718. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  719. def test_histogram_bins_exceed_max(self):
  720. specs = [
  721. (10, 15, [("bar", 0), ("baz", 0), ("foo", 1)]),
  722. (30, 30, [("bar", 1), ("baz", 1), ("foo", 1)]),
  723. ]
  724. self.populate_events(specs)
  725. for array_column in ARRAY_COLUMNS:
  726. alias = get_array_column_alias(array_column)
  727. query = {
  728. "project": [self.project.id],
  729. "field": [f"{alias}.bar", f"{alias}.baz", f"{alias}.foo"],
  730. "numBuckets": 5,
  731. "min": 10,
  732. "max": 21,
  733. }
  734. response = self.do_request(query)
  735. assert response.status_code == 200, f"failing for {array_column}"
  736. expected = [
  737. (
  738. 10,
  739. 15,
  740. [
  741. (f"{alias}.bar", 0),
  742. (f"{alias}.baz", 0),
  743. (f"{alias}.foo", 1),
  744. ],
  745. ),
  746. (
  747. 15,
  748. 20,
  749. [
  750. (f"{alias}.bar", 0),
  751. (f"{alias}.baz", 0),
  752. (f"{alias}.foo", 0),
  753. ],
  754. ),
  755. (
  756. 20,
  757. 25,
  758. [
  759. (f"{alias}.bar", 0),
  760. (f"{alias}.baz", 0),
  761. (f"{alias}.foo", 0),
  762. ],
  763. ),
  764. ]
  765. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  766. def test_bad_params_invalid_data_filter(self):
  767. for array_column in ARRAY_COLUMNS:
  768. alias = get_array_column_alias(array_column)
  769. query = {
  770. "project": [self.project.id],
  771. "field": [f"{alias}.foo", f"{alias}.bar"],
  772. "numBuckets": 10,
  773. "dataFilter": "invalid",
  774. }
  775. response = self.do_request(query)
  776. assert response.status_code == 400, f"failing for {array_column}"
  777. assert response.data == {
  778. "dataFilter": ['"invalid" is not a valid choice.'],
  779. }, f"failing for {array_column}"
  780. def test_histogram_all_data_filter(self):
  781. specs = [
  782. (0, 1, [("foo", 4)]),
  783. (4000, 5000, [("foo", 1)]),
  784. ]
  785. self.populate_events(specs)
  786. for array_column in ARRAY_COLUMNS:
  787. alias = get_array_column_alias(array_column)
  788. query = {
  789. "project": [self.project.id],
  790. "field": [f"{alias}.foo"],
  791. "numBuckets": 5,
  792. "dataFilter": "all",
  793. }
  794. response = self.do_request(query)
  795. assert response.status_code == 200, f"failing for {array_column}"
  796. expected = [
  797. (0, 1000, [(f"{alias}.foo", 4)]),
  798. (1000, 2000, [(f"{alias}.foo", 0)]),
  799. (2000, 3000, [(f"{alias}.foo", 0)]),
  800. (3000, 4000, [(f"{alias}.foo", 0)]),
  801. (4000, 5000, [(f"{alias}.foo", 1)]),
  802. ]
  803. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  804. def test_histogram_exclude_outliers_data_filter(self):
  805. specs = [
  806. (0, 0, [("foo", 4)]),
  807. (4000, 4001, [("foo", 1)]),
  808. ]
  809. self.populate_events(specs)
  810. for array_column in ARRAY_COLUMNS:
  811. alias = get_array_column_alias(array_column)
  812. query = {
  813. "project": [self.project.id],
  814. "field": [f"{alias}.foo"],
  815. "numBuckets": 5,
  816. "dataFilter": "exclude_outliers",
  817. }
  818. response = self.do_request(query)
  819. assert response.status_code == 200, f"failing for {array_column}"
  820. expected = [
  821. (0, 1, [(f"{alias}.foo", 4)]),
  822. ]
  823. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  824. def test_histogram_missing_measurement_data(self):
  825. # make sure there is at least one transaction
  826. specs = [
  827. (0, 1, [("foo", 1)]),
  828. ]
  829. self.populate_events(specs)
  830. for array_column in ARRAY_COLUMNS:
  831. alias = get_array_column_alias(array_column)
  832. query = {
  833. "project": [self.project.id],
  834. # make sure to query a measurement that does not exist
  835. "field": [f"{alias}.bar"],
  836. "numBuckets": 5,
  837. "dataFilter": "exclude_outliers",
  838. }
  839. response = self.do_request(query)
  840. assert response.status_code == 200, f"failing for {array_column}"
  841. expected = [
  842. (0, 1, [(f"{alias}.bar", 0)]),
  843. (1, 1, [(f"{alias}.bar", 0)]),
  844. (2, 2, [(f"{alias}.bar", 0)]),
  845. (3, 3, [(f"{alias}.bar", 0)]),
  846. (4, 4, [(f"{alias}.bar", 0)]),
  847. ]
  848. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  849. def test_histogram_missing_measurement_data_with_explicit_bounds(self):
  850. # make sure there is at least one transaction
  851. specs = [
  852. (0, 1, [("foo", 1)]),
  853. ]
  854. self.populate_events(specs)
  855. for array_column in ARRAY_COLUMNS:
  856. alias = get_array_column_alias(array_column)
  857. query = {
  858. "project": [self.project.id],
  859. # make sure to query a measurement that does not exist
  860. "field": [f"{alias}.bar"],
  861. "numBuckets": 5,
  862. "dataFilter": "exclude_outliers",
  863. "min": 10,
  864. }
  865. response = self.do_request(query)
  866. assert response.status_code == 200, f"failing for {array_column}"
  867. expected = [
  868. (10, 11, [(f"{alias}.bar", 0)]),
  869. (11, 11, [(f"{alias}.bar", 0)]),
  870. (12, 12, [(f"{alias}.bar", 0)]),
  871. (13, 13, [(f"{alias}.bar", 0)]),
  872. (14, 14, [(f"{alias}.bar", 0)]),
  873. ]
  874. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  875. def test_histogram_ignores_aggregate_conditions(self):
  876. # range is [0, 5), so it is divided into 5 buckets of width 1
  877. specs = [
  878. (0, 1, [("foo", 1)]),
  879. (1, 2, [("foo", 1)]),
  880. (2, 3, [("foo", 1)]),
  881. (3, 4, [("foo", 0)]),
  882. (4, 5, [("foo", 1)]),
  883. ]
  884. self.populate_events(specs)
  885. for array_column in ARRAY_COLUMNS:
  886. alias = get_array_column_alias(array_column)
  887. query = {
  888. "project": [self.project.id],
  889. "field": [f"{alias}.foo"],
  890. "numBuckets": 5,
  891. "query": "tpm():>0.001",
  892. }
  893. response = self.do_request(query)
  894. assert response.status_code == 200, f"failing for {array_column}"
  895. expected = [
  896. (0, 1, [(f"{alias}.foo", 1)]),
  897. (1, 2, [(f"{alias}.foo", 1)]),
  898. (2, 3, [(f"{alias}.foo", 1)]),
  899. (3, 4, [(f"{alias}.foo", 0)]),
  900. (4, 5, [(f"{alias}.foo", 1)]),
  901. ]
  902. assert response.data == self.as_response_data(expected), f"failing for {array_column}"
  903. def test_histogram_outlier_filtering_with_no_rows(self):
  904. query = {
  905. "project": [self.project.id],
  906. "field": ["transaction.duration"],
  907. "numBuckets": 5,
  908. "dataFilter": "exclude_outliers",
  909. }
  910. response = self.do_request(query)
  911. assert response.status_code == 200
  912. expected = [
  913. (0, 1, [("transaction.duration", 0)]),
  914. ]
  915. assert response.data == self.as_response_data(expected)
  916. @region_silo_test
  917. class OrganizationEventsMetricsEnhancedPerformanceHistogramEndpointTest(
  918. MetricsEnhancedPerformanceTestCase
  919. ):
  920. def setUp(self):
  921. super().setUp()
  922. self.min_ago = iso_format(before_now(minutes=1))
  923. self.features = {}
  924. def populate_events(self, specs):
  925. start = before_now(minutes=5)
  926. for spec in specs:
  927. spec = HistogramSpec(*spec)
  928. for suffix_key, count in spec.fields:
  929. for i in range(count):
  930. self.store_transaction_metric(
  931. (spec.end + spec.start) / 2,
  932. metric=suffix_key,
  933. tags={"transaction": suffix_key},
  934. timestamp=start,
  935. )
  936. def as_response_data(self, specs):
  937. data = {}
  938. for spec in specs:
  939. spec = HistogramSpec(*spec)
  940. for measurement, count in sorted(spec.fields):
  941. if measurement not in data:
  942. data[measurement] = []
  943. data[measurement].append({"bin": spec.start, "count": count})
  944. return data
  945. def do_request(self, query, features=None):
  946. if features is None:
  947. features = {
  948. "organizations:performance-view": True,
  949. "organizations:performance-use-metrics": True,
  950. }
  951. features.update(self.features)
  952. self.login_as(user=self.user)
  953. url = reverse(
  954. "sentry-api-0-organization-events-histogram",
  955. kwargs={"organization_slug": self.organization.slug},
  956. )
  957. with self.feature(features):
  958. return self.client.get(url, query, format="json")
  959. def test_no_projects(self):
  960. response = self.do_request({})
  961. assert response.status_code == 200, response.content
  962. assert response.data == {}
  963. def test_histogram_simple(self):
  964. specs = [
  965. (0, 1, [("transaction.duration", 5)]),
  966. (1, 2, [("transaction.duration", 10)]),
  967. (2, 3, [("transaction.duration", 1)]),
  968. (4, 5, [("transaction.duration", 15)]),
  969. ]
  970. self.populate_events(specs)
  971. query = {
  972. "project": [self.project.id],
  973. "field": ["transaction.duration"],
  974. "numBuckets": 5,
  975. "dataset": "metrics",
  976. }
  977. response = self.do_request(query)
  978. assert response.status_code == 200, response.content
  979. expected = [
  980. (0, 1, [("transaction.duration", 6)]),
  981. (1, 2, [("transaction.duration", 9)]),
  982. (2, 3, [("transaction.duration", 3)]),
  983. (3, 4, [("transaction.duration", 8)]),
  984. (4, 5, [("transaction.duration", 7)]),
  985. ]
  986. # Note metrics data is approximate, these values are based on running the test and asserting the results
  987. expected_response = self.as_response_data(expected)
  988. expected_response["meta"] = {"isMetricsData": True}
  989. assert response.data == expected_response
  990. def test_multi_histogram(self):
  991. specs = [
  992. (0, 1, [("measurements.fcp", 5), ("measurements.lcp", 5)]),
  993. (1, 2, [("measurements.fcp", 5), ("measurements.lcp", 5)]),
  994. ]
  995. self.populate_events(specs)
  996. query = {
  997. "project": [self.project.id],
  998. "field": ["measurements.fcp", "measurements.lcp"],
  999. "numBuckets": 2,
  1000. "dataset": "metrics",
  1001. }
  1002. response = self.do_request(query)
  1003. assert response.status_code == 200, response.content
  1004. expected = [
  1005. (0, 1, [("measurements.fcp", 5), ("measurements.lcp", 5)]),
  1006. (1, 2, [("measurements.fcp", 5), ("measurements.lcp", 5)]),
  1007. ]
  1008. # Note metrics data is approximate, these values are based on running the test and asserting the results
  1009. expected_response = self.as_response_data(expected)
  1010. expected_response["meta"] = {"isMetricsData": True}
  1011. assert response.data == expected_response
  1012. def test_histogram_exclude_outliers_data_filter(self):
  1013. specs = [
  1014. (0, 0, [("transaction.duration", 4)]),
  1015. (1, 1, [("transaction.duration", 4)]),
  1016. (4000, 4001, [("transaction.duration", 1)]),
  1017. ]
  1018. self.populate_events(specs)
  1019. query = {
  1020. "project": [self.project.id],
  1021. "field": ["transaction.duration"],
  1022. "numBuckets": 5,
  1023. "dataFilter": "exclude_outliers",
  1024. "dataset": "metrics",
  1025. }
  1026. response = self.do_request(query)
  1027. assert response.status_code == 200, response.content
  1028. # Metrics approximation means both buckets got merged
  1029. expected = [
  1030. (0, 0, [("transaction.duration", 8)]),
  1031. (1, 2, [("transaction.duration", 0)]),
  1032. (2, 3, [("transaction.duration", 0)]),
  1033. (3, 4, [("transaction.duration", 0)]),
  1034. (4, 5, [("transaction.duration", 0)]),
  1035. ]
  1036. expected_response = self.as_response_data(expected)
  1037. expected_response["meta"] = {"isMetricsData": True}
  1038. assert response.data == expected_response