Skip to content

Commit 2381228

Browse files
authored
feat(weekly-report): adding week over week percentage change to total errors and transactions (#117037)
Resolves [ID-1587](https://linear.app/getsentry/issue/ID-1587/add-top-level-metric-to-total-errors-and-total-transactions) To test: - Start dev environment - Go to `debug/mail/weekly-reports/` - Email should render normally -- there shouldn't be any changes to the email content itself Adds a percentage change indicator next to Total Errors and Total Transactions in the weekly email report, showing how the current week compares to the previous week (e.g., "▲ 50% (vs. last week)"). The metric is displayed as a gray pill badge with a unicode arrow. **Feature Flag** Gated behind feature flag `organizations:weekly-report-week-over-week-metric` **Previous-week data fetching using cache-first approach** We cache the previous week's counts. If it's a cache miss, then one additional Snuba query per organization fetches last week's accepted error and transaction counts, reusing the existing `project_event_counts_for_organization()` function with shifted dates. The query is instrumented with `@metrics.wraps` and `sentry_sdk.start_span` for cost monitoring. **Percentage display** The percentage is hidden when there's no previous-week data or when the change rounds to 0%. The debug weekly report view also generates randomized previous-week data so the indicator is visible in email previews. Screenshot (fake data): <img width="639" height="265" alt="Screenshot 2026-06-05 at 3 02 47 PM" src="https://github.com/user-attachments/assets/f42f3a68-6e65-4f95-96b5-63531e3f238a" />
1 parent 0b5c808 commit 2381228

7 files changed

Lines changed: 225 additions & 4 deletions

File tree

src/sentry/features/temporary.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,8 @@ def register_temporary_features(manager: FeatureManager) -> None:
362362
# Enable high date range options on new explore page
363363
manager.add("organizations:visibility-explore-range-high", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
364364

365+
# Enable week-over-week percentage change metric in weekly email reports
366+
manager.add("organizations:weekly-report-week-over-week-metric", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
365367
# Enable logging to debug workflow engine process workflows
366368
manager.add("organizations:workflow-engine-process-workflows-logs", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
367369
# Disable issue stream detector notifications for metric issues

src/sentry/tasks/summaries/organization_report_context_factory.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import sentry_sdk
22

3+
from sentry import features
34
from sentry.constants import DataCategory
45
from sentry.models.organization import Organization
56
from sentry.models.organizationmember import OrganizationMember
@@ -17,6 +18,7 @@
1718
project_key_transactions_last_week,
1819
project_key_transactions_this_week,
1920
)
21+
from sentry.tasks.summaries.weekly_report_cache import read_project_metrics
2022
from sentry.utils import metrics
2123
from sentry.utils.outcomes import Outcome
2224
from sentry.utils.snuba import parse_snuba_datetime
@@ -97,6 +99,50 @@ def _append_project_event_counts(self, ctx: OrganizationReportContext) -> None:
9799
project_ctx.error_count_by_day.get(timestamp, 0) + total
98100
)
99101

102+
@metrics.wraps("weekly_report.create_context.project_event_counts_previous_week")
103+
def _append_project_event_counts_previous_week(self, ctx: OrganizationReportContext) -> None:
104+
"""Populate previous-week accepted error/transaction counts for week-over-week comparison.
105+
106+
Reads from Redis cache first (written by cache_project_metrics() at the end of each
107+
weekly report run), then falls back to a Snuba query for any cache misses.
108+
"""
109+
with sentry_sdk.start_span(op="weekly_reports.project_event_counts_previous_week"):
110+
project_ids = list(ctx.projects_context_map.keys())
111+
cached = read_project_metrics(ctx.organization.id, project_ids)
112+
113+
for project_id, values in cached.items():
114+
project_ctx = ctx.projects_context_map[project_id]
115+
project_ctx.prev_week_accepted_error_count = values.get("e", 0)
116+
project_ctx.prev_week_accepted_transaction_count = values.get("t", 0)
117+
118+
missed_project_ids = set(project_ids) - set(cached.keys())
119+
if not missed_project_ids:
120+
return
121+
122+
# Snuba fallback for cache misses (e.g. new projects, first report run)
123+
prev_start = ctx.start - (ctx.end - ctx.start)
124+
prev_end = ctx.start
125+
event_counts = project_event_counts_for_organization(
126+
start=prev_start,
127+
end=prev_end,
128+
ctx=ctx,
129+
referrer=Referrer.REPORTS_OUTCOMES.value,
130+
)
131+
for data in event_counts:
132+
project_id = data["project_id"]
133+
if project_id not in missed_project_ids:
134+
continue
135+
if project_id not in ctx.projects_context_map:
136+
continue
137+
project_ctx = ctx.projects_context_map[project_id]
138+
total = data["total"]
139+
if data["outcome"] != Outcome.ACCEPTED:
140+
continue
141+
if data["category"] == DataCategory.TRANSACTION:
142+
project_ctx.prev_week_accepted_transaction_count += total
143+
elif data["category"] in DataCategory.error_categories():
144+
project_ctx.prev_week_accepted_error_count += total
145+
100146
@metrics.wraps("weekly_report.create_context.issue_substatus_summaries")
101147
def _append_organization_project_issue_substatus_summaries(
102148
self, ctx: OrganizationReportContext
@@ -177,6 +223,8 @@ def create_context(self) -> OrganizationReportContext:
177223
with metrics.timer("weekly_report.create_context.duration"):
178224
self._append_user_project_ownership(ctx)
179225
self._append_project_event_counts(ctx)
226+
if features.has("organizations:weekly-report-week-over-week-metric", self.organization):
227+
self._append_project_event_counts_previous_week(ctx)
180228
self._append_organization_project_issue_substatus_summaries(ctx)
181229

182230
# Enhanced privacy flag hides issue titles, transaction names, and source details

src/sentry/tasks/summaries/utils.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ class ProjectContext:
7272
accepted_replay_count = 0
7373
dropped_replay_count = 0
7474

75+
prev_week_accepted_error_count = 0
76+
prev_week_accepted_transaction_count = 0
77+
7578
new_substatus_count = 0
7679
ongoing_substatus_count = 0
7780
escalating_substatus_count = 0

src/sentry/tasks/summaries/weekly_reports.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from taskbroker_client.retry import Retry
2020
from taskbroker_client.worker.workerchild import ProcessingDeadlineExceeded
2121

22-
from sentry import analytics
22+
from sentry import analytics, features
2323
from sentry.analytics.events.weekly_report import WeeklyReportSent
2424
from sentry.models.group import Group, GroupStatus
2525
from sentry.models.grouphistory import GroupHistoryStatus
@@ -496,6 +496,18 @@ def record_delivery(self) -> bool:
496496
}
497497

498498

499+
def _pct_change(current: int, previous: int) -> str | None:
500+
"""Returns a formatted string like '▲ 50%' or '▼ 25%', or None if not meaningful."""
501+
if previous == 0:
502+
return None
503+
change = (current - previous) / previous
504+
pct = round(change * 100)
505+
if pct == 0:
506+
return None
507+
arrow = "▲" if change > 0 else "▼"
508+
return f"{arrow} {abs(pct)}%"
509+
510+
499511
def get_group_status_badge(group: Group) -> tuple[str, str, str]:
500512
"""
501513
Returns a tuple of (text, background_color, border_color)
@@ -669,12 +681,21 @@ def sum_event_counts(project_ctxs):
669681
}
670682
)
671683
series.append((to_datetime(t), project_series))
684+
prev_week_error = sum(
685+
p.prev_week_accepted_error_count for p in projects_associated_with_user
686+
)
687+
prev_week_transaction = sum(
688+
p.prev_week_accepted_transaction_count for p in projects_associated_with_user
689+
)
690+
672691
return {
673692
"legend": legend,
674693
"series": series,
675694
"total_error_count": total_error,
676695
"total_transaction_count": total_transaction,
677696
"total_replay_count": total_replays,
697+
"error_pct_change": _pct_change(total_error, prev_week_error),
698+
"transaction_pct_change": _pct_change(total_transaction, prev_week_transaction),
678699
"error_maximum": max( # The max error count on any single day
679700
sum(value["error_count"] for value in values) for timestamp, values in series
680701
),
@@ -783,6 +804,9 @@ def issue_summary():
783804
"user_project_count": len(user_projects),
784805
"notification_uuid": notification_uuid,
785806
"enhanced_privacy": ctx.organization.flags.enhanced_privacy,
807+
"show_week_over_week_metric": features.has(
808+
"organizations:weekly-report-week-over-week-metric", ctx.organization
809+
),
786810
}
787811

788812

src/sentry/templates/sentry/emails/reports/body.html

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,13 @@ <h1>
231231

232232
<td class="project-breakdown-graph-cell {% if has_replay_graph %}errors-wide{% else %}errors{% endif %}" {% if has_replay_graph %}colspan="2"{% endif %}>
233233
<h4 class="total-count-title">Total Project Errors</h4>
234-
<h1 style="margin: 0;" class="total-count">{{ trends.total_error_count|small_count:1 }}</h1>
234+
<h1 style="margin: 0;" class="total-count">
235+
{{ trends.total_error_count|small_count:1 }}
236+
{% if show_week_over_week_metric and trends.error_pct_change %}
237+
<span style="font-size: 14px; font-weight: 500; margin-left: 8px; padding: 2px 8px; border-radius: 12px; vertical-align: middle; background-color: #F2F0FA; color: #80708F;">{{ trends.error_pct_change }}</span>
238+
<span style="font-size: 12px; font-weight: normal; color: #80708F; margin-left: 4px; vertical-align: middle;">(vs. last week)</span>
239+
{% endif %}
240+
</h1>
235241
{% url 'sentry-organization-issue-list' organization.slug as issue_list %}
236242
{% querystring referrer="weekly_report" notification_uuid=notification_uuid as query %}
237243
<a href="{% org_url organization issue_list query=query %}"
@@ -273,7 +279,13 @@ <h1 style="margin: 0;" class="total-count">{{ trends.total_error_count|small_cou
273279
{% if trends.total_transaction_count > 0 %}
274280
<td class="project-breakdown-graph-cell {% if has_replay_graph %}transactions-below{% else %}transactions{% endif %} {% if has_replay_graph and trends.total_replay_count == 0 %}some-empty-below{% endif %}">
275281
<h4 class="total-count-title">Total Project Transactions</h4>
276-
<h1 style="margin: 0;" class="total-count">{{ trends.total_transaction_count|small_count:1 }}</h1>
282+
<h1 style="margin: 0;" class="total-count">
283+
{{ trends.total_transaction_count|small_count:1 }}
284+
{% if show_week_over_week_metric and trends.transaction_pct_change %}
285+
<span style="font-size: 14px; font-weight: 500; margin-left: 8px; padding: 2px 8px; border-radius: 12px; vertical-align: middle; background-color: #F2F0FA; color: #80708F;">{{ trends.transaction_pct_change }}</span>
286+
<span style="font-size: 12px; font-weight: normal; color: #80708F; margin-left: 4px; vertical-align: middle;">(vs. last week)</span>
287+
{% endif %}
288+
</h1>
277289
{% url 'sentry-organization-performance' organization.slug as performance_landing %}
278290
{% querystring referrer="weekly_report_view_all" notification_uuid=notification_uuid as query %}
279291
<a href="{% org_url organization performance_landing query=query %}"

src/sentry/web/frontend/debug/debug_weekly_report.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ def get_context(self, request):
9090
project_context.dropped_replay_count = int(
9191
random.weibullvariate(5, 1) * random.paretovariate(0.2)
9292
)
93+
project_context.prev_week_accepted_error_count = int(
94+
project_context.accepted_error_count * random.uniform(0.5, 1.5)
95+
)
96+
project_context.prev_week_accepted_transaction_count = int(
97+
project_context.accepted_transaction_count * random.uniform(0.5, 1.5)
98+
)
9399
project_context.key_errors_by_group = [
94100
(g, random.randint(0, 1000)) for g in Group.objects.all()[:3]
95101
]
@@ -125,7 +131,12 @@ def get_context(self, request):
125131

126132
user_id = request.user.id
127133
ctx.project_ownership[user_id] = {pid for pid in ctx.projects_context_map}
128-
return render_template_context(ctx, user_id)
134+
context = render_template_context(ctx, user_id)
135+
if context is not None:
136+
context["show_week_over_week_metric"] = (
137+
request.GET.get("show_week_over_week_metric", "1") != "0"
138+
)
139+
return context
129140

130141
@property
131142
def html_template(self) -> str:

tests/sentry/tasks/test_weekly_reports.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
)
4141
from sentry.tasks.summaries.weekly_reports import (
4242
OrganizationReportBatch,
43+
_pct_change,
4344
date_format,
4445
group_status_to_color,
4546
prepare_organization_report,
@@ -1536,3 +1537,123 @@ def test_enhanced_privacy_email_does_not_contain_sensitive_data(self) -> None:
15361537
assert "sensitive error title xyz123" not in html
15371538
assert "enhanced privacy" in html.lower()
15381539
assert "Total Project Errors" in html
1540+
1541+
@with_feature("organizations:weekly-report-week-over-week-metric")
1542+
@mock.patch("sentry.tasks.summaries.weekly_reports.MessageBuilder")
1543+
def test_pct_change_with_previous_week(self, message_builder: mock.MagicMock) -> None:
1544+
user = self.create_user()
1545+
self.create_member(teams=[self.team], user=user, organization=self.organization)
1546+
1547+
self.store_event_outcomes(
1548+
self.organization.id, self.project.id, self.three_days_ago, num_times=10
1549+
)
1550+
self.store_event_outcomes(
1551+
self.organization.id,
1552+
self.project.id,
1553+
self.three_days_ago,
1554+
num_times=20,
1555+
category=DataCategory.TRANSACTION,
1556+
)
1557+
1558+
prev_week = self.three_days_ago - timedelta(days=7)
1559+
self.store_event_outcomes(self.organization.id, self.project.id, prev_week, num_times=5)
1560+
self.store_event_outcomes(
1561+
self.organization.id,
1562+
self.project.id,
1563+
prev_week,
1564+
num_times=40,
1565+
category=DataCategory.TRANSACTION,
1566+
)
1567+
1568+
prepare_organization_report(
1569+
self.timestamp, ONE_DAY * 7, self.organization.id, self._dummy_batch_id
1570+
)
1571+
1572+
for call_args in message_builder.call_args_list:
1573+
context = call_args.kwargs["context"]
1574+
assert context["trends"]["error_pct_change"] == "▲ 100%"
1575+
assert context["trends"]["transaction_pct_change"] == "▼ 50%"
1576+
assert context["show_week_over_week_metric"] is True
1577+
1578+
@with_feature("organizations:weekly-report-week-over-week-metric")
1579+
@mock.patch("sentry.tasks.summaries.weekly_reports.MessageBuilder")
1580+
def test_pct_change_no_previous_week(self, message_builder: mock.MagicMock) -> None:
1581+
user = self.create_user()
1582+
self.create_member(teams=[self.team], user=user, organization=self.organization)
1583+
1584+
self.store_event_outcomes(
1585+
self.organization.id, self.project.id, self.three_days_ago, num_times=10
1586+
)
1587+
1588+
prepare_organization_report(
1589+
self.timestamp, ONE_DAY * 7, self.organization.id, self._dummy_batch_id
1590+
)
1591+
1592+
for call_args in message_builder.call_args_list:
1593+
context = call_args.kwargs["context"]
1594+
assert context["trends"]["error_pct_change"] is None
1595+
assert context["trends"]["transaction_pct_change"] is None
1596+
assert context["show_week_over_week_metric"] is True
1597+
1598+
@mock.patch("sentry.tasks.summaries.weekly_reports.MessageBuilder")
1599+
def test_pct_change_hidden_without_feature_flag(self, message_builder: mock.MagicMock) -> None:
1600+
user = self.create_user()
1601+
self.create_member(teams=[self.team], user=user, organization=self.organization)
1602+
1603+
self.store_event_outcomes(
1604+
self.organization.id, self.project.id, self.three_days_ago, num_times=10
1605+
)
1606+
1607+
prev_week = self.three_days_ago - timedelta(days=7)
1608+
self.store_event_outcomes(self.organization.id, self.project.id, prev_week, num_times=5)
1609+
1610+
prepare_organization_report(
1611+
self.timestamp, ONE_DAY * 7, self.organization.id, self._dummy_batch_id
1612+
)
1613+
1614+
for call_args in message_builder.call_args_list:
1615+
context = call_args.kwargs["context"]
1616+
assert context["trends"]["error_pct_change"] is None
1617+
assert context["trends"]["transaction_pct_change"] is None
1618+
assert context["show_week_over_week_metric"] is False
1619+
1620+
@with_feature("organizations:weekly-report-week-over-week-metric")
1621+
@mock.patch("sentry.tasks.summaries.weekly_reports.MessageBuilder")
1622+
def test_pct_change_from_cache(self, message_builder: mock.MagicMock) -> None:
1623+
from sentry.tasks.summaries.weekly_report_cache import cache_project_metrics
1624+
1625+
user = self.create_user()
1626+
self.create_member(teams=[self.team], user=user, organization=self.organization)
1627+
1628+
self.store_event_outcomes(
1629+
self.organization.id, self.project.id, self.three_days_ago, num_times=10
1630+
)
1631+
self.store_event_outcomes(
1632+
self.organization.id,
1633+
self.project.id,
1634+
self.three_days_ago,
1635+
num_times=20,
1636+
category=DataCategory.TRANSACTION,
1637+
)
1638+
1639+
cache_project_metrics(
1640+
self.organization.id,
1641+
{self.project.id: {"e": 5, "t": 40}},
1642+
)
1643+
1644+
prepare_organization_report(
1645+
self.timestamp, ONE_DAY * 7, self.organization.id, self._dummy_batch_id
1646+
)
1647+
1648+
for call_args in message_builder.call_args_list:
1649+
context = call_args.kwargs["context"]
1650+
assert context["trends"]["error_pct_change"] == "▲ 100%"
1651+
assert context["trends"]["transaction_pct_change"] == "▼ 50%"
1652+
1653+
def test_pct_change_helper(self) -> None:
1654+
assert _pct_change(150, 100) == "▲ 50%"
1655+
assert _pct_change(50, 100) == "▼ 50%"
1656+
assert _pct_change(0, 100) == "▼ 100%"
1657+
assert _pct_change(100, 0) is None
1658+
assert _pct_change(0, 0) is None
1659+
assert _pct_change(100, 100) is None

0 commit comments

Comments
 (0)