Skip to content

Commit 603d359

Browse files
Merge branch 'master' into ai/raju-opti/FSSDK-12750-cmab-attribute-id-fix
2 parents ceb6c70 + a1e0da8 commit 603d359

8 files changed

Lines changed: 571 additions & 14 deletions

optimizely/event/event_factory.py

Lines changed: 11 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
@@ -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,19 @@ 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+
normalized_campaign_id = event_id_normalizer.normalize_campaign_id(
139+
experiment_layerId, experiment_id
140+
)
141+
normalized_variation_id = event_id_normalizer.normalize_variation_id(variation_id)
142+
137143
metadata = payload.Metadata(event.flag_key, event.rule_key,
138144
event.rule_type, variation_key,
139145
event.enabled, event.cmab_uuid)
140-
decision = payload.Decision(experiment_layerId, experiment_id, variation_id, metadata)
146+
decision = payload.Decision(
147+
normalized_campaign_id, experiment_id, normalized_variation_id, metadata
148+
)
141149
snapshot_event = payload.SnapshotEvent(
142-
experiment_layerId, event.uuid, cls.ACTIVATE_EVENT_KEY, event.timestamp,
150+
normalized_campaign_id, event.uuid, cls.ACTIVATE_EVENT_KEY, event.timestamp,
143151
)
144152

145153
snapshot = payload.Snapshot([snapshot_event], [decision])
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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.
19+
20+
Rules:
21+
* ``campaign_id`` and impression ``entity_id`` accept **any non-empty
22+
string** (numeric like ``"12345"`` or opaque like ``"default-12345"`` /
23+
``"layer_abc"``). The fallback to ``experiment_id`` fires ONLY when the
24+
value is the empty string, ``None``, or missing. Non-string types are
25+
out of scope for this normalization path (the upstream datafile
26+
producer delivers string or null values).
27+
* ``variation_id`` retains the stricter contract: it MUST be a non-empty
28+
string of decimal digits ``0-9`` (leading zeros allowed). Empty,
29+
whitespace, non-string, and non-numeric inputs are normalized to
30+
``None`` so the wire payload carries an explicit null.
31+
* ``entity_id`` on impression events shares the campaign_id normalization
32+
and is therefore byte-equivalent to the normalized campaign_id for the
33+
same impression.
34+
35+
The normalization path MUST NOT log, warn, or raise. It must never drop or
36+
defer event dispatch.
37+
"""
38+
39+
40+
from sys import version_info
41+
from typing import Any, Optional
42+
43+
if version_info < (3, 10):
44+
from typing_extensions import TypeGuard
45+
else:
46+
from typing import TypeGuard
47+
48+
49+
def is_non_empty_string(value: Any) -> TypeGuard[str]:
50+
"""Return ``True`` if ``value`` is a non-empty :class:`str`.
51+
Any non-empty string is accepted regardless of
52+
character content (IDs may be opaque, e.g. ``"default-12345"``).
53+
"""
54+
return isinstance(value, str) and value != ''
55+
56+
57+
def is_numeric_id_string(value: Any) -> TypeGuard[str]:
58+
"""Return ``True`` if ``value`` is a non-empty decimal-digit string.
59+
Whitespace, signs, decimal points, exponents
60+
and non-string types all return ``False``. Leading
61+
zeros are accepted.
62+
"""
63+
if not isinstance(value, str):
64+
return False
65+
if value == '':
66+
return False
67+
return value.isascii() and value.isdigit()
68+
69+
70+
def normalize_campaign_id(campaign_id: Any, experiment_id: Any) -> str:
71+
"""Normalize a decision-event ``campaign_id``.
72+
73+
Returns ``campaign_id`` unchanged when it is a non-empty string (any
74+
character content — numeric like ``"12345"`` or opaque like
75+
``"default-12345"``). Otherwise falls back to ``experiment_id`` (when it
76+
is itself a non-empty string). If neither is a non-empty string, returns
77+
an empty string so the event still dispatches.
78+
"""
79+
if is_non_empty_string(campaign_id):
80+
return campaign_id
81+
if is_non_empty_string(experiment_id):
82+
return experiment_id
83+
return ''
84+
85+
86+
def normalize_variation_id(variation_id: Any) -> Optional[str]:
87+
"""Normalize a decision-event ``variation_id``.
88+
89+
Returns the original value if it is a valid numeric ID string. Otherwise
90+
returns ``None`` so the event payload carries an explicit null for the
91+
downstream consumer.
92+
"""
93+
return variation_id if is_numeric_id_string(variation_id) else None

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: 12 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
@@ -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,24 @@ 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+
normalized_campaign_id = event_id_normalizer.normalize_campaign_id(
195+
experiment.layerId, experiment.id
196+
)
197+
normalized_variation_id = event_id_normalizer.normalize_variation_id(variation_id)
192198

193199
snapshot[self.EventParams.DECISIONS] = [
194200
{
195201
self.EventParams.EXPERIMENT_ID: experiment.id,
196-
self.EventParams.VARIATION_ID: variation_id,
197-
self.EventParams.CAMPAIGN_ID: experiment.layerId,
202+
self.EventParams.VARIATION_ID: normalized_variation_id,
203+
self.EventParams.CAMPAIGN_ID: normalized_campaign_id,
198204
}
199205
]
200206

201207
snapshot[self.EventParams.EVENTS] = [
202208
{
203-
self.EventParams.EVENT_ID: experiment.layerId,
209+
self.EventParams.EVENT_ID: normalized_campaign_id,
204210
self.EventParams.TIME: self._get_time(),
205211
self.EventParams.KEY: 'campaign_activated',
206212
self.EventParams.UUID: str(uuid.uuid4()),

optimizely/project_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any):
122122
self.global_holdouts.append(holdout)
123123

124124
# Process local holdouts: every entry must carry 'includedRules' (list of rule IDs).
125-
# Entries without 'includedRules' are invalid per spec — log an error and exclude
125+
# Entries without 'includedRules' are invalid — log an error and exclude
126126
# them from evaluation (do NOT fall back to global application).
127127
for holdout_data in local_holdouts_data:
128128
if 'includedRules' not in holdout_data or holdout_data.get('includedRules') is None:

0 commit comments

Comments
 (0)