Repository: getsentry/sentry

Issue:
ref(grouping): Add normalized SDK name as a tag on event_manager metrics
This re-applies previously reverted #59379 combining it with #59504 to normalize SDK tags reducing their cardinality.

Implements: #59053 



Discussion:
I'll re-base once #59504 lands, making it much smaller PR 
## [Codecov](https://app.codecov.io/gh/getsentry/sentry/pull/59572?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) Report
> Merging [#59572](https://app.codecov.io/gh/getsentry/sentry/pull/59572?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) (5c4fe5f) into [master](https://app.codecov.io/gh/getsentry/sentry/commit/579a8299c303479ed481f3741118ce1eac18f124?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) (579a829) will **increase** coverage by `0.00%`.
> Report is 4 commits behind head on master.
> The diff coverage is `n/a`.

```diff
@@           Coverage Diff           @@
##           master   #59572   +/-   ##
=======================================
  Coverage   80.76%   80.76%           
=======================================
  Files        5173     5173           
  Lines      226920   226920           
  Branches    38155    38155           
=======================================
+ Hits       183268   183276    +8     
+ Misses      38097    38092    -5     
+ Partials     5555     5552    -3     
```

[see 8 files with indirect coverage changes](https://app.codecov.io/gh/getsentry/sentry/pull/59572/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry)


Resolving patch:
diff --git a/src/sentry/event_manager.py b/src/sentry/event_manager.py
--- a/src/sentry/event_manager.py
+++ b/src/sentry/event_manager.py
@@ -131,6 +131,7 @@
 from sentry.utils.performance_issues.performance_detection import detect_performance_problems
 from sentry.utils.performance_issues.performance_problem import PerformanceProblem
 from sentry.utils.safe import get_path, safe_execute, setdefault_path, trim
+from sentry.utils.tag_normalization import normalize_sdk_tag
 
 if TYPE_CHECKING:
     from sentry.eventstore.models import BaseEvent, Event
@@ -178,6 +179,26 @@ def is_sample_event(job):
     return get_tag(job["data"], "sample_event") == "yes"
 
 
+def normalized_sdk_tag_from_event(event: Event) -> str:
+    """
+     Normalize tags coming from SDKs to more manageable canonical form, by:
+
+     - combining synonymous tags (`sentry.react` -> `sentry.javascript.react`),
+     - ignoring framework differences (`sentry.python.flask` and `sentry.python.django` -> `sentry.python`)
+     - collapsing all community/third-party SDKs into a single `other` category
+
+    Note: Some platforms may keep their framework-specific values, as needed for analytics.
+
+    This is done to reduce the cardinality of the `sdk.name` tag, while keeping
+    the ones interesinting to us as granual as possible.
+    """
+    try:
+        return normalize_sdk_tag((event.data.get("sdk") or {}).get("name") or "other")
+    except Exception:
+        logger.warning("failed to get SDK name", exc_info=True)
+        return "other"
+
+
 def plugin_is_regression(group: Group, event: Event) -> bool:
     project = event.project
     for plugin in plugins.for_project(project):
@@ -451,7 +472,10 @@ def save(
 
             return jobs[0]["event"]
         else:
-            metric_tags = {"platform": job["event"].platform or "unknown"}
+            metric_tags = {
+                "platform": job["event"].platform or "unknown",
+                "sdk": normalized_sdk_tag_from_event(job["event"]),
+            }
             # This metric allows differentiating from all calls to the `event_manager.save` metric
             # and adds support for differentiating based on platforms
             with metrics.timer("event_manager.save_error_events", tags=metric_tags):
@@ -776,7 +800,7 @@ def _auto_update_grouping(project: Project) -> None:
             "sentry:secondary_grouping_expiry": expiry,
             "sentry:grouping_config": new_grouping,
         }
-        for (key, value) in changes.items():
+        for key, value in changes.items():
             project.update_option(key, value)
         create_system_audit_entry(
             organization=project.organization,
@@ -786,11 +810,17 @@ def _auto_update_grouping(project: Project) -> None:
         )
 
 
-@metrics.wraps("event_manager.background_grouping")
+# TODO: this seems to be dead code, validate and remove
 def _calculate_background_grouping(
     project: Project, event: Event, config: GroupingConfig
 ) -> CalculatedHashes:
-    return _calculate_event_grouping(project, event, config)
+    metric_tags: MutableTags = {
+        "grouping_config": config["id"],
+        "platform": event.platform or "unknown",
+        "sdk": normalized_sdk_tag_from_event(event),
+    }
+    with metrics.timer("event_manager.background_grouping", tags=metric_tags):
+        return _calculate_event_grouping(project, event, config)
 
 
 def _run_background_grouping(project: Project, job: Job) -> None:
@@ -1581,7 +1611,6 @@ def _save_aggregate(
     kwargs["data"]["last_received"] = received_timestamp
 
     if existing_grouphash is None:
-
         if killswitch_matches_context(
             "store.load-shed-group-creation-projects",
             {
@@ -1621,7 +1650,6 @@ def _save_aggregate(
                 root_hierarchical_grouphash = None
 
             if existing_grouphash is None:
-
                 group = _create_group(project, event, **kwargs)
 
                 if (
@@ -1654,7 +1682,10 @@ def _save_aggregate(
                 metrics.incr(
                     "group.created",
                     skip_internal=True,
-                    tags={"platform": event.platform or "unknown"},
+                    tags={
+                        "platform": event.platform or "unknown",
+                        "sdk": normalized_sdk_tag_from_event(event),
+                    },
                 )
 
                 # This only applies to events with stacktraces
@@ -1665,6 +1696,7 @@ def _save_aggregate(
                         sample_rate=1.0,
                         tags={
                             "platform": event.platform or "unknown",
+                            "sdk": normalized_sdk_tag_from_event(event),
                             "frame_mix": frame_mix,
                         },
                     )
@@ -1826,7 +1858,9 @@ def _create_group(project: Project, event: Event, **kwargs: Any) -> Group:
     except OperationalError:
         metrics.incr(
             "next_short_id.timeout",
-            tags={"platform": event.platform or "unknown"},
+            tags={
+                "platform": event.platform or "unknown"
+            },  # TODO: remove this tag, it's nor relevant
         )
         sentry_sdk.capture_message("short_id.timeout")
         raise HashDiscarded("Timeout when getting next_short_id", reason="timeout")
@@ -2207,7 +2241,10 @@ def discard_event(job: Job, attachments: Sequence[Attachment]) -> None:
     metrics.incr(
         "events.discarded",
         skip_internal=True,
-        tags={"platform": job["platform"]},
+        tags={
+            "platform": job["platform"],
+            "sdk": normalized_sdk_tag_from_event(job["event"]),
+        },
     )
 
 
@@ -2455,7 +2492,6 @@ def _materialize_event_metrics(jobs: Sequence[Job]) -> None:
         job["event_metrics"] = event_metrics
 
 
-@metrics.wraps("save_event.calculate_event_grouping")
 def _calculate_event_grouping(
     project: Project, event: Event, grouping_config: GroupingConfig
 ) -> CalculatedHashes:
@@ -2466,40 +2502,42 @@ def _calculate_event_grouping(
     metric_tags: MutableTags = {
         "grouping_config": grouping_config["id"],
         "platform": event.platform or "unknown",
+        "sdk": normalized_sdk_tag_from_event(event),
     }
 
-    with metrics.timer("event_manager.normalize_stacktraces_for_grouping", tags=metric_tags):
-        with sentry_sdk.start_span(op="event_manager.normalize_stacktraces_for_grouping"):
-            event.normalize_stacktraces_for_grouping(load_grouping_config(grouping_config))
-
-    # Detect & set synthetic marker if necessary
-    detect_synthetic_exception(event.data, grouping_config)
-
-    with metrics.timer("event_manager.apply_server_fingerprinting"):
-        # The active grouping config was put into the event in the
-        # normalize step before.  We now also make sure that the
-        # fingerprint was set to `'{{ default }}' just in case someone
-        # removed it from the payload.  The call to get_hashes will then
-        # look at `grouping_config` to pick the right parameters.
-        event.data["fingerprint"] = event.data.data.get("fingerprint") or ["{{ default }}"]
-        apply_server_fingerprinting(
-            event.data.data,
-            get_fingerprinting_config_for_project(project),
-            allow_custom_title=True,
-        )
+    with metrics.timer("save_event.calculate_event_grouping", tags=metric_tags):
+        with metrics.timer("event_manager.normalize_stacktraces_for_grouping", tags=metric_tags):
+            with sentry_sdk.start_span(op="event_manager.normalize_stacktraces_for_grouping"):
+                event.normalize_stacktraces_for_grouping(load_grouping_config(grouping_config))
+
+        # Detect & set synthetic marker if necessary
+        detect_synthetic_exception(event.data, grouping_config)
+
+        with metrics.timer("event_manager.apply_server_fingerprinting", tags=metric_tags):
+            # The active grouping config was put into the event in the
+            # normalize step before.  We now also make sure that the
+            # fingerprint was set to `'{{ default }}' just in case someone
+            # removed it from the payload.  The call to get_hashes will then
+            # look at `grouping_config` to pick the right parameters.
+            event.data["fingerprint"] = event.data.data.get("fingerprint") or ["{{ default }}"]
+            apply_server_fingerprinting(
+                event.data.data,
+                get_fingerprinting_config_for_project(project),
+                allow_custom_title=True,
+            )
 
-    with metrics.timer("event_manager.event.get_hashes", tags=metric_tags):
-        # Here we try to use the grouping config that was requested in the
-        # event. If that config has since been deleted (because it was an
-        # experimental grouping config) we fall back to the default.
-        try:
-            hashes = event.get_hashes(grouping_config)
-        except GroupingConfigNotFound:
-            event.data["grouping_config"] = get_grouping_config_dict_for_project(project)
-            hashes = event.get_hashes()
+        with metrics.timer("event_manager.event.get_hashes", tags=metric_tags):
+            # Here we try to use the grouping config that was requested in the
+            # event. If that config has since been deleted (because it was an
+            # experimental grouping config) we fall back to the default.
+            try:
+                hashes = event.get_hashes(grouping_config)
+            except GroupingConfigNotFound:
+                event.data["grouping_config"] = get_grouping_config_dict_for_project(project)
+                hashes = event.get_hashes()
 
-    hashes.write_to_event(event.data)
-    return hashes
+        hashes.write_to_event(event.data)
+        return hashes
 
 
 @metrics.wraps("save_event.calculate_span_grouping")
@@ -2518,7 +2556,10 @@ def _calculate_span_grouping(jobs: Sequence[Job], projects: ProjectsMapping) ->
             metrics.incr(
                 "save_event.transaction.span_group_count.default",
                 amount=len(unique_default_hashes),
-                tags={"platform": job["platform"] or "unknown"},
+                tags={
+                    "platform": job["platform"] or "unknown",
+                    "sdk": normalized_sdk_tag_from_event(event),
+                },
             )
         except Exception:
             sentry_sdk.capture_exception()


Test patch:
diff --git a/tests/sentry/event_manager/test_event_manager.py b/tests/sentry/event_manager/test_event_manager.py
--- a/tests/sentry/event_manager/test_event_manager.py
+++ b/tests/sentry/event_manager/test_event_manager.py
@@ -2301,7 +2301,6 @@ def test_perf_issue_creation(self):
     @override_options({"performance.issues.all.problem-detection": 1.0})
     @override_options({"performance.issues.n_plus_one_db.problem-creation": 1.0})
     def test_perf_issue_update(self):
-
         with mock.patch("sentry_sdk.tracing.Span.containing_transaction"):
             event = self.create_performance_issue(
                 event_data=make_event(**get_event("n-plus-one-in-django-index-view"))
@@ -2441,7 +2440,9 @@ def attempt_to_generate_slow_db_issue() -> Event:
 
     @patch("sentry.event_manager.metrics.incr")
     def test_new_group_metrics_logging(self, mock_metrics_incr: MagicMock) -> None:
-        manager = EventManager(make_event(platform="javascript"))
+        manager = EventManager(
+            make_event(platform="javascript", sdk={"name": "sentry.javascript.nextjs"})
+        )
         manager.normalize()
         manager.save(self.project.id)
 
@@ -2450,12 +2451,49 @@ def test_new_group_metrics_logging(self, mock_metrics_incr: MagicMock) -> None:
             skip_internal=True,
             tags={
                 "platform": "javascript",
+                "sdk": "sentry.javascript.nextjs",
+            },
+        )
+
+    @patch("sentry.event_manager.metrics.incr")
+    def test_new_group_metrics_logging_no_platform_no_sdk(
+        self, mock_metrics_incr: MagicMock
+    ) -> None:
+        manager = EventManager(make_event(platform=None, sdk=None))
+        manager.normalize()
+        manager.save(self.project.id)
+
+        mock_metrics_incr.assert_any_call(
+            "group.created",
+            skip_internal=True,
+            tags={
+                "platform": "other",
+                "sdk": "other",
+            },
+        )
+
+    @patch("sentry.event_manager.metrics.incr")
+    def test_new_group_metrics_logging_sdk_exist_but_null(
+        self, mock_metrics_incr: MagicMock
+    ) -> None:
+        manager = EventManager(make_event(platform=None, sdk={"name": None}))
+        manager.normalize()
+        manager.save(self.project.id)
+
+        mock_metrics_incr.assert_any_call(
+            "group.created",
+            skip_internal=True,
+            tags={
+                "platform": "other",
+                "sdk": "other",
             },
         )
 
     def test_new_group_metrics_logging_with_frame_mix(self) -> None:
         with patch("sentry.event_manager.metrics.incr") as mock_metrics_incr:
-            manager = EventManager(make_event(platform="javascript"))
+            manager = EventManager(
+                make_event(platform="javascript", sdk={"name": "sentry.javascript.nextjs"})
+            )
             manager.normalize()
             # IRL, `normalize_stacktraces_for_grouping` adds frame mix metadata to the event, but we
             # can't mock that because it's imported inside its calling function to avoid circular imports
@@ -2468,6 +2506,7 @@ def test_new_group_metrics_logging_with_frame_mix(self) -> None:
                 tags={
                     "platform": "javascript",
                     "frame_mix": "in-app-only",
+                    "sdk": "sentry.javascript.nextjs",
                 },
             )