diff --git a/src/sentry/preprod/analytics.py b/src/sentry/preprod/analytics.py index 8cd89d0e78df4a..6de1ee1db13897 100644 --- a/src/sentry/preprod/analytics.py +++ b/src/sentry/preprod/analytics.py @@ -38,6 +38,27 @@ class PreprodArtifactApiGetSnapshotDetailsEvent(analytics.Event): project_id: int user_id: int | None = None artifact_id: str + client: str + + +@analytics.eventclass("preprod_artifact.api.get_snapshot_image") +class PreprodArtifactApiGetSnapshotImageEvent(analytics.Event): + organization_id: int + project_id: int + user_id: int | None = None + artifact_id: str + image_identifier: str + client: str + + +@analytics.eventclass("preprod_artifact.api.get_latest_base_snapshot") +class PreprodArtifactApiGetLatestBaseSnapshotEvent(analytics.Event): + organization_id: int + project_id: int + user_id: int | None = None + artifact_id: str + app_id: str | None = None + client: str @analytics.eventclass("preprod_artifact.api.install_details") @@ -172,6 +193,8 @@ class PreprodStatusCheckApprovalCreatedEvent(analytics.Event): analytics.register(PreprodArtifactApiAssembleGenericEvent) analytics.register(PreprodArtifactApiGetBuildDetailsEvent) analytics.register(PreprodArtifactApiGetSnapshotDetailsEvent) +analytics.register(PreprodArtifactApiGetSnapshotImageEvent) +analytics.register(PreprodArtifactApiGetLatestBaseSnapshotEvent) analytics.register(PreprodArtifactApiInstallDetailsEvent) analytics.register(PreprodArtifactApiRerunAnalysisEvent) analytics.register(PreprodArtifactApiRerunStatusChecksEvent) diff --git a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py index dad57a1d12e50c..c309cb99145283 100644 --- a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py +++ b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py @@ -31,6 +31,7 @@ from sentry.apidocs.response_types import DetailResponse from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.staff import is_active_staff +from sentry.issues.action_log import resolve_action_source from sentry.models.commitcomparison import CommitComparison from sentry.models.organization import Organization from sentry.models.project import Project @@ -473,6 +474,7 @@ def get( request.user.id if request.user and request.user.is_authenticated else None ), artifact_id=str(artifact.id), + client=resolve_action_source(request), ) ) diff --git a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_image_detail.py b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_image_detail.py index 00d19da6b44c50..5b21629ffe37da 100644 --- a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_image_detail.py +++ b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_image_detail.py @@ -8,6 +8,7 @@ from rest_framework.request import Request from rest_framework.response import Response +from sentry import analytics from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint @@ -21,8 +22,10 @@ from sentry.apidocs.response_types import DetailResponse from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.staff import is_active_staff +from sentry.issues.action_log import resolve_action_source from sentry.models.organization import Organization from sentry.objectstore import get_preprod_session +from sentry.preprod.analytics import PreprodArtifactApiGetSnapshotImageEvent from sentry.preprod.api.models.public.snapshots import SnapshotImageDetailResponseDict from sentry.preprod.api.models.snapshots.project_preprod_snapshot_models import ( SnapshotImageDetailImageInfo, @@ -189,6 +192,19 @@ def get( if not is_active_staff(request) and not request.access.has_project_access(artifact.project): return Response({"detail": "Snapshot not found"}, status=404) + analytics.record( + PreprodArtifactApiGetSnapshotImageEvent( + organization_id=organization.id, + project_id=artifact.project_id, + user_id=( + request.user.id if request.user and request.user.is_authenticated else None + ), + artifact_id=str(artifact.id), + image_identifier=image_identifier, + client=resolve_action_source(request), + ) + ) + try: snapshot_metrics = artifact.preprodsnapshotmetrics except PreprodSnapshotMetrics.DoesNotExist: diff --git a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py index ca0c5c066c06ec..35d18c845f0bbc 100644 --- a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py +++ b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py @@ -9,6 +9,7 @@ from rest_framework.request import Request from rest_framework.response import Response +from sentry import analytics from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint @@ -24,8 +25,10 @@ from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.staff import is_active_staff from sentry.constants import ObjectStatus +from sentry.issues.action_log import resolve_action_source from sentry.models.organization import Organization from sentry.objectstore import get_preprod_session +from sentry.preprod.analytics import PreprodArtifactApiGetLatestBaseSnapshotEvent from sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot import ( _strip_to_compact, build_snapshot_image_response, @@ -177,6 +180,19 @@ def get( if artifact is None: return Response({"detail": "No snapshot found"}, status=404) + analytics.record( + PreprodArtifactApiGetLatestBaseSnapshotEvent( + organization_id=organization.id, + project_id=artifact.project_id, + user_id=( + request.user.id if request.user and request.user.is_authenticated else None + ), + artifact_id=str(artifact.id), + app_id=app_id, + client=resolve_action_source(request), + ) + ) + snapshot_metrics = artifact.preprodsnapshotmetrics manifest_key = (snapshot_metrics.extras or {}).get("manifest_key") if not manifest_key: diff --git a/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_image_detail.py b/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_image_detail.py index d9522679597694..c54f3203f29e22 100644 --- a/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_image_detail.py +++ b/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_image_detail.py @@ -3,9 +3,11 @@ import orjson from django.urls import reverse +from sentry.preprod.analytics import PreprodArtifactApiGetSnapshotImageEvent from sentry.preprod.models import PreprodArtifact from sentry.preprod.snapshots.models import PreprodSnapshotComparison, PreprodSnapshotMetrics from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.analytics import assert_last_analytics_event MOCK_TARGET = "sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_image_detail.get_preprod_session" @@ -153,6 +155,68 @@ def test_solo_snapshot_returns_head_image(self, mock_get_session): == f"/api/0/projects/{self.org.slug}/{self.project.slug}/files/images/abc123/" ) + @patch("sentry.analytics.record") + @patch(MOCK_TARGET) + def test_records_web_client_analytics(self, mock_get_session, mock_record): + images = { + "components/alert.png": { + "content_hash": "abc123", + "display_name": "Alert", + "width": 400, + "height": 200, + }, + } + artifact, _, manifest_key, manifest_json = self._create_artifact_with_manifest(images) + mock_get_session.return_value = self._create_mock_session({manifest_key: manifest_json}) + + response = self.client.get(self._get_url(artifact.id, "components/alert.png")) + + assert response.status_code == 200 + assert_last_analytics_event( + mock_record, + PreprodArtifactApiGetSnapshotImageEvent( + organization_id=self.org.id, + project_id=self.project.id, + user_id=self.user.id, + artifact_id=str(artifact.id), + image_identifier="components/alert.png", + client="web", + ), + ) + + @patch("sentry.analytics.record") + @patch(MOCK_TARGET) + def test_records_mcp_client_analytics(self, mock_get_session, mock_record): + images = { + "components/alert.png": { + "content_hash": "abc123", + "display_name": "Alert", + "width": 400, + "height": 200, + }, + } + artifact, _, manifest_key, manifest_json = self._create_artifact_with_manifest(images) + mock_get_session.return_value = self._create_mock_session({manifest_key: manifest_json}) + + response = self.client.get( + self._get_url(artifact.id, "components/alert.png"), + HTTP_USER_AGENT="sentry-mcp/1.0", + HTTP_X_SENTRY_MCP_CLIENT_FAMILY="cursor", + ) + + assert response.status_code == 200 + assert_last_analytics_event( + mock_record, + PreprodArtifactApiGetSnapshotImageEvent( + organization_id=self.org.id, + project_id=self.project.id, + user_id=self.user.id, + artifact_id=str(artifact.id), + image_identifier="components/alert.png", + client="mcp:cursor", + ), + ) + @patch(MOCK_TARGET) def test_changed_image_returns_full_comparison(self, mock_get_session): head_images = { diff --git a/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_latest_base.py b/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_latest_base.py new file mode 100644 index 00000000000000..53d347cd236bc8 --- /dev/null +++ b/tests/sentry/preprod/api/endpoints/snapshots/test_preprod_artifact_snapshot_latest_base.py @@ -0,0 +1,107 @@ +from unittest.mock import MagicMock, patch + +import orjson +from django.urls import reverse + +from sentry.preprod.analytics import PreprodArtifactApiGetLatestBaseSnapshotEvent +from sentry.preprod.models import PreprodArtifact +from sentry.preprod.snapshots.models import PreprodSnapshotMetrics +from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.analytics import assert_last_analytics_event + +MOCK_TARGET = "sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_latest_base.get_preprod_session" + + +class OrganizationPreprodLatestBaseSnapshotTest(APITestCase): + def setUp(self): + super().setUp() + self.login_as(user=self.user) + self.org = self.create_organization(owner=self.user) + self.project = self.create_project(organization=self.org) + + def _get_url(self): + return reverse( + "sentry-api-0-organization-preprod-snapshots-latest-base", + args=[self.org.slug], + ) + + def _create_base_artifact(self, app_id="com.example.app"): + """Create a base snapshot (no commit_comparison) with a manifest.""" + artifact = PreprodArtifact.objects.create( + project=self.project, + state=PreprodArtifact.ArtifactState.UPLOADED, + app_id=app_id, + ) + manifest_key = f"{self.org.id}/{self.project.id}/{artifact.id}/manifest.json" + PreprodSnapshotMetrics.objects.create( + preprod_artifact=artifact, + image_count=1, + extras={"manifest_key": manifest_key}, + ) + manifest_json = orjson.dumps( + { + "images": { + "screen1.png": { + "content_hash": "hash1", + "display_name": "Screen 1", + "width": 375, + "height": 812, + }, + }, + } + ) + return artifact, manifest_key, manifest_json + + def _create_mock_session(self, manifest_json): + mock_result = MagicMock() + mock_result.payload.read.return_value = manifest_json + mock_session = MagicMock() + mock_session.get.return_value = mock_result + return mock_session + + @patch("sentry.analytics.record") + @patch(MOCK_TARGET) + def test_records_web_client_analytics(self, mock_get_session, mock_record): + artifact, _, manifest_json = self._create_base_artifact() + mock_get_session.return_value = self._create_mock_session(manifest_json) + + response = self.client.get(self._get_url(), {"app_id": "com.example.app"}) + + assert response.status_code == 200 + assert_last_analytics_event( + mock_record, + PreprodArtifactApiGetLatestBaseSnapshotEvent( + organization_id=self.org.id, + project_id=self.project.id, + user_id=self.user.id, + artifact_id=str(artifact.id), + app_id="com.example.app", + client="web", + ), + ) + + @patch("sentry.analytics.record") + @patch(MOCK_TARGET) + def test_records_mcp_client_analytics(self, mock_get_session, mock_record): + artifact, _, manifest_json = self._create_base_artifact() + mock_get_session.return_value = self._create_mock_session(manifest_json) + + response = self.client.get( + self._get_url(), + {"app_id": "com.example.app"}, + HTTP_USER_AGENT="sentry-mcp/1.0", + HTTP_X_SENTRY_MCP_CLIENT_FAMILY="cursor", + ) + + assert response.status_code == 200 + assert_last_analytics_event( + mock_record, + PreprodArtifactApiGetLatestBaseSnapshotEvent( + organization_id=self.org.id, + project_id=self.project.id, + user_id=self.user.id, + artifact_id=str(artifact.id), + app_id="com.example.app", + client="mcp:cursor", + ), + ) diff --git a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py index 97845eacfaf2fa..60f8e15c6b2410 100644 --- a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py +++ b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py @@ -5,12 +5,14 @@ from django.urls import reverse from sentry.models.commitcomparison import CommitComparison +from sentry.preprod.analytics import PreprodArtifactApiGetSnapshotDetailsEvent from sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot_latest_base import ( LATEST_BASE_SNAPSHOT_GET_QUERY_PARAMS, ) from sentry.preprod.models import PreprodArtifact, PreprodComparisonApproval from sentry.preprod.snapshots.models import PreprodSnapshotComparison, PreprodSnapshotMetrics from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.analytics import assert_last_analytics_event class ProjectPreprodSnapshotTest(APITestCase): @@ -629,6 +631,50 @@ def test_get_snapshot_details(self, mock_get_session): assert response.data["images"][0]["image_file_name"] == "img1" assert response.data["images"][1]["key"] == "img2" + @patch("sentry.analytics.record") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_preprod_session") + def test_get_snapshot_details_records_web_client(self, mock_get_session, mock_record): + artifact, _, _, manifest_json, _ = self._create_artifact_with_manifest() + mock_get_session.return_value = self._create_mock_session(manifest_json) + + response = self.client.get(self._get_detail_url(artifact.id)) + + assert response.status_code == 200 + assert_last_analytics_event( + mock_record, + PreprodArtifactApiGetSnapshotDetailsEvent( + organization_id=self.org.id, + project_id=self.project.id, + user_id=self.user.id, + artifact_id=str(artifact.id), + client="web", + ), + ) + + @patch("sentry.analytics.record") + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_preprod_session") + def test_get_snapshot_details_records_mcp_client(self, mock_get_session, mock_record): + artifact, _, _, manifest_json, _ = self._create_artifact_with_manifest() + mock_get_session.return_value = self._create_mock_session(manifest_json) + + response = self.client.get( + self._get_detail_url(artifact.id), + HTTP_USER_AGENT="sentry-mcp/1.0", + HTTP_X_SENTRY_MCP_CLIENT_FAMILY="cursor", + ) + + assert response.status_code == 200 + assert_last_analytics_event( + mock_record, + PreprodArtifactApiGetSnapshotDetailsEvent( + organization_id=self.org.id, + project_id=self.project.id, + user_id=self.user.id, + artifact_id=str(artifact.id), + client="mcp:cursor", + ), + ) + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_preprod_session") def test_get_snapshot_details_with_vcs_info(self, mock_get_session): commit_comparison = CommitComparison.objects.create(