From 09ed6227bb4f70401c34b48115ec83aafac9e496 Mon Sep 17 00:00:00 2001 From: nikkikapadia Date: Thu, 28 May 2026 14:57:47 -0400 Subject: [PATCH 1/4] fix(heatmaps): very small y-axis values turning into engineering notation and throwing errors --- .../endpoints/organization_events_heatmap.py | 18 +++- ...ganization_events_heatmap_trace_metrics.py | 95 +++++++++++++++++++ 2 files changed, 110 insertions(+), 3 deletions(-) diff --git a/src/sentry/api/endpoints/organization_events_heatmap.py b/src/sentry/api/endpoints/organization_events_heatmap.py index bfcaa8c96567ae..c0d4f7ff125100 100644 --- a/src/sentry/api/endpoints/organization_events_heatmap.py +++ b/src/sentry/api/endpoints/organization_events_heatmap.py @@ -25,6 +25,16 @@ HEATMAP_DATASETS = {TraceMetrics} +def _format_long_float(value: float) -> str: + # Python's default float-to-string uses scientific notation for very small + # values (e.g. 8.527e-06), which the search query parser does not support. + # Fixed-point with 20 decimal places produces a plain decimal string the parser can handle. + if "e" in str(value) or "E" in str(value): + return f"{value:.20f}".rstrip("0").rstrip(".") + else: + return str(value) + + class LimitTuple(NamedTuple): min_value: float max_value: float @@ -186,17 +196,19 @@ def get(self, request: Request, organization: Organization) -> Response: upper_bound = bucket_ranges.min_value + (current_bucket + 1) * bucket_size if current_bucket == y_buckets - 1: - yAxes[lower_bound] = f"{z_function}_if(`{yAxis}:>={lower_bound}`, {yAxis})" + yAxes[lower_bound] = ( + f"{z_function}_if(`{yAxis}:>={_format_long_float(lower_bound)}`, {yAxis})" + ) else: yAxes[lower_bound] = ( - f"{z_function}_if(`{yAxis}:>={lower_bound} AND {yAxis}:<{upper_bound}`, {yAxis})" + f"{z_function}_if(`{yAxis}:>={_format_long_float(lower_bound)} AND {yAxis}:<{_format_long_float(upper_bound)}`, {yAxis})" ) else: # if max == min, then just have 1 bucket bucket_size = 0 y_buckets = 1 yAxes = { - bucket_ranges.min_value: f"{z_function}_if(`{yAxis}:{bucket_ranges.min_value}`, {yAxis})" + bucket_ranges.min_value: f"{z_function}_if(`{yAxis}:{_format_long_float(bucket_ranges.min_value)}`, {yAxis})" } result = dataset.run_timeseries_query( diff --git a/tests/snuba/api/endpoints/test_organization_events_heatmap_trace_metrics.py b/tests/snuba/api/endpoints/test_organization_events_heatmap_trace_metrics.py index 2ae7dd195f909b..061cc6081a8306 100644 --- a/tests/snuba/api/endpoints/test_organization_events_heatmap_trace_metrics.py +++ b/tests/snuba/api/endpoints/test_organization_events_heatmap_trace_metrics.py @@ -1,7 +1,9 @@ from datetime import timedelta +import pytest from django.urls import reverse +from sentry.api.endpoints.organization_events_heatmap import _format_long_float from sentry.testutils.helpers.datetime import before_now from tests.snuba.api.endpoints.test_organization_events import ( OrganizationEventsEndpointTestBase, @@ -376,3 +378,96 @@ def test_invalid_log_scale(self): ) assert response.status_code == 400, response.content assert response.data["detail"] == "logScale cannot be 1" + + def test_very_small_float_values(self) -> None: + # Values like 8.527e-06 would previously be formatted in scientific + # notation inside the query string, which the search query parser rejects. + small_values = [0.000008, 0.000009, 0.000010, 0.000011] + + trace_metrics = [] + for hour, value in enumerate(small_values): + trace_metrics.append( + self.create_trace_metric( + "foo", + value, + "counter", + timestamp=self.start + timedelta(hours=hour), + ) + ) + self.store_eap_items(trace_metrics) + + response = self._do_request( + data={ + "start": self.start, + "end": self.start + timedelta(hours=6), + "yAxis": "value", + "interval": "1h", + "yBuckets": 4, + "query": "metric.name:foo metric.type:counter", + "project": self.project.id, + "dataset": self.dataset, + }, + ) + assert response.status_code == 200, response.content + assert response.data["meta"]["yAxis"]["start"] == pytest.approx(0.000008, rel=1e-3) + assert response.data["meta"]["yAxis"]["end"] == pytest.approx(0.000011, rel=1e-3) + + def test_very_small_float_min_equals_max(self) -> None: + # When min == max and the value is very small, the single-bucket query + # must also use plain decimal notation rather than scientific notation. + trace_metrics = [ + self.create_trace_metric( + "foo", + 0.000008527, + "counter", + timestamp=self.start + timedelta(hours=hour), + ) + for hour in range(3) + ] + self.store_eap_items(trace_metrics) + + response = self._do_request( + data={ + "start": self.start, + "end": self.start + timedelta(hours=6), + "yAxis": "value", + "interval": "1h", + "yBuckets": 10, + "query": "metric.name:foo metric.type:counter", + "project": self.project.id, + "dataset": self.dataset, + }, + ) + assert response.status_code == 200, response.content + assert response.data["meta"]["yAxis"]["bucketCount"] == 1 + assert response.data["meta"]["yAxis"]["start"] == pytest.approx(0.000008527, rel=1e-3) + + +class TestFormatLongFloat: + def test_small_number_no_scientific_notation(self) -> None: + result = _format_long_float(8.527e-06) + assert "e" not in result + assert "E" not in result + assert result == "0.000008527" + + def test_normal_number(self) -> None: + result = _format_long_float(123.456) + assert "e" not in result + assert result == "123.456" + + def test_huge_number_no_scientific_notation(self) -> None: + result = _format_long_float(123456789012345678901234567890) + assert "e" not in result + assert "E" not in result + assert result == "123456789012345678901234567890" + + def test_zero(self) -> None: + result = _format_long_float(0.0) + assert result == "0.0" + + def test_very_small_number_is_parseable(self) -> None: + # Ensure the formatted string looks like a plain decimal Python float + result = _format_long_float(1.23e-10) + assert "e" not in result + assert "E" not in result + float(result) # must not raise From 2420f755be2c64cdf194fb2e9370c19617b2c8a0 Mon Sep 17 00:00:00 2001 From: nikkikapadia Date: Thu, 28 May 2026 15:48:12 -0400 Subject: [PATCH 2/4] implement fixes --- .../endpoints/organization_events_heatmap.py | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/sentry/api/endpoints/organization_events_heatmap.py b/src/sentry/api/endpoints/organization_events_heatmap.py index c0d4f7ff125100..179785a6dfe022 100644 --- a/src/sentry/api/endpoints/organization_events_heatmap.py +++ b/src/sentry/api/endpoints/organization_events_heatmap.py @@ -25,16 +25,6 @@ HEATMAP_DATASETS = {TraceMetrics} -def _format_long_float(value: float) -> str: - # Python's default float-to-string uses scientific notation for very small - # values (e.g. 8.527e-06), which the search query parser does not support. - # Fixed-point with 20 decimal places produces a plain decimal string the parser can handle. - if "e" in str(value) or "E" in str(value): - return f"{value:.20f}".rstrip("0").rstrip(".") - else: - return str(value) - - class LimitTuple(NamedTuple): min_value: float max_value: float @@ -93,6 +83,17 @@ class OrganizationEventsHeatmapEndpoint(OrganizationEventsEndpointBase): } ) + def _format_long_float(value: float) -> str: + """ + Python's default float-to-string uses scientific notation for very small + values (e.g. 8.527e-06), which the search query parser does not support. + Fixed-point with 20 decimal places produces a plain decimal string the parser can handle. + """ + if "e" in str(value) or "E" in str(value): + return f"{value:.20f}".rstrip("0").rstrip(".") + else: + return str(value) + def get(self, request: Request, organization: Organization) -> Response: """ Retrieves explore data for a given organization as a heatmap. @@ -197,18 +198,18 @@ def get(self, request: Request, organization: Organization) -> Response: if current_bucket == y_buckets - 1: yAxes[lower_bound] = ( - f"{z_function}_if(`{yAxis}:>={_format_long_float(lower_bound)}`, {yAxis})" + f"{z_function}_if(`{yAxis}:>={self._format_long_float(lower_bound)}`, {yAxis})" ) else: yAxes[lower_bound] = ( - f"{z_function}_if(`{yAxis}:>={_format_long_float(lower_bound)} AND {yAxis}:<{_format_long_float(upper_bound)}`, {yAxis})" + f"{z_function}_if(`{yAxis}:>={self._format_long_float(lower_bound)} AND {yAxis}:<{self._format_long_float(upper_bound)}`, {yAxis})" ) else: # if max == min, then just have 1 bucket bucket_size = 0 y_buckets = 1 yAxes = { - bucket_ranges.min_value: f"{z_function}_if(`{yAxis}:{_format_long_float(bucket_ranges.min_value)}`, {yAxis})" + bucket_ranges.min_value: f"{z_function}_if(`{yAxis}:{self._format_long_float(bucket_ranges.min_value)}`, {yAxis})" } result = dataset.run_timeseries_query( From 4b0f8c4cf525fa3ff9e2fa173b59b4539522ad8e Mon Sep 17 00:00:00 2001 From: nikkikapadia Date: Thu, 28 May 2026 15:54:49 -0400 Subject: [PATCH 3/4] fix the whoopsies all the bots yelled at me for --- src/sentry/api/endpoints/organization_events_heatmap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/api/endpoints/organization_events_heatmap.py b/src/sentry/api/endpoints/organization_events_heatmap.py index 179785a6dfe022..83015878690e22 100644 --- a/src/sentry/api/endpoints/organization_events_heatmap.py +++ b/src/sentry/api/endpoints/organization_events_heatmap.py @@ -83,7 +83,7 @@ class OrganizationEventsHeatmapEndpoint(OrganizationEventsEndpointBase): } ) - def _format_long_float(value: float) -> str: + def _format_long_float(self, value: float) -> str: """ Python's default float-to-string uses scientific notation for very small values (e.g. 8.527e-06), which the search query parser does not support. From f72abe6467bdc714d014b5a82da341caf8040f3d Mon Sep 17 00:00:00 2001 From: nikkikapadia Date: Thu, 28 May 2026 16:06:56 -0400 Subject: [PATCH 4/4] fix backend typing --- .../api/endpoints/organization_events_heatmap.py | 3 ++- ...st_organization_events_heatmap_trace_metrics.py | 14 ++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/sentry/api/endpoints/organization_events_heatmap.py b/src/sentry/api/endpoints/organization_events_heatmap.py index 83015878690e22..946293454f8b0e 100644 --- a/src/sentry/api/endpoints/organization_events_heatmap.py +++ b/src/sentry/api/endpoints/organization_events_heatmap.py @@ -83,7 +83,8 @@ class OrganizationEventsHeatmapEndpoint(OrganizationEventsEndpointBase): } ) - def _format_long_float(self, value: float) -> str: + @staticmethod + def _format_long_float(value: float) -> str: """ Python's default float-to-string uses scientific notation for very small values (e.g. 8.527e-06), which the search query parser does not support. diff --git a/tests/snuba/api/endpoints/test_organization_events_heatmap_trace_metrics.py b/tests/snuba/api/endpoints/test_organization_events_heatmap_trace_metrics.py index 061cc6081a8306..a6a79257fc8576 100644 --- a/tests/snuba/api/endpoints/test_organization_events_heatmap_trace_metrics.py +++ b/tests/snuba/api/endpoints/test_organization_events_heatmap_trace_metrics.py @@ -3,7 +3,7 @@ import pytest from django.urls import reverse -from sentry.api.endpoints.organization_events_heatmap import _format_long_float +from sentry.api.endpoints.organization_events_heatmap import OrganizationEventsHeatmapEndpoint from sentry.testutils.helpers.datetime import before_now from tests.snuba.api.endpoints.test_organization_events import ( OrganizationEventsEndpointTestBase, @@ -445,29 +445,31 @@ def test_very_small_float_min_equals_max(self) -> None: class TestFormatLongFloat: def test_small_number_no_scientific_notation(self) -> None: - result = _format_long_float(8.527e-06) + result = OrganizationEventsHeatmapEndpoint._format_long_float(8.527e-06) assert "e" not in result assert "E" not in result assert result == "0.000008527" def test_normal_number(self) -> None: - result = _format_long_float(123.456) + result = OrganizationEventsHeatmapEndpoint._format_long_float(123.456) assert "e" not in result assert result == "123.456" def test_huge_number_no_scientific_notation(self) -> None: - result = _format_long_float(123456789012345678901234567890) + result = OrganizationEventsHeatmapEndpoint._format_long_float( + 123456789012345678901234567890 + ) assert "e" not in result assert "E" not in result assert result == "123456789012345678901234567890" def test_zero(self) -> None: - result = _format_long_float(0.0) + result = OrganizationEventsHeatmapEndpoint._format_long_float(0.0) assert result == "0.0" def test_very_small_number_is_parseable(self) -> None: # Ensure the formatted string looks like a plain decimal Python float - result = _format_long_float(1.23e-10) + result = OrganizationEventsHeatmapEndpoint._format_long_float(1.23e-10) assert "e" not in result assert "E" not in result float(result) # must not raise