test_organization_stats_summary.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754
  1. import functools
  2. from datetime import datetime, timedelta, timezone
  3. from django.urls import reverse
  4. from sentry.constants import DataCategory
  5. from sentry.testutils.cases import APITestCase, OutcomesSnubaTest
  6. from sentry.testutils.helpers.datetime import freeze_time
  7. from sentry.utils.outcomes import Outcome
  8. class OrganizationStatsSummaryTest(APITestCase, OutcomesSnubaTest):
  9. def setUp(self):
  10. super().setUp()
  11. self.now = datetime(2021, 3, 14, 12, 27, 28, tzinfo=timezone.utc)
  12. self.login_as(user=self.user)
  13. self.org = self.organization
  14. self.org.flags.allow_joinleave = False
  15. self.org.save()
  16. self.org2 = self.create_organization()
  17. self.org3 = self.create_organization()
  18. self.project = self.create_project(
  19. name="bar", teams=[self.create_team(organization=self.org, members=[self.user])]
  20. )
  21. self.project2 = self.create_project(
  22. name="foo", teams=[self.create_team(organization=self.org, members=[self.user])]
  23. )
  24. self.project3 = self.create_project(organization=self.org2)
  25. self.user2 = self.create_user(is_superuser=False)
  26. self.create_member(user=self.user2, organization=self.organization, role="member", teams=[])
  27. self.create_member(user=self.user2, organization=self.org3, role="member", teams=[])
  28. self.project4 = self.create_project(
  29. name="users2sproj",
  30. teams=[self.create_team(organization=self.org, members=[self.user2])],
  31. )
  32. self.store_outcomes(
  33. {
  34. "org_id": self.org.id,
  35. "timestamp": self.now - timedelta(hours=1),
  36. "project_id": self.project.id,
  37. "outcome": Outcome.ACCEPTED,
  38. "reason": "none",
  39. "category": DataCategory.ERROR,
  40. "quantity": 1,
  41. },
  42. 5,
  43. )
  44. self.store_outcomes(
  45. {
  46. "org_id": self.org.id,
  47. "timestamp": self.now - timedelta(hours=1),
  48. "project_id": self.project.id,
  49. "outcome": Outcome.ACCEPTED,
  50. "reason": "none",
  51. "category": DataCategory.DEFAULT, # test that this shows up under error
  52. "quantity": 1,
  53. }
  54. )
  55. self.store_outcomes(
  56. {
  57. "org_id": self.org.id,
  58. "timestamp": self.now - timedelta(hours=1),
  59. "project_id": self.project.id,
  60. "outcome": Outcome.RATE_LIMITED,
  61. "reason": "smart_rate_limit",
  62. "category": DataCategory.ATTACHMENT,
  63. "quantity": 1024,
  64. }
  65. )
  66. self.store_outcomes(
  67. {
  68. "org_id": self.org.id,
  69. "timestamp": self.now - timedelta(hours=1),
  70. "project_id": self.project2.id,
  71. "outcome": Outcome.RATE_LIMITED,
  72. "reason": "smart_rate_limit",
  73. "category": DataCategory.TRANSACTION,
  74. "quantity": 1,
  75. }
  76. )
  77. def do_request(self, query, user=None, org=None):
  78. self.login_as(user=user or self.user)
  79. url = reverse(
  80. "sentry-api-0-organization-stats-summary",
  81. kwargs={"organization_id_or_slug": (org or self.organization).slug},
  82. )
  83. return self.client.get(url, query, format="json")
  84. def test_empty_request(self):
  85. response = self.do_request({})
  86. assert response.status_code == 400, response.content
  87. assert response.data == {"detail": 'At least one "field" is required.'}
  88. def test_inaccessible_project(self):
  89. response = self.do_request({"project": [self.project3.id]})
  90. assert response.status_code == 403, response.content
  91. assert response.data == {"detail": "You do not have permission to perform this action."}
  92. def test_no_projects_available(self):
  93. response = self.do_request(
  94. {
  95. "groupBy": ["project"],
  96. "statsPeriod": "1d",
  97. "interval": "1d",
  98. "field": ["sum(quantity)"],
  99. "category": ["error", "transaction"],
  100. },
  101. user=self.user2,
  102. org=self.org3,
  103. )
  104. assert response.status_code == 400, response.content
  105. assert response.data == {
  106. "detail": "No projects available",
  107. }
  108. def test_unknown_field(self):
  109. response = self.do_request(
  110. {
  111. "field": ["summ(qarntenty)"],
  112. "statsPeriod": "1d",
  113. "interval": "1d",
  114. }
  115. )
  116. assert response.status_code == 400, response.content
  117. assert response.data == {
  118. "detail": 'Invalid field: "summ(qarntenty)"',
  119. }
  120. def test_no_end_param(self):
  121. response = self.do_request(
  122. {"field": ["sum(quantity)"], "interval": "1d", "start": "2021-03-14T00:00:00Z"}
  123. )
  124. assert response.status_code == 400, response.content
  125. assert response.data == {"detail": "start and end are both required"}
  126. @freeze_time(datetime(2021, 3, 14, 12, 27, 28, tzinfo=timezone.utc))
  127. def test_future_request(self):
  128. response = self.do_request(
  129. {
  130. "field": ["sum(quantity)"],
  131. "interval": "1h",
  132. "category": ["error"],
  133. "start": "2021-03-14T15:30:00",
  134. "end": "2021-03-14T16:30:00",
  135. }
  136. )
  137. assert response.status_code == 200, response.content
  138. assert response.data == {
  139. "start": "2021-03-14T12:00:00Z",
  140. "end": "2021-03-14T17:00:00Z",
  141. "projects": [],
  142. }
  143. def test_unknown_category(self):
  144. response = self.do_request(
  145. {
  146. "field": ["sum(quantity)"],
  147. "statsPeriod": "1d",
  148. "interval": "1d",
  149. "category": "scoobydoo",
  150. }
  151. )
  152. assert response.status_code == 400, response.content
  153. assert response.data == {
  154. "detail": 'Invalid category: "scoobydoo"',
  155. }
  156. def test_unknown_outcome(self):
  157. response = self.do_request(
  158. {
  159. "field": ["sum(quantity)"],
  160. "statsPeriod": "1d",
  161. "interval": "1d",
  162. "category": "error",
  163. "outcome": "scoobydoo",
  164. }
  165. )
  166. assert response.status_code == 400, response.content
  167. assert response.data == {
  168. "detail": 'Invalid outcome: "scoobydoo"',
  169. }
  170. def test_resolution_invalid(self):
  171. self.login_as(user=self.user)
  172. make_request = functools.partial(
  173. self.client.get,
  174. reverse("sentry-api-0-organization-stats-summary", args=[self.org.slug]),
  175. )
  176. response = make_request(
  177. {
  178. "statsPeriod": "1d",
  179. "interval": "bad_interval",
  180. }
  181. )
  182. assert response.status_code == 400, response.content
  183. @freeze_time("2021-03-14T12:27:28.303Z")
  184. def test_attachment_filter_only(self):
  185. response = self.do_request(
  186. {
  187. "project": [-1],
  188. "statsPeriod": "1d",
  189. "interval": "1d",
  190. "field": ["sum(quantity)"],
  191. "category": ["error", "attachment"],
  192. }
  193. )
  194. assert response.status_code == 400, response.content
  195. assert response.data == {
  196. "detail": "if filtering by attachment no other category may be present"
  197. }
  198. @freeze_time("2021-03-14T12:27:28.303Z")
  199. def test_user_all_accessible(self):
  200. response = self.do_request(
  201. {
  202. "project": self.project.id,
  203. "statsPeriod": "1d",
  204. "interval": "1d",
  205. "field": ["sum(quantity)"],
  206. "category": ["error", "transaction"],
  207. },
  208. user=self.user2,
  209. )
  210. assert response.status_code == 403
  211. response = self.do_request(
  212. {
  213. "project": [-1],
  214. "statsPeriod": "1d",
  215. "interval": "1d",
  216. "field": ["sum(quantity)"],
  217. "category": ["error", "transaction"],
  218. },
  219. user=self.user2,
  220. )
  221. assert response.status_code == 200
  222. assert response.data == {
  223. "start": "2021-03-13T00:00:00Z",
  224. "end": "2021-03-15T00:00:00Z",
  225. "projects": [],
  226. }
  227. @freeze_time("2021-03-14T12:27:28.303Z")
  228. def test_no_project_access(self):
  229. user = self.create_user(is_superuser=False)
  230. self.create_member(user=user, organization=self.organization, role="member", teams=[])
  231. response = self.do_request(
  232. {
  233. "project": [self.project.id],
  234. "statsPeriod": "1d",
  235. "interval": "1d",
  236. "category": ["error", "transaction"],
  237. "field": ["sum(quantity)"],
  238. },
  239. org=self.organization,
  240. user=user,
  241. )
  242. assert response.status_code == 403, response.content
  243. assert response.data == {"detail": "You do not have permission to perform this action."}
  244. @freeze_time("2021-03-14T12:27:28.303Z")
  245. def test_open_membership_semantics(self):
  246. self.org.flags.allow_joinleave = True
  247. self.org.save()
  248. response = self.do_request(
  249. {
  250. "project": [-1],
  251. "statsPeriod": "1d",
  252. "interval": "1d",
  253. "field": ["sum(quantity)"],
  254. "category": ["error", "transaction"],
  255. "groupBy": ["project"],
  256. },
  257. user=self.user2,
  258. )
  259. assert response.status_code == 200
  260. assert response.data == {
  261. "start": "2021-03-13T00:00:00Z",
  262. "end": "2021-03-15T00:00:00Z",
  263. "projects": [
  264. {
  265. "id": self.project.id,
  266. "slug": self.project.slug,
  267. "stats": [
  268. {
  269. "category": "error",
  270. "outcomes": {
  271. "abuse": 0,
  272. "accepted": 6,
  273. "cardinality_limited": 0,
  274. "client_discard": 0,
  275. "filtered": 0,
  276. "invalid": 0,
  277. "rate_limited": 0,
  278. },
  279. "totals": {"dropped": 0, "sum(quantity)": 6},
  280. }
  281. ],
  282. },
  283. {
  284. "id": self.project2.id,
  285. "slug": self.project2.slug,
  286. "stats": [
  287. {
  288. "category": "transaction",
  289. "outcomes": {
  290. "abuse": 0,
  291. "accepted": 0,
  292. "cardinality_limited": 0,
  293. "client_discard": 0,
  294. "filtered": 0,
  295. "invalid": 0,
  296. "rate_limited": 1,
  297. },
  298. "totals": {"dropped": 1, "sum(quantity)": 1},
  299. }
  300. ],
  301. },
  302. ],
  303. }
  304. @freeze_time("2021-03-14T12:27:28.303Z")
  305. def test_org_simple(self):
  306. make_request = functools.partial(
  307. self.client.get,
  308. reverse("sentry-api-0-organization-stats-summary", args=[self.org.slug]),
  309. )
  310. response = make_request(
  311. {
  312. "statsPeriod": "2d",
  313. "interval": "1d",
  314. "field": ["sum(quantity)"],
  315. }
  316. )
  317. assert response.status_code == 200, response.content
  318. assert response.data == {
  319. "start": "2021-03-12T00:00:00Z",
  320. "end": "2021-03-15T00:00:00Z",
  321. "projects": [
  322. {
  323. "id": self.project.id,
  324. "slug": self.project.slug,
  325. "stats": [
  326. {
  327. "category": "attachment",
  328. "outcomes": {
  329. "accepted": 0,
  330. "filtered": 0,
  331. "rate_limited": 1024,
  332. "invalid": 0,
  333. "abuse": 0,
  334. "client_discard": 0,
  335. "cardinality_limited": 0,
  336. },
  337. "totals": {"dropped": 1024, "sum(quantity)": 1024},
  338. },
  339. {
  340. "category": "error",
  341. "outcomes": {
  342. "accepted": 6,
  343. "filtered": 0,
  344. "rate_limited": 0,
  345. "invalid": 0,
  346. "abuse": 0,
  347. "client_discard": 0,
  348. "cardinality_limited": 0,
  349. },
  350. "totals": {"dropped": 0, "sum(quantity)": 6},
  351. },
  352. ],
  353. },
  354. {
  355. "id": self.project2.id,
  356. "slug": self.project2.slug,
  357. "stats": [
  358. {
  359. "category": "transaction",
  360. "outcomes": {
  361. "accepted": 0,
  362. "filtered": 0,
  363. "rate_limited": 1,
  364. "invalid": 0,
  365. "abuse": 0,
  366. "client_discard": 0,
  367. "cardinality_limited": 0,
  368. },
  369. "totals": {"dropped": 1, "sum(quantity)": 1},
  370. }
  371. ],
  372. },
  373. ],
  374. }
  375. @freeze_time("2021-03-14T12:27:28.303Z")
  376. def test_org_multiple_fields(self):
  377. make_request = functools.partial(
  378. self.client.get,
  379. reverse("sentry-api-0-organization-stats-summary", args=[self.org.slug]),
  380. )
  381. response = make_request(
  382. {
  383. "statsPeriod": "2d",
  384. "interval": "1d",
  385. "field": ["sum(quantity)", "sum(times_seen)"],
  386. }
  387. )
  388. assert response.status_code == 200, response.content
  389. assert response.data == {
  390. "start": "2021-03-12T00:00:00Z",
  391. "end": "2021-03-15T00:00:00Z",
  392. "projects": [
  393. {
  394. "id": self.project.id,
  395. "slug": self.project.slug,
  396. "stats": [
  397. {
  398. "category": "attachment",
  399. "outcomes": {
  400. "accepted": 0,
  401. "filtered": 0,
  402. "rate_limited": 1025,
  403. "invalid": 0,
  404. "abuse": 0,
  405. "client_discard": 0,
  406. "cardinality_limited": 0,
  407. },
  408. "totals": {
  409. "dropped": 1025,
  410. "sum(quantity)": 1024,
  411. "sum(times_seen)": 1,
  412. },
  413. },
  414. {
  415. "category": "error",
  416. "outcomes": {
  417. "accepted": 12,
  418. "filtered": 0,
  419. "rate_limited": 0,
  420. "invalid": 0,
  421. "abuse": 0,
  422. "client_discard": 0,
  423. "cardinality_limited": 0,
  424. },
  425. "totals": {"dropped": 0, "sum(quantity)": 6, "sum(times_seen)": 6},
  426. },
  427. ],
  428. },
  429. {
  430. "id": self.project2.id,
  431. "slug": self.project2.slug,
  432. "stats": [
  433. {
  434. "category": "transaction",
  435. "outcomes": {
  436. "accepted": 0,
  437. "filtered": 0,
  438. "rate_limited": 2,
  439. "invalid": 0,
  440. "abuse": 0,
  441. "client_discard": 0,
  442. "cardinality_limited": 0,
  443. },
  444. "totals": {"dropped": 2, "sum(quantity)": 1, "sum(times_seen)": 1},
  445. }
  446. ],
  447. },
  448. ],
  449. }
  450. @freeze_time("2021-03-14T12:27:28.303Z")
  451. def test_org_project_totals_per_project(self):
  452. make_request = functools.partial(
  453. self.client.get,
  454. reverse("sentry-api-0-organization-stats-summary", args=[self.org.slug]),
  455. )
  456. response_per_group = make_request(
  457. {
  458. "statsPeriod": "1d",
  459. "interval": "1h",
  460. "field": ["sum(times_seen)"],
  461. "category": ["error", "transaction"],
  462. }
  463. )
  464. assert response_per_group.status_code == 200, response_per_group.content
  465. assert response_per_group.data == {
  466. "start": "2021-03-13T12:00:00Z",
  467. "end": "2021-03-14T13:00:00Z",
  468. "projects": [
  469. {
  470. "id": self.project.id,
  471. "slug": self.project.slug,
  472. "stats": [
  473. {
  474. "category": "error",
  475. "outcomes": {
  476. "abuse": 0,
  477. "accepted": 6,
  478. "cardinality_limited": 0,
  479. "client_discard": 0,
  480. "filtered": 0,
  481. "invalid": 0,
  482. "rate_limited": 0,
  483. },
  484. "totals": {"dropped": 0, "sum(times_seen)": 6},
  485. }
  486. ],
  487. },
  488. {
  489. "id": self.project2.id,
  490. "slug": self.project2.slug,
  491. "stats": [
  492. {
  493. "category": "transaction",
  494. "outcomes": {
  495. "abuse": 0,
  496. "accepted": 0,
  497. "cardinality_limited": 0,
  498. "client_discard": 0,
  499. "filtered": 0,
  500. "invalid": 0,
  501. "rate_limited": 1,
  502. },
  503. "totals": {"dropped": 1, "sum(times_seen)": 1},
  504. }
  505. ],
  506. },
  507. ],
  508. }
  509. @freeze_time("2021-03-14T12:27:28.303Z")
  510. def test_project_filter(self):
  511. make_request = functools.partial(
  512. self.client.get,
  513. reverse("sentry-api-0-organization-stats-summary", args=[self.org.slug]),
  514. )
  515. response = make_request(
  516. {
  517. "project": self.project.id,
  518. "statsPeriod": "1d",
  519. "interval": "1d",
  520. "field": ["sum(quantity)"],
  521. "category": ["error", "transaction"],
  522. }
  523. )
  524. assert response.status_code == 200, response.content
  525. assert response.data == {
  526. "start": "2021-03-13T00:00:00Z",
  527. "end": "2021-03-15T00:00:00Z",
  528. "projects": [
  529. {
  530. "id": self.project.id,
  531. "slug": self.project.slug,
  532. "stats": [
  533. {
  534. "category": "error",
  535. "outcomes": {
  536. "abuse": 0,
  537. "accepted": 6,
  538. "cardinality_limited": 0,
  539. "client_discard": 0,
  540. "filtered": 0,
  541. "invalid": 0,
  542. "rate_limited": 0,
  543. },
  544. "totals": {"dropped": 0, "sum(quantity)": 6},
  545. },
  546. ],
  547. },
  548. ],
  549. }
  550. @freeze_time("2021-03-14T12:27:28.303Z")
  551. def test_reason_filter(self):
  552. make_request = functools.partial(
  553. self.client.get,
  554. reverse("sentry-api-0-organization-stats-summary", args=[self.org.slug]),
  555. )
  556. response = make_request(
  557. {
  558. "statsPeriod": "1d",
  559. "interval": "1d",
  560. "field": ["sum(times_seen)"],
  561. "reason": ["spike_protection"],
  562. "groupBy": ["category"],
  563. }
  564. )
  565. assert response.status_code == 200, response.content
  566. assert response.data == {
  567. "start": "2021-03-13T00:00:00Z",
  568. "end": "2021-03-15T00:00:00Z",
  569. "projects": [
  570. {
  571. "id": self.project.id,
  572. "slug": self.project.slug,
  573. "stats": [
  574. {
  575. "category": "attachment",
  576. "reason": "spike_protection",
  577. "outcomes": {
  578. "accepted": 0,
  579. "filtered": 0,
  580. "rate_limited": 1,
  581. "invalid": 0,
  582. "abuse": 0,
  583. "client_discard": 0,
  584. "cardinality_limited": 0,
  585. },
  586. "totals": {"dropped": 1, "sum(times_seen)": 1},
  587. }
  588. ],
  589. },
  590. {
  591. "id": self.project2.id,
  592. "slug": self.project2.slug,
  593. "stats": [
  594. {
  595. "category": "transaction",
  596. "reason": "spike_protection",
  597. "outcomes": {
  598. "accepted": 0,
  599. "filtered": 0,
  600. "rate_limited": 1,
  601. "invalid": 0,
  602. "abuse": 0,
  603. "client_discard": 0,
  604. "cardinality_limited": 0,
  605. },
  606. "totals": {"dropped": 1, "sum(times_seen)": 1},
  607. }
  608. ],
  609. },
  610. ],
  611. }
  612. @freeze_time("2021-03-14T12:27:28.303Z")
  613. def test_outcome_filter(self):
  614. make_request = functools.partial(
  615. self.client.get,
  616. reverse("sentry-api-0-organization-stats-summary", args=[self.org.slug]),
  617. )
  618. response = make_request(
  619. {
  620. "statsPeriod": "1d",
  621. "interval": "1d",
  622. "field": ["sum(quantity)"],
  623. "outcome": "accepted",
  624. "category": ["error", "transaction"],
  625. }
  626. )
  627. assert response.status_code == 200, response.content
  628. assert response.data == {
  629. "start": "2021-03-13T00:00:00Z",
  630. "end": "2021-03-15T00:00:00Z",
  631. "projects": [
  632. {
  633. "id": self.project.id,
  634. "slug": self.project.slug,
  635. "stats": [
  636. {
  637. "category": "error",
  638. "outcomes": {
  639. "accepted": 6,
  640. },
  641. "totals": {"sum(quantity)": 6},
  642. }
  643. ],
  644. }
  645. ],
  646. }
  647. @freeze_time("2021-03-14T12:27:28.303Z")
  648. def test_category_filter(self):
  649. make_request = functools.partial(
  650. self.client.get,
  651. reverse("sentry-api-0-organization-stats-summary", args=[self.org.slug]),
  652. )
  653. response = make_request(
  654. {
  655. "statsPeriod": "1d",
  656. "interval": "1d",
  657. "field": ["sum(quantity)"],
  658. "category": "error",
  659. }
  660. )
  661. assert response.status_code == 200, response.content
  662. assert response.data == {
  663. "start": "2021-03-13T00:00:00Z",
  664. "end": "2021-03-15T00:00:00Z",
  665. "projects": [
  666. {
  667. "id": self.project.id,
  668. "slug": self.project.slug,
  669. "stats": [
  670. {
  671. "category": "error",
  672. "outcomes": {
  673. "accepted": 6,
  674. "filtered": 0,
  675. "rate_limited": 0,
  676. "invalid": 0,
  677. "abuse": 0,
  678. "client_discard": 0,
  679. "cardinality_limited": 0,
  680. },
  681. "totals": {"dropped": 0, "sum(quantity)": 6},
  682. }
  683. ],
  684. }
  685. ],
  686. }
  687. def test_download(self):
  688. make_request = functools.partial(
  689. self.client.get,
  690. reverse("sentry-api-0-organization-stats-summary", args=[self.org.slug]),
  691. )
  692. response = make_request(
  693. {
  694. "statsPeriod": "2d",
  695. "interval": "1d",
  696. "field": ["sum(quantity)", "sum(times_seen)"],
  697. "download": True,
  698. }
  699. )
  700. assert response.headers["Content-Type"] == "text/csv"
  701. assert response.headers["Content-Disposition"] == 'attachment; filename="stats_summary.csv"'
  702. assert response.status_code == 200