from datetime import timedelta from django.urls import reverse from rest_framework.exceptions import ErrorDetail from sentry.testutils.cases import APITestCase, SnubaTestCase from sentry.testutils.helpers.datetime import before_now, iso_format from sentry.testutils.silo import region_silo_test from sentry.utils.samples import load_data @region_silo_test class OrganizationEventsSpansHistogramEndpointTest(APITestCase, SnubaTestCase): FEATURES = ["organizations:performance-span-histogram-view"] URL = "sentry-api-0-organization-events-spans-histogram" def setUp(self): super().setUp() self.features = {} self.login_as(user=self.user) self.org = self.create_organization(owner=self.user) self.project = self.create_project(organization=self.org) self.url = reverse( self.URL, kwargs={"organization_slug": self.org.slug}, ) self.min_ago = before_now(minutes=1).replace(microsecond=0) def create_event(self, **kwargs): if "spans" not in kwargs: kwargs["spans"] = [ { "same_process_as_parent": True, "parent_span_id": "a" * 16, "span_id": x * 16, "start_timestamp": iso_format(self.min_ago + timedelta(seconds=1)), "timestamp": iso_format(self.min_ago + timedelta(seconds=4)), "op": "django.middleware", "description": "middleware span", "hash": "cd" * 8, "exclusive_time": 3.0, } for x in ["b", "c"] ] + [ { "same_process_as_parent": True, "parent_span_id": "a" * 16, "span_id": x * 16, "start_timestamp": iso_format(self.min_ago + timedelta(seconds=4)), "timestamp": iso_format(self.min_ago + timedelta(seconds=5)), "op": "django.middleware", "description": "middleware span", "hash": "cd" * 8, "exclusive_time": 10.0, } for x in ["d", "e", "f"] ] data = load_data("transaction", **kwargs) data["transaction"] = "root transaction" return self.store_event(data, project_id=self.project.id) def format_span(self, op, group): return f"{op}:{group}" def do_request(self, query, with_feature=True): features = self.FEATURES if with_feature else [] with self.feature(features): return self.client.get(self.url, query, format="json") def test_no_feature(self): query = { "projects": [-1], "span": self.format_span("django.middleware", "cd" * 8), "numBuckets": 50, } response = self.do_request(query, False) assert response.status_code == 404 def test_no_projects(self): query = { "projects": [-1], "span": self.format_span("django.middleware", "cd" * 8), "numBuckets": 50, } response = self.do_request(query) assert response.status_code == 200 assert response.data == {} def test_bad_params_missing_span(self): query = { "project": [self.project.id], "numBuckets": 50, } response = self.do_request(query) assert response.status_code == 400 assert response.data == {"span": [ErrorDetail("This field is required.", code="required")]} def test_bad_params_missing_num_buckets(self): query = { "project": [self.project.id], "span": self.format_span("django.middleware", "cd" * 8), } response = self.do_request(query) assert response.status_code == 400 assert response.data == { "numBuckets": [ErrorDetail("This field is required.", code="required")] } def test_bad_params_invalid_num_buckets(self): query = { "project": [self.project.id], "span": self.format_span("django.middleware", "cd" * 8), "numBuckets": "foo", } response = self.do_request(query) assert response.status_code == 400, "failing for numBuckets" assert response.data == { "numBuckets": ["A valid integer is required."] }, "failing for numBuckets" def test_bad_params_outside_range_num_buckets(self): query = { "project": [self.project.id], "span": self.format_span("django.middleware", "cd" * 8), "numBuckets": -1, } response = self.do_request(query) assert response.status_code == 400, "failing for numBuckets" assert response.data == { "numBuckets": ["Ensure this value is greater than or equal to 1."] }, "failing for numBuckets" def test_bad_params_num_buckets_too_large(self): query = { "project": [self.project.id], "span": self.format_span("django.middleware", "cd" * 8), "numBuckets": 101, } response = self.do_request(query) assert response.status_code == 400, "failing for numBuckets" assert response.data == { "numBuckets": ["Ensure this value is less than or equal to 100."] }, "failing for numBuckets" def test_bad_params_invalid_precision_too_small(self): query = { "project": [self.project.id], "span": self.format_span("django.middleware", "cd" * 8), "numBuckets": 50, "precision": -1, } response = self.do_request(query) assert response.status_code == 400, "failing for precision" assert response.data == { "precision": ["Ensure this value is greater than or equal to 0."], }, "failing for precision" def test_bad_params_invalid_precision_too_big(self): query = { "project": [self.project.id], "span": self.format_span("django.middleware", "cd" * 8), "numBuckets": 50, "precision": 100, } response = self.do_request(query) assert response.status_code == 400, "failing for precision" assert response.data == { "precision": ["Ensure this value is less than or equal to 4."], }, "failing for precision" def test_bad_params_reverse_min_max(self): query = { "project": [self.project.id], "span": self.format_span("django.middleware", "cd" * 8), "numBuckets": 50, "min": 10, "max": 5, } response = self.do_request(query) assert response.data == {"non_field_errors": ["min cannot be greater than max."]} def test_bad_params_invalid_min(self): query = { "project": [self.project.id], "span": self.format_span("django.middleware", "cd" * 8), "numBuckets": 50, "min": "foo", } response = self.do_request(query) assert response.status_code == 400, "failing for min" assert response.data == {"min": ["A valid number is required."]}, "failing for min" def test_bad_params_invalid_max(self): query = { "project": [self.project.id], "span": self.format_span("django.middleware", "cd" * 8), "numBuckets": 50, "max": "bar", } response = self.do_request(query) assert response.status_code == 400, "failing for max" assert response.data == {"max": ["A valid number is required."]}, "failing for max" def test_bad_params_invalid_data_filter(self): query = { "project": [self.project.id], "span": self.format_span("django.middleware", "cd" * 8), "numBuckets": 50, "dataFilter": "invalid", } response = self.do_request(query) assert response.status_code == 400, "failing for dataFilter" assert response.data == { "dataFilter": ['"invalid" is not a valid choice.'] }, "failing for dataFilter" def test_histogram_empty(self): num_buckets = 5 query = { "project": [self.project.id], "span": self.format_span("django.view", "cd" * 8), "numBuckets": num_buckets, } expected_empty_response = [{"bin": i, "count": 0} for i in range(num_buckets)] response = self.do_request(query) assert response.status_code == 200, response.content assert response.data == expected_empty_response def test_histogram(self): self.create_event() num_buckets = 50 query = { "project": [self.project.id], "span": self.format_span("django.middleware", "cd" * 8), "numBuckets": num_buckets, } response = self.do_request(query) assert response.status_code == 200, response.content for bucket in response.data: if bucket["bin"] == 3: assert bucket["count"] == 2 elif bucket["bin"] == 10: assert bucket["count"] == 3 else: assert bucket["count"] == 0 def test_histogram_using_min_max(self): self.create_event() num_buckets = 10 min = 5 max = 11 query = { "project": [self.project.id], "span": self.format_span("django.middleware", "cd" * 8), "numBuckets": num_buckets, "min": min, "max": max, } response = self.do_request(query) assert response.status_code == 200, response.content for bucket in response.data: if bucket["bin"] == 10: assert bucket["count"] == 3 else: assert bucket["count"] == 0 assert response.data[0]["bin"] == min assert response.data[-1]["bin"] == max - 1 def test_histogram_using_given_min_above_queried_max(self): self.create_event() num_buckets = 10 min = 12 query = { "project": [self.project.id], "span": self.format_span("django.middleware", "cd" * 8), "numBuckets": num_buckets, "min": min, } response = self.do_request(query) assert response.status_code == 200 for bucket in response.data: assert bucket["count"] == 0 assert response.data[0] == {"bin": min, "count": 0} assert len(response.data) == 1 def test_histogram_using_given_max_below_queried_min(self): self.create_event() num_buckets = 10 max = 2 query = { "project": [self.project.id], "span": self.format_span("django.middleware", "cd" * 8), "numBuckets": num_buckets, "max": max, } response = self.do_request(query) assert response.status_code == 200 for bucket in response.data: assert bucket["count"] == 0 assert response.data[-1] == {"bin": max - 1, "count": 0} def test_histogram_all_data_filter(self): # populate with default spans self.create_event() spans = [ { "same_process_as_parent": True, "parent_span_id": "a" * 16, "span_id": "e" * 16, "start_timestamp": iso_format(self.min_ago + timedelta(seconds=1)), "timestamp": iso_format(self.min_ago + timedelta(seconds=4)), "op": "django.middleware", "description": "middleware span", "hash": "cd" * 8, "exclusive_time": 60.0, } ] # populate with an outlier span self.create_event(spans=spans) query = { "project": [self.project.id], "span": self.format_span("django.middleware", "cd" * 8), "numBuckets": 10, "dataFilter": "all", } response = self.do_request(query) assert response.status_code == 200 assert response.data[-1] == {"bin": 60, "count": 1} def test_histogram_exclude_outliers_data_filter(self): # populate with default spans self.create_event() spans = [ { "same_process_as_parent": True, "parent_span_id": "a" * 16, "span_id": "e" * 16, "start_timestamp": iso_format(self.min_ago + timedelta(seconds=1)), "timestamp": iso_format(self.min_ago + timedelta(seconds=4)), "op": "django.middleware", "description": "middleware span", "hash": "cd" * 8, "exclusive_time": 60.0, } ] # populate with an outlier span self.create_event(spans=spans) query = { "project": [self.project.id], "span": self.format_span("django.middleware", "cd" * 8), "numBuckets": 10, "dataFilter": "exclude_outliers", } response = self.do_request(query) assert response.status_code == 200 assert response.data[-1]["bin"] != 60