Skip to content
Open
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
14 changes: 14 additions & 0 deletions .changeset/evaluate-flags-api.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions lib/posthog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
170 changes: 164 additions & 6 deletions lib/posthog/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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/<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 @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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/<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::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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading