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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/flag-evaluations-enabled-default-value.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-ruby': minor
---

Add an optional `default_value:` keyword to `PostHog::FeatureFlagEvaluations#enabled?`. It is returned when the flag has no value in the snapshot β€” it was never loaded, the `/flags` request failed, or no flag with that key exists β€” while a flag that does have a value, including `false` and variant strings, still wins over the default. A flag the server returned but marked as failed resolves to `false`, not the default. Calls that omit the keyword keep returning `false` for a missing flag.
13 changes: 11 additions & 2 deletions lib/posthog/feature_flag_evaluations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,20 @@ def keys
end

# @param key [String, Symbol] The feature flag key.
# @return [Boolean] true when the flag is enabled, false when disabled or missing.
def enabled?(key)
# @param default_value [Boolean, nil] Returned when the flag has no value in this
# 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.
# @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)
key = key.to_s
flag = @flags[key]
_record_access(key, flag)
return !!default_value if flag.nil? && !default_value.nil?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When /flags marks a flag failed: true (with errorsWhileComputingFlags), evaluate_flags still stores it as enabled: false. That means flag isn't nil here, so the default never applies. I confirmed this with a failing spec: { 'f' => { enabled: false, failed: true } } makes enabled?('f', default_value: true) return false. The doc comment and changeset both say the default covers "the evaluation failed", and that's the main fail-open case callers want this for. I think we need to treat failed records as having no value (skip them in evaluate_flags, or carry failed on the record and check it here). Otherwise we should drop that clause from the docs and changeset.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– An AI agent wrote this on behalf of @ioannisj.

Fixed the docs rather than the behavior in 25100ed. posthog-node's evaluateFlags stores failed flags the same way, and isEnabled only falls back to defaultValue when the key is absent, so skipping them here would put Ruby out of step with node.

The doc comment and changeset now say the default covers a failed /flags request (nothing in the snapshot at all), and that a flag the server marked failed resolves to false. Added specs for both, including your repro.

If we want fail-open on per-flag failures, I think that's a cross-SDK change and better raised in sdk-specs first.


flag&.enabled ? true : false
end

Expand Down
2 changes: 1 addition & 1 deletion public_api_snapshot.txt
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ constant PostHog::Defaults::Request::RETRIES: Integer
constant PostHog::Defaults::Request::SSL: Boolean
class PostHog::FeatureFlagEvaluations
instance_method PostHog::FeatureFlagEvaluations#distinct_id()
instance_method PostHog::FeatureFlagEvaluations#enabled?(key)
instance_method PostHog::FeatureFlagEvaluations#enabled?(key, default_value: ...)
instance_method PostHog::FeatureFlagEvaluations#evaluated_at()
instance_method PostHog::FeatureFlagEvaluations#flag_definitions_loaded_at()
instance_method PostHog::FeatureFlagEvaluations#get_flag(key)
Expand Down
50 changes: 50 additions & 0 deletions spec/posthog/feature_flag_evaluations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,56 @@ def capture_stderr
expect(snapshot.enabled?('not-a-flag')).to be(false)
end

it 'enabled? resolves a missing flag to the caller-supplied default' do
stub_flags(flags_response)
snapshot = client.evaluate_flags('user-1')
expect(snapshot.enabled?('not-a-flag', default_value: true)).to be(true)
expect(snapshot.enabled?('not-a-flag', default_value: false)).to be(false)
end

it 'enabled? prefers an existing flag value over the caller-supplied default' do
stub_flags(flags_response)
snapshot = client.evaluate_flags('user-1')
expect(snapshot.enabled?('disabled-flag', default_value: true)).to be(false)
expect(snapshot.enabled?('variant-flag', default_value: false)).to be(true)
expect(snapshot.enabled?('boolean-flag', default_value: false)).to be(true)
end

it 'enabled? uses the caller default on an empty snapshot' do
snapshot = client.evaluate_flags('')
expect(snapshot.enabled?('anything', default_value: true)).to be(true)
expect(snapshot.enabled?('anything')).to be(false)
end

it 'enabled? uses the caller default when the /flags request fails' do
stub_request(:post, FLAGS_ENDPOINT).to_return(status: 500, body: 'error')
snapshot = client.evaluate_flags('user-1')
expect(snapshot.enabled?('boolean-flag', default_value: true)).to be(true)
expect(snapshot.enabled?('boolean-flag')).to be(false)
end

it 'enabled? keeps a flag marked failed by /flags as false, ignoring the caller default' do
stub_flags(
flags: { 'failed-flag' => { key: 'failed-flag', enabled: false, variant: nil, failed: true } },
errorsWhileComputingFlags: true
)
snapshot = client.evaluate_flags('user-1')
expect(snapshot.enabled?('failed-flag', default_value: true)).to be(false)
end

it 'enabled? still reports the evaluated response, not the default, on $feature_flag_called' do
stub_flags(flags_response)
snapshot = client.evaluate_flags('user-1')
snapshot.enabled?('not-a-flag', default_value: true)

msgs = drain_messages(client).select do |m|
m[:event] == '$feature_flag_called' && m[:properties]['$feature_flag'] == 'not-a-flag'
end
expect(msgs.length).to eq(1)
expect(msgs.first[:properties]['$feature_flag_response']).to be_nil
expect(msgs.first[:properties]['$feature_flag_error']).to eq('flag_missing')
end

it 'enabled? and get_flag on a variant flag dedupe to a single event with the variant response' do
stub_flags(flags_response)
snapshot = client.evaluate_flags('user-1')
Expand Down
Loading