Skip to content

Commit ef214b7

Browse files
authored
[AI-FSSDK] [FSSDK-12813] Normalize decision event campaign_id, variation_id, and entity_id (#634)
* [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 the updated FSSDK-12813 spec, decision-event campaign_id and impression entity_id only require a non-empty string (IDs may be opaque, e.g. "default-12345" or "layer_abc"). Fallback to experiment_id now fires only when the value is null or "". variation_id retains the stricter numeric-string-only contract. - EventIdNormalizer: add isNonEmptyString predicate and route normalizeCampaignId through it; isNumericString and normalizeVariationId unchanged. - EventIdNormalizerTest: add isNonEmptyString coverage; flip normalizeCampaignId tests so non-numeric/whitespace inputs assert passthrough; variation_id assertions unchanged. - EventFactoryNormalizationTest: flip whitespace and non-numeric campaign_id tests to passthrough; split the uniform-across-rule-types test into opaque-passthrough and null-fallback variants; update entity_id-mirrors-campaign_id and event-never-dropped tests to use null/empty for the campaign_id fallback path and a non-numeric value only for the variation_id null path. * [FSSDK-12813] Remove ticket references from code comments per cross-sdk guideline * [FSSDK-12813] Fix copyright year to 2026 in changed files * [FSSDK-12813] Trigger CI * [FSSDK-12813] Emit variation_id as JSON null instead of stripping the key JacksonSerializer sets Include.NON_NULL globally, so any null field is dropped from the wire payload unless overridden at the field level. campaign_id and experiment_id already carried @JsonInclude(ALWAYS); variation_id did not, so once normalization began returning null for non-numeric / empty inputs, the entire variation_id key disappeared from the payload — breaking cross-SDK byte-equivalence (all other SDKs emit explicit JSON null). Added @JsonInclude(ALWAYS) on Decision.variationId, plus a focused serializer test that asserts "variation_id":null is present in the serialized output when the value is null.
1 parent ecb136d commit ef214b7

8 files changed

Lines changed: 876 additions & 44 deletions

File tree

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

Lines changed: 9 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, 2026, 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,23 @@ private static Visitor createVisitor(ImpressionEvent impressionEvent) {
9797

9898
UserContext userContext = impressionEvent.getUserContext();
9999

100+
String normalizedCampaignId = EventIdNormalizer.normalizeCampaignId(
101+
impressionEvent.getLayerId(), impressionEvent.getExperimentId());
102+
String normalizedVariationId = EventIdNormalizer.normalizeVariationId(
103+
impressionEvent.getVariationId());
104+
100105
Decision decision = new Decision.Builder()
101-
.setCampaignId(impressionEvent.getLayerId())
106+
.setCampaignId(normalizedCampaignId)
102107
.setExperimentId(impressionEvent.getExperimentId())
103-
.setVariationId(impressionEvent.getVariationId())
108+
.setVariationId(normalizedVariationId)
104109
.setMetadata(impressionEvent.getMetadata())
105110
.setIsCampaignHoldback(false)
106111
.build();
107112

108113
Event event = new Event.Builder()
109114
.setTimestamp(impressionEvent.getTimestamp())
110115
.setUuid(impressionEvent.getUUID())
111-
.setEntityId(impressionEvent.getLayerId())
116+
.setEntityId(normalizedCampaignId)
112117
.setKey(ACTIVATE_EVENT_KEY)
113118
.setType(ACTIVATE_EVENT_KEY)
114119
.build();
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
*
3+
* Copyright 2026, 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+
* <ul>
23+
* <li>{@code campaign_id} and impression {@code entity_id} must be a non-empty
24+
* string of any character content (IDs may be opaque, e.g. {@code "default-12345"},
25+
* {@code "layer_abc"}). If null or empty string, substitute {@code experiment_id}.
26+
* Non-numeric strings pass through unchanged.</li>
27+
* <li>{@code variation_id} must be a non-empty decimal-digit string OR {@code null}.
28+
* If empty / non-numeric / whitespace, substitute {@code null}. This field retains
29+
* the stricter numeric-string-only contract.</li>
30+
* </ul>
31+
*
32+
* <p>For {@code variation_id}, a "numeric string" is a non-empty string consisting
33+
* entirely of decimal digits {@code [0-9]}. Leading zeros are allowed. Whitespace,
34+
* negatives, decimals, and exponents are INVALID.
35+
*
36+
* <p>Normalization applies uniformly to all decision types (experiment, feature test,
37+
* rollout, holdout). It must not drop, defer, or fail event dispatch, and it must not
38+
* emit any log or warning on the normalization path.
39+
*/
40+
final class EventIdNormalizer {
41+
42+
private EventIdNormalizer() {
43+
// Utility class — not instantiable.
44+
}
45+
46+
/**
47+
* @return {@code true} iff {@code value} is non-null and has length &ge; 1.
48+
* Character content is not validated — any non-empty string is accepted.
49+
* Used to validate {@code campaign_id} and impression {@code entity_id}.
50+
*/
51+
static boolean isNonEmptyString(String value) {
52+
return value != null && !value.isEmpty();
53+
}
54+
55+
/**
56+
* @return {@code true} iff {@code value} is non-null and consists entirely of decimal digits.
57+
* Empty strings, whitespace, negatives, decimals, and exponents are all invalid.
58+
* Used to validate {@code variation_id} per the strict numeric-string contract.
59+
*/
60+
static boolean isNumericString(String value) {
61+
if (value == null) {
62+
return false;
63+
}
64+
int length = value.length();
65+
if (length == 0) {
66+
return false;
67+
}
68+
for (int i = 0; i < length; i++) {
69+
char c = value.charAt(i);
70+
if (c < '0' || c > '9') {
71+
return false;
72+
}
73+
}
74+
return true;
75+
}
76+
77+
/**
78+
* Normalize a {@code campaign_id} or impression {@code entity_id}.
79+
*
80+
* <p>Any non-empty string is accepted as-is (IDs may be opaque, e.g.
81+
* {@code "default-12345"}, {@code "layer_abc"}). The fallback to
82+
* {@code experiment_id} fires ONLY when {@code campaignId} is {@code null} or
83+
* the empty string {@code ""}.
84+
*
85+
* @param campaignId the candidate campaign_id (may be null or empty)
86+
* @param experimentId fallback experiment_id (returned as-is; not re-validated)
87+
* @return {@code campaignId} when it is a non-empty string of any content,
88+
* otherwise {@code experimentId} (which may itself be {@code null}).
89+
*/
90+
static String normalizeCampaignId(String campaignId, String experimentId) {
91+
if (isNonEmptyString(campaignId)) {
92+
return campaignId;
93+
}
94+
return experimentId;
95+
}
96+
97+
/**
98+
* Normalize a {@code variation_id}. {@code variation_id} retains the stricter
99+
* contract: must be a non-empty decimal-digit string. Anything else (null,
100+
* empty, whitespace, or non-numeric) is replaced with {@code null}.
101+
*
102+
* @param variationId the candidate variation_id (may be null, empty, or non-numeric)
103+
* @return {@code variationId} when it is a non-empty numeric string, otherwise {@code null}.
104+
*/
105+
static String normalizeVariationId(String variationId) {
106+
if (isNumericString(variationId)) {
107+
return variationId;
108+
}
109+
return null;
110+
}
111+
}

core-api/src/main/java/com/optimizely/ab/event/internal/payload/Decision.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ public class Decision {
2727
@JsonInclude(JsonInclude.Include.ALWAYS)
2828
@JsonProperty("experiment_id")
2929
String experimentId;
30+
@JsonInclude(JsonInclude.Include.ALWAYS)
3031
@JsonProperty("variation_id")
3132
String variationId;
3233
@JsonProperty("is_campaign_holdback")

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

Lines changed: 35 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/****************************************************************************
2-
* Copyright 2016-2023, Optimizely, Inc. and contributors *
2+
* Copyright 2016-2023, 2026, Optimizely, Inc. and contributors *
33
* *
44
* Licensed under the Apache License, Version 2.0 (the "License"); *
55
* you may not use this file except in compliance with the License. *
@@ -618,7 +618,7 @@ 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+
eventHandler.expectImpression(null, null, testUserId);
622622
}
623623

624624
/**
@@ -1810,13 +1810,13 @@ public void getEnabledFeaturesWithListenerMultipleFeatureEnabled() throws Except
18101810
List<String> featureFlags = optimizely.getEnabledFeatures(testUserId, Collections.emptyMap());
18111811
assertEquals(2, featureFlags.size());
18121812

1813-
eventHandler.expectImpression(null, "", testUserId);
1814-
eventHandler.expectImpression(null, "", testUserId);
1813+
eventHandler.expectImpression(null, null, testUserId);
1814+
eventHandler.expectImpression(null, null, testUserId);
18151815
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);
1816+
eventHandler.expectImpression(null, null, testUserId);
1817+
eventHandler.expectImpression(null, null, testUserId);
1818+
eventHandler.expectImpression(null, null, testUserId);
1819+
eventHandler.expectImpression(null, null, testUserId);
18201820
eventHandler.expectImpression("1786133852", "1619235542", testUserId);
18211821

18221822
// Verify that listener being called
@@ -1853,14 +1853,14 @@ public void getEnabledFeaturesWithNoFeatureEnabled() throws Exception {
18531853
// Verify that listener not being called
18541854
assertFalse(isListenerCalled);
18551855

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);
1856+
eventHandler.expectImpression(null, null, genericUserId);
1857+
eventHandler.expectImpression(null, null, genericUserId);
1858+
eventHandler.expectImpression(null, null, genericUserId);
1859+
eventHandler.expectImpression(null, null, genericUserId);
1860+
eventHandler.expectImpression(null, null, genericUserId);
1861+
eventHandler.expectImpression(null, null, genericUserId);
1862+
eventHandler.expectImpression(null, null, genericUserId);
1863+
eventHandler.expectImpression(null, null, genericUserId);
18641864

18651865
assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId));
18661866
}
@@ -2013,7 +2013,7 @@ public void isFeatureEnabledWithListenerUserNotInExperimentAndNotInRollOut() thr
20132013
"Feature \"" + validFeatureKey +
20142014
"\" is enabled for user \"" + genericUserId + "\"? false"
20152015
);
2016-
eventHandler.expectImpression(null, "", genericUserId);
2016+
eventHandler.expectImpression(null, null, genericUserId);
20172017

20182018
// Verify that listener being called
20192019
assertTrue(isListenerCalled);
@@ -3339,7 +3339,7 @@ public void isFeatureEnabledReturnsFalseWhenUserIsNotBucketedIntoAnyVariation()
33393339
"Feature \"" + validFeatureKey +
33403340
"\" is enabled for user \"" + genericUserId + "\"? false"
33413341
);
3342-
eventHandler.expectImpression(null, "", genericUserId);
3342+
eventHandler.expectImpression(null, null, genericUserId);
33433343

33443344
verify(mockDecisionService).getVariationForFeature(
33453345
eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE),
@@ -3384,7 +3384,7 @@ public void isFeatureEnabledReturnsTrueButDoesNotSendWhenUserIsBucketedIntoVaria
33843384
"Feature \"" + validFeatureKey +
33853385
"\" is enabled for user \"" + genericUserId + "\"? true"
33863386
);
3387-
eventHandler.expectImpression("3421010877", "variationId", genericUserId);
3387+
eventHandler.expectImpression("3421010877", null, genericUserId);
33883388

33893389
verify(mockDecisionService).getVariationForFeature(
33903390
eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE),
@@ -3456,7 +3456,7 @@ public void isFeatureEnabledTrueWhenFeatureEnabledOfVariationIsTrue() throws Exc
34563456
);
34573457

34583458
assertTrue(optimizely.isFeatureEnabled(validFeatureKey, genericUserId));
3459-
eventHandler.expectImpression("3421010877", "variationId", genericUserId);
3459+
eventHandler.expectImpression("3421010877", null, genericUserId);
34603460

34613461
}
34623462

@@ -3485,7 +3485,7 @@ public void isFeatureEnabledFalseWhenFeatureEnabledOfVariationIsFalse() throws E
34853485
);
34863486

34873487
assertFalse(spyOptimizely.isFeatureEnabled(FEATURE_MULTI_VARIATE_FEATURE_KEY, genericUserId));
3488-
eventHandler.expectImpression("3421010877", "variationId", genericUserId);
3488+
eventHandler.expectImpression("3421010877", null, genericUserId);
34893489

34903490
}
34913491

@@ -3582,10 +3582,10 @@ public void getEnabledFeatureWithValidUserId() throws Exception {
35823582
List<String> featureFlags = optimizely.getEnabledFeatures(genericUserId, Collections.emptyMap());
35833583
assertFalse(featureFlags.isEmpty());
35843584

3585-
eventHandler.expectImpression(null, "", genericUserId);
3586-
eventHandler.expectImpression(null, "", genericUserId);
3585+
eventHandler.expectImpression(null, null, genericUserId);
3586+
eventHandler.expectImpression(null, null, genericUserId);
35873587
eventHandler.expectImpression("3794675122", "589640735", genericUserId);
3588-
eventHandler.expectImpression(null, "", genericUserId);
3588+
eventHandler.expectImpression(null, null, genericUserId);
35893589
eventHandler.expectImpression("1785077004", "1566407342", genericUserId);
35903590
eventHandler.expectImpression("828245624", "3137445031", genericUserId);
35913591
eventHandler.expectImpression("828245624", "3137445031", genericUserId);
@@ -3606,10 +3606,10 @@ public void getEnabledFeatureWithEmptyUserId() throws Exception {
36063606
List<String> featureFlags = optimizely.getEnabledFeatures("", Collections.emptyMap());
36073607
assertFalse(featureFlags.isEmpty());
36083608

3609-
eventHandler.expectImpression(null, "", "");
3610-
eventHandler.expectImpression(null, "", "");
3609+
eventHandler.expectImpression(null, null, "");
3610+
eventHandler.expectImpression(null, null, "");
36113611
eventHandler.expectImpression("3794675122", "589640735", "");
3612-
eventHandler.expectImpression(null, "", "");
3612+
eventHandler.expectImpression(null, null, "");
36133613
eventHandler.expectImpression("1785077004", "1566407342", "");
36143614
eventHandler.expectImpression("828245624", "3137445031", "");
36153615
eventHandler.expectImpression("828245624", "3137445031", "");
@@ -3660,14 +3660,14 @@ public void getEnabledFeatureWithMockDecisionService() throws Exception {
36603660
List<String> featureFlags = optimizely.getEnabledFeatures(genericUserId, Collections.emptyMap());
36613661
assertTrue(featureFlags.isEmpty());
36623662

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);
3663+
eventHandler.expectImpression(null, null, genericUserId);
3664+
eventHandler.expectImpression(null, null, genericUserId);
3665+
eventHandler.expectImpression(null, null, genericUserId);
3666+
eventHandler.expectImpression(null, null, genericUserId);
3667+
eventHandler.expectImpression(null, null, genericUserId);
3668+
eventHandler.expectImpression(null, null, genericUserId);
3669+
eventHandler.expectImpression(null, null, genericUserId);
3670+
eventHandler.expectImpression(null, null, genericUserId);
36713671
}
36723672

36733673
/**

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
*
3-
* Copyright 2021-2024, Optimizely and contributors
3+
* Copyright 2021-2024, 2026, 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.
@@ -267,7 +267,7 @@ public void decide_nullVariation() {
267267
.setVariationKey("")
268268
.setEnabled(false)
269269
.build();
270-
eventHandler.expectImpression(null, "", userId, Collections.emptyMap(), metadata);
270+
eventHandler.expectImpression(null, null, userId, Collections.emptyMap(), metadata);
271271
}
272272

273273
// decideAll
@@ -639,7 +639,7 @@ public void decide_sendEvent_rollout_withSendFlagDecisionsOn() {
639639
user.decide(flagKey);
640640
assertTrue(isListenerCalled);
641641

642-
eventHandler.expectImpression(null, "", userId, attributes);
642+
eventHandler.expectImpression(null, null, userId, attributes);
643643
}
644644

645645
@Test
@@ -2102,6 +2102,7 @@ public void decisionNotification_with_holdout() throws Exception {
21022102
String variationKey = "ho_off_key"; // holdout (off) variation key
21032103
String experimentId = "10075323428"; // holdout experiment id in holdouts-project-config.json
21042104
String variationId = "$opt_dummy_variation_id";// dummy variation id used for holdout impressions
2105+
String expectedDispatchedVariationId = null;
21052106
String expectedReason = "User (" + userId + ") is in variation (" + variationKey + ") of holdout (" + ruleKey + ").";
21062107

21072108
Map<String, Object> attrs = new HashMap<>();
@@ -2153,7 +2154,7 @@ public void decisionNotification_with_holdout() throws Exception {
21532154
.setVariationKey(variationKey)
21542155
.setEnabled(false)
21552156
.build();
2156-
eventHandler.expectImpression(experimentId, variationId, userId, Collections.singletonMap("nationality", "English"), metadata);
2157+
eventHandler.expectImpression(experimentId, expectedDispatchedVariationId, userId, Collections.singletonMap("nationality", "English"), metadata);
21572158

21582159
// Log expectation (reuse existing pattern)
21592160
logbackVerifier.expectMessage(Level.INFO, expectedReason);
@@ -2177,6 +2178,7 @@ public void decide_for_keys_with_holdout() throws Exception {
21772178

21782179
String holdoutExperimentId = "10075323428"; // basic_holdout id
21792180
String variationId = "$opt_dummy_variation_id";
2181+
String expectedDispatchedVariationId = null;
21802182
String variationKey = "ho_off_key";
21812183
String expectedReason = "User (" + userId + ") is in variation (" + variationKey + ") of holdout (basic_holdout).";
21822184

@@ -2195,7 +2197,7 @@ public void decide_for_keys_with_holdout() throws Exception {
21952197
.setEnabled(false)
21962198
.build();
21972199
// attributes map expected empty (reserved $opt_ attribute filtered out)
2198-
eventHandler.expectImpression(holdoutExperimentId, variationId, userId, Collections.emptyMap(), metadata);
2200+
eventHandler.expectImpression(holdoutExperimentId, expectedDispatchedVariationId, userId, Collections.emptyMap(), metadata);
21992201
}
22002202

22012203
// At least one log message confirming holdout membership

0 commit comments

Comments
 (0)