Skip to content

Commit f243a54

Browse files
vishnupsatishandrewshie-sentry
authored andcommitted
feat(feedback): cache the feedback summary (#93461)
1 parent eb56e99 commit f243a54

File tree

2 files changed

+94
-3
lines changed

2 files changed

+94
-3
lines changed

src/sentry/api/endpoints/organization_feedback_summary.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
from sentry.api.utils import get_date_range_from_stats_period
1414
from sentry.exceptions import InvalidParams
1515
from sentry.feedback.usecases.feedback_summaries import generate_summary
16+
from sentry.grouping.utils import hash_from_values
1617
from sentry.issues.grouptype import FeedbackGroup
1718
from sentry.models.group import Group, GroupStatus
1819
from sentry.models.organization import Organization
20+
from sentry.utils.cache import cache
1921

2022
logger = logging.getLogger(__name__)
2123

@@ -24,6 +26,9 @@
2426
# Token limit is 1,048,576 tokens, see https://ai.google.dev/gemini-api/docs/models#gemini-2.0-flash
2527
MAX_FEEDBACKS_TO_SUMMARIZE_CHARS = 1000000
2628

29+
# One day since the cache key includes the start and end dates at hour granularity
30+
SUMMARY_CACHE_TIMEOUT = 86400
31+
2732

2833
@region_silo_endpoint
2934
class OrganizationFeedbackSummaryEndpoint(OrganizationEndpoint):
@@ -63,12 +68,30 @@ def get(self, request: Request, organization: Organization) -> Response:
6368
except InvalidParams:
6469
raise ParseError(detail="Invalid or missing date range")
6570

71+
projects = self.get_projects(request, organization)
72+
73+
# Sort first, then convert each element to a string
74+
numeric_project_ids = sorted([project.id for project in projects])
75+
project_ids = [str(project_id) for project_id in numeric_project_ids]
76+
hashed_project_ids = hash_from_values(project_ids)
77+
78+
summary_cache_key = f"feedback_summary:{organization.id}:{start.strftime('%Y-%m-%d-%H')}:{end.strftime('%Y-%m-%d-%H')}:{hashed_project_ids}"
79+
summary_cache = cache.get(summary_cache_key)
80+
if summary_cache:
81+
return Response(
82+
{
83+
"summary": summary_cache["summary"],
84+
"success": True,
85+
"numFeedbacksUsed": summary_cache["numFeedbacksUsed"],
86+
}
87+
)
88+
6689
filters = {
6790
"type": FeedbackGroup.type_id,
6891
"first_seen__gte": start,
6992
"first_seen__lte": end,
7093
"status": GroupStatus.UNRESOLVED,
71-
"project__in": self.get_projects(request, organization),
94+
"project__in": projects,
7295
}
7396

7497
groups = Group.objects.filter(**filters).order_by("-first_seen")[
@@ -77,7 +100,13 @@ def get(self, request: Request, organization: Organization) -> Response:
77100

78101
if groups.count() < MIN_FEEDBACKS_TO_SUMMARIZE:
79102
logger.error("Too few feedbacks to summarize")
80-
return Response({"summary": None, "success": False, "numFeedbacksUsed": 0})
103+
return Response(
104+
{
105+
"summary": None,
106+
"success": False,
107+
"numFeedbacksUsed": 0,
108+
}
109+
)
81110

82111
# Also cap the number of characters that we send to the LLM
83112
group_feedbacks = []
@@ -99,6 +128,16 @@ def get(self, request: Request, organization: Organization) -> Response:
99128
logger.exception("Error generating summary of user feedbacks")
100129
return Response({"detail": "Error generating summary"}, status=500)
101130

131+
cache.set(
132+
summary_cache_key,
133+
{"summary": summary, "numFeedbacksUsed": len(group_feedbacks)},
134+
timeout=SUMMARY_CACHE_TIMEOUT,
135+
)
136+
102137
return Response(
103-
{"summary": summary, "success": True, "numFeedbacksUsed": len(group_feedbacks)}
138+
{
139+
"summary": summary,
140+
"success": True,
141+
"numFeedbacksUsed": len(group_feedbacks),
142+
}
104143
)

tests/sentry/api/endpoints/test_organization_feedback_summary.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,3 +260,55 @@ def test_get_feedback_summary_character_limit(self, mock_generate_summary):
260260
assert response.data["success"] is True
261261
assert response.data["summary"] == "Test summary of feedback"
262262
assert response.data["numFeedbacksUsed"] == 12
263+
264+
@django_db_all
265+
@patch(
266+
"sentry.api.endpoints.organization_feedback_summary.generate_summary",
267+
return_value="Test summary of feedback",
268+
)
269+
@patch("sentry.api.endpoints.organization_feedback_summary.cache")
270+
def test_get_feedback_summary_cache_hit(self, mock_cache, mock_generate_summary):
271+
mock_cache.get.return_value = {
272+
"summary": "Test cached summary of feedback",
273+
"numFeedbacksUsed": 13,
274+
}
275+
276+
for _ in range(15):
277+
event = mock_feedback_event(self.project1.id)
278+
create_feedback_issue(
279+
event, self.project1.id, FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE
280+
)
281+
282+
with self.feature(self.features):
283+
response = self.get_success_response(self.org.slug)
284+
285+
assert response.data["success"] is True
286+
assert response.data["summary"] == "Test cached summary of feedback"
287+
assert response.data["numFeedbacksUsed"] == 13
288+
289+
mock_cache.get.assert_called_once()
290+
mock_cache.set.assert_not_called()
291+
292+
@django_db_all
293+
@patch(
294+
"sentry.api.endpoints.organization_feedback_summary.generate_summary",
295+
return_value="Test summary of feedback",
296+
)
297+
@patch("sentry.api.endpoints.organization_feedback_summary.cache")
298+
def test_get_feedback_summary_cache_miss(self, mock_cache, mock_generate_summary):
299+
mock_cache.get.return_value = None
300+
301+
for _ in range(15):
302+
event = mock_feedback_event(self.project1.id)
303+
create_feedback_issue(
304+
event, self.project1.id, FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE
305+
)
306+
307+
with self.feature(self.features):
308+
response = self.get_success_response(self.org.slug)
309+
310+
assert response.data["success"] is True
311+
assert response.data["summary"] == "Test summary of feedback"
312+
assert response.data["numFeedbacksUsed"] == 15
313+
mock_cache.get.assert_called_once()
314+
mock_cache.set.assert_called_once()

0 commit comments

Comments
 (0)