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
1 change: 1 addition & 0 deletions lib/posthog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@
require 'posthog/logging'
require 'posthog/exception_capture'
require 'posthog/feature_flag_error'
require 'posthog/feature_flag_result'
54 changes: 45 additions & 9 deletions lib/posthog/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -278,14 +278,48 @@ def get_feature_flag(
group_properties: {},
only_evaluate_locally: false,
send_feature_flag_events: true
)
result = get_feature_flag_result(
key,
distinct_id,
groups: groups,
person_properties: person_properties,
group_properties: group_properties,
only_evaluate_locally: only_evaluate_locally,
send_feature_flag_events: send_feature_flag_events
)
result&.value
end

# Returns both the feature flag value and payload in a single call.
# This method raises the $feature_flag_called event with the payload included.
#
# @param [String] key The key of the feature flag
# @param [String] distinct_id The distinct id of the user
# @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
# @param [Boolean] send_feature_flag_events
#
# @return [FeatureFlagResult, nil] A FeatureFlagResult object containing the flag value and payload,
# or nil if the flag evaluation returned nil
def get_feature_flag_result(
key,
distinct_id,
groups: {},
person_properties: {},
group_properties: {},
only_evaluate_locally: false,
send_feature_flag_events: true
)
person_properties, group_properties = add_local_person_and_group_properties(
distinct_id,
groups,
person_properties,
group_properties
)
feature_flag_response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error =
feature_flag_response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload =
@feature_flags_poller.get_feature_flag(
key,
distinct_id,
Expand All @@ -294,8 +328,8 @@ def get_feature_flag(
group_properties,
only_evaluate_locally
)

feature_flag_reported_key = "#{key}_#{feature_flag_response}"

if !@distinct_id_has_sent_flag_calls[distinct_id].include?(feature_flag_reported_key) && send_feature_flag_events
properties = {
'$feature_flag' => key,
Expand All @@ -307,16 +341,15 @@ def get_feature_flag(
properties['$feature_flag_error'] = feature_flag_error if feature_flag_error

capture(
{
distinct_id: distinct_id,
event: '$feature_flag_called',
properties: properties,
groups: groups
}
distinct_id: distinct_id,
event: '$feature_flag_called',
properties: properties,
groups: groups
)
@distinct_id_has_sent_flag_calls[distinct_id] << feature_flag_reported_key
end
feature_flag_response

FeatureFlagResult.from_value_and_payload(key, feature_flag_response, payload)
end

# Returns all flags for a given user
Expand All @@ -342,6 +375,9 @@ def get_all_flags(

# Returns payload for a given feature flag
#
# @deprecated Use {#get_feature_flag_result} instead, which returns both the flag value and payload
# and properly raises the $feature_flag_called event.
#
# @param [String] key The key of the feature flag
# @param [String] distinct_id The distinct id of the user
# @option [String or boolean] match_value The value of the feature flag to be matched
Expand Down
56 changes: 56 additions & 0 deletions lib/posthog/feature_flag_result.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# frozen_string_literal: true

require 'json'

module PostHog
# Represents the result of a feature flag evaluation
# containing both the flag value and payload
class FeatureFlagResult
attr_reader :key, :variant, :payload

def initialize(key:, enabled:, variant: nil, payload: nil)
@key = key
@enabled = enabled
@variant = variant
@payload = payload
end

# Returns the effective value of the feature flag
# variant if present, otherwise enabled status
def value
@variant || @enabled
end

# Returns whether or not the feature flag evaluated as enabled
def enabled?
@enabled
end

# Factory method to create from flag value and payload
def self.from_value_and_payload(key, value, payload)
return nil if value.nil?

parsed_payload = parse_payload(payload)

if value.is_a?(String)
new(key: key, enabled: true, variant: value, payload: parsed_payload)
else
new(key: key, enabled: value, payload: parsed_payload)
end
end

def self.parse_payload(payload)
return nil if payload.nil?
return payload unless payload.is_a?(String)
return nil if payload.empty?

begin
JSON.parse(payload)
rescue JSON::ParserError
payload
end
end

private_class_method :parse_payload
end
end
16 changes: 7 additions & 9 deletions lib/posthog/feature_flags.rb
Original file line number Diff line number Diff line change
Expand Up @@ -169,18 +169,13 @@ def get_feature_flag(
end

response = nil
feature_flag = nil

@feature_flags.each do |flag|
if key == flag[:key]
feature_flag = flag
break
end
end
payload = nil
feature_flag = @feature_flags_by_key&.[](key)

unless feature_flag.nil?
begin
response = _compute_flag_locally(feature_flag, distinct_id, groups, person_properties, group_properties)
payload = _compute_flag_payload_locally(key, response) unless response.nil?
logger.debug "Successfully computed flag locally: #{key} -> #{response}"
rescue RequiresServerEvaluation, InconclusiveMatchError => e
logger.debug "Failed to compute flag #{key} locally: #{e}"
Expand All @@ -202,11 +197,13 @@ def get_feature_flag(
only_evaluate_locally, true)
if flags_data.key?(:featureFlags)
flags = stringify_keys(flags_data[:featureFlags] || {})
payloads = stringify_keys(flags_data[:featureFlagPayloads] || {})
request_id = flags_data[:requestId]
evaluated_at = flags_data[:evaluatedAt]
else
logger.debug "Missing feature flags key: #{flags_data.to_json}"
flags = {}
payloads = {}
end

status = flags_data[:status]
Expand All @@ -217,6 +214,7 @@ def get_feature_flag(

response = flags[key]
response = false if response.nil?
payload = payloads[key]
feature_flag_error = errors.join(',') unless errors.empty?

logger.debug "Successfully computed flag remotely: #{key} -> #{response}"
Expand All @@ -232,7 +230,7 @@ def get_feature_flag(
end
end

[response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error]
[response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload]
end

def get_all_flags(
Expand Down
159 changes: 159 additions & 0 deletions spec/posthog/feature_flag_result_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# frozen_string_literal: true

require 'spec_helper'
require 'posthog/feature_flag_result'

module PostHog
describe FeatureFlagResult do
describe '#initialize' do
it 'initializes with all attributes' do
result = FeatureFlagResult.new(
key: 'test-flag',
enabled: true,
variant: 'control',
payload: { 'foo' => 'bar' }
)

expect(result.key).to eq('test-flag')
expect(result.enabled?).to be true
expect(result.variant).to eq('control')
expect(result.payload).to eq({ 'foo' => 'bar' })
end

it 'initializes with minimal attributes' do
result = FeatureFlagResult.new(key: 'test-flag', enabled: false)

expect(result.key).to eq('test-flag')
expect(result.enabled?).to be false
expect(result.variant).to be_nil
expect(result.payload).to be_nil
end
end

describe '#value' do
it 'returns variant when present' do
result = FeatureFlagResult.new(key: 'test-flag', enabled: true, variant: 'control')

expect(result.value).to eq('control')
end

it 'returns enabled when variant is not present' do
result = FeatureFlagResult.new(key: 'test-flag', enabled: true)

expect(result.value).to be true
end

it 'returns false when flag is disabled and no variant' do
result = FeatureFlagResult.new(key: 'test-flag', enabled: false)

expect(result.value).to be false
end
end

describe '.from_value_and_payload' do
context 'with nil value' do
it 'returns nil' do
result = FeatureFlagResult.from_value_and_payload('test-flag', nil, nil)

expect(result).to be_nil
end
end

context 'with boolean true value' do
it 'creates result with enabled true and no variant' do
result = FeatureFlagResult.from_value_and_payload('test-flag', true, nil)

expect(result.key).to eq('test-flag')
expect(result.enabled?).to be true
expect(result.variant).to be_nil
expect(result.payload).to be_nil
end
end

context 'with boolean false value' do
it 'creates result with enabled false and no variant' do
result = FeatureFlagResult.from_value_and_payload('test-flag', false, nil)

expect(result.key).to eq('test-flag')
expect(result.enabled?).to be false
expect(result.variant).to be_nil
expect(result.payload).to be_nil
end
end

context 'with string variant' do
it 'creates result with enabled true and variant set' do
result = FeatureFlagResult.from_value_and_payload('test-flag', 'control', nil)

expect(result.key).to eq('test-flag')
expect(result.enabled?).to be true
expect(result.variant).to eq('control')
expect(result.payload).to be_nil
end
end

context 'with JSON string payload' do
it 'parses the JSON payload' do
result = FeatureFlagResult.from_value_and_payload('test-flag', true, '{"foo": "bar"}')

expect(result.payload).to eq({ 'foo' => 'bar' })
end

it 'parses complex nested JSON payload' do
json_payload = '{"settings": {"theme": "dark", "notifications": true}, "features": ["a", "b"]}'
result = FeatureFlagResult.from_value_and_payload('test-flag', true, json_payload)

expect(result.payload).to eq({
'settings' => { 'theme' => 'dark', 'notifications' => true },
'features' => %w[a b]
})
end
end

context 'with non-JSON string payload' do
it 'returns the string as-is' do
result = FeatureFlagResult.from_value_and_payload('test-flag', true, 'just a plain string')

expect(result.payload).to eq('just a plain string')
end
end

context 'with empty string payload' do
it 'returns nil' do
result = FeatureFlagResult.from_value_and_payload('test-flag', true, '')

expect(result.payload).to be_nil
end
end

context 'with hash payload' do
it 'passes through hash payloads unchanged' do
hash_payload = { 'foo' => 'bar' }
result = FeatureFlagResult.from_value_and_payload('test-flag', true, hash_payload)

expect(result.payload).to eq({ 'foo' => 'bar' })
end
end

context 'with nil payload' do
it 'sets payload to nil' do
result = FeatureFlagResult.from_value_and_payload('test-flag', true, nil)

expect(result.payload).to be_nil
end
end

context 'with variant and payload' do
it 'creates result with variant and parsed payload' do
result = FeatureFlagResult.from_value_and_payload('test-flag', 'control', '{"discount": 10}')

expect(result.key).to eq('test-flag')
expect(result.enabled?).to be true
expect(result.variant).to eq('control')
expect(result.payload).to eq({ 'discount' => 10 })
expect(result.value).to eq('control')
end
end
end
end
end
Loading