Skip to content

Commit 5b3b774

Browse files
authored
[AI-FSSDK] [FSSDK-12813] Normalize decision event campaign_id, variation_id, and entity_id (#401)
* [FSSDK-12813] Normalize decision event campaign_id, variation_id, and entity_id * [FSSDK-12813] Relax campaign_id/entity_id validation to non-empty string per updated spec Per updated FSSDK-12813 spec, campaign_id and entity_id now accept any non-empty string (numeric or opaque, e.g. "default-12345", "layer_abc"). The fallback to experiment_id fires ONLY when the value is empty string, nil, or missing. variation_id retains the stricter numeric-string-only contract — empty/whitespace/non-numeric values still normalize to nil. Non-string types remain out of scope per spec. Changes: - EventIdValidator: introduce non_empty_string? predicate; use it for campaign_id normalization while keeping numeric_string? for variation_id. - Unit tests: add non_empty_string? coverage; update normalize_campaign_id tests so opaque/whitespace inputs pass through (per relaxed FR-001). - EventFactory integration tests: flip non-numeric/whitespace campaign_id cases from fallback assertions to passthrough assertions. - project_spec.rb: update a pre-existing wire-payload assertion to expect variation_id: nil (was '') per the variation_id normalization. * [FSSDK-12813] Remove ticket references from code comments per cross-sdk guideline * [FSSDK-12813] Add 2026 to copyright year in changed files * [FSSDK-12813] Trigger CI
1 parent 83b0a0f commit 5b3b774

5 files changed

Lines changed: 554 additions & 7 deletions

File tree

‎lib/optimizely/event/event_factory.rb‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# frozen_string_literal: true
22

33
#
4-
# Copyright 2019-2020, 2022-2023, Optimizely and contributors
4+
# Copyright 2019-2020, 2022-2023, 2026, Optimizely and contributors
55
#
66
# Licensed under the Apache License, Version 2.0 (the "License");
77
# you may not use this file except in compliance with the License.
@@ -23,6 +23,7 @@
2323
require_relative 'entity/snapshot_event'
2424
require_relative 'entity/visitor'
2525
require 'optimizely/helpers/validator'
26+
require 'optimizely/helpers/event_id_validator'
2627
module Optimizely
2728
class EventFactory
2829
# EventFactory builds LogEvent objects from a given user_event.
@@ -108,15 +109,24 @@ def build_attribute_list(user_attributes, project_config)
108109
private
109110

110111
def create_impression_event_visitor(impression_event)
112+
normalized_campaign_id = Helpers::EventIdValidator.normalize_campaign_id(
113+
impression_event.experiment_layer_id, impression_event.experiment_id
114+
)
115+
normalized_variation_id = Helpers::EventIdValidator.normalize_variation_id(
116+
impression_event.variation_id
117+
)
118+
111119
decision = Decision.new(
112-
campaign_id: impression_event.experiment_layer_id,
120+
campaign_id: normalized_campaign_id,
113121
experiment_id: impression_event.experiment_id,
114-
variation_id: impression_event.variation_id,
122+
variation_id: normalized_variation_id,
115123
metadata: impression_event.metadata
116124
)
117125

126+
# FR-009: entity_id on impression events must equal decisions[].campaign_id
127+
# byte-for-byte. Reuse the already-normalized campaign_id.
118128
snapshot_event = Optimizely::SnapshotEvent.new(
119-
entity_id: impression_event.experiment_layer_id,
129+
entity_id: normalized_campaign_id,
120130
timestamp: impression_event.timestamp,
121131
uuid: impression_event.uuid,
122132
key: ACTIVATE_EVENT_KEY
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# frozen_string_literal: true
2+
3+
#
4+
# Copyright 2026, Optimizely and contributors
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
module Optimizely
19+
module Helpers
20+
# EventIdValidator normalizes ID fields on outgoing decision events so
21+
# that the wire payload is byte-equivalent across SDKs.
22+
#
23+
# Two contracts apply:
24+
# * campaign_id and entity_id (impression events): valid iff non-empty
25+
# string. Any character content is acceptable — IDs may be opaque,
26+
# e.g. "default-12345", "layer_abc". The fallback to experiment_id
27+
# fires ONLY when the value is empty string, nil, or missing.
28+
# * variation_id: valid iff non-empty string of decimal digits [0-9].
29+
# Leading zeros are allowed. Whitespace, signs, decimal points, and
30+
# exponents are NOT allowed. Falls back to nil otherwise.
31+
#
32+
# Non-string types (raw number, boolean, object) are out of scope per
33+
# spec — behavior on such inputs is undefined and not asserted.
34+
#
35+
# This module is silent by design: per spec it must NOT log or warn on the
36+
# normalization path (FR-007), and must NOT fail or defer event dispatch
37+
# (FR-006).
38+
module EventIdValidator
39+
module_function
40+
41+
# Matches a non-empty string of decimal digits only (no sign, no
42+
# whitespace, no decimal point, no exponent).
43+
NUMERIC_STRING_PATTERN = /\A[0-9]+\z/
44+
45+
# Returns true when value is a non-empty string consisting entirely of
46+
# decimal digits. Returns false for nil, non-strings, empty strings, or
47+
# strings containing any non-digit character (including whitespace).
48+
# Used for variation_id (which retains the stricter numeric-only
49+
# contract).
50+
def numeric_string?(value)
51+
value.is_a?(String) && NUMERIC_STRING_PATTERN.match?(value)
52+
end
53+
54+
# Returns true when value is a non-empty string of any character
55+
# content. Returns false for nil, non-strings, or empty strings.
56+
# Used for campaign_id and entity_id (which accept opaque IDs).
57+
def non_empty_string?(value)
58+
value.is_a?(String) && !value.empty?
59+
end
60+
61+
# Normalize a decision's campaign_id (and impression event's entity_id).
62+
#
63+
# When the provided campaign_id is a non-empty string (numeric or
64+
# opaque), return it unchanged. Otherwise, substitute experiment_id
65+
# when experiment_id is itself a non-empty string. When neither is a
66+
# non-empty string, return whatever experiment_id was (typically '' or
67+
# nil) so the wire payload remains consistent with upstream contract.
68+
def normalize_campaign_id(campaign_id, experiment_id)
69+
return campaign_id if non_empty_string?(campaign_id)
70+
71+
# Per FR-002, fall back to experiment_id. Per the spec edge case,
72+
# when experiment_id is itself empty/null we still emit the event
73+
# (FR-006) carrying whatever experiment_id value was present —
74+
# represented here as an empty string to preserve string typing on
75+
# the wire payload.
76+
return experiment_id if non_empty_string?(experiment_id)
77+
78+
''
79+
end
80+
81+
# Normalize a decision's variation_id.
82+
#
83+
# When the provided variation_id is a valid numeric string, return it
84+
# unchanged. Otherwise return nil so the wire payload encodes the field
85+
# as JSON null. variation_id retains the stricter numeric-string-only
86+
# contract — opaque/non-numeric placeholders are normalized to nil.
87+
def normalize_variation_id(variation_id)
88+
return variation_id if numeric_string?(variation_id)
89+
90+
nil
91+
end
92+
end
93+
end
94+
end

‎spec/event/event_factory_spec.rb‎

Lines changed: 238 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# frozen_string_literal: true
22

33
#
4-
# Copyright 2019-2020, Optimizely and contributors
4+
# Copyright 2019-2020, 2026, Optimizely and contributors
55
#
66
# Licensed under the Apache License, Version 2.0 (the "License");
77
# you may not use this file except in compliance with the License.
@@ -741,4 +741,241 @@
741741
expect(log_event.url).to eq(@expected_endpoints[:US])
742742
expect(log_event.http_verb).to eq(:post)
743743
end
744+
745+
# ----------------------------------------------------------------------
746+
# Decision-event ID normalization tests.
747+
#
748+
# These integration tests exercise the EventFactory wire payload to verify
749+
# that campaign_id, variation_id, and entity_id are normalized uniformly
750+
# across every decision type (experiment, feature test, rollout, holdout).
751+
# ----------------------------------------------------------------------
752+
describe 'decision event ID normalization' do
753+
let(:event_context) do
754+
Optimizely::EventContext.new(
755+
region: 'US',
756+
account_id: '12001',
757+
project_id: '111001',
758+
anonymize_ip: false,
759+
revision: '42',
760+
client_name: Optimizely::CLIENT_ENGINE,
761+
client_version: Optimizely::VERSION
762+
).as_json
763+
end
764+
let(:metadata) do
765+
{
766+
flag_key: 'flag_a',
767+
rule_key: 'rule_a',
768+
rule_type: 'experiment',
769+
variation_key: 'var_a',
770+
enabled: true
771+
}
772+
end
773+
774+
def build_impression(experiment_layer_id:, experiment_id:, variation_id:)
775+
Optimizely::ImpressionEvent.new(
776+
event_context: event_context,
777+
user_id: 'test_user',
778+
experiment_layer_id: experiment_layer_id,
779+
experiment_id: experiment_id,
780+
variation_id: variation_id,
781+
metadata: metadata,
782+
visitor_attributes: [],
783+
bot_filtering: nil
784+
)
785+
end
786+
787+
def first_decision(log_event)
788+
log_event.params[:visitors][0][:snapshots][0][:decisions][0]
789+
end
790+
791+
def first_event(log_event)
792+
log_event.params[:visitors][0][:snapshots][0][:events][0]
793+
end
794+
795+
it 'passes valid numeric campaign_id and variation_id through unchanged' do
796+
impression = build_impression(
797+
experiment_layer_id: '111111', experiment_id: '222222', variation_id: '333333'
798+
)
799+
log_event = Optimizely::EventFactory.create_log_event(impression, spy_logger)
800+
decision = first_decision(log_event)
801+
event = first_event(log_event)
802+
803+
expect(decision[:campaign_id]).to eq('111111')
804+
expect(decision[:experiment_id]).to eq('222222')
805+
expect(decision[:variation_id]).to eq('333333')
806+
expect(event[:entity_id]).to eq('111111')
807+
expect(event[:entity_id]).to eq(decision[:campaign_id])
808+
end
809+
810+
it 'substitutes experiment_id when campaign_id (layerId) is nil' do
811+
# FR-001/FR-002: nil campaign_id must be replaced with experiment_id.
812+
impression = build_impression(
813+
experiment_layer_id: nil, experiment_id: '222222', variation_id: '333333'
814+
)
815+
log_event = Optimizely::EventFactory.create_log_event(impression, spy_logger)
816+
decision = first_decision(log_event)
817+
event = first_event(log_event)
818+
819+
expect(decision[:campaign_id]).to eq('222222')
820+
# FR-009: entity_id must equal decisions[].campaign_id byte-for-byte.
821+
expect(event[:entity_id]).to eq('222222')
822+
end
823+
824+
it 'substitutes experiment_id when campaign_id is an empty string' do
825+
impression = build_impression(
826+
experiment_layer_id: '', experiment_id: '222222', variation_id: '333333'
827+
)
828+
log_event = Optimizely::EventFactory.create_log_event(impression, spy_logger)
829+
expect(first_decision(log_event)[:campaign_id]).to eq('222222')
830+
expect(first_event(log_event)[:entity_id]).to eq('222222')
831+
end
832+
833+
it 'passes whitespace campaign_id through unchanged (relaxed contract: non-empty string)' do
834+
# Per relaxed spec, any non-empty string is valid for campaign_id —
835+
# only empty string / nil / missing trigger the experiment_id fallback.
836+
impression = build_impression(
837+
experiment_layer_id: ' ', experiment_id: '222222', variation_id: '333333'
838+
)
839+
log_event = Optimizely::EventFactory.create_log_event(impression, spy_logger)
840+
expect(first_decision(log_event)[:campaign_id]).to eq(' ')
841+
expect(first_event(log_event)[:entity_id]).to eq(' ')
842+
end
843+
844+
it 'passes non-numeric opaque campaign_id through unchanged (relaxed contract)' do
845+
# Per relaxed spec, opaque IDs such as "default-12345" or "layer_abc"
846+
# are valid for campaign_id and entity_id; only empty/null trigger the
847+
# experiment_id fallback.
848+
impression = build_impression(
849+
experiment_layer_id: 'campaign_a', experiment_id: '222222', variation_id: '333333'
850+
)
851+
log_event = Optimizely::EventFactory.create_log_event(impression, spy_logger)
852+
expect(first_decision(log_event)[:campaign_id]).to eq('campaign_a')
853+
expect(first_event(log_event)[:entity_id]).to eq('campaign_a')
854+
end
855+
856+
it 'passes prefixed opaque campaign_id (default-12345) through unchanged' do
857+
impression = build_impression(
858+
experiment_layer_id: 'default-12345', experiment_id: '222222', variation_id: '333333'
859+
)
860+
log_event = Optimizely::EventFactory.create_log_event(impression, spy_logger)
861+
expect(first_decision(log_event)[:campaign_id]).to eq('default-12345')
862+
expect(first_event(log_event)[:entity_id]).to eq('default-12345')
863+
end
864+
865+
it 'falls back to empty string when both campaign_id and experiment_id are invalid' do
866+
# Mirrors the legacy empty-slot impression case that send_impression emits
867+
# when there is no decision (e.g. send_flag_decisions and no rule matched).
868+
impression = build_impression(
869+
experiment_layer_id: '', experiment_id: '', variation_id: nil
870+
)
871+
log_event = Optimizely::EventFactory.create_log_event(impression, spy_logger)
872+
decision = first_decision(log_event)
873+
event = first_event(log_event)
874+
875+
expect(decision[:campaign_id]).to eq('')
876+
expect(decision[:variation_id]).to be_nil
877+
expect(event[:entity_id]).to eq('')
878+
end
879+
880+
it 'normalizes invalid variation_id to nil (empty string case)' do
881+
# FR-003/FR-004: empty variation_id becomes nil.
882+
impression = build_impression(
883+
experiment_layer_id: '111111', experiment_id: '222222', variation_id: ''
884+
)
885+
log_event = Optimizely::EventFactory.create_log_event(impression, spy_logger)
886+
expect(first_decision(log_event)[:variation_id]).to be_nil
887+
end
888+
889+
it 'normalizes invalid variation_id to nil (non-numeric placeholder case)' do
890+
impression = build_impression(
891+
experiment_layer_id: '111111', experiment_id: '222222', variation_id: 'variation_a'
892+
)
893+
log_event = Optimizely::EventFactory.create_log_event(impression, spy_logger)
894+
expect(first_decision(log_event)[:variation_id]).to be_nil
895+
end
896+
897+
it 'normalizes invalid variation_id to nil (whitespace case)' do
898+
impression = build_impression(
899+
experiment_layer_id: '111111', experiment_id: '222222', variation_id: ' '
900+
)
901+
log_event = Optimizely::EventFactory.create_log_event(impression, spy_logger)
902+
expect(first_decision(log_event)[:variation_id]).to be_nil
903+
end
904+
905+
it 'normalizes invalid variation_id to nil (non-string case)' do
906+
impression = build_impression(
907+
experiment_layer_id: '111111', experiment_id: '222222', variation_id: 333_333
908+
)
909+
log_event = Optimizely::EventFactory.create_log_event(impression, spy_logger)
910+
expect(first_decision(log_event)[:variation_id]).to be_nil
911+
end
912+
913+
it 'leaves valid nil variation_id as nil (already-normalized)' do
914+
impression = build_impression(
915+
experiment_layer_id: '111111', experiment_id: '222222', variation_id: nil
916+
)
917+
log_event = Optimizely::EventFactory.create_log_event(impression, spy_logger)
918+
expect(first_decision(log_event)[:variation_id]).to be_nil
919+
end
920+
921+
it 'applies the same normalization for holdout decision metadata (FR-005)' do
922+
# FR-005: normalization must be uniform across decision types. A holdout
923+
# decision carries rule_type 'holdout' in metadata but still flows through
924+
# the same impression factory path — so the same normalization applies.
925+
holdout_metadata = metadata.merge(rule_type: 'holdout')
926+
impression = Optimizely::ImpressionEvent.new(
927+
event_context: event_context,
928+
user_id: 'test_user',
929+
experiment_layer_id: '', # holdout with no layer id
930+
experiment_id: '999777', # falls back to holdout id
931+
variation_id: 'invalid_placeholder',
932+
metadata: holdout_metadata,
933+
visitor_attributes: [],
934+
bot_filtering: nil
935+
)
936+
log_event = Optimizely::EventFactory.create_log_event(impression, spy_logger)
937+
decision = first_decision(log_event)
938+
event = first_event(log_event)
939+
940+
expect(decision[:campaign_id]).to eq('999777')
941+
expect(decision[:variation_id]).to be_nil
942+
expect(event[:entity_id]).to eq('999777')
943+
expect(decision[:metadata][:rule_type]).to eq('holdout')
944+
end
945+
946+
it 'does not log or warn on the normalization path (FR-007)' do
947+
impression = build_impression(
948+
experiment_layer_id: '', experiment_id: '222222', variation_id: 'bad'
949+
)
950+
Optimizely::EventFactory.create_log_event(impression, spy_logger)
951+
952+
# spy_logger.log should not have been invoked for any normalization
953+
# bookkeeping (we still allow other log calls from upstream code paths,
954+
# but in this isolated test there are none).
955+
expect(spy_logger).not_to have_received(:log)
956+
end
957+
958+
it 'still emits the event payload when IDs are invalid (FR-006: do not drop)' do
959+
impression = build_impression(
960+
experiment_layer_id: nil, experiment_id: nil, variation_id: 'bad'
961+
)
962+
log_event = Optimizely::EventFactory.create_log_event(impression, spy_logger)
963+
expect(log_event).not_to be_nil
964+
expect(log_event.params[:visitors][0][:snapshots][0][:decisions]).not_to be_empty
965+
expect(log_event.params[:visitors][0][:snapshots][0][:events]).not_to be_empty
966+
end
967+
968+
it 'does not normalize conversion event entity_id (FR-010)' do
969+
# Conversion events derive entity_id from the event id source and must
970+
# remain unchanged. The conversion fixture's entity_id (111095) is
971+
# already numeric and must be preserved verbatim.
972+
experiment_event = project_config.get_event_from_key('test_event')
973+
allow(project_config).to receive(:bot_filtering).and_return(true)
974+
conversion_event = Optimizely::UserEventFactory.create_conversion_event(
975+
project_config, experiment_event, 'test_user', nil, nil
976+
)
977+
log_event = Optimizely::EventFactory.create_log_event(conversion_event, spy_logger)
978+
expect(first_event(log_event)[:entity_id]).to eq('111095')
979+
end
980+
end
744981
end

0 commit comments

Comments
 (0)