Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions openfeature-provider-local/README.md
Original file line number Diff line number Diff line change
@@ -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
<dependency>
<groupId>com.spotify.confidence</groupId>
<artifactId>openfeature-provider-local</artifactId>
<version>0.2.4</version>
</dependency>
```

## 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+
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
package com.spotify.confidence;

/**
* API credentials for authenticating with the Confidence service.
*
* <p>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) {}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class LocalResolverServiceFactory implements ResolverServiceFactory {
private final Supplier<String> 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);
Expand All @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -116,7 +122,8 @@ private static FlagResolverService createFlagResolverService(
sidecarFlagsAdminFetcher.stateHolder(),
resolveTokenConverter,
resolveLogger,
assignLogger)
assignLogger,
enableExposureLogs)
.create(clientSecret);
} else {
flagsFetcherExecutor.scheduleWithFixedDelay(
Expand All @@ -128,11 +135,30 @@ private static FlagResolverService createFlagResolverService(
sidecarFlagsAdminFetcher.stateHolder(),
resolveTokenConverter,
resolveLogger,
assignLogger)
assignLogger,
enableExposureLogs)
.create(clientSecret);
}
}

LocalResolverServiceFactory(
SwapWasmResolverApi wasmResolveApi,
AtomicReference<ResolverState> 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<ResolverState> resolverStateHolder,
Expand All @@ -146,7 +172,25 @@ private static FlagResolverService createFlagResolverService(
Instant::now,
() -> RandomStringUtils.randomAlphanumeric(32),
resolveLogger,
assignLogger);
assignLogger,
true);
}

LocalResolverServiceFactory(
AtomicReference<ResolverState> resolverStateHolder,
ResolveTokenConverter resolveTokenConverter,
ResolveLogger resolveLogger,
AssignLogger assignLogger,
boolean enableExposureLogs) {
this(
null,
resolverStateHolder,
resolveTokenConverter,
Instant::now,
() -> RandomStringUtils.randomAlphanumeric(32),
resolveLogger,
assignLogger,
enableExposureLogs);
}

LocalResolverServiceFactory(
Expand All @@ -161,7 +205,8 @@ private static FlagResolverService createFlagResolverService(
Instant::now,
() -> RandomStringUtils.randomAlphanumeric(32),
resolveLogger,
assignLogger);
assignLogger,
true);
}

LocalResolverServiceFactory(
Expand All @@ -171,14 +216,16 @@ private static FlagResolverService createFlagResolverService(
Supplier<Instant> timeSupplier,
Supplier<String> resolveIdSupplier,
ResolveLogger resolveLogger,
AssignLogger assignLogger) {
AssignLogger assignLogger,
boolean enableExposureLogs) {
this.wasmResolveApi = wasmResolveApi;
this.resolverStateHolder = resolverStateHolder;
this.resolveTokenConverter = resolveTokenConverter;
this.timeSupplier = timeSupplier;
this.resolveIdSupplier = resolveIdSupplier;
this.resolveLogger = resolveLogger;
this.assignLogger = assignLogger;
this.enableExposureLogs = enableExposureLogs;
}

@VisibleForTesting
Expand Down Expand Up @@ -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,
Expand All @@ -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<ResolvedValue> values) {
// Logging disabled - no-op
}

@Override
public void logAssigns(
String resolveId,
Sdk sdk,
List<FlagToApply> flagsToApply,
AccountClient accountClient) {
// Logging disabled - no-op
}
};
}

return new FlagLogger() {
@Override
public void logResolve(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,105 @@
import java.util.function.Function;
import org.slf4j.Logger;

/**
* OpenFeature provider for Confidence feature flags using local resolution.
*
* <p>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.
*
* <p>The provider supports two resolution modes:
*
* <ul>
* <li><strong>WASM mode</strong> (default): Uses a WebAssembly resolver
* <li><strong>Java mode</strong>: Uses a pure Java resolver
* </ul>
*
* <p>Resolution mode can be controlled via the {@code LOCAL_RESOLVE_MODE} environment variable:
*
* <ul>
* <li>{@code LOCAL_RESOLVE_MODE=WASM} - Forces WASM mode
* <li>{@code LOCAL_RESOLVE_MODE=JAVA} - Forces Java mode
* <li>Not set - Defaults to WASM mode
* </ul>
*
* <p><strong>Usage Example:</strong>
*
* <pre>{@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");
* }</pre>
*
* @since 0.2.4
*/
@Experimental
public class OpenFeatureLocalResolveProvider implements FeatureProvider {
private final String clientSecret;
private static final Logger log =
org.slf4j.LoggerFactory.getLogger(OpenFeatureLocalResolveProvider.class);
private final FlagResolverService flagResolverService;

/**
* Creates a new OpenFeature provider for local flag resolution with exposure logging enabled.
*
* <p>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.
*
* <p>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.
*
* <p>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;
}
Expand Down
Loading