Skip to content

Commit 4c8e1e2

Browse files
committed
feat: add Flag.experiment and gate exposure events on in_experiment
1 parent f67b805 commit 4c8e1e2

5 files changed

Lines changed: 120 additions & 11 deletions

File tree

flagsmith/flagsmith.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,8 @@ def get_experiment_flag(
368368
Resolve a flag for an identity and record an exposure event.
369369
370370
The exposure event's ``value`` is the flag's variant key. It is only
371-
sent when the flag exists, is enabled and carries a variant; any
371+
sent when the flag exists, is enabled and the identity is enrolled in
372+
the feature's experiment (``flag.experiment.in_experiment``); any
372373
other outcome is logged and skipped to keep experimentation data
373374
clean. A `DefaultFlag` served via the `default_flag_handler` counts
374375
as the feature not existing.
@@ -388,9 +389,9 @@ def get_experiment_flag(
388389
FLAG_EXPOSURE_EVENT,
389390
feature_name,
390391
)
391-
elif flag.variant is None:
392+
elif not (flag.experiment and flag.experiment.in_experiment):
392393
logger.debug(
393-
"Not sending %s for feature %s: flag has no variant.",
394+
"Not sending %s for feature %s: identity is not in a running experiment.",
394395
FLAG_EXPOSURE_EVENT,
395396
feature_name,
396397
)
@@ -400,6 +401,7 @@ def get_experiment_flag(
400401
identifier=identifier,
401402
value=flag.variant,
402403
traits=traits,
404+
metadata={"experiment_id": flag.experiment.id},
403405
)
404406
return flag
405407

flagsmith/models.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,29 @@ def build_segment_overrides_index(
4141
return index
4242

4343

44+
@dataclass(frozen=True)
45+
class ExperimentMetadata:
46+
"""The running experiment a flag was evaluated under; identity evaluations only."""
47+
48+
id: int
49+
name: str
50+
in_experiment: bool
51+
"""Whether the identity is enrolled. `variant` alone cannot tell."""
52+
53+
@classmethod
54+
def from_api_metadata(
55+
cls,
56+
metadata: typing.Optional[typing.Mapping[str, typing.Any]],
57+
) -> typing.Optional[ExperimentMetadata]:
58+
if not metadata or not (experiment := metadata.get("experiment")):
59+
return None
60+
return cls(
61+
id=experiment["id"],
62+
name=experiment["name"],
63+
in_experiment=experiment["in_experiment"],
64+
)
65+
66+
4467
@dataclass
4568
class BaseFlag:
4669
enabled: bool
@@ -59,6 +82,7 @@ class Flag(BaseFlag):
5982
variant: typing.Optional[str] = None
6083
reason: typing.Optional[str] = None
6184
is_default: bool = field(default=False)
85+
experiment: typing.Optional[ExperimentMetadata] = None
6286

6387
@classmethod
6488
def from_evaluation_result(
@@ -88,6 +112,7 @@ def from_api_flag(cls, flag_data: typing.Mapping[str, typing.Any]) -> Flag:
88112
feature_id=flag_data["feature"]["id"],
89113
variant=flag_data.get("variant"),
90114
reason=flag_data.get("reason"),
115+
experiment=ExperimentMetadata.from_api_metadata(flag_data.get("metadata")),
91116
)
92117

93118

tests/data/identities.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@
2222
"feature_state_value": "some-value",
2323
"reason": "SPLIT; weight=50.0",
2424
"variant": "treatment",
25+
"metadata": {
26+
"experiment": {
27+
"id": 42,
28+
"name": "Some experiment",
29+
"in_experiment": true
30+
}
31+
},
2532
"enabled": true,
2633
"environment": 1,
2734
"identity": null,

tests/test_flagsmith.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
FlagsmithAPIError,
1818
FlagsmithFeatureDoesNotExistError,
1919
)
20-
from flagsmith.models import DefaultFlag, Flag, Flags
20+
from flagsmith.models import DefaultFlag, ExperimentMetadata, Flag, Flags
2121
from flagsmith.offline_handlers import OfflineHandler
2222
from flagsmith.types import SDKEvaluationContext
2323

@@ -1122,12 +1122,18 @@ def test_get_experiment_flag__variant__returns_flag_and_tracks_exposure(
11221122
assert result.feature_name == "some_feature"
11231123
assert result.value == "some-value"
11241124
assert result.variant == "treatment"
1125+
# Taken from hard coded values in tests/data/identities.json
1126+
assert result.experiment == ExperimentMetadata(
1127+
id=42,
1128+
name="Some experiment",
1129+
in_experiment=True,
1130+
)
11251131
mock_track.assert_called_once_with(
11261132
feature_name="some_feature",
11271133
identifier="user1",
11281134
value="treatment",
11291135
traits={"plan": "premium"},
1130-
metadata=None,
1136+
metadata={"experiment_id": 42},
11311137
)
11321138

11331139

@@ -1185,6 +1191,11 @@ def test_get_experiment_flag__variant__used_as_exposure_value(
11851191
feature_name="checkout_v2",
11861192
feature_id=1,
11871193
variant="control",
1194+
experiment=ExperimentMetadata(
1195+
id=42,
1196+
name="New checkout CTA",
1197+
in_experiment=True,
1198+
),
11881199
)
11891200
mocker.patch.object(
11901201
flagsmith,
@@ -1202,12 +1213,25 @@ def test_get_experiment_flag__variant__used_as_exposure_value(
12021213
identifier="user1",
12031214
value="control",
12041215
traits=None,
1205-
metadata=None,
1216+
metadata={"experiment_id": 42},
12061217
)
12071218

12081219

1209-
def test_get_experiment_flag__no_variant__skips_exposure(
1210-
mocker: MockerFixture, api_key: str, caplog: pytest.LogCaptureFixture
1220+
@pytest.mark.parametrize(
1221+
"experiment",
1222+
[
1223+
pytest.param(None, id="no-experiment"),
1224+
pytest.param(
1225+
ExperimentMetadata(id=42, name="New checkout CTA", in_experiment=False),
1226+
id="not-enrolled",
1227+
),
1228+
],
1229+
)
1230+
def test_get_experiment_flag__not_in_experiment__skips_exposure(
1231+
mocker: MockerFixture,
1232+
api_key: str,
1233+
caplog: pytest.LogCaptureFixture,
1234+
experiment: typing.Optional[ExperimentMetadata],
12111235
) -> None:
12121236
# Given
12131237
config = EventProcessorConfig(events_api_url="http://test/")
@@ -1219,7 +1243,8 @@ def test_get_experiment_flag__no_variant__skips_exposure(
12191243
value="blue",
12201244
feature_name="checkout_v2",
12211245
feature_id=1,
1222-
variant=None,
1246+
variant="control",
1247+
experiment=experiment,
12231248
)
12241249
mocker.patch.object(
12251250
flagsmith,
@@ -1238,8 +1263,8 @@ def test_get_experiment_flag__no_variant__skips_exposure(
12381263
assert result is flag
12391264
mock_track.assert_not_called()
12401265
assert (
1241-
"Not sending $flag_exposure for feature checkout_v2: flag has no variant."
1242-
in caplog.messages
1266+
"Not sending $flag_exposure for feature checkout_v2:"
1267+
" identity is not in a running experiment." in caplog.messages
12431268
)
12441269

12451270

tests/test_models.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from flagsmith.models import (
66
DefaultFlag,
7+
ExperimentMetadata,
78
Flag,
89
Flags,
910
build_segment_overrides_index,
@@ -250,6 +251,55 @@ def test_flag_from_api_flag__no_reason__is_none() -> None:
250251
assert flag.reason is None
251252

252253

254+
@pytest.mark.parametrize(
255+
"metadata, expected_experiment",
256+
[
257+
pytest.param(
258+
{
259+
"experiment": {
260+
"id": 42,
261+
"name": "New checkout CTA",
262+
"in_experiment": True,
263+
}
264+
},
265+
ExperimentMetadata(id=42, name="New checkout CTA", in_experiment=True),
266+
id="enrolled",
267+
),
268+
pytest.param(
269+
{
270+
"experiment": {
271+
"id": 42,
272+
"name": "New checkout CTA",
273+
"in_experiment": False,
274+
}
275+
},
276+
ExperimentMetadata(id=42, name="New checkout CTA", in_experiment=False),
277+
id="not-enrolled",
278+
),
279+
pytest.param({"something_else": {"id": 1}}, None, id="other-metadata"),
280+
pytest.param(None, None, id="no-metadata"),
281+
],
282+
)
283+
def test_flag_from_api_flag__metadata__sets_experiment(
284+
metadata: typing.Optional[dict[str, typing.Any]],
285+
expected_experiment: typing.Optional[ExperimentMetadata],
286+
) -> None:
287+
# Given
288+
flag_data = {
289+
"enabled": True,
290+
"feature_state_value": "buy-now",
291+
"feature": {"name": "test_feature", "id": 123},
292+
"variant": "control",
293+
**({"metadata": metadata} if metadata is not None else {}),
294+
}
295+
296+
# When
297+
flag = Flag.from_api_flag(flag_data)
298+
299+
# Then
300+
assert flag.experiment == expected_experiment
301+
302+
253303
def test_get_flag_without_pipeline_processor() -> None:
254304
flags = Flags(
255305
flags={

0 commit comments

Comments
 (0)