diff --git a/.github/actions/artifacts/action.yml b/.github/actions/artifacts/action.yml index a21b8010175a3a..5f44690fa44640 100644 --- a/.github/actions/artifacts/action.yml +++ b/.github/actions/artifacts/action.yml @@ -26,6 +26,7 @@ runs: steps: - name: Download and Verify Codecov CLI shell: bash + continue-on-error: true run: | ./.github/actions/artifacts/download_codecov_cli.py - name: Upload Coverage and Test Results diff --git a/fixtures/page_objects/explore_logs.py b/fixtures/page_objects/explore_logs.py new file mode 100644 index 00000000000000..52dfade0aac97f --- /dev/null +++ b/fixtures/page_objects/explore_logs.py @@ -0,0 +1,52 @@ +from selenium.webdriver.common.by import By + +from .base import BasePage +from .global_selection import GlobalSelectionPage + + +class ExploreLogsPage(BasePage): + def __init__(self, browser, client): + super().__init__(browser) + self.client = client + self.global_selection = GlobalSelectionPage(browser) + + def visit_explore_logs(self, org): + self.browser.get(f"/organizations/{org}/explore/logs/") + self.wait_until_loaded() + + def toggle_log_row_with_message(self, message): + row = self.get_log_row_with_message(message) + try: + expanded_count = len( + self.browser.find_elements(By.CSS_SELECTOR, '*[data-test-id="fields-tree"]') + ) + except Exception: + expanded_count = 0 + if expanded_count > 0: + row.click() + # If this is breaking make sure to only have one row expanded at a time. + # TODO: Target the correct field-tree with xpath. + self.browser.wait_until_not('[data-test-id="fields-tree"]') + else: + row.click() + self.browser.wait_until('[data-test-id="fields-tree"]') + + return row + + def get_log_row_with_message(self, message): + row = self.browser.find_element( + by=By.XPATH, + value=f'//*[@data-test-id="log-table-row" and .//*[contains(text(),"{message}")]]', + ) + return row + + def get_log_row_columns(self, row): + # The expanded row actually makes a new sibling row that contains the fields-tree. + columns = row.find_elements( + By.XPATH, 'following-sibling::*[1]//*[@data-test-id="attribute-tree-column"]' + ) + return columns + + def wait_until_loaded(self): + self.browser.wait_until_not('[data-test-id="loading-indicator"]') + self.browser.wait_until_test_id("logs-table") diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 619490f00a1580..7acba23f7a33da 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -29,4 +29,4 @@ tempest: 0002_make_message_type_nullable uptime: 0037_fix_drift_default_to_db_default -workflow_engine: 0052_migrate_errored_metric_alerts +workflow_engine: 0053_add_legacy_rule_indices diff --git a/pyproject.toml b/pyproject.toml index 09eeabfd4537eb..0fcccc1d476f82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -312,7 +312,6 @@ module = [ "sentry.issues.status_change", "sentry.issues.status_change_consumer", "sentry.issues.status_change_message", - "sentry.issues.streamline", "sentry.issues.update_inbox", "sentry.lang.java.processing", "sentry.llm.*", diff --git a/requirements-base.txt b/requirements-base.txt index 0305e0c13cbfe1..e13ed4b3e6f858 100644 --- a/requirements-base.txt +++ b/requirements-base.txt @@ -68,7 +68,7 @@ rfc3986-validator>=0.1.1 sentry-arroyo>=2.21.0 sentry-kafka-schemas>=1.2.0 sentry-ophio>=1.1.3 -sentry-protos==0.1.72 +sentry-protos==0.1.74 sentry-redis-tools>=0.5.0 sentry-relay>=0.9.8 sentry-sdk[http2]>=2.25.1 diff --git a/requirements-dev-frozen.txt b/requirements-dev-frozen.txt index 2e58ab5cc9d667..8cc5d7e468fe89 100644 --- a/requirements-dev-frozen.txt +++ b/requirements-dev-frozen.txt @@ -189,7 +189,7 @@ sentry-forked-djangorestframework-stubs==3.15.3.post1 sentry-forked-email-reply-parser==0.5.12.post1 sentry-kafka-schemas==1.2.0 sentry-ophio==1.1.3 -sentry-protos==0.1.72 +sentry-protos==0.1.74 sentry-redis-tools==0.5.0 sentry-relay==0.9.8 sentry-sdk==2.27.0 diff --git a/requirements-frozen.txt b/requirements-frozen.txt index 1a37bb36f16e7e..f84ef2c5a444f0 100644 --- a/requirements-frozen.txt +++ b/requirements-frozen.txt @@ -127,7 +127,7 @@ sentry-arroyo==2.21.0 sentry-forked-email-reply-parser==0.5.12.post1 sentry-kafka-schemas==1.2.0 sentry-ophio==1.1.3 -sentry-protos==0.1.72 +sentry-protos==0.1.74 sentry-redis-tools==0.5.0 sentry-relay==0.9.8 sentry-sdk==2.27.0 diff --git a/src/sentry/api/endpoints/organization_details.py b/src/sentry/api/endpoints/organization_details.py index 5a74cbc3b86e73..b31dadfb265bd1 100644 --- a/src/sentry/api/endpoints/organization_details.py +++ b/src/sentry/api/endpoints/organization_details.py @@ -47,6 +47,7 @@ DEBUG_FILES_ROLE_DEFAULT, EVENTS_MEMBER_ADMIN_DEFAULT, GITHUB_COMMENT_BOT_DEFAULT, + GITLAB_COMMENT_BOT_DEFAULT, HIDE_AI_FEATURES_DEFAULT, ISSUE_ALERTS_THREAD_DEFAULT, JOIN_REQUESTS_DEFAULT, @@ -199,6 +200,12 @@ bool, GITHUB_COMMENT_BOT_DEFAULT, ), + ( + "gitlabPRBot", + "sentry:gitlab_pr_bot", + bool, + GITLAB_COMMENT_BOT_DEFAULT, + ), ( "issueAlertsThreadFlag", "sentry:issue_alerts_thread_flag", @@ -263,6 +270,7 @@ class OrganizationSerializer(BaseOrganizationSerializer): githubOpenPRBot = serializers.BooleanField(required=False) githubNudgeInvite = serializers.BooleanField(required=False) githubPRBot = serializers.BooleanField(required=False) + gitlabPRBot = serializers.BooleanField(required=False) issueAlertsThreadFlag = serializers.BooleanField(required=False) metricAlertsThreadFlag = serializers.BooleanField(required=False) require2FA = serializers.BooleanField(required=False) @@ -793,6 +801,12 @@ class OrganizationDetailsPutSerializer(serializers.Serializer): required=False, ) + # gitlab features + gitlabPRBot = serializers.BooleanField( + help_text="Specify `true` to allow Sentry to comment on recent pull requests suspected of causing issues. Requires a GitLab integration.", + required=False, + ) + # slack features issueAlertsThreadFlag = serializers.BooleanField( help_text="Specify `true` to allow the Sentry Slack integration to post replies in threads for an Issue Alert notification. Requires a Slack integration.", diff --git a/src/sentry/api/endpoints/organization_events.py b/src/sentry/api/endpoints/organization_events.py index 3b1a592daf15cd..28345ce1bc9d99 100644 --- a/src/sentry/api/endpoints/organization_events.py +++ b/src/sentry/api/endpoints/organization_events.py @@ -29,6 +29,7 @@ errors, metrics_enhanced_performance, metrics_performance, + ourlogs, spans_rpc, transactions, ) @@ -754,6 +755,8 @@ def fn(offset, limit): data_fn = data_fn_factory(dataset) + max_per_page = 1000 if dataset == ourlogs else None + with handle_query_errors(): # Don't include cursor headers if the client won't be using them if request.GET.get("noPagination"): @@ -779,4 +782,5 @@ def fn(offset, limit): standard_meta=True, dataset=dataset, ), + max_per_page=max_per_page, ) diff --git a/src/sentry/api/endpoints/organization_index.py b/src/sentry/api/endpoints/organization_index.py index 231ed6009b1ffc..c4088ea4b14161 100644 --- a/src/sentry/api/endpoints/organization_index.py +++ b/src/sentry/api/endpoints/organization_index.py @@ -27,7 +27,6 @@ from sentry.auth.superuser import is_active_superuser from sentry.db.models.query import in_iexact from sentry.hybridcloud.rpc import IDEMPOTENCY_KEY_LENGTH -from sentry.issues.streamline import apply_streamline_rollout_group from sentry.models.organization import Organization, OrganizationStatus from sentry.models.organizationmember import OrganizationMember from sentry.models.projectplatform import ProjectPlatform @@ -307,6 +306,7 @@ def post(self, request: Request) -> Response: organization_id=org.id, ) - apply_streamline_rollout_group(organization=org) + # New organizations should not see the legacy UI + org.update_option("sentry:streamline_ui_only", True) return Response(serialize(org, request.user), status=201) diff --git a/src/sentry/api/endpoints/project_trace_item_details.py b/src/sentry/api/endpoints/project_trace_item_details.py index f58fe37e8104a7..2eaecd7647c3b6 100644 --- a/src/sentry/api/endpoints/project_trace_item_details.py +++ b/src/sentry/api/endpoints/project_trace_item_details.py @@ -44,6 +44,8 @@ def convert_rpc_attribute_to_json( column_type = "string" elif val_type in ["int", "float", "double"]: column_type = "number" + if val_type == "double": + val_type = "float" else: raise BadRequest(f"unknown column type in protobuf: {val_type}") diff --git a/src/sentry/api/endpoints/trace_explorer_ai_query.py b/src/sentry/api/endpoints/trace_explorer_ai_query.py new file mode 100644 index 00000000000000..600cb2a12950e5 --- /dev/null +++ b/src/sentry/api/endpoints/trace_explorer_ai_query.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import logging +from typing import Any + +import orjson +import requests +from django.conf import settings +from rest_framework import status +from rest_framework.response import Response + +from sentry import features +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import region_silo_endpoint +from sentry.api.bases import OrganizationEndpoint +from sentry.models.organization import Organization +from sentry.seer.seer_setup import get_seer_org_acknowledgement, get_seer_user_acknowledgement +from sentry.seer.signed_seer_api import sign_with_seer_secret + +logger = logging.getLogger(__name__) + +from rest_framework.request import Request + + +def send_translate_request(org_id: int, project_ids: list[int], natural_language_query: str) -> Any: + """ + Sends a request to seer to create the initial cached prompt / setup the AI models + """ + body = orjson.dumps( + { + "org_id": org_id, + "project_ids": project_ids, + "natural_language_query": natural_language_query, + } + ) + + response = requests.post( + f"{settings.SEER_AUTOFIX_URL}/v1/assisted-query/translate", + data=body, + headers={ + "content-type": "application/json;charset=utf-8", + **sign_with_seer_secret(body), + }, + ) + response.raise_for_status() + return response.json() + + +@region_silo_endpoint +class TraceExplorerAIQuery(OrganizationEndpoint): + """ + This endpoint is called when a user visits the trace explorer with the correct flags enabled. + """ + + publish_status = { + "POST": ApiPublishStatus.EXPERIMENTAL, + } + owner = ApiOwner.ML_AI + + @staticmethod + def post(request: Request, organization: Organization) -> Response: + """ + Checks if we are able to run Autofix on the given group. + """ + project_ids = [int(x) for x in request.data.get("project_ids", [])] + natural_language_query = request.data.get("natural_language_query") + + if len(project_ids) == 0 or not natural_language_query: + return Response( + { + "detail": "Missing one or more required parameters: project_ids, natural_language_query" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not features.has( + "organizations:gen-ai-explore-traces", organization=organization, actor=request.user + ): + return Response( + {"detail": "Organization does not have access to this feature"}, + status=status.HTTP_403_FORBIDDEN, + ) + + user_acknowledgement = get_seer_user_acknowledgement( + user_id=request.user.id, org_id=organization.id + ) + org_acknowledgement = user_acknowledgement or get_seer_org_acknowledgement( + org_id=organization.id + ) + + if not org_acknowledgement: + return Response( + {"detail": "Organization has not opted in to this feature."}, + status=status.HTTP_403_FORBIDDEN, + ) + + if not settings.SEER_AUTOFIX_URL: + return Response( + {"detail": "Seer is not properly configured."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + data = send_translate_request(organization.id, project_ids, natural_language_query) + + return Response( + { + "status": "ok", + "query": data["query"], # the sentry EQS query as a string + "stats_period": data["stats_period"], + "group_by": list(data.get("group_by", [])), + "visualization": list( + data.get("visualization") + ), # [{chart_type: 1, y_axes: ["count_message"]}, ...] + "sort": data["sort"], + } + ) diff --git a/src/sentry/api/endpoints/trace_explorer_ai_setup.py b/src/sentry/api/endpoints/trace_explorer_ai_setup.py new file mode 100644 index 00000000000000..f099a722ce0bb7 --- /dev/null +++ b/src/sentry/api/endpoints/trace_explorer_ai_setup.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import logging + +import orjson +import requests +from django.conf import settings +from rest_framework import status +from rest_framework.response import Response + +from sentry import features +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import region_silo_endpoint +from sentry.api.bases import OrganizationEndpoint +from sentry.models.organization import Organization +from sentry.seer.seer_setup import get_seer_org_acknowledgement, get_seer_user_acknowledgement +from sentry.seer.signed_seer_api import sign_with_seer_secret + +logger = logging.getLogger(__name__) + +from rest_framework.request import Request + + +def fire_setup_request(org_id: int, project_ids: list[int]) -> None: + """ + Sends a request to seer to create the initial cached prompt / setup the AI models + """ + body = orjson.dumps( + { + "org_id": org_id, + "project_ids": project_ids, + } + ) + + response = requests.post( + f"{settings.SEER_AUTOFIX_URL}/v1/assisted-query/create-cache", + data=body, + headers={ + "content-type": "application/json;charset=utf-8", + **sign_with_seer_secret(body), + }, + ) + response.raise_for_status() + + +@region_silo_endpoint +class TraceExplorerAISetup(OrganizationEndpoint): + """ + This endpoint is called when a user visits the trace explorer with the correct flags enabled. + """ + + publish_status = { + "POST": ApiPublishStatus.EXPERIMENTAL, + } + owner = ApiOwner.ML_AI + + @staticmethod + def post(request: Request, organization: Organization) -> Response: + """ + Checks if we are able to run Autofix on the given group. + """ + project_ids = [int(x) for x in request.data.get("project_ids", [])] + + if not features.has( + "organizations:gen-ai-explore-traces", organization=organization, actor=request.user + ): + return Response( + {"detail": "Organization does not have access to this feature"}, + status=status.HTTP_403_FORBIDDEN, + ) + user_acknowledgement = get_seer_user_acknowledgement( + user_id=request.user.id, org_id=organization.id + ) + org_acknowledgement = user_acknowledgement or get_seer_org_acknowledgement( + org_id=organization.id + ) + + if not org_acknowledgement: + return Response( + {"detail": "Organization has not opted in to this feature."}, + status=status.HTTP_403_FORBIDDEN, + ) + + if not settings.SEER_AUTOFIX_URL: + return Response( + {"detail": "Seer is not properly configured."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + fire_setup_request(organization.id, project_ids) + + return Response({"status": "ok"}) diff --git a/src/sentry/api/serializers/models/organization.py b/src/sentry/api/serializers/models/organization.py index e6789e3e2e4e79..9b8337f0e76fb4 100644 --- a/src/sentry/api/serializers/models/organization.py +++ b/src/sentry/api/serializers/models/organization.py @@ -36,6 +36,7 @@ DEBUG_FILES_ROLE_DEFAULT, EVENTS_MEMBER_ADMIN_DEFAULT, GITHUB_COMMENT_BOT_DEFAULT, + GITLAB_COMMENT_BOT_DEFAULT, HIDE_AI_FEATURES_DEFAULT, ISSUE_ALERTS_THREAD_DEFAULT, JOIN_REQUESTS_DEFAULT, @@ -541,6 +542,7 @@ class DetailedOrganizationSerializerResponse(_DetailedOrganizationSerializerResp githubPRBot: bool githubOpenPRBot: bool githubNudgeInvite: bool + gitlabPRBot: bool aggregatedDataConsent: bool genAIConsent: bool isDynamicallySampled: bool @@ -679,6 +681,7 @@ def serialize( # type: ignore[explicit-override, override] "githubNudgeInvite": bool( obj.get_option("sentry:github_nudge_invite", GITHUB_COMMENT_BOT_DEFAULT) ), + "gitlabPRBot": bool(obj.get_option("sentry:gitlab_pr_bot", GITLAB_COMMENT_BOT_DEFAULT)), "genAIConsent": bool( obj.get_option("sentry:gen_ai_consent_v2024_11_14", DATA_CONSENT_DEFAULT) ), diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 17776b4e63297c..dda58890618b71 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -64,6 +64,7 @@ from sentry.api.endpoints.source_map_debug_blue_thunder_edition import ( SourceMapDebugBlueThunderEditionEndpoint, ) +from sentry.api.endpoints.trace_explorer_ai_setup import TraceExplorerAISetup from sentry.data_export.endpoints.data_export import DataExportEndpoint from sentry.data_export.endpoints.data_export_details import DataExportDetailsEndpoint from sentry.data_secrecy.api.waive_data_secrecy import WaiveDataSecrecyEndpoint @@ -707,6 +708,7 @@ from .endpoints.team_stats import TeamStatsEndpoint from .endpoints.team_time_to_resolution import TeamTimeToResolutionEndpoint from .endpoints.team_unresolved_issue_age import TeamUnresolvedIssueAgeEndpoint +from .endpoints.trace_explorer_ai_query import TraceExplorerAIQuery from .endpoints.user_organizationintegrations import UserOrganizationIntegrationsEndpoint from .endpoints.user_organizations import UserOrganizationsEndpoint from .endpoints.user_subscriptions import UserSubscriptionsEndpoint @@ -2046,6 +2048,16 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: OrganizationSentryAppsEndpoint.as_view(), name="sentry-api-0-organization-sentry-apps", ), + re_path( + r"^(?P[^\/]+)/trace-explorer-ai/setup/$", + TraceExplorerAISetup.as_view(), + name="sentry-api-0-trace-explorer-ai-setup", + ), + re_path( + r"^(?P[^\/]+)/trace-explorer-ai/query/$", + TraceExplorerAIQuery.as_view(), + name="sentry-api-0-trace-explorer-ai-query", + ), re_path( r"^(?P[^\/]+)/sentry-app-components/$", OrganizationSentryAppComponentsEndpoint.as_view(), diff --git a/src/sentry/apidocs/examples/organization_examples.py b/src/sentry/apidocs/examples/organization_examples.py index 8db55876f25e85..ae8bd704fe7f05 100644 --- a/src/sentry/apidocs/examples/organization_examples.py +++ b/src/sentry/apidocs/examples/organization_examples.py @@ -320,6 +320,7 @@ class OrganizationExamples: "githubPRBot": True, "githubOpenPRBot": True, "githubNudgeInvite": True, + "gitlabPRBot": True, "aggregatedDataConsent": False, "issueAlertsThreadFlag": True, "metricAlertsThreadFlag": True, diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index c12741fee1e9b4..d734cf8f54cc66 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -1040,8 +1040,8 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: }, "reattempt-deletions-control": { "task": "sentry.deletions.tasks.reattempt_deletions_control", - # 03:00 PDT, 07:00 EDT, 10:00 UTC - "schedule": crontab(hour="10", minute="0"), + # Every other hour + "schedule": crontab(hour="*/2", minute="0"), "options": {"expires": 60 * 25, "queue": "cleanup.control"}, }, "schedule-hybrid-cloud-foreign-key-jobs-control": { @@ -1174,8 +1174,8 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: }, "reattempt-deletions": { "task": "sentry.deletions.tasks.reattempt_deletions", - # 03:00 PDT, 07:00 EDT, 10:00 UTC - "schedule": crontab(hour="10", minute="0"), + # Every other hour + "schedule": crontab(hour="*/2", minute="0"), "options": {"expires": 60 * 25}, }, "schedule-weekly-organization-reports-new": { @@ -1406,6 +1406,11 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: "sentry.deletions.tasks.hybrid_cloud", "sentry.deletions.tasks.scheduled", "sentry.demo_mode.tasks", + "sentry.dynamic_sampling.tasks.boost_low_volume_projects", + "sentry.dynamic_sampling.tasks.boost_low_volume_transactions", + "sentry.dynamic_sampling.tasks.custom_rule_notifications", + "sentry.dynamic_sampling.tasks.recalibrate_orgs", + "sentry.dynamic_sampling.tasks.sliding_window_org", "sentry.hybridcloud.tasks.deliver_from_outbox", "sentry.hybridcloud.tasks.deliver_webhooks", "sentry.incidents.tasks", @@ -1453,6 +1458,7 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: "sentry.tasks.auto_source_code_config", "sentry.tasks.autofix", "sentry.tasks.beacon", + "sentry.tasks.check_am2_compatibility", "sentry.tasks.check_new_issue_threshold_met", "sentry.tasks.clear_expired_resolutions", "sentry.tasks.clear_expired_rulesnoozes", @@ -1465,14 +1471,15 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: "sentry.tasks.delete_seer_grouping_records", "sentry.tasks.digests", "sentry.tasks.email", - "sentry.tasks.relay", "sentry.tasks.embeddings_grouping.backfill_seer_grouping_records_for_project", "sentry.tasks.groupowner", "sentry.tasks.merge", "sentry.tasks.on_demand_metrics", "sentry.tasks.options", "sentry.tasks.ping", + "sentry.tasks.post_process", "sentry.tasks.process_buffer", + "sentry.tasks.relay", "sentry.tasks.release_registry", "sentry.tasks.repository", "sentry.tasks.reprocessing2", @@ -1490,12 +1497,6 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: "sentry.uptime.rdap.tasks", "sentry.uptime.subscriptions.tasks", "sentry.workflow_engine.processors.delayed_workflow", - "sentry.dynamic_sampling.tasks.boost_low_volume_projects", - "sentry.dynamic_sampling.tasks.recalibrate_orgs", - "sentry.dynamic_sampling.tasks.custom_rule_notifications", - "sentry.dynamic_sampling.tasks.sliding_window_org", - "sentry.dynamic_sampling.tasks.boost_low_volume_transactions", - "sentry.tasks.check_am2_compatibility", # Used for tests "sentry.taskworker.tasks.examples", ) @@ -1508,6 +1509,13 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: }, } +TASKWORKER_ENABLE_HIGH_THROUGHPUT_NAMESPACES = False +TASKWORKER_HIGH_THROUGHPUT_NAMESPACES = { + "ingest.profiling", + "ingest.transactions", + "ingest.errors", +} + # Sentry logs to two major places: stdout, and its internal project. # To disable logging to the internal project, add a logger whose only # handler is 'console' and disable propagating upwards. diff --git a/src/sentry/constants.py b/src/sentry/constants.py index f363174ad53295..4bbdea648b47c8 100644 --- a/src/sentry/constants.py +++ b/src/sentry/constants.py @@ -698,6 +698,7 @@ class InsightModules(Enum): JOIN_REQUESTS_DEFAULT = True HIDE_AI_FEATURES_DEFAULT = False GITHUB_COMMENT_BOT_DEFAULT = True +GITLAB_COMMENT_BOT_DEFAULT = True ISSUE_ALERTS_THREAD_DEFAULT = True METRIC_ALERTS_THREAD_DEFAULT = True DATA_CONSENT_DEFAULT = False diff --git a/src/sentry/deletions/defaults/project.py b/src/sentry/deletions/defaults/project.py index a4deeae904e4e6..fc7fc588593963 100644 --- a/src/sentry/deletions/defaults/project.py +++ b/src/sentry/deletions/defaults/project.py @@ -27,6 +27,7 @@ def get_child_relations(self, instance: Project) -> list[BaseRelation]: from sentry.models.groupassignee import GroupAssignee from sentry.models.groupbookmark import GroupBookmark from sentry.models.groupemailthread import GroupEmailThread + from sentry.models.groupopenperiod import GroupOpenPeriod from sentry.models.grouprelease import GroupRelease from sentry.models.grouprulestatus import GroupRuleStatus from sentry.models.groupseen import GroupSeen @@ -56,6 +57,8 @@ def get_child_relations(self, instance: Project) -> list[BaseRelation]: # in bulk for m1 in ( + # GroupOpenPeriod should be deleted before Activity + GroupOpenPeriod, Activity, AlertRuleProjects, EnvironmentProject, diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 542a86cb92a048..32d3ec32514dba 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -116,8 +116,6 @@ def register_temporary_features(manager: FeatureManager): manager.add("organizations:ds-org-recalibration", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False) # Enable custom dynamic sampling rates manager.add("organizations:dynamic-sampling-custom", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enable new munging logic for java frames - manager.add("organizations:java-frame-munging-new-logic", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable archive/escalating issue workflow features in v2 manager.add("organizations:escalating-issues-v2", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False) # Enable emiting escalating data to the metrics backend diff --git a/src/sentry/hybridcloud/services/region_organization_provisioning/impl.py b/src/sentry/hybridcloud/services/region_organization_provisioning/impl.py index 1511f17c406e46..f42b7ea63995a6 100644 --- a/src/sentry/hybridcloud/services/region_organization_provisioning/impl.py +++ b/src/sentry/hybridcloud/services/region_organization_provisioning/impl.py @@ -12,7 +12,6 @@ from sentry.hybridcloud.services.region_organization_provisioning import ( RegionOrganizationProvisioningRpcService, ) -from sentry.issues.streamline import apply_streamline_rollout_group from sentry.models.organization import ORGANIZATION_NAME_MAX_LENGTH, Organization from sentry.models.organizationmember import OrganizationMember from sentry.models.organizationmemberteam import OrganizationMemberTeam @@ -56,8 +55,8 @@ def _create_organization_and_team( org = Organization.objects.create( id=organization_id, name=truncated_name, slug=slug, is_test=is_test ) - - apply_streamline_rollout_group(organization=org) + # New organizations should not see the legacy UI + org.update_option("sentry:streamline_ui_only", True) # Slug changes mean there was either a collision with the organization slug # or a bug in the slugify implementation, so we reject the organization creation diff --git a/src/sentry/incidents/endpoints/organization_alert_rule_index.py b/src/sentry/incidents/endpoints/organization_alert_rule_index.py index 18f05ac008033a..6fd2c9807f8e28 100644 --- a/src/sentry/incidents/endpoints/organization_alert_rule_index.py +++ b/src/sentry/incidents/endpoints/organization_alert_rule_index.py @@ -325,7 +325,10 @@ def get(self, request: Request, organization) -> Response: incident_status=Case( # If an uptime monitor is failing we want to treat it the same as if an alert is failing, so sort # by the critical status - When(uptime_status=UptimeStatus.FAILED, then=IncidentStatus.CRITICAL.value), + When( + uptime_subscription__uptime_status=UptimeStatus.FAILED, + then=IncidentStatus.CRITICAL.value, + ), default=-2, ) ) diff --git a/src/sentry/incidents/endpoints/serializers/workflow_engine_detector.py b/src/sentry/incidents/endpoints/serializers/workflow_engine_detector.py index 041b8b4aa29f72..4050ea65052e2c 100644 --- a/src/sentry/incidents/endpoints/serializers/workflow_engine_detector.py +++ b/src/sentry/incidents/endpoints/serializers/workflow_engine_detector.py @@ -1,34 +1,349 @@ -from datetime import datetime +from collections import defaultdict +from collections.abc import Mapping, MutableMapping, Sequence +from typing import Any, DefaultDict -from sentry.api.serializers import Serializer +from django.contrib.auth.models import AnonymousUser +from django.db.models import Q + +from sentry.api.serializers import Serializer, serialize from sentry.incidents.endpoints.serializers.alert_rule import AlertRuleSerializerResponse -from sentry.workflow_engine.models import Detector +from sentry.incidents.endpoints.serializers.workflow_engine_data_condition import ( + WorkflowEngineDataConditionSerializer, +) +from sentry.incidents.endpoints.serializers.workflow_engine_incident import ( + WorkflowEngineIncidentSerializer, +) +from sentry.incidents.models.alert_rule import AlertRuleStatus +from sentry.models.groupopenperiod import GroupOpenPeriod +from sentry.sentry_apps.models.sentry_app_installation import prepare_ui_component +from sentry.sentry_apps.services.app import app_service +from sentry.sentry_apps.services.app.model import RpcSentryAppComponentContext +from sentry.snuba.models import QuerySubscription +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser +from sentry.users.services.user.service import user_service +from sentry.workflow_engine.models import ( + Action, + ActionGroupStatus, + AlertRuleDetector, + DataCondition, + DataConditionGroupAction, + DataSourceDetector, + Detector, +) +from sentry.workflow_engine.types import DetectorPriorityLevel class WorkflowEngineDetectorSerializer(Serializer): - def serialize(self, obj: Detector, attrs, user, **kwargs) -> AlertRuleSerializerResponse: - # TODO: Implement this - return { - "id": "-2", - "name": "Test Alert Rule", - "organizationId": "-2", - "status": 1, - "query": "test", - "aggregate": "test", - "timeWindow": 1, - "resolution": 1, - "thresholdPeriod": 1, - "triggers": [ - { - "id": "-1", - "status": 1, - "dateModified": datetime.now(), - "dateCreated": datetime.now(), - } + """ + A temporary serializer to be used by the old alert rule endpoints to return data read from the new ACI models + """ + + def __init__(self, expand: list[str] | None = None, prepare_component_fields: bool = False): + self.expand = expand or [] + self.prepare_component_fields = prepare_component_fields + + def add_sentry_app_installations_by_sentry_app_id( + self, actions: list[Action], organization_id: int + ) -> Mapping[str, RpcSentryAppComponentContext]: + sentry_app_installations_by_sentry_app_id: Mapping[str, RpcSentryAppComponentContext] = {} + if self.prepare_component_fields: + sentry_app_ids = [ + int(action.config.get("target_identifier")) + for action in actions + if action.config.get("sentry_app_identifier") is not None + ] + install_contexts = app_service.get_component_contexts( + filter={"app_ids": sentry_app_ids, "organization_id": organization_id}, + component_type="alert-rule-action", + ) + sentry_app_installations_by_sentry_app_id = { + str(context.installation.sentry_app.id): context + for context in install_contexts + if context.installation.sentry_app + } + return sentry_app_installations_by_sentry_app_id + + def add_triggers_and_actions( + self, + result: DefaultDict[Detector, dict[str, Any]], + detectors: dict[int, Detector], + sentry_app_installations_by_sentry_app_id: Mapping[str, RpcSentryAppComponentContext], + serialized_data_conditions: list[dict[str, Any]], + ) -> None: + for serialized in serialized_data_conditions: + errors = [] + alert_rule_id = serialized.get("alertRuleId") + assert alert_rule_id + detector_id = AlertRuleDetector.objects.values_list("detector_id", flat=True).get( + alert_rule_id=alert_rule_id + ) + detector = detectors[int(detector_id)] + alert_rule_triggers = result[detector].setdefault("triggers", []) + + for action in serialized.get("actions", []): + if action is None: + continue + + # Prepare AlertRuleTriggerActions that are SentryApp components + install_context = None + sentry_app_id = str(action.get("sentryAppId")) + if sentry_app_id: + install_context = sentry_app_installations_by_sentry_app_id.get(sentry_app_id) + if install_context: + rpc_install = install_context.installation + rpc_component = install_context.component + rpc_app = rpc_install.sentry_app + assert rpc_app + + action["sentryAppInstallationUuid"] = rpc_install.uuid + component = ( + prepare_ui_component( + rpc_install, + rpc_component, + None, + action.get("settings"), + ) + if rpc_component + else None + ) + if component is None: + errors.append({"detail": f"Could not fetch details from {rpc_app.name}"}) + action["disabled"] = True + continue + + action["formFields"] = component.app_schema.get("settings", {}) + + if errors: + result[detector]["errors"] = errors + alert_rule_triggers.append(serialized) + + def add_projects( + self, result: DefaultDict[Detector, dict[str, Any]], detectors: dict[int, Detector] + ) -> None: + detector_projects = set() + for detector in detectors.values(): + detector_projects.add((detector.id, detector.project.slug)) + + for detector_id, project_slug in detector_projects: + rule_result = result[detectors[detector_id]].setdefault( + "projects", [] + ) # keyerror I guess could be here + rule_result.append(project_slug) + + def add_created_by( + self, result: DefaultDict[Detector, dict[str, Any]], detectors: Sequence[Detector] + ) -> None: + user_by_user_id: MutableMapping[int, RpcUser] = { + user.id: user + for user in user_service.get_many_by_id( + ids=[ + detector.created_by_id + for detector in detectors + if detector.created_by_id is not None + ] + ) + } + for detector in detectors: + # this is based on who created or updated it during dual write + rpc_user = None + if detector.created_by_id: + rpc_user = user_by_user_id.get(detector.created_by_id) + if not rpc_user: + result[detector]["created_by"] = {} + else: + created_by = dict( + id=rpc_user.id, name=rpc_user.get_display_name(), email=rpc_user.email + ) + result[detector]["created_by"] = created_by + + def add_owner( + self, result: DefaultDict[Detector, dict[str, Any]], detectors: Sequence[Detector] + ) -> None: + for detector in detectors: + if detector.owner_user_id or detector.owner_team_id: + actor = detector.owner + if actor: + result[detector]["owner"] = actor.identifier + + def add_latest_incident( + self, + result: DefaultDict[Detector, dict[str, Any]], + user: User | RpcUser | AnonymousUser, + detectors: dict[int, Detector], + detector_to_action_ids: defaultdict[Detector, list[int]], + ) -> None: + all_action_ids = [] + for action_ids in detector_to_action_ids.values(): + all_action_ids.extend(action_ids) + + action_group_statuses = ActionGroupStatus.objects.filter(action_id__in=all_action_ids) + + detector_to_group_ids = defaultdict(list) + for action_group_status in action_group_statuses: + for detector, action_ids in detector_to_action_ids.items(): + if action_group_status.action_id in action_ids: + detector_to_group_ids[detector].append(action_group_status.group_id) + + open_periods = None + group_ids = [action_group_status.group_id for action_group_status in action_group_statuses] + if group_ids: + open_periods = GroupOpenPeriod.objects.filter( + group__in=[group_id for group_id in group_ids] + ) + + for detector in detectors.values(): + # TODO: this serializer is half baked + if open_periods: + latest_open_periods = open_periods.filter( + Q(group__in=detector_to_group_ids[detector]) + ).order_by("-date_started") + serialized_group_open_period = serialize( + latest_open_periods.first(), user, WorkflowEngineIncidentSerializer() + ) + result[detector]["latestIncident"] = serialized_group_open_period + + def get_attrs( + self, item_list: Sequence[Detector], user: User | RpcUser | AnonymousUser, **kwargs: Any + ) -> defaultdict[Detector, dict[str, Any]]: + detectors = {item.id: item for item in item_list} + result: DefaultDict[Detector, dict[str, Any]] = defaultdict(dict) + + detector_workflow_condition_group_ids = [ + detector.workflow_condition_group.id + for detector in detectors.values() + if detector.workflow_condition_group + ] + detector_trigger_data_conditions = DataCondition.objects.filter( + condition_group__in=detector_workflow_condition_group_ids, + condition_result__in=[DetectorPriorityLevel.HIGH, DetectorPriorityLevel.MEDIUM], + ) + + action_filter_data_condition_groups = DataCondition.objects.filter( + comparison__in=[ + detector_trigger.condition_result + for detector_trigger in detector_trigger_data_conditions ], - "dateModified": datetime.now(), - "dateCreated": datetime.now(), - "createdBy": {}, - "description": "test", - "detectionType": "test", + ).exclude(condition_group__in=detector_workflow_condition_group_ids) + + dcgas = DataConditionGroupAction.objects.filter( + condition_group__in=[ + action_filter.condition_group + for action_filter in action_filter_data_condition_groups + ] + ).select_related("action") + actions = [dcga.action for dcga in dcgas] + + # add sentry app data + organization_id = [detector.project.organization_id for detector in detectors.values()][0] + sentry_app_installations_by_sentry_app_id = ( + self.add_sentry_app_installations_by_sentry_app_id(actions, organization_id) + ) + + # add trigger and action data + serialized_data_conditions: list[dict[str, Any]] = [] + for trigger in detector_trigger_data_conditions: + serialized_data_conditions.extend( + serialize( + [trigger], + user, + WorkflowEngineDataConditionSerializer(), + **kwargs, + ) + ) + self.add_triggers_and_actions( + result, + detectors, + sentry_app_installations_by_sentry_app_id, + serialized_data_conditions, + ) + self.add_projects(result, detectors) + self.add_created_by(result, list(detectors.values())) + self.add_owner(result, list(detectors.values())) + # skipping snapshot data + + if "latestIncident" in self.expand: + # the most horrible way to map a detector to it's action ids but idk if I can make this less horrible + detector_to_workflow_condition_group_ids = { + detector: detector.workflow_condition_group.id + for detector in detectors.values() + if detector.workflow_condition_group + } + detector_to_detector_triggers = defaultdict(list) + for trigger in detector_trigger_data_conditions: + for detector, wcg_id in detector_to_workflow_condition_group_ids.items(): + if trigger.condition_group.id is wcg_id: + detector_to_detector_triggers[detector].append(trigger) + + detector_to_action_filters = defaultdict(list) + for action_filter in action_filter_data_condition_groups: + for detector, detector_triggers in detector_to_detector_triggers.items(): + for trigger in detector_triggers: + if action_filter.comparison is trigger.condition_result: + detector_to_action_filters[detector].append(action_filter) + + detector_to_action_ids = defaultdict(list) + for dcga in dcgas: + for detector, action_filters in detector_to_action_filters.items(): + for action_filter in action_filters: + if action_filter.condition_group.id is dcga.condition_group.id: + detector_to_action_ids[detector].append(dcga.action.id) + + self.add_latest_incident(result, user, detectors, detector_to_action_ids) + + # add information from snubaquery + data_source_detectors = DataSourceDetector.objects.filter(detector_id__in=detectors.keys()) + query_subscriptions = QuerySubscription.objects.filter( + id__in=[dsd.data_source.source_id for dsd in data_source_detectors] + ) + + for detector in detectors.values(): + data_source_detector = data_source_detectors.get(Q(detector=detector)) + query_subscription = query_subscriptions.get( + Q(id=data_source_detector.data_source.source_id) + ) + result[detector]["query"] = query_subscription.snuba_query.query + result[detector]["aggregate"] = query_subscription.snuba_query.aggregate + result[detector]["timeWindow"] = query_subscription.snuba_query.time_window + result[detector]["resolution"] = query_subscription.snuba_query.resolution + + return result + + def serialize(self, obj: Detector, attrs, user, **kwargs) -> AlertRuleSerializerResponse: + triggers = attrs.get("triggers", []) + alert_rule_detector_id = None + + if triggers: + alert_rule_detector_id = triggers[0].get("alertRuleId") + else: + alert_rule_detector_id = AlertRuleDetector.objects.values_list( + "alert_rule_id", flat=True + ).get(detector=obj) + + data: AlertRuleSerializerResponse = { + "id": str(alert_rule_detector_id), + "name": obj.name, + "organizationId": obj.project.organization_id, + "status": ( + AlertRuleStatus.PENDING.value + if obj.enabled is True + else AlertRuleStatus.DISABLED.value + ), + "query": attrs.get("query"), + "aggregate": attrs.get("aggregate"), + "timeWindow": attrs.get("timeWindow"), + "resolution": attrs.get("resolution"), + "thresholdPeriod": obj.config.get("thresholdPeriod"), + "triggers": triggers, + "projects": sorted(attrs.get("projects", [])), + "owner": attrs.get("owner", None), + "dateModified": obj.date_updated, + "dateCreated": obj.date_added, + "createdBy": attrs.get("created_by"), + "description": obj.description if obj.description else "", + "detectionType": obj.type, } + if "latestIncident" in self.expand: + data["latestIncident"] = attrs.get("latestIncident", None) + + return data diff --git a/src/sentry/ingest/transaction_clusterer/datasource/redis.py b/src/sentry/ingest/transaction_clusterer/datasource/redis.py index a9d67933960a52..5948e3b38ca0a8 100644 --- a/src/sentry/ingest/transaction_clusterer/datasource/redis.py +++ b/src/sentry/ingest/transaction_clusterer/datasource/redis.py @@ -71,6 +71,17 @@ def get_active_projects(namespace: ClustererNamespace) -> Iterator[Project]: logger.debug("Could not find project %s in db", project_id) +def get_active_project_ids(namespace: ClustererNamespace) -> Iterator[int]: + """ + Scan redis for projects and fetch their ids. + + Unlike get_active_projects(), this will include ids for projects + that have been deleted since clustering was scheduled. + """ + for key in _get_all_keys(namespace): + yield int(key) + + def _record_sample(namespace: ClustererNamespace, project: Project, sample: str) -> None: with sentry_sdk.start_span(op=f"cluster.{namespace.value.name}.record_sample"): client = get_redis_client() diff --git a/src/sentry/ingest/transaction_clusterer/tasks.py b/src/sentry/ingest/transaction_clusterer/tasks.py index d8fa95a2b1d927..009be12d51a5c6 100644 --- a/src/sentry/ingest/transaction_clusterer/tasks.py +++ b/src/sentry/ingest/transaction_clusterer/tasks.py @@ -55,10 +55,10 @@ def spawn_clusterers(**kwargs: Any) -> None: """Look for existing transaction name sets in redis and spawn clusterers for each""" with sentry_sdk.start_span(op="txcluster_spawn"): project_count = 0 - project_iter = redis.get_active_projects(ClustererNamespace.TRANSACTIONS) + project_iter = redis.get_active_project_ids(ClustererNamespace.TRANSACTIONS) while batch := list(islice(project_iter, PROJECTS_PER_TASK)): project_count += len(batch) - cluster_projects.delay(batch) + cluster_projects.delay(project_ids=batch) metrics.incr("txcluster.spawned_projects", amount=project_count, sample_rate=1.0) @@ -76,7 +76,13 @@ def spawn_clusterers(**kwargs: Any) -> None: retry=Retry(times=5), ), ) -def cluster_projects(projects: Sequence[Project]) -> None: +def cluster_projects( + projects: Sequence[Project] | None = None, project_ids: Sequence[int] | None = None +) -> None: + if project_ids: + projects = Project.objects.get_many_from_cache(project_ids) + assert projects is not None, "Either projects or project_ids must be provided" + pending = set(projects) num_clustered = 0 try: diff --git a/src/sentry/integrations/bitbucket_server/client.py b/src/sentry/integrations/bitbucket_server/client.py index 66f842f6bb8d2a..db98fc7bee89be 100644 --- a/src/sentry/integrations/bitbucket_server/client.py +++ b/src/sentry/integrations/bitbucket_server/client.py @@ -6,7 +6,7 @@ from requests_oauthlib import OAuth1 from sentry.identity.services.identity.model import RpcIdentity -from sentry.integrations.base import IntegrationFeatureNotImplementedError +from sentry.integrations.bitbucket_server.utils import BitbucketServerAPIPath from sentry.integrations.client import ApiClient from sentry.integrations.models.integration import Integration from sentry.integrations.services.integration.model import RpcIntegration @@ -17,20 +17,6 @@ logger = logging.getLogger("sentry.integrations.bitbucket_server") -class BitbucketServerAPIPath: - """ - project is the short key of the project - repo is the fully qualified slug - """ - - repository = "/rest/api/1.0/projects/{project}/repos/{repo}" - repositories = "/rest/api/1.0/repos" - repository_hook = "/rest/api/1.0/projects/{project}/repos/{repo}/webhooks/{id}" - repository_hooks = "/rest/api/1.0/projects/{project}/repos/{repo}/webhooks" - repository_commits = "/rest/api/1.0/projects/{project}/repos/{repo}/commits" - commit_changes = "/rest/api/1.0/projects/{project}/repos/{repo}/commits/{commit}/changes" - - class BitbucketServerSetupClient(ApiClient): """ Client for making requests to Bitbucket Server to follow OAuth1 flow. @@ -256,9 +242,25 @@ def _get_values(self, uri, params, max_pages=1000000): return values def check_file(self, repo: Repository, path: str, version: str | None) -> object | None: - raise IntegrationFeatureNotImplementedError + return self.head_cached( + path=BitbucketServerAPIPath.build_source( + project=repo.config["project"], + repo=repo.config["repo"], + path=path, + sha=version, + ), + ) def get_file( self, repo: Repository, path: str, ref: str | None, codeowners: bool = False ) -> str: - raise IntegrationFeatureNotImplementedError + response = self.get_cached( + path=BitbucketServerAPIPath.build_raw( + project=repo.config["project"], + repo=repo.config["repo"], + path=path, + sha=ref, + ), + raw_response=True, + ) + return response.text diff --git a/src/sentry/integrations/bitbucket_server/integration.py b/src/sentry/integrations/bitbucket_server/integration.py index b2a46a745e4056..d872bdfdcb8d77 100644 --- a/src/sentry/integrations/bitbucket_server/integration.py +++ b/src/sentry/integrations/bitbucket_server/integration.py @@ -2,7 +2,7 @@ from collections.abc import Mapping from typing import Any -from urllib.parse import urlparse +from urllib.parse import parse_qs, quote, urlencode, urlparse from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.serialization import load_pem_private_key @@ -19,7 +19,6 @@ FeatureDescription, IntegrationData, IntegrationDomain, - IntegrationFeatureNotImplementedError, IntegrationFeatures, IntegrationMetadata, IntegrationProvider, @@ -62,6 +61,19 @@ """, IntegrationFeatures.COMMITS, ), + FeatureDescription( + """ + Link your Sentry stack traces back to your Bitbucket Server source code with stack + trace linking. + """, + IntegrationFeatures.STACKTRACE_LINK, + ), + FeatureDescription( + """ + Import your Bitbucket Server [CODEOWNERS file](https://support.atlassian.com/bitbucket-cloud/docs/set-up-and-use-code-owners/) and use it alongside your ownership rules to assign Sentry issues. + """, + IntegrationFeatures.CODEOWNERS, + ), ] setup_alert = { @@ -244,6 +256,8 @@ class BitbucketServerIntegration(RepositoryIntegration): IntegrationInstallation implementation for Bitbucket Server """ + codeowners_locations = [".bitbucket/CODEOWNERS"] + @property def integration_name(self) -> str: return "bitbucket_server" @@ -307,16 +321,40 @@ def get_unmigratable_repositories(self): return list(filter(lambda repo: repo.name not in accessible_repos, repos)) def source_url_matches(self, url: str) -> bool: - raise IntegrationFeatureNotImplementedError + return url.startswith(self.model.metadata["base_url"]) def format_source_url(self, repo: Repository, filepath: str, branch: str | None) -> str: - raise IntegrationFeatureNotImplementedError + project = quote(repo.config["project"]) + repo_name = quote(repo.config["repo"]) + source_url = f"{self.model.metadata["base_url"]}/projects/{project}/repos/{repo_name}/browse/{filepath}" + + if branch: + source_url += "?" + urlencode({"at": branch}) + + return source_url def extract_branch_from_source_url(self, repo: Repository, url: str) -> str: - raise IntegrationFeatureNotImplementedError + parsed_url = urlparse(url) + qs = parse_qs(parsed_url.query) + + if "at" in qs and len(qs["at"]) == 1: + branch = qs["at"][0] + + # branch name may be prefixed with refs/heads/, so we strip that + refs_prefix = "refs/heads/" + if branch.startswith(refs_prefix): + branch = branch[len(refs_prefix) :] + + return branch + + return "" def extract_source_path_from_source_url(self, repo: Repository, url: str) -> str: - raise IntegrationFeatureNotImplementedError + if repo.url is None: + return "" + parsed_repo_url = urlparse(repo.url) + parsed_url = urlparse(url) + return parsed_url.path.replace(parsed_repo_url.path + "/", "") # Bitbucket Server only methods @@ -331,7 +369,13 @@ class BitbucketServerIntegrationProvider(IntegrationProvider): metadata = metadata integration_cls = BitbucketServerIntegration needs_default_identity = True - features = frozenset([IntegrationFeatures.COMMITS]) + features = frozenset( + [ + IntegrationFeatures.COMMITS, + IntegrationFeatures.STACKTRACE_LINK, + IntegrationFeatures.CODEOWNERS, + ] + ) setup_dialog_config = {"width": 1030, "height": 1000} def get_pipeline_views(self) -> list[PipelineView]: diff --git a/src/sentry/integrations/bitbucket_server/utils.py b/src/sentry/integrations/bitbucket_server/utils.py new file mode 100644 index 00000000000000..0dd422ebb7f8b2 --- /dev/null +++ b/src/sentry/integrations/bitbucket_server/utils.py @@ -0,0 +1,37 @@ +from urllib.parse import quote, urlencode + + +class BitbucketServerAPIPath: + """ + project is the short key of the project + repo is the fully qualified slug + """ + + repository = "/rest/api/1.0/projects/{project}/repos/{repo}" + repositories = "/rest/api/1.0/repos" + repository_hook = "/rest/api/1.0/projects/{project}/repos/{repo}/webhooks/{id}" + repository_hooks = "/rest/api/1.0/projects/{project}/repos/{repo}/webhooks" + repository_commits = "/rest/api/1.0/projects/{project}/repos/{repo}/commits" + commit_changes = "/rest/api/1.0/projects/{project}/repos/{repo}/commits/{commit}/changes" + + @staticmethod + def build_raw(project: str, repo: str, path: str, sha: str | None) -> str: + project = quote(project) + repo = quote(repo) + + params = {} + if sha: + params["at"] = sha + + return f"/projects/{project}/repos/{repo}/raw/{path}?{urlencode(params)}" + + @staticmethod + def build_source(project: str, repo: str, path: str, sha: str | None) -> str: + project = quote(project) + repo = quote(repo) + + params = {} + if sha: + params["at"] = sha + + return f"/rest/api/1.0/projects/{project}/repos/{repo}/browse/{path}?{urlencode(params)}" diff --git a/src/sentry/integrations/github/client.py b/src/sentry/integrations/github/client.py index 292e42df004250..4871252c8e7539 100644 --- a/src/sentry/integrations/github/client.py +++ b/src/sentry/integrations/github/client.py @@ -26,6 +26,7 @@ from sentry.integrations.source_code_management.repo_trees import RepoTreesClient from sentry.integrations.source_code_management.repository import RepositoryClient from sentry.integrations.types import EXTERNAL_PROVIDERS, ExternalProviders +from sentry.models.pullrequest import PullRequest, PullRequestComment from sentry.models.repository import Repository from sentry.shared_integrations.client.proxy import IntegrationProxyClient from sentry.shared_integrations.exceptions import ApiError, ApiRateLimitedError @@ -438,6 +439,18 @@ def update_comment( endpoint = f"/repos/{repo}/issues/comments/{comment_id}" return self.patch(endpoint, data=data) + def create_pr_comment(self, repo: Repository, pr: PullRequest, data: dict[str, Any]) -> Any: + return self.create_comment(repo.name, pr.key, data) + + def update_pr_comment( + self, + repo: Repository, + pr: PullRequest, + pr_comment: PullRequestComment, + data: dict[str, Any], + ) -> Any: + return self.update_comment(repo.name, pr.key, pr_comment.external_id, data) + def get_comment_reactions(self, repo: str, comment_id: str) -> Any: endpoint = f"/repos/{repo}/issues/comments/{comment_id}" response = self.get(endpoint) diff --git a/src/sentry/integrations/gitlab/client.py b/src/sentry/integrations/gitlab/client.py index 3dcec7c65cb29d..8696532bed265d 100644 --- a/src/sentry/integrations/gitlab/client.py +++ b/src/sentry/integrations/gitlab/client.py @@ -9,7 +9,6 @@ from requests import PreparedRequest from sentry.identity.services.identity.model import RpcIdentity -from sentry.integrations.base import IntegrationFeatureNotImplementedError from sentry.integrations.gitlab.blame import fetch_file_blames from sentry.integrations.gitlab.utils import GitLabApiClientPath from sentry.integrations.source_code_management.commit_context import ( @@ -18,6 +17,7 @@ SourceLineInfo, ) from sentry.integrations.source_code_management.repository import RepositoryClient +from sentry.models.pullrequest import PullRequest, PullRequestComment from sentry.models.repository import Repository from sentry.shared_integrations.client.proxy import IntegrationProxyClient from sentry.shared_integrations.exceptions import ApiError, ApiUnauthorized @@ -232,7 +232,7 @@ def create_comment(self, repo: str, issue_id: str, data: dict[str, Any]): See https://docs.gitlab.com/ee/api/notes.html#create-new-issue-note """ return self.post( - GitLabApiClientPath.create_note.format(project=repo, issue_id=issue_id), data=data + GitLabApiClientPath.create_issue_note.format(project=repo, issue_id=issue_id), data=data ) def update_comment(self, repo: str, issue_id: str, comment_id: str, data: dict[str, Any]): @@ -241,12 +241,30 @@ def update_comment(self, repo: str, issue_id: str, comment_id: str, data: dict[s See https://docs.gitlab.com/ee/api/notes.html#modify-existing-issue-note """ return self.put( - GitLabApiClientPath.update_note.format( + GitLabApiClientPath.update_issue_note.format( project=repo, issue_id=issue_id, note_id=comment_id ), data=data, ) + def create_pr_comment(self, repo: Repository, pr: PullRequest, data: dict[str, Any]) -> Any: + project_id = repo.config["project_id"] + url = GitLabApiClientPath.create_pr_note.format(project=project_id, pr_key=pr.key) + return self.post(url, data=data) + + def update_pr_comment( + self, + repo: Repository, + pr: PullRequest, + pr_comment: PullRequestComment, + data: dict[str, Any], + ) -> Any: + project_id = repo.config["project_id"] + url = GitLabApiClientPath.update_pr_note.format( + project=project_id, pr_key=pr.key, note_id=pr_comment.external_id + ) + return self.put(url, data=data) + def search_project_issues(self, project_id, query, iids=None): """Search issues in a project @@ -310,7 +328,26 @@ def get_commit(self, project_id, sha): return self.get_cached(GitLabApiClientPath.commit.format(project=project_id, sha=sha)) def get_merge_commit_sha_from_commit(self, repo: Repository, sha: str) -> str | None: - raise IntegrationFeatureNotImplementedError + """ + Get the merge commit sha from a commit sha + See https://docs.gitlab.com/api/commits/#list-merge-requests-associated-with-a-commit + """ + project_id = repo.config["project_id"] + path = GitLabApiClientPath.commit_merge_requests.format(project=project_id, sha=sha) + response = self.get(path) + + # Filter out non-merged merge requests + merge_requests = [] + for merge_request in response: + if merge_request["state"] == "merged": + merge_requests.append(merge_request) + + if len(merge_requests) != 1: + # the response should return a single merged PR, returning None if multiple + return None + + merge_request = merge_requests[0] + return merge_request["merge_commit_sha"] or merge_request["squash_commit_sha"] def compare_commits(self, project_id, start_sha, end_sha): """Compare commits between two SHAs diff --git a/src/sentry/integrations/gitlab/integration.py b/src/sentry/integrations/gitlab/integration.py index 4ea370444c7a4f..0c09492d32d56d 100644 --- a/src/sentry/integrations/gitlab/integration.py +++ b/src/sentry/integrations/gitlab/integration.py @@ -20,8 +20,14 @@ IntegrationProvider, ) from sentry.integrations.services.repository.model import RpcRepository -from sentry.integrations.source_code_management.commit_context import CommitContextIntegration +from sentry.integrations.source_code_management.commit_context import ( + CommitContextIntegration, + PRCommentWorkflow, +) from sentry.integrations.source_code_management.repository import RepositoryIntegration +from sentry.models.group import Group +from sentry.models.organization import Organization +from sentry.models.pullrequest import PullRequest from sentry.models.repository import Repository from sentry.pipeline import NestedPipelineView, Pipeline, PipelineView from sentry.shared_integrations.exceptions import ( @@ -29,7 +35,10 @@ IntegrationError, IntegrationProviderError, ) +from sentry.snuba.referrer import Referrer +from sentry.types.referrer_ids import GITLAB_PR_BOT_REFERRER from sentry.users.models.identity import Identity +from sentry.utils import metrics from sentry.utils.hashlib import sha1_text from sentry.utils.http import absolute_uri from sentry.web.helpers import render_to_response @@ -163,7 +172,14 @@ def extract_source_path_from_source_url(self, repo: Repository, url: str) -> str # CommitContextIntegration methods def on_create_or_update_comment_error(self, api_error: ApiError, metrics_base: str) -> bool: - raise NotImplementedError + if api_error.code == 429: + metrics.incr( + metrics_base.format(integration=self.integration_name, key="error"), + tags={"type": "rate_limited_error"}, + ) + return True + + return False # Gitlab only functions @@ -184,6 +200,62 @@ def search_issues(self, query: str | None, **kwargs) -> list[dict[str, Any]]: assert isinstance(resp, list) return resp + def get_pr_comment_workflow(self) -> PRCommentWorkflow: + return GitlabPRCommentWorkflow(integration=self) + + +MERGED_PR_COMMENT_BODY_TEMPLATE = """\ +## Suspect Issues +This merge request was deployed and Sentry observed the following issues: + +{issue_list}""" + +MERGED_PR_SINGLE_ISSUE_TEMPLATE = "- ‼️ **{title}** `{subtitle}` [View Issue]({url})" + + +class GitlabPRCommentWorkflow(PRCommentWorkflow): + organization_option_key = "sentry:gitlab_pr_bot" + referrer = Referrer.GITLAB_PR_COMMENT_BOT + referrer_id = GITLAB_PR_BOT_REFERRER + + @staticmethod + def format_comment_subtitle(subtitle: str | None) -> str: + if subtitle is None: + return "" + return subtitle[:47] + "..." if len(subtitle) > 50 else subtitle + + @staticmethod + def format_comment_url(url: str, referrer: str) -> str: + return url + "?referrer=" + referrer + + def get_comment_body(self, issue_ids: list[int]) -> str: + issues = Group.objects.filter(id__in=issue_ids).order_by("id").all() + + issue_list = "\n".join( + [ + MERGED_PR_SINGLE_ISSUE_TEMPLATE.format( + title=issue.title, + subtitle=self.format_comment_subtitle(issue.culprit), + url=self.format_comment_url(issue.get_absolute_url(), self.referrer_id), + ) + for issue in issues + ] + ) + + return MERGED_PR_COMMENT_BODY_TEMPLATE.format(issue_list=issue_list) + + def get_comment_data( + self, + organization: Organization, + repo: Repository, + pr: PullRequest, + comment_body: str, + issue_ids: list[int], + ) -> dict[str, Any]: + return { + "body": comment_body, + } + class InstallationForm(forms.Form): url = forms.CharField( diff --git a/src/sentry/integrations/gitlab/utils.py b/src/sentry/integrations/gitlab/utils.py index 587e41261932ca..4fcc31a3e3e12f 100644 --- a/src/sentry/integrations/gitlab/utils.py +++ b/src/sentry/integrations/gitlab/utils.py @@ -25,6 +25,7 @@ class GitLabApiClientPath: blame = "/projects/{project}/repository/files/{path}/blame" commit = "/projects/{project}/repository/commits/{sha}" commits = "/projects/{project}/repository/commits" + commit_merge_requests = "/projects/{project}/repository/commits/{sha}/merge_requests" compare = "/projects/{project}/repository/compare" diff = "/projects/{project}/repository/commits/{sha}/diff" file = "/projects/{project}/repository/files/{path}" @@ -34,8 +35,10 @@ class GitLabApiClientPath: hooks = "/hooks" issue = "/projects/{project}/issues/{issue}" issues = "/projects/{project}/issues" - create_note = "/projects/{project}/issues/{issue_id}/notes" - update_note = "/projects/{project}/issues/{issue_id}/notes/{note_id}" + create_issue_note = "/projects/{project}/issues/{issue_id}/notes" + update_issue_note = "/projects/{project}/issues/{issue_id}/notes/{note_id}" + create_pr_note = "/projects/{project}/merge_requests/{pr_key}/notes" + update_pr_note = "/projects/{project}/merge_requests/{pr_key}/notes/{note_id}" project = "/projects/{project}" project_issues = "/projects/{project}/issues" project_hooks = "/projects/{project}/hooks" diff --git a/src/sentry/integrations/source_code_management/commit_context.py b/src/sentry/integrations/source_code_management/commit_context.py index 06d4821f9cc540..cbb566abaec616 100644 --- a/src/sentry/integrations/source_code_management/commit_context.py +++ b/src/sentry/integrations/source_code_management/commit_context.py @@ -175,7 +175,11 @@ def queue_comment_task_if_needed( group_owner: GroupOwner, group_id: int, ) -> None: - pr_comment_workflow = self.get_pr_comment_workflow() + try: + # TODO(jianyuan): Remove this try/except once we have implemented the abstract method for all integrations + pr_comment_workflow = self.get_pr_comment_workflow() + except NotImplementedError: + return with CommitContextIntegrationInteractionEvent( interaction_type=SCMIntegrationInteractionType.QUEUE_COMMENT_TASK, @@ -335,11 +339,7 @@ def create_or_update_comment( pull_request_id=pr.id, ).capture(): if pr_comment is None: - resp = client.create_comment( - repo=repo.name, - issue_id=str(pr.key), - data=comment_data, - ) + resp = client.create_pr_comment(repo=repo, pr=pr, data=comment_data) current_time = django_timezone.now() comment = PullRequestComment.objects.create( @@ -363,10 +363,10 @@ def create_or_update_comment( language=(language or "not found"), ) else: - resp = client.update_comment( - repo=repo.name, - issue_id=str(pr.key), - comment_id=pr_comment.external_id, + resp = client.update_pr_comment( + repo=repo, + pr=pr, + pr_comment=pr_comment, data=comment_data, ) metrics.incr( @@ -393,6 +393,7 @@ def on_create_or_update_comment_error(self, api_error: ApiError, metrics_base: s """ raise NotImplementedError + # TODO(jianyuan): Make this an abstract method def get_pr_comment_workflow(self) -> PRCommentWorkflow: raise NotImplementedError @@ -417,6 +418,20 @@ def update_comment( ) -> Any: raise NotImplementedError + @abstractmethod + def create_pr_comment(self, repo: Repository, pr: PullRequest, data: dict[str, Any]) -> Any: + raise NotImplementedError + + @abstractmethod + def update_pr_comment( + self, + repo: Repository, + pr: PullRequest, + pr_comment: PullRequestComment, + data: dict[str, Any], + ) -> Any: + raise NotImplementedError + @abstractmethod def get_merge_commit_sha_from_commit(self, repo: Repository, sha: str) -> str | None: raise NotImplementedError diff --git a/src/sentry/integrations/utils/commit_context.py b/src/sentry/integrations/utils/commit_context.py index dd54185effd468..4a19e4fb13eb99 100644 --- a/src/sentry/integrations/utils/commit_context.py +++ b/src/sentry/integrations/utils/commit_context.py @@ -202,7 +202,6 @@ def _generate_integration_to_files_mapping( platform=platform, sdk_name=sdk_name, code_mapping=code_mapping, - organization=organization, ) if not src_path: @@ -386,7 +385,6 @@ def _record_commit_context_all_frames_analytics( platform=platform, sdk_name=sdk_name, code_mapping=selected_blame.code_mapping, - organization=Organization.objects.get(id=organization_id), ) == selected_blame.path ), diff --git a/src/sentry/integrations/utils/stacktrace_link.py b/src/sentry/integrations/utils/stacktrace_link.py index c16e8fc5b72662..98c3ed935a05ad 100644 --- a/src/sentry/integrations/utils/stacktrace_link.py +++ b/src/sentry/integrations/utils/stacktrace_link.py @@ -99,7 +99,6 @@ def get_stacktrace_config( platform=ctx["platform"], sdk_name=ctx["sdk_name"], code_mapping=config, - organization=organization, ) result["src_path"] = src_path if not src_path: 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 ed6e3a2e65f9e7..b51f22e2594c0c 100644 --- a/src/sentry/issues/auto_source_code_config/code_mapping.py +++ b/src/sentry/issues/auto_source_code_config/code_mapping.py @@ -5,7 +5,6 @@ from collections.abc import Mapping, Sequence from typing import Any, NamedTuple -from sentry import features from sentry.integrations.models.repository_project_path_config import RepositoryProjectPathConfig from sentry.integrations.source_code_management.repo_trees import ( RepoAndBranch, @@ -387,7 +386,6 @@ def convert_stacktrace_frame_path_to_source_path( code_mapping: RepositoryProjectPathConfig, platform: str | None, sdk_name: str | None, - organization: Organization | None, ) -> str | None: """ Applies the given code mapping to the given stacktrace frame and returns the source path. @@ -397,15 +395,6 @@ def convert_stacktrace_frame_path_to_source_path( stack_root = code_mapping.stack_root - has_new_logic = ( - features.has("organizations:java-frame-munging-new-logic", organization, actor=None) - if organization - else False - ) - if has_new_logic and platform == "java": - # This will cause the new munging logic to be applied - platform = "java-new-logic" - # In most cases, code mappings get applied to frame.filename, but some platforms such as Java # contain folder info in other parts of the frame (e.g. frame.module="com.example.app.MainActivity" # gets transformed to "com/example/app/MainActivity.java"), so in those cases we use the diff --git a/src/sentry/issues/streamline.py b/src/sentry/issues/streamline.py deleted file mode 100644 index 84c1d90631e1c5..00000000000000 --- a/src/sentry/issues/streamline.py +++ /dev/null @@ -1,23 +0,0 @@ -from random import random - -from sentry import options -from sentry.models.organization import Organization - - -def apply_streamline_rollout_group(organization: Organization) -> None: - # This shouldn't run multiple times, but if it does, don't re-sort the organization. - # If they're already in the experiment, we ignore them. - if organization.get_option("sentry:streamline_ui_only") is not None: - return - - rollout_rate = options.get("issues.details.streamline-experiment-rollout-rate") - - # If the random number is less than the rollout_rate, the organization is in the experiment. - if random() <= rollout_rate: - split_rate = options.get("issues.details.streamline-experiment-split-rate") - # If the random number is less than the split_rate, the split_result is true. - split_result = random() <= split_rate - # When split_result is true, they only receive the Streamline UI. - # When split_result is false, they only receive the Legacy UI. - # This behaviour is controlled on the frontend. - organization.update_option("sentry:streamline_ui_only", split_result) diff --git a/src/sentry/models/commitfilechange.py b/src/sentry/models/commitfilechange.py index 269c60ca18a087..21313b50b44eb7 100644 --- a/src/sentry/models/commitfilechange.py +++ b/src/sentry/models/commitfilechange.py @@ -52,6 +52,7 @@ def is_valid_type(value: str) -> bool: def process_resource_change(instance, **kwargs): from sentry.integrations.bitbucket.integration import BitbucketIntegration + from sentry.integrations.bitbucket_server.integration import BitbucketServerIntegration from sentry.integrations.github.integration import GitHubIntegration from sentry.integrations.gitlab.integration import GitlabIntegration from sentry.integrations.vsts.integration import VstsIntegration @@ -62,6 +63,7 @@ def _spawn_task(): set(GitHubIntegration.codeowners_locations) | set(GitlabIntegration.codeowners_locations) | set(BitbucketIntegration.codeowners_locations) + | set(BitbucketServerIntegration.codeowners_locations) | set(VstsIntegration.codeowners_locations) ) diff --git a/src/sentry/notifications/notification_action/action_handler_registry/base.py b/src/sentry/notifications/notification_action/action_handler_registry/base.py index 6c285780096a07..52618411630062 100644 --- a/src/sentry/notifications/notification_action/action_handler_registry/base.py +++ b/src/sentry/notifications/notification_action/action_handler_registry/base.py @@ -1,6 +1,7 @@ import logging from abc import ABC +from sentry.integrations.types import IntegrationProviderSlug from sentry.notifications.models.notificationaction import ActionTarget from sentry.notifications.notification_action.utils import execute_via_issue_alert_handler from sentry.workflow_engine.models import Action, Detector @@ -10,8 +11,7 @@ class IntegrationActionHandler(ActionHandler, ABC): - # TODO(iamrajjoshi): Switch this to an enum after we decide on what this enum will be - provider_slug: str + provider_slug: IntegrationProviderSlug class TicketingActionHandler(IntegrationActionHandler, ABC): diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index eaf35e0f474416..0e62b518cf85e2 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -883,22 +883,6 @@ flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, ) -# Percentage of orgs that will be put into a bucket using the split rate below. -register( - "issues.details.streamline-experiment-rollout-rate", - type=Float, - default=0.0, - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) - -# 50% of orgs will only see the Streamline UI, 50% will only see the Legacy UI. -register( - "issues.details.streamline-experiment-split-rate", - type=Float, - default=0.5, - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) - # Killswitch for issue priority register( @@ -1641,12 +1625,12 @@ ) register( "performance.issues.consecutive_http.problem-creation", - default=0.25, + default=0.5, flags=FLAG_AUTOMATOR_MODIFIABLE, ) register( "performance.issues.large_http_payload.problem-creation", - default=0.25, + default=0.5, flags=FLAG_AUTOMATOR_MODIFIABLE, ) register( @@ -3307,7 +3291,16 @@ default={}, flags=FLAG_AUTOMATOR_MODIFIABLE, ) - +register( + "taskworker.ingest.errors.rollout", + default={}, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) +register( + "taskworker.ingest.transactions.rollout", + default={}, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) # Orgs for which compression should be disabled in the chunk upload endpoint. # This is intended to circumvent sporadic 503 errors reported by some customers. register("chunk-upload.no-compression", default=[], flags=FLAG_AUTOMATOR_MODIFIABLE) diff --git a/src/sentry/search/eap/constants.py b/src/sentry/search/eap/constants.py index d1079247a828a2..b94af5eb467dff 100644 --- a/src/sentry/search/eap/constants.py +++ b/src/sentry/search/eap/constants.py @@ -162,4 +162,5 @@ "BEST_EFFORT": DownsampledStorageConfig.MODE_BEST_EFFORT, "PREFLIGHT": DownsampledStorageConfig.MODE_PREFLIGHT, "NORMAL": DownsampledStorageConfig.MODE_NORMAL, + "HIGHEST_ACCURACY": DownsampledStorageConfig.MODE_HIGHEST_ACCURACY, } diff --git a/src/sentry/search/eap/spans/aggregates.py b/src/sentry/search/eap/spans/aggregates.py index 1302f29067efe6..35d369fff135a4 100644 --- a/src/sentry/search/eap/spans/aggregates.py +++ b/src/sentry/search/eap/spans/aggregates.py @@ -13,6 +13,7 @@ TraceItemFilter, ) +from sentry.exceptions import InvalidSearchQuery from sentry.search.eap import constants from sentry.search.eap.columns import ( AggregateDefinition, @@ -52,12 +53,21 @@ def resolve_key_eq_value_filter(args: ResolvedArguments) -> tuple[AttributeKey, aggregate_key = cast(AttributeKey, args[0]) key = cast(AttributeKey, args[1]) value = cast(str, args[2]) + attr_value = AttributeValue(val_str=value) + + if key.type == AttributeKey.TYPE_BOOLEAN: + lower_value = value.lower() + if lower_value not in ["true", "false"]: + raise InvalidSearchQuery( + f"Invalid parameter {value}. Must be one of {["true", "false"]}" + ) + attr_value = AttributeValue(val_bool=value == "true") filter = TraceItemFilter( comparison_filter=ComparisonFilter( key=key, op=ComparisonFilter.OP_EQUALS, - value=AttributeValue(val_str=value), + value=attr_value, ) ) return (aggregate_key, filter) @@ -165,6 +175,114 @@ def resolve_bounded_sample(args: ResolvedArguments) -> tuple[AttributeKey, Trace ], aggregate_resolver=resolve_key_eq_value_filter, ), + "p50_if": ConditionalAggregateDefinition( + internal_function=Function.FUNCTION_P50, + default_search_type="duration", + arguments=[ + AttributeArgumentDefinition( + attribute_types={ + "duration", + "number", + "percentage", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + ), + AttributeArgumentDefinition(attribute_types={"string", "boolean"}), + ValueArgumentDefinition(argument_types={"string"}), + ], + aggregate_resolver=resolve_key_eq_value_filter, + ), + "p75_if": ConditionalAggregateDefinition( + internal_function=Function.FUNCTION_P75, + default_search_type="duration", + arguments=[ + AttributeArgumentDefinition( + attribute_types={ + "duration", + "number", + "percentage", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + ), + AttributeArgumentDefinition(attribute_types={"string", "boolean"}), + ValueArgumentDefinition(argument_types={"string"}), + ], + aggregate_resolver=resolve_key_eq_value_filter, + ), + "p90_if": ConditionalAggregateDefinition( + internal_function=Function.FUNCTION_P90, + default_search_type="duration", + arguments=[ + AttributeArgumentDefinition( + attribute_types={ + "duration", + "number", + "percentage", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + ), + AttributeArgumentDefinition(attribute_types={"string", "boolean"}), + ValueArgumentDefinition(argument_types={"string"}), + ], + aggregate_resolver=resolve_key_eq_value_filter, + ), + "p95_if": ConditionalAggregateDefinition( + internal_function=Function.FUNCTION_P95, + default_search_type="duration", + arguments=[ + AttributeArgumentDefinition( + attribute_types={ + "duration", + "number", + "percentage", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + ), + AttributeArgumentDefinition(attribute_types={"string", "boolean"}), + ValueArgumentDefinition(argument_types={"string"}), + ], + aggregate_resolver=resolve_key_eq_value_filter, + ), + "p99_if": ConditionalAggregateDefinition( + internal_function=Function.FUNCTION_P99, + default_search_type="duration", + arguments=[ + AttributeArgumentDefinition( + attribute_types={ + "duration", + "number", + "percentage", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + ), + AttributeArgumentDefinition(attribute_types={"string", "boolean"}), + ValueArgumentDefinition(argument_types={"string"}), + ], + aggregate_resolver=resolve_key_eq_value_filter, + ), + "sum_if": ConditionalAggregateDefinition( + internal_function=Function.FUNCTION_SUM, + default_search_type="duration", + arguments=[ + AttributeArgumentDefinition( + attribute_types={ + "duration", + "number", + "percentage", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + ), + AttributeArgumentDefinition(attribute_types={"string", "boolean"}), + ValueArgumentDefinition(argument_types={"string"}), + ], + aggregate_resolver=resolve_key_eq_value_filter, + ), "count_scores": ConditionalAggregateDefinition( internal_function=Function.FUNCTION_COUNT, default_search_type="integer", diff --git a/src/sentry/search/eap/spans/formulas.py b/src/sentry/search/eap/spans/formulas.py index e30294d51d5fd3..73e8ae30106218 100644 --- a/src/sentry/search/eap/spans/formulas.py +++ b/src/sentry/search/eap/spans/formulas.py @@ -13,6 +13,7 @@ StrArray, ) from sentry_protos.snuba.v1.trace_item_filter_pb2 import ( + AndFilter, ComparisonFilter, ExistsFilter, TraceItemFilter, @@ -27,6 +28,7 @@ ValueArgumentDefinition, ) from sentry.search.eap.constants import RESPONSE_CODE_MAP +from sentry.search.eap.spans.aggregates import resolve_key_eq_value_filter from sentry.search.eap.spans.utils import ( WEB_VITALS_MEASUREMENTS, operate_multiple_columns, @@ -120,6 +122,58 @@ def avg_compare(args: ResolvedArguments, settings: ResolverSettings) -> Column.B return percentage_change +def failure_rate_if(args: ResolvedArguments, settings: ResolverSettings) -> Column.BinaryFormula: + extrapolation_mode = settings["extrapolation_mode"] + key = cast(AttributeKey, args[0]) + value = cast(str, args[1]) + + (_, key_equal_value_filter) = resolve_key_eq_value_filter([key, key, value]) + + return Column.BinaryFormula( + default_value_double=0.0, + left=Column( + conditional_aggregation=AttributeConditionalAggregation( + aggregate=Function.FUNCTION_COUNT, + key=AttributeKey( + name="sentry.trace.status", + type=AttributeKey.TYPE_STRING, + ), + filter=TraceItemFilter( + and_filter=AndFilter( + filters=[ + TraceItemFilter( + comparison_filter=ComparisonFilter( + key=AttributeKey( + name="sentry.trace.status", + type=AttributeKey.TYPE_STRING, + ), + op=ComparisonFilter.OP_NOT_IN, + value=AttributeValue( + val_str_array=StrArray( + values=["ok", "cancelled", "unknown"], + ), + ), + ), + ), + key_equal_value_filter, + ] + ) + ), + extrapolation_mode=extrapolation_mode, + ), + ), + op=Column.BinaryFormula.OP_DIVIDE, + right=Column( + conditional_aggregation=AttributeConditionalAggregation( + aggregate=Function.FUNCTION_COUNT, + key=AttributeKey(type=AttributeKey.TYPE_DOUBLE, name="sentry.exclusive_time_ms"), + filter=key_equal_value_filter, + extrapolation_mode=extrapolation_mode, + ), + ), + ) + + def failure_rate(_: ResolvedArguments, settings: ResolverSettings) -> Column.BinaryFormula: extrapolation_mode = settings["extrapolation_mode"] @@ -484,6 +538,39 @@ def time_spent_percentage( ) +def tpm(_: ResolvedArguments, settings: ResolverSettings) -> Column.BinaryFormula: + extrapolation_mode = settings["extrapolation_mode"] + is_timeseries_request = settings["snuba_params"].is_timeseries_request + + divisor = ( + settings["snuba_params"].timeseries_granularity_secs + if is_timeseries_request + else settings["snuba_params"].interval + ) + + return Column.BinaryFormula( + default_value_double=0.0, + left=Column( + conditional_aggregation=AttributeConditionalAggregation( + aggregate=Function.FUNCTION_COUNT, + key=AttributeKey(type=AttributeKey.TYPE_BOOLEAN, name="sentry.is_segment"), + filter=TraceItemFilter( + comparison_filter=ComparisonFilter( + key=AttributeKey(type=AttributeKey.TYPE_BOOLEAN, name="sentry.is_segment"), + op=ComparisonFilter.OP_EQUALS, + value=AttributeValue(val_bool=True), + ) + ), + extrapolation_mode=extrapolation_mode, + ), + ), + op=Column.BinaryFormula.OP_DIVIDE, + right=Column( + literal=LiteralValue(val_double=divisor / 60), + ), + ) + + def epm(_: ResolvedArguments, settings: ResolverSettings) -> Column.BinaryFormula: extrapolation_mode = settings["extrapolation_mode"] is_timeseries_request = settings["snuba_params"].is_timeseries_request @@ -544,6 +631,15 @@ def epm(_: ResolvedArguments, settings: ResolverSettings) -> Column.BinaryFormul formula_resolver=failure_rate, is_aggregate=True, ), + "failure_rate_if": FormulaDefinition( + default_search_type="percentage", + arguments=[ + AttributeArgumentDefinition(attribute_types={"string", "boolean"}), + ValueArgumentDefinition(argument_types={"string"}), + ], + formula_resolver=failure_rate_if, + is_aggregate=True, + ), "ttfd_contribution_rate": FormulaDefinition( default_search_type="percentage", arguments=[], @@ -636,4 +732,7 @@ def epm(_: ResolvedArguments, settings: ResolverSettings) -> Column.BinaryFormul "epm": FormulaDefinition( default_search_type="rate", arguments=[], formula_resolver=epm, is_aggregate=True ), + "tpm": FormulaDefinition( + default_search_type="rate", arguments=[], formula_resolver=tpm, is_aggregate=True + ), } diff --git a/src/sentry/search/eap/utils.py b/src/sentry/search/eap/utils.py index dec734e83240b7..42b0b47e16b0b4 100644 --- a/src/sentry/search/eap/utils.py +++ b/src/sentry/search/eap/utils.py @@ -102,7 +102,7 @@ def transform_column_to_expression(column: Column) -> Expression: def validate_sampling(sampling_mode: SAMPLING_MODES | None) -> DownsampledStorageConfig: if sampling_mode is None: - return DownsampledStorageConfig(mode=DownsampledStorageConfig.MODE_UNSPECIFIED) + return DownsampledStorageConfig(mode=DownsampledStorageConfig.MODE_HIGHEST_ACCURACY) if sampling_mode not in SAMPLING_MODE_MAP: raise InvalidSearchQuery(f"sampling mode: {sampling_mode} is not supported") else: diff --git a/src/sentry/search/events/types.py b/src/sentry/search/events/types.py index 11099151324dcd..a190000f87fb50 100644 --- a/src/sentry/search/events/types.py +++ b/src/sentry/search/events/types.py @@ -81,7 +81,7 @@ class EventsResponse(TypedDict): meta: EventsMeta -SAMPLING_MODES = Literal["BEST_EFFORT", "PREFLIGHT", "NORMAL"] +SAMPLING_MODES = Literal["BEST_EFFORT", "PREFLIGHT", "NORMAL", "HIGHEST_ACCURACY"] @dataclass diff --git a/src/sentry/snuba/referrer.py b/src/sentry/snuba/referrer.py index 56977b42f13486..da4ded08b3f9ff 100644 --- a/src/sentry/snuba/referrer.py +++ b/src/sentry/snuba/referrer.py @@ -757,6 +757,7 @@ class Referrer(StrEnum): "getsentry.promotion.mobile_performance_adoption.check_eligible" ) GITHUB_PR_COMMENT_BOT = "tasks.github_comment" + GITLAB_PR_COMMENT_BOT = "tasks.gitlab_comment" GROUP_FILTER_BY_EVENT_ID = "group.filter_by_event_id" GROUP_GET_HELPFUL = "Group.get_helpful" GROUP_GET_LATEST = "Group.get_latest" diff --git a/src/sentry/tasks/base.py b/src/sentry/tasks/base.py index 9c59c596b7bafb..98e64ae264fe89 100644 --- a/src/sentry/tasks/base.py +++ b/src/sentry/tasks/base.py @@ -75,14 +75,18 @@ def taskworker_override( def override(*args: P.args, **kwargs: P.kwargs) -> R: rollout_rate = 0 option_flag = f"taskworker.{namespace}.rollout" - rollout_map = options.get(option_flag) - if rollout_map: - if task_name in rollout_map: - rollout_rate = rollout_map.get(task_name, 0) - elif "*" in rollout_map: - rollout_rate = rollout_map.get("*", 0) - - random.seed(datetime.now().timestamp()) + check_option = True + if namespace in settings.TASKWORKER_HIGH_THROUGHPUT_NAMESPACES: + check_option = settings.TASKWORKER_ENABLE_HIGH_THROUGHPUT_NAMESPACES + + if check_option: + rollout_map = options.get(option_flag) + if rollout_map: + if task_name in rollout_map: + rollout_rate = rollout_map.get(task_name, 0) + elif "*" in rollout_map: + rollout_rate = rollout_map.get("*", 0) + if rollout_rate > random.random(): return taskworker_attr(*args, **kwargs) diff --git a/src/sentry/tasks/commit_context.py b/src/sentry/tasks/commit_context.py index a0cd1c7543eff3..ef0cb41e3a88f6 100644 --- a/src/sentry/tasks/commit_context.py +++ b/src/sentry/tasks/commit_context.py @@ -39,8 +39,6 @@ PR_COMMENT_TASK_TTL = timedelta(minutes=5).total_seconds() PR_COMMENT_WINDOW = 14 # days -# TODO: replace this with isinstance(installation, CommitContextIntegration) -PR_COMMENT_SUPPORTED_PROVIDERS = {"github"} logger = logging.getLogger(__name__) @@ -191,12 +189,7 @@ def process_commit_context( }, # Updates date of an existing owner, since we just matched them with this new event ) - if ( - installation - and isinstance(installation, CommitContextIntegration) - and installation.integration_name - in PR_COMMENT_SUPPORTED_PROVIDERS # TODO: remove this check - ): + if installation and isinstance(installation, CommitContextIntegration): installation.queue_comment_task_if_needed(project, commit, group_owner, group_id) ProjectOwnership.handle_auto_assignment( diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 3001f1c40e80ab..49f159b8b7bfe9 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -25,6 +25,8 @@ from sentry.signals import event_processed, issue_unignored from sentry.silo.base import SiloMode from sentry.tasks.base import instrumented_task +from sentry.taskworker.config import TaskworkerConfig +from sentry.taskworker.namespaces import ingest_errors_tasks from sentry.types.group import GroupSubStatus from sentry.utils import json, metrics from sentry.utils.cache import cache @@ -483,6 +485,10 @@ def should_update_escalating_metrics(event: Event) -> bool: time_limit=120, soft_time_limit=110, silo_mode=SiloMode.REGION, + taskworker_config=TaskworkerConfig( + namespace=ingest_errors_tasks, + processing_deadline_duration=120, + ), ) def post_process_group( is_new, diff --git a/src/sentry/tasks/store.py b/src/sentry/tasks/store.py index 8e49c29532d723..a7454d6aefda01 100644 --- a/src/sentry/tasks/store.py +++ b/src/sentry/tasks/store.py @@ -26,7 +26,11 @@ from sentry.stacktraces.processing import process_stacktraces, should_process_for_stacktraces from sentry.tasks.base import instrumented_task from sentry.taskworker.config import TaskworkerConfig -from sentry.taskworker.namespaces import issues_tasks +from sentry.taskworker.namespaces import ( + ingest_errors_tasks, + ingest_transactions_tasks, + issues_tasks, +) from sentry.utils import metrics from sentry.utils.event_tracker import TransactionStageStatus, track_sampled_event from sentry.utils.safe import safe_execute @@ -228,6 +232,10 @@ def _do_preprocess_event( time_limit=65, soft_time_limit=60, silo_mode=SiloMode.REGION, + taskworker_config=TaskworkerConfig( + namespace=ingest_errors_tasks, + processing_deadline_duration=65, + ), ) def preprocess_event( cache_key: str, @@ -438,6 +446,10 @@ def _continue_to_save_event() -> None: time_limit=65, soft_time_limit=60, silo_mode=SiloMode.REGION, + taskworker_config=TaskworkerConfig( + namespace=ingest_errors_tasks, + processing_deadline_duration=65, + ), ) def process_event( cache_key: str, @@ -632,6 +644,10 @@ def _do_save_event( time_limit=65, soft_time_limit=60, silo_mode=SiloMode.REGION, + taskworker_config=TaskworkerConfig( + namespace=ingest_errors_tasks, + processing_deadline_duration=65, + ), ) def save_event( cache_key: str | None = None, @@ -657,6 +673,10 @@ def save_event( time_limit=65, soft_time_limit=60, silo_mode=SiloMode.REGION, + taskworker_config=TaskworkerConfig( + namespace=ingest_transactions_tasks, + processing_deadline_duration=65, + ), ) def save_event_transaction( cache_key: str | None = None, diff --git a/src/sentry/taskworker/namespaces.py b/src/sentry/taskworker/namespaces.py index 47d8c88bf1d802..66a3eb4dca1f70 100644 --- a/src/sentry/taskworker/namespaces.py +++ b/src/sentry/taskworker/namespaces.py @@ -57,6 +57,13 @@ app_feature="profiles", ) +ingest_transactions_tasks = taskregistry.create_namespace( + "ingest.transactions", + app_feature="transactions", +) + +ingest_errors_tasks = taskregistry.create_namespace("ingest.errors", app_feature="errors") + issues_tasks = taskregistry.create_namespace("issues", app_feature="issueplatform") integrations_tasks = taskregistry.create_namespace("integrations", app_feature="integrations") diff --git a/src/sentry/testutils/factories.py b/src/sentry/testutils/factories.py index a97cec94cda1d5..e49c062dff8191 100644 --- a/src/sentry/testutils/factories.py +++ b/src/sentry/testutils/factories.py @@ -2048,8 +2048,6 @@ def create_project_uptime_subscription( mode: ProjectUptimeSubscriptionMode, name: str | None, owner: Actor | None, - uptime_status: UptimeStatus, - uptime_status_update_date: datetime, id: int | None, ): if name is None: @@ -2071,8 +2069,6 @@ def create_project_uptime_subscription( name=name, owner_team_id=owner_team_id, owner_user_id=owner_user_id, - uptime_status=uptime_status, - uptime_status_update_date=uptime_status_update_date, pk=id, ) diff --git a/src/sentry/testutils/fixtures.py b/src/sentry/testutils/fixtures.py index 461d50642cd837..4a316c56641a89 100644 --- a/src/sentry/testutils/fixtures.py +++ b/src/sentry/testutils/fixtures.py @@ -771,8 +771,6 @@ def create_project_uptime_subscription( project = self.project if env is None: env = self.environment - if uptime_status_update_date is None: - uptime_status_update_date = timezone.now() if uptime_subscription is None: uptime_subscription = self.create_uptime_subscription( @@ -787,8 +785,6 @@ def create_project_uptime_subscription( mode, name, Actor.from_object(owner) if owner else None, - uptime_status, - uptime_status_update_date, id, ) # TODO(epurkhiser): Dual create a detector as well, can be removed diff --git a/src/sentry/testutils/helpers/datetime.py b/src/sentry/testutils/helpers/datetime.py index 206634b893e024..2d2df1b73e6b02 100644 --- a/src/sentry/testutils/helpers/datetime.py +++ b/src/sentry/testutils/helpers/datetime.py @@ -12,6 +12,12 @@ def before_now(**kwargs: float) -> datetime: return date - timedelta(microseconds=date.microsecond % 1000) +def isoformat_z(dt: datetime) -> str: + """Generally prefer .isoformat()""" + assert dt.tzinfo == UTC + return f"{dt.isoformat().removesuffix('+00:00')}Z" + + class MockClock: """Returns a distinct, increasing timestamp each time it is called.""" diff --git a/src/sentry/types/referrer_ids.py b/src/sentry/types/referrer_ids.py index a924e848cb422a..7ed60687d91fd9 100644 --- a/src/sentry/types/referrer_ids.py +++ b/src/sentry/types/referrer_ids.py @@ -3,3 +3,4 @@ GITHUB_PR_BOT_REFERRER = "github-pr-bot" GITHUB_OPEN_PR_BOT_REFERRER = "github-open-pr-bot" +GITLAB_PR_BOT_REFERRER = "gitlab-pr-bot" diff --git a/src/sentry/uptime/consumers/results_consumer.py b/src/sentry/uptime/consumers/results_consumer.py index 8471a1b09bce1d..0e4fc4b4c1711b 100644 --- a/src/sentry/uptime/consumers/results_consumer.py +++ b/src/sentry/uptime/consumers/results_consumer.py @@ -186,10 +186,12 @@ def produce_snuba_uptime_result( result: The check result to be sent to Snuba """ try: + uptime_subscription = project_subscription.uptime_subscription project = project_subscription.project + retention_days = quotas.backend.get_event_retention(organization=project.organization) or 90 - if project_subscription.uptime_status == UptimeStatus.FAILED: + if uptime_subscription.uptime_status == UptimeStatus.FAILED: incident_status = IncidentStatus.IN_INCIDENT else: incident_status = IncidentStatus.NO_INCIDENT @@ -300,7 +302,8 @@ def handle_active_result( result: CheckResult, metric_tags: dict[str, str], ): - uptime_status = project_subscription.uptime_status + uptime_subscription = project_subscription.uptime_subscription + uptime_status = uptime_subscription.uptime_status result_status = result["status"] redis = _get_cluster() @@ -322,7 +325,7 @@ def handle_active_result( restricted_host_provider_ids = options.get( "uptime.restrict-issue-creation-by-hosting-provider-id" ) - host_provider_id = project_subscription.uptime_subscription.host_provider_id + host_provider_id = uptime_subscription.host_provider_id issue_creation_restricted_by_provider = host_provider_id in restricted_host_provider_ids if issue_creation_restricted_by_provider: @@ -346,7 +349,7 @@ def handle_active_result( "uptime_active_sent_occurrence", extra={ "project_id": project_subscription.project_id, - "url": project_subscription.uptime_subscription.url, + "url": uptime_subscription.url, **result, }, ) @@ -378,7 +381,7 @@ def handle_active_result( "uptime_active_resolved", extra={ "project_id": project_subscription.project_id, - "url": project_subscription.uptime_subscription.url, + "url": uptime_subscription.url, **result, }, ) diff --git a/src/sentry/uptime/endpoints/serializers.py b/src/sentry/uptime/endpoints/serializers.py index 94f32a5e6103ba..e2f747e9ab2390 100644 --- a/src/sentry/uptime/endpoints/serializers.py +++ b/src/sentry/uptime/endpoints/serializers.py @@ -64,7 +64,7 @@ def serialize( "environment": obj.environment.name if obj.environment else None, "name": obj.name or f"Uptime Monitoring for {obj.uptime_subscription.url}", "status": obj.get_status_display(), - "uptimeStatus": obj.uptime_status, + "uptimeStatus": obj.uptime_subscription.uptime_status, "mode": obj.mode, "url": obj.uptime_subscription.url, "headers": obj.uptime_subscription.headers, diff --git a/src/sentry/uptime/subscriptions/subscriptions.py b/src/sentry/uptime/subscriptions/subscriptions.py index 2bceb52c122da6..453fba1885da0f 100644 --- a/src/sentry/uptime/subscriptions/subscriptions.py +++ b/src/sentry/uptime/subscriptions/subscriptions.py @@ -345,15 +345,15 @@ def disable_uptime_detector(detector: Detector): also be disabled. """ uptime_monitor = get_project_subscription(detector) + uptime_subscription = uptime_monitor.uptime_subscription + if uptime_monitor.status == ObjectStatus.DISABLED: return - if uptime_monitor.uptime_status == UptimeStatus.FAILED: + if uptime_subscription.uptime_status == UptimeStatus.FAILED: # Resolve the issue so that we don't see it in the ui anymore resolve_uptime_issue(uptime_monitor) - uptime_subscription = uptime_monitor.uptime_subscription - uptime_monitor.update( status=ObjectStatus.DISABLED, # We set the status back to ok here so that if we re-enable we'll start diff --git a/src/sentry/utils/event_frames.py b/src/sentry/utils/event_frames.py index edf2e077ae415f..1ea5663551fa31 100644 --- a/src/sentry/utils/event_frames.py +++ b/src/sentry/utils/event_frames.py @@ -43,26 +43,6 @@ class SdkFrameMunger: def java_frame_munger(frame: EventFrame) -> str | None: - if not frame.filename or not frame.module: - return None - - if "$" in frame.module: - path = frame.module.split("$")[0].replace(".", "/") - if frame.abs_path and frame.abs_path.count(".") == 1: - # Append extension - path = path + "." + frame.abs_path.split(".")[-1] - return path - - if "/" not in str(frame.filename) and frame.module: - # Replace the last module segment with the filename, as the - # terminal element in a module path is the class - module = frame.module.split(".") - module[-1] = frame.filename - return "/".join(module) - return None - - -def java_new_logic_frame_munger(frame: EventFrame) -> str | None: stacktrace_path = None if not frame.module or not frame.abs_path: return None @@ -138,7 +118,6 @@ def package_relative_path(abs_path: str | None, package: str | None) -> str | No PLATFORM_FRAME_MUNGER: dict[str, SdkFrameMunger] = { "java": SdkFrameMunger(java_frame_munger), - "java-new-logic": SdkFrameMunger(java_new_logic_frame_munger), "cocoa": SdkFrameMunger(cocoa_frame_munger), "other": SdkFrameMunger(flutter_frame_munger, True, {"sentry.dart.flutter"}), } diff --git a/src/sentry/workflow_engine/migrations/0053_add_legacy_rule_indices.py b/src/sentry/workflow_engine/migrations/0053_add_legacy_rule_indices.py new file mode 100644 index 00000000000000..ebf42b4a7f7213 --- /dev/null +++ b/src/sentry/workflow_engine/migrations/0053_add_legacy_rule_indices.py @@ -0,0 +1,26 @@ +# Generated by Django 5.1.7 on 2025-05-01 00:33 + +from django.db import migrations, models + +from sentry.new_migrations.migrations import CheckedMigration + + +class Migration(CheckedMigration): + atomic = False + + is_post_deployment = True + + dependencies = [ + ("workflow_engine", "0052_migrate_errored_metric_alerts"), + ] + + operations = [ + migrations.AddIndex( + model_name="alertruleworkflow", + index=models.Index(fields=["rule_id"], name="idx_arw_rule_id"), + ), + migrations.AddIndex( + model_name="alertruleworkflow", + index=models.Index(fields=["alert_rule_id"], name="idx_arw_alert_rule_id"), + ), + ] diff --git a/src/sentry/workflow_engine/models/alertrule_workflow.py b/src/sentry/workflow_engine/models/alertrule_workflow.py index b27617e4ae7e86..34359a6bfe85ea 100644 --- a/src/sentry/workflow_engine/models/alertrule_workflow.py +++ b/src/sentry/workflow_engine/models/alertrule_workflow.py @@ -1,3 +1,4 @@ +from django.db import models from django.db.models import CheckConstraint, Q from sentry.backup.scopes import RelocationScope @@ -35,3 +36,13 @@ class Meta: name="rule_or_alert_rule_workflow", ), ] + indexes = [ + models.Index( + fields=["rule_id"], + name="idx_arw_rule_id", + ), + models.Index( + fields=["alert_rule_id"], + name="idx_arw_alert_rule_id", + ), + ] diff --git a/static/app/components/core/menuListItem/index.chonk.tsx b/static/app/components/core/menuListItem/index.chonk.tsx index 9c91e99ede9a05..4b237c30693b3f 100644 --- a/static/app/components/core/menuListItem/index.chonk.tsx +++ b/static/app/components/core/menuListItem/index.chonk.tsx @@ -71,8 +71,12 @@ export const ChonkInnerWrap = chonkStyled('div', { font-size: ${p => p.theme.form[p.size ?? 'md'].fontSize}; &, - &:hover { + &:hover, + &:focus, + &:focus-visible { color: ${getTextColor}; + box-shadow: none; + outline: none; } ${p => p.disabled && `cursor: default;`} diff --git a/static/app/components/core/menuListItem/index.tsx b/static/app/components/core/menuListItem/index.tsx index 7d789238c41e42..68918c35a720a3 100644 --- a/static/app/components/core/menuListItem/index.tsx +++ b/static/app/components/core/menuListItem/index.tsx @@ -2,7 +2,7 @@ import {memo, useId, useRef, useState} from 'react'; import {createPortal} from 'react-dom'; import {usePopper} from 'react-popper'; import isPropValid from '@emotion/is-prop-valid'; -import {type Theme, useTheme} from '@emotion/react'; +import {css, type Theme, useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {mergeRefs} from '@react-aria/utils'; @@ -123,7 +123,6 @@ function BaseMenuListItem({ return ( p.theme.form[p.size ?? 'md'].fontSize}; &, - &:hover { + &:hover, + &:focus, + &:focus-visible { color: ${getTextColor}; + box-shadow: none; + outline: none; } ${p => p.disabled && `cursor: default;`} @@ -330,13 +333,13 @@ export const InnerWrap = withChonk( ${p => p.isFocused && - ` - z-index: 1; - /* Background to hide the previous item's divider */ - ::before { - background: ${p.theme.backgroundElevated}; - } - `} + css` + z-index: 1; + /* Background to hide the previous item's divider */ + ::before { + background: ${p.theme.backgroundElevated}; + } + `} `, ChonkInnerWrap ); diff --git a/static/app/components/dropdownMenu/index.spec.tsx b/static/app/components/dropdownMenu/index.spec.tsx index 9e2b0301388cdf..7169ccb17ef809 100644 --- a/static/app/components/dropdownMenu/index.spec.tsx +++ b/static/app/components/dropdownMenu/index.spec.tsx @@ -1,5 +1,4 @@ import {Fragment} from 'react'; -import {RouterFixture} from 'sentry-fixture/routerFixture'; import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; @@ -25,10 +24,7 @@ describe('DropdownMenu', function () { }, ]} triggerLabel="This is a Menu" - />, - { - deprecatedRouterMocks: true, - } + /> ); // Open the mneu @@ -71,10 +67,7 @@ describe('DropdownMenu', function () { }, ]} triggerLabel="Menu" - />, - { - deprecatedRouterMocks: true, - } + /> ); await userEvent.click(screen.getByRole('button', {name: 'Menu'})); @@ -95,10 +88,7 @@ describe('DropdownMenu', function () { - , - { - deprecatedRouterMocks: true, - } + ); // Can be dismissed by clicking outside @@ -164,10 +154,7 @@ describe('DropdownMenu', function () { }, ]} triggerLabel="Menu" - />, - { - deprecatedRouterMocks: true, - } + /> ); await userEvent.click(screen.getByRole('button', {name: 'Menu'})); @@ -254,10 +241,7 @@ describe('DropdownMenu', function () { }, ]} triggerLabel="Menu" - />, - { - deprecatedRouterMocks: true, - } + /> ); await userEvent.click(screen.getByRole('button', {name: 'Menu'})); @@ -271,10 +255,7 @@ describe('DropdownMenu', function () { , - { - deprecatedRouterMocks: true, - } + /> ); await userEvent.click(screen.getByRole('button', {name: 'Menu'})); @@ -285,14 +266,13 @@ describe('DropdownMenu', function () { }); it('closes after clicking external link', async function () { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + render( , - { - deprecatedRouterMocks: true, - } + /> ); await userEvent.click(screen.getByRole('button', {name: 'Menu'})); @@ -300,39 +280,34 @@ describe('DropdownMenu', function () { await waitFor(() => { expect(screen.queryByRole('menuitemradio')).not.toBeInTheDocument(); }); + + // JSDOM throws an error on navigation to random urls + expect(errorSpy).toHaveBeenCalledTimes(1); }); it('navigates to link on enter', async function () { const onAction = jest.fn(); - const router = RouterFixture(); - render( + const {router} = render( , - { - router, - deprecatedRouterMocks: true, - } + /> ); await userEvent.click(screen.getByRole('button', {name: 'Menu'})); await userEvent.keyboard('{ArrowDown}'); await userEvent.keyboard('{Enter}'); await waitFor(() => { - expect(router.push).toHaveBeenCalledWith( - expect.objectContaining({pathname: '/test2'}) - ); + expect(router.location.pathname).toBe('/test2'); }); expect(onAction).toHaveBeenCalledTimes(1); }); it('navigates to link on meta key', async function () { const onAction = jest.fn(); - const router = RouterFixture(); const user = userEvent.setup(); const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -344,11 +319,7 @@ describe('DropdownMenu', function () { {key: 'item2', label: 'Item Two', to: '/test2', onAction}, ]} triggerLabel="Menu" - />, - { - router, - deprecatedRouterMocks: true, - } + /> ); await user.click(screen.getByRole('button', {name: 'Menu'})); @@ -366,9 +337,10 @@ describe('DropdownMenu', function () { it('navigates to external link enter', async function () { const onAction = jest.fn(); - const router = RouterFixture(); const user = userEvent.setup(); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + render( , - { - router, - deprecatedRouterMocks: true, - } + /> ); await user.click(screen.getByRole('button', {name: 'Menu'})); @@ -393,5 +361,7 @@ describe('DropdownMenu', function () { await user.keyboard('{Enter}'); expect(onAction).toHaveBeenCalledTimes(1); + // JSDOM throws an error on navigation + expect(errorSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/static/app/components/dropdownMenu/index.tsx b/static/app/components/dropdownMenu/index.tsx index 02bb6648d585a4..a56ac9cd35053b 100644 --- a/static/app/components/dropdownMenu/index.tsx +++ b/static/app/components/dropdownMenu/index.tsx @@ -218,7 +218,17 @@ function DropdownMenu({ ); } - const activeItems = useMemo(() => removeHiddenItems(items), [items]); + const activeItems = useMemo( + () => + removeHiddenItems(items).map(item => { + return { + ...item, + // react-aria uses the href prop on item state to determine if the item is a link + href: item.to ?? item.externalHref, + }; + }), + [items] + ); const defaultDisabledKeys = useMemo(() => getDisabledKeys(activeItems), [activeItems]); function renderMenu() { diff --git a/static/app/components/dropdownMenu/item.tsx b/static/app/components/dropdownMenu/item.tsx index a07ecc18097599..5b624850421803 100644 --- a/static/app/components/dropdownMenu/item.tsx +++ b/static/app/components/dropdownMenu/item.tsx @@ -1,20 +1,16 @@ import {Fragment, useContext, useEffect, useRef} from 'react'; import {useHover, useKeyboard} from '@react-aria/interactions'; import {useMenuItem} from '@react-aria/menu'; -import {mergeProps, mergeRefs} from '@react-aria/utils'; +import {mergeProps} from '@react-aria/utils'; import type {TreeState} from '@react-stately/tree'; import type {Node} from '@react-types/shared'; import type {LocationDescriptor} from 'history'; import type {MenuListItemProps} from 'sentry/components/core/menuListItem'; -import { - InnerWrap as MenuListItemInnerWrap, - MenuListItem, -} from 'sentry/components/core/menuListItem'; +import {MenuListItem} from 'sentry/components/core/menuListItem'; import ExternalLink from 'sentry/components/links/externalLink'; import Link from 'sentry/components/links/link'; import {IconChevron} from 'sentry/icons'; -import {useNavigate} from 'sentry/utils/useNavigate'; import usePrevious from 'sentry/utils/usePrevious'; import {DropdownMenuContext} from './list'; @@ -112,17 +108,17 @@ function DropdownMenuItem({ ref, ...props }: DropdownMenuItemProps) { - const listElementRef = useRef(null); + const innerWrapRef = useRef(null); const isDisabled = state.disabledKeys.has(node.key); const isFocused = state.selectionManager.focusedKey === node.key; const {key, onAction, to, label, isSubmenu, trailingItems, externalHref, ...itemProps} = node.value ?? {}; const {size} = node.props; const {rootOverlayState} = useContext(DropdownMenuContext); - const navigate = useNavigate(); + const isLink = to || externalHref; const actionHandler = () => { - if (to || externalHref) { + if (isLink) { // Close the menu after the click event has bubbled to the link // Only needed on links that do not unmount the menu if (closeOnSelect) { @@ -166,25 +162,6 @@ function DropdownMenuItem({ // Open submenu on arrow right key press const {keyboardProps} = useKeyboard({ onKeyDown: e => { - if (e.key === 'Enter' && (to || externalHref)) { - // If the user is holding down the meta key, we want to dispatch a mouse event - if (e.metaKey || e.ctrlKey || externalHref) { - const mouseEvent = new MouseEvent('click', { - ctrlKey: e.ctrlKey, - metaKey: e.metaKey, - }); - listElementRef.current - ?.querySelector(`${MenuListItemInnerWrap}`) - ?.dispatchEvent(mouseEvent); - return; - } - - if (to) { - navigate(to); - } - return; - } - if (e.key === 'ArrowRight' && isSubmenu) { state.selectionManager.replaceSelection(node.key); return; @@ -203,38 +180,48 @@ function DropdownMenuItem({ onClose?.(); rootOverlayState?.close(); }, - closeOnSelect: to || externalHref ? false : closeOnSelect, + closeOnSelect: isLink ? false : closeOnSelect, isDisabled, }, state, - listElementRef + innerWrapRef ); - // Merged menu item props, class names are combined, event handlers chained, - // etc. See: https://react-spectrum.adobe.com/react-aria/mergeProps.html - const mergedProps = mergeProps(props, menuItemProps, hoverProps, keyboardProps); - const itemLabel = node.rendered ?? label; const makeInnerWrapProps = () => { if (to) { - return {as: Link, to}; + return { + as: Link, + to, + }; } if (externalHref) { - return {as: ExternalLink, href: externalHref}; + return { + as: ExternalLink, + href: externalHref, + }; } return {as: 'div' as const}; }; + const mergedMenuItemContentProps = mergeProps( + props, + menuItemProps, + hoverProps, + keyboardProps, + makeInnerWrapProps(), + {ref: innerWrapRef, 'data-test-id': key} + ); + const itemLabel = node.rendered ?? label; return ( ); diff --git a/static/app/components/errorBoundary.tsx b/static/app/components/errorBoundary.tsx index b729a5a386f834..525e6efd3ed727 100644 --- a/static/app/components/errorBoundary.tsx +++ b/static/app/components/errorBoundary.tsx @@ -55,15 +55,18 @@ class ErrorBoundary extends Component { Object.keys(errorTag).forEach(tag => scope.setTag(tag, errorTag[tag])); } - // Based on https://github.com/getsentry/sentry-javascript/blob/6f4ad562c469f546f1098136b65583309d03487b/packages/react/src/errorboundary.tsx#L75-L85 - const errorBoundaryError = new Error(error.message); - errorBoundaryError.name = `React ErrorBoundary ${errorBoundaryError.name}`; - errorBoundaryError.stack = errorInfo.componentStack!; - - error.cause = errorBoundaryError; - - scope.setExtra('errorInfo', errorInfo); - Sentry.captureException(error); + try { + // Based on https://github.com/getsentry/sentry-javascript/blob/6f4ad562c469f546f1098136b65583309d03487b/packages/react/src/errorboundary.tsx#L75-L85 + const errorBoundaryError = new Error(error.message); + errorBoundaryError.name = `React ErrorBoundary ${errorBoundaryError.name}`; + errorBoundaryError.stack = errorInfo.componentStack!; + error.cause = errorBoundaryError; + } catch { + // Some browsers won't let you write to Error instance + scope.setExtra('errorInfo', errorInfo); + } finally { + Sentry.captureException(error); + } }); } diff --git a/static/app/components/events/autofix/autofixChanges.analytics.spec.tsx b/static/app/components/events/autofix/autofixChanges.analytics.spec.tsx index 5e5cbd3bd7972f..740a524725224c 100644 --- a/static/app/components/events/autofix/autofixChanges.analytics.spec.tsx +++ b/static/app/components/events/autofix/autofixChanges.analytics.spec.tsx @@ -65,7 +65,7 @@ describe('AutofixChanges', () => { it('passes correct analytics props for Create PR button when write access is enabled', async () => { MockApiClient.addMockResponse({ - url: '/issues/123/autofix/setup/?check_write_access=true', + url: '/organizations/org-slug/issues/123/autofix/setup/?check_write_access=true', method: 'GET', body: AutofixSetupFixture({ setupAcknowledgement: { @@ -78,7 +78,7 @@ describe('AutofixChanges', () => { }); MockApiClient.addMockResponse({ - url: '/issues/123/autofix/update/', + url: '/organizations/org-slug/issues/123/autofix/update/', method: 'POST', body: {ok: true}, }); @@ -121,7 +121,7 @@ describe('AutofixChanges', () => { it('passes correct analytics props for Create PR Setup button when write access is not enabled', () => { MockApiClient.addMockResponse({ - url: '/issues/123/autofix/setup/?check_write_access=true', + url: '/organizations/org-slug/issues/123/autofix/setup/?check_write_access=true', method: 'GET', body: AutofixSetupFixture({ setupAcknowledgement: { @@ -176,7 +176,7 @@ describe('AutofixChanges', () => { it('passes correct analytics props for Create Branch button when write access is enabled', async () => { MockApiClient.addMockResponse({ - url: '/issues/123/autofix/setup/?check_write_access=true', + url: '/organizations/org-slug/issues/123/autofix/setup/?check_write_access=true', method: 'GET', body: AutofixSetupFixture({ setupAcknowledgement: { @@ -192,7 +192,7 @@ describe('AutofixChanges', () => { }); MockApiClient.addMockResponse({ - url: '/issues/123/autofix/update/', + url: '/organizations/org-slug/issues/123/autofix/update/', method: 'POST', body: {ok: true}, }); @@ -236,7 +236,7 @@ describe('AutofixChanges', () => { it('passes correct analytics props for Create Branch Setup button when write access is not enabled', () => { MockApiClient.addMockResponse({ - url: '/issues/123/autofix/setup/?check_write_access=true', + url: '/organizations/org-slug/issues/123/autofix/setup/?check_write_access=true', method: 'GET', body: AutofixSetupFixture({ setupAcknowledgement: { diff --git a/static/app/components/events/autofix/autofixChanges.tsx b/static/app/components/events/autofix/autofixChanges.tsx index 79f9adbbeca817..0f4533391aba2b 100644 --- a/static/app/components/events/autofix/autofixChanges.tsx +++ b/static/app/components/events/autofix/autofixChanges.tsx @@ -34,6 +34,7 @@ import {useMutation, useQueryClient} from 'sentry/utils/queryClient'; import testableTransition from 'sentry/utils/testableTransition'; import useApi from 'sentry/utils/useApi'; import useCopyToClipboard from 'sentry/utils/useCopyToClipboard'; +import useOrganization from 'sentry/utils/useOrganization'; type AutofixChangesProps = { groupId: string; @@ -468,6 +469,7 @@ function CreatePRsButton({ const api = useApi(); const queryClient = useQueryClient(); const [hasClicked, setHasClicked] = useState(false); + const orgSlug = useOrganization().slug; // Reset hasClicked state and notify parent when isBusy goes from true to false useEffect(() => { @@ -479,19 +481,22 @@ function CreatePRsButton({ const {mutate: createPr} = useMutation({ mutationFn: ({change}: {change: AutofixCodebaseChange}) => { - return api.requestPromise(`/issues/${groupId}/autofix/update/`, { - method: 'POST', - data: { - run_id: runId, - payload: { - type: 'create_pr', - repo_external_id: change.repo_external_id, + return api.requestPromise( + `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`, + { + method: 'POST', + data: { + run_id: runId, + payload: { + type: 'create_pr', + repo_external_id: change.repo_external_id, + }, }, - }, - }); + } + ); }, onSuccess: () => { - queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(groupId)}); + queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(orgSlug, groupId)}); setHasClicked(true); }, onError: () => { @@ -542,6 +547,7 @@ function CreateBranchButton({ const api = useApi(); const queryClient = useQueryClient(); const [hasClicked, setHasClicked] = useState(false); + const orgSlug = useOrganization().slug; // Reset hasClicked state and notify parent when isBusy goes from true to false useEffect(() => { @@ -553,19 +559,22 @@ function CreateBranchButton({ const {mutate: createBranch} = useMutation({ mutationFn: ({change}: {change: AutofixCodebaseChange}) => { - return api.requestPromise(`/issues/${groupId}/autofix/update/`, { - method: 'POST', - data: { - run_id: runId, - payload: { - type: 'create_branch', - repo_external_id: change.repo_external_id, + return api.requestPromise( + `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`, + { + method: 'POST', + data: { + run_id: runId, + payload: { + type: 'create_branch', + repo_external_id: change.repo_external_id, + }, }, - }, - }); + } + ); }, onSuccess: () => { - queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(groupId)}); + queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(orgSlug, groupId)}); }, onError: () => { addErrorMessage(t('Failed to push to branches.')); diff --git a/static/app/components/events/autofix/autofixDiff.spec.tsx b/static/app/components/events/autofix/autofixDiff.spec.tsx index c6b58e67084b52..d52120edf3849b 100644 --- a/static/app/components/events/autofix/autofixDiff.spec.tsx +++ b/static/app/components/events/autofix/autofixDiff.spec.tsx @@ -105,7 +105,7 @@ describe('AutofixDiff', function () { await userEvent.type(textarea!, 'New content'); MockApiClient.addMockResponse({ - url: '/issues/1/autofix/update/', + url: '/organizations/org-slug/issues/1/autofix/update/', method: 'POST', }); @@ -123,7 +123,7 @@ describe('AutofixDiff', function () { render(); MockApiClient.addMockResponse({ - url: '/issues/1/autofix/update/', + url: '/organizations/org-slug/issues/1/autofix/update/', method: 'POST', }); @@ -145,7 +145,7 @@ describe('AutofixDiff', function () { await userEvent.type(textarea!, 'New content'); MockApiClient.addMockResponse({ - url: '/issues/1/autofix/update/', + url: '/organizations/org-slug/issues/1/autofix/update/', method: 'POST', statusCode: 500, }); diff --git a/static/app/components/events/autofix/autofixDiff.tsx b/static/app/components/events/autofix/autofixDiff.tsx index 76957e1571d7bc..2ebab8a19c2079 100644 --- a/static/app/components/events/autofix/autofixDiff.tsx +++ b/static/app/components/events/autofix/autofixDiff.tsx @@ -20,6 +20,7 @@ import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {useMutation, useQueryClient} from 'sentry/utils/queryClient'; import useApi from 'sentry/utils/useApi'; +import useOrganization from 'sentry/utils/useOrganization'; import {usePrismTokens} from 'sentry/utils/usePrismTokens'; type AutofixDiffProps = { @@ -190,6 +191,8 @@ function HunkHeader({lines, sectionHeader}: {lines: DiffLine[]; sectionHeader: s function useUpdateHunk({groupId, runId}: {groupId: string; runId: string}) { const api = useApi({persistInFlight: true}); const queryClient = useQueryClient(); + const orgSlug = useOrganization().slug; + return useMutation({ mutationFn: (params: { fileName: string; @@ -197,22 +200,25 @@ function useUpdateHunk({groupId, runId}: {groupId: string; runId: string}) { lines: DiffLine[]; repoId?: string; }) => { - return api.requestPromise(`/issues/${groupId}/autofix/update/`, { - method: 'POST', - data: { - run_id: runId, - payload: { - type: 'update_code_change', - repo_id: params.repoId ?? null, - hunk_index: params.hunkIndex, - lines: params.lines, - file_path: params.fileName, + return api.requestPromise( + `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`, + { + method: 'POST', + data: { + run_id: runId, + payload: { + type: 'update_code_change', + repo_id: params.repoId ?? null, + hunk_index: params.hunkIndex, + lines: params.lines, + file_path: params.fileName, + }, }, - }, - }); + } + ); }, onSuccess: _ => { - queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(groupId)}); + queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(orgSlug, groupId)}); }, onError: () => { addErrorMessage(t('Something went wrong when updating changes.')); diff --git a/static/app/components/events/autofix/autofixHighlightPopup.tsx b/static/app/components/events/autofix/autofixHighlightPopup.tsx index 9c989654c5b0ca..5161175ba44a7a 100644 --- a/static/app/components/events/autofix/autofixHighlightPopup.tsx +++ b/static/app/components/events/autofix/autofixHighlightPopup.tsx @@ -28,6 +28,7 @@ import {space} from 'sentry/styles/space'; import testableTransition from 'sentry/utils/testableTransition'; import useApi from 'sentry/utils/useApi'; import useMedia from 'sentry/utils/useMedia'; +import useOrganization from 'sentry/utils/useOrganization'; import {useUser} from 'sentry/utils/useUser'; import type {CommentThreadMessage} from './types'; @@ -52,6 +53,7 @@ const MIN_LEFT_MARGIN = 8; function useCommentThread({groupId, runId}: {groupId: string; runId: string}) { const api = useApi({persistInFlight: true}); const queryClient = useQueryClient(); + const orgSlug = useOrganization().slug; return useMutation({ mutationFn: (params: { @@ -62,24 +64,27 @@ function useCommentThread({groupId, runId}: {groupId: string; runId: string}) { step_index: number; thread_id: string; }) => { - return api.requestPromise(`/issues/${groupId}/autofix/update/`, { - method: 'POST', - data: { - run_id: runId, - payload: { - type: 'comment_thread', - message: params.message, - thread_id: params.thread_id, - selected_text: params.selected_text, - step_index: params.step_index, - retain_insight_card_index: params.retain_insight_card_index, - is_agent_comment: params.is_agent_comment, + return api.requestPromise( + `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`, + { + method: 'POST', + data: { + run_id: runId, + payload: { + type: 'comment_thread', + message: params.message, + thread_id: params.thread_id, + selected_text: params.selected_text, + step_index: params.step_index, + retain_insight_card_index: params.retain_insight_card_index, + is_agent_comment: params.is_agent_comment, + }, }, - }, - }); + } + ); }, onSuccess: _ => { - queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(groupId)}); + queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(orgSlug, groupId)}); }, onError: () => { addErrorMessage(t('Something went wrong when sending your comment.')); @@ -90,6 +95,7 @@ function useCommentThread({groupId, runId}: {groupId: string; runId: string}) { function useCloseCommentThread({groupId, runId}: {groupId: string; runId: string}) { const api = useApi({persistInFlight: true}); const queryClient = useQueryClient(); + const orgSlug = useOrganization().slug; return useMutation({ mutationFn: (params: { @@ -97,21 +103,24 @@ function useCloseCommentThread({groupId, runId}: {groupId: string; runId: string step_index: number; thread_id: string; }) => { - return api.requestPromise(`/issues/${groupId}/autofix/update/`, { - method: 'POST', - data: { - run_id: runId, - payload: { - type: 'resolve_comment_thread', - thread_id: params.thread_id, - step_index: params.step_index, - is_agent_comment: params.is_agent_comment, + return api.requestPromise( + `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`, + { + method: 'POST', + data: { + run_id: runId, + payload: { + type: 'resolve_comment_thread', + thread_id: params.thread_id, + step_index: params.step_index, + is_agent_comment: params.is_agent_comment, + }, }, - }, - }); + } + ); }, onSuccess: _ => { - queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(groupId)}); + queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(orgSlug, groupId)}); }, onError: () => { addErrorMessage(t('Something went wrong when resolving the thread.')); diff --git a/static/app/components/events/autofix/autofixInsightCards.spec.tsx b/static/app/components/events/autofix/autofixInsightCards.spec.tsx index a7aeb1658663a4..20dbf1b8f95913 100644 --- a/static/app/components/events/autofix/autofixInsightCards.spec.tsx +++ b/static/app/components/events/autofix/autofixInsightCards.spec.tsx @@ -110,7 +110,7 @@ describe('AutofixInsightCards', () => { it('submits edit request when form is submitted', async () => { const mockApi = MockApiClient.addMockResponse({ - url: '/issues/1/autofix/update/', + url: '/organizations/org-slug/issues/1/autofix/update/', method: 'POST', }); @@ -125,7 +125,7 @@ describe('AutofixInsightCards', () => { await userEvent.click(submitButton); expect(mockApi).toHaveBeenCalledWith( - '/issues/1/autofix/update/', + '/organizations/org-slug/issues/1/autofix/update/', expect.objectContaining({ method: 'POST', data: expect.objectContaining({ @@ -143,7 +143,7 @@ describe('AutofixInsightCards', () => { it('shows success message after successful edit submission', async () => { MockApiClient.addMockResponse({ - url: '/issues/1/autofix/update/', + url: '/organizations/org-slug/issues/1/autofix/update/', method: 'POST', }); @@ -164,7 +164,7 @@ describe('AutofixInsightCards', () => { it('shows error message after failed edit submission', async () => { MockApiClient.addMockResponse({ - url: '/issues/1/autofix/update/', + url: '/organizations/org-slug/issues/1/autofix/update/', method: 'POST', statusCode: 500, }); diff --git a/static/app/components/events/autofix/autofixInsightCards.tsx b/static/app/components/events/autofix/autofixInsightCards.tsx index d0e7a9f32b9e9a..05d256f7f09594 100644 --- a/static/app/components/events/autofix/autofixInsightCards.tsx +++ b/static/app/components/events/autofix/autofixInsightCards.tsx @@ -19,31 +19,25 @@ import {singleLineRenderer} from 'sentry/utils/marked/marked'; import {MarkedText} from 'sentry/utils/marked/markedText'; import {useMutation, useQueryClient} from 'sentry/utils/queryClient'; import useApi from 'sentry/utils/useApi'; +import useOrganization from 'sentry/utils/useOrganization'; interface AutofixInsightCardProps { groupId: string; - hasCardAbove: boolean; - hasCardBelow: boolean; index: number; insight: AutofixInsight; - insightCount: number; + isNewInsight: boolean | undefined; runId: string; stepIndex: number; - isNewInsight?: boolean; } function AutofixInsightCard({ insight, - hasCardBelow, - hasCardAbove, index, stepIndex, groupId, runId, - insightCount, isNewInsight, }: AutofixInsightCardProps) { - const isLastInsightInStep = index === insightCount - 1; const isUserMessage = insight.justification === 'USER'; const [expanded, setExpanded] = useState(false); const [isEditing, setIsEditing] = useState(false); @@ -111,14 +105,6 @@ function AutofixInsightCard({ - {hasCardAbove && ( - - )} {isEditing ? ( @@ -164,14 +150,17 @@ function AutofixInsightCard({ ) : ( - + - + @@ -182,7 +171,7 @@ function AutofixInsightCard({ icon={ } aria-label={expanded ? t('Hide evidence') : t('Show evidence')} @@ -191,7 +180,7 @@ function AutofixInsightCard({ size="zero" borderless onClick={handleEdit} - icon={} + icon={} aria-label={t('Edit insight')} title={t('Replace insight and rethink')} /> @@ -237,16 +226,6 @@ function AutofixInsightCard({ )} - - {hasCardBelow && ( - - )} @@ -260,46 +239,142 @@ interface AutofixInsightCardsProps { insights: AutofixInsight[]; runId: string; stepIndex: number; - shouldCollapseByDefault?: boolean; } -function CollapsibleChainLink({ - isEmpty, - isCollapsed, - onToggleCollapse, - insightCount, -}: { +interface CollapsibleChainLinkProps { + groupId: string; + runId: string; + stepIndex: number; + alignment?: 'start' | 'center'; insightCount?: number; isCollapsed?: boolean; isEmpty?: boolean; onToggleCollapse?: () => void; -}) { + showAddControl?: boolean; + showCollapseControl?: boolean; +} + +function CollapsibleChainLink({ + insightCount, + isCollapsed, + isEmpty, + onToggleCollapse, + showAddControl, + showCollapseControl, + stepIndex, + groupId, + runId, + alignment = 'center', +}: CollapsibleChainLinkProps) { + const [isAdding, setIsAdding] = useState(false); + const [newInsightText, setNewInsightText] = useState(''); + const {mutate: updateInsight} = useUpdateInsightCard({groupId, runId}); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setIsAdding(false); + updateInsight({ + message: newInsightText, + step_index: stepIndex, + retain_insight_card_index: + insightCount !== undefined && insightCount > 0 ? insightCount - 1 : null, + }); + setNewInsightText(''); + }; + + const handleCancel = () => { + setIsAdding(false); + setNewInsightText(''); + }; + return ( - - - - {onToggleCollapse && ( - - {isCollapsed && insightCount && insightCount > 0 && ( + + + {showCollapseControl && onToggleCollapse && ( + + {isCollapsed && insightCount && insightCount > 0 ? ( {tn('%s insight hidden', '%s insights hidden', insightCount)} + ) : ( + {} )} } - title={isCollapsed ? t('Show reasoning') : t('Hide reasoning')} - aria-label={isCollapsed ? t('Show reasoning') : t('Hide reasoning')} + aria-label={t('Toggle reasoning visibility')} /> )} + {showAddControl && + !isCollapsed && + !isEmpty && + (isAdding ? ( + +
+ + setNewInsightText(e.target.value)} + maxLength={4096} + placeholder={t('Share your own insight here...')} + autoFocus + autosize + size="sm" + maxRows={5} + onKeyDown={e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + }} + /> + + + + + +
+
+ ) : ( + setIsAdding(true)} + icon={} + title={t('Add insight and rethink')} + aria-label={t('Add insight and rethink')} + /> + ))}
); @@ -312,9 +387,8 @@ function AutofixInsightCards({ stepIndex, groupId, runId, - shouldCollapseByDefault, }: AutofixInsightCardsProps) { - const [isCollapsed, setIsCollapsed] = useState(!!shouldCollapseByDefault); + const [isCollapsed, setIsCollapsed] = useState(false); const previousInsightsRef = useRef([]); const [newInsightIndices, setNewInsightIndices] = useState([]); @@ -328,10 +402,6 @@ function AutofixInsightCards({ previousInsightsRef.current = [...insights]; }, [insights]); - useEffect(() => { - setIsCollapsed(!!shouldCollapseByDefault); - }, [shouldCollapseByDefault]); - const handleToggleCollapse = () => { setIsCollapsed(!isCollapsed); }; @@ -339,65 +409,75 @@ function AutofixInsightCards({ const validInsightCount = insights.filter(insight => insight).length; return ( - - {insights.length > 0 ? ( - - {hasStepAbove && ( - - )} - - {!isCollapsed && ( - - {insights.map((insight, index) => - insight ? ( - - ) : null - )} - + + + + + + {insights.length > 0 ? ( + + {hasStepAbove && ( + + )} + + {!isCollapsed && ( + + + {insights.map((insight, index) => + insight ? ( + + ) : null + )} + + + )} + + {!isCollapsed && hasStepBelow && ( + )} - - - ) : stepIndex === 0 && !hasStepBelow ? ( - - ) : hasStepBelow ? ( - - - - ) : null} - + + ) : stepIndex === 0 && !hasStepBelow ? ( + + ) : null} + + ); } function useUpdateInsightCard({groupId, runId}: {groupId: string; runId: string}) { const api = useApi({persistInFlight: true}); const queryClient = useQueryClient(); + const orgSlug = useOrganization().slug; return useMutation({ mutationFn: (params: { @@ -405,21 +485,24 @@ function useUpdateInsightCard({groupId, runId}: {groupId: string; runId: string} retain_insight_card_index: number | null; step_index: number; }) => { - return api.requestPromise(`/issues/${groupId}/autofix/update/`, { - method: 'POST', - data: { - run_id: runId, - payload: { - type: 'restart_from_point_with_feedback', - message: params.message.trim(), - step_index: params.step_index, - retain_insight_card_index: params.retain_insight_card_index, + return api.requestPromise( + `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`, + { + method: 'POST', + data: { + run_id: runId, + payload: { + type: 'restart_from_point_with_feedback', + message: params.message.trim(), + step_index: params.step_index, + retain_insight_card_index: params.retain_insight_card_index, + }, }, - }, - }); + } + ); }, onSuccess: _ => { - queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(groupId)}); + queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(orgSlug, groupId)}); addSuccessMessage(t('Rethinking this...')); }, onError: () => { @@ -428,102 +511,13 @@ function useUpdateInsightCard({groupId, runId}: {groupId: string; runId: string} }); } -interface ChainLinkProps { - groupId: string; - insightCount: number; - runId: string; - stepIndex: number; - isEmpty?: boolean; - isLastCard?: boolean; -} - -function ChainLink({ - isLastCard, - isEmpty, - stepIndex, - groupId, - runId, - insightCount, -}: ChainLinkProps) { - const [isAdding, setIsAdding] = useState(false); - const [newInsightText, setNewInsightText] = useState(''); - const {mutate: updateInsight} = useUpdateInsightCard({groupId, runId}); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - setIsAdding(false); - updateInsight({ - message: newInsightText, - step_index: stepIndex, - retain_insight_card_index: insightCount, - }); - setNewInsightText(''); - }; - - const handleCancel = () => { - setIsAdding(false); - setNewInsightText(''); - }; - - return ( - - - - {isLastCard && - (isAdding ? ( - -
- - setNewInsightText(e.target.value)} - maxLength={4096} - placeholder={t('Share your own insight here...')} - autoFocus - /> - - - - - -
-
- ) : ( - setIsAdding(true)} - icon={} - title={t('Add insight and rethink')} - aria-label={t('Add insight and rethink')} - /> - ))} -
-
- ); -} - -const InsightCardRow = styled('div')<{isUserMessage?: boolean}>` +const InsightCardRow = styled('div')<{expanded?: boolean; isUserMessage?: boolean}>` display: flex; justify-content: space-between; align-items: stretch; cursor: pointer; + background-color: ${p => (p.expanded ? p.theme.backgroundSecondary : 'transparent')}; + &:hover { background-color: ${p => p.theme.backgroundSecondary}; } @@ -536,18 +530,37 @@ const NoInsightsYet = styled('div')` color: ${p => p.theme.subText}; `; -const EmptyResultsContainer = styled('div')` +const InsightsGridContainer = styled('div')` + display: grid; + grid-template-columns: max-content 1fr; + position: relative; + z-index: 0; +`; + +const LineColumn = styled('div')` position: relative; - min-height: ${space(2)}; + width: 32px; + display: flex; + flex-direction: column; + align-items: center; `; -const InsightsContainer = styled('div')` - z-index: 0; +const CardsColumn = styled('div')` + display: flex; + flex-direction: column; +`; + +const CardsStack = styled('div')` + display: flex; + flex-direction: column; + gap: 0; `; const InsightContainer = styled(motion.div)` border-radius: ${p => p.theme.borderRadius}; overflow: hidden; + margin-bottom: 0; + background: ${p => p.theme.background}; &[data-new-insight='true'] { animation: fadeFromActive 0.8s ease-in-out; @@ -570,59 +583,98 @@ const InsightContainer = styled(motion.div)` } `; -const VerticalLineContainer = styled('div')<{isEmpty?: boolean}>` - display: grid; - grid-template-columns: 32px auto 1fr; +const VerticalLineContainer = styled('div')<{ + alignment?: 'start' | 'center'; + isEmpty?: boolean; +}>` position: relative; - z-index: 0; - min-height: ${p => (p.isEmpty ? space(4) : space(2))}; + z-index: 1; width: 100%; + display: flex; + padding: 0; + min-height: ${p => (p.isEmpty ? space(4) : 'auto')}; .rethink-button-container { - grid-column: 1 / -1; - justify-self: stretch; - align-self: center; - position: relative; - padding-right: ${space(1)}; + /* Styles are now primarily in RethinkButtonContainer itself */ } `; const VerticalLine = styled('div')` position: absolute; left: 50%; + transform: translateX(-50%); top: 0; bottom: 0; width: 2px; background-color: ${p => p.theme.subText}; - grid-column: 2 / 3; transition: background-color 0.2s ease; + z-index: 0; +`; + +const CollapseButtonWrapper = styled('div')` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + cursor: pointer; + border-radius: ${p => p.theme.borderRadius}; + + &:hover { + background-color: ${p => p.theme.backgroundSecondary}; + } `; -const RethinkButtonContainer = styled('div')` +const RethinkButtonContainer = styled('div')<{parentAlignment?: 'start' | 'center'}>` position: relative; display: flex; - justify-content: flex-end; - width: calc(100% + ${space(1)}); + justify-content: ${p => (p.parentAlignment === 'start' ? 'flex-end' : 'center')}; + align-items: center; + width: ${p => (p.parentAlignment === 'start' ? '100%' : 'max-content')}; + background: ${p => + p.parentAlignment === 'center' ? p.theme.background : 'transparent'}; + border-radius: ${p => (p.parentAlignment === 'center' ? '50%' : '0')}; + padding: ${p => (p.parentAlignment === 'center' ? space(0.25) : '0')}; + z-index: 1; + + &:has(> ${CollapseButtonWrapper}) { + padding: 0; + } `; const ContentWrapper = styled('div')``; -const MiniHeader = styled('p')` - padding-top: ${space(0.75)}; - padding-bottom: ${space(0.75)}; +const MiniHeader = styled('p')<{expanded?: boolean}>` + padding-top: ${space(0.25)}; + padding-bottom: ${space(0.25)}; padding-left: ${space(1)}; padding-right: ${space(2)}; margin: 0; flex: 1; word-break: break-word; + color: ${p => (p.expanded ? p.theme.textColor : p.theme.subText)}; `; const ContextBody = styled('div')` - padding: ${space(2)} ${space(2)} 0; - background: ${p => p.theme.background} - linear-gradient(135deg, ${p => p.theme.pink400}08, ${p => p.theme.pink400}20); + padding: ${space(2)} ${space(2)} 0 ${space(2)}; + background: ${p => p.theme.pink100}; border-radius: 0 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius}; overflow: hidden; + position: relative; + + code { + white-space: pre-wrap; + word-break: break-word; + } + + &::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 2px; + background-color: ${p => p.theme.subText}; + } `; const AnimationWrapper = styled(motion.div)` @@ -642,7 +694,7 @@ const AnimationWrapper = styled(motion.div)` `; const StyledIconChevron = styled(IconChevron)` - color: ${p => p.theme.textColor}; + color: ${p => p.theme.subText}; &:hover { color: ${p => p.theme.pink400}; } @@ -672,13 +724,14 @@ const EditInput = styled(TextArea)` `; const EditButton = styled(Button)` - color: ${p => p.theme.textColor}; + color: ${p => p.theme.subText}; &:hover { color: ${p => p.theme.pink400}; } `; const CollapseButton = styled(Button)` + pointer-events: none; &:hover { color: ${p => p.theme.textColor}; } @@ -688,27 +741,27 @@ const CollapseIconChevron = styled(IconChevron)` color: ${p => p.theme.subText}; `; -const CollapseButtonWrapper = styled('div')` - display: flex; - align-items: center; - gap: ${space(1)}; -`; - const CollapsedCount = styled('span')` color: ${p => p.theme.subText}; font-size: ${p => p.theme.fontSizeSmall}; `; -const AddButton = styled(Button)` - color: ${p => p.theme.textColor}; - &:hover { - color: ${p => p.theme.pink400}; - } - margin-right: ${space(1)}; +const AddEditContainer = styled('div')` + padding: ${space(1)}; + width: 100%; + background: ${p => p.theme.background}; + border-radius: ${p => p.theme.borderRadius}; `; const DiffContainer = styled('div')` margin-bottom: ${space(2)}; `; +const AddButton = styled(Button)` + color: ${p => p.theme.subText}; + &:hover { + color: ${p => p.theme.pink400}; + } +`; + export default AutofixInsightCards; diff --git a/static/app/components/events/autofix/autofixOutputStream.spec.tsx b/static/app/components/events/autofix/autofixOutputStream.spec.tsx index b5340beb233598..f59e6056ea293b 100644 --- a/static/app/components/events/autofix/autofixOutputStream.spec.tsx +++ b/static/app/components/events/autofix/autofixOutputStream.spec.tsx @@ -16,7 +16,7 @@ describe('AutofixOutputStream', () => { (addSuccessMessage as jest.Mock).mockClear(); (addErrorMessage as jest.Mock).mockClear(); MockApiClient.addMockResponse({ - url: '/issues/123/autofix/update/', + url: '/organizations/org-slug/issues/123/autofix/update/', method: 'POST', }); }); @@ -105,7 +105,7 @@ describe('AutofixOutputStream', () => { it('shows error message when user interruption fails', async () => { MockApiClient.addMockResponse({ - url: '/issues/123/autofix/update/', + url: '/organizations/org-slug/issues/123/autofix/update/', method: 'POST', statusCode: 500, }); diff --git a/static/app/components/events/autofix/autofixOutputStream.tsx b/static/app/components/events/autofix/autofixOutputStream.tsx index 63f4a70052f5e3..1542f332b0ccbe 100644 --- a/static/app/components/events/autofix/autofixOutputStream.tsx +++ b/static/app/components/events/autofix/autofixOutputStream.tsx @@ -17,6 +17,7 @@ import {singleLineRenderer} from 'sentry/utils/marked/marked'; import {useMutation, useQueryClient} from 'sentry/utils/queryClient'; import testableTransition from 'sentry/utils/testableTransition'; import useApi from 'sentry/utils/useApi'; +import useOrganization from 'sentry/utils/useOrganization'; function StreamContentText({stream}: {stream: string}) { const [displayedText, setDisplayedText] = useState(''); @@ -115,25 +116,30 @@ export function AutofixOutputStream({ const displayedActiveLog = useTypingAnimation({ text: activeLog, - speed: 100, + speed: 200, enabled: !!activeLog, }); + const orgSlug = useOrganization().slug; + const {mutate: send} = useMutation({ mutationFn: (params: {message: string}) => { - return api.requestPromise(`/issues/${groupId}/autofix/update/`, { - method: 'POST', - data: { - run_id: runId, - payload: { - type: 'user_message', - text: params.message, + return api.requestPromise( + `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`, + { + method: 'POST', + data: { + run_id: runId, + payload: { + type: 'user_message', + text: params.message, + }, }, - }, - }); + } + ); }, onSuccess: _ => { - queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(groupId)}); + queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(orgSlug, groupId)}); addSuccessMessage('Thanks for the input.'); }, onError: () => { @@ -237,7 +243,6 @@ const Wrapper = styled(motion.div)` flex-direction: column; align-items: flex-start; margin-bottom: ${space(1)}; - margin-right: ${space(2)}; gap: ${space(1)}; `; @@ -247,7 +252,6 @@ const ScaleContainer = styled(motion.div)` flex-direction: column; align-items: flex-start; transform-origin: top left; - padding-left: ${space(2)}; `; const shimmer = keyframes` @@ -314,7 +318,7 @@ const VerticalLine = styled('div')` width: 0; height: ${space(4)}; border-left: 2px dashed ${p => p.theme.subText}; - margin-left: 17px; + margin-left: 16px; margin-bottom: -1px; `; diff --git a/static/app/components/events/autofix/autofixRootCause.spec.tsx b/static/app/components/events/autofix/autofixRootCause.spec.tsx index 6302a591a4c20c..5a924f94b18514 100644 --- a/static/app/components/events/autofix/autofixRootCause.spec.tsx +++ b/static/app/components/events/autofix/autofixRootCause.spec.tsx @@ -9,7 +9,7 @@ describe('AutofixRootCause', function () { beforeEach(function () { mockApi = MockApiClient.addMockResponse({ - url: '/issues/1/autofix/update/', + url: '/organizations/org-slug/issues/1/autofix/update/', method: 'POST', body: {success: true}, }); @@ -114,7 +114,7 @@ describe('AutofixRootCause', function () { await waitFor( () => { expect(mockApi).toHaveBeenCalledWith( - '/issues/1/autofix/update/', + '/organizations/org-slug/issues/1/autofix/update/', expect.objectContaining({ method: 'POST', data: { diff --git a/static/app/components/events/autofix/autofixRootCause.tsx b/static/app/components/events/autofix/autofixRootCause.tsx index 5c37ab774349fe..9acb2957c8ebbe 100644 --- a/static/app/components/events/autofix/autofixRootCause.tsx +++ b/static/app/components/events/autofix/autofixRootCause.tsx @@ -29,6 +29,7 @@ import {singleLineRenderer} from 'sentry/utils/marked/marked'; import {setApiQueryData, useMutation, useQueryClient} from 'sentry/utils/queryClient'; import testableTransition from 'sentry/utils/testableTransition'; import useApi from 'sentry/utils/useApi'; +import useOrganization from 'sentry/utils/useOrganization'; import {Divider} from 'sentry/views/issueDetails/divider'; import AutofixHighlightPopup from './autofixHighlightPopup'; @@ -71,6 +72,7 @@ const cardAnimationProps: AnimationProps = { function useSelectCause({groupId, runId}: {groupId: string; runId: string}) { const api = useApi(); const queryClient = useQueryClient(); + const orgSlug = useOrganization().slug; return useMutation({ mutationFn: ( @@ -83,31 +85,34 @@ function useSelectCause({groupId, runId}: {groupId: string; runId: string}) { customRootCause: string; } ) => { - return api.requestPromise(`/issues/${groupId}/autofix/update/`, { - method: 'POST', - data: - 'customRootCause' in params - ? { - run_id: runId, - payload: { - type: 'select_root_cause', - custom_root_cause: params.customRootCause, - }, - } - : { - run_id: runId, - payload: { - type: 'select_root_cause', - cause_id: params.causeId, - instruction: params.instruction, + return api.requestPromise( + `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`, + { + method: 'POST', + data: + 'customRootCause' in params + ? { + run_id: runId, + payload: { + type: 'select_root_cause', + custom_root_cause: params.customRootCause, + }, + } + : { + run_id: runId, + payload: { + type: 'select_root_cause', + cause_id: params.causeId, + instruction: params.instruction, + }, }, - }, - }); + } + ); }, onSuccess: (_, params) => { setApiQueryData( queryClient, - makeAutofixQueryKey(groupId), + makeAutofixQueryKey(orgSlug, groupId), data => { if (!data?.autofix) { return data; diff --git a/static/app/components/events/autofix/autofixSetupWriteAccessModal.spec.tsx b/static/app/components/events/autofix/autofixSetupWriteAccessModal.spec.tsx index f9b1c173eafc70..35d541cbb10d6d 100644 --- a/static/app/components/events/autofix/autofixSetupWriteAccessModal.spec.tsx +++ b/static/app/components/events/autofix/autofixSetupWriteAccessModal.spec.tsx @@ -8,7 +8,7 @@ import {AutofixSetupWriteAccessModal} from 'sentry/components/events/autofix/aut describe('AutofixSetupWriteAccessModal', function () { it('displays help text when repos are not all installed', async function () { MockApiClient.addMockResponse({ - url: '/issues/1/autofix/setup/?check_write_access=true', + url: '/organizations/org-slug/issues/1/autofix/setup/?check_write_access=true', body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: false, @@ -59,7 +59,7 @@ describe('AutofixSetupWriteAccessModal', function () { it('displays success text when installed repos for github app text', async function () { MockApiClient.addMockResponse({ - url: '/issues/1/autofix/setup/?check_write_access=true', + url: '/organizations/org-slug/issues/1/autofix/setup/?check_write_access=true', body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: false, diff --git a/static/app/components/events/autofix/autofixSolution.spec.tsx b/static/app/components/events/autofix/autofixSolution.spec.tsx index dbdb633a6d128a..648661cfc57b0e 100644 --- a/static/app/components/events/autofix/autofixSolution.spec.tsx +++ b/static/app/components/events/autofix/autofixSolution.spec.tsx @@ -35,7 +35,7 @@ describe('AutofixSolution', () => { beforeEach(() => { MockApiClient.clearMockResponses(); MockApiClient.addMockResponse({ - url: '/issues/123/autofix/update/', + url: '/organizations/org-slug/issues/123/autofix/update/', method: 'POST', }); jest.mocked(useAutofixRepos).mockReset(); @@ -221,7 +221,7 @@ describe('AutofixSolution', () => { it('passes the solution array when Code It Up button is clicked', async () => { // Mock the API directly before the test const mockApi = MockApiClient.addMockResponse({ - url: '/issues/123/autofix/update/', + url: '/organizations/org-slug/issues/123/autofix/update/', method: 'POST', }); @@ -253,7 +253,7 @@ describe('AutofixSolution', () => { // Verify payload expect(mockApi).toHaveBeenCalledWith( - '/issues/123/autofix/update/', + '/organizations/org-slug/issues/123/autofix/update/', expect.objectContaining({ data: { run_id: 'run-123', @@ -275,7 +275,7 @@ describe('AutofixSolution', () => { it('allows toggling solution items active/inactive', async () => { // Mock the API directly before the test const mockApi = MockApiClient.addMockResponse({ - url: '/issues/123/autofix/update/', + url: '/organizations/org-slug/issues/123/autofix/update/', method: 'POST', }); @@ -318,7 +318,7 @@ describe('AutofixSolution', () => { // Verify payload expect(mockApi).toHaveBeenCalledWith( - '/issues/123/autofix/update/', + '/organizations/org-slug/issues/123/autofix/update/', expect.objectContaining({ data: { run_id: 'run-123', @@ -340,7 +340,7 @@ describe('AutofixSolution', () => { it('allows adding custom instructions', async () => { // Mock the API directly before the test const mockApi = MockApiClient.addMockResponse({ - url: '/issues/123/autofix/update/', + url: '/organizations/org-slug/issues/123/autofix/update/', method: 'POST', }); @@ -386,7 +386,7 @@ describe('AutofixSolution', () => { // Verify payload expect(mockApi).toHaveBeenCalledWith( - '/issues/123/autofix/update/', + '/organizations/org-slug/issues/123/autofix/update/', expect.objectContaining({ data: { run_id: 'run-123', @@ -510,7 +510,7 @@ describe('AutofixSolution', () => { // Mock the API directly before the test const mockApi = MockApiClient.addMockResponse({ - url: '/issues/123/autofix/update/', + url: '/organizations/org-slug/issues/123/autofix/update/', method: 'POST', }); @@ -541,7 +541,7 @@ describe('AutofixSolution', () => { // Verify payload expect(mockApi).toHaveBeenCalledWith( - '/issues/123/autofix/update/', + '/organizations/org-slug/issues/123/autofix/update/', expect.objectContaining({ data: { run_id: 'run-123', diff --git a/static/app/components/events/autofix/autofixSolution.tsx b/static/app/components/events/autofix/autofixSolution.tsx index 9c0a9274d317a8..fbecd757ab05c5 100644 --- a/static/app/components/events/autofix/autofixSolution.tsx +++ b/static/app/components/events/autofix/autofixSolution.tsx @@ -32,6 +32,7 @@ import {singleLineRenderer} from 'sentry/utils/marked/marked'; import {setApiQueryData, useMutation, useQueryClient} from 'sentry/utils/queryClient'; import testableTransition from 'sentry/utils/testableTransition'; import useApi from 'sentry/utils/useApi'; +import useOrganization from 'sentry/utils/useOrganization'; import {Divider} from 'sentry/views/issueDetails/divider'; import AutofixHighlightPopup from './autofixHighlightPopup'; @@ -39,28 +40,32 @@ import AutofixHighlightPopup from './autofixHighlightPopup'; function useSelectSolution({groupId, runId}: {groupId: string; runId: string}) { const api = useApi(); const queryClient = useQueryClient(); + const orgSlug = useOrganization().slug; return useMutation({ mutationFn: (params: { mode: 'all' | 'fix' | 'test'; solution: AutofixSolutionTimelineEvent[]; }) => { - return api.requestPromise(`/issues/${groupId}/autofix/update/`, { - method: 'POST', - data: { - run_id: runId, - payload: { - type: 'select_solution', - mode: params.mode, - solution: params.solution, + return api.requestPromise( + `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`, + { + method: 'POST', + data: { + run_id: runId, + payload: { + type: 'select_solution', + mode: params.mode, + solution: params.solution, + }, }, - }, - }); + } + ); }, onSuccess: (_, params) => { setApiQueryData( queryClient, - makeAutofixQueryKey(groupId), + makeAutofixQueryKey(orgSlug, groupId), data => { if (!data?.autofix) { return data; diff --git a/static/app/components/events/autofix/autofixSteps.tsx b/static/app/components/events/autofix/autofixSteps.tsx index 499867c751c030..5b17c49636617d 100644 --- a/static/app/components/events/autofix/autofixSteps.tsx +++ b/static/app/components/events/autofix/autofixSteps.tsx @@ -64,7 +64,6 @@ function Step({ hasStepBelow, hasStepAbove, hasErroredStepBefore, - shouldCollapseByDefault, previousDefaultStepIndex, previousInsightCount, feedback, @@ -73,7 +72,7 @@ function Step({ isChangesFirstAppearance, }: StepProps) { return ( - + @@ -91,7 +90,6 @@ function Step({ stepIndex={step.index} groupId={groupId} runId={runId} - shouldCollapseByDefault={shouldCollapseByDefault} /> )} {step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS && ( diff --git a/static/app/components/events/autofix/autofixThumbsUpDownButtons.tsx b/static/app/components/events/autofix/autofixThumbsUpDownButtons.tsx index 48d621ce0c9200..d3f86669c4d5a3 100644 --- a/static/app/components/events/autofix/autofixThumbsUpDownButtons.tsx +++ b/static/app/components/events/autofix/autofixThumbsUpDownButtons.tsx @@ -11,10 +11,12 @@ import {t} from 'sentry/locale'; import {useMutation, useQueryClient} from 'sentry/utils/queryClient'; import useApi from 'sentry/utils/useApi'; import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; +import useOrganization from 'sentry/utils/useOrganization'; function useUpdateAutofixFeedback({groupId, runId}: {groupId: string; runId: string}) { const api = useApi(); const queryClient = useQueryClient(); + const orgSlug = useOrganization().slug; return useMutation({ mutationFn: (params: { @@ -24,45 +26,51 @@ function useUpdateAutofixFeedback({groupId, runId}: {groupId: string; runId: str | 'solution_thumbs_up' | 'solution_thumbs_down'; }) => { - return api.requestPromise(`/issues/${groupId}/autofix/update/`, { - method: 'POST', - data: { - run_id: runId, - payload: { - type: 'feedback', - action: params.action, + return api.requestPromise( + `/organizations/${orgSlug}/issues/${groupId}/autofix/update/`, + { + method: 'POST', + data: { + run_id: runId, + payload: { + type: 'feedback', + action: params.action, + }, }, - }, - }); + } + ); }, onMutate: params => { - queryClient.setQueryData(makeAutofixQueryKey(groupId), (data: AutofixResponse) => { - if (!data?.autofix) { - return data; - } + queryClient.setQueryData( + makeAutofixQueryKey(orgSlug, groupId), + (data: AutofixResponse) => { + if (!data?.autofix) { + return data; + } - return { - ...data, - autofix: { - ...data.autofix, - feedback: params.action.includes('solution') - ? { - ...data.autofix.feedback, - solution_thumbs_up: params.action === 'solution_thumbs_up', - solution_thumbs_down: params.action === 'solution_thumbs_down', - } - : { - ...data.autofix.feedback, - root_cause_thumbs_up: params.action === 'root_cause_thumbs_up', - root_cause_thumbs_down: params.action === 'root_cause_thumbs_down', - }, - }, - }; - }); + return { + ...data, + autofix: { + ...data.autofix, + feedback: params.action.includes('solution') + ? { + ...data.autofix.feedback, + solution_thumbs_up: params.action === 'solution_thumbs_up', + solution_thumbs_down: params.action === 'solution_thumbs_down', + } + : { + ...data.autofix.feedback, + root_cause_thumbs_up: params.action === 'root_cause_thumbs_up', + root_cause_thumbs_down: params.action === 'root_cause_thumbs_down', + }, + }, + }; + } + ); }, onSuccess: () => { queryClient.invalidateQueries({ - queryKey: makeAutofixQueryKey(groupId), + queryKey: makeAutofixQueryKey(orgSlug, groupId), }); }, onError: () => { diff --git a/static/app/components/events/autofix/useAutofix.tsx b/static/app/components/events/autofix/useAutofix.tsx index e88c3adc25507c..8df14cefddfcf2 100644 --- a/static/app/components/events/autofix/useAutofix.tsx +++ b/static/app/components/events/autofix/useAutofix.tsx @@ -16,6 +16,7 @@ import { } from 'sentry/utils/queryClient'; import type RequestError from 'sentry/utils/requestError/requestError'; import useApi from 'sentry/utils/useApi'; +import useOrganization from 'sentry/utils/useOrganization'; export type AutofixResponse = { autofix: AutofixData | null; @@ -23,8 +24,8 @@ export type AutofixResponse = { const POLL_INTERVAL = 500; -export const makeAutofixQueryKey = (groupId: string): ApiQueryKey => [ - `/issues/${groupId}/autofix/`, +export const makeAutofixQueryKey = (orgSlug: string, groupId: string): ApiQueryKey => [ + `/organizations/${orgSlug}/issues/${groupId}/autofix/`, ]; const makeInitialAutofixData = (): AutofixResponse => ({ @@ -154,11 +155,16 @@ export const useAutofixRepos = (groupId: string) => { }; export const useAutofixData = ({groupId}: {groupId: string}) => { - const {data, isPending} = useApiQuery(makeAutofixQueryKey(groupId), { - staleTime: Infinity, - enabled: false, - notifyOnChangeProps: ['data'], - }); + const orgSlug = useOrganization().slug; + + const {data, isPending} = useApiQuery( + makeAutofixQueryKey(orgSlug, groupId), + { + staleTime: Infinity, + enabled: false, + notifyOnChangeProps: ['data'], + } + ); return {data: data?.autofix ?? null, isPending}; }; @@ -173,27 +179,31 @@ export const useAiAutofix = ( ) => { const api = useApi(); const queryClient = useQueryClient(); + const orgSlug = useOrganization().slug; const [isReset, setIsReset] = useState(false); const [currentRunId, setCurrentRunId] = useState(null); const [waitingForNextRun, setWaitingForNextRun] = useState(false); - const {data: apiData} = useApiQuery(makeAutofixQueryKey(group.id), { - staleTime: 0, - retry: false, - refetchInterval: query => { - if ( - isPolling( - query.state.data?.[0]?.autofix || null, - !!currentRunId || waitingForNextRun, - options.isSidebar - ) - ) { - return options.pollInterval ?? POLL_INTERVAL; - } - return false; - }, - } as UseApiQueryOptions); + const {data: apiData} = useApiQuery( + makeAutofixQueryKey(orgSlug, group.id), + { + staleTime: 0, + retry: false, + refetchInterval: query => { + if ( + isPolling( + query.state.data?.[0]?.autofix || null, + !!currentRunId || waitingForNextRun, + options.isSidebar + ) + ) { + return options.pollInterval ?? POLL_INTERVAL; + } + return false; + }, + } as UseApiQueryOptions + ); const triggerAutofix = useCallback( async (instruction: string) => { @@ -202,30 +212,33 @@ export const useAiAutofix = ( setWaitingForNextRun(true); setApiQueryData( queryClient, - makeAutofixQueryKey(group.id), + makeAutofixQueryKey(orgSlug, group.id), makeInitialAutofixData() ); try { - const response = await api.requestPromise(`/issues/${group.id}/autofix/`, { - method: 'POST', - data: { - event_id: event.id, - instruction, - }, - }); + const response = await api.requestPromise( + `/organizations/${orgSlug}/issues/${group.id}/autofix/`, + { + method: 'POST', + data: { + event_id: event.id, + instruction, + }, + } + ); setCurrentRunId(response.run_id ?? null); - queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(group.id)}); + queryClient.invalidateQueries({queryKey: makeAutofixQueryKey(orgSlug, group.id)}); } catch (e) { setWaitingForNextRun(false); setApiQueryData( queryClient, - makeAutofixQueryKey(group.id), + makeAutofixQueryKey(orgSlug, group.id), makeErrorAutofixData(e?.responseJSON?.detail ?? 'An error occurred') ); } }, - [queryClient, group.id, api, event.id] + [queryClient, group.id, api, event.id, orgSlug] ); const reset = useCallback(() => { diff --git a/static/app/components/events/autofix/useAutofixSetup.tsx b/static/app/components/events/autofix/useAutofixSetup.tsx index 633bc5965e90f7..771bde267a93a4 100644 --- a/static/app/components/events/autofix/useAutofixSetup.tsx +++ b/static/app/components/events/autofix/useAutofixSetup.tsx @@ -5,6 +5,7 @@ import { type UseApiQueryOptions, } from 'sentry/utils/queryClient'; import type RequestError from 'sentry/utils/requestError/requestError'; +import useOrganization from 'sentry/utils/useOrganization'; interface AutofixSetupRepoDefinition extends AutofixRepoDefinition { ok: boolean; @@ -26,11 +27,12 @@ export interface AutofixSetupResponse { } function makeAutofixSetupQueryKey( + orgSlug: string, groupId: string, checkWriteAccess?: boolean ): ApiQueryKey { return [ - `/issues/${groupId}/autofix/setup/${checkWriteAccess ? '?check_write_access=true' : ''}`, + `/organizations/${orgSlug}/issues/${groupId}/autofix/setup/${checkWriteAccess ? '?check_write_access=true' : ''}`, ]; } @@ -38,8 +40,10 @@ export function useAutofixSetup( {groupId, checkWriteAccess}: {groupId: string; checkWriteAccess?: boolean}, options: Omit, 'staleTime'> = {} ) { + const orgSlug = useOrganization().slug; + const queryData = useApiQuery( - makeAutofixSetupQueryKey(groupId, checkWriteAccess), + makeAutofixSetupQueryKey(orgSlug, groupId, checkWriteAccess), { enabled: Boolean(groupId), staleTime: 0, diff --git a/static/app/components/events/eventStatisticalDetector/eventRegressionSummary.tsx b/static/app/components/events/eventStatisticalDetector/eventRegressionSummary.tsx index 91ddac1d1675cc..2ea56683f1f1bc 100644 --- a/static/app/components/events/eventStatisticalDetector/eventRegressionSummary.tsx +++ b/static/app/components/events/eventStatisticalDetector/eventRegressionSummary.tsx @@ -55,7 +55,6 @@ export function getKeyValueListData( } switch (issueType) { - case IssueType.PERFORMANCE_DURATION_REGRESSION: case IssueType.PERFORMANCE_ENDPOINT_REGRESSION: { const target = transactionSummaryRouteWithQuery({ organization, @@ -93,7 +92,6 @@ export function getKeyValueListData( }, ]; } - case IssueType.PROFILE_FUNCTION_REGRESSION_EXPERIMENTAL: case IssueType.PROFILE_FUNCTION_REGRESSION: { return [ { diff --git a/static/app/components/events/eventStatisticalDetector/eventThroughput.tsx b/static/app/components/events/eventStatisticalDetector/eventThroughput.tsx index 8eb64bd6d74b9e..467590d4f1a48f 100644 --- a/static/app/components/events/eventStatisticalDetector/eventThroughput.tsx +++ b/static/app/components/events/eventStatisticalDetector/eventThroughput.tsx @@ -348,12 +348,10 @@ function useThroughputStats({datetime, event, group}: UseThroughputStatsOptions) function isValidRegressionEvent(event: Event, group: Group): boolean { switch (group.issueType) { - case IssueType.PERFORMANCE_DURATION_REGRESSION: case IssueType.PERFORMANCE_ENDPOINT_REGRESSION: { const evidenceData = event.occurrence?.evidenceData; return defined(evidenceData?.transaction) && defined(evidenceData?.breakpoint); } - case IssueType.PROFILE_FUNCTION_REGRESSION_EXPERIMENTAL: case IssueType.PROFILE_FUNCTION_REGRESSION: { const evidenceData = event.occurrence?.evidenceData; return defined(evidenceData?.fingerprint) && defined(evidenceData?.breakpoint); @@ -365,10 +363,8 @@ function isValidRegressionEvent(event: Event, group: Group): boolean { function getStatsType(group: Group): 'transactions' | 'functions' | null { switch (group.issueType) { - case IssueType.PERFORMANCE_DURATION_REGRESSION: case IssueType.PERFORMANCE_ENDPOINT_REGRESSION: return 'transactions'; - case IssueType.PROFILE_FUNCTION_REGRESSION_EXPERIMENTAL: case IssueType.PROFILE_FUNCTION_REGRESSION: return 'functions'; default: diff --git a/static/app/components/events/featureFlags/featureFlagDrawer.tsx b/static/app/components/events/featureFlags/featureFlagDrawer.tsx index 4df7f950fe0321..329cc5ecc7b84a 100644 --- a/static/app/components/events/featureFlags/featureFlagDrawer.tsx +++ b/static/app/components/events/featureFlags/featureFlagDrawer.tsx @@ -157,18 +157,12 @@ export const CardContainer = styled('div')<{numCols: number}>` :not(:last-child) { border-right: 1.5px solid ${p => p.theme.innerBorder}; padding-right: ${space(2)}; - div { - padding-left: ${space(0.5)}; - } } :not(:first-child) { border-left: 1.5px solid ${p => p.theme.innerBorder}; padding-left: ${space(2)}; padding-right: 0; margin-left: -1px; - div { - padding-left: ${space(0.5)}; - } } } `; diff --git a/static/app/components/events/featureFlags/featureFlagInlineCTA.tsx b/static/app/components/events/featureFlags/featureFlagInlineCTA.tsx index 9df4f3dd299393..43c876582d5043 100644 --- a/static/app/components/events/featureFlags/featureFlagInlineCTA.tsx +++ b/static/app/components/events/featureFlags/featureFlagInlineCTA.tsx @@ -52,6 +52,12 @@ export function FeatureFlagCTAContent({ priority="default" href="https://docs.sentry.io/product/explore/feature-flags/" external + onClick={() => { + trackAnalytics('flags.cta_read_more_clicked', { + organization, + surface: analyticsArea, + }); + }} > {t('Read More')} diff --git a/static/app/components/events/interfaces/performance/spanEvidenceKeyValueList.tsx b/static/app/components/events/interfaces/performance/spanEvidenceKeyValueList.tsx index b2c7f185f261c7..283b310d5d2a30 100644 --- a/static/app/components/events/interfaces/performance/spanEvidenceKeyValueList.tsx +++ b/static/app/components/events/interfaces/performance/spanEvidenceKeyValueList.tsx @@ -304,16 +304,13 @@ const PREVIEW_COMPONENTS: Partial< [IssueType.PERFORMANCE_CONSECUTIVE_HTTP]: ConsecutiveHTTPSpanEvidence, [IssueType.PERFORMANCE_LARGE_HTTP_PAYLOAD]: LargeHTTPPayloadSpanEvidence, [IssueType.PERFORMANCE_HTTP_OVERHEAD]: HTTPOverheadSpanEvidence, - [IssueType.PERFORMANCE_DURATION_REGRESSION]: RegressionEvidence, [IssueType.PERFORMANCE_ENDPOINT_REGRESSION]: RegressionEvidence, [IssueType.PROFILE_FILE_IO_MAIN_THREAD]: MainThreadFunctionEvidence, [IssueType.PROFILE_IMAGE_DECODE_MAIN_THREAD]: MainThreadFunctionEvidence, [IssueType.PROFILE_JSON_DECODE_MAIN_THREAD]: MainThreadFunctionEvidence, [IssueType.PROFILE_REGEX_MAIN_THREAD]: MainThreadFunctionEvidence, [IssueType.PROFILE_FRAME_DROP]: MainThreadFunctionEvidence, - [IssueType.PROFILE_FRAME_DROP_EXPERIMENTAL]: MainThreadFunctionEvidence, [IssueType.PROFILE_FUNCTION_REGRESSION]: RegressionEvidence, - [IssueType.PROFILE_FUNCTION_REGRESSION_EXPERIMENTAL]: RegressionEvidence, }; export function SpanEvidenceKeyValueList({ diff --git a/static/app/components/group/groupSummary.spec.tsx b/static/app/components/group/groupSummary.spec.tsx index ef23572d4b49a8..0c59302ed21ce8 100644 --- a/static/app/components/group/groupSummary.spec.tsx +++ b/static/app/components/group/groupSummary.spec.tsx @@ -54,7 +54,7 @@ describe('GroupSummary', function () { MockApiClient.clearMockResponses(); MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/setup/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, method: 'GET', body: AutofixSetupFixture({ setupAcknowledgement: { diff --git a/static/app/components/group/groupSummary.tsx b/static/app/components/group/groupSummary.tsx index ec0c0845e518ad..2ffdecd8be18b5 100644 --- a/static/app/components/group/groupSummary.tsx +++ b/static/app/components/group/groupSummary.tsx @@ -90,7 +90,7 @@ export function useGroupSummary( const refresh = () => { queryClient.invalidateQueries({ - queryKey: [`/organizations/${organization.slug}/issues/${group.id}/summarize/`], + queryKey: makeGroupSummaryQueryKey(organization.slug, group.id), exact: false, }); refetch(); @@ -140,10 +140,17 @@ export function GroupSummary({ useEffect(() => { if (isFixable && !isPending && aiConfig.hasAutofix) { queryClient.invalidateQueries({ - queryKey: makeAutofixQueryKey(group.id), + queryKey: makeAutofixQueryKey(organization.slug, group.id), }); } - }, [isFixable, isPending, aiConfig.hasAutofix, group.id, queryClient]); + }, [ + isFixable, + isPending, + aiConfig.hasAutofix, + group.id, + queryClient, + organization.slug, + ]); const eventDetailsItems = [ { diff --git a/static/app/components/group/groupSummaryWithAutofix.tsx b/static/app/components/group/groupSummaryWithAutofix.tsx index 340b57bcdb4b33..ad3a2c56bcc88b 100644 --- a/static/app/components/group/groupSummaryWithAutofix.tsx +++ b/static/app/components/group/groupSummaryWithAutofix.tsx @@ -164,7 +164,13 @@ function AutofixSummary({ organization, group_id: group.id, }); - navigate(seerLink); + navigate({ + ...seerLink, + query: { + ...seerLink.query, + scrollTo: 'root_cause', + }, + }); }, copyTitle: t('Copy root cause as Markdown'), copyText: rootCauseCopyText, @@ -183,7 +189,13 @@ function AutofixSummary({ organization, group_id: group.id, }); - navigate(seerLink); + navigate({ + ...seerLink, + query: { + ...seerLink.query, + scrollTo: 'solution', + }, + }); }, copyTitle: t('Copy solution as Markdown'), copyText: solutionCopyText, @@ -204,7 +216,13 @@ function AutofixSummary({ organization, group_id: group.id, }); - navigate(seerLink); + navigate({ + ...seerLink, + query: { + ...seerLink.query, + scrollTo: 'code_changes', + }, + }); }, }, ] diff --git a/static/app/components/lazyLoad.tsx b/static/app/components/lazyLoad.tsx index 84f0a41ab27e01..2bbdf55c86232f 100644 --- a/static/app/components/lazyLoad.tsx +++ b/static/app/components/lazyLoad.tsx @@ -76,8 +76,20 @@ class ErrorBoundary extends Component<{children: React.ReactNode}, ErrorBoundary if (isWebpackChunkLoadingError(error)) { scope.setFingerprint(['webpack', 'error loading chunk']); } - scope.setExtra('errorInfo', errorInfo); - Sentry.captureException(error); + try { + // Based on https://github.com/getsentry/sentry-javascript/blob/6f4ad562c469f546f1098136b65583309d03487b/packages/react/src/errorboundary.tsx#L75-L85 + const errorBoundaryError = new Error(error.message); + errorBoundaryError.name = `React ErrorBoundary ${errorBoundaryError.name}`; + errorBoundaryError.stack = errorInfo.componentStack!; + + // This will mutate `error` and get captured to Sentry in `RouteError` + error.cause = errorBoundaryError; + } catch { + // Some browsers won't let you write to Error instance + scope.setExtra('errorInfo', errorInfo); + } finally { + Sentry.captureException(error); + } }); // eslint-disable-next-line no-console diff --git a/static/app/components/organizations/pageFilterBar.tsx b/static/app/components/organizations/pageFilterBar.tsx index ea17a06ea964bf..ebe31a47ed3376 100644 --- a/static/app/components/organizations/pageFilterBar.tsx +++ b/static/app/components/organizations/pageFilterBar.tsx @@ -177,6 +177,10 @@ except in mobile */ display: none; } + & > div > button:focus-visible { + z-index: 3; + } + & > div:first-child > button { border-top-right-radius: 0; border-bottom-right-radius: 0; diff --git a/static/app/components/performance/spanSearchQueryBuilder.tsx b/static/app/components/performance/spanSearchQueryBuilder.tsx index dacce27da4c0fc..304c3e0228fe0b 100644 --- a/static/app/components/performance/spanSearchQueryBuilder.tsx +++ b/static/app/components/performance/spanSearchQueryBuilder.tsx @@ -1,7 +1,6 @@ import {useCallback, useMemo} from 'react'; import {fetchSpanFieldValues} from 'sentry/actionCreators/tags'; -import {getHasTag} from 'sentry/components/events/searchBar'; import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse'; import {SearchQueryBuilder} from 'sentry/components/searchQueryBuilder'; import type {CallbackSearchState} from 'sentry/components/searchQueryBuilder/types'; @@ -20,6 +19,11 @@ import { import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; +import { + TraceItemSearchQueryBuilder, + useSearchQueryBuilderProps, +} from 'sentry/views/explore/components/traceItemSearchQueryBuilder'; +import {TraceItemDataset} from 'sentry/views/explore/types'; import {SPANS_FILTER_KEY_SECTIONS} from 'sentry/views/insights/constants'; import { useSpanFieldCustomTags, @@ -181,128 +185,24 @@ export interface EAPSpanSearchQueryBuilderProps extends SpanSearchQueryBuilderPr supportedAggregates?: AggregationKey[]; } -export function useEAPSpanSearchQueryBuilderProps({ - initialQuery, - placeholder, - onSearch, - onBlur, - searchSource, - numberTags, - stringTags, - getFilterTokenWarning, - supportedAggregates = [], - projects, - portalTarget, - onChange, -}: EAPSpanSearchQueryBuilderProps) { - const api = useApi(); - const organization = useOrganization(); - const {selection} = usePageFilters(); - - const placeholderText = placeholder ?? t('Search for spans, users, tags, and more'); - - const functionTags = useMemo(() => { - return getFunctionTags(supportedAggregates); - }, [supportedAggregates]); - - const filterTags: TagCollection = useMemo(() => { - const tags: TagCollection = {...functionTags, ...numberTags, ...stringTags}; - tags.has = getHasTag({...stringTags}); // TODO: add number tags - return tags; - }, [numberTags, stringTags, functionTags]); - - const filterKeySections = useMemo(() => { - const predefined = new Set( - SPANS_FILTER_KEY_SECTIONS.flatMap(section => section.children) - ); - return [ - ...SPANS_FILTER_KEY_SECTIONS.map(section => { - return { - ...section, - children: section.children.filter(key => stringTags.hasOwnProperty(key)), - }; - }), - { - value: 'custom_fields', - label: 'Custom Tags', - children: Object.keys(stringTags).filter(key => !predefined.has(key)), - }, - ]; - }, [stringTags]); - - const getSpanFilterTagValues = useCallback( - async (tag: Tag, queryString: string) => { - if (isAggregateField(tag.key) || numberTags.hasOwnProperty(tag.key)) { - // We can't really auto suggest values for aggregate fields - // or measurements, so we simply don't - return Promise.resolve([]); - } - - try { - const results = await fetchSpanFieldValues({ - api, - orgSlug: organization.slug, - fieldKey: tag.key, - search: queryString, - projectIds: (projects ?? selection.projects).map(String), - endpointParams: normalizeDateTimeParams(selection.datetime), - dataset: 'spans', - }); - return results.filter(({name}) => defined(name)).map(({name}) => name); - } catch (e) { - throw new Error(`Unable to fetch event field values: ${e}`); - } - }, - [api, organization.slug, selection.projects, projects, selection.datetime, numberTags] - ); - - return { - placeholder: placeholderText, - filterKeys: filterTags, - initialQuery, - fieldDefinitionGetter: getSpanFieldDefinitionFunction(filterTags), - onSearch, - onBlur, - getFilterTokenWarning, - searchSource, - filterKeySections, - getTagValues: getSpanFilterTagValues, - disallowUnsupportedFilters: true, - recentSearches: SavedSearchType.SPAN, - showUnsubmittedIndicator: true, - portalTarget, - onChange, - }; -} - -export function EAPSpanSearchQueryBuilder({ - initialQuery, - placeholder, - onSearch, - onBlur, - searchSource, - numberTags, - stringTags, - getFilterTokenWarning, - supportedAggregates = [], - projects, - portalTarget, - onChange, -}: EAPSpanSearchQueryBuilderProps) { - const searchQueryBuilderProps = useEAPSpanSearchQueryBuilderProps({ - initialQuery, - placeholder, - onSearch, - onBlur, - searchSource, - numberTags, - stringTags, - getFilterTokenWarning, - supportedAggregates, - projects, - portalTarget, - onChange, +export function useEAPSpanSearchQueryBuilderProps(props: EAPSpanSearchQueryBuilderProps) { + const {numberTags, stringTags, ...rest} = props; + return useSearchQueryBuilderProps({ + itemType: TraceItemDataset.SPANS, + numberAttributes: numberTags, + stringAttributes: stringTags, + ...rest, }); +} - return ; +export function EAPSpanSearchQueryBuilder(props: EAPSpanSearchQueryBuilderProps) { + const {numberTags, stringTags, ...rest} = props; + return ( + + ); } diff --git a/static/app/components/sidebar/help.tsx b/static/app/components/sidebar/help.tsx index 792e5aed81db1e..088a5a445fa58e 100644 --- a/static/app/components/sidebar/help.tsx +++ b/static/app/components/sidebar/help.tsx @@ -110,7 +110,7 @@ function SidebarHelp({orientation, collapsed, hidePanel, organization}: Props) { ); }} > - {t('Try New Navigation')} {t('Alpha')} + {t('Try New Navigation')} {t('Beta')} )} {organization?.features?.includes('chonk-ui') ? ( diff --git a/static/app/components/version.tsx b/static/app/components/version.tsx index 6f7c48faa47f3f..acc81cb3461720 100644 --- a/static/app/components/version.tsx +++ b/static/app/components/version.tsx @@ -8,7 +8,10 @@ import Link from 'sentry/components/links/link'; import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import {formatVersion} from 'sentry/utils/versions/formatVersion'; -import {makeReleasesPathname} from 'sentry/views/releases/utils/pathnames'; +import { + makeReleaseDrawerPathname, + makeReleasesPathname, +} from 'sentry/views/releases/utils/pathnames'; type Props = { /** @@ -75,13 +78,21 @@ function Version({ const renderVersion = () => { if (anchor && organization?.slug) { const props = { - to: { - pathname: makeReleasesPathname({ - path: `/${encodeURIComponent(version)}/`, - organization, - }), - query: releaseDetailProjectId ? {project: releaseDetailProjectId} : undefined, - }, + to: organization.features.includes('release-bubbles-ui') + ? makeReleaseDrawerPathname({ + location, + release: version, + projectId: releaseDetailProjectId, + }) + : { + pathname: makeReleasesPathname({ + path: `/${encodeURIComponent(version)}/`, + organization, + }), + query: releaseDetailProjectId + ? {project: releaseDetailProjectId} + : undefined, + }, className, }; if (preservePageFilters) { @@ -167,7 +178,10 @@ const truncateStyles = css` text-overflow: ellipsis; `; -const VersionText = styled('span')<{shouldWrapText?: boolean; truncate?: boolean}>` +const VersionText = styled('span')<{ + shouldWrapText?: boolean; + truncate?: boolean; +}>` ${p => p.truncate && truncateStyles} white-space: ${p => (p.shouldWrapText ? 'normal' : 'nowrap')}; `; diff --git a/static/app/types/group.tsx b/static/app/types/group.tsx index c30b1f29454b43..00b9cf2859bce5 100644 --- a/static/app/types/group.tsx +++ b/static/app/types/group.tsx @@ -117,7 +117,6 @@ export enum IssueType { PERFORMANCE_UNCOMPRESSED_ASSET = 'performance_uncompressed_assets', PERFORMANCE_LARGE_HTTP_PAYLOAD = 'performance_large_http_payload', PERFORMANCE_HTTP_OVERHEAD = 'performance_http_overhead', - PERFORMANCE_DURATION_REGRESSION = 'performance_duration_regression', PERFORMANCE_ENDPOINT_REGRESSION = 'performance_p95_endpoint_regression', // Profile @@ -126,9 +125,7 @@ export enum IssueType { PROFILE_JSON_DECODE_MAIN_THREAD = 'profile_json_decode_main_thread', PROFILE_REGEX_MAIN_THREAD = 'profile_regex_main_thread', PROFILE_FRAME_DROP = 'profile_frame_drop', - PROFILE_FRAME_DROP_EXPERIMENTAL = 'profile_frame_drop_experimental', PROFILE_FUNCTION_REGRESSION = 'profile_function_regression', - PROFILE_FUNCTION_REGRESSION_EXPERIMENTAL = 'profile_function_regression_exp', // Replay REPLAY_RAGE_CLICK = 'replay_click_rage', @@ -145,13 +142,7 @@ export enum IssueType { } // Update this if adding an issue type that you don't want to show up in search! -export const VISIBLE_ISSUE_TYPES = Object.values(IssueType).filter( - type => - ![ - IssueType.PROFILE_FRAME_DROP_EXPERIMENTAL, - IssueType.PROFILE_FUNCTION_REGRESSION_EXPERIMENTAL, - ].includes(type) -); +export const VISIBLE_ISSUE_TYPES = Object.values(IssueType); export enum IssueTitle { ERROR = 'Error', @@ -168,7 +159,6 @@ export enum IssueTitle { PERFORMANCE_UNCOMPRESSED_ASSET = 'Uncompressed Asset', PERFORMANCE_LARGE_HTTP_PAYLOAD = 'Large HTTP payload', PERFORMANCE_HTTP_OVERHEAD = 'HTTP/1.1 Overhead', - PERFORMANCE_DURATION_REGRESSION = 'Duration Regression', PERFORMANCE_ENDPOINT_REGRESSION = 'Endpoint Regression', // Profile @@ -178,7 +168,6 @@ export enum IssueTitle { PROFILE_REGEX_MAIN_THREAD = 'Regex on Main Thread', PROFILE_FRAME_DROP = 'Frame Drop', PROFILE_FUNCTION_REGRESSION = 'Function Regression', - PROFILE_FUNCTION_REGRESSION_EXPERIMENTAL = 'Function Duration Regression (Experimental)', // Replay REPLAY_RAGE_CLICK = 'Rage Click Detected', @@ -199,7 +188,6 @@ const ISSUE_TYPE_TO_ISSUE_TITLE = { performance_uncompressed_assets: IssueTitle.PERFORMANCE_UNCOMPRESSED_ASSET, performance_large_http_payload: IssueTitle.PERFORMANCE_LARGE_HTTP_PAYLOAD, performance_http_overhead: IssueTitle.PERFORMANCE_HTTP_OVERHEAD, - performance_duration_regression: IssueTitle.PERFORMANCE_DURATION_REGRESSION, performance_p95_endpoint_regression: IssueTitle.PERFORMANCE_ENDPOINT_REGRESSION, profile_file_io_main_thread: IssueTitle.PROFILE_FILE_IO_MAIN_THREAD, @@ -209,7 +197,6 @@ const ISSUE_TYPE_TO_ISSUE_TITLE = { profile_frame_drop: IssueTitle.PROFILE_FRAME_DROP, profile_frame_drop_experimental: IssueTitle.PROFILE_FRAME_DROP, profile_function_regression: IssueTitle.PROFILE_FUNCTION_REGRESSION, - profile_function_regression_exp: IssueTitle.PROFILE_FUNCTION_REGRESSION_EXPERIMENTAL, replay_click_rage: IssueTitle.REPLAY_RAGE_CLICK, replay_hydration_error: IssueTitle.REPLAY_HYDRATION_ERROR, @@ -235,16 +222,13 @@ const OCCURRENCE_TYPE_TO_ISSUE_TYPE = { 1013: IssueType.PERFORMANCE_DB_MAIN_THREAD, 1015: IssueType.PERFORMANCE_LARGE_HTTP_PAYLOAD, 1016: IssueType.PERFORMANCE_HTTP_OVERHEAD, - 1017: IssueType.PERFORMANCE_DURATION_REGRESSION, 1018: IssueType.PERFORMANCE_ENDPOINT_REGRESSION, 2001: IssueType.PROFILE_FILE_IO_MAIN_THREAD, 2002: IssueType.PROFILE_IMAGE_DECODE_MAIN_THREAD, 2003: IssueType.PROFILE_JSON_DECODE_MAIN_THREAD, 2007: IssueType.PROFILE_REGEX_MAIN_THREAD, 2008: IssueType.PROFILE_FRAME_DROP, - 2009: IssueType.PROFILE_FRAME_DROP_EXPERIMENTAL, 2010: IssueType.PROFILE_FUNCTION_REGRESSION, - 2011: IssueType.PROFILE_FUNCTION_REGRESSION_EXPERIMENTAL, }; const PERFORMANCE_REGRESSION_TYPE_IDS = new Set([1017, 1018, 2010, 2011]); diff --git a/static/app/types/user.tsx b/static/app/types/user.tsx index 19c3d7f63dff1a..1f903d137648ab 100644 --- a/static/app/types/user.tsx +++ b/static/app/types/user.tsx @@ -54,7 +54,7 @@ export interface User extends Omit { prefersChonkUI: boolean; prefersIssueDetailsStreamlinedUI: boolean | null; prefersNextjsInsightsOverview: boolean; - prefersStackedNavigation: boolean; + prefersStackedNavigation: boolean | null; quickStartDisplay: QuickStartDisplay; stacktraceOrder: number; theme: 'system' | 'light' | 'dark'; diff --git a/static/app/utils/analytics/featureFlagAnalyticsEvents.tsx b/static/app/utils/analytics/featureFlagAnalyticsEvents.tsx index b868bf41d73126..d8dd490dda122b 100644 --- a/static/app/utils/analytics/featureFlagAnalyticsEvents.tsx +++ b/static/app/utils/analytics/featureFlagAnalyticsEvents.tsx @@ -3,6 +3,9 @@ import type {PlatformKey} from 'sentry/types/project'; export type FeatureFlagEventParameters = { 'flags.cta_dismissed': {surface: string; type: string}; + 'flags.cta_read_more_clicked': { + surface: string; + }; 'flags.cta_rendered': {surface: string}; 'flags.drawer_details_rendered': { numLogs: number; @@ -51,4 +54,5 @@ export const featureFlagEventMap: Record = { 'flags.table_rendered': 'Flag Table Rendered', 'flags.view-all-clicked': 'Clicked View All Flags', 'flags.view-setup-sidebar': 'Viewed Feature Flag Onboarding Sidebar', + 'flags.cta_read_more_clicked': 'Clicked Read More in Feature Flag CTA', }; diff --git a/static/app/utils/analytics/tracingEventMap.tsx b/static/app/utils/analytics/tracingEventMap.tsx index e1f8e6be2c10bc..0d3fc475154a41 100644 --- a/static/app/utils/analytics/tracingEventMap.tsx +++ b/static/app/utils/analytics/tracingEventMap.tsx @@ -37,6 +37,11 @@ export type TracingEventParameters = { 'trace.explorer.schema_hints_drawer': { drawer_open: boolean; }; + 'trace.explorer.table_pagination': { + direction: string; + num_results: number; + type: 'samples' | 'traces' | 'aggregates'; + }; 'trace.load.empty_state': { source: TraceWaterFallSource; }; @@ -184,6 +189,7 @@ export const tracingEventMap: Record = { 'Improved Trace Explorer: Schema Hints Click Events', 'trace.explorer.schema_hints_drawer': 'Improved Trace Explorer: Schema Hints Drawer Events', + 'trace.explorer.table_pagination': 'Trace Explorer Table Pagination', 'trace.trace_layout.change': 'Changed Trace Layout', 'trace.trace_layout.drawer_minimize': 'Minimized Trace Drawer', 'trace.trace_drawer_explore_search': 'Searched Trace Explorer', diff --git a/static/app/utils/discover/fieldRenderers.tsx b/static/app/utils/discover/fieldRenderers.tsx index 2bfeffdd85605e..4feba1049133a4 100644 --- a/static/app/utils/discover/fieldRenderers.tsx +++ b/static/app/utils/discover/fieldRenderers.tsx @@ -57,6 +57,7 @@ import {decodeScalar} from 'sentry/utils/queryString'; import {isUrl} from 'sentry/utils/string/isUrl'; import {QuickContextHoverWrapper} from 'sentry/views/discover/table/quickContext/quickContextWrapper'; import {ContextType} from 'sentry/views/discover/table/quickContext/utils'; +import {PerformanceBadge} from 'sentry/views/insights/browser/webVitals/components/performanceBadge'; import {PercentChangeCell} from 'sentry/views/insights/common/components/tableCells/percentChangeCell'; import {ResponseStatusCodeCell} from 'sentry/views/insights/common/components/tableCells/responseStatusCodeCell'; import {StarredSegmentCell} from 'sentry/views/insights/common/components/tableCells/starredSegmentCell'; @@ -378,6 +379,7 @@ type SpecialFields = { issue: SpecialField; 'issue.id': SpecialField; minidump: SpecialField; + 'performance_score(measurements.score.total)': SpecialField; 'profile.id': SpecialField; project: SpecialField; release: SpecialField; @@ -403,6 +405,11 @@ const RightAlignedContainer = styled('span')` margin-right: 0; `; +const CenterAlignedContainer = styled('span')` + text-align: center; + width: 100%; +`; + /** * "Special fields" either do not map 1:1 to an single column in the event database, * or they require custom UI formatting that can't be handled by the datatype formatters. @@ -827,6 +834,20 @@ const SPECIAL_FIELDS: SpecialFields = { ), }, + 'performance_score(measurements.score.total)': { + sortField: 'performance_score(measurements.score.total)', + renderFunc: data => { + const score = data['performance_score(measurements.score.total)']; + if (typeof score !== 'number') { + return {emptyValue}; + } + return ( + + + + ); + }, + }, }; type SpecialFunctionFieldRenderer = ( diff --git a/static/app/utils/errorHandler.tsx b/static/app/utils/errorHandler.tsx index d6334cb612b43c..4ec7a01e77a21c 100644 --- a/static/app/utils/errorHandler.tsx +++ b/static/app/utils/errorHandler.tsx @@ -24,12 +24,24 @@ export default function errorHandler

(WrappedComponent: React.ComponentType

error: undefined, }; - componentDidCatch(_error: Error, info: React.ErrorInfo) { + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { // eslint-disable-next-line no-console console.error( 'Component stack trace caught in :', - info.componentStack + errorInfo.componentStack ); + + try { + // Based on https://github.com/getsentry/sentry-javascript/blob/6f4ad562c469f546f1098136b65583309d03487b/packages/react/src/errorboundary.tsx#L75-L85 + const errorBoundaryError = new Error(error.message); + errorBoundaryError.name = `React ErrorBoundary ${errorBoundaryError.name}`; + errorBoundaryError.stack = errorInfo.componentStack!; + + // This will mutate `error` and get captured to Sentry in `RouteError` + error.cause = errorBoundaryError; + } catch { + // Some browsers won't let you write to Error instance + } } render() { diff --git a/static/app/utils/issueTypeConfig/index.tsx b/static/app/utils/issueTypeConfig/index.tsx index 8baae2f071d92e..7fac519b418bb9 100644 --- a/static/app/utils/issueTypeConfig/index.tsx +++ b/static/app/utils/issueTypeConfig/index.tsx @@ -35,7 +35,7 @@ type GetConfigForIssueTypeParams = {eventOccurrenceType: number} | IssueCategory const BASE_CONFIG: IssueTypeConfig = { actions: { archiveUntilOccurrence: {enabled: true}, - delete: {enabled: false}, + delete: {enabled: true}, deleteAndDiscard: {enabled: false}, merge: {enabled: false}, ignore: {enabled: false}, diff --git a/static/app/utils/issueTypeConfig/performanceConfig.tsx b/static/app/utils/issueTypeConfig/performanceConfig.tsx index 3e43c0c64206e5..fcedb3bb2c68c8 100644 --- a/static/app/utils/issueTypeConfig/performanceConfig.tsx +++ b/static/app/utils/issueTypeConfig/performanceConfig.tsx @@ -203,26 +203,6 @@ const performanceConfig: IssueCategoryConfigMapping = { linksByPlatform: {}, }, }, - [IssueType.PERFORMANCE_DURATION_REGRESSION]: { - pages: { - landingPage: Tab.DETAILS, - events: {enabled: true}, - openPeriods: {enabled: false}, - checkIns: {enabled: false}, - uptimeChecks: {enabled: false}, - attachments: {enabled: false}, - userFeedback: {enabled: false}, - replays: {enabled: false}, - tagsTab: {enabled: false}, - }, - discover: {enabled: false}, - regression: {enabled: true}, - performanceDurationRegression: {enabled: true}, - stats: {enabled: false}, - tags: {enabled: false}, - // We show the regression summary instead - spanEvidence: {enabled: false}, - }, [IssueType.PERFORMANCE_ENDPOINT_REGRESSION]: { actions: { archiveUntilOccurrence: { @@ -338,40 +318,6 @@ const performanceConfig: IssueCategoryConfigMapping = { linksByPlatform: {}, }, }, - [IssueType.PROFILE_FRAME_DROP_EXPERIMENTAL]: { - resources: { - description: t( - 'The main (or UI) thread in a mobile app is responsible for handling all user interaction and needs to be able to respond to gestures and taps in real time. If a long-running operation blocks the main thread, the app becomes unresponsive, impacting the quality of the user experience. To learn more, read our documentation:' - ), - links: [ - { - text: t('Frame Drop'), - link: 'https://docs.sentry.io/product/issues/issue-details/performance-issues/frame-drop/', - }, - ], - linksByPlatform: {}, - }, - }, - [IssueType.PROFILE_FUNCTION_REGRESSION_EXPERIMENTAL]: { - pages: { - landingPage: Tab.DETAILS, - events: {enabled: false}, - openPeriods: {enabled: false}, - uptimeChecks: {enabled: false}, - checkIns: {enabled: false}, - attachments: {enabled: false}, - userFeedback: {enabled: false}, - replays: {enabled: false}, - tagsTab: {enabled: false}, - }, - discover: {enabled: false}, - regression: {enabled: true}, - profilingDurationRegression: {enabled: true}, - // We show the regression summary instead - spanEvidence: {enabled: false}, - stats: {enabled: false}, - tags: {enabled: false}, - }, [IssueType.PROFILE_FUNCTION_REGRESSION]: { actions: { archiveUntilOccurrence: { diff --git a/static/app/utils/issueTypeConfig/performanceRegressionConfig.tsx b/static/app/utils/issueTypeConfig/performanceRegressionConfig.tsx index ed21f8d66facb8..b5896800a4e6b6 100644 --- a/static/app/utils/issueTypeConfig/performanceRegressionConfig.tsx +++ b/static/app/utils/issueTypeConfig/performanceRegressionConfig.tsx @@ -47,24 +47,6 @@ const performanceRegressionConfig: IssueCategoryConfigMapping = { usesIssuePlatform: true, issueSummary: {enabled: false}, }, - [IssueType.PERFORMANCE_DURATION_REGRESSION]: { - pages: { - landingPage: Tab.DETAILS, - events: {enabled: true}, - openPeriods: {enabled: false}, - checkIns: {enabled: false}, - uptimeChecks: {enabled: false}, - attachments: {enabled: false}, - userFeedback: {enabled: false}, - replays: {enabled: false}, - tagsTab: {enabled: false}, - }, - discover: {enabled: false}, - regression: {enabled: true}, - performanceDurationRegression: {enabled: true}, - stats: {enabled: false}, - tags: {enabled: false}, - }, [IssueType.PERFORMANCE_ENDPOINT_REGRESSION]: { pages: { landingPage: Tab.DETAILS, @@ -83,24 +65,6 @@ const performanceRegressionConfig: IssueCategoryConfigMapping = { stats: {enabled: false}, tags: {enabled: false}, }, - [IssueType.PROFILE_FUNCTION_REGRESSION_EXPERIMENTAL]: { - pages: { - landingPage: Tab.DETAILS, - events: {enabled: false}, - openPeriods: {enabled: false}, - uptimeChecks: {enabled: false}, - checkIns: {enabled: false}, - attachments: {enabled: false}, - userFeedback: {enabled: false}, - replays: {enabled: false}, - tagsTab: {enabled: false}, - }, - discover: {enabled: false}, - regression: {enabled: true}, - profilingDurationRegression: {enabled: true}, - stats: {enabled: false}, - tags: {enabled: false}, - }, [IssueType.PROFILE_FUNCTION_REGRESSION]: { pages: { landingPage: Tab.DETAILS, diff --git a/static/app/utils/issueTypeConfig/responsivenessConfig.tsx b/static/app/utils/issueTypeConfig/responsivenessConfig.tsx index 792c7e61f0a8c4..f04695619d745b 100644 --- a/static/app/utils/issueTypeConfig/responsivenessConfig.tsx +++ b/static/app/utils/issueTypeConfig/responsivenessConfig.tsx @@ -139,20 +139,6 @@ const responsivenessConfig: IssueCategoryConfigMapping = { linksByPlatform: {}, }, }, - [IssueType.PROFILE_FRAME_DROP_EXPERIMENTAL]: { - resources: { - description: t( - 'The main (or UI) thread in a mobile app is responsible for handling all user interaction and needs to be able to respond to gestures and taps in real time. If a long-running operation blocks the main thread, the app becomes unresponsive, impacting the quality of the user experience. To learn more, read our documentation:' - ), - links: [ - { - text: t('Frame Drop'), - link: 'https://docs.sentry.io/product/issues/issue-details/performance-issues/frame-drop/', - }, - ], - linksByPlatform: {}, - }, - }, }; export default responsivenessConfig; diff --git a/static/app/utils/profiling/routes.tsx b/static/app/utils/profiling/routes.tsx index ee1159c624a616..657430e5d62b43 100644 --- a/static/app/utils/profiling/routes.tsx +++ b/static/app/utils/profiling/routes.tsx @@ -14,7 +14,7 @@ const LEGACY_PROFILING_BASE_PATHNAME = 'profiling'; const PROFILING_BASE_PATHNAME = 'explore/profiling'; function generateProfilingRoute({organization}: {organization: Organization}): Path { - if (prefersStackedNav()) { + if (prefersStackedNav(organization)) { return `/organizations/${organization.slug}/${PROFILING_BASE_PATHNAME}/`; } @@ -30,7 +30,7 @@ export function generateProfileFlamechartRoute({ profileId: Trace['id']; projectSlug: Project['slug']; }): string { - if (prefersStackedNav()) { + if (prefersStackedNav(organization)) { return `/organizations/${organization.slug}/${PROFILING_BASE_PATHNAME}/profile/${projectSlug}/${profileId}/flamegraph/`; } @@ -44,7 +44,7 @@ function generateContinuousProfileFlamechartRoute({ organization: Organization; projectSlug: Project['slug']; }): string { - if (prefersStackedNav()) { + if (prefersStackedNav(organization)) { return `/organizations/${organization.slug}/${PROFILING_BASE_PATHNAME}/profile/${projectSlug}/flamegraph/`; } @@ -58,7 +58,7 @@ function generateProfileDifferentialFlamegraphRoute({ organization: Organization; projectSlug: Project['slug']; }): string { - if (prefersStackedNav()) { + if (prefersStackedNav(organization)) { return `/organizations/${organization.slug}/${PROFILING_BASE_PATHNAME}/profile/${projectSlug}/differential-flamegraph/`; } diff --git a/static/app/views/admin/adminLayout.tsx b/static/app/views/admin/adminLayout.tsx index d9eaa71fb461e7..09456ac5dc2207 100644 --- a/static/app/views/admin/adminLayout.tsx +++ b/static/app/views/admin/adminLayout.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; import {t} from 'sentry/locale'; import type {RouteComponentProps} from 'sentry/types/legacyReactRouter'; +import useOrganization from 'sentry/utils/useOrganization'; import {prefersStackedNav} from 'sentry/views/nav/prefersStackedNav'; import {BreadcrumbProvider} from 'sentry/views/settings/components/settingsBreadcrumb/context'; import SettingsLayout from 'sentry/views/settings/components/settingsLayout'; @@ -46,12 +47,16 @@ type Props = { } & RouteComponentProps; function AdminLayout({children, ...props}: Props) { + const organization = useOrganization(); + return ( {children} diff --git a/static/app/views/alerts/pathnames.tsx b/static/app/views/alerts/pathnames.tsx index ca86f9ed2bee09..09db0397eec057 100644 --- a/static/app/views/alerts/pathnames.tsx +++ b/static/app/views/alerts/pathnames.tsx @@ -13,7 +13,7 @@ export function makeAlertsPathname({ path: '/' | `/${string}/`; }) { return normalizeUrl( - prefersStackedNav() + prefersStackedNav(organization) ? `/organizations/${organization.slug}/${ALERTS_BASE_PATHNAME}${path}` : `/organizations/${organization.slug}/${LEGACY_ALERTS_BASE_PATHNAME}${path}` ); diff --git a/static/app/views/alerts/rules/metric/eapField.spec.tsx b/static/app/views/alerts/rules/metric/eapField.spec.tsx index 041cfcfd9e07a0..f9c40623158855 100644 --- a/static/app/views/alerts/rules/metric/eapField.spec.tsx +++ b/static/app/views/alerts/rules/metric/eapField.spec.tsx @@ -13,7 +13,7 @@ describe('EAPField', () => { beforeEach(() => { fieldsMock = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/spans/fields/`, + url: `/organizations/${organization.slug}/trace-items/attributes/`, method: 'GET', }); }); @@ -25,15 +25,15 @@ describe('EAPField', () => { ); expect(fieldsMock).toHaveBeenCalledWith( - `/organizations/${organization.slug}/spans/fields/`, + `/organizations/${organization.slug}/trace-items/attributes/`, expect.objectContaining({ - query: expect.objectContaining({type: 'number'}), + query: expect.objectContaining({attributeType: 'number'}), }) ); expect(fieldsMock).toHaveBeenCalledWith( - `/organizations/${organization.slug}/spans/fields/`, + `/organizations/${organization.slug}/trace-items/attributes/`, expect.objectContaining({ - query: expect.objectContaining({type: 'string'}), + query: expect.objectContaining({attributeType: 'string'}), }) ); expect(screen.getByText('count')).toBeInTheDocument(); @@ -55,15 +55,15 @@ describe('EAPField', () => { ); expect(fieldsMock).toHaveBeenCalledWith( - `/organizations/${organization.slug}/spans/fields/`, + `/organizations/${organization.slug}/trace-items/attributes/`, expect.objectContaining({ - query: expect.objectContaining({type: 'number'}), + query: expect.objectContaining({attributeType: 'number'}), }) ); expect(fieldsMock).toHaveBeenCalledWith( - `/organizations/${organization.slug}/spans/fields/`, + `/organizations/${organization.slug}/trace-items/attributes/`, expect.objectContaining({ - query: expect.objectContaining({type: 'string'}), + query: expect.objectContaining({attributeType: 'string'}), }) ); await userEvent.click(screen.getByText('count')); diff --git a/static/app/views/alerts/rules/metric/ruleConditionsForm.tsx b/static/app/views/alerts/rules/metric/ruleConditionsForm.tsx index d67b654ba5e58b..1590521cd2a299 100644 --- a/static/app/views/alerts/rules/metric/ruleConditionsForm.tsx +++ b/static/app/views/alerts/rules/metric/ruleConditionsForm.tsx @@ -70,8 +70,8 @@ import { import type {AlertType} from 'sentry/views/alerts/wizard/options'; import {getSupportedAndOmittedTags} from 'sentry/views/alerts/wizard/options'; import { - SpanTagsContext, SpanTagsProvider, + useSpanTags, } from 'sentry/views/explore/contexts/spanTagsContext'; import {hasEAPAlerts} from 'sentry/views/insights/common/utils/hasEAPAlerts'; @@ -630,22 +630,14 @@ class RuleConditionsForm extends PureComponent { > {({onChange, onBlur, initialData, value}: any) => { return alertType === 'eap_metrics' ? ( - - {tags => ( - { - onFilterSearch(query, parsedQuery); - onChange(query, {}); - }} - supportedAggregates={ALLOWED_EXPLORE_VISUALIZE_AGGREGATES} - projects={[parseInt(project.id, 10)]} - /> - )} - + { + onFilterSearch(query, parsedQuery); + onChange(query, {}); + }} + project={project} + /> ) : ( { } } +interface EAPSpanSearchQueryBuilderWithContextProps { + initialQuery: string; + onSearch: (query: string, isQueryValid: any) => void; + project: Project; +} + +function EAPSpanSearchQueryBuilderWithContext({ + initialQuery, + onSearch, + project, +}: EAPSpanSearchQueryBuilderWithContextProps) { + const {tags: numberTags} = useSpanTags('number'); + const {tags: stringTags} = useSpanTags('string'); + return ( + + ); +} + const StyledListTitle = styled('div')` display: flex; span { diff --git a/static/app/views/dashboards/manage/index.tsx b/static/app/views/dashboards/manage/index.tsx index b473494b317d5f..b4a2839ccf8eab 100644 --- a/static/app/views/dashboards/manage/index.tsx +++ b/static/app/views/dashboards/manage/index.tsx @@ -45,7 +45,7 @@ import { import DashboardTable from 'sentry/views/dashboards/manage/dashboardTable'; import type {DashboardsLayout} from 'sentry/views/dashboards/manage/types'; import type {DashboardDetails, DashboardListItem} from 'sentry/views/dashboards/types'; -import {usePrefersStackedNav} from 'sentry/views/nav/prefersStackedNav'; +import {usePrefersStackedNav} from 'sentry/views/nav/usePrefersStackedNav'; import RouteError from 'sentry/views/routeError'; import DashboardGrid from './dashboardGrid'; diff --git a/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar.spec.tsx b/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar.spec.tsx index a58217072ddb7f..c2f9fd54882f9a 100644 --- a/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar.spec.tsx @@ -33,11 +33,11 @@ function mockSpanTags({ type: 'string' | 'number'; }) { MockApiClient.addMockResponse({ - url: `/organizations/org-slug/spans/fields/`, + url: `/organizations/org-slug/trace-items/attributes/`, body: mockedTags, match: [ function (_url: string, options: Record) { - return options.query.type === type; + return options.query.attributeType === type; }, ], }); @@ -53,11 +53,11 @@ function mockSpanTagValues({ type: 'string' | 'number'; }) { MockApiClient.addMockResponse({ - url: `/organizations/org-slug/spans/fields/${tagKey}/values/`, + url: `/organizations/org-slug/trace-items/attributes/${tagKey}/values/`, body: mockedValues, match: [ function (_url: string, options: Record) { - return options.query.type === type; + return options.query.attributeType === type; }, ], }); diff --git a/static/app/views/dashboards/widgetBuilder/components/filtersBar.tsx b/static/app/views/dashboards/widgetBuilder/components/filtersBar.tsx index 3756325a17430f..5b1f066be5591e 100644 --- a/static/app/views/dashboards/widgetBuilder/components/filtersBar.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/filtersBar.tsx @@ -1,9 +1,11 @@ +import {Tooltip} from 'sentry/components/core/tooltip'; import {DatePageFilter} from 'sentry/components/organizations/datePageFilter'; import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter'; import PageFilterBar from 'sentry/components/organizations/pageFilterBar'; import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container'; import {ProjectPageFilter} from 'sentry/components/organizations/projectPageFilter'; import {DEFAULT_STATS_PERIOD} from 'sentry/constants'; +import {t} from 'sentry/locale'; import {ReleasesProvider} from 'sentry/utils/releases/releasesProvider'; import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; @@ -25,18 +27,22 @@ function WidgetBuilderFilterBar() { }, }} > - - {}} /> - {}} /> - {}} /> - - {}} - selectedReleases={[]} - /> - - + + + {}} /> + {}} /> + {}} /> + + {}} + selectedReleases={[]} + /> + + + ); } diff --git a/static/app/views/dashboards/widgetBuilder/components/groupBySelector.spec.tsx b/static/app/views/dashboards/widgetBuilder/components/groupBySelector.spec.tsx index e605ab33faadbb..98094ccb30225f 100644 --- a/static/app/views/dashboards/widgetBuilder/components/groupBySelector.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/groupBySelector.spec.tsx @@ -14,7 +14,7 @@ const organization = OrganizationFixture({ describe('WidgetBuilderGroupBySelector', function () { beforeEach(function () { MockApiClient.addMockResponse({ - url: '/organizations/org-slug/spans/fields/', + url: '/organizations/org-slug/trace-items/attributes/', body: [], }); }); diff --git a/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.spec.tsx b/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.spec.tsx index 00150ebf225fe3..1283d5a6d6da08 100644 --- a/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.spec.tsx @@ -88,7 +88,7 @@ describe('NewWidgetBuiler', function () { }); MockApiClient.addMockResponse({ - url: '/organizations/org-slug/spans/fields/', + url: '/organizations/org-slug/trace-items/attributes/', body: [], }); diff --git a/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.tsx b/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.tsx index 1fefd5f5072df6..de861b995d4910 100644 --- a/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.tsx @@ -52,7 +52,7 @@ import { import {DashboardsMEPProvider} from 'sentry/views/dashboards/widgetCard/dashboardsMEPContext'; import {SpanTagsProvider} from 'sentry/views/explore/contexts/spanTagsContext'; import {useNavContext} from 'sentry/views/nav/context'; -import {usePrefersStackedNav} from 'sentry/views/nav/prefersStackedNav'; +import {usePrefersStackedNav} from 'sentry/views/nav/usePrefersStackedNav'; import {MetricsDataSwitcher} from 'sentry/views/performance/landing/metricsDataSwitcher'; export interface ThresholdMetaState { diff --git a/static/app/views/dashboards/widgetBuilder/components/sortBySelector.spec.tsx b/static/app/views/dashboards/widgetBuilder/components/sortBySelector.spec.tsx index 345300ab5592fb..3efc1abb96cd07 100644 --- a/static/app/views/dashboards/widgetBuilder/components/sortBySelector.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/sortBySelector.spec.tsx @@ -34,7 +34,7 @@ const mockUseNavigate = jest.mocked(useNavigate); describe('WidgetBuilderSortBySelector', function () { beforeEach(function () { MockApiClient.addMockResponse({ - url: '/organizations/org-slug/spans/fields/', + url: '/organizations/org-slug/trace-items/attributes/', body: [], }); }); diff --git a/static/app/views/dashboards/widgetBuilder/widgetBuilderSortBy.spec.tsx b/static/app/views/dashboards/widgetBuilder/widgetBuilderSortBy.spec.tsx index 764731515fdfb2..46aba6be916a56 100644 --- a/static/app/views/dashboards/widgetBuilder/widgetBuilderSortBy.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/widgetBuilderSortBy.spec.tsx @@ -241,7 +241,7 @@ describe('WidgetBuilder', function () { body: [], }); MockApiClient.addMockResponse({ - url: `/organizations/org-slug/spans/fields/`, + url: `/organizations/org-slug/trace-items/attributes/`, body: [], }); diff --git a/static/app/views/dashboards/widgetCard/index.spec.tsx b/static/app/views/dashboards/widgetCard/index.spec.tsx index 98f719625bc620..18a18c92650c19 100644 --- a/static/app/views/dashboards/widgetCard/index.spec.tsx +++ b/static/app/views/dashboards/widgetCard/index.spec.tsx @@ -162,7 +162,7 @@ describe('Dashboards > WidgetCard', function () { ); await userEvent.click(await screen.findByLabelText('Widget actions')); - expect(screen.getByRole('link', {name: 'Open in Discover'})).toHaveAttribute( + expect(screen.getByRole('menuitemradio', {name: 'Open in Discover'})).toHaveAttribute( 'href', '/organizations/org-slug/discover/results/?environment=prod&field=count%28%29&field=failure_count%28%29&name=Errors&project=1&query=event.type%3Aerror&statsPeriod=14d&yAxis=count%28%29&yAxis=failure_count%28%29' ); @@ -221,7 +221,7 @@ describe('Dashboards > WidgetCard', function () { ); await userEvent.click(await screen.findByLabelText('Widget actions')); - expect(screen.getByRole('link', {name: 'Open in Discover'})).toHaveAttribute( + expect(screen.getByRole('menuitemradio', {name: 'Open in Discover'})).toHaveAttribute( 'href', '/organizations/org-slug/discover/results/?environment=prod&field=count_if%28transaction.duration%2Cequals%2C300%29&field=failure_count%28%29&field=count%28%29&field=equation%7C%28count%28%29%20%2B%20failure_count%28%29%29%20%2F%20count_if%28transaction.duration%2Cequals%2C300%29&name=Errors&project=1&query=event.type%3Aerror&statsPeriod=14d&yAxis=equation%7C%28count%28%29%20%2B%20failure_count%28%29%29%20%2F%20count_if%28transaction.duration%2Cequals%2C300%29' ); @@ -256,7 +256,7 @@ describe('Dashboards > WidgetCard', function () { ); await userEvent.click(await screen.findByLabelText('Widget actions')); - expect(screen.getByRole('link', {name: 'Open in Discover'})).toHaveAttribute( + expect(screen.getByRole('menuitemradio', {name: 'Open in Discover'})).toHaveAttribute( 'href', '/organizations/org-slug/discover/results/?display=top5&environment=prod&field=transaction&field=count%28%29&name=Errors&project=1&query=event.type%3Aerror&statsPeriod=14d&yAxis=count%28%29' ); @@ -292,7 +292,7 @@ describe('Dashboards > WidgetCard', function () { ); await userEvent.click(await screen.findByLabelText('Widget actions')); - expect(screen.getByRole('link', {name: 'Open in Discover'})).toHaveAttribute( + expect(screen.getByRole('menuitemradio', {name: 'Open in Discover'})).toHaveAttribute( 'href', '/organizations/org-slug/discover/results/?environment=prod&field=p99%28measurements.custom.measurement%29&name=Errors&project=1&query=&statsPeriod=14d&yAxis=p99%28measurements.custom.measurement%29' ); diff --git a/static/app/views/dashboards/widgetCard/issueWidgetCard.spec.tsx b/static/app/views/dashboards/widgetCard/issueWidgetCard.spec.tsx index 4bdcd12a491a2b..d476c85e3ba4bc 100644 --- a/static/app/views/dashboards/widgetCard/issueWidgetCard.spec.tsx +++ b/static/app/views/dashboards/widgetCard/issueWidgetCard.spec.tsx @@ -174,7 +174,7 @@ describe('Dashboards > IssueWidgetCard', function () { await userEvent.click(await screen.findByLabelText('Widget actions')); expect(screen.getByText('Duplicate Widget')).toBeInTheDocument(); - expect(screen.getByRole('link', {name: 'Open in Issues'})).toHaveAttribute( + expect(screen.getByRole('menuitemradio', {name: 'Open in Issues'})).toHaveAttribute( 'href', '/organizations/org-slug/issues/?environment=prod&project=1&query=event.type%3Adefault&sort=freq&statsPeriod=14d' ); diff --git a/static/app/views/discover/pathnames.tsx b/static/app/views/discover/pathnames.tsx index 4e10792c1ca44d..5e1dab4651d93b 100644 --- a/static/app/views/discover/pathnames.tsx +++ b/static/app/views/discover/pathnames.tsx @@ -13,7 +13,7 @@ export function makeDiscoverPathname({ path: '/' | `/${string}/`; }) { return normalizeUrl( - prefersStackedNav() + prefersStackedNav(organization) ? `/organizations/${organization.slug}/${DISCOVER_BASE_PATHNAME}${path}` : `/organizations/${organization.slug}/${LEGACY_DISCOVER_BASE_PATHNAME}${path}` ); diff --git a/static/app/views/discover/queryList.spec.tsx b/static/app/views/discover/queryList.spec.tsx index 03bd268f592e42..bb25137080142b 100644 --- a/static/app/views/discover/queryList.spec.tsx +++ b/static/app/views/discover/queryList.spec.tsx @@ -526,11 +526,15 @@ describe('Discover > QueryList', function () { const contextMenu = await screen.findByTestId('menu-trigger'); expect(contextMenu).toBeInTheDocument(); - expect(screen.queryByTestId('add-to-dashboard')).not.toBeInTheDocument(); + expect( + screen.queryByRole('menuitemradio', {name: 'Add to Dashboard'}) + ).not.toBeInTheDocument(); await userEvent.click(contextMenu); - const addToDashboardMenuItem = await screen.findByTestId('add-to-dashboard'); + const addToDashboardMenuItem = await screen.findByRole('menuitemradio', { + name: 'Add to Dashboard', + }); await userEvent.click(addToDashboardMenuItem); @@ -595,11 +599,15 @@ describe('Discover > QueryList', function () { const contextMenu = await screen.findByTestId('menu-trigger'); expect(contextMenu).toBeInTheDocument(); - expect(screen.queryByTestId('add-to-dashboard')).not.toBeInTheDocument(); + expect( + screen.queryByRole('menuitemradio', {name: 'Add to Dashboard'}) + ).not.toBeInTheDocument(); await userEvent.click(contextMenu); - const addToDashboardMenuItem = await screen.findByTestId('add-to-dashboard'); + const addToDashboardMenuItem = await screen.findByRole('menuitemradio', { + name: 'Add to Dashboard', + }); await userEvent.click(addToDashboardMenuItem); @@ -666,11 +674,15 @@ describe('Discover > QueryList', function () { const contextMenu = await screen.findByTestId('menu-trigger'); expect(contextMenu).toBeInTheDocument(); - expect(screen.queryByTestId('add-to-dashboard')).not.toBeInTheDocument(); + expect( + screen.queryByRole('menuitemradio', {name: 'Add to Dashboard'}) + ).not.toBeInTheDocument(); await userEvent.click(contextMenu); - const addToDashboardMenuItem = await screen.findByTestId('add-to-dashboard'); + const addToDashboardMenuItem = await screen.findByRole('menuitemradio', { + name: 'Add to Dashboard', + }); await userEvent.click(addToDashboardMenuItem); diff --git a/static/app/views/explore/components/traceItemAttributes/attributesTree.tsx b/static/app/views/explore/components/traceItemAttributes/attributesTree.tsx index 4144b7e06381ca..2a49554ca58a34 100644 --- a/static/app/views/explore/components/traceItemAttributes/attributesTree.tsx +++ b/static/app/views/explore/components/traceItemAttributes/attributesTree.tsx @@ -11,10 +11,7 @@ import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {defined} from 'sentry/utils'; import type {EventsMetaType} from 'sentry/utils/discover/eventView'; -import { - getFieldRenderer, - type RenderFunctionBaggage, -} from 'sentry/utils/discover/fieldRenderers'; +import {type RenderFunctionBaggage} from 'sentry/utils/discover/fieldRenderers'; import {isEmptyObject} from 'sentry/utils/object/isEmptyObject'; import {isUrl} from 'sentry/utils/string/isUrl'; import useCopyToClipboard from 'sentry/utils/useCopyToClipboard'; @@ -73,6 +70,8 @@ interface AttributesFieldRender { interface AttributesTreeProps extends AttributesFieldRender { attributes: TraceItemResponseAttribute[]; + // If provided, locks the number of columns to this number. If not provided, the number of columns will be dynamic based on width. + columnCount?: number; config?: AttributesTreeRowConfig; getAdjustedAttributeKey?: (attribute: TraceItemResponseAttribute) => string; getCustomActions?: (content: AttributesTreeContent) => MenuItemProps[]; @@ -304,14 +303,15 @@ export function AttributesTree( props: AttributesTreeProps ) { const containerRef = useRef(null); - const columnCount = useIssueDetailsColumnCount(containerRef); + const widthBasedColumnCount = useIssueDetailsColumnCount(containerRef); + const columnCount = props.columnCount ?? widthBasedColumnCount; return ( - + ); } @@ -445,7 +445,6 @@ function AttributesTreeValue({ content, renderers = {}, rendererExtra: renderExtra, - theme, }: { content: AttributesTreeContent; config?: AttributesTreeRowConfig; @@ -458,12 +457,7 @@ function AttributesTreeValue({ // Check if we have a custom renderer for this attribute const attributeKey = originalAttribute.original_attribute_key; const renderer = renderers[attributeKey]; - const basicRenderer = getFieldRenderer(attributeKey, {}, false); - const basicRendered = basicRenderer( - {[attributeKey]: content.value}, - {...renderExtra, theme} - ); const defaultValue = {String(content.value)}; if (config?.disableRichValue) { @@ -473,7 +467,7 @@ function AttributesTreeValue({ if (renderer) { return renderer({ item: getAttributeItem(attributeKey, content.value), - basicRendered, + basicRendered: defaultValue, extra: renderExtra, }); } @@ -486,7 +480,7 @@ function AttributesTreeValue({ openNavigateToExternalLinkModal({linkText: String(content.value)}); }} > - {basicRendered} + {defaultValue} ) : ( @@ -532,7 +526,7 @@ const TreeContainer = styled('div')<{columnCount: number}>` const TreeColumn = styled('div')` display: grid; - grid-template-columns: minmax(auto, 175px) 1fr; + grid-template-columns: minmax(min-content, max-content) auto; grid-column-gap: ${space(3)}; &:first-child { margin-left: -${space(1)}; diff --git a/static/app/views/explore/components/traceItemSearchQueryBuilder.tsx b/static/app/views/explore/components/traceItemSearchQueryBuilder.tsx index f297ef98318f1d..58980a13e8dcd1 100644 --- a/static/app/views/explore/components/traceItemSearchQueryBuilder.tsx +++ b/static/app/views/explore/components/traceItemSearchQueryBuilder.tsx @@ -13,7 +13,7 @@ import {TraceItemDataset} from 'sentry/views/explore/types'; import {SPANS_FILTER_KEY_SECTIONS} from 'sentry/views/insights/constants'; type TraceItemSearchQueryBuilderProps = { - itemType: TraceItemDataset.LOGS; // This should include TraceItemDataset.SPANS etc. + itemType: TraceItemDataset; numberAttributes: TagCollection; stringAttributes: TagCollection; } & Omit; @@ -55,8 +55,10 @@ export function useSearchQueryBuilderProps({ searchSource, getFilterTokenWarning, onBlur, + onChange, onSearch, portalTarget, + projects, supportedAggregates = [], }: TraceItemSearchQueryBuilderProps) { const placeholderText = itemTypeToDefaultPlaceholder(itemType); @@ -69,6 +71,7 @@ export function useSearchQueryBuilderProps({ attributeKey: '', enabled: true, type: 'string', + projectIds: projects, }); return { @@ -77,6 +80,7 @@ export function useSearchQueryBuilderProps({ initialQuery, fieldDefinitionGetter: getTraceItemFieldDefinitionFunction(itemType, filterTags), onSearch, + onChange, onBlur, getFilterTokenWarning, searchSource, @@ -102,9 +106,10 @@ export function TraceItemSearchQueryBuilder({ datetime: _datetime, getFilterTokenWarning, onBlur, + onChange, onSearch, portalTarget, - projects: _projects, + projects, supportedAggregates = [], }: TraceItemSearchQueryBuilderProps) { const searchQueryBuilderProps = useSearchQueryBuilderProps({ @@ -115,8 +120,10 @@ export function TraceItemSearchQueryBuilder({ searchSource, getFilterTokenWarning, onBlur, + onChange, onSearch, portalTarget, + projects, supportedAggregates, }); @@ -127,10 +134,12 @@ function useFunctionTags( itemType: TraceItemDataset, supportedAggregates?: AggregationKey[] ) { - if (itemType === TraceItemDataset.SPANS) { - return getFunctionTags(supportedAggregates); - } - return {}; + return useMemo(() => { + if (itemType === TraceItemDataset.SPANS) { + return getFunctionTags(supportedAggregates); + } + return {}; + }, [itemType, supportedAggregates]); } function useFilterTags( @@ -169,7 +178,7 @@ function useFilterKeySections( label: 'Custom Tags', children: Object.keys(stringAttributes).filter(key => !predefined.has(key)), }, - ]; + ].filter(section => section.children.length); }, [stringAttributes, itemType]); } diff --git a/static/app/views/explore/constants.tsx b/static/app/views/explore/constants.tsx index 7ed01fb9d70e95..70b79f2e7653de 100644 --- a/static/app/views/explore/constants.tsx +++ b/static/app/views/explore/constants.tsx @@ -1,11 +1,11 @@ import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; import {SpanIndexedField} from 'sentry/views/insights/types'; -export const SENTRY_SPAN_STRING_TAGS: string[] = [ +export const SENTRY_SEARCHABLE_SPAN_STRING_TAGS: string[] = [ // NOTE: intentionally choose to not expose transaction id // as we're moving toward span ids - 'id', // SpanIndexedField.SPAN_OP is actually `span_id` + 'id', // SpanIndexedField.SPAN_ID is actually `span_id` 'profile.id', // SpanIndexedField.PROFILE_ID is actually `profile_id` SpanIndexedField.BROWSER_NAME, SpanIndexedField.ENVIRONMENT, @@ -39,11 +39,25 @@ export const SENTRY_SPAN_STRING_TAGS: string[] = [ SpanIndexedField.CACHE_HIT, ]; -export const SENTRY_SPAN_NUMBER_TAGS: string[] = [ +export const SENTRY_SEARCHABLE_SPAN_NUMBER_TAGS: string[] = [ SpanIndexedField.SPAN_DURATION, SpanIndexedField.SPAN_SELF_TIME, ]; +export const SENTRY_SPAN_STRING_TAGS: string[] = [ + 'id', // SpanIndexedField.SPAN_ID is actually `span_id` + SpanIndexedField.PROJECT, + SpanIndexedField.SPAN_DESCRIPTION, + SpanIndexedField.SPAN_OP, + SpanIndexedField.TIMESTAMP, + SpanIndexedField.TRANSACTION, + SpanIndexedField.TRACE, + SpanIndexedField.IS_TRANSACTION, // boolean field but we can expose it as a string + SpanIndexedField.NORMALIZED_DESCRIPTION, +]; + +export const SENTRY_SPAN_NUMBER_TAGS: string[] = SENTRY_SEARCHABLE_SPAN_NUMBER_TAGS; + export const SENTRY_LOG_STRING_TAGS: string[] = [ OurLogKnownFieldKey.TRACE_ID, OurLogKnownFieldKey.ID, diff --git a/static/app/views/explore/content.tsx b/static/app/views/explore/content.tsx index 462b31ee515d24..65f933ff1f7437 100644 --- a/static/app/views/explore/content.tsx +++ b/static/app/views/explore/content.tsx @@ -35,7 +35,7 @@ import { import {useExploreSpansTour} from 'sentry/views/explore/spans/tour'; import {StarSavedQueryButton} from 'sentry/views/explore/starSavedQueryButton'; import {limitMaxPickableDays} from 'sentry/views/explore/utils'; -import {usePrefersStackedNav} from 'sentry/views/nav/prefersStackedNav'; +import {usePrefersStackedNav} from 'sentry/views/nav/usePrefersStackedNav'; export function ExploreContent() { return ( diff --git a/static/app/views/explore/contexts/spanTagsContext.tsx b/static/app/views/explore/contexts/spanTagsContext.tsx index eb456af2a78a8d..733dfd9bfe5c70 100644 --- a/static/app/views/explore/contexts/spanTagsContext.tsx +++ b/static/app/views/explore/contexts/spanTagsContext.tsx @@ -1,27 +1,11 @@ import type React from 'react'; -import {createContext, useContext, useMemo} from 'react'; -import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse'; -import type {Tag, TagCollection} from 'sentry/types/group'; -import {DiscoverDatasets} from 'sentry/utils/discover/types'; -import {FieldKind} from 'sentry/utils/fields'; -import {useApiQuery} from 'sentry/utils/queryClient'; -import useOrganization from 'sentry/utils/useOrganization'; -import usePageFilters from 'sentry/utils/usePageFilters'; -import usePrevious from 'sentry/utils/usePrevious'; +import type {DiscoverDatasets} from 'sentry/utils/discover/types'; import { - SENTRY_SPAN_NUMBER_TAGS, - SENTRY_SPAN_STRING_TAGS, -} from 'sentry/views/explore/constants'; -import {useSpanFieldCustomTags} from 'sentry/views/performance/utils/useSpanFieldSupportedTags'; - -type TypedSpanTags = {number: TagCollection; string: TagCollection}; - -type TypedSpanTagsStatus = {numberTagsLoading: boolean; stringTagsLoading: boolean}; - -type TypedSpanTagsResult = TypedSpanTags & TypedSpanTagsStatus; - -export const SpanTagsContext = createContext(undefined); + TraceItemAttributeProvider, + useTraceItemAttributes, +} from 'sentry/views/explore/contexts/traceItemAttributeContext'; +import {TraceItemDataset} from 'sentry/views/explore/types'; interface SpanTagsProviderProps { children: React.ReactNode; @@ -29,73 +13,20 @@ interface SpanTagsProviderProps { enabled: boolean; } -export function SpanTagsProvider({children, dataset, enabled}: SpanTagsProviderProps) { - const {data: indexedTags} = useSpanFieldCustomTags({ - enabled: dataset === DiscoverDatasets.SPANS_INDEXED && enabled, - }); - - const isEAP = - dataset === DiscoverDatasets.SPANS_EAP || dataset === DiscoverDatasets.SPANS_EAP_RPC; - - const {tags: numberTags, isLoading: numberTagsLoading} = useTypedSpanTags({ - enabled: isEAP && enabled, - type: 'number', - }); - - const {tags: stringTags, isLoading: stringTagsLoading} = useTypedSpanTags({ - enabled: isEAP && enabled, - type: 'string', - }); - - const allNumberTags = useMemo(() => { - const measurements = SENTRY_SPAN_NUMBER_TAGS.map(measurement => [ - measurement, - {key: measurement, name: measurement, kind: FieldKind.MEASUREMENT}, - ]); - - if (dataset === DiscoverDatasets.SPANS_INDEXED) { - return {...Object.fromEntries(measurements)}; - } - - return {...numberTags, ...Object.fromEntries(measurements)}; - }, [dataset, numberTags]); - - const allStringTags = useMemo(() => { - const tags = SENTRY_SPAN_STRING_TAGS.map(tag => [ - tag, - {key: tag, name: tag, kind: FieldKind.TAG}, - ]); - - if (dataset === DiscoverDatasets.SPANS_INDEXED) { - return {...indexedTags, ...Object.fromEntries(tags)}; - } - - return {...stringTags, ...Object.fromEntries(tags)}; - }, [dataset, indexedTags, stringTags]); - - const tagsResult = useMemo(() => { - return { - number: allNumberTags, - string: allStringTags, - numberTagsLoading, - stringTagsLoading, - }; - }, [allNumberTags, allStringTags, numberTagsLoading, stringTagsLoading]); - - return {children}; +export function SpanTagsProvider({children, enabled}: SpanTagsProviderProps) { + return ( + + {children} + + ); } export function useSpanTags(type?: 'number' | 'string') { - const typedTagsResult = useContext(SpanTagsContext); - - if (typedTagsResult === undefined) { - throw new Error('useSpanTags must be used within a SpanTagsProvider'); - } - - if (type === 'number') { - return {tags: typedTagsResult.number, isLoading: typedTagsResult.numberTagsLoading}; - } - return {tags: typedTagsResult.string, isLoading: typedTagsResult.stringTagsLoading}; + const {attributes, isLoading} = useTraceItemAttributes(type); + return { + tags: attributes, + isLoading, + }; } export function useSpanTag(key: string) { @@ -104,66 +35,3 @@ export function useSpanTag(key: string) { return stringTags[key] ?? numberTags[key] ?? null; } - -function useTypedSpanTags({ - enabled, - type, -}: { - type: 'number' | 'string'; - enabled?: boolean; -}) { - const organization = useOrganization(); - const {selection} = usePageFilters(); - - const path = `/organizations/${organization.slug}/spans/fields/`; - const endpointOptions = { - query: { - project: selection.projects, - environment: selection.environments, - ...normalizeDateTimeParams(selection.datetime), - dataset: 'spans', - type, - }, - }; - - const result = useApiQuery([path, endpointOptions], { - enabled, - staleTime: 0, - refetchOnWindowFocus: false, - retry: false, - }); - - const tags: TagCollection = useMemo(() => { - const allTags: TagCollection = {}; - - for (const tag of result.data ?? []) { - // For now, skip all the sentry. prefixed tags as they - // should be covered by the static tags that will be - // merged with these results. - if (tag.key.startsWith('sentry.') || tag.key.startsWith('tags[sentry.')) { - continue; - } - - // EAP spans contain tags with illegal characters - // SnQL forbids `-` but is allowed in RPC. So add it back later - if ( - !/^[a-zA-Z0-9_.:]+$/.test(tag.key) && - !/^tags\[[a-zA-Z0-9_.:]+,number\]$/.test(tag.key) - ) { - continue; - } - - allTags[tag.key] = { - key: tag.key, - name: tag.name, - kind: type === 'number' ? FieldKind.MEASUREMENT : FieldKind.TAG, - }; - } - - return allTags; - }, [result.data, type]); - - const previousTags = usePrevious(tags, result.isLoading); - - return {tags: result.isLoading ? previousTags : tags, isLoading: result.isLoading}; -} diff --git a/static/app/views/explore/contexts/traceItemAttributeContext.tsx b/static/app/views/explore/contexts/traceItemAttributeContext.tsx index 0b06d0694525ea..e2689e73658159 100644 --- a/static/app/views/explore/contexts/traceItemAttributeContext.tsx +++ b/static/app/views/explore/contexts/traceItemAttributeContext.tsx @@ -11,7 +11,6 @@ import { } from 'sentry/views/explore/constants'; import {useTraceItemAttributeKeys} from 'sentry/views/explore/hooks/useTraceItemAttributeKeys'; import {TraceItemDataset} from 'sentry/views/explore/types'; -import {useSpanFieldCustomTags} from 'sentry/views/performance/utils/useSpanFieldSupportedTags'; type TypedTraceItemAttributes = {number: TagCollection; string: TagCollection}; @@ -38,10 +37,6 @@ export function TraceItemAttributeProvider({ traceItemType, enabled, }: TraceItemAttributeProviderProps) { - const {data: indexedTags} = useSpanFieldCustomTags({ - enabled: traceItemType === TraceItemDataset.SPANS && enabled, - }); - const {attributes: numberAttributes, isLoading: numberAttributesLoading} = useTraceItemAttributeKeys({ enabled, @@ -71,12 +66,8 @@ export function TraceItemAttributeProvider({ {key: tag, name: tag, kind: FieldKind.TAG}, ]); - if (traceItemType === TraceItemDataset.SPANS) { - return {...indexedTags, ...stringAttributes, ...Object.fromEntries(tags)}; - } - return {...stringAttributes, ...Object.fromEntries(tags)}; - }, [traceItemType, indexedTags, stringAttributes]); + }, [traceItemType, stringAttributes]); const attributesResult = useMemo(() => { return { diff --git a/static/app/views/explore/hooks/usePaginationAnalytics.tsx b/static/app/views/explore/hooks/usePaginationAnalytics.tsx new file mode 100644 index 00000000000000..7a62f6523d3e28 --- /dev/null +++ b/static/app/views/explore/hooks/usePaginationAnalytics.tsx @@ -0,0 +1,23 @@ +import {useCallback} from 'react'; + +import {trackAnalytics} from 'sentry/utils/analytics'; +import useOrganization from 'sentry/utils/useOrganization'; + +export function usePaginationAnalytics( + type: 'samples' | 'traces' | 'aggregates', + numResults: number +) { + const organization = useOrganization(); + + return useCallback( + (direction: string) => { + trackAnalytics('trace.explorer.table_pagination', { + direction, + type, + num_results: numResults, + organization, + }); + }, + [organization, numResults, type] + ); +} diff --git a/static/app/views/explore/hooks/useSortByFields.spec.tsx b/static/app/views/explore/hooks/useSortByFields.spec.tsx index 6a402b4e4eb0dd..b6dbd889df5523 100644 --- a/static/app/views/explore/hooks/useSortByFields.spec.tsx +++ b/static/app/views/explore/hooks/useSortByFields.spec.tsx @@ -40,7 +40,7 @@ describe('useSortByFields', () => { MockApiClient.clearMockResponses(); MockApiClient.addMockResponse({ - url: `/organizations/org-slug/spans/fields/`, + url: `/organizations/org-slug/trace-items/attributes/`, body: [], }); diff --git a/static/app/views/explore/hooks/useTraceItemAttributeKeys.spec.tsx b/static/app/views/explore/hooks/useTraceItemAttributeKeys.spec.tsx index b0990196136f32..2d8496d54f44df 100644 --- a/static/app/views/explore/hooks/useTraceItemAttributeKeys.spec.tsx +++ b/static/app/views/explore/hooks/useTraceItemAttributeKeys.spec.tsx @@ -83,7 +83,7 @@ describe('useTraceItemAttributeKeys', () => { (_url: string, options: {query?: Record}) => { const query = options?.query || {}; return ( - query.item_type === TraceItemDataset.LOGS && query.attribute_type === 'string' + query.itemType === TraceItemDataset.LOGS && query.attributeType === 'string' ); }, ], @@ -146,7 +146,7 @@ describe('useTraceItemAttributeKeys', () => { (_url: string, options: {query?: Record}) => { const query = options?.query || {}; return ( - query.item_type === TraceItemDataset.LOGS && query.attribute_type === 'number' + query.itemType === TraceItemDataset.LOGS && query.attributeType === 'number' ); }, ], diff --git a/static/app/views/explore/hooks/useTraceItemAttributeKeys.tsx b/static/app/views/explore/hooks/useTraceItemAttributeKeys.tsx index 71d014a33740ec..04e62442320889 100644 --- a/static/app/views/explore/hooks/useTraceItemAttributeKeys.tsx +++ b/static/app/views/explore/hooks/useTraceItemAttributeKeys.tsx @@ -38,8 +38,8 @@ export function useTraceItemAttributeKeys({ project: selection.projects, environment: selection.environments, ...normalizeDateTimeParams(selection.datetime), - item_type: traceItemType, - attribute_type: type, + itemType: traceItemType, + attributeType: type, }, }; diff --git a/static/app/views/explore/hooks/useTraceItemAttributeValues.spec.tsx b/static/app/views/explore/hooks/useTraceItemAttributeValues.spec.tsx index 2f97cc2813165c..dd02707e0f96a9 100644 --- a/static/app/views/explore/hooks/useTraceItemAttributeValues.spec.tsx +++ b/static/app/views/explore/hooks/useTraceItemAttributeValues.spec.tsx @@ -70,7 +70,7 @@ describe('useTraceItemAttributeValues', () => { match: [ (_url, options) => { const query = options?.query || {}; - return query.query === 'search-query'; + return query.substringMatch === 'search-query'; }, ], }); diff --git a/static/app/views/explore/hooks/useTraceItemAttributeValues.tsx b/static/app/views/explore/hooks/useTraceItemAttributeValues.tsx index 1632987aa9d0f6..f3dfb23763e1da 100644 --- a/static/app/views/explore/hooks/useTraceItemAttributeValues.tsx +++ b/static/app/views/explore/hooks/useTraceItemAttributeValues.tsx @@ -49,12 +49,12 @@ function traceItemAttributeValuesQueryKey({ type?: 'string' | 'number'; }): ApiQueryKey { const query: Record = { - item_type: traceItemType, - attribute_type: type, + itemType: traceItemType, + attributeType: type, }; if (search) { - query.query = search; + query.substringMatch = search; } if (projectIds?.length) { diff --git a/static/app/views/explore/hooks/useVisualizeFields.spec.tsx b/static/app/views/explore/hooks/useVisualizeFields.spec.tsx index 842a65ef0b2574..28af881bf75d36 100644 --- a/static/app/views/explore/hooks/useVisualizeFields.spec.tsx +++ b/static/app/views/explore/hooks/useVisualizeFields.spec.tsx @@ -39,7 +39,7 @@ describe('useVisualizeFields', () => { MockApiClient.clearMockResponses(); MockApiClient.addMockResponse({ - url: `/organizations/org-slug/spans/fields/`, + url: `/organizations/org-slug/trace-items/attributes/`, body: [], }); diff --git a/static/app/views/explore/logs/index.tsx b/static/app/views/explore/logs/index.tsx index f57049fee1af17..71badd7c549726 100644 --- a/static/app/views/explore/logs/index.tsx +++ b/static/app/views/explore/logs/index.tsx @@ -12,9 +12,9 @@ import useOrganization from 'sentry/utils/useOrganization'; import {LogsPageParamsProvider} from 'sentry/views/explore/contexts/logs/logsPageParams'; import {TraceItemAttributeProvider} from 'sentry/views/explore/contexts/traceItemAttributeContext'; import {LogsTabContent} from 'sentry/views/explore/logs/logsTab'; +import {logsPickableDays} from 'sentry/views/explore/logs/utils'; import {TraceItemDataset} from 'sentry/views/explore/types'; -import {limitMaxPickableDays} from 'sentry/views/explore/utils'; -import {usePrefersStackedNav} from 'sentry/views/nav/prefersStackedNav'; +import {usePrefersStackedNav} from 'sentry/views/nav/usePrefersStackedNav'; function FeedbackButton() { const openForm = useFeedbackForm(); @@ -44,14 +44,23 @@ function FeedbackButton() { export default function LogsPage() { const organization = useOrganization(); - const {defaultPeriod, maxPickableDays, relativeOptions} = - limitMaxPickableDays(organization); + const {defaultPeriod, maxPickableDays, relativeOptions} = logsPickableDays(); const prefersStackedNav = usePrefersStackedNav(); return ( - + diff --git a/static/app/views/explore/logs/logsTab.tsx b/static/app/views/explore/logs/logsTab.tsx index 4e0bf1cd375cd0..b512277f822b0b 100644 --- a/static/app/views/explore/logs/logsTab.tsx +++ b/static/app/views/explore/logs/logsTab.tsx @@ -43,14 +43,10 @@ import {useExploreLogsTable} from 'sentry/views/explore/logs/useLogsQuery'; import {usePersistentLogsPageParameters} from 'sentry/views/explore/logs/usePersistentLogsPageParameters'; import {ColumnEditorModal} from 'sentry/views/explore/tables/columnEditorModal'; import {TraceItemDataset} from 'sentry/views/explore/types'; -import type {DefaultPeriod, MaxPickableDays} from 'sentry/views/explore/utils'; +import type {PickableDays} from 'sentry/views/explore/utils'; import {useSortedTimeSeries} from 'sentry/views/insights/common/queries/useSortedTimeSeries'; -type LogsTabProps = { - defaultPeriod: DefaultPeriod; - maxPickableDays: MaxPickableDays; - relativeOptions: Record; -}; +type LogsTabProps = PickableDays; export function LogsTabContent({ defaultPeriod, diff --git a/static/app/views/explore/logs/logsTableRow.spec.tsx b/static/app/views/explore/logs/logsTableRow.spec.tsx index 2f812f095a7d34..1b1e4c1c1b4060 100644 --- a/static/app/views/explore/logs/logsTableRow.spec.tsx +++ b/static/app/views/explore/logs/logsTableRow.spec.tsx @@ -136,6 +136,21 @@ describe('logsTableRow', () => { expect(logTableRow).toBeInTheDocument(); await userEvent.click(logTableRow); + // Check that there is nothing overflowing in the table row + function hasNoWrapRecursive(element: HTMLElement) { + const children = element.children; + for (const child of children) { + if (getComputedStyle(child).whiteSpace === 'nowrap') { + return true; + } + if (child instanceof HTMLElement && hasNoWrapRecursive(child)) { + return true; + } + } + return false; + } + expect(hasNoWrapRecursive(logTableRow)).toBe(false); + // Check that the attribute values are rendered expect(screen.getByText(projects[0]!.id)).toBeInTheDocument(); expect(screen.getByText('456')).toBeInTheDocument(); diff --git a/static/app/views/explore/logs/logsTableRow.tsx b/static/app/views/explore/logs/logsTableRow.tsx index 1c17be116d9b40..b3a34a7def88ed 100644 --- a/static/app/views/explore/logs/logsTableRow.tsx +++ b/static/app/views/explore/logs/logsTableRow.tsx @@ -282,7 +282,9 @@ function LogRowDetails({ const theme = useTheme(); const logColors = getLogColors(level, theme); const attributes = - data?.attributes?.reduce((it, {name, value}) => ({...it, [name]: value}), {}) ?? {}; + data?.attributes?.reduce((it, {name, value}) => ({...it, [name]: value}), { + [OurLogKnownFieldKey.TIMESTAMP]: dataRow[OurLogKnownFieldKey.TIMESTAMP], + }) ?? {}; if (missingLogId) { return ( diff --git a/static/app/views/explore/logs/utils.tsx b/static/app/views/explore/logs/utils.tsx index e1deec92f48413..9831cd26d45f4b 100644 --- a/static/app/views/explore/logs/utils.tsx +++ b/static/app/views/explore/logs/utils.tsx @@ -21,6 +21,7 @@ import { OurLogKnownFieldKey, type OurLogsResponseItem, } from 'sentry/views/explore/logs/types'; +import type {PickableDays} from 'sentry/views/explore/utils'; const {warn, fmt} = Sentry.logger; @@ -188,3 +189,18 @@ export function getLogRowItem( export function adjustLogTraceID(traceID: string) { return traceID.replace(/-/g, ''); } + +export function logsPickableDays(): PickableDays { + const relativeOptions: Array<[string, React.ReactNode]> = [ + ['1h', t('Last hour')], + ['24h', t('Last 24 hours')], + ['7d', t('Last 7 days')], + ['14d', t('Last 14 days')], + ]; + + return { + defaultPeriod: '24h', + maxPickableDays: 14, + relativeOptions: Object.fromEntries(relativeOptions), + }; +} diff --git a/static/app/views/explore/multiQueryMode/content.spec.tsx b/static/app/views/explore/multiQueryMode/content.spec.tsx index f19016dcc1c2e8..fd054a917d7d68 100644 --- a/static/app/views/explore/multiQueryMode/content.spec.tsx +++ b/static/app/views/explore/multiQueryMode/content.spec.tsx @@ -44,7 +44,7 @@ describe('MultiQueryModeContent', function () { ); MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/spans/fields/`, + url: `/organizations/${organization.slug}/trace-items/attributes/`, method: 'GET', body: [{key: 'span.op', name: 'span.op'}], }); diff --git a/static/app/views/explore/multiQueryMode/index.tsx b/static/app/views/explore/multiQueryMode/index.tsx index 9399805616c709..342a09a4b795dd 100644 --- a/static/app/views/explore/multiQueryMode/index.tsx +++ b/static/app/views/explore/multiQueryMode/index.tsx @@ -13,7 +13,7 @@ import useOrganization from 'sentry/utils/useOrganization'; import {getTitleFromLocation} from 'sentry/views/explore/contexts/pageParamsContext/title'; import {MultiQueryModeContent} from 'sentry/views/explore/multiQueryMode/content'; import {StarSavedQueryButton} from 'sentry/views/explore/starSavedQueryButton'; -import {usePrefersStackedNav} from 'sentry/views/nav/prefersStackedNav'; +import {usePrefersStackedNav} from 'sentry/views/nav/usePrefersStackedNav'; import {makeTracesPathname} from 'sentry/views/traces/pathnames'; export default function MultiQueryMode() { diff --git a/static/app/views/explore/spans/spansTab.spec.tsx b/static/app/views/explore/spans/spansTab.spec.tsx index 9e4300cbdcea52..5614ee5a34cce9 100644 --- a/static/app/views/explore/spans/spansTab.spec.tsx +++ b/static/app/views/explore/spans/spansTab.spec.tsx @@ -157,11 +157,11 @@ describe('SpansTabContent', function () { // Add a group by, and leave one unselected await userEvent.click(aggregates); await userEvent.click(within(groupBy).getByRole('button', {name: '\u2014'})); - await userEvent.click(within(groupBy).getByRole('option', {name: 'release'})); + await userEvent.click(within(groupBy).getByRole('option', {name: 'project'})); - expect(groupBys).toEqual(['release']); + expect(groupBys).toEqual(['project']); await userEvent.click(within(groupBy).getByRole('button', {name: 'Add Group'})); - expect(groupBys).toEqual(['release', '']); + expect(groupBys).toEqual(['project', '']); await userEvent.click(samples); expect(fields).toEqual([ @@ -171,7 +171,7 @@ describe('SpansTabContent', function () { 'span.duration', 'transaction', 'timestamp', - 'release', + 'project', ]); }); diff --git a/static/app/views/explore/spans/spansTab.tsx b/static/app/views/explore/spans/spansTab.tsx index 33730aa0e1b08d..b65ffc1d6ee04c 100644 --- a/static/app/views/explore/spans/spansTab.tsx +++ b/static/app/views/explore/spans/spansTab.tsx @@ -66,22 +66,14 @@ import {useVisitQuery} from 'sentry/views/explore/hooks/useVisitQuery'; import {ExploreSpansTour, ExploreSpansTourContext} from 'sentry/views/explore/spans/tour'; import {ExploreTables} from 'sentry/views/explore/tables'; import {ExploreToolbar} from 'sentry/views/explore/toolbar'; -import { - combineConfidenceForSeries, - type DefaultPeriod, - type MaxPickableDays, -} from 'sentry/views/explore/utils'; +import {combineConfidenceForSeries, type PickableDays} from 'sentry/views/explore/utils'; import {useOnboardingProject} from 'sentry/views/insights/common/queries/useOnboardingProject'; import {Onboarding} from 'sentry/views/performance/onboarding'; // eslint-disable-next-line no-restricted-imports import QuotaExceededAlert from 'getsentry/components/performance/quotaExceededAlert'; -type SpanTabProps = { - defaultPeriod: DefaultPeriod; - maxPickableDays: MaxPickableDays; - relativeOptions: Record; -}; +type SpanTabProps = PickableDays; function SpansTabContentImpl({ defaultPeriod, @@ -417,7 +409,7 @@ export function SpansTabContent(props: SpanTabProps) { function checkIsAllowedSelection( selection: PageFilters, - maxPickableDays: MaxPickableDays + maxPickableDays: PickableDays['maxPickableDays'] ) { const maxPickableMinutes = maxPickableDays * 24 * 60; const selectedMinutes = getDiffInMinutes(selection.datetime); diff --git a/static/app/views/explore/tables/aggregatesTable.tsx b/static/app/views/explore/tables/aggregatesTable.tsx index ff0dadbe0519c7..ab0c02431dad5c 100644 --- a/static/app/views/explore/tables/aggregatesTable.tsx +++ b/static/app/views/explore/tables/aggregatesTable.tsx @@ -40,6 +40,7 @@ import { } from 'sentry/views/explore/contexts/pageParamsContext'; import {useSpanTags} from 'sentry/views/explore/contexts/spanTagsContext'; import type {AggregatesTableResult} from 'sentry/views/explore/hooks/useExploreAggregatesTable'; +import {usePaginationAnalytics} from 'sentry/views/explore/hooks/usePaginationAnalytics'; import {TOP_EVENTS_LIMIT, useTopEvents} from 'sentry/views/explore/hooks/useTopEvents'; import {viewSamplesTarget} from 'sentry/views/explore/utils'; @@ -84,6 +85,11 @@ export function AggregatesTable({ const palette = theme.chart.getColorPalette(numberOfRowsNeedingColor - 2); // -2 because getColorPalette artificially adds 1, I'm not sure why + const paginationAnalyticsEvent = usePaginationAnalytics( + 'aggregates', + result.data?.length ?? 0 + ); + return ( @@ -207,7 +213,10 @@ export function AggregatesTable({ )}
- +
); } diff --git a/static/app/views/explore/tables/spansTable.tsx b/static/app/views/explore/tables/spansTable.tsx index 016ea644486785..b3a5289a836ea6 100644 --- a/static/app/views/explore/tables/spansTable.tsx +++ b/static/app/views/explore/tables/spansTable.tsx @@ -28,6 +28,7 @@ import { } from 'sentry/views/explore/contexts/pageParamsContext'; import {useSpanTags} from 'sentry/views/explore/contexts/spanTagsContext'; import type {SpansTableResult} from 'sentry/views/explore/hooks/useExploreSpansTable'; +import {usePaginationAnalytics} from 'sentry/views/explore/hooks/usePaginationAnalytics'; import {FieldRenderer} from './fieldRenderer'; @@ -61,6 +62,11 @@ export function SpansTable({spansTableResult}: SpansTableProps) { const {tags: numberTags} = useSpanTags('number'); const {tags: stringTags} = useSpanTags('string'); + const paginationAnalyticsEvent = usePaginationAnalytics( + 'samples', + result.data?.length ?? 0 + ); + return ( @@ -154,7 +160,10 @@ export function SpansTable({spansTableResult}: SpansTableProps) { )}
- +
); } diff --git a/static/app/views/explore/tables/tracesTable/index.tsx b/static/app/views/explore/tables/tracesTable/index.tsx index b770c817b5192a..a526b6125829cc 100644 --- a/static/app/views/explore/tables/tracesTable/index.tsx +++ b/static/app/views/explore/tables/tracesTable/index.tsx @@ -24,6 +24,7 @@ import usePageFilters from 'sentry/utils/usePageFilters'; import useProjects from 'sentry/utils/useProjects'; import {useExploreQuery} from 'sentry/views/explore/contexts/pageParamsContext'; import type {TracesTableResult} from 'sentry/views/explore/hooks/useExploreTracesTable'; +import {usePaginationAnalytics} from 'sentry/views/explore/hooks/usePaginationAnalytics'; import type {TraceResult} from 'sentry/views/explore/hooks/useTraces'; import { Description, @@ -59,6 +60,11 @@ export function TracesTable({tracesTableResult}: TracesTableProps) { const showErrorState = !isPending && isError; const showEmptyState = !isPending && !showErrorState && (data?.data?.length ?? 0) === 0; + const paginationAnalyticsEvent = usePaginationAnalytics( + 'traces', + data?.data?.length ?? 0 + ); + return ( @@ -124,7 +130,10 @@ export function TracesTable({tracesTableResult}: TracesTableProps) { ))} - + ); } diff --git a/static/app/views/explore/toolbar/index.spec.tsx b/static/app/views/explore/toolbar/index.spec.tsx index 8d1a1e0478e83a..39ee2c42e28fb0 100644 --- a/static/app/views/explore/toolbar/index.spec.tsx +++ b/static/app/views/explore/toolbar/index.spec.tsx @@ -605,14 +605,15 @@ describe('ExploreToolbar', function () { const section = screen.getByTestId('section-save-as'); await userEvent.click(within(section).getByText(/Save as/)); - await userEvent.hover(within(section).getByText('An Alert for')); - await userEvent.click(screen.getByText('count(spans)')); + await userEvent.hover( + within(section).getByRole('menuitemradio', {name: 'An Alert for'}) + ); + await userEvent.click( + await within(section).findByRole('menuitemradio', {name: 'count(spans)'}) + ); expect(router.push).toHaveBeenCalledWith({ - pathname: '/organizations/org-slug/alerts/new/metric/', - query: expect.objectContaining({ - aggregate: 'count(span.duration)', - dataset: 'events_analytics_platform', - }), + pathname: + '/organizations/org-slug/alerts/new/metric/?aggregate=count%28span.duration%29&dataset=events_analytics_platform&eventTypes=transaction&interval=1h&project=proj-slug&query=&statsPeriod=7d', }); }); diff --git a/static/app/views/explore/utils.tsx b/static/app/views/explore/utils.tsx index ac9452311f8f08..2c6e7187d7736b 100644 --- a/static/app/views/explore/utils.tsx +++ b/static/app/views/explore/utils.tsx @@ -238,14 +238,16 @@ export function viewSamplesTarget( }); } -export type MaxPickableDays = 7 | 14 | 30; -export type DefaultPeriod = '7d' | '14d' | '30d'; +type MaxPickableDays = 7 | 14 | 30; +type DefaultPeriod = '24h' | '7d' | '14d' | '30d'; -export function limitMaxPickableDays(organization: Organization): { +export interface PickableDays { defaultPeriod: DefaultPeriod; maxPickableDays: MaxPickableDays; relativeOptions: Record; -} { +} + +export function limitMaxPickableDays(organization: Organization): PickableDays { const defaultPeriods: Record = { 7: '7d', 14: '14d', diff --git a/static/app/views/feedback/feedbackListPage.tsx b/static/app/views/feedback/feedbackListPage.tsx index 5de6e7eb7ad02d..e22bfcfac108a5 100644 --- a/static/app/views/feedback/feedbackListPage.tsx +++ b/static/app/views/feedback/feedbackListPage.tsx @@ -26,7 +26,7 @@ import {space} from 'sentry/styles/space'; import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; import useProjects from 'sentry/utils/useProjects'; -import {usePrefersStackedNav} from 'sentry/views/nav/prefersStackedNav'; +import {usePrefersStackedNav} from 'sentry/views/nav/usePrefersStackedNav'; import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight'; export default function FeedbackListPage() { diff --git a/static/app/views/insights/common/components/tableCells/renderHeadCell.tsx b/static/app/views/insights/common/components/tableCells/renderHeadCell.tsx index 7d41a088f3f1b3..d198a4ae35e98b 100644 --- a/static/app/views/insights/common/components/tableCells/renderHeadCell.tsx +++ b/static/app/views/insights/common/components/tableCells/renderHeadCell.tsx @@ -77,6 +77,8 @@ const SORTABLE_FIELDS = new Set([ 'p50(span.duration)', 'p95(span.duration)', 'failure_rate()', + 'performance_score(measurements.score.total)', + 'count_unique(user)', ]); const NUMERIC_FIELDS = new Set([ diff --git a/static/app/views/insights/common/utils/useSamplesDrawer.tsx b/static/app/views/insights/common/utils/useSamplesDrawer.tsx index d3d0e60d12867f..70a788af895324 100644 --- a/static/app/views/insights/common/utils/useSamplesDrawer.tsx +++ b/static/app/views/insights/common/utils/useSamplesDrawer.tsx @@ -24,7 +24,7 @@ export function useSamplesDrawer({ onClose = undefined, }: UseSamplesDrawerProps): void { const organization = useOrganization(); - const {openDrawer, closeDrawer, isDrawerOpen} = useDrawer(); + const {openDrawer, isDrawerOpen} = useDrawer(); const navigate = useNavigate(); const location = useLocation(); @@ -99,7 +99,7 @@ export function useSamplesDrawer({ if (shouldDrawerOpen) { openSamplesDrawer(); } - }, [shouldDrawerOpen, openSamplesDrawer, closeDrawer]); + }, [shouldDrawerOpen, openSamplesDrawer]); } const FullHeightWrapper = styled('div')` diff --git a/static/app/views/insights/common/utils/useStarredSegment.tsx b/static/app/views/insights/common/utils/useStarredSegment.tsx index ee875c491f882c..2a0484f2f66bf5 100644 --- a/static/app/views/insights/common/utils/useStarredSegment.tsx +++ b/static/app/views/insights/common/utils/useStarredSegment.tsx @@ -34,25 +34,25 @@ export function useStarredSegment({initialIsStarred, projectId, segmentName}: Pr segment_name: segmentName, }; - const onError = () => { - addErrorMessage(t('Failed to star transaction')); + const onError = (message: string) => { + addErrorMessage(message); setIsStarred(!isStarred); }; - const onSuccess = () => { - addSuccessMessage(t('Transaction starred')); + const onSuccess = (message: string) => { + addSuccessMessage(message); }; const {mutate: starTransaction, ...starTransactionResult} = useMutation({ mutationFn: () => api.requestPromise(url, {method: 'POST', data}), - onSuccess, - onError, + onSuccess: () => onSuccess(t('Transaction starred')), + onError: () => onError(t('Failed to star transaction')), }); const {mutate: unstarTransaction, ...unstarTransactionResult} = useMutation({ mutationFn: () => api.requestPromise(url, {method: 'DELETE', data}), - onSuccess, - onError, + onSuccess: () => onSuccess(t('Transaction unstarred')), + onError: () => onError(t('Failed to unstar transaction')), }); const isPending = diff --git a/static/app/views/insights/pages/backend/backendOverviewPage.tsx b/static/app/views/insights/pages/backend/backendOverviewPage.tsx index 04aaa6570a6a13..54bf7749912e8a 100644 --- a/static/app/views/insights/pages/backend/backendOverviewPage.tsx +++ b/static/app/views/insights/pages/backend/backendOverviewPage.tsx @@ -213,6 +213,7 @@ function EAPBackendOverviewPage() { 'p50(span.duration)', 'p95(span.duration)', 'failure_rate()', + 'count_unique(user)', 'time_spent_percentage(span.duration)', 'sum(span.duration)', ], diff --git a/static/app/views/insights/pages/backend/backendTable.tsx b/static/app/views/insights/pages/backend/backendTable.tsx index d31f2b18e6ef67..267f16ea4b152d 100644 --- a/static/app/views/insights/pages/backend/backendTable.tsx +++ b/static/app/views/insights/pages/backend/backendTable.tsx @@ -1,11 +1,14 @@ import {type Theme, useTheme} from '@emotion/react'; +import styled from '@emotion/styled'; import type {Location} from 'history'; import type {GridColumnHeader} from 'sentry/components/gridEditable'; import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable'; import type {CursorHandler} from 'sentry/components/pagination'; import Pagination from 'sentry/components/pagination'; +import {IconStar} from 'sentry/icons'; import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; import type {Organization} from 'sentry/types/organization'; import type {EventsMetaType} from 'sentry/utils/discover/eventView'; import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers'; @@ -15,6 +18,7 @@ import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; import {renderHeadCell} from 'sentry/views/insights/common/components/tableCells/renderHeadCell'; +import {StarredSegmentCell} from 'sentry/views/insights/common/components/tableCells/starredSegmentCell'; import {QueryParameterNames} from 'sentry/views/insights/common/views/queryParameters'; import {DataTitles} from 'sentry/views/insights/common/views/spans/types'; import {TransactionCell} from 'sentry/views/insights/pages/transactionCell'; @@ -31,6 +35,7 @@ type Row = Pick< | 'p50(span.duration)' | 'p95(span.duration)' | 'failure_rate()' + | 'count_unique(user)' | 'time_spent_percentage(span.duration)' | 'sum(span.duration)' >; @@ -45,16 +50,12 @@ type Column = GridColumnHeader< | 'p50(span.duration)' | 'p95(span.duration)' | 'failure_rate()' + | 'count_unique(user)' | 'time_spent_percentage(span.duration)' | 'sum(span.duration)' >; const COLUMN_ORDER: Column[] = [ - { - key: 'is_starred_transaction', - name: t('Starred'), - width: COL_WIDTH_UNDEFINED, - }, { key: 'request.method', name: t('HTTP Method'), @@ -95,6 +96,11 @@ const COLUMN_ORDER: Column[] = [ name: t('Failure Rate'), width: COL_WIDTH_UNDEFINED, }, + { + key: 'count_unique(user)', + name: t('Users'), + width: COL_WIDTH_UNDEFINED, + }, { key: 'time_spent_percentage(span.duration)', name: DataTitles.timeSpent, @@ -112,6 +118,7 @@ const SORTABLE_FIELDS = [ 'p50(span.duration)', 'p95(span.duration)', 'failure_rate()', + 'count_unique(user)', 'time_spent_percentage(span.duration)', ] as const; @@ -166,6 +173,8 @@ export function BackendOverviewTable({response, sort}: Props) { }, ]} grid={{ + renderPrependColumns, + prependColumnWidths: ['max-content'], renderHeadCell: column => renderHeadCell({ column, @@ -181,6 +190,24 @@ export function BackendOverviewTable({response, sort}: Props) { ); } +function renderPrependColumns(isHeader: boolean, row?: Row | undefined) { + if (isHeader) { + return []; + } + + if (!row) { + return []; + } + return [ + , + ]; +} + function renderBodyCell( column: Column, row: Row, @@ -212,3 +239,7 @@ function renderBodyCell( theme, }); } + +export const StyledIconStar = styled(IconStar)` + margin-left: ${space(0.25)}; +`; diff --git a/static/app/views/insights/pages/frontend/frontendOverviewPage.tsx b/static/app/views/insights/pages/frontend/frontendOverviewPage.tsx index ee78963c3ff069..4067801e41489e 100644 --- a/static/app/views/insights/pages/frontend/frontendOverviewPage.tsx +++ b/static/app/views/insights/pages/frontend/frontendOverviewPage.tsx @@ -42,9 +42,9 @@ import {FrontendHeader} from 'sentry/views/insights/pages/frontend/frontendPageH import {OldFrontendOverviewPage} from 'sentry/views/insights/pages/frontend/oldFrontendOverviewPage'; import { DEFAULT_SORT, + EAP_OVERVIEW_PAGE_ALLOWED_OPS, FRONTEND_LANDING_TITLE, FRONTEND_PLATFORMS, - OVERVIEW_PAGE_ALLOWED_OPS, } from 'sentry/views/insights/pages/frontend/settings'; import {NextJsOverviewPage} from 'sentry/views/insights/pages/platform/nextjs'; import {useIsNextJsInsightsEnabled} from 'sentry/views/insights/pages/platform/nextjs/features'; @@ -111,7 +111,7 @@ function EAPOverviewPage() { const existingQuery = new MutableSearch(eventView.query); // TODO - this query is getting complicated, once were on EAP, we should consider moving this to the backend existingQuery.addOp('('); - existingQuery.addDisjunctionFilterValues('span.op', OVERVIEW_PAGE_ALLOWED_OPS); + existingQuery.addFilterValues('span.op', EAP_OVERVIEW_PAGE_ALLOWED_OPS); // add disjunction filter creates a very long query as it seperates conditions with OR, project ids are numeric with no spaces, so we can use a comma seperated list if (selectedFrontendProjects.length > 0) { existingQuery.addOp('OR'); @@ -127,8 +127,9 @@ function EAPOverviewPage() { const showOnboarding = onboardingProject !== undefined; const doubleChartRowCharts = [ - PerformanceWidgetSetting.SLOW_HTTP_OPS, - PerformanceWidgetSetting.SLOW_RESOURCE_OPS, + PerformanceWidgetSetting.MOST_TIME_CONSUMING_DOMAINS, + PerformanceWidgetSetting.MOST_TIME_CONSUMING_RESOURCES, + PerformanceWidgetSetting.HIGHEST_OPPORTUNITY_PAGES, ]; const tripleChartRowCharts = filterAllowedChartsMetrics( organization, @@ -144,12 +145,6 @@ function EAPOverviewPage() { mepSetting ); - if (organization.features.includes('insights-initial-modules')) { - doubleChartRowCharts.unshift(PerformanceWidgetSetting.MOST_TIME_CONSUMING_DOMAINS); - doubleChartRowCharts.unshift(PerformanceWidgetSetting.MOST_TIME_CONSUMING_RESOURCES); - doubleChartRowCharts.unshift(PerformanceWidgetSetting.HIGHEST_OPPORTUNITY_PAGES); - } - const getFreeTextFromQuery = (query: string) => { const conditions = new MutableSearch(query); const transactionValues = conditions.getFilterValues('transaction'); @@ -188,8 +183,6 @@ function EAPOverviewPage() { decodeSorts(location.query?.sort).find(isAValidSort) ?? DEFAULT_SORT, ]; - existingQuery.addFilterValue('is_transaction', 'true'); - const response = useEAPSpans( { search: existingQuery, @@ -197,13 +190,14 @@ function EAPOverviewPage() { fields: [ 'is_starred_transaction', 'transaction', - 'span.op', 'project', 'epm()', 'p50(span.duration)', 'p95(span.duration)', 'failure_rate()', 'time_spent_percentage(span.duration)', + 'performance_score(measurements.score.total)', + 'count_unique(user)', 'sum(span.duration)', ], }, diff --git a/static/app/views/insights/pages/frontend/frontendOverviewTable.tsx b/static/app/views/insights/pages/frontend/frontendOverviewTable.tsx index f18550fe77618f..5694e79d4c528c 100644 --- a/static/app/views/insights/pages/frontend/frontendOverviewTable.tsx +++ b/static/app/views/insights/pages/frontend/frontendOverviewTable.tsx @@ -15,8 +15,10 @@ import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; import {renderHeadCell} from 'sentry/views/insights/common/components/tableCells/renderHeadCell'; +import {StarredSegmentCell} from 'sentry/views/insights/common/components/tableCells/starredSegmentCell'; import {QueryParameterNames} from 'sentry/views/insights/common/views/queryParameters'; import {DataTitles} from 'sentry/views/insights/common/views/spans/types'; +import {StyledIconStar} from 'sentry/views/insights/pages/backend/backendTable'; import {TransactionCell} from 'sentry/views/insights/pages/transactionCell'; import type {EAPSpanResponse} from 'sentry/views/insights/types'; @@ -24,45 +26,37 @@ type Row = Pick< EAPSpanResponse, | 'is_starred_transaction' | 'transaction' - | 'span.op' | 'project' | 'epm()' | 'p50(span.duration)' | 'p95(span.duration)' | 'failure_rate()' | 'time_spent_percentage(span.duration)' + | 'count_unique(user)' | 'sum(span.duration)' + | 'performance_score(measurements.score.total)' >; type Column = GridColumnHeader< | 'is_starred_transaction' | 'transaction' - | 'span.op' | 'project' | 'epm()' | 'p50(span.duration)' | 'p95(span.duration)' | 'failure_rate()' | 'time_spent_percentage(span.duration)' + | 'count_unique(user)' | 'sum(span.duration)' + | 'performance_score(measurements.score.total)' >; const COLUMN_ORDER: Column[] = [ - { - key: 'is_starred_transaction', - name: t('Starred'), - width: COL_WIDTH_UNDEFINED, - }, { key: 'transaction', name: t('Transaction'), width: COL_WIDTH_UNDEFINED, }, - { - key: 'span.op', - name: t('Operation'), - width: COL_WIDTH_UNDEFINED, - }, { key: 'project', name: t('Project'), @@ -88,23 +82,34 @@ const COLUMN_ORDER: Column[] = [ name: t('Failure Rate'), width: COL_WIDTH_UNDEFINED, }, + { + key: 'count_unique(user)', + name: t('Users'), + width: COL_WIDTH_UNDEFINED, + }, { key: 'time_spent_percentage(span.duration)', name: DataTitles.timeSpent, width: COL_WIDTH_UNDEFINED, }, + { + key: 'performance_score(measurements.score.total)', + name: t('Perf Score'), + width: COL_WIDTH_UNDEFINED, + }, ]; const SORTABLE_FIELDS = [ 'is_starred_transaction', 'transaction', - 'span.op', 'project', 'epm()', 'p50(span.duration)', 'p95(span.duration)', 'failure_rate()', + 'count_unique(user)', 'time_spent_percentage(span.duration)', + 'performance_score(measurements.score.total)', ] as const; export type ValidSort = Sort & { @@ -158,6 +163,8 @@ export function FrontendOverviewTable({response, sort}: Props) { }, ]} grid={{ + prependColumnWidths: ['max-content'], + renderPrependColumns, renderHeadCell: column => renderHeadCell({ column, @@ -173,6 +180,24 @@ export function FrontendOverviewTable({response, sort}: Props) { ); } +function renderPrependColumns(isHeader: boolean, row?: Row | undefined) { + if (isHeader) { + return []; + } + + if (!row) { + return []; + } + return [ + , + ]; +} + function renderBodyCell( column: Column, row: Row, @@ -186,13 +211,7 @@ function renderBodyCell( } if (column.key === 'transaction') { - return ( - - ); + return ; } const renderer = getFieldRenderer(column.key, meta.fields, false); diff --git a/static/app/views/insights/pages/frontend/settings.ts b/static/app/views/insights/pages/frontend/settings.ts index 30b2e7e26f27b7..f143cc18feb631 100644 --- a/static/app/views/insights/pages/frontend/settings.ts +++ b/static/app/views/insights/pages/frontend/settings.ts @@ -8,6 +8,20 @@ export const FRONTEND_LANDING_SUB_PATH = 'frontend'; export const FRONTEND_LANDING_TITLE = t('Frontend'); export const FRONTEND_SIDEBAR_LABEL = t('Frontend'); +export const EAP_OVERVIEW_PAGE_ALLOWED_OPS = [ + 'pageload', + 'navigation', + 'ui.render', + 'interaction', + 'ui.interaction', + 'ui.interaction.click', + 'ui.interaction.hover', + 'ui.interaction.drag', + 'ui.interaction.press', + 'ui.webvital.cls', + 'ui.webvital.fcp', +]; + export const OVERVIEW_PAGE_ALLOWED_OPS = [ 'pageload', 'navigation', diff --git a/static/app/views/insights/pages/mobile/mobileOverviewPage.tsx b/static/app/views/insights/pages/mobile/mobileOverviewPage.tsx index 9a7ec460ed30ca..e8ffcbaf75fac7 100644 --- a/static/app/views/insights/pages/mobile/mobileOverviewPage.tsx +++ b/static/app/views/insights/pages/mobile/mobileOverviewPage.tsx @@ -202,6 +202,7 @@ function EAPMobileOverviewPage() { 'p50(span.duration)', 'p95(span.duration)', 'failure_rate()', + 'count_unique(user)', 'time_spent_percentage(span.duration)', 'sum(span.duration)', ], diff --git a/static/app/views/insights/pages/mobile/mobileOverviewTable.tsx b/static/app/views/insights/pages/mobile/mobileOverviewTable.tsx index 38305a750b9db7..5025a8f86c0ea9 100644 --- a/static/app/views/insights/pages/mobile/mobileOverviewTable.tsx +++ b/static/app/views/insights/pages/mobile/mobileOverviewTable.tsx @@ -15,8 +15,10 @@ import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; import {renderHeadCell} from 'sentry/views/insights/common/components/tableCells/renderHeadCell'; +import {StarredSegmentCell} from 'sentry/views/insights/common/components/tableCells/starredSegmentCell'; import {QueryParameterNames} from 'sentry/views/insights/common/views/queryParameters'; import {DataTitles} from 'sentry/views/insights/common/views/spans/types'; +import {StyledIconStar} from 'sentry/views/insights/pages/backend/backendTable'; import {TransactionCell} from 'sentry/views/insights/pages/transactionCell'; import type {EAPSpanResponse} from 'sentry/views/insights/types'; @@ -30,6 +32,7 @@ type Row = Pick< | 'p50(span.duration)' | 'p95(span.duration)' | 'failure_rate()' + | 'count_unique(user)' | 'time_spent_percentage(span.duration)' | 'sum(span.duration)' >; @@ -43,16 +46,12 @@ type Column = GridColumnHeader< | 'p50(span.duration)' | 'p95(span.duration)' | 'failure_rate()' + | 'count_unique(user)' | 'time_spent_percentage(span.duration)' | 'sum(span.duration)' >; const COLUMN_ORDER: Column[] = [ - { - key: 'is_starred_transaction', - name: t('Starred'), - width: COL_WIDTH_UNDEFINED, - }, { key: 'transaction', name: t('Transaction'), @@ -88,6 +87,11 @@ const COLUMN_ORDER: Column[] = [ name: t('Failure Rate'), width: COL_WIDTH_UNDEFINED, }, + { + key: 'count_unique(user)', + name: t('Users'), + width: COL_WIDTH_UNDEFINED, + }, { key: 'time_spent_percentage(span.duration)', name: DataTitles.timeSpent, @@ -104,6 +108,7 @@ const SORTABLE_FIELDS = [ 'p50(span.duration)', 'p95(span.duration)', 'failure_rate()', + 'count_unique(user)', 'time_spent_percentage(span.duration)', ] as const; @@ -158,6 +163,8 @@ export function MobileOverviewTable({response, sort}: Props) { }, ]} grid={{ + prependColumnWidths: ['max-content'], + renderPrependColumns, renderHeadCell: column => renderHeadCell({ column, @@ -173,6 +180,24 @@ export function MobileOverviewTable({response, sort}: Props) { ); } +function renderPrependColumns(isHeader: boolean, row?: Row | undefined) { + if (isHeader) { + return []; + } + + if (!row) { + return []; + } + return [ + , + ]; +} + function renderBodyCell( column: Column, row: Row, diff --git a/static/app/views/insights/types.tsx b/static/app/views/insights/types.tsx index 72689bcb0dbf3b..0bb50199680849 100644 --- a/static/app/views/insights/types.tsx +++ b/static/app/views/insights/types.tsx @@ -72,8 +72,17 @@ export enum SpanFields { CACHE_HIT = 'cache.hit', IS_STARRED_TRANSACTION = 'is_starred_transaction', SPAN_DURATION = 'span.duration', + USER = 'user', } +type WebVitalsMeasurements = + | 'measurements.score.cls' + | 'measurements.score.fcp' + | 'measurements.score.inp' + | 'measurements.score.lcp' + | 'measurements.score.ttfb' + | 'measurements.score.total'; + type SpanBooleanFields = | SpanFields.CACHE_HIT | SpanFields.IS_TRANSACTION @@ -172,6 +181,8 @@ export const SPAN_FUNCTIONS = [ 'failure_rate', ] as const; +export const WEB_VITAL_FUNCTIONS = ['performance_score'] as const; + type BreakpointCondition = 'less' | 'greater'; type RegressionFunctions = [ @@ -184,6 +195,8 @@ type SpanAnyFunction = `any(${string})`; export type SpanFunctions = (typeof SPAN_FUNCTIONS)[number]; +type WebVitalsFunctions = (typeof WEB_VITAL_FUNCTIONS)[number]; + export type SpanMetricsResponse = { [Property in SpanNumberFields as `${Aggregate}(${Property})`]: number; } & { @@ -225,6 +238,8 @@ export type EAPSpanResponse = { [Property in SpanNumberFields as `${Aggregate}(${Property})`]: number; } & { [Property in SpanFunctions as `${Property}()`]: number; +} & { + [Property in WebVitalsMeasurements as `${WebVitalsFunctions}(${Property})`]: number; } & { [Property in SpanStringFields as `${Property}`]: string; } & { @@ -244,6 +259,8 @@ export type EAPSpanResponse = { | `${Property}(${string},${string},${string})`]: number; } & { [SpanMetricsField.USER_GEO_SUBREGION]: SubregionCode; + } & { + [Property in SpanFields as `count_unique(${Property})`]: number; }; export type EAPSpanProperty = keyof EAPSpanResponse; diff --git a/static/app/views/issueDetails/actions/index.spec.tsx b/static/app/views/issueDetails/actions/index.spec.tsx index ed912ac94f3f96..42a780064cce4b 100644 --- a/static/app/views/issueDetails/actions/index.spec.tsx +++ b/static/app/views/issueDetails/actions/index.spec.tsx @@ -37,7 +37,7 @@ const group = GroupFixture({ const issuePlatformGroup = GroupFixture({ id: '1338', - issueCategory: IssueCategory.PERFORMANCE, + issueCategory: IssueCategory.FEEDBACK, project, }); @@ -182,30 +182,6 @@ describe('GroupActions', function () { }); }); - it('opens share modal from more actions dropdown', async () => { - const org = { - ...organization, - features: ['shared-issues'], - }; - - render( - - - - , - { - organization: org, - deprecatedRouterMocks: true, - } - ); - - await userEvent.click(screen.getByLabelText('More Actions')); - await userEvent.click(await screen.findByText('Publish')); - - const modal = screen.getByRole('dialog'); - expect(within(modal).getByText('Publish Issue')).toBeInTheDocument(); - }); - describe('delete', function () { it('opens delete confirm modal from more actions dropdown', async () => { const router = RouterFixture(); @@ -253,58 +229,53 @@ describe('GroupActions', function () { }); it('delete for issue platform', async () => { + const router = RouterFixture(); const org = OrganizationFixture({ - access: ['event:admin'], // Delete is only shown if this is present + ...organization, + access: [...organization.access, 'event:admin'], }); - render( - , - { - organization: org, - deprecatedRouterMocks: true, - } - ); - - await userEvent.click(screen.getByLabelText('More Actions')); - expect(await screen.findByTestId('delete-issue')).toHaveAttribute( - 'aria-disabled', - 'true' - ); - expect(await screen.findByTestId('delete-and-discard')).toHaveAttribute( - 'aria-disabled', - 'true' - ); - }); - it('delete for issue platform is enabled with feature flag', async () => { - const org = OrganizationFixture({ - access: ['event:admin'], - features: ['issue-platform-deletion-ui'], + MockApiClient.addMockResponse({ + url: `/projects/${org.slug}/${project.slug}/issues/`, + method: 'PUT', + body: {}, + }); + const deleteMock = MockApiClient.addMockResponse({ + url: `/projects/${org.slug}/${project.slug}/issues/`, + method: 'DELETE', + body: {}, }); render( - , + + + + , { + router, organization: org, deprecatedRouterMocks: true, } ); await userEvent.click(screen.getByLabelText('More Actions')); - expect(await screen.findByTestId('delete-issue')).not.toHaveAttribute( - 'aria-disabled' - ); - expect(await screen.findByTestId('delete-and-discard')).toHaveAttribute( - 'aria-disabled', - 'true' - ); + await userEvent.click(await screen.findByRole('menuitemradio', {name: 'Delete'})); + + const modal = screen.getByRole('dialog'); + expect( + within(modal).getByText(/Deleting this issue is permanent/) + ).toBeInTheDocument(); + + await userEvent.click(within(modal).getByRole('button', {name: 'Delete'})); + + expect(deleteMock).toHaveBeenCalled(); + expect(router.push).toHaveBeenCalledWith({ + pathname: `/organizations/${org.slug}/issues/`, + query: {project: project.id}, + }); }); }); diff --git a/static/app/views/issueDetails/actions/index.tsx b/static/app/views/issueDetails/actions/index.tsx index 0091f3b6b2a613..e7a0e6b7926977 100644 --- a/static/app/views/issueDetails/actions/index.tsx +++ b/static/app/views/issueDetails/actions/index.tsx @@ -48,7 +48,6 @@ import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; import {hasDatasetSelector} from 'sentry/views/dashboards/utils'; import {NewIssueExperienceButton} from 'sentry/views/issueDetails/actions/newIssueExperienceButton'; -import PublishIssueModal from 'sentry/views/issueDetails/actions/publishModal'; import ShareIssueModal from 'sentry/views/issueDetails/actions/shareModal'; import SubscribeAction from 'sentry/views/issueDetails/actions/subscribeAction'; import {Divider} from 'sentry/views/issueDetails/divider'; @@ -95,24 +94,14 @@ export function GroupActions({group, project, disabled, event}: GroupActionsProp archiveUntilOccurrence: archiveUntilOccurrenceCap, delete: deleteCap, deleteAndDiscard: deleteDiscardCap, - share: shareCap, resolve: resolveCap, resolveInRelease: resolveInReleaseCap, + share: shareCap, }, customCopy: {resolution: resolvedCopyCap}, discover: discoverCap, } = config; - // Update the deleteCap to be enabled if the feature flag is present - const hasIssuePlatformDeletionUI = organization.features.includes( - 'issue-platform-deletion-ui' - ); - const updatedDeleteCap = { - ...deleteCap, - enabled: hasIssuePlatformDeletionUI || deleteCap.enabled, - disabledReason: hasIssuePlatformDeletionUI ? null : deleteCap.disabledReason, - }; - const getDiscoverUrl = () => { const {title, type, shortId} = group; @@ -225,7 +214,7 @@ export function GroupActions({group, project, disabled, event}: GroupActionsProp openReprocessEventModal({organization, groupId: group.id}); }; - const onToggleShare = () => { + const onTogglePublicShare = () => { const newIsPublic = !group.isPublic; if (newIsPublic) { trackAnalytics('issue.shared_publicly', { @@ -345,23 +334,9 @@ export function GroupActions({group, project, disabled, event}: GroupActionsProp organization={organization} groupId={group.id} event={event} - /> - )); - }; - - const openPublishModal = () => { - trackAnalytics('issue_details.publish_issue_modal_opened', { - organization, - streamline: hasStreamlinedUI, - ...getAnalyticsDataForGroup(group), - }); - openModal(modalProps => ( - )); }; @@ -472,6 +447,7 @@ export function GroupActions({group, project, disabled, event}: GroupActionsProp icon={} aria-label={t('Share')} title={t('Share Issue')} + disabled={disabled} analyticsEventKey="issue_details.share_action_clicked" analyticsEventName="Issue Details: Share Action Clicked" /> @@ -525,13 +501,6 @@ export function GroupActions({group, project, disabled, event}: GroupActionsProp details: !group.inbox || disabled ? t('Issue has been reviewed') : undefined, onAction: () => onUpdate({inbox: false}), }, - { - key: 'publish', - label: t('Publish'), - disabled: disabled || !shareCap.enabled, - hidden: !organization.features.includes('shared-issues'), - onAction: openPublishModal, - }, { key: bookmarkKey, label: bookmarkTitle, @@ -548,8 +517,8 @@ export function GroupActions({group, project, disabled, event}: GroupActionsProp priority: 'danger', label: t('Delete'), hidden: !hasDeleteAccess, - disabled: !updatedDeleteCap.enabled, - details: updatedDeleteCap.disabledReason, + disabled: !deleteCap.enabled, + details: deleteCap.disabledReason, onAction: openDeleteModal, }, { diff --git a/static/app/views/issueDetails/actions/publishModal.spec.tsx b/static/app/views/issueDetails/actions/publishModal.spec.tsx deleted file mode 100644 index 7b93c107da4d2d..00000000000000 --- a/static/app/views/issueDetails/actions/publishModal.spec.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import {GroupFixture} from 'sentry-fixture/group'; -import {OrganizationFixture} from 'sentry-fixture/organization'; -import {ProjectFixture} from 'sentry-fixture/project'; - -import {act, renderGlobalModal, screen, userEvent} from 'sentry-test/reactTestingLibrary'; - -import {openModal} from 'sentry/actionCreators/modal'; -import GroupStore from 'sentry/stores/groupStore'; -import ModalStore from 'sentry/stores/modalStore'; -import PublishIssueModal from 'sentry/views/issueDetails/actions/publishModal'; - -describe('shareModal', () => { - const project = ProjectFixture(); - const organization = OrganizationFixture(); - const onToggle = jest.fn(); - - beforeEach(() => { - GroupStore.init(); - }); - afterEach(() => { - ModalStore.reset(); - GroupStore.reset(); - MockApiClient.clearMockResponses(); - jest.clearAllMocks(); - }); - - it('should share', async () => { - const group = GroupFixture(); - GroupStore.add([group]); - - const issuesApi = MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/issues/`, - method: 'PUT', - body: {...group, isPublic: true, shareId: '12345'}, - }); - renderGlobalModal(); - - act(() => - openModal(modalProps => ( - - )) - ); - - expect(screen.getByText('Publish Issue')).toBeInTheDocument(); - await userEvent.click(screen.getByLabelText('Publish')); - expect(await screen.findByRole('button', {name: 'Copy Link'})).toBeInTheDocument(); - expect(issuesApi).toHaveBeenCalledTimes(1); - expect(onToggle).toHaveBeenCalledTimes(1); - }); - - it('should unshare', async () => { - const group = GroupFixture({isPublic: true, shareId: '12345'}); - GroupStore.add([group]); - - const issuesApi = MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/issues/`, - method: 'PUT', - body: {...group, isPublic: false, shareId: null}, - }); - renderGlobalModal(); - - act(() => - openModal(modalProps => ( - - )) - ); - - await userEvent.click(screen.getByLabelText('Unpublish')); - - expect(issuesApi).toHaveBeenCalledTimes(1); - expect(onToggle).toHaveBeenCalledTimes(1); - }); -}); diff --git a/static/app/views/issueDetails/actions/publishModal.tsx b/static/app/views/issueDetails/actions/publishModal.tsx deleted file mode 100644 index cbaf39126b6a44..00000000000000 --- a/static/app/views/issueDetails/actions/publishModal.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import {Fragment, useCallback, useRef, useState} from 'react'; -import styled from '@emotion/styled'; - -import {bulkUpdate} from 'sentry/actionCreators/group'; -import {addErrorMessage} from 'sentry/actionCreators/indicator'; -import type {ModalRenderProps} from 'sentry/actionCreators/modal'; -import AutoSelectText from 'sentry/components/autoSelectText'; -import {Button} from 'sentry/components/core/button'; -import {Switch} from 'sentry/components/core/switch'; -import LoadingIndicator from 'sentry/components/loadingIndicator'; -import {IconRefresh} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import GroupStore from 'sentry/stores/groupStore'; -import {useLegacyStore} from 'sentry/stores/useLegacyStore'; -import {space} from 'sentry/styles/space'; -import type {Group} from 'sentry/types/group'; -import type {Organization} from 'sentry/types/organization'; -import useApi from 'sentry/utils/useApi'; -import useCopyToClipboard from 'sentry/utils/useCopyToClipboard'; -import {useHasStreamlinedUI} from 'sentry/views/issueDetails/utils'; - -interface PublishIssueModalProps extends ModalRenderProps { - groupId: string; - onToggle: () => void; - organization: Organization; - projectSlug: string; -} - -type UrlRef = React.ElementRef; - -function getShareUrl(group: Group) { - const path = `/share/issue/${group.shareId}/`; - const {host, protocol} = window.location; - return `${protocol}//${host}${path}`; -} - -export default function PublishIssueModal({ - Header, - Body, - organization, - projectSlug, - groupId, - onToggle, - closeModal, -}: PublishIssueModalProps) { - const api = useApi({persistInFlight: true}); - const [loading, setLoading] = useState(false); - const urlRef = useRef(null); - const groups = useLegacyStore(GroupStore); - const group = (groups as Group[]).find(item => item.id === groupId); - const isPublished = group?.isPublic; - const hasStreamlinedUI = useHasStreamlinedUI(); - const handleShare = useCallback( - (e: React.ChangeEvent | null, reshare?: boolean) => { - e?.preventDefault(); - setLoading(true); - onToggle(); - - bulkUpdate( - api, - { - orgId: organization.slug, - projectId: projectSlug, - itemIds: [groupId], - data: { - isPublic: reshare ?? !isPublished, - }, - }, - { - error: () => { - addErrorMessage(t('Error sharing')); - }, - complete: () => { - setLoading(false); - }, - } - ); - }, - [api, setLoading, onToggle, isPublished, organization.slug, projectSlug, groupId] - ); - - const shareUrl = group?.shareId ? getShareUrl(group) : null; - - const {onClick: handleCopy} = useCopyToClipboard({ - text: shareUrl!, - onCopy: closeModal, - }); - - return ( - -

-

{t('Publish Issue')}

-
- - - -
- {t('Create a public link')} - {t('Share a link with anyone outside your organization')} -
- -
- {(!group || loading) && ( - - - - )} - {group && !loading && isPublished && shareUrl && ( - - - - {shareUrl} - - } - onClick={() => handleShare(null, true)} - analyticsEventKey="issue_details.publish_issue_modal.generate_new_url" - analyticsEventName="Issue Details: Publish Issue Modal Generate New URL" - /> - - - - - - )} -
- - - ); -} - -/** - * min-height reduces layout shift when switching on and off - */ -const ModalContent = styled('div')` - display: flex; - gap: ${space(2)}; - flex-direction: column; - min-height: 100px; -`; - -const SwitchWrapper = styled('div')` - display: flex; - justify-content: space-between; - align-items: center; - gap: ${space(2)}; -`; - -const Title = styled('div')` - padding-right: ${space(4)}; - white-space: nowrap; -`; - -const SubText = styled('p')` - color: ${p => p.theme.subText}; - font-size: ${p => p.theme.fontSizeSmall}; -`; - -const LoadingContainer = styled('div')` - display: flex; - justify-content: center; -`; - -const UrlContainer = styled('div')` - display: grid; - grid-template-columns: 1fr max-content max-content; - align-items: center; - border: 1px solid ${p => p.theme.border}; - border-radius: ${space(0.5)}; -`; - -const StyledAutoSelectText = styled(AutoSelectText)` - padding: ${space(1)} ${space(1)}; - ${p => p.theme.overflowEllipsis} -`; - -const TextContainer = styled('div')` - position: relative; - display: flex; - flex-grow: 1; - background-color: transparent; - border-right: 1px solid ${p => p.theme.border}; - min-width: 0; -`; - -const ReshareButton = styled(Button)` - border-radius: 0; - height: 100%; - flex-shrink: 0; -`; - -const PublishActions = styled('div')` - display: flex; - gap: ${space(1)}; -`; - -const ButtonContainer = styled('div')` - align-self: flex-end; -`; diff --git a/static/app/views/issueDetails/actions/shareModal.spec.tsx b/static/app/views/issueDetails/actions/shareModal.spec.tsx new file mode 100644 index 00000000000000..8218da1823cec8 --- /dev/null +++ b/static/app/views/issueDetails/actions/shareModal.spec.tsx @@ -0,0 +1,181 @@ +import {EventFixture} from 'sentry-fixture/event'; +import {GroupFixture} from 'sentry-fixture/group'; +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import {act, renderGlobalModal, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import {openModal} from 'sentry/actionCreators/modal'; +import GroupStore from 'sentry/stores/groupStore'; +import ModalStore from 'sentry/stores/modalStore'; +import ShareIssueModal from 'sentry/views/issueDetails/actions/shareModal'; + +describe('ShareIssueModal', () => { + const project = ProjectFixture(); + const organization = OrganizationFixture({features: ['shared-issues']}); + const onToggle = jest.fn(); + + beforeEach(() => { + GroupStore.init(); + Object.assign(navigator, { + clipboard: { + writeText: jest.fn().mockResolvedValue(''), + }, + }); + }); + afterEach(() => { + ModalStore.reset(); + GroupStore.reset(); + MockApiClient.clearMockResponses(); + jest.clearAllMocks(); + }); + + it('should copy issue link', async () => { + const group = GroupFixture({isPublic: true, shareId: '12345'}); + GroupStore.add([group]); + + renderGlobalModal(); + + act(() => + openModal(modalProps => ( + + )) + ); + + expect(screen.getByText('Share Issue')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', {name: 'Copy Link'})); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + `http://localhost/organizations/org-slug/issues/1/` + ); + }); + + it('should copy link with event id', async () => { + const group = GroupFixture({isPublic: true, shareId: '12345'}); + GroupStore.add([group]); + const event = EventFixture(); + + renderGlobalModal(); + + act(() => + openModal(modalProps => ( + + )) + ); + + expect(screen.getByText('Share Issue')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', {name: 'Copy Link'})); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + `http://localhost/organizations/org-slug/issues/1/events/1/` + ); + }); + + it('should copy as markdown', async () => { + const group = GroupFixture({isPublic: true, shareId: '12345'}); + GroupStore.add([group]); + + renderGlobalModal(); + + act(() => + openModal(modalProps => ( + + )) + ); + + expect(screen.getByText('Share Issue')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', {name: 'Copy as Markdown'})); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + '[JAVASCRIPT-6QS](http://localhost/organizations/org-slug/issues/1/)' + ); + }); + + it('should public share', async () => { + const group = GroupFixture(); + GroupStore.add([group]); + + const issuesApi = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/issues/`, + method: 'PUT', + body: {...group, isPublic: true, shareId: '12345'}, + }); + renderGlobalModal(); + + act(() => + openModal(modalProps => ( + + )) + ); + + await userEvent.click(screen.getByLabelText('Publish')); + expect( + await screen.findByRole('button', {name: 'Copy Public Link'}) + ).toBeInTheDocument(); + expect(issuesApi).toHaveBeenCalledTimes(1); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it('should public unshare', async () => { + const group = GroupFixture({isPublic: true, shareId: '12345'}); + GroupStore.add([group]); + + const issuesApi = MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/issues/`, + method: 'PUT', + body: {...group, isPublic: false, shareId: null}, + }); + renderGlobalModal(); + + act(() => + openModal(modalProps => ( + + )) + ); + + await userEvent.click(screen.getByLabelText('Unpublish')); + + expect(issuesApi).toHaveBeenCalledTimes(1); + expect(onToggle).toHaveBeenCalledTimes(1); + }); +}); diff --git a/static/app/views/issueDetails/actions/shareModal.tsx b/static/app/views/issueDetails/actions/shareModal.tsx index 37932c06f22507..c59af3c5f223ae 100644 --- a/static/app/views/issueDetails/actions/shareModal.tsx +++ b/static/app/views/issueDetails/actions/shareModal.tsx @@ -1,11 +1,16 @@ -import {Fragment, useRef} from 'react'; +import {Fragment, useCallback, useRef, useState} from 'react'; import styled from '@emotion/styled'; +import {bulkUpdate} from 'sentry/actionCreators/group'; +import {addErrorMessage} from 'sentry/actionCreators/indicator'; import type {ModalRenderProps} from 'sentry/actionCreators/modal'; import AutoSelectText from 'sentry/components/autoSelectText'; import {Button} from 'sentry/components/core/button'; import {ButtonBar} from 'sentry/components/core/button/buttonBar'; import {Checkbox} from 'sentry/components/core/checkbox'; +import {Switch} from 'sentry/components/core/switch'; +import LoadingIndicator from 'sentry/components/loadingIndicator'; +import {IconRefresh} from 'sentry/icons'; import {t} from 'sentry/locale'; import GroupStore from 'sentry/stores/groupStore'; import {useLegacyStore} from 'sentry/stores/useLegacyStore'; @@ -15,13 +20,19 @@ import type {Group} from 'sentry/types/group'; import type {Organization} from 'sentry/types/organization'; import {getAnalyticsDataForEvent, getAnalyticsDataForGroup} from 'sentry/utils/events'; import normalizeUrl from 'sentry/utils/url/normalizeUrl'; +import useApi from 'sentry/utils/useApi'; import useCopyToClipboard from 'sentry/utils/useCopyToClipboard'; import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; +import {SectionDivider} from 'sentry/views/issueDetails/streamline/foldSection'; +import {useHasStreamlinedUI} from 'sentry/views/issueDetails/utils'; interface ShareIssueModalProps extends ModalRenderProps { event: Event | null; groupId: string; + hasIssueShare: boolean; + onToggle: () => void; organization: Organization; + projectSlug: string; } type UrlRef = React.ElementRef; @@ -39,6 +50,9 @@ export default function ShareIssueModal({ groupId, closeModal, event, + onToggle, + projectSlug, + hasIssueShare, }: ShareIssueModalProps) { const [includeEventId, setIncludeEventId] = useLocalStorageState( 'issue-details-share-event-id', @@ -48,6 +62,12 @@ export default function ShareIssueModal({ const urlRef = useRef(null); const groups = useLegacyStore(GroupStore); const group = (groups as Group[]).find(item => item.id === groupId); + const api = useApi({persistInFlight: true}); + const [loading, setLoading] = useState(false); + const isPublished = group?.isPublic; + const hasStreamlinedUI = useHasStreamlinedUI(); + + const hasPublicShare = organization.features.includes('shared-issues') && hasIssueShare; const issueUrl = includeEventId && event @@ -72,13 +92,48 @@ export default function ShareIssueModal({ onCopy: closeModal, }); + const handlePublicShare = useCallback( + (e: React.ChangeEvent | null, reshare?: boolean) => { + e?.preventDefault(); + setLoading(true); + onToggle(); + bulkUpdate( + api, + { + orgId: organization.slug, + projectId: projectSlug, + itemIds: [groupId], + data: { + isPublic: reshare ?? !isPublished, + }, + }, + { + error: () => { + addErrorMessage(t('Error sharing')); + }, + complete: () => { + setLoading(false); + }, + } + ); + }, + [api, setLoading, onToggle, isPublished, organization.slug, projectSlug, groupId] + ); + + const shareUrl = group?.shareId ? getShareUrl(group) : null; + + const {onClick: handleCopy} = useCopyToClipboard({ + text: shareUrl!, + onCopy: closeModal, + }); + return (

{t('Share Issue')}

- + {issueUrl} @@ -136,16 +191,73 @@ export default function ShareIssueModal({ {t('Copy Link')} + {hasPublicShare && ( + + + +
+ {t('Create a public link')} + + {t('Share a link with anyone outside your organization')} + +
+ +
+ {(!group || loading) && ( + + + + )} + {group && !loading && isPublished && shareUrl && ( + + + + {shareUrl} + + } + onClick={() => handlePublicShare(null, true)} + analyticsEventKey="issue_details.publish_issue_modal.generate_new_url" + analyticsEventName="Issue Details: Publish Issue Modal Generate New URL" + /> + + + + + + )} +
+ )}
); } -const ModalContent = styled('div')` +const ModalContent = styled('div')<{hasPublicShare: boolean}>` display: flex; gap: ${space(1)}; flex-direction: column; + min-height: ${p => (p.hasPublicShare ? '240px' : '')}; `; const UrlContainer = styled('div')` @@ -181,3 +293,40 @@ const CheckboxContainer = styled('label')` const StyledButtonBar = styled(ButtonBar)` justify-content: flex-end; `; + +const SwitchWrapper = styled('div')` + display: flex; + justify-content: space-between; + align-items: center; + gap: ${space(2)}; +`; + +const Title = styled('div')` + padding-right: ${space(4)}; + white-space: nowrap; +`; + +const SubText = styled('p')` + color: ${p => p.theme.subText}; + font-size: ${p => p.theme.fontSizeSmall}; +`; + +const LoadingContainer = styled('div')` + display: flex; + justify-content: center; +`; + +const ReshareButton = styled(Button)` + border-radius: 0; + height: 100%; + flex-shrink: 0; +`; + +const PublishActions = styled('div')` + display: flex; + gap: ${space(1)}; +`; + +const ButtonContainer = styled('div')` + align-self: flex-end; +`; diff --git a/static/app/views/issueDetails/allEventsTable.tsx b/static/app/views/issueDetails/allEventsTable.tsx index 7f88d22e75d90d..06f4211ce5640b 100644 --- a/static/app/views/issueDetails/allEventsTable.tsx +++ b/static/app/views/issueDetails/allEventsTable.tsx @@ -47,9 +47,7 @@ function AllEventsTable({organization, excludedTags, group}: Props) { groupId: group.id, }); - const isRegressionIssue = - group.issueType === IssueType.PERFORMANCE_DURATION_REGRESSION || - group.issueType === IssueType.PERFORMANCE_ENDPOINT_REGRESSION; + const isRegressionIssue = group.issueType === IssueType.PERFORMANCE_ENDPOINT_REGRESSION; const {data, isLoading, isLoadingError} = useApiQuery([endpointUrl], { staleTime: 60000, enabled: isRegressionIssue, diff --git a/static/app/views/issueDetails/groupDetails.tsx b/static/app/views/issueDetails/groupDetails.tsx index 0cf74a5507ce1e..582c1c525298ff 100644 --- a/static/app/views/issueDetails/groupDetails.tsx +++ b/static/app/views/issueDetails/groupDetails.tsx @@ -785,7 +785,6 @@ function GroupDetailsPageContent(props: GroupDetailsProps & FetchGroupDetailsSta const projectWithFallback = project ?? projects[0]; const isRegressionIssue = - props.group?.issueType === IssueType.PERFORMANCE_DURATION_REGRESSION || props.group?.issueType === IssueType.PERFORMANCE_ENDPOINT_REGRESSION; useEffect(() => { diff --git a/static/app/views/issueDetails/groupEventCarousel.spec.tsx b/static/app/views/issueDetails/groupEventCarousel.spec.tsx index 66387b7e0e42f9..732802f29b1e75 100644 --- a/static/app/views/issueDetails/groupEventCarousel.spec.tsx +++ b/static/app/views/issueDetails/groupEventCarousel.spec.tsx @@ -5,7 +5,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {RouterFixture} from 'sentry-fixture/routerFixture'; import {UserFixture} from 'sentry-fixture/user'; -import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary'; +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; import ConfigStore from 'sentry/stores/configStore'; import * as useMedia from 'sentry/utils/useMedia'; @@ -210,9 +210,7 @@ describe('GroupEventCarousel', () => { await userEvent.click(screen.getByRole('button', {name: /event actions/i})); expect( - within(screen.getByRole('menuitemradio', {name: /full event details/i})).getByRole( - 'link' - ) + screen.getByRole('menuitemradio', {name: /full event details/i}) ).toHaveAttribute('href', `/organizations/org-slug/discover/project-slug:event-id/`); }); diff --git a/static/app/views/issueDetails/groupSidebar.spec.tsx b/static/app/views/issueDetails/groupSidebar.spec.tsx index da40798686fbee..34e7496e997096 100644 --- a/static/app/views/issueDetails/groupSidebar.spec.tsx +++ b/static/app/views/issueDetails/groupSidebar.spec.tsx @@ -86,7 +86,7 @@ describe('GroupSidebar', function () { method: 'GET', }); MockApiClient.addMockResponse({ - url: `/issues/${group.id}/autofix/setup/`, + url: `/organizations/${organization.slug}/issues/${group.id}/autofix/setup/`, body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: true, diff --git a/static/app/views/issueDetails/groupTags/groupTagValues.spec.tsx b/static/app/views/issueDetails/groupTags/groupTagValues.spec.tsx index e5016ad825bb98..0b404ad4c2c7b1 100644 --- a/static/app/views/issueDetails/groupTags/groupTagValues.spec.tsx +++ b/static/app/views/issueDetails/groupTags/groupTagValues.spec.tsx @@ -4,13 +4,7 @@ import {TagsFixture} from 'sentry-fixture/tags'; import {TagValuesFixture} from 'sentry-fixture/tagvalues'; import {initializeOrg} from 'sentry-test/initializeOrg'; -import { - render, - screen, - userEvent, - waitFor, - within, -} from 'sentry-test/reactTestingLibrary'; +import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; import ProjectsStore from 'sentry/stores/projectsStore'; import {GroupTagValues} from 'sentry/views/issueDetails/groupTags/groupTagValues'; @@ -117,9 +111,7 @@ describe('GroupTagValues', () => { await userEvent.click(await screen.findByRole('button', {name: 'More'})); await userEvent.click( - within( - screen.getByRole('menuitemradio', {name: 'Search All Issues with Tag Value'}) - ).getByRole('link') + screen.getByRole('menuitemradio', {name: 'Search All Issues with Tag Value'}) ); expect(router.push).toHaveBeenCalledWith({ diff --git a/static/app/views/issueDetails/groupTags/tagDetailsDrawerContent.spec.tsx b/static/app/views/issueDetails/groupTags/tagDetailsDrawerContent.spec.tsx index 94473444bd488e..ec3a98dc23bd24 100644 --- a/static/app/views/issueDetails/groupTags/tagDetailsDrawerContent.spec.tsx +++ b/static/app/views/issueDetails/groupTags/tagDetailsDrawerContent.spec.tsx @@ -1,3 +1,4 @@ +import * as qs from 'query-string'; import {GroupFixture} from 'sentry-fixture/group'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {TagsFixture} from 'sentry-fixture/tags'; @@ -24,6 +25,14 @@ jest.mock('sentry/utils/useNavigate', () => ({ const group = GroupFixture(); const tags = TagsFixture(); +const makeInitialRouterConfig = (tagKey: string) => ({ + location: { + pathname: `/organizations/org-slug/issues/1/tags/${tagKey}/`, + query: {}, + }, + route: '/organizations/:orgId/issues/:groupId/tags/:tagKey/', +}); + function init(tagKey: string) { return initializeOrg({ router: { @@ -125,28 +134,25 @@ describe('TagDetailsDrawerContent', () => { }); it('navigates to issue details events tab with correct query params', async () => { - const {router} = init('user'); - MockApiClient.addMockResponse({ url: '/organizations/org-slug/issues/1/tags/user/values/', body: TagValuesFixture(), }); render(, { - router, - deprecatedRouterMocks: true, + initialRouterConfig: makeInitialRouterConfig('user'), }); await userEvent.click( await screen.findByRole('button', {name: 'Tag Value Actions Menu'}) ); - await userEvent.click( - await screen.findByRole('link', {name: 'View other events with this tag value'}) + expect( + screen.getByRole('menuitemradio', { + name: 'View other events with this tag value', + }) + ).toHaveAttribute( + 'href', + '/organizations/org-slug/issues/1/events/?query=user.username%3Adavid' ); - - expect(router.push).toHaveBeenCalledWith({ - pathname: '/organizations/org-slug/issues/1/events/', - query: {query: 'user.username:david'}, - }); }); it('navigates to discover with issue + tag query', async () => { @@ -168,20 +174,25 @@ describe('TagDetailsDrawerContent', () => { await userEvent.click( await screen.findByRole('button', {name: 'Tag Value Actions Menu'}) ); - await userEvent.click(await screen.findByRole('link', {name: 'Open in Discover'})); - - expect(router.push).toHaveBeenCalledWith({ - pathname: '/organizations/org-slug/discover/results/', - query: { - dataset: 'errors', - field: ['title', 'release', 'environment', 'user.display', 'timestamp'], - interval: '1m', - name: 'RequestError: GET /issues/ 404', - project: '2', - query: 'issue:JAVASCRIPT-6QS user.username:david', - statsPeriod: '14d', - yAxis: ['count()', 'count_unique(user)'], - }, + + const discoverMenuItem = screen.getByRole('menuitemradio', { + name: 'Open in Discover', + }); + expect(discoverMenuItem).toBeInTheDocument(); + + const link = new URL(discoverMenuItem.getAttribute('href') ?? '', 'http://localhost'); + expect(link.pathname).toBe('/organizations/org-slug/discover/results/'); + const discoverQueryParams = qs.parse(link.search); + + expect(discoverQueryParams).toEqual({ + dataset: 'errors', + field: ['title', 'release', 'environment', 'user.display', 'timestamp'], + interval: '1m', + name: 'RequestError: GET /issues/ 404', + project: '2', + query: 'issue:JAVASCRIPT-6QS user.username:david', + statsPeriod: '14d', + yAxis: ['count()', 'count_unique(user)'], }); }); diff --git a/static/app/views/issueDetails/streamline/eventDetails.tsx b/static/app/views/issueDetails/streamline/eventDetails.tsx index 6e029a3ee7ada9..e5d842aa003596 100644 --- a/static/app/views/issueDetails/streamline/eventDetails.tsx +++ b/static/app/views/issueDetails/streamline/eventDetails.tsx @@ -20,7 +20,7 @@ import { import {useIssueDetails} from 'sentry/views/issueDetails/streamline/context'; import {EventMissingBanner} from 'sentry/views/issueDetails/streamline/eventMissingBanner'; import {EventTitle} from 'sentry/views/issueDetails/streamline/eventTitle'; -import {usePrefersStackedNav} from 'sentry/views/nav/prefersStackedNav'; +import {usePrefersStackedNav} from 'sentry/views/nav/usePrefersStackedNav'; export function EventDetails({group, event, project}: EventDetailsContentProps) { if (!event) { diff --git a/static/app/views/issueDetails/streamline/eventList.tsx b/static/app/views/issueDetails/streamline/eventList.tsx index d82764bc514df9..51b3ce41343b62 100644 --- a/static/app/views/issueDetails/streamline/eventList.tsx +++ b/static/app/views/issueDetails/streamline/eventList.tsx @@ -71,9 +71,7 @@ export function EventList({group}: EventListProps) { eventView.sorts = [{field: 'timestamp', kind: 'desc'}]; } - const isRegressionIssue = - group.issueType === IssueType.PERFORMANCE_DURATION_REGRESSION || - group.issueType === IssueType.PERFORMANCE_ENDPOINT_REGRESSION; + const isRegressionIssue = group.issueType === IssueType.PERFORMANCE_ENDPOINT_REGRESSION; return ( diff --git a/static/app/views/issueDetails/streamline/groupDetailsLayout.spec.tsx b/static/app/views/issueDetails/streamline/groupDetailsLayout.spec.tsx index 8c4b3edb2565c3..59da1d123650be 100644 --- a/static/app/views/issueDetails/streamline/groupDetailsLayout.spec.tsx +++ b/static/app/views/issueDetails/streamline/groupDetailsLayout.spec.tsx @@ -88,7 +88,7 @@ describe('GroupDetailsLayout', () => { body: {committers: []}, }); MockApiClient.addMockResponse({ - url: '/issues/1/autofix/setup/', + url: `/organizations/${organization.slug}/issues/${group.id}/autofix/setup/`, body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: false, diff --git a/static/app/views/issueDetails/streamline/header/issueIdBreadcrumb.tsx b/static/app/views/issueDetails/streamline/header/issueIdBreadcrumb.tsx index 42098a055d6366..dfe3e6a375eb52 100644 --- a/static/app/views/issueDetails/streamline/header/issueIdBreadcrumb.tsx +++ b/static/app/views/issueDetails/streamline/header/issueIdBreadcrumb.tsx @@ -16,8 +16,7 @@ import {trackAnalytics} from 'sentry/utils/analytics'; import {getAnalyticsDataForGroup} from 'sentry/utils/events'; import useCopyToClipboard from 'sentry/utils/useCopyToClipboard'; import useOrganization from 'sentry/utils/useOrganization'; -import PublishIssueModal from 'sentry/views/issueDetails/actions/publishModal'; -import {getShareUrl} from 'sentry/views/issueDetails/actions/shareModal'; +import ShareIssueModal, {getShareUrl} from 'sentry/views/issueDetails/actions/shareModal'; interface ShortIdBreadcrumbProps { group: Group; @@ -93,7 +92,7 @@ export function IssueIdBreadcrumb({project, group}: ShortIdBreadcrumbProps) { color="subText" onClick={() => openModal(modalProps => ( - )) } diff --git a/static/app/views/issueDetails/streamline/sidebar/seerDrawer.spec.tsx b/static/app/views/issueDetails/streamline/sidebar/seerDrawer.spec.tsx index 0d1e0bf2c278bf..8807c299598bae 100644 --- a/static/app/views/issueDetails/streamline/sidebar/seerDrawer.spec.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/seerDrawer.spec.tsx @@ -105,7 +105,7 @@ describe('SeerDrawer', () => { MockApiClient.clearMockResponses(); MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/setup/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: true, @@ -136,7 +136,7 @@ describe('SeerDrawer', () => { it('renders consent state if not consented', async () => { MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/setup/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: false, @@ -147,7 +147,7 @@ describe('SeerDrawer', () => { }), }); MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, body: {autofix: null}, }); @@ -170,7 +170,7 @@ describe('SeerDrawer', () => { it('renders initial state with Start Autofix button', async () => { MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, body: {autofix: null}, }); @@ -193,7 +193,7 @@ describe('SeerDrawer', () => { it('renders GitHub integration setup notice when missing GitHub integration', async () => { MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/setup/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: true, @@ -204,7 +204,7 @@ describe('SeerDrawer', () => { }), }); MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, body: {autofix: null}, }); @@ -225,12 +225,12 @@ describe('SeerDrawer', () => { it('triggers autofix on clicking the Start button', async () => { MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, method: 'POST', body: {autofix: null}, }); MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, method: 'GET', body: {autofix: null}, }); @@ -253,7 +253,7 @@ describe('SeerDrawer', () => { it('hides ButtonBarWrapper when AI consent is needed', async () => { MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/setup/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: false, @@ -264,7 +264,7 @@ describe('SeerDrawer', () => { }), }); MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, body: {autofix: null}, }); @@ -284,7 +284,7 @@ describe('SeerDrawer', () => { it('shows ButtonBarWrapper but hides Start Over button when hasAutofix is false', async () => { // Mock AI consent as okay but no autofix capability MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/setup/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: true, @@ -295,7 +295,7 @@ describe('SeerDrawer', () => { }), }); MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, body: {autofix: null}, }); @@ -328,7 +328,7 @@ describe('SeerDrawer', () => { it('shows ButtonBarWrapper with disabled Start Over button when hasAutofix is true but no autofixData', async () => { // Mock everything as ready for autofix but no data MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/setup/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: true, @@ -339,7 +339,7 @@ describe('SeerDrawer', () => { }), }); MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, body: {autofix: null}, }); @@ -361,7 +361,7 @@ describe('SeerDrawer', () => { it('shows ButtonBarWrapper with enabled Start Over button when hasAutofix and autofixData are both true', async () => { // Mock everything as ready with existing autofix data MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/setup/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: true, @@ -372,7 +372,7 @@ describe('SeerDrawer', () => { }), }); MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, body: {autofix: mockAutofixData}, }); @@ -393,7 +393,7 @@ describe('SeerDrawer', () => { it('displays Start Over button with autofix data', async () => { MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, body: {autofix: mockAutofixData}, }); @@ -406,7 +406,7 @@ describe('SeerDrawer', () => { it('displays Start Over button even without autofix data', async () => { MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, body: {autofix: null}, }); @@ -423,7 +423,7 @@ describe('SeerDrawer', () => { it('resets autofix on clicking the start over button', async () => { MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, body: {autofix: mockAutofixData}, }); @@ -442,7 +442,7 @@ describe('SeerDrawer', () => { it('shows setup instructions when GitHub integration setup is needed', async () => { MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/setup/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: true, @@ -453,7 +453,7 @@ describe('SeerDrawer', () => { }), }); MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, body: {autofix: null}, }); MockApiClient.addMockResponse({ @@ -481,7 +481,7 @@ describe('SeerDrawer', () => { it('does not render SeerNotices when all repositories are readable', async () => { MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/setup/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: true, @@ -492,7 +492,7 @@ describe('SeerDrawer', () => { }), }); MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, body: {autofix: mockAutofixWithReadableRepos}, }); @@ -510,7 +510,7 @@ describe('SeerDrawer', () => { it('renders warning for unreadable GitHub repository', async () => { MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/setup/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: true, @@ -521,7 +521,7 @@ describe('SeerDrawer', () => { }), }); MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, body: {autofix: mockAutofixWithUnreadableGithubRepos}, }); @@ -540,7 +540,7 @@ describe('SeerDrawer', () => { it('renders warning for unreadable non-GitHub repository', async () => { MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/setup/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: true, @@ -551,7 +551,7 @@ describe('SeerDrawer', () => { }), }); MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, body: {autofix: mockAutofixWithUnreadableNonGithubRepos}, }); diff --git a/static/app/views/issueDetails/streamline/sidebar/seerDrawer.tsx b/static/app/views/issueDetails/streamline/sidebar/seerDrawer.tsx index 91f926a68f3b23..56f8c8f93bc9ec 100644 --- a/static/app/views/issueDetails/streamline/sidebar/seerDrawer.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/seerDrawer.tsx @@ -14,6 +14,7 @@ import AutofixFeedback from 'sentry/components/events/autofix/autofixFeedback'; import {AutofixProgressBar} from 'sentry/components/events/autofix/autofixProgressBar'; import {AutofixStartBox} from 'sentry/components/events/autofix/autofixStartBox'; import {AutofixSteps} from 'sentry/components/events/autofix/autofixSteps'; +import {AutofixStepType} from 'sentry/components/events/autofix/types'; import {useAiAutofix} from 'sentry/components/events/autofix/useAutofix'; import useDrawer from 'sentry/components/globalDrawer'; import {DrawerBody, DrawerHeader} from 'sentry/components/globalDrawer/components'; @@ -53,12 +54,19 @@ export function SeerDrawer({group, project, event}: SeerDrawerProps) { const organization = useOrganization(); const {autofixData, triggerAutofix, reset} = useAiAutofix(group, event); const aiConfig = useAiConfig(group, project); + const location = useLocation(); + const navigate = useNavigate(); useRouteAnalyticsParams({autofix_status: autofixData?.status ?? 'none'}); const scrollContainerRef = useRef(null); const userScrolledRef = useRef(false); const lastScrollTopRef = useRef(0); + const autofixDataRef = useRef(autofixData); + + useEffect(() => { + autofixDataRef.current = autofixData; + }, [autofixData]); const handleScroll = () => { const container = scrollContainerRef.current; @@ -85,6 +93,56 @@ export function SeerDrawer({group, project, event}: SeerDrawerProps) { } }; + const scrollToSection = useCallback( + (sectionType: string | null) => { + if (!scrollContainerRef.current || !autofixDataRef.current) { + return; + } + + const findStepByType = (type: string) => { + const currentData = autofixDataRef.current; + if (!currentData?.steps?.length) { + return null; + } + const step = currentData.steps.find(s => { + if (type === 'root_cause') + return s.type === AutofixStepType.ROOT_CAUSE_ANALYSIS; + if (type === 'solution') return s.type === AutofixStepType.SOLUTION; + if (type === 'code_changes') return s.type === AutofixStepType.CHANGES; + return false; + }); + return step; + }; + + if (sectionType) { + const step = findStepByType(sectionType); + if (step) { + const elementId = `autofix-step-${step.id}`; + const element = document.getElementById(elementId); + if (element) { + element.scrollIntoView({behavior: 'smooth'}); + userScrolledRef.current = true; + + // Clear the scrollTo parameter from the URL after scrolling + setTimeout(() => { + navigate( + { + pathname: location.pathname, + query: { + ...location.query, + scrollTo: undefined, + }, + }, + {replace: true} + ); + }, 200); + } + } + } + }, + [location, navigate] + ); + useEffect(() => { // Only auto-scroll if user hasn't manually scrolled if (!userScrolledRef.current && scrollContainerRef.current) { @@ -92,6 +150,17 @@ export function SeerDrawer({group, project, event}: SeerDrawerProps) { } }, [autofixData]); + useEffect(() => { + const scrollTo = location.query.scrollTo as string | undefined; + if (scrollTo) { + const timeoutId = setTimeout(() => { + scrollToSection(scrollTo); + }, 100); + return () => clearTimeout(timeoutId); + } + return () => {}; + }, [location.query.scrollTo, scrollToSection]); + return ( diff --git a/static/app/views/issueDetails/streamline/sidebar/seerSection.spec.tsx b/static/app/views/issueDetails/streamline/sidebar/seerSection.spec.tsx index f26dd48acb5fe1..7bdff582a6d117 100644 --- a/static/app/views/issueDetails/streamline/sidebar/seerSection.spec.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/seerSection.spec.tsx @@ -35,7 +35,7 @@ describe('SeerSection', () => { MockApiClient.clearMockResponses(); MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/setup/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: true, @@ -47,7 +47,7 @@ describe('SeerSection', () => { }); MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, body: {steps: []}, }); }); @@ -108,7 +108,7 @@ describe('SeerSection', () => { }); MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/setup/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: false, @@ -135,7 +135,7 @@ describe('SeerSection', () => { it('shows "Find Root Cause" even when autofix needs setup', async () => { MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/setup/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: true, @@ -165,7 +165,7 @@ describe('SeerSection', () => { it('shows "Find Root Cause" when autofix is available', async () => { // Mock successful autofix setup but disable resources MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/setup/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: true, @@ -203,7 +203,7 @@ describe('SeerSection', () => { // Mock config with autofix disabled MockApiClient.addMockResponse({ - url: `/issues/${mockGroup.id}/autofix/setup/`, + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: true, diff --git a/static/app/views/issueDetails/streamline/sidebar/sidebar.spec.tsx b/static/app/views/issueDetails/streamline/sidebar/sidebar.spec.tsx index e2cdd1eb56e38a..902e1843be2669 100644 --- a/static/app/views/issueDetails/streamline/sidebar/sidebar.spec.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/sidebar.spec.tsx @@ -52,7 +52,7 @@ describe('StreamlinedSidebar', function () { }); MockApiClient.addMockResponse({ - url: '/issues/1/autofix/setup/', + url: `/organizations/${organization.slug}/issues/${group.id}/autofix/setup/`, body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: false, @@ -64,7 +64,7 @@ describe('StreamlinedSidebar', function () { }); MockApiClient.addMockResponse({ - url: `/issues/${group.id}/autofix/`, + url: `/organizations/${organization.slug}/issues/${group.id}/autofix/`, body: {steps: []}, }); diff --git a/static/app/views/issueList/filters.tsx b/static/app/views/issueList/filters.tsx index e4c84c1f8a62c2..536da7b7249dd9 100644 --- a/static/app/views/issueList/filters.tsx +++ b/static/app/views/issueList/filters.tsx @@ -24,7 +24,9 @@ function IssueListFilters({query, sort, onSortChange, onSearch}: Props) { const organization = useOrganization(); const hasIssueViews = organization.features.includes('issue-stream-custom-views'); - const hasIssueViewSharing = organization.features.includes('issue-view-sharing'); + const hasIssueViewSharing = organization.features.includes( + 'enforce-stacked-navigation' + ); return ( diff --git a/static/app/views/issueList/index.spec.tsx b/static/app/views/issueList/index.spec.tsx index 7007b4acc402de..a7dafcd9e21fcf 100644 --- a/static/app/views/issueList/index.spec.tsx +++ b/static/app/views/issueList/index.spec.tsx @@ -13,7 +13,7 @@ describe('IssueListContainer', function () { }; const organization = OrganizationFixture({ - features: ['issue-view-sharing'], + features: ['enforce-stacked-navigation'], }); const initialRouterConfig = { diff --git a/static/app/views/issueList/index.tsx b/static/app/views/issueList/index.tsx index c5d12fe4c23198..41db97714b26cc 100644 --- a/static/app/views/issueList/index.tsx +++ b/static/app/views/issueList/index.tsx @@ -17,8 +17,8 @@ import usePrevious from 'sentry/utils/usePrevious'; import {getIssueViewQueryParams} from 'sentry/views/issueList/issueViews/getIssueViewQueryParams'; import {useSelectedGroupSearchView} from 'sentry/views/issueList/issueViews/useSelectedGroupSeachView'; import type {GroupSearchView} from 'sentry/views/issueList/types'; -import {usePrefersStackedNav} from 'sentry/views/nav/prefersStackedNav'; import {useUpdateGroupSearchViewLastVisited} from 'sentry/views/nav/secondary/sections/issues/issueViews/useUpdateGroupSearchViewLastVisited'; +import {usePrefersStackedNav} from 'sentry/views/nav/usePrefersStackedNav'; type Props = { children: React.ReactNode; @@ -117,7 +117,9 @@ function IssueViewWrapper({children}: Props) { function IssueListContainer({children, title = t('Issues')}: Props) { const organization = useOrganization(); - const hasIssueViewSharing = organization.features.includes('issue-view-sharing'); + const hasIssueViewSharing = organization.features.includes( + 'enforce-stacked-navigation' + ); return ( diff --git a/static/app/views/issueList/issueSearchWithSavedSearches.tsx b/static/app/views/issueList/issueSearchWithSavedSearches.tsx index f1b9b10e18cb6e..cacbb8db42e56a 100644 --- a/static/app/views/issueList/issueSearchWithSavedSearches.tsx +++ b/static/app/views/issueList/issueSearchWithSavedSearches.tsx @@ -81,12 +81,9 @@ const StyledButton = styled(Button)` border-top-right-radius: 0; border-bottom-right-radius: 0; border-right: none; - ${p => p.theme.overflowEllipsis}; - > span { + & > span { ${p => p.theme.overflowEllipsis}; - height: auto; - display: block; } } `; diff --git a/static/app/views/issueList/issueViews/issueViewsList/issueViewsList.spec.tsx b/static/app/views/issueList/issueViews/issueViewsList/issueViewsList.spec.tsx index 4e602f38efa95e..a9e720537d414a 100644 --- a/static/app/views/issueList/issueViews/issueViewsList/issueViewsList.spec.tsx +++ b/static/app/views/issueList/issueViews/issueViewsList/issueViewsList.spec.tsx @@ -14,7 +14,7 @@ import {textWithMarkupMatcher} from 'sentry-test/utils'; import IssueViewsList from 'sentry/views/issueList/issueViews/issueViewsList/issueViewsList'; const organization = OrganizationFixture({ - features: ['issue-view-sharing'], + features: ['enforce-stacked-navigation'], }); describe('IssueViewsList', function () { diff --git a/static/app/views/issueList/issueViews/issueViewsList/issueViewsList.tsx b/static/app/views/issueList/issueViews/issueViewsList/issueViewsList.tsx index b045b7d9e1f4c2..727abd3b13ed82 100644 --- a/static/app/views/issueList/issueViews/issueViewsList/issueViewsList.tsx +++ b/static/app/views/issueList/issueViews/issueViewsList/issueViewsList.tsx @@ -254,7 +254,7 @@ export default function IssueViewsList() { useCreateGroupSearchView(); const defaultProject = useDefaultProject(); - if (!organization.features.includes('issue-view-sharing')) { + if (!organization.features.includes('enforce-stacked-navigation')) { return ; } diff --git a/static/app/views/issueList/leftNavViewsHeader.spec.tsx b/static/app/views/issueList/leftNavViewsHeader.spec.tsx index 77b56abf01a7ff..10e316751dcc58 100644 --- a/static/app/views/issueList/leftNavViewsHeader.spec.tsx +++ b/static/app/views/issueList/leftNavViewsHeader.spec.tsx @@ -32,7 +32,7 @@ describe('LeftNavViewsHeader', function () { const organization = OrganizationFixture({ access: ['org:read'], - features: ['issue-view-sharing'], + features: ['enforce-stacked-navigation'], }); const onIssueViewRouterConfig = { diff --git a/static/app/views/issueList/leftNavViewsHeader.tsx b/static/app/views/issueList/leftNavViewsHeader.tsx index 92a3e09de3bb6c..5e730cd2c59d52 100644 --- a/static/app/views/issueList/leftNavViewsHeader.tsx +++ b/static/app/views/issueList/leftNavViewsHeader.tsx @@ -25,7 +25,7 @@ import {useDeleteGroupSearchView} from 'sentry/views/issueList/mutations/useDele import {useUpdateGroupSearchViewStarred} from 'sentry/views/issueList/mutations/useUpdateGroupSearchViewStarred'; import {makeFetchGroupSearchViewKey} from 'sentry/views/issueList/queries/useFetchGroupSearchView'; import type {GroupSearchView} from 'sentry/views/issueList/types'; -import {usePrefersStackedNav} from 'sentry/views/nav/prefersStackedNav'; +import {usePrefersStackedNav} from 'sentry/views/nav/usePrefersStackedNav'; type LeftNavViewsHeaderProps = { selectedProjectIds: number[]; @@ -36,7 +36,9 @@ function PageTitle({title}: {title: ReactNode}) { const organization = useOrganization(); const {data: groupSearchView} = useSelectedGroupSearchView(); const user = useUser(); - const hasIssueViewSharing = organization.features.includes('issue-view-sharing'); + const hasIssueViewSharing = organization.features.includes( + 'enforce-stacked-navigation' + ); if ( hasIssueViewSharing && @@ -87,7 +89,7 @@ function IssueViewStarButton() { }, }); - if (!organization.features.includes('issue-view-sharing') || !groupSearchView) { + if (!organization.features.includes('enforce-stacked-navigation') || !groupSearchView) { return null; } @@ -134,7 +136,7 @@ function IssueViewEditMenu() { const {mutate: deleteIssueView} = useDeleteGroupSearchView(); const navigate = useNavigate(); - if (!organization.features.includes('issue-view-sharing') || !groupSearchView) { + if (!organization.features.includes('enforce-stacked-navigation') || !groupSearchView) { return null; } @@ -234,6 +236,7 @@ const StyledLayoutTitle = styled('div')` `; const Actions = styled('div')` + align-items: center; display: flex; gap: ${space(1)}; `; diff --git a/static/app/views/issueList/overview.tsx b/static/app/views/issueList/overview.tsx index 7e122df6030a72..40d62db9e57c47 100644 --- a/static/app/views/issueList/overview.tsx +++ b/static/app/views/issueList/overview.tsx @@ -58,7 +58,7 @@ import type {IssueUpdateData} from 'sentry/views/issueList/types'; import {NewTabContextProvider} from 'sentry/views/issueList/utils/newTabContext'; import {parseIssuePrioritySearch} from 'sentry/views/issueList/utils/parseIssuePrioritySearch'; import {useSelectedSavedSearch} from 'sentry/views/issueList/utils/useSelectedSavedSearch'; -import {usePrefersStackedNav} from 'sentry/views/nav/prefersStackedNav'; +import {usePrefersStackedNav} from 'sentry/views/nav/usePrefersStackedNav'; import IssueListFilters from './filters'; import IssueListHeader from './header'; @@ -1096,7 +1096,7 @@ function IssueListOverview({ )} {!prefersStackedNav && (organization.features.includes('issue-stream-custom-views') && - !organization.features.includes('issue-view-sharing') ? ( + !organization.features.includes('enforce-stacked-navigation') ? ( void; @@ -373,11 +373,14 @@ const SearchListItem = styled('li')<{hasMenu?: boolean}>` const TitleDescriptionWrapper = styled('div')` overflow: hidden; display: flex; + flex-direction: column; align-items: center; justify-content: start; + width: 100%; `; const SavedSearchItemTitle = styled('div')` + text-align: left; font-size: ${p => p.theme.fontSizeLarge}; ${p => p.theme.overflowEllipsis} `; diff --git a/static/app/views/nav/optInBanner.tsx b/static/app/views/nav/optInBanner.tsx index 20bdc038b89e3b..97234da5af6c7e 100644 --- a/static/app/views/nav/optInBanner.tsx +++ b/static/app/views/nav/optInBanner.tsx @@ -65,7 +65,7 @@ const TranslucentBackgroundPanel = styled(Panel)<{isDarkMode: boolean}>` background: rgba(245, 243, 247, ${p => (p.isDarkMode ? 0.05 : 0.1)}); border: 1px solid rgba(245, 243, 247, ${p => (p.isDarkMode ? 0.1 : 0.15)}); padding: ${space(1)}; - color: ${p => (p.isDarkMode ? p.theme.textColor : '#ebe6ef')}; + color: ${p => (p.theme.isChonk || p.isDarkMode ? p.theme.textColor : '#ebe6ef')}; margin-bottom: ${space(1)}; `; diff --git a/static/app/views/nav/orgDropdown.spec.tsx b/static/app/views/nav/orgDropdown.spec.tsx index 3b203fb6464fdc..0cdbd73ae552db 100644 --- a/static/app/views/nav/orgDropdown.spec.tsx +++ b/static/app/views/nav/orgDropdown.spec.tsx @@ -24,15 +24,14 @@ describe('OrgDropdown', function () { expect(screen.getByText('org-slug')).toBeInTheDocument(); expect(screen.getByText('0 Projects')).toBeInTheDocument(); - expect(screen.getByRole('link', {name: 'Organization Settings'})).toHaveAttribute( - 'href', - `/organizations/${organization.slug}/settings/` - ); - expect(screen.getByRole('link', {name: 'Members'})).toHaveAttribute( + expect( + screen.getByRole('menuitemradio', {name: 'Organization Settings'}) + ).toHaveAttribute('href', `/organizations/${organization.slug}/settings/`); + expect(screen.getByRole('menuitemradio', {name: 'Members'})).toHaveAttribute( 'href', `/organizations/${organization.slug}/settings/members/` ); - expect(screen.getByRole('link', {name: 'Teams'})).toHaveAttribute( + expect(screen.getByRole('menuitemradio', {name: 'Teams'})).toHaveAttribute( 'href', `/organizations/${organization.slug}/settings/teams/` ); @@ -52,11 +51,11 @@ describe('OrgDropdown', function () { await userEvent.hover(screen.getByText('Switch Organization')); - expect(await screen.findByRole('link', {name: /org-1/})).toHaveAttribute( + expect(await screen.findByRole('menuitemradio', {name: /org-1/})).toHaveAttribute( 'href', `/organizations/org-1/issues/` ); - expect(await screen.findByRole('link', {name: /org-2/})).toHaveAttribute( + expect(await screen.findByRole('menuitemradio', {name: /org-2/})).toHaveAttribute( 'href', `/organizations/org-2/issues/` ); diff --git a/static/app/views/nav/prefersStackedNav.tsx b/static/app/views/nav/prefersStackedNav.tsx index a4a8b8d6ddff8a..0712730741d075 100644 --- a/static/app/views/nav/prefersStackedNav.tsx +++ b/static/app/views/nav/prefersStackedNav.tsx @@ -1,12 +1,19 @@ import ConfigStore from 'sentry/stores/configStore'; -import {useUser} from 'sentry/utils/useUser'; +import type {Organization} from 'sentry/types/organization'; -export function prefersStackedNav() { - return ConfigStore.get('user')?.options?.prefersStackedNavigation ?? false; -} +// IMPORTANT: +// This function and the usePrefersStackedNav hook NEED to have the same logic. +// Make sure to update both if you are changing this logic. + +export function prefersStackedNav(organization: Organization) { + const userStackedNavOption = ConfigStore.get('user')?.options?.prefersStackedNavigation; -export function usePrefersStackedNav() { - const user = useUser(); + if ( + userStackedNavOption !== false && + organization.features.includes('enforce-stacked-navigation') + ) { + return true; + } - return user?.options?.prefersStackedNavigation ?? false; + return userStackedNavOption ?? false; } diff --git a/static/app/views/nav/secondary/sections/insights/insightsSecondaryNav.tsx b/static/app/views/nav/secondary/sections/insights/insightsSecondaryNav.tsx index 4a312f0d6373f4..3b0a80c67b5861 100644 --- a/static/app/views/nav/secondary/sections/insights/insightsSecondaryNav.tsx +++ b/static/app/views/nav/secondary/sections/insights/insightsSecondaryNav.tsx @@ -36,7 +36,6 @@ import {SecondaryNav} from 'sentry/views/nav/secondary/secondary'; import {PrimaryNavGroup} from 'sentry/views/nav/types'; import {isLinkActive} from 'sentry/views/nav/utils'; import {makeProjectsPathname} from 'sentry/views/projects/pathname'; -import {useReleasesDrawer} from 'sentry/views/releases/drawer/useReleasesDrawer'; export function InsightsSecondaryNav() { const organization = useOrganization(); @@ -46,8 +45,6 @@ export function InsightsSecondaryNav() { const {projects} = useProjects(); - useReleasesDrawer(); - const [starredProjects, nonStarredProjects] = useMemo(() => { return partition(projects, project => project.isBookmarked); }, [projects]); diff --git a/static/app/views/nav/secondary/sections/issues/issueViews/issueViewAddViewButton.tsx b/static/app/views/nav/secondary/sections/issues/issueViews/issueViewAddViewButton.tsx index cc9a15de597504..9cb4dd11c6d189 100644 --- a/static/app/views/nav/secondary/sections/issues/issueViews/issueViewAddViewButton.tsx +++ b/static/app/views/nav/secondary/sections/issues/issueViews/issueViewAddViewButton.tsx @@ -63,7 +63,7 @@ export function IssueViewAddViewButton() { } }; - if (organization.features.includes('issue-view-sharing')) { + if (organization.features.includes('enforce-stacked-navigation')) { return null; } diff --git a/static/app/views/nav/secondary/sections/issues/issueViews/issueViewNavItemContent.tsx b/static/app/views/nav/secondary/sections/issues/issueViews/issueViewNavItemContent.tsx index d82db72ed16aff..1043ec6e1e476e 100644 --- a/static/app/views/nav/secondary/sections/issues/issueViews/issueViewNavItemContent.tsx +++ b/static/app/views/nav/secondary/sections/issues/issueViews/issueViewNavItemContent.tsx @@ -71,7 +71,9 @@ export function IssueViewNavItemContent({ const organization = useOrganization(); const {projects} = useProjects(); - const hasIssueViewSharing = organization.features.includes('issue-view-sharing'); + const hasIssueViewSharing = organization.features.includes( + 'enforce-stacked-navigation' + ); const controls = useDragControls(); diff --git a/static/app/views/nav/secondary/sections/issues/issueViews/issueViewNavItems.tsx b/static/app/views/nav/secondary/sections/issues/issueViews/issueViewNavItems.tsx index 10b28e0f62eb1f..eefce7e5c95ef6 100644 --- a/static/app/views/nav/secondary/sections/issues/issueViews/issueViewNavItems.tsx +++ b/static/app/views/nav/secondary/sections/issues/issueViews/issueViewNavItems.tsx @@ -89,7 +89,7 @@ export function IssueViewNavItems({sectionRef, baseUrl}: IssueViewNavItemsProps) /> ))} - {organization.features.includes('issue-view-sharing') && ( + {organization.features.includes('enforce-stacked-navigation') && ( {t('All Views')} diff --git a/static/app/views/nav/usePrefersStackedNav.tsx b/static/app/views/nav/usePrefersStackedNav.tsx new file mode 100644 index 00000000000000..f57632e87b9166 --- /dev/null +++ b/static/app/views/nav/usePrefersStackedNav.tsx @@ -0,0 +1,21 @@ +import useOrganization from 'sentry/utils/useOrganization'; +import {useUser} from 'sentry/utils/useUser'; + +// IMPORTANT: +// This hook and the prefersStackedNav function NEED to have the same logic. +// Make sure to update both if you are changing this logic. + +export function usePrefersStackedNav() { + const user = useUser(); + const organization = useOrganization({allowNull: true}); + const userStackedNavOption = user?.options?.prefersStackedNavigation; + + if ( + userStackedNavOption !== false && + organization?.features.includes('enforce-stacked-navigation') + ) { + return true; + } + + return userStackedNavOption ?? false; +} diff --git a/static/app/views/nav/useRedirectNavV2Routes.tsx b/static/app/views/nav/useRedirectNavV2Routes.tsx index 410e14cf17650e..85fcce8d6a7dd0 100644 --- a/static/app/views/nav/useRedirectNavV2Routes.tsx +++ b/static/app/views/nav/useRedirectNavV2Routes.tsx @@ -7,7 +7,7 @@ import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import {useRoutes} from 'sentry/utils/useRoutes'; import {useLastKnownRoute} from 'sentry/views/lastKnownRouteContextProvider'; -import {usePrefersStackedNav} from 'sentry/views/nav/prefersStackedNav'; +import {usePrefersStackedNav} from 'sentry/views/nav/usePrefersStackedNav'; type Props = { newPathPrefix: `/${string}`; diff --git a/static/app/views/nav/userDropdown.spec.tsx b/static/app/views/nav/userDropdown.spec.tsx index 544d63e458023d..cde7077317a1bd 100644 --- a/static/app/views/nav/userDropdown.spec.tsx +++ b/static/app/views/nav/userDropdown.spec.tsx @@ -18,7 +18,7 @@ describe('UserDropdown', function () { expect(screen.getByText('Foo Bar')).toBeInTheDocument(); expect(screen.getByText('foo@example.com')).toBeInTheDocument(); - expect(screen.getByRole('link', {name: 'User Settings'})).toHaveAttribute( + expect(screen.getByRole('menuitemradio', {name: 'User Settings'})).toHaveAttribute( 'href', '/settings/account/' ); @@ -46,7 +46,7 @@ describe('UserDropdown', function () { await userEvent.click(screen.getByRole('button', {name: 'foo@example.com'})); - expect(screen.queryByRole('link', {name: 'Admin'})).not.toBeInTheDocument(); + expect(screen.queryByRole('menuitemradio', {name: 'Admin'})).not.toBeInTheDocument(); }); it('shows admin link if user is admin', async function () { @@ -56,6 +56,9 @@ describe('UserDropdown', function () { await userEvent.click(screen.getByRole('button', {name: 'foo@example.com'})); - expect(screen.getByRole('link', {name: 'Admin'})).toHaveAttribute('href', `/manage/`); + expect(screen.getByRole('menuitemradio', {name: 'Admin'})).toHaveAttribute( + 'href', + `/manage/` + ); }); }); diff --git a/static/app/views/organizationLayout/index.tsx b/static/app/views/organizationLayout/index.tsx index 99b4f664ab637a..60ef71ea735018 100644 --- a/static/app/views/organizationLayout/index.tsx +++ b/static/app/views/organizationLayout/index.tsx @@ -20,8 +20,9 @@ import useOrganization from 'sentry/utils/useOrganization'; import {AppBodyContent} from 'sentry/views/app/appBodyContent'; import Nav from 'sentry/views/nav'; import {NavContextProvider} from 'sentry/views/nav/context'; -import {usePrefersStackedNav} from 'sentry/views/nav/prefersStackedNav'; +import {usePrefersStackedNav} from 'sentry/views/nav/usePrefersStackedNav'; import OrganizationContainer from 'sentry/views/organizationContainer'; +import {useReleasesDrawer} from 'sentry/views/releases/drawer/useReleasesDrawer'; import OrganizationDetailsBody from './body'; @@ -75,6 +76,7 @@ function AppLayout({children, organization}: LayoutProps) { usePerformanceOnboardingDrawer(); useProfilingOnboardingDrawer(); useFeatureFlagOnboardingDrawer(); + useReleasesDrawer(); return ( @@ -95,6 +97,8 @@ function AppLayout({children, organization}: LayoutProps) { } function LegacyAppLayout({children, organization}: LayoutProps) { + useReleasesDrawer(); + return (
diff --git a/static/app/views/organizationStats/pathname.tsx b/static/app/views/organizationStats/pathname.tsx index 46b87c31ad1d52..cb2af605ec64ae 100644 --- a/static/app/views/organizationStats/pathname.tsx +++ b/static/app/views/organizationStats/pathname.tsx @@ -13,7 +13,7 @@ export function makeStatsPathname({ path: '/' | `/${string}/`; }) { return normalizeUrl( - prefersStackedNav() + prefersStackedNav(organization) ? `/organizations/${organization.slug}/${STATS_BASE_PATHNAME}${path}` : `/organizations/${organization.slug}/${LEGACY_STATS_BASE_PATHNAME}${path}` ); diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/index.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/index.tsx index 62c4a7e004249e..4a4d0cd7c52fc0 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/index.tsx +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/index.tsx @@ -42,6 +42,7 @@ import type {TraceTreeNodeDetailsProps} from 'sentry/views/performance/newTraceD import {isEAPSpanNode} from 'sentry/views/performance/newTraceDetails/traceGuards'; import type {TraceTree} from 'sentry/views/performance/newTraceDetails/traceModels/traceTree'; import type {TraceTreeNode} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeNode'; +import {useTraceState} from 'sentry/views/performance/newTraceDetails/traceState/traceStateProvider'; import {ProfileGroupProvider} from 'sentry/views/profiling/profileGroupProvider'; import {ProfileContext, ProfilesProvider} from 'sentry/views/profiling/profilesProvider'; @@ -338,6 +339,8 @@ function EAPSpanNodeDetails({ const avgSpanDuration = useAvgSpanDuration(node.value, location); + const traceState = useTraceState(); + if (isPending) { return ; } @@ -347,6 +350,11 @@ function EAPSpanNodeDetails({ } const attributes = data?.attributes; + const columnCount = + traceState.preferences.layout === 'drawer left' || + traceState.preferences.layout === 'drawer right' + ? 1 + : undefined; return ( @@ -381,6 +389,7 @@ function EAPSpanNodeDetails({ disableCollapsePersistence > - + ) @@ -182,4 +180,9 @@ const ButtonContainer = styled('div')` } `; +const FinalizeButton = styled(Button)` + font-size: ${p => p.theme.fontSizeSmall}; + padding-inline: ${space(0.5)}; +`; + export default ProjectReleaseDetails; diff --git a/static/app/views/releases/drawer/useReleasesDrawer.tsx b/static/app/views/releases/drawer/useReleasesDrawer.tsx index ff2ff8d150e8b4..76edef324d6e4c 100644 --- a/static/app/views/releases/drawer/useReleasesDrawer.tsx +++ b/static/app/views/releases/drawer/useReleasesDrawer.tsx @@ -1,11 +1,12 @@ import {useEffect} from 'react'; import useDrawer from 'sentry/components/globalDrawer'; +import LoadingIndicator from 'sentry/components/loadingIndicator'; import {t} from 'sentry/locale'; +import {useQuery} from 'sentry/utils/queryClient'; import useLocationQuery from 'sentry/utils/url/useLocationQuery'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; -import {ReleasesDrawer} from 'sentry/views/releases/drawer/releasesDrawer'; import { cleanLocationQuery, @@ -20,21 +21,32 @@ export function useReleasesDrawer() { const navigate = useNavigate(); const location = useLocation(); const {openDrawer} = useDrawer(); + // Dynamically import the ReleasesDrawer component to avoid unnecessary bundle size + circular deps with version & versionHoverCard components + const {data: ReleasesDrawer, isPending} = useQuery({ + queryKey: ['ReleasesDrawerComponent'], + queryFn: async () => { + const mod = await import('sentry/views/releases/drawer/releasesDrawer'); + return mod.ReleasesDrawer; + }, + }); useEffect(() => { if (rd === 'show') { - openDrawer(() => , { - shouldCloseOnLocationChange: nextLocation => { - return nextLocation.query[ReleasesDrawerFields.DRAWER] !== 'show'; - }, - ariaLabel: t('Releases drawer'), - transitionProps: {stiffness: 1000}, - onClose: () => { - navigate({ - query: cleanLocationQuery(location.query), - }); - }, - }); + openDrawer( + () => (!isPending && ReleasesDrawer ? : ), + { + shouldCloseOnLocationChange: nextLocation => { + return nextLocation.query[ReleasesDrawerFields.DRAWER] !== 'show'; + }, + ariaLabel: t('Releases drawer'), + transitionProps: {stiffness: 1000}, + onClose: () => { + navigate({ + query: cleanLocationQuery(location.query), + }); + }, + } + ); } - }, [rd, location.query, navigate, openDrawer]); + }, [rd, location.query, navigate, openDrawer, ReleasesDrawer, isPending]); } diff --git a/static/app/views/releases/utils/pathnames.tsx b/static/app/views/releases/utils/pathnames.tsx index fcad5ce3134eb9..c0236339c0393a 100644 --- a/static/app/views/releases/utils/pathnames.tsx +++ b/static/app/views/releases/utils/pathnames.tsx @@ -19,7 +19,7 @@ export function makeReleasesPathname({ path: '/' | `/${string}/`; }) { return normalizeUrl( - prefersStackedNav() + prefersStackedNav(organization) ? `/organizations/${organization.slug}/${RELEASES_BASE_PATHNAME}${path}` : `/organizations/${organization.slug}/${LEGACY_RELEASES_BASE_PATHNAME}${path}` ); @@ -28,15 +28,18 @@ export function makeReleasesPathname({ export function makeReleaseDrawerPathname({ location, release, + projectId, }: { location: Location; release: string; + projectId?: string | string[] | null; }) { return { query: { ...cleanReleaseCursors(location.query), [ReleasesDrawerFields.DRAWER]: 'show', [ReleasesDrawerFields.RELEASE]: release, + [ReleasesDrawerFields.RELEASE_PROJECT_ID]: projectId, }, }; } diff --git a/static/app/views/replays/pathnames.tsx b/static/app/views/replays/pathnames.tsx index 3da69081074996..3cf62bb12727d9 100644 --- a/static/app/views/replays/pathnames.tsx +++ b/static/app/views/replays/pathnames.tsx @@ -13,7 +13,7 @@ export function makeReplaysPathname({ path: '/' | `/${string}/`; }) { return normalizeUrl( - prefersStackedNav() + prefersStackedNav(organization) ? `/organizations/${organization.slug}/${REPLAYS_BASE_PATHNAME}${path}` : `/organizations/${organization.slug}/${LEGACY_REPLAYS_BASE_PATHNAME}${path}` ); diff --git a/static/app/views/settings/account/accountSettingsLayout.tsx b/static/app/views/settings/account/accountSettingsLayout.tsx index 8afc23a75b11e4..b6335b8f90db44 100644 --- a/static/app/views/settings/account/accountSettingsLayout.tsx +++ b/static/app/views/settings/account/accountSettingsLayout.tsx @@ -12,7 +12,7 @@ function AccountSettingsLayout({children, ...props}: Props) { } diff --git a/static/app/views/settings/account/navigationConfiguration.tsx b/static/app/views/settings/account/navigationConfiguration.tsx index 64ccbff8bac031..6ab32662b361bd 100644 --- a/static/app/views/settings/account/navigationConfiguration.tsx +++ b/static/app/views/settings/account/navigationConfiguration.tsx @@ -12,7 +12,7 @@ type ConfigParams = { }; function getConfiguration({organization}: ConfigParams): NavigationSection[] { - if (organization && prefersStackedNav()) { + if (organization && prefersStackedNav(organization)) { return getUserOrgNavigationConfiguration({organization}); } diff --git a/static/app/views/settings/components/settingsNavigation.tsx b/static/app/views/settings/components/settingsNavigation.tsx index de921101c05b7b..ee7c1939deea1a 100644 --- a/static/app/views/settings/components/settingsNavigation.tsx +++ b/static/app/views/settings/components/settingsNavigation.tsx @@ -72,16 +72,24 @@ class SettingsNavigation extends Component { } render() { - const {navigationObjects, hooks, hookConfigs, stickyTop, ...otherProps} = this.props; + const { + navigationObjects, + hooks, + hookConfigs, + stickyTop, + organization, + ...otherProps + } = this.props; const navWithHooks = navigationObjects.concat(hookConfigs); - if (prefersStackedNav()) { + if (organization && prefersStackedNav(organization)) { return ( ); @@ -92,6 +100,7 @@ class SettingsNavigation extends Component { {navWithHooks.map(config => ( diff --git a/static/app/views/settings/organization/navigationConfiguration.tsx b/static/app/views/settings/organization/navigationConfiguration.tsx index 0acaba3b520b73..bd8e22db2b8b4f 100644 --- a/static/app/views/settings/organization/navigationConfiguration.tsx +++ b/static/app/views/settings/organization/navigationConfiguration.tsx @@ -16,7 +16,7 @@ type ConfigParams = { export function getOrganizationNavigationConfiguration({ organization: incomingOrganization, }: ConfigParams): NavigationSection[] { - if (incomingOrganization && prefersStackedNav()) { + if (incomingOrganization && prefersStackedNav(incomingOrganization)) { return getUserOrgNavigationConfiguration({organization: incomingOrganization}); } @@ -150,7 +150,7 @@ export function getOrganizationNavigationConfiguration({ title: t('Stats & Usage'), description: t('View organization stats and usage'), id: 'stats', - show: () => prefersStackedNav(), + show: ({organization}) => !!organization && prefersStackedNav(organization), }, ], }, diff --git a/static/app/views/settings/organization/organizationSettingsLayout.tsx b/static/app/views/settings/organization/organizationSettingsLayout.tsx index b8e21f0fdcf2a8..400d0276e0498d 100644 --- a/static/app/views/settings/organization/organizationSettingsLayout.tsx +++ b/static/app/views/settings/organization/organizationSettingsLayout.tsx @@ -1,5 +1,5 @@ import type {RouteComponentProps} from 'sentry/types/legacyReactRouter'; -import {usePrefersStackedNav} from 'sentry/views/nav/prefersStackedNav'; +import {usePrefersStackedNav} from 'sentry/views/nav/usePrefersStackedNav'; import SettingsLayout from 'sentry/views/settings/components/settingsLayout'; import OrganizationSettingsNavigation from 'sentry/views/settings/organization/organizationSettingsNavigation'; diff --git a/static/app/views/settings/project/projectSettingsLayout.tsx b/static/app/views/settings/project/projectSettingsLayout.tsx index b38ccddda70c23..5aad278feba26a 100644 --- a/static/app/views/settings/project/projectSettingsLayout.tsx +++ b/static/app/views/settings/project/projectSettingsLayout.tsx @@ -4,7 +4,7 @@ import type {RouteComponentProps} from 'sentry/types/legacyReactRouter'; import type {Project} from 'sentry/types/project'; import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams'; import useOrganization from 'sentry/utils/useOrganization'; -import {usePrefersStackedNav} from 'sentry/views/nav/prefersStackedNav'; +import {usePrefersStackedNav} from 'sentry/views/nav/usePrefersStackedNav'; import ProjectContext from 'sentry/views/projects/projectContext'; import SettingsLayout from 'sentry/views/settings/components/settingsLayout'; import ProjectSettingsNavigation from 'sentry/views/settings/project/projectSettingsNavigation'; diff --git a/static/app/views/settings/settingsIndex.tsx b/static/app/views/settings/settingsIndex.tsx index ec319e9206eba7..352e63a83c9ccc 100644 --- a/static/app/views/settings/settingsIndex.tsx +++ b/static/app/views/settings/settingsIndex.tsx @@ -53,13 +53,12 @@ function SettingsIndex(props: SettingsIndexProps) { const supportLinkProps = { isSelfHosted, - organizationSettingsUrl, }; // For the new navigation, we are removing this page. The default base route should // be the organization settings page. // When GAing, this page should be removed and the redirect should be moved to routes.tsx. - if (organization && prefersStackedNav()) { + if (organization && prefersStackedNav(organization)) { return ( - {t('My Account')} + {t('My Account')} @@ -102,35 +101,44 @@ function SettingsIndex(props: SettingsIndexProps) { const orgSettings = ( - - {organization ? ( + {organization ? ( + - ) : ( + {organization.slug} + + ) : ( + - )} - - {organization ? organization.slug : t('No Organization')} - - + {t('Create an Organization')} + + )}

{t('Quick links')}:

-
    -
  • - - {t('Projects')} - -
  • + {organization ? ( +
      +
    • + + {t('Projects')} + +
    • +
    • + {t('Teams')} +
    • +
    • + + {t('Members')} + +
    • +
    + ) : (
  • - {t('Teams')} + {t('Create an organization')}
  • -
  • - {t('Members')} -
  • -
+ )}
); @@ -142,7 +150,7 @@ function SettingsIndex(props: SettingsIndexProps) { - {t('Documentation')} + {t('Documentation')} @@ -176,7 +184,7 @@ function SettingsIndex(props: SettingsIndexProps) { - {t('Support')} + {t('Support')} @@ -210,26 +218,30 @@ function SettingsIndex(props: SettingsIndexProps) { - {t('API Keys')} + {t('API Keys')}

{t('Quick links')}:

    -
  • - - {t('Organization Auth Tokens')} - -
  • + {organizationSettingsUrl && ( +
  • + + {t('Organization Auth Tokens')} + +
  • + )}
  • {t('User Auth Tokens')}
  • -
  • - - {t('Custom Integrations')} - -
  • + {organizationSettingsUrl && ( +
  • + + {t('Custom Integrations')} + +
  • + )}
  • {t('Documentation')} @@ -274,7 +286,7 @@ const HomePanelHeader = styled(PanelHeader)` font-size: ${p => p.theme.fontSizeExtraLarge}; align-items: center; text-transform: unset; - padding: ${space(4)}; + padding: ${space(4)} ${space(4)} 0; `; const HomePanelBody = styled(PanelBody)` @@ -342,27 +354,20 @@ const ExternalHomeLinkIcon = styled(ExternalLink)` interface SupportLinkProps extends Omit { isSelfHosted: boolean; - organizationSettingsUrl: string; icon?: boolean; } -function SupportLink({ - isSelfHosted, - icon, - organizationSettingsUrl, - ...props -}: SupportLinkProps) { +function SupportLink({isSelfHosted, icon, ...props}: SupportLinkProps) { if (isSelfHosted) { const SelfHostedLink = icon ? ExternalHomeLinkIcon : ExternalHomeLink; return ; } const SelfHostedLink = icon ? HomeLinkIcon : HomeLink; - return ; + return ; } -const OrganizationName = styled('div')` - line-height: 1.1em; - +const HomeLinkLabel = styled('div')` + padding-bottom: ${space(4)}; ${p => p.theme.overflowEllipsis}; `; diff --git a/static/app/views/traces/pathnames.tsx b/static/app/views/traces/pathnames.tsx index d70808c55796bb..8904ffeb38392e 100644 --- a/static/app/views/traces/pathnames.tsx +++ b/static/app/views/traces/pathnames.tsx @@ -13,7 +13,7 @@ export function makeTracesPathname({ path: '/' | `/${string}/`; }) { return normalizeUrl( - prefersStackedNav() + prefersStackedNav(organization) ? `/organizations/${organization.slug}/${TRACES_BASE_PATHNAME}${path}` : `/organizations/${organization.slug}/${LEGACY_TRACES_BASE_PATHNAME}${path}` ); diff --git a/static/app/views/userFeedback/pathnames.tsx b/static/app/views/userFeedback/pathnames.tsx index b3d67da25976be..d267b0c9bff051 100644 --- a/static/app/views/userFeedback/pathnames.tsx +++ b/static/app/views/userFeedback/pathnames.tsx @@ -13,7 +13,7 @@ export function makeFeedbackPathname({ path: '/' | `/${string}/`; }) { return normalizeUrl( - prefersStackedNav() + prefersStackedNav(organization) ? `/organizations/${organization.slug}/${FEEDBACK_BASE_PATHNAME}${path}` : `/organizations/${organization.slug}/${LEGACY_FEEDBACK_BASE_PATHNAME}${path}` ); diff --git a/static/gsApp/components/ai/AiSetupDataConsent.tsx b/static/gsApp/components/ai/AiSetupDataConsent.tsx index 74ad5292d6b247..1b7e908e2582ff 100644 --- a/static/gsApp/components/ai/AiSetupDataConsent.tsx +++ b/static/gsApp/components/ai/AiSetupDataConsent.tsx @@ -35,7 +35,11 @@ function AiSetupDataConsent({groupId}: AiSetupDataConsentProps) { }, onSuccess: () => { // Make sure this query key doesn't go out of date with the one on the Sentry side! - queryClient.invalidateQueries({queryKey: [`/issues/${groupId}/autofix/setup/`]}); + queryClient.invalidateQueries({ + queryKey: [ + `/organizations/${organization.slug}/issues/${groupId}/autofix/setup/`, + ], + }); }, }); diff --git a/static/gsApp/components/ai/aiSetupDataConsent.spec.tsx b/static/gsApp/components/ai/aiSetupDataConsent.spec.tsx index 72d11765e454b0..d025954dcd7b06 100644 --- a/static/gsApp/components/ai/aiSetupDataConsent.spec.tsx +++ b/static/gsApp/components/ai/aiSetupDataConsent.spec.tsx @@ -10,7 +10,7 @@ describe('AiSetupDataConsent', () => { it('renders Enable Seer button if nobody in org has acknowledged', async () => { MockApiClient.addMockResponse({ - url: '/issues/1/autofix/setup/', + url: '/organizations/org-slug/issues/1/autofix/setup/', body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: false, @@ -46,7 +46,7 @@ describe('AiSetupDataConsent', () => { it('renders Try Seer button if org has acknowledged but not the user', async () => { MockApiClient.addMockResponse({ - url: '/issues/1/autofix/setup/', + url: '/organizations/org-slug/issues/1/autofix/setup/', body: AutofixSetupFixture({ setupAcknowledgement: { orgHasAcknowledged: true, diff --git a/static/gsApp/components/gsBanner.tsx b/static/gsApp/components/gsBanner.tsx index b99f67ce12c3cd..d0859163713b34 100644 --- a/static/gsApp/components/gsBanner.tsx +++ b/static/gsApp/components/gsBanner.tsx @@ -983,7 +983,7 @@ class GSBanner extends Component { let overquotaPrompt: React.ReactNode; let eventTypes: EventType[] = []; - if (prefersStackedNav()) { + if (prefersStackedNav(organization)) { // new nav uses sidebar quota alert (see quotaExceededNavItem.tsx) return null; } diff --git a/static/gsApp/components/navBillingStatus.tsx b/static/gsApp/components/navBillingStatus.tsx index 217d08b2eb0409..94548a19b04e78 100644 --- a/static/gsApp/components/navBillingStatus.tsx +++ b/static/gsApp/components/navBillingStatus.tsx @@ -16,12 +16,12 @@ import {DataCategory} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; import getDaysSinceDate from 'sentry/utils/getDaysSinceDate'; import type {Color} from 'sentry/utils/theme'; -import {usePrefersStackedNav} from 'sentry/views/nav/prefersStackedNav'; import {SidebarButton} from 'sentry/views/nav/primary/components'; import { PrimaryButtonOverlay, usePrimaryButtonOverlay, } from 'sentry/views/nav/primary/primaryButtonOverlay'; +import {usePrefersStackedNav} from 'sentry/views/nav/usePrefersStackedNav'; import AddEventsCTA, {type EventType} from 'getsentry/components/addEventsCTA'; import useSubscription from 'getsentry/hooks/useSubscription'; diff --git a/static/gsApp/components/trialStartedSidebarItem.tsx b/static/gsApp/components/trialStartedSidebarItem.tsx index b66eebeefe7ad8..e0733b09f519bc 100644 --- a/static/gsApp/components/trialStartedSidebarItem.tsx +++ b/static/gsApp/components/trialStartedSidebarItem.tsx @@ -104,7 +104,7 @@ class TrialStartedSidebarItem extends Component { } renderWithHovercard(hovercardBody: React.ReactNode) { - const prefersNewNav = prefersStackedNav(); + const prefersNewNav = prefersStackedNav(this.props.organization); return ( { } } - const prefersNewNav = prefersStackedNav(); + const prefersNewNav = prefersStackedNav(this.props.organization); const content = ( { return null; } - if (prefersStackedNav()) { + if (prefersStackedNav(organization)) { return ( ); - const radio = await screen.findByText( - 'The project/product/company is shutting down.' - ); + const radio = await screen.findByRole('radio', { + name: 'The project/product/company is shutting down.', + }); expect(radio).toBeInTheDocument(); expect(screen.queryByRole('textbox', {name: 'followup'})).not.toBeInTheDocument(); @@ -81,36 +83,10 @@ describe('CancelSubscription', function () { method: 'DELETE', }); render(); - const radio = await screen.findByText('We are switching to a different solution.'); - expect(radio).toBeInTheDocument(); - - await userEvent.click(radio); - await userEvent.type(screen.getByRole('textbox'), 'Cancellation reason'); - await userEvent.click(screen.getByRole('button', {name: /Cancel Subscription/})); - - expect(mock).toHaveBeenCalledWith( - `/customers/${organization.slug}/`, - expect.objectContaining({ - data: { - reason: 'competitor', - followup: 'Cancellation reason', - checkboxes: [], - }, - }) - ); - }); - - it('calls cancel API with checkboxes', async function () { - const mock = MockApiClient.addMockResponse({ - url: `/customers/${organization.slug}/`, - method: 'DELETE', - }); - render(); - const radio = await screen.findByText("Sentry doesn't fit our needs."); + const radio = await screen.findByRole('radio', {name: 'Other'}); expect(radio).toBeInTheDocument(); await userEvent.click(radio); - await userEvent.click(screen.getByTestId('checkbox-reach_out')); await userEvent.type(screen.getByRole('textbox'), 'Cancellation reason'); await userEvent.click(screen.getByRole('button', {name: /Cancel Subscription/})); @@ -118,9 +94,8 @@ describe('CancelSubscription', function () { `/customers/${organization.slug}/`, expect.objectContaining({ data: { - reason: 'not_a_fit', + reason: 'other', followup: 'Cancellation reason', - checkboxes: ['reach_out'], }, }) ); diff --git a/static/gsApp/views/cancelSubscription.tsx b/static/gsApp/views/cancelSubscription.tsx index 4b86f188df5451..830a9425c8252e 100644 --- a/static/gsApp/views/cancelSubscription.tsx +++ b/static/gsApp/views/cancelSubscription.tsx @@ -2,10 +2,9 @@ import {Fragment, useCallback, useEffect, useState} from 'react'; import styled from '@emotion/styled'; import moment from 'moment-timezone'; -import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; +import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import {Alert} from 'sentry/components/core/alert'; import {Button} from 'sentry/components/core/button'; -import {Checkbox} from 'sentry/components/core/checkbox'; import RadioGroupField from 'sentry/components/forms/fields/radioField'; import TextareaField from 'sentry/components/forms/fields/textareaField'; import Form from 'sentry/components/forms/form'; @@ -35,64 +34,46 @@ import usePromotionTriggerCheck from 'getsentry/utils/usePromotionTriggerCheck'; import withPromotions from 'getsentry/utils/withPromotions'; type CancelReason = [string, React.ReactNode]; -type CancelCheckbox = [string, React.ReactNode]; -const CANCEL_STEPS: Array<{ - followup: React.ReactNode; - reason: CancelReason; - checkboxes?: CancelCheckbox[]; -}> = [ +const CANCEL_STEPS: Array<{followup: React.ReactNode; reason: CancelReason}> = [ { - reason: ['migration', t('Consolidating Sentry accounts.')], - followup: t( - 'If migrating to another existing account, can you provide the org slug?' - ), + reason: ['shutting_down', t('The project/product/company is shutting down.')], + followup: t('Sorry to hear that! Anything more we should know?'), }, { - reason: ['competitor', t('We are switching to a different solution.')], - followup: t("Care to share the solution you've chosen and why?"), + reason: ['only_need_free', t('We only need the features on the free plan.')], + followup: t( + 'Fair enough. Which features on the free plan are most important to you?' + ), }, { reason: ['not_a_fit', t("Sentry doesn't fit our needs.")], - followup: t('Give us more feedback?'), - checkboxes: [ - [ - 'reach_out', - t( - "Prefer to share feedback live? Let us know what you'd like to discuss and we'll have a Product Manager reach out!" - ), - ], - ], + followup: t('Bummer. What features were missing for you?'), }, { - reason: ['pricing_expensive', t('Pricing is too expensive.')], - followup: t('Anything more we should know?'), - }, - { - reason: ['pricing_value', t("I didn't get the value I wanted.")], - followup: t('What was missing?'), + reason: ['competitor', t('We are switching to a different solution.')], + followup: t('Thanks for letting us know. Which solution(s)? Why?'), }, { - reason: ['only_need_free', t('We only need the free plan.')], - followup: t('Fair enough. Anything more we should know?'), - checkboxes: [ - ['features', t("I don't need so much volume.")], - ['volume', t('Developer features are enough for me.')], - ], + reason: ['pricing', t("The pricing doesn't fit our needs.")], + followup: t("What about it wasn't right for you?"), }, { reason: ['self_hosted', t('We are hosting Sentry ourselves.')], followup: t('Are you interested in a single tenant version of Sentry?'), }, { - reason: ['shutting_down', t('The project/product/company is shutting down.')], - followup: t('Sorry to hear that! Anything more we should know?'), + reason: ['no_more_errors', t('We no longer get any errors.')], + followup: t("Congrats! What's your secret?"), + }, + { + reason: ['other', t('Other')], + followup: t('Other reason?'), }, ]; type State = { canSubmit: boolean; - checkboxes: Record; showFollowup: boolean; understandsMembers: boolean; val: CancelReason[0] | null; @@ -101,7 +82,6 @@ type State = { function CancelSubscriptionForm() { const organization = useOrganization(); const navigate = useNavigate(); - const api = useApi(); const {data: subscription, isPending} = useApiQuery( [`/customers/${organization.slug}/`], {staleTime: 0} @@ -111,37 +91,8 @@ function CancelSubscriptionForm() { showFollowup: false, understandsMembers: false, val: null, - checkboxes: {}, }); - const handleSubmitSuccess = (resp: any) => { - subscriptionStore.loadData(organization.slug); - const msg = resp?.responseJSON?.details || t('Successfully cancelled subscription'); - - addSuccessMessage(msg); - navigate({ - pathname: normalizeUrl(`/settings/${organization.slug}/billing/`), - }); - }; - - const handleSubmit = async (data: any) => { - try { - const submitData = { - ...data, - checkboxes: Object.keys(state.checkboxes).filter(key => state.checkboxes[key]), - }; - - const response = await api.requestPromise(`/customers/${subscription?.slug}/`, { - method: 'DELETE', - data: submitData, - }); - - handleSubmitSuccess(response); - } catch (error) { - addErrorMessage(error.responseJSON?.detail || t('Failed to cancel subscription')); - } - }; - if (isPending || !subscription) { return ; } @@ -188,6 +139,16 @@ function CancelSubscriptionForm() { const followup = CANCEL_STEPS.find(cancel => cancel.reason[0] === state.val)?.followup; + const handleSubmitSuccess = (resp: any) => { + subscriptionStore.loadData(organization.slug); + const msg = resp?.responseJSON?.details || t('Successfully cancelled subscription'); + + addSuccessMessage(msg); + navigate({ + pathname: normalizeUrl(`/settings/${organization.slug}/billing/`), + }); + }; + return ( @@ -214,51 +175,27 @@ function CancelSubscriptionForm() { {t('Cancellation Reason')} -
    + {t('Please help us understand why you are cancelling:')} - (cancel => [ - cancel.reason[0], - - {cancel.reason[1]} - {cancel.checkboxes && state.val === cancel.reason[0] && ( - - {cancel.checkboxes.map(([name, label]) => ( - - { - setState(currentState => ({ - ...currentState, - checkboxes: { - ...currentState.checkboxes, - [name]: value.target.checked, - }, - })); - }} - /> - {label} - - ))} - - )} - , - ])} + choices={CANCEL_STEPS.map(cancel => cancel.reason)} onChange={(val: any) => setState(currentState => ({ ...currentState, canSubmit: true, showFollowup: true, - checkboxes: {}, val, })) } @@ -339,33 +276,6 @@ function CancelSubscriptionWrapper({ ); } -const RadioContainer = styled('div')` - display: flex; - flex-direction: column; - - label { - grid-template-columns: max-content 1fr; - grid-template-rows: auto auto; - - > div:last-child { - grid-column: 2; - } - } -`; - -const RadioGroupContainer = styled(RadioGroupField)` - label { - align-items: flex-start; - } -`; - -const ExtraContainer = styled('div')` - display: flex; - align-items: center; - gap: ${space(1)}; - padding: ${space(1)} 0; -`; - export default withSubscription(withPromotions(CancelSubscriptionWrapper), { noLoader: true, }); diff --git a/tests/acceptance/test_explore_logs.py b/tests/acceptance/test_explore_logs.py new file mode 100644 index 00000000000000..09e6c86eca9443 --- /dev/null +++ b/tests/acceptance/test_explore_logs.py @@ -0,0 +1,86 @@ +from datetime import timedelta +from unittest.mock import patch + +from fixtures.page_objects.explore_logs import ExploreLogsPage +from sentry.testutils.cases import AcceptanceTestCase, OurLogTestCase, SnubaTestCase +from sentry.testutils.helpers.datetime import before_now +from sentry.testutils.silo import no_silo_test + +FEATURE_FLAGS = [ + "organizations:ourlogs-enabled", +] + + +@no_silo_test +class ExploreLogsTest(AcceptanceTestCase, SnubaTestCase, OurLogTestCase): + viewname = "sentry-api-0-organization-events" + + def setUp(self): + super().setUp() + self.start = self.day_ago = before_now(days=1).replace( + hour=10, minute=0, second=0, microsecond=0 + ) + + self.start_minus_one_minute = self.start - timedelta(minutes=1) + self.start_minus_two_minutes = self.start - timedelta(minutes=2) + + self.organization = self.create_organization(owner=self.user, name="Rowdy Tiger") + self.team = self.create_team( + organization=self.organization, name="Mariachi Band", members=[self.user] + ) + self.project = self.create_project( + organization=self.organization, teams=[self.team], name="Bengal" + ) + self.features = { + "organizations:ourlogs-enabled": True, + } + self.login_as(self.user) + + self.page = ExploreLogsPage(self.browser, self.client) + self.dismiss_assistant() + + @patch("django.utils.timezone.now") + def test_opening_log_row_shows_attributes(self, mock_now): + mock_now.return_value = self.start + + assert ( + self.browser.driver.get_window_size().get("width") == 1680 + ) # This test makes assertions based on the current default window size. + + with self.feature(FEATURE_FLAGS): + logs = [ + self.create_ourlog( + {"body": "check these attributes"}, + timestamp=self.start_minus_one_minute, + attributes={ + "test.attribute1": {"string_value": "value1"}, + "test.attribute2": {"string_value": "value2"}, + "long_attribute": {"string_value": "a" * 1000}, + "int_attribute": {"int_value": 1234567890}, + "double_attribute": {"double_value": 1234567890.1234567890}, + "bool_attribute": {"bool_value": True}, + "another.attribute": {"string_value": "value3"}, + "nested.attribute1": {"string_value": "nested value1"}, + "nested.attribute2": {"string_value": "nested value2"}, + "nested.attribute3": {"string_value": "nested value3"}, + }, + ), + self.create_ourlog( + {"body": "ignore this log"}, + timestamp=self.start_minus_two_minutes, + ), + ] + self.store_ourlogs(logs) + + self.page.visit_explore_logs(self.organization.slug) + row = self.page.toggle_log_row_with_message("check these attributes") + columns = self.page.get_log_row_columns(row) + assert len(columns) == 2 + + assert "double_attribute" in columns[0].text + assert "1234567890" in columns[0].text + assert "long_attribute" in columns[0].text + assert "a" * 1000 in columns[0].text + assert "nested value1" in columns[1].text + assert "nested value2" in columns[1].text + assert "nested value3" in columns[1].text diff --git a/tests/sentry/api/endpoints/test_organization_index.py b/tests/sentry/api/endpoints/test_organization_index.py index 79efc06f5a058c..37c315bb9323c9 100644 --- a/tests/sentry/api/endpoints/test_organization_index.py +++ b/tests/sentry/api/endpoints/test_organization_index.py @@ -18,7 +18,6 @@ from sentry.silo.base import SiloMode from sentry.slug.patterns import ORG_SLUG_PATTERN from sentry.testutils.cases import APITestCase, TwoFactorAPITestCase -from sentry.testutils.helpers import override_options from sentry.testutils.hybrid_cloud import HybridCloudTestMixin from sentry.testutils.silo import assume_test_silo_mode, create_test_regions, region_silo_test from sentry.users.models.authenticator import Authenticator @@ -317,56 +316,15 @@ def test_data_consent(self): assert org.name == data["name"] assert OrganizationOption.objects.get_value(org, "sentry:aggregated_data_consent") is True - @override_options({"issues.details.streamline-experiment-rollout-rate": 0}) - @override_options({"issues.details.streamline-experiment-split-rate": 1.0}) - def test_streamline_only_is_unset_with_full_split_rate(self): - """ - If the rollout rate is 0%, Ignore split rate, the organization should not be put into a bucket. - """ - self.login_as(user=self.user) - response = self.get_success_response(name="acme") - organization = Organization.objects.get(id=response.data["id"]) - assert ( - OrganizationOption.objects.get_value(organization, "sentry:streamline_ui_only") is None - ) - - @override_options({"issues.details.streamline-experiment-rollout-rate": 0}) - @override_options({"issues.details.streamline-experiment-split-rate": 0}) - def test_streamline_only_is_unset_with_empty_split_rate(self): - """ - If the rollout rate is 0%, Ignore split rate, the organization should not be put into a bucket. - """ - self.login_as(user=self.user) - response = self.get_success_response(name="acme") - organization = Organization.objects.get(id=response.data["id"]) - assert ( - OrganizationOption.objects.get_value(organization, "sentry:streamline_ui_only") is None - ) - - @override_options({"issues.details.streamline-experiment-rollout-rate": 1.0}) - @override_options({"issues.details.streamline-experiment-split-rate": 1.0}) def test_streamline_only_is_true(self): """ - If the rollout rate is 100%, the split rate should be applied to all orgs. - In this case, with a split rate of 100%, all orgs should see the Streamline UI. + All new organizations should never see the legacy UI. """ self.login_as(user=self.user) response = self.get_success_response(name="acme") organization = Organization.objects.get(id=response.data["id"]) assert OrganizationOption.objects.get_value(organization, "sentry:streamline_ui_only") - @override_options({"issues.details.streamline-experiment-rollout-rate": 1.0}) - @override_options({"issues.details.streamline-experiment-split-rate": 0}) - def test_streamline_only_is_false(self): - """ - If the rollout rate is 100%, the split rate should be applied to all orgs. - In this case, with a split rate of 0%, all orgs should see the Legacy UI. - """ - self.login_as(user=self.user) - response = self.get_success_response(name="acme") - organization = Organization.objects.get(id=response.data["id"]) - assert not OrganizationOption.objects.get_value(organization, "sentry:streamline_ui_only") - @region_silo_test(regions=create_test_regions("de", "us")) class OrganizationsCreateInRegionTest(OrganizationIndexTest, HybridCloudTestMixin): diff --git a/tests/sentry/deletions/test_project.py b/tests/sentry/deletions/test_project.py index 10efc44f842591..c2bb970e7faf49 100644 --- a/tests/sentry/deletions/test_project.py +++ b/tests/sentry/deletions/test_project.py @@ -2,6 +2,7 @@ from sentry.deletions.tasks.scheduled import run_scheduled_deletions from sentry.incidents.models.alert_rule import AlertRule from sentry.incidents.models.incident import Incident +from sentry.models.activity import Activity from sentry.models.commit import Commit from sentry.models.commitauthor import CommitAuthor from sentry.models.debugfile import ProjectDebugFile @@ -11,6 +12,7 @@ from sentry.models.group import Group from sentry.models.groupassignee import GroupAssignee from sentry.models.groupmeta import GroupMeta +from sentry.models.groupopenperiod import GroupOpenPeriod from sentry.models.groupresolution import GroupResolution from sentry.models.groupseen import GroupSeen from sentry.models.project import Project @@ -32,6 +34,7 @@ from sentry.testutils.helpers.datetime import before_now from sentry.testutils.hybrid_cloud import HybridCloudTestMixin from sentry.testutils.skips import requires_snuba +from sentry.types.activity import ActivityType from sentry.uptime.models import ProjectUptimeSubscription, UptimeSubscription from sentry.workflow_engine.models import ( DataCondition, @@ -59,6 +62,19 @@ def test_simple(self): event = self.store_event(data={}, project_id=project.id) assert event.group is not None group = event.group + activity = Activity.objects.create( + group=group, + project=project, + type=ActivityType.SET_RESOLVED.value, + user_id=self.user.id, + ) + open_period = GroupOpenPeriod.objects.create( + group=group, + project=project, + date_started=before_now(minutes=1), + date_ended=before_now(minutes=1), + resolution_activity=activity, + ) GroupAssignee.objects.create(group=group, project=project, user_id=self.user.id) GroupMeta.objects.create(group=group, key="foo", value="bar") release = Release.objects.create(version="a" * 32, organization_id=project.organization_id) @@ -166,6 +182,8 @@ def test_simple(self): assert not ServiceHook.objects.filter(id=hook.id).exists() assert not Monitor.objects.filter(id=monitor.id).exists() assert not MonitorEnvironment.objects.filter(id=monitor_env.id).exists() + assert not GroupOpenPeriod.objects.filter(id=open_period.id).exists() + assert not Activity.objects.filter(id=activity.id).exists() assert not MonitorCheckIn.objects.filter(id=checkin.id).exists() assert not QuerySubscription.objects.filter(id=query_sub.id).exists() diff --git a/tests/sentry/hybridcloud/services/test_region_organization_provisioning.py b/tests/sentry/hybridcloud/services/test_region_organization_provisioning.py index 54ee5c6070bace..9b49529de22a66 100644 --- a/tests/sentry/hybridcloud/services/test_region_organization_provisioning.py +++ b/tests/sentry/hybridcloud/services/test_region_organization_provisioning.py @@ -23,7 +23,6 @@ ) from sentry.silo.base import SiloMode from sentry.testutils.cases import TestCase -from sentry.testutils.helpers.options import override_options from sentry.testutils.silo import assume_test_silo_mode, control_silo_test, create_test_regions from sentry.users.models.user import User @@ -211,7 +210,10 @@ def test_does_not_provision_when_organization_slug_already_in_use( with assume_test_silo_mode(SiloMode.REGION): assert not Organization.objects.filter(id=organization_id).exists() - def setup_experiment_options(self) -> int: + def test_streamline_only_is_true(self) -> None: + """ + All new organizations should never see the legacy UI. + """ user = self.create_user() provision_options = self.get_provisioning_args(user) organization_id = 42 @@ -220,32 +222,10 @@ def setup_experiment_options(self) -> int: provision_payload=provision_options, region_name="us", ) - return organization_id - - @override_options({"issues.details.streamline-experiment-rollout-rate": 1.0}) - @override_options({"issues.details.streamline-experiment-split-rate": 1.0}) - def test_organization_experiment_rollout_with_split(self) -> None: - organization_id = self.setup_experiment_options() with assume_test_silo_mode(SiloMode.REGION): org: Organization = Organization.objects.get(id=organization_id) assert OrganizationOption.objects.get_value(org, "sentry:streamline_ui_only") - @override_options({"issues.details.streamline-experiment-rollout-rate": 1.0}) - @override_options({"issues.details.streamline-experiment-split-rate": 0.0}) - def test_organization_experiment_rollout_without_split(self) -> None: - organization_id = self.setup_experiment_options() - with assume_test_silo_mode(SiloMode.REGION): - org: Organization = Organization.objects.get(id=organization_id) - assert not OrganizationOption.objects.get_value(org, "sentry:streamline_ui_only") - - @override_options({"issues.details.streamline-experiment-rollout-rate": 0.0}) - @override_options({"issues.details.streamline-experiment-split-rate": 1.0}) - def test_organization_experiment_no_rollout_ignores_split(self) -> None: - organization_id = self.setup_experiment_options() - with assume_test_silo_mode(SiloMode.REGION): - org: Organization = Organization.objects.get(id=organization_id) - assert OrganizationOption.objects.get_value(org, "sentry:streamline_ui_only") is None - @control_silo_test(regions=create_test_regions("us")) class TestRegionOrganizationProvisioningUpdateOrganizationSlug(TestCase): diff --git a/tests/sentry/incidents/serializers/test_workflow_engine_detector.py b/tests/sentry/incidents/serializers/test_workflow_engine_detector.py new file mode 100644 index 00000000000000..73d27e33c9514e --- /dev/null +++ b/tests/sentry/incidents/serializers/test_workflow_engine_detector.py @@ -0,0 +1,242 @@ +from datetime import timedelta +from typing import Any +from unittest.mock import patch + +from django.utils import timezone + +from sentry.api.serializers import serialize +from sentry.incidents.endpoints.serializers.workflow_engine_detector import ( + WorkflowEngineDetectorSerializer, +) +from sentry.incidents.models.alert_rule import ( + AlertRuleStatus, + AlertRuleThresholdType, + AlertRuleTriggerAction, +) +from sentry.incidents.models.incident import IncidentTrigger, TriggerStatus +from sentry.models.groupopenperiod import GroupOpenPeriod +from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.datetime import freeze_time +from sentry.types.group import PriorityLevel +from sentry.workflow_engine.migration_helpers.alert_rule import ( + migrate_alert_rule, + migrate_metric_action, + migrate_metric_data_conditions, + migrate_resolve_threshold_data_condition, +) +from sentry.workflow_engine.models import ActionGroupStatus + + +@freeze_time("2024-12-11 03:21:34") +class TestDetectorSerializer(TestCase): + def setUp(self) -> None: + self.alert_rule = self.create_alert_rule() + self.critical_trigger = self.create_alert_rule_trigger( + alert_rule=self.alert_rule, label="critical" + ) + self.critical_trigger_action = self.create_alert_rule_trigger_action( + alert_rule_trigger=self.critical_trigger + ) + self.warning_trigger = self.create_alert_rule_trigger( + alert_rule=self.alert_rule, label="warning" + ) + self.warning_trigger_action = self.create_alert_rule_trigger_action( + alert_rule_trigger=self.warning_trigger + ) + _, _, _, self.detector, _, _, _, _ = migrate_alert_rule(self.alert_rule) + self.critical_detector_trigger, _ = migrate_metric_data_conditions(self.critical_trigger) + self.warning_detector_trigger, _ = migrate_metric_data_conditions(self.warning_trigger) + + self.critical_action, _, _ = migrate_metric_action(self.critical_trigger_action) + self.warning_action, _, _ = migrate_metric_action(self.warning_trigger_action) + self.resolve_trigger_data_condition = migrate_resolve_threshold_data_condition( + self.alert_rule + ) + self.expected_critical_action = [ + { + "id": str(self.critical_trigger_action.id), + "alertRuleTriggerId": str(self.critical_trigger.id), + "type": "email", + "targetType": "user", + "targetIdentifier": str(self.user.id), + "inputChannelId": None, + "integrationId": None, + "sentryAppId": None, + "dateCreated": self.critical_trigger_action.date_added, + "desc": f"Send a notification to {self.user.email}", + "priority": self.critical_action.data.get("priority"), + } + ] + self.expected_warning_action = [ + { + "id": str(self.warning_trigger_action.id), + "alertRuleTriggerId": str(self.warning_trigger.id), + "type": "email", + "targetType": "user", + "targetIdentifier": str(self.user.id), + "inputChannelId": None, + "integrationId": None, + "sentryAppId": None, + "dateCreated": self.warning_trigger_action.date_added, + "desc": f"Send a notification to {self.user.email}", + "priority": self.warning_action.data.get("priority"), + } + ] + self.expected_triggers = [ + { + "id": str(self.critical_trigger.id), + "alertRuleId": str(self.alert_rule.id), + "label": "critical", + "thresholdType": AlertRuleThresholdType.ABOVE.value, + "alertThreshold": self.critical_detector_trigger.comparison, + "resolveThreshold": AlertRuleThresholdType.BELOW, + "dateCreated": self.critical_trigger.date_added, + "actions": self.expected_critical_action, + }, + { + "id": str(self.warning_trigger.id), + "alertRuleId": str(self.alert_rule.id), + "label": "warning", + "thresholdType": AlertRuleThresholdType.ABOVE.value, + "alertThreshold": self.critical_detector_trigger.comparison, + "resolveThreshold": AlertRuleThresholdType.BELOW, + "dateCreated": self.critical_trigger.date_added, + "actions": self.expected_warning_action, + }, + ] + + self.expected = { + "id": str(self.alert_rule.id), + "name": self.detector.name, + "organizationId": self.detector.project.organization_id, + "status": AlertRuleStatus.PENDING.value, + "query": self.alert_rule.snuba_query.query, + "aggregate": self.alert_rule.snuba_query.aggregate, + "timeWindow": self.alert_rule.snuba_query.time_window, + "resolution": self.alert_rule.snuba_query.resolution, + "thresholdPeriod": self.detector.config.get("thresholdPeriod"), + "triggers": self.expected_triggers, + "projects": [self.project.slug], + "owner": self.detector.owner_user_id, + "dateModified": self.detector.date_updated, + "dateCreated": self.detector.date_added, + "createdBy": {}, + "description": self.detector.description or "", + "detectionType": self.detector.type, + } + + def test_simple(self) -> None: + serialized_detector = serialize( + self.detector, self.user, WorkflowEngineDetectorSerializer() + ) + assert serialized_detector == self.expected + + def test_latest_incident(self) -> None: + now = timezone.now() + incident = self.create_incident(alert_rule=self.alert_rule, date_started=now) + IncidentTrigger.objects.create( + incident=incident, + alert_rule_trigger=self.critical_trigger, + status=TriggerStatus.ACTIVE.value, + ) + + past_incident = self.create_incident( + alert_rule=self.alert_rule, date_started=now - timedelta(days=1) + ) + IncidentTrigger.objects.create( + incident=past_incident, + alert_rule_trigger=self.critical_trigger, + status=TriggerStatus.ACTIVE.value, + ) + + self.group.priority = PriorityLevel.HIGH + self.group.save() + ActionGroupStatus.objects.create(action=self.critical_action, group=self.group) + GroupOpenPeriod.objects.create( + group=self.group, project=self.detector.project, date_started=incident.date_started + ) + GroupOpenPeriod.objects.create( + group=self.group, project=self.detector.project, date_started=past_incident.date_started + ) + + serialized_detector = serialize( + self.detector, + self.user, + WorkflowEngineDetectorSerializer(expand=["latestIncident"]), + ) + assert serialized_detector["latestIncident"] is not None + assert serialized_detector["latestIncident"]["dateStarted"] == incident.date_started + + @patch("sentry.sentry_apps.components.SentryAppComponentPreparer.run") + def test_sentry_app(self, mock_sentry_app_components_preparer: Any) -> None: + sentry_app = self.create_sentry_app( + organization=self.organization, + published=True, + verify_install=False, + name="Super Awesome App", + schema={"elements": [self.create_alert_rule_action_schema()]}, + ) + install = self.create_sentry_app_installation( + slug=sentry_app.slug, organization=self.organization, user=self.user + ) + self.sentry_app_trigger_action = self.create_alert_rule_trigger_action( + alert_rule_trigger=self.critical_trigger, + type=AlertRuleTriggerAction.Type.SENTRY_APP, + target_identifier=sentry_app.id, + target_type=AlertRuleTriggerAction.TargetType.SENTRY_APP, + sentry_app=sentry_app, + sentry_app_config=[ + {"name": "title", "value": "An alert"}, + ], + ) + self.sentry_app_action, _, _ = migrate_metric_action(self.sentry_app_trigger_action) + + # add a sentry app action and update expected actions + sentry_app_action_data = { + "id": str(self.sentry_app_trigger_action.id), + "alertRuleTriggerId": str(self.critical_trigger.id), + "type": "sentry_app", + "targetType": "sentry_app", + "targetIdentifier": sentry_app.id, + "inputChannelId": None, + "integrationId": None, + "sentryAppId": sentry_app.id, + "dateCreated": self.sentry_app_trigger_action.date_added, + "desc": f"Send a notification via {sentry_app.name}", + "priority": self.critical_action.data.get("priority"), + "settings": [{"name": "title", "label": None, "value": "An alert"}], + "sentryAppInstallationUuid": install.uuid, + "formFields": { + "type": "alert-rule-settings", + "uri": "/sentry/alert-rule", + "required_fields": [ + {"type": "text", "name": "title", "label": "Title"}, + {"type": "text", "name": "summary", "label": "Summary"}, + ], + "optional_fields": [ + { + "type": "select", + "name": "points", + "label": "Points", + "options": [["1", "1"], ["2", "2"], ["3", "3"], ["5", "5"], ["8", "8"]], + }, + { + "type": "select", + "name": "assignee", + "label": "Assignee", + "uri": "/sentry/members", + }, + ], + }, + } + sentry_app_expected = self.expected.copy() + expected_critical_action = self.expected_critical_action.copy() + expected_critical_action.append(sentry_app_action_data) + sentry_app_expected["triggers"][0]["actions"] = expected_critical_action + + serialized_detector = serialize( + self.detector, + self.user, + WorkflowEngineDetectorSerializer(prepare_component_fields=True), + ) + assert serialized_detector == sentry_app_expected diff --git a/tests/sentry/ingest/test_transaction_clusterer.py b/tests/sentry/ingest/test_transaction_clusterer.py index 472aed36b4c98a..681da5e728ad44 100644 --- a/tests/sentry/ingest/test_transaction_clusterer.py +++ b/tests/sentry/ingest/test_transaction_clusterer.py @@ -9,6 +9,7 @@ _get_redis_key, _record_sample, clear_samples, + get_active_project_ids, get_active_projects, get_redis_client, get_transaction_names, @@ -345,6 +346,30 @@ def _add_mock_data(proj, number): ) +# From the test -- number of transactions: 30 == 10 * 2 + 5 * 2 +@mock.patch("sentry.ingest.transaction_clusterer.datasource.redis.MAX_SET_SIZE", 30) +@mock.patch("sentry.ingest.transaction_clusterer.tasks.MERGE_THRESHOLD", 5) +@mock.patch("sentry.ingest.transaction_clusterer.tasks.cluster_projects.delay") +@django_db_all +@freeze_time("2000-01-01 01:00:00") +def test_run_clusterer_spawn_cluster_projects(cluster_projects_delay, default_organization): + def _add_mock_data(proj, number): + for i in range(0, number): + _record_sample(ClustererNamespace.TRANSACTIONS, proj, f"/user/tx-{proj.name}-{i}") + _record_sample(ClustererNamespace.TRANSACTIONS, proj, f"/org/tx-{proj.name}-{i}") + + project1 = Project(id=123, name="project1", organization_id=default_organization.id) + project2 = Project(id=223, name="project2", organization_id=default_organization.id) + for project in (project1, project2): + project.save() + _add_mock_data(project, 4) + + spawn_clusterers() + + assert cluster_projects_delay.call_count == 1 + cluster_projects_delay.assert_called_once_with(project_ids=[project1.id, project2.id]) + + @mock.patch("sentry.ingest.transaction_clusterer.datasource.redis.MAX_SET_SIZE", 2) @mock.patch("sentry.ingest.transaction_clusterer.tasks.MERGE_THRESHOLD", 2) @mock.patch("sentry.ingest.transaction_clusterer.rules.update_rules") @@ -354,7 +379,7 @@ def test_clusterer_only_runs_when_enough_transactions(mock_update_rules, default assert get_rules(ClustererNamespace.TRANSACTIONS, project) == {} _record_sample(ClustererNamespace.TRANSACTIONS, project, "/transaction/number/1") - cluster_projects([project]) + cluster_projects(project_ids=[project.id]) # Clusterer didn't create rules. Still, it updates the stores. assert mock_update_rules.call_count == 1 assert mock_update_rules.call_args == mock.call(ClustererNamespace.TRANSACTIONS, project, []) @@ -363,18 +388,34 @@ def test_clusterer_only_runs_when_enough_transactions(mock_update_rules, default _record_sample(ClustererNamespace.TRANSACTIONS, project, "/transaction/number/1") _record_sample(ClustererNamespace.TRANSACTIONS, project, "/transaction/number/2") - cluster_projects([project]) + cluster_projects(project_ids=[project.id]) assert mock_update_rules.call_count == 2 assert mock_update_rules.call_args == mock.call( ClustererNamespace.TRANSACTIONS, project, ["/transaction/number/*/**"] ) +@mock.patch("sentry.ingest.transaction_clusterer.datasource.redis.MAX_SET_SIZE", 2) +@mock.patch("sentry.ingest.transaction_clusterer.tasks.MERGE_THRESHOLD", 2) +@mock.patch("sentry.ingest.transaction_clusterer.rules.update_rules") +@django_db_all +def test_clusterer_skips_deleted_projects(mock_update_rules, default_project): + project = default_project + assert get_rules(ClustererNamespace.TRANSACTIONS, project) == {} + + _record_sample(ClustererNamespace.TRANSACTIONS, project, "/transaction/number/1") + # The 6666 record doesn't exist, the task should not fail + cluster_projects(project_ids=[project.id, 6666]) + assert mock_update_rules.call_count == 1 + mock_update_rules.assert_called_once_with(ClustererNamespace.TRANSACTIONS, project, []) + + @django_db_all def test_get_deleted_project(): deleted_project = Project(pk=666, organization=Organization(pk=666)) _record_sample(ClustererNamespace.TRANSACTIONS, deleted_project, "foo") assert list(get_active_projects(ClustererNamespace.TRANSACTIONS)) == [] + assert list(get_active_project_ids(ClustererNamespace.TRANSACTIONS)) == [666] @django_db_all diff --git a/tests/sentry/integrations/bitbucket_server/test_client.py b/tests/sentry/integrations/bitbucket_server/test_client.py index dffeb270889b04..db1646b89d477d 100644 --- a/tests/sentry/integrations/bitbucket_server/test_client.py +++ b/tests/sentry/integrations/bitbucket_server/test_client.py @@ -1,20 +1,30 @@ import orjson +import pytest import responses from django.test import override_settings from requests import Request from fixtures.bitbucket_server import REPO -from sentry.integrations.bitbucket_server.client import ( - BitbucketServerAPIPath, - BitbucketServerClient, -) +from sentry.integrations.bitbucket_server.integration import BitbucketServerIntegration +from sentry.integrations.bitbucket_server.utils import BitbucketServerAPIPath +from sentry.models.repository import Repository +from sentry.shared_integrations.exceptions import ApiError +from sentry.shared_integrations.response.base import BaseApiResponse +from sentry.silo.base import SiloMode from sentry.testutils.cases import BaseTestCase, TestCase -from sentry.testutils.silo import control_silo_test +from sentry.testutils.helpers.integrations import get_installation_of_type +from sentry.testutils.silo import assume_test_silo_mode, control_silo_test from tests.sentry.integrations.jira_server import EXAMPLE_PRIVATE_KEY control_address = "http://controlserver" secret = "hush-hush-im-invisible" +BITBUCKET_SERVER_CODEOWNERS = { + "filepath": ".bitbucket/CODEOWNERS", + "html_url": "https://bitbucket.example.com/projects/PROJ/repos/repository-name/browse/.bitbucket/CODEOWNERS?at=master", + "raw": "docs/* @jianyuan @getsentry/ecosystem\n* @jianyuan\n", +} + @override_settings( SENTRY_SUBNET_SECRET=secret, @@ -44,8 +54,23 @@ def setUp(self): self.integration.add_organization( self.organization, self.user, default_auth_id=self.identity.id ) - self.install = self.integration.get_installation(self.organization.id) - self.bb_server_client: BitbucketServerClient = self.install.get_client() + self.install = get_installation_of_type( + BitbucketServerIntegration, self.integration, self.organization.id + ) + self.bb_server_client = self.install.get_client() + + with assume_test_silo_mode(SiloMode.REGION): + self.repo = Repository.objects.create( + provider=self.integration.provider, + name="PROJ/repository-name", + organization_id=self.organization.id, + config={ + "name": "TEST/repository-name", + "project": "PROJ", + "repo": "repository-name", + }, + integration_id=self.integration.id, + ) def test_authorize_request(self): method = "GET" @@ -81,3 +106,126 @@ def test_get_repo_authentication(self): assert len(responses.calls) == 1 assert "oauth_consumer_key" in responses.calls[0].request.headers["Authorization"] + + @responses.activate + def test_check_file(self): + path = "src/sentry/integrations/bitbucket_server/client.py" + version = "master" + url = self.bb_server_client.base_url + BitbucketServerAPIPath.build_source( + project=self.repo.config["project"], + repo=self.repo.config["repo"], + path=path, + sha=version, + ) + + responses.add( + responses.HEAD, + url=url, + status=200, + ) + + resp = self.bb_server_client.check_file(self.repo, path, version) + assert isinstance(resp, BaseApiResponse) + assert resp.status_code == 200 + + @responses.activate + def test_check_no_file(self): + path = "src/santry/integrations/bitbucket_server/client.py" + version = "master" + url = self.bb_server_client.base_url + BitbucketServerAPIPath.build_source( + project=self.repo.config["project"], + repo=self.repo.config["repo"], + path=path, + sha=version, + ) + + responses.add( + responses.HEAD, + url=url, + status=404, + ) + + with pytest.raises(ApiError): + self.bb_server_client.check_file(self.repo, path, version) + + @responses.activate + def test_get_file(self): + path = "src/sentry/integrations/bitbucket_server/client.py" + version = "master" + url = self.bb_server_client.base_url + BitbucketServerAPIPath.build_raw( + project=self.repo.config["project"], + repo=self.repo.config["repo"], + path=path, + sha=version, + ) + + responses.add( + responses.GET, + url=url, + body="Hello, world!", + status=200, + ) + + resp = self.bb_server_client.get_file(self.repo, path, version) + assert resp == "Hello, world!" + + @responses.activate + def test_get_stacktrace_link(self): + path = "src/sentry/integrations/bitbucket/client.py" + version = "master" + url = self.bb_server_client.base_url + BitbucketServerAPIPath.build_source( + project=self.repo.config["project"], + repo=self.repo.config["repo"], + path=path, + sha=version, + ) + + responses.add( + method=responses.HEAD, + url=url, + status=200, + ) + + source_url = self.install.get_stacktrace_link(self.repo, path, "master", version) + assert ( + source_url + == "https://bitbucket.example.com/projects/PROJ/repos/repository-name/browse/src/sentry/integrations/bitbucket/client.py?at=master" + ) + + @responses.activate + def test_get_codeowner_file(self): + self.config = self.create_code_mapping( + repo=self.repo, + project=self.project, + ) + + path = ".bitbucket/CODEOWNERS" + source_url = self.bb_server_client.base_url + BitbucketServerAPIPath.build_source( + project=self.repo.config["project"], + repo=self.repo.config["repo"], + path=path, + sha=self.config.default_branch, + ) + raw_url = self.bb_server_client.base_url + BitbucketServerAPIPath.build_raw( + project=self.repo.config["project"], + repo=self.repo.config["repo"], + path=path, + sha=self.config.default_branch, + ) + + responses.add( + method=responses.HEAD, + url=source_url, + status=200, + ) + responses.add( + method=responses.GET, + url=raw_url, + content_type="text/plain", + body=BITBUCKET_SERVER_CODEOWNERS["raw"], + ) + + result = self.install.get_codeowner_file( + self.config.repository, ref=self.config.default_branch + ) + assert result == BITBUCKET_SERVER_CODEOWNERS diff --git a/tests/sentry/integrations/bitbucket_server/test_integration.py b/tests/sentry/integrations/bitbucket_server/test_integration.py index 1daf7db9af59e7..9c772d57e32874 100644 --- a/tests/sentry/integrations/bitbucket_server/test_integration.py +++ b/tests/sentry/integrations/bitbucket_server/test_integration.py @@ -1,3 +1,4 @@ +from functools import cached_property from unittest.mock import patch import responses @@ -7,9 +8,11 @@ from sentry.integrations.bitbucket_server.integration import BitbucketServerIntegrationProvider from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration +from sentry.models.repository import Repository +from sentry.silo.base import SiloMode from sentry.testutils.asserts import assert_failure_metric from sentry.testutils.cases import IntegrationTestCase -from sentry.testutils.silo import control_silo_test +from sentry.testutils.silo import assume_test_silo_mode, control_silo_test from sentry.users.models.identity import Identity, IdentityProvider @@ -17,6 +20,21 @@ class BitbucketServerIntegrationTest(IntegrationTestCase): provider = BitbucketServerIntegrationProvider + @cached_property + @assume_test_silo_mode(SiloMode.CONTROL) + def integration(self): + integration = Integration.objects.create( + provider=self.provider.key, + name="Bitbucket Server", + external_id="bitbucket_server:1", + metadata={ + "base_url": "https://bitbucket.example.com", + "domain_name": "bitbucket.example.com", + }, + ) + integration.add_organization(self.organization, self.user) + return integration + def test_config_view(self): resp = self.client.get(self.init_path) assert resp.status_code == 200 @@ -348,3 +366,113 @@ def test_setup_external_id_length(self): integration.external_id == "bitbucket.example.com:a-very-long-consumer-key-that-when-combine" ) + + def test_source_url_matches(self): + installation = self.integration.get_installation(self.organization.id) + + test_cases = [ + ( + "https://bitbucket.example.com/projects/TEST/repos/sentry/browse/src/sentry/integrations/bitbucket_server/integration.py?at=main", + True, + ), + ( + "https://notbitbucket.example.com/projects/TEST/repos/sentry/browse/src/sentry/integrations/bitbucket_server/integration.py?at=main", + False, + ), + ( + "https://jianyuan.io", + False, + ), + ] + + for source_url, matches in test_cases: + assert installation.source_url_matches(source_url) == matches + + def test_format_source_url(self): + installation = self.integration.get_installation(self.organization.id) + + with assume_test_silo_mode(SiloMode.REGION): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="TEST/sentry", + url="https://bitbucket.example.com/projects/TEST/repos/sentry/browse", + provider=self.provider.key, + external_id=123, + config={"name": "TEST/sentry", "project": "TEST", "repo": "sentry"}, + integration_id=self.integration.id, + ) + + assert ( + installation.format_source_url( + repo, "src/sentry/integrations/bitbucket_server/integration.py", None + ) + == "https://bitbucket.example.com/projects/TEST/repos/sentry/browse/src/sentry/integrations/bitbucket_server/integration.py" + ) + assert ( + installation.format_source_url( + repo, "src/sentry/integrations/bitbucket_server/integration.py", "main" + ) + == "https://bitbucket.example.com/projects/TEST/repos/sentry/browse/src/sentry/integrations/bitbucket_server/integration.py?at=main" + ) + + def test_extract_branch_from_source_url(self): + installation = self.integration.get_installation(self.organization.id) + + with assume_test_silo_mode(SiloMode.REGION): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="TEST/sentry", + url="https://bitbucket.example.com/projects/TEST/repos/sentry/browse", + provider=self.provider.key, + external_id=123, + config={"name": "TEST/sentry", "project": "TEST", "repo": "sentry"}, + integration_id=self.integration.id, + ) + + # ?at=main + assert ( + installation.extract_branch_from_source_url( + repo, + "https://bitbucket.example.com/projects/TEST/repos/sentry/browse/src/sentry/integrations/bitbucket_server/integration.py?at=main", + ) + == "main" + ) + # ?at=refs/heads/main + assert ( + installation.extract_branch_from_source_url( + repo, + "https://bitbucket.example.com/projects/TEST/repos/sentry/browse/src/sentry/integrations/bitbucket_server/integration.py?at=refs%2Fheads%2Fmain", + ) + == "main" + ) + + def test_extract_source_path_from_source_url(self): + installation = self.integration.get_installation(self.organization.id) + + with assume_test_silo_mode(SiloMode.REGION): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="TEST/sentry", + url="https://bitbucket.example.com/projects/TEST/repos/sentry/browse", + provider=self.provider.key, + external_id=123, + config={"name": "TEST/sentry", "project": "TEST", "repo": "sentry"}, + integration_id=self.integration.id, + ) + + test_cases = [ + ( + "https://bitbucket.example.com/projects/TEST/repos/sentry/browse/src/sentry/integrations/bitbucket_server/integration.py", + "src/sentry/integrations/bitbucket_server/integration.py", + ), + ( + "https://bitbucket.example.com/projects/TEST/repos/sentry/browse/src/sentry/integrations/bitbucket_server/integration.py?at=main", + "src/sentry/integrations/bitbucket_server/integration.py", + ), + ( + "https://bitbucket.example.com/projects/TEST/repos/sentry/browse/src/sentry/integrations/bitbucket_server/integration.py?at=refs%2Fheads%2Fmain", + "src/sentry/integrations/bitbucket_server/integration.py", + ), + ] + for source_url, expected in test_cases: + assert installation.extract_source_path_from_source_url(repo, source_url) == expected diff --git a/tests/sentry/integrations/github/test_client.py b/tests/sentry/integrations/github/test_client.py index 436c37f845f112..22944adc0df81d 100644 --- a/tests/sentry/integrations/github/test_client.py +++ b/tests/sentry/integrations/github/test_client.py @@ -24,6 +24,7 @@ SourceLineInfo, ) from sentry.integrations.types import EventLifecycleOutcome +from sentry.models.pullrequest import PullRequest, PullRequestComment from sentry.models.repository import Repository from sentry.shared_integrations.exceptions import ApiError, ApiRateLimitedError from sentry.shared_integrations.response.base import BaseApiResponse @@ -330,6 +331,54 @@ def test_update_comment(self, get_jwt): assert responses.calls[1].response.status_code == 200 assert responses.calls[1].request.body == b'{"body": "world"}' + @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") + @responses.activate + def test_update_pr_comment(self, get_jwt): + responses.add( + method=responses.POST, + url=f"https://api.github.com/repos/{self.repo.name}/issues/1/comments", + status=201, + json={ + "id": 1, + "node_id": "MDEyOklzc3VlQ29tbWVudDE=", + "url": f"https://api.github.com/repos/{self.repo.name}/issues/comments/1", + "html_url": f"https://github.com/{self.repo.name}/issues/1#issuecomment-1", + "body": "hello", + "created_at": "2023-05-23T17:00:00Z", + "updated_at": "2023-05-23T17:00:00Z", + "issue_url": f"https://api.github.com/repos/{self.repo.name}/issues/1", + "author_association": "COLLABORATOR", + }, + ) + self.github_client.create_pr_comment( + repo=self.repo, pr=PullRequest(key="1"), data={"body": "hello"} + ) + + responses.add( + method=responses.PATCH, + url=f"https://api.github.com/repos/{self.repo.name}/issues/comments/1", + json={ + "id": 1, + "node_id": "MDEyOklzc3VlQ29tbWVudDE=", + "url": f"https://api.github.com/repos/{self.repo.name}/issues/comments/1", + "html_url": f"https://github.com/{self.repo.name}/issues/1#issuecomment-1", + "body": "world", + "created_at": "2011-04-14T16:00:49Z", + "updated_at": "2011-04-14T16:00:49Z", + "issue_url": f"https://api.github.com/repos/{self.repo.name}/issues/1", + "author_association": "COLLABORATOR", + }, + ) + + self.github_client.update_pr_comment( + repo=self.repo, + pr=PullRequest(key="1"), + pr_comment=PullRequestComment(external_id="1"), + data={"body": "world"}, + ) + assert responses.calls[1].response.status_code == 200 + assert responses.calls[1].request.body == b'{"body": "world"}' + @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @responses.activate def test_get_comment_reactions(self, get_jwt): diff --git a/tests/sentry/integrations/gitlab/tasks/test_pr_comment.py b/tests/sentry/integrations/gitlab/tasks/test_pr_comment.py new file mode 100644 index 00000000000000..6c68a525a33dbe --- /dev/null +++ b/tests/sentry/integrations/gitlab/tasks/test_pr_comment.py @@ -0,0 +1,471 @@ +import logging +from datetime import UTC, datetime, timedelta +from unittest.mock import patch + +import pytest +import responses +from django.utils import timezone + +from fixtures.gitlab import GitLabTestCase +from sentry.integrations.gitlab.integration import GitlabIntegration +from sentry.integrations.source_code_management.tasks import pr_comment_workflow +from sentry.models.commit import Commit +from sentry.models.group import Group +from sentry.models.groupowner import GroupOwner, GroupOwnerType +from sentry.models.pullrequest import ( + CommentType, + PullRequest, + PullRequestComment, + PullRequestCommit, +) +from sentry.shared_integrations.exceptions import ApiError +from sentry.tasks.commit_context import DEBOUNCE_PR_COMMENT_CACHE_KEY +from sentry.testutils.cases import SnubaTestCase +from sentry.testutils.helpers.datetime import before_now, freeze_time +from sentry.testutils.helpers.integrations import get_installation_of_type +from sentry.utils import json +from sentry.utils.cache import cache + + +class GitlabCommentTestCase(GitLabTestCase): + def setUp(self): + super().setUp() + self.installation = get_installation_of_type( + GitlabIntegration, integration=self.integration, org_id=self.organization.id + ) + self.pr_comment_workflow = self.installation.get_pr_comment_workflow() + self.another_integration = self.create_integration( + organization=self.organization, external_id="1", provider="github" + ) + self.another_org_user = self.create_user("foo@localhost") + self.another_organization = self.create_organization( + name="Foobar", owner=self.another_org_user + ) + self.another_team = self.create_team(organization=self.organization, name="Mariachi Band") + self.another_org_project = self.create_project( + organization=self.another_organization, teams=[self.another_team], name="Bengal" + ) + self.another_org_integration = self.create_integration( + organization=self.another_organization, external_id="1", provider="gitlab" + ) + self.user_to_commit_author_map = { + self.user: self.create_commit_author(project=self.project, user=self.user), + self.another_org_user: self.create_commit_author( + project=self.another_org_project, user=self.another_org_user + ), + } + self.repo = self.create_repo(name="Get Sentry / Example Repo", external_id=123) + self.pr_key = 1 + self.commit_sha = 1 + self.fingerprint = 1 + + def add_commit_to_repo(self, repo, user, project): + if user not in self.user_to_commit_author_map: + self.user_to_commit_author_map[user] = self.create_commit_author( + project=repo.project, user=user + ) + commit = self.create_commit( + project=project, + repo=repo, + author=self.user_to_commit_author_map[user], + key=str(self.commit_sha), + message=str(self.commit_sha), + ) + self.commit_sha += 1 + return commit + + def add_pr_to_commit(self, commit: Commit, date_added=None): + if date_added is None: + date_added = before_now(minutes=1) + pr = PullRequest.objects.create( + organization_id=commit.organization_id, + repository_id=commit.repository_id, + key=str(self.pr_key), + author=commit.author, + message="foo", + title="bar", + merge_commit_sha=commit.key, + date_added=date_added, + ) + self.pr_key += 1 + self.add_branch_commit_to_pr(commit, pr) + return pr + + def add_branch_commit_to_pr(self, commit: Commit, pr: PullRequest): + pr_commit = PullRequestCommit.objects.create(pull_request=pr, commit=commit) + return pr_commit + + def add_groupowner_to_commit(self, commit: Commit, project, user): + event = self.store_event( + data={ + "message": f"issue {self.fingerprint}", + "culprit": f"issue{self.fingerprint}", + "fingerprint": [f"issue{self.fingerprint}"], + }, + project_id=project.id, + ) + assert event.group is not None + self.fingerprint += 1 + groupowner = GroupOwner.objects.create( + group=event.group, + user_id=user.id, + project=project, + organization_id=commit.organization_id, + type=GroupOwnerType.SUSPECT_COMMIT.value, + context={"commitId": commit.id}, + ) + return groupowner + + def create_pr_issues(self, repo=None): + if repo is None: + repo = self.repo + + commit_1 = self.add_commit_to_repo(repo, self.user, self.project) + pr = self.add_pr_to_commit(commit_1) + self.add_groupowner_to_commit(commit_1, self.project, self.user) + self.add_groupowner_to_commit(commit_1, self.another_org_project, self.another_org_user) + + return pr + + +class TestPrToIssueQuery(GitlabCommentTestCase): + def test_simple(self): + """one pr with one issue""" + commit = self.add_commit_to_repo(self.repo, self.user, self.project) + pr = self.add_pr_to_commit(commit) + groupowner = self.add_groupowner_to_commit(commit, self.project, self.user) + + results = self.pr_comment_workflow.get_issue_ids_from_pr(pr=pr) + + assert results == [groupowner.group_id] + + def test_multiple_issues(self): + """one pr with multiple issues""" + commit = self.add_commit_to_repo(self.repo, self.user, self.project) + pr = self.add_pr_to_commit(commit) + groupowner_1 = self.add_groupowner_to_commit(commit, self.project, self.user) + groupowner_2 = self.add_groupowner_to_commit(commit, self.project, self.user) + groupowner_3 = self.add_groupowner_to_commit(commit, self.project, self.user) + + results = self.pr_comment_workflow.get_issue_ids_from_pr(pr=pr) + + assert results == [groupowner_1.group_id, groupowner_2.group_id, groupowner_3.group_id] + + def test_multiple_prs(self): + """multiple eligible PRs with one issue each""" + commit_1 = self.add_commit_to_repo(self.repo, self.user, self.project) + commit_2 = self.add_commit_to_repo(self.repo, self.user, self.project) + pr_1 = self.add_pr_to_commit(commit_1) + pr_2 = self.add_pr_to_commit(commit_2) + groupowner_1 = self.add_groupowner_to_commit(commit_1, self.project, self.user) + groupowner_2 = self.add_groupowner_to_commit(commit_2, self.project, self.user) + + results = self.pr_comment_workflow.get_issue_ids_from_pr(pr=pr_1) + assert results == [groupowner_1.group_id] + + results = self.pr_comment_workflow.get_issue_ids_from_pr(pr=pr_2) + assert results == [groupowner_2.group_id] + + def test_multiple_commits(self): + """Multiple eligible commits with one issue each""" + commit_1 = self.add_commit_to_repo(self.repo, self.user, self.project) + commit_2 = self.add_commit_to_repo(self.repo, self.user, self.project) + pr = self.add_pr_to_commit(commit_1) + self.add_branch_commit_to_pr(commit_2, pr) + groupowner_1 = self.add_groupowner_to_commit(commit_1, self.project, self.user) + groupowner_2 = self.add_groupowner_to_commit(commit_2, self.project, self.user) + results = self.pr_comment_workflow.get_issue_ids_from_pr(pr=pr) + assert results == [groupowner_1.group_id, groupowner_2.group_id] + + +class TestTop5IssuesByCount(SnubaTestCase, GitlabCommentTestCase): + def test_simple(self): + group1 = [ + self.store_event( + {"fingerprint": ["group-1"], "timestamp": before_now(days=1).isoformat()}, + project_id=self.project.id, + ) + for _ in range(3) + ][0].group.id + group2 = [ + self.store_event( + {"fingerprint": ["group-2"], "timestamp": before_now(days=1).isoformat()}, + project_id=self.project.id, + ) + for _ in range(6) + ][0].group.id + group3 = [ + self.store_event( + {"fingerprint": ["group-3"], "timestamp": before_now(days=1).isoformat()}, + project_id=self.project.id, + ) + for _ in range(4) + ][0].group.id + res = self.pr_comment_workflow.get_top_5_issues_by_count( + [group1, group2, group3], self.project + ) + assert [issue["group_id"] for issue in res] == [group2, group3, group1] + + def test_over_5_issues(self): + issue_ids = [ + self.store_event( + {"fingerprint": [f"group-{idx}"], "timestamp": before_now(days=1).isoformat()}, + project_id=self.project.id, + ).group.id + for idx in range(6) + ] + res = self.pr_comment_workflow.get_top_5_issues_by_count(issue_ids, self.project) + assert len(res) == 5 + + def test_ignore_info_level_issues(self): + group1 = [ + self.store_event( + { + "fingerprint": ["group-1"], + "timestamp": before_now(days=1).isoformat(), + "level": logging.INFO, + }, + project_id=self.project.id, + ) + for _ in range(3) + ][0].group.id + group2 = [ + self.store_event( + {"fingerprint": ["group-2"], "timestamp": before_now(days=1).isoformat()}, + project_id=self.project.id, + ) + for _ in range(6) + ][0].group.id + group3 = [ + self.store_event( + { + "fingerprint": ["group-3"], + "timestamp": before_now(days=1).isoformat(), + "level": logging.INFO, + }, + project_id=self.project.id, + ) + for _ in range(4) + ][0].group.id + res = self.pr_comment_workflow.get_top_5_issues_by_count( + [group1, group2, group3], self.project + ) + assert [issue["group_id"] for issue in res] == [group2] + + def test_do_not_ignore_other_issues(self): + group1 = [ + self.store_event( + { + "fingerprint": ["group-1"], + "timestamp": before_now(days=1).isoformat(), + "level": logging.ERROR, + }, + project_id=self.project.id, + ) + for _ in range(3) + ][0].group.id + group2 = [ + self.store_event( + { + "fingerprint": ["group-2"], + "timestamp": before_now(days=1).isoformat(), + "level": logging.INFO, + }, + project_id=self.project.id, + ) + for _ in range(6) + ][0].group.id + group3 = [ + self.store_event( + { + "fingerprint": ["group-3"], + "timestamp": before_now(days=1).isoformat(), + "level": logging.DEBUG, + }, + project_id=self.project.id, + ) + for _ in range(4) + ][0].group.id + res = self.pr_comment_workflow.get_top_5_issues_by_count( + [group1, group2, group3], self.project + ) + assert [issue["group_id"] for issue in res] == [group3, group1] + + +class TestGetCommentBody(GitlabCommentTestCase): + def test_simple(self): + ev1 = self.store_event( + data={"message": "issue 1", "culprit": "issue1", "fingerprint": ["group-1"]}, + project_id=self.project.id, + ) + assert ev1.group is not None + ev2 = self.store_event( + data={"message": "issue 2", "culprit": "issue2", "fingerprint": ["group-2"]}, + project_id=self.project.id, + ) + assert ev2.group is not None + ev3 = self.store_event( + data={"message": "issue 3", "culprit": "issue3", "fingerprint": ["group-3"]}, + project_id=self.project.id, + ) + assert ev3.group is not None + formatted_comment = self.pr_comment_workflow.get_comment_body( + [ev1.group.id, ev2.group.id, ev3.group.id] + ) + + expected_comment = f"""## Suspect Issues +This merge request was deployed and Sentry observed the following issues: + +- ‼️ **issue 1** `issue1` [View Issue](http://testserver/organizations/baz/issues/{ev1.group.id}/?referrer=gitlab-pr-bot) +- ‼️ **issue 2** `issue2` [View Issue](http://testserver/organizations/baz/issues/{ev2.group.id}/?referrer=gitlab-pr-bot) +- ‼️ **issue 3** `issue3` [View Issue](http://testserver/organizations/baz/issues/{ev3.group.id}/?referrer=gitlab-pr-bot)""" + assert formatted_comment == expected_comment + + +class TestCommentWorkflow(GitlabCommentTestCase): + def setUp(self): + super().setUp() + self.user_id = "user_1" + self.app_id = "app_1" + self.pr = self.create_pr_issues() + self.cache_key = DEBOUNCE_PR_COMMENT_CACHE_KEY(self.pr.id) + + @patch( + "sentry.integrations.gitlab.integration.GitlabPRCommentWorkflow.get_top_5_issues_by_count" + ) + @patch("sentry.integrations.source_code_management.commit_context.metrics") + @responses.activate + def test_comment_workflow(self, mock_metrics, mock_issues): + group_objs = Group.objects.order_by("id").all() + groups = [g.id for g in group_objs] + titles = [g.title for g in group_objs] + culprits = [g.culprit for g in group_objs] + mock_issues.return_value = [{"group_id": id, "event_count": 10} for id in groups] + + responses.add( + responses.POST, + "https://example.gitlab.com/api/v4/projects/123/merge_requests/1/notes", + json={"id": 1}, + ) + + pr_comment_workflow(self.pr.id, self.project.id) + + request_body = json.loads(responses.calls[0].request.body) + assert request_body == { + "body": f"""\ +## Suspect Issues +This merge request was deployed and Sentry observed the following issues: + +- ‼️ **{titles[0]}** `{culprits[0]}` [View Issue](http://testserver/organizations/baz/issues/{groups[0]}/?referrer=gitlab-pr-bot) +- ‼️ **{titles[1]}** `{culprits[1]}` [View Issue](http://testserver/organizations/foobar/issues/{groups[1]}/?referrer=gitlab-pr-bot)""" + } + + pull_request_comment_query = PullRequestComment.objects.all() + assert len(pull_request_comment_query) == 1 + assert pull_request_comment_query[0].external_id == 1 + assert pull_request_comment_query[0].comment_type == CommentType.MERGED_PR + mock_metrics.incr.assert_called_with("gitlab.pr_comment.comment_created") + + @patch( + "sentry.integrations.gitlab.integration.GitlabPRCommentWorkflow.get_top_5_issues_by_count" + ) + @patch("sentry.integrations.source_code_management.commit_context.metrics") + @responses.activate + @freeze_time(datetime(2023, 6, 8, 0, 0, 0, tzinfo=UTC)) + def test_comment_workflow_updates_comment(self, mock_metrics, mock_issues): + groups = [g.id for g in Group.objects.all()] + mock_issues.return_value = [{"group_id": id, "event_count": 10} for id in groups] + pull_request_comment = PullRequestComment.objects.create( + external_id=1, + pull_request_id=self.pr.id, + created_at=timezone.now() - timedelta(hours=1), + updated_at=timezone.now() - timedelta(hours=1), + group_ids=[1, 2, 3, 4], + ) + + # An Open PR comment should not affect the rest of the test as the filter should ignore it. + PullRequestComment.objects.create( + external_id=2, + pull_request_id=self.pr.id, + created_at=timezone.now() - timedelta(hours=1), + updated_at=timezone.now() - timedelta(hours=1), + group_ids=[], + comment_type=CommentType.OPEN_PR, + ) + + responses.add( + responses.PUT, + "https://example.gitlab.com/api/v4/projects/123/merge_requests/1/notes/1", + json={"id": 1}, + ) + + pr_comment_workflow(self.pr.id, self.project.id) + + request_body = json.loads(responses.calls[0].request.body) + assert request_body == { + "body": f"""\ +## Suspect Issues +This merge request was deployed and Sentry observed the following issues: + +- ‼️ **issue 1** `issue1` [View Issue](http://testserver/organizations/baz/issues/{groups[0]}/?referrer=gitlab-pr-bot) +- ‼️ **issue 2** `issue2` [View Issue](http://testserver/organizations/foobar/issues/{groups[1]}/?referrer=gitlab-pr-bot)""" + } + + pull_request_comment.refresh_from_db() + assert pull_request_comment.group_ids == [g.id for g in Group.objects.all()] + assert pull_request_comment.updated_at == timezone.now() + mock_metrics.incr.assert_called_with("gitlab.pr_comment.comment_updated") + + @patch( + "sentry.integrations.gitlab.integration.GitlabPRCommentWorkflow.get_top_5_issues_by_count" + ) + @patch("sentry.integrations.source_code_management.tasks.metrics") + @patch("sentry.integrations.gitlab.integration.metrics") + @responses.activate + def test_comment_workflow_api_error(self, mock_integration_metrics, mock_metrics, mock_issues): + cache.set(self.cache_key, True, timedelta(minutes=5).total_seconds()) + mock_issues.return_value = [ + {"group_id": g.id, "event_count": 10} for g in Group.objects.all() + ] + + responses.add( + responses.POST, + "https://example.gitlab.com/api/v4/projects/123/merge_requests/1/notes", + status=400, + json={"id": 1}, + ) + responses.add( + responses.POST, + "https://example.gitlab.com/api/v4/projects/123/merge_requests/2/notes", + status=429, + json={}, + ) + + with pytest.raises(ApiError): + pr_comment_workflow(self.pr.id, self.project.id) + assert cache.get(self.cache_key) is None + mock_metrics.incr.assert_called_with("gitlab.pr_comment.error", tags={"type": "api_error"}) + + pr_2 = self.create_pr_issues() + cache_key = DEBOUNCE_PR_COMMENT_CACHE_KEY(pr_2.id) + cache.set(cache_key, True, timedelta(minutes=5).total_seconds()) + + # does not raise ApiError for rate limited error + pr_comment_workflow(pr_2.id, self.project.id) + assert cache.get(cache_key) is None + mock_integration_metrics.incr.assert_called_with( + "gitlab.pr_comment.error", tags={"type": "rate_limited_error"} + ) + + @patch( + "sentry.integrations.gitlab.integration.GitlabPRCommentWorkflow.get_top_5_issues_by_count" + ) + @patch("sentry.integrations.gitlab.integration.GitlabPRCommentWorkflow.get_comment_body") + @responses.activate + def test_comment_workflow_no_issues(self, mock_get_comment_body, mock_issues): + mock_issues.return_value = [] + + pr_comment_workflow(self.pr.id, self.project.id) + + assert mock_issues.called + assert not mock_get_comment_body.called diff --git a/tests/sentry/integrations/gitlab/test_client.py b/tests/sentry/integrations/gitlab/test_client.py index cb38340fa247e4..2a6bc419d9053a 100644 --- a/tests/sentry/integrations/gitlab/test_client.py +++ b/tests/sentry/integrations/gitlab/test_client.py @@ -657,6 +657,91 @@ def test_invalid_commits(self): assert resp == [] +@control_silo_test +class GitLabGetMergeCommitShaFromCommitTest(GitLabClientTest): + @responses.activate + def test_merge_commit_sha(self): + merge_commit_sha = "123" + commit_sha = "123" + responses.add( + responses.GET, + url=f"https://example.gitlab.com/api/v4/projects/{self.gitlab_id}/repository/commits/{commit_sha}/merge_requests", + json=[ + { + "state": "merged", + "merge_commit_sha": merge_commit_sha, + "squash_commit_sha": None, + } + ], + status=200, + ) + + sha = self.gitlab_client.get_merge_commit_sha_from_commit(repo=self.repo, sha=commit_sha) + assert sha == merge_commit_sha + + @responses.activate + def test_squash_commit_sha(self): + squash_commit_sha = "123" + commit_sha = "123" + responses.add( + responses.GET, + url=f"https://example.gitlab.com/api/v4/projects/{self.gitlab_id}/repository/commits/{commit_sha}/merge_requests", + json=[ + { + "state": "merged", + "merge_commit_sha": None, + "squash_commit_sha": squash_commit_sha, + } + ], + status=200, + ) + + sha = self.gitlab_client.get_merge_commit_sha_from_commit(repo=self.repo, sha=commit_sha) + assert sha == squash_commit_sha + + @responses.activate + def test_no_merge_requests(self): + commit_sha = "123" + responses.add( + responses.GET, + url=f"https://example.gitlab.com/api/v4/projects/{self.gitlab_id}/repository/commits/{commit_sha}/merge_requests", + json=[], + status=200, + ) + + sha = self.gitlab_client.get_merge_commit_sha_from_commit(repo=self.repo, sha=commit_sha) + assert sha is None + + @responses.activate + def test_open_merge_request(self): + commit_sha = "123" + responses.add( + responses.GET, + url=f"https://example.gitlab.com/api/v4/projects/{self.gitlab_id}/repository/commits/{commit_sha}/merge_requests", + json=[{"state": "opened"}], + status=200, + ) + + sha = self.gitlab_client.get_merge_commit_sha_from_commit(repo=self.repo, sha=commit_sha) + assert sha is None + + @responses.activate + def test_multiple_merged_requests(self): + commit_sha = "123" + responses.add( + responses.GET, + url=f"https://example.gitlab.com/api/v4/projects/{self.gitlab_id}/repository/commits/{commit_sha}/merge_requests", + json=[ + {"state": "merged"}, + {"state": "merged"}, + ], + status=200, + ) + + sha = self.gitlab_client.get_merge_commit_sha_from_commit(repo=self.repo, sha=commit_sha) + assert sha is None + + @control_silo_test class GitLabUnhappyPathTest(GitLabClientTest): @pytest.mark.skip("Feature is temporarily disabled") diff --git a/tests/sentry/issues/auto_source_code_config/test_code_mapping.py b/tests/sentry/issues/auto_source_code_config/test_code_mapping.py index 86101791ba21de..626ee2278ca4db 100644 --- a/tests/sentry/issues/auto_source_code_config/test_code_mapping.py +++ b/tests/sentry/issues/auto_source_code_config/test_code_mapping.py @@ -26,7 +26,6 @@ ) from sentry.silo.base import SiloMode from sentry.testutils.cases import TestCase -from sentry.testutils.helpers import Feature from sentry.testutils.silo import assume_test_silo_mode from sentry.utils.event_frames import EventFrame @@ -477,7 +476,6 @@ def test_convert_stacktrace_frame_path_to_source_path_empty(self) -> None: code_mapping=self.code_mapping_empty, platform="python", sdk_name="sentry.python", - organization=self.organization, ) == "src/sentry/file.py" ) @@ -491,7 +489,6 @@ def test_convert_stacktrace_frame_path_to_source_path_abs_path(self) -> None: code_mapping=self.code_mapping_abs_path, platform="python", sdk_name="sentry.python", - organization=self.organization, ) == "src/sentry/folder/file.py" ) @@ -502,11 +499,11 @@ def test_convert_stacktrace_frame_path_to_source_path_java(self) -> None: frame=EventFrame( filename="File.java", module="sentry.module.File", + abs_path="File.java", ), code_mapping=self.code_mapping_file, platform="java", sdk_name="sentry.java", - organization=self.organization, ) == "src/sentry/module/File.java" ) @@ -540,7 +537,7 @@ def test_convert_stacktrace_frame_path_to_source_path_java_no_source_context(sel ( "com.example.foo.BarImpl$invoke$bazFetch$2", "Bar.kt", # Notice "Impl" is not included in the module above - "src/com/example/foo/BarImpl.kt", # This is incorrect; the new logic will fix this + "src/com/example/foo/Bar.kt", ), ]: assert ( @@ -549,36 +546,10 @@ def test_convert_stacktrace_frame_path_to_source_path_java_no_source_context(sel code_mapping=code_mapping, platform="java", sdk_name="sentry.java.android", - organization=self.organization, ) == expected_path ) - def test_convert_stacktrace_frame_path_to_source_path_java_new_logic(self) -> None: - code_mapping = self.create_code_mapping( - organization_integration=self.oi, - project=self.project, - repo=self.repo, - stack_root="com/example/", - source_root="src/com/example/", - automatically_generated=False, - ) - with Feature({"organizations:java-frame-munging-new-logic": True}): - assert ( - convert_stacktrace_frame_path_to_source_path( - frame=EventFrame( - filename="Baz.java", - abs_path="Baz.java", # Notice "Impl" is not included in the module below - module="com.example.foo.BazImpl$invoke$bazFetch$2", - ), - code_mapping=code_mapping, - platform="java", - sdk_name="sentry.java.android", - organization=self.organization, - ) - == "src/com/example/foo/Baz.java" - ) - def test_convert_stacktrace_frame_path_to_source_path_backslashes(self) -> None: assert ( convert_stacktrace_frame_path_to_source_path( @@ -588,7 +559,6 @@ def test_convert_stacktrace_frame_path_to_source_path_backslashes(self) -> None: code_mapping=self.code_mapping_backslash, platform="rust", sdk_name="sentry.rust", - organization=self.organization, ) == "src/sentry/folder/file.rs" ) diff --git a/tests/sentry/issues/endpoints/test_project_stacktrace_link.py b/tests/sentry/issues/endpoints/test_project_stacktrace_link.py index 854379dc34c5d5..5a9acdc89631ed 100644 --- a/tests/sentry/issues/endpoints/test_project_stacktrace_link.py +++ b/tests/sentry/issues/endpoints/test_project_stacktrace_link.py @@ -248,6 +248,7 @@ def test_munge_android_worked(self, mock_integration: MagicMock) -> None: self.project.slug, qs_params={ "file": "file.java", + "absPath": "file.java", "module": "usr.src.getsentry.file", "platform": "java", }, diff --git a/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py b/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py index 15e049a31f8de1..cd7b811d53f5db 100644 --- a/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py +++ b/tests/sentry/notifications/notification_action/test_metric_alert_registry_handlers.py @@ -1,7 +1,7 @@ import uuid from collections.abc import Mapping from dataclasses import asdict -from datetime import datetime +from datetime import datetime, timedelta from typing import Any from unittest import mock @@ -21,6 +21,7 @@ NotificationContext, OpenPeriodContext, ) +from sentry.incidents.utils.constants import INCIDENTS_SNUBA_SUBSCRIPTION_TYPE from sentry.issues.issue_occurrence import IssueOccurrence from sentry.models.group import Group, GroupStatus from sentry.models.groupopenperiod import GroupOpenPeriod @@ -28,13 +29,18 @@ from sentry.models.project import Project from sentry.notifications.models.notificationaction import ActionTarget from sentry.notifications.notification_action.types import BaseMetricAlertHandler -from sentry.snuba.models import QuerySubscription, SnubaQuery +from sentry.snuba.dataset import Dataset +from sentry.snuba.models import QuerySubscription, SnubaQuery, SnubaQueryEventType +from sentry.snuba.subscriptions import create_snuba_query, create_snuba_subscription from sentry.testutils.helpers.features import apply_feature_flag_on_cls +from sentry.testutils.skips import requires_snuba from sentry.types.group import PriorityLevel from sentry.workflow_engine.models import Action from sentry.workflow_engine.types import WorkflowEventData from tests.sentry.workflow_engine.test_base import BaseWorkflowTest +pytestmark = [requires_snuba] + class TestHandler(BaseMetricAlertHandler): @classmethod @@ -57,6 +63,27 @@ class MetricAlertHandlerBase(BaseWorkflowTest): def create_models(self): self.project = self.create_project() self.detector = self.create_detector(project=self.project) + + with self.tasks(): + self.snuba_query = create_snuba_query( + query_type=SnubaQuery.Type.ERROR, + dataset=Dataset.Events, + query="hello", + aggregate="count()", + time_window=timedelta(minutes=1), + resolution=timedelta(minutes=1), + environment=self.environment, + event_types=[SnubaQueryEventType.EventType.ERROR], + ) + self.query_subscription = create_snuba_subscription( + project=self.project, + subscription_type=INCIDENTS_SNUBA_SUBSCRIPTION_TYPE, + snuba_query=self.snuba_query, + ) + self.data_source = self.create_data_source( + organization=self.organization, source_id=self.query_subscription.id + ) + self.create_data_source_detector(data_source=self.data_source, detector=self.detector) self.workflow = self.create_workflow(environment=self.environment) self.snuba_query = self.create_snuba_query() diff --git a/tests/sentry/ownership/test_grammar.py b/tests/sentry/ownership/test_grammar.py index 7285b1faa7424f..b88c96ec6abf13 100644 --- a/tests/sentry/ownership/test_grammar.py +++ b/tests/sentry/ownership/test_grammar.py @@ -240,6 +240,8 @@ def test_matcher_test_platform_java_threads() -> None: "frames": [ { "module": "jdk.internal.reflect.NativeMethodAccessorImpl", + "abs_path": "NativeMethodAccessor.java", # This is the correct path + # Many of these "Impl" classes are actually generated at runtime by the JVM "filename": "NativeMethodAccessorImpl.java", } ] @@ -252,12 +254,10 @@ def test_matcher_test_platform_java_threads() -> None: assert Matcher("path", "*.java").test(data, munged_data) assert Matcher("path", "jdk/internal/reflect/*.java").test(data, munged_data) - assert Matcher("path", "jdk/internal/*/NativeMethodAccessorImpl.java").test(data, munged_data) + assert Matcher("path", "jdk/internal/*/NativeMethodAccessor.java").test(data, munged_data) assert Matcher("codeowners", "*.java").test(data, munged_data) assert Matcher("codeowners", "jdk/internal/reflect/*.java").test(data, munged_data) - assert Matcher("codeowners", "jdk/internal/*/NativeMethodAccessorImpl.java").test( - data, munged_data - ) + assert Matcher("codeowners", "jdk/internal/*/NativeMethodAccessor.java").test(data, munged_data) assert not Matcher("path", "*.js").test(data, munged_data) assert not Matcher("path", "*.jsx").test(data, munged_data) assert not Matcher("url", "*.py").test(data, munged_data) diff --git a/tests/sentry/quotas/test_base.py b/tests/sentry/quotas/test_base.py index c5c7c9a576591c..e08d1e3c69d3a8 100644 --- a/tests/sentry/quotas/test_base.py +++ b/tests/sentry/quotas/test_base.py @@ -1,8 +1,5 @@ -from unittest import mock - import pytest -from sentry import options from sentry.constants import DataCategory, ObjectStatus from sentry.models.options.organization_option import OrganizationOption from sentry.models.projectkey import ProjectKey @@ -17,18 +14,11 @@ class QuotaTest(TestCase): def setUp(self): self.backend = Quota() - @pytest.mark.skip(reason="still flaky") def test_get_project_quota(self): org = self.create_organization() project = self.create_project(organization=org) - # Read option to prime cache and make query count consistent - options.get("taskworker.relay.rollout") - with ( - self.assertNumQueries(5), - self.settings(SENTRY_DEFAULT_MAX_EVENTS_PER_MINUTE=0), - mock.patch.object(OrganizationOption.objects, "reload_cache") as mock_reload_cache, - ): + with self.settings(SENTRY_DEFAULT_MAX_EVENTS_PER_MINUTE=0): with self.options({"system.rate-limit": 0}): assert self.backend.get_project_quota(project) == (None, 60) @@ -40,7 +30,19 @@ def test_get_project_quota(self): with self.options({"system.rate-limit": 0}): assert self.backend.get_project_quota(project) == (None, 60) - assert mock_reload_cache.called + def test_get_project_quota_use_cache(self): + org = self.create_organization() + project = self.create_project(organization=org) + + # Prime the organization options cache. + org.get_option("sentry:account-rate-limit") + + with ( + self.assertNumQueries(0), + self.settings(SENTRY_DEFAULT_MAX_EVENTS_PER_MINUTE=0), + self.options({"system.rate-limit": 0}), + ): + assert self.backend.get_project_quota(project) == (None, 60) def test_get_key_quota(self): key = ProjectKey.objects.create( diff --git a/tests/sentry/uptime/consumers/test_results_consumer.py b/tests/sentry/uptime/consumers/test_results_consumer.py index 14374f9ef56800..37bd6947278e4e 100644 --- a/tests/sentry/uptime/consumers/test_results_consumer.py +++ b/tests/sentry/uptime/consumers/test_results_consumer.py @@ -156,7 +156,6 @@ def test(self): assignee = group.get_assignee() assert assignee and (assignee.id == self.user.id) self.project_subscription.refresh_from_db() - assert self.project_subscription.uptime_status == UptimeStatus.FAILED assert self.project_subscription.uptime_subscription.uptime_status == UptimeStatus.FAILED def test_does_nothing_when_missing_project_subscription(self): @@ -221,7 +220,6 @@ def test_restricted_host_provider_id(self): # subscription status is still updated self.project_subscription.refresh_from_db() - assert self.project_subscription.uptime_status == UptimeStatus.FAILED assert self.project_subscription.uptime_subscription.uptime_status == UptimeStatus.FAILED def test_reset_fail_count(self): @@ -318,7 +316,6 @@ def test_reset_fail_count(self): with pytest.raises(Group.DoesNotExist): Group.objects.get(grouphash__hash=hashed_fingerprint) self.project_subscription.refresh_from_db() - assert self.project_subscription.uptime_status == UptimeStatus.OK assert self.project_subscription.uptime_subscription.uptime_status == UptimeStatus.OK def test_no_create_issues_feature(self): @@ -351,7 +348,6 @@ def test_no_create_issues_feature(self): with pytest.raises(Group.DoesNotExist): Group.objects.get(grouphash__hash=hashed_fingerprint) self.project_subscription.refresh_from_db() - assert self.project_subscription.uptime_status == UptimeStatus.FAILED assert self.project_subscription.uptime_subscription.uptime_status == UptimeStatus.FAILED def test_resolve(self): @@ -412,7 +408,6 @@ def test_resolve(self): assert group.issue_type == UptimeDomainCheckFailure assert group.status == GroupStatus.UNRESOLVED self.project_subscription.refresh_from_db() - assert self.project_subscription.uptime_status == UptimeStatus.FAILED assert self.project_subscription.uptime_subscription.uptime_status == UptimeStatus.FAILED result = self.create_uptime_result( @@ -443,7 +438,6 @@ def test_resolve(self): group.refresh_from_db() assert group.status == GroupStatus.RESOLVED self.project_subscription.refresh_from_db() - assert self.project_subscription.uptime_status == UptimeStatus.OK assert self.project_subscription.uptime_subscription.uptime_status == UptimeStatus.OK def test_no_subscription(self): diff --git a/tests/sentry/uptime/endpoints/test_serializers.py b/tests/sentry/uptime/endpoints/test_serializers.py index b247d796c20261..6bb953c55bc0aa 100644 --- a/tests/sentry/uptime/endpoints/test_serializers.py +++ b/tests/sentry/uptime/endpoints/test_serializers.py @@ -13,7 +13,7 @@ def test(self): "name": uptime_monitor.name, "environment": uptime_monitor.environment.name if uptime_monitor.environment else None, "status": uptime_monitor.get_status_display(), - "uptimeStatus": uptime_monitor.uptime_status, + "uptimeStatus": uptime_monitor.uptime_subscription.uptime_status, "mode": uptime_monitor.mode, "url": uptime_monitor.uptime_subscription.url, "method": uptime_monitor.uptime_subscription.method, @@ -38,7 +38,7 @@ def test_default_name(self): "name": f"Uptime Monitoring for {uptime_monitor.uptime_subscription.url}", "environment": uptime_monitor.environment.name if uptime_monitor.environment else None, "status": uptime_monitor.get_status_display(), - "uptimeStatus": uptime_monitor.uptime_status, + "uptimeStatus": uptime_monitor.uptime_subscription.uptime_status, "mode": uptime_monitor.mode, "url": uptime_monitor.uptime_subscription.url, "method": uptime_monitor.uptime_subscription.method, @@ -60,7 +60,7 @@ def test_owner(self): "name": uptime_monitor.name, "environment": uptime_monitor.environment.name if uptime_monitor.environment else None, "status": uptime_monitor.get_status_display(), - "uptimeStatus": uptime_monitor.uptime_status, + "uptimeStatus": uptime_monitor.uptime_subscription.uptime_status, "mode": uptime_monitor.mode, "url": uptime_monitor.uptime_subscription.url, "method": uptime_monitor.uptime_subscription.method, diff --git a/tests/sentry/uptime/subscriptions/test_subscriptions.py b/tests/sentry/uptime/subscriptions/test_subscriptions.py index d1398288c80107..f60b17234a523c 100644 --- a/tests/sentry/uptime/subscriptions/test_subscriptions.py +++ b/tests/sentry/uptime/subscriptions/test_subscriptions.py @@ -759,7 +759,7 @@ def test_disable_failed(self, mock_remove_seat): proj_sub.refresh_from_db() assert proj_sub.status == ObjectStatus.DISABLED - assert proj_sub.uptime_status == UptimeStatus.OK + assert proj_sub.uptime_subscription.uptime_status == UptimeStatus.OK assert proj_sub.uptime_subscription.status == UptimeSubscription.Status.DISABLED.value mock_remove_seat.assert_called_with(DataCategory.UPTIME, proj_sub) diff --git a/tests/sentry/uptime/subscriptions/test_tasks.py b/tests/sentry/uptime/subscriptions/test_tasks.py index 904d8786fdf271..736505439ca545 100644 --- a/tests/sentry/uptime/subscriptions/test_tasks.py +++ b/tests/sentry/uptime/subscriptions/test_tasks.py @@ -523,7 +523,6 @@ def run_test( proj_sub.refresh_from_db() assert proj_sub.status == expected_status - assert proj_sub.uptime_status == expected_uptime_status assert proj_sub.uptime_subscription.uptime_status == expected_uptime_status detector = get_detector(proj_sub.uptime_subscription) diff --git a/tests/sentry/utils/test_event_frames.py b/tests/sentry/utils/test_event_frames.py index 2015c4297be2e3..9a7ab6241e7220 100644 --- a/tests/sentry/utils/test_event_frames.py +++ b/tests/sentry/utils/test_event_frames.py @@ -84,7 +84,7 @@ def test_supported_platform_sdk_name_not_required(self): "abs_path": "NativeMethodAccessorImpl.java", } ] - assert munged_filename_and_frames("java-new-logic", frames, "munged") + assert munged_filename_and_frames("java", frames, "munged") class JavaFilenameMungingTestCase(unittest.TestCase): @@ -106,7 +106,7 @@ def test_platform_java(self): "abs_path": "Application.java", }, ] - ret = munged_filename_and_frames("java-new-logic", frames, "munged_filename") + ret = munged_filename_and_frames("java", frames, "munged_filename") assert ret is not None key, munged_frames = ret assert len(munged_frames) == 3 @@ -120,14 +120,14 @@ def test_platform_java_no_filename(self): no_filename = { "module": "io.sentry.example.Application", } - no_munged = munged_filename_and_frames("java-new-logic", [no_filename]) + no_munged = munged_filename_and_frames("java", [no_filename]) assert not no_munged def test_platform_java_no_module(self): no_module = { "filename": "Application.java", } - no_munged = munged_filename_and_frames("java-new-logic", [no_module]) + no_munged = munged_filename_and_frames("java", [no_module]) assert not no_munged def test_platform_java_do_not_follow_java_package_naming_convention_does_not_raise_exception( @@ -137,7 +137,7 @@ def test_platform_java_do_not_follow_java_package_naming_convention_does_not_rai "abs_path": "gsp_arcus_drops_proofReadingmodecInspectionProofRead_gsp.groovy", "module": "gsp_arcus_drops_proofReadingmodecInspectionProofRead_gsp$_run_closure2", } - munged = munged_filename_and_frames("java-new-logic", [frame]) + munged = munged_filename_and_frames("java", [frame]) assert munged is None def test_platform_android_kotlin(self): @@ -266,7 +266,7 @@ def test_platform_android_kotlin(self): "in_app": True, }, ] - ret = munged_filename_and_frames("java-new-logic", exception_frames, "munged_filename") + ret = munged_filename_and_frames("java", exception_frames, "munged_filename") assert ret is not None key, munged_frames = ret assert len(munged_frames) == 16 diff --git a/tests/sentry/workflow_engine/endpoints/test_organization_available_action_index.py b/tests/sentry/workflow_engine/endpoints/test_organization_available_action_index.py index 2542c94d401ab6..3ae21e98994703 100644 --- a/tests/sentry/workflow_engine/endpoints/test_organization_available_action_index.py +++ b/tests/sentry/workflow_engine/endpoints/test_organization_available_action_index.py @@ -2,6 +2,7 @@ from unittest.mock import ANY, patch from sentry.constants import SentryAppStatus +from sentry.integrations.types import IntegrationProviderSlug from sentry.notifications.notification_action.action_handler_registry.base import ( IntegrationActionHandler, ) @@ -57,7 +58,7 @@ def setup_integrations(self): @dataclass(frozen=True) class SlackActionHandler(IntegrationActionHandler): group = ActionHandler.Group.NOTIFICATION - provider_slug = "slack" + provider_slug = IntegrationProviderSlug.SLACK config_schema = {} data_schema = {} @@ -74,7 +75,7 @@ class SlackActionHandler(IntegrationActionHandler): @dataclass(frozen=True) class GithubActionHandler(IntegrationActionHandler): group = ActionHandler.Group.TICKET_CREATION - provider_slug = "github" + provider_slug = IntegrationProviderSlug.GITHUB config_schema = {} data_schema = {} @@ -92,7 +93,7 @@ class GithubActionHandler(IntegrationActionHandler): @dataclass(frozen=True) class MSTeamsActionHandler(IntegrationActionHandler): group = ActionHandler.Group.NOTIFICATION - provider_slug = "msteams" + provider_slug = IntegrationProviderSlug.MSTEAMS config_schema = {} data_schema = {} diff --git a/tests/snuba/api/endpoints/test_organization_events_span_indexed.py b/tests/snuba/api/endpoints/test_organization_events_span_indexed.py index 5a813a98ace84f..4e67943b247fbb 100644 --- a/tests/snuba/api/endpoints/test_organization_events_span_indexed.py +++ b/tests/snuba/api/endpoints/test_organization_events_span_indexed.py @@ -2005,6 +2005,93 @@ def test_epm(self): assert meta["units"] == {"description": None, "epm()": "1/minute"} assert meta["fields"] == {"description": "string", "epm()": "rate"} + def test_tpm(self): + self.store_spans( + [ + self.create_span( + { + "description": "foo", + "sentry_tags": {"status": "success"}, + "is_segment": True, + }, + start_ts=self.ten_mins_ago, + ), + self.create_span( + {"description": "foo", "sentry_tags": {"status": "success"}}, + start_ts=self.ten_mins_ago, + ), + ], + is_eap=self.is_eap, + ) + + response = self.do_request( + { + "field": ["description", "tpm()"], + "query": "", + "orderby": "description", + "project": self.project.id, + "dataset": self.dataset, + } + ) + + segment_span_count = 1 + total_time = 90 * 24 * 60 + + assert response.status_code == 200, response.content + data = response.data["data"] + meta = response.data["meta"] + assert len(data) == 1 + assert data == [ + { + "description": "foo", + "tpm()": segment_span_count / total_time, + }, + ] + assert meta["dataset"] == self.dataset + assert meta["units"] == {"description": None, "tpm()": "1/minute"} + assert meta["fields"] == {"description": "string", "tpm()": "rate"} + + def test_p75_if(self): + self.store_spans( + [ + self.create_span({"is_segment": True}, start_ts=self.ten_mins_ago, duration=1000), + self.create_span({"is_segment": True}, start_ts=self.ten_mins_ago, duration=1000), + self.create_span({"is_segment": True}, start_ts=self.ten_mins_ago, duration=2000), + self.create_span({"is_segment": True}, start_ts=self.ten_mins_ago, duration=2000), + self.create_span({"is_segment": True}, start_ts=self.ten_mins_ago, duration=3000), + self.create_span({"is_segment": True}, start_ts=self.ten_mins_ago, duration=3000), + self.create_span({"is_segment": True}, start_ts=self.ten_mins_ago, duration=3000), + self.create_span({"is_segment": True}, start_ts=self.ten_mins_ago, duration=4000), + self.create_span({"is_segment": False}, start_ts=self.ten_mins_ago, duration=5000), + self.create_span({"is_segment": False}, start_ts=self.ten_mins_ago, duration=5000), + self.create_span({"is_segment": False}, start_ts=self.ten_mins_ago, duration=5000), + self.create_span({"is_segment": False}, start_ts=self.ten_mins_ago, duration=5000), + ], + is_eap=self.is_eap, + ) + + response = self.do_request( + { + "field": ["p75_if(span.duration, is_transaction, true)"], + "query": "", + "project": self.project.id, + "dataset": self.dataset, + } + ) + + assert response.status_code == 200, response.content + data = response.data["data"] + meta = response.data["meta"] + assert len(data) == 1 + assert data == [ + { + "p75_if(span.duration, is_transaction, true)": 3000, + }, + ] + assert meta["dataset"] == self.dataset + assert meta["units"] == {"p75_if(span.duration, is_transaction, true)": "millisecond"} + assert meta["fields"] == {"p75_if(span.duration, is_transaction, true)": "duration"} + def test_is_transaction(self): self.store_spans( [ @@ -2762,6 +2849,47 @@ def test_failure_rate(self): assert data[0]["failure_rate()"] == 0.25 assert meta["dataset"] == self.dataset + def test_failure_rate_if(self): + trace_statuses = ["ok", "cancelled", "unknown", "failure"] + + spans = [ + self.create_span( + { + "sentry_tags": {"trace.status": status}, + "is_segment": True, + }, + start_ts=self.ten_mins_ago, + ) + for status in trace_statuses + ] + + spans.append( + self.create_span( + { + "sentry_tags": {"trace.status": "ok"}, + "is_segment": False, + }, + start_ts=self.ten_mins_ago, + ) + ) + + self.store_spans(spans, is_eap=self.is_eap) + + response = self.do_request( + { + "field": ["failure_rate_if(is_transaction, true)"], + "project": self.project.id, + "dataset": self.dataset, + } + ) + + assert response.status_code == 200, response.content + data = response.data["data"] + meta = response.data["meta"] + assert len(data) == 1 + assert data[0]["failure_rate_if(is_transaction, true)"] == 0.25 + assert meta["dataset"] == self.dataset + def test_count_op(self): self.store_spans( [ diff --git a/tests/snuba/api/endpoints/test_organization_events_stats_span_indexed.py b/tests/snuba/api/endpoints/test_organization_events_stats_span_indexed.py index df346a487fc600..55c45cf523a18c 100644 --- a/tests/snuba/api/endpoints/test_organization_events_stats_span_indexed.py +++ b/tests/snuba/api/endpoints/test_organization_events_stats_span_indexed.py @@ -2017,3 +2017,32 @@ def test_downsampling_can_go_to_higher_accuracy_tier(self): ) assert response.data["meta"]["dataScanned"] == "partial" + + def test_request_without_sampling_mode_defaults_to_highest_accuracy(self): + response = self._do_request( + data={ + "start": self.day_ago, + "end": self.day_ago + timedelta(minutes=3), + "interval": "1m", + "yAxis": "count()", + "project": self.project.id, + "dataset": self.dataset, + }, + ) + + assert response.data["meta"]["dataScanned"] == "full" + + def test_request_to_highest_accuracy_mode(self): + response = self._do_request( + data={ + "start": self.day_ago, + "end": self.day_ago + timedelta(minutes=3), + "interval": "1m", + "yAxis": "count()", + "project": self.project.id, + "dataset": self.dataset, + "sampling": "HIGHEST_ACCURACY", + }, + ) + + assert response.data["meta"]["dataScanned"] == "full" diff --git a/tests/snuba/api/endpoints/test_organization_stats_summary.py b/tests/snuba/api/endpoints/test_organization_stats_summary.py index e703c5c7edabec..140184d351af28 100644 --- a/tests/snuba/api/endpoints/test_organization_stats_summary.py +++ b/tests/snuba/api/endpoints/test_organization_stats_summary.py @@ -1,21 +1,23 @@ from __future__ import annotations import functools -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from typing import Any from django.urls import reverse from sentry.constants import DataCategory from sentry.testutils.cases import APITestCase, OutcomesSnubaTest -from sentry.testutils.helpers.datetime import freeze_time +from sentry.testutils.helpers.datetime import freeze_time, isoformat_z +from sentry.utils.dates import floor_to_utc_day from sentry.utils.outcomes import Outcome class OrganizationStatsSummaryTest(APITestCase, OutcomesSnubaTest): + _now = datetime.now(UTC).replace(hour=12, minute=27, second=28, microsecond=0) + def setUp(self): super().setUp() - self.now = datetime(2021, 3, 14, 12, 27, 28, tzinfo=timezone.utc) self.login_as(user=self.user) @@ -45,7 +47,7 @@ def setUp(self): self.store_outcomes( { "org_id": self.org.id, - "timestamp": self.now - timedelta(hours=1), + "timestamp": self._now - timedelta(hours=1), "project_id": self.project.id, "outcome": Outcome.ACCEPTED, "reason": "none", @@ -57,7 +59,7 @@ def setUp(self): self.store_outcomes( { "org_id": self.org.id, - "timestamp": self.now - timedelta(hours=1), + "timestamp": self._now - timedelta(hours=1), "project_id": self.project.id, "outcome": Outcome.ACCEPTED, "reason": "none", @@ -69,7 +71,7 @@ def setUp(self): self.store_outcomes( { "org_id": self.org.id, - "timestamp": self.now - timedelta(hours=1), + "timestamp": self._now - timedelta(hours=1), "project_id": self.project.id, "outcome": Outcome.RATE_LIMITED, "reason": "smart_rate_limit", @@ -80,7 +82,7 @@ def setUp(self): self.store_outcomes( { "org_id": self.org.id, - "timestamp": self.now - timedelta(hours=1), + "timestamp": self._now - timedelta(hours=1), "project_id": self.project2.id, "outcome": Outcome.RATE_LIMITED, "reason": "smart_rate_limit", @@ -142,27 +144,31 @@ def test_unknown_field(self): def test_no_end_param(self): response = self.do_request( - {"field": ["sum(quantity)"], "interval": "1d", "start": "2021-03-14T00:00:00Z"} + { + "field": ["sum(quantity)"], + "interval": "1d", + "start": floor_to_utc_day(self._now).isoformat(), + } ) assert response.status_code == 400, response.content assert response.data == {"detail": "start and end are both required"} - @freeze_time(datetime(2021, 3, 14, 12, 27, 28, tzinfo=timezone.utc)) + @freeze_time(_now) def test_future_request(self): response = self.do_request( { "field": ["sum(quantity)"], "interval": "1h", "category": ["error"], - "start": "2021-03-14T15:30:00", - "end": "2021-03-14T16:30:00", + "start": self._now.replace(hour=15, minute=30, second=0).isoformat(), + "end": self._now.replace(hour=16, minute=30, second=0).isoformat(), } ) assert response.status_code == 200, response.content assert response.data == { - "start": "2021-03-14T12:00:00Z", - "end": "2021-03-14T17:00:00Z", + "start": isoformat_z(self._now.replace(hour=12, minute=0, second=0)), + "end": isoformat_z(self._now.replace(hour=17, minute=0, second=0)), "projects": [], } @@ -212,7 +218,7 @@ def test_resolution_invalid(self): assert response.status_code == 400, response.content - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_attachment_filter_only(self): response = self.do_request( { @@ -229,7 +235,7 @@ def test_attachment_filter_only(self): "detail": "if filtering by attachment no other category may be present" } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_user_all_accessible(self): response = self.do_request( { @@ -256,12 +262,12 @@ def test_user_all_accessible(self): assert response.status_code == 200 assert response.data == { - "start": "2021-03-13T00:00:00Z", - "end": "2021-03-15T00:00:00Z", + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), "projects": [], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_no_project_access(self): user = self.create_user(is_superuser=False) self.create_member(user=user, organization=self.organization, role="member", teams=[]) @@ -281,7 +287,7 @@ def test_no_project_access(self): assert response.status_code == 403, response.content assert response.data == {"detail": "You do not have permission to perform this action."} - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_open_membership_semantics(self): self.org.flags.allow_joinleave = True self.org.save() @@ -299,8 +305,8 @@ def test_open_membership_semantics(self): assert response.status_code == 200 assert response.data == { - "start": "2021-03-13T00:00:00Z", - "end": "2021-03-15T00:00:00Z", + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), "projects": [ { "id": self.project.id, @@ -343,7 +349,7 @@ def test_open_membership_semantics(self): ], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_org_simple(self): make_request = functools.partial( self.client.get, @@ -359,8 +365,8 @@ def test_org_simple(self): assert response.status_code == 200, response.content assert response.data == { - "start": "2021-03-12T00:00:00Z", - "end": "2021-03-15T00:00:00Z", + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=2)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), "projects": [ { "id": self.project.id, @@ -416,7 +422,7 @@ def test_org_simple(self): ], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_org_multiple_fields(self): make_request = functools.partial( self.client.get, @@ -432,8 +438,8 @@ def test_org_multiple_fields(self): assert response.status_code == 200, response.content assert response.data == { - "start": "2021-03-12T00:00:00Z", - "end": "2021-03-15T00:00:00Z", + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=2)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), "projects": [ { "id": self.project.id, @@ -493,7 +499,7 @@ def test_org_multiple_fields(self): ], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_org_project_totals_per_project(self): make_request = functools.partial( self.client.get, @@ -510,8 +516,10 @@ def test_org_project_totals_per_project(self): assert response_per_group.status_code == 200, response_per_group.content assert response_per_group.data == { - "start": "2021-03-13T12:00:00Z", - "end": "2021-03-14T13:00:00Z", + "start": isoformat_z( + (self._now - timedelta(days=1)).replace(hour=12, minute=0, second=0) + ), + "end": isoformat_z(self._now.replace(hour=13, minute=0, second=0)), "projects": [ { "id": self.project.id, @@ -554,7 +562,7 @@ def test_org_project_totals_per_project(self): ], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_project_filter(self): make_request = functools.partial( self.client.get, @@ -572,8 +580,8 @@ def test_project_filter(self): assert response.status_code == 200, response.content assert response.data == { - "start": "2021-03-13T00:00:00Z", - "end": "2021-03-15T00:00:00Z", + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), "projects": [ { "id": self.project.id, @@ -597,7 +605,7 @@ def test_project_filter(self): ], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_reason_filter(self): make_request = functools.partial( self.client.get, @@ -615,8 +623,8 @@ def test_reason_filter(self): assert response.status_code == 200, response.content assert response.data == { - "start": "2021-03-13T00:00:00Z", - "end": "2021-03-15T00:00:00Z", + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), "projects": [ { "id": self.project.id, @@ -661,7 +669,7 @@ def test_reason_filter(self): ], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_outcome_filter(self): make_request = functools.partial( self.client.get, @@ -678,8 +686,8 @@ def test_outcome_filter(self): ) assert response.status_code == 200, response.content assert response.data == { - "start": "2021-03-13T00:00:00Z", - "end": "2021-03-15T00:00:00Z", + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), "projects": [ { "id": self.project.id, @@ -697,7 +705,7 @@ def test_outcome_filter(self): ], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_category_filter(self): make_request = functools.partial( self.client.get, @@ -713,8 +721,8 @@ def test_category_filter(self): ) assert response.status_code == 200, response.content assert response.data == { - "start": "2021-03-13T00:00:00Z", - "end": "2021-03-15T00:00:00Z", + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), "projects": [ { "id": self.project.id, diff --git a/tests/snuba/api/endpoints/test_organization_stats_v2.py b/tests/snuba/api/endpoints/test_organization_stats_v2.py index 85e56705677a87..f8ef6dd7b4ee45 100644 --- a/tests/snuba/api/endpoints/test_organization_stats_v2.py +++ b/tests/snuba/api/endpoints/test_organization_stats_v2.py @@ -1,17 +1,19 @@ -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from sentry.constants import DataCategory from sentry.testutils.cases import APITestCase, OutcomesSnubaTest -from sentry.testutils.helpers.datetime import freeze_time +from sentry.testutils.helpers.datetime import freeze_time, isoformat_z +from sentry.utils.dates import floor_to_utc_day from sentry.utils.outcomes import Outcome class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest): endpoint = "sentry-api-0-organization-stats-v2" + _now = datetime.now(UTC).replace(hour=12, minute=27, second=28, microsecond=0) + def setUp(self): super().setUp() - self.now = datetime(2021, 3, 14, 12, 27, 28, tzinfo=timezone.utc) self.login_as(user=self.user) @@ -41,7 +43,7 @@ def setUp(self): self.store_outcomes( { "org_id": self.org.id, - "timestamp": self.now - timedelta(hours=1), + "timestamp": self._now - timedelta(hours=1), "project_id": self.project.id, "outcome": Outcome.ACCEPTED, "reason": "none", @@ -53,7 +55,7 @@ def setUp(self): self.store_outcomes( { "org_id": self.org.id, - "timestamp": self.now - timedelta(hours=1), + "timestamp": self._now - timedelta(hours=1), "project_id": self.project.id, "outcome": Outcome.ACCEPTED, "reason": "none", @@ -64,7 +66,7 @@ def setUp(self): self.store_outcomes( { "org_id": self.org.id, - "timestamp": self.now - timedelta(hours=1), + "timestamp": self._now - timedelta(hours=1), "project_id": self.project.id, "outcome": Outcome.RATE_LIMITED, "reason": "smart_rate_limit", @@ -75,7 +77,7 @@ def setUp(self): self.store_outcomes( { "org_id": self.org.id, - "timestamp": self.now - timedelta(hours=1), + "timestamp": self._now - timedelta(hours=1), "project_id": self.project2.id, "outcome": Outcome.RATE_LIMITED, "reason": "smart_rate_limit", @@ -88,7 +90,7 @@ def setUp(self): self.store_outcomes( { "org_id": self.org.id, - "timestamp": self.now - timedelta(hours=1), + "timestamp": self._now - timedelta(hours=1), "project_id": self.project.id, "outcome": Outcome.ACCEPTED, "reason": "none", @@ -150,32 +152,36 @@ def test_unknown_field(self): def test_no_end_param(self): response = self.do_request( - {"field": ["sum(quantity)"], "interval": "1d", "start": "2021-03-14T00:00:00Z"}, + { + "field": ["sum(quantity)"], + "interval": "1d", + "start": floor_to_utc_day(self._now).isoformat(), + }, status_code=400, ) assert result_sorted(response.data) == {"detail": "start and end are both required"} - @freeze_time(datetime(2021, 3, 14, 12, 27, 28, tzinfo=timezone.utc)) + @freeze_time(_now) def test_future_request(self): response = self.do_request( { "field": ["sum(quantity)"], "interval": "1h", "category": ["error"], - "start": "2021-03-14T15:30:00", - "end": "2021-03-14T16:30:00", + "start": self._now.replace(hour=15, minute=30, second=0).isoformat(), + "end": self._now.replace(hour=16, minute=30, second=0).isoformat(), }, status_code=200, ) assert result_sorted(response.data) == { "intervals": [ - "2021-03-14T12:00:00Z", - "2021-03-14T13:00:00Z", - "2021-03-14T14:00:00Z", - "2021-03-14T15:00:00Z", - "2021-03-14T16:00:00Z", + isoformat_z(self._now.replace(hour=12, minute=0, second=0)), + isoformat_z(self._now.replace(hour=13, minute=0, second=0)), + isoformat_z(self._now.replace(hour=14, minute=0, second=0)), + isoformat_z(self._now.replace(hour=15, minute=0, second=0)), + isoformat_z(self._now.replace(hour=16, minute=0, second=0)), ], "groups": [ { @@ -184,8 +190,8 @@ def test_future_request(self): "totals": {"sum(quantity)": 0}, } ], - "start": "2021-03-14T12:00:00Z", - "end": "2021-03-14T17:00:00Z", + "start": isoformat_z(self._now.replace(hour=12, minute=0, second=0)), + "end": isoformat_z(self._now.replace(hour=17, minute=0, second=0)), } def test_unknown_category(self): @@ -242,7 +248,7 @@ def test_resolution_invalid(self): status_code=400, ) - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_attachment_filter_only(self): response = self.do_request( { @@ -259,7 +265,7 @@ def test_attachment_filter_only(self): "detail": "if filtering by attachment no other category may be present" } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_timeseries_interval(self): response = self.do_request( { @@ -273,12 +279,15 @@ def test_timeseries_interval(self): ) assert result_sorted(response.data) == { - "intervals": ["2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"], + "intervals": [ + isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + isoformat_z(floor_to_utc_day(self._now)), + ], "groups": [ {"by": {}, "series": {"sum(quantity)": [0, 6]}, "totals": {"sum(quantity)": 6}} ], - "start": "2021-03-13T00:00:00Z", - "end": "2021-03-15T00:00:00Z", + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), } response = self.do_request( @@ -294,11 +303,11 @@ def test_timeseries_interval(self): assert result_sorted(response.data) == { "intervals": [ - "2021-03-13T12:00:00Z", - "2021-03-13T18:00:00Z", - "2021-03-14T00:00:00Z", - "2021-03-14T06:00:00Z", - "2021-03-14T12:00:00Z", + isoformat_z((self._now - timedelta(days=1)).replace(hour=12, minute=0, second=0)), + isoformat_z((self._now - timedelta(days=1)).replace(hour=18, minute=0, second=0)), + isoformat_z(self._now.replace(hour=0, minute=0, second=0)), + isoformat_z(self._now.replace(hour=6, minute=0, second=0)), + isoformat_z(self._now.replace(hour=12, minute=0, second=0)), ], "groups": [ { @@ -307,11 +316,13 @@ def test_timeseries_interval(self): "totals": {"sum(quantity)": 6}, } ], - "start": "2021-03-13T12:00:00Z", - "end": "2021-03-14T18:00:00Z", + "start": isoformat_z( + self._now.replace(hour=12, minute=0, second=0) - timedelta(days=1) + ), + "end": isoformat_z(self._now.replace(hour=18, minute=0, second=0)), } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_user_org_total_all_accessible(self): response = self.do_request( { @@ -326,15 +337,18 @@ def test_user_org_total_all_accessible(self): ) assert result_sorted(response.data) == { - "start": "2021-03-13T00:00:00Z", - "end": "2021-03-15T00:00:00Z", - "intervals": ["2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"], + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), + "intervals": [ + isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + isoformat_z(floor_to_utc_day(self._now)), + ], "groups": [ {"by": {}, "series": {"sum(quantity)": [0, 7]}, "totals": {"sum(quantity)": 7}} ], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_user_no_proj_specific_access(self): response = self.do_request( { @@ -362,12 +376,12 @@ def test_user_no_proj_specific_access(self): ) assert result_sorted(response.data) == { - "start": "2021-03-13T00:00:00Z", - "end": "2021-03-15T00:00:00Z", + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), "groups": [], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_no_project_access(self): user = self.create_user(is_superuser=False) self.create_member(user=user, organization=self.organization, role="member", teams=[]) @@ -407,7 +421,7 @@ def test_no_project_access(self): "detail": "You do not have permission to perform this action." } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_open_membership_semantics(self): self.org.flags.allow_joinleave = True self.org.save() @@ -425,8 +439,8 @@ def test_open_membership_semantics(self): ) assert result_sorted(response.data) == { - "start": "2021-03-13T00:00:00Z", - "end": "2021-03-15T00:00:00Z", + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), "groups": [ { "by": {"project": self.project.id}, @@ -439,7 +453,7 @@ def test_open_membership_semantics(self): ], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_org_simple(self): response = self.do_request( { @@ -453,7 +467,6 @@ def test_org_simple(self): ) assert result_sorted(response.data) == { - "end": "2021-03-15T00:00:00Z", "groups": [ { "by": { @@ -484,11 +497,16 @@ def test_org_simple(self): "totals": {"sum(quantity)": 1}, }, ], - "intervals": ["2021-03-12T00:00:00Z", "2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"], - "start": "2021-03-12T00:00:00Z", + "intervals": [ + isoformat_z(floor_to_utc_day(self._now) - timedelta(days=2)), + isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + isoformat_z(floor_to_utc_day(self._now)), + ], + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=2)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_staff_org_individual_category(self): staff_user = self.create_user(is_staff=True, is_superuser=True) self.login_as(user=staff_user, superuser=True) @@ -532,17 +550,17 @@ def test_staff_org_individual_category(self): ) assert result_sorted(response.data) == { - "start": "2021-03-12T00:00:00Z", - "end": "2021-03-15T00:00:00Z", + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=2)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), "intervals": [ - "2021-03-12T00:00:00Z", - "2021-03-13T00:00:00Z", - "2021-03-14T00:00:00Z", + isoformat_z(floor_to_utc_day(self._now) - timedelta(days=2)), + isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + isoformat_z(floor_to_utc_day(self._now)), ], "groups": [category_group_mapping[category]], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_org_multiple_fields(self): response = self.do_request( { @@ -556,9 +574,13 @@ def test_org_multiple_fields(self): ) assert result_sorted(response.data) == { - "start": "2021-03-12T00:00:00Z", - "end": "2021-03-15T00:00:00Z", - "intervals": ["2021-03-12T00:00:00Z", "2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"], + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=2)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), + "intervals": [ + isoformat_z(floor_to_utc_day(self._now) - timedelta(days=2)), + isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + isoformat_z(floor_to_utc_day(self._now)), + ], "groups": [ { "by": { @@ -591,7 +613,7 @@ def test_org_multiple_fields(self): ], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_org_group_by_project(self): response = self.do_request( { @@ -606,8 +628,8 @@ def test_org_group_by_project(self): ) assert result_sorted(response.data) == { - "start": "2021-03-13T00:00:00Z", - "end": "2021-03-15T00:00:00Z", + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), "groups": [ { "by": {"project": self.project.id}, @@ -620,7 +642,7 @@ def test_org_group_by_project(self): ], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_org_project_totals_per_project(self): response_per_group = self.do_request( { @@ -652,7 +674,7 @@ def test_org_project_totals_per_project(self): assert response_total.status_code == 200, response_total.content assert response_total.data["groups"][0]["totals"]["sum(times_seen)"] == per_group_total - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_project_filter(self): response = self.do_request( { @@ -667,15 +689,18 @@ def test_project_filter(self): ) assert result_sorted(response.data) == { - "start": "2021-03-13T00:00:00Z", - "end": "2021-03-15T00:00:00Z", - "intervals": ["2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"], + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), + "intervals": [ + isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + isoformat_z(floor_to_utc_day(self._now)), + ], "groups": [ {"by": {}, "totals": {"sum(quantity)": 6}, "series": {"sum(quantity)": [0, 6]}} ], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_staff_project_filter(self): staff_user = self.create_user(is_staff=True, is_superuser=True) self.login_as(user=staff_user, superuser=True) @@ -687,9 +712,12 @@ def test_staff_project_filter(self): "statsPeriod": "1d", } shared_data = { - "start": "2021-03-13T00:00:00Z", - "end": "2021-03-15T00:00:00Z", - "intervals": ["2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"], + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), + "intervals": [ + isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + isoformat_z(floor_to_utc_day(self._now)), + ], } # Test error category @@ -736,7 +764,7 @@ def test_staff_project_filter(self): ], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_reason_filter(self): response = self.do_request( { @@ -751,9 +779,12 @@ def test_reason_filter(self): ) assert result_sorted(response.data) == { - "start": "2021-03-13T00:00:00Z", - "end": "2021-03-15T00:00:00Z", - "intervals": ["2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"], + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), + "intervals": [ + isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + isoformat_z(floor_to_utc_day(self._now)), + ], "groups": [ { "by": {"category": "attachment"}, @@ -768,7 +799,7 @@ def test_reason_filter(self): ], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_outcome_filter(self): response = self.do_request( { @@ -783,15 +814,18 @@ def test_outcome_filter(self): ) assert result_sorted(response.data) == { - "start": "2021-03-13T00:00:00Z", - "end": "2021-03-15T00:00:00Z", - "intervals": ["2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"], + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), + "intervals": [ + isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + isoformat_z(floor_to_utc_day(self._now)), + ], "groups": [ {"by": {}, "totals": {"sum(quantity)": 6}, "series": {"sum(quantity)": [0, 6]}} ], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_category_filter(self): response = self.do_request( { @@ -805,15 +839,18 @@ def test_category_filter(self): ) assert result_sorted(response.data) == { - "start": "2021-03-13T00:00:00Z", - "end": "2021-03-15T00:00:00Z", - "intervals": ["2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"], + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), + "intervals": [ + isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + isoformat_z(floor_to_utc_day(self._now)), + ], "groups": [ {"by": {}, "totals": {"sum(quantity)": 6}, "series": {"sum(quantity)": [0, 6]}} ], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_minute_interval_sum_quantity(self): response = self.do_request( { @@ -827,14 +864,14 @@ def test_minute_interval_sum_quantity(self): ) assert result_sorted(response.data) == { - "start": "2021-03-14T11:15:00Z", - "end": "2021-03-14T12:30:00Z", + "start": isoformat_z(self._now.replace(hour=11, minute=15, second=0)), + "end": isoformat_z(self._now.replace(hour=12, minute=30, second=0)), "intervals": [ - "2021-03-14T11:15:00Z", - "2021-03-14T11:30:00Z", - "2021-03-14T11:45:00Z", - "2021-03-14T12:00:00Z", - "2021-03-14T12:15:00Z", + isoformat_z(self._now.replace(hour=11, minute=15, second=0)), + isoformat_z(self._now.replace(hour=11, minute=30, second=0)), + isoformat_z(self._now.replace(hour=11, minute=45, second=0)), + isoformat_z(self._now.replace(hour=12, minute=00, second=0)), + isoformat_z(self._now.replace(hour=12, minute=15, second=0)), ], "groups": [ { @@ -845,7 +882,7 @@ def test_minute_interval_sum_quantity(self): ], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_minute_interval_sum_times_seen(self): response = self.do_request( { @@ -857,14 +894,14 @@ def test_minute_interval_sum_times_seen(self): ) assert response.status_code == 200, response.content assert result_sorted(response.data) == { - "start": "2021-03-14T11:15:00Z", - "end": "2021-03-14T12:30:00Z", + "start": isoformat_z(self._now.replace(hour=11, minute=15, second=0)), + "end": isoformat_z(self._now.replace(hour=12, minute=30, second=0)), "intervals": [ - "2021-03-14T11:15:00Z", - "2021-03-14T11:30:00Z", - "2021-03-14T11:45:00Z", - "2021-03-14T12:00:00Z", - "2021-03-14T12:15:00Z", + isoformat_z(self._now.replace(hour=11, minute=15, second=0)), + isoformat_z(self._now.replace(hour=11, minute=30, second=0)), + isoformat_z(self._now.replace(hour=11, minute=45, second=0)), + isoformat_z(self._now.replace(hour=12, minute=00, second=0)), + isoformat_z(self._now.replace(hour=12, minute=15, second=0)), ], "groups": [ { @@ -875,7 +912,7 @@ def test_minute_interval_sum_times_seen(self): ], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_profile_duration_filter(self): """Test that profile_duration data is correctly filtered and returned""" response = self.do_request( @@ -890,9 +927,12 @@ def test_profile_duration_filter(self): ) assert result_sorted(response.data) == { - "start": "2021-03-13T00:00:00Z", - "end": "2021-03-15T00:00:00Z", - "intervals": ["2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"], + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), + "intervals": [ + isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + isoformat_z(floor_to_utc_day(self._now)), + ], "groups": [ { "by": {}, @@ -902,7 +942,7 @@ def test_profile_duration_filter(self): ], } - @freeze_time("2021-03-14T12:27:28.303Z") + @freeze_time(_now) def test_profile_duration_groupby(self): """Test that profile_duration data is correctly grouped""" response = self.do_request( @@ -918,9 +958,12 @@ def test_profile_duration_groupby(self): ) assert result_sorted(response.data) == { - "start": "2021-03-13T00:00:00Z", - "end": "2021-03-15T00:00:00Z", - "intervals": ["2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"], + "start": isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + "end": isoformat_z(floor_to_utc_day(self._now) + timedelta(days=1)), + "intervals": [ + isoformat_z(floor_to_utc_day(self._now) - timedelta(days=1)), + isoformat_z(floor_to_utc_day(self._now)), + ], "groups": [ { "by": {"category": "profile_duration"},