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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
*
* Copyright 2016-2020, 2022, Optimizely and contributors
* Copyright 2016-2020, 2022, 2026, Optimizely and contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -97,18 +97,23 @@ private static Visitor createVisitor(ImpressionEvent impressionEvent) {

UserContext userContext = impressionEvent.getUserContext();

String normalizedCampaignId = EventIdNormalizer.normalizeCampaignId(
impressionEvent.getLayerId(), impressionEvent.getExperimentId());
String normalizedVariationId = EventIdNormalizer.normalizeVariationId(
impressionEvent.getVariationId());

Decision decision = new Decision.Builder()
.setCampaignId(impressionEvent.getLayerId())
.setCampaignId(normalizedCampaignId)
.setExperimentId(impressionEvent.getExperimentId())
.setVariationId(impressionEvent.getVariationId())
.setVariationId(normalizedVariationId)
.setMetadata(impressionEvent.getMetadata())
.setIsCampaignHoldback(false)
.build();

Event event = new Event.Builder()
.setTimestamp(impressionEvent.getTimestamp())
.setUuid(impressionEvent.getUUID())
.setEntityId(impressionEvent.getLayerId())
.setEntityId(normalizedCampaignId)
.setKey(ACTIVATE_EVENT_KEY)
.setType(ACTIVATE_EVENT_KEY)
.build();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
*
* Copyright 2026, Optimizely and contributors
*
* 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.
*/
package com.optimizely.ab.event.internal;

/**
* EventIdNormalizer normalizes decision-event identifier fields prior to wire serialization.
*
* <ul>
* <li>{@code campaign_id} and impression {@code entity_id} must be a non-empty
* string of any character content (IDs may be opaque, e.g. {@code "default-12345"},
* {@code "layer_abc"}). If null or empty string, substitute {@code experiment_id}.
* Non-numeric strings pass through unchanged.</li>
* <li>{@code variation_id} must be a non-empty decimal-digit string OR {@code null}.
* If empty / non-numeric / whitespace, substitute {@code null}. This field retains
* the stricter numeric-string-only contract.</li>
* </ul>
*
* <p>For {@code variation_id}, a "numeric string" is a non-empty string consisting
* entirely of decimal digits {@code [0-9]}. Leading zeros are allowed. Whitespace,
* negatives, decimals, and exponents are INVALID.
*
* <p>Normalization applies uniformly to all decision types (experiment, feature test,
* rollout, holdout). It must not drop, defer, or fail event dispatch, and it must not
* emit any log or warning on the normalization path.
*/
final class EventIdNormalizer {

private EventIdNormalizer() {
// Utility class — not instantiable.
}

/**
* @return {@code true} iff {@code value} is non-null and has length &ge; 1.
* Character content is not validated — any non-empty string is accepted.
* Used to validate {@code campaign_id} and impression {@code entity_id}.
*/
static boolean isNonEmptyString(String value) {
return value != null && !value.isEmpty();
}

/**
* @return {@code true} iff {@code value} is non-null and consists entirely of decimal digits.
* Empty strings, whitespace, negatives, decimals, and exponents are all invalid.
* Used to validate {@code variation_id} per the strict numeric-string contract.
*/
static boolean isNumericString(String value) {
if (value == null) {
return false;
}
int length = value.length();
if (length == 0) {
return false;
}
for (int i = 0; i < length; i++) {
char c = value.charAt(i);
if (c < '0' || c > '9') {
return false;
}
}
return true;
}

/**
* Normalize a {@code campaign_id} or impression {@code entity_id}.
*
* <p>Any non-empty string is accepted as-is (IDs may be opaque, e.g.
* {@code "default-12345"}, {@code "layer_abc"}). The fallback to
* {@code experiment_id} fires ONLY when {@code campaignId} is {@code null} or
* the empty string {@code ""}.
*
* @param campaignId the candidate campaign_id (may be null or empty)
* @param experimentId fallback experiment_id (returned as-is; not re-validated)
* @return {@code campaignId} when it is a non-empty string of any content,
* otherwise {@code experimentId} (which may itself be {@code null}).
*/
static String normalizeCampaignId(String campaignId, String experimentId) {
if (isNonEmptyString(campaignId)) {
return campaignId;
}
return experimentId;
}

/**
* Normalize a {@code variation_id}. {@code variation_id} retains the stricter
* contract: must be a non-empty decimal-digit string. Anything else (null,
* empty, whitespace, or non-numeric) is replaced with {@code null}.
*
* @param variationId the candidate variation_id (may be null, empty, or non-numeric)
* @return {@code variationId} when it is a non-empty numeric string, otherwise {@code null}.
*/
static String normalizeVariationId(String variationId) {
if (isNumericString(variationId)) {
return variationId;
}
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public class Decision {
@JsonInclude(JsonInclude.Include.ALWAYS)
@JsonProperty("experiment_id")
String experimentId;
@JsonInclude(JsonInclude.Include.ALWAYS)
@JsonProperty("variation_id")
String variationId;
@JsonProperty("is_campaign_holdback")
Expand Down
70 changes: 35 additions & 35 deletions core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/****************************************************************************
* Copyright 2016-2023, Optimizely, Inc. and contributors *
* Copyright 2016-2023, 2026, Optimizely, Inc. and contributors *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); *
* you may not use this file except in compliance with the License. *
Expand Down Expand Up @@ -618,7 +618,7 @@ public void isFeatureEnabledWithExperimentKeyForced() throws Exception {
assertTrue(optimizely.setForcedVariation(activatedExperiment.getKey(), testUserId, null));
assertNull(optimizely.getForcedVariation(activatedExperiment.getKey(), testUserId));
assertFalse(optimizely.isFeatureEnabled(FEATURE_FLAG_MULTI_VARIATE_FEATURE.getKey(), testUserId));
eventHandler.expectImpression(null, "", testUserId);
eventHandler.expectImpression(null, null, testUserId);
}

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

eventHandler.expectImpression(null, "", testUserId);
eventHandler.expectImpression(null, "", testUserId);
eventHandler.expectImpression(null, null, testUserId);
eventHandler.expectImpression(null, null, testUserId);
eventHandler.expectImpression("3794675122", "589640735", testUserId);
eventHandler.expectImpression(null, "", testUserId);
eventHandler.expectImpression(null, "", testUserId);
eventHandler.expectImpression(null, "", testUserId);
eventHandler.expectImpression(null, "", testUserId);
eventHandler.expectImpression(null, null, testUserId);
eventHandler.expectImpression(null, null, testUserId);
eventHandler.expectImpression(null, null, testUserId);
eventHandler.expectImpression(null, null, testUserId);
eventHandler.expectImpression("1786133852", "1619235542", testUserId);

// Verify that listener being called
Expand Down Expand Up @@ -1853,14 +1853,14 @@ public void getEnabledFeaturesWithNoFeatureEnabled() throws Exception {
// Verify that listener not being called
assertFalse(isListenerCalled);

eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, null, genericUserId);
eventHandler.expectImpression(null, null, genericUserId);
eventHandler.expectImpression(null, null, genericUserId);
eventHandler.expectImpression(null, null, genericUserId);
eventHandler.expectImpression(null, null, genericUserId);
eventHandler.expectImpression(null, null, genericUserId);
eventHandler.expectImpression(null, null, genericUserId);
eventHandler.expectImpression(null, null, genericUserId);

assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId));
}
Expand Down Expand Up @@ -2013,7 +2013,7 @@ public void isFeatureEnabledWithListenerUserNotInExperimentAndNotInRollOut() thr
"Feature \"" + validFeatureKey +
"\" is enabled for user \"" + genericUserId + "\"? false"
);
eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, null, genericUserId);

// Verify that listener being called
assertTrue(isListenerCalled);
Expand Down Expand Up @@ -3339,7 +3339,7 @@ public void isFeatureEnabledReturnsFalseWhenUserIsNotBucketedIntoAnyVariation()
"Feature \"" + validFeatureKey +
"\" is enabled for user \"" + genericUserId + "\"? false"
);
eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, null, genericUserId);

verify(mockDecisionService).getVariationForFeature(
eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE),
Expand Down Expand Up @@ -3384,7 +3384,7 @@ public void isFeatureEnabledReturnsTrueButDoesNotSendWhenUserIsBucketedIntoVaria
"Feature \"" + validFeatureKey +
"\" is enabled for user \"" + genericUserId + "\"? true"
);
eventHandler.expectImpression("3421010877", "variationId", genericUserId);
eventHandler.expectImpression("3421010877", null, genericUserId);

verify(mockDecisionService).getVariationForFeature(
eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE),
Expand Down Expand Up @@ -3456,7 +3456,7 @@ public void isFeatureEnabledTrueWhenFeatureEnabledOfVariationIsTrue() throws Exc
);

assertTrue(optimizely.isFeatureEnabled(validFeatureKey, genericUserId));
eventHandler.expectImpression("3421010877", "variationId", genericUserId);
eventHandler.expectImpression("3421010877", null, genericUserId);

}

Expand Down Expand Up @@ -3485,7 +3485,7 @@ public void isFeatureEnabledFalseWhenFeatureEnabledOfVariationIsFalse() throws E
);

assertFalse(spyOptimizely.isFeatureEnabled(FEATURE_MULTI_VARIATE_FEATURE_KEY, genericUserId));
eventHandler.expectImpression("3421010877", "variationId", genericUserId);
eventHandler.expectImpression("3421010877", null, genericUserId);

}

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

eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, null, genericUserId);
eventHandler.expectImpression(null, null, genericUserId);
eventHandler.expectImpression("3794675122", "589640735", genericUserId);
eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, null, genericUserId);
eventHandler.expectImpression("1785077004", "1566407342", genericUserId);
eventHandler.expectImpression("828245624", "3137445031", genericUserId);
eventHandler.expectImpression("828245624", "3137445031", genericUserId);
Expand All @@ -3606,10 +3606,10 @@ public void getEnabledFeatureWithEmptyUserId() throws Exception {
List<String> featureFlags = optimizely.getEnabledFeatures("", Collections.emptyMap());
assertFalse(featureFlags.isEmpty());

eventHandler.expectImpression(null, "", "");
eventHandler.expectImpression(null, "", "");
eventHandler.expectImpression(null, null, "");
eventHandler.expectImpression(null, null, "");
eventHandler.expectImpression("3794675122", "589640735", "");
eventHandler.expectImpression(null, "", "");
eventHandler.expectImpression(null, null, "");
eventHandler.expectImpression("1785077004", "1566407342", "");
eventHandler.expectImpression("828245624", "3137445031", "");
eventHandler.expectImpression("828245624", "3137445031", "");
Expand Down Expand Up @@ -3660,14 +3660,14 @@ public void getEnabledFeatureWithMockDecisionService() throws Exception {
List<String> featureFlags = optimizely.getEnabledFeatures(genericUserId, Collections.emptyMap());
assertTrue(featureFlags.isEmpty());

eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, "", genericUserId);
eventHandler.expectImpression(null, null, genericUserId);
eventHandler.expectImpression(null, null, genericUserId);
eventHandler.expectImpression(null, null, genericUserId);
eventHandler.expectImpression(null, null, genericUserId);
eventHandler.expectImpression(null, null, genericUserId);
eventHandler.expectImpression(null, null, genericUserId);
eventHandler.expectImpression(null, null, genericUserId);
eventHandler.expectImpression(null, null, genericUserId);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
*
* Copyright 2021-2024, Optimizely and contributors
* Copyright 2021-2024, 2026, Optimizely and contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -267,7 +267,7 @@ public void decide_nullVariation() {
.setVariationKey("")
.setEnabled(false)
.build();
eventHandler.expectImpression(null, "", userId, Collections.emptyMap(), metadata);
eventHandler.expectImpression(null, null, userId, Collections.emptyMap(), metadata);
}

// decideAll
Expand Down Expand Up @@ -639,7 +639,7 @@ public void decide_sendEvent_rollout_withSendFlagDecisionsOn() {
user.decide(flagKey);
assertTrue(isListenerCalled);

eventHandler.expectImpression(null, "", userId, attributes);
eventHandler.expectImpression(null, null, userId, attributes);
}

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

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

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

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

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

// At least one log message confirming holdout membership
Expand Down
Loading
Loading