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/feature-flag-has-experiment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-ruby": minor
---

feat: add a `$feature_flag_has_experiment` boolean property to `$feature_flag_called` events when the server explicitly reports the `has_experiment` field; the property is omitted when the server does not report it (older deployments)
11 changes: 8 additions & 3 deletions lib/posthog/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,8 @@ def evaluate_flags(
id: definition[:id],
version: nil,
reason: FeatureFlagEvaluations::EVALUATED_LOCALLY_REASON,
locally_evaluated: true
locally_evaluated: true,
has_experiment: definition[:has_experiment]
)
locally_evaluated_keys << key.to_s
end
Expand Down Expand Up @@ -649,7 +650,8 @@ def evaluate_flags(
id: metadata ? metadata.id : nil,
version: metadata ? metadata.version : nil,
reason: reason ? (reason.description || reason.code) : nil,
locally_evaluated: false
locally_evaluated: false,
has_experiment: metadata&.has_experiment
)
end
rescue StandardError => e
Expand Down Expand Up @@ -766,6 +768,7 @@ def get_all_flags_and_payloads(
# Remove internal information
response.delete(:requestId)
response.delete(:evaluatedAt)
response.delete(:flagDetails)
response
end

Expand Down Expand Up @@ -921,7 +924,8 @@ def _get_feature_flag_result(
person_properties, group_properties = add_local_person_and_group_properties(
groups, person_properties, group_properties
)
feature_flag_response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload =
feature_flag_response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload,
has_experiment =
@feature_flags_poller.get_feature_flag(
key, distinct_id, groups, person_properties, group_properties, only_evaluate_locally
)
Expand All @@ -931,6 +935,7 @@ def _get_feature_flag_result(
'$feature_flag_response' => feature_flag_response,
'locally_evaluated' => flag_was_locally_evaluated
}
properties['$feature_flag_has_experiment'] = has_experiment unless has_experiment.nil?
properties['$feature_flag_request_id'] = request_id if request_id
properties['$feature_flag_evaluated_at'] = evaluated_at if evaluated_at
properties['$feature_flag_error'] = feature_flag_error if feature_flag_error
Expand Down
5 changes: 4 additions & 1 deletion lib/posthog/feature_flag.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def initialize(json)
#
# @api private
class FeatureFlagMetadata
attr_reader :id, :version, :payload, :description
attr_reader :id, :version, :payload, :description, :has_experiment

# @param json [Hash] Raw metadata returned by /flags.
def initialize(json)
Expand All @@ -77,6 +77,9 @@ def initialize(json)
@version = json['version']
@payload = json['payload']
@description = json['description']
# Whether the flag is linked to an experiment. nil when the server
# (an older deployment) does not report the field.
@has_experiment = json['has_experiment']
end
end
end
3 changes: 2 additions & 1 deletion lib/posthog/feature_flag_evaluations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class FeatureFlagEvaluations
EVALUATED_LOCALLY_REASON = 'Evaluated locally'

EvaluatedFlagRecord = Struct.new(
:key, :enabled, :variant, :payload, :id, :version, :reason, :locally_evaluated,
:key, :enabled, :variant, :payload, :id, :version, :reason, :locally_evaluated, :has_experiment,
keyword_init: true
)

Expand Down Expand Up @@ -169,6 +169,7 @@ def _record_access(key, flag)
}

if flag
properties['$feature_flag_has_experiment'] = flag.has_experiment unless flag.has_experiment.nil?
properties['$feature_flag_payload'] = flag.payload unless flag.payload.nil?
properties['$feature_flag_id'] = flag.id if flag.id
properties['$feature_flag_version'] = flag.version if flag.version
Expand Down
13 changes: 12 additions & 1 deletion lib/posthog/feature_flags.rb
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,11 @@ def get_feature_flag(
end

flag_was_locally_evaluated = !response.nil?
# Whether the flag is linked to an experiment, as reported by the server.
# Locally-evaluated flags carry it in the stored definition; remotely
# evaluated flags carry it in the response metadata. nil when the server
# (an older deployment) does not report it.
has_experiment = feature_flag[:has_experiment] if flag_was_locally_evaluated

request_id = nil
evaluated_at = nil
Expand Down Expand Up @@ -253,6 +258,9 @@ def get_feature_flag(
payload = payloads[key]
feature_flag_error = errors.join(',') unless errors.empty?

flag_detail = flags_data[:flagDetails]&.[](key.to_sym)
has_experiment = flag_detail&.metadata&.has_experiment

logger.debug "Successfully computed flag remotely: #{key} -> #{response}"
rescue Timeout::Error => e
@on_error.call(-1, "Timeout while fetching flags remotely: #{e}")
Expand All @@ -266,7 +274,7 @@ def get_feature_flag(
end
end

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

def get_all_flags(
Expand Down Expand Up @@ -324,10 +332,12 @@ def get_all_flags_and_payloads(
errors_while_computing = false
quota_limited = nil
status_code = nil
flag_details = nil

if fallback_to_server && !only_evaluate_locally
begin
flags_and_payloads = get_flags(distinct_id, groups, person_properties, group_properties)
flag_details = flags_and_payloads[:flags]
errors_while_computing = flags_and_payloads[:errorsWhileComputingFlags] || false
quota_limited = flags_and_payloads[:quotaLimited]
status_code = flags_and_payloads[:status]
Expand Down Expand Up @@ -368,6 +378,7 @@ def get_all_flags_and_payloads(
{
featureFlags: flags,
featureFlagPayloads: payloads,
flagDetails: flag_details,
requestId: request_id,
evaluatedAt: evaluated_at,
errorsWhileComputingFlags: errors_while_computing,
Expand Down
2 changes: 2 additions & 0 deletions public_api_snapshot.txt
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ constant PostHog::FeatureFlagEvaluations::EVALUATED_LOCALLY_REASON: String
class PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord < Struct
instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#enabled()
instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#enabled=(_)
instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#has_experiment()
instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#has_experiment=(_)
instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#id()
instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#id=(_)
instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#key()
Expand Down
71 changes: 70 additions & 1 deletion spec/posthog/feature_flag_evaluations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,56 @@ def capture_stderr
end
end

describe '$feature_flag_has_experiment' do
let(:has_experiment_response) do
{
flags: {
'experiment-flag' => {
key: 'experiment-flag', enabled: true, variant: nil,
metadata: { id: 10, version: 1, has_experiment: true }
},
'plain-flag' => {
key: 'plain-flag', enabled: true, variant: nil,
metadata: { id: 11, version: 1, has_experiment: false }
},
'legacy-flag' => {
key: 'legacy-flag', enabled: true, variant: nil,
metadata: { id: 12, version: 1 }
}
},
requestId: 'request-id-2'
}
end

def flag_called_properties(client, key)
client.evaluate_flags('user-1').enabled?(key)
event = drain_messages(client).find do |m|
m[:event] == '$feature_flag_called' && m[:properties]['$feature_flag'] == key
end
event[:properties]
end

it 'is true when the server reports has_experiment' do
stub_flags(has_experiment_response)
expect(flag_called_properties(client, 'experiment-flag')['$feature_flag_has_experiment']).to be(true)
end

it 'is false when the server reports has_experiment false' do
stub_flags(has_experiment_response)
expect(flag_called_properties(client, 'plain-flag')['$feature_flag_has_experiment']).to be(false)
end

it 'is omitted when the server does not report has_experiment' do
stub_flags(has_experiment_response)
expect(flag_called_properties(client, 'legacy-flag')).not_to have_key('$feature_flag_has_experiment')
end

it 'is omitted for flags missing from the evaluation' do
stub_flags(has_experiment_response)
expect(flag_called_properties(client, 'not-a-flag')).not_to have_key('$feature_flag_has_experiment')
end
end

describe 'response-level errors' do
it 'combines errorsWhileComputingFlags with flag_missing on $feature_flag_error' do
stub_flags(flags_response.merge(errorsWhileComputingFlags: true))
Expand Down Expand Up @@ -387,7 +437,11 @@ def capture_stderr
{
flags: [
{
id: 99, name: 'Local flag', key: 'local-flag', active: true,
id: 99, name: 'Local flag', key: 'local-flag', active: true, has_experiment: true,
filters: { groups: [{ properties: [], rollout_percentage: 100 }] }
},
{
id: 100, name: 'Plain local flag', key: 'plain-local-flag', active: true,
filters: { groups: [{ properties: [], rollout_percentage: 100 }] }
}
]
Expand All @@ -412,6 +466,21 @@ def capture_stderr
expect(event[:properties]['$feature_flag_definitions_loaded_at']).to be_a(Integer)
end

it 'sources $feature_flag_has_experiment from the local flag definition' do
stub_request(:get, %r{https://us\.i\.posthog\.com/flags/definitions})
.to_return(status: 200, body: local_definitions.to_json)
c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true)
snapshot = c.evaluate_flags('user-1', only_evaluate_locally: true)

snapshot.enabled?('local-flag')
snapshot.enabled?('plain-local-flag')

msgs = drain_messages(c).select { |m| m[:event] == '$feature_flag_called' }
by_key = msgs.to_h { |m| [m[:properties]['$feature_flag'], m[:properties]] }
expect(by_key['local-flag']['$feature_flag_has_experiment']).to be(true)
expect(by_key['plain-local-flag']).not_to have_key('$feature_flag_has_experiment')
end

it 'skips the remote /flags call when flag_keys are all resolved locally' do
stub_request(:get, %r{https://us\.i\.posthog\.com/flags/definitions})
.to_return(status: 200, body: local_definitions.to_json)
Expand Down
81 changes: 81 additions & 0 deletions spec/posthog/feature_flag_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5267,5 +5267,86 @@ module PostHog
expect(captured_message[:properties]['$feature_flag_evaluated_at']).to eq(1_704_067_200_000)
expect(captured_message[:properties]['locally_evaluated']).to be false
end

describe '$feature_flag_has_experiment' do
def local_definition(has_experiment: nil)
flag = {
'id' => 1,
'name' => 'Beta Feature',
'key' => 'test-flag',
'active' => true,
'filters' => { 'groups' => [{ 'rollout_percentage' => 100 }] }
}
flag['has_experiment'] = has_experiment unless has_experiment.nil?
{ 'flags' => [flag] }
end

def stub_definitions(body)
stub_request(
:get,
'https://us.i.posthog.com/flags/definitions?token=testsecret&send_cohorts=true'
).to_return(status: 200, body: body.to_json)
end

def flag_called_properties(client, key)
client.get_feature_flag_result(key, 'some-distinct-id')
captured_message = client.dequeue_last_message
expect(captured_message[:event]).to eq('$feature_flag_called')
captured_message[:properties]
end

it 'is true when the local definition reports has_experiment' do
stub_definitions(local_definition(has_experiment: true))
stub_request(:post, flags_endpoint).to_return(status: 400)
c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true)

expect(flag_called_properties(c, 'test-flag')['$feature_flag_has_experiment']).to be true
end

it 'is false when the local definition reports has_experiment false' do
stub_definitions(local_definition(has_experiment: false))
stub_request(:post, flags_endpoint).to_return(status: 400)
c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true)

expect(flag_called_properties(c, 'test-flag')['$feature_flag_has_experiment']).to be false
end

it 'is omitted when the local definition omits has_experiment' do
stub_definitions(local_definition)
stub_request(:post, flags_endpoint).to_return(status: 400)
c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true)

expect(flag_called_properties(c, 'test-flag')).not_to have_key('$feature_flag_has_experiment')
end

it 'sources has_experiment from the /flags response metadata on remote evaluation' do
stub_definitions('flags' => [])
stub_request(:post, flags_endpoint)
.to_return(status: 200, body: {
'flags' => {
'remote-flag' => {
'key' => 'remote-flag', 'enabled' => true, 'variant' => nil,
'metadata' => { 'id' => 7, 'version' => 3, 'has_experiment' => true }
}
},
'requestId' => 'test-request-id'
}.to_json)
c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true)

expect(flag_called_properties(c, 'remote-flag')['$feature_flag_has_experiment']).to be true
end

it 'is omitted when the /flags response omits has_experiment' do
stub_definitions('flags' => [])
stub_request(:post, flags_endpoint)
.to_return(status: 200, body: {
'featureFlags' => { 'remote-flag' => true },
'requestId' => 'test-request-id'
}.to_json)
c = Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true)

expect(flag_called_properties(c, 'remote-flag')).not_to have_key('$feature_flag_has_experiment')
end
end
end
end
18 changes: 17 additions & 1 deletion spec/posthog/flags_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -462,10 +462,26 @@ module PostHog
id: 1,
version: 23,
payload: '{"foo": 1}',
description: 'This is an enabled flag'
description: 'This is an enabled flag',
has_experiment: nil
)
)
end

it 'parses has_experiment from metadata' do
result = FeatureFlag.new({
'key' => 'experiment-flag',
'enabled' => true,
'variant' => nil,
'metadata' => {
'id' => 1,
'version' => 23,
'has_experiment' => true
}
})

expect(result.metadata.has_experiment).to be(true)
end
end

describe 'Client#get_feature_flag' do
Expand Down
Loading