From e6c0196a9361d792c2cd6e7a3c699ec37739a021 Mon Sep 17 00:00:00 2001
From: "Armen Zambrano G." <44410+armenzg@users.noreply.github.com>
Date: Fri, 20 Jun 2025 12:49:57 -0400
Subject: [PATCH 01/32] feat(browser-reports): Validate both browser report
formats (#93917)
This validates both the [Working
Draft](https://www.w3.org/TR/reporting-1/#concept-reports) and the
[Editor's Draft](https://w3c.github.io/reporting/#concept-reports)
formats.
Fixes [ID-730 - Accept current and upcoming data
model](https://linear.app/getsentry/issue/ID-730/accept-current-and-upcoming-data-model).
---
.../endpoints/browser_reporting_collector.py | 97 ++++++---
.../test_browser_reporting_collector.py | 192 +++++++++++-------
2 files changed, 183 insertions(+), 106 deletions(-)
diff --git a/src/sentry/issues/endpoints/browser_reporting_collector.py b/src/sentry/issues/endpoints/browser_reporting_collector.py
index 2bcf8976511..52fdcde5eeb 100644
--- a/src/sentry/issues/endpoints/browser_reporting_collector.py
+++ b/src/sentry/issues/endpoints/browser_reporting_collector.py
@@ -1,13 +1,14 @@
from __future__ import annotations
import logging
-from dataclasses import dataclass
-from typing import Any, Literal
+from typing import Any
-from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
+from rest_framework import serializers
from rest_framework.parsers import JSONParser
from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND, HTTP_422_UNPROCESSABLE_ENTITY
from sentry import options
from sentry.api.api_owners import ApiOwner
@@ -17,30 +18,46 @@
logger = logging.getLogger(__name__)
-# Known browser report types as defined by the Browser Reporting API specification
-BrowserReportType = Literal[
- # Core report types (always sent to 'default' endpoint)
- "deprecation", # Deprecated API usage
- "intervention", # Browser interventions/blocks
- "crash", # Browser crashes
- # Policy violation report types (can be sent to named endpoints)
- "csp-violation", # Content Security Policy violations
- "coep", # Cross-Origin-Embedder-Policy violations
- "coop", # Cross-Origin-Opener-Policy violations
- "document-policy-violation", # Document Policy violations
- "permissions-policy", # Permissions Policy violations
+BROWSER_REPORT_TYPES = [
+ "deprecation",
+ "intervention",
+ "crash",
+ "csp-violation",
+ "coep",
+ "coop",
+ "document-policy-violation",
+ "permissions-policy",
]
-@dataclass
-class BrowserReport:
- body: dict[str, Any]
- type: BrowserReportType
- url: str
- user_agent: str
- destination: str
- timestamp: int
- attempts: int
+# Working Draft https://www.w3.org/TR/reporting-1/#concept-reports
+# Editor's Draft https://w3c.github.io/reporting/#concept-reports
+# We need to support both
+class BrowserReportSerializer(serializers.Serializer[Any]):
+ """Serializer for validating browser report data structure."""
+
+ body = serializers.DictField()
+ type = serializers.ChoiceField(choices=BROWSER_REPORT_TYPES)
+ url = serializers.URLField()
+ user_agent = serializers.CharField()
+ destination = serializers.CharField()
+ attempts = serializers.IntegerField(min_value=1)
+ # Fields that do not overlap between specs
+ # We need to support both specs
+ age = serializers.IntegerField(required=False)
+ timestamp = serializers.IntegerField(required=False, min_value=0)
+
+ def validate_timestamp(self, value: int) -> int:
+ """Validate that age is absent, but timestamp is present."""
+ if self.initial_data.get("age"):
+ raise serializers.ValidationError("If timestamp is present, age must be absent")
+ return value
+
+ def validate_age(self, value: int) -> int:
+ """Validate that age is present, but not timestamp."""
+ if self.initial_data.get("timestamp"):
+ raise serializers.ValidationError("If age is present, timestamp must be absent")
+ return value
class BrowserReportsJSONParser(JSONParser):
@@ -63,17 +80,15 @@ class BrowserReportingCollectorEndpoint(Endpoint):
permission_classes = ()
# Support both standard JSON and browser reporting API content types
parser_classes = [BrowserReportsJSONParser, JSONParser]
- publish_status = {
- "POST": ApiPublishStatus.PRIVATE,
- }
+ publish_status = {"POST": ApiPublishStatus.PRIVATE}
owner = ApiOwner.ISSUES
# CSRF exemption and CORS support required for Browser Reporting API
@csrf_exempt
@allow_cors_options
- def post(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse:
+ def post(self, request: Request, *args: Any, **kwargs: Any) -> Response:
if not options.get("issues.browser_reporting.collector_endpoint_enabled"):
- return HttpResponse(status=404)
+ return Response(status=HTTP_404_NOT_FOUND)
logger.info("browser_report_received", extra={"request_body": request.data})
@@ -86,14 +101,30 @@ def post(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse:
"browser_report_invalid_format",
extra={"data_type": type(raw_data).__name__, "data": raw_data},
)
- return HttpResponse(status=422)
+ return Response(status=HTTP_422_UNPROCESSABLE_ENTITY)
+ # Validate each report in the array
+ validated_reports = []
for report in raw_data:
- browser_report = BrowserReport(**report)
+ serializer = BrowserReportSerializer(data=report)
+ if not serializer.is_valid():
+ logger.warning(
+ "browser_report_validation_failed",
+ extra={"validation_errors": serializer.errors, "raw_report": report},
+ )
+ return Response(
+ {"error": "Invalid report data", "details": serializer.errors},
+ status=HTTP_422_UNPROCESSABLE_ENTITY,
+ )
+
+ validated_reports.append(serializer.validated_data)
+
+ # Process all validated reports
+ for browser_report in validated_reports:
metrics.incr(
"browser_reporting.raw_report_received",
- tags={"browser_report_type": browser_report.type},
+ tags={"browser_report_type": str(browser_report["type"])},
sample_rate=1.0, # XXX: Remove this once we have a ballpark figure
)
- return HttpResponse(status=200)
+ return Response(status=HTTP_200_OK)
diff --git a/tests/sentry/api/endpoints/test_browser_reporting_collector.py b/tests/sentry/api/endpoints/test_browser_reporting_collector.py
index ccafd5e68a8..2220472a5e9 100644
--- a/tests/sentry/api/endpoints/test_browser_reporting_collector.py
+++ b/tests/sentry/api/endpoints/test_browser_reporting_collector.py
@@ -1,11 +1,46 @@
+from copy import deepcopy
from unittest.mock import MagicMock, patch
from django.urls import reverse
from rest_framework import status
+from rest_framework.response import Response
from sentry.testutils.cases import APITestCase
from sentry.testutils.helpers.options import override_options
+# Working Draft format
+DEPRECATION_REPORT = {
+ "body": {
+ "columnNumber": 12,
+ "id": "RangeExpand",
+ "lineNumber": 31,
+ "message": "Range.expand() is deprecated. Please use Selection.modify() instead.",
+ "sourceFile": "https://dogs.are.great/_next/static/chunks/_4667019e._.js",
+ },
+ "type": "deprecation",
+ "url": "https://dogs.are.great/",
+ "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
+ "destination": "default",
+ "timestamp": 1640995200000, # January 1, 2022 in milliseconds
+ "attempts": 1,
+}
+
+# Editor's Draft format
+INTERVENTION_REPORT = {
+ "body": {
+ "id": "NavigatorVibrate",
+ "message": "The vibrate() method is deprecated.",
+ "sourceFile": "https://dogs.are.great/app.js",
+ "lineNumber": 45,
+ },
+ "type": "intervention",
+ "url": "https://dogs.are.great/page2",
+ "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
+ "destination": "default",
+ "age": 2,
+ "attempts": 1,
+}
+
class BrowserReportingCollectorEndpointTest(APITestCase):
endpoint = "sentry-api-0-reporting-api-experiment"
@@ -14,43 +49,23 @@ def setUp(self) -> None:
super().setUp()
self.url = reverse(self.endpoint)
- self.report_data = [
- {
- "body": {
- "columnNumber": 12,
- "id": "RangeExpand",
- "lineNumber": 31,
- "message": "Range.expand() is deprecated. Please use Selection.modify() instead.",
- "sourceFile": "https://dogs.are.great/_next/static/chunks/_4667019e._.js",
- },
- "type": "deprecation",
- "url": "https://dogs.are.great/",
- "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
- "destination": "default",
- "timestamp": 1640995200000, # January 1, 2022 in milliseconds
- "attempts": 1,
- }
- ]
+ self.report_data = [DEPRECATION_REPORT]
+
+ def assert_invalid_report_data(self, response: Response, details: dict[str, list[str]]) -> None:
+ assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
+ response_data = response.json() # type: ignore[attr-defined]
+ assert response_data["error"] == "Invalid report data"
+ assert response_data["details"] == details
def test_404s_by_default(self) -> None:
response = self.client.post(self.url, self.report_data)
-
assert response.status_code == status.HTTP_404_NOT_FOUND
@override_options({"issues.browser_reporting.collector_endpoint_enabled": True})
@patch("sentry.issues.endpoints.browser_reporting_collector.metrics.incr")
- @patch("sentry.issues.endpoints.browser_reporting_collector.logger.info")
- def test_logs_request_data_if_option_enabled(
- self, mock_logger_info: MagicMock, mock_metrics_incr: MagicMock
- ) -> None:
- response = self.client.post(
- self.url, self.report_data, content_type="application/reports+json"
- )
-
+ def test_basic(self, mock_metrics_incr: MagicMock) -> None:
+ response = self.client.post(self.url, self.report_data)
assert response.status_code == status.HTTP_200_OK
- mock_logger_info.assert_any_call(
- "browser_report_received", extra={"request_body": self.report_data}
- )
mock_metrics_incr.assert_any_call(
"browser_reporting.raw_report_received",
tags={"browser_report_type": "deprecation"},
@@ -62,7 +77,6 @@ def test_logs_request_data_if_option_enabled(
def test_rejects_invalid_content_type(self, mock_metrics_incr: MagicMock) -> None:
"""Test that the endpoint rejects invalid content type and does not call the browser reporting metric"""
response = self.client.post(self.url, self.report_data, content_type="bad/type/json")
-
assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
# Verify that the browser_reporting.raw_report_received metric was not called
# Check that none of the calls were for the browser_reporting.raw_report_received metric
@@ -71,51 +85,11 @@ def test_rejects_invalid_content_type(self, mock_metrics_incr: MagicMock) -> Non
@override_options({"issues.browser_reporting.collector_endpoint_enabled": True})
@patch("sentry.issues.endpoints.browser_reporting_collector.metrics.incr")
- @patch("sentry.issues.endpoints.browser_reporting_collector.logger.info")
- def test_handles_multiple_reports(
- self, mock_logger_info: MagicMock, mock_metrics_incr: MagicMock
- ) -> None:
+ def test_handles_multiple_reports_both_specs(self, mock_metrics_incr: MagicMock) -> None:
"""Test that the endpoint handles multiple reports in a single request"""
- multiple_reports = [
- {
- "body": {
- "columnNumber": 12,
- "id": "RangeExpand",
- "lineNumber": 31,
- "message": "Range.expand() is deprecated. Please use Selection.modify() instead.",
- "sourceFile": "https://dogs.are.great/_next/static/chunks/_4667019e._.js",
- },
- "type": "deprecation",
- "url": "https://dogs.are.great/",
- "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
- "destination": "default",
- "timestamp": 1640995200000,
- "attempts": 1,
- },
- {
- "body": {
- "id": "NavigatorVibrate",
- "message": "The vibrate() method is deprecated.",
- "sourceFile": "https://dogs.are.great/app.js",
- "lineNumber": 45,
- },
- "type": "intervention",
- "url": "https://dogs.are.great/page2",
- "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
- "destination": "default",
- "timestamp": 1640995260000,
- "attempts": 1,
- },
- ]
-
- response = self.client.post(
- self.url, multiple_reports, content_type="application/reports+json"
- )
-
+ multiple_reports = [DEPRECATION_REPORT, INTERVENTION_REPORT]
+ response = self.client.post(self.url, multiple_reports)
assert response.status_code == status.HTTP_200_OK
- mock_logger_info.assert_any_call(
- "browser_report_received", extra={"request_body": multiple_reports}
- )
# Should record metrics for each report type
mock_metrics_incr.assert_any_call(
"browser_reporting.raw_report_received",
@@ -127,3 +101,75 @@ def test_handles_multiple_reports(
tags={"browser_report_type": "intervention"},
sample_rate=1.0,
)
+
+ @override_options({"issues.browser_reporting.collector_endpoint_enabled": True})
+ def test_rejects_missing_required_fields(self) -> None:
+ """Test that missing required fields are properly validated"""
+ report = deepcopy(DEPRECATION_REPORT)
+ del report["user_agent"]
+ response = self.client.post(self.url, [report])
+ self.assert_invalid_report_data(response, {"user_agent": ["This field is required."]})
+
+ @override_options({"issues.browser_reporting.collector_endpoint_enabled": True})
+ def test_rejects_invalid_report_type(self) -> None:
+ """Test that invalid report types are rejected"""
+ report = deepcopy(DEPRECATION_REPORT)
+ report["type"] = "invalid-type"
+ response = self.client.post(self.url, [report])
+ self.assert_invalid_report_data(
+ response,
+ {"type": ['"invalid-type" is not a valid choice.']},
+ )
+
+ @override_options({"issues.browser_reporting.collector_endpoint_enabled": True})
+ def test_rejects_invalid_url(self) -> None:
+ """Test that invalid URLs are rejected"""
+ report = deepcopy(DEPRECATION_REPORT)
+ report["url"] = "not-a-valid-url"
+ response = self.client.post(self.url, [report])
+ self.assert_invalid_report_data(response, {"url": ["Enter a valid URL."]})
+
+ @override_options({"issues.browser_reporting.collector_endpoint_enabled": True})
+ def test_rejects_invalid_timestamp(self) -> None:
+ """Test that invalid timestamps are rejected"""
+ report = deepcopy(DEPRECATION_REPORT)
+ report["timestamp"] = -1
+ response = self.client.post(self.url, [report])
+ self.assert_invalid_report_data(
+ response, {"timestamp": ["Ensure this value is greater than or equal to 0."]}
+ )
+
+ @override_options({"issues.browser_reporting.collector_endpoint_enabled": True})
+ def test_rejects_invalid_attempts(self) -> None:
+ """Test that invalid attempts values are rejected"""
+ report = deepcopy(DEPRECATION_REPORT)
+ report["attempts"] = 0
+ response = self.client.post(self.url, [report])
+ self.assert_invalid_report_data(
+ response, {"attempts": ["Ensure this value is greater than or equal to 1."]}
+ )
+
+ @override_options({"issues.browser_reporting.collector_endpoint_enabled": True})
+ def test_rejects_non_dict_body(self) -> None:
+ """Test that non-dict body values are rejected"""
+ report = deepcopy(DEPRECATION_REPORT)
+ report["body"] = "not-a-dict"
+ response = self.client.post(self.url, [report])
+ self.assert_invalid_report_data(
+ response,
+ {"body": ['Expected a dictionary of items but got type "str".']},
+ )
+
+ @override_options({"issues.browser_reporting.collector_endpoint_enabled": True})
+ def test_mixed_fields(self) -> None:
+ """Test that mixed fields are rejected"""
+ report = deepcopy(DEPRECATION_REPORT)
+ report["age"] = 1
+ response = self.client.post(self.url, [report])
+ self.assert_invalid_report_data(
+ response,
+ {
+ "age": ["If age is present, timestamp must be absent"],
+ "timestamp": ["If timestamp is present, age must be absent"],
+ },
+ )
From ba199fa4bc11d33392ffa671b523dc8d1da534b4 Mon Sep 17 00:00:00 2001
From: edwardgou-sentry <83961295+edwardgou-sentry@users.noreply.github.com>
Date: Fri, 20 Jun 2025 12:56:24 -0400
Subject: [PATCH 02/32] fix(insights): adds referrer to Insights charts create
alerts url (#93936)
---
.../app/views/insights/common/components/chartActionDropdown.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/static/app/views/insights/common/components/chartActionDropdown.tsx b/static/app/views/insights/common/components/chartActionDropdown.tsx
index 36d50a23e15..620e9580b85 100644
--- a/static/app/views/insights/common/components/chartActionDropdown.tsx
+++ b/static/app/views/insights/common/components/chartActionDropdown.tsx
@@ -68,6 +68,7 @@ export function ChartActionDropdown({
pageFilters: selection,
aggregate: yAxis,
organization,
+ referrer,
}),
};
});
From 668229d196e0be1f1353da2ece67dc0f7d4e8ee9 Mon Sep 17 00:00:00 2001
From: getsentry-bot
Date: Fri, 20 Jun 2025 17:01:39 +0000
Subject: [PATCH 03/32] Revert "chore(autofix): Change default automation
tuning from 'off' to 'low' (#93927)"
This reverts commit 8d0452245a8a8d8b801d32fba9735e1cf45479d7.
Co-authored-by: roaga <47861399+roaga@users.noreply.github.com>
---
src/sentry/constants.py | 2 +-
src/sentry/projectoptions/defaults.py | 2 +-
tests/sentry/api/endpoints/test_project_details.py | 4 ++--
tests/sentry/api/serializers/test_project.py | 4 ++--
4 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/sentry/constants.py b/src/sentry/constants.py
index c5374e1dcb0..d2ef563c826 100644
--- a/src/sentry/constants.py
+++ b/src/sentry/constants.py
@@ -721,7 +721,7 @@ class InsightModules(Enum):
TARGET_SAMPLE_RATE_DEFAULT = 1.0
SAMPLING_MODE_DEFAULT = "organization"
ROLLBACK_ENABLED_DEFAULT = True
-DEFAULT_AUTOFIX_AUTOMATION_TUNING_DEFAULT = "low"
+DEFAULT_AUTOFIX_AUTOMATION_TUNING_DEFAULT = "off"
DEFAULT_SEER_SCANNER_AUTOMATION_DEFAULT = False
INGEST_THROUGH_TRUSTED_RELAYS_ONLY_DEFAULT = False
diff --git a/src/sentry/projectoptions/defaults.py b/src/sentry/projectoptions/defaults.py
index 23f793aa099..d96cf2062a9 100644
--- a/src/sentry/projectoptions/defaults.py
+++ b/src/sentry/projectoptions/defaults.py
@@ -202,7 +202,7 @@
register(key="sentry:tempest_fetch_dumps", default=False)
# Should autofix run automatically on new issues
-register(key="sentry:autofix_automation_tuning", default="low")
+register(key="sentry:autofix_automation_tuning", default="off")
# Should seer scanner run automatically on new issues
register(key="sentry:seer_scanner_automation", default=False)
diff --git a/tests/sentry/api/endpoints/test_project_details.py b/tests/sentry/api/endpoints/test_project_details.py
index 411612fe4f9..6be04241249 100644
--- a/tests/sentry/api/endpoints/test_project_details.py
+++ b/tests/sentry/api/endpoints/test_project_details.py
@@ -2065,7 +2065,7 @@ def test_autofix_automation_tuning(self):
"trigger-autofix-on-issue-summary feature enabled"
in resp.data["autofixAutomationTuning"][0]
)
- assert self.project.get_option("sentry:autofix_automation_tuning") == "low" # default
+ assert self.project.get_option("sentry:autofix_automation_tuning") == "off" # default
# Test with feature flag but invalid value - should fail
with self.feature("organizations:trigger-autofix-on-issue-summary"):
@@ -2073,7 +2073,7 @@ def test_autofix_automation_tuning(self):
self.org_slug, self.proj_slug, autofixAutomationTuning="invalid", status_code=400
)
assert '"invalid" is not a valid choice.' in resp.data["autofixAutomationTuning"][0]
- assert self.project.get_option("sentry:autofix_automation_tuning") == "low" # default
+ assert self.project.get_option("sentry:autofix_automation_tuning") == "off" # default
# Test with feature flag and valid value - should succeed
resp = self.get_success_response(
diff --git a/tests/sentry/api/serializers/test_project.py b/tests/sentry/api/serializers/test_project.py
index 5024c2e446f..6a0db58a44a 100644
--- a/tests/sentry/api/serializers/test_project.py
+++ b/tests/sentry/api/serializers/test_project.py
@@ -809,9 +809,9 @@ def test_toolbar_allowed_origins(self):
assert result["options"]["sentry:toolbar_allowed_origins"].split("\n") == origins
def test_autofix_automation_tuning_flag(self):
- # Default is "low"
+ # Default is "off"
result = serialize(self.project, self.user, DetailedProjectSerializer())
- assert result["autofixAutomationTuning"] == "low"
+ assert result["autofixAutomationTuning"] == "off"
# Update the value
self.project.update_option("sentry:autofix_automation_tuning", "high")
From a6e22c7e124163e6956afdb781cedf2b3c254bf1 Mon Sep 17 00:00:00 2001
From: Kyle Consalus
Date: Fri, 20 Jun 2025 10:06:27 -0700
Subject: [PATCH 04/32] fix(aci): Use contextual logger in workflow processor
(#93947)
Missed in the initial commit, leading to some relevant logs being
unannotated.
---
src/sentry/workflow_engine/processors/workflow.py | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/sentry/workflow_engine/processors/workflow.py b/src/sentry/workflow_engine/processors/workflow.py
index c1683e5e100..10521166584 100644
--- a/src/sentry/workflow_engine/processors/workflow.py
+++ b/src/sentry/workflow_engine/processors/workflow.py
@@ -1,4 +1,3 @@
-import logging
from collections.abc import Collection, Mapping
from dataclasses import asdict, dataclass, replace
from enum import StrEnum
@@ -33,7 +32,7 @@
from sentry.workflow_engine.utils import log_context
from sentry.workflow_engine.utils.metrics import metrics_incr
-logger = logging.getLogger(__name__)
+logger = log_context.get_logger(__name__)
WORKFLOW_ENGINE_BUFFER_LIST_KEY = "workflow_engine_delayed_processing_buffer"
From 4549d790e070136410ad70982658584c08f5510e Mon Sep 17 00:00:00 2001
From: Mark Story
Date: Fri, 20 Jun 2025 13:06:45 -0400
Subject: [PATCH 05/32] fix(taskworker) Increase deadlines for webhook delivery
(#93941)
We have had a few tasks get killed at 10% rollout.
---
src/sentry/hybridcloud/tasks/deliver_webhooks.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/sentry/hybridcloud/tasks/deliver_webhooks.py b/src/sentry/hybridcloud/tasks/deliver_webhooks.py
index d87dec54a87..bf8c7c51e26 100644
--- a/src/sentry/hybridcloud/tasks/deliver_webhooks.py
+++ b/src/sentry/hybridcloud/tasks/deliver_webhooks.py
@@ -82,6 +82,7 @@ class DeliveryFailed(Exception):
silo_mode=SiloMode.CONTROL,
taskworker_config=TaskworkerConfig(
namespace=hybridcloud_control_tasks,
+ processing_deadline_duration=30,
),
)
def schedule_webhook_delivery() -> None:
@@ -157,6 +158,7 @@ def schedule_webhook_delivery() -> None:
silo_mode=SiloMode.CONTROL,
taskworker_config=TaskworkerConfig(
namespace=hybridcloud_control_tasks,
+ processing_deadline_duration=300,
),
)
def drain_mailbox(payload_id: int) -> None:
@@ -234,6 +236,7 @@ def drain_mailbox(payload_id: int) -> None:
silo_mode=SiloMode.CONTROL,
taskworker_config=TaskworkerConfig(
namespace=hybridcloud_control_tasks,
+ processing_deadline_duration=120,
),
)
def drain_mailbox_parallel(payload_id: int) -> None:
From 1a815176cd8f447f5d034ec2676dad149918d827 Mon Sep 17 00:00:00 2001
From: Colin <161344340+colin-sentry@users.noreply.github.com>
Date: Fri, 20 Jun 2025 13:10:23 -0400
Subject: [PATCH 06/32] fix(ourlogs): Fix top events for logs (#93939)
Also add a test, so that this doesn't happen again
---
src/sentry/snuba/ourlogs.py | 2 +
.../test_organization_events_stats.py | 113 +++++++++++++++++-
2 files changed, 112 insertions(+), 3 deletions(-)
diff --git a/src/sentry/snuba/ourlogs.py b/src/sentry/snuba/ourlogs.py
index 76eb87e0978..9b355333d92 100644
--- a/src/sentry/snuba/ourlogs.py
+++ b/src/sentry/snuba/ourlogs.py
@@ -154,6 +154,7 @@ def run_top_events_timeseries_query(
referrer: str,
config: SearchResolverConfig,
sampling_mode: SAMPLING_MODES | None,
+ equations: list[str] | None = None,
) -> Any:
return rpc_dataset_common.run_top_events_timeseries_query(
get_resolver=get_resolver,
@@ -166,4 +167,5 @@ def run_top_events_timeseries_query(
referrer=referrer,
config=config,
sampling_mode=sampling_mode,
+ equations=equations,
)
diff --git a/tests/snuba/api/endpoints/test_organization_events_stats.py b/tests/snuba/api/endpoints/test_organization_events_stats.py
index 9a1ea94fc4e..71c12586339 100644
--- a/tests/snuba/api/endpoints/test_organization_events_stats.py
+++ b/tests/snuba/api/endpoints/test_organization_events_stats.py
@@ -1,8 +1,9 @@
from __future__ import annotations
import uuid
+from collections import defaultdict
from datetime import datetime, timedelta
-from typing import Any, TypedDict
+from typing import Any, DefaultDict, TypedDict
from unittest import mock
from uuid import uuid4
@@ -18,7 +19,7 @@
from sentry.models.project import Project
from sentry.models.transaction_threshold import ProjectTransactionThreshold, TransactionMetric
from sentry.snuba.discover import OTHER_KEY
-from sentry.testutils.cases import APITestCase, ProfilesSnubaTestCase, SnubaTestCase
+from sentry.testutils.cases import APITestCase, OurLogTestCase, ProfilesSnubaTestCase, SnubaTestCase
from sentry.testutils.helpers.datetime import before_now
from sentry.utils.samples import load_data
from tests.sentry.issues.test_utils import SearchIssueTestMixin
@@ -1246,7 +1247,7 @@ def test_group_id_tag_simple(self):
assert all([interval[1][0]["count"] == 0 for interval in response.data["data"]])
-class OrganizationEventsStatsTopNEvents(APITestCase, SnubaTestCase):
+class OrganizationEventsStatsTopNEventsSpans(APITestCase, SnubaTestCase):
def setUp(self):
super().setUp()
self.login_as(user=self.user)
@@ -3082,6 +3083,112 @@ def test_functions_dataset_simple(self):
}
+class OrganizationEventsStatsTopNEventsLogs(APITestCase, SnubaTestCase, OurLogTestCase):
+ # This is implemented almost exactly the same as spans, add a simple test case for a sanity check
+ def setUp(self):
+ super().setUp()
+ self.login_as(user=self.user)
+
+ self.day_ago = before_now(days=1).replace(hour=10, minute=0, second=0, microsecond=0)
+
+ self.project = self.create_project()
+ self.logs = (
+ [
+ self.create_ourlog(
+ {"body": "zero seconds"},
+ timestamp=self.day_ago + timedelta(microseconds=i),
+ )
+ for i in range(10)
+ ]
+ + [
+ self.create_ourlog(
+ {"body": "five seconds"},
+ timestamp=self.day_ago + timedelta(seconds=5, microseconds=i),
+ )
+ for i in range(20)
+ ]
+ + [
+ self.create_ourlog(
+ {"body": "ten seconds"},
+ timestamp=self.day_ago + timedelta(seconds=10, microseconds=i),
+ )
+ for i in range(30)
+ ]
+ + [
+ self.create_ourlog(
+ {"body": "fifteen seconds"},
+ timestamp=self.day_ago + timedelta(seconds=15, microseconds=i),
+ )
+ for i in range(40)
+ ]
+ + [
+ self.create_ourlog(
+ {"body": "twenty seconds"},
+ timestamp=self.day_ago + timedelta(seconds=20, microseconds=i),
+ )
+ for i in range(50)
+ ]
+ + [
+ self.create_ourlog(
+ {"body": "twenty five seconds"},
+ timestamp=self.day_ago + timedelta(seconds=25, microseconds=i),
+ )
+ for i in range(60)
+ ]
+ )
+ self.store_ourlogs(self.logs)
+
+ self.enabled_features = {
+ "organizations:discover-basic": True,
+ "organizations:ourlogs-enabled": True,
+ }
+ self.url = reverse(
+ "sentry-api-0-organization-events-stats",
+ kwargs={"organization_id_or_slug": self.project.organization.slug},
+ )
+
+ def test_simple_top_events(self):
+ with self.feature(self.enabled_features):
+ response = self.client.get(
+ self.url,
+ data={
+ "start": self.day_ago.isoformat(),
+ "end": (self.day_ago + timedelta(hours=2)).isoformat(),
+ "dataset": "ourlogs",
+ "interval": "1h",
+ "yAxis": "count()",
+ "orderby": ["-count()"],
+ "field": ["count()", "message"],
+ "topEvents": "5",
+ },
+ format="json",
+ )
+
+ data = response.data
+ assert response.status_code == 200, response.content
+
+ expected_message_counts_dict: DefaultDict[str, int] = defaultdict(int)
+ for log in self.logs:
+ attr = log.attributes.get("sentry.body")
+ if attr is not None:
+ body = attr.string_value
+ expected_message_counts_dict[body] += 1
+
+ expected_message_counts: list[tuple[str, int]] = sorted(
+ expected_message_counts_dict.items(), key=lambda x: x[1], reverse=True
+ )
+
+ assert set(data.keys()) == {x[0] for x in expected_message_counts[:5]}.union({"Other"})
+
+ for index, (message, count) in enumerate(expected_message_counts[:5]):
+ assert [{"count": count}] in data[message]["data"][0]
+ assert data[message]["order"] == index
+
+ other = data["Other"]
+ assert other["order"] == 5
+ assert [{"count": 10}] in other["data"][0]
+
+
class OrganizationEventsStatsTopNEventsErrors(APITestCase, SnubaTestCase):
def setUp(self):
super().setUp()
From 37375d999bfa6272b4fd5337db0f75c136fa28b1 Mon Sep 17 00:00:00 2001
From: Scott Cooper
Date: Fri, 20 Jun 2025 10:12:29 -0700
Subject: [PATCH 07/32] feat(aci): Set ownership on detector (#93745)
---
.../endpoints/validators/base/detector.py | 30 +++++++
.../test_organization_detector_details.py | 89 +++++++++++++++++++
.../test_organization_detector_index.py | 86 ++++++++++++++++++
3 files changed, 205 insertions(+)
diff --git a/src/sentry/workflow_engine/endpoints/validators/base/detector.py b/src/sentry/workflow_engine/endpoints/validators/base/detector.py
index 1732507064c..a32020860d5 100644
--- a/src/sentry/workflow_engine/endpoints/validators/base/detector.py
+++ b/src/sentry/workflow_engine/endpoints/validators/base/detector.py
@@ -5,6 +5,7 @@
from rest_framework import serializers
from sentry import audit_log
+from sentry.api.fields.actor import ActorField
from sentry.api.serializers.rest_framework import CamelSnakeSerializer
from sentry.issues import grouptype
from sentry.issues.grouptype import GroupType
@@ -33,6 +34,7 @@ class BaseDetectorTypeValidator(CamelSnakeSerializer):
)
type = serializers.CharField()
config = serializers.JSONField(default={})
+ owner = ActorField(required=False, allow_null=True)
def validate_type(self, value: str) -> builtins.type[GroupType]:
type = grouptype.registry.get_by_slug(value)
@@ -60,6 +62,22 @@ def data_conditions(self) -> BaseDataConditionValidator:
def update(self, instance: Detector, validated_data: dict[str, Any]):
instance.name = validated_data.get("name", instance.name)
instance.type = validated_data.get("detector_type", instance.group_type).slug
+
+ # Handle owner field update
+ if "owner" in validated_data:
+ owner = validated_data.get("owner")
+ if owner:
+ if owner.is_user:
+ instance.owner_user_id = owner.id
+ instance.owner_team_id = None
+ elif owner.is_team:
+ instance.owner_user_id = None
+ instance.owner_team_id = owner.id
+ else:
+ # Clear owner if None is passed
+ instance.owner_user_id = None
+ instance.owner_team_id = None
+
condition_group = validated_data.pop("condition_group")
data_conditions: list[DataConditionType] = condition_group.get("conditions")
@@ -98,12 +116,24 @@ def create(self, validated_data):
type=condition["type"],
condition_group=condition_group,
)
+
+ owner = validated_data.get("owner")
+ owner_user_id = None
+ owner_team_id = None
+ if owner:
+ if owner.is_user:
+ owner_user_id = owner.id
+ elif owner.is_team:
+ owner_team_id = owner.id
+
detector = Detector.objects.create(
project_id=self.context["project"].id,
name=validated_data["name"],
workflow_condition_group=condition_group,
type=validated_data["type"].slug,
config=validated_data.get("config", {}),
+ owner_user_id=owner_user_id,
+ owner_team_id=owner_team_id,
created_by_id=self.context["request"].user.id,
)
DataSourceDetector.objects.create(data_source=detector_data_source, detector=detector)
diff --git a/tests/sentry/workflow_engine/endpoints/test_organization_detector_details.py b/tests/sentry/workflow_engine/endpoints/test_organization_detector_details.py
index 68aa310f76f..f6d1ce6cffb 100644
--- a/tests/sentry/workflow_engine/endpoints/test_organization_detector_details.py
+++ b/tests/sentry/workflow_engine/endpoints/test_organization_detector_details.py
@@ -241,6 +241,95 @@ def test_update_bad_schema(self):
status_code=400,
)
+ def test_update_owner_to_user(self):
+ # Initially no owner
+ assert self.detector.owner_user_id is None
+ assert self.detector.owner_team_id is None
+
+ data = {
+ **self.valid_data,
+ "owner": self.user.get_actor_identifier(),
+ }
+
+ with self.tasks():
+ response = self.get_success_response(
+ self.organization.slug,
+ self.detector.id,
+ **data,
+ status_code=200,
+ )
+
+ detector = Detector.objects.get(id=response.data["id"])
+
+ # Verify owner is set correctly
+ assert detector.owner_user_id == self.user.id
+ assert detector.owner_team_id is None
+ assert detector.owner is not None
+ assert detector.owner.identifier == self.user.get_actor_identifier()
+
+ # Verify serialized response includes owner
+ assert response.data["owner"] == self.user.get_actor_identifier()
+
+ def test_update_owner_to_team(self):
+ # Set initial user owner
+ self.detector.owner_user_id = self.user.id
+ self.detector.save()
+
+ # Create a team
+ team = self.create_team(organization=self.organization)
+
+ data = {
+ **self.valid_data,
+ "owner": f"team:{team.id}",
+ }
+
+ with self.tasks():
+ response = self.get_success_response(
+ self.organization.slug,
+ self.detector.id,
+ **data,
+ status_code=200,
+ )
+
+ detector = Detector.objects.get(id=response.data["id"])
+
+ # Verify owner changed to team
+ assert detector.owner_user_id is None
+ assert detector.owner_team_id == team.id
+ assert detector.owner is not None
+ assert detector.owner.identifier == f"team:{team.id}"
+
+ # Verify serialized response includes team owner
+ assert response.data["owner"] == f"team:{team.id}"
+
+ def test_update_clear_owner(self):
+ # Set initial owner
+ self.detector.owner_user_id = self.user.id
+ self.detector.save()
+
+ data = {
+ **self.valid_data,
+ "owner": None,
+ }
+
+ with self.tasks():
+ response = self.get_success_response(
+ self.organization.slug,
+ self.detector.id,
+ **data,
+ status_code=200,
+ )
+
+ detector = Detector.objects.get(id=response.data["id"])
+
+ # Verify owner is cleared
+ assert detector.owner_user_id is None
+ assert detector.owner_team_id is None
+ assert detector.owner is None
+
+ # Verify serialized response shows no owner
+ assert response.data["owner"] is None
+
@region_silo_test
class OrganizationDetectorDetailsDeleteTest(OrganizationDetectorDetailsBaseTest):
diff --git a/tests/sentry/workflow_engine/endpoints/test_organization_detector_index.py b/tests/sentry/workflow_engine/endpoints/test_organization_detector_index.py
index 009d6d3bcd7..171e55d9840 100644
--- a/tests/sentry/workflow_engine/endpoints/test_organization_detector_index.py
+++ b/tests/sentry/workflow_engine/endpoints/test_organization_detector_index.py
@@ -454,3 +454,89 @@ def test_empty_query_string(self):
query_sub = QuerySubscription.objects.get(id=int(data_source.source_id))
assert query_sub.snuba_query.query == ""
+
+ def test_valid_creation_with_owner(self):
+ # Test data with owner field
+ data_with_owner = {
+ **self.valid_data,
+ "owner": self.user.get_actor_identifier(),
+ }
+
+ with self.tasks():
+ response = self.get_success_response(
+ self.organization.slug,
+ **data_with_owner,
+ status_code=201,
+ )
+
+ detector = Detector.objects.get(id=response.data["id"])
+
+ # Verify owner is set correctly
+ assert detector.owner_user_id == self.user.id
+ assert detector.owner_team_id is None
+ assert detector.owner is not None
+ assert detector.owner.identifier == self.user.get_actor_identifier()
+
+ # Verify serialized response includes owner
+ assert response.data["owner"] == self.user.get_actor_identifier()
+
+ def test_valid_creation_with_team_owner(self):
+ # Create a team for testing
+ team = self.create_team(organization=self.organization)
+
+ # Test data with team owner
+ data_with_team_owner = {
+ **self.valid_data,
+ "owner": f"team:{team.id}",
+ }
+
+ with self.tasks():
+ response = self.get_success_response(
+ self.organization.slug,
+ **data_with_team_owner,
+ status_code=201,
+ )
+
+ detector = Detector.objects.get(id=response.data["id"])
+
+ # Verify team owner is set correctly
+ assert detector.owner_user_id is None
+ assert detector.owner_team_id == team.id
+ assert detector.owner is not None
+ assert detector.owner.identifier == f"team:{team.id}"
+
+ # Verify serialized response includes team owner
+ assert response.data["owner"] == f"team:{team.id}"
+
+ def test_invalid_owner(self):
+ # Test with invalid owner format
+ data_with_invalid_owner = {
+ **self.valid_data,
+ "owner": "invalid:owner:format",
+ }
+
+ response = self.get_error_response(
+ self.organization.slug,
+ **data_with_invalid_owner,
+ status_code=400,
+ )
+ assert "owner" in response.data
+
+ def test_owner_not_in_organization(self):
+ # Create a user in another organization
+ other_org = self.create_organization()
+ other_user = self.create_user()
+ self.create_member(organization=other_org, user=other_user)
+
+ # Test with owner not in current organization
+ data_with_invalid_owner = {
+ **self.valid_data,
+ "owner": other_user.get_actor_identifier(),
+ }
+
+ response = self.get_error_response(
+ self.organization.slug,
+ **data_with_invalid_owner,
+ status_code=400,
+ )
+ assert "owner" in response.data
From 976bb0b4c06258e334229b3121b867dba6ca1c4c Mon Sep 17 00:00:00 2001
From: Jonas
Date: Fri, 20 Jun 2025 13:32:52 -0400
Subject: [PATCH 08/32] Update RightMask component background gradient (#93933)
Fixes DE-129 and DE-156
---------
Co-authored-by: Cursor Agent
---
static/app/components/scrollCarousel.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/static/app/components/scrollCarousel.tsx b/static/app/components/scrollCarousel.tsx
index a517d7c5876..65bc94c064e 100644
--- a/static/app/components/scrollCarousel.tsx
+++ b/static/app/components/scrollCarousel.tsx
@@ -201,7 +201,7 @@ const RightMask = styled('div')<{transparentMask: boolean}>`
right: 0;
background: ${p =>
p.transparentMask
- ? 'linear-gradient(to right, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1))'
+ ? `linear-gradient(to right, transparent, ${p.theme.background})`
: `linear-gradient(
270deg,
${p.theme.background} 50%,
From d088de36c9bed77166df471752adaae6988dc193 Mon Sep 17 00:00:00 2001
From: Jonas
Date: Fri, 20 Jun 2025 13:38:51 -0400
Subject: [PATCH 09/32] button: fix transition (#93934)
These transitions should be matching
---
static/app/components/core/button/styles.chonk.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/static/app/components/core/button/styles.chonk.tsx b/static/app/components/core/button/styles.chonk.tsx
index b387f05c403..d8c351493db 100644
--- a/static/app/components/core/button/styles.chonk.tsx
+++ b/static/app/components/core/button/styles.chonk.tsx
@@ -110,7 +110,7 @@ export function DO_NOT_USE_getChonkButtonStyles(
borderRadius: 'inherit',
border: `1px solid ${getChonkButtonTheme(type, p.theme).background}`,
transform: `translateY(-${chonkElevation(p.size)})`,
- transition: 'transform 0.1s ease-in-out',
+ transition: 'transform 0.06s ease-in-out',
},
'&:focus-visible': {
From 8a179d9bc81d0a9d88fead5c28309f46701c0eaf Mon Sep 17 00:00:00 2001
From: Malachi Willey
Date: Fri, 20 Jun 2025 10:39:18 -0700
Subject: [PATCH 10/32] fix(migrations): Patch broken saved search migrations
(#93944)
---
...917_convert_org_saved_searches_to_views.py | 21 ++----------
...ert_org_saved_searches_to_views_revised.py | 23 ++-----------
...917_convert_org_saved_searches_to_views.py | 34 -------------------
3 files changed, 6 insertions(+), 72 deletions(-)
delete mode 100644 tests/sentry/migrations/test_0917_convert_org_saved_searches_to_views.py
diff --git a/src/sentry/migrations/0917_convert_org_saved_searches_to_views.py b/src/sentry/migrations/0917_convert_org_saved_searches_to_views.py
index 83d1f9b6637..e8718423086 100644
--- a/src/sentry/migrations/0917_convert_org_saved_searches_to_views.py
+++ b/src/sentry/migrations/0917_convert_org_saved_searches_to_views.py
@@ -5,30 +5,15 @@
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.state import StateApps
-from sentry.models.savedsearch import Visibility
from sentry.new_migrations.migrations import CheckedMigration
-from sentry.utils.query import RangeQuerySetWrapperWithProgressBar
def convert_org_saved_searches_to_views(
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
) -> None:
- SavedSearch = apps.get_model("sentry", "SavedSearch")
- GroupSearchView = apps.get_model("sentry", "GroupSearchView")
-
- org_saved_searches = SavedSearch.objects.filter(visibility=Visibility.ORGANIZATION)
-
- for saved_search in RangeQuerySetWrapperWithProgressBar(org_saved_searches):
- GroupSearchView.objects.update_or_create(
- organization=saved_search.organization,
- user_id=saved_search.owner_id,
- name=saved_search.name,
- defaults={
- "query": saved_search.query,
- "query_sort": saved_search.sort,
- "date_added": saved_search.date_added,
- },
- )
+ # This migration had an error and was never run.
+ # See 0921_convert_org_saved_searches_to_views_rerevised.py for the correct migration.
+ return
class Migration(CheckedMigration):
diff --git a/src/sentry/migrations/0920_convert_org_saved_searches_to_views_revised.py b/src/sentry/migrations/0920_convert_org_saved_searches_to_views_revised.py
index 10ca0ba7251..c3cf88fda38 100644
--- a/src/sentry/migrations/0920_convert_org_saved_searches_to_views_revised.py
+++ b/src/sentry/migrations/0920_convert_org_saved_searches_to_views_revised.py
@@ -4,32 +4,15 @@
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.state import StateApps
-from sentry.models.savedsearch import Visibility
from sentry.new_migrations.migrations import CheckedMigration
-from sentry.utils.query import RangeQuerySetWrapperWithProgressBar
def convert_org_saved_searches_to_views(
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
) -> None:
- SavedSearch = apps.get_model("sentry", "SavedSearch")
- GroupSearchView = apps.get_model("sentry", "GroupSearchView")
-
- org_saved_searches = SavedSearch.objects.filter(
- visibility=Visibility.ORGANIZATION, owner_id__isnull=False
- )
-
- for saved_search in RangeQuerySetWrapperWithProgressBar(org_saved_searches):
- GroupSearchView.objects.update_or_create(
- organization=saved_search.organization,
- user_id=saved_search.owner_id,
- name=saved_search.name,
- defaults={
- "query": saved_search.query,
- "query_sort": saved_search.sort,
- "date_added": saved_search.date_added,
- },
- )
+ # This migration had an error and was never run.
+ # See 0921_convert_org_saved_searches_to_views_rerevised.py for the correct migration.
+ return
class Migration(CheckedMigration):
diff --git a/tests/sentry/migrations/test_0917_convert_org_saved_searches_to_views.py b/tests/sentry/migrations/test_0917_convert_org_saved_searches_to_views.py
deleted file mode 100644
index afe8595405f..00000000000
--- a/tests/sentry/migrations/test_0917_convert_org_saved_searches_to_views.py
+++ /dev/null
@@ -1,34 +0,0 @@
-from sentry.models.groupsearchview import GroupSearchView
-from sentry.models.savedsearch import SavedSearch, Visibility
-from sentry.testutils.cases import TestMigrations
-
-
-class ConvertOrgSavedSearchesToViewsTest(TestMigrations):
- migrate_from = "0916_delete_open_period_rows"
- migrate_to = "0917_convert_org_saved_searches_to_views"
-
- def setup_initial_state(self):
- self.org = self.create_organization()
- self.user = self.create_user()
-
- self.org_saved_search = SavedSearch.objects.create(
- name="Org Saved Search",
- organization=self.org,
- owner_id=self.user.id,
- visibility=Visibility.ORGANIZATION,
- query="is:unresolved",
- )
-
- self.user_saved_search = SavedSearch.objects.create(
- name="User Saved Search",
- organization=self.org,
- owner_id=self.user.id,
- visibility=Visibility.OWNER,
- query="is:resolved",
- )
-
- def test_convert_org_saved_searches_to_views(self):
- assert GroupSearchView.objects.count() == 1
- org_view = GroupSearchView.objects.get(organization=self.org, user_id=self.user.id)
-
- assert org_view.query == self.org_saved_search.query
From 4f92a941125ab8b85ce7de97390d924abd6be9cb Mon Sep 17 00:00:00 2001
From: Billy Vong
Date: Fri, 20 Jun 2025 13:42:07 -0400
Subject: [PATCH 11/32] fix(replay): Fix summaries not loading due to using
wrong `project_id` (#93946)
Use `project_id` on the replay record instead of the URL (where it does
not always exist).
---------
Co-authored-by: Cursor Agent
Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
---
static/app/views/replays/detail/ai/index.tsx | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/static/app/views/replays/detail/ai/index.tsx b/static/app/views/replays/detail/ai/index.tsx
index bb1d72d2b21..9a3861a54a4 100644
--- a/static/app/views/replays/detail/ai/index.tsx
+++ b/static/app/views/replays/detail/ai/index.tsx
@@ -11,8 +11,6 @@ import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {ApiQueryKey} from 'sentry/utils/queryClient';
import {useApiQuery} from 'sentry/utils/queryClient';
-import {decodeScalar} from 'sentry/utils/queryString';
-import useLocationQuery from 'sentry/utils/url/useLocationQuery';
import useOrganization from 'sentry/utils/useOrganization';
import useProjectFromId from 'sentry/utils/useProjectFromId';
import BreadcrumbRow from 'sentry/views/replays/detail/breadcrumbs/breadcrumbRow';
@@ -58,10 +56,7 @@ export default function Ai({replayRecord}: Props) {
function AiContent({replayRecord}: Props) {
const {replay} = useReplayContext();
const organization = useOrganization();
- const {project: project_id} = useLocationQuery({
- fields: {project: decodeScalar},
- });
- const project = useProjectFromId({project_id});
+ const project = useProjectFromId({project_id: replayRecord?.project_id});
const {
data: summaryData,
@@ -92,6 +87,14 @@ function AiContent({replayRecord}: Props) {
);
}
+ if (replayRecord?.project_id && !project) {
+ return (
+
+ {t('Project not found. Unable to load AI summary.')}
+
+ );
+ }
+
if (isPending || isRefetching) {
return (
From a37f99fa6c9f333b58ea06c64bab76e4e67cffd8 Mon Sep 17 00:00:00 2001
From: Billy Vong
Date: Fri, 20 Jun 2025 13:42:21 -0400
Subject: [PATCH 12/32] feat(replay): Add analytics to timeline zoom buttons
(#93910)
Also fixed `replay.view_html` -> `replay.view-html`
---------
Co-authored-by: Michelle Zhang <56095982+michellewzhang@users.noreply.github.com>
---
.../replays/breadcrumbs/breadcrumbItem.tsx | 2 +-
.../replays/timeAndScrubberGrid.tsx | 25 ++++++++++++++++---
.../utils/analytics/replayAnalyticsEvents.tsx | 4 +++
3 files changed, 27 insertions(+), 4 deletions(-)
diff --git a/static/app/components/replays/breadcrumbs/breadcrumbItem.tsx b/static/app/components/replays/breadcrumbs/breadcrumbItem.tsx
index ee0d9b79488..5d98fc59cbb 100644
--- a/static/app/components/replays/breadcrumbs/breadcrumbItem.tsx
+++ b/static/app/components/replays/breadcrumbs/breadcrumbItem.tsx
@@ -111,7 +111,7 @@ function BreadcrumbItem({
onShowSnippet();
e.preventDefault();
e.stopPropagation();
- trackAnalytics('replay.view_html', {
+ trackAnalytics('replay.view-html', {
organization,
breadcrumb_type: 'category' in frame ? frame.category : 'unknown',
});
diff --git a/static/app/components/replays/timeAndScrubberGrid.tsx b/static/app/components/replays/timeAndScrubberGrid.tsx
index e4375dab72e..f325db0faa6 100644
--- a/static/app/components/replays/timeAndScrubberGrid.tsx
+++ b/static/app/components/replays/timeAndScrubberGrid.tsx
@@ -1,4 +1,4 @@
-import {useRef} from 'react';
+import {useCallback, useRef} from 'react';
import {css} from '@emotion/react';
import styled from '@emotion/styled';
@@ -14,10 +14,12 @@ import {useReplayContext} from 'sentry/components/replays/replayContext';
import {IconAdd, IconSubtract} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
+import {trackAnalytics} from 'sentry/utils/analytics';
import useTimelineScale, {
TimelineScaleContextProvider,
} from 'sentry/utils/replays/hooks/useTimelineScale';
import {useReplayPrefs} from 'sentry/utils/replays/playback/providers/replayPreferencesContext';
+import useOrganization from 'sentry/utils/useOrganization';
type TimeAndScrubberGridProps = {
isCompact?: boolean;
@@ -27,10 +29,27 @@ type TimeAndScrubberGridProps = {
function TimelineSizeBar({isLoading}: {isLoading?: boolean}) {
const {replay} = useReplayContext();
+ const organization = useOrganization();
const [timelineScale, setTimelineScale] = useTimelineScale();
const durationMs = replay?.getDurationMs();
const maxScale = durationMs ? Math.ceil(durationMs / 60000) : 10;
+ const handleZoomOut = useCallback(() => {
+ const newScale = Math.max(timelineScale - 1, 1);
+ setTimelineScale(newScale);
+ trackAnalytics('replay.timeline.zoom-out', {
+ organization,
+ });
+ }, [timelineScale, setTimelineScale, organization]);
+
+ const handleZoomIn = useCallback(() => {
+ const newScale = Math.min(timelineScale + 1, maxScale);
+ setTimelineScale(newScale);
+ trackAnalytics('replay.timeline.zoom-in', {
+ organization,
+ });
+ }, [timelineScale, maxScale, setTimelineScale, organization]);
+
return (
}
borderless
- onClick={() => setTimelineScale(Math.max(timelineScale - 1, 1))}
+ onClick={handleZoomOut}
aria-label={t('Zoom out')}
disabled={timelineScale === 1 || isLoading}
/>
@@ -51,7 +70,7 @@ function TimelineSizeBar({isLoading}: {isLoading?: boolean}) {
title={t('Zoom in')}
icon={ }
borderless
- onClick={() => setTimelineScale(Math.min(timelineScale + 1, maxScale))}
+ onClick={handleZoomIn}
aria-label={t('Zoom in')}
disabled={timelineScale === maxScale || isLoading}
/>
diff --git a/static/app/utils/analytics/replayAnalyticsEvents.tsx b/static/app/utils/analytics/replayAnalyticsEvents.tsx
index 2b72bd20577..a48ef1bef36 100644
--- a/static/app/utils/analytics/replayAnalyticsEvents.tsx
+++ b/static/app/utils/analytics/replayAnalyticsEvents.tsx
@@ -113,6 +113,8 @@ export type ReplayEventParameters = {
'replay.search': {
search_keys: string;
};
+ 'replay.timeline.zoom-in': Record;
+ 'replay.timeline.zoom-out': Record;
'replay.toggle-fullscreen': {
context: string;
fullscreen: boolean;
@@ -153,6 +155,8 @@ export const replayEventMap: Record = {
'replay.render-issues-group-list': 'Render Issues Detail Replay List',
'replay.render-missing-replay-alert': 'Render Missing Replay Alert',
'replay.search': 'Searched Replay',
+ 'replay.timeline.zoom-in': 'Zoomed In Replay Timeline',
+ 'replay.timeline.zoom-out': 'Zoomed Out Replay Timeline',
'replay.toggle-fullscreen': 'Toggled Replay Fullscreen',
'replay.view-html': 'Clicked "View HTML" in Replay Breadcrumb',
};
From 38d4d58f982c83e67c092c0dc4269b8defec4e8c Mon Sep 17 00:00:00 2001
From: Scott Cooper
Date: Fri, 20 Jun 2025 10:43:19 -0700
Subject: [PATCH 13/32] ref(profiling): Move flamegraphFrameFilter types around
for tsgo (#93948)
gets `npx @typescript/native-preview` passing again
---
static/app/views/profiling/profileSummary/index.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/static/app/views/profiling/profileSummary/index.tsx b/static/app/views/profiling/profileSummary/index.tsx
index 0fd5a4b3e6b..51eed526615 100644
--- a/static/app/views/profiling/profileSummary/index.tsx
+++ b/static/app/views/profiling/profileSummary/index.tsx
@@ -335,14 +335,14 @@ function ProfileSummaryPage(props: ProfileSummaryPageProps) {
[setFrameFilter]
);
- const flamegraphFrameFilter: ((frame: Frame) => boolean) | undefined = useMemo(() => {
+ const flamegraphFrameFilter = useMemo((): ((frame: Frame) => boolean) => {
if (frameFilter === 'all') {
return () => true;
}
if (frameFilter === 'application') {
- return frame => frame.is_application;
+ return (frame: Frame) => frame.is_application;
}
- return frame => !frame.is_application;
+ return (frame: Frame) => !frame.is_application;
}, [frameFilter]);
const onResetFrameFilter = useCallback(() => {
From 78d346e15efe7ed35e6f605085a2ba0bdec02733 Mon Sep 17 00:00:00 2001
From: Hubert Deng
Date: Fri, 20 Jun 2025 10:49:40 -0700
Subject: [PATCH 14/32] feat(devservices): Add tracing mode (#93759)
This adds mode for all things tracing. This encompasses
transactions/metrics/spans.
Taken from
https://github.com/getsentry/sentry/blob/feeaf393deeca8b97675bff23039c6320270aab5/src/sentry/runner/commands/devserver.py#L370
---
devservices/config.yml | 47 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 47 insertions(+)
diff --git a/devservices/config.yml b/devservices/config.yml
index 51f67a698ec..2a448bc5502 100644
--- a/devservices/config.yml
+++ b/devservices/config.yml
@@ -20,6 +20,13 @@ x-sentry-service-config:
branch: master
repo_link: https://github.com/getsentry/snuba.git
mode: containerized-profiles
+ snuba-metrics:
+ description: Service that provides fast aggregation and query capabilities on top of Clickhouse that includes metrics consumers
+ remote:
+ repo_name: snuba
+ branch: master
+ repo_link: https://github.com/getsentry/snuba.git
+ mode: containerized-metrics-dev
relay:
description: Service event forwarding and ingestion service
remote:
@@ -121,6 +128,15 @@ x-sentry-service-config:
description: Post-process forwarder for transaction events
post-process-forwarder-issue-platform:
description: Post-process forwarder for issue platform events
+ # Subscription results consumers
+ eap-spans-subscription-results:
+ description: Kafka consumer for processing subscription results for spans
+ subscription-results-eap-items:
+ description: Kafka consumer for processing subscription results for eap items
+ metrics-subscription-results:
+ description: Kafka consumer for processing subscription results for metrics
+ generic-metrics-subscription-results:
+ description: Kafka consumer for processing subscription results for generic metrics
# Uptime monitoring
uptime-results:
description: Kafka consumer for uptime monitoring results
@@ -138,6 +154,29 @@ x-sentry-service-config:
rabbitmq: [postgres, snuba, rabbitmq, spotlight]
symbolicator: [postgres, snuba, symbolicator, spotlight]
memcached: [postgres, snuba, memcached, spotlight]
+ tracing:
+ [
+ postgres,
+ snuba-metrics,
+ relay,
+ spotlight,
+ ingest-events,
+ ingest-transactions,
+ ingest-metrics,
+ ingest-generic-metrics,
+ billing-metrics-consumer,
+ post-process-forwarder-errors,
+ post-process-forwarder-transactions,
+ post-process-forwarder-issue-platform,
+ eap-spans-subscription-results,
+ subscription-results-eap-items,
+ metrics-subscription-results,
+ generic-metrics-subscription-results,
+ process-spans,
+ ingest-occurrences,
+ process-segments,
+ worker,
+ ]
crons:
[
postgres,
@@ -283,6 +322,14 @@ x-programs:
command: sentry run consumer post-process-forwarder-issue-platform --consumer-group=sentry-consumer --auto-offset-reset=latest --no-strict-offset-reset
ingest-feedback-events:
command: sentry run consumer ingest-feedback-events --consumer-group=sentry-consumer --auto-offset-reset=latest --no-strict-offset-reset
+ eap-spans-subscription-results:
+ command: sentry run consumer eap-spans-subscription-results --consumer-group=sentry-consumer --auto-offset-reset=latest --no-strict-offset-reset
+ subscription-results-eap-items:
+ command: sentry run consumer subscription-results-eap-items --consumer-group=sentry-consumer --auto-offset-reset=latest --no-strict-offset-reset
+ metrics-subscription-results:
+ command: sentry run consumer metrics-subscription-results --consumer-group=sentry-consumer --auto-offset-reset=latest --no-strict-offset-reset
+ generic-metrics-subscription-results:
+ command: sentry run consumer generic-metrics-subscription-results --consumer-group=sentry-consumer --auto-offset-reset=latest --no-strict-offset-reset
worker:
command: sentry run worker -c 1 --autoreload
From 5cc91e09d1e24800af667552454ec37acb6b2aa2 Mon Sep 17 00:00:00 2001
From: Vishnu Satish
Date: Fri, 20 Jun 2025 10:51:04 -0700
Subject: [PATCH 15/32] feat(feedback): frontend to display summary (#93567)
---
.../components/feedback/feedbackSummary.tsx | 64 ++++++++++++++++++
.../feedback/list/useFeedbackSummary.tsx | 67 +++++++++++++++++++
.../app/views/feedback/feedbackListPage.tsx | 18 +++--
3 files changed, 145 insertions(+), 4 deletions(-)
create mode 100644 static/app/components/feedback/feedbackSummary.tsx
create mode 100644 static/app/components/feedback/list/useFeedbackSummary.tsx
diff --git a/static/app/components/feedback/feedbackSummary.tsx b/static/app/components/feedback/feedbackSummary.tsx
new file mode 100644
index 00000000000..8cf3c23dcb8
--- /dev/null
+++ b/static/app/components/feedback/feedbackSummary.tsx
@@ -0,0 +1,64 @@
+import styled from '@emotion/styled';
+
+import useFeedbackSummary from 'sentry/components/feedback/list/useFeedbackSummary';
+import Placeholder from 'sentry/components/placeholder';
+import {IconSeer} from 'sentry/icons/iconSeer';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import useOrganization from 'sentry/utils/useOrganization';
+
+export default function FeedbackSummary() {
+ const {isError, isPending, summary, tooFewFeedbacks} = useFeedbackSummary();
+
+ const organization = useOrganization();
+
+ if (
+ !organization.features.includes('user-feedback-ai-summaries') ||
+ tooFewFeedbacks ||
+ isError
+ ) {
+ return null;
+ }
+
+ if (isPending) {
+ return ;
+ }
+
+ return (
+
+
+
+ {t('Feedback Summary')}
+ {summary}
+
+
+ );
+}
+
+const SummaryContainer = styled('div')`
+ display: flex;
+ flex-direction: column;
+ gap: ${space(1)};
+ width: 100%;
+`;
+
+const SummaryHeader = styled('p')`
+ font-size: ${p => p.theme.fontSizeMedium};
+ font-weight: ${p => p.theme.fontWeightBold};
+ margin: 0;
+`;
+
+const SummaryContent = styled('p')`
+ font-size: ${p => p.theme.fontSizeSmall};
+ color: ${p => p.theme.subText};
+ margin: 0;
+`;
+
+const SummaryIconContainer = styled('div')`
+ display: flex;
+ gap: ${space(1)};
+ padding: ${space(2)};
+ border: 1px solid ${p => p.theme.border};
+ border-radius: ${p => p.theme.borderRadius};
+ align-items: baseline;
+`;
diff --git a/static/app/components/feedback/list/useFeedbackSummary.tsx b/static/app/components/feedback/list/useFeedbackSummary.tsx
new file mode 100644
index 00000000000..e593e29b0cc
--- /dev/null
+++ b/static/app/components/feedback/list/useFeedbackSummary.tsx
@@ -0,0 +1,67 @@
+import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+
+type FeedbackSummaryResponse = {
+ numFeedbacksUsed: number;
+ success: boolean;
+ summary: string | null;
+};
+
+export default function useFeedbackSummary(): {
+ isError: boolean;
+ isPending: boolean;
+ summary: string | null;
+ tooFewFeedbacks: boolean;
+} {
+ const organization = useOrganization();
+
+ const {selection} = usePageFilters();
+
+ const normalizedDateRange = normalizeDateTimeParams(selection.datetime);
+
+ const {data, isPending, isError} = useApiQuery(
+ [
+ `/organizations/${organization.slug}/feedback-summary/`,
+ {
+ query: {
+ ...normalizedDateRange,
+ project: selection.projects,
+ },
+ },
+ ],
+ {
+ staleTime: 5000,
+ enabled:
+ Boolean(normalizedDateRange) &&
+ organization.features.includes('user-feedback-ai-summaries'),
+ retry: 1,
+ }
+ );
+
+ if (isPending) {
+ return {
+ summary: null,
+ isPending: true,
+ isError: false,
+ tooFewFeedbacks: false,
+ };
+ }
+
+ if (isError) {
+ return {
+ summary: null,
+ isPending: false,
+ isError: true,
+ tooFewFeedbacks: false,
+ };
+ }
+
+ return {
+ summary: data.summary,
+ isPending: false,
+ isError: false,
+ tooFewFeedbacks: data.numFeedbacksUsed === 0 && !data.success,
+ };
+}
diff --git a/static/app/views/feedback/feedbackListPage.tsx b/static/app/views/feedback/feedbackListPage.tsx
index 2ca5c65d4da..ca4f173b99c 100644
--- a/static/app/views/feedback/feedbackListPage.tsx
+++ b/static/app/views/feedback/feedbackListPage.tsx
@@ -7,6 +7,7 @@ import FeedbackItemLoader from 'sentry/components/feedback/feedbackItem/feedback
import FeedbackWidgetBanner from 'sentry/components/feedback/feedbackOnboarding/feedbackWidgetBanner';
import FeedbackSearch from 'sentry/components/feedback/feedbackSearch';
import FeedbackSetupPanel from 'sentry/components/feedback/feedbackSetupPanel';
+import FeedbackSummary from 'sentry/components/feedback/feedbackSummary';
import FeedbackWhatsNewBanner from 'sentry/components/feedback/feedbackWhatsNewBanner';
import FeedbackList from 'sentry/components/feedback/list/feedbackList';
import useCurrentFeedbackId from 'sentry/components/feedback/useCurrentFeedbackId';
@@ -84,9 +85,12 @@ export default function FeedbackListPage() {
{hasSetupOneFeedback || hasSlug ? (
-
-
-
+
+
+
+
+
+
@@ -118,6 +122,12 @@ const Background = styled('div')`
gap: ${space(2)};
`;
+const SummaryListContainer = styled('div')`
+ display: flex;
+ flex-direction: column;
+ gap: ${space(1)};
+`;
+
const LayoutGrid = styled('div')`
overflow: hidden;
flex-grow: 1;
@@ -153,7 +163,7 @@ const LayoutGrid = styled('div')`
}
@media (min-width: ${p => p.theme.breakpoints.medium}) {
- grid-template-columns: minmax(1fr, 195px) 1fr;
+ grid-template-columns: minmax(195px, 1fr) 1.5fr;
}
@media (min-width: ${p => p.theme.breakpoints.large}) {
From 084b63a0421334c0d2f70a8bffc5ef9a992764dd Mon Sep 17 00:00:00 2001
From: Kyle Consalus
Date: Fri, 20 Jun 2025 10:57:37 -0700
Subject: [PATCH 16/32] fix(aci): Ensure slow conditions are consistent
(#93895)
The conditions associated with a DCG can change over time, and it's good
if we can be completely confident that they're consistent within a given
task execution.
---
.../processors/delayed_workflow.py | 20 +++++++++++++++----
.../processors/test_delayed_workflow.py | 16 +++++++++++++--
2 files changed, 30 insertions(+), 6 deletions(-)
diff --git a/src/sentry/workflow_engine/processors/delayed_workflow.py b/src/sentry/workflow_engine/processors/delayed_workflow.py
index 07847099891..8d9cca7abce 100644
--- a/src/sentry/workflow_engine/processors/delayed_workflow.py
+++ b/src/sentry/workflow_engine/processors/delayed_workflow.py
@@ -354,13 +354,12 @@ def get_condition_query_groups(
data_condition_groups: list[DataConditionGroup],
event_data: EventRedisData,
workflows_to_envs: Mapping[WorkflowId, int | None],
+ dcg_to_slow_conditions: dict[DataConditionGroupId, list[DataCondition]],
) -> dict[UniqueConditionQuery, set[GroupId]]:
"""
Map unique condition queries to the group IDs that need to checked for that query.
"""
condition_groups: dict[UniqueConditionQuery, set[GroupId]] = defaultdict(set)
- dcg_to_slow_conditions = get_slow_conditions_for_groups(list(event_data.dcg_to_groups.keys()))
-
for dcg in data_condition_groups:
slow_conditions = dcg_to_slow_conditions[dcg.id]
workflow_id = event_data.dcg_to_workflow.get(dcg.id)
@@ -412,9 +411,9 @@ def get_groups_to_fire(
workflows_to_envs: Mapping[WorkflowId, int | None],
event_data: EventRedisData,
condition_group_results: dict[UniqueConditionQuery, QueryResult],
+ dcg_to_slow_conditions: dict[DataConditionGroupId, list[DataCondition]],
) -> dict[GroupId, set[DataConditionGroup]]:
groups_to_fire: dict[GroupId, set[DataConditionGroup]] = defaultdict(set)
- dcg_to_slow_conditions = get_slow_conditions_for_groups(list(event_data.dcg_ids))
for dcg in data_condition_groups:
slow_conditions = dcg_to_slow_conditions[dcg.id]
@@ -650,6 +649,18 @@ def process_delayed_workflows(
workflows_to_envs = fetch_workflows_envs(list(event_data.workflow_ids))
data_condition_groups = fetch_data_condition_groups(list(event_data.dcg_ids))
+ dcg_to_slow_conditions = get_slow_conditions_for_groups(list(event_data.dcg_ids))
+
+ no_slow_condition_groups = {
+ dcg_id for dcg_id, slow_conds in dcg_to_slow_conditions.items() if not slow_conds
+ }
+ if no_slow_condition_groups:
+ # If the DCG is being processed here, it's because we thought it had a slow condition.
+ # If any don't seem to have a slow condition now, that's interesting enough to log.
+ logger.info(
+ "delayed_workflow.no_slow_condition_groups",
+ extra={"no_slow_condition_groups": sorted(no_slow_condition_groups)},
+ )
logger.info(
"delayed_workflow.workflows",
@@ -661,7 +672,7 @@ def process_delayed_workflows(
# Get unique query groups to query Snuba
condition_groups = get_condition_query_groups(
- data_condition_groups, event_data, workflows_to_envs
+ data_condition_groups, event_data, workflows_to_envs, dcg_to_slow_conditions
)
if not condition_groups:
return
@@ -688,6 +699,7 @@ def process_delayed_workflows(
workflows_to_envs,
event_data,
condition_group_results,
+ dcg_to_slow_conditions,
)
logger.info(
"delayed_workflow.groups_to_fire",
diff --git a/tests/sentry/workflow_engine/processors/test_delayed_workflow.py b/tests/sentry/workflow_engine/processors/test_delayed_workflow.py
index 413f056d68e..297e02b38db 100644
--- a/tests/sentry/workflow_engine/processors/test_delayed_workflow.py
+++ b/tests/sentry/workflow_engine/processors/test_delayed_workflow.py
@@ -38,7 +38,10 @@
SLOW_CONDITIONS,
Condition,
)
-from sentry.workflow_engine.processors.data_condition_group import ProcessedDataConditionGroup
+from sentry.workflow_engine.processors.data_condition_group import (
+ ProcessedDataConditionGroup,
+ get_slow_conditions_for_groups,
+)
from sentry.workflow_engine.processors.delayed_workflow import (
EventInstance,
EventKey,
@@ -403,7 +406,10 @@ def test_get_condition_query_groups(self):
mock_event_data.dcg_to_groups = dcg_to_groups
mock_event_data.dcg_to_workflow = dcg_to_workflow
- result = get_condition_query_groups(dcgs, mock_event_data, workflows_to_envs)
+ dcg_to_slow_conditions = get_slow_conditions_for_groups(list(dcg_to_groups.keys()))
+ result = get_condition_query_groups(
+ dcgs, mock_event_data, workflows_to_envs, dcg_to_slow_conditions
+ )
count_query = generate_unique_queries(self.count_dc, None)[0]
percent_only_query = generate_unique_queries(self.percent_dc, None)[1]
@@ -611,12 +617,15 @@ def setUp(self):
}
)
+ self.dcg_to_slow_conditions = get_slow_conditions_for_groups(list(self.event_data.dcg_ids))
+
def test_simple(self):
result = get_groups_to_fire(
self.data_condition_groups,
self.workflows_to_envs,
self.event_data,
self.condition_group_results,
+ self.dcg_to_slow_conditions,
)
assert result == {
@@ -640,6 +649,7 @@ def test_dcg_all_fails(self):
self.workflows_to_envs,
self.event_data,
self.condition_group_results,
+ self.dcg_to_slow_conditions,
)
assert result == {
@@ -661,6 +671,7 @@ def test_dcg_any_fails(self):
self.workflows_to_envs,
self.event_data,
self.condition_group_results,
+ self.dcg_to_slow_conditions,
)
assert result == {
@@ -691,6 +702,7 @@ def test_multiple_dcgs_per_group(self):
self.workflows_to_envs,
event_data,
self.condition_group_results,
+ self.dcg_to_slow_conditions,
)
assert result == {
self.group1.id: set(self.workflow1_dcgs + [self.workflow2_dcgs[0]]),
From b3644052fff442824e2a2012c9ba68d7e55d6a64 Mon Sep 17 00:00:00 2001
From: Matt Duncan <14761+mrduncan@users.noreply.github.com>
Date: Fri, 20 Jun 2025 11:04:15 -0700
Subject: [PATCH 17/32] chore(issues): Remove regex parameterization experiment
(#93958)
This is unused and most regex experiments have required broader changes
to ensure that regexes are evaluated in a specific order (ex:
traceparent). Removing this for now to simplify the code and very
slightly improve runtime performance.
---
src/sentry/grouping/parameterization.py | 18 ++------
.../sentry/grouping/test_parameterization.py | 45 +------------------
2 files changed, 4 insertions(+), 59 deletions(-)
diff --git a/src/sentry/grouping/parameterization.py b/src/sentry/grouping/parameterization.py
index bfcef71f5d2..c06a719e833 100644
--- a/src/sentry/grouping/parameterization.py
+++ b/src/sentry/grouping/parameterization.py
@@ -11,7 +11,6 @@
"ParameterizationCallableExperiment",
"ParameterizationExperiment",
"ParameterizationRegex",
- "ParameterizationRegexExperiment",
"Parameterizer",
"UniqueIdExperiment",
]
@@ -206,15 +205,6 @@ def run(self, content: str, callback: Callable[[str, int], None]) -> str:
return content
-class ParameterizationRegexExperiment(ParameterizationRegex):
- def run(
- self,
- content: str,
- callback: Callable[[re.Match[str]], str],
- ) -> str:
- return self.compiled_pattern.sub(callback, content)
-
-
class _UniqueId:
# just a namespace for the uniq_id logic, no need to instantiate
@@ -275,7 +265,7 @@ def replace_uniq_ids_in_str(string: str) -> tuple[str, int]:
)
-ParameterizationExperiment = ParameterizationCallableExperiment | ParameterizationRegexExperiment
+ParameterizationExperiment = ParameterizationCallableExperiment
class Parameterizer:
@@ -355,10 +345,8 @@ def _handle_regex_match(match: re.Match[str]) -> str:
for experiment in self._experiments:
if not should_run(experiment.name):
continue
- if isinstance(experiment, ParameterizationCallableExperiment):
- content = experiment.run(content, _incr_counter)
- else:
- content = experiment.run(content, _handle_regex_match)
+
+ content = experiment.run(content, _incr_counter)
return content
diff --git a/tests/sentry/grouping/test_parameterization.py b/tests/sentry/grouping/test_parameterization.py
index 09900621e09..3504abaf28e 100644
--- a/tests/sentry/grouping/test_parameterization.py
+++ b/tests/sentry/grouping/test_parameterization.py
@@ -1,12 +1,6 @@
-from unittest import mock
-
import pytest
-from sentry.grouping.parameterization import (
- ParameterizationRegexExperiment,
- Parameterizer,
- UniqueIdExperiment,
-)
+from sentry.grouping.parameterization import Parameterizer, UniqueIdExperiment
from sentry.grouping.strategies.message import REGEX_PATTERN_KEYS
@@ -227,43 +221,6 @@ def test_parameterize_experiment(name, input, expected, parameterizer):
assert experiments[0] == UniqueIdExperiment
-def test_parameterize_regex_experiment():
- """
- We don't have any of these yet, but we need to test that they work
- """
- FooExperiment = ParameterizationRegexExperiment(name="foo", raw_pattern=r"f[oO]{2}")
-
- parameterizer = Parameterizer(
- regex_pattern_keys=(),
- experiments=(FooExperiment,),
- )
- input_str = "blah foobarbaz fooooo"
- normalized = parameterizer.parameterize_all(input_str)
- assert normalized == "blah barbaz ooo"
- assert len(parameterizer.get_successful_experiments()) == 1
- assert parameterizer.get_successful_experiments()[0] == FooExperiment
-
-
-def test_parameterize_regex_experiment_cached_compiled():
-
- with mock.patch.object(
- ParameterizationRegexExperiment,
- "pattern",
- new_callable=mock.PropertyMock,
- return_value=r"(?Pf[oO]{2})",
- ) as mocked_pattern:
- FooExperiment = ParameterizationRegexExperiment(name="foo", raw_pattern=r"f[oO]{2}")
- parameterizer = Parameterizer(
- regex_pattern_keys=(),
- experiments=(FooExperiment,),
- )
- input_str = "blah foobarbaz fooooo"
- _ = parameterizer.parameterize_all(input_str)
- _ = parameterizer.parameterize_all(input_str)
-
- mocked_pattern.assert_called_once()
-
-
# These are test cases that we should fix
@pytest.mark.xfail()
@pytest.mark.parametrize(
From 8167cc5e52efef0ef66fe8bb14b6a10b234005bc Mon Sep 17 00:00:00 2001
From: Michelle Zhang <56095982+michellewzhang@users.noreply.github.com>
Date: Fri, 20 Jun 2025 14:12:08 -0400
Subject: [PATCH 18/32] ref(replay): include error context in breadcrumb
summary (#93669)
relates to
https://linear.app/getsentry/issue/REPLAY-418/add-error-and-user-feedback-context-to-llm
---
.../project_replay_summarize_breadcrumbs.py | 136 ++++++++++--
...st_project_replay_summarize_breadcrumbs.py | 194 +++++++++++++++++-
2 files changed, 306 insertions(+), 24 deletions(-)
diff --git a/src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py b/src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py
index 8dd77f455b5..733b1211293 100644
--- a/src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py
+++ b/src/sentry/replays/endpoints/project_replay_summarize_breadcrumbs.py
@@ -1,7 +1,7 @@
import functools
import logging
from collections.abc import Generator, Iterator
-from typing import Any
+from typing import Any, TypedDict
import requests
import sentry_sdk
@@ -10,13 +10,17 @@
from rest_framework.request import Request
from rest_framework.response import Response
-from sentry import features
+from sentry import features, nodestore
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.api.bases.project import ProjectEndpoint
from sentry.api.paginator import GenericOffsetPaginator
+from sentry.eventstore.models import Event
+from sentry.models.project import Project
from sentry.replays.lib.storage import RecordingSegmentStorageMeta, storage
+from sentry.replays.post_process import process_raw_response
+from sentry.replays.query import query_replay_instance
from sentry.replays.usecases.ingest.event_parser import as_log_message
from sentry.replays.usecases.reader import fetch_segments_metadata, iter_segment_data
from sentry.seer.signed_seer_api import sign_with_seer_secret
@@ -25,6 +29,14 @@
logger = logging.getLogger(__name__)
+class ErrorEvent(TypedDict):
+ id: str
+ title: str
+ message: str
+ timestamp: float
+ category: str
+
+
@region_silo_endpoint
@extend_schema(tags=["Replays"])
class ProjectReplaySummarizeBreadcrumbsEndpoint(ProjectEndpoint):
@@ -37,7 +49,7 @@ def __init__(self, **options) -> None:
storage.initialize_client()
super().__init__(**options)
- def get(self, request: Request, project, replay_id: str) -> Response:
+ def get(self, request: Request, project: Project, replay_id: str) -> Response:
"""Return a collection of replay recording segments."""
if (
not features.has(
@@ -52,17 +64,117 @@ def get(self, request: Request, project, replay_id: str) -> Response:
):
return self.respond(status=404)
+ filter_params = self.get_filter_params(request, project)
+
+ # Fetch the replay's error IDs from the replay_id.
+ snuba_response = query_replay_instance(
+ project_id=project.id,
+ replay_id=replay_id,
+ start=filter_params["start"],
+ end=filter_params["end"],
+ organization=project.organization,
+ request_user_id=request.user.id,
+ )
+
+ response = process_raw_response(
+ snuba_response,
+ fields=request.query_params.getlist("field"),
+ )
+
+ error_ids = response[0].get("error_ids", []) if response else []
+
+ # Check if error fetching should be disabled
+ disable_error_fetching = (
+ request.query_params.get("enable_error_context", "true").lower() == "false"
+ )
+
+ if disable_error_fetching:
+ error_events = []
+ else:
+ error_events = fetch_error_details(project_id=project.id, error_ids=error_ids)
+
return self.paginate(
request=request,
paginator_cls=GenericOffsetPaginator,
data_fn=functools.partial(fetch_segments_metadata, project.id, replay_id),
- on_results=analyze_recording_segments,
+ on_results=functools.partial(analyze_recording_segments, error_events),
)
+def fetch_error_details(project_id: int, error_ids: list[str]) -> list[ErrorEvent]:
+ """Fetch error details given error IDs and return a list of ErrorEvent objects."""
+ try:
+ node_ids = [Event.generate_node_id(project_id, event_id=id) for id in error_ids]
+ events = nodestore.backend.get_multi(node_ids)
+
+ return [
+ ErrorEvent(
+ category="error",
+ id=event_id,
+ title=data.get("title", ""),
+ timestamp=data.get("timestamp", 0.0),
+ message=data.get("message", ""),
+ )
+ for event_id, data in zip(error_ids, events.values())
+ if data is not None
+ ]
+ except Exception as e:
+ sentry_sdk.capture_exception(e)
+ return []
+
+
+def generate_error_log_message(error: ErrorEvent) -> str:
+ title = error["title"]
+ message = error["message"]
+ timestamp = error["timestamp"]
+
+ return f"User experienced an error: '{title}: {message}' at {timestamp}"
+
+
+def get_request_data(
+ iterator: Iterator[tuple[int, memoryview]], error_events: list[ErrorEvent]
+) -> list[str]:
+ # Sort error events by timestamp
+ error_events.sort(key=lambda x: x["timestamp"])
+ return list(gen_request_data(iterator, error_events))
+
+
+def gen_request_data(
+ iterator: Iterator[tuple[int, memoryview]], error_events: list[ErrorEvent]
+) -> Generator[str]:
+ """Generate log messages from events and errors in chronological order."""
+ error_idx = 0
+
+ # Process segments
+ for _, segment in iterator:
+ events = json.loads(segment.tobytes().decode("utf-8"))
+ for event in events:
+ # Check if we need to yield any error messages that occurred before this event
+ while error_idx < len(error_events) and error_events[error_idx][
+ "timestamp"
+ ] < event.get("timestamp", 0):
+ error = error_events[error_idx]
+ yield generate_error_log_message(error)
+ error_idx += 1
+
+ # Yield the current event's log message
+ if message := as_log_message(event):
+ yield message
+
+ # Yield any remaining error messages
+ while error_idx < len(error_events):
+ error = error_events[error_idx]
+ yield generate_error_log_message(error)
+ error_idx += 1
+
+
@sentry_sdk.trace
-def analyze_recording_segments(segments: list[RecordingSegmentStorageMeta]) -> dict[str, Any]:
- request_data = json.dumps({"logs": get_request_data(iter_segment_data(segments))})
+def analyze_recording_segments(
+ error_events: list[ErrorEvent],
+ segments: list[RecordingSegmentStorageMeta],
+) -> dict[str, Any]:
+ # Combine breadcrumbs and error details
+ request_data = json.dumps({"logs": get_request_data(iter_segment_data(segments), error_events)})
# XXX: I have to deserialize this request so it can be "automatically" reserialized by the
# paginate method. This is less than ideal.
@@ -94,15 +206,3 @@ def make_seer_request(request_data: str) -> bytes:
response.raise_for_status()
return response.content
-
-
-def get_request_data(iterator: Iterator[tuple[int, memoryview]]) -> list[str]:
- return list(gen_request_data(map(lambda r: r[1], iterator)))
-
-
-def gen_request_data(segments: Iterator[memoryview]) -> Generator[str]:
- for segment in segments:
- for event in json.loads(segment.tobytes().decode("utf-8")):
- message = as_log_message(event)
- if message:
- yield message
diff --git a/tests/sentry/replays/test_project_replay_summarize_breadcrumbs.py b/tests/sentry/replays/test_project_replay_summarize_breadcrumbs.py
index f6d83915820..13dd303cf0f 100644
--- a/tests/sentry/replays/test_project_replay_summarize_breadcrumbs.py
+++ b/tests/sentry/replays/test_project_replay_summarize_breadcrumbs.py
@@ -1,18 +1,31 @@
import uuid
import zlib
+from datetime import datetime, timezone
from unittest.mock import patch
+import requests
+from django.conf import settings
from django.urls import reverse
from rest_framework.exceptions import ParseError
-from sentry.replays.endpoints.project_replay_summarize_breadcrumbs import get_request_data
+from sentry import nodestore
+from sentry.eventstore.models import Event
+from sentry.replays.endpoints.project_replay_summarize_breadcrumbs import (
+ ErrorEvent,
+ get_request_data,
+)
from sentry.replays.lib.storage import FilestoreBlob, RecordingSegmentStorageMeta
+from sentry.replays.testutils import mock_replay
from sentry.testutils.cases import TransactionTestCase
+from sentry.testutils.skips import requires_snuba
from sentry.utils import json
# have to use TransactionTestCase because we're using threadpools
-class ProjectReplaySummarizeBreadcrumbsTestCase(TransactionTestCase):
+@requires_snuba
+class ProjectReplaySummarizeBreadcrumbsTestCase(
+ TransactionTestCase,
+):
endpoint = "sentry-api-0-project-replay-summarize-breadcrumbs"
def setUp(self):
@@ -24,6 +37,12 @@ def setUp(self):
args=(self.organization.slug, self.project.slug, self.replay_id),
)
+ def store_replays(self, replay):
+ response = requests.post(
+ settings.SENTRY_SNUBA + "/tests/entities/replays/insert", json=[replay]
+ )
+ assert response.status_code == 200
+
def save_recording_segment(
self, segment_id: int, data: bytes, compressed: bool = True, is_archived: bool = False
) -> None:
@@ -119,6 +138,147 @@ def x(x):
assert response.get("Content-Type") == "application/json"
assert response.json() == {"detail": "e"}
+ @patch("sentry.replays.endpoints.project_replay_summarize_breadcrumbs.make_seer_request")
+ def test_get_with_error(self, make_seer_request):
+ """Test handling of breadcrumbs with error"""
+ return_value = json.dumps({"error": "An error happened"}).encode()
+ make_seer_request.return_value = return_value
+
+ now = datetime.now(timezone.utc)
+ event_id = uuid.uuid4().hex
+ error_timestamp = now.timestamp() - 1
+ self.store_event(
+ data={
+ "event_id": event_id,
+ "timestamp": error_timestamp,
+ "exception": {
+ "values": [
+ {
+ "type": "ZeroDivisionError",
+ "value": "division by zero",
+ }
+ ]
+ },
+ "contexts": {"replay": {"replay_id": self.replay_id}},
+ },
+ project_id=self.project.id,
+ )
+
+ # Ensure the event is stored in nodestore
+ node_id = Event.generate_node_id(self.project.id, event_id)
+ event_data = nodestore.backend.get(node_id)
+ assert event_data is not None, "Event not found in nodestore"
+ assert (
+ event_data.get("exception", {}).get("values", [{}])[0].get("type")
+ == "ZeroDivisionError"
+ )
+
+ self.store_replays(
+ mock_replay(
+ now,
+ self.project.id,
+ self.replay_id,
+ error_ids=[event_id],
+ )
+ )
+
+ data = [
+ {
+ "type": 5,
+ "timestamp": float(now.timestamp()),
+ "data": {
+ "tag": "breadcrumb",
+ "payload": {"category": "console", "message": "hello"},
+ },
+ }
+ ]
+ self.save_recording_segment(0, json.dumps(data).encode())
+
+ with self.feature(
+ {
+ "organizations:session-replay": True,
+ "organizations:replay-ai-summaries": True,
+ "organizations:gen-ai-features": True,
+ }
+ ):
+ response = self.client.get(self.url)
+
+ make_seer_request.assert_called_once()
+ call_args = json.loads(make_seer_request.call_args[0][0])
+ assert "logs" in call_args
+ assert any("ZeroDivisionError" in log for log in call_args["logs"])
+ assert any("division by zero" in log for log in call_args["logs"])
+
+ assert response.status_code == 200
+ assert response.get("Content-Type") == "application/json"
+ assert response.content == return_value
+
+ @patch("sentry.replays.endpoints.project_replay_summarize_breadcrumbs.make_seer_request")
+ def test_get_with_error_context_disabled(self, make_seer_request):
+ """Test handling of breadcrumbs with error context disabled"""
+ return_value = json.dumps({"error": "An error happened"}).encode()
+ make_seer_request.return_value = return_value
+
+ now = datetime.now(timezone.utc)
+ event_id = uuid.uuid4().hex
+ error_timestamp = now.timestamp() - 1
+ self.store_event(
+ data={
+ "event_id": event_id,
+ "timestamp": error_timestamp,
+ "exception": {
+ "values": [
+ {
+ "type": "ZeroDivisionError",
+ "value": "division by zero",
+ }
+ ]
+ },
+ "contexts": {"replay": {"replay_id": self.replay_id}},
+ },
+ project_id=self.project.id,
+ )
+
+ self.store_replays(
+ mock_replay(
+ now,
+ self.project.id,
+ self.replay_id,
+ error_ids=[event_id],
+ )
+ )
+
+ data = [
+ {
+ "type": 5,
+ "timestamp": float(now.timestamp()),
+ "data": {
+ "tag": "breadcrumb",
+ "payload": {"category": "console", "message": "hello"},
+ },
+ }
+ ]
+ self.save_recording_segment(0, json.dumps(data).encode())
+
+ with self.feature(
+ {
+ "organizations:session-replay": True,
+ "organizations:replay-ai-summaries": True,
+ "organizations:gen-ai-features": True,
+ }
+ ):
+ response = self.client.get(self.url, {"enable_error_context": "false"})
+
+ make_seer_request.assert_called_once()
+ call_args = json.loads(make_seer_request.call_args[0][0])
+ assert "logs" in call_args
+ assert not any("ZeroDivisionError" in log for log in call_args["logs"])
+ assert not any("division by zero" in log for log in call_args["logs"])
+
+ assert response.status_code == 200
+ assert response.get("Content-Type") == "application/json"
+ assert response.content == return_value
+
def test_get_request_data():
def _faker():
@@ -127,7 +287,7 @@ def _faker():
[
{
"type": 5,
- "timestamp": 0.0,
+ "timestamp": 1.5,
"data": {
"tag": "breadcrumb",
"payload": {"category": "console", "message": "hello"},
@@ -135,7 +295,7 @@ def _faker():
},
{
"type": 5,
- "timestamp": 0.0,
+ "timestamp": 2.0,
"data": {
"tag": "breadcrumb",
"payload": {"category": "console", "message": "world"},
@@ -145,5 +305,27 @@ def _faker():
).encode()
)
- result = get_request_data(_faker())
- assert result == ["Logged: hello at 0.0", "Logged: world at 0.0"]
+ error_events = [
+ ErrorEvent(
+ category="error",
+ id="123",
+ title="ZeroDivisionError",
+ timestamp=3.0,
+ message="division by zero",
+ ),
+ ErrorEvent(
+ category="error",
+ id="234",
+ title="BadError",
+ timestamp=1.0,
+ message="something else bad",
+ ),
+ ]
+
+ result = get_request_data(_faker(), error_events=error_events)
+ assert result == [
+ "User experienced an error: 'BadError: something else bad' at 1.0",
+ "Logged: hello at 1.5",
+ "Logged: world at 2.0",
+ "User experienced an error: 'ZeroDivisionError: division by zero' at 3.0",
+ ]
From 5d66a4f0449019e5f089459372b7a7f190756119 Mon Sep 17 00:00:00 2001
From: Vishnu Satish
Date: Fri, 20 Jun 2025 11:19:10 -0700
Subject: [PATCH 19/32] ref(feedback): better summarization prompt (#93962)
From some testing (on feedback lists of all different lengths), this
prompt seems to work better. It doesn't write overly long sentences and
also does a better job at "summarizing" versus just mentioning a few
specific topics and leaving out others.
---
src/sentry/feedback/usecases/feedback_summaries.py | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/src/sentry/feedback/usecases/feedback_summaries.py b/src/sentry/feedback/usecases/feedback_summaries.py
index 27000dd16ee..7da2167d11e 100644
--- a/src/sentry/feedback/usecases/feedback_summaries.py
+++ b/src/sentry/feedback/usecases/feedback_summaries.py
@@ -10,12 +10,14 @@
def make_input_prompt(
feedbacks,
):
- feedbacks_string = "\n".join(f"- {msg}" for msg in feedbacks)
+ feedbacks_string = "\n------\n".join(feedbacks)
return f"""Instructions:
-You are an assistant that summarizes customer feedback. Given a list of customer feedback entries, generate a concise summary of 1-2 sentences that reflects the key themes. Begin the summary with "Users...", for example, "Users say...".
+You are an assistant that summarizes customer feedback. Given a list of customer feedback entries, generate a concise summary of 1-2 sentences that reflects the key themes. Begin the summary with "Users...", for example, "Users say...". Don't make overly generic statements like "Users report a variety of issues."
-Balance specificity and generalization based on the size of the input based *only* on the themes and topics present in the list of customer feedback entries. Prioritize brevity and clarity and trying to capture what users are saying, over trying to mention random specific topics. Please don't write overly long sentences, you can leave certain things out and the decision to mention specific topics or themes should be proportional to the number of times they appear in the user feedback entries.
+Balance specificity and generalization based on the size of the input and based only on the themes and topics present in the list of customer feedback entries. Your goal is to focus on identifying and summarizing broader themes that are mentioned more frequently across different feedback entries. For example, if there are many feedback entries, it makes more sense to prioritize mentioning broader themes that apply to many feedbacks, versus mentioning one or two specific isolated concerns and leaving out others that are just as prevalent.
+
+The summary must be AT MOST 55 words, that is an absolute upper limit, and you must write AT MOST two sentences. You can leave certain things out, and when deciding what topics/themes to mention, make sure it is proportional to the number of times they appear in different customer feedback entries.
User Feedbacks:
From fd4ab7b53589527118ad5f1a68d0e7058abdf0db Mon Sep 17 00:00:00 2001
From: Raj Joshi
Date: Fri, 20 Jun 2025 11:20:52 -0700
Subject: [PATCH 20/32] =?UTF-8?q?=E2=9C=A8=20feat(scm):=20add=20environmen?=
=?UTF-8?q?t=20infomation=20to=20suspect=20commit=20prs=20(#93025)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/sentry/integrations/github/integration.py | 6 +--
src/sentry/integrations/gitlab/integration.py | 6 +--
.../source_code_management/commit_context.py | 35 ++++++++++++++++
.../github/tasks/test_pr_comment.py | 32 ++++++++++-----
.../gitlab/tasks/test_pr_comment.py | 40 ++++++++++++++-----
5 files changed, 91 insertions(+), 28 deletions(-)
diff --git a/src/sentry/integrations/github/integration.py b/src/sentry/integrations/github/integration.py
index dc0a4382c0b..3506ca32848 100644
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -384,8 +384,6 @@ def get_open_pr_comment_workflow(self) -> OpenPRCommentWorkflow:
Did you find this useful? React with a 👍 or 👎 """
-MERGED_PR_SINGLE_ISSUE_TEMPLATE = "- ‼️ **{title}** `{subtitle}` [View Issue]({url})"
-
class GitHubPRCommentWorkflow(PRCommentWorkflow):
organization_option_key = "sentry:github_pr_bot"
@@ -405,10 +403,10 @@ def get_comment_body(self, issue_ids: list[int]) -> str:
issue_list = "\n".join(
[
- MERGED_PR_SINGLE_ISSUE_TEMPLATE.format(
+ self.get_merged_pr_single_issue_template(
title=issue.title,
- subtitle=self.format_comment_subtitle(issue.culprit or "unknown culprit"),
url=self.format_comment_url(issue.get_absolute_url(), self.referrer_id),
+ environment=self.get_environment_info(issue),
)
for issue in issues
]
diff --git a/src/sentry/integrations/gitlab/integration.py b/src/sentry/integrations/gitlab/integration.py
index c7afbd5f57f..0244c9a811d 100644
--- a/src/sentry/integrations/gitlab/integration.py
+++ b/src/sentry/integrations/gitlab/integration.py
@@ -230,8 +230,6 @@ def get_open_pr_comment_workflow(self) -> OpenPRCommentWorkflow:
{issue_list}"""
-MERGED_PR_SINGLE_ISSUE_TEMPLATE = "- ‼️ **{title}** `{subtitle}` [View Issue]({url})"
-
class GitlabPRCommentWorkflow(PRCommentWorkflow):
organization_option_key = "sentry:gitlab_pr_bot"
@@ -253,10 +251,10 @@ def get_comment_body(self, issue_ids: list[int]) -> str:
issue_list = "\n".join(
[
- MERGED_PR_SINGLE_ISSUE_TEMPLATE.format(
+ self.get_merged_pr_single_issue_template(
title=issue.title,
- subtitle=self.format_comment_subtitle(issue.culprit),
url=self.format_comment_url(issue.get_absolute_url(), self.referrer_id),
+ environment=self.get_environment_info(issue),
)
for issue in issues
]
diff --git a/src/sentry/integrations/source_code_management/commit_context.py b/src/sentry/integrations/source_code_management/commit_context.py
index ce5483df02e..8492b931ec0 100644
--- a/src/sentry/integrations/source_code_management/commit_context.py
+++ b/src/sentry/integrations/source_code_management/commit_context.py
@@ -139,6 +139,10 @@ class PullRequestFile:
patch: str
+ISSUE_TITLE_MAX_LENGTH = 50
+MERGED_PR_SINGLE_ISSUE_TEMPLATE = "* ‼️ [**{title}**]({url}){environment}\n"
+
+
class CommitContextIntegration(ABC):
"""
Base class for integrations that include commit context features: suspect commits, suspect PR comments
@@ -570,6 +574,37 @@ def get_top_5_issues_by_count(
)
return raw_snql_query(request, referrer=self.referrer.value)["data"]
+ @staticmethod
+ def _truncate_title(title: str, max_length: int = ISSUE_TITLE_MAX_LENGTH) -> str:
+ """Truncate title if it's too long and add ellipsis."""
+ if len(title) <= max_length:
+ return title
+ return title[:max_length].rstrip() + "..."
+
+ def get_environment_info(self, issue: Group) -> str:
+ try:
+ recommended_event = issue.get_recommended_event()
+ if recommended_event:
+ environment = recommended_event.get_environment()
+ if environment and environment.name:
+ return f" in `{environment.name}`"
+ except Exception as e:
+ # If anything goes wrong, just continue without environment info
+ logger.info(
+ "get_environment_info.no-environment",
+ extra={"issue_id": issue.id, "error": e},
+ )
+ return ""
+
+ @staticmethod
+ def get_merged_pr_single_issue_template(title: str, url: str, environment: str) -> str:
+ truncated_title = PRCommentWorkflow._truncate_title(title)
+ return MERGED_PR_SINGLE_ISSUE_TEMPLATE.format(
+ title=truncated_title,
+ url=url,
+ environment=environment,
+ )
+
class OpenPRCommentWorkflow(ABC):
def __init__(self, integration: CommitContextIntegration):
diff --git a/tests/sentry/integrations/github/tasks/test_pr_comment.py b/tests/sentry/integrations/github/tasks/test_pr_comment.py
index 1067ef86de3..9ca79716608 100644
--- a/tests/sentry/integrations/github/tasks/test_pr_comment.py
+++ b/tests/sentry/integrations/github/tasks/test_pr_comment.py
@@ -338,7 +338,12 @@ def test_do_not_ignore_other_issues(self):
class TestGetCommentBody(GithubCommentTestCase):
def test_simple(self):
ev1 = self.store_event(
- data={"message": "issue 1", "culprit": "issue1", "fingerprint": ["group-1"]},
+ data={
+ "message": "issue 1",
+ "culprit": "issue1",
+ "fingerprint": ["group-1"],
+ "environment": "dev",
+ },
project_id=self.project.id,
)
assert ev1.group is not None
@@ -348,7 +353,12 @@ def test_simple(self):
)
assert ev2.group is not None
ev3 = self.store_event(
- data={"message": "issue 3", "culprit": "issue3", "fingerprint": ["group-3"]},
+ data={
+ "message": "issue 3",
+ "culprit": "issue3",
+ "fingerprint": ["group-3"],
+ "environment": "prod",
+ },
project_id=self.project.id,
)
assert ev3.group is not None
@@ -359,9 +369,12 @@ def test_simple(self):
expected_comment = f"""## Suspect Issues
This pull request was deployed and Sentry observed the following issues:
-- ‼️ **issue 1** `issue1` [View Issue](http://testserver/organizations/foo/issues/{ev1.group.id}/?referrer=github-pr-bot)
-- ‼️ **issue 2** `issue2` [View Issue](http://testserver/organizations/foo/issues/{ev2.group.id}/?referrer=github-pr-bot)
-- ‼️ **issue 3** `issue3` [View Issue](http://testserver/organizations/foo/issues/{ev3.group.id}/?referrer=github-pr-bot)
+* ‼️ [**issue 1**](http://testserver/organizations/{self.organization.slug}/issues/{ev1.group.id}/?referrer=github-pr-bot) in `dev`
+
+* ‼️ [**issue 2**](http://testserver/organizations/{self.organization.slug}/issues/{ev2.group.id}/?referrer=github-pr-bot)
+
+* ‼️ [**issue 3**](http://testserver/organizations/{self.organization.slug}/issues/{ev3.group.id}/?referrer=github-pr-bot) in `prod`
+
Did you find this useful? React with a 👍 or 👎 """
assert formatted_comment == expected_comment
@@ -384,7 +397,6 @@ def test_comment_workflow(self, mock_metrics, mock_issues):
group_objs = Group.objects.order_by("id").all()
groups = [g.id for g in group_objs]
titles = [g.title for g in group_objs]
- culprits = [g.culprit for g in group_objs]
mock_issues.return_value = [{"group_id": id, "event_count": 10} for id in groups]
responses.add(
@@ -397,7 +409,7 @@ def test_comment_workflow(self, mock_metrics, mock_issues):
github_comment_workflow(self.pr.id, self.project.id)
assert (
- f'"body": "## Suspect Issues\\nThis pull request was deployed and Sentry observed the following issues:\\n\\n- \\u203c\\ufe0f **{titles[0]}** `{culprits[0]}` [View Issue](http://testserver/organizations/foo/issues/{groups[0]}/?referrer=github-pr-bot)\\n- \\u203c\\ufe0f **{titles[1]}** `{culprits[1]}` [View Issue](http://testserver/organizations/foobar/issues/{groups[1]}/?referrer=github-pr-bot)\\n\\nDid you find this useful? React with a \\ud83d\\udc4d or \\ud83d\\udc4e "'.encode()
+ f'"body": "## Suspect Issues\\nThis pull request was deployed and Sentry observed the following issues:\\n\\n* \\u203c\\ufe0f [**{titles[0]}**](http://testserver/organizations/foo/issues/{groups[0]}/?referrer=github-pr-bot)\\n\\n* \\u203c\\ufe0f [**{titles[1]}**](http://testserver/organizations/foobar/issues/{groups[1]}/?referrer=github-pr-bot)\\n\\n\\nDid you find this useful? React with a \\ud83d\\udc4d or \\ud83d\\udc4e "'.encode()
in responses.calls[0].request.body
)
pull_request_comment_query = PullRequestComment.objects.all()
@@ -413,7 +425,9 @@ def test_comment_workflow(self, mock_metrics, mock_issues):
@responses.activate
@freeze_time(datetime(2023, 6, 8, 0, 0, 0, tzinfo=UTC))
def test_comment_workflow_updates_comment(self, mock_metrics, mock_issues):
- groups = [g.id for g in Group.objects.all()]
+ group_objs = Group.objects.order_by("id").all()
+ groups = [g.id for g in group_objs]
+ titles = [g.title for g in group_objs]
mock_issues.return_value = [{"group_id": id, "event_count": 10} for id in groups]
pull_request_comment = PullRequestComment.objects.create(
external_id=1,
@@ -443,7 +457,7 @@ def test_comment_workflow_updates_comment(self, mock_metrics, mock_issues):
github_comment_workflow(self.pr.id, self.project.id)
assert (
- f'"body": "## Suspect Issues\\nThis pull request was deployed and Sentry observed the following issues:\\n\\n- \\u203c\\ufe0f **issue 1** `issue1` [View Issue](http://testserver/organizations/foo/issues/{groups[0]}/?referrer=github-pr-bot)\\n- \\u203c\\ufe0f **issue 2** `issue2` [View Issue](http://testserver/organizations/foobar/issues/{groups[1]}/?referrer=github-pr-bot)\\n\\nDid you find this useful? React with a \\ud83d\\udc4d or \\ud83d\\udc4e "'.encode()
+ f'"body": "## Suspect Issues\\nThis pull request was deployed and Sentry observed the following issues:\\n\\n* \\u203c\\ufe0f [**{titles[0]}**](http://testserver/organizations/foo/issues/{groups[0]}/?referrer=github-pr-bot)\\n\\n* \\u203c\\ufe0f [**{titles[1]}**](http://testserver/organizations/foobar/issues/{groups[1]}/?referrer=github-pr-bot)\\n\\n\\nDid you find this useful? React with a \\ud83d\\udc4d or \\ud83d\\udc4e "'.encode()
in responses.calls[0].request.body
)
pull_request_comment.refresh_from_db()
diff --git a/tests/sentry/integrations/gitlab/tasks/test_pr_comment.py b/tests/sentry/integrations/gitlab/tasks/test_pr_comment.py
index ec0731fe03e..bf61853d8cb 100644
--- a/tests/sentry/integrations/gitlab/tasks/test_pr_comment.py
+++ b/tests/sentry/integrations/gitlab/tasks/test_pr_comment.py
@@ -299,7 +299,12 @@ def test_do_not_ignore_other_issues(self):
class TestGetCommentBody(GitlabCommentTestCase):
def test_simple(self):
ev1 = self.store_event(
- data={"message": "issue 1", "culprit": "issue1", "fingerprint": ["group-1"]},
+ data={
+ "message": "issue 1",
+ "culprit": "issue1",
+ "fingerprint": ["group-1"],
+ "environment": "dev",
+ },
project_id=self.project.id,
)
assert ev1.group is not None
@@ -309,7 +314,12 @@ def test_simple(self):
)
assert ev2.group is not None
ev3 = self.store_event(
- data={"message": "issue 3", "culprit": "issue3", "fingerprint": ["group-3"]},
+ data={
+ "message": "issue 3",
+ "culprit": "issue3",
+ "fingerprint": ["group-3"],
+ "environment": "prod",
+ },
project_id=self.project.id,
)
assert ev3.group is not None
@@ -320,9 +330,12 @@ def test_simple(self):
expected_comment = f"""## Suspect Issues
This merge request was deployed and Sentry observed the following issues:
-- ‼️ **issue 1** `issue1` [View Issue](http://testserver/organizations/baz/issues/{ev1.group.id}/?referrer=gitlab-pr-bot)
-- ‼️ **issue 2** `issue2` [View Issue](http://testserver/organizations/baz/issues/{ev2.group.id}/?referrer=gitlab-pr-bot)
-- ‼️ **issue 3** `issue3` [View Issue](http://testserver/organizations/baz/issues/{ev3.group.id}/?referrer=gitlab-pr-bot)"""
+* ‼️ [**{ev1.group.title}**](http://testserver/organizations/{self.organization.slug}/issues/{ev1.group.id}/?referrer=gitlab-pr-bot) in `dev`
+
+* ‼️ [**{ev2.group.title}**](http://testserver/organizations/{self.organization.slug}/issues/{ev2.group.id}/?referrer=gitlab-pr-bot)
+
+* ‼️ [**{ev3.group.title}**](http://testserver/organizations/{self.organization.slug}/issues/{ev3.group.id}/?referrer=gitlab-pr-bot) in `prod`
+"""
assert formatted_comment == expected_comment
@@ -343,7 +356,6 @@ def test_comment_workflow(self, mock_metrics, mock_issues):
group_objs = Group.objects.order_by("id").all()
groups = [g.id for g in group_objs]
titles = [g.title for g in group_objs]
- culprits = [g.culprit for g in group_objs]
mock_issues.return_value = [{"group_id": id, "event_count": 10} for id in groups]
responses.add(
@@ -360,8 +372,10 @@ def test_comment_workflow(self, mock_metrics, mock_issues):
## Suspect Issues
This merge request was deployed and Sentry observed the following issues:
-- ‼️ **{titles[0]}** `{culprits[0]}` [View Issue](http://testserver/organizations/baz/issues/{groups[0]}/?referrer=gitlab-pr-bot)
-- ‼️ **{titles[1]}** `{culprits[1]}` [View Issue](http://testserver/organizations/foobar/issues/{groups[1]}/?referrer=gitlab-pr-bot)"""
+* ‼️ [**{titles[0]}**](http://testserver/organizations/{self.organization.slug}/issues/{groups[0]}/?referrer=gitlab-pr-bot)
+
+* ‼️ [**{titles[1]}**](http://testserver/organizations/{self.another_organization.slug}/issues/{groups[1]}/?referrer=gitlab-pr-bot)
+"""
}
pull_request_comment_query = PullRequestComment.objects.all()
@@ -377,7 +391,9 @@ def test_comment_workflow(self, mock_metrics, mock_issues):
@responses.activate
@freeze_time(datetime(2023, 6, 8, 0, 0, 0, tzinfo=UTC))
def test_comment_workflow_updates_comment(self, mock_metrics, mock_issues):
- groups = [g.id for g in Group.objects.all()]
+ group_objs = Group.objects.order_by("id").all()
+ groups = [g.id for g in group_objs]
+ titles = [g.title for g in group_objs]
mock_issues.return_value = [{"group_id": id, "event_count": 10} for id in groups]
pull_request_comment = PullRequestComment.objects.create(
external_id=1,
@@ -411,8 +427,10 @@ def test_comment_workflow_updates_comment(self, mock_metrics, mock_issues):
## Suspect Issues
This merge request was deployed and Sentry observed the following issues:
-- ‼️ **issue 1** `issue1` [View Issue](http://testserver/organizations/baz/issues/{groups[0]}/?referrer=gitlab-pr-bot)
-- ‼️ **issue 2** `issue2` [View Issue](http://testserver/organizations/foobar/issues/{groups[1]}/?referrer=gitlab-pr-bot)"""
+* ‼️ [**{titles[0]}**](http://testserver/organizations/{self.organization.slug}/issues/{groups[0]}/?referrer=gitlab-pr-bot)
+
+* ‼️ [**{titles[1]}**](http://testserver/organizations/{self.another_organization.slug}/issues/{groups[1]}/?referrer=gitlab-pr-bot)
+"""
}
pull_request_comment.refresh_from_db()
From 31c51d5411a02fbd5424dba6c77377bb20e4a2b6 Mon Sep 17 00:00:00 2001
From: Jonas
Date: Fri, 20 Jun 2025 14:23:19 -0400
Subject: [PATCH 21/32] flex: cleanup Flex* custom styling in favor of Flex
primitive (#93809)
Just remove a couple custom Flex* classes in favor of the Flex primitive
---
static/app/components/codeSnippet.tsx | 9 ++--
.../codecov/branchSelector/branchSelector.tsx | 11 ++---
.../codecov/datePicker/dateSelector.tsx | 11 ++---
.../integratedOrgSelector.tsx | 24 +++--------
.../codecov/repoPicker/repoSelector.tsx | 11 ++---
.../components/events/eventAttachments.tsx | 12 ++----
.../spans/newTraceDetailsHeader.tsx | 43 ++++++++-----------
static/app/components/group/times.tsx | 29 +++++++------
.../alerts/list/rules/alertRuleStatus.tsx | 11 ++---
static/app/views/alerts/list/rules/row.tsx | 23 ++++------
.../tests/onboardingSteps/addUploadToken.tsx | 25 ++++-------
.../teamInsights/teamMisery.tsx | 18 +++-----
.../newTraceDetails/traceTabsAndVitals.tsx | 27 ++++--------
.../newTraceDetails/traceWaterfall.tsx | 12 ++----
.../organizationSampleRateInput.tsx | 15 +++----
.../organizationAuditLog/auditLogList.tsx | 13 ++----
.../detailedView/integrationLayout.tsx | 8 +---
.../projectOwnership/codeOwnerFileTable.tsx | 31 ++++++-------
.../instanceLevelOAuthDetails.tsx | 11 ++---
19 files changed, 123 insertions(+), 221 deletions(-)
diff --git a/static/app/components/codeSnippet.tsx b/static/app/components/codeSnippet.tsx
index e6f1fba79a2..4f7ffbe50f4 100644
--- a/static/app/components/codeSnippet.tsx
+++ b/static/app/components/codeSnippet.tsx
@@ -4,6 +4,7 @@ import styled from '@emotion/styled';
import Prism from 'prismjs';
import {Button} from 'sentry/components/core/button';
+import {Flex} from 'sentry/components/core/layout';
import {IconCopy} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
@@ -163,12 +164,12 @@ export function CodeSnippet({
))}
-
+
)}
{icon}
{filename && {filename} }
- {!hasTabs && }
+ {!hasTabs && }
{!hideCopyButton && (
`
: ''}
`;
-const FlexSpacer = styled('div')`
- flex-grow: 1;
-`;
-
const CopyButton = styled(Button)<{isAlwaysVisible: boolean}>`
color: var(--prism-comment);
transition: opacity 0.1s ease-out;
diff --git a/static/app/components/codecov/branchSelector/branchSelector.tsx b/static/app/components/codecov/branchSelector/branchSelector.tsx
index 9ead2764b47..e469f5b11b2 100644
--- a/static/app/components/codecov/branchSelector/branchSelector.tsx
+++ b/static/app/components/codecov/branchSelector/branchSelector.tsx
@@ -5,6 +5,7 @@ import styled from '@emotion/styled';
import {useCodecovContext} from 'sentry/components/codecov/context/codecovContext';
import type {SelectOption} from 'sentry/components/core/compactSelect';
import {CompactSelect} from 'sentry/components/core/compactSelect';
+import {Flex} from 'sentry/components/core/layout';
import DropdownButton from 'sentry/components/dropdownButton';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
@@ -60,12 +61,12 @@ export function BranchSelector() {
{...triggerProps}
>
-
+
{branch || t('Select branch')}
-
+
);
@@ -95,12 +96,6 @@ const OptionLabel = styled('span')`
}
`;
-const FlexContainer = styled('div')`
- display: flex;
- align-items: center;
- gap: ${space(0.75)};
-`;
-
const IconContainer = styled('div')`
flex: 1 0 14px;
height: 14px;
diff --git a/static/app/components/codecov/datePicker/dateSelector.tsx b/static/app/components/codecov/datePicker/dateSelector.tsx
index 4089cab8f2b..de590aeca6d 100644
--- a/static/app/components/codecov/datePicker/dateSelector.tsx
+++ b/static/app/components/codecov/datePicker/dateSelector.tsx
@@ -3,6 +3,7 @@ import styled from '@emotion/styled';
import type {SelectOption, SingleSelectProps} from 'sentry/components/core/compactSelect';
import {CompactSelect} from 'sentry/components/core/compactSelect';
+import {Flex} from 'sentry/components/core/layout';
import DropdownButton from 'sentry/components/dropdownButton';
import {getArbitraryRelativePeriod} from 'sentry/components/timeRangeSelector/utils';
import {IconCalendar} from 'sentry/icons/iconCalendar';
@@ -80,10 +81,10 @@ export function DateSelector({relativeDate, onChange, trigger}: DateSelectorProp
{...triggerProps}
>
-
+
{defaultLabel}
-
+
);
@@ -108,9 +109,3 @@ const OptionLabel = styled('span')`
margin: 0;
}
`;
-
-const FlexContainer = styled('div')`
- display: flex;
- align-items: center;
- gap: ${space(0.75)};
-`;
diff --git a/static/app/components/codecov/integratedOrgSelector/integratedOrgSelector.tsx b/static/app/components/codecov/integratedOrgSelector/integratedOrgSelector.tsx
index 1def6fd99c4..6fe564343cd 100644
--- a/static/app/components/codecov/integratedOrgSelector/integratedOrgSelector.tsx
+++ b/static/app/components/codecov/integratedOrgSelector/integratedOrgSelector.tsx
@@ -6,6 +6,7 @@ import {useCodecovContext} from 'sentry/components/codecov/context/codecovContex
import {LinkButton} from 'sentry/components/core/button/linkButton';
import type {SelectOption} from 'sentry/components/core/compactSelect';
import {CompactSelect} from 'sentry/components/core/compactSelect';
+import {Flex} from 'sentry/components/core/layout';
import DropdownButton from 'sentry/components/dropdownButton';
import {IconAdd, IconInfo} from 'sentry/icons';
import {t} from 'sentry/locale';
@@ -33,7 +34,7 @@ function OrgFooterMessage() {
-
+
@@ -43,7 +44,7 @@ function OrgFooterMessage() {
Ensure you log in to the same GitHub identity
-
+
);
}
@@ -95,14 +96,14 @@ export function IntegratedOrgSelector() {
{...triggerProps}
>
-
+
{integratedOrg || t('Select integrated organization')}
-
+
);
@@ -160,21 +161,6 @@ const MenuFooterDivider = styled('div')`
}
`;
-const FlexContainer = styled('div')`
- display: flex;
- flex-direction: row;
- justify-content: flex-start;
- gap: ${space(1)};
-`;
-
-const TriggerFlexContainer = styled('div')`
- display: flex;
- flex-direction: row;
- justify-content: flex-start;
- gap: ${space(0.75)};
- align-items: center;
-`;
-
const IconContainer = styled('div')`
flex: 1 0 14px;
height: 14px;
diff --git a/static/app/components/codecov/repoPicker/repoSelector.tsx b/static/app/components/codecov/repoPicker/repoSelector.tsx
index c26916626ca..55f967834ca 100644
--- a/static/app/components/codecov/repoPicker/repoSelector.tsx
+++ b/static/app/components/codecov/repoPicker/repoSelector.tsx
@@ -4,6 +4,7 @@ import styled from '@emotion/styled';
import {Button} from 'sentry/components/core/button';
import type {SelectOption, SingleSelectProps} from 'sentry/components/core/compactSelect';
import {CompactSelect} from 'sentry/components/core/compactSelect';
+import {Flex} from 'sentry/components/core/layout';
import DropdownButton from 'sentry/components/dropdownButton';
import Link from 'sentry/components/links/link';
import {IconInfo, IconSync} from 'sentry/icons';
@@ -118,12 +119,12 @@ export function RepoSelector({onChange, trigger, repository}: RepoSelectorProps)
{...triggerProps}
>
-
+
{defaultLabel}
-
+
);
@@ -178,12 +179,6 @@ const OptionLabel = styled('span')`
}
`;
-const FlexContainer = styled('div')`
- display: flex;
- align-items: center;
- gap: ${space(0.75)};
-`;
-
const IconContainer = styled('div')`
flex: 1 0 14px;
height: 14px;
diff --git a/static/app/components/events/eventAttachments.tsx b/static/app/components/events/eventAttachments.tsx
index 2c1a90cd320..51fa18dbfda 100644
--- a/static/app/components/events/eventAttachments.tsx
+++ b/static/app/components/events/eventAttachments.tsx
@@ -6,6 +6,7 @@ import {
useFetchEventAttachments,
} from 'sentry/actionCreators/events';
import {LinkButton} from 'sentry/components/core/button/linkButton';
+import {Flex} from 'sentry/components/core/layout';
import EventAttachmentActions from 'sentry/components/events/eventAttachmentActions';
import FileSize from 'sentry/components/fileSize';
import LoadingError from 'sentry/components/loadingError';
@@ -139,9 +140,10 @@ function EventAttachmentsContent({
>
{attachments.map(attachment => (
-
+
{attachment.name}
-
+
+
@@ -198,12 +200,6 @@ const StyledPanelTable = styled(PanelTable)`
grid-template-columns: 1fr auto auto;
`;
-const FlexCenter = styled('div')`
- ${p => p.theme.overflowEllipsis};
- display: flex;
- align-items: center;
-`;
-
const Name = styled('div')`
${p => p.theme.overflowEllipsis};
white-space: nowrap;
diff --git a/static/app/components/events/interfaces/spans/newTraceDetailsHeader.tsx b/static/app/components/events/interfaces/spans/newTraceDetailsHeader.tsx
index e80d3f639f3..aafc5f6ddef 100644
--- a/static/app/components/events/interfaces/spans/newTraceDetailsHeader.tsx
+++ b/static/app/components/events/interfaces/spans/newTraceDetailsHeader.tsx
@@ -1,6 +1,7 @@
import {Fragment} from 'react';
import styled from '@emotion/styled';
+import {Flex} from 'sentry/components/core/layout';
import {generateStats} from 'sentry/components/events/opsBreakdown';
import {DividerSpacer} from 'sentry/components/performance/waterfall/miniHeader';
import {t} from 'sentry/locale';
@@ -32,18 +33,18 @@ function ServiceBreakdown({
if (!displayBreakdown) {
return (
-
+
{t('server side')}
-
+
{'N/A'}
-
-
-
+
+
+
{t('client side')}
-
+
{'N/A'}
-
-
+
+
);
}
@@ -57,20 +58,20 @@ function ServiceBreakdown({
return httpDuration ? (
-
+
{t('server side')}
-
+
{getDuration(httpDuration, 2, true)}
{serverSidePct}%
-
-
-
+
+
+
{t('client side')}
-
+
{getDuration(totalDuration - httpDuration, 2, true)}
{clientSidePct}%
-
-
+
+
) : null;
}
@@ -151,18 +152,10 @@ const Pct = styled('div')`
font-variant-numeric: tabular-nums;
`;
-const FlexBox = styled('div')`
+const BreakDownWrapper = styled('div')`
display: flex;
-`;
-
-const BreakDownWrapper = styled(FlexBox)`
flex-direction: column;
padding: ${space(2)};
`;
-const BreakDownRow = styled(FlexBox)`
- align-items: center;
- justify-content: space-between;
-`;
-
export default TraceViewHeader;
diff --git a/static/app/components/group/times.tsx b/static/app/components/group/times.tsx
index c4f9d28f1d8..e18e4c6fc0a 100644
--- a/static/app/components/group/times.tsx
+++ b/static/app/components/group/times.tsx
@@ -1,6 +1,8 @@
import {Fragment} from 'react';
import styled from '@emotion/styled';
+import {Flex} from 'sentry/components/core/layout';
+import TextOverflow from 'sentry/components/textOverflow';
import TimeSince from 'sentry/components/timeSince';
import {IconClock} from 'sentry/icons';
import {t} from 'sentry/locale';
@@ -19,20 +21,28 @@ type Props = {
function Times({lastSeen, firstSeen}: Props) {
return (
-
+
{lastSeen && (
-
-
+
+
+
+
)}
{firstSeen && lastSeen && (
—
)}
{firstSeen && (
-
+
+
+
)}
-
+
);
}
@@ -42,18 +52,11 @@ const Container = styled('div')`
min-width: 0; /* flex-hack for overflow-ellipsised children */
`;
-const FlexWrapper = styled('div')`
- ${p => p.theme.overflowEllipsis}
-
- /* The following aligns the icon with the text, fixes bug in Firefox */
- display: flex;
- align-items: center;
-`;
-
const StyledIconClock = styled(IconClock)`
/* this is solely for optics, since TimeSince always begins
with a number, and numbers do not have descenders */
margin-right: ${space(0.5)};
+ min-width: 12px;
`;
export default Times;
diff --git a/static/app/views/alerts/list/rules/alertRuleStatus.tsx b/static/app/views/alerts/list/rules/alertRuleStatus.tsx
index 5eb3f250275..7e0a9d5649a 100644
--- a/static/app/views/alerts/list/rules/alertRuleStatus.tsx
+++ b/static/app/views/alerts/list/rules/alertRuleStatus.tsx
@@ -1,5 +1,6 @@
import styled from '@emotion/styled';
+import {Flex} from 'sentry/components/core/layout';
import {IconArrow, IconMute, IconNot} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
@@ -88,7 +89,7 @@ export default function AlertRuleStatus({rule}: Props) {
}
return (
-
+
{rule.detectionType !== AlertRuleComparisonType.DYNAMIC && (
)}
@@ -109,7 +110,7 @@ export default function AlertRuleStatus({rule}: Props) {
)}
)}
-
+
);
}
@@ -125,9 +126,3 @@ const TriggerText = styled('div')`
white-space: nowrap;
font-variant-numeric: tabular-nums;
`;
-
-// TODO: explore utilizing the FlexContainer from app/components/container/flex.tsx
-const FlexCenter = styled('div')`
- display: flex;
- align-items: center;
-`;
diff --git a/static/app/views/alerts/list/rules/row.tsx b/static/app/views/alerts/list/rules/row.tsx
index 1bc99ea39ba..e780d3441ec 100644
--- a/static/app/views/alerts/list/rules/row.tsx
+++ b/static/app/views/alerts/list/rules/row.tsx
@@ -10,6 +10,7 @@ import {
CompactSelect,
type SelectOptionOrSection,
} from 'sentry/components/core/compactSelect';
+import {Flex} from 'sentry/components/core/layout';
import {Tooltip} from 'sentry/components/core/tooltip';
import type {MenuItemProps} from 'sentry/components/dropdownMenu';
import {DropdownMenu} from 'sentry/components/dropdownMenu';
@@ -265,26 +266,26 @@ function RuleListRow({
-
-
+
+
-
+
{!isUptime && !isCron && (
)}
-
-
+
+
-
+
-
+
{ownerActor ? (
) : (
@@ -311,7 +312,7 @@ function RuleListRow({
)}
)}
-
+
{({hasAccess}) => (
@@ -333,12 +334,6 @@ function RuleListRow({
);
}
-// TODO: see static/app/components/profiling/flex.tsx and utilize the FlexContainer styled component
-const FlexCenter = styled('div')`
- display: flex;
- align-items: center;
-`;
-
const AlertNameWrapper = styled('div')<{isIssueAlert?: boolean}>`
${p => p.theme.overflowEllipsis}
display: flex;
diff --git a/static/app/views/codecov/tests/onboardingSteps/addUploadToken.tsx b/static/app/views/codecov/tests/onboardingSteps/addUploadToken.tsx
index 784c65b9700..23320d27250 100644
--- a/static/app/views/codecov/tests/onboardingSteps/addUploadToken.tsx
+++ b/static/app/views/codecov/tests/onboardingSteps/addUploadToken.tsx
@@ -1,9 +1,9 @@
import {Fragment, useState} from 'react';
-import styled from '@emotion/styled';
import {CodeSnippet} from 'sentry/components/codeSnippet';
import {Alert} from 'sentry/components/core/alert';
import {Button} from 'sentry/components/core/button';
+import {Flex} from 'sentry/components/core/layout';
import Link from 'sentry/components/links/link';
import {IconClose} from 'sentry/icons';
import {t, tct} from 'sentry/locale';
@@ -74,26 +74,26 @@ export function AddUploadToken({step}: AddUploadTokenProps) {
)}
-
-
+
+
SENTRY_PREVENT_TOKEN
{FULL_TOKEN}
-
+
{t('Done')}
-
+
) : (
-
-
+
+
SENTRY_PREVENT_TOKEN
{TRUNCATED_TOKEN}
-
+
{t('Regenerate')}
-
+
)
) : (
@@ -104,10 +104,3 @@ export function AddUploadToken({step}: AddUploadTokenProps) {
);
}
-
-const FlexContainer = styled('div')`
- display: flex;
- flex-direction: row;
- gap: ${space(1)};
- justify-content: space-between;
-`;
diff --git a/static/app/views/organizationStats/teamInsights/teamMisery.tsx b/static/app/views/organizationStats/teamInsights/teamMisery.tsx
index b410940d535..78422718370 100644
--- a/static/app/views/organizationStats/teamInsights/teamMisery.tsx
+++ b/static/app/views/organizationStats/teamInsights/teamMisery.tsx
@@ -6,6 +6,7 @@ import type {Location} from 'history';
import type {DateTimeObject} from 'sentry/components/charts/utils';
import CollapsePanel, {COLLAPSE_COUNT} from 'sentry/components/collapsePanel';
import {LinkButton} from 'sentry/components/core/button/linkButton';
+import {Flex} from 'sentry/components/core/layout';
import Link from 'sentry/components/links/link';
import LoadingError from 'sentry/components/loadingError';
import {PanelTable} from 'sentry/components/panels/panelTable';
@@ -94,9 +95,9 @@ function TeamMisery({
}
headers={[
-
+
{t('Key transaction')}
- ,
+ ,
t('Project'),
tct('Last [period]', {period}),
t('Last 7 Days'),
@@ -147,13 +148,13 @@ function TeamMisery({
-
+
{project && }
-
- {periodMisery}
- {weekMisery ?? '\u2014'}
+
+ {periodMisery}
+ {weekMisery ?? '\u2014'}
{trendValue === 0 ? (
@@ -294,11 +295,6 @@ const StyledPanelTable = styled(PanelTable)<{isEmpty: boolean}>`
`}
`;
-const FlexCenter = styled('div')`
- display: flex;
- align-items: center;
-`;
-
const KeyTransactionTitleWrapper = styled('div')`
${p => p.theme.overflowEllipsis};
display: flex;
diff --git a/static/app/views/performance/newTraceDetails/traceTabsAndVitals.tsx b/static/app/views/performance/newTraceDetails/traceTabsAndVitals.tsx
index 251ad8099c0..0be5aaf67da 100644
--- a/static/app/views/performance/newTraceDetails/traceTabsAndVitals.tsx
+++ b/static/app/views/performance/newTraceDetails/traceTabsAndVitals.tsx
@@ -1,6 +1,7 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import styled from '@emotion/styled';
+import {Flex} from 'sentry/components/core/layout';
import {TabList, Tabs} from 'sentry/components/core/tabs';
import {space} from 'sentry/styles/space';
import type {TraceRootEventQueryResults} from 'sentry/views/performance/newTraceDetails/traceApi/useTraceRootEvent';
@@ -17,18 +18,18 @@ type TraceTabsAndVitalsProps = {
function Placeholder() {
return (
-
-
+
+
-
-
+
+
-
-
+
+
);
}
@@ -89,7 +90,7 @@ export function TraceTabsAndVitals({
}
return (
-
+
{tabOptions.map(tab => (
@@ -102,7 +103,7 @@ export function TraceTabsAndVitals({
tree={tree}
containerWidth={containerWidth}
/>
-
+
);
}
@@ -110,16 +111,6 @@ const StyledPlaceholder = styled(TraceHeaderComponents.StyledPlaceholder)`
background-color: ${p => p.theme.purple100};
`;
-const FlexBox = styled('div')`
- display: flex;
- align-items: center;
- gap: ${space(1)};
-`;
-
-const Container = styled(FlexBox)`
- justify-content: space-between;
-`;
-
const StyledTabsList = styled(TabList)`
display: flex;
align-items: center;
diff --git a/static/app/views/performance/newTraceDetails/traceWaterfall.tsx b/static/app/views/performance/newTraceDetails/traceWaterfall.tsx
index 00d4c16c4e8..4da1088aec9 100644
--- a/static/app/views/performance/newTraceDetails/traceWaterfall.tsx
+++ b/static/app/views/performance/newTraceDetails/traceWaterfall.tsx
@@ -14,6 +14,7 @@ import * as Sentry from '@sentry/react';
import * as qs from 'query-string';
import {addSuccessMessage} from 'sentry/actionCreators/indicator';
+import {Flex} from 'sentry/components/core/layout';
import {t, tct} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Organization} from 'sentry/types/organization';
@@ -708,7 +709,7 @@ export function TraceWaterfall(props: TraceWaterfallProps) {
}
return (
-
+
)}
-
+
);
}
@@ -865,10 +866,3 @@ export const TraceGrid = styled('div')<{
${p => `border-radius: ${p.theme.borderRadius};`}
`;
-
-const FlexBox = styled('div')`
- display: flex;
- flex-grow: 1;
- flex-direction: column;
- height: 100%;
-`;
diff --git a/static/app/views/settings/dynamicSampling/organizationSampleRateInput.tsx b/static/app/views/settings/dynamicSampling/organizationSampleRateInput.tsx
index 99dc2de85ba..615569e334b 100644
--- a/static/app/views/settings/dynamicSampling/organizationSampleRateInput.tsx
+++ b/static/app/views/settings/dynamicSampling/organizationSampleRateInput.tsx
@@ -3,6 +3,7 @@ import {useEffect, useRef} from 'react';
import styled from '@emotion/styled';
import {Button} from 'sentry/components/core/button';
+import {Flex} from 'sentry/components/core/layout';
import {Tooltip} from 'sentry/components/core/tooltip';
import {IconEdit} from 'sentry/icons';
import {t} from 'sentry/locale';
@@ -47,13 +48,13 @@ export function OrganizationSampleRateInput({
const showBulkEditButton = hasAccess && isBulkEditEnabled && !isBulkEditActive;
return (
-
+
{label}
{help}
-
+
{showBulkEditButton && (
onChange(event.target.value)}
/>
-
+
{error ? (
{error}
) : showPreviousValue ? (
@@ -93,16 +94,12 @@ export function OrganizationSampleRateInput({
{t('All spans are stored')}
) : null}
-
+
);
}
-const FlexRow = styled('div')`
+const SampleRateRow = styled('div')`
display: flex;
- gap: ${space(1)};
-`;
-
-const Wrapper = styled(FlexRow)`
padding: ${space(1.5)} ${space(2)} ${space(1)};
border-bottom: 1px solid ${p => p.theme.innerBorder};
gap: ${space(4)};
diff --git a/static/app/views/settings/organizationAuditLog/auditLogList.tsx b/static/app/views/settings/organizationAuditLog/auditLogList.tsx
index 6b1b4263db4..52f8687459b 100644
--- a/static/app/views/settings/organizationAuditLog/auditLogList.tsx
+++ b/static/app/views/settings/organizationAuditLog/auditLogList.tsx
@@ -365,10 +365,10 @@ function AuditLogList({
-
+
{getTypeDisplay(entry.event)}
-
-
+
+
{entry.ipAddress && (
)}
-
+
p.theme.overflowEllipsis};
min-width: 90px;
diff --git a/static/app/views/settings/organizationIntegrations/detailedView/integrationLayout.tsx b/static/app/views/settings/organizationIntegrations/detailedView/integrationLayout.tsx
index d8400064180..246070e5f25 100644
--- a/static/app/views/settings/organizationIntegrations/detailedView/integrationLayout.tsx
+++ b/static/app/views/settings/organizationIntegrations/detailedView/integrationLayout.tsx
@@ -244,7 +244,7 @@ function InformationCard({
return (
-
+
))}
-
+
{author && (
@@ -353,10 +353,6 @@ const DisableWrapper = styled('div')`
align-items: center;
`;
-const FlexContainer = styled('div')`
- flex: 1;
-`;
-
const Description = styled(MarkedText)`
li {
margin-bottom: 6px;
diff --git a/static/app/views/settings/project/projectOwnership/codeOwnerFileTable.tsx b/static/app/views/settings/project/projectOwnership/codeOwnerFileTable.tsx
index 493d14f1709..69076356792 100644
--- a/static/app/views/settings/project/projectOwnership/codeOwnerFileTable.tsx
+++ b/static/app/views/settings/project/projectOwnership/codeOwnerFileTable.tsx
@@ -4,6 +4,7 @@ import styled from '@emotion/styled';
import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
import {openModal} from 'sentry/actionCreators/modal';
+import {Flex} from 'sentry/components/core/layout';
import {DropdownMenu} from 'sentry/components/dropdownMenu';
import ExternalLink from 'sentry/components/links/externalLink';
import {PanelTable} from 'sentry/components/panels/panelTable';
@@ -106,20 +107,20 @@ export function CodeOwnerFileTable({
>
{codeowners.map(codeowner => (
-
+
{getCodeOwnerIcon(codeowner.provider)}
{codeowner.codeMapping?.repoName}
-
-
+
+
{codeowner.codeMapping?.stackRoot}
-
-
+
+
{codeowner.codeMapping?.sourceRoot}
-
-
+
+
-
-
+
+
{codeowner.codeOwnersUrl === 'unknown' ? null : (
@@ -129,8 +130,8 @@ export function CodeOwnerFileTable({
)}
)}
-
-
+
+
-
+
))}
@@ -174,12 +175,6 @@ const StyledPanelTable = styled(PanelTable)`
white-space: nowrap;
`;
-const FlexCenter = styled('div')`
- display: flex;
- align-items: center;
- gap: ${space(1)};
-`;
-
const StyledExternalLink = styled(ExternalLink)`
display: flex;
align-items: center;
diff --git a/static/gsAdmin/views/instanceLevelOAuth/instanceLevelOAuthDetails.tsx b/static/gsAdmin/views/instanceLevelOAuth/instanceLevelOAuthDetails.tsx
index 42048b00832..4366da666fe 100644
--- a/static/gsAdmin/views/instanceLevelOAuth/instanceLevelOAuthDetails.tsx
+++ b/static/gsAdmin/views/instanceLevelOAuth/instanceLevelOAuthDetails.tsx
@@ -4,6 +4,7 @@ import styled from '@emotion/styled';
import {addErrorMessage} from 'sentry/actionCreators/indicator';
import {openModal} from 'sentry/actionCreators/modal';
import {Button} from 'sentry/components/core/button';
+import {Flex} from 'sentry/components/core/layout';
import ApiForm from 'sentry/components/forms/apiForm';
import TextField from 'sentry/components/forms/fields/textField';
import LoadingIndicator from 'sentry/components/loadingIndicator';
@@ -150,7 +151,7 @@ function InstanceLevelOAuthDetails() {
Date added: {clientDetails.createdAt}
-
+
Delete client
-
+
)}
{errorMessage && {errorMessage}
}
@@ -180,9 +181,3 @@ const StyledButton = styled(Button)`
margin-top: 20px;
margin-bottom: 15px;
`;
-
-const FlexDiv = styled('div')`
- display: flex;
- width: 100%;
- justify-content: right;
-`;
From b4d30008b2a8c6d25ffa80f77c32e8ea243f9939 Mon Sep 17 00:00:00 2001
From: Mark Story
Date: Fri, 20 Jun 2025 14:38:14 -0400
Subject: [PATCH 22/32] fix(taskworker) Extend deadlines for check_auth
(#93961)
This has been killed a few times.
Refs SENTRY-42M7
---
src/sentry/tasks/auth/check_auth.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/sentry/tasks/auth/check_auth.py b/src/sentry/tasks/auth/check_auth.py
index 4477e1f295c..e8adb5739e1 100644
--- a/src/sentry/tasks/auth/check_auth.py
+++ b/src/sentry/tasks/auth/check_auth.py
@@ -73,7 +73,9 @@ def check_auth_identity(auth_identity_id: int, **kwargs):
name="sentry.tasks.check_auth_identities",
queue="auth.control",
silo_mode=SiloMode.CONTROL,
- taskworker_config=TaskworkerConfig(namespace=auth_control_tasks),
+ taskworker_config=TaskworkerConfig(
+ namespace=auth_control_tasks, processing_deadline_duration=60
+ ),
)
def check_auth_identities(
auth_identity_id: int | None = None,
From 02695f9ce2b28906c5a6207548aee7354ac8ef26 Mon Sep 17 00:00:00 2001
From: Nora Shapiro
Date: Fri, 20 Jun 2025 12:02:26 -0700
Subject: [PATCH 23/32] ref(issues): Remove redundant Type row from Event
Grouping Information table (#93892)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[ticket](https://linear.app/getsentry/issue/ID-156/grouping-info-remove-type-field-from-ui)
The Type field in the Grouping Info section of the issue details page
was redundant.
This removes the Type row from all variant types while keeping the
underlying data structure intact.
before

after

---
.../events/groupingInfo/groupingVariant.tsx | 64 +------------------
1 file changed, 1 insertion(+), 63 deletions(-)
diff --git a/static/app/components/events/groupingInfo/groupingVariant.tsx b/static/app/components/events/groupingInfo/groupingVariant.tsx
index 1843b1b7047..47d3d174db8 100644
--- a/static/app/components/events/groupingInfo/groupingVariant.tsx
+++ b/static/app/components/events/groupingInfo/groupingVariant.tsx
@@ -100,68 +100,19 @@ function GroupingVariant({event, showGroupingConfig, variant}: GroupingVariantPr
switch (variant.type) {
case EventGroupVariantType.COMPONENT:
component = variant.component;
- data.push([
- t('Type'),
-
- {variant.type}
-
- ,
- ]);
+
if (showGroupingConfig && variant.config?.id) {
data.push([t('Grouping Config'), variant.config.id]);
}
break;
case EventGroupVariantType.CUSTOM_FINGERPRINT:
- data.push([
- t('Type'),
-
- {variant.type}
-
- ,
- ]);
addFingerprintInfo(data, variant);
break;
case EventGroupVariantType.BUILT_IN_FINGERPRINT:
- data.push([
- t('Type'),
-
- {variant.type}
-
- ,
- ]);
addFingerprintInfo(data, variant);
break;
case EventGroupVariantType.SALTED_COMPONENT:
component = variant.component;
- data.push([
- t('Type'),
-
- {variant.type}
-
- ,
- ]);
addFingerprintInfo(data, variant);
if (showGroupingConfig && variant.config?.id) {
data.push([t('Grouping Config'), variant.config.id]);
@@ -173,19 +124,6 @@ function GroupingVariant({event, showGroupingConfig, variant}: GroupingVariantPr
.find((c): c is EntrySpans => c.type === 'spans')
?.data?.map((span: RawSpanType) => [span.span_id, span.hash]) ?? []
);
- data.push([
- t('Type'),
-
- {variant.type}
-
- ,
- ]);
data.push(['Performance Issue Type', variant.key]);
data.push(['Span Operation', variant.evidence.op]);
From 4c997de249b22a30b03ee481470c360413eb1065 Mon Sep 17 00:00:00 2001
From: lzhao-sentry
Date: Fri, 20 Jun 2025 15:22:19 -0400
Subject: [PATCH 24/32] ref(dashboards): add a new table widget visualization
component (#93902)
### Changes
Related to this PR: https://github.com/getsentry/sentry/pull/93810. This
is part 1 of the change, which is pulling out the new component and just
adding it to the repo. Also includes some simplification of the logic in
the base component.
Part 2 will be replacing tables in widgets.
### Before/After
There is no UI change as the table is not being used yet. There is a new
story page for the component.
---
.../app/views/dashboards/widgetCard/chart.tsx | 56 ++++---
.../views/dashboards/widgets/common/types.tsx | 7 +
.../tableWidget/defaultTableCellRenderers.tsx | 104 +++++++++++++
.../fixtures/sampleHTTPRequestTableData.ts | 41 +++++
.../tableWidgetVisualization.spec.tsx | 107 +++++++++++++
.../tableWidgetVisualization.stories.tsx | 141 ++++++++++++++++++
.../tableWidget/tableWidgetVisualization.tsx | 134 +++++++++++++++++
tests/js/fixtures/tabularColumn.ts | 11 ++
tests/js/fixtures/tabularColumns.ts | 9 ++
9 files changed, 588 insertions(+), 22 deletions(-)
create mode 100644 static/app/views/dashboards/widgets/tableWidget/defaultTableCellRenderers.tsx
create mode 100644 static/app/views/dashboards/widgets/tableWidget/fixtures/sampleHTTPRequestTableData.ts
create mode 100644 static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.spec.tsx
create mode 100644 static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx
create mode 100644 static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx
create mode 100644 tests/js/fixtures/tabularColumn.ts
create mode 100644 tests/js/fixtures/tabularColumns.ts
diff --git a/static/app/views/dashboards/widgetCard/chart.tsx b/static/app/views/dashboards/widgetCard/chart.tsx
index 0f66d046c39..cbe9528ae61 100644
--- a/static/app/views/dashboards/widgetCard/chart.tsx
+++ b/static/app/views/dashboards/widgetCard/chart.tsx
@@ -60,6 +60,7 @@ import {getBucketSize} from 'sentry/views/dashboards/utils/getBucketSize';
import WidgetLegendNameEncoderDecoder from 'sentry/views/dashboards/widgetLegendNameEncoderDecoder';
import type WidgetLegendSelectionState from 'sentry/views/dashboards/widgetLegendSelectionState';
import {BigNumberWidgetVisualization} from 'sentry/views/dashboards/widgets/bigNumberWidget/bigNumberWidgetVisualization';
+import {TableWidgetVisualization} from 'sentry/views/dashboards/widgets/tableWidget/tableWidgetVisualization';
import {ConfidenceFooter} from 'sentry/views/explore/charts/confidenceFooter';
import type {GenericWidgetQueriesChildrenProps} from './genericWidgetQueries';
@@ -137,7 +138,7 @@ class WidgetCardChart extends Component {
}
tableResultComponent({loading, tableResults}: TableResultProps): React.ReactNode {
- const {location, widget, selection, minTableColumnWidth} = this.props;
+ const {location, widget, selection, minTableColumnWidth, organization} = this.props;
if (typeof tableResults === 'undefined') {
// Align height to other charts.
return ;
@@ -148,11 +149,9 @@ class WidgetCardChart extends Component {
const getCustomFieldRenderer = (
field: string,
meta: MetaType,
- organization?: Organization
+ org?: Organization
) => {
- return (
- datasetConfig.getCustomFieldRenderer?.(field, meta, widget, organization) || null
- );
+ return datasetConfig.getCustomFieldRenderer?.(field, meta, widget, org) || null;
};
return tableResults.map((result, i) => {
@@ -162,23 +161,36 @@ class WidgetCardChart extends Component {
return (
- 1 ? result.title : ''}
- // Bypass the loading state for span widgets because this renders the loading placeholder
- // and we want to show the underlying data during preflight instead
- loading={widget.widgetType === WidgetType.SPANS ? false : loading}
- loader={ }
- metadata={result.meta}
- data={result.data}
- stickyHeaders
- fieldHeaderMap={datasetConfig.getFieldHeaderMap?.(widget.queries[i])}
- getCustomFieldRenderer={getCustomFieldRenderer}
- minColumnWidth={minTableColumnWidth}
- />
+ {organization.features.includes('use-table-widget-visualization') ? (
+
+ ) : (
+ 1 ? result.title : ''}
+ // Bypass the loading state for span widgets because this renders the loading placeholder
+ // and we want to show the underlying data during preflight instead
+ loading={widget.widgetType === WidgetType.SPANS ? false : loading}
+ loader={ }
+ metadata={result.meta}
+ data={result.data}
+ stickyHeaders
+ fieldHeaderMap={datasetConfig.getFieldHeaderMap?.(widget.queries[i])}
+ getCustomFieldRenderer={getCustomFieldRenderer}
+ minColumnWidth={minTableColumnWidth}
+ />
+ )}
);
});
diff --git a/static/app/views/dashboards/widgets/common/types.tsx b/static/app/views/dashboards/widgets/common/types.tsx
index 6c9f295f2fd..89c9a759ecc 100644
--- a/static/app/views/dashboards/widgets/common/types.tsx
+++ b/static/app/views/dashboards/widgets/common/types.tsx
@@ -73,6 +73,13 @@ export type TabularData = {
meta: TabularMeta;
};
+export type TabularColumn = {
+ key: TFields;
+ name: TFields;
+ type?: AttributeValueType;
+ width?: number;
+};
+
type ErrorProp = Error | string;
export interface ErrorPropWithResponseJSON extends Error {
responseJSON?: {detail: string};
diff --git a/static/app/views/dashboards/widgets/tableWidget/defaultTableCellRenderers.tsx b/static/app/views/dashboards/widgets/tableWidget/defaultTableCellRenderers.tsx
new file mode 100644
index 00000000000..4b2170586ff
--- /dev/null
+++ b/static/app/views/dashboards/widgets/tableWidget/defaultTableCellRenderers.tsx
@@ -0,0 +1,104 @@
+import type {Theme} from '@emotion/react';
+import styled from '@emotion/styled';
+import type {Location} from 'history';
+
+import {Tooltip} from 'sentry/components/core/tooltip';
+import type {Alignments} from 'sentry/components/gridEditable/sortLink';
+import type {Organization} from 'sentry/types/organization';
+import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
+import type {ColumnValueType} from 'sentry/utils/discover/fields';
+import {fieldAlignment} from 'sentry/utils/discover/fields';
+import type {
+ TabularColumn,
+ TabularData,
+ TabularRow,
+} from 'sentry/views/dashboards/widgets/common/types';
+
+interface DefaultHeadCellRenderProps {
+ renderTableHeadCell?: (
+ column: TabularColumn,
+ columnIndex: number
+ ) => React.ReactNode | undefined;
+}
+
+export const renderDefaultHeadCell = ({
+ renderTableHeadCell,
+}: DefaultHeadCellRenderProps) =>
+ function (
+ column: TabularColumn,
+ _columnIndex: number
+ ): React.ReactNode {
+ const cell = renderTableHeadCell?.(column, _columnIndex);
+ if (cell) {
+ return cell;
+ }
+ const align = fieldAlignment(column.name, column.type as ColumnValueType);
+
+ return (
+
+ {column.name}
+
+ );
+ };
+
+interface DefaultBodyCellRenderProps {
+ location: Location;
+ organization: Organization;
+ theme: Theme;
+ renderTableBodyCell?: (
+ column: TabularColumn,
+ dataRow: TabularRow,
+ rowIndex: number,
+ columnIndex: number
+ ) => React.ReactNode | undefined;
+ tableData?: TabularData;
+}
+
+export const renderDefaultBodyCell = ({
+ tableData,
+ location,
+ organization,
+ theme,
+ renderTableBodyCell,
+}: DefaultBodyCellRenderProps) =>
+ function (
+ column: TabularColumn,
+ dataRow: TabularRow,
+ rowIndex: number,
+ columnIndex: number
+ ): React.ReactNode {
+ const cell = renderTableBodyCell?.(column, dataRow, rowIndex, columnIndex);
+ if (cell) {
+ return cell;
+ }
+
+ const columnKey = String(column.key);
+ if (!tableData?.meta) {
+ return dataRow[column.key];
+ }
+
+ const fieldRenderer = getFieldRenderer(columnKey, tableData.meta.fields, false);
+ const unit = tableData.meta.units?.[columnKey] as string;
+
+ return (
+
+ {fieldRenderer(dataRow, {
+ organization,
+ location,
+ unit,
+ theme,
+ })}
+
+ );
+ };
+
+const StyledTooltip = styled(Tooltip)`
+ display: initial;
+`;
+
+const CellWrapper = styled('div')<{align: Alignments}>`
+ display: block;
+ width: 100%;
+ white-space: nowrap;
+ ${(p: {align: Alignments}) => (p.align ? `text-align: ${p.align};` : '')}
+`;
diff --git a/static/app/views/dashboards/widgets/tableWidget/fixtures/sampleHTTPRequestTableData.ts b/static/app/views/dashboards/widgets/tableWidget/fixtures/sampleHTTPRequestTableData.ts
new file mode 100644
index 00000000000..563f7f6949c
--- /dev/null
+++ b/static/app/views/dashboards/widgets/tableWidget/fixtures/sampleHTTPRequestTableData.ts
@@ -0,0 +1,41 @@
+import type {TabularData} from 'sentry/views/dashboards/widgets/common/types';
+
+export const sampleHTTPRequestTableData: TabularData = {
+ data: [
+ {
+ 'http.request_method': 'PATCH',
+ 'count(span.duration)': 14105,
+ id: '',
+ },
+ {
+ 'http.request_method': 'HEAD',
+ 'count(span.duration)': 9494,
+ id: '',
+ },
+ {
+ 'http.request_method': 'GET',
+ 'count(span.duration)': 38583495,
+ id: '',
+ },
+ {
+ 'http.request_method': 'DELETE',
+ 'count(span.duration)': 123,
+ id: '',
+ },
+ {
+ 'http.request_method': 'POST',
+ 'count(span.duration)': 21313,
+ id: '',
+ },
+ ],
+ meta: {
+ fields: {
+ 'http.request_method': 'string',
+ 'count(span.duration)': 'integer',
+ },
+ units: {
+ 'http.request_method': null,
+ 'count(span.duration)': null,
+ },
+ },
+};
diff --git a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.spec.tsx b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.spec.tsx
new file mode 100644
index 00000000000..160da91c59d
--- /dev/null
+++ b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.spec.tsx
@@ -0,0 +1,107 @@
+import {TabularColumnsFixture} from 'sentry-fixture/tabularColumns';
+
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import {DurationUnit, RateUnit} from 'sentry/utils/discover/fields';
+import type {
+ TabularColumn,
+ TabularData,
+ TabularRow,
+} from 'sentry/views/dashboards/widgets/common/types';
+import {sampleHTTPRequestTableData} from 'sentry/views/dashboards/widgets/tableWidget/fixtures/sampleHTTPRequestTableData';
+import {TableWidgetVisualization} from 'sentry/views/dashboards/widgets/tableWidget/tableWidgetVisualization';
+
+describe('TableWidgetVisualization', function () {
+ it('Basic table renders correctly', async function () {
+ render( );
+
+ expect(await screen.findByText('http.request_method')).toBeInTheDocument();
+ expect(await screen.findByText('count(span.duration)')).toBeInTheDocument();
+ });
+
+ it('Table applies custom order and column name if provided', function () {
+ const columns: Array> = [
+ {
+ key: 'count(span.duration)',
+ name: 'Count of Span Duration',
+ },
+ {
+ key: 'http.request_method',
+ name: 'HTTP Request Method',
+ },
+ ];
+
+ render(
+
+ );
+
+ const headers = screen.getAllByTestId('grid-head-cell');
+ expect(headers[0]?.children[0]?.textContent).toEqual(columns[0]?.name);
+ expect(headers[1]?.children[0]?.textContent).toEqual(columns[1]?.name);
+ });
+
+ it('Table renders unique number fields correctly', async function () {
+ const tableData: TabularData = {
+ data: [{'span.duration': 123, failure_rate: 0.1, epm: 6}],
+ meta: {
+ fields: {'span.duration': 'duration', failure_rate: 'percentage', epm: 'rate'},
+ units: {
+ 'span.duration': DurationUnit.MILLISECOND,
+ failure_rate: null,
+ epm: RateUnit.PER_MINUTE,
+ },
+ },
+ };
+ render( );
+
+ expect(await screen.findByText('span.duration')).toBeInTheDocument();
+ expect(await screen.findByText('failure_rate')).toBeInTheDocument();
+ expect(await screen.findByText('epm')).toBeInTheDocument();
+
+ expect(await screen.findByText('123.00ms')).toBeInTheDocument();
+ expect(await screen.findByText('10%')).toBeInTheDocument();
+ expect(await screen.findByText('6.00/min')).toBeInTheDocument();
+ });
+
+ it('Table uses custom renderer over fallback renderer correctly', async function () {
+ const tableData: TabularData = {
+ data: [{date: '2025-06-20T15:14:52+00:00'}],
+ meta: {
+ fields: {date: 'date'},
+ units: {
+ date: null,
+ },
+ },
+ };
+
+ function customDateHeadRenderer(
+ column: TabularColumn,
+ _columnIndex: number
+ ) {
+ return {column.name + ' column'}
;
+ }
+
+ function customDateBodyRenderer(
+ column: TabularColumn,
+ dataRow: TabularRow,
+ _rowIndex: number,
+ _columnIndex: number
+ ) {
+ return {dataRow[column.key]}
;
+ }
+
+ render(
+
+ );
+
+ expect(await screen.findByText('date column')).toBeInTheDocument();
+ expect(await screen.findByText('2025-06-20T15:14:52+00:00')).toBeInTheDocument();
+ });
+});
diff --git a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx
new file mode 100644
index 00000000000..a42362798be
--- /dev/null
+++ b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx
@@ -0,0 +1,141 @@
+import {Fragment} from 'react';
+
+import {CodeSnippet} from 'sentry/components/codeSnippet';
+import * as Storybook from 'sentry/stories';
+import type {
+ TabularColumn,
+ TabularData,
+ TabularRow,
+} from 'sentry/views/dashboards/widgets/common/types';
+import {sampleHTTPRequestTableData} from 'sentry/views/dashboards/widgets/tableWidget/fixtures/sampleHTTPRequestTableData';
+import {TableWidgetVisualization} from 'sentry/views/dashboards/widgets/tableWidget/tableWidgetVisualization';
+
+export default Storybook.story('TableWidgetVisualization', story => {
+ story('Getting Started', () => {
+ return (
+
+
+ is meant to be a robust
+ and eventual replacement to all tables in Dashboards and Insights (and
+ potentially more). The inner component of this table is{' '}
+ . The table allows for custom
+ renderers, but is also able to correctly render fields on its own using
+ fallbacks. Future features planned include sorting, resizing and customizable
+ cell actions.
+
+
+ Below is the the most basic example of the table which requires
+ tableData to populate the headers and body of the table
+
+
+
+ );
+ });
+
+ story('Table Data and Optional Table Columns', () => {
+ const tableWithEmptyData: TabularData = {
+ ...sampleHTTPRequestTableData,
+ data: [],
+ };
+ const customColumns: TabularColumn[] = [
+ {
+ key: 'count(span.duration)',
+ name: 'Count of Span Duration',
+ type: 'number',
+ width: -1,
+ },
+ {
+ key: 'http.request_method',
+ name: 'HTTP Request Method',
+ type: 'string',
+ width: -1,
+ },
+ ];
+ return (
+
+
+ The table data uses the type
+ TabularData. This is a mandatory prop. If the data{' '}
+ field is empty, such as
+
+
+ {`
+${JSON.stringify(tableWithEmptyData)}
+ `}
+
+ Then the table renders empty like this:
+
+
+ The table columns use the type TabularColumn[] which is based off
+ of GridColumnOrder from .
+ The prop is optional, as the table will fallback to extract the columns in order
+ from the table data's meta.fields, displaying them as shown above.
+
+
+ This prop is useful for reordering and giving custom display names to columns:
+
+
+ {`
+${JSON.stringify(customColumns)}
+ `}
+
+ Resulting table:
+
+
+ );
+ });
+
+ story('Using Custom Cell Rendering', () => {
+ function customHeadRenderer(column: TabularColumn, _columnIndex: number) {
+ return {column.name + ' column'}
;
+ }
+ function customBodyRenderer(
+ column: TabularColumn,
+ dataRow: TabularRow,
+ _rowIndex: number,
+ _columnIndex: number
+ ) {
+ if (column.key === 'http.request_method') {
+ return undefined;
+ }
+ return {dataRow[column.key]}
;
+ }
+ return (
+
+ By default, the table falls back on predefined default rendering functions.
+
+ If custom cell rendering is required, pass the functions
+ renderTableBodyCell and renderTableHeadCell
+ which replace the rendering of table body cells and table headers respectively.
+ If the function returns undefined, fallback renderer will run
+ allowing for partial custom rendering
+
+
+ In the below example, a custom header renderer is passed which adds the word
+ "column" to each head cell. A custom body renderer is also provided which only
+ affects the second column:
+
+
+
+ );
+ });
+
+ story('Table Loading Placeholder', () => {
+ return (
+
+
+ can be
+ used as a loading placeholder
+
+
+
+ );
+ });
+});
diff --git a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx
new file mode 100644
index 00000000000..fd0d13d64e9
--- /dev/null
+++ b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx
@@ -0,0 +1,134 @@
+import {useTheme} from '@emotion/react';
+
+import type {GridColumnOrder} from 'sentry/components/gridEditable';
+import GridEditable from 'sentry/components/gridEditable';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import type {
+ TabularColumn,
+ TabularData,
+ TabularRow,
+} from 'sentry/views/dashboards/widgets/common/types';
+import {
+ renderDefaultBodyCell,
+ renderDefaultHeadCell,
+} from 'sentry/views/dashboards/widgets/tableWidget/defaultTableCellRenderers';
+
+interface TableWidgetVisualizationProps {
+ /**
+ * The object that contains all the data needed to render the table
+ */
+ tableData: TabularData;
+ /**
+ * If supplied, will override the ordering of columns from `tableData`. Can also be used to
+ * supply custom display names for columns, column widths and column data type
+ */
+ columns?: TabularColumn[];
+ /**
+ * If provided, forces the table to overflow scroll horizontally without requiring column resizing
+ * - `max-content`: makes the table expand horizontally to fit the largest content
+ */
+ fit?: 'max-content';
+ /**
+ * If true, removes the borders of the sides and bottom of the table
+ */
+ frameless?: boolean;
+ /**
+ * Custom renderer that overrides default for table body cells
+ * @param column
+ * @param dataRow
+ * @param rowIndex
+ * @param columnIndex
+ * @returns `React.ReactNode | undefined`
+ */
+ renderTableBodyCell?: (
+ column: TabularColumn,
+ dataRow: TabularRow,
+ rowIndex: number,
+ columnIndex: number
+ ) => React.ReactNode | undefined;
+ /**
+ * Custom renderer that overrides default for table header cells
+ * @param column
+ * @param columnIndex
+ * @returns `React.ReactNode | undefined`
+ */
+ renderTableHeadCell?: (
+ column: TabularColumn,
+ columnIndex: number
+ ) => React.ReactNode | undefined;
+ /**
+ * If true, the table will scroll on overflow. Note that the table headers will also be sticky
+ */
+ scrollable?: boolean;
+}
+
+const FRAMELESS_STYLES = {
+ borderTopLeftRadius: 0,
+ borderTopRightRadius: 0,
+ marginBottom: 0,
+ borderLeft: 0,
+ borderRight: 0,
+ borderBottom: 0,
+ height: '100%',
+};
+
+export function TableWidgetVisualization(props: TableWidgetVisualizationProps) {
+ const {
+ tableData,
+ frameless,
+ renderTableBodyCell,
+ renderTableHeadCell,
+ columns,
+ scrollable,
+ fit,
+ } = props;
+
+ const theme = useTheme();
+ const location = useLocation();
+ const organization = useOrganization();
+
+ // Fallback to extracting fields from the tableData if no columns are provided
+ const columnOrder: TabularColumn[] =
+ columns ??
+ Object.keys(tableData?.meta.fields).map((key: string) => ({
+ key,
+ name: key,
+ width: -1,
+ type: tableData?.meta.fields[key],
+ }));
+
+ return (
+ React.ReactNode,
+ renderBodyCell: renderDefaultBodyCell({
+ tableData,
+ location,
+ organization,
+ theme,
+ renderTableBodyCell,
+ }),
+ }}
+ stickyHeader={scrollable}
+ scrollable={scrollable}
+ height={scrollable ? '100%' : undefined}
+ bodyStyle={frameless ? FRAMELESS_STYLES : {}}
+ // Resizing is not implemented yet
+ resizable={false}
+ fit={fit}
+ />
+ );
+}
+
+TableWidgetVisualization.LoadingPlaceholder = function () {
+ return (
+
+ );
+};
diff --git a/tests/js/fixtures/tabularColumn.ts b/tests/js/fixtures/tabularColumn.ts
new file mode 100644
index 00000000000..d80626514e3
--- /dev/null
+++ b/tests/js/fixtures/tabularColumn.ts
@@ -0,0 +1,11 @@
+import type {TabularColumn} from 'sentry/views/dashboards/widgets/common/types';
+
+export function TabularColumnFixture(params: Partial): TabularColumn {
+ return {
+ key: 'column_key',
+ name: 'column_name',
+ type: 'string',
+ width: -1,
+ ...params,
+ };
+}
diff --git a/tests/js/fixtures/tabularColumns.ts b/tests/js/fixtures/tabularColumns.ts
new file mode 100644
index 00000000000..a3e09352600
--- /dev/null
+++ b/tests/js/fixtures/tabularColumns.ts
@@ -0,0 +1,9 @@
+import {TabularColumnFixture} from 'sentry-fixture/tabularColumn';
+
+import type {TabularColumn} from 'sentry/views/dashboards/widgets/common/types';
+
+export function TabularColumnsFixture(
+ params: Array>
+): TabularColumn[] {
+ return params.map((param: Partial) => TabularColumnFixture(param));
+}
From 2e992baf1e12e67c745540520ef953b49e4f75f7 Mon Sep 17 00:00:00 2001
From: Richard Roggenkemper <46740234+roggenkemper@users.noreply.github.com>
Date: Fri, 20 Jun 2025 15:24:39 -0400
Subject: [PATCH 25/32] feat(detectors): Add noise config to DB Query Injection
Issue Type (#93943)
to prevent this issue from becoming too noisy, add a noise config
---
src/sentry/issues/grouptype.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/sentry/issues/grouptype.py b/src/sentry/issues/grouptype.py
index 9680891ee13..e32414acf98 100644
--- a/src/sentry/issues/grouptype.py
+++ b/src/sentry/issues/grouptype.py
@@ -511,6 +511,7 @@ class DBQueryInjectionVulnerabilityGroupType(GroupType):
category_v2 = GroupCategory.DB_QUERY.value
enable_auto_resolve = False
enable_escalation_detection = False
+ noise_config = NoiseConfig(ignore_limit=5)
default_priority = PriorityLevel.MEDIUM
From b3f40e5ce439469edd36d804dddd4c8c33cf290c Mon Sep 17 00:00:00 2001
From: Kyle Consalus
Date: Fri, 20 Jun 2025 12:32:17 -0700
Subject: [PATCH 26/32] fix(aci): Fix accidental over-logging (#93971)
Unfortunately, 'event_data' went from being the variable for current
event context to being the complete parsed data from Redis, and we
continued logging it per group.
That's more data than we should be logging even arguably once, let alone
per group.
---
src/sentry/workflow_engine/processors/delayed_workflow.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/sentry/workflow_engine/processors/delayed_workflow.py b/src/sentry/workflow_engine/processors/delayed_workflow.py
index 8d9cca7abce..f627d90d036 100644
--- a/src/sentry/workflow_engine/processors/delayed_workflow.py
+++ b/src/sentry/workflow_engine/processors/delayed_workflow.py
@@ -580,7 +580,7 @@ def fire_actions_for_groups(
extra={
"workflow_ids": [workflow.id for workflow in workflows],
"actions": [action.id for action in filtered_actions],
- "event_data": event_data,
+ "event_data": workflow_event_data,
"event_id": workflow_event_data.event.event_id,
},
)
From de377a69926d02684b3637957d3499ff22bf5600 Mon Sep 17 00:00:00 2001
From: Abdullah Khan <60121741+Abdkhan14@users.noreply.github.com>
Date: Fri, 20 Jun 2025 15:32:29 -0400
Subject: [PATCH 27/32] feat(trace-eap-waterfall): Hiding some attrs (#93964)
Co-authored-by: Abdullah Khan
---
.../traceDrawer/details/span/eapSections/attributes.tsx | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.tsx
index c8d6f7df515..bda8fd1c976 100644
--- a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.tsx
+++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.tsx
@@ -31,6 +31,8 @@ import {makeReplaysPathname} from 'sentry/views/replays/pathnames';
type CustomRenderersProps = AttributesFieldRendererProps;
+const HIDDEN_ATTRIBUTES = ['is_segment', 'project_id', 'received'];
+
export function Attributes({
node,
attributes,
@@ -60,8 +62,10 @@ export function Attributes({
return sorted;
}
- return sorted.filter(attribute =>
- attribute.name.toLowerCase().trim().includes(searchQuery.toLowerCase().trim())
+ return sorted.filter(
+ attribute =>
+ !HIDDEN_ATTRIBUTES.includes(attribute.name) &&
+ attribute.name.toLowerCase().trim().includes(searchQuery.toLowerCase().trim())
);
}, [attributes, searchQuery]);
From 786861a026f0b1675bbe5b999831ec134fdc0e40 Mon Sep 17 00:00:00 2001
From: Trevor Elkins
Date: Fri, 20 Jun 2025 15:35:52 -0400
Subject: [PATCH 28/32] feat(preprod): Add analytics to the assemble endpoint
(#93871)
Adds some simple analytics to our endpoint so we can begin building a
dashboard in Amplitude.
---
src/sentry/preprod/__init__.py | 1 +
src/sentry/preprod/analytics.py | 14 ++++++++++++++
.../organization_preprod_artifact_assemble.py | 10 +++++++++-
3 files changed, 24 insertions(+), 1 deletion(-)
create mode 100644 src/sentry/preprod/analytics.py
diff --git a/src/sentry/preprod/__init__.py b/src/sentry/preprod/__init__.py
index e69de29bb2d..32860f7f157 100644
--- a/src/sentry/preprod/__init__.py
+++ b/src/sentry/preprod/__init__.py
@@ -0,0 +1 @@
+from .analytics import * # NOQA
diff --git a/src/sentry/preprod/analytics.py b/src/sentry/preprod/analytics.py
new file mode 100644
index 00000000000..96ea66e74f1
--- /dev/null
+++ b/src/sentry/preprod/analytics.py
@@ -0,0 +1,14 @@
+from sentry import analytics
+
+
+class PreprodArtifactApiAssembleEvent(analytics.Event):
+ type = "preprod_artifact.api.assemble"
+
+ attributes = (
+ analytics.Attribute("organization_id"),
+ analytics.Attribute("project_id"),
+ analytics.Attribute("user_id", required=False),
+ )
+
+
+analytics.register(PreprodArtifactApiAssembleEvent)
diff --git a/src/sentry/preprod/api/endpoints/organization_preprod_artifact_assemble.py b/src/sentry/preprod/api/endpoints/organization_preprod_artifact_assemble.py
index 20b1b2e68f7..c3ecef72cad 100644
--- a/src/sentry/preprod/api/endpoints/organization_preprod_artifact_assemble.py
+++ b/src/sentry/preprod/api/endpoints/organization_preprod_artifact_assemble.py
@@ -4,7 +4,7 @@
from rest_framework.request import Request
from rest_framework.response import Response
-from sentry import features
+from sentry import analytics, features
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
@@ -77,6 +77,14 @@ def post(self, request: Request, project) -> Response:
"""
Assembles a preprod artifact (mobile build, etc.) and stores it in the database.
"""
+
+ analytics.record(
+ "preprod_artifact.api.assemble",
+ organization_id=project.organization_id,
+ project_id=project.id,
+ user_id=request.user.id,
+ )
+
if not features.has(
"organizations:preprod-artifact-assemble", project.organization, actor=request.user
):
From c05a0c60133d52e936d97c58755216a95a52cc53 Mon Sep 17 00:00:00 2001
From: Tony Xiao
Date: Fri, 20 Jun 2025 15:36:20 -0400
Subject: [PATCH 29/32] ref(explore): Visualize should only have 1 y axis
(#93938)
Previously, explore supported multiple y axis per chart, so each
visualize supported multiple y axis. That functionality has since been
removed for simplicity so update the types here to match. Keep in mind
that saved queries still store them as an array so when
serializing/deserializing, we still need to treat it as an array.
---
static/app/views/explore/charts/index.tsx | 11 ++--
.../pageParamsContext/aggregateFields.tsx | 13 ++--
.../contexts/pageParamsContext/index.spec.tsx | 42 ++++++------
.../contexts/pageParamsContext/index.tsx | 7 +-
.../contexts/pageParamsContext/sortBys.tsx | 4 +-
.../pageParamsContext/visualizes.spec.tsx | 53 +++++----------
.../contexts/pageParamsContext/visualizes.tsx | 29 +++++----
.../views/explore/hooks/useAddToDashboard.tsx | 7 +-
.../app/views/explore/hooks/useAnalytics.tsx | 12 ++--
.../hooks/useExploreAggregatesTable.tsx | 8 +--
.../explore/hooks/useExploreTimeseries.tsx | 8 +--
.../app/views/explore/hooks/useTopEvents.tsx | 8 +--
static/app/views/explore/spans/spansTab.tsx | 3 +-
.../aggregateColumnEditorModal.spec.tsx | 45 +++++++------
.../tables/aggregateColumnEditorModal.tsx | 10 +--
.../app/views/explore/toolbar/index.spec.tsx | 38 +++++------
.../views/explore/toolbar/toolbarSaveAs.tsx | 5 +-
.../views/explore/toolbar/toolbarSortBy.tsx | 2 +-
.../explore/toolbar/toolbarVisualize.tsx | 65 +++++++------------
static/app/views/explore/utils.spec.tsx | 2 +-
static/app/views/explore/utils.tsx | 23 +++----
21 files changed, 170 insertions(+), 225 deletions(-)
diff --git a/static/app/views/explore/charts/index.tsx b/static/app/views/explore/charts/index.tsx
index 9b3941a4dc2..a08320de4ae 100644
--- a/static/app/views/explore/charts/index.tsx
+++ b/static/app/views/explore/charts/index.tsx
@@ -10,7 +10,6 @@ import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Confidence} from 'sentry/types/organization';
import {defined} from 'sentry/utils';
-import {dedupeArray} from 'sentry/utils/dedupeArray';
import {parseFunction, prettifyParsedFunction} from 'sentry/utils/discover/fields';
import type {QueryError} from 'sentry/utils/discover/genericDiscoverQuery';
import {isTimeSeriesOther} from 'sentry/utils/timeSeries/isTimeSeriesOther';
@@ -151,8 +150,8 @@ export function ExploreCharts({
);
const getChartInfo = useCallback(
- (yAxes: readonly string[]) => {
- const dedupedYAxes = dedupeArray(yAxes);
+ (yAxis: string) => {
+ const dedupedYAxes = [yAxis];
const formattedYAxes = dedupedYAxes.map(yaxis => {
const func = parseFunction(yaxis);
@@ -197,7 +196,7 @@ export function ExploreCharts({
sampleCount,
isSampled,
dataScanned,
- } = getChartInfo(visualize.yAxes);
+ } = getChartInfo(visualize.yAxis);
let overrideSampleCount = undefined;
let overrideIsSampled = undefined;
@@ -208,7 +207,7 @@ export function ExploreCharts({
// When this happens, we override it with the sampling meta
// data from the DEFAULT_VISUALIZATION.
if (sampleCount === 0 && !defined(isSampled)) {
- const chartInfo = getChartInfo([DEFAULT_VISUALIZATION]);
+ const chartInfo = getChartInfo(DEFAULT_VISUALIZATION);
overrideSampleCount = chartInfo.sampleCount;
overrideIsSampled = chartInfo.isSampled;
overrideDataScanned = chartInfo.dataScanned;
@@ -224,7 +223,7 @@ export function ExploreCharts({
chartType: visualize.chartType,
stack: visualize.stack,
label: shouldRenderLabel ? visualize.label : undefined,
- yAxes: visualize.yAxes,
+ yAxes: [visualize.yAxis],
formattedYAxes,
data,
error,
diff --git a/static/app/views/explore/contexts/pageParamsContext/aggregateFields.tsx b/static/app/views/explore/contexts/pageParamsContext/aggregateFields.tsx
index acee0f93d75..9b54534102c 100644
--- a/static/app/views/explore/contexts/pageParamsContext/aggregateFields.tsx
+++ b/static/app/views/explore/contexts/pageParamsContext/aggregateFields.tsx
@@ -18,7 +18,7 @@ export interface GroupBy {
groupBy: string;
}
-function isBaseVisualize(value: any): value is BaseVisualize {
+export function isBaseVisualize(value: any): value is BaseVisualize {
return (
typeof value === 'object' &&
Array.isArray(value.yAxes) &&
@@ -32,7 +32,7 @@ export function isGroupBy(value: any): value is GroupBy {
}
export function isVisualize(value: any): value is Visualize {
- return typeof value === 'object' && 'yAxes' in value && Array.isArray(value.yAxes);
+ return typeof value === 'object' && 'yAxis' in value && typeof value.yAxis === 'string';
}
export type BaseAggregateField = GroupBy | BaseVisualize;
@@ -80,7 +80,7 @@ export function getAggregateFieldsFromLocation(
} else if (isBaseVisualize(groupByOrBaseVisualize)) {
for (const yAxis of groupByOrBaseVisualize.yAxes) {
aggregateFields.push(
- new Visualize([yAxis], {
+ new Visualize(yAxis, {
label: String.fromCharCode(65 + i), // starts from 'A',
chartType: groupByOrBaseVisualize.chartType,
})
@@ -111,11 +111,12 @@ export function updateLocationWithAggregateFields(
aggregateFields: Array | null | undefined
) {
if (defined(aggregateFields)) {
- location.query.aggregateField = aggregateFields.map(aggregateField => {
+ location.query.aggregateField = aggregateFields.flatMap(aggregateField => {
if (isBaseVisualize(aggregateField)) {
- return JSON.stringify(Visualize.fromJSON(aggregateField).toJSON());
+ const visualizes = Visualize.fromJSON(aggregateField);
+ return visualizes.map(visualize => JSON.stringify(visualize.toJSON()));
}
- return JSON.stringify(aggregateField);
+ return [JSON.stringify(aggregateField)];
});
} else if (aggregateFields === null) {
delete location.query.aggregateField;
diff --git a/static/app/views/explore/contexts/pageParamsContext/index.spec.tsx b/static/app/views/explore/contexts/pageParamsContext/index.spec.tsx
index b8779a6188c..e69584725d9 100644
--- a/static/app/views/explore/contexts/pageParamsContext/index.spec.tsx
+++ b/static/app/views/explore/contexts/pageParamsContext/index.spec.tsx
@@ -115,7 +115,7 @@ describe('PageParamsProvider', function () {
sortBys: [{field: 'timestamp', kind: 'desc'}],
aggregateFields: [
{groupBy: ''},
- new Visualize(['count(span.duration)'], {label: 'A'}),
+ new Visualize('count(span.duration)', {label: 'A'}),
],
})
);
@@ -135,7 +135,7 @@ describe('PageParamsProvider', function () {
sortBys: [{field: 'count(span.self_time)', kind: 'asc'}],
aggregateFields: [
{groupBy: 'span.op'},
- new Visualize(['count(span.self_time)'], {
+ new Visualize('count(span.self_time)', {
label: 'A',
chartType: ChartType.AREA,
}),
@@ -158,7 +158,7 @@ describe('PageParamsProvider', function () {
sortBys: [{field: 'count(span.self_time)', kind: 'asc'}],
aggregateFields: [
{groupBy: 'browser.name'},
- new Visualize(['count(span.self_time)'], {
+ new Visualize('count(span.self_time)', {
label: 'A',
chartType: ChartType.AREA,
}),
@@ -181,7 +181,7 @@ describe('PageParamsProvider', function () {
query: '',
sortBys: [{field: 'count(span.self_time)', kind: 'asc'}],
aggregateFields: [
- new Visualize(['count(span.self_time)'], {
+ new Visualize('count(span.self_time)', {
label: 'A',
chartType: ChartType.AREA,
}),
@@ -205,7 +205,7 @@ describe('PageParamsProvider', function () {
sortBys: [{field: 'count(span.self_time)', kind: 'asc'}],
aggregateFields: [
{groupBy: ''},
- new Visualize(['count(span.self_time)'], {
+ new Visualize('count(span.self_time)', {
label: 'A',
chartType: ChartType.AREA,
}),
@@ -228,7 +228,7 @@ describe('PageParamsProvider', function () {
sortBys: [{field: 'count(span.self_time)', kind: 'asc'}],
aggregateFields: [
{groupBy: 'span.op'},
- new Visualize(['count(span.self_time)'], {
+ new Visualize('count(span.self_time)', {
label: 'A',
chartType: ChartType.AREA,
}),
@@ -262,7 +262,7 @@ describe('PageParamsProvider', function () {
sortBys: [{field: 'timestamp', kind: 'desc'}],
aggregateFields: [
{groupBy: ''},
- new Visualize(['count(span.self_time)'], {
+ new Visualize('count(span.self_time)', {
label: 'A',
chartType: ChartType.AREA,
}),
@@ -310,7 +310,7 @@ describe('PageParamsProvider', function () {
{groupBy: 'sdk.version'},
{groupBy: 'span.op'},
{groupBy: ''},
- new Visualize(['count(span.self_time)'], {
+ new Visualize('count(span.self_time)', {
label: 'A',
chartType: ChartType.AREA,
}),
@@ -333,7 +333,7 @@ describe('PageParamsProvider', function () {
sortBys: [{field: 'count(span.self_time)', kind: 'asc'}],
aggregateFields: [
{groupBy: 'span.op'},
- new Visualize(['count(span.self_time)'], {
+ new Visualize('count(span.self_time)', {
label: 'A',
chartType: ChartType.AREA,
}),
@@ -356,7 +356,7 @@ describe('PageParamsProvider', function () {
sortBys: [{field: 'id', kind: 'desc'}],
aggregateFields: [
{groupBy: 'span.op'},
- new Visualize(['count(span.self_time)'], {
+ new Visualize('count(span.self_time)', {
label: 'A',
chartType: ChartType.AREA,
}),
@@ -379,7 +379,7 @@ describe('PageParamsProvider', function () {
sortBys: [{field: 'timestamp', kind: 'desc'}],
aggregateFields: [
{groupBy: 'span.op'},
- new Visualize(['count(span.self_time)'], {
+ new Visualize('count(span.self_time)', {
label: 'A',
chartType: ChartType.AREA,
}),
@@ -412,11 +412,11 @@ describe('PageParamsProvider', function () {
sortBys: [{field: 'max(span.duration)', kind: 'desc'}],
aggregateFields: [
{groupBy: 'span.op'},
- new Visualize(['min(span.self_time)'], {
+ new Visualize('min(span.self_time)', {
label: 'A',
chartType: ChartType.AREA,
}),
- new Visualize(['max(span.duration)'], {
+ new Visualize('max(span.duration)', {
label: 'B',
chartType: ChartType.AREA,
}),
@@ -449,11 +449,11 @@ describe('PageParamsProvider', function () {
sortBys: [{field: 'min(span.self_time)', kind: 'desc'}],
aggregateFields: [
{groupBy: 'span.op'},
- new Visualize(['min(span.self_time)'], {
+ new Visualize('min(span.self_time)', {
label: 'A',
chartType: ChartType.AREA,
}),
- new Visualize(['max(span.duration)'], {
+ new Visualize('max(span.duration)', {
label: 'B',
chartType: ChartType.AREA,
}),
@@ -486,7 +486,7 @@ describe('PageParamsProvider', function () {
sortBys: [{field: 'sdk.name', kind: 'desc'}],
aggregateFields: [
{groupBy: 'sdk.name'},
- new Visualize(['count(span.self_time)'], {
+ new Visualize('count(span.self_time)', {
label: 'A',
chartType: ChartType.AREA,
}),
@@ -519,7 +519,7 @@ describe('PageParamsProvider', function () {
sortBys: [{field: 'count(span.self_time)', kind: 'desc'}],
aggregateFields: [
{groupBy: 'sdk.name'},
- new Visualize(['count(span.self_time)'], {
+ new Visualize('count(span.self_time)', {
label: 'A',
chartType: ChartType.AREA,
}),
@@ -542,7 +542,7 @@ describe('PageParamsProvider', function () {
sortBys: [{field: 'count(span.duration)', kind: 'desc'}],
aggregateFields: [
{groupBy: 'span.op'},
- new Visualize(['count(span.duration)'], {label: 'A'}),
+ new Visualize('count(span.duration)', {label: 'A'}),
],
})
);
@@ -573,15 +573,15 @@ describe('PageParamsProvider', function () {
sortBys: [{field: 'count(span.self_time)', kind: 'asc'}],
aggregateFields: [
{groupBy: 'span.op'},
- new Visualize(['count(span.self_time)'], {
+ new Visualize('count(span.self_time)', {
label: 'A',
chartType: ChartType.AREA,
}),
- new Visualize(['avg(span.duration)'], {
+ new Visualize('avg(span.duration)', {
label: 'B',
chartType: ChartType.LINE,
}),
- new Visualize(['avg(span.self_time)'], {
+ new Visualize('avg(span.self_time)', {
label: 'C',
chartType: ChartType.LINE,
}),
diff --git a/static/app/views/explore/contexts/pageParamsContext/index.tsx b/static/app/views/explore/contexts/pageParamsContext/index.tsx
index 92ffb260410..23ba1283b63 100644
--- a/static/app/views/explore/contexts/pageParamsContext/index.tsx
+++ b/static/app/views/explore/contexts/pageParamsContext/index.tsx
@@ -26,6 +26,7 @@ import type {AggregateField, BaseAggregateField, GroupBy} from './aggregateField
import {
defaultAggregateFields,
getAggregateFieldsFromLocation,
+ isBaseVisualize,
isGroupBy,
isVisualize,
updateLocationWithAggregateFields,
@@ -121,7 +122,7 @@ function defaultPageParams(): ReadablePageParams {
const sortBys = defaultSortBys(
mode,
fields,
- aggregateFields.filter(isVisualize).flatMap(visualize => visualize.yAxes)
+ aggregateFields.filter(isVisualize).map(visualize => visualize.yAxis)
);
return new ReadablePageParams({
@@ -357,7 +358,7 @@ function findAllFieldRefs(
const readableVisualizeFields = readablePageParams.aggregateFields
.filter(isVisualize)
- .flatMap(visualize => visualize.yAxes)
+ .map(visualize => visualize.yAxis)
.map(yAxis => parseFunction(yAxis)?.arguments?.[0])
.filter(defined);
@@ -371,7 +372,7 @@ function findAllFieldRefs(
writablePageParams.aggregateFields === null
? []
: writablePageParams.aggregateFields
- ?.filter(isVisualize)
+ ?.filter(isBaseVisualize)
?.flatMap(visualize => visualize.yAxes)
?.map(yAxis => parseFunction(yAxis)?.arguments?.[0])
?.filter(defined);
diff --git a/static/app/views/explore/contexts/pageParamsContext/sortBys.tsx b/static/app/views/explore/contexts/pageParamsContext/sortBys.tsx
index 6203c8c5935..d35536aeee3 100644
--- a/static/app/views/explore/contexts/pageParamsContext/sortBys.tsx
+++ b/static/app/views/explore/contexts/pageParamsContext/sortBys.tsx
@@ -62,7 +62,7 @@ export function getSortBysFromLocation(
sortBys.every(
sortBy =>
groupBys.includes(sortBy.field) ||
- visualizes.some(visualize => visualize.yAxes.includes(sortBy.field))
+ visualizes.some(visualize => visualize.yAxis === sortBy.field)
)
) {
return sortBys;
@@ -72,7 +72,7 @@ export function getSortBysFromLocation(
return defaultSortBys(
mode,
fields,
- visualizes.flatMap(visualize => visualize.yAxes)
+ visualizes.map(visualize => visualize.yAxis)
);
}
diff --git a/static/app/views/explore/contexts/pageParamsContext/visualizes.spec.tsx b/static/app/views/explore/contexts/pageParamsContext/visualizes.spec.tsx
index fbba976fe64..85dbc9be392 100644
--- a/static/app/views/explore/contexts/pageParamsContext/visualizes.spec.tsx
+++ b/static/app/views/explore/contexts/pageParamsContext/visualizes.spec.tsx
@@ -5,18 +5,12 @@ describe('Visualize', function () {
it.each(['count(span.duration)', 'count_unique(span.op)', 'sum(span.duration)'])(
'defaults to bar charts for %s',
function (yAxis) {
- const visualize = new Visualize([yAxis]);
+ const visualize = new Visualize(yAxis);
expect(visualize.chartType).toEqual(ChartType.BAR);
expect(visualize.stack).toBeDefined();
}
);
- it('uses unstacked bar graph', function () {
- const visualize = new Visualize(['count(span.duration)', 'count_unique(span.op)']);
- expect(visualize.chartType).toEqual(ChartType.BAR);
- expect(visualize.stack).toBeUndefined();
- });
-
it.each([
'avg(span.duration)',
'p50(span.duration)',
@@ -28,72 +22,61 @@ describe('Visualize', function () {
'min(span.duration)',
'max(span.duration)',
])('defaults to bar charts for %s', function (yAxis) {
- const visualize = new Visualize([yAxis]);
+ const visualize = new Visualize(yAxis);
expect(visualize.chartType).toEqual(ChartType.LINE);
expect(visualize.stack).toBeDefined();
});
it('uses selected chart type', function () {
- const visualize = new Visualize(['count(span.duration)'], {
+ const visualize = new Visualize('count(span.duration)', {
chartType: ChartType.AREA,
});
expect(visualize.chartType).toEqual(ChartType.AREA);
expect(visualize.stack).toBeDefined();
});
- it('uses the dominant chart type', function () {
- const visualize = new Visualize([
- 'count(span.duration)',
- 'p50(span.duration)',
- 'p75(span.duration)',
- 'p90(span.duration)',
- ]);
- expect(visualize.chartType).toEqual(ChartType.LINE);
- expect(visualize.stack).toBeDefined();
- });
-
it('clones', function () {
- const vis1 = new Visualize(['count(span.duration)'], {chartType: ChartType.AREA});
+ const vis1 = new Visualize('count(span.duration)', {chartType: ChartType.AREA});
const vis2 = vis1.clone();
expect(vis1).toEqual(vis2);
});
it('replaces yAxes', function () {
- const vis1 = new Visualize(['count(span.duration)'], {chartType: ChartType.AREA});
- const vis2 = vis1.replace({yAxes: ['avg(span.duration)']});
+ const vis1 = new Visualize('count(span.duration)', {chartType: ChartType.AREA});
+ const vis2 = vis1.replace({yAxis: 'avg(span.duration)'});
expect(vis2).toEqual(
- new Visualize(['avg(span.duration)'], {chartType: ChartType.AREA})
+ new Visualize('avg(span.duration)', {chartType: ChartType.AREA})
);
});
it('replaces chart type', function () {
- const vis1 = new Visualize(['count(span.duration)'], {chartType: ChartType.AREA});
+ const vis1 = new Visualize('count(span.duration)', {chartType: ChartType.AREA});
const vis2 = vis1.replace({chartType: ChartType.LINE});
expect(vis2).toEqual(
- new Visualize(['count(span.duration)'], {chartType: ChartType.LINE})
+ new Visualize('count(span.duration)', {chartType: ChartType.LINE})
);
});
it('replaces yAxes and chart type', function () {
- const vis1 = new Visualize(['count(span.duration)'], {chartType: ChartType.AREA});
+ const vis1 = new Visualize('count(span.duration)', {chartType: ChartType.AREA});
const vis2 = vis1.replace({
- yAxes: ['avg(span.duration)'],
+ yAxis: 'avg(span.duration)',
chartType: ChartType.LINE,
});
expect(vis2).toEqual(
- new Visualize(['avg(span.duration)'], {chartType: ChartType.LINE})
+ new Visualize('avg(span.duration)', {chartType: ChartType.LINE})
);
});
it('converts to JSON without chart type', function () {
- const visualize = new Visualize(['count(span.duration)']);
+ const visualize = new Visualize('count(span.duration)');
expect(visualize.toJSON()).toEqual({
yAxes: ['count(span.duration)'],
});
});
it('converts to JSON with chart type', function () {
- const visualize = new Visualize(['count(span.duration)'], {
+ const visualize = new Visualize('count(span.duration)', {
chartType: ChartType.AREA,
});
expect(visualize.toJSON()).toEqual({
@@ -106,7 +89,7 @@ describe('Visualize', function () {
const visualize = Visualize.fromJSON({
yAxes: ['count(span.duration)'],
});
- expect(visualize).toEqual(new Visualize(['count(span.duration)']));
+ expect(visualize).toEqual([new Visualize('count(span.duration)')]);
});
it('converts from JSON with chart type', function () {
@@ -114,8 +97,8 @@ describe('Visualize', function () {
yAxes: ['count(span.duration)'],
chartType: ChartType.AREA,
});
- expect(visualize).toEqual(
- new Visualize(['count(span.duration)'], {chartType: ChartType.AREA})
- );
+ expect(visualize).toEqual([
+ new Visualize('count(span.duration)', {chartType: ChartType.AREA}),
+ ]);
});
});
diff --git a/static/app/views/explore/contexts/pageParamsContext/visualizes.tsx b/static/app/views/explore/contexts/pageParamsContext/visualizes.tsx
index a3b311340ac..f7d99c47c62 100644
--- a/static/app/views/explore/contexts/pageParamsContext/visualizes.tsx
+++ b/static/app/views/explore/contexts/pageParamsContext/visualizes.tsx
@@ -21,7 +21,7 @@ export const DEFAULT_VISUALIZATION_FIELD = ALLOWED_EXPLORE_VISUALIZE_FIELDS[0]!;
export const DEFAULT_VISUALIZATION = `${DEFAULT_VISUALIZATION_AGGREGATE}(${DEFAULT_VISUALIZATION_FIELD})`;
export function defaultVisualizes(): Visualize[] {
- return [new Visualize([DEFAULT_VISUALIZATION], {label: 'A'})];
+ return [new Visualize(DEFAULT_VISUALIZATION, {label: 'A'})];
}
type VisualizeOptions = {
@@ -37,28 +37,27 @@ export interface BaseVisualize {
export class Visualize {
chartType: ChartType;
label: string;
- yAxes: readonly string[];
+ yAxis: string;
stack?: string;
private selectedChartType?: ChartType;
- constructor(yAxes: readonly string[], options?: VisualizeOptions) {
- this.yAxes = yAxes;
+ constructor(yAxis: string, options?: VisualizeOptions) {
+ this.yAxis = yAxis;
this.label = options?.label || '';
this.selectedChartType = options?.chartType;
- this.chartType = this.selectedChartType ?? determineDefaultChartType(this.yAxes);
- this.stack =
- this.chartType === ChartType.BAR && this.yAxes.length > 1 ? undefined : 'all';
+ this.chartType = this.selectedChartType ?? determineDefaultChartType([yAxis]);
+ this.stack = 'all';
}
clone(): Visualize {
- return new Visualize(this.yAxes, {
+ return new Visualize(this.yAxis, {
label: this.label,
chartType: this.selectedChartType,
});
}
- replace({chartType, yAxes}: {chartType?: ChartType; yAxes?: string[]}): Visualize {
- return new Visualize(yAxes ?? this.yAxes, {
+ replace({chartType, yAxis}: {chartType?: ChartType; yAxis?: string}): Visualize {
+ return new Visualize(yAxis ?? this.yAxis, {
label: this.label,
chartType: chartType ?? this.selectedChartType,
});
@@ -66,7 +65,7 @@ export class Visualize {
toJSON(): BaseVisualize {
const json: BaseVisualize = {
- yAxes: this.yAxes,
+ yAxes: [this.yAxis],
};
if (defined(this.selectedChartType)) {
@@ -76,8 +75,10 @@ export class Visualize {
return json;
}
- static fromJSON(json: BaseVisualize): Visualize {
- return new Visualize(json.yAxes, {label: '', chartType: json.chartType});
+ static fromJSON(json: BaseVisualize): Visualize[] {
+ return json.yAxes.map(
+ yAxis => new Visualize(yAxis, {label: '', chartType: json.chartType})
+ );
}
}
@@ -97,7 +98,7 @@ export function getVisualizesFromLocation(
for (const visualize of baseVisualizes) {
for (const yAxis of visualize.yAxes) {
visualizes.push(
- new Visualize([yAxis], {
+ new Visualize(yAxis, {
label: String.fromCharCode(65 + i), // starts from 'A',
chartType: visualize.chartType,
})
diff --git a/static/app/views/explore/hooks/useAddToDashboard.tsx b/static/app/views/explore/hooks/useAddToDashboard.tsx
index bfe7c77d980..6689a5d50e1 100644
--- a/static/app/views/explore/hooks/useAddToDashboard.tsx
+++ b/static/app/views/explore/hooks/useAddToDashboard.tsx
@@ -13,7 +13,6 @@ import {
DisplayType,
WidgetType,
} from 'sentry/views/dashboards/types';
-import {MAX_NUM_Y_AXES} from 'sentry/views/dashboards/widgetBuilder/buildSteps/yAxisStep/yAxisSelector';
import {handleAddQueryToDashboard} from 'sentry/views/discover/utils';
import {
useExploreDataset,
@@ -48,14 +47,14 @@ export function useAddToDashboard() {
const getEventView = useCallback(
(visualizeIndex: number) => {
- const yAxes = visualizes[visualizeIndex]!.yAxes.slice(0, MAX_NUM_Y_AXES);
+ const yAxis = visualizes[visualizeIndex]!.yAxis;
let fields: any;
if (mode === Mode.SAMPLES) {
fields = [];
} else {
fields = [
- ...new Set([...groupBys, ...yAxes, ...sortBys.map(sort => sort.field)]),
+ ...new Set([...groupBys, yAxis, ...sortBys.map(sort => sort.field)]),
].filter(Boolean);
}
@@ -68,7 +67,7 @@ export function useAddToDashboard() {
query: search.formatString(),
version: 2,
dataset,
- yAxis: yAxes,
+ yAxis: [yAxis],
};
const newEventView = EventView.fromNewQueryWithPageFilters(
diff --git a/static/app/views/explore/hooks/useAnalytics.tsx b/static/app/views/explore/hooks/useAnalytics.tsx
index 3b95c44c946..1b734667674 100644
--- a/static/app/views/explore/hooks/useAnalytics.tsx
+++ b/static/app/views/explore/hooks/useAnalytics.tsx
@@ -4,7 +4,6 @@ import * as Sentry from '@sentry/react';
import {defined} from 'sentry/utils';
import {trackAnalytics} from 'sentry/utils/analytics';
import type {LogsAnalyticsPageSource} from 'sentry/utils/analytics/logsAnalyticsEvent';
-import {dedupeArray} from 'sentry/utils/dedupeArray';
import {DiscoverDatasets} from 'sentry/utils/discover/types';
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
import useOrganization from 'sentry/utils/useOrganization';
@@ -187,7 +186,7 @@ function useTrackAnalytics({
result_missing_root: 0,
user_queries: search.formatString(),
user_queries_count: search.tokens.length,
- visualizes,
+ visualizes: visualizes.map(visualize => visualize.toJSON()),
visualizes_count: visualizes.length,
title: title || '',
empty_buckets_percentage: computeEmptyBuckets(visualizes, timeseriesResult.data),
@@ -274,7 +273,7 @@ function useTrackAnalytics({
result_missing_root: resultMissingRoot,
user_queries: search.formatString(),
user_queries_count: search.tokens.length,
- visualizes,
+ visualizes: visualizes.map(visualize => visualize.toJSON()),
visualizes_count: visualizes.length,
title: title || '',
empty_buckets_percentage: computeEmptyBuckets(visualizes, timeseriesResult.data),
@@ -377,8 +376,7 @@ export function useCompareAnalytics({
const query = queryParts.query;
const fields = queryParts.fields;
const visualizes = queryParts.yAxes.map(
- yAxis =>
- new Visualize([yAxis], {label: String(index), chartType: queryParts.chartType})
+ yAxis => new Visualize(yAxis, {label: String(index), chartType: queryParts.chartType})
);
return useTrackAnalytics({
@@ -474,7 +472,7 @@ function computeConfidence(
data: ReturnType['data']
) {
return visualizes.map(visualize => {
- const dedupedYAxes = dedupeArray(visualize.yAxes);
+ const dedupedYAxes = [visualize.yAxis];
const series = dedupedYAxes.flatMap(yAxis => data[yAxis]).filter(defined);
return String(combineConfidenceForSeries(series));
});
@@ -495,7 +493,7 @@ function computeEmptyBuckets(
data: ReturnType['data']
) {
return visualizes.flatMap(visualize => {
- const dedupedYAxes = dedupeArray(visualize.yAxes);
+ const dedupedYAxes = [visualize.yAxis];
return dedupedYAxes
.flatMap(yAxis => data[yAxis])
.filter(defined)
diff --git a/static/app/views/explore/hooks/useExploreAggregatesTable.tsx b/static/app/views/explore/hooks/useExploreAggregatesTable.tsx
index e6d7ac0b911..e35764ec592 100644
--- a/static/app/views/explore/hooks/useExploreAggregatesTable.tsx
+++ b/static/app/views/explore/hooks/useExploreAggregatesTable.tsx
@@ -75,12 +75,10 @@ function useExploreAggregatesTableImp({
}
allFields.push(aggregateField.groupBy);
} else {
- for (const yAxis of aggregateField.yAxes) {
- if (allFields.includes(yAxis)) {
- continue;
- }
- allFields.push(yAxis);
+ if (allFields.includes(aggregateField.yAxis)) {
+ continue;
}
+ allFields.push(aggregateField.yAxis);
}
}
diff --git a/static/app/views/explore/hooks/useExploreTimeseries.tsx b/static/app/views/explore/hooks/useExploreTimeseries.tsx
index b3b923448c2..5cacb39b693 100644
--- a/static/app/views/explore/hooks/useExploreTimeseries.tsx
+++ b/static/app/views/explore/hooks/useExploreTimeseries.tsx
@@ -82,9 +82,7 @@ function useExploreTimeseriesImpl({
return [];
}
- return [...groupBys, ...visualizes.flatMap(visualize => visualize.yAxes)].filter(
- Boolean
- );
+ return [...groupBys, ...visualizes.map(visualize => visualize.yAxis)].filter(Boolean);
}, [mode, groupBys, visualizes]);
const orderby: string | string[] | undefined = useMemo(() => {
@@ -96,7 +94,7 @@ function useExploreTimeseriesImpl({
}, [sortBys]);
const yAxes = useMemo(() => {
- const allYAxes = visualizes.flatMap(visualize => visualize.yAxes);
+ const allYAxes = visualizes.map(visualize => visualize.yAxis);
// injects DEFAULT_VISUALIZATION here as it can be used to populate the
// confidence footer as a fallback
@@ -178,7 +176,7 @@ function _checkCanQueryForMoreData(
isTopN: boolean
) {
return visualizes.some(visualize => {
- const dedupedYAxes = dedupeArray(visualize.yAxes);
+ const dedupedYAxes = [visualize.yAxis];
const series = dedupedYAxes.flatMap(yAxis => data[yAxis]).filter(defined);
const {dataScanned} = determineSeriesSampleCountAndIsSampled(series, isTopN);
return dataScanned === 'partial';
diff --git a/static/app/views/explore/hooks/useTopEvents.tsx b/static/app/views/explore/hooks/useTopEvents.tsx
index b315fac9081..85db0f1b2a7 100644
--- a/static/app/views/explore/hooks/useTopEvents.tsx
+++ b/static/app/views/explore/hooks/useTopEvents.tsx
@@ -3,7 +3,6 @@ import {useMemo} from 'react';
import {
useExploreGroupBys,
useExploreMode,
- useExploreVisualizes,
} from 'sentry/views/explore/contexts/pageParamsContext';
import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode';
@@ -13,7 +12,6 @@ export const TOP_EVENTS_LIMIT = 5;
// This hook always returns 5, which can be misleading, but there's no simple way
// to get the series count without adding more complexity to this hook.
export function useTopEvents(): number | undefined {
- const visualizes = useExploreVisualizes();
const groupBys = useExploreGroupBys();
const mode = useExploreMode();
@@ -25,16 +23,12 @@ export function useTopEvents(): number | undefined {
return undefined;
}
- if (visualizes.some(visualize => visualize.yAxes.length > 1)) {
- return undefined;
- }
-
if (groupBys.every(groupBy => groupBy === '')) {
return undefined;
}
return TOP_EVENTS_LIMIT;
- }, [groupBys, mode, visualizes]);
+ }, [groupBys, mode]);
return topEvents;
}
diff --git a/static/app/views/explore/spans/spansTab.tsx b/static/app/views/explore/spans/spansTab.tsx
index bf55a5eb327..cb22a416ab9 100644
--- a/static/app/views/explore/spans/spansTab.tsx
+++ b/static/app/views/explore/spans/spansTab.tsx
@@ -26,7 +26,6 @@ import type {PageFilters} from 'sentry/types/core';
import type {Organization} from 'sentry/types/organization';
import type {Project} from 'sentry/types/project';
import {defined} from 'sentry/utils';
-import {dedupeArray} from 'sentry/utils/dedupeArray';
import {
type AggregationKey,
ALLOWED_EXPLORE_VISUALIZE_AGGREGATES,
@@ -347,7 +346,7 @@ function SpanTabContentSection({
const confidences = useMemo(
() =>
visualizes.map(visualize => {
- const dedupedYAxes = dedupeArray(visualize.yAxes);
+ const dedupedYAxes = [visualize.yAxis];
const series = dedupedYAxes
.flatMap(yAxis => timeseriesResult.data[yAxis])
.filter(defined);
diff --git a/static/app/views/explore/tables/aggregateColumnEditorModal.spec.tsx b/static/app/views/explore/tables/aggregateColumnEditorModal.spec.tsx
index c6d886927d0..e9380d46a9f 100644
--- a/static/app/views/explore/tables/aggregateColumnEditorModal.spec.tsx
+++ b/static/app/views/explore/tables/aggregateColumnEditorModal.spec.tsx
@@ -70,7 +70,7 @@ describe('AggregateColumnEditorModal', function () {
modalProps => (
{}}
stringTags={stringTags}
numberTags={numberTags}
@@ -98,8 +98,8 @@ describe('AggregateColumnEditorModal', function () {
columns={[
{groupBy: 'geo.country'},
{groupBy: 'geo.region'},
- new Visualize(['count(span.duration)']),
- new Visualize(['avg(span.self_time)']),
+ new Visualize('count(span.duration)'),
+ new Visualize('avg(span.self_time)'),
]}
onColumnsChange={onColumnsChange}
stringTags={stringTags}
@@ -116,8 +116,8 @@ describe('AggregateColumnEditorModal', function () {
expectRows(rows).toHaveAggregateFields([
{groupBy: 'geo.country'},
{groupBy: 'geo.region'},
- new Visualize(['count(span.duration)']),
- new Visualize(['avg(span.self_time)']),
+ new Visualize('count(span.duration)'),
+ new Visualize('avg(span.self_time)'),
]);
await userEvent.click(screen.getAllByLabelText('Remove Column')[0]!);
@@ -125,8 +125,8 @@ describe('AggregateColumnEditorModal', function () {
rows = await screen.findAllByTestId('editor-row');
expectRows(rows).toHaveAggregateFields([
{groupBy: 'geo.region'},
- new Visualize(['count(span.duration)']),
- new Visualize(['avg(span.self_time)']),
+ new Visualize('count(span.duration)'),
+ new Visualize('avg(span.self_time)'),
]);
// only 1 group by remaining, disable the delete option
@@ -137,7 +137,7 @@ describe('AggregateColumnEditorModal', function () {
rows = await screen.findAllByTestId('editor-row');
expectRows(rows).toHaveAggregateFields([
{groupBy: 'geo.region'},
- new Visualize(['avg(span.self_time)']),
+ new Visualize('avg(span.self_time)'),
]);
// 1 group by and visualize remaining so both should be disabled
@@ -148,7 +148,7 @@ describe('AggregateColumnEditorModal', function () {
await userEvent.click(screen.getByRole('button', {name: 'Apply'}));
expect(onColumnsChange).toHaveBeenCalledWith([
{groupBy: 'geo.region'},
- new Visualize(['avg(span.self_time)']),
+ {yAxes: ['avg(span.self_time)']},
]);
});
@@ -162,7 +162,7 @@ describe('AggregateColumnEditorModal', function () {
modalProps => (
(
{
- onColumnsChange(tempColumns);
+ onColumnsChange(tempColumns.map(col => (isVisualize(col) ? col.toJSON() : col)));
closeModal();
}, [closeModal, onColumnsChange, tempColumns]);
@@ -113,7 +113,7 @@ export function AggregateColumnEditorModal({
key: 'add-visualize',
label: t('Visualize / Function'),
details: t('ex. p50(span.duration)'),
- onAction: () => insertColumn(new Visualize([DEFAULT_VISUALIZATION])),
+ onAction: () => insertColumn(new Visualize(DEFAULT_VISUALIZATION)),
},
]}
trigger={triggerProps => (
@@ -274,7 +274,7 @@ function VisualizeSelector({
stringTags,
visualize,
}: VisualizeSelectorProps) {
- const yAxis = visualize.yAxes[0]!;
+ const yAxis = visualize.yAxis;
const parsedFunction = useMemo(() => parseFunction(yAxis), [yAxis]);
const aggregateOptions: Array> = useMemo(() => {
@@ -300,7 +300,7 @@ function VisualizeSelector({
oldAggregate: parsedFunction?.name,
oldArgument: parsedFunction?.arguments[0]!,
});
- onChange(visualize.replace({yAxes: [newYAxis]}));
+ onChange(visualize.replace({yAxis: newYAxis}));
},
[parsedFunction, onChange, visualize]
);
@@ -308,7 +308,7 @@ function VisualizeSelector({
const handleArgumentChange = useCallback(
(option: SelectOption) => {
const newYAxis = `${parsedFunction?.name}(${option.value})`;
- onChange(visualize.replace({yAxes: [newYAxis]}));
+ onChange(visualize.replace({yAxis: newYAxis}));
},
[parsedFunction, onChange, visualize]
);
diff --git a/static/app/views/explore/toolbar/index.spec.tsx b/static/app/views/explore/toolbar/index.spec.tsx
index fa08c35bdef..97b6f5a4b32 100644
--- a/static/app/views/explore/toolbar/index.spec.tsx
+++ b/static/app/views/explore/toolbar/index.spec.tsx
@@ -67,7 +67,7 @@ describe('ExploreToolbar', function () {
const section = screen.getByTestId('section-visualizes');
// this is the default
- expect(visualizes).toEqual([new Visualize(['count(span.duration)'], {label: 'A'})]);
+ expect(visualizes).toEqual([new Visualize('count(span.duration)', {label: 'A'})]);
expect(await within(section).findByRole('button', {name: 'spans'})).toBeDisabled();
});
@@ -90,7 +90,7 @@ describe('ExploreToolbar', function () {
const section = screen.getByTestId('section-visualizes');
// this is the default
- expect(visualizes).toEqual([new Visualize(['count(span.duration)'], {label: 'A'})]);
+ expect(visualizes).toEqual([new Visualize('count(span.duration)', {label: 'A'})]);
// try changing the aggregate
await userEvent.click(within(section).getByRole('button', {name: 'count'}));
@@ -100,12 +100,12 @@ describe('ExploreToolbar', function () {
await userEvent.click(within(section).getByRole('button', {name: 'span.duration'}));
await userEvent.click(within(section).getByRole('option', {name: 'span.self_time'}));
- expect(visualizes).toEqual([new Visualize(['avg(span.self_time)'], {label: 'A'})]);
+ expect(visualizes).toEqual([new Visualize('avg(span.self_time)', {label: 'A'})]);
await userEvent.click(within(section).getByRole('button', {name: 'avg'}));
await userEvent.click(within(section).getByRole('option', {name: 'count'}));
- expect(visualizes).toEqual([new Visualize(['count(span.duration)'], {label: 'A'})]);
+ expect(visualizes).toEqual([new Visualize('count(span.duration)', {label: 'A'})]);
});
it('disables changing visualize fields for epm', async function () {
@@ -126,7 +126,7 @@ describe('ExploreToolbar', function () {
const section = screen.getByTestId('section-visualizes');
// this is the default
- expect(visualizes).toEqual([new Visualize(['count(span.duration)'], {label: 'A'})]);
+ expect(visualizes).toEqual([new Visualize('count(span.duration)', {label: 'A'})]);
// change aggregate to epm
await userEvent.click(within(section).getByRole('button', {name: 'count'}));
@@ -153,7 +153,7 @@ describe('ExploreToolbar', function () {
const section = screen.getByTestId('section-visualizes');
// this is the default
- expect(visualizes).toEqual([new Visualize(['count(span.duration)'], {label: 'A'})]);
+ expect(visualizes).toEqual([new Visualize('count(span.duration)', {label: 'A'})]);
// try changing the aggregate
await userEvent.click(within(section).getByRole('button', {name: 'count'}));
@@ -163,18 +163,18 @@ describe('ExploreToolbar', function () {
await userEvent.click(within(section).getByRole('button', {name: 'span.duration'}));
await userEvent.click(within(section).getByRole('option', {name: 'span.self_time'}));
- expect(visualizes).toEqual([new Visualize(['avg(span.self_time)'], {label: 'A'})]);
+ expect(visualizes).toEqual([new Visualize('avg(span.self_time)', {label: 'A'})]);
await userEvent.click(within(section).getByRole('button', {name: 'avg'}));
await userEvent.click(within(section).getByRole('option', {name: 'epm'}));
- expect(visualizes).toEqual([new Visualize(['epm()'], {label: 'A'})]);
+ expect(visualizes).toEqual([new Visualize('epm()', {label: 'A'})]);
// try changing the aggregate
await userEvent.click(within(section).getByRole('button', {name: 'epm'}));
await userEvent.click(within(section).getByRole('option', {name: 'avg'}));
- expect(visualizes).toEqual([new Visualize(['avg(span.duration)'], {label: 'A'})]);
+ expect(visualizes).toEqual([new Visualize('avg(span.duration)', {label: 'A'})]);
});
it('defaults count_unique argument to span.op', async function () {
@@ -195,13 +195,13 @@ describe('ExploreToolbar', function () {
const section = screen.getByTestId('section-visualizes');
// this is the default
- expect(visualizes).toEqual([new Visualize(['count(span.duration)'], {label: 'A'})]);
+ expect(visualizes).toEqual([new Visualize('count(span.duration)', {label: 'A'})]);
// try changing the aggregate
await userEvent.click(within(section).getByRole('button', {name: 'count'}));
await userEvent.click(within(section).getByRole('option', {name: 'count_unique'}));
- expect(visualizes).toEqual([new Visualize(['count_unique(span.op)'], {label: 'A'})]);
+ expect(visualizes).toEqual([new Visualize('count_unique(span.op)', {label: 'A'})]);
// try changing the aggregate + field
await userEvent.click(within(section).getByRole('button', {name: 'count_unique'}));
@@ -211,13 +211,13 @@ describe('ExploreToolbar', function () {
await userEvent.click(within(section).getByRole('button', {name: 'span.duration'}));
await userEvent.click(within(section).getByRole('option', {name: 'span.self_time'}));
- expect(visualizes).toEqual([new Visualize(['avg(span.self_time)'], {label: 'A'})]);
+ expect(visualizes).toEqual([new Visualize('avg(span.self_time)', {label: 'A'})]);
//
// try changing the aggregate back to count_unique
await userEvent.click(within(section).getByRole('button', {name: 'avg'}));
await userEvent.click(within(section).getByRole('option', {name: 'count_unique'}));
- expect(visualizes).toEqual([new Visualize(['count_unique(span.op)'], {label: 'A'})]);
+ expect(visualizes).toEqual([new Visualize('count_unique(span.op)', {label: 'A'})]);
});
it('allows changing visualizes', async function () {
@@ -240,7 +240,7 @@ describe('ExploreToolbar', function () {
const section = screen.getByTestId('section-visualizes');
// this is the default
- expect(visualizes).toEqual([new Visualize(['count(span.duration)'], {label: 'A'})]);
+ expect(visualizes).toEqual([new Visualize('count(span.duration)', {label: 'A'})]);
expect(fields).toEqual([
'id',
@@ -254,12 +254,12 @@ describe('ExploreToolbar', function () {
// try changing the aggregate
await userEvent.click(within(section).getByRole('button', {name: 'count'}));
await userEvent.click(within(section).getByRole('option', {name: 'avg'}));
- expect(visualizes).toEqual([new Visualize(['avg(span.duration)'], {label: 'A'})]);
+ expect(visualizes).toEqual([new Visualize('avg(span.duration)', {label: 'A'})]);
// try changing the field
await userEvent.click(within(section).getByRole('button', {name: 'span.duration'}));
await userEvent.click(within(section).getByRole('option', {name: 'span.self_time'}));
- expect(visualizes).toEqual([new Visualize(['avg(span.self_time)'], {label: 'A'})]);
+ expect(visualizes).toEqual([new Visualize('avg(span.self_time)', {label: 'A'})]);
expect(fields).toEqual([
'id',
@@ -274,13 +274,13 @@ describe('ExploreToolbar', function () {
// try adding a new chart
await userEvent.click(within(section).getByRole('button', {name: 'Add Chart'}));
expect(visualizes).toEqual([
- new Visualize(['avg(span.self_time)'], {label: 'A'}),
- new Visualize(['count(span.duration)'], {label: 'B'}),
+ new Visualize('avg(span.self_time)', {label: 'A'}),
+ new Visualize('count(span.duration)', {label: 'B'}),
]);
// delete second chart
await userEvent.click(within(section).getAllByLabelText('Remove Overlay')[1]!);
- expect(visualizes).toEqual([new Visualize(['avg(span.self_time)'], {label: 'A'})]);
+ expect(visualizes).toEqual([new Visualize('avg(span.self_time)', {label: 'A'})]);
// only one left so we hide the delete button
expect(within(section).queryByLabelText('Remove Overlay')).not.toBeInTheDocument();
diff --git a/static/app/views/explore/toolbar/toolbarSaveAs.tsx b/static/app/views/explore/toolbar/toolbarSaveAs.tsx
index ced6c765ab9..ddb823bc235 100644
--- a/static/app/views/explore/toolbar/toolbarSaveAs.tsx
+++ b/static/app/views/explore/toolbar/toolbarSaveAs.tsx
@@ -17,7 +17,6 @@ import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import {defined} from 'sentry/utils';
import {trackAnalytics} from 'sentry/utils/analytics';
-import {dedupeArray} from 'sentry/utils/dedupeArray';
import {encodeSort} from 'sentry/utils/discover/eventView';
import {parseFunction, prettifyParsedFunction} from 'sentry/utils/discover/fields';
import {valueIsEqual} from 'sentry/utils/object/valueIsEqual';
@@ -59,7 +58,7 @@ export function ToolbarSaveAs() {
const sortBys = useExploreSortBys();
const mode = useExploreMode();
const id = useExploreId();
- const visualizeYAxes = visualizes.flatMap(v => v.yAxes);
+ const visualizeYAxes = visualizes.map(v => v.yAxis);
const [interval] = useChartInterval();
@@ -151,7 +150,7 @@ export function ToolbarSaveAs() {
const disableAddToDashboard = !organization.features.includes('dashboards-edit');
const chartOptions = visualizes.map((chart, index) => {
- const dedupedYAxes = dedupeArray(chart.yAxes);
+ const dedupedYAxes = [chart.yAxis];
const formattedYAxes = dedupedYAxes.map(yaxis => {
const func = parseFunction(yaxis);
return func ? prettifyParsedFunction(func) : undefined;
diff --git a/static/app/views/explore/toolbar/toolbarSortBy.tsx b/static/app/views/explore/toolbar/toolbarSortBy.tsx
index 81a18f90dbe..dc8fb0d65ee 100644
--- a/static/app/views/explore/toolbar/toolbarSortBy.tsx
+++ b/static/app/views/explore/toolbar/toolbarSortBy.tsx
@@ -37,7 +37,7 @@ export function ToolbarSortBy({
const fieldOptions = useSortByFields({
fields,
- yAxes: visualizes.flatMap(v => v.yAxes),
+ yAxes: visualizes.map(v => v.yAxis),
groupBys,
mode,
});
diff --git a/static/app/views/explore/toolbar/toolbarVisualize.tsx b/static/app/views/explore/toolbar/toolbarVisualize.tsx
index 2853461e6cc..9afdeee7613 100644
--- a/static/app/views/explore/toolbar/toolbarVisualize.tsx
+++ b/static/app/views/explore/toolbar/toolbarVisualize.tsx
@@ -1,4 +1,4 @@
-import {Fragment, useCallback, useMemo} from 'react';
+import {useCallback, useMemo} from 'react';
import styled from '@emotion/styled';
import {Button} from 'sentry/components/core/button';
@@ -38,31 +38,23 @@ export function ToolbarVisualize() {
const setVisualizes = useSetExploreVisualizes();
const addChart = useCallback(() => {
- const newVisualizes = [...visualizes, new Visualize([DEFAULT_VISUALIZATION])].map(
+ const newVisualizes = [...visualizes, new Visualize(DEFAULT_VISUALIZATION)].map(
visualize => visualize.toJSON()
);
setVisualizes(newVisualizes);
}, [setVisualizes, visualizes]);
const deleteOverlay = useCallback(
- (group: number, index: number) => {
+ (group: number) => {
const newVisualizes = visualizes
- .map((visualize, orgGroup) => {
- if (group === orgGroup) {
- visualize = visualize.replace({
- yAxes: visualize.yAxes.filter((_, orgIndex) => index !== orgIndex),
- });
- }
- return visualize.toJSON();
- })
- .filter(visualize => visualize.yAxes.length > 0);
+ .toSpliced(group, 1)
+ .map(visualize => visualize.toJSON());
setVisualizes(newVisualizes);
},
[setVisualizes, visualizes]
);
- const canDelete =
- visualizes.map(visualize => visualize.yAxes.length).reduce((a, b) => a + b, 0) > 1;
+ const canDelete = visualizes.length > 1;
const shouldRenderLabel = visualizes.length > 1;
@@ -80,22 +72,16 @@ export function ToolbarVisualize() {
{visualizes.map((visualize, group) => {
return (
-
- {visualize.yAxes.map((yAxis, index) => (
-
-
-
- ))}
-
+
);
})}
@@ -117,9 +103,8 @@ export function ToolbarVisualize() {
interface VisualizeDropdownProps {
canDelete: boolean;
- deleteOverlay: (group: number, index: number) => void;
+ deleteOverlay: (group: number) => void;
group: number;
- index: number;
setVisualizes: (visualizes: BaseVisualize[]) => void;
visualizes: Visualize[];
yAxis: string;
@@ -130,7 +115,6 @@ function VisualizeDropdown({
canDelete,
deleteOverlay,
group,
- index,
setVisualizes,
visualizes,
yAxis,
@@ -159,17 +143,12 @@ function VisualizeDropdown({
const setYAxis = useCallback(
(newYAxis: string) => {
- const newVisualizes = visualizes.map((visualize, i) => {
- if (i === group) {
- const newYAxes = [...visualize.yAxes];
- newYAxes[index] = newYAxis;
- visualize = visualize.replace({yAxes: newYAxes});
- }
- return visualize.toJSON();
- });
+ const newVisualizes = visualizes
+ .toSpliced(group, 1, visualizes[group]!.replace({yAxis: newYAxis}))
+ .map(visualize => visualize.toJSON());
setVisualizes(newVisualizes);
},
- [group, index, setVisualizes, visualizes]
+ [group, setVisualizes, visualizes]
);
const setChartAggregate = useCallback(
@@ -211,7 +190,7 @@ function VisualizeDropdown({
borderless
icon={ }
size="zero"
- onClick={() => deleteOverlay(group, index)}
+ onClick={() => deleteOverlay(group)}
aria-label={t('Remove Overlay')}
/>
) : null}
diff --git a/static/app/views/explore/utils.spec.tsx b/static/app/views/explore/utils.spec.tsx
index d21e8d3c1bc..e600353a6db 100644
--- a/static/app/views/explore/utils.spec.tsx
+++ b/static/app/views/explore/utils.spec.tsx
@@ -8,7 +8,7 @@ import {findSuggestedColumns, viewSamplesTarget} from 'sentry/views/explore/util
describe('viewSamplesTarget', function () {
const project = ProjectFixture();
const projects = [project];
- const visualize = new Visualize(['count(span.duration)']);
+ const visualize = new Visualize('count(span.duration)');
const sort = {
field: 'count(span.duration)',
kind: 'desc' as const,
diff --git a/static/app/views/explore/utils.tsx b/static/app/views/explore/utils.tsx
index a4617f9d454..f355cdc456d 100644
--- a/static/app/views/explore/utils.tsx
+++ b/static/app/views/explore/utils.tsx
@@ -13,7 +13,6 @@ import type {TagCollection} from 'sentry/types/group';
import type {Confidence, Organization} from 'sentry/types/organization';
import type {Project} from 'sentry/types/project';
import {defined} from 'sentry/utils';
-import {dedupeArray} from 'sentry/utils/dedupeArray';
import {encodeSort} from 'sentry/utils/discover/eventView';
import type {Sort} from 'sentry/utils/discover/fields';
import {parseFunction} from 'sentry/utils/discover/fields';
@@ -283,18 +282,16 @@ export function viewSamplesTarget({
// add all the arguments of the visualizations as columns
for (const visualize of visualizes) {
- for (const yAxis of visualize.yAxes) {
- const parsedFunction = parseFunction(yAxis);
- if (!parsedFunction?.arguments[0]) {
- continue;
- }
- const field = parsedFunction.arguments[0];
- if (seenFields.has(field)) {
- continue;
- }
- fields.push(field);
- seenFields.add(field);
+ const parsedFunction = parseFunction(visualize.yAxis);
+ if (!parsedFunction?.arguments[0]) {
+ continue;
+ }
+ const field = parsedFunction.arguments[0];
+ if (seenFields.has(field)) {
+ continue;
}
+ fields.push(field);
+ seenFields.add(field);
}
// fall back, force timestamp to be a column so we
@@ -439,7 +436,7 @@ export function computeVisualizeSampleTotals(
isTopN: boolean
) {
return visualizes.map(visualize => {
- const dedupedYAxes = dedupeArray(visualize.yAxes);
+ const dedupedYAxes = [visualize.yAxis];
const series = dedupedYAxes.flatMap(yAxis => data[yAxis]).filter(defined);
const {sampleCount} = determineSeriesSampleCountAndIsSampled(series, isTopN);
return sampleCount;
From 9f840496b643d2392584f79c6d94e9c04273113d Mon Sep 17 00:00:00 2001
From: Tony Xiao
Date: Fri, 20 Jun 2025 15:36:46 -0400
Subject: [PATCH 30/32] ref(trace-items): Pull useGetTraceItemAttributeKeys
into hook (#93739)
We'll need the `useGetTraceItemAttributeKeys` hook in other places so
refactoring it so that it can exported.
---
.../traceItemSearchQueryBuilder.tsx | 6 +-
.../hooks/useGetTraceItemAttributeKeys.tsx | 133 ++++++++++++++++++
...> useGetTraceItemAttributeValues.spec.tsx} | 10 +-
...tsx => useGetTraceItemAttributeValues.tsx} | 18 +--
.../hooks/useTraceItemAttributeKeys.tsx | 122 +++++-----------
static/app/views/explore/types.tsx | 17 +++
.../pages/transactionNameSearchBar.tsx | 6 +-
7 files changed, 202 insertions(+), 110 deletions(-)
create mode 100644 static/app/views/explore/hooks/useGetTraceItemAttributeKeys.tsx
rename static/app/views/explore/hooks/{useTraceItemAttributeValues.spec.tsx => useGetTraceItemAttributeValues.spec.tsx} (93%)
rename static/app/views/explore/hooks/{useTraceItemAttributeValues.tsx => useGetTraceItemAttributeValues.tsx} (87%)
diff --git a/static/app/views/explore/components/traceItemSearchQueryBuilder.tsx b/static/app/views/explore/components/traceItemSearchQueryBuilder.tsx
index 4d25d11a44d..43d1863cb69 100644
--- a/static/app/views/explore/components/traceItemSearchQueryBuilder.tsx
+++ b/static/app/views/explore/components/traceItemSearchQueryBuilder.tsx
@@ -8,7 +8,7 @@ import {SavedSearchType, type TagCollection} from 'sentry/types/group';
import type {AggregationKey} from 'sentry/utils/fields';
import {FieldKind, getFieldDefinition} from 'sentry/utils/fields';
import useOrganization from 'sentry/utils/useOrganization';
-import {useTraceItemAttributeValues} from 'sentry/views/explore/hooks/useTraceItemAttributeValues';
+import {useGetTraceItemAttributeValues} from 'sentry/views/explore/hooks/useGetTraceItemAttributeValues';
import {LOGS_FILTER_KEY_SECTIONS} from 'sentry/views/explore/logs/constants';
import {TraceItemDataset} from 'sentry/views/explore/types';
import {SPANS_FILTER_KEY_SECTIONS} from 'sentry/views/insights/constants';
@@ -68,10 +68,8 @@ export function useSearchQueryBuilderProps({
const filterKeySections = useFilterKeySections(itemType, stringAttributes);
const filterTags = useFilterTags(numberAttributes, stringAttributes, functionTags);
- const getTraceItemAttributeValues = useTraceItemAttributeValues({
+ const getTraceItemAttributeValues = useGetTraceItemAttributeValues({
traceItemType: itemType,
- attributeKey: '',
- enabled: true,
type: 'string',
projectIds: projects,
});
diff --git a/static/app/views/explore/hooks/useGetTraceItemAttributeKeys.tsx b/static/app/views/explore/hooks/useGetTraceItemAttributeKeys.tsx
new file mode 100644
index 00000000000..59253cde3d3
--- /dev/null
+++ b/static/app/views/explore/hooks/useGetTraceItemAttributeKeys.tsx
@@ -0,0 +1,133 @@
+import {useCallback} from 'react';
+
+import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
+import type {PageFilters} from 'sentry/types/core';
+import type {Tag, TagCollection} from 'sentry/types/group';
+import {FieldKind} from 'sentry/utils/fields';
+import useApi from 'sentry/utils/useApi';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import type {
+ TraceItemDataset,
+ UseTraceItemAttributeBaseProps,
+} from 'sentry/views/explore/types';
+
+interface UseGetTraceItemAttributeKeysProps extends UseTraceItemAttributeBaseProps {
+ projectIds?: Array;
+}
+
+type TraceItemAttributeKeyOptions = Pick<
+ ReturnType,
+ 'end' | 'start' | 'statsPeriod' | 'utc'
+> & {
+ attributeType: 'string' | 'number';
+ itemType: TraceItemDataset;
+ project?: string[];
+ substringMatch?: string;
+};
+
+export function makeTraceItemAttributeKeysQueryOptions({
+ traceItemType,
+ type,
+ datetime,
+ projectIds,
+ search,
+}: {
+ datetime: PageFilters['datetime'];
+ traceItemType: TraceItemDataset;
+ type: 'string' | 'number';
+ projectIds?: Array;
+ search?: string;
+}): TraceItemAttributeKeyOptions {
+ const options: TraceItemAttributeKeyOptions = {
+ itemType: traceItemType,
+ attributeType: type,
+ project: projectIds?.map(String),
+ substringMatch: search,
+ ...normalizeDateTimeParams(datetime),
+ };
+
+ // environment left out intentionally as it's not supported
+
+ return options;
+}
+
+export function useGetTraceItemAttributeKeys({
+ traceItemType,
+ projectIds,
+ type,
+}: UseGetTraceItemAttributeKeysProps) {
+ const api = useApi();
+ const organization = useOrganization();
+ const {selection} = usePageFilters();
+
+ const getTraceItemAttributeKeys = useCallback(
+ async (queryString?: string): Promise => {
+ const options = makeTraceItemAttributeKeysQueryOptions({
+ traceItemType,
+ type,
+ datetime: selection.datetime,
+ projectIds: projectIds ?? selection.projects,
+ search: queryString,
+ });
+
+ let result: Tag[];
+
+ try {
+ result = await api.requestPromise(
+ `/organizations/${organization.slug}/trace-items/attributes/`,
+ {
+ method: 'GET',
+ query: options,
+ }
+ );
+ } catch (e) {
+ throw new Error(`Unable to fetch trace item attribute keys: ${e}`);
+ }
+
+ const attributes: TagCollection = {};
+
+ for (const attribute of result ?? []) {
+ if (isKnownAttribute(attribute)) {
+ continue;
+ }
+
+ // EAP spans contain tags with illegal characters
+ // SnQL forbids `-` but is allowed in RPC. So add it back later
+ if (
+ !/^[a-zA-Z0-9_.:-]+$/.test(attribute.key) &&
+ !/^tags\[[a-zA-Z0-9_.:-]+,number\]$/.test(attribute.key)
+ ) {
+ continue;
+ }
+
+ attributes[attribute.key] = {
+ key: attribute.key,
+ name: attribute.name,
+ kind: type === 'number' ? FieldKind.MEASUREMENT : FieldKind.TAG,
+ };
+ }
+
+ return attributes;
+ },
+ [api, organization, selection, traceItemType, projectIds, type]
+ );
+
+ return getTraceItemAttributeKeys;
+}
+
+function isKnownAttribute(attribute: Tag) {
+ // For now, skip all the sentry. prefixed attributes as they
+ // should be covered by the static attributes that will be
+ // merged with these results.
+
+ // For logs we include sentry.message.* since it contains params etc.
+ if (
+ attribute.key.startsWith('sentry.message.') ||
+ attribute.key.startsWith('tags[sentry.message.')
+ ) {
+ return false;
+ }
+
+ return attribute.key.startsWith('sentry.') || attribute.key.startsWith('tags[sentry.');
+}
diff --git a/static/app/views/explore/hooks/useTraceItemAttributeValues.spec.tsx b/static/app/views/explore/hooks/useGetTraceItemAttributeValues.spec.tsx
similarity index 93%
rename from static/app/views/explore/hooks/useTraceItemAttributeValues.spec.tsx
rename to static/app/views/explore/hooks/useGetTraceItemAttributeValues.spec.tsx
index dd02707e0f9..74171aaaeba 100644
--- a/static/app/views/explore/hooks/useTraceItemAttributeValues.spec.tsx
+++ b/static/app/views/explore/hooks/useGetTraceItemAttributeValues.spec.tsx
@@ -10,7 +10,7 @@ import type {Organization} from 'sentry/types/organization';
import {FieldKind} from 'sentry/utils/fields';
import {QueryClientProvider} from 'sentry/utils/queryClient';
import {useLocation} from 'sentry/utils/useLocation';
-import {useTraceItemAttributeValues} from 'sentry/views/explore/hooks/useTraceItemAttributeValues';
+import {useGetTraceItemAttributeValues} from 'sentry/views/explore/hooks/useGetTraceItemAttributeValues';
import {TraceItemDataset} from 'sentry/views/explore/types';
import {OrganizationContext} from 'sentry/views/organizationContext';
@@ -27,7 +27,7 @@ function createWrapper(organization: Organization) {
};
}
-describe('useTraceItemAttributeValues', () => {
+describe('useGetTraceItemAttributeValues', () => {
const organization = OrganizationFixture({slug: 'org-slug'});
const attributeKey = 'test.attribute';
@@ -77,9 +77,8 @@ describe('useTraceItemAttributeValues', () => {
const {result} = renderHook(
() =>
- useTraceItemAttributeValues({
+ useGetTraceItemAttributeValues({
traceItemType: TraceItemDataset.LOGS,
- attributeKey,
type: 'string',
}),
{
@@ -125,9 +124,8 @@ describe('useTraceItemAttributeValues', () => {
const {result} = renderHook(
() =>
- useTraceItemAttributeValues({
+ useGetTraceItemAttributeValues({
traceItemType: TraceItemDataset.LOGS,
- attributeKey,
type: 'number',
}),
{
diff --git a/static/app/views/explore/hooks/useTraceItemAttributeValues.tsx b/static/app/views/explore/hooks/useGetTraceItemAttributeValues.tsx
similarity index 87%
rename from static/app/views/explore/hooks/useTraceItemAttributeValues.tsx
rename to static/app/views/explore/hooks/useGetTraceItemAttributeValues.tsx
index f3dfb23763e..ede1dce2da6 100644
--- a/static/app/views/explore/hooks/useTraceItemAttributeValues.tsx
+++ b/static/app/views/explore/hooks/useGetTraceItemAttributeValues.tsx
@@ -9,8 +9,10 @@ import type {ApiQueryKey} from 'sentry/utils/queryClient';
import useApi from 'sentry/utils/useApi';
import useOrganization from 'sentry/utils/useOrganization';
import usePageFilters from 'sentry/utils/usePageFilters';
-import type {UseTraceItemAttributeBaseProps} from 'sentry/views/explore/hooks/useTraceItemAttributeKeys';
-import type {TraceItemDataset} from 'sentry/views/explore/types';
+import type {
+ TraceItemDataset,
+ UseTraceItemAttributeBaseProps,
+} from 'sentry/views/explore/types';
interface TraceItemAttributeValue {
first_seen: null;
@@ -20,15 +22,9 @@ interface TraceItemAttributeValue {
value: string;
}
-interface UseTraceItemAttributeValuesProps extends UseTraceItemAttributeBaseProps {
- /**
- * The attribute key for which to fetch values
- */
- attributeKey: string;
+interface UseGetTraceItemAttributeValuesProps extends UseTraceItemAttributeBaseProps {
datetime?: PageFilters['datetime'];
- enabled?: boolean;
projectIds?: PageFilters['projects'];
- search?: string;
}
function traceItemAttributeValuesQueryKey({
@@ -79,12 +75,12 @@ function traceItemAttributeValuesQueryKey({
* Hook to fetch trace item attribute values for the Explore interface.
* This is designed to be used with the organization_trace_item_attributes endpoint.
*/
-export function useTraceItemAttributeValues({
+export function useGetTraceItemAttributeValues({
traceItemType,
projectIds,
datetime,
type = 'string',
-}: UseTraceItemAttributeValuesProps) {
+}: UseGetTraceItemAttributeValuesProps) {
const api = useApi();
const organization = useOrganization();
const {selection} = usePageFilters();
diff --git a/static/app/views/explore/hooks/useTraceItemAttributeKeys.tsx b/static/app/views/explore/hooks/useTraceItemAttributeKeys.tsx
index a553a89def5..abc63344196 100644
--- a/static/app/views/explore/hooks/useTraceItemAttributeKeys.tsx
+++ b/static/app/views/explore/hooks/useTraceItemAttributeKeys.tsx
@@ -1,30 +1,15 @@
import {useMemo} from 'react';
-import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
-import type {Tag, TagCollection} from 'sentry/types/group';
-import type {Project} from 'sentry/types/project';
+import type {TagCollection} from 'sentry/types/group';
import {defined} from 'sentry/utils';
-import {FieldKind} from 'sentry/utils/fields';
-import {useApiQuery} from 'sentry/utils/queryClient';
-import useOrganization from 'sentry/utils/useOrganization';
+import {useQuery} from 'sentry/utils/queryClient';
import usePageFilters from 'sentry/utils/usePageFilters';
import usePrevious from 'sentry/utils/usePrevious';
-import type {TraceItemDataset} from 'sentry/views/explore/types';
-
-export interface UseTraceItemAttributeBaseProps {
- /**
- * The trace item type supported by the endpoint, currently only supports LOGS.
- */
- traceItemType: TraceItemDataset;
- /**
- * The attribute type supported by the endpoint, currently only supports string and number.
- */
- type: 'number' | 'string';
- /**
- * Optional list of projects to search. If not provided, it'll use the page filters.
- */
- projects?: Project[];
-}
+import {
+ makeTraceItemAttributeKeysQueryOptions,
+ useGetTraceItemAttributeKeys,
+} from 'sentry/views/explore/hooks/useGetTraceItemAttributeKeys';
+import type {UseTraceItemAttributeBaseProps} from 'sentry/views/explore/types';
interface UseTraceItemAttributeKeysProps extends UseTraceItemAttributeBaseProps {
enabled?: boolean;
@@ -36,76 +21,43 @@ export function useTraceItemAttributeKeys({
traceItemType,
projects,
}: UseTraceItemAttributeKeysProps) {
- const organization = useOrganization();
const {selection} = usePageFilters();
- const path = `/organizations/${organization.slug}/trace-items/attributes/`;
- const endpointOptions = {
- query: {
- project: defined(projects)
- ? projects.map(project => project.id)
- : selection.projects,
- environment: selection.environments,
- ...normalizeDateTimeParams(selection.datetime),
- itemType: traceItemType,
- attributeType: type,
- },
- };
+ const projectIds = defined(projects)
+ ? projects.map(project => project.id)
+ : selection.projects;
+
+ const queryOptions = useMemo(() => {
+ return makeTraceItemAttributeKeysQueryOptions({
+ traceItemType,
+ type,
+ datetime: selection.datetime,
+ projectIds,
+ });
+ }, [selection, traceItemType, type, projectIds]);
+
+ const queryKey = useMemo(
+ () => ['use-trace-item-attribute-keys', queryOptions],
+ [queryOptions]
+ );
+
+ const getTraceItemAttributeKeys = useGetTraceItemAttributeKeys({
+ traceItemType,
+ type,
+ projectIds,
+ });
- const result = useApiQuery([path, endpointOptions], {
+ const {data, isFetching, error} = useQuery({
enabled,
- staleTime: 0,
- refetchOnWindowFocus: false,
- retry: false,
+ queryKey,
+ queryFn: () => getTraceItemAttributeKeys(),
});
- const attributes: TagCollection = useMemo(() => {
- const allAttributes: TagCollection = {};
-
- for (const attribute of result.data ?? []) {
- if (isKnownAttribute(attribute)) {
- continue;
- }
-
- // EAP spans contain tags with illegal characters
- // SnQL forbids `-` but is allowed in RPC. So add it back later
- if (
- !/^[a-zA-Z0-9_.:-]+$/.test(attribute.key) &&
- !/^tags\[[a-zA-Z0-9_.:-]+,number\]$/.test(attribute.key)
- ) {
- continue;
- }
-
- allAttributes[attribute.key] = {
- key: attribute.key,
- name: attribute.name,
- kind: type === 'number' ? FieldKind.MEASUREMENT : FieldKind.TAG,
- };
- }
-
- return allAttributes;
- }, [result.data, type]);
-
- const previousAttributes = usePrevious(attributes, result.isLoading);
+ const previous = usePrevious(data, isFetching);
return {
- attributes: result.isLoading ? previousAttributes : attributes,
- isLoading: result.isLoading,
+ attributes: isFetching ? previous : data,
+ error,
+ isLoading: isFetching,
};
}
-
-function isKnownAttribute(attribute: Tag) {
- // For now, skip all the sentry. prefixed attributes as they
- // should be covered by the static attributes that will be
- // merged with these results.
-
- // For logs we include sentry.message.* since it contains params etc.
- if (
- attribute.key.startsWith('sentry.message.') ||
- attribute.key.startsWith('tags[sentry.message.')
- ) {
- return false;
- }
-
- return attribute.key.startsWith('sentry.') || attribute.key.startsWith('tags[sentry.');
-}
diff --git a/static/app/views/explore/types.tsx b/static/app/views/explore/types.tsx
index 81d713c4d9e..130b6920656 100644
--- a/static/app/views/explore/types.tsx
+++ b/static/app/views/explore/types.tsx
@@ -1,4 +1,21 @@
+import type {Project} from 'sentry/types/project';
+
export enum TraceItemDataset {
LOGS = 'logs',
SPANS = 'spans',
}
+
+export interface UseTraceItemAttributeBaseProps {
+ /**
+ * The trace item type supported by the endpoint, currently only supports LOGS.
+ */
+ traceItemType: TraceItemDataset;
+ /**
+ * The attribute type supported by the endpoint, currently only supports string and number.
+ */
+ type: 'number' | 'string';
+ /**
+ * Optional list of projects to search. If not provided, it'll use the page filters.
+ */
+ projects?: Project[];
+}
diff --git a/static/app/views/insights/pages/transactionNameSearchBar.tsx b/static/app/views/insights/pages/transactionNameSearchBar.tsx
index ce6597c3145..0c333c623c6 100644
--- a/static/app/views/insights/pages/transactionNameSearchBar.tsx
+++ b/static/app/views/insights/pages/transactionNameSearchBar.tsx
@@ -16,7 +16,7 @@ import {MutableSearch} from 'sentry/utils/tokenizeSearch';
import {useNavigate} from 'sentry/utils/useNavigate';
import useOnClickOutside from 'sentry/utils/useOnClickOutside';
import usePageFilters from 'sentry/utils/usePageFilters';
-import {useTraceItemAttributeValues} from 'sentry/views/explore/hooks/useTraceItemAttributeValues';
+import {useGetTraceItemAttributeValues} from 'sentry/views/explore/hooks/useGetTraceItemAttributeValues';
import {TraceItemDataset} from 'sentry/views/explore/types';
import {useDomainViewFilters} from 'sentry/views/insights/pages/useFilters';
import {SpanFields} from 'sentry/views/insights/types';
@@ -51,10 +51,8 @@ export function TransactionNameSearchBar(props: SearchBarProps) {
} = usePageFilters();
useOnClickOutside(containerRef, useCallback(closeDropdown, []));
- const getTraceItemAttributeValues = useTraceItemAttributeValues({
+ const getTraceItemAttributeValues = useGetTraceItemAttributeValues({
traceItemType: TraceItemDataset.SPANS,
- attributeKey: 'transaction',
- enabled: true,
type: 'string',
});
From c9fee3fa2f4a55aaf11119852eafd563672e6f91 Mon Sep 17 00:00:00 2001
From: Matt Duncan <14761+mrduncan@users.noreply.github.com>
Date: Fri, 20 Jun 2025 13:20:12 -0700
Subject: [PATCH 31/32] chore(issues): Remove traceparent experiment (#93942)
- https://github.com/getsentry/sentry/pull/93894 removed usage
- https://github.com/getsentry/sentry-options-automator/pull/4243
removed the last override
---
src/sentry/options/defaults.py | 5 -----
1 file changed, 5 deletions(-)
diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py
index 98d4051a870..a48369b5a9d 100644
--- a/src/sentry/options/defaults.py
+++ b/src/sentry/options/defaults.py
@@ -2624,11 +2624,6 @@
default=0.0,
flags=FLAG_ADMIN_MODIFIABLE | FLAG_AUTOMATOR_MODIFIABLE | FLAG_RATE,
)
-register(
- "grouping.experiments.parameterization.traceparent",
- default=0.0,
- flags=FLAG_ADMIN_MODIFIABLE | FLAG_AUTOMATOR_MODIFIABLE | FLAG_RATE,
-)
# TODO: For now, only a small number of projects are going through a grouping config transition at
# any given time, so we're sampling at 100% in order to be able to get good signal. Once we've fully
From ea188e2d736fd6ed27c1dba8aae63b7268e2a7a9 Mon Sep 17 00:00:00 2001
From: Colton Allen
Date: Fri, 20 Jun 2025 15:36:01 -0500
Subject: [PATCH 32/32] fix(replay): Add handling for null max_segment_id
(#93989)
When the max segment ID is null the process fails. We should exit early
since if there aren't any segments to delete there's nothing to do.
---
src/sentry/replays/usecases/delete.py | 7 ++++++-
tests/sentry/replays/tasks/test_delete_replays_bulk.py | 2 +-
2 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/src/sentry/replays/usecases/delete.py b/src/sentry/replays/usecases/delete.py
index a0ee4783db1..91b7ed40590 100644
--- a/src/sentry/replays/usecases/delete.py
+++ b/src/sentry/replays/usecases/delete.py
@@ -84,6 +84,11 @@ def _delete_if_exists(filename: str) -> None:
def _make_recording_filenames(project_id: int, row: MatchedRow) -> list[str]:
+ # Null segment_ids can cause this to fail. If no segments were ingested then we can skip
+ # deleting the segements.
+ if row["max_segment_id"] is None:
+ return []
+
# We assume every segment between 0 and the max_segment_id exists. Its a waste of time to
# delete a non-existent segment but its not so significant that we'd want to query ClickHouse
# to verify it exists.
@@ -104,7 +109,7 @@ def _make_recording_filenames(project_id: int, row: MatchedRow) -> list[str]:
class MatchedRow(TypedDict):
retention_days: int
replay_id: str
- max_segment_id: int
+ max_segment_id: int | None
platform: str
diff --git a/tests/sentry/replays/tasks/test_delete_replays_bulk.py b/tests/sentry/replays/tasks/test_delete_replays_bulk.py
index 91900904daa..9198556ac47 100644
--- a/tests/sentry/replays/tasks/test_delete_replays_bulk.py
+++ b/tests/sentry/replays/tasks/test_delete_replays_bulk.py
@@ -94,7 +94,7 @@ def test_run_bulk_replay_delete_job_completion(self, mock_delete_matched_rows, m
{
"retention_days": 90,
"replay_id": "b",
- "max_segment_id": 0,
+ "max_segment_id": None,
"platform": "javascript",
},
],