From 60663ae6aea165279074b4a1fdd33ac9f18c60c6 Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 27 Apr 2026 12:50:53 -0700 Subject: [PATCH 1/2] feat: add evaluate_flags() API for single-call flag evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. The legacy single-flag dedup + capture is extracted into `_capture_feature_flag_called_if_needed` and shared between the existing `get_feature_flag` path and the snapshot's access-recording. Existing `is_feature_enabled`, `get_feature_flag`, `get_feature_flag_payload`, and `capture(send_feature_flags:)` continue to work unchanged; deprecation is a follow-up minor. Generated-By: PostHog Code Task-Id: fe67a5bd-7d33-4568-9148-39d181660a5a --- CHANGELOG.md | 4 + lib/posthog.rb | 1 + lib/posthog/client.rb | 190 ++++++++++++- lib/posthog/feature_flag_evaluations.rb | 166 +++++++++++ lib/posthog/feature_flags.rb | 7 +- lib/posthog/version.rb | 2 +- spec/posthog/feature_flag_evaluations_spec.rb | 259 ++++++++++++++++++ 7 files changed, 612 insertions(+), 17 deletions(-) create mode 100644 lib/posthog/feature_flag_evaluations.rb create mode 100644 spec/posthog/feature_flag_evaluations_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 3512432..04a746b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.10.0 – 2026-04-27 + +1. 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. The snapshot exposes `is_enabled`, `get_flag`, `get_flag_payload`, and `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. The existing `is_feature_enabled`, `get_feature_flag`, `get_feature_flag_payload`, and `capture(send_feature_flags:)` continue to work unchanged; they will be deprecated in a follow-up minor. + ## 2.9.0 – 2025-04-30 1. Use new `/flags` service to power feature flag evaluation. diff --git a/lib/posthog.rb b/lib/posthog.rb index 5b6ca5f..28e4597 100644 --- a/lib/posthog.rb +++ b/lib/posthog.rb @@ -3,6 +3,7 @@ require 'posthog/utils' require 'posthog/field_parser' require 'posthog/client' +require 'posthog/feature_flag_evaluations' require 'posthog/send_worker' require 'posthog/transport' require 'posthog/response' diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index f1791ff..ee35c15 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -7,6 +7,7 @@ require 'posthog/send_worker' require 'posthog/noop_worker' require 'posthog/feature_flags' +require 'posthog/feature_flag_evaluations' class PostHog class Client @@ -43,6 +44,7 @@ def initialize(opts = {}) @worker_thread = nil @feature_flags_poller = nil @personal_api_key = opts[:personal_api_key] + @feature_flags_log_warnings = opts.key?(:feature_flags_log_warnings) ? opts[:feature_flags_log_warnings] : true check_api_key! @@ -92,6 +94,10 @@ def clear # @option attrs [String] :event Event name # @option attrs [Hash] :properties Event properties (optional) # @option attrs [Bool] :send_feature_flags Whether to send feature flags with this event (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, @@ -100,7 +106,12 @@ def clear def capture(attrs) symbolize_keys! attrs - if attrs[:send_feature_flags] + 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) + elsif attrs[:send_feature_flags] feature_variants = @feature_flags_poller.get_feature_variants(attrs[:distinct_id], attrs[:groups] || {}) attrs[:feature_variants] = feature_variants @@ -230,25 +241,143 @@ def get_feature_flag( 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 - capture( - { - distinct_id: distinct_id, - event: '$feature_flag_called', - properties: { - '$feature_flag' => key, - '$feature_flag_response' => feature_flag_response, - 'locally_evaluated' => flag_was_locally_evaluated - }.merge(request_id ? { '$feature_flag_request_id' => request_id } : {}), - groups: groups - } + if send_feature_flag_events + properties = { + '$feature_flag' => key, + '$feature_flag_response' => feature_flag_response, + 'locally_evaluated' => flag_was_locally_evaluated + } + properties['$feature_flag_request_id'] = request_id if request_id + + _capture_feature_flag_called_if_needed( + distinct_id: distinct_id, + key: key, + response: feature_flag_response, + properties: properties, + groups: groups ) - @distinct_id_has_sent_flag_calls[distinct_id] << feature_flag_reported_key end feature_flag_response 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::InconclusiveMatchError, StandardError + next + end + + next if match.nil? + + records[key] = FeatureFlagEvaluations::EvaluatedFlagRecord.new( + key: key, + 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 + 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: 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 = @feature_flags_poller.instance_variable_get(:@on_error) + on_error.call(-1, "Error evaluating flags remotely: #{e}") if on_error + 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 @@ -342,6 +471,37 @@ def shutdown private + # Shared by the legacy single-flag path and FeatureFlagEvaluations#_record_access. + # 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 + # private: Enqueues the action. # # returns Boolean of whether the item was added to the queue. diff --git a/lib/posthog/feature_flag_evaluations.rb b/lib/posthog/feature_flag_evaluations.rb new file mode 100644 index 0000000..5e2c8a1 --- /dev/null +++ b/lib/posthog/feature_flag_evaluations.rb @@ -0,0 +1,166 @@ +require 'set' + +class 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'.freeze + + 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 && 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 ? flag.payload : nil + 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.select { |k, _| @accessed.include?(k) } + _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.select { |k, _| keys.include?(k) } + _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 && 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 18557e4..df89d60 100644 --- a/lib/posthog/feature_flags.rb +++ b/lib/posthog/feature_flags.rb @@ -33,6 +33,7 @@ def initialize( @feature_flag_request_timeout_seconds = feature_flag_request_timeout_seconds @on_error = on_error || proc { |status, error| } @quota_limited = Concurrent::AtomicBoolean.new(false) + @flag_definitions_loaded_at = nil @task = Concurrent::TimerTask.new( execution_interval: polling_interval @@ -55,6 +56,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 = {}, @@ -102,13 +105,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) @@ -614,6 +618,7 @@ def _load_feature_flags @group_type_mapping = res[:group_type_mapping] || {} logger.debug "Loaded #{@feature_flags.length} feature flags" + @flag_definitions_loaded_at = (Time.now.to_f * 1000).to_i @loaded_flags_successfully_once.make_true if @loaded_flags_successfully_once.false? else logger.debug "Failed to load feature flags: #{res}" diff --git a/lib/posthog/version.rb b/lib/posthog/version.rb index eb18575..bdfddda 100644 --- a/lib/posthog/version.rb +++ b/lib/posthog/version.rb @@ -1,3 +1,3 @@ class PostHog - VERSION = '2.9.0'.freeze + VERSION = '2.10.0'.freeze 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..6d1ef42 --- /dev/null +++ b/spec/posthog/feature_flag_evaluations_spec.rb @@ -0,0 +1,259 @@ +require 'spec_helper' + +FLAGS_ENDPOINT = 'https://app.posthog.com/flags/?v=2'.freeze +LOCAL_EVAL_ENDPOINT = 'https://app.posthog.com/api/feature_flag/local_evaluation?token=testsecret'.freeze + +class 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 = nil) + response ||= 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 + 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 + client.evaluate_flags('user-1') + msgs = drain_messages(client) + expect(msgs.any? { |m| m[:event] == '$feature_flag_called' }).to eq(false) + end + + it 'is_enabled fires the event with full metadata on first access and dedupes on second' do + stub_flags + snapshot = client.evaluate_flags('user-1') + + expect(snapshot.is_enabled('boolean-flag')).to eq(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 eq(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 eq(false) + + snapshot.is_enabled('boolean-flag') + msgs = drain_messages(client) + expect(msgs.any? { |m| m[:event] == '$feature_flag_called' }).to eq(false) + end + + it 'get_flag returns variant strings, booleans, and nil for unknown flags' do + stub_flags + snapshot = client.evaluate_flags('user-1') + + expect(snapshot.get_flag('variant-flag')).to eq('variant-value') + expect(snapshot.get_flag('boolean-flag')).to eq(true) + expect(snapshot.get_flag('disabled-flag')).to eq(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 + snapshot = client.evaluate_flags('user-1') + expect(snapshot.is_enabled('not-a-flag')).to eq(false) + end + + it 'get_flag_payload does not fire an event' do + stub_flags + 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 eq(false) + end + + it 'forwards flag_keys to the /flags request body as flag_keys_to_evaluate' do + stub_flags + 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 + snapshot = client.evaluate_flags('') + expect(WebMock).not_to have_requested(:post, FLAGS_ENDPOINT) + expect(snapshot.keys).to eq([]) + expect(snapshot.is_enabled('anything')).to eq(false) + expect(snapshot.get_flag('anything')).to be_nil + msgs = drain_messages(client) + expect(msgs.any? { |m| m[:event] == '$feature_flag_called' }).to eq(false) + end + end + + describe 'filtering helpers' do + it 'only_accessed returns a snapshot with just the accessed flags' do + stub_flags + 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 + stub_flags + warned = [] + c = Client.new(api_key: API_KEY, test_mode: true) + allow(c.send(:logger)).to receive(:warn) { |m| warned << m } + stub_flags + 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 eq(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 + 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 + 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 eq(true) + end + + it 'filtered snapshots do not back-propagate access to the parent' do + stub_flags + snapshot = client.evaluate_flags('user-1') + snapshot.is_enabled('boolean-flag') + filtered = snapshot.only_accessed + filtered.is_enabled('variant-flag') + # parent still only has boolean-flag accessed; only_accessed reflects that + 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 + 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 eq(true) + expect(props['$feature/disabled-flag']).to eq(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 + 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 eq(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 + 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, LOCAL_EVAL_ENDPOINT).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 eq(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 eq(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 2a96b0ac37dd2113a82555104a20c7ea852cd1be Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 27 Apr 2026 13:02:26 -0700 Subject: [PATCH 2/2] fix: use positional arg for flag_keys on FeatureFlagsPoller#get_flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keyword arg `flag_keys:` triggered Ruby 2.7's hash-to-kwargs conversion on the existing `get_flags(distinct_id, groups, person_properties, group_properties)` callsite, eating the trailing `group_properties` hash as kwargs and raising `ArgumentError: unknown keyword: :company` for any non-empty value. Positional default avoids the conversion entirely and keeps the existing internal calls source-compatible across Ruby 2.7 → 3.x. Generated-By: PostHog Code Task-Id: fe67a5bd-7d33-4568-9148-39d181660a5a --- lib/posthog/client.rb | 3 +-- lib/posthog/feature_flags.rb | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index ee35c15..fdb2470 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -337,8 +337,7 @@ def evaluate_flags( unless only_evaluate_locally begin flags_response = @feature_flags_poller.get_flags( - distinct_id, groups, person_properties, group_properties, - flag_keys: flag_keys + distinct_id, groups, person_properties, group_properties, flag_keys ) request_id = flags_response[:requestId] evaluated_at = flags_response[:evaluatedAt] diff --git a/lib/posthog/feature_flags.rb b/lib/posthog/feature_flags.rb index df89d60..d5e1431 100644 --- a/lib/posthog/feature_flags.rb +++ b/lib/posthog/feature_flags.rb @@ -105,7 +105,7 @@ def get_feature_payloads( end end - def get_flags(distinct_id, groups = {}, person_properties = {}, group_properties = {}, flag_keys: nil) + def get_flags(distinct_id, groups = {}, person_properties = {}, group_properties = {}, flag_keys = nil) request_data = { distinct_id: distinct_id, groups: groups,