diff --git a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj index 6d97bc2d..e5730209 100644 --- a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj +++ b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj @@ -266,6 +266,9 @@ Event\Entity\Visitor.cs + + Event\Entity\DecisionMetadata.cs + Event\Entity\VisitorAttribute.cs diff --git a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj index 83639901..5ce0521a 100644 --- a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj +++ b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj @@ -280,6 +280,9 @@ Event\Entity\VisitorAttribute.cs + + Event\Entity\DecisionMetadata.cs + Event\EventFactory.cs diff --git a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj index a38425a0..868c1d8c 100644 --- a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj +++ b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj @@ -121,6 +121,9 @@ VisitorAttribute.cs + + DecisionMetadata.cs + DecisionEvent.cs diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj index 82cf73c9..1a126f2c 100644 --- a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj +++ b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj @@ -174,6 +174,9 @@ Event\Entity\Decision.cs + + + Event\Entity\DecisionMetadata.cs Event\Entity\EventBatch.cs diff --git a/OptimizelySDK.Tests/EventTests/EventEntitiesTest.cs b/OptimizelySDK.Tests/EventTests/EventEntitiesTest.cs index 15c4e28d..10e5c3e4 100644 --- a/OptimizelySDK.Tests/EventTests/EventEntitiesTest.cs +++ b/OptimizelySDK.Tests/EventTests/EventEntitiesTest.cs @@ -1,4 +1,22 @@ -using System; +/** + * + * Copyright 2019-2020, 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. + */ + + +using System; using System.Collections.Generic; using Newtonsoft.Json.Linq; using Newtonsoft.Json.Schema; @@ -104,7 +122,7 @@ public void TestImpressionEventEqualsSerializedPayload() .WithTimeStamp(timeStamp) .WithEventTags(null) .Build(); - + var metadata = new DecisionMetadata("experiment", "experiment_key", "7716830082"); var decision = new Decision("7719770039", "7716830082", "77210100090"); var snapshot = new Snapshot(events: new SnapshotEvent[] { snapshotEvent }, decisions: new Decision[] { decision }); diff --git a/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs b/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs index 338d36c1..eb53073c 100644 --- a/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs +++ b/OptimizelySDK.Tests/EventTests/EventFactoryTest.cs @@ -1,4 +1,21 @@ -using System; +/** + * + * Copyright 2019-2020, 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. + */ + +using System; using System.Collections.Generic; using Moq; using NUnit.Framework; @@ -27,6 +44,25 @@ public void Setup() Config = DatafileProjectConfig.Create(TestData.Datafile, logger, new ErrorHandler.NoOpErrorHandler()); } + [Test] + public void TestCreateImpressionEventReturnsNullWhenSendFlagDecisionsIsFalseAndIsRollout() + { + Config.SendFlagDecisions = false; + var impressionEvent = UserEventFactory.CreateImpressionEvent( + Config, Config.GetExperimentFromKey("test_experiment"), "7722370027", TestUserId, null, "test_feature", "rollout"); + Assert.IsNull(impressionEvent); + } + + [Test] + public void TestCreateImpressionEventReturnsNullWhenSendFlagDecisionsIsFalseAndVariationIsNull() + { + Config.SendFlagDecisions = false; + Variation variation = null; + var impressionEvent = UserEventFactory.CreateImpressionEvent( + Config, Config.GetExperimentFromKey("test_experiment"), variation, TestUserId, null, "test_experiment", "experiment"); + Assert.IsNull(impressionEvent); + } + [Test] public void TestCreateImpressionEventNoAttributes() { @@ -45,7 +81,14 @@ public void TestCreateImpressionEventNoAttributes() new Dictionary { { "campaign_id", "7719770039" }, { "experiment_id", "7716830082" }, - { "variation_id", "7722370027" } + { "variation_id", "7722370027" }, + { "metadata", + new Dictionary { + { "rule_type", "experiment" }, + { "rule_key", "test_experiment" }, + { "flag_key", "test_experiment" }, + { "variation_key", "control" } + } } } } }, @@ -93,7 +136,7 @@ public void TestCreateImpressionEventNoAttributes() { "Content-Type", "application/json" } }); var impressionEvent = UserEventFactory.CreateImpressionEvent( - Config, Config.GetExperimentFromKey("test_experiment"), "7722370027", TestUserId, null); + Config, Config.GetExperimentFromKey("test_experiment"), "7722370027", TestUserId, null, "test_experiment", "experiment"); var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger); @@ -120,7 +163,14 @@ public void TestCreateImpressionEventWithAttributes() new Dictionary { {"campaign_id", "7719770039" }, {"experiment_id", "7716830082" }, - {"variation_id", "7722370027" } + {"variation_id", "7722370027" }, + { "metadata", new Dictionary { + { "rule_type", "experiment" }, + { "rule_key", "test_experiment" }, + { "flag_key", "test_experiment" }, + { "variation_key", "control" } + } + } } } }, @@ -183,7 +233,7 @@ public void TestCreateImpressionEventWithAttributes() { "company", "Optimizely" } }; // - var impressionEvent = UserEventFactory.CreateImpressionEvent(Config, Config.GetExperimentFromKey("test_experiment"), variationId, TestUserId, userAttributes); + var impressionEvent = UserEventFactory.CreateImpressionEvent(Config, Config.GetExperimentFromKey("test_experiment"), variationId, TestUserId, userAttributes, "test_experiment", "experiment"); var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger); TestData.ChangeGUIDAndTimeStamp(expectedLogEvent.Params, impressionEvent.Timestamp, Guid.Parse(impressionEvent.UUID)); @@ -213,7 +263,14 @@ public void TestCreateImpressionEventWithTypedAttributes() { {"campaign_id", "7719770039" }, {"experiment_id", "7716830082" }, - {"variation_id", "7722370027" } + {"variation_id", "7722370027" }, + { "metadata", new Dictionary { + { "rule_type", "experiment" }, + { "rule_key", "test_experiment" }, + { "flag_key", "test_experiment" }, + { "variation_key", "control" } + } + } } } }, @@ -298,7 +355,7 @@ public void TestCreateImpressionEventWithTypedAttributes() {"integer_key", 15 }, {"double_key", 3.14 } }; - var impressionEvent = UserEventFactory.CreateImpressionEvent(Config, Config.GetExperimentFromKey("test_experiment"), "7722370027", TestUserId, userAttributes); + var impressionEvent = UserEventFactory.CreateImpressionEvent(Config, Config.GetExperimentFromKey("test_experiment"), "7722370027", TestUserId, userAttributes, "test_experiment", "experiment"); var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger); TestData.ChangeGUIDAndTimeStamp(expectedLogEvent.Params, impressionEvent.Timestamp, Guid.Parse(impressionEvent.UUID)); @@ -328,7 +385,14 @@ public void TestCreateImpressionEventRemovesInvalidAttributesFromPayload() { {"campaign_id", "7719770039" }, {"experiment_id", "7716830082" }, - {"variation_id", "7722370027" } + {"variation_id", "7722370027" }, + { "metadata", new Dictionary { + { "rule_type", "experiment" }, + { "rule_key", "test_experiment" }, + { "flag_key", "test_experiment" }, + { "variation_key", "control" } + } + } } } }, @@ -413,9 +477,133 @@ public void TestCreateImpressionEventRemovesInvalidAttributesFromPayload() { "nan", double.NaN }, { "invalid_num_value", Math.Pow(2, 53) + 2 }, }; - var impressionEvent = UserEventFactory.CreateImpressionEvent(Config, Config.GetExperimentFromKey("test_experiment"), "7722370027", TestUserId, userAttributes); + var impressionEvent = UserEventFactory.CreateImpressionEvent(Config, Config.GetExperimentFromKey("test_experiment"), "7722370027", TestUserId, userAttributes, "test_experiment", "experiment"); var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger); - + + TestData.ChangeGUIDAndTimeStamp(expectedLogEvent.Params, impressionEvent.Timestamp, Guid.Parse(impressionEvent.UUID)); + + Assert.IsTrue(TestData.CompareObjects(expectedLogEvent, logEvent)); + } + + [Test] + public void TestCreateImpressionEventRemovesInvalidAttributesFromPayloadRollout() + { + var guid = Guid.NewGuid(); + var timeStamp = TestData.SecondsSince1970(); + + var payloadParams = new Dictionary + { + { "visitors", new object[] + { + new Dictionary() + { + { "snapshots", new object[] + { + new Dictionary + { + { "decisions", new object[] + { + new Dictionary + { + {"campaign_id", null }, + {"experiment_id", null }, + {"variation_id", null }, + { "metadata", new Dictionary { + { "rule_type", "rollout" }, + { "rule_key", "" }, + { "flag_key", "test_feature" }, + { "variation_key", "" } + } + } + } + } + }, + { "events", new object[] + { + new Dictionary + { + {"entity_id", null }, + {"timestamp", timeStamp }, + {"uuid", guid }, + {"key", "campaign_activated" } + } + } + } + } + } + }, + {"attributes", new object[] + { + new Dictionary + { + {"entity_id", "7723280020" }, + {"key", "device_type" }, + {"type", "custom" }, + {"value", "iPhone"} + }, + new Dictionary + { + {"entity_id", "323434545" }, + {"key", "boolean_key" }, + {"type", "custom" }, + {"value", true} + }, + new Dictionary + { + {"entity_id", "808797686" }, + {"key", "double_key" }, + {"type", "custom" }, + {"value", 3.14} + }, + new Dictionary + { + {"entity_id", ControlAttributes.BOT_FILTERING_ATTRIBUTE}, + {"key", ControlAttributes.BOT_FILTERING_ATTRIBUTE}, + {"type", "custom" }, + {"value", true } + } + } + }, + { "visitor_id", TestUserId } + } + } + }, + {"project_id", "7720880029" }, + {"account_id", "1592310167" }, + {"enrich_decisions", true} , + {"client_name", "csharp-sdk" }, + {"client_version", Optimizely.SDK_VERSION }, + {"revision", "15" }, + {"anonymize_ip", false} + }; + + var expectedLogEvent = new LogEvent("https://logx.optimizely.com/v1/events", + payloadParams, + "POST", + new Dictionary + { + { "Content-Type", "application/json" } + }); + + var userAttributes = new UserAttributes + { + { "device_type", "iPhone" }, + { "boolean_key", true }, + { "double_key", 3.14 }, + { "", "Android" }, + { "null", null }, + { "objects", new object() }, + { "arrays", new string[] { "a", "b", "c" } }, + { "negative_infinity", double.NegativeInfinity }, + { "positive_infinity", double.PositiveInfinity }, + { "nan", double.NaN }, + { "invalid_num_value", Math.Pow(2, 53) + 2 }, + }; + Variation variation = null; + + var impressionEvent = UserEventFactory.CreateImpressionEvent(Config, null, variation, TestUserId, userAttributes, "test_feature", "rollout"); + var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger); + TestData.ChangeGUIDAndTimeStamp(expectedLogEvent.Params, impressionEvent.Timestamp, Guid.Parse(impressionEvent.UUID)); Assert.IsTrue(TestData.CompareObjects(expectedLogEvent, logEvent)); @@ -1333,7 +1521,14 @@ public void TestCreateImpressionEventWithBucketingIDAttribute() { {"campaign_id", "7719770039" }, {"experiment_id", "7716830082" }, - {"variation_id", "7722370027" } + {"variation_id", "7722370027" }, + { "metadata", new Dictionary { + { "rule_type", "experiment" }, + { "rule_key", "test_experiment" }, + { "flag_key", "test_experiment" }, + { "variation_key", "control" } + } + } } } }, @@ -1403,7 +1598,7 @@ public void TestCreateImpressionEventWithBucketingIDAttribute() { "company", "Optimizely" }, {ControlAttributes.BUCKETING_ID_ATTRIBUTE, "variation" } }; - var impressionEvent = UserEventFactory.CreateImpressionEvent(Config, Config.GetExperimentFromKey("test_experiment"), "7722370027", TestUserId, userAttributes); + var impressionEvent = UserEventFactory.CreateImpressionEvent(Config, Config.GetExperimentFromKey("test_experiment"), "7722370027", TestUserId, userAttributes, "test_experiment", "experiment"); var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger); TestData.ChangeGUIDAndTimeStamp(expectedLogEvent.Params, impressionEvent.Timestamp, Guid.Parse(impressionEvent.UUID)); @@ -1433,7 +1628,14 @@ public void TestCreateImpressionEventWhenBotFilteringIsProvidedInDatafile() { {"campaign_id", "7719770039" }, {"experiment_id", "7716830082" }, - {"variation_id", "7722370027" } + {"variation_id", "7722370027" }, + { "metadata", new Dictionary { + { "rule_type", "experiment" }, + { "rule_key", "test_experiment" }, + { "flag_key", "test_experiment" }, + { "variation_key", "control" } + } + } } } }, @@ -1499,9 +1701,9 @@ public void TestCreateImpressionEventWhenBotFilteringIsProvidedInDatafile() botFilteringEnabledConfig.BotFiltering = true; var experiment = botFilteringEnabledConfig.GetExperimentFromKey("test_experiment"); - var impressionEvent = UserEventFactory.CreateImpressionEvent(botFilteringEnabledConfig, experiment, "7722370027", TestUserId, userAttributes); + var impressionEvent = UserEventFactory.CreateImpressionEvent(botFilteringEnabledConfig, experiment, "7722370027", TestUserId, userAttributes, "test_experiment", "experiment"); var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger); - + TestData.ChangeGUIDAndTimeStamp(expectedLogEvent.Params, impressionEvent.Timestamp, Guid.Parse(impressionEvent.UUID)); Assert.IsTrue(TestData.CompareObjects(expectedLogEvent, logEvent)); @@ -1529,7 +1731,14 @@ public void TestCreateImpressionEventWhenBotFilteringIsNotProvidedInDatafile() { {"campaign_id", "7719770039" }, {"experiment_id", "7716830082" }, - {"variation_id", "7722370027" } + {"variation_id", "7722370027" }, + { "metadata", new Dictionary { + { "rule_type", "experiment" }, + { "rule_key", "test_experiment" }, + { "flag_key", "test_experiment" }, + { "variation_key", "control" } + } + } } } }, @@ -1588,9 +1797,9 @@ public void TestCreateImpressionEventWhenBotFilteringIsNotProvidedInDatafile() botFilteringDisabledConfig.BotFiltering = null; var experiment = botFilteringDisabledConfig.GetExperimentFromKey("test_experiment"); - var impressionEvent = UserEventFactory.CreateImpressionEvent(botFilteringDisabledConfig, experiment, "7722370027", TestUserId, userAttributes); + var impressionEvent = UserEventFactory.CreateImpressionEvent(botFilteringDisabledConfig, experiment, "7722370027", TestUserId, userAttributes, "test_experiment", "experiment"); var logEvent = EventFactory.CreateLogEvent(impressionEvent, Logger); - + TestData.ChangeGUIDAndTimeStamp(expectedLogEvent.Params, impressionEvent.Timestamp, Guid.Parse(impressionEvent.UUID)); Assert.IsTrue(TestData.CompareObjects(expectedLogEvent, logEvent)); @@ -1680,7 +1889,7 @@ public void TestCreateConversionEventWhenBotFilteringIsProvidedInDatafile() var conversionEvent = UserEventFactory.CreateConversionEvent(botFilteringEnabledConfig, "purchase", TestUserId, userAttributes, null); var logEvent = EventFactory.CreateLogEvent(conversionEvent, Logger); - + TestData.ChangeGUIDAndTimeStamp(expectedEvent.Params, conversionEvent.Timestamp, Guid.Parse(conversionEvent.UUID)); Assert.IsTrue(TestData.CompareObjects(expectedEvent, logEvent)); @@ -1762,7 +1971,7 @@ public void TestCreateConversionEventWhenBotFilteringIsNotProvidedInDatafile() botFilteringDisabledConfig.BotFiltering = null; var conversionEvent = UserEventFactory.CreateConversionEvent(botFilteringDisabledConfig, "purchase", TestUserId, userAttributes, null); - var logEvent = EventFactory.CreateLogEvent(conversionEvent, Logger); + var logEvent = EventFactory.CreateLogEvent(conversionEvent, Logger); TestData.ChangeGUIDAndTimeStamp(expectedEvent.Params, conversionEvent.Timestamp, Guid.Parse(conversionEvent.UUID)); @@ -1786,7 +1995,7 @@ public void TestCreateConversionEventWhenEventUsedInMultipleExp() "111130", new Variation{Id="111131", Key="variation"} } }; - + var payloadParams = new Dictionary { {"client_version", Optimizely.SDK_VERSION}, @@ -1987,7 +2196,7 @@ public void TestCreateConversionEventRemovesInvalidAttributesFromPayload() {"7716830082", new Variation{Id="7722370027", Key="control"} } }; var conversionEvent = UserEventFactory.CreateConversionEvent(Config, "purchase", TestUserId, userAttributes, null); - var logEvent = EventFactory.CreateLogEvent(conversionEvent, Logger); + var logEvent = EventFactory.CreateLogEvent(conversionEvent, Logger); TestData.ChangeGUIDAndTimeStamp(expectedEvent.Params, conversionEvent.Timestamp, Guid.Parse(conversionEvent.UUID)); Assert.IsTrue(TestData.CompareObjects(expectedEvent, logEvent)); diff --git a/OptimizelySDK.Tests/EventTests/UserEventFactoryTest.cs b/OptimizelySDK.Tests/EventTests/UserEventFactoryTest.cs index 47300ca7..80acd1a6 100644 --- a/OptimizelySDK.Tests/EventTests/UserEventFactoryTest.cs +++ b/OptimizelySDK.Tests/EventTests/UserEventFactoryTest.cs @@ -1,4 +1,21 @@ -using Moq; +/** + * + * Copyright 2019-2020, 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. + */ + +using Moq; using NUnit.Framework; using OptimizelySDK.Config; using OptimizelySDK.Entity; @@ -32,7 +49,7 @@ public void ImpressionEventTest() var variation = Config.GetVariationFromId(experiment.Key, "77210100090"); var userId = TestUserId; - var impressionEvent = UserEventFactory.CreateImpressionEvent(projectConfig, experiment, variation, userId, null); + var impressionEvent = UserEventFactory.CreateImpressionEvent(projectConfig, experiment, variation, userId, null, "test_experiment", "experiment"); Assert.AreEqual(Config.ProjectId, impressionEvent.Context.ProjectId); Assert.AreEqual(Config.Revision, impressionEvent.Context.Revision); @@ -58,7 +75,7 @@ public void ImpressionEventTestWithAttributes() { "company", "Optimizely" } }; - var impressionEvent = UserEventFactory.CreateImpressionEvent(projectConfig, experiment, variation, userId, userAttributes); + var impressionEvent = UserEventFactory.CreateImpressionEvent(projectConfig, experiment, variation, userId, userAttributes, "test_experiment", "experiment"); Assert.AreEqual(Config.ProjectId, impressionEvent.Context.ProjectId); Assert.AreEqual(Config.Revision, impressionEvent.Context.Revision); diff --git a/OptimizelySDK.Tests/OptimizelyTest.cs b/OptimizelySDK.Tests/OptimizelyTest.cs index 26cb54e9..4b40e0de 100644 --- a/OptimizelySDK.Tests/OptimizelyTest.cs +++ b/OptimizelySDK.Tests/OptimizelyTest.cs @@ -395,7 +395,7 @@ public void TestActivateNoAudienceNoAttributes() LoggerMock.Verify(l => l.Log(LogLevel.INFO, "User [user_1] is in experiment [group_experiment_1] of group [7722400015]."), Times.Once); LoggerMock.Verify(l => l.Log(LogLevel.DEBUG, "Assigned bucket [9525] to user [user_1] with bucketing ID [user_1]."), Times.Once); LoggerMock.Verify(l => l.Log(LogLevel.INFO, "User [user_1] is in variation [group_exp_1_var_2] of experiment [group_experiment_1]."), Times.Once); - LoggerMock.Verify(l => l.Log(LogLevel.INFO, "Activating user user_1 in experiment group_experiment_1."), Times.Once); + LoggerMock.Verify(l => l.Log(LogLevel.INFO, "Activating user user_1 in experiment group_experiment_1."), Times.Once); Assert.IsTrue(TestData.CompareObjects(GroupVariation, variation)); } @@ -430,7 +430,7 @@ public void TestActivateWithAttributes() LoggerMock.Verify(l => l.Log(LogLevel.DEBUG, "Assigned bucket [3037] to user [test_user] with bucketing ID [test_user]."), Times.Once); LoggerMock.Verify(l => l.Log(LogLevel.INFO, "User [test_user] is in variation [control] of experiment [test_experiment]."), Times.Once); LoggerMock.Verify(l => l.Log(LogLevel.INFO, "This decision will not be saved since the UserProfileService is null.")); - LoggerMock.Verify(l => l.Log(LogLevel.INFO, "Activating user test_user in experiment test_experiment."), Times.Once); + LoggerMock.Verify(l => l.Log(LogLevel.INFO, "Activating user test_user in experiment test_experiment."), Times.Once); Assert.IsTrue(TestData.CompareObjects(VariationWithKeyControl, variation)); } @@ -490,7 +490,7 @@ public void TestActivateWithTypedAttributes() LoggerMock.Verify(l => l.Log(LogLevel.DEBUG, "Assigned bucket [3037] to user [test_user] with bucketing ID [test_user]."), Times.Once); LoggerMock.Verify(l => l.Log(LogLevel.INFO, "User [test_user] is in variation [control] of experiment [test_experiment]."), Times.Once); - LoggerMock.Verify(l => l.Log(LogLevel.INFO, "Activating user test_user in experiment test_experiment."), Times.Once); + LoggerMock.Verify(l => l.Log(LogLevel.INFO, "Activating user test_user in experiment test_experiment."), Times.Once); Assert.IsTrue(TestData.CompareObjects(VariationWithKeyControl, variation)); } @@ -683,8 +683,8 @@ public void TestInvalidDispatchImpressionEvent() LoggerMock.Verify(l => l.Log(LogLevel.DEBUG, "Assigned bucket [3037] to user [test_user] with bucketing ID [test_user]."), Times.Once); LoggerMock.Verify(l => l.Log(LogLevel.INFO, "User [test_user] is in variation [control] of experiment [test_experiment]."), Times.Once); - LoggerMock.Verify(l => l.Log(LogLevel.INFO, "Activating user test_user in experiment test_experiment."), Times.Once); + LoggerMock.Verify(l => l.Log(LogLevel.INFO, "Activating user test_user in experiment test_experiment."), Times.Once); // Need to see how error handler can be verified. LoggerMock.Verify(l => l.Log(LogLevel.ERROR, It.IsAny()), Times.Once); @@ -1079,7 +1079,7 @@ public void TestActivateNoAudienceNoAttributesAfterSetForcedVariation() LoggerMock.Verify(l => l.Log(LogLevel.DEBUG, "Assigned bucket [9525] to user [user_1] with bucketing ID [user_1]."), Times.Once); LoggerMock.Verify(l => l.Log(LogLevel.INFO, "User [user_1] is in variation [group_exp_1_var_2] of experiment [group_experiment_1]."), Times.Once); LoggerMock.Verify(l => l.Log(LogLevel.INFO, "This decision will not be saved since the UserProfileService is null."), Times.Once); - LoggerMock.Verify(l => l.Log(LogLevel.INFO, "Activating user user_1 in experiment group_experiment_1."), Times.Once); + LoggerMock.Verify(l => l.Log(LogLevel.INFO, "Activating user user_1 in experiment group_experiment_1."), Times.Once); Assert.IsTrue(TestData.CompareObjects(GroupVariation, variation)); } diff --git a/OptimizelySDK.Tests/ProjectConfigTest.cs b/OptimizelySDK.Tests/ProjectConfigTest.cs index 63121246..98d38dec 100644 --- a/OptimizelySDK.Tests/ProjectConfigTest.cs +++ b/OptimizelySDK.Tests/ProjectConfigTest.cs @@ -63,6 +63,8 @@ public void TestInit() Assert.AreEqual("7720880029", Config.ProjectId); // Check Revision Assert.AreEqual("15", Config.Revision); + // Check SendFlagDecision + Assert.IsTrue(Config.SendFlagDecisions); // Check Group ID Map var expectedGroupId = CreateDictionary("7722400015", Config.GetGroup("7722400015")); @@ -415,6 +417,14 @@ public void TestInit() Assert.IsTrue(TestData.CompareObjects(expectedRolloutIdMap, Config.RolloutIdMap)); } + + [Test] + public void TestIfSendFlagDecisionKeyIsMissingItShouldReturnFalse() + { + var tempConfig = DatafileProjectConfig.Create(TestData.SimpleABExperimentsDatafile, LoggerMock.Object, ErrorHandlerMock.Object); + Assert.IsFalse(tempConfig.SendFlagDecisions); + } + [Test] public void TestGetAccountId() { diff --git a/OptimizelySDK.Tests/TestData.json b/OptimizelySDK.Tests/TestData.json index 11f3decb..9724b0b4 100644 --- a/OptimizelySDK.Tests/TestData.json +++ b/OptimizelySDK.Tests/TestData.json @@ -565,7 +565,7 @@ "key": "integer_variable", "type": "integer", "defaultValue": "7" - } + } ] }, { @@ -940,5 +940,6 @@ ], "revision": "15", "anonymizeIP": false, + "sendFlagDecisions": true, "botFiltering": true } diff --git a/OptimizelySDK/Config/DatafileProjectConfig.cs b/OptimizelySDK/Config/DatafileProjectConfig.cs index a90df7fa..fc479b0c 100644 --- a/OptimizelySDK/Config/DatafileProjectConfig.cs +++ b/OptimizelySDK/Config/DatafileProjectConfig.cs @@ -69,6 +69,11 @@ public enum OPTLYSDKVersion /// public string Revision { get; set; } + /// + /// SendFlagDecisions determines whether impressions events are sent for ALL decision types. + /// + public bool SendFlagDecisions { get; set; } + /// /// Allow Anonymize IP by truncating the last block of visitors' IP address. /// diff --git a/OptimizelySDK/Event/Builder/Params.cs b/OptimizelySDK/Event/Builder/Params.cs index 8b12f96a..f3b47688 100644 --- a/OptimizelySDK/Event/Builder/Params.cs +++ b/OptimizelySDK/Event/Builder/Params.cs @@ -1,5 +1,5 @@ /* - * Copyright 2017, 2019, Optimizely + * Copyright 2017, 2019-2020, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +using OptimizelySDK.Event.Entity; using System; namespace OptimizelySDK.Event.Builder @@ -30,6 +31,7 @@ public static class Params public const string ENTITY_ID = "entity_id"; public const string EVENTS = "events"; public const string EXPERIMENT_ID = "experiment_id"; + public const string METADATA = "metadata"; public const string PROJECT_ID = "project_id"; public const string REVISION = "revision"; public const string TIME = "timestamp"; diff --git a/OptimizelySDK/Event/Entity/Decision.cs b/OptimizelySDK/Event/Entity/Decision.cs index c25f280f..08657a33 100644 --- a/OptimizelySDK/Event/Entity/Decision.cs +++ b/OptimizelySDK/Event/Entity/Decision.cs @@ -1,5 +1,5 @@ /* - * Copyright 2019, Optimizely + * Copyright 2019-2020, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,15 +23,17 @@ public class Decision public string CampaignId { get; private set; } [JsonProperty("experiment_id")] public string ExperimentId { get; private set; } + [JsonProperty("metadata")] + public DecisionMetadata Metadata { get; private set; } [JsonProperty("variation_id")] public string VariationId { get; private set; } - public Decision() {} - public Decision(string campaignId, string experimentId, string variationId) + public Decision(string campaignId, string experimentId, string variationId, DecisionMetadata metadata = null) { CampaignId = campaignId; ExperimentId = experimentId; + Metadata = metadata; VariationId = variationId; } } diff --git a/OptimizelySDK/Event/Entity/DecisionMetadata.cs b/OptimizelySDK/Event/Entity/DecisionMetadata.cs new file mode 100644 index 00000000..551eb5a0 --- /dev/null +++ b/OptimizelySDK/Event/Entity/DecisionMetadata.cs @@ -0,0 +1,44 @@ +/** + * + * Copyright 2020, 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. + */ + +using Newtonsoft.Json; + +namespace OptimizelySDK.Event.Entity +{ + /// + /// DecisionMetadata captures additional information regarding the decision + /// + public class DecisionMetadata + { + [JsonProperty("flag_key")] + public string FlagKey { get; private set; } + [JsonProperty("rule_key")] + public string RuleKey { get; private set; } + [JsonProperty("rule_type")] + public string RuleType { get; private set; } + [JsonProperty("variation_key")] + public string VariationKey { get; private set; } + + public DecisionMetadata(string flagKey, string ruleKey, string ruleType, string variationKey = "") + { + FlagKey = flagKey; + RuleKey = ruleKey; + RuleType = ruleType; + VariationKey = variationKey; + } + } +} diff --git a/OptimizelySDK/Event/Entity/ImpressionEvent.cs b/OptimizelySDK/Event/Entity/ImpressionEvent.cs index 7b3777a1..5d525b2d 100644 --- a/OptimizelySDK/Event/Entity/ImpressionEvent.cs +++ b/OptimizelySDK/Event/Entity/ImpressionEvent.cs @@ -1,5 +1,5 @@ /* - * Copyright 2019, Optimizely + * Copyright 2019-2020, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,7 @@ public class ImpressionEvent : UserEvent public VisitorAttribute[] VisitorAttributes { get; private set; } public Experiment Experiment { get; set; } + public DecisionMetadata Metadata { get; set; } public Variation Variation { get; set; } public bool? BotFiltering { get; set; } @@ -42,6 +43,7 @@ public class Builder public VisitorAttribute[] VisitorAttributes; private Experiment Experiment; private Variation Variation; + private DecisionMetadata Metadata; private bool? BotFiltering; public Builder WithUserId(string userId) @@ -65,6 +67,13 @@ public Builder WithExperiment(Experiment experiment) return this; } + public Builder WithMetadata(DecisionMetadata metadata) + { + Metadata = metadata; + + return this; + } + public Builder WithVisitorAttributes(VisitorAttribute[] visitorAttributes) { VisitorAttributes = visitorAttributes; @@ -102,6 +111,7 @@ public ImpressionEvent Build() impressionEvent.VisitorAttributes = VisitorAttributes; impressionEvent.UserId = UserId; impressionEvent.Variation = Variation; + impressionEvent.Metadata = Metadata; impressionEvent.BotFiltering = BotFiltering; return impressionEvent; diff --git a/OptimizelySDK/Event/EventFactory.cs b/OptimizelySDK/Event/EventFactory.cs index 7097ea03..63482a8b 100644 --- a/OptimizelySDK/Event/EventFactory.cs +++ b/OptimizelySDK/Event/EventFactory.cs @@ -1,5 +1,5 @@ /* - * Copyright 2019, Optimizely + * Copyright 2019-2020, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -115,11 +115,12 @@ private static Visitor CreateVisitor(ImpressionEvent impressionEvent) { Decision decision = new Decision(impressionEvent.Experiment?.LayerId, impressionEvent.Experiment?.Id, - impressionEvent.Variation?.Id); + impressionEvent.Variation?.Id, + impressionEvent.Metadata); SnapshotEvent snapshotEvent = new SnapshotEvent.Builder() .WithUUID(impressionEvent.UUID) - .WithEntityId(impressionEvent.Experiment.LayerId) + .WithEntityId(impressionEvent.Experiment?.LayerId) .WithKey(ACTIVATE_EVENT_KEY) .WithTimeStamp(impressionEvent.Timestamp) .Build(); diff --git a/OptimizelySDK/Event/UserEventFactory.cs b/OptimizelySDK/Event/UserEventFactory.cs index 2733c673..ff78d495 100644 --- a/OptimizelySDK/Event/UserEventFactory.cs +++ b/OptimizelySDK/Event/UserEventFactory.cs @@ -1,4 +1,21 @@ -using OptimizelySDK.Entity; +/** + * + * Copyright 2019-2020, 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. + */ + +using OptimizelySDK.Entity; using OptimizelySDK.Event.Entity; namespace OptimizelySDK.Event @@ -21,10 +38,12 @@ public static ImpressionEvent CreateImpressionEvent(ProjectConfig projectConfig, Experiment activatedExperiment, string variationId, string userId, - UserAttributes userAttributes) + UserAttributes userAttributes, + string flagKey, + string ruleType) { Variation variation = projectConfig.GetVariationFromId(activatedExperiment?.Key, variationId); - return CreateImpressionEvent(projectConfig, activatedExperiment, variation, userId, userAttributes); + return CreateImpressionEvent(projectConfig, activatedExperiment, variation, userId, userAttributes, flagKey, ruleType); } /// @@ -35,25 +54,43 @@ public static ImpressionEvent CreateImpressionEvent(ProjectConfig projectConfig, /// The variation entity /// The user Id /// The user's attributes + /// experiment key or feature key + /// experiment or featureDecision source /// ImpressionEvent instance public static ImpressionEvent CreateImpressionEvent(ProjectConfig projectConfig, Experiment activatedExperiment, Variation variation, string userId, - UserAttributes userAttributes) + UserAttributes userAttributes, + string flagKey, + string ruleType) { + if ((ruleType == FeatureDecision.DECISION_SOURCE_ROLLOUT || variation == null) && !projectConfig.SendFlagDecisions) + { + return null; + } var eventContext = new EventContext.Builder() - .WithProjectId(projectConfig.ProjectId) - .WithAccountId(projectConfig.AccountId) - .WithAnonymizeIP(projectConfig.AnonymizeIP) - .WithRevision(projectConfig.Revision) - .Build(); + .WithProjectId(projectConfig.ProjectId) + .WithAccountId(projectConfig.AccountId) + .WithAnonymizeIP(projectConfig.AnonymizeIP) + .WithRevision(projectConfig.Revision) + .Build(); + + var variationKey = ""; + var ruleKey = ""; + if (variation != null) + { + variationKey = variation.Key; + ruleKey = activatedExperiment.Key; + } + var metadata = new DecisionMetadata(flagKey, ruleKey, ruleType, variationKey); return new ImpressionEvent.Builder() .WithEventContext(eventContext) .WithBotFilteringEnabled(projectConfig.BotFiltering) .WithExperiment(activatedExperiment) + .WithMetadata(metadata) .WithUserId(userId) .WithVariation(variation) .WithVisitorAttributes(EventFactory.BuildAttributeList(userAttributes, projectConfig)) diff --git a/OptimizelySDK/Optimizely.cs b/OptimizelySDK/Optimizely.cs index e9353384..cfc1a186 100644 --- a/OptimizelySDK/Optimizely.cs +++ b/OptimizelySDK/Optimizely.cs @@ -100,6 +100,8 @@ public static String SDK_TYPE public const string EVENT_KEY = "Event Key"; public const string FEATURE_KEY = "Feature Key"; public const string VARIABLE_KEY = "Variable Key"; + private const string SOURCE_TYPE_EXPERIMENT = "experiment"; + public bool Disposed { get; private set; } /// @@ -252,7 +254,7 @@ public Variation Activate(string experimentKey, string userId, UserAttributes us return null; } - SendImpressionEvent(experiment, variation, userId, userAttributes, config); + SendImpressionEvent(experiment, variation, userId, userAttributes, config, SOURCE_TYPE_EXPERIMENT); return variation; } @@ -470,17 +472,21 @@ public virtual bool IsFeatureEnabled(string featureKey, string userId, UserAttri bool featureEnabled = false; var sourceInfo = new Dictionary(); var decision = DecisionService.GetVariationForFeature(featureFlag, userId, config, userAttributes); + var variation = decision.Variation; + var decisionSource = decision?.Source ?? FeatureDecision.DECISION_SOURCE_ROLLOUT; - if (decision.Variation != null) + SendImpressionEvent(decision.Experiment, variation, userId, userAttributes, config, featureKey, decisionSource); + + if (variation != null) { - var variation = decision.Variation; featureEnabled = variation.FeatureEnabled.GetValueOrDefault(); + // This information is only necessary for feature tests. + // For rollouts experiments and variations are an implementation detail only. if (decision.Source == FeatureDecision.DECISION_SOURCE_FEATURE_TEST) { sourceInfo["experimentKey"] = decision.Experiment.Key; sourceInfo["variationKey"] = variation.Key; - SendImpressionEvent(decision.Experiment, variation, userId, userAttributes, config); } else { @@ -683,28 +689,51 @@ public OptimizelyJSON GetFeatureVariableJSON(string featureKey, string variableK /// The variation entity /// The user ID /// The user's attributes + /// It can either be experiment in case impression event is sent from activate or it's feature-test or rollout private void SendImpressionEvent(Experiment experiment, Variation variation, string userId, - UserAttributes userAttributes, ProjectConfig config) + UserAttributes userAttributes, ProjectConfig config, + string ruleType) { - if (experiment.IsExperimentRunning) + SendImpressionEvent(experiment, variation, userId, userAttributes, config, "", ruleType); + } + + /// + /// Sends impression event. + /// + /// The experiment + /// The variation entity + /// The user ID + /// The user's attributes + /// It can either be experiment key in case if ruleType is experiment or it's feature key in case ruleType is feature-test or rollout + /// It can either be experiment in case impression event is sent from activate or it's feature-test or rollout + private void SendImpressionEvent(Experiment experiment, Variation variation, string userId, + UserAttributes userAttributes, ProjectConfig config, + string flagKey, string ruleType) + { + if (experiment != null && !experiment.IsExperimentRunning) { - var userEvent = UserEventFactory.CreateImpressionEvent(config, experiment, variation.Id, userId, userAttributes); - EventProcessor.Process(userEvent); - Logger.Log(LogLevel.INFO, $"Activating user {userId} in experiment {experiment.Key}."); + Logger.Log(LogLevel.ERROR, @"Experiment has ""Launched"" status so not dispatching event during activation."); + } + + var userEvent = UserEventFactory.CreateImpressionEvent(config, experiment, variation, userId, userAttributes, flagKey, ruleType); + if (userEvent == null) + { + return; + } + EventProcessor.Process(userEvent); - // Kept For backwards compatibility. - // This notification is deprecated and the new DecisionNotifications - // are sent via their respective method calls. - if (NotificationCenter.GetNotificationCount(NotificationCenter.NotificationType.Activate) > 0) - { - var impressionEvent = EventFactory.CreateLogEvent(userEvent, Logger); - NotificationCenter.SendNotifications(NotificationCenter.NotificationType.Activate, experiment, userId, - userAttributes, variation, impressionEvent); - } + if (experiment != null) + { + Logger.Log(LogLevel.INFO, $"Activating user {userId} in experiment {experiment.Key}."); } - else + // Kept For backwards compatibility. + // This notification is deprecated and the new DecisionNotifications + // are sent via their respective method calls. + if (NotificationCenter.GetNotificationCount(NotificationCenter.NotificationType.Activate) > 0) { - Logger.Log(LogLevel.ERROR, @"Experiment has ""Launched"" status so not dispatching event during activation."); + var impressionEvent = EventFactory.CreateLogEvent(userEvent, Logger); + NotificationCenter.SendNotifications(NotificationCenter.NotificationType.Activate, experiment, userId, + userAttributes, variation, impressionEvent); } } diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj index 6b25e27e..9964d942 100644 --- a/OptimizelySDK/OptimizelySDK.csproj +++ b/OptimizelySDK/OptimizelySDK.csproj @@ -86,6 +86,7 @@ + diff --git a/OptimizelySDK/ProjectConfig.cs b/OptimizelySDK/ProjectConfig.cs index f51edb66..0b56c2e2 100644 --- a/OptimizelySDK/ProjectConfig.cs +++ b/OptimizelySDK/ProjectConfig.cs @@ -44,6 +44,12 @@ public interface ProjectConfig /// string Revision { get; set; } + + /// + /// SendFlagDecisions determines whether impressions events are sent for ALL decision types. + /// + bool SendFlagDecisions { get; set; } + /// /// Allow Anonymize IP by truncating the last block of visitors' IP address. ///