|
@@ -0,0 +1,333 @@
|
|
|
+import datetime
|
|
|
+from typing import Optional, Sequence
|
|
|
+
|
|
|
+import sentry_sdk
|
|
|
+from django.db import transaction
|
|
|
+from snuba_sdk.conditions import Condition, Op
|
|
|
+from snuba_sdk.orderby import Direction, OrderBy
|
|
|
+from snuba_sdk.query import Column, Entity, Function, Query
|
|
|
+
|
|
|
+from sentry import eventstore, features
|
|
|
+from sentry.api.bases import GroupEndpoint
|
|
|
+from sentry.api.serializers import EventSerializer, serialize
|
|
|
+from sentry.grouping.variants import ComponentVariant
|
|
|
+from sentry.models import Group, GroupHash
|
|
|
+from sentry.utils import snuba
|
|
|
+
|
|
|
+
|
|
|
+class GroupHashesSplitEndpoint(GroupEndpoint):
|
|
|
+ def get(self, request, group):
|
|
|
+ """
|
|
|
+ Return information on whether the group can be split up, has been split
|
|
|
+ up and what it will be split up into.
|
|
|
+
|
|
|
+ In the future this endpoint should supersede the GET on grouphashes
|
|
|
+ endpoint.
|
|
|
+ """
|
|
|
+
|
|
|
+ return self.respond(_render_trees(group, request.user), status=200)
|
|
|
+
|
|
|
+ def put(self, request, group):
|
|
|
+ """
|
|
|
+ Split up a group into subgroups
|
|
|
+ ```````````````````````````````
|
|
|
+
|
|
|
+ If a group is split up using this endpoint, new events that would have
|
|
|
+ been associated with this group will instead create 1..n new, more
|
|
|
+ "specific" groups according to their hierarchical group hashes.
|
|
|
+
|
|
|
+ For example, let's say you have a group containing all events whose
|
|
|
+ crashing frame was `log_error`, i.e. events are only grouped by one
|
|
|
+ frame. This is not a very descriptive frame to group by. If this
|
|
|
+ endpoint is hit, new events that crash in `log_error` will be sorted
|
|
|
+ into groups that hash by `log_error` and the next (calling) frame.
|
|
|
+
|
|
|
+ In the future this endpoint will move existing events into the new,
|
|
|
+ right groups.
|
|
|
+
|
|
|
+ :pparam string issue_id: the ID of the issue to split up.
|
|
|
+ :auth: required
|
|
|
+ """
|
|
|
+
|
|
|
+ if not features.has(
|
|
|
+ "organizations:grouping-tree-ui", group.project.organization, actor=request.user
|
|
|
+ ):
|
|
|
+
|
|
|
+ return self.respond(
|
|
|
+ {"error": "This project does not have the grouping tree feature"},
|
|
|
+ status=404,
|
|
|
+ )
|
|
|
+
|
|
|
+ hashes = request.GET.getlist("id")
|
|
|
+ for hash in hashes:
|
|
|
+ if not isinstance(hash, str) or len(hash) != 32:
|
|
|
+ return self.respond({"error": "hash does not look like a grouphash"}, status=400)
|
|
|
+
|
|
|
+ for hash in hashes:
|
|
|
+ _split_group(group, hash)
|
|
|
+
|
|
|
+ return self.respond(status=200)
|
|
|
+
|
|
|
+ def delete(self, request, group):
|
|
|
+ """
|
|
|
+ Un-split group(s) into their parent group
|
|
|
+ `````````````````````````````````````````
|
|
|
+
|
|
|
+ This basically undoes the split operation one can do with PUT on this
|
|
|
+ endpoint. Note that this API is not very RESTful: The group referenced
|
|
|
+ here is one of the subgroups created rather than the group that was
|
|
|
+ split up.
|
|
|
+
|
|
|
+ When unsplitting, all other child groups are left intact and can be
|
|
|
+ merged into the parent via regular issue merge.
|
|
|
+
|
|
|
+ In the future this endpoint will, much like for PUT, move existing
|
|
|
+ events of the referenced group into the parent group.
|
|
|
+
|
|
|
+ :pparam string issue_id: the ID of the issue to split up.
|
|
|
+ :auth: required
|
|
|
+ """
|
|
|
+
|
|
|
+ if not features.has(
|
|
|
+ "organizations:grouping-tree-ui", group.project.organization, actor=request.user
|
|
|
+ ):
|
|
|
+
|
|
|
+ return self.respond(
|
|
|
+ {"error": "This project does not have the grouping tree feature"},
|
|
|
+ status=404,
|
|
|
+ )
|
|
|
+
|
|
|
+ hashes = request.GET.getlist("id")
|
|
|
+ for hash in hashes:
|
|
|
+ if not isinstance(hash, str) or len(hash) != 32:
|
|
|
+ return self.respond({"error": "hash does not look like a grouphash"}, status=400)
|
|
|
+
|
|
|
+ for hash in hashes:
|
|
|
+ _unsplit_group(group, hash)
|
|
|
+
|
|
|
+ return self.respond(status=200)
|
|
|
+
|
|
|
+
|
|
|
+class NoHierarchicalHash(Exception):
|
|
|
+ pass
|
|
|
+
|
|
|
+
|
|
|
+def _split_group(group: Group, hash: str, hierarchical_hashes: Optional[Sequence[str]] = None):
|
|
|
+ if hierarchical_hashes is None:
|
|
|
+ hierarchical_hashes = _get_full_hierarchical_hashes(group, hash)
|
|
|
+
|
|
|
+ if not hierarchical_hashes:
|
|
|
+ raise NoHierarchicalHash()
|
|
|
+
|
|
|
+ hierarchical_grouphashes = {
|
|
|
+ grouphash.hash: grouphash
|
|
|
+ for grouphash in GroupHash.objects.filter(
|
|
|
+ project=group.project, group=group, hash__in=hierarchical_hashes
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ for hash in hierarchical_hashes:
|
|
|
+ grouphash = hierarchical_grouphashes.get(hash)
|
|
|
+ if grouphash is not None:
|
|
|
+ break
|
|
|
+ else:
|
|
|
+ raise NoHierarchicalHash()
|
|
|
+
|
|
|
+ # Mark one hierarchical hash as SPLIT. Note this also prevents it from
|
|
|
+ # being deleted in group deletion.
|
|
|
+ grouphash.state = GroupHash.State.SPLIT
|
|
|
+ grouphash.save()
|
|
|
+
|
|
|
+
|
|
|
+def _get_full_hierarchical_hashes(group: Group, hash: str) -> Optional[Sequence[str]]:
|
|
|
+ query = (
|
|
|
+ Query("events", Entity("events"))
|
|
|
+ .set_select(
|
|
|
+ [
|
|
|
+ Column("hierarchical_hashes"),
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ .set_where(
|
|
|
+ _get_group_filters(group)
|
|
|
+ + [
|
|
|
+ Condition(
|
|
|
+ Function(
|
|
|
+ "has",
|
|
|
+ [Column("hierarchical_hashes"), hash],
|
|
|
+ ),
|
|
|
+ Op.EQ,
|
|
|
+ 1,
|
|
|
+ ),
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+ data = snuba.raw_snql_query(query, referrer="group_split.get_full_hierarchical_hashes")["data"]
|
|
|
+ if not data:
|
|
|
+ return None
|
|
|
+
|
|
|
+ return data[0]["hierarchical_hashes"]
|
|
|
+
|
|
|
+
|
|
|
+def _unsplit_group(group: Group, hash: str, hierarchical_hashes: Optional[Sequence[str]] = None):
|
|
|
+ if hierarchical_hashes is None:
|
|
|
+ hierarchical_hashes = _get_full_hierarchical_hashes(group, hash)
|
|
|
+
|
|
|
+ if not hierarchical_hashes:
|
|
|
+ raise NoHierarchicalHash()
|
|
|
+
|
|
|
+ hierarchical_grouphashes = {
|
|
|
+ grouphash.hash: grouphash
|
|
|
+ for grouphash in GroupHash.objects.filter(
|
|
|
+ project=group.project, hash__in=hierarchical_hashes
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ grouphash_to_unsplit = None
|
|
|
+ grouphash_to_delete = None
|
|
|
+
|
|
|
+ # Only un-split one grouphash such that issue grouping only moves up only
|
|
|
+ # one level of the tree.
|
|
|
+
|
|
|
+ for hash in hierarchical_hashes:
|
|
|
+ grouphash = hierarchical_grouphashes.get(hash)
|
|
|
+ if grouphash is None:
|
|
|
+ continue
|
|
|
+
|
|
|
+ if grouphash.state == GroupHash.State.SPLIT:
|
|
|
+ grouphash_to_unsplit = grouphash
|
|
|
+
|
|
|
+ if grouphash.group_id == group.id:
|
|
|
+ grouphash_to_delete = grouphash
|
|
|
+
|
|
|
+ with transaction.atomic():
|
|
|
+ if grouphash_to_unsplit is not None:
|
|
|
+ grouphash_to_unsplit.state = GroupHash.State.UNLOCKED
|
|
|
+ grouphash_to_unsplit.save()
|
|
|
+
|
|
|
+ if grouphash_to_delete is not None:
|
|
|
+ grouphash_to_delete.delete()
|
|
|
+
|
|
|
+
|
|
|
+def _get_group_filters(group: Group):
|
|
|
+ return [
|
|
|
+ Condition(Column("timestamp"), Op.GTE, group.first_seen),
|
|
|
+ Condition(Column("timestamp"), Op.LT, group.last_seen + datetime.timedelta(seconds=1)),
|
|
|
+ Condition(Column("project_id"), Op.EQ, group.project_id),
|
|
|
+ Condition(Column("group_id"), Op.EQ, group.id),
|
|
|
+ ]
|
|
|
+
|
|
|
+
|
|
|
+def _render_trees(group: Group, user):
|
|
|
+ materialized_hashes = {
|
|
|
+ gh.hash for gh in GroupHash.objects.filter(project=group.project, group=group)
|
|
|
+ }
|
|
|
+
|
|
|
+ rv = []
|
|
|
+
|
|
|
+ common_where = _get_group_filters(group)
|
|
|
+
|
|
|
+ for materialized_hash in materialized_hashes:
|
|
|
+ # For every materialized hash we want to render parent and child
|
|
|
+ # hashes, a limited view of the entire tree. We fetch one sample event
|
|
|
+ # so we know how we need to slice hierarchical_hashes.
|
|
|
+ hierarchical_hashes = _get_full_hierarchical_hashes(group, materialized_hash)
|
|
|
+
|
|
|
+ if not hierarchical_hashes:
|
|
|
+ # No hierarchical_hashes found, the materialized hash is probably
|
|
|
+ # from flat grouping.
|
|
|
+ parent_pos = None
|
|
|
+ hash_pos = None
|
|
|
+ child_pos = None
|
|
|
+ slice_start = 0
|
|
|
+ else:
|
|
|
+ materialized_pos = hierarchical_hashes.index(materialized_hash)
|
|
|
+
|
|
|
+ if materialized_pos == 0:
|
|
|
+ parent_pos = None
|
|
|
+ hash_pos = 0
|
|
|
+ child_pos = 1
|
|
|
+ slice_start = 1
|
|
|
+ else:
|
|
|
+ parent_pos = 0
|
|
|
+ hash_pos = 1
|
|
|
+ child_pos = 2
|
|
|
+ slice_start = materialized_pos
|
|
|
+
|
|
|
+ # Select sub-views of the trees that contain materialized_hash.
|
|
|
+ query = (
|
|
|
+ Query("events", Entity("events"))
|
|
|
+ .set_select(
|
|
|
+ [
|
|
|
+ Function("count", [], "event_count"),
|
|
|
+ Function("argMax", [Column("event_id"), Column("timestamp")], "event_id"),
|
|
|
+ Function("max", [Column("timestamp")], "latest_event_timestamp"),
|
|
|
+ Function(
|
|
|
+ "arraySlice", [Column("hierarchical_hashes"), slice_start, 3], "hashes"
|
|
|
+ ),
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ .set_where(
|
|
|
+ common_where
|
|
|
+ + [
|
|
|
+ Condition(
|
|
|
+ Function(
|
|
|
+ "has",
|
|
|
+ [
|
|
|
+ Column("hierarchical_hashes"),
|
|
|
+ materialized_hash,
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ Op.EQ,
|
|
|
+ 1,
|
|
|
+ ),
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ .set_groupby([Column("hashes")])
|
|
|
+ .set_orderby([OrderBy(Column("latest_event_timestamp"), Direction.DESC)])
|
|
|
+ )
|
|
|
+
|
|
|
+ for row in snuba.raw_snql_query(query)["data"]:
|
|
|
+ assert not row["hashes"] or row["hashes"][hash_pos] == materialized_hash
|
|
|
+
|
|
|
+ event_id = row["event_id"]
|
|
|
+ event = eventstore.get_event_by_id(group.project_id, event_id)
|
|
|
+
|
|
|
+ tree = {
|
|
|
+ "parentId": _get_checked(row["hashes"], parent_pos),
|
|
|
+ "id": materialized_hash,
|
|
|
+ "childId": _get_checked(row["hashes"], child_pos),
|
|
|
+ "eventCount": row["event_count"],
|
|
|
+ "latestEvent": serialize(event, user, EventSerializer()),
|
|
|
+ }
|
|
|
+
|
|
|
+ rv.append(tree)
|
|
|
+
|
|
|
+ if not row["hashes"]:
|
|
|
+ continue
|
|
|
+
|
|
|
+ try:
|
|
|
+ for variant in event.get_grouping_variants().values():
|
|
|
+ if not isinstance(variant, ComponentVariant):
|
|
|
+ continue
|
|
|
+
|
|
|
+ if variant.get_hash() == tree["parentId"]:
|
|
|
+ tree["parentLabel"] = variant.component.tree_label
|
|
|
+
|
|
|
+ if variant.get_hash() == tree["childId"]:
|
|
|
+ tree["childLabel"] = variant.component.tree_label
|
|
|
+
|
|
|
+ if variant.get_hash() == tree["id"]:
|
|
|
+ tree["label"] = variant.component.tree_label
|
|
|
+ except Exception:
|
|
|
+ sentry_sdk.capture_exception()
|
|
|
+
|
|
|
+ rv.sort(key=lambda tree: (tree["parentId"] or "", tree["id"] or "", tree["childId"] or ""))
|
|
|
+
|
|
|
+ return rv
|
|
|
+
|
|
|
+
|
|
|
+def _get_checked(list, pos):
|
|
|
+ if pos is not None and pos < len(list):
|
|
|
+ return list[pos]
|
|
|
+ return None
|