diff --git a/src/sentry/api/endpoints/organization_feedback_summary.py b/src/sentry/api/endpoints/organization_feedback_summary.py index b69c849d75e18a..8abbd4ed64837e 100644 --- a/src/sentry/api/endpoints/organization_feedback_summary.py +++ b/src/sentry/api/endpoints/organization_feedback_summary.py @@ -13,9 +13,11 @@ from sentry.api.utils import get_date_range_from_stats_period from sentry.exceptions import InvalidParams from sentry.feedback.usecases.feedback_summaries import generate_summary +from sentry.grouping.utils import hash_from_values from sentry.issues.grouptype import FeedbackGroup from sentry.models.group import Group, GroupStatus from sentry.models.organization import Organization +from sentry.utils.cache import cache logger = logging.getLogger(__name__) @@ -24,6 +26,9 @@ # Token limit is 1,048,576 tokens, see https://ai.google.dev/gemini-api/docs/models#gemini-2.0-flash MAX_FEEDBACKS_TO_SUMMARIZE_CHARS = 1000000 +# One day since the cache key includes the start and end dates at hour granularity +SUMMARY_CACHE_TIMEOUT = 86400 + @region_silo_endpoint class OrganizationFeedbackSummaryEndpoint(OrganizationEndpoint): @@ -63,12 +68,30 @@ def get(self, request: Request, organization: Organization) -> Response: except InvalidParams: raise ParseError(detail="Invalid or missing date range") + projects = self.get_projects(request, organization) + + # Sort first, then convert each element to a string + numeric_project_ids = sorted([project.id for project in projects]) + project_ids = [str(project_id) for project_id in numeric_project_ids] + hashed_project_ids = hash_from_values(project_ids) + + summary_cache_key = f"feedback_summary:{organization.id}:{start.strftime('%Y-%m-%d-%H')}:{end.strftime('%Y-%m-%d-%H')}:{hashed_project_ids}" + summary_cache = cache.get(summary_cache_key) + if summary_cache: + return Response( + { + "summary": summary_cache["summary"], + "success": True, + "numFeedbacksUsed": summary_cache["numFeedbacksUsed"], + } + ) + filters = { "type": FeedbackGroup.type_id, "first_seen__gte": start, "first_seen__lte": end, "status": GroupStatus.UNRESOLVED, - "project__in": self.get_projects(request, organization), + "project__in": projects, } groups = Group.objects.filter(**filters).order_by("-first_seen")[ @@ -77,7 +100,13 @@ def get(self, request: Request, organization: Organization) -> Response: if groups.count() < MIN_FEEDBACKS_TO_SUMMARIZE: logger.error("Too few feedbacks to summarize") - return Response({"summary": None, "success": False, "numFeedbacksUsed": 0}) + return Response( + { + "summary": None, + "success": False, + "numFeedbacksUsed": 0, + } + ) # Also cap the number of characters that we send to the LLM group_feedbacks = [] @@ -99,6 +128,16 @@ def get(self, request: Request, organization: Organization) -> Response: logger.exception("Error generating summary of user feedbacks") return Response({"detail": "Error generating summary"}, status=500) + cache.set( + summary_cache_key, + {"summary": summary, "numFeedbacksUsed": len(group_feedbacks)}, + timeout=SUMMARY_CACHE_TIMEOUT, + ) + return Response( - {"summary": summary, "success": True, "numFeedbacksUsed": len(group_feedbacks)} + { + "summary": summary, + "success": True, + "numFeedbacksUsed": len(group_feedbacks), + } ) diff --git a/tests/sentry/api/endpoints/test_organization_feedback_summary.py b/tests/sentry/api/endpoints/test_organization_feedback_summary.py index f6cba80261732a..73fc0a38f6d838 100644 --- a/tests/sentry/api/endpoints/test_organization_feedback_summary.py +++ b/tests/sentry/api/endpoints/test_organization_feedback_summary.py @@ -260,3 +260,55 @@ def test_get_feedback_summary_character_limit(self, mock_generate_summary): assert response.data["success"] is True assert response.data["summary"] == "Test summary of feedback" assert response.data["numFeedbacksUsed"] == 12 + + @django_db_all + @patch( + "sentry.api.endpoints.organization_feedback_summary.generate_summary", + return_value="Test summary of feedback", + ) + @patch("sentry.api.endpoints.organization_feedback_summary.cache") + def test_get_feedback_summary_cache_hit(self, mock_cache, mock_generate_summary): + mock_cache.get.return_value = { + "summary": "Test cached summary of feedback", + "numFeedbacksUsed": 13, + } + + for _ in range(15): + event = mock_feedback_event(self.project1.id) + create_feedback_issue( + event, self.project1.id, FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE + ) + + with self.feature(self.features): + response = self.get_success_response(self.org.slug) + + assert response.data["success"] is True + assert response.data["summary"] == "Test cached summary of feedback" + assert response.data["numFeedbacksUsed"] == 13 + + mock_cache.get.assert_called_once() + mock_cache.set.assert_not_called() + + @django_db_all + @patch( + "sentry.api.endpoints.organization_feedback_summary.generate_summary", + return_value="Test summary of feedback", + ) + @patch("sentry.api.endpoints.organization_feedback_summary.cache") + def test_get_feedback_summary_cache_miss(self, mock_cache, mock_generate_summary): + mock_cache.get.return_value = None + + for _ in range(15): + event = mock_feedback_event(self.project1.id) + create_feedback_issue( + event, self.project1.id, FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE + ) + + with self.feature(self.features): + response = self.get_success_response(self.org.slug) + + assert response.data["success"] is True + assert response.data["summary"] == "Test summary of feedback" + assert response.data["numFeedbacksUsed"] == 15 + mock_cache.get.assert_called_once() + mock_cache.set.assert_called_once()