From 56226c4f159e72e2476b923ffb9743289d8055c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Dorfmeister=20=F0=9F=94=AE?= Date: Tue, 31 Mar 2026 12:33:59 +0200 Subject: [PATCH 01/21] fix(ci): let precommit format json files (#111874) --- .pre-commit-config.yaml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f304f57d164524..72a22d5a22a6e8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -100,7 +100,7 @@ repos: - id: format name: format language: system - types_or: [yaml, ts, tsx, javascript, jsx, css, mdx, markdown] + types_or: [yaml, ts, tsx, javascript, jsx, css, mdx, markdown, json] entry: ./node_modules/.bin/oxfmt - id: knip diff --git a/package.json b/package.json index 0a99a6193c5309..a435196733a0ef 100644 --- a/package.json +++ b/package.json @@ -264,8 +264,8 @@ "eslint-plugin-no-relative-import-paths": "^1.6.1", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "6.1.0", - "eslint-plugin-regexp": "^3.0.0", "eslint-plugin-react-you-might-not-need-an-effect": "0.5.3", + "eslint-plugin-regexp": "^3.0.0", "eslint-plugin-sentry": "^2.10.0", "eslint-plugin-testing-library": "^7.16.0", "eslint-plugin-typescript-sort-keys": "^3.3.0", From 44531beaa017e6eaea9a5d8259fa6c1ac53a12e9 Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Tue, 31 Mar 2026 12:44:38 +0200 Subject: [PATCH 02/21] ref(dynamic-sampling): remove all usage of custom ds rules in code (#110979) - Remove rule hash function - Remove constants - Remove all methods on custom dynamic sampling rules before removal of the model Contributes to TET-1957 --- .github/codeowners-coverage-baseline.txt | 1 - src/sentry/dynamic_sampling/__init__.py | 2 - src/sentry/dynamic_sampling/rules/utils.py | 21 +- src/sentry/models/dynamicsampling.py | 296 +------------- .../services/organization/impl.py | 2 - src/sentry/testutils/helpers/backups.py | 29 +- tests/sentry/models/test_dynamicsampling.py | 363 ------------------ .../test_success.pysnap | 2 +- tests/sentry/users/models/test_user.py | 3 - 9 files changed, 22 insertions(+), 697 deletions(-) delete mode 100644 tests/sentry/models/test_dynamicsampling.py diff --git a/.github/codeowners-coverage-baseline.txt b/.github/codeowners-coverage-baseline.txt index c7b63d380a879d..288c082025f469 100644 --- a/.github/codeowners-coverage-baseline.txt +++ b/.github/codeowners-coverage-baseline.txt @@ -2144,7 +2144,6 @@ tests/sentry/models/test_commitfilechange.py tests/sentry/models/test_dashboard.py tests/sentry/models/test_debugfile.py tests/sentry/models/test_deploy.py -tests/sentry/models/test_dynamicsampling.py tests/sentry/models/test_environment.py tests/sentry/models/test_eventattachment.py tests/sentry/models/test_eventerror.py diff --git a/src/sentry/dynamic_sampling/__init__.py b/src/sentry/dynamic_sampling/__init__.py index e43f7e7c529f25..6431f4973034e8 100644 --- a/src/sentry/dynamic_sampling/__init__.py +++ b/src/sentry/dynamic_sampling/__init__.py @@ -14,7 +14,6 @@ RuleType, get_enabled_user_biases, get_redis_client_for_ds, - get_rule_hash, get_supported_biases_ids, get_user_biases, ) @@ -25,7 +24,6 @@ "get_user_biases", "get_enabled_user_biases", "get_redis_client_for_ds", - "get_rule_hash", "record_latest_release", "RuleType", "ExtendedBoostedRelease", diff --git a/src/sentry/dynamic_sampling/rules/utils.py b/src/sentry/dynamic_sampling/rules/utils.py index d8c51a96c636be..8329911463ae7c 100644 --- a/src/sentry/dynamic_sampling/rules/utils.py +++ b/src/sentry/dynamic_sampling/rules/utils.py @@ -3,14 +3,14 @@ from enum import Enum from typing import Literal, NotRequired, TypedDict, Union -import orjson from django.conf import settings from redis import StrictRedis -from sentry.models.dynamicsampling import CUSTOM_RULE_START from sentry.relay.types import RuleCondition from sentry.utils import redis +CUSTOM_RULE_START = 3000 + BOOSTED_RELEASES_LIMIT = 10 LATEST_RELEASES_BOOST_FACTOR = 1.5 @@ -117,23 +117,6 @@ class DecayingRule(Rule): PolymorphicRule = Union[Rule, DecayingRule] -def get_rule_hash(rule: PolymorphicRule) -> int: - # We want to be explicit in what we use for computing the hash. In addition, we need to remove certain fields like - # the sampleRate. - return ( - orjson.dumps( - { - "id": rule["id"], - "type": rule["type"], - "condition": rule["condition"], - }, - option=orjson.OPT_SORT_KEYS, - ) - .decode() - .__hash__() - ) - - def get_user_biases(user_set_biases: list[ActivatableBias] | None) -> list[ActivatableBias]: if user_set_biases is None: return DEFAULT_BIASES diff --git a/src/sentry/models/dynamicsampling.py b/src/sentry/models/dynamicsampling.py index c4e48774fd0398..9ed171a1bc2524 100644 --- a/src/sentry/models/dynamicsampling.py +++ b/src/sentry/models/dynamicsampling.py @@ -1,67 +1,12 @@ from __future__ import annotations -import hashlib -from collections.abc import Mapping, Sequence -from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any - -from django.db import models, router, transaction -from django.db.models import F, IntegerField, Max, Q, Subquery, Value -from django.db.models.functions import Coalesce +from django.db import models +from django.db.models import Q from django.utils import timezone from sentry.backup.scopes import RelocationScope -from sentry.constants import ObjectStatus from sentry.db.models import FlexibleForeignKey, Model, cell_silo_model from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey -from sentry.utils import json, metrics - -if TYPE_CHECKING: - from sentry.models.organization import Organization - from sentry.models.project import Project - -# max number of custom rules that can be created per organization -MAX_CUSTOM_RULES = 2000 -CUSTOM_RULE_START = 3000 -MAX_CUSTOM_RULES_PER_PROJECT = 50 -CUSTOM_RULE_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" - - -class TooManyRules(ValueError): - """ - Raised when a there is already the max number of rules active for an organization - """ - - -def get_rule_hash(condition: Any, project_ids: Sequence[int]) -> str: - """ - Returns the hash of the rule based on the condition and projects - """ - condition_string = to_order_independent_string(condition) - project_string = to_order_independent_string(list(project_ids)) - rule_string = f"{condition_string}-{project_string}" - # make it a bit shorter - return hashlib.sha1(rule_string.encode("utf-8")).hexdigest() - - -def to_order_independent_string(val: Any) -> str: - """ - Converts a value in an order independent string and then hashes it - - Note: this will insure the same repr is generated for ['x', 'y'] and ['y', 'x'] - Also the same repr is generated for {'x': 1, 'y': 2} and {'y': 2, 'x': 1} - """ - ret_val = "" - if isinstance(val, Mapping): - for key in sorted(val.keys()): - ret_val += f"{key}:{to_order_independent_string(val[key])}-" - elif isinstance(val, (list, tuple)): - vals = sorted([to_order_independent_string(item) for item in val]) - for item in vals: - ret_val += f"{item}-" - else: - ret_val = str(val) - return ret_val @cell_silo_model @@ -114,16 +59,6 @@ class CustomDynamicSamplingRule(Model): created_by_id = HybridCloudForeignKey("sentry.User", on_delete="CASCADE", null=True, blank=True) notification_sent = models.BooleanField(null=True, blank=True) - @property - def external_rule_id(self) -> int: - """ - Returns the external rule id - - For external users, i.e. Relay, we need to shift the ids since the slot we - have allocated starts at the offset specified in RESERVED_IDS. - """ - return self.rule_id + CUSTOM_RULE_START - class Meta: app_label = "sentry" db_table = "sentry_customdynamicsamplingrule" @@ -137,230 +72,3 @@ class Meta: fields=["condition_hash"], name="condition_hash_idx", condition=Q(is_active=True) ), ] - - @staticmethod - def get_rule_for_org( - condition: Any, - organization_id: int, - project_ids: Sequence[int], - ) -> CustomDynamicSamplingRule | None: - """ - Returns an active rule for the given condition and organization if it exists otherwise None - - Note: There should not be more than one active rule for a given condition and organization - This function doesn't verify this condition, it just returns the first one. - """ - rule_hash = get_rule_hash(condition, project_ids) - rules = CustomDynamicSamplingRule.objects.filter( - organization_id=organization_id, - condition_hash=rule_hash, - is_active=True, - end_date__gt=timezone.now(), - )[:1] - - return rules[0] if rules else None - - @staticmethod - def update_or_create( - condition: Any, - start: datetime, - end: datetime, - project_ids: Sequence[int], - organization_id: int, - num_samples: int, - sample_rate: float, - query: str, - created_by_id: int | None = None, - ) -> CustomDynamicSamplingRule: - from sentry.models.organization import Organization - from sentry.models.project import Project - - with transaction.atomic(router.db_for_write(CustomDynamicSamplingRule)): - # check if rule already exists for this organization - existing_rule = CustomDynamicSamplingRule.get_rule_for_org( - condition, organization_id, project_ids - ) - - if existing_rule is not None: - # we already have an active rule for this condition and this organization - # update the expiration date and ensure that our projects are included - existing_rule.end_date = max(end, existing_rule.end_date) - existing_rule.num_samples = max(num_samples, existing_rule.num_samples) - existing_rule.sample_rate = max(sample_rate, existing_rule.sample_rate) - - # for org rules we don't need to do anything with the projects - existing_rule.save() - return existing_rule - else: - projects = Project.objects.get_many_from_cache(project_ids) - projects = list(projects) - organization = Organization.objects.get_from_cache(id=organization_id) - - if CustomDynamicSamplingRule.per_project_limit_reached(projects, organization): - raise TooManyRules() - - # create a new rule - rule_hash = get_rule_hash(condition, project_ids) - is_org_level = len(project_ids) == 0 - condition_str = json.dumps(condition) - rule = CustomDynamicSamplingRule.objects.create( - organization_id=organization_id, - condition=condition_str, - sample_rate=sample_rate, - start_date=start, - end_date=end, - num_samples=num_samples, - condition_hash=rule_hash, - is_active=True, - is_org_level=is_org_level, - query=query, - notification_sent=False, - created_by_id=created_by_id, - ) - - rule.save() - # now try to assign a rule id - id = rule.assign_rule_id() - if id > MAX_CUSTOM_RULES: - # we have too many rules, delete this one - rule.delete() - raise TooManyRules() - - # set the projects if not org level - for project in projects: - CustomDynamicSamplingRuleProject.objects.create( - custom_dynamic_sampling_rule=rule, project=project - ) - return rule - - def assign_rule_id(self) -> int: - """ - Assigns the smallest rule id that is not taken in the - current organization. - """ - if self.id is None: - raise ValueError("Cannot assign rule id to unsaved object") - if self.rule_id != 0: - raise ValueError("Cannot assign rule id to object that already has a rule id") - - now = timezone.now() - - base_qs = CustomDynamicSamplingRule.objects.filter( - organization_id=self.organization.id, end_date__gt=now, is_active=True - ) - - # We want to find the smallest free rule id. We do this by self-joining with rule_id + 1 and excluding the existing rule_ids. - # We then order by rule_id_plus_one and take the first value. - # This also works for the first rule, as it is pre-initialized with 0, and will thus end up with 1. - new_rule_id_subquery = Subquery( - base_qs.annotate(rule_id_plus_one=F("rule_id") + 1) - .exclude(rule_id_plus_one__in=base_qs.values_list("rule_id", flat=True)) - .order_by("rule_id_plus_one") - .values("rule_id_plus_one")[:1] - ) - - max_rule_id = base_qs.aggregate(Max("rule_id"))["rule_id__max"] or 0 - fallback_value = Value(max_rule_id + 1, output_field=IntegerField()) - - safe_new_rule_id = Coalesce(new_rule_id_subquery, fallback_value) - - # Update this instance with the new rule_id - CustomDynamicSamplingRule.objects.filter(id=self.id).update(rule_id=safe_new_rule_id) - self.refresh_from_db() - return self.rule_id - - @staticmethod - def deactivate_old_rules() -> None: - """ - Deactivates all rules expired rules (this is just an optimization to remove old rules from indexes). - - This should be called periodically to clean up old rules (it is not necessary to call it for correctness, - just for performance) - """ - CustomDynamicSamplingRule.objects.filter( - # give it a minute grace period to make sure we don't deactivate rules that are still active - end_date__lt=timezone.now() - timedelta(minutes=1), - ).update(is_active=False) - - @staticmethod - def get_project_rules( - project: Project, - ) -> Sequence[CustomDynamicSamplingRule]: - """ - Returns all active project rules - """ - now = timezone.now() - # org rules ( apply to all projects in the org) - org_rules = CustomDynamicSamplingRule.objects.filter( - is_active=True, - is_org_level=True, - organization=project.organization, - end_date__gt=now, - start_date__lt=now, - )[: MAX_CUSTOM_RULES_PER_PROJECT + 1] - - # project rules - project_rules = CustomDynamicSamplingRule.objects.filter( - is_active=True, - projects__in=[project], - end_date__gt=now, - start_date__lt=now, - )[: MAX_CUSTOM_RULES_PER_PROJECT + 1] - - rules = list(project_rules.union(org_rules)[: MAX_CUSTOM_RULES_PER_PROJECT + 1]) - - if len(rules) > MAX_CUSTOM_RULES_PER_PROJECT: - metrics.incr("dynamic_sampling.custom_rules.overflow") - - return rules[:MAX_CUSTOM_RULES_PER_PROJECT] - - @staticmethod - def deactivate_expired_rules() -> None: - """ - Deactivates all rules that have expired - """ - CustomDynamicSamplingRule.objects.filter( - end_date__lt=timezone.now(), is_active=True - ).update(is_active=False) - - @staticmethod - def num_active_rules_for_project(project: Project) -> int: - """ - Returns the number of active rules for the given project - """ - now = timezone.now() - - num_org_rules = CustomDynamicSamplingRule.objects.filter( - is_active=True, - is_org_level=True, - organization=project.organization, - end_date__gt=now, - start_date__lte=now, - ).count() - - num_proj_rules = CustomDynamicSamplingRule.objects.filter( - is_active=True, - is_org_level=False, - projects__in=[project], - end_date__gt=now, - start_date__lte=now, - ).count() - - return num_proj_rules + num_org_rules - - @staticmethod - def per_project_limit_reached(projects: Sequence[Project], organization: Organization) -> bool: - """ - Returns True if the rule limit is reached for any of the given projects (or all - the projects in the organization if org level rule) - """ - projects = list(projects) - if len(projects) == 0: - # an org rule check all the org projects - org_projects = organization.project_set.filter(status=ObjectStatus.ACTIVE) - projects = list(org_projects) - for project in projects: - num_rules = CustomDynamicSamplingRule.num_active_rules_for_project(project) - if num_rules >= MAX_CUSTOM_RULES_PER_PROJECT: - return True - return False diff --git a/src/sentry/organizations/services/organization/impl.py b/src/sentry/organizations/services/organization/impl.py index 2cdb2a01a3edc6..5b7c8db37cf3fd 100644 --- a/src/sentry/organizations/services/organization/impl.py +++ b/src/sentry/organizations/services/organization/impl.py @@ -20,7 +20,6 @@ from sentry.incidents.models.incident import IncidentActivity from sentry.models.activity import Activity from sentry.models.dashboard import Dashboard, DashboardFavoriteUser -from sentry.models.dynamicsampling import CustomDynamicSamplingRule from sentry.models.groupassignee import GroupAssignee from sentry.models.groupbookmark import GroupBookmark from sentry.models.groupsearchview import GroupSearchView @@ -581,7 +580,6 @@ def merge_users(self, *, organization_id: int, from_user_id: int, to_user_id: in Activity, AlertRule, AlertRuleActivity, - CustomDynamicSamplingRule, Dashboard, DashboardFavoriteUser, GroupAssignee, diff --git a/src/sentry/testutils/helpers/backups.py b/src/sentry/testutils/helpers/backups.py index 51c6cadabfdd8b..f99e6fe1e45cb0 100644 --- a/src/sentry/testutils/helpers/backups.py +++ b/src/sentry/testutils/helpers/backups.py @@ -77,7 +77,10 @@ DashboardWidgetQueryOnDemand, DashboardWidgetTypes, ) -from sentry.models.dynamicsampling import CustomDynamicSamplingRule +from sentry.models.dynamicsampling import ( + CustomDynamicSamplingRule, + CustomDynamicSamplingRuleProject, +) from sentry.models.groupassignee import GroupAssignee from sentry.models.groupbookmark import GroupBookmark from sentry.models.groupsearchview import GroupSearchView, GroupSearchViewProject @@ -520,17 +523,6 @@ def create_exhaustive_organization( sent_initial_email_date=timezone.now(), sent_final_email_date=timezone.now(), ) - CustomDynamicSamplingRule.update_or_create( - created_by_id=owner_id, - condition={"op": "equals", "name": "environment", "value": "prod"}, - start=timezone.now(), - end=timezone.now() + timedelta(hours=1), - project_ids=[project.id], - organization_id=org.id, - num_samples=100, - sample_rate=0.5, - query="environment:prod event.type:transaction", - ) # Environment* self.create_environment(project=project) @@ -827,6 +819,19 @@ def create_exhaustive_organization( overrides={"write_key": "test_override_write_key"}, ) + custom_rule = CustomDynamicSamplingRule.objects.create( + organization=org, + condition='{"op":"and","inner":[]}', + end_date=timezone.now() + timedelta(days=1), + num_samples=100, + condition_hash="abc123def456abc123def456abc123def4560000", + sample_rate=0.5, + ) + CustomDynamicSamplingRuleProject.objects.create( + custom_dynamic_sampling_rule=custom_rule, + project=project, + ) + return org @assume_test_silo_mode(SiloMode.CONTROL) diff --git a/tests/sentry/models/test_dynamicsampling.py b/tests/sentry/models/test_dynamicsampling.py deleted file mode 100644 index 52bbd5737c4a8f..00000000000000 --- a/tests/sentry/models/test_dynamicsampling.py +++ /dev/null @@ -1,363 +0,0 @@ -from datetime import datetime, timedelta - -import pytest -from django.utils import timezone - -from sentry.models.dynamicsampling import ( - MAX_CUSTOM_RULES_PER_PROJECT, - CustomDynamicSamplingRule, - TooManyRules, -) -from sentry.models.organization import Organization -from sentry.models.project import Project -from sentry.testutils.cases import TestCase -from sentry.testutils.helpers.datetime import freeze_time - - -def _create_rule_for_env( - env_idx: int, projects: list[Project], organization: Organization -) -> CustomDynamicSamplingRule: - condition = {"op": "equals", "name": "environment", "value": f"prod{env_idx}"} - return CustomDynamicSamplingRule.update_or_create( - condition=condition, - start=timezone.now(), - end=timezone.now() + timedelta(hours=1), - project_ids=[project.id for project in projects], - organization_id=organization.id, - num_samples=100, - sample_rate=0.5, - query=f"environment:prod{env_idx}", - ) - - -@freeze_time("2023-09-18") -class TestCustomDynamicSamplingRuleProject(TestCase): - def setUp(self) -> None: - super().setUp() - self.second_project = self.create_project() - self.second_organization = self.create_organization(owner=self.user) - self.third_project = self.create_project(organization=self.second_organization) - - def test_update_or_create(self) -> None: - condition = {"op": "equals", "name": "environment", "value": "prod"} - - end1 = timezone.now() + timedelta(hours=1) - - rule = CustomDynamicSamplingRule.update_or_create( - condition=condition, - start=timezone.now(), - end=end1, - project_ids=[self.project.id], - organization_id=self.organization.id, - num_samples=100, - sample_rate=0.5, - query="environment:prod", - ) - - end2 = timezone.now() + timedelta(hours=1) - updated_rule = CustomDynamicSamplingRule.update_or_create( - condition=condition, - start=timezone.now() + timedelta(minutes=1), - end=end2, - project_ids=[self.project.id], - organization_id=self.organization.id, - num_samples=100, - sample_rate=0.5, - query="environment:prod", - ) - - assert rule.id == updated_rule.id - projects = updated_rule.projects.all() - - assert len(projects) == 1 - assert self.project in projects - - assert updated_rule.end_date >= end1 - assert updated_rule.end_date >= end2 - - def test_assign_rule_id(self) -> None: - rule_ids = set() - rules = [] - for idx in range(3): - rule = _create_rule_for_env(idx, [self.project], self.organization) - rule_ids.add(rule.rule_id) - rules.append(rule) - - # all 3 rules have different rule ids - assert len(rule_ids) == 3 - - # make a rule obsolete and check that the rule id is reused - rules[1].is_active = False - rules[1].save() - - new_rule = _create_rule_for_env(4, [self.project], self.organization) - assert new_rule.rule_id == rules[1].rule_id - - # a new rule will take another slot (now that there is no free slot) - new_rule_2 = _create_rule_for_env(5, [self.project], self.organization) - assert new_rule_2.rule_id not in rule_ids - - # make again an empty slot ( this time by having the rule expire) - rules[2].start_date = timezone.now() - timedelta(hours=2) - rules[2].end_date = timezone.now() - timedelta(hours=1) - rules[2].save() - - # the new rule should take the empty slot - new_rule_3 = _create_rule_for_env(6, [self.project], self.organization) - assert new_rule_3.rule_id == rules[2].rule_id - - def test_deactivate_old_rules(self) -> None: - idx = 1 - - old_rules = [] - new_rules = [] - - def create_rule(is_old: bool, idx: int) -> CustomDynamicSamplingRule: - condition = {"op": "equals", "name": "environment", "value": f"prod{idx}"} - if is_old: - end_delta = -timedelta(hours=1) - else: - end_delta = timedelta(hours=1) - return CustomDynamicSamplingRule.update_or_create( - condition=condition, - start=timezone.now() - timedelta(hours=2), - end=timezone.now() + end_delta, - project_ids=[self.project.id], - organization_id=self.organization.id, - num_samples=100, - sample_rate=0.5, - query=f"environment:prod{idx}", - ) - - for i in range(10): - for is_old in [True, False]: - idx += 1 - rule = create_rule(is_old, idx) - if is_old: - old_rules.append(rule) - else: - new_rules.append(rule) - - CustomDynamicSamplingRule.deactivate_old_rules() - - # check that all old rules are inactive and all new rules are active - inactive_rules = list(CustomDynamicSamplingRule.objects.filter(is_active=False)) - assert len(inactive_rules) == 10 - for rule in old_rules: - assert rule in inactive_rules - - active_rules = list(CustomDynamicSamplingRule.objects.filter(is_active=True)) - assert len(active_rules) == 10 - for rule in new_rules: - assert rule in active_rules - - def test_get_rule_for_org(self) -> None: - """ - Test the get_rule_for_org method - """ - condition = {"op": "equals", "name": "environment", "value": "prod"} - - # check empty result - rule = CustomDynamicSamplingRule.get_rule_for_org( - condition, self.organization.id, [self.project.id] - ) - assert rule is None - - new_rule = CustomDynamicSamplingRule.update_or_create( - condition=condition, - start=timezone.now() - timedelta(hours=2), - end=timezone.now() + timedelta(hours=1), - project_ids=[self.project.id], - organization_id=self.organization.id, - num_samples=100, - sample_rate=0.5, - query="environment:prod", - ) - - rule = CustomDynamicSamplingRule.get_rule_for_org( - condition, self.organization.id, [self.project.id] - ) - assert rule == new_rule - - def test_get_project_rules(self) -> None: - """ - Tests that all valid rules (i.e. active and within the date range) that apply to a project - (i.e. that are either organization rules or apply to the project) are returned. - """ - - idx = [1] - - def create_rule( - project_ids: list[int], - org_id: int | None = None, - old: bool = False, - new: bool = False, - ) -> CustomDynamicSamplingRule: - idx[0] += 1 - condition = {"op": "equals", "name": "environment", "value": f"prod{idx[0]}"} - if old: - end_delta = -timedelta(hours=2) - else: - end_delta = timedelta(hours=2) - - if new: - start_delta = timedelta(hours=1) - else: - start_delta = -timedelta(hours=1) - - if org_id is None: - org_id = self.organization.id - - return CustomDynamicSamplingRule.update_or_create( - condition=condition, - start=timezone.now() + start_delta, - end=timezone.now() + end_delta, - project_ids=project_ids, - organization_id=org_id, - num_samples=100, - sample_rate=0.5, - query=f"environment:prod{idx[0]}", - ) - - valid_project_rule = create_rule([self.project.id, self.second_project.id]) - valid_org_rule = create_rule([]) - # rule for another project - create_rule([self.second_project.id]) - # rule for another org - create_rule([self.third_project.id], org_id=self.second_organization.id) - # old project rule ( already expired) - create_rule([self.project.id], old=True) - # new project rule ( not yet active) - create_rule([self.project.id], new=True) - # old org rule - create_rule([], old=True) - # new org rule - create_rule([], new=True) - - # we should only get valid_project_rule and valid_org_rule - rules = list(CustomDynamicSamplingRule.get_project_rules(self.project)) - assert len(rules) == 2 - assert valid_project_rule in rules - assert valid_org_rule in rules - - def test_separate_projects_create_different_rules(self) -> None: - """ - Tests that same condition for different projects create different rules - """ - condition = {"op": "equals", "name": "environment", "value": "prod"} - - end1 = timezone.now() + timedelta(hours=1) - - rule = CustomDynamicSamplingRule.update_or_create( - condition=condition, - start=timezone.now(), - end=end1, - project_ids=[self.project.id], - organization_id=self.organization.id, - num_samples=100, - sample_rate=0.5, - query="environment:prod", - ) - - end2 = timezone.now() + timedelta(hours=1) - second_rule = CustomDynamicSamplingRule.update_or_create( - condition=condition, - start=timezone.now() + timedelta(minutes=1), - end=end2, - project_ids=[self.second_project.id], - organization_id=self.organization.id, - num_samples=100, - sample_rate=0.5, - query="environment:prod", - ) - - assert rule.id != second_rule.id - - first_projects = rule.projects.all() - assert len(first_projects) == 1 - assert self.project == first_projects[0] - - second_projects = second_rule.projects.all() - assert len(second_projects) == 1 - assert self.second_project == second_projects[0] - - def test_deactivate_expired_rules(self) -> None: - """ - Tests that expired, and only expired, rules are deactivated - """ - - def create_rule( - env_idx: int, end: datetime, project_ids: list[int] - ) -> CustomDynamicSamplingRule: - condition = {"op": "equals", "name": "environment", "value": f"prod{env_idx}"} - return CustomDynamicSamplingRule.update_or_create( - condition=condition, - start=timezone.now() - timedelta(hours=5), - end=end, - project_ids=project_ids, - organization_id=self.organization.id, - num_samples=100, - sample_rate=0.5, - query=f"environment:prod{env_idx}", - ) - - env_idx = 1 - expired_rules: set[int] = set() - active_rules: set[int] = set() - - for projects in [ - [self.project], - [self.second_project], - [self.third_project], - [self.project, self.second_project, self.third_project], - [], - ]: - # create some expired rules - project_ids = [p.id for p in projects] - rule = create_rule(env_idx, timezone.now() - timedelta(minutes=5), project_ids) - expired_rules.add(rule.id) - env_idx += 1 - - # create some active rules - rule = create_rule(env_idx, timezone.now() + timedelta(minutes=5), project_ids) - active_rules.add(rule.id) - env_idx += 1 - - # check that all rules are active before deactivation - for rule in CustomDynamicSamplingRule.objects.all(): - assert rule.is_active - - CustomDynamicSamplingRule.deactivate_expired_rules() - - # check that all expired rules are inactive and all active rules are still active - for rule in CustomDynamicSamplingRule.objects.all(): - if rule.id in expired_rules: - assert not rule.is_active - else: - assert rule.is_active - assert rule.id in active_rules - - def test_per_project_limit(self) -> None: - """ - Tests that it is not possible to create more than MAX_CUSTOM_RULES_PER_PROJECT - for a project - """ - - # a few org rules - num_org_rules = 10 - for idx in range(num_org_rules): - _create_rule_for_env(idx, [], self.organization) - - # now add project rules (up to MAX_CUSTOM_RULES_PER_PROJECT) - for idx in range(num_org_rules, MAX_CUSTOM_RULES_PER_PROJECT): - _create_rule_for_env(idx, [self.project], self.organization) - _create_rule_for_env(idx, [self.second_project], self.organization) - - # we've reached the limit for both project and second_project next one should raise TooManyRules() - with pytest.raises(TooManyRules): - _create_rule_for_env(MAX_CUSTOM_RULES_PER_PROJECT, [self.project], self.organization) - - with pytest.raises(TooManyRules): - _create_rule_for_env( - MAX_CUSTOM_RULES_PER_PROJECT, [self.second_project], self.organization - ) diff --git a/tests/sentry/tasks/snapshots/PreprocessingTransferTest/test_success.pysnap b/tests/sentry/tasks/snapshots/PreprocessingTransferTest/test_success.pysnap index 84c6bfda4e2c21..bb542cfeee47e3 100644 --- a/tests/sentry/tasks/snapshots/PreprocessingTransferTest/test_success.pysnap +++ b/tests/sentry/tasks/snapshots/PreprocessingTransferTest/test_success.pysnap @@ -96,7 +96,7 @@ steps: - -U - postgres - -c - - TRUNCATE sentry_controloption,sentry_integration,sentry_option,sentry_organization,sentry_organizationintegration,sentry_organizationoptions,sentry_projecttemplate,sentry_projecttemplateoption,sentry_relay,sentry_relayusage,sentry_repository,sentry_team,auth_user,sentry_userip,sentry_userpermission,sentry_userrole,sentry_userrole_users,workflow_engine_dataconditiongroup,workflow_engine_datasource,workflow_engine_datacondition,sentry_savedsearch,sentry_recentsearch,sentry_project,sentry_orgauthtoken,sentry_organizationmember,sentry_organizationaccessrequest,sentry_monitor,sentry_groupsearchview,sentry_environment,sentry_email,sentry_datasecrecywaiver,sentry_dashboardtombstone,sentry_dashboard,sentry_customdynamicsamplingrule,sentry_projectcounter,sentry_authprovider,sentry_authidentity,auth_authenticator,sentry_apikey,sentry_apiapplication,workflow_engine_workflow,workflow_engine_detector,workflow_engine_datasourcedetector,sentry_useroption,sentry_useremail,sentry_snubaquery,sentry_sentryapp,sentry_rule,sentry_querysubscription,sentry_projectteam,sentry_projectredirect,sentry_projectownership,sentry_projectoptions,sentry_projectkey,sentry_projectintegration,sentry_projectbookmark,sentry_organizationmember_teams,sentry_notificationaction,sentry_neglectedrule,sentry_environmentproject,sentry_dashboardwidget,sentry_dashboardpermissions,sentry_dashboardfavoriteuser,sentry_customdynamicsamplingruleproject,sentry_apitoken,sentry_apigrant,sentry_apiauthorization,sentry_alertrule,workflow_engine_workflowdataconditiongroup,workflow_engine_detectorworkflow,workflow_engine_alertruleworkflow,workflow_engine_alertruledetector,sentry_snubaqueryeventtype,sentry_sentryappinstallation,sentry_sentryappcomponent,sentry_rulesnooze,sentry_ruleactivity,sentry_notificationactionproject,sentry_dashboardwidgetquery,sentry_dashboardpermissionsteam,sentry_alertruletrigger,sentry_alertruleprojects,sentry_alertruleactivity,sentry_alertruleactivationcondition,workflow_engine_alertruletriggerdatacondition,sentry_servicehook,sentry_incident,sentry_dashboardwidgetqueryondemand,sentry_alertruletriggeraction,sentry_timeseriessnapshot,sentry_pendingincidentsnapshot,sentry_incidenttrigger,sentry_incidentsnapshot,sentry_incidentactivity + - TRUNCATE sentry_controloption,sentry_integration,sentry_option,sentry_organization,sentry_organizationintegration,sentry_organizationoptions,sentry_projecttemplate,sentry_projecttemplateoption,sentry_relay,sentry_relayusage,sentry_repository,sentry_team,auth_user,sentry_userip,sentry_userpermission,sentry_userrole,sentry_userrole_users,workflow_engine_dataconditiongroup,workflow_engine_datasource,workflow_engine_datacondition,sentry_savedsearch,sentry_recentsearch,sentry_project,sentry_orgauthtoken,sentry_organizationmember,sentry_organizationaccessrequest,sentry_monitor,sentry_groupsearchview,sentry_environment,sentry_email,sentry_datasecrecywaiver,sentry_dashboardtombstone,sentry_dashboard,sentry_projectcounter,sentry_authprovider,sentry_authidentity,auth_authenticator,sentry_apikey,sentry_apiapplication,workflow_engine_workflow,workflow_engine_detector,workflow_engine_datasourcedetector,sentry_useroption,sentry_useremail,sentry_snubaquery,sentry_sentryapp,sentry_rule,sentry_querysubscription,sentry_projectteam,sentry_projectredirect,sentry_projectownership,sentry_projectoptions,sentry_projectkey,sentry_projectintegration,sentry_projectbookmark,sentry_organizationmember_teams,sentry_notificationaction,sentry_neglectedrule,sentry_environmentproject,sentry_dashboardwidget,sentry_dashboardpermissions,sentry_dashboardfavoriteuser,sentry_apitoken,sentry_apigrant,sentry_apiauthorization,sentry_alertrule,workflow_engine_workflowdataconditiongroup,workflow_engine_detectorworkflow,workflow_engine_alertruleworkflow,workflow_engine_alertruledetector,sentry_snubaqueryeventtype,sentry_sentryappinstallation,sentry_sentryappcomponent,sentry_rulesnooze,sentry_ruleactivity,sentry_notificationactionproject,sentry_dashboardwidgetquery,sentry_dashboardpermissionsteam,sentry_alertruletrigger,sentry_alertruleprojects,sentry_alertruleactivity,sentry_alertruleactivationcondition,workflow_engine_alertruletriggerdatacondition,sentry_servicehook,sentry_incident,sentry_dashboardwidgetqueryondemand,sentry_alertruletriggeraction,sentry_timeseriessnapshot,sentry_pendingincidentsnapshot,sentry_incidenttrigger,sentry_incidentsnapshot,sentry_incidentactivity RESTART IDENTITY CASCADE; id: clear-database name: gcr.io/cloud-builders/docker diff --git a/tests/sentry/users/models/test_user.py b/tests/sentry/users/models/test_user.py index a34ab72712ae1b..4ad79b521c347e 100644 --- a/tests/sentry/users/models/test_user.py +++ b/tests/sentry/users/models/test_user.py @@ -13,7 +13,6 @@ from sentry.models.activity import Activity from sentry.models.authidentity import AuthIdentity from sentry.models.dashboard import Dashboard, DashboardFavoriteUser -from sentry.models.dynamicsampling import CustomDynamicSamplingRule from sentry.models.groupassignee import GroupAssignee from sentry.models.groupbookmark import GroupBookmark from sentry.models.groupsearchview import GroupSearchView @@ -383,7 +382,6 @@ def test_duplicate_memberships(self, expected_models: list[type[Model]]) -> None Activity, AlertRule, AlertRuleActivity, - CustomDynamicSamplingRule, Dashboard, DashboardFavoriteUser, GroupAssignee, @@ -427,7 +425,6 @@ def test_only_source_user_is_member_of_organization( Activity, AlertRule, AlertRuleActivity, - CustomDynamicSamplingRule, Dashboard, DashboardFavoriteUser, GroupAssignee, From 005905e03c5f59571388a00e02338f4032744ccb Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 31 Mar 2026 11:59:21 +0000 Subject: [PATCH 03/21] Revert "ref(dynamic-sampling): remove all usage of custom ds rules in code (#110979)" This reverts commit 44531beaa017e6eaea9a5d8259fa6c1ac53a12e9. Co-authored-by: shellmayr <6788060+shellmayr@users.noreply.github.com> --- .github/codeowners-coverage-baseline.txt | 1 + src/sentry/dynamic_sampling/__init__.py | 2 + src/sentry/dynamic_sampling/rules/utils.py | 21 +- src/sentry/models/dynamicsampling.py | 296 +++++++++++++- .../services/organization/impl.py | 2 + src/sentry/testutils/helpers/backups.py | 29 +- tests/sentry/models/test_dynamicsampling.py | 363 ++++++++++++++++++ .../test_success.pysnap | 2 +- tests/sentry/users/models/test_user.py | 3 + 9 files changed, 697 insertions(+), 22 deletions(-) create mode 100644 tests/sentry/models/test_dynamicsampling.py diff --git a/.github/codeowners-coverage-baseline.txt b/.github/codeowners-coverage-baseline.txt index 288c082025f469..c7b63d380a879d 100644 --- a/.github/codeowners-coverage-baseline.txt +++ b/.github/codeowners-coverage-baseline.txt @@ -2144,6 +2144,7 @@ tests/sentry/models/test_commitfilechange.py tests/sentry/models/test_dashboard.py tests/sentry/models/test_debugfile.py tests/sentry/models/test_deploy.py +tests/sentry/models/test_dynamicsampling.py tests/sentry/models/test_environment.py tests/sentry/models/test_eventattachment.py tests/sentry/models/test_eventerror.py diff --git a/src/sentry/dynamic_sampling/__init__.py b/src/sentry/dynamic_sampling/__init__.py index 6431f4973034e8..e43f7e7c529f25 100644 --- a/src/sentry/dynamic_sampling/__init__.py +++ b/src/sentry/dynamic_sampling/__init__.py @@ -14,6 +14,7 @@ RuleType, get_enabled_user_biases, get_redis_client_for_ds, + get_rule_hash, get_supported_biases_ids, get_user_biases, ) @@ -24,6 +25,7 @@ "get_user_biases", "get_enabled_user_biases", "get_redis_client_for_ds", + "get_rule_hash", "record_latest_release", "RuleType", "ExtendedBoostedRelease", diff --git a/src/sentry/dynamic_sampling/rules/utils.py b/src/sentry/dynamic_sampling/rules/utils.py index 8329911463ae7c..d8c51a96c636be 100644 --- a/src/sentry/dynamic_sampling/rules/utils.py +++ b/src/sentry/dynamic_sampling/rules/utils.py @@ -3,14 +3,14 @@ from enum import Enum from typing import Literal, NotRequired, TypedDict, Union +import orjson from django.conf import settings from redis import StrictRedis +from sentry.models.dynamicsampling import CUSTOM_RULE_START from sentry.relay.types import RuleCondition from sentry.utils import redis -CUSTOM_RULE_START = 3000 - BOOSTED_RELEASES_LIMIT = 10 LATEST_RELEASES_BOOST_FACTOR = 1.5 @@ -117,6 +117,23 @@ class DecayingRule(Rule): PolymorphicRule = Union[Rule, DecayingRule] +def get_rule_hash(rule: PolymorphicRule) -> int: + # We want to be explicit in what we use for computing the hash. In addition, we need to remove certain fields like + # the sampleRate. + return ( + orjson.dumps( + { + "id": rule["id"], + "type": rule["type"], + "condition": rule["condition"], + }, + option=orjson.OPT_SORT_KEYS, + ) + .decode() + .__hash__() + ) + + def get_user_biases(user_set_biases: list[ActivatableBias] | None) -> list[ActivatableBias]: if user_set_biases is None: return DEFAULT_BIASES diff --git a/src/sentry/models/dynamicsampling.py b/src/sentry/models/dynamicsampling.py index 9ed171a1bc2524..c4e48774fd0398 100644 --- a/src/sentry/models/dynamicsampling.py +++ b/src/sentry/models/dynamicsampling.py @@ -1,12 +1,67 @@ from __future__ import annotations -from django.db import models -from django.db.models import Q +import hashlib +from collections.abc import Mapping, Sequence +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any + +from django.db import models, router, transaction +from django.db.models import F, IntegerField, Max, Q, Subquery, Value +from django.db.models.functions import Coalesce from django.utils import timezone from sentry.backup.scopes import RelocationScope +from sentry.constants import ObjectStatus from sentry.db.models import FlexibleForeignKey, Model, cell_silo_model from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey +from sentry.utils import json, metrics + +if TYPE_CHECKING: + from sentry.models.organization import Organization + from sentry.models.project import Project + +# max number of custom rules that can be created per organization +MAX_CUSTOM_RULES = 2000 +CUSTOM_RULE_START = 3000 +MAX_CUSTOM_RULES_PER_PROJECT = 50 +CUSTOM_RULE_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" + + +class TooManyRules(ValueError): + """ + Raised when a there is already the max number of rules active for an organization + """ + + +def get_rule_hash(condition: Any, project_ids: Sequence[int]) -> str: + """ + Returns the hash of the rule based on the condition and projects + """ + condition_string = to_order_independent_string(condition) + project_string = to_order_independent_string(list(project_ids)) + rule_string = f"{condition_string}-{project_string}" + # make it a bit shorter + return hashlib.sha1(rule_string.encode("utf-8")).hexdigest() + + +def to_order_independent_string(val: Any) -> str: + """ + Converts a value in an order independent string and then hashes it + + Note: this will insure the same repr is generated for ['x', 'y'] and ['y', 'x'] + Also the same repr is generated for {'x': 1, 'y': 2} and {'y': 2, 'x': 1} + """ + ret_val = "" + if isinstance(val, Mapping): + for key in sorted(val.keys()): + ret_val += f"{key}:{to_order_independent_string(val[key])}-" + elif isinstance(val, (list, tuple)): + vals = sorted([to_order_independent_string(item) for item in val]) + for item in vals: + ret_val += f"{item}-" + else: + ret_val = str(val) + return ret_val @cell_silo_model @@ -59,6 +114,16 @@ class CustomDynamicSamplingRule(Model): created_by_id = HybridCloudForeignKey("sentry.User", on_delete="CASCADE", null=True, blank=True) notification_sent = models.BooleanField(null=True, blank=True) + @property + def external_rule_id(self) -> int: + """ + Returns the external rule id + + For external users, i.e. Relay, we need to shift the ids since the slot we + have allocated starts at the offset specified in RESERVED_IDS. + """ + return self.rule_id + CUSTOM_RULE_START + class Meta: app_label = "sentry" db_table = "sentry_customdynamicsamplingrule" @@ -72,3 +137,230 @@ class Meta: fields=["condition_hash"], name="condition_hash_idx", condition=Q(is_active=True) ), ] + + @staticmethod + def get_rule_for_org( + condition: Any, + organization_id: int, + project_ids: Sequence[int], + ) -> CustomDynamicSamplingRule | None: + """ + Returns an active rule for the given condition and organization if it exists otherwise None + + Note: There should not be more than one active rule for a given condition and organization + This function doesn't verify this condition, it just returns the first one. + """ + rule_hash = get_rule_hash(condition, project_ids) + rules = CustomDynamicSamplingRule.objects.filter( + organization_id=organization_id, + condition_hash=rule_hash, + is_active=True, + end_date__gt=timezone.now(), + )[:1] + + return rules[0] if rules else None + + @staticmethod + def update_or_create( + condition: Any, + start: datetime, + end: datetime, + project_ids: Sequence[int], + organization_id: int, + num_samples: int, + sample_rate: float, + query: str, + created_by_id: int | None = None, + ) -> CustomDynamicSamplingRule: + from sentry.models.organization import Organization + from sentry.models.project import Project + + with transaction.atomic(router.db_for_write(CustomDynamicSamplingRule)): + # check if rule already exists for this organization + existing_rule = CustomDynamicSamplingRule.get_rule_for_org( + condition, organization_id, project_ids + ) + + if existing_rule is not None: + # we already have an active rule for this condition and this organization + # update the expiration date and ensure that our projects are included + existing_rule.end_date = max(end, existing_rule.end_date) + existing_rule.num_samples = max(num_samples, existing_rule.num_samples) + existing_rule.sample_rate = max(sample_rate, existing_rule.sample_rate) + + # for org rules we don't need to do anything with the projects + existing_rule.save() + return existing_rule + else: + projects = Project.objects.get_many_from_cache(project_ids) + projects = list(projects) + organization = Organization.objects.get_from_cache(id=organization_id) + + if CustomDynamicSamplingRule.per_project_limit_reached(projects, organization): + raise TooManyRules() + + # create a new rule + rule_hash = get_rule_hash(condition, project_ids) + is_org_level = len(project_ids) == 0 + condition_str = json.dumps(condition) + rule = CustomDynamicSamplingRule.objects.create( + organization_id=organization_id, + condition=condition_str, + sample_rate=sample_rate, + start_date=start, + end_date=end, + num_samples=num_samples, + condition_hash=rule_hash, + is_active=True, + is_org_level=is_org_level, + query=query, + notification_sent=False, + created_by_id=created_by_id, + ) + + rule.save() + # now try to assign a rule id + id = rule.assign_rule_id() + if id > MAX_CUSTOM_RULES: + # we have too many rules, delete this one + rule.delete() + raise TooManyRules() + + # set the projects if not org level + for project in projects: + CustomDynamicSamplingRuleProject.objects.create( + custom_dynamic_sampling_rule=rule, project=project + ) + return rule + + def assign_rule_id(self) -> int: + """ + Assigns the smallest rule id that is not taken in the + current organization. + """ + if self.id is None: + raise ValueError("Cannot assign rule id to unsaved object") + if self.rule_id != 0: + raise ValueError("Cannot assign rule id to object that already has a rule id") + + now = timezone.now() + + base_qs = CustomDynamicSamplingRule.objects.filter( + organization_id=self.organization.id, end_date__gt=now, is_active=True + ) + + # We want to find the smallest free rule id. We do this by self-joining with rule_id + 1 and excluding the existing rule_ids. + # We then order by rule_id_plus_one and take the first value. + # This also works for the first rule, as it is pre-initialized with 0, and will thus end up with 1. + new_rule_id_subquery = Subquery( + base_qs.annotate(rule_id_plus_one=F("rule_id") + 1) + .exclude(rule_id_plus_one__in=base_qs.values_list("rule_id", flat=True)) + .order_by("rule_id_plus_one") + .values("rule_id_plus_one")[:1] + ) + + max_rule_id = base_qs.aggregate(Max("rule_id"))["rule_id__max"] or 0 + fallback_value = Value(max_rule_id + 1, output_field=IntegerField()) + + safe_new_rule_id = Coalesce(new_rule_id_subquery, fallback_value) + + # Update this instance with the new rule_id + CustomDynamicSamplingRule.objects.filter(id=self.id).update(rule_id=safe_new_rule_id) + self.refresh_from_db() + return self.rule_id + + @staticmethod + def deactivate_old_rules() -> None: + """ + Deactivates all rules expired rules (this is just an optimization to remove old rules from indexes). + + This should be called periodically to clean up old rules (it is not necessary to call it for correctness, + just for performance) + """ + CustomDynamicSamplingRule.objects.filter( + # give it a minute grace period to make sure we don't deactivate rules that are still active + end_date__lt=timezone.now() - timedelta(minutes=1), + ).update(is_active=False) + + @staticmethod + def get_project_rules( + project: Project, + ) -> Sequence[CustomDynamicSamplingRule]: + """ + Returns all active project rules + """ + now = timezone.now() + # org rules ( apply to all projects in the org) + org_rules = CustomDynamicSamplingRule.objects.filter( + is_active=True, + is_org_level=True, + organization=project.organization, + end_date__gt=now, + start_date__lt=now, + )[: MAX_CUSTOM_RULES_PER_PROJECT + 1] + + # project rules + project_rules = CustomDynamicSamplingRule.objects.filter( + is_active=True, + projects__in=[project], + end_date__gt=now, + start_date__lt=now, + )[: MAX_CUSTOM_RULES_PER_PROJECT + 1] + + rules = list(project_rules.union(org_rules)[: MAX_CUSTOM_RULES_PER_PROJECT + 1]) + + if len(rules) > MAX_CUSTOM_RULES_PER_PROJECT: + metrics.incr("dynamic_sampling.custom_rules.overflow") + + return rules[:MAX_CUSTOM_RULES_PER_PROJECT] + + @staticmethod + def deactivate_expired_rules() -> None: + """ + Deactivates all rules that have expired + """ + CustomDynamicSamplingRule.objects.filter( + end_date__lt=timezone.now(), is_active=True + ).update(is_active=False) + + @staticmethod + def num_active_rules_for_project(project: Project) -> int: + """ + Returns the number of active rules for the given project + """ + now = timezone.now() + + num_org_rules = CustomDynamicSamplingRule.objects.filter( + is_active=True, + is_org_level=True, + organization=project.organization, + end_date__gt=now, + start_date__lte=now, + ).count() + + num_proj_rules = CustomDynamicSamplingRule.objects.filter( + is_active=True, + is_org_level=False, + projects__in=[project], + end_date__gt=now, + start_date__lte=now, + ).count() + + return num_proj_rules + num_org_rules + + @staticmethod + def per_project_limit_reached(projects: Sequence[Project], organization: Organization) -> bool: + """ + Returns True if the rule limit is reached for any of the given projects (or all + the projects in the organization if org level rule) + """ + projects = list(projects) + if len(projects) == 0: + # an org rule check all the org projects + org_projects = organization.project_set.filter(status=ObjectStatus.ACTIVE) + projects = list(org_projects) + for project in projects: + num_rules = CustomDynamicSamplingRule.num_active_rules_for_project(project) + if num_rules >= MAX_CUSTOM_RULES_PER_PROJECT: + return True + return False diff --git a/src/sentry/organizations/services/organization/impl.py b/src/sentry/organizations/services/organization/impl.py index 5b7c8db37cf3fd..2cdb2a01a3edc6 100644 --- a/src/sentry/organizations/services/organization/impl.py +++ b/src/sentry/organizations/services/organization/impl.py @@ -20,6 +20,7 @@ from sentry.incidents.models.incident import IncidentActivity from sentry.models.activity import Activity from sentry.models.dashboard import Dashboard, DashboardFavoriteUser +from sentry.models.dynamicsampling import CustomDynamicSamplingRule from sentry.models.groupassignee import GroupAssignee from sentry.models.groupbookmark import GroupBookmark from sentry.models.groupsearchview import GroupSearchView @@ -580,6 +581,7 @@ def merge_users(self, *, organization_id: int, from_user_id: int, to_user_id: in Activity, AlertRule, AlertRuleActivity, + CustomDynamicSamplingRule, Dashboard, DashboardFavoriteUser, GroupAssignee, diff --git a/src/sentry/testutils/helpers/backups.py b/src/sentry/testutils/helpers/backups.py index f99e6fe1e45cb0..51c6cadabfdd8b 100644 --- a/src/sentry/testutils/helpers/backups.py +++ b/src/sentry/testutils/helpers/backups.py @@ -77,10 +77,7 @@ DashboardWidgetQueryOnDemand, DashboardWidgetTypes, ) -from sentry.models.dynamicsampling import ( - CustomDynamicSamplingRule, - CustomDynamicSamplingRuleProject, -) +from sentry.models.dynamicsampling import CustomDynamicSamplingRule from sentry.models.groupassignee import GroupAssignee from sentry.models.groupbookmark import GroupBookmark from sentry.models.groupsearchview import GroupSearchView, GroupSearchViewProject @@ -523,6 +520,17 @@ def create_exhaustive_organization( sent_initial_email_date=timezone.now(), sent_final_email_date=timezone.now(), ) + CustomDynamicSamplingRule.update_or_create( + created_by_id=owner_id, + condition={"op": "equals", "name": "environment", "value": "prod"}, + start=timezone.now(), + end=timezone.now() + timedelta(hours=1), + project_ids=[project.id], + organization_id=org.id, + num_samples=100, + sample_rate=0.5, + query="environment:prod event.type:transaction", + ) # Environment* self.create_environment(project=project) @@ -819,19 +827,6 @@ def create_exhaustive_organization( overrides={"write_key": "test_override_write_key"}, ) - custom_rule = CustomDynamicSamplingRule.objects.create( - organization=org, - condition='{"op":"and","inner":[]}', - end_date=timezone.now() + timedelta(days=1), - num_samples=100, - condition_hash="abc123def456abc123def456abc123def4560000", - sample_rate=0.5, - ) - CustomDynamicSamplingRuleProject.objects.create( - custom_dynamic_sampling_rule=custom_rule, - project=project, - ) - return org @assume_test_silo_mode(SiloMode.CONTROL) diff --git a/tests/sentry/models/test_dynamicsampling.py b/tests/sentry/models/test_dynamicsampling.py new file mode 100644 index 00000000000000..52bbd5737c4a8f --- /dev/null +++ b/tests/sentry/models/test_dynamicsampling.py @@ -0,0 +1,363 @@ +from datetime import datetime, timedelta + +import pytest +from django.utils import timezone + +from sentry.models.dynamicsampling import ( + MAX_CUSTOM_RULES_PER_PROJECT, + CustomDynamicSamplingRule, + TooManyRules, +) +from sentry.models.organization import Organization +from sentry.models.project import Project +from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.datetime import freeze_time + + +def _create_rule_for_env( + env_idx: int, projects: list[Project], organization: Organization +) -> CustomDynamicSamplingRule: + condition = {"op": "equals", "name": "environment", "value": f"prod{env_idx}"} + return CustomDynamicSamplingRule.update_or_create( + condition=condition, + start=timezone.now(), + end=timezone.now() + timedelta(hours=1), + project_ids=[project.id for project in projects], + organization_id=organization.id, + num_samples=100, + sample_rate=0.5, + query=f"environment:prod{env_idx}", + ) + + +@freeze_time("2023-09-18") +class TestCustomDynamicSamplingRuleProject(TestCase): + def setUp(self) -> None: + super().setUp() + self.second_project = self.create_project() + self.second_organization = self.create_organization(owner=self.user) + self.third_project = self.create_project(organization=self.second_organization) + + def test_update_or_create(self) -> None: + condition = {"op": "equals", "name": "environment", "value": "prod"} + + end1 = timezone.now() + timedelta(hours=1) + + rule = CustomDynamicSamplingRule.update_or_create( + condition=condition, + start=timezone.now(), + end=end1, + project_ids=[self.project.id], + organization_id=self.organization.id, + num_samples=100, + sample_rate=0.5, + query="environment:prod", + ) + + end2 = timezone.now() + timedelta(hours=1) + updated_rule = CustomDynamicSamplingRule.update_or_create( + condition=condition, + start=timezone.now() + timedelta(minutes=1), + end=end2, + project_ids=[self.project.id], + organization_id=self.organization.id, + num_samples=100, + sample_rate=0.5, + query="environment:prod", + ) + + assert rule.id == updated_rule.id + projects = updated_rule.projects.all() + + assert len(projects) == 1 + assert self.project in projects + + assert updated_rule.end_date >= end1 + assert updated_rule.end_date >= end2 + + def test_assign_rule_id(self) -> None: + rule_ids = set() + rules = [] + for idx in range(3): + rule = _create_rule_for_env(idx, [self.project], self.organization) + rule_ids.add(rule.rule_id) + rules.append(rule) + + # all 3 rules have different rule ids + assert len(rule_ids) == 3 + + # make a rule obsolete and check that the rule id is reused + rules[1].is_active = False + rules[1].save() + + new_rule = _create_rule_for_env(4, [self.project], self.organization) + assert new_rule.rule_id == rules[1].rule_id + + # a new rule will take another slot (now that there is no free slot) + new_rule_2 = _create_rule_for_env(5, [self.project], self.organization) + assert new_rule_2.rule_id not in rule_ids + + # make again an empty slot ( this time by having the rule expire) + rules[2].start_date = timezone.now() - timedelta(hours=2) + rules[2].end_date = timezone.now() - timedelta(hours=1) + rules[2].save() + + # the new rule should take the empty slot + new_rule_3 = _create_rule_for_env(6, [self.project], self.organization) + assert new_rule_3.rule_id == rules[2].rule_id + + def test_deactivate_old_rules(self) -> None: + idx = 1 + + old_rules = [] + new_rules = [] + + def create_rule(is_old: bool, idx: int) -> CustomDynamicSamplingRule: + condition = {"op": "equals", "name": "environment", "value": f"prod{idx}"} + if is_old: + end_delta = -timedelta(hours=1) + else: + end_delta = timedelta(hours=1) + return CustomDynamicSamplingRule.update_or_create( + condition=condition, + start=timezone.now() - timedelta(hours=2), + end=timezone.now() + end_delta, + project_ids=[self.project.id], + organization_id=self.organization.id, + num_samples=100, + sample_rate=0.5, + query=f"environment:prod{idx}", + ) + + for i in range(10): + for is_old in [True, False]: + idx += 1 + rule = create_rule(is_old, idx) + if is_old: + old_rules.append(rule) + else: + new_rules.append(rule) + + CustomDynamicSamplingRule.deactivate_old_rules() + + # check that all old rules are inactive and all new rules are active + inactive_rules = list(CustomDynamicSamplingRule.objects.filter(is_active=False)) + assert len(inactive_rules) == 10 + for rule in old_rules: + assert rule in inactive_rules + + active_rules = list(CustomDynamicSamplingRule.objects.filter(is_active=True)) + assert len(active_rules) == 10 + for rule in new_rules: + assert rule in active_rules + + def test_get_rule_for_org(self) -> None: + """ + Test the get_rule_for_org method + """ + condition = {"op": "equals", "name": "environment", "value": "prod"} + + # check empty result + rule = CustomDynamicSamplingRule.get_rule_for_org( + condition, self.organization.id, [self.project.id] + ) + assert rule is None + + new_rule = CustomDynamicSamplingRule.update_or_create( + condition=condition, + start=timezone.now() - timedelta(hours=2), + end=timezone.now() + timedelta(hours=1), + project_ids=[self.project.id], + organization_id=self.organization.id, + num_samples=100, + sample_rate=0.5, + query="environment:prod", + ) + + rule = CustomDynamicSamplingRule.get_rule_for_org( + condition, self.organization.id, [self.project.id] + ) + assert rule == new_rule + + def test_get_project_rules(self) -> None: + """ + Tests that all valid rules (i.e. active and within the date range) that apply to a project + (i.e. that are either organization rules or apply to the project) are returned. + """ + + idx = [1] + + def create_rule( + project_ids: list[int], + org_id: int | None = None, + old: bool = False, + new: bool = False, + ) -> CustomDynamicSamplingRule: + idx[0] += 1 + condition = {"op": "equals", "name": "environment", "value": f"prod{idx[0]}"} + if old: + end_delta = -timedelta(hours=2) + else: + end_delta = timedelta(hours=2) + + if new: + start_delta = timedelta(hours=1) + else: + start_delta = -timedelta(hours=1) + + if org_id is None: + org_id = self.organization.id + + return CustomDynamicSamplingRule.update_or_create( + condition=condition, + start=timezone.now() + start_delta, + end=timezone.now() + end_delta, + project_ids=project_ids, + organization_id=org_id, + num_samples=100, + sample_rate=0.5, + query=f"environment:prod{idx[0]}", + ) + + valid_project_rule = create_rule([self.project.id, self.second_project.id]) + valid_org_rule = create_rule([]) + # rule for another project + create_rule([self.second_project.id]) + # rule for another org + create_rule([self.third_project.id], org_id=self.second_organization.id) + # old project rule ( already expired) + create_rule([self.project.id], old=True) + # new project rule ( not yet active) + create_rule([self.project.id], new=True) + # old org rule + create_rule([], old=True) + # new org rule + create_rule([], new=True) + + # we should only get valid_project_rule and valid_org_rule + rules = list(CustomDynamicSamplingRule.get_project_rules(self.project)) + assert len(rules) == 2 + assert valid_project_rule in rules + assert valid_org_rule in rules + + def test_separate_projects_create_different_rules(self) -> None: + """ + Tests that same condition for different projects create different rules + """ + condition = {"op": "equals", "name": "environment", "value": "prod"} + + end1 = timezone.now() + timedelta(hours=1) + + rule = CustomDynamicSamplingRule.update_or_create( + condition=condition, + start=timezone.now(), + end=end1, + project_ids=[self.project.id], + organization_id=self.organization.id, + num_samples=100, + sample_rate=0.5, + query="environment:prod", + ) + + end2 = timezone.now() + timedelta(hours=1) + second_rule = CustomDynamicSamplingRule.update_or_create( + condition=condition, + start=timezone.now() + timedelta(minutes=1), + end=end2, + project_ids=[self.second_project.id], + organization_id=self.organization.id, + num_samples=100, + sample_rate=0.5, + query="environment:prod", + ) + + assert rule.id != second_rule.id + + first_projects = rule.projects.all() + assert len(first_projects) == 1 + assert self.project == first_projects[0] + + second_projects = second_rule.projects.all() + assert len(second_projects) == 1 + assert self.second_project == second_projects[0] + + def test_deactivate_expired_rules(self) -> None: + """ + Tests that expired, and only expired, rules are deactivated + """ + + def create_rule( + env_idx: int, end: datetime, project_ids: list[int] + ) -> CustomDynamicSamplingRule: + condition = {"op": "equals", "name": "environment", "value": f"prod{env_idx}"} + return CustomDynamicSamplingRule.update_or_create( + condition=condition, + start=timezone.now() - timedelta(hours=5), + end=end, + project_ids=project_ids, + organization_id=self.organization.id, + num_samples=100, + sample_rate=0.5, + query=f"environment:prod{env_idx}", + ) + + env_idx = 1 + expired_rules: set[int] = set() + active_rules: set[int] = set() + + for projects in [ + [self.project], + [self.second_project], + [self.third_project], + [self.project, self.second_project, self.third_project], + [], + ]: + # create some expired rules + project_ids = [p.id for p in projects] + rule = create_rule(env_idx, timezone.now() - timedelta(minutes=5), project_ids) + expired_rules.add(rule.id) + env_idx += 1 + + # create some active rules + rule = create_rule(env_idx, timezone.now() + timedelta(minutes=5), project_ids) + active_rules.add(rule.id) + env_idx += 1 + + # check that all rules are active before deactivation + for rule in CustomDynamicSamplingRule.objects.all(): + assert rule.is_active + + CustomDynamicSamplingRule.deactivate_expired_rules() + + # check that all expired rules are inactive and all active rules are still active + for rule in CustomDynamicSamplingRule.objects.all(): + if rule.id in expired_rules: + assert not rule.is_active + else: + assert rule.is_active + assert rule.id in active_rules + + def test_per_project_limit(self) -> None: + """ + Tests that it is not possible to create more than MAX_CUSTOM_RULES_PER_PROJECT + for a project + """ + + # a few org rules + num_org_rules = 10 + for idx in range(num_org_rules): + _create_rule_for_env(idx, [], self.organization) + + # now add project rules (up to MAX_CUSTOM_RULES_PER_PROJECT) + for idx in range(num_org_rules, MAX_CUSTOM_RULES_PER_PROJECT): + _create_rule_for_env(idx, [self.project], self.organization) + _create_rule_for_env(idx, [self.second_project], self.organization) + + # we've reached the limit for both project and second_project next one should raise TooManyRules() + with pytest.raises(TooManyRules): + _create_rule_for_env(MAX_CUSTOM_RULES_PER_PROJECT, [self.project], self.organization) + + with pytest.raises(TooManyRules): + _create_rule_for_env( + MAX_CUSTOM_RULES_PER_PROJECT, [self.second_project], self.organization + ) diff --git a/tests/sentry/tasks/snapshots/PreprocessingTransferTest/test_success.pysnap b/tests/sentry/tasks/snapshots/PreprocessingTransferTest/test_success.pysnap index bb542cfeee47e3..84c6bfda4e2c21 100644 --- a/tests/sentry/tasks/snapshots/PreprocessingTransferTest/test_success.pysnap +++ b/tests/sentry/tasks/snapshots/PreprocessingTransferTest/test_success.pysnap @@ -96,7 +96,7 @@ steps: - -U - postgres - -c - - TRUNCATE sentry_controloption,sentry_integration,sentry_option,sentry_organization,sentry_organizationintegration,sentry_organizationoptions,sentry_projecttemplate,sentry_projecttemplateoption,sentry_relay,sentry_relayusage,sentry_repository,sentry_team,auth_user,sentry_userip,sentry_userpermission,sentry_userrole,sentry_userrole_users,workflow_engine_dataconditiongroup,workflow_engine_datasource,workflow_engine_datacondition,sentry_savedsearch,sentry_recentsearch,sentry_project,sentry_orgauthtoken,sentry_organizationmember,sentry_organizationaccessrequest,sentry_monitor,sentry_groupsearchview,sentry_environment,sentry_email,sentry_datasecrecywaiver,sentry_dashboardtombstone,sentry_dashboard,sentry_projectcounter,sentry_authprovider,sentry_authidentity,auth_authenticator,sentry_apikey,sentry_apiapplication,workflow_engine_workflow,workflow_engine_detector,workflow_engine_datasourcedetector,sentry_useroption,sentry_useremail,sentry_snubaquery,sentry_sentryapp,sentry_rule,sentry_querysubscription,sentry_projectteam,sentry_projectredirect,sentry_projectownership,sentry_projectoptions,sentry_projectkey,sentry_projectintegration,sentry_projectbookmark,sentry_organizationmember_teams,sentry_notificationaction,sentry_neglectedrule,sentry_environmentproject,sentry_dashboardwidget,sentry_dashboardpermissions,sentry_dashboardfavoriteuser,sentry_apitoken,sentry_apigrant,sentry_apiauthorization,sentry_alertrule,workflow_engine_workflowdataconditiongroup,workflow_engine_detectorworkflow,workflow_engine_alertruleworkflow,workflow_engine_alertruledetector,sentry_snubaqueryeventtype,sentry_sentryappinstallation,sentry_sentryappcomponent,sentry_rulesnooze,sentry_ruleactivity,sentry_notificationactionproject,sentry_dashboardwidgetquery,sentry_dashboardpermissionsteam,sentry_alertruletrigger,sentry_alertruleprojects,sentry_alertruleactivity,sentry_alertruleactivationcondition,workflow_engine_alertruletriggerdatacondition,sentry_servicehook,sentry_incident,sentry_dashboardwidgetqueryondemand,sentry_alertruletriggeraction,sentry_timeseriessnapshot,sentry_pendingincidentsnapshot,sentry_incidenttrigger,sentry_incidentsnapshot,sentry_incidentactivity + - TRUNCATE sentry_controloption,sentry_integration,sentry_option,sentry_organization,sentry_organizationintegration,sentry_organizationoptions,sentry_projecttemplate,sentry_projecttemplateoption,sentry_relay,sentry_relayusage,sentry_repository,sentry_team,auth_user,sentry_userip,sentry_userpermission,sentry_userrole,sentry_userrole_users,workflow_engine_dataconditiongroup,workflow_engine_datasource,workflow_engine_datacondition,sentry_savedsearch,sentry_recentsearch,sentry_project,sentry_orgauthtoken,sentry_organizationmember,sentry_organizationaccessrequest,sentry_monitor,sentry_groupsearchview,sentry_environment,sentry_email,sentry_datasecrecywaiver,sentry_dashboardtombstone,sentry_dashboard,sentry_customdynamicsamplingrule,sentry_projectcounter,sentry_authprovider,sentry_authidentity,auth_authenticator,sentry_apikey,sentry_apiapplication,workflow_engine_workflow,workflow_engine_detector,workflow_engine_datasourcedetector,sentry_useroption,sentry_useremail,sentry_snubaquery,sentry_sentryapp,sentry_rule,sentry_querysubscription,sentry_projectteam,sentry_projectredirect,sentry_projectownership,sentry_projectoptions,sentry_projectkey,sentry_projectintegration,sentry_projectbookmark,sentry_organizationmember_teams,sentry_notificationaction,sentry_neglectedrule,sentry_environmentproject,sentry_dashboardwidget,sentry_dashboardpermissions,sentry_dashboardfavoriteuser,sentry_customdynamicsamplingruleproject,sentry_apitoken,sentry_apigrant,sentry_apiauthorization,sentry_alertrule,workflow_engine_workflowdataconditiongroup,workflow_engine_detectorworkflow,workflow_engine_alertruleworkflow,workflow_engine_alertruledetector,sentry_snubaqueryeventtype,sentry_sentryappinstallation,sentry_sentryappcomponent,sentry_rulesnooze,sentry_ruleactivity,sentry_notificationactionproject,sentry_dashboardwidgetquery,sentry_dashboardpermissionsteam,sentry_alertruletrigger,sentry_alertruleprojects,sentry_alertruleactivity,sentry_alertruleactivationcondition,workflow_engine_alertruletriggerdatacondition,sentry_servicehook,sentry_incident,sentry_dashboardwidgetqueryondemand,sentry_alertruletriggeraction,sentry_timeseriessnapshot,sentry_pendingincidentsnapshot,sentry_incidenttrigger,sentry_incidentsnapshot,sentry_incidentactivity RESTART IDENTITY CASCADE; id: clear-database name: gcr.io/cloud-builders/docker diff --git a/tests/sentry/users/models/test_user.py b/tests/sentry/users/models/test_user.py index 4ad79b521c347e..a34ab72712ae1b 100644 --- a/tests/sentry/users/models/test_user.py +++ b/tests/sentry/users/models/test_user.py @@ -13,6 +13,7 @@ from sentry.models.activity import Activity from sentry.models.authidentity import AuthIdentity from sentry.models.dashboard import Dashboard, DashboardFavoriteUser +from sentry.models.dynamicsampling import CustomDynamicSamplingRule from sentry.models.groupassignee import GroupAssignee from sentry.models.groupbookmark import GroupBookmark from sentry.models.groupsearchview import GroupSearchView @@ -382,6 +383,7 @@ def test_duplicate_memberships(self, expected_models: list[type[Model]]) -> None Activity, AlertRule, AlertRuleActivity, + CustomDynamicSamplingRule, Dashboard, DashboardFavoriteUser, GroupAssignee, @@ -425,6 +427,7 @@ def test_only_source_user_is_member_of_organization( Activity, AlertRule, AlertRuleActivity, + CustomDynamicSamplingRule, Dashboard, DashboardFavoriteUser, GroupAssignee, From 18cd9af18136d843612c7f12252a1b461d941d3e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 31 Mar 2026 14:16:16 +0200 Subject: [PATCH 04/21] feat(code-mappings): Allow multiple source roots per stack root (#111704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Change unique constraint on `RepositoryProjectPathConfig` from `(project, stack_root)` to `(project, stack_root, source_root)` - Update single-mapping serializer validation to include `source_root` in duplicate check - Update bulk endpoint upsert key to match on `(stack_root, source_root)` instead of just `stack_root` This allows the same stack trace root (e.g. `io/sentry/opentelemetry`) to map to multiple source paths in the repo (e.g. `sentry-opentelemetry-core/src/...` and `sentry-opentelemetry-bootstrap/src/...`), which is needed for monorepos with shared package prefixes across modules. Stack trace resolution already handles this correctly — it iterates all mappings sorted by specificity and uses the first match that resolves to a real file. ## Test plan - [x] All 63 existing tests pass (bulk + single endpoints) - [x] Updated tests to reflect new constraint semantics - [ ] Deploy migration and verify with real monorepo upload 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- migrations_lockfile.txt | 2 +- .../endpoints/organization_code_mappings.py | 6 ++- .../organization_code_mappings_bulk.py | 6 +-- .../models/repository_project_path_config.py | 2 +- .../auto_source_code_config/code_mapping.py | 2 +- .../issues/auto_source_code_config/task.py | 2 +- ...8_change_code_mapping_unique_constraint.py | 49 +++++++++++++++++++ .../test_organization_derive_code_mappings.py | 9 +++- .../test_organization_code_mappings.py | 2 +- .../test_organization_code_mappings_bulk.py | 27 ++++++---- .../test_process_event.py | 22 +++++++-- 11 files changed, 102 insertions(+), 27 deletions(-) create mode 100644 src/sentry/migrations/1058_change_code_mapping_unique_constraint.py diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 778a82c4a0744d..f1d2336b29497c 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -31,7 +31,7 @@ replays: 0007_organizationmember_replay_access seer: 0005_delete_seerorganizationsettings -sentry: 1057_drop_legacy_alert_rule_tables +sentry: 1058_change_code_mapping_unique_constraint social_auth: 0003_social_auth_json_field diff --git a/src/sentry/integrations/api/endpoints/organization_code_mappings.py b/src/sentry/integrations/api/endpoints/organization_code_mappings.py index 3eea2dada5e284..9d747c5bb58029 100644 --- a/src/sentry/integrations/api/endpoints/organization_code_mappings.py +++ b/src/sentry/integrations/api/endpoints/organization_code_mappings.py @@ -70,13 +70,15 @@ def organization(self): def validate(self, attrs): query = RepositoryProjectPathConfig.objects.filter( - project_id=attrs.get("project_id"), stack_root=attrs.get("stack_root") + project_id=attrs.get("project_id"), + stack_root=attrs.get("stack_root"), + source_root=attrs.get("source_root"), ) if self.instance: query = query.exclude(id=self.instance.id) if query.exists(): raise serializers.ValidationError( - "Code path config already exists with this project and stack trace root" + "Code path config already exists with this project, stack trace root, and source root" ) return attrs diff --git a/src/sentry/integrations/api/endpoints/organization_code_mappings_bulk.py b/src/sentry/integrations/api/endpoints/organization_code_mappings_bulk.py index 6a56cbf750e2a9..25662a251d9666 100644 --- a/src/sentry/integrations/api/endpoints/organization_code_mappings_bulk.py +++ b/src/sentry/integrations/api/endpoints/organization_code_mappings_bulk.py @@ -256,11 +256,9 @@ def post(self, request: Request, organization: Organization) -> Response: config = RepositoryProjectPathConfig.objects.select_for_update().get( project=project, stack_root=mapping["stack_root"], + source_root=mapping["source_root"], ) - for key, value in { - **defaults, - "source_root": mapping["source_root"], - }.items(): + for key, value in defaults.items(): setattr(config, key, value) created = False except RepositoryProjectPathConfig.DoesNotExist: diff --git a/src/sentry/integrations/models/repository_project_path_config.py b/src/sentry/integrations/models/repository_project_path_config.py index 3dd1e22b12c75b..96c41074ac55ea 100644 --- a/src/sentry/integrations/models/repository_project_path_config.py +++ b/src/sentry/integrations/models/repository_project_path_config.py @@ -37,7 +37,7 @@ class RepositoryProjectPathConfig(DefaultFieldsModelExisting): class Meta: app_label = "sentry" db_table = "sentry_repositoryprojectpathconfig" - unique_together = (("project", "stack_root"),) + unique_together = (("project", "stack_root", "source_root"),) def __repr__(self) -> str: return ( diff --git a/src/sentry/issues/auto_source_code_config/code_mapping.py b/src/sentry/issues/auto_source_code_config/code_mapping.py index 5e5cbda07490e5..4918b88728bd5a 100644 --- a/src/sentry/issues/auto_source_code_config/code_mapping.py +++ b/src/sentry/issues/auto_source_code_config/code_mapping.py @@ -348,12 +348,12 @@ def create_code_mapping( new_code_mapping, _ = RepositoryProjectPathConfig.objects.update_or_create( project=project, stack_root=code_mapping.stacktrace_root, + source_root=code_mapping.source_path, defaults={ "repository": repository, "organization_id": organization.id, "integration_id": installation.model.id, "organization_integration_id": installation.org_integration.id, - "source_root": code_mapping.source_path, "default_branch": code_mapping.repo.branch, # This function is called from the UI, thus, we know that the code mapping is user generated "automatically_generated": False, diff --git a/src/sentry/issues/auto_source_code_config/task.py b/src/sentry/issues/auto_source_code_config/task.py index 27101962c21931..b8aaec44eabc89 100644 --- a/src/sentry/issues/auto_source_code_config/task.py +++ b/src/sentry/issues/auto_source_code_config/task.py @@ -238,12 +238,12 @@ def create_code_mapping( _, created = RepositoryProjectPathConfig.objects.get_or_create( project=project, stack_root=code_mapping.stacktrace_root, + source_root=code_mapping.source_path, defaults={ "repository": repository, "organization_integration_id": org_integration.id, "integration_id": org_integration.integration_id, "organization_id": org_integration.organization_id, - "source_root": code_mapping.source_path, "default_branch": code_mapping.repo.branch, "automatically_generated": True, }, diff --git a/src/sentry/migrations/1058_change_code_mapping_unique_constraint.py b/src/sentry/migrations/1058_change_code_mapping_unique_constraint.py new file mode 100644 index 00000000000000..09bcd9161edda6 --- /dev/null +++ b/src/sentry/migrations/1058_change_code_mapping_unique_constraint.py @@ -0,0 +1,49 @@ +# Generated by Django 5.2.12 on 2026-03-27 11:40 + +from django.db import migrations, models + +from sentry.new_migrations.migrations import CheckedMigration + + +class Migration(CheckedMigration): + # This flag is used to mark that a migration shouldn't be automatically run in production. + # This should only be used for operations where it's safe to run the migration after your + # code has deployed. So this should not be used for most operations that alter the schema + # of a table. + # Here are some things that make sense to mark as post deployment: + # - Large data migrations. Typically we want these to be run manually so that they can be + # monitored and not block the deploy for a long period of time while they run. + # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to + # run this outside deployments so that we don't block them. Note that while adding an index + # is a schema change, it's completely safe to run the operation after the code has deployed. + # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment + + is_post_deployment = False + + dependencies = [ + ("sentry", "1057_drop_legacy_alert_rule_tables"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[ + migrations.AddConstraint( + model_name="repositoryprojectpathconfig", + constraint=models.UniqueConstraint( + fields=["project", "stack_root", "source_root"], + name="sentry_repositoryproject_project_id_stack_root_so_c371dfa7_uniq", + ), + ), + migrations.AlterUniqueTogether( + name="repositoryprojectpathconfig", + unique_together=set(), + ), + ], + state_operations=[ + migrations.AlterUniqueTogether( + name="repositoryprojectpathconfig", + unique_together={("project", "stack_root", "source_root")}, + ), + ], + ), + ] diff --git a/tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py b/tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py index cbd48aebe29d32..84ba5ddfc3323d 100644 --- a/tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py +++ b/tests/sentry/api/endpoints/issues/test_organization_derive_code_mappings.py @@ -321,7 +321,12 @@ def test_post_existing_code_mapping(self) -> None: response = self.client.post(self.url, data=config_data, format="json") assert response.status_code == 201, response.content - new_code_mapping = RepositoryProjectPathConfig.objects.get( + # Both mappings should coexist: the original and the newly derived one + mappings = RepositoryProjectPathConfig.objects.filter( project=self.project, stack_root="/stack/root" ) - assert new_code_mapping.source_root == "/source/root" + assert mappings.count() == 2 + assert set(mappings.values_list("source_root", flat=True)) == { + "/source/root/wrong", + "/source/root", + } diff --git a/tests/sentry/integrations/api/endpoints/test_organization_code_mappings.py b/tests/sentry/integrations/api/endpoints/test_organization_code_mappings.py index de62631476a848..6666b61887a7eb 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_code_mappings.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_code_mappings.py @@ -329,7 +329,7 @@ def test_validate_path_conflict(self) -> None: assert response.status_code == 400 assert response.data == { "nonFieldErrors": [ - "Code path config already exists with this project and stack trace root" + "Code path config already exists with this project, stack trace root, and source root" ] } diff --git a/tests/sentry/integrations/api/endpoints/test_organization_code_mappings_bulk.py b/tests/sentry/integrations/api/endpoints/test_organization_code_mappings_bulk.py index ab668f5e84b74e..e69831f50091a3 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_code_mappings_bulk.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_code_mappings_bulk.py @@ -96,7 +96,8 @@ def test_update_existing_mapping(self) -> None: project=self.project1, repo=self.repo1, stack_root="com/example/maps", - source_root="old/source/root", + source_root="modules/maps/src/main/java/com/example/maps", + default_branch="old-branch", ) response = self.make_post( @@ -114,16 +115,18 @@ def test_update_existing_mapping(self) -> None: assert response.data["updated"] == 1 config = RepositoryProjectPathConfig.objects.get( - project=self.project1, stack_root="com/example/maps" + project=self.project1, + stack_root="com/example/maps", + source_root="modules/maps/src/main/java/com/example/maps", ) - assert config.source_root == "modules/maps/src/main/java/com/example/maps" + assert config.default_branch == "main" def test_mixed_create_and_update(self) -> None: self.create_code_mapping( project=self.project1, repo=self.repo1, stack_root="com/example/existing", - source_root="old/path", + source_root="existing/path", ) response = self.make_post( @@ -131,7 +134,7 @@ def test_mixed_create_and_update(self) -> None: "mappings": [ { "stackRoot": "com/example/existing", - "sourceRoot": "new/path", + "sourceRoot": "existing/path", }, { "stackRoot": "com/example/new", @@ -440,7 +443,7 @@ def test_repo_from_other_org_returns_404(self) -> None: response = self.make_post({"repository": "other-org/other-repo"}) assert response.status_code == 404 - def test_duplicate_stack_roots_in_request_last_wins(self) -> None: + def test_same_stack_root_different_source_roots_creates_both(self) -> None: response = self.make_post( { "mappings": [ @@ -456,13 +459,17 @@ def test_duplicate_stack_roots_in_request_last_wins(self) -> None: } ) assert response.status_code == 200, response.content - assert response.data["created"] == 1 - assert response.data["updated"] == 1 + assert response.data["created"] == 2 + assert response.data["updated"] == 0 - config = RepositoryProjectPathConfig.objects.get( + configs = RepositoryProjectPathConfig.objects.filter( project=self.project1, stack_root="com/example/maps" ) - assert config.source_root == "second/source/root" + assert configs.count() == 2 + assert set(configs.values_list("source_root", flat=True)) == { + "first/source/root", + "second/source/root", + } def test_multiple_repos_same_name_returns_409(self) -> None: # Intentionally use Repository.objects.create since create_repo uses diff --git a/tests/sentry/issues/auto_source_code_config/test_process_event.py b/tests/sentry/issues/auto_source_code_config/test_process_event.py index 049a06c4f760d3..f6d2531b440484 100644 --- a/tests/sentry/issues/auto_source_code_config/test_process_event.py +++ b/tests/sentry/issues/auto_source_code_config/test_process_event.py @@ -177,10 +177,11 @@ def _process_and_assert_configuration_changes( ) for expected_cm in expected_new_code_mappings: code_mapping = current_code_mappings.get( - project_id=self.project.id, stack_root=expected_cm["stack_root"] + project_id=self.project.id, + stack_root=expected_cm["stack_root"], + source_root=expected_cm["source_root"], ) assert code_mapping is not None - assert code_mapping.source_root == expected_cm["source_root"] assert code_mapping.repository.name == expected_cm["repo_name"] else: assert current_code_mappings.count() == starting_code_mappings_count @@ -939,13 +940,26 @@ def test_prevent_creating_duplicate_rules(self) -> None: self.project.update_option("sentry:grouping_enhancements", "stack.module:foo.bar.** +app") # Manually created code mapping self.create_repo_and_code_mapping(REPO1, "foo/bar/", "src/foo/") - # We do not expect code mappings or in-app rules to be created since - # the developer already created the code mapping and in-app rule + # We do not expect in-app rules to be created since the developer + # already created the in-app rule. A new code mapping is created + # because the source_root differs (src/foo/ vs src/foo/bar/). self._process_and_assert_configuration_changes( repo_trees={REPO1: ["src/foo/bar/Baz.java"]}, frames=[self.frame_from_module("foo.bar.Baz", "Baz.java")], platform=self.platform, + expected_new_code_mappings=[ + self.code_mapping(stack_root="foo/bar/", source_root="src/foo/bar/"), + ], ) + # Both mappings should coexist: the manual one and the auto-created one + mappings = RepositoryProjectPathConfig.objects.filter( + project=self.project, stack_root="foo/bar/" + ) + assert mappings.count() == 2 + assert set(mappings.values_list("source_root", flat=True)) == { + "src/foo/", + "src/foo/bar/", + } def test_basic_case(self) -> None: repo_trees = {REPO1: ["src/com/example/foo/Bar.kt"]} From 08bcd1f584e729cf291374c91aad9284033465f1 Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Tue, 31 Mar 2026 14:42:05 +0200 Subject: [PATCH 05/21] chore(dynamic-sampling): remove segments migration feature registration (#111188) --- src/sentry/options/defaults.py | 57 ---------------------------------- 1 file changed, 57 deletions(-) diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 45fe2de8d841e3..086a8c0d285f0d 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -2394,63 +2394,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -# List of organization IDs that should be using segment metrics for boost low volume transactions. -register( - "dynamic-sampling.transactions.segment-metric-orgs", - default=[], - type=Sequence, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -# When enabled, use segment metrics for ALL orgs in boost low volume transactions. -register( - "dynamic-sampling.transactions.segment-metric.enabled", - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) - -# List of organization IDs that should be using segment metrics for recalibrate_orgs. -register( - "dynamic-sampling.recalibrate_orgs.segment-metric-orgs", - default=[], - type=Sequence, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -# When enabled, use segment metrics for ALL orgs in recalibrate_orgs. -register( - "dynamic-sampling.recalibrate_orgs.segment-metric.enabled", - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) - -# List of organization IDs that should be using segment metrics for sliding_window_org. -register( - "dynamic-sampling.sliding_window_org.segment-metric-orgs", - default=[], - type=Sequence, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -# When enabled, use segment metrics for ALL orgs in sliding_window_org. -register( - "dynamic-sampling.sliding_window_org.segment-metric.enabled", - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) - -# List of organization IDs that should be using segment metrics for boost_low_volume_projects. -register( - "dynamic-sampling.boost_low_volume_projects.segment-metric-orgs", - default=[], - type=Sequence, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -# When enabled, use segment metrics for ALL orgs in boost_low_volume_projects. -register( - "dynamic-sampling.boost_low_volume_projects.segment-metric.enabled", - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) - - # === Hybrid cloud subsystem options === # UI rollout register( From 633f9fc691331ef7cd03d5d4118f785f3b90b2a4 Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Tue, 31 Mar 2026 14:43:08 +0200 Subject: [PATCH 06/21] chore(dynamic-sampling): remove unused snuba referrers (#111186) --- src/sentry/snuba/referrer.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/src/sentry/snuba/referrer.py b/src/sentry/snuba/referrer.py index 43135a2180f5b3..bc785e83708fcd 100644 --- a/src/sentry/snuba/referrer.py +++ b/src/sentry/snuba/referrer.py @@ -625,41 +625,18 @@ class Referrer(StrEnum): DELETIONS_GROUP = "deletions.group" DISCOVER = "discover" DISCOVER_SLACK_UNFURL = "discover.slack.unfurl" - DYNAMIC_SAMPLING_DISTRIBUTION_FETCH_PROJECT_BREAKDOWN = ( - "dynamic-sampling.distribution.fetch-project-breakdown" - ) - DYNAMIC_SAMPLING_DISTRIBUTION_FETCH_PROJECT_SDK_VERSIONS_INFO = ( - "dynamic-sampling.distribution.fetch-project-sdk-versions-info" - ) - DYNAMIC_SAMPLING_DISTRIBUTION_FETCH_PROJECT_STATS = ( - "dynamic-sampling.distribution.fetch-project-stats" - ) - DYNAMIC_SAMPLING_DISTRIBUTION_FETCH_TRANSACTIONS_COUNT = ( - "dynamic-sampling.distribution.fetch-transactions-count" - ) - DYNAMIC_SAMPLING_DISTRIBUTION_FETCH_TRANSACTIONS = ( - "dynamic-sampling.distribution.fetch-transactions" - ) - DYNAMIC_SAMPLING_DISTRIBUTION_GET_MOST_RECENT_DAY_WITH_TRANSACTIONS = ( - "dynamic-sampling.distribution.get-most-recent-day-with-transactions" - ) DYNAMIC_SAMPLING_COUNTERS_GET_ORG_TRANSACTION_VOLUMES = ( "dynamic_sampling.counters.get_org_transaction_volumes" ) - DYNAMIC_SAMPLING_DISTRIBUTION_FETCH_ORGS_WITH_COUNT_PER_ROOT = ( - "dynamic_sampling.distribution.fetch_orgs_with_count_per_root_total_volumes" - ) DYNAMIC_SAMPLING_DISTRIBUTION_FETCH_PROJECTS_WITH_COUNT_PER_ROOT = ( "dynamic_sampling.distribution.fetch_projects_with_count_per_root_total_volumes" ) DYNAMIC_SAMPLING_COUNTERS_FETCH_PROJECTS_WITH_COUNT_PER_TRANSACTION = ( "dynamic_sampling.counters.fetch_projects_with_count_per_transaction_volumes" ) - DYNAMIC_SAMPLING_COUNTERS_GET_ACTIVE_ORGS = "dynamic_sampling.counters.get_active_orgs" DYNAMIC_SAMPLING_COUNTERS_FETCH_PROJECTS_WITH_TRANSACTION_TOTALS = ( "dynamic_sampling.counters.fetch_projects_with_transaction_totals" ) - DYNAMIC_SAMPLING_COUNTERS_FETCH_ACTIVE_ORGS = "dynamic_sampling.counters.fetch_active_orgs" DYNAMIC_SAMPLING_SETTINGS_GET_SPAN_COUNTS = "dynamic_sampling.settings.get_project_span_counts" ESCALATING_GROUPS = "sentry.issues.escalating" EVENTSTORE_GET_EVENT_BY_ID_NODESTORE = "eventstore.backend.get_event_by_id_nodestore" From ce9f5e33095dfc5b89117d4678e0bd531774b94b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Tue, 31 Mar 2026 08:52:11 -0400 Subject: [PATCH 07/21] fix(logs): port log detail buttons to Button priority=transparent (#111824) Removes some of the old custom styles in favor of our [standard Scraps transparent Button](https://sentry.sentry.io/stories/core/button/#priorities).
Before After
Fixes EXP-860 Made with [Cursor](https://cursor.com) --- static/app/views/explore/logs/styles.tsx | 5 +---- .../app/views/explore/logs/tables/logsTableRow.tsx | 14 ++++++-------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/static/app/views/explore/logs/styles.tsx b/static/app/views/explore/logs/styles.tsx index f5658dae658d6b..9086df330465d8 100644 --- a/static/app/views/explore/logs/styles.tsx +++ b/static/app/views/explore/logs/styles.tsx @@ -157,15 +157,12 @@ export const LogDetailTableActionsCell = styled(TableBodyCell)` padding: ${p => p.theme.space.xs} ${p => p.theme.space.xl}; } &:last-child { - padding: ${p => p.theme.space.xs} ${p => p.theme.space.xl}; + padding: ${p => p.theme.space.xs} 0; } `; export const LogDetailTableActionsButtonBar = styled('div')` display: flex; gap: ${p => p.theme.space.md}; - & button { - font-weight: ${p => p.theme.font.weight.sans.regular}; - } `; export const DetailsWrapper = styled('tr')` diff --git a/static/app/views/explore/logs/tables/logsTableRow.tsx b/static/app/views/explore/logs/tables/logsTableRow.tsx index 5519c4a8fb03d0..f1977b6caf2c79 100644 --- a/static/app/views/explore/logs/tables/logsTableRow.tsx +++ b/static/app/views/explore/logs/tables/logsTableRow.tsx @@ -612,13 +612,13 @@ function LogRowDetails({ } function LogRowDetailsFilterActions({tableDataRow}: {tableDataRow: LogTableRowItem}) { - const theme = useTheme(); const addSearchFilter = useAddSearchFilter(); return ( @@ -654,7 +653,6 @@ function LogRowDetailsActions({ fullLogDataResult: UseApiQueryResult; tableDataRow: LogTableRowItem; }) { - const theme = useTheme(); const {data, isPending, isError} = fullLogDataResult; const isFrozen = useLogsFrozenIsFrozen(); const organization = useOrganization(); @@ -689,12 +687,12 @@ function LogRowDetailsActions({ )} From 7fdffb3b73b18a396bac7b0418650b3f9b4dd8e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Tue, 31 Mar 2026 08:54:03 -0400 Subject: [PATCH 08/21] feat(logs): add inline JSON syntax highlighting for log attributes (#111730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add inline syntax highlighting for JSON embedded within log attribute values in the expanded attribute tree view. Previously (LOGS-400), only attribute values that were *entirely* valid JSON got pretty-printed with `StructuredEventData`. Values like `This is my JSON: {"it": "would be", "nice": ["to", "highlight"], "this": true}` rendered as plain text. Now, the JSON portion gets Prism-based syntax colorizing inline — keys, strings, numbers, booleans, and punctuation each get their own color — without adding newlines or changing the layout. Screenshot of a log with a myData
field showing syntax-highlighted JSON object The JSON extraction logic lives in a standalone utility (`extractJsonFromText`) that uses string-aware bracket matching to correctly handle braces inside quoted strings and escape sequences — unlike existing npm packages (`balanced-match`, `extract-json-from-string`) which break on these cases. I intentionally set this up so that it acts like a standalone importable utility. It'd be nifty if we could publish this as its own package... Fixes LOGS-636 Made with [Cursor](https://cursor.com) Co-authored-by: Claude Sonnet 4 --- static/app/utils/extractJsonFromText.spec.ts | 483 ++++++++++++++++++ static/app/utils/extractJsonFromText.ts | 136 +++++ .../attributesTreeValue.spec.tsx | 48 ++ .../attributesTreeValue.tsx | 35 +- .../inlineJsonHighlight.spec.tsx | 33 ++ .../inlineJsonHighlight.tsx | 57 +++ 6 files changed, 778 insertions(+), 14 deletions(-) create mode 100644 static/app/utils/extractJsonFromText.spec.ts create mode 100644 static/app/utils/extractJsonFromText.ts create mode 100644 static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.spec.tsx create mode 100644 static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.tsx diff --git a/static/app/utils/extractJsonFromText.spec.ts b/static/app/utils/extractJsonFromText.spec.ts new file mode 100644 index 00000000000000..7d618fb6c15c58 --- /dev/null +++ b/static/app/utils/extractJsonFromText.spec.ts @@ -0,0 +1,483 @@ +import {extractJsonFromText, findMatchingBracket} from './extractJsonFromText'; + +describe('findMatchingBracket', () => { + it('finds matching curly brace', () => { + expect(findMatchingBracket('{}', 0)).toBe(1); + }); + + it('finds matching square bracket', () => { + expect(findMatchingBracket('[]', 0)).toBe(1); + }); + + it('handles nested braces', () => { + expect(findMatchingBracket('{{{}}}', 0)).toBe(5); + }); + + it('handles mixed bracket types', () => { + expect(findMatchingBracket('{[{}]}', 0)).toBe(5); + }); + + it('returns -1 for unmatched opening brace', () => { + expect(findMatchingBracket('{', 0)).toBe(-1); + }); + + it('returns -1 for unmatched opening bracket', () => { + expect(findMatchingBracket('[', 0)).toBe(-1); + }); + + it('starts from the given position', () => { + expect(findMatchingBracket('xx{yy}zz', 2)).toBe(5); + }); + + it('ignores braces inside double-quoted strings', () => { + expect(findMatchingBracket('{"key": "}"}', 0)).toBe(11); + }); + + it('ignores brackets inside double-quoted strings', () => { + expect(findMatchingBracket('{"key": "]"}', 0)).toBe(11); + }); + + it('handles escaped quotes inside strings', () => { + expect(findMatchingBracket('{"key": "val\\"ue"}', 0)).toBe(17); + }); + + it('handles escaped backslash before closing quote', () => { + // The value is a string ending with a literal backslash: "val\\" + // In the JSON: {"k": "val\\"} — the \\\\ is an escaped backslash, + // so the quote after it closes the string. + expect(findMatchingBracket('{"k": "val\\\\"}', 0)).toBe(13); + }); + + it('handles multiple escaped characters in a string', () => { + expect(findMatchingBracket('{"k": "a\\nb\\tc"}', 0)).toBe(15); + }); + + it('handles empty object', () => { + expect(findMatchingBracket('{}', 0)).toBe(1); + }); + + it('handles empty array', () => { + expect(findMatchingBracket('[]', 0)).toBe(1); + }); + + it('handles deeply nested structure', () => { + expect(findMatchingBracket('[[[[[]]]]]', 0)).toBe(9); + }); + + it('handles string containing opening braces', () => { + expect(findMatchingBracket('{"braces": "{{{"}', 0)).toBe(16); + }); + + it('handles string containing brackets and braces mixed', () => { + expect(findMatchingBracket('{"val": "[{]"}', 0)).toBe(13); + }); + + it('returns -1 when string has unbalanced quotes disrupting matching', () => { + // An unclosed string means the closing brace is "inside" the string + expect(findMatchingBracket('{"key: }', 0)).toBe(-1); + }); +}); + +describe('extractJsonFromText', () => { + describe('basic extraction', () => { + it('returns empty array for empty string', () => { + expect(extractJsonFromText('')).toEqual([]); + }); + + it('returns a single text segment for plain text', () => { + expect(extractJsonFromText('hello world')).toEqual([ + {type: 'text', value: 'hello world'}, + ]); + }); + + it('extracts a standalone JSON object', () => { + expect(extractJsonFromText('{"key": "value"}')).toEqual([ + {type: 'json', value: '{"key": "value"}'}, + ]); + }); + + it('extracts a standalone JSON array', () => { + expect(extractJsonFromText('[1, 2, 3]')).toEqual([ + {type: 'json', value: '[1, 2, 3]'}, + ]); + }); + + it('extracts JSON object with surrounding text', () => { + expect(extractJsonFromText('prefix {"key": "value"} suffix')).toEqual([ + {type: 'text', value: 'prefix '}, + {type: 'json', value: '{"key": "value"}'}, + {type: 'text', value: ' suffix'}, + ]); + }); + + it('extracts JSON array with surrounding text', () => { + expect(extractJsonFromText('data: [1, 2, 3] end')).toEqual([ + {type: 'text', value: 'data: '}, + {type: 'json', value: '[1, 2, 3]'}, + {type: 'text', value: ' end'}, + ]); + }); + + it('extracts JSON at the very start', () => { + expect(extractJsonFromText('{"key": "value"} trailing')).toEqual([ + {type: 'json', value: '{"key": "value"}'}, + {type: 'text', value: ' trailing'}, + ]); + }); + + it('extracts JSON at the very end', () => { + expect(extractJsonFromText('leading {"key": "value"}')).toEqual([ + {type: 'text', value: 'leading '}, + {type: 'json', value: '{"key": "value"}'}, + ]); + }); + }); + + describe('multiple JSON values', () => { + it('extracts multiple JSON objects', () => { + expect(extractJsonFromText('a {"x": 1} b {"y": 2} c')).toEqual([ + {type: 'text', value: 'a '}, + {type: 'json', value: '{"x": 1}'}, + {type: 'text', value: ' b '}, + {type: 'json', value: '{"y": 2}'}, + {type: 'text', value: ' c'}, + ]); + }); + + it('extracts adjacent JSON objects without separator', () => { + expect(extractJsonFromText('{"a": 1}{"b": 2}')).toEqual([ + {type: 'json', value: '{"a": 1}'}, + {type: 'json', value: '{"b": 2}'}, + ]); + }); + + it('extracts mixed objects and arrays', () => { + expect(extractJsonFromText('obj: {"a": 1} arr: [2, 3]')).toEqual([ + {type: 'text', value: 'obj: '}, + {type: 'json', value: '{"a": 1}'}, + {type: 'text', value: ' arr: '}, + {type: 'json', value: '[2, 3]'}, + ]); + }); + }); + + describe('nested structures', () => { + it('handles nested objects', () => { + expect(extractJsonFromText('r: {"a": {"b": {"c": 1}}}')).toEqual([ + {type: 'text', value: 'r: '}, + {type: 'json', value: '{"a": {"b": {"c": 1}}}'}, + ]); + }); + + it('handles nested arrays', () => { + expect(extractJsonFromText('r: [[1, [2]], [3]]')).toEqual([ + {type: 'text', value: 'r: '}, + {type: 'json', value: '[[1, [2]], [3]]'}, + ]); + }); + + it('handles objects containing arrays', () => { + expect(extractJsonFromText('r: {"a": [1, 2, 3]}')).toEqual([ + {type: 'text', value: 'r: '}, + {type: 'json', value: '{"a": [1, 2, 3]}'}, + ]); + }); + + it('handles arrays containing objects', () => { + expect(extractJsonFromText('r: [{"a": 1}, {"b": 2}]')).toEqual([ + {type: 'text', value: 'r: '}, + {type: 'json', value: '[{"a": 1}, {"b": 2}]'}, + ]); + }); + }); + + describe('string literal handling (where naive packages fail)', () => { + it('handles braces inside JSON string values', () => { + expect(extractJsonFromText('log: {"pattern": "{user}"}')).toEqual([ + {type: 'text', value: 'log: '}, + {type: 'json', value: '{"pattern": "{user}"}'}, + ]); + }); + + it('handles brackets inside JSON string values', () => { + expect(extractJsonFromText('log: {"pattern": "[item]"}')).toEqual([ + {type: 'text', value: 'log: '}, + {type: 'json', value: '{"pattern": "[item]"}'}, + ]); + }); + + it('handles closing brace inside a string value', () => { + // This is the case that breaks balanced-match and extract-json-from-string + expect(extractJsonFromText('x {"key": "}"} y')).toEqual([ + {type: 'text', value: 'x '}, + {type: 'json', value: '{"key": "}"}'}, + {type: 'text', value: ' y'}, + ]); + }); + + it('handles closing bracket inside a string value', () => { + expect(extractJsonFromText('x {"key": "]"} y')).toEqual([ + {type: 'text', value: 'x '}, + {type: 'json', value: '{"key": "]"}'}, + {type: 'text', value: ' y'}, + ]); + }); + + it('handles escaped quotes in string values', () => { + expect(extractJsonFromText('d: {"msg": "say \\"hello\\""}')).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: '{"msg": "say \\"hello\\""}'}, + ]); + }); + + it('handles escaped backslash before closing quote', () => { + // Value is literally: val\ (backslash at end) + // JSON encoding: "val\\" — the \\\\ is an escaped backslash + expect(extractJsonFromText('d: {"k": "val\\\\"}')).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: '{"k": "val\\\\"}'}, + ]); + }); + + it('handles newlines and tabs in JSON strings', () => { + expect(extractJsonFromText('d: {"k": "line1\\nline2"}')).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: '{"k": "line1\\nline2"}'}, + ]); + }); + + it('handles unicode escapes in JSON strings', () => { + expect(extractJsonFromText('d: {"k": "caf\\u00e9"}')).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: '{"k": "caf\\u00e9"}'}, + ]); + }); + }); + + describe('non-JSON braces treated as text', () => { + it('treats template-style braces as text', () => { + expect(extractJsonFromText('hello {name} world')).toEqual([ + {type: 'text', value: 'hello {name} world'}, + ]); + }); + + it('treats unmatched opening brace as text', () => { + expect(extractJsonFromText('not {json')).toEqual([ + {type: 'text', value: 'not {json'}, + ]); + }); + + it('treats unmatched opening bracket as text', () => { + expect(extractJsonFromText('not [json')).toEqual([ + {type: 'text', value: 'not [json'}, + ]); + }); + + it('treats matched but syntactically invalid JSON as text', () => { + expect(extractJsonFromText('{invalid json content}')).toEqual([ + {type: 'text', value: '{invalid json content}'}, + ]); + }); + + it('treats Python-style dicts as text', () => { + expect(extractJsonFromText("data: {'key': 'value'}")).toEqual([ + {type: 'text', value: "data: {'key': 'value'}"}, + ]); + }); + + it('treats braces in code snippets as text', () => { + expect(extractJsonFromText('function() { return 1; }')).toEqual([ + {type: 'text', value: 'function() { return 1; }'}, + ]); + }); + + it('treats CSS-like braces as text', () => { + expect(extractJsonFromText('.class { color: red; }')).toEqual([ + {type: 'text', value: '.class { color: red; }'}, + ]); + }); + + it('treats multiple template vars as text', () => { + expect(extractJsonFromText('{user} logged in from {ip}')).toEqual([ + {type: 'text', value: '{user} logged in from {ip}'}, + ]); + }); + }); + + describe('JSON value types', () => { + it('does not treat bare primitives as JSON segments', () => { + expect(extractJsonFromText('value: 123')).toEqual([ + {type: 'text', value: 'value: 123'}, + ]); + }); + + it('extracts arrays of primitives', () => { + expect(extractJsonFromText('[true, false, null]')).toEqual([ + {type: 'json', value: '[true, false, null]'}, + ]); + }); + + it('extracts objects with various value types', () => { + const json = '{"s": "str", "n": 42, "b": true, "x": null}'; + expect(extractJsonFromText(`d: ${json}`)).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: json}, + ]); + }); + + it('extracts empty object', () => { + expect(extractJsonFromText('empty: {}')).toEqual([ + {type: 'text', value: 'empty: '}, + {type: 'json', value: '{}'}, + ]); + }); + + it('extracts empty array', () => { + expect(extractJsonFromText('empty: []')).toEqual([ + {type: 'text', value: 'empty: '}, + {type: 'json', value: '[]'}, + ]); + }); + + it('extracts array of strings', () => { + expect(extractJsonFromText('tags: ["a", "b", "c"]')).toEqual([ + {type: 'text', value: 'tags: '}, + {type: 'json', value: '["a", "b", "c"]'}, + ]); + }); + }); + + describe('text preservation invariant', () => { + const cases = [ + 'hello world', + 'prefix {"key": "value"} suffix', + 'a {"x": 1} b {"y": 2} c', + '{"a": 1}{"b": 2}', + 'no json here {invalid} at all', + 'mixed {"valid": true} and {invalid} stuff', + '', + '{"only": "json"}', + 'trailing text after {"json": true}', + '{"json": true} leading text before', + 'braces } without { matching [ pairs ]', + 'log: {"pattern": "{user}"}', + ]; + + it.each(cases)('concatenating segments reproduces the original: %s', input => { + const segments = extractJsonFromText(input); + const reconstructed = segments.map(s => s.value).join(''); + expect(reconstructed).toBe(input); + }); + }); + + describe('mixed valid and invalid JSON', () => { + it('extracts valid JSON surrounded by invalid braces', () => { + expect(extractJsonFromText('{bad} {"good": true} {bad}')).toEqual([ + {type: 'text', value: '{bad} '}, + {type: 'json', value: '{"good": true}'}, + {type: 'text', value: ' {bad}'}, + ]); + }); + + it('handles valid JSON after several invalid brace pairs', () => { + expect(extractJsonFromText('{a} {b} {c} {"d": 1}')).toEqual([ + {type: 'text', value: '{a} {b} {c} '}, + {type: 'json', value: '{"d": 1}'}, + ]); + }); + + it('handles invalid brace pair after valid JSON', () => { + expect(extractJsonFromText('{"a": 1} {b} done')).toEqual([ + {type: 'json', value: '{"a": 1}'}, + {type: 'text', value: ' {b} done'}, + ]); + }); + }); + + describe('whitespace handling', () => { + it('preserves whitespace in text segments', () => { + expect(extractJsonFromText(' {"a": 1} ')).toEqual([ + {type: 'text', value: ' '}, + {type: 'json', value: '{"a": 1}'}, + {type: 'text', value: ' '}, + ]); + }); + + it('handles JSON with internal whitespace', () => { + expect(extractJsonFromText('d: { "key" : "value" }')).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: '{ "key" : "value" }'}, + ]); + }); + + it('handles multiline JSON', () => { + const json = '{\n "key": "value"\n}'; + expect(extractJsonFromText(`d: ${json} end`)).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: json}, + {type: 'text', value: ' end'}, + ]); + }); + }); + + describe('real-world log patterns', () => { + it('extracts JSON from a log line', () => { + expect( + extractJsonFromText( + '2024-01-15 10:30:00 INFO {"event": "login", "user": "alice"}' + ) + ).toEqual([ + {type: 'text', value: '2024-01-15 10:30:00 INFO '}, + {type: 'json', value: '{"event": "login", "user": "alice"}'}, + ]); + }); + + it('extracts JSON from a message with context', () => { + expect( + extractJsonFromText( + 'This is my JSON: { "it": "would be", "nice": ["to", "highlight"], "this": true }' + ) + ).toEqual([ + {type: 'text', value: 'This is my JSON: '}, + { + type: 'json', + value: '{ "it": "would be", "nice": ["to", "highlight"], "this": true }', + }, + ]); + }); + + it('extracts JSON from an error message', () => { + expect( + extractJsonFromText( + 'Failed to process request: {"error": "timeout", "code": 504} - retrying' + ) + ).toEqual([ + {type: 'text', value: 'Failed to process request: '}, + {type: 'json', value: '{"error": "timeout", "code": 504}'}, + {type: 'text', value: ' - retrying'}, + ]); + }); + + it('handles a log line with no JSON', () => { + expect( + extractJsonFromText('2024-01-15 10:30:00 INFO User logged in successfully') + ).toEqual([ + { + type: 'text', + value: '2024-01-15 10:30:00 INFO User logged in successfully', + }, + ]); + }); + + it('handles a stack trace style message with braces', () => { + expect( + extractJsonFromText('Error at MyClass.method(file.java:42) caused by {unknown}') + ).toEqual([ + { + type: 'text', + value: 'Error at MyClass.method(file.java:42) caused by {unknown}', + }, + ]); + }); + }); +}); diff --git a/static/app/utils/extractJsonFromText.ts b/static/app/utils/extractJsonFromText.ts new file mode 100644 index 00000000000000..e056b5b474cdbf --- /dev/null +++ b/static/app/utils/extractJsonFromText.ts @@ -0,0 +1,136 @@ +type TextSegment = {type: 'text'; value: string}; +type JsonSegment = {type: 'json'; value: string}; + +export type ExtractedSegment = TextSegment | JsonSegment; + +/** + * Finds the position of the matching closing bracket for a `{` or `[` + * at position `start`. Correctly handles JSON string literals — bracket + * characters inside double-quoted strings are ignored, and backslash + * escapes within strings are respected. + * + * Returns the index of the matching closing bracket, or -1 if the + * brackets are unbalanced. + */ +export function findMatchingBracket(text: string, start: number): number { + let depth = 0; + let inString = false; + let escaped = false; + + for (let i = start; i < text.length; i++) { + const ch = text[i]; + + if (escaped) { + escaped = false; + continue; + } + + if (ch === '\\' && inString) { + escaped = true; + continue; + } + + if (ch === '"') { + inString = !inString; + continue; + } + + if (inString) { + continue; + } + + if (ch === '{' || ch === '[') { + depth++; + } + if (ch === '}' || ch === ']') { + depth--; + } + + if (depth === 0) { + return i; + } + } + + return -1; +} + +/** + * Extracts JSON object and array substrings from arbitrary text. + * + * Scans `text` for `{` / `[` characters, uses string-aware bracket + * matching to find the candidate closing bracket, then validates the + * candidate with `JSON.parse`. Returns an array of segments preserving + * the full original text — every character appears in exactly one + * segment, and concatenating all segment values reproduces the input. + * + * Only objects and arrays are recognized as JSON segments; bare + * primitives like `"hello"`, `42`, or `true` are left as text. + * + * @example + * extractJsonFromText('msg: {"level":"info"} ok') + * // [ + * // { type: 'text', value: 'msg: ' }, + * // { type: 'json', value: '{"level":"info"}' }, + * // { type: 'text', value: ' ok' }, + * // ] + */ +export function extractJsonFromText(text: string): ExtractedSegment[] { + const segments: ExtractedSegment[] = []; + let i = 0; + + while (i < text.length) { + let nextStart = -1; + for (let j = i; j < text.length; j++) { + if (text[j] === '{' || text[j] === '[') { + nextStart = j; + break; + } + } + + if (nextStart === -1) { + if (i < text.length) { + segments.push({type: 'text', value: text.slice(i)}); + } + break; + } + + if (nextStart > i) { + segments.push({type: 'text', value: text.slice(i, nextStart)}); + } + + const matchEnd = findMatchingBracket(text, nextStart); + if (matchEnd === -1) { + segments.push({type: 'text', value: text.slice(nextStart)}); + break; + } + + const candidate = text.slice(nextStart, matchEnd + 1); + try { + const parsed = JSON.parse(candidate); + if (typeof parsed === 'object' && parsed !== null) { + segments.push({type: 'json', value: candidate}); + i = matchEnd + 1; + } else { + segments.push({type: 'text', value: text[nextStart]!}); + i = nextStart + 1; + } + } catch { + segments.push({type: 'text', value: text[nextStart]!}); + i = nextStart + 1; + } + } + + // Merge consecutive text segments produced when invalid candidates + // cause the scanner to advance one character at a time. + const merged: ExtractedSegment[] = []; + for (const segment of segments) { + const last = merged[merged.length - 1]; + if (segment.type === 'text' && last?.type === 'text') { + last.value += segment.value; + } else { + merged.push(segment); + } + } + + return merged; +} diff --git a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx index 0e6b030fb87dc1..a0d4b3c51f00de 100644 --- a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx +++ b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx @@ -233,4 +233,52 @@ describe('AttributesTreeValue', () => { .closest('pre'); expect(pre).not.toHaveClass('compact'); }); + + it('renders inline JSON highlighting for text containing a JSON object', () => { + const content = { + ...defaultProps.content, + value: 'msg: {"level": "info"}', + }; + + render(); + + const wrapper = screen.getByText(/msg/); + expect(wrapper.querySelector('code.language-json')).toBeInTheDocument(); + }); + + it('renders inline JSON highlighting for text containing a JSON array', () => { + const content = { + ...defaultProps.content, + value: 'tags: [1, 2, 3]', + }; + + render(); + + const wrapper = screen.getByText(/tags/); + expect(wrapper.querySelector('code.language-json')).toBeInTheDocument(); + }); + + it('renders URL with brackets as a link, not inline JSON', () => { + const content = { + ...defaultProps.content, + value: 'https://example.com/api?filter=[1,2]', + }; + + render(); + + const link = screen.getByText('https://example.com/api?filter=[1,2]').closest('a'); + expect(link).toBeInTheDocument(); + }); + + it('renders URL with braces as a link, not inline JSON', () => { + const content = { + ...defaultProps.content, + value: 'https://example.com/api/{id}', + }; + + render(); + + const link = screen.getByText('https://example.com/api/{id}').closest('a'); + expect(link).toBeInTheDocument(); + }); }); diff --git a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx index aeb856dcbf0ad0..861ffef99dd363 100644 --- a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx +++ b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx @@ -7,6 +7,7 @@ import {StructuredEventData} from 'sentry/components/structuredEventData'; import {type RenderFunctionBaggage} from 'sentry/utils/discover/fieldRenderers'; import {isUrl} from 'sentry/utils/string/isUrl'; import {AnnotatedAttributeTooltip} from 'sentry/views/explore/components/annotatedAttributeTooltip'; +import {InlineJsonHighlight} from 'sentry/views/explore/components/traceItemAttributes/inlineJsonHighlight'; import {getAttributeItem} from 'sentry/views/explore/components/traceItemAttributes/utils'; import {TraceItemMetaInfo} from 'sentry/views/explore/utils'; @@ -94,20 +95,26 @@ export function AttributesTreeValue ); } - return isUrl(value) ? ( - - { - e.preventDefault(); - openNavigateToExternalLinkModal({linkText: value}); - }} - > - {defaultValue} - - - ) : ( - defaultValue - ); + if (isUrl(value)) { + return ( + + { + e.preventDefault(); + openNavigateToExternalLinkModal({linkText: value}); + }} + > + {defaultValue} + + + ); + } + + if (value.includes('{') || value.includes('[')) { + return ; + } + + return defaultValue; } const AttributeLinkText = styled('span')` diff --git a/static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.spec.tsx b/static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.spec.tsx new file mode 100644 index 00000000000000..190b8243642238 --- /dev/null +++ b/static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.spec.tsx @@ -0,0 +1,33 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {InlineJsonHighlight} from './inlineJsonHighlight'; + +describe('InlineJsonHighlight', () => { + it('renders plain text without highlighting', () => { + render(); + expect(screen.getByText('hello world')).toBeInTheDocument(); + }); + + it('renders text with embedded JSON and highlights JSON portion', () => { + render(); + const wrapper = screen.getByText(/prefix/); + expect(wrapper).toHaveTextContent('prefix {"key": "value"} suffix'); + expect(wrapper.querySelector('code.language-json')).toBeInTheDocument(); + }); + + it('renders invalid braces as plain text', () => { + render(); + expect(screen.getByText('not {json')).toBeInTheDocument(); + }); + + it('renders template-style braces as plain text', () => { + render(); + expect(screen.getByText('hello {name} world')).toBeInTheDocument(); + }); + + it('uses code element for JSON segments', () => { + render(); + const wrapper = screen.getByText(/data/); + expect(wrapper.querySelector('code.language-json')).toBeInTheDocument(); + }); +}); diff --git a/static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.tsx b/static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.tsx new file mode 100644 index 00000000000000..527cd003aa0659 --- /dev/null +++ b/static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.tsx @@ -0,0 +1,57 @@ +import {Fragment, useMemo} from 'react'; +import styled from '@emotion/styled'; + +import {extractJsonFromText} from 'sentry/utils/extractJsonFromText'; +import {usePrismTokens} from 'sentry/utils/usePrismTokens'; + +function JsonSegment({json}: {json: string}) { + const lines = usePrismTokens({code: json, language: 'json'}); + + return ( + + {lines.map((line, lineIdx) => ( + + {line.map((token, tokenIdx) => ( + + {token.children} + + ))} + + ))} + + ); +} + +/** + * Renders a string with inline syntax highlighting for embedded JSON. + * JSON objects and arrays within the text are colorized using Prism's JSON grammar. + * Non-JSON text is rendered as-is. + */ +export function InlineJsonHighlight({value}: {value: string}) { + const segments = useMemo(() => extractJsonFromText(value), [value]); + + if (segments.length === 1 && segments[0]!.type === 'text') { + return {value}; + } + + return ( + + {segments.map((segment, idx) => + segment.type === 'json' ? ( + + ) : ( + {segment.value} + ) + )} + + ); +} + +const InlineCode = styled('code')` + && { + background: transparent; + padding: 0; + white-space: pre-wrap; + font-size: inherit; + } +`; From 6abcd1c182ac02bf018acf67ead622e627fbbb74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Tue, 31 Mar 2026 09:13:42 -0400 Subject: [PATCH 09/21] fix(eslint): replace untyped default-case and consistent-return with typed switch-exhaustiveness-check (#109743) Following up on https://github.com/getsentry/sentry/pull/109725#discussion_r2874708881: our lint rules that attempt to enforce exhaustive logical handling aren't set up right. We're using ESLint's core rules that are not type-aware and so are actually buggy / problematic: * [`consistent-return`](https://eslint.org/docs/latest/rules/consistent-return): Requires `return` statements to either always or never specify values. This can be thought of as a safety point (never accidentally implicitly returning `undefined`) or a stylistic point (clearly indicating returned values). TypeScript handles the safety point. * [`default-case`](https://eslint.org/docs/latest/rules/default-case): Requires `default` cases in `switch` statements. This doesn't factor in type information for union types, resulting in a need to add a `default` even if the `switch` is already exhaustive or (e.g. for finite unions) or cannot be (e.g. for primitive types). This PR switches from them to the type-aware [`@typescript-eslint/switch-exhaustiveness-check`](https://typescript-eslint.io/rules/switch-exhaustiveness-check). That rule knows to only enforce asking for handling all cases when the `switch` is over a union type. In other words, for a `switch (value)`, if a `value` is... * `string`: nothing will require a `default:` * `"a" | "b"`: then if there isn't both `case "a":` and `case "b":`, there will need to be `default:` --- eslint.config.ts | 6 ++++-- static/app/components/avatarChooser/avatarCropper.tsx | 1 - static/app/components/charts/useChartXRangeSelection.tsx | 2 -- .../components/events/contexts/platformContext/utils.tsx | 2 -- static/app/components/events/contexts/utils.tsx | 4 ---- static/app/components/events/highlights/util.tsx | 1 - static/app/components/events/interfaces/utils.tsx | 2 +- static/app/components/pipeline/usePipeline.tsx | 2 -- .../searchQueryBuilder/tokens/useSearchTokenCombobox.tsx | 2 -- .../alerts/list/rules/alertLastIncidentActivationInfo.tsx | 1 - .../widgetBuilder/hooks/useWidgetBuilderState.tsx | 2 -- .../tableWidget/tableWidgetVisualization.stories.tsx | 2 -- static/app/views/discover/table/cellAction.tsx | 2 -- .../views/explore/hooks/useAttributeBreakdownsTooltip.tsx | 4 ---- static/app/views/explore/hooks/useCrossEventQueries.tsx | 2 -- static/app/views/issueDetails/groupEventCarousel.tsx | 2 -- static/app/views/issueList/editableIssueViewHeader.tsx | 2 -- static/app/views/navigation/navigationTour.tsx | 2 -- .../performance/newTraceDetails/traceHeader/breadcrumbs.tsx | 1 - .../replays/detail/network/truncateJson/completeJson.ts | 2 +- .../replays/detail/network/truncateJson/evaluateJson.ts | 1 - .../components/dataScrubbing/modals/form/attributeField.tsx | 2 -- .../src/rules/restrict-jsx-slot-children.ts | 4 ++-- static/gsAdmin/components/customers/organizationStatus.tsx | 2 -- static/gsAdmin/components/relocationBadge.tsx | 2 -- 25 files changed, 8 insertions(+), 47 deletions(-) diff --git a/eslint.config.ts b/eslint.config.ts index d632d8b2b6da84..5dfce125646ecf 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -310,8 +310,6 @@ export default typescript.config([ rules: { 'array-callback-return': 'error', 'block-scoped-var': 'error', - 'consistent-return': 'error', - 'default-case': 'error', 'dot-notation': 'error', eqeqeq: 'error', 'guard-for-in': 'off', // TODO(ryan953): Fix violations and enable this rule @@ -604,6 +602,10 @@ export default typescript.config([ '@typescript-eslint/no-for-in-array': 'error', '@typescript-eslint/no-unnecessary-template-expression': 'error', '@typescript-eslint/no-unnecessary-type-assertion': 'error', + '@typescript-eslint/switch-exhaustiveness-check': [ + 'error', + {considerDefaultExhaustiveForUnions: true}, + ], '@typescript-eslint/only-throw-error': 'error', '@typescript-eslint/prefer-optional-chain': 'error', '@typescript-eslint/prefer-promise-reject-errors': 'error', diff --git a/static/app/components/avatarChooser/avatarCropper.tsx b/static/app/components/avatarChooser/avatarCropper.tsx index f11fd48355cdfd..ab38e9a3b4be1e 100644 --- a/static/app/components/avatarChooser/avatarCropper.tsx +++ b/static/app/components/avatarChooser/avatarCropper.tsx @@ -332,7 +332,6 @@ function AvatarCropper({maxDimension, minDimension, updateDataUrlState, dataUrl} document.addEventListener('mousemove', updateSize); document.addEventListener('mouseup', stopResize); - // eslint-disable-next-line consistent-return return () => { document.removeEventListener('mousemove', updateSize); document.removeEventListener('mouseup', stopResize); diff --git a/static/app/components/charts/useChartXRangeSelection.tsx b/static/app/components/charts/useChartXRangeSelection.tsx index e38acab1feef4b..3134a5f04a08d6 100644 --- a/static/app/components/charts/useChartXRangeSelection.tsx +++ b/static/app/components/charts/useChartXRangeSelection.tsx @@ -361,7 +361,6 @@ export function useChartXRangeSelection({ document.body.addEventListener('click', handleInsideSelectionClick, true); - // eslint-disable-next-line consistent-return return () => { document.body.removeEventListener('click', handleInsideSelectionClick, true); }; @@ -422,7 +421,6 @@ export function useChartXRangeSelection({ enableBrushMode(); }); - // eslint-disable-next-line consistent-return return () => { if (brushStateSyncFrameRef.current) { cancelAnimationFrame(brushStateSyncFrameRef.current); diff --git a/static/app/components/events/contexts/platformContext/utils.tsx b/static/app/components/events/contexts/platformContext/utils.tsx index 5f280c20374cef..8e21e5f08a70a9 100644 --- a/static/app/components/events/contexts/platformContext/utils.tsx +++ b/static/app/components/events/contexts/platformContext/utils.tsx @@ -54,8 +54,6 @@ export function getPlatformContextIcon({ case PlatformContextKeys.SPRING: platformIconName = 'java-spring'; break; - default: - break; } if (platformIconName.length === 0) { diff --git a/static/app/components/events/contexts/utils.tsx b/static/app/components/events/contexts/utils.tsx index 4b12829e6520df..2f9eab135721d8 100644 --- a/static/app/components/events/contexts/utils.tsx +++ b/static/app/components/events/contexts/utils.tsx @@ -355,8 +355,6 @@ export function getContextIcon({ case 'gpu': iconName = generateIconName(value?.vendor_name ? value?.vendor_name : value?.name); break; - default: - break; } if (iconName.length === 0) { return null; @@ -568,8 +566,6 @@ export function getContextSummary({ subtitleType = t('Version'); } break; - default: - break; } return { title, diff --git a/static/app/components/events/highlights/util.tsx b/static/app/components/events/highlights/util.tsx index d1f96eeff02dfc..10b91f3571e522 100644 --- a/static/app/components/events/highlights/util.tsx +++ b/static/app/components/events/highlights/util.tsx @@ -181,7 +181,6 @@ export function getRuntimeLabelAndTooltip( return null; } - // eslint-disable-next-line default-case switch (event.contexts.runtime?.name || '') { case 'node': return {label: t('Backend'), tooltip: t('Error from Node.js Server Runtime')}; diff --git a/static/app/components/events/interfaces/utils.tsx b/static/app/components/events/interfaces/utils.tsx index 13631cc1d1e4ef..a1b5b18ee6400d 100644 --- a/static/app/components/events/interfaces/utils.tsx +++ b/static/app/components/events/interfaces/utils.tsx @@ -278,7 +278,7 @@ export function parseAssembly(assembly: string | null) { for (let i = 1; i < pieces.length; i++) { const [key, value] = pieces[i]!.trim().split('='); - // eslint-disable-next-line default-case + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check switch (key) { case 'Version': version = value; diff --git a/static/app/components/pipeline/usePipeline.tsx b/static/app/components/pipeline/usePipeline.tsx index cdca24c0cca27a..4a7715ed3fa6aa 100644 --- a/static/app/components/pipeline/usePipeline.tsx +++ b/static/app/components/pipeline/usePipeline.tsx @@ -198,8 +198,6 @@ export function usePipeline< error: new Error((response.data?.detail as string) ?? 'Pipeline error'), }); break; - default: - break; } }, onError: (error: Error, _variables, context) => { diff --git a/static/app/components/searchQueryBuilder/tokens/useSearchTokenCombobox.tsx b/static/app/components/searchQueryBuilder/tokens/useSearchTokenCombobox.tsx index 5cc7e93b437db5..59d384efc6d769 100644 --- a/static/app/components/searchQueryBuilder/tokens/useSearchTokenCombobox.tsx +++ b/static/app/components/searchQueryBuilder/tokens/useSearchTokenCombobox.tsx @@ -114,8 +114,6 @@ export function useSearchTokenCombobox( case 'ArrowRight': state.selectionManager.setFocusedKey(null); break; - default: - break; } }; diff --git a/static/app/views/alerts/list/rules/alertLastIncidentActivationInfo.tsx b/static/app/views/alerts/list/rules/alertLastIncidentActivationInfo.tsx index 63933dfadfe752..53583dd0d8ebe0 100644 --- a/static/app/views/alerts/list/rules/alertLastIncidentActivationInfo.tsx +++ b/static/app/views/alerts/list/rules/alertLastIncidentActivationInfo.tsx @@ -79,7 +79,6 @@ function LastMetricAlertIncident({rule}: {rule: MetricAlert}) { } export function AlertLastIncidentActivationInfo({rule}: Props) { - // eslint-disable-next-line default-case switch (rule.type) { case CombinedAlertType.UPTIME: return ; diff --git a/static/app/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState.tsx b/static/app/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState.tsx index a6a0321cdf0166..c1820a384d0d31 100644 --- a/static/app/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState.tsx +++ b/static/app/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState.tsx @@ -1176,8 +1176,6 @@ export function useWidgetBuilderState(): { setTextContent(action.payload); break; } - default: - break; } }, [ diff --git a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx index 4cc71e78dec4da..d7cc2572da0013 100644 --- a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx +++ b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx @@ -386,8 +386,6 @@ function onTriggerCellAction(actions: Actions, value: string | number) { case Actions.EXCLUDE: setFilter(filter.filter(_value => _value !== value)); break; - default: - break; } } `} diff --git a/static/app/views/discover/table/cellAction.tsx b/static/app/views/discover/table/cellAction.tsx index 95dabbceb94942..481ef92763d174 100644 --- a/static/app/views/discover/table/cellAction.tsx +++ b/static/app/views/discover/table/cellAction.tsx @@ -317,8 +317,6 @@ function getInternalLinkActionLabel(field: string): string { return t('Open issue'); case FieldKey.REPLAY_ID: return t('Open replay'); - default: - break; } return t('Open link'); } diff --git a/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx b/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx index d1b63c1ad9e917..e787f23a3e34b6 100644 --- a/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx +++ b/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx @@ -65,8 +65,6 @@ export function useAttributeBreakdownsTooltipAction(): TooltipActions['onAction' case Actions.COPY_TO_CLIPBOARD: copyToClipboard.copy(value); break; - default: - break; } }, [addSearchFilter, setGroupBys, copyToClipboard] @@ -136,7 +134,6 @@ export function useAttributeBreakdownsTooltip({ dom.addEventListener('click', handleClickAnywhere); dom.addEventListener('mouseleave', handleMouseLeave); - // eslint-disable-next-line consistent-return return () => { dom.removeEventListener('click', handleClickAnywhere); dom.removeEventListener('mouseleave', handleMouseLeave); @@ -185,7 +182,6 @@ export function useAttributeBreakdownsTooltip({ document.addEventListener('mouseover', handleMouseOver); document.addEventListener('mouseout', handleMouseOut); - // eslint-disable-next-line consistent-return return () => { document.removeEventListener('click', handleClickActions); document.removeEventListener('mouseover', handleMouseOver); diff --git a/static/app/views/explore/hooks/useCrossEventQueries.tsx b/static/app/views/explore/hooks/useCrossEventQueries.tsx index f0f74ae4fc9dd2..0e2c12b5431109 100644 --- a/static/app/views/explore/hooks/useCrossEventQueries.tsx +++ b/static/app/views/explore/hooks/useCrossEventQueries.tsx @@ -30,8 +30,6 @@ export function useCrossEventQueries() { case 'logs': logQuery.push(crossEvent.query); break; - default: - break; } } diff --git a/static/app/views/issueDetails/groupEventCarousel.tsx b/static/app/views/issueDetails/groupEventCarousel.tsx index bed30de0333f21..54d7ffa48295cb 100644 --- a/static/app/views/issueDetails/groupEventCarousel.tsx +++ b/static/app/views/issueDetails/groupEventCarousel.tsx @@ -230,8 +230,6 @@ function EventNavigationDropdown({group, event, isDisabled}: GroupEventNavigatio }); break; } - default: - break; } }} /> diff --git a/static/app/views/issueList/editableIssueViewHeader.tsx b/static/app/views/issueList/editableIssueViewHeader.tsx index 6ee4dbebaa4d0b..03567dd6a2ffc5 100644 --- a/static/app/views/issueList/editableIssueViewHeader.tsx +++ b/static/app/views/issueList/editableIssueViewHeader.tsx @@ -101,8 +101,6 @@ function EditingViewTitle({ case 'Escape': stopEditing(); break; - default: - break; } }; diff --git a/static/app/views/navigation/navigationTour.tsx b/static/app/views/navigation/navigationTour.tsx index 737448fb027f59..b5e238099ef3af 100644 --- a/static/app/views/navigation/navigationTour.tsx +++ b/static/app/views/navigation/navigationTour.tsx @@ -208,8 +208,6 @@ export function NavigationTourProvider({children}: {children: React.ReactNode}) navigate(target, {replace: true}); } break; - default: - break; } }, [activeGroup, navigate, organization] diff --git a/static/app/views/performance/newTraceDetails/traceHeader/breadcrumbs.tsx b/static/app/views/performance/newTraceDetails/traceHeader/breadcrumbs.tsx index 5926de65e5ce4e..1c085b85f546fc 100644 --- a/static/app/views/performance/newTraceDetails/traceHeader/breadcrumbs.tsx +++ b/static/app/views/performance/newTraceDetails/traceHeader/breadcrumbs.tsx @@ -360,7 +360,6 @@ function getInsightsModuleBreadcrumbs( }); break; - case ModuleName.CACHE: default: break; } diff --git a/static/app/views/replays/detail/network/truncateJson/completeJson.ts b/static/app/views/replays/detail/network/truncateJson/completeJson.ts index dc14939e65f5e3..6d234052317987 100644 --- a/static/app/views/replays/detail/network/truncateJson/completeJson.ts +++ b/static/app/views/replays/detail/network/truncateJson/completeJson.ts @@ -37,7 +37,7 @@ export function completeJson(incompleteJson: string, stack: JsonToken[]): string for (let i = lastPos; i >= 0; i--) { const step = stack[i]; - // eslint-disable-next-line default-case + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check switch (step) { case OBJ: json = `${json}}`; diff --git a/static/app/views/replays/detail/network/truncateJson/evaluateJson.ts b/static/app/views/replays/detail/network/truncateJson/evaluateJson.ts index b330ba09e505fc..8aeacf351b259d 100644 --- a/static/app/views/replays/detail/network/truncateJson/evaluateJson.ts +++ b/static/app/views/replays/detail/network/truncateJson/evaluateJson.ts @@ -41,7 +41,6 @@ function _evaluateJsonPos(stack: JsonToken[], json: string, pos: number): void { return; } - // eslint-disable-next-line default-case switch (char) { case '{': _handleObj(stack, curStep); diff --git a/static/app/views/settings/components/dataScrubbing/modals/form/attributeField.tsx b/static/app/views/settings/components/dataScrubbing/modals/form/attributeField.tsx index 5d5017f550f976..6ecec3a06e8ea0 100644 --- a/static/app/views/settings/components/dataScrubbing/modals/form/attributeField.tsx +++ b/static/app/views/settings/components/dataScrubbing/modals/form/attributeField.tsx @@ -181,8 +181,6 @@ export function AttributeField({ case 'Escape': setShowSuggestions(false); break; - default: - break; } }, [showSuggestions, filteredSuggestions, activeSuggestion, handleClickSuggestion] diff --git a/static/eslint/eslintPluginScraps/src/rules/restrict-jsx-slot-children.ts b/static/eslint/eslintPluginScraps/src/rules/restrict-jsx-slot-children.ts index ea176b65e6171a..01c62e74a8bfc5 100644 --- a/static/eslint/eslintPluginScraps/src/rules/restrict-jsx-slot-children.ts +++ b/static/eslint/eslintPluginScraps/src/rules/restrict-jsx-slot-children.ts @@ -112,12 +112,12 @@ function isReactFragment(nameNode: TSESTree.JSXTagNameExpression) { */ function getDisplayName(node: TSESTree.JSXTagNameExpression): string { switch (node.type) { + case AST_NODE_TYPES.JSXIdentifier: + return node.name; case AST_NODE_TYPES.JSXMemberExpression: return `${getDisplayName(node.object)}.${node.property.name}`; case AST_NODE_TYPES.JSXNamespacedName: return `${node.namespace.name}:${node.name.name}`; - default: - return node.name; } } diff --git a/static/gsAdmin/components/customers/organizationStatus.tsx b/static/gsAdmin/components/customers/organizationStatus.tsx index 2a386841ed8468..edec561a505d62 100644 --- a/static/gsAdmin/components/customers/organizationStatus.tsx +++ b/static/gsAdmin/components/customers/organizationStatus.tsx @@ -24,8 +24,6 @@ export function OrganizationStatus({orgStatus}: Props) { case 'deletion_in_progress': message = 'This organization in the process of being deleted.'; break; - default: - break; } if (!message) { diff --git a/static/gsAdmin/components/relocationBadge.tsx b/static/gsAdmin/components/relocationBadge.tsx index 7343270de20858..a6a7abbb1adaeb 100644 --- a/static/gsAdmin/components/relocationBadge.tsx +++ b/static/gsAdmin/components/relocationBadge.tsx @@ -26,8 +26,6 @@ export function RelocationBadge({data}: Props) { text = 'Paused'; theme = 'warning'; break; - default: - break; } if ( From 0669c854c308d149bbaed11457c7cd12f8365793 Mon Sep 17 00:00:00 2001 From: Amir Mujacic Date: Tue, 31 Mar 2026 15:53:02 +0200 Subject: [PATCH 10/21] feat(perforce): Implement get_file() for source context (#111768) Implement get_file() in PerforceClient using p4 print to fetch file contents from the depot. This enables the SCM source context feature to display inline code in stack traces for Perforce-hosted projects. Converts P4Exception to ApiError(404) so the source context caller handles errors consistently with other integrations. --------- Co-authored-by: Claude Opus 4.6 --- src/sentry/integrations/perforce/client.py | 57 ++++++++++++++++++++-- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/src/sentry/integrations/perforce/client.py b/src/sentry/integrations/perforce/client.py index c48a0cb113d009..f7a8cdf9d422c6 100644 --- a/src/sentry/integrations/perforce/client.py +++ b/src/sentry/integrations/perforce/client.py @@ -608,10 +608,61 @@ def get_file( self, repo: Repository, path: str, ref: str | None, codeowners: bool = False ) -> str: """ - Get file contents from Perforce depot. - Required by abstract base class but not used (CODEOWNERS). + Get file contents from Perforce depot using ``p4 print``. + + API docs: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_print.html + + Perforce supports two revision specifiers: + - ``@N`` — changelist number (global point-in-time snapshot) + - ``#N`` — file revision (per-file version counter) + + Args: + repo: Repository object containing depot path config + path: File path relative to depot root + ref: Revision specifier. Accepts: + - ``"#3"`` → file revision 3 + - ``"@42"`` → changelist 42 + - ``"42"`` → treated as changelist (``@42``) + - ``None`` → head revision + codeowners: Not used for Perforce + + Returns: + File contents as a UTF-8 string + + Raises: + ApiError(404): File not found in depot + ApiError(500): Perforce connection or command error """ - raise NotImplementedError("get_file is not supported for Perforce") + with self._connect() as p4: + depot_path = self.build_depot_path(repo, path) + + if ref and "#" not in depot_path and "@" not in depot_path: + if ref.startswith("#") or ref.startswith("@"): + depot_path = f"{depot_path}{ref}" + else: + depot_path = f"{depot_path}@{ref}" + + try: + result = p4.run("print", depot_path) + except P4Exception as e: + error_msg = str(e) + if "no such file" in error_msg.lower() or "not in client view" in error_msg.lower(): + raise ApiError(error_msg, code=404) + raise ApiError(error_msg, code=500) + + # p4 print returns a list: first element is file metadata dict, + # remaining elements are file content strings/bytes + if not result or not isinstance(result[0], dict): + raise ApiError(f"File not found: {depot_path}", code=404) + + content_parts = result[1:] + if not content_parts: + return "" + + return "".join( + part.decode("utf-8", errors="replace") if isinstance(part, bytes) else part + for part in content_parts + ) def create_comment(self, repo: str, issue_id: str, data: dict[str, Any]) -> Any: """Create comment. Not applicable for Perforce.""" From 776182a1c2e7458b84e537179a3c6ece2694871a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Dorfmeister=20=F0=9F=94=AE?= Date: Tue, 31 Mar 2026 16:23:35 +0200 Subject: [PATCH 11/21] ref: update TanStack Form (#111873) This fixes a cleanup bug when conditionally rendered fields have their own field level validations --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com> --- package.json | 4 +- pnpm-lock.yaml | 124 ++++++++++++++++++++++++++++++------------------- 2 files changed, 78 insertions(+), 50 deletions(-) diff --git a/package.json b/package.json index a435196733a0ef..5067ac389d35c5 100644 --- a/package.json +++ b/package.json @@ -123,8 +123,8 @@ "@swc/plugin-emotion": "14.3.0", "@tanstack/query-async-storage-persister": "5.83.1", "@tanstack/react-devtools": "0.9.9", - "@tanstack/react-form": "^1.28.0", - "@tanstack/react-form-devtools": "0.2.17", + "@tanstack/react-form": "1.28.6", + "@tanstack/react-form-devtools": "0.2.20", "@tanstack/react-pacer": "^0.17.0", "@tanstack/react-pacer-devtools": "0.5.3", "@tanstack/react-query": "5.85.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb102858f13f2f..b2383794ae1590 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,11 +232,11 @@ importers: specifier: 0.9.9 version: 0.9.9(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.11) '@tanstack/react-form': - specifier: ^1.28.0 - version: 1.28.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: 1.28.6 + version: 1.28.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-form-devtools': - specifier: 0.2.17 - version: 0.2.17(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11) + specifier: 0.2.20 + version: 0.2.20(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11) '@tanstack/react-pacer': specifier: ^0.17.0 version: 0.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -3613,10 +3613,6 @@ packages: resolution: {integrity: sha512-eq+PpuutUyubXu+ycC1GIiVwBs86NF/8yYJJAKSpPcJLWl6R/761F1H4F/9ziX6zKezltFUH1ah3Cz8Ah+KJrw==} engines: {node: '>=18'} - '@tanstack/devtools-event-client@0.4.0': - resolution: {integrity: sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw==} - engines: {node: '>=18'} - '@tanstack/devtools-event-client@0.4.1': resolution: {integrity: sha512-GRxmPw4OHZ2oZeIEUkEwt/NDvuEqzEYRAjzUVMs+I0pd4C7k1ySOiuJK2CqF+K/yEAR3YZNkW3ExrpDarh9Vwg==} engines: {node: '>=18'} @@ -3633,6 +3629,12 @@ packages: peerDependencies: solid-js: '>=1.9.7' + '@tanstack/devtools-ui@0.5.1': + resolution: {integrity: sha512-T9JjAdqMSnxsVO6AQykD5vhxPF4iFLKtbYxee/bU3OLlk446F5C1220GdCmhDSz7y4lx+m8AvIS0bq6zzvdDUA==} + engines: {node: '>=18'} + peerDependencies: + solid-js: '>=1.9.7' + '@tanstack/devtools-utils@0.3.0': resolution: {integrity: sha512-JgApXVrgtgSLIPrm/QWHx0u6c9Ji0MNMDWhwujapj8eMzux5aOfi+2Ycwzj0A0qITXA12SEPYV3HC568mDtYmQ==} engines: {node: '>=18'} @@ -3654,6 +3656,28 @@ packages: vue: optional: true + '@tanstack/devtools-utils@0.4.0': + resolution: {integrity: sha512-KsGzYhA8L/fCNgyyMyoUy+TKtx+DjNbzWwqH6wXL48Llzo7kvV9RynYJlaO8Qkzwm+NdHXSgsljQNjQ3CKPpZA==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@types/react': '>=17.0.0' + preact: '>=10.0.0' + react: '>=17.0.0' + solid-js: '>=1.9.7' + vue: '>=3.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + preact: + optional: true + react: + optional: true + solid-js: + optional: true + vue: + optional: true + '@tanstack/devtools@0.10.10': resolution: {integrity: sha512-/SSJcyhZtq1+HB9UViz8e0Y7Io4JPIbyJ0Wns5ENwzSHsNOAheANA8QnEQBVXETY/osCcaGAVOyVfGgn5aBJKA==} engines: {node: '>=18'} @@ -3665,14 +3689,11 @@ packages: peerDependencies: eslint: ^8.57.0 || ^9.0.0 - '@tanstack/form-core@1.28.0': - resolution: {integrity: sha512-MX3YveB6SKHAJ2yUwp+Ca/PCguub8bVEnLcLUbFLwdkSRMkP0lMGdaZl+F0JuEgZw56c6iFoRyfILhS7OQpydA==} - - '@tanstack/form-core@1.28.4': - resolution: {integrity: sha512-2eox5ePrJ6kvA1DXD5QHk/GeGr3VFZ0uYR63UgQOe7bUg6h1JfXaIMqTjZK9sdGyE4oRNqFpoW54H0pZM7nObQ==} + '@tanstack/form-core@1.28.6': + resolution: {integrity: sha512-4zroxL6VDj5O+w7l3dYZnUeL/h30KtNSV7UWzKAL7cl+8clMFdISPDlDlluS37As7oqvPVKo8B83VlIBvgmRog==} - '@tanstack/form-devtools@0.2.17': - resolution: {integrity: sha512-1i+hAmhbyOm4lJOoQWvDA41bHFFyeSjA79kHxirU2FCSGWk58u1+eyvw6+dUweWfJLW2yTFU9VyQBbFSbG0qig==} + '@tanstack/form-devtools@0.2.20': + resolution: {integrity: sha512-4cW/eU5DBTrWP53mxwHKp4NQWTIQ3XCA91pMWK7dFNNClIwFnxoSJoKwyUa6b8kRIO6uq1Sjk2mhkAtj5kB22A==} peerDependencies: solid-js: '>=1.9.9' @@ -3711,13 +3732,13 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-form-devtools@0.2.17': - resolution: {integrity: sha512-0asnrx9xBRuHptFh6hOB6sl1PrPb4gmjxHU/25L+lnNc0+OLgP13t3+CpC8qS95mdg2HJ42wieG1SvZTsuj0Nw==} + '@tanstack/react-form-devtools@0.2.20': + resolution: {integrity: sha512-aXtorJ7p3TbzOapjaxbjGX/c0uQh/wbYSwgzFt3qatNMb1xL4HM/j00Bx7hDENZNBCf8MF8YEEtvpBmnGb4rnQ==} peerDependencies: react: ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/react-form@1.28.0': - resolution: {integrity: sha512-ibLcf5QkTogV0Ly944CuqGxWTpHyreNA4Cy8Wtky7zE9wtE3HVapQt4/hUuXo51zihfTkv5URiXpoTSKF5Xosg==} + '@tanstack/react-form@1.28.6': + resolution: {integrity: sha512-dRxwKeNW3uuJvf0sXsIQ2compFMnIJNk9B436Lx0fqkqK+CBvA1tNmEdX+faoCpuQ5Wua3c8ahVibJ65cpkijA==} peerDependencies: '@tanstack/react-start': '*' react: ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -3764,8 +3785,8 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/react-store@0.8.0': - resolution: {integrity: sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow==} + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -3784,15 +3805,15 @@ packages: '@tanstack/store@0.7.7': resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} - '@tanstack/store@0.8.0': - resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} - '@tanstack/store@0.8.1': resolution: {integrity: sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw==} '@tanstack/store@0.9.1': resolution: {integrity: sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==} + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@tanstack/virtual-core@3.13.6': resolution: {integrity: sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==} @@ -13314,8 +13335,6 @@ snapshots: '@tanstack/devtools-event-client@0.3.4': {} - '@tanstack/devtools-event-client@0.4.0': {} - '@tanstack/devtools-event-client@0.4.1': {} '@tanstack/devtools-ui@0.4.4(csstype@3.2.3)(solid-js@1.9.11)': @@ -13335,6 +13354,15 @@ snapshots: transitivePeerDependencies: - csstype + '@tanstack/devtools-ui@0.5.1(csstype@3.2.3)(solid-js@1.9.11)': + dependencies: + clsx: 2.1.1 + dayjs: 1.11.19 + goober: 2.1.18(csstype@3.2.3) + solid-js: 1.9.11 + transitivePeerDependencies: + - csstype + '@tanstack/devtools-utils@0.3.0(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11)': dependencies: '@tanstack/devtools-ui': 0.4.4(csstype@3.2.3)(solid-js@1.9.11) @@ -13345,6 +13373,12 @@ snapshots: transitivePeerDependencies: - csstype + '@tanstack/devtools-utils@0.4.0(@types/react@19.2.1)(react@19.2.3)(solid-js@1.9.11)': + optionalDependencies: + '@types/react': 19.2.1 + react: 19.2.3 + solid-js: 1.9.11 + '@tanstack/devtools@0.10.10(csstype@3.2.3)(solid-js@1.9.11)': dependencies: '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.11) @@ -13369,23 +13403,17 @@ snapshots: - supports-color - typescript - '@tanstack/form-core@1.28.0': - dependencies: - '@tanstack/devtools-event-client': 0.4.0 - '@tanstack/pacer-lite': 0.1.1 - '@tanstack/store': 0.7.7 - - '@tanstack/form-core@1.28.4': + '@tanstack/form-core@1.28.6': dependencies: - '@tanstack/devtools-event-client': 0.4.0 + '@tanstack/devtools-event-client': 0.4.1 '@tanstack/pacer-lite': 0.1.1 '@tanstack/store': 0.9.1 - '@tanstack/form-devtools@0.2.17(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11)': + '@tanstack/form-devtools@0.2.20(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11)': dependencies: - '@tanstack/devtools-ui': 0.4.4(csstype@3.2.3)(solid-js@1.9.11) - '@tanstack/devtools-utils': 0.3.0(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11) - '@tanstack/form-core': 1.28.4 + '@tanstack/devtools-ui': 0.5.1(csstype@3.2.3)(solid-js@1.9.11) + '@tanstack/devtools-utils': 0.4.0(@types/react@19.2.1)(react@19.2.3)(solid-js@1.9.11) + '@tanstack/form-core': 1.28.6 clsx: 2.1.1 dayjs: 1.11.19 goober: 2.1.18(csstype@3.2.3) @@ -13446,10 +13474,10 @@ snapshots: - solid-js - utf-8-validate - '@tanstack/react-form-devtools@0.2.17(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11)': + '@tanstack/react-form-devtools@0.2.20(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11)': dependencies: - '@tanstack/devtools-utils': 0.3.0(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11) - '@tanstack/form-devtools': 0.2.17(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11) + '@tanstack/devtools-utils': 0.4.0(@types/react@19.2.1)(react@19.2.3)(solid-js@1.9.11) + '@tanstack/form-devtools': 0.2.20(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11) react: 19.2.3 transitivePeerDependencies: - '@types/react' @@ -13458,10 +13486,10 @@ snapshots: - solid-js - vue - '@tanstack/react-form@1.28.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-form@1.28.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/form-core': 1.28.0 - '@tanstack/react-store': 0.8.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/form-core': 1.28.6 + '@tanstack/react-store': 0.9.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 transitivePeerDependencies: - react-dom @@ -13512,9 +13540,9 @@ snapshots: react-dom: 19.2.3(react@19.2.3) use-sync-external-store: 1.5.0(react@19.2.3) - '@tanstack/react-store@0.8.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-store@0.9.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/store': 0.8.0 + '@tanstack/store': 0.9.3 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) use-sync-external-store: 1.6.0(react@19.2.3) @@ -13532,12 +13560,12 @@ snapshots: '@tanstack/store@0.7.7': {} - '@tanstack/store@0.8.0': {} - '@tanstack/store@0.8.1': {} '@tanstack/store@0.9.1': {} + '@tanstack/store@0.9.3': {} + '@tanstack/virtual-core@3.13.6': {} '@testing-library/dom@10.4.1': From 713c19fdd5c8876d84bfef933e3c3d33ceb58e09 Mon Sep 17 00:00:00 2001 From: Lazar Nikolov Date: Tue, 31 Mar 2026 10:36:59 -0400 Subject: [PATCH 12/21] fix(ui): Prevent code block overflow in GuidedSteps onboarding (#111657) StepDetails in GuidedSteps is a grid item in a `34px 1fr` grid layout. The default `min-width: auto` allows it to expand beyond the 1fr column when content (like a code block with long lines) has a large intrinsic width. This caused the *Metrics* onboarding step 2 to overlap the adjacent preview column. Adding `min-width: 0` lets the grid item respect its track size, so the code block's existing `overflow-x: auto` produces a horizontal scrollbar instead of breaking the layout. ## Before image ## After image Co-authored-by: Claude Opus 4.6 --- static/app/components/guidedSteps/guidedSteps.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/static/app/components/guidedSteps/guidedSteps.tsx b/static/app/components/guidedSteps/guidedSteps.tsx index 113e2dc8497db6..419b34849b5e5f 100644 --- a/static/app/components/guidedSteps/guidedSteps.tsx +++ b/static/app/components/guidedSteps/guidedSteps.tsx @@ -373,6 +373,7 @@ const ChildrenWrapper = styled('div')<{isActive: boolean}>` const StepDetails = styled('div')` overflow: hidden; grid-area: details; + min-width: 0; `; GuidedSteps.Step = Step; From 3d7aeaa477d0b67934ab5f20d50312106350c9a0 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki <44422760+DominikB2014@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:38:51 -0400 Subject: [PATCH 13/21] fix(dashboards): Stop passing list page query params to dashboard details (#111815) The dashboards list page was forwarding its query parameters (`sort`, `filter`, `query`) through to dashboard detail page links. When navigating from the list to a specific dashboard, these list-page-specific params would appear in the detail URL, which could interfere with the dashboard's own filter handling (e.g., saved global filters). These query params aren't really used in dashboard details, and has potential to mess up the state of certain widgets that might confuse these params Removes query param forwarding from: - `DashboardGrid` card links - `DashboardTable` name links - `onCreate`, `loadDashboard`, and `onPreview` navigation in the manage index Fixes DAIN-1432 Co-authored-by: Claude Opus 4.6 --- .../dashboards/manage/dashboardGrid.spec.tsx | 49 +++++-------------- .../views/dashboards/manage/dashboardGrid.tsx | 15 +----- .../dashboards/manage/dashboardTable.spec.tsx | 22 ++------- .../dashboards/manage/dashboardTable.tsx | 14 +----- .../views/dashboards/manage/index.spec.tsx | 5 +- static/app/views/dashboards/manage/index.tsx | 20 ++------ 6 files changed, 20 insertions(+), 105 deletions(-) diff --git a/static/app/views/dashboards/manage/dashboardGrid.spec.tsx b/static/app/views/dashboards/manage/dashboardGrid.spec.tsx index d9ac3775c24853..d9c2b8c8888053 100644 --- a/static/app/views/dashboards/manage/dashboardGrid.spec.tsx +++ b/static/app/views/dashboards/manage/dashboardGrid.spec.tsx @@ -1,9 +1,7 @@ import {DashboardListItemFixture} from 'sentry-fixture/dashboard'; -import {LocationFixture} from 'sentry-fixture/locationFixture'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {UserFixture} from 'sentry-fixture/user'; -import {initializeOrg} from 'sentry-test/initializeOrg'; import { render, renderGlobalModal, @@ -25,8 +23,6 @@ describe('Dashboards - DashboardGrid', () => { features: ['dashboards-basic', 'dashboards-edit', 'discover-query'], }); - const {router} = initializeOrg(); - beforeEach(() => { MockApiClient.clearMockResponses(); @@ -106,7 +102,6 @@ describe('Dashboards - DashboardGrid', () => { onDashboardsChange={jest.fn()} organization={organization} dashboards={[]} - location={router.location} columnCount={3} rowCount={3} /> @@ -124,7 +119,6 @@ describe('Dashboards - DashboardGrid', () => { onDashboardsChange={jest.fn()} organization={organization} dashboards={dashboards} - location={router.location} columnCount={3} rowCount={3} /> @@ -140,7 +134,6 @@ describe('Dashboards - DashboardGrid', () => { onDashboardsChange={jest.fn()} organization={organization} dashboards={dashboards} - location={router.location} columnCount={3} rowCount={3} /> @@ -156,42 +149,28 @@ describe('Dashboards - DashboardGrid', () => { ); }); - it('persists global selection headers', () => { + it('does not forward query params from the list page to dashboard links', () => { render( - ); - - expect(screen.getByRole('link', {name: 'Dashboard 1'})).toHaveAttribute( - 'href', - '/organizations/org-slug/dashboard/1/?statsPeriod=7d' - ); - }); - - it('does not forward search query parameter to dashboard links', () => { - render( - + />, + { + initialRouterConfig: { + location: { + pathname: '/organizations/org-slug/dashboards/', + query: {sort: 'title', query: 'agent'}, + }, + }, + } ); expect(screen.getByRole('link', {name: 'Dashboard 1'})).toHaveAttribute( 'href', - '/organizations/org-slug/dashboard/1/?statsPeriod=7d' + '/organizations/org-slug/dashboard/1/' ); }); @@ -200,7 +179,6 @@ describe('Dashboards - DashboardGrid', () => { { { { { onDashboardsChange={jest.fn()} organization={organization} dashboards={dashboards} - location={router.location} columnCount={3} rowCount={3} />, @@ -383,7 +357,6 @@ describe('Dashboards - DashboardGrid', () => { onDashboardsChange={jest.fn()} organization={organization} dashboards={dashboards} - location={router.location} columnCount={3} rowCount={3} />, diff --git a/static/app/views/dashboards/manage/dashboardGrid.tsx b/static/app/views/dashboards/manage/dashboardGrid.tsx index a70f67c68cc06f..8ce43191c6b3c1 100644 --- a/static/app/views/dashboards/manage/dashboardGrid.tsx +++ b/static/app/views/dashboards/manage/dashboardGrid.tsx @@ -1,6 +1,5 @@ import {Fragment, useEffect, useState} from 'react'; import styled from '@emotion/styled'; -import type {Location} from 'history'; import isEqual from 'lodash/isEqual'; import {Button} from '@sentry/scraps/button'; @@ -36,7 +35,6 @@ type Props = { api: Client; columnCount: number; dashboards: DashboardListItem[] | undefined; - location: Location; onDashboardsChange: () => void; organization: Organization; rowCount: number; @@ -46,7 +44,6 @@ type Props = { function DashboardGrid({ api, organization, - location, dashboards, onDashboardsChange, rowCount, @@ -167,13 +164,6 @@ function DashboardGrid({ return ; } - // TODO(__SENTRY_USING_REACT_ROUTER_SIX): We can remove this later, react - // router 6 handles empty query objects without appending a trailing ? - const {query: _searchQuery, ...queryWithoutSearch} = location.query; - const queryLocation = { - ...(Object.keys(queryWithoutSearch).length > 0 ? {query: queryWithoutSearch} : {}), - }; - function renderMiniDashboards() { // on pagination, render no dashboards to show placeholders while loading if ( @@ -189,10 +179,7 @@ function DashboardGrid({ {dashboardLimitData => ( { ); }); - it('persists global selection headers', async () => { - render( - - ); - - expect(await screen.findByRole('link', {name: 'Dashboard 1'})).toHaveAttribute( - 'href', - '/organizations/org-slug/dashboard/1/?statsPeriod=7d' - ); - }); - - it('does not forward search query parameter to dashboard links', async () => { + it('does not forward query params from the list page to dashboard links', async () => { render( { dashboards={dashboards} location={{ ...LocationFixture(), - query: {query: 'agent', statsPeriod: '7d'}, + query: {sort: 'title', query: 'agent', statsPeriod: '7d'}, }} /> ); expect(await screen.findByRole('link', {name: 'Dashboard 1'})).toHaveAttribute( 'href', - '/organizations/org-slug/dashboard/1/?statsPeriod=7d' + '/organizations/org-slug/dashboard/1/' ); }); diff --git a/static/app/views/dashboards/manage/dashboardTable.tsx b/static/app/views/dashboards/manage/dashboardTable.tsx index dd581aaa3f87fc..77911b2e7c2ec5 100644 --- a/static/app/views/dashboards/manage/dashboardTable.tsx +++ b/static/app/views/dashboards/manage/dashboardTable.tsx @@ -146,13 +146,6 @@ function DashboardTable({ {key: ResponseKeys.CREATED, name: t('Created'), width: COL_WIDTH_UNDEFINED}, ]; - // TODO(__SENTRY_USING_REACT_ROUTER_SIX): We can remove this later, react - // router 6 handles empty query objects without appending a trailing ? - const {query: _searchQuery, ...queryWithoutSearch} = location.query; - const queryLocation = { - ...(Object.keys(queryWithoutSearch).length > 0 ? {query: queryWithoutSearch} : {}), - }; - function renderHeadCell(column: GridColumnOrder) { if (column.key in SortKeys) { const urlSort = decodeScalar(location.query.sort, 'mydashboards'); @@ -212,12 +205,7 @@ function DashboardTable({ if (column.key === ResponseKeys.NAME) { return ( - + {dataRow[ResponseKeys.NAME]} diff --git a/static/app/views/dashboards/manage/index.spec.tsx b/static/app/views/dashboards/manage/index.spec.tsx index a9e0fd8276493d..1aed58bc3fe193 100644 --- a/static/app/views/dashboards/manage/index.spec.tsx +++ b/static/app/views/dashboards/manage/index.spec.tsx @@ -123,10 +123,7 @@ describe('Dashboards > Detail', () => { await userEvent.click(await screen.findByTestId('dashboard-create')); - expect(mockNavigate).toHaveBeenCalledWith({ - pathname: '/organizations/org-slug/dashboards/new/', - query: {}, - }); + expect(mockNavigate).toHaveBeenCalledWith('/organizations/org-slug/dashboards/new/'); }); it('can sort', async () => { diff --git a/static/app/views/dashboards/manage/index.tsx b/static/app/views/dashboards/manage/index.tsx index 4e90d37c651b40..83f33befc65cc4 100644 --- a/static/app/views/dashboards/manage/index.tsx +++ b/static/app/views/dashboards/manage/index.tsx @@ -494,7 +494,6 @@ function ManageDashboards() { api={api} dashboards={dashboards} organization={organization} - location={location} onDashboardsChange={() => refetchDashboards()} isLoading={isLoading} rowCount={rowCount} @@ -543,19 +542,12 @@ function ManageDashboards() { ); } - const {query: _query, ...queryWithoutSearch} = location.query; - function onCreate() { trackAnalytics('dashboards_manage.create.start', { organization, }); - navigate( - normalizeUrl({ - pathname: `/organizations/${organization.slug}/dashboards/new/`, - query: queryWithoutSearch, - }) - ); + navigate(normalizeUrl(`/organizations/${organization.slug}/dashboards/new/`)); } async function onAdd(dashboard: DashboardDetails) { @@ -578,10 +570,7 @@ function ManageDashboards() { function loadDashboard(dashboardId: string) { navigate( - normalizeUrl({ - pathname: `/organizations/${organization.slug}/dashboards/${dashboardId}/`, - query: queryWithoutSearch, - }) + normalizeUrl(`/organizations/${organization.slug}/dashboards/${dashboardId}/`) ); } @@ -592,10 +581,7 @@ function ManageDashboards() { }); navigate( - normalizeUrl({ - pathname: `/organizations/${organization.slug}/dashboards/new/${dashboardId}/`, - query: queryWithoutSearch, - }) + normalizeUrl(`/organizations/${organization.slug}/dashboards/new/${dashboardId}/`) ); } From 3b595183ec994dd9c8b685f4f012f111e2e8b9f8 Mon Sep 17 00:00:00 2001 From: Nikki Kapadia <72356613+nikkikapadia@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:52:11 -0400 Subject: [PATCH 14/21] fix(dashboards): numeric literal not registering in equations builder (#111793) there was an issue in dashboards where a numeric literal in the equation would not fully register and only the first character would register. This is because the number was only committed on enter. We want the number to be committed on blur as well because only committing on enter is not intuitive. This left some widgets to not be saved properly (which sucks). Ticket will be linked by linear for more context. --- .../arithmeticBuilder/token/index.spec.tsx | 22 +++++++++++++++++++ .../arithmeticBuilder/token/literal.tsx | 9 +++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/static/app/components/arithmeticBuilder/token/index.spec.tsx b/static/app/components/arithmeticBuilder/token/index.spec.tsx index ef5996d6b5913b..a6b934fc775119 100644 --- a/static/app/components/arithmeticBuilder/token/index.spec.tsx +++ b/static/app/components/arithmeticBuilder/token/index.spec.tsx @@ -878,6 +878,28 @@ describe('token', () => { expect(await screen.findByRole('row', {name: '10'})).toBeInTheDocument(); expect(screen.getByTestId(dataTestId)).toBeInTheDocument(); }); + + it('completes literal on blur', async () => { + const dispatch = jest.fn(); + render(); + + expect(await screen.findByRole('row', {name: '1'})).toBeInTheDocument(); + + const input = screen.getByRole('textbox', { + name: 'Add a literal', + }); + expect(input).toBeInTheDocument(); + + await userEvent.click(input); + expect(input).toHaveFocus(); + expect(input).toHaveValue('1'); + await userEvent.type(input, '00'); + + // Tab away to trigger blur without pressing Enter + await userEvent.tab(); + + expect(await screen.findByRole('row', {name: '100'})).toBeInTheDocument(); + }); }); describe('ArithmeticTokenOperator', () => { diff --git a/static/app/components/arithmeticBuilder/token/literal.tsx b/static/app/components/arithmeticBuilder/token/literal.tsx index 3e2d4dd9f5c3b0..ecb3209a2fe673 100644 --- a/static/app/components/arithmeticBuilder/token/literal.tsx +++ b/static/app/components/arithmeticBuilder/token/literal.tsx @@ -83,8 +83,15 @@ function InternalInput({item, state, token}: InternalInputProps) { }, [updateSelectionIndex]); const onInputBlur = useCallback(() => { + const trimmed = inputValue.trim(); + const text = validateLiteral(trimmed) ? trimmed : token.text; + dispatch({ + text, + type: 'REPLACE_TOKEN', + token, + }); resetInputValue(); - }, [resetInputValue]); + }, [dispatch, inputValue, token, resetInputValue]); const onInputChange = useCallback( (evt: ChangeEvent) => { From e6e56f4807e677bd2dfe76826264ca066fded61d Mon Sep 17 00:00:00 2001 From: Tony Le Date: Tue, 31 Mar 2026 10:54:31 -0400 Subject: [PATCH 15/21] ref(spans): unregister distributed payloads options (#111821) These options will be unused and are good to be unregistered. --- src/sentry/options/defaults.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 086a8c0d285f0d..a3f6df779157af 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -3252,27 +3252,6 @@ flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) -# Write payload sets to per-span distributed keys AND merged keys. -# Flusher reads merged keys as before. -register( - "spans.buffer.write-distributed-payloads", - default=False, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) -# Switch flusher to read from distributed keys instead of merged. -register( - "spans.buffer.read-distributed-payloads", - default=False, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) -# Set to False to stop writing merged keys and skip set merges. -# Disable after read-distributed-payloads is stable. Rollback: re-enable -# this flag to resume merged writes before reverting read-distributed-payloads. -register( - "spans.buffer.write-merged-payloads", - default=True, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) # List of trace_ids to enable debug logging for. Empty = debug off. # When set, logs detailed metrics about zunionstore set sizes, key existence, and trace structure. register( From 22c3aad7ead2185afec945dc9227ba47db6912d4 Mon Sep 17 00:00:00 2001 From: Max Topolsky <30879163+mtopo27@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:56:25 -0400 Subject: [PATCH 16/21] fix(preprod): Post NEUTRAL status check when all artifacts are skipped (#111811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When all artifacts for a commit are filtered/skipped by project settings, the status check task returned early without updating any previously posted IN_PROGRESS check on GitHub. This left checks stuck as `in_progress` forever. Confirmed in production: artifact uploads would initially post an IN_PROGRESS check (before the filter decision), then a subsequent task invocation would see all artifacts as SKIPPED and hit the early return at `tasks.py:256-261` — never updating the check to a terminal state. Now the `all_skipped` branch posts a NEUTRAL completed check with a "Size analysis skipped" message and a link to configure status check rules, matching the existing pattern for the no-quota case. --------- Co-authored-by: Claude Opus 4.6 --- .../preprod/vcs/status_checks/size/tasks.py | 83 ++++++++++--------- .../vcs/status_checks/size/templates.py | 9 ++ .../size/test_status_checks_tasks.py | 12 ++- 3 files changed, 62 insertions(+), 42 deletions(-) diff --git a/src/sentry/preprod/vcs/status_checks/size/tasks.py b/src/sentry/preprod/vcs/status_checks/size/tasks.py index 65094b6c3e6bd4..9a0c32fd8b3e5c 100644 --- a/src/sentry/preprod/vcs/status_checks/size/tasks.py +++ b/src/sentry/preprod/vcs/status_checks/size/tasks.py @@ -37,6 +37,7 @@ ) from sentry.preprod.url_utils import get_preprod_artifact_url from sentry.preprod.vcs.status_checks.size.templates import ( + format_all_skipped_messages, format_no_quota_messages, format_status_check_messages, ) @@ -258,53 +259,57 @@ def create_preprod_status_check_task( "preprod.status_checks.create.all_skipped", extra={"artifact_id": preprod_artifact.id}, ) - return - - url_artifact = ( - preprod_artifact - if preprod_artifact.id in {a.id for a in all_artifacts} - else all_artifacts[0] - ) - target_url = get_preprod_artifact_url(url_artifact) - completed_at: datetime | None = None - - # Check if any artifact hit quota limits - show neutral status with quota message - if _has_no_quota_artifact(all_artifacts, size_metrics_map): - title, subtitle, summary = format_no_quota_messages() + title, subtitle, summary = format_all_skipped_messages(preprod_artifact.project) status = StatusCheckStatus.NEUTRAL completed_at = preprod_artifact.date_updated + target_url = get_preprod_artifact_url(preprod_artifact) triggered_rules: list[TriggeredRule] = [] else: - rules = _get_status_check_rules(preprod_artifact.project) - base_artifact_map, base_size_metrics_map = _fetch_base_size_metrics(all_artifacts) - - status, triggered_rules = _compute_overall_status( - all_artifacts, - size_metrics_map, - rules=rules, - base_artifact_map=base_artifact_map, - base_metrics_by_artifact=base_size_metrics_map, - approvals_map=approvals_map, + url_artifact = ( + preprod_artifact + if preprod_artifact.id in {a.id for a in all_artifacts} + else all_artifacts[0] ) + target_url = get_preprod_artifact_url(url_artifact) + completed_at = None - title, subtitle, summary = format_status_check_messages( - all_artifacts, - size_metrics_map, - status, - preprod_artifact.project, - base_artifact_map, - base_size_metrics_map, - triggered_rules, - ) - - if GITHUB_STATUS_CHECK_STATUS_MAPPING[status] == GitHubCheckStatus.COMPLETED: - completed_at = preprod_artifact.date_updated - - # When no rules are configured, always show neutral status. - # When rules exist, show actual status (in_progress, failure, success). - if not rules: + # Check if any artifact hit quota limits - show neutral status with quota message + if _has_no_quota_artifact(all_artifacts, size_metrics_map): + title, subtitle, summary = format_no_quota_messages() status = StatusCheckStatus.NEUTRAL completed_at = preprod_artifact.date_updated + triggered_rules = [] + else: + rules = _get_status_check_rules(preprod_artifact.project) + base_artifact_map, base_size_metrics_map = _fetch_base_size_metrics(all_artifacts) + + status, triggered_rules = _compute_overall_status( + all_artifacts, + size_metrics_map, + rules=rules, + base_artifact_map=base_artifact_map, + base_metrics_by_artifact=base_size_metrics_map, + approvals_map=approvals_map, + ) + + title, subtitle, summary = format_status_check_messages( + all_artifacts, + size_metrics_map, + status, + preprod_artifact.project, + base_artifact_map, + base_size_metrics_map, + triggered_rules, + ) + + if GITHUB_STATUS_CHECK_STATUS_MAPPING[status] == GitHubCheckStatus.COMPLETED: + completed_at = preprod_artifact.date_updated + + # When no rules are configured, always show neutral status. + # When rules exist, show actual status (in_progress, failure, success). + if not rules: + status = StatusCheckStatus.NEUTRAL + completed_at = preprod_artifact.date_updated try: check_id = provider.create_status_check( diff --git a/src/sentry/preprod/vcs/status_checks/size/templates.py b/src/sentry/preprod/vcs/status_checks/size/templates.py index f25498f5e385f1..080355030aa044 100644 --- a/src/sentry/preprod/vcs/status_checks/size/templates.py +++ b/src/sentry/preprod/vcs/status_checks/size/templates.py @@ -20,6 +20,15 @@ def format_no_quota_messages() -> tuple[str, str, str]: return str(title), str(subtitle), str(summary) +def format_all_skipped_messages(project: Project) -> tuple[str, str, str]: + """Format status check messages when all artifacts are filtered/skipped.""" + title = _SIZE_ANALYZER_TITLE_BASE + subtitle = _("Size analysis skipped") + settings_url = _get_settings_url(project) + summary = str(_format_configure_link(project, settings_url)) + return str(title), str(subtitle), str(summary) + + def format_status_check_messages( artifacts: list[PreprodArtifact], size_metrics_map: dict[int, list[PreprodArtifactSizeMetrics]], diff --git a/tests/sentry/preprod/vcs/status_checks/size/test_status_checks_tasks.py b/tests/sentry/preprod/vcs/status_checks/size/test_status_checks_tasks.py index 08cc8cc5d2423c..bb2b420b456db6 100644 --- a/tests/sentry/preprod/vcs/status_checks/size/test_status_checks_tasks.py +++ b/tests/sentry/preprod/vcs/status_checks/size/test_status_checks_tasks.py @@ -1432,8 +1432,8 @@ def test_skipped_artifacts_not_included_in_status_check(self) -> None: assert "com.valid" in kwargs["summary"] assert "com.skipped" not in kwargs["summary"] - def test_all_skipped_artifacts_no_status_check(self) -> None: - """No status check created when all artifacts are SKIPPED.""" + def test_all_skipped_artifacts_shows_neutral_status(self) -> None: + """NEUTRAL status check posted when all artifacts are SKIPPED.""" artifact = self._create_preprod_artifact(state=PreprodArtifact.ArtifactState.PROCESSED) PreprodArtifactSizeMetrics.objects.create( preprod_artifact=artifact, @@ -1449,7 +1449,13 @@ def test_all_skipped_artifacts_no_status_check(self) -> None: with client_patch, provider_patch: with self.tasks(): create_preprod_status_check_task(artifact.id) - mock_provider.create_status_check.assert_not_called() + + mock_provider.create_status_check.assert_called_once() + kwargs = mock_provider.create_status_check.call_args.kwargs + assert kwargs["status"] == StatusCheckStatus.NEUTRAL + assert "skipped" in kwargs["subtitle"].lower() + assert "Configure" in kwargs["summary"] + assert kwargs["completed_at"] is not None def test_no_quota_shows_neutral_status(self) -> None: """NO_QUOTA artifacts trigger neutral status with quota exceeded message.""" From 7c22ba9c3de5552bd533bf2a3b192bddb7f0dbb2 Mon Sep 17 00:00:00 2001 From: geoffg-sentry <165922362+geoffg-sentry@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:34:41 -0400 Subject: [PATCH 17/21] Prevent code generation setting bypasses (#111294) It's the second time we have a bypass of this organization setting which allowed for code gen, new branches, and new PRs. Fixing this upstream in chokepoints rather than in all the endpoints since RPC callbacks, on-complete hooks, and slack flows lacked enforcement too. Added check to: - coding_agent.py launch_coding_agents_for_run() - autofix_agent.py trigger_coding_agent_handoff() - autofix.py update_autofix() - SeerExplorerClient.push_changes() - group_autofix_update.py & organization_seer_explorer_update.py to inspect payload_type and request.data type to 403 when appropriate Plus a bunch of tests --- src/sentry/seer/autofix/autofix.py | 12 ++++- src/sentry/seer/autofix/autofix_agent.py | 13 ++++- src/sentry/seer/autofix/coding_agent.py | 5 +- src/sentry/seer/autofix/constants.py | 2 + .../seer/endpoints/group_autofix_update.py | 16 +++++- .../organization_seer_explorer_update.py | 15 +++++- src/sentry/seer/explorer/client.py | 7 +++ .../test_organization_coding_agents.py | 16 ++++++ tests/sentry/seer/autofix/test_autofix.py | 42 ++++++++++++++++ .../sentry/seer/autofix/test_autofix_agent.py | 24 +++++++++ .../sentry/seer/autofix/test_coding_agent.py | 17 +++++++ .../seer/endpoints/test_group_ai_autofix.py | 18 +++++++ .../endpoints/test_group_autofix_update.py | 37 ++++++++++++++ .../test_organization_seer_explorer_update.py | 49 +++++++++++++++++++ 14 files changed, 267 insertions(+), 6 deletions(-) diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index 3dbc0005d5abee..99e260fcf69e7c 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -25,10 +25,11 @@ from sentry.issues.grouptype import WebVitalsGroup from sentry.models.commitauthor import CommitAuthor from sentry.models.group import Group +from sentry.models.organization import Organization from sentry.models.project import Project from sentry.search.eap.types import SearchResolverConfig from sentry.search.events.types import EventsResponse, SnubaParams -from sentry.seer.autofix.constants import AutofixReferrer +from sentry.seer.autofix.constants import CODING_PAYLOAD_TYPES, AutofixReferrer from sentry.seer.autofix.types import ( AutofixCreatePRPayload, AutofixSelectRootCausePayload, @@ -832,6 +833,15 @@ def update_autofix( """ Issue an update to an autofix run. Intentionally matching the output of trigger_autofix. """ + if payload.get("type") in CODING_PAYLOAD_TYPES: + try: + org = Organization.objects.get(id=organization_id) + if not org.get_option("sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT): + return Response( + {"detail": "Code generation is disabled for this organization"}, status=403 + ) + except Organization.DoesNotExist: + return Response({"detail": "Organization not found"}, status=404) data = AutofixUpdateRequest(organization_id=organization_id, run_id=run_id, payload=payload) body = orjson.dumps(data) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 16bca7feffb63f..2e0898cc375f7c 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -6,7 +6,9 @@ from typing import TYPE_CHECKING, Literal from pydantic import BaseModel +from rest_framework.exceptions import PermissionDenied +from sentry.constants import ENABLE_SEER_CODING_DEFAULT from sentry.seer.autofix.artifact_schemas import ( ImpactAssessmentArtifact, RootCauseArtifact, @@ -428,7 +430,11 @@ def trigger_coding_agent_handoff( Returns: Dictionary with 'successes' and 'failures' lists """ - # Fetch project preferences for repos and auto_create_pr setting + if not group.organization.get_option( + "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT + ): + raise PermissionDenied("Code generation is disabled for this organization") + auto_create_pr = False repo_definitions: list[SeerRepoDefinition] = [] try: @@ -509,6 +515,11 @@ def trigger_push_changes( state: SeerRunState | None = None, repo_name: str | None = None, ): + if not group.organization.get_option( + "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT + ): + raise PermissionDenied("Code generation is disabled for this organization") + client = get_autofix_explorer_client(group) if state is None: diff --git a/src/sentry/seer/autofix/coding_agent.py b/src/sentry/seer/autofix/coding_agent.py index 76b68531da5b00..ad9e9fd3cca7db 100644 --- a/src/sentry/seer/autofix/coding_agent.py +++ b/src/sentry/seer/autofix/coding_agent.py @@ -12,7 +12,7 @@ from rest_framework.exceptions import APIException, NotFound, PermissionDenied, ValidationError from sentry import features -from sentry.constants import ObjectStatus +from sentry.constants import ENABLE_SEER_CODING_DEFAULT, ObjectStatus from sentry.integrations.claude_code.integration import ( ClaudeCodeIntegrationMetadata, ) @@ -428,6 +428,9 @@ def launch_coding_agents_for_run( except Organization.DoesNotExist: raise NotFound("Organization not found") + if not organization.get_option("sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT): + raise PermissionDenied("Code generation is disabled for this organization") + integration = None installation: CodingAgentIntegration | None = None client: CodingAgentClient | None = None diff --git a/src/sentry/seer/autofix/constants.py b/src/sentry/seer/autofix/constants.py index eb24d76b441eba..28c08290afcfaf 100644 --- a/src/sentry/seer/autofix/constants.py +++ b/src/sentry/seer/autofix/constants.py @@ -1,5 +1,7 @@ import enum +CODING_PAYLOAD_TYPES = frozenset({"select_solution", "create_branch", "create_pr"}) + # An issue group must have >= this number of occurrences in order to be # a target for 'workflow' autofix. AUTOFIX_AUTOMATION_OCCURRENCE_THRESHOLD = 10 diff --git a/src/sentry/seer/endpoints/group_autofix_update.py b/src/sentry/seer/endpoints/group_autofix_update.py index 09a815365de226..a32535a492a6f3 100644 --- a/src/sentry/seer/endpoints/group_autofix_update.py +++ b/src/sentry/seer/endpoints/group_autofix_update.py @@ -12,9 +12,10 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.helpers.deprecation import deprecated -from sentry.constants import CELL_API_DEPRECATION_DATE +from sentry.constants import CELL_API_DEPRECATION_DATE, ENABLE_SEER_CODING_DEFAULT from sentry.issues.endpoints.bases.group import GroupAiEndpoint from sentry.models.group import Group +from sentry.seer.autofix.constants import CODING_PAYLOAD_TYPES from sentry.seer.models import SeerApiError from sentry.seer.signed_seer_api import ( make_signed_seer_api_request, @@ -36,7 +37,7 @@ def post(self, request: Request, group: Group) -> Response: """ Send an update event to autofix for a given group. """ - if not request.data: + if not request.data or not isinstance(request.data, dict): return Response(status=400, data={"error": "Need a body with a run_id and payload"}) user = request.user @@ -46,6 +47,17 @@ def post(self, request: Request, group: Group) -> Response: data={"error": "You must be authenticated to use this endpoint"}, ) + payload = request.data.get("payload", {}) + payload_type = payload.get("type") if isinstance(payload, dict) else None + if payload_type in CODING_PAYLOAD_TYPES: + if not group.organization.get_option( + "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT + ): + return Response( + status=403, + data={"detail": "Code generation is disabled for this organization"}, + ) + path = "/v1/automation/autofix/update" body = orjson.dumps( diff --git a/src/sentry/seer/endpoints/organization_seer_explorer_update.py b/src/sentry/seer/endpoints/organization_seer_explorer_update.py index 873425d846f000..722fedc69ac669 100644 --- a/src/sentry/seer/endpoints/organization_seer_explorer_update.py +++ b/src/sentry/seer/endpoints/organization_seer_explorer_update.py @@ -10,7 +10,9 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.constants import ENABLE_SEER_CODING_DEFAULT from sentry.models.organization import Organization +from sentry.seer.autofix.constants import CODING_PAYLOAD_TYPES from sentry.seer.explorer.client_utils import ( explorer_connection_pool, has_seer_explorer_access_with_detail, @@ -43,9 +45,20 @@ def post(self, request: Request, organization: Organization, run_id: int) -> Res if not has_access: return Response({"detail": error}, status=403) - if not request.data: + if not request.data or not isinstance(request.data, dict): return Response(status=400, data={"error": "Need a body with a payload"}) + payload = request.data.get("payload", {}) + payload_type = payload.get("type") if isinstance(payload, dict) else None + if payload_type in CODING_PAYLOAD_TYPES: + if not organization.get_option( + "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT + ): + return Response( + status=403, + data={"detail": "Code generation is disabled for this organization"}, + ) + path = "/v1/automation/explorer/update" body = orjson.dumps( diff --git a/src/sentry/seer/explorer/client.py b/src/sentry/seer/explorer/client.py index 5d8867ad06eab9..03648c3aaaa39c 100644 --- a/src/sentry/seer/explorer/client.py +++ b/src/sentry/seer/explorer/client.py @@ -11,6 +11,7 @@ from rest_framework.request import Request from sentry import features, options +from sentry.constants import ENABLE_SEER_CODING_DEFAULT from sentry.models.organization import Organization from sentry.models.project import Project from sentry.seer.explorer.client_models import ExplorerRun, ExplorerRunWithPrs, SeerRunState @@ -550,7 +551,13 @@ def push_changes( Raises: TimeoutError: If polling exceeds timeout SeerApiError: If the Seer API request fails + SeerPermissionError: If code generation is disabled for the organization """ + if not self.organization.get_option( + "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT + ): + raise SeerPermissionError("Code generation is disabled for this organization") + # Trigger PR creation payload: dict[str, Any] = {"type": "create_pr"} if repo_name: diff --git a/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py b/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py index 9057a0c8298fa9..00b735cb361ace 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py @@ -429,6 +429,22 @@ def test_github_copilot_not_shown_without_feature_flag(self) -> None: assert len(integrations) == 0 +class OrganizationCodingAgentsPostCodingDisabledTest(BaseOrganizationCodingAgentsTest): + """Test that the endpoint returns 403 when code generation is disabled. + + The check lives in launch_coding_agents_for_run() which raises PermissionDenied. + """ + + def test_post_blocked_when_coding_disabled(self): + self.organization.update_option("sentry:enable_seer_coding", False) + + data = {"integration_id": str(self.integration.id), "run_id": 123} + response = self.get_error_response( + self.organization.slug, method="post", status_code=403, **data + ) + assert response.data["detail"] == "Code generation is disabled for this organization" + + class OrganizationCodingAgentsPostParameterValidationTest(BaseOrganizationCodingAgentsTest): """Test class for POST endpoint parameter validation.""" diff --git a/tests/sentry/seer/autofix/test_autofix.py b/tests/sentry/seer/autofix/test_autofix.py index c2cfe8520bf475..fa4147bdfd69cb 100644 --- a/tests/sentry/seer/autofix/test_autofix.py +++ b/tests/sentry/seer/autofix/test_autofix.py @@ -1519,6 +1519,48 @@ def test_update_autofix_success(self, mock_request): assert response.status_code == 200 assert response.data == mock_response.json.return_value + @patch("sentry.seer.autofix.autofix.make_autofix_update_request") + def test_update_autofix_blocks_coding_payloads_when_disabled(self, mock_request): + from sentry.seer.autofix.autofix import update_autofix + from sentry.seer.autofix.types import AutofixCreatePRPayload, AutofixSelectSolutionPayload + + self.organization.update_option("sentry:enable_seer_coding", False) + + payloads: list[AutofixSelectSolutionPayload | AutofixCreatePRPayload] = [ + AutofixSelectSolutionPayload(type="select_solution"), + AutofixCreatePRPayload(type="create_pr"), + ] + for payload in payloads: + response = update_autofix( + organization_id=self.organization.id, + run_id=self.run_id, + payload=payload, + ) + + assert response.status_code == 403 + assert response.data["detail"] == "Code generation is disabled for this organization" + + mock_request.assert_not_called() + + @patch("sentry.seer.autofix.autofix.make_autofix_update_request") + def test_update_autofix_allows_select_root_cause_when_coding_disabled(self, mock_request): + from sentry.seer.autofix.autofix import update_autofix + + self.organization.update_option("sentry:enable_seer_coding", False) + mock_response = Mock() + mock_response.status = 200 + mock_response.json.return_value = {"run_id": self.run_id} + mock_request.return_value = mock_response + + response = update_autofix( + organization_id=self.organization.id, + run_id=self.run_id, + payload={"type": "select_root_cause", "cause_id": 1}, + ) + + assert response.status_code == 200 + mock_request.assert_called_once() + class TestPreResolveStacktraceFrames(TestCase): def _make_serialized_event(self, frames, platform="python"): diff --git a/tests/sentry/seer/autofix/test_autofix_agent.py b/tests/sentry/seer/autofix/test_autofix_agent.py index 16042285e06426..d966e571c417c5 100644 --- a/tests/sentry/seer/autofix/test_autofix_agent.py +++ b/tests/sentry/seer/autofix/test_autofix_agent.py @@ -1,5 +1,8 @@ from unittest.mock import MagicMock, patch +import pytest +from rest_framework.exceptions import PermissionDenied + from sentry.seer.autofix.autofix_agent import ( AutofixStep, build_step_prompt, @@ -774,6 +777,17 @@ def test_trigger_coding_agent_handoff_falls_back_when_relevant_repo_doesnt_match }, ) + def test_raises_permission_denied_when_coding_disabled(self): + self.organization.update_option("sentry:enable_seer_coding", False) + + with pytest.raises(PermissionDenied, match="Code generation is disabled"): + trigger_coding_agent_handoff( + group=self.group, + run_id=123, + referrer=AutofixReferrer.UNKNOWN, + integration_id=456, + ) + @patch("sentry.seer.autofix.autofix_agent.get_autofix_state") @patch("sentry.seer.autofix.autofix_agent.get_project_seer_preferences") @patch("sentry.seer.autofix.autofix_agent.SeerExplorerClient") @@ -875,6 +889,16 @@ def setUp(self): super().setUp() self.group = self.create_group(project=self.project) + def test_raises_permission_denied_when_coding_disabled(self): + self.organization.update_option("sentry:enable_seer_coding", False) + + with pytest.raises(PermissionDenied, match="Code generation is disabled"): + trigger_push_changes( + group=self.group, + run_id=123, + referrer=AutofixReferrer.UNKNOWN, + ) + @patch("sentry.seer.explorer.client.make_explorer_update_request") def test_passes_correct_pr_description_suffix(self, mock_post): """push_changes is called with pr_description_suffix matching the group's qualified short id.""" diff --git a/tests/sentry/seer/autofix/test_coding_agent.py b/tests/sentry/seer/autofix/test_coding_agent.py index 5824e64120003e..793e239f2e9488 100644 --- a/tests/sentry/seer/autofix/test_coding_agent.py +++ b/tests/sentry/seer/autofix/test_coding_agent.py @@ -1,6 +1,9 @@ from datetime import UTC, datetime from unittest.mock import MagicMock, patch +import pytest +from rest_framework.exceptions import PermissionDenied + from sentry.integrations.claude_code.utils import ClaudeSessionEvent from sentry.integrations.cursor.integration import CursorAgentIntegration from sentry.integrations.github_copilot.models import ( @@ -1096,3 +1099,17 @@ def test_caches_client_for_same_integration( mock_integration_service.get_integration.assert_called_once() assert mock_client.list_session_events.call_count == 2 + + +class TestLaunchCodingAgentsForRunCodingDisabled(TestCase): + def test_raises_permission_denied_when_coding_disabled(self): + from sentry.seer.autofix.coding_agent import launch_coding_agents_for_run + + self.organization.update_option("sentry:enable_seer_coding", False) + + with pytest.raises(PermissionDenied, match="Code generation is disabled"): + launch_coding_agents_for_run( + organization_id=self.organization.id, + run_id=123, + integration_id=1, + ) diff --git a/tests/sentry/seer/endpoints/test_group_ai_autofix.py b/tests/sentry/seer/endpoints/test_group_ai_autofix.py index 2eb85b223fb2f9..e442c1c2a0bee2 100644 --- a/tests/sentry/seer/endpoints/test_group_ai_autofix.py +++ b/tests/sentry/seer/endpoints/test_group_ai_autofix.py @@ -1165,6 +1165,24 @@ def test_open_pr_permission_error(self, mock_explorer_state_request): assert response.status_code == 404, f"Failed for {flag}: {response.data}" + def test_open_pr_coding_disabled(self): + self.login_as(user=self.user) + group = self.create_group() + self.organization.update_option("sentry:enable_seer_coding", False) + + for flag in EXPLORER_FLAGS: + with self.feature(flag): + response = self.client.post( + self._get_url(group.id, mode="explorer"), + data={ + "step": "open_pr", + "run_id": 123, + }, + format="json", + ) + + assert response.status_code == 403, f"Failed for {flag}: {response.data}" + @with_feature("organizations:gen-ai-features") @with_feature("organizations:seer-explorer") diff --git a/tests/sentry/seer/endpoints/test_group_autofix_update.py b/tests/sentry/seer/endpoints/test_group_autofix_update.py index cbcc8bbf5ae13f..2f20a9346240c3 100644 --- a/tests/sentry/seer/endpoints/test_group_autofix_update.py +++ b/tests/sentry/seer/endpoints/test_group_autofix_update.py @@ -90,3 +90,40 @@ def test_autofix_update_updates_last_triggered_field(self, mock_request): self.group.refresh_from_db() assert isinstance(self.group.seer_autofix_last_triggered, datetime) + + @patch("sentry.seer.endpoints.group_autofix_update.make_signed_seer_api_request") + def test_coding_payload_blocked_when_coding_disabled(self, mock_request: MagicMock) -> None: + self.organization.update_option("sentry:enable_seer_coding", False) + + for payload_type in ("select_solution", "create_branch", "create_pr"): + response = self.client.post( + self.url, + data={ + "run_id": 123, + "payload": {"type": payload_type}, + }, + format="json", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data["detail"] == "Code generation is disabled for this organization" + + mock_request.assert_not_called() + + @patch("sentry.seer.endpoints.group_autofix_update.make_signed_seer_api_request") + def test_select_root_cause_allowed_when_coding_disabled(self, mock_request: MagicMock) -> None: + self.organization.update_option("sentry:enable_seer_coding", False) + mock_request.return_value.status = 202 + mock_request.return_value.json.return_value = {} + + response = self.client.post( + self.url, + data={ + "run_id": 123, + "payload": {"type": "select_root_cause", "cause_id": 1}, + }, + format="json", + ) + + assert response.status_code == status.HTTP_202_ACCEPTED + mock_request.assert_called_once() diff --git a/tests/sentry/seer/endpoints/test_organization_seer_explorer_update.py b/tests/sentry/seer/endpoints/test_organization_seer_explorer_update.py index a7d0f3206f14aa..796ac9d598d12d 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_explorer_update.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_explorer_update.py @@ -140,3 +140,52 @@ def test_explorer_update_feature_flag_disabled(self, mock_has_access: MagicMock) assert response.status_code == status.HTTP_403_FORBIDDEN assert "Feature flag not enabled" in str(response.data) + + +@with_feature("organizations:seer-explorer") +@with_feature("organizations:gen-ai-features") +class TestOrganizationSeerExplorerUpdateCodingDisabled(APITestCase): + def setUp(self) -> None: + super().setUp() + self.login_as(user=self.user) + self.organization = self.create_organization(owner=self.user) + self.organization.flags.allow_joinleave = True + self.organization.save() + self.url = f"/api/0/organizations/{self.organization.slug}/seer/explorer-update/123/" + + @patch( + "sentry.seer.endpoints.organization_seer_explorer_update.has_seer_explorer_access_with_detail" + ) + @patch("sentry.seer.endpoints.organization_seer_explorer_update.make_signed_seer_api_request") + def test_coding_payload_blocked_when_coding_disabled( + self, mock_request: MagicMock, mock_has_access: MagicMock + ) -> None: + mock_has_access.return_value = (True, None) + self.organization.update_option("sentry:enable_seer_coding", False) + + for payload_type in ("select_solution", "create_branch", "create_pr"): + response = self.client.post( + self.url, data={"payload": {"type": payload_type}}, format="json" + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data["detail"] == "Code generation is disabled for this organization" + + mock_request.assert_not_called() + + @patch( + "sentry.seer.endpoints.organization_seer_explorer_update.has_seer_explorer_access_with_detail" + ) + @patch("sentry.seer.endpoints.organization_seer_explorer_update.make_signed_seer_api_request") + def test_non_coding_payload_allowed_when_coding_disabled( + self, mock_request: MagicMock, mock_has_access: MagicMock + ) -> None: + mock_has_access.return_value = (True, None) + self.organization.update_option("sentry:enable_seer_coding", False) + mock_request.return_value.status = 200 + mock_request.return_value.json.return_value = {} + + response = self.client.post( + self.url, data={"payload": {"type": "interrupt"}}, format="json" + ) + assert response.status_code == status.HTTP_202_ACCEPTED + mock_request.assert_called_once() From 2d9a8b4e58f3d01feffa9633c9028e1c37747b94 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Tue, 31 Mar 2026 11:42:34 -0400 Subject: [PATCH 18/21] feat(autofix): Use autofix endpoint for PR creation (#111888) The autofix endpoint has some special handling of PR creation for issue groups so we want to use that instead of the generic explorer update endpoint. Specifically, it'll append `Fixes ISSUE-123` to the end of the PR description so the issue is linked back to the sentry issue. --- .../autofix/useExplorerAutofix.spec.tsx | 79 +++++++++++++++++++ .../events/autofix/useExplorerAutofix.tsx | 19 +++-- 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/static/app/components/events/autofix/useExplorerAutofix.spec.tsx b/static/app/components/events/autofix/useExplorerAutofix.spec.tsx index b93385dd2f512d..c1a9fd7ef0cbd1 100644 --- a/static/app/components/events/autofix/useExplorerAutofix.spec.tsx +++ b/static/app/components/events/autofix/useExplorerAutofix.spec.tsx @@ -1,3 +1,6 @@ +import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {DiffFileType, DiffLineType} from 'sentry/components/events/autofix/types'; import { isCodeChangesArtifact, @@ -5,11 +8,14 @@ import { isPullRequestsArtifact, isRootCauseArtifact, isSolutionArtifact, + useExplorerAutofix, type RootCauseArtifact, type SolutionArtifact, } from 'sentry/components/events/autofix/useExplorerAutofix'; import type {Artifact} from 'sentry/views/seerExplorer/types'; +jest.mock('sentry/actionCreators/indicator'); + function makeValidArtifact(data: T): Artifact { return { key: 'artifact-1', @@ -233,3 +239,76 @@ describe('isCodingAgentsArtifact', () => { expect(isCodingAgentsArtifact([{not_an: 'agent'}])).toBe(false); }); }); + +describe('useExplorerAutofix - createPR', () => { + const GROUP_ID = '123'; + const AUTOFIX_URL = `/organizations/org-slug/issues/${GROUP_ID}/autofix/`; + + beforeEach(() => { + MockApiClient.clearMockResponses(); + MockApiClient.addMockResponse({ + url: AUTOFIX_URL, + method: 'GET', + body: {autofix: null}, + }); + }); + + it('sends correct POST request without repoName', async () => { + const mockPost = MockApiClient.addMockResponse({ + url: AUTOFIX_URL, + method: 'POST', + body: {}, + }); + + const {result} = renderHookWithProviders(() => useExplorerAutofix(GROUP_ID)); + + await act(() => result.current.createPR(42)); + + expect(mockPost).toHaveBeenCalledWith( + AUTOFIX_URL, + expect.objectContaining({ + method: 'POST', + query: {mode: 'explorer'}, + data: {step: 'open_pr', run_id: 42}, + }) + ); + }); + + it('includes repo_name when repoName is provided', async () => { + const mockPost = MockApiClient.addMockResponse({ + url: AUTOFIX_URL, + method: 'POST', + body: {}, + }); + + const {result} = renderHookWithProviders(() => useExplorerAutofix(GROUP_ID)); + + await act(() => result.current.createPR(42, 'org/repo')); + + expect(mockPost).toHaveBeenCalledWith( + AUTOFIX_URL, + expect.objectContaining({ + method: 'POST', + query: {mode: 'explorer'}, + data: {step: 'open_pr', run_id: 42, repo_name: 'org/repo'}, + }) + ); + }); + + it('calls addErrorMessage and throws on API error', async () => { + MockApiClient.addMockResponse({ + url: AUTOFIX_URL, + method: 'POST', + statusCode: 500, + body: {detail: 'Server error'}, + }); + + const {result} = renderHookWithProviders(() => useExplorerAutofix(GROUP_ID)); + + await expect(act(() => result.current.createPR(42))).rejects.toThrow(); + + await waitFor(() => { + expect(addErrorMessage).toHaveBeenCalledWith('Server error'); + }); + }); +}); diff --git a/static/app/components/events/autofix/useExplorerAutofix.tsx b/static/app/components/events/autofix/useExplorerAutofix.tsx index d85c79d80e7d36..1b0d3774061815 100644 --- a/static/app/components/events/autofix/useExplorerAutofix.tsx +++ b/static/app/components/events/autofix/useExplorerAutofix.tsx @@ -601,18 +601,21 @@ export function useExplorerAutofix( const createPR = useCallback( async (runId: number, repoName?: string) => { try { + const data: Record = { + step: 'open_pr', + run_id: runId, + }; + if (repoName) { + data.repo_name = repoName; + } await api.requestPromise( - getApiUrl('/organizations/$organizationIdOrSlug/seer/explorer-update/$runId/', { - path: {organizationIdOrSlug: orgSlug, runId}, + getApiUrl('/organizations/$organizationIdOrSlug/issues/$issueId/autofix/', { + path: {organizationIdOrSlug: orgSlug, issueId: groupId}, }), { method: 'POST', - data: { - payload: { - type: 'create_pr', - repo_name: repoName, - }, - }, + query: {mode: 'explorer'}, + data, } ); From 128112a7c3803c2808938db14cd13eeccbcb39a1 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 31 Mar 2026 08:44:30 -0700 Subject: [PATCH 19/21] feat(seer): Update default triggers for Code Review (#111829) Fixes https://linear.app/getsentry/issue/CW-1131/update-code-review-default-triggers --- src/sentry/constants.py | 1 - static/app/types/integrations.tsx | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/sentry/constants.py b/src/sentry/constants.py index f2aff76cadff2d..fda38cfaf0768a 100644 --- a/src/sentry/constants.py +++ b/src/sentry/constants.py @@ -727,7 +727,6 @@ class InsightModules(Enum): # Seer Org level default for code review triggers DEFAULT_CODE_REVIEW_TRIGGERS: list[str] = [ "on_ready_for_review", - "on_new_commit", ] SEER_DEFAULT_CODING_AGENT_DEFAULT = "seer" SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT = "code_changes" diff --git a/static/app/types/integrations.tsx b/static/app/types/integrations.tsx index 6cf1358bd2d2c1..643cfe07f05265 100644 --- a/static/app/types/integrations.tsx +++ b/static/app/types/integrations.tsx @@ -99,10 +99,7 @@ export interface RepositoryWithSettings extends Repository { }; } -export const DEFAULT_CODE_REVIEW_TRIGGERS: CodeReviewTrigger[] = [ - 'on_ready_for_review', - 'on_new_commit', -]; +export const DEFAULT_CODE_REVIEW_TRIGGERS: CodeReviewTrigger[] = ['on_ready_for_review']; /** * Integration Repositories from OrganizationIntegrationReposEndpoint From 880f02434fcd2e9d84ad5e3377cc11d74b5d6ceb Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 31 Mar 2026 08:50:09 -0700 Subject: [PATCH 20/21] ref(cmdk) simplify types (#111857) The type field is unnecessary as we can already discriminate on the `to`, `onAction` and the presence of children --- .../commandPalette/__stories__/components.tsx | 30 +-- .../makeCommandPaletteAction.tsx | 32 --- .../app/components/commandPalette/types.tsx | 3 - .../commandPalette/ui/commandPalette.spec.tsx | 27 +- .../commandPalette/ui/commandPalette.tsx | 15 +- .../components/commandPalette/ui/modal.tsx | 23 +- .../useCommandPaletteActions.mdx | 22 +- .../useCommandPaletteActions.tsx | 16 +- .../useCommandPaletteAnalytics.tsx | 12 +- .../useDsnLookupActions.spec.tsx | 4 +- .../commandPalette/useDsnLookupActions.tsx | 1 - .../useGlobalCommandPaletteActions.tsx | 232 ++++++++---------- 12 files changed, 177 insertions(+), 240 deletions(-) delete mode 100644 static/app/components/commandPalette/makeCommandPaletteAction.tsx diff --git a/static/app/components/commandPalette/__stories__/components.tsx b/static/app/components/commandPalette/__stories__/components.tsx index 5319bdbba4913a..24c3338432c58f 100644 --- a/static/app/components/commandPalette/__stories__/components.tsx +++ b/static/app/components/commandPalette/__stories__/components.tsx @@ -2,14 +2,10 @@ import {useCallback} from 'react'; import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import {CommandPaletteProvider} from 'sentry/components/commandPalette/context'; -import { - makeCommandPaletteCallback, - makeCommandPaletteGroup, - makeCommandPaletteLink, -} from 'sentry/components/commandPalette/makeCommandPaletteAction'; import type { CommandPaletteAction, - CommandPaletteActionWithKey, + CommandPaletteActionCallbackWithKey, + CommandPaletteActionLinkWithKey, } from 'sentry/components/commandPalette/types'; import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette'; import {useCommandPaletteActions} from 'sentry/components/commandPalette/useCommandPaletteActions'; @@ -25,8 +21,8 @@ export function CommandPaletteDemo() { const navigate = useNavigate(); const handleAction = useCallback( - (action: Exclude) => { - if (action.type === 'navigate') { + (action: CommandPaletteActionLinkWithKey | CommandPaletteActionCallbackWithKey) => { + if ('to' in action) { navigate(normalizeUrl(action.to)); } else { action.onAction(); @@ -35,31 +31,31 @@ export function CommandPaletteDemo() { [navigate] ); - const demoActions = [ - makeCommandPaletteLink({ + const demoActions: CommandPaletteAction[] = [ + { display: {label: 'Go to Flex story'}, to: '/stories/layout/flex/', groupingKey: 'navigate', - }), - makeCommandPaletteCallback({ + }, + { display: {label: 'Execute an action'}, groupingKey: 'help', onAction: () => { addSuccessMessage('Action executed'); }, - }), - makeCommandPaletteGroup({ + }, + { groupingKey: 'add', display: {label: 'Parent action'}, actions: [ - makeCommandPaletteCallback({ + { display: {label: 'Child action'}, onAction: () => { addSuccessMessage('Child action executed'); }, - }), + }, ], - }), + }, ]; return ( diff --git a/static/app/components/commandPalette/makeCommandPaletteAction.tsx b/static/app/components/commandPalette/makeCommandPaletteAction.tsx deleted file mode 100644 index 1de696ba149fdc..00000000000000 --- a/static/app/components/commandPalette/makeCommandPaletteAction.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { - CommandPaletteActionCallback, - CommandPaletteActionGroup, - CommandPaletteActionLink, -} from 'sentry/components/commandPalette/types'; - -export function makeCommandPaletteLink( - options: Omit -): CommandPaletteActionLink { - return { - ...options, - type: 'navigate', - }; -} - -export function makeCommandPaletteCallback( - options: Omit -): CommandPaletteActionCallback { - return { - ...options, - type: 'callback', - }; -} - -export function makeCommandPaletteGroup( - options: Omit -): CommandPaletteActionGroup { - return { - ...options, - type: 'group', - }; -} diff --git a/static/app/components/commandPalette/types.tsx b/static/app/components/commandPalette/types.tsx index e82a3485cb53a9..b585e964216381 100644 --- a/static/app/components/commandPalette/types.tsx +++ b/static/app/components/commandPalette/types.tsx @@ -23,7 +23,6 @@ interface CommonCommandPaletteAction { export interface CommandPaletteActionLink extends CommonCommandPaletteAction { /** Navigate to a route when selected */ to: LocationDescriptor; - type: 'navigate'; } export interface CommandPaletteActionCallback extends CommonCommandPaletteAction { @@ -32,7 +31,6 @@ export interface CommandPaletteActionCallback extends CommonCommandPaletteAction * Use the `to` prop if you want to navigate to a route. */ onAction: () => void; - type: 'callback'; } export type CommandPaletteActionChild = @@ -44,7 +42,6 @@ export interface CommandPaletteActionGroup< > extends CommonCommandPaletteAction { /** Nested actions to show when this action is selected */ actions: T[]; - type: 'group'; } export type CommandPaletteAction = diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index 1b652dbef6c74a..a21d87c95b81ec 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -23,8 +23,11 @@ jest.mock('@tanstack/react-virtual', () => ({ import {closeModal} from 'sentry/actionCreators/modal'; import * as modalActions from 'sentry/actionCreators/modal'; import {CommandPaletteProvider} from 'sentry/components/commandPalette/context'; -import type {CommandPaletteAction} from 'sentry/components/commandPalette/types'; -import type {CommandPaletteActionWithKey} from 'sentry/components/commandPalette/types'; +import type { + CommandPaletteAction, + CommandPaletteActionCallbackWithKey, + CommandPaletteActionLinkWithKey, +} from 'sentry/components/commandPalette/types'; import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette'; import {useCommandPaletteActions} from 'sentry/components/commandPalette/useCommandPaletteActions'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -44,8 +47,8 @@ function GlobalActionsComponent({ const navigate = useNavigate(); const handleAction = useCallback( - (action: Exclude) => { - if (action.type === 'navigate') { + (action: CommandPaletteActionLinkWithKey | CommandPaletteActionCallbackWithKey) => { + if ('to' in action) { navigate(action.to); } else { action.onAction(); @@ -73,13 +76,11 @@ const globalActions: CommandPaletteAction[] = [ display: { label: 'Go to route', }, - type: 'navigate', }, { to: '/other/', groupingKey: 'help', display: {label: 'Other'}, - type: 'navigate', }, { groupingKey: 'add', @@ -88,10 +89,8 @@ const globalActions: CommandPaletteAction[] = [ { onAction: onChild, display: {label: 'Child action'}, - type: 'callback', }, ], - type: 'group', }, ]; @@ -234,13 +233,11 @@ describe('CommandPalette', () => { it('actions are ranked by match quality — better matches appear first', async () => { const actions: CommandPaletteAction[] = [ { - type: 'navigate', to: '/a/', display: {label: 'Something with issues buried'}, groupingKey: 'navigate', }, { - type: 'navigate', to: '/b/', display: {label: 'Issues'}, groupingKey: 'navigate', @@ -260,13 +257,11 @@ describe('CommandPalette', () => { it('top-level actions rank before child actions when both match the query', async () => { const actions: CommandPaletteAction[] = [ { - type: 'group', display: {label: 'Group'}, groupingKey: 'navigate', - actions: [{type: 'navigate', to: '/child/', display: {label: 'Issues child'}}], + actions: [{to: '/child/', display: {label: 'Issues child'}}], }, { - type: 'navigate', to: '/top/', display: {label: 'Issues'}, groupingKey: 'navigate', @@ -286,7 +281,6 @@ describe('CommandPalette', () => { it('actions with matching keywords are included in results', async () => { const actions: CommandPaletteAction[] = [ { - type: 'navigate', to: '/shortcuts/', display: {label: 'Keyboard shortcuts'}, keywords: ['hotkeys', 'keybindings'], @@ -305,12 +299,11 @@ describe('CommandPalette', () => { it("searching within a drilled-in group filters that group's children", async () => { const actions: CommandPaletteAction[] = [ { - type: 'group', display: {label: 'Theme'}, groupingKey: 'navigate', actions: [ - {type: 'callback', onAction: jest.fn(), display: {label: 'Light'}}, - {type: 'callback', onAction: jest.fn(), display: {label: 'Dark'}}, + {onAction: jest.fn(), display: {label: 'Light'}}, + {onAction: jest.fn(), display: {label: 'Dark'}}, ], }, ]; diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index edca2225d4b2c9..2ea0996909390b 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -21,6 +21,8 @@ import {Text} from '@sentry/scraps/text'; import {useCommandPaletteActions} from 'sentry/components/commandPalette/context'; import type { + CommandPaletteActionCallbackWithKey, + CommandPaletteActionLinkWithKey, CommandPaletteActionWithKey, CommandPaletteGroupKey, } from 'sentry/components/commandPalette/types'; @@ -63,7 +65,9 @@ type CommandPaletteActionWithPriority = CommandPaletteActionWithKey & { }; interface CommandPaletteProps { - onAction: (action: Exclude) => void; + onAction: ( + action: CommandPaletteActionLinkWithKey | CommandPaletteActionCallbackWithKey + ) => void; } export function CommandPalette(props: CommandPaletteProps) { @@ -81,7 +85,8 @@ export function CommandPalette(props: CommandPaletteProps) { const displayedActions = useMemo(() => { if ( - state.action?.value.action.type === 'group' && + state.action !== null && + 'actions' in state.action.value.action && state.action.value.action.actions.length > 0 ) { return flattenActions(state.action.value.action.actions); @@ -174,7 +179,7 @@ export function CommandPalette(props: CommandPaletteProps) { return; } - if (action.type === 'group') { + if ('actions' in action) { analytics.recordGroupAction(action, resultIndex); dispatch({type: 'push action', action}); return; @@ -399,7 +404,7 @@ function makeMenuItemFromAction( {action.display.icon} ), - children: action.type === 'group' ? action.actions.map(makeMenuItemFromAction) : [], + children: 'actions' in action ? action.actions.map(makeMenuItemFromAction) : [], hideCheck: true, }; } @@ -428,7 +433,7 @@ function flattenActions( flattened.push({...action, priority: 0}); } - if (action.type === 'group' && action.actions.length > 0) { + if ('actions' in action && action.actions.length > 0) { const childParentLabel = parentLabel ? `${parentLabel} → ${action.display.label}` : action.display.label; diff --git a/static/app/components/commandPalette/ui/modal.tsx b/static/app/components/commandPalette/ui/modal.tsx index a3a16e68d8bb9e..27955990c25402 100644 --- a/static/app/components/commandPalette/ui/modal.tsx +++ b/static/app/components/commandPalette/ui/modal.tsx @@ -3,12 +3,14 @@ import {css} from '@emotion/react'; import type {ModalRenderProps} from 'sentry/actionCreators/modal'; import {closeModal} from 'sentry/actionCreators/modal'; -import type {CommandPaletteActionWithKey} from 'sentry/components/commandPalette/types'; +import type { + CommandPaletteActionCallbackWithKey, + CommandPaletteActionLinkWithKey, +} from 'sentry/components/commandPalette/types'; import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette'; import {useCommandPaletteState} from 'sentry/components/commandPalette/ui/commandPaletteStateContext'; import {useDsnLookupActions} from 'sentry/components/commandPalette/useDsnLookupActions'; import type {Theme} from 'sentry/utils/theme'; -import {unreachable} from 'sentry/utils/unreachable'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -19,18 +21,11 @@ export default function CommandPaletteModal({Body}: ModalRenderProps) { useDsnLookupActions(query); const handleSelect = useCallback( - (action: Exclude) => { - const actionType = action.type; - switch (actionType) { - case 'navigate': - navigate(normalizeUrl(action.to)); - break; - case 'callback': - action.onAction(); - break; - default: - unreachable(actionType); - break; + (action: CommandPaletteActionLinkWithKey | CommandPaletteActionCallbackWithKey) => { + if ('to' in action) { + navigate(normalizeUrl(action.to)); + } else { + action.onAction(); } closeModal(); }, diff --git a/static/app/components/commandPalette/useCommandPaletteActions.mdx b/static/app/components/commandPalette/useCommandPaletteActions.mdx index 9065be2e38565c..48b4704662ce1b 100644 --- a/static/app/components/commandPalette/useCommandPaletteActions.mdx +++ b/static/app/components/commandPalette/useCommandPaletteActions.mdx @@ -13,11 +13,6 @@ import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import {toggleCommandPalette} from 'sentry/actionCreators/modal'; import {CommandPaletteProvider} from 'sentry/components/commandPalette/context'; -import { - makeCommandPaletteCallback, - makeCommandPaletteGroup, - makeCommandPaletteLink, -} from 'sentry/components/commandPalette/makeCommandPaletteAction'; import {useCommandPaletteActions} from 'sentry/components/commandPalette/useCommandPaletteActions'; import * as Storybook from 'sentry/stories'; @@ -45,31 +40,30 @@ import {useCommandPaletteActions} from 'sentry/components/commandPalette/useComm function YourComponent() { useCommandPaletteActions([ - makeCommandPaletteLink({ + { display: {label: 'Go to Input story'}, to: '/stories/core/input/', groupingKey: 'navigate', - }), - makeCommandPaletteCallback({ + }, + { display: {label: 'Execute an action'}, groupingKey: 'help', onAction: () => { addSuccessMessage('Action executed'); }, - }), - makeCommandPaletteGroup({ + }, + { groupingKey: 'add', display: {label: 'Parent action'}, actions: [ - makeCommandPaletteCallback({ - key: 'child-action', + { display: {label: 'Child action'}, onAction: () => { addSuccessMessage('Child action executed'); }, - }), + }, ], - }), + }, ]); } ``` diff --git a/static/app/components/commandPalette/useCommandPaletteActions.tsx b/static/app/components/commandPalette/useCommandPaletteActions.tsx index c6947806709257..d36857f6317bc4 100644 --- a/static/app/components/commandPalette/useCommandPaletteActions.tsx +++ b/static/app/components/commandPalette/useCommandPaletteActions.tsx @@ -5,9 +5,8 @@ import {slugify} from 'sentry/utils/slugify'; import {useCommandPaletteRegistration} from './context'; import type { CommandPaletteAction, - CommandPaletteActionCallback, CommandPaletteActionCallbackWithKey, - CommandPaletteActionLink, + CommandPaletteActionChild, CommandPaletteActionLinkWithKey, CommandPaletteActionWithKey, } from './types'; @@ -17,9 +16,10 @@ function addKeysToActions( actions: CommandPaletteAction[] ): CommandPaletteActionWithKey[] { return actions.map(action => { - const actionKey = `${id}:${action.type}:${slugify(action.display.label)}`; + const kind = 'actions' in action ? 'group' : 'to' in action ? 'navigate' : 'callback'; + const actionKey = `${id}:${kind}:${slugify(action.display.label)}`; - if (action.type === 'group') { + if ('actions' in action) { return { ...action, actions: addKeysToChildActions(id, action.actions), @@ -36,13 +36,13 @@ function addKeysToActions( function addKeysToChildActions( id: string, - actions: Array + actions: CommandPaletteActionChild[] ): Array { return actions.map(action => { const label = action.display.label.toLowerCase().replace(/ /g, '-'); - const disambiguator = - action.type === 'navigate' ? `:${JSON.stringify(action.to)}` : ''; - const actionKey = `${id}:${action.type}:${label}${disambiguator}`; + const disambiguator = 'to' in action ? `:${JSON.stringify(action.to)}` : ''; + const kind = 'to' in action ? 'navigate' : 'callback'; + const actionKey = `${id}:${kind}:${label}${disambiguator}`; return { ...action, key: actionKey, diff --git a/static/app/components/commandPalette/useCommandPaletteAnalytics.tsx b/static/app/components/commandPalette/useCommandPaletteAnalytics.tsx index c657f92bb2453a..9b08eae2c19d63 100644 --- a/static/app/components/commandPalette/useCommandPaletteAnalytics.tsx +++ b/static/app/components/commandPalette/useCommandPaletteAnalytics.tsx @@ -2,7 +2,11 @@ import {useEffect, useMemo, useRef} from 'react'; import * as Sentry from '@sentry/react'; import uniqueId from 'lodash/uniqueId'; -import type {CommandPaletteActionWithKey} from 'sentry/components/commandPalette/types'; +import type { + CommandPaletteActionCallbackWithKey, + CommandPaletteActionLinkWithKey, + CommandPaletteActionWithKey, +} from 'sentry/components/commandPalette/types'; import { getActionPath, type LinkedList, @@ -32,7 +36,7 @@ function getLinkedListDepth(node: LinkedList | null): number { */ export function useCommandPaletteAnalytics(filteredActionCount: number): { recordAction: ( - action: Exclude, + action: CommandPaletteActionLinkWithKey | CommandPaletteActionCallbackWithKey, resultIndex: number, group: string ) => void; @@ -136,7 +140,7 @@ export function useCommandPaletteAnalytics(filteredActionCount: number): { return useMemo( () => ({ recordAction( - action: Exclude, + action: CommandPaletteActionLinkWithKey | CommandPaletteActionCallbackWithKey, resultIndex: number, group: string ) { @@ -149,7 +153,7 @@ export function useCommandPaletteAnalytics(filteredActionCount: number): { organization, action: label, query: s.state.query, - action_type: action.type, + action_type: 'to' in action ? 'navigate' : 'callback', group, result_index: resultIndex, session_id: s.sessionId, diff --git a/static/app/components/commandPalette/useDsnLookupActions.spec.tsx b/static/app/components/commandPalette/useDsnLookupActions.spec.tsx index f3436487815812..23a15d1cb81f67 100644 --- a/static/app/components/commandPalette/useDsnLookupActions.spec.tsx +++ b/static/app/components/commandPalette/useDsnLookupActions.spec.tsx @@ -19,7 +19,9 @@ function DsnLookupHarness({query}: {query: string}) { {actions.map(action => (
  • {action.display.label} diff --git a/static/app/components/commandPalette/useDsnLookupActions.tsx b/static/app/components/commandPalette/useDsnLookupActions.tsx index 7469526a8c7c86..e598c8e4622cba 100644 --- a/static/app/components/commandPalette/useDsnLookupActions.tsx +++ b/static/app/components/commandPalette/useDsnLookupActions.tsx @@ -42,7 +42,6 @@ export function useDsnLookupActions(query: string): void { } return getDsnNavTargets(data).map((target, i) => ({ - type: 'navigate' as const, to: target.to, display: { label: target.label, diff --git a/static/app/components/commandPalette/useGlobalCommandPaletteActions.tsx b/static/app/components/commandPalette/useGlobalCommandPaletteActions.tsx index 28b91c21750a26..53ebfc2b3414a1 100644 --- a/static/app/components/commandPalette/useGlobalCommandPaletteActions.tsx +++ b/static/app/components/commandPalette/useGlobalCommandPaletteActions.tsx @@ -2,11 +2,6 @@ import {ProjectAvatar} from '@sentry/scraps/avatar'; import {addLoadingMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {openInviteMembersModal} from 'sentry/actionCreators/modal'; -import { - makeCommandPaletteCallback, - makeCommandPaletteGroup, - makeCommandPaletteLink, -} from 'sentry/components/commandPalette/makeCommandPaletteAction'; import type { CommandPaletteAction, CommandPaletteActionChild, @@ -55,212 +50,202 @@ function useNavigationActions(): CommandPaletteAction[] { const {projects} = useProjects(); const issuesChildren: CommandPaletteActionChild[] = [ - makeCommandPaletteLink({ + { display: { label: t('Feed'), }, to: `${prefix}/issues/`, - }), - ...Object.values(ISSUE_TAXONOMY_CONFIG).map(config => - makeCommandPaletteLink({ - display: { - label: config.label, - }, - to: `${prefix}/issues/${config.key}/`, - }) - ), - makeCommandPaletteLink({ + }, + ...Object.values(ISSUE_TAXONOMY_CONFIG).map(config => ({ + display: { + label: config.label, + }, + to: `${prefix}/issues/${config.key}/`, + })), + { display: { label: t('User Feedback'), }, to: `${prefix}/issues/feedback/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('All Views'), }, to: `${prefix}/issues/views/`, - }), - ...starredViews.map(view => - makeCommandPaletteLink({ - display: { - label: view.label, - icon: , - }, - to: `${prefix}/issues/views/${view.id}/`, - }) - ), + }, + ...starredViews.map(view => ({ + display: { + label: view.label, + icon: , + }, + to: `${prefix}/issues/views/${view.id}/`, + })), ]; const exploreChildren: CommandPaletteActionChild[] = [ - makeCommandPaletteLink({ + { display: { label: t('Traces'), }, to: `${prefix}/explore/traces/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Logs'), }, to: `${prefix}/explore/logs/`, hidden: !organization.features.includes('ourlogs-enabled'), - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Discover'), }, to: `${prefix}/explore/discover/homepage/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Profiles'), }, to: `${prefix}/explore/profiling/`, hidden: !organization.features.includes('profiling'), - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Replays'), }, to: `${prefix}/explore/replays/`, hidden: !organization.features.includes('session-replay-ui'), - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Releases'), }, to: `${prefix}/explore/releases/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('All Queries'), }, to: `${prefix}/explore/saved-queries/`, - }), + }, ]; const dashboardsChildren: CommandPaletteActionChild[] = [ - makeCommandPaletteLink({ + { display: { label: t('All Dashboards'), }, to: `${prefix}/dashboards/`, - }), - ...starredDashboards.map(dashboard => - makeCommandPaletteLink({ - display: { - label: dashboard.title, - icon: , - }, - to: `${prefix}/dashboard/${dashboard.id}/`, - }) - ), + }, + ...starredDashboards.map(dashboard => ({ + display: { + label: dashboard.title, + icon: , + }, + to: `${prefix}/dashboard/${dashboard.id}/`, + })), ]; const insightsChildren: CommandPaletteActionChild[] = [ - makeCommandPaletteLink({ + { display: { label: t('Frontend'), }, to: `${prefix}/insights/${FRONTEND_LANDING_SUB_PATH}/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Backend'), }, to: `${prefix}/insights/${BACKEND_LANDING_SUB_PATH}/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Mobile'), }, to: `${prefix}/insights/${MOBILE_LANDING_SUB_PATH}/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Agents'), }, to: `${prefix}/insights/${AGENTS_LANDING_SUB_PATH}/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('MCP'), }, to: `${prefix}/insights/${MCP_LANDING_SUB_PATH}/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Crons'), }, to: `${prefix}/insights/crons/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Uptime'), }, to: `${prefix}/insights/uptime/`, hidden: !organization.features.includes('uptime'), - }), - makeCommandPaletteLink({ + }, + { display: { label: t('All Projects'), }, to: `${prefix}/insights/projects/`, - }), + }, ]; const settingsChildren: CommandPaletteActionChild[] = getUserOrgNavigationConfiguration().flatMap(item => - item.items.map(settingsChildItem => - makeCommandPaletteLink({ - display: { - label: settingsChildItem.title, - }, - to: settingsChildItem.path, - }) - ) + item.items.map(settingsChildItem => ({ + display: { + label: settingsChildItem.title, + }, + to: settingsChildItem.path, + })) ); const projectSettingsChildren: CommandPaletteActionChild[] = organization.features.includes('cmd-k-supercharged') - ? projects.map(project => - makeCommandPaletteLink({ - display: { - label: project.name, - icon: , - }, - to: `/settings/${slug}/projects/${project.slug}/`, - }) - ) + ? projects.map(project => ({ + display: { + label: project.name, + icon: , + }, + to: `/settings/${slug}/projects/${project.slug}/`, + })) : []; return [ - makeCommandPaletteGroup({ + { groupingKey: 'navigate', display: { label: t('Issues'), icon: , }, actions: issuesChildren, - }), - makeCommandPaletteGroup({ + }, + { groupingKey: 'navigate', display: { label: t('Explore'), icon: , }, actions: exploreChildren, - }), - makeCommandPaletteGroup({ + }, + { groupingKey: 'navigate', display: { label: t('Dashboards'), icon: , }, actions: dashboardsChildren, - }), - makeCommandPaletteGroup({ + }, + { groupingKey: 'navigate', display: { label: t('Insights'), @@ -268,26 +253,26 @@ function useNavigationActions(): CommandPaletteAction[] { }, actions: insightsChildren, hidden: !organization.features.includes('performance-view'), - }), - makeCommandPaletteGroup({ + }, + { groupingKey: 'navigate', display: { label: t('Settings'), icon: , }, actions: settingsChildren, - }), + }, organization.features.includes('cmd-k-supercharged') - ? makeCommandPaletteGroup({ + ? { groupingKey: 'navigate', display: { label: t('Project Settings'), icon: , }, actions: projectSettingsChildren, - }) + } : null, - ].filter(x => x !== null); + ].filter(x => x !== null) as CommandPaletteAction[]; } function useNavigationToggleCollapsed(): CommandPaletteAction { @@ -295,7 +280,6 @@ function useNavigationToggleCollapsed(): CommandPaletteAction { const isCollapsed = view !== 'expanded'; return { - type: 'callback', display: { label: isCollapsed ? t('Expand Navigation Sidebar') @@ -321,55 +305,55 @@ export function useGlobalCommandPaletteActions() { useCommandPaletteActions([ ...navigateActions, - makeCommandPaletteLink({ + { display: { label: t('Create Dashboard'), icon: , }, groupingKey: 'add', to: `${navPrefix}/dashboards/new/`, - }), - makeCommandPaletteLink({ + }, + { display: { label: t('Create Alert'), icon: , }, groupingKey: 'add', to: `${navPrefix}/issues/alerts/wizard/`, - }), - makeCommandPaletteLink({ + }, + { groupingKey: 'add', display: { label: t('Create Project'), icon: , }, to: `${navPrefix}/projects/new/`, - }), - makeCommandPaletteCallback({ + }, + { display: { label: t('Invite Members'), icon: , }, groupingKey: 'add', onAction: () => openInviteMembersModal(), - }), - makeCommandPaletteCallback({ + }, + { display: { label: t('Open Documentation'), icon: , }, groupingKey: 'help', onAction: () => window.open('https://docs.sentry.io', '_blank', 'noreferrer'), - }), - makeCommandPaletteCallback({ + }, + { display: { label: t('Join Discord'), icon: , }, groupingKey: 'help', onAction: () => window.open('https://discord.gg/sentry', '_blank', 'noreferrer'), - }), - makeCommandPaletteCallback({ + }, + { display: { label: t('Open GitHub Repository'), icon: , @@ -377,23 +361,23 @@ export function useGlobalCommandPaletteActions() { groupingKey: 'help', onAction: () => window.open('https://github.com/getsentry/sentry', '_blank', 'noreferrer'), - }), - makeCommandPaletteCallback({ + }, + { display: { label: t('View Changelog'), icon: , }, groupingKey: 'help', onAction: () => window.open('https://sentry.io/changelog/', '_blank', 'noreferrer'), - }), + }, navigationToggleAction, - makeCommandPaletteGroup({ + { display: { label: t('Change Color Theme'), icon: , }, actions: [ - makeCommandPaletteCallback({ + { display: { label: t('System'), }, @@ -402,8 +386,8 @@ export function useGlobalCommandPaletteActions() { await mutateUserOptions({theme: 'system'}); addSuccessMessage(t('Theme preference saved: System')); }, - }), - makeCommandPaletteCallback({ + }, + { display: { label: t('Light'), }, @@ -412,8 +396,8 @@ export function useGlobalCommandPaletteActions() { await mutateUserOptions({theme: 'light'}); addSuccessMessage(t('Theme preference saved: Light')); }, - }), - makeCommandPaletteCallback({ + }, + { display: { label: t('Dark'), }, @@ -422,8 +406,8 @@ export function useGlobalCommandPaletteActions() { await mutateUserOptions({theme: 'dark'}); addSuccessMessage(t('Theme preference saved: Dark')); }, - }), + }, ], - }), + }, ]); } From da1d85a50670fcabf448b12f1f76af528129ca3e Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 31 Mar 2026 08:55:07 -0700 Subject: [PATCH 21/21] ref(nav): Remove Layout.Page usage from individual views (#111775) This PR modifies the use of Layout.Page to something that is defined at the root layout level as opposed to the route level. This is done because a) folks were not aware they should use it, and we never enforced it and b) the page frame design comes with design opinionation which requires us to lock this down. Because some designs were restyling the layout to hide the footer, the actual location of the footer in the DOM order had to be moved, which enables us to temporarily preserve the functionality before entirely removing the footer from the DOM (pending page-frame release) In other cases where Layout.Page was used, the usage had been converted to `` which preserves the initial styling previously provided by Layout.Page component --------- Co-authored-by: Claude Co-authored-by: Priscila Oliveira --- static/app/components/layouts/thirds.tsx | 19 +++++++++++---- static/app/components/noAccess.tsx | 6 ++--- .../app/components/pageFilters/container.tsx | 7 +++--- .../workflowEngine/layout/detail.tsx | 15 +++++++----- .../components/workflowEngine/layout/edit.tsx | 12 +++++----- .../components/workflowEngine/layout/list.tsx | 6 ++--- static/app/views/alerts/create.tsx | 8 ++++--- static/app/views/alerts/edit.tsx | 8 ++++--- .../app/views/alerts/list/incidents/index.tsx | 9 ++++---- .../alerts/list/rules/alertRulesList.tsx | 5 ++-- .../app/views/alerts/rules/crons/details.tsx | 12 +++++----- .../rules/issue/details/ruleDetails.tsx | 6 ++--- .../alerts/rules/metric/details/index.tsx | 6 ++--- .../app/views/alerts/rules/uptime/details.tsx | 6 ++--- static/app/views/alerts/wizard/index.tsx | 6 ++--- static/app/views/automations/edit.tsx | 6 ++--- static/app/views/automations/new.tsx | 6 ++--- static/app/views/dashboards/create.tsx | 6 ++--- .../app/views/dashboards/createFromSeer.tsx | 6 ++--- .../dashboards/createFromSeerLoading.tsx | 7 +++--- .../views/dashboards/createFromSeerPrompt.tsx | 9 ++++---- static/app/views/dashboards/detail.tsx | 10 ++++---- static/app/views/dashboards/manage/index.tsx | 14 +++++------ static/app/views/dashboards/orgDashboards.tsx | 11 +++++---- static/app/views/dashboards/view.tsx | 6 ++--- .../components/forms/error/index.tsx | 4 ++-- .../detectors/components/forms/index.tsx | 5 ++-- static/app/views/detectors/new-settings.tsx | 6 +++-- static/app/views/discover/index.tsx | 6 ++--- static/app/views/discover/landing.tsx | 9 ++++---- static/app/views/discover/results.tsx | 5 ++-- static/app/views/explore/logs/content.tsx | 6 ++--- static/app/views/explore/metrics/content.tsx | 5 ++-- .../views/explore/multiQueryMode/index.tsx | 6 ++--- .../app/views/explore/savedQueries/index.tsx | 6 ++--- static/app/views/explore/spans/content.tsx | 6 ++--- static/app/views/feedback/index.tsx | 7 +++--- .../common/components/modulePageProviders.tsx | 7 +++--- .../screens/views/screenDetailsPage.tsx | 5 ++-- .../screens/views/screensLandingPage.tsx | 6 +++-- .../insights/pages/conversations/layout.tsx | 7 +++--- .../groupReplays/groupReplays.tsx | 7 +++--- .../views/issueDetails/groupReplays/index.tsx | 6 ++--- .../issueViewsList/issueViewsList.tsx | 6 ++--- static/app/views/issueList/overview.tsx | 7 +++--- .../app/views/issueList/pages/supergroups.tsx | 4 ++-- static/app/views/organizationLayout/index.tsx | 7 ++++-- static/app/views/performance/index.tsx | 6 ++--- .../performance/newTraceDetails/index.tsx | 9 ++++---- .../transactionSummary/pageLayout.tsx | 5 ++-- .../transactionReplays/index.tsx | 6 ++--- static/app/views/permissionDenied.tsx | 6 ++--- .../buildComparison/buildComparison.tsx | 9 ++++---- .../buildDetails/buildDetails.spec.tsx | 1 - .../preprod/buildDetails/buildDetails.tsx | 8 +++---- static/app/views/preprod/index.tsx | 6 ++--- .../app/views/preprod/install/installPage.tsx | 6 ++--- .../app/views/preprod/snapshots/snapshots.tsx | 14 +++++------ static/app/views/profiling/content.tsx | 5 ++-- .../profiling/continuousProfileProvider.tsx | 2 +- .../profiling/differentialFlamegraph.tsx | 2 +- static/app/views/profiling/index.tsx | 6 ++--- .../profiling/layoutPageWithHiddenFooter.tsx | 7 ++++-- .../views/profiling/profileSummary/index.tsx | 2 +- .../profiling/transactionProfileProvider.tsx | 2 +- .../app/views/projectDetail/projectDetail.tsx | 14 +++++------ static/app/views/projectEventRedirect.tsx | 7 +++--- .../views/projectInstall/gettingStarted.tsx | 8 +++---- .../app/views/projectInstall/newProject.tsx | 7 +++--- static/app/views/projects/projectContext.tsx | 6 ++--- static/app/views/projectsDashboard/index.tsx | 6 ++--- .../details/pullRequestDetails.tsx | 13 ++++++----- static/app/views/pullRequest/index.tsx | 6 ++--- static/app/views/releases/detail/index.tsx | 22 +++++++++--------- static/app/views/releases/list/index.tsx | 4 ++-- static/app/views/relocation/index.tsx | 6 ++--- static/app/views/replays/detail/page.tsx | 23 +++++++++---------- static/app/views/replays/details.tsx | 6 ++--- static/app/views/replays/list.tsx | 6 ++--- static/app/views/routeNotFound.tsx | 7 +++--- .../settings/components/settingsWrapper.tsx | 13 ++++------- 81 files changed, 323 insertions(+), 278 deletions(-) diff --git a/static/app/components/layouts/thirds.tsx b/static/app/components/layouts/thirds.tsx index 2f50400881541f..7ecbfaa0acdf93 100644 --- a/static/app/components/layouts/thirds.tsx +++ b/static/app/components/layouts/thirds.tsx @@ -2,7 +2,12 @@ import {useContext, type HTMLAttributes} from 'react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; -import {Container, Stack, type FlexProps} from '@sentry/scraps/layout'; +import { + Container, + Stack, + type ContainerProps, + type FlexProps, +} from '@sentry/scraps/layout'; import {Tabs} from '@sentry/scraps/tabs'; import {usePrimaryNavigation} from 'sentry/views/navigation/primaryNavigationContext'; @@ -22,8 +27,8 @@ export function Page(props: FlexProps<'main'> & {withPadding?: boolean}) { if (hasPageFrame) { return ( & {withPadding?: boolean}) { : undefined : undefined } - background="secondary" + background="primary" {...rest} /> ); @@ -76,7 +81,12 @@ const StyledPageFrameStack = styled(Stack)<{roundedCorner: boolean}>` * * Use `noActionWrap` to disable wrapping if there are minimal actions. */ -export const Header = styled('header')<{ +export const Header = styled((props: ContainerProps<'header'>) => { + const hasPageFrame = useHasPageFrameFeature(); + return ( + + ); +})<{ borderStyle?: 'dashed' | 'solid'; noActionWrap?: boolean; /** @@ -91,7 +101,6 @@ export const Header = styled('header')<{ p.noActionWrap ? 'minmax(0, 1fr) auto' : 'minmax(0, 1fr)'}; padding: ${p => p.theme.space.xl} ${p => p.theme.space.xl} 0 ${p => p.theme.space.xl}; - background-color: ${p => p.theme.tokens.background.primary}; ${p => !p.unified && diff --git a/static/app/components/noAccess.tsx b/static/app/components/noAccess.tsx index 93809db5c9f2e7..666b37644c7ff5 100644 --- a/static/app/components/noAccess.tsx +++ b/static/app/components/noAccess.tsx @@ -1,16 +1,16 @@ import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; -import * as Layout from 'sentry/components/layouts/thirds'; import {t} from 'sentry/locale'; export function NoAccess() { return ( - + {t("You don't have access to this feature")} - + ); } diff --git a/static/app/components/pageFilters/container.tsx b/static/app/components/pageFilters/container.tsx index c521d67cf5d5d4..2b1d73b87cc34d 100644 --- a/static/app/components/pageFilters/container.tsx +++ b/static/app/components/pageFilters/container.tsx @@ -1,7 +1,8 @@ import {Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import isEqual from 'lodash/isEqual'; -import * as Layout from 'sentry/components/layouts/thirds'; +import {Stack} from '@sentry/scraps/layout'; + import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import type {InitializeUrlStateParams} from 'sentry/components/pageFilters/actions'; import { @@ -252,9 +253,9 @@ export function PageFiltersContainer({ // would speed up orgs with tons of projects if (!isReady || !hasInitialized) { return ( - + - + ); } diff --git a/static/app/components/workflowEngine/layout/detail.tsx b/static/app/components/workflowEngine/layout/detail.tsx index 496d4038752f19..1e41b75763bbf6 100644 --- a/static/app/components/workflowEngine/layout/detail.tsx +++ b/static/app/components/workflowEngine/layout/detail.tsx @@ -1,12 +1,13 @@ import {Fragment} from 'react'; import styled from '@emotion/styled'; -import {Flex} from '@sentry/scraps/layout'; +import {Flex, Stack} from '@sentry/scraps/layout'; import ProjectBadge from 'sentry/components/idBadge/projectBadge'; import * as Layout from 'sentry/components/layouts/thirds'; import {HeaderActions} from 'sentry/components/layouts/thirds'; import type {AvatarProject} from 'sentry/types/project'; +import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; interface WorkflowEngineDetailLayoutProps { /** @@ -20,13 +21,15 @@ interface WorkflowEngineDetailLayoutProps { * Precomposed 67/33 layout for Monitors / Alerts detail pages. */ function DetailLayoutComponent({children}: WorkflowEngineDetailLayoutProps) { - return {children}; + // TODO(JonasBadalic): Remove this once the page-frame feature is GA'd + const hasPageFrame = useHasPageFrameFeature(); + return ( + + {children} + + ); } -const StyledPage = styled(Layout.Page)` - background: ${p => p.theme.tokens.background.primary}; -`; - const StyledBody = styled(Layout.Body)` display: flex; flex-direction: column; diff --git a/static/app/components/workflowEngine/layout/edit.tsx b/static/app/components/workflowEngine/layout/edit.tsx index bf56ed28866bd4..ea648469bd13f7 100644 --- a/static/app/components/workflowEngine/layout/edit.tsx +++ b/static/app/components/workflowEngine/layout/edit.tsx @@ -9,6 +9,7 @@ import {HeaderActions} from 'sentry/components/layouts/thirds'; import {FullHeightForm} from 'sentry/components/workflowEngine/form/fullHeightForm'; import {StickyFooter} from 'sentry/components/workflowEngine/ui/footer'; import type {AvatarProject} from 'sentry/types/project'; +import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; interface WorkflowEngineEditLayoutProps { /** @@ -23,18 +24,17 @@ interface WorkflowEngineEditLayoutProps { * Precomposed layout for Monitors / Alerts edit pages with form handling. */ function EditLayoutComponent({children, formProps}: WorkflowEngineEditLayoutProps) { + // TODO(JonasBadalic): Remove this once the page-frame feature is GA'd + const hasPageFrame = useHasPageFrameFeature(); return ( - {children} + + {children} + ); } -const StyledPage = styled(Layout.Page)` - background: ${p => p.theme.tokens.background.primary}; - flex: unset; -`; - const StyledLayoutHeader = styled(Layout.Header)` background-color: ${p => p.theme.tokens.background.primary}; `; diff --git a/static/app/components/workflowEngine/layout/list.tsx b/static/app/components/workflowEngine/layout/list.tsx index 1f22a02e092306..428ff70ff34e63 100644 --- a/static/app/components/workflowEngine/layout/list.tsx +++ b/static/app/components/workflowEngine/layout/list.tsx @@ -1,4 +1,4 @@ -import {Flex} from '@sentry/scraps/layout'; +import {Flex, Stack} from '@sentry/scraps/layout'; import * as Layout from 'sentry/components/layouts/thirds'; import {NoProjectMessage} from 'sentry/components/noProjectMessage'; @@ -28,7 +28,7 @@ export function WorkflowEngineListLayout({ const organization = useOrganization(); return ( - + @@ -47,6 +47,6 @@ export function WorkflowEngineListLayout({ - + ); } diff --git a/static/app/views/alerts/create.tsx b/static/app/views/alerts/create.tsx index 7179f1f4db1bcf..206b4556c42008 100644 --- a/static/app/views/alerts/create.tsx +++ b/static/app/views/alerts/create.tsx @@ -1,4 +1,6 @@ -import {Fragment, useEffect, useRef} from 'react'; +import {useEffect, Fragment, useRef} from 'react'; + +import {Stack} from '@sentry/scraps/layout'; import * as Layout from 'sentry/components/layouts/thirds'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; @@ -137,7 +139,7 @@ export default function Create() { const title = t('New Alert Rule'); return ( - + @@ -228,6 +230,6 @@ export default function Create() { )} - + ); } diff --git a/static/app/views/alerts/edit.tsx b/static/app/views/alerts/edit.tsx index a5dced1453d396..ea1199d97a5c04 100644 --- a/static/app/views/alerts/edit.tsx +++ b/static/app/views/alerts/edit.tsx @@ -1,4 +1,6 @@ -import {Fragment, useState} from 'react'; +import {useState, Fragment} from 'react'; + +import {Stack} from '@sentry/scraps/layout'; import * as Layout from 'sentry/components/layouts/thirds'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; @@ -61,7 +63,7 @@ export default function ProjectAlertsEditor() { const {teams, isLoading: teamsLoading} = useUserTeams(); return ( - + )} - + ); } diff --git a/static/app/views/alerts/list/incidents/index.tsx b/static/app/views/alerts/list/incidents/index.tsx index eb2d9e0b008c59..9ba65f77dca19f 100644 --- a/static/app/views/alerts/list/incidents/index.tsx +++ b/static/app/views/alerts/list/incidents/index.tsx @@ -4,6 +4,7 @@ import type {Location} from 'history'; import {Alert} from '@sentry/scraps/alert'; import {LinkButton} from '@sentry/scraps/button'; +import {Stack} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; import {promptsCheck, promptsUpdate} from 'sentry/actionCreators/prompts'; @@ -263,7 +264,7 @@ class IncidentsList extends DeprecatedAsyncComponent< return ( - + @@ -286,7 +287,7 @@ class IncidentsList extends DeprecatedAsyncComponent< - + ); } @@ -305,7 +306,7 @@ export default function IncidentsListContainer() { }, []); const renderDisabled = () => ( - + @@ -315,7 +316,7 @@ export default function IncidentsListContainer() { - + ); return ( diff --git a/static/app/views/alerts/list/rules/alertRulesList.tsx b/static/app/views/alerts/list/rules/alertRulesList.tsx index 8d37a4e8d81516..b03e27616335ed 100644 --- a/static/app/views/alerts/list/rules/alertRulesList.tsx +++ b/static/app/views/alerts/list/rules/alertRulesList.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled'; import type {Location} from 'history'; import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; import { @@ -210,7 +211,7 @@ export default function AlertRulesList() { - + @@ -337,7 +338,7 @@ export default function AlertRulesList() { - + ); } diff --git a/static/app/views/alerts/rules/crons/details.tsx b/static/app/views/alerts/rules/crons/details.tsx index c190833c5e8492..2368fe7d238e87 100644 --- a/static/app/views/alerts/rules/crons/details.tsx +++ b/static/app/views/alerts/rules/crons/details.tsx @@ -1,8 +1,8 @@ -import {Fragment, useCallback, useState} from 'react'; +import {useCallback, useState, Fragment} from 'react'; import styled from '@emotion/styled'; import {Alert} from '@sentry/scraps/alert'; -import {Flex} from '@sentry/scraps/layout'; +import {Flex, Stack} from '@sentry/scraps/layout'; import {updateMonitor} from 'sentry/actionCreators/monitors'; import {SectionHeading} from 'sentry/components/charts/styles'; @@ -111,14 +111,14 @@ export default function MonitorDetails() { if (!monitor) { return ( - + - + ); } return ( - + @@ -189,7 +189,7 @@ export default function MonitorDetails() { - + ); } diff --git a/static/app/views/alerts/rules/issue/details/ruleDetails.tsx b/static/app/views/alerts/rules/issue/details/ruleDetails.tsx index 2040b0d26c7628..b797d399f51ea0 100644 --- a/static/app/views/alerts/rules/issue/details/ruleDetails.tsx +++ b/static/app/views/alerts/rules/issue/details/ruleDetails.tsx @@ -4,7 +4,7 @@ import moment from 'moment-timezone'; import {Alert} from '@sentry/scraps/alert'; import {Button, LinkButton} from '@sentry/scraps/button'; -import {Grid} from '@sentry/scraps/layout'; +import {Grid, Stack} from '@sentry/scraps/layout'; import {ExternalLink, Link} from '@sentry/scraps/link'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; @@ -383,7 +383,7 @@ export default function AlertRuleDetails() { const {period, start, end, utc} = getDataDatetime(); const cursor = decodeScalar(location.query.cursor); return ( - + - + ); } diff --git a/static/app/views/alerts/rules/metric/details/index.tsx b/static/app/views/alerts/rules/metric/details/index.tsx index d754868aedec1d..2278526fbf1cd2 100644 --- a/static/app/views/alerts/rules/metric/details/index.tsx +++ b/static/app/views/alerts/rules/metric/details/index.tsx @@ -5,11 +5,11 @@ import pick from 'lodash/pick'; import moment from 'moment-timezone'; import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import {fetchOrgMembers} from 'sentry/actionCreators/members'; import type {Client} from 'sentry/api'; import {DateTime} from 'sentry/components/dateTime'; -import * as Layout from 'sentry/components/layouts/thirds'; import {PageFiltersContainer} from 'sentry/components/pageFilters/container'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {t} from 'sentry/locale'; @@ -252,7 +252,7 @@ class MetricAlertDetails extends Component { const {error} = this.state; return ( - + {error?.status === 404 @@ -260,7 +260,7 @@ class MetricAlertDetails extends Component { : t('An error occurred while fetching the alert rule.')} - + ); } diff --git a/static/app/views/alerts/rules/uptime/details.tsx b/static/app/views/alerts/rules/uptime/details.tsx index 5090d57e83c25b..126821537e2a0b 100644 --- a/static/app/views/alerts/rules/uptime/details.tsx +++ b/static/app/views/alerts/rules/uptime/details.tsx @@ -3,7 +3,7 @@ import styled from '@emotion/styled'; import {Alert} from '@sentry/scraps/alert'; import {LinkButton} from '@sentry/scraps/button'; -import {Grid} from '@sentry/scraps/layout'; +import {Grid, Stack} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; import {updateUptimeRule} from 'sentry/actionCreators/uptime'; @@ -121,7 +121,7 @@ export default function UptimeAlertDetails() { ); return ( - + @@ -213,7 +213,7 @@ export default function UptimeAlertDetails() { /> - + ); } diff --git a/static/app/views/alerts/wizard/index.tsx b/static/app/views/alerts/wizard/index.tsx index 64539d234fffa5..5fab7389bb7b30 100644 --- a/static/app/views/alerts/wizard/index.tsx +++ b/static/app/views/alerts/wizard/index.tsx @@ -1,7 +1,7 @@ import {useState} from 'react'; import styled from '@emotion/styled'; -import {Flex} from '@sentry/scraps/layout'; +import {Flex, Stack} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; import Feature from 'sentry/components/acl/feature'; @@ -162,7 +162,7 @@ export default function AlertWizard() { ); const panelContent = getAlertWizardPanelContent({hasMetricIssues})[alertOption]; return ( - + @@ -237,7 +237,7 @@ export default function AlertWizard() { - + ); } diff --git a/static/app/views/automations/edit.tsx b/static/app/views/automations/edit.tsx index d840e4aedc6446..4e8208fb03ea46 100644 --- a/static/app/views/automations/edit.tsx +++ b/static/app/views/automations/edit.tsx @@ -3,7 +3,7 @@ import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import * as Sentry from '@sentry/react'; -import {Flex} from '@sentry/scraps/layout'; +import {Flex, Stack} from '@sentry/scraps/layout'; import {Breadcrumbs} from 'sentry/components/breadcrumbs'; import type {FieldValue} from 'sentry/components/forms/model'; @@ -226,7 +226,7 @@ function AutomationEditForm({automation}: {automation: Automation}) { > - + @@ -263,7 +263,7 @@ function AutomationEditForm({automation}: {automation: Automation}) { - + diff --git a/static/app/views/automations/new.tsx b/static/app/views/automations/new.tsx index eac0539d4be51a..0f18039d850e0e 100644 --- a/static/app/views/automations/new.tsx +++ b/static/app/views/automations/new.tsx @@ -5,7 +5,7 @@ import * as Sentry from '@sentry/react'; import {Observer} from 'mobx-react-lite'; import {Button} from '@sentry/scraps/button'; -import {Flex} from '@sentry/scraps/layout'; +import {Flex, Stack} from '@sentry/scraps/layout'; import {Breadcrumbs} from 'sentry/components/breadcrumbs'; import {FormModel} from 'sentry/components/forms/model'; @@ -193,7 +193,7 @@ export default function AutomationNewSettings() { > - + @@ -229,7 +229,7 @@ export default function AutomationNewSettings() { - + diff --git a/static/app/views/dashboards/create.tsx b/static/app/views/dashboards/create.tsx index 6b97b7d333370c..6f6fa8378330bb 100644 --- a/static/app/views/dashboards/create.tsx +++ b/static/app/views/dashboards/create.tsx @@ -1,10 +1,10 @@ import {useState} from 'react'; import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import Feature from 'sentry/components/acl/feature'; import {ErrorBoundary} from 'sentry/components/errorBoundary'; -import * as Layout from 'sentry/components/layouts/thirds'; import {t} from 'sentry/locale'; import {useLocation} from 'sentry/utils/useLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -46,13 +46,13 @@ export default function CreateDashboard() { function renderDisabled() { return ( - + {t("You don't have access to this feature")} - + ); } diff --git a/static/app/views/dashboards/createFromSeer.tsx b/static/app/views/dashboards/createFromSeer.tsx index 3003fdbb3fb046..be8d41746e63e4 100644 --- a/static/app/views/dashboards/createFromSeer.tsx +++ b/static/app/views/dashboards/createFromSeer.tsx @@ -2,11 +2,11 @@ import {memo, useCallback, useEffect, useRef, useState} from 'react'; import * as Sentry from '@sentry/react'; import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import {validateDashboard} from 'sentry/actionCreators/dashboards'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {ErrorBoundary} from 'sentry/components/errorBoundary'; -import * as Layout from 'sentry/components/layouts/thirds'; import {t} from 'sentry/locale'; import type {Organization} from 'sentry/types/organization'; import {parseQueryKey} from 'sentry/utils/api/apiQueryKey'; @@ -291,13 +291,13 @@ export default function CreateFromSeer() { if (!hasFeature) { return ( - + {t("You don't have access to this feature")} - + ); } diff --git a/static/app/views/dashboards/createFromSeerLoading.tsx b/static/app/views/dashboards/createFromSeerLoading.tsx index dc39f3502b5cd7..495802b39a8a67 100644 --- a/static/app/views/dashboards/createFromSeerLoading.tsx +++ b/static/app/views/dashboards/createFromSeerLoading.tsx @@ -1,8 +1,8 @@ import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Heading, Text} from '@sentry/scraps/text'; -import * as Layout from 'sentry/components/layouts/thirds'; import {t} from 'sentry/locale'; +import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; import {BlockComponent} from 'sentry/views/seerExplorer/blockComponents'; import type {Block} from 'sentry/views/seerExplorer/types'; @@ -13,8 +13,9 @@ interface CreateFromSeerLoadingProps { export function CreateFromSeerLoading({blocks, seerRunId}: CreateFromSeerLoadingProps) { const blocksToRender = blocks.slice(-3); + const hasPageFrame = useHasPageFrameFeature(); return ( - + {t('Generating Dashboard')} @@ -40,6 +41,6 @@ export function CreateFromSeerLoading({blocks, seerRunId}: CreateFromSeerLoading - + ); } diff --git a/static/app/views/dashboards/createFromSeerPrompt.tsx b/static/app/views/dashboards/createFromSeerPrompt.tsx index 7bbc2eb68c4007..f9271ced0b1a50 100644 --- a/static/app/views/dashboards/createFromSeerPrompt.tsx +++ b/static/app/views/dashboards/createFromSeerPrompt.tsx @@ -1,12 +1,11 @@ import {useCallback, useState} from 'react'; import {Button} from '@sentry/scraps/button'; -import {Container, Flex} from '@sentry/scraps/layout'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Heading} from '@sentry/scraps/text'; import {TextArea} from '@sentry/scraps/textarea'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; -import * as Layout from 'sentry/components/layouts/thirds'; import {t} from 'sentry/locale'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import {fetchMutation} from 'sentry/utils/queryClient'; @@ -14,6 +13,7 @@ import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import {useOrganization} from 'sentry/utils/useOrganization'; +import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; export function CreateFromSeerPrompt() { const organization = useOrganization(); @@ -21,6 +21,7 @@ export function CreateFromSeerPrompt() { const navigate = useNavigate(); const [prompt, setPrompt] = useState(''); const [isGenerating, setIsGenerating] = useState(false); + const hasPageFrame = useHasPageFrameFeature(); const handleGenerate = useCallback(async () => { if (!prompt.trim()) { @@ -61,7 +62,7 @@ export function CreateFromSeerPrompt() { }, [prompt, organization.slug, location.query, navigate]); return ( - + {t('Describe your Dashboard')} @@ -100,6 +101,6 @@ export function CreateFromSeerPrompt() { - + ); } diff --git a/static/app/views/dashboards/detail.tsx b/static/app/views/dashboards/detail.tsx index fd7c8c80970ebc..ce2dd5aec10275 100644 --- a/static/app/views/dashboards/detail.tsx +++ b/static/app/views/dashboards/detail.tsx @@ -9,6 +9,8 @@ import isEqualWith from 'lodash/isEqualWith'; import omit from 'lodash/omit'; import pick from 'lodash/pick'; +import {Stack} from '@sentry/scraps/layout'; + import { createDashboard, deleteDashboard, @@ -986,7 +988,7 @@ class DashboardDetail extends Component { }, }} > - + @@ -1061,7 +1063,7 @@ class DashboardDetail extends Component { - + ); } @@ -1108,7 +1110,7 @@ class DashboardDetail extends Component { ); const pageContent = ( - + @@ -1319,7 +1321,7 @@ class DashboardDetail extends Component { - + ); return ( diff --git a/static/app/views/dashboards/manage/index.tsx b/static/app/views/dashboards/manage/index.tsx index 83f33befc65cc4..54b2fed0369b4d 100644 --- a/static/app/views/dashboards/manage/index.tsx +++ b/static/app/views/dashboards/manage/index.tsx @@ -9,7 +9,7 @@ import {Alert} from '@sentry/scraps/alert'; import {FeatureBadge} from '@sentry/scraps/badge'; import {Button} from '@sentry/scraps/button'; import {CompactSelect} from '@sentry/scraps/compactSelect'; -import {Flex, Grid} from '@sentry/scraps/layout'; +import {Flex, Grid, Stack} from '@sentry/scraps/layout'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {SegmentedControl} from '@sentry/scraps/segmentedControl'; import {Switch} from '@sentry/scraps/switch'; @@ -478,13 +478,13 @@ function ManageDashboards() { function renderNoAccess() { return ( - + {t("You don't have access to this feature")} - + ); } @@ -608,11 +608,11 @@ function ManageDashboards() { > {isError ? ( - + - + ) : ( - + @@ -755,7 +755,7 @@ function ManageDashboards() { - + )} diff --git a/static/app/views/dashboards/orgDashboards.tsx b/static/app/views/dashboards/orgDashboards.tsx index 90d4d6cf5a119b..d87ac9a2d2ed72 100644 --- a/static/app/views/dashboards/orgDashboards.tsx +++ b/static/app/views/dashboards/orgDashboards.tsx @@ -1,8 +1,9 @@ import {useEffect, useMemo, useRef, useState} from 'react'; import isEqual from 'lodash/isEqual'; +import {Stack} from '@sentry/scraps/layout'; + import {NotFound} from 'sentry/components/errors/notFound'; -import * as Layout from 'sentry/components/layouts/thirds'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; @@ -202,9 +203,9 @@ export function OrgDashboards({children, initialDashboard}: OrgDashboardsProps) if (isDashboardsPending || isSelectedDashboardLoading || isPrebuiltDashboardLoading) { return ( - + - + ); } @@ -218,9 +219,9 @@ export function OrgDashboards({children, initialDashboard}: OrgDashboardsProps) // the URL does not contain filters yet. The filters can either match the // saved filters, or can be different (i.e. sharing an unsaved state) return ( - + - + ); } diff --git a/static/app/views/dashboards/view.tsx b/static/app/views/dashboards/view.tsx index 77a79023d2395b..5d80e57ce226d1 100644 --- a/static/app/views/dashboards/view.tsx +++ b/static/app/views/dashboards/view.tsx @@ -1,12 +1,12 @@ import {useEffect} from 'react'; import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import {updateDashboardVisit} from 'sentry/actionCreators/dashboards'; import Feature from 'sentry/components/acl/feature'; import {ErrorBoundary} from 'sentry/components/errorBoundary'; import {NotFound} from 'sentry/components/errors/notFound'; -import * as Layout from 'sentry/components/layouts/thirds'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {t} from 'sentry/locale'; import type {Organization} from 'sentry/types/organization'; @@ -69,13 +69,13 @@ type FeatureProps = { export function DashboardBasicFeature({organization, children}: FeatureProps) { const renderDisabled = () => ( - + {t("You don't have access to this feature")} - + ); return ( diff --git a/static/app/views/detectors/components/forms/error/index.tsx b/static/app/views/detectors/components/forms/error/index.tsx index 0162ee8cb5fcad..61687f255fce58 100644 --- a/static/app/views/detectors/components/forms/error/index.tsx +++ b/static/app/views/detectors/components/forms/error/index.tsx @@ -102,13 +102,13 @@ function ErrorDetectorForm({detector}: {detector: ErrorDetector}) { export function NewErrorDetectorForm() { return ( - + - + ); } diff --git a/static/app/views/detectors/components/forms/index.tsx b/static/app/views/detectors/components/forms/index.tsx index 9fd522bc77574a..7f89bd34232249 100644 --- a/static/app/views/detectors/components/forms/index.tsx +++ b/static/app/views/detectors/components/forms/index.tsx @@ -1,4 +1,5 @@ import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import * as Layout from 'sentry/components/layouts/thirds'; import {LoadingError} from 'sentry/components/loadingError'; @@ -29,13 +30,13 @@ import { function PlaceholderForm() { return ( - + - + ); } diff --git a/static/app/views/detectors/new-settings.tsx b/static/app/views/detectors/new-settings.tsx index 0339be2b780b28..395e84f6cba5c8 100644 --- a/static/app/views/detectors/new-settings.tsx +++ b/static/app/views/detectors/new-settings.tsx @@ -1,6 +1,8 @@ import orderBy from 'lodash/orderBy'; import {parseAsString, useQueryState} from 'nuqs'; +import {Stack} from '@sentry/scraps/layout'; + import * as Layout from 'sentry/components/layouts/thirds'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; @@ -28,13 +30,13 @@ export default function DetectorNewSettings() { if (isFetchingProjects) { return ( - + - + ); } diff --git a/static/app/views/discover/index.tsx b/static/app/views/discover/index.tsx index dcecc5a77aed8b..672a6ed58d6f6d 100644 --- a/static/app/views/discover/index.tsx +++ b/static/app/views/discover/index.tsx @@ -1,9 +1,9 @@ import {Outlet} from 'react-router-dom'; import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import Feature from 'sentry/components/acl/feature'; -import * as Layout from 'sentry/components/layouts/thirds'; import {NoProjectMessage} from 'sentry/components/noProjectMessage'; import {Redirect} from 'sentry/components/redirect'; import {t} from 'sentry/locale'; @@ -23,13 +23,13 @@ function DiscoverContainer() { function renderNoAccess() { return ( - + {t("You don't have access to this feature")} - + ); } diff --git a/static/app/views/discover/landing.tsx b/static/app/views/discover/landing.tsx index 6ba57d02acaba7..0c14426e395671 100644 --- a/static/app/views/discover/landing.tsx +++ b/static/app/views/discover/landing.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled'; import {Alert} from '@sentry/scraps/alert'; import {LinkButton} from '@sentry/scraps/button'; import {CompactSelect} from '@sentry/scraps/compactSelect'; +import {Stack} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {Switch} from '@sentry/scraps/switch'; @@ -46,13 +47,13 @@ const SORT_OPTIONS = [ function NoAccess() { return ( - + {t("You don't have access to this feature")} - + ); } @@ -187,7 +188,7 @@ function DiscoverLanding() { renderDisabled={() => } > - + - + ); diff --git a/static/app/views/discover/results.tsx b/static/app/views/discover/results.tsx index cd36e27052ecf6..7857fa88e69e55 100644 --- a/static/app/views/discover/results.tsx +++ b/static/app/views/discover/results.tsx @@ -7,6 +7,7 @@ import omit from 'lodash/omit'; import {Alert} from '@sentry/scraps/alert'; import {Button} from '@sentry/scraps/button'; +import {Stack} from '@sentry/scraps/layout'; import {ExternalLink, Link} from '@sentry/scraps/link'; import {updateSavedQueryVisit} from 'sentry/actionCreators/discoverSavedQueries'; @@ -836,7 +837,7 @@ export class Results extends Component { return ( - + { - + ); } diff --git a/static/app/views/explore/logs/content.tsx b/static/app/views/explore/logs/content.tsx index f05313e9123946..dff06c1ebaefc7 100644 --- a/static/app/views/explore/logs/content.tsx +++ b/static/app/views/explore/logs/content.tsx @@ -1,5 +1,5 @@ import {LinkButton} from '@sentry/scraps/button'; -import {Grid} from '@sentry/scraps/layout'; +import {Grid, Stack} from '@sentry/scraps/layout'; import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; import * as Layout from 'sentry/components/layouts/thirds'; @@ -61,7 +61,7 @@ export default function LogsContent() { analyticsPageSource={LogsAnalyticsPageSource.EXPLORE_LOGS} source="location" > - + {defined(onboardingProject) ? ( @@ -74,7 +74,7 @@ export default function LogsContent() { )} - + diff --git a/static/app/views/explore/metrics/content.tsx b/static/app/views/explore/metrics/content.tsx index ec5f771097bf57..d0dadd8ea97ee5 100644 --- a/static/app/views/explore/metrics/content.tsx +++ b/static/app/views/explore/metrics/content.tsx @@ -1,4 +1,5 @@ import {FeatureBadge} from '@sentry/scraps/badge'; +import {Stack} from '@sentry/scraps/layout'; import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; import * as Layout from 'sentry/components/layouts/thirds'; @@ -48,7 +49,7 @@ export default function MetricsContent() { : undefined } > - + {defined(onboardingProject) ? ( )} - + ); diff --git a/static/app/views/explore/multiQueryMode/index.tsx b/static/app/views/explore/multiQueryMode/index.tsx index a1f1ad708dbc4e..e429ca30a6d101 100644 --- a/static/app/views/explore/multiQueryMode/index.tsx +++ b/static/app/views/explore/multiQueryMode/index.tsx @@ -1,4 +1,4 @@ -import {Grid} from '@sentry/scraps/layout'; +import {Grid, Stack} from '@sentry/scraps/layout'; import Feature from 'sentry/components/acl/feature'; import {Breadcrumbs} from 'sentry/components/breadcrumbs'; @@ -62,9 +62,9 @@ export default function MultiQueryMode() { - + - + ); diff --git a/static/app/views/explore/savedQueries/index.tsx b/static/app/views/explore/savedQueries/index.tsx index 32e3003239ebb0..0b44748131f42d 100644 --- a/static/app/views/explore/savedQueries/index.tsx +++ b/static/app/views/explore/savedQueries/index.tsx @@ -1,7 +1,7 @@ import {useNavigate} from 'react-router-dom'; import {Button, LinkButton} from '@sentry/scraps/button'; -import {Grid} from '@sentry/scraps/layout'; +import {Grid, Stack} from '@sentry/scraps/layout'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; @@ -41,7 +41,7 @@ export default function SavedQueriesView() { return ( - + {t('All Queries')} @@ -88,7 +88,7 @@ export default function SavedQueriesView() { - + ); } diff --git a/static/app/views/explore/spans/content.tsx b/static/app/views/explore/spans/content.tsx index ebf11833118eb9..ed4a320a8ff288 100644 --- a/static/app/views/explore/spans/content.tsx +++ b/static/app/views/explore/spans/content.tsx @@ -2,7 +2,7 @@ import type {ReactNode} from 'react'; import {useMemo} from 'react'; import * as Sentry from '@sentry/react'; -import {Grid} from '@sentry/scraps/layout'; +import {Grid, Stack} from '@sentry/scraps/layout'; import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; import * as Layout from 'sentry/components/layouts/thirds'; @@ -83,7 +83,7 @@ function ExploreContentInner() { return ( - + {defined(onboardingProject) ? ( @@ -96,7 +96,7 @@ function ExploreContentInner() { )} - + ); diff --git a/static/app/views/feedback/index.tsx b/static/app/views/feedback/index.tsx index 17fb02de008045..92b1f827452fce 100644 --- a/static/app/views/feedback/index.tsx +++ b/static/app/views/feedback/index.tsx @@ -1,7 +1,8 @@ import {Outlet} from 'react-router-dom'; +import {Stack} from '@sentry/scraps/layout'; + import {AnalyticsArea} from 'sentry/components/analyticsArea'; -import * as Layout from 'sentry/components/layouts/thirds'; import {NoProjectMessage} from 'sentry/components/noProjectMessage'; import {Redirect} from 'sentry/components/redirect'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -21,11 +22,11 @@ export default function FeedbackContainer() { return ( - + - + ); } diff --git a/static/app/views/insights/common/components/modulePageProviders.tsx b/static/app/views/insights/common/components/modulePageProviders.tsx index 4dea7e1c77ee3a..225eb75ce5dac4 100644 --- a/static/app/views/insights/common/components/modulePageProviders.tsx +++ b/static/app/views/insights/common/components/modulePageProviders.tsx @@ -1,4 +1,5 @@ -import * as Layout from 'sentry/components/layouts/thirds'; +import {Stack} from '@sentry/scraps/layout'; + import {NoProjectMessage} from 'sentry/components/noProjectMessage'; import {PageFiltersContainer} from 'sentry/components/pageFilters/container'; import type {DatePageFilterProps} from 'sentry/components/pageFilters/date/datePageFilter'; @@ -54,11 +55,11 @@ export function ModulePageProviders({ storageNamespace={view} > - + {children} - + ); diff --git a/static/app/views/insights/mobile/screens/views/screenDetailsPage.tsx b/static/app/views/insights/mobile/screens/views/screenDetailsPage.tsx index 93025fb1ed4efb..e8185ce773598e 100644 --- a/static/app/views/insights/mobile/screens/views/screenDetailsPage.tsx +++ b/static/app/views/insights/mobile/screens/views/screenDetailsPage.tsx @@ -3,6 +3,7 @@ import {useState} from 'react'; import omit from 'lodash/omit'; import {FeatureBadge, type FeatureBadgeProps} from '@sentry/scraps/badge'; +import {Stack} from '@sentry/scraps/layout'; import {TabList, Tabs} from '@sentry/scraps/tabs'; import * as Layout from 'sentry/components/layouts/thirds'; @@ -125,7 +126,7 @@ function ScreenDetailsPage() { return ( - + handleTabChange(tabKey)}> - + ); } diff --git a/static/app/views/insights/mobile/screens/views/screensLandingPage.tsx b/static/app/views/insights/mobile/screens/views/screensLandingPage.tsx index 89c089c855c4ac..72d5789750ea3b 100644 --- a/static/app/views/insights/mobile/screens/views/screensLandingPage.tsx +++ b/static/app/views/insights/mobile/screens/views/screensLandingPage.tsx @@ -2,6 +2,8 @@ import {useCallback, useEffect, useState} from 'react'; import styled from '@emotion/styled'; import omit from 'lodash/omit'; +import {Stack} from '@sentry/scraps/layout'; + import {ErrorBoundary} from 'sentry/components/errorBoundary'; import * as Layout from 'sentry/components/layouts/thirds'; import {TabbedCodeSnippet} from 'sentry/components/onboarding/gettingStartedDoc/onboardingCodeSnippet'; @@ -264,7 +266,7 @@ function ScreensLandingPage() { moduleName={ModuleName.MOBILE_VITALS} maxPickableDays={maxPickableDays.maxPickableDays} > - + @@ -323,7 +325,7 @@ function ScreensLandingPage() { - + ); } diff --git a/static/app/views/insights/pages/conversations/layout.tsx b/static/app/views/insights/pages/conversations/layout.tsx index 9df28e8b02b4b9..4c51d48557a18d 100644 --- a/static/app/views/insights/pages/conversations/layout.tsx +++ b/static/app/views/insights/pages/conversations/layout.tsx @@ -1,6 +1,7 @@ import {Outlet, useMatches} from 'react-router-dom'; -import * as Layout from 'sentry/components/layouts/thirds'; +import {Stack} from '@sentry/scraps/layout'; + import {ConversationsPageHeader} from 'sentry/views/insights/pages/conversations/conversationsPageHeader'; import {ModuleName} from 'sentry/views/insights/types'; @@ -8,12 +9,12 @@ function ConversationsLayout() { const handle = useMatches().at(-1)?.handle as {module?: ModuleName} | undefined; return ( - + {handle && 'module' in handle ? ( ) : null} - + ); } diff --git a/static/app/views/issueDetails/groupReplays/groupReplays.tsx b/static/app/views/issueDetails/groupReplays/groupReplays.tsx index fb23253dcae55c..c1c7da78a7b31d 100644 --- a/static/app/views/issueDetails/groupReplays/groupReplays.tsx +++ b/static/app/views/issueDetails/groupReplays/groupReplays.tsx @@ -5,7 +5,6 @@ import type {Location, Query} from 'history'; import {Button} from '@sentry/scraps/button'; import {Flex, Stack} from '@sentry/scraps/layout'; -import * as Layout from 'sentry/components/layouts/thirds'; import {Placeholder} from 'sentry/components/placeholder'; import { SelectedReplayIndexProvider, @@ -118,7 +117,7 @@ function GroupReplaysContent({group}: Props) { if (!eventView) { // Shown on load and no replay data available return ( - + @@ -145,7 +144,7 @@ function GroupReplaysContent({group}: Props) { return ( - + @@ -325,7 +324,7 @@ function ReplayOverlay({ ); } -const StyledLayoutPage = styled(Layout.Page)` +const StyledLayoutPage = styled(Stack)` background-color: ${p => p.theme.tokens.background.primary}; gap: ${p => p.theme.space.lg}; border: 1px solid ${p => p.theme.tokens.border.primary}; diff --git a/static/app/views/issueDetails/groupReplays/index.tsx b/static/app/views/issueDetails/groupReplays/index.tsx index 7a0b8aadd2afa5..58cec5dd873e76 100644 --- a/static/app/views/issueDetails/groupReplays/index.tsx +++ b/static/app/views/issueDetails/groupReplays/index.tsx @@ -1,7 +1,7 @@ import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; import Feature from 'sentry/components/acl/feature'; -import * as Layout from 'sentry/components/layouts/thirds'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {t} from 'sentry/locale'; @@ -13,13 +13,13 @@ import {GroupReplays} from './groupReplays'; function renderNoAccess() { return ( - + {t("You don't have access to this feature")} - + ); } diff --git a/static/app/views/issueList/issueViews/issueViewsList/issueViewsList.tsx b/static/app/views/issueList/issueViews/issueViewsList/issueViewsList.tsx index e0bb7976a2cb1a..565c4980aebdb8 100644 --- a/static/app/views/issueList/issueViews/issueViewsList/issueViewsList.tsx +++ b/static/app/views/issueList/issueViews/issueViewsList/issueViewsList.tsx @@ -3,7 +3,7 @@ import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; import {CompactSelect} from '@sentry/scraps/compactSelect'; -import {Grid} from '@sentry/scraps/layout'; +import {Grid, Stack} from '@sentry/scraps/layout'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import Feature from 'sentry/components/acl/feature'; @@ -358,7 +358,7 @@ export default function IssueViewsList() { return ( - + {t('All Views')} @@ -460,7 +460,7 @@ export default function IssueViewsList() { /> - + ); } diff --git a/static/app/views/issueList/overview.tsx b/static/app/views/issueList/overview.tsx index 7701534b39a2be..008349d65a7d28 100644 --- a/static/app/views/issueList/overview.tsx +++ b/static/app/views/issueList/overview.tsx @@ -9,9 +9,10 @@ import omit from 'lodash/omit'; import pickBy from 'lodash/pickBy'; import * as qs from 'query-string'; +import {Stack} from '@sentry/scraps/layout'; + import {addMessage} from 'sentry/actionCreators/indicator'; import {fetchOrgMembers, indexMembersByProject} from 'sentry/actionCreators/members'; -import * as Layout from 'sentry/components/layouts/thirds'; import {extractSelectionParameters} from 'sentry/components/pageFilters/parse'; import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; import type {CursorHandler} from 'sentry/components/pagination'; @@ -873,7 +874,7 @@ function IssueListOverview({ const {numPreviousIssues, numIssuesOnPage} = getPageCounts(); return ( - + - + ); } diff --git a/static/app/views/issueList/pages/supergroups.tsx b/static/app/views/issueList/pages/supergroups.tsx index 4a011a4edc9984..03868ea3009a35 100644 --- a/static/app/views/issueList/pages/supergroups.tsx +++ b/static/app/views/issueList/pages/supergroups.tsx @@ -120,7 +120,7 @@ function Supergroups() { } return ( - + @@ -172,7 +172,7 @@ function Supergroups() { )} - + ); } diff --git a/static/app/views/organizationLayout/index.tsx b/static/app/views/organizationLayout/index.tsx index e87436d9f5bb6f..61486f222bce64 100644 --- a/static/app/views/organizationLayout/index.tsx +++ b/static/app/views/organizationLayout/index.tsx @@ -9,6 +9,7 @@ import {useFeedbackOnboardingDrawer} from 'sentry/components/feedback/feedbackOn import {Footer} from 'sentry/components/footer'; import {GlobalDrawer} from 'sentry/components/globalDrawer'; import {HookOrDefault} from 'sentry/components/hookOrDefault'; +import * as Layout from 'sentry/components/layouts/thirds'; import {usePerformanceOnboardingDrawer} from 'sentry/components/performanceOnboarding/sidebar'; import {useProfilingOnboardingDrawer} from 'sentry/components/profiling/profilingOnboardingSidebar'; import {useReplaysOnboardingDrawer} from 'sentry/components/replaysOnboarding/sidebar'; @@ -93,10 +94,12 @@ function AppLayout({organization}: LayoutProps) { {organization && } - + + +