Skip to content
Open
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
17 changes: 17 additions & 0 deletions src/sentry/objectstore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ def distribution(
_ATTACHMENTS_CLIENT: Client | None = None
_ATTACHMENTS_USECASE = Usecase("attachments", expiration_policy=TimeToLive(timedelta(days=30)))

_PREPROD_CLIENT: Client | None = None
_PREPROD_USECASE = Usecase("preprod", expiration_policy=TimeToLive(timedelta(days=30)))


def get_attachments_session(org: int, project: int) -> Session:
global _ATTACHMENTS_CLIENT
Expand All @@ -49,3 +52,17 @@ def get_attachments_session(org: int, project: int) -> Session:
)

return _ATTACHMENTS_CLIENT.session(_ATTACHMENTS_USECASE, org=org, project=project)


def get_preprod_session(org: int, project: int) -> Session:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jan-auer saw this practice already used for attachments, is this how you all would recommend using a global session?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. Although you might as well create a dict mapping Usecase -> Client | None to store the usecase and the client, as I can see us adding more of these over time.

global _PREPROD_CLIENT
if not _PREPROD_CLIENT:
from sentry import options as options_store

options = options_store.get("objectstore.config")
_PREPROD_CLIENT = Client(
options["base_url"],
metrics_backend=SentryMetricsBackend(),
)

return _PREPROD_CLIENT.session(_PREPROD_USECASE, org=org, project=project)
31 changes: 16 additions & 15 deletions src/sentry/preprod/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,27 +22,27 @@ class PreprodArtifactApiAssembleGenericEvent(analytics.Event):
project_id: int


@analytics.eventclass("preprod_artifact.api.size_analysis_download")
class PreprodArtifactApiSizeAnalysisDownloadEvent(analytics.Event):
@analytics.eventclass("preprod_artifact.api.get_build_details")
class PreprodArtifactApiGetBuildDetailsEvent(analytics.Event):
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't change these, I just reordered a few of these to match the .register order below

organization_id: int
project_id: int
user_id: int | None = None
artifact_id: str


@analytics.eventclass("preprod_artifact.api.get_build_details")
class PreprodArtifactApiGetBuildDetailsEvent(analytics.Event):
@analytics.eventclass("preprod_artifact.api.list_builds")
class PreprodArtifactApiListBuildsEvent(analytics.Event):
organization_id: int
project_id: int
user_id: int | None = None
artifact_id: str


@analytics.eventclass("preprod_artifact.api.list_builds")
class PreprodArtifactApiListBuildsEvent(analytics.Event):
@analytics.eventclass("preprod_artifact.api.install_details")
class PreprodArtifactApiInstallDetailsEvent(analytics.Event):
organization_id: int
project_id: int
user_id: int | None = None
artifact_id: str


@analytics.eventclass("preprod_artifact.api.admin_rerun_analysis")
Expand Down Expand Up @@ -77,6 +77,15 @@ class PreprodArtifactApiDeleteEvent(analytics.Event):
artifact_id: str


# Size analysis
@analytics.eventclass("preprod_artifact.api.size_analysis_download")
class PreprodArtifactApiSizeAnalysisDownloadEvent(analytics.Event):
organization_id: int
project_id: int
user_id: int | None = None
artifact_id: str


@analytics.eventclass("preprod_artifact.api.size_analysis_compare.get")
class PreprodArtifactApiSizeAnalysisCompareGetEvent(analytics.Event):
organization_id: int
Expand All @@ -95,14 +104,6 @@ class PreprodArtifactApiSizeAnalysisComparePostEvent(analytics.Event):
base_artifact_id: str


@analytics.eventclass("preprod_artifact.api.install_details")
class PreprodArtifactApiInstallDetailsEvent(analytics.Event):
organization_id: int
project_id: int
user_id: int | None = None
artifact_id: str


@analytics.eventclass("preprod_artifact.api.size_analysis_compare_download")
class PreprodArtifactApiSizeAnalysisCompareDownloadEvent(analytics.Event):
organization_id: int
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from __future__ import annotations

import logging

from django.http import HttpResponse
from rest_framework.request import Request

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.project import ProjectEndpoint
from sentry.models.project import Project
from sentry.objectstore import get_preprod_session

logger = logging.getLogger(__name__)


@region_silo_endpoint
class ProjectPreprodArtifactImageEndpoint(ProjectEndpoint):
owner = ApiOwner.EMERGE_TOOLS
publish_status = {
"GET": ApiPublishStatus.EXPERIMENTAL,
}

def get(
self,
_: Request,
project: Project,
image_id: str,
) -> HttpResponse:

organization_id = project.organization_id
project_id = project.id

object_key = f"{organization_id}/{project_id}/{image_id}"
session = get_preprod_session(organization_id, project_id)

try:
result = session.get(object_key)
# Read the entire stream at once (necessary for content_type)
image_data = result.payload.read()

# Detect content type from the image data
return HttpResponse(image_data, content_type=result.metadata.content_type)

except Exception:
logger.exception(
"Unexpected error retrieving image",
extra={
"organization_id": organization_id,
"project_id": project_id,
"image_id": image_id,
},
)
return HttpResponse({"error": "Internal server error"}, status=500)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Invalid dictionary passed to HttpResponse without JSON serialization

The error response passes a dictionary directly to HttpResponse instead of serializing it to JSON. HttpResponse expects string or bytes content, and will convert the dict to its Python string representation (e.g., "{'error': '...'}") instead of valid JSON. Use json.dumps() to serialize the dictionary first.

Fix in Cursor Fix in Web

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Invalid dictionary passed to HttpResponse without JSON serialization

The error response passes a dictionary directly to HttpResponse instead of serializing it to JSON. HttpResponse expects string or bytes content, and will convert the dict to its Python string representation (e.g., "{'error': '...'}") instead of valid JSON. Use json.dumps() to serialize the dictionary first, or use Response from rest_framework.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The HttpResponse constructor at line 55 receives a dictionary, which it does not handle correctly, leading to a TypeError or malformed response.
Severity: HIGH | Confidence: High

🔍 Detailed Analysis

The HttpResponse constructor at line 55 is called with a Python dictionary {'error': 'Internal server error'} as its first argument. Django's HttpResponse expects content to be bytes or a string, not a dictionary. This will cause a TypeError when an exception occurs in the try block (line 39), preventing a graceful 500 error response and instead crashing the server.

💡 Suggested Fix

Serialize the dictionary to JSON: HttpResponse(json.dumps({'error': 'Internal server error'}), content_type='application/json', status=500) or return plain text: HttpResponse('Internal server error', status=500).

🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/sentry/preprod/api/endpoints/project_preprod_artifact_image.py#L55

Potential issue: The `HttpResponse` constructor at line 55 is called with a Python
dictionary `{'error': 'Internal server error'}` as its first argument. Django's
`HttpResponse` expects content to be bytes or a string, not a dictionary. This will
cause a `TypeError` when an exception occurs in the `try` block (line 39), preventing a
graceful 500 error response and instead crashing the server.

Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 3502411

8 changes: 8 additions & 0 deletions src/sentry/preprod/api/endpoints/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

from django.urls import re_path

from sentry.preprod.api.endpoints.project_preprod_artifact_image import (
ProjectPreprodArtifactImageEndpoint,
)
from sentry.preprod.api.endpoints.size_analysis.project_preprod_size_analysis_compare import (
ProjectPreprodArtifactSizeAnalysisCompareEndpoint,
)
Expand Down Expand Up @@ -81,6 +84,11 @@
ProjectInstallablePreprodArtifactDownloadEndpoint.as_view(),
name="sentry-api-0-installable-preprod-artifact-download",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/files/images/(?P<image_id>[^/]+)/$",
ProjectPreprodArtifactImageEndpoint.as_view(),
name="sentry-api-0-project-preprod-artifact-image",
),
# Size analysis
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/preprodartifacts/size-analysis/compare/(?P<head_artifact_id>[^/]+)/(?P<base_artifact_id>[^/]+)/$",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class BuildDetailsAppInfo(BaseModel):
platform: Platform | None = None
is_installable: bool
build_configuration: str | None = None
app_icon_id: str | None = None
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is related to app icon, not the generic download endpoint, but should be fine to merge as the frontend does not yet use this until #102118 lands

apple_app_info: AppleAppInfo | None = None
android_app_info: AndroidAppInfo | None = None

Expand Down Expand Up @@ -213,6 +214,7 @@ def transform_preprod_artifact_to_build_details(
build_configuration=(
artifact.build_configuration.name if artifact.build_configuration else None
),
app_icon_id=artifact.app_icon_id,
apple_app_info=apple_app_info,
android_app_info=android_app_info,
)
Expand Down
6 changes: 5 additions & 1 deletion static/app/utils/api/knownSentryApiUrls.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ export type KnownSentryApiUrls =
| '/organizations/$organizationIdOrSlug/detector-workflow/$detectorWorkflowId/'
| '/organizations/$organizationIdOrSlug/detectors/'
| '/organizations/$organizationIdOrSlug/detectors/$detectorId/'
| '/organizations/$organizationIdOrSlug/detectors/$detectorId/anomaly-data/'
| '/organizations/$organizationIdOrSlug/detectors/count/'
| '/organizations/$organizationIdOrSlug/discover/homepage/'
| '/organizations/$organizationIdOrSlug/discover/saved/'
Expand Down Expand Up @@ -346,6 +347,7 @@ export type KnownSentryApiUrls =
| '/organizations/$organizationIdOrSlug/groups/$issueId/tags/$key/values/'
| '/organizations/$organizationIdOrSlug/groups/$issueId/user-feedback/'
| '/organizations/$organizationIdOrSlug/groups/$issueId/user-reports/'
| '/organizations/$organizationIdOrSlug/incident-groupopenperiod/'
| '/organizations/$organizationIdOrSlug/incidents/'
| '/organizations/$organizationIdOrSlug/incidents/$incidentIdentifier/'
| '/organizations/$organizationIdOrSlug/insights/starred-segments/'
Expand Down Expand Up @@ -452,6 +454,7 @@ export type KnownSentryApiUrls =
| '/organizations/$organizationIdOrSlug/notifications/actions/'
| '/organizations/$organizationIdOrSlug/notifications/actions/$actionId/'
| '/organizations/$organizationIdOrSlug/notifications/available-actions/'
| '/organizations/$organizationIdOrSlug/objectstore/'
| '/organizations/$organizationIdOrSlug/onboarding-continuation-email/'
| '/organizations/$organizationIdOrSlug/onboarding-tasks/'
| '/organizations/$organizationIdOrSlug/ondemand-rules-stats/'
Expand Down Expand Up @@ -609,6 +612,7 @@ export type KnownSentryApiUrls =
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/dsyms/'
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/dsyms/associate/'
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/dsyms/unknown/'
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/images/$imageId/'
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/installablepreprodartifact/$urlPath/'
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/$headArtifactId/size-analysis/'
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/assemble/'
Expand Down Expand Up @@ -711,10 +715,10 @@ export type KnownSentryApiUrls =
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/user-reports/'
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/user-stats/'
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/users/'
| '/projects/$organizationIdOrSlug/$projectIdOrSlug/web-vitals-detector/'
| '/projects/$organizationIdOrSlug/pr-comments/$repoName/$prNumber/'
| '/projects/$organizationIdOrSlug/pull-requests/size-analysis/$artifactId/'
| '/projects/$organizationIdOrSlug/pullrequest-details/$repoName/$prNumber/'
| '/prompts-activity/'
| '/publickeys/relocations/'
| '/relays/'
| '/relays/$relayId/'
Expand Down
117 changes: 117 additions & 0 deletions tests/sentry/preprod/api/endpoints/test_preprod_artifact_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
from unittest.mock import MagicMock, patch

from django.urls import reverse

from sentry.testutils.cases import APITestCase


class ProjectPreprodArtifactImageTest(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)
self.api_token = self.create_user_auth_token(
user=self.user, scope_list=["org:admin", "project:admin"]
)
self.image_id = "test-image-123"
self.base_path = f"/api/0/{self.org.slug}/{self.project.slug}/files/images/{self.image_id}/"

def _get_url(self, image_id=None):
image_id = image_id or self.image_id
return reverse(
"sentry-api-0-project-preprod-artifact-image",
args=[self.org.slug, self.project.slug, image_id],
)

def _create_mock_session(self, image_data, content_type):
"""Create a mock object store session that returns the given data and content type."""
mock_result = MagicMock()
mock_result.payload.read.return_value = image_data
mock_result.metadata.content_type = content_type

mock_session = MagicMock()
mock_session.get.return_value = mock_result

return mock_session

@patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_preprod_session")
def test_successful_image_retrieval_png(self, mock_get_session):
png_data = b"\x89PNG\r\n\x1a\n" + b"fake png content" * 100
mock_session = self._create_mock_session(png_data, "image/png")
mock_get_session.return_value = mock_session

url = self._get_url()
response = self.client.get(
url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}"
)

assert response.status_code == 200
assert response.content == png_data
assert response["Content-Type"] == "image/png"
mock_get_session.assert_called_once_with(self.org.id, self.project.id)
mock_session.get.assert_called_once_with(f"{self.org.id}/{self.project.id}/{self.image_id}")

@patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_preprod_session")
def test_successful_image_retrieval_jpeg(self, mock_get_session):
jpeg_data = b"\xff\xd8\xff" + b"fake jpeg content" * 100
mock_session = self._create_mock_session(jpeg_data, "image/jpeg")
mock_get_session.return_value = mock_session

url = self._get_url()
response = self.client.get(
url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}"
)

assert response.status_code == 200
assert response.content == jpeg_data
assert response["Content-Type"] == "image/jpeg"
mock_get_session.assert_called_once_with(self.org.id, self.project.id)
mock_session.get.assert_called_once_with(f"{self.org.id}/{self.project.id}/{self.image_id}")

@patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_preprod_session")
def test_successful_image_retrieval_webp(self, mock_get_session):
webp_data = b"RIFF" + b"1234" + b"WEBP" + b"fake webp content" * 100
mock_session = self._create_mock_session(webp_data, "image/webp")
mock_get_session.return_value = mock_session

url = self._get_url()
response = self.client.get(
url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}"
)

assert response.status_code == 200
assert response.content == webp_data
assert response["Content-Type"] == "image/webp"
mock_get_session.assert_called_once_with(self.org.id, self.project.id)
mock_session.get.assert_called_once_with(f"{self.org.id}/{self.project.id}/{self.image_id}")

@patch("sentry.preprod.api.endpoints.project_preprod_artifact_image.get_preprod_session")
def test_unknown_image_format(self, mock_get_session):
unknown_data = b"unknown binary data" * 50
mock_session = self._create_mock_session(unknown_data, "application/octet-stream")
mock_get_session.return_value = mock_session

url = self._get_url()
response = self.client.get(
url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}"
)

assert response.status_code == 200
assert response.content == unknown_data
assert response["Content-Type"] == "application/octet-stream"
mock_get_session.assert_called_once_with(self.org.id, self.project.id)
mock_session.get.assert_called_once_with(f"{self.org.id}/{self.project.id}/{self.image_id}")

def test_endpoint_requires_project_access(self):
other_user = self.create_user()
self.login_as(user=other_user)
self.api_token = self.create_user_auth_token(
user=other_user, scope_list=["org:read", "project:read"]
)

url = self._get_url()
response = self.client.get(
url, format="json", HTTP_AUTHORIZATION=f"Bearer {self.api_token.token}"
)
assert response.status_code == 403
Loading