Skip to content
Draft
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
8 changes: 5 additions & 3 deletions flagsmith/flagsmith.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,8 @@ def get_experiment_flag(
Resolve a flag for an identity and record an exposure event.

The exposure event's ``value`` is the flag's variant key. It is only
sent when the flag exists, is enabled and carries a variant; any
sent when the flag exists, is enabled and the identity is enrolled in
the feature's experiment (``flag.experiment.in_experiment``); any
other outcome is logged and skipped to keep experimentation data
clean. A `DefaultFlag` served via the `default_flag_handler` counts
as the feature not existing.
Expand All @@ -388,9 +389,9 @@ def get_experiment_flag(
FLAG_EXPOSURE_EVENT,
feature_name,
)
elif flag.variant is None:
elif not (flag.experiment and flag.experiment.in_experiment):
logger.debug(
"Not sending %s for feature %s: flag has no variant.",
"Not sending %s for feature %s: identity is not in a running experiment.",
FLAG_EXPOSURE_EVENT,
feature_name,
)
Expand All @@ -400,6 +401,7 @@ def get_experiment_flag(
identifier=identifier,
value=flag.variant,
traits=traits,
metadata={"experiment_id": flag.experiment.id},
)
return flag

Expand Down
25 changes: 25 additions & 0 deletions flagsmith/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,29 @@ def build_segment_overrides_index(
return index


@dataclass(frozen=True)
class ExperimentMetadata:
"""The running experiment a flag was evaluated under; identity evaluations only."""

id: int
name: str
in_experiment: bool
"""Whether the identity is enrolled. `variant` alone cannot tell."""

@classmethod
def from_api_metadata(
cls,
metadata: typing.Optional[typing.Mapping[str, typing.Any]],
) -> typing.Optional[ExperimentMetadata]:
if not metadata or not (experiment := metadata.get("experiment")):
return None
return cls(
id=experiment["id"],
name=experiment["name"],
in_experiment=experiment["in_experiment"],
)


@dataclass
class BaseFlag:
enabled: bool
Expand All @@ -59,6 +82,7 @@ class Flag(BaseFlag):
variant: typing.Optional[str] = None
reason: typing.Optional[str] = None
is_default: bool = field(default=False)
experiment: typing.Optional[ExperimentMetadata] = None

@classmethod
def from_evaluation_result(
Expand Down Expand Up @@ -88,6 +112,7 @@ def from_api_flag(cls, flag_data: typing.Mapping[str, typing.Any]) -> Flag:
feature_id=flag_data["feature"]["id"],
variant=flag_data.get("variant"),
reason=flag_data.get("reason"),
experiment=ExperimentMetadata.from_api_metadata(flag_data.get("metadata")),
)


Expand Down
7 changes: 7 additions & 0 deletions tests/data/identities.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@
"feature_state_value": "some-value",
"reason": "SPLIT; weight=50.0",
"variant": "treatment",
"metadata": {
"experiment": {
"id": 42,
"name": "Some experiment",
"in_experiment": true
}
},
"enabled": true,
"environment": 1,
"identity": null,
Expand Down
41 changes: 33 additions & 8 deletions tests/test_flagsmith.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
FlagsmithAPIError,
FlagsmithFeatureDoesNotExistError,
)
from flagsmith.models import DefaultFlag, Flag, Flags
from flagsmith.models import DefaultFlag, ExperimentMetadata, Flag, Flags
from flagsmith.offline_handlers import OfflineHandler
from flagsmith.types import SDKEvaluationContext

Expand Down Expand Up @@ -1122,12 +1122,18 @@ def test_get_experiment_flag__variant__returns_flag_and_tracks_exposure(
assert result.feature_name == "some_feature"
assert result.value == "some-value"
assert result.variant == "treatment"
# Taken from hard coded values in tests/data/identities.json
assert result.experiment == ExperimentMetadata(
id=42,
name="Some experiment",
in_experiment=True,
)
mock_track.assert_called_once_with(
feature_name="some_feature",
identifier="user1",
value="treatment",
traits={"plan": "premium"},
metadata=None,
metadata={"experiment_id": 42},
)


Expand Down Expand Up @@ -1185,6 +1191,11 @@ def test_get_experiment_flag__variant__used_as_exposure_value(
feature_name="checkout_v2",
feature_id=1,
variant="control",
experiment=ExperimentMetadata(
id=42,
name="New checkout CTA",
in_experiment=True,
),
)
mocker.patch.object(
flagsmith,
Expand All @@ -1202,12 +1213,25 @@ def test_get_experiment_flag__variant__used_as_exposure_value(
identifier="user1",
value="control",
traits=None,
metadata=None,
metadata={"experiment_id": 42},
)


def test_get_experiment_flag__no_variant__skips_exposure(
mocker: MockerFixture, api_key: str, caplog: pytest.LogCaptureFixture
@pytest.mark.parametrize(
"experiment",
[
pytest.param(None, id="no-experiment"),
pytest.param(
ExperimentMetadata(id=42, name="New checkout CTA", in_experiment=False),
id="not-enrolled",
),
],
)
def test_get_experiment_flag__not_in_experiment__skips_exposure(
mocker: MockerFixture,
api_key: str,
caplog: pytest.LogCaptureFixture,
experiment: typing.Optional[ExperimentMetadata],
) -> None:
# Given
config = EventProcessorConfig(events_api_url="http://test/")
Expand All @@ -1219,7 +1243,8 @@ def test_get_experiment_flag__no_variant__skips_exposure(
value="blue",
feature_name="checkout_v2",
feature_id=1,
variant=None,
variant="control",
experiment=experiment,
)
mocker.patch.object(
flagsmith,
Expand All @@ -1238,8 +1263,8 @@ def test_get_experiment_flag__no_variant__skips_exposure(
assert result is flag
mock_track.assert_not_called()
assert (
"Not sending $flag_exposure for feature checkout_v2: flag has no variant."
in caplog.messages
"Not sending $flag_exposure for feature checkout_v2:"
" identity is not in a running experiment." in caplog.messages
)


Expand Down
50 changes: 50 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from flagsmith.models import (
DefaultFlag,
ExperimentMetadata,
Flag,
Flags,
build_segment_overrides_index,
Expand Down Expand Up @@ -250,6 +251,55 @@ def test_flag_from_api_flag__no_reason__is_none() -> None:
assert flag.reason is None


@pytest.mark.parametrize(
"metadata, expected_experiment",
[
pytest.param(
{
"experiment": {
"id": 42,
"name": "New checkout CTA",
"in_experiment": True,
}
},
ExperimentMetadata(id=42, name="New checkout CTA", in_experiment=True),
id="enrolled",
),
pytest.param(
{
"experiment": {
"id": 42,
"name": "New checkout CTA",
"in_experiment": False,
}
},
ExperimentMetadata(id=42, name="New checkout CTA", in_experiment=False),
id="not-enrolled",
),
pytest.param({"something_else": {"id": 1}}, None, id="other-metadata"),
pytest.param(None, None, id="no-metadata"),
],
)
def test_flag_from_api_flag__metadata__sets_experiment(
metadata: typing.Optional[dict[str, typing.Any]],
expected_experiment: typing.Optional[ExperimentMetadata],
) -> None:
# Given
flag_data = {
"enabled": True,
"feature_state_value": "buy-now",
"feature": {"name": "test_feature", "id": 123},
"variant": "control",
**({"metadata": metadata} if metadata is not None else {}),
}

# When
flag = Flag.from_api_flag(flag_data)

# Then
assert flag.experiment == expected_experiment


def test_get_flag_without_pipeline_processor() -> None:
flags = Flags(
flags={
Expand Down
Loading