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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/unresolved-local-flags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"posthog-ruby": minor
---

Report flags that local evaluation could not resolve on `evaluate_flags` snapshots. `FeatureFlagEvaluations#unresolved_flags` maps each such flag key to a `PostHog::UnresolvedFlagReason` value (`experience_continuity`, `unsupported_definition`, `unresolved_dependency` or `missing_context`). `InconclusiveMatchError` and `RequiresServerEvaluation` have a new `reason` with the same values. When flag definitions load, log one warning for each flag that local evaluation can never resolve (experience continuity, a static cohort, an unknown operator, a malformed value, a regex that only the server can evaluate, or a missing or circular flag dependency), and log it again only when the definition of that flag changes.

Behavior changes (these apply to every flag method, including `get_feature_flag`, `is_feature_enabled` and `get_all_flags`, not only to `evaluate_flags`):

- A condition or a cohort property group no longer stops at the first filter that local evaluation can't check, unless that filter is a static cohort inside a cohort. If another filter does not match, the flag can now resolve locally. Before, such a flag fell back to a remote request, or stayed unresolved with `only_evaluate_locally: true`.
- An inactive flag with experience continuity enabled now resolves to `false` locally instead of falling back to a remote request.
- A flag that depends on a flag with experience continuity enabled is no longer evaluated locally. It falls back to a remote request, or stays unresolved with `only_evaluate_locally: true`.
- A flag that depends on a flag that needs the server, for example one with a static cohort, no longer forces a remote request at that condition. A later condition can now resolve the flag locally.
- `$feature_flag_called` events from snapshot reads report `local_evaluation_inconclusive` instead of `flag_missing` for flags in `unresolved_flags`.
- When local evaluation left a flag unresolved and the remote fallback marks it as failed, the snapshot no longer holds it as `false`. The flag is absent from the snapshot and listed in `unresolved_flags`, so `enabled?` returns the `default_value:` you pass (this reverses the 3.27.0 rule for these flags) and `get_flag` returns `nil`. A failed flag that has no local definition keeps its `false` value and still ignores `default_value:`.
- The load-time warning is logged at WARN level, so it shows without debug logging.
1 change: 0 additions & 1 deletion .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ Naming/PredicateMethod:
- condition_match
- enqueue
- flush
- match_regular_property_group

Style/OneClassPerFile:
Exclude:
Expand Down
1 change: 1 addition & 0 deletions lib/posthog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
require 'posthog/logging'
require 'posthog/exception_capture'
require 'posthog/feature_flag_error'
require 'posthog/unresolved_flag_reason'
require 'posthog/feature_flag_result'
require 'posthog/feature_flag_evaluations'
require 'posthog/flag_definition_cache'
14 changes: 11 additions & 3 deletions lib/posthog/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,8 @@ def get_feature_flag_result(
# @param [Hash] groups
# @param [Hash] person_properties key-value pairs of properties to associate with the user
# @param [Hash] group_properties
# @param [Boolean] only_evaluate_locally Skip the remote /flags call entirely
# @param [Boolean] only_evaluate_locally Skip the remote /flags call entirely. Flags that local evaluation
# cannot resolve are left out of the snapshot and listed in {FeatureFlagEvaluations#unresolved_flags}.
# @param [Boolean, nil] disable_geoip When true, disables GeoIP lookup for remote evaluation and stamps captured
# access events.
# @param [Array<String, Symbol>, nil] flag_keys When set, scopes evaluation to only these flag keys.
Expand Down Expand Up @@ -676,6 +677,7 @@ def evaluate_flags(

records = {}
locally_evaluated_keys = Set.new
unresolved_flags = {}
flag_keys_set = flag_keys&.to_set(&:to_s)

@feature_flags_poller.load_feature_flags
Expand All @@ -690,7 +692,10 @@ def evaluate_flags(
:_compute_flag_locally,
definition, distinct_id, groups, person_properties, group_properties, snapshot: definition_snapshot
)
rescue PostHog::RequiresServerEvaluation, PostHog::InconclusiveMatchError, StandardError
rescue PostHog::RequiresServerEvaluation, PostHog::InconclusiveMatchError => e
unresolved_flags[key.to_s] = e.reason
next
rescue StandardError
next
end

Expand Down Expand Up @@ -747,6 +752,8 @@ def evaluate_flags(
remote_flags.each do |key, ff|
key_str = key.to_s
next if locally_evaluated_keys.include?(key_str)
# A failed server evaluation has no value, so a flag local evaluation left unresolved stays so.
next if ff.failed == true && unresolved_flags.key?(key_str)

metadata = ff.metadata
reason = ff.reason
Expand Down Expand Up @@ -778,7 +785,8 @@ def evaluate_flags(
flag_definitions_loaded_at: @feature_flags_poller.flag_definitions_loaded_at,
errors_while_computing: errors_while_computing,
quota_limited: quota_limited,
minimal_flag_called_events: minimal_flag_called_events
minimal_flag_called_events: minimal_flag_called_events,
unresolved_flags: unresolved_flags.reject { |key, _reason| records.key?(key) }
)
end

Expand Down
2 changes: 2 additions & 0 deletions lib/posthog/feature_flag_error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ module PostHog
# Error values:
# ERRORS_WHILE_COMPUTING: Server returned errorsWhileComputingFlags=true
# FLAG_MISSING: Requested flag not in API response
# LOCAL_EVALUATION_INCONCLUSIVE: Flag has a loaded local definition but local evaluation could not resolve it
# QUOTA_LIMITED: Rate/quota limit exceeded
# TIMEOUT: Request timed out
# CONNECTION_ERROR: Network connectivity issue
Expand All @@ -21,6 +22,7 @@ module PostHog
class FeatureFlagError
ERRORS_WHILE_COMPUTING = 'errors_while_computing_flags'
FLAG_MISSING = 'flag_missing'
LOCAL_EVALUATION_INCONCLUSIVE = 'local_evaluation_inconclusive'
QUOTA_LIMITED = 'quota_limited'
TIMEOUT = 'timeout'
CONNECTION_ERROR = 'connection_error'
Expand Down
26 changes: 22 additions & 4 deletions lib/posthog/feature_flag_evaluations.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require 'set'
require 'posthog/feature_flag_error'

module PostHog
# A snapshot of feature flag evaluations for one distinct_id, returned by
Expand Down Expand Up @@ -34,6 +35,15 @@ class FeatureFlagEvaluations
# @return [Time, nil] When local flag definitions were loaded.
attr_reader :flag_definitions_loaded_at

# 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. These flags are not
# in {#keys}, and their accessors follow the missing-flag contract. Reading this does not count
# as an access and does not send any event. Always empty on snapshots returned by {#only} and
# {#only_accessed}.
#
# @return [Hash{String => String}] Frozen hash of flag key to an {UnresolvedFlagReason} value.
attr_reader :unresolved_flags

# @param host [Host, nil] Internal host callbacks used to record flag access events.
# @param distinct_id [String, nil] The distinct id these evaluations belong to.
# @param flags [Hash] Evaluated flags keyed by flag key.
Expand All @@ -47,6 +57,8 @@ class FeatureFlagEvaluations
# @param minimal_flag_called_events [Boolean] Server-controlled gate for minimal
# `$feature_flag_called` events.
# @param accessed [Array<String>, Set<String>, nil] Flag keys already accessed by this snapshot.
# @param unresolved_flags [Hash{String => String}, nil] Unresolved flag keys mapped to an
# {UnresolvedFlagReason} value.
def initialize(
host: nil,
distinct_id: nil,
Expand All @@ -59,7 +71,8 @@ def initialize(
errors_while_computing: false,
quota_limited: false,
minimal_flag_called_events: false,
accessed: nil
accessed: nil,
unresolved_flags: nil
)
@host = host
@distinct_id = distinct_id || ''
Expand All @@ -73,6 +86,7 @@ def initialize(
@quota_limited = quota_limited
@minimal_flag_called_events = minimal_flag_called_events == true
@accessed = Set.new(accessed || [])
@unresolved_flags = (unresolved_flags || {}).dup.freeze
end

# @return [Array<String>] The evaluated flag keys in this snapshot.
Expand All @@ -85,8 +99,8 @@ def keys
# snapshot — it was never loaded, the `/flags` request failed, or no flag with that
# key exists. A flag that does have a value, including `false` and variant strings,
# always wins over this default; so does a flag the server returned but marked as
# failed, which resolves to `false`. Defaults to `nil`, which keeps the historical
# `false` result for a missing flag.
# failed, which resolves to `false` unless it is in {#unresolved_flags}. Defaults to
# `nil`, which keeps the historical `false` result for a missing flag.
# @return [Boolean] true when the flag is enabled, false when disabled, and the
# caller-supplied default (or false when none was supplied) when the flag is missing.
def enabled?(key, default_value: nil)
Expand Down Expand Up @@ -198,7 +212,11 @@ def _record_access(key, flag)
errors = []
errors << 'errors_while_computing_flags' if @errors_while_computing
errors << 'quota_limited' if @quota_limited
errors << 'flag_missing' if flag.nil?
if flag.nil? && @unresolved_flags.key?(key)
errors << FeatureFlagError::LOCAL_EVALUATION_INCONCLUSIVE
elsif flag.nil?
errors << FeatureFlagError::FLAG_MISSING
end
properties['$feature_flag_error'] = errors.join(',') unless errors.empty?

# Emit a minimal event only when the server gate is on and the flag is
Expand Down
Loading
Loading