Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
102 changes: 102 additions & 0 deletions optimizely/event/event_id_normalizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# 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 (per spec assumptions; 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 (FR-009).

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

from __future__ import annotations

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`.

Used for ``campaign_id`` and ``entity_id`` validation per the relaxed
FR-001 / FR-009 contract: 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.

Used for ``variation_id`` validation per FR-003 (the only field that
retains the strict numeric-string contract). 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
# ``str.isdigit`` rejects everything except [0-9] characters and the
# empty string. We've already excluded the empty case above. Note that
# ``isdigit`` also accepts some non-ASCII digit code points; ``isascii``
# combined with ``isdigit`` restricts us to plain decimal digits.
return value.isascii() and value.isdigit()


def normalize_campaign_id(campaign_id: Any, experiment_id: Any) -> str:
"""Normalize a decision-event ``campaign_id`` (FR-001/FR-002, FR-009).

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 (FR-006).
"""
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`` (FR-003/FR-004).

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
Loading
Loading