test_organization_stats_summary.py 27 KB

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