Repository: getsentry/sentry
Pull request #89306: feat(ourlogs): Allow graphing ourlogs with the timeseries APIs
Author: login: colin-sentry
Labels: name: Scope: Backend
description: Automatically applied to PRs that change backend components
Created: 2025-04-10T17:15:49Z | Merged: 2025-04-10T19:00:24Z

We want to graph logs in the logs page (amongst other places) - add a count() aggregate and a shim to make it work with events-stats and events-timeseries endpoints.


Diff:
diff --git a/src/sentry/api/endpoints/organization_events_stats.py b/src/sentry/api/endpoints/organization_events_stats.py
index 63ef0236be65..54d3cf76c8a2 100644
--- a/src/sentry/api/endpoints/organization_events_stats.py
+++ b/src/sentry/api/endpoints/organization_events_stats.py
@@ -280,10 +280,12 @@ def get(self, request: Request, organization: Organization) -> Response:
             return Response({"detail": f"Metric type must be one of: {metric_types}"}, status=400)
 
         force_metrics_layer = request.GET.get("forceMetricsLayer") == "true"
-        use_rpc = request.GET.get("useRpc", "0") == "1" and dataset == spans_eap
+        use_rpc = (
+            request.GET.get("useRpc", "0") == "1" and dataset == spans_eap
+        ) or dataset == ourlogs
         sampling_mode = request.GET.get("sampling")
         transform_alias_to_input_format = (
-            request.GET.get("transformAliasToInputFormat") == "1" or use_rpc or dataset == ourlogs
+            request.GET.get("transformAliasToInputFormat") == "1" or use_rpc
         )
         sentry_sdk.set_tag("performance.use_rpc", use_rpc)
 
@@ -298,6 +300,8 @@ def _get_event_stats(
         ) -> SnubaTSResult | dict[str, SnubaTSResult]:
             if top_events > 0:
                 if use_rpc:
+                    if scoped_dataset == ourlogs:
+                        raise NotImplementedError("You can not use top_events with logs for now.")
                     return spans_rpc.run_top_events_timeseries_query(
                         params=snuba_params,
                         query_string=query,
@@ -338,7 +342,9 @@ def _get_event_stats(
                 )
 
             if use_rpc:
-                return spans_rpc.run_timeseries_query(
+                if scoped_dataset == spans_eap:
+                    scoped_dataset = spans_rpc
+                return scoped_dataset.run_timeseries_query(
                     params=snuba_params,
                     query_string=query,
                     y_axes=query_columns,
diff --git a/src/sentry/api/endpoints/organization_events_timeseries.py b/src/sentry/api/endpoints/organization_events_timeseries.py
index 28038590330a..256030f05ac4 100644
--- a/src/sentry/api/endpoints/organization_events_timeseries.py
+++ b/src/sentry/api/endpoints/organization_events_timeseries.py
@@ -272,8 +272,10 @@ def get_event_stats(
                 ),
             )
 
-        if dataset == spans_eap:
-            return spans_rpc.run_timeseries_query(
+        if dataset == spans_eap or dataset == ourlogs:
+            if dataset == spans_eap:
+                dataset = spans_rpc
+            return dataset.run_timeseries_query(
                 params=snuba_params,
                 query_string=query,
                 y_axes=query_columns,
diff --git a/src/sentry/search/eap/ourlogs/definitions.py b/src/sentry/search/eap/ourlogs/definitions.py
index 45921a59a7b0..b0d08894f78d 100644
--- a/src/sentry/search/eap/ourlogs/definitions.py
+++ b/src/sentry/search/eap/ourlogs/definitions.py
@@ -5,9 +5,10 @@
     OURLOG_ATTRIBUTE_DEFINITIONS,
     OURLOG_VIRTUAL_CONTEXTS,
 )
+from sentry.search.eap.spans.aggregates import LOG_AGGREGATE_DEFINITIONS
 
 OURLOG_DEFINITIONS = ColumnDefinitions(
-    aggregates={},
+    aggregates=LOG_AGGREGATE_DEFINITIONS,
     conditional_aggregates={},
     formulas={},
     columns=OURLOG_ATTRIBUTE_DEFINITIONS,
diff --git a/src/sentry/search/eap/spans/aggregates.py b/src/sentry/search/eap/spans/aggregates.py
index 14a2d4ee248e..9a6f5a3a1adb 100644
--- a/src/sentry/search/eap/spans/aggregates.py
+++ b/src/sentry/search/eap/spans/aggregates.py
@@ -476,3 +476,20 @@ def resolve_bounded_sample(args: ResolvedArguments) -> tuple[AttributeKey, Trace
         attribute_resolver=transform_vital_score_to_ratio,
     ),
 }
+
+LOG_AGGREGATE_DEFINITIONS = {
+    "count": AggregateDefinition(
+        internal_function=Function.FUNCTION_COUNT,
+        infer_search_type_from_arguments=False,
+        processor=count_processor,
+        default_search_type="integer",
+        arguments=[
+            AttributeArgumentDefinition(
+                attribute_types={
+                    "string",
+                },
+                default_arg="log.body",
+            )
+        ],
+    ),
+}
diff --git a/src/sentry/snuba/ourlogs.py b/src/sentry/snuba/ourlogs.py
index 4a26e8be1457..201cb4638a82 100644
--- a/src/sentry/snuba/ourlogs.py
+++ b/src/sentry/snuba/ourlogs.py
@@ -1,15 +1,22 @@
 import logging
+from datetime import timedelta
 
+from sentry_sdk import trace
 from snuba_sdk import Column, Condition
 
 from sentry.search.eap.ourlogs.definitions import OURLOG_DEFINITIONS
 from sentry.search.eap.resolver import SearchResolver
 from sentry.search.eap.types import SearchResolverConfig
-from sentry.search.events.types import EventsResponse, SnubaParams
+from sentry.search.eap.utils import handle_downsample_meta
+from sentry.search.events.types import EventsMeta, EventsResponse, SnubaParams
+from sentry.snuba import rpc_dataset_common
 from sentry.snuba.dataset import Dataset
+from sentry.snuba.discover import zerofill
 from sentry.snuba.metrics.extraction import MetricSpecType
 from sentry.snuba.query_sources import QuerySource
 from sentry.snuba.rpc_dataset_common import run_table_query
+from sentry.utils import snuba_rpc
+from sentry.utils.snuba import SnubaTSResult
 
 logger = logging.getLogger("sentry.snuba.ourlogs")
 
@@ -77,3 +84,56 @@ def query(
         ),
         debug=debug,
     )
+
+
+@trace
+def run_timeseries_query(
+    params: SnubaParams,
+    query_string: str,
+    y_axes: list[str],
+    referrer: str,
+    config: SearchResolverConfig,
+    sampling_mode: str | None,
+    comparison_delta: timedelta | None = None,
+) -> SnubaTSResult:
+    rpc_dataset_common.validate_granularity(params)
+    search_resolver = get_resolver(params, config)
+    rpc_request, aggregates, groupbys = rpc_dataset_common.get_timeseries_query(
+        search_resolver, params, query_string, y_axes, [], referrer, sampling_mode=None
+    )
+
+    """Run the query"""
+    rpc_response = snuba_rpc.timeseries_rpc([rpc_request])[0]
+
+    """Process the results"""
+    result = rpc_dataset_common.ProcessedTimeseries()
+    final_meta: EventsMeta = EventsMeta(
+        fields={}, full_scan=handle_downsample_meta(rpc_response.meta.downsampled_storage_meta)
+    )
+    for resolved_field in aggregates + groupbys:
+        final_meta["fields"][resolved_field.public_alias] = resolved_field.search_type
+
+    for timeseries in rpc_response.result_timeseries:
+        processed = rpc_dataset_common.process_timeseries_list([timeseries])
+        if len(result.timeseries) == 0:
+            result = processed
+        else:
+            for attr in ["timeseries", "confidence", "sample_count", "sampling_rate"]:
+                for existing, new in zip(getattr(result, attr), getattr(processed, attr)):
+                    existing.update(new)
+    if len(result.timeseries) == 0:
+        # The rpc only zerofills for us when there are results, if there aren't any we have to do it ourselves
+        result.timeseries = zerofill(
+            [],
+            params.start_date,
+            params.end_date,
+            params.timeseries_granularity_secs,
+            ["time"],
+        )
+
+    return SnubaTSResult(
+        {"data": result.timeseries, "processed_timeseries": result, "meta": final_meta},
+        params.start,
+        params.end,
+        params.granularity_secs,
+    )
diff --git a/tests/snuba/api/endpoints/test_organization_events_timeseries_logs.py b/tests/snuba/api/endpoints/test_organization_events_timeseries_logs.py
new file mode 100644
index 000000000000..24261b827dee
--- /dev/null
+++ b/tests/snuba/api/endpoints/test_organization_events_timeseries_logs.py
@@ -0,0 +1,112 @@
+from datetime import timedelta
+
+from django.urls import reverse
+
+from sentry.testutils.helpers.datetime import before_now
+from tests.snuba.api.endpoints.test_organization_events import OrganizationEventsEndpointTestBase
+from tests.snuba.api.endpoints.test_organization_events_timeseries_spans import (
+    AnyConfidence,
+    build_expected_timeseries,
+)
+
+any_confidence = AnyConfidence()
+
+
+class OrganizationEventsStatsOurlogsMetricsEndpointTest(OrganizationEventsEndpointTestBase):
+    endpoint = "sentry-api-0-organization-events-timeseries"
+
+    def setUp(self):
+        super().setUp()
+        self.login_as(user=self.user)
+        self.start = self.day_ago = before_now(days=1).replace(
+            hour=10, minute=0, second=0, microsecond=0
+        )
+        self.end = self.start + timedelta(hours=6)
+        self.two_days_ago = self.day_ago - timedelta(days=1)
+
+        self.url = reverse(
+            self.endpoint,
+            kwargs={"organization_id_or_slug": self.project.organization.slug},
+        )
+
+    def _do_request(self, data, url=None, features=None):
+        if features is None:
+            features = {"organizations:ourlogs": True}
+        features.update(self.features)
+        with self.feature(features):
+            return self.client.get(self.url if url is None else url, data=data, format="json")
+
+    def test_count(self):
+        event_counts = [6, 0, 6, 3, 0, 3]
+        logs = []
+        for hour, count in enumerate(event_counts):
+            logs.extend(
+                [
+                    self.create_ourlog(
+                        {"body": "foo"},
+                        timestamp=self.start + timedelta(hours=hour, minutes=minute),
+                        attributes={"status": {"string_value": "success"}},
+                    )
+                    for minute in range(count)
+                ],
+            )
+        self.store_ourlogs(logs)
+
+        response = self._do_request(
+            data={
+                "start": self.start,
+                "end": self.end,
+                "interval": "1h",
+                "yAxis": "count()",
+                "project": self.project.id,
+                "dataset": "ourlogs",
+            },
+        )
+        assert response.status_code == 200, response.content
+        assert response.data["meta"] == {
+            "dataset": "ourlogs",
+            "start": self.start.timestamp() * 1000,
+            "end": self.end.timestamp() * 1000,
+        }
+        assert len(response.data["timeseries"]) == 1
+        timeseries = response.data["timeseries"][0]
+        assert len(timeseries["values"]) == 6
+        assert timeseries["yaxis"] == "count()"
+        assert timeseries["values"] == build_expected_timeseries(
+            self.start,
+            3_600_000,
+            event_counts,
+            sample_count=event_counts,
+            sample_rate=[1 if val else 0 for val in event_counts],
+            confidence=[any_confidence if val else None for val in event_counts],
+        )
+        assert timeseries["meta"] == {
+            "valueType": "integer",
+            "interval": 3_600_000,
+        }
+
+    def test_zerofill(self):
+        response = self._do_request(
+            data={
+                "start": self.start,
+                "end": self.end,
+                "interval": "1h",
+                "yAxis": "count()",
+                "project": self.project.id,
+                "dataset": "ourlogs",
+            },
+        )
+        assert response.status_code == 200, response.content
+        assert response.data["meta"] == {
+            "dataset": "ourlogs",
+            "start": self.start.timestamp() * 1000,
+            "end": self.end.timestamp() * 1000,
+        }
+        assert len(response.data["timeseries"]) == 1
+        timeseries = response.data["timeseries"][0]
+        assert len(timeseries["values"]) == 7
+        assert timeseries["values"] == build_expected_timeseries(
+            self.start,
+            3_600_000,
+            [0] * 7,
+        )
diff --git a/tests/snuba/api/endpoints/test_organization_events_timeseries_spans.py b/tests/snuba/api/endpoints/test_organization_events_timeseries_spans.py
index 9222a479ab3a..87a02e623f27 100644
--- a/tests/snuba/api/endpoints/test_organization_events_timeseries_spans.py
+++ b/tests/snuba/api/endpoints/test_organization_events_timeseries_spans.py
@@ -11,7 +11,7 @@
 pytestmark = pytest.mark.sentry_metrics
 
 
-def _timeseries(
+def build_expected_timeseries(
     start,
     interval,
     expected,
@@ -120,7 +120,7 @@ def test_count(self):
         timeseries = response.data["timeseries"][0]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start,
             3_600_000,
             event_counts,
@@ -201,7 +201,7 @@ def test_handle_nans_from_snuba_top_n(self):
         timeseries = response.data["timeseries"][0]
         assert len(timeseries["values"]) == 7
         assert timeseries["yaxis"] == "p50(measurements.lcp)"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             seven_days_ago, interval, [0, 0, 0, 0, 0, 0, 2], ignore_accuracy=True
         )
         assert timeseries["groupBy"] == [{"span.description": "bar"}]
@@ -216,7 +216,7 @@ def test_handle_nans_from_snuba_top_n(self):
         timeseries = response.data["timeseries"][1]
         assert len(timeseries["values"]) == 7
         assert timeseries["yaxis"] == "avg(measurements.lcp)"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             seven_days_ago, interval, [0, 0, 0, 0, 0, 0, 2], ignore_accuracy=True
         )
         assert timeseries["groupBy"] == [{"span.description": "bar"}]
@@ -231,7 +231,7 @@ def test_handle_nans_from_snuba_top_n(self):
         timeseries = response.data["timeseries"][2]
         assert len(timeseries["values"]) == 7
         assert timeseries["yaxis"] == "p50(measurements.lcp)"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             seven_days_ago, interval, [0, 0, 0, 0, 0, 0, 1], ignore_accuracy=True
         )
         assert timeseries["groupBy"] is None
@@ -246,7 +246,7 @@ def test_handle_nans_from_snuba_top_n(self):
         timeseries = response.data["timeseries"][3]
         assert len(timeseries["values"]) == 7
         assert timeseries["yaxis"] == "avg(measurements.lcp)"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             seven_days_ago, interval, [0, 0, 0, 0, 0, 0, 1], ignore_accuracy=True
         )
         assert timeseries["groupBy"] is None
@@ -298,7 +298,7 @@ def test_count_unique(self):
         timeseries = response.data["timeseries"][0]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "count_unique(foo)"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 3_600_000, event_counts, ignore_accuracy=True
         )
         assert timeseries["meta"] == {
@@ -341,7 +341,7 @@ def test_p95(self):
         timeseries = response.data["timeseries"][0]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "p95()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 3_600_000, event_durations, ignore_accuracy=True
         )
         assert timeseries["meta"] == {
@@ -391,7 +391,7 @@ def test_multiaxis(self):
         timeseries = response.data["timeseries"][0]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 3_600_000, event_counts, ignore_accuracy=True
         )
         assert timeseries["meta"] == {
@@ -402,7 +402,7 @@ def test_multiaxis(self):
         timeseries = response.data["timeseries"][1]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "p95()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 3_600_000, event_counts, ignore_accuracy=True
         )
         assert timeseries["meta"] == {
@@ -465,7 +465,7 @@ def test_top_events(self):
         timeseries = response.data["timeseries"][0]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 1, 0, 0, 0, 0], ignore_accuracy=True
         )
         assert timeseries["groupBy"] == [{"transaction": "foo"}]
@@ -479,7 +479,7 @@ def test_top_events(self):
         timeseries = response.data["timeseries"][1]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 1, 0, 0, 0, 0], ignore_accuracy=True
         )
         assert timeseries["groupBy"] == [{"transaction": "bar"}]
@@ -493,7 +493,7 @@ def test_top_events(self):
         timeseries = response.data["timeseries"][2]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 2, 0, 0, 0, 0], ignore_accuracy=True
         )
         assert timeseries["groupBy"] is None
@@ -544,7 +544,7 @@ def test_top_events_empty_other(self):
         timeseries = response.data["timeseries"][0]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 1, 0, 0, 0, 0], ignore_accuracy=True
         )
         assert timeseries["groupBy"] == [{"transaction": "foo"}]
@@ -558,7 +558,7 @@ def test_top_events_empty_other(self):
         timeseries = response.data["timeseries"][1]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 1, 0, 0, 0, 0], ignore_accuracy=True
         )
         assert timeseries["groupBy"] == [{"transaction": "bar"}]
@@ -609,7 +609,7 @@ def test_top_events_multi_y_axis(self):
         timeseries = response.data["timeseries"][0]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 1, 0, 0, 0, 0], ignore_accuracy=True
         )
         assert timeseries["groupBy"] == [{"transaction": "bar"}]
@@ -623,7 +623,7 @@ def test_top_events_multi_y_axis(self):
         timeseries = response.data["timeseries"][1]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "p50(span.duration)"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 2000, 0, 0, 0, 0], ignore_accuracy=True
         )
         assert timeseries["groupBy"] == [{"transaction": "bar"}]
@@ -638,7 +638,7 @@ def test_top_events_multi_y_axis(self):
         timeseries = response.data["timeseries"][2]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 1, 0, 0, 0, 0], ignore_accuracy=True
         )
         assert timeseries["groupBy"] == [{"transaction": "baz"}]
@@ -652,7 +652,7 @@ def test_top_events_multi_y_axis(self):
         timeseries = response.data["timeseries"][3]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "p50(span.duration)"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 2000, 0, 0, 0, 0], ignore_accuracy=True
         )
         assert timeseries["groupBy"] == [{"transaction": "baz"}]
@@ -667,7 +667,7 @@ def test_top_events_multi_y_axis(self):
         timeseries = response.data["timeseries"][4]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 1, 0, 0, 0, 0], ignore_accuracy=True
         )
         assert timeseries["groupBy"] is None
@@ -681,7 +681,7 @@ def test_top_events_multi_y_axis(self):
         timeseries = response.data["timeseries"][5]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "p50(span.duration)"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 2000, 0, 0, 0, 0], ignore_accuracy=True
         )
         assert timeseries["groupBy"] is None
@@ -739,7 +739,7 @@ def test_top_events_with_project(self):
         timeseries = response.data["timeseries"][0]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 1, 0, 0, 0, 0], ignore_accuracy=True
         )
         assert timeseries["groupBy"] == [{"project": projects[0].slug}]
@@ -753,7 +753,7 @@ def test_top_events_with_project(self):
         timeseries = response.data["timeseries"][1]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 1, 0, 0, 0, 0], ignore_accuracy=True
         )
         assert timeseries["groupBy"] == [{"project": projects[1].slug}]
@@ -767,7 +767,7 @@ def test_top_events_with_project(self):
         timeseries = response.data["timeseries"][2]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 1, 0, 0, 0, 0], ignore_accuracy=True
         )
         assert timeseries["groupBy"] is None
@@ -822,7 +822,7 @@ def test_top_events_with_project_and_project_id(self):
         timeseries = response.data["timeseries"][0]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 1, 0, 0, 0, 0], ignore_accuracy=True
         )
         assert timeseries["groupBy"] == [
@@ -839,7 +839,7 @@ def test_top_events_with_project_and_project_id(self):
         timeseries = response.data["timeseries"][1]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 1, 0, 0, 0, 0], ignore_accuracy=True
         )
         assert timeseries["groupBy"] == [
@@ -856,7 +856,7 @@ def test_top_events_with_project_and_project_id(self):
         timeseries = response.data["timeseries"][2]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 1, 0, 0, 0, 0], ignore_accuracy=True
         )
         assert timeseries["groupBy"] is None
@@ -923,7 +923,7 @@ def test_count_extrapolation(self):
         timeseries = response.data["timeseries"][0]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start,
             3_600_000,
             [val * 10 for val in event_counts],
@@ -995,7 +995,7 @@ def test_confidence_is_set(self):
                 expected_values = [val * 10000 for val in event_counts]
             else:
                 expected_values = [1000 if val else 0 for val in event_counts]
-            assert timeseries["values"] == _timeseries(
+            assert timeseries["values"] == build_expected_timeseries(
                 self.start,
                 3_600_000,
                 expected_values,
@@ -1046,7 +1046,7 @@ def test_extrapolation_with_multiaxis(self):
             timeseries = response.data["timeseries"][index]
             assert len(timeseries["values"]) == 6
             assert timeseries["yaxis"] == y_axis
-            assert timeseries["values"] == _timeseries(
+            assert timeseries["values"] == build_expected_timeseries(
                 self.start,
                 3_600_000,
                 [val * 10 if y_axis == "count()" else val for val in event_counts],
@@ -1119,7 +1119,7 @@ def test_top_events_with_extrapolation(self):
                 assert timeseries["groupBy"] == [{"transaction": groupby}]
             else:
                 assert timeseries["groupBy"] is None
-            assert timeseries["values"] == _timeseries(
+            assert timeseries["values"] == build_expected_timeseries(
                 self.start,
                 60_000,
                 [val * 10 for val in event_counts],
@@ -1171,7 +1171,7 @@ def test_comparison_delta(self):
         timeseries = response.data["timeseries"][0]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start,
             3_600_000,
             event_counts,
@@ -1220,7 +1220,7 @@ def test_comparison_delta_with_empty_comparison_values(self):
         timeseries = response.data["timeseries"][0]
         assert len(timeseries["values"]) == 6
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 3_600_000, event_counts, [0] * len(event_counts), ignore_accuracy=True
         )
         assert timeseries["meta"] == {
@@ -1298,7 +1298,7 @@ def test_project_filters(self):
             timeseries = response.data["timeseries"][0]
             assert len(timeseries["values"]) == 6
             assert timeseries["yaxis"] == "count()"
-            assert timeseries["values"] == _timeseries(
+            assert timeseries["values"] == build_expected_timeseries(
                 self.start, 3_600_000, event_counts, ignore_accuracy=True
             )
             assert timeseries["meta"] == {
@@ -1362,7 +1362,7 @@ def test_device_class_filter(self):
             timeseries = response.data["timeseries"][0]
             assert len(timeseries["values"]) == 6
             assert timeseries["yaxis"] == "count()"
-            assert timeseries["values"] == _timeseries(
+            assert timeseries["values"] == build_expected_timeseries(
                 self.start, 3_600_000, event_counts, ignore_accuracy=True
             )
             assert timeseries["meta"] == {
@@ -1503,7 +1503,7 @@ def test_cache_miss_rate(self):
         timeseries = response.data["timeseries"][0]
         assert len(timeseries["values"]) == 3
         assert timeseries["yaxis"] == "cache_miss_rate()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0.0, 1.0, 0.25], ignore_accuracy=True
         )
         assert timeseries["meta"] == {
@@ -1564,7 +1564,7 @@ def test_trace_status_rate(self):
         timeseries = response.data["timeseries"][0]
         assert len(timeseries["values"]) == 3
         assert timeseries["yaxis"] == "trace_status_rate(ok)"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0.0, 0.5, 0.75], ignore_accuracy=True
         )
         assert timeseries["meta"] == {
@@ -1613,7 +1613,7 @@ def test_count_op(self):
         timeseries = response.data["timeseries"][0]
         assert len(timeseries["values"]) == 3
         assert timeseries["yaxis"] == "count_op(queue.publish)"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0.0, 2.0, 1.0], ignore_accuracy=True
         )
         assert timeseries["meta"] == {
@@ -1661,7 +1661,7 @@ def test_downsampling_single_series(self):
         timeseries = response.data["timeseries"][0]
         assert len(timeseries["values"]) == 3
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 512, 0], ignore_accuracy=True
         )
         assert timeseries["meta"] == {
@@ -1692,7 +1692,7 @@ def test_downsampling_single_series(self):
         timeseries = response.data["timeseries"][0]
         assert len(timeseries["values"]) == 3
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 1, 0], ignore_accuracy=True
         )
         assert timeseries["meta"] == {
@@ -1745,7 +1745,7 @@ def test_downsampling_top_events(self):
         timeseries = response.data["timeseries"][0]
         assert len(timeseries["values"]) == 3
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 512, 0], ignore_accuracy=True
         )
         assert timeseries["meta"] == {
@@ -1782,7 +1782,7 @@ def test_downsampling_top_events(self):
         timeseries = response.data["timeseries"][0]
         assert len(timeseries["values"]) == 3
         assert timeseries["yaxis"] == "count()"
-        assert timeseries["values"] == _timeseries(
+        assert timeseries["values"] == build_expected_timeseries(
             self.start, 60_000, [0, 1, 0], ignore_accuracy=True
         )
         assert timeseries["meta"] == {


Reviews:
user: login: wmak
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T18:19:16Z
user: login: colin-sentry
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T18:38:25Z
user: login: wmak
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T18:38:54Z
user: login: colin-sentry
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T18:39:20Z
user: login: wmak
body: lgtm
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-10T18:40:50Z

Review comments:
diff_hunk: @@ -272,8 +272,10 @@ def get_event_stats(
                 ),
             )
 
-        if dataset == spans_eap:
-            return spans_rpc.run_timeseries_query(
+        if dataset == spans_eap or dataset == ourlogs:
+            if dataset == spans_eap:
+                dataset = spans_rpc
+            return dataset.run_timeseries_query(
path: src/sentry/api/endpoints/organization_events_timeseries.py
user: login: wmak
body: Won't you need top_events too?
created_at: 2025-04-10T17:58:32Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,157 @@
+from datetime import timedelta
+from unittest import mock
+
+from django.urls import reverse
+
+from sentry.testutils.helpers.datetime import before_now
+from tests.snuba.api.endpoints.test_organization_events import OrganizationEventsEndpointTestBase
+
+
+def _timeseries(
+    start,
+    interval,
+    expected,
+    expected_comparison=None,
+    ignore_accuracy=False,
+    sample_count=None,
+    sample_rate=None,
+    confidence=None,
+):
path: tests/snuba/api/endpoints/test_organization_events_timeseries_logs.py
user: login: wmak
body: Would it be possible to just import this and `AnyConfidence` from the other test file? (or move to a util)
created_at: 2025-04-10T18:18:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,157 @@
+from datetime import timedelta
+from unittest import mock
+
+from django.urls import reverse
+
+from sentry.testutils.helpers.datetime import before_now
+from tests.snuba.api.endpoints.test_organization_events import OrganizationEventsEndpointTestBase
+
+
+def _timeseries(
+    start,
+    interval,
+    expected,
+    expected_comparison=None,
+    ignore_accuracy=False,
+    sample_count=None,
+    sample_rate=None,
+    confidence=None,
+):
+    if expected_comparison is not None:
+        assert len(expected_comparison) == len(expected)
+    if ignore_accuracy:
+        sample_count = [mock.ANY for val in expected]
+        sample_rate = [mock.ANY for val in expected]
+        confidence = [mock.ANY for val in expected]
+    else:
+        if sample_count is not None:
+            assert len(sample_count) == len(expected)
+        if sample_rate is not None:
+            assert len(sample_rate) == len(expected)
+        if confidence is not None:
+            assert len(confidence) == len(expected)
+
+    expected_value = []
+    for index, value in enumerate(expected):
+        current_value = {
+            "timestamp": start.timestamp() * 1000 + interval * index,
+            "value": value,
+        }
+        if expected_comparison is not None:
+            current_value["comparisonValue"] = expected_comparison[index]
+        if sample_count is not None:
+            current_value["sampleCount"] = sample_count[index]
+        if sample_rate is not None:
+            current_value["sampleRate"] = sample_rate[index]
+        if confidence is not None:
+            current_value["confidence"] = confidence[index]
+        expected_value.append(current_value)
+    return expected_value
+
+
+class AnyConfidence:
+    def __eq__(self, o: object):
+        return o in ["high", "low"]
+
+
+any_confidence = AnyConfidence()
+
+
+class OrganizationEventsStatsOurlogsMetricsEndpointTest(OrganizationEventsEndpointTestBase):
+    endpoint = "sentry-api-0-organization-events-timeseries"
+
+    def setUp(self):
+        super().setUp()
+        self.login_as(user=self.user)
+        self.start = self.day_ago = before_now(days=1).replace(
+            hour=10, minute=0, second=0, microsecond=0
+        )
+        self.end = self.start + timedelta(hours=6)
+        self.two_days_ago = self.day_ago - timedelta(days=1)
+
+        self.url = reverse(
+            self.endpoint,
+            kwargs={"organization_id_or_slug": self.project.organization.slug},
+        )
+
+    def _do_request(self, data, url=None, features=None):
+        if features is None:
+            features = {"organizations:ourlogs": True}
+        features.update(self.features)
+        with self.feature(features):
+            return self.client.get(self.url if url is None else url, data=data, format="json")
+
+    def test_count(self):
+        event_counts = [6, 0, 6, 3, 0, 3]
+        logs = []
+        for hour, count in enumerate(event_counts):
+            logs.extend(
+                [
+                    self.create_ourlog(
+                        {"body": "foo"},
+                        timestamp=self.start + timedelta(hours=hour, minutes=minute),
+                        attributes={"status": {"string_value": "success"}},
+                    )
+                    for minute in range(count)
+                ],
+            )
+        self.store_ourlogs(logs)
+
+        response = self._do_request(
+            data={
+                "start": self.start,
+                "end": self.end,
+                "interval": "1h",
+                "yAxis": "count()",
+                "project": self.project.id,
+                "dataset": "ourlogs",
+            },
+        )
+        assert response.status_code == 200, response.content
+        assert response.data["meta"] == {
+            "dataset": "ourlogs",
+            "start": self.start.timestamp() * 1000,
+            "end": self.end.timestamp() * 1000,
+        }
+        assert len(response.data["timeseries"]) == 1
+        timeseries = response.data["timeseries"][0]
+        assert len(timeseries["values"]) == 6
+        assert timeseries["yaxis"] == "count()"
+        assert timeseries["values"] == _timeseries(
+            self.start,
+            3_600_000,
+            event_counts,
+            sample_count=event_counts,
+            sample_rate=[1 if val else 0 for val in event_counts],
+            confidence=[any_confidence if val else None for val in event_counts],
+        )
+        assert timeseries["meta"] == {
+            "valueType": "integer",
+            "interval": 3_600_000,
+        }
+
+    def test_zerofill(self):
+        response = self._do_request(
+            data={
+                "start": self.start,
+                "end": self.end,
+                "interval": "1h",
+                "yAxis": "count()",
+                "project": self.project.id,
+                "dataset": "ourlogs",
+            },
+        )
+        assert response.status_code == 200, response.content
+        assert response.data["meta"] == {
+            "dataset": "ourlogs",
+            "start": self.start.timestamp() * 1000,
+            "end": self.end.timestamp() * 1000,
+        }
+        assert len(response.data["timeseries"]) == 1
+        timeseries = response.data["timeseries"][0]
+        assert len(timeseries["values"]) == 7
+        assert timeseries["values"] == _timeseries(
+            self.start,
+            3_600_000,
+            [0] * 7,
+        )
path: tests/snuba/api/endpoints/test_organization_events_timeseries_logs.py
user: login: wmak
body: Unsure based on my other comment, but should you test `top_events` as well?
created_at: 2025-04-10T18:18:53Z
author_association: MEMBER
diff_hunk: @@ -272,8 +272,10 @@ def get_event_stats(
                 ),
             )
 
-        if dataset == spans_eap:
-            return spans_rpc.run_timeseries_query(
+        if dataset == spans_eap or dataset == ourlogs:
+            if dataset == spans_eap:
+                dataset = spans_rpc
+            return dataset.run_timeseries_query(
path: src/sentry/api/endpoints/organization_events_timeseries.py
user: login: colin-sentry
body: Not needed for logs for now
created_at: 2025-04-10T18:38:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -272,8 +272,10 @@ def get_event_stats(
                 ),
             )
 
-        if dataset == spans_eap:
-            return spans_rpc.run_timeseries_query(
+        if dataset == spans_eap or dataset == ourlogs:
+            if dataset == spans_eap:
+                dataset = spans_rpc
+            return dataset.run_timeseries_query(
path: src/sentry/api/endpoints/organization_events_timeseries.py
user: login: wmak
body: Do you still need thsi change then? https://github.com/getsentry/sentry/pull/89306/files#diff-4aa78da493b2755b89cf27bd682c88915d46aee98920034188d4405d63b260f6R303-R305
created_at: 2025-04-10T18:38:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,157 @@
+from datetime import timedelta
+from unittest import mock
+
+from django.urls import reverse
+
+from sentry.testutils.helpers.datetime import before_now
+from tests.snuba.api.endpoints.test_organization_events import OrganizationEventsEndpointTestBase
+
+
+def _timeseries(
+    start,
+    interval,
+    expected,
+    expected_comparison=None,
+    ignore_accuracy=False,
+    sample_count=None,
+    sample_rate=None,
+    confidence=None,
+):
path: tests/snuba/api/endpoints/test_organization_events_timeseries_logs.py
user: login: colin-sentry
body: done
created_at: 2025-04-10T18:39:20Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-04-10T18:47:17Z
body: ## [Codecov](https://app.codecov.io/gh/getsentry/sentry/pull/89306?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) Report
Attention: Patch coverage is `97.63780%` with `3 lines` in your changes missing coverage. Please review.

:white_check_mark: All tests successful. No failed tests found.

| [Files with missing lines](https://app.codecov.io/gh/getsentry/sentry/pull/89306?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) | Patch % | Lines |
|---|---|---|
| [src/sentry/snuba/ourlogs.py](https://app.codecov.io/gh/getsentry/sentry/pull/89306?src=pr&el=tree&filepath=src%2Fsentry%2Fsnuba%2Fourlogs.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-c3JjL3NlbnRyeS9zbnViYS9vdXJsb2dzLnB5) | 90.00% | [3 Missing :warning: ](https://app.codecov.io/gh/getsentry/sentry/pull/89306?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master   #89306      +/-   ##
==========================================
- Coverage   80.31%   79.42%   -0.90%     
==========================================
  Files       10137    10138       +1     
  Lines      572752   572786      +34     
  Branches    22513    22511       -2     
==========================================
- Hits       459985   454911    -5074     
- Misses     112321   114171    +1850     
- Partials      446     3704    +3258     
```

</details>
author_association: NONE
user: login: sentry[bot]
created_at: 2025-04-16T17:47:16Z
body: ## Suspect Issues
This pull request was deployed and Sentry observed the following issues:

- ‼️ **SnubaRPCError: HTTPConnectionPool(host='snuba-api', port=80): Max retries exceeded with url: /rpc/EndpointTimeSeries/v1 (Caused by NewConnectionError('<urllib3.connection.HTTPConnection object at 0x79229295b2f0>: Failed to establish a new connection: [Errno 111] Conne...** `/api/0/organizations/{organization_id_or_slug}/...` [View Issue](https://sentry.sentry.io/issues/6546301918/?referrer=github-pr-bot)

<sub>Did you find this useful? React with a 👍 or 👎</sub>
author_association: CONTRIBUTOR