diff --git a/src/sentry/explore/utils.py b/src/sentry/explore/utils.py index 20b7805fe430d1..665c4d99ab1383 100644 --- a/src/sentry/explore/utils.py +++ b/src/sentry/explore/utils.py @@ -11,3 +11,20 @@ def is_logs_enabled(organization: Organization, actor: User | None = None) -> bo This replaces individual feature flag checks for consolidated ourlogs features. """ return features.has("organizations:ourlogs-enabled", organization, actor=actor) + + +def is_trace_metrics_enabled(organization: Organization, actor: User | None = None) -> bool: + """ + Check if trace metrics are enabled for the given organization. + This replaces individual feature flag checks for consolidated tracemetrics features. + """ + return features.has("organizations:tracemetrics-enabled", organization, actor=actor) + + +def is_trace_metrics_alerts_enabled(organization: Organization, actor: User | None = None) -> bool: + """ + Check if trace metrics alerts are enabled for the given organization. + """ + return features.has( + "organizations:tracemetrics-enabled", organization, actor=actor + ) and features.has("organizations:tracemetrics-alerts", organization, actor=actor) diff --git a/src/sentry/incidents/endpoints/serializers/alert_rule.py b/src/sentry/incidents/endpoints/serializers/alert_rule.py index 386f28603082a0..93c434a701114f 100644 --- a/src/sentry/incidents/endpoints/serializers/alert_rule.py +++ b/src/sentry/incidents/endpoints/serializers/alert_rule.py @@ -268,14 +268,25 @@ def serialize( obj.organization, actor=user, ) - # Temporary: Translate aggregate back here from `tags[sentry:user]` to `user` for the frontend. - aggregate = translate_aggregate_field( - obj.snuba_query.aggregate, - reverse=True, - allow_mri=allow_mri, - allow_eap=obj.snuba_query.dataset == Dataset.EventsAnalyticsPlatform.value, + + # Trace metrics have complicated aggregated validation that require EAP SearchResolver and do NOT need translation as they do not have tags in the old format (eg. tags[sentry:user)) + is_trace_metric = ( + obj.snuba_query.dataset == Dataset.EventsAnalyticsPlatform.value + and obj.snuba_query.event_types + and SnubaQueryEventType.EventType.TRACE_ITEM_METRIC in obj.snuba_query.event_types ) + # Temporary: Translate aggregate back here from `tags[sentry:user]` to `user` for the frontend. + if not is_trace_metric: + aggregate = translate_aggregate_field( + obj.snuba_query.aggregate, + reverse=True, + allow_mri=allow_mri, + allow_eap=obj.snuba_query.dataset == Dataset.EventsAnalyticsPlatform.value, + ) + else: + aggregate = obj.snuba_query.aggregate + # Apply transparency: Convert upsampled_count() back to count() for user-facing responses # This hides the internal upsampling implementation from users if aggregate == "upsampled_count()": diff --git a/src/sentry/incidents/metric_issue_detector.py b/src/sentry/incidents/metric_issue_detector.py index 1627112325c68d..d9e5e86761ad03 100644 --- a/src/sentry/incidents/metric_issue_detector.py +++ b/src/sentry/incidents/metric_issue_detector.py @@ -8,6 +8,7 @@ from sentry.incidents.logic import enable_disable_subscriptions from sentry.incidents.models.alert_rule import AlertRuleDetectionType from sentry.relay.config.metric_extraction import on_demand_metrics_feature_flags +from sentry.search.eap.trace_metrics.validator import validate_trace_metrics_aggregate from sentry.seer.anomaly_detection.delete_rule import delete_data_in_seer_for_detector from sentry.seer.anomaly_detection.store_data_workflow_engine import ( send_new_detector_data, @@ -194,6 +195,20 @@ class MetricIssueDetectorValidator(BaseDetectorTypeValidator): ) condition_group = MetricIssueConditionGroupValidator(required=True) + def validate_eap_rule(self, attrs): + """ + Validate EAP rule data. + """ + data_sources = attrs.get("data_sources", []) + for data_source in data_sources: + event_types = data_source.get("event_types", []) + if ( + data_source.get("dataset") == Dataset.EventsAnalyticsPlatform + and SnubaQueryEventType.EventType.TRACE_ITEM_METRIC in event_types + ): + aggregate = data_source.get("aggregate") + validate_trace_metrics_aggregate(aggregate) + def validate(self, attrs): attrs = super().validate(attrs) @@ -202,6 +217,9 @@ def validate(self, attrs): if len(conditions) > 3: raise serializers.ValidationError("Too many conditions") + if "data_sources" in attrs: + self.validate_eap_rule(attrs) + return attrs def _validate_transaction_dataset_deprecation(self, dataset: Dataset) -> None: diff --git a/src/sentry/incidents/serializers/alert_rule.py b/src/sentry/incidents/serializers/alert_rule.py index 31b8389a5f7ee8..bfeb1cfd6c80a1 100644 --- a/src/sentry/incidents/serializers/alert_rule.py +++ b/src/sentry/incidents/serializers/alert_rule.py @@ -30,8 +30,9 @@ AlertRuleTrigger, ) from sentry.incidents.utils.subscription_limits import get_max_metric_alert_subscriptions +from sentry.search.eap.trace_metrics.validator import validate_trace_metrics_aggregate from sentry.snuba.dataset import Dataset -from sentry.snuba.models import QuerySubscription +from sentry.snuba.models import QuerySubscription, SnubaQueryEventType from sentry.snuba.snuba_query_validator import SnubaQueryValidator from sentry.workflow_engine.migration_helpers.alert_rule import ( dual_delete_migrated_alert_rule_trigger, @@ -140,6 +141,16 @@ def validate_aggregate(self, aggregate): ) return aggregate + def validate_eap_rule(self, data): + """ + Validate EAP rule data. + """ + event_types = data.get("event_types", []) + + if SnubaQueryEventType.EventType.TRACE_ITEM_METRIC in event_types: + aggregate = data.get("aggregate") + validate_trace_metrics_aggregate(aggregate) + def validate(self, data): """ Performs validation on an alert rule's data. @@ -149,6 +160,8 @@ def validate(self, data): > or < the value depends on threshold type). """ data = super().validate(data) + if data.get("dataset") == Dataset.EventsAnalyticsPlatform: + self.validate_eap_rule(data) triggers = data.get("triggers", []) if not triggers: diff --git a/src/sentry/search/eap/trace_metrics/validator.py b/src/sentry/search/eap/trace_metrics/validator.py new file mode 100644 index 00000000000000..c1fac22c3cf0e9 --- /dev/null +++ b/src/sentry/search/eap/trace_metrics/validator.py @@ -0,0 +1,71 @@ +import logging +from datetime import datetime, timedelta, timezone + +from rest_framework import serializers + +from sentry.exceptions import InvalidSearchQuery +from sentry.search.eap.resolver import SearchResolver +from sentry.search.eap.trace_metrics.config import TraceMetricsSearchResolverConfig +from sentry.search.eap.trace_metrics.definitions import TRACE_METRICS_DEFINITIONS +from sentry.search.eap.trace_metrics.types import TraceMetric +from sentry.search.events.types import SnubaParams + +logger = logging.getLogger(__name__) + + +def extract_trace_metric_from_aggregate(aggregate: str) -> TraceMetric | None: + """ + Extract trace metric information from an aggregate string using SearchResolver. + + Args: + aggregate: The aggregate function string + + Returns: + TraceMetric | None: The extracted trace metric or None if no specific metric + + Raises: + InvalidSearchQuery: If the aggregate is invalid + """ + + now = datetime.now(tz=timezone.utc) + snuba_params = SnubaParams( + projects=[], + organization=None, + start=now - timedelta(hours=1), + end=now, + ) + + resolver = SearchResolver( + params=snuba_params, + config=TraceMetricsSearchResolverConfig( + metric=None + ), # It's fine to not pass a metric here since that way of using metrics is deprecated. + definitions=TRACE_METRICS_DEFINITIONS, + ) + + resolved_function, _ = resolver.resolve_function(aggregate) + + return getattr(resolved_function, "trace_metric", None) + + +def validate_trace_metrics_aggregate(aggregate: str) -> None: + """ + Validate a trace metrics aggregate using the SearchResolver. + + Args: + aggregate: The aggregate function to validate + + Raises: + serializers.ValidationError: If the aggregate is invalid + """ + try: + trace_metric = extract_trace_metric_from_aggregate(aggregate) + if trace_metric is None: + raise InvalidSearchQuery( + f"Trace metrics aggregate {aggregate} must specify metric name, type, and unit" + ) + except InvalidSearchQuery as e: + logger.exception("Invalid trace metrics aggregate: %s %s", aggregate, e) + raise serializers.ValidationError( + {"aggregate": f"Invalid trace metrics aggregate: {aggregate}"} + ) diff --git a/src/sentry/snuba/entity_subscription.py b/src/sentry/snuba/entity_subscription.py index 8cbd27c0164c7f..8188d0341ec0b0 100644 --- a/src/sentry/snuba/entity_subscription.py +++ b/src/sentry/snuba/entity_subscription.py @@ -20,6 +20,7 @@ from sentry.models.environment import Environment from sentry.models.organization import Organization from sentry.models.project import Project +from sentry.search.eap.trace_metrics.config import TraceMetricsSearchResolverConfig from sentry.search.eap.types import SearchResolverConfig from sentry.search.events.builder.base import BaseQueryBuilder from sentry.search.events.builder.discover import DiscoverQueryBuilder @@ -35,6 +36,7 @@ from sentry.snuba.referrer import Referrer from sentry.snuba.rpc_dataset_common import RPCBase from sentry.snuba.spans_rpc import Spans +from sentry.snuba.trace_metrics import TraceMetrics from sentry.utils import metrics # TODO: If we want to support security events here we'll need a way to @@ -296,6 +298,10 @@ def build_rpc_request( params = {} params["project_id"] = project_ids + is_trace_metric = ( + self.event_types + and self.event_types[0] == SnubaQueryEventType.EventType.TRACE_ITEM_METRIC + ) query = apply_dataset_query_conditions(self.query_type, query, self.event_types) if environment: @@ -304,6 +310,8 @@ def build_rpc_request( dataset_module: type[RPCBase] if self.event_types and self.event_types[0] == SnubaQueryEventType.EventType.TRACE_ITEM_LOG: dataset_module = OurLogs + elif is_trace_metric: + dataset_module = TraceMetrics else: dataset_module = Spans now = datetime.now(tz=timezone.utc) @@ -322,12 +330,24 @@ def build_rpc_request( model_mode = ExtrapolationMode(self.extrapolation_mode) proto_extrapolation_mode = MODEL_TO_PROTO_EXTRAPOLATION_MODE.get(model_mode) - search_resolver = dataset_module.get_resolver( - snuba_params, - SearchResolverConfig( + if is_trace_metric: + search_config: SearchResolverConfig = TraceMetricsSearchResolverConfig( + metric=None, + auto_fields=False, + use_aggregate_conditions=True, + disable_aggregate_extrapolation=False, + extrapolation_mode=proto_extrapolation_mode, + stable_timestamp_quantization=False, + ) + else: + search_config = SearchResolverConfig( stable_timestamp_quantization=False, extrapolation_mode=proto_extrapolation_mode, - ), + ) + + search_resolver = dataset_module.get_resolver( + snuba_params, + search_config, ) rpc_request, _, _ = dataset_module.get_timeseries_query( diff --git a/src/sentry/snuba/models.py b/src/sentry/snuba/models.py index ac75638897e13c..a732ee9e8199b7 100644 --- a/src/sentry/snuba/models.py +++ b/src/sentry/snuba/models.py @@ -121,6 +121,7 @@ class EventType(Enum): TRANSACTION = 2 TRACE_ITEM_SPAN = 3 TRACE_ITEM_LOG = 4 + TRACE_ITEM_METRIC = 5 snuba_query = FlexibleForeignKey("sentry.SnubaQuery") type = models.SmallIntegerField() diff --git a/src/sentry/snuba/snuba_query_validator.py b/src/sentry/snuba/snuba_query_validator.py index ccc3819d5e618e..0258c2d58da6b6 100644 --- a/src/sentry/snuba/snuba_query_validator.py +++ b/src/sentry/snuba/snuba_query_validator.py @@ -15,7 +15,7 @@ InvalidSearchQuery, UnsupportedQuerySubscription, ) -from sentry.explore.utils import is_logs_enabled +from sentry.explore.utils import is_logs_enabled, is_trace_metrics_alerts_enabled from sentry.incidents.logic import ( check_aggregate_column_support, get_column_from_aggregate, @@ -23,6 +23,7 @@ translate_aggregate_field, ) from sentry.incidents.utils.constants import INCIDENTS_SNUBA_SUBSCRIPTION_TYPE +from sentry.search.eap.trace_metrics.validator import validate_trace_metrics_aggregate from sentry.snuba.dataset import Dataset from sentry.snuba.entity_subscription import ( ENTITY_TIME_COLUMNS, @@ -68,6 +69,7 @@ SnubaQueryEventType.EventType.TRANSACTION, SnubaQueryEventType.EventType.TRACE_ITEM_LOG, SnubaQueryEventType.EventType.TRACE_ITEM_SPAN, + SnubaQueryEventType.EventType.TRACE_ITEM_METRIC, }, } @@ -165,6 +167,13 @@ def validate_event_types(self, value: Sequence[str]) -> list[SnubaQueryEventType ) and any([v for v in validated if v == SnubaQueryEventType.EventType.TRACE_ITEM_LOG]): raise serializers.ValidationError("You do not have access to the log alerts feature.") + if not is_trace_metrics_alerts_enabled( + self.context["organization"], actor=self.context.get("user", None) + ) and any([v for v in validated if v == SnubaQueryEventType.EventType.TRACE_ITEM_METRIC]): + raise serializers.ValidationError( + "You do not have access to the metrics alerts feature." + ) + return validated def validate_extrapolation_mode(self, extrapolation_mode: str) -> ExtrapolationMode | None: @@ -207,6 +216,7 @@ def validate(self, data): def _validate_aggregate(self, data): dataset = data.setdefault("dataset", Dataset.Events) aggregate = data.get("aggregate") + event_types = data.get("event_types", []) allow_mri = features.has( "organizations:custom-metrics", self.context["organization"], @@ -217,22 +227,32 @@ def _validate_aggregate(self, data): actor=self.context.get("user", None), ) allow_eap = dataset == Dataset.EventsAnalyticsPlatform - try: - if not check_aggregate_column_support( - aggregate, - allow_mri=allow_mri, - allow_eap=allow_eap, - ): - raise serializers.ValidationError( - {"aggregate": _("Invalid Metric: We do not currently support this field.")} - ) - except InvalidSearchQuery as e: - raise serializers.ValidationError({"aggregate": _(f"Invalid Metric: {e}")}) - data["aggregate"] = translate_aggregate_field( - aggregate, allow_mri=allow_mri, allow_eap=allow_eap + # Check if this is a trace metrics query + is_trace_metrics = ( + dataset == Dataset.EventsAnalyticsPlatform + and event_types + and SnubaQueryEventType.EventType.TRACE_ITEM_METRIC in event_types ) + if is_trace_metrics: + validate_trace_metrics_aggregate(aggregate) + else: + try: + if not check_aggregate_column_support( + aggregate, + allow_mri=allow_mri, + allow_eap=allow_eap, + ): + raise serializers.ValidationError( + {"aggregate": _("Invalid Metric: We do not currently support this field.")} + ) + except InvalidSearchQuery as e: + raise serializers.ValidationError({"aggregate": _(f"Invalid Metric: {e}")}) + data["aggregate"] = translate_aggregate_field( + aggregate, allow_mri=allow_mri, allow_eap=allow_eap + ) + def _validate_query(self, data): dataset = data.setdefault("dataset", Dataset.Events) diff --git a/tests/sentry/incidents/endpoints/test_organization_alert_rule_index.py b/tests/sentry/incidents/endpoints/test_organization_alert_rule_index.py index 1674d57a11df13..2628875ba0bc04 100644 --- a/tests/sentry/incidents/endpoints/test_organization_alert_rule_index.py +++ b/tests/sentry/incidents/endpoints/test_organization_alert_rule_index.py @@ -639,6 +639,144 @@ def test_create_alert_rule_logs(self, mock_create_subscription_in_snuba: MagicMo == SnubaQueryEventType.EventType.TRACE_ITEM_LOG.value ) + @patch( + "sentry.snuba.subscriptions.create_subscription_in_snuba.delay", + wraps=create_subscription_in_snuba, + ) + def test_create_alert_rule_trace_metrics_feature_flag_disabled( + self, mock_create_subscription_in_snuba: MagicMock + ) -> None: + data = deepcopy(self.alert_rule_dict) + data["dataset"] = "events_analytics_platform" + data["alertType"] = "eap_metrics" + data["aggregate"] = "count(trace.duration)" + data["eventTypes"] = ["trace_item_metric"] + data["timeWindow"] = 5 + with ( + outbox_runner(), + self.feature( + { + "organizations:incidents": True, + "organizations:performance-view": True, + "organizations:tracemetrics-alerts": False, + "organizations:tracemetrics-enabled": False, + }, + ), + ): + resp = self.get_error_response( + self.organization.slug, + status_code=400, + **data, + ) + assert "You do not have access to the metrics alerts feature" in str(resp.data) + + @patch( + "sentry.snuba.subscriptions.create_subscription_in_snuba.delay", + wraps=create_subscription_in_snuba, + ) + def test_create_alert_rule_trace_metrics_invalid_aggregate( + self, mock_create_subscription_in_snuba: MagicMock + ) -> None: + data = deepcopy(self.alert_rule_dict) + data["dataset"] = "events_analytics_platform" + data["alertType"] = "eap_metrics" + data["aggregate"] = "count(trace.duration)" + data["eventTypes"] = ["trace_item_metric"] + data["timeWindow"] = 5 + with ( + outbox_runner(), + self.feature( + [ + "organizations:incidents", + "organizations:performance-view", + "organizations:tracemetrics-alerts", + "organizations:tracemetrics-enabled", + ] + ), + ): + resp = self.get_error_response( + self.organization.slug, + status_code=400, + **data, + ) + assert "Invalid trace metrics aggregate" in str(resp.data["aggregate"][0]) + + @patch( + "sentry.snuba.subscriptions.create_subscription_in_snuba.delay", + wraps=create_subscription_in_snuba, + ) + def test_create_alert_rule_trace_metrics_valid_aggregates( + self, mock_create_subscription_in_snuba: MagicMock + ) -> None: + data1 = deepcopy(self.alert_rule_dict) + data1["name"] = "Trace Metrics Per Second Alert" + data1["dataset"] = "events_analytics_platform" + data1["alertType"] = "eap_metrics" + data1["aggregate"] = "per_second(value,metric_name_one,counter,-)" + data1["eventTypes"] = ["trace_item_metric"] + data1["timeWindow"] = 5 + + data2 = deepcopy(self.alert_rule_dict) + data2["name"] = "Trace Metrics Count Alert" + data2["dataset"] = "events_analytics_platform" + data2["alertType"] = "eap_metrics" + data2["aggregate"] = "count(metric.name,metric_name_two,distribution,-)" + data2["eventTypes"] = ["trace_item_metric"] + data2["timeWindow"] = 5 + + with ( + outbox_runner(), + self.feature( + [ + "organizations:incidents", + "organizations:performance-view", + "organizations:tracemetrics-alerts", + "organizations:tracemetrics-enabled", + ] + ), + ): + with patch.object(_snuba_pool, "urlopen", side_effect=_snuba_pool.urlopen) as urlopen: + resp1 = self.get_success_response( + self.organization.slug, + status_code=201, + **data1, + ) + + resp2 = self.get_success_response( + self.organization.slug, + status_code=201, + **data2, + ) + + assert urlopen.call_count == 2 + for call_args in urlopen.call_args_list: + rpc_request_body = call_args[1]["body"] + createSubscriptionRequest = CreateSubscriptionRequest.FromString( + rpc_request_body + ) + assert ( + createSubscriptionRequest.time_series_request.meta.trace_item_type + == TraceItemType.TRACE_ITEM_TYPE_METRIC + ) + + assert "id" in resp1.data + alert_rule1 = AlertRule.objects.get(id=resp1.data["id"]) + assert resp1.data == serialize(alert_rule1, self.user) + assert resp1.data["aggregate"] == "per_second(value,metric_name_one,counter,-)" + assert ( + SnubaQueryEventType.objects.filter(snuba_query_id=alert_rule1.snuba_query_id)[0].type + == SnubaQueryEventType.EventType.TRACE_ITEM_METRIC.value + ) + + assert "id" in resp2.data + alert_rule2 = AlertRule.objects.get(id=resp2.data["id"]) + assert resp2.data == serialize(alert_rule2, self.user) + assert resp2.data["aggregate"] == "count(metric.name,metric_name_two,distribution,-)" + assert ( + SnubaQueryEventType.objects.filter(snuba_query_id=alert_rule2.snuba_query_id)[0].type + == SnubaQueryEventType.EventType.TRACE_ITEM_METRIC.value + ) + @with_feature("organizations:anomaly-detection-alerts") @with_feature("organizations:incidents") @patch( diff --git a/tests/sentry/incidents/endpoints/validators/test_validators.py b/tests/sentry/incidents/endpoints/validators/test_validators.py index 39792873a2facd..d1cc736d8d3a60 100644 --- a/tests/sentry/incidents/endpoints/validators/test_validators.py +++ b/tests/sentry/incidents/endpoints/validators/test_validators.py @@ -603,6 +603,143 @@ def test_transaction_dataset_deprecation_multiple_data_sources(self) -> None: validator.save() +class TestMetricAlertsTraceMetricsValidator(TestMetricAlertsDetectorValidator): + def setUp(self) -> None: + super().setUp() + self.trace_metrics_data = { + "name": "Trace Metrics Detector", + "type": MetricIssue.slug, + "dataSources": [ + { + "queryType": SnubaQuery.Type.PERFORMANCE.value, + "dataset": Dataset.EventsAnalyticsPlatform.value, + "query": "", + "aggregate": "per_second(value,metric_name_one,counter,-)", + "timeWindow": 300, + "environment": self.environment.name, + "eventTypes": [SnubaQueryEventType.EventType.TRACE_ITEM_METRIC.name.lower()], + } + ], + "conditionGroup": { + "logicType": self.data_condition_group.logic_type, + "conditions": [ + { + "type": Condition.GREATER, + "comparison": 100, + "conditionResult": DetectorPriorityLevel.HIGH, + }, + { + "type": Condition.LESS_OR_EQUAL, + "comparison": 100, + "conditionResult": DetectorPriorityLevel.OK, + }, + ], + }, + "config": { + "thresholdPeriod": 1, + "detectionType": AlertRuleDetectionType.STATIC.value, + }, + } + + @with_feature( + { + "organizations:performance-view": False, + "organizations:tracemetrics-alerts": False, + "organizations:tracemetrics-enabled": False, + } + ) + def test_create_detector_trace_metrics_feature_flag_disabled(self) -> None: + validator = MetricIssueDetectorValidator( + data=self.trace_metrics_data, + context=self.context, + ) + assert not validator.is_valid() + data_sources_errors = validator.errors.get("dataSources") + assert data_sources_errors is not None + assert "You do not have access to the metrics alerts feature." in str(data_sources_errors) + + @with_feature( + [ + "organizations:incidents", + "organizations:performance-view", + "organizations:tracemetrics-alerts", + "organizations:tracemetrics-enabled", + ] + ) + def test_create_detector_trace_metrics_invalid_aggregate(self) -> None: + data = { + **self.trace_metrics_data, + "dataSources": [ + { + **self.trace_metrics_data["dataSources"][0], # type: ignore[index] + "aggregate": "count(trace.duration)", + } + ], + } + validator = MetricIssueDetectorValidator(data=data, context=self.context) + assert not validator.is_valid() + assert "Invalid trace metrics aggregate" in str(validator.errors) + + @with_feature( + [ + "organizations:incidents", + "organizations:performance-view", + "organizations:tracemetrics-alerts", + "organizations:tracemetrics-enabled", + ] + ) + def test_create_detector_trace_metrics_valid_aggregates(self) -> None: + data_per_second = { + **self.trace_metrics_data, + "name": "Trace Metrics Per Second Detector", + "dataSources": [ + { + **self.trace_metrics_data["dataSources"][0], # type: ignore[index] + "aggregate": "per_second(value,metric_name_one,counter,-)", + } + ], + } + validator_per_second = MetricIssueDetectorValidator( + data=data_per_second, context=self.context + ) + assert validator_per_second.is_valid(), validator_per_second.errors + + with self.tasks(): + detector_per_second = validator_per_second.save() + + assert detector_per_second.name == "Trace Metrics Per Second Detector" + data_source_per_second = DataSource.objects.get(detector=detector_per_second) + query_sub_per_second = QuerySubscription.objects.get(id=data_source_per_second.source_id) + assert ( + query_sub_per_second.snuba_query.aggregate + == "per_second(value,metric_name_one,counter,-)" + ) + + data_count = { + **self.trace_metrics_data, + "name": "Trace Metrics Count Detector", + "dataSources": [ + { + **self.trace_metrics_data["dataSources"][0], # type: ignore[index] + "aggregate": "count(metric.name,metric_name_two,distribution,-)", + } + ], + } + validator_count = MetricIssueDetectorValidator(data=data_count, context=self.context) + assert validator_count.is_valid(), validator_count.errors + + with self.tasks(): + detector_count = validator_count.save() + + assert detector_count.name == "Trace Metrics Count Detector" + data_source_count = DataSource.objects.get(detector=detector_count) + query_sub_count = QuerySubscription.objects.get(id=data_source_count.source_id) + assert ( + query_sub_count.snuba_query.aggregate + == "count(metric.name,metric_name_two,distribution,-)" + ) + + class TestMetricAlertsUpdateDetectorValidator(TestMetricAlertsDetectorValidator): def test_update_with_valid_data(self) -> None: """ diff --git a/tests/sentry/incidents/test_metric_issue_detector_handler.py b/tests/sentry/incidents/test_metric_issue_detector_handler.py index 317e18ebb5649a..58395b6eabb79a 100644 --- a/tests/sentry/incidents/test_metric_issue_detector_handler.py +++ b/tests/sentry/incidents/test_metric_issue_detector_handler.py @@ -338,3 +338,18 @@ def test_extract_eap_metrics_alert(self) -> None: ) == "eap_metrics" ) + + def test_extract_eap_metrics_alert_trace_metrics(self) -> None: + assert ( + get_alert_type_from_aggregate_dataset( + "per_second(value,metric_name_one,counter,-)", Dataset.EventsAnalyticsPlatform + ) + == "eap_metrics" + ) + assert ( + get_alert_type_from_aggregate_dataset( + "count(metric.name,metric_name_two,distribution,-)", + Dataset.EventsAnalyticsPlatform, + ) + == "eap_metrics" + )