Skip to content
14 changes: 11 additions & 3 deletions optimizely/event/event_factory.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2019, 2022, Optimizely
# Copyright 2019, 2022, 2026, Optimizely
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
Expand All @@ -18,6 +18,7 @@
from optimizely.helpers import enums
from optimizely.helpers import event_tag_utils
from optimizely.helpers import validator
from . import event_id_normalizer
from . import log_event
from . import payload
from . import user_event
Expand Down Expand Up @@ -134,12 +135,19 @@ def _create_visitor(cls, event: Optional[user_event.UserEvent], logger: Logger)
if isinstance(event.experiment, entities.Experiment):
experiment_layerId = event.experiment.layerId

normalized_campaign_id = event_id_normalizer.normalize_campaign_id(
experiment_layerId, experiment_id
)
normalized_variation_id = event_id_normalizer.normalize_variation_id(variation_id)

metadata = payload.Metadata(event.flag_key, event.rule_key,
event.rule_type, variation_key,
event.enabled, event.cmab_uuid)
decision = payload.Decision(experiment_layerId, experiment_id, variation_id, metadata)
decision = payload.Decision(
normalized_campaign_id, experiment_id, normalized_variation_id, metadata
)
snapshot_event = payload.SnapshotEvent(
experiment_layerId, event.uuid, cls.ACTIVATE_EVENT_KEY, event.timestamp,
normalized_campaign_id, event.uuid, cls.ACTIVATE_EVENT_KEY, event.timestamp,
)

snapshot = payload.Snapshot([snapshot_event], [decision])
Expand Down
93 changes: 93 additions & 0 deletions optimizely/event/event_id_normalizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Copyright 2026, Optimizely
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Normalization helpers for decision-event ID fields.

This module provides byte-equivalent, cross-SDK normalization for the
``campaign_id``, ``variation_id``, and impression ``entity_id`` fields that
appear in dispatched decision events.

Rules:
* ``campaign_id`` and impression ``entity_id`` accept **any non-empty
string** (numeric like ``"12345"`` or opaque like ``"default-12345"`` /
``"layer_abc"``). The fallback to ``experiment_id`` fires ONLY when the
value is the empty string, ``None``, or missing. Non-string types are
out of scope for this normalization path (the upstream datafile
producer delivers string or null values).
* ``variation_id`` retains the stricter contract: it MUST be a non-empty
string of decimal digits ``0-9`` (leading zeros allowed). Empty,
whitespace, non-string, and non-numeric inputs are normalized to
``None`` so the wire payload carries an explicit null.
* ``entity_id`` on impression events shares the campaign_id normalization
and is therefore byte-equivalent to the normalized campaign_id for the
same impression.

The normalization path MUST NOT log, warn, or raise. It must never drop or
defer event dispatch.
"""


from sys import version_info
from typing import Any, Optional

if version_info < (3, 10):
from typing_extensions import TypeGuard
else:
from typing import TypeGuard


def is_non_empty_string(value: Any) -> TypeGuard[str]:
"""Return ``True`` if ``value`` is a non-empty :class:`str`.
Any non-empty string is accepted regardless of
character content (IDs may be opaque, e.g. ``"default-12345"``).
"""
return isinstance(value, str) and value != ''


def is_numeric_id_string(value: Any) -> TypeGuard[str]:
"""Return ``True`` if ``value`` is a non-empty decimal-digit string.
Whitespace, signs, decimal points, exponents
and non-string types all return ``False``. Leading
zeros are accepted.
"""
if not isinstance(value, str):
return False
if value == '':
return False
return value.isascii() and value.isdigit()


def normalize_campaign_id(campaign_id: Any, experiment_id: Any) -> str:
"""Normalize a decision-event ``campaign_id``.

Returns ``campaign_id`` unchanged when it is a non-empty string (any
character content — numeric like ``"12345"`` or opaque like
``"default-12345"``). Otherwise falls back to ``experiment_id`` (when it
is itself a non-empty string). If neither is a non-empty string, returns
an empty string so the event still dispatches.
"""
if is_non_empty_string(campaign_id):
return campaign_id
if is_non_empty_string(experiment_id):
return experiment_id
return ''


def normalize_variation_id(variation_id: Any) -> Optional[str]:
"""Normalize a decision-event ``variation_id``.

Returns the original value if it is a valid numeric ID string. Otherwise
returns ``None`` so the event payload carries an explicit null for the
downstream consumer.
"""
return variation_id if is_numeric_id_string(variation_id) else None
10 changes: 8 additions & 2 deletions optimizely/event/payload.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2019, 2022, Optimizely
# Copyright 2019, 2022, 2026, Optimizely
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
Expand Down Expand Up @@ -71,7 +71,13 @@ def get_event_params(self) -> dict[str, Any]:
class Decision:
""" Class respresenting Decision. """

def __init__(self, campaign_id: str, experiment_id: str, variation_id: str, metadata: Metadata):
def __init__(
self,
campaign_id: str,
experiment_id: str,
variation_id: Optional[str],
metadata: Metadata,
):
self.campaign_id = campaign_id
self.experiment_id = experiment_id
self.variation_id = variation_id
Expand Down
18 changes: 12 additions & 6 deletions optimizely/event_builder.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2016-2019, 2022, Optimizely
# Copyright 2016-2019, 2022, 2026, Optimizely
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
Expand All @@ -18,6 +18,7 @@
from sys import version_info

from . import version
from .event import event_id_normalizer
from .helpers import enums
from .helpers import event_tag_utils
from .helpers import validator
Expand Down Expand Up @@ -178,7 +179,7 @@ def _get_common_params(

def _get_required_params_for_impression(
self, experiment: Experiment, variation_id: str
) -> dict[str, list[dict[str, str | int]]]:
) -> dict[str, list[dict[str, Any]]]:
""" Get parameters that are required for the impression event to register.

Args:
Expand All @@ -188,19 +189,24 @@ def _get_required_params_for_impression(
Returns:
Dict consisting of decisions and events info for impression event.
"""
snapshot: dict[str, list[dict[str, str | int]]] = {}
snapshot: dict[str, list[dict[str, Any]]] = {}

normalized_campaign_id = event_id_normalizer.normalize_campaign_id(
experiment.layerId, experiment.id
)
normalized_variation_id = event_id_normalizer.normalize_variation_id(variation_id)

snapshot[self.EventParams.DECISIONS] = [
{
self.EventParams.EXPERIMENT_ID: experiment.id,
self.EventParams.VARIATION_ID: variation_id,
self.EventParams.CAMPAIGN_ID: experiment.layerId,
self.EventParams.VARIATION_ID: normalized_variation_id,
self.EventParams.CAMPAIGN_ID: normalized_campaign_id,
}
]

snapshot[self.EventParams.EVENTS] = [
{
self.EventParams.EVENT_ID: experiment.layerId,
self.EventParams.EVENT_ID: normalized_campaign_id,
self.EventParams.TIME: self._get_time(),
self.EventParams.KEY: 'campaign_activated',
self.EventParams.UUID: str(uuid.uuid4()),
Expand Down
2 changes: 1 addition & 1 deletion optimizely/project_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any):
self.global_holdouts.append(holdout)

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