test_organization_stats_summary.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740
  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_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. "client_discard": 0,
  274. "filtered": 0,
  275. "invalid": 0,
  276. "rate_limited": 0,
  277. },
  278. "totals": {"dropped": 0, "sum(quantity)": 6},
  279. }
  280. ],
  281. },
  282. {
  283. "id": self.project2.id,
  284. "slug": self.project2.slug,
  285. "stats": [
  286. {
  287. "category": "transaction",
  288. "outcomes": {
  289. "abuse": 0,
  290. "accepted": 0,
  291. "client_discard": 0,
  292. "filtered": 0,
  293. "invalid": 0,
  294. "rate_limited": 1,
  295. },
  296. "totals": {"dropped": 1, "sum(quantity)": 1},
  297. }
  298. ],
  299. },
  300. ],
  301. }
  302. @freeze_time("2021-03-14T12:27:28.303Z")
  303. def test_org_simple(self):
  304. make_request = functools.partial(
  305. self.client.get,
  306. reverse("sentry-api-0-organization-stats-summary", args=[self.org.slug]),
  307. )
  308. response = make_request(
  309. {
  310. "statsPeriod": "2d",
  311. "interval": "1d",
  312. "field": ["sum(quantity)"],
  313. }
  314. )
  315. assert response.status_code == 200, response.content
  316. assert response.data == {
  317. "start": "2021-03-12T00:00:00Z",
  318. "end": "2021-03-15T00:00:00Z",
  319. "projects": [
  320. {
  321. "id": self.project.id,
  322. "slug": self.project.slug,
  323. "stats": [
  324. {
  325. "category": "attachment",
  326. "outcomes": {
  327. "accepted": 0,
  328. "filtered": 0,
  329. "rate_limited": 1024,
  330. "invalid": 0,
  331. "abuse": 0,
  332. "client_discard": 0,
  333. },
  334. "totals": {"dropped": 1024, "sum(quantity)": 1024},
  335. },
  336. {
  337. "category": "error",
  338. "outcomes": {
  339. "accepted": 6,
  340. "filtered": 0,
  341. "rate_limited": 0,
  342. "invalid": 0,
  343. "abuse": 0,
  344. "client_discard": 0,
  345. },
  346. "totals": {"dropped": 0, "sum(quantity)": 6},
  347. },
  348. ],
  349. },
  350. {
  351. "id": self.project2.id,
  352. "slug": self.project2.slug,
  353. "stats": [
  354. {
  355. "category": "transaction",
  356. "outcomes": {
  357. "accepted": 0,
  358. "filtered": 0,
  359. "rate_limited": 1,
  360. "invalid": 0,
  361. "abuse": 0,
  362. "client_discard": 0,
  363. },
  364. "totals": {"dropped": 1, "sum(quantity)": 1},
  365. }
  366. ],
  367. },
  368. ],
  369. }
  370. @freeze_time("2021-03-14T12:27:28.303Z")
  371. def test_org_multiple_fields(self):
  372. make_request = functools.partial(
  373. self.client.get,
  374. reverse("sentry-api-0-organization-stats-summary", args=[self.org.slug]),
  375. )
  376. response = make_request(
  377. {
  378. "statsPeriod": "2d",
  379. "interval": "1d",
  380. "field": ["sum(quantity)", "sum(times_seen)"],
  381. }
  382. )
  383. assert response.status_code == 200, response.content
  384. assert response.data == {
  385. "start": "2021-03-12T00:00:00Z",
  386. "end": "2021-03-15T00:00:00Z",
  387. "projects": [
  388. {
  389. "id": self.project.id,
  390. "slug": self.project.slug,
  391. "stats": [
  392. {
  393. "category": "attachment",
  394. "outcomes": {
  395. "accepted": 0,
  396. "filtered": 0,
  397. "rate_limited": 1025,
  398. "invalid": 0,
  399. "abuse": 0,
  400. "client_discard": 0,
  401. },
  402. "totals": {
  403. "dropped": 1025,
  404. "sum(quantity)": 1024,
  405. "sum(times_seen)": 1,
  406. },
  407. },
  408. {
  409. "category": "error",
  410. "outcomes": {
  411. "accepted": 12,
  412. "filtered": 0,
  413. "rate_limited": 0,
  414. "invalid": 0,
  415. "abuse": 0,
  416. "client_discard": 0,
  417. },
  418. "totals": {"dropped": 0, "sum(quantity)": 6, "sum(times_seen)": 6},
  419. },
  420. ],
  421. },
  422. {
  423. "id": self.project2.id,
  424. "slug": self.project2.slug,
  425. "stats": [
  426. {
  427. "category": "transaction",
  428. "outcomes": {
  429. "accepted": 0,
  430. "filtered": 0,
  431. "rate_limited": 2,
  432. "invalid": 0,
  433. "abuse": 0,
  434. "client_discard": 0,
  435. },
  436. "totals": {"dropped": 2, "sum(quantity)": 1, "sum(times_seen)": 1},
  437. }
  438. ],
  439. },
  440. ],
  441. }
  442. @freeze_time("2021-03-14T12:27:28.303Z")
  443. def test_org_project_totals_per_project(self):
  444. make_request = functools.partial(
  445. self.client.get,
  446. reverse("sentry-api-0-organization-stats-summary", args=[self.org.slug]),
  447. )
  448. response_per_group = make_request(
  449. {
  450. "statsPeriod": "1d",
  451. "interval": "1h",
  452. "field": ["sum(times_seen)"],
  453. "category": ["error", "transaction"],
  454. }
  455. )
  456. assert response_per_group.status_code == 200, response_per_group.content
  457. assert response_per_group.data == {
  458. "start": "2021-03-13T12:00:00Z",
  459. "end": "2021-03-14T13:00:00Z",
  460. "projects": [
  461. {
  462. "id": self.project.id,
  463. "slug": self.project.slug,
  464. "stats": [
  465. {
  466. "category": "error",
  467. "outcomes": {
  468. "abuse": 0,
  469. "accepted": 6,
  470. "client_discard": 0,
  471. "filtered": 0,
  472. "invalid": 0,
  473. "rate_limited": 0,
  474. },
  475. "totals": {"dropped": 0, "sum(times_seen)": 6},
  476. }
  477. ],
  478. },
  479. {
  480. "id": self.project2.id,
  481. "slug": self.project2.slug,
  482. "stats": [
  483. {
  484. "category": "transaction",
  485. "outcomes": {
  486. "abuse": 0,
  487. "accepted": 0,
  488. "client_discard": 0,
  489. "filtered": 0,
  490. "invalid": 0,
  491. "rate_limited": 1,
  492. },
  493. "totals": {"dropped": 1, "sum(times_seen)": 1},
  494. }
  495. ],
  496. },
  497. ],
  498. }
  499. @freeze_time("2021-03-14T12:27:28.303Z")
  500. def test_project_filter(self):
  501. make_request = functools.partial(
  502. self.client.get,
  503. reverse("sentry-api-0-organization-stats-summary", args=[self.org.slug]),
  504. )
  505. response = make_request(
  506. {
  507. "project": self.project.id,
  508. "statsPeriod": "1d",
  509. "interval": "1d",
  510. "field": ["sum(quantity)"],
  511. "category": ["error", "transaction"],
  512. }
  513. )
  514. assert response.status_code == 200, response.content
  515. assert response.data == {
  516. "start": "2021-03-13T00:00:00Z",
  517. "end": "2021-03-15T00:00:00Z",
  518. "projects": [
  519. {
  520. "id": self.project.id,
  521. "slug": self.project.slug,
  522. "stats": [
  523. {
  524. "category": "error",
  525. "outcomes": {
  526. "abuse": 0,
  527. "accepted": 6,
  528. "client_discard": 0,
  529. "filtered": 0,
  530. "invalid": 0,
  531. "rate_limited": 0,
  532. },
  533. "totals": {"dropped": 0, "sum(quantity)": 6},
  534. },
  535. ],
  536. },
  537. ],
  538. }
  539. @freeze_time("2021-03-14T12:27:28.303Z")
  540. def test_reason_filter(self):
  541. make_request = functools.partial(
  542. self.client.get,
  543. reverse("sentry-api-0-organization-stats-summary", args=[self.org.slug]),
  544. )
  545. response = make_request(
  546. {
  547. "statsPeriod": "1d",
  548. "interval": "1d",
  549. "field": ["sum(times_seen)"],
  550. "reason": ["spike_protection"],
  551. "groupBy": ["category"],
  552. }
  553. )
  554. assert response.status_code == 200, response.content
  555. assert response.data == {
  556. "start": "2021-03-13T00:00:00Z",
  557. "end": "2021-03-15T00:00:00Z",
  558. "projects": [
  559. {
  560. "id": self.project.id,
  561. "slug": self.project.slug,
  562. "stats": [
  563. {
  564. "category": "attachment",
  565. "reason": "spike_protection",
  566. "outcomes": {
  567. "accepted": 0,
  568. "filtered": 0,
  569. "rate_limited": 1,
  570. "invalid": 0,
  571. "abuse": 0,
  572. "client_discard": 0,
  573. },
  574. "totals": {"dropped": 1, "sum(times_seen)": 1},
  575. }
  576. ],
  577. },
  578. {
  579. "id": self.project2.id,
  580. "slug": self.project2.slug,
  581. "stats": [
  582. {
  583. "category": "transaction",
  584. "reason": "spike_protection",
  585. "outcomes": {
  586. "accepted": 0,
  587. "filtered": 0,
  588. "rate_limited": 1,
  589. "invalid": 0,
  590. "abuse": 0,
  591. "client_discard": 0,
  592. },
  593. "totals": {"dropped": 1, "sum(times_seen)": 1},
  594. }
  595. ],
  596. },
  597. ],
  598. }
  599. @freeze_time("2021-03-14T12:27:28.303Z")
  600. def test_outcome_filter(self):
  601. make_request = functools.partial(
  602. self.client.get,
  603. reverse("sentry-api-0-organization-stats-summary", args=[self.org.slug]),
  604. )
  605. response = make_request(
  606. {
  607. "statsPeriod": "1d",
  608. "interval": "1d",
  609. "field": ["sum(quantity)"],
  610. "outcome": "accepted",
  611. "category": ["error", "transaction"],
  612. }
  613. )
  614. assert response.status_code == 200, response.content
  615. assert response.data == {
  616. "start": "2021-03-13T00:00:00Z",
  617. "end": "2021-03-15T00:00:00Z",
  618. "projects": [
  619. {
  620. "id": self.project.id,
  621. "slug": self.project.slug,
  622. "stats": [
  623. {
  624. "category": "error",
  625. "outcomes": {
  626. "accepted": 6,
  627. },
  628. "totals": {"sum(quantity)": 6},
  629. }
  630. ],
  631. }
  632. ],
  633. }
  634. @freeze_time("2021-03-14T12:27:28.303Z")
  635. def test_category_filter(self):
  636. make_request = functools.partial(
  637. self.client.get,
  638. reverse("sentry-api-0-organization-stats-summary", args=[self.org.slug]),
  639. )
  640. response = make_request(
  641. {
  642. "statsPeriod": "1d",
  643. "interval": "1d",
  644. "field": ["sum(quantity)"],
  645. "category": "error",
  646. }
  647. )
  648. assert response.status_code == 200, response.content
  649. assert response.data == {
  650. "start": "2021-03-13T00:00:00Z",
  651. "end": "2021-03-15T00:00:00Z",
  652. "projects": [
  653. {
  654. "id": self.project.id,
  655. "slug": self.project.slug,
  656. "stats": [
  657. {
  658. "category": "error",
  659. "outcomes": {
  660. "accepted": 6,
  661. "filtered": 0,
  662. "rate_limited": 0,
  663. "invalid": 0,
  664. "abuse": 0,
  665. "client_discard": 0,
  666. },
  667. "totals": {"dropped": 0, "sum(quantity)": 6},
  668. }
  669. ],
  670. }
  671. ],
  672. }
  673. def test_download(self):
  674. make_request = functools.partial(
  675. self.client.get,
  676. reverse("sentry-api-0-organization-stats-summary", args=[self.org.slug]),
  677. )
  678. response = make_request(
  679. {
  680. "statsPeriod": "2d",
  681. "interval": "1d",
  682. "field": ["sum(quantity)", "sum(times_seen)"],
  683. "download": True,
  684. }
  685. )
  686. assert response.headers["Content-Type"] == "text/csv"
  687. assert response.headers["Content-Disposition"] == 'attachment; filename="stats_summary.csv"'
  688. assert response.status_code == 200