diff --git a/openfeature-provider-local/README.md b/openfeature-provider-local/README.md new file mode 100644 index 00000000..5b136c9d --- /dev/null +++ b/openfeature-provider-local/README.md @@ -0,0 +1,95 @@ +# Confidence OpenFeature Local Provider + +![Status: Experimental](https://img.shields.io/badge/status-experimental-orange) + +A high-performance OpenFeature provider for [Confidence](https://confidence.spotify.com/) feature flags that evaluates flags locally for minimal latency. + +## Features + +- **Local Resolution**: Evaluates feature flags locally using WebAssembly (WASM) or pure Java +- **Low Latency**: No network calls during flag evaluation +- **Automatic Sync**: Periodically syncs flag configurations from Confidence +- **Exposure Logging**: Fully supported exposure logging (and other resolve analytics) +- **OpenFeature Compatible**: Works with the standard OpenFeature SDK + +## Installation + +Add this dependency to your `pom.xml`: + +```xml + + com.spotify.confidence + openfeature-provider-local + 0.2.4 + +``` + +## Quick Start + +```java +import com.spotify.confidence.ApiSecret; +import com.spotify.confidence.OpenFeatureLocalResolveProvider; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.Client; + +// Create API credentials +ApiSecret apiSecret = new ApiSecret("your-client-id", "your-client-secret"); +String clientSecret = "your-application-client-secret"; + +// Create and register the provider +OpenFeatureLocalResolveProvider provider = + new OpenFeatureLocalResolveProvider(apiSecret, clientSecret); +OpenFeatureAPI.getInstance().setProvider(provider); + +// Use OpenFeature client +Client client = OpenFeatureAPI.getInstance().getClient(); +String value = client.getStringValue("my-flag", "default-value"); +``` + +## Configuration + +### Resolution Modes + +The provider supports two resolution modes: + +- **WASM mode** (default): Uses WebAssembly resolver +- **Java mode**: Pure Java implementation of the resolver + +Control the mode with the `LOCAL_RESOLVE_MODE` environment variable: + +```bash +# Force WASM mode +export LOCAL_RESOLVE_MODE=WASM + +# Force Java mode +export LOCAL_RESOLVE_MODE=JAVA +``` + +### Exposure Logging + +Enable or disable exposure logging: + +```java +// Enable exposure logging (default) +new OpenFeatureLocalResolveProvider(apiSecret, clientSecret, true); + +// Disable exposure logging +new OpenFeatureLocalResolveProvider(apiSecret, clientSecret, false); +``` + +## Credentials + +You need two types of credentials: + +1. **API Secret** (`ApiSecret`): For authenticating with the Confidence API + - Contains `clientId` and `clientSecret` for your Confidence application + +2. **Client Secret** (`String`): For flag resolution authentication + - Application-specific secret for flag evaluation + +Both can be obtained from your Confidence dashboard. + +## Requirements + +- Java 17+ +- OpenFeature SDK 1.6.1+ \ No newline at end of file diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/ApiSecret.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/ApiSecret.java index f30fdb5d..79214fd1 100644 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/ApiSecret.java +++ b/openfeature-provider-local/src/main/java/com/spotify/confidence/ApiSecret.java @@ -1,3 +1,13 @@ package com.spotify.confidence; +/** + * API credentials for authenticating with the Confidence service. + * + *

This record holds the client ID and client secret used to authenticate with the Confidence API + * for administrative operations like fetching flag configurations and logging exposure events. + * + * @param clientId the client ID for your Confidence application + * @param clientSecret the client secret for your Confidence application + * @since 0.2.4 + */ public record ApiSecret(String clientId, String clientSecret) {} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/LocalResolverServiceFactory.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/LocalResolverServiceFactory.java index ef5a99ed..863411ac 100644 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/LocalResolverServiceFactory.java +++ b/openfeature-provider-local/src/main/java/com/spotify/confidence/LocalResolverServiceFactory.java @@ -37,6 +37,7 @@ class LocalResolverServiceFactory implements ResolverServiceFactory { private final Supplier resolveIdSupplier; private final ResolveLogger resolveLogger; private final AssignLogger assignLogger; + private final boolean enableExposureLogs; private static final MetricRegistry metricRegistry = new MetricRegistry(); private static final String CONFIDENCE_DOMAIN = "edge-grpc.spotify.com"; private static final Duration ASSIGN_LOG_INTERVAL = Duration.ofSeconds(10); @@ -59,11 +60,16 @@ private static ManagedChannel createConfidenceChannel() { } static FlagResolverService from(ApiSecret apiSecret, String clientSecret, boolean isWasm) { - return createFlagResolverService(apiSecret, clientSecret, isWasm); + return createFlagResolverService(apiSecret, clientSecret, isWasm, true); + } + + static FlagResolverService from( + ApiSecret apiSecret, String clientSecret, boolean isWasm, boolean enableExposureLogs) { + return createFlagResolverService(apiSecret, clientSecret, isWasm, enableExposureLogs); } private static FlagResolverService createFlagResolverService( - ApiSecret apiSecret, String clientSecret, boolean isWasm) { + ApiSecret apiSecret, String clientSecret, boolean isWasm, boolean enableExposureLogs) { final var channel = createConfidenceChannel(); final AuthServiceGrpc.AuthServiceBlockingStub authService = AuthServiceGrpc.newBlockingStub(channel); @@ -97,7 +103,7 @@ private static FlagResolverService createFlagResolverService( flagLoggerStub, ASSIGN_LOG_INTERVAL, metricRegistry, assignLogCapacity); final ResolveLogger resolveLogger = ResolveLogger.createStarted(() -> flagsAdminStub, RESOLVE_INFO_LOG_INTERVAL); - final var flagLogger = getFlagLogger(resolveLogger, assignLogger); + final var flagLogger = getFlagLogger(resolveLogger, assignLogger, enableExposureLogs); if (isWasm) { final SwapWasmResolverApi wasmResolverApi = new SwapWasmResolverApi( @@ -116,7 +122,8 @@ private static FlagResolverService createFlagResolverService( sidecarFlagsAdminFetcher.stateHolder(), resolveTokenConverter, resolveLogger, - assignLogger) + assignLogger, + enableExposureLogs) .create(clientSecret); } else { flagsFetcherExecutor.scheduleWithFixedDelay( @@ -128,11 +135,30 @@ private static FlagResolverService createFlagResolverService( sidecarFlagsAdminFetcher.stateHolder(), resolveTokenConverter, resolveLogger, - assignLogger) + assignLogger, + enableExposureLogs) .create(clientSecret); } } + LocalResolverServiceFactory( + SwapWasmResolverApi wasmResolveApi, + AtomicReference resolverStateHolder, + ResolveTokenConverter resolveTokenConverter, + ResolveLogger resolveLogger, + AssignLogger assignLogger, + boolean enableExposureLogs) { + this( + wasmResolveApi, + resolverStateHolder, + resolveTokenConverter, + Instant::now, + () -> RandomStringUtils.randomAlphanumeric(32), + resolveLogger, + assignLogger, + enableExposureLogs); + } + LocalResolverServiceFactory( SwapWasmResolverApi wasmResolveApi, AtomicReference resolverStateHolder, @@ -146,7 +172,25 @@ private static FlagResolverService createFlagResolverService( Instant::now, () -> RandomStringUtils.randomAlphanumeric(32), resolveLogger, - assignLogger); + assignLogger, + true); + } + + LocalResolverServiceFactory( + AtomicReference resolverStateHolder, + ResolveTokenConverter resolveTokenConverter, + ResolveLogger resolveLogger, + AssignLogger assignLogger, + boolean enableExposureLogs) { + this( + null, + resolverStateHolder, + resolveTokenConverter, + Instant::now, + () -> RandomStringUtils.randomAlphanumeric(32), + resolveLogger, + assignLogger, + enableExposureLogs); } LocalResolverServiceFactory( @@ -161,7 +205,8 @@ private static FlagResolverService createFlagResolverService( Instant::now, () -> RandomStringUtils.randomAlphanumeric(32), resolveLogger, - assignLogger); + assignLogger, + true); } LocalResolverServiceFactory( @@ -171,7 +216,8 @@ private static FlagResolverService createFlagResolverService( Supplier timeSupplier, Supplier resolveIdSupplier, ResolveLogger resolveLogger, - AssignLogger assignLogger) { + AssignLogger assignLogger, + boolean enableExposureLogs) { this.wasmResolveApi = wasmResolveApi; this.resolverStateHolder = resolverStateHolder; this.resolveTokenConverter = resolveTokenConverter; @@ -179,6 +225,7 @@ private static FlagResolverService createFlagResolverService( this.resolveIdSupplier = resolveIdSupplier; this.resolveLogger = resolveLogger; this.assignLogger = assignLogger; + this.enableExposureLogs = enableExposureLogs; } @VisibleForTesting @@ -207,7 +254,7 @@ private FlagResolverService createJavaFlagResolverService( } final AccountState accountState = state.accountStates().get(accountClient.accountName()); - final var flagLogger = getFlagLogger(resolveLogger, assignLogger); + final var flagLogger = getFlagLogger(resolveLogger, assignLogger, enableExposureLogs); return new JavaFlagResolverService( accountState, @@ -218,7 +265,31 @@ private FlagResolverService createJavaFlagResolverService( resolveIdSupplier); } - private static FlagLogger getFlagLogger(ResolveLogger resolveLogger, AssignLogger assignLogger) { + private static FlagLogger getFlagLogger( + ResolveLogger resolveLogger, AssignLogger assignLogger, boolean enableExposureLogs) { + if (!enableExposureLogs) { + return new FlagLogger() { + @Override + public void logResolve( + String resolveId, + Struct evaluationContext, + Sdk sdk, + AccountClient accountClient, + List values) { + // Logging disabled - no-op + } + + @Override + public void logAssigns( + String resolveId, + Sdk sdk, + List flagsToApply, + AccountClient accountClient) { + // Logging disabled - no-op + } + }; + } + return new FlagLogger() { @Override public void logResolve( diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/OpenFeatureLocalResolveProvider.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/OpenFeatureLocalResolveProvider.java index 1c8deb15..295e7d63 100644 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/OpenFeatureLocalResolveProvider.java +++ b/openfeature-provider-local/src/main/java/com/spotify/confidence/OpenFeatureLocalResolveProvider.java @@ -18,6 +18,49 @@ import java.util.function.Function; import org.slf4j.Logger; +/** + * OpenFeature provider for Confidence feature flags using local resolution. + * + *

This provider evaluates feature flags locally using either a WebAssembly (WASM) resolver or a + * pure Java implementation. It periodically syncs flag configurations from the Confidence service + * and caches them locally for fast, low-latency flag evaluation. + * + *

The provider supports two resolution modes: + * + *

+ * + *

Resolution mode can be controlled via the {@code LOCAL_RESOLVE_MODE} environment variable: + * + *

+ * + *

Usage Example: + * + *

{@code
+ * // Create API credentials
+ * ApiSecret apiSecret = new ApiSecret("your-client-id", "your-client-secret");
+ * String clientSecret = "your-application-client-secret";
+ *
+ * // Create provider with default settings (exposure logs enabled)
+ * OpenFeatureLocalResolveProvider provider =
+ *     new OpenFeatureLocalResolveProvider(apiSecret, clientSecret);
+ *
+ * // Register with OpenFeature
+ * OpenFeatureAPI.getInstance().setProvider(provider);
+ *
+ * // Use with OpenFeature client
+ * Client client = OpenFeatureAPI.getInstance().getClient();
+ * String flagValue = client.getStringValue("my-flag", "default-value");
+ * }
+ * + * @since 0.2.4 + */ @Experimental public class OpenFeatureLocalResolveProvider implements FeatureProvider { private final String clientSecret; @@ -25,12 +68,55 @@ public class OpenFeatureLocalResolveProvider implements FeatureProvider { org.slf4j.LoggerFactory.getLogger(OpenFeatureLocalResolveProvider.class); private final FlagResolverService flagResolverService; + /** + * Creates a new OpenFeature provider for local flag resolution with exposure logging enabled. + * + *

This constructor enables exposure logging by default, which means flag evaluations will be + * logged to the Confidence service for populating exposure data and other analytics. + * + *

This is equivalent to calling {@code new OpenFeatureLocalResolveProvider(apiSecret, + * clientSecret, true)}. + * + * @param apiSecret the API credentials for authenticating with the Confidence service + * @param clientSecret the client secret for your application + * @see #OpenFeatureLocalResolveProvider(ApiSecret, String, boolean) for detailed parameter + * documentation + */ public OpenFeatureLocalResolveProvider(ApiSecret apiSecret, String clientSecret) { + this(apiSecret, clientSecret, true); + } + + /** + * Creates a new OpenFeature provider for local flag resolution with configurable exposure + * logging. + * + *

This is the primary constructor that allows full control over the provider configuration. + * The provider will automatically determine the resolution mode (WASM or Java) based on the + * {@code LOCAL_RESOLVE_MODE} environment variable, defaulting to WASM mode. + * + * @param apiSecret the API credentials containing client ID and client secret for authenticating + * with the Confidence service. Create using {@code new ApiSecret("client-id", + * "client-secret")} + * @param clientSecret the client secret for your application, used for flag resolution + * authentication. This is different from the API secret and is specific to your application + * configuration + * @param enableExposureLogs whether to enable exposure logging. When {@code true}, flag + * evaluations are logged to Confidence. When {@code false}, evaluations are not logged, + * useful when debugging. + * @since 0.2.4 + */ + public OpenFeatureLocalResolveProvider( + ApiSecret apiSecret, String clientSecret, boolean enableExposureLogs) { final var env = System.getenv("LOCAL_RESOLVE_MODE"); if (env != null && env.equals("WASM")) { - this.flagResolverService = LocalResolverServiceFactory.from(apiSecret, clientSecret, true); + this.flagResolverService = + LocalResolverServiceFactory.from(apiSecret, clientSecret, true, enableExposureLogs); + } else if (env != null && env.equals("JAVA")) { + this.flagResolverService = + LocalResolverServiceFactory.from(apiSecret, clientSecret, false, enableExposureLogs); } else { - this.flagResolverService = LocalResolverServiceFactory.from(apiSecret, clientSecret, false); + this.flagResolverService = + LocalResolverServiceFactory.from(apiSecret, clientSecret, true, enableExposureLogs); } this.clientSecret = clientSecret; } diff --git a/openfeature-provider-local/src/test/java/com/spotify/confidence/TestBase.java b/openfeature-provider-local/src/test/java/com/spotify/confidence/TestBase.java index e4868390..88263eb4 100644 --- a/openfeature-provider-local/src/test/java/com/spotify/confidence/TestBase.java +++ b/openfeature-provider-local/src/test/java/com/spotify/confidence/TestBase.java @@ -67,8 +67,7 @@ public void logAssigns( wasmResolverApi, resolverState, resolveTokenConverter, mock(), mock()); } else { resolverServiceFactory = - new LocalResolverServiceFactory( - null, resolverState, resolveTokenConverter, mock(), mock()); + new LocalResolverServiceFactory(resolverState, resolveTokenConverter, mock(), mock()); } }