diff --git a/src/sentry/api/serializers/rest_framework/dashboard.py b/src/sentry/api/serializers/rest_framework/dashboard.py index db6930c4c259e1..874c9686f8faf9 100644 --- a/src/sentry/api/serializers/rest_framework/dashboard.py +++ b/src/sentry/api/serializers/rest_framework/dashboard.py @@ -105,6 +105,77 @@ def is_table_display_type(display_type): MAX_WIDGET_COLS = 6 +_DEFAULT_CHART_AND_TABLE_TYPES: frozenset[int] = frozenset( + { + DashboardWidgetDisplayTypes.LINE_CHART, + DashboardWidgetDisplayTypes.AREA_CHART, + DashboardWidgetDisplayTypes.BAR_CHART, + DashboardWidgetDisplayTypes.TABLE, + DashboardWidgetDisplayTypes.BIG_NUMBER, + DashboardWidgetDisplayTypes.CATEGORICAL_BAR_CHART, + } +) + + +class DatasetConfig(TypedDict): + supported_display_types: frozenset[int] + + +# Per-dataset config mirroring the frontend dataset configs +# (``static/app/views/dashboards/datasetConfig/*.tsx``). A display type is +# allowed for a widget_type iff it appears in ``supported_display_types`` here. +DATASET_CONFIG: dict[int, DatasetConfig] = { + DashboardWidgetTypes.DISCOVER: {"supported_display_types": _DEFAULT_CHART_AND_TABLE_TYPES}, + # ERROR_EVENTS is intentionally omitted: it's the ``create_widget`` default + # when a request omits widget_type, so any system display type a prebuilt + # config doesn't tag will land here. Without a config entry the validation + # falls through and lets the request pass. + DashboardWidgetTypes.TRANSACTION_LIKE: { + "supported_display_types": _DEFAULT_CHART_AND_TABLE_TYPES + }, + DashboardWidgetTypes.RELEASE_HEALTH: { + "supported_display_types": _DEFAULT_CHART_AND_TABLE_TYPES + }, + DashboardWidgetTypes.METRICS: {"supported_display_types": _DEFAULT_CHART_AND_TABLE_TYPES}, + DashboardWidgetTypes.LOGS: {"supported_display_types": _DEFAULT_CHART_AND_TABLE_TYPES}, + DashboardWidgetTypes.ISSUE: { + "supported_display_types": frozenset( + { + DashboardWidgetDisplayTypes.TABLE, + DashboardWidgetDisplayTypes.LINE_CHART, + DashboardWidgetDisplayTypes.AREA_CHART, + DashboardWidgetDisplayTypes.BAR_CHART, + } + ) + }, + DashboardWidgetTypes.SPANS: { + "supported_display_types": _DEFAULT_CHART_AND_TABLE_TYPES + | frozenset( + { + DashboardWidgetDisplayTypes.DETAILS, + DashboardWidgetDisplayTypes.SERVER_TREE, + # WHEEL is used by built-in performance-score widgets. + DashboardWidgetDisplayTypes.WHEEL, + } + ) + }, + DashboardWidgetTypes.TRACEMETRICS: { + "supported_display_types": frozenset( + { + DashboardWidgetDisplayTypes.LINE_CHART, + DashboardWidgetDisplayTypes.AREA_CHART, + DashboardWidgetDisplayTypes.BAR_CHART, + DashboardWidgetDisplayTypes.BIG_NUMBER, + DashboardWidgetDisplayTypes.CATEGORICAL_BAR_CHART, + } + ) + }, + DashboardWidgetTypes.PREPROD_APP_SIZE: { + "supported_display_types": frozenset({DashboardWidgetDisplayTypes.LINE_CHART}) + }, +} + + class WidgetLayoutSerializer(CamelSnakeSerializer[Dashboard]): """Widget grid layout position and dimensions. @@ -326,7 +397,24 @@ class DashboardWidgetSerializer(CamelSnakeSerializer[Dashboard]): ) def validate_display_type(self, display_type): - return DashboardWidgetDisplayTypes.get_id_for_type_name(display_type) + display_type_id = DashboardWidgetDisplayTypes.get_id_for_type_name(display_type) + + widget_type_name = self.context.get("widget_type") + if widget_type_name is not None and display_type_id is not None: + widget_type_id = DashboardWidgetTypes.get_id_for_type_name(widget_type_name) + config = DATASET_CONFIG.get(widget_type_id) + if config is not None and display_type_id not in config["supported_display_types"]: + supported_names = sorted( + DashboardWidgetDisplayTypes.get_type_name(d) or str(d) + for d in config["supported_display_types"] + ) + raise serializers.ValidationError( + f"Display type '{display_type}' is not supported for the " + f"'{widget_type_name}' dataset. Supported display types: " + f"{', '.join(supported_names)}." + ) + + return display_type_id def _validate_widget_type(self, data): widget_type = DashboardWidgetTypes.get_id_for_type_name(data.get("widget_type")) @@ -358,6 +446,11 @@ def to_internal_value(self, data): queries_serializer = self.fields["queries"] additional_context = {} + # Always reset; with ``many=True`` DRF reuses one child serializer + # instance across items, so stale values would otherwise leak between + # widgets in the same request. + self.context["widget_type"] = data.get("widget_type") + if data.get("display_type"): additional_context["display_type"] = data.get("display_type") if data.get("widget_type"): diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 127c03cce2ca24..b184dd0aaea14f 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -66,8 +66,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:dashboards-basic", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True, default=True) # Enables custom editable dashboards manager.add("organizations:dashboards-edit", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True, default=True) - # Enable unfurling of dashboard widgets in Slack - manager.add("organizations:dashboards-widget-unfurl", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable metrics enhanced performance for AM2+ customers as they transition from AM2 to AM3 manager.add("organizations:dashboards-metrics-transition", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable drilldown flow for dashboards @@ -348,8 +346,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:insights-alerts", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable data browsing heat map widget manager.add("organizations:data-browsing-heat-map-widget", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enable data browsing widget unfurl - manager.add("organizations:data-browsing-widget-unfurl", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable public RPC endpoint for local seer development manager.add("organizations:seer-public-rpc", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Organizations on the old usage-based (v0) Seer plan diff --git a/src/sentry/hybridcloud/outbox/category.py b/src/sentry/hybridcloud/outbox/category.py index acfbc5b27d6477..92bc2cb9269bd7 100644 --- a/src/sentry/hybridcloud/outbox/category.py +++ b/src/sentry/hybridcloud/outbox/category.py @@ -54,8 +54,8 @@ class OutboxCategory(IntEnum): ISSUE_COMMENT_UPDATE = 34 EXTERNAL_ACTOR_UPDATE = 35 - RELOCATION_EXPORT_REQUEST = 36 # no longer in use - RELOCATION_EXPORT_REPLY = 37 # no longer in use + UNUSED_FIVE = 36 + UNUSED_SIX = 37 SEND_VERCEL_INVOICE = 38 FTC_CONSENT = 39 @@ -337,9 +337,7 @@ class OutboxScope(IntEnum): ) SUBSCRIPTION_SCOPE = scope_categories(9, {OutboxCategory.SUBSCRIPTION_UPDATE}) # relocation scope is no longer in use. - RELOCATION_SCOPE = scope_categories( - 10, {OutboxCategory.RELOCATION_EXPORT_REQUEST, OutboxCategory.RELOCATION_EXPORT_REPLY} - ) + RELOCATION_SCOPE = scope_categories(10, {OutboxCategory.UNUSED_FIVE, OutboxCategory.UNUSED_SIX}) API_TOKEN_SCOPE = scope_categories(11, {OutboxCategory.API_TOKEN_UPDATE}) ACTION_SCOPE = scope_categories(12, {OutboxCategory.SENTRY_APP_NORMALIZE_ACTIONS}) SEER_SCOPE = scope_categories(13, {OutboxCategory.SEER_RUN_CREATE}) diff --git a/src/sentry/hybridcloud/rpc/service.py b/src/sentry/hybridcloud/rpc/service.py index 3e3f94691636da..02f2dde54c3eff 100644 --- a/src/sentry/hybridcloud/rpc/service.py +++ b/src/sentry/hybridcloud/rpc/service.py @@ -399,6 +399,7 @@ def list_all_service_method_signatures() -> Iterable[RpcMethodSignature]: "sentry.notifications.services", "sentry.organizations.services", "sentry.projects.services", + "sentry.relocation.services", "sentry.sentry_apps.services", "sentry.users.services", ) diff --git a/src/sentry/integrations/slack/unfurl/dashboards.py b/src/sentry/integrations/slack/unfurl/dashboards.py index ae2054773b9b56..6873b0796b5fc2 100644 --- a/src/sentry/integrations/slack/unfurl/dashboards.py +++ b/src/sentry/integrations/slack/unfurl/dashboards.py @@ -129,7 +129,7 @@ def _unfurl_dashboards( enabled_orgs = { slug: org for slug, org in orgs_by_slug.items() - if features.has("organizations:dashboards-widget-unfurl", org, actor=user) + if features.has("organizations:dashboards-basic", org, actor=user) } if not enabled_orgs: return {} diff --git a/src/sentry/integrations/slack/unfurl/explore.py b/src/sentry/integrations/slack/unfurl/explore.py index 841719b11c8aae..ced91ca3864d8e 100644 --- a/src/sentry/integrations/slack/unfurl/explore.py +++ b/src/sentry/integrations/slack/unfurl/explore.py @@ -375,11 +375,10 @@ def _unfurl_explore( ) orgs_by_slug = {org.slug: org for org in organizations} - # Check if any org has the feature flag enabled before doing any work enabled_orgs = { slug: org for slug, org in orgs_by_slug.items() - if features.has("organizations:data-browsing-widget-unfurl", org, actor=user) + if features.has("organizations:visibility-explore-view", org, actor=user) } if not enabled_orgs: return {} diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py index eee70333cd08e1..504b0e0b4c044f 100644 --- a/src/sentry/integrations/slack/webhooks/event.py +++ b/src/sentry/integrations/slack/webhooks/event.py @@ -229,8 +229,8 @@ def _get_unfurlable_links( feature_flag = { LinkType.DISCOVER: "organizations:discover-basic", - LinkType.EXPLORE: "organizations:data-browsing-widget-unfurl", - LinkType.DASHBOARDS: "organizations:dashboards-widget-unfurl", + LinkType.EXPLORE: "organizations:visibility-explore-view", + LinkType.DASHBOARDS: "organizations:dashboards-basic", }.get(link_type) if ( diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 080744a8fd012e..ad9651c003abb9 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -3048,13 +3048,6 @@ flags=FLAG_SCALAR | FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "relocation.outbox-orgslug.killswitch", - default=[], - type=Sequence, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) - register( "profiling.killswitch.ingest-profiles", type=Sequence, diff --git a/src/sentry/receivers/outbox/cell.py b/src/sentry/receivers/outbox/cell.py index bb4f4acda34594..582654263399bf 100644 --- a/src/sentry/receivers/outbox/cell.py +++ b/src/sentry/receivers/outbox/cell.py @@ -14,7 +14,6 @@ from django.dispatch import receiver -from sentry import options from sentry.audit_log.services.log import AuditLogEvent, UserIpEvent, log_rpc_service from sentry.auth.services.auth import auth_service from sentry.auth.services.orgauthtoken import orgauthtoken_rpc_service @@ -28,11 +27,9 @@ ) from sentry.integrations.services.integration import integration_service from sentry.models.authproviderreplica import AuthProviderReplica -from sentry.models.files.utils import get_relocation_storage from sentry.models.organization import Organization from sentry.models.project import Project from sentry.receivers.outbox import maybe_process_tombstone -from sentry.relocation.services.relocation_export.service import control_relocation_export_service from sentry.seer.agent.client_utils import AgentChatRequest, make_agent_chat_request from sentry.seer.autofix.utils import make_autofix_start_request from sentry.seer.models.run import SeerRun, SeerRunMirrorStatus, SeerRunType @@ -210,45 +207,6 @@ def process_disable_auth_provider(object_identifier: int, shard_identifier: int, AuthProviderReplica.objects.filter(auth_provider_id=object_identifier).delete() -# See the comment on /src/sentry/relocation/tasks/process.py::uploading_start for a detailed description of -# how this outbox drain handler fits into the entire SAAS->SAAS relocation workflow. -@receiver(process_cell_outbox, sender=OutboxCategory.RELOCATION_EXPORT_REPLY) -def process_relocation_reply_with_export(payload: Any, **kwds): - uuid = payload["relocation_uuid"] - slug = payload["org_slug"] - - killswitch_orgs = options.get("relocation.outbox-orgslug.killswitch") - if slug in killswitch_orgs: - logger.info( - "relocation.killswitch.org", - extra={ - "org_slug": slug, - "relocation_uuid": uuid, - }, - ) - return - - relocation_storage = get_relocation_storage() - path = f"runs/{uuid}/saas_to_saas_export/{slug}.tar" - try: - encrypted_bytes = relocation_storage.open(path) - except Exception: - raise FileNotFoundError( - "Could not open SaaS -> SaaS export in export-side relocation bucket." - ) - - with encrypted_bytes: - control_relocation_export_service.reply_with_export( - relocation_uuid=uuid, - requesting_region_name=payload["requesting_region_name"], - replying_region_name=payload["replying_region_name"], - org_slug=slug, - # TODO(azaslavsky): finish transfer from `encrypted_contents` -> `encrypted_bytes`. - encrypted_contents=None, - encrypted_bytes=[int(byte) for byte in encrypted_bytes.read()], - ) - - @receiver(process_cell_outbox, sender=OutboxCategory.SEER_RUN_CREATE) def handle_seer_run_create(object_identifier: int, payload: Any, **kwds: Any) -> None: try: diff --git a/src/sentry/relocation/services/__init__.py b/src/sentry/relocation/services/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/src/sentry/relocation/services/relocation_export/impl.py b/src/sentry/relocation/services/relocation_export/impl.py index 0cb40d86814249..2a6447e62f4905 100644 --- a/src/sentry/relocation/services/relocation_export/impl.py +++ b/src/sentry/relocation/services/relocation_export/impl.py @@ -25,8 +25,6 @@ CellRelocationExportService, ControlRelocationExportService, ) -from sentry.relocation.tasks.process import fulfill_cross_region_export_request, uploading_complete -from sentry.relocation.tasks.transfer import process_relocation_transfer_control from sentry.relocation.utils import RELOCATION_BLOB_SIZE, RELOCATION_FILE_TYPE from sentry.utils.db import atomic_transaction @@ -43,6 +41,8 @@ def request_new_export( org_slug: str, encrypt_with_public_key: bytes, ) -> None: + from sentry.relocation.tasks.process import fulfill_cross_region_export_request + logger_data = { "uuid": relocation_uuid, "requesting_region_name": requesting_region_name, @@ -81,6 +81,8 @@ def reply_with_export( # TODO(azaslavsky): finish transfer from `encrypted_contents` -> `encrypted_bytes`. encrypted_contents: bytes | None = None, ) -> None: + from sentry.relocation.tasks.process import uploading_complete + with atomic_transaction( using=( router.db_for_write(Relocation), @@ -127,7 +129,7 @@ def reply_with_export( logger.info("SaaS -> SaaS relocation RelocationFile saved", extra=logger_data) - uploading_complete.apply_async(args=[relocation.uuid]) + uploading_complete.apply_async(args=[str(relocation.uuid)]) logger.info("SaaS -> SaaS relocation next task scheduled", extra=logger_data) @@ -141,6 +143,8 @@ def request_new_export( org_slug: str, encrypt_with_public_key: bytes, ) -> None: + from sentry.relocation.tasks.transfer import process_relocation_transfer_control + logger_data = { "uuid": relocation_uuid, "requesting_region_name": requesting_region_name, @@ -173,6 +177,8 @@ def reply_with_export( # TODO(azaslavsky): finish transfer from `encrypted_contents` -> `encrypted_bytes`. encrypted_contents: bytes | None = None, ) -> None: + from sentry.relocation.tasks.transfer import process_relocation_transfer_control + logger_data = { "uuid": relocation_uuid, "requesting_region_name": requesting_region_name, diff --git a/src/sentry/relocation/tasks/process.py b/src/sentry/relocation/tasks/process.py index ac8f4ac645c5b8..f229c1a482e191 100644 --- a/src/sentry/relocation/tasks/process.py +++ b/src/sentry/relocation/tasks/process.py @@ -63,7 +63,9 @@ RegionRelocationTransfer, RelocationTransferState, ) -from sentry.relocation.tasks.transfer import process_relocation_transfer_region +from sentry.relocation.services.relocation_export.service import ( + control_relocation_export_service, +) from sentry.relocation.utils import ( TASK_TO_STEP, LoggingPrinter, @@ -247,9 +249,6 @@ def uploading_start(uuid: str, replying_cell_name: str | None, org_slug: str | N with the `Relocation` that originally triggered `uploading_start`, and the next task in the sequence (`uploading_complete`) is scheduled. """ - from sentry.relocation.services.relocation_export.service import ( - control_relocation_export_service, - ) uuid = str(uuid) (relocation, attempts_left) = start_relocation_task( @@ -333,6 +332,8 @@ def fulfill_cross_region_export_request( call is received with the encrypted export in tow, it will trigger the next step in the `SAAS_TO_SAAS` relocation's pipeline, namely `uploading_complete`. """ + from sentry.relocation.tasks.transfer import process_relocation_transfer_region + encrypt_with_public_key_bytes = base64.b64decode(encrypt_with_public_key.encode("utf8")) logger_data = { diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index 9f6d198e95a0ab..67cb156ae63213 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -361,6 +361,19 @@ def _maybe_continue_pipeline( reached_stopping_point=reached_stopping_point, ) ) + logger.info( + "autofix.on_completion_hook.introspection", + extra={ + "organization_id": organization.id, + "project_id": group.project_id, + "group_id": group.id, + "referrer": referrer.value, + "step": current_step.value, + "action": decision.action.value, + "reason": decision.reason, + "reached_stopping_point": reached_stopping_point, + }, + ) if stopping_point is None or reached_stopping_point: # We've reached the stopping point diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index b305f10f4403d8..f6bd2124ae198d 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -134,12 +134,6 @@ class ExplorerAutofixRequestSerializer(CamelSnakeSerializer): required=False, help_text="Coding agent provider (e.g., 'github_copilot'). Alternative to integration_id for user-authenticated providers.", ) - intelligence_level = serializers.ChoiceField( - required=False, - choices=["low", "medium", "high"], - default="medium", - help_text="The intelligence level to use.", - ) user_context = serializers.CharField( required=False, max_length=1000, @@ -328,7 +322,7 @@ def _post_agent(self, request: Request, group: Group) -> Response: referrer=_parse_autofix_referrer(data.get("referrer")), stopping_point=AutofixStoppingPoint(stopping_point) if stopping_point else None, run_id=run_id, - intelligence_level=data["intelligence_level"], + intelligence_level="medium", user_context=data.get("user_context"), insert_index=data.get("insert_index"), ) diff --git a/static/app/components/events/autofix/autofixRootCause.tsx b/static/app/components/events/autofix/autofixRootCause.tsx deleted file mode 100644 index 382f0aac706764..00000000000000 --- a/static/app/components/events/autofix/autofixRootCause.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import type {AutofixRootCauseData} from 'sentry/components/events/autofix/types'; - -export function formatRootCauseText( - cause: AutofixRootCauseData | undefined, - customRootCause?: string -) { - if (!cause && !customRootCause) { - return ''; - } - - if (customRootCause) { - return `# Root Cause of the Issue\n\n${customRootCause}`; - } - - if (!cause) { - return ''; - } - - const parts: string[] = ['# Root Cause of the Issue']; - - if (cause.description) { - parts.push(cause.description); - } - - if (cause.root_cause_reproduction) { - parts.push( - cause.root_cause_reproduction - .map(event => { - const eventParts = [`### ${event.title}`]; - - if (event.code_snippet_and_analysis) { - eventParts.push(event.code_snippet_and_analysis); - } - - if (event.relevant_code_file) { - eventParts.push(`(See @${event.relevant_code_file.file_path})`); - } - - return eventParts.join('\n'); - }) - .join('\n\n') - ); - } - - return parts.join('\n\n'); -} diff --git a/static/app/components/events/autofix/autofixSolution.tsx b/static/app/components/events/autofix/autofixSolution.tsx deleted file mode 100644 index cc1747e28a2de9..00000000000000 --- a/static/app/components/events/autofix/autofixSolution.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type {AutofixSolutionTimelineEvent} from 'sentry/components/events/autofix/types'; - -export function formatSolutionText( - solution: AutofixSolutionTimelineEvent[], - customSolution?: string -) { - if (!solution && !customSolution) { - return ''; - } - - if (customSolution) { - return `# Solution Plan\n\n${customSolution}`; - } - - if (!solution || solution.length === 0) { - return ''; - } - - const parts = ['# Solution Plan']; - - parts.push( - solution - .filter(event => event.is_active) - .map((event, index) => { - const eventParts = [`### ${index + 1}. ${event.title}`]; - - if (event.code_snippet_and_analysis) { - eventParts.push(event.code_snippet_and_analysis); - } - - if (event.relevant_code_file) { - eventParts.push(`(See @${event.relevant_code_file.file_path})`); - } - - return eventParts.join('\n'); - }) - .join('\n\n') - ); - - return parts.join('\n\n'); -} diff --git a/static/app/components/events/autofix/types.ts b/static/app/components/events/autofix/types.ts index 4837aa5ddfef41..0fad08866b7dcf 100644 --- a/static/app/components/events/autofix/types.ts +++ b/static/app/components/events/autofix/types.ts @@ -1,5 +1,4 @@ import {t} from 'sentry/locale'; -import type {User} from 'sentry/types/user'; import {isArrayOf} from 'sentry/types/utils'; export enum DiffFileType { @@ -30,22 +29,6 @@ function isDiffLineType(value: unknown): value is DiffLineType { ); } -export enum AutofixStepType { - DEFAULT = 'default', - ROOT_CAUSE_ANALYSIS = 'root_cause_analysis', - CHANGES = 'changes', - SOLUTION = 'solution', -} - -export enum AutofixStatus { - COMPLETED = 'COMPLETED', - ERROR = 'ERROR', - PROCESSING = 'PROCESSING', - NEED_MORE_INFORMATION = 'NEED_MORE_INFORMATION', - CANCELLED = 'CANCELLED', - WAITING_FOR_USER_RESPONSE = 'WAITING_FOR_USER_RESPONSE', -} - export enum AutofixStoppingPoint { ROOT_CAUSE = 'root_cause', SOLUTION = 'solution', @@ -53,22 +36,6 @@ export enum AutofixStoppingPoint { OPEN_PR = 'open_pr', } -type AutofixPullRequestDetails = { - pr_number: number; - pr_url: string; -}; - -type AutofixOptions = { - iterative_feedback?: boolean; -}; - -interface CodingAgentResult { - description: string; - pr_url: string | null; - repo_full_name: string; - repo_provider: string; -} - export enum CodingAgentStatus { PENDING = 'pending', RUNNING = 'running', @@ -89,185 +56,6 @@ export function getResultButtonLabel(url: string | null | undefined): string { return t('View Pull Request'); } -interface CodingAgentState { - id: string; - name: string; - provider: CodingAgentProvider; - started_at: string; - status: CodingAgentStatus; - agent_url?: string; - results?: CodingAgentResult[]; -} - -type CodebaseState = { - is_readable: boolean | null; - is_writeable: boolean | null; - repo_external_id: string | null; -}; - -export type AutofixData = { - codebases: Record; - last_triggered_at: string; - request: { - repos: SeerRepoDefinition[]; - options?: { - auto_run_source?: string | null; - }; - }; - run_id: string; - status: AutofixStatus; - actor_ids?: number[]; - codebase_indexing?: { - status: 'COMPLETED'; - }; - coding_agents?: Record; - completed_at?: string | null; - error_message?: string; - options?: AutofixOptions; - steps?: AutofixStep[]; - users?: Record; -}; - -type AutofixProgressItem = { - message: string; - timestamp: string; - type: 'INFO' | 'WARNING' | 'ERROR' | 'NEED_MORE_INFORMATION'; - data?: any; -}; - -type AutofixStep = - | AutofixDefaultStep - | AutofixRootCauseStep - | AutofixSolutionStep - | AutofixChangesStep; - -interface BaseStep { - id: string; - index: number; - progress: AutofixProgressItem[]; - status: AutofixStatus; - title: string; - type: AutofixStepType; - active_comment_thread?: CommentThread | null; - agent_comment_thread?: CommentThread | null; - completedMessage?: string; - key?: string; - output_stream?: string | null; -} - -type CommentThread = { - id: string; - is_completed: boolean; - messages: CommentThreadMessage[]; -}; - -interface CommentThreadMessage { - content: string; - role: 'user' | 'assistant'; - isLoading?: boolean; -} - -type AutofixInsight = { - insight: string; - justification: string; - change_diff?: FilePatch[]; - markdown_snippets?: string; - sources?: InsightSources; - type?: 'insight' | 'file_change'; -}; - -type InsightSources = { - breadcrumbs_used: boolean; - code_used_urls: string[]; - connected_error_ids_used: string[]; - diff_urls: string[]; - http_request_used: boolean; - profile_ids_used: string[]; - stacktrace_used: boolean; - thoughts: string; - trace_event_ids_used: string[]; - event_trace_id?: string; - event_trace_timestamp?: number; -}; - -interface AutofixDefaultStep extends BaseStep { - insights: AutofixInsight[]; - type: AutofixStepType.DEFAULT; -} - -type AutofixRootCauseSelection = - | { - cause_id: string; - } - | {custom_root_cause: string} - | null; - -interface AutofixRootCauseStep extends BaseStep { - causes: AutofixRootCauseData[]; - selection: AutofixRootCauseSelection; - type: AutofixStepType.ROOT_CAUSE_ANALYSIS; - termination_reason?: string; -} - -interface AutofixSolutionStep extends BaseStep { - solution: AutofixSolutionTimelineEvent[]; - solution_selected: boolean; - type: AutofixStepType.SOLUTION; - custom_solution?: string; - description?: string; -} - -type AutofixCodebaseChange = { - description: string; - diff: FilePatch[]; - repo_name: string; - title: string; - branch_name?: string; - diff_str?: string; - pull_request?: AutofixPullRequestDetails; - repo_external_id?: string; - repo_id?: number; // The repo_id is only here for temporary backwards compatibility for LA customers, and we should remove it soon. Use repo_external_id instead. -}; - -interface AutofixChangesStep extends BaseStep { - changes: AutofixCodebaseChange[]; - type: AutofixStepType.CHANGES; - termination_reason?: string; -} - -type AutofixRelevantCodeFile = { - file_path: string; - repo_name: string; -}; - -type AutofixRelevantCodeFileWithUrl = AutofixRelevantCodeFile & { - url?: string; -}; - -type AutofixTimelineEvent = { - code_snippet_and_analysis: string; - relevant_code_file: AutofixRelevantCodeFile; - timeline_item_type: 'internal_code' | 'external_system' | 'human_action'; - title: string; - is_most_important_event?: boolean; -}; - -export type AutofixSolutionTimelineEvent = { - timeline_item_type: 'internal_code' | 'human_instruction'; - title: string; - code_snippet_and_analysis?: string; - is_active?: boolean; - is_most_important_event?: boolean; - relevant_code_file?: AutofixRelevantCodeFileWithUrl; -}; - -export type AutofixRootCauseData = { - id: string; - description?: string; - reproduction_urls?: Array; - root_cause_reproduction?: AutofixTimelineEvent[]; -}; - export type FilePatch = { added: number; hunks: Hunk[]; diff --git a/static/app/components/events/autofix/useAutofix.tsx b/static/app/components/events/autofix/useAutofix.tsx index 023e2553b82d4b..abc969f0682d6a 100644 --- a/static/app/components/events/autofix/useAutofix.tsx +++ b/static/app/components/events/autofix/useAutofix.tsx @@ -1,42 +1,6 @@ -import {useQuery} from '@tanstack/react-query'; - -import {type AutofixData} from 'sentry/components/events/autofix/types'; import type {Organization} from 'sentry/types/organization'; import {apiOptions} from 'sentry/utils/api/apiOptions'; import type {RequestError} from 'sentry/utils/requestError/requestError'; -import {useOrganization} from 'sentry/utils/useOrganization'; - -type AutofixResponse = { - autofix: AutofixData | null; -}; - -function autofixApiOptions(orgSlug: string, groupId: string, isUserWatching = false) { - return apiOptions.as()( - '/organizations/$organizationIdOrSlug/issues/$issueId/autofix/', - { - path: {organizationIdOrSlug: orgSlug, issueId: groupId}, - query: {isUserWatching, mode: 'legacy'}, - staleTime: Infinity, - } - ); -} - -export const useAutofixData = ({ - groupId, - isUserWatching = false, -}: { - groupId: string; - isUserWatching?: boolean; -}) => { - const orgSlug = useOrganization().slug; - - const {data, isPending} = useQuery({ - ...autofixApiOptions(orgSlug, groupId, isUserWatching), - enabled: false, - }); - - return {data: data?.autofix ?? null, isPending}; -}; export type CodingAgentIntegration = { id: string | null; diff --git a/static/app/components/events/autofix/useExplorerAutofix.tsx b/static/app/components/events/autofix/useExplorerAutofix.tsx index 9befd99609f146..73c94332d7d6fe 100644 --- a/static/app/components/events/autofix/useExplorerAutofix.tsx +++ b/static/app/components/events/autofix/useExplorerAutofix.tsx @@ -119,7 +119,7 @@ export function isCodingAgentsArtifact( * State returned from the Explorer autofix endpoint. * This extends the SeerExplorer types with autofix-specific data. */ -interface ExplorerAutofixState { +export interface ExplorerAutofixState { blocks: Block[]; run_id: number; status: 'processing' | 'completed' | 'error' | 'awaiting_user_input'; diff --git a/static/app/components/events/autofix/utils.tsx b/static/app/components/events/autofix/utils.tsx index 9ab6b3654d9217..3cfb7d38912ab8 100644 --- a/static/app/components/events/autofix/utils.tsx +++ b/static/app/components/events/autofix/utils.tsx @@ -1,43 +1,9 @@ import {useCallback, useMemo} from 'react'; -import {formatRootCauseText} from 'sentry/components/events/autofix/autofixRootCause'; -import {formatSolutionText} from 'sentry/components/events/autofix/autofixSolution'; -import { - AUTOFIX_TTL_IN_DAYS, - AutofixStepType, - type AutofixData, -} from 'sentry/components/events/autofix/types'; +import {AUTOFIX_TTL_IN_DAYS} from 'sentry/components/events/autofix/types'; import type {Group} from 'sentry/types/group'; import {useOrganization} from 'sentry/utils/useOrganization'; -export function getRootCauseCopyText(autofixData: AutofixData) { - const rootCause = autofixData.steps?.find( - step => step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS - ); - if (!rootCause) { - return null; - } - - const cause = rootCause.causes.at(0); - - if (!cause) { - return null; - } - - return formatRootCauseText(cause); -} - -export function getSolutionCopyText(autofixData: AutofixData) { - const solution = autofixData.steps?.find( - step => step.type === AutofixStepType.SOLUTION - ); - if (!solution) { - return null; - } - - return formatSolutionText(solution.solution, solution.custom_solution); -} - const BASE_SUPPORTED_PROVIDERS = [ 'github', 'integrations:github', diff --git a/static/app/components/events/autofix/v3/drawer.tsx b/static/app/components/events/autofix/v3/drawer.tsx index 72facd29e2323e..b3952c1671f96c 100644 --- a/static/app/components/events/autofix/v3/drawer.tsx +++ b/static/app/components/events/autofix/v3/drawer.tsx @@ -96,7 +96,7 @@ function useHandleCopyMarkdown({ const markdown = getOrderedAutofixSections(aiAutofix.runState) .map(getAutofixArtifactFromSection) .filter(defined) - .map(artifactToMarkdown) + .map(artifact => artifactToMarkdown(artifact)) .filter(defined) .join('\n\n'); copy(markdown, {successMessage: t('Analysis copied to clipboard.')}); diff --git a/static/app/components/events/autofix/v3/utils.ts b/static/app/components/events/autofix/v3/utils.ts index 1895bc95562479..d1256664d5071a 100644 --- a/static/app/components/events/autofix/v3/utils.ts +++ b/static/app/components/events/autofix/v3/utils.ts @@ -17,44 +17,51 @@ import { type RepoPRState, } from 'sentry/views/seerExplorer/types'; -export function artifactToMarkdown(artifact: AutofixArtifact): string | null { +export function artifactToMarkdown( + artifact: AutofixArtifact, + headingLevel: 1 | 2 | 3 = 1 +): string | null { if (isRootCauseArtifact(artifact)) { - return rootCauseArtifactToMarkdown(artifact); + return rootCauseArtifactToMarkdown(artifact, headingLevel); } if (isSolutionArtifact(artifact)) { - return solutionArtifactToMarkdown(artifact); + return solutionArtifactToMarkdown(artifact, headingLevel); } if (isCodeChangesArtifact(artifact)) { - return filePatchesToMarkdown(artifact); + return filePatchesToMarkdown(artifact, headingLevel); } if (isPullRequestsArtifact(artifact)) { - return repoPRStatesToMarkdown(artifact); + return repoPRStatesToMarkdown(artifact, headingLevel); } if (isCodingAgentsArtifact(artifact)) { - return codingAgentsToMarkdown(artifact); + return codingAgentsToMarkdown(artifact, headingLevel); } return null; // unknown artifact } function rootCauseArtifactToMarkdown( - artifact: Artifact + artifact: Artifact, + headingLevel: number ): string | null { const rootCause = artifact.data; if (!defined(rootCause)) { return null; } - const parts: string[] = ['# Root Cause', '', rootCause.one_line_description]; + const h1 = '#'.repeat(headingLevel); + const h2 = '#'.repeat(headingLevel + 1); + + const parts: string[] = [`${h1} Root Cause`, '', rootCause.one_line_description]; if (rootCause.five_whys.length) { parts.push( '', - '## Why did this happen?', + `${h2} Why did this happen?`, '', ...rootCause.five_whys.map(why => `- ${why}`) ); @@ -63,7 +70,7 @@ function rootCauseArtifactToMarkdown( if (rootCause.reproduction_steps?.length) { parts.push( '', - '## Reproduction Steps', + `${h2} Reproduction Steps`, '', ...rootCause.reproduction_steps.map((step, index) => `${index + 1}. ${step}`) ); @@ -72,21 +79,28 @@ function rootCauseArtifactToMarkdown( return parts.join('\n'); } -function solutionArtifactToMarkdown(artifact: Artifact): string | null { +function solutionArtifactToMarkdown( + artifact: Artifact, + headingLevel: number +): string | null { const solution = artifact.data; if (!defined(solution)) { return null; } - const parts: string[] = ['# Plan', '', solution.one_line_summary]; + const h1 = '#'.repeat(headingLevel); + const h2 = '#'.repeat(headingLevel + 1); + const h3 = '#'.repeat(headingLevel + 2); + + const parts: string[] = [`${h1} Plan`, '', solution.one_line_summary]; if (solution.steps.length) { parts.push( '', - '## Steps to Resolve', + `${h2} Steps to Resolve`, '', ...solution.steps.flatMap((step, index) => [ - `### ${index + 1}. ${step.title}`, + `${h3} ${index + 1}. ${step.title}`, step.description, ]) ); @@ -95,17 +109,23 @@ function solutionArtifactToMarkdown(artifact: Artifact): strin return parts.join('\n'); } -function filePatchesToMarkdown(artifact: ExplorerFilePatch[]): string | null { +function filePatchesToMarkdown( + artifact: ExplorerFilePatch[], + headingLevel: number +): string | null { if (!artifact.length) { return null; } - const parts: string[] = ['# Code Changes']; + const h1 = '#'.repeat(headingLevel); + const h2 = '#'.repeat(headingLevel + 1); + + const parts: string[] = [`${h1} Code Changes`]; parts.push( ...artifact.flatMap(filePatch => [ '', - `## Repository: ${filePatch.repo_name}`, + `${h2} Repository: ${filePatch.repo_name}`, '', '```diff', filePatch.diff, @@ -116,12 +136,17 @@ function filePatchesToMarkdown(artifact: ExplorerFilePatch[]): string | null { return parts.join('\n'); } -function repoPRStatesToMarkdown(artifact: RepoPRState[]): string | null { +function repoPRStatesToMarkdown( + artifact: RepoPRState[], + headingLevel: number +): string | null { if (!artifact.length) { return null; } - const parts: string[] = ['# Pull Requests', '']; + const h1 = '#'.repeat(headingLevel); + + const parts: string[] = [`${h1} Pull Requests`, '']; parts.push( ...artifact @@ -137,12 +162,18 @@ function repoPRStatesToMarkdown(artifact: RepoPRState[]): string | null { return parts.join('\n'); } -function codingAgentsToMarkdown(artifact: ExplorerCodingAgentState[]): string | null { +function codingAgentsToMarkdown( + artifact: ExplorerCodingAgentState[], + headingLevel: number +): string | null { if (!artifact.length) { return null; } - const parts: string[] = ['# Coding Agents', '']; + const h1 = '#'.repeat(headingLevel); + const h2 = '#'.repeat(headingLevel + 1); + + const parts: string[] = [`${h1} Coding Agents`, '']; parts.push( ...artifact @@ -152,7 +183,7 @@ function codingAgentsToMarkdown(artifact: ExplorerCodingAgentState[]): string | } return [ - `## ${getCodingAgentName(codingAgent.provider)}`, + `${h2} ${getCodingAgentName(codingAgent.provider)}`, '', `[${codingAgent.name}](${codingAgent.agent_url})`, ]; diff --git a/static/app/components/modals/dataWidgetViewerModal.tsx b/static/app/components/modals/dataWidgetViewerModal.tsx index 41079fcb99f423..520f6ab2681198 100644 --- a/static/app/components/modals/dataWidgetViewerModal.tsx +++ b/static/app/components/modals/dataWidgetViewerModal.tsx @@ -11,7 +11,7 @@ import moment from 'moment-timezone'; import {Alert} from '@sentry/scraps/alert'; import {Button, LinkButton} from '@sentry/scraps/button'; -import {Flex, Grid, Stack, Container} from '@sentry/scraps/layout'; +import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout'; import {Pagination} from '@sentry/scraps/pagination'; import {Select, SelectOption} from '@sentry/scraps/select'; import {Tooltip} from '@sentry/scraps/tooltip'; @@ -79,7 +79,6 @@ import { dashboardFiltersToString, eventViewFromWidget, getFieldsFromEquations, - getNumEquations, getWidgetDiscoverUrl, getWidgetIssueUrl, getWidgetReleasesUrl, @@ -306,7 +305,6 @@ function DataWidgetViewerModal(props: Props) { }; const {aggregates, columns} = tableWidget.queries[0]!; const {orderby} = widget.queries[0]!; - const order = orderby.startsWith('-'); const rawOrderby = trimStart(orderby, '-'); const fields = @@ -342,14 +340,6 @@ function DataWidgetViewerModal(props: Props) { } } - // Need to set the orderby of the eventsv2 query to equation[index] format - // since eventsv2 does not accept the raw equation as a valid sort payload - if (isEquation(rawOrderby) && tableWidget.queries[0]!.orderby === orderby) { - tableWidget.queries[0]!.orderby = `${order ? '-' : ''}equation[${ - getNumEquations(fields) - 1 - }]`; - } - // Default table columns for visualizations that don't have a group by set const hasGroupBy = (widget.queries[0]?.columns.length ?? 0) > 0; const shouldReplaceTableColumns = @@ -1075,8 +1065,8 @@ function ViewerTableV2({ datasetConfig?.getFieldHeaderMap?.(tableWidget.queries[selectedQueryIndex]) ?? {} ); - // Inject any prettified function names that aren't currently aliased into the aliases for (const column of tableColumns) { + // Inject any prettified function names that aren't currently aliased into the aliases const parsedFunction = parseFunction(column.key); if (!aliases[column.key] && parsedFunction) { aliases[column.key] = prettifyParsedFunction(parsedFunction); diff --git a/static/app/utils/analytics/dashboardsAnalyticsEvents.tsx b/static/app/utils/analytics/dashboardsAnalyticsEvents.tsx index 240790dfea562f..4ba171db6081a4 100644 --- a/static/app/utils/analytics/dashboardsAnalyticsEvents.tsx +++ b/static/app/utils/analytics/dashboardsAnalyticsEvents.tsx @@ -9,6 +9,7 @@ export enum WidgetBuilderVersion { type DashboardsEventParametersWidgetBuilder = { 'dashboards_views.engagement.load': { globalFilterCount: number; + isSentryBuilt: boolean; issuesRatio: number; logRatio: number; metricsRatio: number; diff --git a/static/app/utils/theme/theme.tsx b/static/app/utils/theme/theme.tsx index 936707fe074d86..960548ef21a727 100644 --- a/static/app/utils/theme/theme.tsx +++ b/static/app/utils/theme/theme.tsx @@ -196,7 +196,6 @@ const commonTheme = { truncationFullValue: 10, header: 1000, - dropdown: 1001, // dashboard widget builder backdrop sits behind the sidebar // because it renders on the right next to the sidebar @@ -204,6 +203,7 @@ const commonTheme = { widgetBuilderDrawer: 1016, sidebarPanel: 1019, + dropdown: 1020, sidebar: 1020, // Sentry user feedback modal diff --git a/static/app/utils/useChartInterval.spec.tsx b/static/app/utils/useChartInterval.spec.tsx index 4d84c6de26f188..2849ac53aa2268 100644 --- a/static/app/utils/useChartInterval.spec.tsx +++ b/static/app/utils/useChartInterval.spec.tsx @@ -3,7 +3,11 @@ import {act, render} from 'sentry-test/reactTestingLibrary'; import {PageFiltersStore} from 'sentry/components/pageFilters/store'; import {parsePeriodToHours} from 'sentry/utils/duration/parsePeriodToHours'; -import {getIntervalOptionsForPageFilter, useChartInterval} from './useChartInterval'; +import { + ChartIntervalUnspecifiedStrategy, + getIntervalOptionsForPageFilter, + useChartInterval, +} from './useChartInterval'; describe('useChartInterval', () => { beforeEach(() => { @@ -53,6 +57,83 @@ describe('useChartInterval', () => { }); expect(chartInterval).toBe('5m'); }); + + it('defaults to the smallest interval with USE_SMALLEST strategy', () => { + // Default 14d period produces ladder-derived options ['1h', '3h', '6h'] + let chartInterval!: ReturnType[0]; + + function TestPage() { + [chartInterval] = useChartInterval({ + unspecifiedStrategy: ChartIntervalUnspecifiedStrategy.USE_SMALLEST, + }); + return null; + } + + render(); + expect(chartInterval).toBe('1h'); + }); + + it('defaults to the largest ladder-derived interval with USE_BIGGEST strategy', () => { + // Default 14d period produces ladder-derived options ['1h', '3h', '6h']. + // The '1d' option is appended after the default is computed, so it is not + // considered when selecting the biggest default. + let chartInterval!: ReturnType[0]; + let intervalOptions!: ReturnType[2]; + + function TestPage() { + [chartInterval, , intervalOptions] = useChartInterval({ + unspecifiedStrategy: ChartIntervalUnspecifiedStrategy.USE_BIGGEST, + }); + return null; + } + + render(); + expect(chartInterval).toBe('6h'); + // '1d' is still present as a selectable option even though it was not the default + expect(intervalOptions.map(o => o.value)).toContain('1d'); + }); + + it('defaults to the second-largest interval with USE_SECOND_BIGGEST strategy', () => { + // Default 14d period produces ladder-derived options ['1h', '3h', '6h'], + // so the second-biggest is '3h'. + let chartInterval!: ReturnType[0]; + + function TestPage() { + [chartInterval] = useChartInterval({ + unspecifiedStrategy: ChartIntervalUnspecifiedStrategy.USE_SECOND_BIGGEST, + }); + return null; + } + + render(); + expect(chartInterval).toBe('3h'); + }); + + it('falls back to the only option when USE_SECOND_BIGGEST is used with a single-option period', () => { + // A 1-minute period produces only ['1m'] as the valid interval option. + // options[length-2] is undefined, so the fallback is options[length-1] = '1m'. + let chartInterval!: ReturnType[0]; + + function TestPage() { + [chartInterval] = useChartInterval({ + unspecifiedStrategy: ChartIntervalUnspecifiedStrategy.USE_SECOND_BIGGEST, + }); + return null; + } + + render(); + + act(() => + PageFiltersStore.updateDateTime({ + period: '1m', + start: null, + end: null, + utc: true, + }) + ); + + expect(chartInterval).toBe('1m'); + }); }); describe('getIntervalOptionsForPageFilter', () => { diff --git a/static/app/utils/useChartInterval.tsx b/static/app/utils/useChartInterval.tsx index 743564809f02bb..9ebb5fe5e5d83c 100644 --- a/static/app/utils/useChartInterval.tsx +++ b/static/app/utils/useChartInterval.tsx @@ -26,6 +26,8 @@ export enum ChartIntervalUnspecifiedStrategy { USE_SECOND_BIGGEST = 'use_second_biggest', /** Use the smallest possible interval (e.g., the smallest possible buckets) */ USE_SMALLEST = 'use_smallest', + /** Use the biggest possible interval (e.g., the biggest possible buckets) */ + USE_BIGGEST = 'use_biggest', } interface Options { @@ -71,10 +73,20 @@ function useChartIntervalImpl({ const options = getIntervalOptionsForPageFilter(datetime); // Compute the default from the ladder-derived options, before appending extras - const fallback = - unspecifiedStrategy === ChartIntervalUnspecifiedStrategy.USE_SMALLEST - ? options[0]!.value - : (options[options.length - 2]?.value ?? options[options.length - 1]!.value); + let fallback: string; + switch (unspecifiedStrategy) { + case ChartIntervalUnspecifiedStrategy.USE_SMALLEST: + fallback = options[0]!.value; + break; + case ChartIntervalUnspecifiedStrategy.USE_BIGGEST: + fallback = options[options.length - 1]!.value; + break; + case ChartIntervalUnspecifiedStrategy.USE_SECOND_BIGGEST: + default: + fallback = + options[options.length - 2]?.value ?? options[options.length - 1]!.value; + break; + } if (diffInMinutes >= MINIMUM_DURATION_FOR_ONE_DAY_INTERVAL) { options.push(ONE_DAY_OPTION); diff --git a/static/app/views/dashboards/dashboard.tsx b/static/app/views/dashboards/dashboard.tsx index e5511bc1654cba..cb86a27c79aa4d 100644 --- a/static/app/views/dashboards/dashboard.tsx +++ b/static/app/views/dashboards/dashboard.tsx @@ -203,7 +203,8 @@ function DashboardInner({ dashboard.widgets, organization, dashboard.title, - dashboard.filters?.[DashboardFilterKeys.GLOBAL_FILTER]?.length ?? 0 + dashboard.filters?.[DashboardFilterKeys.GLOBAL_FILTER]?.length ?? 0, + dashboard.prebuiltId !== undefined && dashboard.prebuiltId !== null ); return () => { diff --git a/static/app/views/dashboards/datasetConfig/traceMetrics.spec.tsx b/static/app/views/dashboards/datasetConfig/traceMetrics.spec.tsx index 13df2a15b087ce..1b6aaec654e31b 100644 --- a/static/app/views/dashboards/datasetConfig/traceMetrics.spec.tsx +++ b/static/app/views/dashboards/datasetConfig/traceMetrics.spec.tsx @@ -698,6 +698,59 @@ describe('TraceMetricsConfig', () => { }); }); + describe('getTableSortOptions', () => { + it('returns equation aliases with ƒ labels', () => { + const widgetQuery: WidgetQuery = { + name: '', + fields: [ + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + columns: [], + fieldAliases: [], + aggregates: [ + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + conditions: '', + orderby: '', + }; + + const options = TraceMetricsConfig.getTableSortOptions!(organization, widgetQuery); + + expect(options).toEqual( + expect.arrayContaining([ + expect.objectContaining({value: 'equation[0]', label: 'ƒ1'}), + ]) + ); + }); + + it('returns regular aggregates alongside equation aliases', () => { + const widgetQuery: WidgetQuery = { + name: '', + fields: [ + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + columns: [], + fieldAliases: [], + aggregates: [ + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + conditions: '', + orderby: '', + }; + + const options = TraceMetricsConfig.getTableSortOptions!(organization, widgetQuery); + + const labels = options.map(o => o.label); + expect(labels).toHaveLength(2); + expect(labels[0]).toBe('avg(test_metric)'); + expect(labels[1]).toBe('ƒ1'); + }); + }); + describe('TraceMetricsSearchBar', () => { const SearchBar = TraceMetricsConfig.SearchBar; diff --git a/static/app/views/dashboards/datasetConfig/traceMetrics.tsx b/static/app/views/dashboards/datasetConfig/traceMetrics.tsx index 0f15eed12dc544..328e8e82f44e93 100644 --- a/static/app/views/dashboards/datasetConfig/traceMetrics.tsx +++ b/static/app/views/dashboards/datasetConfig/traceMetrics.tsx @@ -8,8 +8,11 @@ import type {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/ import type {EventsTableData} from 'sentry/utils/discover/discoverQuery'; import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers'; import { + getEquationAliasIndex, + isEquationAlias, parseFunction, RateUnit, + stripEquationPrefix, type AggregationOutputType, type DataUnit, type QueryFieldValue, @@ -264,10 +267,19 @@ export const TraceMetricsConfig: DatasetConfig< // We've forced the sort options to use the table sort options UI because // we only want to allow sorting by selected aggregates. getTableSortOptions: (organization, widgetQuery) => - getTableSortOptions(organization, widgetQuery).map(option => ({ - label: formatTraceMetricsFunction(option.value, option.label), - value: option.value, - })), + getTableSortOptions(organization, widgetQuery).map(({value, label}) => { + if (isEquationAlias(value)) { + return { + label: `ƒ${getEquationAliasIndex(value) + 1}`, + value, + }; + } + + return { + label, + value, + }; + }), getGroupByFieldOptions, supportedDisplayTypes: [ DisplayType.AREA, @@ -318,7 +330,9 @@ export const TraceMetricsConfig: DatasetConfig< getFieldHeaderMap: widgetQuery => { return ( widgetQuery?.aggregates.reduce>((acc, aggregate) => { - acc[aggregate] = formatTraceMetricsFunction(aggregate) as string; + acc[aggregate] = stripEquationPrefix( + formatTraceMetricsFunction(aggregate) as string + ); return acc; }, {}) ?? {} ); diff --git a/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.spec.tsx b/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.spec.tsx index d65d9bc1fafd85..aa820d9fe38596 100644 --- a/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.spec.tsx +++ b/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.spec.tsx @@ -188,6 +188,140 @@ describe('utils', () => { expect(requestData.field).not.toContain('count_unique_user'); }); + describe('equation orderby handling for trace metrics', () => { + it('passes full equation orderby through and adds it to fields for TRACEMETRICS', () => { + const widget = WidgetFixture({ + displayType: DisplayType.LINE, + queries: [ + WidgetQueryFixture({ + fields: [ + 'project', + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + aggregates: ['avg(value,test_metric,millisecond,none)'], + columns: ['project'], + orderby: '-equation|avg(value,test_metric,millisecond,none) / 2', + }), + ], + }); + const pageFilters = PageFiltersFixture(); + const organization = OrganizationFixture(); + + const requestData = getSeriesRequestData( + widget, + 0, + organization, + pageFilters, + DiscoverDatasets.TRACEMETRICS, + 'test-referrer' + ); + + expect(requestData.orderby).toBe( + '-equation|avg(value,test_metric,millisecond,none) / 2' + ); + expect(requestData.field).toContain( + 'equation|avg(value,test_metric,millisecond,none) / 2' + ); + }); + + it('resolves equation alias orderby to full equation form for TRACEMETRICS', () => { + const widget = WidgetFixture({ + displayType: DisplayType.LINE, + queries: [ + WidgetQueryFixture({ + fields: [ + 'project', + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + aggregates: ['avg(value,test_metric,millisecond,none)'], + columns: ['project'], + orderby: '-equation[0]', + }), + ], + }); + const pageFilters = PageFiltersFixture(); + const organization = OrganizationFixture(); + + const requestData = getSeriesRequestData( + widget, + 0, + organization, + pageFilters, + DiscoverDatasets.TRACEMETRICS, + 'test-referrer' + ); + + expect(requestData.orderby).toBe( + '-equation|avg(value,test_metric,millisecond,none) / 2' + ); + }); + + it('resolves ascending equation alias orderby for TRACEMETRICS', () => { + const widget = WidgetFixture({ + displayType: DisplayType.LINE, + queries: [ + WidgetQueryFixture({ + fields: [ + 'project', + 'avg(value,test_metric,millisecond,none)', + 'equation|avg(value,test_metric,millisecond,none) / 2', + ], + aggregates: ['avg(value,test_metric,millisecond,none)'], + columns: ['project'], + orderby: 'equation[0]', + }), + ], + }); + const pageFilters = PageFiltersFixture(); + const organization = OrganizationFixture(); + + const requestData = getSeriesRequestData( + widget, + 0, + organization, + pageFilters, + DiscoverDatasets.TRACEMETRICS, + 'test-referrer' + ); + + expect(requestData.orderby).toBe( + 'equation|avg(value,test_metric,millisecond,none) / 2' + ); + }); + + it('converts full equation orderby to alias format for non-EAP datasets', () => { + const widget = WidgetFixture({ + displayType: DisplayType.LINE, + queries: [ + WidgetQueryFixture({ + fields: ['project', 'count()', 'equation|count() / 2'], + aggregates: ['count()'], + columns: ['project'], + orderby: '-equation|count() / 2', + }), + ], + }); + const pageFilters = PageFiltersFixture(); + const organization = OrganizationFixture(); + + const requestData = getSeriesRequestData( + widget, + 0, + organization, + pageFilters, + DiscoverDatasets.ERRORS, + 'test-referrer' + ); + + // Non-EAP datasets convert to equation[N] alias format + // The index is based on the number of equations in aggregates (0 here) + expect(requestData.orderby).toBe('-equation[0]'); + expect(requestData.field).toContain('equation|count() / 2'); + }); + }); + it('adds the orderby to fields if it is not in fields, columns, or aggregates', () => { const widget = WidgetFixture({ displayType: DisplayType.LINE, diff --git a/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.tsx b/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.tsx index 68905561156aa0..311c01de725ffe 100644 --- a/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.tsx +++ b/static/app/views/dashboards/datasetConfig/utils/getSeriesRequestData.tsx @@ -95,7 +95,13 @@ export function getSeriesRequestData( requestData.excludeOther = widgetQuery.aggregates.length !== 1 || widget.queries.length !== 1; - if ([DiscoverDatasets.OURLOGS, DiscoverDatasets.SPANS].includes(dataset)) { + if ( + [ + DiscoverDatasets.OURLOGS, + DiscoverDatasets.SPANS, + DiscoverDatasets.TRACEMETRICS, + ].includes(dataset) + ) { if ( isEquation(trimStart(widgetQuery.orderby, '-')) && !requestData.field?.includes(trimStart(widgetQuery.orderby, '-')) diff --git a/static/app/views/dashboards/utils/getWidgetMetricsUrl.spec.tsx b/static/app/views/dashboards/utils/getWidgetMetricsUrl.spec.tsx index dd863d8e9f2b85..f694939ddda362 100644 --- a/static/app/views/dashboards/utils/getWidgetMetricsUrl.spec.tsx +++ b/static/app/views/dashboards/utils/getWidgetMetricsUrl.spec.tsx @@ -475,6 +475,194 @@ describe('getWidgetMetricsUrl', () => { expect(metricQuery.query).toBe(''); }); + describe('equations', () => { + it('parses equation into sub-component metric queries and equation row', () => { + const widget: Widget = { + id: '1', + title: 'Equation Widget', + displayType: DisplayType.LINE, + interval: '5m', + widgetType: WidgetType.TRACEMETRICS, + queries: [ + { + name: 'Query 1', + fields: [ + 'equation|avg(value,duration,distribution,none) + count(value,requests,counter,none)', + 'transaction', + ], + aggregates: [ + 'equation|avg(value,duration,distribution,none) + count(value,requests,counter,none)', + ], + columns: ['transaction'], + conditions: 'transaction:"/api/users"', + orderby: '', + fieldAliases: [], + }, + ], + }; + + const url = getWidgetMetricsUrl(widget, undefined, selection, organization); + const {params} = parseMetricsUrl(url); + + expect(params.metric).toBeDefined(); + const metrics = Array.isArray(params.metric) ? params.metric : [params.metric]; + // 2 sub-component queries + 1 equation row + expect(metrics).toHaveLength(3); + + const parsedMetrics = metrics.map(metric => JSON.parse(metric!)); + + expect(parsedMetrics[0].metric).toEqual({ + name: 'duration', + type: 'distribution', + unit: 'none', + }); + expect(parsedMetrics[0].aggregateFields[0].yAxes[0]).toBe( + 'avg(value,duration,distribution,none)' + ); + + expect(parsedMetrics[1].metric).toEqual({ + name: 'requests', + type: 'counter', + unit: 'none', + }); + expect(parsedMetrics[1].aggregateFields).toHaveLength(1); + expect(parsedMetrics[1].aggregateFields[0].groupBy).toBeUndefined(); + expect(parsedMetrics[1].aggregateFields[0].yAxes[0]).toBe( + 'count(value,requests,counter,none)' + ); + + // 2 fields, one for the equation and one for the group by + expect(parsedMetrics[2].aggregateFields).toHaveLength(2); + expect(parsedMetrics[2].aggregateFields[0].yAxes[0]).toBe( + 'equation|avg(value,duration,distribution,none) + count(value,requests,counter,none)' + ); + expect(parsedMetrics[2].aggregateFields[1].groupBy).toBe('transaction'); + expect(parsedMetrics[2].query).toContain('transaction:"/api/users"'); + }); + + it('applies dashboard filters to equation query', () => { + const widget: Widget = { + id: '1', + title: 'Filtered Equation', + displayType: DisplayType.LINE, + interval: '5m', + widgetType: WidgetType.TRACEMETRICS, + queries: [ + { + name: 'Query 1', + fields: [], + aggregates: [ + 'equation|avg(value,duration,distribution,none) + count(value,duration,distribution,none)', + ], + columns: [], + conditions: '', + orderby: '', + fieldAliases: [], + }, + ], + }; + + const dashboardFilters: DashboardFilters = { + release: ['v1.0.0'], + }; + + const url = getWidgetMetricsUrl(widget, dashboardFilters, selection, organization); + const {params} = parseMetricsUrl(url); + + const metrics = Array.isArray(params.metric) ? params.metric : [params.metric]; + const parsedMetrics = metrics.map(metric => JSON.parse(metric!)); + + // The equation row should have dashboard filters applied + const equationRow = parsedMetrics[parsedMetrics.length - 1]; + expect(equationRow.query).toContain('release'); + expect(equationRow.query).toContain('v1.0.0'); + }); + + it('handles equation with duplicate function calls', () => { + const widget: Widget = { + id: '1', + title: 'Duplicate Funcs', + displayType: DisplayType.LINE, + interval: '5m', + widgetType: WidgetType.TRACEMETRICS, + queries: [ + { + name: 'Query 1', + fields: [], + aggregates: [ + 'equation|avg(value,duration,distribution,none) + avg(value,duration,distribution,none)', + ], + columns: [], + conditions: '', + orderby: '', + fieldAliases: [], + }, + ], + }; + + const url = getWidgetMetricsUrl(widget, undefined, selection, organization); + const {params} = parseMetricsUrl(url); + + const metrics = Array.isArray(params.metric) ? params.metric : [params.metric]; + // 1 unique sub-component + 1 equation row (duplicates are collapsed) + expect(metrics).toHaveLength(2); + }); + + it('handles equations with conditional subcomponents', () => { + const widget: Widget = { + id: '1', + title: 'Conditional Equation', + displayType: DisplayType.LINE, + interval: '5m', + widgetType: WidgetType.TRACEMETRICS, + queries: [ + { + name: 'Query 1', + fields: [], + aggregates: [ + 'equation|avg_if(`environment:prod`,value,duration,distribution,none) + count(value,duration,distribution,none)', + ], + columns: [], + conditions: '', + orderby: '', + fieldAliases: [], + }, + ], + }; + + const url = getWidgetMetricsUrl(widget, undefined, selection, organization); + const {params} = parseMetricsUrl(url); + + const metrics = Array.isArray(params.metric) ? params.metric : [params.metric]; + const parsedMetrics = metrics.map(metric => JSON.parse(metric!)); + + expect(parsedMetrics).toHaveLength(3); + + // First subcomponent is normalized from avg_if to avg with a filter query + expect(parsedMetrics[0].metric).toEqual({ + name: 'duration', + type: 'distribution', + unit: 'none', + }); + expect(parsedMetrics[0].query).toContain('environment:prod'); + expect(parsedMetrics[0].aggregateFields[0].yAxes[0]).toBe( + 'avg(value,duration,distribution,none)' + ); + expect(parsedMetrics[1].metric).toEqual({ + name: 'duration', + type: 'distribution', + unit: 'none', + }); + expect(parsedMetrics[1].query).toBe(''); + expect(parsedMetrics[1].aggregateFields[0].yAxes[0]).toBe( + 'count(value,duration,distribution,none)' + ); + expect(parsedMetrics[2].aggregateFields[0].yAxes[0]).toBe( + 'equation|avg_if(`environment:prod`,value,duration,distribution,none) + count(value,duration,distribution,none)' + ); + }); + }); + describe('datetime selection', () => { it('includes absolute datetime when start and end are provided', () => { const absoluteSelection: PageFilters = { diff --git a/static/app/views/dashboards/utils/getWidgetMetricsUrl.tsx b/static/app/views/dashboards/utils/getWidgetMetricsUrl.tsx index d550e41473e453..cab94c1d388655 100644 --- a/static/app/views/dashboards/utils/getWidgetMetricsUrl.tsx +++ b/static/app/views/dashboards/utils/getWidgetMetricsUrl.tsx @@ -1,18 +1,23 @@ import type {PageFilters} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; import {defined} from 'sentry/utils'; -import {explodeFieldString} from 'sentry/utils/discover/fields'; +import {explodeFieldString, isEquation} from 'sentry/utils/discover/fields'; import {decodeSorts} from 'sentry/utils/queryString'; import type {DashboardFilters, Widget} from 'sentry/views/dashboards/types'; import {DisplayType} from 'sentry/views/dashboards/types'; import {applyDashboardFilters} from 'sentry/views/dashboards/utils'; import {extractTraceMetricFromColumn} from 'sentry/views/dashboards/widgetBuilder/utils/buildTraceMetricAggregate'; import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode'; +import type {BaseMetricQuery} from 'sentry/views/explore/metrics/metricQuery'; +import {parseAggregateExpression} from 'sentry/views/explore/metrics/parseAggregateExpression'; import {getMetricsUrl, makeMetricsPathname} from 'sentry/views/explore/metrics/utils'; import type {AggregateField} from 'sentry/views/explore/queryParams/aggregateField'; import type {GroupBy} from 'sentry/views/explore/queryParams/groupBy'; import {ReadableQueryParams} from 'sentry/views/explore/queryParams/readableQueryParams'; -import {VisualizeFunction} from 'sentry/views/explore/queryParams/visualize'; +import { + VisualizeEquation, + VisualizeFunction, +} from 'sentry/views/explore/queryParams/visualize'; import {ChartType} from 'sentry/views/insights/common/components/chart'; /** @@ -33,7 +38,37 @@ export function getWidgetMetricsUrl( const metricQueries = widget.queries[0].aggregates .flatMap(aggregate => { - // For each aggregate, create a metric query for each widget query + if (isEquation(aggregate)) { + // Use flatMap because of the queries type, but for an equation we will only have one + // true query. The other metric queries filters are parsed out from the equation string. + return widget.queries.flatMap(query => { + const groupByFields: GroupBy[] = query.columns.map( + (col): GroupBy => ({groupBy: col}) + ); + const queryString = + applyDashboardFilters( + query.conditions, + dashboardFilters, + widget.widgetType + ) ?? ''; + + const parsed = parseAggregateExpression(aggregate, queryString); + const results: BaseMetricQuery[] = [...parsed.metricQueries]; + if (parsed.equationRow) { + results.push({ + ...parsed.equationRow, + queryParams: parsed.equationRow.queryParams.replace({ + aggregateFields: [ + new VisualizeEquation(aggregate, {chartType}), + ...groupByFields, + ], + }), + }); + } + return results; + }); + } + return widget.queries.map(query => { const queryString = applyDashboardFilters(query.conditions, dashboardFilters, widget.widgetType) ?? diff --git a/static/app/views/dashboards/widgetBuilder/utils/trackEngagementAnalytics.tsx b/static/app/views/dashboards/widgetBuilder/utils/trackEngagementAnalytics.tsx index b6ede32662b8c2..9e6b380922f6c9 100644 --- a/static/app/views/dashboards/widgetBuilder/utils/trackEngagementAnalytics.tsx +++ b/static/app/views/dashboards/widgetBuilder/utils/trackEngagementAnalytics.tsx @@ -6,7 +6,8 @@ export function trackEngagementAnalytics( widgets: Widget[], organization: Organization, dashboardTitle: string, - globalFilterCount: number + globalFilterCount: number, + isSentryBuilt: boolean ) { // Handle edge-case of dashboard with no widgets. if (!widgets.length) return; @@ -48,6 +49,7 @@ export function trackEngagementAnalytics( logRatio: logWidgetCount / widgets.length, metricsRatio: metricsWidgetCount / widgets.length, globalFilterCount, + isSentryBuilt, }; trackAnalytics('dashboards_views.engagement.load', analyticsPayload); } diff --git a/static/app/views/dashboards/widgetCard/hooks/useTraceMetricsWidgetQuery.tsx b/static/app/views/dashboards/widgetCard/hooks/useTraceMetricsWidgetQuery.tsx index 2776f50b63fd30..d7c668c86ff6fe 100644 --- a/static/app/views/dashboards/widgetCard/hooks/useTraceMetricsWidgetQuery.tsx +++ b/static/app/views/dashboards/widgetCard/hooks/useTraceMetricsWidgetQuery.tsx @@ -7,9 +7,16 @@ import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; import {toArray} from 'sentry/utils/array/toArray'; import {getUtcDateString} from 'sentry/utils/dates'; import type {EventsTableData} from 'sentry/utils/discover/discoverQuery'; -import type {AggregationOutputType, DataUnit} from 'sentry/utils/discover/fields'; +import { + getEquationAliasIndex, + isEquation, + isEquationAlias, + type AggregationOutputType, + type DataUnit, +} from 'sentry/utils/discover/fields'; import type {DiscoverQueryRequestParams} from 'sentry/utils/discover/genericDiscoverQuery'; import {DiscoverDatasets} from 'sentry/utils/discover/types'; +import {decodeSorts} from 'sentry/utils/queryString'; import {RequestError} from 'sentry/utils/requestError/requestError'; import {SERIES_QUERY_DELIMITER} from 'sentry/utils/timeSeries/transformLegacySeriesToTimeSeries'; import type {EventsTimeSeriesResponse} from 'sentry/utils/timeSeries/useFetchEventsTimeSeries'; @@ -280,7 +287,23 @@ export function useTraceMetricsTableQuery( }; if (query.orderby) { - requestParams.sort = toArray(query.orderby); + const baseSort = decodeSorts(query.orderby)[0]; + if (isEquationAlias(baseSort?.field ?? '')) { + const fields = query.fields ?? [...query.columns, ...query.aggregates]; + const equations = fields.filter(isEquation); + const equationIndex = getEquationAliasIndex(baseSort?.field ?? ''); + const equation = equations[equationIndex]; + if (equation) { + requestParams.sort = toArray( + baseSort?.kind === 'desc' ? `-${equation}` : equation + ); + } else { + // In case we failed to find an equation by its index, reset the sort + requestParams.sort = undefined; + } + } else { + requestParams.sort = toArray(query.orderby); + } } const queryParams = { diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index d337b70120ce11..ff08d9ce514ded 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -18,7 +18,10 @@ import {t} from 'sentry/locale'; import type {PageFilters} from 'sentry/types/core'; import type {DataUnit} from 'sentry/utils/discover/fields'; import {intervalToMilliseconds} from 'sentry/utils/duration/intervalToMilliseconds'; -import {useChartInterval} from 'sentry/utils/useChartInterval'; +import { + ChartIntervalUnspecifiedStrategy, + useChartInterval, +} from 'sentry/utils/useChartInterval'; import {useDimensions} from 'sentry/utils/useDimensions'; import {useOrganization} from 'sentry/utils/useOrganization'; import type {HeatMapSeries} from 'sentry/views/dashboards/widgets/common/types'; @@ -120,11 +123,17 @@ export function MetricPanel({ const aggregateSortBys = useQueryParamsAggregateSortBys(); const groupBys = useQueryParamsGroupBys(); const setGroupBys = useSetQueryParamsGroupBys(); - const [interval, setInterval, intervalOptions] = useChartInterval(); const topEvents = useTopEvents(); const visualize = useMetricVisualize(); const visualizes = useMetricVisualizes(); const setVisualizes = useSetMetricVisualizes(); + // use the biggest interval for the heat map as this produces better patterns + const [interval, setInterval, intervalOptions] = useChartInterval({ + unspecifiedStrategy: + visualize.chartType === ChartType.HEATMAP + ? ChartIntervalUnspecifiedStrategy.USE_BIGGEST + : ChartIntervalUnspecifiedStrategy.USE_SMALLEST, + }); const [title, setTitle] = useState(() => { if (isVisualizeEquation(visualize)) { diff --git a/static/app/views/explore/metrics/useSaveAsMetricItems.spec.tsx b/static/app/views/explore/metrics/useSaveAsMetricItems.spec.tsx index f937bcf4b98d29..abd86222fc9490 100644 --- a/static/app/views/explore/metrics/useSaveAsMetricItems.spec.tsx +++ b/static/app/views/explore/metrics/useSaveAsMetricItems.spec.tsx @@ -10,6 +10,7 @@ import * as modal from 'sentry/actionCreators/modal'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; +import * as discoverUtils from 'sentry/views/discover/utils'; import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode'; import {MockMetricQueryParamsContext} from 'sentry/views/explore/metrics/hooks/testUtils'; import {encodeMetricQueryParams} from 'sentry/views/explore/metrics/metricQuery'; @@ -24,10 +25,17 @@ import {OrganizationContext} from 'sentry/views/organizationContext'; jest.mock('sentry/utils/useLocation'); jest.mock('sentry/utils/useNavigate'); jest.mock('sentry/actionCreators/modal'); +jest.mock('sentry/views/discover/utils'); const mockedUseLocation = jest.mocked(useLocation); const mockUseNavigate = jest.mocked(useNavigate); const mockOpenSaveQueryModal = jest.mocked(modal.openSaveQueryModal); +const mockHandleAddQueryToDashboard = jest.mocked( + discoverUtils.handleAddQueryToDashboard +); +const mockHandleAddMultipleQueriesToDashboard = jest.mocked( + discoverUtils.handleAddMultipleQueriesToDashboard +); describe('useSaveAsMetricItems', () => { const organization = OrganizationFixture({ @@ -208,6 +216,170 @@ describe('useSaveAsMetricItems', () => { ); }); + it('disables equations in add-to-dashboard without the feature flag', () => { + const equation = + 'equation|sum(value,metric.a,counter,none) + avg(value,metric.a,counter,none)'; + const encodedMetricQuery = encodeMetricQueryParams({ + metric: {name: 'metric.a', type: 'counter'}, + queryParams: new ReadableQueryParams({ + extrapolate: true, + mode: Mode.AGGREGATE, + query: 'release:1.2.3', + aggregateCursor: '', + aggregateFields: [new VisualizeEquation(equation)], + aggregateSortBys: [{field: equation, kind: 'desc'}], + cursor: '', + fields: [], + sortBys: [], + }), + label: 'ƒ1', + }); + + mockedUseLocation.mockReturnValue( + LocationFixture({ + query: { + interval: '5m', + metric: [encodedMetricQuery], + }, + }) + ); + + const {result} = renderHook(useSaveAsMetricItems, { + wrapper: createWrapper(), + initialProps: {interval: '5m'}, + }); + + const addToDashboardItem = result.current.find( + item => item.key === 'add-to-dashboard' + ) as + | {children?: Array<{disabled: boolean; key: string; tooltip: string}>} + | undefined; + + const equationChild = addToDashboardItem?.children?.find( + item => item.key === 'add-to-dashboard-0' + ); + + expect(equationChild?.disabled).toBe(true); + expect(equationChild?.tooltip).toBe( + 'Equations cannot currently be added to a dashboard' + ); + }); + + it('enables equations in add-to-dashboard with the feature flag', () => { + const orgWithEquationsInDashboards = OrganizationFixture({ + features: [ + 'tracemetrics-enabled', + 'tracemetrics-equations-in-alerts', + 'tracemetrics-equations-in-dashboards', + ], + }); + + // Break the equation into its components to match how metric queries are encoded: + // sum, avg, and the final equation combining them. + const function1 = new VisualizeFunction('sum(value,metric.a,counter,none)'); + const function2 = new VisualizeFunction('avg(value,metric.a,counter,none)'); + const equation = `equation|${function1.yAxis} + ${function2.yAxis}`; + const equationObj = new VisualizeEquation(equation); + + const metricFunctions = [function1, function2, equationObj]; + const encodedMetricQueries = metricFunctions.map(fn => + encodeMetricQueryParams({ + metric: {name: 'metric.a', type: 'counter'}, + queryParams: new ReadableQueryParams({ + extrapolate: true, + mode: Mode.AGGREGATE, + query: 'release:1.2.3', + aggregateCursor: '', + aggregateFields: [fn], + aggregateSortBys: [{field: fn.yAxis, kind: 'desc'}], + cursor: '', + fields: [], + sortBys: [], + }), + }) + ); + + mockedUseLocation.mockReturnValue( + LocationFixture({ + query: { + interval: '5m', + metric: encodedMetricQueries, + }, + }) + ); + + function createWrapperWithEquationFlags() { + return function ({children}: {children?: React.ReactNode}) { + return ( + + + {children} + + + ); + }; + } + + const {result} = renderHook(useSaveAsMetricItems, { + wrapper: createWrapperWithEquationFlags(), + initialProps: { + interval: '5m', + }, + }); + + const addToDashboardItem = result.current.find( + item => item.key === 'add-to-dashboard' + ) as + | { + children?: Array<{ + disabled: boolean; + key: string; + label: string; + onAction: () => void; + tooltip: string | undefined; + }>; + } + | undefined; + + const equationChild = addToDashboardItem?.children?.find( + item => item.key === 'add-to-dashboard-2' + ); + + expect(equationChild?.label).toBe('ƒ1'); + expect(equationChild?.disabled).toBe(false); + expect(equationChild?.tooltip).toBeUndefined(); + + equationChild?.onAction?.(); + + expect(mockHandleAddQueryToDashboard).toHaveBeenCalledWith( + expect.objectContaining({ + eventView: expect.objectContaining({ + yAxis: equation, + }), + yAxis: equation, + }) + ); + + mockHandleAddQueryToDashboard.mockClear(); + mockHandleAddMultipleQueriesToDashboard.mockClear(); + + const addAllToDashboard = addToDashboardItem?.children?.find( + item => item.key === 'add-to-dashboard-all' + ); + + addAllToDashboard?.onAction?.(); + + expect(mockHandleAddMultipleQueriesToDashboard).toHaveBeenCalledWith( + expect.objectContaining({ + eventViews: expect.arrayContaining([ + expect.objectContaining({ + yAxis: equation, + }), + ]), + }) + ); + }); + it('formats alerts submenu labels for equations', () => { const equation = 'equation|sum(value,metric.a,counter,none) + avg(value,metric.a,counter,none)'; diff --git a/static/app/views/explore/metrics/useSaveAsMetricItems.tsx b/static/app/views/explore/metrics/useSaveAsMetricItems.tsx index bfb9a9187b81ee..180f23aa38421a 100644 --- a/static/app/views/explore/metrics/useSaveAsMetricItems.tsx +++ b/static/app/views/explore/metrics/useSaveAsMetricItems.tsx @@ -35,6 +35,7 @@ import {getAlertsUrl} from 'sentry/views/insights/common/utils/getAlertsUrl'; import { canUseMetricsAlertsUI, canUseMetricsEquationsInAlerts, + canUseMetricsEquationsInDashboards, canUseMetricsSavedQueriesUI, } from './metricsFlags'; @@ -54,6 +55,9 @@ export function useSaveAsMetricItems(options: UseSaveAsMetricItemsOptions) { const metricQueries = useMultiMetricsQueryParams(); const {addToDashboard} = useAddMetricToDashboard(); + const metricsEquationsInDashboardsEnabled = + canUseMetricsEquationsInDashboards(organization); + const project = projects.length === 1 ? projects[0] @@ -193,9 +197,14 @@ export function useSaveAsMetricItems(options: UseSaveAsMetricItemsOptions) { addToDashboard( metricQueries.filter( metricQuery => - !isVisualizeEquation(metricQuery.queryParams.visualizes[0]!) && - metricQuery.queryParams.visualizes[0]!.chartType !== - ChartType.HEATMAP + metricQuery.queryParams.visualizes[0]?.chartType !== + ChartType.HEATMAP && + // Allow all charts if you have the flag, otherwise only allow non-equation charts without the flag + (metricsEquationsInDashboardsEnabled || + (!metricsEquationsInDashboardsEnabled && + !isVisualizeEquation( + metricQuery.queryParams.visualizes[0]! + ))) ) ); }, @@ -205,7 +214,8 @@ export function useSaveAsMetricItems(options: UseSaveAsMetricItemsOptions) { ...metricQueries.map((metricQuery, index) => { const visualize = metricQuery.queryParams.visualizes[0]!; const isUnsupported = - isVisualizeEquation(visualize) || visualize.chartType === ChartType.HEATMAP; + (!metricsEquationsInDashboardsEnabled && isVisualizeEquation(visualize)) || + visualize.chartType === ChartType.HEATMAP; const label = isVisualizeFunction(visualize) ? `${metricQuery.label ?? getVisualizeLabel(index, isVisualizeEquation(visualize))}: ${ formatTraceMetricsFunction( @@ -222,20 +232,22 @@ export function useSaveAsMetricItems(options: UseSaveAsMetricItemsOptions) { if (isUnsupported) { return; } + // TODO: Handle sorting by equation better addToDashboard(metricQuery); }, disabled: isUnsupported, - tooltip: isVisualizeEquation(visualize) - ? t('Equations cannot currently be added to a dashboard') - : visualize.chartType === ChartType.HEATMAP - ? t('Heat maps cannot currently be added to a dashboard') - : undefined, + tooltip: + !metricsEquationsInDashboardsEnabled && isVisualizeEquation(visualize) + ? t('Equations cannot currently be added to a dashboard') + : visualize.chartType === ChartType.HEATMAP + ? t('Heat maps cannot currently be added to a dashboard') + : undefined, }; }), ], }, ]; - }, [addToDashboard, metricQueries]); + }, [addToDashboard, metricQueries, metricsEquationsInDashboardsEnabled]); return useMemo(() => { return [...saveAsItems, ...saveAsAlertItems, ...addToDashboardItems]; diff --git a/static/app/views/issueDetails/streamline/eventNavigation.tsx b/static/app/views/issueDetails/streamline/eventNavigation.tsx index cd49eb2447cdf1..601e6e159ff7b3 100644 --- a/static/app/views/issueDetails/streamline/eventNavigation.tsx +++ b/static/app/views/issueDetails/streamline/eventNavigation.tsx @@ -12,7 +12,7 @@ import {CopyAsDropdown} from 'sentry/components/copyAsDropdown'; import {Count} from 'sentry/components/count'; import {DropdownButton} from 'sentry/components/dropdownButton'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; -import {useAutofixData} from 'sentry/components/events/autofix/useAutofix'; +import {useExplorerAutofix} from 'sentry/components/events/autofix/useExplorerAutofix'; import {useGroupSummaryData} from 'sentry/components/group/groupSummary'; import {TourElement} from 'sentry/components/tours/components'; import {IconTelescope} from 'sentry/icons'; @@ -117,7 +117,7 @@ export function IssueEventNavigation({event, group}: IssueEventNavigationProps) // Get data for markdown copy functionality const {data: groupSummaryData} = useGroupSummaryData(group); - const {data: autofixData} = useAutofixData({groupId: group.id}); + const {runState: autofixData} = useExplorerAutofix(group.id, {enabled: false}); const handleCopyMarkdown = useCallback(() => { const markdownText = issueAndEventToMarkdown( diff --git a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx index 2bd3d13b7a0a16..dc7e69d152585a 100644 --- a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx +++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx @@ -5,12 +5,8 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {renderHook, userEvent} from 'sentry-test/reactTestingLibrary'; import * as indicators from 'sentry/actionCreators/indicator'; -import { - AutofixStatus, - AutofixStepType, - type AutofixData, -} from 'sentry/components/events/autofix/types'; -import * as autofixHooks from 'sentry/components/events/autofix/useAutofix'; +import type {ExplorerAutofixState} from 'sentry/components/events/autofix/useExplorerAutofix'; +import * as explorerAutofixHooks from 'sentry/components/events/autofix/useExplorerAutofix'; import type {GroupSummaryData} from 'sentry/components/group/groupSummary'; import * as groupSummaryHooks from 'sentry/components/group/groupSummary'; import {EntryType} from 'sentry/types/event'; @@ -39,46 +35,50 @@ describe('useCopyIssueDetails', () => { possibleCause: 'Missing parameter', }; - // Create a mock AutofixData with steps that includes root cause and solution steps - const mockAutofixData: AutofixData = { - last_triggered_at: '2023-01-01T00:00:00Z', - request: { - repos: [], - }, - codebases: {}, - run_id: '123', - status: AutofixStatus.COMPLETED, - steps: [ + const mockAutofixData: ExplorerAutofixState = { + run_id: 123, + status: 'completed', + updated_at: '2023-01-01T00:00:00Z', + blocks: [ { - id: 'root-cause-step', - index: 0, - progress: [], - status: AutofixStatus.COMPLETED, - title: 'Root Cause', - type: AutofixStepType.ROOT_CAUSE_ANALYSIS, - causes: [ + id: 'root-cause-block', + message: { + role: 'assistant' as const, + content: 'Found the root cause', + metadata: {step: 'root_cause'}, + }, + timestamp: '2023-01-01T00:00:00Z', + loading: false, + artifacts: [ { - id: 'cause-1', - description: 'Root cause text', + key: 'root_cause', + reason: 'Root cause analysis', + data: { + one_line_description: 'Root cause text', + five_whys: ['Why 1'], + }, }, ], - selection: null, }, { - id: 'solution-step', - index: 1, - progress: [], - status: AutofixStatus.COMPLETED, - title: 'Solution', - type: AutofixStepType.SOLUTION, - solution: [ + id: 'solution-block', + message: { + role: 'assistant' as const, + content: 'Here is the solution', + metadata: {step: 'solution'}, + }, + timestamp: '2023-01-01T00:00:01Z', + loading: false, + artifacts: [ { - timeline_item_type: 'internal_code', - title: 'Solution title', - code_snippet_and_analysis: 'Solution text', + key: 'solution', + reason: 'Solution plan', + data: { + one_line_summary: 'Solution title', + steps: [{title: 'Fix it', description: 'Solution text'}], + }, }, ], - solution_selected: true, }, ], }; @@ -124,7 +124,7 @@ describe('useCopyIssueDetails', () => { ); expect(result).toContain('## Root Cause'); - expect(result).toContain('## Solution'); + expect(result).toContain('## Plan'); }); it('includes tags when present in event', () => { @@ -379,10 +379,17 @@ describe('useCopyIssueDetails', () => { isPending: false, }); - jest.spyOn(autofixHooks, 'useAutofixData').mockReturnValue({ - data: mockAutofixData, - isPending: false, - }); + jest.spyOn(explorerAutofixHooks, 'useExplorerAutofix').mockReturnValue({ + runState: mockAutofixData, + isLoading: false, + isPolling: false, + startStep: jest.fn(), + createPR: jest.fn(), + reset: jest.fn(), + triggerCodingAgentHandoff: jest.fn(), + codingAgentErrors: [], + dismissCodingAgentError: jest.fn(), + } as any); jest.spyOn(indicators, 'addSuccessMessage').mockImplementation(() => {}); jest.spyOn(indicators, 'addErrorMessage').mockImplementation(() => {}); @@ -430,7 +437,7 @@ describe('useCopyIssueDetails', () => { expect(capturedText).toContain(`**Project:** ${group.project?.slug}`); expect(capturedText).toContain('## Issue Summary'); expect(capturedText).toContain('## Root Cause'); - expect(capturedText).toContain('## Solution'); + expect(capturedText).toContain('## Plan'); expect(capturedText).not.toContain('## Exception'); }); diff --git a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx index 85bd72e37344cd..bb636df57906cc 100644 --- a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx +++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx @@ -2,12 +2,15 @@ import {useCallback, useMemo, useSyncExternalStore} from 'react'; import {useHotkeys} from '@sentry/scraps/hotkey'; -import type {AutofixData} from 'sentry/components/events/autofix/types'; -import {useAutofixData} from 'sentry/components/events/autofix/useAutofix'; import { - getRootCauseCopyText, - getSolutionCopyText, -} from 'sentry/components/events/autofix/utils'; + type ExplorerAutofixState, + getAutofixArtifactFromSection, + getOrderedAutofixSections, + isRootCauseSection, + isSolutionSection, + useExplorerAutofix, +} from 'sentry/components/events/autofix/useExplorerAutofix'; +import {artifactToMarkdown} from 'sentry/components/events/autofix/v3/utils'; import { useGroupSummaryData, type GroupSummaryData, @@ -142,7 +145,7 @@ export const issueAndEventToMarkdown = ( group: Group, event: Event | null | undefined, groupSummaryData: GroupSummaryData | null | undefined, - autofixData: AutofixData | null | undefined, + autofixData: ExplorerAutofixState | null | undefined, activeThreadId: number | undefined ): string => { // Format the basic issue information @@ -169,14 +172,29 @@ export const issueAndEventToMarkdown = ( } if (autofixData) { - const rootCauseCopyText = getRootCauseCopyText(autofixData); - const solutionCopyText = getSolutionCopyText(autofixData); + const sections = getOrderedAutofixSections(autofixData); + const rootCauseSection = sections.find(isRootCauseSection); + const solutionSection = sections.find(isSolutionSection); + + const rootCauseArtifact = rootCauseSection + ? getAutofixArtifactFromSection(rootCauseSection) + : null; + const solutionArtifact = solutionSection + ? getAutofixArtifactFromSection(solutionSection) + : null; + + const rootCauseCopyText = rootCauseArtifact + ? artifactToMarkdown(rootCauseArtifact, 2) + : null; + const solutionCopyText = solutionArtifact + ? artifactToMarkdown(solutionArtifact, 2) + : null; if (rootCauseCopyText) { - markdownText += `\n## Root Cause\n\`\`\`\n${rootCauseCopyText}\n\`\`\`\n`; + markdownText += `\n${rootCauseCopyText}\n`; } if (solutionCopyText) { - markdownText += `\n## Solution\n\`\`\`\n${solutionCopyText}\n\`\`\`\n`; + markdownText += `\n${solutionCopyText}\n`; } } @@ -190,9 +208,8 @@ export const issueAndEventToMarkdown = ( export const useCopyIssueDetails = (group: Group, event?: Event) => { const organization = useOrganization(); - // These aren't guarded by useAiConfig because they are both non fetching, and should only return data when it's fetched elsewhere. const {data: groupSummaryData} = useGroupSummaryData(group); - const {data: autofixData} = useAutofixData({groupId: group.id}); + const {runState: autofixData} = useExplorerAutofix(group.id, {enabled: false}); const activeThreadId = useActiveThreadId(); const text = useMemo(() => { diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboard_widget_details.py b/tests/sentry/dashboards/endpoints/test_organization_dashboard_widget_details.py index 75ec4434eacb62..3a11f45f97f09a 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboard_widget_details.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboard_widget_details.py @@ -176,6 +176,30 @@ def test_invalid_display_type(self) -> None: assert response.status_code == 400, response.data assert "displayType" in response.data, response.data + def test_unsupported_display_type_for_widget_type(self) -> None: + data = { + "title": "Table on preprod-app-size", + "displayType": "table", + "widgetType": "preprod-app-size", + "queries": [ + { + "name": "", + "conditions": "", + "fields": ["count()"], + "columns": [], + "aggregates": ["count()"], + } + ], + } + response = self.do_request( + "post", + self.url(), + data=data, + ) + assert response.status_code == 400, response.data + assert "displayType" in response.data, response.data + assert "preprod-app-size" in str(response.data["displayType"]) + def test_invalid_equation(self) -> None: data = { "title": "Invalid query", @@ -1442,7 +1466,7 @@ def test_widget_type_tracemetrics(self) -> None: data = { "title": "Test Metrics Query", "widgetType": "tracemetrics", - "displayType": "table", + "displayType": "line", "queries": [ { "name": "", @@ -1461,6 +1485,30 @@ def test_widget_type_tracemetrics(self) -> None: ) assert response.status_code == 200, response.data + def test_widget_type_tracemetrics_rejects_table(self) -> None: + data = { + "title": "Test Metrics Query", + "widgetType": "tracemetrics", + "displayType": "table", + "queries": [ + { + "name": "", + "conditions": "metric.name:foo", + "fields": ["sum(value)"], + "columns": [], + "aggregates": ["sum(value)"], + }, + ], + } + + response = self.do_request( + "post", + self.url(), + data=data, + ) + assert response.status_code == 400, response.data + assert "displayType" in response.data, response.data + def test_text_widget_without_feature_flag(self) -> None: data = { "title": "Text Widget Title", diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboards.py b/tests/sentry/dashboards/endpoints/test_organization_dashboards.py index d5c51ef1449870..459d9096cdd162 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboards.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboards.py @@ -2169,6 +2169,92 @@ def test_post_with_text_widget(self) -> None: assert DashboardWidgetQuery.objects.filter(widget=text_widget).count() == 0 + def test_agents_traces_table_dashboard_save_and_update(self) -> None: + # Regression: the AI Agents Overview prebuilt config has an + # agents_traces_table widget without a widget_type. The backend defaults + # it to error-events on create. On the next PUT the frontend round-trips + # widget_type=error-events, which would otherwise fail validation. + data = { + "title": "AI Agents Overview", + "widgets": [ + { + "title": "Traces", + "displayType": "agents_traces_table", + "queries": [ + { + "name": "", + "fields": [], + "columns": [], + "aggregates": [], + "conditions": "", + } + ], + }, + ], + } + create = self.do_request("post", self.url, data=data) + assert create.status_code == 201, create.data + dashboard_id = create.data["id"] + widget_id = create.data["widgets"][0]["id"] + widget_type = create.data["widgets"][0].get("widgetType") + + put_url = f"/api/0/organizations/{self.organization.slug}/dashboards/{dashboard_id}/" + put_data = { + "title": "AI Agents Overview", + "widgets": [ + { + "id": widget_id, + "title": "Traces", + "displayType": "agents_traces_table", + "widgetType": widget_type, + "queries": [ + { + "name": "", + "fields": [], + "columns": [], + "aggregates": [], + "conditions": "", + } + ], + }, + ], + } + update = self.do_request("put", put_url, data=put_data) + assert update.status_code == 200, update.data + + def test_post_text_widget_after_restrictive_dataset_widget(self) -> None: + # Regression: DRF reuses a single child serializer for ``many=True``, + # so a previous widget's widget_type can leak via serializer context + # and incorrectly fail validation for a later TEXT widget. + with self.feature("organizations:dashboards-text-widgets"): + data = { + "title": "Dashboard from Post", + "widgets": [ + { + "title": "Mobile Size", + "displayType": "line", + "widgetType": "preprod-app-size", + "interval": "5m", + "queries": [ + { + "name": "", + "fields": ["count()"], + "columns": [], + "aggregates": ["count()"], + "conditions": "", + } + ], + }, + { + "title": "Text Widget", + "displayType": "text", + "description": "Notes", + }, + ], + } + response = self.do_request("post", self.url, data=data) + assert response.status_code == 201, response.data + def test_post_with_text_widget_without_feature_flag(self) -> None: data = { "title": "Dashboard from Post", diff --git a/tests/sentry/integrations/slack/test_unfurl.py b/tests/sentry/integrations/slack/test_unfurl.py index 04e85bac98f674..5ce347cd4cc9b7 100644 --- a/tests/sentry/integrations/slack/test_unfurl.py +++ b/tests/sentry/integrations/slack/test_unfurl.py @@ -23,6 +23,7 @@ from sentry.testutils.cases import TestCase from sentry.testutils.helpers import install_slack from sentry.testutils.helpers.datetime import before_now, freeze_time +from sentry.testutils.helpers.features import with_feature from sentry.testutils.skips import requires_snuba from sentry.types.group import PriorityLevel from sentry.workflow_engine.migration_helpers.alert_rule import migrate_alert_rule @@ -196,6 +197,7 @@ def test_match_link(url, expected) -> None: assert match_link(url) == expected +@with_feature("organizations:visibility-explore-view") class UnfurlTest(TestCase): def setUp(self) -> None: super().setUp() @@ -1135,8 +1137,7 @@ def test_unfurl_explore( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert ( unfurls[url] @@ -1149,28 +1150,6 @@ def test_unfurl_explore( chart_data = mock_generate_chart.call_args[0][1] assert "timeSeries" in chart_data - @patch( - "sentry.integrations.slack.unfurl.explore.client.get", - ) - @patch("sentry.charts.backend.generate_chart", return_value="chart-url") - def test_unfurl_explore_no_feature_flag( - self, mock_generate_chart: MagicMock, mock_client_get: MagicMock - ) -> None: - mock_client_get.return_value = MagicMock(data=self._build_mock_timeseries_response()) - url = f"https://sentry.io/organizations/{self.organization.slug}/explore/traces/?aggregateField=%7B%22yAxes%22%3A%5B%22avg(span.duration)%22%5D%7D&project={self.project.id}&statsPeriod=24h" - link_type, args = match_link(url) - - if not args or not link_type: - raise AssertionError("Missing link_type/args") - - links = [ - UnfurlableUrl(url=url, args=args), - ] - - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) - assert len(unfurls) == 0 - assert len(mock_generate_chart.mock_calls) == 0 - @patch( "sentry.integrations.slack.unfurl.explore.client.get", ) @@ -1189,8 +1168,7 @@ def test_unfurl_explore_with_groupby( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 assert len(mock_generate_chart.mock_calls) == 1 @@ -1221,8 +1199,7 @@ def test_unfurl_explore_forwards_multiple_groupbys_to_api( raise AssertionError("Missing link_type/args") links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - link_handlers[link_type].fn(self.integration, links, self.user) + link_handlers[link_type].fn(self.integration, links, self.user) mock_view.assert_called_once() request = mock_view.call_args[0][0] @@ -1248,8 +1225,7 @@ def test_unfurl_explore_with_groupby_explicit_sort( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 @@ -1277,8 +1253,7 @@ def test_unfurl_explore_default_yaxis( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 assert len(mock_generate_chart.mock_calls) == 1 @@ -1305,8 +1280,7 @@ def test_unfurl_explore_malformed_aggregate_field( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) # Should still unfurl with default yAxis assert len(unfurls) == 1 @@ -1341,8 +1315,7 @@ def test_unfurl_explore_end_to_end( # Step 2: Run handler links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) # Step 3: Verify events-timeseries was called with correct args assert mock_client_get.call_count == 1 @@ -1402,8 +1375,7 @@ def test_unfurl_explore_with_visualize_chart_type( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 assert len(mock_generate_chart.mock_calls) == 1 @@ -1429,8 +1401,7 @@ def test_unfurl_explore_without_chart_type_defaults_to_line( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 chart_data = mock_generate_chart.call_args[0][1] @@ -1456,8 +1427,7 @@ def test_unfurl_explore_skips_unsupported_chart_type( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert unfurls == {} # Skip happens before the events-timeseries call, so neither the API @@ -1486,8 +1456,7 @@ def test_unfurl_explore_without_chart_type_count_defaults_to_bar( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 chart_data = mock_generate_chart.call_args[0][1] @@ -1729,8 +1698,7 @@ def test_unfurl_explore_with_interval( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 call_kwargs = mock_client_get.call_args[1] @@ -1880,8 +1848,7 @@ def test_unfurl_explore_logs( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert ( unfurls[url] @@ -2002,8 +1969,7 @@ def test_unfurl_explore_logs_customer_domain( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 call_kwargs = mock_client_get.call_args[1] @@ -2030,8 +1996,7 @@ def test_unfurl_explore_metrics( UnfurlableUrl(url=url, args=args), ] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert ( unfurls[url] @@ -2075,8 +2040,7 @@ def test_unfurl_explore_metrics_skips_hidden_charts( links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert ( unfurls[url] @@ -2112,8 +2076,7 @@ def test_unfurl_explore_metrics_all_hidden_returns_no_unfurl( links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:data-browsing-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert unfurls == {} assert mock_generate_chart.call_count == 0 @@ -2171,8 +2134,7 @@ def test_unfurl_dashboards_spans_widget( links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert ( unfurls[url] @@ -2213,36 +2175,11 @@ def test_unfurl_dashboards_customer_domain( assert args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 assert mock_generate_chart.call_count == 1 - @patch("sentry.integrations.slack.unfurl.dashboards.client.get") - @patch("sentry.charts.backend.generate_chart", return_value="chart-url") - def test_unfurl_dashboards_no_feature_flag( - self, mock_generate_chart: MagicMock, mock_client_get: MagicMock - ) -> None: - mock_client_get.return_value = MagicMock(data=self._build_mock_timeseries_response()) - dashboard, _ = self._create_spans_widget() - - url = ( - f"https://sentry.io/organizations/{self.organization.slug}" - f"/dashboard/{dashboard.id}/widget/0/?statsPeriod=7d" - ) - link_type, args = match_link(url) - - assert link_type == LinkType.DASHBOARDS - assert args is not None - - links = [UnfurlableUrl(url=url, args=args)] - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) - - assert len(unfurls) == 0 - assert mock_generate_chart.call_count == 0 - assert mock_client_get.call_count == 0 - @patch("sentry.integrations.slack.unfurl.dashboards.client.get") @patch("sentry.charts.backend.generate_chart", return_value="chart-url") def test_unfurl_dashboards_unsupported_widget_type_is_skipped( @@ -2273,8 +2210,7 @@ def test_unfurl_dashboards_unsupported_widget_type_is_skipped( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 0 assert mock_client_get.call_count == 0 @@ -2298,8 +2234,7 @@ def test_unfurl_dashboards_unsupported_display_type( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 0 assert mock_client_get.call_count == 0 @@ -2320,8 +2255,7 @@ def test_unfurl_dashboards_widget_not_found( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 0 assert mock_client_get.call_count == 0 @@ -2372,8 +2306,7 @@ def test_unfurl_dashboards_multiple_queries_are_joined( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 assert mock_client_get.call_count == 2 @@ -2430,8 +2363,7 @@ def test_unfurl_dashboards_multi_query_same_aggregate( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - link_handlers[link_type].fn(self.integration, links, self.user) + link_handlers[link_type].fn(self.integration, links, self.user) chart_data = mock_generate_chart.call_args[0][1] pairs = chart_data["timeSeries"] @@ -2489,8 +2421,7 @@ def grouped_response(group_value: str): assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - link_handlers[link_type].fn(self.integration, links, self.user) + link_handlers[link_type].fn(self.integration, links, self.user) chart_data = mock_generate_chart.call_args[0][1] pairs = chart_data["timeSeries"] @@ -2518,8 +2449,7 @@ def test_unfurl_dashboards_bar_display_type( assert link_type is not None and args is not None links = [UnfurlableUrl(url=url, args=args)] - with self.feature(["organizations:dashboards-widget-unfurl"]): - unfurls = link_handlers[link_type].fn(self.integration, links, self.user) + unfurls = link_handlers[link_type].fn(self.integration, links, self.user) assert len(unfurls) == 1 chart_data = mock_generate_chart.call_args[0][1] diff --git a/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py b/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py index fc3bb427542b35..d7b6946d46e967 100644 --- a/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py +++ b/tests/sentry/integrations/slack/webhooks/events/test_explore_link_shared.py @@ -93,7 +93,7 @@ def share_explore_links_ephemeral_sdk(self, mock_match_link, mock_): return self.mock_post.call_args[1] def test_share_explore_links_unlinked_user(self) -> None: - with self.feature("organizations:data-browsing-widget-unfurl"): + with self.feature("organizations:visibility-explore-view"): data = self.share_explore_links_ephemeral_sdk() blocks = orjson.loads(data["blocks"]) diff --git a/tests/sentry/relocation/tasks/test_transfer.py b/tests/sentry/relocation/tasks/test_transfer.py index 945c409bf2a49f..77fe9dd6835df9 100644 --- a/tests/sentry/relocation/tasks/test_transfer.py +++ b/tests/sentry/relocation/tasks/test_transfer.py @@ -136,7 +136,7 @@ def test_missing_transfer(self) -> None: res = process_relocation_transfer_control(transfer_id=999) assert res is None - @patch("sentry.relocation.services.relocation_export.impl.fulfill_cross_region_export_request") + @patch("sentry.relocation.tasks.process.fulfill_cross_region_export_request") def test_transfer_request_state(self, mock_fulfill: MagicMock) -> None: transfer = create_control_relocation_transfer( organization=self.organization, @@ -149,7 +149,7 @@ def test_transfer_request_state(self, mock_fulfill: MagicMock) -> None: # Should be removed on completion. assert not ControlRelocationTransfer.objects.filter(id=transfer.id).exists() - @patch("sentry.relocation.services.relocation_export.impl.uploading_complete") + @patch("sentry.relocation.tasks.process.uploading_complete") def test_transfer_reply_state(self, mock_uploading_complete: MagicMock) -> None: organization = self.organization with assume_test_silo_mode(SiloMode.CELL):