From 33783e5da1e4eaede3bbf58801a216d23760e891 Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 27 Apr 2026 13:22:09 -0700 Subject: [PATCH 1/2] 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 --- .changeset/evaluate-flags-api.md | 14 + lib/posthog.rb | 1 + lib/posthog/client.rb | 170 +++++++++++- lib/posthog/feature_flag_evaluations.rb | 168 ++++++++++++ lib/posthog/feature_flags.rb | 7 +- spec/posthog/feature_flag_evaluations_spec.rb | 259 ++++++++++++++++++ 6 files changed, 612 insertions(+), 7 deletions(-) create mode 100644 .changeset/evaluate-flags-api.md create mode 100644 lib/posthog/feature_flag_evaluations.rb create mode 100644 spec/posthog/feature_flag_evaluations_spec.rb diff --git a/.changeset/evaluate-flags-api.md b/.changeset/evaluate-flags-api.md new file mode 100644 index 0000000..1fb9c1e --- /dev/null +++ b/.changeset/evaluate-flags-api.md @@ -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.is_enabled("checkout-redesign") +``` + +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 (`$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. + +Existing `is_feature_enabled`, `get_feature_flag`, `get_feature_flag_result`, `get_feature_flag_payload`, and `capture(send_feature_flags:)` continue to work unchanged. diff --git a/lib/posthog.rb b/lib/posthog.rb index c6d8c54..d4227e6 100644 --- a/lib/posthog.rb +++ b/lib/posthog.rb @@ -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' diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index 2f8cd9f..66160d6 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -11,6 +11,7 @@ require 'posthog/message_batch' require 'posthog/transport' require 'posthog/feature_flags' +require 'posthog/feature_flag_evaluations' require 'posthog/send_feature_flags_options' require 'posthog/exception_capture' @@ -135,6 +136,7 @@ def initialize(opts = {}) end @before_send = opts[:before_send] + @feature_flags_log_warnings = opts.key?(:feature_flags_log_warnings) ? opts[:feature_flags_log_warnings] : true end # Synchronously waits until the worker has cleared the queue. @@ -175,6 +177,10 @@ def clear # @option attrs [Hash] :properties Event properties (optional) # @option attrs [Bool, Hash, SendFeatureFlagsOptions] :send_feature_flags # Whether to send feature flags with this event, or configuration for feature flag evaluation (optional) + # @option attrs [PostHog::FeatureFlagEvaluations] :flags A snapshot returned by + # {#evaluate_flags}. When present, `$feature/` and `$active_feature_flags` are + # attached from the snapshot without making an additional /flags request, and this + # takes precedence over `:send_feature_flags`. # @option attrs [String] :uuid ID that uniquely identifies an event; # events in PostHog are deduplicated by the # combination of teamId, timestamp date, @@ -183,6 +189,13 @@ def clear def capture(attrs) symbolize_keys! attrs + if attrs[:flags] + snapshot_props = attrs[:flags]._get_event_properties + attrs[:properties] = snapshot_props.merge(attrs[:properties] || {}) + attrs.delete(:flags) + attrs.delete(:send_feature_flags) + end + send_feature_flags_param = attrs[:send_feature_flags] if send_feature_flags_param # Handle different types of send_feature_flags parameter @@ -402,9 +415,7 @@ def get_feature_flag_result( 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 + if send_feature_flag_events properties = { '$feature_flag' => key, '$feature_flag_response' => feature_flag_response, @@ -414,18 +425,134 @@ def get_feature_flag_result( properties['$feature_flag_evaluated_at'] = evaluated_at if evaluated_at properties['$feature_flag_error'] = feature_flag_error if feature_flag_error - capture( + _capture_feature_flag_called_if_needed( distinct_id: distinct_id, - event: '$feature_flag_called', + key: key, + response: feature_flag_response, properties: properties, groups: groups ) - @distinct_id_has_sent_flag_calls[distinct_id] << feature_flag_reported_key end FeatureFlagResult.from_value_and_payload(key, feature_flag_response, payload) end + # Evaluate feature flags for a distinct id and return a snapshot. + # + # The returned {PostHog::FeatureFlagEvaluations} can be queried with + # `is_enabled` / `get_flag` / `get_flag_payload`, narrowed with + # `only_accessed` / `only`, and passed to {#capture} via the `flags:` option + # to attach `$feature/` and `$active_feature_flags` without an extra + # /flags request. + # + # @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 Skip the remote /flags call entirely + # @param [Boolean] disable_geoip Stamped on captured access events + # @param [Array] flag_keys When set, scopes the underlying /flags + # request to only these flag keys (sent as `flag_keys_to_evaluate`). + # Distinct from {FeatureFlagEvaluations#only}, which filters the + # already-fetched snapshot in memory. + # @return [PostHog::FeatureFlagEvaluations] + def evaluate_flags( + distinct_id, + groups: {}, + person_properties: {}, + group_properties: {}, + only_evaluate_locally: false, + disable_geoip: nil, + flag_keys: nil + ) + host = _feature_flag_evaluations_host + + if distinct_id.nil? || distinct_id.to_s.empty? + return FeatureFlagEvaluations.new(host: host, distinct_id: '', flags: {}) + end + + person_properties, group_properties = add_local_person_and_group_properties( + distinct_id, groups, person_properties, group_properties + ) + + records = {} + locally_evaluated_keys = Set.new + + @feature_flags_poller.load_feature_flags + poller_flags_by_key = @feature_flags_poller.feature_flags_by_key || {} + + poller_flags_by_key.each do |key, definition| + next if flag_keys && !flag_keys.map(&:to_s).include?(key.to_s) + + begin + match = @feature_flags_poller.send( + :_compute_flag_locally, + definition, distinct_id, groups, person_properties, group_properties + ) + rescue PostHog::RequiresServerEvaluation, PostHog::InconclusiveMatchError, StandardError + next + end + + next if match.nil? + + records[key.to_s] = FeatureFlagEvaluations::EvaluatedFlagRecord.new( + key: key.to_s, + enabled: match.is_a?(String) || (match ? true : false), + variant: match.is_a?(String) ? match : nil, + payload: @feature_flags_poller.send(:_compute_flag_payload_locally, key, match), + id: definition[:id], + version: nil, + reason: FeatureFlagEvaluations::EVALUATED_LOCALLY_REASON, + locally_evaluated: true + ) + locally_evaluated_keys << key.to_s + end + + request_id = nil + evaluated_at = nil + + unless only_evaluate_locally + begin + flags_response = @feature_flags_poller.get_flags( + distinct_id, groups, person_properties, group_properties, flag_keys + ) + request_id = flags_response[:requestId] + evaluated_at = flags_response[:evaluatedAt] + remote_flags = flags_response[:flags] || {} + remote_flags.each do |key, ff| + key_str = key.to_s + next if locally_evaluated_keys.include?(key_str) + + metadata = ff.metadata + reason = ff.reason + records[key_str] = FeatureFlagEvaluations::EvaluatedFlagRecord.new( + key: key_str, + enabled: ff.enabled ? true : false, + variant: ff.variant, + payload: ff.payload, + id: metadata ? metadata.id : nil, + version: metadata ? metadata.version : nil, + reason: reason ? (reason.description || reason.code) : nil, + locally_evaluated: false + ) + end + rescue StandardError => e + @on_error&.call(-1, "Error evaluating flags remotely: #{e}") + end + end + + FeatureFlagEvaluations.new( + host: host, + distinct_id: distinct_id, + flags: records, + groups: groups, + disable_geoip: disable_geoip, + request_id: request_id, + evaluated_at: evaluated_at, + flag_definitions_loaded_at: @feature_flags_poller.flag_definitions_loaded_at + ) + end + # Returns all flags for a given user # # @param [String] distinct_id The distinct id of the user @@ -530,6 +657,37 @@ def shutdown private + # Shared by the legacy single-flag path ({#get_feature_flag_result}) and the + # snapshot's access-recording. Owns dedup-key construction, the + # per-distinct_id sent-flags cache, and the `$feature_flag_called` capture call. + def _capture_feature_flag_called_if_needed( + distinct_id: nil, key: nil, response: nil, properties: nil, + groups: nil, disable_geoip: nil + ) + reported_key = "#{key}_#{response.nil? ? '::null::' : response}" + return if @distinct_id_has_sent_flag_calls[distinct_id].include?(reported_key) + + msg = { + distinct_id: distinct_id, + event: '$feature_flag_called', + properties: properties + } + msg[:groups] = groups if groups + msg[:disable_geoip] = disable_geoip unless disable_geoip.nil? + + capture(msg) + @distinct_id_has_sent_flag_calls[distinct_id] << reported_key + end + + def _feature_flag_evaluations_host + @feature_flag_evaluations_host ||= FeatureFlagEvaluations::Host.new( + capture_flag_called_event_if_needed: method(:_capture_feature_flag_called_if_needed), + log_warning: lambda do |message| + logger.warn(message) if @feature_flags_log_warnings + end + ) + end + # before_send should run immediately before the event is sent to the queue. # @param [Object] action The event to be sent to PostHog # @return [null, Object, nil] The processed event or nil if the event should not be sent diff --git a/lib/posthog/feature_flag_evaluations.rb b/lib/posthog/feature_flag_evaluations.rb new file mode 100644 index 0000000..61c6e95 --- /dev/null +++ b/lib/posthog/feature_flag_evaluations.rb @@ -0,0 +1,168 @@ +# 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/` 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, + 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 + @accessed = Set.new(accessed || []) + end + + def keys + @flags.keys + end + + def is_enabled(key) # rubocop:disable Naming/PredicateName + key = key.to_s + flag = @flags[key] + response = flag&.enabled ? true : false + _record_access(key, flag, response) + response + end + + def get_flag(key) + key = key.to_s + flag = @flags[key] + response = + if flag.nil? + nil + elsif flag.variant + flag.variant + else + flag.enabled ? true : false + end + _record_access(key, flag, response) + response + end + + def get_flag_payload(key) + flag = @flags[key.to_s] + flag&.payload + end + + def only_accessed + if @accessed.empty? + @host.log_warning.call( + 'FeatureFlagEvaluations#only_accessed was called before any flags were accessed — ' \ + 'attaching all evaluated flags as a fallback. ' \ + 'See https://posthog.com/docs/feature-flags/server-sdks for details.' + ) + return _clone_with(@flags) + end + filtered = @flags.slice(*@accessed) + _clone_with(filtered) + 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/` 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 + + def _record_access(key, flag, response) + @accessed.add(key) + return if @distinct_id.nil? || @distinct_id.to_s.empty? + + 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) + properties['$feature_flag_error'] = 'flag_missing' if flag.nil? + + @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, + accessed: @accessed.dup + ) + end + end +end diff --git a/lib/posthog/feature_flags.rb b/lib/posthog/feature_flags.rb index 36f21da..2bb11da 100644 --- a/lib/posthog/feature_flags.rb +++ b/lib/posthog/feature_flags.rb @@ -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 @@ -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 = {}, @@ -120,13 +123,14 @@ 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) 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? flags_response = _request_feature_flag_evaluation(request_data) @@ -1124,6 +1128,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 diff --git a/spec/posthog/feature_flag_evaluations_spec.rb b/spec/posthog/feature_flag_evaluations_spec.rb new file mode 100644 index 0000000..706e0a4 --- /dev/null +++ b/spec/posthog/feature_flag_evaluations_spec.rb @@ -0,0 +1,259 @@ +# frozen_string_literal: true + +require 'spec_helper' + +FLAGS_ENDPOINT = 'https://us.i.posthog.com/flags/?v=2' +LOCAL_EVAL_ENDPOINT = 'https://app.posthog.com/flags/definitions?token=testsecret&send_cohorts=true' + +module PostHog + describe FeatureFlagEvaluations do + let(:flags_response) do + { + flags: { + 'variant-flag' => { + key: 'variant-flag', enabled: true, variant: 'variant-value', + reason: { code: 'condition_match', condition_index: 2, description: 'Matched condition set 3' }, + metadata: { id: 2, version: 23, payload: '{"key": "value"}', description: 'description' } + }, + 'boolean-flag' => { + key: 'boolean-flag', enabled: true, variant: nil, + reason: { code: 'condition_match', condition_index: 1, description: 'Matched condition set 1' }, + metadata: { id: 1, version: 12 } + }, + 'disabled-flag' => { + key: 'disabled-flag', enabled: false, variant: nil, + reason: { code: 'no_condition_match', condition_index: nil, description: 'Did not match any condition' }, + metadata: { id: 3, version: 2 } + } + }, + errorsWhileComputingFlags: false, + requestId: 'request-id-1', + evaluatedAt: 1_640_995_200_000 + } + end + + def stub_flags(response) + stub_request(:post, FLAGS_ENDPOINT).to_return(status: 200, body: response.to_json) + end + + def drain_messages(client) + msgs = [] + msgs << client.dequeue_last_message until client.queued_messages.zero? + msgs + end + + let(:client) { Client.new(api_key: API_KEY, test_mode: true) } + + describe 'remote evaluation' do + it 'returns a FeatureFlagEvaluations instance and makes one /flags request' do + stub_flags(flags_response) + snapshot = client.evaluate_flags('user-1') + expect(snapshot).to be_a(FeatureFlagEvaluations) + expect(snapshot.keys).to match_array(%w[variant-flag boolean-flag disabled-flag]) + expect(WebMock).to have_requested(:post, FLAGS_ENDPOINT).once + end + + it 'does not fire $feature_flag_called events for unaccessed flags' do + stub_flags(flags_response) + client.evaluate_flags('user-1') + msgs = drain_messages(client) + expect(msgs.any? { |m| m[:event] == '$feature_flag_called' }).to be(false) + end + + it 'is_enabled fires the event with full metadata on first access and dedupes on second' do + stub_flags(flags_response) + snapshot = client.evaluate_flags('user-1') + + expect(snapshot.is_enabled('boolean-flag')).to be(true) + msgs = drain_messages(client) + event = msgs.find { |m| m[:event] == '$feature_flag_called' } + expect(event).not_to be_nil + expect(event[:properties]['$feature_flag']).to eq('boolean-flag') + expect(event[:properties]['$feature_flag_response']).to be(true) + expect(event[:properties]['$feature_flag_id']).to eq(1) + expect(event[:properties]['$feature_flag_version']).to eq(12) + expect(event[:properties]['$feature_flag_reason']).to eq('Matched condition set 1') + expect(event[:properties]['$feature_flag_request_id']).to eq('request-id-1') + expect(event[:properties]['locally_evaluated']).to be(false) + + snapshot.is_enabled('boolean-flag') + msgs = drain_messages(client) + expect(msgs.any? { |m| m[:event] == '$feature_flag_called' }).to be(false) + end + + it 'get_flag returns variant strings, booleans, and nil for unknown flags' do + stub_flags(flags_response) + snapshot = client.evaluate_flags('user-1') + + expect(snapshot.get_flag('variant-flag')).to eq('variant-value') + expect(snapshot.get_flag('boolean-flag')).to be(true) + expect(snapshot.get_flag('disabled-flag')).to be(false) + expect(snapshot.get_flag('not-a-flag')).to be_nil + + msgs = drain_messages(client).select { |m| m[:event] == '$feature_flag_called' } + unknown = msgs.find { |m| m[:properties]['$feature_flag'] == 'not-a-flag' } + expect(unknown[:properties]['$feature_flag_error']).to eq('flag_missing') + end + + it 'is_enabled returns false for unknown flags' do + stub_flags(flags_response) + snapshot = client.evaluate_flags('user-1') + expect(snapshot.is_enabled('not-a-flag')).to be(false) + end + + it 'get_flag_payload does not fire an event' do + stub_flags(flags_response) + snapshot = client.evaluate_flags('user-1') + snapshot.get_flag_payload('variant-flag') + msgs = drain_messages(client) + expect(msgs.any? { |m| m[:event] == '$feature_flag_called' }).to be(false) + end + + it 'forwards flag_keys to the /flags request body as flag_keys_to_evaluate' do + stub_flags(flags_response) + client.evaluate_flags('user-1', flag_keys: %w[boolean-flag]) + expect(WebMock).to have_requested(:post, FLAGS_ENDPOINT).with( + body: hash_including(flag_keys_to_evaluate: %w[boolean-flag]) + ) + end + + it 'returns a usable empty snapshot for empty distinct_id and does not call /flags' do + stub_flags(flags_response) + snapshot = client.evaluate_flags('') + expect(WebMock).not_to have_requested(:post, FLAGS_ENDPOINT) + expect(snapshot.keys).to eq([]) + expect(snapshot.is_enabled('anything')).to be(false) + expect(snapshot.get_flag('anything')).to be_nil + msgs = drain_messages(client) + expect(msgs.any? { |m| m[:event] == '$feature_flag_called' }).to be(false) + end + end + + describe 'filtering helpers' do + it 'only_accessed returns a snapshot with just the accessed flags' do + stub_flags(flags_response) + snapshot = client.evaluate_flags('user-1') + snapshot.is_enabled('boolean-flag') + filtered = snapshot.only_accessed + expect(filtered.keys).to eq(%w[boolean-flag]) + end + + it 'only_accessed warns and falls back to all flags when nothing was accessed' do + warned = [] + c = Client.new(api_key: API_KEY, test_mode: true) + allow(c.send(:logger)).to receive(:warn) { |m| warned << m } + stub_flags(flags_response) + snapshot = c.evaluate_flags('user-1') + filtered = snapshot.only_accessed + expect(filtered.keys).to match_array(%w[variant-flag boolean-flag disabled-flag]) + expect(warned.any? { |m| m.include?('only_accessed') }).to be(true) + end + + it 'silences filter warnings when feature_flags_log_warnings: false' do + warned = [] + c = Client.new(api_key: API_KEY, test_mode: true, feature_flags_log_warnings: false) + allow(c.send(:logger)).to receive(:warn) { |m| warned << m } + stub_flags(flags_response) + snapshot = c.evaluate_flags('user-1') + snapshot.only_accessed + expect(warned).to eq([]) + end + + it 'only(keys) drops unknown keys with a warning' do + warned = [] + c = Client.new(api_key: API_KEY, test_mode: true) + allow(c.send(:logger)).to receive(:warn) { |m| warned << m } + stub_flags(flags_response) + snapshot = c.evaluate_flags('user-1') + filtered = snapshot.only(%w[boolean-flag does-not-exist]) + expect(filtered.keys).to eq(%w[boolean-flag]) + expect(warned.any? { |m| m.include?('does-not-exist') }).to be(true) + end + + it 'filtered snapshots do not back-propagate access to the parent' do + stub_flags(flags_response) + snapshot = client.evaluate_flags('user-1') + snapshot.is_enabled('boolean-flag') + filtered = snapshot.only_accessed + filtered.is_enabled('variant-flag') + reaccessed = snapshot.only_accessed + expect(reaccessed.keys).to eq(%w[boolean-flag]) + end + end + + describe 'capture(flags:)' do + it 'attaches $feature/* and $active_feature_flags from the snapshot without an extra /flags call' do + stub_flags(flags_response) + snapshot = client.evaluate_flags('user-1') + WebMock.reset_executed_requests! + + client.capture(distinct_id: 'user-1', event: 'test-event', flags: snapshot) + msgs = drain_messages(client) + event = msgs.find { |m| m[:event] == 'test-event' } + expect(event).not_to be_nil + props = event[:properties] + expect(props['$feature/variant-flag']).to eq('variant-value') + expect(props['$feature/boolean-flag']).to be(true) + expect(props['$feature/disabled-flag']).to be(false) + expect(props['$active_feature_flags']).to eq(%w[boolean-flag variant-flag]) + expect(WebMock).not_to have_requested(:post, FLAGS_ENDPOINT) + end + + it 'capture(flags: snapshot.only_accessed) attaches only accessed flags' do + stub_flags(flags_response) + snapshot = client.evaluate_flags('user-1') + snapshot.is_enabled('boolean-flag') + + client.capture(distinct_id: 'user-1', event: 'test-event', flags: snapshot.only_accessed) + msgs = drain_messages(client) + event = msgs.find { |m| m[:event] == 'test-event' } + expect(event[:properties]['$feature/boolean-flag']).to be(true) + expect(event[:properties].keys).not_to include('$feature/variant-flag') + expect(event[:properties]['$active_feature_flags']).to eq(%w[boolean-flag]) + end + + it 'flags: takes precedence over send_feature_flags' do + stub_flags(flags_response) + snapshot = client.evaluate_flags('user-1') + WebMock.reset_executed_requests! + + client.capture( + distinct_id: 'user-1', event: 'test-event', + flags: snapshot, send_feature_flags: true + ) + expect(WebMock).not_to have_requested(:post, FLAGS_ENDPOINT) + end + end + + describe 'local evaluation' do + let(:local_definitions) do + { + flags: [ + { + id: 99, name: 'Local flag', key: 'local-flag', active: true, + filters: { groups: [{ properties: [], rollout_percentage: 100 }] } + } + ] + } + end + + it 'tags locally-evaluated flags and skips remote when only_evaluate_locally' 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) + + expect(WebMock).not_to have_requested(:post, FLAGS_ENDPOINT) + expect(snapshot.is_enabled('local-flag')).to be(true) + + msgs = drain_messages(c).select { |m| m[:event] == '$feature_flag_called' } + event = msgs.find { |m| m[:properties]['$feature_flag'] == 'local-flag' } + expect(event).not_to be_nil + expect(event[:properties]['locally_evaluated']).to be(true) + expect(event[:properties]['$feature_flag_reason']).to eq('Evaluated locally') + expect(event[:properties]['$feature_flag_id']).to eq(99) + expect(event[:properties]['$feature_flag_definitions_loaded_at']).to be_a(Integer) + end + end + end +end From 57f935e2e1298f0c10c31abbdbeded5754ff4fee Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 29 Apr 2026 14:07:59 -0700 Subject: [PATCH 2/2] feat: incorporate posthog-python#539 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/evaluate-flags-api.md | 2 +- lib/posthog/client.rb | 140 ++++++++++++------ lib/posthog/feature_flag_evaluations.rb | 27 ++-- spec/posthog/feature_flag_evaluations_spec.rb | 129 ++++++++++++++-- 4 files changed, 227 insertions(+), 71 deletions(-) diff --git a/.changeset/evaluate-flags-api.md b/.changeset/evaluate-flags-api.md index 1fb9c1e..aadaeca 100644 --- a/.changeset/evaluate-flags-api.md +++ b/.changeset/evaluate-flags-api.md @@ -11,4 +11,4 @@ posthog.capture(distinct_id: "user-1", event: "checkout_started", flags: snapsho 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 (`$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. -Existing `is_feature_enabled`, `get_feature_flag`, `get_feature_flag_result`, `get_feature_flag_payload`, and `capture(send_feature_flags:)` continue to work unchanged. +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 `DeprecationWarning` pointing at `evaluate_flags()`. Removal is planned for the next major version. diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index 66160d6..c02a54a 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -189,7 +189,16 @@ def clear def capture(attrs) symbolize_keys! attrs + # Precedence: an explicit `flags` snapshot always wins, regardless of + # `send_feature_flags`. The snapshot guarantees the event carries the same + # values the developer branched on with no additional network call. if attrs[:flags] + if attrs[:send_feature_flags] + logger.warn( + '[FEATURE FLAGS] Both `flags` and `send_feature_flags` were passed to ' \ + 'capture(); using `flags` and ignoring `send_feature_flags`.' + ) + end snapshot_props = attrs[:flags]._get_event_properties attrs[:properties] = snapshot_props.merge(attrs[:properties] || {}) attrs.delete(:flags) @@ -198,6 +207,13 @@ def capture(attrs) send_feature_flags_param = attrs[:send_feature_flags] if send_feature_flags_param + Kernel.warn( + '`send_feature_flags` is deprecated and will be removed in a future major version. ' \ + 'Pass a `flags` snapshot from `client.evaluate_flags(...)` instead — it avoids a ' \ + 'second `/flags` request per capture and guarantees the event carries the exact ' \ + 'flag values your code branched on.', + category: :deprecated, uplevel: 1 + ) # Handle different types of send_feature_flags parameter case send_feature_flags_param when true @@ -238,7 +254,10 @@ def capture(attrs) # @param [Exception, String, Object] exception The exception to capture, a string message, or exception-like object # @param [String] distinct_id The ID for the user (optional, defaults to a generated UUID) # @param [Hash] additional_properties Additional properties to include with the exception event (optional) - def capture_exception(exception, distinct_id = nil, additional_properties = {}) + # @param [PostHog::FeatureFlagEvaluations] flags A snapshot returned by {#evaluate_flags}. + # Forwarded to the inner {#capture} call so the captured `$exception` event carries the + # same `$feature/` and `$active_feature_flags` properties as the snapshot. + def capture_exception(exception, distinct_id = nil, additional_properties = {}, flags: nil) exception_info = ExceptionCapture.build_parsed_exception(exception) return if exception_info.nil? @@ -256,6 +275,7 @@ def capture_exception(exception, distinct_id = nil, additional_properties = {}) properties: properties, timestamp: Time.now } + event_data[:flags] = flags if flags capture(event_data) end @@ -306,6 +326,7 @@ def queued_messages @queue.length end + # @deprecated Use {#evaluate_flags} and {FeatureFlagEvaluations#is_enabled} instead. # TODO: In future version, rename to `feature_flag_enabled?` def is_feature_enabled( # rubocop:disable Naming/PredicateName flag_key, @@ -316,15 +337,20 @@ def is_feature_enabled( # rubocop:disable Naming/PredicateName only_evaluate_locally: false, send_feature_flag_events: true ) - response = get_feature_flag( - flag_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 + Kernel.warn( + '`is_feature_enabled` is deprecated and will be removed in a future major version. ' \ + 'Use `client.evaluate_flags(distinct_id, ...)` and call `flags.is_enabled(key)` instead — ' \ + 'this consolidates flag evaluation into a single `/flags` request per incoming request.', + category: :deprecated, uplevel: 1 + ) + # Bypass the public `get_feature_flag` so the user only sees a single deprecation + # warning per call, not a cascade. + result = _get_feature_flag_result( + flag_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 ) + response = result&.value return nil if response.nil? !!response @@ -357,6 +383,7 @@ def get_remote_config_payload(flag_key) # ```ruby # group_properties: {"organization": {"name": "PostHog", "employees": 11}} # ``` + # @deprecated Use {#evaluate_flags} and {FeatureFlagEvaluations#get_flag} instead. def get_feature_flag( key, distinct_id, @@ -366,31 +393,23 @@ def get_feature_flag( 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 + Kernel.warn( + '`get_feature_flag` is deprecated and will be removed in a future major version. ' \ + 'Use `client.evaluate_flags(distinct_id, ...)` and call `flags.get_flag(key)` instead — ' \ + 'this consolidates flag evaluation into a single `/flags` request per incoming request.', + category: :deprecated, uplevel: 1 + ) + # Bypass the public `get_feature_flag_result` so the user only sees one deprecation warning. + 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 + # @deprecated Use {#evaluate_flags} and {FeatureFlagEvaluations#get_flag} / + # {FeatureFlagEvaluations#get_flag_payload} instead. def get_feature_flag_result( key, distinct_id, @@ -399,21 +418,39 @@ def get_feature_flag_result( group_properties: {}, only_evaluate_locally: false, send_feature_flag_events: true + ) + Kernel.warn( + '`get_feature_flag_result` is deprecated and will be removed in a future major version. ' \ + 'Use `client.evaluate_flags(distinct_id, ...)` and call `flags.get_flag(key)` / ' \ + '`flags.get_flag_payload(key)` instead — this consolidates flag evaluation into a single ' \ + '`/flags` request per incoming request.', + category: :deprecated, uplevel: 1 + ) + _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 + ) + end + + # Internal: implementation of {#get_feature_flag_result}, called by both the + # public method and the legacy `is_feature_enabled` / `get_feature_flag` + # paths. Bypassing the public wrapper avoids cascading deprecation warnings. + 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 + distinct_id, groups, person_properties, group_properties ) feature_flag_response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload = @feature_flags_poller.get_feature_flag( - key, - distinct_id, - groups, - person_properties, - group_properties, - only_evaluate_locally + key, distinct_id, groups, person_properties, group_properties, only_evaluate_locally ) if send_feature_flag_events properties = { @@ -426,11 +463,8 @@ def get_feature_flag_result( properties['$feature_flag_error'] = feature_flag_error if feature_flag_error _capture_feature_flag_called_if_needed( - distinct_id: distinct_id, - key: key, - response: feature_flag_response, - properties: properties, - groups: groups + distinct_id: distinct_id, key: key, response: feature_flag_response, + properties: properties, groups: groups ) end @@ -510,6 +544,8 @@ def evaluate_flags( request_id = nil evaluated_at = nil + errors_while_computing = false + quota_limited = false unless only_evaluate_locally begin @@ -518,6 +554,8 @@ def evaluate_flags( ) request_id = flags_response[:requestId] evaluated_at = flags_response[:evaluatedAt] + errors_while_computing = flags_response[:errorsWhileComputingFlags] == true + quota_limited = (flags_response[:quotaLimited] || []).include?('feature_flags') remote_flags = flags_response[:flags] || {} remote_flags.each do |key, ff| key_str = key.to_s @@ -549,7 +587,9 @@ def evaluate_flags( disable_geoip: disable_geoip, request_id: request_id, evaluated_at: evaluated_at, - flag_definitions_loaded_at: @feature_flags_poller.flag_definitions_loaded_at + flag_definitions_loaded_at: @feature_flags_poller.flag_definitions_loaded_at, + errors_while_computing: errors_while_computing, + quota_limited: quota_limited ) end @@ -587,6 +627,7 @@ def get_all_flags( # @option [Hash] group_properties # @option [Boolean] only_evaluate_locally # + # @deprecated Use {#evaluate_flags} and {FeatureFlagEvaluations#get_flag_payload} instead. def get_feature_flag_payload( key, distinct_id, @@ -596,6 +637,13 @@ def get_feature_flag_payload( group_properties: {}, only_evaluate_locally: false ) + Kernel.warn( + '`get_feature_flag_payload` is deprecated and will be removed in a future major version. ' \ + 'Use `client.evaluate_flags(distinct_id, ...)` and call `flags.get_flag_payload(key)` ' \ + 'instead — this consolidates flag evaluation into a single `/flags` request per ' \ + 'incoming request.', + category: :deprecated, uplevel: 1 + ) person_properties, group_properties = add_local_person_and_group_properties(distinct_id, groups, person_properties, group_properties) @feature_flags_poller.get_feature_flag_payload(key, distinct_id, match_value, groups, person_properties, diff --git a/lib/posthog/feature_flag_evaluations.rb b/lib/posthog/feature_flag_evaluations.rb index 61c6e95..90b92e8 100644 --- a/lib/posthog/feature_flag_evaluations.rb +++ b/lib/posthog/feature_flag_evaluations.rb @@ -30,6 +30,8 @@ def initialize( request_id: nil, evaluated_at: nil, flag_definitions_loaded_at: nil, + errors_while_computing: false, + quota_limited: false, accessed: nil ) @host = host @@ -40,6 +42,8 @@ def initialize( @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 @@ -75,17 +79,11 @@ def get_flag_payload(key) 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 - if @accessed.empty? - @host.log_warning.call( - 'FeatureFlagEvaluations#only_accessed was called before any flags were accessed — ' \ - 'attaching all evaluated flags as a fallback. ' \ - 'See https://posthog.com/docs/feature-flags/server-sdks for details.' - ) - return _clone_with(@flags) - end - filtered = @flags.slice(*@accessed) - _clone_with(filtered) + _clone_with(@flags.slice(*@accessed)) end def only(keys) @@ -139,7 +137,12 @@ def _record_access(key, flag, response) properties['$feature_flag_request_id'] = @request_id if @request_id properties['$feature_flag_evaluated_at'] = @evaluated_at if @evaluated_at && !(flag && flag.locally_evaluated) - properties['$feature_flag_error'] = 'flag_missing' if flag.nil? + + 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, @@ -161,6 +164,8 @@ def _clone_with(flags) 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 diff --git a/spec/posthog/feature_flag_evaluations_spec.rb b/spec/posthog/feature_flag_evaluations_spec.rb index 706e0a4..e6d0e01 100644 --- a/spec/posthog/feature_flag_evaluations_spec.rb +++ b/spec/posthog/feature_flag_evaluations_spec.rb @@ -42,6 +42,15 @@ def drain_messages(client) msgs end + def capture_stderr + original = $stderr + $stderr = StringIO.new + yield + $stderr.string + ensure + $stderr = original + end + let(:client) { Client.new(api_key: API_KEY, test_mode: true) } describe 'remote evaluation' do @@ -138,15 +147,11 @@ def drain_messages(client) expect(filtered.keys).to eq(%w[boolean-flag]) end - it 'only_accessed warns and falls back to all flags when nothing was accessed' do - warned = [] - c = Client.new(api_key: API_KEY, test_mode: true) - allow(c.send(:logger)).to receive(:warn) { |m| warned << m } + it 'only_accessed returns an empty snapshot when nothing has been accessed' do stub_flags(flags_response) - snapshot = c.evaluate_flags('user-1') + snapshot = client.evaluate_flags('user-1') filtered = snapshot.only_accessed - expect(filtered.keys).to match_array(%w[variant-flag boolean-flag disabled-flag]) - expect(warned.any? { |m| m.include?('only_accessed') }).to be(true) + expect(filtered.keys).to eq([]) end it 'silences filter warnings when feature_flags_log_warnings: false' do @@ -155,7 +160,7 @@ def drain_messages(client) allow(c.send(:logger)).to receive(:warn) { |m| warned << m } stub_flags(flags_response) snapshot = c.evaluate_flags('user-1') - snapshot.only_accessed + snapshot.only(%w[no-such-flag]) expect(warned).to eq([]) end @@ -212,16 +217,114 @@ def drain_messages(client) expect(event[:properties]['$active_feature_flags']).to eq(%w[boolean-flag]) end - it 'flags: takes precedence over send_feature_flags' do + it 'flags: takes precedence over send_feature_flags and warns' do stub_flags(flags_response) snapshot = client.evaluate_flags('user-1') WebMock.reset_executed_requests! - client.capture( - distinct_id: 'user-1', event: 'test-event', - flags: snapshot, send_feature_flags: true - ) + warned = [] + allow(client.send(:logger)).to receive(:warn) { |m| warned << m } + # Suppress the DeprecationWarning on send_feature_flags so we only assert the precedence warning + Kernel.silence_warnings do + client.capture( + distinct_id: 'user-1', event: 'test-event', + flags: snapshot, send_feature_flags: true + ) + end expect(WebMock).not_to have_requested(:post, FLAGS_ENDPOINT) + expect(warned.any? { |m| m.include?('Both `flags` and `send_feature_flags`') }).to be(true) + end + + it 'capture_exception forwards flags: to the inner capture' do + stub_flags(flags_response) + snapshot = client.evaluate_flags('user-1') + + begin + raise 'boom' + rescue StandardError => e + client.capture_exception(e, 'user-1', flags: snapshot) + end + msgs = drain_messages(client) + event = msgs.find { |m| m[:event] == '$exception' } + expect(event).not_to be_nil + expect(event[:properties]['$feature/variant-flag']).to eq('variant-value') + expect(event[:properties]['$active_feature_flags']).to eq(%w[boolean-flag variant-flag]) + 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)) + snapshot = client.evaluate_flags('user-1') + + snapshot.is_enabled('boolean-flag') + snapshot.is_enabled('missing-flag') + msgs = drain_messages(client).select { |m| m[:event] == '$feature_flag_called' } + by_key = msgs.to_h { |m| [m[:properties]['$feature_flag'], m[:properties]] } + + expect(by_key['boolean-flag']['$feature_flag_error']).to eq('errors_while_computing_flags') + expect(by_key['missing-flag']['$feature_flag_error']).to eq('errors_while_computing_flags,flag_missing') + end + + it 'tags quota_limited from response' do + stub_flags(flags_response.merge(quotaLimited: ['feature_flags'])) + snapshot = client.evaluate_flags('user-1') + snapshot.is_enabled('boolean-flag') + msg = drain_messages(client).find { |m| m[:event] == '$feature_flag_called' } + expect(msg[:properties]['$feature_flag_error']).to eq('quota_limited') + end + end + + describe 'Phase 2 deprecation warnings' do + around do |example| + original = Warning[:deprecated] + Warning[:deprecated] = true + example.run + ensure + Warning[:deprecated] = original + end + + it 'is_feature_enabled emits a deprecation warning pointing at evaluate_flags' do + stub_flags(flags_response) + out = capture_stderr { client.is_feature_enabled('boolean-flag', 'user-1') } + expect(out).to include('is_feature_enabled') + expect(out).to include('evaluate_flags') + end + + it 'get_feature_flag emits a deprecation warning' do + stub_flags(flags_response) + out = capture_stderr { client.get_feature_flag('boolean-flag', 'user-1') } + expect(out).to include('get_feature_flag') + expect(out).to include('evaluate_flags') + end + + it 'get_feature_flag_result emits a deprecation warning' do + stub_flags(flags_response) + out = capture_stderr { client.get_feature_flag_result('boolean-flag', 'user-1') } + expect(out).to include('get_feature_flag_result') + end + + it 'get_feature_flag_payload emits a deprecation warning' do + stub_flags(flags_response) + out = capture_stderr { client.get_feature_flag_payload('boolean-flag', 'user-1') } + expect(out).to include('get_feature_flag_payload') + end + + it 'capture(send_feature_flags:) emits a deprecation warning' do + stub_flags(flags_response) + out = capture_stderr do + client.capture(distinct_id: 'user-1', event: 'test', send_feature_flags: true) + end + expect(out).to include('send_feature_flags') + expect(out).to include('evaluate_flags') + end + + it 'is_feature_enabled emits exactly one deprecation warning per call (no cascade)' do + stub_flags(flags_response) + out = capture_stderr { client.is_feature_enabled('boolean-flag', 'user-1') } + # Count occurrences of the deprecation lead phrase + count = out.scan('is deprecated and will be removed').length + expect(count).to eq(1) end end