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
5 changes: 5 additions & 0 deletions .changeset/minimal-flag-called-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-ruby": minor
---

feat: emit minimal `$feature_flag_called` events when the server enables the `minimal_flag_called_events` gate and the evaluated flag has no linked experiment. Minimal events keep a strict allowlist of flag-evaluation properties and strip everything else (context properties, `$feature/<key>`, payloads, system metadata). The gate is read from the top-level `minimalFlagCalledEvents` field of the v2 `/flags` response and the top-level `minimal_flag_called_events` key of the local evaluation definitions payload; when either signal is missing, the full event is sent unchanged.
60 changes: 55 additions & 5 deletions lib/posthog/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,31 @@ class Client
include PostHog::Utils
include PostHog::Logging

# Strict allowlist of properties kept on minimal `$feature_flag_called`
# events (server-gated, non-experiment flags only). Everything else β€”
# context properties, `$feature/<key>`, payloads, system metadata β€” is
# stripped. Includes symbol forms so allowlisted properties supplied
# through context with symbol keys survive.
MINIMAL_FLAG_CALLED_EVENT_PROPERTIES = %w[
$feature_flag
$feature_flag_response
$feature_flag_has_experiment
$feature_flag_id
$feature_flag_version
$feature_flag_reason
$feature_flag_request_id
$feature_flag_evaluated_at
$feature_flag_error
locally_evaluated
$groups
$process_person_profile
$session_id
$is_server
$lib
$lib_version
].flat_map { |key| [key, key.to_sym] }.freeze
private_constant :MINIMAL_FLAG_CALLED_EVENT_PROPERTIES

# Thread-safe tracking of client instances per API key for singleton warnings
@instances_by_api_key = {}
@instances_mutex = Mutex.new
Expand Down Expand Up @@ -253,6 +278,7 @@ def capture(attrs)
return false if @disabled

symbolize_keys! attrs
minimal_flag_called_event = attrs.delete(:_minimal_flag_called_event) == true
enrich_capture_attrs_with_context(attrs)

# Precedence: an explicit `flags` snapshot always wins, regardless of
Expand Down Expand Up @@ -321,7 +347,13 @@ def capture(attrs)
end

attrs[:is_server] = @is_server
enqueue(FieldParser.parse_for_capture(attrs))
message = FieldParser.parse_for_capture(attrs)
# Minimal events are built from the allowlist after full assembly so
# context properties and parser-added metadata can never leak in.
if minimal_flag_called_event
message[:properties] = message[:properties].slice(*MINIMAL_FLAG_CALLED_EVENT_PROPERTIES)
end
enqueue(message)
end

# Captures an exception as an event
Expand Down Expand Up @@ -627,6 +659,11 @@ def evaluate_flags(
evaluated_at = nil
errors_while_computing = false
quota_limited = false
# Server-controlled gate for minimal `$feature_flag_called` events. When
# the snapshot uses a remote /flags response, the response's top-level
# `minimalFlagCalledEvents` field governs; a local-only snapshot reads
# the gate polled with the flag definitions.
minimal_flag_called_events = @feature_flags_poller.minimal_flag_called_events

# Skip the remote `/flags` round-trip when the caller scoped the request
# to a fixed set of `flag_keys` and we've already resolved every one of
Expand All @@ -635,10 +672,16 @@ def evaluate_flags(
all_requested_flags_resolved_locally = flag_keys_set && (flag_keys_set - locally_evaluated_keys).empty?

if !only_evaluate_locally && !all_requested_flags_resolved_locally
# The gate is team-level, so the /flags response gate supersedes the
# poller gate for mixed snapshots β€” both signals come from the same
# server and agree in steady state. When the response omits the gate,
# the whole snapshot fails safe to full events.
minimal_flag_called_events = false
begin
flags_response = @feature_flags_poller.get_flags(
distinct_id, groups, person_properties, group_properties, flag_keys, disable_geoip
)
minimal_flag_called_events = flags_response[:minimalFlagCalledEvents] == true
request_id = flags_response[:requestId]
evaluated_at = flags_response[:evaluatedAt]
errors_while_computing = flags_response[:errorsWhileComputingFlags] == true
Expand Down Expand Up @@ -677,7 +720,8 @@ def evaluate_flags(
evaluated_at: evaluated_at,
flag_definitions_loaded_at: @feature_flags_poller.flag_definitions_loaded_at,
errors_while_computing: errors_while_computing,
quota_limited: quota_limited
quota_limited: quota_limited,
minimal_flag_called_events: minimal_flag_called_events
)
end

Expand Down Expand Up @@ -777,6 +821,7 @@ def get_all_flags_and_payloads(
response.delete(:requestId)
response.delete(:evaluatedAt)
response.delete(:flagDetails)
response.delete(:minimalFlagCalledEvents)
response
end

Expand Down Expand Up @@ -885,7 +930,7 @@ def property_key?(properties, key)
# separate event for each group a user is evaluated under.
def _capture_feature_flag_called_if_needed(
distinct_id: nil, key: nil, response: nil, properties: nil,
groups: nil, disable_geoip: nil
groups: nil, disable_geoip: nil, minimal: false
)
response_repr = response.nil? ? '::null::' : response
groups_repr =
Expand Down Expand Up @@ -915,6 +960,7 @@ def _capture_feature_flag_called_if_needed(
}
msg[:groups] = groups if groups
msg[:disable_geoip] = disable_geoip unless disable_geoip.nil?
msg[:_minimal_flag_called_event] = true if minimal

capture(msg)
end
Expand Down Expand Up @@ -945,7 +991,7 @@ def _get_feature_flag_result(
groups, person_properties, group_properties
)
feature_flag_response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload,
has_experiment =
has_experiment, minimal_flag_called_events =
@feature_flags_poller.get_feature_flag(
key, distinct_id, groups, person_properties, group_properties, only_evaluate_locally
)
Expand All @@ -960,9 +1006,13 @@ 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

# Emit a minimal event only when the server gate is on and the flag is
# known to have no linked experiment. Any missing signal fails safe to
# the full event.
minimal = minimal_flag_called_events == true && has_experiment == false
_capture_feature_flag_called_if_needed(
distinct_id: distinct_id, key: key, response: feature_flag_response,
properties: properties, groups: groups
properties: properties, groups: groups, minimal: minimal
)
end

Expand Down
13 changes: 12 additions & 1 deletion lib/posthog/feature_flag_evaluations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ class FeatureFlagEvaluations
# @param flag_definitions_loaded_at [Time, nil] When local flag definitions were loaded.
# @param errors_while_computing [Boolean] Whether the server reported errors while computing flags.
# @param quota_limited [Boolean] Whether feature flag evaluation was quota limited.
# @param minimal_flag_called_events [Boolean] Server-controlled gate for minimal
# `$feature_flag_called` events.
# @param accessed [Array<String>, Set<String>, nil] Flag keys already accessed by this snapshot.
def initialize(
host: nil,
Expand All @@ -56,6 +58,7 @@ def initialize(
flag_definitions_loaded_at: nil,
errors_while_computing: false,
quota_limited: false,
minimal_flag_called_events: false,
accessed: nil
)
@host = host
Expand All @@ -68,6 +71,7 @@ def initialize(
@flag_definitions_loaded_at = flag_definitions_loaded_at
@errors_while_computing = errors_while_computing
@quota_limited = quota_limited
@minimal_flag_called_events = minimal_flag_called_events == true
@accessed = Set.new(accessed || [])
end

Expand Down Expand Up @@ -188,13 +192,19 @@ def _record_access(key, flag)
errors << 'flag_missing' if flag.nil?
properties['$feature_flag_error'] = errors.join(',') unless errors.empty?

# Emit a minimal event only when the server gate is on and the flag is
# known to have no linked experiment. Any missing signal (unknown flag,
# has_experiment absent) fails safe to the full event.
minimal = @minimal_flag_called_events && !flag.nil? && flag.has_experiment == false

@host.capture_flag_called_event_if_needed.call(
distinct_id: @distinct_id,
key: key,
response: response,
properties: properties,
groups: @groups,
disable_geoip: @disable_geoip
disable_geoip: @disable_geoip,
minimal: minimal
)
end

Expand All @@ -210,6 +220,7 @@ def _clone_with(flags)
flag_definitions_loaded_at: @flag_definitions_loaded_at,
errors_while_computing: @errors_while_computing,
quota_limited: @quota_limited,
minimal_flag_called_events: @minimal_flag_called_events,
accessed: @accessed.dup
)
end
Expand Down
27 changes: 22 additions & 5 deletions lib/posthog/feature_flags.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ def initialize(
@flags_etag = Concurrent::AtomicReference.new(nil)
@flag_definitions_loaded_at = Concurrent::AtomicReference.new(nil)
@async_load = async_load

# Server-controlled gate for minimal `$feature_flag_called` events, read
# from the top-level `minimal_flag_called_events` key of the local
# evaluation definitions payload. false when the server does not send it.
@minimal_flag_called_events = false
@flag_definition_cache_provider = flag_definition_cache_provider
FlagDefinitionCacheProvider.validate!(@flag_definition_cache_provider) if @flag_definition_cache_provider

Expand Down Expand Up @@ -111,7 +114,7 @@ def flag_definitions_loaded_at
@flag_definitions_loaded_at.value
end

attr_reader :feature_flags_by_key
attr_reader :feature_flags_by_key, :minimal_flag_called_events

def get_feature_variants(
distinct_id,
Expand Down Expand Up @@ -245,6 +248,11 @@ def get_feature_flag(
# evaluated flags carry it in the response metadata. nil when the server
# (an older deployment) does not report it.
has_experiment = feature_flag[:has_experiment] if flag_was_locally_evaluated
# Server-controlled gate for minimal `$feature_flag_called` events.
# Locally-evaluated flags read it from the definitions payload; remotely
# evaluated flags read it from the /flags response. nil when the signal
# is unavailable, which fails safe to the full event.
minimal_flag_called_events = @minimal_flag_called_events if flag_was_locally_evaluated

request_id = nil
evaluated_at = nil
Expand Down Expand Up @@ -279,6 +287,7 @@ def get_feature_flag(

flag_detail = flags_data[:flagDetails]&.[](key.to_sym)
has_experiment = flag_detail&.metadata&.has_experiment
minimal_flag_called_events = flags_data[:minimalFlagCalledEvents]

logger.debug "Successfully computed flag remotely: #{key} -> #{response}"
rescue Timeout::Error => e
Expand All @@ -293,7 +302,8 @@ def get_feature_flag(
end
end

[response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload, has_experiment]
[response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload, has_experiment,
minimal_flag_called_events]
end

def get_all_flags(
Expand Down Expand Up @@ -352,6 +362,7 @@ def get_all_flags_and_payloads(
quota_limited = nil
status_code = nil
flag_details = nil
minimal_flag_called_events = nil

if fallback_to_server && !only_evaluate_locally
begin
Expand All @@ -367,6 +378,7 @@ def get_all_flags_and_payloads(

request_id = flags_and_payloads[:requestId]
evaluated_at = flags_and_payloads[:evaluatedAt]
minimal_flag_called_events = flags_and_payloads[:minimalFlagCalledEvents]

# Check if feature_flags are quota limited
if quota_limited&.include?('feature_flags')
Expand Down Expand Up @@ -402,7 +414,8 @@ def get_all_flags_and_payloads(
evaluatedAt: evaluated_at,
errorsWhileComputingFlags: errors_while_computing,
quotaLimited: quota_limited,
status: status_code
status: status_code,
minimalFlagCalledEvents: minimal_flag_called_events
}
end

Expand Down Expand Up @@ -1203,6 +1216,7 @@ def _fetch_and_apply_flag_definitions
@group_type_mapping = Concurrent::Hash.new
@cohorts = Concurrent::Hash.new
@flag_definitions_loaded_at.value = nil
@minimal_flag_called_events = false
@loaded_flags_successfully_once.make_false
@quota_limited.make_true
return
Expand All @@ -1226,7 +1240,8 @@ def _store_in_cache_provider
data = {
flags: @feature_flags.to_a,
group_type_mapping: @group_type_mapping.to_h,
cohorts: @cohorts.to_h
cohorts: @cohorts.to_h,
minimal_flag_called_events: @minimal_flag_called_events
}
@flag_definition_cache_provider.on_flag_definitions_received(data)
rescue StandardError => e
Expand All @@ -1238,6 +1253,7 @@ def _apply_flag_definitions(data)
flags = get_by_symbol_or_string_key(data, 'flags') || []
group_type_mapping = get_by_symbol_or_string_key(data, 'group_type_mapping') || {}
cohorts = get_by_symbol_or_string_key(data, 'cohorts') || {}
minimal_flag_called_events = get_by_symbol_or_string_key(data, 'minimal_flag_called_events')

@feature_flags = Concurrent::Array.new(flags.map { |f| deep_symbolize_keys(f) })

Expand All @@ -1249,6 +1265,7 @@ def _apply_flag_definitions(data)

@group_type_mapping = Concurrent::Hash[deep_symbolize_keys(group_type_mapping)]
@cohorts = Concurrent::Hash[deep_symbolize_keys(cohorts)]
@minimal_flag_called_events = minimal_flag_called_events == true

logger.debug "Loaded #{@feature_flags.length} feature flags and #{@cohorts.length} cohorts"
@flag_definitions_loaded_at.value = (Time.now.to_f * 1000).to_i
Expand Down
14 changes: 8 additions & 6 deletions lib/posthog/flag_definition_cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ module PostHog
#
# @!method flag_definitions
# Retrieve cached flag definitions. Return a Hash with +:flags+,
# +:group_type_mapping+, and +:cohorts+ keys, or +nil+ if the cache
# is empty. Returning +nil+ triggers an API fetch when no flags are
# loaded yet (emergency fallback).
# +:group_type_mapping+, +:cohorts+, and +:minimal_flag_called_events+
# keys, or +nil+ if the cache is empty. Returning +nil+ triggers an API
# fetch when no flags are loaded yet (emergency fallback). Providers
# written before +:minimal_flag_called_events+ existed continue to work;
# a missing key is treated as +false+.
# @return [Hash, nil]
#
# @!method should_fetch_flag_definitions?
Expand All @@ -27,9 +29,9 @@ module PostHog
#
# @!method on_flag_definitions_received(data)
# Called after successfully fetching new definitions from the API.
# +data+ is a Hash with +:flags+, +:group_type_mapping+, and +:cohorts+
# keys (plain Ruby types, not Concurrent:: wrappers). Store it in your
# external cache.
# +data+ is a Hash with +:flags+, +:group_type_mapping+, +:cohorts+, and
# +:minimal_flag_called_events+ keys (plain Ruby types, not Concurrent::
# wrappers). Store it in your external cache.
# @param data [Hash]
# @return [void]
#
Expand Down
18 changes: 18 additions & 0 deletions spec/posthog/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,24 @@ module PostHog
)
end

it 'does not minimize when a caller supplies the minimal marker inside properties' do
client.capture(
distinct_id: 'user',
event: '$feature_flag_called',
properties: {
'_minimal_flag_called_event' => true,
'$feature_flag' => 'test-flag',
'custom_prop' => 'value'
}
)

message = client.dequeue_last_message
# Both properties would be stripped by the allowlist if the smuggled
# marker had triggered minimization.
expect(message[:properties]['custom_prop']).to eq('value')
expect(message[:properties]['_minimal_flag_called_event']).to be true
end

it 'generates a personless distinct_id without an explicit or context distinct_id' do
client.capture(event: 'Event')

Expand Down
Loading
Loading