Skip to content

Commit 6eb1f6a

Browse files
committed
[FSSDK-12813] Normalize decision event campaign_id, variation_id, and entity_id
1 parent ecb136d commit 6eb1f6a

6 files changed

Lines changed: 746 additions & 42 deletions

File tree

‎core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
*
3-
* Copyright 2016-2020, 2022, Optimizely and contributors
3+
* Copyright 2016-2020, 2022, 2025, Optimizely and contributors
44
*
55
* Licensed under the Apache License, Version 2.0 (the "License");
66
* you may not use this file except in compliance with the License.
@@ -97,18 +97,28 @@ private static Visitor createVisitor(ImpressionEvent impressionEvent) {
9797

9898
UserContext userContext = impressionEvent.getUserContext();
9999

100+
// FSSDK-12813: Normalize identifier fields uniformly across all decision types
101+
// (experiment, feature test, rollout, holdout). Empty / null / non-numeric
102+
// campaign_id falls back to experiment_id. Empty / null / non-numeric
103+
// variation_id becomes null. entity_id mirrors the normalized campaign_id
104+
// byte-for-byte to guarantee wire equivalence.
105+
String normalizedCampaignId = EventIdNormalizer.normalizeCampaignId(
106+
impressionEvent.getLayerId(), impressionEvent.getExperimentId());
107+
String normalizedVariationId = EventIdNormalizer.normalizeVariationId(
108+
impressionEvent.getVariationId());
109+
100110
Decision decision = new Decision.Builder()
101-
.setCampaignId(impressionEvent.getLayerId())
111+
.setCampaignId(normalizedCampaignId)
102112
.setExperimentId(impressionEvent.getExperimentId())
103-
.setVariationId(impressionEvent.getVariationId())
113+
.setVariationId(normalizedVariationId)
104114
.setMetadata(impressionEvent.getMetadata())
105115
.setIsCampaignHoldback(false)
106116
.build();
107117

108118
Event event = new Event.Builder()
109119
.setTimestamp(impressionEvent.getTimestamp())
110120
.setUuid(impressionEvent.getUUID())
111-
.setEntityId(impressionEvent.getLayerId())
121+
.setEntityId(normalizedCampaignId)
112122
.setKey(ACTIVATE_EVENT_KEY)
113123
.setType(ACTIVATE_EVENT_KEY)
114124
.build();
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
*
3+
* Copyright 2025, Optimizely and contributors
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package com.optimizely.ab.event.internal;
18+
19+
/**
20+
* EventIdNormalizer normalizes decision-event identifier fields prior to wire serialization.
21+
*
22+
* <p>Implements FSSDK-12813:
23+
* <ul>
24+
* <li>{@code campaign_id} and impression {@code entity_id} must be a non-empty
25+
* decimal-digit string. If empty / null / non-numeric / whitespace, substitute
26+
* {@code experiment_id}.</li>
27+
* <li>{@code variation_id} must be a non-empty decimal-digit string OR {@code null}.
28+
* If empty / non-numeric / non-string / whitespace, substitute {@code null}.</li>
29+
* </ul>
30+
*
31+
* <p>A "numeric string" is a non-empty string consisting entirely of decimal digits
32+
* {@code [0-9]}. Leading zeros are allowed. Whitespace, negatives, decimals, and
33+
* exponents are INVALID.
34+
*
35+
* <p>Normalization applies uniformly to all decision types (experiment, feature test,
36+
* rollout, holdout). It must not drop, defer, or fail event dispatch, and it must not
37+
* emit any log or warning on the normalization path.
38+
*/
39+
final class EventIdNormalizer {
40+
41+
private EventIdNormalizer() {
42+
// Utility class — not instantiable.
43+
}
44+
45+
/**
46+
* @return {@code true} iff {@code value} is non-null and consists entirely of decimal digits.
47+
* Empty strings, whitespace, negatives, decimals, and exponents are all invalid.
48+
*/
49+
static boolean isNumericString(String value) {
50+
if (value == null) {
51+
return false;
52+
}
53+
int length = value.length();
54+
if (length == 0) {
55+
return false;
56+
}
57+
for (int i = 0; i < length; i++) {
58+
char c = value.charAt(i);
59+
if (c < '0' || c > '9') {
60+
return false;
61+
}
62+
}
63+
return true;
64+
}
65+
66+
/**
67+
* Normalize a {@code campaign_id} or impression {@code entity_id}.
68+
*
69+
* @param campaignId the candidate campaign_id (may be null, empty, or non-numeric)
70+
* @param experimentId fallback experiment_id (returned as-is; not re-validated)
71+
* @return {@code campaignId} when it is a non-empty numeric string,
72+
* otherwise {@code experimentId} (which may itself be {@code null}).
73+
*/
74+
static String normalizeCampaignId(String campaignId, String experimentId) {
75+
if (isNumericString(campaignId)) {
76+
return campaignId;
77+
}
78+
return experimentId;
79+
}
80+
81+
/**
82+
* Normalize a {@code variation_id}.
83+
*
84+
* @param variationId the candidate variation_id (may be null, empty, or non-numeric)
85+
* @return {@code variationId} when it is a non-empty numeric string, otherwise {@code null}.
86+
*/
87+
static String normalizeVariationId(String variationId) {
88+
if (isNumericString(variationId)) {
89+
return variationId;
90+
}
91+
return null;
92+
}
93+
}

‎core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java‎

Lines changed: 66 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -618,7 +618,8 @@ public void isFeatureEnabledWithExperimentKeyForced() throws Exception {
618618
assertTrue(optimizely.setForcedVariation(activatedExperiment.getKey(), testUserId, null));
619619
assertNull(optimizely.getForcedVariation(activatedExperiment.getKey(), testUserId));
620620
assertFalse(optimizely.isFeatureEnabled(FEATURE_FLAG_MULTI_VARIATE_FEATURE.getKey(), testUserId));
621-
eventHandler.expectImpression(null, "", testUserId);
621+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
622+
eventHandler.expectImpression(null, null, testUserId);
622623
}
623624

624625
/**
@@ -1810,13 +1811,19 @@ public void getEnabledFeaturesWithListenerMultipleFeatureEnabled() throws Except
18101811
List<String> featureFlags = optimizely.getEnabledFeatures(testUserId, Collections.emptyMap());
18111812
assertEquals(2, featureFlags.size());
18121813

1813-
eventHandler.expectImpression(null, "", testUserId);
1814-
eventHandler.expectImpression(null, "", testUserId);
1814+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
1815+
eventHandler.expectImpression(null, null, testUserId);
1816+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
1817+
eventHandler.expectImpression(null, null, testUserId);
18151818
eventHandler.expectImpression("3794675122", "589640735", testUserId);
1816-
eventHandler.expectImpression(null, "", testUserId);
1817-
eventHandler.expectImpression(null, "", testUserId);
1818-
eventHandler.expectImpression(null, "", testUserId);
1819-
eventHandler.expectImpression(null, "", testUserId);
1819+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
1820+
eventHandler.expectImpression(null, null, testUserId);
1821+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
1822+
eventHandler.expectImpression(null, null, testUserId);
1823+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
1824+
eventHandler.expectImpression(null, null, testUserId);
1825+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
1826+
eventHandler.expectImpression(null, null, testUserId);
18201827
eventHandler.expectImpression("1786133852", "1619235542", testUserId);
18211828

18221829
// Verify that listener being called
@@ -1853,14 +1860,22 @@ public void getEnabledFeaturesWithNoFeatureEnabled() throws Exception {
18531860
// Verify that listener not being called
18541861
assertFalse(isListenerCalled);
18551862

1856-
eventHandler.expectImpression(null, "", genericUserId);
1857-
eventHandler.expectImpression(null, "", genericUserId);
1858-
eventHandler.expectImpression(null, "", genericUserId);
1859-
eventHandler.expectImpression(null, "", genericUserId);
1860-
eventHandler.expectImpression(null, "", genericUserId);
1861-
eventHandler.expectImpression(null, "", genericUserId);
1862-
eventHandler.expectImpression(null, "", genericUserId);
1863-
eventHandler.expectImpression(null, "", genericUserId);
1863+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
1864+
eventHandler.expectImpression(null, null, genericUserId);
1865+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
1866+
eventHandler.expectImpression(null, null, genericUserId);
1867+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
1868+
eventHandler.expectImpression(null, null, genericUserId);
1869+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
1870+
eventHandler.expectImpression(null, null, genericUserId);
1871+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
1872+
eventHandler.expectImpression(null, null, genericUserId);
1873+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
1874+
eventHandler.expectImpression(null, null, genericUserId);
1875+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
1876+
eventHandler.expectImpression(null, null, genericUserId);
1877+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
1878+
eventHandler.expectImpression(null, null, genericUserId);
18641879

18651880
assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId));
18661881
}
@@ -2013,7 +2028,8 @@ public void isFeatureEnabledWithListenerUserNotInExperimentAndNotInRollOut() thr
20132028
"Feature \"" + validFeatureKey +
20142029
"\" is enabled for user \"" + genericUserId + "\"? false"
20152030
);
2016-
eventHandler.expectImpression(null, "", genericUserId);
2031+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
2032+
eventHandler.expectImpression(null, null, genericUserId);
20172033

20182034
// Verify that listener being called
20192035
assertTrue(isListenerCalled);
@@ -3339,7 +3355,8 @@ public void isFeatureEnabledReturnsFalseWhenUserIsNotBucketedIntoAnyVariation()
33393355
"Feature \"" + validFeatureKey +
33403356
"\" is enabled for user \"" + genericUserId + "\"? false"
33413357
);
3342-
eventHandler.expectImpression(null, "", genericUserId);
3358+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
3359+
eventHandler.expectImpression(null, null, genericUserId);
33433360

33443361
verify(mockDecisionService).getVariationForFeature(
33453362
eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE),
@@ -3384,7 +3401,8 @@ public void isFeatureEnabledReturnsTrueButDoesNotSendWhenUserIsBucketedIntoVaria
33843401
"Feature \"" + validFeatureKey +
33853402
"\" is enabled for user \"" + genericUserId + "\"? true"
33863403
);
3387-
eventHandler.expectImpression("3421010877", "variationId", genericUserId);
3404+
// FSSDK-12813: non-numeric variation_id "variationId" is normalized to null on the wire.
3405+
eventHandler.expectImpression("3421010877", null, genericUserId);
33883406

33893407
verify(mockDecisionService).getVariationForFeature(
33903408
eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE),
@@ -3456,7 +3474,8 @@ public void isFeatureEnabledTrueWhenFeatureEnabledOfVariationIsTrue() throws Exc
34563474
);
34573475

34583476
assertTrue(optimizely.isFeatureEnabled(validFeatureKey, genericUserId));
3459-
eventHandler.expectImpression("3421010877", "variationId", genericUserId);
3477+
// FSSDK-12813: non-numeric variation_id "variationId" is normalized to null on the wire.
3478+
eventHandler.expectImpression("3421010877", null, genericUserId);
34603479

34613480
}
34623481

@@ -3485,7 +3504,8 @@ public void isFeatureEnabledFalseWhenFeatureEnabledOfVariationIsFalse() throws E
34853504
);
34863505

34873506
assertFalse(spyOptimizely.isFeatureEnabled(FEATURE_MULTI_VARIATE_FEATURE_KEY, genericUserId));
3488-
eventHandler.expectImpression("3421010877", "variationId", genericUserId);
3507+
// FSSDK-12813: non-numeric variation_id "variationId" is normalized to null on the wire.
3508+
eventHandler.expectImpression("3421010877", null, genericUserId);
34893509

34903510
}
34913511

@@ -3582,10 +3602,13 @@ public void getEnabledFeatureWithValidUserId() throws Exception {
35823602
List<String> featureFlags = optimizely.getEnabledFeatures(genericUserId, Collections.emptyMap());
35833603
assertFalse(featureFlags.isEmpty());
35843604

3585-
eventHandler.expectImpression(null, "", genericUserId);
3586-
eventHandler.expectImpression(null, "", genericUserId);
3605+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
3606+
eventHandler.expectImpression(null, null, genericUserId);
3607+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
3608+
eventHandler.expectImpression(null, null, genericUserId);
35873609
eventHandler.expectImpression("3794675122", "589640735", genericUserId);
3588-
eventHandler.expectImpression(null, "", genericUserId);
3610+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
3611+
eventHandler.expectImpression(null, null, genericUserId);
35893612
eventHandler.expectImpression("1785077004", "1566407342", genericUserId);
35903613
eventHandler.expectImpression("828245624", "3137445031", genericUserId);
35913614
eventHandler.expectImpression("828245624", "3137445031", genericUserId);
@@ -3606,10 +3629,11 @@ public void getEnabledFeatureWithEmptyUserId() throws Exception {
36063629
List<String> featureFlags = optimizely.getEnabledFeatures("", Collections.emptyMap());
36073630
assertFalse(featureFlags.isEmpty());
36083631

3609-
eventHandler.expectImpression(null, "", "");
3610-
eventHandler.expectImpression(null, "", "");
3632+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
3633+
eventHandler.expectImpression(null, null, "");
3634+
eventHandler.expectImpression(null, null, "");
36113635
eventHandler.expectImpression("3794675122", "589640735", "");
3612-
eventHandler.expectImpression(null, "", "");
3636+
eventHandler.expectImpression(null, null, "");
36133637
eventHandler.expectImpression("1785077004", "1566407342", "");
36143638
eventHandler.expectImpression("828245624", "3137445031", "");
36153639
eventHandler.expectImpression("828245624", "3137445031", "");
@@ -3660,14 +3684,22 @@ public void getEnabledFeatureWithMockDecisionService() throws Exception {
36603684
List<String> featureFlags = optimizely.getEnabledFeatures(genericUserId, Collections.emptyMap());
36613685
assertTrue(featureFlags.isEmpty());
36623686

3663-
eventHandler.expectImpression(null, "", genericUserId);
3664-
eventHandler.expectImpression(null, "", genericUserId);
3665-
eventHandler.expectImpression(null, "", genericUserId);
3666-
eventHandler.expectImpression(null, "", genericUserId);
3667-
eventHandler.expectImpression(null, "", genericUserId);
3668-
eventHandler.expectImpression(null, "", genericUserId);
3669-
eventHandler.expectImpression(null, "", genericUserId);
3670-
eventHandler.expectImpression(null, "", genericUserId);
3687+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
3688+
eventHandler.expectImpression(null, null, genericUserId);
3689+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
3690+
eventHandler.expectImpression(null, null, genericUserId);
3691+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
3692+
eventHandler.expectImpression(null, null, genericUserId);
3693+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
3694+
eventHandler.expectImpression(null, null, genericUserId);
3695+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
3696+
eventHandler.expectImpression(null, null, genericUserId);
3697+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
3698+
eventHandler.expectImpression(null, null, genericUserId);
3699+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
3700+
eventHandler.expectImpression(null, null, genericUserId);
3701+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
3702+
eventHandler.expectImpression(null, null, genericUserId);
36713703
}
36723704

36733705
/**

‎core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,8 @@ public void decide_nullVariation() {
267267
.setVariationKey("")
268268
.setEnabled(false)
269269
.build();
270-
eventHandler.expectImpression(null, "", userId, Collections.emptyMap(), metadata);
270+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
271+
eventHandler.expectImpression(null, null, userId, Collections.emptyMap(), metadata);
271272
}
272273

273274
// decideAll
@@ -639,7 +640,8 @@ public void decide_sendEvent_rollout_withSendFlagDecisionsOn() {
639640
user.decide(flagKey);
640641
assertTrue(isListenerCalled);
641642

642-
eventHandler.expectImpression(null, "", userId, attributes);
643+
// FSSDK-12813: empty-string variation_id is normalized to null on the wire.
644+
eventHandler.expectImpression(null, null, userId, attributes);
643645
}
644646

645647
@Test
@@ -2102,6 +2104,8 @@ public void decisionNotification_with_holdout() throws Exception {
21022104
String variationKey = "ho_off_key"; // holdout (off) variation key
21032105
String experimentId = "10075323428"; // holdout experiment id in holdouts-project-config.json
21042106
String variationId = "$opt_dummy_variation_id";// dummy variation id used for holdout impressions
2107+
// FSSDK-12813: dummy variation_id is non-numeric and normalized to null on the wire.
2108+
String expectedDispatchedVariationId = null;
21052109
String expectedReason = "User (" + userId + ") is in variation (" + variationKey + ") of holdout (" + ruleKey + ").";
21062110

21072111
Map<String, Object> attrs = new HashMap<>();
@@ -2153,7 +2157,7 @@ public void decisionNotification_with_holdout() throws Exception {
21532157
.setVariationKey(variationKey)
21542158
.setEnabled(false)
21552159
.build();
2156-
eventHandler.expectImpression(experimentId, variationId, userId, Collections.singletonMap("nationality", "English"), metadata);
2160+
eventHandler.expectImpression(experimentId, expectedDispatchedVariationId, userId, Collections.singletonMap("nationality", "English"), metadata);
21572161

21582162
// Log expectation (reuse existing pattern)
21592163
logbackVerifier.expectMessage(Level.INFO, expectedReason);
@@ -2177,6 +2181,8 @@ public void decide_for_keys_with_holdout() throws Exception {
21772181

21782182
String holdoutExperimentId = "10075323428"; // basic_holdout id
21792183
String variationId = "$opt_dummy_variation_id";
2184+
// FSSDK-12813: dummy variation_id is non-numeric and normalized to null on the wire.
2185+
String expectedDispatchedVariationId = null;
21802186
String variationKey = "ho_off_key";
21812187
String expectedReason = "User (" + userId + ") is in variation (" + variationKey + ") of holdout (basic_holdout).";
21822188

@@ -2195,7 +2201,7 @@ public void decide_for_keys_with_holdout() throws Exception {
21952201
.setEnabled(false)
21962202
.build();
21972203
// attributes map expected empty (reserved $opt_ attribute filtered out)
2198-
eventHandler.expectImpression(holdoutExperimentId, variationId, userId, Collections.emptyMap(), metadata);
2204+
eventHandler.expectImpression(holdoutExperimentId, expectedDispatchedVariationId, userId, Collections.emptyMap(), metadata);
21992205
}
22002206

22012207
// At least one log message confirming holdout membership

0 commit comments

Comments
 (0)