Skip to content

Commit a0f55dc

Browse files
committed
fix: exposure events sent without a variant and duplicated within a flush interval
Send the $flag_exposure event from get_experiment_flag only when the flag exists, is enabled and carries a variant, with the variant key as the event value; log the cause at debug level otherwise. Deduplicate equal exposure events buffered within one flush interval, keyed on feature name, identifier and value. beep boop
1 parent 9a85c05 commit a0f55dc

5 files changed

Lines changed: 166 additions & 34 deletions

File tree

flagsmith/analytics.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ def track_feature(self, feature_name: str) -> None:
8383
self.flush()
8484

8585

86+
class ExposureKey(typing.NamedTuple):
87+
feature_name: typing.Optional[str]
88+
identifier: typing.Optional[str]
89+
value: typing.Optional[str]
90+
91+
8692
@dataclass
8793
class EventProcessorConfig:
8894
events_api_url: str = DEFAULT_EVENT_API_URL
@@ -111,6 +117,7 @@ def __init__(
111117
self._flush_interval_seconds = config.flush_interval_seconds
112118

113119
self._buffer: typing.List[typing.Dict[str, typing.Any]] = []
120+
self._buffered_exposure_keys: typing.Set[ExposureKey] = set()
114121
self._lock = threading.Lock()
115122
self._timer: typing.Optional[threading.Timer] = None
116123

@@ -159,6 +166,24 @@ def _buffer_event(
159166
) -> None:
160167
should_flush = False
161168
with self._lock:
169+
if event == FLAG_EXPOSURE_EVENT:
170+
# An exposure is defined by who saw which variant of which
171+
# feature; equal exposures within one flush interval add no
172+
# information, so only the first is buffered.
173+
exposure_key = ExposureKey(
174+
feature_name=feature_name,
175+
identifier=identifier,
176+
value=str(value) if value is not None else None,
177+
)
178+
if exposure_key in self._buffered_exposure_keys:
179+
logger.debug(
180+
"Skipping duplicate %s event for feature %s, identifier %s",
181+
FLAG_EXPOSURE_EVENT,
182+
feature_name,
183+
identifier,
184+
)
185+
return
186+
self._buffered_exposure_keys.add(exposure_key)
162187
self._buffer.append(
163188
{
164189
"event": event,
@@ -182,6 +207,7 @@ def flush(self) -> None:
182207
return
183208
events = self._buffer
184209
self._buffer = []
210+
self._buffered_exposure_keys.clear()
185211

186212
payload = json.dumps({"events": events})
187213
try:

flagsmith/flagsmith.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -368,19 +368,38 @@ def get_experiment_flag(
368368
"""
369369
Resolve a flag for an identity and record an exposure event.
370370
371-
Skips the exposure event when the resolved flag is a `DefaultFlag`
372-
(i.e. the feature was not present and was served via the
373-
`default_flag_handler`) or when the feature is disabled, to keep
374-
experimentation data clean.
371+
The exposure event's ``value`` is the flag's variant key. It is only
372+
sent when the flag exists, is enabled and carries a variant; any
373+
other outcome is logged and skipped to keep experimentation data
374+
clean. A `DefaultFlag` served via the `default_flag_handler` counts
375+
as the feature not existing.
375376
"""
376377
if not self._event_processor:
377378
raise ValueError("Events must be enabled to use experiment flags.")
378379
flag = self.get_identity_flags(identifier, traits).get_flag(feature_name)
379-
if isinstance(flag, Flag) and flag.enabled:
380+
if not isinstance(flag, Flag):
381+
logger.debug(
382+
"Not sending %s for feature %s: feature not found.",
383+
FLAG_EXPOSURE_EVENT,
384+
feature_name,
385+
)
386+
elif not flag.enabled:
387+
logger.debug(
388+
"Not sending %s for feature %s: feature is disabled.",
389+
FLAG_EXPOSURE_EVENT,
390+
feature_name,
391+
)
392+
elif flag.variant is None:
393+
logger.debug(
394+
"Not sending %s for feature %s: flag has no variant.",
395+
FLAG_EXPOSURE_EVENT,
396+
feature_name,
397+
)
398+
else:
380399
self.track_exposure_event(
381400
feature_name=feature_name,
382401
identifier=identifier,
383-
value=flag.variant if flag.variant is not None else flag.value,
402+
value=flag.variant,
384403
traits=traits,
385404
)
386405
return flag

tests/data/identities.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"project": 1
2121
},
2222
"feature_state_value": "some-value",
23+
"variant": "treatment",
2324
"enabled": true,
2425
"environment": 1,
2526
"identity": null,

tests/test_event_processor.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,72 @@ def test_track_exposure_event_buffers_with_flag_exposure_event_name(
8282
assert "sdk_version" in event["metadata"]
8383

8484

85+
def test_track_exposure_event__equal_events_in_flush_interval__deduplicated(
86+
event_processor: EventProcessor,
87+
) -> None:
88+
# Given / When
89+
for _ in range(3):
90+
event_processor.track_exposure_event(
91+
feature_name="checkout_v2",
92+
identifier="user1",
93+
value="variant_b",
94+
)
95+
96+
# Then
97+
assert len(event_processor._buffer) == 1
98+
99+
100+
def test_track_exposure_event__differing_events__not_deduplicated(
101+
event_processor: EventProcessor,
102+
) -> None:
103+
# Given / When
104+
event_processor.track_exposure_event(
105+
feature_name="checkout_v2", identifier="user1", value="variant_b"
106+
)
107+
event_processor.track_exposure_event(
108+
feature_name="checkout_v2", identifier="user2", value="variant_b"
109+
)
110+
event_processor.track_exposure_event(
111+
feature_name="checkout_v2", identifier="user1", value="variant_a"
112+
)
113+
event_processor.track_exposure_event(
114+
feature_name="banner_test", identifier="user1", value="variant_b"
115+
)
116+
117+
# Then
118+
assert len(event_processor._buffer) == 4
119+
120+
121+
def test_track_exposure_event__equal_event_after_flush__buffered_again(
122+
event_processor: EventProcessor,
123+
) -> None:
124+
# Given
125+
with mock.patch("flagsmith.analytics.session"):
126+
event_processor.track_exposure_event(
127+
feature_name="checkout_v2", identifier="user1", value="variant_b"
128+
)
129+
event_processor.flush()
130+
131+
# When
132+
event_processor.track_exposure_event(
133+
feature_name="checkout_v2", identifier="user1", value="variant_b"
134+
)
135+
136+
# Then
137+
assert len(event_processor._buffer) == 1
138+
139+
140+
def test_track_event__equal_events__not_deduplicated(
141+
event_processor: EventProcessor,
142+
) -> None:
143+
# Given / When
144+
event_processor.track_event(event="purchase", identifier="user1", value="99.5")
145+
event_processor.track_event(event="purchase", identifier="user1", value="99.5")
146+
147+
# Then
148+
assert len(event_processor._buffer) == 2
149+
150+
85151
def test_auto_flush_on_buffer_full() -> None:
86152
config = EventProcessorConfig(events_api_url="http://test/", max_buffer_items=5)
87153
processor = EventProcessor(config=config, environment_key="key")

tests/test_flagsmith.py

Lines changed: 48 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import logging
23
import time
34
import typing
45

@@ -1054,9 +1055,10 @@ def test_get_experiment_flag_raises_without_events_enabled(api_key: str) -> None
10541055

10551056

10561057
@responses.activate()
1057-
def test_get_experiment_flag_returns_flag_and_tracks_exposure(
1058+
def test_get_experiment_flag__variant__returns_flag_and_tracks_exposure(
10581059
mocker: MockerFixture, api_key: str, identities_json: str
10591060
) -> None:
1061+
# Given
10601062
config = EventProcessorConfig(events_api_url="http://test/")
10611063
flagsmith = Flagsmith(
10621064
environment_key=api_key, enable_events=True, event_processor_config=config
@@ -1065,29 +1067,33 @@ def test_get_experiment_flag_returns_flag_and_tracks_exposure(
10651067
mock_track = mocker.patch.object(flagsmith._event_processor, "track_exposure_event")
10661068
responses.add(method="POST", url=flagsmith.identities_url, body=identities_json)
10671069

1070+
# When
10681071
result = flagsmith.get_experiment_flag(
10691072
feature_name="some_feature",
10701073
identifier="user1",
10711074
traits={"plan": "premium"},
10721075
)
10731076

1077+
# Then
10741078
assert isinstance(result, Flag)
10751079
assert result.is_default is False
10761080
assert result.feature_name == "some_feature"
10771081
assert result.value == "some-value"
1082+
assert result.variant == "treatment"
10781083
mock_track.assert_called_once_with(
10791084
feature_name="some_feature",
10801085
identifier="user1",
1081-
value="some-value",
1086+
value="treatment",
10821087
traits={"plan": "premium"},
10831088
metadata=None,
10841089
)
10851090

10861091

10871092
@responses.activate()
1088-
def test_get_experiment_flag_skips_exposure_for_default_flag(
1089-
mocker: MockerFixture, api_key: str
1093+
def test_get_experiment_flag__default_flag__skips_exposure(
1094+
mocker: MockerFixture, api_key: str, caplog: pytest.LogCaptureFixture
10901095
) -> None:
1096+
# Given
10911097
config = EventProcessorConfig(events_api_url="http://test/")
10921098

10931099
def default_flag_handler(feature_name: str) -> DefaultFlag:
@@ -1106,20 +1112,27 @@ def default_flag_handler(feature_name: str) -> DefaultFlag:
11061112
body=json.dumps({"flags": [], "traits": []}),
11071113
)
11081114

1109-
result = flagsmith.get_experiment_flag(
1110-
feature_name="missing_feature", identifier="user1"
1111-
)
1115+
# When
1116+
with caplog.at_level(logging.DEBUG, logger="flagsmith.flagsmith"):
1117+
result = flagsmith.get_experiment_flag(
1118+
feature_name="missing_feature", identifier="user1"
1119+
)
11121120

1121+
# Then
11131122
assert isinstance(result, DefaultFlag)
11141123
assert result.is_default is True
11151124
assert result.value == "default-variant"
11161125
mock_track.assert_not_called()
1126+
assert (
1127+
"Not sending $flag_exposure for feature missing_feature: feature not found."
1128+
in caplog.messages
1129+
)
11171130

11181131

1119-
def test_get_experiment_flag_uses_variant_as_exposure_value(
1132+
def test_get_experiment_flag__variant__used_as_exposure_value(
11201133
mocker: MockerFixture, api_key: str
11211134
) -> None:
1122-
# Given - a resolved flag carrying a variant
1135+
# Given
11231136
config = EventProcessorConfig(events_api_url="http://test/")
11241137
flagsmith = Flagsmith(
11251138
environment_key=api_key, enable_events=True, event_processor_config=config
@@ -1141,7 +1154,7 @@ def test_get_experiment_flag_uses_variant_as_exposure_value(
11411154
# When
11421155
flagsmith.get_experiment_flag(feature_name="checkout_v2", identifier="user1")
11431156

1144-
# Then - the exposure value is the variant, not the flag value
1157+
# Then
11451158
mock_track.assert_called_once_with(
11461159
feature_name="checkout_v2",
11471160
identifier="user1",
@@ -1151,10 +1164,10 @@ def test_get_experiment_flag_uses_variant_as_exposure_value(
11511164
)
11521165

11531166

1154-
def test_get_experiment_flag_falls_back_to_value_without_variant(
1155-
mocker: MockerFixture, api_key: str
1167+
def test_get_experiment_flag__no_variant__skips_exposure(
1168+
mocker: MockerFixture, api_key: str, caplog: pytest.LogCaptureFixture
11561169
) -> None:
1157-
# Given - a resolved flag with no variant
1170+
# Given
11581171
config = EventProcessorConfig(events_api_url="http://test/")
11591172
flagsmith = Flagsmith(
11601173
environment_key=api_key, enable_events=True, event_processor_config=config
@@ -1174,15 +1187,17 @@ def test_get_experiment_flag_falls_back_to_value_without_variant(
11741187
mock_track = mocker.patch.object(flagsmith._event_processor, "track_exposure_event")
11751188

11761189
# When
1177-
flagsmith.get_experiment_flag(feature_name="checkout_v2", identifier="user1")
1190+
with caplog.at_level(logging.DEBUG, logger="flagsmith.flagsmith"):
1191+
result = flagsmith.get_experiment_flag(
1192+
feature_name="checkout_v2", identifier="user1"
1193+
)
11781194

1179-
# Then - the exposure value falls back to the flag value
1180-
mock_track.assert_called_once_with(
1181-
feature_name="checkout_v2",
1182-
identifier="user1",
1183-
value="blue",
1184-
traits=None,
1185-
metadata=None,
1195+
# Then
1196+
assert result is flag
1197+
mock_track.assert_not_called()
1198+
assert (
1199+
"Not sending $flag_exposure for feature checkout_v2: flag has no variant."
1200+
in caplog.messages
11861201
)
11871202

11881203

@@ -1260,10 +1275,10 @@ def test_flagsmith_posts_analytics_to_api_url_when_analytics_url_unset(
12601275
assert len(analytics_posts) == 1
12611276

12621277

1263-
def test_get_experiment_flag_skips_exposure_for_disabled_feature(
1264-
mocker: MockerFixture, api_key: str
1278+
def test_get_experiment_flag__disabled_feature__skips_exposure(
1279+
mocker: MockerFixture, api_key: str, caplog: pytest.LogCaptureFixture
12651280
) -> None:
1266-
# Given - a resolved flag for a disabled feature
1281+
# Given
12671282
config = EventProcessorConfig(events_api_url="http://test/")
12681283
flagsmith = Flagsmith(
12691284
environment_key=api_key, enable_events=True, event_processor_config=config
@@ -1283,10 +1298,15 @@ def test_get_experiment_flag_skips_exposure_for_disabled_feature(
12831298
mock_track = mocker.patch.object(flagsmith._event_processor, "track_exposure_event")
12841299

12851300
# When
1286-
result = flagsmith.get_experiment_flag(
1287-
feature_name="checkout_v2", identifier="user1"
1288-
)
1301+
with caplog.at_level(logging.DEBUG, logger="flagsmith.flagsmith"):
1302+
result = flagsmith.get_experiment_flag(
1303+
feature_name="checkout_v2", identifier="user1"
1304+
)
12891305

1290-
# Then - the flag is returned but no exposure event is tracked
1306+
# Then
12911307
assert result is flag
12921308
mock_track.assert_not_called()
1309+
assert (
1310+
"Not sending $flag_exposure for feature checkout_v2: feature is disabled."
1311+
in caplog.messages
1312+
)

0 commit comments

Comments
 (0)