Skip to content
Merged
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/async-flag-definitions-load.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-ruby": minor
---

Add a `feature_flags_async_load` client option that keeps feature flag definition fetches off the calling thread. With it enabled the constructor no longer blocks on the initial `/flags/definitions` request; the poller fetches immediately on boot (the timer's first tick) and, if that fails (e.g. PostHog unreachable), keeps retrying on its regular polling interval instead of re-fetching inline on evaluation calls. Until the first load succeeds, local evaluation treats definitions as absent. Also adds `Client#feature_flags_loaded?` so callers can distinguish "flag is off or unknown" from "definitions not loaded yet". Defaults to off; the existing synchronous behavior is unchanged.
24 changes: 22 additions & 2 deletions lib/posthog/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ def _decrement_instance_count(api_key)
# @option opts [Integer] :feature_flag_request_max_retries How many times to retry a flag request after a
# transient network error. Each retry sleeps on the calling thread before retrying, so this adds to
# worst-case latency. Defaults to 1. Set to 0 to disable retrying.
# @option opts [Boolean] :feature_flags_async_load +true+ to fetch feature flag definitions for local
# evaluation only on the poller's background thread instead of the calling thread. The constructor
# returns without waiting for definitions: the poller fetches immediately on boot and, if that fails,
# keeps retrying on its regular polling interval until a load succeeds. Until then local evaluation
# treats definitions as absent (check {#feature_flags_loaded?}), so evaluations return nil with
# +only_evaluate_locally: true+, or fall back to the remote flags endpoint (which still runs on the
# calling thread) without it. Defaults to +false+.
# @option opts [Proc] :before_send A callback that receives the event hash and should return either a modified
# hash to be sent to PostHog or nil to prevent the event from being sent. e.g. `before_send: ->(event) { event }`.
# @option opts [Boolean] :disable_singleton_warning +true+ to suppress the warning when multiple clients share
Expand Down Expand Up @@ -162,7 +169,8 @@ def initialize(opts = {})
opts[:feature_flag_request_timeout_seconds] || Defaults::FeatureFlags::FLAG_REQUEST_TIMEOUT_SECONDS,
opts[:on_error],
flag_definition_cache_provider: opts[:flag_definition_cache_provider],
feature_flag_request_max_retries: opts[:feature_flag_request_max_retries]
feature_flag_request_max_retries: opts[:feature_flag_request_max_retries],
async_load: opts[:feature_flags_async_load] == true
)
end

Expand Down Expand Up @@ -772,7 +780,8 @@ def get_all_flags_and_payloads(
response
end

# Reload locally cached feature flag definitions.
# Reload locally cached feature flag definitions synchronously on the
# calling thread.
#
# @return [void]
def reload_feature_flags
Expand All @@ -787,6 +796,17 @@ def reload_feature_flags
@feature_flags_poller.load_feature_flags(true)
end

# Whether feature flag definitions for local evaluation are currently loaded.
# False until the first successful load, and false again if a quota-limited
# (402) response discards the definitions.
#
# @return [Boolean]
def feature_flags_loaded?
return false if @disabled

@feature_flags_poller.definitions_loaded?
end

# Whether the client will actually send events. It is disabled when the
# api_key is missing or blank, in which case every capture call no-ops.
#
Expand Down
35 changes: 27 additions & 8 deletions lib/posthog/feature_flags.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ class FeatureFlagsPoller
# @param feature_flag_request_max_retries [Integer, nil] Retries after a transient network error or retryable
# HTTP response status on a flag request. Defaults to {Defaults::FeatureFlags::FLAG_REQUEST_MAX_RETRIES}.
# Set to 0 to disable retrying.
# @param async_load [Boolean] When true, flag definitions are fetched only on the poller thread: an
# immediate first tick at construction, then the regular polling cadence, which keeps retrying until a
# load succeeds.
def initialize(
polling_interval,
secret_key,
Expand All @@ -49,7 +52,8 @@ def initialize(
feature_flag_request_timeout_seconds,
on_error = nil,
flag_definition_cache_provider: nil,
feature_flag_request_max_retries: nil
feature_flag_request_max_retries: nil,
async_load: false
)
@polling_interval = polling_interval || Defaults::FeatureFlags::POLLING_INTERVAL_SECONDS
@secret_key = secret_key
Expand All @@ -66,34 +70,48 @@ 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_definitions_loaded_at = Concurrent::AtomicReference.new(nil)
@async_load = async_load

@flag_definition_cache_provider = flag_definition_cache_provider
FlagDefinitionCacheProvider.validate!(@flag_definition_cache_provider) if @flag_definition_cache_provider

@task =
Concurrent::TimerTask.new(
execution_interval: @polling_interval
execution_interval: @polling_interval,
run_now: @async_load
) { _load_feature_flags }

# If no secret_key, disable local evaluation & thus polling for definitions
if @secret_key.nil?
logger.info 'No secret_key provided, disabling local evaluation'
@loaded_flags_successfully_once.make_true
else
# load once before timer
load_feature_flags
# load once synchronously before timer, unless @async_load
load_feature_flags unless @async_load
@task.execute
end
end

def load_feature_flags(force_reload = false)
return unless @loaded_flags_successfully_once.false? || force_reload
return if (@loaded_flags_successfully_once.true? || @async_load) && !force_reload

_load_feature_flags
end

attr_reader :flag_definitions_loaded_at, :feature_flags_by_key
# Whether flag definitions are currently loaded. False until the first
# successful load, and false again if a quota-limited (402) response
# discards them.
def definitions_loaded?
!@flag_definitions_loaded_at.value.nil?
end

# Epoch milliseconds of the last successful definitions load, or nil.
def flag_definitions_loaded_at
@flag_definitions_loaded_at.value
end

attr_reader :feature_flags_by_key

def get_feature_variants(
distinct_id,
Expand Down Expand Up @@ -1184,6 +1202,7 @@ def _fetch_and_apply_flag_definitions
@feature_flags_by_key = {}
@group_type_mapping = Concurrent::Hash.new
@cohorts = Concurrent::Hash.new
@flag_definitions_loaded_at.value = nil
@loaded_flags_successfully_once.make_false
@quota_limited.make_true
return
Expand Down Expand Up @@ -1232,7 +1251,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
@flag_definitions_loaded_at.value = (Time.now.to_f * 1000).to_i
@loaded_flags_successfully_once.make_true if @loaded_flags_successfully_once.false?
end

Expand Down
1 change: 1 addition & 0 deletions public_api_snapshot.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ instance_method PostHog::Client#clear()
instance_method PostHog::Client#dequeue_last_message()
instance_method PostHog::Client#enabled?()
instance_method PostHog::Client#evaluate_flags(distinct_id, groups: ..., person_properties: ..., group_properties: ..., only_evaluate_locally: ..., disable_geoip: ..., flag_keys: ...)
instance_method PostHog::Client#feature_flags_loaded?()
instance_method PostHog::Client#flush()
instance_method PostHog::Client#get_all_flags(distinct_id, groups: ..., person_properties: ..., group_properties: ..., only_evaluate_locally: ...)
instance_method PostHog::Client#get_all_flags_and_payloads(distinct_id, groups: ..., person_properties: ..., group_properties: ..., only_evaluate_locally: ...)
Expand Down
162 changes: 162 additions & 0 deletions spec/posthog/feature_flags_async_load_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# frozen_string_literal: true

require 'spec_helper'
require 'posthog/client'

module PostHog
describe 'feature_flags_async_load' do
let(:definitions_endpoint) { 'https://us.i.posthog.com/flags/definitions?token=testsecret&send_cohorts=true' }
let(:beta_flag_definition) do
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
filters: { groups: [{ properties: [], rollout_percentage: 100 }] }
}
end
let(:definitions_body) { { flags: [beta_flag_definition] }.to_json }

# Stop the poller so it doesn't keep hitting (reset) WebMock stubs for the
# rest of the suite.
after { @client&.shutdown }

def build_client(**opts)
@client = Client.new(
api_key: API_KEY,
secret_key: API_KEY,
test_mode: true,
feature_flag_request_max_retries: 0,
feature_flags_async_load: true,
**opts
)
end

def local_flag_value(client, key = 'beta-feature')
client.evaluate_flags('distinct-id', only_evaluate_locally: true).get_flag(key)
end

def monotonic_now
Process.clock_gettime(Process::CLOCK_MONOTONIC)
end

describe 'Client.new' do
it 'returns immediately, fetching flag definitions asynchronously' do
stub_request(:get, definitions_endpoint).to_return do
sleep 1
{ status: 200, body: definitions_body }
end

started = monotonic_now
client = build_client
elapsed = monotonic_now - started

expect(elapsed).to be < 0.5

# no flag definitions yet
expect(client.feature_flags_loaded?).to be(false)

# flag definitions loaded later, asynchronously
eventually { expect(client.feature_flags_loaded?).to be(true) }
expect(local_flag_value(client)).to be(true)
end

it 'keeps the default synchronous load when the option is not set' do
stub_request(:get, definitions_endpoint).to_return do
sleep 0.3
{ status: 200, body: definitions_body }
end

started = monotonic_now
client = @client = Client.new(api_key: API_KEY, secret_key: API_KEY, test_mode: true)
elapsed = monotonic_now - started

expect(elapsed).to be >= 0.3
expect(client.feature_flags_loaded?).to be(true)
expect(local_flag_value(client)).to be(true)
end
end

describe 'Client#evaluate_flags before definitions have loaded' do
it 'returns nil without blocking or fetching definitions' do
fetches_started = Concurrent::AtomicFixnum.new(0)
stub_request(:get, definitions_endpoint).to_return do
fetches_started.increment
sleep 1
{ status: 200, body: definitions_body }
end

client = build_client
eventually { expect(fetches_started.value).to eq(1) }

started = monotonic_now
values = Array.new(3) { local_flag_value(client) }
elapsed = monotonic_now - started

# the poller's initial load is still sleeping in the stub
expect(values).to eq([nil, nil, nil])
expect(elapsed).to be < 0.5
expect(fetches_started.value).to eq(1)
end
end

describe 'definitions loading, when the initial load fails' do
it 'recovers on the polling cadence' do
attempts = Concurrent::AtomicFixnum.new(0)
stub_request(:get, definitions_endpoint).to_return do
if attempts.increment == 1
{ status: 500, body: 'error' }
else
{ status: 200, body: definitions_body }
end
end

client = build_client(feature_flags_polling_interval: 0.2)

expect(local_flag_value(client)).to be_nil
eventually { expect(client.feature_flags_loaded?).to be(true) }
expect(local_flag_value(client)).to be(true)
expect(attempts.value).to be >= 2
end
end

describe 'Client#reload_feature_flags' do
it 'still fetches synchronously on the calling thread' do
stub_request(:get, definitions_endpoint).to_return(status: 200, body: definitions_body)
client = build_client
eventually { expect(client.feature_flags_loaded?).to be(true) }

stub_request(:get, definitions_endpoint).to_return do
sleep 0.3
{ status: 200, body: { flags: [beta_flag_definition.merge(key: 'newer-feature')] }.to_json }
end

started = monotonic_now
client.reload_feature_flags
elapsed = monotonic_now - started

expect(elapsed).to be >= 0.3
expect(local_flag_value(client, 'newer-feature')).to be(true)
end
end

describe 'Client#feature_flags_loaded?' do
it 'is false without a secret_key' do
client = @client = Client.new(api_key: API_KEY, test_mode: true)
expect(client.feature_flags_loaded?).to be(false)
end

it 'becomes false again when a 402 quota-limited response discards the definitions' do
stub_request(:get, definitions_endpoint).to_return(status: 200, body: definitions_body)
client = build_client
eventually { expect(client.feature_flags_loaded?).to be(true) }

stub_request(:get, definitions_endpoint)
.to_return(status: 402, body: { error: 'quota_limit_exceeded' }.to_json)
client.reload_feature_flags

expect(client.feature_flags_loaded?).to be(false)
end
end
end
end
Loading