Skip to content

Commit 56a4b7b

Browse files
yashikakhuranaYashika Khuranafreshstrangemusic
authored
feat(nimbus): fetch and store enrollment funnel data alongside monitoring alerts (#15735)
Because - we want to fetch the enrollment funnel data to show users the funnel enrollment/unenerollment related data This commit - Adds `get_enrollment_funnel_data()` to `jetstream/client.py` to load `enrollment_funnel_v1_latest.json` from GCS - Extends `fetch_monitoring_data()` to fetch funnel data and merge it into `NimbusExperiment.monitoring_data` under the `enrollment_funnel` key alongside existing alert data Fixes #15519 --------- Co-authored-by: Yashika Khurana <yashikakhurana@Yashikas-MacBook-Pro.local> Co-authored-by: Beth Rennie <beth@brennie.ca>
1 parent 6e8d2e9 commit 56a4b7b

3 files changed

Lines changed: 156 additions & 12 deletions

File tree

experimenter/experimenter/jetstream/client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ def get_monitoring_data():
105105
return load_data_from_gcs(str(path))
106106

107107

108+
def get_enrollment_funnel_data():
109+
filename = "enrollment_funnel_v1_latest.json"
110+
path = Path(ENROLLMENT_COUNTS_FOLDER, filename)
111+
return load_data_from_gcs(str(path))
112+
113+
108114
def get_results_metrics_map(
109115
data: JetstreamData,
110116
primary_outcome_slugs: list[str],

experimenter/experimenter/jetstream/tasks.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from experimenter.experiments.constants import NimbusConstants
1212
from experimenter.experiments.models import NimbusChangeLog, NimbusExperiment
1313
from experimenter.jetstream.client import (
14+
get_enrollment_funnel_data,
1415
get_experiment_data,
1516
get_monitoring_data,
1617
get_population_sizing_data,
@@ -144,6 +145,14 @@ def fetch_monitoring_data():
144145
return
145146

146147
alert_data = data.get("v1")
148+
149+
try:
150+
funnel_data = get_enrollment_funnel_data()
151+
funnel_by_slug = funnel_data.get("v1", {}) if funnel_data else {}
152+
except Exception as e:
153+
logger.warning(f"Could not fetch enrollment funnel data: {e}")
154+
funnel_by_slug = {}
155+
147156
updated_count = 0
148157

149158
for exp_slug, monitoring_data in alert_data.items():
@@ -153,11 +162,17 @@ def fetch_monitoring_data():
153162
status=NimbusConstants.Status.LIVE,
154163
)
155164

156-
# Only update if data has changed
157-
if experiment.monitoring_data != monitoring_data:
158-
experiment.monitoring_data = monitoring_data
165+
merged = {
166+
**monitoring_data,
167+
"enrollment_funnel": funnel_by_slug.get(exp_slug, []),
168+
}
169+
170+
if experiment.monitoring_data != merged:
171+
experiment.monitoring_data = merged
159172
experiment.monitoring_data_updated_at = timezone.now()
160-
experiment.save()
173+
experiment.save(
174+
update_fields=["monitoring_data", "monitoring_data_updated_at"]
175+
)
161176
generate_nimbus_changelog(
162177
experiment,
163178
get_kinto_user(),

experimenter/experimenter/jetstream/tests/test_tasks.py

Lines changed: 131 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
from experimenter.experiments.models import NimbusChangeLog, NimbusExperiment
1414
from experimenter.experiments.tests.factories import NimbusExperimentFactory
1515
from experimenter.jetstream import tasks
16-
from experimenter.jetstream.client import get_data, get_monitoring_data
16+
from experimenter.jetstream.client import (
17+
get_data,
18+
get_enrollment_funnel_data,
19+
get_monitoring_data,
20+
)
1721
from experimenter.jetstream.models import AnalysisWindow, Group
1822
from experimenter.jetstream.tests import mock_valid_outcomes
1923
from experimenter.jetstream.tests.constants import (
@@ -3441,14 +3445,48 @@ def mock_monitoring_data(request):
34413445
return data
34423446

34433447

3444-
@pytest.mark.usefixtures("mock_monitoring_data")
3448+
@pytest.fixture
3449+
def mock_funnel_entries(request):
3450+
data = [
3451+
{
3452+
"app_name": "firefox_desktop",
3453+
"branch": "control",
3454+
"status": "Enrolled",
3455+
"reason": "Qualified",
3456+
"conflict_slug": None,
3457+
"client_count": 750000,
3458+
},
3459+
{
3460+
"app_name": "firefox_desktop",
3461+
"branch": None,
3462+
"status": "NotEnrolled",
3463+
"reason": "NotTargeted",
3464+
"conflict_slug": None,
3465+
"client_count": 5000000,
3466+
},
3467+
]
3468+
if request.instance:
3469+
request.instance.funnel_entries = data
3470+
return data
3471+
3472+
3473+
@pytest.mark.usefixtures("mock_monitoring_data", "mock_funnel_entries")
34453474
class TestFetchMonitoringDataTask(TestCase):
34463475
def setUp(self):
34473476
super().setUp()
34483477
patcher = patch("experimenter.jetstream.tasks.get_monitoring_data")
34493478
self.mock_get_monitoring_data = patcher.start()
34503479
self.addCleanup(patcher.stop)
34513480

3481+
self._funnel_patcher = patch(
3482+
"experimenter.jetstream.tasks.get_enrollment_funnel_data"
3483+
)
3484+
self.mock_get_funnel_data = self._funnel_patcher.start()
3485+
self.mock_get_funnel_data.return_value = {"v1": {}}
3486+
3487+
def tearDown(self):
3488+
self._funnel_patcher.stop()
3489+
34523490
@parameterized.expand([(None,), ({},)])
34533491
def test_fetch_monitoring_data_no_data(self, return_value):
34543492
self.mock_get_monitoring_data.return_value = return_value
@@ -3469,9 +3507,62 @@ def test_fetch_monitoring_data_updates_live_experiment(self):
34693507
tasks.fetch_monitoring_data()
34703508

34713509
experiment.refresh_from_db()
3472-
self.assertEqual(experiment.monitoring_data, self.monitoring_data)
3510+
self.assertEqual(
3511+
experiment.monitoring_data,
3512+
{**self.monitoring_data, "enrollment_funnel": []},
3513+
)
34733514
self.assertIsNotNone(experiment.monitoring_data_updated_at)
34743515

3516+
def test_fetch_monitoring_data_merges_funnel_data(self):
3517+
experiment = NimbusExperimentFactory.create(
3518+
status=NimbusExperiment.Status.LIVE,
3519+
monitoring_data={},
3520+
)
3521+
self.mock_get_monitoring_data.return_value = {
3522+
"v1": {experiment.slug: self.monitoring_data}
3523+
}
3524+
self.mock_get_funnel_data.return_value = {
3525+
"v1": {experiment.slug: self.funnel_entries}
3526+
}
3527+
3528+
tasks.fetch_monitoring_data()
3529+
3530+
experiment.refresh_from_db()
3531+
self.assertEqual(
3532+
experiment.monitoring_data,
3533+
{**self.monitoring_data, "enrollment_funnel": self.funnel_entries},
3534+
)
3535+
3536+
def test_fetch_monitoring_data_funnel_defaults_to_empty_list_when_missing(self):
3537+
experiment = NimbusExperimentFactory.create(
3538+
status=NimbusExperiment.Status.LIVE,
3539+
monitoring_data={},
3540+
)
3541+
self.mock_get_monitoring_data.return_value = {
3542+
"v1": {experiment.slug: self.monitoring_data}
3543+
}
3544+
self.mock_get_funnel_data.return_value = {"v1": {}}
3545+
3546+
tasks.fetch_monitoring_data()
3547+
3548+
experiment.refresh_from_db()
3549+
self.assertEqual(experiment.monitoring_data["enrollment_funnel"], [])
3550+
3551+
def test_fetch_monitoring_data_continues_if_funnel_fetch_fails(self):
3552+
experiment = NimbusExperimentFactory.create(
3553+
status=NimbusExperiment.Status.LIVE,
3554+
monitoring_data={},
3555+
)
3556+
self.mock_get_monitoring_data.return_value = {
3557+
"v1": {experiment.slug: self.monitoring_data}
3558+
}
3559+
self.mock_get_funnel_data.side_effect = Exception("GCS unavailable")
3560+
3561+
tasks.fetch_monitoring_data()
3562+
3563+
experiment.refresh_from_db()
3564+
self.assertEqual(experiment.monitoring_data["enrollment_funnel"], [])
3565+
34753566
@parameterized.expand(
34763567
[
34773568
(NimbusExperiment.Status.DRAFT,),
@@ -3558,8 +3649,8 @@ def test_fetch_monitoring_data_updates_multiple_experiments(self):
35583649

35593650
exp1.refresh_from_db()
35603651
exp2.refresh_from_db()
3561-
self.assertEqual(exp1.monitoring_data, data1)
3562-
self.assertEqual(exp2.monitoring_data, data2)
3652+
self.assertEqual(exp1.monitoring_data, {**data1, "enrollment_funnel": []})
3653+
self.assertEqual(exp2.monitoring_data, {**data2, "enrollment_funnel": []})
35633654

35643655
def test_fetch_monitoring_data_fatal_error(self):
35653656
self.mock_get_monitoring_data.side_effect = Exception("GCS connection failed")
@@ -3586,7 +3677,9 @@ def test_get_monitoring_data_success(self, mock_load):
35863677
result = get_monitoring_data()
35873678

35883679
self.assertEqual(result, data)
3589-
mock_load.assert_called_once()
3680+
mock_load.assert_called_once_with(
3681+
"enrollment_counts/enrollment_counts_latest.json"
3682+
)
35903683

35913684
@patch("experimenter.jetstream.client.load_data_from_gcs")
35923685
def test_get_monitoring_data_no_data(self, mock_load):
@@ -3598,7 +3691,37 @@ def test_get_monitoring_data_no_data(self, mock_load):
35983691

35993692
@patch("experimenter.jetstream.client.load_data_from_gcs")
36003693
def test_get_monitoring_data_exception(self, mock_load):
3601-
mock_load.side_effect = Exception("GCS error")
3694+
mock_load.side_effect = RuntimeError("GCS error")
36023695

3603-
with self.assertRaises(Exception):
3696+
with self.assertRaises(RuntimeError):
36043697
get_monitoring_data()
3698+
3699+
3700+
@pytest.mark.usefixtures("mock_funnel_entries")
3701+
class TestGetEnrollmentFunnelData(TestCase):
3702+
@patch("experimenter.jetstream.client.load_data_from_gcs")
3703+
def test_get_enrollment_funnel_data_success(self, mock_load):
3704+
data = {"v1": {"experiment-1": self.funnel_entries}}
3705+
mock_load.return_value = data
3706+
3707+
result = get_enrollment_funnel_data()
3708+
3709+
self.assertEqual(result, data)
3710+
mock_load.assert_called_once_with(
3711+
"enrollment_counts/enrollment_funnel_v1_latest.json"
3712+
)
3713+
3714+
@patch("experimenter.jetstream.client.load_data_from_gcs")
3715+
def test_get_enrollment_funnel_data_no_data(self, mock_load):
3716+
mock_load.return_value = None
3717+
3718+
result = get_enrollment_funnel_data()
3719+
3720+
self.assertIsNone(result)
3721+
3722+
@patch("experimenter.jetstream.client.load_data_from_gcs")
3723+
def test_get_enrollment_funnel_data_exception(self, mock_load):
3724+
mock_load.side_effect = RuntimeError("GCS error")
3725+
3726+
with self.assertRaises(RuntimeError):
3727+
get_enrollment_funnel_data()

0 commit comments

Comments
 (0)