From 4c376db4801983e4ac226e4dd08722ec2c895db6 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 21 May 2026 15:10:13 +0200 Subject: [PATCH 1/2] feat(preprod): Parse cli_version from user agent on snapshot upload (EME-1165) Snapshot uploads go directly from sentry-cli to the sentry backend, bypassing Launchpad. The sentry-cli version was available in the User-Agent header but never extracted. Parse it and store it in the existing cli_version field on PreprodArtifact. --- .../snapshots/preprod_artifact_snapshot.py | 14 ++++ .../test_preprod_artifact_snapshot.py | 73 +++++++++++++++++++ 2 files changed, 87 insertions(+) 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 6aaeeb4f131b85..dac4ff343f8d71 100644 --- a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py +++ b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py @@ -104,6 +104,8 @@ **VCS_ERROR_MESSAGES, } +_SENTRY_CLI_UA_PREFIX = "sentry-cli/" + _COMPACT_FIELDS = {"display_name", "image_file_name", "group", "description"} _COMPACT_IMAGE_LIST_KEYS = ("images", "added", "removed", "unchanged", "skipped") _COMPACT_PAIR_LIST_KEYS = ("changed", "renamed", "errored") @@ -134,6 +136,15 @@ def build_snapshot_image_response( ) +def _parse_cli_version(user_agent: str) -> str | None: + """Extract version from a ``sentry-cli/X.Y.Z`` user-agent string.""" + if not user_agent.startswith(_SENTRY_CLI_UA_PREFIX): + return None + version_part = user_agent[len(_SENTRY_CLI_UA_PREFIX) :] + version = version_part.split(" ", 1)[0] + return version or None + + def validate_preprod_snapshot_post_schema( request_body: bytes, ) -> tuple[dict[str, Any], str | None]: @@ -613,11 +624,14 @@ def post(self, request: Request, project: Project) -> Response: }, ) + cli_version = _parse_cli_version(request.META.get("HTTP_USER_AGENT", "")) + artifact = PreprodArtifact.objects.create( project=project, state=PreprodArtifact.ArtifactState.UPLOADED, app_id=app_id, commit_comparison=commit_comparison, + cli_version=cli_version, ) manifest_key = f"{project.organization_id}/{project.id}/{artifact.id}/manifest.json" 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 6a6f5efdfce66a..a95569387e4a36 100644 --- a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py +++ b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py @@ -61,6 +61,79 @@ def test_successful_snapshot_upload(self) -> None: assert snapshot_metrics.preprod_artifact == artifact assert snapshot_metrics.image_count == 1 + def test_snapshot_upload_stores_cli_version_from_user_agent(self) -> None: + url = self._get_create_url() + data = { + "app_id": "com.example.app", + "images": { + "abc123": { + "content_hash": "abc123", + "display_name": "Screen", + "image_file_name": "screen.png", + "width": 100, + "height": 200, + }, + }, + } + + with self.feature("organizations:preprod-snapshots"): + response = self.client.post( + url, data, format="json", HTTP_USER_AGENT="sentry-cli/2.40.0" + ) + + assert response.status_code == 200 + artifact = PreprodArtifact.objects.get(id=response.data["artifactId"]) + assert artifact.cli_version == "2.40.0" + + def test_snapshot_upload_stores_cli_version_with_pipeline_env(self) -> None: + url = self._get_create_url() + data = { + "app_id": "com.example.app", + "images": { + "abc123": { + "content_hash": "abc123", + "display_name": "Screen", + "image_file_name": "screen.png", + "width": 100, + "height": 200, + }, + }, + } + + with self.feature("organizations:preprod-snapshots"): + response = self.client.post( + url, + data, + format="json", + HTTP_USER_AGENT="sentry-cli/2.40.0 GitHub-Actions", + ) + + assert response.status_code == 200 + artifact = PreprodArtifact.objects.get(id=response.data["artifactId"]) + assert artifact.cli_version == "2.40.0" + + def test_snapshot_upload_no_cli_version_for_unknown_user_agent(self) -> None: + url = self._get_create_url() + data = { + "app_id": "com.example.app", + "images": { + "abc123": { + "content_hash": "abc123", + "display_name": "Screen", + "image_file_name": "screen.png", + "width": 100, + "height": 200, + }, + }, + } + + with self.feature("organizations:preprod-snapshots"): + response = self.client.post(url, data, format="json", HTTP_USER_AGENT="curl/8.0") + + assert response.status_code == 200 + artifact = PreprodArtifact.objects.get(id=response.data["artifactId"]) + assert artifact.cli_version is None + def test_snapshot_upload_creates_commit_comparison(self) -> None: url = self._get_create_url() data = { From d36a49b77d3791f3889d42e94e9a68357435b8f6 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 21 May 2026 15:49:39 +0200 Subject: [PATCH 2/2] feat(preprod): Also parse gradle and fastlane plugin versions from user agent SAGP and Fastlane both invoke sentry-cli with SENTRY_PIPELINE set, which sentry-cli appends to the user agent. Parse all three tool versions from the user agent string. --- .../snapshots/preprod_artifact_snapshot.py | 40 ++++++++++++++----- .../test_preprod_artifact_snapshot.py | 35 +++++++++++++++- 2 files changed, 64 insertions(+), 11 deletions(-) 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 dac4ff343f8d71..8134c9f1bcd447 100644 --- a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py +++ b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from dataclasses import dataclass from typing import Any import jsonschema @@ -105,6 +106,8 @@ } _SENTRY_CLI_UA_PREFIX = "sentry-cli/" +_GRADLE_PLUGIN_UA_PREFIX = "sentry-gradle-plugin/" +_FASTLANE_PLUGIN_UA_PREFIX = "sentry-fastlane-plugin/" _COMPACT_FIELDS = {"display_name", "image_file_name", "group", "description"} _COMPACT_IMAGE_LIST_KEYS = ("images", "added", "removed", "unchanged", "skipped") @@ -136,13 +139,30 @@ def build_snapshot_image_response( ) -def _parse_cli_version(user_agent: str) -> str | None: - """Extract version from a ``sentry-cli/X.Y.Z`` user-agent string.""" - if not user_agent.startswith(_SENTRY_CLI_UA_PREFIX): - return None - version_part = user_agent[len(_SENTRY_CLI_UA_PREFIX) :] - version = version_part.split(" ", 1)[0] - return version or None +@dataclass +class _ToolVersions: + cli_version: str | None = None + gradle_plugin_version: str | None = None + fastlane_plugin_version: str | None = None + + +def _parse_tool_versions(user_agent: str) -> _ToolVersions: + """Extract tool versions from a user-agent string. + + Formats: + ``sentry-cli/X.Y.Z`` + ``sentry-cli/X.Y.Z sentry-gradle-plugin/A.B.C`` + ``sentry-cli/X.Y.Z sentry-fastlane-plugin/A.B.C`` + """ + versions = _ToolVersions() + for part in user_agent.split(): + if part.startswith(_SENTRY_CLI_UA_PREFIX): + versions.cli_version = part[len(_SENTRY_CLI_UA_PREFIX) :] or None + elif part.startswith(_GRADLE_PLUGIN_UA_PREFIX): + versions.gradle_plugin_version = part[len(_GRADLE_PLUGIN_UA_PREFIX) :] or None + elif part.startswith(_FASTLANE_PLUGIN_UA_PREFIX): + versions.fastlane_plugin_version = part[len(_FASTLANE_PLUGIN_UA_PREFIX) :] or None + return versions def validate_preprod_snapshot_post_schema( @@ -624,14 +644,16 @@ def post(self, request: Request, project: Project) -> Response: }, ) - cli_version = _parse_cli_version(request.META.get("HTTP_USER_AGENT", "")) + tool_versions = _parse_tool_versions(request.META.get("HTTP_USER_AGENT", "")) artifact = PreprodArtifact.objects.create( project=project, state=PreprodArtifact.ArtifactState.UPLOADED, app_id=app_id, commit_comparison=commit_comparison, - cli_version=cli_version, + cli_version=tool_versions.cli_version, + gradle_plugin_version=tool_versions.gradle_plugin_version, + fastlane_plugin_version=tool_versions.fastlane_plugin_version, ) manifest_key = f"{project.organization_id}/{project.id}/{artifact.id}/manifest.json" 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 a95569387e4a36..77da36e7513fd4 100644 --- a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py +++ b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py @@ -85,7 +85,7 @@ def test_snapshot_upload_stores_cli_version_from_user_agent(self) -> None: artifact = PreprodArtifact.objects.get(id=response.data["artifactId"]) assert artifact.cli_version == "2.40.0" - def test_snapshot_upload_stores_cli_version_with_pipeline_env(self) -> None: + def test_snapshot_upload_stores_gradle_plugin_version(self) -> None: url = self._get_create_url() data = { "app_id": "com.example.app", @@ -105,12 +105,43 @@ def test_snapshot_upload_stores_cli_version_with_pipeline_env(self) -> None: url, data, format="json", - HTTP_USER_AGENT="sentry-cli/2.40.0 GitHub-Actions", + HTTP_USER_AGENT="sentry-cli/2.40.0 sentry-gradle-plugin/5.0.0", ) assert response.status_code == 200 artifact = PreprodArtifact.objects.get(id=response.data["artifactId"]) assert artifact.cli_version == "2.40.0" + assert artifact.gradle_plugin_version == "5.0.0" + assert artifact.fastlane_plugin_version is None + + def test_snapshot_upload_stores_fastlane_plugin_version(self) -> None: + url = self._get_create_url() + data = { + "app_id": "com.example.app", + "images": { + "abc123": { + "content_hash": "abc123", + "display_name": "Screen", + "image_file_name": "screen.png", + "width": 100, + "height": 200, + }, + }, + } + + with self.feature("organizations:preprod-snapshots"): + response = self.client.post( + url, + data, + format="json", + HTTP_USER_AGENT="sentry-cli/2.40.0 sentry-fastlane-plugin/2.5.1", + ) + + assert response.status_code == 200 + artifact = PreprodArtifact.objects.get(id=response.data["artifactId"]) + assert artifact.cli_version == "2.40.0" + assert artifact.fastlane_plugin_version == "2.5.1" + assert artifact.gradle_plugin_version is None def test_snapshot_upload_no_cli_version_for_unknown_user_agent(self) -> None: url = self._get_create_url()