test_organization_events_histogram.py 42 KB

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