diff --git a/package.json b/package.json
index c0338d1a8163ee..895b499060054c 100644
--- a/package.json
+++ b/package.json
@@ -140,7 +140,7 @@
"papaparse": "^5.3.2",
"pegjs": "^0.10.0",
"pegjs-loader": "^0.5.6",
- "platformicons": "^5.10.9",
+ "platformicons": "^6.0.1",
"po-catalog-loader": "2.0.0",
"prettier": "3.3.2",
"prismjs": "^1.29.0",
@@ -263,9 +263,7 @@
"last 3 iOS major versions",
"Firefox ESR"
],
- "test": [
- "current node"
- ]
+ "test": ["current node"]
},
"volta": {
"extends": ".volta.json"
diff --git a/pyproject.toml b/pyproject.toml
index 10426b3eb3db2c..92902db8f36b13 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -360,7 +360,6 @@ module = [
"sentry.plugins.bases.notify",
"sentry.plugins.config",
"sentry.plugins.endpoints",
- "sentry.plugins.providers.repository",
"sentry.receivers.releases",
"sentry.release_health.metrics_sessions_v2",
"sentry.replays.endpoints.project_replay_clicks_index",
@@ -530,7 +529,6 @@ module = [
"sentry.issues.constants",
"sentry.issues.endpoints",
"sentry.issues.endpoints.group_events",
- "sentry.issues.endpoints.organization_activity",
"sentry.issues.endpoints.organization_group_search_views",
"sentry.issues.endpoints.organization_release_previous_commits",
"sentry.issues.endpoints.organization_searches",
@@ -626,7 +624,6 @@ module = [
"tests.sentry.issues",
"tests.sentry.issues.endpoints",
"tests.sentry.issues.endpoints.test_actionable_items",
- "tests.sentry.issues.endpoints.test_organization_activity",
"tests.sentry.issues.endpoints.test_organization_group_search_views",
"tests.sentry.issues.endpoints.test_organization_searches",
"tests.sentry.issues.endpoints.test_project_stacktrace_link",
diff --git a/src/sentry/api/endpoints/project_rules.py b/src/sentry/api/endpoints/project_rules.py
index 59c252e5a182af..b6bb8e1ad562c3 100644
--- a/src/sentry/api/endpoints/project_rules.py
+++ b/src/sentry/api/endpoints/project_rules.py
@@ -81,12 +81,12 @@ def __init__(
"""
rule.data will supersede rule_data if passed in
"""
- self._project_id: int = project_id
- self._rule_data: dict[Any, Any] = rule.data if rule else rule_data
- self._rule_id: int | None = rule_id
- self._rule: Rule | None = rule
+ self._project_id = project_id
+ self._rule_data = rule.data if rule else rule_data or {}
+ self._rule_id = rule_id
+ self._rule = rule
- self._keys_to_check: set[str] = self._get_keys_to_check()
+ self._keys_to_check = self._get_keys_to_check()
self._matcher_funcs_by_key: dict[str, Callable[[Rule, str], MatcherResult]] = {
self.ENVIRONMENT_KEY: self._environment_matcher,
@@ -99,9 +99,7 @@ def _get_keys_to_check(self) -> set[str]:
Some keys are ignored as they are not part of the logic.
Some keys are required to check, and are added on top.
"""
- keys_to_check: set[str] = {
- key for key in list(self._rule_data.keys()) if key not in self.EXCLUDED_FIELDS
- }
+ keys_to_check = {key for key in self._rule_data if key not in self.EXCLUDED_FIELDS}
keys_to_check.update(self.SPECIAL_FIELDS)
return keys_to_check
diff --git a/src/sentry/api/paginator.py b/src/sentry/api/paginator.py
index 0dc9ddfb3998bf..61684a9161f3e7 100644
--- a/src/sentry/api/paginator.py
+++ b/src/sentry/api/paginator.py
@@ -659,6 +659,8 @@ def _sort_combined_querysets(item):
sort_keys = []
sort_keys.append(self.get_item_key(item))
if len(self.model_key_map.get(type(item))) > 1:
+ # XXX: This doesn't do anything - it just uses a column name as the sort key. It should be pulling the
+ # value of the other keys out instead.
sort_keys.extend(iter(self.model_key_map.get(type(item))[1:]))
sort_keys.append(type(item).__name__)
return tuple(sort_keys)
diff --git a/src/sentry/api/serializers/models/dashboard.py b/src/sentry/api/serializers/models/dashboard.py
index 0360acbb41ab23..52c90df7b8e580 100644
--- a/src/sentry/api/serializers/models/dashboard.py
+++ b/src/sentry/api/serializers/models/dashboard.py
@@ -2,6 +2,7 @@
import orjson
+from sentry import features
from sentry.api.serializers import Serializer, register, serialize
from sentry.constants import ALL_ACCESS_PROJECTS
from sentry.models.dashboard import Dashboard
@@ -36,6 +37,21 @@ def get_attrs(self, item_list, user):
return result
def serialize(self, obj, attrs, user, **kwargs):
+ widget_type = (
+ DashboardWidgetTypes.get_type_name(obj.widget_type)
+ or DashboardWidgetTypes.TYPE_NAMES[0]
+ )
+
+ if (
+ features.has(
+ "organizations:performance-discover-dataset-selector",
+ obj.dashboard.organization,
+ actor=user,
+ )
+ and obj.discover_widget_split is not None
+ ):
+ widget_type = DashboardWidgetTypes.get_type_name(obj.discover_widget_split)
+
return {
"id": str(obj.id),
"title": obj.title,
@@ -49,8 +65,7 @@ def serialize(self, obj, attrs, user, **kwargs):
"queries": attrs["queries"],
"limit": obj.limit,
# Default to discover type if null
- "widgetType": DashboardWidgetTypes.get_type_name(obj.widget_type)
- or DashboardWidgetTypes.TYPE_NAMES[0],
+ "widgetType": widget_type,
"layout": obj.detail.get("layout") if obj.detail else None,
}
diff --git a/src/sentry/data_export/models.py b/src/sentry/data_export/models.py
index b416d43d050d59..d3e339e5aa106d 100644
--- a/src/sentry/data_export/models.py
+++ b/src/sentry/data_export/models.py
@@ -1,4 +1,7 @@
+from __future__ import annotations
+
import logging
+from typing import Any
import orjson
from django.conf import settings
@@ -39,7 +42,7 @@ class ExportedData(Model):
date_finished = models.DateTimeField(null=True)
date_expired = models.DateTimeField(null=True, db_index=True)
query_type = BoundedPositiveIntegerField(choices=ExportQueryType.as_choices())
- query_info = JSONField()
+ query_info: models.Field[dict[str, Any], dict[str, Any]] = JSONField()
@property
def status(self) -> ExportStatus:
diff --git a/src/sentry/discover/models.py b/src/sentry/discover/models.py
index 8d2d98fa06412c..22ed47e5719c32 100644
--- a/src/sentry/discover/models.py
+++ b/src/sentry/discover/models.py
@@ -1,5 +1,7 @@
+from __future__ import annotations
+
from enum import Enum
-from typing import ClassVar
+from typing import Any, ClassVar
from django.db import models, router, transaction
from django.db.models import Q, UniqueConstraint
@@ -89,7 +91,7 @@ class DiscoverSavedQuery(Model):
organization = FlexibleForeignKey("sentry.Organization")
created_by_id = HybridCloudForeignKey("sentry.User", null=True, on_delete="SET_NULL")
name = models.CharField(max_length=255)
- query = JSONField()
+ query: models.Field[dict[str, Any], dict[str, Any]] = JSONField()
version = models.IntegerField(null=True)
date_created = models.DateTimeField(auto_now_add=True)
date_updated = models.DateTimeField(auto_now=True)
diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py
index 14ab58ce705abe..4d3070afbb84f6 100644
--- a/src/sentry/features/temporary.py
+++ b/src/sentry/features/temporary.py
@@ -467,6 +467,10 @@ def register_temporary_features(manager: FeatureManager):
manager.add("organizations:uptime-automatic-hostname-detection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE)
# Enables automatic subscription creation in uptime
manager.add("organizations:uptime-automatic-subscription-creation", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE)
+ # Enabled returning uptime monitors from the rule api
+ manager.add("organizations:uptime-rule-api", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE)
+ # Enable creating issues via the issue platform
+ manager.add("organizations:uptime-create-issues", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE)
# Enables uptime related settings for projects and orgs
manager.add('organizations:uptime-settings', OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE)
manager.add("organizations:use-metrics-layer", OrganizationFeature, FeatureHandlerStrategy.REMOTE)
diff --git a/src/sentry/incidents/endpoints/organization_alert_rule_index.py b/src/sentry/incidents/endpoints/organization_alert_rule_index.py
index 93fbf853a7e295..f2307fdd27ff72 100644
--- a/src/sentry/incidents/endpoints/organization_alert_rule_index.py
+++ b/src/sentry/incidents/endpoints/organization_alert_rule_index.py
@@ -2,7 +2,7 @@
from datetime import UTC, datetime
from django.conf import settings
-from django.db.models import DateTimeField, IntegerField, OuterRef, Q, Subquery, Value
+from django.db.models import Case, DateTimeField, IntegerField, OuterRef, Q, Subquery, Value, When
from django.db.models.functions import Coalesce
from drf_spectacular.utils import extend_schema, extend_schema_serializer
from rest_framework import serializers, status
@@ -35,9 +35,10 @@
AlertRuleSerializerResponse,
CombinedRuleSerializer,
)
+from sentry.incidents.endpoints.utils import parse_team_params
from sentry.incidents.logic import get_slack_actions_with_async_lookups
from sentry.incidents.models.alert_rule import AlertRule
-from sentry.incidents.models.incident import Incident
+from sentry.incidents.models.incident import Incident, IncidentStatus
from sentry.incidents.serializers import AlertRuleSerializer as DrfAlertRuleSerializer
from sentry.incidents.utils.sentry_apps import trigger_sentry_app_action_creators_for_incidents
from sentry.integrations.slack.utils import RedisRuleStatus
@@ -49,10 +50,9 @@
from sentry.signals import alert_rule_created
from sentry.snuba.dataset import Dataset
from sentry.tasks.integrations.slack import find_channel_id_for_alert_rule
+from sentry.uptime.models import ProjectUptimeSubscription, UptimeStatus
from sentry.utils.cursors import Cursor, StringCursor
-from .utils import parse_team_params
-
class AlertRuleIndexMixin(Endpoint):
def fetch_metric_alert(self, request, organization, project=None):
@@ -152,7 +152,7 @@ class OrganizationCombinedRuleIndexEndpoint(OrganizationEndpoint):
def get(self, request: Request, organization) -> Response:
"""
- Fetches (metric) alert rules and legacy (issue alert) rules for an organization
+ Fetches metric, issue and uptime alert rules for an organization
"""
project_ids = self.get_requested_project_ids_unchecked(request) or None
if project_ids == {-1}: # All projects for org:
@@ -196,6 +196,11 @@ def get(self, request: Request, organization) -> Response:
project__in=projects,
)
+ uptime_rules = ProjectUptimeSubscription.objects.filter(project__in=projects)
+
+ if not features.has("organizations:uptime-rule-api", organization):
+ uptime_rules = ProjectUptimeSubscription.objects.none()
+
if not features.has("organizations:performance-view", organization):
# Filter to only error alert rules
alert_rules = alert_rules.filter(snuba_query__dataset=Dataset.Events.value)
@@ -208,8 +213,9 @@ def get(self, request: Request, organization) -> Response:
name = request.GET.get("name", None)
if name:
- alert_rules = alert_rules.filter(Q(name__icontains=name))
- issue_rules = issue_rules.filter(Q(label__icontains=name))
+ alert_rules = alert_rules.filter(name__icontains=name)
+ issue_rules = issue_rules.filter(label__icontains=name)
+ uptime_rules = uptime_rules.filter(name__icontains=name)
if teams_query is not None:
team_ids = teams_query.values_list("id", flat=True)
@@ -220,6 +226,7 @@ def get(self, request: Request, organization) -> Response:
team_rule_condition = team_rule_condition | Q(owner_team_id__isnull=True)
alert_rules = alert_rules.filter(team_alert_condition)
issue_rules = issue_rules.filter(team_rule_condition)
+ uptime_rules = uptime_rules.filter(team_rule_condition)
expand = request.GET.getlist("expand", [])
if "latestIncident" in expand:
@@ -255,6 +262,14 @@ def get(self, request: Request, organization) -> Response:
issue_rules = issue_rules.annotate(
incident_status=Value(-2, output_field=IntegerField())
)
+ uptime_rules = uptime_rules.annotate(
+ incident_status=Case(
+ # If an uptime monitor is failing we want to treat it the same as if an alert is failing, so sort
+ # by the critical status
+ When(uptime_status=UptimeStatus.FAILED, then=IncidentStatus.CRITICAL.value),
+ default=-2,
+ )
+ )
if "date_triggered" in sort_key:
far_past_date = Value(datetime.min.replace(tzinfo=UTC), output_field=DateTimeField())
@@ -269,22 +284,20 @@ def get(self, request: Request, organization) -> Response:
),
)
issue_rules = issue_rules.annotate(date_triggered=far_past_date)
- alert_rules_count = alert_rules.count()
- issue_rules_count = issue_rules.count()
+ uptime_rules = uptime_rules.annotate(date_triggered=far_past_date)
alert_rule_intermediary = CombinedQuerysetIntermediary(alert_rules, sort_key)
rule_intermediary = CombinedQuerysetIntermediary(issue_rules, rule_sort_key)
+ uptime_intermediary = CombinedQuerysetIntermediary(uptime_rules, rule_sort_key)
response = self.paginate(
request,
paginator_cls=CombinedQuerysetPaginator,
on_results=lambda x: serialize(x, request.user, CombinedRuleSerializer(expand=expand)),
default_per_page=25,
- intermediaries=[alert_rule_intermediary, rule_intermediary],
+ intermediaries=[alert_rule_intermediary, rule_intermediary, uptime_intermediary],
desc=not is_asc,
cursor_cls=StringCursor if case_insensitive else Cursor,
case_insensitive=case_insensitive,
)
- response["X-Sentry-Issue-Rule-Hits"] = issue_rules_count
- response[ALERT_RULES_COUNT_HEADER] = alert_rules_count
response[MAX_QUERY_SUBSCRIPTIONS_HEADER] = settings.MAX_QUERY_SUBSCRIPTIONS_PER_ORG
return response
diff --git a/src/sentry/incidents/endpoints/serializers/alert_rule.py b/src/sentry/incidents/endpoints/serializers/alert_rule.py
index 8fe72fcc3118f4..4c6745f70d83dc 100644
--- a/src/sentry/incidents/endpoints/serializers/alert_rule.py
+++ b/src/sentry/incidents/endpoints/serializers/alert_rule.py
@@ -30,6 +30,7 @@
from sentry.sentry_apps.services.app import app_service
from sentry.sentry_apps.services.app.model import RpcSentryAppComponentContext
from sentry.snuba.models import SnubaQueryEventType
+from sentry.uptime.models import ProjectUptimeSubscription
from sentry.users.services.user import RpcUser
from sentry.users.services.user.service import user_service
@@ -395,6 +396,14 @@ def get_attrs(
serialized_rule["id"]: serialized_rule for serialized_rule in serialized_issue_rules
}
+ serialized_uptime_monitors = serialize(
+ [x for x in item_list if isinstance(x, ProjectUptimeSubscription)],
+ user=user,
+ )
+ serialized_uptime_monitor_map_by_id = {
+ item["id"]: item for item in serialized_uptime_monitors
+ }
+
for item in item_list:
item_id = str(item.id)
if item_id in serialized_alert_rule_map_by_id:
@@ -418,6 +427,9 @@ def get_attrs(
elif item_id in serialized_issue_rule_map_by_id:
# This is an issue alert rule
results[item] = serialized_issue_rule_map_by_id[item_id]
+ elif item_id in serialized_uptime_monitor_map_by_id:
+ # This is an uptime monitor
+ results[item] = serialized_uptime_monitor_map_by_id[item_id]
else:
logger.error(
"Alert Rule found but dropped during serialization",
@@ -432,18 +444,18 @@ def get_attrs(
def serialize(
self,
- obj: Rule | AlertRule,
+ obj: Rule | AlertRule | ProjectUptimeSubscription,
attrs: Mapping[Any, Any],
user: User | RpcUser,
**kwargs: Any,
) -> MutableMapping[Any, Any]:
+ updated_attrs = {**attrs}
if isinstance(obj, AlertRule):
- alert_rule_attrs: MutableMapping[Any, Any] = {**attrs}
- alert_rule_attrs["type"] = "alert_rule"
- return alert_rule_attrs
+ updated_attrs["type"] = "alert_rule"
elif isinstance(obj, Rule):
- rule_attrs: MutableMapping[Any, Any] = {**attrs}
- rule_attrs["type"] = "rule"
- return rule_attrs
+ updated_attrs["type"] = "rule"
+ elif isinstance(obj, ProjectUptimeSubscription):
+ updated_attrs["type"] = "uptime"
else:
raise AssertionError(f"Invalid rule to serialize: {type(obj)}")
+ return updated_attrs
diff --git a/src/sentry/models/activity.py b/src/sentry/models/activity.py
index cfe925edc3ee89..32ceb5baba8921 100644
--- a/src/sentry/models/activity.py
+++ b/src/sentry/models/activity.py
@@ -115,7 +115,7 @@ class Activity(Model):
# if the user is not set, it's assumed to be the system
user_id = HybridCloudForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete="SET_NULL")
datetime = models.DateTimeField(default=timezone.now)
- data: models.Field[dict[str, Any], dict[str, Any]] = GzippedDictField(null=True)
+ data: models.Field[dict[str, Any] | None, dict[str, Any]] = GzippedDictField(null=True)
objects: ClassVar[ActivityManager] = ActivityManager()
diff --git a/src/sentry/models/authidentity.py b/src/sentry/models/authidentity.py
index 3554ba202438e7..a85b23f85225ee 100644
--- a/src/sentry/models/authidentity.py
+++ b/src/sentry/models/authidentity.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
from collections.abc import Collection
from typing import Any
@@ -25,7 +27,7 @@ class AuthIdentity(ReplicatedControlModel):
user = FlexibleForeignKey(settings.AUTH_USER_MODEL)
auth_provider = FlexibleForeignKey("sentry.AuthProvider")
ident = models.CharField(max_length=128)
- data = JSONField()
+ data: models.Field[dict[str, Any], dict[str, Any]] = JSONField()
last_verified = models.DateTimeField(default=timezone.now)
last_synced = models.DateTimeField(default=timezone.now)
date_added = models.DateTimeField(default=timezone.now)
diff --git a/src/sentry/models/authprovider.py b/src/sentry/models/authprovider.py
index ede445479b6e89..1b0dc92c7096de 100644
--- a/src/sentry/models/authprovider.py
+++ b/src/sentry/models/authprovider.py
@@ -54,7 +54,7 @@ class AuthProvider(ReplicatedControlModel):
organization_id = HybridCloudForeignKey("sentry.Organization", on_delete="cascade", unique=True)
provider = models.CharField(max_length=128)
- config = JSONField()
+ config: models.Field[dict[str, Any], dict[str, Any]] = JSONField()
date_added = models.DateTimeField(default=timezone.now)
sync_time = BoundedPositiveIntegerField(null=True)
diff --git a/src/sentry/models/dashboard.py b/src/sentry/models/dashboard.py
index 53e00772fe95bc..7ab00fe22b271d 100644
--- a/src/sentry/models/dashboard.py
+++ b/src/sentry/models/dashboard.py
@@ -41,7 +41,7 @@ class Dashboard(Model):
visits = BoundedBigIntegerField(null=True, default=1)
last_visited = models.DateTimeField(null=True, default=timezone.now)
projects = models.ManyToManyField("sentry.Project", through=DashboardProject)
- filters = JSONField(null=True)
+ filters: models.Field[dict[str, Any] | None, dict[str, Any] | None] = JSONField(null=True)
MAX_WIDGETS = 30
diff --git a/src/sentry/models/debugfile.py b/src/sentry/models/debugfile.py
index cdb46cf2370172..ea0d64793ac8b8 100644
--- a/src/sentry/models/debugfile.py
+++ b/src/sentry/models/debugfile.py
@@ -129,7 +129,7 @@ class ProjectDebugFile(Model):
project_id = BoundedBigIntegerField(null=True)
debug_id = models.CharField(max_length=64, db_column="uuid")
code_id = models.CharField(max_length=64, null=True)
- data = JSONField(null=True)
+ data: models.Field[dict[str, Any] | None, dict[str, Any] | None] = JSONField(null=True)
date_accessed = models.DateTimeField(default=timezone.now)
objects: ClassVar[ProjectDebugFileManager] = ProjectDebugFileManager()
diff --git a/src/sentry/models/files/abstractfile.py b/src/sentry/models/files/abstractfile.py
index a963485cfd0382..946e659214413e 100644
--- a/src/sentry/models/files/abstractfile.py
+++ b/src/sentry/models/files/abstractfile.py
@@ -8,7 +8,7 @@
import tempfile
from concurrent.futures import ThreadPoolExecutor
from hashlib import sha1
-from typing import ClassVar
+from typing import Any, ClassVar
import sentry_sdk
from django.core.files.base import ContentFile
@@ -20,6 +20,7 @@
from sentry.celery import SentryTask
from sentry.db.models import BoundedPositiveIntegerField, JSONField, Model
from sentry.models.files.abstractfileblob import AbstractFileBlob
+from sentry.models.files.abstractfileblobindex import AbstractFileBlobIndex
from sentry.models.files.utils import DEFAULT_BLOB_SIZE, AssembleChecksumMismatch, nooplogger
from sentry.utils import metrics
from sentry.utils.db import atomic_transaction
@@ -202,7 +203,7 @@ class AbstractFile(Model):
name = models.TextField()
type = models.CharField(max_length=64)
timestamp = models.DateTimeField(default=timezone.now, db_index=True)
- headers = JSONField()
+ headers: models.Field[dict[str, Any], dict[str, Any]] = JSONField()
size = BoundedPositiveIntegerField(null=True)
checksum = models.CharField(max_length=40, null=True, db_index=True)
@@ -212,7 +213,7 @@ class Meta:
# abstract
# XXX: uses `builtins.type` to avoid clash with `type` local
FILE_BLOB_MODEL: ClassVar[builtins.type[AbstractFileBlob]]
- FILE_BLOB_INDEX_MODEL: ClassVar[builtins.type[Model]]
+ FILE_BLOB_INDEX_MODEL: ClassVar[builtins.type[AbstractFileBlobIndex]]
DELETE_UNREFERENCED_BLOB_TASK: ClassVar[SentryTask]
blobs: models.ManyToManyField
diff --git a/src/sentry/models/group.py b/src/sentry/models/group.py
index 42710910fb3bbc..54134b9646d6dd 100644
--- a/src/sentry/models/group.py
+++ b/src/sentry/models/group.py
@@ -565,7 +565,9 @@ class Group(Model):
score = BoundedIntegerField(default=0)
# deprecated, do not use. GroupShare has superseded
is_public = models.BooleanField(default=False, null=True)
- data: models.Field[dict[str, Any], dict[str, Any]] = GzippedDictField(blank=True, null=True)
+ data: models.Field[dict[str, Any] | None, dict[str, Any]] = GzippedDictField(
+ blank=True, null=True
+ )
short_id = BoundedBigIntegerField(null=True)
type = BoundedPositiveIntegerField(default=ErrorGroupType.type_id, db_index=True)
priority = models.PositiveSmallIntegerField(null=True)
diff --git a/src/sentry/models/grouplink.py b/src/sentry/models/grouplink.py
index 779d7699341a73..ff6f1cb52857e5 100644
--- a/src/sentry/models/grouplink.py
+++ b/src/sentry/models/grouplink.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, ClassVar
+from typing import TYPE_CHECKING, Any, ClassVar
from django.db import models
from django.db.models import QuerySet
@@ -71,7 +71,7 @@ class LinkedType:
default=Relationship.references,
choices=((Relationship.resolves, _("Resolves")), (Relationship.references, _("Linked"))),
)
- data = JSONField()
+ data: models.Field[dict[str, Any], dict[str, Any]] = JSONField()
datetime = models.DateTimeField(default=timezone.now, db_index=True)
objects: ClassVar[GroupLinkManager] = GroupLinkManager()
diff --git a/src/sentry/models/groupsnooze.py b/src/sentry/models/groupsnooze.py
index c77d288490838a..d45e959fd7838f 100644
--- a/src/sentry/models/groupsnooze.py
+++ b/src/sentry/models/groupsnooze.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from datetime import timedelta
-from typing import TYPE_CHECKING, ClassVar, Self
+from typing import TYPE_CHECKING, Any, ClassVar, Self
from django.db import models
from django.db.models.signals import post_delete, post_save
@@ -53,7 +53,7 @@ class GroupSnooze(Model):
window = BoundedPositiveIntegerField(null=True)
user_count = BoundedPositiveIntegerField(null=True)
user_window = BoundedPositiveIntegerField(null=True)
- state = JSONField(null=True)
+ state: models.Field[dict[str, Any] | None, dict[str, Any] | None] = JSONField(null=True)
actor_id = BoundedPositiveIntegerField(null=True)
objects: ClassVar[BaseManager[Self]] = BaseManager(cache_fields=("group",))
@@ -87,6 +87,7 @@ def is_valid(
return False
else:
times_seen = group.times_seen_with_pending if use_pending_data else group.times_seen
+ assert self.state is not None
if self.count <= times_seen - self.state["times_seen"]:
return False
@@ -200,10 +201,10 @@ def test_user_rates(self) -> bool:
def test_user_counts(self, group: Group) -> bool:
cache_key = f"groupsnooze:v1:{self.id}:test_user_counts:events_seen_counter"
- try:
- users_seen = self.state["users_seen"]
- except (KeyError, TypeError):
+ if self.state is None:
users_seen = 0
+ else:
+ users_seen = self.state.get("users_seen", 0)
threshold = self.user_count + users_seen
diff --git a/src/sentry/models/grouptombstone.py b/src/sentry/models/grouptombstone.py
index 386b87e09f6ef7..892bd3a31c895d 100644
--- a/src/sentry/models/grouptombstone.py
+++ b/src/sentry/models/grouptombstone.py
@@ -1,4 +1,7 @@
+from __future__ import annotations
+
import logging
+from typing import Any
from django.db import models
@@ -29,7 +32,9 @@ class GroupTombstone(Model):
)
message = models.TextField()
culprit = models.CharField(max_length=MAX_CULPRIT_LENGTH, blank=True, null=True)
- data = GzippedDictField(blank=True, null=True)
+ data: models.Field[dict[str, Any] | None, dict[str, Any]] = GzippedDictField(
+ blank=True, null=True
+ )
actor_id = BoundedPositiveIntegerField(null=True)
class Meta:
diff --git a/src/sentry/models/identity.py b/src/sentry/models/identity.py
index 4c7fe96d177f27..81b7b4fc42d95f 100644
--- a/src/sentry/models/identity.py
+++ b/src/sentry/models/identity.py
@@ -53,7 +53,7 @@ class IdentityProvider(Model):
__relocation_scope__ = RelocationScope.Excluded
type = models.CharField(max_length=64)
- config = JSONField()
+ config: models.Field[dict[str, Any], dict[str, Any]] = JSONField()
date_added = models.DateTimeField(default=timezone.now, null=True)
external_id = models.CharField(max_length=64, null=True)
@@ -197,7 +197,7 @@ class Identity(Model):
idp = FlexibleForeignKey("sentry.IdentityProvider")
user = FlexibleForeignKey(settings.AUTH_USER_MODEL)
external_id = models.TextField()
- data = JSONField()
+ data: models.Field[dict[str, Any], dict[str, Any]] = JSONField()
status = BoundedPositiveIntegerField(default=IdentityStatus.UNKNOWN)
scopes = ArrayField()
date_verified = models.DateTimeField(default=timezone.now)
diff --git a/src/sentry/models/integrations/integration.py b/src/sentry/models/integrations/integration.py
index d7c886a7405cbd..9a69419e9b429b 100644
--- a/src/sentry/models/integrations/integration.py
+++ b/src/sentry/models/integrations/integration.py
@@ -55,7 +55,7 @@ class Integration(DefaultFieldsModel):
# metadata might be used to store things like credentials, but it should NOT
# be used to store organization-specific information, as an Integration
# instance can be shared by multiple organizations
- metadata = JSONField(default=dict)
+ metadata: models.Field[dict[str, Any], dict[str, Any]] = JSONField(default=dict)
status = BoundedPositiveIntegerField(
default=ObjectStatus.ACTIVE, choices=ObjectStatus.as_choices(), null=True
)
diff --git a/src/sentry/models/integrations/sentry_app_component.py b/src/sentry/models/integrations/sentry_app_component.py
index 27ae523a5d45b2..1f832fc0faf4b0 100644
--- a/src/sentry/models/integrations/sentry_app_component.py
+++ b/src/sentry/models/integrations/sentry_app_component.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
from collections.abc import MutableMapping
from typing import Any
@@ -17,7 +19,7 @@ class SentryAppComponent(Model):
uuid = UUIDField(unique=True, auto_add=True)
sentry_app = FlexibleForeignKey("sentry.SentryApp", related_name="components")
type = models.CharField(max_length=64)
- schema = JSONField()
+ schema: models.Field[dict[str, Any], dict[str, Any]] = JSONField()
class Meta:
app_label = "sentry"
diff --git a/src/sentry/models/notificationmessage.py b/src/sentry/models/notificationmessage.py
index c7f0a2bcd1f270..4071c92f5c9eba 100644
--- a/src/sentry/models/notificationmessage.py
+++ b/src/sentry/models/notificationmessage.py
@@ -1,4 +1,8 @@
-from django.db.models import DateTimeField, IntegerField, Q
+from __future__ import annotations
+
+from typing import Any
+
+from django.db.models import DateTimeField, Field, IntegerField, Q
from django.db.models.constraints import CheckConstraint, UniqueConstraint
from django.utils import timezone
@@ -36,7 +40,7 @@ class NotificationMessage(Model):
# Related information regarding failed notifications.
# Leveraged to help give the user visibility into notifications that are consistently failing.
- error_details = JSONField(null=True)
+ error_details: Field[dict[str, Any] | None, dict[str, Any] | None] = JSONField(null=True)
error_code = IntegerField(null=True, db_index=True)
# Resulting identifier from the vendor that can be leveraged for future interaction with the notification.
diff --git a/src/sentry/models/organizationonboardingtask.py b/src/sentry/models/organizationonboardingtask.py
index 1cf418fe39c3eb..c60905c71ce400 100644
--- a/src/sentry/models/organizationonboardingtask.py
+++ b/src/sentry/models/organizationonboardingtask.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from typing import ClassVar
+from typing import Any, ClassVar
from django.conf import settings
from django.core.cache import cache
@@ -101,9 +101,10 @@ class AbstractOnboardingTask(Model):
completion_seen = models.DateTimeField(null=True)
date_completed = models.DateTimeField(default=timezone.now)
project = FlexibleForeignKey("sentry.Project", db_constraint=False, null=True)
- data = JSONField() # INVITE_MEMBER { invited_member: user.id }
+ # INVITE_MEMBER { invited_member: user.id }
+ data: models.Field[dict[str, Any], dict[str, Any]] = JSONField()
- # fields for typing
+ # abstract
TASK_LOOKUP_BY_KEY: dict[str, int]
SKIPPABLE_TASKS: frozenset[int]
diff --git a/src/sentry/models/projectownership.py b/src/sentry/models/projectownership.py
index 4dafe566821ac0..56026f480d928c 100644
--- a/src/sentry/models/projectownership.py
+++ b/src/sentry/models/projectownership.py
@@ -40,7 +40,7 @@ class ProjectOwnership(Model):
project = FlexibleForeignKey("sentry.Project", unique=True)
raw = models.TextField(null=True)
- schema = JSONField(null=True)
+ schema: models.Field[dict[str, Any] | None, dict[str, Any] | None] = JSONField(null=True)
fallthrough = models.BooleanField(default=True)
# Auto Assignment through Ownership Rules & Code Owners
auto_assignment = models.BooleanField(default=True)
diff --git a/src/sentry/models/repository.py b/src/sentry/models/repository.py
index 5a9b3111ee6688..eebfcd3c97834b 100644
--- a/src/sentry/models/repository.py
+++ b/src/sentry/models/repository.py
@@ -34,7 +34,7 @@ class Repository(Model, PendingDeletionMixin):
provider = models.CharField(max_length=64, null=True)
# The external_id is the id of the repo in the provider's system. (e.g. GitHub's repo id)
external_id = models.CharField(max_length=64, null=True)
- config = JSONField(default=dict)
+ config: models.Field[dict[str, Any], dict[str, Any]] = JSONField(default=dict)
status = BoundedPositiveIntegerField(
default=ObjectStatus.ACTIVE, choices=ObjectStatus.as_choices(), db_index=True
)
diff --git a/src/sentry/models/rule.py b/src/sentry/models/rule.py
index 4f58577c7a3e19..8939cfcc99bce3 100644
--- a/src/sentry/models/rule.py
+++ b/src/sentry/models/rule.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
from collections.abc import Sequence
from enum import Enum, IntEnum
from typing import Any, ClassVar, Self
@@ -46,7 +48,7 @@ class Rule(Model):
environment_id = BoundedPositiveIntegerField(null=True)
label = models.CharField(max_length=256)
# `data` contain all the specifics of the rule - conditions, actions, frequency, etc.
- data = GzippedDictField()
+ data: models.Field[dict[str, Any], dict[str, Any]] = GzippedDictField()
status = BoundedPositiveIntegerField(
default=ObjectStatus.ACTIVE,
choices=((ObjectStatus.ACTIVE, "Active"), (ObjectStatus.DISABLED, "Disabled")),
diff --git a/src/sentry/ownership/grammar.py b/src/sentry/ownership/grammar.py
index fe7dcd178bca3a..f9b4c04d238397 100644
--- a/src/sentry/ownership/grammar.py
+++ b/src/sentry/ownership/grammar.py
@@ -3,7 +3,7 @@
import re
from collections import namedtuple
from collections.abc import Callable, Iterable, Mapping, Sequence
-from typing import Any
+from typing import Any, NamedTuple
from parsimonious.exceptions import ParseError
from parsimonious.grammar import Grammar
@@ -80,7 +80,7 @@ def __str__(self) -> str:
)
return f"{self.matcher} {owners_str}"
- def dump(self) -> Mapping[str, Sequence[Owner]]:
+ def dump(self) -> dict[str, Sequence[Owner]]:
return {"matcher": self.matcher.dump(), "owners": [o.dump() for o in self.owners]}
@classmethod
@@ -109,7 +109,7 @@ class Matcher(namedtuple("Matcher", "type pattern")):
def __str__(self) -> str:
return f"{self.type}:{self.pattern}"
- def dump(self) -> Mapping[str, str]:
+ def dump(self) -> dict[str, str]:
return {"type": self.type, "pattern": self.pattern}
@classmethod
@@ -206,7 +206,7 @@ def test_tag(self, data: PathSearchable) -> bool:
return False
-class Owner(namedtuple("Owner", "type identifier")):
+class Owner(NamedTuple):
"""
An Owner represents a User or Team who owns this Rule.
@@ -217,7 +217,10 @@ class Owner(namedtuple("Owner", "type identifier")):
#team
"""
- def dump(self) -> Mapping[str, str]:
+ type: str
+ identifier: str
+
+ def dump(self) -> dict[str, str]:
return {"type": self.type, "identifier": self.identifier}
@classmethod
@@ -228,7 +231,7 @@ def load(cls, data: Mapping[str, str]) -> Owner:
class OwnershipVisitor(NodeVisitor):
visit_comment = visit_empty = lambda *a: None
- def visit_ownership(self, node: Node, children: Sequence[Rule | None]) -> Sequence[Rule]:
+ def visit_ownership(self, node: Node, children: Sequence[Rule | None]) -> list[Rule]:
return [_f for _f in children if _f]
def visit_line(self, node: Node, children: tuple[Node, Sequence[Rule | None], Any]) -> Any:
@@ -252,7 +255,7 @@ def visit_matcher_tag(self, node: Node, children: Sequence[Any]) -> str:
type, _ = tag
return str(type[0].text)
- def visit_owners(self, node: Node, children: tuple[Any, Sequence[Owner]]) -> Sequence[Owner]:
+ def visit_owners(self, node: Node, children: tuple[Any, Sequence[Owner]]) -> list[Owner]:
_, owners = children
return owners
@@ -277,7 +280,7 @@ def visit_identifier(self, node: Node, children: Sequence[Any]) -> str:
def visit_quoted_identifier(self, node: Node, children: Sequence[Any]) -> str:
return str(node.text[1:-1].encode("ascii", "backslashreplace").decode("unicode-escape"))
- def generic_visit(self, node: Node, children: Sequence[Any]) -> Sequence[Node] | Node:
+ def generic_visit(self, node: Node, children: Sequence[Any]) -> list[Node] | Node:
return children or node
@@ -287,12 +290,12 @@ def parse_rules(data: str) -> Any:
return OwnershipVisitor().visit(tree)
-def dump_schema(rules: Sequence[Rule]) -> Mapping[str, Any]:
+def dump_schema(rules: Sequence[Rule]) -> dict[str, Any]:
"""Convert a Rule tree into a JSON schema"""
return {"$version": VERSION, "rules": [r.dump() for r in rules]}
-def load_schema(schema: Mapping[str, Any]) -> Sequence[Rule]:
+def load_schema(schema: Mapping[str, Any]) -> list[Rule]:
"""Convert a JSON schema into a Rule tree"""
if schema["$version"] != VERSION:
raise RuntimeError("Invalid schema $version: %r" % schema["$version"])
@@ -420,7 +423,7 @@ def convert_codeowners_syntax(
return result
-def resolve_actors(owners: Iterable[Owner], project_id: int) -> Mapping[Owner, Actor]:
+def resolve_actors(owners: Iterable[Owner], project_id: int) -> dict[Owner, Actor]:
"""Convert a list of Owner objects into a dictionary
of {Owner: Actor} pairs. Actors not identified are returned
as None."""
@@ -514,7 +517,7 @@ def create_schema_from_issue_owners(
issue_owners: str | None,
add_owner_ids: bool = False,
remove_deleted_owners: bool = False,
-) -> Mapping[str, Any] | None:
+) -> dict[str, Any] | None:
if issue_owners is None:
return None
diff --git a/src/sentry/rules/match.py b/src/sentry/rules/match.py
index bbf97737eab675..6a55a43ff6a2fa 100644
--- a/src/sentry/rules/match.py
+++ b/src/sentry/rules/match.py
@@ -9,6 +9,7 @@ class MatchType:
GREATER_OR_EQUAL = "gte"
GREATER = "gt"
IS_SET = "is"
+ IS_IN = "in"
LESS_OR_EQUAL = "lte"
LESS = "lt"
NOT_CONTAINS = "nc"
@@ -17,7 +18,6 @@ class MatchType:
NOT_SET = "ns"
NOT_STARTS_WITH = "nsw"
STARTS_WITH = "sw"
- IS_IN = "in"
LEVEL_MATCH_CHOICES = {
@@ -37,13 +37,13 @@ class MatchType:
MatchType.ENDS_WITH: "ends with",
MatchType.EQUAL: "equals",
MatchType.IS_SET: "is set",
+ MatchType.IS_IN: "is in (comma separated)",
MatchType.NOT_CONTAINS: "does not contain",
MatchType.NOT_ENDS_WITH: "does not end with",
MatchType.NOT_EQUAL: "does not equal",
MatchType.NOT_SET: "is not set",
MatchType.NOT_STARTS_WITH: "does not start with",
MatchType.STARTS_WITH: "starts with",
- MatchType.IS_IN: "is in (comma separated)",
}
diff --git a/src/sentry/rules/processing/delayed_processing.py b/src/sentry/rules/processing/delayed_processing.py
index e8a41490b0aeda..0bb7c1fcd7c1c5 100644
--- a/src/sentry/rules/processing/delayed_processing.py
+++ b/src/sentry/rules/processing/delayed_processing.py
@@ -271,11 +271,8 @@ def get_rules_to_fire(
if action_match == "any":
rules_to_fire[alert_rule].add(group_id)
break
- conditions_matched += 1
- else:
- if action_match == "all":
- # We failed to match all conditions for this group, skip
- break
+ elif action_match == "all":
+ conditions_matched += 1
if action_match == "all" and conditions_matched == len(slow_conditions):
rules_to_fire[alert_rule].add(group_id)
return rules_to_fire
diff --git a/src/sentry/testutils/factories.py b/src/sentry/testutils/factories.py
index 7771b94b71da38..954112b7d217a0 100644
--- a/src/sentry/testutils/factories.py
+++ b/src/sentry/testutils/factories.py
@@ -152,11 +152,13 @@
from sentry.testutils.outbox import outbox_runner
from sentry.testutils.silo import assume_test_silo_mode
from sentry.types.activity import ActivityType
+from sentry.types.actor import Actor
from sentry.types.region import Region, get_local_region, get_region_by_name
from sentry.types.token import AuthTokenType
from sentry.uptime.models import (
ProjectUptimeSubscription,
ProjectUptimeSubscriptionMode,
+ UptimeStatus,
UptimeSubscription,
)
from sentry.users.services.user import RpcUser
@@ -1762,7 +1764,7 @@ def create_organization_integration(**integration_params: Any) -> OrganizationIn
@assume_test_silo_mode(SiloMode.CONTROL)
def create_identity_provider(
integration: Integration | None = None,
- config: Mapping[str, Any] | None = None,
+ config: dict[str, Any] | None = None,
**kwargs: Any,
) -> IdentityProvider:
if integration is not None:
@@ -1950,9 +1952,24 @@ def create_project_uptime_subscription(
project: Project,
uptime_subscription: UptimeSubscription,
mode: ProjectUptimeSubscriptionMode,
+ name: str,
+ owner: Actor | None,
+ uptime_status: UptimeStatus,
):
+ owner_team_id = None
+ owner_user_id = None
+ if owner:
+ if owner.is_team:
+ owner_team_id = owner.id
+ elif owner.is_user:
+ owner_user_id = owner.id
+
return ProjectUptimeSubscription.objects.create(
uptime_subscription=uptime_subscription,
project=project,
mode=mode,
+ name=name,
+ owner_team_id=owner_team_id,
+ owner_user_id=owner_user_id,
+ uptime_status=uptime_status,
)
diff --git a/src/sentry/testutils/fixtures.py b/src/sentry/testutils/fixtures.py
index 39a7352ba6a421..c0ff384cfb2056 100644
--- a/src/sentry/testutils/fixtures.py
+++ b/src/sentry/testutils/fixtures.py
@@ -22,6 +22,7 @@
from sentry.models.project import Project
from sentry.models.projecttemplate import ProjectTemplate
from sentry.models.rule import Rule
+from sentry.models.team import Team
from sentry.models.user import User
from sentry.monitors.models import Monitor, MonitorType, ScheduleType
from sentry.organizations.services.organization import RpcOrganization
@@ -34,9 +35,11 @@
# all of the memoized fixtures are copypasta due to our inability to use pytest fixtures
# on a per-class method basis
from sentry.types.activity import ActivityType
+from sentry.types.actor import Actor
from sentry.uptime.models import (
ProjectUptimeSubscription,
ProjectUptimeSubscriptionMode,
+ UptimeStatus,
UptimeSubscription,
)
from sentry.users.services.user import RpcUser
@@ -593,7 +596,7 @@ def create_identity(self, *args, **kwargs):
def create_identity_provider(
self,
integration: Integration | None = None,
- config: Mapping[str, Any] | None = None,
+ config: dict[str, Any] | None = None,
**kwargs: Any,
) -> IdentityProvider:
return Factories.create_identity_provider(integration=integration, config=config, **kwargs)
@@ -647,13 +650,23 @@ def create_project_uptime_subscription(
project: Project | None = None,
uptime_subscription: UptimeSubscription | None = None,
mode=ProjectUptimeSubscriptionMode.AUTO_DETECTED_ACTIVE,
+ name="Test Name",
+ owner: User | Team | None = None,
+ uptime_status=UptimeStatus.OK,
) -> ProjectUptimeSubscription:
if project is None:
project = self.project
if uptime_subscription is None:
uptime_subscription = self.create_uptime_subscription()
- return Factories.create_project_uptime_subscription(project, uptime_subscription, mode)
+ return Factories.create_project_uptime_subscription(
+ project,
+ uptime_subscription,
+ mode,
+ name,
+ Actor.from_object(owner) if owner else None,
+ uptime_status,
+ )
@pytest.fixture(autouse=True)
def _init_insta_snapshot(self, insta_snapshot):
diff --git a/src/sentry/types/actor.py b/src/sentry/types/actor.py
index 5fea404e3c91f7..f10c03576db1d7 100644
--- a/src/sentry/types/actor.py
+++ b/src/sentry/types/actor.py
@@ -40,7 +40,9 @@ class InvalidActor(ObjectDoesNotExist):
pass
@classmethod
- def resolve_many(cls, actors: Sequence["Actor"]) -> list["Team | RpcUser"]:
+ def resolve_many(
+ cls, actors: Sequence["Actor"], filter_none: bool = True
+ ) -> list["Team | RpcUser | None"]:
"""
Resolve a list of actors in a batch to the Team/User the Actor references.
@@ -64,7 +66,10 @@ def resolve_many(cls, actors: Sequence["Actor"]) -> list["Team | RpcUser"]:
for team in Team.objects.filter(id__in=[t.id for t in actor_list]):
results[(actor_type, team.id)] = team
- return list(filter(None, [results.get((actor.actor_type, actor.id)) for actor in actors]))
+ final_results = [results.get((actor.actor_type, actor.id)) for actor in actors]
+ if filter_none:
+ final_results = list(filter(None, final_results))
+ return final_results
@classmethod
def many_from_object(cls, objects: Iterable[ActorTarget]) -> list["Actor"]:
diff --git a/src/sentry/uptime/apps.py b/src/sentry/uptime/apps.py
index 85e532542b698e..a36058cb3931a4 100644
--- a/src/sentry/uptime/apps.py
+++ b/src/sentry/uptime/apps.py
@@ -5,4 +5,4 @@ class Config(AppConfig):
name = "sentry.uptime"
def ready(self):
- pass
+ from sentry.uptime.endpoints import serializers # NOQA
diff --git a/src/sentry/uptime/consumers/results_consumer.py b/src/sentry/uptime/consumers/results_consumer.py
index 0d18033e61c25d..4fe9e60e3c2d47 100644
--- a/src/sentry/uptime/consumers/results_consumer.py
+++ b/src/sentry/uptime/consumers/results_consumer.py
@@ -10,6 +10,7 @@
CheckResult,
)
+from sentry import features
from sentry.conf.types.kafka_definition import Topic
from sentry.remote_subscriptions.consumers.result_consumer import (
ResultProcessor,
@@ -175,13 +176,19 @@ def handle_result_for_project_active_mode(
project_subscription.uptime_status == UptimeStatus.OK
and result["status"] == CHECKSTATUS_FAILURE
):
- create_issue_platform_occurrence(result, project_subscription)
+ if features.has(
+ "organizations:uptime-create-issues", project_subscription.project.organization
+ ):
+ create_issue_platform_occurrence(result, project_subscription)
project_subscription.update(uptime_status=UptimeStatus.FAILED)
elif (
project_subscription.uptime_status == UptimeStatus.FAILED
and result["status"] == CHECKSTATUS_SUCCESS
):
- resolve_uptime_issue(project_subscription)
+ if features.has(
+ "organizations:uptime-create-issues", project_subscription.project.organization
+ ):
+ resolve_uptime_issue(project_subscription)
project_subscription.update(uptime_status=UptimeStatus.OK)
diff --git a/src/sentry/uptime/detectors/detector.py b/src/sentry/uptime/detectors/detector.py
index 58e70ffb668184..d0d9d999423647 100644
--- a/src/sentry/uptime/detectors/detector.py
+++ b/src/sentry/uptime/detectors/detector.py
@@ -3,7 +3,11 @@
from typing import TYPE_CHECKING
from sentry import features
-from sentry.uptime.detectors.ranking import add_base_url_to_rank, should_detect_for_project
+from sentry.uptime.detectors.ranking import (
+ add_base_url_to_rank,
+ should_detect_for_organization,
+ should_detect_for_project,
+)
from sentry.uptime.detectors.url_extraction import extract_base_url
from sentry.utils import metrics
@@ -14,9 +18,11 @@
def detect_base_url_for_project(project: Project, url: str) -> None:
# Note: We might end up removing the `should_detect_for_project` check here if/when we decide to use detected
# urls as suggestions as well.
- if not features.has(
- "organizations:uptime-automatic-hostname-detection", project.organization
- ) or not should_detect_for_project(project):
+ if (
+ not features.has("organizations:uptime-automatic-hostname-detection", project.organization)
+ or not should_detect_for_project(project)
+ or not should_detect_for_organization(project.organization)
+ ):
return
base_url = extract_base_url(url)
diff --git a/src/sentry/uptime/detectors/ranking.py b/src/sentry/uptime/detectors/ranking.py
index 65a2b06bd0086a..eaee986a3228f0 100644
--- a/src/sentry/uptime/detectors/ranking.py
+++ b/src/sentry/uptime/detectors/ranking.py
@@ -8,6 +8,7 @@
from redis.client import StrictRedis
from rediscluster import RedisCluster
+from sentry.constants import UPTIME_AUTODETECTION
from sentry.utils import redis
if TYPE_CHECKING:
@@ -162,7 +163,8 @@ def delete_organization_bucket(bucket: datetime) -> None:
def should_detect_for_organization(organization: Organization) -> bool:
- # TODO: Check setting here
+ if not organization.get_option("sentry:uptime_autodetection", UPTIME_AUTODETECTION):
+ return False
return True
diff --git a/src/sentry/uptime/endpoints/__init__.py b/src/sentry/uptime/endpoints/__init__.py
new file mode 100644
index 00000000000000..e69de29bb2d1d6
diff --git a/src/sentry/uptime/endpoints/serializers.py b/src/sentry/uptime/endpoints/serializers.py
new file mode 100644
index 00000000000000..e2df2237665012
--- /dev/null
+++ b/src/sentry/uptime/endpoints/serializers.py
@@ -0,0 +1,59 @@
+from collections.abc import MutableMapping, Sequence
+from typing import Any, TypedDict
+
+from django.db.models import prefetch_related_objects
+
+from sentry.api.serializers import Serializer, register, serialize
+from sentry.api.serializers.models.actor import ActorSerializer, ActorSerializerResponse
+from sentry.types.actor import Actor
+from sentry.uptime.models import ProjectUptimeSubscription
+
+
+class ProjectUptimeSubscriptionSerializerResponse(TypedDict):
+ id: str
+ projectSlug: str
+ name: str
+ status: int
+ mode: int
+ url: str
+ intervalSeconds: int
+ timeoutMs: int
+ owner: ActorSerializerResponse
+
+
+@register(ProjectUptimeSubscription)
+class ProjectUptimeSubscriptionSerializer(Serializer):
+ def __init__(self, expand=None):
+ self.expand = expand
+
+ def get_attrs(
+ self, item_list: Sequence[ProjectUptimeSubscription], user: Any, **kwargs: Any
+ ) -> MutableMapping[Any, Any]:
+ prefetch_related_objects(item_list, "uptime_subscription", "project")
+ owners = list(filter(None, [item.owner for item in item_list]))
+ owners_serialized = serialize(
+ Actor.resolve_many(owners, filter_none=False), user, ActorSerializer()
+ )
+ serialized_owner_lookup = {
+ owner: serialized_owner for owner, serialized_owner in zip(owners, owners_serialized)
+ }
+
+ return {
+ item: {"owner": serialized_owner_lookup.get(item.owner) if item.owner else None}
+ for item in item_list
+ }
+
+ def serialize(
+ self, obj: ProjectUptimeSubscription, attrs, user, **kwargs
+ ) -> ProjectUptimeSubscriptionSerializerResponse:
+ return {
+ "id": str(obj.id),
+ "projectSlug": obj.project.slug,
+ "name": obj.name,
+ "status": obj.uptime_status,
+ "mode": obj.mode,
+ "url": obj.uptime_subscription.url,
+ "intervalSeconds": obj.uptime_subscription.interval_seconds,
+ "timeoutMs": obj.uptime_subscription.timeout_ms,
+ "owner": attrs["owner"],
+ }
diff --git a/src/sentry/uptime/models.py b/src/sentry/uptime/models.py
index 44d782007a1b29..6ebfdd1356c4d3 100644
--- a/src/sentry/uptime/models.py
+++ b/src/sentry/uptime/models.py
@@ -11,6 +11,7 @@
from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey
from sentry.db.models.manager.base import BaseManager
from sentry.remote_subscriptions.models import BaseRemoteSubscription
+from sentry.types.actor import Actor
@region_silo_model
@@ -104,3 +105,7 @@ class Meta:
),
),
]
+
+ @property
+ def owner(self) -> Actor | None:
+ return Actor.from_id(user_id=self.owner_user_id, team_id=self.owner_team_id)
diff --git a/static/app/components/devtoolbar/components/feedback/feedbackPanel.tsx b/static/app/components/devtoolbar/components/feedback/feedbackPanel.tsx
index 09dfd0db1f1bdd..08aa45f01383a3 100644
--- a/static/app/components/devtoolbar/components/feedback/feedbackPanel.tsx
+++ b/static/app/components/devtoolbar/components/feedback/feedbackPanel.tsx
@@ -142,7 +142,7 @@ function FeedbackListItem({item}: {item: FeedbackIssueListItem}) {
-
+
({
+ return useFetchInfiniteApiData({
queryKey: useMemo(
() => [
`/organizations/${organizationSlug}/issues/`,
diff --git a/static/app/components/devtoolbar/components/issues/issuesPanel.tsx b/static/app/components/devtoolbar/components/issues/issuesPanel.tsx
index 8ee2d33249efb7..206023ad4de86e 100644
--- a/static/app/components/devtoolbar/components/issues/issuesPanel.tsx
+++ b/static/app/components/devtoolbar/components/issues/issuesPanel.tsx
@@ -111,7 +111,7 @@ function IssueListItem({item}: {item: Group}) {
{item.metadata.value}
-
+
({
+ return useFetchInfiniteApiData({
queryKey: useMemo(
() => [
`/organizations/${organizationSlug}/issues/`,
diff --git a/static/app/components/devtoolbar/hooks/useInfiniteApiData.tsx b/static/app/components/devtoolbar/hooks/useFetchInfiniteApiData.tsx
similarity index 87%
rename from static/app/components/devtoolbar/hooks/useInfiniteApiData.tsx
rename to static/app/components/devtoolbar/hooks/useFetchInfiniteApiData.tsx
index 570e92d0b90c6f..8223a4211f5835 100644
--- a/static/app/components/devtoolbar/hooks/useInfiniteApiData.tsx
+++ b/static/app/components/devtoolbar/hooks/useFetchInfiniteApiData.tsx
@@ -8,7 +8,7 @@ interface Props {
queryKey: ApiQueryKey;
}
-export default function useInfiniteApiData>({
+export default function useFetchInfiniteApiData>({
queryKey,
}: Props) {
const {fetchInfiniteFn, getNextPageParam, getPreviousPageParam} = useApiEndpoint();
diff --git a/static/app/components/devtoolbar/styles/listItem.ts b/static/app/components/devtoolbar/styles/listItem.ts
index 9b41ff4c4fa2d7..0684ed082e62cd 100644
--- a/static/app/components/devtoolbar/styles/listItem.ts
+++ b/static/app/components/devtoolbar/styles/listItem.ts
@@ -13,7 +13,7 @@ export const listItemGridCss = css`
grid-template-areas:
'name time'
'message message'
- 'project icons';
+ 'owner icons';
grid-template-columns: 1fr max-content;
gap: var(--space50);
diff --git a/static/app/components/eventOrGroupExtraDetails.tsx b/static/app/components/eventOrGroupExtraDetails.tsx
index bb6ca083e2e222..a7d13273aed1bb 100644
--- a/static/app/components/eventOrGroupExtraDetails.tsx
+++ b/static/app/components/eventOrGroupExtraDetails.tsx
@@ -7,6 +7,7 @@ import TimesTag from 'sentry/components/group/inboxBadges/timesTag';
import UnhandledTag from 'sentry/components/group/inboxBadges/unhandledTag';
import IssueReplayCount from 'sentry/components/group/issueReplayCount';
import ProjectBadge from 'sentry/components/idBadge/projectBadge';
+import ExternalLink from 'sentry/components/links/externalLink';
import Link from 'sentry/components/links/link';
import Placeholder from 'sentry/components/placeholder';
import {IconChat} from 'sentry/icons';
@@ -94,14 +95,20 @@ function EventOrGroupExtraDetails({data, showAssignee, organization}: Props) {
)}
- {annotations?.map((annotation, key) => (
-
- ))}
+ {annotations?.map((annotation, key) =>
+ typeof annotation === 'string' ? (
+
+ ) : (
+
+ {annotation.displayName}
+
+ )
+ )}
{showAssignee && assignedTo && (
{tct('Assigned to [name]', {name: assignedTo.name})}
diff --git a/static/app/components/feedback/feedbackOnboarding/sidebar.tsx b/static/app/components/feedback/feedbackOnboarding/sidebar.tsx
index d949e5378350c4..38ea74d12b6ae9 100644
--- a/static/app/components/feedback/feedbackOnboarding/sidebar.tsx
+++ b/static/app/components/feedback/feedbackOnboarding/sidebar.tsx
@@ -270,7 +270,8 @@ function OnboardingContent({currentProject}: {currentProject: Project}) {
) : (
newDocs?.platformOptions &&
widgetPlatform &&
- !crashReportOnboarding && (
+ !crashReportOnboarding &&
+ !isLoading && (
{tct("I'm using [platformSelect]", {
platformSelect: (
diff --git a/static/app/components/issueLink.tsx b/static/app/components/issueLink.tsx
deleted file mode 100644
index 7b80a1c0d55406..00000000000000
--- a/static/app/components/issueLink.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-import {Fragment} from 'react';
-import styled from '@emotion/styled';
-import classNames from 'classnames';
-
-import Count from 'sentry/components/count';
-import EventOrGroupTitle from 'sentry/components/eventOrGroupTitle';
-import EventAnnotation from 'sentry/components/events/eventAnnotation';
-import EventMessage from 'sentry/components/events/eventMessage';
-import {Hovercard} from 'sentry/components/hovercard';
-import Link from 'sentry/components/links/link';
-import TimeSince from 'sentry/components/timeSince';
-import {t} from 'sentry/locale';
-import {space} from 'sentry/styles/space';
-import type {Group} from 'sentry/types/group';
-import {getMessage} from 'sentry/utils/events';
-
-type Props = {
- card: boolean;
- children: React.ReactNode;
- issue: Group;
- orgId: string;
- to: string;
-};
-
-function IssueLink({children, orgId, issue, to, card = true}: Props) {
- if (!card) {
- return {children};
- }
-
- const message = getMessage(issue);
-
- const className = classNames({
- isBookmarked: issue.isBookmarked,
- hasSeen: issue.hasSeen,
- isResolved: issue.status === 'resolved',
- });
-
- const streamPath = `/organizations/${orgId}/issues/`;
-
- const hovercardBody = (
-
-
-
-
-
-
-
- {issue.logger && (
-
-
- {issue.logger}
-
-
- )}
- {issue.annotations.map((annotation, i) => (
-
- ))}
-
- }
- type={issue.type}
- />
-
-
-
-
- {t('First Seen')}
-
-
-
- {t('Last Seen')}
-
-
-
- {t('Occurrences')}
-
-
-
- {t('Users Affected')}
-
-
-
-
- );
-
- return (
-
- {children}
-
- );
-}
-
-export default IssueLink;
-
-const Title = styled('div')`
- ${p => p.theme.overflowEllipsis};
- margin: 0 0 ${space(0.5)};
-`;
-
-const StyledEventOrGroupTitle = styled(EventOrGroupTitle)`
- font-weight: ${p => p.theme.fontWeightBold};
- font-size: ${p => p.theme.fontSizeMedium};
-
- em {
- font-style: normal;
- font-weight: ${p => p.theme.fontWeightNormal};
- font-size: ${p => p.theme.fontSizeSmall};
- }
-`;
-
-const Section = styled('section')`
- margin-bottom: ${space(2)};
-`;
-
-const Grid = styled('div')`
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: ${space(2)};
-`;
-const HovercardEventMessage = styled(EventMessage)`
- font-size: 12px;
-`;
-
-const GridHeader = styled('h5')`
- color: ${p => p.theme.gray300};
- font-size: 11px;
- margin-bottom: ${space(0.5)};
- text-transform: uppercase;
-`;
-
-const StyledTimeSince = styled(TimeSince)`
- color: inherit;
-`;
diff --git a/static/app/components/onboarding/gettingStartedDoc/utils/index.tsx b/static/app/components/onboarding/gettingStartedDoc/utils/index.tsx
index ad98e25719fc33..fa9911f2eae393 100644
--- a/static/app/components/onboarding/gettingStartedDoc/utils/index.tsx
+++ b/static/app/components/onboarding/gettingStartedDoc/utils/index.tsx
@@ -1,3 +1,4 @@
+import Alert from 'sentry/components/alert';
import ExternalLink from 'sentry/components/links/externalLink';
import {t, tct} from 'sentry/locale';
import type {Organization, PlatformKey} from 'sentry/types';
@@ -68,3 +69,16 @@ export function getUploadSourceMapsStep({
],
};
}
+
+export function MobileBetaBanner({link}: {link: string}) {
+ return (
+
+ {tct(
+ `Currently, Mobile Replay is in beta. You can [link:read our docs] to learn how to set it up for your project.`,
+ {
+ link: ,
+ }
+ )}
+
+ );
+}
diff --git a/static/app/components/replays/contextIcon.tsx b/static/app/components/replays/contextIcon.tsx
index 68b938e1131f5e..897aae3f7e5f11 100644
--- a/static/app/components/replays/contextIcon.tsx
+++ b/static/app/components/replays/contextIcon.tsx
@@ -1,12 +1,12 @@
-import {lazy, Suspense} from 'react';
import styled from '@emotion/styled';
+import {PlatformIcon} from 'platformicons';
-import {generateIconName} from 'sentry/components/events/contexts/utils';
-import LoadingMask from 'sentry/components/loadingMask';
import CountTooltipContent from 'sentry/components/replays/countTooltipContent';
import {Tooltip} from 'sentry/components/tooltip';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
+import {generatePlatformIconName} from 'sentry/utils/replays/generatePlatformIconName';
+import commonTheme from 'sentry/utils/theme';
type Props = {
name: string;
@@ -16,20 +16,17 @@ type Props = {
showVersion?: boolean;
};
-const LazyContextIcon = lazy(
- () => import('sentry/components/events/contexts/contextIcon')
-);
+const iconSize = '16px';
+const iconStyle = {
+ border: '1px solid ' + commonTheme.translucentGray100,
+};
const ContextIcon = styled(
({className, name, version, showVersion, showTooltip}: Props) => {
- const icon = generateIconName(name, version);
+ const icon = generatePlatformIconName(name, version);
if (!showTooltip) {
- return (
- }>
-
-
- );
+ return ;
}
const title = (
@@ -42,9 +39,7 @@ const ContextIcon = styled(
);
return (
- }>
-
-
+
{showVersion ? (version ? version : null) : undefined}
);
diff --git a/static/app/components/replays/replayContext.tsx b/static/app/components/replays/replayContext.tsx
index 9bf559d8f5efdf..9d2f547a020762 100644
--- a/static/app/components/replays/replayContext.tsx
+++ b/static/app/components/replays/replayContext.tsx
@@ -246,9 +246,7 @@ export function Provider({
const startTimeOffsetMs = replay?.getStartOffsetMs() ?? 0;
const videoEvents = replay?.getVideoEvents();
const startTimestampMs = replay?.getStartTimestampMs();
- const isVideoReplay = Boolean(
- organization.features.includes('session-replay-mobile-player') && videoEvents?.length
- );
+ const isVideoReplay = Boolean(videoEvents?.length);
const forceDimensions = (dimension: Dimensions) => {
setDimensions(dimension);
diff --git a/static/app/components/replaysOnboarding/sidebar.tsx b/static/app/components/replaysOnboarding/sidebar.tsx
index 43395102cde697..8b8e11bc1e7f52 100644
--- a/static/app/components/replaysOnboarding/sidebar.tsx
+++ b/static/app/components/replaysOnboarding/sidebar.tsx
@@ -10,6 +10,7 @@ import {CompactSelect} from 'sentry/components/compactSelect';
import RadioGroup from 'sentry/components/forms/controls/radioGroup';
import IdBadge from 'sentry/components/idBadge';
import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {MobileBetaBanner} from 'sentry/components/onboarding/gettingStartedDoc/utils';
import useCurrentProjectState from 'sentry/components/onboarding/gettingStartedDoc/utils/useCurrentProjectState';
import {useLoadGettingStarted} from 'sentry/components/onboarding/gettingStartedDoc/utils/useLoadGettingStarted';
import {PlatformOptionDropdown} from 'sentry/components/replaysOnboarding/platformOptionDropdown';
@@ -43,6 +44,7 @@ function ReplaysOnboardingSidebar(props: CommonSidebarProps) {
const hasProjectAccess = organization.access.includes('project:read');
const {
+ hasDocs,
projects,
allProjects,
currentProject,
@@ -147,13 +149,19 @@ function ReplaysOnboardingSidebar(props: CommonSidebarProps) {
/>
-
+
);
}
-function OnboardingContent({currentProject}: {currentProject: Project}) {
+function OnboardingContent({
+ currentProject,
+ hasDocs,
+}: {
+ currentProject: Project;
+ hasDocs: boolean;
+}) {
const jsFrameworkSelectOptions = replayJsFrameworkOptions().map(platform => {
return {
value: platform.id,
@@ -276,7 +284,8 @@ function OnboardingContent({currentProject}: {currentProject: Project}) {
onChange={setSetupMode}
/>
) : (
- docs?.platformOptions && (
+ docs?.platformOptions &&
+ !isProjKeysLoading && (
{tct("I'm using [platformSelect]", {
platformSelect: (
@@ -298,6 +307,22 @@ function OnboardingContent({currentProject}: {currentProject: Project}) {
);
}
+ // TODO: remove once we have mobile replay onboarding
+ if (['android', 'react-native'].includes(currentPlatform.language)) {
+ return (
+
+ );
+ }
+ if (currentPlatform.language === 'apple') {
+ return (
+
+ );
+ }
+
const doesNotSupportReplay = currentProject.platform
? !replayPlatforms.includes(currentProject.platform)
: true;
@@ -324,8 +349,8 @@ function OnboardingContent({currentProject}: {currentProject: Project}) {
);
}
- // No platform or no docs
- if (!currentPlatform || !docs || !dsn) {
+ // No platform, docs import failed, no DSN, or the platform doesn't have onboarding yet
+ if (!currentPlatform || !docs || !dsn || !hasDocs) {
return (
diff --git a/static/app/data/forms/projectIssueGrouping.tsx b/static/app/data/forms/projectIssueGrouping.tsx
index 1765c1bbd1f857..8caf430eb3378e 100644
--- a/static/app/data/forms/projectIssueGrouping.tsx
+++ b/static/app/data/forms/projectIssueGrouping.tsx
@@ -160,23 +160,6 @@ stack.function:mylibrary_* +app`}
'Changing the expiration date will affect how many new issues are created.'
),
},
- groupingAutoUpdate: {
- name: 'groupingAutoUpdate',
- type: 'boolean',
- label: t('Automatically Update Grouping'),
- saveOnBlur: false,
- help: t(
- 'When enabled projects will in the future automatically update to the latest grouping algorithm. Right now this setting does nothing.'
- ),
- saveMessage: ({value}) =>
- value
- ? t(
- 'Enabling automatic upgrading will take effect on the next incoming event once auto updating has been rolled out.'
- )
- : t(
- 'Disabling auto updates will cause you to no longer receive improvements to the grouping algorithm.'
- ),
- },
};
const RuleDescription = styled('div')`
diff --git a/static/app/data/platformCategories.tsx b/static/app/data/platformCategories.tsx
index 4bd2ca81ac29ff..9c6f6af97997f0 100644
--- a/static/app/data/platformCategories.tsx
+++ b/static/app/data/platformCategories.tsx
@@ -364,17 +364,23 @@ export const replayFrontendPlatforms: readonly PlatformKey[] = [
'javascript-vue',
];
+// These are the mobile platforms that can set up replay.
+export const replayMobilePlatforms: PlatformKey[] = [
+ 'android',
+ 'apple-ios',
+ 'react-native',
+];
+
// These are all the platforms that can set up replay.
export const replayPlatforms: readonly PlatformKey[] = [
...replayFrontendPlatforms,
...replayBackendPlatforms,
+ ...replayMobilePlatforms,
];
/**
* The list of platforms for which we have created onboarding instructions.
* Should be a subset of the list of `replayPlatforms`.
- * This should match sentry-docs: `/src/wizard/${platform}/replay-onboarding/${subPlatform}/`.
- * See: https://github.com/getsentry/sentry-docs/tree/master/src/wizard/javascript/replay-onboarding
*/
export const replayOnboardingPlatforms: readonly PlatformKey[] = [
...replayFrontendPlatforms.filter(p => !['javascript-backbone'].includes(p)),
diff --git a/static/app/types/group.tsx b/static/app/types/group.tsx
index af12bb69b4ca94..5735107c1e5fa1 100644
--- a/static/app/types/group.tsx
+++ b/static/app/types/group.tsx
@@ -299,6 +299,11 @@ export type TagWithTopValues = {
/**
* Inbox, issue owners and Activity
*/
+export type Annotation = {
+ displayName: string;
+ url: string;
+};
+
export type InboxReasonDetails = {
count?: number | null;
until?: string | null;
@@ -777,7 +782,7 @@ export const enum PriorityLevel {
// TODO(ts): incomplete
export interface BaseGroup {
activity: GroupActivity[];
- annotations: string[];
+ annotations: string[] | Annotation[];
assignedTo: Actor | null;
culprit: string;
firstSeen: string;
diff --git a/static/app/types/project.tsx b/static/app/types/project.tsx
index 43c139e686585e..d1556296fd33e6 100644
--- a/static/app/types/project.tsx
+++ b/static/app/types/project.tsx
@@ -27,7 +27,6 @@ export type Project = {
features: string[];
firstEvent: string | null;
firstTransactionEvent: boolean;
- groupingAutoUpdate: boolean;
groupingConfig: string;
hasAccess: boolean;
hasCustomMetrics: boolean;
diff --git a/static/app/utils/dashboards/issueFieldRenderers.spec.tsx b/static/app/utils/dashboards/issueFieldRenderers.spec.tsx
index bccb13a7923667..fcdc8ff92f654a 100644
--- a/static/app/utils/dashboards/issueFieldRenderers.spec.tsx
+++ b/static/app/utils/dashboards/issueFieldRenderers.spec.tsx
@@ -52,7 +52,7 @@ describe('getIssueFieldRenderer', function () {
filteredEvents: 3000,
events: 6000,
period: '7d',
- links: ['
ANNO-123'],
+ links: '
ANNO-123',
};
MockApiClient.addMockResponse({
@@ -152,10 +152,7 @@ describe('getIssueFieldRenderer', function () {
{
data,
...{
- links: [
- '
ANNO-123',
- '
ANNO-456',
- ],
+ links: '
ANNO-123,
ANNO-456',
},
},
{
diff --git a/static/app/utils/dashboards/issueFieldRenderers.tsx b/static/app/utils/dashboards/issueFieldRenderers.tsx
index fac978e0e4900c..991b1c7366ed17 100644
--- a/static/app/utils/dashboards/issueFieldRenderers.tsx
+++ b/static/app/utils/dashboards/issueFieldRenderers.tsx
@@ -5,6 +5,7 @@ import type {Location} from 'history';
import Count from 'sentry/components/count';
import DeprecatedAssigneeSelector from 'sentry/components/deprecatedAssigneeSelector';
+import ExternalLink from 'sentry/components/links/externalLink';
import Link from 'sentry/components/links/link';
import {getRelativeSummary} from 'sentry/components/timeRangeSelector/utils';
import {Tooltip} from 'sentry/components/tooltip';
@@ -12,6 +13,7 @@ import {DEFAULT_STATS_PERIOD} from 'sentry/constants';
import {t} from 'sentry/locale';
import MemberListStore from 'sentry/stores/memberListStore';
import {space} from 'sentry/styles/space';
+import type {Annotation} from 'sentry/types/group';
import type {Organization} from 'sentry/types/organization';
import type {EventData} from 'sentry/utils/discover/eventView';
import EventView from 'sentry/utils/discover/eventView';
@@ -141,7 +143,23 @@ const SPECIAL_FIELDS: SpecialFields = {
},
links: {
sortField: null,
- renderFunc: ({links}) =>
,
+ renderFunc: ({links}) => {
+ if (typeof links === 'string') {
+ return
;
+ }
+ if (isLinkAnnotation(links)) {
+ return (
+
+ {links.map((link, index) => (
+
+ {link.displayName}
+
+ ))}
+
+ );
+ }
+ return
;
+ },
},
};
@@ -241,6 +259,10 @@ export function getSortField(field: string): string | null {
}
}
+function isLinkAnnotation(value: unknown): value is Annotation[] {
+ return Array.isArray(value) && value.every(v => typeof v === 'object');
+}
+
const contentStyle = css`
width: 100%;
justify-content: space-between;
diff --git a/static/app/utils/replays/generatePlatformIconName.tsx b/static/app/utils/replays/generatePlatformIconName.tsx
new file mode 100644
index 00000000000000..325bffcb0eabb6
--- /dev/null
+++ b/static/app/utils/replays/generatePlatformIconName.tsx
@@ -0,0 +1,32 @@
+import {generateIconName} from 'sentry/components/events/contexts/utils';
+
+const PLATFORM_ALIASES = {
+ ios: 'apple',
+ mac: 'apple',
+ macos: 'apple',
+ 'mac-os-x': 'apple',
+ darwin: 'apple',
+ tvos: 'apple-tv',
+ watchos: 'apple-watch',
+ iphone: 'apple-iphone',
+ ipad: 'apple-ipad',
+ 'legacy-edge': 'edge-legacy',
+ 'mobile-safari': 'safari',
+ 'chrome-mobile-ios': 'chrome',
+ 'google-chrome': 'chrome',
+ 'chrome-os': 'chrome',
+ net: 'dotnet',
+ 'net-core': 'dotnetcore',
+ 'net-framework': 'dotnetframework',
+};
+
+/**
+ * Generates names used for PlatformIcon. Translates ContextIcon names (https://sentry.sentry.io/stories/?name=app/components/events/contexts/contextIcon.stories.tsx) to PlatformIcon (https://www.npmjs.com/package/platformicons) names
+ */
+export function generatePlatformIconName(
+ name: string,
+ version: string | undefined
+): string {
+ const contextName = generateIconName(name, version);
+ return contextName in PLATFORM_ALIASES ? PLATFORM_ALIASES[contextName] : contextName;
+}
diff --git a/static/app/utils/replays/projectSupportsReplay.tsx b/static/app/utils/replays/projectSupportsReplay.tsx
index 82c1f49b63a434..009d3152a080d5 100644
--- a/static/app/utils/replays/projectSupportsReplay.tsx
+++ b/static/app/utils/replays/projectSupportsReplay.tsx
@@ -1,4 +1,4 @@
-import {backend, mobile, replayPlatforms} from 'sentry/data/platformCategories';
+import {backend, replayPlatforms} from 'sentry/data/platformCategories';
import type {Organization} from 'sentry/types/organization';
import type {MinimalProject} from 'sentry/types/project';
@@ -17,17 +17,13 @@ function projectSupportsReplay(project: MinimalProject) {
* Basically: is this a backend or frontend project
*/
export function projectCanLinkToReplay(
- organization: Organization,
+ _organization: Organization,
project: undefined | MinimalProject
) {
if (!project || !project.platform) {
return false;
}
-
- const hasMobileReplay = organization.features.includes('session-replay-mobile-player');
- const supportedPlatforms = hasMobileReplay
- ? replayPlatforms.concat(mobile)
- : replayPlatforms;
+ const supportedPlatforms = replayPlatforms;
return (
supportedPlatforms.includes(project.platform) || backend.includes(project.platform)
diff --git a/static/app/views/alerts/list/rules/activatedRuleRow.tsx b/static/app/views/alerts/list/rules/activatedRuleRow.tsx
index 88df87c7722337..606abc205dd5ea 100644
--- a/static/app/views/alerts/list/rules/activatedRuleRow.tsx
+++ b/static/app/views/alerts/list/rules/activatedRuleRow.tsx
@@ -1,4 +1,4 @@
-import {useMemo, useState} from 'react';
+import {useState} from 'react';
import styled from '@emotion/styled';
import Access from 'sentry/components/acl/access';
@@ -18,21 +18,15 @@ import LoadingIndicator from 'sentry/components/loadingIndicator';
import TextOverflow from 'sentry/components/textOverflow';
import TimeSince from 'sentry/components/timeSince';
import {Tooltip} from 'sentry/components/tooltip';
-import {IconArrow, IconChevron, IconEllipsis, IconMute, IconUser} from 'sentry/icons';
+import {IconChevron, IconEllipsis, IconUser} from 'sentry/icons';
import {t, tct} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Actor, Project} from 'sentry/types';
-import type {ColorOrAlias} from 'sentry/utils/theme';
import {useUserTeams} from 'sentry/utils/useUserTeams';
-import {getThresholdUnits} from 'sentry/views/alerts/rules/metric/constants';
-import {
- AlertRuleComparisonType,
- AlertRuleThresholdType,
- AlertRuleTriggerType,
-} from 'sentry/views/alerts/rules/metric/types';
+import AlertRuleStatus from 'sentry/views/alerts/list/rules/alertRuleStatus';
import type {CombinedMetricIssueAlerts, MetricAlert} from '../../types';
-import {ActivationStatus, CombinedAlertType, IncidentStatus} from '../../types';
+import {ActivationStatus, CombinedAlertType} from '../../types';
type Props = {
hasEditAccess: boolean;
@@ -59,12 +53,9 @@ function ActivatedRuleListRow({
}: Props) {
const {teams: userTeams} = useUserTeams();
const [assignee, setAssignee] = useState
('');
- const isWaiting = useMemo(
- () =>
- !rule.activations?.length ||
- (rule.activations?.length && rule.activations[0].isComplete),
- [rule]
- );
+ const isWaiting =
+ !rule.activations?.length ||
+ (rule.activations?.length && rule.activations[0].isComplete);
function renderLatestActivation(): React.ReactNode {
if (!rule.activations?.length) {
@@ -79,69 +70,6 @@ function ActivatedRuleListRow({
);
}
- function renderSnoozeStatus(): React.ReactNode {
- return (
-
-
- {t('Muted')}
-
- );
- }
-
- function renderAlertRuleStatus(): React.ReactNode {
- if (rule.snooze) {
- return renderSnoozeStatus();
- }
-
- const isUnhealthy =
- rule.latestIncident?.status !== undefined &&
- [IncidentStatus.CRITICAL, IncidentStatus.WARNING].includes(
- rule.latestIncident.status
- );
-
- let iconColor: ColorOrAlias = 'successText';
- let iconDirection: 'up' | 'down' =
- rule.thresholdType === AlertRuleThresholdType.ABOVE ? 'down' : 'up';
- let thresholdTypeText =
- rule.thresholdType === AlertRuleThresholdType.ABOVE ? t('Below') : t('Above');
- if (isUnhealthy) {
- iconColor =
- rule.latestIncident?.status === IncidentStatus.CRITICAL
- ? 'errorText'
- : 'warningText';
- // if unhealthy, swap icon direction
- iconDirection = rule.thresholdType === AlertRuleThresholdType.ABOVE ? 'up' : 'down';
- thresholdTypeText =
- rule.thresholdType === AlertRuleThresholdType.ABOVE ? t('Above') : t('Below');
- }
-
- let threshold = rule.triggers.find(
- ({label}) => label === AlertRuleTriggerType.CRITICAL
- )?.alertThreshold;
- if (isUnhealthy && rule.latestIncident?.status === IncidentStatus.WARNING) {
- threshold = rule.triggers.find(
- ({label}) => label === AlertRuleTriggerType.WARNING
- )?.alertThreshold;
- } else if (!isUnhealthy && rule.latestIncident && rule.resolveThreshold) {
- threshold = rule.resolveThreshold;
- }
-
- return (
-
-
-
- {`${thresholdTypeText} ${threshold}`}
- {getThresholdUnits(
- rule.aggregate,
- rule.comparisonDelta
- ? AlertRuleComparisonType.CHANGE
- : AlertRuleComparisonType.COUNT
- )}
-
-
- );
- }
-
const slug = rule.projects[0];
const editLink = `/organizations/${orgId}/alerts/metric-rules/${slug}/${rule.id}/`;
@@ -282,7 +210,9 @@ function ActivatedRuleListRow({
/>
- {renderAlertRuleStatus()}
+
+
+
@@ -355,13 +285,6 @@ const FlexCenter = styled('div')`
align-items: center;
`;
-const IssueAlertStatusWrapper = styled('div')`
- display: flex;
- align-items: center;
- gap: ${space(1)};
- line-height: 2;
-`;
-
const AlertNameWrapper = styled('div')<{isIssueAlert?: boolean}>`
${p => p.theme.overflowEllipsis}
display: flex;
@@ -392,12 +315,6 @@ const ProjectBadge = styled(IdBadge)`
flex-shrink: 0;
`;
-const TriggerText = styled('div')`
- margin-left: ${space(1)};
- white-space: nowrap;
- font-variant-numeric: tabular-nums;
-`;
-
const ActionsColumn = styled('div')`
display: flex;
align-items: center;
diff --git a/static/app/views/alerts/list/rules/alertRuleStatus.tsx b/static/app/views/alerts/list/rules/alertRuleStatus.tsx
new file mode 100644
index 00000000000000..b12af63a233131
--- /dev/null
+++ b/static/app/views/alerts/list/rules/alertRuleStatus.tsx
@@ -0,0 +1,99 @@
+import type {ReactNode} from 'react';
+import styled from '@emotion/styled';
+
+import {IconArrow, IconMute} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {ColorOrAlias} from 'sentry/utils/theme';
+import {getThresholdUnits} from 'sentry/views/alerts/rules/metric/constants';
+import {
+ AlertRuleComparisonType,
+ AlertRuleThresholdType,
+ AlertRuleTriggerType,
+} from 'sentry/views/alerts/rules/metric/types';
+
+import type {MetricAlert} from '../../types';
+import {IncidentStatus} from '../../types';
+
+interface Props {
+ rule: MetricAlert;
+}
+
+export default function AlertRuleStatus({rule}: Props): ReactNode {
+ if (rule.snooze) {
+ return (
+
+
+ {t('Muted')}
+
+ );
+ }
+
+ const isUnhealthy =
+ rule.latestIncident?.status !== undefined &&
+ [IncidentStatus.CRITICAL, IncidentStatus.WARNING].includes(
+ rule.latestIncident.status
+ );
+
+ let iconColor: ColorOrAlias = 'successText';
+ let iconDirection: 'up' | 'down' =
+ rule.thresholdType === AlertRuleThresholdType.ABOVE ? 'down' : 'up';
+ let thresholdTypeText =
+ rule.thresholdType === AlertRuleThresholdType.ABOVE ? t('Below') : t('Above');
+ if (isUnhealthy) {
+ iconColor =
+ rule.latestIncident?.status === IncidentStatus.CRITICAL
+ ? 'errorText'
+ : 'warningText';
+ // if unhealthy, swap icon direction
+ iconDirection = rule.thresholdType === AlertRuleThresholdType.ABOVE ? 'up' : 'down';
+ thresholdTypeText =
+ rule.thresholdType === AlertRuleThresholdType.ABOVE ? t('Above') : t('Below');
+ }
+
+ let threshold = rule.triggers.find(
+ ({label}) => label === AlertRuleTriggerType.CRITICAL
+ )?.alertThreshold;
+ if (isUnhealthy && rule.latestIncident?.status === IncidentStatus.WARNING) {
+ threshold = rule.triggers.find(
+ ({label}) => label === AlertRuleTriggerType.WARNING
+ )?.alertThreshold;
+ } else if (!isUnhealthy && rule.latestIncident && rule.resolveThreshold) {
+ threshold = rule.resolveThreshold;
+ }
+
+ return (
+
+
+
+ {`${thresholdTypeText} ${threshold}`}
+ {getThresholdUnits(
+ rule.aggregate,
+ rule.comparisonDelta
+ ? AlertRuleComparisonType.CHANGE
+ : AlertRuleComparisonType.COUNT
+ )}
+
+
+ );
+}
+
+// TODO: see static/app/components/profiling/flex.tsx and utilize the FlexContainer styled component
+const FlexCenter = styled('div')`
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+`;
+
+const IssueAlertStatusWrapper = styled('div')`
+ display: flex;
+ align-items: center;
+ gap: ${space(1)};
+ line-height: 2;
+`;
+
+const TriggerText = styled('div')`
+ margin-left: ${space(1)};
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+`;
diff --git a/static/app/views/dashboards/datasetConfig/issues.tsx b/static/app/views/dashboards/datasetConfig/issues.tsx
index 11932ec05e33f0..f8002a917c3d57 100644
--- a/static/app/views/dashboards/datasetConfig/issues.tsx
+++ b/static/app/views/dashboards/datasetConfig/issues.tsx
@@ -122,7 +122,9 @@ export function transformIssuesResponseToTable(
issue: shortId,
title,
project: project.slug,
- links: annotations?.join(', '),
+ links: annotations?.every(a => typeof a === 'string')
+ ? annotations.join(', ')
+ : ((annotations ?? []) as any),
};
// Get lifetime stats
diff --git a/static/app/views/issueDetails/header.spec.tsx b/static/app/views/issueDetails/header.spec.tsx
index ae5611f67fa9c8..e80ec7bea783e8 100644
--- a/static/app/views/issueDetails/header.spec.tsx
+++ b/static/app/views/issueDetails/header.spec.tsx
@@ -118,7 +118,7 @@ describe('GroupHeader', () => {
});
const mobileProjectWithSimilarityView = ProjectFixture({
features: ['similarity-view'],
- platform: 'apple-ios',
+ platform: 'unity',
});
const MOCK_GROUP = GroupFixture();
diff --git a/static/app/views/performance/transactionSummary/transactionProfiles/index.tsx b/static/app/views/performance/transactionSummary/transactionProfiles/index.tsx
index 82e3f253d0e124..03cd55c3f19f37 100644
--- a/static/app/views/performance/transactionSummary/transactionProfiles/index.tsx
+++ b/static/app/views/performance/transactionSummary/transactionProfiles/index.tsx
@@ -447,6 +447,7 @@ const RequestStateMessageContainer = styled('div')`
justify-content: center;
align-items: center;
color: ${p => p.theme.subText};
+ pointer-events: none;
`;
const AggregateFlamegraphToolbarContainer = styled('div')`
diff --git a/static/app/views/replays/detail/useAllMobileProj.tsx b/static/app/views/replays/detail/useAllMobileProj.tsx
index f2105815a126e7..efcb0d1dea3d7b 100644
--- a/static/app/views/replays/detail/useAllMobileProj.tsx
+++ b/static/app/views/replays/detail/useAllMobileProj.tsx
@@ -1,10 +1,8 @@
import {mobile} from 'sentry/data/platformCategories';
-import useOrganization from 'sentry/utils/useOrganization';
import usePageFilters from 'sentry/utils/usePageFilters';
import useProjects from 'sentry/utils/useProjects';
export default function useAllMobileProj() {
- const organization = useOrganization();
const {
selection: {projects: projectIds},
} = usePageFilters();
@@ -15,9 +13,7 @@ export default function useAllMobileProj() {
// if no projects selected, look through all projects
const proj = projectsSelected.length ? projectsSelected : projects;
- const allMobileProj =
- organization.features.includes('session-replay-mobile-player') &&
- proj.every(p => mobile.includes(p.platform ?? 'other'));
+ const allMobileProj = proj.every(p => mobile.includes(p.platform ?? 'other'));
return {allMobileProj};
}
diff --git a/static/app/views/replays/details.tsx b/static/app/views/replays/details.tsx
index c94e038cb44335..6a95d80e723925 100644
--- a/static/app/views/replays/details.tsx
+++ b/static/app/views/replays/details.tsx
@@ -177,10 +177,7 @@ function ReplayDetails({params: {replaySlug}}: Props) {
);
}
- const isVideoReplay = Boolean(
- organization.features.includes('session-replay-mobile-player') &&
- replay?.isVideoReplay()
- );
+ const isVideoReplay = replay?.isVideoReplay();
return (
{
expect(mockFetchReplayListRequest).toHaveBeenCalled();
});
- it('should fetch the replay table and show selector tables when the org is on AM2, has sent some replays, and has a newer SDK version', async () => {
+ it('should fetch the replay table when the org is on AM2, has sent some replays, and has a newer SDK version', async () => {
const mockOrg = getMockOrganizationFixture({features: AM2_FEATURES});
mockUseHaveSelectedProjectsSentAnyReplayEvents.mockReturnValue({
fetching: false,
@@ -154,21 +154,13 @@ describe('ReplayList', () => {
isFetching: false,
needsUpdate: false,
});
- mockUseDeadRageSelectors.mockReturnValue({
- isLoading: false,
- isError: false,
- data: [],
- pageLinks: undefined,
- });
render(, {
organization: mockOrg,
});
await waitFor(() => expect(screen.queryAllByTestId('replay-table')).toHaveLength(1));
- await waitFor(() =>
- expect(screen.queryAllByTestId('selector-widget')).toHaveLength(2)
- );
+
expect(mockFetchReplayListRequest).toHaveBeenCalled();
});
});
diff --git a/static/app/views/replays/list/replayOnboardingPanel.tsx b/static/app/views/replays/list/replayOnboardingPanel.tsx
index 45c7112259bfaa..a24fba3face50d 100644
--- a/static/app/views/replays/list/replayOnboardingPanel.tsx
+++ b/static/app/views/replays/list/replayOnboardingPanel.tsx
@@ -12,7 +12,7 @@ import QuestionTooltip from 'sentry/components/questionTooltip';
import Accordion from 'sentry/components/replays/accordion';
import ReplayUnsupportedAlert from 'sentry/components/replays/alerts/replayUnsupportedAlert';
import {Tooltip} from 'sentry/components/tooltip';
-import {mobile, replayPlatforms} from 'sentry/data/platformCategories';
+import {replayPlatforms} from 'sentry/data/platformCategories';
import {t, tct} from 'sentry/locale';
import PreferencesStore from 'sentry/stores/preferencesStore';
import {useLegacyStore} from 'sentry/stores/useLegacyStore';
@@ -22,6 +22,7 @@ import useOrganization from 'sentry/utils/useOrganization';
import usePageFilters from 'sentry/utils/usePageFilters';
import useProjects from 'sentry/utils/useProjects';
import {HeaderContainer, WidgetContainer} from 'sentry/views/profiling/landing/styles';
+import useAllMobileProj from 'sentry/views/replays/detail/useAllMobileProj';
import ReplayPanel from 'sentry/views/replays/list/replayPanel';
type Breakpoints = {
@@ -47,11 +48,8 @@ export default function ReplayOnboardingPanel() {
const projects = useProjects();
const organization = useOrganization();
const {canCreateProject} = useProjectCreationAccess({organization});
- const hasMobileReplays = organization.features.includes('session-replay-mobile-player');
- const supportedPlatforms = hasMobileReplays
- ? replayPlatforms.concat(mobile)
- : replayPlatforms;
+ const supportedPlatforms = replayPlatforms;
const selectedProjects = projects.projects.filter(p =>
pageFilters.selection.projects.includes(Number(p.id))
@@ -124,6 +122,8 @@ export function SetupReplaysCTA({
}: SetupReplaysCTAProps) {
const {activateSidebar} = useReplayOnboardingSidebarPanel();
const [expanded, setExpanded] = useState(-1);
+ const {allMobileProj} = useAllMobileProj();
+
const FAQ = [
{
header: (
@@ -276,7 +276,11 @@ export function SetupReplaysCTA({
{renderCTA()}