diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index e1d4b936d41402..4683234338a78f 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -512,6 +512,8 @@ tests/sentry/api/endpoints/test_organization_dashboard_widget_details.py @ge
/src/sentry/api/helpers/group_index/ @getsentry/issues
/src/sentry/api/helpers/source_map_helper.py @getsentry/issues
/src/sentry/api/issue_search.py @getsentry/issues
+/src/sentry/deletions/defaults/group.py @getsentry/issues
+/src/sentry/deletions/tasks/groups.py @getsentry/issues
/src/sentry/event_manager.py @getsentry/issues
/src/sentry/eventstore/models.py @getsentry/issues
/src/sentry/grouping/ @getsentry/issues
@@ -550,6 +552,7 @@ tests/sentry/api/endpoints/test_organization_dashboard_widget_details.py @ge
/static/app/utils/analytics.tsx @getsentry/issues
/static/app/utils/routeAnalytics/ @getsentry/issues
/tests/sentry/api/test_issue_search.py @getsentry/issues
+/tests/sentry/deletions/test_group.py @getsentry/issues
/tests/sentry/event_manager/ @getsentry/issues
/tests/sentry/grouping/ @getsentry/issues
/tests/sentry/search/ @getsentry/issues
diff --git a/biome.json b/biome.json
index 6026cb19d4928f..030f704518b143 100644
--- a/biome.json
+++ b/biome.json
@@ -36,7 +36,6 @@
"noExcessiveNestedTestSuites": "error"
},
"nursery": {
- "noDuplicateJsonKeys": "error",
"noRestrictedImports": {
"level": "warn",
"options": {
@@ -100,6 +99,10 @@
".devenv"
]
},
+ "css": {
+ "formatter": { "enabled": false },
+ "linter": { "enabled": false }
+ },
"formatter": {
"enabled": true,
"formatWithErrors": true,
diff --git a/config/tsconfig.base.json b/config/tsconfig.base.json
index 081a655b7a25e4..326841c8574a4a 100644
--- a/config/tsconfig.base.json
+++ b/config/tsconfig.base.json
@@ -19,11 +19,7 @@
"moduleResolution": "node",
// We add esnext to lib to pull in types for all newer ECMAScript features
- "lib": [
- "esnext",
- "dom",
- "dom.iterable"
- ],
+ "lib": ["esnext", "dom", "dom.iterable"],
// Skip type checking of all declaration files
"skipLibCheck": true,
diff --git a/fixtures/vsts.py b/fixtures/vsts.py
index 76ad6d83519c3b..4c9f4678670f21 100644
--- a/fixtures/vsts.py
+++ b/fixtures/vsts.py
@@ -66,6 +66,17 @@ def _stub_vsts(self):
},
)
+ responses.add(
+ responses.POST,
+ "https://login.microsoftonline.com/common/oauth2/v2.0/token",
+ json={
+ "access_token": self.access_token,
+ "token_type": "grant",
+ "expires_in": 300, # seconds (5 min)
+ "refresh_token": self.refresh_token,
+ },
+ )
+
responses.add(
responses.GET,
"https://app.vssps.visualstudio.com/_apis/accounts?memberId=%s&api-version=4.1"
@@ -195,19 +206,27 @@ def assert_vsts_oauth_redirect(self, redirect):
assert redirect.netloc == "app.vssps.visualstudio.com"
assert redirect.path == "/oauth2/authorize"
+ def assert_vsts_new_oauth_redirect(self, redirect):
+ assert redirect.scheme == "https"
+ assert redirect.netloc == "login.microsoftonline.com"
+ assert redirect.path == "/common/oauth2/v2.0/authorize"
+
def assert_account_selection(self, response, account_id=None):
account_id = account_id or self.vsts_account_id
assert response.status_code == 200
assert f'=1.10.0
+sentry-devenv>=1.10.2
covdefaults>=2.3.0
docker>=6
diff --git a/scripts/test.js b/scripts/test.js
index 156608c4ceeb17..d73f8696933f8e 100644
--- a/scripts/test.js
+++ b/scripts/test.js
@@ -6,6 +6,9 @@ process.env.NODE_ENV = 'test';
process.env.PUBLIC_URL = '';
process.env.TZ = 'America/New_York';
+// Marker to indicate that we've correctly ran with `yarn test`.
+process.env.USING_YARN_TEST = true;
+
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
diff --git a/src/flagpole/flagpole-schema.json b/src/flagpole/flagpole-schema.json
index fcba869df77fd6..3fd7da4dcca0c5 100644
--- a/src/flagpole/flagpole-schema.json
+++ b/src/flagpole/flagpole-schema.json
@@ -35,12 +35,7 @@
"pattern": "^\\d{4}-\\d{2}-\\d{2}(?:T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?)?$"
}
},
- "required": [
- "name",
- "owner",
- "segments",
- "created_at"
- ],
+ "required": ["name", "owner", "segments", "created_at"],
"definitions": {
"InCondition": {
"title": "InCondition",
@@ -54,9 +49,7 @@
"operator": {
"title": "Operator",
"default": "in",
- "enum": [
- "in"
- ],
+ "enum": ["in"],
"type": "string"
},
"value": {
@@ -83,10 +76,7 @@
]
}
},
- "required": [
- "property",
- "value"
- ]
+ "required": ["property", "value"]
},
"NotInCondition": {
"title": "NotInCondition",
@@ -100,9 +90,7 @@
"operator": {
"title": "Operator",
"default": "not_in",
- "enum": [
- "not_in"
- ],
+ "enum": ["not_in"],
"type": "string"
},
"value": {
@@ -129,10 +117,7 @@
]
}
},
- "required": [
- "property",
- "value"
- ]
+ "required": ["property", "value"]
},
"ContainsCondition": {
"title": "ContainsCondition",
@@ -146,9 +131,7 @@
"operator": {
"title": "Operator",
"default": "contains",
- "enum": [
- "contains"
- ],
+ "enum": ["contains"],
"type": "string"
},
"value": {
@@ -166,10 +149,7 @@
]
}
},
- "required": [
- "property",
- "value"
- ]
+ "required": ["property", "value"]
},
"NotContainsCondition": {
"title": "NotContainsCondition",
@@ -183,9 +163,7 @@
"operator": {
"title": "Operator",
"default": "not_contains",
- "enum": [
- "not_contains"
- ],
+ "enum": ["not_contains"],
"type": "string"
},
"value": {
@@ -203,10 +181,7 @@
]
}
},
- "required": [
- "property",
- "value"
- ]
+ "required": ["property", "value"]
},
"EqualsCondition": {
"title": "EqualsCondition",
@@ -220,9 +195,7 @@
"operator": {
"title": "Operator",
"default": "equals",
- "enum": [
- "equals"
- ],
+ "enum": ["equals"],
"type": "string"
},
"value": {
@@ -261,10 +234,7 @@
]
}
},
- "required": [
- "property",
- "value"
- ]
+ "required": ["property", "value"]
},
"NotEqualsCondition": {
"title": "NotEqualsCondition",
@@ -278,9 +248,7 @@
"operator": {
"title": "Operator",
"default": "not_equals",
- "enum": [
- "not_equals"
- ],
+ "enum": ["not_equals"],
"type": "string"
},
"value": {
@@ -319,10 +287,7 @@
]
}
},
- "required": [
- "property",
- "value"
- ]
+ "required": ["property", "value"]
},
"Segment": {
"title": "Segment",
@@ -379,10 +344,7 @@
"type": "integer"
}
},
- "required": [
- "name",
- "conditions"
- ]
+ "required": ["name", "conditions"]
}
}
}
diff --git a/src/sentry/api/authentication.py b/src/sentry/api/authentication.py
index 126c6ba1d33a11..06df68b600c511 100644
--- a/src/sentry/api/authentication.py
+++ b/src/sentry/api/authentication.py
@@ -26,7 +26,6 @@
from sentry.models.apiapplication import ApiApplication
from sentry.models.apikey import ApiKey
from sentry.models.apitoken import ApiToken
-from sentry.models.integrations.sentry_app import SentryApp
from sentry.models.orgauthtoken import (
OrgAuthToken,
is_org_auth_token_auth,
@@ -35,6 +34,7 @@
from sentry.models.projectkey import ProjectKey
from sentry.models.relay import Relay
from sentry.relay.utils import get_header_relay_id, get_header_relay_signature
+from sentry.sentry_apps.models.sentry_app import SentryApp
from sentry.silo.base import SiloLimit, SiloMode
from sentry.users.models.user import User
from sentry.users.services.user import RpcUser
diff --git a/src/sentry/api/bases/sentryapps.py b/src/sentry/api/bases/sentryapps.py
index 91e9827ac73beb..242c81858f2f25 100644
--- a/src/sentry/api/bases/sentryapps.py
+++ b/src/sentry/api/bases/sentryapps.py
@@ -19,12 +19,12 @@
from sentry.coreapi import APIError
from sentry.integrations.api.bases.integration import PARANOID_GET
from sentry.middleware.stats import add_request_metric_tags
-from sentry.models.integrations.sentry_app import SentryApp
from sentry.models.organization import OrganizationStatus
from sentry.organizations.services.organization import (
RpcUserOrganizationContext,
organization_service,
)
+from sentry.sentry_apps.models.sentry_app import SentryApp
from sentry.sentry_apps.services.app import RpcSentryApp, app_service
from sentry.users.services.user import RpcUser
from sentry.users.services.user.service import user_service
diff --git a/src/sentry/api/endpoints/integrations/sentry_apps/components.py b/src/sentry/api/endpoints/integrations/sentry_apps/components.py
index d7b135069b15d6..c32e752689f589 100644
--- a/src/sentry/api/endpoints/integrations/sentry_apps/components.py
+++ b/src/sentry/api/endpoints/integrations/sentry_apps/components.py
@@ -10,13 +10,13 @@
from sentry.api.paginator import OffsetPaginator
from sentry.api.serializers import serialize
from sentry.coreapi import APIError
-from sentry.models.integrations.sentry_app_component import SentryAppComponent
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
from sentry.organizations.services.organization.model import (
RpcOrganization,
RpcUserOrganizationContext,
)
from sentry.sentry_apps.components import SentryAppComponentPreparer
+from sentry.sentry_apps.models.sentry_app_component import SentryAppComponent
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
# TODO(mgaeta): These endpoints are doing the same thing, but one takes a
diff --git a/src/sentry/api/endpoints/integrations/sentry_apps/details.py b/src/sentry/api/endpoints/integrations/sentry_apps/details.py
index 070abd972ed649..635361f7ce03ed 100644
--- a/src/sentry/api/endpoints/integrations/sentry_apps/details.py
+++ b/src/sentry/api/endpoints/integrations/sentry_apps/details.py
@@ -21,10 +21,10 @@
from sentry.auth.staff import is_active_staff
from sentry.constants import SentryAppStatus
from sentry.mediators.sentry_app_installations.installation_notifier import InstallationNotifier
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
from sentry.organizations.services.organization import organization_service
from sentry.sentry_apps.logic import SentryAppUpdater
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.utils.audit import create_audit_entry
logger = logging.getLogger(__name__)
diff --git a/src/sentry/api/endpoints/integrations/sentry_apps/index.py b/src/sentry/api/endpoints/integrations/sentry_apps/index.py
index 20d7c7ec610b30..976e85e89cd0ec 100644
--- a/src/sentry/api/endpoints/integrations/sentry_apps/index.py
+++ b/src/sentry/api/endpoints/integrations/sentry_apps/index.py
@@ -16,8 +16,8 @@
from sentry.auth.staff import is_active_staff
from sentry.auth.superuser import is_active_superuser
from sentry.constants import SentryAppStatus
-from sentry.models.integrations.sentry_app import SentryApp
from sentry.sentry_apps.logic import SentryAppCreator
+from sentry.sentry_apps.models.sentry_app import SentryApp
from sentry.users.services.user.service import user_service
logger = logging.getLogger(__name__)
diff --git a/src/sentry/api/endpoints/integrations/sentry_apps/installation/details.py b/src/sentry/api/endpoints/integrations/sentry_apps/installation/details.py
index f6e17edf1d0d88..8d124ffb2814a4 100644
--- a/src/sentry/api/endpoints/integrations/sentry_apps/installation/details.py
+++ b/src/sentry/api/endpoints/integrations/sentry_apps/installation/details.py
@@ -13,7 +13,7 @@
from sentry.api.serializers.rest_framework import SentryAppInstallationSerializer
from sentry.mediators.sentry_app_installations.installation_notifier import InstallationNotifier
from sentry.mediators.sentry_app_installations.updater import Updater
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.utils.audit import create_audit_entry
diff --git a/src/sentry/api/endpoints/integrations/sentry_apps/installation/index.py b/src/sentry/api/endpoints/integrations/sentry_apps/installation/index.py
index fc7a5c8bef7870..5e2be9dfecbdfa 100644
--- a/src/sentry/api/endpoints/integrations/sentry_apps/installation/index.py
+++ b/src/sentry/api/endpoints/integrations/sentry_apps/installation/index.py
@@ -14,9 +14,9 @@
from sentry.constants import SENTRY_APP_SLUG_MAX_LENGTH, SentryAppStatus
from sentry.features.exceptions import FeatureNotRegistered
from sentry.integrations.models.integration_feature import IntegrationFeature, IntegrationTypes
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
from sentry.sentry_apps.installations import SentryAppInstallationCreator
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
class SentryAppInstallationsSerializer(serializers.Serializer):
diff --git a/src/sentry/api/endpoints/integrations/sentry_apps/interaction.py b/src/sentry/api/endpoints/integrations/sentry_apps/interaction.py
index 08ce955a0f7e0c..ed3bd99d45015c 100644
--- a/src/sentry/api/endpoints/integrations/sentry_apps/interaction.py
+++ b/src/sentry/api/endpoints/integrations/sentry_apps/interaction.py
@@ -9,7 +9,7 @@
from sentry.api.base import StatsMixin, region_silo_endpoint
from sentry.api.bases import RegionSentryAppBaseEndpoint, SentryAppStatsPermission
from sentry.api.bases.sentryapps import COMPONENT_TYPES
-from sentry.models.integrations.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app import SentryApp
from sentry.sentry_apps.services.app import RpcSentryApp, app_service
from sentry.tsdb.base import TSDBModel
diff --git a/src/sentry/api/endpoints/integrations/sentry_apps/internal_app_token/details.py b/src/sentry/api/endpoints/integrations/sentry_apps/internal_app_token/details.py
index e82e75015a3c47..302ee27a4c140f 100644
--- a/src/sentry/api/endpoints/integrations/sentry_apps/internal_app_token/details.py
+++ b/src/sentry/api/endpoints/integrations/sentry_apps/internal_app_token/details.py
@@ -13,7 +13,7 @@
PARTNERSHIP_RESTRICTED_ERROR_MESSAGE,
)
from sentry.models.apitoken import ApiToken
-from sentry.models.integrations.sentry_app_installation_token import SentryAppInstallationToken
+from sentry.sentry_apps.models.sentry_app_installation_token import SentryAppInstallationToken
@control_silo_endpoint
diff --git a/src/sentry/api/endpoints/integrations/sentry_apps/internal_app_token/index.py b/src/sentry/api/endpoints/integrations/sentry_apps/internal_app_token/index.py
index 390703007f2ba5..0999cf93d6cc35 100644
--- a/src/sentry/api/endpoints/integrations/sentry_apps/internal_app_token/index.py
+++ b/src/sentry/api/endpoints/integrations/sentry_apps/internal_app_token/index.py
@@ -13,9 +13,9 @@
from sentry.api.serializers.models.apitoken import ApiTokenSerializer
from sentry.exceptions import ApiTokenLimitError
from sentry.models.apitoken import ApiToken
-from sentry.models.integrations.sentry_app import MASKED_VALUE
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
from sentry.sentry_apps.installations import SentryAppInstallationTokenCreator
+from sentry.sentry_apps.models.sentry_app import MASKED_VALUE
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
@control_silo_endpoint
diff --git a/src/sentry/api/endpoints/integrations/sentry_apps/organization_sentry_apps.py b/src/sentry/api/endpoints/integrations/sentry_apps/organization_sentry_apps.py
index 5e96d176c6b4aa..1c89fd73ee2b39 100644
--- a/src/sentry/api/endpoints/integrations/sentry_apps/organization_sentry_apps.py
+++ b/src/sentry/api/endpoints/integrations/sentry_apps/organization_sentry_apps.py
@@ -9,9 +9,9 @@
from sentry.api.paginator import OffsetPaginator
from sentry.api.serializers import serialize
from sentry.constants import SentryAppStatus
-from sentry.models.integrations.sentry_app import SentryApp
from sentry.organizations.services.organization import RpcOrganization
from sentry.organizations.services.organization.model import RpcUserOrganizationContext
+from sentry.sentry_apps.models.sentry_app import SentryApp
@control_silo_endpoint
diff --git a/src/sentry/api/endpoints/integrations/sentry_apps/rotate_secret.py b/src/sentry/api/endpoints/integrations/sentry_apps/rotate_secret.py
index a8b6dff9ad16e3..fa171c6f96ca95 100644
--- a/src/sentry/api/endpoints/integrations/sentry_apps/rotate_secret.py
+++ b/src/sentry/api/endpoints/integrations/sentry_apps/rotate_secret.py
@@ -14,8 +14,8 @@
from sentry.auth.superuser import superuser_has_permission
from sentry.constants import SentryAppStatus
from sentry.models.apiapplication import generate_token
-from sentry.models.integrations.sentry_app import SentryApp
from sentry.organizations.services.organization import organization_service
+from sentry.sentry_apps.models.sentry_app import SentryApp
from sentry.users.services.user.service import user_service
logger = logging.getLogger(__name__)
diff --git a/src/sentry/api/endpoints/integrations/sentry_apps/stats/details.py b/src/sentry/api/endpoints/integrations/sentry_apps/stats/details.py
index 8f10ec705bb496..34dc8f7fb9ce7d 100644
--- a/src/sentry/api/endpoints/integrations/sentry_apps/stats/details.py
+++ b/src/sentry/api/endpoints/integrations/sentry_apps/stats/details.py
@@ -5,7 +5,7 @@
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import StatsMixin, control_silo_endpoint
from sentry.api.bases import SentryAppBaseEndpoint, SentryAppStatsPermission
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
@control_silo_endpoint
diff --git a/src/sentry/api/endpoints/integrations/sentry_apps/stats/index.py b/src/sentry/api/endpoints/integrations/sentry_apps/stats/index.py
index f4628b812af78f..c3b9b63732d6fb 100644
--- a/src/sentry/api/endpoints/integrations/sentry_apps/stats/index.py
+++ b/src/sentry/api/endpoints/integrations/sentry_apps/stats/index.py
@@ -9,7 +9,7 @@
from sentry.api.permissions import SuperuserOrStaffFeatureFlaggedPermission
from sentry.api.serializers import serialize
from sentry.models.avatars.sentry_app_avatar import SentryAppAvatar
-from sentry.models.integrations.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app import SentryApp
@control_silo_endpoint
diff --git a/src/sentry/api/endpoints/organization_events.py b/src/sentry/api/endpoints/organization_events.py
index d48e5bb59d3453..672c4441708ea8 100644
--- a/src/sentry/api/endpoints/organization_events.py
+++ b/src/sentry/api/endpoints/organization_events.py
@@ -152,14 +152,6 @@ class EventsApiResponse(TypedDict):
meta: EventsMeta
-# When calling make build-spectacular-docs we hit this issue
-# https://github.com/tfranzel/drf-spectacular/issues/1041
-# This is a work around
-EventsMeta.__annotations__["datasetReason"] = str
-EventsMeta.__annotations__["isMetricsData"] = bool
-EventsMeta.__annotations__["isMetricsExtractedData"] = bool
-
-
def rate_limit_events(
request: Request, organization_id_or_slug: str | None = None, *args, **kwargs
) -> dict[str, dict[RateLimitCategory, RateLimit]]:
diff --git a/src/sentry/api/endpoints/organization_events_anomalies.py b/src/sentry/api/endpoints/organization_events_anomalies.py
new file mode 100644
index 00000000000000..33599c87d4a0f2
--- /dev/null
+++ b/src/sentry/api/endpoints/organization_events_anomalies.py
@@ -0,0 +1,98 @@
+from drf_spectacular.utils import extend_schema
+from rest_framework.request import Request
+from rest_framework.response import Response
+
+from sentry import features
+from sentry.api.api_owners import ApiOwner
+from sentry.api.api_publish_status import ApiPublishStatus
+from sentry.api.base import region_silo_endpoint
+from sentry.api.bases.organization_events import OrganizationEventsV2EndpointBase
+from sentry.api.exceptions import ResourceDoesNotExist
+from sentry.api.paginator import OffsetPaginator
+from sentry.api.serializers.base import serialize
+from sentry.apidocs.constants import (
+ RESPONSE_BAD_REQUEST,
+ RESPONSE_FORBIDDEN,
+ RESPONSE_NOT_FOUND,
+ RESPONSE_UNAUTHORIZED,
+)
+from sentry.apidocs.examples.organization_examples import OrganizationExamples
+from sentry.apidocs.parameters import GlobalParams
+from sentry.apidocs.utils import inline_sentry_response_serializer
+from sentry.models.organization import Organization
+from sentry.seer.anomaly_detection.get_historical_anomalies import (
+ get_historical_anomaly_data_from_seer_preview,
+)
+from sentry.seer.anomaly_detection.types import DetectAnomaliesResponse, TimeSeriesPoint
+
+
+@region_silo_endpoint
+class OrganizationEventsAnomaliesEndpoint(OrganizationEventsV2EndpointBase):
+ owner = ApiOwner.ALERTS_NOTIFICATIONS
+ publish_status = {
+ "POST": ApiPublishStatus.EXPERIMENTAL,
+ }
+
+ @extend_schema(
+ operation_id="Identify anomalies in historical data",
+ parameters=[GlobalParams.ORG_ID_OR_SLUG],
+ responses={
+ 200: inline_sentry_response_serializer(
+ "ListAlertRuleAnomalies", DetectAnomaliesResponse
+ ),
+ 400: RESPONSE_BAD_REQUEST,
+ 401: RESPONSE_UNAUTHORIZED,
+ 403: RESPONSE_FORBIDDEN,
+ 404: RESPONSE_NOT_FOUND,
+ },
+ examples=OrganizationExamples.GET_HISTORICAL_ANOMALIES,
+ )
+ def _format_historical_data(self, data) -> list[TimeSeriesPoint] | None:
+ """
+ Format EventsStatsData into the format that the Seer API expects.
+ EventsStatsData is a list of lists with this format:
+ [epoch timestamp, {'count': count}]
+ Convert the data to this format:
+ list[TimeSeriesPoint]
+ """
+ if data is None:
+ return data
+
+ formatted_data: list[TimeSeriesPoint] = []
+ for datum in data:
+ ts_point = TimeSeriesPoint(timestamp=datum[0], value=datum[1].get("count", 0))
+ formatted_data.append(ts_point)
+ return formatted_data
+
+ def post(self, request: Request, organization: Organization) -> Response:
+ """
+ Return a list of anomalies for a time series of historical event data.
+ """
+ if not features.has("organizations:anomaly-detection-alerts", organization):
+ raise ResourceDoesNotExist("Your organization does not have access to this feature.")
+
+ historical_data = self._format_historical_data(request.data.get("historical_data"))
+ current_data = self._format_historical_data(request.data.get("current_data"))
+
+ config = request.data.get("config")
+ project_id = request.data.get("project_id")
+
+ if project_id is None or not config or not historical_data or not current_data:
+ return Response(
+ "Unable to get historical anomaly data: missing required argument(s) project, start, and/or end",
+ status=400,
+ )
+
+ anomalies = get_historical_anomaly_data_from_seer_preview(
+ current_data, historical_data, project_id, config
+ )
+ # NOTE: returns None if there's a problem with the Seer response
+ if anomalies is None:
+ return Response("Unable to get historical anomaly data", status=400)
+ # NOTE: returns empty list if there is not enough event data
+ return self.paginate(
+ request=request,
+ queryset=anomalies,
+ paginator_cls=OffsetPaginator,
+ on_results=lambda x: serialize(x, request.user),
+ )
diff --git a/src/sentry/api/endpoints/organization_member/details.py b/src/sentry/api/endpoints/organization_member/details.py
index 49fd1e1d6fe0dc..57db9ef68552d2 100644
--- a/src/sentry/api/endpoints/organization_member/details.py
+++ b/src/sentry/api/endpoints/organization_member/details.py
@@ -4,6 +4,7 @@
from django.db.models import Q
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework import serializers
+from rest_framework.exceptions import PermissionDenied
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
@@ -44,7 +45,8 @@
ERR_NO_AUTH = "You cannot remove this member with an unauthenticated API request."
ERR_INSUFFICIENT_ROLE = "You cannot remove a member who has more access than you."
ERR_INSUFFICIENT_SCOPE = "You are missing the member:admin scope."
-ERR_MEMBER_INVITE = "Your role cannot remove an invitation that was sent by someone else."
+ERR_MEMBER_INVITE = "You cannot modify invitations sent by someone else."
+ERR_MEMBER_REINVITE = "You can only reinvite members; you cannot modify other member details."
ERR_ONLY_OWNER = "You cannot remove the only remaining owner of the organization."
ERR_UNINVITABLE = "You cannot send an invitation to a user who is already a full member."
ERR_EXPIRED = "You cannot resend an expired invitation without regenerating the token."
@@ -75,7 +77,7 @@ class RelaxedMemberPermission(OrganizationPermission):
scope_map = {
"GET": ["member:read", "member:write", "member:admin"],
"POST": ["member:write", "member:admin"],
- "PUT": ["member:write", "member:admin"],
+ "PUT": ["member:invite", "member:write", "member:admin"],
# DELETE checks for role comparison as you can either remove a member
# with a lower access role, or yourself, without having the req. scope
"DELETE": ["member:read", "member:write", "member:admin"],
@@ -124,6 +126,7 @@ def _get_member(
403: RESPONSE_FORBIDDEN,
404: RESPONSE_NOT_FOUND,
},
+ examples=OrganizationExamples.UPDATE_ORG_MEMBER,
)
def get(
self,
@@ -134,7 +137,7 @@ def get(
"""
Retrieve an organization member's details.
- Will return a pending invite as long as it's already approved.
+ Response will be a pending invite if it has been approved by organization owners or managers but is waiting to be accepted by the invitee.
"""
allowed_roles = get_allowed_org_roles(request, organization, member)
return Response(
@@ -218,6 +221,26 @@ def put(
status=403,
)
+ is_member = not (
+ request.access.has_scope("member:invite") and request.access.has_scope("member:admin")
+ )
+ enable_member_invite = (
+ features.has("organizations:members-invite-teammates", organization)
+ and not organization.flags.disable_member_invite
+ )
+ # Members can only resend invites
+ reinvite_request_only = set(result.keys()).issubset({"reinvite", "regenerate"})
+ # Members can only resend invites that they sent
+ is_invite_from_user = member.inviter_id == request.user.id
+
+ if is_member:
+ if not enable_member_invite or not member.is_pending:
+ raise PermissionDenied
+ if not reinvite_request_only:
+ return Response({"detail": ERR_MEMBER_REINVITE}, status=403)
+ if not is_invite_from_user:
+ return Response({"detail": ERR_MEMBER_INVITE}, status=403)
+
# XXX(dcramer): if/when this expands beyond reinvite we need to check
# access level
if result.get("reinvite"):
@@ -372,7 +395,7 @@ def _handle_deletion_by_member(
acting_member: OrganizationMember,
) -> Response:
# Members can only delete invitations
- if not member.inviter_id:
+ if not member.is_pending:
return Response({"detail": ERR_INSUFFICIENT_SCOPE}, status=400)
# Members can only delete invitations that they sent
if member.inviter_id != acting_member.user_id:
diff --git a/src/sentry/api/endpoints/organization_member/index.py b/src/sentry/api/endpoints/organization_member/index.py
index fb2821d23c41d3..d92ac5988194c2 100644
--- a/src/sentry/api/endpoints/organization_member/index.py
+++ b/src/sentry/api/endpoints/organization_member/index.py
@@ -205,7 +205,7 @@ def get(self, request: Request, organization) -> Response:
"""
List all organization members.
- Response includes pending invites that are approved by organization admins but waiting to be accepted by the invitee.
+ Response includes pending invites that are approved by organization owners or managers but waiting to be accepted by the invitee.
"""
queryset = OrganizationMember.objects.filter(
Q(user_is_active=True, user_id__isnull=False) | Q(user_id__isnull=True),
diff --git a/src/sentry/api/endpoints/organization_release_details.py b/src/sentry/api/endpoints/organization_release_details.py
index 91c47608178fd3..d0f9986c181e96 100644
--- a/src/sentry/api/endpoints/organization_release_details.py
+++ b/src/sentry/api/endpoints/organization_release_details.py
@@ -5,7 +5,7 @@
from rest_framework.response import Response
from rest_framework.serializers import ListField
-from sentry import release_health
+from sentry import options, release_health
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import ReleaseAnalyticsMixin, region_silo_endpoint
@@ -526,7 +526,12 @@ def put(self, request: Request, organization, version) -> Response:
datetime=release.date_released,
)
- return Response(serialize(release, request.user))
+ no_snuba_for_release_creation = options.get("releases.no_snuba_for_release_creation")
+ return Response(
+ serialize(
+ release, request.user, no_snuba_for_release_creation=no_snuba_for_release_creation
+ )
+ )
@extend_schema(
operation_id="Delete an Organization's Release",
diff --git a/src/sentry/api/endpoints/organization_releases.py b/src/sentry/api/endpoints/organization_releases.py
index a905454461ef24..de668f2730237a 100644
--- a/src/sentry/api/endpoints/organization_releases.py
+++ b/src/sentry/api/endpoints/organization_releases.py
@@ -588,7 +588,9 @@ def post(self, request: Request, organization) -> Response:
update_org_auth_token_last_used(request.auth, [project.id for project in projects])
scope.set_tag("success_status", status)
- return Response(serialize(release, request.user), status=status)
+ return Response(
+ serialize(release, request.user, no_snuba_for_release_creation=True), status=status
+ )
scope.set_tag("failure_reason", "serializer_error")
return Response(serializer.errors, status=400)
diff --git a/src/sentry/api/endpoints/organization_spans_fields.py b/src/sentry/api/endpoints/organization_spans_fields.py
index 836ee168895b19..d3697277dbae73 100644
--- a/src/sentry/api/endpoints/organization_spans_fields.py
+++ b/src/sentry/api/endpoints/organization_spans_fields.py
@@ -31,6 +31,16 @@
from sentry.tagstore.types import TagKey, TagValue
from sentry.utils import snuba
+# This causes problems if a user sends an attribute with any of these values
+# but the meta table currently can't handle that anyways
+# More users will see the 3 of these since they're on everything so lets try to make
+# the common usecase more reasonable
+TAG_NAME_MAPPING = {
+ "segment_name": "transaction",
+ "name": "span.description",
+ "service": "project",
+}
+
class OrganizationSpansFieldsEndpointBase(OrganizationEventsV2EndpointBase):
publish_status = {
@@ -108,7 +118,11 @@ def get(self, request: Request, organization) -> Response:
paginator = ChainPaginator(
[
- [TagKey(tag.name) for tag in rpc_response.tags if tag.name],
+ [
+ TagKey(TAG_NAME_MAPPING.get(tag.name, tag.name))
+ for tag in rpc_response.tags
+ if tag.name
+ ],
],
max_limit=max_span_tags,
)
diff --git a/src/sentry/api/endpoints/project_details.py b/src/sentry/api/endpoints/project_details.py
index de54f967610ed6..ba3c4fca0fee5b 100644
--- a/src/sentry/api/endpoints/project_details.py
+++ b/src/sentry/api/endpoints/project_details.py
@@ -829,6 +829,11 @@ def put(self, request: Request, project) -> Response:
"sentry:feedback_ai_spam_detection",
bool(options["sentry:feedback_ai_spam_detection"]),
)
+ if "sentry:toolbar_allowed_origins" in options:
+ project.update_option(
+ "sentry:toolbar_allowed_origins",
+ clean_newline_inputs(options["sentry:toolbar_allowed_origins"]),
+ )
if "filters:react-hydration-errors" in options:
project.update_option(
"filters:react-hydration-errors",
diff --git a/src/sentry/api/endpoints/project_index.py b/src/sentry/api/endpoints/project_index.py
index 869d476e10cdd2..b0b3a8c99f2ccd 100644
--- a/src/sentry/api/endpoints/project_index.py
+++ b/src/sentry/api/endpoints/project_index.py
@@ -12,10 +12,10 @@
from sentry.auth.superuser import is_active_superuser
from sentry.constants import ObjectStatus
from sentry.db.models.query import in_iexact
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
from sentry.models.project import Project
from sentry.models.projectplatform import ProjectPlatform
from sentry.search.utils import tokenize_query
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
@region_silo_endpoint
diff --git a/src/sentry/api/endpoints/project_release_details.py b/src/sentry/api/endpoints/project_release_details.py
index e07d96205fdde1..be4305d6eb44c7 100644
--- a/src/sentry/api/endpoints/project_release_details.py
+++ b/src/sentry/api/endpoints/project_release_details.py
@@ -2,6 +2,7 @@
from rest_framework.request import Request
from rest_framework.response import Response
+from sentry import options
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import ReleaseAnalyticsMixin, region_silo_endpoint
from sentry.api.bases.project import ProjectEndpoint, ProjectReleasePermission
@@ -144,8 +145,12 @@ def put(self, request: Request, project, version) -> Response:
data={"version": release.version},
datetime=release.date_released,
)
-
- return Response(serialize(release, request.user))
+ no_snuba_for_release_creation = options.get("releases.no_snuba_for_release_creation")
+ return Response(
+ serialize(
+ release, request.user, no_snuba_for_release_creation=no_snuba_for_release_creation
+ )
+ )
def delete(self, request: Request, project, version) -> Response:
"""
diff --git a/src/sentry/api/endpoints/project_rule_actions.py b/src/sentry/api/endpoints/project_rule_actions.py
index c21ac4f39768fd..36bad42c07976b 100644
--- a/src/sentry/api/endpoints/project_rule_actions.py
+++ b/src/sentry/api/endpoints/project_rule_actions.py
@@ -108,7 +108,7 @@ def execute_future_on_test_event(
# If we encounter some unexpected exception, we probably
# don't want to continue executing more callbacks.
logger.warning(
- "%s.test_alert.unexpected_exception", callback_name, extra={"exc": exc}
+ "%s.test_alert.unexpected_exception", callback_name, exc_info=True
)
break
diff --git a/src/sentry/api/endpoints/project_servicehook_details.py b/src/sentry/api/endpoints/project_servicehook_details.py
index 8031fa63a41755..d44bc117494d1f 100644
--- a/src/sentry/api/endpoints/project_servicehook_details.py
+++ b/src/sentry/api/endpoints/project_servicehook_details.py
@@ -12,7 +12,7 @@
from sentry.api.serializers import serialize
from sentry.api.validators import ServiceHookValidator
from sentry.constants import ObjectStatus
-from sentry.models.servicehook import ServiceHook
+from sentry.sentry_apps.models.servicehook import ServiceHook
@region_silo_endpoint
diff --git a/src/sentry/api/endpoints/project_servicehook_stats.py b/src/sentry/api/endpoints/project_servicehook_stats.py
index 970ed6be09acfe..dffeb8c606ae61 100644
--- a/src/sentry/api/endpoints/project_servicehook_stats.py
+++ b/src/sentry/api/endpoints/project_servicehook_stats.py
@@ -7,7 +7,7 @@
from sentry.api.base import StatsMixin, region_silo_endpoint
from sentry.api.bases.project import ProjectEndpoint
from sentry.api.exceptions import ResourceDoesNotExist
-from sentry.models.servicehook import ServiceHook
+from sentry.sentry_apps.models.servicehook import ServiceHook
from sentry.tsdb.base import TSDBModel
diff --git a/src/sentry/api/endpoints/project_servicehooks.py b/src/sentry/api/endpoints/project_servicehooks.py
index 31b40ff451e7df..1c38a74a2b9514 100644
--- a/src/sentry/api/endpoints/project_servicehooks.py
+++ b/src/sentry/api/endpoints/project_servicehooks.py
@@ -12,7 +12,7 @@
from sentry.api.serializers import serialize
from sentry.api.validators import ServiceHookValidator
from sentry.constants import ObjectStatus
-from sentry.models.servicehook import ServiceHook
+from sentry.sentry_apps.models.servicehook import ServiceHook
from sentry.sentry_apps.services.hook import hook_service
diff --git a/src/sentry/api/endpoints/source_map_debug_blue_thunder_edition.py b/src/sentry/api/endpoints/source_map_debug_blue_thunder_edition.py
index 4d010c5afb7f75..f5786082e3ddf2 100644
--- a/src/sentry/api/endpoints/source_map_debug_blue_thunder_edition.py
+++ b/src/sentry/api/endpoints/source_map_debug_blue_thunder_edition.py
@@ -634,6 +634,7 @@ def get_sdk_debug_id_support(event_data):
"sentry.javascript.browser",
"sentry.javascript.capacitor",
"sentry.javascript.cordova",
+ "sentry.javascript.cloudflare",
"sentry.javascript.electron",
"sentry.javascript.gatsby",
"sentry.javascript.nextjs",
@@ -660,32 +661,41 @@ def get_sdk_debug_id_support(event_data):
if sdk_name == "sentry.javascript.react-native":
return (
- "full"
- if Version(sdk_version) >= Version(MIN_REACT_NATIVE_SDK_VERSION_FOR_DEBUG_IDS)
- else "needs-upgrade",
+ (
+ "full"
+ if Version(sdk_version) >= Version(MIN_REACT_NATIVE_SDK_VERSION_FOR_DEBUG_IDS)
+ else "needs-upgrade"
+ ),
MIN_REACT_NATIVE_SDK_VERSION_FOR_DEBUG_IDS,
)
if sdk_name == "sentry.javascript.electron":
return (
- "full"
- if Version(sdk_version) >= Version(MIN_ELECTRON_SDK_VERSION_FOR_DEBUG_IDS)
- else "needs-upgrade",
+ (
+ "full"
+ if Version(sdk_version) >= Version(MIN_ELECTRON_SDK_VERSION_FOR_DEBUG_IDS)
+ else "needs-upgrade"
+ ),
MIN_ELECTRON_SDK_VERSION_FOR_DEBUG_IDS,
)
if sdk_name == "sentry.javascript.nextjs" or sdk_name == "sentry.javascript.sveltekit":
return (
- "full"
- if Version(sdk_version) >= Version(MIN_NEXTJS_AND_SVELTEKIT_SDK_VERSION_FOR_DEBUG_IDS)
- else "needs-upgrade",
+ (
+ "full"
+ if Version(sdk_version)
+ >= Version(MIN_NEXTJS_AND_SVELTEKIT_SDK_VERSION_FOR_DEBUG_IDS)
+ else "needs-upgrade"
+ ),
MIN_NEXTJS_AND_SVELTEKIT_SDK_VERSION_FOR_DEBUG_IDS,
)
return (
- "full"
- if Version(sdk_version) >= Version(MIN_JS_SDK_VERSION_FOR_DEBUG_IDS)
- else "needs-upgrade",
+ (
+ "full"
+ if Version(sdk_version) >= Version(MIN_JS_SDK_VERSION_FOR_DEBUG_IDS)
+ else "needs-upgrade"
+ ),
MIN_JS_SDK_VERSION_FOR_DEBUG_IDS,
)
diff --git a/src/sentry/api/helpers/teams.py b/src/sentry/api/helpers/teams.py
index ad537f6a2a0fee..582519f7d539bf 100644
--- a/src/sentry/api/helpers/teams.py
+++ b/src/sentry/api/helpers/teams.py
@@ -41,7 +41,7 @@ def get_teams(request, organization, teams=None):
raise InvalidParams(f"Invalid Team ID: {team_id}")
requested_teams.update(verified_ids)
- teams_query = Team.objects.filter(id__in=requested_teams)
+ teams_query = Team.objects.filter(id__in=requested_teams, organization=organization)
for team in teams_query:
if team.id in verified_ids:
continue
diff --git a/src/sentry/api/serializers/models/activity.py b/src/sentry/api/serializers/models/activity.py
index 2a429113ca5a7a..af60cec3770f8f 100644
--- a/src/sentry/api/serializers/models/activity.py
+++ b/src/sentry/api/serializers/models/activity.py
@@ -17,9 +17,11 @@ def __init__(self, environment_func=None):
def get_attrs(self, item_list, user, **kwargs):
# TODO(dcramer); assert on relations
user_ids = [i.user_id for i in item_list if i.user_id]
- user_list = user_service.serialize_many(
- filter={"user_ids": user_ids}, as_user=serialize_generic_user(user)
- )
+ user_list = []
+ if user_ids:
+ user_list = user_service.serialize_many(
+ filter={"user_ids": user_ids}, as_user=serialize_generic_user(user)
+ )
users = {u["id"]: u for u in user_list}
commit_ids = {
diff --git a/src/sentry/api/serializers/models/broadcast.py b/src/sentry/api/serializers/models/broadcast.py
index 58749d1d71eb5b..9a7a9537b890b7 100644
--- a/src/sentry/api/serializers/models/broadcast.py
+++ b/src/sentry/api/serializers/models/broadcast.py
@@ -52,5 +52,5 @@ def get_attrs(self, item_list, user, **kwargs):
def serialize(self, obj, attrs, user, **kwargs):
context = super().serialize(obj, attrs, user)
context["userCount"] = attrs["user_count"]
- context["createdBy"] = obj.created_by_id.id if obj.created_by_id else None
+ context["createdBy"] = obj.created_by_id.email if obj.created_by_id else None
return context
diff --git a/src/sentry/api/serializers/models/group.py b/src/sentry/api/serializers/models/group.py
index b9bc251d068201..924aafb1d71e56 100644
--- a/src/sentry/api/serializers/models/group.py
+++ b/src/sentry/api/serializers/models/group.py
@@ -183,9 +183,12 @@ def _serialize_assignees(self, item_list: Sequence[Group]) -> Mapping[int, Team
for team in Team.objects.filter(id__in=all_team_ids.keys()):
for group_id in all_team_ids[team.id]:
result[group_id] = team
- for user in user_service.get_many_by_id(ids=list(all_user_ids.keys())):
- for group_id in all_user_ids[user.id]:
- result[group_id] = user
+
+ user_ids = list(all_user_ids.keys())
+ if user_ids:
+ for user in user_service.get_many_by_id(ids=user_ids):
+ for group_id in all_user_ids[user.id]:
+ result[group_id] = user
return result
diff --git a/src/sentry/api/serializers/models/organization_member/response.py b/src/sentry/api/serializers/models/organization_member/response.py
index ae9014ed55003e..862f80206d88d6 100644
--- a/src/sentry/api/serializers/models/organization_member/response.py
+++ b/src/sentry/api/serializers/models/organization_member/response.py
@@ -63,7 +63,7 @@ class OrganizationMemberSCIMSerializerResponse(OrganizationMemberSCIMSerializerO
class _TeamRole(TypedDict):
teamSlug: str
- role: str
+ role: str | None
@extend_schema_serializer(exclude_fields=["role", "roleName"])
diff --git a/src/sentry/api/serializers/models/project.py b/src/sentry/api/serializers/models/project.py
index 5bde3b196d353a..821d3ccf82c0df 100644
--- a/src/sentry/api/serializers/models/project.py
+++ b/src/sentry/api/serializers/models/project.py
@@ -1103,6 +1103,9 @@ def format_options(self, attrs: dict[str, Any]) -> dict[str, Any]:
"sentry:replay_hydration_error_issues": self.get_value_with_default(
attrs, "sentry:replay_hydration_error_issues"
),
+ "sentry:toolbar_allowed_origins": "\n".join(
+ self.get_value_with_default(attrs, "sentry:toolbar_allowed_origins") or []
+ ),
"quotas:spike-protection-disabled": options.get("quotas:spike-protection-disabled"),
}
diff --git a/src/sentry/api/serializers/models/release.py b/src/sentry/api/serializers/models/release.py
index 483b51c6b06f91..8b73b870a314ba 100644
--- a/src/sentry/api/serializers/models/release.py
+++ b/src/sentry/api/serializers/models/release.py
@@ -458,13 +458,16 @@ def get_attrs(self, item_list, user, **kwargs):
issue_counts_by_release,
) = self.__get_release_data_with_environments(release_project_envs)
- owners = {
- d["id"]: d
- for d in user_service.serialize_many(
- filter={"user_ids": [i.owner_id for i in item_list if i.owner_id]},
- as_user=serialize_generic_user(user),
- )
- }
+ owners = {}
+ owner_ids = [i.owner_id for i in item_list if i.owner_id]
+ if owner_ids:
+ owners = {
+ d["id"]: d
+ for d in user_service.serialize_many(
+ filter={"user_ids": owner_ids},
+ as_user=serialize_generic_user(user),
+ )
+ }
authors_metadata_attrs = _get_authors_metadata(item_list, user)
release_metadata_attrs = _get_last_commit_metadata(item_list, user)
diff --git a/src/sentry/api/serializers/models/rule.py b/src/sentry/api/serializers/models/rule.py
index 52910f726ee9b1..025541ba58e966 100644
--- a/src/sentry/api/serializers/models/rule.py
+++ b/src/sentry/api/serializers/models/rule.py
@@ -7,10 +7,10 @@
from sentry.api.serializers import Serializer, register
from sentry.constants import ObjectStatus
from sentry.models.environment import Environment
-from sentry.models.integrations.sentry_app_installation import prepare_ui_component
from sentry.models.rule import NeglectedRule, Rule, RuleActivity, RuleActivityType
from sentry.models.rulefirehistory import RuleFireHistory
from sentry.models.rulesnooze import RuleSnooze
+from sentry.sentry_apps.models.sentry_app_installation import prepare_ui_component
from sentry.sentry_apps.services.app.model import RpcSentryAppComponentContext
from sentry.users.services.user.service import user_service
diff --git a/src/sentry/api/serializers/models/sentry_app.py b/src/sentry/api/serializers/models/sentry_app.py
index 4d6cf5051555a4..64851baa6652df 100644
--- a/src/sentry/api/serializers/models/sentry_app.py
+++ b/src/sentry/api/serializers/models/sentry_app.py
@@ -13,8 +13,8 @@
from sentry.integrations.models.integration_feature import IntegrationFeature, IntegrationTypes
from sentry.models.apiapplication import ApiApplication
from sentry.models.avatars.sentry_app_avatar import SentryAppAvatar
-from sentry.models.integrations.sentry_app import MASKED_VALUE, SentryApp
from sentry.organizations.services.organization import organization_service
+from sentry.sentry_apps.models.sentry_app import MASKED_VALUE, SentryApp
from sentry.users.models.user import User
from sentry.users.services.user.service import user_service
diff --git a/src/sentry/api/serializers/models/sentry_app_component.py b/src/sentry/api/serializers/models/sentry_app_component.py
index 55cb11d2e4d89e..eed92765b4fe9d 100644
--- a/src/sentry/api/serializers/models/sentry_app_component.py
+++ b/src/sentry/api/serializers/models/sentry_app_component.py
@@ -2,7 +2,7 @@
from sentry.api.serializers import Serializer, register
from sentry.api.serializers.base import serialize
-from sentry.models.integrations.sentry_app_component import SentryAppComponent
+from sentry.sentry_apps.models.sentry_app_component import SentryAppComponent
from sentry.sentry_apps.services.app import SentryAppEventDataInterface
diff --git a/src/sentry/api/serializers/models/sentry_app_installation.py b/src/sentry/api/serializers/models/sentry_app_installation.py
index a62216908ccf4f..c21d0ac32e29d6 100644
--- a/src/sentry/api/serializers/models/sentry_app_installation.py
+++ b/src/sentry/api/serializers/models/sentry_app_installation.py
@@ -6,8 +6,8 @@
from sentry.api.serializers import Serializer, register
from sentry.constants import SentryAppInstallationStatus
from sentry.hybridcloud.services.organization_mapping import organization_mapping_service
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.users.models.user import User
from sentry.users.services.user import RpcUser
diff --git a/src/sentry/api/serializers/models/servicehook.py b/src/sentry/api/serializers/models/servicehook.py
index 290da2f54002e5..a0ee0838c9b42e 100644
--- a/src/sentry/api/serializers/models/servicehook.py
+++ b/src/sentry/api/serializers/models/servicehook.py
@@ -1,5 +1,5 @@
from sentry.api.serializers import Serializer, register
-from sentry.models.servicehook import ServiceHook
+from sentry.sentry_apps.models.servicehook import ServiceHook
@register(ServiceHook)
diff --git a/src/sentry/api/serializers/rest_framework/notification_action.py b/src/sentry/api/serializers/rest_framework/notification_action.py
index 2b1106435484ae..98f2048de063e6 100644
--- a/src/sentry/api/serializers/rest_framework/notification_action.py
+++ b/src/sentry/api/serializers/rest_framework/notification_action.py
@@ -9,9 +9,9 @@
from sentry.constants import SentryAppInstallationStatus
from sentry.integrations.services.integration import integration_service
from sentry.integrations.slack.utils.channel import get_channel_id, validate_channel_id
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
from sentry.models.notificationaction import ActionService, ActionTarget, NotificationAction
from sentry.models.project import Project
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.utils.strings import oxfordize_list
diff --git a/src/sentry/api/serializers/rest_framework/sentry_app.py b/src/sentry/api/serializers/rest_framework/sentry_app.py
index 81c7c788c5da62..4e6c4adaef73f9 100644
--- a/src/sentry/api/serializers/rest_framework/sentry_app.py
+++ b/src/sentry/api/serializers/rest_framework/sentry_app.py
@@ -7,7 +7,7 @@
from sentry.api.validators.sentry_apps.schema import validate_ui_element_schema
from sentry.integrations.models.integration_feature import Feature
from sentry.models.apiscopes import ApiScopes
-from sentry.models.integrations.sentry_app import (
+from sentry.sentry_apps.models.sentry_app import (
REQUIRED_EVENT_PERMISSIONS,
UUID_CHARS_IN_SLUG,
VALID_EVENT_RESOURCES,
diff --git a/src/sentry/api/serializers/rest_framework/sentry_app_request.py b/src/sentry/api/serializers/rest_framework/sentry_app_request.py
index c217f6ecbd2b80..fb75908ea6f33c 100644
--- a/src/sentry/api/serializers/rest_framework/sentry_app_request.py
+++ b/src/sentry/api/serializers/rest_framework/sentry_app_request.py
@@ -7,9 +7,9 @@
from sentry import eventstore
from sentry.api.serializers import Serializer
-from sentry.models.integrations.sentry_app import SentryApp
from sentry.models.organization import Organization
from sentry.models.project import Project
+from sentry.sentry_apps.models.sentry_app import SentryApp
from sentry.utils.sentry_apps.webhooks import TIMEOUT_STATUS_CODE
diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py
index d43fe5ff2928e1..7ed0ee60551096 100644
--- a/src/sentry/api/urls.py
+++ b/src/sentry/api/urls.py
@@ -10,6 +10,7 @@
from sentry.api.endpoints.issues.related_issues import RelatedIssuesEndpoint
from sentry.api.endpoints.org_auth_token_details import OrgAuthTokenDetailsEndpoint
from sentry.api.endpoints.org_auth_tokens import OrgAuthTokensEndpoint
+from sentry.api.endpoints.organization_events_anomalies import OrganizationEventsAnomaliesEndpoint
from sentry.api.endpoints.organization_events_root_cause_analysis import (
OrganizationEventsRootCauseAnalysisEndpoint,
)
@@ -168,6 +169,7 @@
)
from sentry.issues.endpoints import (
ActionableItemsEndpoint,
+ EventJsonEndpoint,
GroupActivitiesEndpoint,
GroupDetailsEndpoint,
GroupEventDetailsEndpoint,
@@ -175,6 +177,7 @@
GroupHashesEndpoint,
GroupNotesDetailsEndpoint,
GroupNotesEndpoint,
+ GroupParticipantsEndpoint,
GroupSimilarIssuesEmbeddingsEndpoint,
GroupSimilarIssuesEndpoint,
OrganizationGroupIndexEndpoint,
@@ -182,11 +185,14 @@
OrganizationGroupSearchViewsEndpoint,
OrganizationReleasePreviousCommitsEndpoint,
OrganizationSearchesEndpoint,
+ ProjectEventDetailsEndpoint,
+ ProjectEventsEndpoint,
ProjectGroupIndexEndpoint,
ProjectGroupStatsEndpoint,
ProjectStacktraceLinkEndpoint,
SharedGroupDetailsEndpoint,
SourceMapDebugEndpoint,
+ TeamGroupsOldEndpoint,
)
from sentry.monitors.endpoints.monitor_ingest_checkin_attachment import (
MonitorIngestCheckinAttachmentEndpoint,
@@ -345,7 +351,6 @@
from .endpoints.group_external_issue_details import GroupExternalIssueDetailsEndpoint
from .endpoints.group_external_issues import GroupExternalIssuesEndpoint
from .endpoints.group_first_last_release import GroupFirstLastReleaseEndpoint
-from .endpoints.group_participants import GroupParticipantsEndpoint
from .endpoints.group_reprocessing import GroupReprocessingEndpoint
from .endpoints.group_stats import GroupStatsEndpoint
from .endpoints.group_tagkey_details import GroupTagKeyDetailsEndpoint
@@ -567,8 +572,6 @@
from .endpoints.project_docs_platform import ProjectDocsPlatformEndpoint
from .endpoints.project_environment_details import ProjectEnvironmentDetailsEndpoint
from .endpoints.project_environments import ProjectEnvironmentsEndpoint
-from .endpoints.project_event_details import EventJsonEndpoint, ProjectEventDetailsEndpoint
-from .endpoints.project_events import ProjectEventsEndpoint
from .endpoints.project_filter_details import ProjectFilterDetailsEndpoint
from .endpoints.project_filters import ProjectFiltersEndpoint
from .endpoints.project_grouping_configs import ProjectGroupingConfigsEndpoint
@@ -647,7 +650,6 @@
from .endpoints.system_options import SystemOptionsEndpoint
from .endpoints.team_all_unresolved_issues import TeamAllUnresolvedIssuesEndpoint
from .endpoints.team_details import TeamDetailsEndpoint
-from .endpoints.team_groups_old import TeamGroupsOldEndpoint
from .endpoints.team_issue_breakdown import TeamIssueBreakdownEndpoint
from .endpoints.team_members import TeamMembersEndpoint
from .endpoints.team_projects import TeamProjectsEndpoint
@@ -1415,6 +1417,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
OrganizationEventsStatsEndpoint.as_view(),
name="sentry-api-0-organization-events-stats",
),
+ re_path(
+ r"^(?P[^\/]+)/events/anomalies/$",
+ OrganizationEventsAnomaliesEndpoint.as_view(),
+ name="sentry-api-0-organization-events-anomalies",
+ ),
re_path(
r"^(?P[^\/]+)/project-templates/$",
OrganizationProjectTemplatesIndexEndpoint.as_view(),
diff --git a/src/sentry/api/validators/servicehook.py b/src/sentry/api/validators/servicehook.py
index d4a70148fb931b..6ec25f0108edda 100644
--- a/src/sentry/api/validators/servicehook.py
+++ b/src/sentry/api/validators/servicehook.py
@@ -1,6 +1,6 @@
from rest_framework import serializers
-from sentry.models.servicehook import SERVICE_HOOK_EVENTS
+from sentry.sentry_apps.models.servicehook import SERVICE_HOOK_EVENTS
class ServiceHookValidator(serializers.Serializer):
diff --git a/src/sentry/apidocs/examples/organization_examples.py b/src/sentry/apidocs/examples/organization_examples.py
index 14d82d063a208d..0542f224ee4b51 100644
--- a/src/sentry/apidocs/examples/organization_examples.py
+++ b/src/sentry/apidocs/examples/organization_examples.py
@@ -1,5 +1,190 @@
from drf_spectacular.utils import OpenApiExample
+ORG_ROLE_LIST = [
+ {
+ "id": "billing",
+ "name": "Billing",
+ "desc": "Can manage subscription and billing details.",
+ "scopes": ["org:billing"],
+ "allowed": True,
+ "isAllowed": True,
+ "isRetired": False,
+ "is_global": False,
+ "isGlobal": False,
+ "isTeamRolesAllowed": False,
+ "minimumTeamRole": "contributor",
+ },
+ {
+ "id": "member",
+ "name": "Member",
+ "desc": "Members can view and act on events, as well as view most other data within the organization.",
+ "scopes": [
+ "team:read",
+ "project:releases",
+ "org:read",
+ "event:read",
+ "alerts:write",
+ "member:read",
+ "alerts:read",
+ "event:admin",
+ "project:read",
+ "event:write",
+ ],
+ "allowed": True,
+ "isAllowed": True,
+ "isRetired": False,
+ "is_global": False,
+ "isGlobal": False,
+ "isTeamRolesAllowed": True,
+ "minimumTeamRole": "contributor",
+ },
+ {
+ "id": "admin",
+ "name": "Admin",
+ "desc": "Admin privileges on any teams of which they're a member. They can create new teams and projects, as well as remove teams and projects on which they already hold membership (or all teams, if open membership is enabled). Additionally, they can manage memberships of teams that they are members of. They cannot invite members to the organization.",
+ "scopes": [
+ "team:admin",
+ "org:integrations",
+ "project:admin",
+ "team:read",
+ "project:releases",
+ "org:read",
+ "team:write",
+ "event:read",
+ "alerts:write",
+ "member:read",
+ "alerts:read",
+ "event:admin",
+ "project:read",
+ "event:write",
+ "project:write",
+ ],
+ "allowed": True,
+ "isAllowed": True,
+ "isRetired": True,
+ "is_global": False,
+ "isGlobal": False,
+ "isTeamRolesAllowed": True,
+ "minimumTeamRole": "admin",
+ },
+ {
+ "id": "manager",
+ "name": "Manager",
+ "desc": "Gains admin access on all teams as well as the ability to add and remove members.",
+ "scopes": [
+ "team:admin",
+ "org:integrations",
+ "project:releases",
+ "team:write",
+ "member:read",
+ "org:write",
+ "project:write",
+ "project:admin",
+ "team:read",
+ "org:read",
+ "event:read",
+ "member:write",
+ "alerts:write",
+ "alerts:read",
+ "event:admin",
+ "project:read",
+ "event:write",
+ "member:admin",
+ ],
+ "allowed": True,
+ "isAllowed": True,
+ "isRetired": False,
+ "is_global": True,
+ "isGlobal": True,
+ "isTeamRolesAllowed": True,
+ "minimumTeamRole": "admin",
+ },
+ {
+ "id": "owner",
+ "name": "Owner",
+ "desc": "Unrestricted access to the organization, its data, and its settings. Can add, modify, and delete projects and members, as well as make billing and plan changes.",
+ "scopes": [
+ "team:admin",
+ "org:integrations",
+ "project:releases",
+ "org:admin",
+ "team:write",
+ "member:read",
+ "org:write",
+ "project:write",
+ "project:admin",
+ "team:read",
+ "org:read",
+ "event:read",
+ "member:write",
+ "alerts:write",
+ "org:billing",
+ "alerts:read",
+ "event:admin",
+ "project:read",
+ "event:write",
+ "member:admin",
+ ],
+ "allowed": True,
+ "isAllowed": True,
+ "isRetired": False,
+ "is_global": True,
+ "isGlobal": True,
+ "isTeamRolesAllowed": True,
+ "minimumTeamRole": "admin",
+ },
+]
+
+TEAM_ROLE_LIST = [
+ {
+ "id": "contributor",
+ "name": "Contributor",
+ "desc": "Contributors can view and act on events, as well as view most other data within the team's projects.",
+ "scopes": [
+ "project:read",
+ "project:releases",
+ "member:read",
+ "team:read",
+ "event:read",
+ "alerts:read",
+ "event:write",
+ "org:read",
+ ],
+ "allowed": False,
+ "isAllowed": False,
+ "isRetired": False,
+ "isTeamRolesAllowed": True,
+ "isMinimumRoleFor": None,
+ },
+ {
+ "id": "admin",
+ "name": "Team Admin",
+ "desc": "Admin privileges on the team. They can create and remove projects, and can manage the team's memberships.",
+ "scopes": [
+ "project:read",
+ "project:releases",
+ "member:read",
+ "event:admin",
+ "team:write",
+ "project:admin",
+ "team:read",
+ "org:integrations",
+ "alerts:write",
+ "team:admin",
+ "project:write",
+ "event:read",
+ "alerts:read",
+ "event:write",
+ "org:read",
+ ],
+ "allowed": False,
+ "isAllowed": False,
+ "isRetired": False,
+ "isTeamRolesAllowed": True,
+ "isMinimumRoleFor": "admin",
+ },
+]
+
class OrganizationExamples:
RETRIEVE_ORGANIZATION = [
@@ -106,189 +291,8 @@ class OrganizationExamples:
"experiments": {},
"isDefault": False,
"defaultRole": "member",
- "orgRoleList": [
- {
- "id": "billing",
- "name": "Billing",
- "desc": "Can manage subscription and billing details.",
- "scopes": ["org:billing"],
- "allowed": False,
- "isAllowed": False,
- "isRetired": False,
- "isTeamRolesAllowed": False,
- "is_global": False,
- "isGlobal": False,
- "minimumTeamRole": "contributor",
- },
- {
- "id": "member",
- "name": "Member",
- "desc": "Members can view and act on events, as well as view most other data within the organization.",
- "scopes": [
- "project:read",
- "project:releases",
- "member:read",
- "event:admin",
- "team:read",
- "alerts:write",
- "event:read",
- "alerts:read",
- "event:write",
- "org:read",
- ],
- "allowed": False,
- "isAllowed": False,
- "isRetired": False,
- "isTeamRolesAllowed": True,
- "is_global": False,
- "isGlobal": False,
- "minimumTeamRole": "contributor",
- },
- {
- "id": "admin",
- "name": "Admin",
- "desc": "Admin privileges on any teams of which they're a member. They can create new teams and projects, as well as remove teams and projects on which they already hold membership (or all teams, if open membership is enabled). Additionally, they can manage memberships of teams that they are members of. They cannot invite members to the organization.",
- "scopes": [
- "project:read",
- "project:releases",
- "member:read",
- "event:admin",
- "team:write",
- "project:admin",
- "team:read",
- "org:integrations",
- "alerts:write",
- "team:admin",
- "project:write",
- "event:read",
- "alerts:read",
- "event:write",
- "org:read",
- ],
- "allowed": False,
- "isAllowed": False,
- "isRetired": False,
- "isTeamRolesAllowed": True,
- "is_global": False,
- "isGlobal": False,
- "minimumTeamRole": "admin",
- },
- {
- "id": "manager",
- "name": "Manager",
- "desc": "Gains admin access on all teams as well as the ability to add and remove members.",
- "scopes": [
- "member:write",
- "project:read",
- "member:read",
- "event:admin",
- "org:integrations",
- "member:admin",
- "alerts:write",
- "event:write",
- "org:write",
- "project:releases",
- "team:write",
- "project:admin",
- "team:read",
- "team:admin",
- "project:write",
- "event:read",
- "alerts:read",
- "org:read",
- ],
- "allowed": False,
- "isAllowed": False,
- "isRetired": False,
- "isTeamRolesAllowed": True,
- "is_global": True,
- "isGlobal": True,
- "minimumTeamRole": "admin",
- },
- {
- "id": "owner",
- "name": "Owner",
- "desc": "Unrestricted access to the organization, its data, and its settings. Can add, modify, and delete projects and members, as well as make billing and plan changes.",
- "scopes": [
- "member:write",
- "project:read",
- "member:read",
- "event:admin",
- "org:integrations",
- "member:admin",
- "alerts:write",
- "event:write",
- "org:write",
- "org:admin",
- "project:releases",
- "team:write",
- "project:admin",
- "team:read",
- "org:billing",
- "team:admin",
- "project:write",
- "event:read",
- "alerts:read",
- "org:read",
- ],
- "allowed": False,
- "isAllowed": False,
- "isRetired": False,
- "isTeamRolesAllowed": True,
- "is_global": True,
- "isGlobal": True,
- "minimumTeamRole": "admin",
- },
- ],
- "teamRoleList": [
- {
- "id": "contributor",
- "name": "Contributor",
- "desc": "Contributors can view and act on events, as well as view most other data within the team's projects.",
- "scopes": [
- "project:read",
- "project:releases",
- "member:read",
- "team:read",
- "event:read",
- "alerts:read",
- "event:write",
- "org:read",
- ],
- "allowed": False,
- "isAllowed": False,
- "isRetired": False,
- "isTeamRolesAllowed": True,
- "isMinimumRoleFor": None,
- },
- {
- "id": "admin",
- "name": "Team Admin",
- "desc": "Admin privileges on the team. They can create and remove projects, and can manage the team's memberships.",
- "scopes": [
- "project:read",
- "project:releases",
- "member:read",
- "event:admin",
- "team:write",
- "project:admin",
- "team:read",
- "org:integrations",
- "alerts:write",
- "team:admin",
- "project:write",
- "event:read",
- "alerts:read",
- "event:write",
- "org:read",
- ],
- "allowed": False,
- "isAllowed": False,
- "isRetired": False,
- "isTeamRolesAllowed": True,
- "isMinimumRoleFor": "admin",
- },
- ],
+ "orgRoleList": ORG_ROLE_LIST,
+ "teamRoleList": TEAM_ROLE_LIST,
"openMembership": True,
"allowSharedIssues": True,
"enhancedPrivacy": False,
@@ -626,189 +630,8 @@ class OrganizationExamples:
],
"invite_link": None,
"isOnlyOwner": False,
- "orgRoleList": [
- {
- "id": "billing",
- "name": "Billing",
- "desc": "Can manage subscription and billing details.",
- "scopes": ["org:billing"],
- "allowed": True,
- "isAllowed": True,
- "isRetired": False,
- "is_global": False,
- "isGlobal": False,
- "isTeamRolesAllowed": False,
- "minimumTeamRole": "contributor",
- },
- {
- "id": "member",
- "name": "Member",
- "desc": "Members can view and act on events, as well as view most other data within the organization.",
- "scopes": [
- "team:read",
- "project:releases",
- "org:read",
- "event:read",
- "alerts:write",
- "member:read",
- "alerts:read",
- "event:admin",
- "project:read",
- "event:write",
- ],
- "allowed": True,
- "isAllowed": True,
- "isRetired": False,
- "is_global": False,
- "isGlobal": False,
- "isTeamRolesAllowed": True,
- "minimumTeamRole": "contributor",
- },
- {
- "id": "admin",
- "name": "Admin",
- "desc": "Admin privileges on any teams of which they're a member. They can create new teams and projects, as well as remove teams and projects on which they already hold membership (or all teams, if open membership is enabled). Additionally, they can manage memberships of teams that they are members of. They cannot invite members to the organization.",
- "scopes": [
- "team:admin",
- "org:integrations",
- "project:admin",
- "team:read",
- "project:releases",
- "org:read",
- "team:write",
- "event:read",
- "alerts:write",
- "member:read",
- "alerts:read",
- "event:admin",
- "project:read",
- "event:write",
- "project:write",
- ],
- "allowed": True,
- "isAllowed": True,
- "isRetired": True,
- "is_global": False,
- "isGlobal": False,
- "isTeamRolesAllowed": True,
- "minimumTeamRole": "admin",
- },
- {
- "id": "manager",
- "name": "Manager",
- "desc": "Gains admin access on all teams as well as the ability to add and remove members.",
- "scopes": [
- "team:admin",
- "org:integrations",
- "project:releases",
- "team:write",
- "member:read",
- "org:write",
- "project:write",
- "project:admin",
- "team:read",
- "org:read",
- "event:read",
- "member:write",
- "alerts:write",
- "alerts:read",
- "event:admin",
- "project:read",
- "event:write",
- "member:admin",
- ],
- "allowed": True,
- "isAllowed": True,
- "isRetired": False,
- "is_global": True,
- "isGlobal": True,
- "isTeamRolesAllowed": True,
- "minimumTeamRole": "admin",
- },
- {
- "id": "owner",
- "name": "Owner",
- "desc": "Unrestricted access to the organization, its data, and its settings. Can add, modify, and delete projects and members, as well as make billing and plan changes.",
- "scopes": [
- "team:admin",
- "org:integrations",
- "project:releases",
- "org:admin",
- "team:write",
- "member:read",
- "org:write",
- "project:write",
- "project:admin",
- "team:read",
- "org:read",
- "event:read",
- "member:write",
- "alerts:write",
- "org:billing",
- "alerts:read",
- "event:admin",
- "project:read",
- "event:write",
- "member:admin",
- ],
- "allowed": True,
- "isAllowed": True,
- "isRetired": False,
- "is_global": True,
- "isGlobal": True,
- "isTeamRolesAllowed": True,
- "minimumTeamRole": "admin",
- },
- ],
- "teamRoleList": [
- {
- "id": "contributor",
- "name": "Contributor",
- "desc": "Contributors can view and act on events, as well as view most other data within the team's projects.",
- "scopes": [
- "team:read",
- "project:releases",
- "org:read",
- "event:read",
- "member:read",
- "alerts:read",
- "project:read",
- "event:write",
- ],
- "allowed": False,
- "isAllowed": False,
- "isRetired": False,
- "isMinimumRoleFor": None,
- "isTeamRolesAllowed": True,
- },
- {
- "id": "admin",
- "name": "Team Admin",
- "desc": "Admin privileges on the team. They can create and remove projects, and can manage the team's memberships. They cannot invite members to the organization.",
- "scopes": [
- "team:admin",
- "org:integrations",
- "project:admin",
- "team:read",
- "project:releases",
- "org:read",
- "team:write",
- "event:read",
- "alerts:write",
- "member:read",
- "alerts:read",
- "event:admin",
- "project:read",
- "event:write",
- "project:write",
- ],
- "allowed": False,
- "isAllowed": False,
- "isRetired": False,
- "isMinimumRoleFor": "admin",
- "isTeamRolesAllowed": True,
- },
- ],
+ "orgRoleList": ORG_ROLE_LIST,
+ "teamRoleList": TEAM_ROLE_LIST,
},
status_codes=["200"],
response_only=True,
@@ -1084,3 +907,27 @@ class OrganizationExamples:
response_only=True,
)
]
+
+ GET_HISTORICAL_ANOMALIES = [
+ OpenApiExample(
+ "Identify anomalies in historical data",
+ value=[
+ {
+ "anomaly": {
+ "anomaly_score": -0.38810767243044786,
+ "anomaly_type": "none",
+ },
+ "timestamp": 169,
+ "value": 0.048480431,
+ },
+ {
+ "anomaly": {
+ "anomaly_score": -0.3890542800124323,
+ "anomaly_type": "none",
+ },
+ "timestamp": 170,
+ "value": 0.047910238,
+ },
+ ],
+ )
+ ]
diff --git a/src/sentry/auth/access.py b/src/sentry/auth/access.py
index 0d17ab0bb24589..5d87b8cd814c0a 100644
--- a/src/sentry/auth/access.py
+++ b/src/sentry/auth/access.py
@@ -29,7 +29,6 @@
from sentry.auth.superuser import get_superuser_scopes, is_active_superuser
from sentry.auth.system import SystemToken, is_system_auth
from sentry.models.apikey import ApiKey
-from sentry.models.integrations.sentry_app import SentryApp
from sentry.models.organization import Organization
from sentry.models.organizationmember import OrganizationMember
from sentry.models.organizationmemberteam import OrganizationMemberTeam
@@ -39,6 +38,7 @@
from sentry.organizations.services.organization.serial import summarize_member
from sentry.roles import organization_roles
from sentry.roles.manager import OrganizationRole, TeamRole
+from sentry.sentry_apps.models.sentry_app import SentryApp
from sentry.users.models.user import User
from sentry.users.services.user import RpcUser
from sentry.utils import metrics
diff --git a/src/sentry/auth/helper.py b/src/sentry/auth/helper.py
index 437ccb0d1caf37..5b2742db5f04ea 100644
--- a/src/sentry/auth/helper.py
+++ b/src/sentry/auth/helper.py
@@ -727,7 +727,7 @@ def __init__(
self.organization: RpcOrganization = self.organization
self.provider: Provider = self.provider
- def get_provider(self, provider_key: str | None) -> PipelineProvider:
+ def get_provider(self, provider_key: str | None, **kwargs) -> PipelineProvider:
if self.provider_model:
return cast(PipelineProvider, self.provider_model.get_provider())
elif provider_key:
diff --git a/src/sentry/buffer/base.py b/src/sentry/buffer/base.py
index 7e9bf9503745d4..5cdb624d011e97 100644
--- a/src/sentry/buffer/base.py
+++ b/src/sentry/buffer/base.py
@@ -104,10 +104,24 @@ def incr(
) -> None:
"""
>>> incr(Group, columns={'times_seen': 1}, filters={'pk': group.pk})
- signal_only - added to indicate that `process` should only call the complete
- signal handler with the updated model and skip creates/updates in the database. this
- is useful in cases where we need to do additional processing before writing to the
- database and opt to do it in a `buffer_incr_complete` receiver.
+
+ model - The model whose records will be updated
+
+ columns - Columns whose values should be incremented, in the form
+ { column_name: increment_amount }
+
+ filters - kwargs to pass to `.objects.get` to select the records which will be
+ updated
+
+ extra - Other columns whose values should be changed, in the form
+ { column_name: new_value }. This is separate from `columns` because existing values in those
+ columns are incremented, whereas existing values in these columns are fully overwritten with
+ the new values.
+
+ signal_only - Added to indicate that `process` should only call the `buffer_incr_complete`
+ signal handler with the updated model and skip creates/updates in the database. This is useful
+ in cases where we need to do additional processing before writing to the database and opt to do
+ it in a `buffer_incr_complete` receiver.
"""
process_incr.apply_async(
kwargs={
diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py
index a12f3598ab29b1..3103d0f8e22feb 100644
--- a/src/sentry/conf/server.py
+++ b/src/sentry/conf/server.py
@@ -396,6 +396,7 @@ def env(
"sentry.analytics.events",
"sentry.nodestore",
"sentry.users",
+ "sentry.sentry_apps",
"sentry.integrations",
"sentry.monitors",
"sentry.uptime",
@@ -995,7 +996,7 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str:
"options": {"expires": 60 * 25, "queue": "cleanup.control"},
},
"schedule-hybrid-cloud-foreign-key-jobs-control": {
- "task": "sentry.tasks.deletion.hybrid_cloud.schedule_hybrid_cloud_foreign_key_jobs_control",
+ "task": "sentry.deletions.tasks.hybrid_cloud.schedule_hybrid_cloud_foreign_key_jobs_control",
# Run every 15 minutes
"schedule": crontab(minute="*/15"),
"options": {"queue": "cleanup.control"},
@@ -1125,7 +1126,7 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str:
"options": {"expires": 60 * 60 * 3},
},
"schedule-hybrid-cloud-foreign-key-jobs": {
- "task": "sentry.tasks.deletion.hybrid_cloud.schedule_hybrid_cloud_foreign_key_jobs",
+ "task": "sentry.deletions.tasks.hybrid_cloud.schedule_hybrid_cloud_foreign_key_jobs",
# Run every 15 minutes
"schedule": crontab(minute="*/15"),
},
@@ -2311,7 +2312,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"clickhouse": lambda settings, options: (
{
"image": (
- "ghcr.io/getsentry/image-mirror-altinity-clickhouse-server:23.3.19.33.altinitystable"
+ "ghcr.io/getsentry/image-mirror-altinity-clickhouse-server:23.8.11.29.altinitystable"
),
"ports": {"9000/tcp": 9000, "9009/tcp": 9009, "8123/tcp": 8123},
"ulimits": [{"name": "nofile", "soft": 262144, "hard": 262144}],
@@ -3146,12 +3147,16 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
SEER_ANOMALY_DETECTION_MODEL_VERSION = "v1"
SEER_ANOMALY_DETECTION_URL = SEER_DEFAULT_URL # for local development, these share a URL
-SEER_ANOMALY_DETECTION_TIMEOUT = 15
+SEER_ANOMALY_DETECTION_TIMEOUT = 5
SEER_ANOMALY_DETECTION_ENDPOINT_URL = (
f"/{SEER_ANOMALY_DETECTION_MODEL_VERSION}/anomaly-detection/detect"
)
+SEER_ALERT_DELETION_URL = (
+ f"/{SEER_ANOMALY_DETECTION_MODEL_VERSION}/anomaly-detection/delete-alert-data"
+)
+
SEER_AUTOFIX_GITHUB_APP_USER_ID = 157164994
SEER_AUTOFIX_FORCE_USE_REPOS: list[dict] = []
diff --git a/src/sentry/deletions/__init__.py b/src/sentry/deletions/__init__.py
index a16779d60ff485..c4f83f46e9bd1d 100644
--- a/src/sentry/deletions/__init__.py
+++ b/src/sentry/deletions/__init__.py
@@ -99,6 +99,10 @@ def load_defaults() -> None:
from sentry.models.commitfilechange import CommitFileChange
from sentry.models.rulefirehistory import RuleFireHistory
from sentry.monitors import models as monitor_models
+ from sentry.sentry_apps.models.sentry_app import SentryApp
+ from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
+ from sentry.sentry_apps.models.sentry_app_installation_token import SentryAppInstallationToken
+ from sentry.sentry_apps.models.servicehook import ServiceHook
from sentry.snuba import models as snuba_models
from . import defaults
@@ -163,14 +167,12 @@ def load_defaults() -> None:
default_manager.register(
RepositoryProjectPathConfig, defaults.RepositoryProjectPathConfigDeletionTask
)
- default_manager.register(models.SentryApp, defaults.SentryAppDeletionTask)
+ default_manager.register(SentryApp, defaults.SentryAppDeletionTask)
+ default_manager.register(SentryAppInstallation, defaults.SentryAppInstallationDeletionTask)
default_manager.register(
- models.SentryAppInstallation, defaults.SentryAppInstallationDeletionTask
+ SentryAppInstallationToken, defaults.SentryAppInstallationTokenDeletionTask
)
- default_manager.register(
- models.SentryAppInstallationToken, defaults.SentryAppInstallationTokenDeletionTask
- )
- default_manager.register(models.ServiceHook, defaults.ServiceHookDeletionTask)
+ default_manager.register(ServiceHook, defaults.ServiceHookDeletionTask)
default_manager.register(models.SavedSearch, BulkModelDeletionTask)
default_manager.register(models.Team, defaults.TeamDeletionTask)
default_manager.register(models.UserReport, BulkModelDeletionTask)
diff --git a/src/sentry/deletions/base.py b/src/sentry/deletions/base.py
index ce82a9530a8e25..856d5ff2f5f497 100644
--- a/src/sentry/deletions/base.py
+++ b/src/sentry/deletions/base.py
@@ -2,8 +2,11 @@
import logging
import re
+from collections.abc import Mapping, Sequence
+from typing import TYPE_CHECKING, Any, Generic, TypeVar
from sentry.constants import ObjectStatus
+from sentry.db.models.base import Model
from sentry.users.services.user.model import RpcUser
from sentry.users.services.user.service import user_service
from sentry.utils import metrics
@@ -12,7 +15,16 @@
_leaf_re = re.compile(r"^(UserReport|Event|Group)(.+)")
-def _delete_children(manager, relations, transaction_id=None, actor_id=None):
+if TYPE_CHECKING:
+ from sentry.deletions.manager import DeletionTaskManager
+
+
+def _delete_children(
+ manager: DeletionTaskManager,
+ relations: Sequence[BaseRelation],
+ transaction_id: str | None = None,
+ actor_id: int | None = None,
+) -> bool:
# Ideally this runs through the deletion manager
for relation in relations:
task = manager.get(
@@ -34,17 +46,23 @@ def _delete_children(manager, relations, transaction_id=None, actor_id=None):
class BaseRelation:
- def __init__(self, params, task):
+ def __init__(self, params: Mapping[str, Any], task: type[BaseDeletionTask[Any]] | None) -> None:
self.task = task
self.params = params
- def __repr__(self):
+ def __repr__(self) -> str:
class_type = type(self)
return f"<{class_type.__module__}.{class_type.__name__}: task={self.task} params={self.params}>"
class ModelRelation(BaseRelation):
- def __init__(self, model, query, task=None, partition_key=None):
+ def __init__(
+ self,
+ model: type[ModelT],
+ query: Mapping[str, Any],
+ task: type[BaseDeletionTask[Any]] | None = None,
+ partition_key: str | None = None,
+ ) -> None:
params = {"model": model, "query": query}
if partition_key:
@@ -53,13 +71,21 @@ def __init__(self, model, query, task=None, partition_key=None):
super().__init__(params=params, task=task)
-class BaseDeletionTask:
+ModelT = TypeVar("ModelT", bound=Model)
+
+
+class BaseDeletionTask(Generic[ModelT]):
logger = logging.getLogger("sentry.deletions.async")
DEFAULT_CHUNK_SIZE = 100
def __init__(
- self, manager, skip_models=None, transaction_id=None, actor_id=None, chunk_size=None
+ self,
+ manager: DeletionTaskManager,
+ skip_models: list[type[Model]] | None = None,
+ transaction_id: str | None = None,
+ actor_id: int | None = None,
+ chunk_size: int | None = None,
):
self.manager = manager
self.skip_models = set(skip_models) if skip_models else None
@@ -67,7 +93,7 @@ def __init__(
self.actor_id = actor_id
self.chunk_size = chunk_size if chunk_size is not None else self.DEFAULT_CHUNK_SIZE
- def __repr__(self):
+ def __repr__(self) -> str:
return "<{}: skip_models={} transaction_id={} actor_id={}>".format(
type(self),
self.skip_models,
@@ -82,7 +108,7 @@ def chunk(self) -> bool:
"""
raise NotImplementedError
- def should_proceed(self, instance):
+ def should_proceed(self, instance: ModelT) -> bool:
"""
Used by root tasks to ensure deletion is ok to proceed.
This allows deletes to be undone by API endpoints without
@@ -90,32 +116,26 @@ def should_proceed(self, instance):
"""
return True
- def get_child_relations(self, instance):
+ def get_child_relations(self, instance: ModelT) -> list[BaseRelation]:
# TODO(dcramer): it'd be nice if we collected the default relationships
return [
# ModelRelation(Model, {'parent_id': instance.id})
]
- def get_child_relations_bulk(self, instance_list):
+ def get_child_relations_bulk(self, instance_list: Sequence[ModelT]) -> list[BaseRelation]:
return [
# ModelRelation(Model, {'parent_id__in': [i.id for id in instance_list]})
]
- def extend_relations(self, child_relations, obj):
- return child_relations
-
- def extend_relations_bulk(self, child_relations, obj_list):
- return child_relations
-
- def filter_relations(self, child_relations):
+ def filter_relations(self, child_relations: Sequence[BaseRelation]) -> list[BaseRelation]:
if not self.skip_models or not child_relations:
- return child_relations
+ return list(child_relations)
return list(
rel for rel in child_relations if rel.params.get("model") not in self.skip_models
)
- def delete_bulk(self, instance_list) -> bool:
+ def delete_bulk(self, instance_list: Sequence[ModelT]) -> bool:
"""
Delete a batch of objects bound to this task.
@@ -125,7 +145,6 @@ def delete_bulk(self, instance_list) -> bool:
self.mark_deletion_in_progress(instance_list)
child_relations = self.get_child_relations_bulk(instance_list)
- child_relations = self.extend_relations_bulk(child_relations, instance_list)
child_relations = self.filter_relations(child_relations)
if child_relations:
has_more = self.delete_children(child_relations)
@@ -134,41 +153,50 @@ def delete_bulk(self, instance_list) -> bool:
for instance in instance_list:
child_relations = self.get_child_relations(instance)
- child_relations = self.extend_relations(child_relations, instance)
child_relations = self.filter_relations(child_relations)
if child_relations:
has_more = self.delete_children(child_relations)
if has_more:
return has_more
- return self.delete_instance_bulk(instance_list)
+ self.delete_instance_bulk(instance_list)
- def delete_instance(self, instance):
+ return False
+
+ def delete_instance(self, instance: ModelT) -> None:
raise NotImplementedError
- def delete_instance_bulk(self, instance_list):
+ def delete_instance_bulk(self, instance_list: Sequence[ModelT]) -> None:
for instance in instance_list:
self.delete_instance(instance)
- def delete_children(self, relations):
+ def delete_children(self, relations: list[BaseRelation]) -> bool:
return _delete_children(self.manager, relations, self.transaction_id, self.actor_id)
- def mark_deletion_in_progress(self, instance_list):
+ def mark_deletion_in_progress(self, instance_list: Sequence[ModelT]) -> None:
pass
-class ModelDeletionTask(BaseDeletionTask):
+class ModelDeletionTask(BaseDeletionTask[ModelT]):
DEFAULT_QUERY_LIMIT = None
manager_name = "objects"
- def __init__(self, manager, model, query, query_limit=None, order_by=None, **kwargs):
+ def __init__(
+ self,
+ manager: DeletionTaskManager,
+ model: type[ModelT],
+ query: Mapping[str, Any],
+ query_limit: int | None = None,
+ order_by: str | None = None,
+ **kwargs: Any,
+ ):
super().__init__(manager, **kwargs)
self.model = model
self.query = query
self.query_limit = query_limit or self.DEFAULT_QUERY_LIMIT or self.chunk_size
self.order_by = order_by
- def __repr__(self):
+ def __repr__(self) -> str:
return "<{}: model={} query={} order_by={} transaction_id={} actor_id={}>".format(
type(self),
self.model,
@@ -178,18 +206,6 @@ def __repr__(self):
self.actor_id,
)
- def extend_relations(self, child_relations, obj):
- from sentry.deletions import default_manager
-
- return child_relations + [rel(obj) for rel in default_manager.dependencies[self.model]]
-
- def extend_relations_bulk(self, child_relations, obj_list):
- from sentry.deletions import default_manager
-
- return child_relations + [
- rel(obj_list) for rel in default_manager.bulk_dependencies[self.model]
- ]
-
def chunk(self) -> bool:
"""
Deletes a chunk of this instance's data. Return ``True`` if there is
@@ -213,7 +229,7 @@ def chunk(self) -> bool:
# We have more work to do as we didn't run out of rows to delete.
return True
- def delete_instance(self, instance):
+ def delete_instance(self, instance: ModelT) -> None:
instance_id = instance.id
try:
instance.delete()
@@ -236,14 +252,14 @@ def get_actor(self) -> RpcUser | None:
return user_service.get_user(user_id=self.actor_id)
return None
- def mark_deletion_in_progress(self, instance_list):
+ def mark_deletion_in_progress(self, instance_list: Sequence[ModelT]) -> None:
for instance in instance_list:
status = getattr(instance, "status", None)
if status not in (ObjectStatus.DELETION_IN_PROGRESS, None):
instance.update(status=ObjectStatus.DELETION_IN_PROGRESS)
-class BulkModelDeletionTask(ModelDeletionTask):
+class BulkModelDeletionTask(ModelDeletionTask[ModelT]):
"""
An efficient mechanism for deleting larger volumes of rows in one pass,
but will hard fail if the relations have resident foreign relations.
@@ -253,7 +269,14 @@ class BulkModelDeletionTask(ModelDeletionTask):
DEFAULT_CHUNK_SIZE = 10000
- def __init__(self, manager, model, query, partition_key=None, **kwargs):
+ def __init__(
+ self,
+ manager: DeletionTaskManager,
+ model: type[ModelT],
+ query: Mapping[str, Any],
+ partition_key: str | None = None,
+ **kwargs: Any,
+ ):
super().__init__(manager, model, query, **kwargs)
self.partition_key = partition_key
diff --git a/src/sentry/deletions/defaults/alert_rule_trigger.py b/src/sentry/deletions/defaults/alert_rule_trigger.py
index 9aa1469390a79f..131ee0078ddae3 100644
--- a/src/sentry/deletions/defaults/alert_rule_trigger.py
+++ b/src/sentry/deletions/defaults/alert_rule_trigger.py
@@ -1,8 +1,9 @@
-from ..base import ModelDeletionTask, ModelRelation
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.incidents.models.alert_rule import AlertRuleTrigger
-class AlertRuleTriggerDeletionTask(ModelDeletionTask):
- def get_child_relations(self, instance):
+class AlertRuleTriggerDeletionTask(ModelDeletionTask[AlertRuleTrigger]):
+ def get_child_relations(self, instance: AlertRuleTrigger) -> list[BaseRelation]:
from sentry.incidents.models.alert_rule import AlertRuleTriggerAction
return [
diff --git a/src/sentry/deletions/defaults/alert_rule_trigger_action.py b/src/sentry/deletions/defaults/alert_rule_trigger_action.py
index 4c050d4ca918ee..ed7c6307839d47 100644
--- a/src/sentry/deletions/defaults/alert_rule_trigger_action.py
+++ b/src/sentry/deletions/defaults/alert_rule_trigger_action.py
@@ -1,10 +1,11 @@
-from ..base import ModelDeletionTask, ModelRelation
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.incidents.models.alert_rule import AlertRuleTriggerAction
-class AlertRuleTriggerActionDeletionTask(ModelDeletionTask):
+class AlertRuleTriggerActionDeletionTask(ModelDeletionTask[AlertRuleTriggerAction]):
manager_name = "objects_for_deletion"
- def get_child_relations(self, instance):
+ def get_child_relations(self, instance: AlertRuleTriggerAction) -> list[BaseRelation]:
from sentry.models.notificationmessage import NotificationMessage
return [
diff --git a/src/sentry/deletions/defaults/alertrule.py b/src/sentry/deletions/defaults/alertrule.py
index 2911d3fbe620fa..715c04e63cbd19 100644
--- a/src/sentry/deletions/defaults/alertrule.py
+++ b/src/sentry/deletions/defaults/alertrule.py
@@ -1,12 +1,13 @@
-from ..base import ModelDeletionTask, ModelRelation
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.incidents.models.alert_rule import AlertRule
-class AlertRuleDeletionTask(ModelDeletionTask):
+class AlertRuleDeletionTask(ModelDeletionTask[AlertRule]):
# The default manager for alert rules excludes snapshots
# which we want to include when deleting an organization.
manager_name = "objects_with_snapshots"
- def get_child_relations(self, instance):
+ def get_child_relations(self, instance: AlertRule) -> list[BaseRelation]:
from sentry.incidents.models.alert_rule import AlertRuleTrigger
return [
diff --git a/src/sentry/deletions/defaults/apiapplication.py b/src/sentry/deletions/defaults/apiapplication.py
index f683adc0588ba9..5722b79076d8ec 100644
--- a/src/sentry/deletions/defaults/apiapplication.py
+++ b/src/sentry/deletions/defaults/apiapplication.py
@@ -1,16 +1,17 @@
-from sentry.models.apiapplication import ApiApplicationStatus
+from collections.abc import Sequence
-from ..base import ModelDeletionTask, ModelRelation
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.models.apiapplication import ApiApplication, ApiApplicationStatus
-class ApiApplicationDeletionTask(ModelDeletionTask):
- def should_proceed(self, instance):
+class ApiApplicationDeletionTask(ModelDeletionTask[ApiApplication]):
+ def should_proceed(self, instance: ApiApplication) -> bool:
return instance.status in {
ApiApplicationStatus.pending_deletion,
ApiApplicationStatus.deletion_in_progress,
}
- def get_child_relations(self, instance):
+ def get_child_relations(self, instance: ApiApplication) -> list[BaseRelation]:
from sentry.models.apigrant import ApiGrant
from sentry.models.apitoken import ApiToken
@@ -18,7 +19,7 @@ def get_child_relations(self, instance):
model_list = (ApiToken, ApiGrant)
return [ModelRelation(m, {"application_id": instance.id}) for m in model_list]
- def mark_deletion_in_progress(self, instance_list):
+ def mark_deletion_in_progress(self, instance_list: Sequence[ApiApplication]) -> None:
from sentry.models.apiapplication import ApiApplicationStatus
for instance in instance_list:
diff --git a/src/sentry/deletions/defaults/apigrant.py b/src/sentry/deletions/defaults/apigrant.py
index 32c48bf29f5cd6..ff3ba854debcff 100644
--- a/src/sentry/deletions/defaults/apigrant.py
+++ b/src/sentry/deletions/defaults/apigrant.py
@@ -1,22 +1,23 @@
+from collections.abc import Sequence
+
from django.db import router
+from sentry.deletions.base import ModelDeletionTask
from sentry.models.apigrant import ApiGrant
from sentry.silo.safety import unguarded_write
-from ..base import ModelDeletionTask
-
-class ModelApiGrantDeletionTask(ModelDeletionTask):
+class ModelApiGrantDeletionTask(ModelDeletionTask[ApiGrant]):
"""
Normally ApiGrants are deleted in bulk, but for cascades originating from sentry app installation, we wish to use
the orm so that set null behavior functions correctly. Do not register this as the default, but instead use it as
the task= parameter to a relation.
"""
- def mark_deletion_in_progress(self, instance_list):
+ def mark_deletion_in_progress(self, instance_list: Sequence[ApiGrant]) -> None:
# no status to track
pass
- def delete_instance(self, instance):
+ def delete_instance(self, instance: ApiGrant) -> None:
with unguarded_write(router.db_for_write(ApiGrant)):
super().delete_instance(instance)
diff --git a/src/sentry/deletions/defaults/apitoken.py b/src/sentry/deletions/defaults/apitoken.py
index e413ba0d013bab..1f2d520c637156 100644
--- a/src/sentry/deletions/defaults/apitoken.py
+++ b/src/sentry/deletions/defaults/apitoken.py
@@ -1,13 +1,16 @@
-from ..base import ModelDeletionTask
+from collections.abc import Sequence
+from sentry.deletions.base import ModelDeletionTask
+from sentry.models.apitoken import ApiToken
-class ModelApiTokenDeletionTask(ModelDeletionTask):
+
+class ModelApiTokenDeletionTask(ModelDeletionTask[ApiToken]):
"""
Normally ApiTokens are deleted in bulk, but for cascades originating from sentry app installation, we wish to use
the orm so that set null behavior functions correctly. Do not register this as the default, but instead use it as
the task= parameter to a relation.
"""
- def mark_deletion_in_progress(self, instance_list):
+ def mark_deletion_in_progress(self, instance_list: Sequence[ApiToken]) -> None:
# no status to track
pass
diff --git a/src/sentry/deletions/defaults/artifactbundle.py b/src/sentry/deletions/defaults/artifactbundle.py
index ff6fd847f36de2..d62d5f58df8956 100644
--- a/src/sentry/deletions/defaults/artifactbundle.py
+++ b/src/sentry/deletions/defaults/artifactbundle.py
@@ -1,8 +1,9 @@
-from ..base import ModelDeletionTask, ModelRelation
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.models.artifactbundle import ArtifactBundle
-class ArtifactBundleDeletionTask(ModelDeletionTask):
- def get_child_relations(self, instance):
+class ArtifactBundleDeletionTask(ModelDeletionTask[ArtifactBundle]):
+ def get_child_relations(self, instance: ArtifactBundle) -> list[BaseRelation]:
from sentry.models.artifactbundle import (
DebugIdArtifactBundle,
ProjectArtifactBundle,
diff --git a/src/sentry/deletions/defaults/commit.py b/src/sentry/deletions/defaults/commit.py
index 14793f9978765d..d64086f95dd6f6 100644
--- a/src/sentry/deletions/defaults/commit.py
+++ b/src/sentry/deletions/defaults/commit.py
@@ -1,8 +1,9 @@
-from ..base import ModelDeletionTask, ModelRelation
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.models.commit import Commit
-class CommitDeletionTask(ModelDeletionTask):
- def get_child_relations(self, instance):
+class CommitDeletionTask(ModelDeletionTask[Commit]):
+ def get_child_relations(self, instance: Commit) -> list[BaseRelation]:
from sentry.models.commitfilechange import CommitFileChange
from sentry.models.releasecommit import ReleaseCommit
from sentry.models.releaseheadcommit import ReleaseHeadCommit
diff --git a/src/sentry/deletions/defaults/commitauthor.py b/src/sentry/deletions/defaults/commitauthor.py
index 92345025b3d2f9..3d2e1935d93219 100644
--- a/src/sentry/deletions/defaults/commitauthor.py
+++ b/src/sentry/deletions/defaults/commitauthor.py
@@ -1,8 +1,9 @@
-from ..base import ModelDeletionTask, ModelRelation
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.models.commitauthor import CommitAuthor
-class CommitAuthorDeletionTask(ModelDeletionTask):
- def get_child_relations(self, instance):
+class CommitAuthorDeletionTask(ModelDeletionTask[CommitAuthor]):
+ def get_child_relations(self, instance: CommitAuthor) -> list[BaseRelation]:
from sentry.models.commit import Commit
return [
diff --git a/src/sentry/deletions/defaults/discoversavedquery.py b/src/sentry/deletions/defaults/discoversavedquery.py
index bfe9fa39dd639b..44314f99232fa9 100644
--- a/src/sentry/deletions/defaults/discoversavedquery.py
+++ b/src/sentry/deletions/defaults/discoversavedquery.py
@@ -1,8 +1,9 @@
-from ..base import ModelDeletionTask, ModelRelation
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.discover.models import DiscoverSavedQuery
-class DiscoverSavedQueryDeletionTask(ModelDeletionTask):
- def get_child_relations(self, instance):
+class DiscoverSavedQueryDeletionTask(ModelDeletionTask[DiscoverSavedQuery]):
+ def get_child_relations(self, instance: DiscoverSavedQuery) -> list[BaseRelation]:
from sentry.discover.models import DiscoverSavedQueryProject
return [
diff --git a/src/sentry/deletions/defaults/group.py b/src/sentry/deletions/defaults/group.py
index b983d8f0cf5bdd..c0da32c0bb6ccf 100644
--- a/src/sentry/deletions/defaults/group.py
+++ b/src/sentry/deletions/defaults/group.py
@@ -48,7 +48,7 @@
)
-class EventDataDeletionTask(BaseDeletionTask):
+class EventDataDeletionTask(BaseDeletionTask[Group]):
"""
Deletes nodestore data, EventAttachment and UserReports for group
"""
@@ -121,7 +121,7 @@ def chunk(self) -> bool:
return True
-class GroupDeletionTask(ModelDeletionTask):
+class GroupDeletionTask(ModelDeletionTask[Group]):
# Delete groups in blocks of 1000. Using 1000 aims to
# balance the number of snuba replacements with memory limits.
DEFAULT_CHUNK_SIZE = 1000
@@ -152,7 +152,9 @@ def delete_bulk(self, instance_list: Sequence[Group]) -> bool:
self.delete_children(child_relations)
# Remove group objects with children removed.
- return self.delete_instance_bulk(instance_list)
+ self.delete_instance_bulk(instance_list)
+
+ return False
def delete_instance(self, instance: Group) -> None:
from sentry import similarity
diff --git a/src/sentry/deletions/defaults/grouphash.py b/src/sentry/deletions/defaults/grouphash.py
index a9769e5f0dc84f..8c4d431d85b32e 100644
--- a/src/sentry/deletions/defaults/grouphash.py
+++ b/src/sentry/deletions/defaults/grouphash.py
@@ -1,8 +1,9 @@
-from ..base import ModelDeletionTask, ModelRelation
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.models.grouphash import GroupHash
-class GroupHashDeletionTask(ModelDeletionTask):
- def get_child_relations(self, instance):
+class GroupHashDeletionTask(ModelDeletionTask[GroupHash]):
+ def get_child_relations(self, instance: GroupHash) -> list[BaseRelation]:
from sentry.models.grouphashmetadata import GroupHashMetadata
return [
diff --git a/src/sentry/deletions/defaults/grouphistory.py b/src/sentry/deletions/defaults/grouphistory.py
index 5612fac3435b8a..150e0fd1d7a66a 100644
--- a/src/sentry/deletions/defaults/grouphistory.py
+++ b/src/sentry/deletions/defaults/grouphistory.py
@@ -1,7 +1,8 @@
from sentry.deletions.base import ModelDeletionTask
+from sentry.models.grouphistory import GroupHistory
-class GroupHistoryDeletionTask(ModelDeletionTask):
+class GroupHistoryDeletionTask(ModelDeletionTask[GroupHistory]):
"""
Specialized deletion handling that operates per group
diff --git a/src/sentry/deletions/defaults/monitor.py b/src/sentry/deletions/defaults/monitor.py
index 4c477201c0b2e4..2af8a5eccd74a8 100644
--- a/src/sentry/deletions/defaults/monitor.py
+++ b/src/sentry/deletions/defaults/monitor.py
@@ -1,8 +1,9 @@
-from ..base import ModelDeletionTask, ModelRelation
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.monitors.models import Monitor
-class MonitorDeletionTask(ModelDeletionTask):
- def get_child_relations(self, instance):
+class MonitorDeletionTask(ModelDeletionTask[Monitor]):
+ def get_child_relations(self, instance: Monitor) -> list[BaseRelation]:
from sentry.monitors import models
return [
diff --git a/src/sentry/deletions/defaults/monitor_environment.py b/src/sentry/deletions/defaults/monitor_environment.py
index d166ab47249933..632550804f1cef 100644
--- a/src/sentry/deletions/defaults/monitor_environment.py
+++ b/src/sentry/deletions/defaults/monitor_environment.py
@@ -1,8 +1,9 @@
-from ..base import ModelDeletionTask, ModelRelation
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.monitors.models import MonitorEnvironment
-class MonitorEnvironmentDeletionTask(ModelDeletionTask):
- def get_child_relations(self, instance):
+class MonitorEnvironmentDeletionTask(ModelDeletionTask[MonitorEnvironment]):
+ def get_child_relations(self, instance: MonitorEnvironment) -> list[BaseRelation]:
from sentry.monitors import models
return [
diff --git a/src/sentry/deletions/defaults/organization.py b/src/sentry/deletions/defaults/organization.py
index 819b5bcf20de77..5aa3d452456e16 100644
--- a/src/sentry/deletions/defaults/organization.py
+++ b/src/sentry/deletions/defaults/organization.py
@@ -1,13 +1,14 @@
-from sentry.models.organization import OrganizationStatus
+from collections.abc import Sequence
+
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.models.organization import Organization, OrganizationStatus
from sentry.organizations.services.organization_actions.impl import (
update_organization_with_outbox_message,
)
-from ..base import ModelDeletionTask, ModelRelation
-
-class OrganizationDeletionTask(ModelDeletionTask):
- def should_proceed(self, instance):
+class OrganizationDeletionTask(ModelDeletionTask[Organization]):
+ def should_proceed(self, instance: Organization) -> bool:
"""
Only delete organizations that haven't been undeleted.
"""
@@ -16,7 +17,7 @@ def should_proceed(self, instance):
OrganizationStatus.DELETION_IN_PROGRESS,
}
- def get_child_relations(self, instance):
+ def get_child_relations(self, instance: Organization) -> list[BaseRelation]:
from sentry.deletions.defaults.discoversavedquery import DiscoverSavedQueryDeletionTask
from sentry.discover.models import DiscoverSavedQuery, TeamKeyTransaction
from sentry.incidents.models.alert_rule import AlertRule
@@ -35,7 +36,7 @@ def get_child_relations(self, instance):
from sentry.models.transaction_threshold import ProjectTransactionThreshold
# Team must come first
- relations = [ModelRelation(Team, {"organization_id": instance.id})]
+ relations: list[BaseRelation] = [ModelRelation(Team, {"organization_id": instance.id})]
model_list = (
OrganizationMember,
@@ -65,7 +66,7 @@ def get_child_relations(self, instance):
return relations
- def mark_deletion_in_progress(self, instance_list):
+ def mark_deletion_in_progress(self, instance_list: Sequence[Organization]) -> None:
from sentry.models.organization import OrganizationStatus
for instance in instance_list:
diff --git a/src/sentry/deletions/defaults/organizationintegration.py b/src/sentry/deletions/defaults/organizationintegration.py
index 941dcee637857e..57befb2fb04e67 100644
--- a/src/sentry/deletions/defaults/organizationintegration.py
+++ b/src/sentry/deletions/defaults/organizationintegration.py
@@ -1,19 +1,18 @@
from sentry.constants import ObjectStatus
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
from sentry.integrations.models.organization_integration import OrganizationIntegration
from sentry.integrations.services.repository import repository_service
from sentry.types.region import RegionMappingNotFound
-from ..base import ModelDeletionTask, ModelRelation
-
-class OrganizationIntegrationDeletionTask(ModelDeletionTask):
- def should_proceed(self, instance):
+class OrganizationIntegrationDeletionTask(ModelDeletionTask[OrganizationIntegration]):
+ def should_proceed(self, instance: OrganizationIntegration) -> bool:
return instance.status in {ObjectStatus.DELETION_IN_PROGRESS, ObjectStatus.PENDING_DELETION}
- def get_child_relations(self, instance):
+ def get_child_relations(self, instance: OrganizationIntegration) -> list[BaseRelation]:
from sentry.users.models.identity import Identity
- relations = []
+ relations: list[BaseRelation] = []
# delete the identity attached through the default_auth_id
if instance.default_auth_id:
@@ -21,7 +20,7 @@ def get_child_relations(self, instance):
return relations
- def delete_instance(self, instance: OrganizationIntegration):
+ def delete_instance(self, instance: OrganizationIntegration) -> None:
try:
repository_service.disassociate_organization_integration(
organization_id=instance.organization_id,
diff --git a/src/sentry/deletions/defaults/organizationmember.py b/src/sentry/deletions/defaults/organizationmember.py
index df0d7b1b7aec17..3f51fc18166a33 100644
--- a/src/sentry/deletions/defaults/organizationmember.py
+++ b/src/sentry/deletions/defaults/organizationmember.py
@@ -1,11 +1,11 @@
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
from sentry.models.groupsearchview import GroupSearchView
+from sentry.models.organizationmember import OrganizationMember
-from ..base import ModelDeletionTask, ModelRelation
-
-class OrganizationMemberDeletionTask(ModelDeletionTask):
- def get_child_relations(self, instance):
- relations = [
+class OrganizationMemberDeletionTask(ModelDeletionTask[OrganizationMember]):
+ def get_child_relations(self, instance: OrganizationMember) -> list[BaseRelation]:
+ relations: list[BaseRelation] = [
ModelRelation(
GroupSearchView,
{"user_id": instance.user_id, "organization_id": instance.organization_id},
diff --git a/src/sentry/deletions/defaults/platform_external_issue.py b/src/sentry/deletions/defaults/platform_external_issue.py
index 45c84fa9bf91eb..ac8ecc31328299 100644
--- a/src/sentry/deletions/defaults/platform_external_issue.py
+++ b/src/sentry/deletions/defaults/platform_external_issue.py
@@ -1,7 +1,10 @@
-from ..base import ModelDeletionTask
+from collections.abc import Sequence
+from sentry.deletions.base import ModelDeletionTask
+from sentry.models.platformexternalissue import PlatformExternalIssue
-class PlatformExternalIssueDeletionTask(ModelDeletionTask):
- def mark_deletion_in_progress(self, instance_list):
+
+class PlatformExternalIssueDeletionTask(ModelDeletionTask[PlatformExternalIssue]):
+ def mark_deletion_in_progress(self, instance_list: Sequence[PlatformExternalIssue]) -> None:
# No status to track this.
pass
diff --git a/src/sentry/deletions/defaults/project.py b/src/sentry/deletions/defaults/project.py
index 52f4c98076a7dd..fb06ba353c6fc5 100644
--- a/src/sentry/deletions/defaults/project.py
+++ b/src/sentry/deletions/defaults/project.py
@@ -1,10 +1,16 @@
from __future__ import annotations
-from ..base import BulkModelDeletionTask, ModelDeletionTask, ModelRelation
+from sentry.deletions.base import (
+ BaseRelation,
+ BulkModelDeletionTask,
+ ModelDeletionTask,
+ ModelRelation,
+)
+from sentry.models.project import Project
-class ProjectDeletionTask(ModelDeletionTask):
- def get_child_relations(self, instance):
+class ProjectDeletionTask(ModelDeletionTask[Project]):
+ def get_child_relations(self, instance: Project) -> list[BaseRelation]:
from sentry.discover.models import DiscoverSavedQueryProject
from sentry.incidents.models.alert_rule import AlertRule, AlertRuleProjects
from sentry.incidents.models.incident import IncidentProject
@@ -33,14 +39,14 @@ def get_child_relations(self, instance):
from sentry.models.release_threshold import ReleaseThreshold
from sentry.models.releaseprojectenvironment import ReleaseProjectEnvironment
from sentry.models.releases.release_project import ReleaseProject
- from sentry.models.servicehook import ServiceHook, ServiceHookProject
from sentry.models.transaction_threshold import ProjectTransactionThreshold
from sentry.models.userreport import UserReport
from sentry.monitors.models import Monitor
from sentry.replays.models import ReplayRecordingSegment
+ from sentry.sentry_apps.models.servicehook import ServiceHook, ServiceHookProject
from sentry.snuba.models import QuerySubscription
- relations = [
+ relations: list[BaseRelation] = [
# ProjectKey gets revoked immediately, in bulk
ModelRelation(ProjectKey, {"project_id": instance.id})
]
diff --git a/src/sentry/deletions/defaults/pullrequest.py b/src/sentry/deletions/defaults/pullrequest.py
index 3a0651f2f32873..78bfd6ad2a0165 100644
--- a/src/sentry/deletions/defaults/pullrequest.py
+++ b/src/sentry/deletions/defaults/pullrequest.py
@@ -1,8 +1,9 @@
-from ..base import ModelDeletionTask, ModelRelation
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.models.pullrequest import PullRequest
-class PullRequestDeletionTask(ModelDeletionTask):
- def get_child_relations(self, instance):
+class PullRequestDeletionTask(ModelDeletionTask[PullRequest]):
+ def get_child_relations(self, instance: PullRequest) -> list[BaseRelation]:
from sentry.models.pullrequest import PullRequestComment
return [
diff --git a/src/sentry/deletions/defaults/querysubscription.py b/src/sentry/deletions/defaults/querysubscription.py
index 465bbc315f9e5c..9c9641706c406f 100644
--- a/src/sentry/deletions/defaults/querysubscription.py
+++ b/src/sentry/deletions/defaults/querysubscription.py
@@ -1,9 +1,8 @@
+from sentry.deletions.base import ModelDeletionTask
from sentry.snuba.models import QuerySubscription
-from ..base import ModelDeletionTask
-
-class QuerySubscriptionDeletionTask(ModelDeletionTask):
+class QuerySubscriptionDeletionTask(ModelDeletionTask[QuerySubscription]):
def delete_instance(self, instance: QuerySubscription) -> None:
from sentry.incidents.models.incident import Incident
diff --git a/src/sentry/deletions/defaults/release.py b/src/sentry/deletions/defaults/release.py
index 114260468bc544..9499e13a2b3e6e 100644
--- a/src/sentry/deletions/defaults/release.py
+++ b/src/sentry/deletions/defaults/release.py
@@ -1,8 +1,9 @@
-from ..base import ModelDeletionTask, ModelRelation
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.models.release import Release
-class ReleaseDeletionTask(ModelDeletionTask):
- def get_child_relations(self, instance):
+class ReleaseDeletionTask(ModelDeletionTask[Release]):
+ def get_child_relations(self, instance: Release) -> list[BaseRelation]:
from sentry.models.deploy import Deploy
from sentry.models.distribution import Distribution
from sentry.models.group import Group
diff --git a/src/sentry/deletions/defaults/repository.py b/src/sentry/deletions/defaults/repository.py
index 812de5a9673446..960befb3b93d01 100644
--- a/src/sentry/deletions/defaults/repository.py
+++ b/src/sentry/deletions/defaults/repository.py
@@ -1,10 +1,10 @@
from sentry.constants import ObjectStatus
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.models.repository import Repository
from sentry.signals import pending_delete
-from ..base import ModelDeletionTask, ModelRelation
-
-def _get_repository_child_relations(instance):
+def _get_repository_child_relations(instance: Repository) -> list[BaseRelation]:
from sentry.integrations.models.repository_project_path_config import (
RepositoryProjectPathConfig,
)
@@ -18,17 +18,17 @@ def _get_repository_child_relations(instance):
]
-class RepositoryDeletionTask(ModelDeletionTask):
- def should_proceed(self, instance):
+class RepositoryDeletionTask(ModelDeletionTask[Repository]):
+ def should_proceed(self, instance: Repository) -> bool:
"""
Only delete repositories that haven't been undeleted.
"""
return instance.status in {ObjectStatus.PENDING_DELETION, ObjectStatus.DELETION_IN_PROGRESS}
- def get_child_relations(self, instance):
+ def get_child_relations(self, instance: Repository) -> list[BaseRelation]:
return _get_repository_child_relations(instance)
- def delete_instance(self, instance):
+ def delete_instance(self, instance: Repository) -> None:
# TODO child_relations should also send pending_delete so we
# don't have to do this here.
pending_delete.send(sender=type(instance), instance=instance, actor=self.get_actor())
diff --git a/src/sentry/deletions/defaults/repositoryprojectpathconfig.py b/src/sentry/deletions/defaults/repositoryprojectpathconfig.py
index 8245808a812854..ccafd905877a31 100644
--- a/src/sentry/deletions/defaults/repositoryprojectpathconfig.py
+++ b/src/sentry/deletions/defaults/repositoryprojectpathconfig.py
@@ -1,8 +1,9 @@
-from ..base import ModelDeletionTask, ModelRelation
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.integrations.models.repository_project_path_config import RepositoryProjectPathConfig
-class RepositoryProjectPathConfigDeletionTask(ModelDeletionTask):
- def get_child_relations(self, instance):
+class RepositoryProjectPathConfigDeletionTask(ModelDeletionTask[RepositoryProjectPathConfig]):
+ def get_child_relations(self, instance: RepositoryProjectPathConfig) -> list[BaseRelation]:
from sentry.models.projectcodeowners import ProjectCodeOwners
return [
diff --git a/src/sentry/deletions/defaults/rule.py b/src/sentry/deletions/defaults/rule.py
index 2582ede051bbb3..31832098961b8d 100644
--- a/src/sentry/deletions/defaults/rule.py
+++ b/src/sentry/deletions/defaults/rule.py
@@ -1,8 +1,11 @@
-from ..base import ModelDeletionTask, ModelRelation
+from collections.abc import Sequence
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.models.rule import Rule
-class RuleDeletionTask(ModelDeletionTask):
- def get_child_relations(self, instance):
+
+class RuleDeletionTask(ModelDeletionTask[Rule]):
+ def get_child_relations(self, instance: Rule) -> list[BaseRelation]:
from sentry.models.grouprulestatus import GroupRuleStatus
from sentry.models.rule import RuleActivity
from sentry.models.rulefirehistory import RuleFireHistory
@@ -13,7 +16,7 @@ def get_child_relations(self, instance):
ModelRelation(RuleActivity, {"rule_id": instance.id}),
]
- def mark_deletion_in_progress(self, instance_list):
+ def mark_deletion_in_progress(self, instance_list: Sequence[Rule]) -> None:
from sentry.constants import ObjectStatus
for instance in instance_list:
diff --git a/src/sentry/deletions/defaults/rulefirehistory.py b/src/sentry/deletions/defaults/rulefirehistory.py
index a2f483ff78a128..67e4f54569cae4 100644
--- a/src/sentry/deletions/defaults/rulefirehistory.py
+++ b/src/sentry/deletions/defaults/rulefirehistory.py
@@ -1,8 +1,9 @@
-from sentry.deletions.base import ModelDeletionTask, ModelRelation
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.models.rulefirehistory import RuleFireHistory
-class RuleFireHistoryDeletionTask(ModelDeletionTask):
- def get_child_relations(self, instance):
+class RuleFireHistoryDeletionTask(ModelDeletionTask[RuleFireHistory]):
+ def get_child_relations(self, instance: RuleFireHistory) -> list[BaseRelation]:
from sentry.models.notificationmessage import NotificationMessage
return [
diff --git a/src/sentry/deletions/defaults/sentry_app.py b/src/sentry/deletions/defaults/sentry_app.py
index c3a6f7e66c22e3..b0e8a618fdb43f 100644
--- a/src/sentry/deletions/defaults/sentry_app.py
+++ b/src/sentry/deletions/defaults/sentry_app.py
@@ -1,10 +1,13 @@
-from ..base import ModelDeletionTask, ModelRelation
+from collections.abc import Sequence
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.sentry_apps.models.sentry_app import SentryApp
-class SentryAppDeletionTask(ModelDeletionTask):
- def get_child_relations(self, instance):
+
+class SentryAppDeletionTask(ModelDeletionTask[SentryApp]):
+ def get_child_relations(self, instance: SentryApp) -> list[BaseRelation]:
from sentry.models.apiapplication import ApiApplication
- from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+ from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.users.models.user import User
return [
@@ -13,7 +16,7 @@ def get_child_relations(self, instance):
ModelRelation(ApiApplication, {"id": instance.application_id}),
]
- def mark_deletion_in_progress(self, instance_list):
+ def mark_deletion_in_progress(self, instance_list: Sequence[SentryApp]) -> None:
from sentry.constants import SentryAppStatus
for instance in instance_list:
diff --git a/src/sentry/deletions/defaults/sentry_app_installation.py b/src/sentry/deletions/defaults/sentry_app_installation.py
index 78884463c695fe..8fe7906f714795 100644
--- a/src/sentry/deletions/defaults/sentry_app_installation.py
+++ b/src/sentry/deletions/defaults/sentry_app_installation.py
@@ -1,14 +1,17 @@
-from ..base import ModelDeletionTask, ModelRelation
-from .apigrant import ModelApiGrantDeletionTask
+from collections.abc import Sequence
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.deletions.defaults.apigrant import ModelApiGrantDeletionTask
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
-class SentryAppInstallationDeletionTask(ModelDeletionTask):
- def get_child_relations(self, instance):
+
+class SentryAppInstallationDeletionTask(ModelDeletionTask[SentryAppInstallation]):
+ def get_child_relations(self, instance: SentryAppInstallation) -> list[BaseRelation]:
from sentry.models.apigrant import ApiGrant
- from sentry.models.integrations.sentry_app_installation_for_provider import (
+ from sentry.sentry_apps.models.sentry_app_installation_for_provider import (
SentryAppInstallationForProvider,
)
- from sentry.models.integrations.sentry_app_installation_token import (
+ from sentry.sentry_apps.models.sentry_app_installation_token import (
SentryAppInstallationToken,
)
@@ -20,5 +23,5 @@ def get_child_relations(self, instance):
),
]
- def mark_deletion_in_progress(self, instance_list):
+ def mark_deletion_in_progress(self, instance_list: Sequence[SentryAppInstallation]) -> None:
pass
diff --git a/src/sentry/deletions/defaults/sentry_app_installation_token.py b/src/sentry/deletions/defaults/sentry_app_installation_token.py
index 5c0b89d4a5c2d7..b4aae9b537d8d2 100644
--- a/src/sentry/deletions/defaults/sentry_app_installation_token.py
+++ b/src/sentry/deletions/defaults/sentry_app_installation_token.py
@@ -1,14 +1,19 @@
-from ..base import ModelDeletionTask, ModelRelation
-from .apitoken import ModelApiTokenDeletionTask
+from collections.abc import Sequence
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.deletions.defaults.apitoken import ModelApiTokenDeletionTask
+from sentry.sentry_apps.models.sentry_app_installation_token import SentryAppInstallationToken
-class SentryAppInstallationTokenDeletionTask(ModelDeletionTask):
- def get_child_relations(self, instance):
+
+class SentryAppInstallationTokenDeletionTask(ModelDeletionTask[SentryAppInstallationToken]):
+ def get_child_relations(self, instance: SentryAppInstallationToken) -> list[BaseRelation]:
from sentry.models.apitoken import ApiToken
return [
ModelRelation(ApiToken, {"id": instance.api_token_id}, task=ModelApiTokenDeletionTask),
]
- def mark_deletion_in_progress(self, instance_list):
+ def mark_deletion_in_progress(
+ self, instance_list: Sequence[SentryAppInstallationToken]
+ ) -> None:
pass
diff --git a/src/sentry/deletions/defaults/service_hook.py b/src/sentry/deletions/defaults/service_hook.py
index 6c290162f927f0..039aa60069bffa 100644
--- a/src/sentry/deletions/defaults/service_hook.py
+++ b/src/sentry/deletions/defaults/service_hook.py
@@ -1,7 +1,8 @@
-from ..base import ModelDeletionTask
+from sentry.deletions.base import ModelDeletionTask
+from sentry.sentry_apps.models.servicehook import ServiceHook
-class ServiceHookDeletionTask(ModelDeletionTask):
+class ServiceHookDeletionTask(ModelDeletionTask[ServiceHook]):
# This subclass just represents an intentional decision to not cascade service hook deletions, and to
# mark status using ObjectStatus on deletion. The behavior is identical to the base class
# so that intentions are clear.
diff --git a/src/sentry/deletions/defaults/team.py b/src/sentry/deletions/defaults/team.py
index 5b2c4db201f3f3..c0295f99f0b910 100644
--- a/src/sentry/deletions/defaults/team.py
+++ b/src/sentry/deletions/defaults/team.py
@@ -1,22 +1,25 @@
-from ..base import ModelDeletionTask, ModelRelation
+from collections.abc import Sequence
+from sentry.deletions.base import BaseRelation, ModelDeletionTask, ModelRelation
+from sentry.models.team import Team
-class TeamDeletionTask(ModelDeletionTask):
- def get_child_relations(self, instance):
+
+class TeamDeletionTask(ModelDeletionTask[Team]):
+ def get_child_relations(self, instance: Team) -> list[BaseRelation]:
from sentry.models.projectteam import ProjectTeam
return [
ModelRelation(ProjectTeam, {"team_id": instance.id}),
]
- def mark_deletion_in_progress(self, instance_list):
+ def mark_deletion_in_progress(self, instance_list: Sequence[Team]) -> None:
from sentry.models.team import TeamStatus
for instance in instance_list:
if instance.status != TeamStatus.DELETION_IN_PROGRESS:
instance.update(status=TeamStatus.DELETION_IN_PROGRESS)
- def delete_instance(self, instance):
+ def delete_instance(self, instance: Team) -> None:
from sentry.incidents.models.alert_rule import AlertRule
from sentry.models.rule import Rule
from sentry.monitors.models import Monitor
diff --git a/src/sentry/deletions/manager.py b/src/sentry/deletions/manager.py
index ce9e89810ead50..7f4e3615fbbc43 100644
--- a/src/sentry/deletions/manager.py
+++ b/src/sentry/deletions/manager.py
@@ -1,16 +1,18 @@
-from collections import defaultdict
+from collections.abc import MutableMapping
+from typing import Any
+
+from sentry.db.models.base import Model
+from sentry.deletions.base import BaseDeletionTask
__all__ = ["DeletionTaskManager"]
class DeletionTaskManager:
- def __init__(self, default_task=None):
- self.tasks = {}
+ def __init__(self, default_task: type[BaseDeletionTask[Any]] | None = None) -> None:
+ self.tasks: MutableMapping[type[Model], type[BaseDeletionTask[Any]]] = {}
self.default_task = default_task
- self.dependencies = defaultdict(set)
- self.bulk_dependencies = defaultdict(set)
- def exec_sync(self, instance):
+ def exec_sync(self, instance: Model) -> None:
task = self.get(
model=type(instance),
query={"id": instance.id},
@@ -18,7 +20,7 @@ def exec_sync(self, instance):
while task.chunk():
pass
- def exec_sync_many(self, instances):
+ def exec_sync_many(self, instances: list[Model]) -> None:
if not instances:
return
@@ -29,20 +31,18 @@ def exec_sync_many(self, instances):
while task.chunk():
pass
- def get(self, task=None, **kwargs):
+ def get(
+ self,
+ task: type[BaseDeletionTask[Any]] | None = None,
+ **kwargs: Any,
+ ) -> BaseDeletionTask[Any]:
if task is None:
model = kwargs.get("model")
- try:
- task = self.tasks[model]
- except KeyError:
- task = self.default_task
+ assert model, "The model parameter is required if `task` is not provided"
+ task = self.tasks.get(model, self.default_task)
+ assert task is not None, "Task cannot be None"
+
return task(manager=self, **kwargs)
- def register(self, model, task):
+ def register(self, model: type[Model], task: type[BaseDeletionTask[Any]]) -> None:
self.tasks[model] = task
-
- def add_dependencies(self, model, dependencies):
- self.dependencies[model] |= set(dependencies)
-
- def add_bulk_dependencies(self, model, dependencies):
- self.bulk_dependencies[model] |= set(dependencies)
diff --git a/src/sentry/deletions/tasks/groups.py b/src/sentry/deletions/tasks/groups.py
index 1059b7380ec696..2ebef834b3d267 100644
--- a/src/sentry/deletions/tasks/groups.py
+++ b/src/sentry/deletions/tasks/groups.py
@@ -18,7 +18,7 @@
)
@retry(exclude=(DeleteAborted,))
@track_group_async_operation
-def delete_groups_new(
+def delete_groups(
object_ids: Sequence[int],
transaction_id: str | None = None,
eventstream_state: Mapping[str, Any] | None = None,
@@ -57,22 +57,3 @@ def delete_groups_new(
# all groups have been deleted
if eventstream_state:
eventstream.backend.end_delete_groups(eventstream_state)
-
-
-@instrumented_task(
- name="sentry.tasks.deletion.delete_groups",
- queue="cleanup",
- default_retry_delay=60 * 5,
- max_retries=MAX_RETRIES,
- acks_late=True,
- silo_mode=SiloMode.REGION,
-)
-@retry(exclude=(DeleteAborted,))
-@track_group_async_operation
-def delete_groups(
- object_ids: Sequence[int],
- transaction_id: str | None = None,
- eventstream_state: Mapping[str, Any] | None = None,
- **kwargs: Any,
-) -> None:
- delete_groups_new(object_ids, transaction_id, eventstream_state, **kwargs)
diff --git a/src/sentry/deletions/tasks/hybrid_cloud.py b/src/sentry/deletions/tasks/hybrid_cloud.py
index ffb1edc496dd15..59e2eae95956d7 100644
--- a/src/sentry/deletions/tasks/hybrid_cloud.py
+++ b/src/sentry/deletions/tasks/hybrid_cloud.py
@@ -40,11 +40,11 @@ class WatermarkBatch:
transaction_id: str
-def get_watermark_key(prefix: str, field: HybridCloudForeignKey) -> str:
+def get_watermark_key(prefix: str, field: HybridCloudForeignKey[Any, Any]) -> str:
return f"{prefix}.{field.model._meta.db_table}.{field.name}"
-def get_watermark(prefix: str, field: HybridCloudForeignKey) -> tuple[int, str]:
+def get_watermark(prefix: str, field: HybridCloudForeignKey[Any, Any]) -> tuple[int, str]:
with redis.clusters.get("default").get_local_client_for_key("deletions.watermark") as client:
key = get_watermark_key(prefix, field)
v = client.get(key)
@@ -59,7 +59,7 @@ def get_watermark(prefix: str, field: HybridCloudForeignKey) -> tuple[int, str]:
def set_watermark(
- prefix: str, field: HybridCloudForeignKey, value: int, prev_transaction_id: str
+ prefix: str, field: HybridCloudForeignKey[Any, Any], value: int, prev_transaction_id: str
) -> None:
with redis.clusters.get("default").get_local_client_for_key("deletions.watermark") as client:
client.set(
@@ -78,8 +78,8 @@ def set_watermark(
def _chunk_watermark_batch(
prefix: str,
- field: HybridCloudForeignKey,
- manager: BaseManager,
+ field: HybridCloudForeignKey[Any, Any],
+ manager: BaseManager[Any],
*,
batch_size: int,
model: type[Model],
@@ -116,7 +116,7 @@ def _chunk_watermark_batch(
acks_late=True,
silo_mode=SiloMode.CONTROL,
)
-def schedule_hybrid_cloud_foreign_key_jobs_control_new():
+def schedule_hybrid_cloud_foreign_key_jobs_control() -> None:
if options.get("hybrid_cloud.disable_tombstone_cleanup"):
return
@@ -125,24 +125,13 @@ def schedule_hybrid_cloud_foreign_key_jobs_control_new():
)
-@instrumented_task(
- name="sentry.tasks.deletion.hybrid_cloud.schedule_hybrid_cloud_foreign_key_jobs_control",
- queue="cleanup.control",
- acks_late=True,
- silo_mode=SiloMode.CONTROL,
-)
-def schedule_hybrid_cloud_foreign_key_jobs_control():
- # Deprecated deploy boundary shim
- schedule_hybrid_cloud_foreign_key_jobs_control_new()
-
-
@instrumented_task(
name="sentry.deletions.tasks.hybrid_cloud.schedule_hybrid_cloud_foreign_key_jobs",
queue="cleanup",
acks_late=True,
silo_mode=SiloMode.REGION,
)
-def schedule_hybrid_cloud_foreign_key_jobs_new():
+def schedule_hybrid_cloud_foreign_key_jobs() -> None:
if options.get("hybrid_cloud.disable_tombstone_cleanup"):
return
@@ -151,17 +140,6 @@ def schedule_hybrid_cloud_foreign_key_jobs_new():
)
-@instrumented_task(
- name="sentry.tasks.deletion.hybrid_cloud.schedule_hybrid_cloud_foreign_key_jobs",
- queue="cleanup",
- acks_late=True,
- silo_mode=SiloMode.REGION,
-)
-def schedule_hybrid_cloud_foreign_key_jobs():
- # Deprecated deploy boundary shim
- schedule_hybrid_cloud_foreign_key_jobs_new()
-
-
def _schedule_hybrid_cloud_foreign_key(silo_mode: SiloMode, cascade_task: Task) -> None:
for app, app_models in apps.all_models.items():
for model in app_models.values():
@@ -190,7 +168,7 @@ def _schedule_hybrid_cloud_foreign_key(silo_mode: SiloMode, cascade_task: Task)
acks_late=True,
silo_mode=SiloMode.CONTROL,
)
-def process_hybrid_cloud_foreign_key_cascade_batch_control_new(
+def process_hybrid_cloud_foreign_key_cascade_batch_control(
app_name: str, model_name: str, field_name: str, **kwargs: Any
) -> None:
if options.get("hybrid_cloud.disable_tombstone_cleanup"):
@@ -205,28 +183,13 @@ def process_hybrid_cloud_foreign_key_cascade_batch_control_new(
)
-@instrumented_task(
- name="sentry.tasks.deletion.process_hybrid_cloud_foreign_key_cascade_batch_control",
- queue="cleanup.control",
- acks_late=True,
- silo_mode=SiloMode.CONTROL,
-)
-def process_hybrid_cloud_foreign_key_cascade_batch_control(
- app_name: str, model_name: str, field_name: str, **kwargs: Any
-) -> None:
- # Deprecated deploy boundary shim
- process_hybrid_cloud_foreign_key_cascade_batch_control_new(
- app_name, model_name, field_name, **kwargs
- )
-
-
@instrumented_task(
name="sentry.deletions.tasks.process_hybrid_cloud_foreign_key_cascade_batch",
queue="cleanup",
acks_late=True,
silo_mode=SiloMode.REGION,
)
-def process_hybrid_cloud_foreign_key_cascade_batch_new(
+def process_hybrid_cloud_foreign_key_cascade_batch(
app_name: str, model_name: str, field_name: str, **kwargs: Any
) -> None:
if options.get("hybrid_cloud.disable_tombstone_cleanup"):
@@ -241,19 +204,6 @@ def process_hybrid_cloud_foreign_key_cascade_batch_new(
)
-@instrumented_task(
- name="sentry.tasks.deletion.process_hybrid_cloud_foreign_key_cascade_batch",
- queue="cleanup",
- acks_late=True,
- silo_mode=SiloMode.REGION,
-)
-def process_hybrid_cloud_foreign_key_cascade_batch(
- app_name: str, model_name: str, field_name: str, **kwargs: Any
-) -> None:
- # Deprecated deploy boundary shim
- process_hybrid_cloud_foreign_key_cascade_batch_new(app_name, model_name, field_name)
-
-
def _process_hybrid_cloud_foreign_key_cascade(
app_name: str, model_name: str, field_name: str, process_task: Task, silo_mode: SiloMode
) -> None:
@@ -308,7 +258,7 @@ def get_batch_size() -> int:
def _process_tombstone_reconciliation(
- field: HybridCloudForeignKey,
+ field: HybridCloudForeignKey[Any, Any],
model: Any,
tombstone_cls: type[TombstoneBase],
row_after_tombstone: bool,
@@ -316,7 +266,7 @@ def _process_tombstone_reconciliation(
from sentry import deletions
prefix = "tombstone"
- watermark_manager: BaseManager = tombstone_cls.objects
+ watermark_manager: BaseManager[Any] = tombstone_cls.objects
if row_after_tombstone:
prefix = "row"
watermark_manager = field.model.objects
@@ -373,7 +323,7 @@ def _process_tombstone_reconciliation(
def _get_model_ids_for_tombstone_cascade(
tombstone_cls: type[TombstoneBase],
model: type[Model],
- field: HybridCloudForeignKey,
+ field: HybridCloudForeignKey[Any, Any],
row_after_tombstone: bool,
watermark_batch: WatermarkBatch,
) -> tuple[list[int], datetime.datetime]:
@@ -449,7 +399,7 @@ def _get_model_ids_for_tombstone_cascade(
def get_ids_cross_db_for_row_watermark(
tombstone_cls: type[TombstoneBase],
model: type[Model],
- field: HybridCloudForeignKey,
+ field: HybridCloudForeignKey[Any, Any],
row_watermark_batch: WatermarkBatch,
) -> tuple[list[int], datetime.datetime]:
@@ -484,7 +434,7 @@ def get_ids_cross_db_for_row_watermark(
def get_ids_cross_db_for_tombstone_watermark(
tombstone_cls: type[TombstoneBase],
model: type[Model],
- field: HybridCloudForeignKey,
+ field: HybridCloudForeignKey[Any, Any],
tombstone_watermark_batch: WatermarkBatch,
) -> tuple[list[int], datetime.datetime]:
oldest_seen = timezone.now()
diff --git a/src/sentry/deletions/tasks/scheduled.py b/src/sentry/deletions/tasks/scheduled.py
index 9f296ab05851fd..e0ae8daa4f6f10 100644
--- a/src/sentry/deletions/tasks/scheduled.py
+++ b/src/sentry/deletions/tasks/scheduled.py
@@ -31,42 +31,20 @@
acks_late=True,
silo_mode=SiloMode.CONTROL,
)
-def reattempt_deletions_control_new():
+def reattempt_deletions_control() -> None:
_reattempt_deletions(ScheduledDeletion)
-@instrumented_task(
- name="sentry.tasks.deletion.reattempt_deletions_control",
- queue="cleanup.control",
- acks_late=True,
- silo_mode=SiloMode.CONTROL,
-)
-def reattempt_deletions_control():
- # Deprecated deploy boundary shim
- reattempt_deletions_control_new()
-
-
@instrumented_task(
name="sentry.deletions.tasks.reattempt_deletions",
queue="cleanup",
acks_late=True,
silo_mode=SiloMode.REGION,
)
-def reattempt_deletions_new():
+def reattempt_deletions() -> None:
_reattempt_deletions(RegionScheduledDeletion)
-@instrumented_task(
- name="sentry.tasks.deletion.reattempt_deletions",
- queue="cleanup",
- acks_late=True,
- silo_mode=SiloMode.REGION,
-)
-def reattempt_deletions():
- # Deprecated deploy boundary shim
- reattempt_deletions_new()
-
-
def _reattempt_deletions(model_class: type[BaseScheduledDeletion]) -> None:
# If a deletion is in progress and was scheduled to run more than
# a day ago we can assume the previous job died/failed.
@@ -83,41 +61,23 @@ def _reattempt_deletions(model_class: type[BaseScheduledDeletion]) -> None:
queue="cleanup.control",
acks_late=True,
)
-def run_scheduled_deletions_control_new() -> None:
+def run_scheduled_deletions_control() -> None:
_run_scheduled_deletions(
model_class=ScheduledDeletion,
process_task=run_deletion_control,
)
-@instrumented_task(
- name="sentry.tasks.deletion.run_scheduled_deletions_control",
- queue="cleanup.control",
- acks_late=True,
-)
-def run_scheduled_deletions_control() -> None:
- # Deprecated deploy boundary shim
- run_scheduled_deletions_control_new()
-
-
@instrumented_task(
name="sentry.deletions.tasks.run_scheduled_deletions", queue="cleanup", acks_late=True
)
-def run_scheduled_deletions_new() -> None:
+def run_scheduled_deletions() -> None:
_run_scheduled_deletions(
model_class=RegionScheduledDeletion,
process_task=run_deletion,
)
-@instrumented_task(
- name="sentry.tasks.deletion.run_scheduled_deletions", queue="cleanup", acks_late=True
-)
-def run_scheduled_deletions() -> None:
- # Deprecated deploy boundary shim
- run_scheduled_deletions_new()
-
-
def _run_scheduled_deletions(model_class: type[BaseScheduledDeletion], process_task: Task) -> None:
queryset = model_class.objects.filter(in_progress=False, date_scheduled__lte=timezone.now())
for item in queryset:
@@ -141,7 +101,7 @@ def _run_scheduled_deletions(model_class: type[BaseScheduledDeletion], process_t
silo_mode=SiloMode.CONTROL,
)
@retry(exclude=(DeleteAborted,))
-def run_deletion_control_new(deletion_id, first_pass=True, **kwargs: Any):
+def run_deletion_control(deletion_id: int, first_pass: bool = True, **kwargs: Any) -> None:
_run_deletion(
deletion_id=deletion_id,
first_pass=first_pass,
@@ -150,20 +110,6 @@ def run_deletion_control_new(deletion_id, first_pass=True, **kwargs: Any):
)
-@instrumented_task(
- name="sentry.tasks.deletion.run_deletion_control",
- queue="cleanup.control",
- default_retry_delay=60 * 5,
- max_retries=MAX_RETRIES,
- acks_late=True,
- silo_mode=SiloMode.CONTROL,
-)
-@retry(exclude=(DeleteAborted,))
-def run_deletion_control(deletion_id, first_pass=True, **kwargs: Any):
- # Deprecated deploy boundary shim
- run_deletion_control_new(deletion_id, first_pass, **kwargs)
-
-
@instrumented_task(
name="sentry.deletions.tasks.run_deletion",
queue="cleanup",
@@ -173,7 +119,7 @@ def run_deletion_control(deletion_id, first_pass=True, **kwargs: Any):
silo_mode=SiloMode.REGION,
)
@retry(exclude=(DeleteAborted,))
-def run_deletion_new(deletion_id, first_pass=True, **kwargs: Any):
+def run_deletion(deletion_id: int, first_pass: bool = True, **kwargs: Any) -> None:
_run_deletion(
deletion_id=deletion_id,
first_pass=first_pass,
@@ -182,20 +128,6 @@ def run_deletion_new(deletion_id, first_pass=True, **kwargs: Any):
)
-@instrumented_task(
- name="sentry.tasks.deletion.run_deletion",
- queue="cleanup",
- default_retry_delay=60 * 5,
- max_retries=MAX_RETRIES,
- acks_late=True,
- silo_mode=SiloMode.REGION,
-)
-@retry(exclude=(DeleteAborted,))
-def run_deletion(deletion_id, first_pass=True, **kwargs: Any):
- # Deprecated deploy boundary shim
- run_deletion_new(deletion_id, first_pass, **kwargs)
-
-
def _run_deletion(
deletion_id: int,
first_pass: bool,
diff --git a/src/sentry/event_manager.py b/src/sentry/event_manager.py
index a3074b33fedbf5..8158b9c23dc253 100644
--- a/src/sentry/event_manager.py
+++ b/src/sentry/event_manager.py
@@ -2209,9 +2209,9 @@ def _process_existing_aggregate(
"title": _get_updated_group_title(existing_metadata, incoming_metadata),
}
- update_kwargs = {"times_seen": 1}
-
- buffer_incr(Group, update_kwargs, {"id": group.id}, updated_group_values)
+ # We pass `times_seen` separately from all of the other columns so that `buffer_inr` knows to
+ # increment rather than overwrite the existing value
+ buffer_incr(Group, {"times_seen": 1}, {"id": group.id}, updated_group_values)
return bool(is_regression)
diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py
index b90481d844f3a6..aca40bc21abbea 100644
--- a/src/sentry/features/temporary.py
+++ b/src/sentry/features/temporary.py
@@ -98,6 +98,8 @@ def register_temporary_features(manager: FeatureManager):
manager.add("organizations:dashboards-span-metrics", OrganizationFeature, FeatureHandlerStrategy.OPTIONS, api_expose=False)
# Enable releases overlay on dashboard chart widgets
manager.add("organizations:dashboards-releases-on-charts", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
+ # Enable equations for Big Number widgets
+ manager.add("organizations:dashboards-bignumber-equations", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable the dev toolbar PoC code for employees
# Data Secrecy
manager.add("organizations:data-secrecy", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
@@ -121,6 +123,8 @@ def register_temporary_features(manager: FeatureManager):
manager.add("organizations:escalating-issues-v2", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False)
# Enable emiting escalating data to the metrics backend
manager.add("organizations:escalating-metrics-backend", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False)
+ # Enable logging for failure rate subscription processor
+ manager.add("organizations:failure-rate-metric-alert-logging", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# Enable feature flag UI in issue details
manager.add("organizations:feature-flag-ui", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable disabling gitlab integrations when broken is detected
@@ -186,6 +190,8 @@ def register_temporary_features(manager: FeatureManager):
manager.add("organizations:metric-alert-threshold-period", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enables the search bar for metrics samples list
manager.add("organizations:metrics-samples-list-search", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
+ # Migrate Orgs to new Azure DevOps Integration
+ manager.add("organizations:migrate-azure-devops-integration", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable Session Stats down to a minute resolution
manager.add("organizations:minute-resolution-sessions", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, default=True, api_expose=True)
# Display CPU and memory metrics in transactions with profiles
@@ -397,8 +403,6 @@ def register_temporary_features(manager: FeatureManager):
manager.add("organizations:session-replay-timeline-gap", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, default=False, api_expose=True)
# Enable the new event linking columns to be queried
manager.add("organizations:session-replay-new-event-counts", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
- # Enable Rage Click Issue Creation In Recording Consumer
- manager.add("organizations:session-replay-rage-click-issue-creation", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False)
# Enable a reduced timeout when waiting for the DOM, before reporting Rage and Dead clicks
manager.add("organizations:session-replay-dead-click-reduced-timeout", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, default=False, api_expose=False)
# Enable data scrubbing of replay recording payloads in Relay.
@@ -433,6 +437,8 @@ def register_temporary_features(manager: FeatureManager):
manager.add("organizations:insights-initial-modules", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=True)
# Enable access to newer Insights modules (Caches, Queues, LLMs, Mobile UI)
manager.add("organizations:insights-addon-modules", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=True)
+ # Make Insights modules use EAP instead of metrics
+ manager.add("organizations:insights-use-eap", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable access to insights metrics alerts
manager.add("organizations:insights-alerts", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable domain view in Insights modules
@@ -489,6 +495,8 @@ def register_temporary_features(manager: FeatureManager):
manager.add("organizations:transaction-name-sanitization", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False)
# Enables creation and full updating of uptime monitors via the api
manager.add("organizations:uptime-api-create-update", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
+ # Displys the "Uptime Monitor" option in the alert creation wizard
+ manager.add("organizations:uptime-display-wizard-create", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enables automatic hostname detection in uptime
manager.add("organizations:uptime-automatic-hostname-detection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# Enables automatic subscription creation in uptime
@@ -526,6 +534,8 @@ def register_temporary_features(manager: FeatureManager):
manager.add("organizations:webhooks-unresolved", OrganizationFeature, FeatureHandlerStrategy.OPTIONS, api_expose=True)
# Display the new 'what's new' experience
manager.add("organizations:what-is-new-revamp", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
+ # Enable new feature parsing code for Jira integrations
+ manager.add("organizations:new-jira-transformers", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# NOTE: Don't add features down here! Add them to their specific group and sort
# them alphabetically! The order features are registered is not important.
diff --git a/src/sentry/feedback/usecases/create_feedback.py b/src/sentry/feedback/usecases/create_feedback.py
index 44442af76cd0a1..57c9aae5e4a033 100644
--- a/src/sentry/feedback/usecases/create_feedback.py
+++ b/src/sentry/feedback/usecases/create_feedback.py
@@ -364,43 +364,26 @@ def shim_to_feedback(
},
}
- if event:
- feedback_event["contexts"]["feedback"]["associated_event_id"] = event.event_id
-
- if get_path(event.data, "contexts", "replay", "replay_id"):
- feedback_event["contexts"]["replay"] = event.data["contexts"]["replay"]
- feedback_event["contexts"]["feedback"]["replay_id"] = event.data["contexts"][
- "replay"
- ]["replay_id"]
-
- if get_path(event.data, "contexts", "trace", "trace_id"):
- feedback_event["contexts"]["trace"] = event.data["contexts"]["trace"]
-
- feedback_event["timestamp"] = event.datetime.timestamp()
- feedback_event["platform"] = event.platform
- feedback_event["level"] = event.data["level"]
- feedback_event["environment"] = event.get_environment().name
- feedback_event["tags"] = [list(item) for item in event.tags]
-
- else:
- metrics.incr(
- "feedback.user_report.missing_event",
- sample_rate=1.0,
- tags={"referrer": source.value},
- )
+ feedback_event["contexts"]["feedback"]["associated_event_id"] = event.event_id
+
+ if get_path(event.data, "contexts", "replay", "replay_id"):
+ feedback_event["contexts"]["replay"] = event.data["contexts"]["replay"]
+ feedback_event["contexts"]["feedback"]["replay_id"] = event.data["contexts"]["replay"][
+ "replay_id"
+ ]
- feedback_event["timestamp"] = datetime.utcnow().timestamp()
- feedback_event["platform"] = "other"
- feedback_event["level"] = report.get("level", "info")
+ if get_path(event.data, "contexts", "trace", "trace_id"):
+ feedback_event["contexts"]["trace"] = event.data["contexts"]["trace"]
- if report.get("event_id"):
- feedback_event["contexts"]["feedback"]["associated_event_id"] = report["event_id"]
+ feedback_event["timestamp"] = event.datetime.timestamp()
+ feedback_event["platform"] = event.platform
+ feedback_event["level"] = event.data["level"]
+ feedback_event["environment"] = event.get_environment().name
+ feedback_event["tags"] = [list(item) for item in event.tags]
create_feedback_issue(feedback_event, project.id, source)
except Exception:
- logger.exception(
- "Error attempting to create new User Feedback from Shiming old User Report"
- )
+ logger.exception("Error attempting to create new user feedback by shimming a user report")
metrics.incr("feedback.shim_to_feedback.failed", tags={"referrer": source.value})
diff --git a/src/sentry/hybridcloud/apigateway/proxy.py b/src/sentry/hybridcloud/apigateway/proxy.py
index a6ddeae1e5a41e..e84cf0722f3680 100644
--- a/src/sentry/hybridcloud/apigateway/proxy.py
+++ b/src/sentry/hybridcloud/apigateway/proxy.py
@@ -18,9 +18,9 @@
from sentry import options
from sentry.api.exceptions import RequestTimeout
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
from sentry.models.organizationmapping import OrganizationMapping
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.silo.util import (
PROXY_DIRECT_LOCATION_HEADER,
clean_outbound_headers,
diff --git a/src/sentry/hybridcloud/rpc/service.py b/src/sentry/hybridcloud/rpc/service.py
index 3a17638407d231..3b85e7f332c28e 100644
--- a/src/sentry/hybridcloud/rpc/service.py
+++ b/src/sentry/hybridcloud/rpc/service.py
@@ -667,10 +667,22 @@ def _fire_request(self, headers: MutableMapping[str, str], data: bytes) -> reque
try:
return http.post(url, headers=headers, data=data, timeout=timeout)
except requests.exceptions.ConnectionError as e:
+ metrics.incr(
+ "hybrid_cloud.dispatch_rpc.failure",
+ tags=self._metrics_tags(kind="connectionerror"),
+ )
raise self._remote_exception("RPC Connection failed") from e
except requests.exceptions.RetryError as e:
+ metrics.incr(
+ "hybrid_cloud.dispatch_rpc.failure",
+ tags=self._metrics_tags(kind="retryerror"),
+ )
raise self._remote_exception("RPC failed, max retries reached.") from e
except requests.exceptions.Timeout as e:
+ metrics.incr(
+ "hybrid_cloud.dispatch_rpc.failure",
+ tags=self._metrics_tags(kind="timeout"),
+ )
raise self._remote_exception(f"Timeout of {settings.RPC_TIMEOUT} exceeded") from e
def _check_disabled(self) -> None:
diff --git a/src/sentry/identity/__init__.py b/src/sentry/identity/__init__.py
index 6a25c58b8b84c9..80c6b7e2a30c98 100644
--- a/src/sentry/identity/__init__.py
+++ b/src/sentry/identity/__init__.py
@@ -9,7 +9,7 @@
from .oauth2 import * # NOQA
from .slack import * # NOQA
from .vercel import * # NOQA
-from .vsts import VSTSIdentityProvider
+from .vsts import VSTSIdentityProvider, VSTSNewIdentityProvider
from .vsts_extension import * # NOQA
default_manager = IdentityManager()
@@ -25,6 +25,7 @@
register(SlackIdentityProvider) # NOQA
register(GitHubIdentityProvider) # NOQA
register(GitHubEnterpriseIdentityProvider) # NOQA
+register(VSTSNewIdentityProvider) # NOQA
register(VSTSIdentityProvider) # NOQA
register(VstsExtensionIdentityProvider) # NOQA
register(VercelIdentityProvider) # NOQA
diff --git a/src/sentry/identity/pipeline.py b/src/sentry/identity/pipeline.py
index 1e4f8f3b21f5cb..c4c577ed32295b 100644
--- a/src/sentry/identity/pipeline.py
+++ b/src/sentry/identity/pipeline.py
@@ -5,7 +5,10 @@
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
-from sentry.pipeline import Pipeline
+from sentry import features
+from sentry.models.organization import Organization
+from sentry.organizations.services.organization.model import RpcOrganization
+from sentry.pipeline import Pipeline, PipelineProvider
from sentry.users.models.identity import Identity, IdentityProvider
from sentry.utils import metrics
@@ -35,6 +38,17 @@ def redirect_url(self):
# Use configured redirect_url if specified for the pipeline if available
return self.config.get("redirect_url", associate_url)
+ # TODO(iamrajjoshi): Delete this after Azure DevOps migration is complete
+ def get_provider(self, provider_key: str, **kwargs) -> PipelineProvider:
+ if kwargs.get("organization"):
+ organization: Organization | RpcOrganization = kwargs["organization"]
+ if provider_key == "vsts" and features.has(
+ "organizations:migrate-azure-devops-integration", organization
+ ):
+ provider_key = "vsts_new"
+
+ return super().get_provider(provider_key)
+
def finish_pipeline(self):
# NOTE: only reached in the case of linking a new identity
# via Social Auth pipelines
diff --git a/src/sentry/identity/vsts/provider.py b/src/sentry/identity/vsts/provider.py
index cb483f446abde4..bc17268b06d4a1 100644
--- a/src/sentry/identity/vsts/provider.py
+++ b/src/sentry/identity/vsts/provider.py
@@ -135,3 +135,115 @@ def exchange_token(self, request: Request, pipeline, code):
if req.headers["Content-Type"].startswith("application/x-www-form-urlencoded"):
return dict(parse_qsl(body))
return orjson.loads(body)
+
+
+# TODO(iamrajjoshi): Make this the default provider
+# We created this new flow in order to quickly update the DevOps integration to use
+# the new Azure AD OAuth2 flow.
+# This is a temporary solution until we can fully migrate to the new flow once customers are migrated
+class VSTSNewIdentityProvider(OAuth2Provider):
+ key = "vsts_new"
+ name = "Azure DevOps"
+
+ oauth_access_token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
+ oauth_authorize_url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"
+
+ # Using a new option
+ def get_oauth_client_id(self):
+ return options.get("vsts_new.client-id")
+
+ def get_oauth_client_secret(self):
+ return options.get("vsts_new.client-secret")
+
+ def get_refresh_token_url(self):
+ return self.oauth_access_token_url
+
+ def get_pipeline_views(self):
+ return [
+ # made a new view to override `get_authorize_params` for the new params needed for the oauth
+ VSTSOAuth2LoginView(
+ authorize_url=self.oauth_authorize_url,
+ client_id=self.get_oauth_client_id(),
+ scope=" ".join(self.get_oauth_scopes()),
+ ),
+ VSTSNewOAuth2CallbackView(
+ access_token_url=self.oauth_access_token_url,
+ client_id=self.get_oauth_client_id(),
+ client_secret=self.get_oauth_client_secret(),
+ ),
+ ]
+
+ def get_refresh_token_headers(self):
+ return {"Content-Type": "application/x-www-form-urlencoded", "Content-Length": "1654"}
+
+ def get_refresh_token_params(self, refresh_token, *args, **kwargs):
+ # TODO(iamrajjoshi): Fix vsts-limited here
+ # Note: ignoring the below from the original provider
+ # # If "vso.code" is missing from the identity.scopes, we know that we installed
+ # using the "vsts-limited.client-secret" and therefore should use that to refresh
+ # the token.
+
+ oauth_redirect_url = kwargs.get("redirect_url")
+ if oauth_redirect_url is None:
+ raise ValueError("VSTS requires oauth redirect url when refreshing identity")
+
+ return {
+ "grant_type": "refresh_token",
+ "client_id": self.get_oauth_client_id(),
+ "client_secret": self.get_oauth_client_secret(),
+ "refresh_token": refresh_token,
+ }
+
+ def build_identity(self, data):
+ data = data["data"]
+ access_token = data.get("access_token")
+ if not access_token:
+ raise PermissionDenied()
+ user = get_user_info(access_token)
+
+ return {
+ "type": "vsts",
+ "id": user["id"],
+ "email": user["emailAddress"],
+ "email_verified": True,
+ "name": user["displayName"],
+ "scopes": sorted(self.oauth_scopes),
+ "data": self.get_oauth_data(data),
+ }
+
+
+class VSTSOAuth2LoginView(OAuth2LoginView):
+ def get_authorize_params(self, state, redirect_uri):
+ return {
+ "client_id": self.client_id,
+ "response_type": "code",
+ "redirect_uri": redirect_uri,
+ "response_mode": "query",
+ "scope": self.get_scope(),
+ "state": state,
+ "prompt": "consent",
+ }
+
+
+class VSTSNewOAuth2CallbackView(OAuth2CallbackView):
+ def exchange_token(self, request: Request, pipeline, code):
+ from urllib.parse import parse_qsl
+
+ from sentry.http import safe_urlopen, safe_urlread
+ from sentry.utils.http import absolute_uri
+
+ req = safe_urlopen(
+ url=self.access_token_url,
+ headers={"Content-Type": "application/x-www-form-urlencoded", "Content-Length": "1322"},
+ data={
+ "grant_type": "authorization_code",
+ "client_id": self.client_id,
+ "client_secret": self.client_secret,
+ "code": code,
+ "redirect_uri": absolute_uri(pipeline.redirect_url()),
+ },
+ )
+ body = safe_urlread(req)
+ if req.headers["Content-Type"].startswith("application/x-www-form-urlencoded"):
+ return dict(parse_qsl(body))
+ return orjson.loads(body)
diff --git a/src/sentry/incidents/endpoints/serializers/alert_rule.py b/src/sentry/incidents/endpoints/serializers/alert_rule.py
index f40f3e5e1c2c63..0ec511f596727c 100644
--- a/src/sentry/incidents/endpoints/serializers/alert_rule.py
+++ b/src/sentry/incidents/endpoints/serializers/alert_rule.py
@@ -23,9 +23,9 @@
)
from sentry.incidents.models.alert_rule_activations import AlertRuleActivations
from sentry.incidents.models.incident import Incident
-from sentry.models.integrations.sentry_app_installation import prepare_ui_component
from sentry.models.rule import Rule
from sentry.models.rulesnooze import RuleSnooze
+from sentry.sentry_apps.models.sentry_app_installation import prepare_ui_component
from sentry.sentry_apps.services.app import app_service
from sentry.sentry_apps.services.app.model import RpcSentryAppComponentContext
from sentry.snuba.models import SnubaQueryEventType
diff --git a/src/sentry/incidents/logic.py b/src/sentry/incidents/logic.py
index 44157a40fd08d8..f09b8fbdb11453 100644
--- a/src/sentry/incidents/logic.py
+++ b/src/sentry/incidents/logic.py
@@ -70,6 +70,7 @@
SPANS_METRICS_FUNCTIONS,
)
from sentry.search.events.fields import is_function, resolve_field
+from sentry.seer.anomaly_detection.delete_rule import delete_rule_in_seer
from sentry.seer.anomaly_detection.store_data import send_historical_data_to_seer
from sentry.sentry_apps.services.app import RpcSentryAppInstallation, app_service
from sentry.shared_integrations.exceptions import (
@@ -961,6 +962,18 @@ def update_alert_rule(
# If there's no historical data available—something went wrong when querying snuba
raise ValidationError("Failed to send data to Seer - cannot update alert rule.")
else:
+ # if this was a dynamic rule, delete the data in Seer
+ if alert_rule.detection_type == AlertRuleDetectionType.DYNAMIC:
+ success = delete_rule_in_seer(
+ alert_rule=alert_rule,
+ )
+ if not success:
+ logger.error(
+ "Call to delete rule data in Seer failed",
+ extra={
+ "rule_id": alert_rule.id,
+ },
+ )
# if this alert was previously a dynamic alert, then we should update the rule to be ready
if alert_rule.status == AlertRuleStatus.NOT_ENOUGH_DATA.value:
alert_rule.update(status=AlertRuleStatus.PENDING.value)
diff --git a/src/sentry/incidents/models/alert_rule.py b/src/sentry/incidents/models/alert_rule.py
index 45c23d6a93dd41..fe310ba0ed91a9 100644
--- a/src/sentry/incidents/models/alert_rule.py
+++ b/src/sentry/incidents/models/alert_rule.py
@@ -36,6 +36,7 @@
from sentry.models.organization import Organization
from sentry.models.project import Project
from sentry.models.team import Team
+from sentry.seer.anomaly_detection.delete_rule import delete_rule_in_seer
from sentry.snuba.models import QuerySubscription
from sentry.snuba.subscriptions import bulk_create_snuba_subscriptions, delete_snuba_subscription
from sentry.types.actor import Actor
@@ -171,6 +172,18 @@ def clear_alert_rule_subscription_caches(cls, instance: AlertRule, **kwargs: Any
for sub_id in subscription_ids
)
+ @classmethod
+ def delete_data_in_seer(cls, instance: AlertRule, **kwargs: Any) -> None:
+ if instance.detection_type == AlertRuleDetectionType.DYNAMIC:
+ success = delete_rule_in_seer(alert_rule=instance)
+ if not success:
+ logger.error(
+ "Call to delete rule data in Seer failed",
+ extra={
+ "rule_id": instance.id,
+ },
+ )
+
def conditionally_subscribe_project_to_alert_rules(
self,
project: Project,
@@ -797,6 +810,7 @@ def update_alert_activations(
post_delete.connect(AlertRuleManager.clear_subscription_cache, sender=QuerySubscription)
+post_delete.connect(AlertRuleManager.delete_data_in_seer, sender=AlertRule)
post_save.connect(AlertRuleManager.clear_subscription_cache, sender=QuerySubscription)
post_save.connect(AlertRuleManager.clear_alert_rule_subscription_caches, sender=AlertRule)
post_delete.connect(AlertRuleManager.clear_alert_rule_subscription_caches, sender=AlertRule)
diff --git a/src/sentry/incidents/subscription_processor.py b/src/sentry/incidents/subscription_processor.py
index c7dc6d74c165ac..a59b48007910f1 100644
--- a/src/sentry/incidents/subscription_processor.py
+++ b/src/sentry/incidents/subscription_processor.py
@@ -517,6 +517,18 @@ def process_update(self, subscription_update: QuerySubscriptionUpdate) -> None:
)
aggregation_value = self.get_aggregation_value(subscription_update)
+ if features.has(
+ "organizations:failure-rate-metric-alert-logging",
+ self.subscription.project.organization,
+ ):
+ logger.info(
+ "Update value in subscription processor",
+ extra={
+ "result": subscription_update,
+ "aggregation_value": aggregation_value,
+ "rule_id": self.alert_rule.id,
+ },
+ )
self.has_anomaly_detection = features.has(
"organizations:anomaly-detection-alerts", self.subscription.project.organization
diff --git a/src/sentry/integrations/jira/integration.py b/src/sentry/integrations/jira/integration.py
index bf683a2acef171..599e85abaf2eca 100644
--- a/src/sentry/integrations/jira/integration.py
+++ b/src/sentry/integrations/jira/integration.py
@@ -42,7 +42,9 @@
from sentry.utils.strings import truncatechars
from .client import JiraCloudClient
+from .models.create_issue_metadata import JIRA_CUSTOM_FIELD_TYPES
from .utils import build_user_choice
+from .utils.create_issue_schema_transformers import transform_fields
logger = logging.getLogger("sentry.integrations.jira")
@@ -108,17 +110,6 @@
# a valid link (e.g. "is blocked by ISSUE-1").
HIDDEN_ISSUE_FIELDS = ["issuelinks"]
-# A list of common builtin custom field types for Jira for easy reference.
-JIRA_CUSTOM_FIELD_TYPES = {
- "select": "com.atlassian.jira.plugin.system.customfieldtypes:select",
- "textarea": "com.atlassian.jira.plugin.system.customfieldtypes:textarea",
- "multiuserpicker": "com.atlassian.jira.plugin.system.customfieldtypes:multiuserpicker",
- "tempo_account": "com.tempoplugin.tempo-accounts:accounts.customfield",
- "sprint": "com.pyxis.greenhopper.jira:gh-sprint",
- "epic": "com.pyxis.greenhopper.jira:gh-epic-link",
- "team": "com.atlassian.jira.plugin.system.customfieldtypes:atlassian-team",
-}
-
class JiraIntegration(IssueSyncIntegration):
comment_key = "sync_comments"
@@ -804,7 +795,9 @@ def get_create_issue_config(self, group: Group | None, user: RpcUser, **kwargs):
return fields
- def create_issue(self, data, **kwargs):
+ def _old_clean_and_transform_issue_data(
+ self, data: dict[str, Any], issue_type_meta: dict[str, Any]
+ ) -> dict[str, Any]:
"""
Get the (cached) "createmeta" from Jira to use as a "schema". Clean up
the Jira issue by removing all fields that aren't enumerated by this
@@ -812,25 +805,10 @@ def create_issue(self, data, **kwargs):
to Jira to make sure the issue was created and return basic issue details.
:param data: JiraCreateTicketAction object
- :param kwargs: not used
:return: simple object with basic Jira issue details
"""
client = self.get_client()
cleaned_data = {}
- # protect against mis-configured integration submitting a form without an
- # issuetype assigned.
- if not data.get("issuetype"):
- raise IntegrationFormError({"issuetype": ["Issue type is required."]})
-
- jira_project = data.get("project")
- if not jira_project:
- raise IntegrationFormError({"project": ["Jira project is required"]})
-
- meta = client.get_create_meta_for_project(jira_project)
- if not meta:
- raise IntegrationError("Could not fetch issue create configuration from Jira.")
-
- issue_type_meta = self.get_issue_type_meta(data["issuetype"], meta)
user_id_field = client.user_id_field()
fs = issue_type_meta["fields"]
@@ -909,6 +887,39 @@ def create_issue(self, data, **kwargs):
# in the projectmeta API call, and would normally be converted in the
# above clean method.)
cleaned_data["issuetype"] = {"id": cleaned_data["issuetype"]}
+ return cleaned_data
+
+ def _clean_and_transform_issue_data(
+ self, issue_metadata: JiraIssueTypeMetadata, data: dict[str, Any]
+ ) -> Any:
+ client = self.get_client()
+ transformed_data = transform_fields(
+ client.user_id_field(), issue_metadata.fields.values(), **data
+ )
+ return transformed_data
+
+ def create_issue(self, data, **kwargs):
+ client = self.get_client()
+ # protect against mis-configured integration submitting a form without an
+ # issuetype assigned.
+ if not data.get("issuetype"):
+ raise IntegrationFormError({"issuetype": ["Issue type is required."]})
+
+ jira_project = data.get("project")
+ if not jira_project:
+ raise IntegrationFormError({"project": ["Jira project is required"]})
+
+ meta = client.get_create_meta_for_project(jira_project)
+ if not meta:
+ raise IntegrationError("Could not fetch issue create configuration from Jira.")
+
+ issue_type_meta = self.get_issue_type_meta(data["issuetype"], meta)
+ if features.has("organizations:new-jira-transformers", organization=self.organization):
+ cleaned_data = self._clean_and_transform_issue_data(
+ JiraIssueTypeMetadata.from_dict(issue_type_meta), data
+ )
+ else:
+ cleaned_data = self._old_clean_and_transform_issue_data(data, issue_type_meta)
try:
response = client.create_issue(cleaned_data)
diff --git a/src/sentry/integrations/jira/models/create_issue_metadata.py b/src/sentry/integrations/jira/models/create_issue_metadata.py
index 6f54b2a994071a..2817c998c4952e 100644
--- a/src/sentry/integrations/jira/models/create_issue_metadata.py
+++ b/src/sentry/integrations/jira/models/create_issue_metadata.py
@@ -4,6 +4,19 @@
from enum import Enum
from typing import Any
+# A list of common builtin custom field types for Jira for easy reference.
+JIRA_CUSTOM_FIELD_TYPES = {
+ "select": "com.atlassian.jira.plugin.system.customfieldtypes:select",
+ "textarea": "com.atlassian.jira.plugin.system.customfieldtypes:textarea",
+ "multiuserpicker": "com.atlassian.jira.plugin.system.customfieldtypes:multiuserpicker",
+ "tempo_account": "com.tempoplugin.tempo-accounts:accounts.customfield",
+ "sprint": "com.pyxis.greenhopper.jira:gh-sprint",
+ "epic": "com.pyxis.greenhopper.jira:gh-epic-link",
+ "team": "com.atlassian.jira.plugin.system.customfieldtypes:atlassian-team",
+ "rank": "com.pyxis.greenhopper.jira:gh-lexo-rank",
+ "development": "com.atlassian.jira.plugins.jira-development-integration-plugin:devsummarycf",
+}
+
class JiraSchemaTypes(str, Enum):
string = "string"
@@ -16,6 +29,7 @@ class JiraSchemaTypes(str, Enum):
date = "date"
team = "team"
number = "number"
+ json = "json"
any = "any"
@@ -39,7 +53,7 @@ class JiraSchema:
The very long custom field name corresponding to some namespace, plugin,
and custom field name.
"""
- custom_id: str | None = None
+ custom_id: int | None = None
"""
A unique identifier for a field on an issue, in the form of 'customfield_'
"""
@@ -156,8 +170,8 @@ def from_dict(cls, data: dict[str, Any]) -> JiraIssueTypeMetadata:
)
@classmethod
- def from_jira_meta_config(cls, meta_config: dict[str, Any]) -> list[JiraIssueTypeMetadata]:
+ def from_jira_meta_config(cls, meta_config: dict[str, Any]) -> dict[str, JiraIssueTypeMetadata]:
issue_types_list = meta_config.get("issuetypes", {})
issue_configs = [cls.from_dict(it) for it in issue_types_list]
- return issue_configs
+ return {it.id: it for it in issue_configs}
diff --git a/src/sentry/integrations/jira/utils/create_issue_schema_transformers.py b/src/sentry/integrations/jira/utils/create_issue_schema_transformers.py
new file mode 100644
index 00000000000000..9db9d6fd3a62d6
--- /dev/null
+++ b/src/sentry/integrations/jira/utils/create_issue_schema_transformers.py
@@ -0,0 +1,140 @@
+from collections.abc import Callable, Iterable, Mapping
+from typing import Any, TypeVar
+
+from sentry.integrations.jira.models.create_issue_metadata import (
+ JIRA_CUSTOM_FIELD_TYPES,
+ JiraField,
+ JiraSchemaTypes,
+)
+from sentry.shared_integrations.exceptions import IntegrationFormError
+
+
+class JiraSchemaParseError(Exception):
+ pass
+
+
+def parse_number_field(num_str: Any) -> int | float:
+ try:
+ if isinstance(num_str, str) and "." in num_str:
+ return float(num_str)
+
+ return int(num_str)
+ except ValueError:
+ raise JiraSchemaParseError(f"Invalid number value provided for field: '{num_str}'")
+
+
+TransformerType = Mapping[str, Callable[[Any], Any]]
+
+T = TypeVar("T")
+
+
+def identity_transformer(input_val: T) -> T:
+ return input_val
+
+
+def id_obj_transformer(input_val: Any) -> dict[str, Any]:
+ return {"id": input_val}
+
+
+def get_type_transformer_mappings(user_id_field: str) -> TransformerType:
+ transformers = {
+ JiraSchemaTypes.user.value: lambda x: {user_id_field: x},
+ JiraSchemaTypes.issue_type.value: id_obj_transformer,
+ JiraSchemaTypes.option.value: lambda x: {"value": x},
+ JiraSchemaTypes.issue_link.value: lambda x: {"key": x},
+ JiraSchemaTypes.project.value: id_obj_transformer,
+ JiraSchemaTypes.number.value: parse_number_field,
+ }
+
+ return transformers
+
+
+def get_custom_field_transformer_mappings() -> TransformerType:
+ transformers = {
+ # TODO(Gabe): `select` type fields are broken in the UI, fix this.
+ # JIRA_CUSTOM_FIELD_TYPES["select"]: identity_transformer,
+ # TODO(Gabe): `epic` type fields don't currently appear in the issue
+ # link dialog. Re-enable this if needed after testing.
+ # JIRA_CUSTOM_FIELD_TYPES["epic"]: identity_transformer,
+ JIRA_CUSTOM_FIELD_TYPES["tempo_account"]: parse_number_field,
+ JIRA_CUSTOM_FIELD_TYPES["sprint"]: parse_number_field,
+ JIRA_CUSTOM_FIELD_TYPES["rank"]: id_obj_transformer,
+ }
+
+ return transformers
+
+
+def get_transformer_for_field(
+ type_transformers: TransformerType, custom_transformers: TransformerType, jira_field: JiraField
+) -> Callable[[Any], Any]:
+ transformer = None
+ if jira_field.is_custom_field():
+ assert jira_field.schema.custom
+ transformer = custom_transformers.get(jira_field.schema.custom)
+
+ if not transformer:
+ field_type = jira_field.get_field_type()
+
+ if field_type:
+ transformer = type_transformers.get(field_type)
+
+ if not transformer:
+ transformer = identity_transformer
+
+ return transformer
+
+
+def transform_fields(
+ user_id_field: str, jira_fields: Iterable[JiraField], **data
+) -> Mapping[str, Any]:
+ transformed_data = {}
+
+ # Special handling for fields that don't map cleanly to the transformer logic
+ data["summary"] = data.get("title")
+ if labels := data.get("labels"):
+ data["labels"] = [label.strip() for label in labels.split(",") if label.strip()]
+
+ type_transformers = get_type_transformer_mappings(user_id_field)
+ custom_field_transformers = get_custom_field_transformer_mappings()
+
+ for field in jira_fields:
+ field_data = data.get(field.key)
+
+ # We don't have a mapping for this field, so it's probably extraneous.
+ # TODO(Gabe): Explore raising a sentry issue for unmapped fields in
+ # order for us to properly filter them out.
+ if field_data is None:
+ continue
+
+ field_transformer = get_transformer_for_field(
+ type_transformers, custom_field_transformers, field
+ )
+
+ try:
+ # Handling for array types and their nested subtypes.
+ # We have to skip this handling for `sprint` custom fields, as they
+ # are the only `array` type that expects a number, not a list.
+ if (
+ field.schema.schema_type.lower() == JiraSchemaTypes.array
+ and field.schema.custom != JIRA_CUSTOM_FIELD_TYPES["sprint"]
+ ):
+ transformed_value = []
+
+ # Occasionally, our UI passes a string instead of a list, so we
+ # have to just wrap it and hope it's in the correct format.
+ if not isinstance(field_data, list):
+ field_data = [field_data]
+
+ # Bulk transform the individual data fields
+ for val in field_data:
+ transformed_value.append(field_transformer(val))
+ else:
+ transformed_value = field_transformer(field_data)
+
+ except JiraSchemaParseError as e:
+ raise IntegrationFormError(field_errors={field.name: str(e)}) from e
+
+ if transformed_value:
+ transformed_data[field.key] = transformed_value
+
+ return transformed_data
diff --git a/src/sentry/integrations/models/integration_feature.py b/src/sentry/integrations/models/integration_feature.py
index 01709720a8c00d..de19d6af31210b 100644
--- a/src/sentry/integrations/models/integration_feature.py
+++ b/src/sentry/integrations/models/integration_feature.py
@@ -17,7 +17,7 @@
)
from sentry.db.models.manager.base import BaseManager
from sentry.integrations.models.doc_integration import DocIntegration
-from sentry.models.integrations.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app import SentryApp
class Feature:
@@ -209,7 +209,7 @@ def feature_str(self) -> str:
@property
def description(self) -> str:
from sentry.integrations.models.doc_integration import DocIntegration
- from sentry.models.integrations.sentry_app import SentryApp
+ from sentry.sentry_apps.models.sentry_app import SentryApp
if self.user_description:
return self.user_description
diff --git a/src/sentry/integrations/models/utils.py b/src/sentry/integrations/models/utils.py
index 613cb04b0b93a3..3c19d174bc4334 100644
--- a/src/sentry/integrations/models/utils.py
+++ b/src/sentry/integrations/models/utils.py
@@ -10,7 +10,7 @@
)
from sentry.integrations.models.integration import Integration
from sentry.integrations.services.integration.model import RpcIntegration
- from sentry.models.integrations.sentry_app import SentryApp
+ from sentry.sentry_apps.models.sentry_app import SentryApp
from sentry.sentry_apps.services.app.model import RpcSentryApp
diff --git a/src/sentry/integrations/msteams/card_builder/issues.py b/src/sentry/integrations/msteams/card_builder/issues.py
index 277868364a1d83..f85dfe68216994 100644
--- a/src/sentry/integrations/msteams/card_builder/issues.py
+++ b/src/sentry/integrations/msteams/card_builder/issues.py
@@ -5,7 +5,7 @@
from datetime import datetime
from typing import Any
-from sentry.eventstore.models import Event
+from sentry.eventstore.models import Event, GroupEvent
from sentry.integrations.messaging.message_builder import (
build_attachment_text,
build_attachment_title,
@@ -53,7 +53,11 @@
class MSTeamsIssueMessageBuilder(MSTeamsMessageBuilder):
def __init__(
- self, group: Group, event: Event, rules: Sequence[Rule], integration: RpcIntegration
+ self,
+ group: Group,
+ event: Event | GroupEvent,
+ rules: Sequence[Rule],
+ integration: RpcIntegration,
):
self.group = group
self.event = event
diff --git a/src/sentry/integrations/msteams/link_identity.py b/src/sentry/integrations/msteams/link_identity.py
index dbfaff687ec156..771c40a67df0f7 100644
--- a/src/sentry/integrations/msteams/link_identity.py
+++ b/src/sentry/integrations/msteams/link_identity.py
@@ -6,6 +6,7 @@
from sentry.integrations.messaging.linkage import LinkIdentityView
from sentry.integrations.models.integration import Integration
from sentry.integrations.msteams.linkage import MsTeamsIdentityLinkageView
+from sentry.integrations.services.integration import RpcIntegration
from sentry.models.organization import Organization
from sentry.utils.http import absolute_uri
from sentry.utils.signing import sign
@@ -15,7 +16,7 @@
def build_linking_url(
- integration: Integration,
+ integration: Integration | RpcIntegration,
organization: Organization,
teams_user_id: str,
team_id: str,
diff --git a/src/sentry/integrations/msteams/parsing.py b/src/sentry/integrations/msteams/parsing.py
new file mode 100644
index 00000000000000..69e5a98687f0d7
--- /dev/null
+++ b/src/sentry/integrations/msteams/parsing.py
@@ -0,0 +1,84 @@
+import logging
+from collections.abc import Mapping
+from typing import Any
+
+from sentry.integrations.msteams.spec import PROVIDER
+from sentry.integrations.services.integration import integration_service
+from sentry.integrations.services.integration.model import RpcIntegration
+
+logger = logging.getLogger(__name__)
+
+
+def _infer_team_id_from_channel_data(data: Mapping[str, Any]) -> str | None:
+ try:
+ channel_data = data["channelData"]
+ team_id = channel_data["team"]["id"]
+ return team_id
+ except Exception:
+ pass
+ return None
+
+
+def get_integration_from_channel_data(data: Mapping[str, Any]) -> RpcIntegration | None:
+ team_id = _infer_team_id_from_channel_data(data=data)
+ if team_id is None:
+ return None
+ return integration_service.get_integration(provider=PROVIDER, external_id=team_id)
+
+
+def get_integration_for_tenant(data: Mapping[str, Any]) -> RpcIntegration | None:
+ try:
+ channel_data = data["channelData"]
+ tenant_id = channel_data["tenant"]["id"]
+ return integration_service.get_integration(provider=PROVIDER, external_id=tenant_id)
+ except Exception as err:
+ logger.info("failed to get tenant id from request data", exc_info=err, extra={"data": data})
+ return None
+
+
+def _infer_integration_id_from_card_action(data: Mapping[str, Any]) -> int | None:
+ # The bot builds and sends Adaptive Cards to the channel, and in it will include card actions and context.
+ # The context will include the "integrationId".
+ # Whenever a user interacts with the card, MS Teams will send the card action and the context to the bot.
+ # Here we parse the "integrationId" from the context.
+ #
+ # See: https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-actions?tabs=json#actionsubmit
+ try:
+ payload = data["value"]["payload"]
+ integration_id = payload["integrationId"]
+ return integration_id
+ except Exception:
+ pass
+ return None
+
+
+def get_integration_from_card_action(data: Mapping[str, Any]) -> RpcIntegration | None:
+ integration_id = _infer_integration_id_from_card_action(data=data)
+ if integration_id is None:
+ return None
+ return integration_service.get_integration(integration_id=integration_id)
+
+
+def can_infer_integration(data: Mapping[str, Any]) -> bool:
+ return (
+ _infer_integration_id_from_card_action(data=data) is not None
+ or _infer_team_id_from_channel_data(data=data) is not None
+ )
+
+
+def is_new_integration_installation_event(data: Mapping[str, Any]) -> bool:
+ from sentry.integrations.msteams.webhook import MsTeamsEvents
+
+ try:
+ raw_event_type = data["type"]
+ event_type = MsTeamsEvents.get_from_value(value=raw_event_type)
+ if event_type != MsTeamsEvents.INSTALLATION_UPDATE:
+ return False
+
+ action = data.get("action", None)
+ if action is None or action != "add":
+ return False
+
+ return True
+ except Exception:
+ return False
diff --git a/src/sentry/integrations/msteams/spec.py b/src/sentry/integrations/msteams/spec.py
index 76ab82f34d1604..b617599c853100 100644
--- a/src/sentry/integrations/msteams/spec.py
+++ b/src/sentry/integrations/msteams/spec.py
@@ -9,11 +9,13 @@
from sentry.models.notificationaction import ActionService
from sentry.rules.actions import IntegrationEventAction
+PROVIDER = "msteams"
+
class MsTeamsMessagingSpec(MessagingIntegrationSpec):
@property
def provider_slug(self) -> str:
- return "msteams"
+ return PROVIDER
@property
def action_service(self) -> ActionService:
diff --git a/src/sentry/integrations/msteams/webhook.py b/src/sentry/integrations/msteams/webhook.py
index 0869d05f2c0c4e..dbfe7fd00f3184 100644
--- a/src/sentry/integrations/msteams/webhook.py
+++ b/src/sentry/integrations/msteams/webhook.py
@@ -4,12 +4,14 @@
import time
from collections.abc import Callable, Mapping
from enum import Enum
-from typing import Any
+from typing import Any, cast
import orjson
from django.http import HttpRequest, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from rest_framework.exceptions import AuthenticationFailed, NotAuthenticated
+from rest_framework.request import Request
+from rest_framework.response import Response
from sentry import analytics, audit_log, eventstore, options
from sentry.api import client
@@ -18,8 +20,9 @@
from sentry.api.base import Endpoint, all_silo_endpoint
from sentry.identity.services.identity import identity_service
from sentry.identity.services.identity.model import RpcIdentity
+from sentry.integrations.msteams import parsing
+from sentry.integrations.msteams.spec import PROVIDER
from sentry.integrations.services.integration import integration_service
-from sentry.integrations.services.integration.model import RpcIntegration
from sentry.models.activity import ActivityIntegration
from sentry.models.apikey import ApiKey
from sentry.models.group import Group
@@ -87,7 +90,7 @@ class MsTeamsIntegrationUnassign(MsTeamsIntegrationAnalytics):
analytics.register(MsTeamsIntegrationUnassign)
-def verify_signature(request):
+def verify_signature(request) -> bool:
# docs for jwt authentication here: https://docs.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication?view=azure-bot-service-4.0#bot-to-connector
token = request.META.get("HTTP_AUTHORIZATION", "").replace("Bearer ", "")
if not token:
@@ -103,7 +106,7 @@ def verify_signature(request):
# get the open id config and jwks
client = MsTeamsJwtClient()
open_id_config = client.get_open_id_config()
- jwks = client.get_cached(open_id_config["jwks_uri"])
+ jwks = cast(Mapping[str, Any], client.get_cached(open_id_config["jwks_uri"]))
# create a mapping of all the keys
# taken from: https://renzolucioni.com/verifying-jwts-with-jwks-and-pyjwt/
@@ -142,81 +145,6 @@ def verify_signature(request):
return True
-class MsTeamsWebhookMixin:
- @classmethod
- def infer_team_id_from_channel_data(cls, data: Mapping[str, Any]) -> str | None:
- try:
- channel_data = data["channelData"]
- team_id = channel_data["team"]["id"]
- return team_id
- except Exception:
- pass
- return None
-
- def get_integration_from_channel_data(self, data: Mapping[str, Any]) -> RpcIntegration | None:
- team_id = self.infer_team_id_from_channel_data(data=data)
- if team_id is None:
- return None
- return integration_service.get_integration(provider=self.provider, external_id=team_id)
-
- def get_integration_for_tenant(self, data: Mapping[str, Any]) -> RpcIntegration | None:
- try:
- channel_data = data["channelData"]
- tenant_id = channel_data["tenant"]["id"]
- return integration_service.get_integration(
- provider=self.provider, external_id=tenant_id
- )
- except Exception as err:
- logger.info(
- "failed to get tenant id from request data", exc_info=err, extra={"data": data}
- )
- return None
-
- @classmethod
- def infer_integration_id_from_card_action(cls, data: Mapping[str, Any]) -> int | None:
- # The bot builds and sends Adaptive Cards to the channel, and in it will include card actions and context.
- # The context will include the "integrationId".
- # Whenever a user interacts with the card, MS Teams will send the card action and the context to the bot.
- # Here we parse the "integrationId" from the context.
- #
- # See: https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-actions?tabs=json#actionsubmit
- try:
- payload = data["value"]["payload"]
- integration_id = payload["integrationId"]
- return integration_id
- except Exception:
- pass
- return None
-
- def get_integration_from_card_action(self, data: Mapping[str, Any]) -> RpcIntegration | None:
- integration_id = self.infer_integration_id_from_card_action(data=data)
- if integration_id is None:
- return None
- return integration_service.get_integration(integration_id=integration_id)
-
- def can_infer_integration(self, data: Mapping[str, Any]) -> bool:
- return (
- self.infer_integration_id_from_card_action(data=data) is not None
- or self.infer_team_id_from_channel_data(data=data) is not None
- )
-
- @classmethod
- def is_new_integration_installation_event(cls, data: Mapping[str, Any]) -> bool:
- try:
- raw_event_type = data["type"]
- event_type = MsTeamsEvents.get_from_value(value=raw_event_type)
- if event_type != MsTeamsEvents.INSTALLATION_UPDATE:
- return False
-
- action = data.get("action", None)
- if action is None or action != "add":
- return False
-
- return True
- except Exception:
- return False
-
-
class MsTeamsEvents(Enum):
INSTALLATION_UPDATE = "installationUpdate"
MESSAGE = "message"
@@ -232,36 +160,36 @@ def get_from_value(cls, value: str) -> MsTeamsEvents:
@all_silo_endpoint
-class MsTeamsWebhookEndpoint(Endpoint, MsTeamsWebhookMixin):
+class MsTeamsWebhookEndpoint(Endpoint):
owner = ApiOwner.INTEGRATIONS
publish_status = {
"POST": ApiPublishStatus.PRIVATE,
}
authentication_classes = ()
permission_classes = ()
- provider = "msteams"
+ provider = PROVIDER
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
- self._event_handlers: dict[MsTeamsEvents, Callable[[HttpRequest], HttpResponse]] = {
- MsTeamsEvents.MESSAGE: self.handle_message_event,
- MsTeamsEvents.CONVERSATION_UPDATE: self.handle_conversation_update_event,
- MsTeamsEvents.INSTALLATION_UPDATE: self.handle_installation_update_event,
- MsTeamsEvents.UNKNOWN: self.handle_unknown_event,
+ self._event_handlers: dict[MsTeamsEvents, Callable[[Request], Response]] = {
+ MsTeamsEvents.MESSAGE: self._handle_message_event,
+ MsTeamsEvents.CONVERSATION_UPDATE: self._handle_conversation_update_event,
+ MsTeamsEvents.INSTALLATION_UPDATE: self._handle_installation_update_event,
+ MsTeamsEvents.UNKNOWN: self._handle_unknown_event,
}
@csrf_exempt
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
return super().dispatch(request, *args, **kwargs)
- def post(self, request: HttpRequest) -> HttpResponse:
+ def post(self, request: Request) -> Response:
"""
POST webhook handler for MSTeams bot.
The events are broadcast to MSTeams from Microsoft, and are documented at https://learn.microsoft.com/en-us/microsoftteams/platform/resources/bot-v3/bots-notifications
"""
# verify_signature will raise the exception corresponding to the error
- self.verify_webhook_request(request)
+ self._verify_webhook_request(request)
data = request.data
raw_event_type = data["type"]
@@ -274,7 +202,7 @@ def post(self, request: HttpRequest) -> HttpResponse:
return response if response else self.respond(status=204)
@classmethod
- def _get_team_installation_request_data(cls, data: dict[str, Any]) -> dict:
+ def _get_team_installation_request_data(cls, data: dict[str, Any]) -> dict[str, Any]:
"""
Helper method that will construct the installation request for a MsTeams team channel.
We want the KeyError exception to be raised if the key does not exist.
@@ -302,7 +230,7 @@ def _get_team_installation_request_data(cls, data: dict[str, Any]) -> dict:
}
return params
- def handle_installation_update_event(self, request: HttpRequest) -> HttpResponse:
+ def _handle_installation_update_event(self, request: Request) -> Response:
data = request.data
action = data.get("action", None)
if action is None or action != "add":
@@ -334,7 +262,7 @@ def handle_installation_update_event(self, request: HttpRequest) -> HttpResponse
return self.respond(status=201)
- def handle_message_event(self, request: HttpRequest) -> HttpResponse:
+ def _handle_message_event(self, request: Request) -> Response:
data = request.data
conversation = data.get("conversation", {})
conversation_type = conversation.get("conversationType")
@@ -346,13 +274,13 @@ def handle_message_event(self, request: HttpRequest) -> HttpResponse:
# Processing card actions can only occur in the Region silo.
if SiloMode.get_current_mode() == SiloMode.CONTROL:
return self.respond(status=400)
- return self.handle_action_submitted(request)
+ return self._handle_action_submitted(request)
elif conversation_type == "channel":
- return self.handle_channel_message(request)
+ return self._handle_channel_message(request)
- return self.handle_personal_message(request)
+ return self._handle_personal_message(request)
- def handle_conversation_update_event(self, request: HttpRequest) -> HttpResponse:
+ def _handle_conversation_update_event(self, request: Request) -> Response:
data = request.data
conversation = data.get("conversation", {})
conversation_type = conversation.get("conversationType")
@@ -360,25 +288,25 @@ def handle_conversation_update_event(self, request: HttpRequest) -> HttpResponse
event = channel_data.get("eventType")
if event == "teamMemberAdded":
- return self.handle_team_member_added(request)
+ return self._handle_team_member_added(request)
elif event == "teamMemberRemoved":
if SiloMode.get_current_mode() == SiloMode.CONTROL:
return self.respond(status=400)
- return self.handle_team_member_removed(request)
+ return self._handle_team_member_removed(request)
elif (
data.get("membersAdded") and conversation_type == "personal"
): # no explicit event for user adding app unfortunately
- return self.handle_personal_member_add(request)
+ return self._handle_personal_member_add(request)
return self.respond(status=204)
- def handle_unknown_event(self, request: HttpRequest) -> HttpResponse:
+ def _handle_unknown_event(self, request: Request) -> Response:
return self.respond(status=204)
- def verify_webhook_request(self, request: HttpRequest) -> bool:
+ def _verify_webhook_request(self, request: Request) -> bool:
return verify_signature(request)
- def handle_personal_member_add(self, request: HttpRequest):
+ def _handle_personal_member_add(self, request: Request):
data = request.data
data["conversation_id"] = data["conversation"]["id"]
tenant_id = data["conversation"]["tenantId"]
@@ -388,9 +316,9 @@ def handle_personal_member_add(self, request: HttpRequest):
"external_name": f"{tenant_id} (Microsoft Tenant)",
"installation_type": "tenant",
}
- return self.handle_member_add(data, params, build_personal_installation_message)
+ return self._handle_member_add(data, params, build_personal_installation_message)
- def handle_team_member_added(self, request: HttpRequest):
+ def _handle_team_member_added(self, request: Request) -> Response:
data = request.data
team = data["channelData"]["team"]
data["conversation_id"] = team["id"]
@@ -401,14 +329,14 @@ def handle_team_member_added(self, request: HttpRequest):
"installation_type": "team",
}
- return self.handle_member_add(data, params, build_team_installation_message)
+ return self._handle_member_add(data, params, build_team_installation_message)
- def handle_member_add(
+ def _handle_member_add(
self,
- data: Mapping[str, str],
- params: Mapping[str, str],
+ data: Mapping[str, Any],
+ params: dict[str, str],
build_installation_card: Callable[[str], AdaptiveCard],
- ) -> HttpResponse:
+ ) -> Response:
# only care if our bot is the new member added
matches = list(filter(lambda x: x["id"] == data["recipient"]["id"], data["membersAdded"]))
if not matches:
@@ -442,7 +370,7 @@ def handle_member_add(
return self.respond(status=201)
- def handle_team_member_removed(self, request: HttpRequest):
+ def _handle_team_member_removed(self, request: Request) -> Response:
data = request.data
channel_data = data["channelData"]
# only care if our bot is the new member removed
@@ -452,7 +380,7 @@ def handle_team_member_removed(self, request: HttpRequest):
team_id = channel_data["team"]["id"]
- integration = self.get_integration_from_channel_data(data=data)
+ integration = parsing.get_integration_from_channel_data(data=data)
if integration is None:
logger.info(
"msteams.uninstall.missing-integration",
@@ -487,8 +415,8 @@ def handle_team_member_removed(self, request: HttpRequest):
integration_service.delete_integration(integration_id=integration.id)
return self.respond(status=204)
- def make_action_data(self, data, user_id):
- action_data = {}
+ def _make_action_data(self, data: Mapping[str, Any], user_id: int) -> dict[str, Any]:
+ action_data: dict[str, Any] = {}
action_type = data["payload"]["actionType"]
if action_type == ACTION_TYPE.UNRESOLVE:
action_data = {"status": "unresolved"}
@@ -519,7 +447,7 @@ def make_action_data(self, data, user_id):
action_data = {"assignedTo": ""}
return action_data
- def issue_state_change(self, group: Group, identity: RpcIdentity, data):
+ def _issue_state_change(self, group: Group, identity: RpcIdentity, data) -> Response:
event_write_key = ApiKey(
organization_id=group.project.organization_id, scope_list=["event:write"]
)
@@ -533,7 +461,7 @@ def issue_state_change(self, group: Group, identity: RpcIdentity, data):
ACTION_TYPE.UNRESOLVE: "unresolve",
ACTION_TYPE.UNASSIGN: "unassign",
}
- action_data = self.make_action_data(data, identity.user_id)
+ action_data = self._make_action_data(data, identity.user_id)
status = action_types[data["payload"]["actionType"]]
analytics_event = f"integrations.msteams.{status}"
analytics.record(
@@ -550,7 +478,7 @@ def issue_state_change(self, group: Group, identity: RpcIdentity, data):
auth=event_write_key,
)
- def handle_action_submitted(self, request: HttpRequest):
+ def _handle_action_submitted(self, request: Request) -> Response:
# pull out parameters
data = request.data
channel_data = data["channelData"]
@@ -566,7 +494,7 @@ def handle_action_submitted(self, request: HttpRequest):
else:
conversation_id = channel_data["channel"]["id"]
- integration = self.get_integration_from_card_action(data=data)
+ integration = parsing.get_integration_from_card_action(data=data)
if integration is None:
logger.info(
"msteams.action.missing-integration", extra={"integration_id": integration_id}
@@ -582,10 +510,13 @@ def handle_action_submitted(self, request: HttpRequest):
if integration is None:
group = None
- if not group:
+ if integration is None or group is None:
logger.info(
"msteams.action.invalid-issue",
- extra={"team_id": team_id, "integration_id": integration.id},
+ extra={
+ "team_id": team_id,
+ "integration_id": (integration.id if integration else None),
+ },
)
return self.respond(status=404)
@@ -615,10 +546,10 @@ def handle_action_submitted(self, request: HttpRequest):
return self.respond(status=201)
# update the state of the issue
- issue_change_response = self.issue_state_change(group, identity, data["value"])
+ issue_change_response = self._issue_state_change(group, identity, data["value"])
# get the rules from the payload
- rules = Rule.objects.filter(id__in=payload["rules"])
+ rules = tuple(Rule.objects.filter(id__in=payload["rules"]))
# pull the event based off our payload
event = eventstore.backend.get_event_by_id(group.project_id, payload["eventId"])
@@ -642,7 +573,7 @@ def handle_action_submitted(self, request: HttpRequest):
return issue_change_response
- def handle_channel_message(self, request: HttpRequest):
+ def _handle_channel_message(self, request: Request) -> Response:
data = request.data
# check to see if we are mentioned
@@ -668,7 +599,7 @@ def handle_channel_message(self, request: HttpRequest):
return self.respond(status=204)
- def handle_personal_message(self, request: HttpRequest):
+ def _handle_personal_message(self, request: Request) -> Response:
data = request.data
command_text = data.get("text", "").strip()
lowercase_command = command_text.lower()
diff --git a/src/sentry/integrations/services/integration/impl.py b/src/sentry/integrations/services/integration/impl.py
index 0b87209a1ca7d7..169e0679970058 100644
--- a/src/sentry/integrations/services/integration/impl.py
+++ b/src/sentry/integrations/services/integration/impl.py
@@ -34,9 +34,9 @@
serialize_integration_external_project,
serialize_organization_integration,
)
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
from sentry.rules.actions.notify_event_service import find_alert_rule_action_ui_component
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.shared_integrations.exceptions import ApiError
from sentry.utils import json, metrics
from sentry.utils.sentry_apps import send_and_save_webhook_request
diff --git a/src/sentry/integrations/vercel/integration.py b/src/sentry/integrations/vercel/integration.py
index 49b42fd4997839..4e3549b9b342a9 100644
--- a/src/sentry/integrations/vercel/integration.py
+++ b/src/sentry/integrations/vercel/integration.py
@@ -19,15 +19,15 @@
)
from sentry.integrations.models.integration import Integration
from sentry.integrations.services.integration import integration_service
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
-from sentry.models.integrations.sentry_app_installation_for_provider import (
- SentryAppInstallationForProvider,
-)
-from sentry.models.integrations.sentry_app_installation_token import SentryAppInstallationToken
from sentry.organizations.services.organization import RpcOrganizationSummary
from sentry.pipeline import NestedPipelineView
from sentry.projects.services.project_key import project_key_service
from sentry.sentry_apps.logic import SentryAppCreator
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation_for_provider import (
+ SentryAppInstallationForProvider,
+)
+from sentry.sentry_apps.models.sentry_app_installation_token import SentryAppInstallationToken
from sentry.shared_integrations.exceptions import ApiError, IntegrationError
from sentry.users.models.user import User
from sentry.utils.http import absolute_uri
diff --git a/src/sentry/integrations/vercel/webhook.py b/src/sentry/integrations/vercel/webhook.py
index e4eeff6f93bae7..af9db32bcce275 100644
--- a/src/sentry/integrations/vercel/webhook.py
+++ b/src/sentry/integrations/vercel/webhook.py
@@ -19,12 +19,12 @@
from sentry.hybridcloud.services.organization_mapping import organization_mapping_service
from sentry.integrations.models.integration import Integration
from sentry.integrations.models.organization_integration import OrganizationIntegration
-from sentry.models.integrations.sentry_app_installation_for_provider import (
- SentryAppInstallationForProvider,
-)
-from sentry.models.integrations.sentry_app_installation_token import SentryAppInstallationToken
from sentry.models.project import Project
from sentry.projects.services.project import project_service
+from sentry.sentry_apps.models.sentry_app_installation_for_provider import (
+ SentryAppInstallationForProvider,
+)
+from sentry.sentry_apps.models.sentry_app_installation_token import SentryAppInstallationToken
from sentry.shared_integrations.exceptions import IntegrationError
from sentry.utils.audit import create_audit_entry
from sentry.utils.http import absolute_uri
diff --git a/src/sentry/integrations/vsts/client.py b/src/sentry/integrations/vsts/client.py
index 6d419aaa0e15e0..7bab65d8fda586 100644
--- a/src/sentry/integrations/vsts/client.py
+++ b/src/sentry/integrations/vsts/client.py
@@ -11,6 +11,7 @@
from sentry.exceptions import InvalidIdentity
from sentry.integrations.base import IntegrationFeatureNotImplementedError
from sentry.integrations.client import ApiClient
+from sentry.integrations.services.integration.service import integration_service
from sentry.integrations.source_code_management.repository import RepositoryClient
from sentry.models.repository import Repository
from sentry.shared_integrations.client.base import BaseApiResponseX
@@ -158,6 +159,7 @@ def request(
class VstsApiClient(IntegrationProxyClient, VstsApiMixin, RepositoryClient):
integration_name = "vsts"
_identity: Identity | None = None
+ INVALID_CLIENT_ERROR = "invalid_client"
def __init__(
self,
@@ -199,9 +201,24 @@ def _refresh_auth_if_expired(self):
if time_expires is None:
raise InvalidIdentity("VstsApiClient requires identity with specified expired time")
if int(time_expires) <= int(time()):
- self.identity.get_provider().refresh_identity(
- self.identity, redirect_url=self.oauth_redirect_url
+ # TODO(iamrajjoshi): Remove this after migration
+ # Need this here because there is no way to get any identifier which would tell us which method we should use to refresh the token
+ from sentry.identity.vsts.provider import VSTSNewIdentityProvider
+ from sentry.integrations.vsts.integration import VstsIntegrationProvider
+
+ integration = integration_service.get_integration(
+ organization_integration_id=self.org_integration_id
)
+ # check if integration has migrated to new identity provider
+ migration_version = integration.metadata.get("integration_migration_version", 0)
+ if migration_version < VstsIntegrationProvider.CURRENT_MIGRATION_VERSION:
+ self.identity.get_provider().refresh_identity(
+ self.identity, redirect_url=self.oauth_redirect_url
+ )
+ else:
+ VSTSNewIdentityProvider().refresh_identity(
+ self.identity, redirect_url=self.oauth_redirect_url
+ )
@control_silo_function
def authorize_request(
diff --git a/src/sentry/integrations/vsts/integration.py b/src/sentry/integrations/vsts/integration.py
index adcec1cecb9750..831f39458bf648 100644
--- a/src/sentry/integrations/vsts/integration.py
+++ b/src/sentry/integrations/vsts/integration.py
@@ -385,6 +385,8 @@ class VstsIntegrationProvider(IntegrationProvider):
oauth_redirect_url = "/extensions/vsts/setup/"
needs_default_identity = True
integration_cls = VstsIntegration
+ CURRENT_MIGRATION_VERSION = 1
+ NEW_SCOPES = ("offline_access", "499b84ac-1321-427f-aa17-267ca6975798/.default")
features = frozenset(
[
@@ -422,6 +424,13 @@ def post_install(
)
def get_scopes(self) -> Sequence[str]:
+ # TODO(iamrajjoshi): Delete this after Azure DevOps migration is complete
+ if features.has(
+ "organizations:migrate-azure-devops-integration", self.pipeline.organization
+ ):
+ # This is the new way we need to pass scopes to the OAuth flow
+ # https://stackoverflow.com/questions/75729931/get-access-token-for-azure-devops-pat
+ return VstsIntegrationProvider.NEW_SCOPES
return ("vso.code", "vso.graph", "vso.serviceendpoint_manage", "vso.work_write")
def get_pipeline_views(self) -> Sequence[PipelineView]:
@@ -459,12 +468,50 @@ def build_integration(self, state: Mapping[str, Any]) -> Mapping[str, Any]:
},
}
+ # TODO(iamrajjoshi): Clean this up this after Azure DevOps migration is complete
try:
integration_model = IntegrationModel.objects.get(
provider="vsts", external_id=account["accountId"], status=ObjectStatus.ACTIVE
)
- # preserve previously created subscription information
- integration["metadata"]["subscription"] = integration_model.metadata["subscription"]
+
+ # Get Integration Metadata
+ integration_migration_version = integration_model.metadata.get(
+ "integration_migration_version", 0
+ )
+
+ if (
+ features.has(
+ "organizations:migrate-azure-devops-integration", self.pipeline.organization
+ )
+ and integration_migration_version
+ < VstsIntegrationProvider.CURRENT_MIGRATION_VERSION
+ ):
+ subscription_id, subscription_secret = self.create_subscription(
+ base_url=base_url, oauth_data=oauth_data
+ )
+ integration["metadata"]["subscription"] = {
+ "id": subscription_id,
+ "secret": subscription_secret,
+ }
+
+ integration["metadata"][
+ "integration_migration_version"
+ ] = VstsIntegrationProvider.CURRENT_MIGRATION_VERSION
+
+ logger.info(
+ "vsts.build_integration.migrated",
+ extra={
+ "organization_id": self.pipeline.organization.id,
+ "user_id": user["id"],
+ "account": account,
+ "migration_version": VstsIntegrationProvider.CURRENT_MIGRATION_VERSION,
+ "subscription_id": subscription_id,
+ "integration_id": integration_model.id,
+ },
+ )
+ else:
+ # preserve previously created subscription information
+ integration["metadata"]["subscription"] = integration_model.metadata["subscription"]
logger.info(
"vsts.build_integration",
diff --git a/src/sentry/issues/endpoints/__init__.py b/src/sentry/issues/endpoints/__init__.py
index 221956860218c8..d862ff8eaa96ea 100644
--- a/src/sentry/issues/endpoints/__init__.py
+++ b/src/sentry/issues/endpoints/__init__.py
@@ -6,6 +6,7 @@
from .group_hashes import GroupHashesEndpoint
from .group_notes import GroupNotesEndpoint
from .group_notes_details import GroupNotesDetailsEndpoint
+from .group_participants import GroupParticipantsEndpoint
from .group_similar_issues import GroupSimilarIssuesEndpoint
from .group_similar_issues_embeddings import GroupSimilarIssuesEmbeddingsEndpoint
from .organization_group_index import OrganizationGroupIndexEndpoint
@@ -13,31 +14,39 @@
from .organization_group_search_views import OrganizationGroupSearchViewsEndpoint
from .organization_release_previous_commits import OrganizationReleasePreviousCommitsEndpoint
from .organization_searches import OrganizationSearchesEndpoint
+from .project_event_details import EventJsonEndpoint, ProjectEventDetailsEndpoint
+from .project_events import ProjectEventsEndpoint
from .project_group_index import ProjectGroupIndexEndpoint
from .project_group_stats import ProjectGroupStatsEndpoint
from .project_stacktrace_link import ProjectStacktraceLinkEndpoint
from .shared_group_details import SharedGroupDetailsEndpoint
from .source_map_debug import SourceMapDebugEndpoint
+from .team_groups_old import TeamGroupsOldEndpoint
__all__ = (
"ActionableItemsEndpoint",
+ "EventJsonEndpoint",
"GroupActivitiesEndpoint",
"GroupDetailsEndpoint",
- "GroupEventsEndpoint",
"GroupEventDetailsEndpoint",
+ "GroupEventsEndpoint",
"GroupHashesEndpoint",
- "GroupNotesEndpoint",
"GroupNotesDetailsEndpoint",
- "GroupSimilarIssuesEndpoint",
+ "GroupNotesEndpoint",
+ "GroupParticipantsEndpoint",
"GroupSimilarIssuesEmbeddingsEndpoint",
+ "GroupSimilarIssuesEndpoint",
"OrganizationGroupIndexEndpoint",
"OrganizationGroupIndexStatsEndpoint",
"OrganizationGroupSearchViewsEndpoint",
"OrganizationReleasePreviousCommitsEndpoint",
"OrganizationSearchesEndpoint",
+ "ProjectEventDetailsEndpoint",
+ "ProjectEventsEndpoint",
"ProjectGroupIndexEndpoint",
"ProjectGroupStatsEndpoint",
"ProjectStacktraceLinkEndpoint",
"SharedGroupDetailsEndpoint",
"SourceMapDebugEndpoint",
+ "TeamGroupsOldEndpoint",
)
diff --git a/src/sentry/issues/endpoints/group_activities.py b/src/sentry/issues/endpoints/group_activities.py
index f816106afa20b7..6529c28dc8f7af 100644
--- a/src/sentry/issues/endpoints/group_activities.py
+++ b/src/sentry/issues/endpoints/group_activities.py
@@ -6,6 +6,7 @@
from sentry.api.bases import GroupEndpoint
from sentry.api.serializers import serialize
from sentry.models.activity import Activity
+from sentry.models.group import Group
@region_silo_endpoint
@@ -14,7 +15,7 @@ class GroupActivitiesEndpoint(GroupEndpoint, EnvironmentMixin):
"GET": ApiPublishStatus.UNKNOWN,
}
- def get(self, request: Request, group) -> Response:
+ def get(self, request: Request, group: Group) -> Response:
"""
Retrieve all the Activities for a Group
"""
diff --git a/src/sentry/issues/endpoints/group_event_details.py b/src/sentry/issues/endpoints/group_event_details.py
index 5c0d62fd5a822b..bd8521b6df6c85 100644
--- a/src/sentry/issues/endpoints/group_event_details.py
+++ b/src/sentry/issues/endpoints/group_event_details.py
@@ -13,12 +13,12 @@
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.api.bases.group import GroupEndpoint
-from sentry.api.endpoints.project_event_details import wrap_event_response
from sentry.api.helpers.environments import get_environments
from sentry.api.helpers.group_index import parse_and_convert_issue_search_query
from sentry.api.helpers.group_index.validators import ValidationError
from sentry.api.serializers import EventSerializer, serialize
from sentry.eventstore.models import Event, GroupEvent
+from sentry.issues.endpoints.project_event_details import wrap_event_response
from sentry.issues.grouptype import GroupCategory
from sentry.models.environment import Environment
from sentry.models.group import Group
diff --git a/src/sentry/api/endpoints/group_participants.py b/src/sentry/issues/endpoints/group_participants.py
similarity index 88%
rename from src/sentry/api/endpoints/group_participants.py
rename to src/sentry/issues/endpoints/group_participants.py
index dd893a76abdab9..1662be566b0b9b 100644
--- a/src/sentry/api/endpoints/group_participants.py
+++ b/src/sentry/issues/endpoints/group_participants.py
@@ -6,6 +6,7 @@
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.api.bases import GroupEndpoint
+from sentry.models.group import Group
from sentry.models.groupsubscription import GroupSubscriptionManager
from sentry.users.services.user.service import user_service
@@ -16,7 +17,7 @@ class GroupParticipantsEndpoint(GroupEndpoint):
"GET": ApiPublishStatus.UNKNOWN,
}
- def get(self, request: Request, group) -> Response:
+ def get(self, request: Request, group: Group) -> Response:
participants = GroupSubscriptionManager.get_participating_user_ids(group)
return Response(
diff --git a/src/sentry/issues/endpoints/organization_group_index_stats.py b/src/sentry/issues/endpoints/organization_group_index_stats.py
index 0494974380986a..7a2be8738c7c1f 100644
--- a/src/sentry/issues/endpoints/organization_group_index_stats.py
+++ b/src/sentry/issues/endpoints/organization_group_index_stats.py
@@ -14,6 +14,7 @@
from sentry.exceptions import InvalidParams
from sentry.issues.endpoints.organization_group_index import ERR_INVALID_STATS_PERIOD
from sentry.models.group import Group
+from sentry.models.organization import Organization
from sentry.types.ratelimit import RateLimit, RateLimitCategory
@@ -34,7 +35,7 @@ class OrganizationGroupIndexStatsEndpoint(OrganizationEndpoint):
}
}
- def get(self, request: Request, organization) -> Response:
+ def get(self, request: Request, organization: Organization) -> Response:
"""
Get the stats on an Organization's Issues
`````````````````````````````
diff --git a/src/sentry/issues/endpoints/organization_group_search_views.py b/src/sentry/issues/endpoints/organization_group_search_views.py
index af1428d47b7397..e0f9f123bc2764 100644
--- a/src/sentry/issues/endpoints/organization_group_search_views.py
+++ b/src/sentry/issues/endpoints/organization_group_search_views.py
@@ -127,7 +127,7 @@ def bulk_update_views(
if "id" not in view:
_create_view(org, user_id, view, position=idx)
else:
- _update_existing_view(view, position=idx)
+ _update_existing_view(org, user_id, view, position=idx)
def _delete_missing_views(org: Organization, user_id: int, view_ids_to_keep: list[str]) -> None:
@@ -136,13 +136,22 @@ def _delete_missing_views(org: Organization, user_id: int, view_ids_to_keep: lis
).delete()
-def _update_existing_view(view: GroupSearchViewValidatorResponse, position: int) -> None:
- GroupSearchView.objects.get(id=view["id"]).update(
- name=view["name"],
- query=view["query"],
- query_sort=view["querySort"],
- position=position,
- )
+def _update_existing_view(
+ org: Organization, user_id: int, view: GroupSearchViewValidatorResponse, position: int
+) -> None:
+ try:
+ GroupSearchView.objects.get(id=view["id"]).update(
+ name=view["name"],
+ query=view["query"],
+ query_sort=view["querySort"],
+ position=position,
+ )
+ except GroupSearchView.DoesNotExist:
+ # It is ~possible~ for a view to come in that doesn't exist anymore if, for example,
+ # the user has the issue stream open in separate windows, deletes a view in one window,
+ # then updates it in the other before refreshing. In this case, we decide to recreate the
+ # tab instead of leaving it deleted.
+ _create_view(org, user_id, view, position)
def _create_view(
diff --git a/src/sentry/api/endpoints/project_event_details.py b/src/sentry/issues/endpoints/project_event_details.py
similarity index 100%
rename from src/sentry/api/endpoints/project_event_details.py
rename to src/sentry/issues/endpoints/project_event_details.py
diff --git a/src/sentry/api/endpoints/project_events.py b/src/sentry/issues/endpoints/project_events.py
similarity index 96%
rename from src/sentry/api/endpoints/project_events.py
rename to src/sentry/issues/endpoints/project_events.py
index ee39b55e471c7f..b236c192713104 100644
--- a/src/sentry/api/endpoints/project_events.py
+++ b/src/sentry/issues/endpoints/project_events.py
@@ -11,6 +11,7 @@
from sentry.api.base import region_silo_endpoint
from sentry.api.bases.project import ProjectEndpoint
from sentry.api.serializers import EventSerializer, SimpleEventSerializer, serialize
+from sentry.models.project import Project
from sentry.snuba.events import Columns
from sentry.types.ratelimit import RateLimit, RateLimitCategory
@@ -30,7 +31,7 @@ class ProjectEventsEndpoint(ProjectEndpoint):
}
}
- def get(self, request: Request, project) -> Response:
+ def get(self, request: Request, project: Project) -> Response:
"""
List a Project's Error Events
```````````````````````
diff --git a/src/sentry/api/endpoints/team_groups_old.py b/src/sentry/issues/endpoints/team_groups_old.py
similarity index 94%
rename from src/sentry/api/endpoints/team_groups_old.py
rename to src/sentry/issues/endpoints/team_groups_old.py
index 529f87a5a275c8..9c62e3c8d78f30 100644
--- a/src/sentry/api/endpoints/team_groups_old.py
+++ b/src/sentry/issues/endpoints/team_groups_old.py
@@ -11,6 +11,7 @@
from sentry.api.helpers.environments import get_environments
from sentry.api.serializers import GroupSerializer, serialize
from sentry.models.group import Group, GroupStatus
+from sentry.models.team import Team
@region_silo_endpoint
@@ -20,7 +21,7 @@ class TeamGroupsOldEndpoint(TeamEndpoint, EnvironmentMixin):
"GET": ApiPublishStatus.PRIVATE,
}
- def get(self, request: Request, team) -> Response:
+ def get(self, request: Request, team: Team) -> Response:
"""
Return the oldest issues owned by a team
"""
diff --git a/src/sentry/issues/merge.py b/src/sentry/issues/merge.py
index 9d79c9f3e35e42..314d6fdaedc513 100644
--- a/src/sentry/issues/merge.py
+++ b/src/sentry/issues/merge.py
@@ -42,7 +42,9 @@ def handle_merge(
primary_group.project_id, group_ids_to_merge, primary_group.id
)
- Group.objects.filter(id__in=group_ids_to_merge).update(status=GroupStatus.PENDING_MERGE)
+ Group.objects.filter(id__in=group_ids_to_merge).update(
+ status=GroupStatus.PENDING_MERGE, substatus=None
+ )
transaction_id = uuid4().hex
merge_groups.delay(
diff --git a/src/sentry/mediators/alert_rule_actions/creator.py b/src/sentry/mediators/alert_rule_actions/creator.py
index 1b2435ed0b73c5..732bd883dce7ea 100644
--- a/src/sentry/mediators/alert_rule_actions/creator.py
+++ b/src/sentry/mediators/alert_rule_actions/creator.py
@@ -8,8 +8,8 @@
)
from sentry.mediators.mediator import Mediator
from sentry.mediators.param import Param
-from sentry.models.integrations.sentry_app_component import SentryAppComponent
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_component import SentryAppComponent
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
class AlertRuleActionCreator(Mediator):
diff --git a/src/sentry/mediators/external_requests/alert_rule_action_requester.py b/src/sentry/mediators/external_requests/alert_rule_action_requester.py
index ec7519b41614a5..839e89baa4a306 100644
--- a/src/sentry/mediators/external_requests/alert_rule_action_requester.py
+++ b/src/sentry/mediators/external_requests/alert_rule_action_requester.py
@@ -11,7 +11,7 @@
from sentry.mediators.external_requests.util import send_and_save_sentry_app_request
from sentry.mediators.mediator import Mediator
from sentry.mediators.param import Param
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.utils import json
logger = logging.getLogger("sentry.mediators.external-requests")
diff --git a/src/sentry/mediators/external_requests/util.py b/src/sentry/mediators/external_requests/util.py
index 4258a3689a256f..13923f59430024 100644
--- a/src/sentry/mediators/external_requests/util.py
+++ b/src/sentry/mediators/external_requests/util.py
@@ -6,7 +6,7 @@
from requests.models import Response
from sentry.http import safe_urlopen
-from sentry.models.integrations.sentry_app import SentryApp, track_response_code
+from sentry.sentry_apps.models.sentry_app import SentryApp, track_response_code
from sentry.utils.sentry_apps import SentryAppWebhookRequestsBuffer
from sentry.utils.sentry_apps.webhooks import TIMEOUT_STATUS_CODE
diff --git a/src/sentry/mediators/sentry_app_installations/installation_notifier.py b/src/sentry/mediators/sentry_app_installations/installation_notifier.py
index 3727e17da8e54c..bb37880dfcda90 100644
--- a/src/sentry/mediators/sentry_app_installations/installation_notifier.py
+++ b/src/sentry/mediators/sentry_app_installations/installation_notifier.py
@@ -6,8 +6,8 @@
from sentry.mediators.mediator import Mediator
from sentry.mediators.param import Param
from sentry.models.apigrant import ApiGrant
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.users.services.user.model import RpcUser
from sentry.utils.sentry_apps import send_and_save_webhook_request
diff --git a/src/sentry/mediators/sentry_app_installations/updater.py b/src/sentry/mediators/sentry_app_installations/updater.py
index feb733866ce61c..4a6fac446b8c28 100644
--- a/src/sentry/mediators/sentry_app_installations/updater.py
+++ b/src/sentry/mediators/sentry_app_installations/updater.py
@@ -4,7 +4,7 @@
from sentry.constants import SentryAppInstallationStatus
from sentry.mediators.mediator import Mediator
from sentry.mediators.param import Param
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.sentry_apps.services.app import RpcSentryAppInstallation
diff --git a/src/sentry/mediators/token_exchange/grant_exchanger.py b/src/sentry/mediators/token_exchange/grant_exchanger.py
index 3519b19187e6d4..53a697ed36802e 100644
--- a/src/sentry/mediators/token_exchange/grant_exchanger.py
+++ b/src/sentry/mediators/token_exchange/grant_exchanger.py
@@ -12,8 +12,8 @@
from sentry.models.apiapplication import ApiApplication
from sentry.models.apigrant import ApiGrant
from sentry.models.apitoken import ApiToken
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.sentry_apps.services.app import RpcSentryAppInstallation
from sentry.silo.safety import unguarded_write
from sentry.users.models.user import User
diff --git a/src/sentry/mediators/token_exchange/refresher.py b/src/sentry/mediators/token_exchange/refresher.py
index 6f731f39876200..08bdb2d0bcd547 100644
--- a/src/sentry/mediators/token_exchange/refresher.py
+++ b/src/sentry/mediators/token_exchange/refresher.py
@@ -9,8 +9,8 @@
from sentry.mediators.token_exchange.validator import Validator
from sentry.models.apiapplication import ApiApplication
from sentry.models.apitoken import ApiToken
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.sentry_apps.services.app import RpcSentryAppInstallation
from sentry.users.models.user import User
diff --git a/src/sentry/mediators/token_exchange/validator.py b/src/sentry/mediators/token_exchange/validator.py
index c6c0c1dc1d9fc0..4b88fac49e4e3b 100644
--- a/src/sentry/mediators/token_exchange/validator.py
+++ b/src/sentry/mediators/token_exchange/validator.py
@@ -5,7 +5,7 @@
from sentry.mediators.mediator import Mediator
from sentry.mediators.param import Param
from sentry.models.apiapplication import ApiApplication
-from sentry.models.integrations.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app import SentryApp
from sentry.sentry_apps.services.app import RpcSentryAppInstallation
from sentry.users.models.user import User
diff --git a/src/sentry/middleware/integrations/parsers/msteams.py b/src/sentry/middleware/integrations/parsers/msteams.py
index b25d1f66a309ff..94fed80c9bd2d8 100644
--- a/src/sentry/middleware/integrations/parsers/msteams.py
+++ b/src/sentry/middleware/integrations/parsers/msteams.py
@@ -13,11 +13,8 @@
from sentry.integrations.middleware.hybrid_cloud.parser import BaseRequestParser
from sentry.integrations.models.integration import Integration
from sentry.integrations.models.organization_integration import OrganizationIntegration
-from sentry.integrations.msteams.webhook import (
- MsTeamsEvents,
- MsTeamsWebhookEndpoint,
- MsTeamsWebhookMixin,
-)
+from sentry.integrations.msteams import parsing
+from sentry.integrations.msteams.webhook import MsTeamsEvents, MsTeamsWebhookEndpoint
from sentry.integrations.types import EXTERNAL_PROVIDERS, ExternalProviders
from sentry.silo.base import control_silo_function
from sentry.types.region import Region, RegionResolutionError
@@ -25,7 +22,7 @@
logger = logging.getLogger(__name__)
-class MsTeamsRequestParser(BaseRequestParser, MsTeamsWebhookMixin):
+class MsTeamsRequestParser(BaseRequestParser):
provider = EXTERNAL_PROVIDERS[ExternalProviders.MSTEAMS]
webhook_identifier = WebhookProviderIdentifier.MSTEAMS
@@ -44,11 +41,11 @@ def request_data(self):
@control_silo_function
def get_integration_from_request(self) -> Integration | None:
- integration = self.get_integration_from_card_action(data=self.request_data)
+ integration = parsing.get_integration_from_card_action(data=self.request_data)
if integration is None:
- integration = self.get_integration_from_channel_data(data=self.request_data)
+ integration = parsing.get_integration_from_channel_data(data=self.request_data)
if integration is None:
- integration = self.get_integration_for_tenant(data=self.request_data)
+ integration = parsing.get_integration_for_tenant(data=self.request_data)
if integration:
return Integration.objects.filter(id=integration.id).first()
return None
@@ -73,14 +70,14 @@ def get_response(self) -> HttpResponseBase:
)
return self.get_response_from_control_silo()
- if not self.can_infer_integration(data=self.request_data):
+ if not parsing.can_infer_integration(data=self.request_data):
logger.info(
"Could not infer integration, sending to webhook handler",
extra={"request_data": self.request_data},
)
return self.get_response_from_control_silo()
- if self.is_new_integration_installation_event(data=self.request_data):
+ if parsing.is_new_integration_installation_event(data=self.request_data):
logger.info(
"New installation event detected, sending to webhook handler",
extra={"request_data": self.request_data},
diff --git a/src/sentry/migrations/0001_squashed_0484_break_org_member_user_fk.py b/src/sentry/migrations/0001_squashed_0484_break_org_member_user_fk.py
index 21be74b41bd569..76ce0088bbaedf 100644
--- a/src/sentry/migrations/0001_squashed_0484_break_org_member_user_fk.py
+++ b/src/sentry/migrations/0001_squashed_0484_break_org_member_user_fk.py
@@ -31,10 +31,10 @@
import sentry.models.apitoken
import sentry.models.broadcast
import sentry.models.groupshare
-import sentry.models.integrations.sentry_app
-import sentry.models.integrations.sentry_app_installation
import sentry.models.scheduledeletion
-import sentry.models.servicehook
+import sentry.sentry_apps.models.sentry_app
+import sentry.sentry_apps.models.sentry_app_installation
+import sentry.sentry_apps.models.servicehook
import sentry.users.models.authenticator
import sentry.users.models.user
import sentry.utils.security.hash
@@ -1842,7 +1842,7 @@ class Migration(CheckedMigration):
(
"uuid",
models.CharField(
- default=sentry.models.integrations.sentry_app.default_uuid, max_length=64
+ default=sentry.sentry_apps.models.sentry_app.default_uuid, max_length=64
),
),
("redirect_url", models.URLField(null=True)),
@@ -1961,7 +1961,7 @@ class Migration(CheckedMigration):
(
"uuid",
models.CharField(
- default=sentry.models.integrations.sentry_app_installation.default_uuid,
+ default=sentry.sentry_apps.models.sentry_app_installation.default_uuid,
max_length=64,
),
),
@@ -2029,7 +2029,10 @@ class Migration(CheckedMigration):
),
),
("url", models.URLField(max_length=512)),
- ("secret", models.TextField(default=sentry.models.servicehook.generate_secret)),
+ (
+ "secret",
+ models.TextField(default=sentry.sentry_apps.models.servicehook.generate_secret),
+ ),
("events", sentry.db.models.fields.array.ArrayField(null=True)),
(
"status",
diff --git a/src/sentry/migrations/0764_migrate_bad_status_substatus_rows.py b/src/sentry/migrations/0764_migrate_bad_status_substatus_rows.py
new file mode 100644
index 00000000000000..3c9dac93b025c2
--- /dev/null
+++ b/src/sentry/migrations/0764_migrate_bad_status_substatus_rows.py
@@ -0,0 +1,170 @@
+# Generated by Django 5.1.1 on 2024-09-17 21:16
+
+from datetime import timedelta
+
+from django.apps.registry import Apps
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+from django.utils import timezone
+
+from sentry.new_migrations.migrations import CheckedMigration
+from sentry.utils.query import RangeQuerySetWrapperWithProgressBarApprox
+
+
+class ActivityType:
+ SET_IGNORED = 3
+
+
+class GroupHistoryStatus:
+ REGRESSED = 7
+ ARCHIVED_UNTIL_ESCALATING = 15
+ ARCHIVED_FOREVER = 16
+ ARCHIVED_UNTIL_CONDITION_MET = 17
+
+
+class GroupSubStatus:
+ # GroupStatus.IGNORED
+ UNTIL_ESCALATING = 1
+ # Group is ignored/archived for a count/user count/duration
+ UNTIL_CONDITION_MET = 4
+ # Group is ignored/archived forever
+ FOREVER = 5
+
+ # GroupStatus.UNRESOLVED
+ ESCALATING = 2
+ ONGOING = 3
+ REGRESSED = 6
+ NEW = 7
+
+
+class GroupStatus:
+ UNRESOLVED = 0
+ RESOLVED = 1
+ IGNORED = 2
+ PENDING_DELETION = 3
+ DELETION_IN_PROGRESS = 4
+ PENDING_MERGE = 5
+
+ # The group's events are being re-processed and after that the group will
+ # be deleted. In this state no new events shall be added to the group.
+ REPROCESSING = 6
+
+ # TODO(dcramer): remove in 9.0
+ MUTED = IGNORED
+
+
+UNRESOLVED_SUBSTATUS_CHOICES = {
+ GroupSubStatus.ONGOING,
+ GroupSubStatus.ESCALATING,
+ GroupSubStatus.REGRESSED,
+ GroupSubStatus.NEW,
+}
+
+IGNORED_SUBSTATUS_CHOICES = {
+ GroupSubStatus.UNTIL_ESCALATING,
+ GroupSubStatus.FOREVER,
+ GroupSubStatus.UNTIL_CONDITION_MET,
+}
+
+# End copy
+
+ACTIVITY_DATA_FIELDS = {
+ "ignoreCount",
+ "ignoreDuration",
+ "ignoreUntil",
+ "ignoreUserCount",
+ "ignoreUserWindow",
+ "ignoreWindow",
+}
+
+
+def fix_substatus_for_groups(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+ Group = apps.get_model("sentry", "Group")
+ Activity = apps.get_model("sentry", "Activity")
+ GroupSnooze = apps.get_model("sentry", "GroupSnooze")
+ GroupHistory = apps.get_model("sentry", "GroupHistory")
+
+ seven_days_ago = timezone.now() - timedelta(days=7)
+ group_history = GroupHistory.objects.filter(
+ date_added__gt=seven_days_ago, status=GroupHistoryStatus.REGRESSED
+ )
+ activity = Activity.objects.filter(type=ActivityType.SET_IGNORED)
+ for group in RangeQuerySetWrapperWithProgressBarApprox(Group.objects.all()):
+ if (
+ group.status not in [GroupStatus.UNRESOLVED, GroupStatus.IGNORED]
+ and group.substatus is None
+ ):
+ # These groups are correct
+ continue
+
+ new_substatus = None
+
+ if group.status == GroupStatus.IGNORED:
+ if group.substatus in IGNORED_SUBSTATUS_CHOICES:
+ # These groups are correct
+ continue
+
+ group_activity = activity.filter(group_id=group.id).order_by("-datetime").first()
+ if group_activity:
+ # If ignoreUntilEscalating is set, we should set the substatus to UNTIL_ESCALATING
+ if group_activity.data.get("ignoreUntilEscalating", False):
+ new_substatus = GroupSubStatus.UNTIL_ESCALATING
+ # If any other field in the activity data is set, we should set the substatus to UNTIL_CONDITION_MET
+ elif any(group_activity.data.get(field) for field in ACTIVITY_DATA_FIELDS):
+ new_substatus = GroupSubStatus.UNTIL_CONDITION_MET
+
+ # If no activity is found or the activity data is not set, check the group snooze table
+ if not new_substatus:
+ snooze = GroupSnooze.objects.filter(group=group)
+ if snooze.exists():
+ # If snooze exists, we should set the substatus to UNTIL_CONDITION_MET
+ new_substatus = GroupSubStatus.UNTIL_CONDITION_MET
+ else:
+ # If we have no other information stored about the group's status conditions, the group is ignored forever
+ new_substatus = GroupSubStatus.FOREVER
+
+ elif group.status == GroupStatus.UNRESOLVED:
+ if group.substatus in UNRESOLVED_SUBSTATUS_CHOICES:
+ # These groups are correct
+ continue
+
+ if group.first_seen > seven_days_ago:
+ new_substatus = GroupSubStatus.NEW
+ else:
+ histories = group_history.filter(group=group)
+ if histories.exists():
+ new_substatus = GroupSubStatus.REGRESSED
+
+ if new_substatus is None:
+ new_substatus = GroupSubStatus.ONGOING
+
+ group.substatus = new_substatus
+ group.save(update_fields=["substatus"])
+
+
+class Migration(CheckedMigration):
+ # This flag is used to mark that a migration shouldn't be automatically run in production.
+ # This should only be used for operations where it's safe to run the migration after your
+ # code has deployed. So this should not be used for most operations that alter the schema
+ # of a table.
+ # Here are some things that make sense to mark as post deployment:
+ # - Large data migrations. Typically we want these to be run manually so that they can be
+ # monitored and not block the deploy for a long period of time while they run.
+ # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
+ # run this outside deployments so that we don't block them. Note that while adding an index
+ # is a schema change, it's completely safe to run the operation after the code has deployed.
+ # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment
+
+ is_post_deployment = True
+
+ dependencies = [
+ ("sentry", "0763_add_created_by_to_broadcasts"),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ fix_substatus_for_groups,
+ migrations.RunPython.noop,
+ hints={"tables": ["sentry_groupedmessage", "sentry_grouphistory"]},
+ ),
+ ]
diff --git a/src/sentry/models/__init__.py b/src/sentry/models/__init__.py
index 95861aa372fbf4..f300d344c9a829 100644
--- a/src/sentry/models/__init__.py
+++ b/src/sentry/models/__init__.py
@@ -107,7 +107,6 @@
from .scheduledeletion import * # NOQA
from .search_common import * # NOQA
from .sentryshot import * # NOQA
-from .servicehook import * # NOQA
from .sourcemapprocessingissue import * # NOQA
from .statistical_detectors import * # NOQA
from .team import * # NOQA
diff --git a/src/sentry/models/apitoken.py b/src/sentry/models/apitoken.py
index 5cdedb0d1605e6..fffd9ee7eea67a 100644
--- a/src/sentry/models/apitoken.py
+++ b/src/sentry/models/apitoken.py
@@ -351,8 +351,8 @@ def sanitize_relocation_json(
@property
def organization_id(self) -> int | None:
- from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
- from sentry.models.integrations.sentry_app_installation_token import (
+ from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
+ from sentry.sentry_apps.models.sentry_app_installation_token import (
SentryAppInstallationToken,
)
diff --git a/src/sentry/models/authprovider.py b/src/sentry/models/authprovider.py
index 24fa7983de9428..3ba7be6bfa5f22 100644
--- a/src/sentry/models/authprovider.py
+++ b/src/sentry/models/authprovider.py
@@ -122,11 +122,11 @@ def get_scim_token(self):
return get_scim_token(self.flags.scim_enabled, self.organization_id, self.provider)
def enable_scim(self, user):
- from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
- from sentry.models.integrations.sentry_app_installation_for_provider import (
+ from sentry.sentry_apps.logic import SentryAppCreator
+ from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
+ from sentry.sentry_apps.models.sentry_app_installation_for_provider import (
SentryAppInstallationForProvider,
)
- from sentry.sentry_apps.logic import SentryAppCreator
if (
not self.get_provider().can_use_scim(self.organization_id, user)
@@ -186,7 +186,7 @@ def outboxes_for_reset_idp_flags(self) -> list[ControlOutbox]:
def disable_scim(self):
from sentry import deletions
- from sentry.models.integrations.sentry_app_installation_for_provider import (
+ from sentry.sentry_apps.models.sentry_app_installation_for_provider import (
SentryAppInstallationForProvider,
)
diff --git a/src/sentry/models/avatars/sentry_app_avatar.py b/src/sentry/models/avatars/sentry_app_avatar.py
index a6d1e78e55bf31..8d517fd615c171 100644
--- a/src/sentry/models/avatars/sentry_app_avatar.py
+++ b/src/sentry/models/avatars/sentry_app_avatar.py
@@ -13,7 +13,7 @@
from . import ControlAvatarBase
if TYPE_CHECKING:
- from sentry.models.integrations.sentry_app import SentryApp
+ from sentry.sentry_apps.models.sentry_app import SentryApp
class SentryAppAvatarTypes(Enum):
diff --git a/src/sentry/models/integrations/__init__.py b/src/sentry/models/integrations/__init__.py
index 19c56736fce763..c56c940a443d8a 100644
--- a/src/sentry/models/integrations/__init__.py
+++ b/src/sentry/models/integrations/__init__.py
@@ -1,18 +1,6 @@
-__all__ = (
- "SentryApp",
- "SentryAppComponent",
- "SentryAppInstallation",
- "SentryAppInstallationForProvider",
- "SentryAppInstallationToken",
-)
+__all__ = ("SentryApp",)
# REQUIRED for migrations to run.
from sentry.integrations.types import ExternalProviders # NOQA
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_component import SentryAppComponent
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
-from sentry.models.integrations.sentry_app_installation_for_provider import (
- SentryAppInstallationForProvider,
-)
-from sentry.models.integrations.sentry_app_installation_token import SentryAppInstallationToken
+from sentry.sentry_apps.models.sentry_app import SentryApp
diff --git a/src/sentry/models/integrations/sentry_app.py b/src/sentry/models/integrations/sentry_app.py
index 84d04faa63fece..db625c60ee0d34 100644
--- a/src/sentry/models/integrations/sentry_app.py
+++ b/src/sentry/models/integrations/sentry_app.py
@@ -1,263 +1,3 @@
-import hmac
-import itertools
-import uuid
-from hashlib import sha256
-from typing import Any, ClassVar
+from sentry.sentry_apps.models.sentry_app import SentryApp
-from django.db import models, router, transaction
-from django.db.models import QuerySet
-from django.utils import timezone
-from rest_framework.request import Request
-
-from sentry.backup.dependencies import NormalizedModelName, get_model_name
-from sentry.backup.sanitize import SanitizableField, Sanitizer
-from sentry.backup.scopes import RelocationScope
-from sentry.constants import (
- SENTRY_APP_SLUG_MAX_LENGTH,
- SentryAppInstallationStatus,
- SentryAppStatus,
-)
-from sentry.db.models import (
- ArrayField,
- BoundedPositiveIntegerField,
- FlexibleForeignKey,
- Model,
- control_silo_model,
-)
-from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey
-from sentry.db.models.fields.jsonfield import JSONField
-from sentry.db.models.fields.slug import SentrySlugField
-from sentry.db.models.paranoia import ParanoidManager, ParanoidModel
-from sentry.hybridcloud.models.outbox import ControlOutbox, outbox_context
-from sentry.hybridcloud.outbox.category import OutboxCategory, OutboxScope
-from sentry.models.apiscopes import HasApiScopes
-from sentry.types.region import find_all_region_names
-from sentry.utils import metrics
-
-# When a developer selects to receive " Webhooks" it really means
-# listening to a list of specific events. This is a mapping of what those
-# specific events are for each resource.
-EVENT_EXPANSION = {
- "issue": [
- "issue.created",
- "issue.resolved",
- "issue.ignored",
- "issue.assigned",
- "issue.unresolved",
- ],
- "error": ["error.created"],
- "comment": ["comment.created", "comment.updated", "comment.deleted"],
-}
-
-# We present Webhook Subscriptions per-resource (Issue, Project, etc.), not
-# per-event-type (issue.created, project.deleted, etc.). These are valid
-# resources a Sentry App may subscribe to.
-VALID_EVENT_RESOURCES = ("issue", "error", "comment")
-
-REQUIRED_EVENT_PERMISSIONS = {
- "issue": "event:read",
- "error": "event:read",
- "project": "project:read",
- "member": "member:read",
- "organization": "org:read",
- "team": "team:read",
- "comment": "event:read",
-}
-
-# The only events valid for Sentry Apps are the ones listed in the values of
-# EVENT_EXPANSION above. This list is likely a subset of all valid ServiceHook
-# events.
-VALID_EVENTS = tuple(itertools.chain(*EVENT_EXPANSION.values()))
-
-MASKED_VALUE = "*" * 64
-
-UUID_CHARS_IN_SLUG = 6
-
-
-def default_uuid():
- return str(uuid.uuid4())
-
-
-def track_response_code(status, integration_slug, webhook_event):
- metrics.incr(
- "integration-platform.http_response",
- sample_rate=1.0,
- tags={"status": status, "integration": integration_slug, "webhook_event": webhook_event},
- )
-
-
-class SentryAppManager(ParanoidManager["SentryApp"]):
- def get_alertable_sentry_apps(self, organization_id: int) -> QuerySet:
- return self.filter(
- installations__organization_id=organization_id,
- is_alertable=True,
- installations__status=SentryAppInstallationStatus.INSTALLED,
- installations__date_deleted=None,
- ).distinct()
-
- def visible_for_user(self, request: Request) -> QuerySet:
- from sentry.auth.superuser import is_active_superuser
-
- if is_active_superuser(request):
- return self.all()
-
- return self.filter(status=SentryAppStatus.PUBLISHED)
-
-
-@control_silo_model
-class SentryApp(ParanoidModel, HasApiScopes, Model):
- __relocation_scope__ = RelocationScope.Global
-
- application = models.OneToOneField(
- "sentry.ApiApplication", null=True, on_delete=models.SET_NULL, related_name="sentry_app"
- )
-
- # Much of the OAuth system in place currently depends on a User existing.
- # This "proxy user" represents the SentryApp in those cases.
- proxy_user = models.OneToOneField(
- "sentry.User", null=True, on_delete=models.SET_NULL, related_name="sentry_app"
- )
-
- # The Organization the Sentry App was created in "owns" it. Members of that
- # Org have differing access, dependent on their role within the Org.
- owner_id = HybridCloudForeignKey("sentry.Organization", on_delete="CASCADE")
-
- name = models.TextField()
- slug = SentrySlugField(max_length=SENTRY_APP_SLUG_MAX_LENGTH, unique=True, db_index=False)
- author = models.TextField(null=True)
- status = BoundedPositiveIntegerField(
- default=SentryAppStatus.UNPUBLISHED, choices=SentryAppStatus.as_choices(), db_index=True
- )
- uuid = models.CharField(max_length=64, default=default_uuid)
-
- redirect_url = models.URLField(null=True)
- webhook_url = models.URLField(max_length=512, null=True)
- # does the application subscribe to `event.alert`,
- # meaning can it be used in alert rules as a {service} ?
- is_alertable = models.BooleanField(default=False)
-
- # does the application need to wait for verification
- # on behalf of the external service to know if its installations
- # are successfully installed ?
- verify_install = models.BooleanField(default=True)
-
- events = ArrayField(of=models.TextField, null=True)
-
- overview = models.TextField(null=True)
- schema = JSONField(default=dict)
-
- date_added = models.DateTimeField(default=timezone.now)
- date_updated = models.DateTimeField(default=timezone.now)
- date_published = models.DateTimeField(null=True, blank=True)
-
- creator_user = FlexibleForeignKey(
- "sentry.User", null=True, on_delete=models.SET_NULL, db_constraint=False
- )
- creator_label = models.TextField(null=True)
-
- popularity = models.PositiveSmallIntegerField(null=True, default=1)
- metadata = JSONField(default=dict)
-
- objects: ClassVar[SentryAppManager] = SentryAppManager()
-
- class Meta:
- app_label = "sentry"
- db_table = "sentry_sentryapp"
-
- @property
- def is_published(self):
- return self.status == SentryAppStatus.PUBLISHED
-
- @property
- def is_unpublished(self):
- return self.status == SentryAppStatus.UNPUBLISHED
-
- @property
- def is_internal(self):
- return self.status == SentryAppStatus.INTERNAL
-
- @property
- def is_publish_request_inprogress(self):
- return self.status == SentryAppStatus.PUBLISH_REQUEST_INPROGRESS
-
- @property
- def slug_for_metrics(self):
- if self.is_internal:
- return "internal"
- if self.is_unpublished:
- return "unpublished"
- return self.slug
-
- def save(self, *args, **kwargs):
- self.date_updated = timezone.now()
- with outbox_context(transaction.atomic(using=router.db_for_write(SentryApp)), flush=False):
- result = super().save(*args, **kwargs)
- for outbox in self.outboxes_for_update():
- outbox.save()
- return result
-
- def update(self, *args, **kwargs):
- with outbox_context(transaction.atomic(using=router.db_for_write(SentryApp)), flush=False):
- result = super().update(*args, **kwargs)
- for outbox in self.outboxes_for_update():
- outbox.save()
- return result
-
- def is_installed_on(self, organization):
- from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
-
- return SentryAppInstallation.objects.filter(
- organization_id=organization.id,
- sentry_app=self,
- ).exists()
-
- def build_signature(self, body):
- assert self.application is not None
- secret = self.application.client_secret
- return hmac.new(
- key=secret.encode("utf-8"), msg=body.encode("utf-8"), digestmod=sha256
- ).hexdigest()
-
- def show_auth_info(self, access):
- encoded_scopes = set({"%s" % scope for scope in list(access.scopes)})
- return set(self.scope_list).issubset(encoded_scopes)
-
- def outboxes_for_update(self) -> list[ControlOutbox]:
- return [
- ControlOutbox(
- shard_scope=OutboxScope.APP_SCOPE,
- shard_identifier=self.id,
- object_identifier=self.id,
- category=OutboxCategory.SENTRY_APP_UPDATE,
- region_name=region_name,
- )
- for region_name in find_all_region_names()
- ]
-
- def delete(self, *args, **kwargs):
- from sentry.models.avatars.sentry_app_avatar import SentryAppAvatar
-
- with outbox_context(transaction.atomic(using=router.db_for_write(SentryApp))):
- for outbox in self.outboxes_for_update():
- outbox.save()
-
- SentryAppAvatar.objects.filter(sentry_app=self).delete()
- return super().delete(*args, **kwargs)
-
- def _disable(self):
- self.events = []
- self.save(update_fields=["events"])
-
- @classmethod
- def sanitize_relocation_json(
- cls, json: Any, sanitizer: Sanitizer, model_name: NormalizedModelName | None = None
- ) -> None:
- model_name = get_model_name(cls) if model_name is None else model_name
- super().sanitize_relocation_json(json, sanitizer, model_name)
-
- sanitizer.set_string(json, SanitizableField(model_name, "author"))
- sanitizer.set_string(json, SanitizableField(model_name, "creator_label"))
- sanitizer.set_json(json, SanitizableField(model_name, "metadata"), {})
- sanitizer.set_string(json, SanitizableField(model_name, "overview"))
- sanitizer.set_json(json, SanitizableField(model_name, "schema"), {})
- json["fields"]["events"] = "[]"
+__all__ = ("SentryApp",)
diff --git a/src/sentry/models/options/project_option.py b/src/sentry/models/options/project_option.py
index 6c5ebc89105bb2..391679e331f69b 100644
--- a/src/sentry/models/options/project_option.py
+++ b/src/sentry/models/options/project_option.py
@@ -43,6 +43,7 @@
"sentry:replay_rage_click_issues",
"sentry:feedback_user_report_notifications",
"sentry:feedback_ai_spam_detection",
+ "sentry:toolbar_allowed_origins",
"sentry:token",
"sentry:token_header",
"sentry:verify_ssl",
diff --git a/src/sentry/models/project.py b/src/sentry/models/project.py
index 3c0d29641846d4..62a08ad6fcdb68 100644
--- a/src/sentry/models/project.py
+++ b/src/sentry/models/project.py
@@ -99,6 +99,7 @@
"javascript",
"javascript-angular",
"javascript-astro",
+ "javascript-cloudflare",
"javascript-ember",
"javascript-gatsby",
"javascript-nextjs",
diff --git a/src/sentry/models/releases/set_commits.py b/src/sentry/models/releases/set_commits.py
index 57642a78a8bbc1..7ee958fcd9a274 100644
--- a/src/sentry/models/releases/set_commits.py
+++ b/src/sentry/models/releases/set_commits.py
@@ -7,7 +7,6 @@
from django.db import IntegrityError, router
-from sentry import features
from sentry.constants import ObjectStatus
from sentry.db.postgres.transactions import in_test_hide_transaction_boundary
from sentry.locks import locks
@@ -60,18 +59,14 @@ def set_commits(release, commit_list):
# the same release rapidly for different projects.
raise ReleaseCommitError
- if features.has("organizations:set-commits-updated", release.organization):
+ with TimedRetryPolicy(10)(lock.acquire):
create_repositories(commit_list, release)
create_commit_authors(commit_list, release)
- with TimedRetryPolicy(10)(lock.acquire):
with (
atomic_transaction(using=router.db_for_write(type(release))),
in_test_hide_transaction_boundary(),
):
- if not features.has("organizations:set-commits-updated", release.organization):
- create_repositories(commit_list, release)
- create_commit_authors(commit_list, release)
head_commit_by_repo, commit_author_by_commit = set_commits_on_release(
release, commit_list
diff --git a/src/sentry/models/scheduledeletion.py b/src/sentry/models/scheduledeletion.py
index 021d2e65514690..60411b4a4e1dc5 100644
--- a/src/sentry/models/scheduledeletion.py
+++ b/src/sentry/models/scheduledeletion.py
@@ -121,9 +121,13 @@ def get_model(self):
def get_instance(self):
from sentry import deletions
+ from sentry.deletions.base import ModelDeletionTask
model = self.get_model()
- query_manager = getattr(model, deletions.get(model=model, query=None).manager_name)
+ deletion_task = deletions.get(model=model, query=None)
+ query_manager = model.objects
+ if isinstance(deletion_task, ModelDeletionTask):
+ query_manager = getattr(model, deletion_task.manager_name)
return query_manager.get(pk=self.object_id)
def get_actor(self) -> RpcUser | None:
diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py
index f1774b5c795384..0df79019e0ee9a 100644
--- a/src/sentry/options/defaults.py
+++ b/src/sentry/options/defaults.py
@@ -464,13 +464,6 @@
default=[],
flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE,
)
-# Disables video packing for an organization
-register(
- "replay.replay-video.organization-file-packing",
- type=Sequence,
- default=[],
- flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE,
-)
# User Feedback Options
register(
@@ -579,6 +572,11 @@
# VSTS Integration
register("vsts.client-id", flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE)
register("vsts.client-secret", flags=FLAG_CREDENTIAL | FLAG_PRIORITIZE_DISK)
+
+# New VSTS Integration
+register("vsts_new.client-id", flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE)
+register("vsts_new.client-secret", flags=FLAG_CREDENTIAL | FLAG_PRIORITIZE_DISK)
+
# VSTS Integration - with limited scopes
register("vsts-limited.client-id", flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE)
register("vsts-limited.client-secret", flags=FLAG_CREDENTIAL | FLAG_PRIORITIZE_DISK)
@@ -2736,3 +2734,10 @@
default=[],
flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE,
)
+
+register(
+ "releases.no_snuba_for_release_creation",
+ type=Bool,
+ default=False,
+ flags=FLAG_AUTOMATOR_MODIFIABLE,
+)
diff --git a/src/sentry/pipeline/base.py b/src/sentry/pipeline/base.py
index 8bcfbc26944731..e7b2f5e3040c46 100644
--- a/src/sentry/pipeline/base.py
+++ b/src/sentry/pipeline/base.py
@@ -95,7 +95,7 @@ def unpack_state(cls, request: HttpRequest) -> PipelineRequestState | None:
return PipelineRequestState(state, provider_model, organization, provider_key)
- def get_provider(self, provider_key: str) -> PipelineProvider:
+ def get_provider(self, provider_key: str, **kwargs) -> PipelineProvider:
provider: PipelineProvider = self.provider_manager.get(provider_key)
return provider
@@ -118,7 +118,7 @@ def __init__(
)
self.state = self.session_store_cls(request, self.pipeline_name, ttl=PIPELINE_STATE_TTL)
self.provider_model = provider_model
- self.provider = self.get_provider(provider_key)
+ self.provider = self.get_provider(provider_key, organization=organization)
self.config = config or {}
self.provider.set_pipeline(self)
diff --git a/src/sentry/projectoptions/defaults.py b/src/sentry/projectoptions/defaults.py
index 5d0e763c222d36..7dd06c0e393c93 100644
--- a/src/sentry/projectoptions/defaults.py
+++ b/src/sentry/projectoptions/defaults.py
@@ -147,6 +147,11 @@
default=True,
)
+register(
+ key="sentry:toolbar_allowed_origins",
+ default=[],
+)
+
register(
key="sentry:feedback_user_report_notifications",
epoch_defaults={12: True},
diff --git a/src/sentry/receivers/outbox/control.py b/src/sentry/receivers/outbox/control.py
index a6326373de373b..1a5e94d448fdcb 100644
--- a/src/sentry/receivers/outbox/control.py
+++ b/src/sentry/receivers/outbox/control.py
@@ -9,7 +9,6 @@
from __future__ import annotations
import logging
-from collections import defaultdict
from collections.abc import Mapping
from typing import Any
@@ -17,18 +16,15 @@
from sentry.hybridcloud.outbox.category import OutboxCategory
from sentry.hybridcloud.outbox.signals import process_control_outbox
-from sentry.hybridcloud.rpc.caching import region_caching_service
from sentry.integrations.models.integration import Integration
from sentry.issues.services.issue import issue_service
from sentry.models.apiapplication import ApiApplication
from sentry.models.files.utils import get_relocation_storage
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
-from sentry.models.organizationmapping import OrganizationMapping
from sentry.organizations.services.organization import RpcOrganizationSignal, organization_service
from sentry.receivers.outbox import maybe_process_tombstone
from sentry.relocation.services.relocation_export.service import region_relocation_export_service
-from sentry.sentry_apps.services.app.service import get_by_application_id, get_installation
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.tasks.sentry_apps import clear_region_cache
logger = logging.getLogger(__name__)
@@ -54,32 +50,9 @@ def process_sentry_app_updates(object_identifier: int, region_name: str, **kwds:
) is None:
return
- # When a sentry app's definition changes purge cache for all the installations.
- # This could get slow for large applications, but generally big applications don't change often.
- install_query = SentryAppInstallation.objects.filter(sentry_app=sentry_app).values(
- "id", "organization_id"
- )
- # There isn't a constraint on org : sentryapp so we have to handle lists
- install_map: dict[int, list[int]] = defaultdict(list)
- for install_row in install_query:
- install_map[install_row["organization_id"]].append(install_row["id"])
-
- # Clear application_id cache
- region_caching_service.clear_key(
- key=get_by_application_id.key_from(sentry_app.application_id), region_name=region_name
- )
-
- # Limit our operations to the region this outbox is for.
- # This could be a single query if we use raw_sql.
- region_query = OrganizationMapping.objects.filter(
- organization_id__in=list(install_map.keys()), region_name=region_name
- ).values("organization_id")
- for region_row in region_query:
- installs = install_map[region_row["organization_id"]]
- for install_id in installs:
- region_caching_service.clear_key(
- key=get_installation.key_from(install_id), region_name=region_name
- )
+ # Spawn a task to clear caches, as there can be 1000+ installations
+ # for a sentry app.
+ clear_region_cache.delay(sentry_app_id=sentry_app.id, region_name=region_name)
@receiver(process_control_outbox, sender=OutboxCategory.API_APPLICATION_UPDATE)
@@ -167,6 +140,10 @@ def process_relocation_reply_with_export(payload: Mapping[str, Any], **kwds):
except Exception:
raise FileNotFoundError("Could not open SaaS -> SaaS export in proxy relocation bucket.")
+ # TODO(mark) remove this after the stuck outbox is cleared
+ if slug == "test-reloc-ct":
+ return
+
with encrypted_bytes:
region_relocation_export_service.reply_with_export(
relocation_uuid=payload["relocation_uuid"],
diff --git a/src/sentry/replays/consumers/recording_buffered.py b/src/sentry/replays/consumers/recording_buffered.py
index 0d924314b564d2..05021dbe51f73b 100644
--- a/src/sentry/replays/consumers/recording_buffered.py
+++ b/src/sentry/replays/consumers/recording_buffered.py
@@ -55,12 +55,11 @@
from sentry_kafka_schemas.codecs import Codec, ValidationError
from sentry_kafka_schemas.schema_types.ingest_replay_recordings_v1 import ReplayRecording
-from sentry import options
from sentry.conf.types.kafka_definition import Topic, get_topic_codec
+from sentry.models.project import Project
from sentry.replays.lib.storage import (
RecordingSegmentStorageMeta,
make_recording_filename,
- make_video_filename,
storage_kv,
)
from sentry.replays.usecases.ingest import process_headers, track_initial_segment_event
@@ -284,26 +283,15 @@ def process_message(buffer: RecordingBuffer, message: bytes) -> None:
unit="byte",
)
- if decoded_message["org_id"] in options.get(
- "replay.replay-video.organization-file-packing"
- ):
- dat = zlib.compress(pack(rrweb=recording_data, video=cast(bytes, replay_video)))
- buffer.upload_events.append(
- {"key": make_recording_filename(recording_segment), "value": dat}
- )
-
- # Track combined payload size.
- metrics.distribution(
- "replays.recording_consumer.replay_video_event_size", len(dat), unit="byte"
- )
- else:
- buffer.upload_events.append(
- {"key": make_recording_filename(recording_segment), "value": compressed_segment}
- )
- buffer.upload_events.append(
- {"key": make_video_filename(recording_segment), "value": cast(bytes, replay_video)}
- )
+ dat = zlib.compress(pack(rrweb=recording_data, video=cast(bytes, replay_video)))
+ buffer.upload_events.append(
+ {"key": make_recording_filename(recording_segment), "value": dat}
+ )
+ # Track combined payload size.
+ metrics.distribution(
+ "replays.recording_consumer.replay_video_event_size", len(dat), unit="byte"
+ )
else:
buffer.upload_events.append(
{"key": make_recording_filename(recording_segment), "value": compressed_segment}
@@ -331,8 +319,9 @@ def process_message(buffer: RecordingBuffer, message: bytes) -> None:
else None
)
+ project = Project.objects.get_from_cache(id=decoded_message["project_id"])
replay_actions = parse_replay_actions(
- decoded_message["project_id"],
+ project,
decoded_message["replay_id"],
decoded_message["retention_days"],
parsed_recording_data,
diff --git a/src/sentry/replays/scripts/delete_replays.py b/src/sentry/replays/scripts/delete_replays.py
index 773b6d733cefc5..0926480409e2cd 100644
--- a/src/sentry/replays/scripts/delete_replays.py
+++ b/src/sentry/replays/scripts/delete_replays.py
@@ -3,7 +3,7 @@
import contextlib
import logging
from collections.abc import Sequence
-from datetime import datetime
+from datetime import datetime, timezone
from sentry.api.event_search import SearchFilter, parse_search_query
from sentry.models.organization import Organization
@@ -28,6 +28,9 @@ def delete_replays(
search_filters = translate_cli_tags_param_to_snuba_tag_param(tags)
offset = 0
+ start_utc = start_utc.replace(tzinfo=timezone.utc)
+ end_utc = end_utc.replace(tzinfo=timezone.utc)
+
has_more = True
while has_more:
response = query_replays_collection_paginated(
diff --git a/src/sentry/replays/usecases/ingest/__init__.py b/src/sentry/replays/usecases/ingest/__init__.py
index 358e3522901e17..137e832b4df851 100644
--- a/src/sentry/replays/usecases/ingest/__init__.py
+++ b/src/sentry/replays/usecases/ingest/__init__.py
@@ -11,13 +11,11 @@
from sentry_sdk import Scope, set_tag
from sentry_sdk.tracing import Span
-from sentry import options
from sentry.constants import DataCategory
from sentry.models.project import Project
from sentry.replays.lib.storage import (
RecordingSegmentStorageMeta,
make_recording_filename,
- make_video_filename,
storage_kv,
)
from sentry.replays.usecases.ingest.dom_index import log_canvas_size, parse_and_emit_replay_actions
@@ -145,19 +143,13 @@ def _ingest_recording(message: RecordingIngestMessage, transaction: Span) -> Non
unit="byte",
)
- # Packing only if the organization has been opted in. Otherwise the default
- # behavior is observed.
- if message.org_id in options.get("replay.replay-video.organization-file-packing"):
- dat = zlib.compress(pack(rrweb=recording_segment, video=message.replay_video))
- storage_kv.set(make_recording_filename(segment_data), dat)
+ dat = zlib.compress(pack(rrweb=recording_segment, video=message.replay_video))
+ storage_kv.set(make_recording_filename(segment_data), dat)
- # Track combined payload size.
- metrics.distribution(
- "replays.recording_consumer.replay_video_event_size", len(dat), unit="byte"
- )
- else:
- storage_kv.set(make_recording_filename(segment_data), compressed_segment)
- storage_kv.set(make_video_filename(segment_data), message.replay_video)
+ # Track combined payload size.
+ metrics.distribution(
+ "replays.recording_consumer.replay_video_event_size", len(dat), unit="byte"
+ )
else:
storage_kv.set(make_recording_filename(segment_data), compressed_segment)
@@ -270,9 +262,10 @@ def recording_post_processor(
op="replays.usecases.ingest.parse_and_emit_replay_actions",
description="parse_and_emit_replay_actions",
):
+ project = Project.objects.get_from_cache(id=message.project_id)
parse_and_emit_replay_actions(
retention_days=message.retention_days,
- project_id=message.project_id,
+ project=project,
replay_id=message.replay_id,
segment_data=parsed_segment_data,
replay_event=parsed_replay_event,
diff --git a/src/sentry/replays/usecases/ingest/dom_index.py b/src/sentry/replays/usecases/ingest/dom_index.py
index 93c0da0c7793a4..e211891d9c1792 100644
--- a/src/sentry/replays/usecases/ingest/dom_index.py
+++ b/src/sentry/replays/usecases/ingest/dom_index.py
@@ -62,7 +62,7 @@ class ReplayActionsEvent(TypedDict):
def parse_and_emit_replay_actions(
- project_id: int,
+ project: Project,
replay_id: str,
retention_days: int,
segment_data: list[dict[str, Any]],
@@ -70,7 +70,7 @@ def parse_and_emit_replay_actions(
) -> None:
with metrics.timer("replays.usecases.ingest.dom_index.parse_and_emit_replay_actions"):
message = parse_replay_actions(
- project_id, replay_id, retention_days, segment_data, replay_event
+ project, replay_id, retention_days, segment_data, replay_event
)
if message is not None:
emit_replay_actions(message)
@@ -82,19 +82,19 @@ def emit_replay_actions(action: ReplayActionsEvent) -> None:
def parse_replay_actions(
- project_id: int,
+ project: Project,
replay_id: str,
retention_days: int,
segment_data: list[dict[str, Any]],
replay_event: dict[str, Any] | None,
) -> ReplayActionsEvent | None:
"""Parse RRWeb payload to ReplayActionsEvent."""
- actions = get_user_actions(project_id, replay_id, segment_data, replay_event)
+ actions = get_user_actions(project, replay_id, segment_data, replay_event)
if len(actions) == 0:
return None
payload = create_replay_actions_payload(replay_id, actions)
- return create_replay_actions_event(replay_id, project_id, retention_days, payload)
+ return create_replay_actions_event(replay_id, project.id, retention_days, payload)
def create_replay_actions_event(
@@ -153,7 +153,7 @@ def log_canvas_size(
def get_user_actions(
- project_id: int,
+ project: Project,
replay_id: str,
events: list[dict[str, Any]],
replay_event: dict[str, Any] | None,
@@ -177,6 +177,10 @@ def get_user_actions(
"textContent": "Helloworld!"
}
"""
+ # Feature flag and project option queries
+ should_report_rage = _should_report_rage_click_issue(project)
+ should_report_hydration = _should_report_hydration_error_issue(project)
+
result: list[ReplayActionsEventPayloadClick] = []
for event in _iter_custom_events(events):
if len(result) == 20:
@@ -185,7 +189,14 @@ def get_user_actions(
tag = event.get("data", {}).get("tag")
if tag == "breadcrumb":
- click = _handle_breadcrumb(event, project_id, replay_id, replay_event)
+ click = _handle_breadcrumb(
+ event,
+ project,
+ replay_id,
+ replay_event,
+ should_report_rage_click_issue=should_report_rage,
+ should_report_hydration_error_issue=should_report_hydration,
+ )
if click is not None:
result.append(click)
# look for request / response breadcrumbs and report metrics on them
@@ -193,7 +204,7 @@ def get_user_actions(
_handle_resource_metric_event(event)
# log the SDK options sent from the SDK 1/500 times
if tag == "options" and random.randint(0, 499) < 1:
- _handle_options_logging_event(project_id, replay_id, event)
+ _handle_options_logging_event(project.id, replay_id, event)
# log large dom mutation breadcrumb events 1/100 times
payload = event.get("data", {}).get("payload", {})
@@ -203,7 +214,7 @@ def get_user_actions(
and payload.get("category") == "replay.mutations"
and random.randint(0, 500) < 1
):
- _handle_mutations_event(project_id, replay_id, event)
+ _handle_mutations_event(project.id, replay_id, event)
return result
@@ -287,12 +298,10 @@ def _parse_classes(classes: str) -> list[str]:
return list(filter(lambda n: n != "", classes.split(" ")))[:10]
-def _should_report_hydration_error_issue(project_id: int) -> bool:
- project = Project.objects.get(id=project_id)
+def _should_report_hydration_error_issue(project: Project) -> bool:
"""
- The feature is controlled by Sentry admins for release of the feature,
- while the project option is controlled by the project owner, and is a
- permanent setting
+ Checks the feature that's controlled by Sentry admins for release of the feature,
+ and the permanent project option, controlled by the project owner.
"""
return features.has(
"organizations:session-replay-hydration-error-issue-creation",
@@ -300,27 +309,11 @@ def _should_report_hydration_error_issue(project_id: int) -> bool:
) and project.get_option("sentry:replay_hydration_error_issues")
-def _should_report_rage_click_issue(project_id: int) -> bool:
- project = Project.objects.get(id=project_id)
-
- def _project_has_feature_enabled() -> bool:
- """
- Check if the project has the feature flag enabled,
- This is controlled by Sentry admins for release of the feature
- """
- return features.has(
- "organizations:session-replay-rage-click-issue-creation",
- project.organization,
- )
-
- def _project_has_option_enabled() -> bool:
- """
- Check if the project has the option enabled,
- This is controlled by the project owner, and is a permanent setting
- """
- return project.get_option("sentry:replay_rage_click_issues")
-
- return all([_project_has_feature_enabled(), _project_has_option_enabled()])
+def _should_report_rage_click_issue(project: Project) -> bool:
+ """
+ Checks the project option, controlled by a project owner.
+ """
+ return project.get_option("sentry:replay_rage_click_issues")
def _iter_custom_events(events: list[dict[str, Any]]) -> Generator[dict[str, Any]]:
@@ -392,7 +385,12 @@ def _handle_mutations_event(project_id: int, replay_id: str, event: dict[str, An
def _handle_breadcrumb(
- event: dict[str, Any], project_id: int, replay_id: str, replay_event: dict[str, Any] | None
+ event: dict[str, Any],
+ project: Project,
+ replay_id: str,
+ replay_event: dict[str, Any] | None,
+ should_report_rage_click_issue=False,
+ should_report_hydration_error_issue=False,
) -> ReplayActionsEventPayloadClick | None:
click = None
@@ -417,15 +415,15 @@ def _handle_breadcrumb(
payload["data"].get("clickCount", 0) or payload["data"].get("clickcount", 0)
) >= 5
click = create_click_event(
- payload, replay_id, is_dead=True, is_rage=is_rage, project_id=project_id
+ payload, replay_id, is_dead=True, is_rage=is_rage, project_id=project.id
)
if click is not None:
if is_rage:
metrics.incr("replay.rage_click_detected")
- if _should_report_rage_click_issue(project_id):
+ if should_report_rage_click_issue:
if replay_event is not None:
report_rage_click_issue_with_replay_event(
- project_id,
+ project.id,
replay_id,
payload["timestamp"],
payload["message"],
@@ -436,7 +434,7 @@ def _handle_breadcrumb(
)
# Log the event for tracking.
log = event["data"].get("payload", {}).copy()
- log["project_id"] = project_id
+ log["project_id"] = project.id
log["replay_id"] = replay_id
log["dom_tree"] = log.pop("message")
@@ -444,16 +442,16 @@ def _handle_breadcrumb(
elif category == "ui.click":
click = create_click_event(
- payload, replay_id, is_dead=False, is_rage=False, project_id=project_id
+ payload, replay_id, is_dead=False, is_rage=False, project_id=project.id
)
if click is not None:
return click
elif category == "replay.hydrate-error":
metrics.incr("replay.hydration_error_breadcrumb")
- if replay_event is not None and _should_report_hydration_error_issue(project_id):
+ if replay_event is not None and should_report_hydration_error_issue:
report_hydration_error_issue_with_replay_event(
- project_id,
+ project.id,
replay_id,
payload["timestamp"],
payload.get("data", {}).get("url"),
diff --git a/src/sentry/replays/usecases/query/__init__.py b/src/sentry/replays/usecases/query/__init__.py
index 6d86a1a81e3256..5b1bbe2edaf5a5 100644
--- a/src/sentry/replays/usecases/query/__init__.py
+++ b/src/sentry/replays/usecases/query/__init__.py
@@ -41,6 +41,7 @@
from sentry.models.organization import Organization
from sentry.replays.lib.new_query.errors import CouldNotParseValue, OperatorNotSupported
from sentry.replays.lib.new_query.fields import ColumnField, ExpressionField, FieldProtocol
+from sentry.replays.usecases.query.errors import RetryAggregated
from sentry.replays.usecases.query.fields import ComputedField, TagField
from sentry.utils.snuba import RateLimitExceeded, raw_snql_query
@@ -339,9 +340,9 @@ def _query_using_scalar_strategy(
period_start: datetime,
period_stop: datetime,
):
- if not can_scalar_search_subquery(search_filters) or not sort_is_scalar_compatible(
- sort or DEFAULT_SORT_FIELD
- ):
+ can_scalar_search = can_scalar_search_subquery(search_filters, period_start)
+ can_scalar_sort = sort_is_scalar_compatible(sort or DEFAULT_SORT_FIELD)
+ if not can_scalar_search or not can_scalar_sort:
return _query_using_aggregated_strategy(
search_filters,
sort,
@@ -357,8 +358,17 @@ def _query_using_scalar_strategy(
# To fix this issue remove the ability to search against "varying" columns and apply a
# "segment_id = 0" condition to the WHERE clause.
- where = handle_search_filters(scalar_search_config, search_filters)
- orderby = handle_ordering(agg_sort_config, sort or "-" + DEFAULT_SORT_FIELD)
+ try:
+ where = handle_search_filters(scalar_search_config, search_filters)
+ orderby = handle_ordering(agg_sort_config, sort or "-" + DEFAULT_SORT_FIELD)
+ except RetryAggregated:
+ return _query_using_aggregated_strategy(
+ search_filters,
+ sort,
+ project_ids,
+ period_start,
+ period_stop,
+ )
query = Query(
match=Entity("replays"),
diff --git a/src/sentry/replays/usecases/query/conditions/__init__.py b/src/sentry/replays/usecases/query/conditions/__init__.py
index 42168dee31ecbb..c4ca15cd99723b 100644
--- a/src/sentry/replays/usecases/query/conditions/__init__.py
+++ b/src/sentry/replays/usecases/query/conditions/__init__.py
@@ -16,9 +16,9 @@
"SumOfRageClickSelectorComposite",
"SumOfStringArray",
"SumOfStringScalar",
- "SumOfTagScalar",
+ "SumOfTagAggregate",
"SumOfUUIDArray",
- "TagScalar",
+ "TagAggregate",
]
@@ -44,4 +44,4 @@
SumOfDeadClickSelectorComposite,
SumOfRageClickSelectorComposite,
)
-from .tags import SumOfTagScalar, TagScalar
+from .tags import SumOfTagAggregate, TagAggregate
diff --git a/src/sentry/replays/usecases/query/conditions/tags.py b/src/sentry/replays/usecases/query/conditions/tags.py
index 52e504cd96a97f..b2807720fc8da1 100644
--- a/src/sentry/replays/usecases/query/conditions/tags.py
+++ b/src/sentry/replays/usecases/query/conditions/tags.py
@@ -4,11 +4,47 @@
from snuba_sdk.expressions import Expression
from sentry.replays.lib.new_query.conditions import GenericBase
-from sentry.replays.lib.new_query.utils import contains, does_not_contain
+from sentry.replays.lib.new_query.utils import (
+ contains,
+ does_not_contain,
+ translate_condition_to_function,
+)
+from sentry.replays.usecases.query.errors import RetryAggregated
class TagScalar(GenericBase):
- """Tag scalar condition class."""
+ @staticmethod
+ def visit_eq(expression_name: str, value: str) -> Condition:
+ hashed_needle = Function("cityHash64", parameters=[f"{expression_name}={value}"])
+ expression = Function("has", parameters=[Column("_tags_hash_map"), hashed_needle])
+ return Condition(expression, Op.EQ, 1)
+
+ @staticmethod
+ def visit_in(expression_name: str, value: list[str]) -> Condition:
+ expressions = [
+ translate_condition_to_function(TagScalar.visit_eq(expression_name, v)) for v in value
+ ]
+ return Condition(Function("or", parameters=expressions), Op.EQ, 1)
+
+ @staticmethod
+ def visit_neq(expression_name: str, value: str) -> Condition:
+ raise RetryAggregated
+
+ @staticmethod
+ def visit_not_in(expression_name: str, value: list[str]) -> Condition:
+ raise RetryAggregated
+
+ @staticmethod
+ def visit_match(expression_name: str, value: str) -> Condition:
+ raise RetryAggregated
+
+ @staticmethod
+ def visit_not_match(expression_name: str, value: str) -> Condition:
+ raise RetryAggregated
+
+
+class TagAggregate(GenericBase):
+ """Tag aggregate condition class."""
@staticmethod
def visit_eq(expression_name: str, value: str) -> Condition:
@@ -35,30 +71,30 @@ def visit_not_match(expression_name: str, value: str) -> Condition:
return Condition(_match_key_value_wildcard(expression_name, value), Op.EQ, 0)
-class SumOfTagScalar(GenericBase):
+class SumOfTagAggregate(GenericBase):
@staticmethod
def visit_eq(expression: Expression, value: str) -> Condition:
- return contains(TagScalar.visit_eq(expression, value))
+ return contains(TagAggregate.visit_eq(expression, value))
@staticmethod
def visit_neq(expression: Expression, value: str) -> Condition:
- return does_not_contain(TagScalar.visit_eq(expression, value))
+ return does_not_contain(TagAggregate.visit_eq(expression, value))
@staticmethod
def visit_match(expression: Expression, value: str) -> Condition:
- return contains(TagScalar.visit_match(expression, value))
+ return contains(TagAggregate.visit_match(expression, value))
@staticmethod
def visit_not_match(expression: Expression, value: str) -> Condition:
- return does_not_contain(TagScalar.visit_match(expression, value))
+ return does_not_contain(TagAggregate.visit_match(expression, value))
@staticmethod
def visit_in(expression: Expression, value: list[str]) -> Condition:
- return contains(TagScalar.visit_in(expression, value))
+ return contains(TagAggregate.visit_in(expression, value))
@staticmethod
def visit_not_in(expression: Expression, value: list[str]) -> Condition:
- return does_not_contain(TagScalar.visit_in(expression, value))
+ return does_not_contain(TagAggregate.visit_in(expression, value))
def _match_key_value_exact(key: str, value: str) -> Function:
diff --git a/src/sentry/replays/usecases/query/configs/aggregate.py b/src/sentry/replays/usecases/query/configs/aggregate.py
index e22170a072ea16..740a9e333528e0 100644
--- a/src/sentry/replays/usecases/query/configs/aggregate.py
+++ b/src/sentry/replays/usecases/query/configs/aggregate.py
@@ -42,6 +42,7 @@
)
from sentry.replays.usecases.query.conditions.aggregate import SumOfUUIDScalar
from sentry.replays.usecases.query.conditions.event_ids import SumOfErrorIdScalar, SumOfInfoIdScalar
+from sentry.replays.usecases.query.conditions.tags import SumOfTagAggregate
from sentry.replays.usecases.query.fields import ComputedField, TagField
@@ -154,4 +155,4 @@ def array_string_field(column_name: str) -> StringColumnField:
# Field-names which could not be found in the set are tag-keys and will, by default, look for
# the `*` key to find their search instructions. If this is not defined an error is returned.
-search_config["*"] = TagField()
+search_config["*"] = TagField(query=SumOfTagAggregate)
diff --git a/src/sentry/replays/usecases/query/configs/scalar.py b/src/sentry/replays/usecases/query/configs/scalar.py
index f8e2c1c0d6615f..efb3a022589e0c 100644
--- a/src/sentry/replays/usecases/query/configs/scalar.py
+++ b/src/sentry/replays/usecases/query/configs/scalar.py
@@ -1,7 +1,9 @@
"""Scalar query filtering configuration module."""
+
from __future__ import annotations
from collections.abc import Sequence
+from datetime import datetime, timezone
from sentry.api.event_search import ParenExpression, SearchFilter
from sentry.replays.lib.new_query.conditions import (
@@ -20,7 +22,9 @@
RageClickSelectorComposite,
)
from sentry.replays.usecases.query.conditions.event_ids import ErrorIdScalar
-from sentry.replays.usecases.query.fields import ComputedField
+from sentry.replays.usecases.query.conditions.tags import TagScalar
+from sentry.replays.usecases.query.configs.aggregate import search_config as aggregate_search_config
+from sentry.replays.usecases.query.fields import ComputedField, TagField
def string_field(column_name: str) -> StringColumnField:
@@ -71,6 +75,7 @@ def string_field(column_name: str) -> StringColumnField:
varying_search_config["trace"] = varying_search_config["trace_ids"]
varying_search_config["url"] = varying_search_config["urls"]
varying_search_config["user.ip"] = varying_search_config["user.ip_address"]
+varying_search_config["*"] = TagField(query=TagScalar)
# Click Search Config
@@ -98,6 +103,7 @@ def string_field(column_name: str) -> StringColumnField:
def can_scalar_search_subquery(
search_filters: Sequence[ParenExpression | SearchFilter | str],
+ started_at: datetime,
) -> bool:
"""Return "True" if a scalar event search can be performed."""
has_seen_varying_field = False
@@ -109,7 +115,7 @@ def can_scalar_search_subquery(
# ParenExpressions are recursive. So we recursively call our own function and return early
# if any of the fields fail.
elif isinstance(search_filter, ParenExpression):
- is_ok = can_scalar_search_subquery(search_filter.children)
+ is_ok = can_scalar_search_subquery(search_filter.children, started_at)
if not is_ok:
return False
else:
@@ -117,7 +123,18 @@ def can_scalar_search_subquery(
# If the search-filter does not exist in either configuration then return false.
if name not in static_search_config and name not in varying_search_config:
- return False
+ # If the field is not a tag or the query's start period is greater than the
+ # period when the new field was introduced then we can not apply the
+ # optimization.
+ #
+ # TODO(cmanallen): Remove date condition after 90 days (~12/17/2024).
+ if name in aggregate_search_config or started_at < datetime(
+ 2024, 9, 17, tzinfo=timezone.utc
+ ):
+ return False
+ else:
+ has_seen_varying_field = True
+ continue
if name in varying_search_config:
# If a varying field has been seen before then we can't use a row-based sub-query. We
diff --git a/src/sentry/replays/usecases/query/errors.py b/src/sentry/replays/usecases/query/errors.py
new file mode 100644
index 00000000000000..16d2c5121d7e60
--- /dev/null
+++ b/src/sentry/replays/usecases/query/errors.py
@@ -0,0 +1,4 @@
+class RetryAggregated(Exception):
+ """Raised when a query can only be executed by an aggregate."""
+
+ ...
diff --git a/src/sentry/replays/usecases/query/fields.py b/src/sentry/replays/usecases/query/fields.py
index 804079ab99300c..a494e90e0b483d 100644
--- a/src/sentry/replays/usecases/query/fields.py
+++ b/src/sentry/replays/usecases/query/fields.py
@@ -9,8 +9,8 @@
from sentry.api.event_search import SearchFilter
from sentry.replays.lib.new_query.errors import OperatorNotSupported
from sentry.replays.lib.new_query.parsers import parse_str
-from sentry.replays.usecases.query.conditions import SumOfTagScalar
from sentry.replays.usecases.query.conditions.base import ComputedBase
+from sentry.replays.usecases.query.conditions.tags import SumOfTagAggregate, TagScalar
T = TypeVar("T")
@@ -95,9 +95,9 @@ def _apply_scalar(self, operator: str, value: T) -> Condition:
class TagField:
- def __init__(self) -> None:
+ def __init__(self, query: type[SumOfTagAggregate] | type[TagScalar]) -> None:
self.parse = parse_str
- self.query = SumOfTagScalar
+ self.query = query
def apply(self, search_filter: SearchFilter) -> Condition:
"""Apply a search operation against any named expression.
diff --git a/src/sentry/rules/actions/services.py b/src/sentry/rules/actions/services.py
index 66da69b5eece37..a2ddc3743c84a8 100644
--- a/src/sentry/rules/actions/services.py
+++ b/src/sentry/rules/actions/services.py
@@ -45,7 +45,7 @@ def service_type(self) -> str:
return "sentry_app"
def has_alert_rule_action(self) -> bool:
- from sentry.models.integrations.sentry_app_component import SentryAppComponent
+ from sentry.sentry_apps.models.sentry_app_component import SentryAppComponent
exists: bool = SentryAppComponent.objects.filter(
sentry_app_id=self.service.id, type="alert-rule-action"
diff --git a/src/sentry/search/events/builder/base.py b/src/sentry/search/events/builder/base.py
index f370b1a2304b36..956707eabb7f87 100644
--- a/src/sentry/search/events/builder/base.py
+++ b/src/sentry/search/events/builder/base.py
@@ -1282,8 +1282,11 @@ def default_filter_converter(
is_tag = isinstance(lhs, Column) and (
lhs.subscriptable == "tags" or lhs.subscriptable == "sentry_tags"
)
+ is_attr = isinstance(lhs, Column) and (
+ lhs.subscriptable == "attr_str" or lhs.subscriptable == "attr_num"
+ )
is_context = isinstance(lhs, Column) and lhs.subscriptable == "contexts"
- if is_tag:
+ if is_tag or is_attr:
subscriptable = lhs.subscriptable
if operator not in ["IN", "NOT IN"] and not isinstance(value, str):
sentry_sdk.set_tag("query.lhs", lhs)
@@ -1296,7 +1299,7 @@ def default_filter_converter(
# Handle checks for existence
if search_filter.operator in ("=", "!=") and search_filter.value.value == "":
- if is_tag or is_context or name in self.config.non_nullable_keys:
+ if is_tag or is_attr or is_context or name in self.config.non_nullable_keys:
return Condition(lhs, Op(search_filter.operator), value)
else:
# If not a tag, we can just check that the column is null.
@@ -1307,6 +1310,7 @@ def default_filter_converter(
if (
search_filter.operator in ("!=", "NOT IN")
and not search_filter.key.is_tag
+ and not is_attr
and not is_tag
and name not in self.config.non_nullable_keys
):
diff --git a/src/sentry/search/events/fields.py b/src/sentry/search/events/fields.py
index 74768ebeb9f31d..f2e2128b4911ca 100644
--- a/src/sentry/search/events/fields.py
+++ b/src/sentry/search/events/fields.py
@@ -1113,7 +1113,12 @@ def _normalize(self, value: str) -> str:
# this even in child classes where `normalize` have been overridden.
# Shortcutting this for now
# TODO: handle different datasets better here
- if self.spans and value in ["span.duration", "span.self_time"]:
+ if self.spans and value in [
+ "span.duration",
+ "span.self_time",
+ "ai.total_tokens.used",
+ "ai.total_cost",
+ ]:
return value
snuba_column = SEARCH_MAP.get(value)
if not snuba_column and is_measurement(value):
diff --git a/src/sentry/seer/anomaly_detection/__init__.py b/src/sentry/seer/anomaly_detection/__init__.py
new file mode 100644
index 00000000000000..e69de29bb2d1d6
diff --git a/src/sentry/seer/anomaly_detection/delete_rule.py b/src/sentry/seer/anomaly_detection/delete_rule.py
new file mode 100644
index 00000000000000..0be8feab60805d
--- /dev/null
+++ b/src/sentry/seer/anomaly_detection/delete_rule.py
@@ -0,0 +1,86 @@
+import logging
+from typing import TYPE_CHECKING, cast
+
+from django.conf import settings
+from urllib3.exceptions import MaxRetryError, TimeoutError
+
+from sentry.conf.server import SEER_ALERT_DELETION_URL
+from sentry.models.organization import Organization
+from sentry.net.http import connection_from_url
+from sentry.seer.anomaly_detection.types import DeleteAlertDataRequest
+from sentry.seer.signed_seer_api import make_signed_seer_api_request
+from sentry.utils import json
+from sentry.utils.json import JSONDecodeError
+
+logger = logging.getLogger(__name__)
+
+seer_anomaly_detection_connection_pool = connection_from_url(
+ settings.SEER_ANOMALY_DETECTION_URL, timeout=settings.SEER_DEFAULT_TIMEOUT
+)
+
+if TYPE_CHECKING:
+ from sentry.incidents.models.alert_rule import AlertRule
+
+
+def delete_rule_in_seer(alert_rule: "AlertRule") -> bool:
+ """
+ Send a request to delete an alert rule from Seer. Returns True if the request was successful.
+ """
+ body = DeleteAlertDataRequest(
+ organization_id=cast(Organization, alert_rule.organization).id,
+ alert={"id": alert_rule.id},
+ )
+ extra_data = {
+ "rule_id": alert_rule.id,
+ }
+
+ try:
+ response = make_signed_seer_api_request(
+ connection_pool=seer_anomaly_detection_connection_pool,
+ path=SEER_ALERT_DELETION_URL,
+ body=json.dumps(body).encode("utf-8"),
+ )
+ except (TimeoutError, MaxRetryError):
+ logger.warning(
+ "Timeout error when hitting Seer delete rule data endpoint",
+ extra=extra_data,
+ )
+ return False
+
+ if response.status > 400:
+ logger.error(
+ "Error when hitting Seer delete rule data endpoint",
+ extra={
+ "response_data": response.data,
+ **extra_data,
+ },
+ )
+ return False
+
+ try:
+ decoded_data = response.data.decode("utf-8")
+ except AttributeError:
+ logger.exception(
+ "Failed to parse Seer delete rule data response",
+ extra=extra_data,
+ )
+ return False
+
+ try:
+ results = json.loads(decoded_data)
+ except JSONDecodeError:
+ logger.exception(
+ "Failed to parse Seer delete rule data response",
+ extra=extra_data,
+ )
+ return False
+
+ status = results.get("success")
+ if status is None or status is not True:
+ logger.error(
+ "Request to delete alert rule from Seer was unsuccessful",
+ extra=extra_data,
+ )
+ return False
+
+ return True
diff --git a/src/sentry/seer/anomaly_detection/get_historical_anomalies.py b/src/sentry/seer/anomaly_detection/get_historical_anomalies.py
index 5306e26ed21198..2d3fe4882f99b8 100644
--- a/src/sentry/seer/anomaly_detection/get_historical_anomalies.py
+++ b/src/sentry/seer/anomaly_detection/get_historical_anomalies.py
@@ -8,7 +8,11 @@
from sentry.incidents.models.alert_rule import AlertRule, AlertRuleStatus
from sentry.models.project import Project
from sentry.net.http import connection_from_url
-from sentry.seer.anomaly_detection.types import AnomalyDetectionConfig, DetectAnomaliesRequest
+from sentry.seer.anomaly_detection.types import (
+ AnomalyDetectionConfig,
+ DetectAnomaliesRequest,
+ TimeSeriesPoint,
+)
from sentry.seer.anomaly_detection.utils import (
fetch_historical_data,
format_historical_data,
@@ -28,6 +32,31 @@
)
+def get_historical_anomaly_data_from_seer_preview(
+ current_data: list[TimeSeriesPoint],
+ historical_data: list[TimeSeriesPoint],
+ project_id: int,
+ config: AnomalyDetectionConfig,
+) -> list | None:
+ """
+ Send current and historical timeseries data to Seer and return anomaly detection response on the current timeseries.
+
+ Dummy function. TODO: write out the Seer request logic.
+ """
+ return [
+ {
+ "anomaly": {"anomaly_score": -0.38810767243044786, "anomaly_type": "none"},
+ "timestamp": 169,
+ "value": 0.048480431,
+ },
+ {
+ "anomaly": {"anomaly_score": -0.3890542800124323, "anomaly_type": "none"},
+ "timestamp": 170,
+ "value": 0.047910238,
+ },
+ ]
+
+
def get_historical_anomaly_data_from_seer(
alert_rule: AlertRule, project: Project, start_string: str, end_string: str
) -> list | None:
diff --git a/src/sentry/seer/anomaly_detection/types.py b/src/sentry/seer/anomaly_detection/types.py
index e90535c1e64679..8971fb40f03c3d 100644
--- a/src/sentry/seer/anomaly_detection/types.py
+++ b/src/sentry/seer/anomaly_detection/types.py
@@ -45,6 +45,12 @@ class DetectAnomaliesRequest(TypedDict):
context: AlertInSeer | list[TimeSeriesPoint]
+class DeleteAlertDataRequest(TypedDict):
+ organization_id: int
+ project_id: NotRequired[int]
+ alert: AlertInSeer
+
+
class DetectAnomaliesResponse(TypedDict):
success: bool
message: NotRequired[str]
diff --git a/src/sentry/sentry_apps/apps.py b/src/sentry/sentry_apps/apps.py
deleted file mode 100644
index 9926fd95f7ab12..00000000000000
--- a/src/sentry/sentry_apps/apps.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from sentry.sentry_apps.logic import SentryAppUpdater
-
-__all__ = ("SentryAppUpdater",)
diff --git a/src/sentry/sentry_apps/components.py b/src/sentry/sentry_apps/components.py
index 6047954930dd0c..024fda2964ca25 100644
--- a/src/sentry/sentry_apps/components.py
+++ b/src/sentry/sentry_apps/components.py
@@ -9,8 +9,8 @@
from django.utils.http import urlencode
from sentry.mediators.external_requests.select_requester import SelectRequester
-from sentry.models.integrations.sentry_app_component import SentryAppComponent
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_component import SentryAppComponent
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.sentry_apps.services.app.model import RpcSentryAppComponent, RpcSentryAppInstallation
from sentry.sentry_apps.services.app.serial import serialize_sentry_app_installation
from sentry.utils import json
diff --git a/src/sentry/sentry_apps/installations.py b/src/sentry/sentry_apps/installations.py
index 599bb70ecf4759..777f91b6bffe30 100644
--- a/src/sentry/sentry_apps/installations.py
+++ b/src/sentry/sentry_apps/installations.py
@@ -13,9 +13,9 @@
from sentry.models.apiapplication import ApiApplication
from sentry.models.apigrant import ApiGrant
from sentry.models.apitoken import ApiToken
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
-from sentry.models.integrations.sentry_app_installation_token import SentryAppInstallationToken
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation_token import SentryAppInstallationToken
from sentry.sentry_apps.services.hook import hook_service
from sentry.tasks.sentry_apps import installation_webhook
from sentry.users.models.user import User
diff --git a/src/sentry/sentry_apps/logic.py b/src/sentry/sentry_apps/logic.py
index 6adcd4c797869c..d4ef64b14b65d8 100644
--- a/src/sentry/sentry_apps/logic.py
+++ b/src/sentry/sentry_apps/logic.py
@@ -24,19 +24,19 @@
from sentry.models.apiapplication import ApiApplication
from sentry.models.apiscopes import add_scope_hierarchy
from sentry.models.apitoken import ApiToken
-from sentry.models.integrations.sentry_app import (
+from sentry.sentry_apps.installations import (
+ SentryAppInstallationCreator,
+ SentryAppInstallationTokenCreator,
+)
+from sentry.sentry_apps.models.sentry_app import (
EVENT_EXPANSION,
REQUIRED_EVENT_PERMISSIONS,
UUID_CHARS_IN_SLUG,
SentryApp,
default_uuid,
)
-from sentry.models.integrations.sentry_app_component import SentryAppComponent
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
-from sentry.sentry_apps.installations import (
- SentryAppInstallationCreator,
- SentryAppInstallationTokenCreator,
-)
+from sentry.sentry_apps.models.sentry_app_component import SentryAppComponent
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.tasks.sentry_apps import create_or_update_service_hooks_for_sentry_app
from sentry.users.models.user import User
from sentry.users.services.user.model import RpcUser
diff --git a/src/sentry/sentry_apps/models/__init__.py b/src/sentry/sentry_apps/models/__init__.py
new file mode 100644
index 00000000000000..05288934069583
--- /dev/null
+++ b/src/sentry/sentry_apps/models/__init__.py
@@ -0,0 +1,15 @@
+from .sentry_app import SentryApp
+from .sentry_app_component import SentryAppComponent
+from .sentry_app_installation import SentryAppInstallation
+from .sentry_app_installation_for_provider import SentryAppInstallationForProvider
+from .sentry_app_installation_token import SentryAppInstallationToken
+from .servicehook import ServiceHook
+
+__all__ = (
+ "SentryApp",
+ "SentryAppInstallationToken",
+ "SentryAppInstallation",
+ "ServiceHook",
+ "SentryAppInstallationForProvider",
+ "SentryAppComponent",
+)
diff --git a/src/sentry/sentry_apps/models/sentry_app.py b/src/sentry/sentry_apps/models/sentry_app.py
new file mode 100644
index 00000000000000..9b7f2372120443
--- /dev/null
+++ b/src/sentry/sentry_apps/models/sentry_app.py
@@ -0,0 +1,263 @@
+import hmac
+import itertools
+import uuid
+from hashlib import sha256
+from typing import Any, ClassVar
+
+from django.db import models, router, transaction
+from django.db.models import QuerySet
+from django.utils import timezone
+from rest_framework.request import Request
+
+from sentry.backup.dependencies import NormalizedModelName, get_model_name
+from sentry.backup.sanitize import SanitizableField, Sanitizer
+from sentry.backup.scopes import RelocationScope
+from sentry.constants import (
+ SENTRY_APP_SLUG_MAX_LENGTH,
+ SentryAppInstallationStatus,
+ SentryAppStatus,
+)
+from sentry.db.models import (
+ ArrayField,
+ BoundedPositiveIntegerField,
+ FlexibleForeignKey,
+ Model,
+ control_silo_model,
+)
+from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey
+from sentry.db.models.fields.jsonfield import JSONField
+from sentry.db.models.fields.slug import SentrySlugField
+from sentry.db.models.paranoia import ParanoidManager, ParanoidModel
+from sentry.hybridcloud.models.outbox import ControlOutbox, outbox_context
+from sentry.hybridcloud.outbox.category import OutboxCategory, OutboxScope
+from sentry.models.apiscopes import HasApiScopes
+from sentry.types.region import find_all_region_names
+from sentry.utils import metrics
+
+# When a developer selects to receive " Webhooks" it really means
+# listening to a list of specific events. This is a mapping of what those
+# specific events are for each resource.
+EVENT_EXPANSION = {
+ "issue": [
+ "issue.created",
+ "issue.resolved",
+ "issue.ignored",
+ "issue.assigned",
+ "issue.unresolved",
+ ],
+ "error": ["error.created"],
+ "comment": ["comment.created", "comment.updated", "comment.deleted"],
+}
+
+# We present Webhook Subscriptions per-resource (Issue, Project, etc.), not
+# per-event-type (issue.created, project.deleted, etc.). These are valid
+# resources a Sentry App may subscribe to.
+VALID_EVENT_RESOURCES = ("issue", "error", "comment")
+
+REQUIRED_EVENT_PERMISSIONS = {
+ "issue": "event:read",
+ "error": "event:read",
+ "project": "project:read",
+ "member": "member:read",
+ "organization": "org:read",
+ "team": "team:read",
+ "comment": "event:read",
+}
+
+# The only events valid for Sentry Apps are the ones listed in the values of
+# EVENT_EXPANSION above. This list is likely a subset of all valid ServiceHook
+# events.
+VALID_EVENTS = tuple(itertools.chain(*EVENT_EXPANSION.values()))
+
+MASKED_VALUE = "*" * 64
+
+UUID_CHARS_IN_SLUG = 6
+
+
+def default_uuid():
+ return str(uuid.uuid4())
+
+
+def track_response_code(status, integration_slug, webhook_event):
+ metrics.incr(
+ "integration-platform.http_response",
+ sample_rate=1.0,
+ tags={"status": status, "integration": integration_slug, "webhook_event": webhook_event},
+ )
+
+
+class SentryAppManager(ParanoidManager["SentryApp"]):
+ def get_alertable_sentry_apps(self, organization_id: int) -> QuerySet:
+ return self.filter(
+ installations__organization_id=organization_id,
+ is_alertable=True,
+ installations__status=SentryAppInstallationStatus.INSTALLED,
+ installations__date_deleted=None,
+ ).distinct()
+
+ def visible_for_user(self, request: Request) -> QuerySet:
+ from sentry.auth.superuser import is_active_superuser
+
+ if is_active_superuser(request):
+ return self.all()
+
+ return self.filter(status=SentryAppStatus.PUBLISHED)
+
+
+@control_silo_model
+class SentryApp(ParanoidModel, HasApiScopes, Model):
+ __relocation_scope__ = RelocationScope.Global
+
+ application = models.OneToOneField(
+ "sentry.ApiApplication", null=True, on_delete=models.SET_NULL, related_name="sentry_app"
+ )
+
+ # Much of the OAuth system in place currently depends on a User existing.
+ # This "proxy user" represents the SentryApp in those cases.
+ proxy_user = models.OneToOneField(
+ "sentry.User", null=True, on_delete=models.SET_NULL, related_name="sentry_app"
+ )
+
+ # The Organization the Sentry App was created in "owns" it. Members of that
+ # Org have differing access, dependent on their role within the Org.
+ owner_id = HybridCloudForeignKey("sentry.Organization", on_delete="CASCADE")
+
+ name = models.TextField()
+ slug = SentrySlugField(max_length=SENTRY_APP_SLUG_MAX_LENGTH, unique=True, db_index=False)
+ author = models.TextField(null=True)
+ status = BoundedPositiveIntegerField(
+ default=SentryAppStatus.UNPUBLISHED, choices=SentryAppStatus.as_choices(), db_index=True
+ )
+ uuid = models.CharField(max_length=64, default=default_uuid)
+
+ redirect_url = models.URLField(null=True)
+ webhook_url = models.URLField(max_length=512, null=True)
+ # does the application subscribe to `event.alert`,
+ # meaning can it be used in alert rules as a {service} ?
+ is_alertable = models.BooleanField(default=False)
+
+ # does the application need to wait for verification
+ # on behalf of the external service to know if its installations
+ # are successfully installed ?
+ verify_install = models.BooleanField(default=True)
+
+ events = ArrayField(of=models.TextField, null=True)
+
+ overview = models.TextField(null=True)
+ schema = JSONField(default=dict)
+
+ date_added = models.DateTimeField(default=timezone.now)
+ date_updated = models.DateTimeField(default=timezone.now)
+ date_published = models.DateTimeField(null=True, blank=True)
+
+ creator_user = FlexibleForeignKey(
+ "sentry.User", null=True, on_delete=models.SET_NULL, db_constraint=False
+ )
+ creator_label = models.TextField(null=True)
+
+ popularity = models.PositiveSmallIntegerField(null=True, default=1)
+ metadata = JSONField(default=dict)
+
+ objects: ClassVar[SentryAppManager] = SentryAppManager()
+
+ class Meta:
+ app_label = "sentry"
+ db_table = "sentry_sentryapp"
+
+ @property
+ def is_published(self):
+ return self.status == SentryAppStatus.PUBLISHED
+
+ @property
+ def is_unpublished(self):
+ return self.status == SentryAppStatus.UNPUBLISHED
+
+ @property
+ def is_internal(self):
+ return self.status == SentryAppStatus.INTERNAL
+
+ @property
+ def is_publish_request_inprogress(self):
+ return self.status == SentryAppStatus.PUBLISH_REQUEST_INPROGRESS
+
+ @property
+ def slug_for_metrics(self):
+ if self.is_internal:
+ return "internal"
+ if self.is_unpublished:
+ return "unpublished"
+ return self.slug
+
+ def save(self, *args, **kwargs):
+ self.date_updated = timezone.now()
+ with outbox_context(transaction.atomic(using=router.db_for_write(SentryApp)), flush=False):
+ result = super().save(*args, **kwargs)
+ for outbox in self.outboxes_for_update():
+ outbox.save()
+ return result
+
+ def update(self, *args, **kwargs):
+ with outbox_context(transaction.atomic(using=router.db_for_write(SentryApp)), flush=False):
+ result = super().update(*args, **kwargs)
+ for outbox in self.outboxes_for_update():
+ outbox.save()
+ return result
+
+ def is_installed_on(self, organization):
+ from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
+
+ return SentryAppInstallation.objects.filter(
+ organization_id=organization.id,
+ sentry_app=self,
+ ).exists()
+
+ def build_signature(self, body):
+ assert self.application is not None
+ secret = self.application.client_secret
+ return hmac.new(
+ key=secret.encode("utf-8"), msg=body.encode("utf-8"), digestmod=sha256
+ ).hexdigest()
+
+ def show_auth_info(self, access):
+ encoded_scopes = set({"%s" % scope for scope in list(access.scopes)})
+ return set(self.scope_list).issubset(encoded_scopes)
+
+ def outboxes_for_update(self) -> list[ControlOutbox]:
+ return [
+ ControlOutbox(
+ shard_scope=OutboxScope.APP_SCOPE,
+ shard_identifier=self.id,
+ object_identifier=self.id,
+ category=OutboxCategory.SENTRY_APP_UPDATE,
+ region_name=region_name,
+ )
+ for region_name in find_all_region_names()
+ ]
+
+ def delete(self, *args, **kwargs):
+ from sentry.models.avatars.sentry_app_avatar import SentryAppAvatar
+
+ with outbox_context(transaction.atomic(using=router.db_for_write(SentryApp))):
+ for outbox in self.outboxes_for_update():
+ outbox.save()
+
+ SentryAppAvatar.objects.filter(sentry_app=self).delete()
+ return super().delete(*args, **kwargs)
+
+ def _disable(self):
+ self.events = []
+ self.save(update_fields=["events"])
+
+ @classmethod
+ def sanitize_relocation_json(
+ cls, json: Any, sanitizer: Sanitizer, model_name: NormalizedModelName | None = None
+ ) -> None:
+ model_name = get_model_name(cls) if model_name is None else model_name
+ super().sanitize_relocation_json(json, sanitizer, model_name)
+
+ sanitizer.set_string(json, SanitizableField(model_name, "author"))
+ sanitizer.set_string(json, SanitizableField(model_name, "creator_label"))
+ sanitizer.set_json(json, SanitizableField(model_name, "metadata"), {})
+ sanitizer.set_string(json, SanitizableField(model_name, "overview"))
+ sanitizer.set_json(json, SanitizableField(model_name, "schema"), {})
+ json["fields"]["events"] = "[]"
diff --git a/src/sentry/models/integrations/sentry_app_component.py b/src/sentry/sentry_apps/models/sentry_app_component.py
similarity index 100%
rename from src/sentry/models/integrations/sentry_app_component.py
rename to src/sentry/sentry_apps/models/sentry_app_component.py
diff --git a/src/sentry/models/integrations/sentry_app_installation.py b/src/sentry/sentry_apps/models/sentry_app_installation.py
similarity index 78%
rename from src/sentry/models/integrations/sentry_app_installation.py
rename to src/sentry/sentry_apps/models/sentry_app_installation.py
index 845bd30f30c926..55b126bd2e7689 100644
--- a/src/sentry/models/integrations/sentry_app_installation.py
+++ b/src/sentry/sentry_apps/models/sentry_app_installation.py
@@ -2,11 +2,10 @@
import uuid
from collections.abc import Collection, Mapping
-from itertools import chain
from typing import TYPE_CHECKING, Any, ClassVar, overload
from django.db import models
-from django.db.models import OuterRef, QuerySet, Subquery
+from django.db.models import QuerySet
from django.utils import timezone
from sentry.auth.services.auth import AuthenticatedToken
@@ -23,7 +22,7 @@
if TYPE_CHECKING:
from sentry.models.apitoken import ApiToken
- from sentry.models.integrations.sentry_app_component import SentryAppComponent
+ 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
@@ -56,48 +55,6 @@ def get_projects(self, token: ApiToken | AuthenticatedToken) -> QuerySet[Project
return Project.objects.filter(organization_id=token.organization_id)
- def get_related_sentry_app_components(
- self,
- organization_ids: list[int],
- sentry_app_ids: list[int],
- type: str,
- group_by="sentry_app_id",
- ):
- from sentry.models.integrations.sentry_app_component import SentryAppComponent
-
- component_query = SentryAppComponent.objects.filter(
- sentry_app_id=OuterRef("sentry_app_id"), type=type
- )
-
- sentry_app_installations = (
- self.filter(**self.get_organization_filter_kwargs(organization_ids))
- .filter(sentry_app_id__in=sentry_app_ids)
- .annotate(
- # Cannot annotate model object only individual fields. We can convert it into SentryAppComponent instance later.
- sentry_app_component_id=Subquery(component_query.values("id")[:1]),
- sentry_app_component_schema=Subquery(component_query.values("schema")[:1]),
- sentry_app_component_uuid=Subquery(component_query.values("uuid")[:1]),
- )
- .filter(sentry_app_component_id__isnull=False)
- )
-
- # There should only be 1 install of a SentryApp per organization
- grouped_sentry_app_installations = {
- getattr(install, group_by): {
- "sentry_app_installation": install.to_dict(),
- "sentry_app_component": {
- "id": install.sentry_app_component_id,
- "type": type,
- "schema": install.sentry_app_component_schema,
- "uuid": install.sentry_app_component_uuid,
- "sentry_app_id": install.sentry_app_id,
- },
- }
- for install in sentry_app_installations
- }
-
- return grouped_sentry_app_installations
-
@control_silo_model
class SentryAppInstallation(ReplicatedControlModel, ParanoidModel):
@@ -156,21 +113,13 @@ class Meta:
# grant code should be included in the serialization.
is_new = False
- def to_dict(self):
- opts = self._meta
- data = {}
- for field in chain(opts.concrete_fields, opts.private_fields, opts.many_to_many):
- field_name = field.get_attname()
- data[field_name] = self.serializable_value(field_name)
- return data
-
def save(self, *args, **kwargs):
self.date_updated = timezone.now()
return super().save(*args, **kwargs)
@property
def api_application_id(self) -> int | None:
- from sentry.models.integrations.sentry_app import SentryApp
+ from sentry.sentry_apps.models.sentry_app import SentryApp
try:
return self.sentry_app.application_id
@@ -244,7 +193,7 @@ def prepare_sentry_app_components(
project_slug: str | None = None,
values: list[Mapping[str, Any]] | None = None,
) -> SentryAppComponent | None:
- from sentry.models.integrations.sentry_app_component import SentryAppComponent
+ from sentry.sentry_apps.models.sentry_app_component import SentryAppComponent
try:
component = SentryAppComponent.objects.get(
diff --git a/src/sentry/models/integrations/sentry_app_installation_for_provider.py b/src/sentry/sentry_apps/models/sentry_app_installation_for_provider.py
similarity index 100%
rename from src/sentry/models/integrations/sentry_app_installation_for_provider.py
rename to src/sentry/sentry_apps/models/sentry_app_installation_for_provider.py
diff --git a/src/sentry/models/integrations/sentry_app_installation_token.py b/src/sentry/sentry_apps/models/sentry_app_installation_token.py
similarity index 100%
rename from src/sentry/models/integrations/sentry_app_installation_token.py
rename to src/sentry/sentry_apps/models/sentry_app_installation_token.py
diff --git a/src/sentry/models/servicehook.py b/src/sentry/sentry_apps/models/servicehook.py
similarity index 100%
rename from src/sentry/models/servicehook.py
rename to src/sentry/sentry_apps/models/servicehook.py
diff --git a/src/sentry/sentry_apps/services/app/impl.py b/src/sentry/sentry_apps/services/app/impl.py
index 4b5e2d3a97cc69..880be08e9c68c4 100644
--- a/src/sentry/sentry_apps/services/app/impl.py
+++ b/src/sentry/sentry_apps/services/app/impl.py
@@ -11,14 +11,14 @@
from sentry.constants import SentryAppInstallationStatus, SentryAppStatus
from sentry.hybridcloud.rpc.filter_query import FilterQueryDatabaseImpl, OpaqueSerializedResponse
from sentry.mediators import alert_rule_actions
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_component import SentryAppComponent
-from sentry.models.integrations.sentry_app_installation import (
+from sentry.sentry_apps.logic import SentryAppCreator
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_component import SentryAppComponent
+from sentry.sentry_apps.models.sentry_app_installation import (
SentryAppInstallation,
prepare_sentry_app_components,
)
-from sentry.models.integrations.sentry_app_installation_token import SentryAppInstallationToken
-from sentry.sentry_apps.logic import SentryAppCreator
+from sentry.sentry_apps.models.sentry_app_installation_token import SentryAppInstallationToken
from sentry.sentry_apps.services.app import (
AppService,
RpcAlertRuleActionResult,
@@ -307,7 +307,7 @@ def create_internal_integration_for_channel_request(
def prepare_sentry_app_components(
self, *, installation_id: int, component_type: str, project_slug: str | None = None
) -> RpcSentryAppComponent | None:
- from sentry.models.integrations.sentry_app_installation import prepare_sentry_app_components
+ from sentry.sentry_apps.models.sentry_app_installation import prepare_sentry_app_components
installation = SentryAppInstallation.objects.get(id=installation_id)
component = prepare_sentry_app_components(installation, component_type, project_slug)
diff --git a/src/sentry/sentry_apps/services/app/serial.py b/src/sentry/sentry_apps/services/app/serial.py
index d39a4989fc56cb..114fd7ed4899c3 100644
--- a/src/sentry/sentry_apps/services/app/serial.py
+++ b/src/sentry/sentry_apps/services/app/serial.py
@@ -1,9 +1,9 @@
from sentry.constants import SentryAppStatus
from sentry.models.apiapplication import ApiApplication
from sentry.models.apitoken import ApiToken
-from sentry.models.integrations import SentryAppComponent
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_component import SentryAppComponent
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.sentry_apps.services.app import (
RpcApiApplication,
RpcSentryApp,
diff --git a/src/sentry/sentry_apps/services/hook/impl.py b/src/sentry/sentry_apps/services/hook/impl.py
index ae475916e2dde1..8e0da305f0862e 100644
--- a/src/sentry/sentry_apps/services/hook/impl.py
+++ b/src/sentry/sentry_apps/services/hook/impl.py
@@ -3,8 +3,8 @@
from django.db import router, transaction
from sentry import deletions
-from sentry.models.servicehook import ServiceHook
from sentry.sentry_apps.logic import expand_events
+from sentry.sentry_apps.models.servicehook import ServiceHook
from sentry.sentry_apps.services.hook import HookService, RpcServiceHook
from sentry.sentry_apps.services.hook.serial import serialize_service_hook
diff --git a/src/sentry/sentry_apps/services/hook/serial.py b/src/sentry/sentry_apps/services/hook/serial.py
index 6f951809cb4edc..adf272c0c93f4f 100644
--- a/src/sentry/sentry_apps/services/hook/serial.py
+++ b/src/sentry/sentry_apps/services/hook/serial.py
@@ -1,4 +1,4 @@
-from sentry.models.servicehook import ServiceHook
+from sentry.sentry_apps.models.servicehook import ServiceHook
from sentry.sentry_apps.services.hook import RpcServiceHook
diff --git a/src/sentry/tasks/embeddings_grouping/backfill_seer_grouping_records_for_project.py b/src/sentry/tasks/embeddings_grouping/backfill_seer_grouping_records_for_project.py
index ce8e21ddee6e2a..487b00ba86d691 100644
--- a/src/sentry/tasks/embeddings_grouping/backfill_seer_grouping_records_for_project.py
+++ b/src/sentry/tasks/embeddings_grouping/backfill_seer_grouping_records_for_project.py
@@ -5,12 +5,16 @@
from django.conf import settings
from sentry import options
+from sentry.api.exceptions import ResourceDoesNotExist
+from sentry.grouping.api import GroupingConfigNotFound
+from sentry.grouping.enhancer.exceptions import InvalidEnhancerConfig
from sentry.models.project import Project
from sentry.seer.similarity.utils import killswitch_enabled, project_is_seer_eligible
from sentry.silo.base import SiloMode
from sentry.tasks.base import instrumented_task
from sentry.tasks.embeddings_grouping.utils import (
FeatureError,
+ GroupStacktraceData,
create_project_cohort,
delete_seer_grouping_records,
filter_snuba_results,
@@ -23,10 +27,12 @@
send_group_and_stacktrace_to_seer_multithreaded,
update_groups,
)
+from sentry.utils import metrics
BACKFILL_NAME = "backfill_grouping_records"
BULK_DELETE_METADATA_CHUNK_SIZE = 100
SEER_ACCEPTABLE_FAILURE_REASONS = ["Gateway Timeout", "Service Unavailable"]
+EVENT_INFO_EXCEPTIONS = (GroupingConfigNotFound, ResourceDoesNotExist, InvalidEnhancerConfig)
logger = logging.getLogger(__name__)
@@ -211,9 +217,14 @@ def backfill_seer_grouping_records_for_project(
)
return
- nodestore_results, group_hashes_dict = get_events_from_nodestore(
- project, filtered_snuba_results, groups_to_backfill_with_no_embedding_has_snuba_row
- )
+ try:
+ nodestore_results, group_hashes_dict = get_events_from_nodestore(
+ project, filtered_snuba_results, groups_to_backfill_with_no_embedding_has_snuba_row
+ )
+ except EVENT_INFO_EXCEPTIONS:
+ metrics.incr("sentry.tasks.backfill_seer_grouping_records.grouping_config_error")
+ nodestore_results, group_hashes_dict = GroupStacktraceData(data=[], stacktrace_list=[]), {}
+
if not group_hashes_dict:
call_next_backfill(
last_processed_group_id=batch_end_id,
diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py
index 81bfc3917f45f9..a1aca3af81357a 100644
--- a/src/sentry/tasks/post_process.py
+++ b/src/sentry/tasks/post_process.py
@@ -69,7 +69,7 @@ class PostProcessJob(TypedDict, total=False):
def _get_service_hooks(project_id):
- from sentry.models.servicehook import ServiceHook
+ from sentry.sentry_apps.models.servicehook import ServiceHook
cache_key = f"servicehooks:1:{project_id}"
result = cache.get(cache_key)
@@ -83,7 +83,7 @@ def _get_service_hooks(project_id):
def _should_send_error_created_hooks(project):
from sentry.models.organization import Organization
- from sentry.models.servicehook import ServiceHook
+ from sentry.sentry_apps.models.servicehook import ServiceHook
cache_key = f"servicehooks-error-created:1:{project.id}"
result = cache.get(cache_key)
diff --git a/src/sentry/tasks/process_buffer.py b/src/sentry/tasks/process_buffer.py
index e4deedee5b5947..73d69a23a07325 100644
--- a/src/sentry/tasks/process_buffer.py
+++ b/src/sentry/tasks/process_buffer.py
@@ -67,9 +67,10 @@ def process_incr(**kwargs):
def buffer_incr(model, *args, **kwargs):
"""
- Call `buffer.incr` task, resolving the model name first.
+ Call `buffer.incr` as a task on the given model, either directly or via celery depending on
+ `settings.SENTRY_BUFFER_INCR_AS_CELERY_TASK`.
- `model_name` must be in form `app_label.model_name` e.g. `sentry.group`.
+ See `Buffer.incr` for an explanation of the args and kwargs to pass here.
"""
(buffer_incr_task.delay if settings.SENTRY_BUFFER_INCR_AS_CELERY_TASK else buffer_incr_task)(
app_label=model._meta.app_label, model_name=model._meta.model_name, args=args, kwargs=kwargs
@@ -83,6 +84,8 @@ def buffer_incr(model, *args, **kwargs):
def buffer_incr_task(app_label, model_name, args, kwargs):
"""
Call `buffer.incr`, resolving the model first.
+
+ `model_name` must be in form `app_label.model_name` e.g. `sentry.group`.
"""
from sentry import buffer
diff --git a/src/sentry/tasks/repository.py b/src/sentry/tasks/repository.py
index d49de2ff553710..054756150b0de5 100644
--- a/src/sentry/tasks/repository.py
+++ b/src/sentry/tasks/repository.py
@@ -27,10 +27,6 @@ def repository_cascade_delete_on_hide(repo_id: int) -> None:
while has_more:
# get child relations
child_relations = _get_repository_child_relations(repo)
- # extend relations
- child_relations = child_relations + [
- rel(repo) for rel in default_manager.dependencies[Repository]
- ]
# no need to filter relations; delete them
if child_relations:
has_more = _delete_children(manager=default_manager, relations=child_relations)
diff --git a/src/sentry/tasks/sentry_apps.py b/src/sentry/tasks/sentry_apps.py
index 0d2fec7202bb92..6fc7abf4cb6de7 100644
--- a/src/sentry/tasks/sentry_apps.py
+++ b/src/sentry/tasks/sentry_apps.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
+from collections import defaultdict
from collections.abc import Mapping
from typing import Any
@@ -12,14 +13,20 @@
from sentry.api.serializers import AppPlatformEvent, serialize
from sentry.constants import SentryAppInstallationStatus
from sentry.eventstore.models import Event, GroupEvent
+from sentry.hybridcloud.rpc.caching import region_caching_service
from sentry.models.activity import Activity
from sentry.models.group import Group
-from sentry.models.integrations.sentry_app import VALID_EVENTS
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
from sentry.models.organization import Organization
+from sentry.models.organizationmapping import OrganizationMapping
from sentry.models.project import Project
-from sentry.models.servicehook import ServiceHook, ServiceHookProject
-from sentry.sentry_apps.services.app.service import app_service
+from sentry.sentry_apps.models.sentry_app import VALID_EVENTS, SentryApp
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.servicehook import ServiceHook, ServiceHookProject
+from sentry.sentry_apps.services.app.service import (
+ app_service,
+ get_by_application_id,
+ get_installation,
+)
from sentry.shared_integrations.exceptions import ApiHostError, ApiTimeoutError, ClientError
from sentry.silo.base import SiloMode
from sentry.tasks.base import instrumented_task, retry
@@ -247,6 +254,44 @@ def installation_webhook(installation_id, user_id, *args, **kwargs):
InstallationNotifier.run(install=install, user=user, action="created")
+@instrumented_task(
+ name="sentry.sentry_apps.tasks.installations.clear_region_cache", **CONTROL_TASK_OPTIONS
+)
+def clear_region_cache(sentry_app_id: int, region_name: str) -> None:
+ try:
+ sentry_app = SentryApp.objects.get(id=sentry_app_id)
+ except SentryApp.DoesNotExist:
+ return
+
+ # When a sentry app's definition changes purge cache for all the installations.
+ # This could get slow for large applications, but generally big applications don't change often.
+ install_query = SentryAppInstallation.objects.filter(
+ sentry_app=sentry_app,
+ ).values("id", "organization_id")
+
+ # There isn't a constraint on org : sentryapp so we have to handle lists
+ install_map: dict[int, list[int]] = defaultdict(list)
+ for install_row in install_query:
+ install_map[install_row["organization_id"]].append(install_row["id"])
+
+ # Clear application_id cache
+ region_caching_service.clear_key(
+ key=get_by_application_id.key_from(sentry_app.application_id), region_name=region_name
+ )
+
+ # Limit our operations to the region this outbox is for.
+ # This could be a single query if we use raw_sql.
+ region_query = OrganizationMapping.objects.filter(
+ organization_id__in=list(install_map.keys()), region_name=region_name
+ ).values("organization_id")
+ for region_row in region_query:
+ installs = install_map[region_row["organization_id"]]
+ for install_id in installs:
+ region_caching_service.clear_key(
+ key=get_installation.key_from(install_id), region_name=region_name
+ )
+
+
@instrumented_task(name="sentry.tasks.sentry_apps.workflow_notification", **TASK_OPTIONS)
@retry_decorator
def workflow_notification(installation_id, issue_id, type, user_id, *args, **kwargs):
diff --git a/src/sentry/tasks/servicehooks.py b/src/sentry/tasks/servicehooks.py
index e064f57f948fa1..8fb42b5d30b9d2 100644
--- a/src/sentry/tasks/servicehooks.py
+++ b/src/sentry/tasks/servicehooks.py
@@ -2,7 +2,7 @@
from sentry.api.serializers import serialize
from sentry.http import safe_urlopen
-from sentry.models.servicehook import ServiceHook
+from sentry.sentry_apps.models.servicehook import ServiceHook
from sentry.silo.base import SiloMode
from sentry.tasks.base import instrumented_task, retry
from sentry.tsdb.base import TSDBModel
diff --git a/src/sentry/testutils/factories.py b/src/sentry/testutils/factories.py
index 2e53ff5bea081f..cf9b019fcee394 100644
--- a/src/sentry/testutils/factories.py
+++ b/src/sentry/testutils/factories.py
@@ -101,11 +101,6 @@
from sentry.models.grouphistory import GroupHistory
from sentry.models.grouplink import GroupLink
from sentry.models.grouprelease import GroupRelease
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
-from sentry.models.integrations.sentry_app_installation_for_provider import (
- SentryAppInstallationForProvider,
-)
from sentry.models.notificationaction import (
ActionService,
ActionTarget,
@@ -133,7 +128,6 @@
from sentry.models.rule import Rule
from sentry.models.rulesnooze import RuleSnooze
from sentry.models.savedsearch import SavedSearch
-from sentry.models.servicehook import ServiceHook
from sentry.models.team import Team
from sentry.models.userreport import UserReport
from sentry.organizations.services.organization import RpcOrganization, RpcUserOrganizationContext
@@ -142,6 +136,12 @@
SentryAppInstallationTokenCreator,
)
from sentry.sentry_apps.logic import SentryAppCreator
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation_for_provider import (
+ SentryAppInstallationForProvider,
+)
+from sentry.sentry_apps.models.servicehook import ServiceHook
from sentry.sentry_apps.services.app.serial import serialize_sentry_app_installation
from sentry.sentry_apps.services.hook import hook_service
from sentry.signals import project_created
diff --git a/src/sentry/testutils/helpers/backups.py b/src/sentry/testutils/helpers/backups.py
index 10378c9000cf08..7b6f60ec7d0bbc 100644
--- a/src/sentry/testutils/helpers/backups.py
+++ b/src/sentry/testutils/helpers/backups.py
@@ -78,7 +78,6 @@
from sentry.models.groupseen import GroupSeen
from sentry.models.groupshare import GroupShare
from sentry.models.groupsubscription import GroupSubscription
-from sentry.models.integrations.sentry_app import SentryApp
from sentry.models.options.option import ControlOption, Option
from sentry.models.options.organization_option import OrganizationOption
from sentry.models.options.project_template_option import ProjectTemplateOption
@@ -98,6 +97,7 @@
from sentry.monitors.models import Monitor, MonitorType, ScheduleType
from sentry.nodestore.django.models import Node
from sentry.sentry_apps.logic import SentryAppUpdater
+from sentry.sentry_apps.models.sentry_app import SentryApp
from sentry.silo.base import SiloMode
from sentry.silo.safety import unguarded_write
from sentry.testutils.cases import TestCase, TransactionTestCase
diff --git a/src/sentry/testutils/pytest/relay.py b/src/sentry/testutils/pytest/relay.py
index 072869dbf45db0..76dff65c2da813 100644
--- a/src/sentry/testutils/pytest/relay.py
+++ b/src/sentry/testutils/pytest/relay.py
@@ -47,7 +47,7 @@ def _remove_container_if_exists(docker_client, container_name):
pass # could not remove the container nothing to do about it
-@pytest.fixture(scope="session")
+@pytest.fixture(scope="module")
def relay_server_setup(live_server, tmpdir_factory):
prefix = "test_relay_config_{}_".format(
datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S_%f")
diff --git a/src/sentry/uptime/detectors/url_extraction.py b/src/sentry/uptime/detectors/url_extraction.py
index b0e5ba681c0c7b..ec6705c69186f9 100644
--- a/src/sentry/uptime/detectors/url_extraction.py
+++ b/src/sentry/uptime/detectors/url_extraction.py
@@ -6,6 +6,7 @@
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator, validate_ipv46_address
from tldextract import TLDExtract
+from tldextract.tldextract import ExtractResult
if TYPE_CHECKING:
pass
@@ -51,3 +52,9 @@ def extract_base_url(url: str | None) -> str | None:
extracted_url = extractor.extract_urllib(split_url)
fqdn = extracted_url.fqdn
return f"{split_url.scheme}://{fqdn}" if fqdn else None
+
+
+def extract_domain_parts(url: str) -> ExtractResult:
+ # We enable private PSL domains so that hosting services that use
+ # subdomains are treated as suffixes for the purposes of monitoring.
+ return extractor.extract_str(url, include_psl_private_domains=True)
diff --git a/src/sentry/uptime/endpoints/validators.py b/src/sentry/uptime/endpoints/validators.py
index 07470f7f4ed950..16710850e1720b 100644
--- a/src/sentry/uptime/endpoints/validators.py
+++ b/src/sentry/uptime/endpoints/validators.py
@@ -8,13 +8,27 @@
from sentry.api.fields import ActorField
from sentry.api.serializers.rest_framework import CamelSnakeSerializer
from sentry.auth.superuser import is_active_superuser
-from sentry.uptime.models import ProjectUptimeSubscriptionMode
+from sentry.uptime.detectors.url_extraction import extract_domain_parts
+from sentry.uptime.models import ProjectUptimeSubscription, ProjectUptimeSubscriptionMode
from sentry.uptime.subscriptions.subscriptions import (
create_project_uptime_subscription,
create_uptime_subscription,
)
from sentry.utils.audit import create_audit_entry
+MAX_MONITORS_PER_DOMAIN = 100
+"""
+The bounding upper limit on how many ProjectUptimeSubscription's can exist for
+a single domain + suffix.
+
+This takes into accunt subdomains by including them in the count. For example,
+for the domain `sentry.io` both the hosts `subdomain-one.sentry.io` and
+`subdomain-2.sentry.io` will both count towards the limit
+
+Importantly domains like `vercel.dev` are considered TLDs as defined by the
+public suffix list (PSL). See `extract_domain_parts` fo more details
+"""
+
@extend_schema_serializer()
class UptimeMonitorValidator(CamelSnakeSerializer):
@@ -34,6 +48,19 @@ class UptimeMonitorValidator(CamelSnakeSerializer):
)
mode = serializers.IntegerField(required=False)
+ def validate_url(self, url):
+ url_parts = extract_domain_parts(url)
+ existing_count = ProjectUptimeSubscription.objects.filter(
+ uptime_subscription__url_domain=url_parts.domain,
+ uptime_subscription__url_domain_suffix=url_parts.suffix,
+ ).count()
+
+ if existing_count >= MAX_MONITORS_PER_DOMAIN:
+ raise serializers.ValidationError(
+ f"The domain *.{url_parts.domain}.{url_parts.suffix} has already been used in {MAX_MONITORS_PER_DOMAIN} uptime monitoring alerts, which is the limit. You cannot create any additional alerts for this domain."
+ )
+ return url
+
def validate_mode(self, mode):
if not is_active_superuser(self.context["request"]):
raise serializers.ValidationError("Only superusers can modify `mode`")
diff --git a/src/sentry/uptime/rdap/tasks.py b/src/sentry/uptime/rdap/tasks.py
index becdd34452b521..af315f7aa56ebd 100644
--- a/src/sentry/uptime/rdap/tasks.py
+++ b/src/sentry/uptime/rdap/tasks.py
@@ -1,7 +1,12 @@
+import logging
+from urllib.parse import urlparse
+
from sentry.tasks.base import instrumented_task
from sentry.uptime.models import UptimeSubscription
from sentry.uptime.rdap.query import resolve_rdap_network_details
+logger = logging.getLogger(__name__)
+
@instrumented_task(
name="sentry.uptime.rdap.tasks.fetch_subscription_rdap_info",
@@ -21,7 +26,12 @@ def fetch_subscription_rdap_info(subscription_id: int):
# the rdap details.
return
- host = f"{sub.url_domain}.{sub.url_domain_suffix}"
- details = resolve_rdap_network_details(host)
+ parsed_url = urlparse(sub.url)
+
+ if parsed_url.hostname is None:
+ logger.warning("rdap_url_missing_hostname", extra={"url": sub.url})
+ return
+
+ details = resolve_rdap_network_details(parsed_url.hostname)
sub.update(host_provider_id=details["handle"], host_provider_name=details["owner_name"])
diff --git a/src/sentry/uptime/subscriptions/subscriptions.py b/src/sentry/uptime/subscriptions/subscriptions.py
index daaa8b422ac609..110ece7e66ddb4 100644
--- a/src/sentry/uptime/subscriptions/subscriptions.py
+++ b/src/sentry/uptime/subscriptions/subscriptions.py
@@ -3,7 +3,7 @@
from sentry.models.project import Project
from sentry.types.actor import Actor
-from sentry.uptime.detectors.url_extraction import extractor
+from sentry.uptime.detectors.url_extraction import extract_domain_parts
from sentry.uptime.models import (
ProjectUptimeSubscription,
ProjectUptimeSubscriptionMode,
@@ -32,9 +32,7 @@ def create_uptime_subscription(
"""
# We extract the domain and suffix of the url here. This is used to prevent there being too many checks to a single
# domain.
- # We enable private PSL domains so that hosting services that use subdomains are treated as suffixes for the
- # purposes of monitoring.
- result = extractor.extract_str(url, include_psl_private_domains=True)
+ result = extract_domain_parts(url)
subscription, created = UptimeSubscription.objects.get_or_create(
url=url,
interval_seconds=interval_seconds,
diff --git a/src/sentry/users/services/user/model.py b/src/sentry/users/services/user/model.py
index 3b5a7e9664df99..b2c4a23d403264 100644
--- a/src/sentry/users/services/user/model.py
+++ b/src/sentry/users/services/user/model.py
@@ -122,7 +122,7 @@ def class_name(self) -> str:
return "User"
def has_2fa(self) -> bool:
- return len(self.authenticators) > 0
+ return any(a.type != 0 for a in self.authenticators)
class UserCreateResult(RpcModel):
diff --git a/src/sentry/utils/event.py b/src/sentry/utils/event.py
index f950f4d1c706fa..348c8f0089c148 100644
--- a/src/sentry/utils/event.py
+++ b/src/sentry/utils/event.py
@@ -77,6 +77,7 @@ def is_event_from_browser_javascript_sdk(event: dict[str, Any]) -> bool:
return sdk_name.lower() in [
"sentry.javascript.astro",
"sentry.javascript.browser",
+ "sentry.javascript.cloudflare",
"sentry.javascript.react",
"sentry.javascript.gatsby",
"sentry.javascript.ember",
diff --git a/src/sentry/utils/platform_categories.py b/src/sentry/utils/platform_categories.py
index 4ca5ab3e78a6e3..ec8b30aa980b0e 100644
--- a/src/sentry/utils/platform_categories.py
+++ b/src/sentry/utils/platform_categories.py
@@ -72,6 +72,7 @@
"java-logging",
"java-spring-boot",
"java-spring",
+ "javascript-cloudflare",
"kotlin",
"native",
"node",
@@ -118,6 +119,7 @@
SERVERLESS = {
"dotnet-awslambda",
"dotnet-gcpfunctions",
+ "javascript-cloudflare",
"node-awslambda",
"node-azurefunctions",
"node-gcpfunctions",
diff --git a/src/sentry/utils/sentry_apps/request_buffer.py b/src/sentry/utils/sentry_apps/request_buffer.py
index 359c027b5c7680..b412198254d96c 100644
--- a/src/sentry/utils/sentry_apps/request_buffer.py
+++ b/src/sentry/utils/sentry_apps/request_buffer.py
@@ -10,11 +10,11 @@
from redis.client import Pipeline
from requests.models import Response
-from sentry.models.integrations.sentry_app import VALID_EVENTS
+from sentry.sentry_apps.models.sentry_app import VALID_EVENTS
from sentry.utils import json, redis
if TYPE_CHECKING:
- from sentry.models.integrations.sentry_app import SentryApp
+ from sentry.sentry_apps.models.sentry_app import SentryApp
from sentry.sentry_apps.services.app.model import RpcSentryApp
BUFFER_SIZE = 100
diff --git a/src/sentry/utils/sentry_apps/service_hook_manager.py b/src/sentry/utils/sentry_apps/service_hook_manager.py
index 788671650eb8af..58af41a48b1b41 100644
--- a/src/sentry/utils/sentry_apps/service_hook_manager.py
+++ b/src/sentry/utils/sentry_apps/service_hook_manager.py
@@ -1,4 +1,4 @@
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.sentry_apps.services.hook import hook_service
diff --git a/src/sentry/utils/sentry_apps/webhooks.py b/src/sentry/utils/sentry_apps/webhooks.py
index f08f75a63f452c..8e35192a329b44 100644
--- a/src/sentry/utils/sentry_apps/webhooks.py
+++ b/src/sentry/utils/sentry_apps/webhooks.py
@@ -14,8 +14,8 @@
from sentry.integrations.models.utils import get_redis_key
from sentry.integrations.notify_disable import notify_disable
from sentry.integrations.request_buffer import IntegrationRequestBuffer
-from sentry.models.integrations.sentry_app import SentryApp, track_response_code
from sentry.models.organization import Organization
+from sentry.sentry_apps.models.sentry_app import SentryApp, track_response_code
from sentry.shared_integrations.exceptions import ApiHostError, ApiTimeoutError, ClientError
from sentry.utils.audit import create_system_audit_entry
from sentry.utils.sentry_apps import SentryAppWebhookRequestsBuffer
diff --git a/src/sentry/utils/snuba.py b/src/sentry/utils/snuba.py
index de6e169237a9cc..bf70cb598739b1 100644
--- a/src/sentry/utils/snuba.py
+++ b/src/sentry/utils/snuba.py
@@ -188,8 +188,11 @@ def log_snuba_info(content):
# message also maps to span description but gets special handling
# to support wild card searching by default
"message": "name",
- "span.domain": "domain",
- "span.group": "group",
+ # These sample columns are for debugging only and shouldn't be used
+ "sampling_weight": "sampling_weight",
+ "sampling_factor": "sampling_factor",
+ "span.domain": "attr_str[domain]",
+ "span.group": "attr_str[group]",
"span.op": "attr_str[op]",
"span.category": "attr_str[category]",
"span.self_time": "exclusive_time_ms",
@@ -204,6 +207,9 @@ def log_snuba_info(content):
"origin.transaction": "segment_name",
"span.status_code": "attr_str[status_code]",
"replay.id": "attr_str[replay_id]",
+ "span.ai.pipeline.group": "attr_str[ai_pipeline_group]",
+ "ai.total_tokens.used": "attr_num[ai_total_tokens_used]",
+ "ai.total_cost": "attr_num[ai_total_cost]",
}
METRICS_SUMMARIES_COLUMN_MAP = {
diff --git a/src/sentry/utils/tag_normalization.py b/src/sentry/utils/tag_normalization.py
index c7cdb2d965cb35..b68776c1ff5f3e 100644
--- a/src/sentry/utils/tag_normalization.py
+++ b/src/sentry/utils/tag_normalization.py
@@ -20,6 +20,7 @@
"sentry.javascript.browser",
"sentry.javascript.capacitor",
"sentry.javascript.cordova",
+ "sentry.javascript.cloudflare",
"sentry.javascript.deno",
"sentry.javascript.electron",
"sentry.javascript.ember",
diff --git a/src/sentry/web/frontend/debug/debug_sentry_app_notify_disable.py b/src/sentry/web/frontend/debug/debug_sentry_app_notify_disable.py
index b014935f18d1ba..53df7c98ce44f5 100644
--- a/src/sentry/web/frontend/debug/debug_sentry_app_notify_disable.py
+++ b/src/sentry/web/frontend/debug/debug_sentry_app_notify_disable.py
@@ -3,9 +3,9 @@
from sentry.constants import SentryAppStatus
from sentry.integrations.notify_disable import get_provider_type, get_url
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
from sentry.models.organization import Organization
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from .mail import MailPreview
@@ -38,9 +38,11 @@ def get(self, request: HttpRequest) -> HttpResponse:
context={
"integration_name": integration_name,
"integration_link": integration_link,
- "webhook_url": self.sentry_app.webhook_url
- if "sentry-app" in redis_key and self.sentry_app.webhook_url
- else "",
+ "webhook_url": (
+ self.sentry_app.webhook_url
+ if "sentry-app" in redis_key and self.sentry_app.webhook_url
+ else ""
+ ),
"dashboard_link": f"{integration_link}dashboard/",
},
).render(request)
diff --git a/static/app/actionCreators/tags.tsx b/static/app/actionCreators/tags.tsx
index abd5014dbaba73..f840e855d91453 100644
--- a/static/app/actionCreators/tags.tsx
+++ b/static/app/actionCreators/tags.tsx
@@ -10,6 +10,7 @@ import type {PageFilters} from 'sentry/types/core';
import type {Tag, TagValue} from 'sentry/types/group';
import {
type ApiQueryKey,
+ keepPreviousData,
useApiQuery,
type UseApiQueryOptions,
} from 'sentry/utils/queryClient';
@@ -250,7 +251,7 @@ export const useFetchOrganizationTags = (
) => {
return useApiQuery(makeFetchOrganizationTags(params), {
staleTime: Infinity,
- keepPreviousData: params.keepPreviousData,
+ placeholderData: params.keepPreviousData ? keepPreviousData : undefined,
enabled: params.enabled,
...options,
});
diff --git a/static/app/bootstrap/initializeSdk.tsx b/static/app/bootstrap/initializeSdk.tsx
index 22e2125aa7d906..cf7a9e74555816 100644
--- a/static/app/bootstrap/initializeSdk.tsx
+++ b/static/app/bootstrap/initializeSdk.tsx
@@ -1,4 +1,5 @@
-// eslint-disable-next-line simple-import-sort/imports
+/* eslint-disable simple-import-sort/imports */
+// biome-ignore lint/nursery/noRestrictedImports: ignore warning
import {browserHistory, createRoutes, match} from 'react-router';
import * as Sentry from '@sentry/react';
import {_browserPerformanceTimeOriginMode} from '@sentry/utils';
diff --git a/static/app/components/badge/groupPriority.tsx b/static/app/components/badge/groupPriority.tsx
index 99a0cf7505c58f..d2649dafa0434b 100644
--- a/static/app/components/badge/groupPriority.tsx
+++ b/static/app/components/badge/groupPriority.tsx
@@ -1,4 +1,4 @@
-import {Fragment, useMemo, useRef} from 'react';
+import {Fragment, useMemo} from 'react';
import type {Theme} from '@emotion/react';
import styled from '@emotion/styled';
@@ -11,7 +11,6 @@ import {Chevron} from 'sentry/components/chevron';
import type {MenuItemProps} from 'sentry/components/dropdownMenu';
import {DropdownMenu} from 'sentry/components/dropdownMenu';
import {DropdownMenuFooter} from 'sentry/components/dropdownMenu/footer';
-import useFeedbackWidget from 'sentry/components/feedback/widget/useFeedbackWidget';
import HookOrDefault from 'sentry/components/hookOrDefault';
import Placeholder from 'sentry/components/placeholder';
import {Tooltip} from 'sentry/components/tooltip';
@@ -127,29 +126,6 @@ function PriorityChangeActor({
);
}
-function GroupPriorityFeedback() {
- const buttonRef = useRef(null);
- const feedback = useFeedbackWidget({
- buttonRef,
- messagePlaceholder: t('How can we make priority better for you?'),
- });
-
- if (!feedback) {
- return null;
- }
-
- return (
- e.stopPropagation()}
- >
- {t('Give Feedback')}
-
- );
-}
-
const DataConsentLearnMore = HookOrDefault({
hookName: 'component:data-consent-priority-learn-more',
defaultComponent: null,
@@ -179,9 +155,13 @@ function GroupPriorityLearnMore() {
{t('Time to prioritize!')}
- {t(
- 'Use priority to make your issue stream more actionable. Sentry will automatically assign a priority score to new issues and filter low priority issues from the default view.'
- )}
+ {organization.features.includes('issue-stream-custom-views')
+ ? t(
+ 'Use priority to make your issue stream more actionable. Sentry will automatically assign a priority score to new issues.'
+ )
+ : t(
+ 'Use priority to make your issue stream more actionable. Sentry will automatically assign a priority score to new issues and filter low priority issues from the default view.'
+ )}
{t('Set Priority')}
-
}
minMenuWidth={230}
@@ -289,18 +268,6 @@ const MenuTitleContainer = styled('div')`
justify-content: space-between;
`;
-const StyledButton = styled(Button)`
- font-size: ${p => p.theme.fontSizeSmall};
- color: ${p => p.theme.subText};
- font-weight: ${p => p.theme.fontWeightNormal};
- padding: 0;
- border: none;
-
- &:hover {
- color: ${p => p.theme.subText};
- }
-`;
-
const StyledFooter = styled(DropdownMenuFooter)`
max-width: 230px;
${p => p.theme.overflowEllipsis};
diff --git a/static/app/components/devtoolbar/components/infiniteListItems.tsx b/static/app/components/devtoolbar/components/infiniteListItems.tsx
index b5dbd16e42abbf..772c1372c2a9d4 100644
--- a/static/app/components/devtoolbar/components/infiniteListItems.tsx
+++ b/static/app/components/devtoolbar/components/infiniteListItems.tsx
@@ -1,5 +1,5 @@
import {useEffect, useRef} from 'react';
-import type {UseInfiniteQueryResult} from '@tanstack/react-query';
+import type {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query';
import {useVirtualizer} from '@tanstack/react-virtual';
import LoadingIndicator from 'sentry/components/loadingIndicator';
@@ -14,7 +14,7 @@ import type {ApiResult} from '../types';
interface Props {
itemRenderer: ({item}: {item: Data}) => React.ReactNode;
- queryResult: UseInfiniteQueryResult, Error>;
+ queryResult: UseInfiniteQueryResult>, Error>;
emptyMessage?: () => React.ReactNode;
estimateSize?: () => number;
loadingCompleteMessage?: () => React.ReactNode;
diff --git a/static/app/components/devtoolbar/components/infiniteListState.tsx b/static/app/components/devtoolbar/components/infiniteListState.tsx
index fdba714dacdb33..1dd0fa0e94c86e 100644
--- a/static/app/components/devtoolbar/components/infiniteListState.tsx
+++ b/static/app/components/devtoolbar/components/infiniteListState.tsx
@@ -7,7 +7,7 @@ export interface Props {
children: React.ReactNode;
queryResult:
| UseQueryResult, Error>
- | UseInfiniteQueryResult, Error>;
+ | UseInfiniteQueryResult;
backgroundUpdatingMessage?: () => React.ReactNode;
errorMessage?: (props: {error: Error}) => React.ReactNode;
loadingMessage?: () => React.ReactNode;
@@ -21,7 +21,7 @@ export default function InfiniteListState({
queryResult,
}: Props) {
const {status, error, isFetching} = queryResult;
- if (status === 'loading') {
+ if (status === 'pending') {
return loadingMessage();
}
if (status === 'error') {
diff --git a/static/app/components/devtoolbar/components/releases/useReleaseSessions.tsx b/static/app/components/devtoolbar/components/releases/useReleaseSessions.tsx
index 6d480e52d5f1ea..c96feb2a12061a 100644
--- a/static/app/components/devtoolbar/components/releases/useReleaseSessions.tsx
+++ b/static/app/components/devtoolbar/components/releases/useReleaseSessions.tsx
@@ -29,6 +29,6 @@ export default function useReleaseSessions({
],
[organizationSlug, projectId, releaseVersion]
),
- cacheTime: 5000,
+ gcTime: 5000,
});
}
diff --git a/static/app/components/devtoolbar/components/releases/useSessionStatus.tsx b/static/app/components/devtoolbar/components/releases/useSessionStatus.tsx
index 7ab262bf8d28f2..0cf6415f16e401 100644
--- a/static/app/components/devtoolbar/components/releases/useSessionStatus.tsx
+++ b/static/app/components/devtoolbar/components/releases/useSessionStatus.tsx
@@ -25,6 +25,6 @@ export default function useSessionStatus() {
],
[organizationSlug, projectId]
),
- cacheTime: 5000,
+ gcTime: 5000,
});
}
diff --git a/static/app/components/devtoolbar/components/releases/useToolbarRelease.tsx b/static/app/components/devtoolbar/components/releases/useToolbarRelease.tsx
index 1ddde01374b30d..06bda2bc668024 100644
--- a/static/app/components/devtoolbar/components/releases/useToolbarRelease.tsx
+++ b/static/app/components/devtoolbar/components/releases/useToolbarRelease.tsx
@@ -23,6 +23,6 @@ export default function useToolbarRelease() {
],
[organizationSlug, projectSlug]
),
- cacheTime: 5000,
+ gcTime: 5000,
});
}
diff --git a/static/app/components/devtoolbar/components/teams/useTeams.tsx b/static/app/components/devtoolbar/components/teams/useTeams.tsx
index c06a8d61706ae3..e7b6286f1b4075 100644
--- a/static/app/components/devtoolbar/components/teams/useTeams.tsx
+++ b/static/app/components/devtoolbar/components/teams/useTeams.tsx
@@ -26,7 +26,7 @@ export default function useTeams({idOrSlug}: Props, opts?: {enabled: boolean}) {
],
[idOrSlug, organizationSlug]
),
- cacheTime: Infinity,
+ gcTime: Infinity,
enabled: opts?.enabled ?? true,
});
}
diff --git a/static/app/components/devtoolbar/hooks/useApiEndpoint.tsx b/static/app/components/devtoolbar/hooks/useApiEndpoint.tsx
index 33a53bbe82b9a1..529039f765dd29 100644
--- a/static/app/components/devtoolbar/hooks/useApiEndpoint.tsx
+++ b/static/app/components/devtoolbar/hooks/useApiEndpoint.tsx
@@ -1,4 +1,5 @@
import {useMemo} from 'react';
+import type {QueryFunctionContext} from '@tanstack/react-query';
import {stringifyUrl} from 'query-string';
import parseLinkHeader, {type ParsedHeader} from 'sentry/utils/parseLinkHeader';
@@ -14,16 +15,14 @@ function parsePageParam(dir: 'previous' | 'next') {
};
}
-const getNextPageParam = parsePageParam('next');
-const getPreviousPageParam = parsePageParam('previous');
+const getNextPageParam: any = parsePageParam('next');
+const getPreviousPageParam: any = parsePageParam('previous');
interface FetchParams {
queryKey: ApiEndpointQueryKey;
}
-interface InfiniteFetchParams extends FetchParams {
- pageParam: ParsedHeader;
-}
+export type PageParam = ParsedHeader | undefined;
export default function useApiEndpoint() {
const {apiPrefix} = useConfiguration();
@@ -59,7 +58,9 @@ export default function useApiEndpoint() {
({
queryKey: [ns, endpoint, options],
pageParam,
- }: InfiniteFetchParams): Promise> => {
+ }: QueryFunctionContext): Promise<
+ ApiResult
+ > => {
const query = {
...options?.query,
cursor: pageParam?.cursor,
diff --git a/static/app/components/devtoolbar/hooks/useFetchApiData.tsx b/static/app/components/devtoolbar/hooks/useFetchApiData.tsx
index a8d175cc2b1232..819be7ada79179 100644
--- a/static/app/components/devtoolbar/hooks/useFetchApiData.tsx
+++ b/static/app/components/devtoolbar/hooks/useFetchApiData.tsx
@@ -1,21 +1,10 @@
-import type {UseQueryOptions, UseQueryResult} from '@tanstack/react-query';
+import type {UseQueryOptions} from '@tanstack/react-query';
import {useQuery} from '@tanstack/react-query';
import type {ApiEndpointQueryKey, ApiResult} from '../types';
import useApiEndpoint from './useApiEndpoint';
-/**
- * isLoading is renamed to isPending in v5, this backports the type to v4
- *
- * TODO(react-query): Remove this when we upgrade to react-query v5
- *
- * @link https://tanstack.com/query/v5/docs/framework/react/guides/migrating-to-v5
- */
-type BackportIsPending = T extends {isLoading: boolean}
- ? T & {isPending: T['isLoading']}
- : T;
-
export default function useFetchApiData<
QueryFnData,
SelectFnData = ApiResult,
@@ -24,15 +13,10 @@ export default function useFetchApiData<
) {
const {fetchFn} = useApiEndpoint();
- const infiniteQueryResult = useQuery({
+ const infiniteQueryResult = useQuery({
queryFn: fetchFn,
...props,
});
- // TODO(react-query): Remove this when we upgrade to react-query v5
- // @ts-expect-error: This is a backport of react-query v5
- infiniteQueryResult.isPending = infiniteQueryResult.isLoading;
-
- // TODO(react-query): Remove casting when we upgrade to react-query v5
- return infiniteQueryResult as BackportIsPending>;
+ return infiniteQueryResult;
}
diff --git a/static/app/components/devtoolbar/hooks/useFetchInfiniteApiData.tsx b/static/app/components/devtoolbar/hooks/useFetchInfiniteApiData.tsx
index e09b730c2408a7..bb95d5496c207e 100644
--- a/static/app/components/devtoolbar/hooks/useFetchInfiniteApiData.tsx
+++ b/static/app/components/devtoolbar/hooks/useFetchInfiniteApiData.tsx
@@ -1,24 +1,40 @@
-import type {UseInfiniteQueryOptions} from '@tanstack/react-query';
-import {useInfiniteQuery} from '@tanstack/react-query';
+import {
+ type InfiniteData,
+ useInfiniteQuery,
+ type UseInfiniteQueryOptions,
+} from '@tanstack/react-query';
import type {ApiEndpointQueryKey, ApiResult} from '../types';
-import useApiEndpoint from './useApiEndpoint';
+import useApiEndpoint, {type PageParam} from './useApiEndpoint';
-export default function useFetchInfiniteApiData>(
- props: UseInfiniteQueryOptions, any>
+export default function useFetchInfiniteApiData(
+ props: Omit<
+ UseInfiniteQueryOptions<
+ ApiResult,
+ Error,
+ InfiniteData>,
+ // TQueryData Not sure what this one should be
+ any,
+ ApiEndpointQueryKey,
+ PageParam
+ >,
+ 'getNextPageParam' | 'getPreviousPageParam' | 'queryFn' | 'initialPageParam' | 'query'
+ >
) {
const {fetchInfiniteFn, getNextPageParam, getPreviousPageParam} = useApiEndpoint();
const infiniteQueryResult = useInfiniteQuery<
- ApiEndpointQueryKey,
- Error,
ApiResult,
- any
+ Error,
+ InfiniteData>,
+ ApiEndpointQueryKey,
+ PageParam
>({
queryFn: fetchInfiniteFn,
getNextPageParam,
getPreviousPageParam,
+ initialPageParam: undefined,
...props,
});
diff --git a/static/app/components/discover/transactionsList.spec.tsx b/static/app/components/discover/transactionsList.spec.tsx
index 20557a9e646673..0387dec731a01d 100644
--- a/static/app/components/discover/transactionsList.spec.tsx
+++ b/static/app/components/discover/transactionsList.spec.tsx
@@ -3,7 +3,6 @@ import type {RenderResult} from 'sentry-test/reactTestingLibrary';
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
import TransactionsList from 'sentry/components/discover/transactionsList';
-import {t} from 'sentry/locale';
import EventView from 'sentry/utils/discover/eventView';
import {MEPSettingProvider} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
import {OrganizationContext} from 'sentry/views/organizationContext';
@@ -60,12 +59,12 @@ describe('TransactionsList', function () {
{
sort: {kind: 'asc', field: 'transaction'},
value: 'name',
- label: t('Transactions'),
+ label: 'Transactions',
},
{
sort: {kind: 'desc', field: 'count'},
value: 'count',
- label: t('Failing Transactions'),
+ label: 'Failing Transactions',
},
];
generateLink = {
@@ -185,7 +184,7 @@ describe('TransactionsList', function () {
options.push({
sort: {kind: 'desc', field: 'trend_percentage()'},
value: 'regression',
- label: t('Trending Regressions'),
+ label: 'Trending Regressions',
trendType: 'regression',
});
render(
diff --git a/static/app/components/draggableTabs/draggableTabList.tsx b/static/app/components/draggableTabs/draggableTabList.tsx
index 7ee03e4a427b9c..d0ad8f798652b2 100644
--- a/static/app/components/draggableTabs/draggableTabList.tsx
+++ b/static/app/components/draggableTabs/draggableTabList.tsx
@@ -28,9 +28,11 @@ import {IconAdd, IconEllipsis} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import {defined} from 'sentry/utils';
+import {trackAnalytics} from 'sentry/utils/analytics';
import {browserHistory} from 'sentry/utils/browserHistory';
import {useDimensions} from 'sentry/utils/useDimensions';
import {useDimensionsMultiple} from 'sentry/utils/useDimensionsMultiple';
+import useOrganization from 'sentry/utils/useOrganization';
import type {DraggableTabListItemProps} from './item';
import {Item} from './item';
@@ -262,6 +264,7 @@ function BaseDraggableTabList({
}: BaseDraggableTabListProps) {
const [hoveringKey, setHoveringKey] = useState(null);
const {rootProps, setTabListState} = useContext(TabsContext);
+ const organization = useOrganization();
const {
value,
defaultValue,
@@ -284,6 +287,11 @@ function BaseDraggableTabList({
if (!linkTo) {
return;
}
+
+ trackAnalytics('issue_views.switched_views', {
+ organization,
+ });
+
browserHistory.push(linkTo);
},
isDisabled: disabled,
@@ -329,7 +337,13 @@ function BaseDraggableTabList({
onHoverStart={() => setHoveringKey('addView')}
onHoverEnd={() => setHoveringKey(null)}
>
-
+
{t('Add View')}
diff --git a/static/app/components/events/attachmentUrl.tsx b/static/app/components/events/attachmentUrl.tsx
deleted file mode 100644
index b4bd44c5dca26f..00000000000000
--- a/static/app/components/events/attachmentUrl.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import {memo} from 'react';
-
-import {Role} from 'sentry/components/acl/role';
-import type {IssueAttachment} from 'sentry/types/group';
-import type {Organization} from 'sentry/types/organization';
-import withOrganization from 'sentry/utils/withOrganization';
-
-type Props = {
- attachment: IssueAttachment;
- children: (downloadUrl: string | null) => React.ReactElement | null;
- eventId: string;
- organization: Organization;
- projectSlug: string;
-};
-
-function AttachmentUrl({
- attachment,
- organization,
- eventId,
- projectSlug,
- children,
-}: Props) {
- function getDownloadUrl() {
- return `/api/0/projects/${organization.slug}/${projectSlug}/events/${eventId}/attachments/${attachment.id}/`;
- }
-
- return (
-
- {({hasRole}) => children(hasRole ? getDownloadUrl() : null)}
-
- );
-}
-
-export default withOrganization(memo(AttachmentUrl));
diff --git a/static/app/components/events/attachmentViewers/previewAttachmentTypes.tsx b/static/app/components/events/attachmentViewers/previewAttachmentTypes.tsx
new file mode 100644
index 00000000000000..b271a675116aed
--- /dev/null
+++ b/static/app/components/events/attachmentViewers/previewAttachmentTypes.tsx
@@ -0,0 +1,36 @@
+import ImageViewer from 'sentry/components/events/attachmentViewers/imageViewer';
+import JsonViewer from 'sentry/components/events/attachmentViewers/jsonViewer';
+import LogFileViewer from 'sentry/components/events/attachmentViewers/logFileViewer';
+import RRWebJsonViewer from 'sentry/components/events/attachmentViewers/rrwebJsonViewer';
+import type {IssueAttachment} from 'sentry/types/group';
+
+export const getInlineAttachmentRenderer = (
+ attachment: IssueAttachment
+): typeof ImageViewer | typeof LogFileViewer | typeof RRWebJsonViewer | undefined => {
+ switch (attachment.mimetype) {
+ case 'text/css':
+ case 'text/csv':
+ case 'text/html':
+ case 'text/javascript':
+ case 'text/plain':
+ return attachment.size > 0 ? LogFileViewer : undefined;
+ case 'application/json':
+ case 'application/ld+json':
+ case 'text/json':
+ case 'text/x-json':
+ if (attachment.name === 'rrweb.json' || attachment.name.startsWith('rrweb-')) {
+ return RRWebJsonViewer;
+ }
+ return JsonViewer;
+ case 'image/jpeg':
+ case 'image/png':
+ case 'image/gif':
+ return ImageViewer;
+ default:
+ return undefined;
+ }
+};
+
+export const hasInlineAttachmentRenderer = (attachment: IssueAttachment): boolean => {
+ return !!getInlineAttachmentRenderer(attachment);
+};
diff --git a/static/app/components/events/autofix/autofixBanner.spec.tsx b/static/app/components/events/autofix/autofixBanner.spec.tsx
index 27e93af4f8545f..af955cef27c17b 100644
--- a/static/app/components/events/autofix/autofixBanner.spec.tsx
+++ b/static/app/components/events/autofix/autofixBanner.spec.tsx
@@ -1,83 +1,96 @@
-import {
- render,
- renderGlobalModal,
- screen,
- userEvent,
-} from 'sentry-test/reactTestingLibrary';
+import {EventFixture} from 'sentry-fixture/event';
+import {GroupFixture} from 'sentry-fixture/group';
+import {ProjectFixture} from 'sentry-fixture/project';
-import {AutofixBanner} from './autofixBanner';
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
-function mockIsSentryEmployee(isEmployee: boolean) {
- jest
- .spyOn(require('sentry/utils/useIsSentryEmployee'), 'useIsSentryEmployee')
- .mockImplementation(() => isEmployee);
-}
+import {openModal} from 'sentry/actionCreators/modal';
+import {AutofixBanner} from 'sentry/components/events/autofix/autofixBanner';
+import {useIsSentryEmployee} from 'sentry/utils/useIsSentryEmployee';
+
+jest.mock('sentry/utils/useIsSentryEmployee');
+jest.mock('sentry/actionCreators/modal');
+
+const mockGroup = GroupFixture();
+const mockProject = ProjectFixture();
+const mockEvent = EventFixture();
describe('AutofixBanner', () => {
- afterEach(() => {
- jest.resetAllMocks();
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useIsSentryEmployee as jest.Mock).mockReturnValue(false);
});
- const defaultProps = {
- groupId: '1',
- hasSuccessfulSetup: true,
- triggerAutofix: jest.fn(),
- };
-
- it('shows PII check for sentry employee users', () => {
- mockIsSentryEmployee(true);
-
- render( );
- expect(
- screen.getByText(
- 'By clicking the button above, you confirm that there is no PII in this event.'
- )
- ).toBeInTheDocument();
+ it('renders the banner with "Set up Autofix" button when setup is not done', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Try Autofix')).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: /Set up Autofix/i})).toBeInTheDocument();
});
- it('does not show PII check for non sentry employee users', () => {
- mockIsSentryEmployee(false);
+ it('renders the banner with "Open Autofix" button when setup is done', () => {
+ render(
+
+ );
- render( );
- expect(
- screen.queryByText(
- 'By clicking the button above, you confirm that there is no PII in this event.'
- )
- ).not.toBeInTheDocument();
+ expect(screen.getByText(/Try Autofix/i)).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: /Open Autofix/i})).toBeInTheDocument();
});
- it('can run without instructions', async () => {
- const mockTriggerAutofix = jest.fn();
+ it('opens the AutofixSetupModal when "Set up Autofix" is clicked', async () => {
+ const mockOpenModal = jest.fn();
+ (openModal as jest.Mock).mockImplementation(mockOpenModal);
render(
);
- renderGlobalModal();
- await userEvent.click(screen.getByRole('button', {name: 'Get root causes'}));
- expect(mockTriggerAutofix).toHaveBeenCalledWith('');
+ await userEvent.click(screen.getByRole('button', {name: /Set up Autofix/i}));
+ expect(openModal).toHaveBeenCalled();
});
- it('can provide instructions', async () => {
- const mockTriggerAutofix = jest.fn();
-
+ it('does not render PII message for non-Sentry employees', () => {
render(
);
- renderGlobalModal();
- await userEvent.click(screen.getByRole('button', {name: 'Provide context first'}));
- await userEvent.type(screen.getByRole('textbox'), 'instruction!');
- await userEvent.click(screen.getByRole('button', {name: "Let's go!"}));
+ expect(screen.queryByText(/By clicking the button above/i)).not.toBeInTheDocument();
+ });
+
+ it('renders PII message for Sentry employees when setup is successful', () => {
+ (useIsSentryEmployee as jest.Mock).mockReturnValue(true);
+
+ render(
+
+ );
- expect(mockTriggerAutofix).toHaveBeenCalledWith('instruction!');
+ expect(screen.getByText(/By clicking the button above/i)).toBeInTheDocument();
});
});
diff --git a/static/app/components/events/autofix/autofixBanner.tsx b/static/app/components/events/autofix/autofixBanner.tsx
index f89d5ceb0f113a..efb45193fbc742 100644
--- a/static/app/components/events/autofix/autofixBanner.tsx
+++ b/static/app/components/events/autofix/autofixBanner.tsx
@@ -1,4 +1,4 @@
-import {Fragment} from 'react';
+import {useRef} from 'react';
import styled from '@emotion/styled';
import bannerImage from 'sentry-images/spot/ai-suggestion-banner.svg';
@@ -6,77 +6,73 @@ import bannerImage from 'sentry-images/spot/ai-suggestion-banner.svg';
import {openModal} from 'sentry/actionCreators/modal';
import FeatureBadge from 'sentry/components/badge/featureBadge';
import {Button} from 'sentry/components/button';
-import {AutofixInstructionsModal} from 'sentry/components/events/autofix/autofixInstructionsModal';
+import {AutofixDrawer} from 'sentry/components/events/autofix/autofixDrawer';
import {AutofixSetupModal} from 'sentry/components/events/autofix/autofixSetupModal';
+import useDrawer from 'sentry/components/globalDrawer';
import Panel from 'sentry/components/panels/panel';
import PanelBody from 'sentry/components/panels/panelBody';
import {t, tct} from 'sentry/locale';
import {space} from 'sentry/styles/space';
+import type {Event} from 'sentry/types/event';
+import type {Group} from 'sentry/types/group';
+import type {Project} from 'sentry/types/project';
import {useIsSentryEmployee} from 'sentry/utils/useIsSentryEmployee';
type Props = {
- groupId: string;
+ event: Event;
+ group: Group;
hasSuccessfulSetup: boolean;
- projectId: string;
- triggerAutofix: (value: string) => void;
+ project: Project;
};
function SuccessfulSetup({
- groupId,
- triggerAutofix,
-}: Pick) {
- const onClickGiveInstructions = () => {
- openModal(deps => (
-
- ));
+ group,
+ project,
+ event,
+}: Pick) {
+ const {openDrawer, isDrawerOpen, closeDrawer} = useDrawer();
+ const openButtonRef = useRef(null);
+
+ const openAutofix = () => {
+ if (!isDrawerOpen) {
+ openDrawer(() => , {
+ ariaLabel: t('Autofix drawer'),
+ // We prevent a click on the Open/Close Autofix button from closing the drawer so that
+ // we don't reopen it immediately, and instead let the button handle this itself.
+ shouldCloseOnInteractOutside: element => {
+ const viewAllButton = openButtonRef.current;
+ if (viewAllButton?.contains(element)) {
+ return false;
+ }
+ return true;
+ },
+ transitionProps: {stiffness: 1000},
+ });
+ } else {
+ closeDrawer();
+ }
};
return (
-
- triggerAutofix('')}
- size="sm"
- analyticsEventKey="autofix.start_fix_clicked"
- analyticsEventName="Autofix: Start Fix Clicked"
- analyticsParams={{group_id: groupId}}
- >
- {t('Get root causes')}
-
-
- {t('Provide context first')}
-
-
+ openAutofix()} size="sm" ref={openButtonRef}>
+ {t('Open Autofix')}
+
);
}
-function AutofixBannerContent({
- groupId,
- triggerAutofix,
- hasSuccessfulSetup,
- projectId,
-}: Props) {
+function AutofixBannerContent({group, hasSuccessfulSetup, project, event}: Props) {
if (hasSuccessfulSetup) {
- return ;
+ return ;
}
return (
{
openModal(deps => (
-
+
));
}}
size="sm"
@@ -86,12 +82,7 @@ function AutofixBannerContent({
);
}
-export function AutofixBanner({
- groupId,
- projectId,
- triggerAutofix,
- hasSuccessfulSetup,
-}: Props) {
+export function AutofixBanner({group, project, event, hasSuccessfulSetup}: Props) {
const isSentryEmployee = useIsSentryEmployee();
return (
@@ -117,9 +108,9 @@ export function AutofixBanner({
diff --git a/static/app/components/events/autofix/autofixCard.tsx b/static/app/components/events/autofix/autofixCard.tsx
deleted file mode 100644
index c1a27c46029cac..00000000000000
--- a/static/app/components/events/autofix/autofixCard.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import {useRef} from 'react';
-import styled from '@emotion/styled';
-
-import {Button} from 'sentry/components/button';
-import ButtonBar from 'sentry/components/buttonBar';
-import {AutofixSteps} from 'sentry/components/events/autofix/autofixSteps';
-import type {AutofixData} from 'sentry/components/events/autofix/types';
-import useFeedbackWidget from 'sentry/components/feedback/widget/useFeedbackWidget';
-import Panel from 'sentry/components/panels/panel';
-import {IconMegaphone} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import {space} from 'sentry/styles/space';
-
-type AutofixCardProps = {
- data: AutofixData;
- groupId: string;
- onRetry: () => void;
-};
-
-function AutofixFeedback() {
- const buttonRef = useRef(null);
- const feedback = useFeedbackWidget({
- buttonRef,
- messagePlaceholder: t('How can we make Autofix better for you?'),
- optionOverrides: {
- tags: {
- ['feedback.source']: 'issue_details_ai_autofix',
- ['feedback.owner']: 'ml-ai',
- },
- },
- });
-
- if (!feedback) {
- return null;
- }
-
- return (
- }>
- {t('Give Feedback')}
-
- );
-}
-
-export function AutofixCard({data, onRetry, groupId}: AutofixCardProps) {
- return (
-
-
- {t('Autofix')}
-
-
-
- {t('Start Over')}
-
-
-
-
-
- );
-}
-
-const Title = styled('div')`
- font-size: ${p => p.theme.fontSizeExtraLarge};
- font-weight: ${p => p.theme.fontWeightBold};
-`;
-
-const AutofixPanel = styled(Panel)`
- margin-bottom: 0;
- overflow: hidden;
- background: ${p => p.theme.backgroundSecondary};
- padding: ${space(2)} ${space(3)} ${space(3)} ${space(3)};
-`;
-
-const AutofixHeader = styled('div')`
- display: grid;
- grid-template-columns: 1fr auto;
- margin-bottom: ${space(2)};
-`;
diff --git a/static/app/components/events/autofix/autofixChanges.spec.tsx b/static/app/components/events/autofix/autofixChanges.spec.tsx
index aed65a37fca8f6..26deda563c1560 100644
--- a/static/app/components/events/autofix/autofixChanges.spec.tsx
+++ b/static/app/components/events/autofix/autofixChanges.spec.tsx
@@ -50,7 +50,7 @@ describe('AutofixChanges', function () {
);
});
- it('displays create PR button when it is last step', function () {
+ it('displays create PR button', function () {
MockApiClient.addMockResponse({
url: '/issues/1/autofix/setup/',
body: {
@@ -76,7 +76,6 @@ describe('AutofixChanges', function () {
],
}) as AutofixChangesStep
}
- isLastStep
/>
);
@@ -85,40 +84,6 @@ describe('AutofixChanges', function () {
).toBeInTheDocument();
});
- it('does not display create PR button when it is not the last step', function () {
- MockApiClient.addMockResponse({
- url: '/issues/1/autofix/setup/',
- body: {
- genAIConsent: {ok: true},
- codebaseIndexing: {ok: true},
- integration: {ok: true},
- githubWriteIntegration: {
- repos: [{ok: true, owner: 'owner', name: 'hello-world', id: 100}],
- },
- },
- });
-
- render(
-
- );
-
- expect(
- screen.queryByRole('button', {name: 'Create a Pull Request'})
- ).not.toBeInTheDocument();
- });
-
it('displays setup button when permissions do not exist for repo', async function () {
MockApiClient.addMockResponse({
url: '/issues/1/autofix/setup/',
@@ -147,7 +112,6 @@ describe('AutofixChanges', function () {
],
}) as AutofixChangesStep
}
- isLastStep
/>
);
renderGlobalModal();
diff --git a/static/app/components/events/autofix/autofixChanges.tsx b/static/app/components/events/autofix/autofixChanges.tsx
index 18da59e934ed61..1bce114ece1276 100644
--- a/static/app/components/events/autofix/autofixChanges.tsx
+++ b/static/app/components/events/autofix/autofixChanges.tsx
@@ -1,9 +1,11 @@
import {Fragment, useEffect, useState} from 'react';
import styled from '@emotion/styled';
+import {AnimatePresence, type AnimationProps, motion} from 'framer-motion';
import {addErrorMessage} from 'sentry/actionCreators/indicator';
import {openModal} from 'sentry/actionCreators/modal';
import {Button, LinkButton} from 'sentry/components/button';
+import ClippedBox from 'sentry/components/clippedBox';
import {AutofixDiff} from 'sentry/components/events/autofix/autofixDiff';
import {AutofixSetupWriteAccessModal} from 'sentry/components/events/autofix/autofixSetupWriteAccessModal';
import type {
@@ -21,13 +23,13 @@ import {IconOpen} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import {setApiQueryData, useMutation, useQueryClient} from 'sentry/utils/queryClient';
+import testableTransition from 'sentry/utils/testableTransition';
import useApi from 'sentry/utils/useApi';
type AutofixChangesProps = {
groupId: string;
onRetry: () => void;
step: AutofixChangesStep;
- isLastStep?: boolean;
};
function CreatePullRequestButton({
@@ -109,11 +111,9 @@ function CreatePullRequestButton({
function PullRequestLinkOrCreateButton({
change,
groupId,
- isLastStep,
}: {
change: AutofixCodebaseChange;
groupId: string;
- isLastStep?: boolean;
}) {
const {data} = useAutofixSetup({groupId});
@@ -133,10 +133,6 @@ function PullRequestLinkOrCreateButton({
);
}
- if (!isLastStep) {
- return null;
- }
-
if (
!data?.githubWriteIntegration?.repos?.find(
repo => `${repo.owner}/${repo.name}` === change.repo_name
@@ -172,11 +168,9 @@ function PullRequestLinkOrCreateButton({
function AutofixRepoChange({
change,
groupId,
- isLastStep,
}: {
change: AutofixCodebaseChange;
groupId: string;
- isLastStep?: boolean;
}) {
return (
@@ -185,23 +179,21 @@ function AutofixRepoChange({
{change.repo_name}
{change.title}
-
+
);
}
-export function AutofixChanges({
- step,
- onRetry,
- groupId,
- isLastStep,
-}: AutofixChangesProps) {
+const cardAnimationProps: AnimationProps = {
+ exit: {opacity: 0},
+ initial: {opacity: 0, y: 20},
+ animate: {opacity: 1, y: 0},
+ transition: testableTransition({duration: 0.3}),
+};
+
+export function AutofixChanges({step, onRetry, groupId}: AutofixChangesProps) {
const data = useAutofixData({groupId});
if (step.status === 'ERROR' || data?.status === 'ERROR') {
@@ -242,14 +234,21 @@ export function AutofixChanges({
}
return (
-
- {step.changes.map((change, i) => (
-
- {i > 0 && }
-
-
- ))}
-
+
+
+
+
+ {t('Fixes')}
+ {step.changes.map((change, i) => (
+
+ {i > 0 && }
+
+
+ ))}
+
+
+
+
);
}
@@ -260,8 +259,20 @@ const PreviewContent = styled('div')`
margin-top: ${space(2)};
`;
+const AnimationWrapper = styled(motion.div)``;
+
const PrefixText = styled('span')``;
+const ChangesContainer = styled('div')`
+ border: 1px solid ${p => p.theme.innerBorder};
+ border-radius: ${p => p.theme.borderRadius};
+ overflow: hidden;
+ box-shadow: ${p => p.theme.dropShadowHeavy};
+ padding-left: ${space(2)};
+ padding-right: ${space(2)};
+ padding-top: ${space(1)};
+`;
+
const Content = styled('div')`
padding: 0 ${space(1)} ${space(1)} ${space(1)};
`;
@@ -295,6 +306,11 @@ const Separator = styled('hr')`
margin: ${space(2)} -${space(2)} 0 -${space(2)};
`;
+const HeaderText = styled('div')`
+ font-weight: bold;
+ font-size: 1.2em;
+`;
+
const ProcessingStatusIndicator = styled(LoadingIndicator)`
&& {
margin: 0;
diff --git a/static/app/components/events/autofix/autofixDrawer.spec.tsx b/static/app/components/events/autofix/autofixDrawer.spec.tsx
new file mode 100644
index 00000000000000..9c00a410d1e5e7
--- /dev/null
+++ b/static/app/components/events/autofix/autofixDrawer.spec.tsx
@@ -0,0 +1,95 @@
+import {AutofixDataFixture} from 'sentry-fixture/autofixData';
+import {AutofixStepFixture} from 'sentry-fixture/autofixStep';
+import {EventFixture} from 'sentry-fixture/event';
+import {GroupFixture} from 'sentry-fixture/group';
+import {ProjectFixture} from 'sentry-fixture/project';
+
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import {AutofixDrawer} from 'sentry/components/events/autofix/autofixDrawer';
+import {t} from 'sentry/locale';
+
+describe('AutofixDrawer', () => {
+ const mockEvent = EventFixture();
+ const mockGroup = GroupFixture();
+ const mockProject = ProjectFixture();
+
+ const mockAutofixData = AutofixDataFixture({steps: [AutofixStepFixture()]});
+
+ beforeEach(() => {
+ MockApiClient.clearMockResponses();
+ });
+
+ it('renders properly', () => {
+ MockApiClient.addMockResponse({
+ url: `/issues/${mockGroup.id}/autofix/`,
+ body: {autofix: mockAutofixData},
+ });
+
+ render( );
+
+ expect(screen.getByText(mockGroup.shortId)).toBeInTheDocument();
+
+ expect(screen.getByText(mockEvent.id)).toBeInTheDocument();
+
+ expect(screen.getByRole('heading', {name: 'Autofix'})).toBeInTheDocument();
+
+ expect(screen.getByText('Autofix is ready to start')).toBeInTheDocument();
+
+ const startButton = screen.getByRole('button', {name: 'Start'});
+ expect(startButton).toBeInTheDocument();
+ });
+
+ it('triggers autofix on clicking the Start button', async () => {
+ MockApiClient.addMockResponse({
+ url: `/issues/${mockGroup.id}/autofix/`,
+ method: 'POST',
+ body: {autofix: null},
+ });
+ MockApiClient.addMockResponse({
+ url: `/issues/${mockGroup.id}/autofix/`,
+ method: 'GET',
+ body: {autofix: null},
+ });
+
+ render( );
+
+ const startButton = screen.getByRole('button', {name: 'Start'});
+ await userEvent.click(startButton);
+
+ expect(
+ await screen.findByRole('button', {name: t('Start Over')})
+ ).toBeInTheDocument();
+ });
+
+ it('displays autofix steps and Start Over button when autofixData is available', async () => {
+ MockApiClient.addMockResponse({
+ url: `/issues/${mockGroup.id}/autofix/`,
+ body: {autofix: mockAutofixData},
+ });
+
+ render( );
+
+ expect(
+ await screen.findByRole('button', {name: t('Start Over')})
+ ).toBeInTheDocument();
+ });
+
+ it('resets autofix on clicking the start over button', async () => {
+ MockApiClient.addMockResponse({
+ url: `/issues/${mockGroup.id}/autofix/`,
+ body: {autofix: mockAutofixData},
+ });
+
+ render( );
+
+ const startOverButton = await screen.findByRole('button', {name: t('Start Over')});
+ expect(startOverButton).toBeInTheDocument();
+ await userEvent.click(startOverButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Autofix is ready to start')).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'Start'})).toBeInTheDocument();
+ });
+ });
+});
diff --git a/static/app/components/events/autofix/autofixDrawer.tsx b/static/app/components/events/autofix/autofixDrawer.tsx
new file mode 100644
index 00000000000000..ecdb157262db87
--- /dev/null
+++ b/static/app/components/events/autofix/autofixDrawer.tsx
@@ -0,0 +1,203 @@
+import {useState} from 'react';
+import styled from '@emotion/styled';
+
+import bannerImage from 'sentry-images/spot/ai-suggestion-banner.svg';
+
+import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
+import {Breadcrumbs as NavigationBreadcrumbs} from 'sentry/components/breadcrumbs';
+import {Button} from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
+import AutofixFeedback from 'sentry/components/events/autofix/autofixFeedback';
+import {AutofixSteps} from 'sentry/components/events/autofix/autofixSteps';
+import {useAiAutofix} from 'sentry/components/events/autofix/useAutofix';
+import {DrawerBody, DrawerHeader} from 'sentry/components/globalDrawer/components';
+import Input from 'sentry/components/input';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Event} from 'sentry/types/event';
+import type {Group} from 'sentry/types/group';
+import type {Project} from 'sentry/types/project';
+import {getShortEventId} from 'sentry/utils/events';
+import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
+import {MIN_NAV_HEIGHT} from 'sentry/views/issueDetails/streamline/eventNavigation';
+
+interface AutofixStartBoxProps {
+ onSend: (message: string) => void;
+}
+
+function AutofixStartBox({onSend}: AutofixStartBoxProps) {
+ const [message, setMessage] = useState('');
+
+ const send = () => {
+ onSend(message);
+ };
+
+ return (
+
+ Autofix is ready to start
+
+
+ We'll begin by trying to figure out the root cause, analyzing the issue details
+ and the codebase. If you have any other helpful context on the issue before we
+ begin, you can share that below.
+
+ setMessage(e.target.value)}
+ placeholder={'Provide any extra context here...'}
+ />
+
+
+ Start
+
+
+
+
+
+ );
+}
+
+interface AutofixDrawerProps {
+ event: Event;
+ group: Group;
+ project: Project;
+}
+
+export function AutofixDrawer({group, project, event}: AutofixDrawerProps) {
+ const {autofixData, triggerAutofix, reset} = useAiAutofix(group, event);
+ useRouteAnalyticsParams({
+ autofix_status: autofixData?.status ?? 'none',
+ });
+
+ const [_, setContainer] = useState(null);
+
+ return (
+
+
+
+
+ {group.shortId}
+
+ ),
+ },
+ {label: getShortEventId(event.id)},
+ {label: t('Autofix')},
+ ]}
+ />
+
+
+
+ {autofixData && (
+
+
+
+ {t('Start Over')}
+
+
+ )}
+
+
+ {!autofixData ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+const IllustrationContainer = styled('div')`
+ padding-top: ${space(4)};
+`;
+
+const Illustration = styled('img')`
+ height: 100%;
+`;
+
+const StartBox = styled('div')`
+ padding: ${space(2)};
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ height: 100%;
+ width: 100%;
+`;
+
+const AutofixDrawerContainer = styled('div')`
+ height: 100%;
+ display: grid;
+ grid-template-rows: auto auto 1fr;
+`;
+
+const AutofixDrawerHeader = styled(DrawerHeader)`
+ position: unset;
+ max-height: ${MIN_NAV_HEIGHT}px;
+ box-shadow: none;
+ border-bottom: 1px solid ${p => p.theme.border};
+`;
+
+const AutofixNavigator = styled('div')`
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ column-gap: ${space(1)};
+ padding: ${space(0.75)} 24px;
+ background: ${p => p.theme.background};
+ z-index: 1;
+ min-height: ${MIN_NAV_HEIGHT}px;
+ box-shadow: ${p => p.theme.translucentBorder} 0 1px;
+`;
+
+const AutofixDrawerBody = styled(DrawerBody)`
+ overflow: auto;
+ overscroll-behavior: contain;
+ /* Move the scrollbar to the left edge */
+ scroll-margin: 0 ${space(2)};
+ direction: rtl;
+ * {
+ direction: ltr;
+ }
+`;
+
+const Header = styled('h3')`
+ display: block;
+ font-size: ${p => p.theme.fontSizeExtraLarge};
+ font-weight: ${p => p.theme.fontWeightBold};
+ margin: 0;
+`;
+
+const NavigationCrumbs = styled(NavigationBreadcrumbs)`
+ margin: 0;
+ padding: 0;
+`;
+
+const CrumbContainer = styled('div')`
+ display: flex;
+ gap: ${space(1)};
+ align-items: center;
+`;
+
+const ShortId = styled('div')`
+ font-family: ${p => p.theme.text.family};
+ font-size: ${p => p.theme.fontSizeMedium};
+ line-height: 1;
+`;
diff --git a/static/app/components/events/autofix/autofixFeedback.tsx b/static/app/components/events/autofix/autofixFeedback.tsx
new file mode 100644
index 00000000000000..fc6ceda7c048b5
--- /dev/null
+++ b/static/app/components/events/autofix/autofixFeedback.tsx
@@ -0,0 +1,32 @@
+import {useRef} from 'react';
+
+import {Button} from 'sentry/components/button';
+import useFeedbackWidget from 'sentry/components/feedback/widget/useFeedbackWidget';
+import {IconMegaphone} from 'sentry/icons/iconMegaphone';
+import {t} from 'sentry/locale';
+
+function AutofixFeedback() {
+ const buttonRef = useRef(null);
+ const feedback = useFeedbackWidget({
+ buttonRef,
+ messagePlaceholder: t('How can we make Autofix better for you?'),
+ optionOverrides: {
+ tags: {
+ ['feedback.source']: 'issue_details_ai_autofix',
+ ['feedback.owner']: 'ml-ai',
+ },
+ },
+ });
+
+ if (!feedback) {
+ return null;
+ }
+
+ return (
+ }>
+ {t('Give Feedback')}
+
+ );
+}
+
+export default AutofixFeedback;
diff --git a/static/app/components/events/autofix/autofixInputField.spec.tsx b/static/app/components/events/autofix/autofixInputField.spec.tsx
deleted file mode 100644
index e603702a9d6154..00000000000000
--- a/static/app/components/events/autofix/autofixInputField.spec.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
-
-import {AutofixInputField} from 'sentry/components/events/autofix/autofixInputField';
-
-describe('AutofixInputField', function () {
- const defaultProps = {
- groupId: '123',
- runId: '456',
- };
-
- it('renders the input field correctly', function () {
- render( );
-
- // Check if the input field is present
- expect(
- screen.getByPlaceholderText('Rename the function foo_bar to fooBar')
- ).toBeInTheDocument();
-
- // Check if the send button is present
- expect(screen.getByText('Send')).toBeInTheDocument();
- });
-
- it('handles input change correctly', async function () {
- render( );
-
- const inputField = screen.getByPlaceholderText(
- 'Rename the function foo_bar to fooBar'
- );
-
- // Simulate user typing in the input field
- await userEvent.click(inputField);
- await userEvent.type(inputField, 'Change the variable name');
-
- // Check if the input field value has changed
- expect(inputField).toHaveValue('Change the variable name');
- });
-
- it('handles send button click correctly', async function () {
- const mockSendUpdate = MockApiClient.addMockResponse({
- url: '/issues/123/autofix/update/',
- method: 'POST',
- });
-
- render( );
-
- const inputField = screen.getByPlaceholderText(
- 'Rename the function foo_bar to fooBar'
- );
- const sendButton = screen.getByText('Send');
-
- // Simulate user typing in the input field
- await userEvent.click(inputField);
- await userEvent.type(inputField, 'Change the variable name');
-
- // Simulate user clicking the send button
- await userEvent.click(sendButton);
-
- // Check if the input field is disabled after clicking the send button
- expect(inputField).toBeDisabled();
-
- // Check if the API request was sent
- expect(mockSendUpdate).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/static/app/components/events/autofix/autofixInputField.tsx b/static/app/components/events/autofix/autofixInputField.tsx
deleted file mode 100644
index 6a6de7ed09e618..00000000000000
--- a/static/app/components/events/autofix/autofixInputField.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import {useCallback, useState} from 'react';
-import styled from '@emotion/styled';
-
-import {addErrorMessage} from 'sentry/actionCreators/indicator';
-import {Button} from 'sentry/components/button';
-import {
- type AutofixResponse,
- makeAutofixQueryKey,
-} from 'sentry/components/events/autofix/useAutofix';
-import TextArea from 'sentry/components/forms/controls/textarea';
-import LoadingIndicator from 'sentry/components/loadingIndicator';
-import Panel from 'sentry/components/panels/panel';
-import {t} from 'sentry/locale';
-import {space} from 'sentry/styles/space';
-import {isCtrlKeyPressed} from 'sentry/utils/isCtrlKeyPressed';
-import {setApiQueryData, useMutation, useQueryClient} from 'sentry/utils/queryClient';
-import useApi from 'sentry/utils/useApi';
-
-const useAutofixUserInstruction = (groupId: string, runId: string) => {
- const api = useApi();
- const queryClient = useQueryClient();
-
- const [instruction, setInstruction] = useState('');
- const [isSubmitting, setIsSubmitting] = useState(false);
-
- const {mutate} = useMutation({
- mutationFn: (params: {instruction: string}) => {
- return api.requestPromise(`/issues/${groupId}/autofix/update/`, {
- method: 'POST',
- data: {
- run_id: runId,
- payload: {
- type: 'instruction',
- content: {
- type: 'text',
- text: params.instruction,
- },
- },
- },
- });
- },
- onSuccess: _ => {
- setApiQueryData(
- queryClient,
- makeAutofixQueryKey(groupId),
- data => {
- if (!data || !data.autofix) {
- return data;
- }
-
- return {
- ...data,
- autofix: {
- ...data.autofix,
- status: 'PROCESSING',
- },
- };
- }
- );
- },
- onError: () => {
- addErrorMessage(t('Something went wrong when responding to autofix.'));
- setIsSubmitting(false);
- },
- });
-
- const sendInstruction = useCallback(() => {
- mutate({instruction});
- setIsSubmitting(true);
- }, [instruction, mutate, setIsSubmitting]);
-
- return {sendInstruction, instruction, setInstruction, isSubmitting};
-};
-
-export function AutofixInputField({groupId, runId}: {groupId: string; runId: string}) {
- const {sendInstruction, instruction, setInstruction, isSubmitting} =
- useAutofixUserInstruction(groupId, runId);
-
- return (
-
- {t("Doesn't look right? Tell Autofix what needs to be changed")}
-
-
- );
-}
-
-const Card = styled(Panel)`
- padding: ${space(2)};
- margin-bottom: 0;
- display: flex;
- flex-direction: column;
- gap: ${space(1)};
-`;
-
-const Title = styled('div')`
- font-weight: bold;
- white-space: nowrap;
-`;
-
-const FormRow = styled('div')`
- display: flex;
- flex-direction: row;
- align-items: flex-start;
- gap: ${space(1)};
- width: 100%;
-`;
-
-const ProcessingStatusIndicator = styled(LoadingIndicator)`
- && {
- margin: 0;
- height: 18px;
- width: 18px;
- }
-`;
diff --git a/static/app/components/events/autofix/autofixInsightCards.spec.tsx b/static/app/components/events/autofix/autofixInsightCards.spec.tsx
new file mode 100644
index 00000000000000..b3511f1cb32480
--- /dev/null
+++ b/static/app/components/events/autofix/autofixInsightCards.spec.tsx
@@ -0,0 +1,55 @@
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import AutofixInsightCards from 'sentry/components/events/autofix/autofixInsightCards';
+
+jest.mock('sentry/utils/marked', () => ({
+ singleLineRenderer: jest.fn(text => text),
+}));
+
+const sampleInsights = [
+ {
+ breadcrumb_context: [],
+ codebase_context: [],
+ error_message_context: ['Error message 1'],
+ insight: 'Sample insight 1',
+ justification: 'Sample justification 1',
+ stacktrace_context: [],
+ },
+];
+
+const sampleRepos = [
+ {
+ external_id: '1',
+ name: 'sample-repo',
+ default_branch: 'main',
+ provider: 'github',
+ url: 'github.com/org/sample-repo',
+ },
+];
+
+describe('AutofixInsightCards', () => {
+ const renderComponent = (props = {}) => {
+ return render(
+
+ );
+ };
+
+ it('renders insights correctly', () => {
+ renderComponent();
+ expect(screen.getByText('Sample insight 1')).toBeInTheDocument();
+ });
+
+ it('expands context when clicked', async () => {
+ renderComponent();
+ const contextButton = screen.getByText('Context');
+ await userEvent.click(contextButton);
+ expect(screen.getByText('Sample justification 1')).toBeInTheDocument();
+ expect(screen.getByText('`Error message 1`')).toBeInTheDocument();
+ });
+});
diff --git a/static/app/components/events/autofix/autofixInsightCards.tsx b/static/app/components/events/autofix/autofixInsightCards.tsx
new file mode 100644
index 00000000000000..65d191d17a223a
--- /dev/null
+++ b/static/app/components/events/autofix/autofixInsightCards.tsx
@@ -0,0 +1,401 @@
+import {useState} from 'react';
+import styled from '@emotion/styled';
+import {AnimatePresence, type AnimationProps, motion} from 'framer-motion';
+
+import bannerImage from 'sentry-images/spot/ai-suggestion-banner.svg';
+
+import {Button} from 'sentry/components/button';
+import {
+ replaceHeadersWithBold,
+ SuggestedFixSnippet,
+} from 'sentry/components/events/autofix/autofixRootCause';
+import type {
+ AutofixInsight,
+ AutofixRepository,
+ BreadcrumbContext,
+} from 'sentry/components/events/autofix/types';
+import BreadcrumbItemContent from 'sentry/components/events/breadcrumbs/breadcrumbItemContent';
+import {
+ BreadcrumbIcon,
+ BreadcrumbLevel,
+ getBreadcrumbColorConfig,
+ getBreadcrumbTitle,
+} from 'sentry/components/events/breadcrumbs/utils';
+import StructuredEventData from 'sentry/components/structuredEventData';
+import Timeline from 'sentry/components/timeline';
+import {IconArrow, IconChevron, IconCode, IconFire} from 'sentry/icons';
+import {space} from 'sentry/styles/space';
+import {BreadcrumbLevelType, BreadcrumbType} from 'sentry/types/breadcrumbs';
+import {singleLineRenderer} from 'sentry/utils/marked';
+import testableTransition from 'sentry/utils/testableTransition';
+
+interface AutofixBreadcrumbSnippetProps {
+ breadcrumb: BreadcrumbContext;
+}
+
+function AutofixBreadcrumbSnippet({breadcrumb}: AutofixBreadcrumbSnippetProps) {
+ const type = BreadcrumbType[breadcrumb.category.toUpperCase()];
+ const level = BreadcrumbLevelType[breadcrumb.level.toUpperCase()];
+ const rawCrumb = {
+ message: breadcrumb.body,
+ category: breadcrumb.category,
+ type: type,
+ level: level,
+ };
+
+ return (
+
+
+
+ {getBreadcrumbTitle(rawCrumb)}
+
+ {level}
+
+ }
+ colorConfig={getBreadcrumbColorConfig(type)}
+ icon={ }
+ isActive
+ showLastLine
+ >
+
+
+
+
+
+ );
+}
+
+export function ExpandableInsightContext({
+ children,
+ title,
+}: {
+ children: React.ReactNode;
+ title: string;
+}) {
+ const [expanded, setExpanded] = useState(false);
+
+ const toggleExpand = () => {
+ setExpanded(oldState => !oldState);
+ };
+
+ return (
+
+
+
+ {title}
+
+
+
+ {expanded && {children} }
+
+ );
+}
+
+const animationProps: AnimationProps = {
+ exit: {opacity: 0},
+ initial: {opacity: 0, y: 20},
+ animate: {opacity: 1, y: 0},
+ transition: testableTransition({duration: 0.3}),
+};
+
+interface AutofixInsightCardProps {
+ hasCardAbove: boolean;
+ hasCardBelow: boolean;
+ insight: AutofixInsight;
+ repos: AutofixRepository[];
+}
+
+function AutofixInsightCard({
+ insight,
+ hasCardBelow,
+ hasCardAbove,
+ repos,
+}: AutofixInsightCardProps) {
+ return (
+
+
+
+ {hasCardAbove && (
+
+
+
+ )}
+
+
+
+
+ {insight.error_message_context &&
+ insight.error_message_context.length > 0 && (
+
+ {insight.error_message_context
+ .map((message, i) => {
+ return (
+
+
+
+
+
+
+
+
+ );
+ })
+ .reverse()}
+
+ )}
+ {insight.stacktrace_context && insight.stacktrace_context.length > 0 && (
+
+ {insight.stacktrace_context
+ .map((stacktrace, i) => {
+ return (
+
+ }
+ />
+
+
+ );
+ })
+ .reverse()}
+
+ )}
+ {insight.breadcrumb_context && insight.breadcrumb_context.length > 0 && (
+
+ {insight.breadcrumb_context
+ .map((breadcrumb, i) => {
+ return
;
+ })
+ .reverse()}
+
+ )}
+ {insight.codebase_context && insight.codebase_context.length > 0 && (
+
+ {insight.codebase_context
+ .map((code, i) => {
+ return (
+ }
+ />
+ );
+ })
+ .reverse()}
+
+ )}
+
+
+ {hasCardBelow && (
+
+
+
+ )}
+
+
+
+ );
+}
+
+interface AutofixInsightCardsProps {
+ hasStepAbove: boolean;
+ hasStepBelow: boolean;
+ insights: AutofixInsight[];
+ repos: AutofixRepository[];
+}
+
+function AutofixInsightCards({
+ insights,
+ repos,
+ hasStepBelow,
+ hasStepAbove,
+}: AutofixInsightCardsProps) {
+ return (
+
+ {!hasStepAbove && (
+
+ Insights
+
+
+
+
+ )}
+ {insights.length > 0 ? (
+ insights.map((insight, index) =>
+ !insight ? null : (
+
+ )
+ )
+ ) : !hasStepAbove && !hasStepBelow ? (
+
+
+ Autofix will share important conclusions here as it discovers them, building a
+ line of reasoning up to the root cause.
+
+
+
+
+
+ ) : null}
+
+ );
+}
+
+const IllustrationContainer = styled('div')`
+ padding-top: ${space(4)};
+`;
+
+const Illustration = styled('img')`
+ height: 100%;
+`;
+
+const NoInsightsYet = styled('div')`
+ display: flex;
+ justify-content: center;
+ flex-direction: column;
+ padding-left: ${space(4)};
+ padding-right: ${space(4)};
+ text-align: center;
+`;
+
+const TitleText = styled('p')`
+ font-size: ${p => p.theme.fontSizeLarge};
+ font-weight: ${p => p.theme.fontWeightBold};
+ margin: 0;
+ display: flex;
+ justify-content: center;
+`;
+
+const InsightsContainer = styled('div')``;
+
+const InsightContainer = styled(motion.div)`
+ border: 1px solid ${p => p.theme.innerBorder};
+ border-radius: ${p => p.theme.borderRadius};
+ overflow: hidden;
+ box-shadow: ${p => p.theme.dropShadowMedium};
+`;
+
+const IconContainer = styled('div')`
+ padding: ${space(1)};
+ display: flex;
+ justify-content: center;
+`;
+
+const BreadcrumbItem = styled(Timeline.Item)`
+ border-bottom: 1px solid transparent;
+ &:not(:last-child) {
+ border-image: linear-gradient(
+ to right,
+ transparent 20px,
+ ${p => p.theme.translucentInnerBorder} 20px
+ )
+ 100% 1;
+ }
+`;
+
+const ContentWrapper = styled('div')`
+ padding-bottom: ${space(1)};
+`;
+
+const Header = styled('div')`
+ display: grid;
+ grid-template-columns: 1fr auto;
+`;
+
+const TextBreak = styled('span')`
+ word-wrap: break-word;
+ word-break: break-all;
+`;
+
+const BackgroundPanel = styled('div')`
+ padding: ${space(1)};
+ margin-bottom: ${space(1)};
+ background: ${p => p.theme.backgroundSecondary};
+ border-radius: ${p => p.theme.borderRadius};
+`;
+
+const MiniHeader = styled('p')`
+ padding-top: ${space(2)};
+ padding-right: ${space(2)};
+ padding-left: ${space(2)};
+`;
+
+const ExpandableContext = styled('div')`
+ width: 100%;
+ background: ${p => p.theme.alert.info.backgroundLight};
+`;
+
+const ContextHeader = styled(Button)`
+ width: 100%;
+ box-shadow: none;
+ margin: 0;
+ border: none;
+ font-weight: normal;
+ background: ${p => p.theme.backgroundSecondary};
+ border-radius: 0px;
+`;
+
+const ContextHeaderWrapper = styled('div')`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+`;
+
+const ContextHeaderText = styled('p')`
+ height: 0;
+`;
+
+const ContextBody = styled('div')`
+ padding: ${space(2)};
+`;
+
+const ErrorMessage = styled('div')`
+ display: flex;
+ gap: ${space(1)};
+`;
+
+const ErrorMessageIcon = styled('div')``;
+
+const StyledStructuredEventData = styled(StructuredEventData)`
+ border-top: solid 1px ${p => p.theme.border};
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+`;
+
+const AnimationWrapper = styled(motion.div)``;
+
+export default AutofixInsightCards;
diff --git a/static/app/components/events/autofix/autofixMessageBox.spec.tsx b/static/app/components/events/autofix/autofixMessageBox.spec.tsx
new file mode 100644
index 00000000000000..941bbfc7c1904d
--- /dev/null
+++ b/static/app/components/events/autofix/autofixMessageBox.spec.tsx
@@ -0,0 +1,117 @@
+import {AutofixStepFixture} from 'sentry-fixture/autofixStep';
+
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
+import AutofixMessageBox from 'sentry/components/events/autofix/autofixMessageBox';
+
+jest.mock('sentry/actionCreators/indicator');
+
+describe('AutofixMessageBox', () => {
+ const defaultProps = {
+ displayText: 'Test display text',
+ groupId: '123',
+ runId: '456',
+ inputPlaceholder: 'Test placeholder',
+ actionText: 'Send',
+ isDisabled: false,
+ allowEmptyMessage: false,
+ responseRequired: false,
+ step: null,
+ onSend: null,
+ };
+
+ beforeEach(() => {
+ (addSuccessMessage as jest.Mock).mockClear();
+ (addErrorMessage as jest.Mock).mockClear();
+ MockApiClient.clearMockResponses();
+ });
+
+ it('renders correctly with default props', () => {
+ render( );
+
+ expect(screen.getByText('Test display text')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Test placeholder')).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'Send'})).toBeInTheDocument();
+ });
+
+ it('calls onSend when provided and button is clicked', async () => {
+ const onSendMock = jest.fn();
+ render( );
+
+ const input = screen.getByPlaceholderText('Test placeholder');
+ await userEvent.type(input, 'Test message');
+ await userEvent.click(screen.getByRole('button', {name: 'Send'}));
+
+ expect(onSendMock).toHaveBeenCalledWith('Test message');
+ });
+
+ it('sends interjection message when onSend is not provided', async () => {
+ MockApiClient.addMockResponse({
+ method: 'POST',
+ url: '/issues/123/autofix/update/',
+ body: {},
+ });
+
+ render( );
+
+ const input = screen.getByPlaceholderText('Test placeholder');
+ await userEvent.type(input, 'Test message');
+ await userEvent.click(screen.getByRole('button', {name: 'Send'}));
+
+ await waitFor(() => {
+ expect(addSuccessMessage).toHaveBeenCalledWith(
+ "Thanks for the input! I'll get to it right after this."
+ );
+ });
+ });
+
+ it('displays error message when API request fails', async () => {
+ MockApiClient.addMockResponse({
+ url: '/issues/123/autofix/update/',
+ method: 'POST',
+ body: {
+ detail: 'Internal Error',
+ },
+ statusCode: 500,
+ });
+
+ render( );
+
+ const input = screen.getByPlaceholderText('Test placeholder');
+ await userEvent.type(input, 'Test message');
+ await userEvent.click(screen.getByRole('button', {name: 'Send'}));
+
+ await waitFor(() => {
+ expect(addErrorMessage).toHaveBeenCalledWith(
+ 'Something went wrong when sending Autofix your message.'
+ );
+ });
+ });
+
+ it('renders step icon and title when step is provided', () => {
+ const stepProps = {
+ ...defaultProps,
+ step: AutofixStepFixture(),
+ };
+
+ render( );
+
+ expect(screen.getByText(AutofixStepFixture().title)).toBeInTheDocument();
+ });
+
+ it('disables input and button when isDisabled is true', () => {
+ render( );
+
+ expect(screen.getByPlaceholderText('Test placeholder')).toBeDisabled();
+ expect(screen.getByRole('button', {name: 'Send'})).toBeDisabled();
+ });
+
+ it('renders required input style when responseRequired is true', () => {
+ render( );
+
+ expect(
+ screen.getByPlaceholderText('Please answer to continue...')
+ ).toBeInTheDocument();
+ });
+});
diff --git a/static/app/components/events/autofix/autofixMessageBox.tsx b/static/app/components/events/autofix/autofixMessageBox.tsx
new file mode 100644
index 00000000000000..30ea803310c99a
--- /dev/null
+++ b/static/app/components/events/autofix/autofixMessageBox.tsx
@@ -0,0 +1,286 @@
+import {type FormEvent, Fragment, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
+import {Button} from 'sentry/components/button';
+import {type AutofixStep, AutofixStepType} from 'sentry/components/events/autofix/types';
+import Input from 'sentry/components/input';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {
+ IconCheckmark,
+ IconChevron,
+ IconClose,
+ IconCode,
+ IconFatal,
+ IconQuestion,
+ IconSad,
+} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import marked, {singleLineRenderer} from 'sentry/utils/marked';
+import {useMutation} from 'sentry/utils/queryClient';
+import useApi from 'sentry/utils/useApi';
+
+function useSendMessage({groupId, runId}: {groupId: string; runId: string}) {
+ const api = useApi({persistInFlight: true});
+
+ return useMutation({
+ mutationFn: (params: {message: string}) => {
+ return api.requestPromise(`/issues/${groupId}/autofix/update/`, {
+ method: 'POST',
+ data: {
+ run_id: runId,
+ payload: {
+ type: 'user_message',
+ text: params.message,
+ },
+ },
+ });
+ },
+ onSuccess: _ => {
+ addSuccessMessage("Thanks for the input! I'll get to it right after this.");
+ },
+ onError: () => {
+ addErrorMessage(t('Something went wrong when sending Autofix your message.'));
+ },
+ });
+}
+
+interface AutofixMessageBoxProps {
+ actionText: string;
+ allowEmptyMessage: boolean;
+ displayText: string;
+ groupId: string;
+ inputPlaceholder: string;
+ isDisabled: boolean;
+ onSend: ((message: string) => void) | null;
+ responseRequired: boolean;
+ runId: string;
+ step: AutofixStep | null;
+ emptyInfoText?: string;
+ notEmptyInfoText?: string;
+ primaryAction?: boolean;
+ scrollIntoView?: (() => void) | null;
+}
+
+function StepIcon({step}: {step: AutofixStep}) {
+ if (step.type === AutofixStepType.CHANGES) {
+ return ;
+ }
+
+ if (step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS) {
+ if (step.causes?.length === 0) {
+ return ;
+ }
+ return step.selection ? (
+
+ ) : (
+
+ );
+ }
+
+ switch (step.status) {
+ case 'PROCESSING':
+ return ;
+ case 'CANCELLED':
+ return ;
+ case 'ERROR':
+ return ;
+ case 'COMPLETED':
+ return ;
+ default:
+ return null;
+ }
+}
+
+function AutofixMessageBox({
+ displayText = '',
+ step = null,
+ inputPlaceholder = 'Say something...',
+ primaryAction = false,
+ responseRequired = false,
+ onSend,
+ actionText = 'Send',
+ allowEmptyMessage = false,
+ isDisabled = false,
+ groupId,
+ runId,
+ emptyInfoText = '',
+ notEmptyInfoText = '',
+ scrollIntoView = null,
+}: AutofixMessageBoxProps) {
+ const [message, setMessage] = useState('');
+ const {mutate: send} = useSendMessage({groupId, runId});
+
+ const handleSend = (e: FormEvent) => {
+ e.preventDefault();
+ if (message.trim() !== '' || allowEmptyMessage) {
+ if (onSend != null) {
+ onSend(message);
+ } else {
+ send({
+ message: message,
+ });
+ }
+ setMessage('');
+ }
+ };
+
+ return (
+
+
+ {step && (
+
+
+
+
+
+ {scrollIntoView !== null && (
+ }
+ aria-label={t('Jump to content')}
+ />
+ )}
+
+ )}
+
+
+ {message.length > 0 ? notEmptyInfoText : emptyInfoText}
+
+
+
+
+ {!responseRequired ? (
+
+ setMessage(e.target.value)}
+ placeholder={inputPlaceholder}
+ disabled={isDisabled}
+ />
+
+ {actionText}
+
+
+ ) : (
+
+ setMessage(e.target.value)}
+ placeholder={'Please answer to continue...'}
+ />
+
+ {actionText}
+
+
+ )}
+
+
+
+ );
+}
+
+const Container = styled('div')`
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: white;
+ z-index: 100;
+ border-top: 1px solid ${p => p.theme.border};
+ padding: 16px;
+ box-shadow: ${p => p.theme.dropShadowHeavy};
+`;
+
+const DisplayArea = styled('div')`
+ height: 8em;
+ overflow-y: hidden;
+ padding: 8px;
+ border-radius: 4px;
+ margin-bottom: 2px;
+`;
+
+const Message = styled('div')`
+ overflow-y: scroll;
+ height: 7em;
+`;
+
+const StepTitle = styled('div')`
+ font-weight: ${p => p.theme.fontWeightBold};
+ white-space: nowrap;
+ display: flex;
+ flex-shrink: 1;
+ align-items: center;
+ flex-grow: 0;
+
+ span {
+ margin-right: ${space(1)};
+ }
+`;
+const StepIconContainer = styled('div')`
+ display: flex;
+ align-items: center;
+ margin-right: ${space(1)};
+`;
+
+const StepHeader = styled('div')`
+ display: flex;
+ align-items: center;
+ padding-bottom: ${space(1)};
+ gap: ${space(1)};
+ font-size: ${p => p.theme.fontSizeMedium};
+ font-family: ${p => p.theme.text.family};
+
+ &:last-child {
+ padding-bottom: ${space(2)};
+ }
+`;
+
+const InputArea = styled('div')`
+ display: flex;
+`;
+
+const NormalInput = styled(Input)`
+ flex-grow: 1;
+ margin-right: 8px;
+`;
+
+const RequiredInput = styled(Input)`
+ flex-grow: 1;
+ margin-right: 8px;
+ border-color: ${p => p.theme.errorFocus};
+ box-shadow: 0 0 0 1px ${p => p.theme.errorFocus};
+`;
+
+const ProcessingStatusIndicator = styled(LoadingIndicator)`
+ && {
+ margin: 0;
+ height: 14px;
+ width: 14px;
+ }
+`;
+
+const ActionBar = styled('div')`
+ position: absolute;
+ bottom: 3em;
+ color: ${p => p.theme.subText};
+`;
+
+export default AutofixMessageBox;
diff --git a/static/app/components/events/autofix/autofixRootCause.spec.tsx b/static/app/components/events/autofix/autofixRootCause.spec.tsx
index 89e1287e081f0a..ef91264ee3b2cc 100644
--- a/static/app/components/events/autofix/autofixRootCause.spec.tsx
+++ b/static/app/components/events/autofix/autofixRootCause.spec.tsx
@@ -13,73 +13,28 @@ describe('AutofixRootCause', function () {
repos: [],
};
- it('can select a relevant code snippet', async function () {
- const mockSelectFix = MockApiClient.addMockResponse({
- url: '/issues/1/autofix/update/',
- method: 'POST',
- });
-
+ it('can view a relevant code snippet', async function () {
render( );
// Displays all root cause and code context info
- expect(screen.getByText('This is the title of a root cause.')).toBeInTheDocument();
expect(
- screen.getByText('This is the description of a root cause.')
+ screen.getByText('Potential Root Cause: This is the title of a root cause.')
).toBeInTheDocument();
expect(
- screen.getByText('Relevant Code #1: This is the title of a relevant code snippet.')
- ).toBeInTheDocument();
- expect(
- screen.getByText('This is the description of a relevant code snippet.')
+ screen.getByText('This is the description of a root cause.')
).toBeInTheDocument();
- await userEvent.click(screen.getByRole('button', {name: 'Find a Fix'}));
-
- expect(mockSelectFix).toHaveBeenCalledWith(
- expect.anything(),
- expect.objectContaining({
- data: {
- run_id: '101',
- payload: {
- type: 'select_root_cause',
- cause_id: '100',
- },
- },
- })
- );
- });
-
- it('can provide a custom root cause', async function () {
- const mockSelectFix = MockApiClient.addMockResponse({
- url: '/issues/1/autofix/update/',
- method: 'POST',
- });
-
- render( );
-
- await userEvent.click(
- screen.getByRole('button', {name: 'Provide your own root cause'})
- );
- await userEvent.keyboard('custom root cause');
await userEvent.click(
screen.getByRole('button', {
- name: 'Find a Fix',
- description: 'Find a Fix',
- })
- );
-
- expect(mockSelectFix).toHaveBeenCalledWith(
- expect.anything(),
- expect.objectContaining({
- data: {
- run_id: '101',
- payload: {
- type: 'select_root_cause',
- custom_root_cause: 'custom root cause',
- },
- },
+ name: 'Relevant code',
})
);
+ expect(
+ screen.getByText('Snippet #1: This is the title of a relevant code snippet.')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText('This is the description of a relevant code snippet.')
+ ).toBeInTheDocument();
});
it('shows graceful error state when there are no causes', function () {
@@ -98,7 +53,7 @@ describe('AutofixRootCause', function () {
).toBeInTheDocument();
});
- it('shows hyperlink when matching GitHub repo available', function () {
+ it('shows hyperlink when matching GitHub repo available', async function () {
render(
);
+ await userEvent.click(
+ screen.getByRole('button', {
+ name: 'Relevant code',
+ })
+ );
+
expect(screen.queryByRole('link', {name: 'GitHub'})).toBeInTheDocument();
expect(screen.queryByRole('link', {name: 'GitHub'})).toHaveAttribute(
'href',
@@ -136,7 +97,7 @@ describe('AutofixRootCause', function () {
expect(screen.queryByRole('link', {name: 'GitHub'})).not.toBeInTheDocument();
});
- it('shows reproduction steps when applicable', function () {
+ it('shows reproduction steps when applicable', async function () {
render(
);
+ await userEvent.click(
+ screen.getByRole('button', {
+ name: 'How to reproduce',
+ })
+ );
+
expect(
screen.getByText('This is the reproduction of a root cause.')
).toBeInTheDocument();
diff --git a/static/app/components/events/autofix/autofixRootCause.tsx b/static/app/components/events/autofix/autofixRootCause.tsx
index eb4dfb17c47589..fdc4b387244840 100644
--- a/static/app/components/events/autofix/autofixRootCause.tsx
+++ b/static/app/components/events/autofix/autofixRootCause.tsx
@@ -6,26 +6,26 @@ import {AnimatePresence, type AnimationProps, motion} from 'framer-motion';
import {addErrorMessage} from 'sentry/actionCreators/indicator';
import Alert from 'sentry/components/alert';
import {Button} from 'sentry/components/button';
+import ClippedBox from 'sentry/components/clippedBox';
import {CodeSnippet} from 'sentry/components/codeSnippet';
+import {ExpandableInsightContext} from 'sentry/components/events/autofix/autofixInsightCards';
import {AutofixShowMore} from 'sentry/components/events/autofix/autofixShowMore';
import {
type AutofixRepository,
type AutofixRootCauseCodeContext,
- type AutofixRootCauseCodeContextSnippet,
type AutofixRootCauseData,
type AutofixRootCauseSelection,
AutofixStepType,
+ type CodeSnippetContext,
} from 'sentry/components/events/autofix/types';
import {
type AutofixResponse,
makeAutofixQueryKey,
} from 'sentry/components/events/autofix/useAutofix';
-import TextArea from 'sentry/components/forms/controls/textarea';
import InteractionStateLayer from 'sentry/components/interactionStateLayer';
import ExternalLink from 'sentry/components/links/externalLink';
import {Tooltip} from 'sentry/components/tooltip';
-import {IconChevron} from 'sentry/icons';
-import {t, tn} from 'sentry/locale';
+import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import {getFileExtension} from 'sentry/utils/fileExtension';
import {getIntegrationIcon} from 'sentry/utils/integrationUtil';
@@ -43,14 +43,14 @@ type AutofixRootCauseProps = {
runId: string;
};
-const animationProps: AnimationProps = {
+const contentAnimationProps: AnimationProps = {
exit: {opacity: 0},
initial: {opacity: 0},
animate: {opacity: 1},
transition: testableTransition({duration: 0.3}),
};
-function useSelectCause({groupId, runId}: {groupId: string; runId: string}) {
+export function useSelectCause({groupId, runId}: {groupId: string; runId: string}) {
const api = useApi();
const queryClient = useQueryClient();
@@ -153,24 +153,32 @@ function getLinesToHighlight(suggestedFix: AutofixRootCauseCodeContext): number[
return lineNumbersToHighlight;
}
+export function replaceHeadersWithBold(markdown: string) {
+ const headerRegex = /^(#{1,6})\s+(.*)$/gm;
+ const boldMarkdown = markdown.replace(headerRegex, (_match, _hashes, content) => {
+ return ` **${content}** `;
+ });
+
+ return boldMarkdown;
+}
+
function RootCauseDescription({cause}: {cause: AutofixRootCauseData}) {
return (
{cause.reproduction && (
-
- {t('How to reproduce this root cause')}
-
-
+
+
+
)}
@@ -188,7 +196,7 @@ function RootCauseContent({
{selected && (
-
+
{children}
)}
@@ -197,14 +205,16 @@ function RootCauseContent({
);
}
-function SuggestedFixSnippet({
+export function SuggestedFixSnippet({
snippet,
linesToHighlight,
repos,
+ icon,
}: {
linesToHighlight: number[];
repos: AutofixRepository[];
- snippet: AutofixRootCauseCodeContextSnippet;
+ snippet: CodeSnippetContext;
+ icon?: React.ReactNode;
}) {
function getSourceLink() {
if (!repos) return undefined;
@@ -215,16 +225,17 @@ function SuggestedFixSnippet({
return `${repo.url}/blob/${repo.default_branch}/${snippet.file_path}`;
}
const extension = getFileExtension(snippet.file_path);
- const lanugage = extension ? getPrismLanguage(extension) : undefined;
+ const language = extension ? getPrismLanguage(extension) : undefined;
const sourceLink = getSourceLink();
return (
{snippet.snippet}
@@ -245,8 +256,6 @@ function CauseOption({
cause,
selected,
setSelectedId,
- runId,
- groupId,
repos,
}: {
cause: AutofixRootCauseData;
@@ -256,41 +265,21 @@ function CauseOption({
selected: boolean;
setSelectedId: (id: string) => void;
}) {
- const {isLoading, mutate: handleSelectFix} = useSelectCause({groupId, runId});
-
return (
setSelectedId(cause.id)}>
{!selected && }
-
- handleSelectFix({causeId: cause.id})}
- busy={isLoading}
- analyticsEventName="Autofix: Root Cause Fix Selected"
- analyticsEventKey="autofix.root_cause_fix_selected"
- analyticsParams={{group_id: groupId}}
- >
- {t('Find a Fix')}
-
- }
- aria-label={t('Select root cause')}
- aria-expanded={selected}
- size="zero"
- borderless
- style={{marginLeft: 8}}
- />
-
-
+
+
+
);
@@ -307,74 +296,15 @@ function SelectedRootCauseOption({
}) {
return (
-
-
-
-
- );
-}
-
-function ProvideYourOwn({
- selected,
- setSelectedId,
- groupId,
- runId,
-}: {
- groupId: string;
- runId: string;
- selected: boolean;
- setSelectedId: (id: string) => void;
-}) {
- const [text, setText] = useState('');
- const {isLoading, mutate: handleSelectFix} = useSelectCause({groupId, runId});
-
- return (
- setSelectedId('custom')}>
- {!selected && }
-
- {t('Provide your own')}
- }
- aria-label={t('Provide your own root cause')}
- aria-expanded={selected}
- size="zero"
- borderless
- />
-
-
- setText(e.target.value)}
- autoFocus
- autosize
- placeholder={t(
- 'This error seems to be caused by ... go look at path/file to make sure it does …'
- )}
- />
-
- handleSelectFix({customRootCause: text})}
- disabled={!text}
- busy={isLoading}
- analyticsEventName="Autofix: Root Cause Custom Cause Provided"
- analyticsEventKey="autofix.root_cause_custom_cause_provided"
- analyticsParams={{group_id: groupId}}
- aria-describedby="continue-custom-root-cause"
- id="continue-custom-root-cause"
- >
- {t('Find a Fix')}
-
-
-
+
+
+
+
);
}
@@ -387,14 +317,14 @@ function AutofixRootCauseDisplay({
repos,
}: AutofixRootCauseProps) {
const [selectedId, setSelectedId] = useState(() => causes[0].id);
- const {isLoading, mutate: handleSelectFix} = useSelectCause({groupId, runId});
+ const {isPending, mutate: handleSelectFix} = useSelectCause({groupId, runId});
if (rootCauseSelection) {
if ('custom_root_cause' in rootCauseSelection) {
return (
- {t('Custom Response Provided')}
+ {t('Custom Root Cause')}
{rootCauseSelection.custom_root_cause}
@@ -411,61 +341,58 @@ function AutofixRootCauseDisplay({
return (
-
- {otherCauses.length > 0 && (
-
- {otherCauses.map(cause => (
-
-
-
+
+ {otherCauses.length > 0 && (
+
+ {otherCauses.map(cause => (
+
+
+
+ handleSelectFix({causeId: cause.id})}
+ busy={isPending}
+ analyticsEventName="Autofix: Root Cause Fix Re-Selected"
+ analyticsEventKey="autofix.root_cause_fix_selected"
+ analyticsParams={{group_id: groupId}}
+ >
+ {t('Fix This Instead')}
+
+
+
+
- handleSelectFix({causeId: cause.id})}
- busy={isLoading}
- analyticsEventName="Autofix: Root Cause Fix Re-Selected"
- analyticsEventKey="autofix.root_cause_fix_selected"
- analyticsParams={{group_id: groupId}}
- >
- {t('Fix This Instead')}
-
-
-
-
-
-
- ))}
-
- )}
+
+
+
+
+ ))}
+
+ )}
+
);
}
return (
-
-
- {tn(
- 'Sentry has identified %s potential root cause. You may select the presented root cause or provide your own.',
- 'Sentry has identified %s potential root causes. You may select one of the presented root causes or provide your own.',
- causes.length
- )}
-
-
-
+
+
+
{causes.map(cause => (
))}
-
-
-
-
+
+
+
);
}
+const cardAnimationProps: AnimationProps = {
+ exit: {opacity: 0},
+ initial: {opacity: 0, y: 20},
+ animate: {opacity: 1, y: 0},
+ transition: testableTransition({duration: 0.3}),
+};
+
export function AutofixRootCause(props: AutofixRootCauseProps) {
if (props.causes.length === 0) {
return (
@@ -500,7 +428,13 @@ export function AutofixRootCause(props: AutofixRootCauseProps) {
);
}
- return ;
+ return (
+
+
+
+
+
+ );
}
export function AutofixRootCauseCodeContexts({
@@ -515,7 +449,7 @@ export function AutofixRootCauseCodeContexts({
@@ -539,31 +473,29 @@ const NoCausesPadding = styled('div')`
padding: 0 ${space(2)};
`;
-const CausesContainer = styled('div')``;
+const CausesContainer = styled('div')`
+ border: 1px solid ${p => p.theme.innerBorder};
+ border-radius: ${p => p.theme.borderRadius};
+ overflow: hidden;
+ box-shadow: ${p => p.theme.dropShadowHeavy};
+`;
-const CausesHeader = styled('div')`
- padding: 0 ${space(2)};
+const PotentialCausesContainer = styled(CausesContainer)`
+ border: 1px solid ${p => p.theme.alert.info.background};
`;
const OptionsPadding = styled('div')`
- padding: ${space(2)};
-`;
-const OptionsWrapper = styled('div')`
- border: 1px solid ${p => p.theme.innerBorder};
- border-radius: ${p => p.theme.borderRadius};
- overflow: hidden;
- box-shadow: ${p => p.theme.dropShadowMedium};
+ padding-left: ${space(1)};
+ padding-right: ${space(1)};
+ padding-top: ${space(1)};
`;
const RootCauseOption = styled('div')<{selected: boolean}>`
- position: relative;
- padding: ${space(2)};
background: ${p => (p.selected ? p.theme.background : p.theme.backgroundElevated)};
cursor: ${p => (p.selected ? 'default' : 'pointer')};
-
- :not(:first-child) {
- border-top: 1px solid ${p => p.theme.innerBorder};
- }
+ padding-top: ${space(1)};
+ padding-left: ${space(2)};
+ padding-right: ${space(2)};
`;
const RootCauseOptionHeader = styled('div')`
@@ -575,6 +507,7 @@ const RootCauseOptionHeader = styled('div')`
const Title = styled('div')`
font-weight: ${p => p.theme.fontWeightBold};
+ font-size: ${p => p.theme.fontSizeLarge};
`;
const CauseDescription = styled('div')`
@@ -582,18 +515,9 @@ const CauseDescription = styled('div')`
margin-top: ${space(1)};
`;
-const CauseReproductionHeader = styled('div')`
- font-weight: ${p => p.theme.fontWeightBold};
- margin-top: ${space(1)};
-`;
-
const SuggestedFixWrapper = styled('div')`
- padding: ${space(2)};
- border: 1px solid ${p => p.theme.alert.info.border};
- background-color: ${p => p.theme.alert.info.backgroundLight};
- border-radius: ${p => p.theme.borderRadius};
margin-top: ${space(1)};
-
+ margin-bottom: ${space(4)};
p {
margin: ${space(1)} 0 0 0;
}
@@ -625,23 +549,8 @@ const ContentWrapper = styled(motion.div)<{selected: boolean}>`
const AnimationWrapper = styled(motion.div)``;
-const CustomTextArea = styled(TextArea)`
- margin-top: ${space(2)};
-`;
-
-const OptionFooter = styled('div')`
- display: flex;
- justify-content: flex-end;
- margin-top: ${space(2)};
-`;
-
const CustomRootCausePadding = styled('div')`
- padding: 0 ${space(2)} ${space(2)} ${space(2)};
-`;
-
-const RootCauseOptionsRow = styled('div')`
- display: flex;
- flex-direction: row;
+ padding: ${space(2)} ${space(2)} ${space(2)} ${space(2)};
`;
const fadeIn = keyframes`
@@ -681,3 +590,8 @@ const CodeLinkWrapper = styled('div')`
const CodeSnippetWrapper = styled('div')`
position: relative;
`;
+
+const HeaderText = styled('div')`
+ font-weight: bold;
+ font-size: 1.2em;
+`;
diff --git a/static/app/components/events/autofix/autofixSteps.tsx b/static/app/components/events/autofix/autofixSteps.tsx
index 79ae7ffcf8548f..d2f91ac2aae450 100644
--- a/static/app/components/events/autofix/autofixSteps.tsx
+++ b/static/app/components/events/autofix/autofixSteps.tsx
@@ -1,92 +1,39 @@
import {Fragment, useEffect, useRef, useState} from 'react';
import styled from '@emotion/styled';
+import {AnimatePresence, type AnimationProps, motion} from 'framer-motion';
-import UserAvatar from 'sentry/components/avatar/userAvatar';
-import {Button} from 'sentry/components/button';
-import {DateTime} from 'sentry/components/dateTime';
import {AutofixChanges} from 'sentry/components/events/autofix/autofixChanges';
-import {AutofixInputField} from 'sentry/components/events/autofix/autofixInputField';
-import {AutofixRootCause} from 'sentry/components/events/autofix/autofixRootCause';
+import AutofixInsightCards from 'sentry/components/events/autofix/autofixInsightCards';
+import AutofixMessageBox from 'sentry/components/events/autofix/autofixMessageBox';
+import {
+ AutofixRootCause,
+ useSelectCause,
+} from 'sentry/components/events/autofix/autofixRootCause';
import {
type AutofixData,
type AutofixProgressItem,
type AutofixRepository,
type AutofixStep,
AutofixStepType,
- type AutofixUserResponseStep,
} from 'sentry/components/events/autofix/types';
-import {useAutofixData} from 'sentry/components/events/autofix/useAutofix';
-import LoadingIndicator from 'sentry/components/loadingIndicator';
-import Panel from 'sentry/components/panels/panel';
-import {
- IconCheckmark,
- IconChevron,
- IconClose,
- IconCode,
- IconFatal,
- IconQuestion,
- IconSad,
-} from 'sentry/icons';
-import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
-import marked, {singleLineRenderer} from 'sentry/utils/marked';
-import usePrevious from 'sentry/utils/usePrevious';
-
-function StepIcon({step}: {step: AutofixStep}) {
- if (step.type === AutofixStepType.CHANGES) {
- return ;
- }
-
- if (step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS) {
- if (step.causes?.length === 0) {
- return ;
- }
- return step.selection ? (
-
- ) : (
-
- );
- }
-
- switch (step.status) {
- case 'PROCESSING':
- return ;
- case 'CANCELLED':
- return ;
- case 'ERROR':
- return ;
- case 'COMPLETED':
- return ;
- default:
- return null;
- }
-}
-
-function stepShouldBeginExpanded(step: AutofixStep, isLastStep?: boolean) {
- if (isLastStep) {
- return true;
- }
-
- if (step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS) {
- return step.selection ? false : true;
- }
-
- return step.status !== 'COMPLETED';
-}
-
+import testableTransition from 'sentry/utils/testableTransition';
+
+const animationProps: AnimationProps = {
+ exit: {opacity: 0},
+ initial: {opacity: 0},
+ animate: {opacity: 1},
+ transition: testableTransition({duration: 0.3}),
+};
interface StepProps {
groupId: string;
+ hasErroredStepBefore: boolean;
+ hasStepAbove: boolean;
+ hasStepBelow: boolean;
onRetry: () => void;
repos: AutofixRepository[];
runId: string;
step: AutofixStep;
- isChild?: boolean;
- isLastStep?: boolean;
- stepNumber?: number;
-}
-
-interface UserStepProps extends StepProps {
- step: AutofixUserResponseStep;
}
interface AutofixStepsProps {
@@ -111,417 +58,218 @@ function replaceHeadersWithBold(markdown: string) {
return boldMarkdown;
}
-function Progress({
- progress,
- groupId,
- runId,
- onRetry,
- repos,
-}: {
- groupId: string;
- onRetry: () => void;
- progress: AutofixProgressItem | AutofixStep;
- repos: AutofixRepository[];
- runId: string;
-}) {
- if (isProgressLog(progress)) {
- const html = progress.message.includes('\n')
- ? marked(replaceHeadersWithBold(progress.message), {
- breaks: true,
- gfm: true,
- })
- : singleLineRenderer(replaceHeadersWithBold(progress.message), {
- breaks: true,
- gfm: true,
- });
-
- return (
-
-
-
-
- );
- }
-
- return (
-
-
-
- );
-}
-
-export function ExpandableStep({
+export function Step({
step,
- isChild,
groupId,
runId,
- isLastStep,
onRetry,
repos,
+ hasStepBelow,
+ hasStepAbove,
+ hasErroredStepBefore,
}: StepProps) {
- const previousIsLastStep = usePrevious(isLastStep);
- const previousStepStatus = usePrevious(step.status);
const isActive = step.status !== 'PENDING' && step.status !== 'CANCELLED';
- const [isExpanded, setIsExpanded] = useState(() =>
- stepShouldBeginExpanded(step, isLastStep)
- );
-
- useEffect(() => {
- if (
- (previousStepStatus &&
- previousStepStatus !== step.status &&
- step.status === 'COMPLETED') ||
- (previousIsLastStep && !isLastStep)
- ) {
- setIsExpanded(false);
- }
- }, [previousStepStatus, step.status, previousIsLastStep, isLastStep]);
-
- const logs: AutofixProgressItem[] = step.progress?.filter(isProgressLog) ?? [];
- const activeLog = step.completedMessage ?? logs.at(-1)?.message ?? null;
- const hasContent = Boolean(
- step.completedMessage ||
- step.progress?.length ||
- step.type !== AutofixStepType.DEFAULT
- );
- const canToggle = Boolean(isActive && hasContent);
return (
- {
- if (canToggle) {
- setIsExpanded(value => !value);
- }
- }}
- >
-
-
-
-
-
- {activeLog && !isExpanded && (
-
- )}
-
-
- {canToggle ? (
- }
- aria-label={t('Toggle step details')}
- aria-expanded={isExpanded}
- size="zero"
- borderless
- />
- ) : null}
-
-
- {isExpanded && (
-
- {step.completedMessage && {step.completedMessage} }
- {step.progress && step.progress.length > 0 ? (
-
- {step.progress.map((progress, i) => (
-
+
+
+
+ {step.type === AutofixStepType.DEFAULT && (
+
+ )}
+ {step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS && (
+
- ))}
-
- ) : null}
- {step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS && (
-
- )}
- {step.type === AutofixStepType.CHANGES && (
-
- )}
-
- )}
+ )}
+ {step.type === AutofixStepType.CHANGES && (
+
+ )}
+ {hasErroredStepBefore && hasStepBelow && (
+
+ Autofix encountered an error.
+
+ Restarting step from scratch...
+
+ )}
+
+
+
+
);
}
-function UserStep({step, groupId}: UserStepProps) {
- const data = useAutofixData({groupId});
- const user = data?.users?.[step.user_id];
+function useInView(ref: HTMLElement | null) {
+ const [inView, setInView] = useState(false);
- return (
-
-
-
-
- {user?.name}
- {step.text}
-
-
-
- );
-}
-
-function Step({step, groupId, runId, onRetry, stepNumber, isLastStep, repos}: StepProps) {
- if (step.type === AutofixStepType.USER_RESPONSE) {
- return (
-
- );
- }
-
- return (
-
- );
+ useEffect(() => {
+ const observer = new IntersectionObserver(([entry]) => {
+ setInView(entry.isIntersecting);
+ });
+
+ if (!ref)
+ return () => {
+ observer.disconnect();
+ };
+
+ observer.observe(ref);
+ return () => {
+ observer.disconnect();
+ };
+ }, [ref]);
+ return inView;
}
export function AutofixSteps({data, groupId, runId, onRetry}: AutofixStepsProps) {
const steps = data.steps;
const repos = data.repositories;
+ const stepsRef = useRef<(HTMLDivElement | null)[]>([]);
+
+ const {mutate: handleSelectFix} = useSelectCause({groupId, runId});
+ const selectRootCause = (text: string) => {
+ if (text.length > 0) {
+ handleSelectFix({customRootCause: text});
+ } else {
+ if (!steps) return;
+ const step = steps[steps.length - 1];
+ if (step.type !== AutofixStepType.ROOT_CAUSE_ANALYSIS) return;
+ const cause = step.causes[0];
+ const id = cause.id;
+ handleSelectFix({causeId: id});
+ }
+ };
+
+ const lastStepVisible = useInView(
+ stepsRef.current.length ? stepsRef.current[stepsRef.current.length - 1] : null
+ );
+
if (!steps) {
return null;
}
- const showInputField =
- data.options?.iterative_feedback && steps.at(-1)?.type === AutofixStepType.CHANGES;
+ const lastStep = steps[steps.length - 1];
+ const logs: AutofixProgressItem[] = lastStep.progress?.filter(isProgressLog) ?? [];
+ const activeLog =
+ lastStep.completedMessage ?? replaceHeadersWithBold(logs.at(-1)?.message ?? '') ?? '';
+
+ const isRootCauseSelectionStep =
+ lastStep.type === AutofixStepType.ROOT_CAUSE_ANALYSIS &&
+ lastStep.status === 'COMPLETED';
+ const areCodeChangesShowing =
+ lastStep.type === AutofixStepType.CHANGES && lastStep.status === 'COMPLETED';
+ const disabled = areCodeChangesShowing ? true : false;
+
+ const previousStep = steps.length > 2 ? steps[steps.length - 2] : null;
+ const previousStepErrored =
+ previousStep !== null &&
+ previousStep?.type === lastStep.type &&
+ previousStep.status === 'ERROR';
+
+ const scrollToMatchingStep = () => {
+ const matchingStepIndex = steps.findIndex(step => step.type === lastStep.type);
+ if (matchingStepIndex !== -1 && stepsRef.current[matchingStepIndex]) {
+ stepsRef.current[matchingStepIndex]?.scrollIntoView({behavior: 'smooth'});
+ }
+ };
return (
- {steps.map((step, index) => (
-
- ))}
- {showInputField &&
}
+
+ {steps.map((step, index) => (
+ (stepsRef.current[index] = el)} key={step.id}>
+ 0}
+ groupId={groupId}
+ runId={runId}
+ onRetry={onRetry}
+ repos={repos}
+ hasErroredStepBefore={previousStepErrored}
+ />
+
+ ))}
+
+
+
);
}
-const StepCard = styled(Panel)<{active?: boolean}>`
- opacity: ${p => (p.active ? 1 : 0.6)};
+const StepMessage = styled('div')`
overflow: hidden;
-
- :last-child {
- margin-bottom: 0;
- }
-`;
-
-const StepHeader = styled('div')<{canToggle: boolean; isChild?: boolean}>`
- display: flex;
- justify-content: space-between;
- align-items: center;
padding: ${space(2)};
- gap: ${space(1)};
- font-size: ${p => p.theme.fontSizeMedium};
- font-family: ${p => p.theme.text.family};
- cursor: ${p => (p.canToggle ? 'pointer' : 'default')};
-
- &:last-child {
- padding-bottom: ${space(2)};
- }
-`;
-
-const UserStepContent = styled('div')`
- display: flex;
- align-items: flex-start;
- gap: ${space(1)};
- padding: ${space(2)};
-`;
-
-const UserStepName = styled('div')`
- font-weight: bold;
-`;
-
-const UserStepText = styled('p')`
- margin: 0;
-`;
-
-const UserTextContentContainer = styled('div')`
- display: flex;
- flex-direction: column;
- gap: ${space(0.5)};
-`;
-
-const StepHeaderLeft = styled('div')`
- display: flex;
- align-items: center;
- flex: 1;
- overflow: hidden;
-`;
-
-const StepHeaderDescription = styled('div')`
- font-size: ${p => p.theme.fontSizeSmall};
color: ${p => p.theme.subText};
- padding: 0 ${space(2)} 0 ${space(1)};
- margin-left: ${space(1)};
- border-left: 1px solid ${p => p.theme.border};
- flex-grow: 1;
- ${p => p.theme.overflowEllipsis};
+ justify-content: center;
+ text-align: center;
`;
-const StepIconContainer = styled('div')`
- display: flex;
- align-items: center;
- margin-right: ${space(1.5)};
+const StepsContainer = styled('div')`
+ margin-bottom: 13em;
`;
-const StepHeaderRight = styled('div')`
- display: flex;
- align-items: center;
- gap: ${space(1)};
-`;
-
-const StepTitle = styled('div')`
- font-weight: ${p => p.theme.fontWeightBold};
- white-space: nowrap;
- display: flex;
- flex-shrink: 1;
- align-items: center;
- flex-grow: 0;
-
- span {
- margin-right: ${space(1)};
- }
-`;
-
-const StepBody = styled('p')`
- padding: 0 ${space(2)} ${space(2)} ${space(2)};
- margin: -${space(1)} 0 0 0;
-`;
+const StepCard = styled('div')<{active?: boolean}>`
+ opacity: ${p => (p.active ? 1 : 0.6)};
+ overflow: hidden;
-const ProcessingStatusIndicator = styled(LoadingIndicator)`
- && {
- margin: 0;
- height: 14px;
- width: 14px;
+ :last-child {
+ margin-bottom: 0;
}
`;
-const ProgressContainer = styled('div')`
- background: ${p => p.theme.backgroundSecondary};
- border-top: 1px solid ${p => p.theme.border};
- padding: ${space(2)};
+const ContentWrapper = styled(motion.div)`
display: grid;
- gap: ${space(1)} ${space(2)};
- grid-template-columns: auto 1fr;
- font-size: ${p => p.theme.fontSizeSmall};
- font-family: ${p => p.theme.text.familyMono};
-`;
-
-const ProgressStepContainer = styled('div')`
- grid-column: 1/-1;
-`;
-
-function LogComponent({html}: {html: string}) {
- const [expanded, setExpanded] = useState(false);
- const [isExpandable, setIsExpandable] = useState(false);
- const logRef = useRef(null);
-
- useEffect(() => {
- const checkExpandable = () => {
- if (logRef.current) {
- const {scrollHeight, clientHeight} = logRef.current;
- setIsExpandable(scrollHeight > clientHeight + 16);
- }
- };
-
- checkExpandable();
- window.addEventListener('resize', checkExpandable);
- return () => window.removeEventListener('resize', checkExpandable);
- }, [html]);
-
- const toggleExpand = () => {
- setExpanded(oldState => !oldState);
- };
-
- return (
-
-
- {isExpandable && (
- }
- aria-label={t('Toggle step details')}
- aria-expanded={expanded}
- size="zero"
- borderless
- onClick={toggleExpand}
- />
- )}
-
- );
-}
-
-const LogText = styled('div')<{expanded: boolean; isExpandable: boolean}>`
- overflow-x: auto;
- display: -webkit-box;
- -webkit-line-clamp: ${props => (props.expanded ? 'unset' : '2')};
- -webkit-box-orient: vertical;
- overflow-y: hidden;
- max-height: ${props => (props.expanded ? 'none' : '3em')};
- flex: 1;
+ grid-template-rows: 1fr;
+ transition: grid-template-rows 300ms;
+ will-change: grid-template-rows;
+
+ > div {
+ /* So that focused element outlines don't get cut off */
+ padding: 0 1px;
+ overflow: hidden;
+ }
`;
-const ExpandableLogRow = styled('div')`
- overflow-x: scroll;
- display: flex;
- flex-direction: row;
- align-items: flex-start;
- width: 100%;
-`;
+const AnimationWrapper = styled(motion.div)``;
diff --git a/static/app/components/events/autofix/index.spec.tsx b/static/app/components/events/autofix/index.spec.tsx
index 4c9dfa9034ed80..20f815939161a7 100644
--- a/static/app/components/events/autofix/index.spec.tsx
+++ b/static/app/components/events/autofix/index.spec.tsx
@@ -1,13 +1,9 @@
-import {AutofixDataFixture} from 'sentry-fixture/autofixData';
-import {AutofixProgressItemFixture} from 'sentry-fixture/autofixProgressItem';
-import {AutofixStepFixture} from 'sentry-fixture/autofixStep';
import {EventFixture} from 'sentry-fixture/event';
import {GroupFixture} from 'sentry-fixture/group';
-import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+import {render, screen} from 'sentry-test/reactTestingLibrary';
import {Autofix} from 'sentry/components/events/autofix';
-import {AutofixStepType} from 'sentry/components/events/autofix/types';
const group = GroupFixture();
const event = EventFixture();
@@ -36,12 +32,7 @@ describe('Autofix', () => {
});
});
- it('renders the Banner component when autofixData is null', () => {
- MockApiClient.addMockResponse({
- url: `/issues/${group.id}/autofix/`,
- body: null,
- });
-
+ it('renders the Banner component', () => {
render( );
expect(screen.getByText('Try Autofix')).toBeInTheDocument();
@@ -86,146 +77,6 @@ describe('Autofix', () => {
render( );
- expect(
- await screen.findByRole('button', {name: 'Get root causes'})
- ).toBeInTheDocument();
- });
-
- it('renders steps with logs', async () => {
- const autofixData = AutofixDataFixture({
- steps: [
- AutofixStepFixture({
- id: '1',
- status: 'PROCESSING',
- progress: [
- AutofixProgressItemFixture({message: 'First log message'}),
- AutofixProgressItemFixture({message: 'Second log message'}),
- ],
- }),
- ],
- });
-
- MockApiClient.addMockResponse({
- url: `/issues/${group.id}/autofix/`,
- body: {autofix: autofixData},
- });
-
- render( );
-
- // Logs should be visible
- expect(await screen.findByText('First log message')).toBeInTheDocument();
- expect(screen.getByText('Second log message')).toBeInTheDocument();
-
- // Toggling step hides old logs
- await userEvent.click(screen.getByRole('button', {name: 'Toggle step details'}));
- expect(screen.queryByText('First log message')).not.toBeInTheDocument();
- // Should show latest log preview in header
- expect(screen.getByText('Second log message')).toBeInTheDocument();
- });
-
- it('can reset and try again while running', async () => {
- const autofixData = AutofixDataFixture({
- steps: [AutofixStepFixture()],
- });
-
- MockApiClient.addMockResponse({
- url: `/issues/${group.id}/autofix/`,
- body: {autofix: autofixData},
- });
-
- const triggerAutofixMock = MockApiClient.addMockResponse({
- url: `/issues/${group.id}/autofix/`,
- method: 'POST',
- });
-
- render( );
-
- await userEvent.click(await screen.findByRole('button', {name: 'Start Over'}));
-
- expect(screen.getByText('Try Autofix')).toBeInTheDocument();
-
- // Clicking the fix button should show the initial state "Starting Autofix..." and call the api
- await userEvent.click(screen.getByRole('button', {name: 'Get root causes'}));
- expect(await screen.findByText('Starting Autofix...')).toBeInTheDocument();
- expect(triggerAutofixMock).toHaveBeenCalledTimes(1);
- });
-
- it('renders the root cause component when changes step is present', async () => {
- MockApiClient.addMockResponse({
- url: `/issues/${group.id}/autofix/`,
- body: {
- autofix: AutofixDataFixture({
- steps: [
- AutofixStepFixture({
- type: AutofixStepType.ROOT_CAUSE_ANALYSIS,
- title: 'Root Cause',
- causes: [
- {
- actionability: 1,
- id: 'cause-1',
- likelihood: 1,
- title: 'Test Cause Title',
- description: 'Test Cause Description',
- code_context: [
- {
- id: 'fix-1',
- title: 'Test Fix Title',
- description: 'Test Fix Description',
- snippet: {
- file_path: 'test/file/path.py',
- snippet: 'two = 1 + 1',
- repo_name: 'owner/repo',
- },
- },
- ],
- },
- ],
- }),
- ],
- }),
- },
- });
-
- render( );
-
- expect(await screen.findByText('Root Cause')).toBeInTheDocument();
- expect(
- screen.getByText(/Sentry has identified 1 potential root cause/)
- ).toBeInTheDocument();
- expect(screen.getByText('Test Cause Title')).toBeInTheDocument();
- expect(screen.getByText('Test Cause Description')).toBeInTheDocument();
- });
-
- it('renders the diff component when changes step is present', async () => {
- MockApiClient.addMockResponse({
- url: `/issues/${group.id}/autofix/`,
- body: {
- autofix: AutofixDataFixture({
- steps: [
- AutofixStepFixture({
- type: AutofixStepType.CHANGES,
- title: 'Review Fix',
- changes: [
- {
- title: 'Test PR Title',
- description: 'Test PR Description',
- repo_external_id: '1',
- repo_name: 'getsentry/sentry',
- diff: [],
- },
- ],
- }),
- ],
- }),
- },
- });
-
- render( );
-
- expect(await screen.findByText('Review Fix')).toBeInTheDocument();
- expect(screen.getByText('getsentry/sentry')).toBeInTheDocument();
- expect(
- screen.getByRole('button', {name: 'Create a Pull Request'})
- ).toBeInTheDocument();
+ expect(await screen.findByRole('button', {name: 'Open Autofix'})).toBeInTheDocument();
});
});
diff --git a/static/app/components/events/autofix/index.tsx b/static/app/components/events/autofix/index.tsx
index ea981d66a84842..e80a40cb7c7980 100644
--- a/static/app/components/events/autofix/index.tsx
+++ b/static/app/components/events/autofix/index.tsx
@@ -1,11 +1,8 @@
import ErrorBoundary from 'sentry/components/errorBoundary';
import {AutofixBanner} from 'sentry/components/events/autofix/autofixBanner';
-import {AutofixCard} from 'sentry/components/events/autofix/autofixCard';
import type {GroupWithAutofix} from 'sentry/components/events/autofix/types';
-import {useAiAutofix} from 'sentry/components/events/autofix/useAutofix';
import {useAutofixSetup} from 'sentry/components/events/autofix/useAutofixSetup';
import type {Event} from 'sentry/types/event';
-import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
interface Props {
event: Event;
@@ -13,30 +10,18 @@ interface Props {
}
export function Autofix({event, group}: Props) {
- const {autofixData, triggerAutofix, reset} = useAiAutofix(group, event);
-
const {canStartAutofix} = useAutofixSetup({
groupId: group.id,
});
- useRouteAnalyticsParams({
- autofix_status: autofixData?.status ?? 'none',
- });
-
return (
-
- {autofixData ? (
-
- ) : (
-
- )}
-
+
);
}
diff --git a/static/app/components/events/autofix/types.ts b/static/app/components/events/autofix/types.ts
index edf88f2e4b08b5..c5da8c4c5d06ff 100644
--- a/static/app/components/events/autofix/types.ts
+++ b/static/app/components/events/autofix/types.ts
@@ -18,7 +18,6 @@ export enum AutofixStepType {
DEFAULT = 'default',
ROOT_CAUSE_ANALYSIS = 'root_cause_analysis',
CHANGES = 'changes',
- USER_RESPONSE = 'user_response',
}
export enum AutofixCodebaseIndexingStatus {
@@ -71,15 +70,11 @@ export type AutofixData = {
export type AutofixProgressItem = {
message: string;
timestamp: string;
- type: 'INFO' | 'WARNING' | 'ERROR' | 'NEED_MORE_INFORMATION' | 'USER_RESPONSE';
+ type: 'INFO' | 'WARNING' | 'ERROR' | 'NEED_MORE_INFORMATION';
data?: any;
};
-export type AutofixStep =
- | AutofixDefaultStep
- | AutofixRootCauseStep
- | AutofixChangesStep
- | AutofixUserResponseStep;
+export type AutofixStep = AutofixDefaultStep | AutofixRootCauseStep | AutofixChangesStep;
interface BaseStep {
id: string;
@@ -91,7 +86,41 @@ interface BaseStep {
completedMessage?: string;
}
+export type CodeSnippetContext = {
+ file_path: string;
+ repo_name: string;
+ snippet: string;
+};
+
+export type StacktraceContext = {
+ code_snippet: string;
+ col_no: number;
+ file_name: string;
+ function: string;
+ line_no: number;
+ repo_name: string;
+ vars_as_json: string;
+};
+
+export type BreadcrumbContext = {
+ body: string;
+ category: string;
+ data_as_json: string;
+ level: string;
+ type: string;
+};
+
+export type AutofixInsight = {
+ breadcrumb_context: BreadcrumbContext[];
+ codebase_context: CodeSnippetContext[];
+ error_message_context: string[];
+ insight: string;
+ justification: string;
+ stacktrace_context: StacktraceContext[];
+};
+
export interface AutofixDefaultStep extends BaseStep {
+ insights: AutofixInsight[];
type: AutofixStepType.DEFAULT;
}
@@ -124,31 +153,17 @@ export interface AutofixChangesStep extends BaseStep {
type: AutofixStepType.CHANGES;
}
-export interface AutofixUserResponseStep extends BaseStep {
- text: string;
- type: AutofixStepType.USER_RESPONSE;
- user_id: number;
-}
-
-export type AutofixRootCauseCodeContextSnippet = {
- file_path: string;
- repo_name: string;
- snippet: string;
-};
-
export type AutofixRootCauseCodeContext = {
description: string;
id: string;
title: string;
- snippet?: AutofixRootCauseCodeContextSnippet;
+ snippet?: CodeSnippetContext;
};
export type AutofixRootCauseData = {
- actionability: number;
code_context: AutofixRootCauseCodeContext[];
description: string;
id: string;
- likelihood: number;
title: string;
reproduction?: string;
};
diff --git a/static/app/components/events/autofix/useAutofix.tsx b/static/app/components/events/autofix/useAutofix.tsx
index daca0265716e57..4760ee7df25311 100644
--- a/static/app/components/events/autofix/useAutofix.tsx
+++ b/static/app/components/events/autofix/useAutofix.tsx
@@ -35,6 +35,7 @@ const makeInitialAutofixData = (): AutofixResponse => ({
index: 0,
status: 'PROCESSING',
title: 'Starting Autofix...',
+ insights: [],
progress: [],
},
],
@@ -56,6 +57,7 @@ const makeErrorAutofixData = (errorMessage: string): AutofixResponse => {
status: 'ERROR',
title: 'Something went wrong',
completedMessage: errorMessage,
+ insights: [],
progress: [],
},
];
@@ -88,8 +90,8 @@ export const useAiAutofix = (group: GroupWithAutofix, event: Event) => {
const {data: apiData} = useApiQuery(makeAutofixQueryKey(group.id), {
staleTime: 0,
retry: false,
- refetchInterval: data => {
- if (isPolling(data?.[0]?.autofix)) {
+ refetchInterval: query => {
+ if (isPolling(query.state.data?.[0]?.autofix)) {
return POLL_INTERVAL;
}
return false;
diff --git a/static/app/components/events/breadcrumbs/breadcrumbsDataSection.tsx b/static/app/components/events/breadcrumbs/breadcrumbsDataSection.tsx
index 967f16b30d58b2..e4c6e031e511a7 100644
--- a/static/app/components/events/breadcrumbs/breadcrumbsDataSection.tsx
+++ b/static/app/components/events/breadcrumbs/breadcrumbsDataSection.tsx
@@ -22,20 +22,13 @@ import {
BreadcrumbSort,
} from 'sentry/components/events/interfaces/breadcrumbs';
import useDrawer from 'sentry/components/globalDrawer';
-import {
- IconClock,
- IconEllipsis,
- IconMegaphone,
- IconSearch,
- IconTimer,
-} from 'sentry/icons';
+import {IconClock, IconEllipsis, IconSearch, IconTimer} from 'sentry/icons';
import {t, tct} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Event} from 'sentry/types/event';
import type {Group} from 'sentry/types/group';
import type {Project} from 'sentry/types/project';
import {trackAnalytics} from 'sentry/utils/analytics';
-import {useFeedbackForm} from 'sentry/utils/useFeedbackForm';
import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
import useOrganization from 'sentry/utils/useOrganization';
import {SectionKey} from 'sentry/views/issueDetails/streamline/context';
@@ -54,7 +47,6 @@ export default function BreadcrumbsDataSection({
}: BreadcrumbsDataSectionProps) {
const viewAllButtonRef = useRef(null);
const [container, setContainer] = useState(null);
- const openForm = useFeedbackForm();
const {closeDrawer, isDrawerOpen, openDrawer} = useDrawer();
const organization = useOrganization();
const [timeDisplay, setTimeDisplay] = useLocalStorageState(
@@ -125,24 +117,6 @@ export default function BreadcrumbsDataSection({
const actions = (
- {openForm && (
- }
- size={'xs'}
- onClick={() =>
- openForm({
- messagePlaceholder: t('How can we make breadcrumbs more useful to you?'),
- tags: {
- ['feedback.source']: 'issue_details_breadcrumbs',
- ['feedback.owner']: 'issues',
- },
- })
- }
- >
- {t('Give Feedback')}
-
- )}
}
diff --git a/static/app/components/events/breadcrumbs/breadcrumbsDrawer.tsx b/static/app/components/events/breadcrumbs/breadcrumbsDrawer.tsx
index f2b16699be1759..5d3d9520be0f4c 100644
--- a/static/app/components/events/breadcrumbs/breadcrumbsDrawer.tsx
+++ b/static/app/components/events/breadcrumbs/breadcrumbsDrawer.tsx
@@ -1,4 +1,4 @@
-import {useCallback, useMemo, useState} from 'react';
+import {useMemo, useState} from 'react';
import {useTheme} from '@emotion/react';
import styled from '@emotion/styled';
@@ -24,13 +24,14 @@ import {
NavigationCrumbs,
SearchInput,
ShortId,
-} from 'sentry/components/events/eventReplay/eventDrawer';
+} from 'sentry/components/events/eventDrawer';
import {
applyBreadcrumbSearch,
BREADCRUMB_SORT_LOCALSTORAGE_KEY,
BREADCRUMB_SORT_OPTIONS,
BreadcrumbSort,
} from 'sentry/components/events/interfaces/breadcrumbs';
+import useFocusControl from 'sentry/components/events/useFocusControl';
import {InputGroup} from 'sentry/components/inputGroup';
import {IconClock, IconFilter, IconSearch, IconSort, IconTimer} from 'sentry/icons';
import {t} from 'sentry/locale';
@@ -49,21 +50,6 @@ export const enum BreadcrumbControlOptions {
SORT = 'sort',
}
-function useFocusControl(initialFocusControl?: BreadcrumbControlOptions) {
- const [focusControl, setFocusControl] = useState(initialFocusControl);
- // If the focused control element is blurred, unset the state to remove styles
- // This will allow us to simulate :focus-visible on the button elements.
- const getFocusProps = useCallback(
- (option: BreadcrumbControlOptions) => {
- return option === focusControl
- ? {autoFocus: true, onBlur: () => setFocusControl(undefined)}
- : {};
- },
- [focusControl]
- );
- return {getFocusProps};
-}
-
interface BreadcrumbsDrawerProps {
breadcrumbs: EnhancedCrumb[];
event: Event;
diff --git a/static/app/components/events/breadcrumbs/utils.tsx b/static/app/components/events/breadcrumbs/utils.tsx
index 402112516da154..d14345302ef1e6 100644
--- a/static/app/components/events/breadcrumbs/utils.tsx
+++ b/static/app/components/events/breadcrumbs/utils.tsx
@@ -228,7 +228,7 @@ export function getEnhancedBreadcrumbs(event: Event): EnhancedCrumb[] {
}));
}
-function getBreadcrumbTitle(crumb: RawCrumb) {
+export function getBreadcrumbTitle(crumb: RawCrumb) {
if (crumb?.type === BreadcrumbType.DEFAULT) {
return crumb?.category ?? BREADCRUMB_TITLE_PLACEHOLDER.toLocaleLowerCase();
}
@@ -250,7 +250,7 @@ function getBreadcrumbTitle(crumb: RawCrumb) {
}
}
-function getBreadcrumbColorConfig(type?: BreadcrumbType): ColorConfig {
+export function getBreadcrumbColorConfig(type?: BreadcrumbType): ColorConfig {
switch (type) {
case BreadcrumbType.ERROR:
return {title: 'red400', icon: 'red400', iconBorder: 'red200'};
@@ -277,7 +277,7 @@ function getBreadcrumbColorConfig(type?: BreadcrumbType): ColorConfig {
}
}
-function getBreadcrumbFilter(type?: BreadcrumbType) {
+export function getBreadcrumbFilter(type?: BreadcrumbType) {
switch (type) {
case BreadcrumbType.USER:
case BreadcrumbType.UI:
@@ -311,7 +311,7 @@ function getBreadcrumbFilter(type?: BreadcrumbType) {
}
}
-function BreadcrumbIcon({type}: {type?: BreadcrumbType}) {
+export function BreadcrumbIcon({type}: {type?: BreadcrumbType}) {
switch (type) {
case BreadcrumbType.USER:
return ;
@@ -346,7 +346,7 @@ function BreadcrumbIcon({type}: {type?: BreadcrumbType}) {
}
}
-const BreadcrumbLevel = styled('div')<{level: BreadcrumbLevelType}>`
+export const BreadcrumbLevel = styled('div')<{level: BreadcrumbLevelType}>`
margin: 0 ${space(1)};
font-weight: normal;
font-size: ${p => p.theme.fontSizeSmall};
diff --git a/static/app/components/events/eventAttachmentActions.tsx b/static/app/components/events/eventAttachmentActions.tsx
index a6aa45d417e393..504e9eadb315dc 100644
--- a/static/app/components/events/eventAttachmentActions.tsx
+++ b/static/app/components/events/eventAttachmentActions.tsx
@@ -1,78 +1,90 @@
+import {Role} from 'sentry/components/acl/role';
import {Button, LinkButton} from 'sentry/components/button';
import ButtonBar from 'sentry/components/buttonBar';
import Confirm from 'sentry/components/confirm';
+import {hasInlineAttachmentRenderer} from 'sentry/components/events/attachmentViewers/previewAttachmentTypes';
import {IconDelete, IconDownload, IconShow} from 'sentry/icons';
import {t} from 'sentry/locale';
+import type {IssueAttachment} from 'sentry/types/group';
+import useOrganization from 'sentry/utils/useOrganization';
type Props = {
- attachmentId: string;
- onDelete: (attachmentId: string) => void;
- url: string | null;
- hasPreview?: boolean;
- onPreview?: (attachmentId: string) => void;
+ attachment: IssueAttachment;
+ onDelete: () => void;
+ projectSlug: string;
+ onPreviewClick?: () => void;
previewIsOpen?: boolean;
withPreviewButton?: boolean;
};
function EventAttachmentActions({
- url,
+ attachment,
+ projectSlug,
withPreviewButton,
- hasPreview,
previewIsOpen,
- onPreview,
+ onPreviewClick,
onDelete,
- attachmentId,
}: Props) {
- function handlePreview() {
- onPreview?.(attachmentId);
- }
+ const organization = useOrganization();
+ const url = `/api/0/projects/${organization.slug}/${projectSlug}/events/${attachment.event_id}/attachments/${attachment.id}/`;
+ const hasPreview = hasInlineAttachmentRenderer(attachment);
return (
-
- onDelete(attachmentId)}
- disabled={!url}
- >
- }
- aria-label={t('Delete')}
- disabled={!url}
- title={!url ? t('Insufficient permissions to delete attachments') : undefined}
- />
-
-
- }
- href={url ? `${url}?download=1` : ''}
- disabled={!url}
- title={!url ? t('Insufficient permissions to download attachments') : undefined}
- aria-label={t('Download')}
- />
-
- {withPreviewButton && (
- }
- onClick={handlePreview}
- title={
- !url
- ? t('Insufficient permissions to preview attachments')
- : !hasPreview
- ? t('This attachment cannot be previewed')
- : undefined
- }
- >
- {t('Preview')}
-
+
+ {({hasRole: hasAttachmentRole}) => (
+
+ {withPreviewButton && (
+ }
+ onClick={onPreviewClick}
+ title={
+ !hasAttachmentRole
+ ? t('Insufficient permissions to preview attachments')
+ : !hasPreview
+ ? t('This attachment cannot be previewed')
+ : undefined
+ }
+ >
+ {t('Preview')}
+
+ )}
+ }
+ href={hasAttachmentRole ? `${url}?download=1` : ''}
+ disabled={!hasAttachmentRole}
+ title={
+ hasAttachmentRole
+ ? t('Download')
+ : t('Insufficient permissions to download attachments')
+ }
+ aria-label={t('Download')}
+ />
+
+ }
+ aria-label={t('Delete')}
+ disabled={!hasAttachmentRole}
+ title={
+ hasAttachmentRole
+ ? t('Delete')
+ : t('Insufficient permissions to delete attachments')
+ }
+ />
+
+
)}
-
+
);
}
diff --git a/static/app/components/events/eventAttachments.spec.tsx b/static/app/components/events/eventAttachments.spec.tsx
index 8fefd5736d9dc8..2e8dcbe32c7ccd 100644
--- a/static/app/components/events/eventAttachments.spec.tsx
+++ b/static/app/components/events/eventAttachments.spec.tsx
@@ -1,5 +1,8 @@
+import {ConfigFixture} from 'sentry-fixture/config';
import {EventFixture} from 'sentry-fixture/event';
import {EventAttachmentFixture} from 'sentry-fixture/eventAttachment';
+import {GroupFixture} from 'sentry-fixture/group';
+import {UserFixture} from 'sentry-fixture/user';
import {initializeOrg} from 'sentry-test/initializeOrg';
import {
@@ -12,6 +15,7 @@ import {
} from 'sentry-test/reactTestingLibrary';
import {EventAttachments} from 'sentry/components/events/eventAttachments';
+import ConfigStore from 'sentry/stores/configStore';
describe('EventAttachments', function () {
const {router, organization, project} = initializeOrg({
@@ -20,17 +24,20 @@ describe('EventAttachments', function () {
orgRole: 'member',
attachmentsRole: 'member',
},
- } as any);
+ });
const event = EventFixture({metadata: {stripped_crash: false}});
+ const defaultUser = UserFixture();
const props = {
- projectSlug: project.slug,
+ group: undefined,
+ project: project,
event,
};
const attachmentsUrl = `/projects/${organization.slug}/${project.slug}/events/${event.id}/attachments/`;
beforeEach(() => {
+ ConfigStore.loadInitialData(ConfigFixture());
MockApiClient.clearMockResponses();
});
@@ -56,7 +63,7 @@ describe('EventAttachments', function () {
expect(screen.getByRole('link', {name: 'configure limit'})).toHaveAttribute(
'href',
- `/settings/org-slug/projects/${props.projectSlug}/security-and-privacy/`
+ `/settings/org-slug/projects/${project.slug}/security-and-privacy/`
);
expect(
@@ -93,7 +100,7 @@ describe('EventAttachments', function () {
orgRole: 'member',
attachmentsRole: 'admin',
},
- } as any);
+ });
const attachment = EventAttachmentFixture({
name: 'some_file.txt',
headers: {
@@ -140,7 +147,7 @@ describe('EventAttachments', function () {
});
MockApiClient.addMockResponse({
- url: '/projects/org-slug/project-slug/events/1/attachments/1/?download',
+ url: `/projects/${organization.slug}/${project.slug}/events/${event.id}/attachments/1/?download`,
body: 'file contents',
});
@@ -187,4 +194,39 @@ describe('EventAttachments', function () {
expect(screen.queryByTestId('pic_1.png')).not.toBeInTheDocument();
});
});
+
+ it('can open the group attachments drawer', async function () {
+ const group = GroupFixture();
+ const attachment1 = EventAttachmentFixture();
+ MockApiClient.addMockResponse({
+ url: attachmentsUrl,
+ body: [attachment1],
+ });
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/issues/${group.id}/attachments/`,
+ body: [attachment1],
+ });
+
+ // Enable streamlined UI
+ ConfigStore.set(
+ 'user',
+ UserFixture({
+ ...defaultUser,
+ options: {
+ ...defaultUser.options,
+ prefersIssueDetailsStreamlinedUI: true,
+ },
+ })
+ );
+
+ render( , {router, organization});
+
+ expect(await screen.findByText('Attachments (1)')).toBeInTheDocument();
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('button', {name: 'View All Attachments'}));
+ expect(
+ await screen.findByRole('complementary', {name: 'attachments drawer'})
+ ).toBeInTheDocument();
+ });
});
diff --git a/static/app/components/events/eventAttachments.tsx b/static/app/components/events/eventAttachments.tsx
index 9437774e50968f..2c8f42bf8d1f34 100644
--- a/static/app/components/events/eventAttachments.tsx
+++ b/static/app/components/events/eventAttachments.tsx
@@ -1,70 +1,39 @@
-import {Fragment, useState} from 'react';
+import {Fragment, useRef, useState} from 'react';
import styled from '@emotion/styled';
import {
useDeleteEventAttachmentOptimistic,
useFetchEventAttachments,
} from 'sentry/actionCreators/events';
-import AttachmentUrl from 'sentry/components/events/attachmentUrl';
-import ImageViewer from 'sentry/components/events/attachmentViewers/imageViewer';
-import JsonViewer from 'sentry/components/events/attachmentViewers/jsonViewer';
-import LogFileViewer from 'sentry/components/events/attachmentViewers/logFileViewer';
-import RRWebJsonViewer from 'sentry/components/events/attachmentViewers/rrwebJsonViewer';
+import {Button} from 'sentry/components/button';
import EventAttachmentActions from 'sentry/components/events/eventAttachmentActions';
import FileSize from 'sentry/components/fileSize';
import LoadingError from 'sentry/components/loadingError';
import {PanelTable} from 'sentry/components/panels/panelTable';
import {t} from 'sentry/locale';
import type {Event} from 'sentry/types/event';
-import type {IssueAttachment} from 'sentry/types/group';
+import type {Group, IssueAttachment} from 'sentry/types/group';
+import type {Project} from 'sentry/types/project';
import useOrganization from 'sentry/utils/useOrganization';
+import {InlineEventAttachment} from 'sentry/views/issueDetails/groupEventAttachments/inlineEventAttachment';
+import {useGroupEventAttachmentsDrawer} from 'sentry/views/issueDetails/groupEventAttachments/useGroupEventAttachmentsDrawer';
import {SectionKey} from 'sentry/views/issueDetails/streamline/context';
import {InterimSection} from 'sentry/views/issueDetails/streamline/interimSection';
+import {useHasStreamlinedUI} from 'sentry/views/issueDetails/utils';
import EventAttachmentsCrashReportsNotice from './eventAttachmentsCrashReportsNotice';
type EventAttachmentsProps = {
event: Event;
- projectSlug: string;
+ /**
+ * Group is not available everywhere this component is used
+ */
+ group: Group | undefined;
+ project: Project;
};
type AttachmentPreviewOpenMap = Record;
-interface InlineAttachmentsProps
- extends Pick {
- attachment: IssueAttachment;
- attachmentPreviews: AttachmentPreviewOpenMap;
-}
-
-const getInlineAttachmentRenderer = (attachment: IssueAttachment) => {
- switch (attachment.mimetype) {
- case 'text/css':
- case 'text/csv':
- case 'text/html':
- case 'text/javascript':
- case 'text/plain':
- return attachment.size > 0 ? LogFileViewer : undefined;
- case 'application/json':
- case 'application/ld+json':
- case 'text/json':
- case 'text/x-json':
- if (attachment.name === 'rrweb.json' || attachment.name.startsWith('rrweb-')) {
- return RRWebJsonViewer;
- }
- return JsonViewer;
- case 'image/jpeg':
- case 'image/png':
- case 'image/gif':
- return ImageViewer;
- default:
- return undefined;
- }
-};
-
-const hasInlineAttachmentRenderer = (attachment: IssueAttachment): boolean => {
- return !!getInlineAttachmentRenderer(attachment);
-};
-
const attachmentPreviewIsOpen = (
attachmentPreviews: Record,
attachment: IssueAttachment
@@ -72,32 +41,28 @@ const attachmentPreviewIsOpen = (
return attachmentPreviews[attachment.id] === true;
};
-function InlineEventAttachment({
- attachmentPreviews,
- attachment,
- projectSlug,
- event,
-}: InlineAttachmentsProps) {
- const organization = useOrganization();
- const AttachmentComponent = getInlineAttachmentRenderer(attachment);
-
- if (!AttachmentComponent || !attachmentPreviewIsOpen(attachmentPreviews, attachment)) {
- return null;
- }
+function ViewAllGroupAttachmentsButton({
+ group,
+ project,
+}: {
+ group: Group;
+ project: Project;
+}) {
+ const openButtonRef = useRef(null);
+ const {openAttachmentDrawer} = useGroupEventAttachmentsDrawer({
+ project,
+ group,
+ openButtonRef,
+ });
return (
-
-
-
+
+ {t('View All Attachments')}
+
);
}
-function EventAttachmentsContent({event, projectSlug}: EventAttachmentsProps) {
+function EventAttachmentsContent({event, project, group}: EventAttachmentsProps) {
const organization = useOrganization();
const {
data: attachments = [],
@@ -105,13 +70,14 @@ function EventAttachmentsContent({event, projectSlug}: EventAttachmentsProps) {
refetch,
} = useFetchEventAttachments({
orgSlug: organization.slug,
- projectSlug,
+ projectSlug: project.slug,
eventId: event.id,
});
const {mutate: deleteAttachment} = useDeleteEventAttachmentOptimistic();
const [attachmentPreviews, setAttachmentPreviews] = useState(
{}
);
+ const hasStreamlinedUI = useHasStreamlinedUI();
const crashFileStripped = event.metadata.stripped_crash;
if (isError) {
@@ -143,11 +109,19 @@ function EventAttachmentsContent({event, projectSlug}: EventAttachmentsProps) {
};
return (
-
+
+ ) : null
+ }
+ >
{crashFileStripped && (
)}
@@ -168,38 +142,30 @@ function EventAttachmentsContent({event, projectSlug}: EventAttachmentsProps) {
-
- {url => (
-
-
- deleteAttachment({
- orgSlug: organization.slug,
- projectSlug,
- eventId: event.id,
- attachmentId,
- })
- }
- onPreview={_attachmentId => togglePreview(attachment)}
- withPreviewButton
- previewIsOpen={attachmentPreviewIsOpen(
- attachmentPreviews,
- attachment
- )}
- hasPreview={hasInlineAttachmentRenderer(attachment)}
- attachmentId={attachment.id}
- />
-
- )}
-
-
+
+
+ deleteAttachment({
+ orgSlug: organization.slug,
+ projectSlug: project.slug,
+ eventId: event.id,
+ attachmentId: attachment.id,
+ })
+ }
+ onPreviewClick={() => togglePreview(attachment)}
+ previewIsOpen={attachmentPreviewIsOpen(attachmentPreviews, attachment)}
+ />
+
+ {attachmentPreviewIsOpen(attachmentPreviews, attachment) ? (
+
+ ) : null}
{/* XXX: hack to deal with table grid borders */}
{lastAttachmentPreviewed && (
@@ -246,9 +212,3 @@ const Size = styled('div')`
justify-content: flex-end;
white-space: nowrap;
`;
-
-const AttachmentPreviewWrapper = styled('div')`
- grid-column: auto / span 3;
- border: none;
- padding: 0;
-`;
diff --git a/static/app/components/events/eventReplay/eventDrawer.tsx b/static/app/components/events/eventDrawer.tsx
similarity index 100%
rename from static/app/components/events/eventReplay/eventDrawer.tsx
rename to static/app/components/events/eventDrawer.tsx
diff --git a/static/app/components/events/eventEntries.tsx b/static/app/components/events/eventEntries.tsx
index 67cd87e449f936..35aa2b8d69184e 100644
--- a/static/app/components/events/eventEntries.tsx
+++ b/static/app/components/events/eventEntries.tsx
@@ -117,7 +117,7 @@ function EventEntries({
{!isShare && }
- {!isShare && }
+ {!isShare && }
{event.type === EventOrGroupType.TRANSACTION && event._metrics_summary && (
ScreenshotModal', function () {
it('fetches a new batch of screenshots correctly', async function () {
const eventAttachment = EventAttachmentFixture();
+ const attachments = Array.from({length: MAX_SCREENSHOTS_PER_PAGE}, (_, index) =>
+ EventAttachmentFixture({id: `${index + 1}`})
+ ).concat(eventAttachment);
+
renderModal({
eventAttachment,
initialData,
projectSlug: project.slug,
- attachmentIndex: 11,
- attachments: [
- EventAttachmentFixture({id: '2'}),
- EventAttachmentFixture({id: '3'}),
- EventAttachmentFixture({id: '4'}),
- EventAttachmentFixture({id: '5'}),
- EventAttachmentFixture({id: '6'}),
- EventAttachmentFixture({id: '7'}),
- EventAttachmentFixture({id: '8'}),
- EventAttachmentFixture({id: '9'}),
- EventAttachmentFixture({id: '10'}),
- EventAttachmentFixture({id: '11'}),
- EventAttachmentFixture({id: '12'}),
- eventAttachment,
- ],
+ attachmentIndex: MAX_SCREENSHOTS_PER_PAGE - 1,
+ attachments,
enablePagination: true,
groupId: 'group-id',
});
diff --git a/static/app/components/events/eventTagsAndScreenshot/screenshot/modal.tsx b/static/app/components/events/eventTagsAndScreenshot/screenshot/modal.tsx
index 04757ddeb490e4..46ced6555e8406 100644
--- a/static/app/components/events/eventTagsAndScreenshot/screenshot/modal.tsx
+++ b/static/app/components/events/eventTagsAndScreenshot/screenshot/modal.tsx
@@ -24,11 +24,12 @@ import getDynamicText from 'sentry/utils/getDynamicText';
import parseLinkHeader from 'sentry/utils/parseLinkHeader';
import useApi from 'sentry/utils/useApi';
import {useLocation} from 'sentry/utils/useLocation';
-import {MAX_SCREENSHOTS_PER_PAGE} from 'sentry/views/issueDetails/groupEventAttachments/groupEventAttachments';
import ImageVisualization from './imageVisualization';
import ScreenshotPagination from './screenshotPagination';
+export const MAX_SCREENSHOTS_PER_PAGE = 50;
+
type Props = ModalRenderProps & {
downloadUrl: string;
eventAttachment: EventAttachment;
diff --git a/static/app/components/events/featureFlags/eventFeatureFlagList.tsx b/static/app/components/events/featureFlags/eventFeatureFlagList.tsx
index a8bd9dda3da71c..9ce421ed1eba7f 100644
--- a/static/app/components/events/featureFlags/eventFeatureFlagList.tsx
+++ b/static/app/components/events/featureFlags/eventFeatureFlagList.tsx
@@ -9,6 +9,7 @@ import {
CardContainer,
FeatureFlagDrawer,
FLAG_SORT_OPTIONS,
+ FlagControlOptions,
FlagSort,
getLabel,
} from 'sentry/components/events/featureFlags/featureFlagDrawer';
@@ -16,7 +17,7 @@ import useDrawer from 'sentry/components/globalDrawer';
import KeyValueData, {
type KeyValueDataContentProps,
} from 'sentry/components/keyValueData';
-import {IconMegaphone, IconSort} from 'sentry/icons';
+import {IconMegaphone, IconSearch, IconSort} from 'sentry/icons';
import {t} from 'sentry/locale';
import type {Event, FeatureFlag} from 'sentry/types/event';
import type {Group} from 'sentry/types/group';
@@ -91,35 +92,39 @@ export function EventFeatureFlagList({
? [...hydratedFlags].reverse()
: hydratedFlags;
- const onViewAllFlags = useCallback(() => {
- trackAnalytics('flags.view-all-clicked', {
- organization,
- });
- openDrawer(
- () => (
-
- ),
- {
- ariaLabel: t('Feature flags drawer'),
- // We prevent a click on the 'View All' button from closing the drawer so that
- // we don't reopen it immediately, and instead let the button handle this itself.
- shouldCloseOnInteractOutside: element => {
- const viewAllButton = viewAllButtonRef.current;
- if (viewAllButton?.contains(element)) {
- return false;
- }
- return true;
- },
- transitionProps: {stiffness: 1000},
- }
- );
- }, [openDrawer, event, group, project, sortMethod, hydratedFlags, organization]);
+ const onViewAllFlags = useCallback(
+ (focusControl?: FlagControlOptions) => {
+ trackAnalytics('flags.view-all-clicked', {
+ organization,
+ });
+ openDrawer(
+ () => (
+
+ ),
+ {
+ ariaLabel: t('Feature flags drawer'),
+ // We prevent a click on the 'View All' button from closing the drawer so that
+ // we don't reopen it immediately, and instead let the button handle this itself.
+ shouldCloseOnInteractOutside: element => {
+ const viewAllButton = viewAllButtonRef.current;
+ if (viewAllButton?.contains(element)) {
+ return false;
+ }
+ return true;
+ },
+ transitionProps: {stiffness: 1000},
+ }
+ );
+ },
+ [openDrawer, event, group, project, sortMethod, hydratedFlags, organization]
+ );
if (!hydratedFlags.length) {
return null;
@@ -128,10 +133,18 @@ export function EventFeatureFlagList({
const actions = (
{feedbackButton}
+ }
+ size="xs"
+ title={t('Open Search')}
+ onClick={() => onViewAllFlags(FlagControlOptions.SEARCH)}
+ />
{
isDrawerOpen ? closeDrawer() : onViewAllFlags();
}}
diff --git a/static/app/components/events/featureFlags/featureFlagDrawer.tsx b/static/app/components/events/featureFlags/featureFlagDrawer.tsx
index d5b94baa79fc10..b15d33186e1f60 100644
--- a/static/app/components/events/featureFlags/featureFlagDrawer.tsx
+++ b/static/app/components/events/featureFlags/featureFlagDrawer.tsx
@@ -15,7 +15,8 @@ import {
NavigationCrumbs,
SearchInput,
ShortId,
-} from 'sentry/components/events/eventReplay/eventDrawer';
+} from 'sentry/components/events/eventDrawer';
+import useFocusControl from 'sentry/components/events/useFocusControl';
import {InputGroup} from 'sentry/components/inputGroup';
import KeyValueData, {
type KeyValueDataContentProps,
@@ -74,6 +75,7 @@ interface FlagDrawerProps {
hydratedFlags: KeyValueDataContentProps[];
initialSort: FlagSort;
project: Project;
+ focusControl?: FlagControlOptions;
}
export function FeatureFlagDrawer({
@@ -82,10 +84,12 @@ export function FeatureFlagDrawer({
project,
initialSort,
hydratedFlags,
+ focusControl: initialFocusControl,
}: FlagDrawerProps) {
const [sortMethod, setSortMethod] = useState(initialSort);
const [search, setSearch] = useState('');
const organization = useOrganization();
+ const {getFocusProps} = useFocusControl(initialFocusControl);
const handleSortAlphabetical = (flags: KeyValueDataContentProps[]) => {
return [...flags].sort((a, b) => {
@@ -111,6 +115,7 @@ export function FeatureFlagDrawer({
setSearch(e.target.value.toLowerCase());
}}
aria-label={t('Search Flags')}
+ {...getFocusProps(FlagControlOptions.SEARCH)}
/>
diff --git a/static/app/components/events/highlights/editHighlightsModal.tsx b/static/app/components/events/highlights/editHighlightsModal.tsx
index f7a1d36ef1c06f..b9b9435306b41c 100644
--- a/static/app/components/events/highlights/editHighlightsModal.tsx
+++ b/static/app/components/events/highlights/editHighlightsModal.tsx
@@ -337,7 +337,7 @@ export default function EditHighlightsModal({
const organization = useOrganization();
- const {mutate: saveHighlights, isLoading} = useMutateProject({
+ const {mutate: saveHighlights, isPending} = useMutateProject({
organization,
project,
onSuccess: closeModal,
@@ -431,7 +431,7 @@ export default function EditHighlightsModal({
)}
{
trackAnalytics('highlights.edit_modal.save_clicked', {organization});
saveHighlights({highlightContext, highlightTags});
@@ -439,7 +439,7 @@ export default function EditHighlightsModal({
priority="primary"
size="sm"
>
- {isLoading ? t('Saving...') : t('Apply to Project')}
+ {isPending ? t('Saving...') : t('Apply to Project')}
diff --git a/static/app/components/events/interfaces/crashContent/exception/sourceMapDebug.tsx b/static/app/components/events/interfaces/crashContent/exception/sourceMapDebug.tsx
index e2aa685247dccd..78b360187402dd 100644
--- a/static/app/components/events/interfaces/crashContent/exception/sourceMapDebug.tsx
+++ b/static/app/components/events/interfaces/crashContent/exception/sourceMapDebug.tsx
@@ -240,7 +240,7 @@ export function SourceMapDebug({debugFrames, event}: SourcemapDebugProps) {
const organization = useOrganization();
const results = useSourceMapDebugQueries(debugFrames.map(debug => debug.query));
- const isLoading = results.every(result => result.isLoading);
+ const isLoading = results.every(result => result.isPending);
const errorMessages = combineErrors(
results.map(result => result.data).filter(defined),
sdkName
diff --git a/static/app/components/events/interfaces/llm-monitoring/llmMonitoringSection.tsx b/static/app/components/events/interfaces/llm-monitoring/llmMonitoringSection.tsx
index 1d538582c37adb..76713326fd5afe 100644
--- a/static/app/components/events/interfaces/llm-monitoring/llmMonitoringSection.tsx
+++ b/static/app/components/events/interfaces/llm-monitoring/llmMonitoringSection.tsx
@@ -1,4 +1,3 @@
-import Alert from 'sentry/components/alert';
import {LinkButton} from 'sentry/components/button';
import ButtonBar from 'sentry/components/buttonBar';
import {IconOpen} from 'sentry/icons';
@@ -7,13 +6,22 @@ import type {Event} from 'sentry/types/event';
import type {Organization} from 'sentry/types/organization';
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
import * as ModuleLayout from 'sentry/views/insights/common/components/moduleLayout';
-import {useSpansIndexed} from 'sentry/views/insights/common/queries/useDiscover';
+import {
+ useEAPSpans,
+ useSpansIndexed,
+} from 'sentry/views/insights/common/queries/useDiscover';
import {useModuleURL} from 'sentry/views/insights/common/utils/useModuleURL';
import {
+ EAPNumberOfPipelinesChart,
+ EAPTotalTokensUsedChart,
NumberOfPipelinesChart,
TotalTokensUsedChart,
} from 'sentry/views/insights/llmMonitoring/components/charts/llmMonitoringCharts';
-import {SpanIndexedField, type SpanIndexedResponse} from 'sentry/views/insights/types';
+import {
+ type EAPSpanResponse,
+ SpanIndexedField,
+ type SpanIndexedResponse,
+} from 'sentry/views/insights/types';
import {SectionKey} from 'sentry/views/issueDetails/streamline/context';
import {InterimSection} from 'sentry/views/issueDetails/streamline/interimSection';
@@ -22,20 +30,53 @@ interface Props {
organization: Organization;
}
-export default function LLMMonitoringSection({event}: Props) {
- const traceId = event.contexts.trace?.trace_id;
- const spanId = event.contexts.trace?.span_id;
- const {data, error, isPending} = useSpansIndexed(
+function useAIPipelineGroup({
+ useEAP,
+ traceId,
+ spanId,
+}: {
+ useEAP: boolean;
+ spanId?: string;
+ traceId?: string;
+}): string | null {
+ const {data: indexedData} = useSpansIndexed(
{
limit: 1,
fields: [SpanIndexedField.SPAN_AI_PIPELINE_GROUP],
search: new MutableSearch(`trace:${traceId} id:"${spanId}"`),
+ enabled: !useEAP,
},
'api.ai-pipelines.view'
);
+ const {data: eapData} = useEAPSpans(
+ {
+ limit: 1,
+ fields: [SpanIndexedField.SPAN_AI_PIPELINE_GROUP_TAG],
+ search: new MutableSearch(`trace:${traceId} id:"${spanId}"`),
+ enabled: useEAP,
+ },
+ 'api.ai-pipelines-eap.view'
+ );
+
+ if (useEAP) {
+ return (
+ eapData &&
+ (eapData[0] as EAPSpanResponse)?.[SpanIndexedField.SPAN_AI_PIPELINE_GROUP_TAG]
+ );
+ }
+ return (
+ indexedData &&
+ (indexedData[0] as SpanIndexedResponse)?.[SpanIndexedField.SPAN_AI_PIPELINE_GROUP]
+ );
+}
+
+export default function LLMMonitoringSection({event, organization}: Props) {
const moduleUrl = useModuleURL('ai');
- const aiPipelineGroup =
- data && (data[0] as SpanIndexedResponse)?.[SpanIndexedField.SPAN_AI_PIPELINE_GROUP];
+ const aiPipelineGroup = useAIPipelineGroup({
+ useEAP: organization.features.includes('insights-use-eap'),
+ traceId: event.contexts.trace?.trace_id,
+ spanId: event.contexts.trace?.span_id,
+ });
const actions = (
@@ -44,6 +85,7 @@ export default function LLMMonitoringSection({event}: Props) {
);
+ const useEAP = organization.features.includes('insights-use-eap');
return (
- {error ? (
-
- {'' + error}
-
- ) : isPending ? (
+ {!aiPipelineGroup ? (
'loading'
) : (
-
+ {useEAP ? (
+
+ ) : (
+
+ )}
-
+ {useEAP ? (
+
+ ) : (
+
+ )}
)}
diff --git a/static/app/components/events/interfaces/performance/eventTraceView.spec.tsx b/static/app/components/events/interfaces/performance/eventTraceView.spec.tsx
index 31b4b189d1aada..3b0097add81786 100644
--- a/static/app/components/events/interfaces/performance/eventTraceView.spec.tsx
+++ b/static/app/components/events/interfaces/performance/eventTraceView.spec.tsx
@@ -2,7 +2,7 @@ import {EventFixture} from 'sentry-fixture/event';
import {GroupFixture} from 'sentry-fixture/group';
import {initializeData} from 'sentry-test/performance/initializePerformanceData';
-import {render, screen} from 'sentry-test/reactTestingLibrary';
+import {act, render, screen} from 'sentry-test/reactTestingLibrary';
import {EntryType} from 'sentry/types/event';
@@ -100,4 +100,37 @@ describe('EventTraceView', () => {
expect(await screen.findByText('Trace Preview')).toBeInTheDocument();
expect(await screen.findByText('transaction')).toBeInTheDocument();
});
+
+ it('does not render the trace preview if it has no transactions', async () => {
+ MockApiClient.addMockResponse({
+ method: 'GET',
+ url: `/organizations/${organization.slug}/events-trace-meta/${traceId}/`,
+ body: {
+ errors: 0,
+ performance_issues: 0,
+ projects: 0,
+ transactions: 0,
+ transaction_child_count_map: [{'transaction.id': '1', count: 1}],
+ },
+ });
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/events-trace/${traceId}/`,
+ body: {
+ transactions: [],
+ orphan_errors: [],
+ },
+ });
+
+ const {container} = render(
+
+ );
+
+ await act(tick);
+ expect(container).toBeEmptyDOMElement();
+ });
});
diff --git a/static/app/components/events/interfaces/performance/eventTraceView.tsx b/static/app/components/events/interfaces/performance/eventTraceView.tsx
index 770b92de18756d..da2e1f467b977b 100644
--- a/static/app/components/events/interfaces/performance/eventTraceView.tsx
+++ b/static/app/components/events/interfaces/performance/eventTraceView.tsx
@@ -61,9 +61,13 @@ function EventTraceViewInner({
traceSlug: traceId ? traceId : undefined,
limit: 10000,
});
- const rootEvent = useTraceRootEvent(trace.data ?? null);
const meta = useTraceMeta([{traceSlug: traceId, timestamp: undefined}]);
+ const hasNoTransactions = meta.data?.transactions === 0;
+ const shouldLoadTraceRoot = !trace.isPending && trace.data && !hasNoTransactions;
+
+ const rootEvent = useTraceRootEvent(shouldLoadTraceRoot ? trace.data! : null);
+
const preferences = useMemo(
() =>
loadTraceViewPreferences('issue-details-trace-view-preferences') ||
@@ -87,7 +91,7 @@ function EventTraceViewInner({
});
}, [location.query.statsPeriod, traceId]);
- if (trace.isPending || rootEvent.isPending || !rootEvent.data) {
+ if (trace.isPending || rootEvent.isPending || !rootEvent.data || hasNoTransactions) {
return null;
}
diff --git a/static/app/components/events/useFocusControl.tsx b/static/app/components/events/useFocusControl.tsx
new file mode 100644
index 00000000000000..352b0a1002d32f
--- /dev/null
+++ b/static/app/components/events/useFocusControl.tsx
@@ -0,0 +1,21 @@
+import {useCallback, useState} from 'react';
+
+import type {BreadcrumbControlOptions} from 'sentry/components/events/breadcrumbs/breadcrumbsDrawer';
+import type {FlagControlOptions} from 'sentry/components/events/featureFlags/featureFlagDrawer';
+
+type FocusControlOption = BreadcrumbControlOptions | FlagControlOptions;
+
+export default function useFocusControl(initialFocusControl?: FocusControlOption) {
+ const [focusControl, setFocusControl] = useState(initialFocusControl);
+ // If the focused control element is blurred, unset the state to remove styles
+ // This will allow us to simulate :focus-visible on the button elements.
+ const getFocusProps = useCallback(
+ (option: FocusControlOption) => {
+ return option === focusControl
+ ? {autoFocus: true, onBlur: () => setFocusControl(undefined)}
+ : {};
+ },
+ [focusControl]
+ );
+ return {getFocusProps};
+}
diff --git a/static/app/components/feedback/useFeedbackCache.tsx b/static/app/components/feedback/useFeedbackCache.tsx
index 046bf0ffe73703..0e7d51d5661b29 100644
--- a/static/app/components/feedback/useFeedbackCache.tsx
+++ b/static/app/components/feedback/useFeedbackCache.tsx
@@ -4,7 +4,7 @@ import type {ApiResult} from 'sentry/api';
import useFeedbackQueryKeys from 'sentry/components/feedback/useFeedbackQueryKeys';
import {defined} from 'sentry/utils';
import type {FeedbackIssue, FeedbackIssueListItem} from 'sentry/utils/feedback/types';
-import type {ApiQueryKey} from 'sentry/utils/queryClient';
+import type {ApiQueryKey, InfiniteData, QueryState} from 'sentry/utils/queryClient';
import {setApiQueryData, useQueryClient} from 'sentry/utils/queryClient';
type TFeedbackIds = 'all' | string[];
@@ -93,12 +93,24 @@ export default function useFeedbackCache() {
const invalidateCachedListPage = useCallback(
(ids: TFeedbackIds) => {
- queryClient.invalidateQueries({
- queryKey: listQueryKey,
- refetchPage: ([results]: ApiResult) => {
- return ids === 'all' || results.some(item => ids.includes(item.id));
- },
- });
+ if (ids === 'all') {
+ queryClient.invalidateQueries({
+ queryKey: listQueryKey,
+ type: 'all',
+ });
+ } else {
+ queryClient.refetchQueries({
+ queryKey: listQueryKey,
+ predicate: query => {
+ // Check if any of the pages contain the items we want to invalidate
+ return Boolean(
+ (
+ query.state.data as QueryState>
+ ).data?.pages.some(items => items.some(item => ids.includes(item.id)))
+ );
+ },
+ });
+ }
},
[listQueryKey, queryClient]
);
diff --git a/static/app/components/feedback/useMutateActivity.tsx b/static/app/components/feedback/useMutateActivity.tsx
index d633ce96f5f291..6cb2304d7efaf7 100644
--- a/static/app/components/feedback/useMutateActivity.tsx
+++ b/static/app/components/feedback/useMutateActivity.tsx
@@ -73,7 +73,7 @@ export default function useMutateActivity({
]);
},
onSettled: onSettled ?? undefined,
- cacheTime: 0,
+ gcTime: 0,
});
const handleUpdate = useCallback(
diff --git a/static/app/components/feedback/useMutateFeedback.tsx b/static/app/components/feedback/useMutateFeedback.tsx
index e2a83f72698f12..bc41d26bd1a44b 100644
--- a/static/app/components/feedback/useMutateFeedback.tsx
+++ b/static/app/components/feedback/useMutateFeedback.tsx
@@ -56,7 +56,7 @@ export default function useMutateFeedback({
onSettled: (_resp, _error, [ids, _payload]) => {
invalidateCached(ids);
},
- cacheTime: 0,
+ gcTime: 0,
});
const markAsRead = useCallback(
diff --git a/static/app/components/group/assigneeSelector.tsx b/static/app/components/group/assigneeSelector.tsx
index 8b20ca8f81d3e4..8d2ecf48cd9f21 100644
--- a/static/app/components/group/assigneeSelector.tsx
+++ b/static/app/components/group/assigneeSelector.tsx
@@ -31,7 +31,7 @@ export function useHandleAssigneeChange({
organization: Organization;
onAssign?: OnAssignCallback;
}) {
- const {mutate: handleAssigneeChange, isLoading: assigneeLoading} = useMutation<
+ const {mutate: handleAssigneeChange, isPending: assigneeLoading} = useMutation<
AssignableEntity | null,
RequestError,
AssignableEntity | null
diff --git a/static/app/components/group/externalIssuesList/streamlinedExternalIssueList.spec.tsx b/static/app/components/group/externalIssuesList/streamlinedExternalIssueList.spec.tsx
index ff2bbc87013f99..f2cad639b526c0 100644
--- a/static/app/components/group/externalIssuesList/streamlinedExternalIssueList.spec.tsx
+++ b/static/app/components/group/externalIssuesList/streamlinedExternalIssueList.spec.tsx
@@ -166,7 +166,7 @@ describe('StreamlinedExternalIssueList', () => {
);
expect(await screen.findByRole('button', {name: 'GitHub'})).toBeInTheDocument();
- userEvent.click(await screen.findByRole('button', {name: 'GitHub'}));
+ await userEvent.click(await screen.findByRole('button', {name: 'GitHub'}));
// Both items are listed inside the dropdown
expect(
diff --git a/static/app/components/group/releaseStats.tsx b/static/app/components/group/releaseStats.tsx
index 1a745056b41cee..0d139a64876638 100644
--- a/static/app/components/group/releaseStats.tsx
+++ b/static/app/components/group/releaseStats.tsx
@@ -60,7 +60,7 @@ function GroupReleaseStats({
],
{
staleTime: 30000,
- cacheTime: 30000,
+ gcTime: 30000,
}
);
diff --git a/static/app/components/groupPreviewTooltip/utils.tsx b/static/app/components/groupPreviewTooltip/utils.tsx
index b705c16370eb29..327aff985c6fe5 100644
--- a/static/app/components/groupPreviewTooltip/utils.tsx
+++ b/static/app/components/groupPreviewTooltip/utils.tsx
@@ -59,7 +59,7 @@ export function usePreviewEvent({
}),
},
],
- {staleTime: 30000, cacheTime: 30000}
+ {staleTime: 30000, gcTime: 30000}
);
// Prefetch the group as well, but don't use the result
@@ -70,7 +70,7 @@ export function usePreviewEvent({
],
{
staleTime: 30000,
- cacheTime: 30000,
+ gcTime: 30000,
enabled: defined(groupId),
}
);
diff --git a/static/app/components/modals/dashboardWidgetQuerySelectorModal.spec.tsx b/static/app/components/modals/dashboardWidgetQuerySelectorModal.spec.tsx
index 449b626bf0a924..eeebc4faec7714 100644
--- a/static/app/components/modals/dashboardWidgetQuerySelectorModal.spec.tsx
+++ b/static/app/components/modals/dashboardWidgetQuerySelectorModal.spec.tsx
@@ -2,7 +2,6 @@ import {initializeOrg} from 'sentry-test/initializeOrg';
import {render, screen} from 'sentry-test/reactTestingLibrary';
import DashboardWidgetQuerySelectorModal from 'sentry/components/modals/dashboardWidgetQuerySelectorModal';
-import {t} from 'sentry/locale';
import {DisplayType} from 'sentry/views/dashboards/types';
const stubEl: any = (props: any) => {props.children}
;
@@ -73,7 +72,7 @@ describe('Modals -> AddDashboardWidgetModal', function () {
});
MockApiClient.addMockResponse({
url: '/organizations/org-slug/dashboards/',
- body: [{id: '1', title: t('Test Dashboard')}],
+ body: [{id: '1', title: 'Test Dashboard'}],
});
});
diff --git a/static/app/components/onboarding/frameworkSuggestionModal.spec.tsx b/static/app/components/onboarding/frameworkSuggestionModal.spec.tsx
index acb20e53fa2aa7..22fc8649440329 100644
--- a/static/app/components/onboarding/frameworkSuggestionModal.spec.tsx
+++ b/static/app/components/onboarding/frameworkSuggestionModal.spec.tsx
@@ -1,5 +1,5 @@
import {initializeOrg} from 'sentry-test/initializeOrg';
-import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
import {
makeClosableHeader,
@@ -73,8 +73,5 @@ describe('Framework suggestion modal', function () {
expect(screen.getByRole('button', {name: 'Configure SDK'})).toBeEnabled();
await userEvent.click(screen.getByRole('button', {name: 'Skip'}));
- await waitFor(() => {
- expect(closeModal).toHaveBeenCalled();
- });
});
});
diff --git a/static/app/components/onboarding/frameworkSuggestionModal.tsx b/static/app/components/onboarding/frameworkSuggestionModal.tsx
index 916809772dd116..f1e21cdb56933e 100644
--- a/static/app/components/onboarding/frameworkSuggestionModal.tsx
+++ b/static/app/components/onboarding/frameworkSuggestionModal.tsx
@@ -209,15 +209,7 @@ export function FrameworkSuggestionModal({
);
onConfigure(selectedFramework);
- closeModal();
- }, [
- selectedPlatform,
- selectedFramework,
- organization,
- onConfigure,
- closeModal,
- newOrg,
- ]);
+ }, [selectedPlatform, selectedFramework, organization, onConfigure, newOrg]);
const handleSkip = useCallback(() => {
trackAnalytics(
@@ -230,8 +222,7 @@ export function FrameworkSuggestionModal({
}
);
onSkip();
- closeModal();
- }, [selectedPlatform, organization, closeModal, onSkip, newOrg]);
+ }, [selectedPlatform, organization, onSkip, newOrg]);
const listEntries = [...topFrameworksOrdered, ...otherFrameworksSortedAlphabetically];
diff --git a/static/app/components/onboarding/gettingStartedDoc/authTokenGenerator.tsx b/static/app/components/onboarding/gettingStartedDoc/authTokenGenerator.tsx
index a827db3ed7a000..1f2094226ca223 100644
--- a/static/app/components/onboarding/gettingStartedDoc/authTokenGenerator.tsx
+++ b/static/app/components/onboarding/gettingStartedDoc/authTokenGenerator.tsx
@@ -36,7 +36,7 @@ export function AuthTokenGeneratorProvider({
const organization = useOrganization();
const [authToken, setAuthToken] = useState();
- const {mutate: generateAuthToken, isLoading} = useMutation<
+ const {mutate: generateAuthToken, isPending} = useMutation<
OrgAuthTokenWithToken,
RequestError
>({
@@ -62,7 +62,9 @@ export function AuthTokenGeneratorProvider({
});
return (
-
+
{children}
);
diff --git a/static/app/components/onboarding/gettingStartedDoc/onboardingLayout.tsx b/static/app/components/onboarding/gettingStartedDoc/onboardingLayout.tsx
index 19a031fd93eb45..a78052a23bfd96 100644
--- a/static/app/components/onboarding/gettingStartedDoc/onboardingLayout.tsx
+++ b/static/app/components/onboarding/gettingStartedDoc/onboardingLayout.tsx
@@ -74,6 +74,7 @@ export function OnboardingLayout({
onPlatformOptionsChange,
onProductSelectionChange,
onPageLoad,
+ onProductSelectionLoad,
} = useMemo(() => {
const doc = docsConfig[configType] ?? docsConfig.onboarding;
@@ -112,6 +113,7 @@ export function OnboardingLayout({
nextSteps: doc.nextSteps?.(docParams) || [],
onPlatformOptionsChange: doc.onPlatformOptionsChange?.(docParams),
onProductSelectionChange: doc.onProductSelectionChange?.(docParams),
+ onProductSelectionLoad: doc.onProductSelectionLoad?.(docParams),
onPageLoad: doc.onPageLoad?.(docParams),
};
}, [
@@ -142,13 +144,14 @@ export function OnboardingLayout({
- {introduction && {introduction}
}
+ {introduction && {introduction} }
{configType === 'onboarding' && (
)}
{platformOptions && !['customMetricsOnboarding'].includes(configType) ? (
@@ -219,3 +222,9 @@ const Wrapper = styled('div')`
}
}
`;
+
+const Introduction = styled('div')`
+ & > p:not(:last-child) {
+ margin-bottom: ${space(2)};
+ }
+`;
diff --git a/static/app/components/onboarding/gettingStartedDoc/types.ts b/static/app/components/onboarding/gettingStartedDoc/types.ts
index 8cfced83f1dc65..d739d3e7f886e5 100644
--- a/static/app/components/onboarding/gettingStartedDoc/types.ts
+++ b/static/app/components/onboarding/gettingStartedDoc/types.ts
@@ -94,6 +94,7 @@ export interface OnboardingConfig<
platformOptions: SelectedPlatformOptions
) => void;
onProductSelectionChange?: (products: ProductSolution[]) => void;
+ onProductSelectionLoad?: (products: ProductSolution[]) => void;
},
DocsParams
> {}
diff --git a/static/app/components/onboarding/gettingStartedDoc/utils/index.tsx b/static/app/components/onboarding/gettingStartedDoc/utils/index.tsx
index 58705c2b13899c..bffd82e1059878 100644
--- a/static/app/components/onboarding/gettingStartedDoc/utils/index.tsx
+++ b/static/app/components/onboarding/gettingStartedDoc/utils/index.tsx
@@ -1,8 +1,7 @@
import Alert from 'sentry/components/alert';
import ExternalLink from 'sentry/components/links/externalLink';
+import type {DocsParams} from 'sentry/components/onboarding/gettingStartedDoc/types';
import {t, tct} from 'sentry/locale';
-import type {Organization} from 'sentry/types/organization';
-import type {PlatformKey} from 'sentry/types/project';
import {trackAnalytics} from 'sentry/utils/analytics';
export function getUploadSourceMapsStep({
@@ -11,13 +10,12 @@ export function getUploadSourceMapsStep({
platformKey,
projectId,
newOrg,
-}: {
+ isSelfHosted,
+ urlPrefix,
+}: DocsParams & {
guideLink: string;
- newOrg?: boolean;
- organization?: Organization;
- platformKey?: PlatformKey;
- projectId?: string;
}) {
+ const urlParam = !isSelfHosted && urlPrefix ? `--url ${urlPrefix}` : '';
return {
title: t('Upload Source Maps'),
description: (
@@ -33,7 +31,7 @@ export function getUploadSourceMapsStep({
configurations: [
{
language: 'bash',
- code: `npx @sentry/wizard@latest -i sourcemaps`,
+ code: `npx @sentry/wizard@latest -i sourcemaps ${urlParam}`,
onCopy: () => {
if (!organization || !projectId || !platformKey) {
return;
diff --git a/static/app/components/onboarding/productSelection.spec.tsx b/static/app/components/onboarding/productSelection.spec.tsx
index d06e8fe2ae1817..aa4bf053cc44af 100644
--- a/static/app/components/onboarding/productSelection.spec.tsx
+++ b/static/app/components/onboarding/productSelection.spec.tsx
@@ -106,57 +106,6 @@ describe('Onboarding Product Selection', function () {
).toBeInTheDocument();
});
- it('renders for Loader Script', async function () {
- const {router, project} = initializeOrg({
- router: {
- location: {
- query: {
- showLoader: 'true',
- product: [
- ProductSolution.PERFORMANCE_MONITORING,
- ProductSolution.SESSION_REPLAY,
- ],
- },
- },
- params: {},
- },
- });
-
- render(
- ,
- {
- router,
- }
- );
-
- // Introduction
- expect(
- screen.getByText(
- textWithMarkupMatcher(/In this quick guide you’ll use our Loader Script/)
- )
- ).toBeInTheDocument();
- expect(
- screen.getByText(
- textWithMarkupMatcher(/Prefer to set up Sentry using npm or yarn\?/)
- )
- ).toBeInTheDocument();
-
- await userEvent.click(screen.getByText('View npm/yarn instructions'));
-
- expect(router.replace).toHaveBeenCalledWith(
- expect.objectContaining({
- query: {
- product: ['performance-monitoring', 'session-replay'],
- showLoader: false,
- },
- })
- );
- });
-
it('renders disabled product', async function () {
const {router, project} = initializeOrg({
router: {
diff --git a/static/app/components/onboarding/productSelection.tsx b/static/app/components/onboarding/productSelection.tsx
index 80ab157b172dcb..673a38d38e030b 100644
--- a/static/app/components/onboarding/productSelection.tsx
+++ b/static/app/components/onboarding/productSelection.tsx
@@ -295,7 +295,11 @@ export type ProductSelectionProps = {
/**
* Fired when the product selection changes
*/
- onChange?: (product: ProductSolution[]) => void;
+ onChange?: (products: ProductSolution[]) => void;
+ /**
+ * Triggered when the component is loaded
+ */
+ onLoad?: (products: ProductSolution[]) => void;
/**
* The platform key of the project (e.g. javascript-react, python-django, etc.)
*/
@@ -304,10 +308,6 @@ export type ProductSelectionProps = {
* A custom list of products per platform. If not provided, the default list is used.
*/
productsPerPlatform?: Record;
- /**
- * If true, the component has a bottom margin of 20px
- */
- withBottomMargin?: boolean;
};
export function ProductSelection({
@@ -316,6 +316,7 @@ export function ProductSelection({
platform,
productsPerPlatform = platformProductAvailability,
onChange,
+ onLoad,
}: ProductSelectionProps) {
const [params, setParams] = useOnboardingQueryParams();
const urlProducts = useMemo(() => params.product ?? [], [params.product]);
@@ -333,6 +334,7 @@ export function ProductSelection({
}, [products, disabledProducts]);
useEffect(() => {
+ onLoad?.(defaultProducts);
setParams({
product: defaultProducts,
});
@@ -390,8 +392,6 @@ export function ProductSelection({
platform !== 'javascript-astro' &&
platform !== 'javascript';
- const showAstroInfo = platform === 'javascript-astro';
-
return (
{showPackageManagerInfo && (
@@ -402,13 +402,6 @@ export function ProductSelection({
})}
)}
- {showAstroInfo && (
-
- {tct("In this quick guide you'll use the [astrocli:astro] CLI to set up:", {
- astrocli: ,
- })}
-
- )}
{
- if (!data) {
+ refetchInterval: query => {
+ if (!query.state.data) {
return false;
}
- const [projectData] = data;
+ const [projectData] = query.state.data;
return projectData?.firstEvent ? false : DEFAULT_POLL_INTERVAL_MS;
},
}
diff --git a/static/app/components/organizations/datePageFilter.tsx b/static/app/components/organizations/datePageFilter.tsx
index 28633b8d046b1c..77dd93baf65758 100644
--- a/static/app/components/organizations/datePageFilter.tsx
+++ b/static/app/components/organizations/datePageFilter.tsx
@@ -1,17 +1,11 @@
-import styled from '@emotion/styled';
-
import {updateDateTime} from 'sentry/actionCreators/pageFilters';
import type {TimeRangeSelectorProps} from 'sentry/components/timeRangeSelector';
import {TimeRangeSelector} from 'sentry/components/timeRangeSelector';
-import {IconCalendar} from 'sentry/icons';
import {t} from 'sentry/locale';
import usePageFilters from 'sentry/utils/usePageFilters';
import useRouter from 'sentry/utils/useRouter';
-import {
- DesyncedFilterIndicator,
- DesyncedFilterMessage,
-} from './pageFilters/desyncedFilter';
+import {DesyncedFilterMessage} from './pageFilters/desyncedFilter';
interface DatePageFilterProps
extends Partial<
@@ -48,6 +42,7 @@ export function DatePageFilter({
utc={utc}
relative={period}
disabled={disabled ?? !pageFilterIsReady}
+ desynced={desynced}
onChange={timePeriodUpdate => {
const {relative, ...startEndUtc} = timePeriodUpdate;
const newTimePeriod = {period: relative, ...startEndUtc};
@@ -63,21 +58,7 @@ export function DatePageFilter({
menuTitle={menuTitle ?? t('Filter Time Range')}
menuWidth={menuWidth ?? desynced ? '22em' : undefined}
menuBody={desynced && }
- triggerProps={{
- icon: (
-
-
- {desynced && }
-
- ),
- ...triggerProps,
- }}
+ triggerProps={triggerProps}
/>
);
}
-
-const TriggerIconWrap = styled('div')`
- position: relative;
- display: flex;
- align-items: center;
-`;
diff --git a/static/app/components/organizations/environmentPageFilter/trigger.tsx b/static/app/components/organizations/environmentPageFilter/trigger.tsx
index f003681aa312bf..7146b4b5e9a937 100644
--- a/static/app/components/organizations/environmentPageFilter/trigger.tsx
+++ b/static/app/components/organizations/environmentPageFilter/trigger.tsx
@@ -4,7 +4,6 @@ import styled from '@emotion/styled';
import Badge from 'sentry/components/badge/badge';
import type {DropdownButtonProps} from 'sentry/components/dropdownButton';
import DropdownButton from 'sentry/components/dropdownButton';
-import {IconWindow} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import {trimSlug} from 'sentry/utils/string/trimSlug';
@@ -43,14 +42,11 @@ function BaseEnvironmentPageFilterTrigger(
{...props}
ref={forwardedRef}
data-test-id="page-filter-environment-selector"
- icon={
-
-
- {desynced && }
-
- }
>
- {ready ? label : t('Loading\u2026')}
+
+ {ready ? label : t('Loading\u2026')}
+ {desynced && }
+
{remainingCount > 0 && }
);
@@ -58,17 +54,16 @@ function BaseEnvironmentPageFilterTrigger(
export const EnvironmentPageFilterTrigger = forwardRef(BaseEnvironmentPageFilterTrigger);
+const TriggerLabelWrap = styled('span')`
+ position: relative;
+ min-width: 0;
+`;
+
const TriggerLabel = styled('span')`
${p => p.theme.overflowEllipsis};
width: auto;
`;
-const TriggerIconWrap = styled('div')`
- position: relative;
- display: flex;
- align-items: center;
-`;
-
const StyledBadge = styled(Badge)`
margin-top: -${space(0.5)};
margin-bottom: -${space(0.5)};
diff --git a/static/app/components/organizations/pageFilterBar.tsx b/static/app/components/organizations/pageFilterBar.tsx
index cf68149840b617..19591a991d78e1 100644
--- a/static/app/components/organizations/pageFilterBar.tsx
+++ b/static/app/components/organizations/pageFilterBar.tsx
@@ -1,5 +1,7 @@
import styled from '@emotion/styled';
+import {space} from 'sentry/styles/space';
+
const PageFilterBar = styled('div')<{condensed?: boolean}>`
display: flex;
position: relative;
@@ -37,6 +39,14 @@ const PageFilterBar = styled('div')<{condensed?: boolean}>`
z-index: 0;
}
+ /* Less inner padding between buttons */
+ & > div:not(:first-child) > button[aria-haspopup] {
+ padding-left: ${space(1.5)};
+ }
+ & > div:not(:last-child) > button[aria-haspopup] {
+ padding-right: ${space(1.5)};
+ }
+
& button[aria-haspopup]:focus-visible {
border-color: ${p => p.theme.focusBorder};
box-shadow: 0 0 0 1px ${p => p.theme.focusBorder};
diff --git a/static/app/components/organizations/pageFilters/desyncedFilter.tsx b/static/app/components/organizations/pageFilters/desyncedFilter.tsx
index 05f3a920244478..21cb8708ceccda 100644
--- a/static/app/components/organizations/pageFilters/desyncedFilter.tsx
+++ b/static/app/components/organizations/pageFilters/desyncedFilter.tsx
@@ -36,14 +36,14 @@ export function DesyncedFilterMessage() {
}
export const DesyncedFilterIndicator = styled('div')`
- width: 9px;
- height: 9px;
+ width: 8px;
+ height: 8px;
border-radius: 50%;
background: ${p => p.theme.active};
border: solid 1px ${p => p.theme.background};
position: absolute;
- top: -${space(0.5)};
- right: -${space(0.5)};
+ top: -${space(0.25)};
+ right: -${space(0.75)};
`;
const DesyncedFilterMessageWrap = styled('div')`
diff --git a/static/app/components/organizations/projectPageFilter/trigger.tsx b/static/app/components/organizations/projectPageFilter/trigger.tsx
index 8005a32380698f..93b09ff1281862 100644
--- a/static/app/components/organizations/projectPageFilter/trigger.tsx
+++ b/static/app/components/organizations/projectPageFilter/trigger.tsx
@@ -5,7 +5,6 @@ import Badge from 'sentry/components/badge/badge';
import type {DropdownButtonProps} from 'sentry/components/dropdownButton';
import DropdownButton from 'sentry/components/dropdownButton';
import PlatformList from 'sentry/components/platformList';
-import {IconProject} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Project} from 'sentry/types/project';
@@ -81,19 +80,19 @@ function BaseProjectPageFilterTrigger(
ref={forwardedRef}
data-test-id="page-filter-project-selector"
icon={
-
- {!ready || isAllProjectsSelected || isMyProjectsSelected ? (
-
- ) : (
- p.platform ?? 'other').reverse()}
- />
- )}
- {desynced && }
-
+ ready &&
+ !isAllProjectsSelected &&
+ !isMyProjectsSelected && (
+ p.platform ?? 'other').reverse()}
+ />
+ )
}
>
- {ready ? label : t('Loading\u2026')}
+
+ {ready ? label : t('Loading\u2026')}
+ {desynced && }
+
{remainingCount > 0 && }
);
@@ -101,16 +100,15 @@ function BaseProjectPageFilterTrigger(
export const ProjectPageFilterTrigger = forwardRef(BaseProjectPageFilterTrigger);
-const TriggerLabel = styled('span')`
- ${p => p.theme.overflowEllipsis};
+const TriggerLabelWrap = styled('span')`
position: relative;
- width: auto;
+ min-width: 0;
`;
-const TriggerIconWrap = styled('div')`
+const TriggerLabel = styled('span')`
+ ${p => p.theme.overflowEllipsis};
position: relative;
- display: flex;
- align-items: center;
+ width: auto;
`;
const StyledBadge = styled(Badge)`
diff --git a/static/app/components/replays/player/replayPlayer.tsx b/static/app/components/replays/player/replayPlayer.tsx
index 62bca2fe895691..a31ea43c52f763 100644
--- a/static/app/components/replays/player/replayPlayer.tsx
+++ b/static/app/components/replays/player/replayPlayer.tsx
@@ -79,6 +79,7 @@ function useReplayerInstance() {
interface Props extends HTMLAttributes {
css?: Interpolation;
+ inspectable?: boolean;
offsetMs?: undefined | number;
}
diff --git a/static/app/components/replays/player/styles.tsx b/static/app/components/replays/player/styles.tsx
index a19bb907758972..02a32c09c7ffa0 100644
--- a/static/app/components/replays/player/styles.tsx
+++ b/static/app/components/replays/player/styles.tsx
@@ -18,7 +18,9 @@ export const baseReplayerCss = css`
.replayer-wrapper > iframe {
border: none;
background: white;
+ }
+ &[data-inspectable='true'] .replayer-wrapper > iframe {
/* Set pointer-events to make it easier to right-click & inspect */
pointer-events: initial !important;
}
diff --git a/static/app/components/replays/replayPlayer.tsx b/static/app/components/replays/replayPlayer.tsx
index c76fb517addf64..c63a4836b81d7d 100644
--- a/static/app/components/replays/replayPlayer.tsx
+++ b/static/app/components/replays/replayPlayer.tsx
@@ -20,6 +20,20 @@ type Dimensions = ReturnType['dimensions'];
interface Props {
className?: string;
+ /**
+ * When the player is "inspectable" it'll capture the mouse and things like
+ * css :hover properties will be applied.
+ * This makes it easier to Right-Click > Inspect Dom Element
+ * But it also makes it harder to have sliders or mouse interactions that overlay
+ * on top of the player.
+ *
+ * Therefore, in cases where the replay is in a debugging/video context it
+ * should be interactable.
+ * But when the player is used for things like static rendering or hydration
+ * diffs, people interact with the
+ *
+ */
+ inspectable?: boolean;
/**
* Use when the player is shown in an embedded preview context.
*/
@@ -65,7 +79,12 @@ function useVideoSizeLogger({
}, [organization, windowDimensions, videoDimensions, didLog, analyticsContext]);
}
-function BasePlayerRoot({className, overlayContent, isPreview = false}: Props) {
+function BasePlayerRoot({
+ className,
+ overlayContent,
+ isPreview = false,
+ inspectable,
+}: Props) {
const {
dimensions: videoDimensions,
fastForwardSpeed,
@@ -154,7 +173,7 @@ function BasePlayerRoot({className, overlayContent, isPreview = false}: Props) {
)}
-
+
{fastForwardSpeed ? : null}
{isBuffering || isVideoBuffering ? : null}
{isPreview || isVideoReplay || isFetching ? null : }
diff --git a/static/app/components/replays/replayView.tsx b/static/app/components/replays/replayView.tsx
index d278fc14a04ec7..8f25c0a3403964 100644
--- a/static/app/components/replays/replayView.tsx
+++ b/static/app/components/replays/replayView.tsx
@@ -56,7 +56,7 @@ function ReplayView({toggleFullscreen, isLoading}: Props) {
-
+
)}
diff --git a/static/app/components/replays/videoReplayer.spec.tsx b/static/app/components/replays/videoReplayer.spec.tsx
index 7270742279813d..88c56381b14f19 100644
--- a/static/app/components/replays/videoReplayer.spec.tsx
+++ b/static/app/components/replays/videoReplayer.spec.tsx
@@ -8,7 +8,6 @@ import {VideoReplayer} from './videoReplayer';
// replays.
//
// advancing by 2000ms ~== 20000s in Timer, but this may depend on hardware, TBD
-// biome-ignore lint/correctness/useHookAtTopLevel: not a hook
jest.useFakeTimers();
jest.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
diff --git a/static/app/components/searchQueryBuilder/context.tsx b/static/app/components/searchQueryBuilder/context.tsx
index fab91ef448ad1b..b33325b73a86f2 100644
--- a/static/app/components/searchQueryBuilder/context.tsx
+++ b/static/app/components/searchQueryBuilder/context.tsx
@@ -11,6 +11,7 @@ import type {FieldDefinition} from 'sentry/utils/fields';
export interface SearchQueryBuilderContextData {
disabled: boolean;
+ disallowFreeText: boolean;
disallowWildcard: boolean;
dispatch: Dispatch;
filterKeyMenuWidth: number;
@@ -48,5 +49,6 @@ export const SearchQueryBuilderContext = createContext = {
- getTagValues: jest.fn(),
+ getTagValues: jest.fn(() => Promise.resolve([])),
initialQuery: '',
filterKeySections: FITLER_KEY_SECTIONS,
filterKeys: FILTER_KEYS,
@@ -189,7 +190,7 @@ describe('SearchQueryBuilder', function () {
onSearch={mockOnSearch}
/>
);
- userEvent.click(screen.getByRole('button', {name: 'Clear search query'}));
+ await userEvent.click(screen.getByRole('button', {name: 'Clear search query'}));
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalledWith('', expect.anything());
@@ -203,7 +204,7 @@ describe('SearchQueryBuilder', function () {
expect(screen.getByRole('combobox')).toHaveFocus();
});
- it('is hidden at small sizes', function () {
+ it('is hidden at small sizes', async function () {
Object.defineProperty(Element.prototype, 'clientWidth', {value: 100});
const mockOnChange = jest.fn();
render(
@@ -213,6 +214,8 @@ describe('SearchQueryBuilder', function () {
onChange={mockOnChange}
/>
);
+ // Must await something to prevent act warnings
+ await act(tick);
expect(
screen.queryByRole('button', {name: 'Clear search query'})
@@ -221,7 +224,7 @@ describe('SearchQueryBuilder', function () {
});
describe('disabled', function () {
- it('disables all interactable elements', function () {
+ it('disables all interactable elements', async function () {
const mockOnChange = jest.fn();
render(
);
+ // Must await something to prevent act warnings
+ await act(tick);
expect(getLastInput()).toBeDisabled();
expect(
@@ -804,10 +809,8 @@ describe('SearchQueryBuilder', function () {
// function which causes an act warning despite using userEvent.click.
// Cannot find a way to avoid this warning.
jest.spyOn(console, 'error').mockImplementation(jest.fn());
- await userEvent.type(
- screen.getByRole('combobox'),
- 'some free text brow{ArrowDown}{Enter}'
- );
+ await userEvent.type(screen.getByRole('combobox'), 'some free text brow');
+ await userEvent.click(screen.getByRole('option', {name: 'browser.name'}));
jest.restoreAllMocks();
// Filter value should have focus
@@ -835,6 +838,50 @@ describe('SearchQueryBuilder', function () {
});
});
+ describe('filter key suggestions', function () {
+ it('will suggest a filter key when typing its value', async function () {
+ render( );
+ await userEvent.click(getLastInput());
+
+ // Typing "firefox" should show suggestions for the filter "browser.name"
+ await userEvent.type(
+ screen.getByRole('combobox', {name: 'Add a search term'}),
+ 'firefox'
+ );
+ const suggestionItem = await screen.findByRole('option', {
+ name: 'browser.name:Firefox',
+ });
+
+ // Clicking it should add the filter and put focus at the end
+ await userEvent.click(suggestionItem);
+ expect(screen.getByRole('row', {name: 'browser.name:Firefox'})).toBeInTheDocument();
+ expect(getLastInput()).toHaveFocus();
+ });
+
+ it('will suggest a raw search when typing with a space', async function () {
+ const mockOnSearch = jest.fn();
+ render(
+
+ );
+ await userEvent.click(getLastInput());
+
+ // Typing "foo bar" should show a suggestion for the raw search "foo bar"
+ await userEvent.type(
+ screen.getByRole('combobox', {name: 'Add a search term'}),
+ 'foo bar'
+ );
+ const suggestionItem = await screen.findByRole('option', {
+ name: '"foo bar"',
+ });
+
+ // Clicking it should add quotes and fire the search
+ await userEvent.click(suggestionItem);
+ expect(screen.getByRole('row', {name: '"foo bar"'})).toBeInTheDocument();
+ expect(getLastInput()).toHaveFocus();
+ expect(mockOnSearch).toHaveBeenCalledWith('"foo bar"', expect.anything());
+ });
+ });
+
describe('keyboard interactions', function () {
beforeEach(() => {
// jsdom does not support clipboard API
@@ -969,10 +1016,11 @@ describe('SearchQueryBuilder', function () {
);
- // Focus into search (cursor be at end of the query)
- screen
- .getByRole('button', {name: 'Edit operator for filter: browser.name'})
- .focus();
+ // Focus the filter operator dropdown
+ const opButton = await screen.findByRole('button', {
+ name: 'Edit operator for filter: browser.name',
+ });
+ await act(() => opButton.focus());
// Pressing backspace once should focus the token
await userEvent.keyboard('{backspace}');
@@ -1647,7 +1695,7 @@ describe('SearchQueryBuilder', function () {
expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveFocus();
});
- it('collapses many selected options', function () {
+ it('collapses many selected options', async function () {
render(
);
- const valueButton = screen.getByRole('button', {
+ const valueButton = await screen.findByRole('button', {
name: 'Edit value for filter: browser.name',
});
expect(within(valueButton).getByText('one')).toBeInTheDocument();
@@ -1670,7 +1718,8 @@ describe('SearchQueryBuilder', function () {
['spaces', 'a b', '"a b"'],
['quotes', 'a"b', '"a\\"b"'],
['parens', 'foo()', '"foo()"'],
- ])('tag values escape %s', async (_, value, expected) => {
+ ['commas', '"a,b"', '"a,b"'],
+ ])('typed tag values escape %s', async (_, value, expected) => {
const mockOnChange = jest.fn();
render(
{
+ const mockOnChange = jest.fn();
+ const mockGetTagValues = jest.fn().mockResolvedValue([value]);
+ render(
+
+ );
+
+ await userEvent.click(
+ screen.getByRole('button', {name: 'Edit value for filter: custom_tag_name'})
+ );
+ await userEvent.click(await screen.findByRole('option', {name: value}));
+
+ // Value should be surrounded by quotes and escaped
+ await waitFor(() => {
+ expect(mockOnChange).toHaveBeenCalledWith(
+ `custom_tag_name:${expected}`,
+ expect.anything()
+ );
+ });
+
+ // Open menu again and check to see if value is correct
+ await userEvent.click(
+ screen.getByRole('button', {name: 'Edit value for filter: custom_tag_name'})
+ );
+
+ // Input value should have the escaped value (with a trailing comma)
+ expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveValue(
+ expected + ','
+ );
+
+ // The original value should be selected in the dropdown
+ expect(
+ within(await screen.findByRole('option', {name: value})).getByRole('checkbox')
+ ).toBeChecked();
+ });
+
it('can replace a value with a new one', async function () {
render(
@@ -2319,14 +2414,14 @@ describe('SearchQueryBuilder', function () {
});
});
- it('displays absolute date value correctly (just date)', function () {
+ it('displays absolute date value correctly (just date)', async function () {
render( );
- expect(screen.getByText('is on or after')).toBeInTheDocument();
+ expect(await screen.findByText('is on or after')).toBeInTheDocument();
expect(screen.getByText('Oct 17')).toBeInTheDocument();
});
- it('displays absolute date value correctly (with local time)', function () {
+ it('displays absolute date value correctly (with local time)', async function () {
render(
);
- expect(screen.getByText('is on or after')).toBeInTheDocument();
+ expect(await screen.findByText('is on or after')).toBeInTheDocument();
expect(screen.getByText('Oct 17, 2:00 PM')).toBeInTheDocument();
});
- it('displays absolute date value correctly (with UTC time)', function () {
+ it('displays absolute date value correctly (with UTC time)', async function () {
render(
);
- expect(screen.getByText('is on or after')).toBeInTheDocument();
+ expect(await screen.findByText('is on or after')).toBeInTheDocument();
expect(screen.getByText('Oct 17, 2:00 PM UTC')).toBeInTheDocument();
});
});
@@ -2720,6 +2815,7 @@ describe('SearchQueryBuilder', function () {
);
await userEvent.click(getLastInput());
+ await userEvent.keyboard('{Escape}'); // Dismiss suggestion menu
expect(
await screen.findByText('Wildcards not supported in search')
).toBeInTheDocument();
diff --git a/static/app/components/searchQueryBuilder/index.stories.tsx b/static/app/components/searchQueryBuilder/index.stories.tsx
index 0c43e2d55652ae..3a0b68dcc6454f 100644
--- a/static/app/components/searchQueryBuilder/index.stories.tsx
+++ b/static/app/components/searchQueryBuilder/index.stories.tsx
@@ -48,7 +48,7 @@ const FILTER_KEYS: TagCollection = {
name: 'Browser Name',
kind: FieldKind.FIELD,
predefined: true,
- values: ['Chrome', 'Firefox', 'Safari', 'Edge'],
+ values: ['Chrome', 'Firefox', 'Safari', 'Edge', 'Internet Explorer', 'Opera 1,2'],
},
[FieldKey.IS]: {
key: FieldKey.IS,
diff --git a/static/app/components/searchQueryBuilder/index.tsx b/static/app/components/searchQueryBuilder/index.tsx
index 3127ef355c9ad6..8104d1f704255c 100644
--- a/static/app/components/searchQueryBuilder/index.tsx
+++ b/static/app/components/searchQueryBuilder/index.tsx
@@ -249,6 +249,7 @@ export function SearchQueryBuilder({
return {
...state,
disabled,
+ disallowFreeText: Boolean(disallowFreeText),
disallowWildcard: Boolean(disallowWildcard),
parsedQuery,
filterKeySections: filterKeySections ?? [],
@@ -267,6 +268,7 @@ export function SearchQueryBuilder({
}, [
state,
disabled,
+ disallowFreeText,
disallowWildcard,
parsedQuery,
filterKeySections,
diff --git a/static/app/components/searchQueryBuilder/tokens/combobox.tsx b/static/app/components/searchQueryBuilder/tokens/combobox.tsx
index e72d636549596f..cf93eb4f493f3a 100644
--- a/static/app/components/searchQueryBuilder/tokens/combobox.tsx
+++ b/static/app/components/searchQueryBuilder/tokens/combobox.tsx
@@ -298,7 +298,6 @@ function OverlayContent>({
hiddenOptions={hiddenOptions}
keyDownHandler={() => true}
overlayIsOpen={isOpen}
- showSectionHeaders={!filterValue}
size="sm"
/>
diff --git a/static/app/components/searchQueryBuilder/tokens/filter/filterKeyCombobox.tsx b/static/app/components/searchQueryBuilder/tokens/filter/filterKeyCombobox.tsx
index e1a5a3c227981a..5c99485a621aea 100644
--- a/static/app/components/searchQueryBuilder/tokens/filter/filterKeyCombobox.tsx
+++ b/static/app/components/searchQueryBuilder/tokens/filter/filterKeyCombobox.tsx
@@ -6,7 +6,7 @@ import type {Node} from '@react-types/shared';
import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context';
import {SearchQueryBuilderCombobox} from 'sentry/components/searchQueryBuilder/tokens/combobox';
import {getFilterValueType} from 'sentry/components/searchQueryBuilder/tokens/filter/utils';
-import type {KeyItem} from 'sentry/components/searchQueryBuilder/tokens/filterKeyListBox/types';
+import type {SearchKeyItem} from 'sentry/components/searchQueryBuilder/tokens/filterKeyListBox/types';
import {useSortedFilterKeyItems} from 'sentry/components/searchQueryBuilder/tokens/useSortedFilterKeyItems';
import {getInitialFilterText} from 'sentry/components/searchQueryBuilder/tokens/utils';
import type {
@@ -28,7 +28,11 @@ type KeyComboboxProps = {
export function FilterKeyCombobox({token, onCommit, item}: KeyComboboxProps) {
const inputRef = useRef(null);
const [inputValue, setInputValue] = useState('');
- const sortedFilterKeys = useSortedFilterKeyItems({filterValue: inputValue});
+ const sortedFilterKeys = useSortedFilterKeyItems({
+ filterValue: inputValue,
+ inputValue,
+ includeSuggestions: false,
+ });
const {dispatch, getFieldDefinition} = useSearchQueryBuilder();
const currentFilterValueType = getFilterValueType(
@@ -78,7 +82,7 @@ export function FilterKeyCombobox({token, onCommit, item}: KeyComboboxProps) {
);
const onOptionSelected = useCallback(
- (option: KeyItem) => {
+ (option: SearchKeyItem) => {
handleSelectKey(option.value);
},
[handleSelectKey]
diff --git a/static/app/components/searchQueryBuilder/tokens/filter/parametersCombobox.tsx b/static/app/components/searchQueryBuilder/tokens/filter/parametersCombobox.tsx
index bed49c98eaf2be..487acc2167edd0 100644
--- a/static/app/components/searchQueryBuilder/tokens/filter/parametersCombobox.tsx
+++ b/static/app/components/searchQueryBuilder/tokens/filter/parametersCombobox.tsx
@@ -7,7 +7,7 @@ import {getEscapedKey} from 'sentry/components/compactSelect/utils';
import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context';
import {SearchQueryBuilderCombobox} from 'sentry/components/searchQueryBuilder/tokens/combobox';
import {FunctionDescription} from 'sentry/components/searchQueryBuilder/tokens/filter/functionDescription';
-import {replaceCommaSeparatedValue} from 'sentry/components/searchQueryBuilder/tokens/filter/utils';
+import {replaceCommaSeparatedValue} from 'sentry/components/searchQueryBuilder/tokens/filter/replaceCommaSeparatedValue';
import type {AggregateFilter} from 'sentry/components/searchSyntax/parser';
import {t} from 'sentry/locale';
import {FieldKind, FieldValueType} from 'sentry/utils/fields';
diff --git a/static/app/components/searchQueryBuilder/tokens/filter/parsers/string/grammar.pegjs b/static/app/components/searchQueryBuilder/tokens/filter/parsers/string/grammar.pegjs
new file mode 100644
index 00000000000000..7c87c913442706
--- /dev/null
+++ b/static/app/components/searchQueryBuilder/tokens/filter/parsers/string/grammar.pegjs
@@ -0,0 +1,44 @@
+{
+ const { TokenConverter, config = {} } = options;
+ const tc = new TokenConverter({text, location, config});
+}
+
+text_in_list
+ = item1:text_in_value
+ items:item* {
+ return tc.tokenValueTextList(item1, items);
+ }
+
+item = s1:spaces c:comma s2:spaces value:(!comma text_in_value)? {
+ return [s1, c, s2, value ?? [undefined, tc.tokenValueText('', false)]];
+}
+
+text_in_value
+ = quoted_value / in_value / empty_value
+
+empty_value
+ = spaces {
+ return tc.tokenValueText(text(), false);
+ }
+
+in_value
+ = (in_value_char)+ {
+ return tc.tokenValueText(text(), false);
+ }
+
+quoted_value
+ = '"' value:('\\"' / [^"])* '"' {
+ return tc.tokenValueText(value.join(''), true);
+ }
+
+in_value_termination
+ = in_value_char (!in_value_end in_value_char)* in_value_end
+
+in_value_char
+ = [^,]
+
+in_value_end
+ = (spaces comma)
+
+comma = ","
+spaces = " "*
diff --git a/static/app/components/searchQueryBuilder/tokens/filter/parsers/string/parser.spec.tsx b/static/app/components/searchQueryBuilder/tokens/filter/parsers/string/parser.spec.tsx
new file mode 100644
index 00000000000000..aa1333237fb8d8
--- /dev/null
+++ b/static/app/components/searchQueryBuilder/tokens/filter/parsers/string/parser.spec.tsx
@@ -0,0 +1,97 @@
+import {parseMultiSelectFilterValue} from 'sentry/components/searchQueryBuilder/tokens/filter/parsers/string/parser';
+
+describe('parseMultiSelectValue', function () {
+ it('single value', function () {
+ const result = parseMultiSelectFilterValue('a');
+
+ expect(result).not.toBeNull();
+
+ expect(result!.items).toHaveLength(1);
+ expect(result?.items[0].value?.value).toEqual('a');
+ });
+
+ it('multiple value', function () {
+ const result = parseMultiSelectFilterValue('a,b,c');
+
+ expect(result).not.toBeNull();
+
+ expect(result!.items).toHaveLength(3);
+ expect(result?.items[0].value?.value).toEqual('a');
+ expect(result?.items[1].value?.value).toEqual('b');
+ expect(result?.items[2].value?.value).toEqual('c');
+ });
+
+ it('quoted value', function () {
+ const result = parseMultiSelectFilterValue('a,"b",c');
+
+ expect(result).not.toBeNull();
+
+ expect(result!.items).toHaveLength(3);
+ expect(result?.items[0].value?.value).toEqual('a');
+
+ expect(result?.items[1].value?.value).toEqual('b');
+ expect(result?.items[1].value?.text).toEqual('"b"');
+ expect(result?.items[1].value?.quoted).toBe(true);
+
+ expect(result?.items[2].value?.value).toEqual('c');
+ });
+
+ it('just quotes', function () {
+ const result = parseMultiSelectFilterValue('""');
+
+ expect(result).not.toBeNull();
+
+ expect(result!.items).toHaveLength(1);
+ const item = result!.items[0];
+
+ expect(item.value?.value).toEqual('');
+ expect(item.value?.text).toEqual('""');
+ expect(item.value?.quoted).toBe(true);
+ });
+
+ it('single empty value', function () {
+ const result = parseMultiSelectFilterValue('');
+
+ expect(result).not.toBeNull();
+
+ expect(result!.items).toHaveLength(1);
+ const item = result!.items[0];
+
+ expect(item.value!.value).toBe('');
+ });
+
+ it('multiple empty value', function () {
+ const result = parseMultiSelectFilterValue('a,,b');
+
+ expect(result).not.toBeNull();
+
+ expect(result!.items).toHaveLength(3);
+
+ expect(result?.items[0].value?.value).toEqual('a');
+ expect(result?.items[1].value?.value).toBe('');
+ expect(result?.items[2].value?.value).toEqual('b');
+ });
+
+ it('trailing comma', function () {
+ const result = parseMultiSelectFilterValue('a,');
+
+ expect(result).not.toBeNull();
+
+ expect(result!.items).toHaveLength(2);
+
+ expect(result?.items[0].value?.value).toEqual('a');
+ expect(result?.items[1].value?.value).toBe('');
+ });
+
+ it('spaces', function () {
+ const result = parseMultiSelectFilterValue('a,b c,d');
+
+ expect(result).not.toBeNull();
+
+ expect(result!.items).toHaveLength(3);
+
+ expect(result?.items[0].value?.value).toEqual('a');
+ expect(result?.items[1].value?.value).toEqual('b c');
+ expect(result?.items[2].value?.value).toEqual('d');
+ });
+});
diff --git a/static/app/components/searchQueryBuilder/tokens/filter/parsers/string/parser.tsx b/static/app/components/searchQueryBuilder/tokens/filter/parsers/string/parser.tsx
new file mode 100644
index 00000000000000..a45f0195d2d348
--- /dev/null
+++ b/static/app/components/searchQueryBuilder/tokens/filter/parsers/string/parser.tsx
@@ -0,0 +1,24 @@
+import {
+ type Token,
+ TokenConverter,
+ type TokenResult,
+} from 'sentry/components/searchSyntax/parser';
+
+import grammar from './grammar.pegjs';
+
+/**
+ * Parses the user input value of a multi select filter.
+ *
+ * This is different from the search syntax parser in the following ways:
+ * - Does not look for surrounding []
+ * - Does not disallow spaces or parens outside of quoted values
+ */
+export function parseMultiSelectFilterValue(
+ value: string
+): TokenResult | null {
+ try {
+ return grammar.parse(value, {TokenConverter});
+ } catch (e) {
+ return null;
+ }
+}
diff --git a/static/app/components/searchQueryBuilder/tokens/filter/replaceCommaSeparatedValue.spec.tsx b/static/app/components/searchQueryBuilder/tokens/filter/replaceCommaSeparatedValue.spec.tsx
new file mode 100644
index 00000000000000..b057047c963324
--- /dev/null
+++ b/static/app/components/searchQueryBuilder/tokens/filter/replaceCommaSeparatedValue.spec.tsx
@@ -0,0 +1,31 @@
+import {replaceCommaSeparatedValue} from 'sentry/components/searchQueryBuilder/tokens/filter/replaceCommaSeparatedValue';
+
+describe('replaceCommaSeparatedValue', function () {
+ it('replaces a value without commas', function () {
+ expect(replaceCommaSeparatedValue('foo', 3, 'bar')).toBe('bar');
+ });
+
+ it('replaces an empty value at end', function () {
+ expect(replaceCommaSeparatedValue('foo,', 4, 'bar')).toBe('foo,bar');
+ });
+
+ it('replaces an empty value at start', function () {
+ expect(replaceCommaSeparatedValue(',foo', 0, 'bar')).toBe('bar,foo');
+ });
+
+ it('replaces an empty value in middle', function () {
+ expect(replaceCommaSeparatedValue('foo,,baz', 4, 'bar')).toBe('foo,bar,baz');
+ });
+
+ it('replaces an non-empty value at end', function () {
+ expect(replaceCommaSeparatedValue('foo,abc', 4, 'bar')).toBe('foo,bar');
+ });
+
+ it('replaces an non-empty value at start', function () {
+ expect(replaceCommaSeparatedValue('abc,foo', 0, 'bar')).toBe('bar,foo');
+ });
+
+ it('replaces an non-empty value in middle', function () {
+ expect(replaceCommaSeparatedValue('foo,abc,baz', 4, 'bar')).toBe('foo,bar,baz');
+ });
+});
diff --git a/static/app/components/searchQueryBuilder/tokens/filter/replaceCommaSeparatedValue.tsx b/static/app/components/searchQueryBuilder/tokens/filter/replaceCommaSeparatedValue.tsx
new file mode 100644
index 00000000000000..f6e37935d8738d
--- /dev/null
+++ b/static/app/components/searchQueryBuilder/tokens/filter/replaceCommaSeparatedValue.tsx
@@ -0,0 +1,41 @@
+import {parseMultiSelectFilterValue} from 'sentry/components/searchQueryBuilder/tokens/filter/parsers/string/parser';
+
+/**
+ * Replaces the focused parameter (at cursorPosition) with the new value.
+ * If cursorPosition is null, will default to the end of the string.
+ *
+ * Example:
+ * replaceCommaSeparatedValue('foo,bar,baz', 5, 'new') => 'foo,new,baz'
+ */
+export function replaceCommaSeparatedValue(
+ value: string,
+ cursorPosition: number | null,
+ replacement: string
+) {
+ const parsed = parseMultiSelectFilterValue(value);
+
+ if (!parsed) {
+ return value;
+ }
+
+ if (cursorPosition === null) {
+ cursorPosition = value.length;
+ }
+
+ const matchingIndex = parsed.items.findIndex(
+ item =>
+ item.value &&
+ item.value?.location.start.offset <= cursorPosition &&
+ item.value?.location.end.offset >= cursorPosition
+ );
+
+ if (matchingIndex === -1) {
+ return replacement;
+ }
+
+ return [
+ ...parsed.items.slice(0, matchingIndex).map(item => item.value?.text ?? ''),
+ replacement,
+ ...parsed.items.slice(matchingIndex + 1).map(item => item.value?.text ?? ''),
+ ].join(',');
+}
diff --git a/static/app/components/searchQueryBuilder/tokens/filter/utils.tsx b/static/app/components/searchQueryBuilder/tokens/filter/utils.tsx
index ec67c53ff7d1b1..35e8464dcc7bde 100644
--- a/static/app/components/searchQueryBuilder/tokens/filter/utils.tsx
+++ b/static/app/components/searchQueryBuilder/tokens/filter/utils.tsx
@@ -16,7 +16,7 @@ import {
getFieldDefinition,
} from 'sentry/utils/fields';
-const SHOULD_ESCAPE_REGEX = /[\s"()]/;
+const SHOULD_ESCAPE_REGEX = /[\s"(),]/;
export function isAggregateFilterToken(
token: TokenResult
@@ -91,32 +91,6 @@ export function formatFilterValue(token: TokenResult['value']): st
}
}
-/**
- * Replaces the focused parameter (at cursorPosition) with the new value.
- * If cursorPosition is null, will default to the end of the string.
- *
- * Example:
- * replaceCommaSeparatedValue('foo,bar,baz', 5, 'new') => 'foo,new,baz'
- */
-export function replaceCommaSeparatedValue(
- value: string,
- cursorPosition: number | null,
- replacement: string
-) {
- const items = value.split(',');
-
- let characterCount = 0;
- for (let i = 0; i < items.length; i++) {
- characterCount += items[i].length + 1;
- if (characterCount > (cursorPosition ?? value.length + 1)) {
- const newItems = [...items.slice(0, i), replacement, ...items.slice(i + 1)];
- return newItems.map(item => item.trim()).join(',');
- }
- }
-
- return value;
-}
-
/**
* Gets the value type for a given token.
*
diff --git a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx
index a12d35d9feb7e9..93314c5ec985c8 100644
--- a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx
+++ b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx
@@ -12,12 +12,13 @@ import {
type CustomComboboxMenu,
SearchQueryBuilderCombobox,
} from 'sentry/components/searchQueryBuilder/tokens/combobox';
+import {parseMultiSelectFilterValue} from 'sentry/components/searchQueryBuilder/tokens/filter/parsers/string/parser';
+import {replaceCommaSeparatedValue} from 'sentry/components/searchQueryBuilder/tokens/filter/replaceCommaSeparatedValue';
import SpecificDatePicker from 'sentry/components/searchQueryBuilder/tokens/filter/specificDatePicker';
import {
escapeTagValue,
formatFilterValue,
getFilterValueType,
- replaceCommaSeparatedValue,
unescapeTagValue,
} from 'sentry/components/searchQueryBuilder/tokens/filter/utils';
import {ValueListBox} from 'sentry/components/searchQueryBuilder/tokens/filter/valueListBox';
@@ -55,7 +56,7 @@ import {trackAnalytics} from 'sentry/utils/analytics';
import {uniq} from 'sentry/utils/array/uniq';
import {type FieldDefinition, FieldValueType} from 'sentry/utils/fields';
import {isCtrlKeyPressed} from 'sentry/utils/isCtrlKeyPressed';
-import {type QueryKey, useQuery} from 'sentry/utils/queryClient';
+import {keepPreviousData, type QueryKey, useQuery} from 'sentry/utils/queryClient';
import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
import useKeyPress from 'sentry/utils/useKeyPress';
import useOrganization from 'sentry/utils/useOrganization';
@@ -74,15 +75,19 @@ function isStringFilterValues(
}
function getMultiSelectInputValue(token: TokenResult) {
+ // Even if this is a multi-select filter, it won't be parsed as such if only a single value is provided
if (
token.value.type !== Token.VALUE_TEXT_LIST &&
token.value.type !== Token.VALUE_NUMBER_LIST
) {
- const value = token.value.value;
- return value ? value + ',' : '';
+ if (!token.value.value) {
+ return '';
+ }
+
+ return token.value.text + ',';
}
- const items = token.value.items.map(item => item.value.value);
+ const items = token.value.items.map(item => item.value?.text ?? '');
if (items.length === 0) {
return '';
@@ -92,21 +97,45 @@ function getMultiSelectInputValue(token: TokenResult) {
}
function prepareInputValueForSaving(valueType: FieldValueType, inputValue: string) {
- const values = uniq(
- inputValue
- .split(',')
- .map(v => cleanFilterValue({valueType, value: v.trim()}))
- .filter(v => v && v.length > 0)
- );
+ const parsed = parseMultiSelectFilterValue(inputValue);
+
+ if (!parsed) {
+ return '""';
+ }
+
+ const values =
+ parsed.items
+ .map(item =>
+ item.value?.quoted
+ ? item.value?.text ?? ''
+ : cleanFilterValue({valueType, value: item.value?.text ?? ''})
+ )
+ .filter(text => text?.length) ?? [];
- return values.length > 1 ? `[${values.join(',')}]` : values[0] ?? '""';
+ const uniqueValues = uniq(values);
+
+ return uniqueValues.length > 1
+ ? `[${uniqueValues.join(',')}]`
+ : uniqueValues[0] ?? '""';
}
-function getSelectedValuesFromText(text: string) {
- return text
- .split(',')
- .map(v => unescapeTagValue(v.trim()))
- .filter(v => v.length > 0);
+function getSelectedValuesFromText(
+ text: string,
+ {escaped = true}: {escaped?: boolean} = {}
+) {
+ const parsed = parseMultiSelectFilterValue(text);
+
+ if (!parsed) {
+ return [];
+ }
+
+ return parsed.items
+ .filter(item => item.value?.value)
+ .map(item => {
+ return (
+ (escaped ? item.value?.text : unescapeTagValue(item.value?.value ?? '')) ?? ''
+ );
+ });
}
function getValueAtCursorPosition(text: string, cursorPosition: number | null) {
@@ -308,7 +337,7 @@ function useFilterSuggestions({
const {data, isFetching} = useQuery({
queryKey: debouncedQueryKey,
queryFn: () => getTagValues(key ? key : {key: keyName, name: keyName}, filterValue),
- keepPreviousData: true,
+ placeholderData: keepPreviousData,
enabled: shouldFetchValues,
});
@@ -489,8 +518,11 @@ export function SearchQueryBuilderValueCombobox({
? getValueAtCursorPosition(inputValue, selectionIndex)
: inputValue;
- const selectedValues = useMemo(
- () => (canSelectMultipleValues ? getSelectedValuesFromText(inputValue) : []),
+ const selectedValuesUnescaped = useMemo(
+ () =>
+ canSelectMultipleValues
+ ? getSelectedValuesFromText(inputValue, {escaped: false})
+ : [],
[canSelectMultipleValues, inputValue]
);
@@ -517,7 +549,7 @@ export function SearchQueryBuilderValueCombobox({
const {items, suggestionSectionItems, isFetching} = useFilterSuggestions({
token,
filterValue,
- selectedValues,
+ selectedValues: selectedValuesUnescaped,
ctrlKeyPressed,
});
@@ -553,11 +585,15 @@ export function SearchQueryBuilderValueCombobox({
}
if (canSelectMultipleValues) {
- if (selectedValues.includes(value)) {
+ if (selectedValuesUnescaped.includes(value)) {
const newValue = prepareInputValueForSaving(
getFilterValueType(token, fieldDefinition),
- selectedValues.filter(v => v !== value).join(',')
+ selectedValuesUnescaped
+ .filter(v => v !== value)
+ .map(escapeTagValue)
+ .join(',')
);
+
dispatch({
type: 'UPDATE_TOKEN_VALUE',
token: token,
@@ -576,7 +612,7 @@ export function SearchQueryBuilderValueCombobox({
token: token,
value: prepareInputValueForSaving(
getFilterValueType(token, fieldDefinition),
- replaceCommaSeparatedValue(inputValue, selectionIndex, value)
+ replaceCommaSeparatedValue(inputValue, selectionIndex, escapeTagValue(value))
),
});
@@ -595,16 +631,16 @@ export function SearchQueryBuilderValueCombobox({
return true;
},
[
- analyticsData,
+ token,
+ fieldDefinition,
canSelectMultipleValues,
+ analyticsData,
+ selectedValuesUnescaped,
dispatch,
- fieldDefinition,
inputValue,
- onCommit,
- selectedValues,
selectionIndex,
- token,
ctrlKeyPressed,
+ onCommit,
]
);
diff --git a/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/types.tsx b/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/types.tsx
index 89cf6a09437095..b12ddfde6169d0 100644
--- a/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/types.tsx
+++ b/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/types.tsx
@@ -16,11 +16,21 @@ export interface KeyItem extends SelectOptionWithKey {
}
export interface KeySectionItem extends SelectSectionWithKey {
- options: KeyItem[];
+ options: SearchKeyItem[];
type: 'section';
value: string;
}
+export interface RawSearchItem extends SelectOptionWithKey {
+ type: 'raw-search';
+ value: string;
+}
+
+export interface FilterValueItem extends SelectOptionWithKey {
+ type: 'filter-value';
+ value: string;
+}
+
export interface RecentFilterItem extends SelectOptionWithKey {
type: 'recent-filter';
value: string;
@@ -32,7 +42,15 @@ export interface RecentQueryItem extends SelectOptionWithKey {
value: string;
}
-export type FilterKeyItem = KeyItem | RecentFilterItem | KeySectionItem | RecentQueryItem;
+export type SearchKeyItem = KeySectionItem | KeyItem | RawSearchItem | FilterValueItem;
+
+export type FilterKeyItem =
+ | KeyItem
+ | RecentFilterItem
+ | KeySectionItem
+ | RecentQueryItem
+ | RawSearchItem
+ | FilterValueItem;
export type Section = {
label: ReactNode;
diff --git a/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/utils.tsx b/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/utils.tsx
index ee4b965aa4b664..6e65f2d675cad2 100644
--- a/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/utils.tsx
+++ b/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/utils.tsx
@@ -1,9 +1,13 @@
+import styled from '@emotion/styled';
+
import {getEscapedKey} from 'sentry/components/compactSelect/utils';
import {FormattedQuery} from 'sentry/components/searchQueryBuilder/formattedQuery';
import {KeyDescription} from 'sentry/components/searchQueryBuilder/tokens/filterKeyListBox/keyDescription';
import type {
+ FilterValueItem,
KeyItem,
KeySectionItem,
+ RawSearchItem,
RecentQueryItem,
} from 'sentry/components/searchQueryBuilder/tokens/filterKeyListBox/types';
import type {
@@ -13,6 +17,7 @@ import type {
import {t} from 'sentry/locale';
import type {RecentSearch, Tag, TagCollection} from 'sentry/types/group';
import {type FieldDefinition, FieldKind} from 'sentry/utils/fields';
+import {escapeFilterValue} from 'sentry/utils/tokenizeSearch';
export const ALL_CATEGORY_VALUE = '__all' as const;
export const RECENT_SEARCH_CATEGORY_VALUE = '__recent_searches' as const;
@@ -26,6 +31,10 @@ export const RECENT_SEARCH_CATEGORY = {
const RECENT_FILTER_KEY_PREFIX = '__recent_filter_key__';
const RECENT_QUERY_KEY_PREFIX = '__recent_search__';
+function trimQuotes(value) {
+ return value.replace(/^"+|"+$/g, '');
+}
+
export function createRecentFilterOptionKey(filter: string) {
return getEscapedKey(`${RECENT_FILTER_KEY_PREFIX}${filter}`);
}
@@ -90,6 +99,37 @@ export function createItem(
};
}
+export function createRawSearchItem(value: string): RawSearchItem {
+ const quoted = `"${trimQuotes(value)}"`;
+
+ return {
+ key: getEscapedKey(quoted),
+ label: quoted,
+ value: quoted,
+ textValue: quoted,
+ hideCheck: true,
+ showDetailsInOverlay: true,
+ details: null,
+ type: 'raw-search',
+ trailingItems: {t('Raw Search')} ,
+ };
+}
+
+export function createFilterValueItem(key: string, value: string): FilterValueItem {
+ const filter = `${key}:${escapeFilterValue(value)}`;
+
+ return {
+ key: getEscapedKey(`${key}:${value}`),
+ label: ,
+ value: filter,
+ textValue: filter,
+ hideCheck: true,
+ showDetailsInOverlay: true,
+ details: null,
+ type: 'filter-value',
+ };
+}
+
export function createRecentFilterItem({filter}: {filter: string}) {
return {
key: createRecentFilterOptionKey(filter),
@@ -124,3 +164,8 @@ export function createRecentQueryItem({
hideCheck: true,
};
}
+
+const SearchItemLabel = styled('div')`
+ color: ${p => p.theme.subText};
+ white-space: nowrap;
+`;
diff --git a/static/app/components/searchQueryBuilder/tokens/freeText.tsx b/static/app/components/searchQueryBuilder/tokens/freeText.tsx
index c4f0f82e5080b3..7858c6e3b303fd 100644
--- a/static/app/components/searchQueryBuilder/tokens/freeText.tsx
+++ b/static/app/components/searchQueryBuilder/tokens/freeText.tsx
@@ -64,17 +64,9 @@ function getWordAtCursorPosition(value: string, cursorPosition: number) {
}
/**
- * Replaces the focused word (at cursorPosition) with the selected filter key.
- *
- * Example:
- * replaceFocusedWordWithFilter('before brow after', 9, 'browser.name') => 'before browser.name: after'
+ * Replaces the focused word (at cursorPosition) with the given text.
*/
-function replaceFocusedWordWithFilter(
- value: string,
- cursorPosition: number,
- key: string,
- getFieldDefinition: FieldDefinitionGetter
-) {
+function replaceFocusedWord(value: string, cursorPosition: number, replacement: string) {
const words = value.split(' ');
let characterCount = 0;
@@ -83,7 +75,7 @@ function replaceFocusedWordWithFilter(
if (characterCount >= cursorPosition) {
return (
value.slice(0, characterCount - word.length - 1).trim() +
- ` ${getInitialFilterText(key, getFieldDefinition(key))} ` +
+ ` ${replacement} ` +
value.slice(characterCount).trim()
).trim();
}
@@ -92,6 +84,25 @@ function replaceFocusedWordWithFilter(
return value;
}
+/**
+ * Replaces the focused word (at cursorPosition) with the selected filter key.
+ *
+ * Example:
+ * replaceFocusedWordWithFilter('before brow after', 9, 'browser.name') => 'before browser.name: after'
+ */
+function replaceFocusedWordWithFilter(
+ value: string,
+ cursorPosition: number,
+ key: string,
+ getFieldDefinition: FieldDefinitionGetter
+) {
+ return replaceFocusedWord(
+ value,
+ cursorPosition,
+ getInitialFilterText(key, getFieldDefinition(key))
+ );
+}
+
function countPreviousItemsOfType({
state,
type,
@@ -119,7 +130,7 @@ function calculateNextFocusForFilter(state: ListState): FocusO
};
}
-function calculateNextFocusForParen(item: Node): FocusOverride {
+function calculateNextFocusForInsertedToken(item: Node): FocusOverride {
const [, tokenTypeIndexStr] = item.key.toString().split(':');
const tokenTypeIndex = parseInt(tokenTypeIndexStr, 10);
@@ -223,7 +234,11 @@ function SearchQueryBuilderInputInternal({
const {customMenu, sectionItems, maxOptions, onKeyDownCapture} = useFilterKeyListBox({
filterValue,
});
- const sortedFilteredItems = useSortedFilterKeyItems({filterValue});
+ const sortedFilteredItems = useSortedFilterKeyItems({
+ filterValue,
+ inputValue,
+ includeSuggestions: true,
+ });
const items = customMenu ? sectionItems : sortedFilteredItems;
@@ -287,6 +302,17 @@ function SearchQueryBuilderInputInternal({
const onPaste = useCallback(
(e: React.ClipboardEvent) => {
+ const {selectionStart, selectionEnd} = inputRef.current ?? {};
+ const currentText = inputRef.current?.value ?? '';
+
+ const allTextSelected = selectionStart === 0 && selectionEnd === currentText.length;
+
+ // If there is text and there is a custom selection, use default paste behavior
+ if (currentText.trim() && !allTextSelected) {
+ return;
+ }
+
+ // Otherwise, we want to parse the clipboard text and replace the current token with it
e.preventDefault();
e.stopPropagation();
@@ -294,12 +320,11 @@ function SearchQueryBuilderInputInternal({
.getData('text/plain')
.replace('\n', '')
.trim();
- const currentText = inputRef.current?.value ?? '';
dispatch({
type: 'REPLACE_TOKENS_WITH_TEXT',
tokens: [token],
- text: currentText + clipboardText,
+ text: clipboardText,
});
resetInputValue();
},
@@ -335,6 +360,27 @@ function SearchQueryBuilderInputInternal({
return;
}
+ if (option.type === 'raw-search') {
+ dispatch({type: 'UPDATE_FREE_TEXT', tokens: [token], text: option.value});
+ resetInputValue();
+
+ // Because the query does not change until a subsequent render,
+ // we need to do the replacement that is does in the reducer here
+ handleSearch(replaceTokensWithPadding(query, [token], option.value));
+ return;
+ }
+
+ if (option.type === 'filter-value' && option.textValue) {
+ dispatch({
+ type: 'UPDATE_FREE_TEXT',
+ tokens: [token],
+ text: replaceFocusedWord(inputValue, selectionIndex, option.textValue),
+ focusOverride: calculateNextFocusForInsertedToken(item),
+ });
+ resetInputValue();
+ return;
+ }
+
const value = option.value;
dispatch({
@@ -398,7 +444,7 @@ function SearchQueryBuilderInputInternal({
type: 'UPDATE_FREE_TEXT',
tokens: [token],
text: e.target.value,
- focusOverride: calculateNextFocusForParen(item),
+ focusOverride: calculateNextFocusForInsertedToken(item),
});
resetInputValue();
return;
diff --git a/static/app/components/searchQueryBuilder/tokens/useSortedFilterKeyItems.tsx b/static/app/components/searchQueryBuilder/tokens/useSortedFilterKeyItems.tsx
index 67206fff2055f8..d2eb4bee01befe 100644
--- a/static/app/components/searchQueryBuilder/tokens/useSortedFilterKeyItems.tsx
+++ b/static/app/components/searchQueryBuilder/tokens/useSortedFilterKeyItems.tsx
@@ -2,41 +2,224 @@ import {useMemo} from 'react';
import type Fuse from 'fuse.js';
import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context';
-import type {KeyItem} from 'sentry/components/searchQueryBuilder/tokens/filterKeyListBox/types';
-import {createItem} from 'sentry/components/searchQueryBuilder/tokens/filterKeyListBox/utils';
+import type {
+ FilterValueItem,
+ KeySectionItem,
+ SearchKeyItem,
+} from 'sentry/components/searchQueryBuilder/tokens/filterKeyListBox/types';
+import {
+ createFilterValueItem,
+ createItem,
+ createRawSearchItem,
+} from 'sentry/components/searchQueryBuilder/tokens/filterKeyListBox/utils';
+import type {FieldDefinitionGetter} from 'sentry/components/searchQueryBuilder/types';
+import type {Tag} from 'sentry/types/group';
import {defined} from 'sentry/utils';
import {useFuzzySearch} from 'sentry/utils/fuzzySearch';
-const FUZZY_SEARCH_OPTIONS: Fuse.IFuseOptions = {
- keys: ['label', 'description'],
+type FilterKeySearchItem = {
+ description: string;
+ item: Tag;
+ keywords: string[];
+ type: 'value' | 'key';
+ key?: string;
+ value?: string;
+};
+
+const FUZZY_SEARCH_OPTIONS: Fuse.IFuseOptions = {
+ keys: [
+ {name: 'key', weight: 10},
+ {name: 'value', weight: 5},
+ {name: 'keywords', weight: 2},
+ {name: 'description', weight: 1},
+ ],
threshold: 0.2,
includeMatches: false,
minMatchCharLength: 1,
+ includeScore: true,
};
-export function useSortedFilterKeyItems({filterValue}: {filterValue: string}): KeyItem[] {
- const {filterKeys, getFieldDefinition, filterKeySections} = useSearchQueryBuilder();
- const flatItems = useMemo(
- () =>
- Object.values(filterKeys).map(filterKey =>
- createItem(filterKey, getFieldDefinition(filterKey.key))
- ),
- [filterKeys, getFieldDefinition]
- );
- const search = useFuzzySearch(flatItems, FUZZY_SEARCH_OPTIONS);
+function isQuoted(inputValue: string) {
+ return inputValue.startsWith('"') && inputValue.endsWith('"');
+}
+
+// Adds static filter values to the searchable items so that they can be
+// suggested if they appear high in the search results.
+function getFilterSearchValues(
+ keys: Tag[],
+ {getFieldDefinition}: {getFieldDefinition: FieldDefinitionGetter}
+): FilterKeySearchItem[] {
+ return keys.reduce((acc, key) => {
+ const fieldDef = getFieldDefinition(key.key);
+ const values = key.values ?? fieldDef?.values ?? [];
+
+ const addItem = (value: string) => {
+ acc.push({
+ value,
+ description: '',
+ keywords: [],
+ type: 'value',
+ item: key,
+ });
+ };
+
+ for (const value of values) {
+ if (typeof value === 'string') {
+ addItem(value);
+ } else {
+ if (value.children.length) {
+ for (const child of value.children) {
+ if (child.value) {
+ addItem(child.value);
+ }
+ }
+ } else {
+ if (value.value) {
+ addItem(value.value);
+ }
+ }
+ }
+ }
+
+ return acc;
+ }, []);
+}
+
+// Returns a section of suggested filter values.
+// This will suggest a maximum of 3 options, and only if they
+// are more relevant than any of the key suggestions.
+function getValueSuggestionsFromSearchResult(
+ results: Fuse.FuseResult[]
+) {
+ const suggestions: FilterValueItem[] = [];
+
+ for (const result of results) {
+ if (result.item.type === 'key' || suggestions.length >= 3) {
+ break;
+ }
+
+ suggestions.push(
+ createFilterValueItem(result.item.item.key, result.item.value ?? '')
+ );
+ }
+
+ const suggestedFiltersSection: KeySectionItem = {
+ key: 'suggested-filters',
+ value: 'suggested-filters',
+ label: '',
+ options: suggestions,
+ type: 'section',
+ };
+
+ return suggestions.length ? [suggestedFiltersSection] : [];
+}
+
+export function useSortedFilterKeyItems({
+ inputValue,
+ filterValue,
+ includeSuggestions,
+}: {
+ filterValue: string;
+ includeSuggestions: boolean;
+ inputValue: string;
+}): SearchKeyItem[] {
+ const {filterKeys, getFieldDefinition, filterKeySections, disallowFreeText} =
+ useSearchQueryBuilder();
+
+ const flatKeys = useMemo(() => Object.values(filterKeys), [filterKeys]);
+
+ const searchableItems = useMemo(() => {
+ const searchKeyItems: FilterKeySearchItem[] = flatKeys.map(key => {
+ const fieldDef = getFieldDefinition(key.key);
+
+ return {
+ key: key.key,
+ description: fieldDef?.desc ?? '',
+ keywords: fieldDef?.keywords ?? [],
+ item: key,
+ type: 'key',
+ };
+ });
+
+ if (includeSuggestions) {
+ return [
+ ...searchKeyItems,
+ ...getFilterSearchValues(flatKeys, {getFieldDefinition}),
+ ];
+ }
+
+ return searchKeyItems;
+ }, [flatKeys, getFieldDefinition, includeSuggestions]);
+
+ const search = useFuzzySearch(searchableItems, FUZZY_SEARCH_OPTIONS);
return useMemo(() => {
if (!filterValue || !search) {
if (!filterKeySections.length) {
- return flatItems.sort((a, b) => a.textValue.localeCompare(b.textValue));
+ return flatKeys
+ .map(key => createItem(key, getFieldDefinition(key.key)))
+ .sort((a, b) => a.textValue.localeCompare(b.textValue));
}
- return filterKeySections
- .flatMap(section => section.children)
- .map(key => flatItems.find(item => item.key === key))
- .filter(defined);
+ const filterSectionKeys = [
+ ...new Set(filterKeySections.flatMap(section => section.children)),
+ ].slice(0, 50);
+
+ return filterSectionKeys
+ .map(key => filterKeys[key])
+ .filter(defined)
+ .map(key => createItem(key, getFieldDefinition(key.key)));
+ }
+
+ const searched = search.search(filterValue);
+
+ const keyItems = searched
+ .map(({item}) => item)
+ .filter(item => item.type === 'key' && filterKeys[item.item.key])
+ .map(({item}) => {
+ return createItem(filterKeys[item.key], getFieldDefinition(item.key));
+ });
+
+ if (includeSuggestions) {
+ const rawSearchSection: KeySectionItem = {
+ key: 'raw-search',
+ value: 'raw-search',
+ label: '',
+ options: [createRawSearchItem(inputValue)],
+ type: 'section',
+ };
+
+ const shouldIncludeRawSearch =
+ !disallowFreeText &&
+ inputValue &&
+ !isQuoted(inputValue) &&
+ (!keyItems.length || inputValue.trim().includes(' '));
+
+ const keyItemsSection: KeySectionItem = {
+ key: 'key-items',
+ value: 'key-items',
+ label: '',
+ options: keyItems,
+ type: 'section',
+ };
+
+ return [
+ ...getValueSuggestionsFromSearchResult(searched),
+ ...(shouldIncludeRawSearch ? [rawSearchSection] : []),
+ keyItemsSection,
+ ];
}
- return search.search(filterValue).map(({item}) => item);
- }, [filterKeySections, filterValue, flatItems, search]);
+ return keyItems;
+ }, [
+ disallowFreeText,
+ filterKeySections,
+ filterKeys,
+ filterValue,
+ flatKeys,
+ getFieldDefinition,
+ includeSuggestions,
+ inputValue,
+ search,
+ ]);
}
diff --git a/static/app/components/stream/group.tsx b/static/app/components/stream/group.tsx
index 41ca323e60defa..706765ae08e58d 100644
--- a/static/app/components/stream/group.tsx
+++ b/static/app/components/stream/group.tsx
@@ -170,7 +170,7 @@ function BaseGroupRow({
};
}, [organization, group, query]);
- const {mutate: handleAssigneeChange, isLoading: assigneeLoading} = useMutation<
+ const {mutate: handleAssigneeChange, isPending: assigneeLoading} = useMutation<
AssignableEntity | null,
RequestError,
AssignableEntity | null
diff --git a/static/app/components/timeRangeSelector/index.tsx b/static/app/components/timeRangeSelector/index.tsx
index db42ee6156815d..b9276443c8ae34 100644
--- a/static/app/components/timeRangeSelector/index.tsx
+++ b/static/app/components/timeRangeSelector/index.tsx
@@ -7,8 +7,9 @@ import {CompactSelect} from 'sentry/components/compactSelect';
import type {Item} from 'sentry/components/dropdownAutoComplete/types';
import DropdownButton from 'sentry/components/dropdownButton';
import HookOrDefault from 'sentry/components/hookOrDefault';
+import {DesyncedFilterIndicator} from 'sentry/components/organizations/pageFilters/desyncedFilter';
import {DEFAULT_RELATIVE_PERIODS, DEFAULT_STATS_PERIOD} from 'sentry/constants';
-import {IconArrow, IconCalendar} from 'sentry/icons';
+import {IconArrow} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {DateString} from 'sentry/types/core';
@@ -78,6 +79,11 @@ export interface TimeRangeSelectorProps
* unclearable.
*/
defaultPeriod?: string;
+ /**
+ * (Specific to DatePageFilter) Whether the current value is out of sync with the
+ * stored persistent value.
+ */
+ desynced?: boolean;
/**
* Forces the user to select from the set of defined relative options
*/
@@ -158,6 +164,7 @@ export function TimeRangeSelector({
menuBody,
menuFooter,
menuFooterMessage,
+ desynced,
...selectProps
}: TimeRangeSelectorProps) {
const router = useRouter();
@@ -348,12 +355,16 @@ export function TimeRangeSelector({
}
data-test-id="page-filter-timerange-selector"
{...triggerProps}
{...selectProps.triggerProps}
>
- {selectProps.triggerLabel ?? defaultLabel}
+
+
+ {selectProps.triggerLabel ?? defaultLabel}
+
+ {desynced && }
+
);
})
@@ -466,6 +477,11 @@ export function TimeRangeSelector({
);
}
+const TriggerLabelWrap = styled('span')`
+ position: relative;
+ min-width: 0;
+`;
+
const TriggerLabel = styled('span')`
${p => p.theme.overflowEllipsis}
width: auto;
diff --git a/static/app/gettingStartedDocs/javascript/astro.tsx b/static/app/gettingStartedDocs/javascript/astro.tsx
index f21f4e73e46581..7359868cd5b72d 100644
--- a/static/app/gettingStartedDocs/javascript/astro.tsx
+++ b/static/app/gettingStartedDocs/javascript/astro.tsx
@@ -95,10 +95,23 @@ const getInstallConfig = () => [
];
const onboarding: OnboardingConfig = {
- introduction: () =>
- tct("Sentry's integration with [astroLink:Astro] supports Astro 3.0.0 and above.", {
- astroLink: ,
- }),
+ introduction: () => (
+
+
+ {tct(
+ "Sentry's integration with [astroLink:Astro] supports Astro 3.0.0 and above.",
+ {
+ astroLink: ,
+ }
+ )}
+
+
+ {tct("In this quick guide you'll use the [astrocli:astro] CLI to set up:", {
+ astrocli: ,
+ })}
+
+
+ ),
install: () => getInstallConfig(),
configure: (params: Params) => [
{
diff --git a/static/app/gettingStartedDocs/javascript/ember.tsx b/static/app/gettingStartedDocs/javascript/ember.tsx
index 3c4f47a9eb8c04..f290d7a901d426 100644
--- a/static/app/gettingStartedDocs/javascript/ember.tsx
+++ b/static/app/gettingStartedDocs/javascript/ember.tsx
@@ -145,6 +145,7 @@ const onboarding: OnboardingConfig = {
},
getUploadSourceMapsStep({
guideLink: 'https://docs.sentry.io/platforms/javascript/guides/ember/sourcemaps/',
+ ...params,
}),
],
verify: () => [
diff --git a/static/app/gettingStartedDocs/javascript/gatsby.tsx b/static/app/gettingStartedDocs/javascript/gatsby.tsx
index 1954e653f3b84b..469fbf0f224eb0 100644
--- a/static/app/gettingStartedDocs/javascript/gatsby.tsx
+++ b/static/app/gettingStartedDocs/javascript/gatsby.tsx
@@ -177,6 +177,7 @@ const onboarding: OnboardingConfig = {
getConfigureStep(params),
getUploadSourceMapsStep({
guideLink: 'https://docs.sentry.io/platforms/javascript/guides/gatsby/sourcemaps//',
+ ...params,
}),
],
verify: () => [
diff --git a/static/app/gettingStartedDocs/javascript/javascript.tsx b/static/app/gettingStartedDocs/javascript/javascript.tsx
index 0ae4f510af5546..4b3b2bc5a6d5c7 100644
--- a/static/app/gettingStartedDocs/javascript/javascript.tsx
+++ b/static/app/gettingStartedDocs/javascript/javascript.tsx
@@ -312,6 +312,17 @@ const loaderScriptOnboarding: OnboardingConfig = {
});
};
},
+ onProductSelectionLoad: params => {
+ return products => {
+ updateDynamicSdkLoaderOptions({
+ orgSlug: params.organization.slug,
+ projectSlug: params.projectSlug,
+ products,
+ projectKey: params.projectKeyId,
+ api: params.api,
+ });
+ };
+ },
};
const packageManagerOnboarding: OnboardingConfig = {
@@ -352,6 +363,7 @@ const packageManagerOnboarding: OnboardingConfig = {
},
getUploadSourceMapsStep({
guideLink: 'https://docs.sentry.io/platforms/javascript/sourcemaps/',
+ ...params,
}),
],
verify: getVerifyConfig,
@@ -438,6 +450,10 @@ const onboarding: OnboardingConfig = {
isAutoInstall(params)
? loaderScriptOnboarding.onPlatformOptionsChange?.(params)
: packageManagerOnboarding.onPlatformOptionsChange?.(params),
+ onProductSelectionLoad: params =>
+ isAutoInstall(params)
+ ? loaderScriptOnboarding.onProductSelectionLoad?.(params)
+ : packageManagerOnboarding.onProductSelectionLoad?.(params),
};
const replayOnboarding: OnboardingConfig = {
diff --git a/static/app/gettingStartedDocs/javascript/nextjs.tsx b/static/app/gettingStartedDocs/javascript/nextjs.tsx
index b5098c5090bd40..ec8a517ec0b627 100644
--- a/static/app/gettingStartedDocs/javascript/nextjs.tsx
+++ b/static/app/gettingStartedDocs/javascript/nextjs.tsx
@@ -33,9 +33,14 @@ import {trackAnalytics} from 'sentry/utils/analytics';
type Params = DocsParams;
-const getInstallSnippet = ({isSelfHosted, urlPrefix}: Params) => {
+const getInstallSnippet = ({
+ isSelfHosted,
+ urlPrefix,
+ organization,
+ projectSlug,
+}: Params) => {
const urlParam = !isSelfHosted && urlPrefix ? `--url ${urlPrefix}` : '';
- return `npx @sentry/wizard@latest -i nextjs ${urlParam}`;
+ return `npx @sentry/wizard@latest -i nextjs ${urlParam} --org ${organization.slug} --project ${projectSlug}`;
};
const getInstallConfig = (params: Params) => {
diff --git a/static/app/gettingStartedDocs/javascript/react.tsx b/static/app/gettingStartedDocs/javascript/react.tsx
index fabc79091f1463..259274bcee8581 100644
--- a/static/app/gettingStartedDocs/javascript/react.tsx
+++ b/static/app/gettingStartedDocs/javascript/react.tsx
@@ -153,6 +153,7 @@ const onboarding: OnboardingConfig = {
},
getUploadSourceMapsStep({
guideLink: 'https://docs.sentry.io/platforms/javascript/guides/react/sourcemaps/',
+ ...params,
}),
],
verify: () => [
diff --git a/static/app/gettingStartedDocs/javascript/remix.tsx b/static/app/gettingStartedDocs/javascript/remix.tsx
index 19824fd6cad1b2..581972cb055e53 100644
--- a/static/app/gettingStartedDocs/javascript/remix.tsx
+++ b/static/app/gettingStartedDocs/javascript/remix.tsx
@@ -29,7 +29,7 @@ import {t, tct} from 'sentry/locale';
type Params = DocsParams;
-const getConfigStep = ({isSelfHosted, urlPrefix}: Params) => {
+const getConfigStep = ({isSelfHosted, urlPrefix, organization, projectSlug}: Params) => {
const urlParam = !isSelfHosted && urlPrefix ? `--url ${urlPrefix}` : '';
return [
{
@@ -42,7 +42,7 @@ const getConfigStep = ({isSelfHosted, urlPrefix}: Params) => {
}
),
language: 'bash',
- code: `npx @sentry/wizard@latest -i remix ${urlParam}`,
+ code: `npx @sentry/wizard@latest -i remix ${urlParam} --org ${organization.slug} --project ${projectSlug}`,
},
];
};
diff --git a/static/app/gettingStartedDocs/javascript/solid.tsx b/static/app/gettingStartedDocs/javascript/solid.tsx
index 197b3a4deea404..dcc78dc1fe990c 100644
--- a/static/app/gettingStartedDocs/javascript/solid.tsx
+++ b/static/app/gettingStartedDocs/javascript/solid.tsx
@@ -165,6 +165,7 @@ const onboarding: OnboardingConfig = {
},
getUploadSourceMapsStep({
guideLink: 'https://docs.sentry.io/platforms/javascript/guides/solid/sourcemaps/',
+ ...params,
}),
],
verify: () => [
diff --git a/static/app/gettingStartedDocs/javascript/svelte.tsx b/static/app/gettingStartedDocs/javascript/svelte.tsx
index 9d03069714d134..85c030bf1443bc 100644
--- a/static/app/gettingStartedDocs/javascript/svelte.tsx
+++ b/static/app/gettingStartedDocs/javascript/svelte.tsx
@@ -162,6 +162,7 @@ const onboarding: OnboardingConfig = {
},
getUploadSourceMapsStep({
guideLink: 'https://docs.sentry.io/platforms/javascript/guides/svelte/sourcemaps/',
+ ...params,
}),
],
verify: () => [
diff --git a/static/app/gettingStartedDocs/javascript/sveltekit.tsx b/static/app/gettingStartedDocs/javascript/sveltekit.tsx
index ab914aca530129..36be073f121549 100644
--- a/static/app/gettingStartedDocs/javascript/sveltekit.tsx
+++ b/static/app/gettingStartedDocs/javascript/sveltekit.tsx
@@ -29,7 +29,12 @@ import {t, tct} from 'sentry/locale';
type Params = DocsParams;
-const getInstallConfig = ({isSelfHosted, urlPrefix}: Params) => {
+const getInstallConfig = ({
+ isSelfHosted,
+ urlPrefix,
+ organization,
+ projectSlug,
+}: Params) => {
const urlParam = !isSelfHosted && urlPrefix ? `--url ${urlPrefix}` : '';
return [
@@ -46,7 +51,7 @@ const getInstallConfig = ({isSelfHosted, urlPrefix}: Params) => {
configurations: [
{
language: 'bash',
- code: `npx @sentry/wizard@latest -i sveltekit ${urlParam}`,
+ code: `npx @sentry/wizard@latest -i sveltekit ${urlParam} --org ${organization.slug} --project ${projectSlug}`,
},
],
},
diff --git a/static/app/gettingStartedDocs/node/node.tsx b/static/app/gettingStartedDocs/node/node.tsx
index 4ca04cde8e2eb5..3e7be4dab8675d 100644
--- a/static/app/gettingStartedDocs/node/node.tsx
+++ b/static/app/gettingStartedDocs/node/node.tsx
@@ -90,6 +90,7 @@ const onboarding: OnboardingConfig = {
},
getUploadSourceMapsStep({
guideLink: 'https://docs.sentry.io/platforms/javascript/guides/node/sourcemaps/',
+ ...params,
}),
],
verify: ({isPerformanceSelected}) => [
diff --git a/static/app/gettingStartedDocs/python/serverless.tsx b/static/app/gettingStartedDocs/python/serverless.tsx
index 687baef2953f48..c7913a88343e9e 100644
--- a/static/app/gettingStartedDocs/python/serverless.tsx
+++ b/static/app/gettingStartedDocs/python/serverless.tsx
@@ -64,9 +64,11 @@ const onboarding: OnboardingConfig = {
}
)}
- {t(
- 'If you use a serverless provider not directly supported by the SDK, you can use this generic integration.'
- )}
+
+ {t(
+ 'If you use a serverless provider not directly supported by the SDK, you can use this generic integration.'
+ )}
+
),
install: (params: Params) => [
diff --git a/static/app/main.tsx b/static/app/main.tsx
index 62e0b6cc38b259..169605f35e9924 100644
--- a/static/app/main.tsx
+++ b/static/app/main.tsx
@@ -66,7 +66,7 @@ function Main() {
)}
{USE_REACT_QUERY_DEVTOOL && (
-
+
)}
diff --git a/static/app/routes.tsx b/static/app/routes.tsx
index 992570b648c935..73f600c2b164a2 100644
--- a/static/app/routes.tsx
+++ b/static/app/routes.tsx
@@ -1,4 +1,5 @@
import {Fragment, lazy} from 'react';
+// biome-ignore lint/nursery/noRestrictedImports: warning
import {IndexRedirect, Redirect} from 'react-router';
import memoize from 'lodash/memoize';
@@ -557,6 +558,11 @@ function buildRoutes() {
name={t('Replays')}
component={make(() => import('sentry/views/settings/project/projectReplays'))}
/>
+ import('sentry/views/settings/project/toolbar'))}
+ />
import('sentry/views/settings/projectSourceMaps'))}
diff --git a/static/app/types/hooks.tsx b/static/app/types/hooks.tsx
index bae289849f83e0..e2f77487e3eddd 100644
--- a/static/app/types/hooks.tsx
+++ b/static/app/types/hooks.tsx
@@ -123,12 +123,10 @@ type OrganizationHeaderProps = {
organization: Organization;
};
-type ProductSelectionAvailabilityProps = Pick<
+type ProductSelectionAvailabilityProps = Omit<
ProductSelectionProps,
- 'platform' | 'withBottomMargin' | 'projectId' | 'onChange'
-> & {
- organization: Organization;
-};
+ 'disabledProducts' | 'productsPerPlatform'
+>;
type FirstPartyIntegrationAlertProps = {
integrations: Integration[];
diff --git a/static/app/types/system.tsx b/static/app/types/system.tsx
index 3bd7c356d582e8..ce59ef7a7d9c70 100644
--- a/static/app/types/system.tsx
+++ b/static/app/types/system.tsx
@@ -259,6 +259,10 @@ export interface Broadcast {
* Image url
*/
mediaUrl?: string;
+ /**
+ * Region of the broadcast. If not set, the broadcast will be shown for all regions.
+ */
+ region?: string;
}
// XXX(epurkhiser): The components list can be generated using jq
diff --git a/static/app/utils/analytics/issueAnalyticsEvents.tsx b/static/app/utils/analytics/issueAnalyticsEvents.tsx
index 51fd97cd33afad..ef04c6cae21ca2 100644
--- a/static/app/utils/analytics/issueAnalyticsEvents.tsx
+++ b/static/app/utils/analytics/issueAnalyticsEvents.tsx
@@ -221,6 +221,30 @@ export type IssueEventParameters = {
search_source: string;
search_type: string;
};
+ 'issue_views.add_view.clicked': {};
+ 'issue_views.add_view.custom_query_saved': {
+ query: string;
+ };
+ 'issue_views.add_view.recommended_view_saved': {
+ label: string;
+ persisted: boolean;
+ query: string;
+ };
+ 'issue_views.add_view.saved_search_saved': {
+ query: string;
+ };
+ 'issue_views.deleted_view': {};
+ 'issue_views.discarded_changes': {};
+ 'issue_views.duplicated_view': {};
+ 'issue_views.renamed_view': {};
+ 'issue_views.reordered_views': {};
+ 'issue_views.saved_changes': {};
+ 'issue_views.shared_view_opened': {
+ query: string;
+ };
+ 'issue_views.switched_views': {};
+ 'issue_views.temp_view_discarded': {};
+ 'issue_views.temp_view_saved': {};
'issues_stream.archived': {
action_status_details?: string;
action_substatus?: string | null;
@@ -248,6 +272,7 @@ export type IssueEventParameters = {
priority: PriorityLevel;
};
'issues_tab.viewed': {
+ issue_views_enabled: boolean;
num_issues: number;
num_new_issues: number;
num_old_issues: number;
@@ -349,6 +374,21 @@ export const issueEventMap: Record = {
'issue_error_banner.proguard_misconfigured.clicked':
'Proguard Potentially Misconfigured Issue Error Banner Link Clicked',
'issues_tab.viewed': 'Viewed Issues Tab',
+ 'issue_views.switched_views': 'Issue Views: Switched Views',
+ 'issue_views.saved_changes': 'Issue Views: Updated View',
+ 'issue_views.discarded_changes': 'Issue Views: Discarded Changes',
+ 'issue_views.renamed_view': 'Issue Views: Renamed View',
+ 'issue_views.duplicated_view': 'Issue Views: Duplicated View',
+ 'issue_views.deleted_view': 'Issue Views: Deleted View',
+ 'issue_views.reordered_views': 'Issue Views: Views Reordered',
+ 'issue_views.add_view.clicked': 'Issue Views: Add View Clicked',
+ 'issue_views.add_view.custom_query_saved':
+ 'Issue Views: Custom Query Saved From Add View',
+ 'issue_views.add_view.saved_search_saved': 'Issue Views: Saved Search Saved',
+ 'issue_views.add_view.recommended_view_saved': 'Issue Views: Recommended View Saved',
+ 'issue_views.shared_view_opened': 'Issue Views: Shared View Opened',
+ 'issue_views.temp_view_discarded': 'Issue Views: Temporary View Discarded',
+ 'issue_views.temp_view_saved': 'Issue Views: Temporary View Saved',
'issue_search.failed': 'Issue Search: Failed',
'issue_search.empty': 'Issue Search: Empty',
'issue.search_sidebar_clicked': 'Issue Search Sidebar Clicked',
diff --git a/static/app/utils/api/useAggregatedQueryKeys.tsx b/static/app/utils/api/useAggregatedQueryKeys.tsx
index e104d43312ef84..f61a356914ae36 100644
--- a/static/app/utils/api/useAggregatedQueryKeys.tsx
+++ b/static/app/utils/api/useAggregatedQueryKeys.tsx
@@ -143,7 +143,7 @@ export default function useAggregatedQueryKeys({
predicate: isQueryKeyInBatch,
});
queuedAggregatableBatch.forEach(queryKey => {
- queryClient.setQueryData(
+ queryClient.setQueryData(
['aggregate', cacheKey, key, 'inFlight', queryKey],
true
);
@@ -210,7 +210,7 @@ export default function useAggregatedQueryKeys({
// Cache sentinel data for the new cacheKeys
newQueryKeys
.map(agg => ['aggregate', cacheKey, key, 'queued', agg])
- .forEach(queryKey => queryClient.setQueryData(queryKey, true));
+ .forEach(queryKey => queryClient.setQueryData(queryKey, true));
if (newQueryKeys.length) {
setData(readCache());
diff --git a/static/app/utils/discover/genericDiscoverQuery.tsx b/static/app/utils/discover/genericDiscoverQuery.tsx
index c7fde207274829..71cd6e95add5de 100644
--- a/static/app/utils/discover/genericDiscoverQuery.tsx
+++ b/static/app/utils/discover/genericDiscoverQuery.tsx
@@ -11,6 +11,7 @@ import type EventView from 'sentry/utils/discover/eventView';
import {isAPIPayloadSimilar} from 'sentry/utils/discover/eventView';
import type {QueryBatching} from 'sentry/utils/performance/contexts/genericQueryBatcher';
import {PerformanceEventViewContext} from 'sentry/utils/performance/contexts/performanceEventViewContext';
+import type {UseQueryOptions} from 'sentry/utils/queryClient';
import useApi from '../useApi';
import useOrganization from '../useOrganization';
@@ -85,7 +86,10 @@ type BaseDiscoverQueryProps = {
* passed, but cursor will be ignored.
*/
noPagination?: boolean;
- options?: Omit[2], 'initialData'>;
+ options?: Omit<
+ UseQueryOptions<[any, string | undefined, ResponseMeta | undefined], QueryError>,
+ 'queryKey' | 'queryFn'
+ >;
/**
* A container for query batching data and functions.
*/
@@ -144,7 +148,7 @@ type ComponentProps = {
* Allows components to modify the payload before it is set.
*/
getRequestPayload?: (props: Props) => any;
- options?: Omit[2], 'initialData'>;
+ options?: BaseDiscoverQueryProps['options'];
/**
* An external hook to parse errors in case there are differences for a specific api.
*/
@@ -438,7 +442,6 @@ export function useGenericDiscoverQuery(props: Props) {
error: parseError(res.error),
statusCode: res.data?.[1] ?? undefined,
response: res.data?.[2] ?? undefined,
- isPending: res.isLoading,
};
}
diff --git a/static/app/utils/eventWaiter.spec.tsx b/static/app/utils/eventWaiter.spec.tsx
index 6e4968aedb148b..8267742cf62052 100644
--- a/static/app/utils/eventWaiter.spec.tsx
+++ b/static/app/utils/eventWaiter.spec.tsx
@@ -5,7 +5,6 @@ import {act, render} from 'sentry-test/reactTestingLibrary';
import EventWaiter from 'sentry/utils/eventWaiter';
-// biome-ignore lint/correctness/useHookAtTopLevel: not a hook
jest.useFakeTimers();
describe('EventWaiter', function () {
diff --git a/static/app/utils/fields/index.ts b/static/app/utils/fields/index.ts
index f1e1b76c938154..60d02a3fc2456f 100644
--- a/static/app/utils/fields/index.ts
+++ b/static/app/utils/fields/index.ts
@@ -1321,7 +1321,6 @@ const EVENT_FIELD_DEFINITIONS: Record = {
desc: t('The properties of an issue (i.e. Resolved, unresolved)'),
kind: FieldKind.FIELD,
valueType: FieldValueType.STRING,
- keywords: ['ignored', 'assigned', 'for_review', 'unassigned', 'linked', 'unlinked'],
defaultValue: 'unresolved',
allowWildcard: false,
},
@@ -1335,14 +1334,12 @@ const EVENT_FIELD_DEFINITIONS: Record = {
desc: t('Category of issue (error or performance)'),
kind: FieldKind.FIELD,
valueType: FieldValueType.STRING,
- keywords: ['error', 'performance'],
allowWildcard: false,
},
[FieldKey.ISSUE_PRIORITY]: {
desc: t('The priority of the issue'),
kind: FieldKind.FIELD,
valueType: FieldValueType.STRING,
- keywords: ['high', 'medium', 'low'],
allowWildcard: false,
},
[FieldKey.ISSUE_TYPE]: {
diff --git a/static/app/utils/queryClient.tsx b/static/app/utils/queryClient.tsx
index 7ad3f821a341c9..370e79e925bd6b 100644
--- a/static/app/utils/queryClient.tsx
+++ b/static/app/utils/queryClient.tsx
@@ -64,17 +64,6 @@ export type ApiQueryKey =
>,
];
-/**
- * isLoading is renamed to isPending in v5, this backports the type to v4
- *
- * TODO(react-query): Remove this when we upgrade to react-query v5
- *
- * @link https://tanstack.com/query/v5/docs/framework/react/guides/migrating-to-v5
- */
-type BackportIsPending = T extends {isLoading: boolean}
- ? T & {isPending: T['isLoading']}
- : T;
-
export interface UseApiQueryOptions
extends Omit<
UseQueryOptions<
@@ -90,10 +79,6 @@ export interface UseApiQueryOptions
// We do not include the select option as this is difficult to make interop
// with the way we extract data out of the ApiResult tuple
| 'select'
- // onSuccess and onError are gone in v5, avoid using
- // TODO(react-query): Remove this when we upgrade to react-query v5
- | 'onSuccess'
- | 'onError'
> {
/**
* staleTime is the amount of time (in ms) before cached data gets marked as stale.
@@ -113,9 +98,7 @@ export interface UseApiQueryOptions
staleTime: number;
}
-export type UseApiQueryResult = BackportIsPending<
- UseQueryResult
-> & {
+export type UseApiQueryResult = UseQueryResult & {
/**
* Get a header value from the response
*/
@@ -152,9 +135,6 @@ export function useApiQuery(
const queryResult = {
data: data?.[0],
getResponseHeader: data?.[2]?.getResponseHeader,
- // Backport isLoading to isPending
- // TODO(react-query): Remove this when we upgrade to react-query v5 as it will already exist
- isPending: rest.isLoading,
...rest,
};
@@ -297,11 +277,6 @@ function parsePageParam(dir: 'previous' | 'next') {
};
}
-// TODO(react-query): Remove this when we upgrade to react-query v5
-export type UseInfiniteQueryResult = BackportIsPending<
- _UseInfiniteQueryResult
->;
-
/**
* Wraps React Query's useInfiniteQuery for consistent usage in the Sentry app.
* Query keys should be an array which include an endpoint URL and options such as query params.
@@ -316,14 +291,10 @@ export function useInfiniteApiQuery({queryKey}: {queryKey: ApiQue
queryFn: fetchInfiniteQuery(api),
getPreviousPageParam: parsePageParam('previous'),
getNextPageParam: parsePageParam('next'),
+ initialPageParam: undefined,
});
- // TODO(react-query): Remove this when we upgrade to react-query v5
- // @ts-expect-error: This is a backport of react-query v5
- query.isPending = query.isLoading;
-
- // TODO(react-query): Remove casting when we upgrade to react-query v5
- return query as UseInfiniteQueryResult, unknown>;
+ return query;
}
type ApiMutationVariables<
diff --git a/static/app/utils/replays/hooks/useCountDomNodes.tsx b/static/app/utils/replays/hooks/useCountDomNodes.tsx
index 6a35d8f6d8cd6f..0b594ae2394736 100644
--- a/static/app/utils/replays/hooks/useCountDomNodes.tsx
+++ b/static/app/utils/replays/hooks/useCountDomNodes.tsx
@@ -22,6 +22,6 @@ export default function useCountDomNodes({
return replay?.getCountDomNodes();
},
enabled: Boolean(replay),
- cacheTime: Infinity,
+ gcTime: Infinity,
});
}
diff --git a/static/app/utils/replays/hooks/useExtractDomNodes.tsx b/static/app/utils/replays/hooks/useExtractDomNodes.tsx
index b2e2de766ce142..2d0dfbcc8e13ac 100644
--- a/static/app/utils/replays/hooks/useExtractDomNodes.tsx
+++ b/static/app/utils/replays/hooks/useExtractDomNodes.tsx
@@ -14,6 +14,6 @@ export default function useExtractDomNodes({
return replay?.getExtractDomNodes();
},
enabled: Boolean(replay),
- cacheTime: Infinity,
+ gcTime: Infinity,
});
}
diff --git a/static/app/utils/replays/hooks/useExtractPageHtml.tsx b/static/app/utils/replays/hooks/useExtractPageHtml.tsx
index a83002e454e829..633b80c1d1d99a 100644
--- a/static/app/utils/replays/hooks/useExtractPageHtml.tsx
+++ b/static/app/utils/replays/hooks/useExtractPageHtml.tsx
@@ -64,6 +64,6 @@ export default function useExtractPageHtml({replay, offsetMsToStopAt}: Props) {
startTimestampMs: replay?.getReplay().started_at.getTime() ?? 0,
}),
enabled: Boolean(replay),
- cacheTime: Infinity,
+ gcTime: Infinity,
});
}
diff --git a/static/app/utils/replays/timer.spec.tsx b/static/app/utils/replays/timer.spec.tsx
index 3f61a1d7efdcad..81bdbebba3c758 100644
--- a/static/app/utils/replays/timer.spec.tsx
+++ b/static/app/utils/replays/timer.spec.tsx
@@ -1,6 +1,5 @@
import {Timer} from './timer';
-// biome-ignore lint/correctness/useHookAtTopLevel: not a hook
jest.useFakeTimers();
describe('Replay Timer', () => {
diff --git a/static/app/utils/url/fetchImageData.ts b/static/app/utils/url/fetchImageData.ts
deleted file mode 100644
index d1ec754658a1f6..00000000000000
--- a/static/app/utils/url/fetchImageData.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * Fetches and provides an HTMLImageElement from a given URL.
- * It validates the URL format before attempting to load the image.
- *
- * @param {string} url - The URL of the image to fetch and inspect.
- * @returns {Promise}
- * A promise that resolves to the HTMLImageElement object representing the loaded image.
- *
- * If the URL is invalid or the image fails to load, the promise is rejected with an error.
- */
-export function fetchImageData(url: string): Promise {
- return new Promise((resolve, reject) => {
- const img = new Image();
- let isCanceled = false;
-
- img.onload = () => {
- if (!isCanceled) {
- resolve(img);
- }
- };
-
- img.onerror = error => {
- if (!isCanceled) {
- reject(error);
- }
- };
-
- img.src = url;
-
- return () => {
- isCanceled = true;
- img.onload = null;
- img.onerror = null;
- };
- });
-}
diff --git a/static/app/utils/url/validateLinkExists.ts b/static/app/utils/url/validateLinkExists.ts
deleted file mode 100644
index 69a38be627b5e7..00000000000000
--- a/static/app/utils/url/validateLinkExists.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import {t} from 'sentry/locale';
-import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse';
-
-/**
- * Checks if a URL exists by sending a HEAD request.
- * @param url - The URL to check.
- * @returns A promise that resolves to `true` if the URL exists, or `false` otherwise.
- */
-export async function validateLinkExists(url: string): Promise {
- try {
- const response = await fetch(url, {method: 'HEAD'}); // Using HEAD method to just check if the resource exists
- if (response.ok) {
- return true;
- }
- return false;
- } catch (error) {
- handleXhrErrorResponse(t('Unable to validate if the link exists'), error);
- return false;
- }
-}
diff --git a/static/app/utils/useProjectSdkNeedsUpdate.tsx b/static/app/utils/useProjectSdkNeedsUpdate.tsx
index fc746437c5f964..35a02f92e1b33f 100644
--- a/static/app/utils/useProjectSdkNeedsUpdate.tsx
+++ b/static/app/utils/useProjectSdkNeedsUpdate.tsx
@@ -19,7 +19,7 @@ function useProjectSdkNeedsUpdate({
| {isError: false; isFetching: false; needsUpdate: boolean} {
const path = `/organizations/${organization.slug}/sdk-updates/`;
const api = useApi({persistInFlight: true});
- const {data, isLoading, isError} = useQuery({
+ const {data, isPending, isError} = useQuery({
queryKey: [path],
queryFn: async () => {
try {
@@ -34,7 +34,7 @@ function useProjectSdkNeedsUpdate({
refetchOnMount: false,
});
- if (isLoading) {
+ if (isPending) {
return {isError: false, isFetching: true, needsUpdate: undefined};
}
diff --git a/static/app/utils/useServiceIncidents.tsx b/static/app/utils/useServiceIncidents.tsx
index 88e92b51be9c78..c2e2c576909f0e 100644
--- a/static/app/utils/useServiceIncidents.tsx
+++ b/static/app/utils/useServiceIncidents.tsx
@@ -29,7 +29,7 @@ export function useServiceIncidents({
return useQuery({
queryKey: ['statuspage-incidents', statusFilter],
- cacheTime: 60 * 5,
+ gcTime: 60 * 5,
queryFn: async () => {
const {api_host, id} = statuspage ?? {};
diff --git a/static/app/utils/useTimeout.spec.tsx b/static/app/utils/useTimeout.spec.tsx
index 5e5f9cc52e433d..364cf38a778ceb 100644
--- a/static/app/utils/useTimeout.spec.tsx
+++ b/static/app/utils/useTimeout.spec.tsx
@@ -2,7 +2,6 @@ import {renderHook} from 'sentry-test/reactTestingLibrary';
import useTimeout from './useTimeout';
-// biome-ignore lint/correctness/useHookAtTopLevel: not a hook
jest.useFakeTimers();
describe('useTimeout', () => {
diff --git a/static/app/views/alerts/create.tsx b/static/app/views/alerts/create.tsx
index 87b817aa71248d..e3b17255015cb7 100644
--- a/static/app/views/alerts/create.tsx
+++ b/static/app/views/alerts/create.tsx
@@ -17,6 +17,7 @@ import BuilderBreadCrumbs from 'sentry/views/alerts/builder/builderBreadCrumbs';
import IssueRuleEditor from 'sentry/views/alerts/rules/issue';
import MetricRulesCreate from 'sentry/views/alerts/rules/metric/create';
import MetricRuleDuplicate from 'sentry/views/alerts/rules/metric/duplicate';
+import {UptimeAlertForm} from 'sentry/views/alerts/rules/uptime/uptimeAlertForm';
import {AlertRuleType} from 'sentry/views/alerts/types';
import type {
AlertType as WizardAlertType,
@@ -141,16 +142,29 @@ function Create(props: Props) {
) : (
- {(!hasMetricAlerts || alertType === AlertRuleType.ISSUE) && (
+ {organization.features.includes('uptime-api-create-update') &&
+ alertType === AlertRuleType.UPTIME ? (
+ {
+ router.push(
+ normalizeUrl(
+ `/organizations/${organization.slug}/alerts/rules/uptime/${project.slug}/${response.id}/details`
+ )
+ );
+ }}
+ />
+ ) : !hasMetricAlerts || alertType === AlertRuleType.ISSUE ? (
id)}
members={members}
/>
- )}
-
- {hasMetricAlerts &&
+ ) : (
+ hasMetricAlerts &&
alertType === AlertRuleType.METRIC &&
(isDuplicateRule ? (
id)}
/>
- ))}
+ ))
+ )}
)}
diff --git a/static/app/views/alerts/list/rules/alertRulesList.tsx b/static/app/views/alerts/list/rules/alertRulesList.tsx
index 3ea2f2e0ff976e..389557d7dfa926 100644
--- a/static/app/views/alerts/list/rules/alertRulesList.tsx
+++ b/static/app/views/alerts/list/rules/alertRulesList.tsx
@@ -255,7 +255,9 @@ function AlertRulesList() {
const isIssueAlertInstance = isIssueAlert(rule);
const keyPrefix = isIssueAlertInstance
? AlertRuleType.ISSUE
- : AlertRuleType.METRIC;
+ : rule.type === CombinedAlertType.UPTIME
+ ? AlertRuleType.UPTIME
+ : AlertRuleType.METRIC;
return (
{
project={project}
/>
);
- await expect(metaMock).toHaveBeenCalledWith(
- '/organizations/org-slug/metrics/meta/',
- expect.objectContaining({
- query: {
- project: [2],
- useCase: ['spans'],
- },
- })
- );
+ await waitFor(() => {
+ expect(metaMock).toHaveBeenCalledWith(
+ '/organizations/org-slug/metrics/meta/',
+ expect.objectContaining({
+ query: {
+ project: [2],
+ useCase: ['spans'],
+ },
+ })
+ );
+ });
screen.getByText('avg');
screen.getByText('span.exclusive_time');
});
@@ -68,8 +70,8 @@ describe('InsightsMetricField', () => {
project={project}
/>
);
- userEvent.click(screen.getByText('avg'));
- userEvent.click(await screen.findByText('sum'));
+ await userEvent.click(screen.getByText('avg'));
+ await userEvent.click(await screen.findByText('sum'));
await waitFor(() =>
expect(onChange).toHaveBeenCalledWith('sum(d:spans/exclusive_time@millisecond)', {})
);
@@ -85,8 +87,8 @@ describe('InsightsMetricField', () => {
project={project}
/>
);
- userEvent.click(screen.getByText('avg'));
- userEvent.click(await screen.findByText('spm'));
+ await userEvent.click(screen.getByText('avg'));
+ await userEvent.click(await screen.findByText('spm'));
await waitFor(() => expect(onChange).toHaveBeenCalledWith('spm()', {}));
});
@@ -100,8 +102,8 @@ describe('InsightsMetricField', () => {
project={project}
/>
);
- userEvent.click(screen.getByText('avg'));
- userEvent.click(await screen.findByText('http_response_rate'));
+ await userEvent.click(screen.getByText('avg'));
+ await userEvent.click(await screen.findByText('http_response_rate'));
await waitFor(() =>
expect(onChange).toHaveBeenCalledWith('http_response_rate(3)', {})
);
diff --git a/static/app/views/alerts/rules/metric/mriField.spec.tsx b/static/app/views/alerts/rules/metric/mriField.spec.tsx
index 3012260a674e03..0120bc383d96f5 100644
--- a/static/app/views/alerts/rules/metric/mriField.spec.tsx
+++ b/static/app/views/alerts/rules/metric/mriField.spec.tsx
@@ -45,8 +45,8 @@ describe('MRIField', () => {
/>
);
await screen.findByText('Select an operation');
- userEvent.click(screen.getByText('sum'));
- userEvent.click(await screen.findByText('p95'));
+ await userEvent.click(screen.getByText('sum'));
+ await userEvent.click(await screen.findByText('p95'));
await waitFor(() =>
expect(onChange).toHaveBeenCalledWith(
'p95(d:custom/sentry.distribution.metric@second)',
diff --git a/static/app/views/alerts/types.tsx b/static/app/views/alerts/types.tsx
index 6cdc28ad390951..6e0a6f8c8854e0 100644
--- a/static/app/views/alerts/types.tsx
+++ b/static/app/views/alerts/types.tsx
@@ -8,6 +8,7 @@ type Data = [number, {count: number}[]][];
export enum AlertRuleType {
METRIC = 'metric',
ISSUE = 'issue',
+ UPTIME = 'uptime',
}
export type Incident = {
diff --git a/static/app/views/alerts/wizard/index.spec.tsx b/static/app/views/alerts/wizard/index.spec.tsx
index 461da2ff989a86..af0bf4feed087a 100644
--- a/static/app/views/alerts/wizard/index.spec.tsx
+++ b/static/app/views/alerts/wizard/index.spec.tsx
@@ -103,4 +103,30 @@ describe('AlertWizard', () => {
const alertGroups = screen.getAllByRole('radiogroup');
expect(alertGroups.length).toEqual(1);
});
+
+ it('shows uptime alert according to feature flag', () => {
+ const {organization, project, routerProps, router} = initializeOrg({
+ organization: {
+ features: [
+ 'alert-crash-free-metrics',
+ 'incidents',
+ 'performance-view',
+ 'crash-rate-alerts',
+ 'organizations:uptime-display-wizard-create',
+ ],
+ access: ['org:write', 'alerts:write'],
+ },
+ });
+
+ render(
+ ,
+ {router, organization}
+ );
+
+ expect(screen.getByText('Uptime Monitor')).toBeInTheDocument();
+ });
});
diff --git a/static/app/views/alerts/wizard/index.tsx b/static/app/views/alerts/wizard/index.tsx
index 0d0ae0d534b296..31f781fd58e60c 100644
--- a/static/app/views/alerts/wizard/index.tsx
+++ b/static/app/views/alerts/wizard/index.tsx
@@ -116,7 +116,11 @@ function AlertWizard({organization, params, location, projectId}: AlertWizardPro
priority="primary"
to={{
pathname: `/organizations/${organization.slug}/alerts/new/${
- isMetricAlert ? AlertRuleType.METRIC : AlertRuleType.ISSUE
+ isMetricAlert
+ ? AlertRuleType.METRIC
+ : alertOption === 'uptime_monitor'
+ ? AlertRuleType.UPTIME
+ : AlertRuleType.ISSUE
}/`,
query: {
...(metricRuleTemplate ? metricRuleTemplate : {}),
diff --git a/static/app/views/alerts/wizard/options.tsx b/static/app/views/alerts/wizard/options.tsx
index 100f7e60061074..7eef2fdb46516f 100644
--- a/static/app/views/alerts/wizard/options.tsx
+++ b/static/app/views/alerts/wizard/options.tsx
@@ -46,7 +46,8 @@ export type AlertType =
| 'custom_metrics'
| 'llm_tokens'
| 'llm_cost'
- | 'insights_metrics';
+ | 'insights_metrics'
+ | 'uptime_monitor';
export enum MEPAlertsQueryType {
ERROR = 0,
@@ -60,7 +61,7 @@ export enum MEPAlertsDataset {
METRICS_ENHANCED = 'metricsEnhanced',
}
-export type MetricAlertType = Exclude;
+export type MetricAlertType = Exclude;
export const DatasetMEPAlertQueryTypes: Record<
Exclude, // IssuePlatform (search_issues) is not used in alerts, so we can exclude it here
@@ -90,6 +91,7 @@ export const AlertWizardAlertNames: Record = {
llm_cost: t('LLM cost'),
llm_tokens: t('LLM token usage'),
insights_metrics: t('Insights Metric'),
+ uptime_monitor: t('Uptime Monitor'),
};
type AlertWizardCategory = {
@@ -131,6 +133,12 @@ export const getAlertWizardCategories = (org: Organization) => {
options: ['llm_tokens', 'llm_cost'],
});
}
+ if (org.features.includes('organizations:uptime-display-wizard-create')) {
+ result.push({
+ categoryHeading: t('Uptime'),
+ options: ['uptime_monitor'],
+ });
+ }
result.push({
categoryHeading: hasCustomMetrics(org) ? t('Metrics') : t('Custom'),
options: [hasCustomMetrics(org) ? 'custom_metrics' : 'custom_transactions'],
diff --git a/static/app/views/alerts/wizard/panelContent.tsx b/static/app/views/alerts/wizard/panelContent.tsx
index 7ff00ad376fc2e..b9fb29deb5fb46 100644
--- a/static/app/views/alerts/wizard/panelContent.tsx
+++ b/static/app/views/alerts/wizard/panelContent.tsx
@@ -182,4 +182,8 @@ export const AlertWizardPanelContent: Record = {
],
illustration: diagramCrashFreeUsers,
},
+ uptime_monitor: {
+ description: t('Monitor the availability and reliability of your web services.'),
+ examples: [t('When a URL is detected to be down, create an issue.')],
+ },
};
diff --git a/static/app/views/dashboards/discoverSplitAlert.spec.tsx b/static/app/views/dashboards/discoverSplitAlert.spec.tsx
new file mode 100644
index 00000000000000..47a88eac553d97
--- /dev/null
+++ b/static/app/views/dashboards/discoverSplitAlert.spec.tsx
@@ -0,0 +1,33 @@
+import {WidgetFixture} from 'sentry-fixture/widget';
+
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {DatasetSource} from 'sentry/utils/discover/types';
+import localStorage from 'sentry/utils/localStorage';
+import {DiscoverSplitAlert} from 'sentry/views/dashboards/discoverSplitAlert';
+
+describe('DiscoverSplitAlert', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('renders if the widget has a forced split decision', async () => {
+ render(
+
+ );
+
+ await userEvent.hover(screen.getByLabelText('Dataset split warning'));
+
+ expect(
+ await screen.findByText(/We're splitting our datasets up/)
+ ).toBeInTheDocument();
+ });
+
+ it('does not render if there the widget has not been forced', () => {
+ render( );
+
+ expect(screen.queryByText(/We're splitting our datasets up/)).not.toBeInTheDocument();
+ });
+});
diff --git a/static/app/views/dashboards/discoverSplitAlert.tsx b/static/app/views/dashboards/discoverSplitAlert.tsx
new file mode 100644
index 00000000000000..31529f4e04f65b
--- /dev/null
+++ b/static/app/views/dashboards/discoverSplitAlert.tsx
@@ -0,0 +1,26 @@
+import {Tooltip} from 'sentry/components/tooltip';
+import {IconWarning} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {DatasetSource} from 'sentry/utils/discover/types';
+import type {Widget} from 'sentry/views/dashboards/types';
+
+interface DiscoverSplitAlertProps {
+ widget: Widget;
+}
+
+export function DiscoverSplitAlert({widget}: DiscoverSplitAlertProps) {
+ if (widget?.datasetSource !== DatasetSource.FORCED) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/static/app/views/dashboards/types.tsx b/static/app/views/dashboards/types.tsx
index 35ed36e976d1a5..d59a2dfbfa76d8 100644
--- a/static/app/views/dashboards/types.tsx
+++ b/static/app/views/dashboards/types.tsx
@@ -80,6 +80,8 @@ export type WidgetQuery = {
isHidden?: boolean | null;
// Contains the on-demand entries for the widget query.
onDemand?: WidgetQueryOnDemand[];
+ // Aggregate selected for the Big Number widget builder
+ selectedAggregate?: number;
};
export type Widget = {
diff --git a/static/app/views/dashboards/widgetBuilder/buildSteps/yAxisStep/index.tsx b/static/app/views/dashboards/widgetBuilder/buildSteps/yAxisStep/index.tsx
index 4ecfccc61a2426..c0160f38194149 100644
--- a/static/app/views/dashboards/widgetBuilder/buildSteps/yAxisStep/index.tsx
+++ b/static/app/views/dashboards/widgetBuilder/buildSteps/yAxisStep/index.tsx
@@ -14,11 +14,12 @@ interface Props {
aggregates: QueryFieldValue[];
dataSet: DataSet;
displayType: DisplayType;
- onYAxisChange: (newFields: QueryFieldValue[]) => void;
+ onYAxisChange: (newFields: QueryFieldValue[], newSelectedAggregate?: number) => void;
organization: Organization;
tags: TagCollection;
widgetType: WidgetType;
queryErrors?: Record[];
+ selectedAggregate?: number;
}
export function YAxisStep({
@@ -28,6 +29,7 @@ export function YAxisStep({
onYAxisChange,
tags,
widgetType,
+ selectedAggregate,
}: Props) {
return (
);
diff --git a/static/app/views/dashboards/widgetBuilder/buildSteps/yAxisStep/yAxisSelector/index.tsx b/static/app/views/dashboards/widgetBuilder/buildSteps/yAxisStep/yAxisSelector/index.tsx
index 174931207eb359..0bf9a32f4eaa25 100644
--- a/static/app/views/dashboards/widgetBuilder/buildSteps/yAxisStep/yAxisSelector/index.tsx
+++ b/static/app/views/dashboards/widgetBuilder/buildSteps/yAxisStep/yAxisSelector/index.tsx
@@ -1,7 +1,10 @@
import styled from '@emotion/styled';
+import Feature from 'sentry/components/acl/feature';
import ButtonBar from 'sentry/components/buttonBar';
+import {RadioLineItem} from 'sentry/components/forms/controls/radioGroup';
import FieldGroup from 'sentry/components/forms/fieldGroup';
+import Radio from 'sentry/components/radio';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {TagCollection} from 'sentry/types/group';
@@ -25,11 +28,12 @@ interface Props {
/**
* Fired when aggregates are added/removed/modified/reordered.
*/
- onChange: (aggregates: QueryFieldValue[]) => void;
+ onChange: (aggregates: QueryFieldValue[], selectedAggregate?: number) => void;
tags: TagCollection;
widgetType: Widget['widgetType'];
errors?: Record;
noFieldsMessage?: string;
+ selectedAggregate?: number;
}
export function YAxisSelector({
@@ -40,20 +44,26 @@ export function YAxisSelector({
onChange,
errors,
noFieldsMessage,
+ selectedAggregate,
}: Props) {
const organization = useOrganization();
const datasetConfig = getDatasetConfig(widgetType);
const {customMeasurements} = useCustomMeasurements();
- function handleAddOverlay(event: React.MouseEvent) {
+ function handleAddFields(event: React.MouseEvent) {
event.preventDefault();
const newAggregates = [
...aggregates,
{kind: FieldValueKind.FIELD, field: ''} as QueryFieldValue,
];
- onChange(newAggregates);
+ if (
+ organization.features.includes('dashboards-bignumber-equations') &&
+ displayType === DisplayType.BIG_NUMBER
+ ) {
+ onChange(newAggregates, newAggregates.length - 1);
+ } else onChange(newAggregates);
}
function handleAddEquation(event: React.MouseEvent) {
@@ -63,7 +73,13 @@ export function YAxisSelector({
...aggregates,
{kind: FieldValueKind.EQUATION, field: ''} as QueryFieldValue,
];
- onChange(newAggregates);
+ if (
+ organization.features.includes('dashboards-bignumber-equations') &&
+ displayType === DisplayType.BIG_NUMBER
+ ) {
+ const newSelectedAggregate = newAggregates.length - 1;
+ onChange(newAggregates, newSelectedAggregate);
+ } else onChange(newAggregates);
}
function handleRemoveQueryField(event: React.MouseEvent, fieldIndex: number) {
@@ -71,7 +87,13 @@ export function YAxisSelector({
const newAggregates = [...aggregates];
newAggregates.splice(fieldIndex, 1);
- onChange(newAggregates);
+ if (
+ organization.features.includes('dashboards-bignumber-equations') &&
+ displayType === DisplayType.BIG_NUMBER
+ ) {
+ const newSelectedAggregate = newAggregates.length - 1;
+ onChange(newAggregates, newSelectedAggregate);
+ } else onChange(newAggregates);
}
function handleChangeQueryField(value: QueryFieldValue, fieldIndex: number) {
@@ -80,13 +102,19 @@ export function YAxisSelector({
onChange(newAggregates);
}
+ function handleSelectField(newSelectedAggregate: number) {
+ onChange(aggregates, newSelectedAggregate);
+ }
+
const fieldError = errors?.find(error => error?.aggregates)?.aggregates;
const canDelete = aggregates.length > 1;
const hideAddYAxisButtons =
- (DisplayType.BIG_NUMBER === displayType && aggregates.length === 1) ||
([DisplayType.LINE, DisplayType.AREA, DisplayType.BAR].includes(displayType) &&
- aggregates.length === 3);
+ aggregates.length === 3) ||
+ (organization.features.includes('dashboards-bignumber-equations')
+ ? displayType === DisplayType.BIG_NUMBER && widgetType === WidgetType.RELEASE
+ : DisplayType.BIG_NUMBER === displayType && aggregates.length === 1);
let injectedFunctions: Set = new Set();
@@ -112,6 +140,17 @@ export function YAxisSelector({
{aggregates.map((fieldValue, i) => (
+ {aggregates.length > 1 && displayType === DisplayType.BIG_NUMBER && (
+
+
+ handleSelectField(i)}
+ aria-label={'field' + i}
+ />
+
+
+ )}
-
+
{datasetConfig.enableEquations && (
)}
diff --git a/static/app/views/dashboards/widgetBuilder/utils.tsx b/static/app/views/dashboards/widgetBuilder/utils.tsx
index d6b3c3d17094d7..d3ea79b5ce104e 100644
--- a/static/app/views/dashboards/widgetBuilder/utils.tsx
+++ b/static/app/views/dashboards/widgetBuilder/utils.tsx
@@ -6,7 +6,7 @@ import type {FieldValue} from 'sentry/components/forms/types';
import {t} from 'sentry/locale';
import type {SelectValue} from 'sentry/types/core';
import type {TagCollection} from 'sentry/types/group';
-import type {OrganizationSummary} from 'sentry/types/organization';
+import type {Organization, OrganizationSummary} from 'sentry/types/organization';
import {defined} from 'sentry/utils';
import {
aggregateFunctionOutputType,
@@ -148,9 +148,11 @@ export function normalizeQueries({
displayType,
queries,
widgetType,
+ organization,
}: {
displayType: DisplayType;
queries: Widget['queries'];
+ organization?: Organization;
widgetType?: Widget['widgetType'];
}): Widget['queries'] {
const isTimeseriesChart = getIsTimeseriesChart(displayType);
@@ -294,16 +296,25 @@ export function normalizeQueries({
}
if (DisplayType.BIG_NUMBER === displayType) {
- // For world map chart, cap fields of the queries to only one field.
- queries = queries.map(query => {
- return {
- ...query,
- fields: query.aggregates.slice(0, 1),
- aggregates: query.aggregates.slice(0, 1),
- orderby: '',
- columns: [],
- };
- });
+ if (organization?.features.includes('dashboards-bignumber-equations')) {
+ queries = queries.map(query => {
+ return {
+ ...query,
+ orderby: '',
+ columns: [],
+ };
+ });
+ } else {
+ queries = queries.map(query => {
+ return {
+ ...query,
+ fields: query.aggregates.slice(0, 1),
+ aggregates: query.aggregates.slice(0, 1),
+ orderby: '',
+ columns: [],
+ };
+ });
+ }
}
return queries;
diff --git a/static/app/views/dashboards/widgetBuilder/widgetBuilder.spec.tsx b/static/app/views/dashboards/widgetBuilder/widgetBuilder.spec.tsx
index 06f8c624bcb033..b74da2c2eaa355 100644
--- a/static/app/views/dashboards/widgetBuilder/widgetBuilder.spec.tsx
+++ b/static/app/views/dashboards/widgetBuilder/widgetBuilder.spec.tsx
@@ -601,6 +601,39 @@ describe('WidgetBuilder', function () {
expect(handleSave).toHaveBeenCalledTimes(1);
});
+ it('can add additional fields and equation for Big Number with selection', async function () {
+ renderTestComponent({
+ query: {
+ displayType: DisplayType.BIG_NUMBER,
+ },
+ orgFeatures: [...defaultOrgFeatures, 'dashboards-bignumber-equations'],
+ });
+
+ // Add new field
+ await userEvent.click(screen.getByLabelText('Add Field'));
+ expect(screen.getByText('(Required)')).toBeInTheDocument();
+ await selectEvent.select(screen.getByText('(Required)'), ['count_unique(…)']);
+ expect(screen.getByRole('radio', {name: 'field1'})).toBeChecked();
+
+ // Add another new field
+ await userEvent.click(screen.getByLabelText('Add Field'));
+ expect(screen.getByText('(Required)')).toBeInTheDocument();
+ await selectEvent.select(screen.getByText('(Required)'), ['eps()']);
+ expect(screen.getByRole('radio', {name: 'field2'})).toBeChecked();
+
+ // Add an equation
+ await userEvent.click(screen.getByLabelText('Add an Equation'));
+ expect(screen.getByPlaceholderText('Equation')).toBeInTheDocument();
+ expect(screen.getByRole('radio', {name: 'field3'})).toBeChecked();
+ await userEvent.click(screen.getByPlaceholderText('Equation'));
+ await userEvent.paste('eps() + 100');
+
+ // Check if right value is displayed from equation
+ await userEvent.click(screen.getByPlaceholderText('Equation'));
+ await userEvent.paste('2 * 100');
+ expect(screen.getByText('200')).toBeInTheDocument();
+ });
+
it('can add equation fields', async function () {
const handleSave = jest.fn();
diff --git a/static/app/views/dashboards/widgetBuilder/widgetBuilder.tsx b/static/app/views/dashboards/widgetBuilder/widgetBuilder.tsx
index 880857eb56f48a..88b755d09c9749 100644
--- a/static/app/views/dashboards/widgetBuilder/widgetBuilder.tsx
+++ b/static/app/views/dashboards/widgetBuilder/widgetBuilder.tsx
@@ -327,6 +327,7 @@ function WidgetBuilder({
displayType: newDisplayType,
queries: widgetFromDashboard.queries,
widgetType: widgetFromDashboard.widgetType ?? defaultWidgetType,
+ organization: organization,
}).map(query => ({
...query,
// Use the last aggregate because that's where the y-axis is stored
@@ -339,6 +340,7 @@ function WidgetBuilder({
displayType: newDisplayType,
queries: widgetFromDashboard.queries,
widgetType: widgetFromDashboard.widgetType ?? defaultWidgetType,
+ organization: organization,
});
}
@@ -437,6 +439,7 @@ function WidgetBuilder({
displayType: newDisplayType,
queries: [{...getDatasetConfig(defaultWidgetType).defaultWidgetQuery}],
widgetType: defaultWidgetType,
+ organization: organization,
})
);
set(newState, 'dataSet', defaultDataset);
@@ -448,6 +451,7 @@ function WidgetBuilder({
displayType: newDisplayType,
queries: prevState.queries,
widgetType: DATA_SET_TO_WIDGET_TYPE[prevState.dataSet],
+ organization: organization,
});
if (newDisplayType === DisplayType.TOP_N) {
@@ -675,7 +679,10 @@ function WidgetBuilder({
return handleColumnFieldChange;
}
- function handleYAxisChange(newFields: QueryFieldValue[]) {
+ function handleYAxisChange(
+ newFields: QueryFieldValue[],
+ newSelectedAggregate?: number
+ ) {
const fieldStrings = newFields.map(generateFieldAsString);
const newState = cloneDeep(state);
@@ -704,6 +711,12 @@ function WidgetBuilder({
return newQuery;
});
+ if (
+ organization.features.includes('dashboards-bignumber-equations') &&
+ defined(newSelectedAggregate)
+ )
+ newQueries[0].selectedAggregate = newSelectedAggregate;
+
set(newState, 'queries', newQueries);
set(newState, 'userHasModified', true);
@@ -1242,10 +1255,13 @@ function WidgetBuilder({
displayType={state.displayType}
widgetType={widgetType}
queryErrors={state.errors?.queries}
- onYAxisChange={newFields => {
- handleYAxisChange(newFields);
+ onYAxisChange={(newFields, newSelectedField) => {
+ handleYAxisChange(newFields, newSelectedField);
}}
aggregates={explodedAggregates}
+ selectedAggregate={
+ state.queries[0].selectedAggregate
+ }
tags={tags}
organization={organization}
/>
diff --git a/static/app/views/dashboards/widgetBuilder/widgetLibrary/index.tsx b/static/app/views/dashboards/widgetBuilder/widgetLibrary/index.tsx
index 50b9333f1464c3..4d1c71f78e2f82 100644
--- a/static/app/views/dashboards/widgetBuilder/widgetLibrary/index.tsx
+++ b/static/app/views/dashboards/widgetBuilder/widgetLibrary/index.tsx
@@ -67,6 +67,7 @@ export function WidgetLibrary({
displayType,
queries: widget.queries,
widgetType: widget.widgetType,
+ organization: organization,
});
const newWidget = {
diff --git a/static/app/views/dashboards/widgetCard/autoSizedText.tsx b/static/app/views/dashboards/widgetCard/autoSizedText.tsx
index a316ffed7c87f6..f6e4a58ca57f54 100644
--- a/static/app/views/dashboards/widgetCard/autoSizedText.tsx
+++ b/static/app/views/dashboards/widgetCard/autoSizedText.tsx
@@ -51,52 +51,44 @@ export function AutoSizedText({children}: Props) {
let iterationCount = 0;
- Sentry.withScope(scope => {
- const span = Sentry.startInactiveSpan({
- op: 'function',
- name: 'AutoSizedText.iterate',
- forceTransaction: true,
- });
-
- const t1 = performance.now();
-
- // Run the resize iteration in a loop. This blocks the main UI thread and prevents
- // visible layout jitter. If this was done through a `ResizeObserver` or React State
- // each step in the resize iteration would be visible to the user
- while (iterationCount <= ITERATION_LIMIT) {
- const childDimensions = getElementDimensions(childElement);
-
- const widthDifference = parentDimensions.width - childDimensions.width;
- const heightDifference = parentDimensions.height - childDimensions.height;
-
- const childFitsIntoParent = heightDifference > 0 && widthDifference > 0;
- const childIsWithinWidthTolerance =
- Math.abs(widthDifference) <= MAXIMUM_DIFFERENCE;
- const childIsWithinHeightTolerance =
- Math.abs(heightDifference) <= MAXIMUM_DIFFERENCE;
-
- if (
- childFitsIntoParent &&
- (childIsWithinWidthTolerance || childIsWithinHeightTolerance)
- ) {
- // Stop the iteration, we've found a fit!
- span.setAttribute('widthDifference', widthDifference);
- span.setAttribute('heightDifference', heightDifference);
- break;
- }
-
- adjustFontSize(childDimensions, parentDimensions);
-
- iterationCount += 1;
+ const span = Sentry.startInactiveSpan({
+ op: 'function',
+ name: 'AutoSizedText.iterate',
+ onlyIfParent: true,
+ });
+
+ // Run the resize iteration in a loop. This blocks the main UI thread and prevents
+ // visible layout jitter. If this was done through a `ResizeObserver` or React State
+ // each step in the resize iteration would be visible to the user
+ while (iterationCount <= ITERATION_LIMIT) {
+ const childDimensions = getElementDimensions(childElement);
+
+ const widthDifference = parentDimensions.width - childDimensions.width;
+ const heightDifference = parentDimensions.height - childDimensions.height;
+
+ const childFitsIntoParent = heightDifference >= 0 && widthDifference >= 0;
+ const childIsWithinWidthTolerance =
+ Math.abs(widthDifference) <= MAXIMUM_DIFFERENCE;
+ const childIsWithinHeightTolerance =
+ Math.abs(heightDifference) <= MAXIMUM_DIFFERENCE;
+
+ if (
+ childFitsIntoParent &&
+ (childIsWithinWidthTolerance || childIsWithinHeightTolerance)
+ ) {
+ // Stop the iteration, we've found a fit!
+ span.setAttribute('widthDifference', widthDifference);
+ span.setAttribute('heightDifference', heightDifference);
+ break;
}
- const t2 = performance.now();
- scope.setTag('didExceedIterationLimit', iterationCount >= ITERATION_LIMIT);
+ adjustFontSize(childDimensions, parentDimensions);
- span.setAttribute('iterationCount', iterationCount);
- span.setAttribute('durationFromPerformanceAPI', t2 - t1);
- span.end();
- });
+ iterationCount += 1;
+ }
+
+ span.setAttribute('iterationCount', iterationCount);
+ span.end();
});
observer.observe(parentElement);
diff --git a/static/app/views/dashboards/widgetCard/chart.tsx b/static/app/views/dashboards/widgetCard/chart.tsx
index c19cadd74ed0ec..ce39c92378d2ba 100644
--- a/static/app/views/dashboards/widgetCard/chart.tsx
+++ b/static/app/views/dashboards/widgetCard/chart.tsx
@@ -32,6 +32,7 @@ import type {
} from 'sentry/types/echarts';
import type {InjectedRouter} from 'sentry/types/legacyReactRouter';
import type {Organization} from 'sentry/types/organization';
+import {defined} from 'sentry/utils';
import {
axisLabelFormatter,
axisLabelFormatterUsingAggregateOutputType,
@@ -206,7 +207,15 @@ class WidgetCardChart extends Component {
const tableMeta = {...result.meta};
const fields = Object.keys(tableMeta);
- const field = fields[0];
+ let field = fields[0];
+
+ if (
+ organization.features.includes('dashboards-bignumber-equations') &&
+ defined(widget.queries[0].selectedAggregate)
+ ) {
+ const index = widget.queries[0].selectedAggregate;
+ field = widget.queries[0].aggregates[index];
+ }
// Change tableMeta for the field from integer to string since we will be rendering with toLocaleString
const shouldExpandInteger = !!expandNumbers && tableMeta[field] === 'integer';
diff --git a/static/app/views/dashboards/widgetCard/index.spec.tsx b/static/app/views/dashboards/widgetCard/index.spec.tsx
index e7f719e0d8c98d..ac4e294d79a65e 100644
--- a/static/app/views/dashboards/widgetCard/index.spec.tsx
+++ b/static/app/views/dashboards/widgetCard/index.spec.tsx
@@ -1,4 +1,5 @@
import {OrganizationFixture} from 'sentry-fixture/organization';
+import {WidgetFixture} from 'sentry-fixture/widget';
import {initializeOrg} from 'sentry-test/initializeOrg';
import {
@@ -12,6 +13,7 @@ import {
import * as modal from 'sentry/actionCreators/modal';
import * as LineChart from 'sentry/components/charts/lineChart';
import SimpleTableChart from 'sentry/components/charts/simpleTableChart';
+import {DatasetSource} from 'sentry/utils/discover/types';
import {MINUTE, SECOND} from 'sentry/utils/formatters';
import {MEPSettingProvider} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
import type {Widget} from 'sentry/views/dashboards/types';
@@ -789,4 +791,31 @@ describe('Dashboards > WidgetCard', function () {
expect(await screen.findByText('Indexed')).toBeInTheDocument();
});
+
+ it('displays the discover split warning icon when the dataset source is forced', async function () {
+ const testWidget = {...WidgetFixture(), datasetSource: DatasetSource.FORCED};
+
+ renderWithProviders(
+ undefined}
+ onEdit={() => undefined}
+ onDuplicate={() => undefined}
+ renderErrorMessage={() => undefined}
+ showContextMenu
+ widgetLimitReached={false}
+ isPreview
+ />
+ );
+
+ await userEvent.hover(screen.getByLabelText('Dataset split warning'));
+
+ expect(
+ await screen.findByText(/We're splitting our datasets up/)
+ ).toBeInTheDocument();
+ });
});
diff --git a/static/app/views/dashboards/widgetCard/index.tsx b/static/app/views/dashboards/widgetCard/index.tsx
index 598506827de2fe..a3a0835502b885 100644
--- a/static/app/views/dashboards/widgetCard/index.tsx
+++ b/static/app/views/dashboards/widgetCard/index.tsx
@@ -41,6 +41,7 @@ import withPageFilters from 'sentry/utils/withPageFilters';
// eslint-disable-next-line no-restricted-imports
import withSentryRouter from 'sentry/utils/withSentryRouter';
import {DASHBOARD_CHART_GROUP} from 'sentry/views/dashboards/dashboard';
+import {DiscoverSplitAlert} from 'sentry/views/dashboards/discoverSplitAlert';
import {MetricWidgetCard} from 'sentry/views/dashboards/metrics/widgetCard';
import {Toolbar} from 'sentry/views/dashboards/widgetCard/toolbar';
@@ -331,6 +332,7 @@ class WidgetCard extends Component {
)}
+
{widget.description && (
{
>
{({hasFeature}) =>
hasFeature && (
-
+
+
+
)
}
@@ -255,4 +257,10 @@ const BannerWrapper = styled('div')`
grid-column: 1 / -1;
`;
+// Force the dataset selector to have the entire width of the grid
+// so it doesn't go into the overflow menu state when the window is small
+const DatasetSelectorWrapper = styled('div')`
+ grid-column: 1 / -1;
+`;
+
export default withApi(ResultsHeader);
diff --git a/static/app/views/explore/tables/tracesTable/index.tsx b/static/app/views/explore/tables/tracesTable/index.tsx
index 1594f743776e99..3d8d418bbcf720 100644
--- a/static/app/views/explore/tables/tracesTable/index.tsx
+++ b/static/app/views/explore/tables/tracesTable/index.tsx
@@ -45,18 +45,18 @@ import {
export function TracesTable() {
const [query] = useUserQuery();
- const {data, isLoading, isError} = useTraces({
+ const {data, isPending, isError} = useTraces({
query,
limit: DEFAULT_PER_PAGE,
});
const showErrorState = useMemo(() => {
- return !isLoading && isError;
- }, [isLoading, isError]);
+ return !isPending && isError;
+ }, [isPending, isError]);
const showEmptyState = useMemo(() => {
- return !isLoading && !showErrorState && (data?.data?.length ?? 0) === 0;
- }, [data, isLoading, showErrorState]);
+ return !isPending && !showErrorState && (data?.data?.length ?? 0) === 0;
+ }, [data, isPending, showErrorState]);
return (
@@ -79,7 +79,7 @@ export function TracesTable() {
{t('Timestamp')}
- {isLoading && (
+ {isPending && (
diff --git a/static/app/views/explore/tables/tracesTable/spansTable.tsx b/static/app/views/explore/tables/tracesTable/spansTable.tsx
index 57d73e6345b0ed..1feaf1779818ff 100644
--- a/static/app/views/explore/tables/tracesTable/spansTable.tsx
+++ b/static/app/views/explore/tables/tracesTable/spansTable.tsx
@@ -49,7 +49,7 @@ export function SpanTable({
const [query] = useUserQuery();
- const {data, isLoading, isError} = useTraceSpans({
+ const {data, isPending, isError} = useTraceSpans({
trace,
fields: [
...FIELDS,
@@ -72,12 +72,12 @@ export function SpanTable({
const spans = useMemo(() => data?.data ?? [], [data]);
const showErrorState = useMemo(() => {
- return !isLoading && isError;
- }, [isLoading, isError]);
+ return !isPending && isError;
+ }, [isPending, isError]);
const hasData = useMemo(() => {
- return !isLoading && !showErrorState && spans.length > 0;
- }, [spans, isLoading, showErrorState]);
+ return !isPending && !showErrorState && spans.length > 0;
+ }, [spans, isPending, showErrorState]);
return (
@@ -96,7 +96,7 @@ export function SpanTable({
{t('Timestamp')}
- {isLoading && (
+ {isPending && (
diff --git a/static/app/views/insights/browser/webVitals/views/pageOverview.tsx b/static/app/views/insights/browser/webVitals/views/pageOverview.tsx
index f8fb6bed4dc5a5..dffe9459fee836 100644
--- a/static/app/views/insights/browser/webVitals/views/pageOverview.tsx
+++ b/static/app/views/insights/browser/webVitals/views/pageOverview.tsx
@@ -38,7 +38,6 @@ import decodeBrowserTypes from 'sentry/views/insights/browser/webVitals/utils/qu
import {ModulePageProviders} from 'sentry/views/insights/common/components/modulePageProviders';
import {useModuleBreadcrumbs} from 'sentry/views/insights/common/utils/useModuleBreadcrumbs';
import {useModuleURL} from 'sentry/views/insights/common/utils/useModuleURL';
-import SubregionSelector from 'sentry/views/insights/common/views/spans/selectors/subregionSelector';
import {SpanIndexedField, type SubregionCode} from 'sentry/views/insights/types';
import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils';
@@ -197,7 +196,6 @@ export function PageOverview() {
-
-
}
/>
diff --git a/static/app/views/insights/cache/views/cacheLandingPage.tsx b/static/app/views/insights/cache/views/cacheLandingPage.tsx
index 51254868e2dcf9..5f5505497ece67 100644
--- a/static/app/views/insights/cache/views/cacheLandingPage.tsx
+++ b/static/app/views/insights/cache/views/cacheLandingPage.tsx
@@ -46,7 +46,12 @@ import {useOnboardingProject} from 'sentry/views/insights/common/queries/useOnbo
import {useModuleBreadcrumbs} from 'sentry/views/insights/common/utils/useModuleBreadcrumbs';
import {QueryParameterNames} from 'sentry/views/insights/common/views/queryParameters';
import {DataTitles} from 'sentry/views/insights/common/views/spans/types';
-import {ModuleName, SpanFunction, SpanMetricsField} from 'sentry/views/insights/types';
+import {
+ type InsightLandingProps,
+ ModuleName,
+ SpanFunction,
+ SpanMetricsField,
+} from 'sentry/views/insights/types';
const {CACHE_MISS_RATE} = SpanFunction;
const {CACHE_ITEM_SIZE} = SpanMetricsField;
@@ -64,7 +69,7 @@ const SDK_UPDATE_ALERT = (
const CACHE_ERROR_MESSAGE = 'Column cache.hit was not found in metrics indexer';
-export function CacheLandingPage() {
+export function CacheLandingPage({disableHeader}: InsightLandingProps) {
const location = useLocation();
const {setPageInfo, pageAlert} = usePageAlert();
@@ -181,24 +186,26 @@ export function CacheLandingPage() {
return (
-
-
-
+ {!disableHeader && (
+
+
+
-
- {MODULE_TITLE}
-
-
-
-
-
-
-
-
-
+
+ {MODULE_TITLE}
+
+
+
+
+
+
+
+
+
+ )}
@@ -244,7 +251,7 @@ export function CacheLandingPage() {
);
}
-function PageWithProviders() {
+function PageWithProviders(props: InsightLandingProps) {
return (
-
+
);
diff --git a/static/app/views/insights/common/components/fullSpanDescription.spec.tsx b/static/app/views/insights/common/components/fullSpanDescription.spec.tsx
index 1e15175e83dd12..a97a94039c757a 100644
--- a/static/app/views/insights/common/components/fullSpanDescription.spec.tsx
+++ b/static/app/views/insights/common/components/fullSpanDescription.spec.tsx
@@ -135,4 +135,51 @@ describe('FullSpanDescription', function () {
expect(queryCodeSnippet).toBeInTheDocument();
expect(queryCodeSnippet).toHaveClass('language-json');
});
+
+ it('successfully handles truncated MongoDB queries', async function () {
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/events/`,
+ body: {
+ data: [
+ {
+ 'transaction.id': eventId,
+ project: project.slug,
+ span_id: spanId,
+ },
+ ],
+ },
+ });
+
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/events/${project.slug}:${eventId}/`,
+ body: {
+ id: eventId,
+ entries: [
+ {
+ type: EntryType.SPANS,
+ data: [
+ {
+ span_id: spanId,
+ description: `{"insert": "my_cool_collection😎", "a": {}, "uh_oh":"the_query_is_truncated", "ohno*`,
+ data: {'db.system': 'mongodb'},
+ },
+ ],
+ },
+ ],
+ },
+ });
+
+ render( , {
+ organization,
+ });
+
+ await waitForElementToBeRemoved(() => screen.getByTestId('loading-indicator'));
+
+ // The last truncated entry will have a null value assigned and the JSON document is properly closed
+ const queryCodeSnippet = screen.getByText(
+ /\{ "insert": "my_cool_collection😎", "a": \{\}, "uh_oh": "the_query_is_truncated", "ohno\*": null \}/i
+ );
+ expect(queryCodeSnippet).toBeInTheDocument();
+ expect(queryCodeSnippet).toHaveClass('language-json');
+ });
});
diff --git a/static/app/views/insights/common/components/fullSpanDescription.tsx b/static/app/views/insights/common/components/fullSpanDescription.tsx
index 751b7d1479ab1d..70b7ff2e8b0517 100644
--- a/static/app/views/insights/common/components/fullSpanDescription.tsx
+++ b/static/app/views/insights/common/components/fullSpanDescription.tsx
@@ -6,10 +6,7 @@ import LoadingIndicator from 'sentry/components/loadingIndicator';
import {space} from 'sentry/styles/space';
import {SQLishFormatter} from 'sentry/utils/sqlish/SQLishFormatter';
import {useFullSpanFromTrace} from 'sentry/views/insights/common/queries/useFullSpanFromTrace';
-import {
- isValidJson,
- prettyPrintJsonString,
-} from 'sentry/views/insights/database/utils/jsonUtils';
+import {prettyPrintJsonString} from 'sentry/views/insights/database/utils/jsonUtils';
import {ModuleName} from 'sentry/views/insights/types';
const formatter = new SQLishFormatter();
@@ -57,10 +54,12 @@ export function FullSpanDescription({
if (system === 'mongodb') {
let stringifiedQuery = '';
- if (fullSpan?.sentry_tags && isValidJson(fullSpan?.sentry_tags?.description)) {
+ if (fullSpan?.sentry_tags) {
stringifiedQuery = prettyPrintJsonString(fullSpan?.sentry_tags?.description);
- } else if (isValidJson(description)) {
+ } else if (description) {
stringifiedQuery = prettyPrintJsonString(description);
+ } else if (fullSpan?.sentry_tags?.description) {
+ stringifiedQuery = prettyPrintJsonString(fullSpan?.sentry_tags?.description);
} else {
stringifiedQuery = description || fullSpan?.sentry_tags?.description || 'N/A';
}
diff --git a/static/app/views/insights/common/components/issues.tsx b/static/app/views/insights/common/components/issues.tsx
index 296bd4b9e97c8e..bd7b29cbb6b7ee 100644
--- a/static/app/views/insights/common/components/issues.tsx
+++ b/static/app/views/insights/common/components/issues.tsx
@@ -32,7 +32,7 @@ function Issue({data}: {data: Group}) {
return (
-
+
diff --git a/static/app/views/insights/common/queries/useDiscover.spec.tsx b/static/app/views/insights/common/queries/useDiscover.spec.tsx
index 7d197288829291..1c91ae369fa4e9 100644
--- a/static/app/views/insights/common/queries/useDiscover.spec.tsx
+++ b/static/app/views/insights/common/queries/useDiscover.spec.tsx
@@ -13,7 +13,11 @@ import {
useSpanMetrics,
useSpansIndexed,
} from 'sentry/views/insights/common/queries/useDiscover';
-import {SpanIndexedField, type SpanMetricsProperty} from 'sentry/views/insights/types';
+import {
+ SpanIndexedField,
+ type SpanIndexedProperty,
+ type SpanMetricsProperty,
+} from 'sentry/views/insights/types';
import {OrganizationContext} from 'sentry/views/organizationContext';
jest.mock('sentry/utils/useLocation');
@@ -196,7 +200,7 @@ describe('useDiscover', () => {
{
wrapper: Wrapper,
initialProps: {
- fields: [SpanIndexedField.SPAN_DESCRIPTION],
+ fields: [SpanIndexedField.SPAN_DESCRIPTION] as SpanIndexedProperty[],
enabled: false,
},
}
@@ -253,7 +257,7 @@ describe('useDiscover', () => {
SpanIndexedField.SPAN_OP,
SpanIndexedField.SPAN_GROUP,
SpanIndexedField.SPAN_DESCRIPTION,
- ],
+ ] as SpanIndexedProperty[],
sorts: [{field: 'span.group', kind: 'desc' as const}],
limit: 10,
referrer: 'api-spec',
diff --git a/static/app/views/insights/common/queries/useDiscover.ts b/static/app/views/insights/common/queries/useDiscover.ts
index f4ced0f20ab234..0457344f5fbe71 100644
--- a/static/app/views/insights/common/queries/useDiscover.ts
+++ b/static/app/views/insights/common/queries/useDiscover.ts
@@ -6,9 +6,11 @@ import type {MutableSearch} from 'sentry/utils/tokenizeSearch';
import usePageFilters from 'sentry/utils/usePageFilters';
import {useWrappedDiscoverQuery} from 'sentry/views/insights/common/queries/useSpansQuery';
import type {
+ EAPSpanProperty,
+ EAPSpanResponse,
MetricsProperty,
MetricsResponse,
- SpanIndexedField,
+ SpanIndexedProperty,
SpanIndexedResponse,
SpanMetricsProperty,
SpanMetricsResponse,
@@ -25,7 +27,7 @@ interface UseMetricsOptions {
sorts?: Sort[];
}
-export const useSpansIndexed = (
+export const useSpansIndexed = (
options: UseMetricsOptions = {},
referrer: string
) => {
@@ -36,6 +38,17 @@ export const useSpansIndexed = (
);
};
+export const useEAPSpans = (
+ options: UseMetricsOptions = {},
+ referrer: string
+) => {
+ return useDiscover(
+ options,
+ DiscoverDatasets.SPANS_EAP,
+ referrer
+ );
+};
+
export const useSpanMetrics = (
options: UseMetricsOptions = {},
referrer: string
diff --git a/static/app/views/insights/common/queries/useFullSpanFromTrace.tsx b/static/app/views/insights/common/queries/useFullSpanFromTrace.tsx
index 0099be493f40e7..2c792329243f72 100644
--- a/static/app/views/insights/common/queries/useFullSpanFromTrace.tsx
+++ b/static/app/views/insights/common/queries/useFullSpanFromTrace.tsx
@@ -4,7 +4,7 @@ import type {Sort} from 'sentry/utils/discover/fields';
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
import {useSpansIndexed} from 'sentry/views/insights/common/queries/useDiscover';
import {useEventDetails} from 'sentry/views/insights/common/queries/useEventDetails';
-import {SpanIndexedField} from 'sentry/views/insights/types';
+import {SpanIndexedField, type SpanIndexedProperty} from 'sentry/views/insights/types';
const DEFAULT_SORT: Sort[] = [{field: 'timestamp', kind: 'desc'}];
@@ -34,7 +34,7 @@ export function useFullSpanFromTrace(
SpanIndexedField.TRANSACTION_ID,
SpanIndexedField.PROJECT,
SpanIndexedField.ID,
- ...(sorts?.map(sort => sort.field as SpanIndexedField) || []),
+ ...(sorts?.map(sort => sort.field as SpanIndexedProperty) || []),
],
},
'api.starfish.full-span-from-trace'
diff --git a/static/app/views/insights/common/queries/useTransactions.tsx b/static/app/views/insights/common/queries/useTransactions.tsx
index ef38b7729fec3a..cf455ff50a98b2 100644
--- a/static/app/views/insights/common/queries/useTransactions.tsx
+++ b/static/app/views/insights/common/queries/useTransactions.tsx
@@ -49,7 +49,7 @@ export function useTransactions(eventIDs: string[], referrer = 'use-transactions
if (!enabled) {
return {
isFetching: false,
- isLoading: false,
+ isPending: false,
error: null,
data: [],
isEnabled: enabled,
diff --git a/static/app/views/insights/common/views/spanSummaryPage/sampleList/durationChart/index.tsx b/static/app/views/insights/common/views/spanSummaryPage/sampleList/durationChart/index.tsx
index 51929b04ed17ee..9a9d8a20b42f36 100644
--- a/static/app/views/insights/common/views/spanSummaryPage/sampleList/durationChart/index.tsx
+++ b/static/app/views/insights/common/views/spanSummaryPage/sampleList/durationChart/index.tsx
@@ -107,7 +107,7 @@ function DurationChart({
const {
data: spans,
- isLoading: areSpanSamplesLoading,
+ isPending: areSpanSamplesLoading,
isRefetching: areSpanSamplesRefetching,
} = useSpanSamples({
groupId,
diff --git a/static/app/views/insights/common/views/spanSummaryPage/sampleList/sampleTable/sampleTable.spec.tsx b/static/app/views/insights/common/views/spanSummaryPage/sampleList/sampleTable/sampleTable.spec.tsx
index b53bfd726f084d..0b92c60f619a61 100644
--- a/static/app/views/insights/common/views/spanSummaryPage/sampleList/sampleTable/sampleTable.spec.tsx
+++ b/static/app/views/insights/common/views/spanSummaryPage/sampleList/sampleTable/sampleTable.spec.tsx
@@ -5,7 +5,6 @@ import {
} from 'sentry-test/reactTestingLibrary';
import {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
-import {t} from 'sentry/locale';
import type {PageFilters} from 'sentry/types/core';
import {ModuleName, SpanMetricsField} from 'sentry/views/insights/types';
@@ -98,17 +97,17 @@ describe('SampleTable', function () {
columnOrder={[
{
key: 'transaction_id',
- name: t('Event ID'),
+ name: 'Event ID',
width: COL_WIDTH_UNDEFINED,
},
{
key: 'profile_id',
- name: t('Profile'),
+ name: 'Profile',
width: COL_WIDTH_UNDEFINED,
},
{
key: 'avg_comparison',
- name: t('Compared to Average'),
+ name: 'Compared to Average',
width: COL_WIDTH_UNDEFINED,
},
]}
diff --git a/static/app/views/insights/common/views/spanSummaryPage/sampleList/sampleTable/sampleTable.tsx b/static/app/views/insights/common/views/spanSummaryPage/sampleList/sampleTable/sampleTable.tsx
index d345de1c33d402..a8bd848e7e29e8 100644
--- a/static/app/views/insights/common/views/spanSummaryPage/sampleList/sampleTable/sampleTable.tsx
+++ b/static/app/views/insights/common/views/spanSummaryPage/sampleList/sampleTable/sampleTable.tsx
@@ -113,7 +113,7 @@ function SampleTable({
data: transactions,
isFetching: isFetchingTransactions,
isEnabled: isTransactionsEnabled,
- isLoading: isLoadingTransactions,
+ isPending: isLoadingTransactions,
error: transactionError,
} = useTransactions(
spans.map(span => span['transaction.id']),
diff --git a/static/app/views/insights/common/views/spans/selectors/subregionSelector.tsx b/static/app/views/insights/common/views/spans/selectors/subregionSelector.tsx
index 22c803d7a13267..962d99c7d6ae75 100644
--- a/static/app/views/insights/common/views/spans/selectors/subregionSelector.tsx
+++ b/static/app/views/insights/common/views/spans/selectors/subregionSelector.tsx
@@ -66,7 +66,7 @@ export default function SubregionSelector({size}: Props) {
triggerProps={{
prefix: (
-
+
{t('Geo region')}
),
diff --git a/static/app/views/insights/database/components/databaseSystemSelector.spec.tsx b/static/app/views/insights/database/components/databaseSystemSelector.spec.tsx
index 62a2c19d89a695..77eb1c37ad44d8 100644
--- a/static/app/views/insights/database/components/databaseSystemSelector.spec.tsx
+++ b/static/app/views/insights/database/components/databaseSystemSelector.spec.tsx
@@ -63,7 +63,7 @@ describe('DatabaseSystemSelector', function () {
expect(mockSetState).not.toHaveBeenCalled();
const dropdownButton = await screen.findByRole('button');
expect(dropdownButton).toBeInTheDocument();
- expect(dropdownButton).toHaveTextContent('DB SystemNone');
+ expect(dropdownButton).toHaveTextContent('SystemNone');
});
it('is disabled when only one database system is present and shows that system as selected', async function () {
@@ -121,8 +121,8 @@ describe('DatabaseSystemSelector', function () {
const dropdownOptionLabels = await screen.findAllByTestId('menu-list-item-label');
expect(dropdownOptionLabels[0]).toHaveTextContent('PostgreSQL');
expect(dropdownOptionLabels[1]).toHaveTextContent('MongoDB');
- // chungusdb does not exist, so we do not expect this option to have casing
- expect(dropdownOptionLabels[2]).toHaveTextContent('chungusdb');
+ // chungusdb should not be added as an option
+ expect(dropdownOptionLabels).toHaveLength(2);
});
it('chooses the currently selected system from localStorage', async function () {
@@ -208,7 +208,7 @@ describe('DatabaseSystemSelector', function () {
render( , {organization});
const dropdownSelector = await screen.findByRole('button');
- expect(dropdownSelector).toHaveTextContent('DB SystemMongoDB');
+ expect(dropdownSelector).toHaveTextContent('SystemMongoDB');
expect(mockSetState).not.toHaveBeenCalledWith('mongodb');
// Now that it has been confirmed that following a URL does not reset localStorage state, confirm that
@@ -219,7 +219,7 @@ describe('DatabaseSystemSelector', function () {
expect(dropdownOptionLabels[1]).toHaveTextContent('MongoDB');
await userEvent.click(dropdownOptionLabels[0]);
- expect(dropdownSelector).toHaveTextContent('DB SystemPostgreSQL');
+ expect(dropdownSelector).toHaveTextContent('SystemPostgreSQL');
expect(mockSetState).toHaveBeenCalledWith('postgresql');
expect(mockNavigate).toHaveBeenCalledWith({
action: 'POP',
diff --git a/static/app/views/insights/database/components/databaseSystemSelector.tsx b/static/app/views/insights/database/components/databaseSystemSelector.tsx
index cfea831bbccf77..01affaadaa4caa 100644
--- a/static/app/views/insights/database/components/databaseSystemSelector.tsx
+++ b/static/app/views/insights/database/components/databaseSystemSelector.tsx
@@ -33,7 +33,7 @@ export function DatabaseSystemSelector() {
});
}}
options={options}
- triggerProps={{prefix: t('DB System')}}
+ triggerProps={{prefix: t('System')}}
loading={isLoading}
disabled={isError || isLoading || options.length <= 1}
value={systemQueryParam ?? selectedSystem}
diff --git a/static/app/views/insights/database/components/useSystemSelectorOptions.tsx b/static/app/views/insights/database/components/useSystemSelectorOptions.tsx
index 8fe6e98b75a8aa..78771e92a5f06a 100644
--- a/static/app/views/insights/database/components/useSystemSelectorOptions.tsx
+++ b/static/app/views/insights/database/components/useSystemSelectorOptions.tsx
@@ -1,8 +1,16 @@
+import type {ReactNode} from 'react';
+import styled from '@emotion/styled';
+
+import FeatureBadge from 'sentry/components/badge/featureBadge';
import type {SelectOption} from 'sentry/components/compactSelect';
+import {space} from 'sentry/styles/space';
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
import {useSpanMetrics} from 'sentry/views/insights/common/queries/useDiscover';
-import {DATABASE_SYSTEM_TO_LABEL} from 'sentry/views/insights/database/utils/constants';
+import {
+ DATABASE_SYSTEM_TO_LABEL,
+ SupportedDatabaseSystem,
+} from 'sentry/views/insights/database/utils/constants';
import {SpanMetricsField} from 'sentry/views/insights/types';
export function useSystemSelectorOptions() {
@@ -11,7 +19,7 @@ export function useSystemSelectorOptions() {
''
);
- const {data, isLoading, isError} = useSpanMetrics(
+ const {data, isPending, isError} = useSpanMetrics(
{
search: MutableSearch.fromQueryObject({'span.op': 'db'}),
@@ -25,10 +33,28 @@ export function useSystemSelectorOptions() {
data.forEach(entry => {
const system = entry['span.system'];
if (system) {
- const label: string =
+ let label: ReactNode = '';
+ const textValue =
system in DATABASE_SYSTEM_TO_LABEL ? DATABASE_SYSTEM_TO_LABEL[system] : system;
- options.push({value: system, label, textValue: label});
+ const supportedSystemSet: Set = new Set(
+ Object.values(SupportedDatabaseSystem)
+ );
+
+ if (system === SupportedDatabaseSystem.MONGODB) {
+ label = (
+
+ {textValue}
+
+
+ );
+ } else {
+ label = textValue;
+ }
+
+ if (supportedSystemSet.has(system)) {
+ options.push({value: system, label, textValue});
+ }
}
});
@@ -42,5 +68,14 @@ export function useSystemSelectorOptions() {
setSelectedSystem(options[0].value);
}
- return {selectedSystem, setSelectedSystem, options, isLoading, isError};
+ return {selectedSystem, setSelectedSystem, options, isLoading: isPending, isError};
}
+
+const StyledFeatureBadge = styled(FeatureBadge)`
+ margin-left: ${space(1)};
+`;
+
+const LabelContainer = styled('div')`
+ display: flex;
+ align-items: center;
+`;
diff --git a/static/app/views/insights/database/utils/formatMongoDBQuery.spec.tsx b/static/app/views/insights/database/utils/formatMongoDBQuery.spec.tsx
index c08bbf659b980b..57189089494705 100644
--- a/static/app/views/insights/database/utils/formatMongoDBQuery.spec.tsx
+++ b/static/app/views/insights/database/utils/formatMongoDBQuery.spec.tsx
@@ -87,9 +87,30 @@ describe('formatMongoDBQuery', function () {
);
});
- it('returns an unformatted string when given invalid JSON', function () {
- const query = "{'foo': 'bar'}";
- const tokenizedQuery = formatMongoDBQuery(query, 'find');
- expect(tokenizedQuery).toEqual(query);
+ it('handles truncated MongoDB query strings by repairing the JSON', function () {
+ const query = `{"_id":{},"test":"?","insert":"some_collection","address":"?","details":{"email":"?","nam*`;
+ const tokenizedQuery = formatMongoDBQuery(query, 'insert');
+ render({tokenizedQuery} );
+
+ const boldedText = screen.getByText(/"insert": "some_collection"/i);
+ expect(boldedText).toContainHTML('"insert": "some_collection" ');
+
+ // The last entry in this case will be repaired by assigning a null value to the incomplete key
+ const truncatedEntry = screen.getByText(
+ /"details": \{ "email": "\?", "nam\*": null \}/i
+ );
+ expect(truncatedEntry).toBeInTheDocument();
+ });
+
+ it('properly handles formatting MongoDB queries when the operation entry is the last entry', function () {
+ const query = `{"first_key":"first_value","second_key":"second_value","findOne":"my_collection"}`;
+ const tokenizedQuery = formatMongoDBQuery(query, 'findOne');
+ render({tokenizedQuery} );
+
+ const boldedText = screen.getByText(/"findOne": "my_collection"/i);
+ expect(boldedText).toContainHTML('"findOne": "my_collection" ');
+
+ const commaTokens = screen.getAllByText(',');
+ expect(commaTokens).toHaveLength(2);
});
});
diff --git a/static/app/views/insights/database/utils/formatMongoDBQuery.tsx b/static/app/views/insights/database/utils/formatMongoDBQuery.tsx
index d5b728f46464ef..3fe3a63781d3b5 100644
--- a/static/app/views/insights/database/utils/formatMongoDBQuery.tsx
+++ b/static/app/views/insights/database/utils/formatMongoDBQuery.tsx
@@ -1,5 +1,6 @@
import type {ReactElement} from 'react';
import * as Sentry from '@sentry/react';
+import {jsonrepair} from 'jsonrepair';
type JSONValue = string | number | object | boolean | null;
@@ -26,38 +27,40 @@ export function formatMongoDBQuery(query: string, command: string) {
try {
queryObject = JSON.parse(query);
} catch {
- return query;
+ try {
+ const repairedJson = jsonrepair(query);
+ queryObject = JSON.parse(repairedJson);
+ } catch {
+ return query;
+ }
}
const tokens: ReactElement[] = [];
const tempTokens: ReactElement[] = [];
const queryEntries = Object.entries(queryObject);
- queryEntries.forEach(([key, val], index) => {
+ queryEntries.forEach(([key, val]) => {
+ const isBoldedEntry = key.toLowerCase() === command.toLowerCase();
+
// Push the bolded entry into tokens so it is the first entry displayed.
// The other tokens will be pushed into tempTokens, and then copied into tokens afterwards
- const isBoldedEntry = key.toLowerCase() === command.toLowerCase();
+ isBoldedEntry
+ ? tokens.push(jsonEntryToToken(key, val, true))
+ : tempTokens.push(jsonEntryToToken(key, val));
+ });
- if (index === queryEntries.length - 1) {
- isBoldedEntry
- ? tokens.push(jsonEntryToToken(key, val, true))
- : tempTokens.push(jsonEntryToToken(key, val));
+ if (tokens.length === 1 && tempTokens.length > 0) {
+ tokens.push(stringToToken(', ', `${tokens[0].key}:,`));
+ }
- return;
- }
+ tempTokens.forEach((token, index) => {
+ tokens.push(token);
- if (isBoldedEntry) {
- tokens.push(jsonEntryToToken(key, val, true));
- tokens.push(stringToToken(', ', `${key}:${val},`));
- return;
+ if (index !== tempTokens.length - 1) {
+ tokens.push(stringToToken(', ', `${token.key}:${index}`));
}
-
- tempTokens.push(jsonEntryToToken(key, val));
- tempTokens.push(stringToToken(', ', `${key}:${val},`));
});
- tempTokens.forEach(token => tokens.push(token));
-
sentrySpan.end();
return tokens;
diff --git a/static/app/views/insights/database/utils/jsonUtils.tsx b/static/app/views/insights/database/utils/jsonUtils.tsx
index 3c4086f58425e3..79c423dbc60a1d 100644
--- a/static/app/views/insights/database/utils/jsonUtils.tsx
+++ b/static/app/views/insights/database/utils/jsonUtils.tsx
@@ -1,3 +1,5 @@
+import {jsonrepair} from 'jsonrepair';
+
export const isValidJson = (str: string) => {
try {
JSON.parse(str);
@@ -8,5 +10,15 @@ export const isValidJson = (str: string) => {
};
export function prettyPrintJsonString(json: string) {
- return JSON.stringify(JSON.parse(json), null, 4);
+ try {
+ return JSON.stringify(JSON.parse(json), null, 4);
+ } catch {
+ // Attempt to repair the JSON
+ try {
+ const repairedJson = jsonrepair(json);
+ return JSON.stringify(JSON.parse(repairedJson), null, 4);
+ } catch {
+ return json;
+ }
+ }
}
diff --git a/static/app/views/insights/database/views/databaseLandingPage.tsx b/static/app/views/insights/database/views/databaseLandingPage.tsx
index c834f752f64068..485e9cb885455c 100644
--- a/static/app/views/insights/database/views/databaseLandingPage.tsx
+++ b/static/app/views/insights/database/views/databaseLandingPage.tsx
@@ -40,9 +40,13 @@ import {
MODULE_DOC_LINK,
MODULE_TITLE,
} from 'sentry/views/insights/database/settings';
-import {ModuleName, SpanMetricsField} from 'sentry/views/insights/types';
+import {
+ type InsightLandingProps,
+ ModuleName,
+ SpanMetricsField,
+} from 'sentry/views/insights/types';
-export function DatabaseLandingPage() {
+export function DatabaseLandingPage({disableHeader}: InsightLandingProps) {
const organization = useOrganization();
const moduleName = ModuleName.DB;
const location = useLocation();
@@ -160,24 +164,26 @@ export function DatabaseLandingPage() {
return (
-
-
-
+ {!disableHeader && (
+
+
+
-
- {MODULE_TITLE}
-
-
-
-
-
-
-
-
-
+
+ {MODULE_TITLE}
+
+
+
+
+
+
+
+
+
+ )}
@@ -249,14 +255,14 @@ function AlertBanner(props) {
const LIMIT: number = 25;
-function PageWithProviders() {
+function PageWithProviders(props: InsightLandingProps) {
return (
-
+
);
}
diff --git a/static/app/views/insights/http/queries/useSpanSamples.spec.tsx b/static/app/views/insights/http/queries/useSpanSamples.spec.tsx
index 646a3836d81a44..d0274dfb0d0ed9 100644
--- a/static/app/views/insights/http/queries/useSpanSamples.spec.tsx
+++ b/static/app/views/insights/http/queries/useSpanSamples.spec.tsx
@@ -8,7 +8,7 @@ import {QueryClientProvider} from 'sentry/utils/queryClient';
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
import usePageFilters from 'sentry/utils/usePageFilters';
import {useSpanSamples} from 'sentry/views/insights/http/queries/useSpanSamples';
-import {SpanIndexedField} from 'sentry/views/insights/types';
+import {SpanIndexedField, type SpanIndexedProperty} from 'sentry/views/insights/types';
import {OrganizationContext} from 'sentry/views/organizationContext';
jest.mock('sentry/utils/usePageFilters');
@@ -59,7 +59,10 @@ describe('useSpanSamples', () => {
{
wrapper: Wrapper,
initialProps: {
- fields: [SpanIndexedField.TRANSACTION_ID, SpanIndexedField.ID],
+ fields: [
+ SpanIndexedField.TRANSACTION_ID,
+ SpanIndexedField.ID,
+ ] as SpanIndexedProperty[],
enabled: false,
},
}
@@ -100,7 +103,10 @@ describe('useSpanSamples', () => {
release: '0.0.1',
environment: undefined,
},
- fields: [SpanIndexedField.TRANSACTION_ID, SpanIndexedField.ID],
+ fields: [
+ SpanIndexedField.TRANSACTION_ID,
+ SpanIndexedField.ID,
+ ] as SpanIndexedProperty[],
referrer: 'api-spec',
},
}
diff --git a/static/app/views/insights/http/queries/useSpanSamples.tsx b/static/app/views/insights/http/queries/useSpanSamples.tsx
index 635699130cc025..92af450d9b452b 100644
--- a/static/app/views/insights/http/queries/useSpanSamples.tsx
+++ b/static/app/views/insights/http/queries/useSpanSamples.tsx
@@ -6,7 +6,11 @@ import type {MutableSearch} from 'sentry/utils/tokenizeSearch';
import useOrganization from 'sentry/utils/useOrganization';
import usePageFilters from 'sentry/utils/usePageFilters';
import {getDateConditions} from 'sentry/views/insights/common/utils/getDateConditions';
-import type {SpanIndexedField, SpanIndexedResponse} from 'sentry/views/insights/types';
+import type {
+ SpanIndexedField,
+ SpanIndexedProperty,
+ SpanIndexedResponse,
+} from 'sentry/views/insights/types';
interface UseSpanSamplesOptions {
enabled?: boolean;
@@ -17,7 +21,7 @@ interface UseSpanSamplesOptions {
search?: MutableSearch;
}
-export const useSpanSamples = (
+export const useSpanSamples = (
options: UseSpanSamplesOptions = {}
) => {
const {
diff --git a/static/app/views/insights/llmMonitoring/components/charts/llmMonitoringCharts.tsx b/static/app/views/insights/llmMonitoring/components/charts/llmMonitoringCharts.tsx
index 6e83f9db2d9f55..16f3c29d03b627 100644
--- a/static/app/views/insights/llmMonitoring/components/charts/llmMonitoringCharts.tsx
+++ b/static/app/views/insights/llmMonitoring/components/charts/llmMonitoringCharts.tsx
@@ -1,15 +1,58 @@
import {CHART_PALETTE} from 'sentry/constants/chartPalette';
import {t} from 'sentry/locale';
+import {DiscoverDatasets} from 'sentry/utils/discover/types';
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
import Chart, {ChartType} from 'sentry/views/insights/common/components/chart';
import ChartPanel from 'sentry/views/insights/common/components/chartPanel';
-import {useSpanMetricsSeries} from 'sentry/views/insights/common/queries/useDiscoverSeries';
+import {
+ useSpanIndexedSeries,
+ useSpanMetricsSeries,
+} from 'sentry/views/insights/common/queries/useDiscoverSeries';
import {ALERTS} from 'sentry/views/insights/llmMonitoring/alerts';
interface TotalTokensUsedChartProps {
groupId?: string;
}
+export function EAPTotalTokensUsedChart({groupId}: TotalTokensUsedChartProps) {
+ const aggregate = 'sum(ai.total_tokens.used)';
+
+ let query = 'span.category:"ai"';
+ if (groupId) {
+ query = `${query} span.ai.pipeline.group:"${groupId}"`;
+ }
+ const {data, isPending, error} = useSpanIndexedSeries(
+ {
+ yAxis: [aggregate],
+ search: new MutableSearch(query),
+ },
+ 'api.ai-pipelines.view',
+ DiscoverDatasets.SPANS_EAP
+ );
+
+ return (
+
+
+
+ );
+}
+
export function TotalTokensUsedChart({groupId}: TotalTokensUsedChartProps) {
const aggregate = 'sum(ai.total_tokens.used)';
@@ -51,6 +94,42 @@ export function TotalTokensUsedChart({groupId}: TotalTokensUsedChartProps) {
interface NumberOfPipelinesChartProps {
groupId?: string;
}
+
+export function EAPNumberOfPipelinesChart({groupId}: NumberOfPipelinesChartProps) {
+ const aggregate = 'count()';
+
+ let query = 'span.category:"ai.pipeline"';
+ if (groupId) {
+ query = `${query} span.group:"${groupId}"`;
+ }
+ const {data, isPending, error} = useSpanIndexedSeries(
+ {
+ yAxis: [aggregate],
+ search: new MutableSearch(query),
+ },
+ 'api.ai-pipelines-eap.view',
+ DiscoverDatasets.SPANS_EAP
+ );
+
+ return (
+
+
+
+ );
+}
export function NumberOfPipelinesChart({groupId}: NumberOfPipelinesChartProps) {
const aggregate = 'count()';
@@ -89,6 +168,45 @@ export function NumberOfPipelinesChart({groupId}: NumberOfPipelinesChartProps) {
interface PipelineDurationChartProps {
groupId?: string;
}
+
+export function EAPPipelineDurationChart({groupId}: PipelineDurationChartProps) {
+ const aggregate = 'avg(span.duration)';
+ let query = 'span.category:"ai.pipeline"';
+ if (groupId) {
+ query = `${query} span.group:"${groupId}"`;
+ }
+ const {data, isPending, error} = useSpanIndexedSeries(
+ {
+ yAxis: [aggregate],
+ search: new MutableSearch(query),
+ },
+ 'api.ai-pipelines-eap.view',
+ DiscoverDatasets.SPANS_EAP
+ );
+
+ return (
+
+
+
+ );
+}
+
export function PipelineDurationChart({groupId}: PipelineDurationChartProps) {
const aggregate = 'avg(span.duration)';
let query = 'span.category:"ai.pipeline"';
diff --git a/static/app/views/insights/llmMonitoring/components/tables/pipelineSpansTable.tsx b/static/app/views/insights/llmMonitoring/components/tables/pipelineSpansTable.tsx
index de008bf783fde3..6ff915c3d75cb6 100644
--- a/static/app/views/insights/llmMonitoring/components/tables/pipelineSpansTable.tsx
+++ b/static/app/views/insights/llmMonitoring/components/tables/pipelineSpansTable.tsx
@@ -17,7 +17,10 @@ import {MutableSearch} from 'sentry/utils/tokenizeSearch';
import {useLocation} from 'sentry/utils/useLocation';
import useOrganization from 'sentry/utils/useOrganization';
import {renderHeadCell} from 'sentry/views/insights/common/components/tableCells/renderHeadCell';
-import {useSpansIndexed} from 'sentry/views/insights/common/queries/useDiscover';
+import {
+ useEAPSpans,
+ useSpansIndexed,
+} from 'sentry/views/insights/common/queries/useDiscover';
import {QueryParameterNames} from 'sentry/views/insights/common/views/queryParameters';
import {SpanIndexedField} from 'sentry/views/insights/types';
@@ -70,8 +73,9 @@ export function isAValidSort(sort: Sort): sort is ValidSort {
interface Props {
groupId: string;
+ useEAP: boolean;
}
-export function PipelineSpansTable({groupId}: Props) {
+export function PipelineSpansTable({groupId, useEAP}: Props) {
const location = useLocation();
const organization = useOrganization();
@@ -101,21 +105,46 @@ export function PipelineSpansTable({groupId}: Props) {
SpanIndexedField.PROJECT,
],
search: new MutableSearch(`span.category:ai.pipeline span.group:"${groupId}"`),
+ enabled: !useEAP,
},
'api.ai-pipelines.view'
);
- const data = rawData || [];
- const meta = rawMeta as EventsMetaType;
+
+ const {
+ data: eapData,
+ meta: eapMeta,
+ error: eapError,
+ isPending: eapPending,
+ } = useEAPSpans(
+ {
+ limit: 30,
+ sorts: [sort],
+ fields: [
+ SpanIndexedField.ID,
+ SpanIndexedField.TRACE,
+ SpanIndexedField.SPAN_DURATION,
+ SpanIndexedField.TRANSACTION_ID,
+ SpanIndexedField.USER,
+ SpanIndexedField.TIMESTAMP,
+ SpanIndexedField.PROJECT,
+ ],
+ search: new MutableSearch(`span.category:ai.pipeline span.group:"${groupId}"`),
+ enabled: useEAP,
+ },
+ 'api.ai-pipelines.view'
+ );
+ const data = (useEAP ? eapData : rawData) ?? [];
+ const meta = (useEAP ? eapMeta : rawMeta) as EventsMetaType;
return (
0}
- isLoading={isPending}
+ isLoading={useEAP ? eapPending : isPending}
>
x['span.group'])
+ ?.filter(x => !!x)
+ .join(',')}]`
+ ),
+ fields: ['span.ai.pipeline.group', 'sum(ai.total_tokens.used)'],
+ },
+ 'api.ai-pipelines-eap.table'
+ );
+
+ const {
+ data: tokenCostData,
+ isPending: tokenCostLoading,
+ error: tokenCostError,
+ } = useEAPSpans(
+ {
+ search: new MutableSearch(
+ `span.category:ai span.ai.pipeline.group:[${(data as Row[])?.map(x => x['span.group']).join(',')}]`
+ ),
+ fields: ['span.ai.pipeline.group', 'sum(ai.total_cost)'],
+ },
+ 'api.ai-pipelines-eap.table'
+ );
+
+ const rows: Row[] = (data as Row[]).map(baseRow => {
+ const row: Row = {
+ ...baseRow,
+ 'sum(ai.total_tokens.used)': 0,
+ 'sum(ai.total_cost)': 0,
+ };
+ if (!tokensUsedLoading) {
+ const tokenUsedDataPoint = tokensUsedData.find(
+ tokenRow => tokenRow['span.ai.pipeline.group'] === row['span.group']
+ );
+ if (tokenUsedDataPoint) {
+ row['sum(ai.total_tokens.used)'] =
+ tokenUsedDataPoint['sum(ai.total_tokens.used)'];
+ }
+ }
+ if (!tokenCostLoading && !tokenCostError) {
+ const tokenCostDataPoint = tokenCostData.find(
+ tokenRow => tokenRow['span.ai.pipeline.group'] === row['span.group']
+ );
+ if (tokenCostDataPoint) {
+ row['sum(ai.total_cost)'] = tokenCostDataPoint['sum(ai.total_cost)'];
+ }
+ }
+ return row;
+ });
+
+ const handleCursor: CursorHandler = (newCursor, pathname, query) => {
+ browserHistory.push({
+ pathname,
+ query: {...query, [QueryParameterNames.SPANS_CURSOR]: newCursor},
+ });
+ };
+
+ const handleSearch = (newQuery: string) => {
+ browserHistory.push({
+ ...location,
+ query: {
+ ...location.query,
+ 'span.description': newQuery === '' ? undefined : newQuery,
+ [QueryParameterNames.SPANS_CURSOR]: undefined,
+ },
+ });
+ };
+
+ return (
+ 0}
+ isLoading={isPending}
+ >
+
+
+
+ renderHeadCell({
+ column,
+ sort,
+ location,
+ sortParameterName: QueryParameterNames.SPANS_SORT,
+ }),
+ renderBodyCell: (column, row) =>
+ renderBodyCell(moduleURL, column, row, meta, location, organization),
+ }}
+ />
+
+
+
+ );
+}
+
export function PipelinesTable() {
const location = useLocation();
const moduleURL = useModuleURL('ai');
@@ -110,7 +262,6 @@ export function PipelinesTable() {
'span.description': spanDescription ? `*${spanDescription}*` : undefined,
}),
fields: [
- 'project.id',
'span.group',
'span.description',
'spm()',
diff --git a/static/app/views/insights/llmMonitoring/views/llmMonitoringDetailsPage.tsx b/static/app/views/insights/llmMonitoring/views/llmMonitoringDetailsPage.tsx
index 72df5368bd8e7c..cce0a6c8352d1a 100644
--- a/static/app/views/insights/llmMonitoring/views/llmMonitoringDetailsPage.tsx
+++ b/static/app/views/insights/llmMonitoring/views/llmMonitoringDetailsPage.tsx
@@ -17,9 +17,15 @@ import {MetricReadout} from 'sentry/views/insights/common/components/metricReado
import * as ModuleLayout from 'sentry/views/insights/common/components/moduleLayout';
import {ModulePageProviders} from 'sentry/views/insights/common/components/modulePageProviders';
import {ReadoutRibbon, ToolRibbon} from 'sentry/views/insights/common/components/ribbon';
-import {useSpanMetrics} from 'sentry/views/insights/common/queries/useDiscover';
+import {
+ useEAPSpans,
+ useSpanMetrics,
+} from 'sentry/views/insights/common/queries/useDiscover';
import {useModuleBreadcrumbs} from 'sentry/views/insights/common/utils/useModuleBreadcrumbs';
import {
+ EAPNumberOfPipelinesChart,
+ EAPPipelineDurationChart,
+ EAPTotalTokensUsedChart,
NumberOfPipelinesChart,
PipelineDurationChart,
TotalTokensUsedChart,
@@ -54,8 +60,9 @@ export function LLMMonitoringPage({params}: Props) {
'span.group': groupId,
'span.category': 'ai.pipeline',
};
+ const useEAP = organization?.features?.includes('insights-use-eap');
- const {data, isPending: areSpanMetricsLoading} = useSpanMetrics(
+ const {data: spanMetricData, isPending: areSpanMetricsLoading} = useSpanMetrics(
{
search: MutableSearch.fromQueryObject(filters),
fields: [
@@ -64,11 +71,25 @@ export function LLMMonitoringPage({params}: Props) {
`${SpanFunction.SPM}()`,
`avg(${SpanMetricsField.SPAN_DURATION})`,
],
- enabled: Boolean(groupId),
+ enabled: Boolean(groupId) && !useEAP,
},
- 'api.ai-pipelines.view'
+ 'api.ai-pipelines.details.view'
);
- const spanMetrics = data[0] ?? {};
+
+ const {data: eapData, isPending: isEAPPending} = useEAPSpans(
+ {
+ search: MutableSearch.fromQueryObject(filters),
+ fields: [
+ SpanMetricsField.SPAN_OP,
+ 'count()',
+ `${SpanFunction.SPM}()`,
+ `avg(${SpanMetricsField.SPAN_DURATION})`,
+ ],
+ enabled: Boolean(groupId) && useEAP,
+ },
+ 'api.ai-pipelines.details-eap.view'
+ );
+ const spanMetrics = (useEAP ? eapData[0] : spanMetricData[0]) ?? {};
const {data: totalTokenData, isPending: isTotalTokenDataLoading} = useSpanMetrics(
{
@@ -77,11 +98,23 @@ export function LLMMonitoringPage({params}: Props) {
'span.ai.pipeline.group': groupId,
}),
fields: ['sum(ai.total_tokens.used)', 'sum(ai.total_cost)'],
- enabled: Boolean(groupId),
+ enabled: Boolean(groupId) && !useEAP,
+ },
+ 'api.ai-pipelines.details.view'
+ );
+
+ const {data: eapTokenData, isPending: isEAPTotalTokenDataLoading} = useEAPSpans(
+ {
+ search: MutableSearch.fromQueryObject({
+ 'span.category': 'ai',
+ 'span.ai.pipeline.group': groupId,
+ }),
+ fields: ['sum(ai.total_tokens.used)', 'sum(ai.total_cost)'],
+ enabled: Boolean(groupId) && useEAP,
},
- 'api.ai-pipelines.view'
+ 'api.ai-pipelines.details.view'
);
- const tokenUsedMetric = totalTokenData[0] ?? {};
+ const tokenUsedMetric = (useEAP ? eapTokenData[0] : totalTokenData[0]) ?? {};
const crumbs = useModuleBreadcrumbs('ai');
@@ -122,43 +155,59 @@ export function LLMMonitoringPage({params}: Props) {
title={t('Total Tokens Used')}
value={tokenUsedMetric['sum(ai.total_tokens.used)']}
unit={'count'}
- isLoading={isTotalTokenDataLoading}
+ isLoading={
+ useEAP ? isEAPTotalTokenDataLoading : isTotalTokenDataLoading
+ }
/>
-
+ {useEAP ? (
+
+ ) : (
+
+ )}
-
+ {useEAP ? (
+
+ ) : (
+
+ )}
-
+ {useEAP ? (
+
+ ) : (
+
+ )}
-
+
diff --git a/static/app/views/insights/llmMonitoring/views/llmMonitoringLandingPage.tsx b/static/app/views/insights/llmMonitoring/views/llmMonitoringLandingPage.tsx
index 4a9eef9a6cf4fa..e23b3180671bed 100644
--- a/static/app/views/insights/llmMonitoring/views/llmMonitoringLandingPage.tsx
+++ b/static/app/views/insights/llmMonitoring/views/llmMonitoringLandingPage.tsx
@@ -13,44 +13,53 @@ import {ModulePageProviders} from 'sentry/views/insights/common/components/modul
import {ModulesOnboarding} from 'sentry/views/insights/common/components/modulesOnboarding';
import {useModuleBreadcrumbs} from 'sentry/views/insights/common/utils/useModuleBreadcrumbs';
import {
+ EAPNumberOfPipelinesChart,
+ EAPPipelineDurationChart,
+ EAPTotalTokensUsedChart,
NumberOfPipelinesChart,
PipelineDurationChart,
TotalTokensUsedChart,
} from 'sentry/views/insights/llmMonitoring/components/charts/llmMonitoringCharts';
-import {PipelinesTable} from 'sentry/views/insights/llmMonitoring/components/tables/pipelinesTable';
+import {
+ EAPPipelinesTable,
+ PipelinesTable,
+} from 'sentry/views/insights/llmMonitoring/components/tables/pipelinesTable';
import {
MODULE_DOC_LINK,
MODULE_TITLE,
RELEASE_LEVEL,
} from 'sentry/views/insights/llmMonitoring/settings';
-import {ModuleName} from 'sentry/views/insights/types';
+import {type InsightLandingProps, ModuleName} from 'sentry/views/insights/types';
-export function LLMMonitoringPage() {
+export function LLMMonitoringPage({disableHeader}: InsightLandingProps) {
const organization = useOrganization();
const crumbs = useModuleBreadcrumbs('ai');
+ const useEAP = organization?.features?.includes('insights-use-eap');
return (
-
-
-
-
- {MODULE_TITLE}
-
-
-
-
-
-
-
-
-
-
+ {!disableHeader && (
+
+
+
+
+ {MODULE_TITLE}
+
+
+
+
+
+
+
+
+
+
+ )}
@@ -59,16 +68,16 @@ export function LLMMonitoringPage() {
-
+ {useEAP ? : }
-
+ {useEAP ? : }
-
+ {useEAP ? : }
-
+ {useEAP ? : }
@@ -79,14 +88,14 @@ export function LLMMonitoringPage() {
);
}
-function PageWithProviders() {
+function PageWithProviders(props: InsightLandingProps) {
return (
-
+
);
}
diff --git a/static/app/views/insights/mobile/screens/views/screensLandingPage.tsx b/static/app/views/insights/mobile/screens/views/screensLandingPage.tsx
index 8fc9d1cad6c5de..7bffb1c357fd65 100644
--- a/static/app/views/insights/mobile/screens/views/screensLandingPage.tsx
+++ b/static/app/views/insights/mobile/screens/views/screensLandingPage.tsx
@@ -48,9 +48,9 @@ import {
STATUS_UNKNOWN,
type VitalItem,
} from 'sentry/views/insights/mobile/screens/utils';
-import {ModuleName} from 'sentry/views/insights/types';
+import {type InsightLandingProps, ModuleName} from 'sentry/views/insights/types';
-export function ScreensLandingPage() {
+export function ScreensLandingPage({disableHeader}: InsightLandingProps) {
const moduleName = ModuleName.MOBILE_SCREENS;
const crumbs = useModuleBreadcrumbs(moduleName);
const location = useLocation();
@@ -225,24 +225,26 @@ export function ScreensLandingPage() {
-
-
-
-
- {MODULE_TITLE}
-
-
-
-
-
- {isProjectCrossPlatform && }
-
-
-
-
+ {!disableHeader && (
+
+
+
+
+ {MODULE_TITLE}
+
+
+
+
+
+ {isProjectCrossPlatform && }
+
+
+
+
+ )}
diff --git a/static/app/views/insights/pages/aiLandingPage.tsx b/static/app/views/insights/pages/aiLandingPage.tsx
index 6780396fbb531d..3d62f4eb1a629f 100644
--- a/static/app/views/insights/pages/aiLandingPage.tsx
+++ b/static/app/views/insights/pages/aiLandingPage.tsx
@@ -1,7 +1,93 @@
+import {Fragment} from 'react';
+
+import {Breadcrumbs, type Crumb} from 'sentry/components/breadcrumbs';
+import ButtonBar from 'sentry/components/buttonBar';
+import FeedbackWidgetButton from 'sentry/components/feedback/widget/feedbackWidgetButton';
+import * as Layout from 'sentry/components/layouts/thirds';
+import {TabList, TabPanels, Tabs} from 'sentry/components/tabs';
import {t} from 'sentry/locale';
+import {PageAlert} from 'sentry/utils/performance/contexts/pageAlert';
+import LLMLandingPage from 'sentry/views/insights/llmMonitoring/views/llmMonitoringLandingPage';
+import {OVERVIEW_PAGE_TITLE} from 'sentry/views/insights/pages/settings';
+import {
+ type Filters,
+ useFilters,
+ useUpdateFilters,
+} from 'sentry/views/insights/pages/useFilters';
+import {MODULE_TITLES} from 'sentry/views/insights/settings';
+import {type InsightLandingProps, ModuleName} from 'sentry/views/insights/types';
function AiLandingPage() {
- return WELCOME!
;
+ const filters = useFilters();
+ const updateFilters = useUpdateFilters();
+
+ const landingPageProps: InsightLandingProps = {disableHeader: true};
+
+ const crumbs: Crumb[] = [
+ {
+ label: t('Performance'),
+ to: '/performance', // There is no page at `/insights/` so there is nothing to link to
+ preservePageFilters: true,
+ },
+ {
+ label: AI_LANDING_TITLE,
+ to: undefined,
+ preservePageFilters: true,
+ },
+ {
+ label: filters.module ? MODULE_TITLES[filters.module] : OVERVIEW_PAGE_TITLE,
+ to: undefined,
+ preservePageFilters: true,
+ },
+ ];
+
+ const handleTabChange: (key: Filters['module']) => void = key => {
+ if (key === filters.module || (key === OVERVIEW_PAGE_TITLE && !filters.module)) {
+ return;
+ }
+ if (!key) {
+ return;
+ }
+ if (key === OVERVIEW_PAGE_TITLE) {
+ updateFilters({module: undefined});
+ return;
+ }
+ updateFilters({module: key});
+ };
+
+ return (
+
+
+
+
+
+
+ {AI_LANDING_TITLE}
+
+
+
+
+
+
+
+ {OVERVIEW_PAGE_TITLE}
+
+ {MODULE_TITLES[ModuleName.AI]}
+
+
+
+
+
+
+ {'overview page'}
+
+
+
+
+
+
+
+ );
}
export default AiLandingPage;
diff --git a/static/app/views/insights/pages/backendLandingPage.tsx b/static/app/views/insights/pages/backendLandingPage.tsx
index 48cec8041f40dd..03e9f8e3143c1e 100644
--- a/static/app/views/insights/pages/backendLandingPage.tsx
+++ b/static/app/views/insights/pages/backendLandingPage.tsx
@@ -1,10 +1,117 @@
+import {Fragment} from 'react';
+
+import {Breadcrumbs, type Crumb} from 'sentry/components/breadcrumbs';
+import ButtonBar from 'sentry/components/buttonBar';
+import FeedbackWidgetButton from 'sentry/components/feedback/widget/feedbackWidgetButton';
+import * as Layout from 'sentry/components/layouts/thirds';
+import {TabList, TabPanels, Tabs} from 'sentry/components/tabs';
import {t} from 'sentry/locale';
+import {PageAlert} from 'sentry/utils/performance/contexts/pageAlert';
+import CachesLandingPage from 'sentry/views/insights/cache/views/cacheLandingPage';
+import DatabaseLandingPage from 'sentry/views/insights/database/views//databaseLandingPage';
+import HTTPLandingPage from 'sentry/views/insights/http/views/httpLandingPage';
+import {OVERVIEW_PAGE_TITLE} from 'sentry/views/insights/pages/settings';
+import {
+ type Filters,
+ useFilters,
+ useUpdateFilters,
+} from 'sentry/views/insights/pages/useFilters';
+import QueuesLandingPage from 'sentry/views/insights/queues/views/queuesLandingPage';
+import {MODULE_TITLES} from 'sentry/views/insights/settings';
+import {type InsightLandingProps, ModuleName} from 'sentry/views/insights/types';
+
+function BackendLandingPage() {
+ const filters = useFilters();
+ const updateFilters = useUpdateFilters();
+
+ const landingPageProps: InsightLandingProps = {disableHeader: true};
+
+ const crumbs: Crumb[] = [
+ {
+ label: t('Performance'),
+ to: '/performance', // There is no page at `/insights/` so there is nothing to link to
+ preservePageFilters: true,
+ },
+ {
+ label: BACKEND_LANDING_TITLE,
+ to: undefined,
+ preservePageFilters: true,
+ },
+ {
+ label: filters.module ? MODULE_TITLES[filters.module] : OVERVIEW_PAGE_TITLE,
+ to: undefined,
+ preservePageFilters: true,
+ },
+ ];
+
+ const handleTabChange: (key: Filters['module']) => void = key => {
+ if (key === filters.module || (key === OVERVIEW_PAGE_TITLE && !filters.module)) {
+ return;
+ }
+ if (!key) {
+ return;
+ }
+ if (key === OVERVIEW_PAGE_TITLE) {
+ updateFilters({module: undefined});
+ return;
+ }
+ updateFilters({module: key});
+ };
+
+ return (
+
+
+
+
+
-function PlatformLandingPage() {
- return WELCOME!
;
+ {BACKEND_LANDING_TITLE}
+
+
+
+
+
+
+
+ {OVERVIEW_PAGE_TITLE}
+
+ {MODULE_TITLES[ModuleName.DB]}
+
+
+ {MODULE_TITLES[ModuleName.HTTP]}
+
+
+ {MODULE_TITLES[ModuleName.CACHE]}
+
+
+ {MODULE_TITLES[ModuleName.QUEUE]}
+
+
+
+
+
+
+ {'overview page'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
}
-export default PlatformLandingPage;
+export default BackendLandingPage;
export const BACKEND_LANDING_SUB_PATH = 'backend';
export const BACKEND_LANDING_TITLE = t('Backend');
diff --git a/static/app/views/insights/pages/frontendLandingPage.tsx b/static/app/views/insights/pages/frontendLandingPage.tsx
index 683b480e6fe93d..62af61ed62810b 100644
--- a/static/app/views/insights/pages/frontendLandingPage.tsx
+++ b/static/app/views/insights/pages/frontendLandingPage.tsx
@@ -10,6 +10,7 @@ import {PageAlert} from 'sentry/utils/performance/contexts/pageAlert';
import ResourcesLandingPage from 'sentry/views/insights/browser/resources/views/resourcesLandingPage';
import WebVitalsLandingPage from 'sentry/views/insights/browser/webVitals/views/webVitalsLandingPage';
import HTTPLandingPage from 'sentry/views/insights/http/views/httpLandingPage';
+import {OVERVIEW_PAGE_TITLE} from 'sentry/views/insights/pages/settings';
import {
type Filters,
useFilters,
@@ -18,7 +19,7 @@ import {
import {MODULE_TITLES} from 'sentry/views/insights/settings';
import {type InsightLandingProps, ModuleName} from 'sentry/views/insights/types';
-function WebLandingPage() {
+function FrontendLandingPage() {
const filters = useFilters();
const updateFilters = useUpdateFilters();
@@ -32,24 +33,24 @@ function WebLandingPage() {
},
{
label: FRONTEND_LANDING_TITLE,
- to: '/performance/web',
+ to: undefined,
preservePageFilters: true,
},
{
- label: filters.module ? MODULE_TITLES[filters.module] : 'Overview',
+ label: filters.module ? MODULE_TITLES[filters.module] : OVERVIEW_PAGE_TITLE,
to: undefined,
preservePageFilters: true,
},
];
const handleTabChange: (key: Filters['module']) => void = key => {
- if (key === filters.module || (key === 'overview' && !filters.module)) {
+ if (key === filters.module || (key === OVERVIEW_PAGE_TITLE && !filters.module)) {
return;
}
if (!key) {
return;
}
- if (key === 'overview') {
+ if (key === OVERVIEW_PAGE_TITLE) {
updateFilters({module: undefined});
return;
}
@@ -58,7 +59,7 @@ function WebLandingPage() {
return (
-
+
@@ -71,7 +72,7 @@ function WebLandingPage() {
- {'Overview'}
+ {'Overview'}
{MODULE_TITLES[ModuleName.VITAL]}
@@ -86,7 +87,7 @@ function WebLandingPage() {
- {'overview page'}
+ {'overview page'}
@@ -103,7 +104,7 @@ function WebLandingPage() {
);
}
-export default WebLandingPage;
+export default FrontendLandingPage;
export const FRONTEND_LANDING_SUB_PATH = 'frontend';
export const FRONTEND_LANDING_TITLE = t('Frontend');
diff --git a/static/app/views/insights/pages/mobileLandingPage.tsx b/static/app/views/insights/pages/mobileLandingPage.tsx
index 9ff464daadc735..59402daa08858a 100644
--- a/static/app/views/insights/pages/mobileLandingPage.tsx
+++ b/static/app/views/insights/pages/mobileLandingPage.tsx
@@ -1,7 +1,93 @@
+import {Fragment} from 'react';
+
+import {Breadcrumbs, type Crumb} from 'sentry/components/breadcrumbs';
+import ButtonBar from 'sentry/components/buttonBar';
+import FeedbackWidgetButton from 'sentry/components/feedback/widget/feedbackWidgetButton';
+import * as Layout from 'sentry/components/layouts/thirds';
+import {TabList, TabPanels, Tabs} from 'sentry/components/tabs';
import {t} from 'sentry/locale';
+import {PageAlert} from 'sentry/utils/performance/contexts/pageAlert';
+import ScreensLandingPage from 'sentry/views/insights/mobile/screens/views/screensLandingPage';
+import {OVERVIEW_PAGE_TITLE} from 'sentry/views/insights/pages/settings';
+import {
+ type Filters,
+ useFilters,
+ useUpdateFilters,
+} from 'sentry/views/insights/pages/useFilters';
+import {MODULE_TITLES} from 'sentry/views/insights/settings';
+import {type InsightLandingProps, ModuleName} from 'sentry/views/insights/types';
function MobileLandingPage() {
- return WELCOME!
;
+ const filters = useFilters();
+ const updateFilters = useUpdateFilters();
+
+ const landingPageProps: InsightLandingProps = {disableHeader: true};
+
+ const crumbs: Crumb[] = [
+ {
+ label: t('Performance'),
+ to: '/performance', // There is no page at `/insights/` so there is nothing to link to
+ preservePageFilters: true,
+ },
+ {
+ label: MOBILE_LANDING_TITLE,
+ to: undefined,
+ preservePageFilters: true,
+ },
+ {
+ label: filters.module ? MODULE_TITLES[filters.module] : OVERVIEW_PAGE_TITLE,
+ to: undefined,
+ preservePageFilters: true,
+ },
+ ];
+
+ const handleTabChange: (key: Filters['module']) => void = key => {
+ if (key === filters.module || (key === OVERVIEW_PAGE_TITLE && !filters.module)) {
+ return;
+ }
+ if (!key) {
+ return;
+ }
+ if (key === OVERVIEW_PAGE_TITLE) {
+ updateFilters({module: undefined});
+ return;
+ }
+ updateFilters({module: key});
+ };
+
+ return (
+
+
+
+
+
+
+ {MOBILE_LANDING_TITLE}
+
+
+
+
+
+
+
+ {OVERVIEW_PAGE_TITLE}
+
+ {MODULE_TITLES[ModuleName.MOBILE_SCREENS]}
+
+
+
+
+
+
+ {'overview page'}
+
+
+
+
+
+
+
+ );
}
export default MobileLandingPage;
diff --git a/static/app/views/insights/pages/settings.ts b/static/app/views/insights/pages/settings.ts
new file mode 100644
index 00000000000000..64ae33222c8492
--- /dev/null
+++ b/static/app/views/insights/pages/settings.ts
@@ -0,0 +1,3 @@
+import {t} from 'sentry/locale';
+
+export const OVERVIEW_PAGE_TITLE = t('Overview');
diff --git a/static/app/views/insights/queues/views/queuesLandingPage.tsx b/static/app/views/insights/queues/views/queuesLandingPage.tsx
index cd6115ce18c1c1..03703dc82d22b4 100644
--- a/static/app/views/insights/queues/views/queuesLandingPage.tsx
+++ b/static/app/views/insights/queues/views/queuesLandingPage.tsx
@@ -34,14 +34,14 @@ import {
MODULE_DOC_LINK,
MODULE_TITLE,
} from 'sentry/views/insights/queues/settings';
-import {ModuleName} from 'sentry/views/insights/types';
+import {type InsightLandingProps, ModuleName} from 'sentry/views/insights/types';
const DEFAULT_SORT = {
field: 'time_spent_percentage(app,span.duration)' as const,
kind: 'desc' as const,
};
-function QueuesLandingPage() {
+function QueuesLandingPage({disableHeader}: InsightLandingProps) {
const location = useLocation();
const organization = useOrganization();
@@ -83,24 +83,26 @@ function QueuesLandingPage() {
return (
-
-
-
+ {!disableHeader && (
+
+
+
-
- {MODULE_TITLE}
-
-
-
-
-
-
-
-
-
+
+ {MODULE_TITLE}
+
+
+
+
+
+
+
+
+
+ )}
@@ -133,14 +135,14 @@ function QueuesLandingPage() {
);
}
-function PageWithProviders() {
+function PageWithProviders(props: InsightLandingProps) {
return (
-
+
);
}
diff --git a/static/app/views/insights/types.tsx b/static/app/views/insights/types.tsx
index 16d4fa44ab4b4a..bf4645447a0b5d 100644
--- a/static/app/views/insights/types.tsx
+++ b/static/app/views/insights/types.tsx
@@ -73,6 +73,7 @@ export type SpanNumberFields =
| SpanMetricsField.CACHE_ITEM_SIZE;
export type SpanStringFields =
+ | 'span_id'
| 'span.op'
| 'span.description'
| 'span.module'
@@ -80,14 +81,18 @@ export type SpanStringFields =
| 'span.group'
| 'span.category'
| 'span.system'
+ | 'timestamp'
+ | 'trace'
| 'transaction'
+ | 'transaction.id'
| 'transaction.method'
| 'release'
| 'os.name'
| 'span.status_code'
| 'span.ai.pipeline.group'
| 'project'
- | 'messaging.destination.name';
+ | 'messaging.destination.name'
+ | 'user';
export type SpanMetricsQueryFilters = {
[Field in SpanStringFields]?: string;
@@ -178,6 +183,35 @@ export type MetricsFilters = {
export type SpanMetricsProperty = keyof SpanMetricsResponse;
+export type EAPSpanResponse = {
+ [Property in SpanNumberFields as `${Aggregate}(${Property})`]: number;
+} & {
+ [Property in SpanFunctions as `${Property}()`]: number;
+} & {
+ [Property in SpanStringFields as `${Property}`]: string;
+} & {
+ [Property in SpanNumberFields as `${Property}`]: number;
+} & {
+ [Property in SpanStringArrayFields as `${Property}`]: string[];
+} & {
+ ['project']: string;
+ ['project.id']: number;
+} & {
+ [Function in RegressionFunctions]: number;
+} & {
+ [Function in SpanAnyFunction]: string;
+} & {
+ [Property in ConditionalAggregate as
+ | `${Property}(${string})`
+ | `${Property}(${string},${string})`
+ | `${Property}(${string},${string},${string})`]: number;
+} & {
+ [SpanMetricsField.USER_GEO_SUBREGION]: SubregionCode;
+ [SpanIndexedField.SPAN_AI_PIPELINE_GROUP_TAG]: string;
+};
+
+export type EAPSpanProperty = keyof EAPSpanResponse;
+
export enum SpanIndexedField {
ENVIRONMENT = 'environment',
RESOURCE_RENDER_BLOCKING_STATUS = 'resource.render_blocking_status',
@@ -193,6 +227,7 @@ export enum SpanIndexedField {
ID = 'span_id',
SPAN_ACTION = 'span.action',
SPAN_AI_PIPELINE_GROUP = 'span.ai.pipeline.group',
+ SPAN_AI_PIPELINE_GROUP_TAG = 'ai_pipeline_group',
SDK_NAME = 'sdk.name',
TRACE = 'trace',
TRANSACTION_ID = 'transaction.id',
@@ -297,7 +332,7 @@ export type SpanIndexedResponse = {
[SpanIndexedField.USER_GEO_SUBREGION]: string;
};
-export type SpanIndexedPropery = keyof SpanIndexedResponse;
+export type SpanIndexedProperty = keyof SpanIndexedResponse;
// TODO: When convenient, remove this alias and use `IndexedResponse` everywhere
export type SpanIndexedFieldTypes = SpanIndexedResponse;
diff --git a/static/app/views/issueDetails/groupDetails.tsx b/static/app/views/issueDetails/groupDetails.tsx
index 36e141d55f9009..f66c3883f43649 100644
--- a/static/app/views/issueDetails/groupDetails.tsx
+++ b/static/app/views/issueDetails/groupDetails.tsx
@@ -260,7 +260,7 @@ function useEventApiQuery({
const latestOrRecommendedEvent = useApiQuery(queryKey, {
// Latest/recommended event will change over time, so only cache for 30 seconds
staleTime: 30000,
- cacheTime: 30000,
+ gcTime: 30000,
enabled: isOnDetailsTab && isLatestOrRecommendedEvent,
retry: false,
});
@@ -370,7 +370,7 @@ function useFetchGroupDetails(): FetchGroupDetailsState {
makeFetchGroupQueryKey({organizationSlug: organization.slug, groupId, environments}),
{
staleTime: 30000,
- cacheTime: 30000,
+ gcTime: 30000,
retry: false,
}
);
diff --git a/static/app/views/issueDetails/groupEventAttachments/groupEventAttachments.spec.tsx b/static/app/views/issueDetails/groupEventAttachments/groupEventAttachments.spec.tsx
index 85b81071b28645..56a7796e2162bb 100644
--- a/static/app/views/issueDetails/groupEventAttachments/groupEventAttachments.spec.tsx
+++ b/static/app/views/issueDetails/groupEventAttachments/groupEventAttachments.spec.tsx
@@ -1,28 +1,39 @@
import {EventAttachmentFixture} from 'sentry-fixture/eventAttachment';
-import {OrganizationFixture} from 'sentry-fixture/organization';
import {ProjectFixture} from 'sentry-fixture/project';
import {initializeOrg} from 'sentry-test/initializeOrg';
-import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+import {
+ render,
+ renderGlobalModal,
+ screen,
+ userEvent,
+ within,
+} from 'sentry-test/reactTestingLibrary';
-import {openModal} from 'sentry/actionCreators/modal';
import GroupStore from 'sentry/stores/groupStore';
+import ModalStore from 'sentry/stores/modalStore';
import ProjectsStore from 'sentry/stores/projectsStore';
+import type {Project} from 'sentry/types/project';
-import GroupEventAttachments, {MAX_SCREENSHOTS_PER_PAGE} from './groupEventAttachments';
+import GroupEventAttachments from './groupEventAttachments';
-jest.mock('sentry/actionCreators/modal');
-
-describe('GroupEventAttachments > Screenshots', function () {
+describe('GroupEventAttachments', function () {
+ const groupId = 'group-id';
const {organization, router} = initializeOrg({
- organization: OrganizationFixture(),
+ organization: {
+ features: ['event-attachments'],
+ orgRole: 'member',
+ attachmentsRole: 'member',
+ },
+ });
+ const {router: screenshotRouter} = initializeOrg({
router: {
params: {orgId: 'org-slug', groupId: 'group-id'},
- location: {query: {types: 'event.screenshot'}},
+ location: {query: {attachmentFilter: 'screenshot'}},
},
- } as Parameters[0]);
- let project;
- let getAttachmentsMock;
+ });
+ let project: Project;
+ let getAttachmentsMock: jest.Mock;
beforeEach(function () {
project = ProjectFixture({platform: 'apple-ios'});
@@ -30,56 +41,66 @@ describe('GroupEventAttachments > Screenshots', function () {
GroupStore.init();
getAttachmentsMock = MockApiClient.addMockResponse({
- url: '/organizations/org-slug/issues/group-id/attachments/',
+ url: `/organizations/org-slug/issues/${groupId}/attachments/`,
body: [EventAttachmentFixture()],
});
});
- afterEach(() => {});
+ afterEach(() => {
+ MockApiClient.clearMockResponses();
+ ModalStore.reset();
+ });
- function renderGroupEventAttachments() {
- return render( , {
- router,
+ it('calls attachments api with screenshot filter', async function () {
+ render( , {
+ router: screenshotRouter,
organization,
});
- }
-
- it('calls attachments api with screenshot filter', async function () {
- renderGroupEventAttachments();
expect(screen.getByRole('radio', {name: 'Screenshots'})).toBeInTheDocument();
await userEvent.click(screen.getByRole('radio', {name: 'Screenshots'}));
expect(getAttachmentsMock).toHaveBeenCalledWith(
'/organizations/org-slug/issues/group-id/attachments/',
expect.objectContaining({
- query: {per_page: MAX_SCREENSHOTS_PER_PAGE, screenshot: 1, types: undefined},
+ query: {screenshot: '1'},
})
);
});
it('does not render screenshots tab if not mobile platform', function () {
project.platform = 'javascript';
- renderGroupEventAttachments();
+ render( , {
+ router: screenshotRouter,
+ organization,
+ });
expect(screen.queryByText('Screenshots')).not.toBeInTheDocument();
});
it('calls opens modal when clicking on panel body', async function () {
- renderGroupEventAttachments();
+ render( , {
+ router: screenshotRouter,
+ organization,
+ });
+ renderGlobalModal();
await userEvent.click(await screen.findByTestId('screenshot-1'));
- expect(openModal).toHaveBeenCalled();
+ expect(await screen.findByRole('dialog')).toBeInTheDocument();
});
it('links event id to event detail', async function () {
- renderGroupEventAttachments();
- expect(
- (await screen.findByText('12345678901234567890123456789012')).closest('a')
- ).toHaveAttribute(
+ render( , {
+ router,
+ organization,
+ });
+ expect(await screen.findByRole('link', {name: '12345678'})).toHaveAttribute(
'href',
'/organizations/org-slug/issues/group-id/events/12345678901234567890123456789012/'
);
});
it('links to the download URL', async function () {
- renderGroupEventAttachments();
+ render( , {
+ router: screenshotRouter,
+ organization,
+ });
await userEvent.click(await screen.findByLabelText('Actions'));
expect(screen.getByText('Download').closest('a')).toHaveAttribute(
'href',
@@ -87,12 +108,41 @@ describe('GroupEventAttachments > Screenshots', function () {
);
});
- it('displays an error message when request fails', async function () {
+ it('displays error message when request fails', async function () {
MockApiClient.addMockResponse({
url: '/organizations/org-slug/issues/group-id/attachments/',
statusCode: 500,
});
- renderGroupEventAttachments();
+ render( , {
+ router,
+ organization,
+ });
expect(await screen.findByText(/error loading/i)).toBeInTheDocument();
});
+
+ it('can delete an attachment', async function () {
+ const deleteMock = MockApiClient.addMockResponse({
+ url: '/projects/org-slug/project-slug/events/12345678901234567890123456789012/attachments/1/',
+ method: 'DELETE',
+ });
+ render( , {
+ router,
+ organization,
+ });
+ renderGlobalModal();
+
+ expect(await screen.findByText('12345678')).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'Delete'})).toBeEnabled();
+ await userEvent.click(screen.getByRole('button', {name: 'Delete'}));
+
+ expect(
+ await screen.findByText('Are you sure you wish to delete this file?')
+ ).toBeInTheDocument();
+ await userEvent.click(
+ within(screen.getByRole('dialog')).getByRole('button', {name: 'Delete'})
+ );
+
+ expect(deleteMock).toHaveBeenCalled();
+ expect(screen.queryByText('12345678')).not.toBeInTheDocument();
+ });
});
diff --git a/static/app/views/issueDetails/groupEventAttachments/groupEventAttachments.tsx b/static/app/views/issueDetails/groupEventAttachments/groupEventAttachments.tsx
index 4cd9e69cc3b013..baabe2095502af 100644
--- a/static/app/views/issueDetails/groupEventAttachments/groupEventAttachments.tsx
+++ b/static/app/views/issueDetails/groupEventAttachments/groupEventAttachments.tsx
@@ -1,150 +1,53 @@
-import {useState} from 'react';
import styled from '@emotion/styled';
-import pick from 'lodash/pick';
-import xor from 'lodash/xor';
-import {addErrorMessage} from 'sentry/actionCreators/indicator';
import EmptyStateWarning from 'sentry/components/emptyStateWarning';
-import * as Layout from 'sentry/components/layouts/thirds';
import LoadingError from 'sentry/components/loadingError';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import Pagination from 'sentry/components/pagination';
-import Panel from 'sentry/components/panels/panel';
-import PanelBody from 'sentry/components/panels/panelBody';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {IssueAttachment} from 'sentry/types/group';
import type {Project} from 'sentry/types/project';
-import {useApiQuery, useMutation} from 'sentry/utils/queryClient';
-import {decodeList} from 'sentry/utils/queryString';
-import useApi from 'sentry/utils/useApi';
import {useLocation} from 'sentry/utils/useLocation';
-import {useParams} from 'sentry/utils/useParams';
+import useOrganization from 'sentry/utils/useOrganization';
import GroupEventAttachmentsFilter, {
- crashReportTypes,
- SCREENSHOT_TYPE,
+ EventAttachmentFilter,
} from './groupEventAttachmentsFilter';
import GroupEventAttachmentsTable from './groupEventAttachmentsTable';
import {ScreenshotCard} from './screenshotCard';
+import {useDeleteGroupEventAttachment} from './useDeleteGroupEventAttachment';
+import {useGroupEventAttachments} from './useGroupEventAttachments';
type GroupEventAttachmentsProps = {
+ groupId: string;
project: Project;
};
-enum EventAttachmentFilter {
- ALL = 'all',
- CRASH_REPORTS = 'onlyCrash',
- SCREENSHOTS = 'screenshot',
-}
-
-export const MAX_SCREENSHOTS_PER_PAGE = 12;
-
-function useActiveAttachmentsTab() {
- const location = useLocation();
-
- const types = decodeList(location.query.types);
- if (types.length === 0) {
- return EventAttachmentFilter.ALL;
- }
- if (types[0] === SCREENSHOT_TYPE) {
- return EventAttachmentFilter.SCREENSHOTS;
- }
- if (xor(crashReportTypes, types).length === 0) {
- return EventAttachmentFilter.CRASH_REPORTS;
- }
- return EventAttachmentFilter.ALL;
-}
-
-function GroupEventAttachments({project}: GroupEventAttachmentsProps) {
+function GroupEventAttachments({project, groupId}: GroupEventAttachmentsProps) {
const location = useLocation();
- const {groupId, orgId} = useParams<{groupId: string; orgId: string}>();
- const activeAttachmentsTab = useActiveAttachmentsTab();
- const [deletedAttachments, setDeletedAttachments] = useState([]);
- const api = useApi();
-
- const {
- data: eventAttachments,
- isPending,
- isError,
- getResponseHeader,
- refetch,
- } = useApiQuery(
- [
- `/organizations/${orgId}/issues/${groupId}/attachments/`,
- {
- query:
- activeAttachmentsTab === EventAttachmentFilter.SCREENSHOTS
- ? {
- ...location.query,
- types: undefined, // need to explicitly set this to undefined because AsyncComponent adds location query back into the params
- screenshot: 1,
- per_page: MAX_SCREENSHOTS_PER_PAGE,
- }
- : {
- ...pick(location.query, ['cursor', 'environment', 'types']),
- per_page: 50,
- },
- },
- ],
- {staleTime: 0}
- );
-
- const {mutate: deleteAttachment} = useMutation({
- mutationFn: ({attachmentId, eventId}: {attachmentId: string; eventId: string}) =>
- api.requestPromise(
- `/projects/${orgId}/${project.slug}/events/${eventId}/attachments/${attachmentId}/`,
- {
- method: 'DELETE',
- }
- ),
- onError: () => {
- addErrorMessage('An error occurred while deleteting the attachment');
- },
- });
-
- const handleDelete = (deletedAttachmentId: string) => {
- const attachment = eventAttachments?.find(item => item.id === deletedAttachmentId);
- if (!attachment) {
- return;
- }
-
- setDeletedAttachments(prevState => [...prevState, deletedAttachmentId]);
-
- deleteAttachment({attachmentId: attachment.id, eventId: attachment.event_id});
- };
-
- const renderInnerBody = () => {
- if (isPending) {
- return ;
- }
-
- if (eventAttachments && eventAttachments.length > 0) {
- return (
-
- );
- }
-
- if (activeAttachmentsTab === EventAttachmentFilter.CRASH_REPORTS) {
- return (
-
- {t('No crash reports found')}
-
- );
- }
-
- return (
-
- {t('No attachments found')}
-
- );
+ const organization = useOrganization();
+ const activeAttachmentsTab =
+ (location.query.attachmentFilter as EventAttachmentFilter | undefined) ??
+ EventAttachmentFilter.ALL;
+ const {attachments, isPending, isError, getResponseHeader, refetch} =
+ useGroupEventAttachments({
+ groupId,
+ activeAttachmentsTab,
+ });
+
+ const {mutate: deleteAttachment} = useDeleteGroupEventAttachment();
+
+ const handleDelete = (attachment: IssueAttachment) => {
+ deleteAttachment({
+ attachment,
+ groupId,
+ orgSlug: organization.slug,
+ activeAttachmentsTab,
+ projectSlug: project.slug,
+ cursor: location.query.cursor as string | undefined,
+ environment: location.query.environment as string[] | string | undefined,
+ });
};
const renderAttachmentsTable = () => {
@@ -153,9 +56,18 @@ function GroupEventAttachments({project}: GroupEventAttachmentsProps) {
}
return (
-
- {renderInnerBody()}
-
+
);
};
@@ -168,10 +80,10 @@ function GroupEventAttachments({project}: GroupEventAttachmentsProps) {
return ;
}
- if (eventAttachments && eventAttachments.length > 0) {
+ if (attachments.length > 0) {
return (
- {eventAttachments?.map((screenshot, index) => {
+ {attachments.map((screenshot, index) => {
return (
);
@@ -198,15 +110,13 @@ function GroupEventAttachments({project}: GroupEventAttachmentsProps) {
};
return (
-
-
-
- {activeAttachmentsTab === EventAttachmentFilter.SCREENSHOTS
- ? renderScreenshotGallery()
- : renderAttachmentsTable()}
-
-
-
+
+
+ {activeAttachmentsTab === EventAttachmentFilter.SCREENSHOT
+ ? renderScreenshotGallery()
+ : renderAttachmentsTable()}
+
+
);
}
@@ -230,3 +140,13 @@ const ScreenshotGrid = styled('div')`
grid-template-columns: repeat(6, minmax(100px, 1fr));
}
`;
+
+const NoMarginPagination = styled(Pagination)`
+ margin: 0;
+`;
+
+const Wrapper = styled('div')`
+ display: flex;
+ flex-direction: column;
+ gap: ${space(2)};
+`;
diff --git a/static/app/views/issueDetails/groupEventAttachments/groupEventAttachmentsDrawer.spec.tsx b/static/app/views/issueDetails/groupEventAttachments/groupEventAttachmentsDrawer.spec.tsx
new file mode 100644
index 00000000000000..e588eabcb82e3b
--- /dev/null
+++ b/static/app/views/issueDetails/groupEventAttachments/groupEventAttachmentsDrawer.spec.tsx
@@ -0,0 +1,137 @@
+import {EventAttachmentFixture} from 'sentry-fixture/eventAttachment';
+import {ProjectFixture} from 'sentry-fixture/project';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {
+ render,
+ renderGlobalModal,
+ screen,
+ userEvent,
+ within,
+} from 'sentry-test/reactTestingLibrary';
+
+import GroupStore from 'sentry/stores/groupStore';
+import ModalStore from 'sentry/stores/modalStore';
+import ProjectsStore from 'sentry/stores/projectsStore';
+import type {Project} from 'sentry/types/project';
+
+import {GroupEventAttachmentsDrawer} from './groupEventAttachmentsDrawer';
+
+describe('GroupEventAttachmentsDrawer', function () {
+ const groupId = 'group-id';
+ const {organization, router} = initializeOrg({
+ organization: {
+ features: ['event-attachments'],
+ orgRole: 'member',
+ attachmentsRole: 'member',
+ },
+ });
+ const {router: screenshotRouter} = initializeOrg({
+ router: {
+ params: {orgId: 'org-slug', groupId: 'group-id'},
+ location: {query: {attachmentFilter: 'screenshot'}},
+ },
+ });
+ let project: Project;
+ let getAttachmentsMock: jest.Mock;
+
+ beforeEach(function () {
+ project = ProjectFixture({platform: 'apple-ios'});
+ ProjectsStore.loadInitialData([project]);
+ GroupStore.init();
+
+ getAttachmentsMock = MockApiClient.addMockResponse({
+ url: `/organizations/org-slug/issues/${groupId}/attachments/`,
+ body: [EventAttachmentFixture()],
+ });
+ });
+
+ afterEach(() => {
+ MockApiClient.clearMockResponses();
+ ModalStore.reset();
+ });
+
+ it('calls attachments api with screenshot filter', async function () {
+ render( , {
+ router: screenshotRouter,
+ organization,
+ });
+ expect(screen.getByRole('radio', {name: 'Screenshots'})).toBeInTheDocument();
+ await userEvent.click(screen.getByRole('radio', {name: 'Screenshots'}));
+ expect(getAttachmentsMock).toHaveBeenCalledWith(
+ '/organizations/org-slug/issues/group-id/attachments/',
+ expect.objectContaining({
+ query: {screenshot: '1'},
+ })
+ );
+ });
+
+ it('does not render screenshots tab if not mobile platform', function () {
+ project.platform = 'javascript';
+ render( , {
+ router: screenshotRouter,
+ organization,
+ });
+ expect(screen.queryByText('Screenshots')).not.toBeInTheDocument();
+ });
+
+ it('links event id to event detail', async function () {
+ render( , {
+ router,
+ organization,
+ });
+ expect(await screen.findByRole('link', {name: '12345678'})).toHaveAttribute(
+ 'href',
+ '/organizations/org-slug/issues/group-id/events/12345678901234567890123456789012/'
+ );
+ });
+
+ it('links to the download URL', async function () {
+ render( , {
+ router: screenshotRouter,
+ organization,
+ });
+ expect(await screen.findByRole('button', {name: 'Download'})).toHaveAttribute(
+ 'href',
+ '/api/0/projects/org-slug/project-slug/events/12345678901234567890123456789012/attachments/1/?download=1'
+ );
+ });
+
+ it('displays error message when request fails', async function () {
+ MockApiClient.addMockResponse({
+ url: '/organizations/org-slug/issues/group-id/attachments/',
+ statusCode: 500,
+ });
+ render( , {
+ router,
+ organization,
+ });
+ expect(await screen.findByText(/error loading/i)).toBeInTheDocument();
+ });
+
+ it('can delete an attachment', async function () {
+ const deleteMock = MockApiClient.addMockResponse({
+ url: '/projects/org-slug/project-slug/events/12345678901234567890123456789012/attachments/1/',
+ method: 'DELETE',
+ });
+ render( , {
+ router,
+ organization,
+ });
+ renderGlobalModal();
+
+ expect(await screen.findByText('12345678')).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'Delete'})).toBeEnabled();
+ await userEvent.click(screen.getByRole('button', {name: 'Delete'}));
+
+ expect(
+ await screen.findByText('Are you sure you wish to delete this file?')
+ ).toBeInTheDocument();
+ await userEvent.click(
+ within(screen.getByRole('dialog')).getByRole('button', {name: 'Delete'})
+ );
+
+ expect(deleteMock).toHaveBeenCalled();
+ expect(screen.queryByText('12345678')).not.toBeInTheDocument();
+ });
+});
diff --git a/static/app/views/issueDetails/groupEventAttachments/groupEventAttachmentsDrawer.tsx b/static/app/views/issueDetails/groupEventAttachments/groupEventAttachmentsDrawer.tsx
new file mode 100644
index 00000000000000..544932d8c29f92
--- /dev/null
+++ b/static/app/views/issueDetails/groupEventAttachments/groupEventAttachmentsDrawer.tsx
@@ -0,0 +1,125 @@
+import styled from '@emotion/styled';
+
+import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
+import {
+ CrumbContainer,
+ EventDrawerBody,
+ EventDrawerContainer,
+ EventDrawerHeader,
+ EventNavigator,
+ Header,
+ NavigationCrumbs,
+ ShortId,
+} from 'sentry/components/events/eventDrawer';
+import LoadingError from 'sentry/components/loadingError';
+import Pagination from 'sentry/components/pagination';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {IssueAttachment} from 'sentry/types/group';
+import type {Project} from 'sentry/types/project';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import GroupEventAttachmentsFilter, {
+ EventAttachmentFilter,
+} from './groupEventAttachmentsFilter';
+import GroupEventAttachmentsTable from './groupEventAttachmentsTable';
+import {useDeleteGroupEventAttachment} from './useDeleteGroupEventAttachment';
+import {useGroupEventAttachments} from './useGroupEventAttachments';
+
+type GroupEventAttachmentsProps = {
+ groupId: string;
+ project: Project;
+};
+
+export function GroupEventAttachmentsDrawer({
+ project,
+ groupId,
+}: GroupEventAttachmentsProps) {
+ const location = useLocation();
+ const organization = useOrganization();
+ const activeAttachmentsTab =
+ (location.query.attachmentFilter as EventAttachmentFilter | undefined) ??
+ EventAttachmentFilter.ALL;
+ const {attachments, isPending, isError, getResponseHeader, refetch} =
+ useGroupEventAttachments({
+ groupId,
+ activeAttachmentsTab,
+ });
+
+ const {mutate: deleteAttachment} = useDeleteGroupEventAttachment();
+
+ const handleDelete = (attachment: IssueAttachment) => {
+ deleteAttachment({
+ attachment,
+ groupId,
+ orgSlug: organization.slug,
+ activeAttachmentsTab,
+ projectSlug: project.slug,
+ cursor: location.query.cursor as string | undefined,
+ environment: location.query.environment as string[] | string | undefined,
+ });
+ };
+
+ const renderAttachmentsTable = () => {
+ if (isError) {
+ return ;
+ }
+
+ return (
+
+ );
+ };
+
+ return (
+
+
+
+
+ {groupId}
+
+ ),
+ },
+ {label: t('Attachments')},
+ ]}
+ />
+
+
+
+
+
+
+
+ {/* TODO(issue-details-streamline): Bring back a grid for screenshots */}
+ {renderAttachmentsTable()}
+
+
+
+
+ );
+}
+
+const NoMarginPagination = styled(Pagination)`
+ margin: 0;
+`;
+
+const Wrapper = styled('div')`
+ display: flex;
+ flex-direction: column;
+ gap: ${space(2)};
+`;
diff --git a/static/app/views/issueDetails/groupEventAttachments/groupEventAttachmentsFilter.tsx b/static/app/views/issueDetails/groupEventAttachments/groupEventAttachmentsFilter.tsx
index 65a02d077826a3..3f9f3eb52da3c3 100644
--- a/static/app/views/issueDetails/groupEventAttachments/groupEventAttachmentsFilter.tsx
+++ b/static/app/views/issueDetails/groupEventAttachments/groupEventAttachmentsFilter.tsx
@@ -1,78 +1,64 @@
import styled from '@emotion/styled';
-import omit from 'lodash/omit';
-import xor from 'lodash/xor';
import {SegmentedControl} from 'sentry/components/segmentedControl';
import {t} from 'sentry/locale';
-import {space} from 'sentry/styles/space';
import type {Project} from 'sentry/types/project';
import {isMobilePlatform} from 'sentry/utils/platform';
import {useLocation} from 'sentry/utils/useLocation';
-import useRouter from 'sentry/utils/useRouter';
+import {useNavigate} from 'sentry/utils/useNavigate';
const crashReportTypes = ['event.minidump', 'event.applecrashreport'];
const SCREENSHOT_TYPE = 'event.screenshot';
+export const enum EventAttachmentFilter {
+ ALL = 'all',
+ CRASH_REPORTS = 'onlyCrash',
+ SCREENSHOT = 'screenshot',
+}
+
+type AttachmentFilterValue = `${EventAttachmentFilter}`;
+
type Props = {
project: Project;
};
function GroupEventAttachmentsFilter(props: Props) {
const {project} = props;
- const {query, pathname} = useLocation();
- const router = useRouter();
- const {types} = query;
- const allAttachmentsQuery = omit(query, 'types');
- const onlyCrashReportsQuery = {
- ...query,
- types: crashReportTypes,
- };
+ const location = useLocation();
+ const navigate = useNavigate();
- const onlyScreenshotQuery = {
- ...query,
- types: SCREENSHOT_TYPE,
- };
-
- let activeButton: 'all' | 'screenshot' | 'onlyCrash' = 'all';
-
- if (types === undefined) {
- activeButton = 'all';
- } else if (types === SCREENSHOT_TYPE) {
- activeButton = 'screenshot';
- } else if (xor(crashReportTypes, types).length === 0) {
- activeButton = 'onlyCrash';
- }
+ const activeFilter: AttachmentFilterValue =
+ (location.query.attachmentFilter as AttachmentFilterValue | undefined) ??
+ EventAttachmentFilter.ALL;
return (
{
- switch (key) {
- case 'screenshot':
- router.replace({pathname, query: onlyScreenshotQuery});
- break;
- case 'onlyCrash':
- router.replace({pathname, query: onlyCrashReportsQuery});
- break;
- case 'all':
- default:
- router.replace({pathname, query: allAttachmentsQuery});
- }
+ navigate(
+ {
+ pathname: location.pathname,
+ query: {...location.query, attachmentFilter: key},
+ },
+ {replace: true}
+ );
}}
>
{[
- {t('All Attachments')} ,
+
+ {t('All Attachments')}
+ ,
...(isMobilePlatform(project.platform)
? [
-
+
{t('Screenshots')}
,
]
: []),
-
+
{t('Only Crash Reports')}
,
]}
@@ -84,7 +70,6 @@ function GroupEventAttachmentsFilter(props: Props) {
const FilterWrapper = styled('div')`
display: flex;
justify-content: flex-end;
- margin-bottom: ${space(3)};
`;
export {crashReportTypes, SCREENSHOT_TYPE};
diff --git a/static/app/views/issueDetails/groupEventAttachments/groupEventAttachmentsTable.tsx b/static/app/views/issueDetails/groupEventAttachments/groupEventAttachmentsTable.tsx
index 36f6063d022c28..fdf63cab1dbde8 100644
--- a/static/app/views/issueDetails/groupEventAttachments/groupEventAttachmentsTable.tsx
+++ b/static/app/views/issueDetails/groupEventAttachments/groupEventAttachmentsTable.tsx
@@ -1,50 +1,62 @@
+import styled from '@emotion/styled';
+
+import {PanelTable} from 'sentry/components/panels/panelTable';
import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
import type {IssueAttachment} from 'sentry/types/group';
import GroupEventAttachmentsTableRow from 'sentry/views/issueDetails/groupEventAttachments/groupEventAttachmentsTableRow';
type Props = {
attachments: IssueAttachment[];
- deletedAttachments: string[];
+ emptyMessage: string;
groupId: string;
- onDelete: (attachmentId: string) => void;
- orgId: string;
+ isLoading: boolean;
+ onDelete: (attachment: IssueAttachment) => void;
projectSlug: string;
};
function GroupEventAttachmentsTable({
+ isLoading,
attachments,
- orgId,
projectSlug,
groupId,
+ emptyMessage,
onDelete,
- deletedAttachments,
}: Props) {
- const tableRowNames = [t('Name'), t('Type'), t('Size'), t('Actions')];
-
return (
-
-
-
- {tableRowNames.map(name => (
- {name}
- ))}
-
-
-
- {attachments.map(attachment => (
- attachment.id === id)}
- />
- ))}
-
-
+
+ {attachments.map(attachment => (
+
+ ))}
+
);
}
export default GroupEventAttachmentsTable;
+
+const AttachmentsPanelTable = styled(PanelTable)`
+ grid-template-columns: 1fr repeat(3, min-content);
+ margin-bottom: 0;
+
+ & > :last-child {
+ padding: ${p => (p.isEmpty ? space(4) : undefined)};
+ }
+
+ .preview {
+ padding: 0;
+ }
+ .preview-open {
+ border-bottom: none;
+ }
+`;
diff --git a/static/app/views/issueDetails/groupEventAttachments/groupEventAttachmentsTableRow.tsx b/static/app/views/issueDetails/groupEventAttachments/groupEventAttachmentsTableRow.tsx
index 7a063b6a967516..ccdcb888dad105 100644
--- a/static/app/views/issueDetails/groupEventAttachments/groupEventAttachmentsTableRow.tsx
+++ b/static/app/views/issueDetails/groupEventAttachments/groupEventAttachmentsTableRow.tsx
@@ -1,20 +1,27 @@
+import {Fragment, useState} from 'react';
import styled from '@emotion/styled';
import {DateTime} from 'sentry/components/dateTime';
-import AttachmentUrl from 'sentry/components/events/attachmentUrl';
import EventAttachmentActions from 'sentry/components/events/eventAttachmentActions';
import FileSize from 'sentry/components/fileSize';
import Link from 'sentry/components/links/link';
+import {Tooltip} from 'sentry/components/tooltip';
import {t} from 'sentry/locale';
import type {IssueAttachment} from 'sentry/types/group';
-import {types} from 'sentry/views/issueDetails/groupEventAttachments/types';
+import {getShortEventId} from 'sentry/utils/events';
+import useOrganization from 'sentry/utils/useOrganization';
+import {InlineEventAttachment} from 'sentry/views/issueDetails/groupEventAttachments/inlineEventAttachment';
+
+const friendlyAttachmentType = {
+ 'event.minidump': t('Minidump'),
+ 'event.applecrashreport': t('Apple Crash Report'),
+ 'event.attachment': t('Other'),
+};
type Props = {
attachment: IssueAttachment;
groupId: string;
- isDeleted: boolean;
- onDelete: (attachmentId: string) => void;
- orgId: string;
+ onDelete: (attachment: IssueAttachment) => void;
projectSlug: string;
};
@@ -22,65 +29,75 @@ function GroupEventAttachmentsTableRow({
attachment,
projectSlug,
onDelete,
- isDeleted,
- orgId,
groupId,
}: Props) {
+ const organization = useOrganization();
+ const [previewIsOpen, setPreviewIsOpen] = useState(false);
+
+ const handlePreviewClick = () => {
+ setPreviewIsOpen(!previewIsOpen);
+ };
+
+ const sharedClassName = previewIsOpen ? 'preview-open' : undefined;
+
return (
-
-
-
- {attachment.name}
-
-
+
+
+
+
{attachment.name}
+
·{' '}
- {attachment.event_id}
+
+ {getShortEventId(attachment.event_id)}
+
-
-
-
-
- {types[attachment.type] || t('Other')}
-
-
+
+
+
+
+ {friendlyAttachmentType[attachment.type] ?? t('Other')}
+
+
-
-
-
-
-
+
+ onDelete(attachment)}
+ onPreviewClick={handlePreviewClick}
+ previewIsOpen={previewIsOpen}
+ projectSlug={projectSlug}
+ />
+
+ {previewIsOpen && (
+
+
- {url =>
- !isDeleted ? (
-
- ) : null
- }
-
-
-
-
+ />
+
+ )}
+
);
}
-const TableRow = styled('tr')<{isDeleted: boolean}>`
- opacity: ${p => (p.isDeleted ? 0.3 : 1)};
- td {
- text-decoration: ${p => (p.isDeleted ? 'line-through' : 'normal')};
- }
+const AttachmentName = styled('div')`
+ font-weight: bold;
+`;
+
+const FlexCenter = styled('div')`
+ display: flex;
+ align-items: center;
+ white-space: nowrap;
`;
-const ActionsWrapper = styled('div')`
- display: inline-block;
+const InlineAttachment = styled('div')`
+ grid-column: 1/-1;
`;
export default GroupEventAttachmentsTableRow;
diff --git a/static/app/views/issueDetails/groupEventAttachments/index.tsx b/static/app/views/issueDetails/groupEventAttachments/index.tsx
index bde6640f16e195..337914c6457ac3 100644
--- a/static/app/views/issueDetails/groupEventAttachments/index.tsx
+++ b/static/app/views/issueDetails/groupEventAttachments/index.tsx
@@ -1,19 +1,19 @@
import Feature from 'sentry/components/acl/feature';
import FeatureDisabled from 'sentry/components/acl/featureDisabled';
+import * as Layout from 'sentry/components/layouts/thirds';
import {t} from 'sentry/locale';
import type {Group} from 'sentry/types/group';
import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
-import type {Organization} from 'sentry/types/organization';
-import withOrganization from 'sentry/utils/withOrganization';
+import useOrganization from 'sentry/utils/useOrganization';
import GroupEventAttachments from './groupEventAttachments';
type Props = RouteComponentProps<{groupId: string}, {}> & {
group: Group;
- organization: Organization;
};
-function GroupEventAttachmentsContainer({organization, group}: Props) {
+function GroupEventAttachmentsContainer({group}: Props) {
+ const organization = useOrganization();
return (
)}
>
-
+
+
+
+
+
);
}
-export default withOrganization(GroupEventAttachmentsContainer);
+export default GroupEventAttachmentsContainer;
diff --git a/static/app/views/issueDetails/groupEventAttachments/inlineEventAttachment.tsx b/static/app/views/issueDetails/groupEventAttachments/inlineEventAttachment.tsx
new file mode 100644
index 00000000000000..891fe3bdb7d9d0
--- /dev/null
+++ b/static/app/views/issueDetails/groupEventAttachments/inlineEventAttachment.tsx
@@ -0,0 +1,42 @@
+import styled from '@emotion/styled';
+
+import {getInlineAttachmentRenderer} from 'sentry/components/events/attachmentViewers/previewAttachmentTypes';
+import type {Event} from 'sentry/types/event';
+import type {IssueAttachment} from 'sentry/types/group';
+import useOrganization from 'sentry/utils/useOrganization';
+
+interface InlineAttachmentsProps {
+ attachment: IssueAttachment;
+ eventId: Event['id'];
+ projectSlug: string;
+}
+
+export function InlineEventAttachment({
+ attachment,
+ projectSlug,
+ eventId,
+}: InlineAttachmentsProps) {
+ const organization = useOrganization();
+ const AttachmentComponent = getInlineAttachmentRenderer(attachment);
+
+ if (!AttachmentComponent) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
+
+const AttachmentPreviewWrapper = styled('div')`
+ grid-column: auto / span 3;
+ border: none;
+ padding: 0;
+`;
diff --git a/static/app/views/issueDetails/groupEventAttachments/screenshotCard.tsx b/static/app/views/issueDetails/groupEventAttachments/screenshotCard.tsx
index 9832a87fcf717b..5bb319b2dffe36 100644
--- a/static/app/views/issueDetails/groupEventAttachments/screenshotCard.tsx
+++ b/static/app/views/issueDetails/groupEventAttachments/screenshotCard.tsx
@@ -29,7 +29,7 @@ type Props = {
eventAttachment: IssueAttachment;
eventId: string;
groupId: string;
- onDelete: (attachmentId: string) => void;
+ onDelete: (attachment: IssueAttachment) => void;
projectSlug: Project['slug'];
pageLinks?: string | null | undefined;
};
@@ -53,7 +53,7 @@ export function ScreenshotCard({
trackAnalytics('issue_details.attachment_tab.screenshot_modal_deleted', {
organization,
});
- onDelete(eventAttachment.id);
+ onDelete(eventAttachment);
}
function openVisualizationModal() {
@@ -148,7 +148,7 @@ export function ScreenshotCard({
shouldConfirm
confirmPriority="danger"
confirmLabel={t('Delete')}
- onAction={() => onDelete(eventAttachment.id)}
+ onAction={() => onDelete(eventAttachment)}
header={t('This image was captured around the time that the event occurred.')}
message={t('Are you sure you wish to delete this image?')}
>
diff --git a/static/app/views/issueDetails/groupEventAttachments/types.tsx b/static/app/views/issueDetails/groupEventAttachments/types.tsx
deleted file mode 100644
index 433a8a8d8dcd50..00000000000000
--- a/static/app/views/issueDetails/groupEventAttachments/types.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-import {t} from 'sentry/locale';
-
-export const types = {
- 'event.minidump': t('Minidump'),
- 'event.applecrashreport': t('Apple Crash Report'),
- 'event.attachment': t('Other'),
-};
diff --git a/static/app/views/issueDetails/groupEventAttachments/useDeleteGroupEventAttachment.tsx b/static/app/views/issueDetails/groupEventAttachments/useDeleteGroupEventAttachment.tsx
new file mode 100644
index 00000000000000..39556b809c4998
--- /dev/null
+++ b/static/app/views/issueDetails/groupEventAttachments/useDeleteGroupEventAttachment.tsx
@@ -0,0 +1,87 @@
+import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
+import {t} from 'sentry/locale';
+import type {IssueAttachment} from 'sentry/types/group';
+import {
+ getApiQueryData,
+ setApiQueryData,
+ useMutation,
+ useQueryClient,
+} from 'sentry/utils/queryClient';
+import type RequestError from 'sentry/utils/requestError/requestError';
+import useApi from 'sentry/utils/useApi';
+
+import {makeFetchGroupEventAttachmentsQueryKey} from './useGroupEventAttachments';
+
+type DeleteGroupEventAttachmentVariables = Parameters<
+ typeof makeFetchGroupEventAttachmentsQueryKey
+>[0] & {
+ attachment: IssueAttachment;
+ projectSlug: string;
+};
+
+type DeleteGroupEventAttachmentContext = {
+ previous?: IssueAttachment[];
+};
+
+export function useDeleteGroupEventAttachment() {
+ const api = useApi({persistInFlight: true});
+ const queryClient = useQueryClient();
+ return useMutation<
+ unknown,
+ RequestError,
+ DeleteGroupEventAttachmentVariables,
+ DeleteGroupEventAttachmentContext
+ >({
+ mutationFn: variables =>
+ api.requestPromise(
+ `/projects/${variables.orgSlug}/${variables.projectSlug}/events/${variables.attachment.event_id}/attachments/${variables.attachment.id}/`,
+ {
+ method: 'DELETE',
+ }
+ ),
+ onMutate: async variables => {
+ await queryClient.cancelQueries({
+ queryKey: makeFetchGroupEventAttachmentsQueryKey(variables),
+ });
+
+ const previous = getApiQueryData(
+ queryClient,
+ makeFetchGroupEventAttachmentsQueryKey(variables)
+ );
+
+ setApiQueryData(
+ queryClient,
+ makeFetchGroupEventAttachmentsQueryKey(variables),
+ oldData => {
+ if (!Array.isArray(oldData)) {
+ return oldData;
+ }
+
+ return oldData.filter(
+ oldAttachment => oldAttachment.id !== variables.attachment.id
+ );
+ }
+ );
+
+ return {previous};
+ },
+ onSuccess: () => {
+ addSuccessMessage(t('Attachment deleted'));
+ },
+ onError: (error, variables, context) => {
+ addErrorMessage(
+ error?.responseJSON?.detail
+ ? (error.responseJSON.detail as string)
+ : t('An error occurred while deleting the attachment')
+ );
+
+ if (context) {
+ setApiQueryData(
+ queryClient,
+ makeFetchGroupEventAttachmentsQueryKey(variables),
+ context.previous
+ );
+ }
+ },
+ });
+}
diff --git a/static/app/views/issueDetails/groupEventAttachments/useGroupEventAttachments.tsx b/static/app/views/issueDetails/groupEventAttachments/useGroupEventAttachments.tsx
new file mode 100644
index 00000000000000..8dbf03380ba9c9
--- /dev/null
+++ b/static/app/views/issueDetails/groupEventAttachments/useGroupEventAttachments.tsx
@@ -0,0 +1,91 @@
+import type {IssueAttachment} from 'sentry/types/group';
+import {
+ type ApiQueryKey,
+ useApiQuery,
+ type UseApiQueryOptions,
+} from 'sentry/utils/queryClient';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+
+interface UseGroupEventAttachmentsOptions {
+ activeAttachmentsTab: 'all' | 'onlyCrash' | 'screenshot';
+ groupId: string;
+ options?: Pick, 'placeholderData'>;
+}
+
+interface MakeFetchGroupEventAttachmentsQueryKeyOptions
+ extends UseGroupEventAttachmentsOptions {
+ cursor: string | undefined;
+ environment: string[] | string | undefined;
+ orgSlug: string;
+}
+
+type GroupEventAttachmentsTypeFilter =
+ | 'event.minidump'
+ | 'event.applecrashreport'
+ | 'event.screenshot';
+
+interface GroupEventAttachmentsQuery {
+ cursor?: string;
+ environment?: string[] | string;
+ per_page?: string;
+ screenshot?: '1';
+ types?: `${GroupEventAttachmentsTypeFilter}` | `${GroupEventAttachmentsTypeFilter}`[];
+}
+
+export const makeFetchGroupEventAttachmentsQueryKey = ({
+ activeAttachmentsTab,
+ groupId,
+ orgSlug,
+ cursor,
+ environment,
+}: MakeFetchGroupEventAttachmentsQueryKeyOptions): ApiQueryKey => {
+ const query: GroupEventAttachmentsQuery = {};
+ if (environment) {
+ query.environment = environment;
+ }
+
+ if (cursor) {
+ query.cursor = cursor;
+ }
+
+ if (activeAttachmentsTab === 'screenshot') {
+ query.screenshot = '1';
+ } else if (activeAttachmentsTab === 'onlyCrash') {
+ query.types = ['event.minidump', 'event.applecrashreport'];
+ }
+
+ return [`/organizations/${orgSlug}/issues/${groupId}/attachments/`, {query}];
+};
+
+export function useGroupEventAttachments({
+ groupId,
+ activeAttachmentsTab,
+ options,
+}: UseGroupEventAttachmentsOptions) {
+ const organization = useOrganization();
+ const location = useLocation();
+ const {
+ data: attachments = [],
+ isPending,
+ isError,
+ getResponseHeader,
+ refetch,
+ } = useApiQuery(
+ makeFetchGroupEventAttachmentsQueryKey({
+ activeAttachmentsTab,
+ groupId,
+ orgSlug: organization.slug,
+ cursor: location.query.cursor as string | undefined,
+ environment: location.query.environment as string[] | string | undefined,
+ }),
+ {...options, staleTime: 60_000}
+ );
+ return {
+ attachments,
+ isPending,
+ isError,
+ getResponseHeader,
+ refetch,
+ };
+}
diff --git a/static/app/views/issueDetails/groupEventAttachments/useGroupEventAttachmentsDrawer.tsx b/static/app/views/issueDetails/groupEventAttachments/useGroupEventAttachmentsDrawer.tsx
new file mode 100644
index 00000000000000..c47dc9e6384efb
--- /dev/null
+++ b/static/app/views/issueDetails/groupEventAttachments/useGroupEventAttachmentsDrawer.tsx
@@ -0,0 +1,59 @@
+import {useCallback} from 'react';
+
+import useDrawer from 'sentry/components/globalDrawer';
+import type {Group} from 'sentry/types/group';
+import type {Project} from 'sentry/types/project';
+import {useLocation} from 'sentry/utils/useLocation';
+import {useNavigate} from 'sentry/utils/useNavigate';
+import {GroupEventAttachmentsDrawer} from 'sentry/views/issueDetails/groupEventAttachments/groupEventAttachmentsDrawer';
+
+export function useGroupEventAttachmentsDrawer({
+ project,
+ group,
+ openButtonRef,
+}: {
+ group: Group;
+ openButtonRef: React.RefObject;
+ project: Project;
+}) {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const drawer = useDrawer();
+
+ const openAttachmentDrawer = useCallback(() => {
+ drawer.openDrawer(
+ () => ,
+ {
+ ariaLabel: 'attachments drawer',
+ onClose: () => {
+ if (location.query.attachmentFilter || location.query.cursor) {
+ // Remove drawer state from URL
+ navigate({
+ pathname: location.pathname,
+ query: {
+ ...location.query,
+ attachmentFilter: undefined,
+ cursor: undefined,
+ },
+ });
+ }
+ },
+ shouldCloseOnInteractOutside: element => {
+ // Prevent closing the drawer when deleting an attachment
+ if (document.querySelector('[role="dialog"]')?.contains(element)) {
+ return false;
+ }
+
+ // Prevent closing the drawer when clicking the button that opens it
+ const viewAllButton = openButtonRef.current;
+ if (viewAllButton?.contains(element)) {
+ return false;
+ }
+ return true;
+ },
+ }
+ );
+ }, [location, navigate, drawer, project, group, openButtonRef]);
+
+ return {openAttachmentDrawer};
+}
diff --git a/static/app/views/issueDetails/groupEventDetails/groupEventDetails.spec.tsx b/static/app/views/issueDetails/groupEventDetails/groupEventDetails.spec.tsx
index a9f8722abf87e4..32c085c3ee4a2d 100644
--- a/static/app/views/issueDetails/groupEventDetails/groupEventDetails.spec.tsx
+++ b/static/app/views/issueDetails/groupEventDetails/groupEventDetails.spec.tsx
@@ -352,6 +352,22 @@ const mockGroupApis = (
url: `/projects/${organization.slug}/${project.slug}/`,
body: project,
});
+
+ MockApiClient.addMockResponse({
+ url: `/issues/${group.id}/autofix/setup/`,
+ method: 'GET',
+ body: {
+ integration: {
+ ok: true,
+ },
+ genAIConsent: {
+ ok: true,
+ },
+ githubWriteIntegration: {
+ ok: true,
+ },
+ },
+ });
};
describe('groupEventDetails', () => {
diff --git a/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx b/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx
index 8d882bb10dd310..90d51c84b4f431 100644
--- a/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx
+++ b/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx
@@ -393,7 +393,7 @@ export function EventDetailsContent({
{hasStreamlinedUI && (
)}
-
+
{hasStreamlinedUI && (
diff --git a/static/app/views/issueDetails/groupMerged/index.spec.tsx b/static/app/views/issueDetails/groupMerged/index.spec.tsx
index d374b5e3b3f781..85c03fa1c5e80c 100644
--- a/static/app/views/issueDetails/groupMerged/index.spec.tsx
+++ b/static/app/views/issueDetails/groupMerged/index.spec.tsx
@@ -54,10 +54,6 @@ describe('Issues -> Merged View', function () {
project={project}
params={{orgId: 'orgId', groupId: 'groupId'}}
location={router.location}
- routeParams={{}}
- route={{}}
- routes={router.routes}
- router={router}
/>,
{router}
);
@@ -81,10 +77,6 @@ describe('Issues -> Merged View', function () {
project={project}
params={{orgId: 'orgId', groupId: 'groupId'}}
location={router.location}
- routeParams={{}}
- route={{}}
- routes={router.routes}
- router={router}
/>,
{router}
);
diff --git a/static/app/views/issueDetails/groupMerged/index.tsx b/static/app/views/issueDetails/groupMerged/index.tsx
index 0199ea7d68aa4e..90326bc3b3401e 100644
--- a/static/app/views/issueDetails/groupMerged/index.tsx
+++ b/static/app/views/issueDetails/groupMerged/index.tsx
@@ -19,9 +19,9 @@ import withOrganization from 'sentry/utils/withOrganization';
import MergedList from './mergedList';
-type Props = RouteComponentProps<
- {groupId: Group['id']; orgId: Organization['slug']},
- {}
+type Props = Pick<
+ RouteComponentProps<{groupId: Group['id']; orgId: Organization['slug']}, {}>,
+ 'params' | 'location'
> & {
organization: Organization;
project: Project;
diff --git a/static/app/views/issueDetails/groupMerged/mergedIssuesDataSection.tsx b/static/app/views/issueDetails/groupMerged/mergedIssuesDataSection.tsx
new file mode 100644
index 00000000000000..1f92671eaad298
--- /dev/null
+++ b/static/app/views/issueDetails/groupMerged/mergedIssuesDataSection.tsx
@@ -0,0 +1,36 @@
+import {t} from 'sentry/locale';
+import type {Group} from 'sentry/types/group';
+import type {Project} from 'sentry/types/project';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import GroupMergedView from 'sentry/views/issueDetails/groupMerged';
+import {SectionKey} from 'sentry/views/issueDetails/streamline/context';
+import {FoldSection} from 'sentry/views/issueDetails/streamline/foldSection';
+
+interface MergedIssuesDataSectionProps {
+ group: Group;
+ project: Project;
+}
+
+export function MergedIssuesDataSection({project, group}: MergedIssuesDataSectionProps) {
+ const organization = useOrganization();
+ const location = useLocation();
+
+ return (
+
+
+
+ );
+}
diff --git a/static/app/views/issueDetails/groupRelatedIssues/index.spec.tsx b/static/app/views/issueDetails/groupRelatedIssues/index.spec.tsx
index ad5dfa12107256..60c02d6000a1fc 100644
--- a/static/app/views/issueDetails/groupRelatedIssues/index.spec.tsx
+++ b/static/app/views/issueDetails/groupRelatedIssues/index.spec.tsx
@@ -1,5 +1,4 @@
import {OrganizationFixture} from 'sentry-fixture/organization';
-import {RouterFixture} from 'sentry-fixture/routerFixture';
import {render, screen} from 'sentry-test/reactTestingLibrary';
@@ -9,7 +8,6 @@ describe('Related Issues View', function () {
let sameRootIssuesMock: jest.Mock;
let traceIssuesMock: jest.Mock;
let issuesMock: jest.Mock;
- const router = RouterFixture();
const organization = OrganizationFixture();
const orgSlug = organization.slug;
@@ -85,16 +83,7 @@ describe('Related Issues View', function () {
body: issuesData,
});
- render(
-
- );
+ render( );
// Wait for the issues showing up on the table
expect(await screen.findByText(`EARTH-${group1}`)).toBeInTheDocument();
@@ -123,16 +112,7 @@ describe('Related Issues View', function () {
url: orgIssuesEndpoint,
body: issuesData,
});
- render(
-
- );
+ render( );
// Wait for the issues showing up on the table
expect(await screen.findByText(`EARTH-${group1}`)).toBeInTheDocument();
diff --git a/static/app/views/issueDetails/groupRelatedIssues/index.tsx b/static/app/views/issueDetails/groupRelatedIssues/index.tsx
index ea9342f32ede03..6df574ee21d625 100644
--- a/static/app/views/issueDetails/groupRelatedIssues/index.tsx
+++ b/static/app/views/issueDetails/groupRelatedIssues/index.tsx
@@ -16,7 +16,7 @@ type RouteParams = {
groupId: string;
};
-type Props = RouteComponentProps;
+type Props = Pick, 'params'>;
type RelatedIssuesResponse = {
data: number[];
diff --git a/static/app/views/issueDetails/groupSidebar.tsx b/static/app/views/issueDetails/groupSidebar.tsx
index 0c10dfedf4019d..fc885c68c10901 100644
--- a/static/app/views/issueDetails/groupSidebar.tsx
+++ b/static/app/views/issueDetails/groupSidebar.tsx
@@ -64,7 +64,7 @@ function useFetchAllEnvsGroupData(organization: OrganizationSummary, group: Grou
],
{
staleTime: 30000,
- cacheTime: 30000,
+ gcTime: 30000,
}
);
}
@@ -74,7 +74,7 @@ function useFetchCurrentRelease(organization: OrganizationSummary, group: Group)
[`/organizations/${organization.slug}/issues/${group.id}/current-release/`],
{
staleTime: 30000,
- cacheTime: 30000,
+ gcTime: 30000,
}
);
}
diff --git a/static/app/views/issueDetails/groupSimilarIssues/index.spec.tsx b/static/app/views/issueDetails/groupSimilarIssues/index.spec.tsx
index eaa65e77bfea27..5dc7a2cabd2ad9 100644
--- a/static/app/views/issueDetails/groupSimilarIssues/index.spec.tsx
+++ b/static/app/views/issueDetails/groupSimilarIssues/index.spec.tsx
@@ -69,10 +69,6 @@ describe('Issues Similar View', function () {
project={project}
params={{orgId: 'org-slug', groupId: 'group-id'}}
location={router.location}
- router={router}
- routeParams={router.params}
- routes={router.routes}
- route={{}}
/>,
{router}
);
@@ -98,10 +94,6 @@ describe('Issues Similar View', function () {
project={project}
params={{orgId: 'org-slug', groupId: 'group-id'}}
location={router.location}
- router={router}
- routeParams={router.params}
- routes={router.routes}
- route={{}}
/>,
{router}
);
@@ -131,10 +123,6 @@ describe('Issues Similar View', function () {
project={project}
params={{orgId: 'org-slug', groupId: 'group-id'}}
location={router.location}
- router={router}
- routeParams={router.params}
- routes={router.routes}
- route={{}}
/>,
{router}
);
@@ -162,10 +150,6 @@ describe('Issues Similar View', function () {
project={project}
params={{orgId: 'org-slug', groupId: 'group-id'}}
location={router.location}
- router={router}
- routeParams={router.params}
- routes={router.routes}
- route={{}}
/>,
{router}
);
@@ -239,10 +223,6 @@ describe('Issues Similar Embeddings View', function () {
project={project}
params={{orgId: 'org-slug', groupId: 'group-id'}}
location={router.location}
- router={router}
- routeParams={router.params}
- routes={router.routes}
- route={{}}
/>,
{router}
);
@@ -269,10 +249,6 @@ describe('Issues Similar Embeddings View', function () {
project={project}
params={{orgId: 'org-slug', groupId: 'group-id'}}
location={router.location}
- router={router}
- routeParams={router.params}
- routes={router.routes}
- route={{}}
/>,
{router}
);
@@ -302,10 +278,6 @@ describe('Issues Similar Embeddings View', function () {
project={project}
params={{orgId: 'org-slug', groupId: 'group-id'}}
location={router.location}
- router={router}
- routeParams={router.params}
- routes={router.routes}
- route={{}}
/>,
{router}
);
@@ -325,10 +297,6 @@ describe('Issues Similar Embeddings View', function () {
project={project}
params={{orgId: 'org-slug', groupId: 'group-id'}}
location={router.location}
- router={router}
- routeParams={router.params}
- routes={router.routes}
- route={{}}
/>,
{router}
);
@@ -362,10 +330,6 @@ describe('Issues Similar Embeddings View', function () {
project={project}
params={{orgId: 'org-slug', groupId: 'group-id'}}
location={router.location}
- router={router}
- routeParams={router.params}
- routes={router.routes}
- route={{}}
/>,
{router}
);
diff --git a/static/app/views/issueDetails/groupSimilarIssues/similarIssuesDataSection.tsx b/static/app/views/issueDetails/groupSimilarIssues/similarIssuesDataSection.tsx
new file mode 100644
index 00000000000000..e3ad78bc867c7f
--- /dev/null
+++ b/static/app/views/issueDetails/groupSimilarIssues/similarIssuesDataSection.tsx
@@ -0,0 +1,38 @@
+import {t} from 'sentry/locale';
+import type {Group} from 'sentry/types/group';
+import type {Project} from 'sentry/types/project';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import GroupSimilarIssues from 'sentry/views/issueDetails/groupSimilarIssues';
+import {SectionKey} from 'sentry/views/issueDetails/streamline/context';
+import {FoldSection} from 'sentry/views/issueDetails/streamline/foldSection';
+
+interface SimilarIssuesDataSectionProps {
+ group: Group;
+ project: Project;
+}
+
+export function SimilarIssuesDataSection({
+ project,
+ group,
+}: SimilarIssuesDataSectionProps) {
+ const organization = useOrganization();
+ const location = useLocation();
+
+ return (
+
+
+
+ );
+}
diff --git a/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/index.tsx b/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/index.tsx
index ed785843fd4f57..4be8ee4c35955e 100644
--- a/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/index.tsx
+++ b/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/index.tsx
@@ -25,7 +25,7 @@ type RouteParams = {
orgId: string;
};
-type Props = RouteComponentProps & {
+type Props = Pick, 'params' | 'location'> & {
location: Location;
project: Project;
};
diff --git a/static/app/views/issueDetails/header.tsx b/static/app/views/issueDetails/header.tsx
index 8dadfed8df9b2a..15303e328405b5 100644
--- a/static/app/views/issueDetails/header.tsx
+++ b/static/app/views/issueDetails/header.tsx
@@ -38,7 +38,7 @@ import {useIssueDetailsHeader} from 'sentry/views/issueDetails/useIssueDetailsHe
import GroupActions from './actions';
import {Tab} from './types';
-import type {ReprocessingStatus} from './utils';
+import {type ReprocessingStatus, useHasStreamlinedUI} from './utils';
type Props = {
baseUrl: string;
@@ -63,6 +63,7 @@ export function GroupHeaderTabs({
}: GroupHeaderTabsProps) {
const organization = useOrganization();
const location = useLocation();
+ const hasStreamlinedUI = useHasStreamlinedUI();
const {getReplayCountForIssue} = useReplayCountForIssues({
statsPeriod: '90d',
@@ -94,7 +95,73 @@ export function GroupHeaderTabs({
}
}, [group.issueType, organization]);
- return (
+ return hasStreamlinedUI ? (
+
+
+ {t('Details')}
+
+
+ {t('Activity')}
+
+ {group.numComments}
+
+
+
+
+ {t('User Feedback')}
+
+
+ {t('Attachments')}
+
+
+ {t('Tags')}
+
+
+ {group.issueCategory === IssueCategory.ERROR
+ ? t('All Events')
+ : t('Sampled Events')}
+
+
+ {t('Replays')}
+
+
+
+ ) : (
{
+ const organization = OrganizationFixture();
+ const group = GroupFixture();
+ const project = ProjectFixture();
+
+ beforeEach(() => {
+ MockApiClient.clearMockResponses();
+ });
+
+ it('does not show up if there are no attachments', async () => {
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/issues/${group.id}/attachments/`,
+ body: [],
+ });
+
+ const {container} = render( );
+
+ // Wait for requests to finish
+ await act(tick);
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders 1 when there is only 1 attachment', async () => {
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/issues/${group.id}/attachments/`,
+ body: [EventAttachmentFixture()],
+ });
+
+ render( );
+
+ expect(await screen.findByRole('button', {name: '1 Attachment'})).toBeInTheDocument();
+ });
+
+ it('renders 50+ when there is a next page', async () => {
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/issues/${group.id}/attachments/`,
+ body: [EventAttachmentFixture()],
+ headers: {
+ // Assumes there is more than 50 attachments if there is a next page
+ Link: '; rel="previous"; results="false"; cursor="0:0:1", ; rel="next"; results="true"; cursor="0:20:0"',
+ },
+ });
+
+ render( );
+
+ expect(
+ await screen.findByRole('button', {name: '50+ Attachments'})
+ ).toBeInTheDocument();
+ });
+});
diff --git a/static/app/views/issueDetails/streamline/attachmentsBadge.tsx b/static/app/views/issueDetails/streamline/attachmentsBadge.tsx
new file mode 100644
index 00000000000000..0e0bb10255d8aa
--- /dev/null
+++ b/static/app/views/issueDetails/streamline/attachmentsBadge.tsx
@@ -0,0 +1,65 @@
+import {Fragment, useRef} from 'react';
+import styled from '@emotion/styled';
+
+import {Button} from 'sentry/components/button';
+import {IconAttachment} from 'sentry/icons';
+import {t, tn} from 'sentry/locale';
+import type {Group} from 'sentry/types/group';
+import type {Project} from 'sentry/types/project';
+import parseLinkHeader from 'sentry/utils/parseLinkHeader';
+import {keepPreviousData} from 'sentry/utils/queryClient';
+import {Divider} from 'sentry/views/issueDetails/divider';
+import {useGroupEventAttachments} from 'sentry/views/issueDetails/groupEventAttachments/useGroupEventAttachments';
+import {useGroupEventAttachmentsDrawer} from 'sentry/views/issueDetails/groupEventAttachments/useGroupEventAttachmentsDrawer';
+
+export function AttachmentsBadge({group, project}: {group: Group; project: Project}) {
+ const attachments = useGroupEventAttachments({
+ groupId: group.id,
+ activeAttachmentsTab: 'all',
+ options: {placeholderData: keepPreviousData},
+ });
+ const openButtonRef = useRef(null);
+ const {openAttachmentDrawer} = useGroupEventAttachmentsDrawer({
+ project,
+ group,
+ openButtonRef,
+ });
+
+ const attachmentPagination = parseLinkHeader(
+ attachments.getResponseHeader?.('Link') ?? null
+ );
+
+ // Since we reuse whatever page the user was on, we can look at pagination to determine if there are more attachments
+ const hasManyAttachments =
+ attachmentPagination.next?.results || attachmentPagination.previous?.results;
+
+ if (!attachments.attachments.length && !hasManyAttachments) {
+ return null;
+ }
+
+ return (
+
+
+ }
+ onClick={() => {
+ openAttachmentDrawer();
+ }}
+ >
+ {hasManyAttachments
+ ? t('50+ Attachments')
+ : tn('%s Attachment', '%s Attachments', attachments.attachments.length)}
+
+
+ );
+}
+
+const AttachmentButton = styled(Button)`
+ color: ${p => p.theme.gray300};
+ text-decoration: underline;
+ text-decoration-style: dotted;
+`;
diff --git a/static/app/views/issueDetails/streamline/context.tsx b/static/app/views/issueDetails/streamline/context.tsx
index 0d9ad96152220a..787d42708a76f0 100644
--- a/static/app/views/issueDetails/streamline/context.tsx
+++ b/static/app/views/issueDetails/streamline/context.tsx
@@ -55,6 +55,9 @@ export const enum SectionKey {
PROCESSING_ERROR = 'processing-error',
RRWEB = 'rrweb', // Legacy integration prior to replays
+ MERGED_ISSUES = 'merged',
+ SIMILAR_ISSUES = 'similar',
+
REGRESSION_SUMMARY = 'regression-summary',
REGRESSION_BREAKPOINT_CHART = 'regression-breakpoint-chart',
REGRESSION_FLAMEGRAPH = 'regression-flamegraph',
diff --git a/static/app/views/issueDetails/streamline/eventDetails.spec.tsx b/static/app/views/issueDetails/streamline/eventDetails.spec.tsx
index dde7fb45bd493b..6a890bb470064d 100644
--- a/static/app/views/issueDetails/streamline/eventDetails.spec.tsx
+++ b/static/app/views/issueDetails/streamline/eventDetails.spec.tsx
@@ -13,6 +13,7 @@ import ProjectsStore from 'sentry/stores/projectsStore';
import {EventDetails} from 'sentry/views/issueDetails/streamline/eventDetails';
jest.mock('sentry/views/issueDetails/groupEventDetails/groupEventDetailsContent');
+jest.mock('sentry/views/issueDetails/streamline/issueContent');
jest.mock('screenfull', () => ({
enabled: true,
isFullscreen: false,
diff --git a/static/app/views/issueDetails/streamline/eventDetails.tsx b/static/app/views/issueDetails/streamline/eventDetails.tsx
index faa6e64e988efe..1cb9fffbdfa948 100644
--- a/static/app/views/issueDetails/streamline/eventDetails.tsx
+++ b/static/app/views/issueDetails/streamline/eventDetails.tsx
@@ -28,6 +28,7 @@ import {
EventSearch,
useEventQuery,
} from 'sentry/views/issueDetails/streamline/eventSearch';
+import {IssueContent} from 'sentry/views/issueDetails/streamline/issueContent';
import {useFetchEventStats} from 'sentry/views/issueDetails/streamline/useFetchEventStats';
export function EventDetails({
@@ -112,17 +113,22 @@ export function EventDetails({
) : (
graphComponent
)}
-
+
-
+
-
+
+
+
+
+
+
);
}
@@ -153,14 +159,17 @@ const GraphAlert = styled(Alert)`
border: 1px solid ${p => p.theme.translucentBorder};
`;
-const GroupContent = styled('div')<{navHeight?: number}>`
+const ExtraContent = styled('div')`
border: 1px solid ${p => p.theme.translucentBorder};
background: ${p => p.theme.background};
border-radius: ${p => p.theme.borderRadius};
+`;
+
+const GroupContent = styled(ExtraContent)`
position: relative;
`;
-const GroupContentPadding = styled('div')`
+const ContentPadding = styled('div')`
padding: ${space(1)} ${space(1.5)};
`;
diff --git a/static/app/views/issueDetails/streamline/eventGraph.spec.tsx b/static/app/views/issueDetails/streamline/eventGraph.spec.tsx
index c0aed69a888a8d..1eae49eb81f9ef 100644
--- a/static/app/views/issueDetails/streamline/eventGraph.spec.tsx
+++ b/static/app/views/issueDetails/streamline/eventGraph.spec.tsx
@@ -16,6 +16,7 @@ import {EventDetails} from 'sentry/views/issueDetails/streamline/eventDetails';
jest.mock('sentry/utils/useLocation');
jest.mock('sentry/components/events/suspectCommits');
jest.mock('sentry/views/issueDetails/groupEventDetails/groupEventDetailsContent');
+jest.mock('sentry/views/issueDetails/streamline/issueContent');
jest.mock('screenfull', () => ({
enabled: true,
isFullscreen: false,
diff --git a/static/app/views/issueDetails/streamline/header.spec.tsx b/static/app/views/issueDetails/streamline/header.spec.tsx
index 4f4b756215ab57..a8a6761a5fc331 100644
--- a/static/app/views/issueDetails/streamline/header.spec.tsx
+++ b/static/app/views/issueDetails/streamline/header.spec.tsx
@@ -54,6 +54,10 @@ describe('UpdatedGroupHeader', () => {
url: `/organizations/org-slug/releases/${encodeURIComponent(firstRelease.version)}/deploys/`,
body: {},
});
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/issues/${group.id}/attachments/`,
+ body: [],
+ });
});
it('shows all elements of header', async () => {
diff --git a/static/app/views/issueDetails/streamline/header.tsx b/static/app/views/issueDetails/streamline/header.tsx
index afc8818f9344dc..b03a48651d88b8 100644
--- a/static/app/views/issueDetails/streamline/header.tsx
+++ b/static/app/views/issueDetails/streamline/header.tsx
@@ -30,6 +30,7 @@ import GroupActions from 'sentry/views/issueDetails/actions/index';
import {Divider} from 'sentry/views/issueDetails/divider';
import GroupPriority from 'sentry/views/issueDetails/groupPriority';
import {GroupHeaderTabs} from 'sentry/views/issueDetails/header';
+import {AttachmentsBadge} from 'sentry/views/issueDetails/streamline/attachmentsBadge';
import {useIssueDetailsHeader} from 'sentry/views/issueDetails/useIssueDetailsHeader';
import type {ReprocessingStatus} from 'sentry/views/issueDetails/utils';
@@ -61,7 +62,7 @@ export default function StreamlinedGroupHeader({
[`/organizations/${organization.slug}/issues/${group.id}/first-last-release/`],
{
staleTime: 30000,
- cacheTime: 30000,
+ gcTime: 30000,
}
);
@@ -158,6 +159,7 @@ export default function StreamlinedGroupHeader({
)}
+
diff --git a/static/app/views/issueDetails/streamline/issueContent.spec.tsx b/static/app/views/issueDetails/streamline/issueContent.spec.tsx
new file mode 100644
index 00000000000000..69bf68c1d30eab
--- /dev/null
+++ b/static/app/views/issueDetails/streamline/issueContent.spec.tsx
@@ -0,0 +1,60 @@
+import {EventFixture} from 'sentry-fixture/event';
+import {GroupFixture} from 'sentry-fixture/group';
+import {OrganizationFixture} from 'sentry-fixture/organization';
+import {ProjectFixture} from 'sentry-fixture/project';
+
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {IssueContent} from 'sentry/views/issueDetails/streamline/issueContent';
+
+describe('IssueContent', () => {
+ const organization = OrganizationFixture();
+ const project = ProjectFixture({features: ['similarity-view']});
+ const group = GroupFixture();
+ const event = EventFixture();
+
+ let mockMergedIssues;
+ let mockSimilarIssues;
+
+ beforeEach(() => {
+ MockApiClient.clearMockResponses();
+ mockMergedIssues = MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/issues/${group.id}/hashes/?limit=50&query=`,
+ body: [
+ {
+ latestEvent: event,
+ state: 'unlocked',
+ id: '2c4887696f708c476a81ce4e834c4b02',
+ },
+ ],
+ method: 'GET',
+ });
+ mockSimilarIssues = MockApiClient.addMockResponse({
+ url: `/organizations/${organization.id}/issues/${group.id}/similar/?limit=50`,
+ body: [[group, {'exception:stacktrace:pairs': 0.375}]],
+ method: 'GET',
+ });
+ });
+
+ it('displays the extra data sections as closed by default', async function () {
+ render( , {organization});
+
+ const mergedIssues = await screen.findByText('Merged Issues');
+ expect(mergedIssues).toBeInTheDocument();
+ expect(
+ screen.queryByText('Fingerprints included in this issue')
+ ).not.toBeInTheDocument();
+ await userEvent.click(mergedIssues);
+ expect(screen.getByText('Fingerprints included in this issue')).toBeInTheDocument();
+ expect(mockMergedIssues).toHaveBeenCalled();
+
+ const similarIssues = await screen.findByText('Similar Issues');
+ expect(similarIssues).toBeInTheDocument();
+ expect(
+ screen.queryByText('Issues with a similar stack trace')
+ ).not.toBeInTheDocument();
+ await userEvent.click(similarIssues);
+ expect(screen.getByText('Issues with a similar stack trace')).toBeInTheDocument();
+ expect(mockSimilarIssues).toHaveBeenCalled();
+ });
+});
diff --git a/static/app/views/issueDetails/streamline/issueContent.tsx b/static/app/views/issueDetails/streamline/issueContent.tsx
new file mode 100644
index 00000000000000..524479c8f08427
--- /dev/null
+++ b/static/app/views/issueDetails/streamline/issueContent.tsx
@@ -0,0 +1,20 @@
+import {Fragment} from 'react';
+
+import type {Group} from 'sentry/types/group';
+import type {Project} from 'sentry/types/project';
+import {MergedIssuesDataSection} from 'sentry/views/issueDetails/groupMerged/mergedIssuesDataSection';
+import {SimilarIssuesDataSection} from 'sentry/views/issueDetails/groupSimilarIssues/similarIssuesDataSection';
+
+export interface IssueContentProps {
+ group: Group;
+ project: Project;
+}
+
+export function IssueContent({group, project}: IssueContentProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/static/app/views/issueList/addViewPage.tsx b/static/app/views/issueList/addViewPage.tsx
index f119a5f428d8f9..e74adfdc674e1d 100644
--- a/static/app/views/issueList/addViewPage.tsx
+++ b/static/app/views/issueList/addViewPage.tsx
@@ -11,7 +11,9 @@ import {IconMegaphone} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {SavedSearch} from 'sentry/types/group';
+import {trackAnalytics} from 'sentry/utils/analytics';
import {useFeedbackForm} from 'sentry/utils/useFeedbackForm';
+import useOrganization from 'sentry/utils/useOrganization';
import {OverflowEllipsisTextContainer} from 'sentry/views/insights/common/components/textAlign';
import {NewTabContext} from 'sentry/views/issueList/utils/newTabContext';
@@ -23,16 +25,20 @@ type SearchSuggestion = {
interface SearchSuggestionListProps {
searchSuggestions: SearchSuggestion[];
title: React.ReactNode;
+ type: 'recommended' | 'saved_searches';
}
const RECOMMENDED_SEARCHES: SearchSuggestion[] = [
- {label: 'Assigned to Me', query: 'assigned:me'},
- {label: 'My Bookmarks', query: 'bookmarks:me'},
- {label: 'Errors Only', query: 'status:unresolved level:error'},
+ {label: 'Prioritized', query: 'is:unresolved issue.priority:[high, medium]'},
+ {label: 'Assigned to Me', query: 'is:unresolved assigned_or_suggested:me'},
{
- label: 'Unhandled',
- query: 'status:unresolved error.unhandled:True',
+ label: 'For Review',
+ query: 'is:unresolved is:for_review assigned_or_suggested:[me, my_teams, none]',
},
+ {label: 'Request Errors', query: 'is:unresolved http.status_code:5*'},
+ {label: 'High Volume Issues', query: 'is:unresolved timesSeen:>100'},
+ {label: 'Recent Errors', query: 'is:unresolved issue.category:error firstSeen:-24h'},
+ {label: 'Function Regressions', query: 'issue.type:profile_function_regression'},
];
function AddViewPage({savedSearches}: {savedSearches: SavedSearch[]}) {
@@ -68,6 +74,7 @@ function AddViewPage({savedSearches}: {savedSearches: SavedSearch[]}) {
{savedSearches && savedSearches.length !== 0 && (
)}
);
}
-function SearchSuggestionList({title, searchSuggestions}: SearchSuggestionListProps) {
+function SearchSuggestionList({
+ title,
+ searchSuggestions,
+ type,
+}: SearchSuggestionListProps) {
const {onNewViewSaved} = useContext(NewTabContext);
+ const organization = useOrganization();
return (
@@ -94,7 +107,19 @@ function SearchSuggestionList({title, searchSuggestions}: SearchSuggestionListPr
{searchSuggestions.map((suggestion, index) => (
onNewViewSaved?.(suggestion.label, suggestion.query, false)}
+ onClick={() => {
+ onNewViewSaved?.(suggestion.label, suggestion.query, false);
+ const analyticsKey =
+ type === 'recommended'
+ ? 'issue_views.add_view.recommended_view_saved'
+ : 'issue_views.add_view.saved_search_saved';
+ trackAnalytics(analyticsKey, {
+ organization,
+ persisted: false,
+ label: suggestion.label,
+ query: suggestion.query,
+ });
+ }}
>
{/*
Saved search labels have an average length of approximately 16 characters
@@ -111,6 +136,16 @@ function SearchSuggestionList({title, searchSuggestions}: SearchSuggestionListPr
onClick={e => {
e.stopPropagation();
onNewViewSaved?.(suggestion.label, suggestion.query, true);
+ const analyticsKey =
+ type === 'recommended'
+ ? 'issue_views.add_view.recommended_view_saved'
+ : 'issue_views.add_view.saved_search_saved';
+ trackAnalytics(analyticsKey, {
+ organization,
+ persisted: true,
+ label: suggestion.label,
+ query: suggestion.query,
+ });
}}
borderless
>
diff --git a/static/app/views/issueList/customViewsHeader.tsx b/static/app/views/issueList/customViewsHeader.tsx
index 7af156066cd952..3fd9088d83c6ed 100644
--- a/static/app/views/issueList/customViewsHeader.tsx
+++ b/static/app/views/issueList/customViewsHeader.tsx
@@ -15,6 +15,7 @@ import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {InjectedRouter} from 'sentry/types/legacyReactRouter';
import type {Organization} from 'sentry/types/organization';
+import {trackAnalytics} from 'sentry/utils/analytics';
import normalizeUrl from 'sentry/utils/url/normalizeUrl';
import {useLocation} from 'sentry/utils/useLocation';
import {useNavigate} from 'sentry/utils/useNavigate';
@@ -125,15 +126,15 @@ function CustomViewsIssueListHeaderTabsContent({
// TODO(msun): Use the location from useLocation instead of props router in the future
const {cursor: _cursor, page: _page, ...queryParams} = router?.location.query;
+ const {query, sort, viewId, project, environment} = queryParams;
+
const queryParamsWithPageFilters = {
...queryParams,
- project: pageFilters.selection.projects,
- environment: pageFilters.selection.environments,
+ project: project ?? pageFilters.selection.projects,
+ environment: environment ?? pageFilters.selection.environments,
...normalizeDateTimeParams(pageFilters.selection.datetime),
};
- const {query, sort, viewId} = queryParams;
-
const viewsToTabs = views.map(
({id, name, query: viewQuery, querySort: viewQuerySort}, index): Tab => {
const tabId = id ?? `default${index.toString()}`;
@@ -275,6 +276,10 @@ function CustomViewsIssueListHeaderTabsContent({
},
})
);
+ trackAnalytics('issue_views.shared_view_opened', {
+ organization,
+ query,
+ });
return;
}
return;
@@ -336,6 +341,10 @@ function CustomViewsIssueListHeaderTabsContent({
);
setDraggableTabs(updatedTabs);
debounceUpdateViews(updatedTabs);
+ trackAnalytics('issue_views.add_view.custom_query_saved', {
+ organization,
+ query,
+ });
} else {
setNewViewActive(true);
}
diff --git a/static/app/views/issueList/groupSearchViewTabs/draggableTabBar.tsx b/static/app/views/issueList/groupSearchViewTabs/draggableTabBar.tsx
index f60331c09cc2fe..1954c6f8cc8670 100644
--- a/static/app/views/issueList/groupSearchViewTabs/draggableTabBar.tsx
+++ b/static/app/views/issueList/groupSearchViewTabs/draggableTabBar.tsx
@@ -14,9 +14,11 @@ import {TabsContext} from 'sentry/components/tabs';
import {t} from 'sentry/locale';
import type {InjectedRouter} from 'sentry/types/legacyReactRouter';
import {defined} from 'sentry/utils';
+import {trackAnalytics} from 'sentry/utils/analytics';
import normalizeUrl from 'sentry/utils/url/normalizeUrl';
import {useLocation} from 'sentry/utils/useLocation';
import {useNavigate} from 'sentry/utils/useNavigate';
+import useOrganization from 'sentry/utils/useOrganization';
import {DraggableTabMenuButton} from 'sentry/views/issueList/groupSearchViewTabs/draggableTabMenuButton';
import EditableTabTitle from 'sentry/views/issueList/groupSearchViewTabs/editableTabTitle';
import {IssueSortOptions} from 'sentry/views/issueList/utils';
@@ -108,6 +110,7 @@ export function DraggableTabBar({
// TODO: Extract this to a separate component encompassing Tab.Item in the future
const [editingTabKey, setEditingTabKey] = useState(null);
+ const organization = useOrganization();
const navigate = useNavigate();
const location = useLocation();
@@ -126,6 +129,9 @@ export function DraggableTabBar({
.filter(defined);
setTabs(newTabs);
onReorder?.(newTabs);
+ trackAnalytics('issue_views.reordered_views', {
+ organization,
+ });
};
const handleOnSaveChanges = () => {
@@ -143,6 +149,9 @@ export function DraggableTabBar({
});
setTabs(newTabs);
onSave?.(newTabs);
+ trackAnalytics('issue_views.saved_changes', {
+ organization,
+ });
}
};
@@ -166,6 +175,9 @@ export function DraggableTabBar({
},
});
onDiscard?.();
+ trackAnalytics('issue_views.discarded_changes', {
+ organization,
+ });
}
};
@@ -177,6 +189,9 @@ export function DraggableTabBar({
);
setTabs(newTabs);
onTabRenamed?.(newTabs, newLabel);
+ trackAnalytics('issue_views.renamed_view', {
+ organization,
+ });
}
};
@@ -205,6 +220,9 @@ export function DraggableTabBar({
setTabs(newTabs);
tabListState?.setSelectedKey(tempId);
onDuplicate?.(newTabs);
+ trackAnalytics('issue_views.duplicated_view', {
+ organization,
+ });
}
};
@@ -214,6 +232,9 @@ export function DraggableTabBar({
setTabs(newTabs);
tabListState?.setSelectedKey(newTabs[0].key);
onDelete?.(newTabs);
+ trackAnalytics('issue_views.deleted_view', {
+ organization,
+ });
}
};
@@ -232,6 +253,9 @@ export function DraggableTabBar({
setTempTab(undefined);
tabListState?.setSelectedKey(tempId);
onSaveTempView?.(newTabs);
+ trackAnalytics('issue_views.temp_view_saved', {
+ organization,
+ });
}
};
@@ -239,6 +263,9 @@ export function DraggableTabBar({
tabListState?.setSelectedKey(tabs[0].key);
setTempTab(undefined);
onDiscardTempView?.();
+ trackAnalytics('issue_views.temp_view_discarded', {
+ organization,
+ });
};
const handleCreateNewView = () => {
@@ -267,6 +294,9 @@ export function DraggableTabBar({
});
setTabs(newTabs);
tabListState?.setSelectedKey(tempId);
+ trackAnalytics('issue_views.add_view.clicked', {
+ organization,
+ });
}
};
diff --git a/static/app/views/issueList/issueListSetAsDefault.tsx b/static/app/views/issueList/issueListSetAsDefault.tsx
index 82decab60e48f7..490072ab8e8591 100644
--- a/static/app/views/issueList/issueListSetAsDefault.tsx
+++ b/static/app/views/issueList/issueListSetAsDefault.tsx
@@ -40,7 +40,7 @@ function IssueListSetAsDefault({organization, sort, query}: IssueListSetAsDefaul
? pinnedSearch?.id === selectedSavedSearch?.id
: false;
- const {mutate: pinSearch, isLoading: isPinning} = usePinSearch({
+ const {mutate: pinSearch, isPending: isPinning} = usePinSearch({
onSuccess: response => {
const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
browserHistory.replace(
@@ -52,7 +52,7 @@ function IssueListSetAsDefault({organization, sort, query}: IssueListSetAsDefaul
);
},
});
- const {mutate: unpinSearch, isLoading: isUnpinning} = useUnpinSearch({
+ const {mutate: unpinSearch, isPending: isUnpinning} = useUnpinSearch({
onSuccess: () => {
const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
browserHistory.replace(
diff --git a/static/app/views/issueList/issuesDataConsentBanner.tsx b/static/app/views/issueList/issuesDataConsentBanner.tsx
new file mode 100644
index 00000000000000..73c688dd79a71c
--- /dev/null
+++ b/static/app/views/issueList/issuesDataConsentBanner.tsx
@@ -0,0 +1,15 @@
+import {useContext} from 'react';
+
+import HookOrDefault from 'sentry/components/hookOrDefault';
+import {NewTabContext} from 'sentry/views/issueList/utils/newTabContext';
+
+const DataConsentBanner = HookOrDefault({
+ hookName: 'component:data-consent-banner',
+ defaultComponent: null,
+});
+
+export function IssuesDataConsentBanner({source}: {source: string}) {
+ const {newViewActive} = useContext(NewTabContext);
+
+ return newViewActive ? null : ;
+}
diff --git a/static/app/views/issueList/overview.tsx b/static/app/views/issueList/overview.tsx
index 3ae7814336e9f9..269d5cbac75054 100644
--- a/static/app/views/issueList/overview.tsx
+++ b/static/app/views/issueList/overview.tsx
@@ -15,7 +15,6 @@ import {fetchOrgMembers, indexMembersByProject} from 'sentry/actionCreators/memb
import {fetchTagValues, loadOrganizationTags} from 'sentry/actionCreators/tags';
import type {Client} from 'sentry/api';
import ErrorBoundary from 'sentry/components/errorBoundary';
-import HookOrDefault from 'sentry/components/hookOrDefault';
import * as Layout from 'sentry/components/layouts/thirds';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import {extractSelectionParameters} from 'sentry/components/organizations/pageFilters/utils';
@@ -58,6 +57,7 @@ import withPageFilters from 'sentry/utils/withPageFilters';
import withSavedSearches from 'sentry/utils/withSavedSearches';
import CustomViewsIssueListHeader from 'sentry/views/issueList/customViewsHeader';
import IssueListTable from 'sentry/views/issueList/issueListTable';
+import {IssuesDataConsentBanner} from 'sentry/views/issueList/issuesDataConsentBanner';
import SavedIssueSearches from 'sentry/views/issueList/savedIssueSearches';
import type {IssueUpdateData} from 'sentry/views/issueList/types';
import {NewTabContextProvider} from 'sentry/views/issueList/utils/newTabContext';
@@ -144,11 +144,6 @@ type StatEndpointParams = Omit & {
expand?: string | string[];
};
-const DataConsentBanner = HookOrDefault({
- hookName: 'component:data-consent-banner',
- defaultComponent: null,
-});
-
class IssueListOverview extends Component {
state: State = this.getInitialState();
@@ -197,6 +192,11 @@ class IssueListOverview extends Component {
}
this.fetchTags();
this.fetchMemberList();
+ this.props.setRouteAnalyticsParams?.({
+ issue_views_enabled: this.props.organization.features.includes(
+ 'issue-stream-custom-views'
+ ),
+ });
// let custom analytics take control
this.props.setDisableRouteAnalytics?.();
}
@@ -845,6 +845,7 @@ class IssueListOverview extends Component {
num_new_issues: numNewIssues,
num_issues: data.length,
total_issues_count: numHits,
+ issue_views_enabled: organization.features.includes('issue-stream-custom-views'),
sort: this.getSort(),
});
}
@@ -1248,7 +1249,7 @@ class IssueListOverview extends Component {
-
+
{
- if (!data) {
+ refetchInterval: query => {
+ if (!query.state.data) {
return false;
}
- const [monitorData] = data;
+ const [monitorData] = query.state.data;
return hasLastCheckIn(monitorData) ? false : DEFAULT_POLL_INTERVAL_MS;
},
});
diff --git a/static/app/views/monitors/edit.tsx b/static/app/views/monitors/edit.tsx
index 01d7e8a06ee50b..b24302c40b96cb 100644
--- a/static/app/views/monitors/edit.tsx
+++ b/static/app/views/monitors/edit.tsx
@@ -35,7 +35,7 @@ export default function EditMonitor() {
data: monitor,
refetch,
} = useApiQuery(queryKey, {
- cacheTime: 0,
+ gcTime: 0,
staleTime: 0,
});
diff --git a/static/app/views/onboarding/createSampleEventButton.spec.tsx b/static/app/views/onboarding/createSampleEventButton.spec.tsx
index 362ac6add12ba3..27b6c353f498c3 100644
--- a/static/app/views/onboarding/createSampleEventButton.spec.tsx
+++ b/static/app/views/onboarding/createSampleEventButton.spec.tsx
@@ -8,7 +8,6 @@ import {trackAnalytics} from 'sentry/utils/analytics';
import {browserHistory} from 'sentry/utils/browserHistory';
import CreateSampleEventButton from 'sentry/views/onboarding/createSampleEventButton';
-// biome-ignore lint/correctness/useHookAtTopLevel: not a hook
jest.useFakeTimers();
jest.mock('sentry/utils/analytics');
diff --git a/static/app/views/onboarding/setupDocs.spec.tsx b/static/app/views/onboarding/setupDocs.spec.tsx
index 394f4cfe0f89f6..8ba07951a51ed6 100644
--- a/static/app/views/onboarding/setupDocs.spec.tsx
+++ b/static/app/views/onboarding/setupDocs.spec.tsx
@@ -1,7 +1,13 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
import {ProjectKeysFixture} from 'sentry-fixture/projectKeys';
import {initializeOrg} from 'sentry-test/initializeOrg';
-import {render, screen, waitForElementToBeRemoved} from 'sentry-test/reactTestingLibrary';
+import {
+ render,
+ screen,
+ userEvent,
+ waitForElementToBeRemoved,
+} from 'sentry-test/reactTestingLibrary';
import {OnboardingContextProvider} from 'sentry/components/onboarding/onboardingContext';
import {ProductSolution} from 'sentry/components/onboarding/productSelection';
@@ -367,11 +373,11 @@ describe('Onboarding Setup Docs', function () {
router: {
location: {
query: {
- showLoader: 'true',
product: [
ProductSolution.PERFORMANCE_MONITORING,
ProductSolution.SESSION_REPLAY,
],
+ installationMode: 'auto',
},
},
},
@@ -382,6 +388,9 @@ describe('Onboarding Setup Docs', function () {
platform: 'javascript',
},
],
+ organization: OrganizationFixture({
+ features: ['session-replay', 'performance-view'],
+ }),
});
const updateLoaderMock = MockApiClient.addMockResponse({
@@ -398,7 +407,7 @@ describe('Onboarding Setup Docs', function () {
orgSlug: organization.slug,
});
- const {rerender} = render(
+ render(
- {}}
- stepIndex={2}
- router={router}
- route={{}}
- location={router.location}
- genSkipOnboardingLink={() => ''}
- orgId={organization.slug}
- search=""
- recentCreatedProject={project as OnboardingRecentCreatedProject}
- />
-
- );
-
expect(
- await screen.findByRole('heading', {name: 'Configure Browser JavaScript SDK'})
+ await screen.findByRole('radio', {name: 'Loader Script'})
).toBeInTheDocument();
+ await userEvent.click(screen.getByRole('checkbox', {name: 'Session Replay'}));
expect(updateLoaderMock).toHaveBeenCalledTimes(2);
+
expect(updateLoaderMock).toHaveBeenLastCalledWith(
expect.any(String), // The URL
{
data: {
dynamicSdkLoaderOptions: {
hasDebug: false,
- hasPerformance: false,
- hasReplay: true,
+ hasPerformance: true,
+ hasReplay: false,
},
},
error: expect.any(Function),
diff --git a/static/app/views/performance/newTraceDetails/index.tsx b/static/app/views/performance/newTraceDetails/index.tsx
index bde8fbd954408b..24df36c7cfd588 100644
--- a/static/app/views/performance/newTraceDetails/index.tsx
+++ b/static/app/views/performance/newTraceDetails/index.tsx
@@ -134,8 +134,8 @@ export function getTraceViewQueryStatus(
return 'error';
}
- if (traceQueryStatus === 'loading' || traceMetaQueryStatus === 'loading') {
- return 'loading';
+ if (traceQueryStatus === 'pending' || traceMetaQueryStatus === 'pending') {
+ return 'pending';
}
return 'success';
@@ -360,7 +360,7 @@ export function TraceViewWaterfall(props: TraceViewWaterfallProps) {
return;
}
- if (props.status === 'loading') {
+ if (props.status === 'pending') {
const loadingTrace =
loadingTraceRef.current ??
TraceTree.Loading(
diff --git a/static/app/views/performance/newTraceDetails/traceApi/useTraceMeta.spec.tsx b/static/app/views/performance/newTraceDetails/traceApi/useTraceMeta.spec.tsx
index 466f5637687ad4..f562d7c47bae71 100644
--- a/static/app/views/performance/newTraceDetails/traceApi/useTraceMeta.spec.tsx
+++ b/static/app/views/performance/newTraceDetails/traceApi/useTraceMeta.spec.tsx
@@ -80,7 +80,7 @@ describe('useTraceMeta', () => {
data: undefined,
errors: [],
isLoading: true,
- status: 'loading',
+ status: 'pending',
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
@@ -130,7 +130,7 @@ describe('useTraceMeta', () => {
data: undefined,
errors: [],
isLoading: true,
- status: 'loading',
+ status: 'pending',
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
@@ -193,7 +193,7 @@ describe('useTraceMeta', () => {
data: undefined,
errors: [],
isLoading: true,
- status: 'loading',
+ status: 'pending',
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
diff --git a/static/app/views/performance/newTraceDetails/traceApi/useTraceMeta.tsx b/static/app/views/performance/newTraceDetails/traceApi/useTraceMeta.tsx
index 6288e81d8b6281..ba2ab0db088e8f 100644
--- a/static/app/views/performance/newTraceDetails/traceApi/useTraceMeta.tsx
+++ b/static/app/views/performance/newTraceDetails/traceApi/useTraceMeta.tsx
@@ -147,7 +147,7 @@ export function useTraceMeta(replayTraces: ReplayTrace[]): TraceMetaQueryResults
// used to query a demo transaction event from the backend.
const mode = decodeScalar(normalizedParams.demo) ? 'demo' : undefined;
- const {data, isLoading, status} = useQuery<
+ const {data, isPending, status} = useQuery<
{
apiErrors: Error[];
metaResults: TraceMeta;
@@ -170,10 +170,10 @@ export function useTraceMeta(replayTraces: ReplayTrace[]): TraceMetaQueryResults
return {
data: data?.metaResults,
errors: data?.apiErrors || [],
- isLoading,
+ isLoading: isPending,
status,
};
- }, [data, isLoading, status]);
+ }, [data, isPending, status]);
// When projects don't have performance set up, we allow them to view a sample transaction.
// The backend creates the sample transaction, however the trace is created async, so when the
diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/issues/issueSummary.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/issues/issueSummary.tsx
index 321c9aa89ec313..98148369e6a898 100644
--- a/static/app/views/performance/newTraceDetails/traceDrawer/details/issues/issueSummary.tsx
+++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/issues/issueSummary.tsx
@@ -16,8 +16,8 @@ import useOrganization from 'sentry/utils/useOrganization';
interface EventOrGroupHeaderProps {
data: Group;
- event_id: string;
organization: Organization;
+ event_id?: string;
}
/**
@@ -51,7 +51,7 @@ function IssueTitleChildren(props: IssueTitleChildrenProps) {
interface IssueTitleProps {
data: Group;
- event_id: string;
+ event_id?: string;
}
function IssueTitle(props: IssueTitleProps) {
const organization = useOrganization();
@@ -71,7 +71,9 @@ function IssueTitle(props: IssueTitleProps) {
diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/transaction/index.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/transaction/index.tsx
index 9cdd1ceada49b9..7afbc021f1ea24 100644
--- a/static/app/views/performance/newTraceDetails/traceDrawer/details/transaction/index.tsx
+++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/transaction/index.tsx
@@ -217,8 +217,8 @@ export function TransactionNodeDetails({
- {event.projectSlug ? (
-
+ {project ? (
+
) : null}
{project ? : null}
diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/tabs/trace/index.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/tabs/trace/index.tsx
index 4ca02f0e347a5a..0e7ddc0b42897f 100644
--- a/static/app/views/performance/newTraceDetails/traceDrawer/tabs/trace/index.tsx
+++ b/static/app/views/performance/newTraceDetails/traceDrawer/tabs/trace/index.tsx
@@ -8,7 +8,11 @@ import type {
TraceFullDetailed,
TraceSplitResults,
} from 'sentry/utils/performance/quickTrace/types';
-import type {UseApiQueryResult, UseInfiniteQueryResult} from 'sentry/utils/queryClient';
+import type {
+ InfiniteData,
+ UseApiQueryResult,
+ UseInfiniteQueryResult,
+} from 'sentry/utils/queryClient';
import type RequestError from 'sentry/utils/requestError/requestError';
import {useLocation} from 'sentry/utils/useLocation';
import useOrganization from 'sentry/utils/useOrganization';
@@ -27,7 +31,10 @@ type TraceDetailsProps = {
metaResults: TraceMetaQueryResults;
node: TraceTreeNode | null;
rootEventResults: UseApiQueryResult;
- tagsInfiniteQueryResults: UseInfiniteQueryResult, unknown>;
+ tagsInfiniteQueryResults: UseInfiniteQueryResult<
+ InfiniteData, unknown>,
+ Error
+ >;
traceEventView: EventView;
traceType: TraceType;
traces: TraceSplitResults | null;
diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/tabs/trace/tagsSummary.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/tabs/trace/tagsSummary.tsx
index 5a2fa3430019a2..b55f6a4db7fbfa 100644
--- a/static/app/views/performance/newTraceDetails/traceDrawer/tabs/trace/tagsSummary.tsx
+++ b/static/app/views/performance/newTraceDetails/traceDrawer/tabs/trace/tagsSummary.tsx
@@ -15,7 +15,7 @@ import {generateQueryWithTag} from 'sentry/utils';
import type EventView from 'sentry/utils/discover/eventView';
import {formatTagKey} from 'sentry/utils/discover/fields';
import {SavedQueryDatasets} from 'sentry/utils/discover/types';
-import type {UseInfiniteQueryResult} from 'sentry/utils/queryClient';
+import type {InfiniteData, UseInfiniteQueryResult} from 'sentry/utils/queryClient';
import {hasDatasetSelector} from 'sentry/views/dashboards/utils';
import StyledEmptyStateWarning from 'sentry/views/replays/detail/emptyState';
@@ -43,7 +43,10 @@ type TagSummaryProps = {
eventView: EventView;
location: Location;
organization: Organization;
- tagsInfiniteQueryResults: UseInfiniteQueryResult, unknown>;
+ tagsInfiniteQueryResults: UseInfiniteQueryResult<
+ InfiniteData, unknown>,
+ Error
+ >;
totalValues: number | null;
};
diff --git a/static/app/views/performance/traceDetails/traceViewDetailPanel.tsx b/static/app/views/performance/traceDetails/traceViewDetailPanel.tsx
index fb74fe46fb477b..0e5343e6af90b8 100644
--- a/static/app/views/performance/traceDetails/traceViewDetailPanel.tsx
+++ b/static/app/views/performance/traceDetails/traceViewDetailPanel.tsx
@@ -473,7 +473,9 @@ function EventDetails({detail, organization, location}: EventDetailProps) {
/>
) : null}
- {projectSlug && }
+ {project && (
+
+ )}
{project && }
{projectSlug && (
diff --git a/static/app/views/profiling/content.tsx b/static/app/views/profiling/content.tsx
index d7ace2e833d7bd..b9ab22441ba658 100644
--- a/static/app/views/profiling/content.tsx
+++ b/static/app/views/profiling/content.tsx
@@ -274,7 +274,7 @@ function ProfilingContentLegacy({location}: ProfilingContentProps) {
? t('Unable to load profiles')
: null
}
- isLoading={transactions.status === 'loading'}
+ isLoading={transactions.status === 'pending'}
sort={sort}
sortableColumns={new Set(fields)}
/>
@@ -552,7 +552,7 @@ function ProfilingTransactionsContent(props: ProfilingTabContentProps) {
error={
transactions.status === 'error' ? t('Unable to load profiles') : null
}
- isLoading={transactions.status === 'loading'}
+ isLoading={transactions.status === 'pending'}
sort={sort}
sortableColumns={new Set(fields)}
/>
diff --git a/static/app/views/profiling/landing/slowestFunctionsTable.spec.tsx b/static/app/views/profiling/landing/slowestFunctionsTable.spec.tsx
index 2600323334b395..28593d3464090c 100644
--- a/static/app/views/profiling/landing/slowestFunctionsTable.spec.tsx
+++ b/static/app/views/profiling/landing/slowestFunctionsTable.spec.tsx
@@ -165,13 +165,13 @@ describe('SlowestFunctionsTable', () => {
render( );
expect(await screen.findAllByText('slow-package')).toHaveLength(5);
- userEvent.click(screen.getByLabelText('Next'));
+ await userEvent.click(screen.getByLabelText('Next'));
for (let i = 6; i < 10; i++) {
expect(await screen.findByText('slow-function-' + i)).toBeInTheDocument();
}
expect(screen.getByLabelText('Next')).toBeDisabled();
- userEvent.click(screen.getByLabelText('Previous'));
+ await userEvent.click(screen.getByLabelText('Previous'));
for (let i = 0; i < 5; i++) {
expect(await screen.findByText('slow-function-' + i)).toBeInTheDocument();
}
diff --git a/static/app/views/profiling/profileSummary/profilesTable.tsx b/static/app/views/profiling/profileSummary/profilesTable.tsx
index c6fe2bddf4a99c..e316b8bb11a440 100644
--- a/static/app/views/profiling/profileSummary/profilesTable.tsx
+++ b/static/app/views/profiling/profileSummary/profilesTable.tsx
@@ -113,7 +113,7 @@ export function ProfilesTable() {
sort={sort}
data={profiles.status === 'success' ? data : null}
error={profiles.status === 'error' ? t('Unable to load profiles') : null}
- isLoading={profiles.status === 'loading'}
+ isLoading={profiles.status === 'pending'}
{...eventsTableProps}
/>
diff --git a/static/app/views/replays/detail/accessibility/index.tsx b/static/app/views/replays/detail/accessibility/index.tsx
index 0ea9a1d8dda623..39817e80102a33 100644
--- a/static/app/views/replays/detail/accessibility/index.tsx
+++ b/static/app/views/replays/detail/accessibility/index.tsx
@@ -51,7 +51,7 @@ function AccessibilityList() {
const {
dataOffsetMs,
data: accessibilityData,
- isLoading,
+ isPending,
isRefetching,
refetch,
} = useA11yData();
@@ -164,7 +164,7 @@ function AccessibilityList() {
return (
-
+
diff --git a/static/app/views/replays/detail/memoryPanel/index.tsx b/static/app/views/replays/detail/memoryPanel/index.tsx
index 6fc2c0d206c71a..2dcb91c8eaa888 100644
--- a/static/app/views/replays/detail/memoryPanel/index.tsx
+++ b/static/app/views/replays/detail/memoryPanel/index.tsx
@@ -17,7 +17,7 @@ export default function MemoryPanel() {
const memoryFrames = replay?.getMemoryFrames();
- const {data: frameToCount, isLoading: isDomNodeDataLoading} = useCountDomNodes({
+ const {data: frameToCount, isPending: isDomNodeDataLoading} = useCountDomNodes({
replay,
});
const domNodeData = useMemo(
diff --git a/static/app/views/settings/account/accountEmails.tsx b/static/app/views/settings/account/accountEmails.tsx
index deeac12ad2a750..ec358d3f862661 100644
--- a/static/app/views/settings/account/accountEmails.tsx
+++ b/static/app/views/settings/account/accountEmails.tsx
@@ -77,7 +77,7 @@ export function EmailAddresses() {
isPending,
isError,
refetch,
- } = useApiQuery(makeEmailsEndpointKey(), {staleTime: 0, cacheTime: 0});
+ } = useApiQuery(makeEmailsEndpointKey(), {staleTime: 0, gcTime: 0});
if (isPending || isUpdating) {
return (
diff --git a/static/app/views/settings/account/accountSecurity/accountSecurityDetails.tsx b/static/app/views/settings/account/accountSecurity/accountSecurityDetails.tsx
index cb055f0c8d593e..7b22cde470d48d 100644
--- a/static/app/views/settings/account/accountSecurity/accountSecurityDetails.tsx
+++ b/static/app/views/settings/account/accountSecurity/accountSecurityDetails.tsx
@@ -69,7 +69,7 @@ function AccountSecurityDetails({deleteDisabled, onRegenerateBackupCodes}: Props
staleTime: 0,
});
- const {mutate: remove, isLoading: isRemoveLoading} = useMutation({
+ const {mutate: remove, isPending: isRemoveLoading} = useMutation({
mutationFn: ({id, device}: {id: string; device?: AuthenticatorDevice}) => {
// if the device is defined, it means that U2f is being removed
// reason for adding a trailing slash is a result of the endpoint on line 109 needing it but it can't be set there as if deviceId is None, the route will end with '//'
@@ -91,7 +91,7 @@ function AccountSecurityDetails({deleteDisabled, onRegenerateBackupCodes}: Props
},
});
- const {mutate: rename, isLoading: isRenameLoading} = useMutation({
+ const {mutate: rename, isPending: isRenameLoading} = useMutation({
mutationFn: ({
id,
device,
diff --git a/static/app/views/settings/account/accountSecurity/accountSecurityWrapper.tsx b/static/app/views/settings/account/accountSecurity/accountSecurityWrapper.tsx
index 041e501c090c2a..fe3aedf3be2e84 100644
--- a/static/app/views/settings/account/accountSecurity/accountSecurityWrapper.tsx
+++ b/static/app/views/settings/account/accountSecurity/accountSecurityWrapper.tsx
@@ -72,11 +72,11 @@ function AccountSecurityWrapper({children}: Props) {
});
if (
- orgRequest.isLoading ||
+ orgRequest.isPending ||
emailsRequest.isPending ||
authenticatorsRequest.isPending ||
- disableAuthenticatorMutation.isLoading ||
- regenerateBackupCodesMutation.isLoading
+ disableAuthenticatorMutation.isPending ||
+ regenerateBackupCodesMutation.isPending
) {
return ;
}
diff --git a/static/app/views/settings/organizationAuthTokens/index.tsx b/static/app/views/settings/organizationAuthTokens/index.tsx
index aa41afdc36ccd9..c87353f42c5666 100644
--- a/static/app/views/settings/organizationAuthTokens/index.tsx
+++ b/static/app/views/settings/organizationAuthTokens/index.tsx
@@ -110,7 +110,7 @@ export function OrganizationAuthTokensIndex({
}
);
- const {mutate: handleRevokeToken, isLoading: isRevoking} = useMutation<
+ const {mutate: handleRevokeToken, isPending: isRevoking} = useMutation<
{},
RequestError,
RevokeTokenQueryVariables
diff --git a/static/app/views/settings/project/loaderScript.spec.tsx b/static/app/views/settings/project/loaderScript.spec.tsx
index 40607c0b8f6257..ceecf663ba5bc5 100644
--- a/static/app/views/settings/project/loaderScript.spec.tsx
+++ b/static/app/views/settings/project/loaderScript.spec.tsx
@@ -8,7 +8,6 @@ import {
waitForElementToBeRemoved,
} from 'sentry-test/reactTestingLibrary';
-import {t} from 'sentry/locale';
import type {Organization} from 'sentry/types/organization';
import type {Project, ProjectKey} from 'sentry/types/project';
import LoaderScript from 'sentry/views/settings/project/loaderScript';
@@ -172,24 +171,24 @@ describe('LoaderScript', function () {
await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator'));
- expect(screen.getByText(t('Enable Performance Monitoring'))).toBeInTheDocument();
- expect(screen.getByText(t('Enable Session Replay'))).toBeInTheDocument();
- expect(screen.getByText(t('Enable Debug Bundles & Logging'))).toBeInTheDocument();
+ expect(screen.getByText('Enable Performance Monitoring')).toBeInTheDocument();
+ expect(screen.getByText('Enable Session Replay')).toBeInTheDocument();
+ expect(screen.getByText('Enable Debug Bundles & Logging')).toBeInTheDocument();
let performanceCheckbox = screen.getByRole('checkbox', {
- name: t('Enable Performance Monitoring'),
+ name: 'Enable Performance Monitoring',
});
expect(performanceCheckbox).toBeEnabled();
expect(performanceCheckbox).not.toBeChecked();
const replayCheckbox = screen.getByRole('checkbox', {
- name: t('Enable Session Replay'),
+ name: 'Enable Session Replay',
});
expect(replayCheckbox).toBeEnabled();
expect(replayCheckbox).toBeChecked();
const debugCheckbox = screen.getByRole('checkbox', {
- name: t('Enable Debug Bundles & Logging'),
+ name: 'Enable Debug Bundles & Logging',
});
expect(debugCheckbox).toBeEnabled();
expect(debugCheckbox).not.toBeChecked();
@@ -197,12 +196,12 @@ describe('LoaderScript', function () {
// Toggle performance option
await userEvent.click(
screen.getByRole('checkbox', {
- name: t('Enable Performance Monitoring'),
+ name: 'Enable Performance Monitoring',
})
);
performanceCheckbox = await screen.findByRole('checkbox', {
- name: t('Enable Performance Monitoring'),
+ name: 'Enable Performance Monitoring',
checked: true,
});
expect(performanceCheckbox).toBeEnabled();
@@ -285,19 +284,19 @@ describe('LoaderScript', function () {
expect(
screen.getAllByRole('checkbox', {
- name: t('Enable Performance Monitoring'),
+ name: 'Enable Performance Monitoring',
checked: false,
})
).toHaveLength(2);
expect(
screen.getAllByRole('checkbox', {
- name: t('Enable Session Replay'),
+ name: 'Enable Session Replay',
checked: false,
})
).toHaveLength(2);
expect(
screen.getAllByRole('checkbox', {
- name: t('Enable Debug Bundles & Logging'),
+ name: 'Enable Debug Bundles & Logging',
checked: false,
})
).toHaveLength(2);
@@ -305,32 +304,32 @@ describe('LoaderScript', function () {
// Toggle performance option
await userEvent.click(
screen.getAllByRole('checkbox', {
- name: t('Enable Performance Monitoring'),
+ name: 'Enable Performance Monitoring',
})[1]
);
expect(
await screen.findByRole('checkbox', {
- name: t('Enable Performance Monitoring'),
+ name: 'Enable Performance Monitoring',
checked: true,
})
).toBeInTheDocument();
expect(
screen.getByRole('checkbox', {
- name: t('Enable Performance Monitoring'),
+ name: 'Enable Performance Monitoring',
checked: false,
})
).toBeInTheDocument();
expect(
screen.getAllByRole('checkbox', {
- name: t('Enable Session Replay'),
+ name: 'Enable Session Replay',
checked: false,
})
).toHaveLength(2);
expect(
screen.getAllByRole('checkbox', {
- name: t('Enable Debug Bundles & Logging'),
+ name: 'Enable Debug Bundles & Logging',
checked: false,
})
).toHaveLength(2);
diff --git a/static/app/views/settings/project/navigationConfiguration.tsx b/static/app/views/settings/project/navigationConfiguration.tsx
index c3dbd1e025c754..f5a5431b89ce31 100644
--- a/static/app/views/settings/project/navigationConfiguration.tsx
+++ b/static/app/views/settings/project/navigationConfiguration.tsx
@@ -64,6 +64,11 @@ export default function getConfiguration({
title: t('User Feedback'),
show: () => !isSelfHostedErrorsOnly,
},
+ {
+ path: `${pathPrefix}/toolbar/`,
+ title: t('Developer Toolbar'),
+ show: () => !!organization?.features?.includes('dev-toolbar-ui'),
+ },
],
},
{
diff --git a/static/app/views/settings/project/projectFilters/projectFiltersSettings.tsx b/static/app/views/settings/project/projectFilters/projectFiltersSettings.tsx
index 4819f852b37640..77f0e4f52ad2ff 100644
--- a/static/app/views/settings/project/projectFilters/projectFiltersSettings.tsx
+++ b/static/app/views/settings/project/projectFilters/projectFiltersSettings.tsx
@@ -410,7 +410,7 @@ export function ProjectFiltersSettings({project, params, features}: Props) {
refetch,
} = useApiQuery([`/projects/${organization.slug}/${projectSlug}/filters/`], {
staleTime: 0,
- cacheTime: 0,
+ gcTime: 0,
});
const filterList = filterListData ?? [];
diff --git a/static/app/views/settings/project/projectKeys/details/loaderSettings.spec.tsx b/static/app/views/settings/project/projectKeys/details/loaderSettings.spec.tsx
index b88c9e9d3e242d..86dfaf5408b12c 100644
--- a/static/app/views/settings/project/projectKeys/details/loaderSettings.spec.tsx
+++ b/static/app/views/settings/project/projectKeys/details/loaderSettings.spec.tsx
@@ -4,7 +4,6 @@ import {initializeOrg} from 'sentry-test/initializeOrg';
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
import selectEvent from 'sentry-test/selectEvent';
-import {t} from 'sentry/locale';
import type {Organization} from 'sentry/types/organization';
import type {Project, ProjectKey} from 'sentry/types/project';
@@ -71,24 +70,24 @@ describe('Loader Script Settings', function () {
// Panel title
expect(screen.getByText('JavaScript Loader Script')).toBeInTheDocument();
- expect(screen.getByText(t('Enable Performance Monitoring'))).toBeInTheDocument();
- expect(screen.getByText(t('Enable Session Replay'))).toBeInTheDocument();
- expect(screen.getByText(t('Enable Debug Bundles & Logging'))).toBeInTheDocument();
+ expect(screen.getByText('Enable Performance Monitoring')).toBeInTheDocument();
+ expect(screen.getByText('Enable Session Replay')).toBeInTheDocument();
+ expect(screen.getByText('Enable Debug Bundles & Logging')).toBeInTheDocument();
const performanceCheckbox = screen.getByRole('checkbox', {
- name: t('Enable Performance Monitoring'),
+ name: 'Enable Performance Monitoring',
});
expect(performanceCheckbox).toBeEnabled();
expect(performanceCheckbox).not.toBeChecked();
const replayCheckbox = screen.getByRole('checkbox', {
- name: t('Enable Session Replay'),
+ name: 'Enable Session Replay',
});
expect(replayCheckbox).toBeEnabled();
expect(replayCheckbox).toBeChecked();
const debugCheckbox = screen.getByRole('checkbox', {
- name: t('Enable Debug Bundles & Logging'),
+ name: 'Enable Debug Bundles & Logging',
});
expect(debugCheckbox).toBeEnabled();
expect(debugCheckbox).not.toBeChecked();
@@ -127,7 +126,7 @@ describe('Loader Script Settings', function () {
// Toggle performance option
await userEvent.click(
screen.getByRole('checkbox', {
- name: t('Enable Performance Monitoring'),
+ name: 'Enable Performance Monitoring',
})
);
@@ -236,17 +235,17 @@ describe('Loader Script Settings', function () {
);
const performanceCheckbox = screen.getByRole('checkbox', {
- name: t('Enable Performance Monitoring'),
+ name: 'Enable Performance Monitoring',
});
expect(performanceCheckbox).not.toBeChecked();
const replayCheckbox = screen.getByRole('checkbox', {
- name: t('Enable Session Replay'),
+ name: 'Enable Session Replay',
});
expect(replayCheckbox).not.toBeChecked();
const debugCheckbox = screen.getByRole('checkbox', {
- name: t('Enable Debug Bundles & Logging'),
+ name: 'Enable Debug Bundles & Logging',
});
expect(debugCheckbox).toBeChecked();
diff --git a/static/app/views/settings/project/toolbar.tsx b/static/app/views/settings/project/toolbar.tsx
new file mode 100644
index 00000000000000..d9c08b9d7d24b0
--- /dev/null
+++ b/static/app/views/settings/project/toolbar.tsx
@@ -0,0 +1,68 @@
+import Access from 'sentry/components/acl/access';
+import Form from 'sentry/components/forms/form';
+import JsonForm from 'sentry/components/forms/jsonForm';
+import type {JsonFormObject} from 'sentry/components/forms/types';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {t} from 'sentry/locale';
+import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
+import type {Organization} from 'sentry/types/organization';
+import type {Project} from 'sentry/types/project';
+import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
+import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
+
+type RouteParams = {
+ projectId: string;
+};
+type Props = RouteComponentProps & {
+ organization: Organization;
+ project: Project;
+};
+
+function ProjectToolbarSettings({organization, project, params: {projectId}}: Props) {
+ const formGroups: JsonFormObject[] = [
+ {
+ title: 'Settings',
+ fields: [
+ {
+ name: 'sentry:toolbar_allowed_origins',
+ type: 'textarea',
+ rows: 3,
+ autosize: true,
+
+ // additional data/props that is related to rendering of form field rather than data
+ label: t('Allowed Origins'),
+ help: t(
+ 'Domain URLs where the dev toolbar can be installed and access your data. Wildcards (*) are supported. Please separate multiple entries with a newline.'
+ ),
+ getData: data => ({options: data}),
+ },
+ ],
+ },
+ ];
+
+ return (
+
+
+
+
+
+
+ {({hasAccess}) => (
+
+ )}
+
+
+
+ );
+}
+
+export default ProjectToolbarSettings;
diff --git a/static/app/views/settings/projectAlerts/settings.tsx b/static/app/views/settings/projectAlerts/settings.tsx
index 464ccc32494f60..c62181cf7c8491 100644
--- a/static/app/views/settings/projectAlerts/settings.tsx
+++ b/static/app/views/settings/projectAlerts/settings.tsx
@@ -45,7 +45,7 @@ function ProjectAlertSettings({canEditRule, params}: ProjectAlertSettingsProps)
refetch: refetchProject,
} = useApiQuery([`/projects/${organization.slug}/${projectSlug}/`], {
staleTime: 0,
- cacheTime: 0,
+ gcTime: 0,
});
const {
data: pluginList = [],
@@ -54,7 +54,7 @@ function ProjectAlertSettings({canEditRule, params}: ProjectAlertSettingsProps)
refetch: refetchPluginList,
} = useApiQuery(
makeFetchProjectPluginsQueryKey(organization.slug, projectSlug),
- {staleTime: 0, cacheTime: 0}
+ {staleTime: 0, gcTime: 0}
);
if ((!isProjectLoading && !project) || isPluginListError || isProjectError) {
diff --git a/static/app/views/settings/projectIssueGrouping/index.tsx b/static/app/views/settings/projectIssueGrouping/index.tsx
index 262c1ffbde4361..7462d0b098e626 100644
--- a/static/app/views/settings/projectIssueGrouping/index.tsx
+++ b/static/app/views/settings/projectIssueGrouping/index.tsx
@@ -30,7 +30,7 @@ export default function ProjectIssueGrouping({organization, project, params}: Pr
isPending,
isError,
refetch,
- } = useApiQuery([queryKey], {staleTime: 0, cacheTime: 0});
+ } = useApiQuery([queryKey], {staleTime: 0, gcTime: 0});
if (isPending) {
return ;
diff --git a/static/app/views/settings/projectMetrics/customMetricsTable.tsx b/static/app/views/settings/projectMetrics/customMetricsTable.tsx
index d74a5c34f95bf8..2ef837bd00fba0 100644
--- a/static/app/views/settings/projectMetrics/customMetricsTable.tsx
+++ b/static/app/views/settings/projectMetrics/customMetricsTable.tsx
@@ -199,7 +199,7 @@ function MetricsTable({metrics, isLoading, query, project}: MetricsTableProps) {
{
diff --git a/static/app/views/settings/projectMetrics/projectMetricsDetails.tsx b/static/app/views/settings/projectMetrics/projectMetricsDetails.tsx
index 72f5c5f9c9310c..5bf805f9404ae9 100644
--- a/static/app/views/settings/projectMetrics/projectMetricsDetails.tsx
+++ b/static/app/views/settings/projectMetrics/projectMetricsDetails.tsx
@@ -113,7 +113,7 @@ function ProjectMetricsDetails({project, params, organization}: Props) {
handleMetricTagBlockToggle(key)}
blockTarget="tag"
diff --git a/static/app/views/settings/projectSecurityHeaders/csp.tsx b/static/app/views/settings/projectSecurityHeaders/csp.tsx
index 3b0d0955a4976f..827914fbc91e05 100644
--- a/static/app/views/settings/projectSecurityHeaders/csp.tsx
+++ b/static/app/views/settings/projectSecurityHeaders/csp.tsx
@@ -102,7 +102,7 @@ export default function ProjectCspReports() {
initialData={project.options}
apiEndpoint={`/projects/${organization.slug}/${projectId}/`}
>
-
+
{({hasAccess}) => }
diff --git a/static/app/views/settings/projectSourceMaps/projectSourceMaps.tsx b/static/app/views/settings/projectSourceMaps/projectSourceMaps.tsx
index 86ded0361bb5c3..bb767d049a8a1c 100644
--- a/static/app/views/settings/projectSourceMaps/projectSourceMaps.tsx
+++ b/static/app/views/settings/projectSourceMaps/projectSourceMaps.tsx
@@ -27,7 +27,7 @@ import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
import type {Project} from 'sentry/types/project';
import type {SourceMapsArchive} from 'sentry/types/release';
import type {DebugIdBundle} from 'sentry/types/sourceMaps';
-import {useApiQuery} from 'sentry/utils/queryClient';
+import {keepPreviousData, useApiQuery} from 'sentry/utils/queryClient';
import {decodeScalar} from 'sentry/utils/queryString';
import normalizeUrl from 'sentry/utils/url/normalizeUrl';
import useApi from 'sentry/utils/useApi';
@@ -198,7 +198,7 @@ export function ProjectSourceMaps({location, router, project}: Props) {
],
{
staleTime: 0,
- keepPreviousData: true,
+ placeholderData: keepPreviousData,
enabled: !tabDebugIdBundlesActive,
}
);
@@ -217,7 +217,7 @@ export function ProjectSourceMaps({location, router, project}: Props) {
],
{
staleTime: 0,
- keepPreviousData: true,
+ placeholderData: keepPreviousData,
enabled: tabDebugIdBundlesActive,
}
);
diff --git a/static/app/views/settings/projectSourceMaps/projectSourceMapsArtifacts.tsx b/static/app/views/settings/projectSourceMaps/projectSourceMapsArtifacts.tsx
index 00a21eed60e65d..f7428807e52df2 100644
--- a/static/app/views/settings/projectSourceMaps/projectSourceMapsArtifacts.tsx
+++ b/static/app/views/settings/projectSourceMaps/projectSourceMapsArtifacts.tsx
@@ -19,7 +19,7 @@ import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
import type {Project} from 'sentry/types/project';
import type {Artifact} from 'sentry/types/release';
import type {DebugIdBundleArtifact} from 'sentry/types/sourceMaps';
-import {useApiQuery} from 'sentry/utils/queryClient';
+import {keepPreviousData, useApiQuery} from 'sentry/utils/queryClient';
import {decodeScalar} from 'sentry/utils/queryString';
import normalizeUrl from 'sentry/utils/url/normalizeUrl';
import useApi from 'sentry/utils/useApi';
@@ -148,7 +148,7 @@ export function ProjectSourceMapsArtifacts({params, location, router, project}:
],
{
staleTime: 0,
- keepPreviousData: true,
+ placeholderData: keepPreviousData,
enabled: !tabDebugIdBundlesActive,
}
);
@@ -166,7 +166,7 @@ export function ProjectSourceMapsArtifacts({params, location, router, project}:
],
{
staleTime: 0,
- keepPreviousData: true,
+ placeholderData: keepPreviousData,
enabled: tabDebugIdBundlesActive,
}
);
diff --git a/static/app/views/settings/projectTags/index.tsx b/static/app/views/settings/projectTags/index.tsx
index 98e041d4594cd8..193e2830a66d69 100644
--- a/static/app/views/settings/projectTags/index.tsx
+++ b/static/app/views/settings/projectTags/index.tsx
@@ -46,7 +46,7 @@ function ProjectTags(props: Props) {
const {projects} = useProjects();
const {projectId} = props.params;
- const project = projects.find(p => p.id === projectId);
+ const project = projects.find(p => p.slug === projectId);
const api = useApi();
const queryClient = useQueryClient();
diff --git a/static/app/views/settings/projectUserFeedback/index.tsx b/static/app/views/settings/projectUserFeedback/index.tsx
index 590216c060cbef..260ff067b0c293 100644
--- a/static/app/views/settings/projectUserFeedback/index.tsx
+++ b/static/app/views/settings/projectUserFeedback/index.tsx
@@ -60,7 +60,7 @@ function ProjectUserFeedback({organization, project, params: {projectId}}: Props
title={t('User Feedback')}
action={
-
+
{t('Read the Docs')}
diff --git a/tests/acceptance/test_organization_sentry_app_detailed_view.py b/tests/acceptance/test_organization_sentry_app_detailed_view.py
index 17886272691f51..a7f1f9712ea464 100644
--- a/tests/acceptance/test_organization_sentry_app_detailed_view.py
+++ b/tests/acceptance/test_organization_sentry_app_detailed_view.py
@@ -1,7 +1,7 @@
from fixtures.page_objects.organization_integration_settings import (
OrganizationSentryAppDetailViewPage,
)
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.testutils.cases import AcceptanceTestCase
from sentry.testutils.silo import no_silo_test
diff --git a/tests/acceptance/test_project_servicehooks.py b/tests/acceptance/test_project_servicehooks.py
index 19ceca945b641f..e7ecd169c3d6cf 100644
--- a/tests/acceptance/test_project_servicehooks.py
+++ b/tests/acceptance/test_project_servicehooks.py
@@ -1,4 +1,4 @@
-from sentry.models.servicehook import ServiceHook
+from sentry.sentry_apps.models.servicehook import ServiceHook
from sentry.testutils.cases import AcceptanceTestCase
from sentry.testutils.silo import no_silo_test
diff --git a/tests/apidocs/endpoints/integration_platform/test_sentry_app_external_issue_details.py b/tests/apidocs/endpoints/integration_platform/test_sentry_app_external_issue_details.py
index eb5170a2783f2d..a6dd6a71d58624 100644
--- a/tests/apidocs/endpoints/integration_platform/test_sentry_app_external_issue_details.py
+++ b/tests/apidocs/endpoints/integration_platform/test_sentry_app_external_issue_details.py
@@ -2,7 +2,7 @@
from django.urls import reverse
from fixtures.apidocs_test_case import APIDocsTestCase
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.silo.base import SiloMode
from sentry.testutils.silo import assume_test_silo_mode
diff --git a/tests/apidocs/endpoints/integration_platform/test_sentry_app_external_issues.py b/tests/apidocs/endpoints/integration_platform/test_sentry_app_external_issues.py
index a5c112980756b9..24ea0557ff8af7 100644
--- a/tests/apidocs/endpoints/integration_platform/test_sentry_app_external_issues.py
+++ b/tests/apidocs/endpoints/integration_platform/test_sentry_app_external_issues.py
@@ -2,7 +2,7 @@
from django.urls import reverse
from fixtures.apidocs_test_case import APIDocsTestCase
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.silo.base import SiloMode
from sentry.testutils.silo import assume_test_silo_mode
diff --git a/tests/apidocs/endpoints/integration_platform/test_sentry_app_installations.py b/tests/apidocs/endpoints/integration_platform/test_sentry_app_installations.py
index 141a12b9c6f524..3d2197ac3968b6 100644
--- a/tests/apidocs/endpoints/integration_platform/test_sentry_app_installations.py
+++ b/tests/apidocs/endpoints/integration_platform/test_sentry_app_installations.py
@@ -2,7 +2,7 @@
from django.urls import reverse
from fixtures.apidocs_test_case import APIDocsTestCase
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.testutils.silo import control_silo_test
diff --git a/tests/js/fixtures/autofixRootCauseData.ts b/tests/js/fixtures/autofixRootCauseData.ts
index ef6ca280d6bcb3..05d4e0c2d109fe 100644
--- a/tests/js/fixtures/autofixRootCauseData.ts
+++ b/tests/js/fixtures/autofixRootCauseData.ts
@@ -10,8 +10,6 @@ export function AutofixRootCauseData(
title: 'This is the title of a root cause.',
description: 'This is the description of a root cause.',
reproduction: 'This is the reproduction of a root cause.',
- actionability: 0.8,
- likelihood: 0.9,
code_context: [AutofixRootCauseCodeContext()],
...params,
};
diff --git a/tests/js/fixtures/autofixStep.ts b/tests/js/fixtures/autofixStep.ts
index fe7189819d0705..a589ad16bf2d6b 100644
--- a/tests/js/fixtures/autofixStep.ts
+++ b/tests/js/fixtures/autofixStep.ts
@@ -12,6 +12,7 @@ export function AutofixStepFixture(params: Partial = {}): AutofixSt
title: 'I am processing',
status: 'PROCESSING',
progress: [],
+ insights: [],
...params,
} as AutofixDefaultStep;
}
diff --git a/tests/js/sentry-test/onboarding/renderWithOnboardingLayout.tsx b/tests/js/sentry-test/onboarding/renderWithOnboardingLayout.tsx
index c22d74cae858e6..7ef07e1070e69e 100644
--- a/tests/js/sentry-test/onboarding/renderWithOnboardingLayout.tsx
+++ b/tests/js/sentry-test/onboarding/renderWithOnboardingLayout.tsx
@@ -1,3 +1,5 @@
+import {ProjectKeysFixture} from 'sentry-fixture/projectKeys';
+
import {initializeOrg} from 'sentry-test/initializeOrg';
import {render} from 'sentry-test/reactTestingLibrary';
@@ -30,7 +32,7 @@ export function renderWithOnboardingLayout<
selectedOptions = {},
} = options;
- const {organization, router} = initializeOrg({
+ const {organization, project, router} = initializeOrg({
router: {
location: {
query: selectedOptions,
@@ -38,15 +40,23 @@ export function renderWithOnboardingLayout<
},
});
+ const projectKey = 'test-project-key-id';
+
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/sdks/`,
body: releaseRegistry,
});
+ MockApiClient.addMockResponse({
+ url: `/projects/${organization.slug}/${project.slug}/keys/${projectKey}/`,
+ method: 'PUT',
+ body: [ProjectKeysFixture()[0]],
+ });
+
render(
,
{
organization,
diff --git a/tests/sentry/api/endpoints/test_organization_details.py b/tests/sentry/api/endpoints/test_organization_details.py
index 793197b133212f..5b3e56d228a61b 100644
--- a/tests/sentry/api/endpoints/test_organization_details.py
+++ b/tests/sentry/api/endpoints/test_organization_details.py
@@ -20,6 +20,7 @@
from sentry.api.endpoints.organization_details import ERR_NO_2FA, ERR_SSO_ENABLED
from sentry.api.serializers.models.organization import TrustedRelaySerializer
from sentry.api.utils import generate_region_url
+from sentry.auth.authenticators.recovery_code import RecoveryCodeInterface
from sentry.auth.authenticators.totp import TotpInterface
from sentry.constants import RESERVED_ORGANIZATION_SLUGS, ObjectStatus
from sentry.models.auditlogentry import AuditLogEntry
@@ -1117,6 +1118,12 @@ def test_cannot_enforce_2fa_without_2fa_enabled(self):
assert not self.owner.has_2fa()
self.assert_cannot_enable_org_2fa(self.organization, self.owner, 400, ERR_NO_2FA)
+ # having recovery codes only (backup method) should not allow to enforce org 2FA
+ with assume_test_silo_mode_of(Authenticator):
+ RecoveryCodeInterface().enroll(self.owner)
+ assert not self.owner.has_2fa()
+ self.assert_cannot_enable_org_2fa(self.organization, self.owner, 400, ERR_NO_2FA)
+
def test_cannot_enforce_2fa_with_sso_enabled(self):
with assume_test_silo_mode_of(AuthProvider):
auth_provider = AuthProvider.objects.create(
diff --git a/tests/sentry/api/endpoints/test_organization_historical_anomalies.py b/tests/sentry/api/endpoints/test_organization_historical_anomalies.py
new file mode 100644
index 00000000000000..b4a66cbfcb599b
--- /dev/null
+++ b/tests/sentry/api/endpoints/test_organization_historical_anomalies.py
@@ -0,0 +1,54 @@
+import orjson
+
+from sentry.incidents.models.alert_rule import (
+ AlertRuleSeasonality,
+ AlertRuleSensitivity,
+ AlertRuleThresholdType,
+)
+from sentry.seer.anomaly_detection.types import AnomalyDetectionConfig
+from sentry.seer.anomaly_detection.utils import translate_direction
+from sentry.testutils.cases import APITestCase
+from sentry.testutils.helpers.features import with_feature
+from sentry.testutils.outbox import outbox_runner
+
+
+class OrganizationEventsAnomaliesEndpointTest(APITestCase):
+ endpoint = "sentry-api-0-organization-events-anomalies"
+
+ method = "post"
+
+ @with_feature("organizations:anomaly-detection-alerts")
+ @with_feature("organizations:incidents")
+ def test_simple(self):
+ self.create_team(organization=self.organization, members=[self.user])
+ self.login_as(self.user)
+ config = AnomalyDetectionConfig(
+ time_period=60,
+ sensitivity=AlertRuleSensitivity.LOW,
+ direction=translate_direction(AlertRuleThresholdType.ABOVE.value),
+ expected_seasonality=AlertRuleSeasonality.AUTO,
+ )
+ data = {
+ "project_id": 1,
+ "config": config,
+ "current_data": [[1, {"count": 0.077881957}], [2, {"count": 0.075652768}]],
+ "historical_data": [[169, {"count": 0.048480431}], [170, {"count": 0.047910238}]],
+ }
+
+ with outbox_runner():
+ resp = self.get_success_response(
+ self.organization.slug, status_code=200, raw_data=orjson.dumps(data)
+ )
+
+ assert resp.data == [
+ {
+ "anomaly": {"anomaly_score": -0.38810767243044786, "anomaly_type": "none"},
+ "timestamp": 169,
+ "value": 0.048480431,
+ },
+ {
+ "anomaly": {"anomaly_score": -0.3890542800124323, "anomaly_type": "none"},
+ "timestamp": 170,
+ "value": 0.047910238,
+ },
+ ]
diff --git a/tests/sentry/api/endpoints/test_organization_member_details.py b/tests/sentry/api/endpoints/test_organization_member_details.py
index c24936ea4cb8eb..098404e640e3ea 100644
--- a/tests/sentry/api/endpoints/test_organization_member_details.py
+++ b/tests/sentry/api/endpoints/test_organization_member_details.py
@@ -144,6 +144,33 @@ def test_lists_team_roles(self):
class UpdateOrganizationMemberTest(OrganizationMemberTestBase, HybridCloudTestMixin):
method = "put"
+ def setUp(self):
+ super().setUp()
+
+ self.curr_user = self.create_user("member@example.com")
+ self.curr_member = self.create_member(
+ organization=self.organization, role="member", user=self.curr_user
+ )
+ self.other_user = self.create_user("other@example.com")
+ self.other_member = self.create_member(
+ organization=self.organization, role="member", user=self.other_user
+ )
+
+ self.curr_invite = self.create_member(
+ organization=self.organization,
+ user=None,
+ email="member_invite@example.com",
+ role="member",
+ inviter_id=self.curr_user.id,
+ )
+ self.other_invite = self.create_member(
+ organization=self.organization,
+ user=None,
+ email="other_invite@example.com",
+ role="member",
+ inviter_id=self.other_user.id,
+ )
+
def test_invalid_id(self):
self.get_error_response(self.organization.slug, "trash", reinvite=1, status_code=404)
@@ -156,6 +183,87 @@ def test_reinvite_pending_member(self, mock_send_invite_email):
self.get_success_response(self.organization.slug, member_om.id, reinvite=1)
mock_send_invite_email.assert_called_once_with()
+ @patch("sentry.models.OrganizationMember.send_invite_email")
+ @with_feature("organizations:members-invite-teammates")
+ def test_member_reinvite_pending_member(self, mock_send_invite_email):
+ self.login_as(self.curr_user)
+
+ self.organization.flags.disable_member_invite = True
+ self.organization.save()
+ response = self.get_error_response(
+ self.organization.slug, self.curr_invite.id, reinvite=1, status_code=403
+ )
+ assert response.data.get("detail") == "You do not have permission to perform this action."
+ response = self.get_error_response(
+ self.organization.slug, self.other_invite.id, reinvite=1, status_code=403
+ )
+ assert response.data.get("detail") == "You do not have permission to perform this action."
+ assert not mock_send_invite_email.mock_calls
+
+ self.organization.flags.disable_member_invite = False
+ self.organization.save()
+ self.get_success_response(self.organization.slug, self.curr_invite.id, reinvite=1)
+ mock_send_invite_email.assert_called_once_with()
+ mock_send_invite_email.reset_mock()
+ response = self.get_error_response(
+ self.organization.slug, self.other_invite.id, reinvite=1, status_code=403
+ )
+ assert response.data.get("detail") == "You cannot modify invitations sent by someone else."
+ assert not mock_send_invite_email.mock_calls
+
+ @patch("sentry.models.OrganizationMember.send_invite_email")
+ @with_feature("organizations:members-invite-teammates")
+ def test_member_can_only_reinvite(self, mock_send_invite_email):
+ foo = self.create_team(organization=self.organization, name="Team Foo")
+ self.login_as(self.curr_user)
+
+ self.organization.flags.disable_member_invite = True
+ self.organization.save()
+ response = self.get_error_response(
+ self.organization.slug,
+ self.curr_invite.id,
+ reinvite=1,
+ teams=[foo.slug],
+ status_code=403,
+ )
+ assert response.data.get("detail") == "You do not have permission to perform this action."
+ assert not mock_send_invite_email.mock_calls
+
+ self.organization.flags.disable_member_invite = False
+ self.organization.save()
+ response = self.get_error_response(
+ self.organization.slug,
+ self.curr_invite.id,
+ reinvite=1,
+ teams=[foo.slug],
+ status_code=403,
+ )
+ assert (
+ response.data.get("detail")
+ == "You can only reinvite members; you cannot modify other member details."
+ )
+ assert not mock_send_invite_email.mock_calls
+
+ @patch("sentry.models.OrganizationMember.send_invite_email")
+ @with_feature("organizations:members-invite-teammates")
+ def test_member_cannot_reinvite_members(self, mock_send_invite_email):
+ self.login_as(self.curr_user)
+
+ self.organization.flags.disable_member_invite = True
+ self.organization.save()
+ response = self.get_error_response(
+ self.organization.slug, self.other_member.id, reinvite=1, status_code=403
+ )
+ assert response.data.get("detail") == "You do not have permission to perform this action."
+
+ self.organization.flags.disable_member_invite = False
+ self.organization.save()
+ response = self.get_error_response(
+ self.organization.slug, self.other_member.id, reinvite=1, status_code=403
+ )
+ assert response.data.get("detail") == "You do not have permission to perform this action."
+ assert not mock_send_invite_email.mock_calls
+
@patch("sentry.ratelimits.for_organization_member_invite")
@patch("sentry.models.OrganizationMember.send_invite_email")
def test_rate_limited(self, mock_send_invite_email, mock_rate_limit):
@@ -170,6 +278,7 @@ def test_rate_limited(self, mock_send_invite_email, mock_rate_limit):
assert not mock_send_invite_email.mock_calls
@patch("sentry.models.OrganizationMember.send_invite_email")
+ @with_feature("organizations:members-invite-teammates")
def test_member_cannot_regenerate_pending_invite(self, mock_send_invite_email):
member_om = self.create_member(
organization=self.organization, email="foo@example.com", role="member"
@@ -187,6 +296,26 @@ def test_member_cannot_regenerate_pending_invite(self, mock_send_invite_email):
assert old_invite == member_om.get_invite_link()
assert not mock_send_invite_email.mock_calls
+ self.login_as(self.curr_user)
+
+ self.organization.flags.disable_member_invite = True
+ self.organization.save()
+ response = self.get_error_response(
+ self.organization.slug, self.curr_invite.id, reinvite=1, regenerate=1, status_code=403
+ )
+ assert response.data.get("detail") == "You do not have permission to perform this action."
+
+ self.organization.flags.disable_member_invite = False
+ self.organization.save()
+ response = self.get_error_response(
+ self.organization.slug,
+ self.curr_invite.id,
+ reinvite=1,
+ regenerate=1,
+ status_code=400,
+ )
+ assert response.data.get("detail") == "You are missing the member:admin scope."
+
@patch("sentry.models.OrganizationMember.send_invite_email")
def test_admin_can_regenerate_pending_invite(self, mock_send_invite_email):
member_om = self.create_member(
diff --git a/tests/sentry/api/endpoints/test_organization_sentry_app_installations.py b/tests/sentry/api/endpoints/test_organization_sentry_app_installations.py
index 0ecc7168cb17a8..78f18885024344 100644
--- a/tests/sentry/api/endpoints/test_organization_sentry_app_installations.py
+++ b/tests/sentry/api/endpoints/test_organization_sentry_app_installations.py
@@ -2,8 +2,8 @@
from sentry.constants import SentryAppStatus
from sentry.integrations.models.integration_feature import Feature
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
from sentry.sentry_apps.logic import SentryAppUpdater
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.slug.errors import DEFAULT_SLUG_ERROR_MESSAGE
from sentry.testutils.cases import APITestCase
from sentry.testutils.helpers.features import with_feature
diff --git a/tests/sentry/api/endpoints/test_organization_sentry_apps.py b/tests/sentry/api/endpoints/test_organization_sentry_apps.py
index b258fe40dca262..1310343aa60a95 100644
--- a/tests/sentry/api/endpoints/test_organization_sentry_apps.py
+++ b/tests/sentry/api/endpoints/test_organization_sentry_apps.py
@@ -1,7 +1,7 @@
import orjson
from django.urls import reverse
-from sentry.models.integrations.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app import SentryApp
from sentry.testutils.cases import APITestCase
from sentry.testutils.silo import control_silo_test
diff --git a/tests/sentry/api/endpoints/test_organization_spans_fields.py b/tests/sentry/api/endpoints/test_organization_spans_fields.py
index 2694c0ccba1a6f..19cbccde568fc2 100644
--- a/tests/sentry/api/endpoints/test_organization_spans_fields.py
+++ b/tests/sentry/api/endpoints/test_organization_spans_fields.py
@@ -1,6 +1,5 @@
from uuid import uuid4
-import pytest
from django.urls import reverse
from sentry.testutils.cases import APITestCase, BaseSpansTestCase
@@ -35,8 +34,6 @@ def test_no_project(self):
assert response.status_code == 200, response.data
assert response.data == []
- # shellmayr: https://github.com/getsentry/sentry/actions/runs/10918616180/job/30304486687
- @pytest.mark.xfail(reason="test is failing in CI")
def test_tags_list(self):
for tag in ["foo", "bar", "baz"]:
self.store_segment(
@@ -89,6 +86,39 @@ def do_request(self, query=None, features=None, **kwargs):
**kwargs,
)
+ def test_tags_list(self):
+ for tag in ["foo", "bar", "baz"]:
+ self.store_segment(
+ self.project.id,
+ uuid4().hex,
+ uuid4().hex,
+ span_id=uuid4().hex[:15],
+ organization_id=self.organization.id,
+ parent_span_id=None,
+ timestamp=before_now(days=0, minutes=10).replace(microsecond=0),
+ transaction="foo",
+ duration=100,
+ exclusive_time=100,
+ tags={tag: tag},
+ is_eap=self.is_eap,
+ )
+
+ for features in [
+ None, # use the default features
+ ["organizations:performance-trace-explorer"],
+ ]:
+ response = self.do_request(features=features)
+ assert response.status_code == 200, response.data
+ assert {"key": "bar", "name": "Bar"} in response.data
+ assert {"key": "foo", "name": "Foo"} in response.data
+ assert {"key": "baz", "name": "Baz"} in response.data
+ # Skipping for now
+ # assert response.data == [
+ # {"key": "span.description", "name": "Span.Description"},
+ # {"key": "transaction", "name": "Transaction"},
+ # {"key": "project", "name": "Project"},
+ # ]
+
class OrganizationSpansTagKeyValuesEndpointTest(BaseSpansTestCase, APITestCase):
view = "sentry-api-0-organization-spans-fields-values"
diff --git a/tests/sentry/api/endpoints/test_project_details.py b/tests/sentry/api/endpoints/test_project_details.py
index ece879fb1ed4e0..269d637ab80203 100644
--- a/tests/sentry/api/endpoints/test_project_details.py
+++ b/tests/sentry/api/endpoints/test_project_details.py
@@ -619,6 +619,7 @@ def test_options(self):
"sentry:token_header": "*",
"sentry:verify_ssl": False,
"sentry:replay_hydration_error_issues": True,
+ "sentry:toolbar_allowed_origins": "*.sentry.io\nexample.net \nnugettrends.com",
"sentry:replay_rage_click_issues": True,
"sentry:feedback_user_report_notifications": True,
"sentry:feedback_ai_spam_detection": True,
@@ -736,6 +737,11 @@ def test_options(self):
).exists()
assert project.get_option("feedback:branding") == "0"
assert project.get_option("sentry:replay_hydration_error_issues") is True
+ assert project.get_option("sentry:toolbar_allowed_origins") == [
+ "*.sentry.io",
+ "example.net",
+ "nugettrends.com",
+ ]
assert project.get_option("sentry:replay_rage_click_issues") is True
assert project.get_option("sentry:feedback_user_report_notifications") is True
assert project.get_option("sentry:feedback_ai_spam_detection") is True
diff --git a/tests/sentry/api/endpoints/test_project_index.py b/tests/sentry/api/endpoints/test_project_index.py
index 03791ff9738773..095984e0192f1b 100644
--- a/tests/sentry/api/endpoints/test_project_index.py
+++ b/tests/sentry/api/endpoints/test_project_index.py
@@ -9,9 +9,9 @@
schedule_hybrid_cloud_foreign_key_jobs_control,
)
from sentry.models.apitoken import ApiToken
-from sentry.models.integrations.sentry_app_installation_token import SentryAppInstallationToken
from sentry.models.project import Project
from sentry.models.projectkey import ProjectKey
+from sentry.sentry_apps.models.sentry_app_installation_token import SentryAppInstallationToken
from sentry.silo.base import SiloMode
from sentry.silo.safety import unguarded_write
from sentry.testutils.cases import APITestCase
diff --git a/tests/sentry/api/endpoints/test_project_servicehook_details.py b/tests/sentry/api/endpoints/test_project_servicehook_details.py
index cab2dc54ad6a2f..3dcdbe7f9a9309 100644
--- a/tests/sentry/api/endpoints/test_project_servicehook_details.py
+++ b/tests/sentry/api/endpoints/test_project_servicehook_details.py
@@ -1,4 +1,4 @@
-from sentry.models.servicehook import ServiceHook
+from sentry.sentry_apps.models.servicehook import ServiceHook
from sentry.testutils.cases import APITestCase
diff --git a/tests/sentry/api/endpoints/test_project_servicehook_stats.py b/tests/sentry/api/endpoints/test_project_servicehook_stats.py
index 10c7a77ecdaf12..5265d6c750020b 100644
--- a/tests/sentry/api/endpoints/test_project_servicehook_stats.py
+++ b/tests/sentry/api/endpoints/test_project_servicehook_stats.py
@@ -1,5 +1,5 @@
from sentry import tsdb
-from sentry.models.servicehook import ServiceHook
+from sentry.sentry_apps.models.servicehook import ServiceHook
from sentry.testutils.cases import APITestCase
from sentry.tsdb.base import TSDBModel
diff --git a/tests/sentry/api/endpoints/test_project_servicehooks.py b/tests/sentry/api/endpoints/test_project_servicehooks.py
index 77632a4682b921..29c75a6f376bc2 100644
--- a/tests/sentry/api/endpoints/test_project_servicehooks.py
+++ b/tests/sentry/api/endpoints/test_project_servicehooks.py
@@ -1,4 +1,4 @@
-from sentry.models.servicehook import ServiceHook, ServiceHookProject
+from sentry.sentry_apps.models.servicehook import ServiceHook, ServiceHookProject
from sentry.testutils.cases import APITestCase
diff --git a/tests/sentry/api/endpoints/test_release_deploys.py b/tests/sentry/api/endpoints/test_release_deploys.py
index 2c68f9fde30e8e..ace431e849bfb1 100644
--- a/tests/sentry/api/endpoints/test_release_deploys.py
+++ b/tests/sentry/api/endpoints/test_release_deploys.py
@@ -238,6 +238,60 @@ def test_with_project_slugs(self):
)
assert rpe.last_deploy_id == deploy.id
+ def test_with_multiple_projects(self):
+ """
+ Test that when a release is associated with multiple projects the user is still able to create
+ a deploy to only one project
+ """
+ project_bar = self.create_project(organization=self.org, name="bar")
+ release = Release.objects.create(organization_id=self.org.id, version="1", total_deploys=0)
+ release.add_project(self.project)
+ release.add_project(project_bar)
+
+ environment = Environment.objects.create(organization_id=self.org.id, name="production")
+
+ url = reverse(
+ "sentry-api-0-organization-release-deploys",
+ kwargs={
+ "organization_id_or_slug": self.org.slug,
+ "version": release.version,
+ },
+ )
+
+ response = self.client.post(
+ url,
+ data={
+ "name": "foo_bar",
+ "environment": "production",
+ "url": "https://www.example.com",
+ "projects": [project_bar.slug],
+ },
+ )
+ assert response.status_code == 201, response.content
+ assert response.data["name"] == "foo_bar"
+ assert response.data["url"] == "https://www.example.com"
+ assert response.data["environment"] == "production"
+
+ deploy = Deploy.objects.get(id=response.data["id"])
+
+ assert deploy.name == "foo_bar"
+ assert deploy.environment_id == environment.id
+ assert deploy.url == "https://www.example.com"
+ assert deploy.release == release
+
+ release = Release.objects.get(id=release.id)
+ assert release.total_deploys == 1
+ assert release.last_deploy_id == deploy.id
+
+ assert not ReleaseProjectEnvironment.objects.filter(
+ project=self.project, release=release, environment=environment
+ ).exists()
+
+ rpe = ReleaseProjectEnvironment.objects.get(
+ project=project_bar, release=release, environment=environment
+ )
+ assert rpe.last_deploy_id == deploy.id
+
def test_with_project_ids(self):
project_bar = self.create_project(organization=self.org, name="bar")
release = Release.objects.create(organization_id=self.org.id, version="1", total_deploys=0)
diff --git a/tests/sentry/api/endpoints/test_sentry_app_components.py b/tests/sentry/api/endpoints/test_sentry_app_components.py
index 5388f6c079be16..d866ddf7d3f4eb 100644
--- a/tests/sentry/api/endpoints/test_sentry_app_components.py
+++ b/tests/sentry/api/endpoints/test_sentry_app_components.py
@@ -3,7 +3,7 @@
from sentry.api.serializers.base import serialize
from sentry.constants import SentryAppInstallationStatus
from sentry.coreapi import APIError
-from sentry.models.integrations.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app import SentryApp
from sentry.testutils.cases import APITestCase
from sentry.testutils.silo import control_silo_test
diff --git a/tests/sentry/api/endpoints/test_sentry_app_details.py b/tests/sentry/api/endpoints/test_sentry_app_details.py
index 14bbb62136d70a..f12855ac8844a3 100644
--- a/tests/sentry/api/endpoints/test_sentry_app_details.py
+++ b/tests/sentry/api/endpoints/test_sentry_app_details.py
@@ -8,10 +8,10 @@
)
from sentry.constants import SentryAppStatus
from sentry.models.auditlogentry import AuditLogEntry
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
from sentry.models.organizationmember import OrganizationMember
-from sentry.models.servicehook import ServiceHook
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.servicehook import ServiceHook
from sentry.silo.base import SiloMode
from sentry.testutils.cases import APITestCase
from sentry.testutils.helpers import with_feature
diff --git a/tests/sentry/api/endpoints/test_sentry_app_rotate_secret.py b/tests/sentry/api/endpoints/test_sentry_app_rotate_secret.py
index e90e4b407428d6..cc3960af6c58ee 100644
--- a/tests/sentry/api/endpoints/test_sentry_app_rotate_secret.py
+++ b/tests/sentry/api/endpoints/test_sentry_app_rotate_secret.py
@@ -1,7 +1,7 @@
from django.urls import reverse
from sentry.models.apiapplication import ApiApplication
-from sentry.models.integrations.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app import SentryApp
from sentry.testutils.cases import APITestCase
from sentry.testutils.silo import control_silo_test
diff --git a/tests/sentry/api/endpoints/test_sentry_apps.py b/tests/sentry/api/endpoints/test_sentry_apps.py
index 31ecdbef022aba..ffe2760e16bca2 100644
--- a/tests/sentry/api/endpoints/test_sentry_apps.py
+++ b/tests/sentry/api/endpoints/test_sentry_apps.py
@@ -14,11 +14,11 @@
from sentry import deletions
from sentry.constants import SentryAppStatus
from sentry.models.apitoken import ApiToken
-from sentry.models.integrations.sentry_app import MASKED_VALUE, SentryApp
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
-from sentry.models.integrations.sentry_app_installation_token import SentryAppInstallationToken
from sentry.models.organization import Organization
from sentry.models.organizationmember import OrganizationMember
+from sentry.sentry_apps.models.sentry_app import MASKED_VALUE, SentryApp
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation_token import SentryAppInstallationToken
from sentry.silo.base import SiloMode
from sentry.testutils.cases import APITestCase
from sentry.testutils.helpers import Feature, with_feature
diff --git a/tests/sentry/api/endpoints/test_sentry_internal_app_tokens.py b/tests/sentry/api/endpoints/test_sentry_internal_app_tokens.py
index 75de54aa6f5798..20f2971b316248 100644
--- a/tests/sentry/api/endpoints/test_sentry_internal_app_tokens.py
+++ b/tests/sentry/api/endpoints/test_sentry_internal_app_tokens.py
@@ -2,7 +2,7 @@
from rest_framework import status
from sentry.models.apitoken import ApiToken
-from sentry.models.integrations.sentry_app import MASKED_VALUE
+from sentry.sentry_apps.models.sentry_app import MASKED_VALUE
from sentry.testutils.cases import APITestCase
from sentry.testutils.helpers.options import override_options
from sentry.testutils.silo import control_silo_test
diff --git a/tests/sentry/api/serializers/test_project.py b/tests/sentry/api/serializers/test_project.py
index 63a59ad448e549..7be5cfa13735d3 100644
--- a/tests/sentry/api/serializers/test_project.py
+++ b/tests/sentry/api/serializers/test_project.py
@@ -792,6 +792,17 @@ def test_replay_hydration_error_flag(self):
result = serialize(self.project, self.user, DetailedProjectSerializer())
assert result["options"]["sentry:replay_hydration_error_issues"] is False
+ def test_toolbar_allowed_origins(self):
+ # Does not allow trailing newline or extra whitespace.
+ # Default is empty:
+ result = serialize(self.project, self.user, DetailedProjectSerializer())
+ assert result["options"]["sentry:toolbar_allowed_origins"] == ""
+
+ origins = ["*.sentry.io", "example.net", "nugettrends.com"]
+ self.project.update_option("sentry:toolbar_allowed_origins", origins)
+ result = serialize(self.project, self.user, DetailedProjectSerializer())
+ assert result["options"]["sentry:toolbar_allowed_origins"].split("\n") == origins
+
class BulkFetchProjectLatestReleases(TestCase):
@cached_property
diff --git a/tests/sentry/backup/snapshots/ReleaseTests/test_at_24_9_0.pysnap b/tests/sentry/backup/snapshots/ReleaseTests/test_at_24_9_0.pysnap
new file mode 100644
index 00000000000000..e547d833606a87
--- /dev/null
+++ b/tests/sentry/backup/snapshots/ReleaseTests/test_at_24_9_0.pysnap
@@ -0,0 +1,1853 @@
+---
+created: '2024-09-11T23:30:31.124877+00:00'
+creator: sentry
+source: tests/sentry/backup/test_releases.py
+---
+- fields:
+ key: bar
+ last_updated: '2024-09-11T23:30:30.612Z'
+ last_updated_by: unknown
+ value: '"b"'
+ model: sentry.controloption
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:28.983Z'
+ date_updated: '2024-09-11T23:30:28.983Z'
+ external_id: slack:test-org
+ metadata: {}
+ name: Slack for test-org
+ provider: slack
+ status: 0
+ model: sentry.integration
+ pk: 1
+- fields:
+ key: foo
+ last_updated: '2024-09-11T23:30:30.603Z'
+ last_updated_by: unknown
+ value: '"a"'
+ model: sentry.option
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:26.664Z'
+ default_role: member
+ flags: '1'
+ is_test: false
+ name: test-org
+ slug: test-org
+ status: 0
+ model: sentry.organization
+ pk: 4554684875538432
+- fields:
+ date_added: '2024-09-11T23:30:29.494Z'
+ default_role: member
+ flags: '1'
+ is_test: false
+ name: Sure Redfish
+ slug: sure-redfish
+ status: 0
+ model: sentry.organization
+ pk: 4554684875735041
+- fields:
+ config:
+ hello: hello
+ date_added: '2024-09-11T23:30:28.988Z'
+ date_updated: '2024-09-11T23:30:28.988Z'
+ default_auth_id: null
+ grace_period_end: null
+ integration: 1
+ organization_id: 4554684875538432
+ status: 0
+ model: sentry.organizationintegration
+ pk: 1
+- fields:
+ key: sentry:account-rate-limit
+ organization: 4554684875538432
+ value: 0
+ model: sentry.organizationoption
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:28.150Z'
+ date_updated: '2024-09-11T23:30:28.150Z'
+ name: template-test-org
+ organization: 4554684875538432
+ model: sentry.projecttemplate
+ pk: 1
+- fields:
+ key: mail:subject_prefix
+ project_template: 1
+ value: '"[test-org]"'
+ model: sentry.projecttemplateoption
+ pk: 1
+- fields:
+ first_seen: null
+ is_internal: true
+ last_seen: null
+ public_key: UFMIbCNL-RafL9O2T7FtrqgiBVIRnxuyL6WVP_o1nV8
+ relay_id: d930d381-e4b0-45df-a019-75f66bdb7f5c
+ model: sentry.relay
+ pk: 1
+- fields:
+ first_seen: '2024-09-11T23:30:30.600Z'
+ last_seen: '2024-09-11T23:30:30.600Z'
+ public_key: UFMIbCNL-RafL9O2T7FtrqgiBVIRnxuyL6WVP_o1nV8
+ relay_id: d930d381-e4b0-45df-a019-75f66bdb7f5c
+ version: 0.0.1
+ model: sentry.relayusage
+ pk: 1
+- fields:
+ config: {}
+ date_added: '2024-09-11T23:30:29.406Z'
+ external_id: https://git.example.com:1234
+ integration_id: 1
+ languages: '[]'
+ name: getsentry/getsentry
+ organization_id: 4554684875538432
+ provider: integrations:github
+ status: 0
+ url: https://github.com/getsentry/getsentry
+ model: sentry.repository
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:27.919Z'
+ idp_provisioned: false
+ name: test_team_in_test-org
+ organization: 4554684875538432
+ slug: test_team_in_test-org
+ status: 0
+ model: sentry.team
+ pk: 4554684875603968
+- fields:
+ avatar_type: 0
+ avatar_url: null
+ date_joined: '2024-09-11T23:30:26.371Z'
+ email: superadmin
+ flags: '0'
+ is_active: true
+ is_managed: false
+ is_password_expired: false
+ is_sentry_app: null
+ is_staff: true
+ is_superuser: true
+ is_unclaimed: false
+ last_active: '2024-09-11T23:30:26.371Z'
+ last_login: null
+ last_password_change: '2024-09-11T23:30:26.371Z'
+ name: ''
+ password: md5$xunomatyWyipoG3WtuJ4GF$d83a6f5f016fc4be0a07ee1e1487f613
+ session_nonce: null
+ username: superadmin
+ model: sentry.user
+ pk: 1
+- fields:
+ avatar_type: 0
+ avatar_url: null
+ date_joined: '2024-09-11T23:30:26.497Z'
+ email: owner
+ flags: '0'
+ is_active: true
+ is_managed: false
+ is_password_expired: false
+ is_sentry_app: null
+ is_staff: false
+ is_superuser: false
+ is_unclaimed: false
+ last_active: '2024-09-11T23:30:26.497Z'
+ last_login: null
+ last_password_change: '2024-09-11T23:30:26.497Z'
+ name: ''
+ password: md5$EkMaxx33mAT4pX6ipV12Mu$ce6b3f67cbaf9919cb737490d7a020e0
+ session_nonce: null
+ username: owner
+ model: sentry.user
+ pk: 2
+- fields:
+ avatar_type: 0
+ avatar_url: null
+ date_joined: '2024-09-11T23:30:26.522Z'
+ email: member
+ flags: '0'
+ is_active: true
+ is_managed: false
+ is_password_expired: false
+ is_sentry_app: null
+ is_staff: false
+ is_superuser: false
+ is_unclaimed: false
+ last_active: '2024-09-11T23:30:26.522Z'
+ last_login: null
+ last_password_change: '2024-09-11T23:30:26.522Z'
+ name: ''
+ password: md5$ZIgrhNLcYuB1c7WQ6tnOrd$d4246849cbfc22dd2cc2f1ee4ed2c45c
+ session_nonce: null
+ username: member
+ model: sentry.user
+ pk: 3
+- fields:
+ avatar_type: 0
+ avatar_url: null
+ date_joined: '2024-09-11T23:30:26.549Z'
+ email: added-by-superadmin-not-in-org
+ flags: '0'
+ is_active: true
+ is_managed: false
+ is_password_expired: false
+ is_sentry_app: null
+ is_staff: false
+ is_superuser: false
+ is_unclaimed: false
+ last_active: '2024-09-11T23:30:26.549Z'
+ last_login: null
+ last_password_change: '2024-09-11T23:30:26.549Z'
+ name: ''
+ password: md5$Q08MkBQf1o4nYU1VAB3Cfj$86c145edeabe9ba5052856a2d92c1aea
+ session_nonce: null
+ username: added-by-superadmin-not-in-org
+ model: sentry.user
+ pk: 4
+- fields:
+ avatar_type: 0
+ avatar_url: null
+ date_joined: '2024-09-11T23:30:26.580Z'
+ email: added-by-org-owner
+ flags: '0'
+ is_active: true
+ is_managed: false
+ is_password_expired: false
+ is_sentry_app: null
+ is_staff: false
+ is_superuser: false
+ is_unclaimed: false
+ last_active: '2024-09-11T23:30:26.580Z'
+ last_login: null
+ last_password_change: '2024-09-11T23:30:26.580Z'
+ name: ''
+ password: md5$Batk5JBtUpp9sUYrB59EXA$2cb31cc8bf4273928149b3c3708cb012
+ session_nonce: null
+ username: added-by-org-owner
+ model: sentry.user
+ pk: 5
+- fields:
+ avatar_type: 0
+ avatar_url: null
+ date_joined: '2024-09-11T23:30:26.621Z'
+ email: added-by-org-member
+ flags: '0'
+ is_active: true
+ is_managed: false
+ is_password_expired: false
+ is_sentry_app: null
+ is_staff: false
+ is_superuser: false
+ is_unclaimed: false
+ last_active: '2024-09-11T23:30:26.621Z'
+ last_login: null
+ last_password_change: '2024-09-11T23:30:26.621Z'
+ name: ''
+ password: md5$ibqYfbGzsypoB4jFP0CLKG$4bbe1c085d9baee4125f5d19bcf39311
+ session_nonce: null
+ username: added-by-org-member
+ model: sentry.user
+ pk: 6
+- fields:
+ avatar_type: 0
+ avatar_url: null
+ date_joined: '2024-09-11T23:30:29.265Z'
+ email: admin@localhost
+ flags: '0'
+ is_active: true
+ is_managed: false
+ is_password_expired: false
+ is_sentry_app: null
+ is_staff: true
+ is_superuser: true
+ is_unclaimed: false
+ last_active: '2024-09-11T23:30:29.265Z'
+ last_login: null
+ last_password_change: '2024-09-11T23:30:29.265Z'
+ name: ''
+ password: md5$RWX8mXCsTvz2C2xkDAj0fz$a8b7a9772f60be74b92ee7dd09668cd0
+ session_nonce: null
+ username: admin@localhost
+ model: sentry.user
+ pk: 7
+- fields:
+ avatar_type: 0
+ avatar_url: null
+ date_joined: '2024-09-11T23:30:29.481Z'
+ email: 25549474e90f40f9832f08ede8fda417@example.com
+ flags: '0'
+ is_active: true
+ is_managed: false
+ is_password_expired: false
+ is_sentry_app: null
+ is_staff: false
+ is_superuser: false
+ is_unclaimed: false
+ last_active: '2024-09-11T23:30:29.481Z'
+ last_login: null
+ last_password_change: '2024-09-11T23:30:29.481Z'
+ name: ''
+ password: md5$EjG8u38HlyCMIKTPJCtXk1$fe2d89846ce28808a1f3aeaebb8b6721
+ session_nonce: null
+ username: 25549474e90f40f9832f08ede8fda417@example.com
+ model: sentry.user
+ pk: 8
+- fields:
+ avatar_type: 0
+ avatar_url: null
+ date_joined: '2024-09-11T23:30:29.609Z'
+ email: ''
+ flags: '0'
+ is_active: true
+ is_managed: false
+ is_password_expired: false
+ is_sentry_app: true
+ is_staff: false
+ is_superuser: false
+ is_unclaimed: false
+ last_active: '2024-09-11T23:30:29.609Z'
+ last_login: null
+ last_password_change: null
+ name: ''
+ password: ''
+ session_nonce: null
+ username: test-app-447cc2d0-bb11-486a-9e8d-de4026f5bdd5
+ model: sentry.user
+ pk: 9
+- fields:
+ avatar_type: 0
+ avatar_url: null
+ date_joined: '2024-09-11T23:30:30.330Z'
+ email: 4288351af3464cdeabf1ad6677d86e8c@example.com
+ flags: '0'
+ is_active: true
+ is_managed: false
+ is_password_expired: false
+ is_sentry_app: null
+ is_staff: false
+ is_superuser: false
+ is_unclaimed: false
+ last_active: '2024-09-11T23:30:30.330Z'
+ last_login: null
+ last_password_change: '2024-09-11T23:30:30.330Z'
+ name: ''
+ password: md5$LeI2Nbrh0KaMN36fQC8Cn3$9b86f10405c287d3aed97de77307c014
+ session_nonce: null
+ username: 4288351af3464cdeabf1ad6677d86e8c@example.com
+ model: sentry.user
+ pk: 10
+- fields:
+ country_code: null
+ first_seen: '2012-04-05T03:29:45.000Z'
+ ip_address: 127.0.0.2
+ last_seen: '2012-04-05T03:29:45.000Z'
+ region_code: null
+ user: 1
+ model: sentry.userip
+ pk: 1
+- fields:
+ country_code: null
+ first_seen: '2012-04-05T03:29:45.000Z'
+ ip_address: 127.0.0.2
+ last_seen: '2012-04-05T03:29:45.000Z'
+ region_code: null
+ user: 2
+ model: sentry.userip
+ pk: 2
+- fields:
+ country_code: null
+ first_seen: '2012-04-05T03:29:45.000Z'
+ ip_address: 127.0.0.2
+ last_seen: '2012-04-05T03:29:45.000Z'
+ region_code: null
+ user: 3
+ model: sentry.userip
+ pk: 3
+- fields:
+ country_code: null
+ first_seen: '2012-04-05T03:29:45.000Z'
+ ip_address: 127.0.0.2
+ last_seen: '2012-04-05T03:29:45.000Z'
+ region_code: null
+ user: 4
+ model: sentry.userip
+ pk: 4
+- fields:
+ country_code: null
+ first_seen: '2012-04-05T03:29:45.000Z'
+ ip_address: 127.0.0.2
+ last_seen: '2012-04-05T03:29:45.000Z'
+ region_code: null
+ user: 5
+ model: sentry.userip
+ pk: 5
+- fields:
+ country_code: null
+ first_seen: '2012-04-05T03:29:45.000Z'
+ ip_address: 127.0.0.2
+ last_seen: '2012-04-05T03:29:45.000Z'
+ region_code: null
+ user: 6
+ model: sentry.userip
+ pk: 6
+- fields:
+ permission: users.admin
+ user: 1
+ model: sentry.userpermission
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:26.433Z'
+ date_updated: '2024-09-11T23:30:26.433Z'
+ name: test-admin-role
+ permissions: '[]'
+ model: sentry.userrole
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:26.440Z'
+ date_updated: '2024-09-11T23:30:26.440Z'
+ role: 1
+ user: 1
+ model: sentry.userroleuser
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:29.478Z'
+ date_updated: '2024-09-11T23:30:29.478Z'
+ organization: 4554684875538432
+ query_id: 5183
+ type: 1
+ model: workflow_engine.datasource
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:29.479Z'
+ date_updated: '2024-09-11T23:30:29.479Z'
+ name: Sure Gator
+ organization: 4554684875538432
+ owner_team: null
+ owner_user_id: null
+ model: workflow_engine.detector
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:29.475Z'
+ date_updated: '2024-09-11T23:30:29.475Z'
+ name: Merry Glowworm
+ organization: 4554684875538432
+ model: workflow_engine.workflow
+ pk: 1
+- fields:
+ data: {}
+ date_added: '2024-09-11T23:30:29.477Z'
+ date_updated: '2024-09-11T23:30:29.477Z'
+ required: false
+ type: ''
+ workflow: 1
+ model: workflow_engine.workflowaction
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:29.392Z'
+ is_global: false
+ name: Saved query for test-org
+ organization: 4554684875538432
+ owner_id: 2
+ query: saved query for test-org
+ sort: date
+ type: 0
+ visibility: organization
+ model: sentry.savedsearch
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:29.387Z'
+ last_seen: '2024-09-11T23:30:29.387Z'
+ organization: 4554684875538432
+ query: some query for test-org
+ query_hash: 7c69362cd42207b83f80087bc15ebccb
+ type: 0
+ user_id: 2
+ model: sentry.recentsearch
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:28.156Z'
+ first_event: null
+ flags: '10'
+ forced_color: null
+ name: project-test-org
+ organization: 4554684875538432
+ platform: null
+ public: false
+ slug: project-test-org
+ status: 0
+ template: null
+ model: sentry.project
+ pk: 4554684875669504
+- fields:
+ date_added: '2024-09-11T23:30:29.108Z'
+ first_event: null
+ flags: '10'
+ forced_color: null
+ name: other-project-test-org
+ organization: 4554684875538432
+ platform: null
+ public: false
+ slug: other-project-test-org
+ status: 0
+ template: null
+ model: sentry.project
+ pk: 4554684875735040
+- fields:
+ date_added: '2024-09-11T23:30:29.662Z'
+ first_event: null
+ flags: '10'
+ forced_color: null
+ name: Exotic Airedale
+ organization: 4554684875538432
+ platform: null
+ public: false
+ slug: exotic-airedale
+ status: 0
+ template: null
+ model: sentry.project
+ pk: 4554684875735042
+- fields:
+ date_added: '2024-09-11T23:30:30.364Z'
+ first_event: null
+ flags: '10'
+ forced_color: null
+ name: Arriving Gar
+ organization: 4554684875538432
+ platform: null
+ public: false
+ slug: arriving-gar
+ status: 0
+ template: null
+ model: sentry.project
+ pk: 4554684875800576
+- fields:
+ created_by: 2
+ date_added: '2024-09-11T23:30:28.765Z'
+ date_deactivated: null
+ date_last_used: null
+ name: token 1 for test-org
+ organization_id: 4554684875538432
+ project_last_used_id: 4554684875669504
+ scope_list: '[''org:ci'']'
+ token_hashed: ABCDEFtest-org
+ token_last_characters: xyz1
+ model: sentry.orgauthtoken
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:26.845Z'
+ email: null
+ flags: '0'
+ has_global_access: true
+ invite_status: 0
+ inviter_id: null
+ organization: 4554684875538432
+ role: owner
+ token: null
+ token_expires_at: null
+ type: 50
+ user_email: owner
+ user_id: 2
+ user_is_active: true
+ model: sentry.organizationmember
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:27.134Z'
+ email: null
+ flags: '0'
+ has_global_access: true
+ invite_status: 0
+ inviter_id: null
+ organization: 4554684875538432
+ role: member
+ token: null
+ token_expires_at: null
+ type: 50
+ user_email: member
+ user_id: 3
+ user_is_active: true
+ model: sentry.organizationmember
+ pk: 2
+- fields:
+ date_added: '2024-09-11T23:30:27.283Z'
+ email: invited-by-superadmin-not-in-org@example.com
+ flags: '0'
+ has_global_access: true
+ invite_status: 1
+ inviter_id: 1
+ organization: 4554684875538432
+ role: member
+ token: null
+ token_expires_at: null
+ type: 50
+ user_email: null
+ user_id: null
+ user_is_active: true
+ model: sentry.organizationmember
+ pk: 3
+- fields:
+ date_added: '2024-09-11T23:30:27.340Z'
+ email: invited-by-org-owner@example.com
+ flags: '0'
+ has_global_access: true
+ invite_status: 1
+ inviter_id: 2
+ organization: 4554684875538432
+ role: member
+ token: null
+ token_expires_at: null
+ type: 50
+ user_email: null
+ user_id: null
+ user_is_active: true
+ model: sentry.organizationmember
+ pk: 4
+- fields:
+ date_added: '2024-09-11T23:30:27.430Z'
+ email: invited-by-org-member@example.com
+ flags: '0'
+ has_global_access: true
+ invite_status: 1
+ inviter_id: 3
+ organization: 4554684875538432
+ role: member
+ token: null
+ token_expires_at: null
+ type: 50
+ user_email: null
+ user_id: null
+ user_is_active: true
+ model: sentry.organizationmember
+ pk: 5
+- fields:
+ date_added: '2024-09-11T23:30:27.502Z'
+ email: null
+ flags: '0'
+ has_global_access: true
+ invite_status: 0
+ inviter_id: 1
+ organization: 4554684875538432
+ role: member
+ token: null
+ token_expires_at: null
+ type: 50
+ user_email: added-by-superadmin-not-in-org
+ user_id: 4
+ user_is_active: true
+ model: sentry.organizationmember
+ pk: 6
+- fields:
+ date_added: '2024-09-11T23:30:27.635Z'
+ email: null
+ flags: '0'
+ has_global_access: true
+ invite_status: 0
+ inviter_id: 2
+ organization: 4554684875538432
+ role: member
+ token: null
+ token_expires_at: null
+ type: 50
+ user_email: added-by-org-owner
+ user_id: 5
+ user_is_active: true
+ model: sentry.organizationmember
+ pk: 7
+- fields:
+ date_added: '2024-09-11T23:30:27.772Z'
+ email: null
+ flags: '0'
+ has_global_access: true
+ invite_status: 0
+ inviter_id: 3
+ organization: 4554684875538432
+ role: member
+ token: null
+ token_expires_at: null
+ type: 50
+ user_email: added-by-org-member
+ user_id: 6
+ user_is_active: true
+ model: sentry.organizationmember
+ pk: 8
+- fields:
+ member: 2
+ requester_id: 2
+ team: 4554684875603968
+ model: sentry.organizationaccessrequest
+ pk: 1
+- fields:
+ config:
+ schedule: '* * * * *'
+ schedule_type: 1
+ date_added: '2024-09-11T23:30:29.080Z'
+ guid: 2a6fe27f-7d68-4d33-9943-d07e2fd586c7
+ is_muted: false
+ name: ''
+ organization_id: 4554684875538432
+ owner_team_id: null
+ owner_user_id: 2
+ project_id: 4554684875669504
+ slug: 855bb1810417
+ status: 0
+ type: 3
+ model: sentry.monitor
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:29.425Z'
+ date_updated: '2024-09-11T23:30:29.425Z'
+ name: View 1 for test-org
+ organization: 4554684875538432
+ position: 0
+ query: some query for test-org
+ query_sort: date
+ user_id: 2
+ model: sentry.groupsearchview
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:29.065Z'
+ name: frankly equipped sculpin
+ organization_id: 4554684875538432
+ model: sentry.environment
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:26.385Z'
+ email: superadmin
+ model: sentry.email
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:26.502Z'
+ email: owner
+ model: sentry.email
+ pk: 2
+- fields:
+ date_added: '2024-09-11T23:30:26.529Z'
+ email: member
+ model: sentry.email
+ pk: 3
+- fields:
+ date_added: '2024-09-11T23:30:26.555Z'
+ email: added-by-superadmin-not-in-org
+ model: sentry.email
+ pk: 4
+- fields:
+ date_added: '2024-09-11T23:30:26.591Z'
+ email: added-by-org-owner
+ model: sentry.email
+ pk: 5
+- fields:
+ date_added: '2024-09-11T23:30:26.631Z'
+ email: added-by-org-member
+ model: sentry.email
+ pk: 6
+- fields:
+ date_added: '2024-09-11T23:30:29.272Z'
+ email: admin@localhost
+ model: sentry.email
+ pk: 7
+- fields:
+ date_added: '2024-09-11T23:30:29.486Z'
+ email: 25549474e90f40f9832f08ede8fda417@example.com
+ model: sentry.email
+ pk: 8
+- fields:
+ date_added: '2024-09-11T23:30:29.627Z'
+ email: ''
+ model: sentry.email
+ pk: 9
+- fields:
+ date_added: '2024-09-11T23:30:30.338Z'
+ email: 4288351af3464cdeabf1ad6677d86e8c@example.com
+ model: sentry.email
+ pk: 10
+- fields:
+ access_end: '2024-09-12T23:30:29.474Z'
+ access_start: '2024-09-11T23:30:29.474Z'
+ date_added: '2024-09-11T23:30:29.474Z'
+ date_updated: '2024-09-11T23:30:29.474Z'
+ organization: 4554684875538432
+ zendesk_tickets: '[]'
+ model: sentry.datasecrecywaiver
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:29.385Z'
+ organization: 4554684875538432
+ slug: test-tombstone-in-test-org
+ model: sentry.dashboardtombstone
+ pk: 1
+- fields:
+ created_by_id: 2
+ date_added: '2024-09-11T23:30:29.378Z'
+ filters: null
+ last_visited: '2024-09-11T23:30:29.378Z'
+ organization: 4554684875538432
+ title: Dashboard 1 for test-org
+ visits: 1
+ model: sentry.dashboard
+ pk: 1
+- fields:
+ condition: '{"op":"equals","name":"environment","value":"prod"}'
+ condition_hash: b695f09de4b509c52d262afe66bc767b3d159302
+ created_by_id: 2
+ date_added: '2024-09-11T23:30:29.046Z'
+ end_date: '2024-09-12T00:30:29.036Z'
+ is_active: true
+ is_org_level: false
+ notification_sent: false
+ num_samples: 100
+ organization: 4554684875538432
+ query: environment:prod event.type:transaction
+ rule_id: 1
+ sample_rate: 0.5
+ start_date: '2024-09-11T23:30:29.036Z'
+ model: sentry.customdynamicsamplingrule
+ pk: 1
+- fields:
+ project: 4554684875669504
+ value: 2
+ model: sentry.counter
+ pk: 1
+- fields:
+ config: {}
+ date_added: '2024-09-11T23:30:28.393Z'
+ default_global_access: true
+ default_role: 50
+ flags: '0'
+ last_sync: null
+ organization_id: 4554684875538432
+ provider: sentry
+ sync_time: null
+ model: sentry.authprovider
+ pk: 1
+- fields:
+ auth_provider: 1
+ data:
+ key1: value1
+ key2: 42
+ key3:
+ - 1
+ - 2
+ - 3
+ key4:
+ nested_key: nested_value
+ date_added: '2024-09-11T23:30:28.585Z'
+ ident: 123456789test-org
+ last_synced: '2024-09-11T23:30:28.585Z'
+ last_verified: '2024-09-11T23:30:28.585Z'
+ user: 2
+ model: sentry.authidentity
+ pk: 1
+- fields:
+ config: '""'
+ created_at: '2024-09-11T23:30:26.419Z'
+ last_used_at: null
+ type: 1
+ user: 1
+ model: sentry.authenticator
+ pk: 1
+- fields:
+ config: '""'
+ created_at: '2024-09-11T23:30:26.516Z'
+ last_used_at: null
+ type: 1
+ user: 2
+ model: sentry.authenticator
+ pk: 2
+- fields:
+ config: '""'
+ created_at: '2024-09-11T23:30:26.543Z'
+ last_used_at: null
+ type: 1
+ user: 3
+ model: sentry.authenticator
+ pk: 3
+- fields:
+ config: '""'
+ created_at: '2024-09-11T23:30:26.570Z'
+ last_used_at: null
+ type: 1
+ user: 4
+ model: sentry.authenticator
+ pk: 4
+- fields:
+ config: '""'
+ created_at: '2024-09-11T23:30:26.608Z'
+ last_used_at: null
+ type: 1
+ user: 5
+ model: sentry.authenticator
+ pk: 5
+- fields:
+ config: '""'
+ created_at: '2024-09-11T23:30:26.657Z'
+ last_used_at: null
+ type: 1
+ user: 6
+ model: sentry.authenticator
+ pk: 6
+- fields:
+ allowed_origins: null
+ date_added: '2024-09-11T23:30:28.310Z'
+ key: 2c447753af1a411184dc6b16ae2fa383
+ label: Default
+ organization_id: 4554684875538432
+ scope_list: '[]'
+ scopes: '0'
+ status: 0
+ model: sentry.apikey
+ pk: 1
+- fields:
+ allowed_origins: ''
+ client_id: b5a6543f7532dc435431117ed80abe948e6d10b01f1fdbc0f1af1019fbdc4ac1
+ client_secret: 88c3659535f634d2561b5f112ea39a97eaa5713c9c91383e8137842912de99af
+ date_added: '2024-09-11T23:30:29.641Z'
+ homepage_url: null
+ name: Cheerful Aardvark
+ owner: 9
+ privacy_url: null
+ redirect_uris: ''
+ requires_org_level_access: false
+ scopes: '[]'
+ status: 0
+ terms_url: null
+ model: sentry.apiapplication
+ pk: 1
+- fields:
+ key: timezone
+ organization_id: null
+ project_id: null
+ user: 1
+ value: '"Europe/Vienna"'
+ model: sentry.useroption
+ pk: 1
+- fields:
+ key: timezone
+ organization_id: null
+ project_id: null
+ user: 2
+ value: '"Europe/Vienna"'
+ model: sentry.useroption
+ pk: 2
+- fields:
+ key: timezone
+ organization_id: null
+ project_id: null
+ user: 3
+ value: '"Europe/Vienna"'
+ model: sentry.useroption
+ pk: 3
+- fields:
+ key: timezone
+ organization_id: null
+ project_id: null
+ user: 4
+ value: '"Europe/Vienna"'
+ model: sentry.useroption
+ pk: 4
+- fields:
+ key: timezone
+ organization_id: null
+ project_id: null
+ user: 5
+ value: '"Europe/Vienna"'
+ model: sentry.useroption
+ pk: 5
+- fields:
+ key: timezone
+ organization_id: null
+ project_id: null
+ user: 6
+ value: '"Europe/Vienna"'
+ model: sentry.useroption
+ pk: 6
+- fields:
+ date_hash_added: '2024-09-11T23:30:26.378Z'
+ email: superadmin
+ is_verified: true
+ user: 1
+ validation_hash: yn0d5wEWfeH2Zd7pRTmFYKMUO2IyQvs7
+ model: sentry.useremail
+ pk: 1
+- fields:
+ date_hash_added: '2024-09-11T23:30:26.499Z'
+ email: owner
+ is_verified: true
+ user: 2
+ validation_hash: LSTPMXNEcictbWl2MpOeg4Nffcy63DwI
+ model: sentry.useremail
+ pk: 2
+- fields:
+ date_hash_added: '2024-09-11T23:30:26.526Z'
+ email: member
+ is_verified: true
+ user: 3
+ validation_hash: wPzaL1Njidg2LUxIiUNh9JpBiahsKbWC
+ model: sentry.useremail
+ pk: 3
+- fields:
+ date_hash_added: '2024-09-11T23:30:26.552Z'
+ email: added-by-superadmin-not-in-org
+ is_verified: true
+ user: 4
+ validation_hash: xQ10ZEE9nYO20cpgL9aVuThjuVncM8jv
+ model: sentry.useremail
+ pk: 4
+- fields:
+ date_hash_added: '2024-09-11T23:30:26.585Z'
+ email: added-by-org-owner
+ is_verified: true
+ user: 5
+ validation_hash: NMe3z6uF7nkO1KHuBHroVAhQM89LYrHg
+ model: sentry.useremail
+ pk: 5
+- fields:
+ date_hash_added: '2024-09-11T23:30:26.625Z'
+ email: added-by-org-member
+ is_verified: true
+ user: 6
+ validation_hash: Tfi5SWwyHZtwKVABkDtYw1KTvFxidXVS
+ model: sentry.useremail
+ pk: 6
+- fields:
+ date_hash_added: '2024-09-11T23:30:29.268Z'
+ email: admin@localhost
+ is_verified: true
+ user: 7
+ validation_hash: 73wFZCW76Ne9dCmqotjYdPGIsRNWcPAz
+ model: sentry.useremail
+ pk: 7
+- fields:
+ date_hash_added: '2024-09-11T23:30:29.484Z'
+ email: 25549474e90f40f9832f08ede8fda417@example.com
+ is_verified: true
+ user: 8
+ validation_hash: GND03UcOsFWotzVnt59gEAZYo3lLpvBc
+ model: sentry.useremail
+ pk: 8
+- fields:
+ date_hash_added: '2024-09-11T23:30:29.620Z'
+ email: ''
+ is_verified: false
+ user: 9
+ validation_hash: zCEOmxcfYIgSCFAFyiPXxiLJOznoM0ho
+ model: sentry.useremail
+ pk: 9
+- fields:
+ date_hash_added: '2024-09-11T23:30:30.333Z'
+ email: 4288351af3464cdeabf1ad6677d86e8c@example.com
+ is_verified: true
+ user: 10
+ validation_hash: tYThNZDfO2aLxStPkkASYhkBG7qaqVR6
+ model: sentry.useremail
+ pk: 10
+- fields:
+ aggregate: count()
+ dataset: events
+ date_added: '2024-09-11T23:30:29.193Z'
+ environment: null
+ query: level:error
+ resolution: 60
+ time_window: 600
+ type: 0
+ model: sentry.snubaquery
+ pk: 1
+- fields:
+ aggregate: count()
+ dataset: events
+ date_added: '2024-09-11T23:30:29.299Z'
+ environment: null
+ query: level:error
+ resolution: 60
+ time_window: 600
+ type: 0
+ model: sentry.snubaquery
+ pk: 2
+- fields:
+ aggregate: count()
+ dataset: events
+ date_added: '2024-09-11T23:30:29.337Z'
+ environment: null
+ query: test query
+ resolution: 60
+ time_window: 60
+ type: 0
+ model: sentry.snubaquery
+ pk: 3
+- fields:
+ application: 1
+ author: A Company
+ creator_label: 25549474e90f40f9832f08ede8fda417@example.com
+ creator_user: 8
+ date_added: '2024-09-11T23:30:29.642Z'
+ date_deleted: null
+ date_published: null
+ date_updated: '2024-09-11T23:30:30.144Z'
+ events: '[]'
+ is_alertable: false
+ metadata: {}
+ name: test app
+ overview: A sample description
+ owner_id: 4554684875538432
+ popularity: 1
+ proxy_user: 9
+ redirect_url: https://example.com/sentry-app/redirect/
+ schema:
+ elements:
+ - settings:
+ optional_fields:
+ - label: Points
+ name: points
+ options:
+ - - '1'
+ - '1'
+ - - '2'
+ - '2'
+ - - '3'
+ - '3'
+ - - '5'
+ - '5'
+ - - '8'
+ - '8'
+ type: select
+ - label: Assignee
+ name: assignee
+ type: select
+ uri: /sentry/members
+ required_fields:
+ - label: Title
+ name: title
+ type: text
+ - label: Summary
+ name: summary
+ type: text
+ type: alert-rule-settings
+ uri: /sentry/alert-rule
+ title: Create Task with App
+ type: alert-rule-action
+ scope_list: '[]'
+ scopes: '0'
+ slug: test-app
+ status: 0
+ uuid: 9a5311dd-ef9e-4abb-a21a-09fffa5038bd
+ verify_install: true
+ webhook_url: https://example.com/sentry-app/webhook/
+ model: sentry.sentryapp
+ pk: 1
+- fields:
+ data: '{"conditions":[{"id":"sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"},{"id":"sentry.rules.conditions.every_event.EveryEventCondition"}],"action_match":"all","filter_match":"all","actions":[{"id":"sentry.rules.actions.notify_event.NotifyEventAction"},{"id":"sentry.rules.actions.notify_event_service.NotifyEventServiceAction","service":"mail"}]}'
+ date_added: '2024-09-11T23:30:29.013Z'
+ environment_id: null
+ label: ''
+ owner_team: null
+ owner_user_id: 2
+ project: 4554684875669504
+ source: 0
+ status: 0
+ model: sentry.rule
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:29.240Z'
+ date_updated: '2024-09-11T23:30:29.240Z'
+ project: 4554684875669504
+ query_extra: null
+ snuba_query: 1
+ status: 1
+ subscription_id: null
+ type: incidents
+ model: sentry.querysubscription
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:29.314Z'
+ date_updated: '2024-09-11T23:30:29.314Z'
+ project: 4554684875669504
+ query_extra: null
+ snuba_query: 2
+ status: 1
+ subscription_id: null
+ type: incidents
+ model: sentry.querysubscription
+ pk: 2
+- fields:
+ date_added: '2024-09-11T23:30:29.352Z'
+ date_updated: '2024-09-11T23:30:29.352Z'
+ project: 4554684875669504
+ query_extra: null
+ snuba_query: 3
+ status: 1
+ subscription_id: null
+ type: incidents
+ model: sentry.querysubscription
+ pk: 3
+- fields:
+ date_added: '2024-09-11T23:30:29.673Z'
+ date_updated: '2024-09-11T23:30:29.673Z'
+ project: 4554684875735042
+ query_extra: null
+ snuba_query: 1
+ status: 1
+ subscription_id: null
+ type: incidents
+ model: sentry.querysubscription
+ pk: 4
+- fields:
+ date_added: '2024-09-11T23:30:30.385Z'
+ date_updated: '2024-09-11T23:30:30.385Z'
+ project: 4554684875800576
+ query_extra: null
+ snuba_query: 1
+ status: 1
+ subscription_id: null
+ type: incidents
+ model: sentry.querysubscription
+ pk: 5
+- fields:
+ project: 4554684875669504
+ team: 4554684875603968
+ model: sentry.projectteam
+ pk: 1
+- fields:
+ project: 4554684875735040
+ team: 4554684875603968
+ model: sentry.projectteam
+ pk: 2
+- fields:
+ date_added: '2024-09-11T23:30:28.282Z'
+ organization: 4554684875538432
+ project: 4554684875669504
+ redirect_slug: project_slug_in_test-org
+ model: sentry.projectredirect
+ pk: 1
+- fields:
+ auto_assignment: true
+ codeowners_auto_sync: true
+ date_created: '2024-09-11T23:30:28.265Z'
+ fallthrough: true
+ is_active: true
+ last_updated: '2024-09-11T23:30:28.265Z'
+ project: 4554684875669504
+ raw: '{"hello":"hello"}'
+ schema:
+ hello: hello
+ suspect_committer_auto_assignment: false
+ model: sentry.projectownership
+ pk: 1
+- fields:
+ key: sentry:option-epoch
+ project: 4554684875669504
+ value: 13
+ model: sentry.projectoption
+ pk: 1
+- fields:
+ key: sentry:option-epoch
+ project: 4554684875735040
+ value: 13
+ model: sentry.projectoption
+ pk: 2
+- fields:
+ key: sentry:option-epoch
+ project: 4554684875735042
+ value: 13
+ model: sentry.projectoption
+ pk: 3
+- fields:
+ key: sentry:option-epoch
+ project: 4554684875800576
+ value: 13
+ model: sentry.projectoption
+ pk: 4
+- fields:
+ data:
+ dynamicSdkLoaderOptions:
+ hasPerformance: true
+ hasReplay: true
+ date_added: '2024-09-11T23:30:28.204Z'
+ label: Default
+ project: 4554684875669504
+ public_key: 0f7147c8d9a411d3a8863af3985c77a1
+ rate_limit_count: null
+ rate_limit_window: null
+ roles: '1'
+ secret_key: 867425dd522c1839957cfd95ae033239
+ status: 0
+ use_case: user
+ model: sentry.projectkey
+ pk: 1
+- fields:
+ data:
+ dynamicSdkLoaderOptions:
+ hasPerformance: true
+ hasReplay: true
+ date_added: '2024-09-11T23:30:29.145Z'
+ label: Default
+ project: 4554684875735040
+ public_key: 50b0b399251fc2713a5530016a6139b4
+ rate_limit_count: null
+ rate_limit_window: null
+ roles: '1'
+ secret_key: 9829e507800623e51dda475a07200932
+ status: 0
+ use_case: user
+ model: sentry.projectkey
+ pk: 2
+- fields:
+ data:
+ dynamicSdkLoaderOptions:
+ hasPerformance: true
+ hasReplay: true
+ date_added: '2024-09-11T23:30:29.692Z'
+ label: Default
+ project: 4554684875735042
+ public_key: fc1d24d31386130aa2a5898abd9d3fbe
+ rate_limit_count: null
+ rate_limit_window: null
+ roles: '1'
+ secret_key: 079e6300d07b44787477e49410d40998
+ status: 0
+ use_case: user
+ model: sentry.projectkey
+ pk: 3
+- fields:
+ data:
+ dynamicSdkLoaderOptions:
+ hasPerformance: true
+ hasReplay: true
+ date_added: '2024-09-11T23:30:30.423Z'
+ label: Default
+ project: 4554684875800576
+ public_key: 35d113b950110337887d2287c568ad2a
+ rate_limit_count: null
+ rate_limit_window: null
+ roles: '1'
+ secret_key: a4fc1ef986e2af321cf8e8bdb79fac2d
+ status: 0
+ use_case: user
+ model: sentry.projectkey
+ pk: 4
+- fields:
+ config:
+ hello: hello
+ integration_id: 1
+ project: 4554684875669504
+ model: sentry.projectintegration
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:28.260Z'
+ project: 4554684875669504
+ user_id: 2
+ model: sentry.projectbookmark
+ pk: 1
+- fields:
+ is_active: true
+ organizationmember: 1
+ role: null
+ team: 4554684875603968
+ model: sentry.organizationmemberteam
+ pk: 1
+- fields:
+ integration_id: null
+ organization: 4554684875538432
+ sentry_app_id: null
+ target_display: Sentry User
+ target_identifier: '1'
+ target_type: 1
+ trigger_type: 0
+ type: 5
+ model: sentry.notificationaction
+ pk: 1
+- fields:
+ integration_id: null
+ organization: 4554684875538432
+ sentry_app_id: 1
+ target_display: Sentry User
+ target_identifier: '1'
+ target_type: 1
+ trigger_type: 0
+ type: 5
+ model: sentry.notificationaction
+ pk: 2
+- fields:
+ disable_date: '2024-09-11T23:30:29.033Z'
+ opted_out: false
+ organization: 4554684875538432
+ rule: 1
+ sent_final_email_date: '2024-09-11T23:30:29.033Z'
+ sent_initial_email_date: '2024-09-11T23:30:29.033Z'
+ model: sentry.neglectedrule
+ pk: 1
+- fields:
+ environment: 1
+ is_hidden: null
+ project: 4554684875669504
+ model: sentry.environmentproject
+ pk: 1
+- fields:
+ dashboard: 1
+ dataset_source: 0
+ date_added: '2024-09-11T23:30:29.379Z'
+ description: null
+ detail: null
+ discover_widget_split: null
+ display_type: 0
+ interval: null
+ limit: null
+ order: 1
+ thresholds: null
+ title: Test Widget for test-org
+ widget_type: 0
+ model: sentry.dashboardwidget
+ pk: 1
+- fields:
+ custom_dynamic_sampling_rule: 1
+ project: 4554684875669504
+ model: sentry.customdynamicsamplingruleproject
+ pk: 1
+- fields:
+ application: 1
+ date_added: '2024-09-11T23:30:29.938Z'
+ expires_at: '2024-09-12T07:30:29.938Z'
+ hashed_refresh_token: b3b4135cb04687d30a681bd871be7a7cc4af39ce3be233bb3982d74eb7508ef4
+ hashed_token: de274dfd718be117886f3c512935de428301c65ca233af69975c85f07a13939b
+ name: null
+ refresh_token: a8ecd0ad049ad5990b3bc863380ff050fdbdbef84e9c2c6db8862afa410e9bad
+ scope_list: '[]'
+ scopes: '0'
+ token: 001e792fc864073328b8de184ebc6ed30aad5cc7526992635bae95ced31e486a
+ token_last_characters: 486a
+ token_type: null
+ user: 9
+ model: sentry.apitoken
+ pk: 1
+- fields:
+ application: 1
+ date_added: '2024-09-11T23:30:30.227Z'
+ expires_at: null
+ hashed_refresh_token: 4ff2f55d9603b3164189b275104dc604663e7ed80a7479d19f5ee0c9176b0d7a
+ hashed_token: 37a222c1bab719781fd57b8ac2f5b188920919b8a62949bb2a302ff274ef3215
+ name: create_exhaustive_sentry_app
+ refresh_token: 2792b43412eec1049ce794e5d7065ef90ab59e0126877f7e50faab85a31b09fe
+ scope_list: '[]'
+ scopes: '0'
+ token: 91d1f6717884f34390339c5639fb6531506cb134fd31df731642a8492391be22
+ token_last_characters: be22
+ token_type: null
+ user: 2
+ model: sentry.apitoken
+ pk: 2
+- fields:
+ application: null
+ date_added: '2024-09-11T23:30:30.503Z'
+ expires_at: null
+ hashed_refresh_token: null
+ hashed_token: d26518da7eb296ad7732350fc4ac3643be1a403978c6c8f393bfdb11b6c9c164
+ name: create_exhaustive_global_configs_for_
+ refresh_token: null
+ scope_list: '[]'
+ scopes: '0'
+ token: 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
+ token_last_characters: 614e
+ token_type: sntryu_
+ user: 2
+ model: sentry.apitoken
+ pk: 3
+- fields:
+ application: 1
+ code: 44931ad24e554b89c2dde2797e3f2e3a92bb345129a4a1b11672a3e82c66f277
+ expires_at: '2022-01-01T11:11:00.000Z'
+ redirect_uri: https://example.com
+ scope_list: '[''openid'', ''profile'', ''email'']'
+ scopes: '0'
+ user: 2
+ model: sentry.apigrant
+ pk: 2
+- fields:
+ application: 1
+ date_added: '2024-09-11T23:30:30.224Z'
+ scope_list: '[]'
+ scopes: '0'
+ user: 2
+ model: sentry.apiauthorization
+ pk: 1
+- fields:
+ application: null
+ date_added: '2024-09-11T23:30:30.498Z'
+ scope_list: '[]'
+ scopes: '0'
+ user: 2
+ model: sentry.apiauthorization
+ pk: 2
+- fields:
+ comparison_delta: null
+ date_added: '2024-09-11T23:30:29.209Z'
+ date_modified: '2024-09-11T23:30:29.209Z'
+ description: null
+ detection_type: static
+ include_all_projects: true
+ monitor_type: 0
+ name: Suited Bluebird
+ organization: 4554684875538432
+ resolve_threshold: null
+ seasonality: null
+ sensitivity: null
+ snuba_query: 1
+ status: 0
+ team: null
+ threshold_period: 1
+ threshold_type: 0
+ user_id: 2
+ model: sentry.alertrule
+ pk: 1
+- fields:
+ comparison_delta: null
+ date_added: '2024-09-11T23:30:29.304Z'
+ date_modified: '2024-09-11T23:30:29.304Z'
+ description: null
+ detection_type: static
+ include_all_projects: false
+ monitor_type: 1
+ name: Crack Colt
+ organization: 4554684875538432
+ resolve_threshold: null
+ seasonality: null
+ sensitivity: null
+ snuba_query: 2
+ status: 0
+ team: null
+ threshold_period: 1
+ threshold_type: 0
+ user_id: null
+ model: sentry.alertrule
+ pk: 2
+- fields:
+ comparison_delta: null
+ date_added: '2024-09-11T23:30:29.345Z'
+ date_modified: '2024-09-11T23:30:29.345Z'
+ description: null
+ detection_type: static
+ include_all_projects: false
+ monitor_type: 0
+ name: Proud Fly
+ organization: 4554684875538432
+ resolve_threshold: null
+ seasonality: null
+ sensitivity: null
+ snuba_query: 3
+ status: 0
+ team: null
+ threshold_period: 1
+ threshold_type: 0
+ user_id: null
+ model: sentry.alertrule
+ pk: 3
+- fields:
+ snuba_query: 1
+ type: 0
+ model: sentry.snubaqueryeventtype
+ pk: 1
+- fields:
+ snuba_query: 2
+ type: 0
+ model: sentry.snubaqueryeventtype
+ pk: 2
+- fields:
+ snuba_query: 3
+ type: 0
+ model: sentry.snubaqueryeventtype
+ pk: 3
+- fields:
+ api_grant: null
+ api_token: 1
+ date_added: '2024-09-11T23:30:29.717Z'
+ date_deleted: null
+ date_updated: '2024-09-11T23:30:29.864Z'
+ organization_id: 4554684875538432
+ sentry_app: 1
+ status: 1
+ uuid: f6b4be8e-7380-4699-97ee-4dc169449006
+ model: sentry.sentryappinstallation
+ pk: 1
+- fields:
+ schema:
+ settings:
+ optional_fields:
+ - label: Points
+ name: points
+ options:
+ - - '1'
+ - '1'
+ - - '2'
+ - '2'
+ - - '3'
+ - '3'
+ - - '5'
+ - '5'
+ - - '8'
+ - '8'
+ type: select
+ - label: Assignee
+ name: assignee
+ type: select
+ uri: /sentry/members
+ required_fields:
+ - label: Title
+ name: title
+ type: text
+ - label: Summary
+ name: summary
+ type: text
+ type: alert-rule-settings
+ uri: /sentry/alert-rule
+ title: Create Task with App
+ type: alert-rule-action
+ sentry_app: 1
+ type: alert-rule-action
+ uuid: d367341e-f5a4-49e0-9a0c-adf607a211c2
+ model: sentry.sentryappcomponent
+ pk: 1
+- fields:
+ alert_rule: null
+ date_added: '2024-09-11T23:30:29.029Z'
+ owner_id: 2
+ rule: 1
+ until: null
+ user_id: 2
+ model: sentry.rulesnooze
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:29.024Z'
+ rule: 1
+ type: 1
+ user_id: 2
+ model: sentry.ruleactivity
+ pk: 1
+- fields:
+ action: 1
+ project: 4554684875669504
+ model: sentry.notificationactionproject
+ pk: 1
+- fields:
+ action: 2
+ project: 4554684875669504
+ model: sentry.notificationactionproject
+ pk: 2
+- fields:
+ aggregates: null
+ columns: null
+ conditions: ''
+ date_added: '2024-09-11T23:30:29.382Z'
+ date_modified: '2024-09-11T23:30:29.382Z'
+ field_aliases: null
+ fields: '[]'
+ is_hidden: false
+ name: Test Query for test-org
+ order: 1
+ orderby: ''
+ widget: 1
+ model: sentry.dashboardwidgetquery
+ pk: 1
+- fields:
+ alert_rule: 1
+ alert_threshold: 100.0
+ date_added: '2024-09-11T23:30:29.260Z'
+ label: Helping Bug
+ resolve_threshold: null
+ threshold_type: null
+ model: sentry.alertruletrigger
+ pk: 1
+- fields:
+ alert_rule: 2
+ alert_threshold: 100.0
+ date_added: '2024-09-11T23:30:29.327Z'
+ label: Moving Kite
+ resolve_threshold: null
+ threshold_type: null
+ model: sentry.alertruletrigger
+ pk: 2
+- fields:
+ alert_rule: 1
+ date_added: '2024-09-11T23:30:29.235Z'
+ project: 4554684875669504
+ model: sentry.alertruleprojects
+ pk: 1
+- fields:
+ alert_rule: 2
+ date_added: '2024-09-11T23:30:29.309Z'
+ project: 4554684875669504
+ model: sentry.alertruleprojects
+ pk: 2
+- fields:
+ alert_rule: 3
+ date_added: '2024-09-11T23:30:29.350Z'
+ project: 4554684875669504
+ model: sentry.alertruleprojects
+ pk: 3
+- fields:
+ alert_rule: 1
+ date_added: '2024-09-11T23:30:29.221Z'
+ project: 4554684875735040
+ model: sentry.alertruleexcludedprojects
+ pk: 1
+- fields:
+ alert_rule: 1
+ date_added: '2024-09-11T23:30:29.243Z'
+ previous_alert_rule: null
+ type: 1
+ user_id: 2
+ model: sentry.alertruleactivity
+ pk: 1
+- fields:
+ alert_rule: 2
+ date_added: '2024-09-11T23:30:29.311Z'
+ previous_alert_rule: null
+ type: 1
+ user_id: null
+ model: sentry.alertruleactivity
+ pk: 2
+- fields:
+ alert_rule: 3
+ date_added: '2024-09-11T23:30:29.354Z'
+ previous_alert_rule: null
+ type: 1
+ user_id: null
+ model: sentry.alertruleactivity
+ pk: 3
+- fields:
+ alert_rule: 2
+ condition_type: 0
+ date_added: '2024-09-11T23:30:29.307Z'
+ label: ''
+ model: sentry.alertruleactivationcondition
+ pk: 1
+- fields:
+ actor_id: 1
+ application_id: 1
+ date_added: '2024-09-11T23:30:29.857Z'
+ events: '[]'
+ guid: 30a7423b7d1a4ab5815b152b086d27e7
+ installation_id: 1
+ organization_id: 4554684875538432
+ project_id: null
+ secret: 4ef492cdc2c43d3bac8da328f9e7f843bb2e7e3a033b73464dbd4ab68b219e36
+ status: 0
+ url: https://example.com/sentry-app/webhook/
+ version: 0
+ model: sentry.servicehook
+ pk: 1
+- fields:
+ actor_id: 10
+ application_id: 1
+ date_added: '2024-09-11T23:30:30.456Z'
+ events: '[''event.created'']'
+ guid: 16f5409ed63c4022b1b422c7fc9b6bd3
+ installation_id: 1
+ organization_id: 4554684875538432
+ project_id: 4554684875800576
+ secret: 3f4bab6a6ef0b70fcebef2dfed1598b01cff4327ada9a9fe0ae609da0dc6be4e
+ status: 0
+ url: https://example.com/sentry/webhook
+ version: 0
+ model: sentry.servicehook
+ pk: 2
+- fields:
+ activation: null
+ alert_rule: 3
+ date_added: '2024-09-11T23:30:29.360Z'
+ date_closed: null
+ date_detected: '2024-09-11T23:30:29.357Z'
+ date_started: '2024-09-11T23:30:29.357Z'
+ detection_uuid: null
+ identifier: 1
+ organization: 4554684875538432
+ status: 1
+ status_method: 3
+ subscription: null
+ title: Hopeful Snapper
+ type: 2
+ model: sentry.incident
+ pk: 1
+- fields:
+ dashboard_widget_query: 1
+ date_added: '2024-09-11T23:30:29.384Z'
+ date_modified: '2024-09-11T23:30:29.384Z'
+ extraction_state: disabled:not-applicable
+ spec_hashes: '[]'
+ spec_version: null
+ model: sentry.dashboardwidgetqueryondemand
+ pk: 1
+- fields:
+ alert_rule_trigger: 1
+ date_added: '2024-09-11T23:30:29.262Z'
+ query_subscription: 1
+ model: sentry.alertruletriggerexclusion
+ pk: 1
+- fields:
+ alert_rule_trigger: 1
+ date_added: '2024-09-11T23:30:29.295Z'
+ integration_id: null
+ sentry_app_config: null
+ sentry_app_id: null
+ status: 0
+ target_display: null
+ target_identifier: '7'
+ target_type: 1
+ type: 0
+ model: sentry.alertruletriggeraction
+ pk: 1
+- fields:
+ alert_rule_trigger: 2
+ date_added: '2024-09-11T23:30:29.334Z'
+ integration_id: null
+ sentry_app_config: null
+ sentry_app_id: null
+ status: 0
+ target_display: null
+ target_identifier: '7'
+ target_type: 1
+ type: 0
+ model: sentry.alertruletriggeraction
+ pk: 2
+- fields:
+ date_added: '2024-09-11T23:30:29.369Z'
+ end: '2024-09-11T23:30:29.369Z'
+ period: 1
+ start: '2024-09-10T23:30:29.369Z'
+ values: '[[1.0, 2.0, 3.0], [1.5, 2.5, 3.5]]'
+ model: sentry.timeseriessnapshot
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:29.376Z'
+ incident: 1
+ target_run_date: '2024-09-12T03:30:29.376Z'
+ model: sentry.pendingincidentsnapshot
+ pk: 1
+- fields:
+ alert_rule_trigger: 1
+ date_added: '2024-09-11T23:30:29.374Z'
+ date_modified: '2024-09-11T23:30:29.374Z'
+ incident: 1
+ status: 1
+ model: sentry.incidenttrigger
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:29.373Z'
+ incident: 1
+ user_id: 2
+ model: sentry.incidentsubscription
+ pk: 1
+- fields:
+ date_added: '2024-09-11T23:30:29.371Z'
+ event_stats_snapshot: 1
+ incident: 1
+ total_events: 1
+ unique_users: 1
+ model: sentry.incidentsnapshot
+ pk: 1
+- fields:
+ comment: hello test-org
+ date_added: '2024-09-11T23:30:29.367Z'
+ incident: 1
+ notification_uuid: null
+ previous_value: null
+ type: 1
+ user_id: 2
+ value: null
+ model: sentry.incidentactivity
+ pk: 1
diff --git a/tests/sentry/backup/snapshots/ReleaseTests/test_at_head.pysnap b/tests/sentry/backup/snapshots/ReleaseTests/test_at_head.pysnap
index 79ed3308c4b08c..5f99700b6f9ab5 100644
--- a/tests/sentry/backup/snapshots/ReleaseTests/test_at_head.pysnap
+++ b/tests/sentry/backup/snapshots/ReleaseTests/test_at_head.pysnap
@@ -1,18 +1,18 @@
---
-created: '2024-09-17T12:19:44.078933+00:00'
+created: '2024-09-18T18:52:40.976940+00:00'
creator: sentry
source: tests/sentry/backup/test_releases.py
---
- fields:
key: bar
- last_updated: '2024-09-17T12:19:43.756Z'
+ last_updated: '2024-09-18T18:52:40.623Z'
last_updated_by: unknown
value: '"b"'
model: sentry.controloption
pk: 1
- fields:
- date_added: '2024-09-17T12:19:43.286Z'
- date_updated: '2024-09-17T12:19:43.286Z'
+ date_added: '2024-09-18T18:52:39.976Z'
+ date_updated: '2024-09-18T18:52:39.976Z'
external_id: slack:test-org
metadata: {}
name: Slack for test-org
@@ -22,13 +22,13 @@ source: tests/sentry/backup/test_releases.py
pk: 1
- fields:
key: foo
- last_updated: '2024-09-17T12:19:43.754Z'
+ last_updated: '2024-09-18T18:52:40.621Z'
last_updated_by: unknown
value: '"a"'
model: sentry.option
pk: 1
- fields:
- date_added: '2024-09-17T12:19:42.744Z'
+ date_added: '2024-09-18T18:52:39.140Z'
default_role: member
flags: '1'
is_test: false
@@ -36,40 +36,40 @@ source: tests/sentry/backup/test_releases.py
slug: test-org
status: 0
model: sentry.organization
- pk: 4554716211970048
+ pk: 4554723419422720
- fields:
- date_added: '2024-09-17T12:19:43.454Z'
+ date_added: '2024-09-18T18:52:40.200Z'
default_role: member
flags: '1'
is_test: false
- name: Fleet Octopus
- slug: fleet-octopus
+ name: Primary Trout
+ slug: primary-trout
status: 0
model: sentry.organization
- pk: 4554716212035587
+ pk: 4554723419488257
- fields:
config:
hello: hello
- date_added: '2024-09-17T12:19:43.288Z'
- date_updated: '2024-09-17T12:19:43.288Z'
+ date_added: '2024-09-18T18:52:39.977Z'
+ date_updated: '2024-09-18T18:52:39.977Z'
default_auth_id: null
grace_period_end: null
integration: 1
- organization_id: 4554716211970048
+ organization_id: 4554723419422720
status: 0
model: sentry.organizationintegration
pk: 1
- fields:
key: sentry:account-rate-limit
- organization: 4554716211970048
+ organization: 4554723419422720
value: 0
model: sentry.organizationoption
pk: 1
- fields:
- date_added: '2024-09-17T12:19:43.150Z'
- date_updated: '2024-09-17T12:19:43.150Z'
+ date_added: '2024-09-18T18:52:39.781Z'
+ date_updated: '2024-09-18T18:52:39.781Z'
name: template-test-org
- organization: 4554716211970048
+ organization: 4554723419422720
model: sentry.projecttemplate
pk: 1
- fields:
@@ -82,44 +82,44 @@ source: tests/sentry/backup/test_releases.py
first_seen: null
is_internal: true
last_seen: null
- public_key: kPpoaj3dpvRmdO0-GK7LB8qjhJTABiyPv7vZOcS5hhk
- relay_id: d219f39d-cd61-4a4d-a07a-28af9a310c3e
+ public_key: EcfmnXY-fhjTxNL6ktH6mSa0f6AorrMug0Wo_mQF_5o
+ relay_id: 4d40b877-e549-4402-ac27-b905bbe77780
model: sentry.relay
pk: 1
- fields:
- first_seen: '2024-09-17T12:19:43.752Z'
- last_seen: '2024-09-17T12:19:43.752Z'
- public_key: kPpoaj3dpvRmdO0-GK7LB8qjhJTABiyPv7vZOcS5hhk
- relay_id: d219f39d-cd61-4a4d-a07a-28af9a310c3e
+ first_seen: '2024-09-18T18:52:40.620Z'
+ last_seen: '2024-09-18T18:52:40.620Z'
+ public_key: EcfmnXY-fhjTxNL6ktH6mSa0f6AorrMug0Wo_mQF_5o
+ relay_id: 4d40b877-e549-4402-ac27-b905bbe77780
version: 0.0.1
model: sentry.relayusage
pk: 1
- fields:
config: {}
- date_added: '2024-09-17T12:19:43.410Z'
+ date_added: '2024-09-18T18:52:40.158Z'
external_id: https://git.example.com:1234
integration_id: 1
languages: '[]'
name: getsentry/getsentry
- organization_id: 4554716211970048
+ organization_id: 4554723419422720
provider: integrations:github
status: 0
url: https://github.com/getsentry/getsentry
model: sentry.repository
pk: 1
- fields:
- date_added: '2024-09-17T12:19:43.106Z'
+ date_added: '2024-09-18T18:52:39.707Z'
idp_provisioned: false
name: test_team_in_test-org
- organization: 4554716211970048
+ organization: 4554723419422720
slug: test_team_in_test-org
status: 0
model: sentry.team
- pk: 4554716212035584
+ pk: 4554723419422721
- fields:
avatar_type: 0
avatar_url: null
- date_joined: '2024-09-17T12:19:42.608Z'
+ date_joined: '2024-09-18T18:52:37.902Z'
email: superadmin
flags: '0'
is_active: true
@@ -129,11 +129,11 @@ source: tests/sentry/backup/test_releases.py
is_staff: true
is_superuser: true
is_unclaimed: false
- last_active: '2024-09-17T12:19:42.608Z'
+ last_active: '2024-09-18T18:52:37.902Z'
last_login: null
- last_password_change: '2024-09-17T12:19:42.608Z'
+ last_password_change: '2024-09-18T18:52:37.903Z'
name: ''
- password: md5$06Yg0WcHlnVvLtqIeeZ8eF$783881c7418fd56c60c64ddbcc6e047a
+ password: md5$lkUeyyudEkJAgIYQ9x3SJ3$381007efc71038c72b16d33c96392161
session_nonce: null
username: superadmin
model: sentry.user
@@ -141,7 +141,7 @@ source: tests/sentry/backup/test_releases.py
- fields:
avatar_type: 0
avatar_url: null
- date_joined: '2024-09-17T12:19:42.668Z'
+ date_joined: '2024-09-18T18:52:39.020Z'
email: owner
flags: '0'
is_active: true
@@ -151,11 +151,11 @@ source: tests/sentry/backup/test_releases.py
is_staff: false
is_superuser: false
is_unclaimed: false
- last_active: '2024-09-17T12:19:42.668Z'
+ last_active: '2024-09-18T18:52:39.020Z'
last_login: null
- last_password_change: '2024-09-17T12:19:42.668Z'
+ last_password_change: '2024-09-18T18:52:39.021Z'
name: ''
- password: md5$T4JwsoG6JsEpek5zx0zgPI$696135333fbacfde786690290a215d7d
+ password: md5$thzVE1qS5rCVVJVGEqJFPz$c90289704422b0e22c1117247c107eb5
session_nonce: null
username: owner
model: sentry.user
@@ -163,7 +163,7 @@ source: tests/sentry/backup/test_releases.py
- fields:
avatar_type: 0
avatar_url: null
- date_joined: '2024-09-17T12:19:42.683Z'
+ date_joined: '2024-09-18T18:52:39.058Z'
email: member
flags: '0'
is_active: true
@@ -173,11 +173,11 @@ source: tests/sentry/backup/test_releases.py
is_staff: false
is_superuser: false
is_unclaimed: false
- last_active: '2024-09-17T12:19:42.683Z'
+ last_active: '2024-09-18T18:52:39.058Z'
last_login: null
- last_password_change: '2024-09-17T12:19:42.683Z'
+ last_password_change: '2024-09-18T18:52:39.058Z'
name: ''
- password: md5$9hfoOzKrW9tFeRn02S8sSA$c46eb2ea76cb3aede3fef18f4af8f0c3
+ password: md5$JFP7ir8AoZrvauoTltCDfq$1da527e5df7ab1b3820fc8f2450a3472
session_nonce: null
username: member
model: sentry.user
@@ -185,7 +185,7 @@ source: tests/sentry/backup/test_releases.py
- fields:
avatar_type: 0
avatar_url: null
- date_joined: '2024-09-17T12:19:42.698Z'
+ date_joined: '2024-09-18T18:52:39.079Z'
email: added-by-superadmin-not-in-org
flags: '0'
is_active: true
@@ -195,11 +195,11 @@ source: tests/sentry/backup/test_releases.py
is_staff: false
is_superuser: false
is_unclaimed: false
- last_active: '2024-09-17T12:19:42.698Z'
+ last_active: '2024-09-18T18:52:39.079Z'
last_login: null
- last_password_change: '2024-09-17T12:19:42.698Z'
+ last_password_change: '2024-09-18T18:52:39.079Z'
name: ''
- password: md5$ui6nfZLth31fhfTngpYwbn$662257ab00763f7ae901c3f490f888d8
+ password: md5$LBBlSmify755zLdfhhmplR$fb2b8b46c1951199662f2040c2414e8a
session_nonce: null
username: added-by-superadmin-not-in-org
model: sentry.user
@@ -207,7 +207,7 @@ source: tests/sentry/backup/test_releases.py
- fields:
avatar_type: 0
avatar_url: null
- date_joined: '2024-09-17T12:19:42.714Z'
+ date_joined: '2024-09-18T18:52:39.099Z'
email: added-by-org-owner
flags: '0'
is_active: true
@@ -217,11 +217,11 @@ source: tests/sentry/backup/test_releases.py
is_staff: false
is_superuser: false
is_unclaimed: false
- last_active: '2024-09-17T12:19:42.714Z'
+ last_active: '2024-09-18T18:52:39.099Z'
last_login: null
- last_password_change: '2024-09-17T12:19:42.714Z'
+ last_password_change: '2024-09-18T18:52:39.099Z'
name: ''
- password: md5$PNevhaGy1ZOJlHRuWwguiD$89fa6a9440c2ff7291f59e9088c630b2
+ password: md5$IKarMNUFirqa2tx4OCYrVO$411e38917add0637609e8a925e12588c
session_nonce: null
username: added-by-org-owner
model: sentry.user
@@ -229,7 +229,7 @@ source: tests/sentry/backup/test_releases.py
- fields:
avatar_type: 0
avatar_url: null
- date_joined: '2024-09-17T12:19:42.729Z'
+ date_joined: '2024-09-18T18:52:39.119Z'
email: added-by-org-member
flags: '0'
is_active: true
@@ -239,11 +239,11 @@ source: tests/sentry/backup/test_releases.py
is_staff: false
is_superuser: false
is_unclaimed: false
- last_active: '2024-09-17T12:19:42.729Z'
+ last_active: '2024-09-18T18:52:39.119Z'
last_login: null
- last_password_change: '2024-09-17T12:19:42.729Z'
+ last_password_change: '2024-09-18T18:52:39.119Z'
name: ''
- password: md5$iI67h1oVGeuL6IdryaSVdB$575ac19e1c1f62653a2e36734183cd67
+ password: md5$3V1RwEiZcza9ubR63c3h0o$18828f87efe38aafd6cd5c099a88619e
session_nonce: null
username: added-by-org-member
model: sentry.user
@@ -251,7 +251,7 @@ source: tests/sentry/backup/test_releases.py
- fields:
avatar_type: 0
avatar_url: null
- date_joined: '2024-09-17T12:19:43.348Z'
+ date_joined: '2024-09-18T18:52:40.077Z'
email: admin@localhost
flags: '0'
is_active: true
@@ -261,11 +261,11 @@ source: tests/sentry/backup/test_releases.py
is_staff: true
is_superuser: true
is_unclaimed: false
- last_active: '2024-09-17T12:19:43.348Z'
+ last_active: '2024-09-18T18:52:40.077Z'
last_login: null
- last_password_change: '2024-09-17T12:19:43.348Z'
+ last_password_change: '2024-09-18T18:52:40.077Z'
name: ''
- password: md5$soUD8yN9LuFl8LrzPmjiar$6df06e4daaaec55760b3029a72cfc88e
+ password: md5$qUveS7FEIX5HLOThxQxJP8$c4359ad463eb4e5a225017f07595e612
session_nonce: null
username: admin@localhost
model: sentry.user
@@ -273,8 +273,8 @@ source: tests/sentry/backup/test_releases.py
- fields:
avatar_type: 0
avatar_url: null
- date_joined: '2024-09-17T12:19:43.444Z'
- email: 2eff8974c8f64f5e9a8745ac7eb11074@example.com
+ date_joined: '2024-09-18T18:52:40.187Z'
+ email: 67d6dbedf685436cae760f9d11c77f12@example.com
flags: '0'
is_active: true
is_managed: false
@@ -283,19 +283,19 @@ source: tests/sentry/backup/test_releases.py
is_staff: false
is_superuser: false
is_unclaimed: false
- last_active: '2024-09-17T12:19:43.444Z'
+ last_active: '2024-09-18T18:52:40.187Z'
last_login: null
- last_password_change: '2024-09-17T12:19:43.445Z'
+ last_password_change: '2024-09-18T18:52:40.187Z'
name: ''
- password: md5$JnB8thXA5i5PRxGQ7AdUQ4$6ec3e02b14c5f62e9c5a131d255dad7b
+ password: md5$W0XzS3XBuHL2BvTTAgIRD7$ce16b1e67f2b253458eb5785981f0696
session_nonce: null
- username: 2eff8974c8f64f5e9a8745ac7eb11074@example.com
+ username: 67d6dbedf685436cae760f9d11c77f12@example.com
model: sentry.user
pk: 8
- fields:
avatar_type: 0
avatar_url: null
- date_joined: '2024-09-17T12:19:43.501Z'
+ date_joined: '2024-09-18T18:52:40.261Z'
email: ''
flags: '0'
is_active: true
@@ -305,20 +305,20 @@ source: tests/sentry/backup/test_releases.py
is_staff: false
is_superuser: false
is_unclaimed: false
- last_active: '2024-09-17T12:19:43.501Z'
+ last_active: '2024-09-18T18:52:40.261Z'
last_login: null
last_password_change: null
name: ''
password: ''
session_nonce: null
- username: test-app-c8aada1c-c621-48a9-9c31-b606a2230f27
+ username: test-app-c9261e33-c552-4047-97cf-97974dc904a7
model: sentry.user
pk: 9
- fields:
avatar_type: 0
avatar_url: null
- date_joined: '2024-09-17T12:19:43.686Z'
- email: f898705a0fdb43f2b08fca779495514d@example.com
+ date_joined: '2024-09-18T18:52:40.540Z'
+ email: 17e4af6a9e4646eca56aa7d8e1878d8d@example.com
flags: '0'
is_active: true
is_managed: false
@@ -327,13 +327,13 @@ source: tests/sentry/backup/test_releases.py
is_staff: false
is_superuser: false
is_unclaimed: false
- last_active: '2024-09-17T12:19:43.686Z'
+ last_active: '2024-09-18T18:52:40.540Z'
last_login: null
- last_password_change: '2024-09-17T12:19:43.686Z'
+ last_password_change: '2024-09-18T18:52:40.540Z'
name: ''
- password: md5$CwXP9zRuhOuh3ahhQTX1KM$c2279239b24eebb589eb49c17d8bc7cb
+ password: md5$FF6Ruy37ZFJ7fRrePpNRjO$509890650edda41af81d4428a04ce592
session_nonce: null
- username: f898705a0fdb43f2b08fca779495514d@example.com
+ username: 17e4af6a9e4646eca56aa7d8e1878d8d@example.com
model: sentry.user
pk: 10
- fields:
@@ -396,54 +396,54 @@ source: tests/sentry/backup/test_releases.py
model: sentry.userpermission
pk: 1
- fields:
- date_added: '2024-09-17T12:19:42.631Z'
- date_updated: '2024-09-17T12:19:42.631Z'
+ date_added: '2024-09-18T18:52:37.931Z'
+ date_updated: '2024-09-18T18:52:37.931Z'
name: test-admin-role
permissions: '[]'
model: sentry.userrole
pk: 1
- fields:
- date_added: '2024-09-17T12:19:42.635Z'
- date_updated: '2024-09-17T12:19:42.635Z'
+ date_added: '2024-09-18T18:52:37.936Z'
+ date_updated: '2024-09-18T18:52:37.936Z'
role: 1
user: 1
model: sentry.userroleuser
pk: 1
- fields:
- date_added: '2024-09-17T12:19:43.439Z'
- date_updated: '2024-09-17T12:19:43.439Z'
- organization: 4554716211970048
- query_id: 2902
+ date_added: '2024-09-18T18:52:40.183Z'
+ date_updated: '2024-09-18T18:52:40.183Z'
+ organization: 4554723419422720
+ query_id: 6909
type: 1
model: workflow_engine.datasource
pk: 1
- fields:
- date_added: '2024-09-17T12:19:43.441Z'
- date_updated: '2024-09-17T12:19:43.441Z'
- name: Guided Chow
- organization: 4554716211970048
+ date_added: '2024-09-18T18:52:40.184Z'
+ date_updated: '2024-09-18T18:52:40.184Z'
+ name: Legible Mule
+ organization: 4554723419422720
owner_team: null
owner_user_id: null
model: workflow_engine.detector
pk: 1
- fields:
- date_added: '2024-09-17T12:19:43.434Z'
- date_updated: '2024-09-17T12:19:43.434Z'
- name: Moved Grackle
- organization: 4554716211970048
+ date_added: '2024-09-18T18:52:40.179Z'
+ date_updated: '2024-09-18T18:52:40.179Z'
+ name: Modest Katydid
+ organization: 4554723419422720
model: workflow_engine.workflow
pk: 1
- fields:
- date_added: '2024-09-17T12:19:43.438Z'
- date_updated: '2024-09-17T12:19:43.438Z'
- name: United Sunfish
- organization: 4554716211970048
+ date_added: '2024-09-18T18:52:40.182Z'
+ date_updated: '2024-09-18T18:52:40.182Z'
+ name: Hardy Bream
+ organization: 4554723419422720
model: workflow_engine.workflow
pk: 2
- fields:
data: {}
- date_added: '2024-09-17T12:19:43.436Z'
- date_updated: '2024-09-17T12:19:43.436Z'
+ date_added: '2024-09-18T18:52:40.180Z'
+ date_updated: '2024-09-18T18:52:40.180Z'
required: false
type: ''
workflow: 1
@@ -451,16 +451,16 @@ source: tests/sentry/backup/test_releases.py
pk: 1
- fields:
data_source: 1
- date_added: '2024-09-17T12:19:43.443Z'
- date_updated: '2024-09-17T12:19:43.443Z'
+ date_added: '2024-09-18T18:52:40.185Z'
+ date_updated: '2024-09-18T18:52:40.185Z'
detector: 1
model: workflow_engine.datasourcedetector
pk: 1
- fields:
- date_added: '2024-09-17T12:19:43.404Z'
+ date_added: '2024-09-18T18:52:40.151Z'
is_global: false
name: Saved query for test-org
- organization: 4554716211970048
+ organization: 4554723419422720
owner_id: 2
query: saved query for test-org
sort: date
@@ -469,9 +469,9 @@ source: tests/sentry/backup/test_releases.py
model: sentry.savedsearch
pk: 1
- fields:
- date_added: '2024-09-17T12:19:43.403Z'
- last_seen: '2024-09-17T12:19:43.403Z'
- organization: 4554716211970048
+ date_added: '2024-09-18T18:52:40.150Z'
+ last_seen: '2024-09-18T18:52:40.150Z'
+ organization: 4554723419422720
query: some query for test-org
query_hash: 7c69362cd42207b83f80087bc15ebccb
type: 0
@@ -479,82 +479,82 @@ source: tests/sentry/backup/test_releases.py
model: sentry.recentsearch
pk: 1
- fields:
- date_added: '2024-09-17T12:19:43.154Z'
+ date_added: '2024-09-18T18:52:39.791Z'
first_event: null
flags: '10'
forced_color: null
name: project-test-org
- organization: 4554716211970048
+ organization: 4554723419422720
platform: null
public: false
slug: project-test-org
status: 0
template: null
model: sentry.project
- pk: 4554716212035585
+ pk: 4554723419422722
- fields:
- date_added: '2024-09-17T12:19:43.315Z'
+ date_added: '2024-09-18T18:52:40.025Z'
first_event: null
flags: '10'
forced_color: null
name: other-project-test-org
- organization: 4554716211970048
+ organization: 4554723419422720
platform: null
public: false
slug: other-project-test-org
status: 0
template: null
model: sentry.project
- pk: 4554716212035586
+ pk: 4554723419488256
- fields:
- date_added: '2024-09-17T12:19:43.513Z'
+ date_added: '2024-09-18T18:52:40.284Z'
first_event: null
flags: '10'
forced_color: null
- name: Credible Bobcat
- organization: 4554716211970048
+ name: Sunny Reindeer
+ organization: 4554723419422720
platform: null
public: false
- slug: credible-bobcat
+ slug: sunny-reindeer
status: 0
template: null
model: sentry.project
- pk: 4554716212035588
+ pk: 4554723419488258
- fields:
- date_added: '2024-09-17T12:19:43.697Z'
+ date_added: '2024-09-18T18:52:40.555Z'
first_event: null
flags: '10'
forced_color: null
- name: Splendid Gobbler
- organization: 4554716211970048
+ name: Full Koala
+ organization: 4554723419422720
platform: null
public: false
- slug: splendid-gobbler
+ slug: full-koala
status: 0
template: null
model: sentry.project
- pk: 4554716212035589
+ pk: 4554723419488259
- fields:
created_by: 2
- date_added: '2024-09-17T12:19:43.264Z'
+ date_added: '2024-09-18T18:52:39.925Z'
date_deactivated: null
date_last_used: null
name: token 1 for test-org
- organization_id: 4554716211970048
- project_last_used_id: 4554716212035585
+ organization_id: 4554723419422720
+ project_last_used_id: 4554723419422722
scope_list: '[''org:ci'']'
token_hashed: ABCDEFtest-org
token_last_characters: xyz1
model: sentry.orgauthtoken
pk: 1
- fields:
- date_added: '2024-09-17T12:19:42.792Z'
+ date_added: '2024-09-18T18:52:39.218Z'
email: null
flags: '0'
has_global_access: true
invite_status: 0
inviter_id: null
- organization: 4554716211970048
+ organization: 4554723419422720
role: owner
token: null
token_expires_at: null
@@ -565,13 +565,13 @@ source: tests/sentry/backup/test_releases.py
model: sentry.organizationmember
pk: 1
- fields:
- date_added: '2024-09-17T12:19:42.841Z'
+ date_added: '2024-09-18T18:52:39.286Z'
email: null
flags: '0'
has_global_access: true
invite_status: 0
inviter_id: null
- organization: 4554716211970048
+ organization: 4554723419422720
role: member
token: null
token_expires_at: null
@@ -582,13 +582,13 @@ source: tests/sentry/backup/test_releases.py
model: sentry.organizationmember
pk: 2
- fields:
- date_added: '2024-09-17T12:19:42.890Z'
+ date_added: '2024-09-18T18:52:39.374Z'
email: invited-by-superadmin-not-in-org@example.com
flags: '0'
has_global_access: true
invite_status: 1
inviter_id: 1
- organization: 4554716211970048
+ organization: 4554723419422720
role: member
token: null
token_expires_at: null
@@ -599,13 +599,13 @@ source: tests/sentry/backup/test_releases.py
model: sentry.organizationmember
pk: 3
- fields:
- date_added: '2024-09-17T12:19:42.912Z'
+ date_added: '2024-09-18T18:52:39.401Z'
email: invited-by-org-owner@example.com
flags: '0'
has_global_access: true
invite_status: 1
inviter_id: 2
- organization: 4554716211970048
+ organization: 4554723419422720
role: member
token: null
token_expires_at: null
@@ -616,13 +616,13 @@ source: tests/sentry/backup/test_releases.py
model: sentry.organizationmember
pk: 4
- fields:
- date_added: '2024-09-17T12:19:42.930Z'
+ date_added: '2024-09-18T18:52:39.428Z'
email: invited-by-org-member@example.com
flags: '0'
has_global_access: true
invite_status: 1
inviter_id: 3
- organization: 4554716211970048
+ organization: 4554723419422720
role: member
token: null
token_expires_at: null
@@ -633,13 +633,13 @@ source: tests/sentry/backup/test_releases.py
model: sentry.organizationmember
pk: 5
- fields:
- date_added: '2024-09-17T12:19:42.948Z'
+ date_added: '2024-09-18T18:52:39.453Z'
email: null
flags: '0'
has_global_access: true
invite_status: 0
inviter_id: 1
- organization: 4554716211970048
+ organization: 4554723419422720
role: member
token: null
token_expires_at: null
@@ -650,13 +650,13 @@ source: tests/sentry/backup/test_releases.py
model: sentry.organizationmember
pk: 6
- fields:
- date_added: '2024-09-17T12:19:42.997Z'
+ date_added: '2024-09-18T18:52:39.528Z'
email: null
flags: '0'
has_global_access: true
invite_status: 0
inviter_id: 2
- organization: 4554716211970048
+ organization: 4554723419422720
role: member
token: null
token_expires_at: null
@@ -667,13 +667,13 @@ source: tests/sentry/backup/test_releases.py
model: sentry.organizationmember
pk: 7
- fields:
- date_added: '2024-09-17T12:19:43.052Z'
+ date_added: '2024-09-18T18:52:39.598Z'
email: null
flags: '0'
has_global_access: true
invite_status: 0
inviter_id: 3
- organization: 4554716211970048
+ organization: 4554723419422720
role: member
token: null
token_expires_at: null
@@ -686,31 +686,31 @@ source: tests/sentry/backup/test_releases.py
- fields:
member: 2
requester_id: 2
- team: 4554716212035584
+ team: 4554723419422721
model: sentry.organizationaccessrequest
pk: 1
- fields:
config:
schedule: '* * * * *'
schedule_type: 1
- date_added: '2024-09-17T12:19:43.311Z'
- guid: 9b78eb24-9b37-429f-85ca-51b6b58d97fa
+ date_added: '2024-09-18T18:52:40.020Z'
+ guid: 972dd900-cadf-4d1d-9cee-ecea8ebfc72e
is_muted: false
name: ''
- organization_id: 4554716211970048
+ organization_id: 4554723419422720
owner_team_id: null
owner_user_id: 2
- project_id: 4554716212035585
- slug: ae6d7bfd7d57
+ project_id: 4554723419422722
+ slug: 5e5eb869a9c0
status: 0
type: 3
model: sentry.monitor
pk: 1
- fields:
- date_added: '2024-09-17T12:19:43.420Z'
- date_updated: '2024-09-17T12:19:43.420Z'
+ date_added: '2024-09-18T18:52:40.168Z'
+ date_updated: '2024-09-18T18:52:40.168Z'
name: View 1 for test-org
- organization: 4554716211970048
+ organization: 4554723419422720
position: 0
query: some query for test-org
query_sort: date
@@ -718,116 +718,116 @@ source: tests/sentry/backup/test_releases.py
model: sentry.groupsearchview
pk: 1
- fields:
- date_added: '2024-09-17T12:19:43.307Z'
- name: carefully enabled peacock
- organization_id: 4554716211970048
+ date_added: '2024-09-18T18:52:40.016Z'
+ name: preferably casual porpoise
+ organization_id: 4554723419422720
model: sentry.environment
pk: 1
- fields:
- date_added: '2024-09-17T12:19:42.612Z'
+ date_added: '2024-09-18T18:52:37.909Z'
email: superadmin
model: sentry.email
pk: 1
- fields:
- date_added: '2024-09-17T12:19:42.671Z'
+ date_added: '2024-09-18T18:52:39.033Z'
email: owner
model: sentry.email
pk: 2
- fields:
- date_added: '2024-09-17T12:19:42.686Z'
+ date_added: '2024-09-18T18:52:39.063Z'
email: member
model: sentry.email
pk: 3
- fields:
- date_added: '2024-09-17T12:19:42.701Z'
+ date_added: '2024-09-18T18:52:39.084Z'
email: added-by-superadmin-not-in-org
model: sentry.email
pk: 4
- fields:
- date_added: '2024-09-17T12:19:42.717Z'
+ date_added: '2024-09-18T18:52:39.104Z'
email: added-by-org-owner
model: sentry.email
pk: 5
- fields:
- date_added: '2024-09-17T12:19:42.732Z'
+ date_added: '2024-09-18T18:52:39.124Z'
email: added-by-org-member
model: sentry.email
pk: 6
- fields:
- date_added: '2024-09-17T12:19:43.351Z'
+ date_added: '2024-09-18T18:52:40.082Z'
email: admin@localhost
model: sentry.email
pk: 7
- fields:
- date_added: '2024-09-17T12:19:43.449Z'
- email: 2eff8974c8f64f5e9a8745ac7eb11074@example.com
+ date_added: '2024-09-18T18:52:40.192Z'
+ email: 67d6dbedf685436cae760f9d11c77f12@example.com
model: sentry.email
pk: 8
- fields:
- date_added: '2024-09-17T12:19:43.503Z'
+ date_added: '2024-09-18T18:52:40.266Z'
email: ''
model: sentry.email
pk: 9
- fields:
- date_added: '2024-09-17T12:19:43.690Z'
- email: f898705a0fdb43f2b08fca779495514d@example.com
+ date_added: '2024-09-18T18:52:40.545Z'
+ email: 17e4af6a9e4646eca56aa7d8e1878d8d@example.com
model: sentry.email
pk: 10
- fields:
- access_end: '2024-09-18T12:19:43.432Z'
- access_start: '2024-09-17T12:19:43.432Z'
- date_added: '2024-09-17T12:19:43.432Z'
- date_updated: '2024-09-17T12:19:43.432Z'
- organization: 4554716211970048
+ access_end: '2024-09-19T18:52:40.178Z'
+ access_start: '2024-09-18T18:52:40.178Z'
+ date_added: '2024-09-18T18:52:40.178Z'
+ date_updated: '2024-09-18T18:52:40.178Z'
+ organization: 4554723419422720
zendesk_tickets: '[]'
model: sentry.datasecrecywaiver
pk: 1
- fields:
- date_added: '2024-09-17T12:19:43.402Z'
- organization: 4554716211970048
+ date_added: '2024-09-18T18:52:40.149Z'
+ organization: 4554723419422720
slug: test-tombstone-in-test-org
model: sentry.dashboardtombstone
pk: 1
- fields:
created_by_id: 2
- date_added: '2024-09-17T12:19:43.396Z'
+ date_added: '2024-09-18T18:52:40.144Z'
filters: null
- last_visited: '2024-09-17T12:19:43.396Z'
- organization: 4554716211970048
+ last_visited: '2024-09-18T18:52:40.144Z'
+ organization: 4554723419422720
title: Dashboard 1 for test-org
visits: 1
model: sentry.dashboard
pk: 1
- fields:
condition: '{"op":"equals","name":"environment","value":"prod"}'
- condition_hash: 1000a9117a89ab7822bb2c2838df77dc6b242f3d
+ condition_hash: 7461d5c2dffcb2812dc8523975ca822ef3deab07
created_by_id: 2
- date_added: '2024-09-17T12:19:43.303Z'
- end_date: '2024-09-17T13:19:43.300Z'
+ date_added: '2024-09-18T18:52:40.006Z'
+ end_date: '2024-09-18T19:52:40.002Z'
is_active: true
is_org_level: false
notification_sent: false
num_samples: 100
- organization: 4554716211970048
+ organization: 4554723419422720
query: environment:prod event.type:transaction
rule_id: 1
sample_rate: 0.5
- start_date: '2024-09-17T12:19:43.300Z'
+ start_date: '2024-09-18T18:52:40.002Z'
model: sentry.customdynamicsamplingrule
pk: 1
- fields:
- project: 4554716212035585
+ project: 4554723419422722
value: 2
model: sentry.counter
pk: 1
- fields:
config: {}
- date_added: '2024-09-17T12:19:43.208Z'
+ date_added: '2024-09-18T18:52:39.867Z'
default_global_access: true
default_role: 50
flags: '0'
last_sync: null
- organization_id: 4554716211970048
+ organization_id: 4554723419422720
provider: sentry
sync_time: null
model: sentry.authprovider
@@ -843,16 +843,16 @@ source: tests/sentry/backup/test_releases.py
- 3
key4:
nested_key: nested_value
- date_added: '2024-09-17T12:19:43.233Z'
+ date_added: '2024-09-18T18:52:39.895Z'
ident: 123456789test-org
- last_synced: '2024-09-17T12:19:43.233Z'
- last_verified: '2024-09-17T12:19:43.233Z'
+ last_synced: '2024-09-18T18:52:39.895Z'
+ last_verified: '2024-09-18T18:52:39.895Z'
user: 2
model: sentry.authidentity
pk: 1
- fields:
config: '""'
- created_at: '2024-09-17T12:19:42.623Z'
+ created_at: '2024-09-18T18:52:37.920Z'
last_used_at: null
type: 1
user: 1
@@ -860,7 +860,7 @@ source: tests/sentry/backup/test_releases.py
pk: 1
- fields:
config: '""'
- created_at: '2024-09-17T12:19:42.679Z'
+ created_at: '2024-09-18T18:52:39.053Z'
last_used_at: null
type: 1
user: 2
@@ -868,7 +868,7 @@ source: tests/sentry/backup/test_releases.py
pk: 2
- fields:
config: '""'
- created_at: '2024-09-17T12:19:42.694Z'
+ created_at: '2024-09-18T18:52:39.074Z'
last_used_at: null
type: 1
user: 3
@@ -876,7 +876,7 @@ source: tests/sentry/backup/test_releases.py
pk: 3
- fields:
config: '""'
- created_at: '2024-09-17T12:19:42.710Z'
+ created_at: '2024-09-18T18:52:39.094Z'
last_used_at: null
type: 1
user: 4
@@ -884,7 +884,7 @@ source: tests/sentry/backup/test_releases.py
pk: 4
- fields:
config: '""'
- created_at: '2024-09-17T12:19:42.726Z'
+ created_at: '2024-09-18T18:52:39.114Z'
last_used_at: null
type: 1
user: 5
@@ -892,7 +892,7 @@ source: tests/sentry/backup/test_releases.py
pk: 5
- fields:
config: '""'
- created_at: '2024-09-17T12:19:42.741Z'
+ created_at: '2024-09-18T18:52:39.134Z'
last_used_at: null
type: 1
user: 6
@@ -900,10 +900,10 @@ source: tests/sentry/backup/test_releases.py
pk: 6
- fields:
allowed_origins: null
- date_added: '2024-09-17T12:19:43.185Z'
- key: be9a13686340469b8389809a56d54a7c
+ date_added: '2024-09-18T18:52:39.843Z'
+ key: 76ffaca4def94a40b95a2938bb1b4e77
label: Default
- organization_id: 4554716211970048
+ organization_id: 4554723419422720
scope_list: '[]'
scopes: '0'
status: 0
@@ -911,11 +911,11 @@ source: tests/sentry/backup/test_releases.py
pk: 1
- fields:
allowed_origins: ''
- client_id: 913a46ab2f0efe93826d4c925697c2f5b3330e4cec18ce8a9ee0758edc589886
- client_secret: af0db82cece865049997b4f54bc6b499b0f5db131325e64516e5cc968d86a1c1
- date_added: '2024-09-17T12:19:43.506Z'
+ client_id: 9138dbcecb78c21b22487209e65011d9d1fdbdd79b1c73d7aaf05a0f85f13270
+ client_secret: b0823e481cb452b125aa28fc5512812af2f60a5f195f73232267a3b2a2e1352c
+ date_added: '2024-09-18T18:52:40.273Z'
homepage_url: null
- name: Vocal Mollusk
+ name: Enough Gobbler
owner: 9
privacy_url: null
redirect_uris: ''
@@ -974,89 +974,89 @@ source: tests/sentry/backup/test_releases.py
model: sentry.useroption
pk: 6
- fields:
- date_hash_added: '2024-09-17T12:19:42.610Z'
+ date_hash_added: '2024-09-18T18:52:37.906Z'
email: superadmin
is_verified: true
user: 1
- validation_hash: KBtBf4oWdwlRRCnHxoqLmVEnvKlLnRm2
+ validation_hash: oW9D4yvvlZKEjJdnd7dIqiuajOTwGMDY
model: sentry.useremail
pk: 1
- fields:
- date_hash_added: '2024-09-17T12:19:42.669Z'
+ date_hash_added: '2024-09-18T18:52:39.026Z'
email: owner
is_verified: true
user: 2
- validation_hash: zYPSn6Cyi0hSraZzq6goANhDaUkM5NMQ
+ validation_hash: IRKTOxnMpTm2bwJvOOKMRsGg5z8cTvyz
model: sentry.useremail
pk: 2
- fields:
- date_hash_added: '2024-09-17T12:19:42.684Z'
+ date_hash_added: '2024-09-18T18:52:39.060Z'
email: member
is_verified: true
user: 3
- validation_hash: 73fghDIXN35Cp6dlzSMYIDTFt4kxwQIO
+ validation_hash: PSvYIYCLQm0R6AupoLGMerf2WWbJmC9r
model: sentry.useremail
pk: 3
- fields:
- date_hash_added: '2024-09-17T12:19:42.700Z'
+ date_hash_added: '2024-09-18T18:52:39.081Z'
email: added-by-superadmin-not-in-org
is_verified: true
user: 4
- validation_hash: bmVqeJEjIPJoP3p6xsuMtNJ9qYBNlOJJ
+ validation_hash: 4JJ0MFFYHue5839eG5s7jHnWbhO6Rdzq
model: sentry.useremail
pk: 4
- fields:
- date_hash_added: '2024-09-17T12:19:42.715Z'
+ date_hash_added: '2024-09-18T18:52:39.101Z'
email: added-by-org-owner
is_verified: true
user: 5
- validation_hash: Zf555XQYZiYziO3ABHDbYdNt3McFP66b
+ validation_hash: nAEyCwhOKlaDTDx0Ro4uuOEwrrzWYsw4
model: sentry.useremail
pk: 5
- fields:
- date_hash_added: '2024-09-17T12:19:42.731Z'
+ date_hash_added: '2024-09-18T18:52:39.121Z'
email: added-by-org-member
is_verified: true
user: 6
- validation_hash: ctjSLBQiayOv5qWn6HEjJXKz6faYEFkA
+ validation_hash: 9SpSRZ8QOncvtifvPpbo1x4Bnxvgymjv
model: sentry.useremail
pk: 6
- fields:
- date_hash_added: '2024-09-17T12:19:43.350Z'
+ date_hash_added: '2024-09-18T18:52:40.080Z'
email: admin@localhost
is_verified: true
user: 7
- validation_hash: VWuDS3i2ifW3MULUxebE8zjZvIs6rA4i
+ validation_hash: T7JDiCEOAbvJynixAG0a5kxXRKe8Sb4s
model: sentry.useremail
pk: 7
- fields:
- date_hash_added: '2024-09-17T12:19:43.447Z'
- email: 2eff8974c8f64f5e9a8745ac7eb11074@example.com
+ date_hash_added: '2024-09-18T18:52:40.189Z'
+ email: 67d6dbedf685436cae760f9d11c77f12@example.com
is_verified: true
user: 8
- validation_hash: UEwNJxw6WeFSaBl1jGCq1RZcXYWNb1Fr
+ validation_hash: Rj36JeUhvek6PNhM8w562hgoCcti1GZR
model: sentry.useremail
pk: 8
- fields:
- date_hash_added: '2024-09-17T12:19:43.502Z'
+ date_hash_added: '2024-09-18T18:52:40.263Z'
email: ''
is_verified: false
user: 9
- validation_hash: OPswEGQChv3cOjz6ySAeOdu7JlOSGw8q
+ validation_hash: ovwaat0Y9q73Hpa8kE3jG66dsP68n7Qv
model: sentry.useremail
pk: 9
- fields:
- date_hash_added: '2024-09-17T12:19:43.688Z'
- email: f898705a0fdb43f2b08fca779495514d@example.com
+ date_hash_added: '2024-09-18T18:52:40.543Z'
+ email: 17e4af6a9e4646eca56aa7d8e1878d8d@example.com
is_verified: true
user: 10
- validation_hash: 3R81a566z64bvAq2lcYAodfU04Xj0TlJ
+ validation_hash: OzrbCyvN9xqJvkv1BdxxSFOZRxwtYTXK
model: sentry.useremail
pk: 10
- fields:
aggregate: count()
dataset: events
- date_added: '2024-09-17T12:19:43.330Z'
+ date_added: '2024-09-18T18:52:40.050Z'
environment: null
query: level:error
resolution: 60
@@ -1067,7 +1067,7 @@ source: tests/sentry/backup/test_releases.py
- fields:
aggregate: count()
dataset: events
- date_added: '2024-09-17T12:19:43.359Z'
+ date_added: '2024-09-18T18:52:40.103Z'
environment: null
query: level:error
resolution: 60
@@ -1078,7 +1078,7 @@ source: tests/sentry/backup/test_releases.py
- fields:
aggregate: count()
dataset: events
- date_added: '2024-09-17T12:19:43.376Z'
+ date_added: '2024-09-18T18:52:40.122Z'
environment: null
query: test query
resolution: 60
@@ -1089,18 +1089,18 @@ source: tests/sentry/backup/test_releases.py
- fields:
application: 1
author: A Company
- creator_label: 2eff8974c8f64f5e9a8745ac7eb11074@example.com
+ creator_label: 67d6dbedf685436cae760f9d11c77f12@example.com
creator_user: 8
- date_added: '2024-09-17T12:19:43.507Z'
+ date_added: '2024-09-18T18:52:40.274Z'
date_deleted: null
date_published: null
- date_updated: '2024-09-17T12:19:43.647Z'
+ date_updated: '2024-09-18T18:52:40.489Z'
events: '[]'
is_alertable: false
metadata: {}
name: test app
overview: A sample description
- owner_id: 4554716211970048
+ owner_id: 4554723419422720
popularity: 1
proxy_user: 9
redirect_url: https://example.com/sentry-app/redirect/
@@ -1141,27 +1141,27 @@ source: tests/sentry/backup/test_releases.py
scopes: '0'
slug: test-app
status: 0
- uuid: 31888f02-8afa-41f5-9fb7-06a9b9b525ba
+ uuid: 9e7a7a75-c220-43a5-bc60-8d3b4c2fe737
verify_install: true
webhook_url: https://example.com/sentry-app/webhook/
model: sentry.sentryapp
pk: 1
- fields:
data: '{"conditions":[{"id":"sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"},{"id":"sentry.rules.conditions.every_event.EveryEventCondition"}],"action_match":"all","filter_match":"all","actions":[{"id":"sentry.rules.actions.notify_event.NotifyEventAction"},{"id":"sentry.rules.actions.notify_event_service.NotifyEventServiceAction","service":"mail"}]}'
- date_added: '2024-09-17T12:19:43.293Z'
+ date_added: '2024-09-18T18:52:39.995Z'
environment_id: null
label: ''
owner_team: null
owner_user_id: 2
- project: 4554716212035585
+ project: 4554723419422722
source: 0
status: 0
model: sentry.rule
pk: 1
- fields:
- date_added: '2024-09-17T12:19:43.338Z'
- date_updated: '2024-09-17T12:19:43.338Z'
- project: 4554716212035585
+ date_added: '2024-09-18T18:52:40.063Z'
+ date_updated: '2024-09-18T18:52:40.063Z'
+ project: 4554723419422722
query_extra: null
snuba_query: 1
status: 1
@@ -1170,9 +1170,9 @@ source: tests/sentry/backup/test_releases.py
model: sentry.querysubscription
pk: 1
- fields:
- date_added: '2024-09-17T12:19:43.365Z'
- date_updated: '2024-09-17T12:19:43.365Z'
- project: 4554716212035585
+ date_added: '2024-09-18T18:52:40.112Z'
+ date_updated: '2024-09-18T18:52:40.112Z'
+ project: 4554723419422722
query_extra: null
snuba_query: 2
status: 1
@@ -1181,9 +1181,9 @@ source: tests/sentry/backup/test_releases.py
model: sentry.querysubscription
pk: 2
- fields:
- date_added: '2024-09-17T12:19:43.379Z'
- date_updated: '2024-09-17T12:19:43.379Z'
- project: 4554716212035585
+ date_added: '2024-09-18T18:52:40.127Z'
+ date_updated: '2024-09-18T18:52:40.127Z'
+ project: 4554723419422722
query_extra: null
snuba_query: 3
status: 1
@@ -1192,9 +1192,9 @@ source: tests/sentry/backup/test_releases.py
model: sentry.querysubscription
pk: 3
- fields:
- date_added: '2024-09-17T12:19:43.515Z'
- date_updated: '2024-09-17T12:19:43.515Z'
- project: 4554716212035588
+ date_added: '2024-09-18T18:52:40.288Z'
+ date_updated: '2024-09-18T18:52:40.288Z'
+ project: 4554723419488258
query_extra: null
snuba_query: 1
status: 1
@@ -1203,9 +1203,9 @@ source: tests/sentry/backup/test_releases.py
model: sentry.querysubscription
pk: 4
- fields:
- date_added: '2024-09-17T12:19:43.700Z'
- date_updated: '2024-09-17T12:19:43.700Z'
- project: 4554716212035589
+ date_added: '2024-09-18T18:52:40.559Z'
+ date_updated: '2024-09-18T18:52:40.559Z'
+ project: 4554723419488259
query_extra: null
snuba_query: 1
status: 1
@@ -1214,30 +1214,30 @@ source: tests/sentry/backup/test_releases.py
model: sentry.querysubscription
pk: 5
- fields:
- project: 4554716212035585
- team: 4554716212035584
+ project: 4554723419422722
+ team: 4554723419422721
model: sentry.projectteam
pk: 1
- fields:
- project: 4554716212035586
- team: 4554716212035584
+ project: 4554723419488256
+ team: 4554723419422721
model: sentry.projectteam
pk: 2
- fields:
- date_added: '2024-09-17T12:19:43.177Z'
- organization: 4554716211970048
- project: 4554716212035585
+ date_added: '2024-09-18T18:52:39.834Z'
+ organization: 4554723419422720
+ project: 4554723419422722
redirect_slug: project_slug_in_test-org
model: sentry.projectredirect
pk: 1
- fields:
auto_assignment: true
codeowners_auto_sync: true
- date_created: '2024-09-17T12:19:43.172Z'
+ date_created: '2024-09-18T18:52:39.829Z'
fallthrough: true
is_active: true
- last_updated: '2024-09-17T12:19:43.172Z'
- project: 4554716212035585
+ last_updated: '2024-09-18T18:52:39.829Z'
+ project: 4554723419422722
raw: '{"hello":"hello"}'
schema:
hello: hello
@@ -1246,25 +1246,25 @@ source: tests/sentry/backup/test_releases.py
pk: 1
- fields:
key: sentry:option-epoch
- project: 4554716212035585
+ project: 4554723419422722
value: 13
model: sentry.projectoption
pk: 1
- fields:
key: sentry:option-epoch
- project: 4554716212035586
+ project: 4554723419488256
value: 13
model: sentry.projectoption
pk: 2
- fields:
key: sentry:option-epoch
- project: 4554716212035588
+ project: 4554723419488258
value: 13
model: sentry.projectoption
pk: 3
- fields:
key: sentry:option-epoch
- project: 4554716212035589
+ project: 4554723419488259
value: 13
model: sentry.projectoption
pk: 4
@@ -1273,14 +1273,14 @@ source: tests/sentry/backup/test_releases.py
dynamicSdkLoaderOptions:
hasPerformance: true
hasReplay: true
- date_added: '2024-09-17T12:19:43.161Z'
+ date_added: '2024-09-18T18:52:39.811Z'
label: Default
- project: 4554716212035585
- public_key: cd413c7e06e9ca9cf935330ad1f462c9
+ project: 4554723419422722
+ public_key: ffb759fd3fa5cdba05e4b3e5c1e80576
rate_limit_count: null
rate_limit_window: null
roles: '1'
- secret_key: 8d6b9cd590ad070acc9e39cfca1e7332
+ secret_key: 701bf9cca5be3972601bd8a57e47d67a
status: 0
use_case: user
model: sentry.projectkey
@@ -1290,14 +1290,14 @@ source: tests/sentry/backup/test_releases.py
dynamicSdkLoaderOptions:
hasPerformance: true
hasReplay: true
- date_added: '2024-09-17T12:19:43.322Z'
+ date_added: '2024-09-18T18:52:40.038Z'
label: Default
- project: 4554716212035586
- public_key: c65ab9038bd7eafeb38c62dc4943f9ad
+ project: 4554723419488256
+ public_key: 356b5fc3b9631ae8105f3b257ad0455a
rate_limit_count: null
rate_limit_window: null
roles: '1'
- secret_key: b4058b7de11c1c231372c25fad8a7228
+ secret_key: ec25e702c12b0b47905c1f8ee68f317e
status: 0
use_case: user
model: sentry.projectkey
@@ -1307,14 +1307,14 @@ source: tests/sentry/backup/test_releases.py
dynamicSdkLoaderOptions:
hasPerformance: true
hasReplay: true
- date_added: '2024-09-17T12:19:43.522Z'
+ date_added: '2024-09-18T18:52:40.298Z'
label: Default
- project: 4554716212035588
- public_key: 954daab23c1a191a0921d0eb47d235e3
+ project: 4554723419488258
+ public_key: e7ae377dcd32d4ffeb6c68d9bab021c6
rate_limit_count: null
rate_limit_window: null
roles: '1'
- secret_key: 32e6e914815e0447aca18357b7ade8ad
+ secret_key: af1b13cf65cd80bfb66dc0785306516e
status: 0
use_case: user
model: sentry.projectkey
@@ -1324,14 +1324,14 @@ source: tests/sentry/backup/test_releases.py
dynamicSdkLoaderOptions:
hasPerformance: true
hasReplay: true
- date_added: '2024-09-17T12:19:43.707Z'
+ date_added: '2024-09-18T18:52:40.570Z'
label: Default
- project: 4554716212035589
- public_key: 440f12a29793a921c2489cb008aae759
+ project: 4554723419488259
+ public_key: 8d8bb1908e72baddea183cee7657653f
rate_limit_count: null
rate_limit_window: null
roles: '1'
- secret_key: 4007f8bd2b3fb24371aeb2c11fc8c737
+ secret_key: 88823b8897648bc98ed239a164403978
status: 0
use_case: user
model: sentry.projectkey
@@ -1340,12 +1340,12 @@ source: tests/sentry/backup/test_releases.py
config:
hello: hello
integration_id: 1
- project: 4554716212035585
+ project: 4554723419422722
model: sentry.projectintegration
pk: 1
- fields:
- date_added: '2024-09-17T12:19:43.170Z'
- project: 4554716212035585
+ date_added: '2024-09-18T18:52:39.828Z'
+ project: 4554723419422722
user_id: 2
model: sentry.projectbookmark
pk: 1
@@ -1353,12 +1353,12 @@ source: tests/sentry/backup/test_releases.py
is_active: true
organizationmember: 1
role: null
- team: 4554716212035584
+ team: 4554723419422721
model: sentry.organizationmemberteam
pk: 1
- fields:
integration_id: null
- organization: 4554716211970048
+ organization: 4554723419422720
sentry_app_id: null
target_display: Sentry User
target_identifier: '1'
@@ -1369,7 +1369,7 @@ source: tests/sentry/backup/test_releases.py
pk: 1
- fields:
integration_id: null
- organization: 4554716211970048
+ organization: 4554723419422720
sentry_app_id: 1
target_display: Sentry User
target_identifier: '1'
@@ -1379,24 +1379,24 @@ source: tests/sentry/backup/test_releases.py
model: sentry.notificationaction
pk: 2
- fields:
- disable_date: '2024-09-17T12:19:43.298Z'
+ disable_date: '2024-09-18T18:52:40.000Z'
opted_out: false
- organization: 4554716211970048
+ organization: 4554723419422720
rule: 1
- sent_final_email_date: '2024-09-17T12:19:43.298Z'
- sent_initial_email_date: '2024-09-17T12:19:43.298Z'
+ sent_final_email_date: '2024-09-18T18:52:40.000Z'
+ sent_initial_email_date: '2024-09-18T18:52:40.000Z'
model: sentry.neglectedrule
pk: 1
- fields:
environment: 1
is_hidden: null
- project: 4554716212035585
+ project: 4554723419422722
model: sentry.environmentproject
pk: 1
- fields:
dashboard: 1
dataset_source: 0
- date_added: '2024-09-17T12:19:43.398Z'
+ date_added: '2024-09-18T18:52:40.145Z'
description: null
detail: null
discover_widget_split: null
@@ -1411,60 +1411,60 @@ source: tests/sentry/backup/test_releases.py
pk: 1
- fields:
custom_dynamic_sampling_rule: 1
- project: 4554716212035585
+ project: 4554723419422722
model: sentry.customdynamicsamplingruleproject
pk: 1
- fields:
application: 1
- date_added: '2024-09-17T12:19:43.605Z'
- expires_at: '2024-09-17T20:19:43.605Z'
- hashed_refresh_token: 4d79d17e0837e992a531f18d26ccc87363241c6c0439c3a414bb867c138f30c9
- hashed_token: 4734d1e82fb505707a88a4818ca9823038547f4449a04f46f1e6400283a57248
+ date_added: '2024-09-18T18:52:40.421Z'
+ expires_at: '2024-09-19T02:52:40.421Z'
+ hashed_refresh_token: d07f031b3c0915ee0fc41f10dca0148b1b063dc589a55978757afa7fca479e7c
+ hashed_token: 6ee13b16273045202aa1c6ead6870cfc4d4dd252908ac12bd1a26b8a99961d40
name: null
- refresh_token: 225cd0b451c85b55bcddfec05fb27c453034c03efff163e40a5c0310634c3c3a
+ refresh_token: 426a27a9bb83c9f53640eb2a7c8bb3f79d7f356d46abcfd090b6a8b698de4f30
scope_list: '[]'
scopes: '0'
- token: 8dba5a68962bd13d0ae158e576df42a858304a37133206d0ad064df1cb32fe38
- token_last_characters: fe38
+ token: d99d69737f1bd0a149eab3c8fd003eeba75cccb585b53b543c3ac7be32cf1c0a
+ token_last_characters: 1c0a
token_type: null
user: 9
model: sentry.apitoken
pk: 1
- fields:
application: 1
- date_added: '2024-09-17T12:19:43.661Z'
+ date_added: '2024-09-18T18:52:40.510Z'
expires_at: null
- hashed_refresh_token: ab456f69a7d0bd5b5e010c18e93f67b7f9ca56f1852e361504445e96dd5d8e43
- hashed_token: 6859d8cfc3f242c190762d77c1fb8121213142ad5fe1cfc94a0b7329a3da6c2e
+ hashed_refresh_token: 6434d9fe629bad7f970e5bb9b0b15ff84521af67379cbb12b1618060c76dd032
+ hashed_token: 1ff1e7373ebd0b73ad9c890ff151747a833cd2c4c34522864864e9051f56f1bf
name: create_exhaustive_sentry_app
- refresh_token: 1c5c5773248ef2260bba0ba4dfe4e1c52ac0b59131f271a0e241e29718401cd0
+ refresh_token: e714819d3e8d22a80c5e04170cdd3bca52ad8a856b5b55b177be2e41cfac730b
scope_list: '[]'
scopes: '0'
- token: f6084474a44b6e4fd7198fd0bf62d1acdb7d1efe2a5a38249ae25847eb4a1d1f
- token_last_characters: 1d1f
+ token: 00fde92790249c95b8404f81594018ea21ea0470c2c77cad5f493342512ec9d6
+ token_last_characters: c9d6
token_type: null
user: 2
model: sentry.apitoken
pk: 2
- fields:
application: null
- date_added: '2024-09-17T12:19:43.730Z'
+ date_added: '2024-09-18T18:52:40.593Z'
expires_at: null
hashed_refresh_token: null
- hashed_token: 6891f36cff78d5198a49ae4a510e584e4e4f0e0516c7870283c126e792bc0897
+ hashed_token: a2008b70cb07133282788f0204c1c300b30abf7bd872006c209b21f9918651ac
name: create_exhaustive_global_configs_for_
refresh_token: null
scope_list: '[]'
scopes: '0'
- token: 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
- token_last_characters: f875
+ token: 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
+ token_last_characters: '3460'
token_type: sntryu_
user: 2
model: sentry.apitoken
pk: 3
- fields:
application: 1
- code: 80752bcc7a28677645de1b5d9b56b7e7474fc88f25d661bd79917200f4025582
+ code: 0af5d5f1a1dfb0113645b0b4e9347d8916182bf8f5be0adc003f9a5d99c2ea98
expires_at: '2022-01-01T11:11:00.000Z'
redirect_uri: https://example.com
scope_list: '[''openid'', ''profile'', ''email'']'
@@ -1474,7 +1474,7 @@ source: tests/sentry/backup/test_releases.py
pk: 2
- fields:
application: 1
- date_added: '2024-09-17T12:19:43.659Z'
+ date_added: '2024-09-18T18:52:40.509Z'
scope_list: '[]'
scopes: '0'
user: 2
@@ -1482,7 +1482,7 @@ source: tests/sentry/backup/test_releases.py
pk: 1
- fields:
application: null
- date_added: '2024-09-17T12:19:43.728Z'
+ date_added: '2024-09-18T18:52:40.591Z'
scope_list: '[]'
scopes: '0'
user: 2
@@ -1490,14 +1490,14 @@ source: tests/sentry/backup/test_releases.py
pk: 2
- fields:
comparison_delta: null
- date_added: '2024-09-17T12:19:43.332Z'
- date_modified: '2024-09-17T12:19:43.332Z'
+ date_added: '2024-09-18T18:52:40.053Z'
+ date_modified: '2024-09-18T18:52:40.053Z'
description: null
detection_type: static
include_all_projects: true
monitor_type: 0
- name: Infinite Cowbird
- organization: 4554716211970048
+ name: Major Lizard
+ organization: 4554723419422720
resolve_threshold: null
seasonality: null
sensitivity: null
@@ -1511,14 +1511,14 @@ source: tests/sentry/backup/test_releases.py
pk: 1
- fields:
comparison_delta: null
- date_added: '2024-09-17T12:19:43.361Z'
- date_modified: '2024-09-17T12:19:43.361Z'
+ date_added: '2024-09-18T18:52:40.106Z'
+ date_modified: '2024-09-18T18:52:40.106Z'
description: null
detection_type: static
include_all_projects: false
monitor_type: 1
- name: Crack Amoeba
- organization: 4554716211970048
+ name: Normal Bird
+ organization: 4554723419422720
resolve_threshold: null
seasonality: null
sensitivity: null
@@ -1532,14 +1532,14 @@ source: tests/sentry/backup/test_releases.py
pk: 2
- fields:
comparison_delta: null
- date_added: '2024-09-17T12:19:43.377Z'
- date_modified: '2024-09-17T12:19:43.377Z'
+ date_added: '2024-09-18T18:52:40.125Z'
+ date_modified: '2024-09-18T18:52:40.125Z'
description: null
detection_type: static
include_all_projects: false
monitor_type: 0
- name: Optimal Hamster
- organization: 4554716211970048
+ name: Wondrous Sawfly
+ organization: 4554723419422720
resolve_threshold: null
seasonality: null
sensitivity: null
@@ -1569,13 +1569,13 @@ source: tests/sentry/backup/test_releases.py
- fields:
api_grant: null
api_token: 1
- date_added: '2024-09-17T12:19:43.533Z'
+ date_added: '2024-09-18T18:52:40.312Z'
date_deleted: null
- date_updated: '2024-09-17T12:19:43.582Z'
- organization_id: 4554716211970048
+ date_updated: '2024-09-18T18:52:40.387Z'
+ organization_id: 4554723419422720
sentry_app: 1
status: 1
- uuid: bef5a370-8bb9-41da-a7f2-6c66300910a5
+ uuid: 1da4cb8f-51fc-4aaa-be1c-aaf6f48a1c5b
model: sentry.sentryappinstallation
pk: 1
- fields:
@@ -1613,12 +1613,12 @@ source: tests/sentry/backup/test_releases.py
type: alert-rule-action
sentry_app: 1
type: alert-rule-action
- uuid: a177f003-ded1-421b-8c72-566befa4f56f
+ uuid: fe107713-b907-4fd8-909d-40b9f07f631f
model: sentry.sentryappcomponent
pk: 1
- fields:
alert_rule: null
- date_added: '2024-09-17T12:19:43.297Z'
+ date_added: '2024-09-18T18:52:39.999Z'
owner_id: 2
rule: 1
until: null
@@ -1626,7 +1626,7 @@ source: tests/sentry/backup/test_releases.py
model: sentry.rulesnooze
pk: 1
- fields:
- date_added: '2024-09-17T12:19:43.295Z'
+ date_added: '2024-09-18T18:52:39.997Z'
rule: 1
type: 1
user_id: 2
@@ -1634,20 +1634,20 @@ source: tests/sentry/backup/test_releases.py
pk: 1
- fields:
action: 1
- project: 4554716212035585
+ project: 4554723419422722
model: sentry.notificationactionproject
pk: 1
- fields:
action: 2
- project: 4554716212035585
+ project: 4554723419422722
model: sentry.notificationactionproject
pk: 2
- fields:
aggregates: null
columns: null
conditions: ''
- date_added: '2024-09-17T12:19:43.399Z'
- date_modified: '2024-09-17T12:19:43.399Z'
+ date_added: '2024-09-18T18:52:40.146Z'
+ date_modified: '2024-09-18T18:52:40.146Z'
field_aliases: null
fields: '[]'
is_hidden: false
@@ -1660,8 +1660,8 @@ source: tests/sentry/backup/test_releases.py
- fields:
alert_rule: 1
alert_threshold: 100.0
- date_added: '2024-09-17T12:19:43.345Z'
- label: Subtle Seahorse
+ date_added: '2024-09-18T18:52:40.072Z'
+ label: Climbing Stallion
resolve_threshold: null
threshold_type: null
model: sentry.alertruletrigger
@@ -1669,39 +1669,39 @@ source: tests/sentry/backup/test_releases.py
- fields:
alert_rule: 2
alert_threshold: 100.0
- date_added: '2024-09-17T12:19:43.372Z'
- label: Apt Hen
+ date_added: '2024-09-18T18:52:40.119Z'
+ label: Close Hermit
resolve_threshold: null
threshold_type: null
model: sentry.alertruletrigger
pk: 2
- fields:
alert_rule: 1
- date_added: '2024-09-17T12:19:43.337Z'
- project: 4554716212035585
+ date_added: '2024-09-18T18:52:40.062Z'
+ project: 4554723419422722
model: sentry.alertruleprojects
pk: 1
- fields:
alert_rule: 2
- date_added: '2024-09-17T12:19:43.362Z'
- project: 4554716212035585
+ date_added: '2024-09-18T18:52:40.108Z'
+ project: 4554723419422722
model: sentry.alertruleprojects
pk: 2
- fields:
alert_rule: 3
- date_added: '2024-09-17T12:19:43.378Z'
- project: 4554716212035585
+ date_added: '2024-09-18T18:52:40.126Z'
+ project: 4554723419422722
model: sentry.alertruleprojects
pk: 3
- fields:
alert_rule: 1
- date_added: '2024-09-17T12:19:43.334Z'
- project: 4554716212035586
+ date_added: '2024-09-18T18:52:40.056Z'
+ project: 4554723419488256
model: sentry.alertruleexcludedprojects
pk: 1
- fields:
alert_rule: 1
- date_added: '2024-09-17T12:19:43.339Z'
+ date_added: '2024-09-18T18:52:40.065Z'
previous_alert_rule: null
type: 1
user_id: 2
@@ -1709,7 +1709,7 @@ source: tests/sentry/backup/test_releases.py
pk: 1
- fields:
alert_rule: 2
- date_added: '2024-09-17T12:19:43.363Z'
+ date_added: '2024-09-18T18:52:40.109Z'
previous_alert_rule: null
type: 1
user_id: null
@@ -1717,7 +1717,7 @@ source: tests/sentry/backup/test_releases.py
pk: 2
- fields:
alert_rule: 3
- date_added: '2024-09-17T12:19:43.380Z'
+ date_added: '2024-09-18T18:52:40.128Z'
previous_alert_rule: null
type: 1
user_id: null
@@ -1726,20 +1726,20 @@ source: tests/sentry/backup/test_releases.py
- fields:
alert_rule: 2
condition_type: 0
- date_added: '2024-09-17T12:19:43.362Z'
+ date_added: '2024-09-18T18:52:40.107Z'
label: ''
model: sentry.alertruleactivationcondition
pk: 1
- fields:
actor_id: 1
application_id: 1
- date_added: '2024-09-17T12:19:43.577Z'
+ date_added: '2024-09-18T18:52:40.383Z'
events: '[]'
- guid: f5328eee57f4431581b38fe8c1b0818c
+ guid: 19a7c844c0f34c9ab7ab444adf29552e
installation_id: 1
- organization_id: 4554716211970048
+ organization_id: 4554723419422720
project_id: null
- secret: b85fb32ad68736cb75edbca23f0ec8ff4d9f29b023eeb750cc22d100dee859ca
+ secret: 93341bcd88615735bdeee9623456c8a1b6cbebf8e9f809e486614978bd48b84a
status: 0
url: https://example.com/sentry-app/webhook/
version: 0
@@ -1748,13 +1748,13 @@ source: tests/sentry/backup/test_releases.py
- fields:
actor_id: 10
application_id: 1
- date_added: '2024-09-17T12:19:43.715Z'
+ date_added: '2024-09-18T18:52:40.580Z'
events: '[''event.created'']'
- guid: 135a3438051d4eebb865a2723cce14a0
+ guid: 92bc181a2dd2463d906204260213299f
installation_id: 1
- organization_id: 4554716211970048
- project_id: 4554716212035589
- secret: cfe867dd2c4103544fa1dbec960df5ce282e5b48ce02b44200d2d2cd9bb290c4
+ organization_id: 4554723419422720
+ project_id: 4554723419488259
+ secret: 0b69b081548ff293cc7f769bf0211d8aac977d4f3419f59701aac93d07bdab07
status: 0
url: https://example.com/sentry/webhook
version: 0
@@ -1763,24 +1763,24 @@ source: tests/sentry/backup/test_releases.py
- fields:
activation: null
alert_rule: 3
- date_added: '2024-09-17T12:19:43.384Z'
+ date_added: '2024-09-18T18:52:40.132Z'
date_closed: null
- date_detected: '2024-09-17T12:19:43.383Z'
- date_started: '2024-09-17T12:19:43.383Z'
+ date_detected: '2024-09-18T18:52:40.131Z'
+ date_started: '2024-09-18T18:52:40.131Z'
detection_uuid: null
identifier: 1
- organization: 4554716211970048
+ organization: 4554723419422720
status: 1
status_method: 3
subscription: null
- title: Merry Goblin
+ title: Square Pheasant
type: 2
model: sentry.incident
pk: 1
- fields:
dashboard_widget_query: 1
- date_added: '2024-09-17T12:19:43.401Z'
- date_modified: '2024-09-17T12:19:43.401Z'
+ date_added: '2024-09-18T18:52:40.147Z'
+ date_modified: '2024-09-18T18:52:40.147Z'
extraction_state: disabled:not-applicable
spec_hashes: '[]'
spec_version: null
@@ -1788,13 +1788,13 @@ source: tests/sentry/backup/test_releases.py
pk: 1
- fields:
alert_rule_trigger: 1
- date_added: '2024-09-17T12:19:43.345Z'
+ date_added: '2024-09-18T18:52:40.074Z'
query_subscription: 1
model: sentry.alertruletriggerexclusion
pk: 1
- fields:
alert_rule_trigger: 1
- date_added: '2024-09-17T12:19:43.357Z'
+ date_added: '2024-09-18T18:52:40.101Z'
integration_id: null
sentry_app_config: null
sentry_app_id: null
@@ -1807,7 +1807,7 @@ source: tests/sentry/backup/test_releases.py
pk: 1
- fields:
alert_rule_trigger: 2
- date_added: '2024-09-17T12:19:43.374Z'
+ date_added: '2024-09-18T18:52:40.121Z'
integration_id: null
sentry_app_config: null
sentry_app_id: null
@@ -1819,35 +1819,35 @@ source: tests/sentry/backup/test_releases.py
model: sentry.alertruletriggeraction
pk: 2
- fields:
- date_added: '2024-09-17T12:19:43.389Z'
- end: '2024-09-17T12:19:43.389Z'
+ date_added: '2024-09-18T18:52:40.138Z'
+ end: '2024-09-18T18:52:40.138Z'
period: 1
- start: '2024-09-16T12:19:43.389Z'
+ start: '2024-09-17T18:52:40.138Z'
values: '[[1.0, 2.0, 3.0], [1.5, 2.5, 3.5]]'
model: sentry.timeseriessnapshot
pk: 1
- fields:
- date_added: '2024-09-17T12:19:43.395Z'
+ date_added: '2024-09-18T18:52:40.143Z'
incident: 1
- target_run_date: '2024-09-17T16:19:43.395Z'
+ target_run_date: '2024-09-18T22:52:40.143Z'
model: sentry.pendingincidentsnapshot
pk: 1
- fields:
alert_rule_trigger: 1
- date_added: '2024-09-17T12:19:43.393Z'
- date_modified: '2024-09-17T12:19:43.393Z'
+ date_added: '2024-09-18T18:52:40.141Z'
+ date_modified: '2024-09-18T18:52:40.141Z'
incident: 1
status: 1
model: sentry.incidenttrigger
pk: 1
- fields:
- date_added: '2024-09-17T12:19:43.392Z'
+ date_added: '2024-09-18T18:52:40.140Z'
incident: 1
user_id: 2
model: sentry.incidentsubscription
pk: 1
- fields:
- date_added: '2024-09-17T12:19:43.390Z'
+ date_added: '2024-09-18T18:52:40.139Z'
event_stats_snapshot: 1
incident: 1
total_events: 1
@@ -1856,7 +1856,7 @@ source: tests/sentry/backup/test_releases.py
pk: 1
- fields:
comment: hello test-org
- date_added: '2024-09-17T12:19:43.387Z'
+ date_added: '2024-09-18T18:52:40.137Z'
incident: 1
notification_uuid: null
previous_value: null
diff --git a/tests/sentry/backup/test_releases.py b/tests/sentry/backup/test_releases.py
index 6bd71acddb8f5a..8b443baa9da86b 100644
--- a/tests/sentry/backup/test_releases.py
+++ b/tests/sentry/backup/test_releases.py
@@ -96,9 +96,9 @@ def test_at_head(self, expected_models: list[type[Model]]):
# Check the export so that we can ensure that all models were seen.
verify_models_in_output(expected_models, exported)
- def test_at_24_8_0(self):
+ def test_at_24_9_0(self):
with tempfile.TemporaryDirectory() as tmp_dir:
- _, snapshot_refval = read_snapshot_file(self.get_snapshot_path("24.8.0"))
+ _, snapshot_refval = read_snapshot_file(self.get_snapshot_path("24.9.0"))
snapshot_data = yaml.safe_load(snapshot_refval)
tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json")
with open(tmp_path, "wb") as f:
@@ -107,9 +107,9 @@ def test_at_24_8_0(self):
with open(tmp_path, "rb") as f:
import_in_global_scope(f, printer=NOOP_PRINTER)
- def test_at_24_7_0(self):
+ def test_at_24_8_0(self):
with tempfile.TemporaryDirectory() as tmp_dir:
- _, snapshot_refval = read_snapshot_file(self.get_snapshot_path("24.7.0"))
+ _, snapshot_refval = read_snapshot_file(self.get_snapshot_path("24.8.0"))
snapshot_data = yaml.safe_load(snapshot_refval)
tmp_path = Path(tmp_dir).joinpath(f"{self._testMethodName}.json")
with open(tmp_path, "wb") as f:
diff --git a/tests/sentry/deletions/test_alert_rule.py b/tests/sentry/deletions/test_alert_rule.py
index 1cae755f09ed52..1be16ad13719ae 100644
--- a/tests/sentry/deletions/test_alert_rule.py
+++ b/tests/sentry/deletions/test_alert_rule.py
@@ -1,7 +1,19 @@
+from unittest.mock import patch
+
+import orjson
+from urllib3 import HTTPResponse
+
from sentry.deletions.tasks.scheduled import run_scheduled_deletions
-from sentry.incidents.models.alert_rule import AlertRule, AlertRuleTrigger
+from sentry.incidents.models.alert_rule import (
+ AlertRule,
+ AlertRuleDetectionType,
+ AlertRuleSeasonality,
+ AlertRuleSensitivity,
+ AlertRuleTrigger,
+)
from sentry.models.organization import Organization
from sentry.testutils.cases import TestCase
+from sentry.testutils.helpers.features import with_feature
from sentry.testutils.hybrid_cloud import HybridCloudTestMixin
@@ -19,3 +31,36 @@ def test_simple(self):
assert Organization.objects.filter(id=organization.id).exists()
assert not AlertRule.objects.filter(id=alert_rule.id).exists()
assert not AlertRuleTrigger.objects.filter(id=alert_rule_trigger.id).exists()
+
+ @with_feature("organizations:anomaly-detection-alerts")
+ @patch(
+ "sentry.seer.anomaly_detection.delete_rule.seer_anomaly_detection_connection_pool.urlopen"
+ )
+ @patch(
+ "sentry.seer.anomaly_detection.store_data.seer_anomaly_detection_connection_pool.urlopen"
+ )
+ def test_dynamic_alert_rule(self, mock_store_request, mock_delete_request):
+ organization = self.create_organization()
+ alert_rule = self.create_alert_rule(organization=organization)
+
+ seer_return_value = {"success": True}
+ mock_store_request.return_value = HTTPResponse(orjson.dumps(seer_return_value), status=200)
+ mock_delete_request.return_value = HTTPResponse(orjson.dumps(seer_return_value), status=200)
+
+ alert_rule = self.create_alert_rule(
+ sensitivity=AlertRuleSensitivity.HIGH,
+ seasonality=AlertRuleSeasonality.AUTO,
+ time_window=60,
+ detection_type=AlertRuleDetectionType.DYNAMIC,
+ organization=organization,
+ )
+
+ self.ScheduledDeletion.schedule(instance=alert_rule, days=0)
+
+ with self.tasks():
+ run_scheduled_deletions()
+
+ assert Organization.objects.filter(id=organization.id).exists()
+ assert not AlertRule.objects.filter(id=alert_rule.id).exists()
+
+ assert mock_delete_request.call_count == 1
diff --git a/tests/sentry/deletions/test_apiapplication.py b/tests/sentry/deletions/test_apiapplication.py
index 131d9d07bcd9c6..ebfc6ebaad7624 100644
--- a/tests/sentry/deletions/test_apiapplication.py
+++ b/tests/sentry/deletions/test_apiapplication.py
@@ -4,7 +4,7 @@
from sentry.models.apigrant import ApiGrant
from sentry.models.apitoken import ApiToken
from sentry.models.scheduledeletion import ScheduledDeletion
-from sentry.models.servicehook import ServiceHook
+from sentry.sentry_apps.models.servicehook import ServiceHook
from sentry.silo.base import SiloMode
from sentry.testutils.cases import TransactionTestCase
from sentry.testutils.hybrid_cloud import HybridCloudTestMixin
diff --git a/tests/sentry/deletions/test_group.py b/tests/sentry/deletions/test_group.py
index 400565673fc4eb..000276342623e2 100644
--- a/tests/sentry/deletions/test_group.py
+++ b/tests/sentry/deletions/test_group.py
@@ -6,6 +6,7 @@
from sentry.deletions.defaults.group import EventDataDeletionTask
from sentry.deletions.tasks.groups import delete_groups
from sentry.eventstore.models import Event
+from sentry.issues.grouptype import ReplayDeadClickType
from sentry.models.eventattachment import EventAttachment
from sentry.models.files.file import File
from sentry.models.group import Group
@@ -17,14 +18,15 @@
from sentry.models.userreport import UserReport
from sentry.testutils.cases import SnubaTestCase, TestCase
from sentry.testutils.helpers.datetime import before_now, iso_format
+from tests.sentry.issues.test_utils import OccurrenceTestMixin
class DeleteGroupTest(TestCase, SnubaTestCase):
def setUp(self):
super().setUp()
- group1_data = {"timestamp": iso_format(before_now(minutes=1)), "fingerprint": ["group1"]}
- group2_data = {"timestamp": iso_format(before_now(minutes=1)), "fingerprint": ["group2"]}
- self.project = self.create_project()
+ one_minute = iso_format(before_now(minutes=1))
+ group1_data = {"timestamp": one_minute, "fingerprint": ["group1"]}
+ group2_data = {"timestamp": one_minute, "fingerprint": ["group2"]}
# Group 1 events
self.event = self.store_event(
@@ -189,3 +191,35 @@ def test_delete_groups_delete_grouping_records_by_hash(
assert mock_delete_seer_grouping_records_by_hash_apply_async.call_args[1] == {
"args": [group.project.id, hashes, 0]
}
+
+
+class DeleteIssuePlatformTest(TestCase, SnubaTestCase, OccurrenceTestMixin):
+ def test_issue_platform(self):
+ event = self.store_event(data={}, project_id=self.project.id)
+ issue_occurrence, group_info = self.process_occurrence(
+ event_id=event.event_id,
+ project_id=self.project.id,
+ # We are using ReplayDeadClickType as a representative of Issue Platform
+ type=ReplayDeadClickType.type_id,
+ event_data={
+ "fingerprint": ["issue-platform-group"],
+ "timestamp": before_now(minutes=1).isoformat(),
+ },
+ )
+ assert group_info is not None
+ issue_platform_group = group_info.group
+ assert event.group_id != issue_platform_group.id
+
+ with self.tasks():
+ delete_groups(object_ids=[issue_platform_group.id])
+
+ # The original event and group still exist
+ assert Group.objects.filter(id=event.group_id).exists()
+ node_id = Event.generate_node_id(event.project_id, event.event_id)
+ assert nodestore.backend.get(node_id)
+
+ # The Issue Platform group and occurrence are deleted
+ assert issue_platform_group.issue_type == ReplayDeadClickType
+ assert not Group.objects.filter(id=issue_platform_group.id).exists()
+ node_id = Event.generate_node_id(issue_occurrence.project_id, issue_occurrence.id)
+ assert not nodestore.backend.get(node_id)
diff --git a/tests/sentry/deletions/test_project.py b/tests/sentry/deletions/test_project.py
index f1690ab293c503..4f12dfca3d5619 100644
--- a/tests/sentry/deletions/test_project.py
+++ b/tests/sentry/deletions/test_project.py
@@ -18,7 +18,6 @@
from sentry.models.releasecommit import ReleaseCommit
from sentry.models.repository import Repository
from sentry.models.rulesnooze import RuleSnooze
-from sentry.models.servicehook import ServiceHook
from sentry.monitors.models import (
CheckInStatus,
Monitor,
@@ -27,6 +26,7 @@
MonitorType,
ScheduleType,
)
+from sentry.sentry_apps.models.servicehook import ServiceHook
from sentry.snuba.models import QuerySubscription, SnubaQuery
from sentry.testutils.cases import APITestCase, TransactionTestCase
from sentry.testutils.helpers.datetime import before_now, iso_format
diff --git a/tests/sentry/deletions/test_sentry_app.py b/tests/sentry/deletions/test_sentry_app.py
index af2b7952652b1b..f9df415061f2ee 100644
--- a/tests/sentry/deletions/test_sentry_app.py
+++ b/tests/sentry/deletions/test_sentry_app.py
@@ -3,8 +3,8 @@
from sentry import deletions
from sentry.models.apiapplication import ApiApplication
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.testutils.cases import TestCase
from sentry.testutils.silo import control_silo_test
from sentry.users.models.user import User
diff --git a/tests/sentry/deletions/test_sentry_app_installations.py b/tests/sentry/deletions/test_sentry_app_installations.py
index 514701cc7e945f..419e574b6e368b 100644
--- a/tests/sentry/deletions/test_sentry_app_installations.py
+++ b/tests/sentry/deletions/test_sentry_app_installations.py
@@ -6,12 +6,12 @@
from sentry.deletions.tasks.hybrid_cloud import schedule_hybrid_cloud_foreign_key_jobs
from sentry.models.apigrant import ApiGrant
from sentry.models.apitoken import ApiToken
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
-from sentry.models.integrations.sentry_app_installation_for_provider import (
+from sentry.sentry_apps.installations import SentryAppInstallationCreator
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation_for_provider import (
SentryAppInstallationForProvider,
)
-from sentry.models.servicehook import ServiceHook
-from sentry.sentry_apps.installations import SentryAppInstallationCreator
+from sentry.sentry_apps.models.servicehook import ServiceHook
from sentry.silo.base import SiloMode
from sentry.silo.safety import unguarded_write
from sentry.testutils.cases import TestCase
diff --git a/tests/sentry/deletions/test_sentry_installation_tokens.py b/tests/sentry/deletions/test_sentry_installation_tokens.py
index 84ac80f753b566..90781c19ebce95 100644
--- a/tests/sentry/deletions/test_sentry_installation_tokens.py
+++ b/tests/sentry/deletions/test_sentry_installation_tokens.py
@@ -1,7 +1,7 @@
from sentry import deletions
from sentry.models.apitoken import ApiToken
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
-from sentry.models.integrations.sentry_app_installation_token import SentryAppInstallationToken
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation_token import SentryAppInstallationToken
from sentry.testutils.cases import TestCase
from sentry.testutils.silo import control_silo_test
diff --git a/tests/sentry/feedback/usecases/test_create_feedback.py b/tests/sentry/feedback/usecases/test_create_feedback.py
index 65ac8a00779d5f..69496b24ff69a0 100644
--- a/tests/sentry/feedback/usecases/test_create_feedback.py
+++ b/tests/sentry/feedback/usecases/test_create_feedback.py
@@ -9,10 +9,12 @@
from openai.types.chat.chat_completion import ChatCompletion, Choice
from openai.types.chat.chat_completion_message import ChatCompletionMessage
+from sentry.eventstore.models import Event
from sentry.feedback.usecases.create_feedback import (
FeedbackCreationSource,
create_feedback_issue,
fix_for_issue_platform,
+ shim_to_feedback,
validate_issue_platform_event_schema,
)
from sentry.models.group import Group, GroupStatus
@@ -768,3 +770,47 @@ def test_create_feedback_spam_detection_set_status_ignored(
group = Group.objects.get()
assert group.status == GroupStatus.IGNORED
assert group.substatus == GroupSubStatus.FOREVER
+
+
+# Unit tests for shim_to_feedback error cases. The typical behavior of this function is tested in
+# test_project_user_reports, test_post_process, and test_update_user_reports.
+
+
+@django_db_all
+def test_shim_to_feedback_missing_event(default_project, monkeypatch):
+ # Not allowing this since creating feedbacks with no environment (copied from the associated event) doesn't work well.
+ mock_create_feedback_issue = Mock()
+ monkeypatch.setattr(
+ "sentry.feedback.usecases.create_feedback.create_feedback_issue", mock_create_feedback_issue
+ )
+ report_dict = {
+ "name": "andrew",
+ "email": "aliu@example.com",
+ "comments": "Shim this",
+ "event_id": "a" * 32,
+ "level": "error",
+ }
+ shim_to_feedback(
+ report_dict, None, default_project, FeedbackCreationSource.USER_REPORT_ENVELOPE # type: ignore[arg-type]
+ )
+ # Error is handled:
+ assert mock_create_feedback_issue.call_count == 0
+
+
+@django_db_all
+def test_shim_to_feedback_missing_fields(default_project, monkeypatch):
+ # Email and comments are required to shim. Tests key errors are handled.
+ mock_create_feedback_issue = Mock()
+ monkeypatch.setattr(
+ "sentry.feedback.usecases.create_feedback.create_feedback_issue", mock_create_feedback_issue
+ )
+ report_dict = {
+ "name": "andrew",
+ "event_id": "a" * 32,
+ "level": "error",
+ }
+ event = Event(event_id="a" * 32, project_id=default_project.id)
+ shim_to_feedback(
+ report_dict, event, default_project, FeedbackCreationSource.USER_REPORT_ENVELOPE # type: ignore[arg-type]
+ )
+ assert mock_create_feedback_issue.call_count == 0
diff --git a/tests/sentry/incidents/endpoints/test_organization_alert_rule_available_action_index.py b/tests/sentry/incidents/endpoints/test_organization_alert_rule_available_action_index.py
index 7f38cb4a071efb..3e6605982d8962 100644
--- a/tests/sentry/incidents/endpoints/test_organization_alert_rule_available_action_index.py
+++ b/tests/sentry/incidents/endpoints/test_organization_alert_rule_available_action_index.py
@@ -9,7 +9,8 @@
from sentry.integrations.models.organization_integration import OrganizationIntegration
from sentry.integrations.pagerduty.utils import add_service
from sentry.integrations.services.integration.serial import serialize_integration
-from sentry.models.integrations import SentryAppComponent, SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_component import SentryAppComponent
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.sentry_apps.services.app.serial import serialize_sentry_app_installation
from sentry.silo.base import SiloMode
from sentry.testutils.cases import APITestCase
diff --git a/tests/sentry/incidents/endpoints/test_organization_combined_rule_index_endpoint.py b/tests/sentry/incidents/endpoints/test_organization_combined_rule_index_endpoint.py
index c0f260b310f568..bff541819fa644 100644
--- a/tests/sentry/incidents/endpoints/test_organization_combined_rule_index_endpoint.py
+++ b/tests/sentry/incidents/endpoints/test_organization_combined_rule_index_endpoint.py
@@ -729,7 +729,7 @@ def test_myteams_filter_superuser(self):
assert response.status_code == 200
assert len(response.data) == 2 # We are not on this team, but we are a superuser.
- def test_team_filter_no_access(self):
+ def test_team_filter_no_cross_org_access(self):
self.setup_project_and_rules()
another_org = self.create_organization(owner=self.user, name="Rowdy Tiger")
another_org_team = self.create_team(organization=another_org, name="Meow Band", members=[])
@@ -742,7 +742,36 @@ def test_team_filter_no_access(self):
response = self.client.get(
path=self.combined_rules_url, data=request_data, content_type="application/json"
)
+ assert response.status_code == 200
+ assert len(response.data) == 1
+ assert response.data[0]["owner"] == f"team:{self.team.id}"
+
+ def test_team_filter_no_access(self):
+ self.setup_project_and_rules()
+
+ # disable Open Membership
+ self.org.flags.allow_joinleave = False
+ self.org.save()
+
+ user2 = self.create_user("bulldog@example.com")
+ team2 = self.create_team(organization=self.org, name="Barking Voices")
+ project2 = self.create_project(organization=self.org, teams=[team2], name="Bones")
+ self.create_member(user=user2, organization=self.org, role="member", teams=[team2])
+ self.login_as(user2)
+
+ with self.feature(["organizations:incidents", "organizations:performance-view"]):
+ request_data = {
+ "per_page": "10",
+ "project": [project2.id],
+ "team": [team2.id, self.team.id],
+ }
+ response = self.client.get(
+ path=self.combined_rules_url, data=request_data, content_type="application/json"
+ )
assert response.status_code == 403
+ assert (
+ response.data["detail"] == "Error: You do not have permission to access Mariachi Band"
+ )
def test_name_filter(self):
self.setup_project_and_rules()
diff --git a/tests/sentry/incidents/test_logic.py b/tests/sentry/incidents/test_logic.py
index 2bdc4176fb3bf5..59c4d0dcd222ec 100644
--- a/tests/sentry/incidents/test_logic.py
+++ b/tests/sentry/incidents/test_logic.py
@@ -1473,9 +1473,15 @@ def test_update_alert_load_shedding_on_comparison_and_window(self):
@patch(
"sentry.seer.anomaly_detection.store_data.seer_anomaly_detection_connection_pool.urlopen"
)
- def test_update_detection_type(self, mock_seer_request):
+ @patch(
+ "sentry.seer.anomaly_detection.delete_rule.seer_anomaly_detection_connection_pool.urlopen"
+ )
+ def test_update_detection_type(self, mock_seer_delete_request, mock_seer_request):
seer_return_value: StoreDataResponse = {"success": True}
mock_seer_request.return_value = HTTPResponse(orjson.dumps(seer_return_value), status=200)
+ mock_seer_delete_request.return_value = HTTPResponse(
+ orjson.dumps(seer_return_value), status=200
+ )
comparison_delta = 60
# test percent to dynamic
rule = self.create_alert_rule(
@@ -1887,6 +1893,31 @@ def test_update_alert_rule_static_to_anomaly_detection_seer_timeout(
static_rule.refresh_from_db()
assert static_rule.detection_type == AlertRuleDetectionType.STATIC
+ @with_feature("organizations:anomaly-detection-alerts")
+ @patch(
+ "sentry.seer.anomaly_detection.delete_rule.seer_anomaly_detection_connection_pool.urlopen"
+ )
+ @patch(
+ "sentry.seer.anomaly_detection.store_data.seer_anomaly_detection_connection_pool.urlopen"
+ )
+ def test_update_alert_rule_dynamic_to_static_delete_call(
+ self, mock_store_request, mock_delete_request
+ ):
+ seer_return_value = {"success": True}
+ mock_store_request.return_value = HTTPResponse(orjson.dumps(seer_return_value), status=200)
+ mock_delete_request.return_value = HTTPResponse(orjson.dumps(seer_return_value), status=200)
+
+ alert_rule = self.create_alert_rule(
+ sensitivity=AlertRuleSensitivity.HIGH,
+ seasonality=AlertRuleSeasonality.AUTO,
+ time_window=60,
+ detection_type=AlertRuleDetectionType.DYNAMIC,
+ )
+
+ update_alert_rule(alert_rule, detection_type=AlertRuleDetectionType.STATIC)
+
+ assert mock_delete_request.call_count == 1
+
@patch(
"sentry.seer.anomaly_detection.store_data.seer_anomaly_detection_connection_pool.urlopen"
)
@@ -1925,10 +1956,51 @@ def test_update_invalid_time_window(self, mock_seer_request):
class DeleteAlertRuleTest(TestCase, BaseIncidentsTest):
+ def setUp(self):
+ super().setUp()
+
+ class _DynamicMetricAlertSettings(TypedDict):
+ name: str
+ query: str
+ aggregate: str
+ time_window: int
+ threshold_type: AlertRuleThresholdType
+ threshold_period: int
+ event_types: list[SnubaQueryEventType.EventType]
+ detection_type: AlertRuleDetectionType
+ sensitivity: AlertRuleSensitivity
+ seasonality: AlertRuleSeasonality
+
+ self.dynamic_metric_alert_settings: _DynamicMetricAlertSettings = {
+ "name": "hello",
+ "query": "level:error",
+ "aggregate": "count(*)",
+ "time_window": 30,
+ "threshold_type": AlertRuleThresholdType.ABOVE,
+ "threshold_period": 1,
+ "event_types": [SnubaQueryEventType.EventType.ERROR],
+ "detection_type": AlertRuleDetectionType.DYNAMIC,
+ "sensitivity": AlertRuleSensitivity.LOW,
+ "seasonality": AlertRuleSeasonality.AUTO,
+ }
+
@cached_property
def alert_rule(self):
return self.create_alert_rule()
+ @cached_property
+ @patch(
+ "sentry.seer.anomaly_detection.store_data.seer_anomaly_detection_connection_pool.urlopen"
+ )
+ def dynamic_alert_rule(self, mock_seer_request):
+ seer_return_value: StoreDataResponse = {"success": True}
+ mock_seer_request.return_value = HTTPResponse(orjson.dumps(seer_return_value), status=200)
+ return self.create_alert_rule(
+ self.organization,
+ [self.project],
+ **self.dynamic_metric_alert_settings,
+ )
+
def test(self):
alert_rule_id = self.alert_rule.id
with self.tasks():
@@ -1964,6 +2036,163 @@ def test_on_demand_metric_alert(self, mocked_schedule_update_project_config):
mocked_schedule_update_project_config.assert_called_with(alert_rule, [self.project])
+ @with_feature("organizations:anomaly-detection-alerts")
+ @patch(
+ "sentry.seer.anomaly_detection.delete_rule.seer_anomaly_detection_connection_pool.urlopen"
+ )
+ def test_delete_anomaly_detection_rule(self, mock_seer_request):
+ alert_rule = self.dynamic_alert_rule
+ alert_rule_id = alert_rule.id
+
+ with self.tasks():
+ delete_alert_rule(alert_rule)
+
+ assert not AlertRule.objects.filter(id=alert_rule_id).exists()
+ assert AlertRule.objects_with_snapshots.filter(id=alert_rule_id).exists()
+ seer_return_value: StoreDataResponse = {"success": True}
+ mock_seer_request.return_value = HTTPResponse(orjson.dumps(seer_return_value), status=200)
+
+ with self.tasks():
+ run_scheduled_deletions()
+
+ assert not AlertRule.objects.filter(id=alert_rule_id).exists()
+ assert not AlertRule.objects_with_snapshots.filter(id=alert_rule_id).exists()
+
+ assert mock_seer_request.call_count == 1
+
+ @with_feature("organizations:anomaly-detection-alerts")
+ @patch(
+ "sentry.seer.anomaly_detection.delete_rule.seer_anomaly_detection_connection_pool.urlopen"
+ )
+ @patch("sentry.seer.anomaly_detection.delete_rule.logger")
+ @patch("sentry.incidents.models.alert_rule.logger")
+ def test_delete_anomaly_detection_rule_timeout(
+ self, mock_model_logger, mock_seer_logger, mock_seer_request
+ ):
+ alert_rule = self.dynamic_alert_rule
+ alert_rule_id = alert_rule.id
+
+ with self.tasks():
+ delete_alert_rule(alert_rule)
+
+ mock_seer_request.side_effect = TimeoutError
+
+ with self.tasks():
+ run_scheduled_deletions()
+
+ assert not AlertRule.objects.filter(id=alert_rule_id).exists()
+ assert not AlertRule.objects_with_snapshots.filter(id=alert_rule_id).exists()
+
+ mock_seer_logger.warning.assert_called_with(
+ "Timeout error when hitting Seer delete rule data endpoint",
+ extra={"rule_id": alert_rule_id},
+ )
+ mock_model_logger.error.assert_called_with(
+ "Call to delete rule data in Seer failed",
+ extra={"rule_id": alert_rule_id},
+ )
+ assert mock_seer_request.call_count == 1
+
+ @with_feature("organizations:anomaly-detection-alerts")
+ @patch(
+ "sentry.seer.anomaly_detection.delete_rule.seer_anomaly_detection_connection_pool.urlopen"
+ )
+ @patch("sentry.seer.anomaly_detection.delete_rule.logger")
+ @patch("sentry.incidents.models.alert_rule.logger")
+ def test_delete_anomaly_detection_rule_error(
+ self, mock_model_logger, mock_seer_logger, mock_seer_request
+ ):
+ alert_rule = self.dynamic_alert_rule
+ alert_rule_id = alert_rule.id
+
+ with self.tasks():
+ delete_alert_rule(alert_rule)
+
+ mock_seer_request.return_value = HTTPResponse("Bad request", status=500)
+
+ with self.tasks():
+ run_scheduled_deletions()
+
+ assert not AlertRule.objects.filter(id=alert_rule_id).exists()
+ assert not AlertRule.objects_with_snapshots.filter(id=alert_rule_id).exists()
+
+ mock_seer_logger.error.assert_called_with(
+ "Error when hitting Seer delete rule data endpoint",
+ extra={"response_data": "Bad request", "rule_id": alert_rule_id},
+ )
+ mock_model_logger.error.assert_called_with(
+ "Call to delete rule data in Seer failed",
+ extra={"rule_id": alert_rule_id},
+ )
+ assert mock_seer_request.call_count == 1
+
+ @with_feature("organizations:anomaly-detection-alerts")
+ @patch(
+ "sentry.seer.anomaly_detection.delete_rule.seer_anomaly_detection_connection_pool.urlopen"
+ )
+ @patch("sentry.seer.anomaly_detection.delete_rule.logger")
+ @patch("sentry.incidents.models.alert_rule.logger")
+ def test_delete_anomaly_detection_rule_attribute_error(
+ self, mock_model_logger, mock_seer_logger, mock_seer_request
+ ):
+ alert_rule = self.dynamic_alert_rule
+ alert_rule_id = alert_rule.id
+
+ with self.tasks():
+ delete_alert_rule(alert_rule)
+
+ mock_seer_request.return_value = HTTPResponse(None, status=200) # type:ignore[arg-type]
+
+ with self.tasks():
+ run_scheduled_deletions()
+
+ assert not AlertRule.objects.filter(id=alert_rule_id).exists()
+ assert not AlertRule.objects_with_snapshots.filter(id=alert_rule_id).exists()
+
+ mock_seer_logger.exception.assert_called_with(
+ "Failed to parse Seer delete rule data response",
+ extra={"rule_id": alert_rule_id},
+ )
+ mock_model_logger.error.assert_called_with(
+ "Call to delete rule data in Seer failed",
+ extra={"rule_id": alert_rule_id},
+ )
+ assert mock_seer_request.call_count == 1
+
+ @with_feature("organizations:anomaly-detection-alerts")
+ @patch(
+ "sentry.seer.anomaly_detection.delete_rule.seer_anomaly_detection_connection_pool.urlopen"
+ )
+ @patch("sentry.seer.anomaly_detection.delete_rule.logger")
+ @patch("sentry.incidents.models.alert_rule.logger")
+ def test_delete_anomaly_detection_rule_failure(
+ self, mock_model_logger, mock_seer_logger, mock_seer_request
+ ):
+ alert_rule = self.dynamic_alert_rule
+ alert_rule_id = alert_rule.id
+
+ with self.tasks():
+ delete_alert_rule(alert_rule)
+
+ seer_return_value: StoreDataResponse = {"success": False}
+ mock_seer_request.return_value = HTTPResponse(orjson.dumps(seer_return_value), status=200)
+
+ with self.tasks():
+ run_scheduled_deletions()
+
+ assert not AlertRule.objects.filter(id=alert_rule_id).exists()
+ assert not AlertRule.objects_with_snapshots.filter(id=alert_rule_id).exists()
+
+ mock_seer_logger.error.assert_called_with(
+ "Request to delete alert rule from Seer was unsuccessful",
+ extra={"rule_id": alert_rule_id},
+ )
+ mock_model_logger.error.assert_called_with(
+ "Call to delete rule data in Seer failed",
+ extra={"rule_id": alert_rule_id},
+ )
+ assert mock_seer_request.call_count == 1
+
class EnableAlertRuleTest(TestCase, BaseIncidentsTest):
@cached_property
diff --git a/tests/sentry/integrations/jira/models/test_jira_schema.py b/tests/sentry/integrations/jira/models/test_jira_schema.py
index 44525bb2047c87..125450a7d52de7 100644
--- a/tests/sentry/integrations/jira/models/test_jira_schema.py
+++ b/tests/sentry/integrations/jira/models/test_jira_schema.py
@@ -6,7 +6,7 @@
class TestJiraSchema(TestCase):
def test_schema_parsing(self):
create_meta = StubJiraApiClient().get_create_meta_for_project("proj-1")
- issue_configs = JiraIssueTypeMetadata.from_jira_meta_config(create_meta)
+ issue_configs = list(JiraIssueTypeMetadata.from_jira_meta_config(create_meta).values())
assert len(issue_configs) == 1
assert issue_configs[0].name == "Bug"
assert issue_configs[0].id == "1"
diff --git a/tests/sentry/integrations/jira/utils/test_create_issue_schema_transformers.py b/tests/sentry/integrations/jira/utils/test_create_issue_schema_transformers.py
new file mode 100644
index 00000000000000..7df0b72e501556
--- /dev/null
+++ b/tests/sentry/integrations/jira/utils/test_create_issue_schema_transformers.py
@@ -0,0 +1,160 @@
+from typing import Any
+
+import pytest
+
+from fixtures.integrations.jira.stub_client import StubJiraApiClient
+from sentry.integrations.jira.models.create_issue_metadata import (
+ JIRA_CUSTOM_FIELD_TYPES,
+ JiraField,
+ JiraSchema,
+ JiraSchemaTypes,
+)
+from sentry.integrations.jira.utils.create_issue_schema_transformers import transform_fields
+from sentry.shared_integrations.exceptions import IntegrationFormError
+from sentry.testutils.cases import TestCase
+
+
+class TestDataTransformer(TestCase):
+ def setUp(self):
+ # TODO(Gabe): Add an interface for the Jira client to share among the different impls
+ self.client: Any = StubJiraApiClient()
+
+ def test_transform_with_empty_fields_set(self):
+ transformed_data = transform_fields(
+ self.client.user_id_field(),
+ [],
+ **{"field1": "abcd", "field2": "1234", "field3": "foobar"},
+ )
+
+ assert transformed_data == {}
+
+ def create_standard_field(
+ self,
+ name: str,
+ schema_type: JiraSchemaTypes,
+ is_array: bool = False,
+ key: str | None = None,
+ required: bool = False,
+ ) -> JiraField:
+ if is_array:
+ jira_schema = JiraSchema(
+ schema_type=JiraSchemaTypes.array,
+ items=schema_type,
+ )
+ else:
+ jira_schema = JiraSchema(
+ schema_type=schema_type,
+ )
+ return JiraField(
+ name=name,
+ key=key or name,
+ operations=[],
+ has_default_value=False,
+ required=required,
+ schema=jira_schema,
+ )
+
+ def test_multi_user_array(self):
+ field = self.create_standard_field(
+ name="Foo Bar", key="foobar", schema_type=JiraSchemaTypes.user, is_array=True
+ )
+ transformed_data = transform_fields(
+ self.client.user_id_field(), jira_fields=[field], **{"foobar": "abcd"}
+ )
+ assert transformed_data == {"foobar": [{"accountId": "abcd"}]}
+
+ transformed_data = transform_fields(
+ self.client.user_id_field(), jira_fields=[field], **{"foobar": ["abcd", "efgh"]}
+ )
+ assert transformed_data == {"foobar": [{"accountId": "abcd"}, {"accountId": "efgh"}]}
+
+ def test_transform_single_user(self):
+ field = self.create_standard_field(schema_type=JiraSchemaTypes.user, name="barfoo")
+ transformed_data = transform_fields(
+ self.client.user_id_field(), jira_fields=[field], **{"barfoo": "abcd"}
+ )
+
+ assert transformed_data == {"barfoo": {"accountId": "abcd"}}
+
+ def test_transform_number_field(self):
+ field = self.create_standard_field(schema_type=JiraSchemaTypes.number, name="num_field")
+ with pytest.raises(IntegrationFormError) as exc:
+ transform_fields(
+ self.client.user_id_field(), jira_fields=[field], **{"num_field": "abcd"}
+ )
+
+ assert exc.value.field_errors == {
+ "num_field": "Invalid number value provided for field: 'abcd'"
+ }
+
+ transformed_data = transform_fields(
+ self.client.user_id_field(), jira_fields=[field], **{"num_field": "1.5"}
+ )
+
+ assert transformed_data == {"num_field": 1.5}
+
+ transformed_data = transform_fields(
+ self.client.user_id_field(), jira_fields=[field], **{"num_field": "5"}
+ )
+
+ assert transformed_data == {"num_field": 5}
+
+ def test_transform_issue_type_field(self):
+ field = self.create_standard_field(name="issue", schema_type=JiraSchemaTypes.issue_type)
+ transformed_data = transform_fields(
+ self.client.user_id_field(), jira_fields=[field], **{"issue": "abcd"}
+ )
+ assert transformed_data == {"issue": {"id": "abcd"}}
+
+ def test_transform_option_field(self):
+ field = self.create_standard_field(name="option_thing", schema_type=JiraSchemaTypes.option)
+ transformed_data = transform_fields(
+ self.client.user_id_field(),
+ jira_fields=[field],
+ **{"option_thing": "abcd"},
+ )
+ assert transformed_data == {"option_thing": {"value": "abcd"}}
+
+ def test_transform_issue_link_field(self):
+ field = self.create_standard_field(name="link", schema_type=JiraSchemaTypes.issue_link)
+
+ transformed_data = transform_fields(
+ self.client.user_id_field(),
+ jira_fields=[field],
+ **{"link": "abcd"},
+ )
+
+ assert transformed_data == {"link": {"key": "abcd"}}
+
+ def test_transform_project_field(self):
+ field = self.create_standard_field(name="project", schema_type=JiraSchemaTypes.project)
+ transformed_data = transform_fields(
+ self.client.user_id_field(),
+ jira_fields=[field],
+ **{"project": "abcd"},
+ )
+
+ assert transformed_data == {"project": {"id": "abcd"}}
+
+ def test_sprint_custom_field(self):
+ sprint_field = JiraField(
+ schema=JiraSchema(
+ custom_id=1001,
+ custom=JIRA_CUSTOM_FIELD_TYPES["sprint"],
+ schema_type=JiraSchemaTypes.array,
+ items=JiraSchemaTypes.json,
+ ),
+ name="sprint",
+ key="sprint",
+ required=False,
+ has_default_value=False,
+ operations=[],
+ )
+
+ transformed_data = transform_fields(
+ self.client.user_id_field(),
+ jira_fields=[sprint_field],
+ **{"sprint": 2},
+ )
+
+ assert transformed_data == {"sprint": 2}
diff --git a/tests/sentry/integrations/msteams/webhook/test_ms_teams_webhook_mixin.py b/tests/sentry/integrations/msteams/webhook/test_ms_teams_webhook_parsing.py
similarity index 58%
rename from tests/sentry/integrations/msteams/webhook/test_ms_teams_webhook_mixin.py
rename to tests/sentry/integrations/msteams/webhook/test_ms_teams_webhook_parsing.py
index 46423a20b5a2d2..c679190f86322b 100644
--- a/tests/sentry/integrations/msteams/webhook/test_ms_teams_webhook_mixin.py
+++ b/tests/sentry/integrations/msteams/webhook/test_ms_teams_webhook_parsing.py
@@ -1,37 +1,37 @@
from typing import Any
-from sentry.integrations.msteams import MsTeamsWebhookMixin
+from sentry.integrations.msteams.parsing import is_new_integration_installation_event
class TestIsNewIntegrationInstallationEvent:
def test_valid_new_installation_event(self) -> None:
data: dict[str, Any] = {"type": "installationUpdate", "action": "add"}
- assert MsTeamsWebhookMixin.is_new_integration_installation_event(data) is True
+ assert is_new_integration_installation_event(data) is True
def test_valid_non_installation_event(self) -> None:
data: dict[str, Any] = {"type": "message", "action": "add"}
- assert MsTeamsWebhookMixin.is_new_integration_installation_event(data) is False
+ assert is_new_integration_installation_event(data) is False
def test_invalid_missing_type_field(self) -> None:
data: dict[str, Any] = {"action": "add"}
- assert MsTeamsWebhookMixin.is_new_integration_installation_event(data) is False
+ assert is_new_integration_installation_event(data) is False
def test_only_required_fields(self) -> None:
data: dict[str, Any] = {"type": "installationUpdate"}
- assert MsTeamsWebhookMixin.is_new_integration_installation_event(data) is False
+ assert is_new_integration_installation_event(data) is False
def test_additional_fields(self) -> None:
data: dict[str, Any] = {"type": "installationUpdate", "action": "add", "extra": "field"}
- assert MsTeamsWebhookMixin.is_new_integration_installation_event(data) is True
+ assert is_new_integration_installation_event(data) is True
def test_minimum_input(self) -> None:
data: dict[str, Any] = {"type": "installationUpdate", "action": "add"}
- assert MsTeamsWebhookMixin.is_new_integration_installation_event(data) is True
+ assert is_new_integration_installation_event(data) is True
def test_invalid_event_type(self) -> None:
data: dict[str, Any] = {"type": "invalidType", "action": "add"}
- assert MsTeamsWebhookMixin.is_new_integration_installation_event(data) is False
+ assert is_new_integration_installation_event(data) is False
def test_invalid_action(self) -> None:
data: dict[str, Any] = {"type": "installationUpdate", "action": "remove"}
- assert MsTeamsWebhookMixin.is_new_integration_installation_event(data) is False
+ assert is_new_integration_installation_event(data) is False
diff --git a/tests/sentry/integrations/vercel/test_integration.py b/tests/sentry/integrations/vercel/test_integration.py
index 6e13732c22958a..1d27e061128ee7 100644
--- a/tests/sentry/integrations/vercel/test_integration.py
+++ b/tests/sentry/integrations/vercel/test_integration.py
@@ -10,14 +10,14 @@
from sentry.integrations.models.integration import Integration
from sentry.integrations.models.organization_integration import OrganizationIntegration
from sentry.integrations.vercel import VercelClient, VercelIntegrationProvider
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
-from sentry.models.integrations.sentry_app_installation_for_provider import (
- SentryAppInstallationForProvider,
-)
-from sentry.models.integrations.sentry_app_installation_token import SentryAppInstallationToken
from sentry.models.project import Project
from sentry.models.projectkey import ProjectKey, ProjectKeyStatus
from sentry.models.scheduledeletion import ScheduledDeletion
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation_for_provider import (
+ SentryAppInstallationForProvider,
+)
+from sentry.sentry_apps.models.sentry_app_installation_token import SentryAppInstallationToken
from sentry.silo.base import SiloMode
from sentry.testutils.cases import IntegrationTestCase
from sentry.testutils.silo import assume_test_silo_mode, control_silo_test
diff --git a/tests/sentry/integrations/vercel/test_webhook.py b/tests/sentry/integrations/vercel/test_webhook.py
index bd213911be1ee5..952ff8b9c2991a 100644
--- a/tests/sentry/integrations/vercel/test_webhook.py
+++ b/tests/sentry/integrations/vercel/test_webhook.py
@@ -13,7 +13,7 @@
SIGNATURE,
)
from sentry import VERSION
-from sentry.models.integrations.sentry_app_installation_token import SentryAppInstallationToken
+from sentry.sentry_apps.models.sentry_app_installation_token import SentryAppInstallationToken
from sentry.silo.base import SiloMode
from sentry.testutils.cases import APITestCase
from sentry.testutils.helpers import override_options
diff --git a/tests/sentry/integrations/vsts/test_integration.py b/tests/sentry/integrations/vsts/test_integration.py
index 4059c5545d188e..415e3af1b6a4fc 100644
--- a/tests/sentry/integrations/vsts/test_integration.py
+++ b/tests/sentry/integrations/vsts/test_integration.py
@@ -8,13 +8,16 @@
import responses
from fixtures.vsts import CREATE_SUBSCRIPTION, VstsIntegrationTestCase
+from sentry.identity.vsts.provider import VSTSNewIdentityProvider
from sentry.integrations.models.integration import Integration
from sentry.integrations.models.integration_external_project import IntegrationExternalProject
from sentry.integrations.models.organization_integration import OrganizationIntegration
+from sentry.integrations.pipeline import ensure_integration
from sentry.integrations.vsts import VstsIntegration, VstsIntegrationProvider
from sentry.models.repository import Repository
from sentry.shared_integrations.exceptions import IntegrationError, IntegrationProviderError
from sentry.silo.base import SiloMode
+from sentry.testutils.helpers import with_feature
from sentry.testutils.silo import assume_test_silo_mode, control_silo_test
from sentry.users.models.identity import Identity
@@ -22,6 +25,96 @@
LIMITED_SCOPES = ["vso.graph", "vso.serviceendpoint_manage", "vso.work_write"]
+@control_silo_test
+class VstsIntegrationMigrationTest(VstsIntegrationTestCase):
+
+ # Test regular install still works
+ @with_feature("organizations:migrate-azure-devops-integration")
+ @patch(
+ "sentry.integrations.vsts.VstsIntegrationProvider.get_scopes",
+ return_value=VstsIntegrationProvider.NEW_SCOPES,
+ )
+ @patch(
+ "sentry.identity.pipeline.IdentityProviderPipeline.get_provider",
+ return_value=VSTSNewIdentityProvider(),
+ )
+ def test_original_installation_still_works(self, mock_get_scopes, mock_get_provider):
+ self.pipeline = Mock()
+ self.pipeline.organization = self.organization
+ self.assert_installation(new=True)
+ integration = Integration.objects.get(provider="vsts")
+ assert integration.external_id == self.vsts_account_id
+ assert integration.name == self.vsts_account_name
+
+ metadata = integration.metadata
+ assert set(metadata["scopes"]) == set(VstsIntegrationProvider.NEW_SCOPES)
+ assert metadata["subscription"]["id"] == CREATE_SUBSCRIPTION["id"]
+ assert metadata["domain_name"] == self.vsts_base_url
+
+ # Test that install second time doesn't have the metadata and updates the integration object
+ # Assert that the Integration object now has the migrated metadata
+ @with_feature("organizations:migrate-azure-devops-integration")
+ @patch(
+ "sentry.integrations.vsts.VstsIntegrationProvider.get_scopes",
+ return_value=VstsIntegrationProvider.NEW_SCOPES,
+ )
+ def test_migration(self, mock_get_scopes):
+ state = {
+ "account": {"accountName": self.vsts_account_name, "accountId": self.vsts_account_id},
+ "base_url": self.vsts_base_url,
+ "identity": {
+ "data": {
+ "access_token": self.access_token,
+ "expires_in": "3600",
+ "refresh_token": self.refresh_token,
+ "token_type": "jwt-bearer",
+ }
+ },
+ }
+
+ external_id = self.vsts_account_id
+ # Create the integration with old integration metadata
+ old_integraton_obj = self.create_provider_integration(
+ metadata=state, provider="vsts", external_id=external_id
+ )
+ assert old_integraton_obj.metadata.get("subscription", None) is None
+
+ provider = VstsIntegrationProvider()
+ pipeline = Mock()
+ pipeline.organization = self.organization
+ provider.set_pipeline(pipeline)
+
+ data = provider.build_integration(
+ {
+ "account": {"accountName": self.vsts_account_name, "accountId": external_id},
+ "base_url": self.vsts_base_url,
+ "identity": {
+ "data": {
+ "access_token": "new_access_token",
+ "expires_in": "3600",
+ "refresh_token": "new_refresh_token",
+ "token_type": "bearer",
+ }
+ },
+ }
+ )
+ assert external_id == data["external_id"]
+ subscription = data["metadata"]["subscription"]
+ assert subscription["id"] is not None and subscription["secret"] is not None
+ metadata = data.get("metadata")
+ assert metadata is not None
+ assert set(metadata["scopes"]) == set(VstsIntegrationProvider.NEW_SCOPES)
+ assert metadata["integration_migration_version"] == 1
+
+ # Make sure the integration object is updated
+ # ensure_integration will be called in _finish_pipeline
+ new_integration_obj = ensure_integration("vsts", data)
+ assert new_integration_obj.metadata["integration_migration_version"] == 1
+ assert set(new_integration_obj.metadata["scopes"]) == set(
+ VstsIntegrationProvider.NEW_SCOPES
+ )
+
+
@control_silo_test
class VstsIntegrationProviderTest(VstsIntegrationTestCase):
# Test data setup in ``VstsIntegrationTestCase``
@@ -125,7 +218,11 @@ def test_webhook_subscription_created_once(self, mock_get_scopes):
def test_fix_subscription(self, mock_get_scopes):
external_id = self.vsts_account_id
self.create_provider_integration(metadata={}, provider="vsts", external_id=external_id)
- data = VstsIntegrationProvider().build_integration(
+ provider = VstsIntegrationProvider()
+ pipeline = Mock()
+ pipeline.organization = self.organization
+ provider.set_pipeline(pipeline)
+ data = provider.build_integration(
{
"account": {"accountName": self.vsts_account_name, "accountId": external_id},
"base_url": self.vsts_base_url,
diff --git a/tests/sentry/api/endpoints/test_group_participants.py b/tests/sentry/issues/endpoints/test_group_participants.py
similarity index 100%
rename from tests/sentry/api/endpoints/test_group_participants.py
rename to tests/sentry/issues/endpoints/test_group_participants.py
diff --git a/tests/sentry/issues/endpoints/test_organization_group_search_views.py b/tests/sentry/issues/endpoints/test_organization_group_search_views.py
index 780d4e9057904d..c08f0382211982 100644
--- a/tests/sentry/issues/endpoints/test_organization_group_search_views.py
+++ b/tests/sentry/issues/endpoints/test_organization_group_search_views.py
@@ -301,3 +301,34 @@ def test_invalid_over_max_views(self) -> None:
)
]
}
+
+ @with_feature({"organizations:issue-stream-custom-views": True})
+ def test_updated_deleted_view(self) -> None:
+ views = self.client.get(self.url).data
+
+ updated_views = views[1:]
+
+ # First delete a view
+ self.get_success_response(self.organization.slug, views=updated_views)
+
+ # Then reorder the tabs as if the deleted view is still there
+ view_one = views[0]
+ view_two = views[1]
+ views[0] = view_two
+ views[1] = view_one
+
+ # Then save the views as if the deleted view is still there
+ response = self.get_success_response(self.organization.slug, views=views)
+
+ # We should expect the position of these two views to be swapped in the response
+ view_one["position"] = 1
+ view_two["position"] = 0
+
+ assert len(response.data) == 3
+ # Unlike in the plain reordering test, the ids are going to be different here but the views are otherwise the same,
+ # So we need to check for equality of the fields instead of the objects themselves
+ assert response.data[0]["query"] == view_two["query"]
+ assert response.data[0]["querySort"] == view_two["querySort"]
+ assert response.data[1]["query"] == view_one["query"]
+ assert response.data[1]["querySort"] == view_one["querySort"]
+ assert response.data[2] == views[2]
diff --git a/tests/sentry/api/endpoints/test_team_groups_old.py b/tests/sentry/issues/endpoints/test_team_groups_old.py
similarity index 100%
rename from tests/sentry/api/endpoints/test_team_groups_old.py
rename to tests/sentry/issues/endpoints/test_team_groups_old.py
diff --git a/tests/sentry/issues/test_merge.py b/tests/sentry/issues/test_merge.py
index b7b69e5b48ceba..6d2b915d898791 100644
--- a/tests/sentry/issues/test_merge.py
+++ b/tests/sentry/issues/test_merge.py
@@ -12,6 +12,7 @@
from sentry.testutils.cases import TestCase
from sentry.testutils.skips import requires_snuba
from sentry.types.activity import ActivityType
+from sentry.types.group import GroupSubStatus
pytestmark = [requires_snuba]
@@ -21,7 +22,9 @@ def setUp(self) -> None:
self.groups = []
self.project_lookup = {self.project.id: self.project}
for _ in range(5):
- group = self.create_group()
+ group = self.create_group(
+ status=GroupStatus.UNRESOLVED, substatus=GroupSubStatus.ONGOING
+ )
add_group_to_inbox(group, GroupInboxReason.NEW)
self.groups.append(group)
@@ -30,18 +33,18 @@ def test_handle_merge(self, merge_groups: Any) -> None:
Activity.objects.all().delete()
merge = handle_merge(self.groups, self.project_lookup, self.user)
- statuses = list(
- Group.objects.filter(id__in=[g.id for g in self.groups]).values_list(
- "status", flat=True
- )
- )
- assert statuses.count(GroupStatus.PENDING_MERGE) == 4
+ groups = Group.objects.filter(id__in=[g.id for g in self.groups])
+
+ assert len(groups.filter(status=GroupStatus.PENDING_MERGE)) == 4
+ assert len(groups.filter(substatus__isnull=True)) == 4
assert merge_groups.called
primary_group = self.groups[-1]
assert Activity.objects.filter(type=ActivityType.MERGE.value, group=primary_group)
assert merge["parent"] == str(primary_group.id)
assert len(merge["children"]) == 4
+ assert primary_group.status == GroupStatus.UNRESOLVED
+ assert primary_group.substatus == GroupSubStatus.ONGOING
def test_handle_merge_performance_issues(self) -> None:
group = Group.objects.create(
diff --git a/tests/sentry/mediators/token_exchange/test_grant_exchanger.py b/tests/sentry/mediators/token_exchange/test_grant_exchanger.py
index 94d402452afe46..86ca3fee206305 100644
--- a/tests/sentry/mediators/token_exchange/test_grant_exchanger.py
+++ b/tests/sentry/mediators/token_exchange/test_grant_exchanger.py
@@ -7,8 +7,8 @@
from sentry.mediators.token_exchange.grant_exchanger import GrantExchanger
from sentry.models.apiapplication import ApiApplication
from sentry.models.apigrant import ApiGrant
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.sentry_apps.services.app import app_service
from sentry.testutils.cases import TestCase
from sentry.testutils.silo import control_silo_test
diff --git a/tests/sentry/mediators/token_exchange/test_refresher.py b/tests/sentry/mediators/token_exchange/test_refresher.py
index 09ee97e8d7f1da..6937c12705e3a6 100644
--- a/tests/sentry/mediators/token_exchange/test_refresher.py
+++ b/tests/sentry/mediators/token_exchange/test_refresher.py
@@ -6,8 +6,8 @@
from sentry.mediators.token_exchange.refresher import Refresher
from sentry.models.apiapplication import ApiApplication
from sentry.models.apitoken import ApiToken
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.sentry_apps.services.app import app_service
from sentry.testutils.cases import TestCase
from sentry.testutils.silo import control_silo_test
diff --git a/tests/sentry/mediators/token_exchange/test_validator.py b/tests/sentry/mediators/token_exchange/test_validator.py
index fdfb8f83378474..1d81eaae8f892e 100644
--- a/tests/sentry/mediators/token_exchange/test_validator.py
+++ b/tests/sentry/mediators/token_exchange/test_validator.py
@@ -4,7 +4,7 @@
from sentry.coreapi import APIUnauthorized
from sentry.mediators.token_exchange.validator import Validator
-from sentry.models.integrations.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app import SentryApp
from sentry.sentry_apps.services.app import app_service
from sentry.testutils.cases import TestCase
from sentry.testutils.silo import control_silo_test
diff --git a/tests/sentry/migrations/test_0764_migrate_bad_status_substatus_rows.py b/tests/sentry/migrations/test_0764_migrate_bad_status_substatus_rows.py
new file mode 100644
index 00000000000000..06d0a6cf457299
--- /dev/null
+++ b/tests/sentry/migrations/test_0764_migrate_bad_status_substatus_rows.py
@@ -0,0 +1,159 @@
+from datetime import timedelta
+
+from django.utils import timezone
+
+from sentry.models.activity import Activity
+from sentry.models.group import Group, GroupStatus
+from sentry.models.grouphistory import GroupHistory, GroupHistoryStatus
+from sentry.models.groupsnooze import GroupSnooze
+from sentry.models.organization import Organization
+from sentry.testutils.cases import TestMigrations
+from sentry.types.activity import ActivityType
+from sentry.types.group import GroupSubStatus
+
+
+class BackfillMissingUnresolvedSubstatusTest(TestMigrations):
+ migrate_from = "0763_add_created_by_to_broadcasts"
+ migrate_to = "0764_migrate_bad_status_substatus_rows"
+
+ def setup_before_migration(self, app):
+ self.organization = Organization.objects.create(name="test", slug="test")
+ self.project = self.create_project(organization=self.organization)
+ self.do_not_update = Group.objects.create(
+ project=self.project,
+ status=GroupStatus.UNRESOLVED,
+ substatus=GroupSubStatus.NEW,
+ )
+
+ self.ongoing_group = Group.objects.create(
+ project=self.project,
+ status=GroupStatus.UNRESOLVED,
+ )
+ # .update() skips calling the pre_save checks which add a substatus
+ self.ongoing_group.update(
+ substatus=GroupSubStatus.UNTIL_ESCALATING,
+ first_seen=timezone.now() - timedelta(days=8),
+ )
+
+ self.regressed_group = Group.objects.create(
+ project=self.project,
+ status=GroupStatus.UNRESOLVED,
+ first_seen=timezone.now() - timedelta(days=8),
+ )
+ self.regressed_group.update(substatus=GroupSubStatus.FOREVER)
+ GroupHistory.objects.create(
+ group=self.regressed_group,
+ date_added=timezone.now() - timedelta(days=1),
+ organization_id=self.organization.id,
+ project_id=self.project.id,
+ status=GroupHistoryStatus.REGRESSED,
+ )
+
+ self.new_group = Group.objects.create(
+ project=self.project,
+ status=GroupStatus.UNRESOLVED,
+ first_seen=timezone.now(),
+ )
+ self.new_group.update(substatus=GroupSubStatus.UNTIL_CONDITION_MET)
+
+ self.do_not_update_2 = Group.objects.create(
+ project=self.project,
+ status=GroupStatus.IGNORED,
+ substatus=GroupSubStatus.UNTIL_ESCALATING,
+ )
+
+ self.ignored_until_condition_met = Group.objects.create(
+ project=self.project,
+ status=GroupStatus.IGNORED,
+ )
+ # .update() skips calling the pre_save checks which requires a substatus
+ self.ignored_until_condition_met.update(substatus=GroupSubStatus.ONGOING)
+ Activity.objects.create(
+ group=self.ignored_until_condition_met,
+ project=self.project,
+ type=ActivityType.SET_IGNORED.value,
+ data={"ignoreCount": 10},
+ )
+
+ self.ignored_until_condition_met_no_activity = Group.objects.create(
+ project=self.project,
+ status=GroupStatus.IGNORED,
+ )
+ self.ignored_until_condition_met_no_activity.update(substatus=GroupSubStatus.REGRESSED)
+ Activity.objects.create(
+ group=self.ignored_until_condition_met_no_activity,
+ project=self.project,
+ type=ActivityType.SET_IGNORED.value,
+ data={
+ "ignoreCount": None,
+ "ignoreDuration": None,
+ "ignoreUntil": None,
+ "ignoreUserCount": None,
+ "ignoreUserWindow": None,
+ "ignoreWindow": None,
+ "ignoreUntilEscalating": None,
+ },
+ )
+ GroupSnooze.objects.create(
+ group=self.ignored_until_condition_met_no_activity,
+ count=10,
+ )
+
+ self.ignored_until_escalating = Group.objects.create(
+ project=self.project,
+ status=GroupStatus.IGNORED,
+ )
+ # .update() skips calling the pre_save checks which requires a substatus
+ self.ignored_until_escalating.update(substatus=GroupSubStatus.NEW)
+ Activity.objects.create(
+ group=self.ignored_until_escalating,
+ project=self.project,
+ type=ActivityType.SET_IGNORED.value,
+ data={"ignoreUntilEscalating": True},
+ )
+
+ self.ignored_forever = Group.objects.create(
+ project=self.project,
+ status=GroupStatus.IGNORED,
+ )
+ self.ignored_forever.update(substatus=GroupSubStatus.ONGOING)
+
+ self.pending_merge = Group.objects.create(
+ project=self.project,
+ status=GroupStatus.PENDING_MERGE,
+ )
+ self.pending_merge.update(substatus=GroupSubStatus.NEW)
+
+ def test(self):
+ self.do_not_update.refresh_from_db()
+ assert self.do_not_update.substatus == GroupSubStatus.NEW
+
+ self.ongoing_group.refresh_from_db()
+ assert self.ongoing_group.substatus == GroupSubStatus.ONGOING
+
+ self.regressed_group.refresh_from_db()
+ assert self.regressed_group.substatus == GroupSubStatus.REGRESSED
+
+ self.new_group.refresh_from_db()
+ assert self.new_group.substatus == GroupSubStatus.NEW
+
+ self.do_not_update_2.refresh_from_db()
+ assert self.do_not_update_2.substatus == GroupSubStatus.UNTIL_ESCALATING
+
+ self.ignored_until_condition_met.refresh_from_db()
+ assert self.ignored_until_condition_met.substatus == GroupSubStatus.UNTIL_CONDITION_MET
+
+ self.ignored_until_condition_met_no_activity.refresh_from_db()
+ assert (
+ self.ignored_until_condition_met_no_activity.substatus
+ == GroupSubStatus.UNTIL_CONDITION_MET
+ )
+
+ self.ignored_until_escalating.refresh_from_db()
+ assert self.ignored_until_escalating.substatus == GroupSubStatus.UNTIL_ESCALATING
+
+ self.ignored_forever.refresh_from_db()
+ assert self.ignored_forever.substatus == GroupSubStatus.FOREVER
+
+ self.pending_merge.refresh_from_db()
+ assert self.pending_merge.substatus is None
diff --git a/tests/sentry/models/test_apitoken.py b/tests/sentry/models/test_apitoken.py
index b8a8c30b36261d..5cc25a6c6e0d30 100644
--- a/tests/sentry/models/test_apitoken.py
+++ b/tests/sentry/models/test_apitoken.py
@@ -7,8 +7,8 @@
from sentry.conf.server import SENTRY_SCOPE_HIERARCHY_MAPPING, SENTRY_SCOPES
from sentry.hybridcloud.models import ApiTokenReplica
from sentry.models.apitoken import ApiToken, NotSupported, PlaintextSecretAlreadyRead
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
-from sentry.models.integrations.sentry_app_installation_token import SentryAppInstallationToken
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation_token import SentryAppInstallationToken
from sentry.silo.base import SiloMode
from sentry.testutils.cases import TestCase
from sentry.testutils.outbox import outbox_runner
diff --git a/tests/sentry/ratelimits/utils/test_get_ratelimit_key.py b/tests/sentry/ratelimits/utils/test_get_ratelimit_key.py
index a01c9733693628..38f61e52a38a84 100644
--- a/tests/sentry/ratelimits/utils/test_get_ratelimit_key.py
+++ b/tests/sentry/ratelimits/utils/test_get_ratelimit_key.py
@@ -9,10 +9,10 @@
from sentry.auth.system import SystemToken
from sentry.hybridcloud.models.apitokenreplica import ApiTokenReplica
from sentry.models.apitoken import ApiToken
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
-from sentry.models.integrations.sentry_app_installation_token import SentryAppInstallationToken
from sentry.ratelimits import get_rate_limit_config, get_rate_limit_key
from sentry.ratelimits.config import RateLimitConfig
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation_token import SentryAppInstallationToken
from sentry.testutils.cases import TestCase
from sentry.testutils.silo import all_silo_test, assume_test_silo_mode_of
from sentry.types.ratelimit import RateLimit, RateLimitCategory
diff --git a/tests/sentry/receivers/outbox/test_control.py b/tests/sentry/receivers/outbox/test_control.py
index a4ba6862d7ab8d..0c653d100b2704 100644
--- a/tests/sentry/receivers/outbox/test_control.py
+++ b/tests/sentry/receivers/outbox/test_control.py
@@ -19,7 +19,7 @@ class ProcessControlOutboxTest(TestCase):
identifier = 1
@patch("sentry.receivers.outbox.control.maybe_process_tombstone")
- def test_process_integration_updatess(self, mock_maybe_process):
+ def test_process_integration_updates(self, mock_maybe_process):
process_integration_updates(
object_identifier=self.identifier, region_name=_TEST_REGION.name
)
@@ -36,7 +36,7 @@ def test_process_api_application_updates(self, mock_maybe_process):
ApiApplication, self.identifier, region_name=_TEST_REGION.name
)
- @patch("sentry.receivers.outbox.control.region_caching_service")
+ @patch("sentry.tasks.sentry_apps.region_caching_service")
def test_process_sentry_app_updates(self, mock_caching):
org = self.create_organization()
sentry_app = self.create_sentry_app()
@@ -48,7 +48,10 @@ def test_process_sentry_app_updates(self, mock_caching):
slug=sentry_app.slug, organization=org_two
)
- process_sentry_app_updates(object_identifier=sentry_app.id, region_name=_TEST_REGION.name)
+ with self.tasks():
+ process_sentry_app_updates(
+ object_identifier=sentry_app.id, region_name=_TEST_REGION.name
+ )
mock_caching.clear_key.assert_any_call(
key=f"app_service.get_installation:{install.id}", region_name=_TEST_REGION.name
)
diff --git a/tests/sentry/replays/consumers/test_recording.py b/tests/sentry/replays/consumers/test_recording.py
index 8de0b1d41b8a93..a685e7ba656d68 100644
--- a/tests/sentry/replays/consumers/test_recording.py
+++ b/tests/sentry/replays/consumers/test_recording.py
@@ -12,14 +12,13 @@
from arroyo.types import BrokerValue, Message, Partition, Topic
from sentry_kafka_schemas.schema_types.ingest_replay_recordings_v1 import ReplayRecording
-from sentry import options
from sentry.models.organizationonboardingtask import OnboardingTask, OnboardingTaskStatus
from sentry.replays.consumers.recording import ProcessReplayRecordingStrategyFactory
from sentry.replays.consumers.recording_buffered import (
RecordingBufferedStrategyFactory,
cast_payload_from_bytes,
)
-from sentry.replays.lib.storage import _make_recording_filename, _make_video_filename, storage_kv
+from sentry.replays.lib.storage import _make_recording_filename, storage_kv
from sentry.replays.models import ReplayRecordingSegment
from sentry.replays.usecases.pack import unpack
from sentry.testutils.cases import TransactionTestCase
@@ -80,28 +79,16 @@ def get_recording_data(self, segment_id):
return unpack(zlib.decompress(result))[1]
def get_video_data(self, segment_id):
- if self.organization.id in options.get("replay.replay-video.organization-file-packing"):
- result = storage_kv.get(
- _make_recording_filename(
- project_id=self.project.id,
- replay_id=self.replay_id,
- segment_id=segment_id,
- retention_days=30,
- )
- )
- if result:
- return unpack(zlib.decompress(result))[0]
- else:
- result = storage_kv.get(
- _make_video_filename(
- project_id=self.project.id,
- replay_id=self.replay_id,
- segment_id=segment_id,
- retention_days=30,
- )
+ result = storage_kv.get(
+ _make_recording_filename(
+ project_id=self.project.id,
+ replay_id=self.replay_id,
+ segment_id=segment_id,
+ retention_days=30,
)
- if result:
- return result
+ )
+ if result:
+ return unpack(zlib.decompress(result))[0]
def processing_factory(self):
return ProcessReplayRecordingStrategyFactory(
@@ -229,38 +216,35 @@ def test_event_with_replay_video(self, track_outcome, mock_record, mock_onboardi
def test_event_with_replay_video_packed(self, track_outcome, mock_record, mock_onboarding_task):
segment_id = 0
- with self.options(
- {"replay.replay-video.organization-file-packing": [self.organization.id]}
- ):
- self.submit(
- self.nonchunked_messages(
- segment_id=segment_id,
- compressed=True,
- replay_video=b"hello, world!",
- )
+ self.submit(
+ self.nonchunked_messages(
+ segment_id=segment_id,
+ compressed=True,
+ replay_video=b"hello, world!",
)
- self.assert_replay_recording_segment(segment_id, compressed=True)
- assert self.get_video_data(segment_id) == b"hello, world!"
+ )
+ self.assert_replay_recording_segment(segment_id, compressed=True)
+ assert self.get_video_data(segment_id) == b"hello, world!"
- self.project.refresh_from_db()
- assert self.project.flags.has_replays
+ self.project.refresh_from_db()
+ assert self.project.flags.has_replays
- mock_onboarding_task.assert_called_with(
- organization_id=self.project.organization_id,
- task=OnboardingTask.SESSION_REPLAY,
- status=OnboardingTaskStatus.COMPLETE,
- date_completed=ANY,
- )
+ mock_onboarding_task.assert_called_with(
+ organization_id=self.project.organization_id,
+ task=OnboardingTask.SESSION_REPLAY,
+ status=OnboardingTaskStatus.COMPLETE,
+ date_completed=ANY,
+ )
- mock_record.assert_called_with(
- "first_replay.sent",
- organization_id=self.organization.id,
- project_id=self.project.id,
- platform=self.project.platform,
- user_id=self.organization.default_owner_id,
- )
+ mock_record.assert_called_with(
+ "first_replay.sent",
+ organization_id=self.organization.id,
+ project_id=self.project.id,
+ platform=self.project.platform,
+ user_id=self.organization.default_owner_id,
+ )
- assert track_outcome.called
+ assert track_outcome.called
@patch("sentry.models.OrganizationOnboardingTask.objects.record")
@patch("sentry.analytics.record")
diff --git a/tests/sentry/replays/test_organization_replay_index.py b/tests/sentry/replays/test_organization_replay_index.py
index 9300426f027b22..65eff73a5c08db 100644
--- a/tests/sentry/replays/test_organization_replay_index.py
+++ b/tests/sentry/replays/test_organization_replay_index.py
@@ -1762,6 +1762,52 @@ def test_query_scalar_optimization_multiple_varying(self):
response_data = response.json()
assert len(response_data["data"]) == 1
+ def test_query_scalar_optimization_varying_with_tags(self):
+ project = self.create_project(teams=[self.team])
+
+ replay1_id = uuid.uuid4().hex
+ seq1_timestamp = datetime.datetime.now() - datetime.timedelta(seconds=22)
+ seq2_timestamp = datetime.datetime.now() - datetime.timedelta(seconds=5)
+
+ self.store_replays(
+ mock_replay(seq1_timestamp, project.id, replay1_id, tags={"something": "else"})
+ )
+ self.store_replays(mock_replay(seq2_timestamp, project.id, replay1_id))
+
+ with self.feature(self.features):
+ # EQ and IN supported.
+ response = self.client.get(self.url + "?field=id&query=something:else&statsPeriod=1d")
+ assert response.status_code == 200
+ assert response.headers["X-Data-Source"] == "scalar-subquery"
+
+ response = self.client.get(
+ self.url + "?field=id&query=something:else,other&statsPeriod=1d"
+ )
+ assert response.status_code == 200
+ assert response.headers["X-Data-Source"] == "scalar-subquery"
+
+ # Not operators are not supported.
+ response = self.client.get(self.url + "?field=id&query=!something:else&statsPeriod=1d")
+ assert response.status_code == 200
+ assert response.headers["X-Data-Source"] == "aggregated-subquery"
+
+ response = self.client.get(
+ self.url + "?field=id&query=!something:else,other&statsPeriod=1d"
+ )
+ assert response.status_code == 200
+ assert response.headers["X-Data-Source"] == "aggregated-subquery"
+
+ # Match not supported.
+ response = self.client.get(self.url + "?field=id&query=something:*else*&statsPeriod=1d")
+ assert response.status_code == 200
+ assert response.headers["X-Data-Source"] == "aggregated-subquery"
+
+ response = self.client.get(
+ self.url + "?field=id&query=!something:*else*&statsPeriod=1d"
+ )
+ assert response.status_code == 200
+ assert response.headers["X-Data-Source"] == "aggregated-subquery"
+
def test_get_replays_missing_segment_0(self):
"""Test fetching replays when the 0th segment is missing."""
project = self.create_project(teams=[self.team])
diff --git a/tests/sentry/replays/unit/test_ingest_dom_index.py b/tests/sentry/replays/unit/test_ingest_dom_index.py
index 026d2c02d0eeca..34eaa548fafb32 100644
--- a/tests/sentry/replays/unit/test_ingest_dom_index.py
+++ b/tests/sentry/replays/unit/test_ingest_dom_index.py
@@ -3,9 +3,11 @@
import uuid
from typing import Any
from unittest import mock
+from unittest.mock import Mock
import pytest
+from sentry.models.project import Project
from sentry.replays.testutils import mock_replay_event
from sentry.replays.usecases.ingest.dom_index import (
_get_testid,
@@ -15,7 +17,6 @@
log_canvas_size,
parse_replay_actions,
)
-from sentry.testutils.helpers.features import Feature
from sentry.testutils.pytest.fixtures import django_db_all
from sentry.utils import json
@@ -28,7 +29,15 @@ def patch_rage_click_issue_with_replay_event():
yield m
-def test_get_user_actions():
+@pytest.fixture(autouse=True)
+def mock_project() -> Project:
+ """Has id=1. Use for unit tests so we can skip @django_db"""
+ proj = Mock(spec=Project)
+ proj.id = 1
+ return proj
+
+
+def test_get_user_actions(mock_project):
"""Test "get_user_actions" function."""
events = [
{
@@ -64,7 +73,7 @@ def test_get_user_actions():
}
]
- user_actions = get_user_actions(1, uuid.uuid4().hex, events, None)
+ user_actions = get_user_actions(mock_project, uuid.uuid4().hex, events, None)
assert len(user_actions) == 1
assert user_actions[0]["node_id"] == 1
assert user_actions[0]["tag"] == "div"
@@ -83,7 +92,7 @@ def test_get_user_actions():
assert len(user_actions[0]["event_hash"]) == 36
-def test_get_user_actions_str_payload():
+def test_get_user_actions_str_payload(mock_project):
"""Test "get_user_actions" function."""
events = [
{
@@ -96,11 +105,11 @@ def test_get_user_actions_str_payload():
}
]
- user_actions = get_user_actions(1, uuid.uuid4().hex, events, None)
+ user_actions = get_user_actions(mock_project, uuid.uuid4().hex, events, None)
assert len(user_actions) == 0
-def test_get_user_actions_missing_node():
+def test_get_user_actions_missing_node(mock_project):
"""Test "get_user_actions" function."""
events = [
{
@@ -118,11 +127,11 @@ def test_get_user_actions_missing_node():
}
]
- user_actions = get_user_actions(1, uuid.uuid4().hex, events, None)
+ user_actions = get_user_actions(mock_project, uuid.uuid4().hex, events, None)
assert len(user_actions) == 0
-def test_get_user_actions_performance_spans():
+def test_get_user_actions_performance_spans(mock_project):
"""Test that "get_user_actions" doesn't error when collecting rsrc metrics, on various formats of performanceSpan"""
# payloads are not realistic examples - only include the fields necessary for testing
# TODO: does not test if metrics.distribution() is called downstream, with correct param types.
@@ -194,10 +203,10 @@ def test_get_user_actions_performance_spans():
},
},
]
- get_user_actions(1, uuid.uuid4().hex, events, None)
+ get_user_actions(mock_project, uuid.uuid4().hex, events, None)
-def test_parse_replay_actions():
+def test_parse_replay_actions(mock_project):
events = [
{
"type": 5,
@@ -232,7 +241,7 @@ def test_parse_replay_actions():
},
}
]
- replay_actions = parse_replay_actions(1, "1", 30, events, None)
+ replay_actions = parse_replay_actions(mock_project, "1", 30, events, None)
assert replay_actions is not None
assert replay_actions["type"] == "replay_event"
@@ -375,15 +384,8 @@ def test_parse_replay_dead_click_actions(patch_rage_click_issue_with_replay_even
},
]
- with Feature(
- {
- "organizations:session-replay-rage-click-issue-creation": True,
- }
- ):
- default_project.update_option("sentry:replay_rage_click_issues", True)
- replay_actions = parse_replay_actions(
- default_project.id, "1", 30, events, mock_replay_event()
- )
+ default_project.update_option("sentry:replay_rage_click_issues", True)
+ replay_actions = parse_replay_actions(default_project, "1", 30, events, mock_replay_event())
assert patch_rage_click_issue_with_replay_event.call_count == 2
assert replay_actions is not None
assert replay_actions["type"] == "replay_event"
@@ -535,13 +537,8 @@ def test_rage_click_issue_creation_no_component_name(
},
]
- with Feature(
- {
- "organizations:session-replay-rage-click-issue-creation": True,
- }
- ):
- default_project.update_option("sentry:replay_rage_click_issues", True)
- parse_replay_actions(default_project.id, "1", 30, events, mock_replay_event())
+ default_project.update_option("sentry:replay_rage_click_issues", True)
+ parse_replay_actions(default_project, "1", 30, events, mock_replay_event())
# test that 2 rage click issues are still created
assert patch_rage_click_issue_with_replay_event.call_count == 2
@@ -588,7 +585,7 @@ def test_parse_replay_click_actions_not_dead(
}
]
- replay_actions = parse_replay_actions(default_project.id, "1", 30, events, None)
+ replay_actions = parse_replay_actions(default_project, "1", 30, events, None)
assert patch_rage_click_issue_with_replay_event.delay.call_count == 0
assert replay_actions is None
@@ -632,7 +629,7 @@ def test_parse_replay_rage_click_actions(default_project):
},
}
]
- replay_actions = parse_replay_actions(default_project.id, "1", 30, events, None)
+ replay_actions = parse_replay_actions(default_project, "1", 30, events, None)
assert replay_actions is not None
assert replay_actions["type"] == "replay_event"
@@ -672,7 +669,7 @@ def test_encode_as_uuid():
assert isinstance(uuid.UUID(a), uuid.UUID)
-def test_parse_request_response_latest():
+def test_parse_request_response_latest(mock_project):
events = [
{
"type": 5,
@@ -711,14 +708,14 @@ def test_parse_request_response_latest():
}
]
with mock.patch("sentry.utils.metrics.distribution") as timing:
- parse_replay_actions(1, "1", 30, events, None)
+ parse_replay_actions(mock_project, "1", 30, events, None)
assert timing.call_args_list == [
mock.call("replays.usecases.ingest.request_body_size", 2949, unit="byte"),
mock.call("replays.usecases.ingest.response_body_size", 94, unit="byte"),
]
-def test_parse_request_response_no_info():
+def test_parse_request_response_no_info(mock_project):
events = [
{
"type": 5,
@@ -739,11 +736,11 @@ def test_parse_request_response_no_info():
},
},
]
- parse_replay_actions(1, "1", 30, events, None)
+ parse_replay_actions(mock_project, "1", 30, events, None)
# just make sure we don't raise
-def test_parse_request_response_old_format_request_only():
+def test_parse_request_response_old_format_request_only(mock_project):
events = [
{
"type": 5,
@@ -766,13 +763,13 @@ def test_parse_request_response_old_format_request_only():
},
]
with mock.patch("sentry.utils.metrics.distribution") as timing:
- parse_replay_actions(1, "1", 30, events, None)
+ parse_replay_actions(mock_project, "1", 30, events, None)
assert timing.call_args_list == [
mock.call("replays.usecases.ingest.request_body_size", 1002, unit="byte"),
]
-def test_parse_request_response_old_format_response_only():
+def test_parse_request_response_old_format_response_only(mock_project):
events = [
{
"type": 5,
@@ -794,13 +791,13 @@ def test_parse_request_response_old_format_response_only():
},
]
with mock.patch("sentry.utils.metrics.distribution") as timing:
- parse_replay_actions(1, "1", 30, events, None)
+ parse_replay_actions(mock_project, "1", 30, events, None)
assert timing.call_args_list == [
mock.call("replays.usecases.ingest.response_body_size", 1002, unit="byte"),
]
-def test_parse_request_response_old_format_request_and_response():
+def test_parse_request_response_old_format_request_and_response(mock_project):
events = [
{
"type": 5,
@@ -823,7 +820,7 @@ def test_parse_request_response_old_format_request_and_response():
},
]
with mock.patch("sentry.utils.metrics.distribution") as timing:
- parse_replay_actions(1, "1", 30, events, None)
+ parse_replay_actions(mock_project, "1", 30, events, None)
assert timing.call_args_list == [
mock.call("replays.usecases.ingest.request_body_size", 1002, unit="byte"),
mock.call("replays.usecases.ingest.response_body_size", 8001, unit="byte"),
@@ -942,15 +939,8 @@ def test_parse_replay_rage_clicks_with_replay_event(
},
]
- with Feature(
- {
- "organizations:session-replay-rage-click-issue-creation": True,
- }
- ):
- default_project.update_option("sentry:replay_rage_click_issues", True)
- replay_actions = parse_replay_actions(
- default_project.id, "1", 30, events, mock_replay_event()
- )
+ default_project.update_option("sentry:replay_rage_click_issues", True)
+ replay_actions = parse_replay_actions(default_project, "1", 30, events, mock_replay_event())
assert patch_rage_click_issue_with_replay_event.call_count == 2
assert replay_actions is not None
assert replay_actions["type"] == "replay_event"
@@ -961,7 +951,7 @@ def test_parse_replay_rage_clicks_with_replay_event(
assert isinstance(replay_actions["payload"], list)
-def test_log_sdk_options():
+def test_log_sdk_options(mock_project):
events: list[dict[str, Any]] = [
{
"data": {
@@ -993,11 +983,11 @@ def test_log_sdk_options():
mock.patch("random.randint") as randint,
):
randint.return_value = 0
- parse_replay_actions(1, "1", 30, events, None)
+ parse_replay_actions(mock_project, "1", 30, events, None)
assert logger.info.call_args_list == [mock.call("sentry.replays.slow_click", extra=log)]
-def test_log_large_dom_mutations():
+def test_log_large_dom_mutations(mock_project):
events: list[dict[str, Any]] = [
{
"type": 5,
@@ -1023,7 +1013,7 @@ def test_log_large_dom_mutations():
mock.patch("random.randint") as randint,
):
randint.return_value = 0
- parse_replay_actions(1, "1", 30, events, None)
+ parse_replay_actions(mock_project, "1", 30, events, None)
assert logger.info.call_args_list == [mock.call("Large DOM Mutations List:", extra=log)]
@@ -1100,7 +1090,7 @@ def test_log_canvas_size():
log_canvas_size(1, 1, "a", [])
-def test_emit_click_negative_node_id():
+def test_emit_click_negative_node_id(mock_project):
"""Test "get_user_actions" function."""
events = [
{
@@ -1136,5 +1126,5 @@ def test_emit_click_negative_node_id():
}
]
- user_actions = get_user_actions(1, uuid.uuid4().hex, events, None)
+ user_actions = get_user_actions(mock_project, uuid.uuid4().hex, events, None)
assert len(user_actions) == 0
diff --git a/tests/sentry/sentry_apps/models/__init__.py b/tests/sentry/sentry_apps/models/__init__.py
new file mode 100644
index 00000000000000..e69de29bb2d1d6
diff --git a/tests/sentry/models/test_sentryapp.py b/tests/sentry/sentry_apps/models/test_sentryapp.py
similarity index 98%
rename from tests/sentry/models/test_sentryapp.py
rename to tests/sentry/sentry_apps/models/test_sentryapp.py
index b67b13d6e5d57e..68a30e115aab66 100644
--- a/tests/sentry/models/test_sentryapp.py
+++ b/tests/sentry/sentry_apps/models/test_sentryapp.py
@@ -2,7 +2,7 @@
from sentry.hybridcloud.models.outbox import ControlOutbox
from sentry.hybridcloud.outbox.category import OutboxCategory
from sentry.models.apiapplication import ApiApplication
-from sentry.models.integrations.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app import SentryApp
from sentry.testutils.cases import TestCase
from sentry.testutils.silo import control_silo_test
diff --git a/tests/sentry/models/test_sentryappinstallation.py b/tests/sentry/sentry_apps/models/test_sentryappinstallation.py
similarity index 93%
rename from tests/sentry/models/test_sentryappinstallation.py
rename to tests/sentry/sentry_apps/models/test_sentryappinstallation.py
index e21920e9ec3a65..34613eae73558f 100644
--- a/tests/sentry/models/test_sentryappinstallation.py
+++ b/tests/sentry/sentry_apps/models/test_sentryappinstallation.py
@@ -2,8 +2,8 @@
import sentry.hybridcloud.rpc.caching as caching_module
from sentry.models.apiapplication import ApiApplication
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.testutils.cases import TestCase
from sentry.testutils.silo import control_silo_test
from sentry.types.region import get_region_for_organization
diff --git a/tests/sentry/models/test_sentryappinstallationtoken.py b/tests/sentry/sentry_apps/models/test_sentryappinstallationtoken.py
similarity index 86%
rename from tests/sentry/models/test_sentryappinstallationtoken.py
rename to tests/sentry/sentry_apps/models/test_sentryappinstallationtoken.py
index c0963828847c9c..b4481fa5b7ca0f 100644
--- a/tests/sentry/models/test_sentryappinstallationtoken.py
+++ b/tests/sentry/sentry_apps/models/test_sentryappinstallationtoken.py
@@ -1,10 +1,10 @@
from sentry.models.apiapplication import ApiApplication
from sentry.models.apitoken import ApiToken
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
-from sentry.models.integrations.sentry_app_installation_for_provider import (
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation_for_provider import (
SentryAppInstallationForProvider,
)
-from sentry.models.integrations.sentry_app_installation_token import SentryAppInstallationToken
+from sentry.sentry_apps.models.sentry_app_installation_token import SentryAppInstallationToken
from sentry.testutils.cases import TestCase
from sentry.testutils.silo import control_silo_test
diff --git a/tests/sentry/sentry_apps/services/test_app.py b/tests/sentry/sentry_apps/services/test_app.py
index a5f83986104478..d2ba930ed8a110 100644
--- a/tests/sentry/sentry_apps/services/test_app.py
+++ b/tests/sentry/sentry_apps/services/test_app.py
@@ -1,5 +1,5 @@
from sentry.constants import SentryAppInstallationStatus
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.sentry_apps.services.app import app_service
from sentry.testutils.factories import Factories
from sentry.testutils.pytest.fixtures import django_db_all
diff --git a/tests/sentry/sentry_apps/services/test_hook_service.py b/tests/sentry/sentry_apps/services/test_hook_service.py
index 6868fc1556bb2c..3930a04096f8d9 100644
--- a/tests/sentry/sentry_apps/services/test_hook_service.py
+++ b/tests/sentry/sentry_apps/services/test_hook_service.py
@@ -1,6 +1,6 @@
-from sentry.models.integrations.sentry_app import EVENT_EXPANSION
-from sentry.models.servicehook import ServiceHook
from sentry.sentry_apps.logic import consolidate_events, expand_events
+from sentry.sentry_apps.models.sentry_app import EVENT_EXPANSION
+from sentry.sentry_apps.models.servicehook import ServiceHook
from sentry.sentry_apps.services.hook import RpcServiceHook, hook_service
from sentry.silo.base import SiloMode
from sentry.testutils.cases import TestCase
diff --git a/tests/sentry/sentry_apps/test_sentry_app_creator.py b/tests/sentry/sentry_apps/test_sentry_app_creator.py
index c80a6b5a3fc3e9..8452020fb0bbd8 100644
--- a/tests/sentry/sentry_apps/test_sentry_app_creator.py
+++ b/tests/sentry/sentry_apps/test_sentry_app_creator.py
@@ -6,10 +6,10 @@
from sentry.integrations.models.integration_feature import IntegrationFeature, IntegrationTypes
from sentry.models.apiapplication import ApiApplication
from sentry.models.auditlogentry import AuditLogEntry
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_component import SentryAppComponent
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
from sentry.sentry_apps.logic import SentryAppCreator
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_component import SentryAppComponent
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.testutils.cases import TestCase
from sentry.testutils.silo import control_silo_test
from sentry.users.models.user import User
diff --git a/tests/sentry/sentry_apps/test_sentry_app_installation_creator.py b/tests/sentry/sentry_apps/test_sentry_app_installation_creator.py
index 1ec19a7a8426a0..4df4b1c03dd641 100644
--- a/tests/sentry/sentry_apps/test_sentry_app_installation_creator.py
+++ b/tests/sentry/sentry_apps/test_sentry_app_installation_creator.py
@@ -6,8 +6,8 @@
from sentry.constants import SentryAppInstallationStatus
from sentry.models.apigrant import ApiGrant
from sentry.models.auditlogentry import AuditLogEntry
-from sentry.models.servicehook import ServiceHook, ServiceHookProject
from sentry.sentry_apps.installations import SentryAppInstallationCreator
+from sentry.sentry_apps.models.servicehook import ServiceHook, ServiceHookProject
from sentry.silo.base import SiloMode
from sentry.testutils.cases import TestCase
from sentry.testutils.silo import assume_test_silo_mode, control_silo_test
diff --git a/tests/sentry/sentry_apps/test_sentry_app_installation_token_creator.py b/tests/sentry/sentry_apps/test_sentry_app_installation_token_creator.py
index 67f912341a8c7e..fb5639b478b256 100644
--- a/tests/sentry/sentry_apps/test_sentry_app_installation_token_creator.py
+++ b/tests/sentry/sentry_apps/test_sentry_app_installation_token_creator.py
@@ -1,12 +1,12 @@
from datetime import UTC, datetime
from unittest.mock import patch
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
-from sentry.models.integrations.sentry_app_installation_token import SentryAppInstallationToken
from sentry.sentry_apps.installations import (
SentryAppInstallationCreator,
SentryAppInstallationTokenCreator,
)
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
+from sentry.sentry_apps.models.sentry_app_installation_token import SentryAppInstallationToken
from sentry.testutils.cases import TestCase
from sentry.testutils.silo import control_silo_test
diff --git a/tests/sentry/sentry_apps/test_sentry_app_updater.py b/tests/sentry/sentry_apps/test_sentry_app_updater.py
index 6c6dff003570d9..141e2ef1a3419d 100644
--- a/tests/sentry/sentry_apps/test_sentry_app_updater.py
+++ b/tests/sentry/sentry_apps/test_sentry_app_updater.py
@@ -6,10 +6,10 @@
from sentry.constants import SentryAppStatus
from sentry.coreapi import APIError
from sentry.models.apitoken import ApiToken
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_component import SentryAppComponent
-from sentry.models.servicehook import ServiceHook
from sentry.sentry_apps.logic import SentryAppUpdater, expand_events
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_component import SentryAppComponent
+from sentry.sentry_apps.models.servicehook import ServiceHook
from sentry.silo.base import SiloMode
from sentry.testutils.cases import TestCase
from sentry.testutils.silo import assume_test_silo_mode, control_silo_test
diff --git a/tests/sentry/tasks/test_backfill_seer_grouping_records.py b/tests/sentry/tasks/test_backfill_seer_grouping_records.py
index a849b8c8c9abb3..9d7af966ff19ea 100644
--- a/tests/sentry/tasks/test_backfill_seer_grouping_records.py
+++ b/tests/sentry/tasks/test_backfill_seer_grouping_records.py
@@ -15,8 +15,11 @@
from urllib3.response import HTTPResponse
from sentry import options
+from sentry.api.exceptions import ResourceDoesNotExist
from sentry.conf.server import SEER_SIMILARITY_MODEL_VERSION
from sentry.eventstore.models import Event
+from sentry.grouping.api import GroupingConfigNotFound
+from sentry.grouping.enhancer.exceptions import InvalidEnhancerConfig
from sentry.issues.occurrence_consumer import EventLookupError
from sentry.models.group import Group, GroupStatus
from sentry.models.grouphash import GroupHash
@@ -1487,6 +1490,39 @@ def test_backfill_seer_grouping_records_empty_nodestore(
worker_number=None,
)
+ @with_feature("projects:similarity-embeddings-backfill")
+ @patch("sentry.tasks.embeddings_grouping.utils.logger")
+ @patch("sentry.tasks.embeddings_grouping.utils.lookup_group_data_stacktrace_bulk")
+ @patch(
+ "sentry.tasks.embeddings_grouping.backfill_seer_grouping_records_for_project.call_next_backfill"
+ )
+ def test_backfill_seer_grouping_records_nodestore_grouping_config_not_found(
+ self,
+ mock_call_next_backfill,
+ mock_lookup_group_data_stacktrace_bulk,
+ mock_logger,
+ ):
+ exceptions = (GroupingConfigNotFound(), ResourceDoesNotExist(), InvalidEnhancerConfig())
+
+ for exception in exceptions:
+ mock_lookup_group_data_stacktrace_bulk.side_effect = exception
+
+ with TaskRunner():
+ backfill_seer_grouping_records_for_project(self.project.id, None)
+
+ groups = Group.objects.all()
+ group_ids_sorted = sorted([group.id for group in groups], reverse=True)
+ mock_call_next_backfill.assert_called_with(
+ last_processed_group_id=group_ids_sorted[-1],
+ project_id=self.project.id,
+ last_processed_project_index=0,
+ cohort=None,
+ enable_ingestion=False,
+ skip_processed_projects=False,
+ skip_project_ids=None,
+ worker_number=None,
+ )
+
@with_feature("projects:similarity-embeddings-backfill")
@patch("sentry.tasks.embeddings_grouping.utils.logger")
@patch("sentry.tasks.embeddings_grouping.utils.post_bulk_grouping_records")
diff --git a/tests/sentry/tasks/test_sentry_apps.py b/tests/sentry/tasks/test_sentry_apps.py
index 16d083adf9e10a..5cc8ca5b653f1d 100644
--- a/tests/sentry/tasks/test_sentry_apps.py
+++ b/tests/sentry/tasks/test_sentry_apps.py
@@ -17,9 +17,9 @@
from sentry.integrations.request_buffer import IntegrationRequestBuffer
from sentry.models.activity import Activity
from sentry.models.auditlogentry import AuditLogEntry
-from sentry.models.integrations.sentry_app import SentryApp
-from sentry.models.integrations.sentry_app_installation import SentryAppInstallation
from sentry.models.rule import Rule
+from sentry.sentry_apps.models.sentry_app import SentryApp
+from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
from sentry.shared_integrations.exceptions import ClientError
from sentry.tasks.post_process import post_process_group
from sentry.tasks.sentry_apps import (
diff --git a/tests/sentry/uptime/endpoints/test_project_uptime_alert_details.py b/tests/sentry/uptime/endpoints/test_project_uptime_alert_details.py
index 125b8d0a82e956..a8d09fc0ebcffe 100644
--- a/tests/sentry/uptime/endpoints/test_project_uptime_alert_details.py
+++ b/tests/sentry/uptime/endpoints/test_project_uptime_alert_details.py
@@ -1,3 +1,5 @@
+from unittest import mock
+
import pytest
from rest_framework.exceptions import ErrorDetail
@@ -92,6 +94,32 @@ def test_not_found(self):
resp = self.get_error_response(self.organization.slug, self.project.slug, 3)
assert resp.status_code == 404
+ @mock.patch("sentry.uptime.endpoints.validators.MAX_MONITORS_PER_DOMAIN", 1)
+ def test_domain_limit(self):
+ # First monitor is for test-one.example.com
+ self.create_project_uptime_subscription(
+ uptime_subscription=self.create_uptime_subscription(
+ url="test-one.example.com",
+ url_domain="example",
+ url_domain_suffix="com",
+ )
+ )
+
+ # Update second monitor to use the same domain. This will fail with a
+ # validation error
+ uptime_subscription = self.create_project_uptime_subscription()
+ resp = self.get_error_response(
+ self.organization.slug,
+ uptime_subscription.project.slug,
+ uptime_subscription.id,
+ status_code=400,
+ url="https://test-two.example.com",
+ )
+ assert (
+ resp.data["url"][0]
+ == "The domain *.example.com has already been used in 1 uptime monitoring alerts, which is the limit. You cannot create any additional alerts for this domain."
+ )
+
class ProjectUptimeAlertDetailsDeleteEndpointTest(ProjectUptimeAlertDetailsBaseEndpointTest):
method = "delete"
diff --git a/tests/sentry/uptime/rdap/test_tasks.py b/tests/sentry/uptime/rdap/test_tasks.py
index 38c92de960119e..1b9ff53604e416 100644
--- a/tests/sentry/uptime/rdap/test_tasks.py
+++ b/tests/sentry/uptime/rdap/test_tasks.py
@@ -17,13 +17,11 @@ def test(self, mock_fetch_subscription_rdap_info):
mock_fetch_subscription_rdap_info.return_value = test_info
uptime_subscription = self.create_uptime_subscription(
- url="https://example.com",
- url_domain="example",
- url_domain_suffix="com",
+ url="https://some.example.com/health",
)
fetch_subscription_rdap_info(uptime_subscription.id)
uptime_subscription.refresh_from_db()
- mock_fetch_subscription_rdap_info.assert_called_with("example.com")
+ mock_fetch_subscription_rdap_info.assert_called_with("some.example.com")
assert uptime_subscription.host_provider_id == "TEST-HANDLE"
assert uptime_subscription.host_provider_name == "Rick Sanchez"
diff --git a/tests/sentry/web/frontend/test_organization_auth_settings.py b/tests/sentry/web/frontend/test_organization_auth_settings.py
index ea6a3e1622607f..ec8b382d25e77e 100644
--- a/tests/sentry/web/frontend/test_organization_auth_settings.py
+++ b/tests/sentry/web/frontend/test_organization_auth_settings.py
@@ -20,13 +20,13 @@
from sentry.models.authidentity import AuthIdentity
from sentry.models.authprovider import AuthProvider
from sentry.models.authproviderreplica import AuthProviderReplica
-from sentry.models.integrations.sentry_app_installation_for_provider import (
- SentryAppInstallationForProvider,
-)
from sentry.models.organization import Organization
from sentry.models.organizationmember import OrganizationMember
from sentry.models.team import Team
from sentry.organizations.services.organization import organization_service
+from sentry.sentry_apps.models.sentry_app_installation_for_provider import (
+ SentryAppInstallationForProvider,
+)
from sentry.signals import receivers_raise_on_send
from sentry.silo.base import SiloMode
from sentry.testutils.cases import AuthProviderTestCase, PermissionTestCase
diff --git a/tests/snuba/api/endpoints/test_organization_events_span_indexed.py b/tests/snuba/api/endpoints/test_organization_events_span_indexed.py
index 616fd2d8a89a30..15f9cbf8655433 100644
--- a/tests/snuba/api/endpoints/test_organization_events_span_indexed.py
+++ b/tests/snuba/api/endpoints/test_organization_events_span_indexed.py
@@ -498,6 +498,32 @@ def test_tag_wildcards(self):
assert response.status_code == 200, response.content
assert response.data["data"] == [{"foo": "BaR", "count()": 1}]
+ def test_query_for_missing_tag(self):
+ self.store_spans(
+ [
+ self.create_span(
+ {"description": "foo"},
+ start_ts=self.ten_mins_ago,
+ ),
+ self.create_span(
+ {"description": "qux", "tags": {"foo": "bar"}},
+ start_ts=self.ten_mins_ago,
+ ),
+ ],
+ is_eap=self.is_eap,
+ )
+
+ response = self.do_request(
+ {
+ "field": ["foo", "count()"],
+ "query": 'foo:""',
+ "project": self.project.id,
+ "dataset": self.dataset,
+ }
+ )
+ assert response.status_code == 200, response.content
+ assert response.data["data"] == [{"foo": "", "count()": 1}]
+
class OrganizationEventsEAPSpanEndpointTest(OrganizationEventsSpanIndexedEndpointTest):
is_eap = True
diff --git a/webpack.config.ts b/webpack.config.ts
index 725c0b0a030af0..ae7349bb451c2f 100644
--- a/webpack.config.ts
+++ b/webpack.config.ts
@@ -827,7 +827,10 @@ appConfig.plugins?.push(
enabled: true,
},
bundleSizeOptimizations: {
- excludeDebugStatements: IS_PRODUCTION,
+ // This is enabled so that our SDKs send exceptions to Sentry
+ excludeDebugStatements: false,
+ excludeReplayIframe: true,
+ excludeReplayShadowDom: true,
},
})
);
diff --git a/yarn.lock b/yarn.lock
index 13078de592c0ad..0492a0651246fa 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1229,59 +1229,59 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
-"@biomejs/biome@^1.8.3":
- version "1.8.3"
- resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-1.8.3.tgz#3b5eecea90d973f71618aae3e6e8be4d2ca23e42"
- integrity sha512-/uUV3MV+vyAczO+vKrPdOW0Iaet7UnJMU4bNMinggGJTAnBPjCoLEYcyYtYHNnUNYlv4xZMH6hVIQCAozq8d5w==
+"@biomejs/biome@^1.9.1":
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-1.9.1.tgz#93866252fb441687fdbed0a1b4e146e76ef7495e"
+ integrity sha512-Ps0Rg0zg3B1zpx+zQHMz5b0n0PBNCAaXttHEDTVrJD5YXR6Uj3T+abTDgeS3wsu4z5i2whqcE1lZxGyWH4bZYg==
optionalDependencies:
- "@biomejs/cli-darwin-arm64" "1.8.3"
- "@biomejs/cli-darwin-x64" "1.8.3"
- "@biomejs/cli-linux-arm64" "1.8.3"
- "@biomejs/cli-linux-arm64-musl" "1.8.3"
- "@biomejs/cli-linux-x64" "1.8.3"
- "@biomejs/cli-linux-x64-musl" "1.8.3"
- "@biomejs/cli-win32-arm64" "1.8.3"
- "@biomejs/cli-win32-x64" "1.8.3"
-
-"@biomejs/cli-darwin-arm64@1.8.3":
- version "1.8.3"
- resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.8.3.tgz#be2bfdd445cd2d3cb0ff41a96a72ec761753997c"
- integrity sha512-9DYOjclFpKrH/m1Oz75SSExR8VKvNSSsLnVIqdnKexj6NwmiMlKk94Wa1kZEdv6MCOHGHgyyoV57Cw8WzL5n3A==
-
-"@biomejs/cli-darwin-x64@1.8.3":
- version "1.8.3"
- resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.8.3.tgz#47d408edd9f5c04069fbcf8610bacf1db8c6c0d9"
- integrity sha512-UeW44L/AtbmOF7KXLCoM+9PSgPo0IDcyEUfIoOXYeANaNXXf9mLUwV1GeF2OWjyic5zj6CnAJ9uzk2LT3v/wAw==
-
-"@biomejs/cli-linux-arm64-musl@1.8.3":
- version "1.8.3"
- resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.8.3.tgz#44df284383d57cf4f28daeedd080dad7be05df78"
- integrity sha512-9yjUfOFN7wrYsXt/T/gEWfvVxKlnh3yBpnScw98IF+oOeCYb5/b/+K7YNqKROV2i1DlMjg9g/EcN9wvj+NkMuQ==
-
-"@biomejs/cli-linux-arm64@1.8.3":
- version "1.8.3"
- resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.8.3.tgz#6a6b1da1dfce0294a028cbb5d6c40d73691dd713"
- integrity sha512-fed2ji8s+I/m8upWpTJGanqiJ0rnlHOK3DdxsyVLZQ8ClY6qLuPc9uehCREBifRJLl/iJyQpHIRufLDeotsPtw==
-
-"@biomejs/cli-linux-x64-musl@1.8.3":
- version "1.8.3"
- resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.8.3.tgz#ceef30a8ee1a00d4ad31e32dd31ba2a661f2719d"
- integrity sha512-UHrGJX7PrKMKzPGoEsooKC9jXJMa28TUSMjcIlbDnIO4EAavCoVmNQaIuUSH0Ls2mpGMwUIf+aZJv657zfWWjA==
-
-"@biomejs/cli-linux-x64@1.8.3":
- version "1.8.3"
- resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-1.8.3.tgz#665df74d19fb8f83001a9d80824d3a1723e2123f"
- integrity sha512-I8G2QmuE1teISyT8ie1HXsjFRz9L1m5n83U1O6m30Kw+kPMPSKjag6QGUn+sXT8V+XWIZxFFBoTDEDZW2KPDDw==
-
-"@biomejs/cli-win32-arm64@1.8.3":
- version "1.8.3"
- resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.8.3.tgz#0fb6f58990f4de0331a6ed22c47c66f5a89133cc"
- integrity sha512-J+Hu9WvrBevfy06eU1Na0lpc7uR9tibm9maHynLIoAjLZpQU3IW+OKHUtyL8p6/3pT2Ju5t5emReeIS2SAxhkQ==
-
-"@biomejs/cli-win32-x64@1.8.3":
- version "1.8.3"
- resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-1.8.3.tgz#6a9dc5a4e13357277da43c015cd5cdc374035448"
- integrity sha512-/PJ59vA1pnQeKahemaQf4Nyj7IKUvGQSc3Ze1uIGi+Wvr1xF7rGobSrAAG01T/gUDG21vkDsZYM03NAmPiVkqg==
+ "@biomejs/cli-darwin-arm64" "1.9.1"
+ "@biomejs/cli-darwin-x64" "1.9.1"
+ "@biomejs/cli-linux-arm64" "1.9.1"
+ "@biomejs/cli-linux-arm64-musl" "1.9.1"
+ "@biomejs/cli-linux-x64" "1.9.1"
+ "@biomejs/cli-linux-x64-musl" "1.9.1"
+ "@biomejs/cli-win32-arm64" "1.9.1"
+ "@biomejs/cli-win32-x64" "1.9.1"
+
+"@biomejs/cli-darwin-arm64@1.9.1":
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.1.tgz#efa26ae2302350d3b00720cdda15576dfcf9cddd"
+ integrity sha512-js0brHswq/BoeKgfSEUJYOjUOlML6p65Nantti+PsoQ61u9+YVGIZ7325LK7iUpDH8KVJT+Bx7K2b/6Q//W1Pw==
+
+"@biomejs/cli-darwin-x64@1.9.1":
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.1.tgz#a597b28d681483110c6aa7d28dcdefa0772b5450"
+ integrity sha512-2zVyjUg5rN0k8XrytkubQWLbp2r/AS5wPhXs4vgVjvqbLnzo32EGX8p61gzroF2dH9DCUCfskdrigCGqNdEbpg==
+
+"@biomejs/cli-linux-arm64-musl@1.9.1":
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.1.tgz#d3d744c16060d09129b3e7c67697d54915c84342"
+ integrity sha512-L/JmXKvhsZ1lTgqOr3tWkzuY/NRppdIscHeC9aaiR72WjnBgJS94mawl9BWmGB3aWBc0q6oSDWnBS7617EMMmA==
+
+"@biomejs/cli-linux-arm64@1.9.1":
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.1.tgz#aeece1d7842ae0389df14bc18ec7eeddffcb989c"
+ integrity sha512-QgxwfnG+r2aer5RNGR67Ey91Tv7xXW8E9YckHhwuyWjdLEvKWkrSJrhVG/6ub0kVvTSNkYOuT/7/jMOFBuUbRA==
+
+"@biomejs/cli-linux-x64-musl@1.9.1":
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.1.tgz#e7ac315a22344394db5ec3385235d88a1449f649"
+ integrity sha512-gY+eFLIAW45v3WicQHicvjRfA0ntMZHx7h937bXwBMFNFoKmB6rMi6+fKQ6/hiS6juhsFxZdZIz20m15s49J6A==
+
+"@biomejs/cli-linux-x64@1.9.1":
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.1.tgz#40f8c7aa55b95f3f74e3e4f57a07a3cc6dee2fef"
+ integrity sha512-F0INygtzI2L2n2R1KtYHGr3YWDt9Up1zrUluwembM+iJ1dXN3qzlSb7deFUsSJm4FaIPriqs6Xa56ukdQW6UeQ==
+
+"@biomejs/cli-win32-arm64@1.9.1":
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.1.tgz#599f562fccbc9fd7e00dc3f43fb93ff3091f98e9"
+ integrity sha512-7Jahxar3OB+aTPOgXisMJmMKMsjcK+UmdlG3UIOQjzN/ZFEsPV+GT3bfrVjZDQaCw/zes0Cqd7VTWFjFTC/+MQ==
+
+"@biomejs/cli-win32-x64@1.9.1":
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.1.tgz#f754bb8a94f2dab6311eff39dc30c4a6aa0f5628"
+ integrity sha512-liSRWjWzFhyG7s1jg/Bbv9FL+ha/CEd5tFO3+dFIJNplL4TnvAivtyfRVi/tu/pNjISbV1k9JwdBewtAKAgA0w==
"@codecov/bundler-plugin-core@^1.0.0":
version "1.0.0"
@@ -3528,34 +3528,29 @@
dependencies:
tslib "^2.4.0"
-"@tanstack/match-sorter-utils@^8.7.0":
- version "8.11.8"
- resolved "https://registry.yarnpkg.com/@tanstack/match-sorter-utils/-/match-sorter-utils-8.11.8.tgz#9132c2a21cf18ca2f0071b604ddadb7a66e73367"
- integrity sha512-3VPh0SYMGCa5dWQEqNab87UpCMk+ANWHDP4ALs5PeEW9EpfTAbrezzaOk/OiM52IESViefkoAOYuxdoa04p6aA==
- dependencies:
- remove-accents "0.4.2"
+"@tanstack/query-core@5.56.2":
+ version "5.56.2"
+ resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.56.2.tgz#2def2fb0290cd2836bbb08afb0c175595bb8109b"
+ integrity sha512-gor0RI3/R5rVV3gXfddh1MM+hgl0Z4G7tj6Xxpq6p2I03NGPaJ8dITY9Gz05zYYb/EJq9vPas/T4wn9EaDPd4Q==
-"@tanstack/query-core@4.29.7":
- version "4.29.7"
- resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.29.7.tgz#9fe4587e23cb9566b937c518ffa44226041d388d"
- integrity sha512-GXG4b5hV2Loir+h2G+RXhJdoZhJLnrBWsuLB2r0qBRyhWuXq9w/dWxzvpP89H0UARlH6Mr9DiVj4SMtpkF/aUA==
+"@tanstack/query-devtools@5.56.1":
+ version "5.56.1"
+ resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.56.1.tgz#319c362dd19c6cfe005e74a8777baefa4a4f72de"
+ integrity sha512-xnp9jq/9dHfSCDmmf+A5DjbIjYqbnnUL2ToqlaaviUQGRTapXQ8J+GxusYUu1IG0vZMaWdiVUA4HRGGZYAUU+A==
-"@tanstack/react-query-devtools@^4.36.1":
- version "4.36.1"
- resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-4.36.1.tgz#7e63601135902a993ca9af73507b125233b1554e"
- integrity sha512-WYku83CKP3OevnYSG8Y/QO9g0rT75v1om5IvcWUwiUZJ4LanYGLVCZ8TdFG5jfsq4Ej/lu2wwDAULEUnRIMBSw==
+"@tanstack/react-query-devtools@^5.56.2":
+ version "5.56.2"
+ resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.56.2.tgz#c129cdb811927085434ea27691e4b7f605eb4128"
+ integrity sha512-7nINJtRZZVwhTTyDdMIcSaXo+EHMLYJu1S2e6FskvvD5prx87LlAXXWZDfU24Qm4HjshEtM5lS3HIOszNGblcw==
dependencies:
- "@tanstack/match-sorter-utils" "^8.7.0"
- superjson "^1.10.0"
- use-sync-external-store "^1.2.0"
+ "@tanstack/query-devtools" "5.56.1"
-"@tanstack/react-query@^4.29.7":
- version "4.29.7"
- resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.29.7.tgz#772996905a81ca64172582891c5a82e88dbafccd"
- integrity sha512-ijBWEzAIo09fB1yd22slRZzprrZ5zMdWYzBnCg5qiXuFbH78uGN1qtGz8+Ed4MuhaPaYSD+hykn+QEKtQviEtg==
+"@tanstack/react-query@^5.56.2":
+ version "5.56.2"
+ resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.56.2.tgz#3a0241b9d010910905382f5e99160997b8795f91"
+ integrity sha512-SR0GzHVo6yzhN72pnRhkEFRAHMsUo5ZPzAxfTMvUxFIDVS6W9LYUp6nXW3fcHVdg0ZJl8opSH85jqahvm6DSVg==
dependencies:
- "@tanstack/query-core" "4.29.7"
- use-sync-external-store "^1.2.0"
+ "@tanstack/query-core" "5.56.2"
"@tanstack/react-virtual@^3.5.1":
version "3.5.1"
@@ -5494,13 +5489,6 @@ copy-anything@^2.0.1:
dependencies:
is-what "^3.12.0"
-copy-anything@^3.0.2:
- version "3.0.5"
- resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-3.0.5.tgz#2d92dce8c498f790fa7ad16b01a1ae5a45b020a0"
- integrity sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==
- dependencies:
- is-what "^4.1.8"
-
copy-webpack-plugin@^12.0.2:
version "12.0.2"
resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz#935e57b8e6183c82f95bd937df658a59f6a2da28"
@@ -8032,11 +8020,6 @@ is-what@^3.12.0:
resolved "https://registry.yarnpkg.com/is-what/-/is-what-3.14.1.tgz#e1222f46ddda85dead0fd1c9df131760e77755c1"
integrity sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==
-is-what@^4.1.8:
- version "4.1.16"
- resolved "https://registry.yarnpkg.com/is-what/-/is-what-4.1.16.tgz#1ad860a19da8b4895ad5495da3182ce2acdd7a6f"
- integrity sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==
-
is-wsl@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271"
@@ -8705,6 +8688,11 @@ jsonfile@^6.0.1:
optionalDependencies:
graceful-fs "^4.1.6"
+jsonrepair@^3.8.0:
+ version "3.8.0"
+ resolved "https://registry.yarnpkg.com/jsonrepair/-/jsonrepair-3.8.0.tgz#33a1b0d3630c452e9945ef07d760469cdfad8823"
+ integrity sha512-89lrxpwp+IEcJ6kwglF0HH3Tl17J08JEpYfXnvvjdp4zV4rjSoGu2NdQHxBs7yTOk3ETjTn9du48pBy8iBqj1w==
+
"jsx-ast-utils@^2.4.1 || ^3.0.0":
version "3.3.5"
resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz#4766bd05a8e2a11af222becd19e15575e52a853a"
@@ -10656,11 +10644,6 @@ relateurl@^0.2.7:
resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"
integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=
-remove-accents@0.4.2:
- version "0.4.2"
- resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.4.2.tgz#0a43d3aaae1e80db919e07ae254b285d9e1c7bb5"
- integrity sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==
-
renderkid@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-3.0.0.tgz#5fd823e4d6951d37358ecc9a58b1f06836b6268a"
@@ -11439,13 +11422,6 @@ substyle@^9.1.0:
"@babel/runtime" "^7.3.4"
invariant "^2.2.4"
-superjson@^1.10.0:
- version "1.13.3"
- resolved "https://registry.yarnpkg.com/superjson/-/superjson-1.13.3.tgz#3bd64046f6c0a47062850bb3180ef352a471f930"
- integrity sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==
- dependencies:
- copy-anything "^3.0.2"
-
supports-color@^5.3.0:
version "5.5.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
@@ -11918,11 +11894,6 @@ url-parse@^1.5.3:
querystringify "^2.1.1"
requires-port "^1.0.0"
-use-sync-external-store@^1.2.0:
- version "1.2.0"
- resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
- integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
-
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"