Skip to content

Commit 1ad4f2b

Browse files
authored
feat: add evaluate_flags() API for single-call flag evaluation (#137)
* feat: add evaluate_flags() API for single-call flag evaluation Add Client#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. The snapshot exposes is_enabled, get_flag, get_flag_payload, plus only_accessed / only([keys]) filter helpers. flag_keys: scopes the underlying /flags request itself. is_enabled and get_flag fire $feature_flag_called events with full metadata (id, version, reason, request_id), deduped through the existing per-distinct_id cache. get_flag_payload does not record access or fire an event. The dedup + capture in get_feature_flag_result is extracted into _capture_feature_flag_called_if_needed and shared between the existing path and the snapshot's access-recording. Existing is_feature_enabled, get_feature_flag, get_feature_flag_result, get_feature_flag_payload, and capture(send_feature_flags:) continue to work unchanged. Generated-By: PostHog Code Task-Id: fe67a5bd-7d33-4568-9148-39d181660a5a * feat: incorporate posthog-python#539 review feedback Mirrors the changes that landed on the Python PR after review: - only_accessed returns an empty snapshot when nothing has been accessed (drops the warn-and-fall-back-to-all-flags behavior — that was a misguided safety net that surprised callers in the early-pre-access pattern). - capture(flags:, send_feature_flags:) now warns when both are passed, uses the snapshot, and ignores send_feature_flags. The snapshot guarantees the event carries the values branched on; the precedence was previously implicit. - $feature_flag_called events now carry response-level errors (errors_while_computing_flags, quota_limited) combined with per-flag errors (flag_missing) as a comma-joined $feature_flag_error, matching the granularity of the legacy single-flag path. - capture_exception now accepts a flags: kwarg and forwards it to the inner capture() so $exception events can carry the same flag context as other events. - Phase 2 deprecation warnings ship alongside Phase 1: is_feature_enabled, get_feature_flag, get_feature_flag_result, get_feature_flag_payload, and capture(send_feature_flags:) emit Kernel.warn(..., category: :deprecated) pointing at evaluate_flags(). Public methods bypass each other internally (via a new private _get_feature_flag_result) so a single user-level call emits exactly one warning. Generated-By: PostHog Code Task-Id: fe67a5bd-7d33-4568-9148-39d181660a5a * feat: address Dustin's review feedback on evaluate_flags PR Snapshot: - Rename is_enabled → enabled? for Ruby idiom (the legacy client-level is_feature_enabled keeps its name but is now deprecated). - Canonicalize the recorded $feature_flag_response across enabled?/get_flag so a variant flag accessed both ways collapses to one $feature_flag_called event (with the variant value) instead of two with mismatched booleans. Same fix for missing flags: response is nil regardless of which method was called. Client / poller: - Drop the feature_flags_log_warnings config option in favor of standard log-level gating: warnings flow through logger.warn so callers silence them via their existing log config (mirrors the posthog-android feedback). - Skip the remote /flags call when flag_keys are all locally resolved. Without flag_keys we still hit the server because we don't know about server-side-only flags. - Forward disable_geoip to the /flags request body as geoip_disable. - Use a Set for flag_keys lookup to keep the per-flag check O(1). - Normalize remote and local-eval payloads via FeatureFlagResult.parse_payload (made public — same contract as FeatureFlagResult.from_value_and_payload uses internally). - Guard capture(flags:) against non-snapshot values: log a warning and ignore instead of raising NoMethodError. - Move _get_feature_flag_result into the private section. Deprecation warnings: - Drop the :deprecated category — Ruby suppresses those by default since 2.7.2 and most users wouldn't see them. Use Kernel.warn with a [posthog-ruby] DEPRECATION prefix; standard log-level / IO silencing still works. - Emit each deprecation at most once per (method, client) pair via Concurrent::Set so high-volume callers don't pay a per-call cost. Generated-By: PostHog Code Task-Id: fe67a5bd-7d33-4568-9148-39d181660a5a * chore: update changeset to match enabled? rename The changeset was written before the is_enabled → enabled? rename that came out of Dustin's review; align the example snippet and description so the generated CHANGELOG entry uses the final API naming. Also softens "DeprecationWarning" to "one-time deprecation warning per method" since we dropped the :deprecated category in favor of always-visible Kernel.warn. Generated-By: PostHog Code Task-Id: fe67a5bd-7d33-4568-9148-39d181660a5a
1 parent d847947 commit 1ad4f2b

7 files changed

Lines changed: 928 additions & 62 deletions

File tree

.changeset/evaluate-flags-api.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"posthog-ruby": minor
3+
---
4+
5+
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.
6+
7+
```ruby
8+
snapshot = posthog.evaluate_flags("user-1", flag_keys: ["checkout-redesign"])
9+
posthog.capture(distinct_id: "user-1", event: "checkout_started", flags: snapshot) if snapshot.enabled?("checkout-redesign")
10+
```
11+
12+
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.
13+
14+
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.

lib/posthog.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@
1212
require 'posthog/exception_capture'
1313
require 'posthog/feature_flag_error'
1414
require 'posthog/feature_flag_result'
15+
require 'posthog/feature_flag_evaluations'
1516
require 'posthog/flag_definition_cache'

lib/posthog/client.rb

Lines changed: 291 additions & 59 deletions
Large diffs are not rendered by default.
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# frozen_string_literal: true
2+
3+
require 'set'
4+
5+
module PostHog
6+
# A snapshot of feature flag evaluations for one distinct_id, returned by
7+
# PostHog::Client#evaluate_flags. Calls to {#is_enabled} / {#get_flag} fire the
8+
# `$feature_flag_called` event (deduped through the existing per-distinct_id
9+
# cache); {#get_flag_payload} does not. Pass the snapshot to `capture(flags:)`
10+
# to attach `$feature/<key>` and `$active_feature_flags` without a second
11+
# /flags request.
12+
class FeatureFlagEvaluations
13+
EVALUATED_LOCALLY_REASON = 'Evaluated locally'
14+
15+
EvaluatedFlagRecord = Struct.new(
16+
:key, :enabled, :variant, :payload, :id, :version, :reason, :locally_evaluated,
17+
keyword_init: true
18+
)
19+
20+
Host = Struct.new(:capture_flag_called_event_if_needed, :log_warning, keyword_init: true)
21+
22+
attr_reader :distinct_id, :groups, :request_id, :evaluated_at, :flag_definitions_loaded_at
23+
24+
def initialize(
25+
host: nil,
26+
distinct_id: nil,
27+
flags: {},
28+
groups: nil,
29+
disable_geoip: nil,
30+
request_id: nil,
31+
evaluated_at: nil,
32+
flag_definitions_loaded_at: nil,
33+
errors_while_computing: false,
34+
quota_limited: false,
35+
accessed: nil
36+
)
37+
@host = host
38+
@distinct_id = distinct_id || ''
39+
@flags = flags || {}
40+
@groups = groups
41+
@disable_geoip = disable_geoip
42+
@request_id = request_id
43+
@evaluated_at = evaluated_at
44+
@flag_definitions_loaded_at = flag_definitions_loaded_at
45+
@errors_while_computing = errors_while_computing
46+
@quota_limited = quota_limited
47+
@accessed = Set.new(accessed || [])
48+
end
49+
50+
def keys
51+
@flags.keys
52+
end
53+
54+
def enabled?(key)
55+
key = key.to_s
56+
flag = @flags[key]
57+
_record_access(key, flag)
58+
flag&.enabled ? true : false
59+
end
60+
61+
def get_flag(key)
62+
key = key.to_s
63+
flag = @flags[key]
64+
_record_access(key, flag)
65+
_flag_value(flag)
66+
end
67+
68+
def get_flag_payload(key)
69+
flag = @flags[key.to_s]
70+
flag&.payload
71+
end
72+
73+
# Order-dependent: if nothing has been accessed yet, the returned snapshot is
74+
# empty. The method honors its name — pre-access flags before calling this if
75+
# you want a populated result.
76+
def only_accessed
77+
_clone_with(@flags.slice(*@accessed))
78+
end
79+
80+
def only(keys)
81+
keys = Array(keys).map(&:to_s)
82+
missing = keys.reject { |k| @flags.key?(k) }
83+
unless missing.empty?
84+
@host.log_warning.call(
85+
'FeatureFlagEvaluations#only was called with flag keys that are not in the ' \
86+
"evaluation set and will be dropped: #{missing.join(', ')}"
87+
)
88+
end
89+
filtered = @flags.slice(*keys)
90+
_clone_with(filtered)
91+
end
92+
93+
# Builds the `$feature/<key>` and `$active_feature_flags` properties for a
94+
# captured event. Called from PostHog::Client#capture when `flags:` is set.
95+
def _get_event_properties
96+
properties = {}
97+
active = []
98+
@flags.each do |key, flag|
99+
properties["$feature/#{key}"] = flag.enabled ? (flag.variant || true) : false
100+
active << key if flag.enabled
101+
end
102+
properties['$active_feature_flags'] = active.sort unless active.empty?
103+
properties
104+
end
105+
106+
private
107+
108+
# Canonical "stored" value for a flag — used for both the
109+
# `$feature_flag_response` event property and the dedup cache key, so
110+
# `enabled?` and `get_flag` collapse to a single exposure per flag.
111+
# Variant string when present, else boolean enabled status; `nil` for
112+
# unknown flags.
113+
def _flag_value(flag)
114+
return nil if flag.nil?
115+
return flag.variant if flag.variant
116+
117+
flag.enabled ? true : false
118+
end
119+
120+
def _record_access(key, flag)
121+
@accessed.add(key)
122+
return if @distinct_id.nil? || @distinct_id.to_s.empty?
123+
124+
response = _flag_value(flag)
125+
properties = {
126+
'$feature_flag' => key,
127+
'$feature_flag_response' => response,
128+
'locally_evaluated' => flag&.locally_evaluated ? true : false,
129+
"$feature/#{key}" => response
130+
}
131+
132+
if flag
133+
properties['$feature_flag_payload'] = flag.payload unless flag.payload.nil?
134+
properties['$feature_flag_id'] = flag.id if flag.id
135+
properties['$feature_flag_version'] = flag.version if flag.version
136+
properties['$feature_flag_reason'] = flag.reason if flag.reason
137+
if flag.locally_evaluated && @flag_definitions_loaded_at
138+
properties['$feature_flag_definitions_loaded_at'] = @flag_definitions_loaded_at
139+
end
140+
end
141+
142+
properties['$feature_flag_request_id'] = @request_id if @request_id
143+
properties['$feature_flag_evaluated_at'] = @evaluated_at if @evaluated_at && !(flag && flag.locally_evaluated)
144+
145+
errors = []
146+
errors << 'errors_while_computing_flags' if @errors_while_computing
147+
errors << 'quota_limited' if @quota_limited
148+
errors << 'flag_missing' if flag.nil?
149+
properties['$feature_flag_error'] = errors.join(',') unless errors.empty?
150+
151+
@host.capture_flag_called_event_if_needed.call(
152+
distinct_id: @distinct_id,
153+
key: key,
154+
response: response,
155+
properties: properties,
156+
groups: @groups,
157+
disable_geoip: @disable_geoip
158+
)
159+
end
160+
161+
def _clone_with(flags)
162+
self.class.new(
163+
host: @host,
164+
distinct_id: @distinct_id,
165+
flags: flags,
166+
groups: @groups,
167+
disable_geoip: @disable_geoip,
168+
request_id: @request_id,
169+
evaluated_at: @evaluated_at,
170+
flag_definitions_loaded_at: @flag_definitions_loaded_at,
171+
errors_while_computing: @errors_while_computing,
172+
quota_limited: @quota_limited,
173+
accessed: @accessed.dup
174+
)
175+
end
176+
end
177+
end

lib/posthog/feature_flag_result.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ def self.from_value_and_payload(key, value, payload)
3939
end
4040
end
4141

42+
# Deserialize a flag payload. Strings are JSON-parsed (with the raw string
43+
# returned when the body is not valid JSON); already-deserialized values
44+
# pass through. Public so {FeatureFlagEvaluations} can normalize payloads
45+
# the same way {FeatureFlagResult} does.
4246
def self.parse_payload(payload)
4347
return nil if payload.nil?
4448
return payload unless payload.is_a?(String)
@@ -50,7 +54,5 @@ def self.parse_payload(payload)
5054
payload
5155
end
5256
end
53-
54-
private_class_method :parse_payload
5557
end
5658
end

lib/posthog/feature_flags.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def initialize(
4646
@on_error = on_error || proc { |status, error| }
4747
@quota_limited = Concurrent::AtomicBoolean.new(false)
4848
@flags_etag = Concurrent::AtomicReference.new(nil)
49+
@flag_definitions_loaded_at = nil
4950

5051
@flag_definition_cache_provider = flag_definition_cache_provider
5152
FlagDefinitionCacheProvider.validate!(@flag_definition_cache_provider) if @flag_definition_cache_provider
@@ -72,6 +73,8 @@ def load_feature_flags(force_reload = false)
7273
_load_feature_flags
7374
end
7475

76+
attr_reader :flag_definitions_loaded_at, :feature_flags_by_key
77+
7578
def get_feature_variants(
7679
distinct_id,
7780
groups = {},
@@ -120,13 +123,16 @@ def get_feature_payloads(
120123
end
121124
end
122125

123-
def get_flags(distinct_id, groups = {}, person_properties = {}, group_properties = {})
126+
def get_flags(distinct_id, groups = {}, person_properties = {}, group_properties = {}, flag_keys = nil,
127+
disable_geoip = nil)
124128
request_data = {
125129
distinct_id: distinct_id,
126130
groups: groups,
127131
person_properties: person_properties,
128132
group_properties: group_properties
129133
}
134+
request_data[:flag_keys_to_evaluate] = flag_keys if flag_keys && !flag_keys.empty?
135+
request_data[:geoip_disable] = true if disable_geoip
130136

131137
flags_response = _request_feature_flag_evaluation(request_data)
132138

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

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

0 commit comments

Comments
 (0)