Skip to content

Commit b11dd6f

Browse files
committed
[FSSDK-12813] Normalize decision event campaign_id, variation_id, and entity_id
1 parent a68b313 commit b11dd6f

6 files changed

Lines changed: 517 additions & 8 deletions

File tree

optimizely/event/event_factory.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from optimizely.helpers import enums
1919
from optimizely.helpers import event_tag_utils
2020
from optimizely.helpers import validator
21+
from . import event_id_normalizer
2122
from . import log_event
2223
from . import payload
2324
from . import user_event
@@ -134,12 +135,25 @@ def _create_visitor(cls, event: Optional[user_event.UserEvent], logger: Logger)
134135
if isinstance(event.experiment, entities.Experiment):
135136
experiment_layerId = event.experiment.layerId
136137

138+
# FSSDK-12813: Normalize decision-event IDs uniformly across all
139+
# decision types (experiment, feature test, rollout, holdout).
140+
# campaign_id falls back to experiment_id when invalid.
141+
# variation_id becomes None when invalid.
142+
# entity_id on the impression event mirrors campaign_id (FR-009)
143+
# so the two fields are byte-equivalent for the same event.
144+
normalized_campaign_id = event_id_normalizer.normalize_campaign_id(
145+
experiment_layerId, experiment_id
146+
)
147+
normalized_variation_id = event_id_normalizer.normalize_variation_id(variation_id)
148+
137149
metadata = payload.Metadata(event.flag_key, event.rule_key,
138150
event.rule_type, variation_key,
139151
event.enabled, event.cmab_uuid)
140-
decision = payload.Decision(experiment_layerId, experiment_id, variation_id, metadata)
152+
decision = payload.Decision(
153+
normalized_campaign_id, experiment_id, normalized_variation_id, metadata
154+
)
141155
snapshot_event = payload.SnapshotEvent(
142-
experiment_layerId, event.uuid, cls.ACTIVATE_EVENT_KEY, event.timestamp,
156+
normalized_campaign_id, event.uuid, cls.ACTIVATE_EVENT_KEY, event.timestamp,
143157
)
144158

145159
snapshot = payload.Snapshot([snapshot_event], [decision])
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Copyright 2026, Optimizely
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
14+
"""Normalization helpers for decision-event ID fields.
15+
16+
This module provides byte-equivalent, cross-SDK normalization for the
17+
``campaign_id``, ``variation_id``, and impression ``entity_id`` fields that
18+
appear in dispatched decision events. See FSSDK-12813.
19+
20+
Rules:
21+
* A "numeric ID string" is a non-empty :class:`str` consisting entirely of
22+
decimal digits ``0-9``. Leading zeros are allowed. Whitespace, negatives,
23+
decimals, and exponents are INVALID.
24+
* ``campaign_id`` -> when invalid, falls back to ``experiment_id`` (which is
25+
itself passed through :func:`normalize_string_id`).
26+
* ``variation_id`` -> when invalid, becomes ``None``.
27+
* ``entity_id`` on impression events shares the campaign_id normalization
28+
and is therefore byte-equivalent to the normalized campaign_id for the
29+
same impression (FR-009).
30+
31+
The normalization path MUST NOT log, warn, or raise. It must never drop or
32+
defer event dispatch.
33+
"""
34+
35+
from __future__ import annotations
36+
37+
from typing import Any, Optional
38+
39+
40+
def is_numeric_id_string(value: Any) -> bool:
41+
"""Return ``True`` if ``value`` is a non-empty decimal-digit string.
42+
43+
Whitespace, signs, decimal points, exponents, and non-string types all
44+
return ``False``. Leading zeros are accepted.
45+
"""
46+
if not isinstance(value, str):
47+
return False
48+
if value == '':
49+
return False
50+
# ``str.isdigit`` rejects everything except [0-9] characters and the
51+
# empty string. We've already excluded the empty case above. Note that
52+
# ``isdigit`` also accepts some non-ASCII digit code points; ``isascii``
53+
# combined with ``isdigit`` restricts us to plain decimal digits.
54+
return value.isascii() and value.isdigit()
55+
56+
57+
def normalize_string_id(value: Any) -> Optional[str]:
58+
"""Return ``value`` if it's a numeric ID string, otherwise ``None``."""
59+
return value if is_numeric_id_string(value) else None
60+
61+
62+
def normalize_campaign_id(campaign_id: Any, experiment_id: Any) -> str:
63+
"""Normalize a decision-event ``campaign_id`` (FR-001/FR-002, FR-009).
64+
65+
If ``campaign_id`` is a valid numeric ID string it is returned unchanged.
66+
Otherwise the function falls back to ``experiment_id`` (after applying
67+
the same validation). If neither is a numeric ID string, an empty string
68+
is returned so the event still dispatches (FR-006).
69+
"""
70+
if is_numeric_id_string(campaign_id):
71+
return campaign_id # type: ignore[no-any-return]
72+
if is_numeric_id_string(experiment_id):
73+
return experiment_id # type: ignore[no-any-return]
74+
return ''
75+
76+
77+
def normalize_variation_id(variation_id: Any) -> Optional[str]:
78+
"""Normalize a decision-event ``variation_id`` (FR-003/FR-004).
79+
80+
Returns the original value if it is a valid numeric ID string. Otherwise
81+
returns ``None`` so the event payload omits/clears the field for the
82+
downstream consumer.
83+
"""
84+
return variation_id if is_numeric_id_string(variation_id) else None

optimizely/event/payload.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,18 @@ 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
83+
# FSSDK-12813: variation_id may be None when input is invalid /
84+
# non-numeric (FR-003/FR-004). All other decision fields remain
85+
# strings.
7786
self.variation_id = variation_id
7887
self.metadata = metadata
7988

optimizely/event_builder.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from sys import version_info
1919

2020
from . import version
21+
from .event import event_id_normalizer
2122
from .helpers import enums
2223
from .helpers import event_tag_utils
2324
from .helpers import validator
@@ -178,7 +179,7 @@ def _get_common_params(
178179

179180
def _get_required_params_for_impression(
180181
self, experiment: Experiment, variation_id: str
181-
) -> dict[str, list[dict[str, str | int]]]:
182+
) -> dict[str, list[dict[str, Any]]]:
182183
""" Get parameters that are required for the impression event to register.
183184
184185
Args:
@@ -188,19 +189,28 @@ def _get_required_params_for_impression(
188189
Returns:
189190
Dict consisting of decisions and events info for impression event.
190191
"""
191-
snapshot: dict[str, list[dict[str, str | int]]] = {}
192+
snapshot: dict[str, list[dict[str, Any]]] = {}
193+
194+
# FSSDK-12813: Normalize decision-event IDs uniformly across all
195+
# decision types. campaign_id falls back to experiment_id when
196+
# invalid; variation_id becomes None when invalid; entity_id mirrors
197+
# the normalized campaign_id (FR-009).
198+
normalized_campaign_id = event_id_normalizer.normalize_campaign_id(
199+
experiment.layerId, experiment.id
200+
)
201+
normalized_variation_id = event_id_normalizer.normalize_variation_id(variation_id)
192202

193203
snapshot[self.EventParams.DECISIONS] = [
194204
{
195205
self.EventParams.EXPERIMENT_ID: experiment.id,
196-
self.EventParams.VARIATION_ID: variation_id,
197-
self.EventParams.CAMPAIGN_ID: experiment.layerId,
206+
self.EventParams.VARIATION_ID: normalized_variation_id,
207+
self.EventParams.CAMPAIGN_ID: normalized_campaign_id,
198208
}
199209
]
200210

201211
snapshot[self.EventParams.EVENTS] = [
202212
{
203-
self.EventParams.EVENT_ID: experiment.layerId,
213+
self.EventParams.EVENT_ID: normalized_campaign_id,
204214
self.EventParams.TIME: self._get_time(),
205215
self.EventParams.KEY: 'campaign_activated',
206216
self.EventParams.UUID: str(uuid.uuid4()),

tests/test_event_factory.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1237,3 +1237,203 @@ def test_create_impression_event_without_cmab_uuid(self):
12371237
EventFactory.HTTP_VERB,
12381238
EventFactory.HTTP_HEADERS,
12391239
)
1240+
1241+
1242+
class EventFactoryIdNormalizationIntegrationTest(base.BaseTest):
1243+
"""FSSDK-12813: end-to-end decision-event ID normalization.
1244+
1245+
These tests build real ``ImpressionEvent`` instances using crafted
1246+
Experiment/Variation objects, then call ``EventFactory.create_log_event``
1247+
and inspect the dispatched payload. They exercise FR-001..FR-009.
1248+
"""
1249+
1250+
def setUp(self, *args, **kwargs):
1251+
base.BaseTest.setUp(self, 'config_dict_with_multiple_experiments')
1252+
self.logger = logger.NoOpLogger()
1253+
1254+
def _build_impression(
1255+
self,
1256+
experiment_id,
1257+
layer_id,
1258+
variation_id,
1259+
rule_type='experiment',
1260+
):
1261+
"""Build an ImpressionEvent with the provided raw ID values.
1262+
1263+
``experiment_id``/``layer_id``/``variation_id`` are inserted verbatim
1264+
so tests can exercise empty/non-string/non-numeric inputs.
1265+
"""
1266+
from optimizely.entities import Experiment, Variation
1267+
from optimizely.event.user_event import EventContext, ImpressionEvent
1268+
1269+
experiment = Experiment(
1270+
id=experiment_id,
1271+
key='exp_key',
1272+
status='Running',
1273+
audienceIds=[],
1274+
variations=[],
1275+
forcedVariations={},
1276+
trafficAllocation=[],
1277+
layerId=layer_id,
1278+
)
1279+
variation = Variation(
1280+
id=variation_id,
1281+
key='variation_key',
1282+
featureEnabled=True,
1283+
) if isinstance(variation_id, str) else None
1284+
1285+
event_context = EventContext(
1286+
account_id='12001',
1287+
project_id='111001',
1288+
revision='42',
1289+
anonymize_ip=False,
1290+
region='US',
1291+
)
1292+
return ImpressionEvent(
1293+
event_context=event_context,
1294+
user_id='test_user',
1295+
experiment=experiment,
1296+
visitor_attributes=[],
1297+
variation=variation,
1298+
flag_key='flag_key',
1299+
rule_key='rule_key',
1300+
rule_type=rule_type,
1301+
enabled=True,
1302+
)
1303+
1304+
def _dispatched_decision(self, impression_event):
1305+
"""Return (decision_dict, event_dict) for an impression event."""
1306+
log_event = EventFactory.create_log_event(impression_event, self.logger)
1307+
snapshot = log_event.params['visitors'][0]['snapshots'][0]
1308+
return snapshot['decisions'][0], snapshot['events'][0]
1309+
1310+
# ------------------------------------------------------------------ FR-001
1311+
def test_valid_campaign_id_is_passed_through(self):
1312+
impression = self._build_impression('111127', '111182', '111129')
1313+
decision, event = self._dispatched_decision(impression)
1314+
self.assertEqual('111182', decision['campaign_id'])
1315+
# FR-009: entity_id mirrors campaign_id byte-for-byte.
1316+
self.assertEqual(decision['campaign_id'], event['entity_id'])
1317+
1318+
# ------------------------------------------------------------------ FR-002
1319+
def test_empty_campaign_id_falls_back_to_experiment_id(self):
1320+
impression = self._build_impression('111127', '', '111129')
1321+
decision, event = self._dispatched_decision(impression)
1322+
self.assertEqual('111127', decision['campaign_id'])
1323+
self.assertEqual('111127', event['entity_id'])
1324+
1325+
def test_non_numeric_campaign_id_falls_back_to_experiment_id(self):
1326+
impression = self._build_impression('111127', 'campaign_a', '111129')
1327+
decision, event = self._dispatched_decision(impression)
1328+
self.assertEqual('111127', decision['campaign_id'])
1329+
self.assertEqual('111127', event['entity_id'])
1330+
1331+
def test_whitespace_campaign_id_falls_back_to_experiment_id(self):
1332+
impression = self._build_impression('111127', ' ', '111129')
1333+
decision, event = self._dispatched_decision(impression)
1334+
self.assertEqual('111127', decision['campaign_id'])
1335+
self.assertEqual('111127', event['entity_id'])
1336+
1337+
# ------------------------------------------------------------------ FR-003
1338+
def test_valid_variation_id_is_passed_through(self):
1339+
impression = self._build_impression('111127', '111182', '111129')
1340+
decision, _ = self._dispatched_decision(impression)
1341+
self.assertEqual('111129', decision['variation_id'])
1342+
1343+
# ------------------------------------------------------------------ FR-004
1344+
def test_empty_variation_id_becomes_none(self):
1345+
impression = self._build_impression('111127', '111182', '')
1346+
decision, _ = self._dispatched_decision(impression)
1347+
self.assertIsNone(decision['variation_id'])
1348+
1349+
def test_non_numeric_variation_id_becomes_none(self):
1350+
impression = self._build_impression('111127', '111182', 'variation_a')
1351+
decision, _ = self._dispatched_decision(impression)
1352+
self.assertIsNone(decision['variation_id'])
1353+
1354+
def test_whitespace_variation_id_becomes_none(self):
1355+
impression = self._build_impression('111127', '111182', ' ')
1356+
decision, _ = self._dispatched_decision(impression)
1357+
self.assertIsNone(decision['variation_id'])
1358+
1359+
# ------------------------------------------------------------------ FR-005
1360+
def test_normalization_applies_to_rollout_decisions(self):
1361+
impression = self._build_impression(
1362+
'111127', 'bad_layer', 'bad_var', rule_type='rollout'
1363+
)
1364+
decision, event = self._dispatched_decision(impression)
1365+
self.assertEqual('111127', decision['campaign_id'])
1366+
self.assertIsNone(decision['variation_id'])
1367+
self.assertEqual('111127', event['entity_id'])
1368+
1369+
def test_normalization_applies_to_feature_test_decisions(self):
1370+
impression = self._build_impression(
1371+
'111127', '', '', rule_type='feature-test'
1372+
)
1373+
decision, event = self._dispatched_decision(impression)
1374+
self.assertEqual('111127', decision['campaign_id'])
1375+
self.assertIsNone(decision['variation_id'])
1376+
self.assertEqual('111127', event['entity_id'])
1377+
1378+
def test_normalization_applies_to_holdout_decisions(self):
1379+
impression = self._build_impression(
1380+
'111127', '', '', rule_type='holdout'
1381+
)
1382+
decision, event = self._dispatched_decision(impression)
1383+
self.assertEqual('111127', decision['campaign_id'])
1384+
self.assertIsNone(decision['variation_id'])
1385+
self.assertEqual('111127', event['entity_id'])
1386+
1387+
# ------------------------------------------------------------------ FR-006
1388+
def test_event_still_dispatches_when_all_ids_invalid(self):
1389+
"""FR-006: never drop / fail dispatch."""
1390+
impression = self._build_impression('', '', '')
1391+
log_event = EventFactory.create_log_event(impression, self.logger)
1392+
self.assertIsNotNone(log_event)
1393+
decision, event = self._dispatched_decision(impression)
1394+
# campaign_id and entity_id end up as '' but the event still
1395+
# dispatches and the two fields remain byte-equivalent.
1396+
self.assertEqual('', decision['campaign_id'])
1397+
self.assertEqual('', event['entity_id'])
1398+
self.assertIsNone(decision['variation_id'])
1399+
1400+
# ------------------------------------------------------------------ FR-009
1401+
def test_entity_id_equals_campaign_id_byte_for_byte(self):
1402+
"""FR-009: ``events[].entity_id`` must equal ``decisions[].campaign_id``."""
1403+
for layer_id, exp_id, expected in [
1404+
('111182', '111127', '111182'), # campaign_id wins
1405+
('', '111127', '111127'), # falls back to experiment_id
1406+
('bad', '111127', '111127'), # fallback on invalid
1407+
('007', '111127', '007'), # leading zeros preserved
1408+
]:
1409+
with self.subTest(layer_id=layer_id, exp_id=exp_id):
1410+
impression = self._build_impression(exp_id, layer_id, '111129')
1411+
decision, event = self._dispatched_decision(impression)
1412+
self.assertEqual(expected, decision['campaign_id'])
1413+
self.assertEqual(decision['campaign_id'], event['entity_id'])
1414+
1415+
# ----------------------------------------------------------------- FR-010
1416+
def test_conversion_event_entity_id_unchanged(self):
1417+
"""FR-010: conversion events derive entity_id from event.id, not the
1418+
normalizer.
1419+
"""
1420+
from optimizely.event.user_event_factory import UserEventFactory
1421+
1422+
with mock.patch('time.time', return_value=42.123), mock.patch(
1423+
'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'
1424+
):
1425+
conversion_event = UserEventFactory.create_conversion_event(
1426+
self.project_config,
1427+
'test_event',
1428+
'test_user',
1429+
None,
1430+
None,
1431+
)
1432+
log_event = EventFactory.create_log_event(conversion_event, self.logger)
1433+
snapshot = log_event.params['visitors'][0]['snapshots'][0]
1434+
# Conversion entity_id comes from the event.id of the conversion event
1435+
# and must NOT pass through the campaign_id normalizer.
1436+
self.assertEqual(
1437+
self.project_config.get_event('test_event').id,
1438+
snapshot['events'][0]['entity_id'],
1439+
)

0 commit comments

Comments
 (0)