Skip to content

Commit 620014a

Browse files
committed
[FSSDK-12839] Normalize impression event campaign_id, variation_id, entity_id
1 parent a68b313 commit 620014a

6 files changed

Lines changed: 266 additions & 13 deletions

File tree

optimizely/event/event_factory.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2019, 2022, Optimizely
1+
# Copyright 2019, 2022, 2026, Optimizely
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -134,12 +134,21 @@ def _create_visitor(cls, event: Optional[user_event.UserEvent], logger: Logger)
134134
if isinstance(event.experiment, entities.Experiment):
135135
experiment_layerId = event.experiment.layerId
136136

137+
campaign_id: str = (
138+
experiment_layerId
139+
if validator.is_numeric_string_id(experiment_layerId)
140+
else experiment_id
141+
)
142+
normalized_variation_id: Optional[str] = (
143+
variation_id if validator.is_numeric_string_id(variation_id) else None
144+
)
145+
137146
metadata = payload.Metadata(event.flag_key, event.rule_key,
138147
event.rule_type, variation_key,
139148
event.enabled, event.cmab_uuid)
140-
decision = payload.Decision(experiment_layerId, experiment_id, variation_id, metadata)
149+
decision = payload.Decision(campaign_id, experiment_id, normalized_variation_id, metadata)
141150
snapshot_event = payload.SnapshotEvent(
142-
experiment_layerId, event.uuid, cls.ACTIVATE_EVENT_KEY, event.timestamp,
151+
campaign_id, event.uuid, cls.ACTIVATE_EVENT_KEY, event.timestamp,
143152
)
144153

145154
snapshot = payload.Snapshot([snapshot_event], [decision])

optimizely/event/payload.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2019, 2022, Optimizely
1+
# Copyright 2019, 2022, 2026, Optimizely
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -71,7 +71,13 @@ def get_event_params(self) -> dict[str, Any]:
7171
class Decision:
7272
""" Class respresenting Decision. """
7373

74-
def __init__(self, campaign_id: str, experiment_id: str, variation_id: str, metadata: Metadata):
74+
def __init__(
75+
self,
76+
campaign_id: str,
77+
experiment_id: str,
78+
variation_id: Optional[str],
79+
metadata: Metadata,
80+
):
7581
self.campaign_id = campaign_id
7682
self.experiment_id = experiment_id
7783
self.variation_id = variation_id

optimizely/event_builder.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2016-2019, 2022, Optimizely
1+
# Copyright 2016-2019, 2022, 2026, Optimizely
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -178,7 +178,7 @@ def _get_common_params(
178178

179179
def _get_required_params_for_impression(
180180
self, experiment: Experiment, variation_id: str
181-
) -> dict[str, list[dict[str, str | int]]]:
181+
) -> dict[str, list[dict[str, Any]]]:
182182
""" Get parameters that are required for the impression event to register.
183183
184184
Args:
@@ -188,19 +188,26 @@ def _get_required_params_for_impression(
188188
Returns:
189189
Dict consisting of decisions and events info for impression event.
190190
"""
191-
snapshot: dict[str, list[dict[str, str | int]]] = {}
191+
campaign_id: str = (
192+
experiment.layerId if validator.is_numeric_string_id(experiment.layerId) else experiment.id
193+
)
194+
normalized_variation_id: Optional[str] = (
195+
variation_id if validator.is_numeric_string_id(variation_id) else None
196+
)
197+
198+
snapshot: dict[str, list[dict[str, Any]]] = {}
192199

193200
snapshot[self.EventParams.DECISIONS] = [
194201
{
195202
self.EventParams.EXPERIMENT_ID: experiment.id,
196-
self.EventParams.VARIATION_ID: variation_id,
197-
self.EventParams.CAMPAIGN_ID: experiment.layerId,
203+
self.EventParams.VARIATION_ID: normalized_variation_id,
204+
self.EventParams.CAMPAIGN_ID: campaign_id,
198205
}
199206
]
200207

201208
snapshot[self.EventParams.EVENTS] = [
202209
{
203-
self.EventParams.EVENT_ID: experiment.layerId,
210+
self.EventParams.EVENT_ID: campaign_id,
204211
self.EventParams.TIME: self._get_time(),
205212
self.EventParams.KEY: 'campaign_activated',
206213
self.EventParams.UUID: str(uuid.uuid4()),

optimizely/helpers/validator.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,27 @@ def is_non_empty_string(input_id_key: str) -> bool:
228228
return False
229229

230230

231+
def is_numeric_string_id(value: Any) -> bool:
232+
""" Determine if value is a non-empty string consisting entirely of decimal digits.
233+
234+
Booleans are rejected even though they are technically instances of int in Python; the
235+
field contract is "string of decimal digits", and a bool is neither a string nor a digit.
236+
237+
Args:
238+
value: Variable which needs to be validated.
239+
240+
Returns:
241+
True if value is a non-empty string of decimal digits [0-9]. False otherwise
242+
(including for None, empty string, whitespace, non-string types, or strings
243+
containing any non-digit character such as a sign, decimal point, or letter).
244+
"""
245+
if not isinstance(value, str) or isinstance(value, bool):
246+
return False
247+
if not value:
248+
return False
249+
return value.isdigit() and value.isascii()
250+
251+
231252
def is_attribute_valid(attribute_key: str, attribute_value: Any) -> bool:
232253
""" Determine if given attribute is valid.
233254

tests/test_event_builder.py

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2016-2019, Optimizely
1+
# Copyright 2016-2019, 2026, Optimizely
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -11,6 +11,7 @@
1111
# See the License for the specific language governing permissions and
1212
# limitations under the License.
1313

14+
import copy
1415
from unittest import mock
1516
import unittest
1617
from operator import itemgetter
@@ -1015,3 +1016,123 @@ def test_create_conversion_event__when_event_is_used_in_multiple_experiments(sel
10151016
event_builder.EventBuilder.HTTP_VERB,
10161017
event_builder.EventBuilder.HTTP_HEADERS,
10171018
)
1019+
1020+
1021+
class ImpressionEventIdNormalizationTest(base.BaseTest):
1022+
"""Impression-event normalization rules for campaign_id, variation_id, and entity_id."""
1023+
1024+
def setUp(self, *args, **kwargs):
1025+
base.BaseTest.setUp(self, 'config_dict_with_multiple_experiments')
1026+
self.event_builder = self.optimizely.event_builder
1027+
self.experiment = self.project_config.get_experiment_from_key('test_experiment')
1028+
1029+
def _build_impression(self, experiment, variation_id):
1030+
return self.event_builder._get_required_params_for_impression(experiment, variation_id)
1031+
1032+
def _with_layer_id(self, layer_id):
1033+
experiment = copy.deepcopy(self.experiment)
1034+
experiment.layerId = layer_id
1035+
return experiment
1036+
1037+
def _decision(self, snapshot):
1038+
return snapshot[event_builder.EventBuilder.EventParams.DECISIONS][0]
1039+
1040+
def _event(self, snapshot):
1041+
return snapshot[event_builder.EventBuilder.EventParams.EVENTS][0]
1042+
1043+
# campaign_id normalization (US1) ----------------------------------------------
1044+
1045+
def test_campaign_id_valid_numeric_layer_id_passes_through(self):
1046+
experiment = self._with_layer_id('111182')
1047+
snapshot = self._build_impression(experiment, '111129')
1048+
self.assertEqual(self._decision(snapshot)['campaign_id'], '111182')
1049+
1050+
def test_campaign_id_empty_string_falls_back_to_experiment_id(self):
1051+
experiment = self._with_layer_id('')
1052+
snapshot = self._build_impression(experiment, '111129')
1053+
self.assertEqual(self._decision(snapshot)['campaign_id'], experiment.id)
1054+
1055+
def test_campaign_id_none_falls_back_to_experiment_id(self):
1056+
experiment = self._with_layer_id(None)
1057+
snapshot = self._build_impression(experiment, '111129')
1058+
self.assertEqual(self._decision(snapshot)['campaign_id'], experiment.id)
1059+
1060+
def test_campaign_id_non_numeric_string_falls_back_to_experiment_id(self):
1061+
experiment = self._with_layer_id('abc')
1062+
snapshot = self._build_impression(experiment, '111129')
1063+
self.assertEqual(self._decision(snapshot)['campaign_id'], experiment.id)
1064+
1065+
def test_campaign_id_whitespace_falls_back_to_experiment_id(self):
1066+
experiment = self._with_layer_id(' ')
1067+
snapshot = self._build_impression(experiment, '111129')
1068+
self.assertEqual(self._decision(snapshot)['campaign_id'], experiment.id)
1069+
1070+
def test_campaign_id_integer_value_falls_back_to_experiment_id(self):
1071+
experiment = self._with_layer_id(111182)
1072+
snapshot = self._build_impression(experiment, '111129')
1073+
self.assertEqual(self._decision(snapshot)['campaign_id'], experiment.id)
1074+
1075+
# variation_id normalization (US2) ---------------------------------------------
1076+
1077+
def test_variation_id_valid_numeric_passes_through(self):
1078+
snapshot = self._build_impression(self.experiment, '111129')
1079+
self.assertEqual(self._decision(snapshot)['variation_id'], '111129')
1080+
1081+
def test_variation_id_empty_string_becomes_none(self):
1082+
snapshot = self._build_impression(self.experiment, '')
1083+
self.assertIsNone(self._decision(snapshot)['variation_id'])
1084+
1085+
def test_variation_id_non_numeric_string_becomes_none(self):
1086+
snapshot = self._build_impression(self.experiment, 'variation_a')
1087+
self.assertIsNone(self._decision(snapshot)['variation_id'])
1088+
1089+
def test_variation_id_none_stays_none(self):
1090+
snapshot = self._build_impression(self.experiment, None)
1091+
self.assertIsNone(self._decision(snapshot)['variation_id'])
1092+
1093+
# entity_id normalization (US3) and US3 acceptance #5: byte-equality with campaign_id
1094+
1095+
def test_entity_id_valid_layer_id_passes_through(self):
1096+
experiment = self._with_layer_id('111182')
1097+
snapshot = self._build_impression(experiment, '111129')
1098+
self.assertEqual(self._event(snapshot)['entity_id'], '111182')
1099+
1100+
def test_entity_id_empty_falls_back_to_experiment_id(self):
1101+
experiment = self._with_layer_id('')
1102+
snapshot = self._build_impression(experiment, '111129')
1103+
self.assertEqual(self._event(snapshot)['entity_id'], experiment.id)
1104+
1105+
def test_entity_id_non_numeric_falls_back_to_experiment_id(self):
1106+
experiment = self._with_layer_id('abc')
1107+
snapshot = self._build_impression(experiment, '111129')
1108+
self.assertEqual(self._event(snapshot)['entity_id'], experiment.id)
1109+
1110+
def test_entity_id_equals_campaign_id_when_layer_invalid(self):
1111+
experiment = self._with_layer_id('')
1112+
snapshot = self._build_impression(experiment, '111129')
1113+
self.assertEqual(
1114+
self._event(snapshot)['entity_id'],
1115+
self._decision(snapshot)['campaign_id'],
1116+
)
1117+
1118+
def test_entity_id_equals_campaign_id_when_layer_valid(self):
1119+
experiment = self._with_layer_id('111182')
1120+
snapshot = self._build_impression(experiment, '111129')
1121+
self.assertEqual(
1122+
self._event(snapshot)['entity_id'],
1123+
self._decision(snapshot)['campaign_id'],
1124+
)
1125+
1126+
# Negative regression: conversion events are out of scope (FR-010).
1127+
1128+
def test_conversion_event_entity_id_uses_event_id_unchanged(self):
1129+
with mock.patch('time.time', return_value=42.123), mock.patch(
1130+
'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'
1131+
):
1132+
event_obj = self.event_builder.create_conversion_event(
1133+
self.project_config, 'test_event', 'test_user', None, None,
1134+
)
1135+
1136+
snapshot = event_obj.params['visitors'][0]['snapshots'][0]
1137+
event = snapshot['events'][0]
1138+
self.assertEqual(event['entity_id'], self.project_config.get_event('test_event').id)

tests/test_event_factory.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2019, Optimizely
1+
# Copyright 2019, 2026, Optimizely
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -11,6 +11,7 @@
1111
# See the License for the specific language governing permissions and
1212
# limitations under the License.
1313

14+
import copy
1415
from unittest import mock
1516
import time
1617
import unittest
@@ -1237,3 +1238,91 @@ def test_create_impression_event_without_cmab_uuid(self):
12371238
EventFactory.HTTP_VERB,
12381239
EventFactory.HTTP_HEADERS,
12391240
)
1241+
1242+
1243+
class ImpressionEventIdNormalizationFactoryTest(base.BaseTest):
1244+
"""Impression-event normalization rules in EventFactory._create_visitor."""
1245+
1246+
def setUp(self, *args, **kwargs):
1247+
base.BaseTest.setUp(self, 'config_dict_with_multiple_experiments')
1248+
self.logger = logger.NoOpLogger()
1249+
self.experiment = self.project_config.get_experiment_from_key('test_experiment')
1250+
1251+
def _build_visitor(self, layer_id, variation_id):
1252+
from optimizely.event import user_event
1253+
1254+
experiment = copy.deepcopy(self.experiment)
1255+
experiment.layerId = layer_id
1256+
1257+
variation_payload = {'id': variation_id, 'key': 'whatever'} if variation_id is not None else None
1258+
1259+
ctx = user_event.EventContext(
1260+
self.project_config.get_account_id(),
1261+
self.project_config.get_project_id(),
1262+
self.project_config.get_revision(),
1263+
self.project_config.get_anonymize_ip_value(),
1264+
'US',
1265+
)
1266+
impression = user_event.ImpressionEvent(
1267+
event_context=ctx,
1268+
user_id='test_user',
1269+
experiment=experiment,
1270+
visitor_attributes=[],
1271+
variation=variation_payload,
1272+
flag_key='flag_a',
1273+
rule_key=experiment.key,
1274+
rule_type='experiment',
1275+
enabled=True,
1276+
)
1277+
visitor = EventFactory._create_visitor(impression, self.logger)
1278+
self.assertIsNotNone(visitor)
1279+
snapshot = visitor.snapshots[0]
1280+
decision = snapshot.decisions[0]
1281+
snapshot_event = snapshot.events[0]
1282+
return decision, snapshot_event, experiment
1283+
1284+
def test_campaign_id_valid_layer_id_passes_through(self):
1285+
decision, _event, _exp = self._build_visitor('111182', '111129')
1286+
self.assertEqual(decision.campaign_id, '111182')
1287+
1288+
def test_campaign_id_empty_layer_id_falls_back_to_experiment_id(self):
1289+
decision, _event, experiment = self._build_visitor('', '111129')
1290+
self.assertEqual(decision.campaign_id, experiment.id)
1291+
1292+
def test_campaign_id_non_numeric_layer_id_falls_back_to_experiment_id(self):
1293+
decision, _event, experiment = self._build_visitor('abc', '111129')
1294+
self.assertEqual(decision.campaign_id, experiment.id)
1295+
1296+
def test_variation_id_empty_becomes_none(self):
1297+
decision, _event, _exp = self._build_visitor('111182', '')
1298+
self.assertIsNone(decision.variation_id)
1299+
1300+
def test_variation_id_non_numeric_becomes_none(self):
1301+
decision, _event, _exp = self._build_visitor('111182', 'variation_a')
1302+
self.assertIsNone(decision.variation_id)
1303+
1304+
def test_variation_id_valid_passes_through(self):
1305+
decision, _event, _exp = self._build_visitor('111182', '111129')
1306+
self.assertEqual(decision.variation_id, '111129')
1307+
1308+
def test_entity_id_matches_campaign_id_when_layer_invalid(self):
1309+
decision, snapshot_event, _exp = self._build_visitor('', '111129')
1310+
self.assertEqual(snapshot_event.entity_id, decision.campaign_id)
1311+
1312+
def test_entity_id_matches_campaign_id_when_layer_valid(self):
1313+
decision, snapshot_event, _exp = self._build_visitor('111182', '111129')
1314+
self.assertEqual(snapshot_event.entity_id, decision.campaign_id)
1315+
1316+
def test_variation_id_serializes_to_json_null(self):
1317+
decision, _event, _exp = self._build_visitor('111182', 'not_numeric')
1318+
# The EventBatch serializer renders the variation_id field as JSON null,
1319+
# which is the wire contract this fix exists to honor.
1320+
from optimizely.event.payload import EventBatch, Snapshot, Visitor
1321+
batch = EventBatch('acc', 'proj', 'rev', 'python-sdk', '1.0', False, True)
1322+
snapshot = Snapshot([], [decision])
1323+
visitor = Visitor([snapshot], [], 'u')
1324+
batch.visitors = [visitor]
1325+
params = batch.get_event_params()
1326+
rendered = params['visitors'][0]['snapshots'][0]['decisions'][0]
1327+
self.assertIn('variation_id', rendered)
1328+
self.assertIsNone(rendered['variation_id'])

0 commit comments

Comments
 (0)