Skip to content

Commit c7323db

Browse files
cameroncookemchen-sentry
authored andcommitted
feat(preprod): Add App Clip artifact support to size checks (#108676)
Add backend App Clip artifact support across preprod size checks. This wires App Clip through the backend size-analysis and VCS check pipeline: - extend `PreprodArtifactSizeMetrics.MetricsArtifactType` with `APP_CLIP_ARTIFACT` - add status check rule type mapping/filtering for app clips - render App Clip in status-check templates following existing Watch-style labeling conventions - update status-check copy to component-centric wording - add/adjust tests for model parsing, task ingestion, status-check filtering, templates, and webhook payload expectations Stack context: - base/frontend dependency: PR #108675 Merge/deploy ordering for EME-811: 1) Frontend first (PR #108675) 2) Backend (this PR) 3) Launchpad emitter changes last <img width="764" height="462" alt="Screenshot 2026-02-19 at 16 11 02" src="https://github.com/user-attachments/assets/cc5cd77e-2ed7-40ff-ac26-4b524f5216fe" /> Refs EME-811
1 parent 01bddd2 commit c7323db

File tree

11 files changed

+329
-29
lines changed

11 files changed

+329
-29
lines changed

src/sentry/preprod/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,13 +522,16 @@ class MetricsArtifactType(IntEnum):
522522
"""An embedded watch artifact."""
523523
ANDROID_DYNAMIC_FEATURE = 2
524524
"""An embedded Android dynamic feature artifact."""
525+
APP_CLIP_ARTIFACT = 3
526+
"""An embedded App Clip artifact."""
525527

526528
@classmethod
527529
def as_choices(cls) -> tuple[tuple[int, str], ...]:
528530
return (
529531
(cls.MAIN_ARTIFACT, "main_artifact"),
530532
(cls.WATCH_ARTIFACT, "watch_artifact"),
531533
(cls.ANDROID_DYNAMIC_FEATURE, "android_dynamic_feature_artifact"),
534+
(cls.APP_CLIP_ARTIFACT, "app_clip_artifact"),
532535
)
533536

534537
class SizeAnalysisState(IntEnum):

src/sentry/preprod/vcs/status_checks/size/tasks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
RuleArtifactType.MAIN_ARTIFACT: PreprodArtifactSizeMetrics.MetricsArtifactType.MAIN_ARTIFACT,
9090
RuleArtifactType.WATCH_ARTIFACT: PreprodArtifactSizeMetrics.MetricsArtifactType.WATCH_ARTIFACT,
9191
RuleArtifactType.ANDROID_DYNAMIC_FEATURE_ARTIFACT: PreprodArtifactSizeMetrics.MetricsArtifactType.ANDROID_DYNAMIC_FEATURE,
92+
RuleArtifactType.APP_CLIP_ARTIFACT: PreprodArtifactSizeMetrics.MetricsArtifactType.APP_CLIP_ARTIFACT,
9293
}
9394

9495

src/sentry/preprod/vcs/status_checks/size/templates.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,17 +85,29 @@ def format_status_check_messages(
8585
parts = []
8686
if analyzed_count > 0 and not triggered_rules:
8787
parts.append(
88-
ngettext("%(count)d app analyzed", "%(count)d apps analyzed", analyzed_count)
88+
ngettext(
89+
"%(count)d component analyzed",
90+
"%(count)d components analyzed",
91+
analyzed_count,
92+
)
8993
% {"count": analyzed_count}
9094
)
9195
if processing_count > 0:
9296
parts.append(
93-
ngettext("%(count)d app processing", "%(count)d apps processing", processing_count)
97+
ngettext(
98+
"%(count)d component processing",
99+
"%(count)d components processing",
100+
processing_count,
101+
)
94102
% {"count": processing_count}
95103
)
96104
if errored_count > 0:
97105
parts.append(
98-
ngettext("%(count)d app errored", "%(count)d apps errored", errored_count)
106+
ngettext(
107+
"%(count)d component errored",
108+
"%(count)d components errored",
109+
errored_count,
110+
)
99111
% {"count": errored_count}
100112
)
101113

@@ -478,6 +490,8 @@ def _get_size_metric_type_display_name(
478490
return "Watch"
479491
case PreprodArtifactSizeMetrics.MetricsArtifactType.ANDROID_DYNAMIC_FEATURE:
480492
return "Dynamic Feature"
493+
case PreprodArtifactSizeMetrics.MetricsArtifactType.APP_CLIP_ARTIFACT:
494+
return "App Clip"
481495
case _:
482496
return None
483497

@@ -495,6 +509,8 @@ def _get_triggered_metric_type_display_name(
495509
if identifier:
496510
return f"Dynamic Feature ({identifier})"
497511
return "Dynamic Feature"
512+
case PreprodArtifactSizeMetrics.MetricsArtifactType.APP_CLIP_ARTIFACT:
513+
return "App Clip"
498514
case _:
499515
return ""
500516

src/sentry/preprod/vcs/status_checks/size/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class RuleArtifactType(StrEnum):
88
MAIN_ARTIFACT = "main_artifact"
99
WATCH_ARTIFACT = "watch_artifact"
1010
ANDROID_DYNAMIC_FEATURE_ARTIFACT = "android_dynamic_feature_artifact"
11+
APP_CLIP_ARTIFACT = "app_clip_artifact"
1112
ALL_ARTIFACTS = "all_artifacts"
1213

1314
@classmethod

tests/sentry/preprod/test_models_size_metrics.py

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,15 @@ def test_get_size_metrics_filtering(self):
2626
metrics_type=PreprodArtifactSizeMetrics.MetricsArtifactType.ANDROID_DYNAMIC_FEATURE,
2727
identifier="test_feature",
2828
)
29+
app_clip_metrics = self.create_preprod_artifact_size_metrics(
30+
artifact,
31+
metrics_type=PreprodArtifactSizeMetrics.MetricsArtifactType.APP_CLIP_ARTIFACT,
32+
identifier="test_app_clip",
33+
)
2934

3035
# Test getting all metrics (no filters)
3136
all_metrics = artifact.get_size_metrics()
32-
assert all_metrics.count() == 3
37+
assert all_metrics.count() == 4
3338

3439
# Test filtering by metrics type
3540
main_only = artifact.get_size_metrics(
@@ -65,6 +70,15 @@ def test_get_size_metrics_filtering(self):
6570
assert feature_typed_first is not None
6671
assert feature_typed_first.id == feature_metrics.id
6772

73+
app_clip_only = artifact.get_size_metrics(
74+
metrics_artifact_type=PreprodArtifactSizeMetrics.MetricsArtifactType.APP_CLIP_ARTIFACT,
75+
identifier="test_app_clip",
76+
)
77+
assert app_clip_only.count() == 1
78+
app_clip_first = app_clip_only.first()
79+
assert app_clip_first is not None
80+
assert app_clip_first.id == app_clip_metrics.id
81+
6882
# Test no matches
6983
no_matches = artifact.get_size_metrics(identifier="nonexistent")
7084
assert no_matches.count() == 0
@@ -82,6 +96,11 @@ def test_get_size_metrics_for_artifacts_bulk(self):
8296
artifact1,
8397
metrics_type=PreprodArtifactSizeMetrics.MetricsArtifactType.WATCH_ARTIFACT,
8498
)
99+
artifact1_app_clip = self.create_preprod_artifact_size_metrics(
100+
artifact1,
101+
metrics_type=PreprodArtifactSizeMetrics.MetricsArtifactType.APP_CLIP_ARTIFACT,
102+
identifier="clip.one",
103+
)
85104
artifact2_main = self.create_preprod_artifact_size_metrics(
86105
artifact2,
87106
metrics_type=PreprodArtifactSizeMetrics.MetricsArtifactType.MAIN_ARTIFACT,
@@ -92,7 +111,7 @@ def test_get_size_metrics_for_artifacts_bulk(self):
92111

93112
assert artifact1.id in results
94113
assert artifact2.id in results
95-
assert results[artifact1.id].count() == 2 # main + watch
114+
assert results[artifact1.id].count() == 3 # main + watch + app clip
96115
assert results[artifact2.id].count() == 1 # main only
97116

98117
# Test bulk retrieval with type filter (should get only main metrics)
@@ -122,6 +141,18 @@ def test_get_size_metrics_for_artifacts_bulk(self):
122141
assert artifact1_watch_first is not None
123142
assert artifact1_watch_first.id == artifact1_watch.id
124143

144+
app_clip_results = PreprodArtifact.get_size_metrics_for_artifacts(
145+
[artifact1, artifact2],
146+
metrics_artifact_type=PreprodArtifactSizeMetrics.MetricsArtifactType.APP_CLIP_ARTIFACT,
147+
identifier="clip.one",
148+
)
149+
150+
assert app_clip_results[artifact1.id].count() == 1
151+
assert app_clip_results[artifact2.id].count() == 0
152+
artifact1_app_clip_first = app_clip_results[artifact1.id].first()
153+
assert artifact1_app_clip_first is not None
154+
assert artifact1_app_clip_first.id == artifact1_app_clip.id
155+
125156
# Test with empty list
126157
empty_results = PreprodArtifact.get_size_metrics_for_artifacts([])
127158
assert empty_results == {}
@@ -144,6 +175,11 @@ def test_get_size_metrics_ignores_other_artifacts(self):
144175
metrics_type=PreprodArtifactSizeMetrics.MetricsArtifactType.ANDROID_DYNAMIC_FEATURE,
145176
identifier="feature_a",
146177
)
178+
artifact1_app_clip = self.create_preprod_artifact_size_metrics(
179+
artifact1,
180+
metrics_type=PreprodArtifactSizeMetrics.MetricsArtifactType.APP_CLIP_ARTIFACT,
181+
identifier="clip_a",
182+
)
147183

148184
artifact2_main = self.create_preprod_artifact_size_metrics(
149185
artifact2,
@@ -158,22 +194,37 @@ def test_get_size_metrics_ignores_other_artifacts(self):
158194
metrics_type=PreprodArtifactSizeMetrics.MetricsArtifactType.ANDROID_DYNAMIC_FEATURE,
159195
identifier="feature_a", # Same identifier as artifact1 but different artifact
160196
)
197+
artifact2_app_clip = self.create_preprod_artifact_size_metrics(
198+
artifact2,
199+
metrics_type=PreprodArtifactSizeMetrics.MetricsArtifactType.APP_CLIP_ARTIFACT,
200+
identifier="clip_a",
201+
)
161202

162203
# Test artifact1's metrics - should only get artifact1 metrics, not artifact2
163204
artifact1_metrics = artifact1.get_size_metrics()
164-
assert artifact1_metrics.count() == 3
205+
assert artifact1_metrics.count() == 4
165206

166207
artifact1_ids = {m.id for m in artifact1_metrics}
167-
expected_artifact1_ids = {artifact1_main.id, artifact1_watch.id, artifact1_feature.id}
208+
expected_artifact1_ids = {
209+
artifact1_main.id,
210+
artifact1_watch.id,
211+
artifact1_feature.id,
212+
artifact1_app_clip.id,
213+
}
168214
assert artifact1_ids == expected_artifact1_ids
169215

170216
# Ensure none of artifact2's metrics are included
171-
artifact2_ids = {artifact2_main.id, artifact2_watch.id, artifact2_feature.id}
217+
artifact2_ids = {
218+
artifact2_main.id,
219+
artifact2_watch.id,
220+
artifact2_feature.id,
221+
artifact2_app_clip.id,
222+
}
172223
assert artifact1_ids.isdisjoint(artifact2_ids)
173224

174225
# Test artifact2's metrics - should only get artifact2 metrics, not artifact1
175226
artifact2_metrics = artifact2.get_size_metrics()
176-
assert artifact2_metrics.count() == 3
227+
assert artifact2_metrics.count() == 4
177228

178229
artifact2_result_ids = {m.id for m in artifact2_metrics}
179230
assert artifact2_result_ids == artifact2_ids

tests/sentry/preprod/test_tasks.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,37 @@ def test_assemble_preprod_artifact_size_analysis_multiple_components(self) -> No
911911
assert watch_metrics.max_download_size == 2000
912912
assert watch_metrics.max_install_size == 4000
913913

914+
def test_assemble_preprod_artifact_size_analysis_app_clip_component(self) -> None:
915+
status, details = self._run_task_and_verify_status(
916+
b'{"analysis_duration": 2.5, "download_size": 5000, "install_size": 10000, "treemap": null, "analysis_version": "1.0", "app_components": [{"component_type": 0, "name": "Main App", "app_id": "com.example.app", "path": "/", "download_size": 3000, "install_size": 6000}, {"component_type": 3, "name": "App Clip", "app_id": "com.example.app.clip", "path": "/AppClips/AppClip.app", "download_size": 2000, "install_size": 4000}]}'
917+
)
918+
919+
assert status == ChunkFileState.OK
920+
assert details is None
921+
922+
all_size_metrics = PreprodArtifactSizeMetrics.objects.filter(
923+
preprod_artifact=self.preprod_artifact
924+
).order_by("metrics_artifact_type")
925+
assert len(all_size_metrics) == 2
926+
927+
main_metrics = all_size_metrics[0]
928+
assert (
929+
main_metrics.metrics_artifact_type
930+
== PreprodArtifactSizeMetrics.MetricsArtifactType.MAIN_ARTIFACT
931+
)
932+
assert main_metrics.identifier is None
933+
assert main_metrics.max_download_size == 3000
934+
assert main_metrics.max_install_size == 6000
935+
936+
app_clip_metrics = all_size_metrics[1]
937+
assert (
938+
app_clip_metrics.metrics_artifact_type
939+
== PreprodArtifactSizeMetrics.MetricsArtifactType.APP_CLIP_ARTIFACT
940+
)
941+
assert app_clip_metrics.identifier == "com.example.app.clip"
942+
assert app_clip_metrics.max_download_size == 2000
943+
assert app_clip_metrics.max_install_size == 4000
944+
914945
def test_assemble_preprod_artifact_size_analysis_removes_stale_metrics(self) -> None:
915946
status, details = self._run_task_and_verify_status(
916947
b'{"analysis_duration": 2.5, "download_size": 5000, "install_size": 10000, "treemap": null, "analysis_version": "1.0", "app_components": [{"component_type": 0, "name": "Main App", "app_id": "com.example.app", "path": "/", "download_size": 3000, "install_size": 6000}, {"component_type": 1, "name": "Watch App", "app_id": "com.example.app.watchkitapp", "path": "/Watch", "download_size": 2000, "install_size": 4000}]}'

tests/sentry/preprod/vcs/status_checks/size/test_metric_selection_helpers.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ def test_get_candidate_metrics_for_rule_main_artifact(self) -> None:
5050
PreprodArtifactSizeMetrics.MetricsArtifactType.ANDROID_DYNAMIC_FEATURE,
5151
identifier="feature.alpha",
5252
)
53+
self._create_metric(
54+
PreprodArtifactSizeMetrics.MetricsArtifactType.APP_CLIP_ARTIFACT,
55+
identifier="com.example.app.clip",
56+
)
5357

5458
rule = StatusCheckRule(
5559
id="rule-main",
@@ -74,6 +78,10 @@ def test_get_candidate_metrics_for_rule_all_artifacts_sorted(self) -> None:
7478
PreprodArtifactSizeMetrics.MetricsArtifactType.ANDROID_DYNAMIC_FEATURE,
7579
identifier="dynamic.a",
7680
)
81+
app_clip_a = self._create_metric(
82+
PreprodArtifactSizeMetrics.MetricsArtifactType.APP_CLIP_ARTIFACT,
83+
identifier="clip.a",
84+
)
7785
main = self._create_metric(PreprodArtifactSizeMetrics.MetricsArtifactType.MAIN_ARTIFACT)
7886
watch_a = self._create_metric(
7987
PreprodArtifactSizeMetrics.MetricsArtifactType.WATCH_ARTIFACT,
@@ -88,9 +96,11 @@ def test_get_candidate_metrics_for_rule_all_artifacts_sorted(self) -> None:
8896
artifact_type=RuleArtifactType.ALL_ARTIFACTS,
8997
)
9098

91-
candidates = _get_candidate_metrics_for_rule(rule, [watch_b, dynamic_a, main, watch_a])
99+
candidates = _get_candidate_metrics_for_rule(
100+
rule, [watch_b, dynamic_a, app_clip_a, main, watch_a]
101+
)
92102

93-
assert candidates == [main, watch_a, watch_b, dynamic_a]
103+
assert candidates == [main, watch_a, watch_b, dynamic_a, app_clip_a]
94104

95105
def test_get_matching_base_metric_matches_type_and_identifier(self) -> None:
96106
base_main = self._create_metric(
@@ -115,6 +125,27 @@ def test_get_matching_base_metric_matches_type_and_identifier(self) -> None:
115125

116126
assert matched == base_watch
117127

128+
def test_get_candidate_metrics_for_rule_app_clip_artifact(self) -> None:
129+
app_clip = self._create_metric(
130+
PreprodArtifactSizeMetrics.MetricsArtifactType.APP_CLIP_ARTIFACT,
131+
identifier="com.example.app.clip",
132+
)
133+
self._create_metric(PreprodArtifactSizeMetrics.MetricsArtifactType.MAIN_ARTIFACT)
134+
135+
rule = StatusCheckRule(
136+
id="rule-app-clip",
137+
metric="install_size",
138+
measurement="absolute",
139+
value=1000,
140+
artifact_type=RuleArtifactType.APP_CLIP_ARTIFACT,
141+
)
142+
143+
candidates = _get_candidate_metrics_for_rule(
144+
rule, list(PreprodArtifactSizeMetrics.objects.filter(preprod_artifact=self.artifact))
145+
)
146+
147+
assert candidates == [app_clip]
148+
118149
def test_get_matching_base_metric_returns_none_when_identifier_differs(self) -> None:
119150
base_watch = self._create_metric(
120151
PreprodArtifactSizeMetrics.MetricsArtifactType.WATCH_ARTIFACT,

0 commit comments

Comments
 (0)