Skip to content

Commit d79ed7e

Browse files
authored
feat(experimentation): expose experiment rollout in API responses (#7858)
1 parent c983f85 commit d79ed7e

6 files changed

Lines changed: 200 additions & 7 deletions

File tree

api/experimentation/serializers.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@
1919
WarehouseConnection,
2020
WarehouseType,
2121
)
22-
from experimentation.services import apply_experiment_rollout
22+
from experimentation.services import (
23+
apply_experiment_rollout,
24+
get_experiment_rollout,
25+
)
2326
from experimentation.types import (
2427
SNOWFLAKE_DEFAULTS,
2528
MetricExperimentResult,
@@ -251,7 +254,9 @@ class ExperimentSerializer(serializers.ModelSerializer): # type: ignore[type-ar
251254
required=False,
252255
write_only=True,
253256
)
254-
experiment_rollout = ExperimentRolloutSerializer(required=False, write_only=True)
257+
experiment_rollout: Any = ExperimentRolloutSerializer(
258+
required=False, write_only=True
259+
)
255260

256261
class Meta:
257262
model = Experiment
@@ -395,6 +400,13 @@ class ExperimentListSerializer(ExperimentSerializer):
395400
)
396401

397402

403+
class ExperimentDetailSerializer(ExperimentListSerializer):
404+
experiment_rollout = serializers.SerializerMethodField()
405+
406+
def get_experiment_rollout(self, experiment: Experiment) -> dict[str, Any] | None:
407+
return get_experiment_rollout(experiment)
408+
409+
398410
class ExperimentExposuresSerializer(serializers.ModelSerializer): # type: ignore[type-arg]
399411
is_final = serializers.BooleanField(read_only=True)
400412

api/experimentation/services.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,14 @@
5454
srm_p_value,
5555
)
5656
from features.models import FeatureState
57+
from features.value_types import BOOLEAN, INTEGER, STRING
5758
from features.versioning.dataclasses import FlagChangeSet
5859
from features.versioning.versioning_service import update_flag
5960
from integrations.flagsmith.client import get_openfeature_client
6061
from segments.models import Condition, Segment, SegmentRule
6162

63+
_ROLLOUT_VALUE_TYPE = {INTEGER: "integer", STRING: "string", BOOLEAN: "boolean"}
64+
6265
if typing.TYPE_CHECKING:
6366
from collections.abc import Sequence
6467
from datetime import datetime
@@ -592,6 +595,42 @@ def apply_experiment_rollout(experiment: Experiment, spec: RolloutSpec) -> None:
592595
)
593596

594597

598+
def get_experiment_rollout(experiment: Experiment) -> dict[str, typing.Any] | None:
599+
segment_id = experiment.rollout_segment_id
600+
if segment_id is None:
601+
return None
602+
603+
feature_state = FeatureState.objects.get_live_feature_states(
604+
environment=experiment.environment,
605+
additional_filters=Q(
606+
feature_segment__segment_id=segment_id, identity__isnull=True
607+
),
608+
feature_id=experiment.feature_id,
609+
).latest("id")
610+
611+
condition = Condition.objects.get(
612+
rule__segment_id=segment_id, operator=PERCENTAGE_SPLIT
613+
)
614+
value = feature_state.feature_state_value
615+
return {
616+
"enabled": feature_state.enabled,
617+
"rollout_percentage": float(condition.value or 0),
618+
"feature_state_value": {
619+
"type": _ROLLOUT_VALUE_TYPE.get(value.type or STRING, "string"),
620+
"value": (
621+
str(value.value).lower() if value.type == BOOLEAN else str(value.value)
622+
),
623+
},
624+
"multivariate_feature_state_values": [
625+
{
626+
"multivariate_feature_option": mv.multivariate_feature_option_id,
627+
"percentage_allocation": mv.percentage_allocation,
628+
}
629+
for mv in feature_state.multivariate_feature_state_values.all()
630+
],
631+
}
632+
633+
595634
def mark_warehouse_pending_connection(
596635
connection: WarehouseConnection,
597636
) -> WarehouseConnection:

api/experimentation/views.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
WarehouseConnectionPermission,
3939
)
4040
from experimentation.serializers import (
41+
ExperimentDetailSerializer,
4142
ExperimentExposuresSerializer,
4243
ExperimentListSerializer,
4344
ExperimentMetricSerializer,
@@ -178,7 +179,9 @@ def get_serializer_context(self) -> dict[str, Any]:
178179
return context
179180

180181
def get_serializer_class(self) -> type[BaseSerializer[Experiment]]:
181-
if self.action in ("list", "retrieve", "start", "pause", "complete", "rollout"):
182+
if self.action == "retrieve":
183+
return ExperimentDetailSerializer
184+
if self.action in ("list", "start", "pause", "complete", "rollout"):
182185
return ExperimentListSerializer
183186
return ExperimentSerializer
184187

api/tests/unit/experimentation/test_experiment_views.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2013,3 +2013,46 @@ def test_patch__experiment_rollout_on_update__returns_400(
20132013
# Then
20142014
assert response.status_code == status.HTTP_400_BAD_REQUEST
20152015
assert "Cannot change the rollout" in str(response.json())
2016+
2017+
2018+
def test_get_detail__with_rollout__returns_rollout(
2019+
admin_client_new: APIClient,
2020+
environment: Environment,
2021+
experiment_with_rollout: Experiment,
2022+
multivariate_options: list[MultivariateFeatureOption],
2023+
enable_features: EnableFeaturesFixture,
2024+
) -> None:
2025+
# Given
2026+
enable_features(EXPERIMENT_FLAG)
2027+
option_a, option_b, _ = multivariate_options
2028+
2029+
# When
2030+
response = admin_client_new.get(_detail_url(environment, experiment_with_rollout))
2031+
2032+
# Then
2033+
assert response.status_code == status.HTTP_200_OK
2034+
rollout = response.json()["experiment_rollout"]
2035+
assert rollout["enabled"] is True
2036+
assert rollout["rollout_percentage"] == 20.0
2037+
assert rollout["feature_state_value"] == {"type": "string", "value": "control"}
2038+
assert {
2039+
(mv["multivariate_feature_option"], mv["percentage_allocation"])
2040+
for mv in rollout["multivariate_feature_state_values"]
2041+
} == {(option_a.id, 50.0), (option_b.id, 50.0)}
2042+
2043+
2044+
def test_get_detail__without_rollout__returns_null(
2045+
admin_client_new: APIClient,
2046+
environment: Environment,
2047+
experiment: Experiment,
2048+
enable_features: EnableFeaturesFixture,
2049+
) -> None:
2050+
# Given
2051+
enable_features(EXPERIMENT_FLAG)
2052+
2053+
# When
2054+
response = admin_client_new.get(_detail_url(environment, experiment))
2055+
2056+
# Then
2057+
assert response.status_code == status.HTTP_200_OK
2058+
assert response.json()["experiment_rollout"] is None

api/tests/unit/experimentation/test_services.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1478,3 +1478,99 @@ def test_apply_experiment_rollout__update_flag_fails__rolls_back(
14781478
rule__segment=experiment.rollout_segment, operator=PERCENTAGE_SPLIT
14791479
)
14801480
assert condition.value == "20.0"
1481+
1482+
1483+
def test_get_experiment_rollout__rollout_exists__returns_representation(
1484+
experiment_with_rollout: Experiment,
1485+
multivariate_options: list[MultivariateFeatureOption],
1486+
) -> None:
1487+
# Given a rollout (20%, options split 50/50, value "control") from the fixture
1488+
option_a, option_b, _ = multivariate_options
1489+
1490+
# When
1491+
rollout = services.get_experiment_rollout(experiment_with_rollout)
1492+
1493+
# Then
1494+
assert rollout is not None
1495+
assert rollout["enabled"] is True
1496+
assert rollout["rollout_percentage"] == 20.0
1497+
assert rollout["feature_state_value"] == {"type": "string", "value": "control"}
1498+
assert {
1499+
(mv["multivariate_feature_option"], mv["percentage_allocation"])
1500+
for mv in rollout["multivariate_feature_state_values"]
1501+
} == {(option_a.id, 50.0), (option_b.id, 50.0)}
1502+
1503+
1504+
def test_get_experiment_rollout__no_rollout__returns_none(
1505+
experiment: Experiment,
1506+
) -> None:
1507+
# Given an experiment without a rollout
1508+
# When / Then
1509+
assert services.get_experiment_rollout(experiment) is None
1510+
1511+
1512+
def test_get_experiment_rollout__v2_versioning__returns_representation(
1513+
environment_v2_versioning: Environment,
1514+
multivariate_feature: Feature,
1515+
multivariate_options: list[MultivariateFeatureOption],
1516+
admin_user: FFAdminUser,
1517+
) -> None:
1518+
# Given a rollout on a v2 environment
1519+
option_a, option_b, _ = multivariate_options
1520+
experiment = Experiment.objects.create(
1521+
environment=environment_v2_versioning,
1522+
feature=multivariate_feature,
1523+
name="exp",
1524+
hypothesis="h",
1525+
status=ExperimentStatus.CREATED,
1526+
)
1527+
services.apply_experiment_rollout(
1528+
experiment,
1529+
RolloutSpec(
1530+
enabled=True,
1531+
rollout_percentage=30.0,
1532+
feature_state_value="control",
1533+
value_type="string",
1534+
multivariate_values=[
1535+
MultivariateValueChangeSet(option_a.id, 60.0),
1536+
MultivariateValueChangeSet(option_b.id, 40.0),
1537+
],
1538+
author=AuthorData(user=admin_user),
1539+
),
1540+
)
1541+
1542+
# When
1543+
rollout = services.get_experiment_rollout(experiment)
1544+
1545+
# Then
1546+
assert rollout is not None
1547+
assert rollout["rollout_percentage"] == 30.0
1548+
assert {
1549+
(mv["multivariate_feature_option"], mv["percentage_allocation"])
1550+
for mv in rollout["multivariate_feature_state_values"]
1551+
} == {(option_a.id, 60.0), (option_b.id, 40.0)}
1552+
1553+
1554+
def test_get_experiment_rollout__boolean_value__returns_lowercase_string(
1555+
experiment: Experiment,
1556+
admin_user: FFAdminUser,
1557+
) -> None:
1558+
# Given
1559+
services.apply_experiment_rollout(
1560+
experiment,
1561+
RolloutSpec(
1562+
enabled=True,
1563+
rollout_percentage=20.0,
1564+
feature_state_value="true",
1565+
value_type="boolean",
1566+
multivariate_values=[],
1567+
author=AuthorData(user=admin_user),
1568+
),
1569+
)
1570+
1571+
# When
1572+
rollout = services.get_experiment_rollout(experiment)
1573+
1574+
# Then
1575+
assert rollout is not None
1576+
assert rollout["feature_state_value"] == {"type": "boolean", "value": "true"}

docs/docs/deployment-self-hosting/observability/_events-catalogue.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,7 @@ Attributes:
494494
### `warehouse.connection.connected`
495495

496496
Logged at `info` from:
497-
- `api/experimentation/services.py:625`
497+
- `api/experimentation/services.py:664`
498498

499499
Attributes:
500500
- `environment.id`
@@ -503,7 +503,7 @@ Attributes:
503503
### `warehouse.connection.test_event_sent`
504504

505505
Logged at `info` from:
506-
- `api/experimentation/services.py:605`
506+
- `api/experimentation/services.py:644`
507507

508508
Attributes:
509509
- `environment.id`
@@ -512,7 +512,7 @@ Attributes:
512512
### `warehouse.srm.overallocated`
513513

514514
Logged at `error` from:
515-
- `api/experimentation/services.py:388`
515+
- `api/experimentation/services.py:391`
516516

517517
Attributes:
518518
- `environment.id`
@@ -522,7 +522,7 @@ Attributes:
522522
### `warehouse.srm.unkeyed_variant`
523523

524524
Logged at `error` from:
525-
- `api/experimentation/services.py:374`
525+
- `api/experimentation/services.py:377`
526526

527527
Attributes:
528528
- `environment.id`

0 commit comments

Comments
 (0)