Repository: getsentry/sentry
Pull request: ref(issue platform): Allow for searching generic issues in Discover
Enable Discover searching for generic issues. When the "Open in Discover" button is clicked and the query is `issue:short_id` there will be results using the issue platform dataset. This does require some front end changes to fully work - it will need to send a param `dataset=issuePlatform`.

Closes #42586 

Problem statement:
Allow issue platform issues to be searched for in discover
We don't necessarily need the full discover functionality here. We just want to be able to search by issue id, so that `open in discover` works, and the events table will work.

We probably just want a way to specify that we're in the generic dataset and be able to specify an issue id (and other search columns too, potentially)

Hints from issue discussion:

Commit message: dont use a real domain though technically bar.com exists

Patch to review:
diff --git a/src/sentry/api/bases/organization_events.py b/src/sentry/api/bases/organization_events.py
index 9da72cf3833511..6a047fa786c894 100644
--- a/src/sentry/api/bases/organization_events.py
+++ b/src/sentry/api/bases/organization_events.py
@@ -22,7 +22,13 @@
 from sentry.search.events.constants import DURATION_UNITS, SIZE_UNITS, TIMEOUT_ERROR_MESSAGE
 from sentry.search.events.fields import get_function_alias
 from sentry.search.events.types import SnubaParams
-from sentry.snuba import discover, metrics_enhanced_performance, metrics_performance, profiles
+from sentry.snuba import (
+    discover,
+    issue_platform,
+    metrics_enhanced_performance,
+    metrics_performance,
+    profiles,
+)
 from sentry.utils import snuba
 from sentry.utils.cursors import Cursor
 from sentry.utils.dates import get_interval_from_range, get_rollup_from_request, parse_stats_period
@@ -36,6 +42,7 @@
     "metricsEnhanced": metrics_enhanced_performance,
     "metrics": metrics_performance,
     "profiles": profiles,
+    "issuePlatform": issue_platform,
 }
 
 
diff --git a/src/sentry/api/endpoints/organization_events_meta.py b/src/sentry/api/endpoints/organization_events_meta.py
index 7b62be04dfffac..dfb322bb6f7926 100644
--- a/src/sentry/api/endpoints/organization_events_meta.py
+++ b/src/sentry/api/endpoints/organization_events_meta.py
@@ -12,7 +12,6 @@
 from sentry.api.helpers.group_index import build_query_params_from_request
 from sentry.api.serializers import serialize
 from sentry.api.serializers.models.group import GroupSerializer
-from sentry.snuba import discover
 
 
 @region_silo_endpoint
@@ -23,8 +22,10 @@ def get(self, request: Request, organization) -> Response:
         except NoProjects:
             return Response({"count": 0})
 
+        dataset = self.get_dataset(request)
+
         with self.handle_query_errors():
-            result = discover.query(
+            result = dataset.query(
                 selected_columns=["count()"],
                 params=params,
                 query=request.query_params.get("query"),
diff --git a/src/sentry/search/events/builder/__init__.py b/src/sentry/search/events/builder/__init__.py
index 4a37b6ba1ca585..0c77d70a4c04ba 100644
--- a/src/sentry/search/events/builder/__init__.py
+++ b/src/sentry/search/events/builder/__init__.py
@@ -5,6 +5,7 @@
     TopEventsQueryBuilder,
     UnresolvedQuery,
 )
+from .issue_platform import IssuePlatformTimeseriesQueryBuilder  # NOQA
 from .metrics import (  # NOQA
     AlertMetricsQueryBuilder,
     HistogramMetricQueryBuilder,
@@ -22,6 +23,7 @@
     "HistogramQueryBuilder",
     "QueryBuilder",
     "TimeseriesQueryBuilder",
+    "IssuePlatformTimeseriesQueryBuilder",
     "TopEventsQueryBuilder",
     "UnresolvedQuery",
     "AlertMetricsQueryBuilder",
diff --git a/src/sentry/search/events/builder/issue_platform.py b/src/sentry/search/events/builder/issue_platform.py
new file mode 100644
index 00000000000000..6a22b4a2e17501
--- /dev/null
+++ b/src/sentry/search/events/builder/issue_platform.py
@@ -0,0 +1,67 @@
+from typing import List, Optional
+
+from snuba_sdk import Column, Function
+
+from sentry.search.events.builder import TimeseriesQueryBuilder
+from sentry.search.events.types import ParamsType
+from sentry.utils.snuba import Dataset
+
+
+class IssuePlatformTimeseriesQueryBuilder(TimeseriesQueryBuilder):
+    """The IssuePlatform dataset isn't using the TimeSeriesProcessor which does the translation of 'time' to 'timestamp'."""
+
+    def __init__(
+        self,
+        dataset: Dataset,
+        params: ParamsType,
+        interval: int,
+        query: Optional[str] = None,
+        selected_columns: Optional[List[str]] = None,
+        equations: Optional[List[str]] = None,
+        functions_acl: Optional[List[str]] = None,
+        limit: Optional[int] = 10000,
+        has_metrics: bool = False,
+        skip_tag_resolution: bool = False,
+    ):
+        super().__init__(
+            dataset,
+            params,
+            interval,
+            query=query,
+            selected_columns=selected_columns,
+            equations=equations,
+            functions_acl=functions_acl,
+            has_metrics=has_metrics,
+            skip_tag_resolution=skip_tag_resolution,
+        )
+        rollup_to_start_func = {
+            60: "toStartOfMinute",
+            3600: "toStartOfHour",
+            3600 * 24: "toDate",
+        }
+        rollup_func = rollup_to_start_func.get(interval)
+        if rollup_func:
+            self.time_column = Function(
+                "toUnixTimestamp",
+                [Function("toDateTime", [Function(rollup_func, [Column("timestamp")])])],
+                alias="time",
+            )
+        else:
+            self.time_column = Function(
+                "multiply",
+                [
+                    Function(
+                        "intDiv",
+                        [
+                            Function(
+                                "toUInt32", [Function("toUnixTimestamp", [Column("timestamp")])]
+                            ),
+                            interval,
+                        ],
+                    ),
+                    interval,
+                ],
+                alias="time",
+            )
+
+        self.groupby = [self.time_column]
diff --git a/src/sentry/snuba/events.py b/src/sentry/snuba/events.py
index c7e3dd53ac0d0f..a7700b92e188e1 100644
--- a/src/sentry/snuba/events.py
+++ b/src/sentry/snuba/events.py
@@ -225,7 +225,7 @@ class Columns(Enum):
         event_name="ip_address",
         transaction_name="ip_address",
         discover_name="ip_address",
-        issue_platform_name="ip_address_v4",
+        issue_platform_name="ip_address",
         alias="user.ip",
     )
     USER_DISPLAY = Column(
diff --git a/src/sentry/snuba/issue_platform.py b/src/sentry/snuba/issue_platform.py
new file mode 100644
index 00000000000000..8c31215a17b773
--- /dev/null
+++ b/src/sentry/snuba/issue_platform.py
@@ -0,0 +1,208 @@
+from copy import deepcopy
+from datetime import timedelta
+from typing import Dict, Optional, Sequence
+
+import sentry_sdk
+
+from sentry.discover.arithmetic import categorize_columns
+from sentry.search.events.builder import IssuePlatformTimeseriesQueryBuilder, QueryBuilder
+from sentry.search.events.fields import InvalidSearchQuery, get_json_meta_type
+from sentry.snuba.discover import EventsResponse, transform_tips, zerofill
+from sentry.utils.snuba import Dataset, SnubaTSResult, bulk_snql_query
+
+
+def query(
+    selected_columns,
+    query,
+    params,
+    snuba_params=None,
+    equations=None,
+    orderby=None,
+    offset=None,
+    limit=50,
+    referrer=None,
+    auto_fields=False,
+    auto_aggregations=False,
+    include_equation_fields=False,
+    allow_metric_aggregates=False,
+    use_aggregate_conditions=False,
+    conditions=None,
+    functions_acl=None,
+    transform_alias_to_input_format=False,
+    sample=None,
+    has_metrics=False,
+    use_metrics_layer=False,
+    skip_tag_resolution=False,
+) -> EventsResponse:
+    """
+    High-level API for doing arbitrary user queries against events.
+
+    This function operates on the Discover public event schema and
+    virtual fields/aggregate functions for selected columns and
+    conditions are supported through this function.
+
+    The resulting list will have all internal field names mapped
+    back into their public schema names.
+
+    selected_columns (Sequence[str]) List of public aliases to fetch.
+    query (str) Filter query string to create conditions from.
+    params (Dict[str, str]) Filtering parameters with start, end, project_id, environment
+    equations (Sequence[str]) List of equations to calculate for the query
+    orderby (None|str|Sequence[str]) The field to order results by.
+    offset (None|int) The record offset to read.
+    limit (int) The number of records to fetch.
+    referrer (str|None) A referrer string to help locate the origin of this query.
+    auto_fields (bool) Set to true to have project + eventid fields automatically added.
+    auto_aggregations (bool) Whether aggregates should be added automatically if they're used
+                    in conditions, and there's at least one aggregate already.
+    include_equation_fields (bool) Whether fields should be added automatically if they're used in
+                    equations
+    allow_metric_aggregates (bool) Ignored here, only used in metric enhanced performance
+    use_aggregate_conditions (bool) Set to true if aggregates conditions should be used at all.
+    conditions (Sequence[Condition]) List of conditions that are passed directly to snuba without
+                    any additional processing.
+    transform_alias_to_input_format (bool) Whether aggregate columns should be returned in the originally
+                                requested function format.
+    sample (float) The sample rate to run the query with
+    """
+    if not selected_columns:
+        raise InvalidSearchQuery("No columns selected")
+
+    builder = QueryBuilder(
+        Dataset.IssuePlatform,
+        params,
+        snuba_params=snuba_params,
+        query=query,
+        selected_columns=selected_columns,
+        equations=equations,
+        orderby=orderby,
+        auto_fields=auto_fields,
+        auto_aggregations=auto_aggregations,
+        use_aggregate_conditions=use_aggregate_conditions,
+        functions_acl=functions_acl,
+        limit=limit,
+        offset=offset,
+        equation_config={"auto_add": include_equation_fields},
+        sample_rate=sample,
+        has_metrics=has_metrics,
+        transform_alias_to_input_format=transform_alias_to_input_format,
+        skip_tag_resolution=skip_tag_resolution,
+    )
+    if conditions is not None:
+        builder.add_conditions(conditions)
+    result = builder.process_results(builder.run_query(referrer))
+    result["meta"]["tips"] = transform_tips(builder.tips)
+    return result
+
+
+def timeseries_query(
+    selected_columns: Sequence[str],
+    query: str,
+    params: Dict[str, str],
+    rollup: int,
+    referrer: Optional[str] = None,
+    zerofill_results: bool = True,
+    comparison_delta: Optional[timedelta] = None,
+    functions_acl: Optional[Sequence[str]] = None,
+    allow_metric_aggregates=False,
+    has_metrics=False,
+    use_metrics_layer=False,
+):
+    """
+    High-level API for doing arbitrary user timeseries queries against events.
+
+    This function operates on the public event schema and
+    virtual fields/aggregate functions for selected columns and
+    conditions are supported through this function.
+
+    This function is intended to only get timeseries based
+    results and thus requires the `rollup` parameter.
+
+    Returns a SnubaTSResult object that has been zerofilled in
+    case of gaps.
+
+    selected_columns (Sequence[str]) List of public aliases to fetch.
+    query (str) Filter query string to create conditions from.
+    params (Dict[str, str]) Filtering parameters with start, end, project_id, environment,
+    rollup (int) The bucket width in seconds
+    referrer (str|None) A referrer string to help locate the origin of this query.
+    comparison_delta: A timedelta used to convert this into a comparison query. We make a second
+    query time-shifted back by comparison_delta, and compare the results to get the % change for each
+    time bucket. Requires that we only pass
+    allow_metric_aggregates (bool) Ignored here, only used in metric enhanced performance
+    """
+    with sentry_sdk.start_span(op="discover.discover", description="timeseries.filter_transform"):
+        equations, columns = categorize_columns(selected_columns)
+        base_builder = IssuePlatformTimeseriesQueryBuilder(
+            Dataset.IssuePlatform,
+            params,
+            rollup,
+            query=query,
+            selected_columns=columns,
+            equations=equations,
+            functions_acl=functions_acl,
+            has_metrics=has_metrics,
+        )
+        query_list = [base_builder]
+        if comparison_delta:
+            if len(base_builder.aggregates) != 1:
+                raise InvalidSearchQuery("Only one column can be selected for comparison queries")
+            comp_query_params = deepcopy(params)
+            comp_query_params["start"] -= comparison_delta
+            comp_query_params["end"] -= comparison_delta
+            comparison_builder = IssuePlatformTimeseriesQueryBuilder(
+                Dataset.IssuePlatform,
+                comp_query_params,
+                rollup,
+                query=query,
+                selected_columns=columns,
+                equations=equations,
+            )
+            query_list.append(comparison_builder)
+
+        query_results = bulk_snql_query([query.get_snql_query() for query in query_list], referrer)
+
+    with sentry_sdk.start_span(op="discover.discover", description="timeseries.transform_results"):
+        results = []
+        for snql_query, result in zip(query_list, query_results):
+            results.append(
+                {
+                    "data": zerofill(
+                        result["data"],
+                        snql_query.params.start,
+                        snql_query.params.end,
+                        rollup,
+                        "time",
+                    )
+                    if zerofill_results
+                    else result["data"],
+                    "meta": result["meta"],
+                }
+            )
+
+    if len(results) == 2 and comparison_delta:
+        col_name = base_builder.aggregates[0].alias
+        # If we have two sets of results then we're doing a comparison queries. Divide the primary
+        # results by the comparison results.
+        for result, cmp_result in zip(results[0]["data"], results[1]["data"]):
+            cmp_result_val = cmp_result.get(col_name, 0)
+            result["comparisonCount"] = cmp_result_val
+
+    result = results[0]
+
+    return SnubaTSResult(
+        {
+            "data": result["data"],
+            "meta": {
+                "fields": {
+                    value["name"]: get_json_meta_type(
+                        value["name"], value.get("type"), base_builder
+                    )
+                    for value in result["meta"]
+                }
+            },
+        },
+        params["start"],
+        params["end"],
+        rollup,
+    )
diff --git a/tests/sentry/issues/test_utils.py b/tests/sentry/issues/test_utils.py
index a987a169529264..94223517efca19 100644
--- a/tests/sentry/issues/test_utils.py
+++ b/tests/sentry/issues/test_utils.py
@@ -2,7 +2,7 @@
 from dataclasses import replace
 from datetime import datetime, timedelta
 from hashlib import md5
-from typing import Any, Optional, Sequence, Tuple
+from typing import Any, Dict, Optional, Sequence, Tuple
 
 from django.utils import timezone
 
@@ -70,6 +70,7 @@ def store_search_issue(
         insert_time: Optional[datetime] = None,
         tags: Optional[Sequence[Tuple[str, Any]]] = None,
         release: Optional[str] = None,
+        user: Optional[Dict[str, Any]] = None,
     ) -> Tuple[Event, IssueOccurrence, Optional[GroupInfo]]:
         from sentry.utils import snuba
 
@@ -83,6 +84,9 @@ def store_search_issue(
         if tags:
             event_data["tags"].extend(tags)
 
+        if user:
+            event_data["user"] = user
+
         if environment:
             event_data["environment"] = environment
             event_data["tags"].extend([("environment", environment)])
@@ -124,7 +128,9 @@ def store_search_issue(
         assert len(result["data"]) == 1
         assert result["data"][0]["project_id"] == project_id
         assert result["data"][0]["group_id"] == group_info.group.id if group_info else None
-        assert result["data"][0]["tags[sentry:user]"] == user_id_val
+        assert (
+            result["data"][0]["tags[sentry:user]"] == user_id_val if not user else f"id:{user_id}"
+        )
         assert result["data"][0]["environment"] == environment
         assert result["data"][0]["timestamp"] == insert_timestamp.isoformat()
 
diff --git a/tests/snuba/api/endpoints/test_organization_events.py b/tests/snuba/api/endpoints/test_organization_events.py
index e167eb62b11bc7..88d3a1ffabd223 100644
--- a/tests/snuba/api/endpoints/test_organization_events.py
+++ b/tests/snuba/api/endpoints/test_organization_events.py
@@ -28,12 +28,13 @@
 from sentry.utils import json
 from sentry.utils.samples import load_data
 from sentry.utils.snuba import QueryExecutionError, QueryIllegalTypeOfArgument, RateLimitExceeded
+from tests.sentry.issues.test_utils import SearchIssueTestMixin
 
 MAX_QUERYABLE_TRANSACTION_THRESHOLDS = 1
 
 
 @region_silo_test
-class OrganizationEventsEndpointTest(APITestCase, SnubaTestCase):
+class OrganizationEventsEndpointTest(APITestCase, SnubaTestCase, SearchIssueTestMixin):
     viewname = "sentry-api-0-organization-events"
     referrer = "api.organization-events"
 
@@ -598,6 +599,40 @@ def test_performance_issue_ids_filter(self):
         assert response.status_code == 200, response.content
         assert response.data["data"][0]["count()"] == 1
 
+    def test_generic_issue_ids_filter(self):
+        user_data = {
+            "id": self.user.id,
+            "username": "user",
+            "email": "hellboy@bar.com",
+            "ip_address": "127.0.0.1",
+        }
+        event, _, group_info = self.store_search_issue(
+            self.project.id,
+            self.user.id,
+            [f"{GroupType.PROFILE_BLOCKED_THREAD.value}-group1"],
+            "prod",
+            timezone.now().replace(hour=0, minute=0, second=0) + timedelta(minutes=1),
+            user=user_data,
+        )
+
+        query = {
+            "field": ["title", "release", "environment", "user.display", "timestamp"],
+            "statsPeriod": "90d",
+            "query": f"issue:{group_info.group.qualified_short_id}",
+            "dataset": "issuePlatform",
+        }
+        with self.feature(
+            [
+                "organizations:profiling",
+            ]
+        ):
+            response = self.do_request(query)
+        assert response.status_code == 200, response.content
+        assert response.data["data"][0]["title"] == group_info.group.title
+        assert response.data["data"][0]["environment"] == "prod"
+        assert response.data["data"][0]["user.display"] == user_data["email"]
+        assert response.data["data"][0]["timestamp"] == event.timestamp
+
     def test_has_performance_issue_ids(self):
         data = load_data(
             platform="transaction",
@@ -2972,6 +3007,41 @@ def test_user_display(self):
         result = {r["user.display"] for r in data}
         assert result == {"catherine", "cathy@example.com"}
 
+    def test_user_display_issue_platform(self):
+        project1 = self.create_project()
+        user_data = {
+            "id": self.user.id,
+            "username": "user",
+            "email": "hellboy@bar.com",
+            "ip_address": "127.0.0.1",
+        }
+        _, _, group_info = self.store_search_issue(
+            project1.id,
+            1,
+            ["group1-fingerprint"],
+            None,
+            timezone.now().replace(hour=0, minute=0, second=0) + timedelta(minutes=10),
+            user=user_data,
+        )
+
+        features = {
+            "organizations:discover-basic": True,
+            "organizations:global-views": True,
+            "organizations:profiling": True,
+        }
+        query = {
+            "field": ["user.display"],
+            "query": f"user.display:hell* issue.id:{group_info.group.id}",
+            "statsPeriod": "24h",
+            "dataset": "issuePlatform",
+        }
+        response = self.do_request(query, features=features)
+        assert response.status_code == 200, response.content
+        data = response.data["data"]
+        assert len(data) == 1
+        result = {r["user.display"] for r in data}
+        assert result == {user_data["email"]}
+
     def test_user_display_with_aggregates(self):
         self.store_event(
             data={
diff --git a/tests/snuba/api/endpoints/test_organization_events_mep.py b/tests/snuba/api/endpoints/test_organization_events_mep.py
index f61a731e67e03e..78c6c570e330e1 100644
--- a/tests/snuba/api/endpoints/test_organization_events_mep.py
+++ b/tests/snuba/api/endpoints/test_organization_events_mep.py
@@ -3,6 +3,7 @@
 import pytest
 from django.urls import reverse
 
+from sentry.api.bases.organization_events import DATASET_OPTIONS
 from sentry.discover.models import TeamKeyTransaction
 from sentry.models import ProjectTeam
 from sentry.models.transaction_threshold import (
@@ -85,7 +86,7 @@ def test_invalid_dataset(self):
         assert response.status_code == 400, response.content
         assert (
             response.data["detail"]
-            == "dataset must be one of: discover, metricsEnhanced, metrics, profiles"
+            == f"dataset must be one of: {', '.join([key for key in DATASET_OPTIONS.keys()])}"
         )
 
     def test_out_of_retention(self):
diff --git a/tests/snuba/api/endpoints/test_organization_events_meta.py b/tests/snuba/api/endpoints/test_organization_events_meta.py
index b46f3b18ccfd6e..1574a1de57c327 100644
--- a/tests/snuba/api/endpoints/test_organization_events_meta.py
+++ b/tests/snuba/api/endpoints/test_organization_events_meta.py
@@ -1,19 +1,23 @@
+from datetime import timedelta
 from unittest import mock
 
 import pytest
 from django.urls import reverse
+from django.utils import timezone
 from pytz import utc
 from rest_framework.exceptions import ParseError
 
 from sentry.testutils import APITestCase, SnubaTestCase
 from sentry.testutils.helpers.datetime import before_now, iso_format
 from sentry.testutils.silo import region_silo_test
+from sentry.types.issues import GroupType
+from tests.sentry.issues.test_utils import SearchIssueTestMixin
 
 pytestmark = pytest.mark.sentry_metrics
 
 
 @region_silo_test
-class OrganizationEventsMetaEndpoint(APITestCase, SnubaTestCase):
+class OrganizationEventsMetaEndpoint(APITestCase, SnubaTestCase, SearchIssueTestMixin):
     def setUp(self):
         super().setUp()
         self.min_ago = before_now(minutes=1)
@@ -109,6 +113,32 @@ def test_transaction_event(self):
         assert response.status_code == 200, response.content
         assert response.data["count"] == 1
 
+    def test_generic_event(self):
+        """Test that the issuePlatform dataset returns data for a generic issue's short ID"""
+        _, _, group_info = self.store_search_issue(
+            self.project.id,
+            self.user.id,
+            [f"{GroupType.PROFILE_BLOCKED_THREAD.value}-group1"],
+            "prod",
+            timezone.now().replace(hour=0, minute=0, second=0) + timedelta(minutes=1),
+        )
+        url = reverse(
+            "sentry-api-0-organization-events-meta",
+            kwargs={"organization_slug": self.project.organization.slug},
+        )
+        with self.feature(self.features):
+            response = self.client.get(
+                url,
+                {
+                    "query": f"issue:{group_info.group.qualified_short_id}",
+                    "dataset": "issuePlatform",
+                },
+                format="json",
+            )
+
+        assert response.status_code == 200, response.content
+        assert response.data["count"] == 1
+
     def test_transaction_event_with_last_seen(self):
         data = {
             "event_id": "a" * 32,
diff --git a/tests/snuba/api/endpoints/test_organization_events_stats.py b/tests/snuba/api/endpoints/test_organization_events_stats.py
index 140d56d31df4cc..ff996a77eacd76 100644
--- a/tests/snuba/api/endpoints/test_organization_events_stats.py
+++ b/tests/snuba/api/endpoints/test_organization_events_stats.py
@@ -6,6 +6,7 @@
 import pytest
 from dateutil.parser import parse as parse_date
 from django.urls import reverse
+from django.utils import timezone
 from pytz import utc
 from snuba_sdk.column import Column
 from snuba_sdk.conditions import Condition, Op
@@ -17,13 +18,15 @@
 from sentry.testutils import APITestCase, SnubaTestCase
 from sentry.testutils.helpers.datetime import before_now, iso_format
 from sentry.testutils.silo import region_silo_test
+from sentry.types.issues import GroupType
 from sentry.utils.samples import load_data
+from tests.sentry.issues.test_utils import SearchIssueTestMixin
 
 pytestmark = pytest.mark.sentry_metrics
 
 
 @region_silo_test
-class OrganizationEventsStatsEndpointTest(APITestCase, SnubaTestCase):
+class OrganizationEventsStatsEndpointTest(APITestCase, SnubaTestCase, SearchIssueTestMixin):
     endpoint = "sentry-api-0-organization-events-stats"
 
     def setUp(self):
@@ -88,10 +91,90 @@ def test_simple(self):
                 "interval": "1h",
             },
         )
+        assert response.status_code == 200, response.content
+        assert [attrs for time, attrs in response.data["data"]] == [[{"count": 1}], [{"count": 2}]]
 
+    def test_generic_issue(self):
+        _, _, group_info = self.store_search_issue(
+            self.project.id,
+            self.user.id,
+            [f"{GroupType.PROFILE_BLOCKED_THREAD.value}-group1"],
+            "prod",
+            timezone.now().replace(hour=0, minute=0, second=0) + timedelta(minutes=1),
+        )
+        self.store_search_issue(
+            self.project.id,
+            self.user.id,
+            [f"{GroupType.PROFILE_BLOCKED_THREAD.value}-group1"],
+            "prod",
+            timezone.now().replace(hour=1, minute=0, second=0) + timedelta(minutes=1),
+        )
+        self.store_search_issue(
+            self.project.id,
+            self.user.id,
+            [f"{GroupType.PROFILE_BLOCKED_THREAD.value}-group1"],
+            "prod",
+            timezone.now().replace(hour=1, minute=0, second=0) + timedelta(minutes=2),
+        )
+        with self.feature(
+            [
+                "organizations:profiling",
+            ]
+        ):
+            response = self.do_request(
+                {
+                    "start": timezone.now().replace(hour=0, minute=0, second=0),
+                    "end": timezone.now().replace(hour=2, minute=0, second=0),
+                    "interval": "1h",
+                    "query": f"issue:{group_info.group.qualified_short_id}",
+                    "dataset": "issuePlatform",
+                },
+            )
         assert response.status_code == 200, response.content
         assert [attrs for time, attrs in response.data["data"]] == [[{"count": 1}], [{"count": 2}]]
 
+    def test_generic_issue_calculated_interval(self):
+        """Test that a 4h interval returns the correct generic event stats.
+        This follows a different code path than 1h or 1d as the IssuePlatformTimeSeriesQueryBuilder
+        does some calculation to create the time column."""
+        _, _, group_info = self.store_search_issue(
+            self.project.id,
+            self.user.id,
+            [f"{GroupType.PROFILE_BLOCKED_THREAD.value}-group1"],
+            "prod",
+            timezone.now().replace(hour=0, minute=0, second=0) + timedelta(minutes=1),
+        )
+        self.store_search_issue(
+            self.project.id,
+            self.user.id,
+            [f"{GroupType.PROFILE_BLOCKED_THREAD.value}-group1"],
+            "prod",
+            timezone.now().replace(hour=3, minute=0, second=0) + timedelta(minutes=1),
+        )
+        self.store_search_issue(
+            self.project.id,
+            self.user.id,
+            [f"{GroupType.PROFILE_BLOCKED_THREAD.value}-group1"],
+            "prod",
+            timezone.now().replace(hour=3, minute=1, second=0) + timedelta(minutes=2),
+        )
+        with self.feature(
+            [
+                "organizations:profiling",
+            ]
+        ):
+            response = self.do_request(
+                {
+                    "start": timezone.now().replace(hour=0, minute=0, second=0),
+                    "end": timezone.now().replace(hour=4, minute=0, second=0),
+                    "interval": "4h",
+                    "query": f"issue:{group_info.group.qualified_short_id}",
+                    "dataset": "issuePlatform",
+                },
+            )
+        assert response.status_code == 200, response.content
+        assert [attrs for time, attrs in response.data["data"]] == [[{"count": 3}], [{"count": 0}]]
+
     def test_misaligned_last_bucket(self):
         response = self.do_request(
             data={


Code review comments:
diff_hunk: @@ -0,0 +1,67 @@
+from typing import List, Optional
+
+from snuba_sdk import Column, Function
+
+from sentry.search.events.builder import TimeseriesQueryBuilder
+from sentry.search.events.types import ParamsType
+from sentry.utils.snuba import Dataset
+
+
+class IssuePlatformTimeseriesQueryBuilder(TimeseriesQueryBuilder):
+    """The IssuePlatform dataset isn't using the TimeSeriesProcessor which does the translation of 'time' to 'timestamp'."""
+
+    def __init__(
+        self,
+        dataset: Dataset,
+        params: ParamsType,
+        interval: int,
+        query: Optional[str] = None,
+        selected_columns: Optional[List[str]] = None,
+        equations: Optional[List[str]] = None,
+        functions_acl: Optional[List[str]] = None,
+        limit: Optional[int] = 10000,
+        has_metrics: bool = False,
+        skip_tag_resolution: bool = False,
+    ):
+        super().__init__(
+            dataset,
+            params,
+            interval,
+            query=query,
+            selected_columns=selected_columns,
+            equations=equations,
+            functions_acl=functions_acl,
+            has_metrics=has_metrics,
+            skip_tag_resolution=skip_tag_resolution,
+        )
+        rollup_to_start_func = {
+            60: "toStartOfMinute",
+            3600: "toStartOfHour",
+            3600 * 24: "toDate",
+        }
+        rollup_func = rollup_to_start_func.get(interval)
+        if rollup_func:
+            self.time_column = Function(
+                "toUnixTimestamp",
+                [Function("toDateTime", [Function(rollup_func, [Column("timestamp")])])],
+                alias="time",
+            )
path: src/sentry/search/events/builder/issue_platform.py
text: @user1:
Might need to update this bit to reflect the update aggregations:
https://github.com/getsentry/sentry/blob/4794fa13341dc462230dfee4e58c289d97020f7f/src/sentry/tsdb/snuba.py#L208

@author:
That's what I based it off of 🤔 What needs to change?

diff_hunk: @@ -0,0 +1,208 @@
+from copy import deepcopy
+from datetime import timedelta
+from typing import Dict, Optional, Sequence
+
+import sentry_sdk
+
+from sentry.discover.arithmetic import categorize_columns
+from sentry.search.events.builder import IssuePlatformTimeseriesQueryBuilder, QueryBuilder
+from sentry.search.events.fields import InvalidSearchQuery, get_json_meta_type
+from sentry.snuba.discover import EventsResponse, transform_tips, zerofill
+from sentry.utils.snuba import Dataset, SnubaTSResult, bulk_snql_query
+
+
+def query(
+    selected_columns,
+    query,
+    params,
+    snuba_params=None,
+    equations=None,
+    orderby=None,
+    offset=None,
+    limit=50,
+    referrer=None,
+    auto_fields=False,
+    auto_aggregations=False,
+    include_equation_fields=False,
+    allow_metric_aggregates=False,
+    use_aggregate_conditions=False,
+    conditions=None,
+    functions_acl=None,
+    transform_alias_to_input_format=False,
+    sample=None,
+    has_metrics=False,
+    use_metrics_layer=False,
+    skip_tag_resolution=False,
+) -> EventsResponse:
+    """
+    High-level API for doing arbitrary user queries against events.
+
+    This function operates on the Discover public event schema and
+    virtual fields/aggregate functions for selected columns and
+    conditions are supported through this function.
+
+    The resulting list will have all internal field names mapped
+    back into their public schema names.
+
+    selected_columns (Sequence[str]) List of public aliases to fetch.
+    query (str) Filter query string to create conditions from.
+    params (Dict[str, str]) Filtering parameters with start, end, project_id, environment
+    equations (Sequence[str]) List of equations to calculate for the query
+    orderby (None|str|Sequence[str]) The field to order results by.
+    offset (None|int) The record offset to read.
+    limit (int) The number of records to fetch.
+    referrer (str|None) A referrer string to help locate the origin of this query.
+    auto_fields (bool) Set to true to have project + eventid fields automatically added.
+    auto_aggregations (bool) Whether aggregates should be added automatically if they're used
+                    in conditions, and there's at least one aggregate already.
+    include_equation_fields (bool) Whether fields should be added automatically if they're used in
+                    equations
+    allow_metric_aggregates (bool) Ignored here, only used in metric enhanced performance
+    use_aggregate_conditions (bool) Set to true if aggregates conditions should be used at all.
+    conditions (Sequence[Condition]) List of conditions that are passed directly to snuba without
+                    any additional processing.
+    transform_alias_to_input_format (bool) Whether aggregate columns should be returned in the originally
+                                requested function format.
+    sample (float) The sample rate to run the query with
+    """
+    if not selected_columns:
+        raise InvalidSearchQuery("No columns selected")
+
+    builder = QueryBuilder(
+        Dataset.IssuePlatform,
+        params,
+        snuba_params=snuba_params,
+        query=query,
+        selected_columns=selected_columns,
+        equations=equations,
+        orderby=orderby,
+        auto_fields=auto_fields,
+        auto_aggregations=auto_aggregations,
+        use_aggregate_conditions=use_aggregate_conditions,
+        functions_acl=functions_acl,
+        limit=limit,
+        offset=offset,
+        equation_config={"auto_add": include_equation_fields},
+        sample_rate=sample,
+        has_metrics=has_metrics,
+        transform_alias_to_input_format=transform_alias_to_input_format,
+        skip_tag_resolution=skip_tag_resolution,
+    )
+    if conditions is not None:
+        builder.add_conditions(conditions)
+    result = builder.process_results(builder.run_query(referrer))
+    result["meta"]["tips"] = transform_tips(builder.tips)
+    return result
+
+
+def timeseries_query(
+    selected_columns: Sequence[str],
+    query: str,
+    params: Dict[str, str],
+    rollup: int,
+    referrer: Optional[str] = None,
+    zerofill_results: bool = True,
+    comparison_delta: Optional[timedelta] = None,
+    functions_acl: Optional[Sequence[str]] = None,
+    allow_metric_aggregates=False,
+    has_metrics=False,
+    use_metrics_layer=False,
+):
+    """
+    High-level API for doing arbitrary user timeseries queries against events.
+
+    This function operates on the public event schema and
+    virtual fields/aggregate functions for selected columns and
+    conditions are supported through this function.
+
+    This function is intended to only get timeseries based
+    results and thus requires the `rollup` parameter.
+
+    Returns a SnubaTSResult object that has been zerofilled in
+    case of gaps.
+
+    selected_columns (Sequence[str]) List of public aliases to fetch.
+    query (str) Filter query string to create conditions from.
+    params (Dict[str, str]) Filtering parameters with start, end, project_id, environment,
+    rollup (int) The bucket width in seconds
+    referrer (str|None) A referrer string to help locate the origin of this query.
+    comparison_delta: A timedelta used to convert this into a comparison query. We make a second
+    query time-shifted back by comparison_delta, and compare the results to get the % change for each
+    time bucket. Requires that we only pass
+    allow_metric_aggregates (bool) Ignored here, only used in metric enhanced performance
+    """
+    with sentry_sdk.start_span(op="discover.discover", description="timeseries.filter_transform"):
path: src/sentry/snuba/issue_platform.py
text: @user1:
Should we update the `op` param to something like else?