From 7cfe9d0d988fa64fa4f8726e017099f24b5e00a7 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Sun, 14 Jun 2026 21:17:08 +0200 Subject: [PATCH 1/2] feat(profiling): Add endpoint to download profile chunk attachments Add ProjectProfilingChunkAttachmentEndpoint to download an attachment (e.g. a perfetto trace) of a profile chunk. The client supplies only the profiler/chunk IDs and the attachment name. The endpoint loads the chunk from the profiles object store and resolves the attachment's object store id and content type from the chunk itself, so no client-supplied storage key or content type is reflected back. The download is served as an attachment and the file handle is closed once the stream is exhausted. Co-Authored-By: Claude Fable 5 --- .../endpoints/project_profiling_profile.py | 63 ++++++- src/sentry/api/urls.py | 6 + .../test_project_profiling_profile.py | 159 ++++++++++++++++++ 3 files changed, 227 insertions(+), 1 deletion(-) diff --git a/src/sentry/api/endpoints/project_profiling_profile.py b/src/sentry/api/endpoints/project_profiling_profile.py index 03009b426fab46..cf7d4a67aea1f2 100644 --- a/src/sentry/api/endpoints/project_profiling_profile.py +++ b/src/sentry/api/endpoints/project_profiling_profile.py @@ -1,7 +1,8 @@ from typing import Any import orjson -from django.http import HttpResponse +import vroomrs +from django.http import Http404, HttpResponse, StreamingHttpResponse from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.request import Request from rest_framework.response import Response @@ -16,6 +17,7 @@ from sentry.apidocs.examples.profiling_examples import ProfilingExamples from sentry.apidocs.parameters import GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.models.files.utils import get_profiles_storage from sentry.models.project import Project from sentry.models.release import Release from sentry.profiles.utils import get_from_profiling_service, proxy_profiling_service @@ -141,3 +143,62 @@ def get( "path": f"/organizations/{project.organization_id}/projects/{project.id}/raw_chunks/{profiler_id}/{chunk_id}", } return proxy_profiling_service(**kwargs) + + +@cell_silo_endpoint +class ProjectProfilingChunkAttachmentEndpoint(ProjectProfilingBaseEndpoint): + def get( + self, + request: Request, + project: Project, + profiler_id: str, + chunk_id: str, + attachment_name: str, + ) -> Response | StreamingHttpResponse: + """Download an attachment (e.g. a perfetto trace) of a profile chunk. + + The chunk is loaded from the profiles object store and carries the list + of its attachments, each with a name, content type and the object store + id of the attached file. The client only supplies the profiler/chunk IDs + and the attachment name; everything else is resolved server-side. + """ + if not features.has( + "organizations:continuous-profiling", project.organization, actor=request.user + ): + return Response(status=404) + + storage = get_profiles_storage() + + chunk_path = f"{project.organization_id}/{project.id}/{profiler_id}/{chunk_id}" + if not storage.exists(chunk_path): + raise Http404 + + try: + with storage.open(chunk_path) as f: + chunk = vroomrs.decompress_profile_chunk(f.read()) + except OSError: + raise Http404 + + attachment = next( + (a for a in chunk.get_attachments() if a.name == attachment_name), + None, + ) + if attachment is None or not attachment.stored_id: + raise Http404 + + try: + fp = storage.open(attachment.stored_id) + except OSError: + raise Http404 + + def stream_attachment(): + with fp: + while chunk := fp.read(4096): + yield chunk + + response = StreamingHttpResponse( + stream_attachment(), + content_type=attachment.content_type or "application/octet-stream", + ) + response["Content-Disposition"] = f'attachment; filename="{attachment.name}"' + return response diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index aa6bd479ed9016..25b828e50e58d5 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -813,6 +813,7 @@ from .endpoints.project_plugin_details import ProjectPluginDetailsEndpoint from .endpoints.project_plugins import ProjectPluginsEndpoint from .endpoints.project_profiling_profile import ( + ProjectProfilingChunkAttachmentEndpoint, ProjectProfilingProfileEndpoint, ProjectProfilingRawChunkEndpoint, ProjectProfilingRawProfileEndpoint, @@ -3224,6 +3225,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: ProjectProfilingRawChunkEndpoint.as_view(), name="sentry-api-0-project-profiling-raw-chunk", ), + re_path( + r"^(?P[^/]+)/(?P[^/]+)/profiling/chunks/(?P(?:\d+|[A-Fa-f0-9-]{32,36}))/(?P(?:\d+|[A-Fa-f0-9-]{32,36}))/attachments/(?P[^/]+)/$", + ProjectProfilingChunkAttachmentEndpoint.as_view(), + name="sentry-api-0-project-profiling-chunk-attachment", + ), re_path( r"^(?P[^/]+)/(?P[^/]+)/statistical-detector/$", ProjectStatisticalDetectors.as_view(), diff --git a/tests/sentry/api/endpoints/test_project_profiling_profile.py b/tests/sentry/api/endpoints/test_project_profiling_profile.py index 602a70a5c30852..4698387ca5a8e5 100644 --- a/tests/sentry/api/endpoints/test_project_profiling_profile.py +++ b/tests/sentry/api/endpoints/test_project_profiling_profile.py @@ -1,5 +1,10 @@ +from io import BytesIO +from typing import Any +from unittest.mock import Mock, patch from uuid import uuid4 +import vroomrs + from sentry.testutils.cases import APITestCase PROFILING_FEATURES = {"organizations:profiling": True} @@ -14,3 +19,157 @@ def setUp(self) -> None: def test_feature_flag_disabled(self) -> None: response = self.get_response(self.project.organization.slug, self.project.id, str(uuid4())) assert response.status_code == 404 + + +class ProjectProfilingChunkAttachmentTest(APITestCase): + endpoint = "sentry-api-0-project-profiling-chunk-attachment" + + def setUp(self) -> None: + self.login_as(user=self.user) + self.profiler_id = uuid4().hex + self.chunk_id = uuid4().hex + self.chunk_path = ( + f"{self.organization.id}/{self.project.id}/{self.profiler_id}/{self.chunk_id}" + ) + + def get_attachment_response(self, attachment_name: str = "raw_profile") -> Any: + return self.get_response( + self.organization.slug, + self.project.slug, + self.profiler_id, + self.chunk_id, + attachment_name, + ) + + def make_chunk(self, attachments: list[vroomrs.Attachment]) -> Mock: + chunk = Mock() + chunk.get_attachments.return_value = attachments + return chunk + + def make_storage(self, chunk_exists: bool, stored_files: dict[str, bytes]) -> Mock: + storage = Mock() + storage.exists.return_value = chunk_exists + + def open_(path: str) -> BytesIO: + if path == self.chunk_path and chunk_exists: + return BytesIO(b"compressed-chunk") + if path in stored_files: + return BytesIO(stored_files[path]) + raise OSError(f"no such object: {path}") + + storage.open.side_effect = open_ + return storage + + def test_feature_flag_disabled(self) -> None: + response = self.get_attachment_response() + assert response.status_code == 404 + + @patch("sentry.api.endpoints.project_profiling_profile.vroomrs") + @patch("sentry.api.endpoints.project_profiling_profile.get_profiles_storage") + def test_download(self, mock_get_storage: Mock, mock_vroomrs: Mock) -> None: + storage = self.make_storage(True, {"aef123345": b"raw-profile-bytes"}) + mock_get_storage.return_value = storage + mock_vroomrs.decompress_profile_chunk.return_value = self.make_chunk( + [ + vroomrs.Attachment( + name="raw_profile", + content_type="application/x-perfetto-trace", + stored_id="aef123345", + ) + ] + ) + + with self.feature("organizations:continuous-profiling"): + response = self.get_attachment_response() + + assert response.status_code == 200 + assert b"".join(response.streaming_content) == b"raw-profile-bytes" + assert response["Content-Type"] == "application/x-perfetto-trace" + assert response["Content-Disposition"] == 'attachment; filename="raw_profile"' + storage.exists.assert_called_once_with(self.chunk_path) + + @patch("sentry.api.endpoints.project_profiling_profile.vroomrs") + @patch("sentry.api.endpoints.project_profiling_profile.get_profiles_storage") + def test_download_without_content_type_falls_back_to_octet_stream( + self, mock_get_storage: Mock, mock_vroomrs: Mock + ) -> None: + mock_get_storage.return_value = self.make_storage(True, {"aef123345": b"trace"}) + mock_vroomrs.decompress_profile_chunk.return_value = self.make_chunk( + [vroomrs.Attachment(name="raw_profile", content_type=None, stored_id="aef123345")] + ) + + with self.feature("organizations:continuous-profiling"): + response = self.get_attachment_response() + + assert response.status_code == 200 + assert response["Content-Type"] == "application/octet-stream" + + @patch("sentry.api.endpoints.project_profiling_profile.vroomrs") + @patch("sentry.api.endpoints.project_profiling_profile.get_profiles_storage") + def test_unknown_attachment_name_returns_404( + self, mock_get_storage: Mock, mock_vroomrs: Mock + ) -> None: + mock_get_storage.return_value = self.make_storage(True, {"aef123345": b"trace"}) + mock_vroomrs.decompress_profile_chunk.return_value = self.make_chunk( + [ + vroomrs.Attachment( + name="raw_profile", + content_type="application/x-perfetto-trace", + stored_id="aef123345", + ) + ] + ) + + with self.feature("organizations:continuous-profiling"): + response = self.get_attachment_response("some_other_attachment") + + assert response.status_code == 404 + + @patch("sentry.api.endpoints.project_profiling_profile.vroomrs") + @patch("sentry.api.endpoints.project_profiling_profile.get_profiles_storage") + def test_chunk_without_attachments_returns_404( + self, mock_get_storage: Mock, mock_vroomrs: Mock + ) -> None: + mock_get_storage.return_value = self.make_storage(True, {}) + mock_vroomrs.decompress_profile_chunk.return_value = self.make_chunk([]) + + with self.feature("organizations:continuous-profiling"): + response = self.get_attachment_response() + + assert response.status_code == 404 + + @patch("sentry.api.endpoints.project_profiling_profile.vroomrs") + @patch("sentry.api.endpoints.project_profiling_profile.get_profiles_storage") + def test_missing_chunk_returns_404(self, mock_get_storage: Mock, mock_vroomrs: Mock) -> None: + storage = self.make_storage(False, {}) + mock_get_storage.return_value = storage + + with self.feature("organizations:continuous-profiling"): + response = self.get_attachment_response() + + assert response.status_code == 404 + storage.open.assert_not_called() + mock_vroomrs.decompress_profile_chunk.assert_not_called() + + @patch("sentry.api.endpoints.project_profiling_profile.vroomrs") + @patch("sentry.api.endpoints.project_profiling_profile.get_profiles_storage") + def test_missing_attachment_file_returns_404( + self, mock_get_storage: Mock, mock_vroomrs: Mock + ) -> None: + # The attachment is referenced by the chunk but the file is gone from + # the object store. + mock_get_storage.return_value = self.make_storage(True, {}) + mock_vroomrs.decompress_profile_chunk.return_value = self.make_chunk( + [ + vroomrs.Attachment( + name="raw_profile", + content_type="application/x-perfetto-trace", + stored_id="aef123345", + ) + ] + ) + + with self.feature("organizations:continuous-profiling"): + response = self.get_attachment_response() + + assert response.status_code == 404 From b244bc4ad642dc7f059fc4816205a300d554f4f2 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Sun, 14 Jun 2026 19:49:38 +0000 Subject: [PATCH 2/2] :hammer_and_wrench: Sync API Urls to TypeScript --- static/app/utils/api/knownSentryApiUrls.generated.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index bfaf13c5cc305a..1f264e4d289d34 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -148,6 +148,7 @@ export type KnownSentryApiUrls = | '/organizations/$organizationIdOrSlug/events/' | '/organizations/$organizationIdOrSlug/events/$projectIdOrSlug:$eventId/' | '/organizations/$organizationIdOrSlug/events/anomalies/' + | '/organizations/$organizationIdOrSlug/events/validate/' | '/organizations/$organizationIdOrSlug/explore/saved/' | '/organizations/$organizationIdOrSlug/explore/saved/$id/' | '/organizations/$organizationIdOrSlug/explore/saved/$id/starred/' @@ -195,6 +196,7 @@ export type KnownSentryApiUrls = | '/organizations/$organizationIdOrSlug/issue-view-title/generate/' | '/organizations/$organizationIdOrSlug/issues-count/' | '/organizations/$organizationIdOrSlug/issues-metrics/' + | '/organizations/$organizationIdOrSlug/issues-progress/' | '/organizations/$organizationIdOrSlug/issues-stats/' | '/organizations/$organizationIdOrSlug/issues-timeseries/' | '/organizations/$organizationIdOrSlug/issues-with-supergroups/' @@ -309,7 +311,6 @@ export type KnownSentryApiUrls = | '/organizations/$organizationIdOrSlug/preprodartifacts/size-analysis/compare/$headArtifactId/$baseArtifactId/' | '/organizations/$organizationIdOrSlug/preprodartifacts/snapshots/$snapshotId/' | '/organizations/$organizationIdOrSlug/preprodartifacts/snapshots/$snapshotId/archive/' - | '/organizations/$organizationIdOrSlug/preprodartifacts/snapshots/$snapshotId/download/' | '/organizations/$organizationIdOrSlug/preprodartifacts/snapshots/$snapshotId/images/$imageIdentifier/' | '/organizations/$organizationIdOrSlug/preprodartifacts/snapshots/$snapshotId/recompare/' | '/organizations/$organizationIdOrSlug/preprodartifacts/snapshots/latest-base/' @@ -392,7 +393,6 @@ export type KnownSentryApiUrls = | '/organizations/$organizationIdOrSlug/shortids/$issueId/' | '/organizations/$organizationIdOrSlug/spans-samples/' | '/organizations/$organizationIdOrSlug/spans/fields/' - | '/organizations/$organizationIdOrSlug/spans/fields/$key/values/' | '/organizations/$organizationIdOrSlug/stats-summary/' | '/organizations/$organizationIdOrSlug/stats/' | '/organizations/$organizationIdOrSlug/stats_v2/' @@ -500,6 +500,7 @@ export type KnownSentryApiUrls = | '/projects/$organizationIdOrSlug/$projectIdOrSlug/preprodartifacts/snapshots/upload-options/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/processing-errors/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/processing-errors/$uuid/' + | '/projects/$organizationIdOrSlug/$projectIdOrSlug/profiling/chunks/$profilerId/$chunkId/attachments/$attachmentName/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/profiling/profiles/$profileId/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/profiling/raw_chunks/$profilerId/$chunkId/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/profiling/raw_profiles/$profileId/'