From 11b9f708d2ef8b39386716038971f2acacf842ac Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Wed, 5 Mar 2025 14:23:43 +0100 Subject: [PATCH 01/39] chore(hybrid-cloud): Add logging to release registry (#86374) Adds logging to understand recent AWS integration failures. See: https://github.com/getsentry/sentry/pull/62007 --- src/sentry/integrations/aws_lambda/utils.py | 12 ++++++++++++ src/sentry/tasks/release_registry.py | 15 +++++++++++++++ 2 files changed, 27 insertions(+) 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/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") From d9b324975ce18b3f7d19892b75d56643aae5cefc Mon Sep 17 00:00:00 2001 From: anthony sottile <103459774+asottile-sentry@users.noreply.github.com> Date: Wed, 5 Mar 2025 08:48:03 -0500 Subject: [PATCH 02/39] ref: fix typing for organization_member.details endpoint (#86349) --- pyproject.toml | 1 - src/sentry/api/endpoints/organization_member/details.py | 8 ++++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ebb6b3f0167208..177ae5dc358529 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,7 +118,6 @@ module = [ "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", 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) From 26a06e9fdbce49b5ea6c6dc31ae43342215e69d4 Mon Sep 17 00:00:00 2001 From: anthony sottile <103459774+asottile-sentry@users.noreply.github.com> Date: Wed, 5 Mar 2025 08:48:10 -0500 Subject: [PATCH 03/39] ref: fix types for endpoints.organization_incident_index (#86347) --- pyproject.toml | 1 - .../endpoints/organization_incident_index.py | 17 +++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 177ae5dc358529..5fe22aa2f855d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,7 +139,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/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") From aec68331374d674316b0795c94bc7d948313a91c Mon Sep 17 00:00:00 2001 From: anthony sottile <103459774+asottile-sentry@users.noreply.github.com> Date: Wed, 5 Mar 2025 08:48:19 -0500 Subject: [PATCH 04/39] ref: fix types for organization_request_project_creation (#86346) --- pyproject.toml | 1 - src/sentry/api/endpoints/auth_index.py | 2 +- .../api/endpoints/organization_request_project_creation.py | 5 ++++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5fe22aa2f855d3..09333e430e7089 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,7 +120,6 @@ module = [ "sentry.api.endpoints.organization_events_spans_performance", "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", 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_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) From 78a017baa4f1853a96189d18dc6c95f0bbe5f43d Mon Sep 17 00:00:00 2001 From: anthony sottile <103459774+asottile-sentry@users.noreply.github.com> Date: Wed, 5 Mar 2025 08:48:26 -0500 Subject: [PATCH 05/39] ref: fix typing for api.endpoints.project_index (#86298) --- pyproject.toml | 1 - src/sentry/api/endpoints/project_index.py | 10 ++++++---- .../models/sentry_app_installation.py | 20 +++++++++---------- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 09333e430e7089..69b0ecfa5a9218 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,7 +121,6 @@ module = [ "sentry.api.endpoints.organization_projects", "sentry.api.endpoints.organization_releases", "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", 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/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) From 73a3cfe3d8825e7810dd3b2df2f6aaf640dc8c29 Mon Sep 17 00:00:00 2001 From: anthony sottile <103459774+asottile-sentry@users.noreply.github.com> Date: Wed, 5 Mar 2025 08:48:31 -0500 Subject: [PATCH 06/39] ref: fix types for api.bases.organization_events (#86296) --- pyproject.toml | 1 - src/sentry/api/bases/organization.py | 43 ++++++++++++++++--- src/sentry/api/bases/organization_events.py | 24 +++++++---- src/sentry/api/serializers/snuba.py | 12 +++--- .../endpoints/organization_replay_index.py | 11 +---- 5 files changed, 59 insertions(+), 32 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 69b0ecfa5a9218..3a81c07f05445c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,7 +112,6 @@ 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", 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/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/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"), From d4315a14abd314c254c0229075e550c5fcc9d2f9 Mon Sep 17 00:00:00 2001 From: "Armen Zambrano G." <44410+armenzg@users.noreply.github.com> Date: Wed, 5 Mar 2025 09:01:33 -0500 Subject: [PATCH 07/39] fix(code_mappings): Do not append extension (#86283) In #84779 I fixed an issue, however, appending `.java` at the end is not the right fix and may need to be handled differently somewhere else in the codebase. This also changes the test structure and adds an extra test case. --- src/sentry/utils/event_frames.py | 8 ++- .../test_code_mapping.py | 51 ++++++++++--------- 2 files changed, 32 insertions(+), 27 deletions(-) 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/tests/sentry/issues/auto_source_code_config/test_code_mapping.py b/tests/sentry/issues/auto_source_code_config/test_code_mapping.py index cbb3ba14f9b0c4..42fc4ad70e3ad1 100644 --- a/tests/sentry/issues/auto_source_code_config/test_code_mapping.py +++ b/tests/sentry/issues/auto_source_code_config/test_code_mapping.py @@ -512,36 +512,37 @@ def test_convert_stacktrace_frame_path_to_source_path_java_no_source_context(sel organization_integration=self.oi, project=self.project, repo=self.repo, - # XXX: Discuss support for dot notation - # XXX: Discuss support for forgetting a last back slash stack_root="com/example/", source_root="src/com/example/", automatically_generated=False, ) - assert ( - convert_stacktrace_frame_path_to_source_path( - frame=EventFrame( - filename="MainActivity.java", - module="com.example.vu.android.MainActivity", - ), - code_mapping=code_mapping, - platform="java", - sdk_name="sentry.java.android", - ) - == "src/com/example/vu/android/MainActivity.java" - ) - assert ( - convert_stacktrace_frame_path_to_source_path( - frame=EventFrame( - filename="D8$$SyntheticClass", - module="com.example.vu.android.MainActivity$$ExternalSyntheticLambda4", - ), - code_mapping=code_mapping, - platform="java", - sdk_name="sentry.java.android", + for module, abs_path, expected_path in [ + ( + "com.example.vu.android.MainActivity", + "MainActivity.java", + "src/com/example/vu/android/MainActivity.java", + ), + ( + "com.example.vu.android.MainActivity$$ExternalSyntheticLambda4", + "D8$$SyntheticClass", + # Extension not appended since abs_path did not contain one + "src/com/example/vu/android/MainActivity", + ), + ( + "com.example.vu.android.empowerplant.MainFragment$3$1", + "MainFragment.java", + "src/com/example/vu/android/empowerplant/MainFragment.java", + ), + ]: + assert ( + convert_stacktrace_frame_path_to_source_path( + frame=EventFrame(filename=abs_path, abs_path=abs_path, module=module), + code_mapping=code_mapping, + platform="java", + sdk_name="sentry.java.android", + ) + == expected_path ) - == "src/com/example/vu/android/MainActivity.java" - ) def test_convert_stacktrace_frame_path_to_source_path_backslashes(self) -> None: assert ( From f729b8e0b4a44f1c8b171813196a5f33b1eba782 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 5 Mar 2025 09:11:55 -0500 Subject: [PATCH 08/39] help: change to use input component (#86363) Same change as https://github.com/getsentry/sentry/pull/86355 --- .../app/components/modals/helpSearchModal.tsx | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/static/app/components/modals/helpSearchModal.tsx b/static/app/components/modals/helpSearchModal.tsx index f35bd913348676..116bc0385e30f6 100644 --- a/static/app/components/modals/helpSearchModal.tsx +++ b/static/app/components/modals/helpSearchModal.tsx @@ -2,10 +2,10 @@ import {ClassNames, css, useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import type {ModalRenderProps} from 'sentry/actionCreators/modal'; +import {Input} from 'sentry/components/core/input'; import HelpSearch from 'sentry/components/helpSearch'; import Hook from 'sentry/components/hook'; import {t} from 'sentry/locale'; -import {space} from 'sentry/styles/space'; import type {Organization} from 'sentry/types/organization'; import withOrganization from 'sentry/utils/withOrganization'; @@ -40,9 +40,10 @@ function HelpSearchModal({ border-top: 1px solid ${theme.border}; `} renderInput={({getInputProps}) => ( - - - + )} resultFooter={ @@ -54,19 +55,13 @@ function HelpSearchModal({ ); } -const InputWrapper = styled('div')` - padding: ${space(0.25)}; -`; - -const Input = styled('input')` - width: 100%; - padding: ${space(1)}; - border: none; - border-radius: 8px; - outline: none; - - &:focus { +const InputWithoutFocusStyles = styled(Input)` + &:focus, + &:active, + &:hover { outline: none; + box-shadow: none; + border: none; } `; From 2d227f2af09fc35179ed71e70a2d8f45bfef050d Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 5 Mar 2025 09:12:09 -0500 Subject: [PATCH 09/39] commandpalette: use input component (#86358) Will check with @Jesse-Box about hiding focus border, I don't think it's terrible to have it, but we can also remove it. Before ![CleanShot 2025-03-04 at 18 33 05@2x](https://github.com/user-attachments/assets/f903ef8d-baed-4d22-a5fd-ed1a1c44134d) After ![CleanShot 2025-03-04 at 18 33 10@2x](https://github.com/user-attachments/assets/7e9ad1ed-7973-4b4c-898b-598897dc95f7) --- .../app/components/modals/commandPalette.tsx | 51 +++++++------------ 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/static/app/components/modals/commandPalette.tsx b/static/app/components/modals/commandPalette.tsx index ade4e91a3d6635..9fed1201fc4b26 100644 --- a/static/app/components/modals/commandPalette.tsx +++ b/static/app/components/modals/commandPalette.tsx @@ -3,10 +3,9 @@ import {ClassNames, css, useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import type {ModalRenderProps} from 'sentry/actionCreators/modal'; -import {Input} from 'sentry/components/core/input'; +import {InputGroup} from 'sentry/components/core/input/inputGroup'; import {Search} from 'sentry/components/search'; import {t} from 'sentry/locale'; -import {space} from 'sentry/styles/space'; import {trackAnalytics} from 'sentry/utils/analytics'; function CommandPalette({Body}: ModalRenderProps) { @@ -38,15 +37,13 @@ function CommandPalette({Body}: ModalRenderProps) { border-top: 1px solid ${theme.border}; `} renderInput={({getInputProps}) => ( - - - + )} /> )} @@ -57,30 +54,18 @@ function CommandPalette({Body}: ModalRenderProps) { export default CommandPalette; -export const modalCss = css` - [role='document'] { - padding: 0; +const InputWithoutFocusStyles = styled(InputGroup.Input)` + &:focus, + &:active, + &:hover { + outline: none; + box-shadow: none; + border: none; } `; -const InputWrapper = styled('div')` - padding: ${space(0.25)}; -`; - -const StyledInput = styled(Input)` - width: 100%; - padding: ${space(1)}; - border-radius: ${p => p.theme.borderRadius}; - - outline: none; - border: none; - box-shadow: none; - - :focus, - :active, - :hover { - outline: none; - border: none; - box-shadow: none; +export const modalCss = css` + [role='document'] { + padding: 0; } `; From 432d5bc0435edd7e8f3b837876daf4e469491b74 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 5 Mar 2025 09:12:39 -0500 Subject: [PATCH 10/39] settings: use input component (#86355) Use input component and remove custom styles Before ![CleanShot 2025-03-04 at 18 30 36@2x](https://github.com/user-attachments/assets/33be0fac-e9aa-49d6-b7e2-6e559c165306) After ![CleanShot 2025-03-04 at 18 29 47@2x](https://github.com/user-attachments/assets/da95b230-b7e6-4c27-99ad-4ae0847b08ac) --- .../components/settingsSearch/index.tsx | 45 ++++--------------- 1 file changed, 9 insertions(+), 36 deletions(-) diff --git a/static/app/views/settings/components/settingsSearch/index.tsx b/static/app/views/settings/components/settingsSearch/index.tsx index c6ef63f475cf23..9f645e45dd2974 100644 --- a/static/app/views/settings/components/settingsSearch/index.tsx +++ b/static/app/views/settings/components/settingsSearch/index.tsx @@ -1,6 +1,7 @@ import {useMemo, useRef} from 'react'; import styled from '@emotion/styled'; +import {InputGroup} from 'sentry/components/core/input/inputGroup'; import {Search} from 'sentry/components/search'; import {IconSearch} from 'sentry/icons'; import {t} from 'sentry/locale'; @@ -24,14 +25,17 @@ function SettingsSearch() { minSearch={MIN_SEARCH_LENGTH} maxResults={MAX_RESULTS} renderInput={({getInputProps}) => ( - - - + + + + - + )} /> ); @@ -39,37 +43,6 @@ function SettingsSearch() { export default SettingsSearch; -const SearchInputWrapper = styled('div')` - position: relative; -`; - -const SearchInputIcon = styled(IconSearch)` - color: ${p => p.theme.gray300}; - position: absolute; - left: 10px; - top: 8px; -`; - -const SearchInput = styled('input')` - color: ${p => p.theme.formText}; - background-color: ${p => p.theme.background}; - transition: border-color 0.15s ease; - font-size: 14px; +const StyledSearchInput = styled(InputGroup.Input)` width: 260px; - line-height: 1; - padding: 5px 8px 4px 28px; - border: 1px solid ${p => p.theme.border}; - border-radius: 30px; - height: 28px; - - box-shadow: inset ${p => p.theme.dropShadowMedium}; - - &:focus { - outline: none; - border: 1px solid ${p => p.theme.border}; - } - - &::placeholder { - color: ${p => p.theme.formPlaceholder}; - } `; From 6539af99ecf76597e6a572f4f79c0406e823ad0d Mon Sep 17 00:00:00 2001 From: ArthurKnaus Date: Wed, 5 Mar 2025 15:18:31 +0100 Subject: [PATCH 11/39] feat(laravel-insights): Improve table responsiveness (#86378) Give min size to all columns except `Path`. Add ellipsis + tooltip to transaction name. --- .../pages/backend/laravelOverviewPage.tsx | 74 +++++++++++-------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/static/app/views/insights/pages/backend/laravelOverviewPage.tsx b/static/app/views/insights/pages/backend/laravelOverviewPage.tsx index adac656464d98c..ca2b61606b79e5 100644 --- a/static/app/views/insights/pages/backend/laravelOverviewPage.tsx +++ b/static/app/views/insights/pages/backend/laravelOverviewPage.tsx @@ -1,5 +1,5 @@ import {Fragment, useCallback, useMemo} from 'react'; -import {useTheme} from '@emotion/react'; +import {css, useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import type {Location} from 'history'; import pick from 'lodash/pick'; @@ -949,23 +949,8 @@ const getCellColor = (value: number, thresholds: Record) => { return Object.entries(thresholds).find(([_, threshold]) => value >= threshold)?.[0]; }; -const PathCell = styled('div')` - display: flex; - flex-direction: column; - padding: ${space(1)} ${space(2)}; - justify-content: center; - gap: ${space(0.5)}; - min-width: 200px; -`; - -const ControllerText = styled('div')` - color: ${p => p.theme.gray300}; - font-size: ${p => p.theme.fontSizeSmall}; - line-height: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0px; +const StyledPanelTable = styled(PanelTable)` + grid-template-columns: max-content minmax(200px, 1fr) repeat(5, max-content); `; const Cell = styled('div')` @@ -992,6 +977,22 @@ const HeaderCell = styled(Cell)` padding: 0; `; +const PathCell = styled(Cell)` + flex-direction: column; + justify-content: center; + align-items: flex-start; + gap: ${space(0.5)}; + min-width: 0px; +`; + +const ControllerText = styled('div')` + ${p => p.theme.overflowEllipsis}; + color: ${p => p.theme.gray300}; + font-size: ${p => p.theme.fontSizeSmall}; + line-height: 1; + min-width: 0px; +`; + function RoutesTable({query}: {query?: string}) { const organization = useOrganization(); const pageFilterChartParams = usePageFilterChartParams(); @@ -1087,7 +1088,7 @@ function RoutesTable({query}: {query?: string}) { }, [transactionsRequest.data, routeControllersRequest.data]); return ( - {transaction.method} - - {transaction.transaction} - + + {transaction.transaction} + + {routeControllersRequest.isLoading ? ( ) : ( @@ -1136,6 +1149,7 @@ function RoutesTable({query}: {query?: string}) { position="top" maxWidth={400} showOnlyOnOverflow + skipWrapper > {transaction.controller} @@ -1157,6 +1171,6 @@ function RoutesTable({query}: {query?: string}) { ); })} - + ); } From 5414e81190c4b4bae963ae1f5f30eed8117d15f8 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 5 Mar 2025 09:35:27 -0500 Subject: [PATCH 12/39] deploybadge: deprecate black and white variant (#86193) Only used in quicktrace and not part of the spec --- static/app/components/badge/groupPriority.tsx | 5 ++--- .../app/components/core/badge/tag.stories.tsx | 2 -- static/app/components/core/badge/tag.tsx | 20 +++++++++---------- .../group/inboxBadges/groupStatusTag.tsx | 4 ++-- .../group/inboxBadges/statusBadge.tsx | 5 ++--- .../components/visualize/index.tsx | 18 ++++++++--------- static/app/views/releases/utils/index.tsx | 4 ++-- .../productTrial/productTrialTag.tsx | 5 ++--- 8 files changed, 28 insertions(+), 35 deletions(-) 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/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..002031e52c7c48 100644 --- a/static/app/components/core/badge/tag.tsx +++ b/static/app/components/core/badge/tag.tsx @@ -9,15 +9,13 @@ import {space} from 'sentry/styles/space'; 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 +28,7 @@ export interface TagProps extends React.HTMLAttributes { /** * Dictates color scheme of the tag. */ - type?: TagType; + type?: TagType | DeprecatedTagType; } export const Tag = forwardRef( @@ -65,7 +63,7 @@ export const Tag = forwardRef( ); const StyledTag = styled('div')<{ - type: TagType; + type: NonNullable; }>` font-size: ${p => p.theme.fontSizeSmall}; background-color: ${p => p.theme.tag[p.type].background}; diff --git a/static/app/components/group/inboxBadges/groupStatusTag.tsx b/static/app/components/group/inboxBadges/groupStatusTag.tsx index 50e5a867c95be6..8a0888cfb56f66 100644 --- a/static/app/components/group/inboxBadges/groupStatusTag.tsx +++ b/static/app/components/group/inboxBadges/groupStatusTag.tsx @@ -2,7 +2,7 @@ import {Fragment} from 'react'; import type {Theme} from '@emotion/react'; import styled from '@emotion/styled'; -import {Tag} from 'sentry/components/core/badge/tag'; +import {Tag, type TagProps} from 'sentry/components/core/badge/tag'; import TimeSince from 'sentry/components/timeSince'; import {Tooltip} from 'sentry/components/tooltip'; @@ -11,7 +11,7 @@ interface GroupStatusBadgeProps { dateAdded?: string; fontSize?: 'sm' | 'md'; tooltip?: React.ReactNode; - type?: keyof Theme['tag']; + type?: TagProps['type']; } /** diff --git a/static/app/components/group/inboxBadges/statusBadge.tsx b/static/app/components/group/inboxBadges/statusBadge.tsx index 3a5210b4aa4ade..f3d8ddcd4cc39c 100644 --- a/static/app/components/group/inboxBadges/statusBadge.tsx +++ b/static/app/components/group/inboxBadges/statusBadge.tsx @@ -1,5 +1,4 @@ -import type {Theme} from '@emotion/react'; - +import type {TagProps} from 'sentry/components/core/badge/tag'; import {GroupStatusTag} from 'sentry/components/group/inboxBadges/groupStatusTag'; import {t} from 'sentry/locale'; import type {Group} from 'sentry/types/group'; @@ -14,7 +13,7 @@ interface SubstatusBadgeProps { export function getBadgeProperties( status: Group['status'], substatus: Group['substatus'] -): {status: string; tagType: keyof Theme['tag']; tooltip?: string} | undefined { +): {status: string; tagType: TagProps['type']; tooltip?: string} | undefined { if (status === 'resolved') { return { tagType: 'highlight', diff --git a/static/app/views/dashboards/widgetBuilder/components/visualize/index.tsx b/static/app/views/dashboards/widgetBuilder/components/visualize/index.tsx index edee0b6963ebcf..36995bcd0d4a20 100644 --- a/static/app/views/dashboards/widgetBuilder/components/visualize/index.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/visualize/index.tsx @@ -1,14 +1,13 @@ import {Fragment, type ReactNode, useMemo, useState} from 'react'; import {closestCenter, DndContext, DragOverlay} from '@dnd-kit/core'; import {arrayMove, SortableContext, verticalListSortingStrategy} from '@dnd-kit/sortable'; -import type {Theme} from '@emotion/react'; import styled from '@emotion/styled'; import cloneDeep from 'lodash/cloneDeep'; import {Button} from 'sentry/components/button'; import {CompactSelect} from 'sentry/components/compactSelect'; import {TriggerLabel} from 'sentry/components/compactSelect/control'; -import {Tag} from 'sentry/components/core/badge/tag'; +import {Tag, type TagProps} from 'sentry/components/core/badge/tag'; import {Input} from 'sentry/components/core/input'; import {Radio} from 'sentry/components/core/radio'; import {RadioLineItem} from 'sentry/components/forms/controls/radioGroup'; @@ -863,32 +862,33 @@ export function renderTag(kind: FieldValueKind, label: string, dataType?: string return {dataType}; } } - let text, tagType; + let text: string | undefined, tagType: TagProps['type'] | undefined; + switch (kind) { case FieldValueKind.FUNCTION: text = 'f(x)'; - tagType = 'warning' as keyof Theme['tag']; + tagType = 'warning'; break; case FieldValueKind.CUSTOM_MEASUREMENT: case FieldValueKind.MEASUREMENT: text = 'field'; - tagType = 'highlight' as keyof Theme['tag']; + tagType = 'highlight'; break; case FieldValueKind.BREAKDOWN: text = 'field'; - tagType = 'highlight' as keyof Theme['tag']; + tagType = 'highlight'; break; case FieldValueKind.TAG: text = kind; - tagType = 'warning' as keyof Theme['tag']; + tagType = 'warning'; break; case FieldValueKind.NUMERIC_METRICS: text = 'f(x)'; - tagType = 'warning' as keyof Theme['tag']; + tagType = 'warning'; break; case FieldValueKind.FIELD: text = DEPRECATED_FIELDS.includes(label) ? 'deprecated' : 'field'; - tagType = 'highlight' as keyof Theme['tag']; + tagType = 'highlight'; break; default: text = kind; diff --git a/static/app/views/releases/utils/index.tsx b/static/app/views/releases/utils/index.tsx index 5c67b1a5be0514..d51f71cc6ba431 100644 --- a/static/app/views/releases/utils/index.tsx +++ b/static/app/views/releases/utils/index.tsx @@ -1,10 +1,10 @@ -import type {Theme} from '@emotion/react'; import type {Location} from 'history'; import pick from 'lodash/pick'; import round from 'lodash/round'; import moment from 'moment-timezone'; import type {DateTimeObject} from 'sentry/components/charts/utils'; +import type {TagProps} from 'sentry/components/core/badge/tag'; import ExternalLink from 'sentry/components/links/externalLink'; import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse'; import {PAGE_URL_PARAM, URL_PARAM} from 'sentry/constants/pageFilters'; @@ -208,7 +208,7 @@ const adoptionStagesLink = ( export const ADOPTION_STAGE_LABELS: Record< string, - {name: string; tooltipTitle: React.ReactNode; type: keyof Theme['tag']} + {name: string; tooltipTitle: React.ReactNode; type: TagProps['type']} > = { low_adoption: { name: t('Low Adoption'), diff --git a/static/gsApp/components/productTrial/productTrialTag.tsx b/static/gsApp/components/productTrial/productTrialTag.tsx index 71917e9b2b6918..a105ddce8bcb81 100644 --- a/static/gsApp/components/productTrial/productTrialTag.tsx +++ b/static/gsApp/components/productTrial/productTrialTag.tsx @@ -1,7 +1,6 @@ -import type {Theme} from '@emotion/react'; import moment from 'moment-timezone'; -import {Tag} from 'sentry/components/core/badge/tag'; +import {Tag, type TagProps} from 'sentry/components/core/badge/tag'; import {IconBusiness} from 'sentry/icons'; import {IconClock} from 'sentry/icons/iconClock'; import {IconFlag} from 'sentry/icons/iconFlag'; @@ -13,7 +12,7 @@ import type {ProductTrial} from 'getsentry/types'; interface ProductTrialTagProps { trial: ProductTrial; showTrialEnded?: boolean; - type?: keyof Theme['tag']; + type?: TagProps['type']; } function ProductTrialTag({trial, type, showTrialEnded = false}: ProductTrialTagProps) { From 2861b4809d6a65c820ab3689beb44c83a7912b44 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 5 Mar 2025 09:35:40 -0500 Subject: [PATCH 13/39] checkbox: add chonk style (#86263) Implement chonk styling for checkbox component **Light mode** ![CleanShot 2025-03-03 at 21 09 42@2x](https://github.com/user-attachments/assets/ee6324cc-b0e1-4dfb-8747-9eb6bb3cfe6e) **Dark mode** ![CleanShot 2025-03-03 at 21 09 39@2x](https://github.com/user-attachments/assets/918cdbed-0170-40e0-acca-9ca9e4bfaa6c) --- .../components/core/checkbox/index.chonk.tsx | 48 +++++++++ static/app/components/core/checkbox/index.tsx | 98 ++++++++++--------- 2 files changed, 101 insertions(+), 45 deletions(-) create mode 100644 static/app/components/core/checkbox/index.chonk.tsx 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); `; From 008edc4b917c96bfdf71b055813e5fa7d066d914 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 5 Mar 2025 09:36:23 -0500 Subject: [PATCH 14/39] switch: use native-like input props (#86291) Refactor component to use input-like props `isActive -> checked`, `isDisabled -> disabled` and `toggle -> onClick` which will later become onChange as I will change this component to and actual checkbox --- .../app/components/core/switch/index.spec.tsx | 28 +++++++++ .../components/core/switch/index.stories.tsx | 25 ++++++-- static/app/components/core/switch/index.tsx | 60 ++++--------------- .../featureFlags/customOverride.tsx | 4 +- .../featureFlags/featureFlagItem.tsx | 4 +- .../feedbackConfigToggle.tsx | 8 +-- .../components/forms/fields/booleanField.tsx | 14 ++--- .../deprecatedAggregateFlamegraph.tsx | 4 +- .../replaysOnboarding/replayConfigToggle.tsx | 4 +- .../performance/contexts/onDemandControl.tsx | 2 +- static/app/views/dashboards/manage/index.tsx | 4 +- static/app/views/discover/landing.tsx | 6 +- .../awsLambdaFunctionSelect.tsx | 4 +- .../issueDetails/actions/publishModal.tsx | 4 +- .../views/organizationStats/usageStatsOrg.tsx | 4 +- .../settings/account/accountSubscriptions.tsx | 4 +- .../dynamicSampling/samplingModeSwitch.tsx | 6 +- .../installedPlugin.tsx | 6 +- .../integrationServerlessRow.tsx | 6 +- .../projectFilters/projectFiltersSettings.tsx | 6 +- .../settings/project/projectServiceHooks.tsx | 2 +- .../projectPlugins/projectPluginRow.tsx | 6 +- 22 files changed, 107 insertions(+), 104 deletions(-) create mode 100644 static/app/components/core/switch/index.spec.tsx 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..702513564d2b1b --- /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..c0807f889f40b1 100644 --- a/static/app/components/core/switch/index.stories.tsx +++ b/static/app/components/core/switch/index.stories.tsx @@ -27,14 +27,27 @@ export default storyBook('Switch', (story, APIReference) => {

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 . + which is controlled by the prop .

+

+ You can also pass a prop to disable the + toggle. +

+ ); }); @@ -53,18 +66,18 @@ export default storyBook('Switch', (story, APIReference) => { Large switch setToggleOnL(!toggleOnL)} + onClick={() => setToggleOnL(!toggleOnL)} size="lg" - isActive={toggleOnL} + checked={toggleOnL} /> diff --git a/static/app/components/core/switch/index.tsx b/static/app/components/core/switch/index.tsx index 318aa2ed0d94c0..084a1b037b172c 100644 --- a/static/app/components/core/switch/index.tsx +++ b/static/app/components/core/switch/index.tsx @@ -1,72 +1,36 @@ 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; +export interface SwitchProps + extends Omit, 'size' | 'type'> { size?: 'sm' | 'lg'; } export const Switch = forwardRef( - ( - { - size = 'sm', - isActive, - forceActiveColor, - isDisabled, - toggle, - id, - name, - className, - ...props - }: SwitchProps, - ref - ) => { + ({size = 'sm', ...props}: SwitchProps, ref) => { return ( - + ); } ); -type StyleProps = Pick< - SwitchProps, - 'size' | 'isActive' | 'forceActiveColor' | 'isDisabled' ->; +type StyleProps = Pick; 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); + p.checked ? getToggleTop(p) + getSize(p) * 0.875 : getToggleTop(p); const SwitchButton = styled('button')` display: inline-block; @@ -82,8 +46,9 @@ const SwitchButton = styled('button')` border 0.1s, box-shadow 0.1s; - &[disabled] { - cursor: not-allowed; + span { + background: ${p => (p.checked ? p.theme.active : p.theme.border)}; + opacity: ${p => (p.disabled ? 0.4 : null)}; } &:focus, @@ -103,7 +68,4 @@ const Toggle = styled('span')` 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)}; `; diff --git a/static/app/components/devtoolbar/components/featureFlags/customOverride.tsx b/static/app/components/devtoolbar/components/featureFlags/customOverride.tsx index 8ba2c7865d4177..3975c2d2221bd5 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} + onClick={() => { 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..e70cfc5ab1e35e 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} + onClick={() => { setOverride(flag.name, !isActive); setIsActive(!isActive); trackAnalytics?.({ diff --git a/static/app/components/feedback/feedbackOnboarding/feedbackConfigToggle.tsx b/static/app/components/feedback/feedbackOnboarding/feedbackConfigToggle.tsx index 502c45ab863ea6..2d2d22e39a8b27 100644 --- a/static/app/components/feedback/feedbackOnboarding/feedbackConfigToggle.tsx +++ b/static/app/components/feedback/feedbackOnboarding/feedbackConfigToggle.tsx @@ -23,19 +23,19 @@ function FeedbackConfigToggle({ {t('Name Required')} - + {t('Email Required')} - + {t('Enable Screenshots')} diff --git a/static/app/components/forms/fields/booleanField.tsx b/static/app/components/forms/fields/booleanField.tsx index 63062408c9eb60..b7797c0762e144 100644 --- a/static/app/components/forms/fields/booleanField.tsx +++ b/static/app/components/forms/fields/booleanField.tsx @@ -1,7 +1,7 @@ import {Component} from 'react'; import Confirm from 'sentry/components/confirm'; -import {Switch} from 'sentry/components/core/switch'; +import {Switch, type SwitchProps} from 'sentry/components/core/switch'; import FormField from 'sentry/components/forms/formField'; import {Tooltip} from 'sentry/components/tooltip'; @@ -59,12 +59,12 @@ export default class BooleanField extends Component { const handleChange = this.handleChange.bind(this, value, onChange, onBlur); const {type: _, ...propsWithoutType} = props; - const switchProps = { + const switchProps: SwitchProps = { ...propsWithoutType, - size: 'lg' as React.ComponentProps['size'], - isActive: !!value, - isDisabled: disabled, - toggle: handleChange, + size: 'lg', + checked: !!value, + disabled, + onClick: handleChange, }; if (confirm) { @@ -79,7 +79,7 @@ export default class BooleanField extends Component { { + onClick={e => { // If we have a `confirm` prop and enabling switch // Then show confirm dialog, otherwise propagate change as normal // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message diff --git a/static/app/components/profiling/flamegraph/deprecatedAggregateFlamegraph.tsx b/static/app/components/profiling/flamegraph/deprecatedAggregateFlamegraph.tsx index cabcbb0e779178..c5148e0273d59f 100644 --- a/static/app/components/profiling/flamegraph/deprecatedAggregateFlamegraph.tsx +++ b/static/app/components/profiling/flamegraph/deprecatedAggregateFlamegraph.tsx @@ -321,8 +321,8 @@ export function DeprecatedAggregateFlamegraph( {t('Hide System Frames')} props.setHideSystemFrames(!props.hideSystemFrames)} - isActive={props.hideSystemFrames} + onClick={() => props.setHideSystemFrames(!props.hideSystemFrames)} + checked={props.hideSystemFrames} /> diff --git a/static/app/components/replaysOnboarding/replayConfigToggle.tsx b/static/app/components/replaysOnboarding/replayConfigToggle.tsx index 93d9abc311d1f1..fee17c5f4162c9 100644 --- a/static/app/components/replaysOnboarding/replayConfigToggle.tsx +++ b/static/app/components/replaysOnboarding/replayConfigToggle.tsx @@ -19,11 +19,11 @@ function ReplayConfigToggle({ {t('Mask All Text')} - + {t('Block All Media')} - + ); diff --git a/static/app/utils/performance/contexts/onDemandControl.tsx b/static/app/utils/performance/contexts/onDemandControl.tsx index 3f988aeb981ccd..ea062b0c8e0151 100644 --- a/static/app/utils/performance/contexts/onDemandControl.tsx +++ b/static/app/utils/performance/contexts/onDemandControl.tsx @@ -158,7 +158,7 @@ export function ToggleOnDemand() { }} > {t('On-demand metrics')} - + ); } diff --git a/static/app/views/dashboards/manage/index.tsx b/static/app/views/dashboards/manage/index.tsx index a3df5ee3fc671f..a1536f6122306b 100644 --- a/static/app/views/dashboards/manage/index.tsx +++ b/static/app/views/dashboards/manage/index.tsx @@ -447,9 +447,9 @@ function ManageDashboards() { {t('Show Templates')} diff --git a/static/app/views/discover/landing.tsx b/static/app/views/discover/landing.tsx index a997c042adaca9..b2226472b96c0b 100644 --- a/static/app/views/discover/landing.tsx +++ b/static/app/views/discover/landing.tsx @@ -201,10 +201,10 @@ class DiscoverLanding extends DeprecatedAsyncComponent { Show Prebuilt { )} diff --git a/static/app/views/issueDetails/actions/publishModal.tsx b/static/app/views/issueDetails/actions/publishModal.tsx index 8d1a16bacc59cf..5ad1d7dc8b41e8 100644 --- a/static/app/views/issueDetails/actions/publishModal.tsx +++ b/static/app/views/issueDetails/actions/publishModal.tsx @@ -100,9 +100,9 @@ export default function PublishIssueModal({ {(!group || loading) && ( diff --git a/static/app/views/organizationStats/usageStatsOrg.tsx b/static/app/views/organizationStats/usageStatsOrg.tsx index bf06a909c1611f..aff987703fe3b5 100644 --- a/static/app/views/organizationStats/usageStatsOrg.tsx +++ b/static/app/views/organizationStats/usageStatsOrg.tsx @@ -513,10 +513,10 @@ class UsageStatsOrganization< {t('Show client-discarded data:')} { + onClick={() => { handleChangeState({clientDiscard: !clientDiscard}); }} - isActive={clientDiscard} + checked={clientDiscard} /> )} diff --git a/static/app/views/settings/account/accountSubscriptions.tsx b/static/app/views/settings/account/accountSubscriptions.tsx index da9fbcbd63ac79..f8280ee48271ec 100644 --- a/static/app/views/settings/account/accountSubscriptions.tsx +++ b/static/app/views/settings/account/accountSubscriptions.tsx @@ -182,9 +182,9 @@ function AccountSubscriptions() {
handleToggle(subscription)} + onClick={() => handleToggle(subscription)} />
diff --git a/static/app/views/settings/dynamicSampling/samplingModeSwitch.tsx b/static/app/views/settings/dynamicSampling/samplingModeSwitch.tsx index 8e70ebb7e2f32b..4b620f4c1a7ed2 100644 --- a/static/app/views/settings/dynamicSampling/samplingModeSwitch.tsx +++ b/static/app/views/settings/dynamicSampling/samplingModeSwitch.tsx @@ -48,9 +48,9 @@ export function SamplingModeSwitch({initialTargetRate}: Props) { > diff --git a/static/app/views/settings/organizationIntegrations/installedPlugin.tsx b/static/app/views/settings/organizationIntegrations/installedPlugin.tsx index 3c253084ad08d1..4892004d4f712d 100644 --- a/static/app/views/settings/organizationIntegrations/installedPlugin.tsx +++ b/static/app/views/settings/organizationIntegrations/installedPlugin.tsx @@ -160,11 +160,11 @@ export class InstalledPlugin extends Component { + checked={projectItem.enabled} + onClick={() => this.toggleEnablePlugin(projectItem.projectId, !projectItem.enabled) } - isDisabled={!hasAccess} + disabled={!hasAccess} /> )} diff --git a/static/app/views/settings/organizationIntegrations/integrationServerlessRow.tsx b/static/app/views/settings/organizationIntegrations/integrationServerlessRow.tsx index 594c1b8e2e1a99..c9803b2a514c1c 100644 --- a/static/app/views/settings/organizationIntegrations/integrationServerlessRow.tsx +++ b/static/app/views/settings/organizationIntegrations/integrationServerlessRow.tsx @@ -130,10 +130,10 @@ export function IntegrationServerlessRow({ {layerStatus} ); diff --git a/static/app/views/settings/project/projectFilters/projectFiltersSettings.tsx b/static/app/views/settings/project/projectFilters/projectFiltersSettings.tsx index dfdb050ea21a82..7b595518db1418 100644 --- a/static/app/views/settings/project/projectFilters/projectFiltersSettings.tsx +++ b/static/app/views/settings/project/projectFilters/projectFiltersSettings.tsx @@ -314,13 +314,13 @@ class LegacyBrowserFilterRow extends Component { diff --git a/static/app/views/settings/project/projectServiceHooks.tsx b/static/app/views/settings/project/projectServiceHooks.tsx index 4f5df67c9c5eee..7303e535ee43e3 100644 --- a/static/app/views/settings/project/projectServiceHooks.tsx +++ b/static/app/views/settings/project/projectServiceHooks.tsx @@ -60,7 +60,7 @@ function ServiceHookRow({orgId, projectId, hook, onToggleActive}: RowProps) { } > - + ); } diff --git a/static/app/views/settings/projectPlugins/projectPluginRow.tsx b/static/app/views/settings/projectPlugins/projectPluginRow.tsx index 3a2d22dcb62b9e..57e5dfe16c50bc 100644 --- a/static/app/views/settings/projectPlugins/projectPluginRow.tsx +++ b/static/app/views/settings/projectPlugins/projectPluginRow.tsx @@ -94,9 +94,9 @@ class ProjectPluginRow extends PureComponent { ); From 76388a8b2ab74bcf16ffe7cc67e77e12cd9ff108 Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Wed, 5 Mar 2025 09:38:12 -0500 Subject: [PATCH 15/39] fix(insights): Add `"meta"` to more server mocks (#86350) `"meta"` is truly required! A lot of our code has been pretending it's _not_, but it is. I'm going through the backend mocks and adding it in places it was missing. --- .../views/resourcesLandingPage.spec.tsx | 16 +++++++++ .../cache/views/cacheLandingPage.spec.tsx | 3 ++ .../http/views/httpDomainSummaryPage.spec.tsx | 34 +++++++++++++++++++ .../http/views/httpLandingPage.spec.tsx | 34 +++++++++++++++++++ .../queues/charts/latencyChart.spec.tsx | 12 +++++++ .../views/destinationSummaryPage.spec.tsx | 6 ++++ 6 files changed, 105 insertions(+) diff --git a/static/app/views/insights/browser/resources/views/resourcesLandingPage.spec.tsx b/static/app/views/insights/browser/resources/views/resourcesLandingPage.spec.tsx index e88238e58dcff4..cc479fd495c115 100644 --- a/static/app/views/insights/browser/resources/views/resourcesLandingPage.spec.tsx +++ b/static/app/views/insights/browser/resources/views/resourcesLandingPage.spec.tsx @@ -264,12 +264,28 @@ const setupMockRequests = (organization: Organization) => { [1699907700, [{count: 7810.2}]], [1699908000, [{count: 1216.8}]], ], + meta: { + fields: { + [`${SPM}()`]: 'rate', + }, + units: { + [`${SPM}()`]: '1/second', + }, + }, }, [`avg(${SPAN_SELF_TIME})`]: { data: [ [1699907700, [{count: 1111.2}]], [1699908000, [{count: 2222.8}]], ], + meta: { + fields: { + [`avg(${SPAN_SELF_TIME})`]: 'duration', + }, + units: { + [`avg(${SPAN_SELF_TIME})`]: 'millisecond', + }, + }, }, }, }); diff --git a/static/app/views/insights/cache/views/cacheLandingPage.spec.tsx b/static/app/views/insights/cache/views/cacheLandingPage.spec.tsx index a636e46b842cae..f57a6c1565fce0 100644 --- a/static/app/views/insights/cache/views/cacheLandingPage.spec.tsx +++ b/static/app/views/insights/cache/views/cacheLandingPage.spec.tsx @@ -325,6 +325,7 @@ const setRequestMocks = (organization: Organization) => { time: 'date', cache_miss_rate: 'percentage', }, + units: {}, }, }, }); @@ -347,6 +348,7 @@ const setRequestMocks = (organization: Organization) => { time: 'date', cache_miss_rate: 'percentage', }, + units: {}, }, }, }); @@ -369,6 +371,7 @@ const setRequestMocks = (organization: Organization) => { time: 'date', spm_14400: 'rate', }, + units: {}, }, }, }); diff --git a/static/app/views/insights/http/views/httpDomainSummaryPage.spec.tsx b/static/app/views/insights/http/views/httpDomainSummaryPage.spec.tsx index a0d959aee02f5a..27c1409dc750dc 100644 --- a/static/app/views/insights/http/views/httpDomainSummaryPage.spec.tsx +++ b/static/app/views/insights/http/views/httpDomainSummaryPage.spec.tsx @@ -113,6 +113,14 @@ describe('HTTPSummaryPage', function () { [1699907700, [{count: 7810.2}]], [1699908000, [{count: 1216.8}]], ], + meta: { + fields: { + 'spm()': 'rate', + }, + units: { + 'spm()': '1/second', + }, + }, }, }); @@ -129,6 +137,14 @@ describe('HTTPSummaryPage', function () { [1699907700, [{count: 710.2}]], [1699908000, [{count: 116.8}]], ], + meta: { + fields: { + 'avg(span.duration)': 'rate', + }, + units: { + 'avg(span.duration)': '1/second', + }, + }, }, }); @@ -143,12 +159,30 @@ describe('HTTPSummaryPage', function () { body: { 'http_response_rate(3)': { data: [[1699908000, [{count: 0.2}]]], + meta: { + fields: { + 'http_response_rate(3)': 'percentage', + }, + units: {}, + }, }, 'http_response_rate(4)': { data: [[1699908000, [{count: 0.1}]]], + meta: { + fields: { + 'http_response_rate(4)': 'percentage', + }, + units: {}, + }, }, 'http_response_rate(5)': { data: [[1699908000, [{count: 0.3}]]], + meta: { + fields: { + 'http_response_rate(5)': 'percentage', + }, + units: {}, + }, }, }, }); diff --git a/static/app/views/insights/http/views/httpLandingPage.spec.tsx b/static/app/views/insights/http/views/httpLandingPage.spec.tsx index 028fc788def81d..570d61a0c7184b 100644 --- a/static/app/views/insights/http/views/httpLandingPage.spec.tsx +++ b/static/app/views/insights/http/views/httpLandingPage.spec.tsx @@ -182,6 +182,14 @@ describe('HTTPLandingPage', function () { [1699907700, [{count: 7810.2}]], [1699908000, [{count: 1216.8}]], ], + meta: { + fields: { + 'spm()': 'rate', + }, + units: { + 'spm()': '1/second', + }, + }, }, }); @@ -198,6 +206,14 @@ describe('HTTPLandingPage', function () { [1699907700, [{count: 710.2}]], [1699908000, [{count: 116.8}]], ], + meta: { + fields: { + 'avg(span.duration)': 'duration', + }, + units: { + 'avg(span.duration)': 'millisecond', + }, + }, }, }); @@ -212,12 +228,30 @@ describe('HTTPLandingPage', function () { body: { 'http_response_rate(3)': { data: [[1699908000, [{count: 0.2}]]], + meta: { + fields: { + 'http_response_rate(3)': 'percentage', + }, + units: {}, + }, }, 'http_response_rate(4)': { data: [[1699908000, [{count: 0.1}]]], + meta: { + fields: { + 'http_response_rate(4)': 'percentage', + }, + units: {}, + }, }, 'http_response_rate(5)': { data: [[1699908000, [{count: 0.3}]]], + meta: { + fields: { + 'http_response_rate(5)': 'percentage', + }, + units: {}, + }, }, }, }); diff --git a/static/app/views/insights/queues/charts/latencyChart.spec.tsx b/static/app/views/insights/queues/charts/latencyChart.spec.tsx index 7a44014f937e91..10ac0943eeb122 100644 --- a/static/app/views/insights/queues/charts/latencyChart.spec.tsx +++ b/static/app/views/insights/queues/charts/latencyChart.spec.tsx @@ -16,6 +16,18 @@ describe('latencyChart', () => { method: 'GET', body: { data: [[1739378162, [{count: 1}]]], + meta: { + fields: { + 'avg(span.duration)': 'duration', + 'avg(messaging.message.receive.latency)': 'duration', + 'spm()': 'rate', + }, + units: { + 'avg(span.duration)': 'millisecond', + 'avg(messaging.message.receive.latency)': 'millisecond', + 'spm()': '1/second', + }, + }, }, }); }); diff --git a/static/app/views/insights/queues/views/destinationSummaryPage.spec.tsx b/static/app/views/insights/queues/views/destinationSummaryPage.spec.tsx index 7275cd0f2d6e3b..717d50ec607e0e 100644 --- a/static/app/views/insights/queues/views/destinationSummaryPage.spec.tsx +++ b/static/app/views/insights/queues/views/destinationSummaryPage.spec.tsx @@ -69,6 +69,12 @@ describe('destinationSummaryPage', () => { method: 'GET', body: { data: [[1699907700, [{count: 0.2}]]], + meta: { + fields: {'avg(span.duration)': 'duration'}, + units: { + 'avg(span.duration)': 'millisecond', + }, + }, }, }); }); From 241efad2688fdf064c48433bec3413a4e477ae6c Mon Sep 17 00:00:00 2001 From: ArthurKnaus Date: Wed, 5 Mar 2025 15:41:13 +0100 Subject: [PATCH 16/39] fix(laravel-insights): Fix requests chart query (#86380) Fix query of requests chart. Add an EAP-compatible low granularity ladder which is suitable for bar charts. --- static/app/components/charts/utils.tsx | 21 ++++++- .../pages/backend/laravelOverviewPage.tsx | 62 ++++++++++++------- 2 files changed, 58 insertions(+), 25 deletions(-) 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/views/insights/pages/backend/laravelOverviewPage.tsx b/static/app/views/insights/pages/backend/laravelOverviewPage.tsx index ca2b61606b79e5..73e475eb83dee8 100644 --- a/static/app/views/insights/pages/backend/laravelOverviewPage.tsx +++ b/static/app/views/insights/pages/backend/laravelOverviewPage.tsx @@ -29,7 +29,11 @@ import {URL_PARAM} from 'sentry/constants/pageFilters'; import {IconArrow, IconUser} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; -import type {MultiSeriesEventsStats, Organization} from 'sentry/types/organization'; +import type { + EventsStats, + MultiSeriesEventsStats, + Organization, +} from 'sentry/types/organization'; import type {EventsMetaType} from 'sentry/utils/discover/eventView'; import getDuration from 'sentry/utils/duration/getDuration'; import {parsePeriodToHours} from 'sentry/utils/duration/parsePeriodToHours'; @@ -389,7 +393,7 @@ function usePageFilterChartParams({ function RequestsWidget({query}: {query?: string}) { const organization = useOrganization(); - const pageFilterChartParams = usePageFilterChartParams(); + const pageFilterChartParams = usePageFilterChartParams({granularity: 'spans-low'}); const theme = useTheme(); const {data, isLoading, error} = useApiQuery( @@ -399,7 +403,7 @@ function RequestsWidget({query}: {query?: string}) { query: { ...pageFilterChartParams, dataset: 'spans', - field: ['http.status_code', 'count(span.duration)'], + field: ['trace.status', 'count(span.duration)'], yAxis: 'count(span.duration)', orderby: '-count(span.duration)', partial: 1, @@ -412,49 +416,59 @@ function RequestsWidget({query}: {query?: string}) { {staleTime: 0} ); - const getTimeSeries = useCallback( - (codePrefix: string, color?: string): DiscoverSeries | undefined => { - if (!data) { - return undefined; - } - - const filteredSeries = Object.keys(data) - .filter(key => key.startsWith(codePrefix)) - .map(key => data[key]!); - - const firstSeries = filteredSeries[0]; + const combineTimeSeries = useCallback( + ( + seriesData: EventsStats[], + color: string, + fieldName: string + ): DiscoverSeries | undefined => { + const firstSeries = seriesData[0]; if (!firstSeries) { return undefined; } - const field = `${codePrefix}xx`; - return { data: firstSeries.data.map(([time], index) => ({ name: new Date(time * 1000).toISOString(), - value: filteredSeries.reduce( + value: seriesData.reduce( (acc, series) => acc + series.data[index]?.[1][0]?.count!, 0 ), })), - seriesName: `${codePrefix}xx`, + seriesName: fieldName, meta: { fields: { - [field]: 'integer', + [fieldName]: 'integer', }, units: {}, }, color, } satisfies DiscoverSeries; }, - [data] + [] ); const timeSeries = useMemo(() => { - return [getTimeSeries('2', theme.gray200), getTimeSeries('5', theme.error)].filter( - series => !!series - ); - }, [getTimeSeries, theme.error, theme.gray200]); + return [ + combineTimeSeries( + [data?.ok].filter(series => !!series), + theme.gray200, + '2xx' + ), + combineTimeSeries( + [data?.invalid_argument, data?.internal_error].filter(series => !!series), + theme.error, + '5xx' + ), + ].filter(series => !!series); + }, [ + combineTimeSeries, + data?.internal_error, + data?.invalid_argument, + data?.ok, + theme.error, + theme.gray200, + ]); return ( Date: Wed, 5 Mar 2025 15:48:11 +0100 Subject: [PATCH 17/39] ref(ui): cleanup deprecated input component (#86379) --- static/app/components/input.tsx | 6 ------ static/app/components/tours/tour.stories.tsx | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) delete mode 100644 static/app/components/input.tsx diff --git a/static/app/components/input.tsx b/static/app/components/input.tsx deleted file mode 100644 index 98846f6239b8d7..00000000000000 --- a/static/app/components/input.tsx +++ /dev/null @@ -1,6 +0,0 @@ -/** - * @deprecated Import from `sentry/components/core/input` instead. - */ -// biome-ignore lint/performance/noBarrelFile: Remove this once imports are fixed -export * from 'sentry/components/core/input'; -export {Input as default} from 'sentry/components/core/input'; diff --git a/static/app/components/tours/tour.stories.tsx b/static/app/components/tours/tour.stories.tsx index e9fff3a13baa12..ac6902cee2d1bf 100644 --- a/static/app/components/tours/tour.stories.tsx +++ b/static/app/components/tours/tour.stories.tsx @@ -7,7 +7,7 @@ import {Button} from 'sentry/components/button'; import {CodeSnippet} from 'sentry/components/codeSnippet'; import {Flex} from 'sentry/components/container/flex'; import {Alert} from 'sentry/components/core/alert'; -import {Input} from 'sentry/components/input'; +import {Input} from 'sentry/components/core/input'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import JSXNode from 'sentry/components/stories/jsxNode'; import SizingWindow from 'sentry/components/stories/sizingWindow'; From 7b1bd7ccedb7770f0aa36339296e16f674177fd5 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 5 Mar 2025 10:03:36 -0500 Subject: [PATCH 18/39] switch: make switch a checkbox (#86321) Change semantics to a checkbox, which is closer to the actual implementation and semantics (requiring onChange whenever checked is set) and also means we don't need to manually handle any aria attributes. --- .../app/components/core/switch/index.spec.tsx | 4 +- .../components/core/switch/index.stories.tsx | 86 ++++------- static/app/components/core/switch/index.tsx | 134 ++++++++++++------ .../featureFlags/customOverride.tsx | 2 +- .../featureFlags/featureFlagItem.tsx | 2 +- .../feedbackConfigToggle.tsx | 6 +- .../components/forms/fields/booleanField.tsx | 4 +- .../deprecatedAggregateFlamegraph.tsx | 2 +- .../replaysOnboarding/replayConfigToggle.tsx | 4 +- .../performance/contexts/onDemandControl.tsx | 2 +- static/app/views/dashboards/manage/index.tsx | 2 +- static/app/views/discover/landing.tsx | 2 +- .../awsLambdaFunctionSelect.tsx | 2 +- .../issueDetails/actions/publishModal.tsx | 4 +- .../views/organizationStats/usageStatsOrg.tsx | 2 +- .../settings/account/accountSubscriptions.tsx | 2 +- .../dynamicSampling/samplingModeSwitch.tsx | 2 +- .../installedPlugin.tsx | 2 +- .../integrationServerlessRow.tsx | 2 +- .../projectFilters/projectFiltersSettings.tsx | 2 +- .../settings/project/projectServiceHooks.tsx | 2 +- .../projectPlugins/projectPluginRow.tsx | 2 +- 22 files changed, 148 insertions(+), 124 deletions(-) diff --git a/static/app/components/core/switch/index.spec.tsx b/static/app/components/core/switch/index.spec.tsx index 702513564d2b1b..656ebcb285351d 100644 --- a/static/app/components/core/switch/index.spec.tsx +++ b/static/app/components/core/switch/index.spec.tsx @@ -9,14 +9,14 @@ describe('Switch', () => { }); it('checked', () => { - render(); + render(); expect(screen.getByRole('checkbox')).toBeChecked(); }); it('disallows toggling a disabled switch', async () => { const onClick = jest.fn(); - render(); + render(); const switchButton = screen.getByRole('checkbox'); expect(switchButton).not.toBeChecked(); diff --git a/static/app/components/core/switch/index.stories.tsx b/static/app/components/core/switch/index.stories.tsx index c0807f889f40b1..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,79 +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 . -

- You can also pass a prop to disable the - toggle. +

- -
- ); - }); - - 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 084a1b037b172c..96d5cb4103e4ae 100644 --- a/static/app/components/core/switch/index.tsx +++ b/static/app/components/core/switch/index.tsx @@ -2,70 +2,118 @@ import {forwardRef} from 'react'; import styled from '@emotion/styled'; export interface SwitchProps - extends Omit, 'size' | 'type'> { + extends Omit, 'size' | 'type'> { size?: 'sm' | 'lg'; } -export const Switch = forwardRef( +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; +const ToggleConfig = { + sm: { + size: 12, + top: 1, + }, + lg: { + size: 16, + top: 3, + }, +}; -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.checked ? getToggleTop(p) + getSize(p) * 0.875 : getToggleTop(p); +const ToggleWrapperSize = { + sm: 16, + lg: 24, +}; -const SwitchButton = styled('button')` - display: inline-block; - background: none; - padding: 0; - border: 1px solid ${p => p.theme.border}; +const SwitchWrapper = styled('div')` 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; + cursor: pointer; + display: inline-flex; + justify-content: flex-start; +`; + +const NativeHiddenCheckbox = styled('input')<{ + nativeSize: NonNullable; +}>` + position: absolute; + opacity: 0; + top: 0; + left: 0; + height: 100%; + width: 100%; + margin: 0; + padding: 0; + cursor: pointer; - span { - background: ${p => (p.checked ? p.theme.active : p.theme.border)}; - opacity: ${p => (p.disabled ? 0.4 : null)}; + & + div { + > div { + background: ${p => p.theme.border}; + transform: translateX(${p => ToggleConfig[p.nativeSize].top}px); + } } - &:focus, - &:focus-visible { + &: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; + } + } +`; + +const FakeCheckbox = 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; `; -const Toggle = styled('span')` - display: block; +const FakeCheckboxButton = styled('div')<{ + size: NonNullable; +}>` position: absolute; - border-radius: 50%; transition: 0.25s all ease; - top: ${getToggleTop}px; - transform: translateX(${getTranslateX}px); - width: ${getToggleSize}px; - height: ${getToggleSize}px; + border-radius: 50%; + top: ${p => ToggleConfig[p.size].top}px; + width: ${p => ToggleConfig[p.size].size}px; + height: ${p => ToggleConfig[p.size].size}px; `; diff --git a/static/app/components/devtoolbar/components/featureFlags/customOverride.tsx b/static/app/components/devtoolbar/components/featureFlags/customOverride.tsx index 3975c2d2221bd5..abd63a10361f40 100644 --- a/static/app/components/devtoolbar/components/featureFlags/customOverride.tsx +++ b/static/app/components/devtoolbar/components/featureFlags/customOverride.tsx @@ -53,7 +53,7 @@ export default function CustomOverride({ { + 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 e70cfc5ab1e35e..c29621e9018976 100644 --- a/static/app/components/devtoolbar/components/featureFlags/featureFlagItem.tsx +++ b/static/app/components/devtoolbar/components/featureFlags/featureFlagItem.tsx @@ -105,7 +105,7 @@ function FlagValueBooleanInput({flag}: {flag: FeatureFlag}) { { + onChange={() => { setOverride(flag.name, !isActive); setIsActive(!isActive); trackAnalytics?.({ diff --git a/static/app/components/feedback/feedbackOnboarding/feedbackConfigToggle.tsx b/static/app/components/feedback/feedbackOnboarding/feedbackConfigToggle.tsx index 2d2d22e39a8b27..e451e41ad5e0ba 100644 --- a/static/app/components/feedback/feedbackOnboarding/feedbackConfigToggle.tsx +++ b/static/app/components/feedback/feedbackOnboarding/feedbackConfigToggle.tsx @@ -23,17 +23,17 @@ function FeedbackConfigToggle({ {t('Name Required')} - + {t('Email Required')} - + {t('Enable Screenshots')} diff --git a/static/app/components/forms/fields/booleanField.tsx b/static/app/components/forms/fields/booleanField.tsx index b7797c0762e144..d511fa52a25da0 100644 --- a/static/app/components/forms/fields/booleanField.tsx +++ b/static/app/components/forms/fields/booleanField.tsx @@ -64,7 +64,7 @@ export default class BooleanField extends Component { size: 'lg', checked: !!value, disabled, - onClick: handleChange, + onChange: handleChange, }; if (confirm) { @@ -79,7 +79,7 @@ export default class BooleanField extends Component { { + onChange={e => { // If we have a `confirm` prop and enabling switch // Then show confirm dialog, otherwise propagate change as normal // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message diff --git a/static/app/components/profiling/flamegraph/deprecatedAggregateFlamegraph.tsx b/static/app/components/profiling/flamegraph/deprecatedAggregateFlamegraph.tsx index c5148e0273d59f..59ae7b9aefc46f 100644 --- a/static/app/components/profiling/flamegraph/deprecatedAggregateFlamegraph.tsx +++ b/static/app/components/profiling/flamegraph/deprecatedAggregateFlamegraph.tsx @@ -321,7 +321,7 @@ export function DeprecatedAggregateFlamegraph( {t('Hide System Frames')} props.setHideSystemFrames(!props.hideSystemFrames)} + onChange={() => props.setHideSystemFrames(!props.hideSystemFrames)} checked={props.hideSystemFrames} /> diff --git a/static/app/components/replaysOnboarding/replayConfigToggle.tsx b/static/app/components/replaysOnboarding/replayConfigToggle.tsx index fee17c5f4162c9..9a6d80f1deade3 100644 --- a/static/app/components/replaysOnboarding/replayConfigToggle.tsx +++ b/static/app/components/replaysOnboarding/replayConfigToggle.tsx @@ -19,11 +19,11 @@ function ReplayConfigToggle({ {t('Mask All Text')} - + {t('Block All Media')} - + ); diff --git a/static/app/utils/performance/contexts/onDemandControl.tsx b/static/app/utils/performance/contexts/onDemandControl.tsx index ea062b0c8e0151..0fa025aeb0ca2e 100644 --- a/static/app/utils/performance/contexts/onDemandControl.tsx +++ b/static/app/utils/performance/contexts/onDemandControl.tsx @@ -158,7 +158,7 @@ export function ToggleOnDemand() { }} > {t('On-demand metrics')} - + ); } diff --git a/static/app/views/dashboards/manage/index.tsx b/static/app/views/dashboards/manage/index.tsx index a1536f6122306b..0be6ffcedecebb 100644 --- a/static/app/views/dashboards/manage/index.tsx +++ b/static/app/views/dashboards/manage/index.tsx @@ -449,7 +449,7 @@ function ManageDashboards() { diff --git a/static/app/views/discover/landing.tsx b/static/app/views/discover/landing.tsx index b2226472b96c0b..7f30ef7e20be4a 100644 --- a/static/app/views/discover/landing.tsx +++ b/static/app/views/discover/landing.tsx @@ -204,7 +204,7 @@ class DiscoverLanding extends DeprecatedAsyncComponent { checked={renderPrebuilt} disabled={renderPrebuilt && (savedQueries ?? []).length === 0} size="lg" - onClick={this.togglePrebuilt} + onChange={this.togglePrebuilt} /> { diff --git a/static/app/views/issueDetails/actions/publishModal.tsx b/static/app/views/issueDetails/actions/publishModal.tsx index 5ad1d7dc8b41e8..9a8021a2ba81a8 100644 --- a/static/app/views/issueDetails/actions/publishModal.tsx +++ b/static/app/views/issueDetails/actions/publishModal.tsx @@ -51,7 +51,7 @@ export default function PublishIssueModal({ const isPublished = group?.isPublic; const hasStreamlinedUI = useHasStreamlinedUI(); const handleShare = useCallback( - (e: React.MouseEvent | null, reshare?: boolean) => { + (e: React.ChangeEvent | null, reshare?: boolean) => { e?.preventDefault(); setLoading(true); onToggle(); @@ -102,7 +102,7 @@ export default function PublishIssueModal({ aria-label={isPublished ? t('Unpublish') : t('Publish')} checked={isPublished} size="lg" - onClick={handleShare} + onChange={handleShare} /> {(!group || loading) && ( diff --git a/static/app/views/organizationStats/usageStatsOrg.tsx b/static/app/views/organizationStats/usageStatsOrg.tsx index aff987703fe3b5..9912829fd85f48 100644 --- a/static/app/views/organizationStats/usageStatsOrg.tsx +++ b/static/app/views/organizationStats/usageStatsOrg.tsx @@ -513,7 +513,7 @@ class UsageStatsOrganization< {t('Show client-discarded data:')} { + onChange={() => { handleChangeState({clientDiscard: !clientDiscard}); }} checked={clientDiscard} diff --git a/static/app/views/settings/account/accountSubscriptions.tsx b/static/app/views/settings/account/accountSubscriptions.tsx index f8280ee48271ec..4a175a9ab01e2c 100644 --- a/static/app/views/settings/account/accountSubscriptions.tsx +++ b/static/app/views/settings/account/accountSubscriptions.tsx @@ -184,7 +184,7 @@ function AccountSubscriptions() { id={`${subscription.email}-${subscription.listId}`} checked={subscription.subscribed} size="lg" - onClick={() => handleToggle(subscription)} + onChange={() => handleToggle(subscription)} /> diff --git a/static/app/views/settings/dynamicSampling/samplingModeSwitch.tsx b/static/app/views/settings/dynamicSampling/samplingModeSwitch.tsx index 4b620f4c1a7ed2..449a4710d0e815 100644 --- a/static/app/views/settings/dynamicSampling/samplingModeSwitch.tsx +++ b/static/app/views/settings/dynamicSampling/samplingModeSwitch.tsx @@ -48,7 +48,7 @@ export function SamplingModeSwitch({initialTargetRate}: Props) { > diff --git a/static/app/views/settings/organizationIntegrations/installedPlugin.tsx b/static/app/views/settings/organizationIntegrations/installedPlugin.tsx index 4892004d4f712d..df3c791a7d632b 100644 --- a/static/app/views/settings/organizationIntegrations/installedPlugin.tsx +++ b/static/app/views/settings/organizationIntegrations/installedPlugin.tsx @@ -161,7 +161,7 @@ export class InstalledPlugin extends Component { + onChange={() => this.toggleEnablePlugin(projectItem.projectId, !projectItem.enabled) } disabled={!hasAccess} diff --git a/static/app/views/settings/organizationIntegrations/integrationServerlessRow.tsx b/static/app/views/settings/organizationIntegrations/integrationServerlessRow.tsx index c9803b2a514c1c..2e6555eb1fe76e 100644 --- a/static/app/views/settings/organizationIntegrations/integrationServerlessRow.tsx +++ b/static/app/views/settings/organizationIntegrations/integrationServerlessRow.tsx @@ -133,7 +133,7 @@ export function IntegrationServerlessRow({ checked={serverlessFunction.enabled} disabled={isSubmitting} size="sm" - onClick={handleToggle} + onChange={handleToggle} /> ); diff --git a/static/app/views/settings/project/projectFilters/projectFiltersSettings.tsx b/static/app/views/settings/project/projectFilters/projectFiltersSettings.tsx index 7b595518db1418..dc26c40670238e 100644 --- a/static/app/views/settings/project/projectFilters/projectFiltersSettings.tsx +++ b/static/app/views/settings/project/projectFilters/projectFiltersSettings.tsx @@ -320,7 +320,7 @@ class LegacyBrowserFilterRow extends Component { flex-shrink: 0; margin-left: 6; `} - onClick={this.handleToggleSubfilters.bind(this, key)} + onChange={this.handleToggleSubfilters.bind(this, key)} size="lg" /> diff --git a/static/app/views/settings/project/projectServiceHooks.tsx b/static/app/views/settings/project/projectServiceHooks.tsx index 7303e535ee43e3..6a0f9cf9c7bbb6 100644 --- a/static/app/views/settings/project/projectServiceHooks.tsx +++ b/static/app/views/settings/project/projectServiceHooks.tsx @@ -60,7 +60,7 @@ function ServiceHookRow({orgId, projectId, hook, onToggleActive}: RowProps) { } > - + ); } diff --git a/static/app/views/settings/projectPlugins/projectPluginRow.tsx b/static/app/views/settings/projectPlugins/projectPluginRow.tsx index 57e5dfe16c50bc..93339ce4c0f42f 100644 --- a/static/app/views/settings/projectPlugins/projectPluginRow.tsx +++ b/static/app/views/settings/projectPlugins/projectPluginRow.tsx @@ -96,7 +96,7 @@ class ProjectPluginRow extends PureComponent { size="lg" disabled={!hasAccess || !canDisable} checked={enabled} - onClick={this.handleChange} + onChange={this.handleChange} /> ); From d4f1917030745acabb6cc12b3196ef3a15f09645 Mon Sep 17 00:00:00 2001 From: Raj Joshi Date: Wed, 5 Mar 2025 10:08:30 -0500 Subject: [PATCH 19/39] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20ref(metric=20alerts)?= =?UTF-8?q?:=20refactor=20`get=5Fmetric=5Fissue=5Faggregates`=20(#86266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit continuing refactors for aci metric alerts, this time `get_metric_issue_aggregates` --- .../discord/message_builder/metric_alerts.py | 24 ++++-- src/sentry/integrations/metric_alerts.py | 83 +++++++++++-------- .../card_builder/incident_attachment.py | 26 +++--- src/sentry/integrations/opsgenie/utils.py | 21 ++++- src/sentry/integrations/pagerduty/utils.py | 20 ++++- .../slack/message_builder/incidents.py | 26 ++++-- .../rules/actions/notify_event_service.py | 23 ++++- .../sentry/integrations/test_metric_alerts.py | 75 +++++++++++------ 8 files changed, 206 insertions(+), 92 deletions(-) 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/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/tests/sentry/integrations/test_metric_alerts.py b/tests/sentry/integrations/test_metric_alerts.py index f107427089fa82..e239edce68a1eb 100644 --- a/tests/sentry/integrations/test_metric_alerts.py +++ b/tests/sentry/integrations/test_metric_alerts.py @@ -7,7 +7,7 @@ from sentry.incidents.logic import CRITICAL_TRIGGER_LABEL from sentry.incidents.models.alert_rule import AlertRuleDetectionType, AlertRuleThresholdType from sentry.incidents.models.incident import IncidentStatus, IncidentTrigger -from sentry.integrations.metric_alerts import incident_attachment_info +from sentry.integrations.metric_alerts import AlertContext, incident_attachment_info from sentry.snuba.dataset import Dataset from sentry.snuba.models import SnubaQuery from sentry.testutils.cases import BaseIncidentsTest, BaseMetricsTestCase, TestCase @@ -16,6 +16,17 @@ pytestmark = pytest.mark.sentry_metrics +def incident_attachment_info_with_metric_value(incident, new_status, metric_value): + return incident_attachment_info( + AlertContext.from_alert_rule_incident(incident.alert_rule), + open_period_identifier=incident.identifier, + new_status=new_status, + organization=incident.organization, + snuba_query=incident.alert_rule.snuba_query, + metric_value=metric_value, + ) + + class IncidentAttachmentInfoTest(TestCase, BaseIncidentsTest): def test_returns_correct_info(self): alert_rule = self.create_alert_rule() @@ -36,9 +47,12 @@ def test_returns_correct_info(self): referrer = "metric_alert_custom" notification_uuid = str(uuid.uuid4()) data = incident_attachment_info( - incident, - IncidentStatus.CLOSED, - metric_value, + AlertContext.from_alert_rule_incident(incident.alert_rule), + open_period_identifier=incident.identifier, + new_status=IncidentStatus.CLOSED, + organization=incident.organization, + snuba_query=alert_rule.snuba_query, + metric_value=metric_value, notification_uuid=notification_uuid, referrer=referrer, ) @@ -46,7 +60,6 @@ def test_returns_correct_info(self): assert data["title"] == f"Resolved: {alert_rule.name}" assert data["status"] == "Resolved" assert data["text"] == "123 events in the last 10 minutes" - assert data["date_started"] == date_started assert ( data["title_link"] == f"http://testserver/organizations/baz/alerts/rules/details/{alert_rule.id}/?alert={incident.identifier}&referrer={referrer}&detection_type=static¬ification_uuid={notification_uuid}" @@ -85,13 +98,14 @@ def test_with_incident_trigger(self): incident_trigger.update(date_modified=now) # Test the trigger "firing" - data = incident_attachment_info(incident, IncidentStatus.CRITICAL, metric_value=4) + data = incident_attachment_info_with_metric_value( + incident, IncidentStatus.CRITICAL, metric_value=4 + ) assert data["title"] == "Critical: {}".format( alert_rule.name ) # Pulls from trigger, not incident assert data["status"] == "Critical" # Should pull from the action/trigger. assert data["text"] == "4 events in the last 10 minutes" - assert data["date_started"] == date_started assert ( data["title_link"] == f"http://testserver/organizations/baz/alerts/rules/details/{alert_rule.id}/?alert={incident.identifier}&referrer=metric_alert&detection_type=static" @@ -102,11 +116,12 @@ def test_with_incident_trigger(self): ) # Test the trigger "resolving" - data = incident_attachment_info(incident, IncidentStatus.CLOSED, metric_value=4) + data = incident_attachment_info_with_metric_value( + incident, IncidentStatus.CLOSED, metric_value=4 + ) assert data["title"] == f"Resolved: {alert_rule.name}" assert data["status"] == "Resolved" assert data["text"] == "4 events in the last 10 minutes" - assert data["date_started"] == date_started assert ( data["title_link"] == f"http://testserver/organizations/baz/alerts/rules/details/{alert_rule.id}/?alert={incident.identifier}&referrer=metric_alert&detection_type=static" @@ -117,11 +132,12 @@ def test_with_incident_trigger(self): ) # No trigger passed, uses incident as fallback - data = incident_attachment_info(incident, IncidentStatus.CLOSED, metric_value=4) + data = incident_attachment_info_with_metric_value( + incident, IncidentStatus.CLOSED, metric_value=4 + ) assert data["title"] == f"Resolved: {alert_rule.name}" assert data["status"] == "Resolved" assert data["text"] == "4 events in the last 10 minutes" - assert data["date_started"] == date_started assert ( data["title_link"] == f"http://testserver/organizations/baz/alerts/rules/details/{alert_rule.id}/?alert={incident.identifier}&referrer=metric_alert&detection_type=static" @@ -152,7 +168,9 @@ def test_percent_change_alert(self): alert_rule_trigger=trigger, triggered_for_incident=incident ) metric_value = 123.12 - data = incident_attachment_info(incident, IncidentStatus.CRITICAL, metric_value) + data = incident_attachment_info_with_metric_value( + incident, IncidentStatus.CRITICAL, metric_value + ) assert ( data["text"] == "Events 123% higher in the last 10 minutes compared to the same time one hour ago" @@ -181,7 +199,9 @@ def test_percent_change_alert_rpc(self): alert_rule_trigger=trigger, triggered_for_incident=incident ) metric_value = 123.12 - data = incident_attachment_info(incident, IncidentStatus.CRITICAL, metric_value) + data = incident_attachment_info_with_metric_value( + incident, IncidentStatus.CRITICAL, metric_value + ) assert ( data["text"] == "Events 123% higher in the last 10 minutes compared to the same time one hour ago" @@ -208,7 +228,9 @@ def test_percent_change_alert_custom_comparison_delta(self): alert_rule_trigger=trigger, triggered_for_incident=incident ) metric_value = 123.12 - data = incident_attachment_info(incident, IncidentStatus.CRITICAL, metric_value) + data = incident_attachment_info_with_metric_value( + incident, IncidentStatus.CRITICAL, metric_value + ) assert ( data["text"] == "Events 123% higher in the last 10 minutes compared to the same time 720 minutes ago" @@ -272,12 +294,13 @@ def create_daily_incident_and_related_objects(self, field="sessions"): def test_with_incident_trigger_sessions(self): self.create_incident_and_related_objects() - data = incident_attachment_info(self.incident, IncidentStatus.CRITICAL, 92) + data = incident_attachment_info_with_metric_value( + self.incident, IncidentStatus.CRITICAL, 92 + ) assert data["title"] == f"Critical: {self.alert_rule.name}" assert data["status"] == "Critical" assert data["text"] == "92% sessions crash free rate in the last hour" - assert data["date_started"] == self.date_started assert ( data["logo_url"] == "http://testserver/_static/{version}/sentry/images/sentry-email-avatar.png" @@ -285,11 +308,12 @@ def test_with_incident_trigger_sessions(self): def test_with_incident_trigger_sessions_resolve(self): self.create_incident_and_related_objects() - data = incident_attachment_info(self.incident, IncidentStatus.CLOSED, metric_value=100.0) + data = incident_attachment_info_with_metric_value( + self.incident, IncidentStatus.CLOSED, metric_value=100.0 + ) assert data["title"] == f"Resolved: {self.alert_rule.name}" assert data["status"] == "Resolved" assert data["text"] == "100.0% sessions crash free rate in the last hour" - assert data["date_started"] == self.date_started assert ( data["logo_url"] == "http://testserver/_static/{version}/sentry/images/sentry-email-avatar.png" @@ -297,11 +321,12 @@ def test_with_incident_trigger_sessions_resolve(self): def test_with_incident_trigger_users(self): self.create_incident_and_related_objects(field="users") - data = incident_attachment_info(self.incident, IncidentStatus.CRITICAL, 92) + data = incident_attachment_info_with_metric_value( + self.incident, IncidentStatus.CRITICAL, 92 + ) assert data["title"] == f"Critical: {self.alert_rule.name}" assert data["status"] == "Critical" assert data["text"] == "92% users crash free rate in the last hour" - assert data["date_started"] == self.date_started assert ( data["logo_url"] == "http://testserver/_static/{version}/sentry/images/sentry-email-avatar.png" @@ -309,11 +334,12 @@ def test_with_incident_trigger_users(self): def test_with_incident_trigger_users_resolve(self): self.create_incident_and_related_objects(field="users") - data = incident_attachment_info(self.incident, IncidentStatus.CLOSED, metric_value=100.0) + data = incident_attachment_info_with_metric_value( + self.incident, IncidentStatus.CLOSED, metric_value=100.0 + ) assert data["title"] == f"Resolved: {self.alert_rule.name}" assert data["status"] == "Resolved" assert data["text"] == "100.0% users crash free rate in the last hour" - assert data["date_started"] == self.date_started assert ( data["logo_url"] == "http://testserver/_static/{version}/sentry/images/sentry-email-avatar.png" @@ -321,13 +347,12 @@ def test_with_incident_trigger_users_resolve(self): def test_with_daily_incident_trigger_users_resolve(self): self.create_daily_incident_and_related_objects(field="users") - data = incident_attachment_info( + data = incident_attachment_info_with_metric_value( self.daily_incident, IncidentStatus.CLOSED, metric_value=100.0 ) assert data["title"] == f"Resolved: {self.daily_alert_rule.name}" assert data["status"] == "Resolved" assert data["text"] == "100.0% users crash free rate in the last day" - assert data["date_started"] == self.date_started assert ( data["logo_url"] == "http://testserver/_static/{version}/sentry/images/sentry-email-avatar.png" @@ -352,7 +377,7 @@ def test_with_incident_where_no_sessions_exist(self): self.create_alert_rule_trigger_action( alert_rule_trigger=trigger, triggered_for_incident=incident ) - data = incident_attachment_info(incident, IncidentStatus.CRITICAL, 0) + data = incident_attachment_info_with_metric_value(incident, IncidentStatus.CRITICAL, 0) assert data["title"] == f"Critical: {alert_rule.name}" assert data["status"] == "Critical" From d8f014de87b7b23520304024b9de9f39af58c11a Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 5 Mar 2025 10:17:08 -0500 Subject: [PATCH 20/39] badge: chonkify badge and tags (#86207) Designs have changed, this PR adjusts them to the latest spec --- .../app/components/core/badge/index.chonk.tsx | 46 +++++----- .../app/components/core/badge/tag.chonk.tsx | 90 +++++++++++++++++++ static/app/components/core/badge/tag.tsx | 7 +- 3 files changed, 116 insertions(+), 27 deletions(-) create mode 100644 static/app/components/core/badge/tag.chonk.tsx 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.tsx b/static/app/components/core/badge/tag.tsx index 002031e52c7c48..72d5c140b0bccb 100644 --- a/static/app/components/core/badge/tag.tsx +++ b/static/app/components/core/badge/tag.tsx @@ -6,6 +6,9 @@ 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 @@ -62,7 +65,7 @@ export const Tag = forwardRef( } ); -const StyledTag = styled('div')<{ +const TagPill = styled('div')<{ type: NonNullable; }>` font-size: ${p => p.theme.fontSizeSmall}; @@ -83,6 +86,8 @@ const StyledTag = styled('div')<{ } `; +const StyledTag = withChonk(TagPill, ChonkTag.TagPill, ChonkTag.chonkTagPropMapping); + const Text = styled('div')` overflow: hidden; white-space: nowrap; From 3faedd7db9e0f460c8e52af694bb981205773d9f Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Wed, 5 Mar 2025 10:25:59 -0500 Subject: [PATCH 21/39] fix(widget-viewer): Append sort field if not plotted in chart (#86384) The condition was only applying to Discover widgets, but this should apply for all widgets. Also normalizes the sort to check the aliased format in case a widget is still using that format, so we don't add a column that's already there Fixes #86338 --- .../modals/widgetViewerModal.spec.tsx | 29 +++++++++++++++++++ .../components/modals/widgetViewerModal.tsx | 8 +++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/static/app/components/modals/widgetViewerModal.spec.tsx b/static/app/components/modals/widgetViewerModal.spec.tsx index 0907853d880497..d1ee3ed47c690e 100644 --- a/static/app/components/modals/widgetViewerModal.spec.tsx +++ b/static/app/components/modals/widgetViewerModal.spec.tsx @@ -847,6 +847,35 @@ describe('Modals -> WidgetViewerModal', function () { await waitForMetaToHaveBeenCalled(); expect(eventsStatsMock).toHaveBeenCalledTimes(1); }); + + it('appends the orderby to the query if it is not already selected as an aggregate', async function () { + const eventsStatsMock = mockEventsStats(); + mockEvents(); + + const widget = WidgetFixture({ + widgetType: WidgetType.TRANSACTIONS, + queries: [ + { + orderby: '-epm()', + aggregates: ['count()'], + columns: ['country'], + conditions: '', + name: '', + }, + ], + }); + + await renderModal({initialData, widget}); + expect(await screen.findByText('epm()')).toBeInTheDocument(); + expect(eventsStatsMock).toHaveBeenCalledWith( + '/organizations/org-slug/events-stats/', + expect.objectContaining({ + query: expect.objectContaining({ + field: ['country', 'count()', 'epm()'], + }), + }) + ); + }); }); describe('Table Widget', function () { diff --git a/static/app/components/modals/widgetViewerModal.tsx b/static/app/components/modals/widgetViewerModal.tsx index 6c94741d38b0f3..245d490d99eefd 100644 --- a/static/app/components/modals/widgetViewerModal.tsx +++ b/static/app/components/modals/widgetViewerModal.tsx @@ -38,6 +38,7 @@ import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery'; import type EventView from 'sentry/utils/discover/eventView'; import type {AggregationOutputType} from 'sentry/utils/discover/fields'; import { + getAggregateAlias, isAggregateField, isEquation, isEquationAlias, @@ -284,15 +285,16 @@ function WidgetViewerModal(props: Props) { ? tableWidget.queries[0]!.fields : [...columns, ...aggregates]; - // Some Discover Widgets (Line, Area, Bar) allow the user to specify an orderby + // Timeseries Widgets (Line, Area, Bar) allow the user to specify an orderby // that is not explicitly selected as an aggregate or column. We need to explicitly // include the orderby in the table widget aggregates and columns otherwise // eventsv2 will complain about sorting on an unselected field. if ( - widget.widgetType === WidgetType.DISCOVER && orderby && !isEquationAlias(rawOrderby) && - !fields.includes(rawOrderby) + // Normalize to the aggregate alias because we may still have widgets + // that store that format + !fields.map(getAggregateAlias).includes(getAggregateAlias(rawOrderby)) ) { fields.push(rawOrderby); [tableWidget, primaryWidget].forEach(aggregatesAndColumns => { From 9479cdeb4f114e8ca7469e91dd0653703ad90927 Mon Sep 17 00:00:00 2001 From: ArthurKnaus Date: Wed, 5 Mar 2025 16:28:20 +0100 Subject: [PATCH 22/39] ref(laravel-insights): Split into multiple files (#86385) Create a separate file for each widget. --- .../pages/backend/backendOverviewPage.tsx | 2 +- .../pages/backend/laravel/cachesWidget.tsx | 157 +++ .../pages/backend/laravel/durationWidget.tsx | 69 + .../insights/pages/backend/laravel/index.tsx | 240 ++++ .../pages/backend/laravel/issuesWidget.tsx | 77 ++ .../pages/backend/laravel/jobsWidget.tsx | 113 ++ .../pages/backend/laravel/pathsTable.tsx | 278 ++++ .../pages/backend/laravel/queriesWidget.tsx | 195 +++ .../pages/backend/laravel/requestsWidget.tsx | 99 ++ .../insights/pages/backend/laravel/utils.tsx | 24 + .../pages/backend/laravelOverviewPage.tsx | 1190 ----------------- 11 files changed, 1253 insertions(+), 1191 deletions(-) create mode 100644 static/app/views/insights/pages/backend/laravel/cachesWidget.tsx create mode 100644 static/app/views/insights/pages/backend/laravel/durationWidget.tsx create mode 100644 static/app/views/insights/pages/backend/laravel/index.tsx create mode 100644 static/app/views/insights/pages/backend/laravel/issuesWidget.tsx create mode 100644 static/app/views/insights/pages/backend/laravel/jobsWidget.tsx create mode 100644 static/app/views/insights/pages/backend/laravel/pathsTable.tsx create mode 100644 static/app/views/insights/pages/backend/laravel/queriesWidget.tsx create mode 100644 static/app/views/insights/pages/backend/laravel/requestsWidget.tsx create mode 100644 static/app/views/insights/pages/backend/laravel/utils.tsx delete mode 100644 static/app/views/insights/pages/backend/laravelOverviewPage.tsx diff --git a/static/app/views/insights/pages/backend/backendOverviewPage.tsx b/static/app/views/insights/pages/backend/backendOverviewPage.tsx index 00b7d650ff4b68..5bcc60f6964d2d 100644 --- a/static/app/views/insights/pages/backend/backendOverviewPage.tsx +++ b/static/app/views/insights/pages/backend/backendOverviewPage.tsx @@ -34,7 +34,7 @@ import {ToolRibbon} from 'sentry/views/insights/common/components/ribbon'; import {useOnboardingProject} from 'sentry/views/insights/common/queries/useOnboardingProject'; import {ViewTrendsButton} from 'sentry/views/insights/common/viewTrendsButton'; import {BackendHeader} from 'sentry/views/insights/pages/backend/backendPageHeader'; -import {LaravelOverviewPage} from 'sentry/views/insights/pages/backend/laravelOverviewPage'; +import {LaravelOverviewPage} from 'sentry/views/insights/pages/backend/laravel'; import { BACKEND_LANDING_TITLE, OVERVIEW_PAGE_ALLOWED_OPS, diff --git a/static/app/views/insights/pages/backend/laravel/cachesWidget.tsx b/static/app/views/insights/pages/backend/laravel/cachesWidget.tsx new file mode 100644 index 00000000000000..f594746622af43 --- /dev/null +++ b/static/app/views/insights/pages/backend/laravel/cachesWidget.tsx @@ -0,0 +1,157 @@ +import {Fragment, useMemo} from 'react'; +import {Link} from 'react-router-dom'; +import styled from '@emotion/styled'; + +import {space} from 'sentry/styles/space'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import {MutableSearch} from 'sentry/utils/tokenizeSearch'; +import useOrganization from 'sentry/utils/useOrganization'; +import {MISSING_DATA_MESSAGE} from 'sentry/views/dashboards/widgets/common/settings'; +import {TimeSeriesWidgetVisualization} from 'sentry/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization'; +import {Widget} from 'sentry/views/dashboards/widgets/widget/widget'; +import type {DiscoverSeries} from 'sentry/views/insights/common/queries/useDiscoverSeries'; +import {useSpanMetricsTopNSeries} from 'sentry/views/insights/common/queries/useSpanMetricsTopNSeries'; +import {convertSeriesToTimeseries} from 'sentry/views/insights/common/utils/convertSeriesToTimeseries'; +import {usePageFilterChartParams} from 'sentry/views/insights/pages/backend/laravel/utils'; + +export function CachesWidget({query}: {query?: string}) { + const organization = useOrganization(); + const pageFilterChartParams = usePageFilterChartParams(); + + const cachesRequest = useApiQuery<{ + data: Array<{ + 'cache_miss_rate()': number; + 'project.id': string; + transaction: string; + }>; + }>( + [ + `/organizations/${organization.slug}/events/`, + { + query: { + ...pageFilterChartParams, + dataset: 'spansMetrics', + field: ['transaction', 'project.id', 'cache_miss_rate()'], + query: `span.op:[cache.get_item,cache.get] ${query}`, + sort: '-cache_miss_rate()', + per_page: 4, + }, + }, + ], + {staleTime: 0} + ); + + const timeSeriesRequest = useSpanMetricsTopNSeries({ + search: new MutableSearch( + // Cannot use transaction:[value1, value2] syntax as + // MutableSearch might escape it to transactions:"[value1, value2]" for some values + cachesRequest.data?.data + .map(item => `transaction:"${item.transaction}"`) + .join(' OR ') || '' + ), + fields: ['transaction', 'cache_miss_rate()'], + yAxis: ['cache_miss_rate()'], + sorts: [ + { + field: 'cache_miss_rate()', + kind: 'desc', + }, + ], + topEvents: 4, + enabled: !!cachesRequest.data?.data, + }); + + const timeSeries = useMemo(() => { + if (!timeSeriesRequest.data && timeSeriesRequest.meta) { + return []; + } + + return Object.keys(timeSeriesRequest.data).map(key => { + const seriesData = timeSeriesRequest.data[key]!; + return { + ...seriesData, + // TODO(aknaus): useSpanMetricsTopNSeries does not return the meta for the series + meta: { + fields: { + [seriesData.seriesName]: 'percentage', + }, + units: { + [seriesData.seriesName]: '%', + }, + }, + }; + }); + }, [timeSeriesRequest.data, timeSeriesRequest.meta]); + + const isLoading = timeSeriesRequest.isLoading || cachesRequest.isLoading; + const error = timeSeriesRequest.error || cachesRequest.error; + + const hasData = + cachesRequest.data && cachesRequest.data.data.length > 0 && timeSeries.length > 0; + + return ( + } + Visualization={ + isLoading ? ( + + ) : error ? ( + + ) : !hasData ? ( + + ) : ( + + ) + } + Footer={ + hasData && ( + + {cachesRequest.data?.data.map(item => ( + + + + {item.transaction} + + + {(item['cache_miss_rate()'] * 100).toFixed(2)}% + + ))} + + ) + } + /> + ); +} + +const OverflowCell = styled('div')` + ${p => p.theme.overflowEllipsis}; + min-width: 0px; +`; + +const WidgetFooterTable = styled('div')` + display: grid; + grid-template-columns: 1fr max-content; + margin: -${space(1)} -${space(2)}; + font-size: ${p => p.theme.fontSizeSmall}; + + & > * { + padding: ${space(1)} ${space(1)}; + } + + & > *:nth-child(2n + 1) { + padding-left: ${space(2)}; + } + + & > *:nth-child(2n) { + padding-right: ${space(2)}; + } + + & > *:not(:nth-last-child(-n + 2)) { + border-bottom: 1px solid ${p => p.theme.border}; + } +`; diff --git a/static/app/views/insights/pages/backend/laravel/durationWidget.tsx b/static/app/views/insights/pages/backend/laravel/durationWidget.tsx new file mode 100644 index 00000000000000..742f9eada6fa84 --- /dev/null +++ b/static/app/views/insights/pages/backend/laravel/durationWidget.tsx @@ -0,0 +1,69 @@ +import {useCallback, useMemo} from 'react'; + +import {CHART_PALETTE} from 'sentry/constants/chartPalette'; +import type {MultiSeriesEventsStats} from 'sentry/types/organization'; +import type {EventsMetaType} from 'sentry/utils/discover/eventView'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import useOrganization from 'sentry/utils/useOrganization'; +import {InsightsLineChartWidget} from 'sentry/views/insights/common/components/insightsLineChartWidget'; +import type {DiscoverSeries} from 'sentry/views/insights/common/queries/useDiscoverSeries'; +import {usePageFilterChartParams} from 'sentry/views/insights/pages/backend/laravel/utils'; + +export function DurationWidget({query}: {query?: string}) { + const organization = useOrganization(); + const pageFilterChartParams = usePageFilterChartParams(); + + const {data, isLoading, error} = useApiQuery( + [ + `/organizations/${organization.slug}/events-stats/`, + { + query: { + ...pageFilterChartParams, + dataset: 'spans', + yAxis: ['avg(span.duration)', 'p95(span.duration)'], + orderby: 'avg(span.duration)', + partial: 1, + useRpc: 1, + query: `span.op:http.server ${query}`.trim(), + }, + }, + ], + {staleTime: 0} + ); + + const getTimeSeries = useCallback( + (field: string, color?: string): DiscoverSeries | undefined => { + const series = data?.[field]; + if (!series) { + return undefined; + } + + return { + data: series.data.map(([time, [value]]) => ({ + value: value?.count!, + name: new Date(time * 1000).toISOString(), + })), + seriesName: field, + meta: series.meta as EventsMetaType, + color, + } satisfies DiscoverSeries; + }, + [data] + ); + + const timeSeries = useMemo(() => { + return [ + getTimeSeries('avg(span.duration)', CHART_PALETTE[1][0]), + getTimeSeries('p95(span.duration)', CHART_PALETTE[1][1]), + ].filter(series => !!series); + }, [getTimeSeries]); + + return ( + + ); +} diff --git a/static/app/views/insights/pages/backend/laravel/index.tsx b/static/app/views/insights/pages/backend/laravel/index.tsx new file mode 100644 index 00000000000000..d454b7e18d4638 --- /dev/null +++ b/static/app/views/insights/pages/backend/laravel/index.tsx @@ -0,0 +1,240 @@ +import styled from '@emotion/styled'; + +import Feature from 'sentry/components/acl/feature'; +import * as Layout from 'sentry/components/layouts/thirds'; +import {NoAccess} from 'sentry/components/noAccess'; +import {DatePageFilter} from 'sentry/components/organizations/datePageFilter'; +import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter'; +import PageFilterBar from 'sentry/components/organizations/pageFilterBar'; +import {ProjectPageFilter} from 'sentry/components/organizations/projectPageFilter'; +import PanelHeader from 'sentry/components/panels/panelHeader'; +import TransactionNameSearchBar from 'sentry/components/performance/searchBar'; +import {space} from 'sentry/styles/space'; +import {canUseMetricsData} from 'sentry/utils/performance/contexts/metricsEnhancedSetting'; +import {PerformanceDisplayProvider} from 'sentry/utils/performance/contexts/performanceDisplayContext'; +import {MutableSearch} from 'sentry/utils/tokenizeSearch'; +import {useLocation} from 'sentry/utils/useLocation'; +import {useNavigate} from 'sentry/utils/useNavigate'; +import useOrganization from 'sentry/utils/useOrganization'; +import * as ModuleLayout from 'sentry/views/insights/common/components/moduleLayout'; +import {ToolRibbon} from 'sentry/views/insights/common/components/ribbon'; +import {useOnboardingProject} from 'sentry/views/insights/common/queries/useOnboardingProject'; +import {ViewTrendsButton} from 'sentry/views/insights/common/viewTrendsButton'; +import {BackendHeader} from 'sentry/views/insights/pages/backend/backendPageHeader'; +import {CachesWidget} from 'sentry/views/insights/pages/backend/laravel/cachesWidget'; +import {DurationWidget} from 'sentry/views/insights/pages/backend/laravel/durationWidget'; +import {IssuesWidget} from 'sentry/views/insights/pages/backend/laravel/issuesWidget'; +import {JobsWidget} from 'sentry/views/insights/pages/backend/laravel/jobsWidget'; +import {PathsTable} from 'sentry/views/insights/pages/backend/laravel/pathsTable'; +import {QueriesWidget} from 'sentry/views/insights/pages/backend/laravel/queriesWidget'; +import {RequestsWidget} from 'sentry/views/insights/pages/backend/laravel/requestsWidget'; +import {BACKEND_LANDING_TITLE} from 'sentry/views/insights/pages/backend/settings'; +import {generateBackendPerformanceEventView} from 'sentry/views/performance/data'; +import {LegacyOnboarding} from 'sentry/views/performance/onboarding'; +import { + getTransactionSearchQuery, + ProjectPerformanceType, +} from 'sentry/views/performance/utils'; + +function getFreeTextFromQuery(query: string) { + const conditions = new MutableSearch(query); + const transactionValues = conditions.getFilterValues('transaction'); + if (transactionValues.length) { + return transactionValues[0]; + } + if (conditions.freeText.length > 0) { + // raw text query will be wrapped in wildcards in generatePerformanceEventView + // so no need to wrap it here + return conditions.freeText.join(' '); + } + return ''; +} + +export function LaravelOverviewPage() { + const organization = useOrganization(); + const location = useLocation(); + const onboardingProject = useOnboardingProject(); + const navigate = useNavigate(); + + const withStaticFilters = canUseMetricsData(organization); + const eventView = generateBackendPerformanceEventView(location, withStaticFilters); + + const showOnboarding = onboardingProject !== undefined; + + function handleSearch(searchQuery: string) { + navigate({ + pathname: location.pathname, + query: { + ...location.query, + cursor: undefined, + query: String(searchQuery).trim() || undefined, + isDefaultQuery: false, + }, + }); + } + + const derivedQuery = getTransactionSearchQuery(location, eventView.query); + + return ( + + } + /> + + + + + + + + + + + {!showOnboarding && ( + { + handleSearch(query); + }} + query={getFreeTextFromQuery(derivedQuery)!} + /> + )} + + + + {!showOnboarding && ( + + + + + + + + + + + + + + + + + + + + + + + + )} + {showOnboarding && ( + + )} + + + + + + ); +} + +const WidgetGrid = styled('div')` + display: grid; + gap: ${space(2)}; + padding-bottom: ${space(2)}; + + grid-template-columns: minmax(0, 1fr); + grid-template-rows: 180px 180px 300px 180px 300px 300px; + grid-template-areas: + 'requests' + 'duration' + 'issues' + 'jobs' + 'queries' + 'caches'; + + @media (min-width: ${p => p.theme.breakpoints.xsmall}) { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-rows: 180px 300px 180px 300px; + grid-template-areas: + 'requests duration' + 'issues issues' + 'jobs jobs' + 'queries caches'; + } + + @media (min-width: ${p => p.theme.breakpoints.large}) { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); + grid-template-rows: 180px 180px 300px; + grid-template-areas: + 'requests issues issues' + 'duration issues issues' + 'jobs queries caches'; + } +`; + +const RequestsContainer = styled('div')` + grid-area: requests; + min-width: 0; + & > * { + height: 100% !important; + } +`; + +// TODO(aknaus): Remove css hacks and build custom IssuesWidget +const IssuesContainer = styled('div')` + grid-area: issues; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 1fr; + & > * { + min-width: 0; + overflow-y: auto; + margin-bottom: 0 !important; + } + + & ${PanelHeader} { + position: sticky; + top: 0; + z-index: ${p => p.theme.zIndex.header}; + } +`; + +const DurationContainer = styled('div')` + grid-area: duration; + min-width: 0; + & > * { + height: 100% !important; + } +`; + +const JobsContainer = styled('div')` + grid-area: jobs; + min-width: 0; + & > * { + height: 100% !important; + } +`; + +const QueriesContainer = styled('div')` + grid-area: queries; +`; + +const CachesContainer = styled('div')` + grid-area: caches; +`; + +const StyledTransactionNameSearchBar = styled(TransactionNameSearchBar)` + flex: 2; +`; diff --git a/static/app/views/insights/pages/backend/laravel/issuesWidget.tsx b/static/app/views/insights/pages/backend/laravel/issuesWidget.tsx new file mode 100644 index 00000000000000..4f0857ccda716d --- /dev/null +++ b/static/app/views/insights/pages/backend/laravel/issuesWidget.tsx @@ -0,0 +1,77 @@ +import pick from 'lodash/pick'; + +import GroupList from 'sentry/components/issues/groupList'; +import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse'; +import Panel from 'sentry/components/panels/panel'; +import PanelBody from 'sentry/components/panels/panelBody'; +import {DEFAULT_STATS_PERIOD} from 'sentry/constants'; +import {URL_PARAM} from 'sentry/constants/pageFilters'; +import {t, tct} from 'sentry/locale'; +import {decodeScalar} from 'sentry/utils/queryString'; +import useApi from 'sentry/utils/useApi'; +import {useBreakpoints} from 'sentry/utils/useBreakpoints'; +import {useLocation} from 'sentry/utils/useLocation'; +import useOrganization from 'sentry/utils/useOrganization'; +import {usePageFilterChartParams} from 'sentry/views/insights/pages/backend/laravel/utils'; +import NoGroupsHandler from 'sentry/views/issueList/noGroupsHandler'; + +export function IssuesWidget({query = ''}: {query?: string}) { + const api = useApi(); + const organization = useOrganization(); + const location = useLocation(); + + const pageFilterChartParams = usePageFilterChartParams({granularity: 'spans-low'}); + + const queryParams = { + limit: '5', + ...normalizeDateTimeParams( + pick(location.query, [...Object.values(URL_PARAM), 'cursor']) + ), + query, + sort: 'freq', + }; + + const breakpoints = useBreakpoints(); + + function renderEmptyMessage() { + const selectedTimePeriod = location.query.start + ? null + : // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + DEFAULT_RELATIVE_PERIODS[ + decodeScalar(location.query.statsPeriod, DEFAULT_STATS_PERIOD) + ]; + const displayedPeriod = selectedTimePeriod + ? selectedTimePeriod.toLowerCase() + : t('given timeframe'); + + return ( + + + + + + ); + } + + // TODO(aknaus): Remove GroupList and use StreamGroup directly + return ( + + ); +} diff --git a/static/app/views/insights/pages/backend/laravel/jobsWidget.tsx b/static/app/views/insights/pages/backend/laravel/jobsWidget.tsx new file mode 100644 index 00000000000000..36da6ab79bd5bb --- /dev/null +++ b/static/app/views/insights/pages/backend/laravel/jobsWidget.tsx @@ -0,0 +1,113 @@ +import {useMemo} from 'react'; +import {useTheme} from '@emotion/react'; + +import type {MultiSeriesEventsStats} from 'sentry/types/organization'; +import {parsePeriodToHours} from 'sentry/utils/duration/parsePeriodToHours'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import useOrganization from 'sentry/utils/useOrganization'; +import {InsightsBarChartWidget} from 'sentry/views/insights/common/components/insightsBarChartWidget'; +import type {DiscoverSeries} from 'sentry/views/insights/common/queries/useDiscoverSeries'; +import {usePageFilterChartParams} from 'sentry/views/insights/pages/backend/laravel/utils'; + +export function JobsWidget({query}: {query?: string}) { + const organization = useOrganization(); + const pageFilterChartParams = usePageFilterChartParams({ + granularity: 'low', + }); + const theme = useTheme(); + + const {data, isLoading, error} = useApiQuery( + [ + `/organizations/${organization.slug}/events-stats/`, + { + query: { + ...pageFilterChartParams, + dataset: 'spansMetrics', + excludeOther: 0, + per_page: 50, + partial: 1, + transformAliasToInputFormat: 1, + query: `span.op:queue.process ${query}`.trim(), + yAxis: ['trace_status_rate(ok)', 'spm()'], + }, + }, + ], + {staleTime: 0} + ); + + const intervalInMinutes = parsePeriodToHours(pageFilterChartParams.interval) * 60; + + const timeSeries = useMemo(() => { + if (!data) { + return []; + } + + const okJobsRate = data['trace_status_rate(ok)']; + const spansPerMinute = data['spm()']; + + if (!okJobsRate || !spansPerMinute) { + return []; + } + + const getSpansInTimeBucket = (index: number) => { + const spansPerMinuteValue = spansPerMinute.data[index]?.[1][0]?.count! || 0; + return spansPerMinuteValue * intervalInMinutes; + }; + + const [okJobs, failedJobs] = okJobsRate.data.reduce<[DiscoverSeries, DiscoverSeries]>( + (acc, [time, [value]], index) => { + const spansInTimeBucket = getSpansInTimeBucket(index); + const okJobsRateValue = value?.count! || 0; + const failedJobsRateValue = value?.count ? 1 - value.count : 1; + + acc[0].data.push({ + value: okJobsRateValue * spansInTimeBucket, + name: new Date(time * 1000).toISOString(), + }); + + acc[1].data.push({ + value: failedJobsRateValue * spansInTimeBucket, + name: new Date(time * 1000).toISOString(), + }); + + return acc; + }, + [ + { + data: [], + color: theme.gray200, + seriesName: 'Processed', + meta: { + fields: { + Processed: 'integer', + }, + units: {}, + }, + }, + { + data: [], + color: theme.error, + seriesName: 'Failed', + meta: { + fields: { + Failed: 'integer', + }, + units: {}, + }, + }, + ] + ); + + return [okJobs, failedJobs]; + }, [data, intervalInMinutes, theme.error, theme.gray200]); + + return ( + + ); +} diff --git a/static/app/views/insights/pages/backend/laravel/pathsTable.tsx b/static/app/views/insights/pages/backend/laravel/pathsTable.tsx new file mode 100644 index 00000000000000..f76d5df1b0832b --- /dev/null +++ b/static/app/views/insights/pages/backend/laravel/pathsTable.tsx @@ -0,0 +1,278 @@ +import {Fragment, useMemo} from 'react'; +import {Link} from 'react-router-dom'; +import {css, useTheme} from '@emotion/react'; +import styled from '@emotion/styled'; + +import {PanelTable} from 'sentry/components/panels/panelTable'; +import Placeholder from 'sentry/components/placeholder'; +import {Tooltip} from 'sentry/components/tooltip'; +import {IconArrow, IconUser} from 'sentry/icons'; +import {space} from 'sentry/styles/space'; +import getDuration from 'sentry/utils/duration/getDuration'; +import {formatAbbreviatedNumber} from 'sentry/utils/formatters'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import useOrganization from 'sentry/utils/useOrganization'; +import {usePageFilterChartParams} from 'sentry/views/insights/pages/backend/laravel/utils'; +import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils'; + +interface DiscoverQueryResponse { + data: Array<{ + 'avg(transaction.duration)': number; + 'count()': number; + 'count_unique(user)': number; + 'failure_rate()': number; + 'http.method': string; + 'p95()': number; + 'project.id': string; + transaction: string; + }>; +} + +interface RouteControllerMapping { + 'count(span.duration)': number; + 'span.description': string; + transaction: string; + 'transaction.method': string; +} + +const errorRateColorThreshold = { + danger: 0.1, + warning: 0.05, +} as const; + +const getP95Threshold = (avg: number) => { + return { + danger: avg * 3, + warning: avg * 2, + }; +}; + +const getCellColor = (value: number, thresholds: Record) => { + return Object.entries(thresholds).find(([_, threshold]) => value >= threshold)?.[0]; +}; + +export function PathsTable({query}: {query?: string}) { + const organization = useOrganization(); + const pageFilterChartParams = usePageFilterChartParams(); + const theme = useTheme(); + + const transactionsRequest = useApiQuery( + [ + `/organizations/${organization.slug}/events/`, + { + query: { + ...pageFilterChartParams, + dataset: 'metrics', + field: [ + 'http.method', + 'project.id', + 'transaction', + 'avg(transaction.duration)', + 'p95()', + 'failure_rate()', + 'count()', + 'count_unique(user)', + ], + query: `(transaction.op:http.server) event.type:transaction ${query}`, + referrer: 'api.performance.landing-table', + orderby: '-count()', + per_page: 10, + }, + }, + ], + {staleTime: 0} + ); + + // Get the list of transactions from the first request + const transactionPaths = useMemo(() => { + return ( + transactionsRequest.data?.data.map(transactions => transactions.transaction) ?? [] + ); + }, [transactionsRequest.data]); + + const routeControllersRequest = useApiQuery<{data: RouteControllerMapping[]}>( + [ + `/organizations/${organization.slug}/events/`, + { + query: { + ...pageFilterChartParams, + dataset: 'spans', + field: [ + 'span.description', + 'transaction', + 'transaction.method', + 'count(span.duration)', + ], + // Add transaction filter to route controller request + query: `transaction.op:http.server span.op:http.route transaction:[${ + transactionPaths.map(transactions => `"${transactions}"`).join(',') || '""' + }]`, + sort: '-transaction', + per_page: 25, + }, + }, + ], + { + staleTime: 0, + // Only fetch after we have the transactions data and there are transactions to look up + enabled: !!transactionsRequest.data?.data && transactionPaths.length > 0, + } + ); + + const tableData = useMemo(() => { + if (!transactionsRequest.data?.data) { + return []; + } + + // Create a mapping of transaction path to controller + const controllerMap = new Map( + routeControllersRequest.data?.data.map(item => [ + item.transaction, + item['span.description'], + ]) + ); + + return transactionsRequest.data.data.map(transaction => ({ + method: transaction['http.method'], + transaction: transaction.transaction, + requests: transaction['count()'], + avg: transaction['avg(transaction.duration)'], + p95: transaction['p95()'], + errorRate: transaction['failure_rate()'], + users: transaction['count_unique(user)'], + controller: controllerMap.get(transaction.transaction), + projectId: transaction['project.id'], + })); + }, [transactionsRequest.data, routeControllersRequest.data]); + + return ( + + + Requests + , + 'Error Rate', + 'AVG', + 'P95', + + Users + , + ]} + isLoading={transactionsRequest.isLoading} + isEmpty={!tableData || tableData.length === 0} + > + {tableData?.map(transaction => { + const p95Color = getCellColor(transaction.p95, getP95Threshold(transaction.avg)); + const errorRateColor = getCellColor( + transaction.errorRate, + errorRateColorThreshold + ); + + return ( + + {transaction.method} + + + + {transaction.transaction} + + + {routeControllersRequest.isLoading ? ( + + ) : ( + transaction.controller && ( + + {transaction.controller} + + ) + )} + + {formatAbbreviatedNumber(transaction.requests)} + + {(transaction.errorRate * 100).toFixed(2)}% + + {getDuration(transaction.avg / 1000, 2, true, true)} + + {getDuration(transaction.p95 / 1000, 2, true, true)} + + + {formatAbbreviatedNumber(transaction.users)} + + + + ); + })} + + ); +} + +const StyledPanelTable = styled(PanelTable)` + grid-template-columns: max-content minmax(200px, 1fr) repeat(5, max-content); +`; + +const Cell = styled('div')` + display: flex; + align-items: center; + gap: ${space(0.5)}; + overflow: hidden; + white-space: nowrap; + padding: ${space(1)} ${space(2)}; + + &[data-color='danger'] { + color: ${p => p.theme.red400}; + } + &[data-color='warning'] { + color: ${p => p.theme.yellow400}; + } + &[data-align='right'] { + text-align: right; + justify-content: flex-end; + } +`; + +const HeaderCell = styled(Cell)` + padding: 0; +`; + +const PathCell = styled(Cell)` + flex-direction: column; + justify-content: center; + align-items: flex-start; + gap: ${space(0.5)}; + min-width: 0px; +`; + +const ControllerText = styled('div')` + ${p => p.theme.overflowEllipsis}; + color: ${p => p.theme.gray300}; + font-size: ${p => p.theme.fontSizeSmall}; + line-height: 1; + min-width: 0px; +`; diff --git a/static/app/views/insights/pages/backend/laravel/queriesWidget.tsx b/static/app/views/insights/pages/backend/laravel/queriesWidget.tsx new file mode 100644 index 00000000000000..99f009b5b0f460 --- /dev/null +++ b/static/app/views/insights/pages/backend/laravel/queriesWidget.tsx @@ -0,0 +1,195 @@ +import {Fragment, useMemo} from 'react'; +import styled from '@emotion/styled'; + +import {space} from 'sentry/styles/space'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import {MutableSearch} from 'sentry/utils/tokenizeSearch'; +import useOrganization from 'sentry/utils/useOrganization'; +import {MISSING_DATA_MESSAGE} from 'sentry/views/dashboards/widgets/common/settings'; +import {TimeSeriesWidgetVisualization} from 'sentry/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization'; +import {Widget} from 'sentry/views/dashboards/widgets/widget/widget'; +import {SpanDescriptionCell} from 'sentry/views/insights/common/components/tableCells/spanDescriptionCell'; +import {TimeSpentCell} from 'sentry/views/insights/common/components/tableCells/timeSpentCell'; +import type {DiscoverSeries} from 'sentry/views/insights/common/queries/useDiscoverSeries'; +import {useSpanMetricsTopNSeries} from 'sentry/views/insights/common/queries/useSpanMetricsTopNSeries'; +import {convertSeriesToTimeseries} from 'sentry/views/insights/common/utils/convertSeriesToTimeseries'; +import {usePageFilterChartParams} from 'sentry/views/insights/pages/backend/laravel/utils'; +import {ModuleName} from 'sentry/views/insights/types'; + +interface QueriesDiscoverQueryResponse { + data: Array<{ + 'avg(span.self_time)': number; + 'project.id': string; + 'span.description': string; + 'span.group': string; + 'span.op': string; + 'sum(span.self_time)': number; + 'time_spent_percentage()': number; + transaction: string; + }>; +} + +export function QueriesWidget({query}: {query?: string}) { + const organization = useOrganization(); + const pageFilterChartParams = usePageFilterChartParams(); + + const queriesRequest = useApiQuery( + [ + `/organizations/${organization.slug}/events/`, + { + query: { + ...pageFilterChartParams, + dataset: 'spansMetrics', + field: [ + 'span.op', + 'span.group', + 'project.id', + 'span.description', + 'sum(span.self_time)', + 'avg(span.self_time)', + 'time_spent_percentage()', + 'transaction', + ], + query: `has:span.description span.module:db ${query}`, + sort: '-time_spent_percentage()', + per_page: 3, + }, + }, + ], + {staleTime: 0} + ); + + const timeSeriesRequest = useSpanMetricsTopNSeries({ + search: new MutableSearch( + // Cannot use transaction:[value1, value2] syntax as + // MutableSearch might escape it to transactions:"[value1, value2]" for some values + queriesRequest.data?.data + .map(item => `span.group:"${item['span.group']}"`) + .join(' OR ') || '' + ), + fields: ['span.group', 'sum(span.self_time)'], + yAxis: ['sum(span.self_time)'], + sorts: [ + { + field: 'sum(span.self_time)', + kind: 'desc', + }, + ], + topEvents: 3, + enabled: !!queriesRequest.data?.data, + }); + + const timeSeries = useMemo(() => { + if (!timeSeriesRequest.data && timeSeriesRequest.meta) { + return []; + } + + return Object.keys(timeSeriesRequest.data).map(key => { + const seriesData = timeSeriesRequest.data[key]!; + return { + ...seriesData, + // TODO(aknaus): useSpanMetricsTopNSeries does not return the meta for the series + meta: { + fields: { + [seriesData.seriesName]: 'duration', + }, + units: { + [seriesData.seriesName]: 'millisecond', + }, + }, + }; + }); + }, [timeSeriesRequest.data, timeSeriesRequest.meta]); + + const isLoading = timeSeriesRequest.isLoading || queriesRequest.isLoading; + const error = timeSeriesRequest.error || queriesRequest.error; + + const hasData = + queriesRequest.data && queriesRequest.data.data.length > 0 && timeSeries.length > 0; + + return ( + } + Visualization={ + isLoading ? ( + + ) : error ? ( + + ) : !hasData ? ( + + ) : ( + [ + item['span.group'], + item['span.description'], + ]) ?? [] + )} + timeSeries={timeSeries.map(convertSeriesToTimeseries)} + /> + ) + } + noFooterPadding + Footer={ + hasData && ( + + {queriesRequest.data?.data.map(item => ( + + + + {item.transaction} + + + + ))} + + ) + } + /> + ); +} + +const OverflowCell = styled('div')` + ${p => p.theme.overflowEllipsis}; + min-width: 0px; +`; + +const WidgetFooterTable = styled('div')` + display: grid; + grid-template-columns: 1fr max-content; + font-size: ${p => p.theme.fontSizeSmall}; + + & > * { + padding: ${space(1)} ${space(1)}; + } + + & > *:nth-child(2n + 1) { + padding-left: ${space(2)}; + } + + & > *:nth-child(2n) { + padding-right: ${space(2)}; + } + + & > *:not(:nth-last-child(-n + 2)) { + border-bottom: 1px solid ${p => p.theme.border}; + } +`; + +const ControllerText = styled('div')` + ${p => p.theme.overflowEllipsis}; + color: ${p => p.theme.gray300}; + font-size: ${p => p.theme.fontSizeSmall}; + line-height: 1; + min-width: 0px; +`; diff --git a/static/app/views/insights/pages/backend/laravel/requestsWidget.tsx b/static/app/views/insights/pages/backend/laravel/requestsWidget.tsx new file mode 100644 index 00000000000000..efd0bd74e10a59 --- /dev/null +++ b/static/app/views/insights/pages/backend/laravel/requestsWidget.tsx @@ -0,0 +1,99 @@ +import {useCallback, useMemo} from 'react'; +import {useTheme} from '@emotion/react'; + +import type {EventsStats, MultiSeriesEventsStats} from 'sentry/types/organization'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import useOrganization from 'sentry/utils/useOrganization'; +import {InsightsBarChartWidget} from 'sentry/views/insights/common/components/insightsBarChartWidget'; +import type {DiscoverSeries} from 'sentry/views/insights/common/queries/useDiscoverSeries'; +import {usePageFilterChartParams} from 'sentry/views/insights/pages/backend/laravel/utils'; + +export function RequestsWidget({query}: {query?: string}) { + const organization = useOrganization(); + const pageFilterChartParams = usePageFilterChartParams({granularity: 'spans-low'}); + const theme = useTheme(); + + const {data, isLoading, error} = useApiQuery( + [ + `/organizations/${organization.slug}/events-stats/`, + { + query: { + ...pageFilterChartParams, + dataset: 'spans', + field: ['trace.status', 'count(span.duration)'], + yAxis: 'count(span.duration)', + orderby: '-count(span.duration)', + partial: 1, + query: `span.op:http.server ${query}`.trim(), + useRpc: 1, + topEvents: 10, + }, + }, + ], + {staleTime: 0} + ); + + const combineTimeSeries = useCallback( + ( + seriesData: EventsStats[], + color: string, + fieldName: string + ): DiscoverSeries | undefined => { + const firstSeries = seriesData[0]; + if (!firstSeries) { + return undefined; + } + + return { + data: firstSeries.data.map(([time], index) => ({ + name: new Date(time * 1000).toISOString(), + value: seriesData.reduce( + (acc, series) => acc + series.data[index]?.[1][0]?.count!, + 0 + ), + })), + seriesName: fieldName, + meta: { + fields: { + [fieldName]: 'integer', + }, + units: {}, + }, + color, + } satisfies DiscoverSeries; + }, + [] + ); + + const timeSeries = useMemo(() => { + return [ + combineTimeSeries( + [data?.ok].filter(series => !!series), + theme.gray200, + '2xx' + ), + combineTimeSeries( + [data?.invalid_argument, data?.internal_error].filter(series => !!series), + theme.error, + '5xx' + ), + ].filter(series => !!series); + }, [ + combineTimeSeries, + data?.internal_error, + data?.invalid_argument, + data?.ok, + theme.error, + theme.gray200, + ]); + + return ( + + ); +} diff --git a/static/app/views/insights/pages/backend/laravel/utils.tsx b/static/app/views/insights/pages/backend/laravel/utils.tsx new file mode 100644 index 00000000000000..34edd8c722870e --- /dev/null +++ b/static/app/views/insights/pages/backend/laravel/utils.tsx @@ -0,0 +1,24 @@ +import {useMemo} from 'react'; + +import {type Fidelity, getInterval} from 'sentry/components/charts/utils'; +import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse'; +import usePageFilters from 'sentry/utils/usePageFilters'; + +export function usePageFilterChartParams({ + granularity = 'spans', +}: { + granularity?: Fidelity; +} = {}) { + const {selection} = usePageFilters(); + + const normalizedDateTime = useMemo( + () => normalizeDateTimeParams(selection.datetime), + [selection.datetime] + ); + + return { + ...normalizedDateTime, + interval: getInterval(selection.datetime, granularity), + project: selection.projects, + }; +} diff --git a/static/app/views/insights/pages/backend/laravelOverviewPage.tsx b/static/app/views/insights/pages/backend/laravelOverviewPage.tsx deleted file mode 100644 index 73e475eb83dee8..00000000000000 --- a/static/app/views/insights/pages/backend/laravelOverviewPage.tsx +++ /dev/null @@ -1,1190 +0,0 @@ -import {Fragment, useCallback, useMemo} from 'react'; -import {css, useTheme} from '@emotion/react'; -import styled from '@emotion/styled'; -import type {Location} from 'history'; -import pick from 'lodash/pick'; - -import type {Client} from 'sentry/api'; -import Feature from 'sentry/components/acl/feature'; -import {type Fidelity, getInterval} from 'sentry/components/charts/utils'; -import GroupList from 'sentry/components/issues/groupList'; -import * as Layout from 'sentry/components/layouts/thirds'; -import Link from 'sentry/components/links/link'; -import {NoAccess} from 'sentry/components/noAccess'; -import {DatePageFilter} from 'sentry/components/organizations/datePageFilter'; -import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter'; -import PageFilterBar from 'sentry/components/organizations/pageFilterBar'; -import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse'; -import {ProjectPageFilter} from 'sentry/components/organizations/projectPageFilter'; -import Panel from 'sentry/components/panels/panel'; -import PanelBody from 'sentry/components/panels/panelBody'; -import PanelHeader from 'sentry/components/panels/panelHeader'; -import {PanelTable} from 'sentry/components/panels/panelTable'; -import TransactionNameSearchBar from 'sentry/components/performance/searchBar'; -import Placeholder from 'sentry/components/placeholder'; -import {Tooltip} from 'sentry/components/tooltip'; -import {DEFAULT_RELATIVE_PERIODS, DEFAULT_STATS_PERIOD} from 'sentry/constants'; -import {CHART_PALETTE} from 'sentry/constants/chartPalette'; -import {URL_PARAM} from 'sentry/constants/pageFilters'; -import {IconArrow, IconUser} from 'sentry/icons'; -import {t, tct} from 'sentry/locale'; -import {space} from 'sentry/styles/space'; -import type { - EventsStats, - MultiSeriesEventsStats, - Organization, -} from 'sentry/types/organization'; -import type {EventsMetaType} from 'sentry/utils/discover/eventView'; -import getDuration from 'sentry/utils/duration/getDuration'; -import {parsePeriodToHours} from 'sentry/utils/duration/parsePeriodToHours'; -import {formatAbbreviatedNumber} from 'sentry/utils/formatters'; -import {canUseMetricsData} from 'sentry/utils/performance/contexts/metricsEnhancedSetting'; -import {PerformanceDisplayProvider} from 'sentry/utils/performance/contexts/performanceDisplayContext'; -import {useApiQuery} from 'sentry/utils/queryClient'; -import {decodeScalar} from 'sentry/utils/queryString'; -import {MutableSearch} from 'sentry/utils/tokenizeSearch'; -import useApi from 'sentry/utils/useApi'; -import {useBreakpoints} from 'sentry/utils/useBreakpoints'; -import {useLocation} from 'sentry/utils/useLocation'; -import {useNavigate} from 'sentry/utils/useNavigate'; -import useOrganization from 'sentry/utils/useOrganization'; -import usePageFilters from 'sentry/utils/usePageFilters'; -import {MISSING_DATA_MESSAGE} from 'sentry/views/dashboards/widgets/common/settings'; -import {TimeSeriesWidgetVisualization} from 'sentry/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization'; -import {Widget} from 'sentry/views/dashboards/widgets/widget/widget'; -import * as ModuleLayout from 'sentry/views/insights/common/components/moduleLayout'; -import {ToolRibbon} from 'sentry/views/insights/common/components/ribbon'; -import {SpanDescriptionCell} from 'sentry/views/insights/common/components/tableCells/spanDescriptionCell'; -import {TimeSpentCell} from 'sentry/views/insights/common/components/tableCells/timeSpentCell'; -import {useOnboardingProject} from 'sentry/views/insights/common/queries/useOnboardingProject'; -import {useSpanMetricsTopNSeries} from 'sentry/views/insights/common/queries/useSpanMetricsTopNSeries'; -import {convertSeriesToTimeseries} from 'sentry/views/insights/common/utils/convertSeriesToTimeseries'; -import {ViewTrendsButton} from 'sentry/views/insights/common/viewTrendsButton'; -import {BackendHeader} from 'sentry/views/insights/pages/backend/backendPageHeader'; -import {BACKEND_LANDING_TITLE} from 'sentry/views/insights/pages/backend/settings'; -import {ModuleName} from 'sentry/views/insights/types'; -import NoGroupsHandler from 'sentry/views/issueList/noGroupsHandler'; -import {generateBackendPerformanceEventView} from 'sentry/views/performance/data'; -import {LegacyOnboarding} from 'sentry/views/performance/onboarding'; -import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils'; -import { - getTransactionSearchQuery, - ProjectPerformanceType, -} from 'sentry/views/performance/utils'; - -import {InsightsBarChartWidget} from '../../common/components/insightsBarChartWidget'; -import {InsightsLineChartWidget} from '../../common/components/insightsLineChartWidget'; -import type {DiscoverSeries} from '../../common/queries/useDiscoverSeries'; - -function getFreeTextFromQuery(query: string) { - const conditions = new MutableSearch(query); - const transactionValues = conditions.getFilterValues('transaction'); - if (transactionValues.length) { - return transactionValues[0]; - } - if (conditions.freeText.length > 0) { - // raw text query will be wrapped in wildcards in generatePerformanceEventView - // so no need to wrap it here - return conditions.freeText.join(' '); - } - return ''; -} - -export function LaravelOverviewPage() { - const api = useApi(); - const organization = useOrganization(); - const location = useLocation(); - const onboardingProject = useOnboardingProject(); - const {selection} = usePageFilters(); - const navigate = useNavigate(); - - const withStaticFilters = canUseMetricsData(organization); - const eventView = generateBackendPerformanceEventView(location, withStaticFilters); - - const showOnboarding = onboardingProject !== undefined; - - function handleSearch(searchQuery: string) { - navigate({ - pathname: location.pathname, - query: { - ...location.query, - cursor: undefined, - query: String(searchQuery).trim() || undefined, - isDefaultQuery: false, - }, - }); - } - - const derivedQuery = getTransactionSearchQuery(location, eventView.query); - - return ( - - } - /> - - - - - - - - - - - {!showOnboarding && ( - { - handleSearch(query); - }} - query={getFreeTextFromQuery(derivedQuery)!} - /> - )} - - - - {!showOnboarding && ( - - - - - - - - - - - - - - - - - - - - - - - - )} - {showOnboarding && ( - - )} - - - - - - ); -} - -const WidgetGrid = styled('div')` - display: grid; - gap: ${space(2)}; - padding-bottom: ${space(2)}; - - grid-template-columns: minmax(0, 1fr); - grid-template-rows: repeat(6, 300px); - grid-template-areas: - 'requests' - 'issues' - 'duration' - 'jobs' - 'queries' - 'caches'; - - @media (min-width: ${p => p.theme.breakpoints.xsmall}) { - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - grid-template-rows: 300px 270px repeat(2, 300px); - grid-template-areas: - 'requests duration' - 'issues issues' - 'jobs queries' - 'caches caches'; - } - - @media (min-width: ${p => p.theme.breakpoints.large}) { - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); - grid-template-rows: 200px 200px repeat(1, 300px); - grid-template-areas: - 'requests issues issues' - 'duration issues issues' - 'jobs queries caches'; - } -`; - -const RequestsContainer = styled('div')` - grid-area: requests; - min-width: 0; - & > * { - height: 100% !important; - } -`; - -// TODO(aknaus): Remove css hacks and build custom IssuesWidget -const IssuesContainer = styled('div')` - grid-area: issues; - display: grid; - grid-template-columns: 1fr; - grid-template-rows: 1fr; - & > * { - min-width: 0; - overflow-y: auto; - margin-bottom: 0 !important; - } - - & ${PanelHeader} { - position: sticky; - top: 0; - z-index: ${p => p.theme.zIndex.header}; - } -`; - -const DurationContainer = styled('div')` - grid-area: duration; - min-width: 0; - & > * { - height: 100% !important; - } -`; - -const JobsContainer = styled('div')` - grid-area: jobs; - min-width: 0; - & > * { - height: 100% !important; - } -`; - -// TODO(aknaus): Remove css hacks and build custom QueryWidget -const QueriesContainer = styled('div')` - grid-area: queries; - display: grid; - grid-template-columns: 1fr; - grid-template-rows: 1fr; - - & > * { - min-width: 0; - } -`; - -// TODO(aknaus): Remove css hacks and build custom CacheWidget -const CachesContainer = styled('div')` - grid-area: caches; - display: grid; - grid-template-columns: 1fr; - grid-template-rows: 1fr; - - & > * { - min-width: 0; - } -`; - -const StyledTransactionNameSearchBar = styled(TransactionNameSearchBar)` - flex: 2; -`; - -type IssuesWidgetProps = { - api: Client; - location: Location; - organization: Organization; - projectId: number; - query: string; -}; - -function IssuesWidget({ - organization, - location, - projectId, - query, - api, -}: IssuesWidgetProps) { - const queryParams = { - limit: '5', - ...normalizeDateTimeParams( - pick(location.query, [...Object.values(URL_PARAM), 'cursor']) - ), - query, - sort: 'freq', - }; - - const breakpoints = useBreakpoints(); - - function renderEmptyMessage() { - const selectedTimePeriod = location.query.start - ? null - : // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message - DEFAULT_RELATIVE_PERIODS[ - decodeScalar(location.query.statsPeriod, DEFAULT_STATS_PERIOD) - ]; - const displayedPeriod = selectedTimePeriod - ? selectedTimePeriod.toLowerCase() - : t('given timeframe'); - - return ( - - - - - - ); - } - - // TODO(aknaus): Remove GroupList and use StreamGroup directly - return ( - - ); -} - -function usePageFilterChartParams({ - granularity = 'spans', -}: { - granularity?: Fidelity; -} = {}) { - const {selection} = usePageFilters(); - - const normalizedDateTime = useMemo( - () => normalizeDateTimeParams(selection.datetime), - [selection.datetime] - ); - - return { - ...normalizedDateTime, - interval: getInterval(selection.datetime, granularity), - project: selection.projects, - }; -} - -function RequestsWidget({query}: {query?: string}) { - const organization = useOrganization(); - const pageFilterChartParams = usePageFilterChartParams({granularity: 'spans-low'}); - const theme = useTheme(); - - const {data, isLoading, error} = useApiQuery( - [ - `/organizations/${organization.slug}/events-stats/`, - { - query: { - ...pageFilterChartParams, - dataset: 'spans', - field: ['trace.status', 'count(span.duration)'], - yAxis: 'count(span.duration)', - orderby: '-count(span.duration)', - partial: 1, - query: `span.op:http.server ${query}`.trim(), - useRpc: 1, - topEvents: 10, - }, - }, - ], - {staleTime: 0} - ); - - const combineTimeSeries = useCallback( - ( - seriesData: EventsStats[], - color: string, - fieldName: string - ): DiscoverSeries | undefined => { - const firstSeries = seriesData[0]; - if (!firstSeries) { - return undefined; - } - - return { - data: firstSeries.data.map(([time], index) => ({ - name: new Date(time * 1000).toISOString(), - value: seriesData.reduce( - (acc, series) => acc + series.data[index]?.[1][0]?.count!, - 0 - ), - })), - seriesName: fieldName, - meta: { - fields: { - [fieldName]: 'integer', - }, - units: {}, - }, - color, - } satisfies DiscoverSeries; - }, - [] - ); - - const timeSeries = useMemo(() => { - return [ - combineTimeSeries( - [data?.ok].filter(series => !!series), - theme.gray200, - '2xx' - ), - combineTimeSeries( - [data?.invalid_argument, data?.internal_error].filter(series => !!series), - theme.error, - '5xx' - ), - ].filter(series => !!series); - }, [ - combineTimeSeries, - data?.internal_error, - data?.invalid_argument, - data?.ok, - theme.error, - theme.gray200, - ]); - - return ( - - ); -} - -function DurationWidget({query}: {query?: string}) { - const organization = useOrganization(); - const pageFilterChartParams = usePageFilterChartParams(); - - const {data, isLoading, error} = useApiQuery( - [ - `/organizations/${organization.slug}/events-stats/`, - { - query: { - ...pageFilterChartParams, - dataset: 'spans', - yAxis: ['avg(span.duration)', 'p95(span.duration)'], - orderby: 'avg(span.duration)', - partial: 1, - useRpc: 1, - query: `span.op:http.server ${query}`.trim(), - }, - }, - ], - {staleTime: 0} - ); - - const getTimeSeries = useCallback( - (field: string, color?: string): DiscoverSeries | undefined => { - const series = data?.[field]; - if (!series) { - return undefined; - } - - return { - data: series.data.map(([time, [value]]) => ({ - value: value?.count!, - name: new Date(time * 1000).toISOString(), - })), - seriesName: field, - meta: series.meta as EventsMetaType, - color, - } satisfies DiscoverSeries; - }, - [data] - ); - - const timeSeries = useMemo(() => { - return [ - getTimeSeries('avg(span.duration)', CHART_PALETTE[1][0]), - getTimeSeries('p95(span.duration)', CHART_PALETTE[1][1]), - ].filter(series => !!series); - }, [getTimeSeries]); - - return ( - - ); -} - -function JobsWidget({query}: {query?: string}) { - const organization = useOrganization(); - const pageFilterChartParams = usePageFilterChartParams({ - granularity: 'low', - }); - const theme = useTheme(); - - const {data, isLoading, error} = useApiQuery( - [ - `/organizations/${organization.slug}/events-stats/`, - { - query: { - ...pageFilterChartParams, - dataset: 'spansMetrics', - excludeOther: 0, - per_page: 50, - partial: 1, - transformAliasToInputFormat: 1, - query: `span.op:queue.process ${query}`.trim(), - yAxis: ['trace_status_rate(ok)', 'spm()'], - }, - }, - ], - {staleTime: 0} - ); - - const intervalInMinutes = parsePeriodToHours(pageFilterChartParams.interval) * 60; - - const timeSeries = useMemo(() => { - if (!data) { - return []; - } - - const okJobsRate = data['trace_status_rate(ok)']; - const spansPerMinute = data['spm()']; - - if (!okJobsRate || !spansPerMinute) { - return []; - } - - const getSpansInTimeBucket = (index: number) => { - const spansPerMinuteValue = spansPerMinute.data[index]?.[1][0]?.count! || 0; - return spansPerMinuteValue * intervalInMinutes; - }; - - const [okJobs, failedJobs] = okJobsRate.data.reduce<[DiscoverSeries, DiscoverSeries]>( - (acc, [time, [value]], index) => { - const spansInTimeBucket = getSpansInTimeBucket(index); - const okJobsRateValue = value?.count! || 0; - const failedJobsRateValue = value?.count ? 1 - value.count : 1; - - acc[0].data.push({ - value: okJobsRateValue * spansInTimeBucket, - name: new Date(time * 1000).toISOString(), - }); - - acc[1].data.push({ - value: failedJobsRateValue * spansInTimeBucket, - name: new Date(time * 1000).toISOString(), - }); - - return acc; - }, - [ - { - data: [], - color: theme.gray200, - seriesName: 'Processed', - meta: { - fields: { - Processed: 'integer', - }, - units: {}, - }, - }, - { - data: [], - color: theme.error, - seriesName: 'Failed', - meta: { - fields: { - Failed: 'integer', - }, - units: {}, - }, - }, - ] - ); - - return [okJobs, failedJobs]; - }, [data, intervalInMinutes, theme.error, theme.gray200]); - - return ( - - ); -} - -interface QueriesDiscoverQueryResponse { - data: Array<{ - 'avg(span.self_time)': number; - 'project.id': string; - 'span.description': string; - 'span.group': string; - 'span.op': string; - 'sum(span.self_time)': number; - 'time_spent_percentage()': number; - transaction: string; - }>; -} - -function QueriesWidget({query}: {query?: string}) { - const organization = useOrganization(); - const pageFilterChartParams = usePageFilterChartParams(); - - const queriesRequest = useApiQuery( - [ - `/organizations/${organization.slug}/events/`, - { - query: { - ...pageFilterChartParams, - dataset: 'spansMetrics', - field: [ - 'span.op', - 'span.group', - 'project.id', - 'span.description', - 'sum(span.self_time)', - 'avg(span.self_time)', - 'time_spent_percentage()', - 'transaction', - ], - query: `has:span.description span.module:db ${query}`, - sort: '-time_spent_percentage()', - per_page: 3, - }, - }, - ], - {staleTime: 0} - ); - - const timeSeriesRequest = useSpanMetricsTopNSeries({ - search: new MutableSearch( - // Cannot use transaction:[value1, value2] syntax as - // MutableSearch might escape it to transactions:"[value1, value2]" for some values - queriesRequest.data?.data - .map(item => `span.group:"${item['span.group']}"`) - .join(' OR ') || '' - ), - fields: ['span.group', 'sum(span.self_time)'], - yAxis: ['sum(span.self_time)'], - sorts: [ - { - field: 'sum(span.self_time)', - kind: 'desc', - }, - ], - topEvents: 3, - enabled: !!queriesRequest.data?.data, - }); - - const timeSeries = useMemo(() => { - if (!timeSeriesRequest.data && timeSeriesRequest.meta) { - return []; - } - - return Object.keys(timeSeriesRequest.data).map(key => { - const seriesData = timeSeriesRequest.data[key]!; - return { - ...seriesData, - // TODO(aknaus): useSpanMetricsTopNSeries does not return the meta for the series - meta: { - fields: { - [seriesData.seriesName]: 'duration', - }, - units: { - [seriesData.seriesName]: 'millisecond', - }, - }, - }; - }); - }, [timeSeriesRequest.data, timeSeriesRequest.meta]); - - const isLoading = timeSeriesRequest.isLoading || queriesRequest.isLoading; - const error = timeSeriesRequest.error || queriesRequest.error; - - const hasData = - queriesRequest.data && queriesRequest.data.data.length > 0 && timeSeries.length > 0; - - return ( - } - Visualization={ - isLoading ? ( - - ) : error ? ( - - ) : !hasData ? ( - - ) : ( - [ - item['span.group'], - item['span.description'], - ]) ?? [] - )} - timeSeries={timeSeries.map(convertSeriesToTimeseries)} - /> - ) - } - Footer={ - hasData && ( - - {queriesRequest.data?.data.map(item => ( - - - - {item.transaction} - - - - ))} - - ) - } - /> - ); -} - -function CachesWidget({query}: {query?: string}) { - const organization = useOrganization(); - const pageFilterChartParams = usePageFilterChartParams(); - - const cachesRequest = useApiQuery<{ - data: Array<{ - 'cache_miss_rate()': number; - 'project.id': string; - transaction: string; - }>; - }>( - [ - `/organizations/${organization.slug}/events/`, - { - query: { - ...pageFilterChartParams, - dataset: 'spansMetrics', - field: ['transaction', 'project.id', 'cache_miss_rate()'], - query: `span.op:[cache.get_item,cache.get] ${query}`, - sort: '-cache_miss_rate()', - per_page: 4, - }, - }, - ], - {staleTime: 0} - ); - - const timeSeriesRequest = useSpanMetricsTopNSeries({ - search: new MutableSearch( - // Cannot use transaction:[value1, value2] syntax as - // MutableSearch might escape it to transactions:"[value1, value2]" for some values - cachesRequest.data?.data - .map(item => `transaction:"${item.transaction}"`) - .join(' OR ') || '' - ), - fields: ['transaction', 'cache_miss_rate()'], - yAxis: ['cache_miss_rate()'], - sorts: [ - { - field: 'cache_miss_rate()', - kind: 'desc', - }, - ], - topEvents: 4, - enabled: !!cachesRequest.data?.data, - }); - - const timeSeries = useMemo(() => { - if (!timeSeriesRequest.data && timeSeriesRequest.meta) { - return []; - } - - return Object.keys(timeSeriesRequest.data).map(key => { - const seriesData = timeSeriesRequest.data[key]!; - return { - ...seriesData, - // TODO(aknaus): useSpanMetricsTopNSeries does not return the meta for the series - meta: { - fields: { - [seriesData.seriesName]: 'percentage', - }, - units: { - [seriesData.seriesName]: '%', - }, - }, - }; - }); - }, [timeSeriesRequest.data, timeSeriesRequest.meta]); - - const isLoading = timeSeriesRequest.isLoading || cachesRequest.isLoading; - const error = timeSeriesRequest.error || cachesRequest.error; - - const hasData = - cachesRequest.data && cachesRequest.data.data.length > 0 && timeSeries.length > 0; - - return ( - } - Visualization={ - isLoading ? ( - - ) : error ? ( - - ) : !hasData ? ( - - ) : ( - - ) - } - Footer={ - hasData && ( - - {cachesRequest.data?.data.map(item => ( - - - - {item.transaction} - - - {(item['cache_miss_rate()'] * 100).toFixed(2)}% - - ))} - - ) - } - /> - ); -} - -const OverflowCell = styled('div')` - ${p => p.theme.overflowEllipsis}; - min-width: 0px; -`; - -const WidgetFooterTable = styled('div')` - display: grid; - grid-template-columns: 1fr max-content; - margin: -${space(1)} -${space(2)}; - font-size: ${p => p.theme.fontSizeSmall}; - - & > * { - padding: ${space(1)} ${space(1)}; - } - - & > *:nth-child(2n + 1) { - padding-left: ${space(2)}; - } - - & > *:nth-child(2n) { - padding-right: ${space(2)}; - } - - & > *:not(:nth-last-child(-n + 2)) { - border-bottom: 1px solid ${p => p.theme.border}; - } -`; - -interface DiscoverQueryResponse { - data: Array<{ - 'avg(transaction.duration)': number; - 'count()': number; - 'count_unique(user)': number; - 'failure_rate()': number; - 'http.method': string; - 'p95()': number; - 'project.id': string; - transaction: string; - }>; -} - -interface RouteControllerMapping { - 'count(span.duration)': number; - 'span.description': string; - transaction: string; - 'transaction.method': string; -} - -const errorRateColorThreshold = { - danger: 0.1, - warning: 0.05, -} as const; - -const getP95Threshold = (avg: number) => { - return { - danger: avg * 3, - warning: avg * 2, - }; -}; - -const getCellColor = (value: number, thresholds: Record) => { - return Object.entries(thresholds).find(([_, threshold]) => value >= threshold)?.[0]; -}; - -const StyledPanelTable = styled(PanelTable)` - grid-template-columns: max-content minmax(200px, 1fr) repeat(5, max-content); -`; - -const Cell = styled('div')` - display: flex; - align-items: center; - gap: ${space(0.5)}; - overflow: hidden; - white-space: nowrap; - padding: ${space(1)} ${space(2)}; - - &[data-color='danger'] { - color: ${p => p.theme.red400}; - } - &[data-color='warning'] { - color: ${p => p.theme.yellow400}; - } - &[data-align='right'] { - text-align: right; - justify-content: flex-end; - } -`; - -const HeaderCell = styled(Cell)` - padding: 0; -`; - -const PathCell = styled(Cell)` - flex-direction: column; - justify-content: center; - align-items: flex-start; - gap: ${space(0.5)}; - min-width: 0px; -`; - -const ControllerText = styled('div')` - ${p => p.theme.overflowEllipsis}; - color: ${p => p.theme.gray300}; - font-size: ${p => p.theme.fontSizeSmall}; - line-height: 1; - min-width: 0px; -`; - -function RoutesTable({query}: {query?: string}) { - const organization = useOrganization(); - const pageFilterChartParams = usePageFilterChartParams(); - const theme = useTheme(); - - const transactionsRequest = useApiQuery( - [ - `/organizations/${organization.slug}/events/`, - { - query: { - ...pageFilterChartParams, - dataset: 'metrics', - field: [ - 'http.method', - 'project.id', - 'transaction', - 'avg(transaction.duration)', - 'p95()', - 'failure_rate()', - 'count()', - 'count_unique(user)', - ], - query: `(transaction.op:http.server) event.type:transaction ${query}`, - referrer: 'api.performance.landing-table', - orderby: '-count()', - per_page: 10, - }, - }, - ], - {staleTime: 0} - ); - - // Get the list of transactions from the first request - const transactionPaths = useMemo(() => { - return ( - transactionsRequest.data?.data.map(transactions => transactions.transaction) ?? [] - ); - }, [transactionsRequest.data]); - - const routeControllersRequest = useApiQuery<{data: RouteControllerMapping[]}>( - [ - `/organizations/${organization.slug}/events/`, - { - query: { - ...pageFilterChartParams, - dataset: 'spans', - field: [ - 'span.description', - 'transaction', - 'transaction.method', - 'count(span.duration)', - ], - // Add transaction filter to route controller request - query: `transaction.op:http.server span.op:http.route transaction:[${ - transactionPaths.map(transactions => `"${transactions}"`).join(',') || '""' - }]`, - sort: '-transaction', - per_page: 25, - }, - }, - ], - { - staleTime: 0, - // Only fetch after we have the transactions data and there are transactions to look up - enabled: !!transactionsRequest.data?.data && transactionPaths.length > 0, - } - ); - - const tableData = useMemo(() => { - if (!transactionsRequest.data?.data) { - return []; - } - - // Create a mapping of transaction path to controller - const controllerMap = new Map( - routeControllersRequest.data?.data.map(item => [ - item.transaction, - item['span.description'], - ]) - ); - - return transactionsRequest.data.data.map(transaction => ({ - method: transaction['http.method'], - transaction: transaction.transaction, - requests: transaction['count()'], - avg: transaction['avg(transaction.duration)'], - p95: transaction['p95()'], - errorRate: transaction['failure_rate()'], - users: transaction['count_unique(user)'], - controller: controllerMap.get(transaction.transaction), - projectId: transaction['project.id'], - })); - }, [transactionsRequest.data, routeControllersRequest.data]); - - return ( - - - Requests - , - 'Error Rate', - 'AVG', - 'P95', - - Users - , - ]} - isLoading={transactionsRequest.isLoading} - isEmpty={!tableData || tableData.length === 0} - > - {tableData?.map(transaction => { - const p95Color = getCellColor(transaction.p95, getP95Threshold(transaction.avg)); - const errorRateColor = getCellColor( - transaction.errorRate, - errorRateColorThreshold - ); - - return ( - - {transaction.method} - - - - {transaction.transaction} - - - {routeControllersRequest.isLoading ? ( - - ) : ( - transaction.controller && ( - - {transaction.controller} - - ) - )} - - {formatAbbreviatedNumber(transaction.requests)} - - {(transaction.errorRate * 100).toFixed(2)}% - - {getDuration(transaction.avg / 1000, 2, true, true)} - - {getDuration(transaction.p95 / 1000, 2, true, true)} - - - {formatAbbreviatedNumber(transaction.users)} - - - - ); - })} - - ); -} From 93e2628183fb4a833590c654ed57efe73ddf6d1e Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Wed, 5 Mar 2025 16:32:56 +0100 Subject: [PATCH 23/39] ref(ui): move textarea to components/core (#86381) --- static/app/components/core/input/inputGroup.chonk.tsx | 4 ++-- static/app/components/core/input/inputGroup.tsx | 4 ++-- .../controls/textarea.tsx => core/textarea/index.tsx} | 6 ++---- static/app/components/events/autofix/autofixDiff.tsx | 2 +- static/app/components/featureFeedback/feedbackModal.tsx | 4 ++-- .../widgetBuilder/components/nameAndDescFields.tsx | 2 +- static/app/views/settings/organizationRelay/modals/form.tsx | 4 ++-- .../views/settings/project/projectOwnership/ownerInput.tsx | 2 +- .../views/settings/project/projectOwnership/rulesPanel.tsx | 2 +- 9 files changed, 14 insertions(+), 16 deletions(-) rename static/app/components/{forms/controls/textarea.tsx => core/textarea/index.tsx} (91%) 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/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/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/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 > -