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
14 changes: 14 additions & 0 deletions .changeset/evaluate-flags-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"posthog-ruby": minor
---

Add `evaluate_flags(distinct_id, …)` returning a `FeatureFlagEvaluations` snapshot, and a `flags:` option on `capture` so a single `/flags` call can power both flag branching and event enrichment per request.

```ruby
snapshot = posthog.evaluate_flags("user-1", flag_keys: ["checkout-redesign"])
posthog.capture(distinct_id: "user-1", event: "checkout_started", flags: snapshot) if snapshot.enabled?("checkout-redesign")
```

The snapshot exposes `enabled?`, `get_flag`, `get_flag_payload`, plus `only_accessed` / `only([keys])` filter helpers. `flag_keys:` scopes the underlying `/flags` request itself. `enabled?` and `get_flag` fire `$feature_flag_called` events with full metadata (`$feature_flag_id`, `$feature_flag_version`, `$feature_flag_reason`, `$feature_flag_request_id`), deduped through the existing per-distinct_id cache. `get_flag_payload` does not record access or fire an event.

Deprecates `is_feature_enabled`, `get_feature_flag`, `get_feature_flag_result`, `get_feature_flag_payload`, and `capture(send_feature_flags:)`. They continue to work unchanged but now emit a one-time deprecation warning per method pointing at `evaluate_flags()`. Removal is planned for the next major version.
1 change: 1 addition & 0 deletions lib/posthog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@
require 'posthog/exception_capture'
require 'posthog/feature_flag_error'
require 'posthog/feature_flag_result'
require 'posthog/feature_flag_evaluations'
require 'posthog/flag_definition_cache'
350 changes: 291 additions & 59 deletions lib/posthog/client.rb

Large diffs are not rendered by default.

177 changes: 177 additions & 0 deletions lib/posthog/feature_flag_evaluations.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# frozen_string_literal: true

require 'set'

module PostHog
# A snapshot of feature flag evaluations for one distinct_id, returned by
# PostHog::Client#evaluate_flags. Calls to {#is_enabled} / {#get_flag} fire the
# `$feature_flag_called` event (deduped through the existing per-distinct_id
# cache); {#get_flag_payload} does not. Pass the snapshot to `capture(flags:)`
# to attach `$feature/<key>` and `$active_feature_flags` without a second
# /flags request.
class FeatureFlagEvaluations
EVALUATED_LOCALLY_REASON = 'Evaluated locally'

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

Host = Struct.new(:capture_flag_called_event_if_needed, :log_warning, keyword_init: true)

attr_reader :distinct_id, :groups, :request_id, :evaluated_at, :flag_definitions_loaded_at

def initialize(
host: nil,
distinct_id: nil,
flags: {},
groups: nil,
disable_geoip: nil,
request_id: nil,
evaluated_at: nil,
flag_definitions_loaded_at: nil,
errors_while_computing: false,
quota_limited: false,
accessed: nil
)
@host = host
@distinct_id = distinct_id || ''
@flags = flags || {}
@groups = groups
@disable_geoip = disable_geoip
@request_id = request_id
@evaluated_at = evaluated_at
@flag_definitions_loaded_at = flag_definitions_loaded_at
@errors_while_computing = errors_while_computing
@quota_limited = quota_limited
@accessed = Set.new(accessed || [])
end

def keys
@flags.keys
end

def enabled?(key)
key = key.to_s
flag = @flags[key]
_record_access(key, flag)
flag&.enabled ? true : false
end

def get_flag(key)
key = key.to_s
flag = @flags[key]
_record_access(key, flag)
_flag_value(flag)
end

def get_flag_payload(key)
flag = @flags[key.to_s]
flag&.payload
end

# Order-dependent: if nothing has been accessed yet, the returned snapshot is
# empty. The method honors its name — pre-access flags before calling this if
# you want a populated result.
def only_accessed
_clone_with(@flags.slice(*@accessed))
end

def only(keys)
keys = Array(keys).map(&:to_s)
missing = keys.reject { |k| @flags.key?(k) }
unless missing.empty?
@host.log_warning.call(
'FeatureFlagEvaluations#only was called with flag keys that are not in the ' \
"evaluation set and will be dropped: #{missing.join(', ')}"
)
end
filtered = @flags.slice(*keys)
_clone_with(filtered)
end

# Builds the `$feature/<key>` and `$active_feature_flags` properties for a
# captured event. Called from PostHog::Client#capture when `flags:` is set.
def _get_event_properties
properties = {}
active = []
@flags.each do |key, flag|
properties["$feature/#{key}"] = flag.enabled ? (flag.variant || true) : false
active << key if flag.enabled
end
properties['$active_feature_flags'] = active.sort unless active.empty?
properties
end

private

# Canonical "stored" value for a flag — used for both the
# `$feature_flag_response` event property and the dedup cache key, so
# `enabled?` and `get_flag` collapse to a single exposure per flag.
# Variant string when present, else boolean enabled status; `nil` for
# unknown flags.
def _flag_value(flag)
return nil if flag.nil?
return flag.variant if flag.variant

flag.enabled ? true : false
end

def _record_access(key, flag)
@accessed.add(key)
return if @distinct_id.nil? || @distinct_id.to_s.empty?

response = _flag_value(flag)
properties = {
'$feature_flag' => key,
'$feature_flag_response' => response,
'locally_evaluated' => flag&.locally_evaluated ? true : false,
"$feature/#{key}" => response
}

if flag
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
properties['$feature_flag_reason'] = flag.reason if flag.reason
if flag.locally_evaluated && @flag_definitions_loaded_at
properties['$feature_flag_definitions_loaded_at'] = @flag_definitions_loaded_at
end
end

properties['$feature_flag_request_id'] = @request_id if @request_id
properties['$feature_flag_evaluated_at'] = @evaluated_at if @evaluated_at && !(flag && flag.locally_evaluated)

errors = []
errors << 'errors_while_computing_flags' if @errors_while_computing
errors << 'quota_limited' if @quota_limited
errors << 'flag_missing' if flag.nil?
properties['$feature_flag_error'] = errors.join(',') unless errors.empty?

@host.capture_flag_called_event_if_needed.call(
distinct_id: @distinct_id,
key: key,
response: response,
properties: properties,
groups: @groups,
disable_geoip: @disable_geoip
)
end

def _clone_with(flags)
self.class.new(
host: @host,
distinct_id: @distinct_id,
flags: flags,
groups: @groups,
disable_geoip: @disable_geoip,
request_id: @request_id,
evaluated_at: @evaluated_at,
flag_definitions_loaded_at: @flag_definitions_loaded_at,
errors_while_computing: @errors_while_computing,
quota_limited: @quota_limited,
accessed: @accessed.dup
)
end
end
end
6 changes: 4 additions & 2 deletions lib/posthog/feature_flag_result.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ def self.from_value_and_payload(key, value, payload)
end
end

# Deserialize a flag payload. Strings are JSON-parsed (with the raw string
# returned when the body is not valid JSON); already-deserialized values
# pass through. Public so {FeatureFlagEvaluations} can normalize payloads
# the same way {FeatureFlagResult} does.
def self.parse_payload(payload)
return nil if payload.nil?
return payload unless payload.is_a?(String)
Expand All @@ -50,7 +54,5 @@ def self.parse_payload(payload)
payload
end
end

private_class_method :parse_payload
end
end
9 changes: 8 additions & 1 deletion lib/posthog/feature_flags.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def initialize(
@on_error = on_error || proc { |status, error| }
@quota_limited = Concurrent::AtomicBoolean.new(false)
@flags_etag = Concurrent::AtomicReference.new(nil)
@flag_definitions_loaded_at = nil

@flag_definition_cache_provider = flag_definition_cache_provider
FlagDefinitionCacheProvider.validate!(@flag_definition_cache_provider) if @flag_definition_cache_provider
Expand All @@ -72,6 +73,8 @@ def load_feature_flags(force_reload = false)
_load_feature_flags
end

attr_reader :flag_definitions_loaded_at, :feature_flags_by_key

def get_feature_variants(
distinct_id,
groups = {},
Expand Down Expand Up @@ -120,13 +123,16 @@ def get_feature_payloads(
end
end

def get_flags(distinct_id, groups = {}, person_properties = {}, group_properties = {})
def get_flags(distinct_id, groups = {}, person_properties = {}, group_properties = {}, flag_keys = nil,
disable_geoip = nil)
request_data = {
distinct_id: distinct_id,
groups: groups,
person_properties: person_properties,
group_properties: group_properties
}
request_data[:flag_keys_to_evaluate] = flag_keys if flag_keys && !flag_keys.empty?
request_data[:geoip_disable] = true if disable_geoip

flags_response = _request_feature_flag_evaluation(request_data)

Expand Down Expand Up @@ -1124,6 +1130,7 @@ def _apply_flag_definitions(data)
@cohorts = Concurrent::Hash[deep_symbolize_keys(cohorts)]

logger.debug "Loaded #{@feature_flags.length} feature flags and #{@cohorts.length} cohorts"
@flag_definitions_loaded_at = (Time.now.to_f * 1000).to_i
@loaded_flags_successfully_once.make_true if @loaded_flags_successfully_once.false?
end

Expand Down
Loading
Loading