test_organization_spans_aggregation.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603
  1. import hashlib
  2. from datetime import timedelta
  3. from unittest import mock
  4. from uuid import uuid4
  5. from django.urls import reverse
  6. from snuba_sdk import Column, Condition, Function, Op
  7. from sentry.api.endpoints.organization_spans_aggregation import NULL_GROUP
  8. from sentry.testutils.cases import APITestCase, SnubaTestCase
  9. from sentry.testutils.helpers.datetime import before_now
  10. from sentry.utils.samples import load_data
  11. MOCK_SNUBA_RESPONSE = {
  12. "data": [
  13. {
  14. "transaction_id": "80fe542aea4945ffbe612646987ee449",
  15. "count": 71,
  16. "spans": [
  17. [
  18. "root_1",
  19. 1,
  20. "parent_1",
  21. "e238e6c2e2466b07",
  22. "e238e6c2e2466b07",
  23. "api/0/foo",
  24. "other",
  25. "2023-09-13 17:12:19",
  26. 100,
  27. 1000,
  28. 1000,
  29. ],
  30. [
  31. "B1",
  32. 0,
  33. "root_1",
  34. "B",
  35. "B",
  36. "connect",
  37. "db",
  38. "2023-09-13 17:12:19",
  39. 150,
  40. 50,
  41. 50.0,
  42. ],
  43. [
  44. "C1",
  45. 0,
  46. "root_1",
  47. "C",
  48. "C",
  49. "resolve_conditions",
  50. "discover.endpoint",
  51. "2023-09-13 17:12:19",
  52. 155,
  53. 0,
  54. 10.0,
  55. ],
  56. [
  57. "D1",
  58. 0,
  59. "C1",
  60. "D",
  61. "D",
  62. "resolve_orderby",
  63. "discover.snql",
  64. "2023-09-13 17:12:19",
  65. 157,
  66. 0,
  67. 20.0,
  68. ],
  69. [
  70. "E1",
  71. 0,
  72. "C1",
  73. NULL_GROUP,
  74. "E",
  75. "resolve_columns",
  76. "discover.snql",
  77. "2023-09-13 17:12:19",
  78. 157,
  79. 0,
  80. 20.0,
  81. ],
  82. ],
  83. },
  84. {
  85. "transaction_id": "86b21833d1854d9b811000b91e7fccfa",
  86. "count": 71,
  87. "spans": [
  88. [
  89. "root_2",
  90. 1,
  91. "parent_2",
  92. "e238e6c2e2466b07",
  93. "e238e6c2e2466b07",
  94. "bind_organization_context",
  95. "other",
  96. "2023-09-13 17:12:39",
  97. 100,
  98. 700,
  99. 0.0,
  100. ],
  101. [
  102. "B2",
  103. 0,
  104. "root_2",
  105. "B",
  106. "B",
  107. "connect",
  108. "db",
  109. "2023-09-13 17:12:39",
  110. 110,
  111. 10,
  112. 30.0,
  113. ],
  114. [
  115. "C2",
  116. 0,
  117. "root_2",
  118. "C",
  119. "C",
  120. "resolve_conditions",
  121. "discover.endpoint",
  122. "2023-09-13 17:12:39",
  123. 115,
  124. 0,
  125. 40.0,
  126. ],
  127. [
  128. "D2",
  129. 0,
  130. "C2",
  131. "D",
  132. "D",
  133. "resolve_orderby",
  134. "discover.snql",
  135. "2023-09-13 17:12:39",
  136. 150,
  137. 0,
  138. 10.0,
  139. ],
  140. [
  141. "D2-duplicate",
  142. 0,
  143. "C2",
  144. "D",
  145. "D",
  146. "resolve_orderby",
  147. "discover.snql",
  148. "2023-09-13 17:12:40",
  149. 155,
  150. 0,
  151. 20.0,
  152. ],
  153. [
  154. "E2",
  155. 0,
  156. "C2",
  157. NULL_GROUP,
  158. "E",
  159. "resolve_columns",
  160. "discover.snql",
  161. "2023-09-13 17:12:39",
  162. 157,
  163. 0,
  164. 20.0,
  165. ],
  166. ],
  167. },
  168. ]
  169. }
  170. class OrganizationSpansAggregationTest(APITestCase, SnubaTestCase):
  171. url_name = "sentry-api-0-organization-spans-aggregation"
  172. FEATURES = [
  173. "organizations:starfish-aggregate-span-waterfall",
  174. "organizations:performance-view",
  175. ]
  176. def get_start_end(self, duration):
  177. return self.day_ago, self.day_ago + timedelta(milliseconds=duration)
  178. def create_event(
  179. self,
  180. trace,
  181. transaction,
  182. spans,
  183. parent_span_id,
  184. project_id,
  185. tags=None,
  186. duration=4000,
  187. span_id=None,
  188. measurements=None,
  189. trace_context=None,
  190. environment=None,
  191. **kwargs,
  192. ):
  193. start, end = self.get_start_end(duration)
  194. data = load_data(
  195. "transaction",
  196. trace=trace,
  197. spans=spans,
  198. timestamp=end,
  199. start_timestamp=start,
  200. trace_context=trace_context,
  201. )
  202. data["transaction"] = transaction
  203. data["contexts"]["trace"]["parent_span_id"] = parent_span_id
  204. if span_id:
  205. data["contexts"]["trace"]["span_id"] = span_id
  206. if measurements:
  207. for key, value in measurements.items():
  208. data["measurements"][key]["value"] = value
  209. if tags is not None:
  210. data["tags"] = tags
  211. if environment is not None:
  212. data["environment"] = environment
  213. with self.feature(self.FEATURES):
  214. return self.store_event(data, project_id=project_id, **kwargs)
  215. def setUp(self):
  216. super().setUp()
  217. self.login_as(user=self.user)
  218. self.day_ago = before_now(days=1).replace(hour=10, minute=0, second=0, microsecond=0)
  219. self.span_ids_event_1 = dict(
  220. zip(["A", "B", "C", "D", "E"], [uuid4().hex[:16] for _ in range(5)])
  221. )
  222. self.trace_id_1 = uuid4().hex
  223. self.root_event_1 = self.create_event(
  224. trace=self.trace_id_1,
  225. trace_context={
  226. "trace_id": self.trace_id_1,
  227. "span_id": self.span_ids_event_1["A"],
  228. "exclusive_time": 100,
  229. },
  230. transaction="api/0/foo",
  231. spans=[
  232. {
  233. "same_process_as_parent": True,
  234. "op": "db",
  235. "description": "connect",
  236. "span_id": self.span_ids_event_1["B"],
  237. "trace_id": self.trace_id_1,
  238. "parent_span_id": self.span_ids_event_1["A"],
  239. "exclusive_time": 50.0,
  240. "data": {
  241. "duration": 0.050,
  242. "offset": 0.050,
  243. "span.group": "B",
  244. "span.description": "connect",
  245. },
  246. "sentry_tags": {
  247. "description": "connect",
  248. },
  249. },
  250. {
  251. "same_process_as_parent": True,
  252. "op": "discover.endpoint",
  253. "description": "resolve_conditions",
  254. "span_id": self.span_ids_event_1["C"],
  255. "trace_id": self.trace_id_1,
  256. "parent_span_id": self.span_ids_event_1["A"],
  257. "exclusive_time": 10,
  258. "data": {
  259. "duration": 0.00,
  260. "offset": 0.055,
  261. "span.group": "C",
  262. "span.description": "connect",
  263. },
  264. "sentry_tags": {
  265. "description": "connect",
  266. },
  267. },
  268. {
  269. "same_process_as_parent": True,
  270. "op": "discover.snql",
  271. "description": "resolve_orderby",
  272. "span_id": self.span_ids_event_1["D"],
  273. "trace_id": self.trace_id_1,
  274. "parent_span_id": self.span_ids_event_1["C"],
  275. "exclusive_time": 20,
  276. "data": {
  277. "duration": 0.00,
  278. "offset": 0.057,
  279. "span.group": "D",
  280. "span.description": "resolve_orderby",
  281. },
  282. "sentry_tags": {
  283. "description": "resolve_orderby",
  284. },
  285. },
  286. {
  287. "same_process_as_parent": True,
  288. "op": "discover.snql",
  289. "description": "resolve_columns",
  290. "span_id": self.span_ids_event_1["E"],
  291. "trace_id": self.trace_id_1,
  292. "parent_span_id": self.span_ids_event_1["C"],
  293. "exclusive_time": 20,
  294. "data": {
  295. "duration": 0.00,
  296. "offset": 0.057,
  297. "span.description": "resolve_columns",
  298. },
  299. },
  300. ],
  301. parent_span_id=None,
  302. project_id=self.project.id,
  303. duration=1000,
  304. environment="production",
  305. )
  306. self.span_ids_event_2 = dict(
  307. zip(["A", "B", "C", "D", "D2", "E"], [uuid4().hex[:16] for _ in range(6)])
  308. )
  309. self.trace_id_2 = uuid4().hex
  310. self.root_event_2 = self.create_event(
  311. trace=self.trace_id_2,
  312. trace_context={
  313. "trace_id": self.trace_id_2,
  314. "span_id": self.span_ids_event_2["A"],
  315. "exclusive_time": 100,
  316. },
  317. transaction="api/0/foo",
  318. spans=[
  319. {
  320. "same_process_as_parent": True,
  321. "op": "db",
  322. "description": "connect",
  323. "span_id": self.span_ids_event_2["B"],
  324. "trace_id": self.trace_id_2,
  325. "parent_span_id": self.span_ids_event_2["A"],
  326. "exclusive_time": 50.0,
  327. "data": {
  328. "duration": 0.010,
  329. "offset": 0.010,
  330. "span.group": "B",
  331. "span.description": "connect",
  332. },
  333. "sentry_tags": {
  334. "description": "connect",
  335. },
  336. },
  337. {
  338. "same_process_as_parent": True,
  339. "op": "discover.endpoint",
  340. "description": "resolve_conditions",
  341. "span_id": self.span_ids_event_2["C"],
  342. "trace_id": self.trace_id_2,
  343. "parent_span_id": self.span_ids_event_2["A"],
  344. "exclusive_time": 10,
  345. "data": {
  346. "duration": 0.00,
  347. "offset": 0.015,
  348. "span.group": "C",
  349. "span.description": "connect",
  350. },
  351. "sentry_tags": {
  352. "description": "connect",
  353. },
  354. },
  355. {
  356. "same_process_as_parent": True,
  357. "op": "discover.snql",
  358. "description": "resolve_orderby",
  359. "span_id": self.span_ids_event_2["D"],
  360. "trace_id": self.trace_id_2,
  361. "parent_span_id": self.span_ids_event_2["C"],
  362. "exclusive_time": 10,
  363. "data": {
  364. "duration": 0.00,
  365. "offset": 0.050,
  366. "span.group": "D",
  367. "span.description": "resolve_orderby",
  368. },
  369. "sentry_tags": {
  370. "description": "resolve_orderby",
  371. },
  372. },
  373. {
  374. "same_process_as_parent": True,
  375. "op": "discover.snql",
  376. "description": "resolve_orderby",
  377. "span_id": self.span_ids_event_2["D2"],
  378. "trace_id": self.trace_id_2,
  379. "parent_span_id": self.span_ids_event_2["C"],
  380. "exclusive_time": 20,
  381. "data": {
  382. "duration": 0.00,
  383. "offset": 1.055,
  384. "span.group": "D",
  385. "span.description": "resolve_orderby",
  386. },
  387. "sentry_tags": {
  388. "description": "resolve_orderby",
  389. },
  390. },
  391. {
  392. "same_process_as_parent": True,
  393. "op": "discover.snql",
  394. "description": "resolve_columns",
  395. "span_id": self.span_ids_event_2["E"],
  396. "trace_id": self.trace_id_2,
  397. "parent_span_id": self.span_ids_event_2["C"],
  398. "exclusive_time": 20,
  399. "data": {
  400. "duration": 0.00,
  401. "offset": 0.057,
  402. "span.description": "resolve_columns",
  403. },
  404. },
  405. ],
  406. parent_span_id=None,
  407. project_id=self.project.id,
  408. duration=700,
  409. environment="development",
  410. )
  411. self.url = reverse(
  412. self.url_name,
  413. kwargs={"organization_slug": self.project.organization.slug},
  414. )
  415. @mock.patch("sentry.api.endpoints.organization_spans_aggregation.raw_snql_query")
  416. def test_simple(self, mock_query):
  417. mock_query.side_effect = [MOCK_SNUBA_RESPONSE]
  418. for backend in ["indexedSpans", "nodestore"]:
  419. with self.feature(self.FEATURES):
  420. response = self.client.get(
  421. self.url,
  422. data={"transaction": "api/0/foo", "backend": backend},
  423. format="json",
  424. )
  425. assert response.data
  426. data = response.data
  427. root_fingerprint = hashlib.md5(b"e238e6c2e2466b07").hexdigest()[:16]
  428. assert root_fingerprint in data
  429. assert data[root_fingerprint]["count()"] == 2
  430. assert data[root_fingerprint]["description"] == "api/0/foo"
  431. assert round(data[root_fingerprint]["avg(duration)"]) == 850
  432. if backend == "indexedSpans":
  433. assert data[root_fingerprint]["samples"] == {
  434. ("80fe542aea4945ffbe612646987ee449", "root_1"),
  435. ("86b21833d1854d9b811000b91e7fccfa", "root_2"),
  436. }
  437. else:
  438. assert data[root_fingerprint]["samples"] == {
  439. (self.root_event_1.event_id, self.span_ids_event_1["A"]),
  440. (self.root_event_2.event_id, self.span_ids_event_2["A"]),
  441. }
  442. fingerprint = hashlib.md5(b"e238e6c2e2466b07-B").hexdigest()[:16]
  443. assert data[fingerprint]["description"] == "connect"
  444. assert round(data[fingerprint]["avg(duration)"]) == 30
  445. fingerprint = hashlib.md5(b"e238e6c2e2466b07-C-D").hexdigest()[:16]
  446. assert data[fingerprint]["description"] == "resolve_orderby"
  447. assert data[fingerprint]["avg(exclusive_time)"] == 15.0
  448. assert data[fingerprint]["count()"] == 2
  449. fingerprint = hashlib.md5(b"e238e6c2e2466b07-C-D2").hexdigest()[:16]
  450. assert data[fingerprint]["description"] == "resolve_orderby"
  451. assert data[fingerprint]["avg(exclusive_time)"] == 20.0
  452. assert data[fingerprint]["count()"] == 1
  453. @mock.patch("sentry.api.endpoints.organization_spans_aggregation.raw_snql_query")
  454. def test_offset_logic(self, mock_query):
  455. mock_query.side_effect = [MOCK_SNUBA_RESPONSE]
  456. for backend in ["indexedSpans", "nodestore"]:
  457. with self.feature(self.FEATURES):
  458. response = self.client.get(
  459. self.url,
  460. data={"transaction": "api/0/foo", "backend": backend},
  461. format="json",
  462. )
  463. assert response.data
  464. data = response.data
  465. root_fingerprint = hashlib.md5(b"e238e6c2e2466b07").hexdigest()[:16]
  466. assert root_fingerprint in data
  467. assert data[root_fingerprint]["avg(absolute_offset)"] == 0.0
  468. fingerprint = hashlib.md5(b"e238e6c2e2466b07-B").hexdigest()[:16]
  469. assert data[fingerprint]["avg(absolute_offset)"] == 30.0
  470. fingerprint = hashlib.md5(b"e238e6c2e2466b07-C").hexdigest()[:16]
  471. assert data[fingerprint]["avg(absolute_offset)"] == 35.0
  472. fingerprint = hashlib.md5(b"e238e6c2e2466b07-C-D").hexdigest()[:16]
  473. assert data[fingerprint]["avg(absolute_offset)"] == 53.5
  474. fingerprint = hashlib.md5(b"e238e6c2e2466b07-C-D2").hexdigest()[:16]
  475. assert data[fingerprint]["avg(absolute_offset)"] == 1075.0
  476. @mock.patch("sentry.api.endpoints.organization_spans_aggregation.raw_snql_query")
  477. def test_null_group_fallback(self, mock_query):
  478. mock_query.side_effect = [MOCK_SNUBA_RESPONSE]
  479. for backend in ["indexedSpans", "nodestore"]:
  480. with self.feature(self.FEATURES):
  481. response = self.client.get(
  482. self.url,
  483. data={"transaction": "api/0/foo", "backend": backend},
  484. format="json",
  485. )
  486. assert response.data
  487. data = response.data
  488. root_fingerprint = hashlib.md5(b"e238e6c2e2466b07-C-discover.snql").hexdigest()[:16]
  489. assert root_fingerprint in data
  490. assert data[root_fingerprint]["description"] == ""
  491. assert data[root_fingerprint]["count()"] == 2
  492. @mock.patch("sentry.api.endpoints.organization_spans_aggregation.raw_snql_query")
  493. def test_http_method_filter(self, mock_query):
  494. with self.feature(self.FEATURES):
  495. response = self.client.get(
  496. self.url,
  497. data={"transaction": "api/0/foo", "backend": "nodestore", "http.method": "GET"},
  498. format="json",
  499. )
  500. assert response.data
  501. data = response.data
  502. root_fingerprint = hashlib.md5(b"e238e6c2e2466b07").hexdigest()[:16]
  503. assert root_fingerprint in data
  504. assert data[root_fingerprint]["count()"] == 2
  505. with self.feature(self.FEATURES):
  506. response = self.client.get(
  507. self.url,
  508. data={"transaction": "api/0/foo", "backend": "nodestore", "http.method": "POST"},
  509. format="json",
  510. )
  511. assert response.data == {}
  512. with self.feature(self.FEATURES):
  513. self.client.get(
  514. self.url,
  515. data={"transaction": "api/0/foo", "backend": "indexedSpans", "http.method": "GET"},
  516. format="json",
  517. )
  518. assert (
  519. Condition(
  520. lhs=Function(
  521. function="ifNull",
  522. parameters=[
  523. Column(
  524. name="tags[transaction.method]",
  525. ),
  526. "",
  527. ],
  528. alias=None,
  529. ),
  530. op=Op.EQ,
  531. rhs="GET",
  532. )
  533. in mock_query.mock_calls[0].args[0].query.where
  534. )
  535. def test_environment_filter(self):
  536. with self.feature(self.FEATURES):
  537. response = self.client.get(
  538. self.url,
  539. data={
  540. "transaction": "api/0/foo",
  541. "backend": "nodestore",
  542. "environment": "production",
  543. },
  544. format="json",
  545. )
  546. assert response.data
  547. data = response.data
  548. root_fingerprint = hashlib.md5(b"e238e6c2e2466b07").hexdigest()[:16]
  549. assert root_fingerprint in data
  550. assert data[root_fingerprint]["count()"] == 1
  551. with self.feature(self.FEATURES):
  552. response = self.client.get(
  553. self.url,
  554. data={
  555. "transaction": "api/0/foo",
  556. "backend": "nodestore",
  557. "environment": ["production", "development"],
  558. },
  559. format="json",
  560. )
  561. assert response.data
  562. data = response.data
  563. root_fingerprint = hashlib.md5(b"e238e6c2e2466b07").hexdigest()[:16]
  564. assert root_fingerprint in data
  565. assert data[root_fingerprint]["count()"] == 2