Skip to content

feat(server): filter the evaluateFlags snapshot by evaluation runtime - #805

Merged
gustavohstrassburger merged 6 commits into
mainfrom
gustavo/flag-evaluation-runtime
Sep 28, 2026
Merged

gustavohstrassburger merged 6 commits into
mainfrom
gustavo/flag-evaluation-runtime

Conversation

@gustavohstrassburger

@gustavohstrassburger gustavohstrassburger commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

With local evaluation, a server can't tell which flags are safe to forward to a browser client, for example when bootstrapping a client SDK. Each flag's evaluation_runtime ("all", "client" or "server") is already in the /local_evaluation payload, but posthog-server drops it.

This adds a runtime criterion to the snapshot filter, so the caller picks the flags to forward in one call:

val snapshot = postHog.evaluateFlags(distinctId)
val forBrowser = snapshot.only(PostHogFeatureFlagFilter(evaluationRuntimes = setOf("client", "all")))
  • only(PostHogFeatureFlagFilter) filters the snapshot in memory by keys and/or evaluation runtimes, next to the existing only(keys) and onlyAccessed(). Criteria AND together. Java callers use PostHogFeatureFlagFilter.builder().
  • A flag that falls back to /flags keeps the runtime of its local definition. A flag with an unknown runtime (no local definition, or a definition without the field) never matches a runtime criterion, because /flags does not report the runtime and unknown is not client-safe.
  • Filtering fires no event and does not record access.
  • FlagDefinition and FeatureFlagMetadata (both @PostHogInternal) gain evaluationRuntime, the same route hasExperiment took in feat(flags): add $feature_flag_has_experiment to $feature_flag_called events #621. The metadata field is @Transient so a /flags response is never a source for it.

An earlier revision also exposed a per-key getEvaluationRuntime(key) accessor. It was dropped after review on PostHog/sdk-specs#74, where @dustinbyrne asked for one surface and preferred only(filter) because it extends the existing filter API and does not require iterating keys from the caller's end. PostHog/sdk-specs#77 makes the filter the spec's only runtime surface.

Origin: https://us.posthog.com/project/2/support/tickets/74646 (internal support ticket)

💚 How did you test it?

  • Unit tests for only(filter): the runtime criterion keeps the listed runtimes, drops unknown ones, and combines with keys.
  • End-to-end tests in PostHogEvaluateFlagsTest: a "client" and a "server" definition, a definition without the field, a flag filled from /flags that keeps its local runtime, a flag with no definition, a local-only evaluation, and a /flags response that reports a runtime and is ignored.
  • :posthog-server:test, apiDump and spotlessApply run locally.
  • Not tested against a live PostHog instance.

📝 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.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Written with an AI coding agent, directed by the assignee. An automated code review pass ran before the first commit and found nothing blocking.
  • An earlier design had the SDK filter flags by runtime through an evaluateFlags option. It was rejected in favor of a filter on the snapshot, so the server still evaluates once and branches on the full snapshot.
  • The changeset file was written by hand in the pnpm changeset format.

@gustavohstrassburger gustavohstrassburger self-assigned this Sep 21, 2026
@greptile-apps

greptile-apps Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Retrigger

The implementation appears behaviorally sound, but the repository’s required public-API agreement must be completed before merging.

Reviews (1) · Last reviewed commit: "feat(server): expose flag evaluation run..."

Comment on lines +121 to +123
public fun getEvaluationRuntime(key: String): String? {
return flagMap[key]?.metadata?.evaluationRuntime
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Public API lacks agreement

This adds the public getEvaluationRuntime API even though the PR states that its shape has not been discussed on an issue. The repository requires public API shapes to be agreed on an issue first, so that requirement must be satisfied before merging.

Context Used: AGENTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog-server/src/main/java/com/posthog/server/PostHogFeatureFlagEvaluations.kt
Line: 121-123

Comment:
**Public API lacks agreement**

This adds the public `getEvaluationRuntime` API even though the PR states that its shape has not been discussed on an issue. The repository requires public API shapes to be agreed on an issue first, so that requirement must be satisfied before merging.

**Context Used:** AGENTS.md ([source](https://github.com/posthog/posthog-android/blob/main/AGENTS.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@gustavohstrassburger make sure to document this new API in https://github.com/PostHog/sdk-specs
happy to review!

@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

posthog-android Compliance Report

Date: 2026-09-28 17:10:09 UTC
Duration: 118453ms

✅ All Tests Passed!

46/46 tests passed


Capture Tests

✅ 29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 389ms
Format Validation.Event Has Uuid ✅ 37ms
Format Validation.Event Has Lib Properties ✅ 31ms
Format Validation.Distinct Id Is String ✅ 26ms
Format Validation.Token Is Present ✅ 28ms
Format Validation.Custom Properties Preserved ✅ 33ms
Format Validation.Event Has Timestamp ✅ 24ms
Retry Behavior.Retries On 503 ✅ 7030ms
Retry Behavior.Does Not Retry On 400 ✅ 4030ms
Retry Behavior.Does Not Retry On 401 ✅ 4025ms
Retry Behavior.Respects Retry After Header ✅ 7026ms
Retry Behavior.Implements Backoff ✅ 17037ms
Retry Behavior.Retries On 500 ✅ 7020ms
Retry Behavior.Retries On 502 ✅ 7018ms
Retry Behavior.Retries On 504 ✅ 7016ms
Retry Behavior.Max Retries Respected ✅ 17036ms
Deduplication.Generates Unique Uuids ✅ 44ms
Deduplication.Preserves Uuid On Retry ✅ 7015ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 12030ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 7020ms
Deduplication.No Duplicate Events In Batch ✅ 36ms
Deduplication.Different Events Have Different Uuids ✅ 23ms
Compression.Sends Gzip When Enabled ✅ 18ms
Batch Format.Uses Proper Batch Structure ✅ 18ms
Batch Format.Flush With No Events Sends Nothing ✅ 13ms
Batch Format.Multiple Events Batched Together ✅ 33ms
Error Handling.Does Not Retry On 403 ✅ 4023ms
Error Handling.Does Not Retry On 413 ✅ 4022ms
Error Handling.Retries On 408 ✅ 5029ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 34ms
Request Payload.Flags Request Uses V2 Query Param ✅ 21ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 25ms
Request Payload.Flags Request Omits Authorization Header ✅ 21ms
Request Payload.Token In Flags Body Matches Init ✅ 20ms
Request Payload.Groups Round Trip ✅ 34ms
Request Payload.Groups Default To Empty Object ✅ 26ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 29ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 22ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 24ms
Request Lifecycle.No Flags Request On Init Alone ✅ 8ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 20ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 35ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 24ms
Retry Behavior.Retries Flags On 502 ✅ 324ms
Retry Behavior.Retries Flags On 504 ✅ 323ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 24ms

@gustavohstrassburger
gustavohstrassburger marked this pull request as ready for review September 21, 2026 18:32
@gustavohstrassburger
gustavohstrassburger requested a review from a team as a code owner September 21, 2026 18:32
@marandaneto

Copy link
Copy Markdown
Member

node added that recently PostHog/posthog-js#4885
so is it the same thing? if so, all good, and if not, whats the difference?

@marandaneto

Copy link
Copy Markdown
Member

suggestion: Consider preserving evaluation_runtime from the local definition when a flag falls back to remote evaluation. Currently, a flag configured for "client" or "all" can return null from getEvaluationRuntime() if its value cannot resolve locally (for example, because a required person property is missing). The bootstrap filter in the PR description then excludes that flag despite its client-compatible configuration. Treating null as client-safe would not be a safe workaround because it means unknown. This limitation is documented and is not blocking, but retaining the known runtime could make the accessor more useful for browser bootstrapping.

@gustavohstrassburger

Copy link
Copy Markdown
Contributor Author

node added that recently PostHog/posthog-js#4885
so is it the same thing? if so, all good, and if not, whats the difference?

No, it's a different thing. The only overlap is the evaluation_runtime field.

#4885 is about the /flags request. posthog-node now sends evaluation_runtime: "server" in the body so the backend stops inferring the runtime from headers, which was misclassifying it as a browser and dropping server-only flags. It changes what the SDK sends; there's no new public API.

This PR is about the /local_evaluation response. The payload already carries evaluation_runtime per flag and posthog-server was discarding it. This keeps it and exposes it via getEvaluationRuntime(key) on the evaluateFlags() snapshot, so an app doing server-side bootstrap of a browser client can decide which flags to forward. It changes what the SDK returns; it's new public API and doesn't touch any request.

Different endpoint, different direction, different problem. posthog-server also doesn't declare its runtime on /flags today, so a port of #4885 would be a separate PR.

@gustavohstrassburger

Copy link
Copy Markdown
Contributor Author

Done in 299ade7. A flag that falls back to /flags now keeps the evaluation_runtime of its local definition; it is only null when the flag has no local definition at all. The existing runtime test covers the gated flag filled from /flags ("all") and a definition-less remote flag (null).

@gustavohstrassburger

Copy link
Copy Markdown
Contributor Author

@marandaneto heads-up since your approval predates it: d5f3035 adds only(PostHogFeatureFlagFilter) on the snapshot, filtering by keys and/or evaluation runtimes, so snapshot.only(PostHogFeatureFlagFilter(evaluationRuntimes = setOf("client", "all"))) is the set to forward to a browser. It came out of the review on PostHog/sdk-specs#74; the API shape is discussed there. Unknown runtimes never match. only(keys) is unchanged and delegates to it. API dump, tests and changeset updated.

Comment thread posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt Outdated
@veria-ai

veria-ai Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

@haacked haacked left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice addition! One blocking issue inline, plus a few suggestions.

continue
}
val runtimes = filter.evaluationRuntimes
if (runtimes != null && flag.metadata.evaluationRuntime !in runtimes) continue

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

blocking: only(filter) throws a NullPointerException when evaluationRuntimes is a JDK immutable collection and any flag's runtime is null. With a null runtime, flag.metadata.evaluationRuntime !in runtimes calls Collection.contains(null), and Set.of, List.of, and Set.copyOf throw instead of returning false. So new PostHogFeatureFlagFilter(null, Set.of("client", "all")) fails on the first flag without a local definition, or on every flag when local evaluation is off. The tests pass because Kotlin's setOf and the builder's list both return false for contains(null).

Check for null before the lookup, and add a test that filters with java.util.Set.of("client") over a flag whose runtime is null:

Suggested change
if (runtimes != null && flag.metadata.evaluationRuntime !in runtimes) continue
val runtime = flag.metadata.evaluationRuntime
if (runtimes != null && (runtime == null || runtime !in runtimes)) continue

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, thanks. only(filter) now reads the runtime into a local and skips the flag when it is null before asking the collection. Covered by only with a runtime filter never asks the collection about a null runtime. It uses a java.util.TreeSet rather than Set.of, because animal sniffer checks the test sources against the Java 8 signature and Set.of is Java 9; a TreeSet throws the same NullPointerException on contains(null).

val version: Int,
@SerializedName("has_experiment")
val hasExperiment: Boolean? = null,
@SerializedName("evaluation_runtime")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: Mapping evaluation_runtime here makes /flags a second source for the runtime, but /flags never sends it (the FlagDetailsMetadata in the flags service has no such field). The override loop only replaces the value for flags that have a local definition, and it doesn't run when no definitions are loaded. So for a flag without a local definition, the snapshot reports whatever /flags sends. The test at PostHogEvaluateFlagsTest.kt:636 asserts "client" for that case, while the getEvaluationRuntime KDoc, the changeset, and the sdk-specs#74 contract all say it returns null.

Marking the field @Transient (as RRWireframe.kt:29 does) stops Gson from reading it, so local definitions become the only source. Remove the sentence about reclassification from the merge-loop comment in PostHogFeatureFlags.kt, and update the test at line 636 to assert null.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. evaluationRuntime on FeatureFlagMetadata is now @Transient, so Gson never reads it from /flags and the local definition is the only source. The merge comment lost the reclassification sentence, and the test is now an evaluation runtime reported by flags is ignored, asserting null for the flag without a definition and an empty runtime-filtered snapshot.

// `locally_evaluated_keys`. Note a group flag evaluated without `groups` resolves locally to
// `false`, and that now beats the server's answer — pass `groups` when gating on one.
val merged = LinkedHashMap(entry?.flags ?: EMPTY_FLAGS).apply { putAll(localFlags) }
// The definition in memory is the source of the runtime, even for a flag that fell back to

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: This keeps a fallback flag's runtime only for "all" and "server" flags. The flags service classifies the posthog-server/<version> user agent as a server runtime and leaves every "client" flag out of the /flags response. So a "client" flag gated on a person property the caller didn't pass resolves neither locally nor remotely. It never reaches merged, and only(PostHogFeatureFlagFilter(evaluationRuntimes = setOf("client", "all"))) can't return it. The fallback test only uses an "all" flag.

Could you say so in this comment, the getEvaluationRuntime KDoc and the changeset? Sending an explicit evaluation_runtime in the /flags request would bring these flags back, but it changes every /flags call, so that's your call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Said so in the helper's KDoc, in the getEvaluationRuntime KDoc and in the changeset: /flags answers this SDK as a server runtime and leaves "client" flags out, so a "client" flag that does not resolve locally is not in the snapshot at all. Sending an explicit evaluation_runtime on the /flags request stays out of this PR, since it changes every call.

}

@Test
fun `getEvaluationRuntime passes through the runtime of locally evaluated flags`() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: This test doesn't cover the runtime when every flag resolves locally. gated-flag requires email, so evaluateFlags goes through the /flags merge, where the override loop sets every defined flag's runtime from its definition. Deleting evaluationRuntime = flagDef.evaluationRuntime at PostHogFeatureFlags.kt:803 would leave both runtime tests passing. A local-only read catches it:

val localOnly = postHog.evaluateFlags("user-1", onlyEvaluateLocally = true)
assertEquals("client", localOnly.getEvaluationRuntime("client-flag"))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a local-only read to the passthrough test (onlyEvaluateLocally = true), asserting "client" and "server" for the two conclusive flags, so removing the runtime from buildFeatureFlagFromResult fails it.

// `/flags`, so a caller filtering flags to forward does not drop a client flag because one
// person property was missing. It also wins over anything `/flags` reports for the field,
// so a response cannot reclassify a server-only flag as client-safe.
val definitions = flagDefinitions

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: evaluateFlags is already the longest function in this file, and this block is a separate step placed inside the local/remote merge. Moving it into a named helper, the way evaluateMissingFlagsRemotely is, keeps the merge readable and gives the rule a name:

private fun applyDefinitionRuntimes(flags: MutableMap<String, FeatureFlag>) {
    val definitions = flagDefinitions ?: return
    for (slot in flags.entries) {
        val runtime = (definitions[slot.key] ?: continue).evaluationRuntime
        val flag = slot.value
        if (flag.metadata.evaluationRuntime != runtime) {
            slot.setValue(flag.copy(metadata = flag.metadata.copy(evaluationRuntime = runtime)))
        }
    }
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Extracted as applyDefinitionRuntimes(flags), as suggested, with the rule's comment moved onto it.

/**
* Mutable builder for [PostHogFeatureFlagFilter].
*/
public class Builder {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: The only builder test sets both criteria, so the Java form of the browser filter (PostHogFeatureFlagFilter.builder().evaluationRuntimes("client", "all").build()) has no test. Neither does an empty runtime list, which keeps no flags while null keeps them all.

A PostHogFeatureFlagFilterTest modeled on PostHogEvaluateFlagsOptionsTest would cover both.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added PostHogFeatureFlagFilterTest with the no-criteria default, the runtimes-only builder (evaluationRuntimes("client", "all") leaving keys null) and accumulation across list and varargs calls. The empty-versus-null runtime criterion is asserted in only with a runtime filter keeps the listed runtimes and drops unknown ones: an empty set keeps nothing, an unset filter keeps everything.


@Test
fun `getFlagPayload returns the raw payload string and does not fire an event`() {
fun `getFlagPayload and getEvaluationRuntime do not fire an event or record access`() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: This test reads only known keys, but unknown keys are where isEnabled and getFlag fire flag_missing. Consider adding assertNull(snapshot.getEvaluationRuntime("missing")) before the capture assertion to cover that case too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added assertNull(snapshot.getEvaluationRuntime("missing")) before the capture assertion.

@gustavohstrassburger gustavohstrassburger changed the title feat(server): expose flag evaluation runtime on evaluateFlags snapshot feat(server): filter the evaluateFlags snapshot by evaluation runtime Sep 28, 2026
@gustavohstrassburger
gustavohstrassburger merged commit 39c2485 into main Sep 28, 2026
18 checks passed
@gustavohstrassburger
gustavohstrassburger deleted the gustavo/flag-evaluation-runtime branch September 28, 2026 18:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants