Skip to content

Conversation

@dustinbyrne
Copy link
Contributor

@dustinbyrne dustinbyrne commented Oct 3, 2025

💡 Motivation and Context

This PR adds local evaluation capabilities to feature flags. See the updated USAGE.md for details on how to use it.

If you're reviewing this PR, skimming FlagEvaluator.kt, LocalEvaluationPoller.kt, and changes to PostHogFeatureFlags.kt would be the best places to start.

This change is pretty massive. I'm sure there's still a couple of bugs lurking, but test coverage is pretty good. This PR doesn't really change the public API, so we can always follow up with smaller bug fixes.

Resolves #307

💚 How did you test it?

Lots of unit testing and updating the Java sample app to use local eval

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

@dustinbyrne dustinbyrne marked this pull request as ready for review October 6, 2025 22:14
@dustinbyrne dustinbyrne requested a review from a team as a code owner October 6, 2025 22:14
@dustinbyrne dustinbyrne requested a review from a team October 6, 2025 22:23
val parsedDate =
try {
parseDateValue(value.toString())
} catch (e: Exception) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd always use Throwable since it catches more than just Exception

Copy link
Contributor Author

@dustinbyrne dustinbyrne Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I typically try to avoid catching Throwable because it includes JVM exceptions that are generally unrecoverable like out of memory, thread death, JVM errors, etc.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you do catch a Throwable when executing scheduleAtFixedRate tho.
I usually prefer it because it's a lower level that captures many more errors, but of course, it depends on the throw signature of the methods you call.
I usually prefer to swallow SDK exceptions rather than let them bubble up, and if the caller does not handle the exception. It crashes the app
Sometimes you can get an OOM exception and its not even because the SDK is using a lot of memory, but it used the last bit, but people would still complain its the SDK doing the wrong thing :(
I will let you decide on this since its more server side and might be less of an issue

Copy link
Member

@marandaneto marandaneto left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

huge PR pheww... :D
i left a few comments, but i'd love to get eyes from the flags team as well, i just checked kotlin-ish code and if stuff made sense but i dont know the logic behind local eval

@dustinbyrne dustinbyrne marked this pull request as draft October 20, 2025 22:25
@dustinbyrne
Copy link
Contributor Author

Here's the current code coverage summary as of now

Metric Coverage Covered/Total
Class 100% 29/29
Method 90.1% 173/192
Branch 69.4% 369/532
Line 87.7% 1051/1198
Instruction 84.6% 4406/5209

@dustinbyrne dustinbyrne force-pushed the feat/local-eval branch 2 times, most recently from 5d7a9b1 to 8479f0f Compare October 21, 2025 20:32
@dustinbyrne dustinbyrne marked this pull request as ready for review October 21, 2025 20:33
@dustinbyrne dustinbyrne moved this to In Review in Feature Flags Oct 21, 2025
@dustinbyrne dustinbyrne force-pushed the feat/local-eval branch 2 times, most recently from 887a3cf to 7489684 Compare October 28, 2025 18:50
@dustinbyrne dustinbyrne changed the base branch from main to fix!/group-properties-structure October 30, 2025 17:08
@dmarticus dmarticus self-assigned this Oct 31, 2025
Copy link
Contributor

@dmarticus dmarticus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally good, I approve from my end with a few recommendations (see the comments for the specific changes). I also have a few general questions:

  • When definitions are reloaded, the feature flag result cache isn't cleared. Is this intentional? Users might get stale results from the cache even after new definitions load.

  • If the initial load fails, what happens? The poller will retry, but should there be a fallback to remote evaluation immediately?

  • Should there be validation that personalApiKey is provided when localEvaluation=true?

} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
config.logger.log("Interrupted while waiting for flag definitions to load")
return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This returns without notifying waiters afaict, which means that if it's interrupted, it'll return without setting isLoading = false, so other threads might wait.

Maybe do something like this?

synchronized(loadLock) {
    while (isLoading) {
        try {
            loadLock.wait()
        } catch (e: InterruptedException) {
            Thread.currentThread().interrupt()
            isLoading = false
            loadLock.notifyAll()
            throw InterruptedException("Interrupted while waiting")
        }
    }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fact that a waiting thread doesn't wake the other threads is by design. If they've got into this block, it's because another thread is executing the try block below. It'll reach a finally block to reset isLoading = false and to wake the waiting threads.

tl;dr - only the loading thread is responsible for waking the waiting threads

@github-project-automation github-project-automation bot moved this from In Review to Approved in Feature Flags Oct 31, 2025
@dustinbyrne dustinbyrne force-pushed the fix!/group-properties-structure branch from 3bb5b82 to 886d3a2 Compare November 4, 2025 00:35
Base automatically changed from fix!/group-properties-structure to main November 4, 2025 19:13
This cleans things up for future `onlyEvaluateLocally` config and
sending feature flags on capture
Deserialization is handled here, paired closely with the Api. This
simplifies deserialization in the server SDK.
This brings method coverage to 92% overall, line 85% overall.
It'll automatically clean up in case the developer forgets to call
posthog.close()
It's not necessary considering the first request to trigger local
evaluation will synchronously retrieve flag definitions if they're not
yet loaded.
Aggregation makes it difficult to access `featureFlags` without exposing
it
This reloads feature flag definitions, and is present in other
server-side SDKs
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.
@dustinbyrne dustinbyrne merged commit 4559d3f into main Nov 6, 2025
9 checks passed
@dustinbyrne dustinbyrne deleted the feat/local-eval branch November 6, 2025 18:46
@github-project-automation github-project-automation bot moved this from Approved to Done in Feature Flags Nov 6, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Add local evaluation

4 participants