diff --git a/src/sentry/search/eap/spans.py b/src/sentry/search/eap/spans.py index a18bf950e58849..3365af25e4733a 100644 --- a/src/sentry/search/eap/spans.py +++ b/src/sentry/search/eap/spans.py @@ -288,6 +288,9 @@ def _resolve_where( def _resolve_having( self, terms: event_filter.ParsedTerms ) -> tuple[AggregationFilter | None, list[VirtualColumnContext | None]]: + if not self.config.use_aggregate_conditions: + return None, [] + parsed_terms = [] resolved_contexts = [] for item in terms: diff --git a/tests/sentry/search/eap/test_spans.py b/tests/sentry/search/eap/test_spans.py index baa04863cfa57e..8077105b7d1ea0 100644 --- a/tests/sentry/search/eap/test_spans.py +++ b/tests/sentry/search/eap/test_spans.py @@ -1,4 +1,10 @@ import pytest +from sentry_protos.snuba.v1.endpoint_trace_item_table_pb2 import ( + AggregationAndFilter, + AggregationComparisonFilter, + AggregationFilter, + AggregationOrFilter, +) from sentry_protos.snuba.v1.trace_item_attribute_pb2 import ( AttributeAggregation, AttributeKey, @@ -28,92 +34,100 @@ def setUp(self): self.resolver = SearchResolver(params=SnubaParams(), config=SearchResolverConfig()) def test_simple_query(self): - query, _ = self.resolver.resolve_query("span.description:foo") - assert query == TraceItemFilter( + where, having, _ = self.resolver.resolve_query("span.description:foo") + assert where == TraceItemFilter( comparison_filter=ComparisonFilter( key=AttributeKey(name="sentry.name", type=AttributeKey.Type.TYPE_STRING), op=ComparisonFilter.OP_EQUALS, value=AttributeValue(val_str="foo"), ) ) + assert having is None def test_negation(self): - query, _ = self.resolver.resolve_query("!span.description:foo") - assert query == TraceItemFilter( + where, having, _ = self.resolver.resolve_query("!span.description:foo") + assert where == TraceItemFilter( comparison_filter=ComparisonFilter( key=AttributeKey(name="sentry.name", type=AttributeKey.Type.TYPE_STRING), op=ComparisonFilter.OP_NOT_EQUALS, value=AttributeValue(val_str="foo"), ) ) + assert having is None def test_numeric_query(self): - query, _ = self.resolver.resolve_query("ai.total_tokens.used:123") - assert query == TraceItemFilter( + where, having, _ = self.resolver.resolve_query("ai.total_tokens.used:123") + assert where == TraceItemFilter( comparison_filter=ComparisonFilter( key=AttributeKey(name="ai_total_tokens_used", type=AttributeKey.Type.TYPE_FLOAT), op=ComparisonFilter.OP_EQUALS, value=AttributeValue(val_float=123), ) ) + assert having is None def test_in_filter(self): - query, _ = self.resolver.resolve_query("span.description:[foo,bar,baz]") - assert query == TraceItemFilter( + where, having, _ = self.resolver.resolve_query("span.description:[foo,bar,baz]") + assert where == TraceItemFilter( comparison_filter=ComparisonFilter( key=AttributeKey(name="sentry.name", type=AttributeKey.Type.TYPE_STRING), op=ComparisonFilter.OP_IN, value=AttributeValue(val_str_array=StrArray(values=["foo", "bar", "baz"])), ) ) + assert having is None def test_uuid_validation(self): - query, _ = self.resolver.resolve_query(f"id:{'f'*16}") - assert query == TraceItemFilter( + where, having, _ = self.resolver.resolve_query(f"id:{'f'*16}") + assert where == TraceItemFilter( comparison_filter=ComparisonFilter( key=AttributeKey(name="sentry.span_id", type=AttributeKey.Type.TYPE_STRING), op=ComparisonFilter.OP_EQUALS, value=AttributeValue(val_str="f" * 16), ) ) + assert having is None def test_invalid_uuid_validation(self): with pytest.raises(InvalidSearchQuery): self.resolver.resolve_query("id:hello") def test_not_in_filter(self): - query, _ = self.resolver.resolve_query("!span.description:[foo,bar,baz]") - assert query == TraceItemFilter( + where, having, _ = self.resolver.resolve_query("!span.description:[foo,bar,baz]") + assert where == TraceItemFilter( comparison_filter=ComparisonFilter( key=AttributeKey(name="sentry.name", type=AttributeKey.Type.TYPE_STRING), op=ComparisonFilter.OP_NOT_IN, value=AttributeValue(val_str_array=StrArray(values=["foo", "bar", "baz"])), ) ) + assert having is None def test_in_numeric_filter(self): - query, _ = self.resolver.resolve_query("ai.total_tokens.used:[123,456,789]") - assert query == TraceItemFilter( + where, having, _ = self.resolver.resolve_query("ai.total_tokens.used:[123,456,789]") + assert where == TraceItemFilter( comparison_filter=ComparisonFilter( key=AttributeKey(name="ai_total_tokens_used", type=AttributeKey.Type.TYPE_FLOAT), op=ComparisonFilter.OP_IN, value=AttributeValue(val_float_array=FloatArray(values=[123, 456, 789])), ) ) + assert having is None def test_greater_than_numeric_filter(self): - query, _ = self.resolver.resolve_query("ai.total_tokens.used:>123") - assert query == TraceItemFilter( + where, having, _ = self.resolver.resolve_query("ai.total_tokens.used:>123") + assert where == TraceItemFilter( comparison_filter=ComparisonFilter( key=AttributeKey(name="ai_total_tokens_used", type=AttributeKey.Type.TYPE_FLOAT), op=ComparisonFilter.OP_GREATER_THAN, value=AttributeValue(val_float=123), ) ) + assert having is None def test_query_with_and(self): - query, _ = self.resolver.resolve_query("span.description:foo span.op:bar") - assert query == TraceItemFilter( + where, having, _ = self.resolver.resolve_query("span.description:foo span.op:bar") + assert where == TraceItemFilter( and_filter=AndFilter( filters=[ TraceItemFilter( @@ -135,10 +149,11 @@ def test_query_with_and(self): ] ) ) + assert having is None def test_query_with_or(self): - query, _ = self.resolver.resolve_query("span.description:foo or span.op:bar") - assert query == TraceItemFilter( + where, having, _ = self.resolver.resolve_query("span.description:foo or span.op:bar") + assert where == TraceItemFilter( or_filter=OrFilter( filters=[ TraceItemFilter( @@ -160,12 +175,13 @@ def test_query_with_or(self): ] ) ) + assert having is None def test_query_with_or_and_brackets(self): - query, _ = self.resolver.resolve_query( + where, having, _ = self.resolver.resolve_query( "(span.description:123 and span.op:345) or (span.description:foo and span.op:bar)" ) - assert query == TraceItemFilter( + assert where == TraceItemFilter( or_filter=OrFilter( filters=[ TraceItemFilter( @@ -221,10 +237,290 @@ def test_query_with_or_and_brackets(self): ) def test_empty_query(self): - query, _ = self.resolver.resolve_query("") - assert query is None - query, _ = self.resolver.resolve_query(None) - assert query is None + where, having, _ = self.resolver.resolve_query("") + assert where is None + assert having is None + + def test_none_query(self): + where, having, _ = self.resolver.resolve_query(None) + assert where is None + assert having is None + + def test_simple_aggregate_query(self): + operators = [ + ("", AggregationComparisonFilter.OP_EQUALS), + (">", AggregationComparisonFilter.OP_GREATER_THAN), + (">=", AggregationComparisonFilter.OP_GREATER_THAN_OR_EQUALS), + ("<", AggregationComparisonFilter.OP_LESS_THAN), + ("<=", AggregationComparisonFilter.OP_LESS_THAN_OR_EQUALS), + ] + for str_op, rpc_op in operators: + where, having, _ = self.resolver.resolve_query(f"count():{str_op}2") + assert where is None + assert having == AggregationFilter( + comparison_filter=AggregationComparisonFilter( + aggregation=AttributeAggregation( + aggregate=Function.FUNCTION_COUNT, + key=AttributeKey( + name="sentry.duration_ms", type=AttributeKey.Type.TYPE_FLOAT + ), + label="count()", + extrapolation_mode=ExtrapolationMode.EXTRAPOLATION_MODE_SAMPLE_WEIGHTED, + ), + op=rpc_op, + val=2, + ) + ) + + def test_simple_negation_aggregate_query(self): + operators = [ + ("", AggregationComparisonFilter.OP_NOT_EQUALS), + (">", AggregationComparisonFilter.OP_LESS_THAN_OR_EQUALS), + (">=", AggregationComparisonFilter.OP_LESS_THAN), + ("<", AggregationComparisonFilter.OP_GREATER_THAN_OR_EQUALS), + ("<=", AggregationComparisonFilter.OP_GREATER_THAN), + ] + for str_op, rpc_op in operators: + where, having, _ = self.resolver.resolve_query(f"!count():{str_op}2") + assert where is None + assert having == AggregationFilter( + comparison_filter=AggregationComparisonFilter( + aggregation=AttributeAggregation( + aggregate=Function.FUNCTION_COUNT, + key=AttributeKey( + name="sentry.duration_ms", type=AttributeKey.Type.TYPE_FLOAT + ), + label="count()", + extrapolation_mode=ExtrapolationMode.EXTRAPOLATION_MODE_SAMPLE_WEIGHTED, + ), + op=rpc_op, + val=2, + ) + ) + + def test_aggregate_query_on_custom_attributes(self): + where, having, _ = self.resolver.resolve_query("avg(tags[foo,number]):>1000") + assert where is None + assert having == AggregationFilter( + comparison_filter=AggregationComparisonFilter( + aggregation=AttributeAggregation( + aggregate=Function.FUNCTION_AVG, + key=AttributeKey(name="foo", type=AttributeKey.Type.TYPE_FLOAT), + label="avg(tags[foo, number])", + extrapolation_mode=ExtrapolationMode.EXTRAPOLATION_MODE_SAMPLE_WEIGHTED, + ), + op=AggregationComparisonFilter.OP_GREATER_THAN, + val=1000, + ) + ) + + def test_aggregate_query_on_attributes_with_units(self): + for value in ["1000", "1s", "1000ms"]: + where, having, _ = self.resolver.resolve_query(f"avg(measurements.lcp):>{value}") + assert where is None + assert having == AggregationFilter( + comparison_filter=AggregationComparisonFilter( + aggregation=AttributeAggregation( + aggregate=Function.FUNCTION_AVG, + key=AttributeKey(name="lcp", type=AttributeKey.Type.TYPE_FLOAT), + label="avg(measurements.lcp)", + extrapolation_mode=ExtrapolationMode.EXTRAPOLATION_MODE_SAMPLE_WEIGHTED, + ), + op=AggregationComparisonFilter.OP_GREATER_THAN, + val=1000, + ) + ) + + def test_aggregate_query_with_multiple_conditions(self): + where, having, _ = self.resolver.resolve_query("count():>1 avg(measurements.lcp):>3000") + assert where is None + assert having == AggregationFilter( + and_filter=AggregationAndFilter( + filters=[ + AggregationFilter( + comparison_filter=AggregationComparisonFilter( + aggregation=AttributeAggregation( + aggregate=Function.FUNCTION_COUNT, + key=AttributeKey( + name="sentry.duration_ms", type=AttributeKey.Type.TYPE_FLOAT + ), + label="count()", + extrapolation_mode=ExtrapolationMode.EXTRAPOLATION_MODE_SAMPLE_WEIGHTED, + ), + op=AggregationComparisonFilter.OP_GREATER_THAN, + val=1, + ), + ), + AggregationFilter( + comparison_filter=AggregationComparisonFilter( + aggregation=AttributeAggregation( + aggregate=Function.FUNCTION_AVG, + key=AttributeKey(name="lcp", type=AttributeKey.Type.TYPE_FLOAT), + label="avg(measurements.lcp)", + extrapolation_mode=ExtrapolationMode.EXTRAPOLATION_MODE_SAMPLE_WEIGHTED, + ), + op=AggregationComparisonFilter.OP_GREATER_THAN, + val=3000, + ), + ), + ], + ) + ) + + def test_aggregate_query_with_multiple_conditions_explicit_and(self): + where, having, _ = self.resolver.resolve_query("count():>1 AND avg(measurements.lcp):>3000") + assert where is None + assert having == AggregationFilter( + and_filter=AggregationAndFilter( + filters=[ + AggregationFilter( + comparison_filter=AggregationComparisonFilter( + aggregation=AttributeAggregation( + aggregate=Function.FUNCTION_COUNT, + key=AttributeKey( + name="sentry.duration_ms", type=AttributeKey.Type.TYPE_FLOAT + ), + label="count()", + extrapolation_mode=ExtrapolationMode.EXTRAPOLATION_MODE_SAMPLE_WEIGHTED, + ), + op=AggregationComparisonFilter.OP_GREATER_THAN, + val=1, + ), + ), + AggregationFilter( + comparison_filter=AggregationComparisonFilter( + aggregation=AttributeAggregation( + aggregate=Function.FUNCTION_AVG, + key=AttributeKey(name="lcp", type=AttributeKey.Type.TYPE_FLOAT), + label="avg(measurements.lcp)", + extrapolation_mode=ExtrapolationMode.EXTRAPOLATION_MODE_SAMPLE_WEIGHTED, + ), + op=AggregationComparisonFilter.OP_GREATER_THAN, + val=3000, + ), + ), + ], + ) + ) + + def test_aggregate_query_with_multiple_conditions_explicit_or(self): + where, having, _ = self.resolver.resolve_query("count():>1 or avg(measurements.lcp):>3000") + assert where is None + assert having == AggregationFilter( + or_filter=AggregationOrFilter( + filters=[ + AggregationFilter( + comparison_filter=AggregationComparisonFilter( + aggregation=AttributeAggregation( + aggregate=Function.FUNCTION_COUNT, + key=AttributeKey( + name="sentry.duration_ms", type=AttributeKey.Type.TYPE_FLOAT + ), + label="count()", + extrapolation_mode=ExtrapolationMode.EXTRAPOLATION_MODE_SAMPLE_WEIGHTED, + ), + op=AggregationComparisonFilter.OP_GREATER_THAN, + val=1, + ), + ), + AggregationFilter( + comparison_filter=AggregationComparisonFilter( + aggregation=AttributeAggregation( + aggregate=Function.FUNCTION_AVG, + key=AttributeKey(name="lcp", type=AttributeKey.Type.TYPE_FLOAT), + label="avg(measurements.lcp)", + extrapolation_mode=ExtrapolationMode.EXTRAPOLATION_MODE_SAMPLE_WEIGHTED, + ), + op=AggregationComparisonFilter.OP_GREATER_THAN, + val=3000, + ), + ), + ], + ) + ) + + def test_aggregate_query_with_multiple_conditions_nested(self): + where, having, _ = self.resolver.resolve_query( + "(count():>1 AND avg(http.response_content_length):>3000) OR (count():>1 AND avg(measurements.lcp):>3000)" + ) + assert where is None + assert having == AggregationFilter( + or_filter=AggregationOrFilter( + filters=[ + AggregationFilter( + and_filter=AggregationAndFilter( + filters=[ + AggregationFilter( + comparison_filter=AggregationComparisonFilter( + aggregation=AttributeAggregation( + aggregate=Function.FUNCTION_COUNT, + key=AttributeKey( + name="sentry.duration_ms", + type=AttributeKey.Type.TYPE_FLOAT, + ), + label="count()", + extrapolation_mode=ExtrapolationMode.EXTRAPOLATION_MODE_SAMPLE_WEIGHTED, + ), + op=AggregationComparisonFilter.OP_GREATER_THAN, + val=1, + ), + ), + AggregationFilter( + comparison_filter=AggregationComparisonFilter( + aggregation=AttributeAggregation( + aggregate=Function.FUNCTION_AVG, + key=AttributeKey( + name="http.response_content_length", + type=AttributeKey.Type.TYPE_FLOAT, + ), + label="avg(http.response_content_length)", + extrapolation_mode=ExtrapolationMode.EXTRAPOLATION_MODE_SAMPLE_WEIGHTED, + ), + op=AggregationComparisonFilter.OP_GREATER_THAN, + val=3000, + ), + ), + ], + ) + ), + AggregationFilter( + and_filter=AggregationAndFilter( + filters=[ + AggregationFilter( + comparison_filter=AggregationComparisonFilter( + aggregation=AttributeAggregation( + aggregate=Function.FUNCTION_COUNT, + key=AttributeKey( + name="sentry.duration_ms", + type=AttributeKey.Type.TYPE_FLOAT, + ), + label="count()", + extrapolation_mode=ExtrapolationMode.EXTRAPOLATION_MODE_SAMPLE_WEIGHTED, + ), + op=AggregationComparisonFilter.OP_GREATER_THAN, + val=1, + ), + ), + AggregationFilter( + comparison_filter=AggregationComparisonFilter( + aggregation=AttributeAggregation( + aggregate=Function.FUNCTION_AVG, + key=AttributeKey( + name="lcp", type=AttributeKey.Type.TYPE_FLOAT + ), + label="avg(measurements.lcp)", + extrapolation_mode=ExtrapolationMode.EXTRAPOLATION_MODE_SAMPLE_WEIGHTED, + ), + op=AggregationComparisonFilter.OP_GREATER_THAN, + val=3000, + ), + ), + ], + ) + ), + ] + ) + ) class SearchResolverColumnTest(TestCase): 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 62e7e07d6ebb53..592f4e9d037cdb 100644 --- a/tests/snuba/api/endpoints/test_organization_events_span_indexed.py +++ b/tests/snuba/api/endpoints/test_organization_events_span_indexed.py @@ -974,7 +974,7 @@ def test_in_filter(self): ] assert meta["dataset"] == self.dataset - def test_aggregate_filter(self): + def _test_aggregate_filter(self, queries): self.store_spans( [ self.create_span( @@ -1005,15 +1005,7 @@ def test_aggregate_filter(self): is_eap=self.is_eap, ) - for query in [ - "count():2", - "count():>1", - "avg(measurements.lcp):>3000", - "avg(measurements.lcp):>3s", - "count():>1 avg(measurements.lcp):>3000", - "count():>1 AND avg(measurements.lcp):>3000", - "count():>1 OR avg(measurements.lcp):>3000", - ]: + for query in queries: response = self.do_request( { "field": ["transaction", "count()"], @@ -1031,6 +1023,19 @@ def test_aggregate_filter(self): assert data[0]["count()"] == 2 assert meta["dataset"] == self.dataset + def test_aggregate_filter(self): + self._test_aggregate_filter( + [ + "count():2", + "count():>1", + "avg(measurements.lcp):>3000", + "avg(measurements.lcp):>3s", + "count():>1 avg(measurements.lcp):>3000", + "count():>1 AND avg(measurements.lcp):>3000", + "count():>1 OR avg(measurements.lcp):>3000", + ] + ) + class OrganizationEventsEAPSpanEndpointTest(OrganizationEventsSpanIndexedEndpointTest): is_eap = True @@ -1607,6 +1612,20 @@ def test_byte_fields(self): }, ] + def test_aggregate_filter(self): + self._test_aggregate_filter( + [ + "count():2", + "count():>1", + "avg(measurements.lcp):>3000", + "avg(measurements.lcp):>3s", + "count():>1 avg(measurements.lcp):>3000", + "count():>1 AND avg(measurements.lcp):>3000", + "count():>1 OR avg(measurements.lcp):>3000", + "(count():>1 AND avg(http.response_content_length):>3000) OR (count():>1 AND avg(measurements.lcp):>3000)", + ] + ) + class OrganizationEventsEAPRPCSpanEndpointTest(OrganizationEventsEAPSpanEndpointTest): """These tests aren't fully passing yet, currently inheriting xfail from the eap tests"""