diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 271cf805f8b63f..9f8f7fa6218a5a 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -15,7 +15,7 @@ remote_subscriptions: 0003_drop_remote_subscription replays: 0004_index_together -sentry: 0836_create_groupsearchviewstarred_table +sentry: 0837_create_groupsearchviewlastseen_table social_auth: 0002_default_auto_field diff --git a/pyproject.toml b/pyproject.toml index ebb6b3f0167208..3a81c07f05445c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,18 +112,14 @@ ignore_missing_imports = true [[tool.mypy.overrides]] module = [ "sentry.api.base", - "sentry.api.bases.organization_events", "sentry.api.endpoints.group_integration_details", "sentry.api.endpoints.group_integrations", "sentry.api.endpoints.organization_events_facets_performance", "sentry.api.endpoints.organization_events_meta", "sentry.api.endpoints.organization_events_spans_performance", - "sentry.api.endpoints.organization_member.details", "sentry.api.endpoints.organization_projects", "sentry.api.endpoints.organization_releases", - "sentry.api.endpoints.organization_request_project_creation", "sentry.api.endpoints.organization_search_details", - "sentry.api.endpoints.project_index", "sentry.api.endpoints.project_ownership", "sentry.api.endpoints.project_repo_path_parsing", "sentry.api.endpoints.project_rules_configuration", @@ -140,7 +136,6 @@ module = [ "sentry.incidents.endpoints.bases", "sentry.incidents.endpoints.organization_alert_rule_details", "sentry.incidents.endpoints.organization_alert_rule_index", - "sentry.incidents.endpoints.organization_incident_index", "sentry.integrations.aws_lambda.integration", "sentry.integrations.bitbucket_server.integration", "sentry.integrations.example.integration", diff --git a/src/sentry/api/bases/organization.py b/src/sentry/api/bases/organization.py index ec767c02ff1b7a..5f99eed91bf0e7 100644 --- a/src/sentry/api/bases/organization.py +++ b/src/sentry/api/bases/organization.py @@ -2,7 +2,7 @@ from collections.abc import Sequence from datetime import datetime -from typing import Any, TypedDict +from typing import Any, Literal, NotRequired, TypedDict, overload import sentry_sdk from django.core.cache import cache @@ -305,14 +305,24 @@ def convert_args( return (args, kwargs) -class FilterParams(TypedDict, total=False): +class FilterParams(TypedDict): start: datetime | None end: datetime | None project_id: list[int] project_objects: list[Project] organization_id: int - environment: list[str] | None - environment_objects: list[Environment] | None + environment: NotRequired[list[str]] + environment_objects: NotRequired[list[Environment]] + + +class FilterParamsDateNotNull(TypedDict): + start: datetime + end: datetime + project_id: list[int] + project_objects: list[Project] + organization_id: int + environment: NotRequired[list[str]] + environment_objects: NotRequired[list[Environment]] def _validate_fetched_projects( @@ -461,14 +471,35 @@ def get_environments( ) -> list[Environment]: return get_environments(request, organization) + @overload def get_filter_params( self, request: Request, organization: Organization | RpcOrganization, - date_filter_optional: bool = False, project_ids: list[int] | set[int] | None = None, project_slugs: list[str] | set[str] | None = None, - ) -> FilterParams: + ) -> FilterParamsDateNotNull: ... + + @overload + def get_filter_params( + self, + request: Request, + organization: Organization | RpcOrganization, + project_ids: list[int] | set[int] | None = None, + project_slugs: list[str] | set[str] | None = None, + *, + date_filter_optional: Literal[True], + ) -> FilterParams: ... + + def get_filter_params( + self, + request: Request, + organization: Organization | RpcOrganization, + project_ids: list[int] | set[int] | None = None, + project_slugs: list[str] | set[str] | None = None, + *, + date_filter_optional: bool = False, + ) -> FilterParams | FilterParamsDateNotNull: """ Extracts common filter parameters from the request and returns them in a standard format. diff --git a/src/sentry/api/bases/organization_events.py b/src/sentry/api/bases/organization_events.py index 225a32f61c79e5..2fef759f473591 100644 --- a/src/sentry/api/bases/organization_events.py +++ b/src/sentry/api/bases/organization_events.py @@ -17,10 +17,10 @@ from sentry.api.api_owners import ApiOwner from sentry.api.base import CURSOR_LINK_HEADER from sentry.api.bases import NoProjects -from sentry.api.bases.organization import OrganizationEndpoint +from sentry.api.bases.organization import FilterParamsDateNotNull, OrganizationEndpoint from sentry.api.helpers.mobile import get_readable_device_name from sentry.api.helpers.teams import get_teams -from sentry.api.serializers.snuba import BaseSnubaSerializer, SnubaTSResultSerializer +from sentry.api.serializers.snuba import SnubaTSResultSerializer from sentry.api.utils import handle_query_errors from sentry.discover.arithmetic import is_equation, strip_equation from sentry.discover.models import DatasetSourcesTypes, DiscoverSavedQueryTypes @@ -37,6 +37,7 @@ from sentry.snuba import discover from sentry.snuba.metrics.extraction import MetricSpecType from sentry.snuba.utils import DATASET_LABELS, DATASET_OPTIONS, get_dataset +from sentry.users.services.user.serial import serialize_generic_user from sentry.utils import snuba from sentry.utils.cursors import Cursor from sentry.utils.dates import get_interval_from_range, get_rollup_from_request, parse_stats_period @@ -129,7 +130,7 @@ def get_snuba_params( detail=f"You can view up to {MAX_FIELDS} fields at a time. Please delete some and try again." ) - filter_params: dict[str, Any] = self.get_filter_params(request, organization) + filter_params = self.get_filter_params(request, organization) if quantize_date_params: filter_params = self.quantize_date_params(request, filter_params) params = SnubaParams( @@ -137,7 +138,9 @@ def get_snuba_params( end=filter_params["end"], environments=filter_params.get("environment_objects", []), projects=filter_params["project_objects"], - user=request.user if request.user else None, + user=serialize_generic_user( + request.user if request.user.is_authenticated else None + ), teams=self.get_teams(request, organization), organization=organization, ) @@ -163,7 +166,9 @@ def get_orderby(self, request: Request) -> list[str] | None: return orderby return None - def quantize_date_params(self, request: Request, params: dict[str, Any]) -> dict[str, Any]: + def quantize_date_params( + self, request: Request, params: FilterParamsDateNotNull + ) -> FilterParamsDateNotNull: # We only need to perform this rounding on relative date periods if "statsPeriod" not in request.GET: return params @@ -418,7 +423,8 @@ def get_event_stats_data( request: Request, organization: Organization, get_event_stats: Callable[ - [list[str], str, SnubaParams, int, bool, timedelta | None], SnubaTSResult + [list[str], str, SnubaParams, int, bool, timedelta | None], + SnubaTSResult | dict[str, SnubaTSResult], ], top_events: int = 0, query_column: str = "count()", @@ -466,7 +472,7 @@ def get_event_stats_data( stats_period = parse_stats_period(get_interval_from_range(date_range, False)) rollup = int(stats_period.total_seconds()) if stats_period is not None else 3600 if comparison_delta is not None: - retention = quotas.get_event_retention(organization=organization) + retention = quotas.backend.get_event_retention(organization=organization) comparison_start = snuba_params.start_date - comparison_delta if retention and comparison_start < timezone.now() - timedelta(days=retention): raise ValidationError("Comparison period is outside your retention window") @@ -484,7 +490,7 @@ def get_event_stats_data( # there were no top events found. In this case, result contains a zerofilled series # that acts as a placeholder. is_multiple_axis = len(query_columns) > 1 - if top_events > 0 and isinstance(result, dict): + if isinstance(result, dict): results = {} for key, event_result in result.items(): if is_multiple_axis: @@ -588,7 +594,7 @@ def serialize_multiple_axis( self, request: Request, organization: Organization, - serializer: BaseSnubaSerializer, + serializer: SnubaTSResultSerializer, event_result: SnubaTSResult, snuba_params: SnubaParams, columns: Sequence[str], diff --git a/src/sentry/api/endpoints/auth_index.py b/src/sentry/api/endpoints/auth_index.py index 0b116fb2be466b..8d43250e4d1a7e 100644 --- a/src/sentry/api/endpoints/auth_index.py +++ b/src/sentry/api/endpoints/auth_index.py @@ -190,7 +190,7 @@ def post(self, request: Request) -> Response: curl -X ###METHOD### -u username:password ###URL### """ - if isinstance(request.user, AnonymousUser) or not request.user.is_authenticated: + if not request.user.is_authenticated: return Response(status=status.HTTP_400_BAD_REQUEST) if is_demo_user(request.user): diff --git a/src/sentry/api/endpoints/organization_member/details.py b/src/sentry/api/endpoints/organization_member/details.py index 797af542797543..47a3a3ad5e1504 100644 --- a/src/sentry/api/endpoints/organization_member/details.py +++ b/src/sentry/api/endpoints/organization_member/details.py @@ -5,7 +5,7 @@ from django.db import router, transaction from django.db.models import Q from drf_spectacular.utils import extend_schema, inline_serializer -from rest_framework import serializers +from rest_framework import serializers, status from rest_framework.exceptions import PermissionDenied from rest_framework.request import Request from rest_framework.response import Response @@ -199,6 +199,9 @@ def put( For example, an organization Manager may change someone's role from Member to Manager, but not to Owner. """ + if not request.user.is_authenticated: + return Response(status=status.HTTP_400_BAD_REQUEST) + allowed_roles = get_allowed_org_roles(request, organization) serializer = OrganizationMemberRequestSerializer( data=request.data, @@ -245,6 +248,7 @@ def put( if not is_reinvite_request_only: return Response({"detail": ERR_EDIT_WHEN_REINVITING}, status=403) if member.is_pending: + assert member.email is not None if ratelimits.for_organization_member_invite( organization=organization, email=member.email, @@ -270,7 +274,7 @@ def put( return Response({"detail": ERR_EXPIRED}, status=400) member.send_invite_email() elif auth_provider and not getattr(member.flags, "sso:linked"): - member.send_sso_link_email(request.user.id, auth_provider) + member.send_sso_link_email(request.user.email, auth_provider) else: # TODO(dcramer): proper error message return Response({"detail": ERR_UNINVITABLE}, status=400) diff --git a/src/sentry/api/endpoints/organization_request_project_creation.py b/src/sentry/api/endpoints/organization_request_project_creation.py index e3561921e0f1b9..ac92dfafebb37d 100644 --- a/src/sentry/api/endpoints/organization_request_project_creation.py +++ b/src/sentry/api/endpoints/organization_request_project_creation.py @@ -1,5 +1,5 @@ from django.utils.translation import gettext_lazy as _ -from rest_framework import serializers +from rest_framework import serializers, status from rest_framework.request import Request from rest_framework.response import Response @@ -26,6 +26,9 @@ def post(self, request: Request, organization) -> Response: Send an email requesting a project be created """ + if not request.user.is_authenticated: + return Response(status=status.HTTP_400_BAD_REQUEST) + serializer = OrganizationRequestProjectCreationSerializer(data=request.data) if not serializer.is_valid(): return self.respond(serializer.errors, status=400) diff --git a/src/sentry/api/endpoints/project_index.py b/src/sentry/api/endpoints/project_index.py index b0b3a8c99f2ccd..c84d65fa963c9d 100644 --- a/src/sentry/api/endpoints/project_index.py +++ b/src/sentry/api/endpoints/project_index.py @@ -46,14 +46,14 @@ def get(self, request: Request) -> Response: queryset = queryset.none() if request.auth and not request.user.is_authenticated: - if hasattr(request.auth, "project"): + if request.auth.project_id: queryset = queryset.filter(id=request.auth.project_id) elif request.auth.organization_id is not None: queryset = queryset.filter(organization_id=request.auth.organization_id) else: queryset = queryset.none() elif not (is_active_superuser(request) and request.GET.get("show") == "all"): - if request.user.is_sentry_app: + if request.user.is_authenticated and request.user.is_sentry_app: queryset = SentryAppInstallation.objects.get_projects(request.auth) if isinstance(queryset, EmptyQuerySet): raise AuthenticationFailed("Token not found") @@ -69,8 +69,10 @@ def get(self, request: Request) -> Response: tokens = tokenize_query(query) for key, value in tokens.items(): if key == "query": - value = " ".join(value) - queryset = queryset.filter(Q(name__icontains=value) | Q(slug__icontains=value)) + value_s = " ".join(value) + queryset = queryset.filter( + Q(name__icontains=value_s) | Q(slug__icontains=value_s) + ) elif key == "slug": queryset = queryset.filter(in_iexact("slug", value)) elif key == "name": diff --git a/src/sentry/api/serializers/snuba.py b/src/sentry/api/serializers/snuba.py index be06f38d6add17..1149b9d09b3d55 100644 --- a/src/sentry/api/serializers/snuba.py +++ b/src/sentry/api/serializers/snuba.py @@ -50,7 +50,11 @@ def calculate_time_frame(start, end, rollup): return {"start": rollup_start, "end": rollup_end} -class BaseSnubaSerializer: +class SnubaTSResultSerializer: + """ + Serializer for time-series Snuba data. + """ + def __init__(self, organization, lookup, user): self.organization = organization self.lookup = lookup @@ -62,12 +66,6 @@ def get_attrs(self, item_list): return self.lookup.serializer(self.organization, item_list, self.user) - -class SnubaTSResultSerializer(BaseSnubaSerializer): - """ - Serializer for time-series Snuba data. - """ - def serialize( self, result, diff --git a/src/sentry/backup/comparators.py b/src/sentry/backup/comparators.py index e2a81d13ee0811..970ec939562889 100644 --- a/src/sentry/backup/comparators.py +++ b/src/sentry/backup/comparators.py @@ -806,6 +806,9 @@ def get_default_comparators() -> dict[str, list[JSONScrubbingComparator]]: DateUpdatedComparator("date_added", "date_updated"), ], "sentry.groupsearchview": [DateUpdatedComparator("date_updated")], + "sentry.groupsearchviewlastvisited": [ + DateUpdatedComparator("last_visited", "date_added", "date_updated") + ], "sentry.groupsearchviewstarred": [DateUpdatedComparator("date_updated", "date_added")], "sentry.groupsearchviewproject": [ DateUpdatedComparator("date_updated"), diff --git a/src/sentry/grouping/ingest/grouphash_metadata.py b/src/sentry/grouping/ingest/grouphash_metadata.py index fdc78149f1753a..00cb94e6c69ef1 100644 --- a/src/sentry/grouping/ingest/grouphash_metadata.py +++ b/src/sentry/grouping/ingest/grouphash_metadata.py @@ -12,7 +12,7 @@ from datetime import datetime from typing import Any, TypeIs, cast -from sentry import features, options +from sentry import options from sentry.eventstore.models import Event from sentry.grouping.api import get_contributing_variant_and_component from sentry.grouping.component import ( @@ -110,9 +110,7 @@ def should_handle_grouphash_metadata(project: Project, grouphash_is_new: bool) -> bool: # Killswitches - if not options.get("grouping.grouphash_metadata.ingestion_writes_enabled") or not features.has( - "organizations:grouphash-metadata-creation", project.organization - ): + if not options.get("grouping.grouphash_metadata.ingestion_writes_enabled"): return False # While we're backfilling metadata for existing grouphash records, if the load is too high, we diff --git a/src/sentry/incidents/endpoints/organization_incident_index.py b/src/sentry/incidents/endpoints/organization_incident_index.py index 96e5964eb7f049..05975e76e65084 100644 --- a/src/sentry/incidents/endpoints/organization_incident_index.py +++ b/src/sentry/incidents/endpoints/organization_incident_index.py @@ -55,11 +55,12 @@ def get(self, request: Request, organization) -> Response: query_alert_rule = request.GET.get("alertRule") query_include_snapshots = request.GET.get("includeSnapshots") if query_alert_rule is not None: - alert_rule_ids = [int(query_alert_rule)] + query_alert_rule_id = int(query_alert_rule) + alert_rule_ids = [query_alert_rule_id] if query_include_snapshots: snapshot_alerts = list( AlertRuleActivity.objects.filter( - previous_alert_rule=query_alert_rule, + previous_alert_rule=query_alert_rule_id, type=AlertRuleActivityType.SNAPSHOT.value, ) ) @@ -67,16 +68,16 @@ def get(self, request: Request, organization) -> Response: alert_rule_ids.append(snapshot_alert.alert_rule_id) incidents = incidents.filter(alert_rule__in=alert_rule_ids) - query_start = request.GET.get("start") - if query_start is not None: + query_start_s = request.GET.get("start") + if query_start_s is not None: # exclude incidents closed before the window - query_start = ensure_aware(parse_date(query_start)) + query_start = ensure_aware(parse_date(query_start_s)) incidents = incidents.exclude(date_closed__lt=query_start) - query_end = request.GET.get("end") - if query_end is not None: + query_end_s = request.GET.get("end") + if query_end_s is not None: # exclude incidents started after the window - query_end = ensure_aware(parse_date(query_end)) + query_end = ensure_aware(parse_date(query_end_s)) incidents = incidents.exclude(date_started__gt=query_end) query_status = request.GET.get("status") diff --git a/src/sentry/integrations/aws_lambda/utils.py b/src/sentry/integrations/aws_lambda/utils.py index 6f0310ab99a636..de91e85d028b54 100644 --- a/src/sentry/integrations/aws_lambda/utils.py +++ b/src/sentry/integrations/aws_lambda/utils.py @@ -9,6 +9,7 @@ from sentry import options from sentry.projects.services.project_key import ProjectKeyRole, project_key_service from sentry.shared_integrations.exceptions import IntegrationError +from sentry.silo.base import SiloMode from sentry.tasks.release_registry import LAYER_INDEX_CACHE_KEY SUPPORTED_RUNTIMES = [ @@ -113,6 +114,17 @@ def get_option_value(function, option): key = f"aws-layer:{prefix}" cache_value = cache_options.get(key) + if SiloMode.get_current_mode() == SiloMode.REGION: + with sentry_sdk.isolation_scope() as scope: + scope.set_context( + "aws_lambda_cache", + { + "cache_options": cache_options, + "key": key, + }, + ) + sentry_sdk.capture_message("Fetching aws layer from cache") + if cache_value is None: raise IntegrationError(f"Could not find cache value with key {key}") diff --git a/src/sentry/integrations/discord/message_builder/metric_alerts.py b/src/sentry/integrations/discord/message_builder/metric_alerts.py index 9bcae5d4d16f09..656ac9146ebb9e 100644 --- a/src/sentry/integrations/discord/message_builder/metric_alerts.py +++ b/src/sentry/integrations/discord/message_builder/metric_alerts.py @@ -9,7 +9,11 @@ from sentry.integrations.discord.message_builder.base.base import DiscordMessageBuilder from sentry.integrations.discord.message_builder.base.embed.base import DiscordMessageEmbed from sentry.integrations.discord.message_builder.base.embed.image import DiscordMessageEmbedImage -from sentry.integrations.metric_alerts import incident_attachment_info +from sentry.integrations.metric_alerts import ( + AlertContext, + get_metric_count_from_incident, + incident_attachment_info, +) class DiscordMetricAlertMessageBuilder(DiscordMessageBuilder): @@ -18,7 +22,7 @@ def __init__( alert_rule: AlertRule, incident: Incident, new_status: IncidentStatus, - metric_value: float, + metric_value: float | None = None, chart_url: str | None = None, ) -> None: self.alert_rule = alert_rule @@ -28,15 +32,21 @@ def __init__( self.chart_url = chart_url def build(self, notification_uuid: str | None = None) -> dict[str, object]: + if self.metric_value is None: + self.metric_value = get_metric_count_from_incident(self.incident) + data = incident_attachment_info( - self.incident, - self.new_status, - self.metric_value, - notification_uuid, + AlertContext.from_alert_rule_incident(self.alert_rule), + open_period_identifier=self.incident.identifier, + organization=self.incident.organization, + snuba_query=self.alert_rule.snuba_query, + metric_value=self.metric_value, + new_status=self.new_status, + notification_uuid=notification_uuid, referrer="metric_alert_discord", ) - description = f"{data['text']}{get_started_at(data['date_started'])}" + description = f"{data['text']}{get_started_at(self.incident.date_started)}" embeds = [ DiscordMessageEmbed( diff --git a/src/sentry/integrations/metric_alerts.py b/src/sentry/integrations/metric_alerts.py index 86190476b39346..11256e036254f2 100644 --- a/src/sentry/integrations/metric_alerts.py +++ b/src/sentry/integrations/metric_alerts.py @@ -1,8 +1,10 @@ +from __future__ import annotations + +from dataclasses import dataclass from datetime import datetime, timedelta -from typing import TypedDict +from typing import NotRequired, TypedDict from urllib import parse -import sentry_sdk from django.db.models import Max from django.urls import reverse from django.utils.translation import gettext as _ @@ -10,7 +12,11 @@ from sentry import features from sentry.constants import CRASH_RATE_ALERT_AGGREGATE_ALIAS from sentry.incidents.logic import GetMetricIssueAggregatesParams, get_metric_issue_aggregates -from sentry.incidents.models.alert_rule import AlertRule, AlertRuleThresholdType +from sentry.incidents.models.alert_rule import ( + AlertRule, + AlertRuleDetectionType, + AlertRuleThresholdType, +) from sentry.incidents.models.incident import ( INCIDENT_STATUS, Incident, @@ -49,7 +55,7 @@ class AttachmentInfo(TypedDict): text: str status: str logo_url: str - date_started: datetime | None + date_started: NotRequired[datetime | None] class TitleLinkParams(TypedDict, total=False): @@ -59,6 +65,25 @@ class TitleLinkParams(TypedDict, total=False): notification_uuid: str +@dataclass +class AlertContext: + name: str + action_identifier_id: int + threshold_type: AlertRuleThresholdType | None + detection_type: AlertRuleDetectionType + comparison_delta: int | None + + @classmethod + def from_alert_rule_incident(cls, alert_rule: AlertRule) -> AlertContext: + return cls( + name=alert_rule.name, + action_identifier_id=alert_rule.id, + threshold_type=AlertRuleThresholdType(alert_rule.threshold_type), + detection_type=AlertRuleDetectionType(alert_rule.detection_type), + comparison_delta=alert_rule.comparison_delta, + ) + + def logo_url() -> str: return absolute_uri(get_asset_url("sentry", "images/sentry-email-avatar.png")) @@ -165,60 +190,50 @@ def build_title_link( def incident_attachment_info( - incident: Incident, + alert_context: AlertContext, + open_period_identifier: int, + organization: Organization, + snuba_query: SnubaQuery, new_status: IncidentStatus, - # WIP(iamrajjoshi): This should shouldn't be None, but it sometimes is. Working on figuring out why. metric_value: float | None = None, - notification_uuid=None, - referrer="metric_alert", + referrer: str = "metric_alert", + notification_uuid: str | None = None, ) -> AttachmentInfo: - alert_rule = incident.alert_rule - - if metric_value is None: - sentry_sdk.capture_message( - "Metric value is None when building incident attachment info", - level="warning", - ) - # TODO(iamrajjoshi): This should be fixed by the time we get rid of this function. - metric_value = get_metric_count_from_incident(incident) - status = get_status_text(new_status) text = "" if metric_value is not None: text = get_incident_status_text( - alert_rule.snuba_query, - ( - AlertRuleThresholdType(alert_rule.threshold_type) - if alert_rule.threshold_type is not None - else None - ), - alert_rule.comparison_delta, + snuba_query, + alert_context.threshold_type, + alert_context.comparison_delta, str(metric_value), ) - if features.has( - "organizations:anomaly-detection-alerts", incident.organization - ) and features.has("organizations:anomaly-detection-rollout", incident.organization): - text += f"\nThreshold: {alert_rule.detection_type.title()}" - title = get_title(status, alert_rule.name) + if features.has("organizations:anomaly-detection-alerts", organization) and features.has( + "organizations:anomaly-detection-rollout", organization + ): + text += f"\nThreshold: {alert_context.detection_type.title()}" + + title = get_title(status, alert_context.name) title_link_params: TitleLinkParams = { - "alert": str(incident.identifier), + "alert": str(open_period_identifier), "referrer": referrer, - "detection_type": alert_rule.detection_type, + "detection_type": alert_context.detection_type.value, } if notification_uuid: title_link_params["notification_uuid"] = notification_uuid - title_link = build_title_link(alert_rule.id, alert_rule.organization, title_link_params) + title_link = build_title_link( + alert_context.action_identifier_id, organization, title_link_params + ) return AttachmentInfo( title=title, text=text, logo_url=logo_url(), status=status, - date_started=incident.date_started, title_link=title_link, ) diff --git a/src/sentry/integrations/msteams/card_builder/incident_attachment.py b/src/sentry/integrations/msteams/card_builder/incident_attachment.py index a0d197406eaefe..01ff1b114f23ee 100644 --- a/src/sentry/integrations/msteams/card_builder/incident_attachment.py +++ b/src/sentry/integrations/msteams/card_builder/incident_attachment.py @@ -3,7 +3,11 @@ from typing import Literal from sentry.incidents.models.incident import Incident, IncidentStatus -from sentry.integrations.metric_alerts import incident_attachment_info +from sentry.integrations.metric_alerts import ( + AlertContext, + get_metric_count_from_incident, + incident_attachment_info, +) from sentry.integrations.msteams.card_builder.block import ( AdaptiveCard, ColumnWidth, @@ -15,13 +19,19 @@ def build_incident_attachment( incident: Incident, new_status: IncidentStatus, - metric_value: float, + metric_value: float | None = None, notification_uuid: str | None = None, ) -> AdaptiveCard: + if metric_value is None: + metric_value = get_metric_count_from_incident(incident) + data = incident_attachment_info( - incident, - new_status, - metric_value, + AlertContext.from_alert_rule_incident(incident.alert_rule), + open_period_identifier=incident.identifier, + organization=incident.organization, + snuba_query=incident.alert_rule.snuba_query, + metric_value=metric_value, + new_status=new_status, notification_uuid=notification_uuid, referrer="metric_alert_msteams", ) @@ -29,11 +39,7 @@ def build_incident_attachment( colors: dict[str, Literal["good", "warning", "attention"]] colors = {"Resolved": "good", "Warning": "warning", "Critical": "attention"} - footer_text = ( - "Sentry Incident | {}".format(data["date_started"].strftime("%b %d")) - if data["date_started"] is not None - else "Sentry Incident" - ) + footer_text = "Sentry Incident | {}".format(incident.date_started.strftime("%b %d")) return { "type": "AdaptiveCard", diff --git a/src/sentry/integrations/opsgenie/utils.py b/src/sentry/integrations/opsgenie/utils.py index 9c323ed025fb3b..4e37bee1289697 100644 --- a/src/sentry/integrations/opsgenie/utils.py +++ b/src/sentry/integrations/opsgenie/utils.py @@ -6,7 +6,11 @@ from sentry.constants import ObjectStatus from sentry.incidents.models.alert_rule import AlertRuleTriggerAction from sentry.incidents.models.incident import Incident, IncidentStatus -from sentry.integrations.metric_alerts import incident_attachment_info +from sentry.integrations.metric_alerts import ( + AlertContext, + get_metric_count_from_incident, + incident_attachment_info, +) from sentry.integrations.opsgenie.client import OPSGENIE_DEFAULT_PRIORITY from sentry.integrations.services.integration import integration_service from sentry.integrations.services.integration.model import RpcOrganizationIntegration @@ -20,12 +24,23 @@ def build_incident_attachment( incident: Incident, new_status: IncidentStatus, - metric_value: float, + metric_value: float | None = None, notification_uuid: str | None = None, ) -> dict[str, Any]: + if metric_value is None: + metric_value = get_metric_count_from_incident(incident) + data = incident_attachment_info( - incident, new_status, metric_value, notification_uuid, referrer="metric_alert_opsgenie" + AlertContext.from_alert_rule_incident(incident.alert_rule), + open_period_identifier=incident.identifier, + organization=incident.organization, + snuba_query=incident.alert_rule.snuba_query, + new_status=new_status, + metric_value=metric_value, + notification_uuid=notification_uuid, + referrer="metric_alert_opsgenie", ) + alert_key = f"incident_{incident.organization_id}_{incident.identifier}" if new_status == IncidentStatus.CLOSED: payload = {"identifier": alert_key} diff --git a/src/sentry/integrations/pagerduty/utils.py b/src/sentry/integrations/pagerduty/utils.py index 7a27c713653940..e37226b1f7bf75 100644 --- a/src/sentry/integrations/pagerduty/utils.py +++ b/src/sentry/integrations/pagerduty/utils.py @@ -8,7 +8,11 @@ from sentry.incidents.models.alert_rule import AlertRuleTriggerAction from sentry.incidents.models.incident import Incident, IncidentStatus -from sentry.integrations.metric_alerts import incident_attachment_info +from sentry.integrations.metric_alerts import ( + AlertContext, + get_metric_count_from_incident, + incident_attachment_info, +) from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.pagerduty.client import PAGERDUTY_DEFAULT_SEVERITY from sentry.integrations.services.integration import integration_service @@ -91,11 +95,21 @@ def build_incident_attachment( incident, integration_key, new_status: IncidentStatus, - metric_value: float, + metric_value: float | None = None, notfication_uuid: str | None = None, ) -> dict[str, Any]: + if metric_value is None: + metric_value = get_metric_count_from_incident(incident) + data = incident_attachment_info( - incident, new_status, metric_value, notfication_uuid, referrer="metric_alert_pagerduty" + AlertContext.from_alert_rule_incident(incident.alert_rule), + open_period_identifier=incident.identifier, + organization=incident.organization, + snuba_query=incident.alert_rule.snuba_query, + metric_value=metric_value, + new_status=new_status, + notification_uuid=notfication_uuid, + referrer="metric_alert_pagerduty", ) severity = "info" if new_status == IncidentStatus.CRITICAL: diff --git a/src/sentry/integrations/slack/message_builder/incidents.py b/src/sentry/integrations/slack/message_builder/incidents.py index e900adfe2af957..a026e513602ba6 100644 --- a/src/sentry/integrations/slack/message_builder/incidents.py +++ b/src/sentry/integrations/slack/message_builder/incidents.py @@ -1,7 +1,11 @@ from datetime import datetime from sentry.incidents.models.incident import Incident, IncidentStatus -from sentry.integrations.metric_alerts import incident_attachment_info +from sentry.integrations.metric_alerts import ( + AlertContext, + get_metric_count_from_incident, + incident_attachment_info, +) from sentry.integrations.slack.message_builder.base.block import BlockSlackMessageBuilder from sentry.integrations.slack.message_builder.types import ( INCIDENT_COLOR_MAPPING, @@ -24,7 +28,7 @@ def __init__( self, incident: Incident, new_status: IncidentStatus, - metric_value: float, + metric_value: float | None = None, chart_url: str | None = None, notification_uuid: str | None = None, ) -> None: @@ -44,14 +48,22 @@ def __init__( self.notification_uuid = notification_uuid def build(self) -> SlackBody: + # (iamrajjoshi): Need this check since the type hint is wrong. + if self.metric_value is None: + self.metric_value = get_metric_count_from_incident(self.incident) + data = incident_attachment_info( - self.incident, - self.new_status, - self.metric_value, - self.notification_uuid, + AlertContext.from_alert_rule_incident(self.incident.alert_rule), + self.incident.identifier, + organization=self.incident.organization, + snuba_query=self.incident.alert_rule.snuba_query, + metric_value=self.metric_value, + new_status=self.new_status, + notification_uuid=self.notification_uuid, referrer="metric_alert_slack", ) - incident_text = f"{data['text']}\n{get_started_at(data['date_started'])}" + + incident_text = f"{data['text']}\n{get_started_at(self.incident.date_started)}" blocks = [ self.get_markdown_block(text=incident_text), ] diff --git a/src/sentry/migrations/0837_create_groupsearchviewlastseen_table.py b/src/sentry/migrations/0837_create_groupsearchviewlastseen_table.py new file mode 100644 index 00000000000000..d3af2fc742d850 --- /dev/null +++ b/src/sentry/migrations/0837_create_groupsearchviewlastseen_table.py @@ -0,0 +1,74 @@ +# Generated by Django 5.1.5 on 2025-03-04 00:01 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + +import sentry.db.models.fields.bounded +import sentry.db.models.fields.foreignkey +import sentry.db.models.fields.hybrid_cloud_foreign_key +from sentry.new_migrations.migrations import CheckedMigration + + +class Migration(CheckedMigration): + # This flag is used to mark that a migration shouldn't be automatically run in production. + # This should only be used for operations where it's safe to run the migration after your + # code has deployed. So this should not be used for most operations that alter the schema + # of a table. + # Here are some things that make sense to mark as post deployment: + # - Large data migrations. Typically we want these to be run manually so that they can be + # monitored and not block the deploy for a long period of time while they run. + # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to + # run this outside deployments so that we don't block them. Note that while adding an index + # is a schema change, it's completely safe to run the operation after the code has deployed. + # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment + + is_post_deployment = False + + dependencies = [ + ("sentry", "0836_create_groupsearchviewstarred_table"), + ] + + operations = [ + migrations.CreateModel( + name="GroupSearchViewLastVisited", + fields=[ + ( + "id", + sentry.db.models.fields.bounded.BoundedBigAutoField( + primary_key=True, serialize=False + ), + ), + ("date_updated", models.DateTimeField(auto_now=True)), + ("date_added", models.DateTimeField(auto_now_add=True)), + ( + "user_id", + sentry.db.models.fields.hybrid_cloud_foreign_key.HybridCloudForeignKey( + "sentry.User", db_index=True, on_delete="CASCADE" + ), + ), + ("last_visited", models.DateTimeField(default=django.utils.timezone.now)), + ( + "group_search_view", + sentry.db.models.fields.foreignkey.FlexibleForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="sentry.groupsearchview" + ), + ), + ( + "organization", + sentry.db.models.fields.foreignkey.FlexibleForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="sentry.organization" + ), + ), + ], + options={ + "db_table": "sentry_groupsearchviewlastvisited", + "constraints": [ + models.UniqueConstraint( + fields=("user_id", "organization_id", "group_search_view_id"), + name="sentry_groupsearchviewlastvisited_unique_last_visited_per_org_user_view", + ) + ], + }, + ), + ] diff --git a/src/sentry/models/__init__.py b/src/sentry/models/__init__.py index 4505f86fa51c97..d31cc910e06f71 100644 --- a/src/sentry/models/__init__.py +++ b/src/sentry/models/__init__.py @@ -53,6 +53,7 @@ from .groupresolution import * # NOQA from .grouprulestatus import * # NOQA from .groupsearchview import * # NOQA +from .groupsearchviewlastvisited import * # NOQA from .groupsearchviewstarred import * # NOQA from .groupseen import * # NOQA from .groupshare import * # NOQA diff --git a/src/sentry/models/groupsearchviewlastvisited.py b/src/sentry/models/groupsearchviewlastvisited.py new file mode 100644 index 00000000000000..f33c1059557438 --- /dev/null +++ b/src/sentry/models/groupsearchviewlastvisited.py @@ -0,0 +1,29 @@ +from django.db import models +from django.db.models import UniqueConstraint +from django.utils import timezone + +from sentry.backup.scopes import RelocationScope +from sentry.db.models import FlexibleForeignKey, region_silo_model +from sentry.db.models.base import DefaultFieldsModel +from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey + + +@region_silo_model +class GroupSearchViewLastVisited(DefaultFieldsModel): + __relocation_scope__ = RelocationScope.Organization + + user_id = HybridCloudForeignKey("sentry.User", on_delete="CASCADE") + organization = FlexibleForeignKey("sentry.Organization") + group_search_view = FlexibleForeignKey("sentry.GroupSearchView") + + last_visited = models.DateTimeField(null=False, default=timezone.now) + + class Meta: + app_label = "sentry" + db_table = "sentry_groupsearchviewlastvisited" + constraints = [ + UniqueConstraint( + fields=["user_id", "organization_id", "group_search_view_id"], + name="sentry_groupsearchviewlastvisited_unique_last_visited_per_org_user_view", + ) + ] diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 12f370277ceb72..0c38d9ca69d06c 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -477,13 +477,6 @@ default=[], flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) -# Separate compute and IO. -register( - "replay.consumer.separate-compute-and-io-org-ids", - type=Sequence, - default=[], - flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) # Used for internal dogfooding of a reduced timeout on rage/dead clicks. register( "replay.rage-click.experimental-timeout.org-id-list", @@ -3117,7 +3110,7 @@ ) register( "sentry.demo_mode.sync_artifact_bundles.source_org_id", - default=None, + type=Int, flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) register( diff --git a/src/sentry/organizations/services/organization/impl.py b/src/sentry/organizations/services/organization/impl.py index 00446a425a9f95..073e468c5f2c80 100644 --- a/src/sentry/organizations/services/organization/impl.py +++ b/src/sentry/organizations/services/organization/impl.py @@ -24,6 +24,7 @@ from sentry.models.groupassignee import GroupAssignee from sentry.models.groupbookmark import GroupBookmark from sentry.models.groupsearchview import GroupSearchView +from sentry.models.groupsearchviewlastvisited import GroupSearchViewLastVisited from sentry.models.groupsearchviewstarred import GroupSearchViewStarred from sentry.models.groupseen import GroupSeen from sentry.models.groupshare import GroupShare @@ -592,6 +593,7 @@ def merge_users(self, *, organization_id: int, from_user_id: int, to_user_id: in GroupSeen, GroupShare, GroupSearchView, + GroupSearchViewLastVisited, GroupSearchViewStarred, GroupSubscription, IncidentActivity, diff --git a/src/sentry/replays/endpoints/organization_replay_index.py b/src/sentry/replays/endpoints/organization_replay_index.py index 494f660dc6411d..b10231e37e138d 100644 --- a/src/sentry/replays/endpoints/organization_replay_index.py +++ b/src/sentry/replays/endpoints/organization_replay_index.py @@ -88,17 +88,10 @@ def data_fn(offset: int, limit: int): if not isinstance(sort, str): sort = None - start = filter_params["start"] - end = filter_params["end"] - if start is None or end is None: - # It's not possible to reach this point but the type hint is wrong so I have - # to do this for completeness sake. - return Response({"detail": "Missing start or end period."}, status=400) - response = query_replays_collection_paginated( project_ids=filter_params["project_id"], - start=start, - end=end, + start=filter_params["start"], + end=filter_params["end"], environment=filter_params.get("environment") or [], sort=sort, fields=request.query_params.getlist("field"), diff --git a/src/sentry/replays/usecases/ingest/__init__.py b/src/sentry/replays/usecases/ingest/__init__.py index ea5c479318dde3..a5721b856eea1f 100644 --- a/src/sentry/replays/usecases/ingest/__init__.py +++ b/src/sentry/replays/usecases/ingest/__init__.py @@ -107,79 +107,13 @@ def ingest_recording(message_bytes: bytes) -> None: ): try: message = parse_recording_message(message_bytes) - if message.org_id in options.get("replay.consumer.separate-compute-and-io-org-ids"): - _ingest_recording_separated_io_compute(message) - else: - _ingest_recording(message) + _ingest_recording_separated_io_compute(message) except DropSilently: # The message couldn't be parsed for whatever reason. We shouldn't block the consumer # so we ignore it. pass -@sentry_sdk.trace -def _ingest_recording(message: RecordingIngestMessage) -> None: - """Ingest recording messages.""" - set_tag("org_id", message.org_id) - set_tag("project_id", message.project_id) - - headers, segment_bytes = parse_headers(message.payload_with_headers, message.replay_id) - segment = decompress_segment(segment_bytes) - _report_size_metrics(len(segment.compressed), len(segment.decompressed)) - - # Normalize ingest data into a standardized ingest format. - segment_data = RecordingSegmentStorageMeta( - project_id=message.project_id, - replay_id=message.replay_id, - segment_id=headers["segment_id"], - retention_days=message.retention_days, - ) - - if message.replay_video: - # Logging org info for bigquery - logger.info( - "sentry.replays.slow_click", - extra={ - "event_type": "mobile_event", - "org_id": message.org_id, - "project_id": message.project_id, - "size": len(message.replay_video), - }, - ) - - # Record video size for COGS analysis. - metrics.incr("replays.recording_consumer.replay_video_count") - metrics.distribution( - "replays.recording_consumer.replay_video_size", - len(message.replay_video), - unit="byte", - ) - - dat = zlib.compress(pack(rrweb=segment.decompressed, video=message.replay_video)) - storage_kv.set(make_recording_filename(segment_data), dat) - - # Track combined payload size. - metrics.distribution( - "replays.recording_consumer.replay_video_event_size", len(dat), unit="byte" - ) - else: - storage_kv.set(make_recording_filename(segment_data), segment.compressed) - - recording_post_processor(message, headers, segment.decompressed, message.replay_event) - - # The first segment records an accepted outcome. This is for billing purposes. Subsequent - # segments are not billed. - if headers["segment_id"] == 0: - track_initial_segment_event( - message.org_id, - message.project_id, - message.replay_id, - message.key_id, - message.received, - is_replay_video=message.replay_video is not None, - ) - - @sentry_sdk.trace def track_initial_segment_event( org_id: int, diff --git a/src/sentry/rules/actions/notify_event_service.py b/src/sentry/rules/actions/notify_event_service.py index a4cd957629e22d..0ae67e244c3dad 100644 --- a/src/sentry/rules/actions/notify_event_service.py +++ b/src/sentry/rules/actions/notify_event_service.py @@ -11,7 +11,11 @@ from sentry.incidents.endpoints.serializers.incident import IncidentSerializer from sentry.incidents.models.alert_rule import AlertRuleTriggerAction from sentry.incidents.models.incident import Incident, IncidentStatus -from sentry.integrations.metric_alerts import incident_attachment_info +from sentry.integrations.metric_alerts import ( + AlertContext, + get_metric_count_from_incident, + incident_attachment_info, +) from sentry.integrations.services.integration import integration_service from sentry.organizations.services.organization.serial import serialize_rpc_organization from sentry.plugins.base import plugins @@ -31,7 +35,7 @@ def build_incident_attachment( incident: Incident, new_status: IncidentStatus, - metric_value: float, + metric_value: float | None = None, notification_uuid: str | None = None, ) -> dict[str, str]: from sentry.api.serializers.rest_framework.base import ( @@ -39,7 +43,20 @@ def build_incident_attachment( convert_dict_key_case, ) - data = incident_attachment_info(incident, new_status, metric_value, notification_uuid) + if metric_value is None: + metric_value = get_metric_count_from_incident(incident) + + data = incident_attachment_info( + AlertContext.from_alert_rule_incident(incident.alert_rule), + open_period_identifier=incident.identifier, + organization=incident.organization, + snuba_query=incident.alert_rule.snuba_query, + new_status=new_status, + metric_value=metric_value, + notification_uuid=notification_uuid, + referrer="metric_alert_sentry_app", + ) + return { "metric_alert": convert_dict_key_case( serialize(incident, serializer=IncidentSerializer()), camel_to_snake_case diff --git a/src/sentry/search/eap/columns.py b/src/sentry/search/eap/columns.py index 5a9501e05d3c50..fb046a71463487 100644 --- a/src/sentry/search/eap/columns.py +++ b/src/sentry/search/eap/columns.py @@ -4,6 +4,9 @@ from typing import Any from dateutil.tz import tz +from sentry_protos.snuba.v1.attribute_conditional_aggregation_pb2 import ( + AttributeConditionalAggregation, +) from sentry_protos.snuba.v1.endpoint_trace_item_table_pb2 import Column from sentry_protos.snuba.v1.request_common_pb2 import TraceItemType from sentry_protos.snuba.v1.trace_item_attribute_pb2 import ( @@ -102,25 +105,46 @@ class VirtualColumnDefinition: @dataclass(frozen=True, kw_only=True) -class ResolvedFormula(ResolvedAttribute): - formula: Column.BinaryFormula +class ResolvedFunction(ResolvedAttribute): + """ + A Function should be used as a non-attribute column, this means an aggregate or formula is a type of function. + The function is considered resolved when it can be passed in directly to the RPC (typically meaning arguments are resolved). + """ @property - def proto_definition(self) -> Column.BinaryFormula: - """The definition of this function as needed by the RPC""" - return self.formula + def proto_definition( + self, + ) -> Column.BinaryFormula | AttributeAggregation | AttributeConditionalAggregation: + raise NotImplementedError() @property def proto_type(self) -> AttributeKey.Type.ValueType: - """The rpc always returns functions as floats, especially count() even though it should be an integer - - see: https://www.notion.so/sentry/Should-count-return-an-int-in-the-v1-RPC-API-1348b10e4b5d80498bfdead194cc304e - """ return constants.DOUBLE @dataclass(frozen=True, kw_only=True) -class ResolvedFunction(ResolvedAttribute): +class ResolvedFormula(ResolvedFunction): + """ + A formula is a type of function that may accept a parameter, it divides an attribute, aggregate or formula by another. + The FormulaDefinition contains a method `resolve`, which takes in the argument passed into the function and returns the resolved formula. + For example if the user queries for `http_response_rate(5), the FormulaDefinition calles `resolve` with the argument `5` and returns the `ResolvedFormula`. + """ + + formula: Column.BinaryFormula + + @property + def proto_definition(self) -> Column.BinaryFormula: + """The definition of this function as needed by the RPC""" + return self.formula + + +@dataclass(frozen=True, kw_only=True) +class ResolvedAggregate(ResolvedFunction): + """ + An aggregate is the most primitive type of function, these are the ones that are availble via the RPC directly and contain no logic + Examples of this are `sum()` and `avg()`. + """ + # The internal rpc alias for this column internal_name: Function.ValueType # Whether to enable extrapolation @@ -140,18 +164,13 @@ def proto_definition(self) -> AttributeAggregation: ), ) - @property - def proto_type(self) -> AttributeKey.Type.ValueType: - """The rpc always returns functions as floats, especially count() even though it should be an integer - - see: https://www.notion.so/sentry/Should-count-return-an-int-in-the-v1-RPC-API-1348b10e4b5d80498bfdead194cc304e - """ - return constants.DOUBLE - -@dataclass +@dataclass(kw_only=True) class FunctionDefinition: - internal_function: Function.ValueType + """ + The FunctionDefinition is a base class for defining a function, a function is a non-attribute column. + """ + # The list of arguments for this function arguments: list[ArgumentDefinition] # The search_type the argument should be the default type for this column @@ -160,19 +179,32 @@ class FunctionDefinition: infer_search_type_from_arguments: bool = True # The internal rpc type for this function, optional as it can mostly be inferred from search_type internal_type: AttributeKey.Type.ValueType | None = None - # Processor is the function run in the post process step to transform a row into the final result - processor: Callable[[Any], Any] | None = None # Whether to request extrapolation or not, should be true for all functions except for _sample functions for debugging extrapolation: bool = True + # Processor is the function run in the post process step to transform a row into the final result + processor: Callable[[Any], Any] | None = None @property def required_arguments(self) -> list[ArgumentDefinition]: return [arg for arg in self.arguments if arg.default_arg is None and not arg.ignored] + def resolve( + self, + alias: str, + search_type: constants.SearchType, + resolved_argument: AttributeKey | Any | None, + ) -> ResolvedFormula | ResolvedAggregate: + raise NotImplementedError() + + +@dataclass(kw_only=True) +class AggregateDefinition(FunctionDefinition): + internal_function: Function.ValueType + def resolve( self, alias: str, search_type: constants.SearchType, resolved_argument: AttributeKey | None - ) -> ResolvedFunction: - return ResolvedFunction( + ) -> ResolvedAggregate: + return ResolvedAggregate( public_alias=alias, internal_name=self.internal_function, search_type=search_type, @@ -183,22 +215,10 @@ def resolve( ) -@dataclass -class FormulaDefinition: - # The list of arguments for this function - arguments: list[ArgumentDefinition] +@dataclass(kw_only=True) +class FormulaDefinition(FunctionDefinition): # A function that takes in the resolved argument and returns a Column.BinaryFormula - formula_resolver: Callable[[Any], Any] - # The search_type the argument should be the default type for this column - default_search_type: constants.SearchType - # Try to infer the search type from the function arguments - infer_search_type_from_arguments: bool = True - # The internal rpc type for this function, optional as it can mostly be inferred from search_type - internal_type: AttributeKey.Type.ValueType | None = None - # Processor is the function run in the post process step to transform a row into the final result - processor: Callable[[Any], Column.BinaryFormula] | None = None - # Whether to request extrapolation or not, should be true for all functions except for _sample functions for debugging - extrapolation: bool = True + formula_resolver: Callable[[Any], Column.BinaryFormula] @property def required_arguments(self) -> list[ArgumentDefinition]: @@ -270,7 +290,7 @@ def project_term_resolver( @dataclass(frozen=True) class ColumnDefinitions: - functions: dict[str, FunctionDefinition] + aggregates: dict[str, AggregateDefinition] formulas: dict[str, FormulaDefinition] columns: dict[str, ResolvedColumn] contexts: dict[str, VirtualColumnDefinition] diff --git a/src/sentry/search/eap/ourlog_columns.py b/src/sentry/search/eap/ourlog_columns.py index be3a492d3a2c44..c00cde047a60d8 100644 --- a/src/sentry/search/eap/ourlog_columns.py +++ b/src/sentry/search/eap/ourlog_columns.py @@ -73,7 +73,7 @@ OURLOG_DEFINITIONS = ColumnDefinitions( - functions={}, + aggregates={}, formulas={}, columns=OURLOG_ATTRIBUTE_DEFINITIONS, contexts=OURLOG_VIRTUAL_CONTEXTS, diff --git a/src/sentry/search/eap/resolver.py b/src/sentry/search/eap/resolver.py index 9c6e6536aa48f7..f4d4233c299482 100644 --- a/src/sentry/search/eap/resolver.py +++ b/src/sentry/search/eap/resolver.py @@ -36,12 +36,12 @@ from sentry.exceptions import InvalidSearchQuery from sentry.search.eap import constants from sentry.search.eap.columns import ( + AggregateDefinition, ColumnDefinitions, FormulaDefinition, - FunctionDefinition, + ResolvedAggregate, ResolvedColumn, ResolvedFormula, - ResolvedFunction, VirtualColumnDefinition, ) from sentry.search.eap.types import SearchResolverConfig @@ -65,7 +65,7 @@ class SearchResolver: field(default_factory=dict) ) _resolved_function_cache: dict[ - str, tuple[ResolvedFunction | ResolvedFormula, VirtualColumnDefinition | None] + str, tuple[ResolvedFormula | ResolvedAggregate, VirtualColumnDefinition | None] ] = field(default_factory=dict) @sentry_sdk.trace @@ -553,7 +553,7 @@ def resolve_contexts( @sentry_sdk.trace def resolve_columns(self, selected_columns: list[str]) -> tuple[ - list[ResolvedColumn | ResolvedFunction | ResolvedFormula], + list[ResolvedColumn | ResolvedAggregate | ResolvedFormula], list[VirtualColumnDefinition | None], ]: """Given a list of columns resolve them and get their context if applicable @@ -590,12 +590,14 @@ def resolve_columns(self, selected_columns: list[str]) -> tuple[ def resolve_column( self, column: str, match: Match | None = None - ) -> tuple[ResolvedColumn | ResolvedFunction | ResolvedFormula, VirtualColumnDefinition | None]: + ) -> tuple[ + ResolvedColumn | ResolvedAggregate | ResolvedFormula, VirtualColumnDefinition | None + ]: """Column is either an attribute or an aggregate, this function will determine which it is and call the relevant resolve function""" match = fields.is_function(column) if match: - return self.resolve_aggregate(column, match) + return self.resolve_function(column, match) else: return self.resolve_attribute(column) @@ -670,27 +672,27 @@ def resolve_attribute( raise InvalidSearchQuery(f"Could not parse {column}") @sentry_sdk.trace - def resolve_aggregates( + def resolve_functions( self, columns: list[str] - ) -> tuple[list[ResolvedFunction | ResolvedFormula], list[VirtualColumnDefinition | None]]: - """Helper function to resolve a list of aggregates instead of 1 attribute at a time""" - resolved_aggregates, resolved_contexts = [], [] + ) -> tuple[list[ResolvedFormula | ResolvedAggregate], list[VirtualColumnDefinition | None]]: + """Helper function to resolve a list of functions instead of 1 attribute at a time""" + resolved_functions, resolved_contexts = [], [] for column in columns: - aggregate, context = self.resolve_aggregate(column) - resolved_aggregates.append(aggregate) + function, context = self.resolve_function(column) + resolved_functions.append(function) resolved_contexts.append(context) - return resolved_aggregates, resolved_contexts + return resolved_functions, resolved_contexts - def resolve_aggregate( + def resolve_function( self, column: str, match: Match | None = None - ) -> tuple[ResolvedFunction | ResolvedFormula, VirtualColumnDefinition | None]: + ) -> tuple[ResolvedFormula | ResolvedAggregate, VirtualColumnDefinition | None]: if column in self._resolved_function_cache: return self._resolved_function_cache[column] # Check if the column looks like a function (matches a pattern), parse the function name and args out if match is None: match = fields.is_function(column) if match is None: - raise InvalidSearchQuery(f"{column} is not an aggregate") + raise InvalidSearchQuery(f"{column} is not a function") function = match.group("function") columns = match.group("columns") @@ -698,9 +700,9 @@ def resolve_aggregate( alias = match.group("alias") or column # Get the function definition - function_definition: FunctionDefinition | FormulaDefinition - if function in self.definitions.functions: - function_definition = self.definitions.functions[function] + function_definition: AggregateDefinition | FormulaDefinition + if function in self.definitions.aggregates: + function_definition = self.definitions.aggregates[function] elif function in self.definitions.formulas: function_definition = self.definitions.formulas[function] else: diff --git a/src/sentry/search/eap/span_columns.py b/src/sentry/search/eap/span_columns.py index fa4f46b7a27138..181c889715e567 100644 --- a/src/sentry/search/eap/span_columns.py +++ b/src/sentry/search/eap/span_columns.py @@ -19,10 +19,10 @@ from sentry.exceptions import InvalidSearchQuery from sentry.search.eap import constants from sentry.search.eap.columns import ( + AggregateDefinition, ArgumentDefinition, ColumnDefinitions, FormulaDefinition, - FunctionDefinition, ResolvedColumn, VirtualColumnDefinition, datetime_processor, @@ -471,8 +471,8 @@ def _validator(input: str) -> bool: "http_response_rate": http_response_rate } -SPAN_FUNCTION_DEFINITIONS = { - "sum": FunctionDefinition( +SPAN_AGGREGATE_DEFINITIONS = { + "sum": AggregateDefinition( internal_function=Function.FUNCTION_SUM, default_search_type="duration", arguments=[ @@ -487,7 +487,7 @@ def _validator(input: str) -> bool: ) ], ), - "avg": FunctionDefinition( + "avg": AggregateDefinition( internal_function=Function.FUNCTION_AVG, default_search_type="duration", arguments=[ @@ -503,7 +503,7 @@ def _validator(input: str) -> bool: ) ], ), - "avg_sample": FunctionDefinition( + "avg_sample": AggregateDefinition( internal_function=Function.FUNCTION_AVG, default_search_type="duration", arguments=[ @@ -520,7 +520,7 @@ def _validator(input: str) -> bool: ], extrapolation=False, ), - "count": FunctionDefinition( + "count": AggregateDefinition( internal_function=Function.FUNCTION_COUNT, infer_search_type_from_arguments=False, default_search_type="integer", @@ -537,7 +537,7 @@ def _validator(input: str) -> bool: ) ], ), - "count_sample": FunctionDefinition( + "count_sample": AggregateDefinition( internal_function=Function.FUNCTION_COUNT, infer_search_type_from_arguments=False, default_search_type="integer", @@ -555,7 +555,7 @@ def _validator(input: str) -> bool: ], extrapolation=False, ), - "p50": FunctionDefinition( + "p50": AggregateDefinition( internal_function=Function.FUNCTION_P50, default_search_type="duration", arguments=[ @@ -570,7 +570,7 @@ def _validator(input: str) -> bool: ) ], ), - "p50_sample": FunctionDefinition( + "p50_sample": AggregateDefinition( internal_function=Function.FUNCTION_P50, default_search_type="duration", arguments=[ @@ -586,7 +586,7 @@ def _validator(input: str) -> bool: ], extrapolation=False, ), - "p75": FunctionDefinition( + "p75": AggregateDefinition( internal_function=Function.FUNCTION_P75, default_search_type="duration", arguments=[ @@ -601,7 +601,7 @@ def _validator(input: str) -> bool: ) ], ), - "p90": FunctionDefinition( + "p90": AggregateDefinition( internal_function=Function.FUNCTION_P90, default_search_type="duration", arguments=[ @@ -616,7 +616,7 @@ def _validator(input: str) -> bool: ) ], ), - "p95": FunctionDefinition( + "p95": AggregateDefinition( internal_function=Function.FUNCTION_P95, default_search_type="duration", arguments=[ @@ -631,7 +631,7 @@ def _validator(input: str) -> bool: ) ], ), - "p99": FunctionDefinition( + "p99": AggregateDefinition( internal_function=Function.FUNCTION_P99, default_search_type="duration", arguments=[ @@ -646,7 +646,7 @@ def _validator(input: str) -> bool: ) ], ), - "p100": FunctionDefinition( + "p100": AggregateDefinition( internal_function=Function.FUNCTION_MAX, default_search_type="duration", arguments=[ @@ -661,7 +661,7 @@ def _validator(input: str) -> bool: ) ], ), - "max": FunctionDefinition( + "max": AggregateDefinition( internal_function=Function.FUNCTION_MAX, default_search_type="duration", arguments=[ @@ -677,7 +677,7 @@ def _validator(input: str) -> bool: ) ], ), - "min": FunctionDefinition( + "min": AggregateDefinition( internal_function=Function.FUNCTION_MIN, default_search_type="duration", arguments=[ @@ -693,7 +693,7 @@ def _validator(input: str) -> bool: ) ], ), - "count_unique": FunctionDefinition( + "count_unique": AggregateDefinition( internal_function=Function.FUNCTION_UNIQ, default_search_type="integer", infer_search_type_from_arguments=False, @@ -721,7 +721,7 @@ def _validator(input: str) -> bool: } SPAN_DEFINITIONS = ColumnDefinitions( - functions=SPAN_FUNCTION_DEFINITIONS, + aggregates=SPAN_AGGREGATE_DEFINITIONS, formulas=SPAN_FORMULA_DEFINITIONS, columns=SPAN_ATTRIBUTE_DEFINITIONS, contexts=SPAN_VIRTUAL_CONTEXTS, diff --git a/src/sentry/search/eap/uptime_check_columns.py b/src/sentry/search/eap/uptime_check_columns.py index 6e91643cdf87a0..2c6f5360a01f56 100644 --- a/src/sentry/search/eap/uptime_check_columns.py +++ b/src/sentry/search/eap/uptime_check_columns.py @@ -86,7 +86,7 @@ UPTIME_CHECK_DEFINITIONS = ColumnDefinitions( - functions={}, + aggregates={}, formulas={}, columns=UPTIME_CHECK_ATTRIBUTE_DEFINITIONS, contexts=UPTIME_CHECK_VIRTUAL_CONTEXTS, diff --git a/src/sentry/sentry_apps/models/sentry_app_installation.py b/src/sentry/sentry_apps/models/sentry_app_installation.py index 989c3a6c43e348..7b4a7b5a1fbed2 100644 --- a/src/sentry/sentry_apps/models/sentry_app_installation.py +++ b/src/sentry/sentry_apps/models/sentry_app_installation.py @@ -5,7 +5,6 @@ from typing import TYPE_CHECKING, Any, ClassVar, overload from django.db import models -from django.db.models import QuerySet from django.utils import timezone from jsonschema import ValidationError @@ -14,7 +13,9 @@ from sentry.constants import SentryAppInstallationStatus from sentry.db.models import BoundedPositiveIntegerField, FlexibleForeignKey, control_silo_model from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey +from sentry.db.models.manager.base_query_set import BaseQuerySet from sentry.db.models.paranoia import ParanoidManager, ParanoidModel +from sentry.hybridcloud.models.outbox import ControlOutboxBase, outbox_context from sentry.hybridcloud.outbox.base import ReplicatedControlModel from sentry.hybridcloud.outbox.category import OutboxCategory from sentry.projects.services.project import RpcProject @@ -27,14 +28,11 @@ from sentry.types.region import find_regions_for_orgs if TYPE_CHECKING: - from sentry.models.apitoken import ApiToken - from sentry.sentry_apps.models.sentry_app_component import SentryAppComponent from sentry.models.project import Project - -from sentry.hybridcloud.models.outbox import ControlOutboxBase, outbox_context + from sentry.sentry_apps.models.sentry_app_component import SentryAppComponent -def default_uuid(): +def default_uuid() -> str: return str(uuid.uuid4()) @@ -46,17 +44,19 @@ def get_organization_filter_kwargs(self, organization_ids: list[int]): "date_deleted": None, } - def get_installed_for_organization(self, organization_id: int) -> QuerySet: + def get_installed_for_organization( + self, organization_id: int + ) -> BaseQuerySet[SentryAppInstallation]: return self.filter(**self.get_organization_filter_kwargs([organization_id])) - def get_by_api_token(self, token_id: int) -> QuerySet: + def get_by_api_token(self, token_id: int) -> BaseQuerySet[SentryAppInstallation]: return self.filter(status=SentryAppInstallationStatus.INSTALLED, api_token_id=token_id) - def get_projects(self, token: ApiToken | AuthenticatedToken) -> QuerySet[Project]: + def get_projects(self, token: AuthenticatedToken | None) -> BaseQuerySet[Project]: from sentry.models.apitoken import is_api_token_auth from sentry.models.project import Project - if not is_api_token_auth(token) or token.organization_id is None: + if token is None or not is_api_token_auth(token) or token.organization_id is None: return Project.objects.none() return Project.objects.filter(organization_id=token.organization_id) diff --git a/src/sentry/snuba/rpc_dataset_common.py b/src/sentry/snuba/rpc_dataset_common.py index 47cb167b2e7e73..47e3b852e645d5 100644 --- a/src/sentry/snuba/rpc_dataset_common.py +++ b/src/sentry/snuba/rpc_dataset_common.py @@ -5,7 +5,7 @@ from sentry_protos.snuba.v1.request_common_pb2 import PageToken from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeAggregation, AttributeKey -from sentry.search.eap.columns import ResolvedColumn, ResolvedFormula, ResolvedFunction +from sentry.search.eap.columns import ResolvedAggregate, ResolvedColumn, ResolvedFormula from sentry.search.eap.resolver import SearchResolver from sentry.search.eap.types import CONFIDENCES, ConfidenceData, EAPResponse from sentry.search.events.fields import get_function_alias @@ -16,10 +16,10 @@ logger = logging.getLogger("sentry.snuba.spans_rpc") -def categorize_column(column: ResolvedColumn | ResolvedFunction | ResolvedFormula) -> Column: +def categorize_column(column: ResolvedColumn | ResolvedAggregate | ResolvedFormula) -> Column: if isinstance(column, ResolvedFormula): return Column(formula=column.proto_definition, label=column.public_alias) - if isinstance(column, ResolvedFunction): + if isinstance(column, ResolvedAggregate): return Column(aggregation=column.proto_definition, label=column.public_alias) else: return Column(key=column.proto_definition, label=column.public_alias) diff --git a/src/sentry/snuba/spans_rpc.py b/src/sentry/snuba/spans_rpc.py index 3cda748c1901bd..9f76bcc446f973 100644 --- a/src/sentry/snuba/spans_rpc.py +++ b/src/sentry/snuba/spans_rpc.py @@ -13,7 +13,7 @@ from sentry.api.event_search import SearchFilter, SearchKey, SearchValue from sentry.exceptions import InvalidSearchQuery -from sentry.search.eap.columns import ResolvedColumn, ResolvedFormula, ResolvedFunction +from sentry.search.eap.columns import ResolvedAggregate, ResolvedColumn, ResolvedFormula from sentry.search.eap.constants import DOUBLE, INT, MAX_ROLLUP_POINTS, STRING, VALID_GRANULARITIES from sentry.search.eap.resolver import SearchResolver from sentry.search.eap.span_columns import SPAN_DEFINITIONS @@ -76,11 +76,11 @@ def get_timeseries_query( config: SearchResolverConfig, granularity_secs: int, extra_conditions: TraceItemFilter | None = None, -) -> tuple[TimeSeriesRequest, list[ResolvedFunction | ResolvedFormula], list[ResolvedColumn]]: +) -> tuple[TimeSeriesRequest, list[ResolvedFormula | ResolvedAggregate], list[ResolvedColumn]]: resolver = get_resolver(params=params, config=config) meta = resolver.resolve_meta(referrer=referrer) query, _, query_contexts = resolver.resolve_query(query_string) - (aggregations, _) = resolver.resolve_aggregates(y_axes) + (aggregations, _) = resolver.resolve_functions(y_axes) (groupbys, _) = resolver.resolve_attributes(groupby) if extra_conditions is not None: if query is not None: diff --git a/src/sentry/tasks/release_registry.py b/src/sentry/tasks/release_registry.py index b8b3d48e2b620f..a817836438c23b 100644 --- a/src/sentry/tasks/release_registry.py +++ b/src/sentry/tasks/release_registry.py @@ -1,5 +1,6 @@ import logging +import sentry_sdk from django.conf import settings from django.core.cache import cache @@ -47,6 +48,11 @@ def fetch_release_registry_data(**kwargs): More details about the registry: https://github.com/getsentry/sentry-release-registry/ """ + logger.info( + "release_registry.fetch.starting", + extra={"release_registry_baseurl": str(settings.SENTRY_RELEASE_REGISTRY_BASEURL)}, + ) + if not settings.SENTRY_RELEASE_REGISTRY_BASEURL: logger.warning("Release registry URL is not specified, skipping the task.") return @@ -61,4 +67,13 @@ def fetch_release_registry_data(**kwargs): # AWS Layers layer_data = _fetch_registry_url("/aws-lambda-layers") + logger.info( + "release_registry.fetch.aws-lambda-layers", + extra={"layer_data": layer_data}, + ) cache.set(LAYER_INDEX_CACHE_KEY, layer_data, CACHE_TTL) + logger.info( + "release_registry.fetch.aws-lambda-layers", + extra={"layer_cache": cache.get(LAYER_INDEX_CACHE_KEY)}, + ) + sentry_sdk.capture_message("Fetching release registry") diff --git a/src/sentry/testutils/helpers/backups.py b/src/sentry/testutils/helpers/backups.py index ced626790e5271..2231f5f85d2f26 100644 --- a/src/sentry/testutils/helpers/backups.py +++ b/src/sentry/testutils/helpers/backups.py @@ -74,6 +74,7 @@ from sentry.models.groupassignee import GroupAssignee from sentry.models.groupbookmark import GroupBookmark from sentry.models.groupsearchview import GroupSearchView, GroupSearchViewProject +from sentry.models.groupsearchviewlastvisited import GroupSearchViewLastVisited from sentry.models.groupsearchviewstarred import GroupSearchViewStarred from sentry.models.groupseen import GroupSeen from sentry.models.groupshare import GroupShare @@ -624,6 +625,12 @@ def create_exhaustive_organization( group_search_view=group_search_view, project=project, ) + GroupSearchViewLastVisited.objects.create( + organization=org, + user_id=owner_id, + group_search_view=group_search_view, + last_visited=timezone.now(), + ) GroupSearchViewStarred.objects.create( organization=org, user_id=owner_id, diff --git a/src/sentry/utils/event_frames.py b/src/sentry/utils/event_frames.py index 600ae39064e3e0..c0955217df5a70 100644 --- a/src/sentry/utils/event_frames.py +++ b/src/sentry/utils/event_frames.py @@ -42,8 +42,12 @@ def java_frame_munger(frame: EventFrame) -> str | None: if not frame.filename or not frame.module: return None - if "$$" in frame.module: - return frame.module.split("$$")[0].replace(".", "/") + ".java" + if "$" in frame.module: + path = frame.module.split("$")[0].replace(".", "/") + if frame.abs_path and frame.abs_path.count(".") == 1: + # Append extension + path = path + "." + frame.abs_path.split(".")[-1] + return path if "/" not in str(frame.filename) and frame.module: # Replace the last module segment with the filename, as the diff --git a/static/app/components/assistant/guideAnchor.spec.tsx b/static/app/components/assistant/guideAnchor.spec.tsx index 6dc8e65ffa972a..36ae08780481e0 100644 --- a/static/app/components/assistant/guideAnchor.spec.tsx +++ b/static/app/components/assistant/guideAnchor.spec.tsx @@ -90,7 +90,7 @@ describe('GuideAnchor', function () { url: '/assistant/', }); - await userEvent.click(screen.getByLabelText('Dismiss')); + await userEvent.click(screen.getByRole('button', {name: 'Close'})); expect(dismissMock).toHaveBeenCalledWith( '/assistant/', diff --git a/static/app/components/assistant/guideAnchor.tsx b/static/app/components/assistant/guideAnchor.tsx index 264cc996980abb..9ab892ee3fc7c6 100644 --- a/static/app/components/assistant/guideAnchor.tsx +++ b/static/app/components/assistant/guideAnchor.tsx @@ -12,12 +12,12 @@ import { unregisterAnchor, } from 'sentry/actionCreators/guides'; import type {Guide} from 'sentry/components/assistant/types'; -import {Button, LinkButton} from 'sentry/components/button'; -import {Hovercard} from 'sentry/components/hovercard'; -import {t, tct} from 'sentry/locale'; +import ButtonBar from 'sentry/components/buttonBar'; +import type {Hovercard} from 'sentry/components/hovercard'; +import {TourAction, TourGuide} from 'sentry/components/tours/components'; +import {t} from 'sentry/locale'; import type {GuideStoreState} from 'sentry/stores/guideStore'; import GuideStore from 'sentry/stores/guideStore'; -import {space} from 'sentry/styles/space'; import type {Organization} from 'sentry/types/organization'; type Props = { @@ -145,102 +145,52 @@ class BaseGuideAnchor extends Component { } }; - getHovercardBody() { - const {to} = this.props; - const {currentGuide, step} = this.state; - if (!currentGuide) { - return null; + render() { + const {children, position, offset, containerClassName, to} = this.props; + const {active, currentGuide, step} = this.state; + + if (!active) { + return children ? children : null; } - const totalStepCount = currentGuide.steps.length; + const totalStepCount = currentGuide?.steps.length ?? 0; const currentStepCount = step + 1; - const currentStep = currentGuide.steps[step]!; + const currentStep = currentGuide?.steps[step]!; const lastStep = currentStepCount === totalStepCount; const hasManySteps = totalStepCount > 1; - // to clear `#assistant` from the url - const href = window.location.hash === '#assistant' ? '#' : ''; - - const dismissButton = ( - - {currentStep.dismissText || t('Dismiss')} - - ); - return ( - - - {currentStep.title && {currentStep.title}} - {currentStep.description} - - -
+ { + this.handleDismiss(e); + window.location.hash = ''; + }} + wrapperComponent={GuideAnchorWrapper} + actions={ + {lastStep ? ( - - - {currentStep.nextText || - (hasManySteps ? t('Enough Already') : t('Got It'))} - - {currentStep.hasNextGuide && dismissButton} - + + {currentStep.nextText || + (hasManySteps ? t('Enough Already') : t('Got It'))} + ) : ( - - - {currentStep.nextText || t('Next')} - - {!currentStep.cantDismiss && dismissButton} - + + {currentStep.nextText || t('Next')} + )} -
- - {hasManySteps && ( - - {tct('[currentStepCount] of [totalStepCount]', { - currentStepCount, - totalStepCount, - })} - - )} -
-
- ); - } - - render() { - const {children, position, offset, containerClassName} = this.props; - const {active} = this.state; - - if (!active) { - return children ? children : null; - } - - return ( - + } + className={containerClassName} position={position} offset={offset} - containerClassName={containerClassName} > {children} - + ); } } @@ -266,75 +216,9 @@ function GuideAnchor({disabled, children, ...rest}: WrapperProps) { return {children}; } -export default GuideAnchor; - -const GuideContainer = styled('div')` - display: grid; - grid-template-rows: repeat(2, auto); - gap: ${space(2)}; - text-align: center; - line-height: 1.5; - background-color: ${p => p.theme.purple300}; - border-color: ${p => p.theme.purple300}; - color: ${p => p.theme.white}; - - a { - :hover { - color: ${p => p.theme.white}; - } - } -`; - -const GuideContent = styled('div')` - display: grid; - grid-template-rows: repeat(2, auto); - gap: ${space(1)}; - - a { - color: ${p => p.theme.white}; - text-decoration: underline; - } -`; - -const GuideTitle = styled('div')` - font-weight: ${p => p.theme.fontWeightBold}; - font-size: ${p => p.theme.fontSizeExtraLarge}; -`; - -const GuideDescription = styled('div')` - font-size: ${p => p.theme.fontSizeMedium}; -`; - -const GuideAction = styled('div')` - display: grid; - grid-template-rows: repeat(2, auto); - gap: ${space(1)}; -`; - -const StyledButton = styled(Button)` - font-size: ${p => p.theme.fontSizeMedium}; - min-width: 40%; +const GuideAnchorWrapper = styled('span')` + display: inline-block; + max-width: 100%; `; -const DismissButton = styled(LinkButton)` - font-size: ${p => p.theme.fontSizeMedium}; - min-width: 40%; - margin-left: ${space(1)}; - - &:hover, - &:focus, - &:active { - color: ${p => p.theme.white}; - } - color: ${p => p.theme.white}; -`; - -const StepCount = styled('div')` - font-size: ${p => p.theme.fontSizeSmall}; - font-weight: ${p => p.theme.fontWeightBold}; - text-transform: uppercase; -`; - -const StyledHovercard = styled(Hovercard)` - background-color: ${p => p.theme.purple300}; -`; +export default GuideAnchor; diff --git a/static/app/components/badge/groupPriority.tsx b/static/app/components/badge/groupPriority.tsx index a1da5d6339089a..4aa53f1562c649 100644 --- a/static/app/components/badge/groupPriority.tsx +++ b/static/app/components/badge/groupPriority.tsx @@ -1,5 +1,4 @@ import {Fragment, useMemo} from 'react'; -import type {Theme} from '@emotion/react'; import styled from '@emotion/styled'; import {VisuallyHidden} from '@react-aria/visually-hidden'; @@ -9,7 +8,7 @@ import {usePrompt} from 'sentry/actionCreators/prompts'; import {IconCellSignal} from 'sentry/components/badge/iconCellSignal'; import {Button, LinkButton} from 'sentry/components/button'; import {Chevron} from 'sentry/components/chevron'; -import {Tag} from 'sentry/components/core/badge/tag'; +import {Tag, type TagProps} from 'sentry/components/core/badge/tag'; import type {MenuItemProps} from 'sentry/components/dropdownMenu'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; import {DropdownMenuFooter} from 'sentry/components/dropdownMenu/footer'; @@ -48,7 +47,7 @@ const PRIORITY_KEY_TO_LABEL: Record = { const PRIORITY_OPTIONS = [PriorityLevel.HIGH, PriorityLevel.MEDIUM, PriorityLevel.LOW]; -function getTagTypeForPriority(priority: string): keyof Theme['tag'] { +function getTagTypeForPriority(priority: string): TagProps['type'] { switch (priority) { case PriorityLevel.HIGH: return 'error'; diff --git a/static/app/components/charts/utils.tsx b/static/app/components/charts/utils.tsx index 9d99fa5e142c40..a2e31fbbe1e1f5 100644 --- a/static/app/components/charts/utils.tsx +++ b/static/app/components/charts/utils.tsx @@ -118,7 +118,14 @@ export class GranularityLadder { } } -export type Fidelity = 'high' | 'medium' | 'low' | 'metrics' | 'issues' | 'spans'; +export type Fidelity = + | 'high' + | 'medium' + | 'low' + | 'metrics' + | 'issues' + | 'spans' + | 'spans-low'; export function getInterval(datetimeObj: DateTimeObject, fidelity: Fidelity = 'medium') { const diffInMinutes = getDiffInMinutes(datetimeObj); @@ -130,6 +137,7 @@ export function getInterval(datetimeObj: DateTimeObject, fidelity: Fidelity = 'm metrics: metricsFidelityLadder, issues: issuesFidelityLadder, spans: spansFidelityLadder, + 'spans-low': spansLowFidelityLadder, }[fidelity].getInterval(diffInMinutes); } @@ -192,6 +200,17 @@ const spansFidelityLadder = new GranularityLadder([ [0, '1m'], ]); +const spansLowFidelityLadder = new GranularityLadder([ + [THIRTY_DAYS, '1d'], + [TWO_WEEKS, '12h'], + [ONE_WEEK, '4h'], + [FORTY_EIGHT_HOURS, '2h'], + [TWENTY_FOUR_HOURS, '1h'], + [SIX_HOURS, '30m'], + [ONE_HOUR, '10m'], + [0, '5m'], +]); + /** * Duplicate of getInterval, except that we do not support <1h granularity * Used by OrgStatsV2 API diff --git a/static/app/components/core/badge/index.chonk.tsx b/static/app/components/core/badge/index.chonk.tsx index 6878ee3fb7f7dc..76b5f28b24f0c4 100644 --- a/static/app/components/core/badge/index.chonk.tsx +++ b/static/app/components/core/badge/index.chonk.tsx @@ -50,26 +50,22 @@ function makeChonkBadgeTheme( case 'alpha': return { color: theme.colors.static.black, - // @TODO(jonasbadalic) should this use theme colors? - background: `linear-gradient(103deg, #EE8019 0%, #FAA80A 25%, #FBB20B 50%, #FAA80A 75%, #EE8019 100%);`, + background: theme.colors.static.pink400, }; case 'beta': return { - color: theme.colors.static.white, - // @TODO(jonasbadalic) should this use theme colors? - background: `linear-gradient(103deg, #FC8B61 0%, #FC5F64 50%, #F32474 100%);`, + color: theme.colors.static.black, + background: theme.colors.static.yellow400, }; case 'new': return { - color: theme.colors.static.white, - // @TODO(jonasbadalic) should this use theme colors? - background: `linear-gradient(103deg, #7B51F8 0%, #F644AB 100%);`, + color: theme.colors.static.black, + background: theme.colors.static.green400, }; case 'experimental': return { - color: theme.colors.static.white, - // @TODO(jonasbadalic) should this use theme colors? - background: `linear-gradient(103deg, #4E2A9A 0%, #7C30A9 25%, #A737B4 50%, #F2306F 75%, #EE8019 100%);`, + color: theme.colors.dynamic.grayTransparent400, + background: theme.colors.dynamic.surface300, }; // End feature badge variants case 'default': @@ -77,35 +73,33 @@ function makeChonkBadgeTheme( background: theme.colors.dynamic.surface300, color: theme.colors.dynamic.grayTransparent400, }; + + // Highlight maps to info badge for now, but the highlight variant should be removed + case 'highlight': case 'info': return { - background: theme.colors.static.blue400, - color: theme.colors.static.white, + background: theme.colors.dynamic.surface300, + color: theme.colors.dynamic.blue400, }; case 'success': return { - background: theme.colors.static.green400, - color: theme.colors.static.black, + background: theme.colors.dynamic.surface300, + color: theme.colors.dynamic.green400, }; case 'warning': return { - background: theme.colors.static.yellow400, - color: theme.colors.static.black, + background: theme.colors.dynamic.surface300, + color: theme.colors.dynamic.yellow400, }; case 'danger': return { - background: theme.colors.static.red400, - color: theme.colors.static.white, + background: theme.colors.dynamic.surface300, + color: theme.colors.dynamic.red400, }; case 'promotion': return { - background: theme.colors.static.pink400, - color: theme.colors.static.black, - }; - case 'highlight': - return { - background: theme.colors.dynamic.blue400, - color: theme.colors.static.white, + background: theme.colors.dynamic.surface300, + color: theme.colors.dynamic.pink400, }; default: unreachable(p.type); diff --git a/static/app/components/core/badge/tag.chonk.tsx b/static/app/components/core/badge/tag.chonk.tsx new file mode 100644 index 00000000000000..66fecc1cf63c76 --- /dev/null +++ b/static/app/components/core/badge/tag.chonk.tsx @@ -0,0 +1,90 @@ +import type {DO_NOT_USE_ChonkTheme} from '@emotion/react'; + +import type {TagProps} from 'sentry/components/core/badge/tag'; +import {space} from 'sentry/styles/space'; +import {chonkStyled} from 'sentry/utils/theme/theme.chonk'; +import {unreachable} from 'sentry/utils/unreachable'; + +type TagType = 'default' | 'info' | 'success' | 'warning' | 'danger' | 'promotion'; + +interface ChonkTagProps extends Omit { + type?: TagType; +} + +const legacyMapping: Partial, TagType>> = { + highlight: 'info', + error: 'danger', + white: 'default', + black: 'default', +}; + +export function chonkTagPropMapping(props: TagProps): ChonkTagProps { + return { + ...props, + type: props.type && legacyMapping[props.type], + }; +} + +export const TagPill = chonkStyled('div')<{ + type?: TagType; +}>` + ${p => ({...makeTagPillTheme(p.type, p.theme)})}; + + font-size: ${p => p.theme.fontSizeSmall}; + display: inline-flex; + align-items: center; + height: 20px; + border-radius: 20px; + padding: 0 ${space(1)}; + max-width: 166px; + + /* @TODO(jonasbadalic): We need to override button colors because they wrongly default to a blue color... */ + button, + button:hover { + color: currentColor; + } +`; + +function makeTagPillTheme( + type: TagType | undefined, + theme: DO_NOT_USE_ChonkTheme +): React.CSSProperties { + switch (type) { + case undefined: + case 'default': + return { + background: theme.colors.dynamic.surface300, + color: theme.colors.dynamic.grayTransparent400, + }; + + // Highlight maps to info badge for now, but the highlight variant should be removed + case 'info': + return { + background: theme.colors.dynamic.surface300, + color: theme.colors.dynamic.blue400, + }; + case 'success': + return { + background: theme.colors.dynamic.surface300, + color: theme.colors.dynamic.green400, + }; + case 'warning': + return { + background: theme.colors.dynamic.surface300, + color: theme.colors.dynamic.yellow400, + }; + case 'danger': + return { + background: theme.colors.dynamic.surface300, + color: theme.colors.dynamic.red400, + }; + case 'promotion': + return { + background: theme.colors.dynamic.surface300, + color: theme.colors.dynamic.pink400, + }; + default: + unreachable(type); + throw new TypeError(`Unsupported badge type: ${type}`); + } +} diff --git a/static/app/components/core/badge/tag.stories.tsx b/static/app/components/core/badge/tag.stories.tsx index f4651577488b5d..978c5eaddf928c 100644 --- a/static/app/components/core/badge/tag.stories.tsx +++ b/static/app/components/core/badge/tag.stories.tsx @@ -28,8 +28,6 @@ export default storyBook('Tag', (story, APIReference) => { Error Warning Info - Black - White Promotion Highlight diff --git a/static/app/components/core/badge/tag.tsx b/static/app/components/core/badge/tag.tsx index 186aab8b5df980..72d5c140b0bccb 100644 --- a/static/app/components/core/badge/tag.tsx +++ b/static/app/components/core/badge/tag.tsx @@ -6,18 +6,19 @@ import {IconClose} from 'sentry/icons'; import {IconDefaultsProvider} from 'sentry/icons/useIconDefaults'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; +import {withChonk} from 'sentry/utils/theme/withChonk'; + +import * as ChonkTag from './tag.chonk'; type TagType = // @TODO(jonasbadalic): "default" is a bad API naming - | 'default' - | 'promotion' - | 'highlight' - | 'warning' - | 'success' - | 'error' - | 'info' - | 'white' - | 'black'; + 'default' | 'info' | 'success' | 'warning' | 'error' | 'promotion' | 'highlight'; + +/** + * @deprecated Do not use these tag types + */ +type DeprecatedTagType = 'white' | 'black'; + export interface TagProps extends React.HTMLAttributes { /** * Icon on the left side. @@ -30,7 +31,7 @@ export interface TagProps extends React.HTMLAttributes { /** * Dictates color scheme of the tag. */ - type?: TagType; + type?: TagType | DeprecatedTagType; } export const Tag = forwardRef( @@ -64,8 +65,8 @@ export const Tag = forwardRef( } ); -const StyledTag = styled('div')<{ - type: TagType; +const TagPill = styled('div')<{ + type: NonNullable; }>` font-size: ${p => p.theme.fontSizeSmall}; background-color: ${p => p.theme.tag[p.type].background}; @@ -85,6 +86,8 @@ const StyledTag = styled('div')<{ } `; +const StyledTag = withChonk(TagPill, ChonkTag.TagPill, ChonkTag.chonkTagPropMapping); + const Text = styled('div')` overflow: hidden; white-space: nowrap; diff --git a/static/app/components/core/checkbox/index.chonk.tsx b/static/app/components/core/checkbox/index.chonk.tsx new file mode 100644 index 00000000000000..343349dfbd0263 --- /dev/null +++ b/static/app/components/core/checkbox/index.chonk.tsx @@ -0,0 +1,48 @@ +import {chonkStyled} from 'sentry/utils/theme/theme.chonk'; + +export const ChonkNativeHiddenCheckbox = chonkStyled('input')` + position: absolute; + opacity: 0; + top: 0; + left: 0; + height: 100%; + width: 100%; + margin: 0; + padding: 0; + cursor: pointer; + + & + * { + color: ${p => p.theme.colors.static.white}; + border: 1px solid ${p => p.theme.colors.dynamic.surface100}; + + svg { + stroke: ${p => p.theme.colors.static.white}; + } + } + + &:focus-visible + * { + outline: none; + box-shadow: 0 0 0 2px ${p => p.theme.background}, + 0 0 0 4px ${p => p.theme.focusBorder}; + } + + &:disabled + *, + &[aria-disabled='true'] + * { + background-color: ${p => p.theme.colors.dynamic.surface500}; + border: 1px solid ${p => p.theme.colors.dynamic.surface100}; + cursor: not-allowed; + } + + &:checked + *, + &:indeterminate + * { + background-color: ${p => p.theme.colors.static.blue400}; + color: ${p => p.theme.white}; + } + + &:disabled:checked + *, + &:disabled:indeterminate + * { + background-color: ${p => p.theme.colors.static.blue400}; + border: 1px solid ${p => p.theme.colors.static.blue400}; + opacity: 0.6; + } +`; diff --git a/static/app/components/core/checkbox/index.tsx b/static/app/components/core/checkbox/index.tsx index 6b7b07ff9e40b8..18e07930318aa5 100644 --- a/static/app/components/core/checkbox/index.tsx +++ b/static/app/components/core/checkbox/index.tsx @@ -4,6 +4,9 @@ import styled from '@emotion/styled'; import InteractionStateLayer from 'sentry/components/interactionStateLayer'; import mergeRefs from 'sentry/utils/mergeRefs'; import type {FormSize} from 'sentry/utils/theme'; +import {withChonk} from 'sentry/utils/theme/withChonk'; + +import * as ChonkCheckbox from './index.chonk'; type CheckboxConfig = { borderRadius: string; @@ -85,49 +88,56 @@ const CheckboxWrapper = styled('div')<{ border-radius: ${p => checkboxSizeMap[p.size].borderRadius}; `; -const NativeHiddenCheckbox = styled('input')` - position: absolute; - opacity: 0; - top: 0; - left: 0; - height: 100%; - width: 100%; - margin: 0; - padding: 0; - cursor: pointer; - - & + * { - color: ${p => p.theme.textColor}; - border: 1px solid ${p => p.theme.gray200}; - } - - &:focus-visible + * { - border: 1px solid ${p => p.theme.focusBorder}; - box-shadow: ${p => p.theme.focusBorder} 0 0 0 1px; - } - - &:checked:focus-visible + *, - &:indeterminate:focus-visible + * { - box-shadow: ${p => p.theme.focus} 0 0 0 3px; - } - - &:disabled + * { - background-color: ${p => p.theme.backgroundSecondary}; - border: 1px solid ${p => p.theme.disabledBorder}; - } - - &:checked + *, - &:indeterminate + * { - background-color: ${p => p.theme.active}; - color: ${p => p.theme.white}; - } - - &:disabled:checked + *, - &:disabled:indeterminate + * { - background-color: ${p => p.theme.disabled}; - border: 1px solid ${p => p.theme.disabledBorder}; - } -`; +const NativeHiddenCheckbox = withChonk( + styled('input')` + position: absolute; + opacity: 0; + top: 0; + left: 0; + height: 100%; + width: 100%; + margin: 0; + padding: 0; + cursor: pointer; + + & + * { + box-shadow: ${p => p.theme.dropShadowMedium} inset; + color: ${p => p.theme.textColor}; + border: 1px solid ${p => p.theme.gray200}; + + svg { + stroke: ${p => p.theme.white}; + } + } + + &:focus-visible + * { + box-shadow: ${p => p.theme.focusBorder} 0 0 0 1px; + } + + &:checked:focus-visible + *, + &:indeterminate:focus-visible + * { + box-shadow: ${p => p.theme.focus} 0 0 0 3px; + } + + &:disabled + * { + background-color: ${p => p.theme.backgroundSecondary}; + border: 1px solid ${p => p.theme.disabledBorder}; + } + + &:checked + *, + &:indeterminate + * { + background-color: ${p => p.theme.active}; + color: ${p => p.theme.white}; + } + + &:disabled:checked + *, + &:disabled:indeterminate + * { + background-color: ${p => p.theme.disabled}; + border: 1px solid ${p => p.theme.disabledBorder}; + } + `, + ChonkCheckbox.ChonkNativeHiddenCheckbox +); const FakeCheckbox = styled('div')<{ size: FormSize; @@ -137,7 +147,6 @@ const FakeCheckbox = styled('div')<{ align-items: center; justify-content: center; color: inherit; - box-shadow: ${p => p.theme.dropShadowMedium} inset; width: ${p => checkboxSizeMap[p.size].box}; height: ${p => checkboxSizeMap[p.size].box}; border-radius: ${p => checkboxSizeMap[p.size].borderRadius}; @@ -151,6 +160,5 @@ const CheckboxIcon = styled('svg')<{size: string}>` fill: none; stroke-linecap: round; stroke-linejoin: round; - stroke: ${p => p.theme.white}; stroke-width: calc(1.4px + ${p => p.size} * 0.04); `; diff --git a/static/app/components/core/input/inputGroup.chonk.tsx b/static/app/components/core/input/inputGroup.chonk.tsx index c9c7fda5d7f71a..d7d32a0d7cf74e 100644 --- a/static/app/components/core/input/inputGroup.chonk.tsx +++ b/static/app/components/core/input/inputGroup.chonk.tsx @@ -2,7 +2,7 @@ import {css, type DO_NOT_USE_ChonkTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {Input} from 'sentry/components/core/input/index'; -import Textarea from 'sentry/components/forms/controls/textarea'; +import {TextArea} from 'sentry/components/core/textarea'; import {space} from 'sentry/styles/space'; import type {FormSize, StrictCSSObject} from 'sentry/utils/theme'; import {chonkStyled} from 'sentry/utils/theme/theme.chonk'; @@ -59,7 +59,7 @@ export const ChonkStyledInput = chonkStyled(Input)` ${chonkInputStyles} `; -export const ChonkStyledTextArea = chonkStyled(Textarea)` +export const ChonkStyledTextArea = chonkStyled(TextArea)` ${chonkInputStyles} `; diff --git a/static/app/components/core/input/inputGroup.tsx b/static/app/components/core/input/inputGroup.tsx index b63e088cf0ef9a..e5fa8a9050467c 100644 --- a/static/app/components/core/input/inputGroup.tsx +++ b/static/app/components/core/input/inputGroup.tsx @@ -21,8 +21,8 @@ import { InputItemsWrap, type InputStyleProps, } from 'sentry/components/core/input/inputGroup.chonk'; -import type {TextAreaProps} from 'sentry/components/forms/controls/textarea'; -import _TextArea from 'sentry/components/forms/controls/textarea'; +import type {TextAreaProps} from 'sentry/components/core/textarea'; +import {TextArea as _TextArea} from 'sentry/components/core/textarea'; import type {FormSize} from 'sentry/utils/theme'; import {withChonk} from 'sentry/utils/theme/withChonk'; diff --git a/static/app/components/core/switch/index.chonk.tsx b/static/app/components/core/switch/index.chonk.tsx new file mode 100644 index 00000000000000..310145ed8b1708 --- /dev/null +++ b/static/app/components/core/switch/index.chonk.tsx @@ -0,0 +1,119 @@ +import type {SwitchProps} from 'sentry/components/core/switch'; +import {chonkStyled} from 'sentry/utils/theme/theme.chonk'; + +const toggleWrapperSize = { + sm: {width: 36, height: 20}, + lg: {width: 40, height: 24}, +}; + +const toggleButtonSize = { + sm: {width: 20, height: 20, icon: 14, iconOffset: 2}, + lg: {width: 24, height: 24, icon: 16, iconOffset: 3}, +}; + +/** We inject hex colors as background image, which requires escaping the hex characters */ +function urlEscapeHex(hex: string) { + return hex.replace(/#/g, '%23'); +} + +export const ChonkNativeHiddenCheckbox = chonkStyled('input')<{ + nativeSize: NonNullable; +}>` + position: absolute; + opacity: 0; + top: 0; + left: 0; + width: 100%; + height: 100%; + cursor: pointer; + + &:focus-visible + div { + outline: none; + box-shadow: 0 0 0 2px ${p => p.theme.background}, 0 0 0 4px ${p => p.theme.focusBorder}; + } + + + div { + border-radius: ${p => p.theme.radius.sm}; + pointer-events: none; + + background: ${p => p.theme.colors.dynamic.surface200}; + border-top: 3px solid ${p => p.theme.colors.dynamic.surface100}; + border-right: 1px solid ${p => p.theme.colors.dynamic.surface100}; + border-bottom: 1px solid ${p => p.theme.colors.dynamic.surface100}; + border-left: 1px solid ${p => p.theme.colors.dynamic.surface100}; + transition: all 100ms ease-in-out; + + > div { + border-radius: ${p => p.theme.radius.sm}; + background: ${p => p.theme.colors.dynamic.surface500}; + border: 1px solid ${p => p.theme.colors.dynamic.surface100}; + + width: ${p => toggleButtonSize[p.nativeSize].width}px; + height: ${p => toggleButtonSize[p.nativeSize].height}px; + position: absolute; + top: 0; + left: 0; + transform: translateY(-1px); + transition: all 100ms ease-in-out, transform 400ms linear(0, 0.877 9.4%, 1.08 14.6%, 0.993 30.8%, 1); + + &:after { + /** The icon is not clickable */ + pointer-events: none; + position: absolute; + content: ''; + display: block; + width: ${p => toggleButtonSize[p.nativeSize].icon}px; + height: ${p => toggleButtonSize[p.nativeSize].icon}px; + top: ${p => toggleButtonSize[p.nativeSize].iconOffset}px; + left: ${p => toggleButtonSize[p.nativeSize].iconOffset}px; + background-repeat: no-repeat; + background-size: ${p => toggleButtonSize[p.nativeSize].icon}px ${p => toggleButtonSize[p.nativeSize].icon}px; + transition: transform 500ms linear(0, 0.877 9.4%, 1.08 14.6%, 0.993 30.8%, 1); + background-image: url('data:image/svg+xml,'); + } + } + } + + &:checked + div { + background: ${p => p.theme.colors.static.blue400}; + border-top: 3px solid ${p => p.theme.colors.dynamic.blue100}; + border-right: 1px solid ${p => p.theme.colors.dynamic.blue100}; + border-bottom: 1px solid ${p => p.theme.colors.dynamic.blue100}; + border-left: 1px solid ${p => p.theme.colors.dynamic.blue100}; + + > div { + background: ${p => p.theme.colors.dynamic.surface500}; + border: 1px solid ${p => p.theme.colors.dynamic.blue100}; + transform: translateY(-1px) translateX(${p => toggleWrapperSize[p.nativeSize].width - toggleButtonSize[p.nativeSize].width}px); + + &:after { + background-image: url('data:image/svg+xml,'); + } + } + } + + &:disabled { + cursor: not-allowed; + + + div { + opacity: 0.6; + + > div { + transform: translateY(0px) translateX(0px); + } + } + } + } + &:checked:disabled + div > div { + transform: translateY(0px) translateX(16px); + } +`; + +export const ChonkFakeCheckbox = chonkStyled('div')<{ + size: NonNullable; +}>` + width: ${p => toggleWrapperSize[p.size].width}px; + height: ${p => toggleWrapperSize[p.size].height}px; +`; +export const ChonkFakeCheckboxButton = chonkStyled('div')` +`; diff --git a/static/app/components/core/switch/index.spec.tsx b/static/app/components/core/switch/index.spec.tsx new file mode 100644 index 00000000000000..656ebcb285351d --- /dev/null +++ b/static/app/components/core/switch/index.spec.tsx @@ -0,0 +1,28 @@ +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import {Switch} from 'sentry/components/core/switch'; + +describe('Switch', () => { + it('disabled', () => { + render(); + expect(screen.getByRole('checkbox')).toBeDisabled(); + }); + + it('checked', () => { + render(); + expect(screen.getByRole('checkbox')).toBeChecked(); + }); + + it('disallows toggling a disabled switch', async () => { + const onClick = jest.fn(); + + render(); + + const switchButton = screen.getByRole('checkbox'); + expect(switchButton).not.toBeChecked(); + + await userEvent.click(switchButton); + expect(switchButton).not.toBeChecked(); + expect(onClick).not.toHaveBeenCalled(); + }); +}); diff --git a/static/app/components/core/switch/index.stories.tsx b/static/app/components/core/switch/index.stories.tsx index 5fb554eb4ef830..2e73b69104d067 100644 --- a/static/app/components/core/switch/index.stories.tsx +++ b/static/app/components/core/switch/index.stories.tsx @@ -1,9 +1,10 @@ import {Fragment, useState} from 'react'; import styled from '@emotion/styled'; -import {Switch} from 'sentry/components/core/switch'; +import {Switch, type SwitchProps} from 'sentry/components/core/switch'; import JSXNode from 'sentry/components/stories/jsxNode'; import JSXProperty from 'sentry/components/stories/jsxProperty'; +import SideBySide from 'sentry/components/stories/sideBySide'; import storyBook from 'sentry/stories/storyBook'; import {space} from 'sentry/styles/space'; @@ -12,66 +13,54 @@ import types from '!!type-loader!sentry/components/core/switch'; export default storyBook('Switch', (story, APIReference) => { APIReference(types.Switch); + story('Default', () => { - const [toggleOn, setToggleOn] = useState(false); return (

- The component is a toggle button. It doesn't have any - property to specify a text label, so you'll need to add your own accompanying - label if you need one. + The component is a checkbox button. It doesn't have + any property to specify a text label, so you'll need to add your own + accompanying label if you need one.

Here we are specifying the label with an HTML label element, which is also accessibility friendly -- clicking the label also affects the toggle!

-

- You can pass a callback function into the {' '} - prop to control what happens when the toggle is clicked. Pair this with a{' '} - useState or some other code to set the active state of the toggle, - which is controlled by the prop . +

-
- ); - }); + + + + + - story('Size', () => { - const [toggleOnL, setToggleOnL] = useState(false); - const [toggleOnS, setToggleOnS] = useState(false); - - return ( -

- The prop has two options: "sm"{' '} - and "lg". The default value is "sm". +

- - + + + + +
); }); }); +function SwitchCase(props: SwitchProps) { + const [checked, setChecked] = useState(!!props.checked); + const {checked: _checkedProp, ...rest} = props; + return ( + + ); +} + const Label = styled('label')` display: flex; align-items: center; diff --git a/static/app/components/core/switch/index.tsx b/static/app/components/core/switch/index.tsx index 318aa2ed0d94c0..c7425d921dd8e7 100644 --- a/static/app/components/core/switch/index.tsx +++ b/static/app/components/core/switch/index.tsx @@ -1,109 +1,133 @@ import {forwardRef} from 'react'; import styled from '@emotion/styled'; -export interface SwitchProps { - toggle: React.MouseEventHandler; - className?: string; - /** - * Toggle color is always active. - */ - forceActiveColor?: boolean; - id?: string; - isActive?: boolean; - isDisabled?: boolean; - name?: string; +import {withChonk} from 'sentry/utils/theme/withChonk'; + +import * as ChonkSwitch from './index.chonk'; + +export interface SwitchProps + extends Omit, 'size' | 'type' | 'onClick'> { size?: 'sm' | 'lg'; } -export const Switch = forwardRef( - ( - { - size = 'sm', - isActive, - forceActiveColor, - isDisabled, - toggle, - id, - name, - className, - ...props - }: SwitchProps, - ref - ) => { +export const Switch = forwardRef( + ({size = 'sm', ...props}: SwitchProps, ref) => { return ( - - - + + {/* @TODO(jonasbadalic): if we name the prop size, it conflicts with the native input size prop, + * so we need to use a different name, or somehow tell emotion to not create a type intersection. + */} + + + + + ); } ); -type StyleProps = Pick< - SwitchProps, - 'size' | 'isActive' | 'forceActiveColor' | 'isDisabled' ->; - -const getSize = (p: StyleProps) => (p.size === 'sm' ? 16 : 24); -const getToggleSize = (p: StyleProps) => getSize(p) - (p.size === 'sm' ? 4 : 8); -const getToggleTop = (p: StyleProps) => (p.size === 'sm' ? 1 : 3); -const getTranslateX = (p: StyleProps) => - p.isActive ? getToggleTop(p) + getSize(p) * 0.875 : getToggleTop(p); - -const SwitchButton = styled('button')` - display: inline-block; - background: none; - padding: 0; - border: 1px solid ${p => p.theme.border}; - position: relative; - box-shadow: inset ${p => p.theme.dropShadowMedium}; - height: ${getSize}px; - width: ${p => getSize(p) * 1.875}px; - border-radius: ${getSize}px; - transition: - border 0.1s, - box-shadow 0.1s; - - &[disabled] { - cursor: not-allowed; - } +const ToggleConfig = { + sm: { + size: 12, + top: 1, + }, + lg: { + size: 16, + top: 3, + }, +}; - &:focus, - &:focus-visible { - outline: none; - border-color: ${p => p.theme.focusBorder}; - box-shadow: ${p => p.theme.focusBorder} 0 0 0 1px; - } -`; +const ToggleWrapperSize = { + sm: 16, + lg: 24, +}; -const Toggle = styled('span')` - display: block; - position: absolute; - border-radius: 50%; - transition: 0.25s all ease; - top: ${getToggleTop}px; - transform: translateX(${getTranslateX}px); - width: ${getToggleSize}px; - height: ${getToggleSize}px; - background: ${p => - p.isActive || p.forceActiveColor ? p.theme.active : p.theme.border}; - opacity: ${p => (p.isDisabled ? 0.4 : null)}; +const SwitchWrapper = styled('div')` + position: relative; + cursor: pointer; + display: inline-flex; + justify-content: flex-start; `; + +const NativeHiddenCheckbox = withChonk( + styled('input')<{ + nativeSize: NonNullable; + }>` + position: absolute; + opacity: 0; + top: 0; + left: 0; + height: 100%; + width: 100%; + margin: 0; + padding: 0; + cursor: pointer; + + & + div { + > div { + background: ${p => p.theme.border}; + transform: translateX(${p => ToggleConfig[p.nativeSize].top}px); + } + } + + &:checked + div { + > div { + background: ${p => p.theme.active}; + transform: translateX( + ${p => + ToggleConfig[p.nativeSize].top + ToggleWrapperSize[p.nativeSize] * 0.875}px + ); + } + } + + &:focus + div, + &:focus-visible + div { + outline: none; + border-color: ${p => p.theme.focusBorder}; + box-shadow: ${p => p.theme.focusBorder} 0 0 0 1px; + } + + &:disabled { + cursor: not-allowed; + + + div { + opacity: 0.4; + } + } + `, + ChonkSwitch.ChonkNativeHiddenCheckbox +); + +const FakeCheckbox = withChonk( + styled('div')<{ + size: NonNullable; + }>` + position: relative; + display: inline-block; + border: 1px solid ${p => p.theme.border}; + box-shadow: inset ${p => p.theme.dropShadowMedium}; + height: ${p => ToggleWrapperSize[p.size]}px; + width: ${p => ToggleWrapperSize[p.size] * 1.875}px; + border-radius: ${p => ToggleWrapperSize[p.size]}px; + pointer-events: none; + + transition: + border 0.1s, + box-shadow 0.1s; + `, + ChonkSwitch.ChonkFakeCheckbox +); + +const FakeCheckboxButton = withChonk( + styled('div')<{ + size: NonNullable; + }>` + position: absolute; + transition: 0.25s all ease; + border-radius: 50%; + top: ${p => ToggleConfig[p.size].top}px; + width: ${p => ToggleConfig[p.size].size}px; + height: ${p => ToggleConfig[p.size].size}px; + `, + ChonkSwitch.ChonkFakeCheckboxButton +); diff --git a/static/app/components/forms/controls/textarea.tsx b/static/app/components/core/textarea/index.tsx similarity index 91% rename from static/app/components/forms/controls/textarea.tsx rename to static/app/components/core/textarea/index.tsx index 11880ea64390c6..62914ce3c72020 100644 --- a/static/app/components/forms/controls/textarea.tsx +++ b/static/app/components/core/textarea/index.tsx @@ -39,7 +39,7 @@ const TextAreaControl = forwardRef(function TextAreaControl( TextAreaControl.displayName = 'TextAreaControl'; -const TextArea = styled(Input.withComponent(TextAreaControl), { +export const TextArea = styled(Input.withComponent(TextAreaControl), { shouldForwardProp: (p: string) => ['autosize', 'rows', 'maxRows'].includes(p) || isPropValid(p), })` @@ -52,6 +52,4 @@ const TextArea = styled(Input.withComponent(TextAreaControl), { height: unset; min-height: unset; `} -`; - -export default TextArea as unknown as typeof TextAreaControl; +` as unknown as typeof TextAreaControl; diff --git a/static/app/components/devtoolbar/components/featureFlags/customOverride.tsx b/static/app/components/devtoolbar/components/featureFlags/customOverride.tsx index 8ba2c7865d4177..abd63a10361f40 100644 --- a/static/app/components/devtoolbar/components/featureFlags/customOverride.tsx +++ b/static/app/components/devtoolbar/components/featureFlags/customOverride.tsx @@ -52,8 +52,8 @@ export default function CustomOverride({ /> { + checked={isActive} + onChange={() => { setIsActive(!isActive); }} css={css` diff --git a/static/app/components/devtoolbar/components/featureFlags/featureFlagItem.tsx b/static/app/components/devtoolbar/components/featureFlags/featureFlagItem.tsx index f0d4d5f6e6b7c6..c29621e9018976 100644 --- a/static/app/components/devtoolbar/components/featureFlags/featureFlagItem.tsx +++ b/static/app/components/devtoolbar/components/featureFlags/featureFlagItem.tsx @@ -104,8 +104,8 @@ function FlagValueBooleanInput({flag}: {flag: FeatureFlag}) { {String(isActive)} { + checked={isActive} + onChange={() => { setOverride(flag.name, !isActive); setIsActive(!isActive); trackAnalytics?.({ diff --git a/static/app/components/events/autofix/autofixDiff.tsx b/static/app/components/events/autofix/autofixDiff.tsx index c68c9e6178773d..71d9086097e8fc 100644 --- a/static/app/components/events/autofix/autofixDiff.tsx +++ b/static/app/components/events/autofix/autofixDiff.tsx @@ -4,6 +4,7 @@ import {type Change, diffWords} from 'diff'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {Button} from 'sentry/components/button'; +import {TextArea} from 'sentry/components/core/textarea'; import AutofixHighlightPopup from 'sentry/components/events/autofix/autofixHighlightPopup'; import { type DiffLine, @@ -12,7 +13,6 @@ import { } from 'sentry/components/events/autofix/types'; import {makeAutofixQueryKey} from 'sentry/components/events/autofix/useAutofix'; import {useTextSelection} from 'sentry/components/events/autofix/useTextSelection'; -import TextArea from 'sentry/components/forms/controls/textarea'; import InteractionStateLayer from 'sentry/components/interactionStateLayer'; import {DIFF_COLORS} from 'sentry/components/splitDiff'; import {IconChevron, IconClose, IconDelete, IconEdit} from 'sentry/icons'; diff --git a/static/app/components/events/interfaces/performance/eventTraceView.spec.tsx b/static/app/components/events/interfaces/performance/eventTraceView.spec.tsx index 08fde598d6366e..7817a6df7657c4 100644 --- a/static/app/components/events/interfaces/performance/eventTraceView.spec.tsx +++ b/static/app/components/events/interfaces/performance/eventTraceView.spec.tsx @@ -159,9 +159,6 @@ describe('EventTraceView', () => { ); expect(await screen.findByText('Trace Preview')).toBeInTheDocument(); - expect( - await screen.findByRole('link', {name: 'View Full Trace'}) - ).toBeInTheDocument(); expect( screen.getByText('One other issue appears in the same trace.') ).toBeInTheDocument(); diff --git a/static/app/components/events/interfaces/performance/eventTraceView.tsx b/static/app/components/events/interfaces/performance/eventTraceView.tsx index 7597b18ae15de7..a540a13e447641 100644 --- a/static/app/components/events/interfaces/performance/eventTraceView.tsx +++ b/static/app/components/events/interfaces/performance/eventTraceView.tsx @@ -4,7 +4,6 @@ import type {LocationDescriptor} from 'history'; import {LinkButton} from 'sentry/components/button'; import ExternalLink from 'sentry/components/links/externalLink'; -import Link from 'sentry/components/links/link'; import {generateTraceTarget} from 'sentry/components/quickTrace/utils'; import {t} from 'sentry/locale'; import type {Event} from 'sentry/types/event'; @@ -14,7 +13,6 @@ import {defined} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams'; import {useLocation} from 'sentry/utils/useLocation'; -import useOrganization from 'sentry/utils/useOrganization'; import {SectionKey} from 'sentry/views/issueDetails/streamline/context'; import {InterimSection} from 'sentry/views/issueDetails/streamline/interimSection'; import {TraceIssueEvent} from 'sentry/views/issueDetails/traceTimeline/traceIssue'; @@ -144,8 +142,6 @@ function getHrefFromTraceTarget(traceTarget: LocationDescriptor) { } function OneOtherIssueEvent({event}: {event: Event}) { - const location = useLocation(); - const organization = useOrganization(); const {isLoading, oneOtherIssueEvent} = useTraceTimelineEvents({event}); useRouteAnalyticsParams(oneOtherIssueEvent ? {has_related_trace_issue: true} : {}); @@ -153,25 +149,9 @@ function OneOtherIssueEvent({event}: {event: Event}) { return null; } - const traceTarget = generateTraceTarget( - event, - organization, - { - ...location, - query: { - ...location.query, - groupId: event.groupID, - }, - }, - TraceViewSources.ISSUE_DETAILS - ); - return ( - - {t('One other issue appears in the same trace. ')} - {t('View Full Trace')} - + {t('One other issue appears in the same trace.')} ); diff --git a/static/app/components/featureFeedback/feedbackModal.tsx b/static/app/components/featureFeedback/feedbackModal.tsx index 068f1c454a8d35..2bd20df98ff60e 100644 --- a/static/app/components/featureFeedback/feedbackModal.tsx +++ b/static/app/components/featureFeedback/feedbackModal.tsx @@ -16,7 +16,7 @@ import type {ModalRenderProps} from 'sentry/actionCreators/modal'; import {Button} from 'sentry/components/button'; import ButtonBar from 'sentry/components/buttonBar'; import {Alert} from 'sentry/components/core/alert'; -import Textarea from 'sentry/components/forms/controls/textarea'; +import {TextArea} from 'sentry/components/core/textarea'; import FieldGroup from 'sentry/components/forms/fieldGroup'; import SelectField from 'sentry/components/forms/fields/selectField'; import type {Data} from 'sentry/components/forms/types'; @@ -307,7 +307,7 @@ export function FeedbackModal({ flexibleControlStateSize stacked > -