Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/warn-local-only-continuity-flags.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, FeatureFlag>,
val needsRemote: Boolean,
val missingDefinitionKeys: Set<String>,
val continuityFlagKeys: Set<String>,
)

private class MissingFlagProbe {
Expand Down Expand Up @@ -317,6 +319,7 @@ internal class PostHogFeatureFlags(
val props = localPersonProperties(distinctId, personProperties)
val requestedKeys = flagKeys?.toHashSet()
var needsRemote = false
val continuityFlagKeys = mutableSetOf<String>()

for ((key, flagDef) in currentFlagDefinitions) {
if (requestedKeys != null && key !in requestedKeys) {
Expand All @@ -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
Expand All @@ -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)
}

/**
Expand Down Expand Up @@ -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 },
Expand Down
2 changes: 2 additions & 0 deletions posthog-server/src/test/java/com/posthog/server/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -508,13 +508,15 @@ private fun evaluationRuntimeJson(evaluationRuntime: String?): String {
public fun conclusiveFlagDefinition(
key: String,
evaluationRuntime: String? = null,
ensureExperienceContinuity: Boolean = false,
): String {
return """
{
"id": 1,
"name": "$key",
"key": "$key",
"active": true,
"ensure_experience_continuity": $ensureExperienceContinuity,
${evaluationRuntimeJson(evaluationRuntime)}
"filters": {
"groups": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading