From 4c8e1e2d734bd673916e4746eb64baa4eb520116 Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 14 Sep 2026 17:12:51 +0200 Subject: [PATCH] feat: add Flag.experiment and gate exposure events on in_experiment --- flagsmith/flagsmith.py | 8 +++--- flagsmith/models.py | 25 +++++++++++++++++++ tests/data/identities.json | 7 ++++++ tests/test_flagsmith.py | 41 +++++++++++++++++++++++++------ tests/test_models.py | 50 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 11 deletions(-) diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index c46bdd1..bd4b1b0 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -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. @@ -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, ) @@ -400,6 +401,7 @@ def get_experiment_flag( identifier=identifier, value=flag.variant, traits=traits, + metadata={"experiment_id": flag.experiment.id}, ) return flag diff --git a/flagsmith/models.py b/flagsmith/models.py index ed327d5..1842272 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -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 @@ -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( @@ -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")), ) diff --git a/tests/data/identities.json b/tests/data/identities.json index 07951b3..bdf5130 100644 --- a/tests/data/identities.json +++ b/tests/data/identities.json @@ -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, diff --git a/tests/test_flagsmith.py b/tests/test_flagsmith.py index d0a82c8..7e010c2 100644 --- a/tests/test_flagsmith.py +++ b/tests/test_flagsmith.py @@ -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 @@ -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}, ) @@ -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, @@ -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/") @@ -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, @@ -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 ) diff --git a/tests/test_models.py b/tests/test_models.py index 702c0ed..af1274b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -4,6 +4,7 @@ from flagsmith.models import ( DefaultFlag, + ExperimentMetadata, Flag, Flags, build_segment_overrides_index, @@ -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={