All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.launchdarkly.sdk.server.LDClient Maven / Gradle / Ivy

There is a newer version: 7.6.0
Show newest version
package com.launchdarkly.sdk.server;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import com.launchdarkly.logging.LDLogger;
import com.launchdarkly.logging.LogValues;
import com.launchdarkly.sdk.EvaluationDetail;
import com.launchdarkly.sdk.LDContext;
import com.launchdarkly.sdk.LDValue;
import com.launchdarkly.sdk.LDValueType;
import com.launchdarkly.sdk.internal.http.HttpHelpers;
import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider;
import com.launchdarkly.sdk.server.interfaces.BigSegmentsConfiguration;
import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider;
import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider;
import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent;
import com.launchdarkly.sdk.server.interfaces.FlagChangeListener;
import com.launchdarkly.sdk.server.interfaces.FlagTracker;
import com.launchdarkly.sdk.server.interfaces.LDClientInterface;
import com.launchdarkly.sdk.server.subsystems.DataSource;
import com.launchdarkly.sdk.server.subsystems.DataSourceUpdateSink;
import com.launchdarkly.sdk.server.subsystems.DataStore;
import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor;
import com.launchdarkly.sdk.server.subsystems.EventProcessor;
import org.apache.commons.codec.binary.Hex;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import static com.google.common.base.Preconditions.checkNotNull;
import static com.launchdarkly.sdk.server.DataModel.FEATURES;
import static com.launchdarkly.sdk.server.DataModel.SEGMENTS;

/**
 * A client for the LaunchDarkly API. Client instances are thread-safe. Applications should instantiate
 * a single {@code LDClient} for the lifetime of their application.
 */
public final class LDClient implements LDClientInterface {
  private static final String HMAC_ALGORITHM = "HmacSHA256";

  private final String sdkKey;
  private final boolean offline;
  @VisibleForTesting
  final EvaluatorInterface evaluator;
  final EvaluatorInterface migrationEvaluator;
  final EventProcessor eventProcessor;
  final DataSource dataSource;
  final DataStore dataStore;
  private final BigSegmentStoreStatusProvider bigSegmentStoreStatusProvider;
  private final BigSegmentStoreWrapper bigSegmentStoreWrapper;
  private final DataSourceUpdateSink dataSourceUpdates;
  private final DataStoreStatusProviderImpl dataStoreStatusProvider;
  private final DataSourceStatusProviderImpl dataSourceStatusProvider;
  private final FlagTrackerImpl flagTracker;
  private final EventBroadcasterImpl flagChangeBroadcaster;
  private final ScheduledExecutorService sharedExecutor;
  private final LDLogger baseLogger;
  private final LDLogger evaluationLogger;

  private static final int EXCESSIVE_INIT_WAIT_MILLIS = 60000;

  /**
   * Creates a new client instance that connects to LaunchDarkly with the default configuration.
   * 

* If you need to specify any custom SDK options, use {@link LDClient#LDClient(String, LDConfig)} * instead. *

* Applications should instantiate a single instance for the lifetime of the application. In * unusual cases where an application needs to evaluate feature flags from different LaunchDarkly * projects or environments, you may create multiple clients, but they should still be retained * for the lifetime of the application rather than created per request or per thread. *

* The client will begin attempting to connect to LaunchDarkly as soon as you call the constructor. * The constructor will return when it successfully connects, or when the default timeout of 5 seconds * expires, whichever comes first. If it has not succeeded in connecting when the timeout elapses, * you will receive the client in an uninitialized state where feature flags will return default * values; it will still continue trying to connect in the background. You can detect whether * initialization has succeeded by calling {@link #isInitialized()}. If you prefer to customize * this behavior, use {@link LDClient#LDClient(String, LDConfig)} instead. *

* For rules regarding the throwing of unchecked exceptions for error conditions, see * {@link LDClient#LDClient(String, LDConfig)}. * * @param sdkKey the SDK key for your LaunchDarkly environment * @throws IllegalArgumentException if a parameter contained a grossly malformed value; * for security reasons, in case of an illegal SDK key, the exception message does * not include the key * @throws NullPointerException if a non-nullable parameter was null * @see LDClient#LDClient(String, LDConfig) */ public LDClient(String sdkKey) { // COVERAGE: this constructor cannot be called in unit tests because it uses the default base // URI and will attempt to make a live connection to LaunchDarkly. this(sdkKey, LDConfig.DEFAULT); } private static DataModel.FeatureFlag getFlag(DataStore store, String key) { ItemDescriptor item = store.get(FEATURES, key); return item == null ? null : (DataModel.FeatureFlag) item.getItem(); } private static DataModel.Segment getSegment(DataStore store, String key) { ItemDescriptor item = store.get(SEGMENTS, key); return item == null ? null : (DataModel.Segment) item.getItem(); } /** * Creates a new client to connect to LaunchDarkly with a custom configuration. *

* This constructor can be used to configure advanced SDK features; see {@link LDConfig.Builder}. *

* Applications should instantiate a single instance for the lifetime of the application. In * unusual cases where an application needs to evaluate feature flags from different LaunchDarkly * projects or environments, you may create multiple clients, but they should still be retained * for the lifetime of the application rather than created per request or per thread. *

* Unless it is configured to be offline with {@link LDConfig.Builder#offline(boolean)} or * {@link Components#externalUpdatesOnly()}, the client will begin attempting to connect to * LaunchDarkly as soon as you call the constructor. The constructor will return when it successfully * connects, or when the timeout set by {@link LDConfig.Builder#startWait(java.time.Duration)} (default: * 5 seconds) expires, whichever comes first. If it has not succeeded in connecting when the timeout * elapses, you will receive the client in an uninitialized state where feature flags will return * default values; it will still continue trying to connect in the background. You can detect * whether initialization has succeeded by calling {@link #isInitialized()}. *

* If you prefer to have the constructor return immediately, and then wait for initialization to finish * at some other point, you can use {@link #getDataSourceStatusProvider()} as follows: *


   *     LDConfig config = new LDConfig.Builder()
   *         .startWait(Duration.ZERO)
   *         .build();
   *     LDClient client = new LDClient(sdkKey, config);
   *
   *     // later, when you want to wait for initialization to finish:
   *     boolean inited = client.getDataSourceStatusProvider().waitFor(
   *         DataSourceStatusProvider.State.VALID, Duration.ofSeconds(10));
   *     if (!inited) {
   *         // do whatever is appropriate if initialization has timed out
   *     }
   * 
*

* This constructor can throw unchecked exceptions if it is immediately apparent that * the SDK cannot work with these parameters. For instance, if the SDK key contains a * non-printable character that cannot be used in an HTTP header, it will throw an * {@link IllegalArgumentException} since the SDK key is normally sent to LaunchDarkly * in an HTTP header and no such value could possibly be valid. Similarly, a null * value for a non-nullable parameter may throw a {@link NullPointerException}. The * constructor will not throw an exception for any error condition that could only be * detected after making a request to LaunchDarkly (such as an SDK key that is simply * wrong despite being valid ASCII, so it is invalid but not illegal); those are logged * and treated as an unsuccessful initialization, as described above. * * @param sdkKey the SDK key for your LaunchDarkly environment * @param config a client configuration object * @throws IllegalArgumentException if a parameter contained a grossly malformed value; * for security reasons, in case of an illegal SDK key, the exception message does * not include the key * @throws NullPointerException if a non-nullable parameter was null * @see LDClient#LDClient(String, LDConfig) */ public LDClient(String sdkKey, LDConfig config) { checkNotNull(config, "config must not be null"); this.sdkKey = checkNotNull(sdkKey, "sdkKey must not be null"); if (!HttpHelpers.isAsciiHeaderValue(sdkKey)) { throw new IllegalArgumentException("SDK key contained an invalid character"); } this.offline = config.offline; this.sharedExecutor = createSharedExecutor(config); final ClientContextImpl context = ClientContextImpl.fromConfig( sdkKey, config, sharedExecutor ); this.baseLogger = context.getBaseLogger(); this.evaluationLogger = this.baseLogger.subLogger(Loggers.EVALUATION_LOGGER_NAME); this.eventProcessor = config.events.build(context); EventBroadcasterImpl bigSegmentStoreStatusNotifier = EventBroadcasterImpl.forBigSegmentStoreStatus(sharedExecutor, baseLogger); BigSegmentsConfiguration bigSegmentsConfig = config.bigSegments.build(context); if (bigSegmentsConfig.getStore() != null) { bigSegmentStoreWrapper = new BigSegmentStoreWrapper(bigSegmentsConfig, bigSegmentStoreStatusNotifier, sharedExecutor, this.baseLogger.subLogger(Loggers.BIG_SEGMENTS_LOGGER_NAME)); } else { bigSegmentStoreWrapper = null; } bigSegmentStoreStatusProvider = new BigSegmentStoreStatusProviderImpl(bigSegmentStoreStatusNotifier, bigSegmentStoreWrapper); EventBroadcasterImpl dataStoreStatusNotifier = EventBroadcasterImpl.forDataStoreStatus(sharedExecutor, baseLogger); DataStoreUpdatesImpl dataStoreUpdates = new DataStoreUpdatesImpl(dataStoreStatusNotifier); this.dataStore = config.dataStore.build(context.withDataStoreUpdateSink(dataStoreUpdates)); EvaluatorInterface evaluator = new InputValidatingEvaluator(dataStore, bigSegmentStoreWrapper, eventProcessor, evaluationLogger); // decorate evaluator with hooks if hooks were provided if (config.hooks.getHooks().isEmpty()) { this.evaluator = evaluator; this.migrationEvaluator = new MigrationStageEnforcingEvaluator(evaluator, evaluationLogger); } else { this.evaluator = new EvaluatorWithHooks(evaluator, config.hooks.getHooks(), this.baseLogger.subLogger(Loggers.HOOKS_LOGGER_NAME)); this.migrationEvaluator = new EvaluatorWithHooks(new MigrationStageEnforcingEvaluator(evaluator, evaluationLogger), config.hooks.getHooks(), this.baseLogger.subLogger(Loggers.HOOKS_LOGGER_NAME)); } this.flagChangeBroadcaster = EventBroadcasterImpl.forFlagChangeEvents(sharedExecutor, baseLogger); this.flagTracker = new FlagTrackerImpl(flagChangeBroadcaster, (key, ctx) -> jsonValueVariation(key, ctx, LDValue.ofNull())); this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(this.dataStore, dataStoreUpdates); EventBroadcasterImpl dataSourceStatusNotifier = EventBroadcasterImpl.forDataSourceStatus(sharedExecutor, baseLogger); DataSourceUpdatesImpl dataSourceUpdates = new DataSourceUpdatesImpl( dataStore, dataStoreStatusProvider, flagChangeBroadcaster, dataSourceStatusNotifier, sharedExecutor, context.getLogging().getLogDataSourceOutageAsErrorAfter(), baseLogger ); this.dataSourceUpdates = dataSourceUpdates; this.dataSource = config.dataSource.build(context.withDataSourceUpdateSink(dataSourceUpdates)); this.dataSourceStatusProvider = new DataSourceStatusProviderImpl(dataSourceStatusNotifier, dataSourceUpdates); Future startFuture = dataSource.start(); if (!config.startWait.isZero() && !config.startWait.isNegative()) { if (!(dataSource instanceof ComponentsImpl.NullDataSource)) { baseLogger.info("Waiting up to {} milliseconds for LaunchDarkly client to start...", config.startWait.toMillis()); if (config.startWait.toMillis() > EXCESSIVE_INIT_WAIT_MILLIS) { baseLogger.warn("LaunchDarkly client created with start wait time of {} milliseconds. We recommend a timeout of less than {} milliseconds.", config.startWait.toMillis(), EXCESSIVE_INIT_WAIT_MILLIS); } } try { startFuture.get(config.startWait.toMillis(), TimeUnit.MILLISECONDS); } catch (TimeoutException e) { baseLogger.error("Timeout encountered waiting for LaunchDarkly client initialization"); } catch (Exception e) { baseLogger.error("Exception encountered waiting for LaunchDarkly client initialization: {}", LogValues.exceptionSummary(e)); baseLogger.debug("{}", LogValues.exceptionTrace(e)); } if (!dataSource.isInitialized()) { baseLogger.warn("LaunchDarkly client was not successfully initialized"); } } } @Override public boolean isInitialized() { return dataSource.isInitialized(); } @Override public void track(String eventName, LDContext context) { trackData(eventName, context, LDValue.ofNull()); } @Override public void trackMigration(MigrationOpTracker tracker) { eventProcessor.recordMigrationEvent(tracker); } @Override public void trackData(String eventName, LDContext context, LDValue data) { if (context == null) { baseLogger.warn("Track called with null context!"); } else if (!context.isValid()) { baseLogger.warn("Track called with invalid context: " + context.getError()); } else { eventProcessor.recordCustomEvent(context, eventName, data, null); } } @Override public void trackMetric(String eventName, LDContext context, LDValue data, double metricValue) { if (context == null) { baseLogger.warn("Track called with null context!"); } else if (!context.isValid()) { baseLogger.warn("Track called with invalid context: " + context.getError()); } else { eventProcessor.recordCustomEvent(context, eventName, data, metricValue); } } @Override public void identify(LDContext context) { if (context == null) { baseLogger.warn("Identify called with null context!"); } else if (!context.isValid()) { baseLogger.warn("Identify called with invalid context: " + context.getError()); } else { eventProcessor.recordIdentifyEvent(context); } } @Override public FeatureFlagsState allFlagsState(LDContext context, FlagsStateOption... options) { if (isOffline()) { evaluationLogger.debug("allFlagsState() was called when client is in offline mode."); } return evaluator.allFlagsState(context, options); } @Override public boolean boolVariation(String featureKey, LDContext context, boolean defaultValue) { return evaluator.evalAndFlag("LDClient.boolVariation", featureKey, context, LDValue.of(defaultValue), LDValueType.BOOLEAN, EvaluationOptions.EVENTS_WITHOUT_REASONS).getResult().getValue().booleanValue(); } @Override public int intVariation(String featureKey, LDContext context, int defaultValue) { return evaluator.evalAndFlag("LDClient.intVariation", featureKey, context, LDValue.of(defaultValue), LDValueType.NUMBER, EvaluationOptions.EVENTS_WITHOUT_REASONS).getResult().getValue().intValue(); } @Override public double doubleVariation(String featureKey, LDContext context, double defaultValue) { return evaluator.evalAndFlag("LDClient.doubleVariation", featureKey, context, LDValue.of(defaultValue), LDValueType.NUMBER, EvaluationOptions.EVENTS_WITHOUT_REASONS).getResult().getValue().doubleValue(); } @Override public String stringVariation(String featureKey, LDContext context, String defaultValue) { return evaluator.evalAndFlag("LDClient.stringVariation", featureKey, context, LDValue.of(defaultValue), LDValueType.STRING, EvaluationOptions.EVENTS_WITHOUT_REASONS).getResult().getValue().stringValue(); } @Override public LDValue jsonValueVariation(String featureKey, LDContext context, LDValue defaultValue) { return evaluator.evalAndFlag("LDClient.jsonValueVariation", featureKey, context, LDValue.normalize(defaultValue), null, EvaluationOptions.EVENTS_WITHOUT_REASONS).getResult().getValue(); } @Override public EvaluationDetail boolVariationDetail(String featureKey, LDContext context, boolean defaultValue) { return evaluator.evalAndFlag("LDClient.boolVariationDetail", featureKey, context, LDValue.of(defaultValue), LDValueType.BOOLEAN, EvaluationOptions.EVENTS_WITH_REASONS).getResult().getAsBoolean(); } @Override public EvaluationDetail intVariationDetail(String featureKey, LDContext context, int defaultValue) { return evaluator.evalAndFlag("LDClient.intVariationDetail", featureKey, context, LDValue.of(defaultValue), LDValueType.NUMBER, EvaluationOptions.EVENTS_WITH_REASONS).getResult().getAsInteger(); } @Override public EvaluationDetail doubleVariationDetail(String featureKey, LDContext context, double defaultValue) { return evaluator.evalAndFlag("LDClient.doubleVariationDetail", featureKey, context, LDValue.of(defaultValue), LDValueType.NUMBER, EvaluationOptions.EVENTS_WITH_REASONS).getResult().getAsDouble(); } @Override public EvaluationDetail stringVariationDetail(String featureKey, LDContext context, String defaultValue) { return evaluator.evalAndFlag("LDClient.stringVariationDetail", featureKey, context, LDValue.of(defaultValue), LDValueType.STRING, EvaluationOptions.EVENTS_WITH_REASONS).getResult().getAsString(); } @Override public EvaluationDetail jsonValueVariationDetail(String featureKey, LDContext context, LDValue defaultValue) { return evaluator.evalAndFlag("LDClient.jsonValueVariationDetail", featureKey, context, LDValue.normalize(defaultValue), null, EvaluationOptions.EVENTS_WITH_REASONS).getResult().getAnyType(); } @Override public MigrationVariation migrationVariation(String key, LDContext context, MigrationStage defaultStage) { // The migration evaluator is decorated with logic that will enforce the result is for a recognized migration // stage or an error result is returned with the default stage value. This decorator was added as part of // the Hooks implementation to ensure that the Hook would be given the result after that migration stage // enforcement. EvalResultAndFlag res = migrationEvaluator.evalAndFlag("LDClient.migrationVariation", key, context, LDValue.of(defaultStage.toString()), LDValueType.STRING, EvaluationOptions.EVENTS_WITHOUT_REASONS); // since evaluation result inner types are boxed primitives, it is necessary to still make this mapping to the // MigrationState type. EvaluationDetail resDetail = res.getResult().getAsString(); MigrationStage stageChecked = MigrationStage.of(resDetail.getValue(), defaultStage); long checkRatio = 1; if (res.getFlag() != null && res.getFlag().getMigration() != null && res.getFlag().getMigration().getCheckRatio() != null) { checkRatio = res.getFlag().getMigration().getCheckRatio(); } MigrationOpTracker tracker = new MigrationOpTracker(key, res.getFlag(), resDetail, defaultStage, stageChecked, context, checkRatio, baseLogger); return new MigrationVariation(stageChecked, tracker); } @Override public boolean isFlagKnown(String featureKey) { if (!isInitialized()) { if (dataStore.isInitialized()) { baseLogger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); } else { baseLogger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; data store unavailable, returning false", featureKey); return false; } } try { if (getFlag(dataStore, featureKey) != null) { return true; } } catch (Exception e) { baseLogger.error("Encountered exception while calling isFlagKnown for feature flag \"{}\": {}", featureKey, LogValues.exceptionSummary(e)); baseLogger.debug("{}", LogValues.exceptionTrace(e)); } return false; } @Override public FlagTracker getFlagTracker() { return flagTracker; } @Override public BigSegmentStoreStatusProvider getBigSegmentStoreStatusProvider() { return bigSegmentStoreStatusProvider; } @Override public DataStoreStatusProvider getDataStoreStatusProvider() { return dataStoreStatusProvider; } @Override public LDLogger getLogger() { return baseLogger; } @Override public DataSourceStatusProvider getDataSourceStatusProvider() { return dataSourceStatusProvider; } /** * Shuts down the client and releases any resources it is using. *

* Unless it is offline, the client will attempt to deliver any pending analytics events before * closing. */ @Override public void close() throws IOException { baseLogger.info("Closing LaunchDarkly Client"); this.dataStore.close(); this.eventProcessor.close(); this.dataSource.close(); this.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.OFF, null); if (this.bigSegmentStoreWrapper != null) { this.bigSegmentStoreWrapper.close(); } this.sharedExecutor.shutdownNow(); } @Override public void flush() { this.eventProcessor.flush(); } @Override public boolean isOffline() { return offline; } @Override public String secureModeHash(LDContext context) { if (context == null || !context.isValid()) { return null; } try { Mac mac = Mac.getInstance(HMAC_ALGORITHM); mac.init(new SecretKeySpec(sdkKey.getBytes(), HMAC_ALGORITHM)); return Hex.encodeHexString(mac.doFinal(context.getFullyQualifiedKey().getBytes("UTF8"))); } catch (InvalidKeyException | UnsupportedEncodingException | NoSuchAlgorithmException e) { // COVERAGE: there is no way to cause these errors in a unit test. baseLogger.error("Could not generate secure mode hash: {}", LogValues.exceptionSummary(e)); baseLogger.debug("{}", LogValues.exceptionTrace(e)); } return null; } /** * Returns the current version string of the client library. * * @return a version string conforming to Semantic Versioning (http://semver.org) */ @Override public String version() { return Version.SDK_VERSION; } // This executor is used for a variety of SDK tasks such as flag change events, checking the data store // status after an outage, and the poll task in polling mode. These are all tasks that we do not expect // to be executing frequently so that it is acceptable to use a single thread to execute them one at a // time rather than a thread pool, thus reducing the number of threads spawned by the SDK. This also // has the benefit of producing predictable delivery order for event listener notifications. private ScheduledExecutorService createSharedExecutor(LDConfig config) { ThreadFactory threadFactory = new ThreadFactoryBuilder() .setDaemon(true) .setNameFormat("LaunchDarkly-tasks-%d") .setPriority(config.threadPriority) .build(); return Executors.newSingleThreadScheduledExecutor(threadFactory); } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy