Skip to content

Commit 23dd005

Browse files
mp-hoggithub-actions[bot]
authored andcommitted
refactor(experiments): Move pausing/resuming experiments to API (#52051)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 6fc291b commit 23dd005

File tree

9 files changed

+546
-99
lines changed

9 files changed

+546
-99
lines changed

ee/clickhouse/views/experiments.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,45 @@ def archive(self, request: Request, *args: Any, **kwargs: Any) -> Response:
322322
archived_experiment = service.archive_experiment(experiment, request=request)
323323
return Response(ExperimentSerializer(archived_experiment, context=self.get_serializer_context()).data)
324324

325+
@extend_schema(
326+
request=None,
327+
responses=ExperimentSerializer,
328+
)
329+
@action(methods=["POST"], detail=True, required_scopes=["experiment:write"])
330+
def pause(self, request: Request, *args: Any, **kwargs: Any) -> Response:
331+
"""
332+
Pause a running experiment.
333+
334+
Deactivates the linked feature flag so it is no longer returned by the
335+
/decide endpoint. Users fall back to the application default (typically
336+
the control experience), and no new exposure events are recorded (i.e.
337+
$feature_flag_called is not fired).
338+
Returns 400 if the experiment is not running or is already paused.
339+
"""
340+
experiment: Experiment = self.get_object()
341+
service = ExperimentService(team=self.team, user=request.user)
342+
paused_experiment = service.pause_experiment(experiment, request=request)
343+
return Response(ExperimentSerializer(paused_experiment, context=self.get_serializer_context()).data)
344+
345+
@extend_schema(
346+
request=None,
347+
responses=ExperimentSerializer,
348+
)
349+
@action(methods=["POST"], detail=True, required_scopes=["experiment:write"])
350+
def resume(self, request: Request, *args: Any, **kwargs: Any) -> Response:
351+
"""
352+
Resume a paused experiment.
353+
354+
Reactivates the linked feature flag so it is returned by /decide again.
355+
Users are re-bucketed deterministically into the same variants they had
356+
before the pause, and exposure tracking resumes.
357+
Returns 400 if the experiment is not running or is not paused.
358+
"""
359+
experiment: Experiment = self.get_object()
360+
service = ExperimentService(team=self.team, user=request.user)
361+
resumed_experiment = service.resume_experiment(experiment, request=request)
362+
return Response(ExperimentSerializer(resumed_experiment, context=self.get_serializer_context()).data)
363+
325364
@action(methods=["POST"], detail=True, required_scopes=["experiment:write"])
326365
def duplicate(self, request: Request, *args: Any, **kwargs: Any) -> Response:
327366
source_experiment: Experiment = self.get_object()

ee/clickhouse/views/test/test_clickhouse_experiments.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3658,6 +3658,128 @@ def test_archive_experiment_endpoint_not_ended(self):
36583658
)
36593659
self.assertEqual(archive_response.status_code, status.HTTP_400_BAD_REQUEST)
36603660

3661+
def _create_running_experiment(self, name: str = "Running Test", flag_key: str = "running-flag") -> dict:
3662+
"""Helper: create an experiment and launch it via the API."""
3663+
response = self.client.post(
3664+
f"/api/projects/{self.team.id}/experiments/",
3665+
{
3666+
"name": name,
3667+
"feature_flag_key": flag_key,
3668+
"metrics": [
3669+
{
3670+
"kind": "ExperimentMetric",
3671+
"metric_type": "mean",
3672+
"source": {"kind": "EventsNode", "event": "$pageview"},
3673+
}
3674+
],
3675+
},
3676+
format="json",
3677+
)
3678+
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
3679+
experiment_id = response.json()["id"]
3680+
3681+
launch_response = self.client.post(
3682+
f"/api/projects/{self.team.id}/experiments/{experiment_id}/launch/",
3683+
)
3684+
self.assertEqual(launch_response.status_code, status.HTTP_200_OK)
3685+
return launch_response.json()
3686+
3687+
def test_pause_experiment_endpoint(self):
3688+
data = self._create_running_experiment(name="Pause Endpoint", flag_key="pause-endpoint-flag")
3689+
experiment_id = data["id"]
3690+
3691+
# Flag should be active after launch
3692+
flag = FeatureFlag.objects.get(key="pause-endpoint-flag", team=self.team)
3693+
self.assertTrue(flag.active)
3694+
3695+
pause_response = self.client.post(
3696+
f"/api/projects/{self.team.id}/experiments/{experiment_id}/pause/",
3697+
)
3698+
self.assertEqual(pause_response.status_code, status.HTTP_200_OK)
3699+
self.assertEqual(pause_response.json()["status"], "running")
3700+
self.assertFalse(pause_response.json()["feature_flag"]["active"])
3701+
3702+
# Verify flag is now inactive
3703+
flag.refresh_from_db()
3704+
self.assertFalse(flag.active)
3705+
3706+
def test_resume_experiment_endpoint(self):
3707+
data = self._create_running_experiment(name="Resume Endpoint", flag_key="resume-endpoint-flag")
3708+
experiment_id = data["id"]
3709+
3710+
# Pause first
3711+
pause_response = self.client.post(
3712+
f"/api/projects/{self.team.id}/experiments/{experiment_id}/pause/",
3713+
)
3714+
self.assertEqual(pause_response.status_code, status.HTTP_200_OK)
3715+
3716+
# Resume
3717+
resume_response = self.client.post(
3718+
f"/api/projects/{self.team.id}/experiments/{experiment_id}/resume/",
3719+
)
3720+
self.assertEqual(resume_response.status_code, status.HTTP_200_OK)
3721+
self.assertEqual(resume_response.json()["status"], "running")
3722+
self.assertTrue(resume_response.json()["feature_flag"]["active"])
3723+
3724+
flag = FeatureFlag.objects.get(key="resume-endpoint-flag", team=self.team)
3725+
self.assertTrue(flag.active)
3726+
3727+
def test_pause_experiment_already_paused_returns_400(self):
3728+
data = self._create_running_experiment(name="Double Pause", flag_key="double-pause-flag")
3729+
experiment_id = data["id"]
3730+
3731+
self.client.post(f"/api/projects/{self.team.id}/experiments/{experiment_id}/pause/")
3732+
3733+
second_pause = self.client.post(
3734+
f"/api/projects/{self.team.id}/experiments/{experiment_id}/pause/",
3735+
)
3736+
self.assertEqual(second_pause.status_code, status.HTTP_400_BAD_REQUEST)
3737+
3738+
def test_resume_experiment_not_paused_returns_400(self):
3739+
data = self._create_running_experiment(name="Resume Not Paused", flag_key="resume-not-paused-flag")
3740+
experiment_id = data["id"]
3741+
3742+
resume_response = self.client.post(
3743+
f"/api/projects/{self.team.id}/experiments/{experiment_id}/resume/",
3744+
)
3745+
self.assertEqual(resume_response.status_code, status.HTTP_400_BAD_REQUEST)
3746+
3747+
def test_pause_draft_experiment_returns_400(self):
3748+
response = self.client.post(
3749+
f"/api/projects/{self.team.id}/experiments/",
3750+
{
3751+
"name": "Pause Draft",
3752+
"feature_flag_key": "pause-draft-flag",
3753+
},
3754+
format="json",
3755+
)
3756+
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
3757+
experiment_id = response.json()["id"]
3758+
3759+
pause_response = self.client.post(
3760+
f"/api/projects/{self.team.id}/experiments/{experiment_id}/pause/",
3761+
)
3762+
self.assertEqual(pause_response.status_code, status.HTTP_400_BAD_REQUEST)
3763+
3764+
def test_pause_ended_experiment_returns_400(self):
3765+
response = self.client.post(
3766+
f"/api/projects/{self.team.id}/experiments/",
3767+
{
3768+
"name": "Pause Ended",
3769+
"feature_flag_key": "pause-ended-flag",
3770+
"start_date": "2024-01-01T10:00",
3771+
"end_date": "2024-01-15T10:00",
3772+
},
3773+
format="json",
3774+
)
3775+
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
3776+
experiment_id = response.json()["id"]
3777+
3778+
pause_response = self.client.post(
3779+
f"/api/projects/{self.team.id}/experiments/{experiment_id}/pause/",
3780+
)
3781+
self.assertEqual(pause_response.status_code, status.HTTP_400_BAD_REQUEST)
3782+
36613783

36623784
class TestExperimentAuxiliaryEndpoints(ClickhouseTestMixin, APILicensedTest):
36633785
def _generate_experiment(self, start_date="2024-01-01T10:23", extra_parameters=None):

frontend/src/lib/utils/eventUsageLogic.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -477,8 +477,6 @@ export const eventUsageLogic = kea<eventUsageLogicType>([
477477
reportHelpButtonUsed: (help_type: HelpType) => ({ help_type }),
478478
reportExperimentWizardStarted: (guideVisible: boolean) => ({ guideVisible }),
479479
reportExperimentWizardGuideToggled: (visible: boolean, currentStep: string) => ({ visible, currentStep }),
480-
reportExperimentPaused: (experiment: Experiment) => ({ experiment }),
481-
reportExperimentResumed: (experiment: Experiment) => ({ experiment }),
482480
reportExperimentStopped: (experiment: Experiment) => ({ experiment }),
483481
reportExperimentReset: (experiment: Experiment) => ({ experiment }),
484482
reportExperimentCreated: (
@@ -1421,16 +1419,6 @@ export const eventUsageLogic = kea<eventUsageLogicType>([
14211419
})
14221420
}
14231421
},
1424-
reportExperimentPaused: ({ experiment }) => {
1425-
posthog.capture('experiment paused', {
1426-
...getEventPropertiesForExperiment(experiment),
1427-
})
1428-
},
1429-
reportExperimentResumed: ({ experiment }) => {
1430-
posthog.capture('experiment resumed', {
1431-
...getEventPropertiesForExperiment(experiment),
1432-
})
1433-
},
14341422
reportExperimentStopped: ({ experiment }) => {
14351423
posthog.capture('experiment stopped', {
14361424
...getEventPropertiesForExperiment(experiment),

0 commit comments

Comments
 (0)