Browse Source

feat(metrics): Add APDEX derived metric (#33451)

Adds the [APDEX](https://docs.sentry.io/product/performance/metrics/#apdex) derived metric.
Iker Barriocanal 2 years ago
parent
commit
09c2e50b91

+ 13 - 0
src/sentry/snuba/metrics/fields/base.py

@@ -38,6 +38,7 @@ from sentry.snuba.metrics.fields.snql import (
     all_sessions,
     all_transactions,
     all_users,
+    apdex,
     crashed_sessions,
     crashed_users,
     division_float,
@@ -928,6 +929,18 @@ DERIVED_METRICS: Mapping[str, DerivedMetricExpression] = {
                 org_id=org_id, metric_ids=metric_ids, alias=alias
             ),
         ),
+        SingularEntityDerivedMetric(
+            metric_name=TransactionMRI.APDEX.value,
+            metrics=[
+                TransactionMRI.SATISFIED.value,
+                TransactionMRI.TOLERATED.value,
+                TransactionMRI.ALL.value,
+            ],
+            unit="percentage",
+            snql=lambda satisfied, tolerated, total, org_id, metric_ids, alias=None: apdex(
+                satisfied, tolerated, total, alias=alias
+            ),
+        ),
     ]
 }
 

+ 8 - 0
src/sentry/snuba/metrics/fields/snql.py

@@ -233,6 +233,14 @@ def tolerated_count_transaction(org_id, metric_ids, alias=None):
     )
 
 
+def apdex(satifactory_snql, tolerable_snql, total_snql, alias=None):
+    return division_float(
+        arg1_snql=addition(satifactory_snql, division_float(tolerable_snql, 2)),
+        arg2_snql=total_snql,
+        alias=alias,
+    )
+
+
 def percentage(arg1_snql, arg2_snql, alias=None):
     return Function("minus", [1, Function("divide", [arg1_snql, arg2_snql])], alias)
 

+ 1 - 0
src/sentry/snuba/metrics/naming_layer/mri.py

@@ -81,3 +81,4 @@ class TransactionMRI(Enum):
     FAILURE_RATE = "e:transaction/failure_rate@ratio"
     SATISFIED = "e:transactions/satisfied@none"
     TOLERATED = "e:transactions/tolerated@none"
+    APDEX = "e:transactions/apdex@ratio"

+ 1 - 0
src/sentry/snuba/metrics/naming_layer/public.py

@@ -72,6 +72,7 @@ class TransactionMetricKey(Enum):
     BREAKDOWNS_BROWSER = "transaction.breakdowns.ops.browser"
     BREAKDOWNS_RESOURCE = "transaction.breakdowns.ops.resource"
     FAILURE_RATE = "transaction.failure_rate"
+    APDEX = "transaction.apdex"
 
 
 # TODO: these tag keys and values below probably don't belong here, and should

+ 69 - 1
tests/sentry/api/endpoints/test_organization_metric_data.py

@@ -10,7 +10,11 @@ from freezegun import freeze_time
 from sentry.sentry_metrics import indexer
 from sentry.snuba.metrics import TransactionStatusTagValue, TransactionTagsKey
 from sentry.snuba.metrics.naming_layer.mri import SessionMRI, TransactionMRI
-from sentry.snuba.metrics.naming_layer.public import SessionMetricKey, TransactionMetricKey
+from sentry.snuba.metrics.naming_layer.public import (
+    SessionMetricKey,
+    TransactionMetricKey,
+    TransactionSatisfactionTagValue,
+)
 from sentry.testutils.cases import MetricsAPIBaseTestCase
 from sentry.utils.cursors import Cursor
 from tests.sentry.api.endpoints.test_organization_metrics import MOCKED_DERIVED_METRICS
@@ -986,6 +990,9 @@ class DerivedMetricsDataTest(MetricsAPIBaseTestCase):
         self.transaction_lcp_metric = indexer.record(
             self.organization.id, TransactionMRI.MEASUREMENTS_LCP.value
         )
+        self.tx_satisfaction = indexer.record(
+            self.organization.id, TransactionTagsKey.TRANSACTION_SATISFACTION.value
+        )
 
     @patch("sentry.snuba.metrics.fields.base.DERIVED_METRICS", MOCKED_DERIVED_METRICS)
     @patch("sentry.snuba.metrics.fields.base.get_public_name_from_mri")
@@ -1967,6 +1974,67 @@ class DerivedMetricsDataTest(MetricsAPIBaseTestCase):
                 "or a supported aggregate derived metric like `session.crash_free_rate"
             )
 
+    def test_apdex_transactions(self):
+        # See https://docs.sentry.io/product/performance/metrics/#apdex
+        user_ts = time.time()
+        self._send_buckets(
+            [
+                {
+                    "org_id": self.organization.id,
+                    "project_id": self.project.id,
+                    "metric_id": self.tx_metric,
+                    "timestamp": user_ts,
+                    "tags": {
+                        self.tx_satisfaction: indexer.record(
+                            self.organization.id, TransactionSatisfactionTagValue.SATISFIED.value
+                        ),
+                    },
+                    "type": "d",
+                    "value": [3.4],
+                    "retention_days": 90,
+                },
+                {
+                    "org_id": self.organization.id,
+                    "project_id": self.project.id,
+                    "metric_id": self.tx_metric,
+                    "timestamp": user_ts,
+                    "tags": {
+                        self.tx_satisfaction: indexer.record(
+                            self.organization.id, TransactionSatisfactionTagValue.TOLERATED.value
+                        ),
+                    },
+                    "type": "d",
+                    "value": [0.3],
+                    "retention_days": 90,
+                },
+                {
+                    "org_id": self.organization.id,
+                    "project_id": self.project.id,
+                    "metric_id": self.tx_metric,
+                    "timestamp": user_ts,
+                    "tags": {
+                        self.tx_satisfaction: indexer.record(
+                            self.organization.id, TransactionSatisfactionTagValue.TOLERATED.value
+                        ),
+                    },
+                    "type": "d",
+                    "value": [2.3],
+                    "retention_days": 90,
+                },
+            ],
+            entity="metrics_distributions",
+        )
+
+        response = self.get_success_response(
+            self.organization.slug,
+            field=["transaction.apdex"],
+            statsPeriod="1m",
+            interval="1m",
+        )
+
+        assert len(response.data["groups"]) == 1
+        assert response.data["groups"][0]["totals"] == {"transaction.apdex": 0.6666666666666666}
+
     def test_session_duration_derived_alias(self):
         org_id = self.organization.id
         user_ts = time.time()