From bfa3eba0de22c65b2dcfaa8079e574e150a59a1e Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:30:06 +0000 Subject: [PATCH] fix(server): log when local-only evaluation omits continuity flags evaluateFlags(onlyEvaluateLocally = true) silently left out every flag with experience continuity on, because local evaluation always treats those flags as inconclusive. Log the omitted flag keys so the caller can see why they are missing. Generated-By: PostHog Desktop Task-Id: 0a6e5669-93e4-40ff-b9af-1be7d03de6fb --- .../warn-local-only-continuity-flags.md | 5 +++ .../server/internal/PostHogFeatureFlags.kt | 15 ++++++- .../src/test/java/com/posthog/server/Utils.kt | 2 + .../internal/PostHogFeatureFlagsTest.kt | 43 +++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 .changeset/warn-local-only-continuity-flags.md diff --git a/.changeset/warn-local-only-continuity-flags.md b/.changeset/warn-local-only-continuity-flags.md new file mode 100644 index 000000000..e6200e904 --- /dev/null +++ b/.changeset/warn-local-only-continuity-flags.md @@ -0,0 +1,5 @@ +--- +"posthog-server": patch +--- + +Log a message when `evaluateFlags(onlyEvaluateLocally = true)` omits flags because experience continuity is on. Local evaluation cannot resolve these flags, so a local-only snapshot does not contain them. The message names the flags. Like all SDK logs, it shows only when `debug` is on. diff --git a/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt b/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt index 2f31d3956..ba8308c47 100644 --- a/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt +++ b/posthog-server/src/main/java/com/posthog/server/internal/PostHogFeatureFlags.kt @@ -252,11 +252,13 @@ internal class PostHogFeatureFlags( /** * The result of one local evaluation pass. [flags] holds every flag that resolved, whether or * not [needsRemote] is set, so callers can fill only the gaps from `/flags`. + * [continuityFlagKeys] holds the flags that did not resolve because experience continuity is on. */ private data class LocalEvaluationOutcome( val flags: Map, val needsRemote: Boolean, val missingDefinitionKeys: Set, + val continuityFlagKeys: Set, ) private class MissingFlagProbe { @@ -317,6 +319,7 @@ internal class PostHogFeatureFlags( val props = localPersonProperties(distinctId, personProperties) val requestedKeys = flagKeys?.toHashSet() var needsRemote = false + val continuityFlagKeys = mutableSetOf() for ((key, flagDef) in currentFlagDefinitions) { if (requestedKeys != null && key !in requestedKeys) { @@ -338,6 +341,9 @@ internal class PostHogFeatureFlags( } catch (e: InconclusiveMatchException) { config.logger.log("Local evaluation inconclusive for flag '$key': ${e.message}") needsRemote = true + if (flagDef.ensureExperienceContinuity) { + continuityFlagKeys.add(key) + } } catch (e: Exception) { config.logger.log("Local evaluation failed for flag '$key': ${e.message}") needsRemote = true @@ -358,7 +364,7 @@ internal class PostHogFeatureFlags( } config.logger.log("Local evaluation resolved ${localFlags.size} flags, needsRemote=$needsRemote") - return LocalEvaluationOutcome(localFlags, needsRemote, missingDefinitionKeys) + return LocalEvaluationOutcome(localFlags, needsRemote, missingDefinitionKeys, continuityFlagKeys) } /** @@ -1105,6 +1111,13 @@ internal class PostHogFeatureFlags( } if (local != null && ((!local.needsRemote && !hasMissingKeyToProbe) || onlyEvaluateLocally)) { + if (onlyEvaluateLocally && local.continuityFlagKeys.isNotEmpty()) { + config.logger.log( + "Local-only evaluation omitted flag(s) ${local.continuityFlagKeys.joinToString(", ")}: " + + "experience continuity is incompatible with local evaluation. " + + "Allow remote evaluation or turn off experience continuity for these flags.", + ) + } return EvaluateFlagsResult( flags = localFlags, locallyEvaluated = localFlags.mapValues { true }, diff --git a/posthog-server/src/test/java/com/posthog/server/Utils.kt b/posthog-server/src/test/java/com/posthog/server/Utils.kt index 9a992b92c..dff17819f 100644 --- a/posthog-server/src/test/java/com/posthog/server/Utils.kt +++ b/posthog-server/src/test/java/com/posthog/server/Utils.kt @@ -508,6 +508,7 @@ private fun evaluationRuntimeJson(evaluationRuntime: String?): String { public fun conclusiveFlagDefinition( key: String, evaluationRuntime: String? = null, + ensureExperienceContinuity: Boolean = false, ): String { return """ { @@ -515,6 +516,7 @@ public fun conclusiveFlagDefinition( "name": "$key", "key": "$key", "active": true, + "ensure_experience_continuity": $ensureExperienceContinuity, ${evaluationRuntimeJson(evaluationRuntime)} "filters": { "groups": [ diff --git a/posthog-server/src/test/java/com/posthog/server/internal/PostHogFeatureFlagsTest.kt b/posthog-server/src/test/java/com/posthog/server/internal/PostHogFeatureFlagsTest.kt index dff366e11..6a1f7b5c9 100644 --- a/posthog-server/src/test/java/com/posthog/server/internal/PostHogFeatureFlagsTest.kt +++ b/posthog-server/src/test/java/com/posthog/server/internal/PostHogFeatureFlagsTest.kt @@ -1849,6 +1849,49 @@ internal class PostHogFeatureFlagsTest { mockServer.shutdown() } + @Test + fun `local only evaluation logs the experience continuity flags it omits`() { + val logger = TestLogger() + val mockServer = + createMockHttp( + jsonResponse( + createLocalEvaluationResponseFrom( + conclusiveFlagDefinition("local-flag"), + conclusiveFlagDefinition("continuity-flag", ensureExperienceContinuity = true), + ), + ), + ) + val config = createTestConfig(logger, mockServer.url("/").toString()) + val featureFlags = + PostHogFeatureFlags( + config, + PostHogApi(config), + 60000, + 100, + localEvaluation = true, + personalApiKey = "test-personal-key", + pollerEnabled = false, + ) + + val result = + featureFlags.evaluateFlags( + distinctId = "user-123", + groups = null, + personProperties = null, + groupProperties = null, + flagKeys = null, + onlyEvaluateLocally = true, + disableGeoip = false, + ) + + assertEquals(setOf("local-flag"), result.flags.keys) + assertEquals(1, mockServer.requestCount, "only the definitions load is allowed") + assertTrue(logger.containsLog("Local-only evaluation omitted flag(s) continuity-flag")) + + featureFlags.shutDown() + mockServer.shutdown() + } + @Test fun `definitions that fail to load are not re-fetched on every evaluateFlags call`() { // A personal API key that always fails never sets `definitionsLoaded`, so nothing but the