Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/provider-only-local-evaluation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-server": patch
---

Allow local feature flag evaluation from a shared definition cache without a personal API key when local evaluation is enabled.
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,13 @@ public open class PostHogConfig constructor(
* Enable local evaluation of feature flags
* When enabled, the SDK periodically fetches flag definitions and evaluates flags locally
* without making API calls for each flag check. Falls back to API if evaluation is inconclusive.
* Requires personalApiKey to be set.
* Requires personalApiKey or flagDefinitionCacheProvider to be set.
* Defaults to false
*/
public var localEvaluation: Boolean = false,
/**
* Personal API key for local evaluation
* Required when localEvaluation is true.
* Personal API key for fetching local-evaluation definitions from PostHog.
* Not required to read definitions from flagDefinitionCacheProvider.
* Defaults to null
*/
personalApiKey: String? = null,
Expand Down Expand Up @@ -184,8 +184,8 @@ public open class PostHogConfig constructor(
public val host: String = host.trim().ifBlank { DEFAULT_HOST }

/**
* Personal API key for local evaluation
* Required when localEvaluation is true.
* Personal API key for fetching local-evaluation definitions from PostHog.
* Not required to read definitions from [flagDefinitionCacheProvider].
* Defaults to null
*/
public var personalApiKey: String? = personalApiKey?.trim()?.ifBlank { null }
Expand All @@ -194,7 +194,9 @@ public open class PostHogConfig constructor(
* Shared cache provider for local-evaluation feature flag definitions.
*
* This can reduce duplicate definition fetches when multiple SDK instances run in
* the same service. Defaults to null.
* the same service. With [localEvaluation] enabled, instances without [personalApiKey]
* only read cached definitions, without consulting fetch leadership or publishing.
* Direct definition fetches still require [personalApiKey]. Defaults to null.
*/
public var flagDefinitionCacheProvider: PostHogFlagDefinitionCacheProvider? = null

Expand Down Expand Up @@ -648,7 +650,7 @@ public open class PostHogConfig constructor(
public fun localEvaluation(localEvaluation: Boolean): Builder = apply { this.localEvaluation = localEvaluation }

/**
* Sets the personal API key used for local feature flag evaluation.
* Sets the personal API key used to fetch local feature flag definitions from PostHog.
*
* Setting a non-blank personal API key automatically enables [localEvaluation] unless it has
* already been set explicitly.
Expand Down Expand Up @@ -682,6 +684,7 @@ public open class PostHogConfig constructor(

/**
* Sets the provider for caching feature flag definitions.
* Enable [localEvaluation] to read cached definitions without a personal API key.
Comment thread
dustinbyrne marked this conversation as resolved.
*
* @param flagDefinitionCacheProvider The cache provider, or null to use the default.
* @return This builder.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,23 @@ import java.util.concurrent.CompletionStage
/**
* Shared cache provider for local-evaluation feature flag definitions.
*
* Implementations usually coordinate leadership across SDK instances. When
* [shouldFetchFlagDefinitions] returns true this instance fetches definitions from
* With local evaluation enabled, instances without a personal API key only read
* [getFlagDefinitions]. They do not call [shouldFetchFlagDefinitions] or publish definitions.
*
* Implementations usually coordinate fetch leadership across instances with a personal API key.
* When [shouldFetchFlagDefinitions] returns true, this instance fetches definitions from
* PostHog and receives them through [onFlagDefinitionsReceived]. When it returns
* false this instance reads definitions from [getFlagDefinitions] instead.
* false, this instance reads definitions from [getFlagDefinitions] instead.
*
* Provider methods return [CompletionStage] so implementations can use either blocking
* backends or async clients such as Reactor `Mono.toFuture()`; `Flux` should be reduced
* to one result first, for example with `next()` or `collectList()`. The SDK waits for
* these stages in its current synchronous flag-loading flow.
*
* Provider errors are handled defensively by the SDK: failed reads fall back to the
* API only when no definitions are already loaded, and failed writes/shutdowns are
* logged without failing flag evaluation. A failed write also makes the next fetch
* unconditional, so the SDK retries the write with a full set of definitions.
* API only when no definitions are already loaded and a personal API key is configured.
* Failed writes/shutdowns are logged without failing flag evaluation. A failed write also
* makes the next fetch unconditional, so the SDK retries the write with a full set of definitions.
*/
public interface PostHogFlagDefinitionCacheProvider {
/**
Expand All @@ -34,6 +37,7 @@ public interface PostHogFlagDefinitionCacheProvider {

/**
* Return true when this SDK instance should fetch definitions from PostHog.
* Called only when a personal API key is configured.
*/
public fun shouldFetchFlagDefinitions(): CompletionStage<Boolean>

Expand Down Expand Up @@ -65,6 +69,7 @@ public abstract class PostHogBlockingFlagDefinitionCacheProvider : PostHogFlagDe

/**
* Return true when this SDK instance should fetch definitions from PostHog.
* Called only when a personal API key is configured.
*/
public abstract fun shouldFetchFlagDefinitionsBlocking(): Boolean

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,7 @@ internal class PostHogFeatureFlags(
if (!localEvaluation) {
return
}
if (personalApiKey == null) {
if (!localEvaluationEnabled()) {
logMissingPersonalApiKey()
return
}
Expand Down Expand Up @@ -579,6 +579,11 @@ internal class PostHogFeatureFlags(
config.logger.log("Flag definition cache empty before initial load, falling back to API")
}

if (personalApiKey == null) {
Comment thread
dustinbyrne marked this conversation as resolved.
logMissingPersonalApiKey()
return
}

config.logger.log("Loading feature flags for local evaluation")
val response = api.localEvaluation(personalApiKey, etag)

Expand Down Expand Up @@ -639,6 +644,10 @@ internal class PostHogFeatureFlags(
}

private fun shouldFetchFlagDefinitions(): Boolean {
// Cache-only readers must not claim the provider's fetch leadership.
if (personalApiKey == null) {
return false
}
val provider = flagDefinitionCacheProvider ?: return true
return awaitFlagDefinitionCacheProvider(
errorDescription = "Error in flag definition cache provider shouldFetchFlagDefinitions",
Expand Down Expand Up @@ -855,7 +864,7 @@ internal class PostHogFeatureFlags(
return
}

if (personalApiKey == null) {
if (!localEvaluationEnabled()) {
logMissingPersonalApiKey()
return
}
Expand Down Expand Up @@ -949,11 +958,11 @@ internal class PostHogFeatureFlags(
}

private fun localEvaluationEnabled(): Boolean {
return localEvaluation && personalApiKey != null
return localEvaluation && (personalApiKey != null || flagDefinitionCacheProvider != null)
}

private fun logMissingPersonalApiKey() {
config.logger.log("Local evaluation requires a personal API key. This call will be ignored.")
config.logger.log("Fetching feature flag definitions requires a personal API key. This call will be ignored.")
}

/**
Expand Down Expand Up @@ -1051,7 +1060,7 @@ internal class PostHogFeatureFlags(
return EMPTY_EVALUATE_FLAGS_RESULT
}

if (onlyEvaluateLocally && personalApiKey == null) {
if (onlyEvaluateLocally && personalApiKey == null && flagDefinitionCacheProvider == null) {
logMissingPersonalApiKey()
return EMPTY_EVALUATE_FLAGS_RESULT
}
Expand Down
Loading
Loading