From 80847f922503f4cb114334423df752ae34b57ba9 Mon Sep 17 00:00:00 2001 From: Hector Dearman Date: Thu, 28 May 2026 11:17:03 +0100 Subject: [PATCH 01/14] ref(autofix): Remove the organizations:autofix-on-explorer feature flag (#116165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Third in a stack (follow-up to #116162 and #116164). The `organizations:autofix-on-explorer` flag is now true everywhere, so this PR removes it. - Drop the flag registration from `temporary.py`. - Collapse every `features.has("organizations:autofix-on-explorer", ...)` check to the True branch: - `search/snuba/backend.py` — always use the `seer_explorer_autofix_last_triggered` column - `seer/autofix/issue_summary.py` — always route to `trigger_autofix_agent`; remove the legacy fallback - `seer/agent/client_utils.py` — gate only on `organizations:seer-explorer` (collapse the dual-flag `batch_has` to a single `features.has`) - `seer/entrypoints/operator.py` — `trigger_autofix` always calls the agent path; `trigger_handoff` always fetches via `fetch_run_status` - Delete the `trigger_autofix_legacy` method on `SeerAutofixOperator` (no callers remain) and its dead legacy imports. - Tests: drop the legacy-path tests in `test_operator.py`, rename the no-longer-distinguished `explorer` tests, and de-flag the remaining tests. Net: 9 files changed, 42 insertions, 667 deletions. Agent transcript: https://claudescope.sentry.dev/share/XIAoRxeHQ-AT-i3eUi_fVXryk-kKsNjn_0qaxBkUXqE --- src/sentry/features/temporary.py | 2 - src/sentry/search/snuba/backend.py | 8 +- src/sentry/seer/agent/client_utils.py | 19 +- src/sentry/seer/autofix/issue_summary.py | 39 +- src/sentry/seer/entrypoints/operator.py | 275 +------------ .../test_organization_group_index.py | 22 +- tests/sentry/seer/agent/test_client_utils.py | 23 +- .../seer/entrypoints/slack/test_tasks.py | 1 - .../sentry/seer/entrypoints/test_operator.py | 379 +----------------- 9 files changed, 42 insertions(+), 726 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 25c6aa355cbe8e..3bd5327635ed7b 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -289,8 +289,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:seer-wizard", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable the Seer issues view manager.add("organizations:seer-issue-view", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enable Autofix to use Seer Agent instead of legacy Celery pipeline - manager.add("organizations:autofix-on-explorer", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable autofix introspection for early stopping of autofix runs manager.add("organizations:seer-autofix-introspection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable Seer Workflows in Slack (released, kept until overrides are removed) diff --git a/src/sentry/search/snuba/backend.py b/src/sentry/search/snuba/backend.py index 5d54321b08d33a..79f0ea594fafcf 100644 --- a/src/sentry/search/snuba/backend.py +++ b/src/sentry/search/snuba/backend.py @@ -12,7 +12,7 @@ from django.utils import timezone from django.utils.functional import SimpleLazyObject -from sentry import features, quotas +from sentry import quotas from sentry.api.event_search import SearchFilter from sentry.db.models.manager.base_query_set import BaseQuerySet from sentry.exceptions import InvalidSearchQuery @@ -595,11 +595,7 @@ def _get_queryset_conditions( "issue.type": QCallbackCondition(lambda types: Q(type__in=types)), "issue.priority": QCallbackCondition(lambda priorities: Q(priority__in=priorities)), "issue.seer_actionability": QCallbackCondition(seer_actionability_filter), - "issue.seer_last_run": ScalarCondition( - "seer_explorer_autofix_last_triggered" - if features.has("organizations:autofix-on-explorer", organization) - else "seer_autofix_last_triggered" - ), + "issue.seer_last_run": ScalarCondition("seer_explorer_autofix_last_triggered"), "issue.id": QCallbackCondition( lambda ids: Q(id__in=[int(v) for v in (ids if isinstance(ids, list) else [ids])]) ), diff --git a/src/sentry/seer/agent/client_utils.py b/src/sentry/seer/agent/client_utils.py index 07b9e3d2eb584d..591297a8312a26 100644 --- a/src/sentry/seer/agent/client_utils.py +++ b/src/sentry/seer/agent/client_utils.py @@ -208,24 +208,7 @@ def has_seer_agent_access_with_detail( if not has_access: return False, error - feature_names = [ - # Access to seer agent - "organizations:seer-explorer", - # Access to seer agent powered autofix - "organizations:autofix-on-explorer", - ] - - batch_features = features.batch_has( - feature_names, - organization=organization, - actor=actor, - ) - - if batch_features is None: - return False, "Feature flag not enabled" - - org_features = batch_features.get(f"organization:{organization.id}", {}) - if not any(bool(org_features.get(feature_name)) for feature_name in feature_names): + if not features.has("organizations:seer-explorer", organization, actor=actor): return False, "Feature flag not enabled" # Check open team membership (the agent requires this for context) diff --git a/src/sentry/seer/autofix/issue_summary.py b/src/sentry/seer/autofix/issue_summary.py index ff6ce342973c88..af3e22daff94cd 100644 --- a/src/sentry/seer/autofix/issue_summary.py +++ b/src/sentry/seer/autofix/issue_summary.py @@ -18,7 +18,7 @@ from sentry.locks import locks from sentry.models.group import Group from sentry.net.http import connection_from_url -from sentry.seer.autofix.autofix import _get_trace_tree_for_event, trigger_legacy_autofix +from sentry.seer.autofix.autofix import _get_trace_tree_for_event from sentry.seer.autofix.autofix_agent import ( AutofixStep, NoSeerQuotaException, @@ -52,7 +52,6 @@ from sentry.taskworker.namespaces import seer_tasks from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser -from sentry.users.services.user.service import user_service from sentry.utils.cache import cache from sentry.utils.locking import UnableToAcquireLock @@ -176,41 +175,17 @@ def _trigger_autofix_task( } ) - user: User | AnonymousUser | RpcUser | None = None - if user_id: - user = user_service.get_user(user_id=user_id) - if user is None: - logger.warning( - "_trigger_autofix_task.user_not_found", - extra={"group_id": group_id, "user_id": user_id}, - ) - user = AnonymousUser() - else: - user = AnonymousUser() - - # Route to agent-based autofix if both feature flags are enabled run_id: int | None = None - if features.has("organizations:autofix-on-explorer", group.organization): - try: - run_id = trigger_autofix_agent( - group=group, - step=AutofixStep.ROOT_CAUSE, - referrer=referrer, - run_id=None, - stopping_point=stopping_point, - ) - except NoSeerQuotaException: - pass - else: - response = trigger_legacy_autofix( + try: + run_id = trigger_autofix_agent( group=group, - event_id=event_id, - user=user, + step=AutofixStep.ROOT_CAUSE, referrer=referrer, - auto_run_source=auto_run_source, + run_id=None, stopping_point=stopping_point, ) - run_id = response.data.get("run_id") + except NoSeerQuotaException: + pass if run_id and SeerAutofixOperator.has_access(organization=group.project.organization): SeerOperatorAutofixCache.migrate(from_group_id=group_id, to_run_id=run_id) diff --git a/src/sentry/seer/entrypoints/operator.py b/src/sentry/seer/entrypoints/operator.py index 2eee7b381a3ba8..739b1eb9033199 100644 --- a/src/sentry/seer/entrypoints/operator.py +++ b/src/sentry/seer/entrypoints/operator.py @@ -1,8 +1,6 @@ import logging from typing import Any -from rest_framework.response import Response - from sentry import features from sentry.constants import DataCategory from sentry.models.activity import Activity @@ -13,20 +11,8 @@ from sentry.seer.agent.client_models import CodingAgentState, SeerRunState from sentry.seer.agent.client_utils import fetch_run_status from sentry.seer.agent.on_completion_hook import AgentOnCompletionHook -from sentry.seer.autofix.autofix import trigger_legacy_autofix, update_legacy_autofix -from sentry.seer.autofix.constants import AutofixReferrer, AutofixStatus -from sentry.seer.autofix.types import ( - AutofixCreatePRPayload, - AutofixSelectRootCausePayload, - AutofixSelectSolutionPayload, -) -from sentry.seer.autofix.utils import ( - AutofixState, - AutofixStoppingPoint, - get_autofix_state, - get_automation_handoff, -) -from sentry.seer.autofix.utils import CodingAgentState as LegacyCodingAgentState +from sentry.seer.autofix.constants import AutofixReferrer +from sentry.seer.autofix.utils import AutofixStoppingPoint, get_automation_handoff from sentry.seer.entrypoints.cache import SeerOperatorAgentCache, SeerOperatorAutofixCache from sentry.seer.entrypoints.metrics import ( SeerOperatorEventLifecycleMetric, @@ -67,7 +53,6 @@ # entrypoint's ability to receive updates from those triggers. So 12 is plenty, even accounting for # incidents, since a run should not take nearly that long to complete. PROCESS_AUTOFIX_TIMEOUT_SECONDS = 60 * 5 # 5 minutes -AUTOFIX_FALLBACK_CAUSE_ID = 0 def has_seer_autofix_entrypoint_access( @@ -149,22 +134,13 @@ def trigger_autofix( instruction: str | None = None, run_id: int | None = None, ) -> None: - if features.has("organizations:autofix-on-explorer", group.organization): - self.trigger_autofix_agent( - group=group, - user=user, - stopping_point=stopping_point, - instruction=instruction, - run_id=run_id, - ) - else: - self.trigger_autofix_legacy( - group=group, - user=user, - stopping_point=stopping_point, - instruction=instruction, - run_id=run_id, - ) + self.trigger_autofix_agent( + group=group, + user=user, + stopping_point=stopping_point, + instruction=instruction, + run_id=run_id, + ) def trigger_autofix_agent( self, @@ -352,17 +328,8 @@ def trigger_handoff( ) try: - coding_agents: list[CodingAgentState] | list[LegacyCodingAgentState] - if features.has("organizations:autofix-on-explorer", group.organization): - agent_state = fetch_run_status(run_id=run_id, organization=group.organization) - coding_agents = list(agent_state.coding_agents.values()) - else: - autofix_state = get_autofix_state( - run_id=run_id, organization_id=group.organization.id - ) - coding_agents = ( - list(autofix_state.coding_agents.values()) if autofix_state else [] - ) + agent_state = fetch_run_status(run_id=run_id, organization=group.organization) + coding_agents: list[CodingAgentState] = list(agent_state.coding_agents.values()) except Exception as e: with SeerOperatorEventLifecycleMetric( interaction_type=SeerOperatorInteractionType.ENTRYPOINT_ON_TRIGGER_HANDOFF_ERROR, @@ -419,162 +386,6 @@ def trigger_handoff( ).capture(): self.entrypoint.on_trigger_handoff_success(run_id=run_id, target=target) - def trigger_autofix_legacy( - self, - *, - group: Group, - user: User | RpcUser, - stopping_point: AutofixStoppingPoint, - instruction: str | None = None, - run_id: int | None = None, - ) -> None: - event_lifecyle = SeerOperatorEventLifecycleMetric( - interaction_type=SeerOperatorInteractionType.OPERATOR_TRIGGER_AUTOFIX, - entrypoint_key=self.entrypoint.key, - ) - - raw_response: Response | None = None - with event_lifecyle.capture() as lifecycle: - lifecycle.add_extras( - { - "group_id": str(group.id), - "user_id": str(user.id), - "stopping_point": str(stopping_point), - } - ) - try: - existing_state = get_autofix_state( - group_id=group.id, organization_id=group.organization.id - ) - except Exception as e: - with SeerOperatorEventLifecycleMetric( - interaction_type=SeerOperatorInteractionType.ENTRYPOINT_ON_TRIGGER_AUTOFIX_ERROR, - entrypoint_key=self.entrypoint.key, - ).capture(): - self.entrypoint.on_trigger_autofix_error( - error="Encountered an error while talking to Seer" - ) - lifecycle.record_failure(failure_reason=e) - return - if existing_state: - stopping_point_step = get_stopping_point_status(stopping_point, existing_state) - lifecycle.add_extras( - { - "existing_run_id": str(existing_state.run_id), - "existing_run_status": str(existing_state.status), - } - ) - # For now, we don't support re-runs over slack -- it causes a confusing UX without - # reliably being able to edit messages. - if stopping_point_step: - with SeerOperatorEventLifecycleMetric( - interaction_type=SeerOperatorInteractionType.ENTRYPOINT_ON_TRIGGER_AUTOFIX_ALREADY_EXISTS, - entrypoint_key=self.entrypoint.key, - ).capture(): - has_complete_stage = ( - False - if stopping_point_step.get("key") - in {"root_cause_analysis_processing", "solution_processing"} - else stopping_point_step.get("status") == AutofixStatus.COMPLETED - ) - self.entrypoint.on_trigger_autofix_already_exists( - run_id=existing_state.run_id, - has_complete_stage=has_complete_stage, - ) - return - - if not run_id: - raw_response = trigger_legacy_autofix( - group=group, - user=user, - referrer=AutofixReferrer.SLACK, - instruction=instruction, - stopping_point=stopping_point, - ) - else: - payload: ( - AutofixSelectRootCausePayload - | AutofixSelectSolutionPayload - | AutofixCreatePRPayload - | None - ) = None - if stopping_point == AutofixStoppingPoint.SOLUTION: - payload = AutofixSelectRootCausePayload( - type="select_root_cause", - cause_id=get_latest_cause_id(existing_state), - ) - elif stopping_point == AutofixStoppingPoint.CODE_CHANGES: - payload = AutofixSelectSolutionPayload(type="select_solution") - elif stopping_point == AutofixStoppingPoint.OPEN_PR: - payload = AutofixCreatePRPayload(type="create_pr") - else: - lifecycle.record_failure(failure_reason="invalid_stopping_point") - with SeerOperatorEventLifecycleMetric( - interaction_type=SeerOperatorInteractionType.ENTRYPOINT_ON_TRIGGER_AUTOFIX_ERROR, - entrypoint_key=self.entrypoint.key, - ).capture(): - self.entrypoint.on_trigger_autofix_error( - error="Invalid stopping point provided" - ) - return - - raw_response = update_legacy_autofix( - organization_id=group.organization.id, - run_id=run_id, - payload=payload, - ) - - error_message = raw_response.data.get("detail") - - # Let the entrypoint signal to the external service that no run was started :/ - if error_message: - lifecycle.record_failure(failure_reason=error_message) - with SeerOperatorEventLifecycleMetric( - interaction_type=SeerOperatorInteractionType.ENTRYPOINT_ON_TRIGGER_AUTOFIX_ERROR, - entrypoint_key=self.entrypoint.key, - ).capture(): - self.entrypoint.on_trigger_autofix_error(error=error_message) - return - - run_id = raw_response.data.get("run_id") if not run_id else run_id - if not run_id: - lifecycle.record_failure(failure_reason="no_run_id") - with SeerOperatorEventLifecycleMetric( - interaction_type=SeerOperatorInteractionType.ENTRYPOINT_ON_TRIGGER_AUTOFIX_ERROR, - entrypoint_key=self.entrypoint.key, - ).capture(): - self.entrypoint.on_trigger_autofix_error(error="An unknown error has occurred") - return - lifecycle.add_extra("run_id", str(run_id)) - - # Let the entrypoint signal to the external service that the run started - with SeerOperatorEventLifecycleMetric( - interaction_type=SeerOperatorInteractionType.ENTRYPOINT_ON_TRIGGER_AUTOFIX_SUCCESS, - entrypoint_key=self.entrypoint.key, - ).capture(): - self.entrypoint.on_trigger_autofix_success(run_id=run_id) - - # Create a cache payload that will be picked up for subsequent updates - with SeerOperatorEventLifecycleMetric( - interaction_type=SeerOperatorInteractionType.ENTRYPOINT_CREATE_AUTOFIX_CACHE_PAYLOAD, - entrypoint_key=self.entrypoint.key, - ).capture(): - cache_payload = self.entrypoint.create_autofix_cache_payload() - - if not cache_payload: - return - cache_result = SeerOperatorAutofixCache.populate_post_autofix_cache( - entrypoint_key=str(self.entrypoint.key), - cache_payload=cache_payload, - run_id=run_id, - ) - lifecycle.add_extras( - { - "cache_key": cache_result["key"], - "cache_source": cache_result["source"], - } - ) - def has_seer_agent_entrypoint_access( *, @@ -879,44 +690,6 @@ def process_autofix_updates( ept_lifecycle.record_failure(failure_reason=e) -def get_stopping_point_status( - stopping_point: AutofixStoppingPoint, autofix_state: AutofixState -) -> dict | None: - """ - Gets the most recent matching step state from a given stopping point. - """ - # The most recent of a repeated step is at the end of the list, that's what we want to surface - steps = reversed(autofix_state.steps) - match stopping_point: - case AutofixStoppingPoint.ROOT_CAUSE: - step = next( - ( - step - for step in steps - if step.get("key") in {"root_cause_analysis", "root_cause_analysis_processing"} - ), - None, - ) - case AutofixStoppingPoint.SOLUTION: - step = next( - (step for step in steps if step.get("key") in {"solution", "solution_processing"}), - None, - ) - case AutofixStoppingPoint.CODE_CHANGES: - step = next((step for step in steps if step.get("key") == "changes"), None) - case AutofixStoppingPoint.OPEN_PR: - step = next( - ( - step - for step in steps - if step.get("key") == "changes" - and any(change.get("pull_request") for change in step.get("changes", [])) - ), - None, - ) - return step - - def get_autofix_explorer_status( stopping_point: AutofixStoppingPoint, autofix_state: SeerRunState ) -> bool | None: @@ -975,32 +748,6 @@ def get_autofix_explorer_status( return None -def get_latest_cause_id(autofix_state: AutofixState | None) -> int: - """ - Gets the latest cause_id from a given autofix state. - """ - if not autofix_state: - return AUTOFIX_FALLBACK_CAUSE_ID - root_cause_step = next( - ( - step - # If there are multiple RCA steps, we want the latest, so we reverse the list - for step in reversed(autofix_state.steps) - if step.get("key") == "root_cause_analysis" - ), - None, - ) - if not root_cause_step: - return AUTOFIX_FALLBACK_CAUSE_ID - - root_causes = root_cause_step.get("causes", []) - if not root_causes: - return AUTOFIX_FALLBACK_CAUSE_ID - - # The most recent cause is at the end of the list - return root_causes[-1].get("id", AUTOFIX_FALLBACK_CAUSE_ID) - - class SeerOperatorCompletionHook(AgentOnCompletionHook): """Completion hook that notifies all entrypoints when a Seer Agent run finishes. diff --git a/tests/sentry/issues/endpoints/test_organization_group_index.py b/tests/sentry/issues/endpoints/test_organization_group_index.py index 7039f5bd06573f..9c438eb823254a 100644 --- a/tests/sentry/issues/endpoints/test_organization_group_index.py +++ b/tests/sentry/issues/endpoints/test_organization_group_index.py @@ -531,7 +531,7 @@ def test_perf_issue(self) -> None: assert response.data[0]["id"] == str(perf_group.id) def test_has_seer_last_run(self) -> None: - """Test filtering issues by whether they have seer_autofix_last_triggered set.""" + """Test filtering issues by whether they have seer_explorer_autofix_last_triggered set.""" event1 = self.store_event( data={ "fingerprint": ["no-seer-group"], @@ -563,29 +563,17 @@ def test_has_seer_last_run(self) -> None: self.login_as(user=self.user) - # Query for issues that have seer_autofix_last_triggered set + # Query for issues that have seer_explorer_autofix_last_triggered set response = self.get_success_response(query="has:issue.seer_last_run") assert len(response.data) == 1 - assert response.data[0]["id"] == str(group_with_legacy_seer.id) + assert response.data[0]["id"] == str(group_with_explorer_seer.id) - # Query for issues that do NOT have seer_autofix_last_triggered set + # Query for issues that do NOT have seer_explorer_autofix_last_triggered set response = self.get_success_response(query="!has:issue.seer_last_run") assert len(response.data) == 2 - assert response.data[0]["id"] == str(group_with_explorer_seer.id) + assert response.data[0]["id"] == str(group_with_legacy_seer.id) assert response.data[1]["id"] == str(group_without_seer.id) - # Query for issues that have seer_explorer_autofix_last_triggered set - with self.feature("organizations:autofix-on-explorer"): - response = self.get_success_response(query="has:issue.seer_last_run") - assert len(response.data) == 1 - assert response.data[0]["id"] == str(group_with_explorer_seer.id) - - # Query for issues that do NOT have seer_explorer_autofix_last_triggered set - response = self.get_success_response(query="!has:issue.seer_last_run") - assert len(response.data) == 2 - assert response.data[0]["id"] == str(group_with_legacy_seer.id) - assert response.data[1]["id"] == str(group_without_seer.id) - def test_lookup_by_event_id(self) -> None: project = self.project project.update_option("sentry:resolve_age", 1) diff --git a/tests/sentry/seer/agent/test_client_utils.py b/tests/sentry/seer/agent/test_client_utils.py index 4b9f2bd7585f9b..a1f9e9c1830791 100644 --- a/tests/sentry/seer/agent/test_client_utils.py +++ b/tests/sentry/seer/agent/test_client_utils.py @@ -32,36 +32,18 @@ def test_hide_ai_features_option_set(self) -> None: result = has_seer_agent_access_with_detail(self.org, self.user) assert result == (False, "AI features are disabled for this organization.") - def test_no_explorer_flags_enabled(self) -> None: + def test_no_explorer_flag_enabled(self) -> None: with self.feature("organizations:gen-ai-features"): result = has_seer_agent_access_with_detail(self.org, self.user) assert result == (False, "Feature flag not enabled") - def test_only_seer_explorer_flag(self) -> None: + def test_seer_explorer_flag_enabled(self) -> None: with self.feature( {"organizations:gen-ai-features": True, "organizations:seer-explorer": True} ): result = has_seer_agent_access_with_detail(self.org, self.user) assert result == (True, None) - def test_only_autofix_on_explorer_flag(self) -> None: - with self.feature( - {"organizations:gen-ai-features": True, "organizations:autofix-on-explorer": True} - ): - result = has_seer_agent_access_with_detail(self.org, self.user) - assert result == (True, None) - - def test_all_explorer_flags_enabled(self) -> None: - with self.feature( - { - "organizations:gen-ai-features": True, - "organizations:seer-explorer": True, - "organizations:autofix-on-explorer": True, - } - ): - result = has_seer_agent_access_with_detail(self.org, self.user) - assert result == (True, None) - def test_allow_joinleave_disabled(self) -> None: self.org.flags.allow_joinleave = False self.org.save() @@ -69,7 +51,6 @@ def test_allow_joinleave_disabled(self) -> None: { "organizations:gen-ai-features": True, "organizations:seer-explorer": True, - "organizations:autofix-on-explorer": True, } ): result = has_seer_agent_access_with_detail(self.org, self.user) diff --git a/tests/sentry/seer/entrypoints/slack/test_tasks.py b/tests/sentry/seer/entrypoints/slack/test_tasks.py index 8f146bf18c1058..b8092855c75bd9 100644 --- a/tests/sentry/seer/entrypoints/slack/test_tasks.py +++ b/tests/sentry/seer/entrypoints/slack/test_tasks.py @@ -41,7 +41,6 @@ _SEER_SLACK_FEATURES = { "organizations:gen-ai-features": True, "organizations:seer-explorer": True, - "organizations:autofix-on-explorer": True, } diff --git a/tests/sentry/seer/entrypoints/test_operator.py b/tests/sentry/seer/entrypoints/test_operator.py index 6cc0fad8f1f15c..e96c3148c06fa9 100644 --- a/tests/sentry/seer/entrypoints/test_operator.py +++ b/tests/sentry/seer/entrypoints/test_operator.py @@ -3,8 +3,6 @@ from typing import Any, TypedDict, cast from unittest.mock import Mock, patch -from rest_framework.response import Response - from fixtures.seer.webhooks import MOCK_RUN_ID from sentry.models.activity import Activity from sentry.models.organization import Organization @@ -16,16 +14,12 @@ RepoPRState, SeerRunState, ) -from sentry.seer.autofix.constants import AutofixReferrer, AutofixStatus +from sentry.seer.autofix.constants import AutofixReferrer from sentry.seer.autofix.utils import ( - AutofixState, AutofixStoppingPoint, CodingAgentProviderType, - CodingAgentStatus, ) -from sentry.seer.autofix.utils import CodingAgentState as LegacyCodingAgentState from sentry.seer.entrypoints.operator import ( - AUTOFIX_FALLBACK_CAUSE_ID, SEER_EVENT_TO_ACTIVITY_TYPE, SeerAgentOperator, SeerAutofixOperator, @@ -113,22 +107,6 @@ def _set_automation_handoff( self.project.update_option("sentry:seer_automation_handoff_target", target.value) self.project.update_option("sentry:seer_automation_handoff_integration_id", 789) - def _build_autofix_state_with_agents( - self, agents: dict[str, LegacyCodingAgentState] - ) -> AutofixState: - return AutofixState( - run_id=MOCK_RUN_ID, - request={ - "organization_id": self.organization.id, - "project_id": self.project.id, - "issue": {"id": self.group.id, "title": "test"}, - "repos": [], - }, - updated_at=datetime.now(), - status=AutofixStatus.PROCESSING, - coding_agents=agents, - ) - @patch("sentry.seer.entrypoints.operator.has_seer_access", return_value=True) def test_has_access_with_seer(self, _mock_has_seer_access): MockNoAccessEntrypoint = Mock(spec=SeerAutofixEntrypoint) @@ -163,234 +141,6 @@ def test_has_access_without_seer(self, _mock_has_seer_access): entrypoint_key=cast(SeerEntrypointKey, entrypoint_key), ) - @patch( - "sentry.seer.entrypoints.operator.update_legacy_autofix", - return_value=Response({"run_id": MOCK_RUN_ID}, status=202), - ) - @patch( - "sentry.seer.entrypoints.operator.trigger_legacy_autofix", - return_value=Response({"run_id": MOCK_RUN_ID}, status=202), - ) - @patch("sentry.seer.entrypoints.operator.get_autofix_state", return_value=None) - def test_trigger_autofix_pathway( - self, - mock_get_autofix_state, - mock_trigger_autofix_helper, - mock_update_autofix_helper, - ): - self.operator.trigger_autofix( - group=self.group, - user=self.user, - stopping_point=AutofixStoppingPoint.ROOT_CAUSE, - ) - assert mock_trigger_autofix_helper.call_count == 1 - assert mock_update_autofix_helper.call_count == 0 - mock_trigger_autofix_helper.reset_mock() - - self.operator.trigger_autofix( - group=self.group, - user=self.user, - stopping_point=AutofixStoppingPoint.SOLUTION, - run_id=MOCK_RUN_ID, - ) - assert mock_trigger_autofix_helper.call_count == 0 - assert mock_update_autofix_helper.call_count == 1 - - @patch( - "sentry.seer.entrypoints.operator.trigger_legacy_autofix", - return_value=Response({"run_id": MOCK_RUN_ID}, status=202), - ) - @patch("sentry.seer.entrypoints.operator.get_autofix_state", return_value=None) - def test_trigger_autofix_success(self, mock_get_autofix_state, mock_trigger_autofix_helper): - self.operator.trigger_autofix( - group=self.group, - user=self.user, - stopping_point=AutofixStoppingPoint.ROOT_CAUSE, - ) - assert mock_trigger_autofix_helper.call_count == 1 - assert self.entrypoint.autofix_errors == [] - assert self.entrypoint.autofix_run_ids == [MOCK_RUN_ID] - - @patch("sentry.seer.entrypoints.operator.trigger_legacy_autofix") - @patch("sentry.seer.entrypoints.operator.get_autofix_state") - def test_trigger_autofix_already_exists( - self, mock_get_autofix_state, mock_trigger_autofix_helper - ): - existing_rca_step_state = { - "key": "root_cause_analysis", - "status": AutofixStatus.COMPLETED, - } - existing_state = AutofixState( - run_id=MOCK_RUN_ID, - request={ - "organization_id": self.organization.id, - "project_id": self.project.id, - "issue": {"id": self.group.id, "title": "test"}, - "repos": [], - }, - updated_at=datetime.now(), - status=AutofixStatus.PROCESSING, - steps=[existing_rca_step_state], - ) - mock_get_autofix_state.return_value = existing_state - - self.operator.trigger_autofix( - group=self.group, - user=self.user, - stopping_point=AutofixStoppingPoint.ROOT_CAUSE, - ) - - mock_trigger_autofix_helper.assert_not_called() - assert self.entrypoint.autofix_already_exists_states == [(existing_state.run_id, True)] - assert self.entrypoint.autofix_run_ids == [] - assert self.entrypoint.autofix_errors == [] - - @patch( - "sentry.seer.entrypoints.operator.trigger_legacy_autofix", - return_value=Response({"run_id": MOCK_RUN_ID}, status=202), - ) - @patch("sentry.seer.entrypoints.operator.get_autofix_state") - def test_trigger_autofix_proceeds_when_completed( - self, mock_get_autofix_state, mock_trigger_autofix_helper - ): - existing_state = AutofixState( - run_id=MOCK_RUN_ID, - request={ - "organization_id": self.organization.id, - "project_id": self.project.id, - "issue": {"id": self.group.id, "title": "test"}, - "repos": [], - }, - updated_at=datetime.now(), - status=AutofixStatus.COMPLETED, - ) - mock_get_autofix_state.return_value = existing_state - - self.operator.trigger_autofix( - group=self.group, - user=self.user, - stopping_point=AutofixStoppingPoint.ROOT_CAUSE, - ) - - mock_trigger_autofix_helper.assert_called_once() - assert self.entrypoint.autofix_already_exists_states == [] - assert self.entrypoint.autofix_run_ids == [MOCK_RUN_ID] - - @patch("sentry.seer.entrypoints.operator.trigger_legacy_autofix") - @patch("sentry.seer.entrypoints.operator.get_autofix_state", return_value=None) - def test_trigger_autofix_error(self, mock_get_autofix_state, mock_trigger_autofix_helper): - mock_trigger_autofix_helper.return_value = Response( - {"detail": "Invalid request"}, status=400 - ) - self.operator.trigger_autofix( - group=self.group, - user=self.user, - stopping_point=AutofixStoppingPoint.ROOT_CAUSE, - ) - mock_trigger_autofix_helper.return_value = Response({"run_id": None}, status=202) - self.operator.trigger_autofix( - group=self.group, - user=self.user, - stopping_point=AutofixStoppingPoint.ROOT_CAUSE, - ) - assert mock_trigger_autofix_helper.call_count == 2 - self.operator.trigger_autofix( - group=self.group, - user=self.user, - stopping_point=AutofixStoppingPoint.ROOT_CAUSE, - run_id=MOCK_RUN_ID, - ) - assert self.entrypoint.autofix_errors == [ - "Invalid request", - "An unknown error has occurred", - "Invalid stopping point provided", - ] - assert self.entrypoint.autofix_run_ids == [] - - @patch("sentry.seer.entrypoints.operator.get_autofix_state", return_value=None) - @patch("sentry.seer.autofix.autofix_agent.trigger_coding_agent_handoff") - def test_trigger_handoff_success(self, mock_trigger_handoff_helper, mock_get_state): - self._set_automation_handoff() - self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) - mock_trigger_handoff_helper.assert_called_once() - assert mock_trigger_handoff_helper.call_args.kwargs["referrer"] == AutofixReferrer.SLACK - assert self.entrypoint.handoff_successes == [ - (MOCK_RUN_ID, CodingAgentProviderType.CURSOR_BACKGROUND_AGENT) - ] - assert self.entrypoint.handoff_already_exists == [] - assert self.entrypoint.handoff_errors == [] - - @patch("sentry.seer.entrypoints.operator.get_autofix_state") - @patch("sentry.seer.autofix.autofix_agent.trigger_coding_agent_handoff") - def test_trigger_handoff_already_exists_running( - self, mock_trigger_handoff_helper, mock_get_state - ): - self._set_automation_handoff() - mock_get_state.return_value = self._build_autofix_state_with_agents( - { - "agent-1": LegacyCodingAgentState( - id="agent-1", - status=CodingAgentStatus.RUNNING, - provider=CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, - name="Cursor", - started_at=datetime.now(), - ) - } - ) - self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) - mock_trigger_handoff_helper.assert_not_called() - assert self.entrypoint.handoff_already_exists == [ - (MOCK_RUN_ID, CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, False) - ] - assert self.entrypoint.handoff_successes == [] - - @patch("sentry.seer.entrypoints.operator.get_autofix_state") - @patch("sentry.seer.autofix.autofix_agent.trigger_coding_agent_handoff") - def test_trigger_handoff_already_exists_completed( - self, mock_trigger_handoff_helper, mock_get_state - ): - self._set_automation_handoff() - mock_get_state.return_value = self._build_autofix_state_with_agents( - { - "agent-1": LegacyCodingAgentState( - id="agent-1", - status=CodingAgentStatus.COMPLETED, - provider=CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, - name="Cursor", - started_at=datetime.now(), - ) - } - ) - self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) - mock_trigger_handoff_helper.assert_not_called() - assert self.entrypoint.handoff_already_exists == [ - (MOCK_RUN_ID, CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, True) - ] - - @patch("sentry.seer.entrypoints.operator.get_autofix_state") - @patch("sentry.seer.autofix.autofix_agent.trigger_coding_agent_handoff") - def test_trigger_handoff_proceeds_when_all_agents_failed( - self, mock_trigger_handoff_helper, mock_get_state - ): - self._set_automation_handoff() - mock_get_state.return_value = self._build_autofix_state_with_agents( - { - "agent-1": LegacyCodingAgentState( - id="agent-1", - status=CodingAgentStatus.FAILED, - provider=CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, - name="Cursor", - started_at=datetime.now(), - ) - } - ) - self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) - mock_trigger_handoff_helper.assert_called_once() - assert self.entrypoint.handoff_successes == [ - (MOCK_RUN_ID, CodingAgentProviderType.CURSOR_BACKGROUND_AGENT) - ] - assert self.entrypoint.handoff_already_exists == [] - @patch("sentry.seer.autofix.autofix_agent.trigger_coding_agent_handoff") def test_trigger_handoff_no_config_is_silent_halt(self, mock_trigger_handoff_helper): self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) @@ -399,29 +149,16 @@ def test_trigger_handoff_no_config_is_silent_halt(self, mock_trigger_handoff_hel assert self.entrypoint.handoff_already_exists == [] assert self.entrypoint.handoff_errors == [] - @patch( - "sentry.seer.entrypoints.operator.get_autofix_state", - side_effect=Exception("seer down"), - ) - @patch("sentry.seer.autofix.autofix_agent.trigger_coding_agent_handoff") - def test_trigger_handoff_state_fetch_error_calls_error_hook( - self, mock_trigger_handoff_helper, mock_get_state - ): - self._set_automation_handoff() - self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) - mock_trigger_handoff_helper.assert_not_called() - assert self.entrypoint.handoff_errors == ["Encountered an error while talking to Seer"] - assert self.entrypoint.handoff_successes == [] - - @patch("sentry.seer.entrypoints.operator.get_autofix_state", return_value=None) + @patch("sentry.seer.entrypoints.operator.fetch_run_status", return_value=None) @patch( "sentry.seer.autofix.autofix_agent.trigger_coding_agent_handoff", side_effect=RuntimeError("boom"), ) def test_trigger_handoff_launch_error_calls_error_hook( - self, mock_trigger_handoff_helper, mock_get_state + self, mock_trigger_handoff_helper, mock_fetch_status ): self._set_automation_handoff() + mock_fetch_status.return_value = self._build_explorer_state_with_agents({}) self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) assert self.entrypoint.handoff_errors == [ "Encountered an error while launching the coding agent" @@ -441,11 +178,10 @@ def _build_explorer_state_with_agents( @patch("sentry.seer.entrypoints.operator.fetch_run_status") @patch("sentry.seer.autofix.autofix_agent.trigger_coding_agent_handoff") - def test_trigger_handoff_explorer_success(self, mock_trigger_handoff_helper, mock_fetch_status): + def test_trigger_handoff_success(self, mock_trigger_handoff_helper, mock_fetch_status): self._set_automation_handoff() mock_fetch_status.return_value = self._build_explorer_state_with_agents({}) - with self.feature("organizations:autofix-on-explorer"): - self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) + self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) mock_trigger_handoff_helper.assert_called_once() assert mock_trigger_handoff_helper.call_args.kwargs["referrer"] == AutofixReferrer.SLACK assert self.entrypoint.handoff_successes == [ @@ -456,7 +192,7 @@ def test_trigger_handoff_explorer_success(self, mock_trigger_handoff_helper, moc @patch("sentry.seer.entrypoints.operator.fetch_run_status") @patch("sentry.seer.autofix.autofix_agent.trigger_coding_agent_handoff") - def test_trigger_handoff_explorer_already_exists_running( + def test_trigger_handoff_already_exists_running( self, mock_trigger_handoff_helper, mock_fetch_status ): self._set_automation_handoff() @@ -471,8 +207,7 @@ def test_trigger_handoff_explorer_already_exists_running( ) } ) - with self.feature("organizations:autofix-on-explorer"): - self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) + self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) mock_trigger_handoff_helper.assert_not_called() assert self.entrypoint.handoff_already_exists == [ (MOCK_RUN_ID, CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, False) @@ -480,7 +215,7 @@ def test_trigger_handoff_explorer_already_exists_running( @patch("sentry.seer.entrypoints.operator.fetch_run_status") @patch("sentry.seer.autofix.autofix_agent.trigger_coding_agent_handoff") - def test_trigger_handoff_explorer_already_exists_completed( + def test_trigger_handoff_already_exists_completed( self, mock_trigger_handoff_helper, mock_fetch_status ): self._set_automation_handoff() @@ -495,8 +230,7 @@ def test_trigger_handoff_explorer_already_exists_completed( ) } ) - with self.feature("organizations:autofix-on-explorer"): - self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) + self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) mock_trigger_handoff_helper.assert_not_called() assert self.entrypoint.handoff_already_exists == [ (MOCK_RUN_ID, CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, True) @@ -504,7 +238,7 @@ def test_trigger_handoff_explorer_already_exists_completed( @patch("sentry.seer.entrypoints.operator.fetch_run_status") @patch("sentry.seer.autofix.autofix_agent.trigger_coding_agent_handoff") - def test_trigger_handoff_explorer_proceeds_when_all_agents_failed( + def test_trigger_handoff_proceeds_when_all_agents_failed( self, mock_trigger_handoff_helper, mock_fetch_status ): self._set_automation_handoff() @@ -519,8 +253,7 @@ def test_trigger_handoff_explorer_proceeds_when_all_agents_failed( ) } ) - with self.feature("organizations:autofix-on-explorer"): - self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) + self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) mock_trigger_handoff_helper.assert_called_once() assert self.entrypoint.handoff_successes == [ (MOCK_RUN_ID, CodingAgentProviderType.CURSOR_BACKGROUND_AGENT) @@ -532,39 +265,15 @@ def test_trigger_handoff_explorer_proceeds_when_all_agents_failed( side_effect=Exception("seer down"), ) @patch("sentry.seer.autofix.autofix_agent.trigger_coding_agent_handoff") - def test_trigger_handoff_explorer_state_fetch_error_calls_error_hook( + def test_trigger_handoff_state_fetch_error_calls_error_hook( self, mock_trigger_handoff_helper, mock_fetch_status ): self._set_automation_handoff() - with self.feature("organizations:autofix-on-explorer"): - self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) + self.operator.trigger_handoff(group=self.group, run_id=MOCK_RUN_ID) mock_trigger_handoff_helper.assert_not_called() assert self.entrypoint.handoff_errors == ["Encountered an error while talking to Seer"] assert self.entrypoint.handoff_successes == [] - @patch( - "sentry.seer.entrypoints.operator.trigger_legacy_autofix", - return_value=Response({"run_id": MOCK_RUN_ID}, status=202), - ) - @patch("sentry.seer.entrypoints.operator.get_autofix_state", return_value=None) - @patch("sentry.seer.entrypoints.cache.SeerOperatorAutofixCache.populate_post_autofix_cache") - def test_trigger_autofix_creates_cache_payload( - self, - mock_populate_post_autofix_cache, - mock_get_autofix_state, - mock_trigger_autofix_helper, - ): - self.operator.trigger_autofix( - group=self.group, - user=self.user, - stopping_point=AutofixStoppingPoint.ROOT_CAUSE, - ) - mock_populate_post_autofix_cache.assert_called_with( - entrypoint_key=MockAutofixEntrypoint.key, - run_id=MOCK_RUN_ID, - cache_payload=self.entrypoint.create_autofix_cache_payload(), - ) - @patch.object(SeerAutofixOperator, "has_access", return_value=True) @patch.dict( "sentry.seer.entrypoints.operator.autofix_entrypoint_registry.registrations", @@ -693,66 +402,6 @@ def test_process_autofix_updates_skips_entrypoint_without_access( cache_payload=cache_payload, ) - @patch("sentry.seer.entrypoints.operator.update_legacy_autofix") - @patch("sentry.seer.entrypoints.operator.get_autofix_state", return_value=None) - def test_solution_stopping_point_sends_select_root_cause( - self, _mock_get_autofix_state, mock_update_autofix - ): - mock_update_autofix.return_value = Response({"run_id": MOCK_RUN_ID}, status=202) - - self.operator.trigger_autofix( - group=self.group, - user=self.user, - stopping_point=AutofixStoppingPoint.SOLUTION, - run_id=MOCK_RUN_ID, - ) - - mock_update_autofix.assert_called_once() - call_kwargs = mock_update_autofix.call_args.kwargs - assert call_kwargs["organization_id"] == self.group.organization.id - payload = call_kwargs["payload"] - assert payload["type"] == "select_root_cause" - assert payload["cause_id"] == AUTOFIX_FALLBACK_CAUSE_ID - - @patch("sentry.seer.entrypoints.operator.update_legacy_autofix") - @patch("sentry.seer.entrypoints.operator.get_autofix_state") - def test_solution_stopping_point_uses_cause_id_from_state( - self, mock_get_autofix_state, mock_update_autofix - ): - mock_update_autofix.return_value = Response({"run_id": MOCK_RUN_ID}, status=202) - existing_state = AutofixState( - run_id=MOCK_RUN_ID, - request={ - "organization_id": self.organization.id, - "project_id": self.project.id, - "issue": {"id": self.group.id, "title": "test"}, - "repos": [], - }, - updated_at=datetime.now(), - status=AutofixStatus.PROCESSING, - steps=[ - { - "key": "root_cause_analysis", - "status": AutofixStatus.COMPLETED, - "causes": [{"id": 12}, {"id": 34}], - }, - ], - ) - mock_get_autofix_state.return_value = existing_state - - self.operator.trigger_autofix( - group=self.group, - user=self.user, - stopping_point=AutofixStoppingPoint.SOLUTION, - run_id=MOCK_RUN_ID, - ) - - mock_update_autofix.assert_called_once() - call_kwargs = mock_update_autofix.call_args.kwargs - payload = call_kwargs["payload"] - assert payload["type"] == "select_root_cause" - assert payload["cause_id"] == 34 - def test_can_trigger_autofix_returns_false_without_seer_access(self) -> None: assert SeerAutofixOperator.can_trigger_autofix(group=self.group) is False From cd21d1680813e20ca94eb3b2ce3978ad377f7d7f Mon Sep 17 00:00:00 2001 From: Ogi Date: Thu, 28 May 2026 12:24:08 +0200 Subject: [PATCH 02/14] fix(conversations): use 24h statsPeriod on detail page back link (#116361) --- static/app/views/explore/conversations/layout.spec.tsx | 2 +- static/app/views/explore/conversations/layout.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/static/app/views/explore/conversations/layout.spec.tsx b/static/app/views/explore/conversations/layout.spec.tsx index 4e9e15201f3343..ea8dfb61f728fd 100644 --- a/static/app/views/explore/conversations/layout.spec.tsx +++ b/static/app/views/explore/conversations/layout.spec.tsx @@ -45,7 +45,7 @@ describe('ConversationsLayout', () => { expect(within(topBar).getByText('6c5b72fc')).toBeInTheDocument(); expect(within(topBar).getByRole('link', {name: 'Conversations'})).toHaveAttribute( 'href', - `/organizations/${organization.slug}/explore/conversations/?environment=prod&project=1&statsPeriod=7d` + `/organizations/${organization.slug}/explore/conversations/?environment=prod&project=1&statsPeriod=24h` ); }); }); diff --git a/static/app/views/explore/conversations/layout.tsx b/static/app/views/explore/conversations/layout.tsx index 2942daddcdfdaa..c1fe760fafd1a6 100644 --- a/static/app/views/explore/conversations/layout.tsx +++ b/static/app/views/explore/conversations/layout.tsx @@ -87,7 +87,10 @@ function ConversationsHeader() { crumbs={[ { label: CONVERSATIONS_SIDEBAR_LABEL, - to: conversationsBaseUrl, + to: { + pathname: conversationsBaseUrl, + query: {statsPeriod: '24h', start: undefined, end: undefined}, + }, preservePageFilters: true, }, { From 2f00af4578adb08b14ae1390d7ea215af8ec36bc Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Thu, 28 May 2026 12:48:09 +0200 Subject: [PATCH 03/14] ref(forms): Migrate RequestIntegrationModal to TanStack form system (#115990) closes https://linear.app/getsentry/issue/DE-1246/migrate --------- Co-authored-by: Claude Opus 4.7 --- .../RequestIntegrationModal.tsx | 116 ++++++++++-------- 1 file changed, 64 insertions(+), 52 deletions(-) diff --git a/static/app/views/settings/organizationIntegrations/integrationRequest/RequestIntegrationModal.tsx b/static/app/views/settings/organizationIntegrations/integrationRequest/RequestIntegrationModal.tsx index ee0d2c41c8e6cc..d84fda70e0f984 100644 --- a/static/app/views/settings/organizationIntegrations/integrationRequest/RequestIntegrationModal.tsx +++ b/static/app/views/settings/organizationIntegrations/integrationRequest/RequestIntegrationModal.tsx @@ -1,17 +1,18 @@ -import {Fragment, useState} from 'react'; import {useMutation} from '@tanstack/react-query'; +import {z} from 'zod'; -import {Button} from '@sentry/scraps/button'; +import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form'; +import {Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import type {ModalRenderProps} from 'sentry/actionCreators/modal'; -import {TextareaField} from 'sentry/components/forms/fields/textareaField'; import {t} from 'sentry/locale'; import type {IntegrationType} from 'sentry/types/integrations'; import {trackIntegrationAnalytics} from 'sentry/utils/integrationUtil'; -import {useApi} from 'sentry/utils/useApi'; +import {fetchMutation} from 'sentry/utils/queryClient'; import {useOrganization} from 'sentry/utils/useOrganization'; -import {TextBlock} from 'sentry/views/settings/components/text/textBlock'; + type Props = { name: string; onSuccess: () => void; @@ -19,34 +20,40 @@ type Props = { type: IntegrationType; } & ModalRenderProps; +const schema = z.object({ + message: z.string(), +}); + /** * This modal serves as a non-owner's confirmation step before sending * organization owners an email requesting a new organization integration. It * lets the user attach an optional message to be included in the email. */ -export function RequestIntegrationModal(props: Props) { - const [isSending, setIsSending] = useState(false); - const [message, setMessage] = useState(''); +export function RequestIntegrationModal({ + Header, + Body, + Footer, + CloseButton, + name, + slug, + type, + closeModal, + onSuccess, +}: Props) { const organization = useOrganization(); - const api = useApi({persistInFlight: true}); - - const {Header, Body, Footer, CloseButton, name, slug, type, closeModal, onSuccess} = - props; - const endpoint = `/organizations/${organization.slug}/integration-requests/`; const sendRequestMutation = useMutation({ - mutationFn: () => { - return api.requestPromise(endpoint, { + mutationFn: (data: z.infer) => + fetchMutation({ + url: `/organizations/${organization.slug}/integration-requests/`, method: 'POST', data: { providerSlug: slug, providerType: type, - message, + message: data.message, }, - }); - }, + }), onMutate: () => { - setIsSending(true); trackIntegrationAnalytics('integrations.request_install', { integration_type: type, integration: slug, @@ -55,55 +62,60 @@ export function RequestIntegrationModal(props: Props) { }, onSuccess: () => { addSuccessMessage(t('Request successfully sent.')); - setIsSending(false); onSuccess(); closeModal(); }, onError: () => { - addErrorMessage('Error sending the request'); - setIsSending(false); + addErrorMessage(t('Error sending the request')); }, }); - const buttonText = isSending ? t('Sending Request') : t('Send Request'); + const form = useScrapsForm({ + ...defaultFormOptions, + defaultValues: {message: ''}, + validators: {onDynamic: schema}, + onSubmit: ({value}) => sendRequestMutation.mutateAsync(value).catch(() => {}), + }); return ( - +

{t('Request %s Installation', name)}

- - {t( - 'Looks like your organization owner, manager, or admin needs to install %s. Want to send them a request?', - name - )} - - - {t( - '(Optional) You’ve got good reasons for installing the %s Integration. Share them with your organization owner.', - name - )} - - - - {t( - 'When you click “Send Request”, we’ll email your request to your organization’s owners. So just keep that in mind.' - )} - + + + {t( + 'Looks like your organization owner, manager, or admin needs to install %s. Want to send them a request?', + name + )} + + + {t( + 'You’ve got good reasons for installing the %s Integration. Share them with your organization owner.', + name + )} + + + {field => ( + + )} + + + {t( + 'When you click “Send Request”, we’ll email your request to your organization’s owners. So just keep that in mind.' + )} + +
- + {t('Send Request')}
-
+ ); } From b0870c64e314ae9b2b94d1ec29253bc01ff79169 Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Thu, 28 May 2026 13:12:53 +0200 Subject: [PATCH 04/14] feat(dynamic-sampling): add sliding window calculation to per-org (#116083) - Sliding window retrieves the total 24h volume from EAP, extrapolates to the monthly volume, and then gets the sample rate from getsentry's tier system - If available, this sample rate is used as the base for project sample rates for the automatic configurations - Add the sliding window calculation to the new per-org pipeline --- .../per_org/tasks/configuration.py | 43 +++++++++++- .../dynamic_sampling/per_org/tasks/queries.py | 16 +++-- .../per_org/tasks/test_configuration.py | 65 +++++++++++++++++++ .../per_org/tasks/test_queries.py | 23 ++++--- 4 files changed, 133 insertions(+), 14 deletions(-) diff --git a/src/sentry/dynamic_sampling/per_org/tasks/configuration.py b/src/sentry/dynamic_sampling/per_org/tasks/configuration.py index 24c91aace8f12f..5c6ef598d5d083 100644 --- a/src/sentry/dynamic_sampling/per_org/tasks/configuration.py +++ b/src/sentry/dynamic_sampling/per_org/tasks/configuration.py @@ -2,16 +2,20 @@ from abc import ABC, abstractmethod from collections.abc import Mapping +from datetime import timedelta from django.core.exceptions import ObjectDoesNotExist from sentry import options, quotas from sentry.constants import SAMPLING_MODE_DEFAULT, TARGET_SAMPLE_RATE_DEFAULT, ObjectStatus +from sentry.dynamic_sampling.per_org.tasks.queries import get_eap_organization_volume from sentry.dynamic_sampling.per_org.tasks.telemetry import ( DynamicSamplingException, DynamicSamplingStatus, ) from sentry.dynamic_sampling.rules.utils import ProjectId +from sentry.dynamic_sampling.tasks.common import compute_sliding_window_sample_rate +from sentry.dynamic_sampling.tasks.helpers.sliding_window import FALLBACK_SLIDING_WINDOW_SIZE from sentry.dynamic_sampling.types import DynamicSamplingMode, SamplingMeasure from sentry.dynamic_sampling.utils import has_custom_dynamic_sampling from sentry.models.options.project_option import ProjectOption @@ -50,12 +54,17 @@ class BaseDynamicSamplingConfiguration(ABC): def __init__(self, organization: Organization) -> None: self.organization = organization + self.sliding_window_sample_rate: TargetSampleRate = None @property @abstractmethod def is_enabled(self) -> bool: raise NotImplementedError + @abstractmethod + def get_sample_rate(self) -> TargetSampleRate: + raise NotImplementedError + @property def is_span_based(self) -> bool: return self.measure == SamplingMeasure.SPANS @@ -79,12 +88,15 @@ def _get_projects(self) -> list[Project]: class NoDynamicSamplingConfiguration(BaseDynamicSamplingConfiguration): def __init__(self) -> None: - pass + self.sliding_window_sample_rate: TargetSampleRate = None @property def is_enabled(self) -> bool: return False + def get_sample_rate(self) -> TargetSampleRate: + return None + class AutomaticDynamicSamplingConfiguration(BaseDynamicSamplingConfiguration): sample_rate: TargetSampleRate @@ -99,11 +111,34 @@ def __init__(self, organization: Organization) -> None: except ObjectDoesNotExist as exc: raise DynamicSamplingException(DynamicSamplingStatus.NO_SUBSCRIPTION) from exc self.projects = self._get_projects() + self.sliding_window_sample_rate = self._get_sliding_window_sample_rate() @property def is_enabled(self) -> bool: return self.sample_rate is not None + def get_sample_rate(self) -> TargetSampleRate: + if self.sliding_window_sample_rate is not None: + return self.sliding_window_sample_rate + return self.sample_rate + + def _get_sliding_window_sample_rate(self) -> TargetSampleRate: + if not self.projects: + return None + + org_volume_24h = get_eap_organization_volume( + self, time_interval=timedelta(hours=FALLBACK_SLIDING_WINDOW_SIZE) + ) + if org_volume_24h is None: + return None + + return compute_sliding_window_sample_rate( + org_id=self.organization.id, + project_id=None, + total_root_count=org_volume_24h.total, + window_size=FALLBACK_SLIDING_WINDOW_SIZE, + ) + class CustomDynamicSamplingOrganizationConfiguration(BaseDynamicSamplingConfiguration): sample_rate: TargetSampleRate @@ -121,6 +156,9 @@ def __init__(self, organization: Organization) -> None: def is_enabled(self) -> bool: return True + def get_sample_rate(self) -> TargetSampleRate: + return self.sample_rate + class CustomDynamicSamplingProjectConfiguration(BaseDynamicSamplingConfiguration): project_target_sample_rates: ProjectTargetSampleRates @@ -138,6 +176,9 @@ def is_enabled(self) -> bool: sample_rate is not None for sample_rate in self.project_target_sample_rates.values() ) + def get_sample_rate(self) -> TargetSampleRate: + return None + def _get_project_target_sample_rates(self) -> ProjectTargetSampleRates: project_sample_rates = ProjectOption.objects.get_value_bulk( self.projects, "sentry:target_sample_rate" diff --git a/src/sentry/dynamic_sampling/per_org/tasks/queries.py b/src/sentry/dynamic_sampling/per_org/tasks/queries.py index af77a229a97332..7b18516249105a 100644 --- a/src/sentry/dynamic_sampling/per_org/tasks/queries.py +++ b/src/sentry/dynamic_sampling/per_org/tasks/queries.py @@ -5,17 +5,18 @@ from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from enum import StrEnum -from typing import Any, Literal +from typing import Any, Literal, Protocol from sentry_protos.snuba.v1.trace_item_attribute_pb2 import ExtrapolationMode -from sentry.dynamic_sampling.per_org.tasks.configuration import BaseDynamicSamplingConfiguration from sentry.dynamic_sampling.rules.utils import ProjectId from sentry.dynamic_sampling.tasks.boost_low_volume_transactions import ProjectTransactions from sentry.dynamic_sampling.tasks.common import ( ACTIVE_ORGS_VOLUMES_DEFAULT_TIME_INTERVAL, OrganizationDataVolume, ) +from sentry.models.organization import Organization +from sentry.models.project import Project from sentry.search.eap.constants import SAMPLING_MODE_HIGHEST_ACCURACY from sentry.search.eap.types import SearchResolverConfig from sentry.search.events.types import SnubaParams @@ -23,6 +24,11 @@ from sentry.snuba.spans_rpc import Spans +class OrganizationVolumeConfig(Protocol): + organization: Organization + projects: list[Project] + + class DynamicSamplingQueryFilters(StrEnum): IS_SEGMENT = "sentry.is_segment:true" @@ -86,7 +92,7 @@ def run_eap_spans_table_query_in_chunks( def get_eap_organization_volume( - config: BaseDynamicSamplingConfiguration, + config: OrganizationVolumeConfig, time_interval: timedelta = ACTIVE_ORGS_VOLUMES_DEFAULT_TIME_INTERVAL, ) -> OrganizationDataVolume | None: end_time = datetime.now(UTC) @@ -128,7 +134,7 @@ def get_eap_organization_volume( def get_eap_project_volumes( - config: BaseDynamicSamplingConfiguration, + config: OrganizationVolumeConfig, time_interval: timedelta = timedelta(hours=1), ) -> list[ProjectVolume]: end_time = datetime.now(UTC) @@ -177,7 +183,7 @@ def get_eap_project_volumes( def get_eap_transaction_volumes( - config: BaseDynamicSamplingConfiguration, + config: OrganizationVolumeConfig, time_interval: timedelta = ACTIVE_ORGS_VOLUMES_DEFAULT_TIME_INTERVAL, order_by_volume: Literal["asc", "desc"] = "asc", max_transactions: int = 100, diff --git a/tests/sentry/dynamic_sampling/per_org/tasks/test_configuration.py b/tests/sentry/dynamic_sampling/per_org/tasks/test_configuration.py index 3648da8ef7e3a1..b9d2c0d7e647fd 100644 --- a/tests/sentry/dynamic_sampling/per_org/tasks/test_configuration.py +++ b/tests/sentry/dynamic_sampling/per_org/tasks/test_configuration.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +from datetime import timedelta from typing import NamedTuple from unittest.mock import patch @@ -18,6 +19,8 @@ DynamicSamplingException, DynamicSamplingStatus, ) +from sentry.dynamic_sampling.tasks.common import OrganizationDataVolume +from sentry.dynamic_sampling.tasks.helpers.sliding_window import FALLBACK_SLIDING_WINDOW_SIZE from sentry.dynamic_sampling.types import DynamicSamplingMode, SamplingMeasure from sentry.models.organization import Organization from sentry.testutils.cases import TestCase @@ -66,6 +69,66 @@ def test_subscription_backed_org_uses_blended_sample_rate(self) -> None: with pytest.raises(AttributeError): getattr(configuration, "project_target_sample_rates") get_blended_sample_rate.assert_called_once_with(organization_id=org.id) + assert configuration.get_sample_rate() == 0.5 + + def test_subscription_backed_org_uses_eap_sliding_window_sample_rate(self) -> None: + org = self.create_organization() + self.create_project(organization=org) + sliding_window_volume = OrganizationDataVolume(org_id=org.id, total=1000, indexed=250) + + with ( + patch( + "sentry.dynamic_sampling.per_org.tasks.configuration.quotas.backend.get_blended_sample_rate", + return_value=0.5, + ), + patch( + "sentry.dynamic_sampling.per_org.tasks.configuration.get_eap_organization_volume", + return_value=sliding_window_volume, + ) as get_volume, + patch( + "sentry.dynamic_sampling.per_org.tasks.configuration.compute_sliding_window_sample_rate", + return_value=0.25, + ) as compute_sample_rate, + ): + configuration = get_configuration(org.id) + + assert isinstance(configuration, AutomaticDynamicSamplingConfiguration) + assert configuration.get_sample_rate() == 0.25 + get_volume.assert_called_once() + assert get_volume.call_args.kwargs["time_interval"] == timedelta( + hours=FALLBACK_SLIDING_WINDOW_SIZE + ) + compute_sample_rate.assert_called_once_with( + org_id=org.id, + project_id=None, + total_root_count=1000, + window_size=FALLBACK_SLIDING_WINDOW_SIZE, + ) + + def test_subscription_backed_org_falls_back_to_blended_sample_rate_without_volume( + self, + ) -> None: + org = self.create_organization() + self.create_project(organization=org) + + with ( + patch( + "sentry.dynamic_sampling.per_org.tasks.configuration.quotas.backend.get_blended_sample_rate", + return_value=0.5, + ), + patch( + "sentry.dynamic_sampling.per_org.tasks.configuration.get_eap_organization_volume", + return_value=None, + ), + patch( + "sentry.dynamic_sampling.per_org.tasks.configuration.compute_sliding_window_sample_rate", + ) as compute_sample_rate, + ): + configuration = get_configuration(org.id) + + assert isinstance(configuration, AutomaticDynamicSamplingConfiguration) + assert configuration.get_sample_rate() == 0.5 + compute_sample_rate.assert_not_called() def test_subscription_backed_org_without_sample_rate_is_disabled(self) -> None: org = self.create_organization() @@ -142,6 +205,7 @@ def test_org_mode_custom_dynamic_sampling_uses_org_target_sample_rate(self) -> N measure_case.expected_measure == SamplingMeasure.SEGMENTS ) assert configuration.sample_rate == 0.3 + assert configuration.get_sample_rate() == 0.3 with pytest.raises(AttributeError): getattr(configuration, "project_target_sample_rates") get_blended_sample_rate.assert_not_called() @@ -182,6 +246,7 @@ def test_project_mode_custom_dynamic_sampling_stores_project_sample_rates(self) project.id: 0.2, project_without_rate.id: None, } + assert configuration.get_sample_rate() is None with pytest.raises(AttributeError): getattr(configuration, "sample_rate") get_blended_sample_rate.assert_not_called() diff --git a/tests/sentry/dynamic_sampling/per_org/tasks/test_queries.py b/tests/sentry/dynamic_sampling/per_org/tasks/test_queries.py index af2b7a4f30f575..7a0770184c6c16 100644 --- a/tests/sentry/dynamic_sampling/per_org/tasks/test_queries.py +++ b/tests/sentry/dynamic_sampling/per_org/tasks/test_queries.py @@ -84,9 +84,15 @@ def get_config( self, organization: Organization, ) -> BaseDynamicSamplingConfiguration: - with patch( - "sentry.dynamic_sampling.per_org.tasks.configuration.quotas.backend.get_blended_sample_rate", - return_value=1.0, + with ( + patch( + "sentry.dynamic_sampling.per_org.tasks.configuration.quotas.backend.get_blended_sample_rate", + return_value=1.0, + ), + patch( + "sentry.dynamic_sampling.per_org.tasks.configuration.get_eap_organization_volume", + return_value=None, + ), ): return get_configuration(organization.id) @@ -207,8 +213,8 @@ def test_get_eap_project_volumes_without_traffic(self) -> None: self.create_project(organization=organization) with patch( - "sentry.dynamic_sampling.per_org.tasks.queries.Spans.run_table_query", - return_value={"data": []}, + "sentry.dynamic_sampling.per_org.tasks.queries.run_eap_spans_table_query_in_chunks", + return_value=[], ): project_volumes = get_eap_project_volumes( self.get_config(organization), time_interval=timedelta(hours=1) @@ -259,8 +265,8 @@ def test_get_eap_project_volumes_without_projects(self) -> None: organization = self.create_organization() with patch( - "sentry.dynamic_sampling.per_org.tasks.queries.Spans.run_table_query", - return_value={"data": []}, + "sentry.dynamic_sampling.per_org.tasks.queries.run_eap_spans_table_query_in_chunks", + return_value=[], ) as run_table_query: project_volumes = get_eap_project_volumes( self.get_config(organization), time_interval=timedelta(hours=1) @@ -268,7 +274,8 @@ def test_get_eap_project_volumes_without_projects(self) -> None: assert project_volumes == [] run_table_query.assert_called_once() - assert run_table_query.call_args.kwargs["params"].projects == [] + query = run_table_query.call_args.args[0] + assert query["params"].projects == [] class EAPTransactionVolumesTest(TestCase, SnubaTestCase, SpanTestCase): From 61bae642c2aea9cc7f084990f6eaba4af030145b Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Thu, 28 May 2026 13:17:55 +0200 Subject: [PATCH 05/14] chore(dynamic-sampling): with multiple org volumes, make sure their duration is clear in scheduler (#116367) just a variable rename, but important for readability, we're going to query different durations --- src/sentry/dynamic_sampling/per_org/tasks/scheduler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/dynamic_sampling/per_org/tasks/scheduler.py b/src/sentry/dynamic_sampling/per_org/tasks/scheduler.py index 86b92bcaa5de1c..6a710e6178292b 100644 --- a/src/sentry/dynamic_sampling/per_org/tasks/scheduler.py +++ b/src/sentry/dynamic_sampling/per_org/tasks/scheduler.py @@ -108,8 +108,8 @@ def run_calculations_per_org_task(org_id: OrganizationId) -> DynamicSamplingStat if not config.projects: return DynamicSamplingStatus.ORG_HAS_NO_PROJECTS - org_volume = get_eap_organization_volume(config) - if org_volume is None: + org_volume_5m = get_eap_organization_volume(config) + if org_volume_5m is None: return DynamicSamplingStatus.NO_ORG_VOLUME if config.should_balance_projects: From 1ed25cbf0f14fb070cd811b272fa1df183df8dab Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Thu, 28 May 2026 14:53:40 +0200 Subject: [PATCH 06/14] ref(dynamic-sampling): only run sliding window calculations when config is enabled (#116371) - Add an enabled check to the config init to prevent queries from being run if DS is not enabled for this org --- .../dynamic_sampling/per_org/tasks/configuration.py | 2 ++ .../per_org/tasks/test_configuration.py | 13 ++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/sentry/dynamic_sampling/per_org/tasks/configuration.py b/src/sentry/dynamic_sampling/per_org/tasks/configuration.py index 5c6ef598d5d083..a7635681759279 100644 --- a/src/sentry/dynamic_sampling/per_org/tasks/configuration.py +++ b/src/sentry/dynamic_sampling/per_org/tasks/configuration.py @@ -110,6 +110,8 @@ def __init__(self, organization: Organization) -> None: ) except ObjectDoesNotExist as exc: raise DynamicSamplingException(DynamicSamplingStatus.NO_SUBSCRIPTION) from exc + if not self.is_enabled: + return self.projects = self._get_projects() self.sliding_window_sample_rate = self._get_sliding_window_sample_rate() diff --git a/tests/sentry/dynamic_sampling/per_org/tasks/test_configuration.py b/tests/sentry/dynamic_sampling/per_org/tasks/test_configuration.py index b9d2c0d7e647fd..8fda8542883540 100644 --- a/tests/sentry/dynamic_sampling/per_org/tasks/test_configuration.py +++ b/tests/sentry/dynamic_sampling/per_org/tasks/test_configuration.py @@ -132,15 +132,22 @@ def test_subscription_backed_org_falls_back_to_blended_sample_rate_without_volum def test_subscription_backed_org_without_sample_rate_is_disabled(self) -> None: org = self.create_organization() + self.create_project(organization=org) - with patch( - "sentry.dynamic_sampling.per_org.tasks.configuration.quotas.backend.get_blended_sample_rate", - return_value=None, + with ( + patch( + "sentry.dynamic_sampling.per_org.tasks.configuration.quotas.backend.get_blended_sample_rate", + return_value=None, + ), + patch( + "sentry.dynamic_sampling.per_org.tasks.configuration.get_eap_organization_volume", + ) as get_volume, ): configuration = get_configuration(org.id) assert isinstance(configuration, NoDynamicSamplingConfiguration) assert not configuration.is_enabled + get_volume.assert_not_called() with pytest.raises(AttributeError): getattr(configuration, "measure") with pytest.raises(AttributeError): From 42e4849a003884ff50f883ea6f8db59c28208624 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Thu, 28 May 2026 09:52:54 -0400 Subject: [PATCH 07/14] fix(discord): Route App Directory install through API pipeline modal (#116375) Discord App Directory installs were broken: `build_integration` was rewritten to read state from top-level keys when legacy pipeline views were removed in #113323, but the surviving `DiscordExtensionConfigurationView` still bound state under `state["discord"]`, so users hit `Invalid guild ID. The Discord guild ID must be entirely numeric.` after authorizing in Discord's App Directory. This rewires the App Directory entry point through the API pipeline: - Adds an initial-data serializer accepting `code`, `guild_id`, and `use_configure`. `OrganizationPipelineEndpoint` binds each validated field to top-level pipeline state at initialize time. - Branches `DiscordOAuthApiStep.get_step_data` on `use_configure`: when set, return `{appDirectoryInstall: True, code, guildId, state}` so the frontend can advance immediately instead of opening a popup. The returned `state` is the pipeline's own signature, so `handle_post`'s existing CSRF check passes unchanged with no conditional. - `build_integration` reads `use_configure` from top-level state to pick `configure_url` vs `setup_url` for the OAuth token-exchange `redirect_uri` parameter (Discord requires that value to byte-exactly match what was used at authorize). - Replaces `DiscordExtensionConfigurationView` with a small `RedirectView` that forwards `/extensions/discord/configure/?...` to `/extensions/discord/link/?...`. The URL has to keep resolving because Discord's App Directory listing has it registered as the OAuth `redirect_uri`; we can't remove it without Discord-side coordination. Frontend changes (link view triggers the pipeline modal with the URL params as `initialData`, and the Discord OAuth step component auto-advances when `appDirectoryInstall` is set) ship in a follow-up PR. --- .../integrations/discord/integration.py | 37 ++++++-- src/sentry/integrations/discord/urls.py | 10 +-- .../discord/views/configure_redirect.py | 16 ++++ .../web/discord_extension_configuration.py | 13 --- .../integrations/parsers/discord.py | 6 +- .../integrations/discord/test_integration.py | 90 +++++++++++++++++-- 6 files changed, 138 insertions(+), 34 deletions(-) create mode 100644 src/sentry/integrations/discord/views/configure_redirect.py delete mode 100644 src/sentry/integrations/web/discord_extension_configuration.py diff --git a/src/sentry/integrations/discord/integration.py b/src/sentry/integrations/discord/integration.py index 06430ac58c1671..0acb6959223f6a 100644 --- a/src/sentry/integrations/discord/integration.py +++ b/src/sentry/integrations/discord/integration.py @@ -148,6 +148,19 @@ class DiscordOAuthApiSerializer(CamelSnakeSerializer): guild_id = CharField(required=True) +class DiscordInitialDataSerializer(CamelSnakeSerializer): + """Initial pipeline data for App Directory-originated Discord installs. + + When a user installs from Discord's App Directory, Discord initiates OAuth + and redirects back to Sentry with `code` and `guild_id`. The frontend + forwards them here so the pipeline can skip its own OAuth step. + """ + + code = CharField(required=False) + guild_id = CharField(required=False) + use_configure = CharField(required=False) + + class DiscordOAuthApiStep: """API-mode OAuth step for Discord integration setup. @@ -170,7 +183,18 @@ def __init__( self.scopes = scopes self.redirect_url = redirect_url - def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> dict[str, str]: + def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> dict[str, Any]: + # App Directory installs arrive with OAuth already complete: code and + # guild_id are bound to state via initialData. Signal the frontend to + # advance immediately using those values instead of opening a popup. + if pipeline.fetch_state("use_configure"): + return { + "appDirectoryInstall": True, + "code": pipeline.fetch_state("code"), + "guildId": pipeline.fetch_state("guild_id"), + "state": pipeline.signature, + } + params = urlencode( { "client_id": self.client_id, @@ -245,6 +269,9 @@ def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]: ), ] + def get_initial_data_serializer_cls(self) -> type[DiscordInitialDataSerializer]: + return DiscordInitialDataSerializer + def build_integration(self, state: Mapping[str, Any]) -> IntegrationData: guild_id = str(state.get("guild_id")) @@ -258,11 +285,9 @@ def build_integration(self, state: Mapping[str, Any]) -> IntegrationData: except (ApiError, AttributeError): guild_name = guild_id - discord_config = state.get(IntegrationProviderSlug.DISCORD.value, {}) - if isinstance(discord_config, dict): - use_configure = discord_config.get("use_configure") == "1" - else: - use_configure = False + # App Directory installs initiated OAuth with configure_url as the + # redirect_uri, so token exchange must echo it back. + use_configure = state.get("use_configure") == "1" url = self.configure_url if use_configure else self.setup_url auth_code = str(state.get("code")) diff --git a/src/sentry/integrations/discord/urls.py b/src/sentry/integrations/discord/urls.py index 2e6657bc57758c..7ae5b50b64ac70 100644 --- a/src/sentry/integrations/discord/urls.py +++ b/src/sentry/integrations/discord/urls.py @@ -1,9 +1,7 @@ from django.urls import re_path from sentry.integrations.discord.spec import DiscordMessagingSpec -from sentry.integrations.web.discord_extension_configuration import ( - DiscordExtensionConfigurationView, -) +from sentry.integrations.discord.views.configure_redirect import DiscordConfigureRedirectView from .webhooks.base import DiscordInteractionsEndpoint @@ -13,10 +11,12 @@ DiscordInteractionsEndpoint.as_view(), name="sentry-integration-discord-interactions", ), - # Discord App Directory extension install flow + # Discord App Directory's redirect_uri lands here after the user authorizes + # in Discord. We forward the OAuth params to the link view, which opens the + # install pipeline modal to finish the install. re_path( r"^configure/$", - DiscordExtensionConfigurationView.as_view(), + DiscordConfigureRedirectView.as_view(), name="discord-extension-configuration", ), ] diff --git a/src/sentry/integrations/discord/views/configure_redirect.py b/src/sentry/integrations/discord/views/configure_redirect.py new file mode 100644 index 00000000000000..d9f7145fa9324e --- /dev/null +++ b/src/sentry/integrations/discord/views/configure_redirect.py @@ -0,0 +1,16 @@ +from django.views.generic.base import RedirectView + +from sentry.web.frontend.base import control_silo_view + + +@control_silo_view +class DiscordConfigureRedirectView(RedirectView): + """OAuth redirect target for Discord App Directory installs. + + Forwards `code` and `guild_id` from Discord's OAuth callback to the + integration link view, which picks an org and opens the install pipeline. + """ + + url = "/extensions/discord/link/" + query_string = True + permanent = False diff --git a/src/sentry/integrations/web/discord_extension_configuration.py b/src/sentry/integrations/web/discord_extension_configuration.py deleted file mode 100644 index 854cabf32982af..00000000000000 --- a/src/sentry/integrations/web/discord_extension_configuration.py +++ /dev/null @@ -1,13 +0,0 @@ -from sentry.integrations.types import IntegrationProviderSlug -from sentry.web.frontend.base import control_silo_view - -from .integration_extension_configuration import IntegrationExtensionConfigurationView - - -@control_silo_view -class DiscordExtensionConfigurationView(IntegrationExtensionConfigurationView): - provider = IntegrationProviderSlug.DISCORD.value - external_provider_key = IntegrationProviderSlug.DISCORD.value - - def map_params_to_state(self, params): - return {"use_configure": "1", **params} diff --git a/src/sentry/middleware/integrations/parsers/discord.py b/src/sentry/middleware/integrations/parsers/discord.py index 390e84ff8c1d38..041a45540c62aa 100644 --- a/src/sentry/middleware/integrations/parsers/discord.py +++ b/src/sentry/middleware/integrations/parsers/discord.py @@ -12,6 +12,7 @@ from sentry.hybridcloud.outbox.category import WebhookProviderIdentifier from sentry.integrations.discord.message_builder.base.flags import EPHEMERAL_FLAG from sentry.integrations.discord.requests.base import DiscordRequest, DiscordRequestError +from sentry.integrations.discord.views.configure_redirect import DiscordConfigureRedirectView from sentry.integrations.discord.views.link_identity import DiscordLinkIdentityView from sentry.integrations.discord.views.unlink_identity import DiscordUnlinkIdentityView from sentry.integrations.discord.webhooks.base import DiscordInteractionsEndpoint @@ -22,9 +23,6 @@ ) from sentry.integrations.models.integration import Integration from sentry.integrations.types import EXTERNAL_PROVIDERS, ExternalProviders -from sentry.integrations.web.discord_extension_configuration import ( - DiscordExtensionConfigurationView, -) from sentry.middleware.integrations.tasks import convert_to_async_discord_response from sentry.types.cell import Cell @@ -38,7 +36,7 @@ class DiscordRequestParser(BaseRequestParser): control_classes = [ DiscordLinkIdentityView, DiscordUnlinkIdentityView, - DiscordExtensionConfigurationView, + DiscordConfigureRedirectView, ] # Dynamically set to avoid RawPostDataException from double reads diff --git a/tests/sentry/integrations/discord/test_integration.py b/tests/sentry/integrations/discord/test_integration.py index 1605e4df8eb62b..68c0236e6a1a52 100644 --- a/tests/sentry/integrations/discord/test_integration.py +++ b/tests/sentry/integrations/discord/test_integration.py @@ -366,12 +366,11 @@ def _get_pipeline_url(self) -> str: args=[self.organization.slug, IntegrationPipeline.pipeline_name], ) - def _initialize_pipeline(self) -> Any: - return self.client.post( - self._get_pipeline_url(), - data={"action": "initialize", "provider": "discord"}, - format="json", - ) + def _initialize_pipeline(self, initial_data: dict[str, Any] | None = None) -> Any: + payload: dict[str, Any] = {"action": "initialize", "provider": "discord"} + if initial_data is not None: + payload["initialData"] = initial_data + return self.client.post(self._get_pipeline_url(), data=payload, format="json") def _advance_step(self, data: dict[str, Any]) -> Any: return self.client.post(self._get_pipeline_url(), data=data, format="json") @@ -459,3 +458,82 @@ def test_full_pipeline_flow(self, mock_set_application_command: mock.MagicMock) organization_id=self.organization.id, integration=integration, ).exists() + + @responses.activate + def test_app_directory_initialize_returns_auto_advance_data(self) -> None: + resp = self._initialize_pipeline( + initial_data={ + "code": "discord-auth-code", + "guildId": self.guild_id, + "useConfigure": "1", + } + ) + assert resp.status_code == 200 + assert resp.data["step"] == "oauth_login" + data = resp.data["data"] + assert data["appDirectoryInstall"] is True + assert data["code"] == "discord-auth-code" + assert data["guildId"] == self.guild_id + assert "state" in data + assert "oauthUrl" not in data + + @responses.activate + @mock.patch("sentry.integrations.discord.client.DiscordClient.set_application_command") + def test_app_directory_full_flow(self, mock_set_application_command: mock.MagicMock) -> None: + responses.add( + responses.GET, + url=f"{DiscordClient.base_url}{GUILD_URL.format(guild_id=self.guild_id)}", + match=[header_matcher({"Authorization": f"Bot {self.bot_token}"})], + json={"id": self.guild_id, "name": self.guild_name}, + ) + responses.add( + responses.GET, + url=f"{DiscordClient.base_url}{APPLICATION_COMMANDS_URL.format(application_id=self.application_id)}", + match=[header_matcher({"Authorization": f"Bot {self.bot_token}"})], + json=COMMANDS, + ) + responses.add( + responses.POST, + url=f"{DISCORD_BASE_URL}/oauth2/token", + json={"access_token": "access_token"}, + ) + responses.add( + responses.GET, + url=f"{DiscordClient.base_url}/users/@me", + json={"id": "user_1234"}, + ) + responses.add( + responses.GET, + url=f"{DiscordClient.base_url}/users/@me/guilds/{self.guild_id}/member", + json={}, + ) + + resp = self._initialize_pipeline( + initial_data={ + "code": "discord-auth-code", + "guildId": self.guild_id, + "useConfigure": "1", + } + ) + data = resp.data["data"] + assert data["appDirectoryInstall"] is True + pipeline_signature = data["state"] + + resp = self._advance_step( + { + "code": data["code"], + "state": pipeline_signature, + "guildId": data["guildId"], + } + ) + assert resp.status_code == 200 + assert resp.data["status"] == "complete" + + # Token exchange must echo `configure_url` as redirect_uri because OAuth + # was initiated from Discord's App Directory with that URL. + token_calls = [c for c in responses.calls if c.request.url.endswith("/oauth2/token")] + assert len(token_calls) == 1 + token_body = token_calls[0].request.body + if isinstance(token_body, bytes): + token_body = token_body.decode() + assert "configure" in token_body From c35ded6b21ebc74006d4ffc0fc599fd940550685 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki <44422760+DominikB2014@users.noreply.github.com> Date: Thu, 28 May 2026 10:24:45 -0400 Subject: [PATCH 08/14] test(dashboards): validate prebuilt widget layouts and lengths (#116217) --- .../dashboards/utils/prebuiltConfigs.spec.ts | 43 +++++++++++++++++++ .../dashboards/utils/prebuiltConfigs.tsx | 22 +++++++++- .../prebuiltConfigs/ai/aiAgentsModels.ts | 7 ++- .../prebuiltConfigs/ai/aiAgentsOverview.ts | 7 ++- .../utils/prebuiltConfigs/ai/aiAgentsTools.ts | 7 ++- .../utils/prebuiltConfigs/ai/mcpOverview.ts | 7 ++- .../utils/prebuiltConfigs/ai/mcpPrompts.ts | 7 ++- .../utils/prebuiltConfigs/ai/mcpResources.ts | 7 ++- .../utils/prebuiltConfigs/ai/mcpTools.ts | 7 ++- .../backendOverview/backendOverview.ts | 9 ++-- .../utils/prebuiltConfigs/caches/caches.ts | 9 ++-- .../frontendAssets/frontendAssets.ts | 9 ++-- .../frontendAssets/frontendAssetsDetails.ts | 11 +++-- .../frontendOverview/frontendOverview.ts | 9 ++-- .../prebuiltConfigs/http/domainSummary.ts | 11 +++-- .../utils/prebuiltConfigs/http/http.ts | 9 ++-- .../laravelOverview/laravelOverview.ts | 13 +++--- .../prebuiltConfigs/mobileSessionHealth.ts | 11 +++-- .../prebuiltConfigs/mobileVitals/appStarts.ts | 28 ++++++------ .../mobileVitals/mobileVitals.ts | 28 ++++++------ .../mobileVitals/screenLoads.ts | 29 +++++++------ .../mobileVitals/screenRendering.ts | 4 +- .../nextJsOverview/nextJsOverview.ts | 13 +++--- .../nodeRuntimeMetrics/nodeRuntimeMetrics.ts | 13 ++++-- .../prebuiltConfigs/queues/queueCharts.ts | 5 ++- .../prebuiltConfigs/queues/queueDetails.ts | 11 +++-- .../utils/prebuiltConfigs/queues/queues.ts | 9 ++-- .../utils/spaceWidgetsEquallyOnRow.ts | 12 ++++-- 28 files changed, 246 insertions(+), 111 deletions(-) create mode 100644 static/app/views/dashboards/utils/prebuiltConfigs.spec.ts diff --git a/static/app/views/dashboards/utils/prebuiltConfigs.spec.ts b/static/app/views/dashboards/utils/prebuiltConfigs.spec.ts new file mode 100644 index 00000000000000..0ee4a66b71b070 --- /dev/null +++ b/static/app/views/dashboards/utils/prebuiltConfigs.spec.ts @@ -0,0 +1,43 @@ +import {NUM_DESKTOP_COLS} from 'sentry/views/dashboards/constants'; +import { + PREBUILT_DASHBOARDS, + PrebuiltDashboardId, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; + +// Must match the limits enforced by the backend serializer at +// src/sentry/api/serializers/rest_framework/dashboard.py. +const MAX_WIDGET_DESCRIPTION_LENGTH = 350; +const MAX_WIDGET_TITLE_LENGTH = 255; +const MAX_DASHBOARD_TITLE_LENGTH = 255; + +const entries = Object.entries(PREBUILT_DASHBOARDS) as Array< + [`${PrebuiltDashboardId}`, (typeof PREBUILT_DASHBOARDS)[PrebuiltDashboardId]] +>; + +describe('PREBUILT_DASHBOARDS', () => { + it.each(entries)('dashboard %s has a title within the backend limit', (_id, config) => { + expect(config.title.length).toBeLessThanOrEqual(MAX_DASHBOARD_TITLE_LENGTH); + }); + + describe.each(entries)('dashboard %s widgets', (_id, config) => { + it.each( + config.widgets.map((widget, index) => [index, widget.title, widget] as const) + )('widget %i (%s) passes backend validation', (_index, _title, widget) => { + expect(widget.title.length).toBeLessThanOrEqual(MAX_WIDGET_TITLE_LENGTH); + expect(widget.description?.length ?? 0).toBeLessThanOrEqual( + MAX_WIDGET_DESCRIPTION_LENGTH + ); + + // x and w bounds are enforced by PrebuiltWidgetLayout's literal-union + // types; only the wide fields and the cross-field x+w invariant + // need runtime checks. + const layout = widget.layout; + if (layout) { + expect(layout.y).toBeGreaterThanOrEqual(0); + expect(layout.h).toBeGreaterThanOrEqual(1); + expect(layout.minH).toBeGreaterThanOrEqual(1); + expect(layout.x + layout.w).toBeLessThanOrEqual(NUM_DESKTOP_COLS); + } + }); + }); +}); diff --git a/static/app/views/dashboards/utils/prebuiltConfigs.tsx b/static/app/views/dashboards/utils/prebuiltConfigs.tsx index 31bfbbe2047240..29ef57c650612f 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs.tsx +++ b/static/app/views/dashboards/utils/prebuiltConfigs.tsx @@ -1,5 +1,9 @@ import type {Project} from 'sentry/types/project'; -import {type DashboardDetails} from 'sentry/views/dashboards/types'; +import { + type DashboardDetails, + type Widget, + type WidgetLayout, +} from 'sentry/views/dashboards/types'; import {AI_AGENTS_MODELS_PREBUILT_CONFIG} from 'sentry/views/dashboards/utils/prebuiltConfigs/ai/aiAgentsModels'; import {AI_AGENTS_OVERVIEW_PREBUILT_CONFIG} from 'sentry/views/dashboards/utils/prebuiltConfigs/ai/aiAgentsOverview'; import {AI_AGENTS_TOOLS_PREBUILT_CONFIG} from 'sentry/views/dashboards/utils/prebuiltConfigs/ai/aiAgentsTools'; @@ -90,7 +94,21 @@ type OnboardingConfig = type: 'overview'; }; -export type PrebuiltDashboard = Omit & { +// Narrow x/w to literal unions so the desktop-grid invariants (x in [0,5], +// w in [1,6]) are checked at compile time for prebuilt configs. User-created +// widgets stay on the wider `WidgetLayout` because their layouts come from +// react-grid-layout arithmetic, not hand-written literals. +export type PrebuiltWidgetLayout = Omit & { + w: 1 | 2 | 3 | 4 | 5 | 6; + x: 0 | 1 | 2 | 3 | 4 | 5; +}; + +export type PrebuiltWidget = Omit & { + layout?: PrebuiltWidgetLayout | null; +}; + +export type PrebuiltDashboard = Omit & { + widgets: PrebuiltWidget[]; onboarding?: OnboardingConfig; }; diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/ai/aiAgentsModels.ts b/static/app/views/dashboards/utils/prebuiltConfigs/ai/aiAgentsModels.ts index 0fcbd8738c360b..ab59d25dfcf320 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/ai/aiAgentsModels.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/ai/aiAgentsModels.ts @@ -1,7 +1,10 @@ import {t} from 'sentry/locale'; import {FieldKind} from 'sentry/utils/fields'; import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; -import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; +import type { + PrebuiltDashboard, + PrebuiltWidget, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import {AI_AGENTS_MODELS_DASHBOARD_TITLE} from 'sentry/views/dashboards/utils/prebuiltConfigs/ai/settings'; import {WIDGET_COLUMN_LABELS} from 'sentry/views/dashboards/utils/prebuiltConfigs/settings'; import {spaceWidgetsEquallyOnRow} from 'sentry/views/dashboards/utils/prebuiltConfigs/utils/spaceWidgetsEquallyOnRow'; @@ -97,7 +100,7 @@ const FIRST_ROW_WIDGETS = spaceWidgetsEquallyOnRow( {h: 3, minH: 3} ); -const MODELS_TABLE = { +const MODELS_TABLE: PrebuiltWidget = { id: 'ai-agents-models-table', title: t('Models'), displayType: DisplayType.TABLE, diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/ai/aiAgentsOverview.ts b/static/app/views/dashboards/utils/prebuiltConfigs/ai/aiAgentsOverview.ts index 64dcdcf3b20a29..85494331b30fb9 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/ai/aiAgentsOverview.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/ai/aiAgentsOverview.ts @@ -2,7 +2,10 @@ import {COL_WIDTH_UNDEFINED} from 'sentry/components/tables/gridEditable'; import {t} from 'sentry/locale'; import {FieldKind} from 'sentry/utils/fields'; import {DisplayType, MAX_TABLE_LIMIT, WidgetType} from 'sentry/views/dashboards/types'; -import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; +import type { + PrebuiltDashboard, + PrebuiltWidget, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import {AI_AGENTS_OVERVIEW_DASHBOARD_TITLE} from 'sentry/views/dashboards/utils/prebuiltConfigs/ai/settings'; import { WIDGET_COLUMN_LABELS, @@ -206,7 +209,7 @@ const SECOND_ROW_WIDGETS = spaceWidgetsEquallyOnRow( {h: 3, minH: 3} ); -const AGENTS_TRACES_TABLE = { +const AGENTS_TRACES_TABLE: PrebuiltWidget = { id: 'ai-agents-traces-table', title: t('Traces'), description: t('Agent traces with duration, token, cost, and tool usage.'), diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/ai/aiAgentsTools.ts b/static/app/views/dashboards/utils/prebuiltConfigs/ai/aiAgentsTools.ts index acc1cfd9ad15b0..1ade63e690a12d 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/ai/aiAgentsTools.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/ai/aiAgentsTools.ts @@ -1,7 +1,10 @@ import {t} from 'sentry/locale'; import {FieldKind} from 'sentry/utils/fields'; import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; -import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; +import type { + PrebuiltDashboard, + PrebuiltWidget, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import {AI_AGENTS_TOOLS_DASHBOARD_TITLE} from 'sentry/views/dashboards/utils/prebuiltConfigs/ai/settings'; import {WIDGET_COLUMN_LABELS} from 'sentry/views/dashboards/utils/prebuiltConfigs/settings'; import {spaceWidgetsEquallyOnRow} from 'sentry/views/dashboards/utils/prebuiltConfigs/utils/spaceWidgetsEquallyOnRow'; @@ -56,7 +59,7 @@ const FIRST_ROW_WIDGETS = spaceWidgetsEquallyOnRow( {h: 3, minH: 3} ); -const TOOLS_TABLE = { +const TOOLS_TABLE: PrebuiltWidget = { id: 'ai-agents-tools-table', title: t('Tools'), displayType: DisplayType.TABLE, diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/ai/mcpOverview.ts b/static/app/views/dashboards/utils/prebuiltConfigs/ai/mcpOverview.ts index 4402cbdc225d2d..fa9974cdec535d 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/ai/mcpOverview.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/ai/mcpOverview.ts @@ -1,6 +1,9 @@ import {t} from 'sentry/locale'; import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; -import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; +import type { + PrebuiltDashboard, + PrebuiltWidget, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import {MCP_OVERVIEW_DASHBOARD_TITLE} from 'sentry/views/dashboards/utils/prebuiltConfigs/ai/settings'; import {WIDGET_COLUMN_LABELS} from 'sentry/views/dashboards/utils/prebuiltConfigs/settings'; import {spaceWidgetsEquallyOnRow} from 'sentry/views/dashboards/utils/prebuiltConfigs/utils/spaceWidgetsEquallyOnRow'; @@ -164,7 +167,7 @@ const SECOND_ROW_WIDGETS = spaceWidgetsEquallyOnRow( {h: 3, minH: 3} ); -const OVERVIEW_TABLE = { +const OVERVIEW_TABLE: PrebuiltWidget = { id: 'mcp-overview-table', title: t('MCP Overview'), displayType: DisplayType.TABLE, diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/ai/mcpPrompts.ts b/static/app/views/dashboards/utils/prebuiltConfigs/ai/mcpPrompts.ts index 914f705a9440b1..f958f2b4d2ec91 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/ai/mcpPrompts.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/ai/mcpPrompts.ts @@ -1,7 +1,10 @@ import {t} from 'sentry/locale'; import {FieldKind} from 'sentry/utils/fields'; import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; -import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; +import type { + PrebuiltDashboard, + PrebuiltWidget, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import {MCP_PROMPTS_DASHBOARD_TITLE} from 'sentry/views/dashboards/utils/prebuiltConfigs/ai/settings'; import {WIDGET_COLUMN_LABELS} from 'sentry/views/dashboards/utils/prebuiltConfigs/settings'; import {spaceWidgetsEquallyOnRow} from 'sentry/views/dashboards/utils/prebuiltConfigs/utils/spaceWidgetsEquallyOnRow'; @@ -76,7 +79,7 @@ const FIRST_ROW_WIDGETS = spaceWidgetsEquallyOnRow( {h: 3, minH: 3} ); -const PROMPTS_TABLE = { +const PROMPTS_TABLE: PrebuiltWidget = { id: 'mcp-prompts-table', title: t('Prompts'), displayType: DisplayType.TABLE, diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/ai/mcpResources.ts b/static/app/views/dashboards/utils/prebuiltConfigs/ai/mcpResources.ts index 363a577582af87..767082f0129821 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/ai/mcpResources.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/ai/mcpResources.ts @@ -1,7 +1,10 @@ import {t} from 'sentry/locale'; import {FieldKind} from 'sentry/utils/fields'; import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; -import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; +import type { + PrebuiltDashboard, + PrebuiltWidget, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import {MCP_RESOURCES_DASHBOARD_TITLE} from 'sentry/views/dashboards/utils/prebuiltConfigs/ai/settings'; import {WIDGET_COLUMN_LABELS} from 'sentry/views/dashboards/utils/prebuiltConfigs/settings'; import {spaceWidgetsEquallyOnRow} from 'sentry/views/dashboards/utils/prebuiltConfigs/utils/spaceWidgetsEquallyOnRow'; @@ -76,7 +79,7 @@ const FIRST_ROW_WIDGETS = spaceWidgetsEquallyOnRow( {h: 3, minH: 3} ); -const RESOURCES_TABLE = { +const RESOURCES_TABLE: PrebuiltWidget = { id: 'mcp-resources-table', title: t('Resources'), displayType: DisplayType.TABLE, diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/ai/mcpTools.ts b/static/app/views/dashboards/utils/prebuiltConfigs/ai/mcpTools.ts index cf49305811d13d..e620c6e94a57f4 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/ai/mcpTools.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/ai/mcpTools.ts @@ -1,7 +1,10 @@ import {t} from 'sentry/locale'; import {FieldKind} from 'sentry/utils/fields'; import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; -import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; +import type { + PrebuiltDashboard, + PrebuiltWidget, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import {MCP_TOOLS_DASHBOARD_TITLE} from 'sentry/views/dashboards/utils/prebuiltConfigs/ai/settings'; import {WIDGET_COLUMN_LABELS} from 'sentry/views/dashboards/utils/prebuiltConfigs/settings'; import {spaceWidgetsEquallyOnRow} from 'sentry/views/dashboards/utils/prebuiltConfigs/utils/spaceWidgetsEquallyOnRow'; @@ -76,7 +79,7 @@ const FIRST_ROW_WIDGETS = spaceWidgetsEquallyOnRow( {h: 3, minH: 3} ); -const TOOLS_TABLE = { +const TOOLS_TABLE: PrebuiltWidget = { id: 'mcp-tools-table', title: t('Tools'), displayType: DisplayType.TABLE, diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/backendOverview/backendOverview.ts b/static/app/views/dashboards/utils/prebuiltConfigs/backendOverview/backendOverview.ts index 48105ec26d72fd..f34bc777f10d4a 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/backendOverview/backendOverview.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/backendOverview/backendOverview.ts @@ -2,8 +2,11 @@ import {COL_WIDTH_UNDEFINED} from 'sentry/components/tables/gridEditable'; import {t} from 'sentry/locale'; import {FieldKind} from 'sentry/utils/fields'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; -import {DisplayType, WidgetType, type Widget} from 'sentry/views/dashboards/types'; -import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; +import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; +import type { + PrebuiltDashboard, + PrebuiltWidget, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import {DASHBOARD_TITLE} from 'sentry/views/dashboards/utils/prebuiltConfigs/backendOverview/settings'; import {BASE_FILTER_STRING} from 'sentry/views/dashboards/utils/prebuiltConfigs/queries/settings'; import { @@ -215,7 +218,7 @@ const TABLE_FIELDS = [ `sum(${SpanFields.SPAN_DURATION})`, ]; -const TRANSACTIONS_TABLE: Widget = { +const TRANSACTIONS_TABLE: PrebuiltWidget = { id: 'backend-overview-transactions-table', title: t('Transactions'), description: '', diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/caches/caches.ts b/static/app/views/dashboards/utils/prebuiltConfigs/caches/caches.ts index 8cef72382c079b..ae6de6c7e52dd7 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/caches/caches.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/caches/caches.ts @@ -1,6 +1,9 @@ import {t} from 'sentry/locale'; -import {DisplayType, WidgetType, type Widget} from 'sentry/views/dashboards/types'; -import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; +import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; +import type { + PrebuiltDashboard, + PrebuiltWidget, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import {DASHBOARD_TITLE} from 'sentry/views/dashboards/utils/prebuiltConfigs/caches/settings'; import { WIDGET_COLUMN_LABELS, @@ -55,7 +58,7 @@ const FIRST_ROW_WIDGETS = spaceWidgetsEquallyOnRow( 0 ); -const TRANSACTION_TABLE: Widget = { +const TRANSACTION_TABLE: PrebuiltWidget = { id: 'transaction-table', title: t('Transactions'), displayType: DisplayType.TABLE, diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/frontendAssets/frontendAssets.ts b/static/app/views/dashboards/utils/prebuiltConfigs/frontendAssets/frontendAssets.ts index de895c04bfad78..a5d8833a1b1745 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/frontendAssets/frontendAssets.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/frontendAssets/frontendAssets.ts @@ -1,8 +1,11 @@ import {t} from 'sentry/locale'; import {FieldKind} from 'sentry/utils/fields'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; -import {DisplayType, WidgetType, type Widget} from 'sentry/views/dashboards/types'; -import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; +import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; +import type { + PrebuiltDashboard, + PrebuiltWidget, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import {DASHBOARD_TITLE} from 'sentry/views/dashboards/utils/prebuiltConfigs/frontendAssets/settings'; import { WIDGET_COLUMN_LABELS, @@ -57,7 +60,7 @@ const FIRST_ROW_WIDGETS = spaceWidgetsEquallyOnRow( {h: 2, minH: 2} ); -const ASSETS_TABLE: Widget = { +const ASSETS_TABLE: PrebuiltWidget = { id: 'assets-table', title: t('Assets'), displayType: DisplayType.TABLE, diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/frontendAssets/frontendAssetsDetails.ts b/static/app/views/dashboards/utils/prebuiltConfigs/frontendAssets/frontendAssetsDetails.ts index 9272c872298c75..75e72de8a2fe84 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/frontendAssets/frontendAssetsDetails.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/frontendAssets/frontendAssetsDetails.ts @@ -1,14 +1,17 @@ import {t} from 'sentry/locale'; import {FieldKind} from 'sentry/utils/fields'; -import {DisplayType, WidgetType, type Widget} from 'sentry/views/dashboards/types'; -import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; +import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; +import type { + PrebuiltDashboard, + PrebuiltWidget, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import {DETAILS_DASHBOARD_TITLE} from 'sentry/views/dashboards/utils/prebuiltConfigs/frontendAssets/settings'; import {WIDGET_COLUMN_LABELS} from 'sentry/views/dashboards/utils/prebuiltConfigs/settings'; import {spaceWidgetsEquallyOnRow} from 'sentry/views/dashboards/utils/prebuiltConfigs/utils/spaceWidgetsEquallyOnRow'; import type {DefaultDetailWidgetFields} from 'sentry/views/dashboards/widgets/detailsWidget/types'; import {ModuleName, SpanFields} from 'sentry/views/insights/types'; -const ASSET_DESCRIPTION_WIDGET: Widget = { +const ASSET_DESCRIPTION_WIDGET: PrebuiltWidget = { id: 'domain-widget', title: t('Example Asset'), displayType: DisplayType.DETAILS, @@ -229,7 +232,7 @@ const THIRD_ROW_WIDGETS = spaceWidgetsEquallyOnRow( 3 ); -const ASSETS_TABLE_WIDGET: Widget = { +const ASSETS_TABLE_WIDGET: PrebuiltWidget = { id: 'assets-table-widget', title: t('Pages Containing This Asset'), displayType: DisplayType.TABLE, diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/frontendOverview/frontendOverview.ts b/static/app/views/dashboards/utils/prebuiltConfigs/frontendOverview/frontendOverview.ts index b1310ac4da9373..e9d532a49f5140 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/frontendOverview/frontendOverview.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/frontendOverview/frontendOverview.ts @@ -1,8 +1,11 @@ import {t} from 'sentry/locale'; import {FieldKind} from 'sentry/utils/fields'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; -import {DisplayType, WidgetType, type Widget} from 'sentry/views/dashboards/types'; -import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; +import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; +import type { + PrebuiltDashboard, + PrebuiltWidget, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import { DASHBOARD_TITLE, FRONTEND_SDK_NAMES, @@ -188,7 +191,7 @@ const TABLE_FIELDS = [ 'performance_score(measurements.score.total)', ]; -const TRANSACTIONS_TABLE: Widget = { +const TRANSACTIONS_TABLE: PrebuiltWidget = { id: 'frontend-overview-table', title: t('Transactions'), displayType: DisplayType.TABLE, diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/http/domainSummary.ts b/static/app/views/dashboards/utils/prebuiltConfigs/http/domainSummary.ts index bcb9055eb1e55f..b513b02cdcee71 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/http/domainSummary.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/http/domainSummary.ts @@ -1,8 +1,11 @@ import {t} from 'sentry/locale'; import {FieldKind} from 'sentry/utils/fields'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; -import {DisplayType, WidgetType, type Widget} from 'sentry/views/dashboards/types'; -import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; +import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; +import type { + PrebuiltDashboard, + PrebuiltWidget, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import { PERCENTAGE_3XX, PERCENTAGE_4XX, @@ -23,7 +26,7 @@ import {ModuleName, SpanFields} from 'sentry/views/insights/types'; const FILTER_STRING = MutableSearch.fromQueryObject(BASE_FILTERS).formatString(); -const DOMAIN_WIDGET: Widget = { +const DOMAIN_WIDGET: PrebuiltWidget = { id: 'domain-widget', title: t('Domain'), displayType: DisplayType.DETAILS, @@ -229,7 +232,7 @@ const CHART_ROW_WIDGETS = spaceWidgetsEquallyOnRow( 2 ); -const TRANSACTIONS_TABLE: Widget = { +const TRANSACTIONS_TABLE: PrebuiltWidget = { id: 'transactions-table', title: t('Transactions Making Requests to This Domain'), displayType: DisplayType.TABLE, diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/http/http.ts b/static/app/views/dashboards/utils/prebuiltConfigs/http/http.ts index bf1f86319c37d0..6a1257b73bb426 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/http/http.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/http/http.ts @@ -2,8 +2,11 @@ import {t} from 'sentry/locale'; import {RATE_UNIT_TITLE, RateUnit} from 'sentry/utils/discover/fields'; import {FieldKind} from 'sentry/utils/fields'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; -import {DisplayType, WidgetType, type Widget} from 'sentry/views/dashboards/types'; -import {type PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; +import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; +import type { + PrebuiltDashboard, + PrebuiltWidget, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import { PERCENTAGE_3XX, PERCENTAGE_4XX, @@ -80,7 +83,7 @@ const FIRST_ROW_WIDGETS = spaceWidgetsEquallyOnRow( 0 ); -const DOMAIN_TABLE: Widget = { +const DOMAIN_TABLE: PrebuiltWidget = { id: 'domain-table', title: t('Domains'), displayType: DisplayType.TABLE, diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/laravelOverview/laravelOverview.ts b/static/app/views/dashboards/utils/prebuiltConfigs/laravelOverview/laravelOverview.ts index 9d424168b99561..7bd98c8c5afd2a 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/laravelOverview/laravelOverview.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/laravelOverview/laravelOverview.ts @@ -1,7 +1,10 @@ import {t} from 'sentry/locale'; import {FieldKind} from 'sentry/utils/fields'; -import {DisplayType, WidgetType, type Widget} from 'sentry/views/dashboards/types'; -import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; +import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; +import type { + PrebuiltDashboard, + PrebuiltWidget, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import { BACKEND_OVERVIEW_FIRST_ROW_WIDGETS, BACKEND_OVERVIEW_SECOND_ROW_WIDGETS, @@ -13,7 +16,7 @@ import { } from 'sentry/views/dashboards/utils/prebuiltConfigs/settings'; import {SpanFields} from 'sentry/views/insights/types'; -const PATHS_TABLE: Widget = { +const PATHS_TABLE: PrebuiltWidget = { id: 'paths-table', title: t('Paths'), displayType: DisplayType.TABLE, @@ -64,7 +67,7 @@ const PATHS_TABLE: Widget = { }, }; -const COMMANDS_TABLE: Widget = { +const COMMANDS_TABLE: PrebuiltWidget = { id: 'commands-table', title: t('Commands'), displayType: DisplayType.TABLE, @@ -110,7 +113,7 @@ const COMMANDS_TABLE: Widget = { }, }; -const JOBS_TABLE: Widget = { +const JOBS_TABLE: PrebuiltWidget = { id: 'jobs-table', title: t('Jobs'), displayType: DisplayType.TABLE, diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/mobileSessionHealth.ts b/static/app/views/dashboards/utils/prebuiltConfigs/mobileSessionHealth.ts index 235432226cc315..0466949140b528 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/mobileSessionHealth.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/mobileSessionHealth.ts @@ -1,6 +1,9 @@ import {t} from 'sentry/locale'; -import {DisplayType, WidgetType, type Widget} from 'sentry/views/dashboards/types'; -import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; +import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; +import type { + PrebuiltDashboard, + PrebuiltWidget, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import {spaceWidgetsEquallyOnRow} from 'sentry/views/dashboards/utils/prebuiltConfigs/utils/spaceWidgetsEquallyOnRow'; const FIRST_ROW_WIDGETS = spaceWidgetsEquallyOnRow( @@ -143,7 +146,7 @@ const THIRD_ROW_WIDGETS = spaceWidgetsEquallyOnRow( 4 ); -const CRASH_RATE_TABLE: Widget = { +const CRASH_RATE_TABLE: PrebuiltWidget = { id: 'crash-rate-table', title: t('Crash Free Rate by Project'), displayType: DisplayType.TABLE, @@ -163,7 +166,7 @@ const CRASH_RATE_TABLE: Widget = { layout: {x: 0, y: 6, w: 6, h: 2, minH: 2}, }; -const RELEASE_TABLE: Widget = { +const RELEASE_TABLE: PrebuiltWidget = { id: 'release-table', title: t('Releases'), displayType: DisplayType.TABLE, diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/mobileVitals/appStarts.ts b/static/app/views/dashboards/utils/prebuiltConfigs/mobileVitals/appStarts.ts index 30887745c40226..45a4701d07ff18 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/mobileVitals/appStarts.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/mobileVitals/appStarts.ts @@ -1,7 +1,7 @@ import {t} from 'sentry/locale'; import {FieldKind} from 'sentry/utils/fields'; import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; -import type {Widget} from 'sentry/views/dashboards/types'; +import type {PrebuiltWidget} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import { COLD_START_CONDITION, @@ -14,7 +14,7 @@ import {APP_STARTS_DASHBOARD_TITLE} from 'sentry/views/dashboards/utils/prebuilt import {WIDGET_COLUMN_LABELS} from 'sentry/views/dashboards/utils/prebuiltConfigs/settings'; import {ModuleName, SpanFields} from 'sentry/views/insights/types'; -const AVG_COLD_STARTS_BIG_NUMBER_WIDGET: Widget = { +const AVG_COLD_STARTS_BIG_NUMBER_WIDGET: PrebuiltWidget = { id: 'avg-cold-starts-big-number', title: t('Average Cold Start'), description: '', @@ -41,7 +41,7 @@ const AVG_COLD_STARTS_BIG_NUMBER_WIDGET: Widget = { }, }; -const TOTAL_COLD_START_COUNT_BIG_NUMBER_WIDGET: Widget = { +const TOTAL_COLD_START_COUNT_BIG_NUMBER_WIDGET: PrebuiltWidget = { id: 'total-cold-start-count-big-number', title: t('Cold Start Count'), description: '', @@ -68,7 +68,7 @@ const TOTAL_COLD_START_COUNT_BIG_NUMBER_WIDGET: Widget = { }, }; -const AVG_WARM_STARTS_BIG_NUMBER_WIDGET: Widget = { +const AVG_WARM_STARTS_BIG_NUMBER_WIDGET: PrebuiltWidget = { id: 'avg-warm-starts-big-number', title: t('Average Warm Start'), description: '', @@ -95,7 +95,7 @@ const AVG_WARM_STARTS_BIG_NUMBER_WIDGET: Widget = { }, }; -const TOTAL_WARM_START_COUNT_BIG_NUMBER_WIDGET: Widget = { +const TOTAL_WARM_START_COUNT_BIG_NUMBER_WIDGET: PrebuiltWidget = { id: 'total-warm-start-count-big-number', title: t('Warm Start Count'), description: '', @@ -122,7 +122,7 @@ const TOTAL_WARM_START_COUNT_BIG_NUMBER_WIDGET: Widget = { }, }; -const AVG_COLD_START_LINE_WIDGET: Widget = { +const AVG_COLD_START_LINE_WIDGET: PrebuiltWidget = { id: 'avg-cold-start-line', title: t('Average Cold Start'), description: '', @@ -150,7 +150,7 @@ const AVG_COLD_START_LINE_WIDGET: Widget = { }, }; -const AVG_WARM_START_LINE_WIDGET: Widget = { +const AVG_WARM_START_LINE_WIDGET: PrebuiltWidget = { id: 'avg-warm-start-line', title: t('Average Warm Start'), description: '', @@ -178,7 +178,7 @@ const AVG_WARM_START_LINE_WIDGET: Widget = { }, }; -const COLD_START_DEVICE_DISTRIBUTION_WIDGET: Widget = { +const COLD_START_DEVICE_DISTRIBUTION_WIDGET: PrebuiltWidget = { id: 'cold-start-device-distribution-bar', title: t('Cold Start Device Distribution'), description: '', @@ -205,7 +205,7 @@ const COLD_START_DEVICE_DISTRIBUTION_WIDGET: Widget = { }, }; -const WARM_START_DEVICE_DISTRIBUTION_WIDGET: Widget = { +const WARM_START_DEVICE_DISTRIBUTION_WIDGET: PrebuiltWidget = { id: 'warm-start-device-distribution-bar', title: t('Warm Start Device Distribution'), description: '', @@ -232,7 +232,7 @@ const WARM_START_DEVICE_DISTRIBUTION_WIDGET: Widget = { }, }; -const COLD_OPERATIONS_TABLE: Widget = { +const COLD_OPERATIONS_TABLE: PrebuiltWidget = { id: 'cold-operations-table', title: t('Cold Start Operations'), description: '', @@ -270,7 +270,7 @@ const COLD_OPERATIONS_TABLE: Widget = { }, }; -const WARM_OPERATIONS_TABLE: Widget = { +const WARM_OPERATIONS_TABLE: PrebuiltWidget = { id: 'warm-operations-table', title: t('Warm Start Operations'), description: '', @@ -308,19 +308,19 @@ const WARM_OPERATIONS_TABLE: Widget = { }, }; -const HEADER_ROW_WIDGETS: Widget[] = [ +const HEADER_ROW_WIDGETS: PrebuiltWidget[] = [ AVG_COLD_STARTS_BIG_NUMBER_WIDGET, AVG_WARM_STARTS_BIG_NUMBER_WIDGET, TOTAL_COLD_START_COUNT_BIG_NUMBER_WIDGET, TOTAL_WARM_START_COUNT_BIG_NUMBER_WIDGET, ]; -const FIRST_ROW_WIDGETS: Widget[] = [ +const FIRST_ROW_WIDGETS: PrebuiltWidget[] = [ AVG_COLD_START_LINE_WIDGET, AVG_WARM_START_LINE_WIDGET, ]; -const SECOND_ROW_WIDGETS: Widget[] = [ +const SECOND_ROW_WIDGETS: PrebuiltWidget[] = [ COLD_START_DEVICE_DISTRIBUTION_WIDGET, WARM_START_DEVICE_DISTRIBUTION_WIDGET, ]; diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/mobileVitals/mobileVitals.ts b/static/app/views/dashboards/utils/prebuiltConfigs/mobileVitals/mobileVitals.ts index 6240ac7d66bc0b..e81cbd9da5aedc 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/mobileVitals/mobileVitals.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/mobileVitals/mobileVitals.ts @@ -1,7 +1,7 @@ import {t} from 'sentry/locale'; import {FieldKind} from 'sentry/utils/fields'; import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; -import type {Widget} from 'sentry/views/dashboards/types'; +import type {PrebuiltWidget} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import { APP_START_TABLE_CONDITION, @@ -18,7 +18,7 @@ import {DASHBOARD_TITLE} from 'sentry/views/dashboards/utils/prebuiltConfigs/mob import {TABLE_MIN_HEIGHT} from 'sentry/views/dashboards/utils/prebuiltConfigs/settings'; import {ModuleName, SpanFields} from 'sentry/views/insights/types'; -const COLD_START_BIG_NUMBER_WIDGET: Widget = { +const COLD_START_BIG_NUMBER_WIDGET: PrebuiltWidget = { id: 'cold-start-big-number', title: t('Average Cold App Start'), description: 'Average cold app start duration', @@ -51,7 +51,7 @@ const COLD_START_BIG_NUMBER_WIDGET: Widget = { }, }; -const WARM_START_BIG_NUMBER_WIDGET: Widget = { +const WARM_START_BIG_NUMBER_WIDGET: PrebuiltWidget = { id: 'warm-start-big-number', title: t('Average Warm App Start'), description: 'Average warm app start duration', @@ -84,7 +84,7 @@ const WARM_START_BIG_NUMBER_WIDGET: Widget = { }, }; -const AVG_TTID_BIG_NUMBER_WIDGET: Widget = { +const AVG_TTID_BIG_NUMBER_WIDGET: PrebuiltWidget = { id: 'avg-ttid-big-number', title: t('Average TTID'), description: 'Average time to initial display', @@ -111,7 +111,7 @@ const AVG_TTID_BIG_NUMBER_WIDGET: Widget = { }, }; -const AVG_TTFD_BIG_NUMBER_WIDGET: Widget = { +const AVG_TTFD_BIG_NUMBER_WIDGET: PrebuiltWidget = { id: 'avg-ttfd-big-number', title: t('Average TTFD'), description: 'Average time to full display', @@ -140,7 +140,7 @@ const AVG_TTFD_BIG_NUMBER_WIDGET: Widget = { // Uses the Sessions (Release) dataset, so most dashboard global filters (which target Spans) // don't apply. Still valuable as a top-level health signal alongside the span-based vitals. -const CRASH_FREE_SESSION_RATE_BIG_NUMBER_WIDGET: Widget = { +const CRASH_FREE_SESSION_RATE_BIG_NUMBER_WIDGET: PrebuiltWidget = { id: 'crash-free-session-rate-big-number', title: t('Crash Free Session Rate'), description: @@ -168,7 +168,7 @@ const CRASH_FREE_SESSION_RATE_BIG_NUMBER_WIDGET: Widget = { }, }; -const SLOW_FRAME_RATE_WIDGET: Widget = { +const SLOW_FRAME_RATE_WIDGET: PrebuiltWidget = { id: 'slow-frame-rate-big-number', title: t('Slow Frame Rate'), description: @@ -205,7 +205,7 @@ const SLOW_FRAME_RATE_WIDGET: Widget = { }, }; -const FROZEN_FRAME_RATE_WIDGET: Widget = { +const FROZEN_FRAME_RATE_WIDGET: PrebuiltWidget = { id: 'frozen-frame-rate-big-number', title: t('Frozen Frame Rate'), description: @@ -242,7 +242,7 @@ const FROZEN_FRAME_RATE_WIDGET: Widget = { }, }; -const AVG_FRAME_DELAY_WIDGET: Widget = { +const AVG_FRAME_DELAY_WIDGET: PrebuiltWidget = { id: 'avg-frame-delay-big-number', title: t('Average Frame Delay'), description: 'Average frame delay', @@ -269,7 +269,7 @@ const AVG_FRAME_DELAY_WIDGET: Widget = { }, }; -const APP_START_TABLE: Widget = { +const APP_START_TABLE: PrebuiltWidget = { id: 'app-start-table', title: t('App Starts'), description: t( @@ -320,7 +320,7 @@ const APP_START_TABLE: Widget = { }, }; -const SCREEN_RENDERING_TABLE: Widget = { +const SCREEN_RENDERING_TABLE: PrebuiltWidget = { id: 'screen-rendering-table', title: t('Screen Rendering'), description: @@ -370,7 +370,7 @@ const SCREEN_RENDERING_TABLE: Widget = { }, }; -const SCREEN_LOAD_TABLE: Widget = { +const SCREEN_LOAD_TABLE: PrebuiltWidget = { id: 'screen-load-table', title: t('Screen Loads'), description: '', @@ -414,14 +414,14 @@ const SCREEN_LOAD_TABLE: Widget = { }, }; -const FIRST_ROW_WIDGETS: Widget[] = [ +const FIRST_ROW_WIDGETS: PrebuiltWidget[] = [ COLD_START_BIG_NUMBER_WIDGET, WARM_START_BIG_NUMBER_WIDGET, AVG_TTID_BIG_NUMBER_WIDGET, AVG_TTFD_BIG_NUMBER_WIDGET, ]; -const SECOND_ROW_WIDGETS: Widget[] = [ +const SECOND_ROW_WIDGETS: PrebuiltWidget[] = [ SLOW_FRAME_RATE_WIDGET, FROZEN_FRAME_RATE_WIDGET, AVG_FRAME_DELAY_WIDGET, diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/mobileVitals/screenLoads.ts b/static/app/views/dashboards/utils/prebuiltConfigs/mobileVitals/screenLoads.ts index 73eb34a2f75425..b084e6721380bf 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/mobileVitals/screenLoads.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/mobileVitals/screenLoads.ts @@ -1,7 +1,7 @@ import {t} from 'sentry/locale'; import {FieldKind} from 'sentry/utils/fields'; import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; -import type {Widget} from 'sentry/views/dashboards/types'; +import type {PrebuiltWidget} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import { SCREEN_LOAD_CONDITION, @@ -13,7 +13,7 @@ import { import {SCREEN_LOADS_DASHBOARD_TITLE} from 'sentry/views/dashboards/utils/prebuiltConfigs/mobileVitals/settings'; import {ModuleName, SpanFields} from 'sentry/views/insights/types'; -const AVG_TTID_BIG_NUMBER_WIDGET: Widget = { +const AVG_TTID_BIG_NUMBER_WIDGET: PrebuiltWidget = { id: 'avg-ttid-big-number', title: t('Average TTID'), description: '', @@ -40,7 +40,7 @@ const AVG_TTID_BIG_NUMBER_WIDGET: Widget = { }, }; -const AVG_TTFD_BIG_NUMBER_WIDGET: Widget = { +const AVG_TTFD_BIG_NUMBER_WIDGET: PrebuiltWidget = { id: 'avg-ttfd-big-number', title: t('Average TTFD'), description: '', @@ -67,7 +67,7 @@ const AVG_TTFD_BIG_NUMBER_WIDGET: Widget = { }, }; -const TOTAL_COUNT_BIG_NUMBER_WIDGET: Widget = { +const TOTAL_COUNT_BIG_NUMBER_WIDGET: PrebuiltWidget = { id: 'total-count-big-number', title: t('Total Count'), description: '', @@ -94,7 +94,7 @@ const TOTAL_COUNT_BIG_NUMBER_WIDGET: Widget = { }, }; -const AVG_TTID_LINE_WIDGET: Widget = { +const AVG_TTID_LINE_WIDGET: PrebuiltWidget = { id: 'average-ttid-line', title: t('Average TTID'), description: '', @@ -122,7 +122,7 @@ const AVG_TTID_LINE_WIDGET: Widget = { }, }; -const AVG_TTFD_LINE_WIDGET: Widget = { +const AVG_TTFD_LINE_WIDGET: PrebuiltWidget = { id: 'average-ttfd-line', title: t('Average TTFD'), description: '', @@ -150,7 +150,7 @@ const AVG_TTFD_LINE_WIDGET: Widget = { }, }; -const TOTAL_COUNT_LINE_WIDGET: Widget = { +const TOTAL_COUNT_LINE_WIDGET: PrebuiltWidget = { id: 'total-count-line', title: t('Total Count'), description: '', @@ -178,7 +178,7 @@ const TOTAL_COUNT_LINE_WIDGET: Widget = { }, }; -const TTID_BAR_CHART_WIDGET: Widget = { +const TTID_BAR_CHART_WIDGET: PrebuiltWidget = { id: 'ttid-device-class-bar', title: t('TTID by Device Class'), description: '', @@ -206,7 +206,7 @@ const TTID_BAR_CHART_WIDGET: Widget = { }, }; -const TTFD_BAR_CHART_WIDGET: Widget = { +const TTFD_BAR_CHART_WIDGET: PrebuiltWidget = { id: 'ttfd-device-class-bar', title: t('TTFD by Device Class'), description: '', @@ -234,7 +234,7 @@ const TTFD_BAR_CHART_WIDGET: Widget = { }, }; -const SPAN_OPERATIONS_TABLE: Widget = { +const SPAN_OPERATIONS_TABLE: PrebuiltWidget = { id: 'span-operations-table', title: t('Span Operations'), description: '', @@ -283,19 +283,22 @@ const SPAN_OPERATIONS_TABLE: Widget = { }, }; -const HEADER_ROW_WIDGETS: Widget[] = [ +const HEADER_ROW_WIDGETS: PrebuiltWidget[] = [ AVG_TTID_BIG_NUMBER_WIDGET, AVG_TTFD_BIG_NUMBER_WIDGET, TOTAL_COUNT_BIG_NUMBER_WIDGET, ]; -const SECOND_ROW_WIDGETS: Widget[] = [ +const SECOND_ROW_WIDGETS: PrebuiltWidget[] = [ AVG_TTID_LINE_WIDGET, AVG_TTFD_LINE_WIDGET, TOTAL_COUNT_LINE_WIDGET, ]; -const THIRD_ROW_WIDGETS: Widget[] = [TTID_BAR_CHART_WIDGET, TTFD_BAR_CHART_WIDGET]; +const THIRD_ROW_WIDGETS: PrebuiltWidget[] = [ + TTID_BAR_CHART_WIDGET, + TTFD_BAR_CHART_WIDGET, +]; export const MOBILE_VITALS_SCREEN_LOADS_PREBUILT_CONFIG: PrebuiltDashboard = { dateCreated: '', diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/mobileVitals/screenRendering.ts b/static/app/views/dashboards/utils/prebuiltConfigs/mobileVitals/screenRendering.ts index b06c0013606766..1b84de1da4b2a3 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/mobileVitals/screenRendering.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/mobileVitals/screenRendering.ts @@ -1,13 +1,13 @@ import {t} from 'sentry/locale'; import {FieldKind} from 'sentry/utils/fields'; import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; -import type {Widget} from 'sentry/views/dashboards/types'; +import type {PrebuiltWidget} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import {SCREEN_RENDERING_SPAN_OPERATIONS_CONDITION} from 'sentry/views/dashboards/utils/prebuiltConfigs/mobileVitals/constants'; import {SCREEN_RENDERING_DASHBOARD_TITLE} from 'sentry/views/dashboards/utils/prebuiltConfigs/mobileVitals/settings'; import {ModuleName, SpanFields} from 'sentry/views/insights/types'; -const SPAN_OPERATIONS_TABLE: Widget = { +const SPAN_OPERATIONS_TABLE: PrebuiltWidget = { id: 'span-operations-table', title: t('Span Operations'), description: '', diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/nextJsOverview/nextJsOverview.ts b/static/app/views/dashboards/utils/prebuiltConfigs/nextJsOverview/nextJsOverview.ts index f6d18ff0c89ba5..f033717223763c 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/nextJsOverview/nextJsOverview.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/nextJsOverview/nextJsOverview.ts @@ -1,8 +1,11 @@ import {t} from 'sentry/locale'; import {FieldKind} from 'sentry/utils/fields'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; -import {DisplayType, WidgetType, type Widget} from 'sentry/views/dashboards/types'; -import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; +import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; +import type { + PrebuiltDashboard, + PrebuiltWidget, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import {DASHBOARD_TITLE} from 'sentry/views/dashboards/utils/prebuiltConfigs/nextJsOverview/settings'; import { WIDGET_COLUMN_LABELS, @@ -141,7 +144,7 @@ const CLIENT_TRANSACTIONS_TABLE_FIELDS = [ `performance_score(${SpanFields.TOTAL_SCORE})`, ]; -const CLIENT_TRANSACTIONS_TABLE: Widget = { +const CLIENT_TRANSACTIONS_TABLE: PrebuiltWidget = { id: 'client-transactions-table', title: t('Client Transactions'), displayType: DisplayType.TABLE, @@ -193,7 +196,7 @@ const SERVER_TRANSACTIONS_TABLE_FIELDS = [ `sum(${SpanFields.SPAN_DURATION})`, ]; -const SERVER_TRANSACTIONS_TABLE: Widget = { +const SERVER_TRANSACTIONS_TABLE: PrebuiltWidget = { id: 'server-transactions-table', title: t('Server Transactions'), displayType: DisplayType.TABLE, @@ -234,7 +237,7 @@ const SERVER_TRANSACTIONS_TABLE: Widget = { }, }; -const SERVER_TREE_WIDGET: Widget = { +const SERVER_TREE_WIDGET: PrebuiltWidget = { ...SERVER_TREE_WIDGET_TEMPLATE, layout: { x: 0, diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/nodeRuntimeMetrics/nodeRuntimeMetrics.ts b/static/app/views/dashboards/utils/prebuiltConfigs/nodeRuntimeMetrics/nodeRuntimeMetrics.ts index b8e8d18bd842c7..e66218a5f45a4a 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/nodeRuntimeMetrics/nodeRuntimeMetrics.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/nodeRuntimeMetrics/nodeRuntimeMetrics.ts @@ -1,7 +1,10 @@ import {t} from 'sentry/locale'; import {DurationUnit, SizeUnit} from 'sentry/utils/discover/fields'; -import {DisplayType, WidgetType, type Widget} from 'sentry/views/dashboards/types'; -import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; +import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; +import type { + PrebuiltDashboard, + PrebuiltWidget, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import {DASHBOARD_TITLE} from 'sentry/views/dashboards/utils/prebuiltConfigs/nodeRuntimeMetrics/settings'; import {spaceWidgetsEquallyOnRow} from 'sentry/views/dashboards/utils/prebuiltConfigs/utils/spaceWidgetsEquallyOnRow'; import {traceMetricField} from 'sentry/views/dashboards/utils/prebuiltConfigs/utils/traceMetricField'; @@ -198,7 +201,11 @@ const CORRELATION_WIDGETS = spaceWidgetsEquallyOnRow( 3 ); -const WIDGETS: Widget[] = [...KPI_WIDGETS, ...MEMORY_WIDGETS, ...CORRELATION_WIDGETS]; +const WIDGETS: PrebuiltWidget[] = [ + ...KPI_WIDGETS, + ...MEMORY_WIDGETS, + ...CORRELATION_WIDGETS, +]; export const NODE_RUNTIME_METRICS_PREBUILT_CONFIG: PrebuiltDashboard = { dateCreated: '', diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/queues/queueCharts.ts b/static/app/views/dashboards/utils/prebuiltConfigs/queues/queueCharts.ts index 68df7e0f56fe4c..7651de8fd85681 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/queues/queueCharts.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/queues/queueCharts.ts @@ -1,8 +1,9 @@ import {t} from 'sentry/locale'; -import {DisplayType, WidgetType, type Widget} from 'sentry/views/dashboards/types'; +import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; +import type {PrebuiltWidget} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import {SpanFields} from 'sentry/views/insights/types'; -export const QUEUE_CHARTS: Widget[] = [ +export const QUEUE_CHARTS: PrebuiltWidget[] = [ { id: 'average-duration-widget', title: t('Average Duration'), diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/queues/queueDetails.ts b/static/app/views/dashboards/utils/prebuiltConfigs/queues/queueDetails.ts index 4a64a03f9b3a69..8215cd052adca9 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/queues/queueDetails.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/queues/queueDetails.ts @@ -1,6 +1,9 @@ import {t} from 'sentry/locale'; -import {DisplayType, WidgetType, type Widget} from 'sentry/views/dashboards/types'; -import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; +import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; +import type { + PrebuiltDashboard, + PrebuiltWidget, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import {QUEUE_CHARTS} from 'sentry/views/dashboards/utils/prebuiltConfigs/queues/queueCharts'; import {DETAILS_DASHBOARD_TITLE} from 'sentry/views/dashboards/utils/prebuiltConfigs/queues/settings'; import {spaceWidgetsEquallyOnRow} from 'sentry/views/dashboards/utils/prebuiltConfigs/utils/spaceWidgetsEquallyOnRow'; @@ -127,7 +130,7 @@ const FIRST_ROW_WIDGTS = spaceWidgetsEquallyOnRow( const SECOND_ROW_WIDGETS = spaceWidgetsEquallyOnRow([...QUEUE_CHARTS], 1); -const PRODUCER_TABLE: Widget = { +const PRODUCER_TABLE: PrebuiltWidget = { id: 'producer-table', title: t('Producer Transactions'), displayType: DisplayType.TABLE, @@ -162,7 +165,7 @@ const PRODUCER_TABLE: Widget = { }, }; -const CONSUMER_TABLE: Widget = { +const CONSUMER_TABLE: PrebuiltWidget = { id: 'consumer-table', title: t('Consumer Transactions'), displayType: DisplayType.TABLE, diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/queues/queues.ts b/static/app/views/dashboards/utils/prebuiltConfigs/queues/queues.ts index 777937b8b714c7..8bed62d629238a 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/queues/queues.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/queues/queues.ts @@ -1,7 +1,10 @@ import {t} from 'sentry/locale'; import {FieldKind} from 'sentry/utils/fields'; -import {DisplayType, WidgetType, type Widget} from 'sentry/views/dashboards/types'; -import type {PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConfigs'; +import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; +import type { + PrebuiltDashboard, + PrebuiltWidget, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import {QUEUE_CHARTS} from 'sentry/views/dashboards/utils/prebuiltConfigs/queues/queueCharts'; import {DASHBOARD_TITLE} from 'sentry/views/dashboards/utils/prebuiltConfigs/queues/settings'; import {TABLE_MIN_HEIGHT} from 'sentry/views/dashboards/utils/prebuiltConfigs/settings'; @@ -10,7 +13,7 @@ import {ModuleName, SpanFields} from 'sentry/views/insights/types'; const FIRST_ROW_WIDGETS = spaceWidgetsEquallyOnRow([...QUEUE_CHARTS], 0); -const DESTINATION_TABLE: Widget = { +const DESTINATION_TABLE: PrebuiltWidget = { id: 'destination-table', title: t('Destinations'), displayType: DisplayType.TABLE, diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/utils/spaceWidgetsEquallyOnRow.ts b/static/app/views/dashboards/utils/prebuiltConfigs/utils/spaceWidgetsEquallyOnRow.ts index 84e32ae3f2c82c..3c67328bd4efcd 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/utils/spaceWidgetsEquallyOnRow.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/utils/spaceWidgetsEquallyOnRow.ts @@ -1,11 +1,15 @@ import {NUM_DESKTOP_COLS} from 'sentry/views/dashboards/constants'; import type {Widget, WidgetLayout} from 'sentry/views/dashboards/types'; +import type { + PrebuiltWidget, + PrebuiltWidgetLayout, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; export function spaceWidgetsEquallyOnRow( widgets: Widget[], y: number, height: Pick = {h: 2, minH: 2} -): Widget[] { +): PrebuiltWidget[] { if (widgets.length > NUM_DESKTOP_COLS) { throw new Error( `Expected no more than ${NUM_DESKTOP_COLS} widgets, got ${widgets.length}` @@ -18,12 +22,14 @@ export function spaceWidgetsEquallyOnRow( const widgetWidth = Math.floor(NUM_DESKTOP_COLS / widgets.length); + // Casts are safe: the early-return above caps widgets.length at + // NUM_DESKTOP_COLS, so widgetWidth in [1,6] and idx*widgetWidth in [0,5]. return widgets.map((widget, idx) => ({ ...widget, layout: { - x: idx * widgetWidth, + x: (idx * widgetWidth) as PrebuiltWidgetLayout['x'], y, - w: widgetWidth, + w: widgetWidth as PrebuiltWidgetLayout['w'], ...height, }, })); From 012d8ec2967902084d5d01f43898c0dbc3b208a4 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 14:45:23 +0000 Subject: [PATCH 09/14] fix(feedback): remove extra padding from LayoutGrid component (#116377) Replaces the previous breakpoint-specific padding on `LayoutGrid` with a flat `padding: 8px 16px` (`space.md space.xl`) applied at all breakpoints. Action taken on behalf of Ryan Albrecht. **Before** before **After** after --- [View Session in Sentry](https://sentry.sentry.io/traces/?project=4510944073809921&query=gen_ai.conversation.id%3A%22slack%3AC0B63QA6RGA%3A1779975838.713209%22) --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> --- static/app/views/feedback/feedbackListPage.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/static/app/views/feedback/feedbackListPage.tsx b/static/app/views/feedback/feedbackListPage.tsx index 29e715f9ece3e2..49208026cf9a9a 100644 --- a/static/app/views/feedback/feedbackListPage.tsx +++ b/static/app/views/feedback/feedbackListPage.tsx @@ -314,11 +314,7 @@ const LayoutGrid = styled('div')<{hideTop?: boolean}>` gap: ${p => p.theme.space.xl}; place-items: stretch; - padding: ${p => p.theme.space.xl}; - - @media (min-width: ${p => p.theme.breakpoints.lg}) { - padding: ${p => p.theme.space.xl} ${p => p.theme.space['3xl']}; - } + padding: ${p => p.theme.space.lg} ${p => p.theme.space.xl}; grid-template-rows: max-content minmax(0, 1fr); grid-template-areas: From 18bc507570ff54f2a87ac0af8961918204efa41e Mon Sep 17 00:00:00 2001 From: Mark Story Date: Thu, 28 May 2026 10:59:24 -0400 Subject: [PATCH 10/14] chore(typing) Fix typing issues in relocations (#116301) Remove relocations code from the typing ignore list and fix up the typing issues in those modules. --- pyproject.toml | 13 ------------- src/sentry/relocation/api/endpoints/index.py | 5 +++-- src/sentry/relocation/models/relocation.py | 6 +++--- src/sentry/relocation/tasks/process.py | 12 ++++++------ src/sentry/relocation/tasks/transfer.py | 3 ++- src/sentry/relocation/utils.py | 6 +++--- 6 files changed, 17 insertions(+), 28 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cbf0f462485b2a..b8882810da83d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1286,19 +1286,6 @@ module = [ "sentry.release_health.tasks", "sentry.releases", "sentry.releases.endpoints.*", - "sentry.relocation.api.endpoints", - "sentry.relocation.api.endpoints.abort", - "sentry.relocation.api.endpoints.cancel", - "sentry.relocation.api.endpoints.details", - "sentry.relocation.api.endpoints.index", - "sentry.relocation.api.endpoints.pause", - "sentry.relocation.api.endpoints.public_key", - "sentry.relocation.api.endpoints.recover", - "sentry.relocation.api.endpoints.retry", - "sentry.relocation.api.endpoints.unpause", - "sentry.relocation.models.*", - "sentry.relocation.tasks.*", - "sentry.relocation.utils", "sentry.remote_subscriptions.*", "sentry.replays", "sentry.replays._case_studies.*", diff --git a/src/sentry/relocation/api/endpoints/index.py b/src/sentry/relocation/api/endpoints/index.py index e6a44d5cbcdbc2..f9e5c6d573e477 100644 --- a/src/sentry/relocation/api/endpoints/index.py +++ b/src/sentry/relocation/api/endpoints/index.py @@ -3,6 +3,7 @@ from datetime import timedelta from functools import reduce from string import Template +from typing import Any from django.db import router from django.db.models import Q @@ -53,7 +54,7 @@ RELOCATION_FILE_SIZE_MEDIUM = 100 * 1024**2 -def get_relocation_size_category(size) -> str: +def get_relocation_size_category(size: int) -> str: if size < RELOCATION_FILE_SIZE_SMALL: return "small" elif size < RELOCATION_FILE_SIZE_MEDIUM: @@ -81,7 +82,7 @@ def should_throttle_relocation(relocation_bucket_size: str) -> bool: return True -class RelocationsPostSerializer(serializers.Serializer): +class RelocationsPostSerializer(serializers.Serializer[dict[str, Any]]): file = serializers.FileField(required=True) orgs = serializers.CharField(required=True, allow_blank=False, allow_null=False) owner = serializers.CharField( diff --git a/src/sentry/relocation/models/relocation.py b/src/sentry/relocation/models/relocation.py index 3e9e9891ecb84b..42406e23fab43b 100644 --- a/src/sentry/relocation/models/relocation.py +++ b/src/sentry/relocation/models/relocation.py @@ -12,7 +12,7 @@ from sentry.db.models.fields.uuid import UUIDField -def default_guid(): +def default_guid() -> str: return uuid4().hex @@ -57,7 +57,7 @@ def get_in_progress_choices(cls) -> list[tuple[int, str]]: return [(key.value, key.name) for key in cls if key.name != "COMPLETED"] @classmethod - def max_value(cls): + def max_value(cls) -> int: return max(item.value for item in cls) class Status(Enum): @@ -242,7 +242,7 @@ def __str__(self) -> str: else: raise ValueError("Cannot extract a filename from `RelocationFile.Kind.UNKNOWN`.") - def to_filename(self, ext: str): + def to_filename(self, ext: str) -> str: return str(self) + "." + ext relocation = FlexibleForeignKey("sentry.Relocation") diff --git a/src/sentry/relocation/tasks/process.py b/src/sentry/relocation/tasks/process.py index 9b7341f075cea6..72db7295c9cdb7 100644 --- a/src/sentry/relocation/tasks/process.py +++ b/src/sentry/relocation/tasks/process.py @@ -971,11 +971,11 @@ class NextTask: the task to be scheduled at some later point in the execution. """ - task: Task + task: Task[..., Any] args: list[Any] countdown: int | None = None - def schedule(self): + def schedule(self) -> None: """ Run the `.apply_async()` call defined by this future. """ @@ -1126,7 +1126,7 @@ def validating_start(uuid: str) -> None: ): cb_client = CloudBuildClient() - def camel_to_snake_keep_underscores(value): + def camel_to_snake_keep_underscores(value: str) -> str: match = re.search(r"(_++)$", value) converted = camel_to_snake_case(value) return converted + (match.group(0) if match else "") @@ -1708,11 +1708,11 @@ def completed(uuid: str) -> None: processing_deadline_duration=FAST_TIME_LIMIT, silo_mode=SiloMode.CELL, ) -def noop(): +def noop() -> None: pass -TASK_MAP: dict[OrderedTask, Task] = { +TASK_MAP: dict[OrderedTask, Task[..., Any]] = { OrderedTask.NONE: noop, OrderedTask.UPLOADING_START: uploading_start, OrderedTask.UPLOADING_COMPLETE: uploading_complete, @@ -1735,7 +1735,7 @@ def noop(): assert set(OrderedTask._member_map_.keys()) == {k.name for k in TASK_MAP.keys()} -def get_first_task_for_step(target_step: Relocation.Step) -> Task | None: +def get_first_task_for_step(target_step: Relocation.Step) -> Task[..., Any] | None: min_task: OrderedTask | None = None for ordered_task, step in TASK_TO_STEP.items(): if step == target_step: diff --git a/src/sentry/relocation/tasks/transfer.py b/src/sentry/relocation/tasks/transfer.py index 6e6b314b4e3e4e..c1e0f25c11a791 100644 --- a/src/sentry/relocation/tasks/transfer.py +++ b/src/sentry/relocation/tasks/transfer.py @@ -1,4 +1,5 @@ import logging +from typing import Any from django.db.models import Subquery from django.utils import timezone @@ -46,7 +47,7 @@ def find_relocation_transfer_region() -> None: def _find_relocation_transfer( model_cls: type[BaseRelocationTransfer], - process_task: Task, + process_task: Task[..., Any], ) -> None: """ Advance the scheduled_for time for all transfers that are diff --git a/src/sentry/relocation/utils.py b/src/sentry/relocation/utils.py index 0b42c92dd7b882..2191085490efad 100644 --- a/src/sentry/relocation/utils.py +++ b/src/sentry/relocation/utils.py @@ -650,7 +650,7 @@ def make_cloudbuild_step_args(indent: int, args: list[str]) -> str: # The set of arguments to invoke a "docker compose" in a cloudbuild step is tedious and repetitive - # better to just handle it here. @lru_cache(maxsize=1) -def get_docker_compose_cmd(): +def get_docker_compose_cmd() -> str: return make_cloudbuild_step_args( 3, [ @@ -666,7 +666,7 @@ def get_docker_compose_cmd(): # The set of arguments to invoke a "docker compose run" in a cloudbuild step is tedious and # repetitive - better to just handle it here. @lru_cache(maxsize=1) -def get_docker_compose_run(): +def get_docker_compose_run() -> str: return make_cloudbuild_step_args( 3, [ @@ -678,7 +678,7 @@ def get_docker_compose_run(): @lru_cache(maxsize=1) -def get_relocations_bucket_name(): +def get_relocations_bucket_name() -> str: """ When using the local FileSystemStorage (ie, in tests), we use a contrived bucket name, since this is really just an alias for a bespoke local directory in that case. From de5caf4a7da97252d0e4ff7b4c29f98deb3a6a25 Mon Sep 17 00:00:00 2001 From: Nick Date: Thu, 28 May 2026 11:59:51 -0300 Subject: [PATCH 11/14] ref(search-query-builder): Break up contexts (#116126) The goal of this PR is to break up the context for the search query builder into smaller more focused contexts. Currently the single context is quite large and can probably be narrowed down for better maintainability of the search query builder. Closes EXP-963 --------- Co-authored-by: OpenAI Codex --- .../searchQueryBuilder/askSeer/askSeer.tsx | 4 +- .../askSeer/askSeerFeedback.tsx | 4 +- .../askSeer/askSeerOption.tsx | 4 +- .../askSeerCombobox/askSeerComboBox.spec.tsx | 4 +- .../askSeerCombobox/askSeerComboBox.tsx | 4 +- .../askSeerPollingComboBox.tsx | 4 +- .../askSeerCombobox/queryTokens.tsx | 4 +- .../components/searchQueryBuilder/context.tsx | 288 ++++++++++++------ .../formattedQuery.spec.tsx | 6 +- .../searchQueryBuilder/formattedQuery.tsx | 4 +- .../searchQueryBuilder/hooks/useOnChange.tsx | 4 +- .../hooks/useQueryBuilderGridItem.tsx | 4 +- .../hooks/useSelectOnDrag.tsx | 4 +- .../searchQueryBuilder/hooks/useUndoStack.tsx | 4 +- .../searchQueryBuilder/index.spec.tsx | 6 +- .../searchQueryBuilder/index.stories.tsx | 12 +- .../components/searchQueryBuilder/index.tsx | 21 +- .../plainTextQueryInput.tsx | 11 +- .../selectionKeyHandler.tsx | 10 +- .../searchQueryBuilder/tokenizedQueryGrid.tsx | 14 +- .../searchQueryBuilder/tokens/boolean.tsx | 11 +- .../searchQueryBuilder/tokens/combobox.tsx | 15 +- .../tokens/deletableToken.tsx | 4 +- .../tokens/filter/aggregateKey.tsx | 8 +- .../tokens/filter/filter.tsx | 18 +- .../tokens/filter/filterKey.tsx | 4 +- .../tokens/filter/filterKeyCombobox.tsx | 19 +- .../tokens/filter/filterOperator.tsx | 19 +- .../tokens/filter/functionDescription.tsx | 4 +- .../tokens/filter/parametersCombobox.tsx | 11 +- .../tokens/filter/useAggregateParamVisual.tsx | 4 +- .../tokens/filter/valueCombobox.tsx | 16 +- .../tokens/filterKeyListBox/index.tsx | 18 +- .../filterKeyListBox/keyDescription.tsx | 4 +- .../filterKeyListBox/useFilterKeyListBox.tsx | 27 +- .../useRecentSearchFilters.tsx | 10 +- .../filterKeyListBox/useRecentSearches.tsx | 4 +- .../searchQueryBuilder/tokens/freeText.tsx | 19 +- .../tokens/useSortedFilterKeyItems.tsx | 9 +- .../results/issueListSeerComboBox.tsx | 17 +- .../results/resultsSearchQueryBuilder.tsx | 4 +- .../schemaHints/schemaHintsList.spec.tsx | 31 +- .../schemaHints/schemaHintsList.tsx | 8 +- static/app/views/explore/logs/logsTab.tsx | 4 +- .../explore/logs/logsTabSeerComboBox.tsx | 16 +- .../explore/metrics/metricToolbar/filter.tsx | 4 +- .../metrics/metricsTabSeerComboBox.tsx | 16 +- .../explore/spans/spansTabSearchSection.tsx | 4 +- .../explore/spans/spansTabSeerComboBox.tsx | 16 +- .../views/issueList/issueListSeerComboBox.tsx | 17 +- static/app/views/issueList/issueSearch.tsx | 4 +- 51 files changed, 469 insertions(+), 312 deletions(-) diff --git a/static/app/components/searchQueryBuilder/askSeer/askSeer.tsx b/static/app/components/searchQueryBuilder/askSeer/askSeer.tsx index b1cf2b84fa530c..3467f365ba13c1 100644 --- a/static/app/components/searchQueryBuilder/askSeer/askSeer.tsx +++ b/static/app/components/searchQueryBuilder/askSeer/askSeer.tsx @@ -11,13 +11,13 @@ import { AskSeerListItem, AskSeerPane, } from 'sentry/components/searchQueryBuilder/askSeer/components'; -import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context'; +import {useSearchQueryBuilderAI} from 'sentry/components/searchQueryBuilder/context'; import {t} from 'sentry/locale'; import {useOrganization} from 'sentry/utils/useOrganization'; export function AskSeer({state}: {state: ComboBoxState}) { const organization = useOrganization(); - const {displayAskSeerFeedback} = useSearchQueryBuilder(); + const {displayAskSeerFeedback} = useSearchQueryBuilderAI(); const isMutating = useIsMutating({ mutationKey: [setupCheckQueryKey(organization.slug)], diff --git a/static/app/components/searchQueryBuilder/askSeer/askSeerFeedback.tsx b/static/app/components/searchQueryBuilder/askSeer/askSeerFeedback.tsx index 0e2e82b9d5da9c..4933a8efa82f9a 100644 --- a/static/app/components/searchQueryBuilder/askSeer/askSeerFeedback.tsx +++ b/static/app/components/searchQueryBuilder/askSeer/askSeerFeedback.tsx @@ -5,7 +5,7 @@ import {Text} from '@sentry/scraps/text'; import {useAnalyticsArea} from 'sentry/components/analyticsArea'; import {AskSeerLabel} from 'sentry/components/searchQueryBuilder/askSeer/components'; -import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context'; +import {useSearchQueryBuilderAI} from 'sentry/components/searchQueryBuilder/context'; import {IconSeer, IconThumb} from 'sentry/icons'; import {t} from 'sentry/locale'; import {trackAnalytics} from 'sentry/utils/analytics'; @@ -15,7 +15,7 @@ export function AskSeerFeedback() { const organization = useOrganization(); const analyticsArea = useAnalyticsArea(); const {setDisplayAskSeerFeedback, askSeerNLQueryRef, askSeerSuggestedQueryRef} = - useSearchQueryBuilder(); + useSearchQueryBuilderAI(); const handleClick = (type: 'positive' | 'negative') => { trackAnalytics('ai_query.feedback', { diff --git a/static/app/components/searchQueryBuilder/askSeer/askSeerOption.tsx b/static/app/components/searchQueryBuilder/askSeer/askSeerOption.tsx index 0729dae919811b..f45ca54121a8a4 100644 --- a/static/app/components/searchQueryBuilder/askSeer/askSeerOption.tsx +++ b/static/app/components/searchQueryBuilder/askSeer/askSeerOption.tsx @@ -11,7 +11,7 @@ import { AskSeerLabel, AskSeerListItem, } from 'sentry/components/searchQueryBuilder/askSeer/components'; -import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context'; +import {useSearchQueryBuilderAI} from 'sentry/components/searchQueryBuilder/context'; import {IconSeer} from 'sentry/icons'; import {t} from 'sentry/locale'; import {trackAnalytics} from 'sentry/utils/analytics'; @@ -21,7 +21,7 @@ export const ASK_SEER_ITEM_KEY = 'ask_seer'; export function AskSeerOption({state}: {state: ComboBoxState}) { const ref = useRef(null); - const {setDisplayAskSeer, aiSearchBadgeType} = useSearchQueryBuilder(); + const {setDisplayAskSeer, aiSearchBadgeType} = useSearchQueryBuilderAI(); const analyticsArea = useAnalyticsArea(); const organization = useOrganization(); diff --git a/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerComboBox.spec.tsx b/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerComboBox.spec.tsx index de70de119131f0..6d6b2a89521e41 100644 --- a/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerComboBox.spec.tsx +++ b/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerComboBox.spec.tsx @@ -7,7 +7,7 @@ import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrar import {AskSeerComboBox} from 'sentry/components/searchQueryBuilder/askSeerCombobox/askSeerComboBox'; import { SearchQueryBuilderProvider, - useSearchQueryBuilder, + useSearchQueryBuilderAI, } from 'sentry/components/searchQueryBuilder/context'; import {fetchMutation} from 'sentry/utils/queryClient'; @@ -116,7 +116,7 @@ describe('AskSeerComboBox', () => { it('closes seer search when close button is clicked', async () => { function TestComponent() { - const {displayAskSeer, setDisplayAskSeer} = useSearchQueryBuilder(); + const {displayAskSeer, setDisplayAskSeer} = useSearchQueryBuilderAI(); return displayAskSeer ? ( ({ autoSubmitSeer, setAutoSubmitSeer, enableAISearch, - } = useSearchQueryBuilder(); + } = useSearchQueryBuilderAI(); const analyticsArea = useAnalyticsArea(); diff --git a/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerPollingComboBox.tsx b/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerPollingComboBox.tsx index 362f04cd0967fd..a27df53b420ebc 100644 --- a/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerPollingComboBox.tsx +++ b/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerPollingComboBox.tsx @@ -28,7 +28,7 @@ import { generateQueryTokensString, isNoneOfTheseItem, } from 'sentry/components/searchQueryBuilder/askSeerCombobox/utils'; -import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context'; +import {useSearchQueryBuilderAI} from 'sentry/components/searchQueryBuilder/context'; import {useSearchTokenCombobox} from 'sentry/components/searchQueryBuilder/tokens/useSearchTokenCombobox'; import {IconClose, IconMegaphone, IconSearch} from 'sentry/icons'; import {t} from 'sentry/locale'; @@ -139,7 +139,7 @@ export function AskSeerPollingComboBox({ autoSubmitSeer, setAutoSubmitSeer, enableAISearch, - } = useSearchQueryBuilder(); + } = useSearchQueryBuilderAI(); const analyticsArea = useAnalyticsArea(); diff --git a/static/app/components/searchQueryBuilder/askSeerCombobox/queryTokens.tsx b/static/app/components/searchQueryBuilder/askSeerCombobox/queryTokens.tsx index c811b3ef5a7f48..c6dfc69b16c1c6 100644 --- a/static/app/components/searchQueryBuilder/askSeerCombobox/queryTokens.tsx +++ b/static/app/components/searchQueryBuilder/askSeerCombobox/queryTokens.tsx @@ -4,7 +4,7 @@ import {Flex} from '@sentry/scraps/layout'; import type {QueryTokensProps} from 'sentry/components/searchQueryBuilder/askSeerCombobox/types'; import {formatDateRange} from 'sentry/components/searchQueryBuilder/askSeerCombobox/utils'; -import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context'; +import {useSearchQueryBuilderConfig} from 'sentry/components/searchQueryBuilder/context'; import {ProvidedFormattedQuery} from 'sentry/components/searchQueryBuilder/formattedQuery'; import {parseQueryBuilderValue} from 'sentry/components/searchQueryBuilder/utils'; import {t} from 'sentry/locale'; @@ -19,7 +19,7 @@ export function QueryTokens({ visualizations, }: QueryTokensProps) { const tokens = []; - const {getFieldDefinition} = useSearchQueryBuilder(); + const {getFieldDefinition} = useSearchQueryBuilderConfig(); const parsedQuery = query ? parseQueryBuilderValue(query, getFieldDefinition) : null; if (query && parsedQuery?.length) { tokens.push( diff --git a/static/app/components/searchQueryBuilder/context.tsx b/static/app/components/searchQueryBuilder/context.tsx index 07a6373cc2fe57..7a5c88e6bc4fda 100644 --- a/static/app/components/searchQueryBuilder/context.tsx +++ b/static/app/components/searchQueryBuilder/context.tsx @@ -35,72 +35,120 @@ import {useDimensions} from 'sentry/utils/useDimensions'; import {useOrganization} from 'sentry/utils/useOrganization'; import {usePrevious} from 'sentry/utils/usePrevious'; -interface SearchQueryBuilderContextData { - actionBarRef: React.RefObject; - aiSearchBadgeType: 'alpha' | 'beta'; - askSeerNLQueryRef: React.RefObject; - askSeerSuggestedQueryRef: React.RefObject; - autoSubmitSeer: boolean; +interface SearchQueryBuilderStateContextData { clearSearchQuery: (options?: {reopenDropdown?: boolean}) => void; committedQuery: string; - consumeReopenDropdownOnQueryClear: () => void; - currentInputValueRef: React.RefObject; + dispatch: Dispatch; + focusOverride: FocusOverride | null; + handleSearch: (query: string) => void; + parseQuery: (query: string) => ParseResult | null; + parsedQuery: ParseResult | null; + query: string; +} + +interface SearchQueryBuilderConfigContextData { + caseInsensitive: CaseInsensitive | undefined; disabled: boolean; disallowFreeText: boolean; disallowLogicalOperators: boolean; disallowWildcard: boolean; - dispatch: Dispatch; - displayAskSeer: boolean; - displayAskSeerFeedback: boolean; - enableAISearch: boolean; - filterKeyMenuWidth: number; + filterKeyAliases: TagCollection | undefined; filterKeySections: FilterKeySection[]; filterKeys: TagCollection; - focusOverride: FocusOverride | null; - // @deprecated: remove this, it's constant now - gaveSeerConsent: true; getFieldDefinition: (key: string, kind?: FieldKind) => FieldDefinition | null; getSuggestedFilterKey: (key: string) => string | null; + getTagKeys: GetTagKeys | undefined; getTagValues: GetTagValues; - handleSearch: (query: string) => void; invalidFilterKeys: string[]; - parseQuery: (query: string) => ParseResult | null; - parsedQuery: ParseResult | null; - query: string; - reopenDropdownOnQueryClear: boolean; + matchKeySuggestions: Array<{key: string; valuePattern: RegExp}> | undefined; + namespace: string | undefined; + onCaseInsensitiveClick: ((value: CaseInsensitive) => void) | undefined; + placeholder: string | undefined; + recentSearches: SavedSearchType | undefined; + replaceRawSearchKeys: string[] | undefined; searchSource: string; +} + +interface SearchQueryBuilderLayoutContextData { + actionBarRef: React.RefObject; + currentInputValueRef: React.RefObject; + filterKeyMenuWidth: number; + portalTarget: HTMLElement | null | undefined; + size: 'small' | 'normal'; + wrapperRef: React.RefObject; +} + +interface SearchQueryBuilderAIContextData { + aiSearchBadgeType: 'alpha' | 'beta'; + askSeerNLQueryRef: React.RefObject; + askSeerSuggestedQueryRef: React.RefObject; + autoSubmitSeer: boolean; + displayAskSeer: boolean; + displayAskSeerFeedback: boolean; + enableAISearch: boolean; setAutoSubmitSeer: (enabled: boolean) => void; setDisplayAskSeer: (enabled: boolean) => void; setDisplayAskSeerFeedback: (enabled: boolean) => void; - size: 'small' | 'normal'; - wrapperRef: React.RefObject; - caseInsensitive?: CaseInsensitive; - filterKeyAliases?: TagCollection; - getTagKeys?: GetTagKeys; - matchKeySuggestions?: Array<{key: string; valuePattern: RegExp}>; - namespace?: string; - onCaseInsensitiveClick?: (value: CaseInsensitive) => void; - placeholder?: string; - /** - * The element to render the combobox popovers into. - */ - portalTarget?: HTMLElement | null; - recentSearches?: SavedSearchType; - replaceRawSearchKeys?: string[]; } -export function useSearchQueryBuilder() { - const context = useContext(SearchQueryBuilderContext); - if (!context) { - throw new Error( - 'useSearchQueryBuilder must be used within a SearchQueryBuilderProvider' - ); +interface SearchQueryBuilderInteractionContextData { + consumeReopenDropdownOnQueryClear: () => void; + reopenDropdownOnQueryClear: boolean; +} + +function useRequiredContext(context: React.Context, hookName: string) { + const contextValue = useContext(context); + if (!contextValue) { + throw new Error(`${hookName} must be used within a SearchQueryBuilderProvider`); } - return context; + return contextValue; +} + +export function useSearchQueryBuilderState() { + return useRequiredContext(SearchQueryBuilderStateContext, 'useSearchQueryBuilderState'); +} + +export function useSearchQueryBuilderConfig() { + return useRequiredContext( + SearchQueryBuilderConfigContext, + 'useSearchQueryBuilderConfig' + ); +} + +export function useSearchQueryBuilderLayout() { + return useRequiredContext( + SearchQueryBuilderLayoutContext, + 'useSearchQueryBuilderLayout' + ); +} + +export function useSearchQueryBuilderAI() { + return useRequiredContext(SearchQueryBuilderAIContext, 'useSearchQueryBuilderAI'); } -export const SearchQueryBuilderContext = - createContext(null); +export function useSearchQueryBuilderInteraction() { + return useRequiredContext( + SearchQueryBuilderInteractionContext, + 'useSearchQueryBuilderInteraction' + ); +} + +export function useHasSearchQueryBuilderProvider() { + return useContext(SearchQueryBuilderProviderContext); +} + +const SearchQueryBuilderStateContext = + createContext(null); +const SearchQueryBuilderConfigContext = + createContext(null); +const SearchQueryBuilderLayoutContext = + createContext(null); +const SearchQueryBuilderAIContext = createContext( + null +); +const SearchQueryBuilderInteractionContext = + createContext(null); +const SearchQueryBuilderProviderContext = createContext(false); export function SearchQueryBuilderProvider({ children, @@ -267,96 +315,136 @@ export function SearchQueryBuilderProvider({ const size = searchBarWidth && searchBarWidth < 600 ? ('small' as const) : ('normal' as const); - const contextValue = useMemo((): SearchQueryBuilderContextData => { + const stateValue = useMemo((): SearchQueryBuilderStateContextData => { return { - ...state, - aiSearchBadgeType, - invalidFilterKeys: stableInvalidFilterKeys, + clearSearchQuery, + committedQuery: state.committedQuery, + dispatch, + focusOverride: state.focusOverride, + handleSearch, + parseQuery, + parsedQuery, + query: state.query, + }; + }, [ + clearSearchQuery, + dispatch, + handleSearch, + parseQuery, + parsedQuery, + state.committedQuery, + state.focusOverride, + state.query, + ]); + + const configValue = useMemo((): SearchQueryBuilderConfigContextData => { + return { + caseInsensitive, disabled, disallowFreeText: Boolean(disallowFreeText), disallowLogicalOperators: Boolean(disallowLogicalOperators), disallowWildcard: Boolean(disallowWildcard), - enableAISearch, - parseQuery, - parsedQuery, + filterKeyAliases, filterKeySections: filterKeySections ?? [], - filterKeyMenuWidth, filterKeys: stableFilterKeys, + getFieldDefinition: stableFieldDefinitionGetter, getSuggestedFilterKey: stableGetSuggestedFilterKey, - getTagValues, getTagKeys, - getFieldDefinition: stableFieldDefinitionGetter, - dispatch, - clearSearchQuery, - consumeReopenDropdownOnQueryClear, - wrapperRef, - actionBarRef, - handleSearch, + getTagValues, + invalidFilterKeys: stableInvalidFilterKeys, + matchKeySuggestions, + namespace, + onCaseInsensitiveClick, placeholder, recentSearches, - namespace, - searchSource, - size, - portalTarget, - autoSubmitSeer, - setAutoSubmitSeer, - displayAskSeer, - setDisplayAskSeer: setDisplayAskSeerState, replaceRawSearchKeys, - matchKeySuggestions, - filterKeyAliases, - gaveSeerConsent: true, - currentInputValueRef, - reopenDropdownOnQueryClear, - displayAskSeerFeedback, - setDisplayAskSeerFeedback, - askSeerNLQueryRef, - askSeerSuggestedQueryRef, - caseInsensitive, - onCaseInsensitiveClick, + searchSource, }; }, [ - aiSearchBadgeType, - autoSubmitSeer, caseInsensitive, - clearSearchQuery, - consumeReopenDropdownOnQueryClear, disabled, disallowFreeText, disallowLogicalOperators, disallowWildcard, - dispatch, - displayAskSeer, - displayAskSeerFeedback, - enableAISearch, filterKeyAliases, - filterKeyMenuWidth, filterKeySections, getTagKeys, getTagValues, - handleSearch, stableInvalidFilterKeys, matchKeySuggestions, + namespace, onCaseInsensitiveClick, - parseQuery, - parsedQuery, placeholder, - portalTarget, recentSearches, - reopenDropdownOnQueryClear, - namespace, replaceRawSearchKeys, searchSource, - size, stableFieldDefinitionGetter, stableFilterKeys, stableGetSuggestedFilterKey, - state, ]); + const layoutValue = useMemo((): SearchQueryBuilderLayoutContextData => { + return { + actionBarRef, + currentInputValueRef, + filterKeyMenuWidth, + portalTarget, + size, + wrapperRef, + }; + }, [ + actionBarRef, + currentInputValueRef, + filterKeyMenuWidth, + portalTarget, + size, + wrapperRef, + ]); + + const aiValue = useMemo((): SearchQueryBuilderAIContextData => { + return { + aiSearchBadgeType, + askSeerNLQueryRef, + askSeerSuggestedQueryRef, + autoSubmitSeer, + displayAskSeer, + displayAskSeerFeedback, + enableAISearch, + setAutoSubmitSeer, + setDisplayAskSeer: setDisplayAskSeerState, + setDisplayAskSeerFeedback, + }; + }, [ + aiSearchBadgeType, + askSeerNLQueryRef, + askSeerSuggestedQueryRef, + autoSubmitSeer, + displayAskSeer, + displayAskSeerFeedback, + enableAISearch, + setDisplayAskSeerFeedback, + ]); + + const interactionValue = useMemo((): SearchQueryBuilderInteractionContextData => { + return { + consumeReopenDropdownOnQueryClear, + reopenDropdownOnQueryClear, + }; + }, [consumeReopenDropdownOnQueryClear, reopenDropdownOnQueryClear]); + return ( - - {children} - + + + + + + + {children} + + + + + + ); } diff --git a/static/app/components/searchQueryBuilder/formattedQuery.spec.tsx b/static/app/components/searchQueryBuilder/formattedQuery.spec.tsx index 355ab1dd934468..d15a41aaa4343f 100644 --- a/static/app/components/searchQueryBuilder/formattedQuery.spec.tsx +++ b/static/app/components/searchQueryBuilder/formattedQuery.spec.tsx @@ -15,10 +15,12 @@ const FILTER_KEYS: TagCollection = { }; jest.mock('sentry/components/searchQueryBuilder/context', () => ({ - useSearchQueryBuilder: () => ({ - size: 'normal', + useSearchQueryBuilderConfig: () => ({ getFieldDefinition: () => null, }), + useSearchQueryBuilderLayout: () => ({ + size: 'normal', + }), })); describe('FormattedQuery', () => { diff --git a/static/app/components/searchQueryBuilder/formattedQuery.tsx b/static/app/components/searchQueryBuilder/formattedQuery.tsx index 0b6361b4a82066..c4c736a134dbf6 100644 --- a/static/app/components/searchQueryBuilder/formattedQuery.tsx +++ b/static/app/components/searchQueryBuilder/formattedQuery.tsx @@ -5,7 +5,7 @@ import {Text} from '@sentry/scraps/text'; import { SearchQueryBuilderProvider, - useSearchQueryBuilder, + useSearchQueryBuilderConfig, } from 'sentry/components/searchQueryBuilder/context'; import {AggregateKeyVisual} from 'sentry/components/searchQueryBuilder/tokens/filter/aggregateKey'; import {FilterValueText} from 'sentry/components/searchQueryBuilder/tokens/filter/filter'; @@ -54,7 +54,7 @@ function FilterKey({token}: {token: TokenResult}) { } function Filter({token}: {token: TokenResult}) { - const {getFieldDefinition} = useSearchQueryBuilder(); + const {getFieldDefinition} = useSearchQueryBuilderConfig(); const label = useMemo( () => getOperatorInfo({ diff --git a/static/app/components/searchQueryBuilder/hooks/useOnChange.tsx b/static/app/components/searchQueryBuilder/hooks/useOnChange.tsx index 9e6d9889847db3..4a513bcf37888a 100644 --- a/static/app/components/searchQueryBuilder/hooks/useOnChange.tsx +++ b/static/app/components/searchQueryBuilder/hooks/useOnChange.tsx @@ -1,11 +1,11 @@ import type {SearchQueryBuilderProps} from 'sentry/components/searchQueryBuilder'; -import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context'; +import {useSearchQueryBuilderState} from 'sentry/components/searchQueryBuilder/context'; import {queryIsValid} from 'sentry/components/searchQueryBuilder/utils'; import {useEffectAfterFirstRender} from 'sentry/utils/useEffectAfterFirstRender'; import {usePrevious} from 'sentry/utils/usePrevious'; export function useOnChange({onChange}: Pick) { - const {committedQuery, handleSearch, parseQuery} = useSearchQueryBuilder(); + const {committedQuery, handleSearch, parseQuery} = useSearchQueryBuilderState(); const previousCommittedQuery = usePrevious(committedQuery); diff --git a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderGridItem.tsx b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderGridItem.tsx index 69cfc0fdf52b00..6910465a9db716 100644 --- a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderGridItem.tsx +++ b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderGridItem.tsx @@ -5,7 +5,7 @@ import {isMac} from '@react-aria/utils'; import type {ListState} from '@react-stately/list'; import type {FocusableElement, Node} from '@react-types/shared'; -import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context'; +import {useSearchQueryBuilderLayout} from 'sentry/components/searchQueryBuilder/context'; import {useKeyboardSelection} from 'sentry/components/searchQueryBuilder/hooks/useKeyboardSelection'; import {findNearestFreeTextKey} from 'sentry/components/searchQueryBuilder/utils'; import type {ParseResultToken} from 'sentry/components/searchSyntax/parser'; @@ -94,7 +94,7 @@ export function useQueryBuilderGridItem( state: ListState, ref: RefObject ) { - const {wrapperRef} = useSearchQueryBuilder(); + const {wrapperRef} = useSearchQueryBuilderLayout(); const {rowProps, gridCellProps} = useGridListItem({node: item}, state, ref); const {selectInDirection} = useKeyboardSelection(); diff --git a/static/app/components/searchQueryBuilder/hooks/useSelectOnDrag.tsx b/static/app/components/searchQueryBuilder/hooks/useSelectOnDrag.tsx index 05c0416a7a25ac..84a65d33cb4d35 100644 --- a/static/app/components/searchQueryBuilder/hooks/useSelectOnDrag.tsx +++ b/static/app/components/searchQueryBuilder/hooks/useSelectOnDrag.tsx @@ -2,7 +2,7 @@ import {useCallback, useEffect, useRef} from 'react'; import type {ListState} from '@react-stately/list'; import type {Key} from '@react-types/shared'; -import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context'; +import {useSearchQueryBuilderLayout} from 'sentry/components/searchQueryBuilder/context'; import {Token, type ParseResultToken} from 'sentry/components/searchSyntax/parser'; type DraggingState = { @@ -118,7 +118,7 @@ function getItemIndexAtPosition( * and should behave similarly to selection within a textarea. */ export function useSelectOnDrag(state: ListState) { - const {wrapperRef} = useSearchQueryBuilder(); + const {wrapperRef} = useSearchQueryBuilderLayout(); const dragState = useRef(null); const cachedTokenCoordinates = useRef(null); // Mouse move events fire more than once per frame, so we use this ref to diff --git a/static/app/components/searchQueryBuilder/hooks/useUndoStack.tsx b/static/app/components/searchQueryBuilder/hooks/useUndoStack.tsx index 388edd7c3c77bc..2c7db1f1a95e0b 100644 --- a/static/app/components/searchQueryBuilder/hooks/useUndoStack.tsx +++ b/static/app/components/searchQueryBuilder/hooks/useUndoStack.tsx @@ -2,7 +2,7 @@ import {useCallback, useRef} from 'react'; import type {ListState} from '@react-stately/list'; import type {Key} from '@react-types/shared'; -import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context'; +import {useSearchQueryBuilderState} from 'sentry/components/searchQueryBuilder/context'; import type {FocusOverride} from 'sentry/components/searchQueryBuilder/types'; import type {ParseResultToken} from 'sentry/components/searchSyntax/parser'; import {defined} from 'sentry/utils'; @@ -77,7 +77,7 @@ function updateUndoStack({ * Hook that manages the undo stack for the search query builder. */ export function useUndoStack(state: ListState) { - const {query, focusOverride, dispatch} = useSearchQueryBuilder(); + const {query, focusOverride, dispatch} = useSearchQueryBuilderState(); const undoStackRef = useRef([]); const trimmedQuery = query.trim(); diff --git a/static/app/components/searchQueryBuilder/index.spec.tsx b/static/app/components/searchQueryBuilder/index.spec.tsx index ffdcb054166301..26e065245eb2f8 100644 --- a/static/app/components/searchQueryBuilder/index.spec.tsx +++ b/static/app/components/searchQueryBuilder/index.spec.tsx @@ -19,7 +19,8 @@ import { import {AskSeerComboBox} from 'sentry/components/searchQueryBuilder/askSeerCombobox/askSeerComboBox'; import { SearchQueryBuilderProvider, - useSearchQueryBuilder, + useSearchQueryBuilderAI, + useSearchQueryBuilderState, } from 'sentry/components/searchQueryBuilder/context'; import { QueryInterfaceType, @@ -5807,7 +5808,8 @@ describe('SearchQueryBuilder', () => { }); function AskSeerTestComponent({children}: {children: React.ReactNode}) { - const {displayAskSeer, query} = useSearchQueryBuilder(); + const {displayAskSeer} = useSearchQueryBuilderAI(); + const {query} = useSearchQueryBuilderState(); return displayAskSeer ? ( { story('SearchQueryBuilderProvider', () => { function OpenDropdownButton() { - const {dispatch} = useSearchQueryBuilder(); + const {dispatch} = useSearchQueryBuilderState(); return (