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 ( - + ) : ( - - + + SENTRY_PREVENT_TOKEN {TRUNCATED_TOKEN} - + - + ) ) : (