From cce01cbb3eefcc92f19677c647b8ad7c23bb96cc Mon Sep 17 00:00:00 2001 From: LaunchDarklyReleaseBot <86431345+LaunchDarklyReleaseBot@users.noreply.github.com> Date: Wed, 11 Oct 2023 15:31:42 -0700 Subject: [PATCH] prepare 1.2.0 release (#8) ## [1.2.0] - 2023-10-11 ### Added: - Added support for the migration operation event. - Added support for event sampling for feature events and migration operation events. --------- Co-authored-by: Eli Bishop Co-authored-by: LaunchDarklyReleaseBot Co-authored-by: Todd Anderson Co-authored-by: tanderson-ld <127344469+tanderson-ld@users.noreply.github.com> Co-authored-by: ld-repository-standards[bot] <113625520+ld-repository-standards[bot]@users.noreply.github.com> Co-authored-by: Kane Parkinson <93555788+kparkinson-ld@users.noreply.github.com> Co-authored-by: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> --- .circleci/config.yml | 4 +- CODEOWNERS | 2 +- .../events/DefaultEventProcessor.java | 18 +- .../sdk/internal/events/Event.java | 491 ++++++++++++++++-- .../internal/events/EventOutputFormatter.java | 140 ++++- .../sdk/internal/events/Sampler.java | 28 + .../sdk/internal/events/BaseEventTest.java | 24 +- .../DefaultEventProcessorOutputTest.java | 76 +++ .../sdk/internal/events/EventOutputTest.java | 308 ++++++++++- 9 files changed, 1042 insertions(+), 49 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/internal/events/Sampler.java diff --git a/.circleci/config.yml b/.circleci/config.yml index 4f520b6..df7bf66 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -118,12 +118,14 @@ jobs: command: choco uninstall -y openjdk - run: name: install OpenJDK - command: choco install openjdk --version <> + command: choco install openjdk --version <> -y - attach_workspace: at: build - run: name: run tests command: | + Import-Module $env:ChocolateyInstall\helpers\chocolateyProfile.psm1 + refreshenv .\gradlew.bat --no-daemon test # must use --no-daemon because CircleCI in Windows will hang if there's a daemon running - run: name: save test results diff --git a/CODEOWNERS b/CODEOWNERS index 7d0dac3..f541913 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,2 +1,2 @@ # Repository Maintainers -* @launchdarkly/team-sdk +* @launchdarkly/team-sdk-java diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java index 3bbd217..1ad7a02 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java @@ -518,6 +518,16 @@ private void processEvent(Event e, EventBuffer outbox) { return; } + // For migration events we process them and exit early. They cannot generate additional event types or be + // summarized. + if(e instanceof Event.MigrationOp) { + Event.MigrationOp me = (Event.MigrationOp)e; + if (Sampler.shouldSample(me.getSamplingRatio())) { + outbox.add(e); + } + return; + } + LDContext context = e.getContext(); if (context == null) { return; // LDClient should never give us an event with no context @@ -531,7 +541,9 @@ private void processEvent(Event e, EventBuffer outbox) { if (e instanceof Event.FeatureRequest) { Event.FeatureRequest fe = (Event.FeatureRequest)e; - outbox.addToSummary(fe); + if(!fe.isExcludeFromSummaries()) { + outbox.addToSummary(fe); + } addFullEvent = fe.isTrackEvents(); if (shouldDebugEvent(fe)) { debugEvent = fe.toDebugEvent(); @@ -562,10 +574,10 @@ private void processEvent(Event e, EventBuffer outbox) { Event.Index ie = new Event.Index(e.getCreationDate(), e.getContext()); outbox.add(ie); } - if (addFullEvent) { + if (addFullEvent && Sampler.shouldSample(e.getSamplingRatio())) { outbox.add(e); } - if (debugEvent != null) { + if (debugEvent != null && Sampler.shouldSample(e.getSamplingRatio())) { outbox.add(debugEvent); } } diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/Event.java b/src/main/java/com/launchdarkly/sdk/internal/events/Event.java index 0767509..16c5b66 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/Event.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/Event.java @@ -3,10 +3,12 @@ import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** * Base class for all analytics events that are generated by the client. Also defines all of its own subclasses. - * + *

* These types are not visible to applications; they are an implementation detail of the default event * processor. */ @@ -16,30 +18,44 @@ public class Event { /** * Base event constructor. + * * @param creationDate the timestamp in milliseconds - * @param context the context associated with the event + * @param context the context associated with the event */ public Event(long creationDate, LDContext context) { this.creationDate = creationDate; this.context = context; } - + /** * The event timestamp. + * * @return the timestamp in milliseconds */ public long getCreationDate() { return creationDate; } - + /** * The context associated with the event. + * * @return the context object */ public LDContext getContext() { return context; } + /** + * Ratio used for sampling the event. The default sampling ratio is 1. + *

+ * Currently, sampling applies to feature, debug, and migration events. + * + * @return the sampling ratio + */ + public long getSamplingRatio() { + return 1; + } + /** * A custom event created with one of the SDK's {@code track} methods. */ @@ -50,10 +66,11 @@ public static final class Custom extends Event { /** * Constructs a custom event. - * @param timestamp the timestamp in milliseconds - * @param key the event key - * @param context the context associated with the event - * @param data custom data if any (null is the same as {@link LDValue#ofNull()}) + * + * @param timestamp the timestamp in milliseconds + * @param key the event key + * @param context the context associated with the event + * @param data custom data if any (null is the same as {@link LDValue#ofNull()}) * @param metricValue custom metric value if any */ public Custom(long timestamp, String key, LDContext context, LDValue data, Double metricValue) { @@ -65,22 +82,25 @@ public Custom(long timestamp, String key, LDContext context, LDValue data, Doubl /** * The custom event key. + * * @return the event key */ public String getKey() { return key; } - + /** * The custom data associated with the event, if any. + * * @return the event data (null is equivalent to {@link LDValue#ofNull()}) */ public LDValue getData() { return data; } - + /** * The numeric metric value associated with the event, if any. + * * @return the metric value or null */ public Double getMetricValue() { @@ -95,8 +115,9 @@ public Double getMetricValue() { public static final class Identify extends Event { /** * Constructs an identify event. + * * @param timestamp the timestamp in milliseconds - * @param context the context associated with the event + * @param context the context associated with the event */ public Identify(long timestamp, LDContext context) { super(timestamp, context); @@ -109,14 +130,15 @@ public Identify(long timestamp, LDContext context) { public static final class Index extends Event { /** * Constructs an index event. + * * @param timestamp the timestamp in milliseconds - * @param context the context associated with the event + * @param context the context associated with the event */ public Index(long timestamp, LDContext context) { super(timestamp, context); } } - + /** * An event generated by a feature flag evaluation. */ @@ -131,24 +153,30 @@ public static final class FeatureRequest extends Event { private final Long debugEventsUntilDate; private final EvaluationReason reason; private final boolean debug; + private final long samplingRatio; + private final boolean excludeFromSummaries; /** * Constructs a feature request event. - * @param timestamp the timestamp in milliseconds - * @param key the flag key - * @param context the context associated with the event - * @param version the flag version, or -1 if the flag was not found - * @param variation the result variation, or -1 if there was an error - * @param value the result value - * @param defaultVal the default value passed by the application - * @param reason the evaluation reason, if it is to be included in the event - * @param prereqOf if this flag was evaluated as a prerequisite, this is the key of the flag that referenced it - * @param trackEvents true if full event tracking is turned on for this flag + * + * @param timestamp the timestamp in milliseconds + * @param key the flag key + * @param context the context associated with the event + * @param version the flag version, or -1 if the flag was not found + * @param variation the result variation, or -1 if there was an error + * @param value the result value + * @param defaultVal the default value passed by the application + * @param reason the evaluation reason, if it is to be included in the event + * @param prereqOf if this flag was evaluated as a prerequisite, this is the key of the flag that referenced it + * @param trackEvents true if full event tracking is turned on for this flag * @param debugEventsUntilDate if non-null, the time until which event debugging should be enabled - * @param debug true if this is a debugging event + * @param debug true if this is a debugging event + * @param excludeFromSummaries true to exclude the event from summaries + * @param samplingRatio the sampling ratio for the event */ public FeatureRequest(long timestamp, String key, LDContext context, int version, int variation, LDValue value, - LDValue defaultVal, EvaluationReason reason, String prereqOf, boolean trackEvents, Long debugEventsUntilDate, boolean debug) { + LDValue defaultVal, EvaluationReason reason, String prereqOf, boolean trackEvents, + Long debugEventsUntilDate, boolean debug, long samplingRatio, boolean excludeFromSummaries) { super(timestamp, context); this.key = key; this.version = version; @@ -160,10 +188,38 @@ public FeatureRequest(long timestamp, String key, LDContext context, int version this.debugEventsUntilDate = debugEventsUntilDate; this.reason = reason; this.debug = debug; + this.excludeFromSummaries = excludeFromSummaries; + this.samplingRatio = samplingRatio; + } + + /** + * Constructs a feature request event. + *

+ * This version of the constructor uses default values for the samplingRatio (1) and excludeFromSummaries (false). + * + * @param timestamp the timestamp in milliseconds + * @param key the flag key + * @param context the context associated with the event + * @param version the flag version, or -1 if the flag was not found + * @param variation the result variation, or -1 if there was an error + * @param value the result value + * @param defaultVal the default value passed by the application + * @param reason the evaluation reason, if it is to be included in the event + * @param prereqOf if this flag was evaluated as a prerequisite, this is the key of the flag that referenced it + * @param trackEvents true if full event tracking is turned on for this flag + * @param debugEventsUntilDate if non-null, the time until which event debugging should be enabled + * @param debug true if this is a debugging event + */ + public FeatureRequest(long timestamp, String key, LDContext context, int version, int variation, LDValue value, + LDValue defaultVal, EvaluationReason reason, String prereqOf, boolean trackEvents, + Long debugEventsUntilDate, boolean debug) { + this(timestamp, key, context, version, variation, value, defaultVal, reason, prereqOf, trackEvents, + debugEventsUntilDate, debug, 1, false); } /** * The key of the feature flag that was evaluated. + * * @return the flag key */ public String getKey() { @@ -172,6 +228,7 @@ public String getKey() { /** * The index of the selected flag variation, or -1 if the application default value was used. + * * @return zero-based index of the variation, or -1 */ public int getVariation() { @@ -180,6 +237,7 @@ public int getVariation() { /** * The value of the selected flag variation. + * * @return the value */ public LDValue getValue() { @@ -188,6 +246,7 @@ public LDValue getValue() { /** * The application default value used in the evaluation. + * * @return the application default */ public LDValue getDefaultVal() { @@ -196,6 +255,7 @@ public LDValue getDefaultVal() { /** * The version of the feature flag that was evaluated, or -1 if the flag was not found. + * * @return the flag version or null */ public int getVersion() { @@ -204,6 +264,7 @@ public int getVersion() { /** * If this flag was evaluated as a prerequisite for another flag, the key of the other flag. + * * @return a flag key or null */ public String getPrereqOf() { @@ -212,6 +273,7 @@ public String getPrereqOf() { /** * True if full event tracking is enabled for this flag. + * * @return true if full event tracking is on */ public boolean isTrackEvents() { @@ -220,6 +282,7 @@ public boolean isTrackEvents() { /** * If debugging is enabled for this flag, the Unix millisecond time at which to stop debugging. + * * @return a timestamp or null */ public Long getDebugEventsUntilDate() { @@ -227,7 +290,8 @@ public Long getDebugEventsUntilDate() { } /** - * The {@link EvaluationReason} for this evaluation, or null if the reason was not requested for this evaluation. + * The {@link EvaluationReason} for this evaluation, or null if the reason was not requested for this evaluation. + * * @return a reason object or null */ public EvaluationReason getReason() { @@ -236,20 +300,389 @@ public EvaluationReason getReason() { /** * True if this event was generated due to debugging being enabled. + * * @return true if this is a debug event */ public boolean isDebug() { return debug; } - + + public boolean isExcludeFromSummaries() { + return excludeFromSummaries; + } + + @Override + public long getSamplingRatio() { + return samplingRatio; + } + /** * Creates a debug event with the same properties as this event. + * * @return a debug event */ public FeatureRequest toDebugEvent() { return new FeatureRequest(getCreationDate(), getKey(), getContext(), getVersion(), getVariation(), getValue(), getDefaultVal(), getReason(), getPrereqOf(), - false, null, true); + false, null, true, samplingRatio, excludeFromSummaries); + } + } + + /** + * An event generated by a migration operation. + */ + public static final class MigrationOp extends Event { + private final String featureKey; + private final int variation; + private final LDValue value; + private final LDValue defaultVal; + private final EvaluationReason reason; + + private final long samplingRatio; + + private final String operation; + + private final int flagVersion; + + private final ConsistencyMeasurement consistencyMeasurement; + private final LatencyMeasurement latencyMeasurement; + private final ErrorMeasurement errorMeasurement; + private final InvokedMeasurement invokedMeasurement; + + /** + * Measurement used to indicate if the values in a read operation were consistent. + */ + public static final class ConsistencyMeasurement { + private final boolean consistent; + private final long samplingRatio; + + /** + * Construct a new consistency measurement. + * + * @param consistent true if the result was consistent + * @param samplingRatio the sampling ratio for the consistency check + */ + public ConsistencyMeasurement(boolean consistent, long samplingRatio) { + this.consistent = consistent; + this.samplingRatio = samplingRatio; + } + + /** + * Check if the operation was consistent. + * + * @return true if the operation was consistent + */ + public boolean isConsistent() { + return consistent; + } + + /** + * Get the sampling ratio for the consistency check. + * + * @return the sampling ratio + */ + public long getSamplingRatio() { + return samplingRatio; + } + } + + /** + * Latency measurement for a migration operation. + */ + public static final class LatencyMeasurement { + private final Long oldLatencyMs; + private final Long newLatencyMs; + + /** + * Construct a latency measurement. + * + * @param oldLatency the old method latency, in milliseconds, or null if the old method was not executed + * @param newLatency the new method latency, in milliseconds, or null if the new method was not executed + */ + public LatencyMeasurement(@Nullable Long oldLatency, @Nullable Long newLatency) { + this.oldLatencyMs = oldLatency; + this.newLatencyMs = newLatency; + } + + /** + * Get the old method execution latency in milliseconds. + * + * @return The old latency or null if the method was not invoked. + */ + public Long getOldLatencyMs() { + return oldLatencyMs; + } + + /** + * Get the new method execution latency in milliseconds. + * + * @return The new latency or null if the method was not invoked. + */ + public Long getNewLatencyMs() { + return newLatencyMs; + } + + /** + * Returns true if either of the durations are set. + * + * @return true if either of the durations are set + */ + public boolean hasMeasurement() { + return oldLatencyMs != null || newLatencyMs != null; + } + } + + /** + * Error measurement for a migration operation. + */ + public static final class ErrorMeasurement { + private final boolean oldError; + private final boolean newError; + + /** + * Construct an error measurement. + * + * @param oldError true if there was an error executing the old method + * @param newError true if there was an error executing the new method + */ + public ErrorMeasurement(boolean oldError, boolean newError) { + this.oldError = oldError; + this.newError = newError; + } + + /** + * Check if there was an error executing the old method. + * + * @return true if there was an error executing the old method + */ + public boolean hasOldError() { + return oldError; + } + + /** + * Check if there was an error executing the new method. + * + * @return true if there was an error executing the new method + */ + public boolean hasNewError() { + return newError; + } + + /** + * Returns true if there are errors present for either of the origins. + * + * @return true if errors are present + */ + public boolean hasMeasurement() { + return oldError || newError; + } + } + + /** + * Invoked measurement for a migration op. + *

+ * Indicates which origins/sources were executed while doing a migration operation. + */ + public static final class InvokedMeasurement { + private final boolean oldInvoked; + private final boolean newInvoked; + + /** + * Construct a new invoked measurement. + * + * @param oldInvoked true if old was invoked + * @param newInvoked true if new was invoked + */ + public InvokedMeasurement(boolean oldInvoked, boolean newInvoked) { + this.oldInvoked = oldInvoked; + this.newInvoked = newInvoked; + } + + /** + * Check if the old method was invoked. + * + * @return true if the old method was invoked + */ + public boolean wasOldInvoked() { + return oldInvoked; + } + + /** + * Check if the new method was invoked. + * + * @return true if the new method was invoked + */ + public boolean wasNewInvoked() { + return newInvoked; + } + } + + /** + * Construct a new migration operation event. + * + * @param timestamp the timestamp in milliseconds + * @param context the context associated with the event + * @param featureKey the flag key + * @param variation the result variation, or -1 if there was an error + * @param flagVersion the flag version, or -1 if the flag was not found + * @param value the result value + * @param defaultVal the default value passed by the application + * @param reason the evaluation reason, if it is to be included in the event + * @param samplingRatio the sampling ratio for this event + * @param operation the operation for the event + * @param invokedMeasurement measurement containing which origins were invoked + * @param consistencyMeasurement measurement containing results of a consistency check, or null if no check was done + * @param latencyMeasurement measurement containing the execution latencies of invoked methods, or null if no check + * was done + * @param errorMeasurement measurement reporting any errors, or null if no errors were encountered + */ + public MigrationOp( + long timestamp, + @NotNull LDContext context, + @NotNull String featureKey, + int variation, + int flagVersion, + @NotNull LDValue value, + @NotNull LDValue defaultVal, + @Nullable EvaluationReason reason, // For a server SDK this will not be null, but if it is ever used client side + // then likely this would be null unless evaluation reasons were requested. + long samplingRatio, + @NotNull String operation, + @NotNull InvokedMeasurement invokedMeasurement, // An invoked measurement is required. + @Nullable ConsistencyMeasurement consistencyMeasurement, + @Nullable LatencyMeasurement latencyMeasurement, + @Nullable ErrorMeasurement errorMeasurement + ) { + super(timestamp, context); + this.featureKey = featureKey; + this.variation = variation; + this.flagVersion = flagVersion; + this.value = value; + this.defaultVal = defaultVal; + this.reason = reason; + this.samplingRatio = samplingRatio; + this.operation = operation; + this.consistencyMeasurement = consistencyMeasurement; + this.latencyMeasurement = latencyMeasurement; + this.errorMeasurement = errorMeasurement; + this.invokedMeasurement = invokedMeasurement; + } + + /** + * The key of the feature flag that was evaluated. + * + * @return the flag key + */ + @NotNull + public String getFeatureKey() { + return featureKey; + } + + /** + * The index of the selected flag variation, or -1 if the application default value was used. + * + * @return zero-based index of the variation, or -1 + */ + public int getVariation() { + return variation; + } + + /** + * The version of the feature flag that was evaluated, or -1 if the flag was not found. + * + * @return the flag version or -1 + */ + public int getFlagVersion() { + return flagVersion; + } + + /** + * The value of the selected flag variation. + * + * @return the value + */ + @NotNull + public LDValue getValue() { + return value; + } + + /** + * The application default value used in the evaluation. + * + * @return the application default + */ + @NotNull + public LDValue getDefaultVal() { + return defaultVal; + } + + /** + * The {@link EvaluationReason} for this evaluation, or null if the reason was not requested for this evaluation. + * + * @return a reason object or null + */ + @Nullable + public EvaluationReason getReason() { + return reason; + } + + /** + * The {@link InvokedMeasurement} for this operation. + * + * @return the invoked measurement + */ + @NotNull + public InvokedMeasurement getInvokedMeasurement() { + return invokedMeasurement; + } + + /** + * The {@link LatencyMeasurement} for this operation. + * + * @return the latency measurement or null + */ + @Nullable + public LatencyMeasurement getLatencyMeasurement() { + return latencyMeasurement; + } + + /** + * The {@link ErrorMeasurement} for this operation. + * + * @return the error measurement or null + */ + @Nullable + public ErrorMeasurement getErrorMeasurement() { + return errorMeasurement; + } + + /** + * Get the {@link ConsistencyMeasurement} for this operation. + * + * @return the consistency measurement or null + */ + @Nullable + public ConsistencyMeasurement getConsistencyMeasurement() { + return consistencyMeasurement; + } + + /** + * Get the sampling ratio for this event. + * + * @return the sampling ratio + */ + @Override + public long getSamplingRatio() { + return samplingRatio; + } + + /** + * Get the migration operation for this event. + * + * @return the migration operation + */ + public String getOperation() { + return operation; } } -} \ No newline at end of file +} diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java b/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java index 933648b..538e8b6 100644 --- a/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java +++ b/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java @@ -19,7 +19,7 @@ * Transforms analytics events and summary data into the JSON format that we send to LaunchDarkly. * Rather than creating intermediate objects to represent this schema, we use the Gson streaming * output API to construct JSON directly. - * + *

* Test coverage for this logic is in EventOutputTest and DefaultEventProcessorOutputTest. The * handling of context data and private attribute redaction is implemented in EventContextFormatter * and tested in more detail in EventContextFormatterTest. @@ -33,7 +33,7 @@ final class EventOutputFormatter { config.privateAttributes.toArray(new AttributeRef[config.privateAttributes.size()])); } - final int writeOutputEvents(Event[] events, EventSummarizer.EventSummary summary, Writer writer) throws IOException { + int writeOutputEvents(Event[] events, EventSummarizer.EventSummary summary, Writer writer) throws IOException { int count = 0; JsonWriter jsonWriter = new JsonWriter(writer); jsonWriter.beginArray(); @@ -51,7 +51,7 @@ final int writeOutputEvents(Event[] events, EventSummarizer.EventSummary summary return count; } - private final boolean writeOutputEvent(Event event, JsonWriter jw) throws IOException { + private boolean writeOutputEvent(Event event, JsonWriter jw) throws IOException { if (event.getContext() == null || !event.getContext().isValid()) { // The SDK should never send us an event without a valid context, but if we somehow get one, // just skip the event since there's no way to serialize it. @@ -81,7 +81,7 @@ private final boolean writeOutputEvent(Event event, JsonWriter jw) throws IOExce jw.name("prereqOf"); jw.value(fe.getPrereqOf()); } - writeEvaluationReason("reason", fe.getReason(), jw); + writeEvaluationReason(fe.getReason(), jw); jw.endObject(); } else if (event instanceof Event.Identify) { jw.beginObject(); @@ -104,14 +104,130 @@ private final boolean writeOutputEvent(Event event, JsonWriter jw) throws IOExce jw.beginObject(); writeKindAndCreationDate(jw, "index", event.getCreationDate()); writeContext(event.getContext(), jw); + jw.endObject(); + } else if (event instanceof Event.MigrationOp) { + jw.beginObject(); + writeKindAndCreationDate(jw, "migration_op", event.getCreationDate()); + writeContextKeys(event.getContext(), jw); + + Event.MigrationOp me = (Event.MigrationOp)event; + jw.name("operation").value(me.getOperation()); + + long samplingRatio = me.getSamplingRatio(); + if(samplingRatio != 1) { + jw.name("samplingRatio").value(samplingRatio); + } + + writeMigrationEvaluation(jw, me); + writeMeasurements(jw, me); + jw.endObject(); } else { return false; } return true; } - - private final void writeSummaryEvent(EventSummarizer.EventSummary summary, JsonWriter jw) throws IOException { + + private static void writeMeasurements(JsonWriter jw, Event.MigrationOp me) throws IOException { + jw.name("measurements"); + jw.beginArray(); + + writeInvokedMeasurement(jw, me); + writeConsistencyMeasurement(jw, me); + writeLatencyMeasurement(jw, me); + writeErrorMeasurement(jw, me); + + jw.endArray(); // end measurements + } + + private static void writeErrorMeasurement(JsonWriter jw, Event.MigrationOp me) throws IOException { + Event.MigrationOp.ErrorMeasurement errorMeasurement = me.getErrorMeasurement(); + if(errorMeasurement != null && errorMeasurement.hasMeasurement()) { + jw.beginObject(); + jw.name("key").value("error"); + jw.name("values"); + jw.beginObject(); + if(errorMeasurement.hasOldError()) { + jw.name("old").value(errorMeasurement.hasOldError()); + } + if(errorMeasurement.hasNewError()) { + jw.name("new").value(errorMeasurement.hasNewError()); + } + jw.endObject(); // end of values + jw.endObject(); // end of measurement + } + } + + private static void writeLatencyMeasurement(JsonWriter jw, Event.MigrationOp me) throws IOException { + Event.MigrationOp.LatencyMeasurement latencyMeasurement = me.getLatencyMeasurement(); + if(latencyMeasurement != null && latencyMeasurement.hasMeasurement()) { + jw.beginObject(); + + jw.name("key").value("latency_ms"); + + jw.name("values"); + jw.beginObject(); + if(latencyMeasurement.getOldLatencyMs() != null) { + jw.name("old").value(latencyMeasurement.getOldLatencyMs()); + } + if(latencyMeasurement.getNewLatencyMs() != null) { + jw.name("new").value(latencyMeasurement.getNewLatencyMs()); + } + + jw.endObject(); // end of values + jw.endObject(); // end of measurement + } + } + + private static void writeConsistencyMeasurement(JsonWriter jw, Event.MigrationOp me) throws IOException { + Event.MigrationOp.ConsistencyMeasurement consistencyMeasurement = me.getConsistencyMeasurement(); + if(consistencyMeasurement != null) { + jw.beginObject(); + jw.name("key").value("consistent"); + jw.name("value").value(consistencyMeasurement.isConsistent()); + if(consistencyMeasurement.getSamplingRatio() != 1) { + jw.name("samplingRatio").value(consistencyMeasurement.getSamplingRatio()); + } + jw.endObject(); // end measurement + } + } + + private static void writeInvokedMeasurement(JsonWriter jw, Event.MigrationOp me) throws IOException { + jw.beginObject(); + jw.name("key").value("invoked"); + Event.MigrationOp.InvokedMeasurement invokedMeasurement = me.getInvokedMeasurement(); + + jw.name("values"); + jw.beginObject(); + if(invokedMeasurement.wasOldInvoked()) { + jw.name("old").value(invokedMeasurement.wasOldInvoked()); + } + if(invokedMeasurement.wasNewInvoked()) { + jw.name("new").value(invokedMeasurement.wasNewInvoked()); + } + jw.endObject(); // end values + jw.endObject(); // end measurement + } + + private void writeMigrationEvaluation(JsonWriter jw, Event.MigrationOp me) throws IOException { + jw.name("evaluation"); + jw.beginObject(); + jw.name("key").value(me.getFeatureKey()); + if (me.getVariation() >= 0) { + jw.name("variation"); + jw.value(me.getVariation()); + } + if (me.getFlagVersion() >= 0) { + jw.name("version"); + jw.value(me.getFlagVersion()); + } + writeLDValue("value", me.getValue(), jw); + writeLDValue("default", me.getDefaultVal(), jw); + writeEvaluationReason(me.getReason(), jw); + jw.endObject(); + } + + private void writeSummaryEvent(EventSummarizer.EventSummary summary, JsonWriter jw) throws IOException { jw.beginObject(); jw.name("kind"); @@ -174,17 +290,17 @@ private final void writeSummaryEvent(EventSummarizer.EventSummary summary, JsonW jw.endObject(); // end of summary event object } - private final void writeKindAndCreationDate(JsonWriter jw, String kind, long creationDate) throws IOException { + private void writeKindAndCreationDate(JsonWriter jw, String kind, long creationDate) throws IOException { jw.name("kind").value(kind); jw.name("creationDate").value(creationDate); } - private final void writeContext(LDContext context, JsonWriter jw) throws IOException { + private void writeContext(LDContext context, JsonWriter jw) throws IOException { jw.name("context"); contextFormatter.write(context, jw); } - private final void writeContextKeys(LDContext context, JsonWriter jw) throws IOException { + private void writeContextKeys(LDContext context, JsonWriter jw) throws IOException { jw.name("contextKeys").beginObject(); for (int i = 0; i < context.getIndividualContextCount(); i++) { LDContext c = context.getIndividualContext(i); @@ -195,7 +311,7 @@ private final void writeContextKeys(LDContext context, JsonWriter jw) throws IOE jw.endObject(); } - private final void writeLDValue(String key, LDValue value, JsonWriter jw) throws IOException { + private void writeLDValue(String key, LDValue value, JsonWriter jw) throws IOException { if (value == null || value.isNull()) { return; } @@ -203,11 +319,11 @@ private final void writeLDValue(String key, LDValue value, JsonWriter jw) throws gsonInstance().toJson(value, LDValue.class, jw); // LDValue defines its own custom serializer } - private final void writeEvaluationReason(String key, EvaluationReason er, JsonWriter jw) throws IOException { + private void writeEvaluationReason(EvaluationReason er, JsonWriter jw) throws IOException { if (er == null) { return; } - jw.name(key); + jw.name("reason"); gsonInstance().toJson(er, EvaluationReason.class, jw); // EvaluationReason defines its own custom serializer } } diff --git a/src/main/java/com/launchdarkly/sdk/internal/events/Sampler.java b/src/main/java/com/launchdarkly/sdk/internal/events/Sampler.java new file mode 100644 index 0000000..f86e02b --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/internal/events/Sampler.java @@ -0,0 +1,28 @@ +package com.launchdarkly.sdk.internal.events; + +import java.util.concurrent.ThreadLocalRandom; + +/** + * Class used for event sampling. + */ +public final class Sampler { + /** + * Given a ratio determine if an event should be sampled. + * + * @param ratio the sampling ratio + * @return true if it should be sampled + */ + public static boolean shouldSample(long ratio) { + if(ratio == 1) { + return true; + } + if(ratio == 0) { + return false; + } + + // Checking for any number in the range will have approximately a 1 in X + // chance. So we check for 0 as it is part of any range. + // This random number is not used for cryptographic purposes. + return ThreadLocalRandom.current().nextLong(ratio) == 0; + } +} diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java index 0f894ef..d59bd98 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java @@ -203,6 +203,15 @@ public static Matcher isIdentifyEvent(Event sourceEvent, LDValue ); } + public static Matcher isMigrationEvent(Event sourceEvent, LDValue context) { + // Doesn't fully test an event, but makes sure it is a specific event. + return allOf( + jsonProperty("kind", "migration_op"), + jsonProperty("creationDate", (double)sourceEvent.getCreationDate()), + hasContextKeys(sourceEvent) + ); + } + public static Matcher isIndexEvent() { return jsonProperty("kind", "index"); } @@ -462,6 +471,8 @@ public static final class FeatureRequestEventBuilder { private String prereqOf = null; private boolean trackEvents = false; private Long debugEventsUntilDate = null; + private long samplingRatio = 1; + private boolean excludeFromSummaries = false; public FeatureRequestEventBuilder(LDContext context, String flagKey) { this.context = context; @@ -470,7 +481,8 @@ public FeatureRequestEventBuilder(LDContext context, String flagKey) { public Event.FeatureRequest build() { return new Event.FeatureRequest(timestamp, flagKey, context, flagVersion, variation, value, - defaultValue, reason, prereqOf, trackEvents, debugEventsUntilDate, false); + defaultValue, reason, prereqOf, trackEvents, debugEventsUntilDate, false, samplingRatio, + excludeFromSummaries); } public FeatureRequestEventBuilder flagVersion(int flagVersion) { @@ -512,6 +524,16 @@ public FeatureRequestEventBuilder debugEventsUntilDate(Long debugEventsUntilDate this.debugEventsUntilDate = debugEventsUntilDate; return this; } + + public FeatureRequestEventBuilder excludeFromSummaries(boolean excludeFromSummaries) { + this.excludeFromSummaries = excludeFromSummaries; + return this; + } + + public FeatureRequestEventBuilder samplingRatio(long samplingRatio) { + this.samplingRatio = samplingRatio; + return this; + } } public static final class CustomEventBuilder { diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorOutputTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorOutputTest.java index 8bbebba..55a505b 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessorOutputTest.java @@ -4,10 +4,13 @@ import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.testhelpers.JsonTestValue; import org.hamcrest.Matchers; +import org.junit.Assert; import org.junit.Test; import java.util.Date; +import java.util.List; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.allOf; @@ -93,6 +96,46 @@ public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { )); } + @Test + public void featureEventWith0SamplingRatioIsNotSampled() throws Exception { + MockEventSender es = new MockEventSender(); + Event.FeatureRequest fe = featureEvent(user, FLAG_KEY).trackEvents(true).samplingRatio(0).build(); + + EventContextDeduplicator contextDeduplicator = contextDeduplicatorThatAlwaysSaysKeysAreNew(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).contextDeduplicator(contextDeduplicator))) { + ep.sendEvent(fe); + } + + List events = es.getEventsFromLastRequest(); + assertThat(events, contains( + isIndexEvent(fe, userJson), + isSummaryEvent() + )); + // No feature event. + Assert.assertEquals(2, events.size()); + } + + @Test + public void featureEventCanBeExcludedFromSummaries() throws Exception { + MockEventSender es = new MockEventSender(); + Event.FeatureRequest fe = featureEvent(user, FLAG_KEY).trackEvents(true).excludeFromSummaries(true).build(); + + EventContextDeduplicator contextDeduplicator = contextDeduplicatorThatAlwaysSaysKeysAreNew(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).contextDeduplicator(contextDeduplicator))) { + ep.sendEvent(fe); + } + + List events = es.getEventsFromLastRequest(); + assertThat(events, contains( + isIndexEvent(fe, userJson), + isFeatureEvent(fe) + )); + // No feature event. + Assert.assertEquals(2, events.size()); + } + @SuppressWarnings("unchecked") @Test public void userIsFilteredInIndexEvent() throws Exception { @@ -379,4 +422,37 @@ public void customEventWithNullContextOrInvalidContextDoesNotCauseError() throws isCustomEvent(event3) )); } + + @Test + public void migrationEventIsQueued() throws Exception { + MockEventSender es = new MockEventSender(); + Event.MigrationOp event = new Event.MigrationOp( + 0, + user, + "migration-key", + 1, + 2, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 1, + "read", + new Event.MigrationOp.InvokedMeasurement(true, false), + null, + null, + null + ); + + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(event); + } + + List events = es.getEventsFromLastRequest(); + assertThat(events, contains( + isMigrationEvent(event, userJson) + )); + // Migration events should not trigger any other events (index, debug, etc.) + Assert.assertEquals(1, events.size()); + } } diff --git a/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java b/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java index 2168de4..c38add5 100644 --- a/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java @@ -27,7 +27,7 @@ public class EventOutputTest extends BaseEventTest { private static final Gson gson = new Gson(); - private ContextBuilder contextBuilderWithAllAttributes = LDContext.builder("userkey") + private final ContextBuilder contextBuilderWithAllAttributes = LDContext.builder("userkey") .anonymous(true) .name("me") .set("custom1", "value1") @@ -312,7 +312,311 @@ public void summaryEventIsSerialized() throws Exception { parseValue("{\"unknown\":true,\"value\":\"default3\",\"count\":1}") )); } - + + @Test + public void migrationOpEventIsSerialized() throws IOException { + LDContext context = LDContext.builder("user-key").name("me").build(); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); + + Event.MigrationOp event = new Event.MigrationOp( + 0, + context, + "migration-key", + 1, + 2, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 2, + "read", + new Event.MigrationOp.InvokedMeasurement(false, true), + new Event.MigrationOp.ConsistencyMeasurement(true, 1), + new Event.MigrationOp.LatencyMeasurement(100l, 50l), + new Event.MigrationOp.ErrorMeasurement(false, true) + ); + + LDValue received = getSingleOutputEvent(f, event); + LDValue expected = LDValue.buildObject() + .put("operation", "read") + .put("kind", "migration_op") + .put("creationDate", 0) + .put("evaluation", LDValue.buildObject() + .put("key", "migration-key") + .put("variation", 1) + .put("version", 2) + .put("value", "live") + .put("default", "off") + .put("reason", LDValue.buildObject() + .put("kind", "FALLTHROUGH") + .build()).build()) + .put("contextKeys", LDValue.buildObject() + .put("user", "user-key") + .build()) + .put("samplingRatio", 2) + .put("measurements", LDValue.buildArray() + .add(LDValue.buildObject() + .put("key", "invoked") + .put("values", LDValue.buildObject() + .put("new", true) + .build()) + .build()) + .add(LDValue.buildObject() + .put("key", "consistent") + .put("value", true) + .build()) + .add(LDValue.buildObject() + .put("key", "latency_ms") + .put("values", LDValue.buildObject() + .put("old", 100) + .put("new", 50) + .build()) + .build()) + .add(LDValue.buildObject() + .put("key", "error") + .put("values", LDValue.buildObject() + .put("new", true) + .build()) + .build()) + .build()) + .build(); + + assertJsonEquals(expected, received); + } + + @Test + public void migrationOpEventSerializationCanExcludeOptionalItems() throws IOException { + LDContext context = LDContext.builder("user-key").name("me").build(); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); + + Event.MigrationOp event = new Event.MigrationOp( + 0, + context, + "migration-key", + -1, + -1, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 1, + "read", + new Event.MigrationOp.InvokedMeasurement(true, false), + null, + null, + null + ); + + LDValue received1 = getSingleOutputEvent(f, event); + Event.MigrationOp event2 = new Event.MigrationOp( + 0, + context, + "migration-key", + -1, + -1, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 1, + "read", + new Event.MigrationOp.InvokedMeasurement(true, false), + null, + // Null measurement, versus a measurement containing no values, should behave the same. + new Event.MigrationOp.LatencyMeasurement(null, null), + new Event.MigrationOp.ErrorMeasurement(false, false) + ); + LDValue received2 = getSingleOutputEvent(f, event2); + + LDValue expected = LDValue.buildObject() + .put("operation", "read") + .put("kind", "migration_op") + .put("creationDate", 0) + .put("evaluation", LDValue.buildObject() + .put("key", "migration-key") + .put("value", "live") + .put("default", "off") + .put("reason", LDValue.buildObject() + .put("kind", "FALLTHROUGH") + .build()).build()) + .put("contextKeys", LDValue.buildObject() + .put("user", "user-key") + .build()) + .put("measurements", LDValue.buildArray() + .add(LDValue.buildObject() + .put("key", "invoked") + .put("values", LDValue.buildObject() + .put("old", true) + .build()) + .build()) + .build()) + .build(); + + assertJsonEquals(expected, received1); + assertJsonEquals(expected, received2); + } + + @Test + public void migrationOpEventCanSerializeDifferentLatencyPermutations() throws IOException { + LDContext context = LDContext.builder("user-key").name("me").build(); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); + + Event.MigrationOp event1 = new Event.MigrationOp( + 0, + context, + "migration-key", + 1, + 2, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 2, + "read", + new Event.MigrationOp.InvokedMeasurement(false, true), + null, + new Event.MigrationOp.LatencyMeasurement(null, 50l), + null + ); + + LDValue received1 = getSingleOutputEvent(f, event1); + assertJsonEquals(LDValue.buildObject() + .put("key", "latency_ms") + .put("values", LDValue.buildObject() + .put("new", 50) + .build()) + .build(), received1.get("measurements").get(1)); + + Event.MigrationOp event2 = new Event.MigrationOp( + 0, + context, + "migration-key", + 1, + 2, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 2, + "read", + new Event.MigrationOp.InvokedMeasurement(false, true), + null, + new Event.MigrationOp.LatencyMeasurement(50l, null), + null + ); + + LDValue received2 = getSingleOutputEvent(f, event2); + assertJsonEquals(LDValue.buildObject() + .put("key", "latency_ms") + .put("values", LDValue.buildObject() + .put("old", 50) + .build()) + .build(), received2.get("measurements").get(1)); + + Event.MigrationOp event3 = new Event.MigrationOp( + 0, + context, + "migration-key", + 1, + 2, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 2, + "read", + new Event.MigrationOp.InvokedMeasurement(false, true), + null, + new Event.MigrationOp.LatencyMeasurement(50l, 150l), + null + ); + + LDValue received3 = getSingleOutputEvent(f, event3); + assertJsonEquals(LDValue.buildObject() + .put("key", "latency_ms") + .put("values", LDValue.buildObject() + .put("old", 50) + .put("new", 150) + .build()) + .build(), received3.get("measurements").get(1)); + } + + @Test + public void migrationOpEventCanSerializeDifferentErrorPermutations() throws IOException { + LDContext context = LDContext.builder("user-key").name("me").build(); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); + + Event.MigrationOp event1 = new Event.MigrationOp( + 0, + context, + "migration-key", + 1, + 2, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 2, + "read", + new Event.MigrationOp.InvokedMeasurement(false, true), + null, + null, + new Event.MigrationOp.ErrorMeasurement(true, false) + ); + + LDValue received1 = getSingleOutputEvent(f, event1); + assertJsonEquals(LDValue.buildObject() + .put("key", "error") + .put("values", LDValue.buildObject() + .put("old", true) + .build()) + .build(), received1.get("measurements").get(1)); + + Event.MigrationOp event2 = new Event.MigrationOp( + 0, + context, + "migration-key", + 1, + 2, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 2, + "read", + new Event.MigrationOp.InvokedMeasurement(false, true), + null, + null, + new Event.MigrationOp.ErrorMeasurement(false, true) + ); + + LDValue received2 = getSingleOutputEvent(f, event2); + assertJsonEquals(LDValue.buildObject() + .put("key", "error") + .put("values", LDValue.buildObject() + .put("new", true) + .build()) + .build(), received2.get("measurements").get(1)); + + Event.MigrationOp event3 = new Event.MigrationOp( + 0, + context, + "migration-key", + 1, + 2, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 2, + "read", + new Event.MigrationOp.InvokedMeasurement(false, true), + null, + null, + new Event.MigrationOp.ErrorMeasurement(true, true) + ); + + LDValue received3 = getSingleOutputEvent(f, event3); + assertJsonEquals(LDValue.buildObject() + .put("key", "error") + .put("values", LDValue.buildObject() + .put("old", true) + .put("new", true) + .build()) + .build(), received3.get("measurements").get(1)); + } + @Test public void unknownEventClassIsNotSerialized() throws Exception { // This shouldn't be able to happen in reality.