diff --git a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
index a8a704e0..86b5f282 100644
--- a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
+++ b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
@@ -178,6 +178,9 @@
Utils\ConfigParser.cs
+
+ Utils\EventIdNormalizer.cs
+
Utils\EventTagUtils.cs
diff --git a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
index 017e210c..0527ff75 100644
--- a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
+++ b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
@@ -180,6 +180,9 @@
Utils\ConditionParser.cs
+
+ Utils\EventIdNormalizer.cs
+
Utils\EventTagUtils.cs
diff --git a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
index 18aef612..ede232e0 100644
--- a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
+++ b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
@@ -60,6 +60,7 @@
+
diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
index 841059bd..0d41cd24 100644
--- a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
+++ b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
@@ -360,6 +360,9 @@
Utils\DecisionInfoTypes.cs
+
+ Utils\EventIdNormalizer.cs
+
Utils\EventTagUtils.cs
diff --git a/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs b/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs
index 8c839d67..2c56ca8a 100644
--- a/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs
+++ b/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs
@@ -1,6 +1,6 @@
/**
*
- * Copyright 2019-2020, Optimizely and contributors
+ * Copyright 2019-2020, 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.
@@ -23,6 +23,7 @@
using OptimizelySDK.Entity;
using OptimizelySDK.ErrorHandler;
using OptimizelySDK.Event;
+using OptimizelySDK.Event.Entity;
using OptimizelySDK.Logger;
using OptimizelySDK.Utils;
@@ -767,7 +768,9 @@ public void TestCreateImpressionEventRemovesInvalidAttributesFromPayloadRollout(
{
new Dictionary
{
- { "campaign_id", null },
+ // campaign_id falls back to experiment_id (string.Empty
+ // here) when LayerId is missing.
+ { "campaign_id", string.Empty },
{ "experiment_id", string.Empty },
{ "variation_id", null },
{
@@ -788,7 +791,9 @@ public void TestCreateImpressionEventRemovesInvalidAttributesFromPayloadRollout(
{
new Dictionary
{
- { "entity_id", null },
+ // entity_id mirrors campaign_id byte-for-byte for
+ // impression events.
+ { "entity_id", string.Empty },
{ "timestamp", timeStamp },
{ "uuid", guid },
{ "key", "campaign_activated" },
@@ -2759,5 +2764,280 @@ public void TestCreateConversionEventRemovesInvalidAttributesFromPayload()
Guid.Parse(conversionEvent.UUID));
Assert.IsTrue(TestData.CompareObjects(expectedEvent, logEvent));
}
+
+ // ======================================================================
+ // Decision-event id normalization end-to-end tests.
+ //
+ // These tests build ImpressionEvent payloads directly (bypassing
+ // ProjectConfig lookups) so we can exercise the normalization branches
+ // for each invalid-input variant uniformly across decision types
+ // (experiment, feature test, rollout, holdout) without per-type
+ // branching in the normalization path.
+ // ======================================================================
+
+ private static OptimizelySDK.Event.Entity.EventContext NormalizationTestEventContext()
+ {
+ return new OptimizelySDK.Event.Entity.EventContext.Builder()
+ .WithProjectId("7720880029")
+ .WithAccountId("1592310167")
+ .WithAnonymizeIP(false)
+ .WithRevision("15")
+ .Build();
+ }
+
+ private static ImpressionEvent BuildImpressionEvent(
+ string layerId, string experimentId, string variationId, string ruleType)
+ {
+ var experiment = new Experiment
+ {
+ Id = experimentId,
+ Key = "test_experiment",
+ LayerId = layerId,
+ };
+ var variation = variationId == null
+ ? null
+ : new Variation { Id = variationId, Key = "v" };
+ var metadata = new OptimizelySDK.Event.Entity.DecisionMetadata(
+ "test_flag", "test_experiment", ruleType, variation?.Key ?? string.Empty, true);
+
+ return new ImpressionEvent.Builder()
+ .WithEventContext(NormalizationTestEventContext())
+ .WithExperiment(experiment)
+ .WithVariation(variation)
+ .WithMetadata(metadata)
+ .WithUserId("testUserId")
+ .WithVisitorAttributes(new OptimizelySDK.Event.Entity.VisitorAttribute[0])
+ .Build();
+ }
+
+ // Re-serialize the LogEvent params to a JObject so we can navigate without
+ // having to know whether the underlying values are object[], JArray, Snapshot,
+ // or Dictionary instances. This mirrors what the wire payload looks like.
+ private static Newtonsoft.Json.Linq.JObject AsJson(LogEvent logEvent)
+ {
+ var json = Newtonsoft.Json.JsonConvert.SerializeObject(logEvent.Params);
+ return Newtonsoft.Json.Linq.JObject.Parse(json);
+ }
+
+ private static Newtonsoft.Json.Linq.JObject ExtractDecisionJson(LogEvent logEvent)
+ {
+ var root = AsJson(logEvent);
+ return (Newtonsoft.Json.Linq.JObject)
+ root["visitors"][0]["snapshots"][0]["decisions"][0];
+ }
+
+ private static Newtonsoft.Json.Linq.JObject ExtractEventJson(LogEvent logEvent)
+ {
+ var root = AsJson(logEvent);
+ return (Newtonsoft.Json.Linq.JObject)
+ root["visitors"][0]["snapshots"][0]["events"][0];
+ }
+
+
+ [Test]
+ public void TestNormalize_ValidNumericIds_PassThroughUnchanged()
+ {
+ // Happy path: valid numeric IDs flow through unchanged.
+ var impressionEvent = BuildImpressionEvent(
+ layerId: "7719770039",
+ experimentId: "1111111111",
+ variationId: "7722370027",
+ ruleType: "experiment");
+ var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger);
+
+ var decision = ExtractDecisionJson(logEvent);
+ Assert.AreEqual("7719770039", (string)decision["campaign_id"]);
+ Assert.AreEqual("1111111111", (string)decision["experiment_id"]);
+ Assert.AreEqual("7722370027", (string)decision["variation_id"]);
+
+ var ev = ExtractEventJson(logEvent);
+ Assert.AreEqual("7719770039", (string)ev["entity_id"]);
+ Assert.AreEqual((string)decision["campaign_id"], (string)ev["entity_id"],
+ "entity_id must equal campaign_id byte-for-byte");
+ }
+
+ [Test]
+ public void TestNormalize_NullLayerId_CampaignIdFallsBackToExperimentId()
+ {
+ // FR-001/FR-002: campaign_id null -> experiment_id substituted.
+ var impressionEvent = BuildImpressionEvent(
+ layerId: null,
+ experimentId: "1111111111",
+ variationId: "7722370027",
+ ruleType: "experiment");
+ var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger);
+
+ var decision = ExtractDecisionJson(logEvent);
+ Assert.AreEqual("1111111111", (string)decision["campaign_id"]);
+ Assert.AreEqual((string)decision["campaign_id"],
+ (string)ExtractEventJson(logEvent)["entity_id"]);
+ }
+
+ [Test]
+ public void TestNormalize_OpaqueLayerId_CampaignIdPassesThroughUnchanged()
+ {
+ // campaign_id accepts any non-empty string, including opaque IDs
+ // like "default-12345" or "layer_abc". The experiment_id fallback
+ // fires ONLY when campaign_id is null or "".
+ var impressionEvent = BuildImpressionEvent(
+ layerId: "default-12345",
+ experimentId: "1111111111",
+ variationId: "7722370027",
+ ruleType: "feature-test");
+ var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger);
+
+ var decision = ExtractDecisionJson(logEvent);
+ Assert.AreEqual("default-12345", (string)decision["campaign_id"]);
+ Assert.AreEqual((string)decision["campaign_id"],
+ (string)ExtractEventJson(logEvent)["entity_id"],
+ "entity_id must equal campaign_id byte-for-byte");
+ }
+
+ [Test]
+ public void TestNormalize_EmptyLayerId_CampaignIdFallsBackToExperimentId()
+ {
+ // FR-001/FR-002: empty-string campaign_id -> experiment_id substituted.
+ var impressionEvent = BuildImpressionEvent(
+ layerId: string.Empty,
+ experimentId: "1111111111",
+ variationId: "7722370027",
+ ruleType: "feature-test");
+ var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger);
+
+ var decision = ExtractDecisionJson(logEvent);
+ Assert.AreEqual("1111111111", (string)decision["campaign_id"]);
+ Assert.AreEqual((string)decision["campaign_id"],
+ (string)ExtractEventJson(logEvent)["entity_id"]);
+ }
+
+ [Test]
+ public void TestNormalize_NonNumericVariationId_VariationIdBecomesNull()
+ {
+ // FR-003/FR-004: variation_id non-numeric -> null.
+ var impressionEvent = BuildImpressionEvent(
+ layerId: "7719770039",
+ experimentId: "1111111111",
+ variationId: "variation_a",
+ ruleType: "experiment");
+ var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger);
+
+ var decision = ExtractDecisionJson(logEvent);
+ Assert.AreEqual(Newtonsoft.Json.Linq.JTokenType.Null, decision["variation_id"].Type);
+ }
+
+ [Test]
+ public void TestNormalize_EmptyVariationId_VariationIdBecomesNull()
+ {
+ var impressionEvent = BuildImpressionEvent(
+ layerId: "7719770039",
+ experimentId: "1111111111",
+ variationId: string.Empty,
+ ruleType: "experiment");
+ var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger);
+
+ var decision = ExtractDecisionJson(logEvent);
+ Assert.AreEqual(Newtonsoft.Json.Linq.JTokenType.Null, decision["variation_id"].Type);
+ }
+
+ [Test]
+ public void TestNormalize_AppliedUniformlyAcrossRuleTypes()
+ {
+ // Rule applies uniformly to ALL decision types. Same inputs must
+ // produce byte-equivalent wire output regardless of rule_type
+ // (experiment, feature-test, rollout, holdout). Opaque non-empty
+ // campaign_id ("layer_abc") passes through; non-numeric
+ // variation_id falls back to null (strict numeric-string contract).
+ var ruleTypes = new[] { "experiment", "feature-test", "rollout", "holdout" };
+ string firstCampaignId = null;
+ Newtonsoft.Json.Linq.JTokenType firstVariationIdType =
+ Newtonsoft.Json.Linq.JTokenType.None;
+ string firstEntityId = null;
+
+ foreach (var ruleType in ruleTypes)
+ {
+ var impressionEvent = BuildImpressionEvent(
+ layerId: "layer_abc",
+ experimentId: "1111111111",
+ variationId: "also_not_numeric",
+ ruleType: ruleType);
+ var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger);
+
+ var decision = ExtractDecisionJson(logEvent);
+ var ev = ExtractEventJson(logEvent);
+
+ var campaignId = (string)decision["campaign_id"];
+ var variationIdType = decision["variation_id"].Type;
+ var entityId = (string)ev["entity_id"];
+
+ Assert.AreEqual("layer_abc", campaignId, "rule_type=" + ruleType);
+ Assert.AreEqual(Newtonsoft.Json.Linq.JTokenType.Null, variationIdType,
+ "rule_type=" + ruleType);
+ Assert.AreEqual(campaignId, entityId,
+ "entity_id must equal campaign_id (rule_type=" + ruleType + ")");
+
+ if (firstCampaignId == null)
+ {
+ firstCampaignId = campaignId;
+ firstVariationIdType = variationIdType;
+ firstEntityId = entityId;
+ }
+ else
+ {
+ Assert.AreEqual(firstCampaignId, campaignId,
+ "campaign_id must be uniform across rule types");
+ Assert.AreEqual(firstVariationIdType, variationIdType,
+ "variation_id must be uniform across rule types");
+ Assert.AreEqual(firstEntityId, entityId,
+ "entity_id must be uniform across rule types");
+ }
+ }
+ }
+
+ [Test]
+ public void TestNormalize_NullLayerIdAppliedUniformlyAcrossRuleTypes()
+ {
+ // Companion to TestNormalize_AppliedUniformlyAcrossRuleTypes: exercises
+ // the fallback branch (null campaign_id) uniformly across rule types.
+ var ruleTypes = new[] { "experiment", "feature-test", "rollout", "holdout" };
+ foreach (var ruleType in ruleTypes)
+ {
+ var impressionEvent = BuildImpressionEvent(
+ layerId: null,
+ experimentId: "1111111111",
+ variationId: null,
+ ruleType: ruleType);
+ var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger);
+
+ var decision = ExtractDecisionJson(logEvent);
+ var ev = ExtractEventJson(logEvent);
+ Assert.AreEqual("1111111111", (string)decision["campaign_id"],
+ "rule_type=" + ruleType);
+ Assert.AreEqual((string)decision["campaign_id"], (string)ev["entity_id"],
+ "entity_id must equal campaign_id (rule_type=" + ruleType + ")");
+ Assert.AreEqual(Newtonsoft.Json.Linq.JTokenType.Null,
+ decision["variation_id"].Type, "rule_type=" + ruleType);
+ }
+ }
+
+ [Test]
+ public void TestNormalize_DoesNotDropEventDispatch()
+ {
+ // FR-006: do not drop, defer, or fail event dispatch.
+ // Even when every id is invalid, a LogEvent must still be produced.
+ var impressionEvent = BuildImpressionEvent(
+ layerId: null,
+ experimentId: string.Empty,
+ variationId: null,
+ ruleType: "rollout");
+ var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger);
+
+ Assert.IsNotNull(logEvent, "Event dispatch must NOT be dropped during normalization");
+ var decision = ExtractDecisionJson(logEvent);
+ Assert.AreEqual(string.Empty, (string)decision["campaign_id"]);
+ Assert.AreEqual(string.Empty, (string)decision["experiment_id"]);
+ Assert.AreEqual(Newtonsoft.Json.Linq.JTokenType.Null, decision["variation_id"].Type);
+ Assert.AreEqual(string.Empty,
+ (string)ExtractEventJson(logEvent)["entity_id"]);
+ }
}
}
diff --git a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
index 98db5acf..11283bfe 100644
--- a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
+++ b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
@@ -121,6 +121,7 @@
+
diff --git a/OptimizelySDK.Tests/UtilsTests/EventIdNormalizerTest.cs b/OptimizelySDK.Tests/UtilsTests/EventIdNormalizerTest.cs
new file mode 100644
index 00000000..e866de4e
--- /dev/null
+++ b/OptimizelySDK.Tests/UtilsTests/EventIdNormalizerTest.cs
@@ -0,0 +1,281 @@
+/*
+ * 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.
+ */
+
+using NUnit.Framework;
+using OptimizelySDK.Utils;
+
+namespace OptimizelySDK.Tests.UtilsTests
+{
+ ///
+ /// Unit tests for decision-event id normalization rules.
+ ///
+ /// Two distinct validity definitions are exercised here:
+ /// - IsNonEmptyString: campaign_id / entity_id contract (any non-empty string OK,
+ /// including opaque IDs like "default-12345").
+ /// - IsNumericIdString: variation_id contract (decimal digits only).
+ ///
+ [TestFixture]
+ public class EventIdNormalizerTest
+ {
+ // ---------- IsNonEmptyString ----------
+
+ [Test]
+ public void IsNonEmptyString_NumericString_ReturnsTrue()
+ {
+ Assert.IsTrue(EventIdNormalizer.IsNonEmptyString("7719770039"));
+ }
+
+ [Test]
+ public void IsNonEmptyString_OpaqueString_ReturnsTrue()
+ {
+ // Any non-empty string is valid for campaign_id / entity_id.
+ // Opaque and prefixed IDs pass through.
+ Assert.IsTrue(EventIdNormalizer.IsNonEmptyString("default-12345"));
+ Assert.IsTrue(EventIdNormalizer.IsNonEmptyString("layer_abc"));
+ Assert.IsTrue(EventIdNormalizer.IsNonEmptyString("abc"));
+ Assert.IsTrue(EventIdNormalizer.IsNonEmptyString("exp_42"));
+ }
+
+ [Test]
+ public void IsNonEmptyString_Whitespace_ReturnsTrue()
+ {
+ // Whitespace is non-empty content. The relaxed contract does not
+ // re-validate character content beyond length >= 1.
+ Assert.IsTrue(EventIdNormalizer.IsNonEmptyString(" "));
+ Assert.IsTrue(EventIdNormalizer.IsNonEmptyString("\t"));
+ }
+
+ [Test]
+ public void IsNonEmptyString_Null_ReturnsFalse()
+ {
+ Assert.IsFalse(EventIdNormalizer.IsNonEmptyString(null));
+ }
+
+ [Test]
+ public void IsNonEmptyString_Empty_ReturnsFalse()
+ {
+ Assert.IsFalse(EventIdNormalizer.IsNonEmptyString(string.Empty));
+ }
+
+ // ---------- IsNumericIdString ----------
+
+ [Test]
+ public void IsNumericIdString_DigitString_ReturnsTrue()
+ {
+ Assert.IsTrue(EventIdNormalizer.IsNumericIdString("7719770039"));
+ }
+
+ [Test]
+ public void IsNumericIdString_SingleDigit_ReturnsTrue()
+ {
+ Assert.IsTrue(EventIdNormalizer.IsNumericIdString("0"));
+ Assert.IsTrue(EventIdNormalizer.IsNumericIdString("9"));
+ }
+
+ [Test]
+ public void IsNumericIdString_LeadingZeros_ReturnsTrue()
+ {
+ // Leading zeros are allowed.
+ Assert.IsTrue(EventIdNormalizer.IsNumericIdString("0001"));
+ Assert.IsTrue(EventIdNormalizer.IsNumericIdString("0000"));
+ }
+
+ [Test]
+ public void IsNumericIdString_Null_ReturnsFalse()
+ {
+ Assert.IsFalse(EventIdNormalizer.IsNumericIdString(null));
+ }
+
+ [Test]
+ public void IsNumericIdString_Empty_ReturnsFalse()
+ {
+ Assert.IsFalse(EventIdNormalizer.IsNumericIdString(string.Empty));
+ }
+
+ [Test]
+ public void IsNumericIdString_Whitespace_ReturnsFalse()
+ {
+ Assert.IsFalse(EventIdNormalizer.IsNumericIdString(" "));
+ Assert.IsFalse(EventIdNormalizer.IsNumericIdString("\t"));
+ Assert.IsFalse(EventIdNormalizer.IsNumericIdString("123 "));
+ Assert.IsFalse(EventIdNormalizer.IsNumericIdString(" 123"));
+ Assert.IsFalse(EventIdNormalizer.IsNumericIdString("12 3"));
+ }
+
+ [Test]
+ public void IsNumericIdString_AlphaOrAlphanumeric_ReturnsFalse()
+ {
+ Assert.IsFalse(EventIdNormalizer.IsNumericIdString("abc"));
+ Assert.IsFalse(EventIdNormalizer.IsNumericIdString("variation_a"));
+ Assert.IsFalse(EventIdNormalizer.IsNumericIdString("exp_42"));
+ Assert.IsFalse(EventIdNormalizer.IsNumericIdString("123abc"));
+ Assert.IsFalse(EventIdNormalizer.IsNumericIdString("abc123"));
+ }
+
+ [Test]
+ public void IsNumericIdString_SignedOrDecimal_ReturnsFalse()
+ {
+ Assert.IsFalse(EventIdNormalizer.IsNumericIdString("-123"));
+ Assert.IsFalse(EventIdNormalizer.IsNumericIdString("+123"));
+ Assert.IsFalse(EventIdNormalizer.IsNumericIdString("12.3"));
+ Assert.IsFalse(EventIdNormalizer.IsNumericIdString("1e5"));
+ Assert.IsFalse(EventIdNormalizer.IsNumericIdString("0x1F"));
+ }
+
+ // ---------- NormalizeCampaignId ----------
+
+ [Test]
+ public void NormalizeCampaignId_ValidNumeric_ReturnsAsIs()
+ {
+ Assert.AreEqual("7719770039",
+ EventIdNormalizer.NormalizeCampaignId("7719770039", "1111111111"));
+ }
+
+ [Test]
+ public void NormalizeCampaignId_OpaqueString_ReturnsAsIs()
+ {
+ // Any non-empty string is valid for campaign_id. Opaque IDs
+ // pass through unchanged — no fallback.
+ Assert.AreEqual("default-12345",
+ EventIdNormalizer.NormalizeCampaignId("default-12345", "1111111111"));
+ Assert.AreEqual("layer_abc",
+ EventIdNormalizer.NormalizeCampaignId("layer_abc", "1111111111"));
+ Assert.AreEqual("variation_a",
+ EventIdNormalizer.NormalizeCampaignId("variation_a", "1111111111"));
+ Assert.AreEqual("exp_42",
+ EventIdNormalizer.NormalizeCampaignId("exp_42", "1111111111"));
+ Assert.AreEqual("abc",
+ EventIdNormalizer.NormalizeCampaignId("abc", "1111111111"));
+ }
+
+ [Test]
+ public void NormalizeCampaignId_WhitespaceString_ReturnsAsIs()
+ {
+ // Whitespace strings are non-empty, so they pass through under the
+ // relaxed contract (fallback fires only on null or "").
+ Assert.AreEqual(" 7719770039 ",
+ EventIdNormalizer.NormalizeCampaignId(" 7719770039 ", "1111111111"));
+ Assert.AreEqual(" ",
+ EventIdNormalizer.NormalizeCampaignId(" ", "1111111111"));
+ }
+
+ [Test]
+ public void NormalizeCampaignId_Null_SubstitutesExperimentId()
+ {
+ Assert.AreEqual("1111111111",
+ EventIdNormalizer.NormalizeCampaignId(null, "1111111111"));
+ }
+
+ [Test]
+ public void NormalizeCampaignId_Empty_SubstitutesExperimentId()
+ {
+ Assert.AreEqual("1111111111",
+ EventIdNormalizer.NormalizeCampaignId(string.Empty, "1111111111"));
+ }
+
+ [Test]
+ public void NormalizeCampaignId_InvalidWithEmptyExperimentId_ReturnsEmpty()
+ {
+ // Mirrors the rollout case where activatedExperiment is null and we want
+ // wire output `""` rather than `null` (matches existing test contract).
+ Assert.AreEqual(string.Empty,
+ EventIdNormalizer.NormalizeCampaignId(null, string.Empty));
+ }
+
+ [Test]
+ public void NormalizeCampaignId_SubstituteNotRecursivelyNormalized()
+ {
+ // The normalizer returns experimentId AS-IS when campaignId is empty/null.
+ // This matches the cross-SDK contract and lets callers see the exact substitute.
+ Assert.AreEqual("not_numeric_either",
+ EventIdNormalizer.NormalizeCampaignId(null, "not_numeric_either"));
+ }
+
+ // ---------- NormalizeVariationId ----------
+ // variation_id contract is UNCHANGED — still strict numeric-string only.
+
+ [Test]
+ public void NormalizeVariationId_ValidNumeric_ReturnsAsIs()
+ {
+ Assert.AreEqual("7722370027",
+ EventIdNormalizer.NormalizeVariationId("7722370027"));
+ }
+
+ [Test]
+ public void NormalizeVariationId_Null_ReturnsNull()
+ {
+ Assert.IsNull(EventIdNormalizer.NormalizeVariationId(null));
+ }
+
+ [Test]
+ public void NormalizeVariationId_Empty_ReturnsNull()
+ {
+ Assert.IsNull(EventIdNormalizer.NormalizeVariationId(string.Empty));
+ }
+
+ [Test]
+ public void NormalizeVariationId_NonNumeric_ReturnsNull()
+ {
+ Assert.IsNull(EventIdNormalizer.NormalizeVariationId("variation_a"));
+ Assert.IsNull(EventIdNormalizer.NormalizeVariationId("v1"));
+ Assert.IsNull(EventIdNormalizer.NormalizeVariationId("abc"));
+ }
+
+ [Test]
+ public void NormalizeVariationId_Whitespace_ReturnsNull()
+ {
+ Assert.IsNull(EventIdNormalizer.NormalizeVariationId(" "));
+ Assert.IsNull(EventIdNormalizer.NormalizeVariationId(" 123"));
+ Assert.IsNull(EventIdNormalizer.NormalizeVariationId("12 3"));
+ }
+
+ [Test]
+ public void NormalizeVariationId_SignedOrDecimal_ReturnsNull()
+ {
+ Assert.IsNull(EventIdNormalizer.NormalizeVariationId("-123"));
+ Assert.IsNull(EventIdNormalizer.NormalizeVariationId("12.3"));
+ }
+
+ // ---------- Cross-field invariant: entity_id == campaign_id ----------
+
+ [Test]
+ public void EntityIdFollowsSameRuleAsCampaignId()
+ {
+ // FR-009: entity_id (impression events) uses the same normalization rule
+ // as campaign_id. Callers should pass the SAME inputs to NormalizeCampaignId
+ // for both fields to ensure byte-equivalence. Non-empty opaque/whitespace
+ // strings pass through; only null and "" trigger the experiment_id fallback.
+ var inputs = new[] {
+ new { CampaignId = "7719770039", ExperimentId = "1111111111" },
+ new { CampaignId = (string)null, ExperimentId = "1111111111" },
+ new { CampaignId = string.Empty, ExperimentId = "1111111111" },
+ new { CampaignId = "default-12345", ExperimentId = "1111111111" },
+ new { CampaignId = "layer_abc", ExperimentId = "1111111111" },
+ new { CampaignId = " ", ExperimentId = string.Empty },
+ };
+
+ foreach (var input in inputs)
+ {
+ var campaignId = EventIdNormalizer.NormalizeCampaignId(
+ input.CampaignId, input.ExperimentId);
+ var entityId = EventIdNormalizer.NormalizeCampaignId(
+ input.CampaignId, input.ExperimentId);
+ Assert.AreEqual(campaignId, entityId,
+ "entity_id must equal campaign_id byte-for-byte for the same impression event");
+ }
+ }
+ }
+}
diff --git a/OptimizelySDK/Event/Builder/EventBuilder.cs b/OptimizelySDK/Event/Builder/EventBuilder.cs
index 0dd4562a..7e2b3c3e 100644
--- a/OptimizelySDK/Event/Builder/EventBuilder.cs
+++ b/OptimizelySDK/Event/Builder/EventBuilder.cs
@@ -1,5 +1,5 @@
/*
- * Copyright 2017-2019, Optimizely
+ * Copyright 2017-2019, 2026, Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -130,13 +130,18 @@ string variationId
{
var impressionEvent = new Dictionary();
+ var experimentId = experiment?.Id ?? string.Empty;
+ var normalizedCampaignId = EventIdNormalizer.NormalizeCampaignId(
+ experiment?.LayerId, experimentId);
+ var normalizedVariationId = EventIdNormalizer.NormalizeVariationId(variationId);
+
var decisions = new object[]
{
new Dictionary
{
- { Params.CAMPAIGN_ID, experiment?.LayerId },
- { Params.EXPERIMENT_ID, experiment?.Id ?? string.Empty },
- { Params.VARIATION_ID, variationId },
+ { Params.CAMPAIGN_ID, normalizedCampaignId },
+ { Params.EXPERIMENT_ID, experimentId },
+ { Params.VARIATION_ID, normalizedVariationId },
},
};
@@ -145,7 +150,7 @@ string variationId
{
new Dictionary
{
- { "entity_id", experiment?.LayerId },
+ { "entity_id", normalizedCampaignId },
{ "timestamp", DateTimeUtils.SecondsSince1970 * 1000 },
{ "key", ACTIVATE_EVENT_KEY },
{ "uuid", Guid.NewGuid() },
diff --git a/OptimizelySDK/Event/EventFactory.cs b/OptimizelySDK/Event/EventFactory.cs
index 771e0f39..f9788858 100644
--- a/OptimizelySDK/Event/EventFactory.cs
+++ b/OptimizelySDK/Event/EventFactory.cs
@@ -1,5 +1,5 @@
/*
- * Copyright 2019-2020, Optimizely
+ * Copyright 2019-2020, 2026, Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -145,13 +145,19 @@ private static Visitor CreateVisitor(ImpressionEvent impressionEvent)
return null;
}
- var decision = new Decision(impressionEvent.Experiment?.LayerId,
- impressionEvent.Experiment?.Id ?? string.Empty,
- impressionEvent.Variation?.Id,
+ var experimentId = impressionEvent.Experiment?.Id ?? string.Empty;
+ var normalizedCampaignId = EventIdNormalizer.NormalizeCampaignId(
+ impressionEvent.Experiment?.LayerId, experimentId);
+ var normalizedVariationId = EventIdNormalizer.NormalizeVariationId(
+ impressionEvent.Variation?.Id);
+
+ var decision = new Decision(normalizedCampaignId,
+ experimentId,
+ normalizedVariationId,
impressionEvent.Metadata);
var snapshotEvent = new SnapshotEvent.Builder().WithUUID(impressionEvent.UUID).
- WithEntityId(impressionEvent.Experiment?.LayerId).
+ WithEntityId(normalizedCampaignId).
WithKey(ACTIVATE_EVENT_KEY).
WithTimeStamp(impressionEvent.Timestamp).
Build();
diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj
index a0ae7ba4..ddf58187 100644
--- a/OptimizelySDK/OptimizelySDK.csproj
+++ b/OptimizelySDK/OptimizelySDK.csproj
@@ -180,6 +180,7 @@
+
diff --git a/OptimizelySDK/Utils/EventIdNormalizer.cs b/OptimizelySDK/Utils/EventIdNormalizer.cs
new file mode 100644
index 00000000..e1626cb8
--- /dev/null
+++ b/OptimizelySDK/Utils/EventIdNormalizer.cs
@@ -0,0 +1,111 @@
+/*
+ * 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.
+ */
+
+namespace OptimizelySDK.Utils
+{
+ ///
+ /// Normalizes decision-event identifier fields (campaign_id, variation_id, entity_id)
+ /// so that the wire output is byte-equivalent across SDKs for the same input
+ /// regardless of decision type (experiment, feature test, rollout, holdout).
+ ///
+ /// Two distinct validity definitions apply:
+ ///
+ /// - "Non-empty string" (campaign_id, impression-event entity_id): a string value of
+ /// length >= 1 with any character content. Numeric ("12345"), prefixed
+ /// ("default-12345"), and opaque ("layer_abc") IDs are all valid. Only `null` and
+ /// the empty string `""` trigger the fallback. Whitespace-only strings (e.g. " ")
+ /// are non-empty strings and therefore PASS THROUGH unchanged per the relaxed
+ /// contract (the upstream datafile is expected to deliver well-formed string IDs).
+ ///
+ /// - "Numeric string" (variation_id only): a non-empty string consisting entirely of
+ /// decimal digits [0-9]. Leading zeros are allowed. Whitespace, signs, decimals
+ /// and exponents are INVALID and trigger the null fallback.
+ ///
+ /// Rules:
+ /// - campaign_id (and impression-event entity_id): if not a non-empty string,
+ /// substitute the provided experiment_id (which may itself be empty or null;
+ /// callers MUST normalize experiment_id separately if they want a guarantee here).
+ /// - variation_id: if not a non-empty numeric string, substitute null.
+ ///
+ /// Non-string types (raw number, boolean, object) are out of scope per the spec
+ /// — the upstream datafile producer is assumed to deliver string-typed (or null)
+ /// values for these three fields.
+ ///
+ /// This normalization MUST NOT log, warn, throw, drop, or defer event dispatch.
+ ///
+ internal static class EventIdNormalizer
+ {
+ ///
+ /// Returns true if is a non-empty string (length >= 1).
+ /// Any character content is accepted — IDs may be opaque like "default-12345"
+ /// or "layer_abc". Only `null` and the empty string return false.
+ ///
+ internal static bool IsNonEmptyString(string value)
+ {
+ return value != null && value.Length > 0;
+ }
+
+ ///
+ /// Returns true if is a non-empty string consisting
+ /// entirely of decimal digits [0-9]. Leading zeros are allowed.
+ ///
+ internal static bool IsNumericIdString(string value)
+ {
+ if (value == null)
+ {
+ return false;
+ }
+
+ if (value.Length == 0)
+ {
+ return false;
+ }
+
+ for (int i = 0; i < value.Length; i++)
+ {
+ char c = value[i];
+ if (c < '0' || c > '9')
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Normalize a campaign_id (or impression-event entity_id, which follows the same rule).
+ /// If is a non-empty string, return it unchanged
+ /// (any character content is accepted — numeric, prefixed, or opaque).
+ /// Otherwise (null or empty string) substitute .
+ /// The returned value is returned as-is (NOT recursively normalized) so callers
+ /// see the exact substitute they passed in, matching the cross-SDK contract.
+ ///
+ internal static string NormalizeCampaignId(string campaignId, string experimentId)
+ {
+ return IsNonEmptyString(campaignId) ? campaignId : experimentId;
+ }
+
+ ///
+ /// Normalize a variation_id. If is a valid numeric
+ /// string, return it unchanged. Otherwise return null.
+ ///
+ internal static string NormalizeVariationId(string variationId)
+ {
+ return IsNumericIdString(variationId) ? variationId : null;
+ }
+ }
+}