Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/sentry/preprod/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
),
)
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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(
Expand Down
Loading