Browse Source

feat(metrics): add metrics extraction rule endpoint (#72768)

Simon Hellmayr 8 months ago
parent
commit
4e071ead0b

+ 147 - 0
src/sentry/api/endpoints/project_metrics_extraction_rules.py

@@ -0,0 +1,147 @@
+from collections.abc import Sequence
+
+from rest_framework.request import Request
+from rest_framework.response import Response
+
+from sentry import features
+from sentry.api.api_owners import ApiOwner
+from sentry.api.api_publish_status import ApiPublishStatus
+from sentry.api.base import region_silo_endpoint
+from sentry.api.bases import ProjectEndpoint
+from sentry.api.paginator import OffsetPaginator
+from sentry.api.serializers import serialize
+from sentry.api.serializers.models.metrics_extraction_rules import MetricsExtractionRuleSerializer
+from sentry.models.project import Project
+from sentry.sentry_metrics.extraction_rules import (
+    MetricsExtractionRule,
+    create_metrics_extraction_rules,
+    delete_metrics_extraction_rules,
+    get_metrics_extraction_rules,
+    update_metrics_extraction_rules,
+)
+from sentry.utils import json
+
+"""
+Open Questions
+1. Do we need to register the key metricsExtractionRules in the project config somehow?
+2. Do we need to prevent breaking something in the project config somehow?
+"""
+
+
+@region_silo_endpoint
+class ProjectMetricsExtractionRulesEndpoint(ProjectEndpoint):
+    publish_status = {
+        "DELETE": ApiPublishStatus.EXPERIMENTAL,
+        "GET": ApiPublishStatus.EXPERIMENTAL,
+        "POST": ApiPublishStatus.EXPERIMENTAL,
+        "PUT": ApiPublishStatus.EXPERIMENTAL,
+    }
+    owner = ApiOwner.TELEMETRY_EXPERIENCE
+
+    def has_feature(self, organization, request):
+        return features.has(
+            "organizations:custom-metrics-extraction-rule", organization, actor=request.user
+        )
+
+    def delete(self, request: Request, project: Project) -> Response:
+        """DELETE an extraction rule in a project. Returns 204 No Data on success."""
+        if not self.has_feature(project.organization, request):
+            return Response(status=404)
+
+        rules_update = request.data.get("metricsExtractionRules") or ""
+        if len(rules_update) == 0:
+            return Response(status=204)
+
+        try:
+            state_update = self._deserialize_deleted_rules_update(rules_update)
+            delete_metrics_extraction_rules(project, state_update)
+        except Exception as e:
+            return Response(status=500, data={"detail": str(e)})
+
+        return Response(status=204)
+
+    def get(self, request: Request, project: Project) -> Response:
+        """GET extraction rules for project. Returns 200 and a list of extraction rules on success."""
+        if not self.has_feature(project.organization, request):
+            return Response(status=404)
+
+        try:
+            extraction_rules = get_metrics_extraction_rules(project)
+        except Exception as e:
+            return Response(status=500, data={"detail": str(e)})
+
+        return self.paginate(
+            request,
+            queryset=extraction_rules,
+            paginator_cls=OffsetPaginator,
+            on_results=lambda x: serialize(
+                x, user=request.user, serializer=MetricsExtractionRuleSerializer()
+            ),
+            default_per_page=25,
+        )
+
+    def post(self, request: Request, project: Project) -> Response:
+        """POST an extraction rule to create a resource."""
+        if not self.has_feature(project.organization, request):
+            return Response(status=404)
+
+        rules_update = request.data.get("metricsExtractionRules")
+
+        if not rules_update or len(rules_update) == 0:
+            return Response(
+                status=400,
+                data={"detail": "Please specify the metric extraction rule to be created."},
+            )
+
+        try:
+            state_update = self._deserialize_rules_update(rules_update)
+            persisted_rules = create_metrics_extraction_rules(project, state_update)
+            updated_rules = serialize(
+                persisted_rules, request.user, MetricsExtractionRuleSerializer()
+            )
+        except Exception as e:
+            return Response(status=400, data={"detail": str(e)})
+
+        return Response(status=200, data=updated_rules)
+
+    def put(self, request: Request, project: Project) -> Response:
+        """PUT to modify an existing extraction rule."""
+        if not self.has_feature(project.organization, request):
+            return Response(status=404)
+
+        rules_update = request.data.get("metricsExtractionRules")
+
+        if not rules_update or len(rules_update) == 0:
+            return Response(status=200)
+
+        try:
+            state_update = self._deserialize_rules_update(rules_update)
+            persisted_rules = update_metrics_extraction_rules(project, state_update)
+            updated_rules = serialize(
+                persisted_rules, request.user, MetricsExtractionRuleSerializer()
+            )
+        except Exception as e:
+            return Response(status=400, data={"detail": str(e)})
+
+        return Response(status=200, data=updated_rules)
+
+    def _deserialize_rules_update(self, json_payload: str):
+        state_update = {}
+        deserialized_rules_update = json.loads(json_payload)
+
+        for deserialized_rule_update in deserialized_rules_update:
+            rule = MetricsExtractionRule.from_dict(deserialized_rule_update)
+            mri = rule.generate_mri()
+            state_update[mri] = rule
+
+        return state_update
+
+    def _deserialize_deleted_rules_update(
+        self, json_payload: str
+    ) -> Sequence[MetricsExtractionRule]:
+        deserialized_rules = json.loads(json_payload)
+        state_update = []
+        for updated_rule in deserialized_rules:
+            state_update.append(MetricsExtractionRule.from_dict(updated_rule))
+
+        return state_update

+ 18 - 0
src/sentry/api/serializers/models/metrics_extraction_rules.py

@@ -0,0 +1,18 @@
+from sentry.api.serializers import Serializer
+
+
+class MetricsExtractionRuleSerializer(Serializer):
+    def __init__(self, *args, **kwargs):
+        Serializer.__init__(self, *args, **kwargs)
+
+    def get_attrs(self, item_list, user):
+        return {item: item.__dict__ for item in item_list}
+
+    def serialize(self, obj, attrs, user):
+        return {
+            "spanAttribute": attrs.get("span_attribute"),
+            "type": attrs.get("type"),
+            "unit": attrs.get("unit"),
+            "tags": list(attrs.get("tags") or set()),
+            "conditions": list(attrs.get("conditions") or set()),
+        }

+ 6 - 0
src/sentry/api/urls.py

@@ -537,6 +537,7 @@ from .endpoints.project_key_stats import ProjectKeyStatsEndpoint
 from .endpoints.project_keys import ProjectKeysEndpoint
 from .endpoints.project_member_index import ProjectMemberIndexEndpoint
 from .endpoints.project_metrics import ProjectMetricsVisibilityEndpoint
+from .endpoints.project_metrics_extraction_rules import ProjectMetricsExtractionRulesEndpoint
 from .endpoints.project_ownership import ProjectOwnershipEndpoint
 from .endpoints.project_performance_general_settings import (
     ProjectPerformanceGeneralSettingsEndpoint,
@@ -2385,6 +2386,11 @@ PROJECT_URLS: list[URLPattern | URLResolver] = [
         ProjectMetricsVisibilityEndpoint.as_view(),
         name="sentry-api-0-project-metrics-visibility",
     ),
+    re_path(
+        r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/metrics/extraction-rules/$",
+        ProjectMetricsExtractionRulesEndpoint.as_view(),
+        name="sentry-api-0-project-metrics-extraction-rules",
+    ),
     re_path(
         r"^(?P<organization_id_or_slug>[^\/]+)/(?P<project_id_or_slug>[^\/]+)/releases/$",
         ProjectReleasesEndpoint.as_view(),

+ 2 - 0
src/sentry/features/temporary.py

@@ -82,6 +82,8 @@ def register_temporary_features(manager: FeatureManager):
     # Delightful Developer Metrics (DDM):
     # Enables experimental WIP custom metrics related features
     manager.add("organizations:custom-metrics-experimental", OrganizationFeature, FeatureHandlerStrategy.REMOTE)
+    # Enables custom metrics extraction rule endpoint
+    manager.add("organizations:custom-metrics-extraction-rule", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE)
     # Hides DDM sidebar item
     manager.add("organizations:ddm-sidebar-item-hidden", OrganizationFeature, FeatureHandlerStrategy.REMOTE)
     # Enable the default alert at project creation to be the high priority alert

+ 1 - 0
src/sentry/models/options/project_option.py

@@ -56,6 +56,7 @@ OPTION_KEYS = frozenset(
         "sentry:grouping_auto_update",
         "sentry:fingerprinting_rules",
         "sentry:relay_pii_config",
+        "sentry:metrics_extraction_rules",
         "sentry:dynamic_sampling",
         "sentry:dynamic_sampling_biases",
         "sentry:breakdowns",

+ 137 - 0
src/sentry/sentry_metrics/extraction_rules.py

@@ -0,0 +1,137 @@
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from typing import Any
+
+from sentry.models.project import Project
+from sentry.sentry_metrics.use_case_utils import string_to_use_case_id
+from sentry.tasks.relay import schedule_invalidate_project_config
+from sentry.utils import json
+
+METRICS_EXTRACTION_RULES_OPTION_KEY = "sentry:metrics_extraction_rules"
+
+
+class MetricsExtractionRuleValidationError(ValueError):
+    pass
+
+
+HARD_CODED_UNITS = {"span.duration": "millisecond"}
+
+
+@dataclass(frozen=True)
+class MetricsExtractionRule:
+    span_attribute: str
+    type: str
+    unit: str
+    tags: set[str]
+    conditions: list[str]
+
+    @classmethod
+    def from_dict(cls, dictionary: Mapping[str, Any]) -> "MetricsExtractionRule":
+        return MetricsExtractionRule(
+            span_attribute=dictionary["spanAttribute"],
+            type=dictionary["type"],
+            unit=HARD_CODED_UNITS.get(dictionary["spanAttribute"], "none"),
+            tags=set(dictionary.get("tags") or set()),
+            conditions=list(dictionary.get("conditions") or []),
+        )
+
+    def to_dict(self) -> Mapping[str, Any]:
+        return {
+            "spanAttribute": self.span_attribute,
+            "type": self.type,
+            "unit": self.unit,
+            "tags": self.tags,
+            "conditions": self.conditions,
+        }
+
+    def generate_mri(self, use_case: str = "custom"):
+        """Generate the Metric Resource Identifier (MRI) associated with the extraction rule."""
+        use_case_id = string_to_use_case_id(use_case)
+        return f"{self.type}:{use_case_id}/{self.span_attribute}@{self.unit}"
+
+    def __hash__(self):
+        return hash(self.generate_mri())
+
+
+@dataclass(frozen=True)
+class MetricsExtractionRuleState:
+    rules: dict[str, MetricsExtractionRule]
+
+    @classmethod
+    def load_from_project(cls, project: Project) -> "MetricsExtractionRuleState":
+        json_payload = project.get_option(METRICS_EXTRACTION_RULES_OPTION_KEY)
+        return MetricsExtractionRuleState.from_json(json_payload)
+
+    @classmethod
+    def from_json(cls, json_payload: str):
+        if not json_payload:
+            return MetricsExtractionRuleState(rules={})
+        try:
+            metrics_extraction_rules = json.loads(json_payload)
+        except Exception:
+            raise MetricsExtractionRuleValidationError("Invalid JSON Payload.")
+
+        rules: dict[str, MetricsExtractionRule] = {}
+        for metrics_extraction_rule in metrics_extraction_rules:
+            rule = MetricsExtractionRule.from_dict(metrics_extraction_rule)
+            if rule is not None:
+                mri = rule.generate_mri()
+                rules[mri] = rule
+
+        return MetricsExtractionRuleState(rules=rules)
+
+    def save_to_project(self, project: Project) -> None:
+        metrics_extraction_rules = [rule.to_dict() for rule in self.rules.values()]
+
+        json_payload = json.dumps(metrics_extraction_rules)
+        project.update_option(METRICS_EXTRACTION_RULES_OPTION_KEY, json_payload)
+
+        # We invalidate the project configuration once the updated settings were stored.
+        schedule_invalidate_project_config(
+            project_id=project.id, trigger="metrics_extraction_rules"
+        )
+        return
+
+    def get_rules(self) -> Sequence[MetricsExtractionRule]:
+        return list(self.rules.values())
+
+    def delete_rule(self, rule: MetricsExtractionRule) -> None:
+        mri = rule.generate_mri()
+        if mri in self.rules:
+            del self.rules[mri]
+        else:
+            return
+
+
+def create_metrics_extraction_rules(
+    project: Project, state_update: dict[str, MetricsExtractionRule]
+) -> Sequence[MetricsExtractionRule]:
+    state = MetricsExtractionRuleState.load_from_project(project)
+    state.rules.update(state_update)
+    state.save_to_project(project)
+    return state.get_rules()
+
+
+def update_metrics_extraction_rules(
+    project: Project, state_update: dict[str, MetricsExtractionRule]
+) -> Sequence[MetricsExtractionRule]:
+    state = MetricsExtractionRuleState.load_from_project(project)
+    state.rules.update(state_update)
+    state.save_to_project(project)
+    return state.get_rules()
+
+
+def delete_metrics_extraction_rules(
+    project: Project, state_update: Sequence[MetricsExtractionRule]
+) -> None:
+    state = MetricsExtractionRuleState.load_from_project(project)
+    for rule in state_update:
+        state.delete_rule(rule)
+
+    state.save_to_project(project)
+    return
+
+
+def get_metrics_extraction_rules(project: Project) -> Sequence[MetricsExtractionRule]:
+    state = MetricsExtractionRuleState.load_from_project(project)
+    return state.get_rules()

+ 386 - 0
tests/sentry/api/endpoints/test_project_metrics_extraction_rules.py

@@ -0,0 +1,386 @@
+from django.urls import reverse
+
+from sentry.models.apitoken import ApiToken
+from sentry.sentry_metrics.extraction_rules import MetricsExtractionRuleState
+from sentry.silo.base import SiloMode
+from sentry.testutils.cases import APITestCase
+from sentry.testutils.helpers import with_feature
+from sentry.testutils.silo import assume_test_silo_mode
+from sentry.utils import json
+
+
+class ProjectMetricsExtractionEndpointTestCase(APITestCase):
+    endpoint = "sentry-api-0-project-metrics-extraction-rules"
+
+    def setUp(self):
+        self.login_as(user=self.user)
+
+    @with_feature("organizations:custom-metrics-extraction-rule")
+    def send_put_request(self, token, endpoint):
+        url = reverse(endpoint, args=(self.project.organization.slug, self.project.slug))
+        return self.client.put(url, HTTP_AUTHORIZATION=f"Bearer {token.token}", format="json")
+
+    @with_feature("organizations:custom-metrics-extraction-rule")
+    def test_permissions(self):
+        with assume_test_silo_mode(SiloMode.CONTROL):
+            token = ApiToken.objects.create(user=self.user, scope_list=[])
+
+        response = self.send_put_request(token, self.endpoint)
+        assert response.status_code == 403
+
+        with assume_test_silo_mode(SiloMode.CONTROL):
+            token = ApiToken.objects.create(user=self.user, scope_list=["project:write"])
+
+        response = self.send_put_request(token, self.endpoint)
+        assert response.status_code != 403
+
+    @with_feature("organizations:custom-metrics-extraction-rule")
+    def test_create_new_extraction_rule(self):
+        new_rule_json_1 = [
+            {
+                "spanAttribute": "count_clicks",
+                "type": "c",
+                "unit": "none",
+                "tags": ["tag1", "tag2", "tag3"],
+                "conditions": ["foo:bar", "baz:faz"],
+            }
+        ]
+
+        response = self.get_response(
+            self.organization.slug,
+            self.project.slug,
+            method="post",
+            metricsExtractionRules=json.dumps(new_rule_json_1),
+        )
+
+        assert response.status_code == 200
+        data = response.data
+        assert len(data) == 1
+        assert data[0]["spanAttribute"] == "count_clicks"
+        assert data[0]["type"] == "c"
+        assert data[0]["unit"] == "none"
+        assert set(data[0]["tags"]) == {"tag1", "tag2", "tag3"}
+
+        new_rule_json_2 = [
+            {
+                "spanAttribute": "process_latency",
+                "type": "d",
+                "unit": "ms",
+                "tags": ["tag3"],
+                "conditions": ["hello:world", "baz:faz"],
+            }
+        ]
+
+        response = self.get_success_response(
+            self.organization.slug,
+            self.project.slug,
+            method="post",
+            metricsExtractionRules=json.dumps(new_rule_json_2),
+        )
+        assert response.status_code == 200
+        data = response.data
+        assert len(data) == 2
+        assert data[1]["spanAttribute"] == "process_latency"
+        assert data[1]["type"] == "d"
+        assert data[1]["unit"] == "none"
+        assert data[1]["conditions"] == ["hello:world", "baz:faz"]
+        assert set(data[1]["tags"]) == {"tag3"}
+
+        project_state = MetricsExtractionRuleState.load_from_project(self.project)
+        project_rules = project_state.get_rules()
+        assert len(project_rules) == 2
+        assert ["count_clicks", "process_latency"] == sorted(
+            r.span_attribute for r in project_rules
+        )
+
+    @with_feature("organizations:custom-metrics-extraction-rule")
+    def test_create_new_extraction_rule_hardcoded_units(self):
+        new_rule_json_1 = [
+            {
+                "spanAttribute": "span.duration",
+                "type": "d",
+                "unit": "none",
+                "tags": ["tag1", "tag2", "tag3"],
+                "conditions": ["foo:bar", "baz:faz"],
+            }
+        ]
+
+        response = self.get_response(
+            self.organization.slug,
+            self.project.slug,
+            method="post",
+            metricsExtractionRules=json.dumps(new_rule_json_1),
+        )
+
+        assert response.status_code == 200
+        data = response.data
+        assert len(data) == 1
+        assert data[0]["spanAttribute"] == "span.duration"
+        assert data[0]["type"] == "d"
+        assert data[0]["unit"] == "millisecond"
+        assert set(data[0]["tags"]) == {"tag1", "tag2", "tag3"}
+
+    @with_feature("organizations:custom-metrics-extraction-rule")
+    def test_update_existing_extraction_rule(self):
+        original_rule_json = [
+            {"spanAttribute": "process_latency", "type": "d", "unit": "ms", "tags": ["tag3"]}
+        ]
+
+        response = self.get_success_response(
+            self.organization.slug,
+            self.project.slug,
+            method="put",
+            metricsExtractionRules=json.dumps(original_rule_json),
+        )
+        assert response.status_code == 200
+
+        updated_rule_json = """[{"spanAttribute": "process_latency", "type": "d","unit": "ms","tags": ["tag3", "new_tag"]}]"""
+
+        response = self.get_success_response(
+            self.organization.slug,
+            self.project.slug,
+            method="put",
+            metricsExtractionRules=updated_rule_json,
+        )
+        assert response.status_code == 200
+        data = response.data
+        assert len(data) == 1
+        assert data[0]["spanAttribute"] == "process_latency"
+        assert data[0]["type"] == "d"
+        assert data[0]["unit"] == "none"
+        assert set(data[0]["tags"]) == {"tag3", "new_tag"}
+
+        project_state = MetricsExtractionRuleState.load_from_project(self.project)
+        project_rules = project_state.get_rules()
+        assert len(project_rules) == 1
+        assert ["process_latency"] == sorted(r.span_attribute for r in project_rules)
+
+    @with_feature("organizations:custom-metrics-extraction-rule")
+    def test_delete_existing_extraction_rule(self):
+        new_rule_json_1 = """[{"spanAttribute": "count_clicks", "type": "c","unit": "none","tags": ["tag1", "tag2", "tag3"]}]"""
+
+        response = self.get_success_response(
+            self.organization.slug,
+            self.project.slug,
+            method="post",
+            metricsExtractionRules=new_rule_json_1,
+        )
+
+        assert response.status_code == 200
+        data = response.data
+        assert len(data) == 1
+        assert data[0]["spanAttribute"] == "count_clicks"
+        assert data[0]["type"] == "c"
+        assert data[0]["unit"] == "none"
+        assert set(data[0]["tags"]) == {"tag1", "tag2", "tag3"}
+
+        new_rule_json_2 = (
+            """[{"spanAttribute": "process_latency", "type": "d","unit": "ms","tags": ["tag3"]}]"""
+        )
+
+        response = self.get_success_response(
+            self.organization.slug,
+            self.project.slug,
+            method="post",
+            metricsExtractionRules=new_rule_json_2,
+        )
+        assert response.status_code == 200
+        data = response.data
+        assert len(data) == 2
+        assert data[1]["spanAttribute"] == "process_latency"
+        assert data[1]["type"] == "d"
+        assert data[1]["unit"] == "none"
+        assert set(data[1]["tags"]) == {"tag3"}
+
+        project_state = MetricsExtractionRuleState.load_from_project(self.project)
+        project_rules = project_state.get_rules()
+        assert len(project_rules) == 2
+        assert ["count_clicks", "process_latency"] == sorted(
+            r.span_attribute for r in project_rules
+        )
+
+        response = self.get_response(
+            self.organization.slug,
+            self.project.slug,
+            method="delete",
+            metricsExtractionRules=new_rule_json_2,
+        )
+        assert response.status_code == 204
+
+        project_state = MetricsExtractionRuleState.load_from_project(self.project)
+        project_rules = project_state.get_rules()
+        assert len(project_rules) == 1
+        assert ["count_clicks"] == [r.span_attribute for r in project_rules]
+
+    @with_feature("organizations:custom-metrics-extraction-rule")
+    def test_idempotent_update(self):
+        rule_json = (
+            """[{"spanAttribute": "process_latency", "type": "d","unit": "ms","tags": ["tag3"]}]"""
+        )
+
+        response = self.get_success_response(
+            self.organization.slug,
+            self.project.slug,
+            method="post",
+            metricsExtractionRules=rule_json,
+        )
+        assert response.status_code == 200
+
+        response = self.get_success_response(
+            self.organization.slug,
+            self.project.slug,
+            method="put",
+            metricsExtractionRules=rule_json,
+        )
+        assert response.status_code == 200
+        data = response.data
+        assert len(data) == 1
+        assert data[0]["spanAttribute"] == "process_latency"
+        assert data[0]["type"] == "d"
+        assert data[0]["unit"] == "none"
+        assert set(data[0]["tags"]) == {"tag3"}
+
+        project_state = MetricsExtractionRuleState.load_from_project(self.project)
+        project_rules = project_state.get_rules()
+        assert len(project_rules) == 1
+        assert ["process_latency"] == sorted(r.span_attribute for r in project_rules)
+
+    @with_feature("organizations:custom-metrics-extraction-rule")
+    def test_delete_non_existing_extraction_rule(self):
+        non_existing_rule = (
+            """[{"spanAttribute": "process_latency", "type": "d","unit": "ms","tags": ["tag3"]}]"""
+        )
+        response = self.get_response(
+            self.organization.slug,
+            self.project.slug,
+            method="delete",
+            metricsExtractionRules=non_existing_rule,
+        )
+        assert response.status_code == 204
+
+    @with_feature("organizations:custom-metrics-extraction-rule")
+    def test_malformed_json(self):
+        malformed_json = (
+            """[{"spanAttribute": "process_latency", "type": "d","unit": "ms","tags": ["tag3"],}]"""
+        )
+        response = self.get_response(
+            self.organization.slug,
+            self.project.slug,
+            method="delete",
+            metricsExtractionRules=malformed_json,
+        )
+        assert response.status_code == 500
+
+    def test_option_hides_endpoints(self):
+        rule_json = (
+            """[{"spanAttribute": "process_latency", "type": "d","unit": "ms","tags": ["tag3"]}]"""
+        )
+
+        response = self.get_response(
+            self.organization.slug,
+            self.project.slug,
+            method="put",
+            metricsExtractionRules=rule_json,
+        )
+        assert response.status_code == 404
+
+        response = self.get_response(
+            self.organization.slug,
+            self.project.slug,
+            method="delete",
+            metricsExtractionRules=rule_json,
+        )
+        assert response.status_code == 404
+
+        response = self.get_response(
+            self.organization.slug,
+            self.project.slug,
+            method="post",
+            metricsExtractionRules=rule_json,
+        )
+        assert response.status_code == 404
+
+        response = self.get_response(
+            self.organization.slug,
+            self.project.slug,
+            method="get",
+        )
+        assert response.status_code == 404
+
+    @with_feature("organizations:custom-metrics-extraction-rule")
+    def test_get_extraction_rules(self):
+        new_rule_json_1 = """[{"spanAttribute": "count_clicks", "type": "c","unit": "none","tags": ["tag1", "tag2", "tag3"]}]"""
+
+        response = self.get_success_response(
+            self.organization.slug,
+            self.project.slug,
+            method="post",
+            metricsExtractionRules=new_rule_json_1,
+        )
+
+        assert response.status_code == 200
+
+        new_rule_json_2 = (
+            """[{"spanAttribute": "process_latency", "type": "d","unit": "ms","tags": ["tag3"]}]"""
+        )
+
+        response = self.get_success_response(
+            self.organization.slug,
+            self.project.slug,
+            method="post",
+            metricsExtractionRules=new_rule_json_2,
+        )
+
+        response = self.get_response(
+            self.organization.slug,
+            self.project.slug,
+            method="get",
+        )
+        assert response.status_code == 200
+        data = response.data
+        assert len(data) == 2
+        assert data[0]["spanAttribute"] == "count_clicks"
+        assert data[1]["spanAttribute"] == "process_latency"
+
+    @with_feature("organizations:custom-metrics-extraction-rule")
+    def test_get_pagination(self):
+        for i in range(0, 60):
+            new_rule = f"""[{{"spanAttribute": "count_clicks_{i:02d}", "type": "c","unit": "none","tags": ["tag1", "tag2", "tag3"]}}]"""
+
+            response = self.get_success_response(
+                self.organization.slug,
+                self.project.slug,
+                method="post",
+                metricsExtractionRules=new_rule,
+            )
+            assert response.status_code == 200
+
+        response = self.get_success_response(
+            self.organization.slug, self.project.slug, method="get"
+        )
+        assert response.status_code == 200
+        span_attributes = [x["spanAttribute"] for x in response.data]
+        assert len(span_attributes) == 25
+        assert min(span_attributes) == "count_clicks_00"
+        assert max(span_attributes) == "count_clicks_24"
+        assert len(set(span_attributes)) == len(span_attributes)
+
+        response = self.get_success_response(
+            self.organization.slug, self.project.slug, method="get", cursor="25:1:0"
+        )
+        assert response.status_code == 200
+        span_attributes = [x["spanAttribute"] for x in response.data]
+        assert len(span_attributes) == 25
+        assert min(span_attributes) == "count_clicks_25"
+        assert max(span_attributes) == "count_clicks_49"
+        assert len(set(span_attributes)) == len(span_attributes)
+
+        response = self.get_success_response(
+            self.organization.slug, self.project.slug, method="get", cursor="25:2:0"
+        )
+        assert response.status_code == 200
+        span_attributes = [x["spanAttribute"] for x in response.data]
+        assert len(span_attributes) == 10
+        assert min(span_attributes) == "count_clicks_50"
+        assert max(span_attributes) == "count_clicks_59"
+        assert len(set(span_attributes)) == len(span_attributes)

+ 32 - 0
tests/sentry/sentry_metrics/test_extraction_rules.py

@@ -0,0 +1,32 @@
+from sentry.api.serializers import serialize
+from sentry.api.serializers.models.metrics_extraction_rules import MetricsExtractionRuleSerializer
+from sentry.sentry_metrics.extraction_rules import MetricsExtractionRule, MetricsExtractionRuleState
+from sentry.utils import json
+
+
+def test_serialization():
+    rules = [
+        MetricsExtractionRule("count_clicks", "c", "none", {"tag_1", "tag_2"}, []),
+        MetricsExtractionRule(
+            "process_latency",
+            "d",
+            "none",
+            {"tag_3"},
+            ["first:value second:value", "foo:bar", "greetings:['hello', 'goodbye']"],
+        ),
+        MetricsExtractionRule("unique_ids", "s", "none", set(), ["foo:bar"]),
+        MetricsExtractionRule("span.duration", "s", "millisecond", set(), ["foo:bar"]),
+    ]
+
+    rule_dict = {rule.generate_mri(): rule for rule in rules}
+
+    state = MetricsExtractionRuleState(rule_dict)
+    output_rules = state.get_rules()
+
+    serialized = serialize(output_rules, serializer=MetricsExtractionRuleSerializer())
+
+    assert len(serialized) == 4
+    json_payload = json.dumps(serialized)
+
+    serde_state = MetricsExtractionRuleState.from_json(json_payload)
+    assert state == serde_state