123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742 |
- import functools
- from datetime import datetime, timedelta, timezone
- from django.urls import reverse
- from freezegun import freeze_time
- from sentry.constants import DataCategory
- from sentry.testutils.cases import APITestCase, OutcomesSnubaTest
- from sentry.testutils.silo import region_silo_test
- from sentry.utils.outcomes import Outcome
- @region_silo_test
- class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
- def setUp(self):
- super().setUp()
- self.now = datetime(2021, 3, 14, 12, 27, 28, tzinfo=timezone.utc)
- self.login_as(user=self.user)
- self.org = self.organization
- self.org.flags.allow_joinleave = False
- self.org.save()
- self.org2 = self.create_organization()
- self.org3 = self.create_organization()
- self.project = self.create_project(
- name="bar", teams=[self.create_team(organization=self.org, members=[self.user])]
- )
- self.project2 = self.create_project(
- name="foo", teams=[self.create_team(organization=self.org, members=[self.user])]
- )
- self.project3 = self.create_project(organization=self.org2)
- self.user2 = self.create_user(is_superuser=False)
- self.create_member(user=self.user2, organization=self.organization, role="member", teams=[])
- self.create_member(user=self.user2, organization=self.org3, role="member", teams=[])
- self.project4 = self.create_project(
- name="users2sproj",
- teams=[self.create_team(organization=self.org, members=[self.user2])],
- )
- self.store_outcomes(
- {
- "org_id": self.org.id,
- "timestamp": self.now - timedelta(hours=1),
- "project_id": self.project.id,
- "outcome": Outcome.ACCEPTED,
- "reason": "none",
- "category": DataCategory.ERROR,
- "quantity": 1,
- },
- 5,
- )
- self.store_outcomes(
- {
- "org_id": self.org.id,
- "timestamp": self.now - timedelta(hours=1),
- "project_id": self.project.id,
- "outcome": Outcome.ACCEPTED,
- "reason": "none",
- "category": DataCategory.DEFAULT, # test that this shows up under error
- "quantity": 1,
- }
- )
- self.store_outcomes(
- {
- "org_id": self.org.id,
- "timestamp": self.now - timedelta(hours=1),
- "project_id": self.project.id,
- "outcome": Outcome.RATE_LIMITED,
- "reason": "smart_rate_limit",
- "category": DataCategory.ATTACHMENT,
- "quantity": 1024,
- }
- )
- self.store_outcomes(
- {
- "org_id": self.org.id,
- "timestamp": self.now - timedelta(hours=1),
- "project_id": self.project2.id,
- "outcome": Outcome.RATE_LIMITED,
- "reason": "smart_rate_limit",
- "category": DataCategory.TRANSACTION,
- "quantity": 1,
- }
- )
- def do_request(self, query, user=None, org=None):
- self.login_as(user=user or self.user)
- url = reverse(
- "sentry-api-0-organization-stats-v2",
- kwargs={"organization_slug": (org or self.organization).slug},
- )
- return self.client.get(url, query, format="json")
- def test_empty_request(self):
- response = self.do_request({})
- assert response.status_code == 400, response.content
- assert result_sorted(response.data) == {"detail": 'At least one "field" is required.'}
- def test_inaccessible_project(self):
- response = self.do_request({"project": [self.project3.id]})
- assert response.status_code == 403, response.content
- assert result_sorted(response.data) == {
- "detail": "You do not have permission to perform this action."
- }
- def test_no_projects_available(self):
- response = self.do_request(
- {
- "groupBy": ["project"],
- "statsPeriod": "1d",
- "interval": "1d",
- "field": ["sum(quantity)"],
- "category": ["error", "transaction"],
- },
- user=self.user2,
- org=self.org3,
- )
- assert response.status_code == 400, response.content
- assert result_sorted(response.data) == {
- "detail": "No projects available",
- }
- def test_unknown_field(self):
- response = self.do_request(
- {
- "field": ["summ(qarntenty)"],
- "statsPeriod": "1d",
- "interval": "1d",
- }
- )
- assert response.status_code == 400, response.content
- assert result_sorted(response.data) == {
- "detail": 'Invalid field: "summ(qarntenty)"',
- }
- def test_no_end_param(self):
- response = self.do_request(
- {"field": ["sum(quantity)"], "interval": "1d", "start": "2021-03-14T00:00:00Z"}
- )
- assert response.status_code == 400, response.content
- assert result_sorted(response.data) == {"detail": "start and end are both required"}
- @freeze_time(datetime(2021, 3, 14, 12, 27, 28, tzinfo=timezone.utc))
- def test_future_request(self):
- response = self.do_request(
- {
- "field": ["sum(quantity)"],
- "interval": "1h",
- "category": ["error"],
- "start": "2021-03-14T15:30:00",
- "end": "2021-03-14T16:30:00",
- }
- )
- assert response.status_code == 200, response.content
- assert result_sorted(response.data) == {
- "intervals": ["2021-03-14T12:00:00Z"],
- "groups": [
- {"by": {}, "series": {"sum(quantity)": [0]}, "totals": {"sum(quantity)": 0}}
- ],
- "start": "2021-03-14T12:00:00Z",
- "end": "2021-03-14T12:28:00Z",
- }
- def test_unknown_category(self):
- response = self.do_request(
- {
- "field": ["sum(quantity)"],
- "statsPeriod": "1d",
- "interval": "1d",
- "category": "scoobydoo",
- }
- )
- assert response.status_code == 400, response.content
- assert result_sorted(response.data) == {
- "detail": 'Invalid category: "scoobydoo"',
- }
- def test_unknown_outcome(self):
- response = self.do_request(
- {
- "field": ["sum(quantity)"],
- "statsPeriod": "1d",
- "interval": "1d",
- "category": "error",
- "outcome": "scoobydoo",
- }
- )
- assert response.status_code == 400, response.content
- assert result_sorted(response.data) == {
- "detail": 'Invalid outcome: "scoobydoo"',
- }
- def test_unknown_groupby(self):
- response = self.do_request(
- {
- "field": ["sum(quantity)"],
- "groupBy": ["category_"],
- "statsPeriod": "1d",
- "interval": "1d",
- }
- )
- assert response.status_code == 400, response.content
- assert result_sorted(response.data) == {"detail": 'Invalid groupBy: "category_"'}
- def test_resolution_invalid(self):
- self.login_as(user=self.user)
- make_request = functools.partial(
- self.client.get,
- reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
- )
- response = make_request(
- {
- "statsPeriod": "1d",
- "interval": "bad_interval",
- }
- )
- assert response.status_code == 400, response.content
- @freeze_time("2021-03-14T12:27:28.303Z")
- def test_attachment_filter_only(self):
- response = self.do_request(
- {
- "project": [-1],
- "statsPeriod": "1d",
- "interval": "1d",
- "field": ["sum(quantity)"],
- "category": ["error", "attachment"],
- }
- )
- assert response.status_code == 400, response.content
- assert result_sorted(response.data) == {
- "detail": "if filtering by attachment no other category may be present"
- }
- @freeze_time("2021-03-14T12:27:28.303Z")
- def test_timeseries_interval(self):
- response = self.do_request(
- {
- "project": [-1],
- "category": ["error"],
- "statsPeriod": "1d",
- "interval": "1d",
- "field": ["sum(quantity)"],
- }
- )
- assert response.status_code == 200, response.content
- assert result_sorted(response.data) == {
- "intervals": ["2021-03-14T00:00:00Z"],
- "groups": [
- {"by": {}, "series": {"sum(quantity)": [6]}, "totals": {"sum(quantity)": 6}}
- ],
- "start": "2021-03-14T00:00:00Z",
- "end": "2021-03-14T12:28:00Z",
- }
- response = self.do_request(
- {
- "project": [-1],
- "statsPeriod": "1d",
- "interval": "6h",
- "field": ["sum(quantity)"],
- "category": ["error"],
- }
- )
- assert response.status_code == 200, response.content
- assert result_sorted(response.data) == {
- "intervals": [
- "2021-03-13T18:00:00Z",
- "2021-03-14T00:00:00Z",
- "2021-03-14T06:00:00Z",
- "2021-03-14T12:00:00Z",
- ],
- "groups": [
- {
- "by": {},
- "series": {"sum(quantity)": [0, 0, 6, 0]},
- "totals": {"sum(quantity)": 6},
- }
- ],
- "start": "2021-03-13T18:00:00Z",
- "end": "2021-03-14T12:28:00Z",
- }
- @freeze_time("2021-03-14T12:27:28.303Z")
- def test_user_org_total_all_accessible(self):
- response = self.do_request(
- {
- "project": [-1],
- "statsPeriod": "1d",
- "interval": "1d",
- "field": ["sum(quantity)"],
- "category": ["error", "transaction"],
- },
- user=self.user2,
- )
- assert response.status_code == 200, response.content
- assert result_sorted(response.data) == {
- "start": "2021-03-14T00:00:00Z",
- "end": "2021-03-14T12:28:00Z",
- "intervals": ["2021-03-14T00:00:00Z"],
- "groups": [
- {"by": {}, "series": {"sum(quantity)": [7]}, "totals": {"sum(quantity)": 7}}
- ],
- }
- @freeze_time("2021-03-14T12:27:28.303Z")
- def test_user_no_proj_specific_access(self):
- response = self.do_request(
- {
- "project": self.project.id,
- "statsPeriod": "1d",
- "interval": "1d",
- "field": ["sum(quantity)"],
- "category": ["error", "transaction"],
- },
- user=self.user2,
- )
- assert response.status_code == 403
- response = self.do_request(
- {
- "project": [-1],
- "statsPeriod": "1d",
- "interval": "1d",
- "field": ["sum(quantity)"],
- "category": ["error", "transaction"],
- "groupBy": ["project"],
- },
- user=self.user2,
- )
- assert response.status_code == 200
- assert result_sorted(response.data) == {
- "start": "2021-03-14T00:00:00Z",
- "end": "2021-03-14T12:28:00Z",
- "groups": [],
- }
- @freeze_time("2021-03-14T12:27:28.303Z")
- def test_no_project_access(self):
- user = self.create_user(is_superuser=False)
- self.create_member(user=user, organization=self.organization, role="member", teams=[])
- response = self.do_request(
- {
- "project": [self.project.id],
- "statsPeriod": "1d",
- "interval": "1d",
- "category": ["error", "transaction"],
- "field": ["sum(quantity)"],
- },
- org=self.organization,
- user=user,
- )
- assert response.status_code == 403, response.content
- assert result_sorted(response.data) == {
- "detail": "You do not have permission to perform this action."
- }
- response = self.do_request(
- {
- "project": [self.project.id],
- "groupBy": ["project"],
- "statsPeriod": "1d",
- "interval": "1d",
- "category": ["error", "transaction"],
- "field": ["sum(quantity)"],
- },
- org=self.organization,
- user=user,
- )
- assert response.status_code == 403, response.content
- assert result_sorted(response.data) == {
- "detail": "You do not have permission to perform this action."
- }
- @freeze_time("2021-03-14T12:27:28.303Z")
- def test_open_membership_semantics(self):
- self.org.flags.allow_joinleave = True
- self.org.save()
- response = self.do_request(
- {
- "project": [-1],
- "statsPeriod": "1d",
- "interval": "1d",
- "field": ["sum(quantity)"],
- "category": ["error", "transaction"],
- "groupBy": ["project"],
- },
- user=self.user2,
- )
- assert response.status_code == 200
- assert result_sorted(response.data) == {
- "start": "2021-03-14T00:00:00Z",
- "end": "2021-03-14T12:28:00Z",
- "groups": [
- {
- "by": {"project": self.project.id},
- "totals": {"sum(quantity)": 6},
- },
- {
- "by": {"project": self.project2.id},
- "totals": {"sum(quantity)": 1},
- },
- ],
- }
- @freeze_time("2021-03-14T12:27:28.303Z")
- def test_org_simple(self):
- make_request = functools.partial(
- self.client.get, reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug])
- )
- response = make_request(
- {
- "statsPeriod": "2d",
- "interval": "1d",
- "field": ["sum(quantity)"],
- "groupBy": ["category", "outcome", "reason"],
- }
- )
- assert response.status_code == 200, response.content
- assert result_sorted(response.data) == {
- "start": "2021-03-13T00:00:00Z",
- "end": "2021-03-14T12:28:00Z",
- "intervals": ["2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"],
- "groups": [
- {
- "by": {
- "outcome": "rate_limited",
- "reason": "spike_protection",
- "category": "attachment",
- },
- "totals": {"sum(quantity)": 1024},
- "series": {"sum(quantity)": [0, 1024]},
- },
- {
- "by": {"outcome": "accepted", "reason": "none", "category": "error"},
- "totals": {"sum(quantity)": 6},
- "series": {"sum(quantity)": [0, 6]},
- },
- {
- "by": {
- "category": "transaction",
- "reason": "spike_protection",
- "outcome": "rate_limited",
- },
- "totals": {"sum(quantity)": 1},
- "series": {"sum(quantity)": [0, 1]},
- },
- ],
- }
- @freeze_time("2021-03-14T12:27:28.303Z")
- def test_org_multiple_fields(self):
- make_request = functools.partial(
- self.client.get, reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug])
- )
- response = make_request(
- {
- "statsPeriod": "2d",
- "interval": "1d",
- "field": ["sum(quantity)", "sum(times_seen)"],
- "groupBy": ["category", "outcome", "reason"],
- }
- )
- assert response.status_code == 200, response.content
- assert result_sorted(response.data) == {
- "start": "2021-03-13T00:00:00Z",
- "end": "2021-03-14T12:28:00Z",
- "intervals": ["2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"],
- "groups": [
- {
- "by": {
- "outcome": "rate_limited",
- "category": "attachment",
- "reason": "spike_protection",
- },
- "totals": {"sum(quantity)": 1024, "sum(times_seen)": 1},
- "series": {"sum(quantity)": [0, 1024], "sum(times_seen)": [0, 1]},
- },
- {
- "by": {"outcome": "accepted", "reason": "none", "category": "error"},
- "totals": {"sum(quantity)": 6, "sum(times_seen)": 6},
- "series": {"sum(quantity)": [0, 6], "sum(times_seen)": [0, 6]},
- },
- {
- "by": {
- "category": "transaction",
- "reason": "spike_protection",
- "outcome": "rate_limited",
- },
- "totals": {"sum(quantity)": 1, "sum(times_seen)": 1},
- "series": {"sum(quantity)": [0, 1], "sum(times_seen)": [0, 1]},
- },
- ],
- }
- @freeze_time("2021-03-14T12:27:28.303Z")
- def test_org_group_by_project(self):
- make_request = functools.partial(
- self.client.get,
- reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
- )
- response = make_request(
- {
- "statsPeriod": "1d",
- "interval": "1d",
- "field": ["sum(times_seen)"],
- "groupBy": ["project"],
- "category": ["error", "transaction"],
- }
- )
- assert response.status_code == 200, response.content
- assert result_sorted(response.data) == {
- "start": "2021-03-14T00:00:00Z",
- "end": "2021-03-14T12:28:00Z",
- "groups": [
- {
- "by": {"project": self.project.id},
- "totals": {"sum(times_seen)": 6},
- },
- {
- "by": {"project": self.project2.id},
- "totals": {"sum(times_seen)": 1},
- },
- ],
- }
- @freeze_time("2021-03-14T12:27:28.303Z")
- def test_org_project_totals_per_project(self):
- make_request = functools.partial(
- self.client.get,
- reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
- )
- response_per_group = make_request(
- {
- "statsPeriod": "1d",
- "interval": "1h",
- "field": ["sum(times_seen)"],
- "groupBy": ["project"],
- "category": ["error", "transaction"],
- }
- )
- response_total = make_request(
- {
- "statsPeriod": "1d",
- "interval": "1h",
- "field": ["sum(times_seen)"],
- "category": ["error", "transaction"],
- }
- )
- per_group_total = 0
- for total in response_per_group.data["groups"]:
- per_group_total += total["totals"]["sum(times_seen)"]
- assert response_per_group.status_code == 200, response_per_group.content
- assert response_total.status_code == 200, response_total.content
- assert response_total.data["groups"][0]["totals"]["sum(times_seen)"] == per_group_total
- @freeze_time("2021-03-14T12:27:28.303Z")
- def test_project_filter(self):
- make_request = functools.partial(
- self.client.get,
- reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
- )
- response = make_request(
- {
- "project": self.project.id,
- "statsPeriod": "1d",
- "interval": "1d",
- "field": ["sum(quantity)"],
- "category": ["error", "transaction"],
- }
- )
- assert response.status_code == 200, response.content
- assert result_sorted(response.data) == {
- "start": "2021-03-14T00:00:00Z",
- "end": "2021-03-14T12:28:00Z",
- "intervals": ["2021-03-14T00:00:00Z"],
- "groups": [
- {"by": {}, "totals": {"sum(quantity)": 6}, "series": {"sum(quantity)": [6]}}
- ],
- }
- @freeze_time("2021-03-14T12:27:28.303Z")
- def test_reason_filter(self):
- make_request = functools.partial(
- self.client.get,
- reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
- )
- response = make_request(
- {
- "statsPeriod": "1d",
- "interval": "1d",
- "field": ["sum(times_seen)"],
- "reason": ["spike_protection"],
- "groupBy": ["category"],
- }
- )
- assert response.status_code == 200, response.content
- assert result_sorted(response.data) == {
- "start": "2021-03-14T00:00:00Z",
- "end": "2021-03-14T12:28:00Z",
- "intervals": ["2021-03-14T00:00:00Z"],
- "groups": [
- {
- "by": {"category": "attachment"},
- "totals": {"sum(times_seen)": 1},
- "series": {"sum(times_seen)": [1]},
- },
- {
- "by": {"category": "transaction"},
- "totals": {"sum(times_seen)": 1},
- "series": {"sum(times_seen)": [1]},
- },
- ],
- }
- @freeze_time("2021-03-14T12:27:28.303Z")
- def test_outcome_filter(self):
- make_request = functools.partial(
- self.client.get,
- reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
- )
- response = make_request(
- {
- "statsPeriod": "1d",
- "interval": "1d",
- "field": ["sum(quantity)"],
- "outcome": "accepted",
- "category": ["error", "transaction"],
- }
- )
- assert response.status_code == 200, response.content
- assert result_sorted(response.data) == {
- "start": "2021-03-14T00:00:00Z",
- "end": "2021-03-14T12:28:00Z",
- "intervals": ["2021-03-14T00:00:00Z"],
- "groups": [
- {"by": {}, "totals": {"sum(quantity)": 6}, "series": {"sum(quantity)": [6]}}
- ],
- }
- @freeze_time("2021-03-14T12:27:28.303Z")
- def test_category_filter(self):
- make_request = functools.partial(
- self.client.get,
- reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
- )
- response = make_request(
- {
- "statsPeriod": "1d",
- "interval": "1d",
- "field": ["sum(quantity)"],
- "category": "error",
- }
- )
- assert response.status_code == 200, response.content
- assert result_sorted(response.data) == {
- "start": "2021-03-14T00:00:00Z",
- "end": "2021-03-14T12:28:00Z",
- "intervals": ["2021-03-14T00:00:00Z"],
- "groups": [
- {"by": {}, "totals": {"sum(quantity)": 6}, "series": {"sum(quantity)": [6]}}
- ],
- }
- @freeze_time("2021-03-14T12:27:28.303Z")
- def test_minute_interval(self):
- make_request = functools.partial(
- self.client.get,
- reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
- )
- response = make_request(
- {
- "statsPeriod": "1h",
- "interval": "15m",
- "field": ["sum(quantity)"],
- "category": "error",
- }
- )
- assert response.status_code == 200, response.content
- assert result_sorted(response.data) == {
- "start": "2021-03-14T11:00:00Z",
- "end": "2021-03-14T12:28:00Z",
- "intervals": [
- "2021-03-14T11:00:00Z",
- "2021-03-14T11:15:00Z",
- "2021-03-14T11:30:00Z",
- "2021-03-14T11:45:00Z",
- "2021-03-14T12:00:00Z",
- "2021-03-14T12:15:00Z",
- ],
- "groups": [
- {
- "by": {},
- "totals": {"sum(quantity)": 6},
- "series": {"sum(quantity)": [0, 6, 0, 0, 0, 0]},
- }
- ],
- }
- def result_sorted(result):
- """sort the groups of the results array by the `by` object, ensuring a stable order"""
- def stable_dict(d):
- return tuple(sorted(d.items(), key=lambda t: t[0]))
- if "groups" in result:
- result["groups"].sort(key=lambda group: stable_dict(group["by"]))
- return result
- # TEST invalid parameter
|