Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 1 addition & 0 deletions lib/posthog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
189 changes: 174 additions & 15 deletions lib/posthog/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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!

Expand Down Expand Up @@ -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/<key>` 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,
Expand All @@ -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
Expand Down Expand Up @@ -230,25 +241,142 @@ 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/<key>` 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<String>] 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
)
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
Expand Down Expand Up @@ -342,6 +470,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.
Expand Down
Loading
Loading