Skip to content

Commit 4559d3f

Browse files
authored
feat(server): Add local evaluation (#299)
* feat(core): Add `localEvaluation` API * feat(core): Add an optional shutdown override to Feature Flags * docs(core): Update CHANGELOG * feat(server): Add feature flags local evaluation * docs(server): Update CHANGELOG * docs(server): Update USAGE * chore: Update Java sample to include local eval * Don't shadow reserved keyword identifiers * Drop a list allocation * Add enums for property operators and types * Regex patterns are static * Separate duties in feature flags This cleans things up for future `onlyEvaluateLocally` config and sending feature flags on capture * refactor: Move local eval models to core Deserialization is handled here, paired closely with the Api. This simplifies deserialization in the server SDK. * refactor: Use deserialization from core * fix: Flag definitions are loaded synchronously if missing * fix: Use group properties when evaluating group flags * test: Use JSON objects * test: Add flag dependency tests This brings method coverage to 92% overall, line 85% overall. * fix: Mark the poller as daemon It'll automatically clean up in case the developer forgets to call posthog.close() * chore(java-sample): Move logic out of onFeatureFlags It's not necessary considering the first request to trigger local evaluation will synchronously retrieve flag definitions if they're not yet loaded. * docs(core): Update CHANGELOG * chore(server): apply formatter * fix: Properly type `userProperties`/`groupProperties` * feat: Setting `personalApiKey` turns local eval on by default * fix(server): Skip flags with ensure_experience_continuity * refactor(server): Inherit `PostHogStateless` Aggregation makes it difficult to access `featureFlags` without exposing it * feat(server): Add `reloadFeatureFlags` method This reloads feature flag definitions, and is present in other server-side SDKs * fix(server): Improve synchronization of flag definitions Previously the synchronization prevented flag definitions from being overwritten. This now ensures the poller and the client don't load flag definitions at the same time. * fix(server): Catch errors on feature flag init * style: Fix up a comment * fixup! style: Fix up a comment * style: Missing newline * style: spotlessApply * chore: apiDump * test: Use a latch to reduce flakiness in CI
1 parent b58c77b commit 4559d3f

27 files changed

+5421
-606
lines changed
Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,31 @@
11
package com.posthog.java.sample;
22

3+
import com.posthog.server.PostHog;
34
import com.posthog.server.PostHogCaptureOptions;
45
import com.posthog.server.PostHogConfig;
5-
import com.posthog.server.PostHog;
6+
import com.posthog.server.PostHogFeatureFlagOptions;
67
import com.posthog.server.PostHogInterface;
78

89
import java.util.HashMap;
9-
import java.util.Map;
1010

1111
/**
1212
* Simple Java 1.8 example demonstrating PostHog usage
1313
*/
1414
public class PostHogJavaExample {
15+
1516
public static void main(String[] args) {
1617
PostHogConfig config = PostHogConfig
17-
.builder("phc_wz4KZkikEluCCdfY2B2h7MXYygNGdTqFgjbU7I1ZdVR")
18+
.builder("phc_wxtaSxv9yC8UYxUAxNojluoAf41L8p6SJZmiTMtS8jA")
19+
.personalApiKey("phs_DuaFTmUtxQNj5R2W03emB1jMLIX5XwDvrt3DKfi5uYNcxzd")
20+
.host("http://localhost:8010")
21+
.localEvaluation(true)
22+
.debug(true)
1823
.build();
1924

20-
PostHogInterface postHog = PostHog.with(config);
25+
PostHogInterface posthog = PostHog.with(config);
2126

22-
postHog.group("distinct-id", "company", "some-company-id");
23-
postHog.capture(
27+
posthog.group("distinct-id", "company", "some-company-id");
28+
posthog.capture(
2429
"distinct-id",
2530
"new-purchase",
2631
PostHogCaptureOptions
@@ -31,29 +36,40 @@ public static void main(String[] args) {
3136

3237
HashMap<String, Object> userProperties = new HashMap<>();
3338
userProperties.put("email", "[email protected]");
34-
postHog.identify("distinct-id", userProperties);
39+
posthog.identify("distinct-id", userProperties);
3540

3641
// AVOID - Anonymous inner class holds reference to outer class.
3742
// The following won't serialize properly.
38-
// postHog.identify("user-123", new HashMap<String, Object>() {{
39-
// put("key", "value");
43+
// posthog.identify("user-123", new HashMap<String, Object>() {{
44+
// put("key", "value");
4045
// }});
4146

42-
postHog.alias("distinct-id", "alias-id");
47+
posthog.alias("distinct-id", "alias-id");
4348

44-
45-
if (postHog.isFeatureEnabled("distinct-id", "beta-feature", false)) {
49+
// Feature flag examples with local evaluation
50+
if (posthog.isFeatureEnabled("distinct-id", "beta-feature", false)) {
4651
System.out.println("The feature is enabled.");
4752
}
4853

49-
Object flagValue = postHog.getFeatureFlag("distinct-id", "multi-variate-flag", "default");
54+
Object flagValue = posthog.getFeatureFlag("distinct-id", "multi-variate-flag", "default");
5055
String flagVariate = flagValue instanceof String ? (String) flagValue : "default";
51-
Object flagPayload = postHog.getFeatureFlagPayload("distinct-id", "multi-variate-flag");
56+
Object flagPayload = posthog.getFeatureFlagPayload("distinct-id", "multi-variate-flag");
5257

5358
System.out.println("The flag variant was: " + flagVariate);
5459
System.out.println("Received flag payload: " + flagPayload);
5560

56-
postHog.flush();
57-
postHog.close();
61+
Boolean hasFilePreview = posthog.isFeatureEnabled(
62+
"distinct-id",
63+
"file-previews",
64+
PostHogFeatureFlagOptions
65+
.builder()
66+
.defaultValue(false)
67+
.personProperty("email", "[email protected]")
68+
.build());
69+
70+
System.out.println("File previews enabled: " + hasFilePreview);
71+
72+
posthog.flush();
73+
posthog.close();
5874
}
59-
}
75+
}

posthog-server/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
## Next
22

3+
- feat: Add local evaluation for feature flags ([#299](https://github.com/PostHog/posthog-android/issues/299))
34
- fix: Restructured `groupProperties` and `userProperties` types to match the API and other SDKs ([#312](https://github.com/PostHog/posthog-android/pull/312))
45

56
## 1.1.0 - 2025-10-03

posthog-server/USAGE.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ PostHogConfig config = PostHogConfig.builder("phc_your_api_key_here")
9292
- `flushIntervalSeconds`: Interval between automatic flushes (default: `30`)
9393
- `featureFlagCacheSize`: The maximum number of feature flags results to cache (default: `1000`)
9494
- `featureFlagCacheMaxAgeMs`: The maximum age of a feature flag cache record in memory in milliseconds (default: `300000` or five minutes)
95+
- `localEvaluation`: Enable local evaluation of feature flags (default: `false`)
96+
- `personalApiKey`: Personal API key required for local evaluation (default: `null`)
97+
- `pollIntervalSeconds`: Interval for polling flag definitions for local evaluation (default: `30`)
9598

9699
## Capturing Events
97100

@@ -202,6 +205,58 @@ postHog.identify("user123", userProperties, userPropertiesSetOnce);
202205

203206
## Feature Flags
204207

208+
### Local Evaluation (Experimental)
209+
210+
Local evaluation allows the SDK to evaluate feature flags locally without making API calls for each flag check. This reduces latency and API costs.
211+
212+
**How it works:**
213+
214+
1. The SDK periodically polls for flag definitions from PostHog (every 30 seconds by default)
215+
2. Flags are evaluated locally using cached definitions and properties provided by the caller
216+
3. If evaluation is inconclusive (missing properties, etc.), the SDK falls back to the API
217+
218+
**Requirements:**
219+
220+
- A feature flags secure API key _or_ a personal API key
221+
- A feature flags secure API key can be obtained via PostHog → Settings → Project → Feature Flags → Feature Flags Secure API key
222+
- A personal API key can be generated via PostHog → Settings → Account → Personal API Keys
223+
- The `localEvaluation` config option set to `true`
224+
225+
#### Kotlin
226+
227+
```kotlin
228+
val config = PostHogConfig(
229+
apiKey = "phc_your_api_key_here",
230+
host = "https://your-posthog-instance.com",
231+
localEvaluation = true,
232+
personalApiKey = "phx_your_personal_api_key_here",
233+
pollIntervalSeconds = 30 // Optional: customize polling interval
234+
)
235+
```
236+
237+
#### Java
238+
239+
```java
240+
PostHogConfig config = PostHogConfig.builder("phc_your_api_key_here")
241+
.host("https://your-posthog-instance.com")
242+
.localEvaluation(true)
243+
.personalApiKey("phx_your_personal_api_key_here")
244+
.pollIntervalSeconds(30) // Optional: customize polling interval
245+
.build();
246+
```
247+
248+
**Benefits:**
249+
250+
- **Reduced latency**: No API call needed for most flag evaluations
251+
- **Lower costs**: Fewer API requests in most cases
252+
- **Offline support**: Flags continue to work with cached definitions
253+
254+
**Limitations:**
255+
256+
- Requires person/group properties to be provided with each call
257+
- Falls back to API for cohort-based flags without local cohort data
258+
- May not reflect real-time flag changes (respects polling interval)
259+
205260
### Check if Feature is Enabled
206261

207262
#### Kotlin

posthog-server/api/posthog-server.api

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
public final class com/posthog/server/PostHog : com/posthog/server/PostHogInterface {
1+
public final class com/posthog/server/PostHog : com/posthog/PostHogStateless, com/posthog/server/PostHogInterface {
22
public static final field Companion Lcom/posthog/server/PostHog$Companion;
33
public fun <init> ()V
44
public fun alias (Ljava/lang/String;Ljava/lang/String;)V
@@ -25,6 +25,7 @@ public final class com/posthog/server/PostHog : com/posthog/server/PostHogInterf
2525
public fun isFeatureEnabled (Ljava/lang/String;Ljava/lang/String;Lcom/posthog/server/PostHogFeatureFlagOptions;)Z
2626
public fun isFeatureEnabled (Ljava/lang/String;Ljava/lang/String;Z)Z
2727
public fun isFeatureEnabled (Ljava/lang/String;Ljava/lang/String;ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;)Z
28+
public fun reloadFeatureFlags ()V
2829
public fun setup (Lcom/posthog/server/PostHogConfig;)V
2930
public static final fun with (Lcom/posthog/server/PostHogConfig;)Lcom/posthog/server/PostHogInterface;
3031
}
@@ -86,10 +87,11 @@ public class com/posthog/server/PostHogConfig {
8687
public static final field DEFAULT_HOST Ljava/lang/String;
8788
public static final field DEFAULT_MAX_BATCH_SIZE I
8889
public static final field DEFAULT_MAX_QUEUE_SIZE I
90+
public static final field DEFAULT_POLL_INTERVAL_SECONDS I
8991
public static final field DEFAULT_US_ASSETS_HOST Ljava/lang/String;
9092
public static final field DEFAULT_US_HOST Ljava/lang/String;
91-
public fun <init> (Ljava/lang/String;Ljava/lang/String;ZZZZIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;Ljava/net/Proxy;III)V
92-
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;ZZZZIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;Ljava/net/Proxy;IIIILkotlin/jvm/internal/DefaultConstructorMarker;)V
93+
public fun <init> (Ljava/lang/String;Ljava/lang/String;ZZZZIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;Ljava/net/Proxy;IIIZLjava/lang/String;I)V
94+
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;ZZZZIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;Ljava/net/Proxy;IIIZLjava/lang/String;IILkotlin/jvm/internal/DefaultConstructorMarker;)V
9395
public final fun addBeforeSend (Lcom/posthog/PostHogBeforeSend;)V
9496
public final fun addIntegration (Lcom/posthog/PostHogIntegration;)V
9597
public static final fun builder (Ljava/lang/String;)Lcom/posthog/server/PostHogConfig$Builder;
@@ -102,9 +104,12 @@ public class com/posthog/server/PostHogConfig {
102104
public final fun getFlushAt ()I
103105
public final fun getFlushIntervalSeconds ()I
104106
public final fun getHost ()Ljava/lang/String;
107+
public final fun getLocalEvaluation ()Z
105108
public final fun getMaxBatchSize ()I
106109
public final fun getMaxQueueSize ()I
107110
public final fun getOnFeatureFlags ()Lcom/posthog/PostHogOnFeatureFlags;
111+
public final fun getPersonalApiKey ()Ljava/lang/String;
112+
public final fun getPollIntervalSeconds ()I
108113
public final fun getPreloadFeatureFlags ()Z
109114
public final fun getProxy ()Ljava/net/Proxy;
110115
public final fun getRemoteConfig ()Z
@@ -117,9 +122,12 @@ public class com/posthog/server/PostHogConfig {
117122
public final fun setFeatureFlagCalledCacheSize (I)V
118123
public final fun setFlushAt (I)V
119124
public final fun setFlushIntervalSeconds (I)V
125+
public final fun setLocalEvaluation (Z)V
120126
public final fun setMaxBatchSize (I)V
121127
public final fun setMaxQueueSize (I)V
122128
public final fun setOnFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V
129+
public final fun setPersonalApiKey (Ljava/lang/String;)V
130+
public final fun setPollIntervalSeconds (I)V
123131
public final fun setPreloadFeatureFlags (Z)V
124132
public final fun setProxy (Ljava/net/Proxy;)V
125133
public final fun setRemoteConfig (Z)V
@@ -137,9 +145,12 @@ public final class com/posthog/server/PostHogConfig$Builder {
137145
public final fun flushAt (I)Lcom/posthog/server/PostHogConfig$Builder;
138146
public final fun flushIntervalSeconds (I)Lcom/posthog/server/PostHogConfig$Builder;
139147
public final fun host (Ljava/lang/String;)Lcom/posthog/server/PostHogConfig$Builder;
148+
public final fun localEvaluation (Z)Lcom/posthog/server/PostHogConfig$Builder;
140149
public final fun maxBatchSize (I)Lcom/posthog/server/PostHogConfig$Builder;
141150
public final fun maxQueueSize (I)Lcom/posthog/server/PostHogConfig$Builder;
142151
public final fun onFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)Lcom/posthog/server/PostHogConfig$Builder;
152+
public final fun personalApiKey (Ljava/lang/String;)Lcom/posthog/server/PostHogConfig$Builder;
153+
public final fun pollIntervalSeconds (I)Lcom/posthog/server/PostHogConfig$Builder;
143154
public final fun preloadFeatureFlags (Z)Lcom/posthog/server/PostHogConfig$Builder;
144155
public final fun proxy (Ljava/net/Proxy;)Lcom/posthog/server/PostHogConfig$Builder;
145156
public final fun remoteConfig (Z)Lcom/posthog/server/PostHogConfig$Builder;
@@ -209,6 +220,7 @@ public abstract interface class com/posthog/server/PostHogInterface {
209220
public abstract fun isFeatureEnabled (Ljava/lang/String;Ljava/lang/String;Lcom/posthog/server/PostHogFeatureFlagOptions;)Z
210221
public abstract fun isFeatureEnabled (Ljava/lang/String;Ljava/lang/String;Z)Z
211222
public abstract fun isFeatureEnabled (Ljava/lang/String;Ljava/lang/String;ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;)Z
223+
public abstract fun reloadFeatureFlags ()V
212224
public abstract fun setup (Lcom/posthog/server/PostHogConfig;)V
213225
}
214226

posthog-server/src/main/java/com/posthog/server/PostHog.kt

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,36 @@
11
package com.posthog.server
22

3+
import com.posthog.PostHog
34
import com.posthog.PostHogStateless
4-
import com.posthog.PostHogStatelessInterface
5-
6-
public class PostHog : PostHogInterface {
7-
private var instance: PostHogStatelessInterface? = null
5+
import com.posthog.server.internal.PostHogFeatureFlags
86

7+
public class PostHog : PostHogStateless(), PostHogInterface {
98
override fun <T : PostHogConfig> setup(config: T) {
10-
instance = PostHogStateless.with(config.asCoreConfig())
9+
super.setup(config.asCoreConfig())
1110
}
1211

1312
override fun close() {
14-
instance?.close()
13+
super.close()
1514
}
1615

1716
override fun identify(
1817
distinctId: String,
1918
userProperties: Map<String, Any>?,
2019
userPropertiesSetOnce: Map<String, Any>?,
2120
) {
22-
instance?.identify(
21+
super<PostHogStateless>.identify(
2322
distinctId,
2423
userProperties,
2524
userPropertiesSetOnce,
2625
)
2726
}
2827

2928
override fun flush() {
30-
instance?.flush()
29+
super.flush()
3130
}
3231

3332
override fun debug(enable: Boolean) {
34-
instance?.debug(enable)
33+
super.debug(enable)
3534
}
3635

3736
override fun capture(
@@ -43,7 +42,7 @@ public class PostHog : PostHogInterface {
4342
groups: Map<String, String>?,
4443
timestamp: java.util.Date?,
4544
) {
46-
instance?.captureStateless(
45+
super.captureStateless(
4746
event,
4847
distinctId,
4948
properties,
@@ -62,14 +61,14 @@ public class PostHog : PostHogInterface {
6261
personProperties: Map<String, Any?>?,
6362
groupProperties: Map<String, Map<String, Any?>>?,
6463
): Boolean {
65-
return instance?.isFeatureEnabledStateless(
64+
return super.isFeatureEnabledStateless(
6665
distinctId,
6766
key,
6867
defaultValue,
6968
groups,
7069
personProperties,
7170
groupProperties,
72-
) ?: false
71+
)
7372
}
7473

7574
override fun getFeatureFlag(
@@ -80,7 +79,7 @@ public class PostHog : PostHogInterface {
8079
personProperties: Map<String, Any?>?,
8180
groupProperties: Map<String, Map<String, Any?>>?,
8281
): Any? {
83-
return instance?.getFeatureFlagStateless(
82+
return super.getFeatureFlagStateless(
8483
distinctId,
8584
key,
8685
defaultValue,
@@ -98,7 +97,7 @@ public class PostHog : PostHogInterface {
9897
personProperties: Map<String, Any?>?,
9998
groupProperties: Map<String, Map<String, Any?>>?,
10099
): Any? {
101-
return instance?.getFeatureFlagPayloadStateless(
100+
return super.getFeatureFlagPayloadStateless(
102101
distinctId,
103102
key,
104103
defaultValue,
@@ -114,7 +113,7 @@ public class PostHog : PostHogInterface {
114113
key: String,
115114
groupProperties: Map<String, Any>?,
116115
) {
117-
instance?.groupStateless(
116+
super.groupStateless(
118117
distinctId,
119118
type,
120119
key,
@@ -126,12 +125,16 @@ public class PostHog : PostHogInterface {
126125
distinctId: String,
127126
alias: String,
128127
) {
129-
instance?.aliasStateless(
128+
super.aliasStateless(
130129
distinctId,
131130
alias,
132131
)
133132
}
134133

134+
override fun reloadFeatureFlags() {
135+
(featureFlags as? PostHogFeatureFlags)?.loadFeatureFlagDefinitions()
136+
}
137+
135138
public companion object {
136139
/**
137140
* Set up the SDK and returns an instance that you can hold and pass it around

0 commit comments

Comments
 (0)