From 5f4782fcbcdb2962451f5d6eef0a0c5340c8d356 Mon Sep 17 00:00:00 2001 From: Sofia Rest <68917129+srest2021@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:55:37 -0700 Subject: [PATCH 01/11] feat(seer): Add org-level default stopping point and wire coding agent defaults into project creation (#111697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes CW-1120 Register new org option `sentry:default_automated_run_stopping_point` and wire all org-level Seer defaults (stopping point, coding agent, auto_open_prs) into both project creation and the existing-org migration task. Project creation and the org migration task to new seat-based pricing now: - Read the org's `defaultAutomatedRunStoppingPoint` and `defaultCodingAgent` and `defaultCodingAgentIntegrationId` - For external agents, configure automation_handoff with `auto_create_pr` from `auto_open_prs` - For Seer agent, `auto_open_prs=true` forces `open_pr`; `auto_open_prs=false` caps `open_pr` down to `code_changes` - Also adds ChoiceField validation for `defaultCodingAgent` (with alias mapping for cursor → cursor_background_agent, claude_code → claude_code_agent) and `defaultAutomatedRunStoppingPoint`. --------- Co-authored-by: Claude Sonnet 4 --- .../api/serializers/models/organization.py | 14 +- .../apidocs/examples/organization_examples.py | 3 +- .../core/endpoints/organization_details.py | 26 ++- src/sentry/core/endpoints/team_projects.py | 4 +- src/sentry/seer/autofix/utils.py | 42 ++++- src/sentry/seer/similarity/utils.py | 22 ++- src/sentry/tasks/seer/autofix.py | 40 +++-- .../endpoints/test_organization_details.py | 69 ++++++-- .../sentry/seer/autofix/test_autofix_utils.py | 89 ++++++++++ tests/sentry/tasks/seer/test_autofix.py | 158 +++++++++++++++++- 10 files changed, 417 insertions(+), 50 deletions(-) diff --git a/src/sentry/api/serializers/models/organization.py b/src/sentry/api/serializers/models/organization.py index b06d6aa591ff27..1f68aa0fc4f336 100644 --- a/src/sentry/api/serializers/models/organization.py +++ b/src/sentry/api/serializers/models/organization.py @@ -58,6 +58,7 @@ ROLLBACK_ENABLED_DEFAULT, SAMPLING_MODE_DEFAULT, SCRAPE_JAVASCRIPT_DEFAULT, + SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, SEER_DEFAULT_CODING_AGENT_DEFAULT, TARGET_SAMPLE_RATE_DEFAULT, ObjectStatus, @@ -558,8 +559,9 @@ class DetailedOrganizationSerializerResponse(_DetailedOrganizationSerializerResp defaultSeerScannerAutomation: bool enableSeerEnhancedAlerts: bool enableSeerCoding: bool - defaultCodingAgent: str | None + defaultCodingAgent: str defaultCodingAgentIntegrationId: int | None + defaultAutomatedRunStoppingPoint: str autoEnableCodeReview: bool autoOpenPrs: bool defaultCodeReviewTriggers: list[str] @@ -734,12 +736,14 @@ def serialize( # type: ignore[override] ) ), "defaultCodingAgent": obj.get_option( - "sentry:seer_default_coding_agent", - SEER_DEFAULT_CODING_AGENT_DEFAULT, + "sentry:seer_default_coding_agent", SEER_DEFAULT_CODING_AGENT_DEFAULT ), "defaultCodingAgentIntegrationId": obj.get_option( - "sentry:seer_default_coding_agent_integration_id", - None, + "sentry:seer_default_coding_agent_integration_id", None + ), + "defaultAutomatedRunStoppingPoint": obj.get_option( + "sentry:default_automated_run_stopping_point", + SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, ), "autoOpenPrs": bool( obj.get_option( diff --git a/src/sentry/apidocs/examples/organization_examples.py b/src/sentry/apidocs/examples/organization_examples.py index 67acd600769107..fd238fc93f95ce 100644 --- a/src/sentry/apidocs/examples/organization_examples.py +++ b/src/sentry/apidocs/examples/organization_examples.py @@ -304,8 +304,9 @@ class OrganizationExamples: "enableSeerCoding": True, "enableSeerEnhancedAlerts": True, "autoOpenPrs": False, - "defaultCodingAgent": None, + "defaultCodingAgent": "seer", "defaultCodingAgentIntegrationId": None, + "defaultAutomatedRunStoppingPoint": "code_changes", "issueAlertsThreadFlag": True, "metricAlertsThreadFlag": True, "trustedRelays": [], diff --git a/src/sentry/core/endpoints/organization_details.py b/src/sentry/core/endpoints/organization_details.py index 5eecb3461606ec..a3c7fd1f39b75a 100644 --- a/src/sentry/core/endpoints/organization_details.py +++ b/src/sentry/core/endpoints/organization_details.py @@ -68,6 +68,7 @@ ROLLBACK_ENABLED_DEFAULT, SAMPLING_MODE_DEFAULT, SCRAPE_JAVASCRIPT_DEFAULT, + SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, SEER_DEFAULT_CODING_AGENT_DEFAULT, TARGET_SAMPLE_RATE_DEFAULT, ObjectStatus, @@ -254,12 +255,17 @@ None, ), ( - # Informs UI default for automated_run_stopping_point in project preferences "autoOpenPrs", "sentry:auto_open_prs", bool, AUTO_OPEN_PRS_DEFAULT, ), + ( + "defaultAutomatedRunStoppingPoint", + "sentry:default_automated_run_stopping_point", + str, + SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, + ), ( "autoEnableCodeReview", "sentry:auto_enable_code_review", @@ -371,8 +377,15 @@ class OrganizationSerializer(BaseOrganizationSerializer): dashboardsAsyncQueueParallelLimit = serializers.IntegerField(required=False, min_value=1) enableSeerEnhancedAlerts = serializers.BooleanField(required=False) enableSeerCoding = serializers.BooleanField(required=False) - defaultCodingAgent = serializers.CharField(required=False, allow_null=True) + defaultCodingAgent = serializers.ChoiceField( + choices=["seer", "cursor", "claude_code", "cursor_background_agent", "claude_code_agent"], + required=False, + allow_null=True, + ) defaultCodingAgentIntegrationId = serializers.IntegerField(required=False, allow_null=True) + defaultAutomatedRunStoppingPoint = serializers.ChoiceField( + choices=["code_changes", "open_pr"], required=False + ) autoOpenPrs = serializers.BooleanField(required=False) autoEnableCodeReview = serializers.BooleanField(required=False) defaultCodeReviewTriggers = serializers.ListField( @@ -401,6 +414,15 @@ def validate_relayPiiConfig(self, value): organization = self.context["organization"] return validate_pii_config_update(organization, value) + def validate_defaultCodingAgent(self, value: str | None) -> str: + if value is None: + return SEER_DEFAULT_CODING_AGENT_DEFAULT + coding_agent_aliases: dict[str, str] = { + "cursor": "cursor_background_agent", + "claude_code": "claude_code_agent", + } + return coding_agent_aliases.get(value, value) + def validate_defaultCodingAgentIntegrationId(self, value: int | None) -> int | None: if value is None: return None diff --git a/src/sentry/core/endpoints/team_projects.py b/src/sentry/core/endpoints/team_projects.py index ae2d9b8b254cc8..b82545bfdbb5d4 100644 --- a/src/sentry/core/endpoints/team_projects.py +++ b/src/sentry/core/endpoints/team_projects.py @@ -31,8 +31,8 @@ from sentry.models.team import Team from sentry.seer.similarity.utils import ( project_is_seer_eligible, - set_default_project_auto_open_prs, set_default_project_autofix_automation_tuning, + set_default_project_seer_preferences, set_default_project_seer_scanner_automation, ) from sentry.signals import project_created @@ -56,7 +56,7 @@ def apply_default_project_settings(organization: Organization, project: Project) set_default_project_autofix_automation_tuning(organization, project) set_default_project_seer_scanner_automation(organization, project) - set_default_project_auto_open_prs(organization, project) + set_default_project_seer_preferences(organization, project) class ProjectPostSerializer(serializers.Serializer): diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 12e4d5feb2092a..8ac05e2efa1eba 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -15,6 +15,7 @@ from sentry import features, options, ratelimits from sentry.constants import ( + AUTO_OPEN_PRS_DEFAULT, SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, DataCategory, ObjectStatus, @@ -42,6 +43,10 @@ SeerProjectRepository, SeerProjectRepositoryBranchOverride, ) +from sentry.seer.models.seer_api_models import ( + AutofixHandoffPoint, + SeerAutomationHandoffConfiguration, +) from sentry.seer.signed_seer_api import SeerViewerContext, make_signed_seer_api_request from sentry.utils.cache import cache from sentry.utils.outcomes import Outcome, track_outcome @@ -384,14 +389,47 @@ def validate(self, data): def default_seer_project_preference(project: Project) -> SeerProjectPreference: + stopping_point, handoff = get_org_default_seer_automation_handoff(project.organization) return SeerProjectPreference( organization_id=project.organization.id, project_id=project.id, repositories=[], - automated_run_stopping_point=AutofixStoppingPoint.CODE_CHANGES.value, - automation_handoff=None, + automated_run_stopping_point=stopping_point, + automation_handoff=handoff, + ) + + +def get_org_default_seer_automation_handoff( + organization: Organization, +) -> tuple[str, SeerAutomationHandoffConfiguration | None]: + """Get the default stopping point and automation handoff for an organization.""" + stopping_point = organization.get_option( + "sentry:default_automated_run_stopping_point", SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT ) + auto_open_prs = organization.get_option("sentry:auto_open_prs", AUTO_OPEN_PRS_DEFAULT) + + automation_handoff: SeerAutomationHandoffConfiguration | None = None + coding_agent = organization.get_option("sentry:seer_default_coding_agent") + coding_agent_integration_id = organization.get_option( + "sentry:seer_default_coding_agent_integration_id" + ) + if coding_agent and coding_agent != "seer" and coding_agent_integration_id is not None: + automation_handoff = SeerAutomationHandoffConfiguration( + handoff_point=AutofixHandoffPoint.ROOT_CAUSE, + target=coding_agent, + integration_id=coding_agent_integration_id, + auto_create_pr=auto_open_prs, + ) + # If Seer agent and auto open PRs, we can run up to open_pr. + elif auto_open_prs: + stopping_point = "open_pr" + # If Seer agent and no auto open PRs, we shouldn't go past code_changes. + elif stopping_point == "open_pr": + stopping_point = "code_changes" + + return stopping_point, automation_handoff + def get_project_seer_preferences(project_id: int) -> SeerRawPreferenceResponse: """ diff --git a/src/sentry/seer/similarity/utils.py b/src/sentry/seer/similarity/utils.py index 448ab305a0e13f..ff530ed98c12dd 100644 --- a/src/sentry/seer/similarity/utils.py +++ b/src/sentry/seer/similarity/utils.py @@ -9,7 +9,9 @@ from tokenizers import Tokenizer from sentry import features, options -from sentry.constants import DATA_ROOT +from sentry.constants import ( + DATA_ROOT, +) from sentry.grouping.api import get_contributing_variant_and_component from sentry.grouping.grouping_info import get_grouping_info_from_variants_legacy from sentry.grouping.variants import BaseVariant @@ -18,12 +20,14 @@ from sentry.models.project import Project from sentry.seer.autofix.constants import AutofixAutomationTuningSettings from sentry.seer.autofix.utils import ( - AutofixStoppingPoint, + get_org_default_seer_automation_handoff, is_seer_seat_based_tier_enabled, set_project_seer_preference, write_preference_to_sentry_db, ) -from sentry.seer.models import SeerProjectPreference +from sentry.seer.models import ( + SeerProjectPreference, +) from sentry.seer.similarity.types import GroupingVersion from sentry.services.eventstore.models import Event, GroupEvent from sentry.utils import metrics @@ -563,14 +567,14 @@ def set_default_project_seer_scanner_automation( project.update_option("sentry:seer_scanner_automation", org_default) -def set_default_project_auto_open_prs(organization: Organization, project: Project) -> None: - """Called once at project creation time to set the initial auto open PRs.""" +def set_default_project_seer_preferences(organization: Organization, project: Project) -> None: + """Called once at project creation time to set the initial automated run stopping + point and automation handoff. + """ if not is_seer_seat_based_tier_enabled(organization): return - stopping_point = AutofixStoppingPoint.CODE_CHANGES - if organization.get_option("sentry:auto_open_prs"): - stopping_point = AutofixStoppingPoint.OPEN_PR + stopping_point, automation_handoff = get_org_default_seer_automation_handoff(organization) # We need to make an API call to Seer to set this preference preference = SeerProjectPreference( @@ -578,7 +582,9 @@ def set_default_project_auto_open_prs(organization: Organization, project: Proje project_id=project.id, repositories=[], automated_run_stopping_point=stopping_point, + automation_handoff=automation_handoff, ) + try: set_project_seer_preference(preference) except Exception as e: diff --git a/src/sentry/tasks/seer/autofix.py b/src/sentry/tasks/seer/autofix.py index 8671d98bee3281..1d4ac97d8abcb2 100644 --- a/src/sentry/tasks/seer/autofix.py +++ b/src/sentry/tasks/seer/autofix.py @@ -8,7 +8,9 @@ from sentry import analytics, features from sentry.analytics.events.autofix_automation_events import AiAutofixAutomationEvent -from sentry.constants import ObjectStatus +from sentry.constants import ( + ObjectStatus, +) from sentry.models.group import Group from sentry.models.organization import Organization from sentry.models.project import Project @@ -24,6 +26,7 @@ deduplicate_repositories, get_autofix_repos_from_project_code_mappings, get_autofix_state, + get_org_default_seer_automation_handoff, get_seer_seat_based_tier_cache_key, resolve_repository_ids, ) @@ -238,34 +241,49 @@ def configure_seer_for_existing_org(organization_id: int) -> None: "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM ) + default_stopping_point, default_handoff = get_org_default_seer_automation_handoff(organization) + default_handoff_dict = default_handoff.dict() if default_handoff else None + + valid_stopping_points = {"open_pr", "code_changes"} + preferences_by_id = bulk_get_project_preferences(organization_id, project_ids) # Determine which projects need updates preferences_to_set = [] projects_by_id = {p.id: p for p in projects} for project_id in project_ids: + stopping_point = default_stopping_point + handoff = default_handoff_dict + existing_pref = preferences_by_id.get(str(project_id)) if not existing_pref: # No existing preferences, get repositories from code mappings repositories = get_autofix_repos_from_project_code_mappings(projects_by_id[project_id]) else: - # Skip projects that already have an acceptable stopping point configured - if existing_pref.get("automated_run_stopping_point") in ("open_pr", "code_changes"): - continue repositories = existing_pref.get("repositories") or [] - repositories = deduplicate_repositories(repositories) + existing_stopping_point = existing_pref.get("automated_run_stopping_point") + existing_handoff = existing_pref.get("automation_handoff") + + # Skip projects that a) already have an acceptable stopping point configured + # AND b) already have a handoff configured or no org default handoff. + if existing_stopping_point in valid_stopping_points and ( + existing_handoff or default_handoff_dict is None + ): + continue + + if existing_stopping_point in valid_stopping_points: + stopping_point = existing_stopping_point + if existing_handoff: + handoff = existing_handoff - # Preserve existing repositories and automation_handoff, only update the stopping point preferences_to_set.append( { "organization_id": organization_id, "project_id": project_id, - "repositories": repositories or [], - "automated_run_stopping_point": "code_changes", - "automation_handoff": ( - existing_pref.get("automation_handoff") if existing_pref else None - ), + "repositories": deduplicate_repositories(repositories) or [], + "automated_run_stopping_point": stopping_point, + "automation_handoff": handoff, } ) diff --git a/tests/sentry/core/endpoints/test_organization_details.py b/tests/sentry/core/endpoints/test_organization_details.py index 60679a0f2e9085..f01d258575ad5f 100644 --- a/tests/sentry/core/endpoints/test_organization_details.py +++ b/tests/sentry/core/endpoints/test_organization_details.py @@ -21,6 +21,7 @@ from sentry.auth.authenticators.totp import TotpInterface from sentry.constants import ( RESERVED_ORGANIZATION_SLUGS, + SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, SEER_DEFAULT_CODING_AGENT_DEFAULT, ObjectStatus, ) @@ -1499,19 +1500,50 @@ def test_default_coding_agent_default(self) -> None: response = self.get_success_response(self.organization.slug) assert response.data["defaultCodingAgent"] == SEER_DEFAULT_CODING_AGENT_DEFAULT - def test_default_coding_agent_can_be_set(self) -> None: + def test_default_coding_agent_can_be_set_to_seer(self) -> None: data = {"defaultCodingAgent": "seer"} response = self.get_success_response(self.organization.slug, **data) assert self.organization.get_option("sentry:seer_default_coding_agent") == "seer" assert response.data["defaultCodingAgent"] == "seer" - def test_default_coding_agent_null_on_first_write_create_path(self) -> None: - # Tests the create path (no OrganizationOption row exists yet): sending null - # must store null rather than the string "None" via str(None). + def test_default_coding_agent_can_be_set_to_cursor(self) -> None: + for value in ("cursor", "cursor_background_agent"): + data = {"defaultCodingAgent": value} + response = self.get_success_response(self.organization.slug, **data) + assert ( + self.organization.get_option("sentry:seer_default_coding_agent") + == "cursor_background_agent" + ) + assert response.data["defaultCodingAgent"] == "cursor_background_agent" + + def test_default_coding_agent_can_be_set_to_claude(self) -> None: + for value in ("claude_code", "claude_code_agent"): + data = {"defaultCodingAgent": value} + response = self.get_success_response(self.organization.slug, **data) + assert ( + self.organization.get_option("sentry:seer_default_coding_agent") + == "claude_code_agent" + ) + assert response.data["defaultCodingAgent"] == "claude_code_agent" + + def test_default_coding_agent_none_casts_to_seer(self) -> None: + data = {"defaultCodingAgent": None} + response = self.get_success_response(self.organization.slug, **data) + assert self.organization.get_option("sentry:seer_default_coding_agent") == "seer" + assert response.data["defaultCodingAgent"] == "seer" + + def test_default_coding_agent_none_resets_to_seer(self) -> None: + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) data = {"defaultCodingAgent": None} response = self.get_success_response(self.organization.slug, **data) - assert self.organization.get_option("sentry:seer_default_coding_agent") is None - assert response.data["defaultCodingAgent"] is None + assert self.organization.get_option("sentry:seer_default_coding_agent") == "seer" + assert response.data["defaultCodingAgent"] == "seer" + + def test_default_coding_agent_rejects_invalid_choice(self) -> None: + data = {"defaultCodingAgent": "invalid_agent"} + self.get_error_response(self.organization.slug, status_code=400, **data) def test_default_coding_agent_writing_default_value_stores_but_skips_audit_log( self, @@ -1581,12 +1613,25 @@ def test_default_coding_agent_integration_id_null_on_first_write_create_path(sel ) assert response.data["defaultCodingAgentIntegrationId"] is None - def test_default_coding_agent_can_be_cleared(self) -> None: - self.organization.update_option("sentry:seer_default_coding_agent", "seer") - data = {"defaultCodingAgent": None} - response = self.get_success_response(self.organization.slug, **data) - assert self.organization.get_option("sentry:seer_default_coding_agent") is None - assert response.data["defaultCodingAgent"] is None + def test_default_automated_run_stopping_point_default(self) -> None: + response = self.get_success_response(self.organization.slug) + assert ( + response.data["defaultAutomatedRunStoppingPoint"] + == SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT + ) + + def test_default_automated_run_stopping_point_can_be_set(self) -> None: + for choice in ("code_changes", "open_pr"): + with self.subTest(choice=choice): + data = {"defaultAutomatedRunStoppingPoint": choice} + response = self.get_success_response(self.organization.slug, **data) + assert response.data["defaultAutomatedRunStoppingPoint"] == choice + + def test_default_automated_run_stopping_point_rejects_invalid(self) -> None: + for invalid in ("root_cause", "solution", "invalid_point"): + with self.subTest(value=invalid): + data = {"defaultAutomatedRunStoppingPoint": invalid} + self.get_error_response(self.organization.slug, status_code=400, **data) def test_default_coding_agent_integration_id_can_be_cleared(self) -> None: self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 123) diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index 96260e838075e7..2f5c8c401d6ed3 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -15,6 +15,7 @@ deduplicate_repositories, get_autofix_prompt, get_coding_agent_prompt, + get_org_default_seer_automation_handoff, has_project_connected_repos, is_seer_seat_based_tier_enabled, resolve_repository_ids, @@ -1218,3 +1219,91 @@ def test_bulk_write_replaces_per_project(self) -> None: assert p1_repo.branch_name == "new-branch" p2_repo = SeerProjectRepository.objects.get(project=project2) assert p2_repo.branch_name == "project-2-branch" + + +class TestGetOrgDefaultSeerAutomationHandoff(TestCase): + def test_defaults(self): + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "code_changes" + assert handoff is None + + def test_respects_org_stopping_point_option(self): + self.organization.update_option("sentry:default_automated_run_stopping_point", "open_pr") + self.organization.update_option("sentry:auto_open_prs", True) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "open_pr" + assert handoff is None + + def test_seer_agent_auto_open_prs_forces_open_pr(self): + self.organization.update_option( + "sentry:default_automated_run_stopping_point", "code_changes" + ) + self.organization.update_option("sentry:auto_open_prs", True) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "open_pr" + assert handoff is None + + def test_seer_agent_no_auto_open_prs_caps_open_pr_to_code_changes(self): + self.organization.update_option("sentry:default_automated_run_stopping_point", "open_pr") + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "code_changes" + assert handoff is None + + def test_external_agent_returns_handoff_config(self): + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "code_changes" + assert handoff is not None + assert handoff.handoff_point == "root_cause" + assert handoff.target == "cursor_background_agent" + assert handoff.integration_id == 42 + assert handoff.auto_create_pr is False + + def test_external_agent_auto_open_prs_sets_auto_create_pr(self): + self.organization.update_option("sentry:seer_default_coding_agent", "claude_code_agent") + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 99) + self.organization.update_option("sentry:auto_open_prs", True) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert handoff is not None + assert handoff.auto_create_pr is True + + def test_external_agent_auto_open_prs_does_not_override_stopping_point(self): + self.organization.update_option( + "sentry:default_automated_run_stopping_point", "code_changes" + ) + self.organization.update_option("sentry:auto_open_prs", True) + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "code_changes" + assert handoff is not None + + def test_external_agent_without_integration_id_falls_back_to_seer(self): + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:auto_open_prs", True) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "open_pr" + assert handoff is None + + def test_seer_coding_agent_treated_as_no_external_agent(self): + self.organization.update_option("sentry:seer_default_coding_agent", "seer") + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + self.organization.update_option("sentry:auto_open_prs", True) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "open_pr" + assert handoff is None diff --git a/tests/sentry/tasks/seer/test_autofix.py b/tests/sentry/tasks/seer/test_autofix.py index 20566413060f75..ed92f065fb2c17 100644 --- a/tests/sentry/tasks/seer/test_autofix.py +++ b/tests/sentry/tasks/seer/test_autofix.py @@ -4,6 +4,7 @@ import pytest from django.test import TestCase +from sentry.constants import SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT from sentry.models.repository import Repository from sentry.seer.autofix.constants import AutofixStatus, SeerAutomationSource from sentry.seer.autofix.utils import AutofixState, get_seer_seat_based_tier_cache_key @@ -197,23 +198,166 @@ def test_overrides_autofix_off_to_medium( @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") - def test_skips_projects_with_existing_stopping_point( + def test_new_project_gets_stopping_point_and_no_handoff_from_org_defaults( self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock ) -> None: - """Test that projects with open_pr or code_changes stopping point are skipped.""" - project1 = self.create_project(organization=self.organization) - project2 = self.create_project(organization=self.organization) + """Project with no existing prefs gets stopping point and no handoff (seer coding agent) from org defaults.""" + project = self.create_project(organization=self.organization) + + mock_bulk_get.return_value = {} + + configure_seer_for_existing_org(organization_id=self.organization.id) + + mock_bulk_set.assert_called_once() + prefs = mock_bulk_set.call_args[0][1] + prefs_by_project = {p["project_id"]: p for p in prefs} + assert prefs_by_project[project.id]["automated_run_stopping_point"] == "code_changes" + assert prefs_by_project[project.id]["automation_handoff"] is None + + @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") + @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") + def test_new_project_gets_stopping_point_and_handoff_from_org_defaults( + self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock + ) -> None: + """Project with no existing prefs gets stopping point and external agent handoff from org defaults.""" + project = self.create_project(organization=self.organization) + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + self.organization.update_option("sentry:auto_open_prs", True) + + mock_bulk_get.return_value = {} + + configure_seer_for_existing_org(organization_id=self.organization.id) + + mock_bulk_set.assert_called_once() + prefs = mock_bulk_set.call_args[0][1] + prefs_by_project = {p["project_id"]: p for p in prefs} + assert prefs_by_project[project.id]["automation_handoff"] == { + "handoff_point": "root_cause", + "target": "cursor_background_agent", + "integration_id": 42, + "auto_create_pr": True, + } + # auto_open_prs should NOT override stopping point for external agents + assert ( + prefs_by_project[project.id]["automated_run_stopping_point"] + == SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT + ) + + @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") + @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") + def test_skips_project_with_valid_stopping_point_and_no_default_handoff( + self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock + ) -> None: + """Project is skipped when it has a valid stopping point and the org has no default handoff (seer agent).""" + project = self.create_project(organization=self.organization) mock_bulk_get.return_value = { - str(project1.id): {"automated_run_stopping_point": "open_pr"}, - str(project2.id): {"automated_run_stopping_point": "code_changes"}, + str(project.id): {"automated_run_stopping_point": "open_pr"}, } configure_seer_for_existing_org(organization_id=self.organization.id) - # bulk_set should not be called since both projects are skipped mock_bulk_set.assert_not_called() + @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") + @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") + def test_skips_project_with_valid_stopping_point_and_existing_handoff( + self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock + ) -> None: + """Project is skipped when it has a valid stopping point and an existing handoff configured.""" + project = self.create_project(organization=self.organization) + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + + mock_bulk_get.return_value = { + str(project.id): { + "automated_run_stopping_point": "code_changes", + "automation_handoff": { + "handoff_point": "root_cause", + "target": "claude_code_agent", + "integration_id": 99, + "auto_create_pr": False, + }, + }, + } + + configure_seer_for_existing_org(organization_id=self.organization.id) + + mock_bulk_set.assert_not_called() + + @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") + @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") + def test_project_with_valid_stopping_point_gets_handoff_from_org_defaults( + self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock + ) -> None: + """Project with valid stopping point but no handoff gets org default handoff applied. + Existing stopping point is preserved.""" + project = self.create_project(organization=self.organization) + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + self.organization.update_option("sentry:auto_open_prs", True) + + mock_bulk_get.return_value = { + str(project.id): {"automated_run_stopping_point": "open_pr"}, + } + + configure_seer_for_existing_org(organization_id=self.organization.id) + + mock_bulk_set.assert_called_once() + prefs = mock_bulk_set.call_args[0][1] + prefs_by_project = {p["project_id"]: p for p in prefs} + assert prefs_by_project[project.id]["automated_run_stopping_point"] == "open_pr" + assert prefs_by_project[project.id]["automation_handoff"] == { + "handoff_point": "root_cause", + "target": "cursor_background_agent", + "integration_id": 42, + "auto_create_pr": True, + } + + @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") + @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") + def test_project_with_invalid_stopping_point_gets_org_default_stopping_point( + self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock + ) -> None: + """Project with unrecognized stopping point gets org default stopping point applied. + Existing handoff (if any) is preserved.""" + project = self.create_project(organization=self.organization) + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + + existing_handoff = { + "handoff_point": "root_cause", + "target": "claude_code_agent", + "integration_id": 99, + "auto_create_pr": False, + } + mock_bulk_get.return_value = { + str(project.id): { + "automated_run_stopping_point": "root_cause", + "automation_handoff": existing_handoff, + }, + } + + configure_seer_for_existing_org(organization_id=self.organization.id) + + mock_bulk_set.assert_called_once() + prefs = mock_bulk_set.call_args[0][1] + prefs_by_project = {p["project_id"]: p for p in prefs} + assert ( + prefs_by_project[project.id]["automated_run_stopping_point"] + == SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT + ) + assert prefs_by_project[project.id]["automation_handoff"] == existing_handoff + @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") def test_raises_on_bulk_get_api_failure(self, mock_bulk_get: MagicMock) -> None: """Test that task raises on bulk GET API failure to trigger retry.""" From 8279c67b9b487baa4897bbe70ea59d103cae6244 Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Tue, 31 Mar 2026 15:06:23 -0700 Subject: [PATCH 02/11] chore(aci): Update monitor type selection page copy (#111960) --- .../detectors/components/detectorTypeForm.tsx | 35 ++++++++----------- static/app/views/detectors/new.tsx | 21 +++++++---- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/static/app/views/detectors/components/detectorTypeForm.tsx b/static/app/views/detectors/components/detectorTypeForm.tsx index 8f55d175ee30d6..1ead676531c99c 100644 --- a/static/app/views/detectors/components/detectorTypeForm.tsx +++ b/static/app/views/detectors/components/detectorTypeForm.tsx @@ -12,10 +12,7 @@ import {t, tct} from 'sentry/locale'; import {HookStore} from 'sentry/stores/hookStore'; import type {DetectorType} from 'sentry/types/workflowEngine/detectors'; import {useOrganization} from 'sentry/utils/useOrganization'; -import { - makeAutomationBasePathname, - makeAutomationCreatePathname, -} from 'sentry/views/automations/pathnames'; +import {makeAutomationCreatePathname} from 'sentry/views/automations/pathnames'; import {getDetectorTypeLabel} from 'sentry/views/detectors/utils/detectorTypeConfig'; export function DetectorTypeForm() { @@ -23,23 +20,17 @@ export function DetectorTypeForm() { return ( - - - {t('Select monitor type')} - - - {tct( - 'Do you want to alert existing issues? Create a [newAlertLink:new alert], or [connectAlertLink:connect an existing one].', - { - newAlertLink: , - connectAlertLink: ( - - ), - } - )} - - + + {tct('Want to just alert on an existing issue? [link:Create an issue alert].', { + link: , + })} + + + {t( + 'If you’re looking for an Error Monitors, those are created by Sentry. To customize an error monitor, click into an existing one.' + )} + ); } @@ -94,7 +85,9 @@ function MonitorTypeField() { { id: 'metric_issue', name: getDetectorTypeLabel('metric_issue'), - description: t('Monitor error counts, transaction duration, and more!'), + description: t( + 'Monitor error counts, logs, custom metrics, span duration, crash rates, and more. ' + ), visualization: , infoBanner: canCreateMetricDetector ? undefined : ( diff --git a/static/app/views/detectors/new.tsx b/static/app/views/detectors/new.tsx index d563ddbb2ac723..212b96bed9661f 100644 --- a/static/app/views/detectors/new.tsx +++ b/static/app/views/detectors/new.tsx @@ -2,12 +2,14 @@ import {useTheme} from '@emotion/react'; import {parseAsString, useQueryState} from 'nuqs'; import {Button, LinkButton} from '@sentry/scraps/button'; +import {ExternalLink} from '@sentry/scraps/link'; +import {Text} from '@sentry/scraps/text'; import {Breadcrumbs} from 'sentry/components/breadcrumbs'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {EditLayout} from 'sentry/components/workflowEngine/layout/edit'; import {useWorkflowEngineFeatureGate} from 'sentry/components/workflowEngine/useWorkflowEngineFeatureGate'; -import {t} from 'sentry/locale'; +import {t, tct} from 'sentry/locale'; import {useNavigate} from 'sentry/utils/useNavigate'; import {useOrganization} from 'sentry/utils/useOrganization'; import { @@ -43,8 +45,6 @@ export default function DetectorNew() { const [detectorType] = useDetectorTypeQueryState(); const [projectId] = useQueryState('project', parseAsString); - const newMonitorName = t('New Monitor'); - const formProps = { onSubmit: () => { navigate({ @@ -62,12 +62,21 @@ export default function DetectorNew() { return ( - - + - + + + {tct( + 'Monitors detect problems in your application and send alerts when they occur. [docsLink:Read the Docs].', + { + docsLink: ( + + ), + } + )} +
From 17abac35f249fa91b6d89a5a3b94d4b41be36cb6 Mon Sep 17 00:00:00 2001 From: Sehr <58871345+sehr-m@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:15:30 -0700 Subject: [PATCH 03/11] feat(seer agent): add integration button to handoff dropdown (#111499) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds button labeled "Add Integration" to the bottom of the dropdown for coding agent handoff. This is only in explorer v3 as this will be EA and then GA very soon. The button links to the integrations page and filters to "Coding Agents". Pictures provided below of the dropdown with and without agents configured + the page it links to. A test has also been written for the frontend. Screenshot 2026-03-24 at 4 52 05 PM Screenshot 2026-03-24 at 4 46 10 PM Screenshot 2026-03-24 at 4 46 23 PM --- .../events/autofix/v3/nextStep.spec.tsx | 27 +++++++++++++++++++ .../components/events/autofix/v3/nextStep.tsx | 15 +++++++++++ 2 files changed, 42 insertions(+) diff --git a/static/app/components/events/autofix/v3/nextStep.spec.tsx b/static/app/components/events/autofix/v3/nextStep.spec.tsx index 5a9f7bf457c79d..5efc1cfecc76f8 100644 --- a/static/app/components/events/autofix/v3/nextStep.spec.tsx +++ b/static/app/components/events/autofix/v3/nextStep.spec.tsx @@ -234,6 +234,33 @@ describe('SeerDrawerNextStep', () => { await screen.findByRole('button', {name: 'More code fix options'}) ).toBeInTheDocument(); }); + + it('shows Add Integration link in dropdown footer', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/integrations/coding-agents/', + body: { + integrations: [ + {id: '1', name: 'Copilot', provider: 'github', requires_identity: false}, + ], + }, + }); + const autofix = makeAutofix(); + render( + + ); + await userEvent.click( + await screen.findByRole('button', {name: 'More code fix options'}) + ); + const addIntegrationLink = screen.getByRole('button', {name: 'Add Integration'}); + expect(addIntegrationLink).toHaveAttribute( + 'href', + '/settings/org-slug/integrations/?category=coding%20agent' + ); + }); }); describe('SolutionNextStep', () => { diff --git a/static/app/components/events/autofix/v3/nextStep.tsx b/static/app/components/events/autofix/v3/nextStep.tsx index 2f3df3bcc24323..f5c5579f4f9059 100644 --- a/static/app/components/events/autofix/v3/nextStep.tsx +++ b/static/app/components/events/autofix/v3/nextStep.tsx @@ -1,11 +1,13 @@ import {useCallback, useMemo, useState, type ReactNode} from 'react'; import {Button, ButtonBar} from '@sentry/scraps/button'; +import {MenuComponents} from '@sentry/scraps/compactSelect'; import {Flex} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import {TextArea} from '@sentry/scraps/textarea'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; +import {DropdownMenuFooter} from 'sentry/components/dropdownMenu/footer'; import { organizationIntegrationsCodingAgents, type CodingAgentIntegration, @@ -18,6 +20,7 @@ import { type AutofixSection, type useExplorerAutofix, } from 'sentry/components/events/autofix/useExplorerAutofix'; +import {IconAdd} from 'sentry/icons/iconAdd'; import {IconChevron} from 'sentry/icons/iconChevron'; import {t} from 'sentry/locale'; import {PluginIcon} from 'sentry/plugins/components/pluginIcon'; @@ -275,6 +278,8 @@ function NextStepTemplate({ codingAgentIntegrations, onCodingAgentHandoff, }: NextStepTemplateProps) { + const organization = useOrganization(); + const codingAgentOptions = useMemo(() => { return (codingAgentIntegrations ?? []).map(integration => { const actionLabel = @@ -349,6 +354,16 @@ function NextStepTemplate({ /> )} position="bottom-end" + menuFooter={ + + } + to={`/settings/${organization.slug}/integrations/?category=coding%20agent`} + > + {t('Add Integration')} + + + } /> ) : null} From 17085c908af23c6cabe1632a1e5b9cf9f8c5a648 Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Tue, 31 Mar 2026 15:18:12 -0700 Subject: [PATCH 04/11] fix(aci): Link from legacy alerts should go to /monitors/alerts (#111962) --- static/app/views/automations/list.tsx | 4 ++++ static/app/views/detectors/list/allMonitors.tsx | 4 ---- .../secondary/sections/issues/issuesSecondaryNavigation.tsx | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/static/app/views/automations/list.tsx b/static/app/views/automations/list.tsx index d2237fa8b42e37..7f9554dfda4e0e 100644 --- a/static/app/views/automations/list.tsx +++ b/static/app/views/automations/list.tsx @@ -23,6 +23,7 @@ import {AutomationSearch} from 'sentry/views/automations/components/automationLi import {AUTOMATION_LIST_PAGE_LIMIT} from 'sentry/views/automations/constants'; import {useAutomationsQuery} from 'sentry/views/automations/hooks'; import {makeAutomationCreatePathname} from 'sentry/views/automations/pathnames'; +import {AlertsRedirectNotice} from 'sentry/views/detectors/list/common/alertsRedirectNotice'; export default function AutomationsList() { const location = useLocation(); @@ -85,6 +86,9 @@ export default function AutomationsList() { )} docsUrl="https://docs.sentry.io/product/new-monitors-and-alerts/alerts/" > + + {t('Alert Rules have been moved to Monitors and Alerts.')} +
- - {t('Alert Rules have been moved to Monitors and Alerts.')} - diff --git a/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx b/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx index 74abd32fd23cc6..0922df43bc238e 100644 --- a/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx +++ b/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx @@ -4,7 +4,7 @@ import styled from '@emotion/styled'; import {t} from 'sentry/locale'; import {useOrganization} from 'sentry/utils/useOrganization'; -import {makeMonitorBasePathname} from 'sentry/views/detectors/pathnames'; +import {makeAutomationBasePathname} from 'sentry/views/automations/pathnames'; import {ISSUE_TAXONOMY_CONFIG} from 'sentry/views/issueList/taxonomies'; import {usePrimaryNavigation} from 'sentry/views/navigation/primaryNavigationContext'; import {SecondaryNavigation} from 'sentry/views/navigation/secondary/components'; @@ -127,7 +127,7 @@ function ConfigureSection({baseUrl}: {baseUrl: string}) { !hasRedirectOptOut && organization.features.includes('workflow-engine-ui'); const alertsLink = shouldRedirectToWorkflowEngineUI - ? `${makeMonitorBasePathname(organization.slug)}?alertsRedirect=true` + ? `${makeAutomationBasePathname(organization.slug)}?alertsRedirect=true` : `${baseUrl}/alerts/rules/`; return ( From 85e4adb8896b828584658b408e5763184fe7ff4f Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Tue, 31 Mar 2026 18:22:52 -0400 Subject: [PATCH 05/11] fix(autofix): Github webhook analytics for explorer autofix (#111913) It's possible that the hook is for an explorer autofix run so if no autofix state is found, try to look for an explorer state and record any analytics. --- .../analytics/events/ai_autofix_pr_events.py | 1 + src/sentry/seer/autofix/webhooks.py | 67 ++++++++++++++----- src/sentry/seer/explorer/client_utils.py | 39 +++++++++++ 3 files changed, 91 insertions(+), 16 deletions(-) diff --git a/src/sentry/analytics/events/ai_autofix_pr_events.py b/src/sentry/analytics/events/ai_autofix_pr_events.py index 71a5e3c9068423..e9330c6764fce5 100644 --- a/src/sentry/analytics/events/ai_autofix_pr_events.py +++ b/src/sentry/analytics/events/ai_autofix_pr_events.py @@ -9,6 +9,7 @@ class AiAutofixPrEvent(analytics.Event): run_id: int integration: str github_app: str + referrer: str | None = None @analytics.eventclass("ai.autofix.pr.closed") diff --git a/src/sentry/seer/autofix/webhooks.py b/src/sentry/seer/autofix/webhooks.py index 3f6e4f4ad5a15d..f61384333cf681 100644 --- a/src/sentry/seer/autofix/webhooks.py +++ b/src/sentry/seer/autofix/webhooks.py @@ -11,8 +11,10 @@ AiAutofixPrOpenedEvent, ) from sentry.integrations.types import IntegrationProviderSlug +from sentry.models.group import Group from sentry.models.organization import Organization from sentry.seer.autofix.utils import get_autofix_state_from_pr_id +from sentry.seer.explorer.client_utils import get_explorer_state_from_pr_id from sentry.utils import metrics AnalyticAction = Literal["opened", "closed", "merged"] @@ -43,24 +45,57 @@ def handle_github_pr_webhook_for_autofix( if action not in ["opened", "closed"]: return None + try: + record_pr_action_analytic(org, action, pull_request, github_app) + except Exception as e: + sentry_sdk.capture_exception(e) + + +def record_pr_action_analytic( + org: Organization, action: str, pull_request: dict[str, Any], github_app: str +) -> None: + analytic_action: AnalyticAction = "opened" if action == "opened" else "closed" + if pull_request["merged"]: + analytic_action = "merged" + autofix_state = get_autofix_state_from_pr_id("integrations:github", pull_request["id"]) if autofix_state: - analytic_action: AnalyticAction = "opened" if action == "opened" else "closed" - if pull_request["merged"]: - analytic_action = "merged" - - try: - analytics.record( - ACTION_TO_EVENTS[analytic_action]( - organization_id=org.id, - integration=IntegrationProviderSlug.GITHUB.value, - project_id=autofix_state.request.project_id, - group_id=autofix_state.request.issue["id"], - run_id=autofix_state.run_id, - github_app=github_app, - ) + analytics.record( + ACTION_TO_EVENTS[analytic_action]( + organization_id=org.id, + integration=IntegrationProviderSlug.GITHUB.value, + project_id=autofix_state.request.project_id, + group_id=autofix_state.request.issue["id"], + run_id=autofix_state.run_id, + github_app=github_app, ) - except Exception as e: - sentry_sdk.capture_exception(e) + ) metrics.incr(f"ai.autofix.pr.{analytic_action}") + return + + explorer_state = get_explorer_state_from_pr_id( + org.id, "integrations:github", pull_request["id"] + ) + if explorer_state: + group_id = explorer_state.metadata.get("group_id") if explorer_state.metadata else None + if group_id is None: + raise ValueError(f"Missing group id in explorer run {explorer_state.run_id}") + group = Group.objects.get(id=group_id, project__organization_id=org.id) + + analytics.record( + ACTION_TO_EVENTS[analytic_action]( + organization_id=org.id, + integration=IntegrationProviderSlug.GITHUB.value, + project_id=group.project.id, + group_id=group.id, + run_id=explorer_state.run_id, + github_app=github_app, + referrer=explorer_state.metadata.get("referrer") + if explorer_state.metadata + else None, + ) + ) + + metrics.incr(f"ai.autofix.pr.{analytic_action}", tags={"mode": "explorer"}) + return diff --git a/src/sentry/seer/explorer/client_utils.py b/src/sentry/seer/explorer/client_utils.py index cf699f2ffb3f05..fd5aa32171957b 100644 --- a/src/sentry/seer/explorer/client_utils.py +++ b/src/sentry/seer/explorer/client_utils.py @@ -88,6 +88,12 @@ class ExplorerUpdateRequest(TypedDict): payload: NotRequired[dict[str, Any]] +class ExplorerPrStateRequest(TypedDict): + organization_id: int + provider: str + pr_id: int + + def make_explorer_state_request( body: ExplorerStateRequest, connection_pool: HTTPConnectionPool | None = None, @@ -140,6 +146,39 @@ def make_explorer_update_request( ) +def make_explorer_state_pr_request( + body: ExplorerPrStateRequest, + connection_pool: HTTPConnectionPool | None = None, + viewer_context: SeerViewerContext | None = None, +) -> BaseHTTPResponse: + return make_signed_seer_api_request( + connection_pool or explorer_connection_pool, + "/v1/automation/explorer/state/pr", + body=orjson.dumps(body, option=orjson.OPT_NON_STR_KEYS), + viewer_context=viewer_context, + ) + + +def get_explorer_state_from_pr_id( + organization_id: int, provider: str, pr_id: int +) -> SeerRunState | None: + body = ExplorerPrStateRequest(organization_id=organization_id, provider=provider, pr_id=pr_id) + response = make_explorer_state_pr_request(body) + + if response.status >= 400: + raise SeerApiError("Seer request failed", response.status) + + result = response.json() + if not result: + return None + + session = result.get("session") + if session is None: + return None + + return SeerRunState(**session) + + def has_seer_explorer_access_with_detail( organization: Organization, actor: SentryUser | AnonymousUser | RpcUser | None = None ) -> tuple[bool, str | None]: From f13055355de14a2ab76f7d11dee4507b5f605fa0 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Tue, 31 Mar 2026 18:34:32 -0400 Subject: [PATCH 06/11] chore(autofix): Remove redundant tooltip on autofix buttons (#111950) The tooltips say the same thing as the button. --- .../app/views/issueDetails/streamline/sidebar/autofixSection.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/static/app/views/issueDetails/streamline/sidebar/autofixSection.tsx b/static/app/views/issueDetails/streamline/sidebar/autofixSection.tsx index 5416d04c20e570..93566027385de7 100644 --- a/static/app/views/issueDetails/streamline/sidebar/autofixSection.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/autofixSection.tsx @@ -382,7 +382,6 @@ function AutofixPreviews({ size="md" icon={} aria-label={t('Open Seer')} - tooltipProps={{title: t('Open Seer')}} priority="primary" onClick={openSeerDrawer} analyticsEventKey="issue_details.seer_opened" From 92c4bbd278ee55ef5871e7c02b0494c8dd7a601b Mon Sep 17 00:00:00 2001 From: joshuarli Date: Tue, 31 Mar 2026 16:00:37 -0700 Subject: [PATCH 07/11] ci(st): handle renames (#111937) see https://github.com/getsentry/getsentry/pull/19716 these changes don't affect existing infra --- .../scripts/compute-sentry-selected-tests.py | 15 ++++- .../workflows/scripts/getsentry-dispatch.js | 2 + .../test_compute_sentry_selected_tests.py | 66 +++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/.github/workflows/scripts/compute-sentry-selected-tests.py b/.github/workflows/scripts/compute-sentry-selected-tests.py index 65bbcd1a6ba034..24d1c626f8caad 100644 --- a/.github/workflows/scripts/compute-sentry-selected-tests.py +++ b/.github/workflows/scripts/compute-sentry-selected-tests.py @@ -135,6 +135,11 @@ def main() -> int: required=True, help="Space-separated changed files relative to sentry repo root", ) + parser.add_argument( + "--previous-filenames", + default="", + help="Space-separated previous filenames for renamed files (queried against coverage DB)", + ) parser.add_argument("--output", help="Output file path for selected test files (one per line)") parser.add_argument("--github-output", action="store_true", help="Write to GITHUB_OUTPUT") args = parser.parse_args() @@ -145,6 +150,7 @@ def main() -> int: return 1 changed = [f.strip() for f in args.changed_files.split() if f.strip()] + previous_filenames = [f.strip() for f in args.previous_filenames.split() if f.strip()] selective_applied = False @@ -152,8 +158,9 @@ def main() -> int: print("No changed files provided, running full test suite") affected_test_files: set[str] = set() else: + all_paths = changed + previous_filenames triggered_by = [ - f for f in changed if any(_matches_trigger(f, t) for t in FULL_SUITE_TRIGGERS) + f for f in all_paths if any(_matches_trigger(f, t) for t in FULL_SUITE_TRIGGERS) ] if triggered_by: print(f"Full test suite triggered by: {', '.join(triggered_by)}") @@ -161,8 +168,12 @@ def main() -> int: else: selective_applied = True - # Map repo-relative paths to DB format (add ../sentry/ prefix) + # Map repo-relative paths to DB format (add ../sentry/ prefix). + # Include previous filenames for renames so the coverage DB + # (which still stores the old path) can find the right tests. db_paths = [DB_PREFIX + f for f in changed] + for old_name in previous_filenames: + db_paths.append(DB_PREFIX + old_name) print(f"Computing selected tests for {len(changed)} changed files...") try: diff --git a/.github/workflows/scripts/getsentry-dispatch.js b/.github/workflows/scripts/getsentry-dispatch.js index 285be77daec73e..b0cb8caaba5440 100644 --- a/.github/workflows/scripts/getsentry-dispatch.js +++ b/.github/workflows/scripts/getsentry-dispatch.js @@ -20,6 +20,7 @@ export async function dispatch({ fileChanges, mergeCommitSha, sentryChangedFiles, + sentryPreviousFilenames, targetWorkflow, }) { core.startGroup('Dispatching request to getsentry.'); @@ -42,6 +43,7 @@ export async function dispatch({ // Changed files for selective testing. Empty string means full suite. 'sentry-changed-files': sentryChangedFiles || '', + 'sentry-previous-filenames': sentryPreviousFilenames || '', }; core.info( diff --git a/.github/workflows/scripts/test_compute_sentry_selected_tests.py b/.github/workflows/scripts/test_compute_sentry_selected_tests.py index d2af3dd08d5dae..5020d39dc96b9c 100644 --- a/.github/workflows/scripts/test_compute_sentry_selected_tests.py +++ b/.github/workflows/scripts/test_compute_sentry_selected_tests.py @@ -285,6 +285,72 @@ def test_zero_tests_signals_selective_applied(self, tmp_path): assert "test-count=0" in gh assert output.read_text() == "" + def test_renamed_file_queries_old_path(self, tmp_path): + """When a file is renamed, the old path should be queried against the coverage DB.""" + db_path = tmp_path / "coverage.db" + _create_coverage_db( + str(db_path), + { + # Coverage DB still has the old filename + "../sentry/src/sentry/models/old_name.py": [ + "../sentry/tests/sentry/test_old_name.py::T::test|run", + ], + }, + ) + output = tmp_path / "output.txt" + gh_output = tmp_path / "gh_output" + gh_output.write_text("") + + with mock.patch("compute_sentry_selected_tests.Path.exists", return_value=True): + _run( + [ + "--coverage-db", + str(db_path), + "--changed-files", + "src/sentry/models/new_name.py", + "--previous-filenames", + "src/sentry/models/old_name.py", + "--output", + str(output), + "--github-output", + ], + {"GITHUB_OUTPUT": str(gh_output)}, + ) + + gh = gh_output.read_text() + assert "has-selected-tests=true" in gh + assert "test-count=1" in gh + assert output.read_text().strip() == "tests/sentry/test_old_name.py" + + def test_renamed_file_without_previous_misses_coverage(self, tmp_path): + """Without --previous-filenames, a renamed file gets no coverage hits.""" + db_path = tmp_path / "coverage.db" + _create_coverage_db( + str(db_path), + { + "../sentry/src/sentry/models/old_name.py": [ + "../sentry/tests/sentry/test_old_name.py::T::test|run", + ], + }, + ) + gh_output = tmp_path / "gh_output" + gh_output.write_text("") + + _run( + [ + "--coverage-db", + str(db_path), + "--changed-files", + "src/sentry/models/new_name.py", + "--github-output", + ], + {"GITHUB_OUTPUT": str(gh_output)}, + ) + + gh = gh_output.read_text() + assert "has-selected-tests=true" in gh + assert "test-count=0" in gh + def test_missing_db_returns_error(self): ret = _run(["--coverage-db", "/nonexistent/coverage.db", "--changed-files", "foo.py"]) assert ret == 1 From 59e222d77c9bce7a711ae922ae0ac615445dccc5 Mon Sep 17 00:00:00 2001 From: Katie Byers Date: Tue, 31 Mar 2026 16:18:34 -0700 Subject: [PATCH 08/11] fix(grouping): Fix git sha parameterization test (#111964) We have a parameterization test case for our git sha parameterization which was meant to show that if the value doesn't include any numbers, it doesn't match our the pattern. But our git sha pattern only matches strings with exactly 7 characters, and the test case currently has 8, so of course it doesn't match! This fixes things so we're actually testing against a valid git sha. --- tests/sentry/grouping/test_parameterization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sentry/grouping/test_parameterization.py b/tests/sentry/grouping/test_parameterization.py index 43bf6ef0bf5152..49f9a6130c8d53 100644 --- a/tests/sentry/grouping/test_parameterization.py +++ b/tests/sentry/grouping/test_parameterization.py @@ -161,7 +161,7 @@ ("hex without prefix - no letters, < 8 digits, negative", "-1234567", ""), ("hex without prefix - no letters, 8+ digits, positive", "12345678", ""), ("git sha", "commit a93c7d2", "commit "), - ("git sha - all letters", "commit deadbeef", "commit deadbeef"), + ("git sha - all letters", "commit cabcafe", "commit cabcafe"), ("git sha - all numbers", "commit 4150908", "commit "), ("float", "0.23", ""), ("int", "23", ""), From 193165a8280e1e59663f1f087a580aa8a827303c Mon Sep 17 00:00:00 2001 From: Mihir-Mavalankar Date: Tue, 31 Mar 2026 16:25:38 -0700 Subject: [PATCH 09/11] feat(seer): Add structured LLM context system for Seer Explorer (#111554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds infrastructure for Seer Explorer to read structured semantic state from the currently rendered page — without scraping the DOM. ## What Three cooperating primitives: **`LLMContextProvider`** — drop-in root provider (mounted in the app shell). Owns a flat node registry stored in refs and exposes read/write operations via internal React context. Uses pure ref-based state — no reducer, no re-renders on registration — since consumers read data imperatively via `getSnapshot()`. **`registerLLMContext(nodeType, Component)`** — HOC that auto-registers a component as a named node on mount and removes it (plus all descendants) on unmount. `nodeType` is strictly typed (`LLMContextNodeType`) for typeahead and to prevent naming drift. Nesting follows React component hierarchy automatically via `LLMNodeContext`, which carries each component's `useId()`-generated node ID downward so child HOC wrappers can declare their `parentId` synchronously during render. **`useLLMContext(data)`** / **`useLLMContext()`** — write and read overloads. The write overload accepts any non-undefined value (objects, arrays, strings, numbers — polymorphic) and pushes it into the nearest registered context node. The read overload returns `getLLMContext(componentOnly?)` for full-tree or subtree snapshots. Named `LLMContext` (not `SeerContext`) since the system is generic and could be used by any LLM integration. ## Why Seer Explorer needs to understand what the user is currently looking at (dashboard, widgets, charts, etc.) to give grounded AI responses. This system lets any component opt in by wrapping with `registerLLMContext` and calling `useLLMContext(data)` — no manual tree wiring, no DOM inspection. ## Design notes - **Flat storage, lazy tree assembly** — nodes stored as `Map` in a ref; tree assembled at `getSnapshot()` time. Avoids ordering dependencies: a child can declare its `parentId` before the parent's registration effect has fired. - **Imperative ref for data** — `useLLMContext(data)` writes to a `useRef` rather than dispatching state updates. This sidesteps a fundamental timing issue: child effects fire before parent effects, so data writes happen before `registerNode`. The ref is always read fresh at `getSnapshot()` time. - **Zero re-renders** — the provider uses refs for all state and `useCallback(fn, [])` for all operations. The memoized context value is referentially stable, so neither the provider nor its consumers ever re-render from context changes. - **Strict context requirement** — `useLLMContextRegistry()` throws if called outside the provider (which lives at the app root), treating missing context as a bug rather than silently returning undefined. - **JSON dedup with circular-reference safety** — write path uses `JSON.stringify` equality to skip redundant writes, with a `try/catch` that falls back to always-write for non-serializable values. - **Cleanup on unregister** — `unregisterNode` removes descendant entries from both the node map and the data ref so stale entries don't accumulate. ## Tests 9 integration tests covering: empty state, nesting (Dashboard → Widget → Chart with full shape assertion), unmount cleanup, data updates across re-renders, non-object data types (strings, arrays, numbers), full-tree vs `componentOnly` subtree reads. --------- Co-authored-by: Claude Sonnet 4 Co-authored-by: Jeremy Stanley Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com> --- .github/CODEOWNERS | 1 + knip.config.ts | 2 + static/app/views/app/index.tsx | 15 +- .../seerExplorer/contexts/llmContext.spec.tsx | 339 ++++++++++++++++++ .../seerExplorer/contexts/llmContext.tsx | 251 +++++++++++++ .../seerExplorer/contexts/llmContextTypes.ts | 71 ++++ .../contexts/registerLLMContext.tsx | 61 ++++ 7 files changed, 734 insertions(+), 6 deletions(-) create mode 100644 static/app/views/seerExplorer/contexts/llmContext.spec.tsx create mode 100644 static/app/views/seerExplorer/contexts/llmContext.tsx create mode 100644 static/app/views/seerExplorer/contexts/llmContextTypes.ts create mode 100644 static/app/views/seerExplorer/contexts/registerLLMContext.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d55a776caa5241..858deb0a38ecee 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -338,6 +338,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /static/app/views/explore/spans/spansTabSeerComboBox.tsx @getsentry/explore @getsentry/machine-learning-ai /static/app/views/traces/ @getsentry/explore /static/app/components/quickTrace/ @getsentry/explore +/static/app/components/dnd/ @getsentry/explore /src/sentry/insights/ @getsentry/data-browsing /static/app/views/performance/ @getsentry/data-browsing /static/app/components/performance/ @getsentry/data-browsing diff --git a/knip.config.ts b/knip.config.ts index 83730053fb429e..b0b8657a864274 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -21,6 +21,8 @@ const productionEntryPoints = [ 'static/app/chartcuterie/**/*.{js,ts,tsx}', // TODO: Remove when used 'static/app/components/pipeline/**/*.{js,ts,tsx}', + // TODO: Remove when used + 'static/app/views/seerExplorer/contexts/**/*.{js,ts,tsx}', ]; const testingEntryPoints = [ diff --git a/static/app/views/app/index.tsx b/static/app/views/app/index.tsx index 8c33108514b4d9..38ab3b139210f5 100644 --- a/static/app/views/app/index.tsx +++ b/static/app/views/app/index.tsx @@ -36,6 +36,7 @@ import {AsyncSDKIntegrationContextProvider} from 'sentry/views/app/asyncSDKInteg import {LastKnownRouteContextProvider} from 'sentry/views/lastKnownRouteContextProvider'; import {OrganizationContextProvider} from 'sentry/views/organizationContext'; import {RouteAnalyticsContextProvider} from 'sentry/views/routeAnalyticsContextProvider'; +import {LLMContextProvider} from 'sentry/views/seerExplorer/contexts/llmContext'; import {ExplorerPanel} from 'sentry/views/seerExplorer/explorerPanel'; import {ExplorerPanelProvider} from 'sentry/views/seerExplorer/useExplorerPanel'; @@ -241,12 +242,14 @@ export function App() { - - - - - {renderBody()} - + + + + + + {renderBody()} + + diff --git a/static/app/views/seerExplorer/contexts/llmContext.spec.tsx b/static/app/views/seerExplorer/contexts/llmContext.spec.tsx new file mode 100644 index 00000000000000..faa19cf9bf38c0 --- /dev/null +++ b/static/app/views/seerExplorer/contexts/llmContext.spec.tsx @@ -0,0 +1,339 @@ +import type {ReactNode} from 'react'; + +import {render, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {LLMContextProvider, useLLMContext} from './llmContext'; +import type {LLMContextSnapshot} from './llmContextTypes'; +import {registerLLMContext} from './registerLLMContext'; + +// --------------------------------------------------------------------------- +// Test helper: ContextCapture +// +// Renders nothing but stores a reference to the getSnapshot function. +// Since the context value is memoized (stable), this component only renders +// once. However, getSnapshot() always reads stateRef.current (fresh), so +// calling capturedRef.current() in waitFor gives live data. +// --------------------------------------------------------------------------- + +function makeContextCapture() { + const ref: {current: ((componentOnly?: boolean) => LLMContextSnapshot) | null} = { + current: null, + }; + + function ContextCapture() { + const {getLLMContext} = useLLMContext(); + ref.current = getLLMContext; + return null; + } + + function getSnapshot(componentOnly?: boolean): LLMContextSnapshot { + if (!ref.current) throw new Error('ContextCapture not mounted'); + return ref.current(componentOnly); + } + + return {ContextCapture, getSnapshot}; +} + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +function DummyChart({label}: {label?: string}) { + useLLMContext({label: label ?? 'chart'}); + return
{label ?? 'chart'}
; +} + +function DummyWidget({title, children}: {children?: ReactNode; title?: string}) { + useLLMContext({title: title ?? 'widget', type: 'timeseries', unit: 'ms'}); + return ( +
+ {title ?? 'widget'} + {children} +
+ ); +} + +function DummyDashboard({name, children}: {children?: ReactNode; name?: string}) { + useLLMContext({name: name ?? 'dashboard'}); + return ( +
+ {name ?? 'dashboard'} + {children} +
+ ); +} + +const ContextChart = registerLLMContext('chart', DummyChart); +const ContextWidget = registerLLMContext('widget', DummyWidget); +const ContextDashboard = registerLLMContext('dashboard', DummyDashboard); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('LLMContextProvider — empty state', () => { + it('returns an empty snapshot when no nodes are registered', async () => { + const {ContextCapture, getSnapshot} = makeContextCapture(); + + render( + + + + ); + + await waitFor(() => { + expect(getSnapshot().nodes).toEqual([]); + }); + }); +}); + +describe('registerLLMContext — nesting', () => { + it('nests Chart inside Widget inside Dashboard in the snapshot', async () => { + const {ContextCapture, getSnapshot} = makeContextCapture(); + + render( + + + + + + + + + ); + + // Wait for the cascade of registration effects to settle, then assert the + // entire nested shape in one pass so the failure message shows the full tree. + await waitFor(() => { + expect(getSnapshot()).toEqual({ + version: expect.any(Number), + nodes: [ + { + nodeType: 'dashboard', + data: {name: 'Backend Health'}, + children: [ + { + nodeType: 'widget', + data: {title: 'Error Rate', type: 'timeseries', unit: 'ms'}, + children: [ + { + nodeType: 'chart', + data: {label: 'p99'}, + children: [], + }, + ], + }, + ], + }, + ], + }); + }); + }); +}); + +describe('registerLLMContext — unmount cleanup', () => { + it('removes the node from the tree when the component unmounts', async () => { + const {ContextCapture, getSnapshot} = makeContextCapture(); + + const {rerender} = render( + + + + + ); + + // Node should be present after mount + await waitFor(() => { + expect(getSnapshot().nodes).toHaveLength(1); + }); + + // Unmount the widget + rerender( + + + + ); + + // Node should be gone + await waitFor(() => { + expect(getSnapshot().nodes).toHaveLength(0); + }); + }); +}); + +describe('useLLMContext — data updates', () => { + it('writes data into the node and updates it on re-render', async () => { + const {ContextCapture, getSnapshot} = makeContextCapture(); + + function Gauge({value}: {value: number}) { + useLLMContext({value}); + return
{value}
; + } + const ContextGauge = registerLLMContext('widget', Gauge); + + const {rerender} = render( + + + + + ); + + // Initial data written after HOC registers and inner component re-renders + await waitFor(() => { + expect(getSnapshot().nodes[0]?.data).toEqual({value: 1}); + }); + + // Update the prop + rerender( + + + + + ); + + await waitFor(() => { + expect(getSnapshot().nodes[0]?.data).toEqual({value: 2}); + }); + }); + + it('handles non-object data types', async () => { + const {ContextCapture, getSnapshot} = makeContextCapture(); + + function Label({text}: {text: string}) { + useLLMContext(text); + return
{text}
; + } + const ContextLabel = registerLLMContext('widget', Label); + + render( + + + + + ); + + await waitFor(() => { + expect(getSnapshot().nodes[0]?.data).toBe('hello'); + }); + }); + + it('handles array data', async () => { + const {ContextCapture, getSnapshot} = makeContextCapture(); + + function Tags({items}: {items: string[]}) { + useLLMContext(items); + return
{items.join(',')}
; + } + const ContextTags = registerLLMContext('widget', Tags); + + render( + + + + + ); + + await waitFor(() => { + expect(getSnapshot().nodes[0]?.data).toEqual(['a', 'b', 'c']); + }); + }); + + it('handles numeric data', async () => { + const {ContextCapture, getSnapshot} = makeContextCapture(); + + function Score({value}: {value: number}) { + useLLMContext(value); + return
{value}
; + } + const ContextScore = registerLLMContext('widget', Score); + + render( + + + + + ); + + await waitFor(() => { + expect(getSnapshot().nodes[0]?.data).toBe(42); + }); + }); +}); + +describe('getLLMContext — full tree vs componentOnly', () => { + it('getLLMContext() returns full tree including sibling branches', async () => { + const {ContextCapture, getSnapshot} = makeContextCapture(); + + function Widget1() { + useLLMContext({id: 'w1'}); + return
w1
; + } + function Widget2() { + useLLMContext({id: 'w2'}); + return
w2
; + } + const CW1 = registerLLMContext('widget', Widget1); + const CW2 = registerLLMContext('widget', Widget2); + + render( + + + + + + ); + + await waitFor(() => { + const snapshot = getSnapshot(); + expect(snapshot.nodes).toHaveLength(2); + const types = snapshot.nodes.map(n => n.nodeType); + expect(types).toEqual(['widget', 'widget']); + }); + }); + + it('getLLMContext(true) returns only the current component subtree', async () => { + // We need a capture inside the dashboard to test componentOnly + const innerRef: { + current: ((c?: boolean) => LLMContextSnapshot) | null; + } = {current: null}; + + function DashboardWithCapture({name}: {name: string}) { + useLLMContext({name}); + const {getLLMContext} = useLLMContext(); + innerRef.current = getLLMContext; + return ( +
+ +
+ ); + } + const ContextDashboardWithCapture = registerLLMContext( + 'dashboard', + DashboardWithCapture + ); + + function SiblingDashboard() { + useLLMContext({name: 'sibling'}); + return
sibling
; + } + const ContextSiblingDashboard = registerLLMContext('dashboard', SiblingDashboard); + + render( + + + + + ); + + // componentOnly snapshot should contain only the dashboard + its inner widget, + // not the sibling dashboard + await waitFor(() => { + if (!innerRef.current) throw new Error('not mounted'); + const snapshot = innerRef.current(true); // componentOnly + expect(snapshot.nodes).toHaveLength(1); + expect(snapshot.nodes[0]?.nodeType).toBe('dashboard'); + expect(snapshot.nodes[0]?.children).toHaveLength(1); + expect(snapshot.nodes[0]?.children[0]?.nodeType).toBe('widget'); + }); + }); +}); diff --git a/static/app/views/seerExplorer/contexts/llmContext.tsx b/static/app/views/seerExplorer/contexts/llmContext.tsx new file mode 100644 index 00000000000000..9919867ccb132f --- /dev/null +++ b/static/app/views/seerExplorer/contexts/llmContext.tsx @@ -0,0 +1,251 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, +} from 'react'; + +import {createDefinedContext} from 'sentry/utils/performance/contexts/utils'; + +import type { + LLMContextInternalValue, + LLMContextNode, + LLMContextNodeSnapshot, + LLMContextSnapshot, + LLMContextState, +} from './llmContextTypes'; + +// --------------------------------------------------------------------------- +// Internal context — holds the registry operations (registerNode, etc.) +// --------------------------------------------------------------------------- + +const [_LLMContextProvider, _useLLMContextValue] = + createDefinedContext({ + name: 'LLMContext', + strict: true, + }); + +/** + * Hook for internal use by registerLLMContext and useLLMContext to access + * the registry operations (registerNode, unregisterNode, updateNodeData, getSnapshot). + * Throws if called outside an LLMContextProvider. + */ +export const useLLMContextRegistry = _useLLMContextValue; + +// --------------------------------------------------------------------------- +// LLMNodeContext — carries the current component's nodeId down the tree +// so child registerLLMContext wrappers can declare their parentId immediately +// during render (before any effects have fired). +// Default undefined = no parent (root level). +// --------------------------------------------------------------------------- + +export const LLMNodeContext = createContext(undefined); + +// --------------------------------------------------------------------------- +// Tree assembly helpers — convert the flat node map to a nested snapshot. +// Data is read from nodeData (imperative ref) rather than the reducer state +// so that writes from useLLMContext(data) are visible immediately even +// before the HOC's registerNode effect has fired. +// --------------------------------------------------------------------------- + +function collectDescendantIds( + nodes: Map, + nodeId: string, + result = new Set() +): Set { + result.add(nodeId); + for (const [id, node] of nodes) { + if (node.parentId === nodeId) { + collectDescendantIds(nodes, id, result); + } + } + return result; +} + +function buildTree( + nodes: LLMContextState['nodes'], + nodeData: Map, + parentId: string | undefined +): LLMContextNodeSnapshot[] { + const children: LLMContextNodeSnapshot[] = []; + for (const [id, node] of nodes) { + if (node.parentId === parentId) { + children.push({ + nodeType: node.nodeType, + data: nodeData.has(id) ? nodeData.get(id) : {}, + children: buildTree(nodes, nodeData, id), + }); + } + } + return children; +} + +function serializeState( + state: LLMContextState, + nodeData: Map, + fromNodeId?: string +): LLMContextSnapshot { + if (fromNodeId) { + const node = state.nodes.get(fromNodeId); + if (!node) { + return {version: state.version, nodes: []}; + } + return { + version: state.version, + nodes: [ + { + nodeType: node.nodeType, + data: nodeData.has(fromNodeId) ? nodeData.get(fromNodeId) : {}, + children: buildTree(state.nodes, nodeData, fromNodeId), + }, + ], + }; + } + return { + version: state.version, + nodes: buildTree(state.nodes, nodeData, undefined), + }; +} + +// --------------------------------------------------------------------------- +// LLMContextProvider — root of the entire context tree +// --------------------------------------------------------------------------- + +interface LLMContextProviderProps { + children: ReactNode; +} + +const INITIAL_STATE: LLMContextState = { + nodes: new Map(), + version: 0, +}; + +export function LLMContextProvider({children}: LLMContextProviderProps) { + // All state lives in refs — no re-renders needed. Consumers read + // the latest data imperatively via getSnapshot(). + const stateRef = useRef(INITIAL_STATE); + const nodeDataRef = useRef>(new Map()); + + const getSnapshot = useCallback((fromNodeId?: string): LLMContextSnapshot => { + return serializeState(stateRef.current, nodeDataRef.current, fromNodeId); + }, []); + + const registerNode = useCallback( + (nodeId: string, nodeType: string, parentId?: string): void => { + const prev = stateRef.current; + const newNodes = new Map(prev.nodes); + newNodes.set(nodeId, {nodeType, parentId}); + stateRef.current = {nodes: newNodes, version: prev.version + 1}; + }, + [] + ); + + const unregisterNode = useCallback((nodeId: string) => { + const prev = stateRef.current; + if (!prev.nodes.has(nodeId)) { + return; + } + const toRemove = collectDescendantIds(prev.nodes, nodeId); + const newNodes = new Map(prev.nodes); + for (const id of toRemove) { + newNodes.delete(id); + nodeDataRef.current.delete(id); + } + stateRef.current = {nodes: newNodes, version: prev.version + 1}; + }, []); + + const updateNodeData = useCallback((nodeId: string, data: unknown) => { + nodeDataRef.current.set(nodeId, data); + // Bump version so consumers using it as a change token detect data updates. + stateRef.current = {...stateRef.current, version: stateRef.current.version + 1}; + }, []); + + // Memoize so that the context value reference is stable across re-renders. + const value = useMemo( + () => ({getSnapshot, registerNode, unregisterNode, updateNodeData}), + [getSnapshot, registerNode, unregisterNode, updateNodeData] + ); + + return <_LLMContextProvider value={value}>{children}; +} + +// --------------------------------------------------------------------------- +// useLLMContext — write overload +// +// Call inside a registerLLMContext-wrapped component (or any descendant) +// to push structured data into the nearest registered context node. +// Accepts any value type — objects, arrays, strings, numbers, etc. +// +// useLLMContext({ title: 'Error Rate', threshold: 5 }); +// useLLMContext(someComputedValue); +// --------------------------------------------------------------------------- + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- {} here means "any non-undefined value" to distinguish from the no-arg read overload +export function useLLMContext(data: {} | null): void; + +// --------------------------------------------------------------------------- +// useLLMContext — read overload +// +// Call with no arguments to get getLLMContext. +// +// const { getLLMContext } = useLLMContext(); +// getLLMContext() // full tree from root +// getLLMContext(true) // current component's subtree only +// --------------------------------------------------------------------------- + +export function useLLMContext(): { + getLLMContext: (componentOnly?: boolean) => LLMContextSnapshot; +}; + +export function useLLMContext( + data?: unknown +): void | {getLLMContext: (componentOnly?: boolean) => LLMContextSnapshot} { + const ctx = useLLMContextRegistry(); + const nodeId = useContext(LLMNodeContext); + const prevDataRef = useRef(''); + + // Write path: sync data into the nearest node whenever it changes. + // JSON equality guard prevents redundant writes. updateNodeData writes + // imperatively to a ref — no dispatch, no re-render required. + useEffect(() => { + if (!nodeId || data === undefined) { + return; + } + let serialized: string | null; + let safeData: unknown = data; + try { + serialized = JSON.stringify(data); + } catch { + // Non-serializable value (e.g. circular reference) — store a + // placeholder so getSnapshot() remains JSON-serializable. + serialized = null; + safeData = {error: 'non-serializable value'}; + } + if (serialized === null || serialized !== prevDataRef.current) { + if (serialized !== null) { + prevDataRef.current = serialized; + } + ctx.updateNodeData(nodeId, safeData); + } + }); + + // Read path: always created so hooks run unconditionally. + // Only returned when called without data. + const getLLMContext = useCallback( + (componentOnly?: boolean): LLMContextSnapshot => { + if (componentOnly && nodeId) { + return ctx.getSnapshot(nodeId); + } + return ctx.getSnapshot(); + }, + [ctx, nodeId] + ); + + if (data === undefined) { + return {getLLMContext}; + } + return undefined; +} diff --git a/static/app/views/seerExplorer/contexts/llmContextTypes.ts b/static/app/views/seerExplorer/contexts/llmContextTypes.ts new file mode 100644 index 00000000000000..1120072a3a6667 --- /dev/null +++ b/static/app/views/seerExplorer/contexts/llmContextTypes.ts @@ -0,0 +1,71 @@ +/** + * LLM Context System — Types + * + * A flat map of context nodes that captures semantic state from the currently + * rendered page. Each node corresponds to a React component (dashboard, + * widget, etc.) and holds key-value data about it. The LLM context reader + * (e.g. Seer Explorer) reads a snapshot of this tree instead of scraping + * the DOM. + * + * Nodes are stored flat (keyed by ID) with a `parentId` pointer. The nested + * tree structure is assembled lazily at getSnapshot() time. This avoids + * ordering dependencies during registration — a child can declare its + * parentId immediately even before the parent's effect has fired. + */ + +/** + * Known node types for the LLM context tree. + * Add new types here as new context-aware components are registered. + */ +export type LLMContextNodeType = 'chart' | 'dashboard' | 'widget'; + +/** + * A single node in the flat registry. + * + * - `nodeType` — what kind of thing this is ("dashboard", "widget", etc.) + * - `parentId` — ID of the parent node, or undefined for root-level nodes + * + * Note: node data is stored separately in the provider's imperative + * `nodeDataRef` rather than on this struct, so that writes from + * `useLLMContext(data)` don't require a state mutation. + */ +export interface LLMContextNode { + nodeType: string; + parentId?: string; +} + +/** + * The full state held by the provider (stored in a ref, not reactive). + * + * - `nodes` — flat map of all registered nodes keyed by ID + * - `version` — bumped on every mutation so consumers can detect updates cheaply + */ +export interface LLMContextState { + nodes: Map; + version: number; +} + +/** + * The snapshot format returned by `getSnapshot()`. This is what gets sent + * to the LLM API — a plain-JSON-serializable nested tree. + */ +export interface LLMContextSnapshot { + nodes: LLMContextNodeSnapshot[]; + version: number; +} + +export interface LLMContextNodeSnapshot { + children: LLMContextNodeSnapshot[]; + data: unknown; + nodeType: string; +} + +/** + * The value exposed by the internal LLMContext to the HOC and hooks. + */ +export interface LLMContextInternalValue { + getSnapshot: (fromNodeId?: string) => LLMContextSnapshot; + registerNode: (nodeId: string, nodeType: string, parentId?: string) => void; + unregisterNode: (nodeId: string) => void; + updateNodeData: (nodeId: string, data: unknown) => void; +} diff --git a/static/app/views/seerExplorer/contexts/registerLLMContext.tsx b/static/app/views/seerExplorer/contexts/registerLLMContext.tsx new file mode 100644 index 00000000000000..062603624a64d1 --- /dev/null +++ b/static/app/views/seerExplorer/contexts/registerLLMContext.tsx @@ -0,0 +1,61 @@ +import type {ComponentType} from 'react'; +import {useContext, useEffect, useId} from 'react'; + +import {LLMNodeContext, useLLMContextRegistry} from './llmContext'; +import type {LLMContextNodeType} from './llmContextTypes'; + +/** + * HOC that registers a component as a named node in the LLM context tree. + * + * On mount, a new node of the given `nodeType` is created in the tree, + * nested under the nearest parent node (from `LLMNodeContext`). + * On unmount, the node and all its descendants are removed. + * + * The wrapped component receives all its original props unchanged. + * To push structured data into this component's node, call + * `useLLMContext({ key: value })` anywhere inside the wrapped component. + * + * Usage: + * const ContextAwareDashboard = registerLLMContext('dashboard', Dashboard); + * const ContextAwareWidget = registerLLMContext('widget', Widget); + * + * // Widget rendered inside Dashboard will nest correctly: + * // { nodeType: 'dashboard', children: [{ nodeType: 'widget', ... }] } + */ +export function registerLLMContext

>( + nodeType: LLMContextNodeType, + WrappedComponent: ComponentType

+): ComponentType

{ + function LLMContextWrapper(props: P) { + const ctx = useLLMContextRegistry(); + + // Read the nearest parent's nodeId from LLMNodeContext. + // undefined = no parent (this node will be at root level). + const parentNodeId = useContext(LLMNodeContext); + + // React's useId generates a stable, unique ID for this component instance. + const ownNodeId = useId(); + + useEffect(() => { + ctx.registerNode(ownNodeId, nodeType, parentNodeId); + return () => { + ctx.unregisterNode(ownNodeId); + }; + // parentNodeId in deps: if the parent context changes (e.g. parent + // component re-mounts), re-register under the new parent. + }, [ctx, ownNodeId, parentNodeId]); + + return ( + // Provide ownNodeId downward so child registerLLMContext wrappers + // and useLLMContext(data) calls read this as their context anchor. + + {/* TODO(any): HoC prop types not working w/ emotion https://github.com/emotion-js/emotion/issues/3261 */} + + + ); + } + + LLMContextWrapper.displayName = `registerLLMContext(${nodeType}, ${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`; + + return LLMContextWrapper; +} From cea01648813768d8339d476855ee514eb55b81a2 Mon Sep 17 00:00:00 2001 From: Kyle Consalus Date: Tue, 31 Mar 2026 16:31:44 -0700 Subject: [PATCH 10/11] chore(workflows): Stop scheduling sentry.workflow_engine.tasks.cleanup.prune_old_fire_history (#111792) It has done the backlog clearing that was necessary; now we can lean on incinerator. --- src/sentry/conf/server.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index cbd1bd453e7908..c83313afaa50ff 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -1013,10 +1013,6 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: "task": "workflow_engine:sentry.workflow_engine.tasks.workflows.schedule_delayed_workflows", "schedule": timedelta(seconds=15), }, - "prune-old-fire-history": { - "task": "workflow_engine:sentry.workflow_engine.tasks.cleanup.prune_old_fire_history", - "schedule": timedelta(minutes=2), - }, "resolve-stale-sourcemap-detectors": { "task": "workflow_engine:sentry.processing_errors.tasks.resolve_stale_sourcemap_detectors", "schedule": crontab("*/5", "*", "*", "*", "*"), From 7cab0a10f18baa50bf80ee5d60a443428bbce517 Mon Sep 17 00:00:00 2001 From: Sofia Rest <68917129+srest2021@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:08:53 -0700 Subject: [PATCH 11/11] fix(seer): don't read from org defaults when creating default project preference (#111967) I made [this change](https://github.com/getsentry/sentry/pull/111697/changes#diff-204df1d50b6d80eb112a1bafcdc4bb853e274f0e56ae3e082897b201aa0de550L391-L392) in my previous PR which I now realize is incorrect. This needs to read from project preferences and can be updated once the settings migration is complete. For now we can just use CODE_CHANGES. Functionally there is not much difference between the two except the change in my previous PR allows the autoOpenPrs org option to override the stopping point. The project preference should always have the correct stopping point. Let's change it back. --- src/sentry/seer/autofix/utils.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 8ac05e2efa1eba..fba791d6413418 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -389,13 +389,12 @@ def validate(self, data): def default_seer_project_preference(project: Project) -> SeerProjectPreference: - stopping_point, handoff = get_org_default_seer_automation_handoff(project.organization) return SeerProjectPreference( organization_id=project.organization.id, project_id=project.id, repositories=[], - automated_run_stopping_point=stopping_point, - automation_handoff=handoff, + automated_run_stopping_point=AutofixStoppingPoint.CODE_CHANGES.value, + automation_handoff=None, )