diff --git a/src/sentry/search/eap/spans/formulas.py b/src/sentry/search/eap/spans/formulas.py index 659cd6c7c1c6a4..bd44406f0506a5 100644 --- a/src/sentry/search/eap/spans/formulas.py +++ b/src/sentry/search/eap/spans/formulas.py @@ -36,7 +36,11 @@ ) from sentry.search.eap.types import SearchResolverConfig from sentry.search.eap.utils import literal_validator -from sentry.search.events.constants import WEB_VITALS_PERFORMANCE_SCORE_WEIGHTS +from sentry.search.events.constants import ( + MISERY_ALPHA, + MISERY_BETA, + WEB_VITALS_PERFORMANCE_SCORE_WEIGHTS, +) from sentry.snuba import spans_rpc from sentry.snuba.referrer import Referrer @@ -795,6 +799,234 @@ def eps(_: ResolvedArguments, settings: ResolverSettings) -> Column.BinaryFormul ) +def apdex(args: ResolvedArguments, settings: ResolverSettings) -> Column.BinaryFormula: + """ + Calculate Apdex score based on response time field and threshold. + + Apdex = (Satisfactory + Tolerable/2) / Total Requests + Where: + - Satisfactory: response time ≤ T + - Tolerable: response time > T and ≤ 4T + - Frustrated: response time > 4T + """ + extrapolation_mode = settings["extrapolation_mode"] + + # Get the response time field and threshold + response_time_field = cast(AttributeKey, args[0]) + threshold = cast(float, args[1]) + + # Calculate 4T for tolerable range + tolerable_threshold = threshold * 4 + + # Satisfactory requests: response time ≤ T and is_transaction = True + satisfactory = Column( + conditional_aggregation=AttributeConditionalAggregation( + aggregate=Function.FUNCTION_COUNT, + key=response_time_field, + filter=TraceItemFilter( + and_filter=AndFilter( + filters=[ + TraceItemFilter( + comparison_filter=ComparisonFilter( + key=response_time_field, + op=ComparisonFilter.OP_LESS_THAN_OR_EQUALS, + value=AttributeValue(val_double=threshold), + ) + ), + TraceItemFilter( + comparison_filter=ComparisonFilter( + key=AttributeKey( + type=AttributeKey.TYPE_BOOLEAN, name="sentry.is_segment" + ), + op=ComparisonFilter.OP_EQUALS, + value=AttributeValue(val_bool=True), + ) + ), + ] + ) + ), + extrapolation_mode=extrapolation_mode, + ) + ) + + # Tolerable requests: response time > T and ≤ 4T and is_transaction = True + tolerable = Column( + conditional_aggregation=AttributeConditionalAggregation( + aggregate=Function.FUNCTION_COUNT, + key=response_time_field, + filter=TraceItemFilter( + and_filter=AndFilter( + filters=[ + TraceItemFilter( + comparison_filter=ComparisonFilter( + key=response_time_field, + op=ComparisonFilter.OP_GREATER_THAN, + value=AttributeValue(val_double=threshold), + ) + ), + TraceItemFilter( + comparison_filter=ComparisonFilter( + key=response_time_field, + op=ComparisonFilter.OP_LESS_THAN_OR_EQUALS, + value=AttributeValue(val_double=tolerable_threshold), + ) + ), + TraceItemFilter( + comparison_filter=ComparisonFilter( + key=AttributeKey( + type=AttributeKey.TYPE_BOOLEAN, name="sentry.is_segment" + ), + op=ComparisonFilter.OP_EQUALS, + value=AttributeValue(val_bool=True), + ) + ), + ] + ) + ), + extrapolation_mode=extrapolation_mode, + ) + ) + + # Total requests: count of all requests with the response time field and is_transaction = True + total = Column( + conditional_aggregation=AttributeConditionalAggregation( + aggregate=Function.FUNCTION_COUNT, + key=response_time_field, + filter=TraceItemFilter( + comparison_filter=ComparisonFilter( + key=AttributeKey(type=AttributeKey.TYPE_BOOLEAN, name="sentry.is_segment"), + op=ComparisonFilter.OP_EQUALS, + value=AttributeValue(val_bool=True), + ) + ), + extrapolation_mode=extrapolation_mode, + ) + ) + + # Calculate (Satisfactory + Tolerable/2) / Total + numerator = Column( + formula=Column.BinaryFormula( + left=satisfactory, + op=Column.BinaryFormula.OP_ADD, + right=Column( + formula=Column.BinaryFormula( + left=tolerable, + op=Column.BinaryFormula.OP_DIVIDE, + right=Column(literal=LiteralValue(val_double=2.0)), + ) + ), + ) + ) + + return Column.BinaryFormula( + left=numerator, + op=Column.BinaryFormula.OP_DIVIDE, + right=total, + ) + + +def user_misery(args: ResolvedArguments, settings: ResolverSettings) -> Column.BinaryFormula: + """ + Calculate User Misery score based on response time field and threshold. + + User Misery = (miserable_users + α) / (total_unique_users + α + β) + Where: + - miserable_users: unique users with response time > 4T + - total_unique_users: total unique users with response time field + - α (MISERY_ALPHA) = 5.8875 + - β (MISERY_BETA) = 111.8625 + """ + extrapolation_mode = settings["extrapolation_mode"] + + # Get the response time field and threshold + response_time_field = cast(AttributeKey, args[0]) + threshold = cast(float, args[1]) + + # Calculate 4T for miserable threshold + miserable_threshold = threshold * 4 + + # Count miserable users: unique users with response time > 4T and is_transaction = True + miserable_users = Column( + conditional_aggregation=AttributeConditionalAggregation( + aggregate=Function.FUNCTION_UNIQ, + key=AttributeKey(type=AttributeKey.TYPE_STRING, name="sentry.user"), + filter=TraceItemFilter( + and_filter=AndFilter( + filters=[ + TraceItemFilter( + comparison_filter=ComparisonFilter( + key=response_time_field, + op=ComparisonFilter.OP_GREATER_THAN, + value=AttributeValue(val_double=miserable_threshold), + ) + ), + TraceItemFilter( + comparison_filter=ComparisonFilter( + key=AttributeKey( + type=AttributeKey.TYPE_BOOLEAN, name="sentry.is_segment" + ), + op=ComparisonFilter.OP_EQUALS, + value=AttributeValue(val_bool=True), + ) + ), + ] + ) + ), + extrapolation_mode=extrapolation_mode, + ) + ) + + # Count total unique users: unique users with response time field and is_transaction = True + total_unique_users = Column( + conditional_aggregation=AttributeConditionalAggregation( + aggregate=Function.FUNCTION_UNIQ, + key=AttributeKey(type=AttributeKey.TYPE_STRING, name="sentry.user"), + filter=TraceItemFilter( + and_filter=AndFilter( + filters=[ + TraceItemFilter( + exists_filter=ExistsFilter(key=response_time_field), + ), + TraceItemFilter( + comparison_filter=ComparisonFilter( + key=AttributeKey( + type=AttributeKey.TYPE_BOOLEAN, name="sentry.is_segment" + ), + op=ComparisonFilter.OP_EQUALS, + value=AttributeValue(val_bool=True), + ) + ), + ] + ) + ), + extrapolation_mode=extrapolation_mode, + ) + ) + + # Calculate (miserable_users + α) / (total_unique_users + α + β) + numerator = Column( + formula=Column.BinaryFormula( + left=miserable_users, + op=Column.BinaryFormula.OP_ADD, + right=Column(literal=LiteralValue(val_double=MISERY_ALPHA)), + ) + ) + + denominator = Column( + formula=Column.BinaryFormula( + left=total_unique_users, + op=Column.BinaryFormula.OP_ADD, + right=Column(literal=LiteralValue(val_double=MISERY_ALPHA + MISERY_BETA)), + ) + ) + + return Column.BinaryFormula( + left=numerator, + op=Column.BinaryFormula.OP_DIVIDE, + right=denominator, + ) + + SPAN_FORMULA_DEFINITIONS = { "http_response_rate": FormulaDefinition( default_search_type="percentage", @@ -987,4 +1219,34 @@ def eps(_: ResolvedArguments, settings: ResolverSettings) -> Column.BinaryFormul "eps": FormulaDefinition( default_search_type="rate", arguments=[], formula_resolver=eps, is_aggregate=True ), + "apdex": FormulaDefinition( + default_search_type="number", + infer_search_type_from_arguments=False, + arguments=[ + AttributeArgumentDefinition( + attribute_types={ + "duration", + *constants.DURATION_TYPE, + }, + ), + ValueArgumentDefinition(argument_types={"number"}), + ], + formula_resolver=apdex, + is_aggregate=True, + ), + "user_misery": FormulaDefinition( + default_search_type="number", + infer_search_type_from_arguments=False, + arguments=[ + AttributeArgumentDefinition( + attribute_types={ + "duration", + *constants.DURATION_TYPE, + }, + ), + ValueArgumentDefinition(argument_types={"number"}), + ], + formula_resolver=user_misery, + is_aggregate=True, + ), } diff --git a/tests/snuba/api/endpoints/test_organization_events_span_indexed.py b/tests/snuba/api/endpoints/test_organization_events_span_indexed.py index 9264c0d686242a..4bf3055640f4e3 100644 --- a/tests/snuba/api/endpoints/test_organization_events_span_indexed.py +++ b/tests/snuba/api/endpoints/test_organization_events_span_indexed.py @@ -5879,3 +5879,321 @@ def test_eps(self): assert meta["dataset"] == self.dataset assert meta["units"] == {"description": None, "eps()": "1/second"} assert meta["fields"] == {"description": "string", "eps()": "rate"} + + def test_apdex_function(self): + """Test the apdex function with span.duration and threshold.""" + # Create spans with different durations to test apdex calculation + # Threshold = 1000ms (1 second) + # Satisfactory: ≤ 1000ms + # Tolerable: > 1000ms and ≤ 4000ms + # Frustrated: > 4000ms + spans = [ + # Satisfactory spans (≤ 1000ms) + self.create_span( + {"description": "http.server", "is_segment": True}, + start_ts=self.ten_mins_ago, + duration=500, # 500ms - satisfactory + ), + self.create_span( + {"description": "http.server", "is_segment": True}, + start_ts=self.ten_mins_ago, + duration=1000, # 1000ms - satisfactory (at threshold) + ), + # Tolerable spans (> 1000ms and ≤ 4000ms) + self.create_span( + {"description": "http.server", "is_segment": True}, + start_ts=self.ten_mins_ago, + duration=2000, # 2000ms - tolerable + ), + self.create_span( + {"description": "http.server", "is_segment": True}, + start_ts=self.ten_mins_ago, + duration=4000, # 4000ms - tolerable (at 4T) + ), + # Frustrated spans (> 4000ms) + self.create_span( + {"description": "http.server", "is_segment": True}, + start_ts=self.ten_mins_ago, + duration=5000, # 5000ms - frustrated + ), + # Non-segment span + self.create_span( + {"description": "http.server", "is_segment": False}, + start_ts=self.ten_mins_ago, + duration=5000, # 5000ms - frustrated + ), + ] + self.store_spans(spans, is_eap=self.is_eap) + + response = self.do_request( + { + "field": ["apdex(span.duration,1000)"], + "query": "", + "project": self.project.id, + "dataset": self.dataset, + } + ) + + assert response.status_code == 200, response.content + data = response.data["data"] + meta = response.data["meta"] + + # Expected apdex calculation: + # Satisfactory: 2 spans (500ms, 1000ms) + # Tolerable: 2 spans (2000ms, 4000ms) + # Frustrated: 1 span (5000ms) + # Total: 5 spans + # Apdex = (2 + 2/2) / 5 = (2 + 1) / 5 = 3/5 = 0.6 + expected_apdex = 0.6 + + assert len(data) == 1 + assert data[0]["apdex(span.duration,1000)"] == expected_apdex + assert meta["dataset"] == self.dataset + assert meta["fields"] == {"apdex(span.duration,1000)": "number"} + + def test_apdex_function_with_filter(self): + """Test the apdex function with filtering.""" + # Create spans with different descriptions and durations + # Only segments (transactions) will be counted in apdex calculation + spans = [ + # Satisfactory spans for "api" operations (segments) + self.create_span( + { + "description": "http.server", + "sentry_tags": {"status": "success"}, + "is_segment": True, + }, + start_ts=self.ten_mins_ago, + duration=500, + ), + self.create_span( + { + "description": "http.server", + "sentry_tags": {"status": "success"}, + "is_segment": True, + }, + start_ts=self.ten_mins_ago, + duration=800, + ), + # Tolerable span for "api" operations (segment) + self.create_span( + { + "description": "http.server", + "sentry_tags": {"status": "success"}, + "is_segment": True, + }, + start_ts=self.ten_mins_ago, + duration=2000, + ), + # Frustrated span for "api" operations (segment) + self.create_span( + { + "description": "http.server", + "sentry_tags": {"status": "success"}, + "is_segment": True, + }, + start_ts=self.ten_mins_ago, + duration=5000, + ), + # Other spans that should be filtered out + self.create_span( + { + "description": "task", + "sentry_tags": {"status": "success"}, + "is_segment": True, + }, + start_ts=self.ten_mins_ago, + duration=100, + ), + ] + self.store_spans(spans, is_eap=self.is_eap) + + response = self.do_request( + { + "field": ["apdex(span.duration,1000)"], + "query": "description:http.server", + "project": self.project.id, + "dataset": self.dataset, + } + ) + + assert response.status_code == 200, response.content + data = response.data["data"] + + # Expected apdex calculation for filtered results: + # Only segments (transactions) are counted in apdex + # Satisfactory: 2 spans (500ms, 800ms) - both are segments + # Tolerable: 1 span (2000ms) - is a segment + # Frustrated: 1 span (5000ms) - is a segment + # Total: 4 spans (all segments) + # Apdex = (2 + 1/2) / 4 = (2 + 0.5) / 4 = 2.5/4 = 0.625 + expected_apdex = 0.625 + + assert len(data) == 1 + assert data[0]["apdex(span.duration,1000)"] == expected_apdex + + def test_user_misery_function(self): + """Test the user_misery function with span.duration and threshold.""" + # Create spans with different durations and users to test user misery calculation + # Threshold = 1000ms (1 second) + # Miserable threshold = 4000ms (4x threshold) + # Users are considered miserable when response time > 4000ms + spans = [ + # Happy users (≤ 4000ms) - segments + self.create_span( + { + "description": "http.server", + "sentry_tags": {"user": "user1"}, + "is_segment": True, + }, + start_ts=self.ten_mins_ago, + duration=500, # 500ms - happy + ), + self.create_span( + { + "description": "http.server", + "sentry_tags": {"user": "user2"}, + "is_segment": True, + }, + start_ts=self.ten_mins_ago, + duration=2000, # 2000ms - happy + ), + self.create_span( + { + "description": "http.server", + "sentry_tags": {"user": "user3"}, + "is_segment": True, + }, + start_ts=self.ten_mins_ago, + duration=4000, # 4000ms - happy (at 4T) + ), + # Miserable users (> 4000ms) - segments + self.create_span( + { + "description": "http.server", + "sentry_tags": {"user": "user4"}, + "is_segment": True, + }, + start_ts=self.ten_mins_ago, + duration=5000, # 5000ms - miserable + ), + self.create_span( + { + "description": "http.server", + "sentry_tags": {"user": "user2"}, + "is_segment": True, + }, + start_ts=self.ten_mins_ago, + duration=6000, # 6000ms - miserable + ), + # Non-segment span (should not be counted) + self.create_span( + { + "description": "http.server", + "sentry_tags": {"user": "user5"}, + "is_segment": False, + }, + start_ts=self.ten_mins_ago, + duration=5000, # 5000ms - miserable but not a segment + ), + ] + self.store_spans(spans, is_eap=self.is_eap) + + response = self.do_request( + { + "field": ["user_misery(span.duration,1000)"], + "query": "", + "project": self.project.id, + "dataset": self.dataset, + } + ) + + assert response.status_code == 200, response.content + data = response.data["data"] + meta = response.data["meta"] + + # Expected user misery calculation: + # Miserable users: 2 (user4, user2) - both are segments + # Total unique users: 5 (user1, user2, user3, user4) - all segments + # MISERY_ALPHA = 5.8875, MISERY_BETA = 111.8625 + # User Misery = (2 + 5.8875) / (5 + 5.8875 + 111.8625) = 7.8875 / 122.75 ≈ 0.0643 + expected_user_misery = (2 + 5.8875) / (4 + 5.8875 + 111.8625) + + assert len(data) == 1 + assert data[0]["user_misery(span.duration,1000)"] == pytest.approx( + expected_user_misery, rel=1e-3 + ) + assert meta["dataset"] == self.dataset + assert meta["fields"] == {"user_misery(span.duration,1000)": "number"} + + def test_user_misery_function_with_filter(self): + """Test the user_misery function with filtering.""" + # Create spans with different descriptions, durations, and users + # Only segments (transactions) will be counted in user misery calculation + spans = [ + # Happy users for "api" operations (segments) + self.create_span( + { + "description": "http.server", + "sentry_tags": {"user": "user1", "status": "success"}, + "is_segment": True, + }, + start_ts=self.ten_mins_ago, + duration=500, + ), + self.create_span( + { + "description": "http.server", + "sentry_tags": {"user": "user2", "status": "success"}, + "is_segment": True, + }, + start_ts=self.ten_mins_ago, + duration=2000, + ), + # Miserable user for "api" operations (segment) + self.create_span( + { + "description": "http.server", + "sentry_tags": {"user": "user3", "status": "success"}, + "is_segment": True, + }, + start_ts=self.ten_mins_ago, + duration=5000, + ), + # Other spans that should be filtered out + self.create_span( + { + "description": "task", + "sentry_tags": {"user": "user4", "status": "success"}, + "is_segment": True, + }, + start_ts=self.ten_mins_ago, + duration=100, + ), + ] + self.store_spans(spans, is_eap=self.is_eap) + + response = self.do_request( + { + "field": ["user_misery(span.duration,1000)"], + "query": "description:http.server", + "project": self.project.id, + "dataset": self.dataset, + } + ) + + assert response.status_code == 200, response.content + data = response.data["data"] + + # Expected user misery calculation for filtered results: + # Only segments (transactions) are counted in user misery + # Miserable users: 1 (user3) - is a segment + # Total unique users: 3 (user1, user2, user3) - all segments + # MISERY_ALPHA = 5.8875, MISERY_BETA = 111.8625 + # User Misery = (1 + 5.8875) / (3 + 5.8875 + 111.8625) = 6.8875 / 120.75 ≈ 0.0570 + expected_user_misery = (1 + 5.8875) / (3 + 5.8875 + 111.8625) + + assert len(data) == 1 + assert data[0]["user_misery(span.duration,1000)"] == pytest.approx( + expected_user_misery, rel=1e-3 + )