diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index a4588247cc2c42..7c69f7a1f00476 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -31,7 +31,7 @@ releases: 0004_cleanup_failed_safe_deletes replays: 0006_add_bulk_delete_job -sentry: 1010_add_organizationcontributors_table +sentry: 1011_discover_to_explore_queries_self_hosted social_auth: 0003_social_auth_json_field diff --git a/src/sentry/migrations/1011_discover_to_explore_queries_self_hosted.py b/src/sentry/migrations/1011_discover_to_explore_queries_self_hosted.py new file mode 100644 index 00000000000000..2388a3ab75010f --- /dev/null +++ b/src/sentry/migrations/1011_discover_to_explore_queries_self_hosted.py @@ -0,0 +1,98 @@ +# Generated by Django 5.2.8 on 2025-12-01 16:35 + + +import sentry_sdk +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor +from django.db.migrations.state import StateApps + +from sentry.discover.models import DiscoverSavedQuery, DiscoverSavedQueryProject +from sentry.explore.models import ExploreSavedQueryProject +from sentry.explore.translation.discover_translation import ( + translate_discover_query_to_explore_query, +) +from sentry.models.dashboard_widget import TypesClass +from sentry.new_migrations.migrations import CheckedMigration +from sentry.utils.query import RangeQuerySetWrapperWithProgressBar + + +class DiscoverSavedQueryTypes(TypesClass): + DISCOVER = 0 + ERROR_EVENTS = 1 + """ + Error side of the split from Discover. + """ + TRANSACTION_LIKE = 2 + """ + This targets transaction-like data from the split from discover. + """ + + TYPES = [ + (DISCOVER, "discover"), + (ERROR_EVENTS, "error-events"), + (TRANSACTION_LIKE, "transaction-like"), + ] + TYPE_NAMES = [t[1] for t in TYPES] + + +def migrate_transactions_discover_queries_self_hosted( + apps: StateApps, schema_editor: BaseDatabaseSchemaEditor +) -> None: + queryset = DiscoverSavedQuery.objects.filter( + dataset=DiscoverSavedQueryTypes.TRANSACTION_LIKE + ).select_related("organization") + + for discover_query in RangeQuerySetWrapperWithProgressBar(queryset): + try: + new_explore_query = translate_discover_query_to_explore_query(discover_query) + if not new_explore_query.projects.exists(): + discover_projects_qs = DiscoverSavedQueryProject.objects.filter( + discover_saved_query_id=discover_query.id + ) + projects_to_create = [ + ExploreSavedQueryProject( + explore_saved_query_id=new_explore_query.id, + project_id=discover_project.project_id, + ) + for discover_project in discover_projects_qs + ] + ExploreSavedQueryProject.objects.bulk_create(projects_to_create) + new_explore_query.save() + except Exception as e: + sentry_sdk.capture_exception(e) + + +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 = True + + dependencies = [ + ("sentry", "1010_add_organizationcontributors_table"), + ("discover", "0003_discover_json_field"), + ("explore", "0006_add_changed_reason_field_explore"), + ] + + operations = [ + migrations.RunPython( + migrate_transactions_discover_queries_self_hosted, + reverse_code=migrations.RunPython.noop, + hints={ + "tables": [ + "sentry_discoversavedquery", + "sentry_discoversavedqueryproject", + "explore_exploresavedqueryproject", + ] + }, + ) + ] diff --git a/tests/sentry/migrations/test_1011_discover_to_explore_queries_self_hosted.py b/tests/sentry/migrations/test_1011_discover_to_explore_queries_self_hosted.py new file mode 100644 index 00000000000000..fdcf581a066ada --- /dev/null +++ b/tests/sentry/migrations/test_1011_discover_to_explore_queries_self_hosted.py @@ -0,0 +1,99 @@ +from sentry.discover.models import ( + DiscoverSavedQuery, + DiscoverSavedQueryProject, + DiscoverSavedQueryTypes, +) +from sentry.explore.models import ExploreSavedQueryDataset +from sentry.testutils.cases import SnubaTestCase, TestMigrations + + +class MigrateDiscoverQueriesToExploreQueriesSelfHostedTest(TestMigrations, SnubaTestCase): + migrate_from = "1010_add_organizationcontributors_table" + migrate_to = "1011_discover_to_explore_queries_self_hosted" + + def setup_before_migration(self, apps): + User = apps.get_model("sentry", "User") + + self.user = User.objects.create(email="test@sentry.io") + self.org = self.create_organization(name="Test org", slug="test-org") + self.project = self.create_project(organization=self.org) + self.project_ids = [self.project.id] + + self.query = {"fields": ["title", "count()"], "query": ""} + self.discover_query_with_fields_to_translate = DiscoverSavedQuery.objects.create( + organization=self.org, + name="Test query", + query=self.query, + dataset=DiscoverSavedQueryTypes.TRANSACTION_LIKE, + ) + for project_id in self.project_ids: + DiscoverSavedQueryProject.objects.create( + discover_saved_query=self.discover_query_with_fields_to_translate, + project_id=project_id, + ) + + self.discover_query_without_projects = DiscoverSavedQuery.objects.create( + organization=self.org, + name="Test query", + query=self.query, + dataset=DiscoverSavedQueryTypes.TRANSACTION_LIKE, + ) + + self.dropped_fields_query = { + "fields": ["id", "title", "count_web_vitals(measurements.lcp,good)"] + } + self.discover_query_dropped_fields = DiscoverSavedQuery.objects.create( + organization=self.org, + name="Test query", + query=self.dropped_fields_query, + dataset=DiscoverSavedQueryTypes.TRANSACTION_LIKE, + ) + + self.discover_query_errors = DiscoverSavedQuery.objects.create( + organization=self.org, + name="Test query", + query={}, + dataset=DiscoverSavedQueryTypes.ERROR_EVENTS, + ) + + def test(self): + self.discover_query_with_fields_to_translate.refresh_from_db() + self.discover_query_without_projects.refresh_from_db() + self.discover_query_dropped_fields.refresh_from_db() + self.discover_query_errors.refresh_from_db() + + # All TRANSACTION_LIKE queries should get explore queries, even without projects + # (no projects means "all projects") + assert self.discover_query_with_fields_to_translate.explore_query is not None + assert self.discover_query_without_projects.explore_query is not None + assert self.discover_query_dropped_fields.explore_query is not None + # ERRORS queries should NOT get explore queries + assert self.discover_query_errors.explore_query is None + + explore_query_with_translated_fields = ( + self.discover_query_with_fields_to_translate.explore_query + ) + explore_query_dropped_fields = self.discover_query_dropped_fields.explore_query + explore_query_no_projects = self.discover_query_without_projects.explore_query + + assert ( + explore_query_with_translated_fields.dataset == ExploreSavedQueryDataset.SEGMENT_SPANS + ) + assert explore_query_dropped_fields.dataset == ExploreSavedQueryDataset.SEGMENT_SPANS + assert explore_query_no_projects.dataset == ExploreSavedQueryDataset.SEGMENT_SPANS + + assert explore_query_with_translated_fields.query["query"][0]["fields"] == [ + "id", + "transaction", + ] + assert explore_query_with_translated_fields.query["query"][0]["aggregateField"] == [ + {"yAxes": ["count(span.duration)"], "chartType": 2} + ] + assert explore_query_with_translated_fields.query["query"][0]["query"] == "is_transaction:1" + assert explore_query_with_translated_fields.projects.count() == len(self.project_ids) + + assert explore_query_dropped_fields.query["query"][0]["fields"] == ["id", "transaction"] + assert explore_query_dropped_fields.query["query"][0]["aggregateField"] == [] + assert explore_query_dropped_fields.query["query"][0]["query"] == "is_transaction:1" + + assert explore_query_no_projects.projects.count() == 0