diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 8ea952d7e4e3a1..48ce24f6e25351 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -2650,6 +2650,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: OrganizationObjectstoreEndpoint.as_view(), name="sentry-api-0-organization-objectstore", ), + re_path( + r"^(?P[^/]+)/preprod/app-size-stats/$", + preprod_urls.OrganizationPreprodAppSizeStatsEndpoint.as_view(), + name="sentry-api-0-organization-preprod-app-size-stats", + ), ] PROJECT_URLS: list[URLPattern | URLResolver] = [ diff --git a/src/sentry/models/dashboard_widget.py b/src/sentry/models/dashboard_widget.py index 83f2d7cc522198..ca4bf8845c34d0 100644 --- a/src/sentry/models/dashboard_widget.py +++ b/src/sentry/models/dashboard_widget.py @@ -72,6 +72,10 @@ class DashboardWidgetTypes(TypesClass): These represent the tracemetrics item type on the EAP dataset. """ TRACEMETRICS = 104 + """ + Mobile app size metrics from preprod item type on the EAP dataset. + """ + MOBILE_APP_SIZE = 105 TYPES = [ (DISCOVER, "discover"), @@ -85,6 +89,7 @@ class DashboardWidgetTypes(TypesClass): (SPANS, "spans"), (LOGS, "logs"), (TRACEMETRICS, "tracemetrics"), + (MOBILE_APP_SIZE, "mobile-app-size"), ] TYPE_NAMES = [t[1] for t in TYPES] diff --git a/src/sentry/preprod/api/endpoints/organization_preprod_app_size_stats.py b/src/sentry/preprod/api/endpoints/organization_preprod_app_size_stats.py new file mode 100644 index 00000000000000..20a47a81394dd9 --- /dev/null +++ b/src/sentry/preprod/api/endpoints/organization_preprod_app_size_stats.py @@ -0,0 +1,379 @@ +from __future__ import annotations + +import logging +from collections import defaultdict +from datetime import datetime, timedelta +from typing import Any + +from django.http import QueryDict +from rest_framework.exceptions import ParseError +from rest_framework.request import Request +from rest_framework.response import Response +from sentry_protos.snuba.v1.endpoint_trace_item_table_pb2 import TraceItemTableResponse +from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeKey, AttributeValue +from sentry_protos.snuba.v1.trace_item_filter_pb2 import ( + AndFilter, + ComparisonFilter, + TraceItemFilter, +) + +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 +from sentry.api.utils import get_date_range_from_params +from sentry.exceptions import InvalidParams +from sentry.models.organization import Organization +from sentry.preprod.eap.read import query_preprod_size_metrics +from sentry.utils.dates import get_rollup_from_request + +logger = logging.getLogger(__name__) + + +@region_silo_endpoint +class OrganizationPreprodAppSizeStatsEndpoint(OrganizationEndpoint): + owner = ApiOwner.EMERGE_TOOLS + publish_status = { + "GET": ApiPublishStatus.PRIVATE, + } + + def get(self, request: Request, organization: Organization) -> Response: + """ + Retrieve app size metrics over time based on the provided filters. + + Query Parameters (all optional): + - project: Project ID(s) (can be repeated or comma-separated). Default: all accessible projects + - start: Start timestamp (ISO format or Unix timestamp). Default: 14 days ago + - end: End timestamp (ISO format or Unix timestamp). Default: now + - statsPeriod: Alternative to start/end (e.g., "14d", "24h"). Default: "14d" + - interval: Time interval for buckets (e.g., "1h", "1d"). Default: "1d" + - field: Aggregate field (e.g., "max(max_install_size)"). Default: "max(max_install_size)" + + Filters (all optional): + - app_id: Filter by app ID (e.g., "com.example.app") + - artifact_type: Filter by artifact type (e.g., "0" or "1") + - git_head_ref: Filter by git branch (e.g., "main") + - build_configuration_name: Filter by build configuration (e.g., "Release") + + Other: + - includeFilters: If "true", includes available filter values in response. Default: false + + Response Format: + { + "data": [[timestamp, [{"count": value}]], ...], + "start": unix_timestamp, + "end": unix_timestamp, + "meta": {...}, + "filters": { // Only if includeFilters=true + "app_ids": ["com.example.app", ...], + "branches": ["main", "develop", ...] + } + } + """ + projects = self.get_projects(request=request, organization=organization) + project_ids = [p.id for p in projects] + + try: + start, end = get_date_range_from_params( + request.GET, default_stats_period=timedelta(days=14) + ) + + interval_seconds = get_rollup_from_request( + request, + date_range=end - start, + default_interval="1d", + error=ParseError("Invalid interval"), + ) + + field = request.GET.get("field", "max(max_install_size)") + aggregate_func, aggregate_field = self._parse_field(field) + + filter_kwargs = self._parse_filters(request.GET) + query_filter = self._build_filter(filter_kwargs) if filter_kwargs else None + except InvalidParams: + logger.exception("Invalid parameters for app size stats request") + raise ParseError("Invalid query parameters") + except ValueError: + logger.exception("Error while parsing app size stats request") + raise ParseError("Invalid request parameters") + + response = query_preprod_size_metrics( + organization_id=organization.id, + project_ids=project_ids, + start=start, + end=end, + referrer="api.preprod.app-size-stats", + filter=query_filter, + limit=10000, + ) + + timeseries_data = self._transform_to_timeseries( + response, start, end, interval_seconds, aggregate_func, aggregate_field + ) + + result: dict[str, Any] = { + "data": timeseries_data, + "start": int(start.timestamp()), + "end": int(end.timestamp()), + "meta": { + "fields": { + field: "integer", + } + }, + } + + include_filters = request.GET.get("includeFilters", "").lower() == "true" + if include_filters: + filter_values = self._fetch_filter_values(organization.id, project_ids, start, end) + result["filters"] = filter_values + + return Response(result) + + def _parse_field(self, field: str) -> tuple[str, str]: + """Parse field like 'max(max_install_size)' into ('max', 'max_install_size').""" + if "(" not in field or ")" not in field: + raise ParseError(f"Invalid field format: {field}") + + func_name = field[: field.index("(")] + field_name = field[field.index("(") + 1 : field.index(")")] + + valid_funcs = ["max", "min", "avg", "count"] + if func_name not in valid_funcs: + raise ParseError(f"Unsupported aggregate function: {func_name}") + + valid_fields = [ + "max_install_size", + "max_download_size", + "min_install_size", + "min_download_size", + ] + + if not field_name: + raise ParseError(f"Field name is required for {func_name}() function") + + if field_name not in valid_fields: + raise ParseError(f"Invalid field: {field_name}") + + return func_name, field_name + + def _parse_filters(self, query_params: QueryDict) -> dict[str, Any]: + """Parse filter query parameters.""" + filters: dict[str, str | int] = {} + + if app_id := query_params.get("app_id"): + filters["app_id"] = app_id + + if artifact_type := query_params.get("artifact_type"): + filters["artifact_type"] = int(artifact_type) + + if git_head_ref := query_params.get("git_head_ref"): + filters["git_head_ref"] = git_head_ref + + if build_configuration_name := query_params.get("build_configuration_name"): + filters["build_configuration_name"] = build_configuration_name + + return filters + + def _build_filter(self, filter_kwargs: dict[str, Any]) -> TraceItemFilter: + """Build TraceItemFilter from parsed query parameters.""" + filters = [] + + for key, value in filter_kwargs.items(): + if isinstance(value, str): + filters.append( + TraceItemFilter( + comparison_filter=ComparisonFilter( + key=AttributeKey(name=key, type=AttributeKey.Type.TYPE_STRING), + op=ComparisonFilter.OP_EQUALS, + value=AttributeValue(val_str=value), + ) + ) + ) + elif isinstance(value, int): + filters.append( + TraceItemFilter( + comparison_filter=ComparisonFilter( + key=AttributeKey(name=key, type=AttributeKey.Type.TYPE_INT), + op=ComparisonFilter.OP_EQUALS, + value=AttributeValue(val_int=value), + ) + ) + ) + + if len(filters) == 1: + return filters[0] + return TraceItemFilter(and_filter=AndFilter(filters=filters)) + + def _transform_to_timeseries( + self, + response: TraceItemTableResponse, + start: datetime, + end: datetime, + interval_seconds: int, + aggregate_func: str, + aggregate_field: str, + ) -> list[tuple[int, list[dict[str, Any]]]]: + """ + Transform EAP protobuf response into dashboard time-series format: [[timestamp, [{"count": value}]], ...] + """ + column_values = response.column_values + if not column_values: + return self._generate_empty_buckets(start, end, interval_seconds) + + column_map: dict[str, int] = {} + for idx, column_value in enumerate(column_values): + column_map[column_value.attribute_name] = idx + + if aggregate_field not in column_map: + raise ValueError(f"Required field '{aggregate_field}' not found in EAP response") + + timestamp_idx = column_map.get("timestamp") + if timestamp_idx is None: + raise ValueError("Required 'timestamp' column not found in EAP response") + + field_idx = column_map[aggregate_field] + num_rows = len(column_values[0].results) + + for column in column_values: + if len(column.results) != num_rows: + raise ValueError( + f"EAP response has inconsistent column lengths: expected {num_rows}, " + f"got {len(column.results)} for column '{column.attribute_name}'" + ) + + buckets: dict[int, list[float]] = defaultdict(list) + + for row_idx in range(num_rows): + timestamp_result = column_values[timestamp_idx].results[row_idx] + timestamp = self._extract_numeric_value(timestamp_result) + if timestamp is None: + continue + + bucket_ts = int(timestamp // interval_seconds * interval_seconds) + + value_result = column_values[field_idx].results[row_idx] + value = self._extract_numeric_value(value_result) + + if value is not None: + buckets[bucket_ts].append(value) + + return self._aggregate_buckets(buckets, start, end, interval_seconds, aggregate_func) + + def _extract_numeric_value(self, result: AttributeValue) -> float | None: + """Extract numeric value from protobuf result, supporting int/float/double.""" + if result.is_null: + return None + + if result.HasField("val_int"): + return float(result.val_int) + elif result.HasField("val_double"): + return result.val_double + elif result.HasField("val_float"): + return result.val_float + return None + + def _generate_empty_buckets( + self, start: datetime, end: datetime, interval_seconds: int + ) -> list[tuple[int, list[dict[str, Any]]]]: + """Generate time buckets with None values when no data is available.""" + result: list[tuple[int, list[dict[str, Any]]]] = [] + start_ts = int(start.timestamp()) + current = int(start_ts // interval_seconds * interval_seconds) + end_ts = int(end.timestamp()) + + while current < end_ts: + result.append((current, [{"count": None}])) + current += interval_seconds + + return result + + def _aggregate_buckets( + self, + buckets: dict[int, list[float]], + start: datetime, + end: datetime, + interval_seconds: int, + aggregate_func: str, + ) -> list[tuple[int, list[dict[str, Any]]]]: + """Aggregate bucketed values and generate final time series.""" + result: list[tuple[int, list[dict[str, Any]]]] = [] + start_ts = int(start.timestamp()) + current = int(start_ts // interval_seconds * interval_seconds) + end_ts = int(end.timestamp()) + + while current < end_ts: + values = buckets.get(current, []) + + if not values: + aggregated = None + elif aggregate_func == "max": + aggregated = max(values) + elif aggregate_func == "min": + aggregated = min(values) + elif aggregate_func == "avg": + aggregated = sum(values) / len(values) + elif aggregate_func == "count": + aggregated = len(values) + else: + aggregated = None + + result.append((current, [{"count": aggregated}])) + current += interval_seconds + + return result + + def _fetch_filter_values( + self, + organization_id: int, + project_ids: list[int], + start: datetime, + end: datetime, + ) -> dict[str, list[str]]: + response = query_preprod_size_metrics( + organization_id=organization_id, + project_ids=project_ids, + start=start, + end=end, + referrer="api.preprod.app-size-filters", + filter=None, + columns=["app_id", "git_head_ref", "build_configuration_name"], + limit=10000, + ) + + unique_values = self._extract_unique_values_from_response(response) + + branches = list(unique_values.get("git_head_ref", set())) + branches.sort(key=self._branch_sort_key) + + return { + "app_ids": sorted(list(unique_values.get("app_id", set()))), + "branches": branches, + "build_configs": sorted(list(unique_values.get("build_configuration_name", set()))), + } + + def _extract_unique_values_from_response( + self, response: TraceItemTableResponse + ) -> dict[str, set[str]]: + unique_values: dict[str, set[str]] = {} + + if not response.column_values: + return unique_values + + for column in response.column_values: + attr_name = column.attribute_name + unique_values[attr_name] = set() + + for result in column.results: + if not result.is_null and result.HasField("val_str") and result.val_str: + unique_values[attr_name].add(result.val_str) + + return unique_values + + def _branch_sort_key(self, branch: str) -> tuple[int, str]: + """Sort key that prioritizes main and master branches.""" + if branch == "main": + return (0, branch) + elif branch == "master": + return (1, branch) + else: + return (2, branch.lower()) diff --git a/src/sentry/preprod/api/endpoints/urls.py b/src/sentry/preprod/api/endpoints/urls.py index 4a3c6aef5d8edc..7c795355e02dee 100644 --- a/src/sentry/preprod/api/endpoints/urls.py +++ b/src/sentry/preprod/api/endpoints/urls.py @@ -15,6 +15,7 @@ ProjectPreprodArtifactSizeAnalysisDownloadEndpoint, ) +from .organization_preprod_app_size_stats import OrganizationPreprodAppSizeStatsEndpoint from .organization_preprod_artifact_assemble import ProjectPreprodArtifactAssembleEndpoint from .preprod_artifact_admin_batch_delete import PreprodArtifactAdminBatchDeleteEndpoint from .preprod_artifact_admin_info import PreprodArtifactAdminInfoEndpoint @@ -43,6 +44,12 @@ OrganizationPullRequestSizeAnalysisDownloadEndpoint, ) +__all__ = [ + "OrganizationPreprodAppSizeStatsEndpoint", + "preprod_urlpatterns", + "preprod_internal_urlpatterns", +] + preprod_urlpatterns = [ re_path( r"^(?P[^/]+)/(?P[^/]+)/files/preprodartifacts/assemble/$", diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index abe2e44a5c9fd4..8bd3f2be1f19c4 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -468,6 +468,7 @@ export type KnownSentryApiUrls = | '/organizations/$organizationIdOrSlug/plugins/' | '/organizations/$organizationIdOrSlug/plugins/$pluginSlug/deprecation-info/' | '/organizations/$organizationIdOrSlug/plugins/configs/' + | '/organizations/$organizationIdOrSlug/preprod/app-size-stats/' | '/organizations/$organizationIdOrSlug/prevent/ai/github/config/$gitOrganizationName/' | '/organizations/$organizationIdOrSlug/prevent/ai/github/repos/' | '/organizations/$organizationIdOrSlug/prevent/owner/$owner/repositories/' diff --git a/tests/sentry/preprod/api/endpoints/test_organization_preprod_app_size_stats.py b/tests/sentry/preprod/api/endpoints/test_organization_preprod_app_size_stats.py new file mode 100644 index 00000000000000..5a9c9a2e5184e9 --- /dev/null +++ b/tests/sentry/preprod/api/endpoints/test_organization_preprod_app_size_stats.py @@ -0,0 +1,432 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta + +import requests +from django.conf import settings +from django.urls import reverse +from google.protobuf.timestamp_pb2 import Timestamp +from rest_framework.test import APIClient +from sentry_protos.snuba.v1.request_common_pb2 import TraceItemType +from sentry_protos.snuba.v1.trace_item_pb2 import TraceItem + +from sentry.preprod.eap.constants import PREPROD_NAMESPACE +from sentry.search.eap.rpc_utils import anyvalue +from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.datetime import before_now +from sentry.utils.eap import EAP_ITEMS_INSERT_ENDPOINT + + +class OrganizationPreprodAppSizeStatsEndpointTest(APITestCase): + endpoint = "sentry-api-0-organization-preprod-app-size-stats" + + def setUp(self) -> None: + super().setUp() + self.login_as(user=self.user) + self.url = reverse( + self.endpoint, kwargs={"organization_id_or_slug": self.organization.slug} + ) + + def store_preprod_size_metric( + self, + project_id: int, + organization_id: int, + timestamp: datetime, + preprod_artifact_id: int = 1, + size_metric_id: int = 1, + app_id: str = "com.example.app", + artifact_type: int = 0, + max_install_size: int = 100000, + max_download_size: int = 80000, + min_install_size: int = 95000, + min_download_size: int = 75000, + git_head_ref: str | None = None, + build_configuration_name: str | None = None, + ) -> None: + """Write a preprod size metric to EAP for testing.""" + proto_timestamp = Timestamp() + proto_timestamp.FromDatetime(timestamp) + + trace_id = uuid.uuid5(PREPROD_NAMESPACE, str(preprod_artifact_id)).hex + item_id_str = f"size_metric_{size_metric_id}" + item_id = int(uuid.uuid5(PREPROD_NAMESPACE, item_id_str).hex, 16).to_bytes(16, "little") + + attributes = { + "preprod_artifact_id": anyvalue(preprod_artifact_id), + "size_metric_id": anyvalue(size_metric_id), + "sub_item_type": anyvalue("size_metric"), + "metrics_artifact_type": anyvalue(0), + "identifier": anyvalue(""), + "min_install_size": anyvalue(min_install_size), + "max_install_size": anyvalue(max_install_size), + "min_download_size": anyvalue(min_download_size), + "max_download_size": anyvalue(max_download_size), + "artifact_type": anyvalue(artifact_type), + "app_id": anyvalue(app_id), + } + + if git_head_ref: + attributes["git_head_ref"] = anyvalue(git_head_ref) + if build_configuration_name: + attributes["build_configuration_name"] = anyvalue(build_configuration_name) + + trace_item = TraceItem( + organization_id=organization_id, + project_id=project_id, + item_type=TraceItemType.TRACE_ITEM_TYPE_PREPROD, + timestamp=proto_timestamp, + trace_id=trace_id, + item_id=item_id, + received=proto_timestamp, + retention_days=90, + attributes=attributes, + ) + + response = requests.post( + settings.SENTRY_SNUBA + EAP_ITEMS_INSERT_ENDPOINT, + files={"item_0": trace_item.SerializeToString()}, + ) + assert response.status_code == 200 + + def test_get_with_include_filters(self) -> None: + """Test that response includes available filter values.""" + project = self.create_project(organization=self.organization) + now = before_now(minutes=5) + start = now - timedelta(minutes=10) + + self.store_preprod_size_metric( + project_id=project.id, + organization_id=self.organization.id, + timestamp=now, + app_id="com.test.app", + git_head_ref="main", + build_configuration_name="Release", + ) + + response = self.get_success_response( + self.organization.slug, + qs_params={ + "project": [project.id], + "includeFilters": "true", + "start": str(int(start.timestamp())), + "end": str(int((now + timedelta(minutes=1)).timestamp())), + }, + ) + + assert response.status_code == 200 + assert "filters" in response.data + assert "app_ids" in response.data["filters"] + assert "branches" in response.data["filters"] + assert "build_configs" in response.data["filters"] + assert isinstance(response.data["filters"]["app_ids"], list) + assert isinstance(response.data["filters"]["branches"], list) + assert isinstance(response.data["filters"]["build_configs"], list) + assert "com.test.app" in response.data["filters"]["app_ids"] + assert "main" in response.data["filters"]["branches"] + assert "Release" in response.data["filters"]["build_configs"] + + def test_get_invalid_field(self) -> None: + """Test validation of field format.""" + response = self.get_error_response( + self.organization.slug, + qs_params={"field": "invalid_field"}, + ) + assert "Invalid field format" in str(response.data) + + def test_get_requires_authentication(self) -> None: + """Test that endpoint requires authentication.""" + client = APIClient() + response = client.get(self.url) + assert response.status_code == 401 + + def test_cannot_access_other_organization_projects(self) -> None: + """Test that users cannot access projects from other organizations (IDOR protection).""" + other_org = self.create_organization(name="Other Org") + other_project = self.create_project(organization=other_org, name="Other Project") + + response = self.get_error_response( + self.organization.slug, + qs_params={"project": [other_project.id]}, + ) + + assert response.status_code == 403 + + def test_get_with_project_filter(self) -> None: + """Test filtering by project ID actually filters the data.""" + now = before_now(minutes=5) + start = now - timedelta(hours=1) + + project1 = self.create_project(organization=self.organization, name="Project 1") + project2 = self.create_project(organization=self.organization, name="Project 2") + + self.store_preprod_size_metric( + project_id=project1.id, + organization_id=self.organization.id, + timestamp=now - timedelta(minutes=30), + preprod_artifact_id=1, + size_metric_id=1, + app_id="com.project1.app", + max_install_size=100000, + ) + + self.store_preprod_size_metric( + project_id=project2.id, + organization_id=self.organization.id, + timestamp=now - timedelta(minutes=30), + preprod_artifact_id=2, + size_metric_id=2, + app_id="com.project2.app", + max_install_size=200000, + ) + + response = self.get_success_response( + self.organization.slug, + qs_params={ + "project": [project1.id], + "start": str(int(start.timestamp())), + "end": str(int(now.timestamp())), + "includeFilters": "true", + }, + ) + + assert response.status_code == 200 + app_ids = response.data["filters"]["app_ids"] + assert "com.project1.app" in app_ids + assert "com.project2.app" not in app_ids + + def test_aggregate_functions(self) -> None: + """Test that all supported aggregate functions work.""" + for func in ["max", "min", "avg", "count"]: + with self.subTest(func=func): + response = self.get_success_response( + self.organization.slug, + qs_params={"field": f"{func}(max_install_size)"}, + ) + assert response.status_code == 200 + assert f"{func}(max_install_size)" in response.data["meta"]["fields"] + + def test_get_with_data(self) -> None: + """Test querying actual data written to EAP.""" + project = self.create_project(organization=self.organization) + now = before_now(minutes=5) + start = now - timedelta(hours=2) + + self.store_preprod_size_metric( + project_id=project.id, + organization_id=self.organization.id, + timestamp=now - timedelta(hours=1), + preprod_artifact_id=1, + size_metric_id=1, + app_id="com.example.app", + max_install_size=100000, + min_install_size=95000, + ) + + self.store_preprod_size_metric( + project_id=project.id, + organization_id=self.organization.id, + timestamp=now - timedelta(minutes=30), + preprod_artifact_id=2, + size_metric_id=2, + app_id="com.example.app", + max_install_size=105000, + min_install_size=98000, + ) + + response = self.get_success_response( + self.organization.slug, + qs_params={ + "project": [project.id], + "start": str(int(start.timestamp())), + "end": str(int(now.timestamp())), + "interval": "1h", + "field": "max(max_install_size)", + }, + ) + + assert response.status_code == 200 + assert "data" in response.data + data_points = response.data["data"] + assert len(data_points) > 0 + + non_null_values = [d[1][0]["count"] for d in data_points if d[1][0]["count"] is not None] + assert len(non_null_values) > 0 + assert max(non_null_values) == 105000 + + def test_aggregation_data(self) -> None: + """Test different aggregation functions with real data.""" + project = self.create_project(organization=self.organization) + now = before_now(minutes=5) + start = now - timedelta(hours=1) + + for i, size in enumerate([100000, 150000, 125000]): + self.store_preprod_size_metric( + project_id=project.id, + organization_id=self.organization.id, + timestamp=now - timedelta(minutes=30), + preprod_artifact_id=i + 1, + size_metric_id=i + 1, + max_install_size=size, + ) + + response = self.get_success_response( + self.organization.slug, + qs_params={ + "project": [project.id], + "start": str(int(start.timestamp())), + "end": str(int(now.timestamp())), + "interval": "1h", + "field": "max(max_install_size)", + }, + ) + data_points = [ + d[1][0]["count"] for d in response.data["data"] if d[1][0]["count"] is not None + ] + assert max(data_points) == 150000 + + response = self.get_success_response( + self.organization.slug, + qs_params={ + "project": [project.id], + "start": str(int(start.timestamp())), + "end": str(int(now.timestamp())), + "interval": "1h", + "field": "min(max_install_size)", + }, + ) + data_points = [ + d[1][0]["count"] for d in response.data["data"] if d[1][0]["count"] is not None + ] + assert min(data_points) == 100000 + + def test_filter_by_app_id(self) -> None: + """Test filtering by app_id.""" + project = self.create_project(organization=self.organization) + now = before_now(minutes=5) + start = now - timedelta(hours=1) + + self.store_preprod_size_metric( + project_id=project.id, + organization_id=self.organization.id, + timestamp=now - timedelta(minutes=30), + preprod_artifact_id=1, + size_metric_id=1, + app_id="com.example.app1", + max_install_size=100000, + ) + + self.store_preprod_size_metric( + project_id=project.id, + organization_id=self.organization.id, + timestamp=now - timedelta(minutes=30), + preprod_artifact_id=2, + size_metric_id=2, + app_id="com.example.app2", + max_install_size=200000, + ) + + response = self.get_success_response( + self.organization.slug, + qs_params={ + "project": [project.id], + "start": str(int(start.timestamp())), + "end": str(int(now.timestamp())), + "app_id": "com.example.app1", + "field": "max(max_install_size)", + }, + ) + + data_points = [ + d[1][0]["count"] for d in response.data["data"] if d[1][0]["count"] is not None + ] + # Should only get app1's data + assert len(data_points) > 0 + assert max(data_points) == 100000 + + def test_multiple_filters(self) -> None: + """Test combining multiple filter parameters.""" + project = self.create_project(organization=self.organization) + now = before_now(minutes=5) + start = now - timedelta(hours=1) + + self.store_preprod_size_metric( + project_id=project.id, + organization_id=self.organization.id, + timestamp=now - timedelta(minutes=30), + preprod_artifact_id=1, + size_metric_id=1, + app_id="com.example.app", + git_head_ref="main", + build_configuration_name="Release", + artifact_type=0, + max_install_size=100000, + ) + + self.store_preprod_size_metric( + project_id=project.id, + organization_id=self.organization.id, + timestamp=now - timedelta(minutes=30), + preprod_artifact_id=2, + size_metric_id=2, + app_id="com.example.app", + git_head_ref="develop", + build_configuration_name="Debug", + artifact_type=1, + max_install_size=200000, + ) + + response = self.get_success_response( + self.organization.slug, + qs_params={ + "project": [project.id], + "start": str(int(start.timestamp())), + "end": str(int(now.timestamp())), + "app_id": "com.example.app", + "git_head_ref": "main", + "build_configuration_name": "Release", + "artifact_type": "0", + "field": "max(max_install_size)", + }, + ) + + data_points = [ + d[1][0]["count"] for d in response.data["data"] if d[1][0]["count"] is not None + ] + assert len(data_points) > 0 + assert max(data_points) == 100000 + + def test_branch_sorting_priority(self) -> None: + """Test that main and master branches are prioritized in the list.""" + project = self.create_project(organization=self.organization) + now = before_now(minutes=5) + start = now - timedelta(minutes=10) + + for idx, branch in enumerate(["feature/test", "main", "develop", "master", "release/1.0"]): + self.store_preprod_size_metric( + project_id=project.id, + organization_id=self.organization.id, + timestamp=now, + preprod_artifact_id=100 + idx, + size_metric_id=100 + idx, + app_id="com.test.branches", + git_head_ref=branch, + ) + + response = self.get_success_response( + self.organization.slug, + qs_params={ + "project": [project.id], + "includeFilters": "true", + "start": str(int(start.timestamp())), + "end": str(int((now + timedelta(minutes=1)).timestamp())), + }, + ) + + branches = response.data["filters"]["branches"] + + # Verify main is first, master is second + assert branches[0] == "main" + assert branches[1] == "master" + remaining = branches[2:] + assert remaining == sorted(remaining, key=str.lower)