From 4fc4c15bcc65d3017618a01d87e6edd116347c37 Mon Sep 17 00:00:00 2001 From: Vignesh Raja Date: Mon, 30 Jan 2017 11:12:43 -0800 Subject: [PATCH 1/4] Add support for "Launched" experiment status (#66) --- .../java/com/optimizely/ab/Optimizely.java | 27 ++++++---- .../com/optimizely/ab/bucketing/Bucketer.java | 2 +- .../com/optimizely/ab/config/Experiment.java | 31 +++++++++-- .../ab/event/internal/EventBuilderV2.java | 14 +++-- .../ab/internal/ProjectValidationUtils.java | 2 +- .../com/optimizely/ab/OptimizelyTestV2.java | 51 +++++++++++++++++++ .../ab/config/ProjectConfigTestUtils.java | 11 +++- .../ab/event/internal/EventBuilderV2Test.java | 34 +++++++++++++ .../config/no-audience-project-config-v2.json | 29 +++++++++++ 9 files changed, 179 insertions(+), 22 deletions(-) diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 3f85edbff..665fad3ce 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -172,18 +172,23 @@ private Optimizely(@Nonnull ProjectConfig projectConfig, return null; } - LogEvent impressionEvent = - eventBuilder.createImpressionEvent(projectConfig, experiment, variation, userId, attributes); - logger.info("Activating user \"{}\" in experiment \"{}\".", userId, experiment.getKey()); - logger.debug("Dispatching impression event to URL {} with params {} and payload \"{}\".", - impressionEvent.getEndpointUrl(), impressionEvent.getRequestParams(), impressionEvent.getBody()); - try { - eventHandler.dispatchEvent(impressionEvent); - } catch (Exception e) { - logger.error("Unexpected exception in event dispatcher", e); - } + if (experiment.isRunning()) { + LogEvent impressionEvent = + eventBuilder.createImpressionEvent(projectConfig, experiment, variation, userId, attributes); + logger.info("Activating user \"{}\" in experiment \"{}\".", userId, experiment.getKey()); + logger.debug( + "Dispatching impression event to URL {} with params {} and payload \"{}\".", + impressionEvent.getEndpointUrl(), impressionEvent.getRequestParams(), impressionEvent.getBody()); + try { + eventHandler.dispatchEvent(impressionEvent); + } catch (Exception e) { + logger.error("Unexpected exception in event dispatcher", e); + } - notificationBroadcaster.broadcastExperimentActivated(experiment, userId, attributes, variation); + notificationBroadcaster.broadcastExperimentActivated(experiment, userId, attributes, variation); + } else { + logger.info("Experiment has \"Launched\" status so not dispatching event during activation."); + } return variation; } diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java b/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java index 71f6eab84..b7199a113 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java @@ -240,7 +240,7 @@ public void cleanUserProfiles() { for (Map.Entry> record : records.entrySet()) { for (String experimentId : record.getValue().keySet()) { Experiment experiment = projectConfig.getExperimentIdMapping().get(experimentId); - if (experiment == null || !experiment.isRunning()) { + if (experiment == null || !experiment.isActive()) { userProfile.remove(record.getKey(), experimentId); } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/Experiment.java b/core-api/src/main/java/com/optimizely/ab/config/Experiment.java index f7bb62521..6ac00d1e2 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/Experiment.java +++ b/core-api/src/main/java/com/optimizely/ab/config/Experiment.java @@ -51,9 +51,23 @@ public class Experiment implements IdKeyMapped { private final Map variationIdToVariationMap; private final Map userIdToVariationKeyMap; - // constant storing the status of a running experiment. Other possible statuses for an experiment - // include 'Not started', 'Paused', and 'Archived' - private static final String STATUS_RUNNING = "Running"; + public enum ExperimentStatus { + RUNNING ("Running"), + LAUNCHED ("Launched"), + PAUSED ("Paused"), + NOT_STARTED ("Not started"), + ARCHIVED ("Archived"); + + private final String experimentStatus; + + ExperimentStatus(String experimentStatus) { + this.experimentStatus = experimentStatus; + } + + public String toString() { + return experimentStatus; + } + } @JsonCreator public Experiment(@JsonProperty("id") String id, @@ -133,8 +147,17 @@ public String getGroupId() { return groupId; } + public boolean isActive() { + return status.equals(ExperimentStatus.RUNNING.toString()) || + status.equals(ExperimentStatus.LAUNCHED.toString()); + } + public boolean isRunning() { - return status.equals(STATUS_RUNNING); + return status.equals(ExperimentStatus.RUNNING.toString()); + } + + public boolean isLaunched() { + return status.equals(ExperimentStatus.LAUNCHED.toString()); } @Override diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/EventBuilderV2.java b/core-api/src/main/java/com/optimizely/ab/event/internal/EventBuilderV2.java index 72d46425f..d3329316b 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/EventBuilderV2.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/EventBuilderV2.java @@ -199,10 +199,16 @@ private List createLayerStates(ProjectConfig projectConfig, Bucketer for (Experiment experiment : allExperiments) { if (experimentIds.contains(experiment.getId()) && ProjectValidationUtils.validatePreconditions(projectConfig, experiment, userId, attributes)) { - Variation bucketedVariation = bucketer.bucket(experiment, userId); - if (bucketedVariation != null) { - Decision decision = new Decision(bucketedVariation.getId(), false, experiment.getId()); - layerStates.add(new LayerState(experiment.getLayerId(), decision, true)); + if (experiment.isRunning()) { + Variation bucketedVariation = bucketer.bucket(experiment, userId); + if (bucketedVariation != null) { + Decision decision = new Decision(bucketedVariation.getId(), false, experiment.getId()); + layerStates.add(new LayerState(experiment.getLayerId(), decision, true)); + } + } else { + logger.info( + "Not tracking event \"{}\" for experiment \"{}\" because experiment has status \"Launched\".", + eventKey, experiment.getKey()); } } } diff --git a/core-api/src/main/java/com/optimizely/ab/internal/ProjectValidationUtils.java b/core-api/src/main/java/com/optimizely/ab/internal/ProjectValidationUtils.java index 1def80568..2ea00ea79 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/ProjectValidationUtils.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/ProjectValidationUtils.java @@ -42,7 +42,7 @@ private ProjectValidationUtils() {} */ public static boolean validatePreconditions(ProjectConfig projectConfig, Experiment experiment, String userId, Map attributes) { - if (!experiment.isRunning()) { + if (!experiment.isActive()) { logger.info("Experiment \"{}\" is not running.", experiment.getKey(), userId); return false; } diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTestV2.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTestV2.java index c7f73eeef..87e6f4f6f 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTestV2.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTestV2.java @@ -669,6 +669,37 @@ public void activateDispatchEventThrowsException() throws Exception { optimizely.activate(experiment.getKey(), "userId"); } + /** + * Verify that {@link Optimizely#activate(String, String)} doesn't dispatch an event for an experiment with a + * "Launched" status. + */ + @Test + public void activateLaunchedExperimentDoesNotDispatchEvent() throws Exception { + String datafile = noAudienceProjectConfigJsonV2(); + ProjectConfig projectConfig = noAudienceProjectConfigV2(); + Experiment launchedExperiment = projectConfig.getExperiments().get(2); + + Optimizely optimizely = Optimizely.builder(datafile, mockEventHandler) + .withBucketing(mockBucketer) + .withConfig(projectConfig) + .build(); + + Variation expectedVariation = launchedExperiment.getVariations().get(0); + + when(mockBucketer.bucket(launchedExperiment, "userId")) + .thenReturn(launchedExperiment.getVariations().get(0)); + + logbackVerifier.expectMessage(Level.INFO, + "Experiment has \"Launched\" status so not dispatching event during activation."); + Variation variation = optimizely.activate(launchedExperiment.getKey(), "userId"); + + assertNotNull(variation); + assertThat(variation.getKey(), is(expectedVariation.getKey())); + + // verify that we did NOT dispatch an event + verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); + } + //======== track tests ========// /** @@ -953,6 +984,26 @@ public void trackDispatchEventThrowsException() throws Exception { optimizely.track(eventType.getKey(), "userId"); } + /** + * Verify that {@link Optimizely#track(String, String)} doesn't make a dispatch for an event being used by a + * single experiment with a "Launched" status. + */ + @Test + public void trackLaunchedExperimentDoesNotDispatchEvent() throws Exception { + String datafile = noAudienceProjectConfigJsonV2(); + ProjectConfig projectConfig = noAudienceProjectConfigV2(); + EventType eventType = projectConfig.getEventTypes().get(3); + + Optimizely optimizely = Optimizely.builder(datafile, mockEventHandler) + .withConfig(projectConfig) + .build(); + + optimizely.track(eventType.getKey(), "userId"); + + // verify that we did NOT dispatch an event + verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); + } + //======== getVariation tests ========// /** diff --git a/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTestUtils.java b/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTestUtils.java index e645e4971..15459344e 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTestUtils.java +++ b/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTestUtils.java @@ -295,6 +295,14 @@ private static ProjectConfig generateNoAudienceProjectConfigV2() { Collections.emptyMap(), asList(new TrafficAllocation("278", 4500), new TrafficAllocation("279", 9000)), + ""), + new Experiment("119", "etag3", "Launched", "3", + Collections.emptyList(), + asList(new Variation("280", "vtag5"), + new Variation("281", "vtag6")), + Collections.emptyMap(), + asList(new TrafficAllocation("280", 5000), + new TrafficAllocation("281", 10000)), "") ); @@ -304,7 +312,8 @@ private static ProjectConfig generateNoAudienceProjectConfigV2() { List multipleExperimentIds = asList("118", "223"); List events = asList(new EventType("971", "clicked_cart", singleExperimentId), new EventType("098", "Total Revenue", singleExperimentId), - new EventType("099", "clicked_purchase", multipleExperimentIds)); + new EventType("099", "clicked_purchase", multipleExperimentIds), + new EventType("100", "launched_exp_event", singletonList("119"))); return new ProjectConfig("789", "1234", "2", "42", Collections.emptyList(), experiments, attributes, events, Collections.emptyList()); diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/EventBuilderV2Test.java b/core-api/src/test/java/com/optimizely/ab/event/internal/EventBuilderV2Test.java index 43027ae70..baf299f59 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/EventBuilderV2Test.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/EventBuilderV2Test.java @@ -16,6 +16,7 @@ */ package com.optimizely.ab.event.internal; +import ch.qos.logback.classic.Level; import com.google.gson.Gson; import com.optimizely.ab.bucketing.Bucketer; @@ -33,8 +34,10 @@ import com.optimizely.ab.event.internal.payload.Feature; import com.optimizely.ab.event.internal.payload.Impression; import com.optimizely.ab.event.internal.payload.LayerState; +import com.optimizely.ab.internal.LogbackVerifier; import com.optimizely.ab.internal.ProjectValidationUtils; +import org.junit.Rule; import org.junit.Test; import java.util.ArrayList; @@ -58,6 +61,9 @@ */ public class EventBuilderV2Test { + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + private Gson gson = new Gson(); private EventBuilderV2 builder = new EventBuilderV2(); @@ -364,4 +370,32 @@ public void createConversionEventCustomClientEngineClientVersion() throws Except assertThat(conversion.getClientEngine(), is(ClientEngine.ANDROID_SDK.getClientEngineValue())); assertThat(conversion.getClientVersion(), is("0.0.0")); } + + /** + * Verify that {@link EventBuilderV2} doesn't add experiments with a "Launched" status to the bucket map + */ + @Test + public void createConversionEventForEventUsingLaunchedExperiment() throws Exception { + EventBuilderV2 builder = new EventBuilderV2(); + ProjectConfig projectConfig = ProjectConfigTestUtils.noAudienceProjectConfigV2(); + EventType eventType = projectConfig.getEventTypes().get(3); + String userId = "userId"; + + Bucketer mockBucketAlgorithm = mock(Bucketer.class); + for (Experiment experiment : projectConfig.getExperiments()) { + when(mockBucketAlgorithm.bucket(experiment, userId)) + .thenReturn(experiment.getVariations().get(0)); + } + + logbackVerifier.expectMessage(Level.INFO, + "Not tracking event \"launched_exp_event\" for experiment \"etag3\" because experiment has status " + + "\"Launched\"."); + LogEvent conversionEvent = builder.createConversionEvent(projectConfig, mockBucketAlgorithm, userId, + eventType.getId(), eventType.getKey(), + Collections.emptyMap()); + + // only 1 experiment uses the event and it has a "Launched" status so the bucket map is empty and the returned + // event will be null + assertNull(conversionEvent); + } } diff --git a/core-api/src/test/resources/config/no-audience-project-config-v2.json b/core-api/src/test/resources/config/no-audience-project-config-v2.json index b363a8c55..660b99f13 100644 --- a/core-api/src/test/resources/config/no-audience-project-config-v2.json +++ b/core-api/src/test/resources/config/no-audience-project-config-v2.json @@ -51,6 +51,28 @@ "entityId": "279", "endOfRange": 9000 }] + }, + { + "id": "119", + "key": "etag3", + "status": "Launched", + "layerId": "3", + "audienceIds": [], + "variations": [{ + "id": "280", + "key": "vtag5" + }, { + "id": "281", + "key": "vtag6" + }], + "forcedVariations": {}, + "trafficAllocation": [{ + "entityId": "280", + "endOfRange": 5000 + }, { + "entityId": "281", + "endOfRange": 10000 + }] } ], "groups": [], @@ -83,6 +105,13 @@ "118", "223" ] + }, + { + "id": "100", + "key": "launched_exp_event", + "experimentIds": [ + "119" + ] } ] } \ No newline at end of file From a0cd46d2ac94c20557eb62b1a023b75cf79721dc Mon Sep 17 00:00:00 2001 From: Vignesh Raja Date: Mon, 30 Jan 2017 15:03:58 -0800 Subject: [PATCH 2/4] Add sessionId and revision to event payload (#67) --- .../java/com/optimizely/ab/Optimizely.java | 90 +++++++++--- .../ab/event/internal/EventBuilder.java | 31 ++-- .../ab/event/internal/EventBuilderV1.java | 18 ++- .../ab/event/internal/EventBuilderV2.java | 14 +- .../ab/event/internal/payload/Conversion.java | 76 +++++++--- .../ab/event/internal/payload/Impression.java | 70 ++++++--- .../ab/event/internal/payload/LayerState.java | 33 +++-- .../serializer/JacksonSerializer.java | 2 + .../serializer/JsonSimpleSerializer.java | 11 ++ .../com/optimizely/ab/OptimizelyTestV1.java | 36 +++-- .../com/optimizely/ab/OptimizelyTestV2.java | 137 ++++++++++++++---- .../com/optimizely/ab/OptimizelyTestV3.java | 98 +++++++------ .../ab/event/internal/EventBuilderV2Test.java | 53 ++++++- .../serializer/GsonSerializerTest.java | 26 +++- .../serializer/JacksonSerializerTest.java | 26 +++- .../serializer/JsonSerializerTest.java | 26 +++- .../serializer/JsonSimpleSerializerTest.java | 26 +++- .../serializer/SerializerTestUtils.java | 32 +++- .../serializer/conversion-session-id.json | 42 ++++++ .../test/resources/serializer/conversion.json | 4 +- .../serializer/impression-session-id.json | 27 ++++ .../test/resources/serializer/impression.json | 3 +- 22 files changed, 697 insertions(+), 184 deletions(-) create mode 100644 core-api/src/test/resources/serializer/conversion-session-id.json create mode 100644 core-api/src/test/resources/serializer/impression-session-id.json diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 665fad3ce..334564508 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -117,9 +117,22 @@ private Optimizely(@Nonnull ProjectConfig projectConfig, return activate(experimentKey, userId, Collections.emptyMap()); } + public @Nullable Variation activate(@Nonnull String experimentKey, + @Nonnull String userId, + @CheckForNull String sessionId) throws UnknownExperimentException { + return activate(experimentKey, userId, Collections.emptyMap(), sessionId); + } + public @Nullable Variation activate(@Nonnull String experimentKey, @Nonnull String userId, @Nonnull Map attributes) throws UnknownExperimentException { + return activate(experimentKey, userId, attributes, null); + } + + public @Nullable Variation activate(@Nonnull String experimentKey, + @Nonnull String userId, + @Nonnull Map attributes, + @CheckForNull String sessionId) throws UnknownExperimentException { if (!validateUserId(userId)) { logger.info("Not activating user for experiment \"{}\".", experimentKey); @@ -135,7 +148,7 @@ private Optimizely(@Nonnull ProjectConfig projectConfig, return null; } - return activate(currentConfig, experiment, userId, attributes); + return activate(currentConfig, experiment, userId, attributes, sessionId); } public @Nullable Variation activate(@Nonnull Experiment experiment, @@ -143,19 +156,34 @@ private Optimizely(@Nonnull ProjectConfig projectConfig, return activate(experiment, userId, Collections.emptyMap()); } + public @Nullable Variation activate(@Nonnull Experiment experiment, + @Nonnull String userId, + @CheckForNull String sessionId) { + return activate(experiment, userId, Collections.emptyMap(), sessionId); + } + public @Nullable Variation activate(@Nonnull Experiment experiment, @Nonnull String userId, @Nonnull Map attributes) { + return activate(experiment, userId, attributes, null); + } + + public @Nullable Variation activate(@Nonnull Experiment experiment, + @Nonnull String userId, + @Nonnull Map attributes, + @CheckForNull String sessionId) { + ProjectConfig currentConfig = getProjectConfig(); - return activate(currentConfig, experiment, userId, attributes); + return activate(currentConfig, experiment, userId, attributes, sessionId); } private @Nullable Variation activate(@Nonnull ProjectConfig projectConfig, @Nonnull Experiment experiment, @Nonnull String userId, - @Nonnull Map attributes) { + @Nonnull Map attributes, + @CheckForNull String sessionId) { // determine whether all the given attributes are present in the project config. If not, filter out the unknown // attributes. attributes = filterAttributes(projectConfig, attributes); @@ -173,8 +201,8 @@ private Optimizely(@Nonnull ProjectConfig projectConfig, } if (experiment.isRunning()) { - LogEvent impressionEvent = - eventBuilder.createImpressionEvent(projectConfig, experiment, variation, userId, attributes); + LogEvent impressionEvent = eventBuilder.createImpressionEvent(projectConfig, experiment, variation, userId, + attributes, sessionId); logger.info("Activating user \"{}\" in experiment \"{}\".", userId, experiment.getKey()); logger.debug( "Dispatching impression event to URL {} with params {} and payload \"{}\".", @@ -197,13 +225,26 @@ private Optimizely(@Nonnull ProjectConfig projectConfig, public void track(@Nonnull String eventName, @Nonnull String userId) throws UnknownEventTypeException { - track(eventName, userId, Collections.emptyMap(), null); + track(eventName, userId, Collections.emptyMap(), null, null); + } + + public void track(@Nonnull String eventName, + @Nonnull String userId, + @CheckForNull String sessionId) throws UnknownEventTypeException { + track(eventName, userId, Collections.emptyMap(), null, sessionId); } public void track(@Nonnull String eventName, @Nonnull String userId, @Nonnull Map attributes) throws UnknownEventTypeException { - track(eventName, userId, attributes, null); + track(eventName, userId, attributes, null, null); + } + + public void track(@Nonnull String eventName, + @Nonnull String userId, + @Nonnull Map attributes, + @CheckForNull String sessionId) throws UnknownEventTypeException { + track(eventName, userId, attributes, null, sessionId); } public void track(@Nonnull String eventName, @@ -212,17 +253,33 @@ public void track(@Nonnull String eventName, track(eventName, userId, Collections.emptyMap(), eventValue); } + public void track(@Nonnull String eventName, + @Nonnull String userId, + long eventValue, + @CheckForNull String sessionId) throws UnknownEventTypeException { + track(eventName, userId, Collections.emptyMap(), eventValue, sessionId); + } + public void track(@Nonnull String eventName, @Nonnull String userId, @Nonnull Map attributes, long eventValue) throws UnknownEventTypeException { - track(eventName, userId, attributes, (Long)eventValue); + track(eventName, userId, attributes, (Long)eventValue, null); + } + + public void track(@Nonnull String eventName, + @Nonnull String userId, + @Nonnull Map attributes, + long eventValue, + @CheckForNull String sessionId) throws UnknownEventTypeException { + track(eventName, userId, attributes, (Long)eventValue, sessionId); } private void track(@Nonnull String eventName, @Nonnull String userId, @Nonnull Map attributes, - @CheckForNull Long eventValue) throws UnknownEventTypeException { + @CheckForNull Long eventValue, + @CheckForNull String sessionId) throws UnknownEventTypeException { ProjectConfig currentConfig = getProjectConfig(); @@ -238,16 +295,9 @@ private void track(@Nonnull String eventName, attributes = filterAttributes(currentConfig, attributes); // create the conversion event request parameters, then dispatch - LogEvent conversionEvent; - if (eventValue == null) { - conversionEvent = eventBuilder.createConversionEvent(currentConfig, bucketer, userId, - eventType.getId(), eventType.getKey(), - attributes); - } else { - conversionEvent = eventBuilder.createConversionEvent(currentConfig, bucketer, userId, - eventType.getId(), eventType.getKey(), attributes, - eventValue); - } + LogEvent conversionEvent = eventBuilder.createConversionEvent(currentConfig, bucketer, userId, + eventType.getId(), eventType.getKey(), attributes, + eventValue, sessionId); if (conversionEvent == null) { logger.info("There are no valid experiments for event \"{}\" to track.", eventName); @@ -265,7 +315,7 @@ private void track(@Nonnull String eventName, } notificationBroadcaster.broadcastEventTracked(eventName, userId, attributes, eventValue, - conversionEvent); + conversionEvent); } //======== live variable getters ========// diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/EventBuilder.java b/core-api/src/main/java/com/optimizely/ab/event/internal/EventBuilder.java index bfc76aca6..33bf72272 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/EventBuilder.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/EventBuilder.java @@ -29,11 +29,20 @@ public abstract class EventBuilder { + public LogEvent createImpressionEvent(@Nonnull ProjectConfig projectConfig, + @Nonnull Experiment activatedExperiment, + @Nonnull Variation variation, + @Nonnull String userId, + @Nonnull Map attributes) { + return createImpressionEvent(projectConfig, activatedExperiment, variation, userId, attributes, null); + } + public abstract LogEvent createImpressionEvent(@Nonnull ProjectConfig projectConfig, @Nonnull Experiment activatedExperiment, @Nonnull Variation variation, @Nonnull String userId, - @Nonnull Map attributes); + @Nonnull Map attributes, + @CheckForNull String sessionId); public LogEvent createConversionEvent(@Nonnull ProjectConfig projectConfig, @Nonnull Bucketer bucketer, @@ -41,7 +50,7 @@ public LogEvent createConversionEvent(@Nonnull ProjectConfig projectConfig, @Nonnull String eventId, @Nonnull String eventName, @Nonnull Map attributes) { - return createConversionEvent(projectConfig, bucketer, userId, eventId, eventName, attributes, null); + return createConversionEvent(projectConfig, bucketer, userId, eventId, eventName, attributes, null, null); } public LogEvent createConversionEvent(@Nonnull ProjectConfig projectConfig, @@ -51,14 +60,16 @@ public LogEvent createConversionEvent(@Nonnull ProjectConfig projectConfig, @Nonnull String eventName, @Nonnull Map attributes, long eventValue) { - return createConversionEvent(projectConfig, bucketer, userId, eventId, eventName, attributes, (Long)eventValue); + return createConversionEvent(projectConfig, bucketer, userId, eventId, eventName, attributes, (Long)eventValue, + null); } - abstract LogEvent createConversionEvent(@Nonnull ProjectConfig projectConfig, - @Nonnull Bucketer bucketer, - @Nonnull String userId, - @Nonnull String eventId, - @Nonnull String eventName, - @Nonnull Map attributes, - @CheckForNull Long eventValue); + public abstract LogEvent createConversionEvent(@Nonnull ProjectConfig projectConfig, + @Nonnull Bucketer bucketer, + @Nonnull String userId, + @Nonnull String eventId, + @Nonnull String eventName, + @Nonnull Map attributes, + @CheckForNull Long eventValue, + @CheckForNull String sessionId); } diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/EventBuilderV1.java b/core-api/src/main/java/com/optimizely/ab/event/internal/EventBuilderV1.java index 86100597d..a1a4860b7 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/EventBuilderV1.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/EventBuilderV1.java @@ -67,7 +67,8 @@ public LogEvent createImpressionEvent(@Nonnull ProjectConfig projectConfig, @Nonnull Experiment activatedExperiment, @Nonnull Variation variation, @Nonnull String userId, - @Nonnull Map attributes) { + @Nonnull Map attributes, + @CheckForNull String sessionId) { Map requestParams = new HashMap(); addCommonRequestParams(requestParams, projectConfig, userId, attributes); @@ -78,13 +79,14 @@ public LogEvent createImpressionEvent(@Nonnull ProjectConfig projectConfig, String.format(ENDPOINT_FORMAT, projectConfig.getProjectId()), requestParams, EMPTY_BODY); } - LogEvent createConversionEvent(@Nonnull ProjectConfig projectConfig, - @Nonnull Bucketer bucketer, - @Nonnull String userId, - @Nonnull String eventId, - @Nonnull String eventName, - @Nonnull Map attributes, - @CheckForNull Long eventValue) { + public LogEvent createConversionEvent(@Nonnull ProjectConfig projectConfig, + @Nonnull Bucketer bucketer, + @Nonnull String userId, + @Nonnull String eventId, + @Nonnull String eventName, + @Nonnull Map attributes, + @CheckForNull Long eventValue, + @CheckForNull String sessionId) { Map requestParams = new HashMap(); List addedExperiments = diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/EventBuilderV2.java b/core-api/src/main/java/com/optimizely/ab/event/internal/EventBuilderV2.java index d3329316b..295dcf565 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/EventBuilderV2.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/EventBuilderV2.java @@ -76,7 +76,8 @@ public LogEvent createImpressionEvent(@Nonnull ProjectConfig projectConfig, @Nonnull Experiment activatedExperiment, @Nonnull Variation variation, @Nonnull String userId, - @Nonnull Map attributes) { + @Nonnull Map attributes, + @CheckForNull String sessionId) { Impression impressionPayload = new Impression(); impressionPayload.setVisitorId(userId); @@ -96,6 +97,8 @@ public LogEvent createImpressionEvent(@Nonnull ProjectConfig projectConfig, impressionPayload.setClientEngine(clientEngine); impressionPayload.setClientVersion(clientVersion); impressionPayload.setAnonymizeIP(projectConfig.getAnonymizeIP()); + impressionPayload.setRevision(projectConfig.getRevision()); + impressionPayload.setSessionId(sessionId); String payload = this.serializer.serialize(impressionPayload); return new LogEvent(RequestMethod.POST, IMPRESSION_ENDPOINT, Collections.emptyMap(), payload); @@ -107,7 +110,8 @@ public LogEvent createConversionEvent(@Nonnull ProjectConfig projectConfig, @Nonnull String eventId, @Nonnull String eventName, @Nonnull Map attributes, - @CheckForNull Long eventValue) { + @CheckForNull Long eventValue, + @CheckForNull String sessionId) { Conversion conversionPayload = new Conversion(); conversionPayload.setVisitorId(userId); @@ -137,6 +141,9 @@ public LogEvent createConversionEvent(@Nonnull ProjectConfig projectConfig, conversionPayload.setAnonymizeIP(projectConfig.getAnonymizeIP()); conversionPayload.setClientEngine(clientEngine); conversionPayload.setClientVersion(clientVersion); + conversionPayload.setRevision(projectConfig.getRevision()); + conversionPayload.setSessionId(sessionId); + String payload = this.serializer.serialize(conversionPayload); return new LogEvent(RequestMethod.POST, CONVERSION_ENDPOINT, Collections.emptyMap(), payload); @@ -203,7 +210,8 @@ private List createLayerStates(ProjectConfig projectConfig, Bucketer Variation bucketedVariation = bucketer.bucket(experiment, userId); if (bucketedVariation != null) { Decision decision = new Decision(bucketedVariation.getId(), false, experiment.getId()); - layerStates.add(new LayerState(experiment.getLayerId(), decision, true)); + layerStates.add( + new LayerState(experiment.getLayerId(), projectConfig.getRevision(), decision, true)); } } else { logger.info( diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Conversion.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Conversion.java index e85c9eeb5..f2a02a7b1 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Conversion.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Conversion.java @@ -32,12 +32,23 @@ public class Conversion extends Event { private List eventFeatures; private boolean isGlobalHoldback; private boolean anonymizeIP; + private String sessionId; + private String revision; public Conversion() { } public Conversion(String visitorId, long timestamp, String projectId, String accountId, List userFeatures, List layerStates, String eventEntityId, String eventName, - List eventMetrics, List eventFeatures, boolean isGlobalHoldback, boolean anonymizeIP) { + List eventMetrics, List eventFeatures, boolean isGlobalHoldback, + String revision, boolean anonymizeIP) { + this(visitorId, timestamp, projectId, accountId, userFeatures, layerStates, eventEntityId, eventName, + eventMetrics, eventFeatures, isGlobalHoldback, anonymizeIP, revision, null); + } + + public Conversion(String visitorId, long timestamp, String projectId, String accountId, List userFeatures, + List layerStates, String eventEntityId, String eventName, + List eventMetrics, List eventFeatures, boolean isGlobalHoldback, + boolean anonymizeIP, String revision, String sessionId) { this.visitorId = visitorId; this.timestamp = timestamp; this.projectId = projectId; @@ -50,6 +61,8 @@ public Conversion(String visitorId, long timestamp, String projectId, String acc this.eventFeatures = eventFeatures; this.isGlobalHoldback = isGlobalHoldback; this.anonymizeIP = anonymizeIP; + this.revision = revision; + this.sessionId = sessionId; } public String getVisitorId() { @@ -144,27 +157,44 @@ public void setIsGlobalHoldback(boolean globalHoldback) { public void setAnonymizeIP(boolean anonymizeIP) { this.anonymizeIP = anonymizeIP; } - @Override - public boolean equals(Object other) { - if (!(other instanceof Conversion)) - return false; + public String getSessionId() { + return sessionId; + } - if (!super.equals(other)) - return false; + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } - Conversion otherConversion = (Conversion)other; + public String getRevision() { + return revision; + } - return timestamp == otherConversion.timestamp && - isGlobalHoldback == otherConversion.isGlobalHoldback && - visitorId.equals(otherConversion.visitorId) && - projectId.equals(otherConversion.projectId) && - accountId.equals(otherConversion.accountId) && - userFeatures.equals(otherConversion.userFeatures) && - layerStates.equals(otherConversion.layerStates) && - eventEntityId.equals(otherConversion.eventEntityId) && - eventName.equals(otherConversion.eventName) && - eventMetrics.equals(otherConversion.eventMetrics) && - eventFeatures.equals(otherConversion.eventFeatures); + public void setRevision(String revision) { + this.revision = revision; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + + Conversion that = (Conversion) o; + + if (timestamp != that.timestamp) return false; + if (isGlobalHoldback != that.isGlobalHoldback) return false; + if (anonymizeIP != that.anonymizeIP) return false; + if (!visitorId.equals(that.visitorId)) return false; + if (!projectId.equals(that.projectId)) return false; + if (!accountId.equals(that.accountId)) return false; + if (!userFeatures.equals(that.userFeatures)) return false; + if (!layerStates.equals(that.layerStates)) return false; + if (!eventEntityId.equals(that.eventEntityId)) return false; + if (!eventName.equals(that.eventName)) return false; + if (!eventMetrics.equals(that.eventMetrics)) return false; + if (!eventFeatures.equals(that.eventFeatures)) return false; + if (sessionId != null ? !sessionId.equals(that.sessionId) : that.sessionId != null) return false; + return revision.equals(that.revision); } @Override @@ -181,6 +211,9 @@ public int hashCode() { result = 31 * result + eventMetrics.hashCode(); result = 31 * result + eventFeatures.hashCode(); result = 31 * result + (isGlobalHoldback ? 1 : 0); + result = 31 * result + (anonymizeIP ? 1 : 0); + result = 31 * result + (sessionId != null ? sessionId.hashCode() : 0); + result = 31 * result + revision.hashCode(); return result; } @@ -199,7 +232,8 @@ public String toString() { ", eventFeatures=" + eventFeatures + ", isGlobalHoldback=" + isGlobalHoldback + ", anonymizeIP=" + anonymizeIP + - ", clientEngine='" + clientEngine + - ", clientVersion='" + clientVersion + '}'; + ", sessionId='" + sessionId + '\'' + + ", revision='" + revision + '\'' + + '}'; } } diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Impression.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Impression.java index 5619619a6..771c1213c 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Impression.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Impression.java @@ -29,11 +29,21 @@ public class Impression extends Event { private String accountId; private List userFeatures; private boolean anonymizeIP; + private String sessionId; + private String revision; public Impression() { } public Impression(String visitorId, long timestamp, boolean isGlobalHoldback, String projectId, Decision decision, - String layerId, String accountId, List userFeatures, boolean anonymizeIP) { + String layerId, String accountId, List userFeatures, boolean anonymizeIP, + String revision) { + this(visitorId, timestamp, isGlobalHoldback, projectId, decision, layerId, accountId, userFeatures, + anonymizeIP, revision, null); + } + + public Impression(String visitorId, long timestamp, boolean isGlobalHoldback, String projectId, Decision decision, + String layerId, String accountId, List userFeatures, boolean anonymizeIP, + String revision, String sessionId) { this.visitorId = visitorId; this.timestamp = timestamp; this.isGlobalHoldback = isGlobalHoldback; @@ -43,6 +53,8 @@ public Impression(String visitorId, long timestamp, boolean isGlobalHoldback, St this.accountId = accountId; this.userFeatures = userFeatures; this.anonymizeIP = anonymizeIP; + this.revision = revision; + this.sessionId = sessionId; } public String getVisitorId() { @@ -117,24 +129,42 @@ public void setAnonymizeIP(boolean anonymizeIP) { this.anonymizeIP = anonymizeIP; } + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getRevision() { + return revision; + } + + public void setRevision(String revision) { + this.revision = revision; + } + @Override - public boolean equals(Object other) { - if (!(other instanceof Impression)) - return false; + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; - if (!super.equals(other)) - return false; + Impression that = (Impression) o; - Impression otherImpression = (Impression)other; + if (timestamp != that.timestamp) return false; + if (isGlobalHoldback != that.isGlobalHoldback) return false; + if (anonymizeIP != that.anonymizeIP) return false; + if (!visitorId.equals(that.visitorId)) return false; + if (!projectId.equals(that.projectId)) return false; + if (!decision.equals(that.decision)) return false; + if (!layerId.equals(that.layerId)) return false; + if (!accountId.equals(that.accountId)) return false; + if (!userFeatures.equals(that.userFeatures)) return false; + if (sessionId != null ? !sessionId.equals(that.sessionId) : that.sessionId != null) return false; + return revision.equals(that.revision); - return timestamp == otherImpression.timestamp && - isGlobalHoldback == otherImpression.isGlobalHoldback && - visitorId.equals(otherImpression.visitorId) && - projectId.equals(otherImpression.projectId) && - decision.equals(otherImpression.decision) && - layerId.equals(otherImpression.layerId) && - accountId.equals(otherImpression.accountId) && - userFeatures.equals(otherImpression.userFeatures); } @Override @@ -148,6 +178,9 @@ public int hashCode() { result = 31 * result + layerId.hashCode(); result = 31 * result + accountId.hashCode(); result = 31 * result + userFeatures.hashCode(); + result = 31 * result + (anonymizeIP ? 1 : 0); + result = 31 * result + (sessionId != null ? sessionId.hashCode() : 0); + result = 31 * result + revision.hashCode(); return result; } @@ -157,13 +190,14 @@ public String toString() { "visitorId='" + visitorId + '\'' + ", timestamp=" + timestamp + ", isGlobalHoldback=" + isGlobalHoldback + - ", anonymizeIP=" + anonymizeIP + ", projectId='" + projectId + '\'' + ", decision=" + decision + ", layerId='" + layerId + '\'' + ", accountId='" + accountId + '\'' + ", userFeatures=" + userFeatures + - ", clientEngine='" + clientEngine + - ", clientVersion='" + clientVersion + '}'; + ", anonymizeIP=" + anonymizeIP + + ", sessionId='" + sessionId + '\'' + + ", revision='" + revision + '\'' + + '}'; } } diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/LayerState.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/LayerState.java index 5fc71b5f7..02915ffdf 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/LayerState.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/LayerState.java @@ -19,13 +19,15 @@ public class LayerState { private String layerId; + private String revision; private Decision decision; private boolean actionTriggered; public LayerState() { } - public LayerState(String layerId, Decision decision, boolean actionTriggered) { + public LayerState(String layerId, String revision, Decision decision, boolean actionTriggered) { this.layerId = layerId; + this.revision = revision; this.decision = decision; this.actionTriggered = actionTriggered; } @@ -38,6 +40,14 @@ public void setLayerId(String layerId) { this.layerId = layerId; } + public String getRevision() { + return revision; + } + + public void setRevision(String revision) { + this.revision = revision; + } + public Decision getDecision() { return decision; } @@ -55,21 +65,24 @@ public void setActionTriggered(boolean actionTriggered) { } @Override - public boolean equals(Object other) { - if (!(other instanceof LayerState)) - return false; + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + LayerState that = (LayerState) o; - LayerState otherLayerState = (LayerState)other; + if (actionTriggered != that.actionTriggered) return false; + if (!layerId.equals(that.layerId)) return false; + if (!revision.equals(that.revision)) return false; + return decision.equals(that.decision); - return layerId.equals(otherLayerState.getLayerId()) && - decision.equals(otherLayerState.getDecision()) && - actionTriggered == otherLayerState.getActionTriggered(); } @Override public int hashCode() { - int result = layerId != null ? layerId.hashCode() : 0; - result = 31 * result + (decision != null ? decision.hashCode() : 0); + int result = layerId.hashCode(); + result = 31 * result + revision.hashCode(); + result = 31 * result + decision.hashCode(); result = 31 * result + (actionTriggered ? 1 : 0); return result; } diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/JacksonSerializer.java b/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/JacksonSerializer.java index f6b2640f9..107aa47ff 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/JacksonSerializer.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/JacksonSerializer.java @@ -16,6 +16,7 @@ */ package com.optimizely.ab.event.internal.serializer; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -26,6 +27,7 @@ class JacksonSerializer implements Serializer { private final ObjectMapper mapper = new ObjectMapper(); public String serialize(T payload) { + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); try { return mapper.writeValueAsString(payload); } catch (JsonProcessingException e) { diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/JsonSimpleSerializer.java b/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/JsonSimpleSerializer.java index c79db95e0..625754200 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/JsonSimpleSerializer.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/JsonSimpleSerializer.java @@ -56,6 +56,11 @@ private JSONObject serializeImpression(Impression impression) { jsonObject.put("userFeatures", serializeFeatures(impression.getUserFeatures())); jsonObject.put("clientEngine", impression.getClientEngine()); jsonObject.put("clientVersion", impression.getClientVersion()); + jsonObject.put("revision", impression.getRevision()); + + if (impression.getSessionId() != null) { + jsonObject.put("sessionId", impression.getSessionId()); + } return jsonObject; } @@ -76,6 +81,11 @@ private JSONObject serializeConversion(Conversion conversion) { jsonObject.put("anonymizeIP", conversion.getAnonymizeIP()); jsonObject.put("clientEngine", conversion.getClientEngine()); jsonObject.put("clientVersion", conversion.getClientVersion()); + jsonObject.put("revision", conversion.getRevision()); + + if (conversion.getSessionId() != null) { + jsonObject.put("sessionId", conversion.getSessionId()); + } return jsonObject; } @@ -121,6 +131,7 @@ private JSONArray serializeLayerStates(List layerStates) { private JSONObject serializeLayerState(LayerState layerState) { JSONObject jsonObject = new JSONObject(); jsonObject.put("layerId", layerState.getLayerId()); + jsonObject.put("revision", layerState.getRevision()); jsonObject.put("decision", serializeDecision(layerState.getDecision())); jsonObject.put("actionTriggered", layerState.getActionTriggered()); diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTestV1.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTestV1.java index 137072862..55af6086d 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTestV1.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTestV1.java @@ -68,6 +68,7 @@ import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyMapOf; import static org.mockito.Matchers.eq; +import static org.mockito.Matchers.isNull; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -121,7 +122,7 @@ public void activateEndToEnd() throws Exception { testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createImpressionEvent(projectConfig, activatedExperiment, bucketedVariation, "userId", - testUserAttributes)) + testUserAttributes, null)) .thenReturn(logEventToDispatch); when(mockBucketer.bucket(activatedExperiment, "userId")) @@ -250,7 +251,7 @@ public void activateWithExperimentKey() throws Exception { testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createImpressionEvent(eq(projectConfig), eq(activatedExperiment), eq(bucketedVariation), - eq("userId"), eq(testUserAttributes))) + eq("userId"), eq(testUserAttributes), isNull(String.class))) .thenReturn(logEventToDispatch); when(mockBucketer.bucket(activatedExperiment, "userId")) @@ -339,7 +340,8 @@ public void activateWithAttributes() throws Exception { testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createImpressionEvent(eq(projectConfig), eq(activatedExperiment), eq(bucketedVariation), - eq("userId"), anyMapOf(String.class, String.class))) + eq("userId"), anyMapOf(String.class, String.class), + isNull(String.class))) .thenReturn(logEventToDispatch); when(mockBucketer.bucket(activatedExperiment, "userId")) @@ -359,7 +361,8 @@ public void activateWithAttributes() throws Exception { eq(activatedExperiment), eq(bucketedVariation), eq("userId"), - attributeCaptor.capture()); + attributeCaptor.capture(), + isNull(String.class)); Map actualValue = attributeCaptor.getValue(); assertThat(actualValue, hasEntry(attribute.getKey(), "attributeValue")); @@ -400,7 +403,8 @@ public void activateWithUnknownAttribute() throws Exception { testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createImpressionEvent(eq(projectConfig), eq(activatedExperiment), eq(bucketedVariation), - eq("userId"), anyMapOf(String.class, String.class))) + eq("userId"), anyMapOf(String.class, String.class), + isNull(String.class))) .thenReturn(logEventToDispatch); when(mockBucketer.bucket(activatedExperiment, "userId")) @@ -422,7 +426,8 @@ public void activateWithUnknownAttribute() throws Exception { // verify that the event builder was called with the expected attributes verify(mockEventBuilder).createImpressionEvent(eq(projectConfig), eq(activatedExperiment), - eq(bucketedVariation), eq("userId"), attributeCaptor.capture()); + eq(bucketedVariation), eq("userId"), attributeCaptor.capture(), + isNull(String.class)); Map actualValue = attributeCaptor.getValue(); assertThat(actualValue, not(hasKey("unknownAttribute"))); @@ -789,7 +794,8 @@ public void trackEventWithAttributes() throws Exception { LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createConversionEvent(eq(projectConfig), eq(mockBucketer), eq("userId"), eq(eventType.getId()), eq(eventType.getKey()), - anyMapOf(String.class, String.class))) + anyMapOf(String.class, String.class), isNull(Long.class), + isNull(String.class))) .thenReturn(logEventToDispatch); logbackVerifier.expectMessage(Level.INFO, "Tracking event \"clicked_cart\" for user \"userId\"."); @@ -804,8 +810,9 @@ public void trackEventWithAttributes() throws Exception { // verify that the event builder was called with the expected attributes verify(mockEventBuilder).createConversionEvent(eq(projectConfig), eq(mockBucketer), eq("userId"), - eq(eventType.getId()), eq(eventType.getKey()), - attributeCaptor.capture()); + eq(eventType.getId()), eq(eventType.getKey()), + attributeCaptor.capture(), isNull(Long.class), + isNull(String.class)); Map actualValue = attributeCaptor.getValue(); assertThat(actualValue, hasEntry(attribute.getKey(), "attributeValue")); @@ -841,7 +848,8 @@ public void trackEventWithUnknownAttribute() throws Exception { LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createConversionEvent(eq(projectConfig), eq(mockBucketer), eq("userId"), eq(eventType.getId()), eq(eventType.getKey()), - anyMapOf(String.class, String.class))) + anyMapOf(String.class, String.class), isNull(Long.class), + isNull(String.class))) .thenReturn(logEventToDispatch); logbackVerifier.expectMessage(Level.INFO, "Tracking event \"clicked_cart\" for user \"userId\"."); @@ -858,7 +866,8 @@ public void trackEventWithUnknownAttribute() throws Exception { // verify that the event builder was called with the expected attributes verify(mockEventBuilder).createConversionEvent(eq(projectConfig), eq(mockBucketer), eq("userId"), eq(eventType.getId()), eq(eventType.getKey()), - attributeCaptor.capture()); + attributeCaptor.capture(), isNull(Long.class), + isNull(String.class)); Map actualValue = attributeCaptor.getValue(); assertThat(actualValue, not(hasKey("unknownAttribute"))); @@ -891,7 +900,8 @@ public void trackEventWithRevenue() throws Exception { LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createConversionEvent(eq(projectConfig), eq(mockBucketer), eq("userId"), eq(eventType.getId()), eq(eventType.getKey()), - eq(Collections.emptyMap()), eq(revenue))) + eq(Collections.emptyMap()), eq(revenue), + isNull(String.class))) .thenReturn(logEventToDispatch); // call track @@ -904,7 +914,7 @@ public void trackEventWithRevenue() throws Exception { verify(mockEventBuilder).createConversionEvent(eq(projectConfig), eq(mockBucketer), eq("userId"), eq(eventType.getId()), eq(eventType.getKey()), eq(Collections.emptyMap()), - revenueCaptor.capture()); + revenueCaptor.capture(), isNull(String.class)); Long actualValue = revenueCaptor.getValue(); assertThat(actualValue, is(revenue)); diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTestV2.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTestV2.java index 87e6f4f6f..9d3c8d9ba 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTestV2.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTestV2.java @@ -68,6 +68,7 @@ import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyMapOf; import static org.mockito.Matchers.eq; +import static org.mockito.Matchers.isNull; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -121,8 +122,8 @@ public void activateEndToEnd() throws Exception { testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createImpressionEvent(projectConfig, activatedExperiment, bucketedVariation, "userId", - testUserAttributes)) - .thenReturn(logEventToDispatch); + testUserAttributes, null)) + .thenReturn(logEventToDispatch); when(mockBucketer.bucket(activatedExperiment, "userId")) .thenReturn(bucketedVariation); @@ -250,8 +251,8 @@ public void activateWithExperimentKey() throws Exception { testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createImpressionEvent(eq(projectConfig), eq(activatedExperiment), eq(bucketedVariation), - eq("userId"), eq(testUserAttributes))) - .thenReturn(logEventToDispatch); + eq("userId"), eq(testUserAttributes), isNull(String.class))) + .thenReturn(logEventToDispatch); when(mockBucketer.bucket(activatedExperiment, "userId")) .thenReturn(bucketedVariation); @@ -339,8 +340,9 @@ public void activateWithAttributes() throws Exception { testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createImpressionEvent(eq(projectConfig), eq(activatedExperiment), eq(bucketedVariation), - eq("userId"), anyMapOf(String.class, String.class))) - .thenReturn(logEventToDispatch); + eq("userId"), anyMapOf(String.class, String.class), + isNull(String.class))) + .thenReturn(logEventToDispatch); when(mockBucketer.bucket(activatedExperiment, "userId")) .thenReturn(bucketedVariation); @@ -355,11 +357,9 @@ public void activateWithAttributes() throws Exception { // setup the attribute map captor (so we can verify its content) ArgumentCaptor attributeCaptor = ArgumentCaptor.forClass(Map.class); - verify(mockEventBuilder).createImpressionEvent(eq(projectConfig), - eq(activatedExperiment), - eq(bucketedVariation), - eq("userId"), - attributeCaptor.capture()); + verify(mockEventBuilder).createImpressionEvent(eq(projectConfig), eq(activatedExperiment), + eq(bucketedVariation), eq("userId"), attributeCaptor.capture(), + isNull(String.class)); Map actualValue = attributeCaptor.getValue(); assertThat(actualValue, hasEntry(attribute.getKey(), "attributeValue")); @@ -400,8 +400,9 @@ public void activateWithUnknownAttribute() throws Exception { testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createImpressionEvent(eq(projectConfig), eq(activatedExperiment), eq(bucketedVariation), - eq("userId"), anyMapOf(String.class, String.class))) - .thenReturn(logEventToDispatch); + eq("userId"), anyMapOf(String.class, String.class), + isNull(String.class))) + .thenReturn(logEventToDispatch); when(mockBucketer.bucket(activatedExperiment, "userId")) .thenReturn(bucketedVariation); @@ -422,7 +423,8 @@ public void activateWithUnknownAttribute() throws Exception { // verify that the event builder was called with the expected attributes verify(mockEventBuilder).createImpressionEvent(eq(projectConfig), eq(activatedExperiment), - eq(bucketedVariation), eq("userId"), attributeCaptor.capture()); + eq(bucketedVariation), eq("userId"), attributeCaptor.capture(), + isNull(String.class)); Map actualValue = attributeCaptor.getValue(); assertThat(actualValue, not(hasKey("unknownAttribute"))); @@ -700,6 +702,41 @@ public void activateLaunchedExperimentDoesNotDispatchEvent() throws Exception { verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); } + /** + * Verify that {@link Optimizely#activate(String, String, String)} passes the session ID to + * {@link EventBuilder#createImpressionEvent(ProjectConfig, Experiment, Variation, String, Map, String)} + */ + @Test + public void activateWithSessionId() throws Exception { + String datafile = noAudienceProjectConfigJsonV2(); + ProjectConfig projectConfig = noAudienceProjectConfigV2(); + Experiment experiment = projectConfig.getExperiments().get(0); + + EventBuilder mockEventBuilder = mock(EventBuilder.class); + + Map testParams = new HashMap(); + testParams.put("test", "params"); + LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); + + when(mockEventBuilder.createImpressionEvent(eq(projectConfig), any(Experiment.class), any(Variation.class), + eq("userId"), eq(Collections.emptyMap()), + eq("test_session_id"))) + .thenReturn(logEventToDispatch); + + Optimizely optimizely = Optimizely.builder(datafile, mockEventHandler) + .withConfig(projectConfig) + .withEventBuilder(mockEventBuilder) + .build(); + + optimizely.activate(experiment.getKey(), "userId", "test_session_id"); + + // verify that the event builder was called with the expected attributes + verify(mockEventBuilder).createImpressionEvent(eq(projectConfig), any(Experiment.class), + any(Variation.class), eq("userId"), + eq(Collections.emptyMap()), + eq("test_session_id")); + } + //======== track tests ========// /** @@ -820,7 +857,8 @@ public void trackEventWithAttributes() throws Exception { LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createConversionEvent(eq(projectConfig), eq(mockBucketer), eq("userId"), eq(eventType.getId()), eq(eventType.getKey()), - anyMapOf(String.class, String.class))) + anyMapOf(String.class, String.class), isNull(Long.class), + isNull(String.class))) .thenReturn(logEventToDispatch); logbackVerifier.expectMessage(Level.INFO, "Tracking event \"clicked_cart\" for user \"userId\"."); @@ -835,8 +873,9 @@ public void trackEventWithAttributes() throws Exception { // verify that the event builder was called with the expected attributes verify(mockEventBuilder).createConversionEvent(eq(projectConfig), eq(mockBucketer), eq("userId"), - eq(eventType.getId()), eq(eventType.getKey()), - attributeCaptor.capture()); + eq(eventType.getId()), eq(eventType.getKey()), + attributeCaptor.capture(), isNull(Long.class), + isNull(String.class)); Map actualValue = attributeCaptor.getValue(); assertThat(actualValue, hasEntry(attribute.getKey(), "attributeValue")); @@ -871,9 +910,10 @@ public void trackEventWithUnknownAttribute() throws Exception { testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createConversionEvent(eq(projectConfig), eq(mockBucketer), eq("userId"), - eq(eventType.getId()), eq(eventType.getKey()), - anyMapOf(String.class, String.class))) - .thenReturn(logEventToDispatch); + eq(eventType.getId()), eq(eventType.getKey()), + anyMapOf(String.class, String.class), isNull(Long.class), + isNull(String.class))) + .thenReturn(logEventToDispatch); logbackVerifier.expectMessage(Level.INFO, "Tracking event \"clicked_cart\" for user \"userId\"."); logbackVerifier.expectMessage(Level.WARN, "Attribute(s) [unknownAttribute] not in the datafile."); @@ -888,8 +928,9 @@ public void trackEventWithUnknownAttribute() throws Exception { // verify that the event builder was called with the expected attributes verify(mockEventBuilder).createConversionEvent(eq(projectConfig), eq(mockBucketer), eq("userId"), - eq(eventType.getId()), eq(eventType.getKey()), - attributeCaptor.capture()); + eq(eventType.getId()), eq(eventType.getKey()), + attributeCaptor.capture(), isNull(Long.class), + isNull(String.class)); Map actualValue = attributeCaptor.getValue(); assertThat(actualValue, not(hasKey("unknownAttribute"))); @@ -921,9 +962,10 @@ public void trackEventWithRevenue() throws Exception { testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createConversionEvent(eq(projectConfig), eq(mockBucketer), eq("userId"), - eq(eventType.getId()), eq(eventType.getKey()), - eq(Collections.emptyMap()), eq(revenue))) - .thenReturn(logEventToDispatch); + eq(eventType.getId()), eq(eventType.getKey()), + eq(Collections.emptyMap()), eq(revenue), + isNull(String.class))) + .thenReturn(logEventToDispatch); // call track optimizely.track(eventType.getKey(), "userId", revenue); @@ -933,9 +975,9 @@ public void trackEventWithRevenue() throws Exception { // verify that the event builder was called with the expected revenue verify(mockEventBuilder).createConversionEvent(eq(projectConfig), eq(mockBucketer), eq("userId"), - eq(eventType.getId()), eq(eventType.getKey()), - eq(Collections.emptyMap()), - revenueCaptor.capture()); + eq(eventType.getId()), eq(eventType.getKey()), + eq(Collections.emptyMap()), + revenueCaptor.capture(), isNull(String.class)); Long actualValue = revenueCaptor.getValue(); assertThat(actualValue, is(revenue)); @@ -1004,6 +1046,45 @@ public void trackLaunchedExperimentDoesNotDispatchEvent() throws Exception { verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); } + /** + * Verify that {@link Optimizely#track(String, String, String)} passes the session ID to + * {@link EventBuilder#createConversionEvent(ProjectConfig, Bucketer, String, String, String, Map, Long, String)} + */ + @Test + public void trackEventWithSessionId() throws Exception { + String datafile = noAudienceProjectConfigJsonV2(); + ProjectConfig projectConfig = noAudienceProjectConfigV2(); + EventType eventType = projectConfig.getEventTypes().get(0); + + // setup a mock event builder to return expected conversion params + EventBuilder mockEventBuilder = mock(EventBuilder.class); + + Optimizely optimizely = Optimizely.builder(datafile, mockEventHandler) + .withBucketing(mockBucketer) + .withEventBuilder(mockEventBuilder) + .withConfig(projectConfig) + .withErrorHandler(mockErrorHandler) + .build(); + + Map testParams = new HashMap(); + testParams.put("test", "params"); + LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); + when(mockEventBuilder.createConversionEvent(eq(projectConfig), eq(mockBucketer), eq("userId"), + eq(eventType.getId()), eq(eventType.getKey()), + eq(Collections.emptyMap()), isNull(Long.class), + eq("test_session_id"))) + .thenReturn(logEventToDispatch); + + // call track + optimizely.track(eventType.getKey(), "userId", "test_session_id"); + + // verify that the event builder was called with the expected attributes + verify(mockEventBuilder).createConversionEvent(eq(projectConfig), eq(mockBucketer), eq("userId"), + eq(eventType.getId()), eq(eventType.getKey()), + eq(Collections.emptyMap()), isNull(Long.class), + eq("test_session_id")); + } + //======== getVariation tests ========// /** diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTestV3.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTestV3.java index 10269f178..de8212376 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTestV3.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTestV3.java @@ -71,6 +71,7 @@ import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyMapOf; import static org.mockito.Matchers.eq; +import static org.mockito.Matchers.isNull; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -125,8 +126,8 @@ public void activateEndToEnd() throws Exception { testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createImpressionEvent(projectConfig, activatedExperiment, bucketedVariation, "userId", - testUserAttributes)) - .thenReturn(logEventToDispatch); + testUserAttributes, null)) + .thenReturn(logEventToDispatch); when(mockBucketer.bucket(activatedExperiment, "userId")) .thenReturn(bucketedVariation); @@ -254,8 +255,8 @@ public void activateWithExperimentKey() throws Exception { testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createImpressionEvent(eq(projectConfig), eq(activatedExperiment), eq(bucketedVariation), - eq("userId"), eq(testUserAttributes))) - .thenReturn(logEventToDispatch); + eq("userId"), eq(testUserAttributes), isNull(String.class))) + .thenReturn(logEventToDispatch); when(mockBucketer.bucket(activatedExperiment, "userId")) .thenReturn(bucketedVariation); @@ -343,8 +344,9 @@ public void activateWithAttributes() throws Exception { testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createImpressionEvent(eq(projectConfig), eq(activatedExperiment), eq(bucketedVariation), - eq("userId"), anyMapOf(String.class, String.class))) - .thenReturn(logEventToDispatch); + eq("userId"), anyMapOf(String.class, String.class), + isNull(String.class))) + .thenReturn(logEventToDispatch); when(mockBucketer.bucket(activatedExperiment, "userId")) .thenReturn(bucketedVariation); @@ -359,11 +361,9 @@ public void activateWithAttributes() throws Exception { // setup the attribute map captor (so we can verify its content) ArgumentCaptor attributeCaptor = ArgumentCaptor.forClass(Map.class); - verify(mockEventBuilder).createImpressionEvent(eq(projectConfig), - eq(activatedExperiment), - eq(bucketedVariation), - eq("userId"), - attributeCaptor.capture()); + verify(mockEventBuilder).createImpressionEvent(eq(projectConfig), eq(activatedExperiment), + eq(bucketedVariation), eq("userId"), attributeCaptor.capture(), + isNull(String.class)); Map actualValue = attributeCaptor.getValue(); assertThat(actualValue, hasEntry(attribute.getKey(), "attributeValue")); @@ -404,8 +404,9 @@ public void activateWithUnknownAttribute() throws Exception { testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createImpressionEvent(eq(projectConfig), eq(activatedExperiment), eq(bucketedVariation), - eq("userId"), anyMapOf(String.class, String.class))) - .thenReturn(logEventToDispatch); + eq("userId"), anyMapOf(String.class, String.class), + isNull(String.class))) + .thenReturn(logEventToDispatch); when(mockBucketer.bucket(activatedExperiment, "userId")) .thenReturn(bucketedVariation); @@ -426,7 +427,8 @@ public void activateWithUnknownAttribute() throws Exception { // verify that the event builder was called with the expected attributes verify(mockEventBuilder).createImpressionEvent(eq(projectConfig), eq(activatedExperiment), - eq(bucketedVariation), eq("userId"), attributeCaptor.capture()); + eq(bucketedVariation), eq("userId"), attributeCaptor.capture(), + isNull(String.class)); Map actualValue = attributeCaptor.getValue(); assertThat(actualValue, not(hasKey("unknownAttribute"))); @@ -793,7 +795,8 @@ public void trackEventWithAttributes() throws Exception { LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createConversionEvent(eq(projectConfig), eq(mockBucketer), eq("userId"), eq(eventType.getId()), eq(eventType.getKey()), - anyMapOf(String.class, String.class))) + anyMapOf(String.class, String.class), isNull(Long.class), + isNull(String.class))) .thenReturn(logEventToDispatch); logbackVerifier.expectMessage(Level.INFO, "Tracking event \"clicked_cart\" for user \"userId\"."); @@ -808,8 +811,9 @@ public void trackEventWithAttributes() throws Exception { // verify that the event builder was called with the expected attributes verify(mockEventBuilder).createConversionEvent(eq(projectConfig), eq(mockBucketer), eq("userId"), - eq(eventType.getId()), eq(eventType.getKey()), - attributeCaptor.capture()); + eq(eventType.getId()), eq(eventType.getKey()), + attributeCaptor.capture(), isNull(Long.class), + isNull(String.class)); Map actualValue = attributeCaptor.getValue(); assertThat(actualValue, hasEntry(attribute.getKey(), "attributeValue")); @@ -844,9 +848,10 @@ public void trackEventWithUnknownAttribute() throws Exception { testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createConversionEvent(eq(projectConfig), eq(mockBucketer), eq("userId"), - eq(eventType.getId()), eq(eventType.getKey()), - anyMapOf(String.class, String.class))) - .thenReturn(logEventToDispatch); + eq(eventType.getId()), eq(eventType.getKey()), + anyMapOf(String.class, String.class), isNull(Long.class), + isNull(String.class))) + .thenReturn(logEventToDispatch); logbackVerifier.expectMessage(Level.INFO, "Tracking event \"clicked_cart\" for user \"userId\"."); logbackVerifier.expectMessage(Level.WARN, "Attribute(s) [unknownAttribute] not in the datafile."); @@ -861,8 +866,9 @@ public void trackEventWithUnknownAttribute() throws Exception { // verify that the event builder was called with the expected attributes verify(mockEventBuilder).createConversionEvent(eq(projectConfig), eq(mockBucketer), eq("userId"), - eq(eventType.getId()), eq(eventType.getKey()), - attributeCaptor.capture()); + eq(eventType.getId()), eq(eventType.getKey()), + attributeCaptor.capture(), isNull(Long.class), + isNull(String.class)); Map actualValue = attributeCaptor.getValue(); assertThat(actualValue, not(hasKey("unknownAttribute"))); @@ -894,9 +900,10 @@ public void trackEventWithRevenue() throws Exception { testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createConversionEvent(eq(projectConfig), eq(mockBucketer), eq("userId"), - eq(eventType.getId()), eq(eventType.getKey()), - eq(Collections.emptyMap()), eq(revenue))) - .thenReturn(logEventToDispatch); + eq(eventType.getId()), eq(eventType.getKey()), + eq(Collections.emptyMap()), eq(revenue), + isNull(String.class))) + .thenReturn(logEventToDispatch); // call track optimizely.track(eventType.getKey(), "userId", revenue); @@ -906,9 +913,9 @@ public void trackEventWithRevenue() throws Exception { // verify that the event builder was called with the expected revenue verify(mockEventBuilder).createConversionEvent(eq(projectConfig), eq(mockBucketer), eq("userId"), - eq(eventType.getId()), eq(eventType.getKey()), - eq(Collections.emptyMap()), - revenueCaptor.capture()); + eq(eventType.getId()), eq(eventType.getKey()), + eq(Collections.emptyMap()), + revenueCaptor.capture(), isNull(String.class)); Long actualValue = revenueCaptor.getValue(); assertThat(actualValue, is(revenue)); @@ -1483,15 +1490,15 @@ public void addNotificationListener() throws Exception { testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createImpressionEvent(projectConfig, activatedExperiment, - bucketedVariation, userId, attributes)) - .thenReturn(logEventToDispatch); + bucketedVariation, userId, attributes, null)) + .thenReturn(logEventToDispatch); when(mockBucketer.bucket(activatedExperiment, userId)) .thenReturn(bucketedVariation); when(mockEventBuilder.createImpressionEvent(projectConfig, activatedExperiment, - bucketedVariation, userId, attributes)) - .thenReturn(logEventToDispatch); + bucketedVariation, userId, attributes, null)) + .thenReturn(logEventToDispatch); // Add listener NotificationListener listener = mock(NotificationListener.class); @@ -1513,9 +1520,10 @@ public void addNotificationListener() throws Exception { String eventKey = eventType.getKey(); when(mockEventBuilder.createConversionEvent(eq(projectConfig), eq(mockBucketer), eq(userId), - eq(eventType.getId()), eq(eventKey), - anyMapOf(String.class, String.class))) - .thenReturn(logEventToDispatch); + eq(eventType.getId()), eq(eventKey), + anyMapOf(String.class, String.class), isNull(Long.class), + isNull(String.class))) + .thenReturn(logEventToDispatch); optimizely.track(eventKey, userId, attributes); verify(listener, times(1)) @@ -1549,15 +1557,15 @@ public void removeNotificationListener() throws Exception { testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createImpressionEvent(projectConfig, activatedExperiment, - bucketedVariation, userId, attributes)) - .thenReturn(logEventToDispatch); + bucketedVariation, userId, attributes, null)) + .thenReturn(logEventToDispatch); when(mockBucketer.bucket(activatedExperiment, userId)) .thenReturn(bucketedVariation); - when(mockEventBuilder.createImpressionEvent(projectConfig, activatedExperiment, - bucketedVariation, userId, attributes)) - .thenReturn(logEventToDispatch); + when(mockEventBuilder.createImpressionEvent(projectConfig, activatedExperiment, bucketedVariation, userId, + attributes, null)) + .thenReturn(logEventToDispatch); // Add and remove listener NotificationListener listener = mock(NotificationListener.class); @@ -1616,15 +1624,15 @@ public void clearNotificationListeners() throws Exception { testParams.put("test", "params"); LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); when(mockEventBuilder.createImpressionEvent(projectConfig, activatedExperiment, - bucketedVariation, userId, attributes)) - .thenReturn(logEventToDispatch); + bucketedVariation, userId, attributes, null)) + .thenReturn(logEventToDispatch); when(mockBucketer.bucket(activatedExperiment, userId)) .thenReturn(bucketedVariation); - when(mockEventBuilder.createImpressionEvent(projectConfig, activatedExperiment, - bucketedVariation, userId, attributes)) - .thenReturn(logEventToDispatch); + when(mockEventBuilder.createImpressionEvent(projectConfig, activatedExperiment, bucketedVariation, userId, + attributes, null)) + .thenReturn(logEventToDispatch); NotificationListener listener = mock(NotificationListener.class); optimizely.addNotificationListener(listener); diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/EventBuilderV2Test.java b/core-api/src/test/java/com/optimizely/ab/event/internal/EventBuilderV2Test.java index baf299f59..becfefc6e 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/EventBuilderV2Test.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/EventBuilderV2Test.java @@ -104,6 +104,7 @@ public void createImpressionEvent() throws Exception { assertThat(impression.getUserFeatures(), is(expectedUserFeatures)); assertThat(impression.getClientEngine(), is(ClientEngine.JAVA_SDK.getClientEngineValue())); assertThat(impression.getClientVersion(), is(BuildVersionInfo.VERSION)); + assertNull(impression.getSessionId()); } /** @@ -152,6 +153,27 @@ public void createImpressionEventCustomClientEngineClientVersion() throws Except assertThat(impression.getClientVersion(), is("0.0.0")); } + /** + * Verify that passing a non-null session ID to + * {@link EventBuilder#createImpressionEvent(ProjectConfig, Experiment, Variation, String, Map, String)} properly + * constructs an impression payload with the session ID specified. + */ + @Test + public void createImpressionEventWithSessionId() throws Exception { + ProjectConfig projectConfig = ProjectConfigTestUtils.validProjectConfigV2(); + Experiment activatedExperiment = projectConfig.getExperiments().get(0); + Variation bucketedVariation = activatedExperiment.getVariations().get(0); + Attribute attribute = projectConfig.getAttributes().get(0); + String userId = "userId"; + Map attributeMap = Collections.singletonMap(attribute.getKey(), "value"); + String sessionId = "sessionid"; + + LogEvent impressionEvent = builder.createImpressionEvent(projectConfig, activatedExperiment, bucketedVariation, + userId, attributeMap, sessionId); + Impression impression = gson.fromJson(impressionEvent.getBody(), Impression.class); + assertThat(impression.getSessionId(), is(sessionId)); + } + /** * Verify {@link Conversion} event creation */ @@ -185,7 +207,7 @@ public void createConversionEvent() throws Exception { if (experimentIds.contains(experiment.getId()) && ProjectValidationUtils.validatePreconditions(projectConfig, experiment, userId, attributeMap)) { verify(mockBucketAlgorithm).bucket(experiment, userId); - LayerState layerState = new LayerState(experiment.getLayerId(), + LayerState layerState = new LayerState(experiment.getLayerId(), projectConfig.getRevision(), new Decision(experiment.getVariations().get(0).getId(), false, experiment.getId()), true); expectedLayerStates.add(layerState); } else { @@ -398,4 +420,33 @@ public void createConversionEventForEventUsingLaunchedExperiment() throws Except // event will be null assertNull(conversionEvent); } + + /** + * Verify that passing a non-null session ID to + * {@link EventBuilder#createConversionEvent(ProjectConfig, Bucketer, String, String, String, Map, Long, String)} + * properly constructs an impression payload with the session ID specified. + */ + @Test + public void createConversionEventWithSessionId() throws Exception { + EventBuilderV2 builder = new EventBuilderV2(ClientEngine.ANDROID_SDK, "0.0.0"); + ProjectConfig projectConfig = ProjectConfigTestUtils.validProjectConfigV2(); + Attribute attribute = projectConfig.getAttributes().get(0); + EventType eventType = projectConfig.getEventTypes().get(0); + String userId = "userId"; + String sessionId = "sessionid"; + + Bucketer mockBucketAlgorithm = mock(Bucketer.class); + for (Experiment experiment : projectConfig.getExperiments()) { + when(mockBucketAlgorithm.bucket(experiment, userId)) + .thenReturn(experiment.getVariations().get(0)); + } + + Map attributeMap = Collections.singletonMap(attribute.getKey(), "value"); + LogEvent conversionEvent = builder.createConversionEvent(projectConfig, mockBucketAlgorithm, userId, + eventType.getId(), eventType.getKey(), attributeMap, + null, sessionId); + + Conversion conversion = gson.fromJson(conversionEvent.getBody(), Conversion.class); + assertThat(conversion.getSessionId(), is(sessionId)); + } } diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/GsonSerializerTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/GsonSerializerTest.java index 12e8c5112..e7977092a 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/GsonSerializerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/GsonSerializerTest.java @@ -26,9 +26,13 @@ import java.io.IOException; import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateConversion; -import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateImpression; import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateConversionJson; +import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateConversionWithSessionId; +import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateConversionWithSessionIdJson; +import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateImpression; import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateImpressionJson; +import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateImpressionWithSessionId; +import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateImpressionWithSessionIdJson; import static org.hamcrest.CoreMatchers.is; import static org.junit.Assert.assertThat; @@ -48,6 +52,16 @@ public void serializeImpression() throws IOException { assertThat(actual, is(expected)); } + @Test + public void serializeImpressionWithSessionId() throws IOException { + Impression impression = generateImpressionWithSessionId(); + // can't compare JSON strings since orders could vary so compare objects instead + Impression actual = gson.fromJson(serializer.serialize(impression), Impression.class); + Impression expected = gson.fromJson(generateImpressionWithSessionIdJson(), Impression.class); + + assertThat(actual, is(expected)); + } + @Test public void serializeConversion() throws IOException { Conversion conversion = generateConversion(); @@ -57,4 +71,14 @@ public void serializeConversion() throws IOException { assertThat(actual, is(expected)); } + + @Test + public void serializeConversionWithSessionId() throws Exception { + Conversion conversion = generateConversionWithSessionId(); + // can't compare JSON strings since orders could vary so compare objects instead + Conversion actual = gson.fromJson(serializer.serialize(conversion), Conversion.class); + Conversion expected = gson.fromJson(generateConversionWithSessionIdJson(), Conversion.class); + + assertThat(actual, is(expected)); + } } diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JacksonSerializerTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JacksonSerializerTest.java index 0fe8344f3..e29c5ebaa 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JacksonSerializerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JacksonSerializerTest.java @@ -26,9 +26,13 @@ import java.io.IOException; import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateConversion; -import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateImpression; import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateConversionJson; +import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateConversionWithSessionId; +import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateConversionWithSessionIdJson; +import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateImpression; import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateImpressionJson; +import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateImpressionWithSessionId; +import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateImpressionWithSessionIdJson; import static org.hamcrest.CoreMatchers.is; import static org.junit.Assert.assertThat; @@ -48,6 +52,16 @@ public void serializeImpression() throws IOException { assertThat(actual, is(expected)); } + @Test + public void serializeImpressionWithSessionId() throws IOException { + Impression impression = generateImpressionWithSessionId(); + // can't compare JSON strings since orders could vary so compare objects instead + Impression actual = mapper.readValue(serializer.serialize(impression), Impression.class); + Impression expected = mapper.readValue(generateImpressionWithSessionIdJson(), Impression.class); + + assertThat(actual, is(expected)); + } + @Test public void serializeConversion() throws IOException { Conversion conversion = generateConversion(); @@ -57,5 +71,15 @@ public void serializeConversion() throws IOException { assertThat(actual, is(expected)); } + + @Test + public void serializeConversionWithSessionId() throws IOException { + Conversion conversion = generateConversionWithSessionId(); + // can't compare JSON strings since orders could vary so compare objects instead + Conversion actual = mapper.readValue(serializer.serialize(conversion), Conversion.class); + Conversion expected = mapper.readValue(generateConversionWithSessionIdJson(), Conversion.class); + + assertThat(actual, is(expected)); + } } diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JsonSerializerTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JsonSerializerTest.java index ed80c277e..64c7988e9 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JsonSerializerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JsonSerializerTest.java @@ -26,9 +26,13 @@ import java.io.IOException; import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateConversion; -import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateImpression; import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateConversionJson; +import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateConversionWithSessionId; +import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateConversionWithSessionIdJson; +import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateImpression; import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateImpressionJson; +import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateImpressionWithSessionId; +import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateImpressionWithSessionIdJson; import static org.junit.Assert.assertTrue; @@ -46,6 +50,16 @@ public void serializeImpression() throws IOException { assertTrue(actual.similar(expected)); } + @Test + public void serializeImpressionWithSessionId() throws IOException { + Impression impression = generateImpressionWithSessionId(); + // can't compare JSON strings since orders could vary so compare JSONObjects instead + JSONObject actual = new JSONObject(serializer.serialize(impression)); + JSONObject expected = new JSONObject(generateImpressionWithSessionIdJson()); + + assertTrue(actual.similar(expected)); + } + @Test public void serializeConversion() throws IOException { Conversion conversion = generateConversion(); @@ -55,4 +69,14 @@ public void serializeConversion() throws IOException { assertTrue(actual.similar(expected)); } + + @Test + public void serializeConversionWithSessionId() throws IOException { + Conversion conversion = generateConversionWithSessionId(); + // can't compare JSON strings since orders could vary so compare JSONObjects instead + JSONObject actual = new JSONObject(serializer.serialize(conversion)); + JSONObject expected = new JSONObject(generateConversionWithSessionIdJson()); + + assertTrue(actual.similar(expected)); + } } diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JsonSimpleSerializerTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JsonSimpleSerializerTest.java index 27ac4bd21..c2e0775f5 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JsonSimpleSerializerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JsonSimpleSerializerTest.java @@ -28,9 +28,13 @@ import java.io.IOException; import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateConversion; -import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateImpression; import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateConversionJson; +import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateConversionWithSessionId; +import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateConversionWithSessionIdJson; +import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateImpression; import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateImpressionJson; +import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateImpressionWithSessionId; +import static com.optimizely.ab.event.internal.serializer.SerializerTestUtils.generateImpressionWithSessionIdJson; import static org.hamcrest.CoreMatchers.is; import static org.junit.Assert.assertThat; @@ -50,6 +54,16 @@ public void serializeImpression() throws IOException, ParseException { assertThat(actual, is(expected)); } + @Test + public void serializeImpressionWithSessionId() throws IOException, ParseException { + Impression impression = generateImpressionWithSessionId(); + // can't compare JSON strings since orders could vary so compare JSONObjects instead + JSONObject actual = (JSONObject)parser.parse(serializer.serialize(impression)); + JSONObject expected = (JSONObject)parser.parse(generateImpressionWithSessionIdJson()); + + assertThat(actual, is(expected)); + } + @Test public void serializeConversion() throws IOException, ParseException { Conversion conversion = generateConversion(); @@ -59,4 +73,14 @@ public void serializeConversion() throws IOException, ParseException { assertThat(actual, is(expected)); } + + @Test + public void serializeConversionWithSessionId() throws IOException, ParseException { + Conversion conversion = generateConversionWithSessionId(); + // can't compare JSON strings since orders could vary so compare JSONObjects instead + JSONObject actual = (JSONObject)parser.parse(serializer.serialize(conversion)); + JSONObject expected = (JSONObject)parser.parse(generateConversionWithSessionIdJson()); + + assertThat(actual, is(expected)); + } } diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/SerializerTestUtils.java b/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/SerializerTestUtils.java index 853cc2293..456ec149b 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/SerializerTestUtils.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/SerializerTestUtils.java @@ -40,6 +40,8 @@ public class SerializerTestUtils { private static final String variationId = "4"; private static final boolean isLayerHoldback = false; private static final String experimentId = "5"; + private static final String sessionId = "sessionid"; + private static final String revision = "1"; private static final Decision decision = new Decision(variationId, isLayerHoldback, experimentId); private static final String featureId = "6"; @@ -52,7 +54,7 @@ public class SerializerTestUtils { private static final boolean actionTriggered = true; private static final List layerStates = - Collections.singletonList(new LayerState(layerId, decision, actionTriggered)); + Collections.singletonList(new LayerState(layerId, revision, decision, actionTriggered)); private static final String eventEntityId = "7"; private static final String eventName = "testevent"; @@ -74,6 +76,14 @@ static Impression generateImpression() { impression.setUserFeatures(userFeatures); impression.setClientVersion("0.1.1"); impression.setAnonymizeIP(true); + impression.setRevision(revision); + + return impression; + } + + static Impression generateImpressionWithSessionId() { + Impression impression = generateImpression(); + impression.setSessionId(sessionId); return impression; } @@ -93,6 +103,14 @@ static Conversion generateConversion() { conversion.setIsGlobalHoldback(isGlobalHoldback); conversion.setClientVersion("0.1.1"); conversion.setAnonymizeIP(true); + conversion.setRevision(revision); + + return conversion; + } + + static Conversion generateConversionWithSessionId() { + Conversion conversion = generateConversion(); + conversion.setSessionId(sessionId); return conversion; } @@ -102,8 +120,20 @@ static String generateImpressionJson() throws IOException { return impressionJson.replaceAll("\\s+", ""); } + static String generateImpressionWithSessionIdJson() throws IOException { + String impressionJson = Resources.toString(Resources.getResource("serializer/impression-session-id.json"), + Charsets.UTF_8); + return impressionJson.replaceAll("\\s+", ""); + } + static String generateConversionJson() throws IOException { String conversionJson = Resources.toString(Resources.getResource("serializer/conversion.json"), Charsets.UTF_8); return conversionJson.replaceAll("\\s+", ""); } + + static String generateConversionWithSessionIdJson() throws IOException { + String conversionJson = Resources.toString(Resources.getResource("serializer/conversion-session-id.json"), + Charsets.UTF_8); + return conversionJson.replaceAll("\\s+", ""); + } } diff --git a/core-api/src/test/resources/serializer/conversion-session-id.json b/core-api/src/test/resources/serializer/conversion-session-id.json new file mode 100644 index 000000000..8d3ae851f --- /dev/null +++ b/core-api/src/test/resources/serializer/conversion-session-id.json @@ -0,0 +1,42 @@ +{ + "visitorId": "testvisitor", + "timestamp": 12345, + "projectId": "1", + "accountId": "3", + "userFeatures": [ + { + "id": "6", + "name": "testfeature", + "type": "custom", + "value": "testfeaturevalue", + "shouldIndex": true + } + ], + "layerStates": [ + { + "layerId": "2", + "revision": "1", + "decision": { + "variationId": "4", + "isLayerHoldback": false, + "experimentId": "5" + }, + "actionTriggered": true + } + ], + "eventEntityId": "7", + "eventName": "testevent", + "eventMetrics": [ + { + "name": "revenue", + "value": 5000 + } + ], + "eventFeatures": [], + "isGlobalHoldback": false, + "anonymizeIP": true, + "clientEngine": "java-sdk", + "clientVersion": "0.1.1", + "sessionId": "sessionid", + "revision": "1" +} \ No newline at end of file diff --git a/core-api/src/test/resources/serializer/conversion.json b/core-api/src/test/resources/serializer/conversion.json index 0a9ceacfd..5e432f3f4 100644 --- a/core-api/src/test/resources/serializer/conversion.json +++ b/core-api/src/test/resources/serializer/conversion.json @@ -15,6 +15,7 @@ "layerStates": [ { "layerId": "2", + "revision": "1", "decision": { "variationId": "4", "isLayerHoldback": false, @@ -35,5 +36,6 @@ "isGlobalHoldback": false, "anonymizeIP": true, "clientEngine": "java-sdk", - "clientVersion": "0.1.1" + "clientVersion": "0.1.1", + "revision": "1" } \ No newline at end of file diff --git a/core-api/src/test/resources/serializer/impression-session-id.json b/core-api/src/test/resources/serializer/impression-session-id.json new file mode 100644 index 000000000..476a29e27 --- /dev/null +++ b/core-api/src/test/resources/serializer/impression-session-id.json @@ -0,0 +1,27 @@ +{ + "visitorId": "testvisitor", + "timestamp": 12345, + "isGlobalHoldback": false, + "anonymizeIP": true, + "projectId": "1", + "decision": { + "variationId": "4", + "isLayerHoldback": false, + "experimentId": "5" + }, + "layerId": "2", + "accountId": "3", + "userFeatures": [ + { + "id": "6", + "name": "testfeature", + "type": "custom", + "value": "testfeaturevalue", + "shouldIndex": true + } + ], + "clientEngine": "java-sdk", + "clientVersion": "0.1.1", + "sessionId": "sessionid", + "revision": "1" +} \ No newline at end of file diff --git a/core-api/src/test/resources/serializer/impression.json b/core-api/src/test/resources/serializer/impression.json index cb1900f57..8d2f183d5 100644 --- a/core-api/src/test/resources/serializer/impression.json +++ b/core-api/src/test/resources/serializer/impression.json @@ -21,5 +21,6 @@ } ], "clientEngine": "java-sdk", - "clientVersion": "0.1.1" + "clientVersion": "0.1.1", + "revision": "1" } \ No newline at end of file From cfb481f41e81115f979280aafe62e6bd5733f849 Mon Sep 17 00:00:00 2001 From: Vignesh Raja Date: Tue, 31 Jan 2017 13:17:55 -0800 Subject: [PATCH 3/4] Prepare for 1.4.0 release (#68) --- CHANGELOG.md | 8 ++++++++ gradle.properties | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f62321194..469b6ec36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 1.4.0 + +January 30, 2017 + +- Add `sessionId` parameter to `activate` and `track` and include in event payload +- Append datafile `revision` to event payload +- Add support for "Launched" experiment status + ## 1.3.0 January 17, 2017 diff --git a/gradle.properties b/gradle.properties index 3236443f5..1a0ef0594 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ # Maven version -version = 1.3.0-SNAPSHOT +version = 1.4.0-SNAPSHOT # Artifact paths mavenS3Bucket = optimizely-maven From ef5d7f9f3ab29975f61ba9b45318c91bc18b247b Mon Sep 17 00:00:00 2001 From: Vignesh Raja Date: Tue, 31 Jan 2017 13:25:20 -0800 Subject: [PATCH 4/4] Update date (#69) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 469b6ec36..c7829cfe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## 1.4.0 -January 30, 2017 +January 31, 2017 - Add `sessionId` parameter to `activate` and `track` and include in event payload - Append datafile `revision` to event payload