Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions api/experimentation/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
WarehouseConnection,
WarehouseType,
)
from experimentation.services import apply_experiment_rollout
from experimentation.services import (
apply_experiment_rollout,
get_experiment_rollout,
)
from experimentation.types import (
SNOWFLAKE_DEFAULTS,
MetricExperimentResult,
Expand Down Expand Up @@ -251,7 +254,9 @@ class ExperimentSerializer(serializers.ModelSerializer): # type: ignore[type-ar
required=False,
write_only=True,
)
experiment_rollout = ExperimentRolloutSerializer(required=False, write_only=True)
experiment_rollout: Any = ExperimentRolloutSerializer(
required=False, write_only=True
)

class Meta:
model = Experiment
Expand Down Expand Up @@ -395,6 +400,13 @@ class ExperimentListSerializer(ExperimentSerializer):
)


class ExperimentDetailSerializer(ExperimentListSerializer):
experiment_rollout = serializers.SerializerMethodField()

def get_experiment_rollout(self, experiment: Experiment) -> dict[str, Any] | None:
Comment thread
gagantrivedi marked this conversation as resolved.
return get_experiment_rollout(experiment)


class ExperimentExposuresSerializer(serializers.ModelSerializer): # type: ignore[type-arg]
is_final = serializers.BooleanField(read_only=True)

Expand Down
39 changes: 39 additions & 0 deletions api/experimentation/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,14 @@
srm_p_value,
)
from features.models import FeatureState
from features.value_types import BOOLEAN, INTEGER, STRING
from features.versioning.dataclasses import FlagChangeSet
from features.versioning.versioning_service import update_flag
from integrations.flagsmith.client import get_openfeature_client
from segments.models import Condition, Segment, SegmentRule

_ROLLOUT_VALUE_TYPE = {INTEGER: "integer", STRING: "string", BOOLEAN: "boolean"}

if typing.TYPE_CHECKING:
from collections.abc import Sequence
from datetime import datetime
Expand Down Expand Up @@ -592,6 +595,42 @@ def apply_experiment_rollout(experiment: Experiment, spec: RolloutSpec) -> None:
)


def get_experiment_rollout(experiment: Experiment) -> dict[str, typing.Any] | None:
segment_id = experiment.rollout_segment_id
if segment_id is None:
return None

feature_state = FeatureState.objects.get_live_feature_states(
environment=experiment.environment,
additional_filters=Q(
feature_segment__segment_id=segment_id, identity__isnull=True
),
feature_id=experiment.feature_id,
).latest("id")

condition = Condition.objects.get(
rule__segment_id=segment_id, operator=PERCENTAGE_SPLIT
)
value = feature_state.feature_state_value
return {
"enabled": feature_state.enabled,
"rollout_percentage": float(condition.value or 0),
"feature_state_value": {
"type": _ROLLOUT_VALUE_TYPE.get(value.type or STRING, "string"),
"value": (
str(value.value).lower() if value.type == BOOLEAN else str(value.value)
),
},
"multivariate_feature_state_values": [
{
"multivariate_feature_option": mv.multivariate_feature_option_id,
"percentage_allocation": mv.percentage_allocation,
}
for mv in feature_state.multivariate_feature_state_values.all()
],
}


def mark_warehouse_pending_connection(
connection: WarehouseConnection,
) -> WarehouseConnection:
Expand Down
5 changes: 4 additions & 1 deletion api/experimentation/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
WarehouseConnectionPermission,
)
from experimentation.serializers import (
ExperimentDetailSerializer,
ExperimentExposuresSerializer,
ExperimentListSerializer,
ExperimentMetricSerializer,
Expand Down Expand Up @@ -178,7 +179,9 @@ def get_serializer_context(self) -> dict[str, Any]:
return context

def get_serializer_class(self) -> type[BaseSerializer[Experiment]]:
if self.action in ("list", "retrieve", "start", "pause", "complete", "rollout"):
if self.action == "retrieve":
return ExperimentDetailSerializer
if self.action in ("list", "start", "pause", "complete", "rollout"):
return ExperimentListSerializer
return ExperimentSerializer

Expand Down
43 changes: 43 additions & 0 deletions api/tests/unit/experimentation/test_experiment_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2013,3 +2013,46 @@ def test_patch__experiment_rollout_on_update__returns_400(
# Then
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "Cannot change the rollout" in str(response.json())


def test_get_detail__with_rollout__returns_rollout(
admin_client_new: APIClient,
environment: Environment,
experiment_with_rollout: Experiment,
multivariate_options: list[MultivariateFeatureOption],
enable_features: EnableFeaturesFixture,
) -> None:
# Given
enable_features(EXPERIMENT_FLAG)
option_a, option_b, _ = multivariate_options

# When
response = admin_client_new.get(_detail_url(environment, experiment_with_rollout))

# Then
assert response.status_code == status.HTTP_200_OK
rollout = response.json()["experiment_rollout"]
assert rollout["enabled"] is True
assert rollout["rollout_percentage"] == 20.0
assert rollout["feature_state_value"] == {"type": "string", "value": "control"}
assert {
(mv["multivariate_feature_option"], mv["percentage_allocation"])
for mv in rollout["multivariate_feature_state_values"]
} == {(option_a.id, 50.0), (option_b.id, 50.0)}


def test_get_detail__without_rollout__returns_null(
admin_client_new: APIClient,
environment: Environment,
experiment: Experiment,
enable_features: EnableFeaturesFixture,
) -> None:
# Given
enable_features(EXPERIMENT_FLAG)

# When
response = admin_client_new.get(_detail_url(environment, experiment))

# Then
assert response.status_code == status.HTTP_200_OK
assert response.json()["experiment_rollout"] is None
96 changes: 96 additions & 0 deletions api/tests/unit/experimentation/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -1478,3 +1478,99 @@ def test_apply_experiment_rollout__update_flag_fails__rolls_back(
rule__segment=experiment.rollout_segment, operator=PERCENTAGE_SPLIT
)
assert condition.value == "20.0"


def test_get_experiment_rollout__rollout_exists__returns_representation(
experiment_with_rollout: Experiment,
multivariate_options: list[MultivariateFeatureOption],
) -> None:
# Given a rollout (20%, options split 50/50, value "control") from the fixture
option_a, option_b, _ = multivariate_options

# When
rollout = services.get_experiment_rollout(experiment_with_rollout)

# Then
assert rollout is not None
assert rollout["enabled"] is True
assert rollout["rollout_percentage"] == 20.0
assert rollout["feature_state_value"] == {"type": "string", "value": "control"}
assert {
(mv["multivariate_feature_option"], mv["percentage_allocation"])
for mv in rollout["multivariate_feature_state_values"]
} == {(option_a.id, 50.0), (option_b.id, 50.0)}


def test_get_experiment_rollout__no_rollout__returns_none(
experiment: Experiment,
) -> None:
# Given an experiment without a rollout
# When / Then
assert services.get_experiment_rollout(experiment) is None


def test_get_experiment_rollout__v2_versioning__returns_representation(
environment_v2_versioning: Environment,
multivariate_feature: Feature,
multivariate_options: list[MultivariateFeatureOption],
admin_user: FFAdminUser,
) -> None:
# Given a rollout on a v2 environment
option_a, option_b, _ = multivariate_options
experiment = Experiment.objects.create(
environment=environment_v2_versioning,
feature=multivariate_feature,
name="exp",
hypothesis="h",
status=ExperimentStatus.CREATED,
)
services.apply_experiment_rollout(
experiment,
RolloutSpec(
enabled=True,
rollout_percentage=30.0,
feature_state_value="control",
value_type="string",
multivariate_values=[
MultivariateValueChangeSet(option_a.id, 60.0),
MultivariateValueChangeSet(option_b.id, 40.0),
],
author=AuthorData(user=admin_user),
),
)

# When
rollout = services.get_experiment_rollout(experiment)

# Then
assert rollout is not None
assert rollout["rollout_percentage"] == 30.0
assert {
(mv["multivariate_feature_option"], mv["percentage_allocation"])
for mv in rollout["multivariate_feature_state_values"]
} == {(option_a.id, 60.0), (option_b.id, 40.0)}


def test_get_experiment_rollout__boolean_value__returns_lowercase_string(
experiment: Experiment,
admin_user: FFAdminUser,
) -> None:
# Given
services.apply_experiment_rollout(
experiment,
RolloutSpec(
enabled=True,
rollout_percentage=20.0,
feature_state_value="true",
value_type="boolean",
multivariate_values=[],
author=AuthorData(user=admin_user),
),
)

# When
rollout = services.get_experiment_rollout(experiment)

# Then
assert rollout is not None
assert rollout["feature_state_value"] == {"type": "boolean", "value": "true"}
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ Attributes:
### `warehouse.connection.connected`

Logged at `info` from:
- `api/experimentation/services.py:625`
- `api/experimentation/services.py:664`

Attributes:
- `environment.id`
Expand All @@ -485,7 +485,7 @@ Attributes:
### `warehouse.connection.test_event_sent`

Logged at `info` from:
- `api/experimentation/services.py:605`
- `api/experimentation/services.py:644`

Attributes:
- `environment.id`
Expand All @@ -494,7 +494,7 @@ Attributes:
### `warehouse.srm.overallocated`

Logged at `error` from:
- `api/experimentation/services.py:388`
- `api/experimentation/services.py:391`

Attributes:
- `environment.id`
Expand All @@ -504,7 +504,7 @@ Attributes:
### `warehouse.srm.unkeyed_variant`

Logged at `error` from:
- `api/experimentation/services.py:374`
- `api/experimentation/services.py:377`

Attributes:
- `environment.id`
Expand Down
Loading