test_organization_events_histogram.py 43 KB

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