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