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