From 449375aca2e8bf0e254dcb0f4c9eaaee9f7e30b2 Mon Sep 17 00:00:00 2001 From: "Gustavo H. Strassburger" Date: Tue, 27 Oct 2026 14:34:40 -0300 Subject: [PATCH 1/6] feat(flags): report unresolved local flags on evaluateFlags snapshots Implements the "Snapshot unresolved flag reporting" and "Warning for flags local evaluation never resolves" requirements of the evaluate-flags spec (PostHog/sdk-specs#85). - Add `FeatureFlagEvaluations::getUnresolvedFlags()`, with an `UnresolvedFlagReason` for each flag and the spec's fixed precedence. - Report `local_evaluation_inconclusive` instead of `flag_missing` when a snapshot read hits an unresolved flag. - Do not stop a condition at the first inconclusive filter, so a filter that does not match resolves the condition locally. - Check `active` before experience continuity, also for dependencies. - Log one warning per flag that local evaluation can never resolve when a fresh definitions response loads. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 39793b31-809e-4689-a5d8-b4359cbb22c1 --- .changeset/unresolved-local-flags.md | 14 + api/public-api.json | 277 +++++--- lib/Client.php | 106 ++- lib/FeatureFlag.php | 793 ++++++++++++++++------ lib/FeatureFlagError.php | 5 + lib/FeatureFlagEvaluations.php | 26 +- lib/InconclusiveMatchException.php | 26 + lib/RequiresServerEvaluationException.php | 26 + lib/UnresolvedFlagReason.php | 35 + test/FeatureFlagEvaluationsTest.php | 339 ++++++++- test/FeatureFlagLocalEvaluationTest.php | 562 ++++++++++++++- test/FlagDefinitionCacheProviderTest.php | 34 + test/FlagDependencyTest.php | 198 +++++- 13 files changed, 2130 insertions(+), 311 deletions(-) create mode 100644 .changeset/unresolved-local-flags.md create mode 100644 lib/UnresolvedFlagReason.php diff --git a/.changeset/unresolved-local-flags.md b/.changeset/unresolved-local-flags.md new file mode 100644 index 00000000..0d249bcb --- /dev/null +++ b/.changeset/unresolved-local-flags.md @@ -0,0 +1,14 @@ +--- +"posthog-php": minor +--- + +Report the flags local evaluation could not resolve on `evaluateFlags()` snapshots. `FeatureFlagEvaluations::getUnresolvedFlags()` maps each such flag key to an `UnresolvedFlagReason` value: `experience_continuity`, `unsupported_definition`, `unresolved_dependency` or `missing_context`. + +Behavior changes: + +- A condition with a filter that local evaluation cannot check and a filter that does not match now resolves locally as no match. An inconclusive flag dependency in one condition, or a dependency that needs server data such as a static cohort, no longer stops a later condition from resolving the flag. So a flag that went to the server before can now resolve locally. +- An inactive flag with experience continuity enabled now resolves to `false` locally instead of going to the server. +- A flag that depends on a flag with experience continuity enabled no longer resolves locally. It goes to the server, or stays unresolved with `onlyEvaluateLocally`. +- A malformed date in a flag filter or a person property now makes its condition inconclusive, like other malformed values, instead of failing local evaluation of the whole flag. +- `$feature_flag_called` events from snapshot reads of unresolved flags report `local_evaluation_inconclusive` instead of `flag_missing`. +- When a fresh flag definitions response (HTTP 200) loads, the SDK logs a warning, also with `debug` off, that names the flags local evaluation can never resolve: flags with experience continuity, and flags that use a static cohort, an unknown operator, a malformed value, or a missing or circular dependency. Each flag is warned about once per client until its definition changes. Definitions from a cache provider or a 304 response do not log the warning. diff --git a/api/public-api.json b/api/public-api.json index 69f97289..4823d405 100644 --- a/api/public-api.json +++ b/api/public-api.json @@ -2166,6 +2166,54 @@ } ] }, + "getDefinitionUnresolvedReason": { + "static": true, + "abstract": false, + "final": false, + "returnType": "?string", + "parameters": [ + { + "name": "flag", + "type": null, + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "flagsByKey", + "type": null, + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "cohorts", + "type": null, + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "groupTypeMapping", + "type": null, + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, "matchCohort": { "static": true, "abstract": false, @@ -2554,6 +2602,10 @@ "type": "string", "value": "flag_missing" }, + "LOCAL_EVALUATION_INCONCLUSIVE": { + "type": "string", + "value": "local_evaluation_inconclusive" + }, "QUOTA_LIMITED": { "type": "string", "value": "quota_limited" @@ -2704,6 +2756,16 @@ "default": null, "defaultConstant": null, "hasDefault": true + }, + { + "name": "unresolvedFlags", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true } ] }, @@ -2757,6 +2819,13 @@ "returnType": "array", "parameters": [] }, + "getUnresolvedFlags": { + "static": false, + "abstract": false, + "final": false, + "returnType": "array", + "parameters": [] + }, "isEnabled": { "static": false, "abstract": false, @@ -3394,126 +3463,67 @@ "constants": [], "properties": [], "methods": { - "errorMessage": { + "__construct": { "static": false, "abstract": false, "final": false, "returnType": null, - "parameters": [] - } - } - }, - "PostHog\\Internal\\SpanContext": { - "type": "class", - "abstract": false, - "final": true, - "readonly": false, - "extends": null, - "implements": [], - "constants": [], - "properties": [], - "methods": { - "enrich": { - "static": true, - "abstract": false, - "final": false, - "returnType": "void", "parameters": [ { - "name": "client", - "type": "PostHog\\Client", + "name": "message", + "type": "string", "byReference": false, "variadic": false, - "optional": false, - "default": null, + "optional": true, + "default": "", "defaultConstant": null, - "hasDefault": false + "hasDefault": true }, { - "name": "span", - "type": "OpenTelemetry\\API\\Trace\\SpanInterface", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - } - ] - }, - "enrichCurrent": { - "static": true, - "abstract": false, - "final": false, - "returnType": "void", - "parameters": [ - { - "name": "client", - "type": "PostHog\\Client", + "name": "code", + "type": "int", "byReference": false, "variadic": false, - "optional": false, - "default": null, + "optional": true, + "default": 0, "defaultConstant": null, - "hasDefault": false - } - ] - }, - "register": { - "static": true, - "abstract": false, - "final": false, - "returnType": "void", - "parameters": [ + "hasDefault": true + }, { - "name": "client", - "type": "PostHog\\Client", + "name": "previous", + "type": "?Throwable", "byReference": false, "variadic": false, - "optional": false, + "optional": true, "default": null, "defaultConstant": null, - "hasDefault": false + "hasDefault": true }, { - "name": "processor", - "type": "object", + "name": "reason", + "type": "string", "byReference": false, "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false + "optional": true, + "default": "unsupported_definition", + "defaultConstant": "PostHog\\UnresolvedFlagReason::UNSUPPORTED_DEFINITION", + "hasDefault": true } ] }, - "unregister": { - "static": true, + "errorMessage": { + "static": false, "abstract": false, "final": false, - "returnType": "void", - "parameters": [ - { - "name": "client", - "type": "PostHog\\Client", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - }, - { - "name": "processor", - "type": "object", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - } - ] + "returnType": null, + "parameters": [] + }, + "getReason": { + "static": false, + "abstract": false, + "final": false, + "returnType": "string", + "parameters": [] } } }, @@ -4674,12 +4684,67 @@ "constants": [], "properties": [], "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "message", + "type": "string", + "byReference": false, + "variadic": false, + "optional": true, + "default": "", + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "code", + "type": "int", + "byReference": false, + "variadic": false, + "optional": true, + "default": 0, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "previous", + "type": "?Throwable", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "reason", + "type": "string", + "byReference": false, + "variadic": false, + "optional": true, + "default": "unsupported_definition", + "defaultConstant": "PostHog\\UnresolvedFlagReason::UNSUPPORTED_DEFINITION", + "hasDefault": true + } + ] + }, "errorMessage": { "static": false, "abstract": false, "final": false, "returnType": null, "parameters": [] + }, + "getReason": { + "static": false, + "abstract": false, + "final": false, + "returnType": "string", + "parameters": [] } } }, @@ -4829,6 +4894,34 @@ } } }, + "PostHog\\UnresolvedFlagReason": { + "type": "class", + "abstract": false, + "final": false, + "readonly": false, + "extends": null, + "implements": [], + "constants": { + "EXPERIENCE_CONTINUITY": { + "type": "string", + "value": "experience_continuity" + }, + "MISSING_CONTEXT": { + "type": "string", + "value": "missing_context" + }, + "UNRESOLVED_DEPENDENCY": { + "type": "string", + "value": "unresolved_dependency" + }, + "UNSUPPORTED_DEFINITION": { + "type": "string", + "value": "unsupported_definition" + } + }, + "properties": [], + "methods": [] + }, "PostHog\\Uuid": { "type": "class", "abstract": false, diff --git a/lib/Client.php b/lib/Client.php index ecc1ce0c..3b1bfd8e 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -243,6 +243,13 @@ class Client implements FeatureFlagEvaluationsHost */ private array $missingDistinctIdWarnings = []; + /** + * Definition fingerprint per flag key already warned about as never locally resolvable. + * + * @var array + */ + private array $warnedUnresolvableFlags = []; + /** * Create a new PostHog client with your project's API key. * @@ -1135,7 +1142,9 @@ public function getAllFlags( * FeatureFlagEvaluations snapshot. When distinctId is omitted, the current request context * distinctId is used if available. Reads on the snapshot do not trigger additional /flags * requests; access via isEnabled() or getFlag() fires a deduped $feature_flag_called event the - * first time each key is touched. + * first time each key is touched. Flags with a local definition that neither local evaluation nor + * the remote fallback resolved are absent from the snapshot; getUnresolvedFlags() lists them with + * a reason. * * @param string|null $distinctId Defaults to the current request context distinctId, when set. * @param array $groups Group identifiers for group-based flags. @@ -1189,6 +1198,7 @@ public function evaluateFlags( ); $records = []; + $unresolvedFlags = []; $requestId = null; $evaluatedAt = null; $errorsWhileComputing = false; @@ -1222,11 +1232,9 @@ public function evaluateFlags( $personProperties, $groupProperties ); - } catch (RequiresServerEvaluationException $e) { - $fallbackToRemote = true; - continue; - } catch (InconclusiveMatchException $e) { + } catch (RequiresServerEvaluationException | InconclusiveMatchException $e) { $fallbackToRemote = true; + $unresolvedFlags[$key] = $e->getReason(); continue; } catch (Exception $e) { $fallbackToRemote = true; @@ -1337,6 +1345,9 @@ public function evaluateFlags( } } + // A flag the remote fallback resolved has a value, so it is not unresolved. + $unresolvedFlags = array_diff_key($unresolvedFlags, $records); + return new FeatureFlagEvaluations( $distinctId, $records, @@ -1348,6 +1359,7 @@ public function evaluateFlags( $errorsWhileComputing, $quotaLimited, $disableGeoip, + $unresolvedFlags, ); } @@ -1478,14 +1490,17 @@ private function computeFlagLocally( // Create evaluation cache for flag dependencies $evaluationCache = []; - if ($featureFlag["ensure_experience_continuity"] ?? false) { - throw new InconclusiveMatchException("Flag has experience continuity enabled"); - } - if (!$featureFlag["active"]) { return false; } + if ($featureFlag["ensure_experience_continuity"] ?? false) { + throw new InconclusiveMatchException( + "Flag has experience continuity enabled", + reason: UnresolvedFlagReason::EXPERIENCE_CONTINUITY + ); + } + $flagFilters = $featureFlag["filters"] ?? []; $aggregationGroupTypeIndex = $flagFilters["aggregation_group_type_index"] ?? null; @@ -1493,7 +1508,10 @@ private function computeFlagLocally( $groupName = $definitionSnapshot['group_type_mapping'][strval($aggregationGroupTypeIndex)] ?? null; if (is_null($groupName)) { - throw new InconclusiveMatchException("Flag has unknown group type index"); + throw new InconclusiveMatchException( + "Flag has unknown group type index", + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } if (!array_key_exists($groupName, $groups)) { @@ -1699,9 +1717,77 @@ private function fetchAndApplyFlagDefinitionsFromApi(): void $data = $this->normalizeFlagDefinitionData($payload); $this->applyFlagDefinitions($data); + // Only a fresh response warns: PHP builds a client per request, and a 304 or a shared-cache + // load carries definitions that a fresh response already warned about. + $this->warnAboutUnresolvableFlags(); $this->storeFlagDefinitionsInCacheProvider($data); } + /** + * Warn about flags whose definition local evaluation cannot resolve. Each flag is warned about + * once per client until its definition changes. + * + * @return void + */ + private function warnAboutUnresolvableFlags(): void + { + $flagKeysByReason = [ + UnresolvedFlagReason::EXPERIENCE_CONTINUITY => [], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION => [], + ]; + // Rebuilt from the current definitions, so a flag that is deleted or becomes resolvable is + // forgotten and warns again if it later returns to an unresolvable definition. + $warnedFlags = []; + foreach ($this->featureFlags as $flag) { + if (!is_array($flag) || !is_string($flag['key'] ?? null)) { + continue; + } + $reason = FeatureFlag::getDefinitionUnresolvedReason( + $flag, + $this->featureFlagsByKey, + $this->cohorts, + $this->groupTypeMapping + ); + if ($reason === null) { + continue; + } + $fingerprint = ($flag['version'] ?? '') . ':' . sha1(serialize($flag)) . ':' . $reason; + $warnedFlags[$flag['key']] = $fingerprint; + if (($this->warnedUnresolvableFlags[$flag['key']] ?? null) !== $fingerprint) { + $flagKeysByReason[$reason][] = $flag['key']; + } + } + $this->warnedUnresolvableFlags = $warnedFlags; + + $experienceContinuityFlags = $flagKeysByReason[UnresolvedFlagReason::EXPERIENCE_CONTINUITY]; + if (count($experienceContinuityFlags) > 0) { + $this->logWarning(sprintf( + 'You are using local evaluation but %d flag(s) have experience continuity enabled and cannot be ' + . 'resolved locally (reason: %s): %s. Experience continuity is incompatible with local evaluation ' + . 'and causes a server request on every evaluation of these flags. To evaluate them locally, ' + . 'disable experience continuity on these flags in PostHog; with onlyEvaluateLocally: true they ' + . 'are left unresolved.', + count($experienceContinuityFlags), + UnresolvedFlagReason::EXPERIENCE_CONTINUITY, + implode(', ', $experienceContinuityFlags) + )); + } + + $unsupportedDefinitionFlags = $flagKeysByReason[UnresolvedFlagReason::UNSUPPORTED_DEFINITION]; + if (count($unsupportedDefinitionFlags) > 0) { + $this->logWarning(sprintf( + 'You are using local evaluation but %d flag(s) use something local evaluation does not support, ' + . 'such as a static cohort, an unknown operator, a malformed value, or a missing or circular flag ' + . 'dependency (reason: %s): %s. Evaluating these flags falls back to a server request when ' + . 'evaluation reaches that part. To evaluate them locally, change these flags in PostHog or ' + . 'upgrade posthog-php.', + count($unsupportedDefinitionFlags), + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + implode(', ', $unsupportedDefinitionFlags) + )); + } + } + /** * Normalize API flag definition data using SDK defaults for optional fields. * diff --git a/lib/FeatureFlag.php b/lib/FeatureFlag.php index af4dc1a3..b22917a1 100644 --- a/lib/FeatureFlag.php +++ b/lib/FeatureFlag.php @@ -13,6 +13,15 @@ class FeatureFlag { private const LONG_SCALE = 0xfffffffffffffff; + // When several causes apply to one flag, the spec reports the first reason in this order, so + // every SDK picks the same reason whatever order it inspects the filters in. + private const UNRESOLVED_REASON_PRECEDENCE = [ + UnresolvedFlagReason::EXPERIENCE_CONTINUITY, + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + UnresolvedFlagReason::UNRESOLVED_DEPENDENCY, + UnresolvedFlagReason::MISSING_CONTEXT, + ]; + /** * Match a single property filter against provided property values. * @@ -23,13 +32,93 @@ class FeatureFlag * @throws InconclusiveMatchException When the property cannot be evaluated locally. */ public static function matchProperty($property, $propertyValues, $propertyMatchingVersion = 1) + { + try { + return FeatureFlag::matchPropertyValue($property, $propertyValues, $propertyMatchingVersion); + } catch (InconclusiveMatchException $e) { + // An unusable filter outranks missing context, so check the filter before blaming the context. + if ($e->getReason() === UnresolvedFlagReason::MISSING_CONTEXT) { + FeatureFlag::assertFilterSupported($property); + } + throw $e; + } + } + + /** + * Throw an unsupported_definition error when local evaluation cannot use the filter, whatever + * the context holds: an unknown operator, or a filter value that the operator cannot parse. + * + * @param array $property Feature flag property filter. + * @throws InconclusiveMatchException When local evaluation cannot use the filter. + */ + private static function assertFilterSupported($property): void + { + $operator = $property["operator"] ?? "exact"; + $value = $property["value"] ?? null; + + if ( + in_array($operator, [ + "exact", "is_not", "is_set", "is_not_set", "icontains", "not_icontains", + "starts_with", "not_starts_with", "ends_with", "not_ends_with", + ]) + ) { + return; + } + + if (in_array($operator, ["regex", "not_regex"])) { + $pattern = FeatureFlag::valueToString($value); + FeatureFlag::assertPortableRegex($pattern); + FeatureFlag::isRegularExpression(FeatureFlag::prepareValueForRegex($pattern)); + return; + } + + if (in_array($operator, ["gt", "gte", "lt", "lte"])) { + FeatureFlag::parseNumericFilter($value); + return; + } + + if (in_array($operator, ["is_date_before", "is_date_after"])) { + FeatureFlag::parseDateFilter($value); + return; + } + + if (in_array($operator, ["semver_eq", "semver_neq", "semver_gt", "semver_gte", "semver_lt", "semver_lte"])) { + FeatureFlag::parseSemver($value); + return; + } + + if ($operator === "semver_tilde") { + FeatureFlag::tildeBounds($value); + return; + } + + if ($operator === "semver_caret") { + FeatureFlag::caretBounds($value); + return; + } + + if ($operator === "semver_wildcard") { + FeatureFlag::wildcardBounds($value); + return; + } + + throw new InconclusiveMatchException( + "Unknown operator: " . $operator, + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); + } + + private static function matchPropertyValue($property, $propertyValues, $propertyMatchingVersion) { $key = $property["key"]; $operator = $property["operator"] ?? "exact"; $value = $property["value"]; if (!array_key_exists($key, $propertyValues)) { - throw new InconclusiveMatchException("Can't match properties without a given property value"); + throw new InconclusiveMatchException( + "Can't match properties without a given property value", + reason: UnresolvedFlagReason::MISSING_CONTEXT + ); } if ($operator == "is_not_set") { @@ -85,22 +174,25 @@ public static function matchProperty($property, $propertyValues, $propertyMatchi $input = FeatureFlag::valueToString($overrideValue); // Rust and PCRE disagree on word membership for join controls, marks, numbers and symbols. if ($usesWordClass && preg_match('/[^\x00-\x7f\p{L}\p{Nd}]/u', $input) === 1) { - throw new InconclusiveMatchException("Regex Unicode word class requires server evaluation"); + throw new InconclusiveMatchException( + "Regex Unicode word class requires server evaluation", + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } $matched = preg_match($regexValue, $input); if ($matched === false) { - throw new InconclusiveMatchException("Regex execution failed: " . preg_last_error_msg()); + throw new InconclusiveMatchException( + "Regex execution failed: " . preg_last_error_msg(), + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } return ($matched === 1) === ($operator == "regex"); } if (in_array($operator, ["gt", "gte", "lt", "lte"])) { - $parsedValue = FeatureFlag::parseNumericValue($value); - if ($parsedValue === null) { - throw new InconclusiveMatchException("The number set on the flag is not a valid format"); - } + $parsedValue = FeatureFlag::parseNumericFilter($value); $parsedOverrideValue = FeatureFlag::parseNumericValue($overrideValue); if ($parsedOverrideValue === null) { @@ -111,17 +203,9 @@ public static function matchProperty($property, $propertyValues, $propertyMatchi } if (in_array($operator, ["is_date_before", "is_date_after"])) { - $parsedDate = FeatureFlag::relativeDateParseForFeatureFlagMatching($value); - - if (is_null($parsedDate)) { - $parsedDate = FeatureFlag::convertToDateTime($value); - } + $parsedDate = FeatureFlag::parseDateFilter($value); - if (is_null($parsedDate)) { - throw new InconclusiveMatchException("The date set on the flag is not a valid format"); - } - - $overrideDate = FeatureFlag::convertToDateTime($overrideValue); + $overrideDate = FeatureFlag::convertToDateTime($overrideValue, UnresolvedFlagReason::MISSING_CONTEXT); if ($operator == 'is_date_before') { return $overrideDate < $parsedDate; } else { @@ -129,10 +213,11 @@ public static function matchProperty($property, $propertyValues, $propertyMatchi } } - // Semver operators + // Semver operators. The filter is parsed before the context, so a bad filter reports + // unsupported_definition even when the context value is bad too. if (in_array($operator, ["semver_eq", "semver_neq", "semver_gt", "semver_gte", "semver_lt", "semver_lte"])) { - $overrideTuple = FeatureFlag::parseSemver($overrideValue); $valueTuple = FeatureFlag::parseSemver($value); + $overrideTuple = FeatureFlag::parseContextSemver($overrideValue); $comparison = FeatureFlag::compareSemverTuples($overrideTuple, $valueTuple); @@ -152,30 +237,33 @@ public static function matchProperty($property, $propertyValues, $propertyMatchi } if ($operator === "semver_tilde") { - $overrideTuple = FeatureFlag::parseSemver($overrideValue); list($lower, $upper) = FeatureFlag::tildeBounds($value); + $overrideTuple = FeatureFlag::parseContextSemver($overrideValue); return FeatureFlag::compareSemverTuples($overrideTuple, $lower) >= 0 && FeatureFlag::compareSemverTuples($overrideTuple, $upper) < 0; } if ($operator === "semver_caret") { - $overrideTuple = FeatureFlag::parseSemver($overrideValue); list($lower, $upper) = FeatureFlag::caretBounds($value); + $overrideTuple = FeatureFlag::parseContextSemver($overrideValue); return FeatureFlag::compareSemverTuples($overrideTuple, $lower) >= 0 && FeatureFlag::compareSemverTuples($overrideTuple, $upper) < 0; } if ($operator === "semver_wildcard") { - $overrideTuple = FeatureFlag::parseSemver($overrideValue); list($lower, $upper) = FeatureFlag::wildcardBounds($value); + $overrideTuple = FeatureFlag::parseContextSemver($overrideValue); return FeatureFlag::compareSemverTuples($overrideTuple, $lower) >= 0 && FeatureFlag::compareSemverTuples($overrideTuple, $upper) < 0; } - throw new InconclusiveMatchException("Unknown operator: " . $operator); + throw new InconclusiveMatchException( + "Unknown operator: " . $operator, + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } /** @@ -219,7 +307,7 @@ private static function matchCohortForIdentity( $propertyValues, $cohortProperties, $flagsByKey, - $evaluationCache, + &$evaluationCache, $distinctId, $propertyMatchingVersion, $identityAggregation @@ -228,7 +316,8 @@ private static function matchCohortForIdentity( if (!array_key_exists($cohortId, $cohortProperties)) { throw new RequiresServerEvaluationException( "cohort {$cohortId} not found in local cohorts - " . - "likely a static cohort that requires server evaluation" + "likely a static cohort that requires server evaluation", + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION ); } @@ -286,7 +375,7 @@ private static function matchPropertyGroupForIdentity( $propertyValues, $cohortProperties, $flagsByKey, - $evaluationCache, + &$evaluationCache, $distinctId, $propertyMatchingVersion, $identityAggregation @@ -303,7 +392,7 @@ private static function matchPropertyGroupForIdentity( return true; } - $errorMatchingLocally = false; + $inconclusiveReason = null; if (array_key_exists("values", $properties[0])) { // a nested property group @@ -331,14 +420,17 @@ private static function matchPropertyGroupForIdentity( } } catch (RequiresServerEvaluationException $err) { // Immediately propagate - this condition requires server-side data - throw $err; + throw FeatureFlag::withHigherReason($err, $inconclusiveReason); } catch (InconclusiveMatchException $err) { - $errorMatchingLocally = true; + $inconclusiveReason = FeatureFlag::pickUnresolvedReason($inconclusiveReason, $err->getReason()); } } - if ($errorMatchingLocally) { - throw new InconclusiveMatchException("Can't match cohort without a given cohort property value"); + if ($inconclusiveReason !== null) { + throw new InconclusiveMatchException( + "Can't match cohort without a given cohort property value", + reason: $inconclusiveReason + ); } // if we get here, all matched in AND case, or none matched in OR case return $propertyGroupType === 'AND'; @@ -394,18 +486,17 @@ private static function matchPropertyGroupForIdentity( } } catch (RequiresServerEvaluationException $err) { // Immediately propagate - this condition requires server-side data - throw $err; + throw FeatureFlag::withHigherReason($err, $inconclusiveReason); } catch (InconclusiveMatchException $err) { - // If this is a flag dependency error, preserve the original message - if ($propType === 'flag') { - throw $err; - } - $errorMatchingLocally = true; + $inconclusiveReason = FeatureFlag::pickUnresolvedReason($inconclusiveReason, $err->getReason()); } } - if ($errorMatchingLocally) { - throw new InconclusiveMatchException("can't match cohort without a given cohort property value"); + if ($inconclusiveReason !== null) { + throw new InconclusiveMatchException( + "can't match cohort without a given cohort property value", + reason: $inconclusiveReason + ); } // if we get here, all matched in AND case, or none matched in OR case @@ -461,10 +552,16 @@ private static function parseSemverNumeric(string $part, string $component, $val { // Semver 2.0.0 ยง2: numeric identifiers MUST NOT include leading zeros. if ($part === "" || !ctype_digit($part)) { - throw new InconclusiveMatchException("Cannot parse semver: invalid {$component} version in {$value}"); + throw new InconclusiveMatchException( + "Cannot parse semver: invalid {$component} version in {$value}", + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } if (strlen($part) > 1 && $part[0] === "0") { - throw new InconclusiveMatchException("Cannot parse semver: {$component} version has leading zero in {$value}"); + throw new InconclusiveMatchException( + "Cannot parse semver: {$component} version has leading zero in {$value}", + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } return intval($part); } @@ -489,20 +586,36 @@ private static function parseSemverNumeric(string $part, string $component, $val public static function parseSemver($value): array { if ($value === null || $value === "") { - throw new InconclusiveMatchException("Cannot parse empty or null value as semver"); + throw new InconclusiveMatchException( + "Cannot parse empty or null value as semver", + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); + } + + if (!is_scalar($value)) { + throw new InconclusiveMatchException( + "Cannot parse a non-scalar value as semver", + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } $text = trim(strval($value)); if ($text === "") { - throw new InconclusiveMatchException("Cannot parse empty value as semver"); + throw new InconclusiveMatchException( + "Cannot parse empty value as semver", + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } // Strip v/V prefix $text = ltrim($text, "vV"); if ($text === "") { - throw new InconclusiveMatchException("Cannot parse semver: only prefix found"); + throw new InconclusiveMatchException( + "Cannot parse semver: only prefix found", + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } // Strip pre-release and build metadata (split on - or +, take first part) @@ -510,7 +623,10 @@ public static function parseSemver($value): array // Check for leading dot if (str_starts_with($text, ".")) { - throw new InconclusiveMatchException("Cannot parse semver with leading dot: {$value}"); + throw new InconclusiveMatchException( + "Cannot parse semver with leading dot: {$value}", + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } // Split on dots @@ -601,7 +717,17 @@ private static function caretBounds($value): array private static function wildcardBounds($value): array { if ($value === null || $value === "") { - throw new InconclusiveMatchException("Cannot parse empty or null value as semver wildcard"); + throw new InconclusiveMatchException( + "Cannot parse empty or null value as semver wildcard", + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); + } + + if (!is_scalar($value)) { + throw new InconclusiveMatchException( + "Cannot parse a non-scalar value as semver wildcard", + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } $text = trim(strval($value)); @@ -618,7 +744,10 @@ private static function wildcardBounds($value): array } if (count($parts) === 0) { - throw new InconclusiveMatchException("Cannot parse semver wildcard: no version components found in {$value}"); + throw new InconclusiveMatchException( + "Cannot parse semver wildcard: no version components found in {$value}", + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } $major = FeatureFlag::parseSemverNumeric($parts[0], "major", $value); @@ -637,21 +766,67 @@ private static function wildcardBounds($value): array return [$lower, $upper]; } - private static function convertToDateTime($value) + private static function parseDateFilter($value): \DateTime + { + $parsedDate = is_string($value) ? FeatureFlag::relativeDateParseForFeatureFlagMatching($value) : null; + + return $parsedDate ?? FeatureFlag::convertToDateTime($value, UnresolvedFlagReason::UNSUPPORTED_DEFINITION); + } + + /** + * @param string $reason Reason for an unusable value: unsupported_definition for a filter value, + * missing_context for a value the evaluation call passed. + */ + private static function convertToDateTime($value, string $reason): \DateTime { if ($value instanceof \DateTime) { return $value; - } elseif (is_string($value)) { - try { - $date = new \DateTime($value); - if (!is_nan($date->getTimestamp())) { - return $date; - } - } catch (Exception $e) { - throw new InconclusiveMatchException("{$value} is in an invalid date format"); - } - } else { - throw new InconclusiveMatchException("The date provided {$value} must be a string or date object"); + } + + if (!is_string($value)) { + throw new InconclusiveMatchException( + "The date provided " . FeatureFlag::valueToString($value) . " must be a string or date object", + reason: $reason + ); + } + + try { + return new \DateTime($value); + } catch (\Exception $e) { + throw new InconclusiveMatchException( + "{$value} is in an invalid date format: " . $e->getMessage(), + 0, + $e, + $reason + ); + } + } + + private static function parseNumericFilter($value): float + { + $parsedValue = FeatureFlag::parseNumericValue($value); + if ($parsedValue === null) { + throw new InconclusiveMatchException( + "The number set on the flag is not a valid format", + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); + } + + return $parsedValue; + } + + /** + * Parse a semver value read from the evaluation call: failing to use it is missing context, + * not an unsupported definition. + * + * @return array{int, int, int} + */ + private static function parseContextSemver($value): array + { + try { + return FeatureFlag::parseSemver($value); + } catch (InconclusiveMatchException $e) { + throw new InconclusiveMatchException($e->getMessage(), 0, $e, UnresolvedFlagReason::MISSING_CONTEXT); } } @@ -1062,7 +1237,7 @@ private static function matchFeatureFlagPropertiesForIdentity( $properties, $cohorts, $flagsByKey, - $evaluationCache, + &$evaluationCache, $groups, $groupProperties, $groupTypeMapping, @@ -1080,7 +1255,8 @@ private static function matchFeatureFlagPropertiesForIdentity( $flagConditions = $flagFilters["groups"] ?? []; $flagAggregation = $flagFilters["aggregation_group_type_index"] ?? null; $earlyExitEnabled = $flagFilters["early_exit"] ?? false; - $isInconclusive = false; + $inconclusiveReason = null; + $groupEvaluationCaches = []; foreach ($flagConditions as $condition) { try { @@ -1095,6 +1271,7 @@ private static function matchFeatureFlagPropertiesForIdentity( $effectiveProperties = $properties; $effectiveBucketing = $distinctId; $effectiveAggregation = $identityAggregation; + $conditionEvaluationCache = &$evaluationCache; // Mixed-override path: condition-level aggregation differs from flag-level. // This assumes flag-level aggregation is null for mixed flags. @@ -1105,12 +1282,18 @@ private static function matchFeatureFlagPropertiesForIdentity( continue; } if (!array_key_exists($groupName, $groupProperties)) { - $isInconclusive = true; + $inconclusiveReason = FeatureFlag::pickUnresolvedReason( + $inconclusiveReason, + UnresolvedFlagReason::MISSING_CONTEXT + ); continue; } $effectiveProperties = $groupProperties[$groupName]; $effectiveBucketing = $groups[$groupName]; $effectiveAggregation = $conditionAggregation; + // Dependency results depend on the bucketing identity, so each group keeps its own. + $groupEvaluationCaches[$groupName] ??= is_null($evaluationCache) ? null : []; + $conditionEvaluationCache = &$groupEvaluationCaches[$groupName]; } } @@ -1121,7 +1304,7 @@ private static function matchFeatureFlagPropertiesForIdentity( $effectiveProperties, $cohorts, $flagsByKey, - $evaluationCache, + $conditionEvaluationCache, $propertyMatchingVersion, $effectiveAggregation ); @@ -1146,28 +1329,24 @@ private static function matchFeatureFlagPropertiesForIdentity( // evaluating (early exit still applies to later groups) and let the // post-loop inconclusive check fall back to server evaluation. A throw here // would be swallowed by the catch below and evaluation would wrongly continue. - if ($isInconclusive) { + if ($inconclusiveReason !== null) { break; } return false; } } catch (RequiresServerEvaluationException $e) { // Immediately propagate - this condition requires server-side data - throw $e; + throw FeatureFlag::withHigherReason($e, $inconclusiveReason); } catch (InconclusiveMatchException $e) { - // If this is a flag dependency error, preserve the original message - if ( - strpos($e->getMessage(), "Cannot evaluate flag dependency") !== false || - strpos($e->getMessage(), "Circular dependency detected") !== false - ) { - throw $e; - } - $isInconclusive = true; + $inconclusiveReason = FeatureFlag::pickUnresolvedReason($inconclusiveReason, $e->getReason()); } } - if ($isInconclusive) { - throw new InconclusiveMatchException("Can't determine if feature flag is enabled or not with given properties"); //phpcs:ignore + if ($inconclusiveReason !== null) { + throw new InconclusiveMatchException( + "Can't determine if feature flag is enabled or not with given properties", + reason: $inconclusiveReason + ); } return false; @@ -1180,40 +1359,57 @@ private static function isConditionMatch( $properties, $cohorts, $flagsByKey, - $evaluationCache, + &$evaluationCache, $propertyMatchingVersion, $identityAggregation ): ConditionMatch { $rolloutPercentage = array_key_exists("rollout_percentage", $condition) ? $condition["rollout_percentage"] : null; if (count($condition['properties'] ?? []) > 0) { + // Filters in a condition have no defined order, so an inconclusive filter does not stop + // the loop: a later filter can still rule the condition out or carry a higher-precedence reason. + $inconclusiveReason = null; + $serverEvaluationError = null; foreach ($condition['properties'] as $property) { - $matches = false; $propertyType = $property['type'] ?? null; - if ($propertyType == 'cohort') { - $matches = FeatureFlag::matchCohortForIdentity( - $property, - $properties, - $cohorts, - $flagsByKey, - $evaluationCache, - $distinctId, - $propertyMatchingVersion, - $identityAggregation - ); - } elseif ($propertyType == 'flag') { - $matches = FeatureFlag::evaluateFlagDependencyForIdentity( - $property, - $flagsByKey, - $evaluationCache, - $distinctId, - $properties, - $cohorts, - $propertyMatchingVersion, - $identityAggregation - ); - } else { - $matches = FeatureFlag::matchProperty($property, $properties, $propertyMatchingVersion); + try { + if ($propertyType == 'cohort') { + $matches = FeatureFlag::matchCohortForIdentity( + $property, + $properties, + $cohorts, + $flagsByKey, + $evaluationCache, + $distinctId, + $propertyMatchingVersion, + $identityAggregation + ); + } elseif ($propertyType == 'flag') { + $matches = FeatureFlag::evaluateFlagDependencyForIdentity( + $property, + $flagsByKey, + $evaluationCache, + $distinctId, + $properties, + $cohorts, + $propertyMatchingVersion, + $identityAggregation + ); + } else { + $matches = FeatureFlag::matchProperty($property, $properties, $propertyMatchingVersion); + } + } catch (RequiresServerEvaluationException $e) { + // Fall back to the server only when this is the first filter that could not be + // checked, as when the loop stopped there. After another inconclusive filter, + // later conditions must still be able to resolve the flag locally. + if ($inconclusiveReason === null) { + $serverEvaluationError = $e; + } + $inconclusiveReason = FeatureFlag::pickUnresolvedReason($inconclusiveReason, $e->getReason()); + continue; + } catch (InconclusiveMatchException $e) { + $inconclusiveReason = FeatureFlag::pickUnresolvedReason($inconclusiveReason, $e->getReason()); + continue; } if (!$matches) { @@ -1221,6 +1417,18 @@ private static function isConditionMatch( } } + // Rethrow the server error type: the caller stops at it and falls back to the server + // instead of trying later conditions. + if ($serverEvaluationError !== null) { + throw FeatureFlag::withHigherReason($serverEvaluationError, $inconclusiveReason); + } + if ($inconclusiveReason !== null) { + throw new InconclusiveMatchException( + sprintf("Can't determine if a condition of flag '%s' matches", $featureFlag["key"] ?? "unknown"), + reason: $inconclusiveReason + ); + } + if (is_null($rolloutPercentage)) { return ConditionMatch::Match; } @@ -1233,6 +1441,38 @@ private static function isConditionMatch( return ConditionMatch::Match; } + /** + * Pick the reason to report when two causes apply to one flag. + * + * @param string|null $current Reason found so far, if any. + * @param string $next Reason of the cause just found. + * @return string + */ + private static function pickUnresolvedReason(?string $current, string $next): string + { + if ($current === null) { + return $next; + } + + $precedence = array_flip(self::UNRESOLVED_REASON_PRECEDENCE); + return $precedence[$next] < $precedence[$current] ? $next : $current; + } + + /** + * The server-required error with the higher-precedence reason of its own and $reason. + */ + private static function withHigherReason( + RequiresServerEvaluationException $e, + ?string $reason + ): RequiresServerEvaluationException { + $higherReason = FeatureFlag::pickUnresolvedReason($reason, $e->getReason()); + if ($higherReason === $e->getReason()) { + return $e; + } + + return new RequiresServerEvaluationException($e->getMessage(), 0, $e, $higherReason); + } + private static function isRegularExpression($string) { if ($string === null) { @@ -1246,7 +1486,10 @@ private static function isRegularExpression($string) restore_error_handler(); if (!$isRegularExpression && $error !== PREG_INTERNAL_ERROR) { // Preflight also executes the pattern; execution limits do not mean invalid syntax. - throw new InconclusiveMatchException("Regex execution failed: " . $errorMessage); + throw new InconclusiveMatchException( + "Regex execution failed: " . $errorMessage, + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } return $isRegularExpression; } @@ -1267,7 +1510,10 @@ private static function assertPortableRegex(string $pattern): bool } if ($escaped === 'x') { if (!preg_match('/^[0-9a-fA-F]{2}$/D', substr($pattern, $index + 1, 2))) { - throw new InconclusiveMatchException('Regex escape requires server evaluation'); + throw new InconclusiveMatchException( + 'Regex escape requires server evaluation', + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } $index += 2; } elseif ( @@ -1277,20 +1523,29 @@ private static function assertPortableRegex(string $pattern): bool && !preg_match('/^[\x20-\x2f:;=?@\x5b-\x60\x7b-\x7e]$/D', $escaped) ) { // In particular, word boundaries can exhaust the service's 10,000-step budget. - throw new InconclusiveMatchException('Regex escape requires server evaluation'); + throw new InconclusiveMatchException( + 'Regex escape requires server evaluation', + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } if ( $inClass && !str_contains('dDsSwWbfnrtx\\^-[]', $escaped) ) { - throw new InconclusiveMatchException('Regex class escape requires server evaluation'); + throw new InconclusiveMatchException( + 'Regex class escape requires server evaluation', + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } if ( $inClass && str_contains('dDsSwW', $escaped) && (($pattern[$index - 2] ?? '') === '-' || (($pattern[$index + 1] ?? '') === '-' && ($pattern[$index + 2] ?? '') !== ']')) ) { - throw new InconclusiveMatchException('Regex class range requires server evaluation'); + throw new InconclusiveMatchException( + 'Regex class range requires server evaluation', + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } if ($escaped === 'w' || $escaped === 'W') { $usesWordClass = true; @@ -1302,20 +1557,29 @@ private static function assertPortableRegex(string $pattern): bool if ($character === '[') { // PCRE accepts POSIX/nested/set classes with semantics different from Rust's. if ($inClass) { - throw new InconclusiveMatchException('Regex nested class requires server evaluation'); + throw new InconclusiveMatchException( + 'Regex nested class requires server evaluation', + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } $inClass = true; $classStart = $index + (($pattern[$index + 1] ?? '') === '^' ? 2 : 1); } elseif ($character === ']' && $inClass) { if ($index === $classStart) { - throw new InconclusiveMatchException('Regex empty class requires server evaluation'); + throw new InconclusiveMatchException( + 'Regex empty class requires server evaluation', + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } $inClass = false; $previousToken = 'literal'; continue; } elseif ($inClass) { if (in_array(substr($pattern, $index, 2), ['&&', '--', '~~'], true)) { - throw new InconclusiveMatchException('Regex set operation requires server evaluation'); + throw new InconclusiveMatchException( + 'Regex set operation requires server evaluation', + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } continue; } elseif ($character === '(' && ($pattern[$index + 1] ?? '') === '?') { @@ -1325,10 +1589,16 @@ private static function assertPortableRegex(string $pattern): bool $index += 3; continue; } else { - throw new InconclusiveMatchException('Regex group requires server evaluation'); + throw new InconclusiveMatchException( + 'Regex group requires server evaluation', + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } } elseif ($character === '(' && ($pattern[$index + 1] ?? '') === '*') { - throw new InconclusiveMatchException('Regex verb requires server evaluation'); + throw new InconclusiveMatchException( + 'Regex verb requires server evaluation', + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } elseif ($character === '{') { if ( !preg_match('/^\{(\d+)(?:,(\d*))?\}/', substr($pattern, $index), $repetition) @@ -1337,16 +1607,25 @@ private static function assertPortableRegex(string $pattern): bool || (isset($repetition[2]) && (float) $repetition[2] > 65535) ) { // PCRE's repetition count limit is smaller than the service's. - throw new InconclusiveMatchException('Regex repetition requires server evaluation'); + throw new InconclusiveMatchException( + 'Regex repetition requires server evaluation', + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } } elseif ( str_contains('*+?}', $character) && in_array($pattern[$index + 1] ?? '', ['+', '{'], true) ) { - throw new InconclusiveMatchException('Regex stacked repetition requires server evaluation'); + throw new InconclusiveMatchException( + 'Regex stacked repetition requires server evaluation', + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } if (str_contains('*+?{', $character) && in_array($previousToken, ['^', '$', 'empty group'], true)) { - throw new InconclusiveMatchException('Regex assertion repetition requires server evaluation'); + throw new InconclusiveMatchException( + 'Regex assertion repetition requires server evaluation', + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } $previousToken = $character === ')' && in_array($previousToken, ['(', 'empty group'], true) ? 'empty group' : $character; @@ -1404,7 +1683,7 @@ public static function evaluateFlagDependency( private static function evaluateFlagDependencyForIdentity( $property, $flagsByKey, - $evaluationCache, + &$evaluationCache, $distinctId, $properties, $cohortProperties, @@ -1412,95 +1691,116 @@ private static function evaluateFlagDependencyForIdentity( $identityAggregation ) { if ($flagsByKey === null || $evaluationCache === null) { - throw new InconclusiveMatchException(sprintf( - "Cannot evaluate flag dependency on '%s' without flags_by_key and evaluation_cache", - $property["key"] ?? "unknown" - )); + throw new InconclusiveMatchException( + sprintf( + "Cannot evaluate flag dependency on '%s' without flags_by_key and evaluation_cache", + $property["key"] ?? "unknown" + ), + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } // Check if dependency_chain is present - it should always be provided for flag dependencies - if (!array_key_exists("dependency_chain", $property)) { - throw new InconclusiveMatchException(sprintf( - "Cannot evaluate flag dependency on '%s' without dependency_chain", - $property["key"] ?? "unknown" - )); + if (!is_array($property["dependency_chain"] ?? null)) { + throw new InconclusiveMatchException( + sprintf( + "Cannot evaluate flag dependency on '%s' without dependency_chain", + $property["key"] ?? "unknown" + ), + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } $dependencyChain = $property["dependency_chain"]; // Handle circular dependency (empty chain means circular) if (count($dependencyChain) === 0) { - throw new InconclusiveMatchException(sprintf( - "Circular dependency detected for flag '%s'", - $property["key"] ?? "unknown" - )); + throw new InconclusiveMatchException( + sprintf("Circular dependency detected for flag '%s'", $property["key"] ?? "unknown"), + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } // The flag key to evaluate is in the "key" field $depFlagKey = $property["key"] ?? null; if (!$depFlagKey) { - throw new InconclusiveMatchException(sprintf( - "Flag dependency missing 'key' field: %s", - json_encode($property) - )); + throw new InconclusiveMatchException( + sprintf("Flag dependency missing 'key' field: %s", json_encode($property)), + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); + } + + // A dependency with no definition outranks one that is only inconclusive, so check the + // whole chain before evaluating any of it. + foreach (array_merge($dependencyChain, [$depFlagKey]) as $chainFlagKey) { + if (!array_key_exists($chainFlagKey, $evaluationCache) && empty($flagsByKey[$chainFlagKey])) { + throw new InconclusiveMatchException( + sprintf("Cannot evaluate flag dependency '%s' - flag not found in local flags", $chainFlagKey), + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); + } } // Check if we've already evaluated this flag if (!array_key_exists($depFlagKey, $evaluationCache)) { - // Need to evaluate this dependency first - $depFlag = $flagsByKey[$depFlagKey] ?? null; - if (!$depFlag) { - // Missing flag dependency - cannot evaluate locally + $depFlag = $flagsByKey[$depFlagKey]; + // Same order as Client::computeFlagLocally: an inactive flag is false whatever its + // experience continuity setting. + if (!($depFlag["active"] ?? false)) { + $evaluationCache[$depFlagKey] = false; + } elseif ($depFlag["ensure_experience_continuity"] ?? false) { $evaluationCache[$depFlagKey] = null; - throw new InconclusiveMatchException(sprintf( - "Cannot evaluate flag dependency '%s' - flag not found in local flags", - $depFlagKey - )); + throw new InconclusiveMatchException( + sprintf( + "Cannot evaluate flag dependency '%s' - flag has experience continuity enabled", + $depFlagKey + ), + reason: UnresolvedFlagReason::UNRESOLVED_DEPENDENCY + ); } else { - // Check if the flag is active (same check as in Client::computeFlagLocally) - if (!($depFlag["active"] ?? false)) { - $evaluationCache[$depFlagKey] = false; - } else { - // Holdout bucketing uses the evaluated identity, which can't be resolved for a - // dependency aggregated differently, so defer to server evaluation. - $depFilters = $depFlag["filters"] ?? []; - if ( - $identityAggregation !== false && - FeatureFlag::hasActiveHoldout($depFilters["holdout"] ?? null) && - FeatureFlag::aggregationKey($depFilters["aggregation_group_type_index"] ?? null) !== - FeatureFlag::aggregationKey($identityAggregation) - ) { - throw new InconclusiveMatchException(sprintf( + // Holdout bucketing uses the evaluated identity, which can't be resolved for a + // dependency aggregated differently, so defer to server evaluation. + $depFilters = $depFlag["filters"] ?? []; + if ( + $identityAggregation !== false && + FeatureFlag::hasActiveHoldout($depFilters["holdout"] ?? null) && + FeatureFlag::aggregationKey($depFilters["aggregation_group_type_index"] ?? null) !== + FeatureFlag::aggregationKey($identityAggregation) + ) { + throw new InconclusiveMatchException( + sprintf( "Cannot evaluate flag dependency '%s' with a holdout aggregated differently from the dependent flag", //phpcs:ignore $depFlagKey - )); - } + ), + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); + } - // Recursively evaluate the dependency - try { - $depResult = FeatureFlag::matchFeatureFlagPropertiesForIdentity( - $depFlag, - $distinctId, - $properties, - $cohortProperties, - $flagsByKey, - $evaluationCache, - [], - [], - [], - $propertyMatchingVersion, - $identityAggregation - ); - $evaluationCache[$depFlagKey] = $depResult; - } catch (InconclusiveMatchException $e) { - // If we can't evaluate a dependency, store null and propagate the error - $evaluationCache[$depFlagKey] = null; - throw new InconclusiveMatchException(sprintf( - "Cannot evaluate flag dependency '%s': %s", - $depFlagKey, - $e->getMessage() - )); - } + // Recursively evaluate the dependency + try { + $depResult = FeatureFlag::matchFeatureFlagPropertiesForIdentity( + $depFlag, + $distinctId, + $properties, + $cohortProperties, + $flagsByKey, + $evaluationCache, + [], + [], + [], + $propertyMatchingVersion, + $identityAggregation + ); + $evaluationCache[$depFlagKey] = $depResult; + } catch (InconclusiveMatchException | RequiresServerEvaluationException $e) { + // If we can't evaluate a dependency, store null and propagate the error + $evaluationCache[$depFlagKey] = null; + throw new InconclusiveMatchException( + sprintf("Cannot evaluate flag dependency '%s': %s", $depFlagKey, $e->getMessage()), + 0, + $e, + UnresolvedFlagReason::UNRESOLVED_DEPENDENCY + ); } } } @@ -1509,10 +1809,10 @@ private static function evaluateFlagDependencyForIdentity( $flagValue = $evaluationCache[$depFlagKey]; if ($flagValue === null) { // Previously inconclusive - raise error again - throw new InconclusiveMatchException(sprintf( - "Flag dependency '%s' was previously inconclusive", - $depFlagKey - )); + throw new InconclusiveMatchException( + sprintf("Flag dependency '%s' was previously inconclusive", $depFlagKey), + reason: UnresolvedFlagReason::UNRESOLVED_DEPENDENCY + ); } // Now check if the flag value matches the expected value in the property @@ -1526,11 +1826,14 @@ private static function evaluateFlagDependencyForIdentity( return FeatureFlag::matchesDependencyValue($expectedValue, $flagValue); } else { // This should never happen, but just to be defensive - throw new InconclusiveMatchException(sprintf( - "Flag dependency property for '%s' has invalid operator '%s'", - $depFlagKey, - $operator - )); + throw new InconclusiveMatchException( + sprintf( + "Flag dependency property for '%s' has invalid operator '%s'", + $depFlagKey, + $operator + ), + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); } } @@ -1566,4 +1869,114 @@ public static function matchesDependencyValue($expectedValue, $actualValue) // Default case return false; } + + /** + * The unresolved reason that comes from the flag's definition alone, so it is known when + * definitions load. Matches the reason local evaluation reports for the same cause. + * + * @param array $flag Feature flag definition. + * @param array> $flagsByKey Local feature flag definitions keyed by key. + * @param array $cohorts Local cohort definitions keyed by cohort ID. + * @param array $groupTypeMapping Mapping from group type index to group type name. + * @return string|null UnresolvedFlagReason::EXPERIENCE_CONTINUITY or + * UnresolvedFlagReason::UNSUPPORTED_DEFINITION, or null when the definition alone does not + * stop local evaluation. + */ + public static function getDefinitionUnresolvedReason($flag, $flagsByKey, $cohorts, $groupTypeMapping): ?string + { + if (!($flag["active"] ?? false)) { + return null; + } + + if ($flag["ensure_experience_continuity"] ?? false) { + return UnresolvedFlagReason::EXPERIENCE_CONTINUITY; + } + + $flagFilters = $flag["filters"] ?? []; + $aggregation = $flagFilters["aggregation_group_type_index"] ?? null; + if (!is_null($aggregation) && is_null($groupTypeMapping[strval($aggregation)] ?? null)) { + return UnresolvedFlagReason::UNSUPPORTED_DEFINITION; + } + + foreach ($flagFilters["groups"] ?? [] as $condition) { + foreach ($condition["properties"] ?? [] as $property) { + $visitedCohortIds = []; + if (FeatureFlag::isUnsupportedProperty($property, $flagsByKey, $cohorts, $visitedCohortIds)) { + return UnresolvedFlagReason::UNSUPPORTED_DEFINITION; + } + } + } + + return null; + } + + /** + * @param array $visitedCohortIds + */ + private static function isUnsupportedProperty($property, $flagsByKey, $cohorts, array &$visitedCohortIds): bool + { + $propertyType = $property["type"] ?? null; + + if ($propertyType === "flag") { + $dependencyChain = $property["dependency_chain"] ?? null; + $depFlagKey = $property["key"] ?? null; + if (!is_array($dependencyChain) || count($dependencyChain) === 0 || !$depFlagKey) { + return true; + } + if (($property["value"] ?? null) !== null && ($property["operator"] ?? "exact") !== "flag_evaluates_to") { + return true; + } + foreach (array_merge($dependencyChain, [$depFlagKey]) as $chainFlagKey) { + if (empty($flagsByKey[$chainFlagKey])) { + return true; + } + } + return false; + } + + if ($propertyType === "cohort") { + $cohortId = strval($property["value"] ?? ""); + if (!array_key_exists($cohortId, $cohorts)) { + return true; + } + if (isset($visitedCohortIds[$cohortId])) { + return false; + } + $visitedCohortIds[$cohortId] = true; + return FeatureFlag::isUnsupportedPropertyGroup( + $cohorts[$cohortId], + $flagsByKey, + $cohorts, + $visitedCohortIds + ); + } + + try { + FeatureFlag::assertFilterSupported($property); + return false; + } catch (InconclusiveMatchException $e) { + return true; + } + } + + /** + * @param array $visitedCohortIds + */ + private static function isUnsupportedPropertyGroup( + $propertyGroup, + $flagsByKey, + $cohorts, + array &$visitedCohortIds + ): bool { + foreach ($propertyGroup["values"] ?? [] as $value) { + $unsupported = is_array($value) && array_key_exists("values", $value) + ? FeatureFlag::isUnsupportedPropertyGroup($value, $flagsByKey, $cohorts, $visitedCohortIds) + : FeatureFlag::isUnsupportedProperty($value, $flagsByKey, $cohorts, $visitedCohortIds); + if ($unsupported) { + return true; + } + } + + return false; + } } diff --git a/lib/FeatureFlagError.php b/lib/FeatureFlagError.php index 215912ed..a470ec59 100644 --- a/lib/FeatureFlagError.php +++ b/lib/FeatureFlagError.php @@ -19,6 +19,11 @@ class FeatureFlagError */ public const FLAG_MISSING = 'flag_missing'; + /** + * Flag has a loaded local definition but local evaluation could not resolve it + */ + public const LOCAL_EVALUATION_INCONCLUSIVE = 'local_evaluation_inconclusive'; + /** * Rate/quota limit exceeded */ diff --git a/lib/FeatureFlagEvaluations.php b/lib/FeatureFlagEvaluations.php index b69d4933..5ac53e89 100644 --- a/lib/FeatureFlagEvaluations.php +++ b/lib/FeatureFlagEvaluations.php @@ -27,6 +27,8 @@ class FeatureFlagEvaluations * @param bool $errorsWhileComputing Whether the remote response reported computation errors. * @param bool $quotaLimited Whether the remote response was quota limited. * @param bool|null $disableGeoip Override GeoIP enrichment on access events; null uses the client setting. + * @param array $unresolvedFlags Flags local evaluation could not resolve, mapped to an + * UnresolvedFlagReason value. */ public function __construct( private readonly string $distinctId, @@ -39,6 +41,7 @@ public function __construct( private readonly bool $errorsWhileComputing = false, private readonly bool $quotaLimited = false, private readonly ?bool $disableGeoip = null, + private readonly array $unresolvedFlags = [], ) { $this->accessed = $accessed ?? []; } @@ -53,6 +56,21 @@ public function getKeys(): array return array_keys($this->flags); } + /** + * Flags that have a loaded local definition but that neither local evaluation nor a remote + * fallback resolved, mapped to the reason local evaluation was inconclusive (an + * UnresolvedFlagReason value). These flags are absent from getKeys(), and their accessors + * return the missing-flag results. Reading this does not record access or fire an event. + * + * Always empty on snapshots returned by only() and onlyAccessed(). + * + * @return array + */ + public function getUnresolvedFlags(): array + { + return $this->unresolvedFlags; + } + /** * Whether the flag is enabled for the snapshot's distinct id. Returns false for unknown keys. * @@ -214,7 +232,8 @@ private function recordAccess(string $key, ?EvaluatedFlagRecord $record): void } // Build a comma-joined $feature_flag_error matching the single-flag path's granularity: - // response-level errors combine with per-flag errors so consumers can filter by type. + // response-level errors combine with per-flag errors (flag-missing or + // local-evaluation-inconclusive) so consumers can filter by type. $errors = []; if ($this->errorsWhileComputing) { $errors[] = FeatureFlagError::ERRORS_WHILE_COMPUTING_FLAGS; @@ -223,7 +242,9 @@ private function recordAccess(string $key, ?EvaluatedFlagRecord $record): void $errors[] = FeatureFlagError::QUOTA_LIMITED; } if ($record === null) { - $errors[] = FeatureFlagError::FLAG_MISSING; + $errors[] = array_key_exists($key, $this->unresolvedFlags) + ? FeatureFlagError::LOCAL_EVALUATION_INCONCLUSIVE + : FeatureFlagError::FLAG_MISSING; } if (!empty($errors)) { $properties['$feature_flag_error'] = implode(',', $errors); @@ -246,6 +267,7 @@ private function cloneWith(array $flags): self { // Filtered views start with an empty access set so reads on the child don't propagate back // into the parent's view. PHP's value-copy semantics on arrays already give us isolation. + // Unresolved flags are left out on purpose: filtered views carry no unresolved entries. return new self( $this->distinctId, $flags, diff --git a/lib/InconclusiveMatchException.php b/lib/InconclusiveMatchException.php index cd270297..120f695c 100644 --- a/lib/InconclusiveMatchException.php +++ b/lib/InconclusiveMatchException.php @@ -3,6 +3,7 @@ namespace PostHog; use Exception; +use Throwable; /** * Raised when a feature flag cannot be evaluated conclusively with local data. @@ -11,6 +12,31 @@ */ class InconclusiveMatchException extends Exception { + /** + * @param string $message Exception message. + * @param int $code Exception code. + * @param Throwable|null $previous Previous exception. + * @param string $reason Why local evaluation is inconclusive, one of the UnresolvedFlagReason values. + */ + public function __construct( + string $message = "", + int $code = 0, + ?Throwable $previous = null, + private readonly string $reason = UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ) { + parent::__construct($message, $code, $previous); + } + + /** + * Why local evaluation is inconclusive, one of the UnresolvedFlagReason values. + * + * @return string + */ + public function getReason(): string + { + return $this->reason; + } + /** * Format the exception as an HTML-ish error message. * diff --git a/lib/RequiresServerEvaluationException.php b/lib/RequiresServerEvaluationException.php index 3d5c3ab6..21cdd865 100644 --- a/lib/RequiresServerEvaluationException.php +++ b/lib/RequiresServerEvaluationException.php @@ -3,6 +3,7 @@ namespace PostHog; use Exception; +use Throwable; /** * Raised when feature flag evaluation requires server-side data. @@ -11,6 +12,31 @@ */ class RequiresServerEvaluationException extends Exception { + /** + * @param string $message Exception message. + * @param int $code Exception code. + * @param Throwable|null $previous Previous exception. + * @param string $reason Why local evaluation is inconclusive, one of the UnresolvedFlagReason values. + */ + public function __construct( + string $message = "", + int $code = 0, + ?Throwable $previous = null, + private readonly string $reason = UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ) { + parent::__construct($message, $code, $previous); + } + + /** + * Why local evaluation is inconclusive, one of the UnresolvedFlagReason values. + * + * @return string + */ + public function getReason(): string + { + return $this->reason; + } + /** * Format the exception as an HTML-ish error message. * diff --git a/lib/UnresolvedFlagReason.php b/lib/UnresolvedFlagReason.php new file mode 100644 index 00000000..b3077ebf --- /dev/null +++ b/lib/UnresolvedFlagReason.php @@ -0,0 +1,35 @@ +http_client = new MockedHttpClient( 'app.posthog.com', flagEndpointResponse: $localEvaluationResponse, - flagsEndpointResponse: $flagsEndpointResponse + flagsEndpointResponse: $flagsEndpointResponse, + flagsEndpointResponseCode: $flagsEndpointResponseCode ); $this->client = new Client( self::FAKE_API_KEY, @@ -1157,4 +1160,336 @@ public function testIsFeatureEnabledDoesNotCascadeDeprecationWarnings(): void $this->assertCount(1, $messages); } + + private static function localFlag( + string $key, + array $properties = [], + bool $continuity = false, + bool $active = true + ): array { + return [ + 'id' => 1, + 'key' => $key, + 'active' => $active, + 'ensure_experience_continuity' => $continuity, + 'filters' => ['groups' => [['properties' => $properties, 'rollout_percentage' => 100]]], + ]; + } + + private static function localDefinitions(array ...$flags): array + { + return ['flags' => $flags, 'group_type_mapping' => [], 'cohorts' => []]; + } + + private static function planFilter(string $operator = 'exact', string $value = 'pro'): array + { + return ['key' => 'plan', 'type' => 'person', 'operator' => $operator, 'value' => $value]; + } + + private static function staticCohortFilter(): array + { + return ['key' => 'id', 'type' => 'cohort', 'value' => 999]; + } + + private static function dependsOnFilter(string $key): array + { + return [ + 'key' => $key, + 'type' => 'flag', + 'operator' => 'flag_evaluates_to', + 'value' => true, + 'dependency_chain' => [$key], + ]; + } + + private function makeLocalClient( + array $localEvaluationResponse, + array $flagsEndpointResponse = ['flags' => []], + int $flagsEndpointResponseCode = 200 + ): void { + $this->makeClient( + flagsEndpointResponse: $flagsEndpointResponse, + localEvaluationResponse: $localEvaluationResponse, + personalApiKey: 'test-personal-key', + flagsEndpointResponseCode: $flagsEndpointResponseCode + ); + } + + /** @return list> Properties of each delivered $feature_flag_called event for $key. */ + private function flagCalledEvents(string $key): array + { + $this->client->flush(); + $events = []; + foreach ($this->batchRequests() as $batch) { + foreach ($batch['batch'] as $event) { + if ($event['event'] === '$feature_flag_called' && $event['properties']['$feature_flag'] === $key) { + $events[] = $event['properties']; + } + } + } + return $events; + } + + public function testLocalOnlyEvaluationReportsExperienceContinuityFlagAsUnresolved(): void + { + $this->makeLocalClient(self::localDefinitions( + self::localFlag('beta-ui'), + self::localFlag('checkout', continuity: true), + )); + + $snapshot = $this->client->evaluateFlags('user-123', onlyEvaluateLocally: true); + + $this->assertSame(['beta-ui'], $snapshot->getKeys()); + $this->assertSame(['checkout' => UnresolvedFlagReason::EXPERIENCE_CONTINUITY], $snapshot->getUnresolvedFlags()); + $this->assertSame(0, $this->flagsRequestCount()); + $this->client->flush(); + $this->assertSame([], $this->batchRequests()); + $this->assertSame([], $snapshot->onlyAccessed()->getKeys()); + $this->assertSame([], $snapshot->onlyAccessed()->getUnresolvedFlags()); + $this->assertSame([], $snapshot->only(['beta-ui', 'checkout'])->getUnresolvedFlags()); + $this->assertSame(true, $snapshot->getFlag('beta-ui')); + } + + #[DataProvider('unresolvedReasonProvider')] + public function testEachInconclusiveCauseReportsItsUnresolvedReason( + array $properties, + array $personProperties, + string $reason + ): void { + $this->makeLocalClient(self::localDefinitions(self::localFlag('checkout', $properties))); + + $snapshot = $this->client->evaluateFlags( + 'user-123', + personProperties: $personProperties, + onlyEvaluateLocally: true + ); + + $this->assertNotContains('checkout', $snapshot->getKeys()); + $this->assertSame(['checkout' => $reason], $snapshot->getUnresolvedFlags()); + } + + public static function unresolvedReasonProvider(): iterable + { + // The device-bucketed row of the spec is not applicable: this SDK has no device id bucketing. + yield 'person property not passed' => [[self::planFilter()], [], UnresolvedFlagReason::MISSING_CONTEXT]; + yield 'unknown operator' => [ + [self::planFilter('not_a_real_operator')], + ['plan' => 'enterprise'], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'static cohort' => [[self::staticCohortFilter()], [], UnresolvedFlagReason::UNSUPPORTED_DEFINITION]; + yield 'static cohort and person property not passed' => [ + [self::staticCohortFilter(), self::planFilter()], + [], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'person property not passed and static cohort' => [ + [self::planFilter(), self::staticCohortFilter()], + [], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'malformed semver person property' => [ + [self::planFilter('semver_gt', '1.2.3')], + ['plan' => 'not-a-version'], + UnresolvedFlagReason::MISSING_CONTEXT, + ]; + yield 'malformed semver filter' => [ + [self::planFilter('semver_gt', 'not-a-version')], + ['plan' => '1.2.3'], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + } + + public function testFlagBlockedByUnresolvedDependencyReportsTheDependency(): void + { + $this->makeLocalClient(self::localDefinitions( + self::localFlag('checkout', continuity: true), + self::localFlag('beta-ui', [self::dependsOnFilter('checkout')]), + )); + + $snapshot = $this->client->evaluateFlags('user-123', onlyEvaluateLocally: true, flagKeys: ['beta-ui']); + + $this->assertSame([], $snapshot->getKeys()); + $this->assertSame(['beta-ui' => UnresolvedFlagReason::UNRESOLVED_DEPENDENCY], $snapshot->getUnresolvedFlags()); + } + + public function testFlagDependingOnFlagWithNoLoadedDefinitionReportsUnsupportedDefinition(): void + { + $this->makeLocalClient(self::localDefinitions( + self::localFlag('beta-ui', [self::dependsOnFilter('checkout')]), + )); + + $snapshot = $this->client->evaluateFlags('user-123', onlyEvaluateLocally: true, flagKeys: ['beta-ui']); + + $this->assertSame([], $snapshot->getKeys()); + $this->assertSame(['beta-ui' => UnresolvedFlagReason::UNSUPPORTED_DEFINITION], $snapshot->getUnresolvedFlags()); + } + + public function testFlagResolvedByRemoteFallbackIsNotUnresolved(): void + { + $this->makeLocalClient( + self::localDefinitions(self::localFlag('checkout', continuity: true)), + ['flags' => ['checkout' => ['key' => 'checkout', 'enabled' => true, 'variant' => null]]] + ); + + $snapshot = $this->client->evaluateFlags('user-123'); + + $this->assertSame(['checkout'], $snapshot->getKeys()); + $this->assertSame([], $snapshot->getUnresolvedFlags()); + $this->assertTrue($snapshot->isEnabled('checkout')); + + $events = $this->flagCalledEvents('checkout'); + $this->assertCount(1, $events); + $this->assertTrue($events[0]['$feature_flag_response']); + $this->assertArrayNotHasKey('$feature_flag_error', $events[0]); + } + + #[DataProvider('unresolvingFallbackProvider')] + public function testRemoteFallbackThatDoesNotResolveTheFlagLeavesItUnresolved( + array $flagsEndpointResponse, + int $flagsEndpointResponseCode + ): void { + $this->makeLocalClient( + self::localDefinitions(self::localFlag('checkout', continuity: true)), + $flagsEndpointResponse, + $flagsEndpointResponseCode + ); + + $snapshot = $this->client->evaluateFlags('user-123'); + + $this->assertSame(1, $this->flagsRequestCount()); + $this->assertSame([], $snapshot->getKeys()); + $this->assertSame(['checkout' => UnresolvedFlagReason::EXPERIENCE_CONTINUITY], $snapshot->getUnresolvedFlags()); + $this->assertFalse($snapshot->isEnabled('checkout')); + + $events = $this->flagCalledEvents('checkout'); + $this->assertCount(1, $events); + $this->assertSame('local_evaluation_inconclusive', $events[0]['$feature_flag_error']); + } + + public static function unresolvingFallbackProvider(): iterable + { + yield 'request fails' => [['error' => 'unavailable'], 500]; + yield 'response has no flags' => [['flags' => []], 200]; + yield 'flag failed remotely' => [ + ['flags' => ['checkout' => ['key' => 'checkout', 'enabled' => false, 'failed' => true]]], + 200, + ]; + } + + public function testKeyWithoutLocalDefinitionIsMissingNotUnresolved(): void + { + $this->makeLocalClient(self::localDefinitions(self::localFlag('beta-ui'))); + + $snapshot = $this->client->evaluateFlags('user-123', flagKeys: ['beta-ui', 'typo-flag']); + $snapshot->isEnabled('typo-flag'); + + $this->assertSame([], $snapshot->getUnresolvedFlags()); + $events = $this->flagCalledEvents('typo-flag'); + $this->assertCount(1, $events); + $this->assertSame('flag_missing', $events[0]['$feature_flag_error']); + } + + public function testFlagOutsideRequestedKeysIsNotUnresolved(): void + { + $this->makeLocalClient(self::localDefinitions( + self::localFlag('beta-ui'), + self::localFlag('checkout', continuity: true), + )); + + $snapshot = $this->client->evaluateFlags('user-123', flagKeys: ['beta-ui']); + + $this->assertSame(['beta-ui'], $snapshot->getKeys()); + $this->assertTrue($snapshot->isEnabled('beta-ui')); + $this->assertSame([], $snapshot->getUnresolvedFlags()); + $this->assertSame(0, $this->flagsRequestCount()); + } + + public function testInactiveFlagIsResolvedAsFalseNotUnresolved(): void + { + $this->makeLocalClient(self::localDefinitions( + self::localFlag('checkout', continuity: true, active: false), + )); + + $snapshot = $this->client->evaluateFlags('user-123', onlyEvaluateLocally: true); + + $this->assertSame(['checkout'], $snapshot->getKeys()); + $this->assertFalse($snapshot->getFlag('checkout')); + $this->assertSame([], $snapshot->getUnresolvedFlags()); + } + + public function testReadingUnresolvedFlagReportsInconclusiveLocalEvaluation(): void + { + $this->makeLocalClient(self::localDefinitions(self::localFlag('checkout', continuity: true))); + + $snapshot = $this->client->evaluateFlags('user-123', onlyEvaluateLocally: true); + + $this->assertFalse($snapshot->isEnabled('checkout')); + $this->assertNull($snapshot->getFlag('checkout')); + $this->assertSame(['checkout' => UnresolvedFlagReason::EXPERIENCE_CONTINUITY], $snapshot->getUnresolvedFlags()); + $events = $this->flagCalledEvents('checkout'); + $this->assertCount(1, $events); + $this->assertNull($events[0]['$feature_flag_response']); + $this->assertSame('local_evaluation_inconclusive', $events[0]['$feature_flag_error']); + } + + public function testInconclusiveErrorCombinesWithResponseLevelErrors(): void + { + $this->makeLocalClient( + self::localDefinitions(self::localFlag('checkout', continuity: true)), + ['flags' => [], 'errorsWhileComputingFlags' => true] + ); + + $this->client->evaluateFlags('user-123')->isEnabled('checkout'); + + $events = $this->flagCalledEvents('checkout'); + $this->assertCount(1, $events); + $this->assertSame( + 'errors_while_computing_flags,local_evaluation_inconclusive', + $events[0]['$feature_flag_error'] + ); + } + + public function testFilteredSnapshotReadOfUnresolvedFlagReportsFlagMissing(): void + { + $this->makeLocalClient(self::localDefinitions(self::localFlag('checkout', continuity: true))); + + $snapshot = $this->client->evaluateFlags('user-123', onlyEvaluateLocally: true); + $snapshot->only(['checkout'])->isEnabled('checkout'); + + $events = $this->flagCalledEvents('checkout'); + $this->assertCount(1, $events); + $this->assertSame('flag_missing', $events[0]['$feature_flag_error']); + } + + public function testUnexpectedLocalEvaluationErrorIsNotUnresolved(): void + { + $this->makeLocalClient(self::localDefinitions(self::localFlag('checkout', [self::planFilter()]))); + + // A property value that cannot be encoded as JSON fails outside the inconclusive paths. + $snapshot = $this->client->evaluateFlags( + 'user-123', + personProperties: ['plan' => ["\xB1"]], + onlyEvaluateLocally: true + ); + $snapshot->isEnabled('checkout'); + + $this->assertSame([], $snapshot->getKeys()); + $this->assertSame([], $snapshot->getUnresolvedFlags()); + $events = $this->flagCalledEvents('checkout'); + $this->assertCount(1, $events); + $this->assertSame('flag_missing', $events[0]['$feature_flag_error']); + } + + public function testReadingUnresolvedFlagsDoesNotRecordAccessOrFireEvents(): void + { + $this->makeLocalClient(self::localDefinitions(self::localFlag('checkout', continuity: true))); + + $snapshot = $this->client->evaluateFlags('user-123', onlyEvaluateLocally: true); + $snapshot->getUnresolvedFlags(); + + $this->assertSame([], $snapshot->onlyAccessed()->getKeys()); + $this->assertSame([], $this->flagCalledEvents('checkout')); + $this->assertSame(0, $this->flagsRequestCount()); + } } diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index 839a41e3..465448ce 100644 --- a/test/FeatureFlagLocalEvaluationTest.php +++ b/test/FeatureFlagLocalEvaluationTest.php @@ -13,6 +13,8 @@ use PostHog\PostHog; use PostHog\Test\Assets\MockedResponses; use PostHog\InconclusiveMatchException; +use PostHog\RequiresServerEvaluationException; +use PostHog\UnresolvedFlagReason; use PostHog\SizeLimitedHash; class FeatureFlagLocalEvaluationTest extends TestCase @@ -68,6 +70,13 @@ public function checkEmptyErrorLogs(): void $this->assertTrue(empty($errorMessages), "Error logs are not empty: " . implode("\n", $errorMessages)); } + private function checkOnlyUnsupportedDefinitionWarning(string $flagKey): void + { + global $errorMessages; + $this->assertCount(1, $errorMessages, "Error logs: " . implode("\n", $errorMessages)); + $this->assertStringContainsString("(reason: unsupported_definition): {$flagKey}.", $errorMessages[0]); + } + private function assertHttpCallsEqual(array $expectedCalls): void { $this->assertEquals( @@ -1963,7 +1972,7 @@ public function testFlagFallbackToDecide() $this->assertEquals(PostHog::getFeatureFlag('feature-1', 'some-distinct'), 'decide-fallback-value'); $this->assertEquals(PostHog::getFeatureFlag('feature-2', 'some-distinct'), 'decide-fallback-value'); - $this->checkEmptyErrorLogs(); + $this->checkOnlyUnsupportedDefinitionWarning('feature-1'); } public function testFlagFallbackToDecideWithFalseFlag() @@ -1990,7 +1999,7 @@ public function testFlagFallbackToDecideWithFalseFlag() array_column($this->http_client->calls, 'path') ); - $this->checkEmptyErrorLogs(); + $this->checkOnlyUnsupportedDefinitionWarning('feature-1'); } public function testFeatureFlagDefaultsComeIntoPlayOnlyWhenDecideErrorsOut() @@ -4645,7 +4654,7 @@ public function testFeatureFlagsWithFlagDependencies(): void try { FeatureFlag::matchPropertyGroup($propertyGroup, $properties, []); } catch (InconclusiveMatchException $e) { - $this->assertStringContainsString("Cannot evaluate flag dependency on 'parent-flag' without flags_by_key and evaluation_cache", $e->getMessage()); + $this->assertSame(UnresolvedFlagReason::UNSUPPORTED_DEFINITION, $e->getReason()); $threwException = true; } $this->assertTrue($threwException, "Expected InconclusiveMatchException was not thrown"); @@ -4673,7 +4682,7 @@ public function testFeatureFlagsWithFlagDependencies(): void try { FeatureFlag::matchFeatureFlagProperties($flag, "test-user", $properties); } catch (InconclusiveMatchException $e) { - $this->assertStringContainsString("Cannot evaluate flag dependency", $e->getMessage()); + $this->assertSame(UnresolvedFlagReason::UNSUPPORTED_DEFINITION, $e->getReason()); $threwException = true; } $this->assertTrue($threwException, "Expected InconclusiveMatchException was not thrown"); @@ -4706,7 +4715,7 @@ public function testFallsBackToAPIWhenFlagHasStaticCohort() // Should return 'set-1' from API, not 'set-8' from local evaluation $this->assertEquals('set-1', $result); - $this->checkEmptyErrorLogs(); + $this->checkOnlyUnsupportedDefinitionWarning('multi-condition-flag'); } public function testFallsBackToAPIInGetAllFlagsWhenFlagHasStaticCohort() @@ -4737,7 +4746,7 @@ public function testFallsBackToAPIInGetAllFlagsWhenFlagHasStaticCohort() 'multi-condition-flag' => 'set-1' ], $result); - $this->checkEmptyErrorLogs(); + $this->checkOnlyUnsupportedDefinitionWarning('multi-condition-flag'); } public function testFallsBackToAPIInGetFeatureFlagPayloadWhenFlagHasStaticCohort() @@ -4767,7 +4776,7 @@ public function testFallsBackToAPIInGetFeatureFlagPayloadWhenFlagHasStaticCohort 'message' => 'from-api' ], $result); - $this->checkEmptyErrorLogs(); + $this->checkOnlyUnsupportedDefinitionWarning('flag-with-payload'); } // ==================== Semver Operator Tests ==================== @@ -5462,4 +5471,543 @@ public function testEarlyExitOutOfRolloutBoundAfterInconclusiveConditionThrows() $this->expectException(InconclusiveMatchException::class); FeatureFlag::matchFeatureFlagProperties($flag, "test-user", ["region" => "us"]); } + + // ==================== Unresolved Reason Tests ==================== + + private static function reasonOf(callable $evaluate): ?string + { + try { + $evaluate(); + } catch (InconclusiveMatchException | RequiresServerEvaluationException $e) { + return $e->getReason(); + } + return null; + } + + private static function filter(string $operator, mixed $value, string $key = 'key'): array + { + return ['key' => $key, 'operator' => $operator, 'value' => $value]; + } + + private static function flagWithConditions(array ...$conditions): array + { + return [ + 'key' => 'reason-flag', + 'active' => true, + 'filters' => [ + 'groups' => array_map( + fn($properties) => ['properties' => $properties, 'rollout_percentage' => 100], + $conditions + ), + ], + ]; + } + + private static function staticCohortFilter(): array + { + return ['key' => 'id', 'type' => 'cohort', 'value' => 999]; + } + + public function testInconclusiveExceptionsDefaultToUnsupportedDefinition(): void + { + $this->assertSame( + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + (new InconclusiveMatchException('message'))->getReason() + ); + $this->assertSame( + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + (new RequiresServerEvaluationException('message'))->getReason() + ); + $this->assertSame( + UnresolvedFlagReason::MISSING_CONTEXT, + (new InconclusiveMatchException('message', reason: UnresolvedFlagReason::MISSING_CONTEXT))->getReason() + ); + } + + #[DataProvider('matchPropertyReasonProvider')] + public function testMatchPropertyReportsUnresolvedReason(array $property, array $propertyValues, string $reason): void + { + $this->assertSame($reason, self::reasonOf(fn() => FeatureFlag::matchProperty($property, $propertyValues))); + } + + public static function matchPropertyReasonProvider(): iterable + { + yield 'a missing property' => [self::filter('exact', 'x'), [], UnresolvedFlagReason::MISSING_CONTEXT]; + yield 'an unknown operator and a missing property' => [ + self::filter('unknown', 'x'), + [], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'an invalid semver filter and a missing property' => [ + self::filter('semver_gt', 'x.y'), + [], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'an invalid numeric filter and a missing property' => [ + self::filter('gt', 'ten'), + [], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'an invalid date filter and a missing property' => [ + self::filter('is_date_after', 'not-a-date'), + [], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'an unsupported regex and a missing property' => [ + self::filter('regex', '(?a)'), + [], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'an unknown operator' => [ + self::filter('unknown', 'x'), + ['key' => 'x'], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'an invalid date filter' => [ + self::filter('is_date_after', 'not-a-date'), + ['key' => '2025-01-01'], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'a numeric date filter' => [ + self::filter('is_date_after', 1234), + ['key' => '2025-01-01'], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'an invalid context date' => [ + self::filter('is_date_after', '2025-01-01'), + ['key' => 'not-a-date'], + UnresolvedFlagReason::MISSING_CONTEXT, + ]; + yield 'a numeric context date' => [ + self::filter('is_date_after', '2025-01-01'), + ['key' => 1], + UnresolvedFlagReason::MISSING_CONTEXT, + ]; + yield 'an invalid date filter and context' => [ + self::filter('is_date_after', 'not-a-date'), + ['key' => 'also-not-a-date'], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'an invalid semver filter' => [ + self::filter('semver_gt', 'x.y'), + ['key' => '1.2.3'], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'an invalid tilde filter' => [ + self::filter('semver_tilde', 'x.y'), + ['key' => '1.2.3'], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'an invalid caret filter' => [ + self::filter('semver_caret', 'x.y'), + ['key' => '1.2.3'], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'an invalid wildcard filter' => [ + self::filter('semver_wildcard', '1x.*'), + ['key' => '1.8.0'], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'an invalid context semver' => [ + self::filter('semver_gt', '1.2.3'), + ['key' => 'x.y'], + UnresolvedFlagReason::MISSING_CONTEXT, + ]; + yield 'an invalid context semver for a range' => [ + self::filter('semver_wildcard', '1.*'), + ['key' => 'x.y'], + UnresolvedFlagReason::MISSING_CONTEXT, + ]; + yield 'an invalid semver filter and context' => [ + self::filter('semver_gt', 'x.y'), + ['key' => 'a.b'], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'an invalid wildcard filter and context' => [ + self::filter('semver_wildcard', '1x.*'), + ['key' => 'a.b'], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'an unsupported regex' => [ + self::filter('regex', '(?a)'), + ['key' => 'a'], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + } + + public function testConditionWithNonMatchingFilterResolvesDespiteInconclusiveFilter(): void + { + $missing = self::filter('exact', 'pro', 'plan'); + $nonMatching = self::filter('exact', 'other@example.com', 'email'); + $properties = ['email' => 'test@example.com']; + + foreach ([[$missing, $nonMatching], [$nonMatching, $missing]] as $filters) { + $this->assertFalse( + FeatureFlag::matchFeatureFlagProperties(self::flagWithConditions($filters), 'user', $properties) + ); + } + } + + public function testConditionWithStaticCohortAndNonMatchingFilterResolvesLocally(): void + { + $flag = self::flagWithConditions([self::staticCohortFilter(), self::filter('exact', 'other', 'email')]); + + $this->assertFalse(FeatureFlag::matchFeatureFlagProperties($flag, 'user', ['email' => 'test'])); + } + + public function testConditionWithInconclusiveAndMatchingFiltersIsInconclusive(): void + { + $flag = self::flagWithConditions([self::filter('exact', 'pro', 'plan'), self::filter('exact', 'a', 'email')]); + + $this->assertSame( + UnresolvedFlagReason::MISSING_CONTEXT, + self::reasonOf(fn() => FeatureFlag::matchFeatureFlagProperties($flag, 'user', ['email' => 'a'])) + ); + } + + #[DataProvider('conditionReasonPrecedenceProvider')] + public function testUnresolvedReasonFollowsSpecPrecedence(array $conditions, string $reason): void + { + $flag = self::flagWithConditions(...$conditions); + $flagsByKey = [ + 'continuity-flag' => [ + 'key' => 'continuity-flag', + 'active' => true, + 'ensure_experience_continuity' => true, + ], + ]; + + $this->assertSame( + $reason, + self::reasonOf(fn() => FeatureFlag::matchFeatureFlagProperties( + $flag, + 'user', + ['email' => 'a'], + [], + $flagsByKey, + [] + )) + ); + } + + public static function conditionReasonPrecedenceProvider(): iterable + { + $missing = self::filter('exact', 'pro', 'plan'); + $unknownOperator = self::filter('unknown', 'a', 'email'); + $dependsOn = fn(array $chain) => [ + 'key' => end($chain), 'type' => 'flag', 'operator' => 'flag_evaluates_to', 'value' => true, + 'dependency_chain' => $chain, + ]; + $unresolvedDependency = $dependsOn(['continuity-flag']); + $missingDependency = $dependsOn(['gone']); + + yield 'missing property then unknown operator' => [ + [[$missing, $unknownOperator]], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'unknown operator then missing property' => [ + [[$unknownOperator, $missing]], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'missing property condition then unknown operator condition' => [ + [[$missing], [$unknownOperator]], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'unknown operator condition then missing property condition' => [ + [[$unknownOperator], [$missing]], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'missing property then unresolved dependency' => [ + [[$missing, $unresolvedDependency]], + UnresolvedFlagReason::UNRESOLVED_DEPENDENCY, + ]; + yield 'unresolved dependency condition then missing property condition' => [ + [[$unresolvedDependency], [$missing]], + UnresolvedFlagReason::UNRESOLVED_DEPENDENCY, + ]; + yield 'unresolved dependency then unknown operator' => [ + [[$unresolvedDependency, $unknownOperator]], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'unresolved dependency then missing dependency' => [ + [[$unresolvedDependency, $missingDependency]], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'missing property then static cohort' => [ + [[$missing, self::staticCohortFilter()]], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'static cohort then missing property' => [ + [[self::staticCohortFilter(), $missing]], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + } + + public function testCohortGroupKeepsHighestPrecedenceReason(): void + { + $flag = self::flagWithConditions([['key' => 'id', 'type' => 'cohort', 'value' => 1]]); + $cohorts = [ + '1' => [ + 'type' => 'OR', + 'values' => [ + self::filter('unknown', 'a', 'email'), + self::filter('exact', 'pro', 'plan'), + ], + ], + ]; + + $this->assertSame( + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + self::reasonOf(fn() => FeatureFlag::matchFeatureFlagProperties($flag, 'user', ['email' => 'a'], $cohorts)) + ); + } + + public function testStaticCohortRequiresServerOnlyWhenFirstUncheckableFilter(): void + { + $missing = self::filter('exact', 'pro', 'plan'); + $matching = [self::filter('exact', 'a', 'email')]; + + // The static cohort is the first filter local evaluation cannot check: fall back to the server. + $flag = self::flagWithConditions([self::staticCohortFilter(), $missing], $matching); + try { + FeatureFlag::matchFeatureFlagProperties($flag, 'user', ['email' => 'a']); + $this->fail('Expected RequiresServerEvaluationException'); + } catch (RequiresServerEvaluationException $e) { + $this->assertSame(UnresolvedFlagReason::UNSUPPORTED_DEFINITION, $e->getReason()); + } + + // After another uncheckable filter, a later condition can still resolve the flag locally. + $flag = self::flagWithConditions([$missing, self::staticCohortFilter()], $matching); + $this->assertTrue(FeatureFlag::matchFeatureFlagProperties($flag, 'user', ['email' => 'a'])); + } + + public function testMissingGroupPropertiesReportMissingContext(): void + { + $flag = [ + 'key' => 'mixed-flag', + 'active' => true, + 'filters' => [ + 'groups' => [ + ['aggregation_group_type_index' => 0, 'properties' => [], 'rollout_percentage' => 100], + ], + ], + ]; + + $this->assertSame( + UnresolvedFlagReason::MISSING_CONTEXT, + self::reasonOf(fn() => FeatureFlag::matchFeatureFlagProperties( + $flag, + 'user', + [], + [], + [], + [], + ['company' => 'acme'], + [], + ['0' => 'company'] + )) + ); + } + + #[DataProvider('definitionUnresolvedReasonProvider')] + public function testDefinitionUnresolvedReason(array $flag, ?string $reason): void + { + $flagsByKey = [ + 'continuity-flag' => ['key' => 'continuity-flag', 'active' => true, 'ensure_experience_continuity' => true], + 'loaded-flag' => ['key' => 'loaded-flag', 'active' => true], + ]; + $cohorts = ['1' => ['type' => 'AND', 'values' => [self::filter('unknown', 'a', 'email')]]]; + + $this->assertSame( + $reason, + FeatureFlag::getDefinitionUnresolvedReason($flag, $flagsByKey, $cohorts, ['0' => 'company']) + ); + } + + public static function definitionUnresolvedReasonProvider(): iterable + { + $dependency = fn(array $chain) => [ + 'key' => end($chain), 'type' => 'flag', 'operator' => 'flag_evaluates_to', 'value' => true, + 'dependency_chain' => $chain, + ]; + $continuity = self::flagWithConditions([]); + $continuity['ensure_experience_continuity'] = true; + $inactive = $continuity; + $inactive['active'] = false; + $unknownGroupType = self::flagWithConditions([]); + $unknownGroupType['filters']['aggregation_group_type_index'] = 5; + + yield 'experience continuity' => [$continuity, UnresolvedFlagReason::EXPERIENCE_CONTINUITY]; + yield 'inactive with experience continuity' => [$inactive, null]; + yield 'unknown group type' => [$unknownGroupType, UnresolvedFlagReason::UNSUPPORTED_DEFINITION]; + yield 'static cohort' => [self::flagWithConditions([self::staticCohortFilter()]), UnresolvedFlagReason::UNSUPPORTED_DEFINITION]; + yield 'unknown operator' => [self::flagWithConditions([self::filter('unknown', 'a')]), UnresolvedFlagReason::UNSUPPORTED_DEFINITION]; + yield 'malformed semver' => [self::flagWithConditions([self::filter('semver_gt', 'x.y')]), UnresolvedFlagReason::UNSUPPORTED_DEFINITION]; + yield 'unknown operator in a cohort' => [ + self::flagWithConditions([['key' => 'id', 'type' => 'cohort', 'value' => 1]]), + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'missing dependency' => [self::flagWithConditions([$dependency(['gone'])]), UnresolvedFlagReason::UNSUPPORTED_DEFINITION]; + yield 'missing dependency in the chain' => [ + self::flagWithConditions([$dependency(['gone', 'loaded-flag'])]), + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'circular dependency' => [self::flagWithConditions([$dependency([])]), UnresolvedFlagReason::UNSUPPORTED_DEFINITION]; + yield 'dependency on an experience continuity flag' => [self::flagWithConditions([$dependency(['continuity-flag'])]), null]; + yield 'loaded dependency' => [self::flagWithConditions([$dependency(['loaded-flag'])]), null]; + yield 'person property' => [self::flagWithConditions([self::filter('exact', 'pro', 'plan')]), null]; + } + + // ==================== Unresolvable Flag Warning Tests ==================== + + private static function definitionsResponse(array ...$flags): array + { + return ['flags' => $flags, 'group_type_mapping' => [], 'cohorts' => []]; + } + + private static function continuityFlag(string $key = 'checkout', int $version = 1): array + { + return [ + 'id' => 1, + 'key' => $key, + 'version' => $version, + 'active' => true, + 'ensure_experience_continuity' => true, + 'filters' => ['groups' => [['properties' => [], 'rollout_percentage' => 100]]], + ]; + } + + private function loadDefinitionsInOrder(array $responses): Client + { + $this->http_client = new MockedHttpClient(host: 'app.posthog.com'); + $this->http_client->setFlagEndpointResponseQueue($responses); + $this->client = new Client(self::FAKE_API_KEY, [], $this->http_client, 'test'); + for ($i = 1; $i < count($responses); $i++) { + $this->client->loadFlags(); + } + return $this->client; + } + + /** @return list */ + private static function warningsNaming(string $flagKey, string $reason): array + { + global $errorMessages; + return array_values(array_filter( + $errorMessages, + fn($message) => str_contains($message, "(reason: {$reason}):") + && preg_match('/\): (.*)\. /U', $message, $matches) === 1 + && in_array($flagKey, explode(', ', $matches[1]), true) + )); + } + + #[DataProvider('unchangedRefreshProvider')] + public function testLoadingNeverResolvableDefinitionLogsOneWarning(array $refresh): void + { + $this->loadDefinitionsInOrder([ + ['response' => self::definitionsResponse(self::continuityFlag()), 'etag' => '"v1"'], + $refresh, + ]); + + $this->assertCount(1, self::warningsNaming('checkout', UnresolvedFlagReason::EXPERIENCE_CONTINUITY)); + $this->assertSame( + ['/flags/definitions?send_cohorts&token=random_key', '/flags/definitions?send_cohorts&token=random_key'], + array_column($this->http_client->calls, 'path') + ); + } + + public static function unchangedRefreshProvider(): iterable + { + yield 'unchanged definitions' => [ + ['response' => self::definitionsResponse(self::continuityFlag()), 'etag' => '"v1-again"'], + ]; + yield '304 not modified' => [['responseCode' => 304, 'etag' => '"v1"']]; + } + + public function testChangedNeverResolvableDefinitionLogsWarningAgain(): void + { + $this->loadDefinitionsInOrder([ + ['response' => self::definitionsResponse(self::continuityFlag(version: 1))], + ['response' => self::definitionsResponse(self::continuityFlag(version: 2))], + ]); + + $this->assertCount(2, self::warningsNaming('checkout', UnresolvedFlagReason::EXPERIENCE_CONTINUITY)); + } + + public function testWarningIsLoggedWithoutDebugAndNamesEveryUnsupportedDefinition(): void + { + $flag = fn(string $key, array $properties) => [ + 'id' => 1, + 'key' => $key, + 'active' => true, + 'filters' => ['groups' => [['properties' => $properties, 'rollout_percentage' => 100]]], + ]; + $dependsOn = fn(string $key, array $chain) => [ + 'key' => $key, 'type' => 'flag', 'operator' => 'flag_evaluates_to', 'value' => true, + 'dependency_chain' => $chain, + ]; + $inactiveContinuity = self::continuityFlag('inactive-continuity'); + $inactiveContinuity['active'] = false; + + $this->loadDefinitionsInOrder([[ + 'response' => self::definitionsResponse( + self::continuityFlag(), + $flag('static-cohort', [self::staticCohortFilter()]), + $flag('unknown-operator', [self::filter('unknown', 'a')]), + $flag('malformed-value', [self::filter('semver_gt', 'x.y')]), + $flag('missing-dependency', [$dependsOn('gone', ['gone'])]), + $flag('circular-dependency', [$dependsOn('checkout', [])]), + $flag('missing-context', [self::filter('exact', 'pro', 'plan')]), + $flag('unresolved-dependency', [$dependsOn('checkout', ['checkout'])]), + $inactiveContinuity, + ), + ]]); + + global $errorMessages; + $this->assertCount(2, $errorMessages); + $this->assertStringContainsString( + '1 flag(s) have experience continuity enabled and cannot be resolved locally ' + . '(reason: experience_continuity): checkout.', + $errorMessages[0] + ); + $this->assertStringContainsString( + '(reason: unsupported_definition): static-cohort, unknown-operator, malformed-value, ' + . 'missing-dependency, circular-dependency.', + $errorMessages[1] + ); + } + + public function testFlagThatReturnsAfterRemovalLogsWarningAgain(): void + { + $this->loadDefinitionsInOrder([ + ['response' => self::definitionsResponse(self::continuityFlag())], + ['response' => self::definitionsResponse()], + ['response' => self::definitionsResponse(self::continuityFlag())], + ]); + + $this->assertCount(2, self::warningsNaming('checkout', UnresolvedFlagReason::EXPERIENCE_CONTINUITY)); + } + + public function testDependencyLoadedThenRemovedLogsWarningAgain(): void + { + $dependent = [ + 'id' => 2, + 'key' => 'dependent', + 'active' => true, + 'filters' => ['groups' => [[ + 'properties' => [[ + 'key' => 'checkout', 'type' => 'flag', 'operator' => 'flag_evaluates_to', 'value' => true, + 'dependency_chain' => ['checkout'], + ]], + 'rollout_percentage' => 100, + ]]], + ]; + + $this->loadDefinitionsInOrder([ + ['response' => self::definitionsResponse($dependent)], + ['response' => self::definitionsResponse($dependent, self::continuityFlag())], + ['response' => self::definitionsResponse($dependent)], + ]); + + $this->assertCount(2, self::warningsNaming('dependent', UnresolvedFlagReason::UNSUPPORTED_DEFINITION)); + } } diff --git a/test/FlagDefinitionCacheProviderTest.php b/test/FlagDefinitionCacheProviderTest.php index 2ae85751..a6d57282 100644 --- a/test/FlagDefinitionCacheProviderTest.php +++ b/test/FlagDefinitionCacheProviderTest.php @@ -812,6 +812,40 @@ private function sampleFlagDefinitionData(): array ]; } + public function testUnresolvableFlagWarningIsLoggedForApiLoadsOnly(): void + { + $definitions = $this->sampleFlagDefinitionData(); + $definitions['flags'][0]['ensure_experience_continuity'] = true; + $warning = '(reason: experience_continuity): beta-ui.'; + + // A client that only reads the shared cache does not warn: the request that fetched the + // definitions already did. + $provider = new MockFlagDefinitionCacheProvider(); + $provider->shouldFetch = false; + $provider->cachedData = $definitions; + $this->createClient($provider, new MockedHttpClient(host: 'app.posthog.com')); + $this->assertWarningCount($warning, 0); + + $provider = new MockFlagDefinitionCacheProvider(); + $httpClient = new MockedHttpClient(host: 'app.posthog.com', flagEndpointResponse: $definitions); + $client = $this->createClient($provider, $httpClient); + $this->assertWarningCount($warning, 1); + + $provider->shouldFetch = false; + $provider->cachedData = $provider->storedData; + $client->loadFlags(); + $this->assertSame(1, $provider->getCallCount); + $this->assertCount(1, $httpClient->calls); + $this->assertWarningCount($warning, 1); + } + + private function assertWarningCount(string $expected, int $count): void + { + global $errorMessages; + $matching = array_filter($errorMessages, fn($message) => str_contains($message, $expected)); + $this->assertCount($count, $matching, implode("\n", $errorMessages)); + } + private function assertWarningContains(string $expected): void { global $errorMessages; diff --git a/test/FlagDependencyTest.php b/test/FlagDependencyTest.php index cad95288..d89c527a 100644 --- a/test/FlagDependencyTest.php +++ b/test/FlagDependencyTest.php @@ -7,6 +7,8 @@ use PostHog\Client; use PostHog\FeatureFlag; use PostHog\InconclusiveMatchException; +use PostHog\RequiresServerEvaluationException; +use PostHog\UnresolvedFlagReason; class FlagDependencyTest extends TestCase { @@ -141,9 +143,12 @@ public function testFlagDependencyCircularDependency(): void $evaluationCache = []; // Both flags should raise InconclusiveMatchException due to circular dependency - $this->expectException(InconclusiveMatchException::class); - $this->expectExceptionMessage("Circular dependency detected for flag 'flag-b'"); - FeatureFlag::matchFeatureFlagProperties($flagA, "test-user", [], [], $flagsByKey, $evaluationCache); + try { + FeatureFlag::matchFeatureFlagProperties($flagA, "test-user", [], [], $flagsByKey, $evaluationCache); + $this->fail('Expected InconclusiveMatchException'); + } catch (InconclusiveMatchException $e) { + $this->assertSame(UnresolvedFlagReason::UNSUPPORTED_DEFINITION, $e->getReason()); + } } public function testFlagDependencyMissingFlag(): void @@ -179,11 +184,12 @@ public function testFlagDependencyMissingFlag(): void $evaluationCache = []; // Should raise InconclusiveMatchException because dependency doesn't exist - $this->expectException(InconclusiveMatchException::class); - $this->expectExceptionMessage( - "Cannot evaluate flag dependency 'non-existent-flag' - flag not found in local flags" - ); - FeatureFlag::matchFeatureFlagProperties($flag, "test-user", [], [], $flagsByKey, $evaluationCache); + try { + FeatureFlag::matchFeatureFlagProperties($flag, "test-user", [], [], $flagsByKey, $evaluationCache); + $this->fail('Expected InconclusiveMatchException'); + } catch (InconclusiveMatchException $e) { + $this->assertSame(UnresolvedFlagReason::UNSUPPORTED_DEFINITION, $e->getReason()); + } } public function testFlagDependencyComplexChain(): void @@ -823,4 +829,180 @@ public function testMultiLevelMultivariateDependencyChain(): void ); $this->assertFalse($intermediateResult); } + + private static function dependencyFlag(string $key, array $properties = [], array $overrides = []): array + { + return array_merge([ + "key" => $key, + "active" => true, + "filters" => ["groups" => [["properties" => $properties, "rollout_percentage" => 100]]], + ], $overrides); + } + + private static function dependsOn(string $key, mixed $value = true, ?array $chain = null): array + { + return [ + "key" => $key, + "operator" => "flag_evaluates_to", + "value" => $value, + "type" => "flag", + "dependency_chain" => $chain ?? [$key], + ]; + } + + private static function dependencyReason(array $flag, array $flagsByKey, array $properties = []): ?string + { + try { + FeatureFlag::matchFeatureFlagProperties($flag, "test-user", $properties, [], $flagsByKey, []); + } catch (InconclusiveMatchException $e) { + return $e->getReason(); + } + return null; + } + + public function testDependencyWithExperienceContinuityIsAnUnresolvedDependency(): void + { + $dependency = self::dependencyFlag("dependency", [], ["ensure_experience_continuity" => true]); + $flag = self::dependencyFlag("flag", [self::dependsOn("dependency")]); + + $this->assertSame( + UnresolvedFlagReason::UNRESOLVED_DEPENDENCY, + self::dependencyReason($flag, ["dependency" => $dependency, "flag" => $flag]) + ); + } + + public function testInactiveDependencyWithExperienceContinuityIsFalse(): void + { + $dependency = self::dependencyFlag( + "dependency", + [], + ["active" => false, "ensure_experience_continuity" => true] + ); + $flag = self::dependencyFlag("flag", [self::dependsOn("dependency", false)]); + + $this->assertTrue(FeatureFlag::matchFeatureFlagProperties( + $flag, + "test-user", + [], + [], + ["dependency" => $dependency, "flag" => $flag], + [] + )); + } + + public function testDependencyThatNeedsContextIsAnUnresolvedDependency(): void + { + $dependency = self::dependencyFlag("dependency", [["key" => "plan", "operator" => "exact", "value" => "pro"]]); + $flag = self::dependencyFlag("flag", [self::dependsOn("dependency")]); + + $this->assertSame( + UnresolvedFlagReason::UNRESOLVED_DEPENDENCY, + self::dependencyReason($flag, ["dependency" => $dependency, "flag" => $flag]) + ); + } + + public function testDependencyThatRequiresServerIsAnUnresolvedDependency(): void + { + $dependency = self::dependencyFlag("dependency", [["key" => "id", "type" => "cohort", "value" => 999]]); + $flag = self::dependencyFlag("flag", [self::dependsOn("dependency")]); + + try { + FeatureFlag::matchFeatureFlagProperties($flag, "test-user", [], [], ["dependency" => $dependency], []); + $this->fail("Expected InconclusiveMatchException"); + } catch (RequiresServerEvaluationException $e) { + $this->fail("A dependency that requires the server must not force a server fallback"); + } catch (InconclusiveMatchException $e) { + $this->assertSame(UnresolvedFlagReason::UNRESOLVED_DEPENDENCY, $e->getReason()); + } + } + + public function testDependencyThatRequiresServerDoesNotBlockLaterConditions(): void + { + $dependency = self::dependencyFlag("dependency", [["key" => "id", "type" => "cohort", "value" => 999]]); + $flag = [ + "key" => "flag", + "active" => true, + "filters" => ["groups" => [ + ["properties" => [self::dependsOn("dependency")], "rollout_percentage" => 100], + ["properties" => [], "rollout_percentage" => 100], + ]], + ]; + + $this->assertTrue(FeatureFlag::matchFeatureFlagProperties( + $flag, + "test-user", + [], + [], + ["dependency" => $dependency], + [] + )); + } + + public function testMissingDependencyAnywhereInTheChainIsAnUnsupportedDefinition(): void + { + $dependency = self::dependencyFlag("dependency", [], ["ensure_experience_continuity" => true]); + $flag = self::dependencyFlag("flag", [self::dependsOn("dependency", true, ["gone", "dependency"])]); + + $this->assertSame( + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + self::dependencyReason($flag, ["dependency" => $dependency, "flag" => $flag]) + ); + } + + public function testRepeatedInconclusiveDependencyKeepsItsReason(): void + { + $dependency = self::dependencyFlag("dependency", [], ["ensure_experience_continuity" => true]); + $flag = [ + "key" => "flag", + "active" => true, + "filters" => ["groups" => [ + ["properties" => [self::dependsOn("dependency")], "rollout_percentage" => 100], + ["properties" => [self::dependsOn("dependency", false)], "rollout_percentage" => 100], + ]], + ]; + + $this->assertSame( + UnresolvedFlagReason::UNRESOLVED_DEPENDENCY, + self::dependencyReason($flag, ["dependency" => $dependency]) + ); + } + + public function testGroupConditionDependencyResultIsNotReusedForPersonCondition(): void + { + // The dependency matches only with the group's properties, so a result cached for the group + // condition must not resolve the person condition. + $dependency = self::dependencyFlag( + "dependency", + [["key" => "\$group_key", "operator" => "exact", "value" => "acme"]] + ); + $flag = [ + "key" => "flag", + "active" => true, + "filters" => ["groups" => [ + [ + "aggregation_group_type_index" => 0, + "properties" => [self::dependsOn("dependency", false)], + "rollout_percentage" => 100, + ], + ["properties" => [self::dependsOn("dependency")], "rollout_percentage" => 100], + ]], + ]; + + try { + FeatureFlag::matchFeatureFlagProperties( + $flag, + "test-user", + [], + [], + ["dependency" => $dependency], + [], + ["company" => "acme"], + ["company" => ["\$group_key" => "acme"]], + ["0" => "company"] + ); + $this->fail("Expected InconclusiveMatchException"); + } catch (InconclusiveMatchException $e) { + $this->assertSame(UnresolvedFlagReason::UNRESOLVED_DEPENDENCY, $e->getReason()); + } + } } From 20c3b45e094bac3533bb946bf78652ab3c6f378b Mon Sep 17 00:00:00 2001 From: "Gustavo H. Strassburger" Date: Wed, 28 Oct 2026 00:55:38 -0300 Subject: [PATCH 2/6] fix(flags): address review findings for unresolved local flags --- .changeset/unresolved-local-flags.md | 2 +- api/public-api.json | 48 ------- lib/FeatureFlag.php | 163 ++++++++++------------ lib/RequiresServerEvaluationException.php | 21 +-- test/FeatureFlagEvaluationsTest.php | 12 -- test/FeatureFlagLocalEvaluationTest.php | 22 +++ test/FlagDependencyTest.php | 15 ++ 7 files changed, 116 insertions(+), 167 deletions(-) diff --git a/.changeset/unresolved-local-flags.md b/.changeset/unresolved-local-flags.md index 0d249bcb..90ed7f0b 100644 --- a/.changeset/unresolved-local-flags.md +++ b/.changeset/unresolved-local-flags.md @@ -11,4 +11,4 @@ Behavior changes: - A flag that depends on a flag with experience continuity enabled no longer resolves locally. It goes to the server, or stays unresolved with `onlyEvaluateLocally`. - A malformed date in a flag filter or a person property now makes its condition inconclusive, like other malformed values, instead of failing local evaluation of the whole flag. - `$feature_flag_called` events from snapshot reads of unresolved flags report `local_evaluation_inconclusive` instead of `flag_missing`. -- When a fresh flag definitions response (HTTP 200) loads, the SDK logs a warning, also with `debug` off, that names the flags local evaluation can never resolve: flags with experience continuity, and flags that use a static cohort, an unknown operator, a malformed value, or a missing or circular dependency. Each flag is warned about once per client until its definition changes. Definitions from a cache provider or a 304 response do not log the warning. +- When a fresh flag definitions response (HTTP 200) loads, the SDK logs a warning, also with `debug` off, that names the flags local evaluation can never resolve: flags with experience continuity, and flags that use a static cohort, an unknown operator, a malformed value, or a missing or circular dependency. Each flag is warned about once per client until its definition changes. Definitions from a cache provider or a 304 response do not log the warning. Without a `flag_definition_cache_provider`, each new client fetches fresh definitions, so under PHP-FPM the warning is logged on every request. Configure a cache provider to log it only when definitions are fetched. diff --git a/api/public-api.json b/api/public-api.json index 4823d405..52f14ef0 100644 --- a/api/public-api.json +++ b/api/public-api.json @@ -4684,54 +4684,6 @@ "constants": [], "properties": [], "methods": { - "__construct": { - "static": false, - "abstract": false, - "final": false, - "returnType": null, - "parameters": [ - { - "name": "message", - "type": "string", - "byReference": false, - "variadic": false, - "optional": true, - "default": "", - "defaultConstant": null, - "hasDefault": true - }, - { - "name": "code", - "type": "int", - "byReference": false, - "variadic": false, - "optional": true, - "default": 0, - "defaultConstant": null, - "hasDefault": true - }, - { - "name": "previous", - "type": "?Throwable", - "byReference": false, - "variadic": false, - "optional": true, - "default": null, - "defaultConstant": null, - "hasDefault": true - }, - { - "name": "reason", - "type": "string", - "byReference": false, - "variadic": false, - "optional": true, - "default": "unsupported_definition", - "defaultConstant": "PostHog\\UnresolvedFlagReason::UNSUPPORTED_DEFINITION", - "hasDefault": true - } - ] - }, "errorMessage": { "static": false, "abstract": false, diff --git a/lib/FeatureFlag.php b/lib/FeatureFlag.php index b22917a1..7f906d90 100644 --- a/lib/FeatureFlag.php +++ b/lib/FeatureFlag.php @@ -316,8 +316,7 @@ private static function matchCohortForIdentity( if (!array_key_exists($cohortId, $cohortProperties)) { throw new RequiresServerEvaluationException( "cohort {$cohortId} not found in local cohorts - " . - "likely a static cohort that requires server evaluation", - reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + "likely a static cohort that requires server evaluation" ); } @@ -420,7 +419,7 @@ private static function matchPropertyGroupForIdentity( } } catch (RequiresServerEvaluationException $err) { // Immediately propagate - this condition requires server-side data - throw FeatureFlag::withHigherReason($err, $inconclusiveReason); + throw $err; } catch (InconclusiveMatchException $err) { $inconclusiveReason = FeatureFlag::pickUnresolvedReason($inconclusiveReason, $err->getReason()); } @@ -486,7 +485,7 @@ private static function matchPropertyGroupForIdentity( } } catch (RequiresServerEvaluationException $err) { // Immediately propagate - this condition requires server-side data - throw FeatureFlag::withHigherReason($err, $inconclusiveReason); + throw $err; } catch (InconclusiveMatchException $err) { $inconclusiveReason = FeatureFlag::pickUnresolvedReason($inconclusiveReason, $err->getReason()); } @@ -1336,7 +1335,7 @@ private static function matchFeatureFlagPropertiesForIdentity( } } catch (RequiresServerEvaluationException $e) { // Immediately propagate - this condition requires server-side data - throw FeatureFlag::withHigherReason($e, $inconclusiveReason); + throw $e; } catch (InconclusiveMatchException $e) { $inconclusiveReason = FeatureFlag::pickUnresolvedReason($inconclusiveReason, $e->getReason()); } @@ -1410,6 +1409,13 @@ private static function isConditionMatch( } catch (InconclusiveMatchException $e) { $inconclusiveReason = FeatureFlag::pickUnresolvedReason($inconclusiveReason, $e->getReason()); continue; + } catch (\Throwable $e) { + // After an inconclusive filter the condition is already inconclusive, so an unexpected + // error must not fail the whole flag and stop later conditions from resolving it. + if ($inconclusiveReason === null) { + throw $e; + } + break; } if (!$matches) { @@ -1420,7 +1426,7 @@ private static function isConditionMatch( // Rethrow the server error type: the caller stops at it and falls back to the server // instead of trying later conditions. if ($serverEvaluationError !== null) { - throw FeatureFlag::withHigherReason($serverEvaluationError, $inconclusiveReason); + throw $serverEvaluationError; } if ($inconclusiveReason !== null) { throw new InconclusiveMatchException( @@ -1458,21 +1464,6 @@ private static function pickUnresolvedReason(?string $current, string $next): st return $precedence[$next] < $precedence[$current] ? $next : $current; } - /** - * The server-required error with the higher-precedence reason of its own and $reason. - */ - private static function withHigherReason( - RequiresServerEvaluationException $e, - ?string $reason - ): RequiresServerEvaluationException { - $higherReason = FeatureFlag::pickUnresolvedReason($reason, $e->getReason()); - if ($higherReason === $e->getReason()) { - return $e; - } - - return new RequiresServerEvaluationException($e->getMessage(), 0, $e, $higherReason); - } - private static function isRegularExpression($string) { if ($string === null) { @@ -1700,46 +1691,10 @@ private static function evaluateFlagDependencyForIdentity( ); } - // Check if dependency_chain is present - it should always be provided for flag dependencies - if (!is_array($property["dependency_chain"] ?? null)) { - throw new InconclusiveMatchException( - sprintf( - "Cannot evaluate flag dependency on '%s' without dependency_chain", - $property["key"] ?? "unknown" - ), - reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION - ); - } - - $dependencyChain = $property["dependency_chain"]; - - // Handle circular dependency (empty chain means circular) - if (count($dependencyChain) === 0) { - throw new InconclusiveMatchException( - sprintf("Circular dependency detected for flag '%s'", $property["key"] ?? "unknown"), - reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION - ); - } - - // The flag key to evaluate is in the "key" field - $depFlagKey = $property["key"] ?? null; - if (!$depFlagKey) { - throw new InconclusiveMatchException( - sprintf("Flag dependency missing 'key' field: %s", json_encode($property)), - reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION - ); - } - - // A dependency with no definition outranks one that is only inconclusive, so check the - // whole chain before evaluating any of it. - foreach (array_merge($dependencyChain, [$depFlagKey]) as $chainFlagKey) { - if (!array_key_exists($chainFlagKey, $evaluationCache) && empty($flagsByKey[$chainFlagKey])) { - throw new InconclusiveMatchException( - sprintf("Cannot evaluate flag dependency '%s' - flag not found in local flags", $chainFlagKey), - reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION - ); - } - } + // An unsupported dependency outranks one that is only inconclusive, so check it before + // evaluating any of it. + FeatureFlag::assertFlagDependencySupported($property, $flagsByKey, $evaluationCache); + $depFlagKey = $property["key"]; // Check if we've already evaluated this flag if (!array_key_exists($depFlagKey, $evaluationCache)) { @@ -1817,28 +1772,68 @@ private static function evaluateFlagDependencyForIdentity( // Now check if the flag value matches the expected value in the property $expectedValue = $property["value"] ?? null; + if ($expectedValue !== null) { + return FeatureFlag::matchesDependencyValue($expectedValue, $flagValue); + } + + // If no value check needed, return true (all dependencies passed) + return true; + } + + /** + * Throw unsupported_definition when local evaluation can never evaluate a flag dependency filter. + * + * @param array $evaluationCache Dependency results already known, keyed by flag key. + * @throws InconclusiveMatchException + */ + private static function assertFlagDependencySupported($property, $flagsByKey, array $evaluationCache): void + { + // Check if dependency_chain is present - it should always be provided for flag dependencies + if (!is_array($property["dependency_chain"] ?? null)) { + throw new InconclusiveMatchException( + sprintf( + "Cannot evaluate flag dependency on '%s' without dependency_chain", + $property["key"] ?? "unknown" + ), + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); + } + + $dependencyChain = $property["dependency_chain"]; + + // Handle circular dependency (empty chain means circular) + if (count($dependencyChain) === 0) { + throw new InconclusiveMatchException( + sprintf("Circular dependency detected for flag '%s'", $property["key"] ?? "unknown"), + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); + } + + // The flag key to evaluate is in the "key" field + $depFlagKey = $property["key"] ?? null; + if (!$depFlagKey) { + throw new InconclusiveMatchException( + sprintf("Flag dependency missing 'key' field: %s", json_encode($property)), + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); + } + $operator = $property["operator"] ?? "exact"; + if (($property["value"] ?? null) !== null && $operator !== "flag_evaluates_to") { + throw new InconclusiveMatchException( + sprintf("Flag dependency property for '%s' has invalid operator '%s'", $depFlagKey, $operator), + reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ); + } - if ($expectedValue !== null) { - // For flag dependencies, we need to compare the actual flag result with expected value - // using the flag_evaluates_to operator logic - if ($operator === "flag_evaluates_to") { - return FeatureFlag::matchesDependencyValue($expectedValue, $flagValue); - } else { - // This should never happen, but just to be defensive + foreach (array_merge($dependencyChain, [$depFlagKey]) as $chainFlagKey) { + if (!array_key_exists($chainFlagKey, $evaluationCache) && empty($flagsByKey[$chainFlagKey])) { throw new InconclusiveMatchException( - sprintf( - "Flag dependency property for '%s' has invalid operator '%s'", - $depFlagKey, - $operator - ), + sprintf("Cannot evaluate flag dependency '%s' - flag not found in local flags", $chainFlagKey), reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION ); } } - - // If no value check needed, return true (all dependencies passed) - return true; } /** @@ -1918,20 +1913,12 @@ private static function isUnsupportedProperty($property, $flagsByKey, $cohorts, $propertyType = $property["type"] ?? null; if ($propertyType === "flag") { - $dependencyChain = $property["dependency_chain"] ?? null; - $depFlagKey = $property["key"] ?? null; - if (!is_array($dependencyChain) || count($dependencyChain) === 0 || !$depFlagKey) { - return true; - } - if (($property["value"] ?? null) !== null && ($property["operator"] ?? "exact") !== "flag_evaluates_to") { + try { + FeatureFlag::assertFlagDependencySupported($property, $flagsByKey, []); + return false; + } catch (InconclusiveMatchException $e) { return true; } - foreach (array_merge($dependencyChain, [$depFlagKey]) as $chainFlagKey) { - if (empty($flagsByKey[$chainFlagKey])) { - return true; - } - } - return false; } if ($propertyType === "cohort") { diff --git a/lib/RequiresServerEvaluationException.php b/lib/RequiresServerEvaluationException.php index 21cdd865..ca0b2308 100644 --- a/lib/RequiresServerEvaluationException.php +++ b/lib/RequiresServerEvaluationException.php @@ -3,7 +3,6 @@ namespace PostHog; use Exception; -use Throwable; /** * Raised when feature flag evaluation requires server-side data. @@ -13,28 +12,14 @@ class RequiresServerEvaluationException extends Exception { /** - * @param string $message Exception message. - * @param int $code Exception code. - * @param Throwable|null $previous Previous exception. - * @param string $reason Why local evaluation is inconclusive, one of the UnresolvedFlagReason values. - */ - public function __construct( - string $message = "", - int $code = 0, - ?Throwable $previous = null, - private readonly string $reason = UnresolvedFlagReason::UNSUPPORTED_DEFINITION - ) { - parent::__construct($message, $code, $previous); - } - - /** - * Why local evaluation is inconclusive, one of the UnresolvedFlagReason values. + * Why local evaluation is inconclusive. Server-side data is never available locally, so the + * definition is unsupported. * * @return string */ public function getReason(): string { - return $this->reason; + return UnresolvedFlagReason::UNSUPPORTED_DEFINITION; } /** diff --git a/test/FeatureFlagEvaluationsTest.php b/test/FeatureFlagEvaluationsTest.php index 51ba832b..e210937e 100644 --- a/test/FeatureFlagEvaluationsTest.php +++ b/test/FeatureFlagEvaluationsTest.php @@ -1480,16 +1480,4 @@ public function testUnexpectedLocalEvaluationErrorIsNotUnresolved(): void $this->assertCount(1, $events); $this->assertSame('flag_missing', $events[0]['$feature_flag_error']); } - - public function testReadingUnresolvedFlagsDoesNotRecordAccessOrFireEvents(): void - { - $this->makeLocalClient(self::localDefinitions(self::localFlag('checkout', continuity: true))); - - $snapshot = $this->client->evaluateFlags('user-123', onlyEvaluateLocally: true); - $snapshot->getUnresolvedFlags(); - - $this->assertSame([], $snapshot->onlyAccessed()->getKeys()); - $this->assertSame([], $this->flagCalledEvents('checkout')); - $this->assertSame(0, $this->flagsRequestCount()); - } } diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index 465448ce..e24a7c7e 100644 --- a/test/FeatureFlagLocalEvaluationTest.php +++ b/test/FeatureFlagLocalEvaluationTest.php @@ -5648,6 +5648,28 @@ public function testConditionWithNonMatchingFilterResolvesDespiteInconclusiveFil } } + public function testUnexpectedErrorAfterInconclusiveFilterLeavesTheConditionInconclusive(): void + { + $missing = self::filter('exact', 'pro', 'plan'); + $throwing = self::filter('exact', 'a', 'email'); + // A value that cannot be encoded as JSON makes the exact match throw a plain error. + $properties = ['email' => ["\xB1"]]; + + $this->assertTrue(FeatureFlag::matchFeatureFlagProperties( + self::flagWithConditions([$missing, $throwing], []), + 'user', + $properties + )); + $this->assertSame( + UnresolvedFlagReason::MISSING_CONTEXT, + self::reasonOf(fn() => FeatureFlag::matchFeatureFlagProperties( + self::flagWithConditions([$missing, $throwing]), + 'user', + $properties + )) + ); + } + public function testConditionWithStaticCohortAndNonMatchingFilterResolvesLocally(): void { $flag = self::flagWithConditions([self::staticCohortFilter(), self::filter('exact', 'other', 'email')]); diff --git a/test/FlagDependencyTest.php b/test/FlagDependencyTest.php index d89c527a..78dbec93 100644 --- a/test/FlagDependencyTest.php +++ b/test/FlagDependencyTest.php @@ -901,6 +901,21 @@ public function testDependencyThatNeedsContextIsAnUnresolvedDependency(): void ); } + public function testInvalidDependencyOperatorOutranksADependencyThatNeedsContext(): void + { + $dependency = self::dependencyFlag("dependency", [["key" => "plan", "operator" => "exact", "value" => "pro"]]); + $flag = self::dependencyFlag("flag", [array_merge(self::dependsOn("dependency"), ["operator" => "exact"])]); + $flagsByKey = ["dependency" => $dependency, "flag" => $flag]; + + $this->assertSame( + [UnresolvedFlagReason::UNSUPPORTED_DEFINITION, UnresolvedFlagReason::UNSUPPORTED_DEFINITION], + [ + self::dependencyReason($flag, $flagsByKey), + FeatureFlag::getDefinitionUnresolvedReason($flag, $flagsByKey, [], []), + ] + ); + } + public function testDependencyThatRequiresServerIsAnUnresolvedDependency(): void { $dependency = self::dependencyFlag("dependency", [["key" => "id", "type" => "cohort", "value" => 999]]); From 4277da1d050d5f8df6bb2325814affa347aea417 Mon Sep 17 00:00:00 2001 From: "Gustavo H. Strassburger" Date: Wed, 28 Oct 2026 16:45:07 -0300 Subject: [PATCH 3/6] fix(flags): fall back to the server when a flag dependency needs server data --- .changeset/unresolved-local-flags.md | 2 +- api/public-api.json | 48 ++++++++++++ lib/FeatureFlag.php | 51 +++++++++++-- lib/RequiresServerEvaluationException.php | 22 +++++- .../__pycache__/check_report.cpython-314.pyc | Bin 0 -> 7856 bytes .../__pycache__/server.cpython-314.pyc | Bin 0 -> 18314 bytes .../test_check_report.cpython-314.pyc | Bin 0 -> 10832 bytes .../__pycache__/test_server.cpython-314.pyc | Bin 0 -> 21879 bytes test/FeatureFlagEvaluationsTest.php | 70 ++++++++++++++++++ test/FlagDependencyTest.php | 46 ++++++++---- 10 files changed, 215 insertions(+), 24 deletions(-) create mode 100644 sdk_compliance_adapter/__pycache__/check_report.cpython-314.pyc create mode 100644 sdk_compliance_adapter/__pycache__/server.cpython-314.pyc create mode 100644 sdk_compliance_adapter/__pycache__/test_check_report.cpython-314.pyc create mode 100644 sdk_compliance_adapter/__pycache__/test_server.cpython-314.pyc diff --git a/.changeset/unresolved-local-flags.md b/.changeset/unresolved-local-flags.md index 90ed7f0b..aa098453 100644 --- a/.changeset/unresolved-local-flags.md +++ b/.changeset/unresolved-local-flags.md @@ -6,7 +6,7 @@ Report the flags local evaluation could not resolve on `evaluateFlags()` snapsho Behavior changes: -- A condition with a filter that local evaluation cannot check and a filter that does not match now resolves locally as no match. An inconclusive flag dependency in one condition, or a dependency that needs server data such as a static cohort, no longer stops a later condition from resolving the flag. So a flag that went to the server before can now resolve locally. +- A condition with a filter that local evaluation cannot check and a filter that does not match now resolves locally as no match. An inconclusive flag dependency in one condition no longer stops a later condition from resolving the flag. So a flag that went to the server before can now resolve locally. A dependency that needs server data, such as a static cohort, still sends the flag to the server. - An inactive flag with experience continuity enabled now resolves to `false` locally instead of going to the server. - A flag that depends on a flag with experience continuity enabled no longer resolves locally. It goes to the server, or stays unresolved with `onlyEvaluateLocally`. - A malformed date in a flag filter or a person property now makes its condition inconclusive, like other malformed values, instead of failing local evaluation of the whole flag. diff --git a/api/public-api.json b/api/public-api.json index 52189bf8..81b148bb 100644 --- a/api/public-api.json +++ b/api/public-api.json @@ -4695,6 +4695,54 @@ "constants": [], "properties": [], "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "message", + "type": "string", + "byReference": false, + "variadic": false, + "optional": true, + "default": "", + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "code", + "type": "int", + "byReference": false, + "variadic": false, + "optional": true, + "default": 0, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "previous", + "type": "?Throwable", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "reason", + "type": "string", + "byReference": false, + "variadic": false, + "optional": true, + "default": "unsupported_definition", + "defaultConstant": "PostHog\\UnresolvedFlagReason::UNSUPPORTED_DEFINITION", + "hasDefault": true + } + ] + }, "errorMessage": { "static": false, "abstract": false, diff --git a/lib/FeatureFlag.php b/lib/FeatureFlag.php index 7f906d90..2e01f231 100644 --- a/lib/FeatureFlag.php +++ b/lib/FeatureFlag.php @@ -1257,7 +1257,7 @@ private static function matchFeatureFlagPropertiesForIdentity( $inconclusiveReason = null; $groupEvaluationCaches = []; - foreach ($flagConditions as $condition) { + foreach ($flagConditions as $conditionIndex => $condition) { try { // Per-condition aggregation overrides only when the condition explicitly // sets its own aggregation_group_type_index (mixed targeting). When absent, @@ -1334,8 +1334,18 @@ private static function matchFeatureFlagPropertiesForIdentity( return false; } } catch (RequiresServerEvaluationException $e) { - // Immediately propagate - this condition requires server-side data - throw $e; + // Immediately propagate - this condition requires server-side data. Later conditions + // are not evaluated, so their definitions still count toward the reason. + $reason = FeatureFlag::pickUnresolvedReason($inconclusiveReason, $e->getReason()); + $laterReason = FeatureFlag::conditionsDefinitionReason( + array_slice($flagConditions, $conditionIndex + 1), + $flagsByKey ?? [], + $cohorts ?? [] + ); + if ($laterReason !== null) { + $reason = FeatureFlag::pickUnresolvedReason($reason, $laterReason); + } + throw new RequiresServerEvaluationException($e->getMessage(), 0, $e, $reason); } catch (InconclusiveMatchException $e) { $inconclusiveReason = FeatureFlag::pickUnresolvedReason($inconclusiveReason, $e->getReason()); } @@ -1424,9 +1434,15 @@ private static function isConditionMatch( } // Rethrow the server error type: the caller stops at it and falls back to the server - // instead of trying later conditions. + // instead of trying later conditions. Later filters in this condition can still + // outrank its reason. if ($serverEvaluationError !== null) { - throw $serverEvaluationError; + throw new RequiresServerEvaluationException( + $serverEvaluationError->getMessage(), + 0, + $serverEvaluationError, + $inconclusiveReason + ); } if ($inconclusiveReason !== null) { throw new InconclusiveMatchException( @@ -1747,7 +1763,17 @@ private static function evaluateFlagDependencyForIdentity( $identityAggregation ); $evaluationCache[$depFlagKey] = $depResult; - } catch (InconclusiveMatchException | RequiresServerEvaluationException $e) { + } catch (RequiresServerEvaluationException $e) { + // Keep the server error type so the dependent flag falls back to the server instead + // of letting a later condition resolve it locally. Nothing is cached, so a later + // check of this dependency needs the server too. + throw new RequiresServerEvaluationException( + sprintf("Cannot evaluate flag dependency '%s': %s", $depFlagKey, $e->getMessage()), + 0, + $e, + UnresolvedFlagReason::UNRESOLVED_DEPENDENCY + ); + } catch (InconclusiveMatchException $e) { // If we can't evaluate a dependency, store null and propagate the error $evaluationCache[$depFlagKey] = null; throw new InconclusiveMatchException( @@ -1893,7 +1919,18 @@ public static function getDefinitionUnresolvedReason($flag, $flagsByKey, $cohort return UnresolvedFlagReason::UNSUPPORTED_DEFINITION; } - foreach ($flagFilters["groups"] ?? [] as $condition) { + return FeatureFlag::conditionsDefinitionReason($flagFilters["groups"] ?? [], $flagsByKey, $cohorts); + } + + /** + * UnresolvedFlagReason::UNSUPPORTED_DEFINITION when local evaluation can never check a filter in + * one of the conditions, null otherwise. + * + * @param array> $conditions Release conditions of a feature flag. + */ + private static function conditionsDefinitionReason(array $conditions, $flagsByKey, $cohorts): ?string + { + foreach ($conditions as $condition) { foreach ($condition["properties"] ?? [] as $property) { $visitedCohortIds = []; if (FeatureFlag::isUnsupportedProperty($property, $flagsByKey, $cohorts, $visitedCohortIds)) { diff --git a/lib/RequiresServerEvaluationException.php b/lib/RequiresServerEvaluationException.php index ca0b2308..ec40100b 100644 --- a/lib/RequiresServerEvaluationException.php +++ b/lib/RequiresServerEvaluationException.php @@ -3,6 +3,7 @@ namespace PostHog; use Exception; +use Throwable; /** * Raised when feature flag evaluation requires server-side data. @@ -12,14 +13,29 @@ class RequiresServerEvaluationException extends Exception { /** - * Why local evaluation is inconclusive. Server-side data is never available locally, so the - * definition is unsupported. + * @param string $message Exception message. + * @param int $code Exception code. + * @param Throwable|null $previous Previous exception. + * @param string $reason Why local evaluation is inconclusive, one of the UnresolvedFlagReason values. + * Server-side data is never available locally, so by default the definition is unsupported. + */ + public function __construct( + string $message = "", + int $code = 0, + ?Throwable $previous = null, + private readonly string $reason = UnresolvedFlagReason::UNSUPPORTED_DEFINITION + ) { + parent::__construct($message, $code, $previous); + } + + /** + * Why local evaluation is inconclusive, one of the UnresolvedFlagReason values. * * @return string */ public function getReason(): string { - return UnresolvedFlagReason::UNSUPPORTED_DEFINITION; + return $this->reason; } /** diff --git a/sdk_compliance_adapter/__pycache__/check_report.cpython-314.pyc b/sdk_compliance_adapter/__pycache__/check_report.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d4592756f026d73341b3135fcb95bfd2ff172d8b GIT binary patch literal 7856 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 HQ7Zp`5u&7d literal 0 HcmV?d00001 diff --git a/sdk_compliance_adapter/__pycache__/test_check_report.cpython-314.pyc b/sdk_compliance_adapter/__pycache__/test_check_report.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..10fe8c6e8cf1cf9b84135ca2a4b2d4bcba3e9e42 GIT binary patch literal 10832 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 literal 0 HcmV?d00001 diff --git a/sdk_compliance_adapter/__pycache__/test_server.cpython-314.pyc b/sdk_compliance_adapter/__pycache__/test_server.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6cfa52a66346f388ebceaaf4ebf5dba174e24bde GIT binary patch literal 21879 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 Z`J}dZU1$B);Xgk7%_CsVuPY%v`TyVAF}45z literal 0 HcmV?d00001 diff --git a/test/FeatureFlagEvaluationsTest.php b/test/FeatureFlagEvaluationsTest.php index e210937e..3330617c 100644 --- a/test/FeatureFlagEvaluationsTest.php +++ b/test/FeatureFlagEvaluationsTest.php @@ -1325,6 +1325,76 @@ public function testFlagDependingOnFlagWithNoLoadedDefinitionReportsUnsupportedD $this->assertSame(['beta-ui' => UnresolvedFlagReason::UNSUPPORTED_DEFINITION], $snapshot->getUnresolvedFlags()); } + /** A flag whose first condition depends on `dep` and whose second condition matches everyone. */ + private static function flagWithDependencyAndCatchAll(): array + { + return [ + 'id' => 2, + 'key' => 'target', + 'active' => true, + 'filters' => [ + 'multivariate' => ['variants' => [ + ['key' => 'a', 'rollout_percentage' => 50], + ['key' => 'b', 'rollout_percentage' => 50], + ]], + 'groups' => [ + ['properties' => [self::dependsOnFilter('dep')], 'rollout_percentage' => 100, 'variant' => 'a'], + ['properties' => [], 'rollout_percentage' => 100, 'variant' => 'b'], + ], + ], + ]; + } + + #[DataProvider('dependencyThatNeedsServerProvider')] + public function testFlagWithDependencyThatNeedsServerFallsBackToServer(?array $flagKeys): void + { + $this->makeLocalClient( + self::localDefinitions( + self::localFlag('dep', [self::staticCohortFilter()]), + self::flagWithDependencyAndCatchAll(), + ), + ['flags' => ['target' => ['key' => 'target', 'enabled' => true, 'variant' => 'a']]] + ); + + $snapshot = $this->client->evaluateFlags('user-123', flagKeys: $flagKeys); + + $this->assertSame(1, $this->flagsRequestCount()); + $this->assertSame('a', $snapshot->getFlag('target')); + $this->assertArrayNotHasKey('target', $snapshot->getUnresolvedFlags()); + } + + public static function dependencyThatNeedsServerProvider(): iterable + { + yield 'flag requested alone' => [['target']]; + yield 'all flags requested' => [null]; + } + + public function testFlagWithDependencyThatNeedsServerIsUnresolvedLocally(): void + { + $this->makeLocalClient(self::localDefinitions( + self::localFlag('dep', [self::staticCohortFilter()]), + self::flagWithDependencyAndCatchAll(), + )); + + $snapshot = $this->client->evaluateFlags('user-123', onlyEvaluateLocally: true, flagKeys: ['target']); + + $this->assertSame([], $snapshot->getKeys()); + $this->assertSame(['target' => UnresolvedFlagReason::UNRESOLVED_DEPENDENCY], $snapshot->getUnresolvedFlags()); + } + + public function testFlagWithLocallyResolvedDependencyMatchesTheDependencyCondition(): void + { + $this->makeLocalClient(self::localDefinitions( + self::localFlag('dep', [self::planFilter()]), + self::flagWithDependencyAndCatchAll(), + )); + + $snapshot = $this->client->evaluateFlags('user-123', personProperties: ['plan' => 'pro'], flagKeys: ['target']); + + $this->assertSame(0, $this->flagsRequestCount()); + $this->assertSame('a', $snapshot->getFlag('target')); + } + public function testFlagResolvedByRemoteFallbackIsNotUnresolved(): void { $this->makeLocalClient( diff --git a/test/FlagDependencyTest.php b/test/FlagDependencyTest.php index 78dbec93..4d3d329c 100644 --- a/test/FlagDependencyTest.php +++ b/test/FlagDependencyTest.php @@ -2,6 +2,7 @@ namespace PostHog\Test; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use PostHog\Client; @@ -923,34 +924,53 @@ public function testDependencyThatRequiresServerIsAnUnresolvedDependency(): void try { FeatureFlag::matchFeatureFlagProperties($flag, "test-user", [], [], ["dependency" => $dependency], []); - $this->fail("Expected InconclusiveMatchException"); + $this->fail("Expected RequiresServerEvaluationException"); } catch (RequiresServerEvaluationException $e) { - $this->fail("A dependency that requires the server must not force a server fallback"); - } catch (InconclusiveMatchException $e) { $this->assertSame(UnresolvedFlagReason::UNRESOLVED_DEPENDENCY, $e->getReason()); } } - public function testDependencyThatRequiresServerDoesNotBlockLaterConditions(): void - { + #[DataProvider('laterFiltersOfServerDependencyProvider')] + public function testDependencyThatRequiresServerBlocksLaterConditions( + array $sameConditionFilters, + array $laterConditionFilters, + string $reason + ): void { $dependency = self::dependencyFlag("dependency", [["key" => "id", "type" => "cohort", "value" => 999]]); $flag = [ "key" => "flag", "active" => true, "filters" => ["groups" => [ - ["properties" => [self::dependsOn("dependency")], "rollout_percentage" => 100], - ["properties" => [], "rollout_percentage" => 100], + [ + "properties" => array_merge([self::dependsOn("dependency")], $sameConditionFilters), + "rollout_percentage" => 100, + ], + ["properties" => $laterConditionFilters, "rollout_percentage" => 100], ]], ]; - $this->assertTrue(FeatureFlag::matchFeatureFlagProperties( - $flag, - "test-user", + try { + FeatureFlag::matchFeatureFlagProperties($flag, "test-user", [], [], ["dependency" => $dependency], []); + $this->fail("Expected RequiresServerEvaluationException"); + } catch (RequiresServerEvaluationException $e) { + $this->assertSame($reason, $e->getReason()); + } + } + + public static function laterFiltersOfServerDependencyProvider(): iterable + { + $unknownOperator = ["key" => "plan", "operator" => "not_a_real_operator", "value" => "pro"]; + yield 'later condition matches everyone' => [[], [], UnresolvedFlagReason::UNRESOLVED_DEPENDENCY]; + yield 'later condition is unsupported' => [ [], + [$unknownOperator], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'later filter in the same condition is unsupported' => [ + [$unknownOperator], [], - ["dependency" => $dependency], - [] - )); + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; } public function testMissingDependencyAnywhereInTheChainIsAnUnsupportedDefinition(): void From 39fbad238a28d38011fdd42b688c10333415407d Mon Sep 17 00:00:00 2001 From: "Gustavo H. Strassburger" Date: Wed, 28 Oct 2026 16:45:16 -0300 Subject: [PATCH 4/6] chore: remove committed python bytecode --- .../__pycache__/check_report.cpython-314.pyc | Bin 7856 -> 0 bytes .../__pycache__/server.cpython-314.pyc | Bin 18314 -> 0 bytes .../test_check_report.cpython-314.pyc | Bin 10832 -> 0 bytes .../__pycache__/test_server.cpython-314.pyc | Bin 21879 -> 0 bytes 4 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 sdk_compliance_adapter/__pycache__/check_report.cpython-314.pyc delete mode 100644 sdk_compliance_adapter/__pycache__/server.cpython-314.pyc delete mode 100644 sdk_compliance_adapter/__pycache__/test_check_report.cpython-314.pyc delete mode 100644 sdk_compliance_adapter/__pycache__/test_server.cpython-314.pyc diff --git a/sdk_compliance_adapter/__pycache__/check_report.cpython-314.pyc b/sdk_compliance_adapter/__pycache__/check_report.cpython-314.pyc deleted file mode 100644 index d4592756f026d73341b3135fcb95bfd2ff172d8b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7856 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 HQ7Zp`5u&7d diff --git a/sdk_compliance_adapter/__pycache__/test_check_report.cpython-314.pyc b/sdk_compliance_adapter/__pycache__/test_check_report.cpython-314.pyc deleted file mode 100644 index 10fe8c6e8cf1cf9b84135ca2a4b2d4bcba3e9e42..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10832 zcmb_iYj7Lab-oK=@dimyAovg;;6oA#kst{|q9{>PB#Mw}QWUkMKtCX^Az(>C1_5Yy zp$A4ym1NS;ZKjs;M@(ba%+#5{lXR-2?u1S=W7$p|{mEnzhyl9RjNPQ2jDJ;;oKd4+ zJ$DxifD}mEb(X~4i~BtH-t+j*x!7G{F(P;#Gyi%1=XQjCkH07nna@1@F=VbG3h`(T zQN$SWl!{li;Jj)~&8wxihSx}Mk|(9Nme;~tJ*Im~$LpTb^ZKU@yrBhAdP-A^A_Q-& zL9K`)YZ0YAqn(18H$EP#fg)oPzUHWtxk`olO zz%m>cjzu}ge2jJQ=NLyk9E~y|$GHF-g(}C10Q+Jnb}8xzM=vr_KE_^gEChJQ(HV_7 zrjL#HMj8H6jD4}o)kTP!sQ`a&vP&(RgXfswi!{r`V=OOLL}GyuUFa|Br>K+Ov5C>? zpsIva6Xfxt9yUOF4fzSBP9Z(6qL2@nVT;{rN=2zD4W;rCyYkIK(5n%)>;yq+%arS& zTo1hrl*(m_t9`1e{01ts;I}eU6&fYHg9u#$`q5fqS{Xq z>q|$8@%=JQRNIubxCSNXQ!Cujj=DOM7443AjN{M67JB06;yuY~$4G239tj7cLB>JJ zGKZ4QPUkTYRHt*l!`FNN*SBB#e=fNV>Jw~$2jRzU`uqA8xx{vuvc28$ zyMI{upVgNg)3HUyF~e{?=kNx?5hj$V%h$j1b;prNBwsfb;5a7K`nbdC^z7~R3_6_- zUg{K=D!Ls`XFrt9OG6~>&}#6>R!MbFPmibz2I72zWs+*3BB_-pNMNSSljL-YL~pWb z%PA5dr091*qSKkI+|V*<7!EN!P{JKbwhVKL#l--7<&ZDTTyh+f%ZGcF(q!XRi2%n{ z$21?{6C7leEpuFjTA&!7{Hr7b*#uTEzkBtYf0Ev>d|f15B0-CUc-%00InD%mP{#cZmbs9?d)n<-i1Cg-kuY|tS*$)D z8;5(96-fnM237;*A{pkHMNTC7Fdtz=b%cqE1}2(VWZ=vhk>r43cu@x`IuZynqBanZ zGtrQ!IUfs0yG)`k&c^29$VIJuY@(s4zsX1#Oaly&fGI?EIK+u&DNpCylHq8Gxh!g; zfkhaAllD&3g~Bjr;0lL@Y3ELd@`z`8Pjd{*^)4i!<;B=J4%TzA7aJ9xWf-ovgx7oH zY>#_ie=iq$kq#Dko(_b7YZ$f{a~)l3dR*}3^u89=I&Q{p#Ip8XFBzA}Ty0af)+5w#LFFxm5oTr*Cch!856+g}u}1>X}@9%j@cu zk$=)JCsWoR4y3pr>`OUsofK-1-9CF~cdmNi79|YL=BoSd8#LAKKOv35y+nRuM2$OE zrvLHKOXEwUA2vHviB;l<$s4Cv6HDHlz2WBh8|PQHtpqam&Xje{zB_9lNZSW)jjV3V z*bipyBWe3c#y+}qJZHAPJn_=RvK#NpN-(uERd=gSsNerv^MOC=P^0sa9+~T)(e<;- zu2kDC)vbYB7yfPUPlkUqd|Q3{RA%7#9b#?ZX~8{y$1S)fg{jjyyZhGYeKlb}@FzrA z4lIp+q__OWx@%3p>+i@vHlfG1Rw@BvxYKXgcJ z0fHtHs)U*@K#mqPgUXq?qIoZx=Gkh+F!~Axb%p@5qw=XRcjHHfLvyYZ#9S}bsQm>K z=2I!g#Eif!<|zVPsKI{;5-gw`()*Q^NFfnH|sPI(t{H2z}mP``%%NXP+0m ze!cVBxKGqXO@er&M)spby5`73HQL?@NuA>lHAi4{q2|bA4r{$P zju1zN4etz_j#@11_#0PB{QYA{%ffHMk2hF)C{&AlL^*y{KyOunvlVUw_)cJ8K83B; zhQ|kRzPqH1D)2S5FF?{_1K`uVY_11tHbZ+=Q9+0KdU#-kC zWm9s+chT>Aa_%_kFZh*H7}srhT9Lm1(aZUi{uZT!zqOESQ*z3hr*r0l7(feRoljc6QHxR1Duu92YK;gqr4L=o+1j~aBm>aqT+!y9841NEYDpE^XIx$qRQ1LY9yS94b0BGQZ@`PFj3}moINC--c-ddhGkXFEXMgOlEMy0 zC9sF}ctK|aT6PUbV!;3)c~@cB9+-@4g5+aZu~0pm?igG?_V(eo4&T|IJ>a|P`IaZs zG5BXr4=ui0*G0T$>>?!4oa|ihQ$@mO>0pA5Jn31nSYRL9F<@uFCtw-c8wo6MvZumg zY5eKr$8daT@(n`N%H)cAWxUB`cLlt zOL)I}268bLyrN@a7A!Udd|=$fSul-3hzE&kHW3xo+!Yvvxy%F;eBdm0FF2lM@kxt% z8jyB`p=r^8S%Jnr0E>@BG_nlr7aV1X5%n@TV^QoLgcz3X(y_gm*h9FbnvGo&^%nyy zxU@Vc8WsZ)u+fW5NHl~BM8V#2i3rbObCUNaWXmrb>d(wY;?Xb8iCBGbXF#yPpRl%M ztgTDhdll8`>e&@3?6}x-^!v z*4%8o(YW$#iu>N&^~S8#vu5=GtjN~vOxNsuJ(;a>uhqEk*&HkNsi*JS1~EJsfTVg) z+UohE8a1?iqDK|>tfliemd>ojdDr4hh40n0+?@N?-0RcdpZo6Io6|qD{p{SoH)n@V zrH4)lR4_9X%IslsJ9^*T_U6oL*T2%YudeNx&h*a!VLN<8ZvH$V5qpGK@_uBl%9ow(@zKGSiP z>6fJGxZSW$FJfi^&8B}SNQYG>$XJ4M6fCnf&hb7gVSW`L3J`mYEy^Jr3KcERhTvB@ zd;>^Hu`}iHi9#9Rm-6^X?b872*FYGmfbiqQIF(2A&*wF{5oiYe5+}=#;v-=UElyz^ zok9juN|lr=##`(NPLAOO!%J{r zidxJd$3!4J!nWfE7zv4FFqQzFh9gw6(vo%7cFDO&GHi^NaR z=Wat%qK)czW$L@K^?m93zTeinm&QJ*YrU??H8j8G{I+vtI@OwK=uVwlYv|2345mRg z)vME)h9lXA1WsC?VS^g5FT6RVOfZ)!S65c+o;#eyAN)JN_`83_c!gmuJy*= z6j2IMH`bQceZF~$`se>gS6LkhS2hY2%KI-kI!S4jQI$9NdK#Z5@9U9(^ULU3A4%y- z&x6u`imzAVi71Ta$BZgH3XXo#-)cdAtcj!t<}@fYg3dGIzY!*|8kqm1pqt1a+} ztHPh66(`z1i>ERE_llpFEOHBhpo5h`ae$eX+|x#CF?-y2LFyJh|7;~AUh*8ESRKZ@R- zxzqZK**hU&dgfOP!ZZF~MFl!2gqSsYer<9-vv*-#XQBH<#x}XGNFMKBPQGz+b@cY$ zO!w0n+xX@UhBDF!_jPL96ktlw-wkbF5LH6VsiE!94@{`)H1Vin1E3QC&~Yx3CY`7#Er*)@&1bX%gBG2nVT*ZMyJ%NH}mZ zW1E6YAsdB-NvjA`*4mb~w!OYTYjv$zUH7UQm!C};?^a2USZ~_ul0@c1s|)G{t4jtw z?>H?-N!`1o>8O1@`xijZN1Hk@n=${oplVt1SjX<=T9mN+CbN_n;Z0fq8!JLpR!DV4 zcc3WT5dJhOX1Q!H6J=IL-DF-1RZ$WI)@=~hGUD&YAYFR46xITy0Aa0EXaHfY!hdCi zwd&Ixni65PCQ0e89A5ZhUH-N&Ne=Y&4)i5S_mK4JAMEXiI{exvU9Xq9glDnlnR$bd z?_s7E#x>d!vNbC`~IAz3HPlFc1VmDt+ZI}4e;KE*nIYE{-Y zoVE=M2Tu#Lf0eO)`JSyU)p6I>o3#z5ZG)>7cPcWrS!J-HRCv{zu^oVWS=&L-1;#I; z3*I9vBaOQE8cicT>vcgPm~l~BY>BjhjBP0`aNVMCYMcO5RfhX1P4Vi{2bY^ld*uxr zu7#UZ-3+@3v$Iu@0+z_M&ba7+|OWJ3iCz+WI1PaB? zt;pvei~)i;jG`sRTjB3oyX7dBu;*0HbNZp0sGcR3v>)lsFI!%+2=;wH>%M*QPBOFq zx!>xaXFH+e8xV!C;1DW~nnDQGNYjh4P(li)nrRwegXU{0ahR3mq+o9}M$>E`bZ2q2 zpQUjE7tx44kCP})E8Qa54&s zs82-0JO&REXGlRB>GoLS8&N+4H6sDIJ(lR@aSsiiiRJJJc(Y~vp&yoa_z?RQD8uXI zFdBexCPDlMqW&AzeSoYVAmaz968`^y4yV!KPqa3o@e|i5LHLQ~&TPZ(bi?jX;5UQt g_5PF!Y0OK>yXyL!#{7*FUp?_RCn4&gX^^J;Kf4Gd(EtDd diff --git a/sdk_compliance_adapter/__pycache__/test_server.cpython-314.pyc b/sdk_compliance_adapter/__pycache__/test_server.cpython-314.pyc deleted file mode 100644 index 6cfa52a66346f388ebceaaf4ebf5dba174e24bde..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21879 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 Z`J}dZU1$B);Xgk7%_CsVuPY%v`TyVAF}45z From fb2078c6ddb24c45ed855ec7358cf5582eff148a Mon Sep 17 00:00:00 2001 From: "Gustavo H. Strassburger" Date: Wed, 28 Oct 2026 16:56:25 -0300 Subject: [PATCH 5/6] fix(flags): rank unresolved reasons across the whole flag on server fallback --- lib/FeatureFlag.php | 26 ++++++++----- test/FeatureFlagEvaluationsTest.php | 53 ++++++++++++++++++++++++-- test/FlagDependencyTest.php | 59 +++++++++++++++++++++-------- 3 files changed, 109 insertions(+), 29 deletions(-) diff --git a/lib/FeatureFlag.php b/lib/FeatureFlag.php index 2e01f231..7b36a376 100644 --- a/lib/FeatureFlag.php +++ b/lib/FeatureFlag.php @@ -1257,7 +1257,7 @@ private static function matchFeatureFlagPropertiesForIdentity( $inconclusiveReason = null; $groupEvaluationCaches = []; - foreach ($flagConditions as $conditionIndex => $condition) { + foreach ($flagConditions as $condition) { try { // Per-condition aggregation overrides only when the condition explicitly // sets its own aggregation_group_type_index (mixed targeting). When absent, @@ -1334,16 +1334,22 @@ private static function matchFeatureFlagPropertiesForIdentity( return false; } } catch (RequiresServerEvaluationException $e) { - // Immediately propagate - this condition requires server-side data. Later conditions - // are not evaluated, so their definitions still count toward the reason. + // Immediately propagate - this condition requires server-side data. Evaluation stops + // before it visits every filter of the flag, including those inside cohorts, so the + // definition of the whole flag still counts toward the reason. $reason = FeatureFlag::pickUnresolvedReason($inconclusiveReason, $e->getReason()); - $laterReason = FeatureFlag::conditionsDefinitionReason( - array_slice($flagConditions, $conditionIndex + 1), - $flagsByKey ?? [], - $cohorts ?? [] - ); - if ($laterReason !== null) { - $reason = FeatureFlag::pickUnresolvedReason($reason, $laterReason); + try { + $definitionReason = FeatureFlag::conditionsDefinitionReason( + $flagConditions, + $flagsByKey ?? [], + $cohorts ?? [] + ); + } catch (\Throwable $lookupError) { + // A malformed filter must not stop the server fallback, so keep the reason found so far. + $definitionReason = null; + } + if ($definitionReason !== null) { + $reason = FeatureFlag::pickUnresolvedReason($reason, $definitionReason); } throw new RequiresServerEvaluationException($e->getMessage(), 0, $e, $reason); } catch (InconclusiveMatchException $e) { diff --git a/test/FeatureFlagEvaluationsTest.php b/test/FeatureFlagEvaluationsTest.php index 3330617c..30261917 100644 --- a/test/FeatureFlagEvaluationsTest.php +++ b/test/FeatureFlagEvaluationsTest.php @@ -12,6 +12,7 @@ use PostHog\Client; use PostHog\EvaluatedFlagRecord; use PostHog\FeatureFlagEvaluations; +use PostHog\FlagDefinitionCacheProvider; use PostHog\PostHog; use PostHog\Test\Assets\MockedResponses; use PostHog\UnresolvedFlagReason; @@ -1325,8 +1326,8 @@ public function testFlagDependingOnFlagWithNoLoadedDefinitionReportsUnsupportedD $this->assertSame(['beta-ui' => UnresolvedFlagReason::UNSUPPORTED_DEFINITION], $snapshot->getUnresolvedFlags()); } - /** A flag whose first condition depends on `dep` and whose second condition matches everyone. */ - private static function flagWithDependencyAndCatchAll(): array + /** A flag whose first condition depends on `dep` and whose second condition matches everyone by default. */ + private static function flagWithDependencyAndCatchAll(array $laterConditionFilters = []): array { return [ 'id' => 2, @@ -1339,7 +1340,7 @@ private static function flagWithDependencyAndCatchAll(): array ]], 'groups' => [ ['properties' => [self::dependsOnFilter('dep')], 'rollout_percentage' => 100, 'variant' => 'a'], - ['properties' => [], 'rollout_percentage' => 100, 'variant' => 'b'], + ['properties' => $laterConditionFilters, 'rollout_percentage' => 100, 'variant' => 'b'], ], ], ]; @@ -1369,6 +1370,52 @@ public static function dependencyThatNeedsServerProvider(): iterable yield 'all flags requested' => [null]; } + public function testFlagWithDependencyThatNeedsServerFallsBackToServerDespiteAMalformedLaterCondition(): void + { + global $errorMessages; + $malformedDependency = array_merge(self::dependsOnFilter('dep'), ['dependency_chain' => [['dep']]]); + $definitions = self::localDefinitions( + self::localFlag('dep', [self::staticCohortFilter()]), + self::flagWithDependencyAndCatchAll([$malformedDependency]), + ); + // Cached definitions skip the load-time warning, so evaluation is the first code to read them. + $cacheProvider = new class ($definitions) implements FlagDefinitionCacheProvider { + public function __construct(private array $definitions) + { + } + + public function getFlagDefinitions(): ?array + { + return $this->definitions; + } + + public function shouldFetchFlagDefinitions(): bool + { + return false; + } + + public function onFlagDefinitionsReceived(array $data): void + { + } + + public function shutdown(): void + { + } + }; + $this->makeClient( + flagsEndpointResponse: ['flags' => ['target' => ['key' => 'target', 'enabled' => true, 'variant' => 'a']]], + personalApiKey: 'test-personal-key', + options: ['flag_definition_cache_provider' => $cacheProvider] + ); + + $snapshot = $this->client->evaluateFlags('user-123', flagKeys: ['target']); + + $this->assertSame( + [1, 'a', []], + [$this->flagsRequestCount(), $snapshot->getFlag('target'), $errorMessages] + ); + } + public function testFlagWithDependencyThatNeedsServerIsUnresolvedLocally(): void { $this->makeLocalClient(self::localDefinitions( diff --git a/test/FlagDependencyTest.php b/test/FlagDependencyTest.php index 4d3d329c..8cbad54a 100644 --- a/test/FlagDependencyTest.php +++ b/test/FlagDependencyTest.php @@ -930,47 +930,74 @@ public function testDependencyThatRequiresServerIsAnUnresolvedDependency(): void } } - #[DataProvider('laterFiltersOfServerDependencyProvider')] - public function testDependencyThatRequiresServerBlocksLaterConditions( - array $sameConditionFilters, - array $laterConditionFilters, + #[DataProvider('serverDependencyReasonProvider')] + public function testDependencyThatRequiresServerReportsTheHighestReasonOfTheFlag( + array $conditionFilters, + array $cohorts, string $reason ): void { $dependency = self::dependencyFlag("dependency", [["key" => "id", "type" => "cohort", "value" => 999]]); $flag = [ "key" => "flag", "active" => true, - "filters" => ["groups" => [ - [ - "properties" => array_merge([self::dependsOn("dependency")], $sameConditionFilters), - "rollout_percentage" => 100, - ], - ["properties" => $laterConditionFilters, "rollout_percentage" => 100], - ]], + "filters" => ["groups" => array_map( + fn (array $filters) => ["properties" => $filters, "rollout_percentage" => 100], + $conditionFilters + )], ]; try { - FeatureFlag::matchFeatureFlagProperties($flag, "test-user", [], [], ["dependency" => $dependency], []); + FeatureFlag::matchFeatureFlagProperties( + $flag, + "test-user", + ["plan" => "free"], + $cohorts, + ["dependency" => $dependency], + [] + ); $this->fail("Expected RequiresServerEvaluationException"); } catch (RequiresServerEvaluationException $e) { $this->assertSame($reason, $e->getReason()); } } - public static function laterFiltersOfServerDependencyProvider(): iterable + public static function serverDependencyReasonProvider(): iterable { + $dependsOn = self::dependsOn("dependency"); $unknownOperator = ["key" => "plan", "operator" => "not_a_real_operator", "value" => "pro"]; - yield 'later condition matches everyone' => [[], [], UnresolvedFlagReason::UNRESOLVED_DEPENDENCY]; + $onProPlan = ["key" => "plan", "operator" => "exact", "value" => "pro"]; + $inCohort = ["key" => "id", "type" => "cohort", "value" => 1]; + + yield 'later condition matches everyone' => [ + [[$dependsOn], []], + [], + UnresolvedFlagReason::UNRESOLVED_DEPENDENCY, + ]; yield 'later condition is unsupported' => [ + [[$dependsOn], [$unknownOperator]], [], - [$unknownOperator], UnresolvedFlagReason::UNSUPPORTED_DEFINITION, ]; yield 'later filter in the same condition is unsupported' => [ - [$unknownOperator], + [[$dependsOn, $unknownOperator]], [], UnresolvedFlagReason::UNSUPPORTED_DEFINITION, ]; + yield 'earlier condition fails before its unsupported filter' => [ + [[$onProPlan, $unknownOperator], [$dependsOn]], + [], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'cohort checks the dependency before an unsupported filter' => [ + [[$inCohort]], + [1 => ["type" => "AND", "values" => [$dependsOn, $unknownOperator]]], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; + yield 'cohort checks an unsupported filter before the dependency' => [ + [[$inCohort]], + [1 => ["type" => "AND", "values" => [$unknownOperator, $dependsOn]]], + UnresolvedFlagReason::UNSUPPORTED_DEFINITION, + ]; } public function testMissingDependencyAnywhereInTheChainIsAnUnsupportedDefinition(): void From 33c0e455ddd24d227660c05d7fb99772555b3ecd Mon Sep 17 00:00:00 2001 From: "Gustavo H. Strassburger" Date: Wed, 28 Oct 2026 16:58:33 -0300 Subject: [PATCH 6/6] fix(flags): keep loading definitions when the unresolvable-flag check fails --- lib/Client.php | 28 +++++++++++--- test/FeatureFlagEvaluationsTest.php | 58 +++++++++++------------------ 2 files changed, 44 insertions(+), 42 deletions(-) diff --git a/lib/Client.php b/lib/Client.php index 3b1bfd8e..a857ca36 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -1738,16 +1738,23 @@ private function warnAboutUnresolvableFlags(): void // Rebuilt from the current definitions, so a flag that is deleted or becomes resolvable is // forgotten and warns again if it later returns to an unresolvable definition. $warnedFlags = []; + $uncheckedFlags = []; foreach ($this->featureFlags as $flag) { if (!is_array($flag) || !is_string($flag['key'] ?? null)) { continue; } - $reason = FeatureFlag::getDefinitionUnresolvedReason( - $flag, - $this->featureFlagsByKey, - $this->cohorts, - $this->groupTypeMapping - ); + try { + $reason = FeatureFlag::getDefinitionUnresolvedReason( + $flag, + $this->featureFlagsByKey, + $this->cohorts, + $this->groupTypeMapping + ); + } catch (Throwable $throwable) { + // A malformed definition must not stop the load or the warning for the other flags. + $uncheckedFlags[] = $flag['key']; + continue; + } if ($reason === null) { continue; } @@ -1786,6 +1793,15 @@ private function warnAboutUnresolvableFlags(): void implode(', ', $unsupportedDefinitionFlags) )); } + + if (count($uncheckedFlags) > 0) { + $this->logWarning(sprintf( + 'You are using local evaluation but the SDK could not check the definition of %d flag(s) ' + . 'because it is malformed: %s. Evaluating these flags can fall back to a server request.', + count($uncheckedFlags), + implode(', ', $uncheckedFlags) + )); + } } /** diff --git a/test/FeatureFlagEvaluationsTest.php b/test/FeatureFlagEvaluationsTest.php index 30261917..4d89543a 100644 --- a/test/FeatureFlagEvaluationsTest.php +++ b/test/FeatureFlagEvaluationsTest.php @@ -12,7 +12,6 @@ use PostHog\Client; use PostHog\EvaluatedFlagRecord; use PostHog\FeatureFlagEvaluations; -use PostHog\FlagDefinitionCacheProvider; use PostHog\PostHog; use PostHog\Test\Assets\MockedResponses; use PostHog\UnresolvedFlagReason; @@ -1374,46 +1373,33 @@ public function testFlagWithDependencyThatNeedsServerFallsBackToServerDespiteAMa { global $errorMessages; $malformedDependency = array_merge(self::dependsOnFilter('dep'), ['dependency_chain' => [['dep']]]); - $definitions = self::localDefinitions( - self::localFlag('dep', [self::staticCohortFilter()]), - self::flagWithDependencyAndCatchAll([$malformedDependency]), - ); - // Cached definitions skip the load-time warning, so evaluation is the first code to read them. - $cacheProvider = new class ($definitions) implements FlagDefinitionCacheProvider { - public function __construct(private array $definitions) - { - } - - public function getFlagDefinitions(): ?array - { - return $this->definitions; - } - - public function shouldFetchFlagDefinitions(): bool - { - return false; - } - - public function onFlagDefinitionsReceived(array $data): void - { - } - - public function shutdown(): void - { - } - }; - $this->makeClient( - flagsEndpointResponse: ['flags' => ['target' => ['key' => 'target', 'enabled' => true, 'variant' => 'a']]], - personalApiKey: 'test-personal-key', - options: ['flag_definition_cache_provider' => $cacheProvider] + $this->makeLocalClient( + self::localDefinitions( + self::localFlag('dep', [self::staticCohortFilter()]), + self::flagWithDependencyAndCatchAll([$malformedDependency]), + self::localFlag('everyone'), + ), + ['flags' => ['target' => ['key' => 'target', 'enabled' => true, 'variant' => 'a']]] ); + $loadMessages = $errorMessages; + $errorMessages = []; - $snapshot = $this->client->evaluateFlags('user-123', flagKeys: ['target']); + $snapshot = $this->client->evaluateFlags('user-123', flagKeys: ['target', 'everyone']); $this->assertSame( - [1, 'a', []], - [$this->flagsRequestCount(), $snapshot->getFlag('target'), $errorMessages] + [1, 'a', true, []], + [ + $this->flagsRequestCount(), + $snapshot->getFlag('target'), + $snapshot->getFlag('everyone'), + $errorMessages, + ] ); + $this->assertCount(1, array_filter( + $loadMessages, + fn (string $message) => str_contains($message, 'could not check the definition') && + str_contains($message, 'target') + )); } public function testFlagWithDependencyThatNeedsServerIsUnresolvedLocally(): void