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; + } + } +}