From 3a10fcc560c2f7781dd0fa7c64c747341217f7f4 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Tue, 29 Jul 2025 18:27:41 -0400 Subject: [PATCH] feat(uptime): Add organization uptime percentiles endpoint - Add OrganizationUptimePercentlesEndpoint for calculating uptime statistics. There might be a better name for this, but we already have `uptime-stats` and `uptime-counts`. - Extract common utility function for subscription ID authorization and mapping - Add UptimePercentileStats model and serializer for percentile calculations - Support both EAP results and legacy uptime checks --- src/sentry/api/urls.py | 6 + .../endpoints/organization_uptime_stats.py | 50 +-- .../endpoints/organization_uptime_summary.py | 287 ++++++++++++++ src/sentry/uptime/endpoints/serializers.py | 23 +- src/sentry/uptime/endpoints/utils.py | 61 +++ src/sentry/uptime/types.py | 12 + .../test_organization_uptime_summary.py | 357 ++++++++++++++++++ tests/sentry/uptime/endpoints/test_utils.py | 107 ++++++ 8 files changed, 857 insertions(+), 46 deletions(-) create mode 100644 src/sentry/uptime/endpoints/organization_uptime_summary.py create mode 100644 src/sentry/uptime/endpoints/utils.py create mode 100644 tests/sentry/uptime/endpoints/test_organization_uptime_summary.py create mode 100644 tests/sentry/uptime/endpoints/test_utils.py diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 8d45b62880da89..91de63f8611252 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -416,6 +416,7 @@ OrganizationUptimeAlertIndexCountEndpoint, ) from sentry.uptime.endpoints.organization_uptime_stats import OrganizationUptimeStatsEndpoint +from sentry.uptime.endpoints.organization_uptime_summary import OrganizationUptimeSummaryEndpoint from sentry.uptime.endpoints.project_uptime_alert_checks_index import ( ProjectUptimeAlertCheckIndexEndpoint, ) @@ -2426,6 +2427,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: OrganizationUptimeStatsEndpoint.as_view(), name="sentry-api-0-organization-uptime-stats", ), + re_path( + r"^(?P[^/]+)/uptime-summary/$", + OrganizationUptimeSummaryEndpoint.as_view(), + name="sentry-api-0-organization-uptime-summary", + ), re_path( r"^(?P[^/]+)/insights/tree/$", OrganizationInsightsTreeEndpoint.as_view(), diff --git a/src/sentry/uptime/endpoints/organization_uptime_stats.py b/src/sentry/uptime/endpoints/organization_uptime_stats.py index 797b2e93f5c02e..6eb5686406262e 100644 --- a/src/sentry/uptime/endpoints/organization_uptime_stats.py +++ b/src/sentry/uptime/endpoints/organization_uptime_stats.py @@ -2,7 +2,6 @@ import logging import uuid from collections import defaultdict -from collections.abc import Callable from drf_spectacular.utils import extend_schema from google.protobuf.timestamp_pb2 import Timestamp @@ -31,16 +30,16 @@ from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission from sentry.models.organization import Organization from sentry.models.project import Project -from sentry.uptime.models import ProjectUptimeSubscription +from sentry.uptime.endpoints.utils import ( + MAX_UPTIME_SUBSCRIPTION_IDS, + authorize_and_map_project_uptime_subscription_ids, +) from sentry.uptime.types import IncidentStatus from sentry.utils.snuba_rpc import timeseries_rpc logger = logging.getLogger(__name__) -MAX_UPTIME_SUBSCRIPTION_IDS = 100 - - @region_silo_endpoint @extend_schema(tags=["Uptime Monitors"]) class OrganizationUptimeStatsEndpoint(OrganizationEndpoint, StatsMixin): @@ -78,7 +77,7 @@ def get(self, request: Request, organization: Organization) -> Response: subscription_id_formatter = lambda sub_id: str(uuid.UUID(sub_id)) subscription_id_to_project_uptime_subscription_id, subscription_ids = ( - self._authorize_and_map_project_uptime_subscription_ids( + authorize_and_map_project_uptime_subscription_ids( project_uptime_subscription_ids, projects, subscription_id_formatter ) ) @@ -135,45 +134,6 @@ def get(self, request: Request, organization: Organization) -> Response: return self.respond(response_with_extra_buckets) - def _authorize_and_map_project_uptime_subscription_ids( - self, - project_uptime_subscription_ids: list[str], - projects: list[Project], - sub_id_formatter: Callable[[str], str], - ) -> tuple[dict[str, int], list[str]]: - """ - Authorize the project uptime subscription ids and return their corresponding subscription ids - we don't store the project uptime subscription id in snuba, so we need to map it to the subscription id - """ - project_uptime_subscription_ids_ints = [int(_id) for _id in project_uptime_subscription_ids] - project_uptime_subscriptions = ProjectUptimeSubscription.objects.filter( - project_id__in=[project.id for project in projects], - id__in=project_uptime_subscription_ids_ints, - ).values_list("id", "uptime_subscription__subscription_id") - - validated_project_uptime_subscription_ids = { - project_uptime_subscription[0] - for project_uptime_subscription in project_uptime_subscriptions - if project_uptime_subscription[0] is not None - } - if set(project_uptime_subscription_ids_ints) != validated_project_uptime_subscription_ids: - raise ValueError("Invalid project uptime subscription ids provided") - - subscription_id_to_project_uptime_subscription_id = { - sub_id_formatter(project_uptime_subscription[1]): project_uptime_subscription[0] - for project_uptime_subscription in project_uptime_subscriptions - if project_uptime_subscription[0] is not None - and project_uptime_subscription[1] is not None - } - - validated_subscription_ids = [ - sub_id_formatter(project_uptime_subscription[1]) - for project_uptime_subscription in project_uptime_subscriptions - if project_uptime_subscription[1] is not None - ] - - return subscription_id_to_project_uptime_subscription_id, validated_subscription_ids - def _make_eap_request( self, organization: Organization, diff --git a/src/sentry/uptime/endpoints/organization_uptime_summary.py b/src/sentry/uptime/endpoints/organization_uptime_summary.py new file mode 100644 index 00000000000000..831e80754ca924 --- /dev/null +++ b/src/sentry/uptime/endpoints/organization_uptime_summary.py @@ -0,0 +1,287 @@ +import logging +import uuid +from datetime import datetime + +from drf_spectacular.utils import extend_schema +from google.protobuf.timestamp_pb2 import Timestamp +from rest_framework.request import Request +from rest_framework.response import Response +from sentry_kafka_schemas.schema_types.uptime_results_v1 import ( + CHECKSTATUS_FAILURE, + CHECKSTATUS_MISSED_WINDOW, +) +from sentry_protos.snuba.v1.attribute_conditional_aggregation_pb2 import ( + AttributeConditionalAggregation, +) +from sentry_protos.snuba.v1.downsampled_storage_pb2 import DownsampledStorageConfig +from sentry_protos.snuba.v1.endpoint_trace_item_table_pb2 import ( + Column, + TraceItemTableRequest, + TraceItemTableResponse, +) +from sentry_protos.snuba.v1.request_common_pb2 import RequestMeta, TraceItemType +from sentry_protos.snuba.v1.trace_item_attribute_pb2 import ( + AttributeAggregation, + AttributeKey, + AttributeValue, + Function, + StrArray, +) +from sentry_protos.snuba.v1.trace_item_filter_pb2 import ( + AndFilter, + ComparisonFilter, + TraceItemFilter, +) + +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 import OrganizationEndpoint, OrganizationPermission +from sentry.api.serializers import serialize +from sentry.api.utils import get_date_range_from_params +from sentry.models.organization import Organization +from sentry.models.project import Project +from sentry.uptime.endpoints.utils import ( + MAX_UPTIME_SUBSCRIPTION_IDS, + authorize_and_map_project_uptime_subscription_ids, +) +from sentry.uptime.types import IncidentStatus, UptimeSummary +from sentry.utils.snuba_rpc import table_rpc + +logger = logging.getLogger(__name__) + + +@region_silo_endpoint +@extend_schema(tags=["Uptime Monitors"]) +class OrganizationUptimeSummaryEndpoint(OrganizationEndpoint): + publish_status = { + "GET": ApiPublishStatus.EXPERIMENTAL, + } + owner = ApiOwner.CRONS + permission_classes = (OrganizationPermission,) + + def get(self, request: Request, organization: Organization) -> Response: + start, end = get_date_range_from_params(request.GET) + projects = self.get_projects(request, organization, include_all_accessible=True) + + project_uptime_subscription_ids = request.GET.getlist("projectUptimeSubscriptionId") + + if not project_uptime_subscription_ids: + return self.respond("No project uptime subscription ids provided", status=400) + + if len(project_uptime_subscription_ids) > MAX_UPTIME_SUBSCRIPTION_IDS: + return self.respond( + f"Too many project uptime subscription ids provided. Maximum is {MAX_UPTIME_SUBSCRIPTION_IDS}", + status=400, + ) + + use_eap_results = features.has( + "organizations:uptime-eap-uptime-results-query", organization, actor=request.user + ) + + try: + # XXX: We need to query these using hex, since we store them without dashes. + # We remove this once we remove the old uptime checks + if use_eap_results: + subscription_id_formatter = lambda sub_id: uuid.UUID(sub_id).hex + else: + subscription_id_formatter = lambda sub_id: str(uuid.UUID(sub_id)) + + subscription_id_to_project_uptime_subscription_id, subscription_ids = ( + authorize_and_map_project_uptime_subscription_ids( + project_uptime_subscription_ids, projects, subscription_id_formatter + ) + ) + except ValueError: + return self.respond("Invalid project uptime subscription ids provided", status=400) + + try: + if use_eap_results: + eap_response = self._make_eap_request( + organization, + projects, + subscription_ids, + start, + end, + TraceItemType.TRACE_ITEM_TYPE_UPTIME_RESULT, + "subscription_id", + ) + else: + eap_response = self._make_eap_request( + organization, + projects, + subscription_ids, + start, + end, + TraceItemType.TRACE_ITEM_TYPE_UPTIME_CHECK, + "uptime_subscription_id", + ) + formatted_response = self._format_response(eap_response) + except Exception: + logger.exception("Error making EAP RPC request for uptime check summary") + return self.respond("error making request", status=400) + + # Map the response back to project uptime subscription ids + mapped_response = self._map_response_to_project_uptime_subscription_ids( + subscription_id_to_project_uptime_subscription_id, formatted_response + ) + + # Serialize the UptimeSummary objects + serialized_response = { + project_id: serialize(stats, request.user) + for project_id, stats in mapped_response.items() + } + + return self.respond(serialized_response) + + def _make_eap_request( + self, + organization: Organization, + projects: list[Project], + subscription_ids: list[str], + start: datetime, + end: datetime, + trace_item_type: TraceItemType.ValueType, + subscription_key: str, + ) -> TraceItemTableResponse: + start_timestamp = Timestamp() + start_timestamp.FromDatetime(start) + end_timestamp = Timestamp() + end_timestamp.FromDatetime(end) + + subscription_attribute_key = AttributeKey( + name=subscription_key, + type=AttributeKey.Type.TYPE_STRING, + ) + + query_filter = TraceItemFilter( + comparison_filter=ComparisonFilter( + key=subscription_attribute_key, + op=ComparisonFilter.OP_IN, + value=AttributeValue(val_str_array=StrArray(values=subscription_ids)), + ) + ) + + def failure_filter(incident_status: IncidentStatus) -> TraceItemFilter: + status_filter = TraceItemFilter( + comparison_filter=ComparisonFilter( + key=AttributeKey(name="check_status", type=AttributeKey.Type.TYPE_STRING), + op=ComparisonFilter.OP_EQUALS, + value=AttributeValue(val_str=CHECKSTATUS_FAILURE), + ) + ) + incident_filter = TraceItemFilter( + comparison_filter=ComparisonFilter( + key=AttributeKey(name="incident_status", type=AttributeKey.Type.TYPE_INT), + op=ComparisonFilter.OP_EQUALS, + value=AttributeValue(val_int=incident_status.value), + ) + ) + return TraceItemFilter(and_filter=AndFilter(filters=[status_filter, incident_filter])) + + columns: list[Column] = [ + Column(label="uptime_subscription_id", key=subscription_attribute_key), + Column( + label="total_checks", + aggregation=AttributeAggregation( + aggregate=Function.FUNCTION_COUNT, + key=subscription_attribute_key, + label="count()", + ), + ), + Column( + label="failed_checks", + conditional_aggregation=AttributeConditionalAggregation( + aggregate=Function.FUNCTION_COUNT, + key=subscription_attribute_key, + filter=failure_filter(incident_status=IncidentStatus.NO_INCIDENT), + ), + ), + Column( + label="downtime_checks", + conditional_aggregation=AttributeConditionalAggregation( + aggregate=Function.FUNCTION_COUNT, + key=subscription_attribute_key, + filter=failure_filter(incident_status=IncidentStatus.IN_INCIDENT), + ), + ), + Column( + label="missed_window_checks", + conditional_aggregation=AttributeConditionalAggregation( + aggregate=Function.FUNCTION_COUNT, + key=subscription_attribute_key, + filter=TraceItemFilter( + comparison_filter=ComparisonFilter( + key=AttributeKey( + name="check_status", type=AttributeKey.Type.TYPE_STRING + ), + op=ComparisonFilter.OP_EQUALS, + value=AttributeValue(val_str=CHECKSTATUS_MISSED_WINDOW), + ) + ), + ), + ), + ] + + request = TraceItemTableRequest( + meta=RequestMeta( + organization_id=organization.id, + project_ids=[project.id for project in projects], + trace_item_type=trace_item_type, + start_timestamp=start_timestamp, + end_timestamp=end_timestamp, + downsampled_storage_config=DownsampledStorageConfig( + mode=DownsampledStorageConfig.MODE_HIGHEST_ACCURACY + ), + ), + group_by=[subscription_attribute_key], + filter=query_filter, + columns=columns, + ) + responses = table_rpc([request]) + assert len(responses) == 1 + return responses[0] + + def _format_response(self, response: TraceItemTableResponse) -> dict[str, UptimeSummary]: + """ + Formats the response from the EAP RPC request into a dictionary mapping + subscription ids to UptimeSummary + """ + column_values = response.column_values + column_names = [cv.attribute_name for cv in column_values] + formatted_data: dict[str, UptimeSummary] = {} + + if not column_values: + return {} + + for row_idx in range(len(column_values[0].results)): + row_dict: dict[str, AttributeValue] = { + col_name: column_values[col_idx].results[row_idx] + for col_idx, col_name in enumerate(column_names) + } + + summary_stats = UptimeSummary( + total_checks=int(row_dict["total_checks"].val_double), + failed_checks=int(row_dict["failed_checks"].val_double), + downtime_checks=int(row_dict["downtime_checks"].val_double), + missed_window_checks=int(row_dict["missed_window_checks"].val_double), + ) + + subscription_id = row_dict["uptime_subscription_id"].val_str + formatted_data[subscription_id] = summary_stats + + return formatted_data + + def _map_response_to_project_uptime_subscription_ids( + self, + subscription_id_to_project_uptime_subscription_id: dict[str, int], + formatted_response: dict[str, UptimeSummary], + ) -> dict[int, UptimeSummary]: + """ + Map the response back to project uptime subscription ids + """ + return { + subscription_id_to_project_uptime_subscription_id[subscription_id]: data + for subscription_id, data in formatted_response.items() + } diff --git a/src/sentry/uptime/endpoints/serializers.py b/src/sentry/uptime/endpoints/serializers.py index d6778583eb4831..a3c46a72f74665 100644 --- a/src/sentry/uptime/endpoints/serializers.py +++ b/src/sentry/uptime/endpoints/serializers.py @@ -12,7 +12,7 @@ from sentry.types.actor import Actor from sentry.uptime.models import ProjectUptimeSubscription, UptimeSubscription from sentry.uptime.subscriptions.regions import get_region_config -from sentry.uptime.types import EapCheckEntry, IncidentStatus +from sentry.uptime.types import EapCheckEntry, IncidentStatus, UptimeSummary class UptimeSubscriptionSerializerResponse(TypedDict): @@ -151,3 +151,24 @@ def serialize( "region": obj.region, "regionName": region_name, } + + +class UptimeSummarySerializerResponse(TypedDict): + total_checks: int + failed_checks: int + downtime_checks: int + missed_window_checks: int + + +@register(UptimeSummary) +class UptimeSummarySerializer(Serializer): + @override + def serialize( + self, obj: UptimeSummary, attrs: Any, user: Any, **kwargs: Any + ) -> UptimeSummarySerializerResponse: + return { + "total_checks": obj.total_checks, + "failed_checks": obj.failed_checks, + "downtime_checks": obj.downtime_checks, + "missed_window_checks": obj.missed_window_checks, + } diff --git a/src/sentry/uptime/endpoints/utils.py b/src/sentry/uptime/endpoints/utils.py new file mode 100644 index 00000000000000..9860ea19d66c93 --- /dev/null +++ b/src/sentry/uptime/endpoints/utils.py @@ -0,0 +1,61 @@ +from collections.abc import Callable + +from sentry.models.project import Project +from sentry.uptime.models import ProjectUptimeSubscription + +MAX_UPTIME_SUBSCRIPTION_IDS = 100 +""" +Maximum number of uptime subscription IDs that may be queried at once +""" + + +def authorize_and_map_project_uptime_subscription_ids( + project_uptime_subscription_ids: list[str], + projects: list[Project], + sub_id_formatter: Callable[[str], str], +) -> tuple[dict[str, int], list[str]]: + """ + Authorize the project uptime subscription ids and return their corresponding subscription ids. + + We don't store the project uptime subscription id in snuba, so we need to map it to the subscription id. + + Args: + project_uptime_subscription_ids: List of ProjectUptimeSubscription IDs as strings + projects: List of Project objects the user has access to + sub_id_formatter: Function to format subscription IDs (e.g., hex vs string format) + + Returns: + Tuple of: + - Mapping from formatted subscription_id to project_uptime_subscription_id + - List of formatted subscription IDs for use in Snuba queries + + Raises: + ValueError: If any of the provided IDs are invalid or unauthorized + """ + project_uptime_subscription_ids_ints = [int(_id) for _id in project_uptime_subscription_ids] + project_uptime_subscriptions = ProjectUptimeSubscription.objects.filter( + project_id__in=[project.id for project in projects], + id__in=project_uptime_subscription_ids_ints, + ).values_list("id", "uptime_subscription__subscription_id") + + validated_project_uptime_subscription_ids = { + project_uptime_subscription[0] + for project_uptime_subscription in project_uptime_subscriptions + if project_uptime_subscription[0] is not None + } + if set(project_uptime_subscription_ids_ints) != validated_project_uptime_subscription_ids: + raise ValueError("Invalid project uptime subscription ids provided") + + subscription_id_to_project_uptime_subscription_id = { + sub_id_formatter(project_uptime_subscription[1]): project_uptime_subscription[0] + for project_uptime_subscription in project_uptime_subscriptions + if project_uptime_subscription[0] is not None and project_uptime_subscription[1] is not None + } + + validated_subscription_ids = [ + sub_id_formatter(project_uptime_subscription[1]) + for project_uptime_subscription in project_uptime_subscriptions + if project_uptime_subscription[1] is not None + ] + + return subscription_id_to_project_uptime_subscription_id, validated_subscription_ids diff --git a/src/sentry/uptime/types.py b/src/sentry/uptime/types.py index aa7516c35a11b3..103744ad401c9e 100644 --- a/src/sentry/uptime/types.py +++ b/src/sentry/uptime/types.py @@ -125,6 +125,18 @@ class EapCheckEntry: region: str +@dataclass(frozen=True) +class UptimeSummary: + """ + Represents data used for uptime summary + """ + + total_checks: int + failed_checks: int + downtime_checks: int + missed_window_checks: int + + class UptimeMonitorMode(enum.IntEnum): # Manually created by a user MANUAL = 1 diff --git a/tests/sentry/uptime/endpoints/test_organization_uptime_summary.py b/tests/sentry/uptime/endpoints/test_organization_uptime_summary.py new file mode 100644 index 00000000000000..b51af169afb7c2 --- /dev/null +++ b/tests/sentry/uptime/endpoints/test_organization_uptime_summary.py @@ -0,0 +1,357 @@ +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any + +from sentry.testutils.cases import APITestCase, UptimeCheckSnubaTestCase +from sentry.testutils.helpers.datetime import freeze_time +from sentry.uptime.types import IncidentStatus +from tests.sentry.uptime.endpoints.test_base import UptimeResultEAPTestCase + +MOCK_DATETIME = datetime.now(tz=timezone.utc) - timedelta(days=1) + + +class OrganizationUptimeSummaryBaseTest(APITestCase): + __test__ = False + endpoint = "sentry-api-0-organization-uptime-summary" + features: dict[str, bool] = {} + + def setUp(self) -> None: + super().setUp() + self.login_as(user=self.user) + self.subscription_id = uuid.uuid4().hex + self.subscription = self.create_uptime_subscription( + url="https://santry.io", subscription_id=self.subscription_id + ) + self.project_uptime_subscription = self.create_project_uptime_subscription( + uptime_subscription=self.subscription + ) + + scenarios: list[dict] = [ + {"check_status": "success"}, + {"check_status": "success"}, + {"check_status": "success"}, + {"check_status": "failure", "incident_status": IncidentStatus.NO_INCIDENT}, + {"check_status": "failure", "incident_status": IncidentStatus.NO_INCIDENT}, + {"check_status": "failure", "incident_status": IncidentStatus.IN_INCIDENT}, + {"check_status": "missed_window"}, + {"check_status": "missed_window"}, + ] + + for scenario in scenarios: + self.store_uptime_data(self.subscription_id, **scenario) + + def store_uptime_data( + self, + subscription_id, + check_status, + incident_status=IncidentStatus.NO_INCIDENT, + scheduled_check_time=None, + ): + """ + Store a single uptime data row. Must be implemented by subclasses. + """ + raise NotImplementedError("Subclasses must implement store_uptime_data") + + def test_simple(self) -> None: + """ + Test that the endpoint returns correct summary stats. + """ + with self.feature(self.features): + response = self.get_success_response( + self.organization.slug, + project=[self.project.id], + projectUptimeSubscriptionId=[str(self.project_uptime_subscription.id)], + since=(datetime.now(timezone.utc) - timedelta(days=7)).timestamp(), + until=datetime.now(timezone.utc).timestamp(), + ) + assert response.data is not None + data = response.data + + # Verify structure + assert self.project_uptime_subscription.id in data + stats = data[self.project_uptime_subscription.id] + + # Verify expected counts based on test scenarios + assert stats["total_checks"] == 8 + assert stats["failed_checks"] == 2 # failures without incident + assert stats["downtime_checks"] == 1 # failures with incident + assert stats["missed_window_checks"] == 2 + + def test_multiple_subscriptions(self) -> None: + """ + Test endpoint with multiple uptime subscriptions. + """ + # Create second subscription + subscription_id2 = uuid.uuid4().hex + subscription2 = self.create_uptime_subscription( + url="https://example.com", subscription_id=subscription_id2 + ) + project_uptime_subscription2 = self.create_project_uptime_subscription( + uptime_subscription=subscription2 + ) + + # Add data for second subscription + scenarios2: list[dict[str, Any]] = [ + {"check_status": "success"}, + {"check_status": "failure", "incident_status": IncidentStatus.IN_INCIDENT}, + ] + for scenario in scenarios2: + self.store_uptime_data(subscription_id2, **scenario) + + with self.feature(self.features): + response = self.get_success_response( + self.organization.slug, + project=[self.project.id], + projectUptimeSubscriptionId=[ + str(self.project_uptime_subscription.id), + str(project_uptime_subscription2.id), + ], + since=(datetime.now(timezone.utc) - timedelta(days=7)).timestamp(), + until=datetime.now(timezone.utc).timestamp(), + ) + assert response.data is not None + data = response.data + + # Verify both subscriptions are present + assert self.project_uptime_subscription.id in data + assert project_uptime_subscription2.id in data + + # Verify first subscription stats + stats1 = data[self.project_uptime_subscription.id] + assert stats1["total_checks"] == 8 + assert stats1["failed_checks"] == 2 + assert stats1["downtime_checks"] == 1 + assert stats1["missed_window_checks"] == 2 + + # Verify second subscription stats + stats2 = data[project_uptime_subscription2.id] + assert stats2["total_checks"] == 2 + assert stats2["failed_checks"] == 0 + assert stats2["downtime_checks"] == 1 + assert stats2["missed_window_checks"] == 0 + + def test_empty_results(self) -> None: + """ + Test endpoint when no data exists for subscription + .""" + # Create subscription with no data + empty_subscription_id = uuid.uuid4().hex + empty_subscription = self.create_uptime_subscription( + url="https://empty.com", subscription_id=empty_subscription_id + ) + empty_project_uptime_subscription = self.create_project_uptime_subscription( + uptime_subscription=empty_subscription + ) + + with self.feature(self.features): + response = self.get_success_response( + self.organization.slug, + project=[self.project.id], + projectUptimeSubscriptionId=[str(empty_project_uptime_subscription.id)], + since=(datetime.now(timezone.utc) - timedelta(days=7)).timestamp(), + until=datetime.now(timezone.utc).timestamp(), + ) + assert response.data is not None + data = response.data + + # Should return empty dict for subscriptions with no data + assert data == {} + + def test_invalid_uptime_subscription_id(self) -> None: + """ + Test that an invalid uptime_subscription_id produces a 400 response. + """ + with self.feature(self.features): + response = self.get_response( + self.organization.slug, + project=[self.project.id], + projectUptimeSubscriptionId=[str(uuid.uuid4())], + since=(datetime.now(timezone.utc) - timedelta(days=7)).timestamp(), + until=datetime.now(timezone.utc).timestamp(), + ) + assert response.status_code == 400 + assert response.json() == "Invalid project uptime subscription ids provided" + + def test_no_uptime_subscription_id(self) -> None: + """ + Test that not sending any uptime_subscription_id produces a 400 response. + """ + with self.feature(self.features): + response = self.get_response( + self.organization.slug, + project=[self.project.id], + projectUptimeSubscriptionId=[], + since=(datetime.now(timezone.utc) - timedelta(days=7)).timestamp(), + until=datetime.now(timezone.utc).timestamp(), + ) + assert response.status_code == 400 + assert response.json() == "No project uptime subscription ids provided" + + def test_too_many_uptime_subscription_ids(self) -> None: + """ + Test that sending too many subscription IDs produces a 400 response. + """ + with self.feature(self.features): + response = self.get_response( + self.organization.slug, + project=[self.project.id], + projectUptimeSubscriptionId=[str(uuid.uuid4()) for _ in range(101)], + since=(datetime.now(timezone.utc) - timedelta(days=7)).timestamp(), + until=datetime.now(timezone.utc).timestamp(), + ) + assert response.status_code == 400 + assert ( + response.json() + == "Too many project uptime subscription ids provided. Maximum is 100" + ) + + def test_cross_project_access_denied(self) -> None: + """ + Test that cross-project access is properly restricted. + """ + # Create subscription in different project + other_project = self.create_project(organization=self.organization) + other_subscription_id = uuid.uuid4().hex + other_subscription = self.create_uptime_subscription( + url="https://other.com", subscription_id=other_subscription_id + ) + other_project_uptime_subscription = self.create_project_uptime_subscription( + uptime_subscription=other_subscription, project=other_project + ) + + with self.feature(self.features): + response = self.get_response( + self.organization.slug, + project=[self.project.id], # Only include original project + projectUptimeSubscriptionId=[str(other_project_uptime_subscription.id)], + since=(datetime.now(timezone.utc) - timedelta(days=7)).timestamp(), + until=datetime.now(timezone.utc).timestamp(), + ) + assert response.status_code == 400 + assert response.json() == "Invalid project uptime subscription ids provided" + + def test_success_only_scenario(self) -> None: + """ + Test scenario with only successful checks. + """ + success_subscription_id = uuid.uuid4().hex + success_subscription = self.create_uptime_subscription( + url="https://success.com", subscription_id=success_subscription_id + ) + success_project_uptime_subscription = self.create_project_uptime_subscription( + uptime_subscription=success_subscription + ) + + # Only success checks + for _ in range(5): + self.store_uptime_data(success_subscription_id, "success") + + with self.feature(self.features): + response = self.get_success_response( + self.organization.slug, + project=[self.project.id], + projectUptimeSubscriptionId=[str(success_project_uptime_subscription.id)], + since=(datetime.now(timezone.utc) - timedelta(days=7)).timestamp(), + until=datetime.now(timezone.utc).timestamp(), + ) + assert response.data is not None + data = response.data + + stats = data[success_project_uptime_subscription.id] + assert stats["total_checks"] == 5 + assert stats["failed_checks"] == 0 + assert stats["downtime_checks"] == 0 + assert stats["missed_window_checks"] == 0 + + def test_time_range_filtering(self) -> None: + """ + Test that the endpoint accepts time range parameters and filters summary stats. + """ + filter_subscription_id = uuid.uuid4().hex + filter_subscription = self.create_uptime_subscription( + url="https://filter-test.com", subscription_id=filter_subscription_id + ) + filter_project_uptime_subscription = self.create_project_uptime_subscription( + uptime_subscription=filter_subscription + ) + + for i in range(10): + check_time = datetime.now(timezone.utc) - timedelta(minutes=i * 5) + self.store_uptime_data( + filter_subscription_id, + "success", + scheduled_check_time=check_time, + ) + + # Test that endpoint processes time range parameters without errors + filter_start = datetime.now(timezone.utc) - timedelta(minutes=20) + filter_end = datetime.now(timezone.utc) + + with self.feature(self.features): + # Query with time range - should work without error + response = self.get_success_response( + self.organization.slug, + project=[self.project.id], + projectUptimeSubscriptionId=[str(filter_project_uptime_subscription.id)], + start=filter_start.isoformat(), + end=filter_end.isoformat(), + ) + assert response.data is not None + data = response.data + + assert filter_project_uptime_subscription.id in data + stats = data[filter_project_uptime_subscription.id] + assert stats["total_checks"] == 4 + assert stats["failed_checks"] == 0 + assert stats["downtime_checks"] == 0 + assert stats["missed_window_checks"] == 0 + + +@freeze_time(MOCK_DATETIME) +class OrganizationUptimeSummarySnubaTest( + OrganizationUptimeSummaryBaseTest, UptimeCheckSnubaTestCase +): + __test__ = True + + def store_uptime_data( + self, + subscription_id, + check_status, + incident_status=IncidentStatus.NO_INCIDENT, + scheduled_check_time=None, + ): + self.store_snuba_uptime_check( + subscription_id=subscription_id, + check_status=check_status, + incident_status=incident_status, + scheduled_check_time=scheduled_check_time, + ) + + +@freeze_time(MOCK_DATETIME) +class OrganizationUptimeSummaryEAPTest(OrganizationUptimeSummaryBaseTest, UptimeResultEAPTestCase): + __test__ = True + + def setUp(self) -> None: + super().setUp() + self.features = { + "organizations:uptime-eap-enabled": True, + "organizations:uptime-eap-uptime-results-query": True, + } + + def store_uptime_data( + self, + subscription_id, + check_status, + incident_status=IncidentStatus.NO_INCIDENT, + scheduled_check_time=None, + ): + uptime_result = self.create_eap_uptime_result( + subscription_id=uuid.UUID(subscription_id).hex, + guid=uuid.UUID(subscription_id).hex, + request_url="https://santry.io", + check_status=check_status, + incident_status=incident_status, + scheduled_check_time=scheduled_check_time, + ) + self.store_uptime_results([uptime_result]) diff --git a/tests/sentry/uptime/endpoints/test_utils.py b/tests/sentry/uptime/endpoints/test_utils.py new file mode 100644 index 00000000000000..fda699f929aa22 --- /dev/null +++ b/tests/sentry/uptime/endpoints/test_utils.py @@ -0,0 +1,107 @@ +import uuid + +from pytest import raises + +from sentry.testutils.cases import TestCase +from sentry.uptime.endpoints.utils import authorize_and_map_project_uptime_subscription_ids + + +class AuthorizeAndMapProjectUptimeSubscriptionIdsTest(TestCase): + def test_successful_authorization_and_mapping(self): + """Test successful authorization and mapping of subscription IDs.""" + subscription_id = uuid.uuid4().hex + subscription = self.create_uptime_subscription( + url="https://example.com", subscription_id=subscription_id + ) + project_uptime_subscription = self.create_project_uptime_subscription( + uptime_subscription=subscription, project=self.project + ) + + # Test with hex formatter (EAP style) + hex_formatter = lambda sub_id: uuid.UUID(sub_id).hex + + mapping, subscription_ids = authorize_and_map_project_uptime_subscription_ids( + project_uptime_subscription_ids=[str(project_uptime_subscription.id)], + projects=[self.project], + sub_id_formatter=hex_formatter, + ) + + # Verify mapping + expected_hex_id = uuid.UUID(subscription_id).hex + assert expected_hex_id in mapping + assert mapping[expected_hex_id] == project_uptime_subscription.id + + # Verify subscription IDs list + assert subscription_ids == [expected_hex_id] + + def test_invalid_subscription_id_raises_error(self): + """Test that invalid subscription IDs raise ValueError.""" + invalid_id = "999999" + + with raises(ValueError): + authorize_and_map_project_uptime_subscription_ids( + project_uptime_subscription_ids=[invalid_id], + projects=[self.project], + sub_id_formatter=str, + ) + + def test_cross_project_access_denied(self): + """Test that cross-project subscription access is denied.""" + other_project = self.create_project(organization=self.organization) + subscription_id = uuid.uuid4().hex + subscription = self.create_uptime_subscription( + url="https://example.com", subscription_id=subscription_id + ) + other_project_uptime_subscription = self.create_project_uptime_subscription( + uptime_subscription=subscription, project=other_project + ) + + # Try to authorize with original project, should fail + with raises(ValueError): + authorize_and_map_project_uptime_subscription_ids( + project_uptime_subscription_ids=[str(other_project_uptime_subscription.id)], + projects=[self.project], # Wrong project + sub_id_formatter=str, + ) + + def test_multiple_subscriptions(self): + """Test authorization with multiple subscription IDs.""" + subscription_id1 = uuid.uuid4().hex + subscription_id2 = uuid.uuid4().hex + + subscription1 = self.create_uptime_subscription( + url="https://example1.com", subscription_id=subscription_id1 + ) + subscription2 = self.create_uptime_subscription( + url="https://example2.com", subscription_id=subscription_id2 + ) + + project_uptime_subscription1 = self.create_project_uptime_subscription( + uptime_subscription=subscription1, project=self.project + ) + project_uptime_subscription2 = self.create_project_uptime_subscription( + uptime_subscription=subscription2, project=self.project + ) + + string_formatter = lambda sub_id: str(uuid.UUID(sub_id)) + + mapping, subscription_ids = authorize_and_map_project_uptime_subscription_ids( + project_uptime_subscription_ids=[ + str(project_uptime_subscription1.id), + str(project_uptime_subscription2.id), + ], + projects=[self.project], + sub_id_formatter=string_formatter, + ) + + # Verify both subscriptions are mapped + assert len(mapping) == 2 + assert len(subscription_ids) == 2 + + expected_str_id1 = str(uuid.UUID(subscription_id1)) + expected_str_id2 = str(uuid.UUID(subscription_id2)) + + assert expected_str_id1 in mapping + assert expected_str_id2 in mapping + assert mapping[expected_str_id1] == project_uptime_subscription1.id + assert mapping[expected_str_id2] == project_uptime_subscription2.id