From 9630674b732799ba4c47d46c95c1a41c81ec4a14 Mon Sep 17 00:00:00 2001 From: Felix Livni Date: Thu, 16 Jul 2026 20:51:29 -0700 Subject: [PATCH 1/4] feat(flags): add feature_flags_async_load option to keep definition fetches off the calling thread --- .changeset/async-flag-definitions-load.md | 5 + lib/posthog/client.rb | 23 ++- lib/posthog/feature_flags.rb | 21 ++- public_api_snapshot.txt | 1 + spec/posthog/feature_flags_async_load_spec.rb | 150 ++++++++++++++++++ 5 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 .changeset/async-flag-definitions-load.md create mode 100644 spec/posthog/feature_flags_async_load_spec.rb diff --git a/.changeset/async-flag-definitions-load.md b/.changeset/async-flag-definitions-load.md new file mode 100644 index 0000000..f06fd96 --- /dev/null +++ b/.changeset/async-flag-definitions-load.md @@ -0,0 +1,5 @@ +--- +"posthog-ruby": minor +--- + +feat: 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. diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index f2bdad4..583c4f7 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -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 @@ -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 @@ -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 @@ -787,6 +796,16 @@ def reload_feature_flags @feature_flags_poller.load_feature_flags(true) end + # Whether feature flag definitions for local evaluation have been successfully + # loaded at least once. + # + # @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. # diff --git a/lib/posthog/feature_flags.rb b/lib/posthog/feature_flags.rb index dde847c..59bc5fa 100644 --- a/lib/posthog/feature_flags.rb +++ b/lib/posthog/feature_flags.rb @@ -40,6 +40,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, @@ -48,7 +51,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 || 30 @secret_key = secret_key @@ -66,13 +70,15 @@ def initialize( @quota_limited = Concurrent::AtomicBoolean.new(false) @flags_etag = Concurrent::AtomicReference.new(nil) @flag_definitions_loaded_at = 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 @@ -80,18 +86,23 @@ def initialize( 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 + # Whether flag definitions have been successfully loaded at least once. + def definitions_loaded? + !@flag_definitions_loaded_at.nil? + end + attr_reader :flag_definitions_loaded_at, :feature_flags_by_key def get_feature_variants( diff --git a/public_api_snapshot.txt b/public_api_snapshot.txt index fb26974..4ba9db9 100644 --- a/public_api_snapshot.txt +++ b/public_api_snapshot.txt @@ -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: ...) diff --git a/spec/posthog/feature_flags_async_load_spec.rb b/spec/posthog/feature_flags_async_load_spec.rb new file mode 100644 index 0000000..e1697b5 --- /dev/null +++ b/spec/posthog/feature_flags_async_load_spec.rb @@ -0,0 +1,150 @@ +# 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 + end + end +end From 967c34a04139277b32fd53d4fb68d78c71bfbded Mon Sep 17 00:00:00 2001 From: Felix Livni Date: Thu, 16 Jul 2026 22:56:04 -0700 Subject: [PATCH 2/4] fix(flags): clear flag_definitions_loaded_at on 402 quota response --- lib/posthog/client.rb | 5 +++-- lib/posthog/feature_flags.rb | 5 ++++- spec/posthog/feature_flags_async_load_spec.rb | 12 ++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/posthog/client.rb b/lib/posthog/client.rb index 583c4f7..359cb02 100644 --- a/lib/posthog/client.rb +++ b/lib/posthog/client.rb @@ -796,8 +796,9 @@ def reload_feature_flags @feature_flags_poller.load_feature_flags(true) end - # Whether feature flag definitions for local evaluation have been successfully - # loaded at least once. + # 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? diff --git a/lib/posthog/feature_flags.rb b/lib/posthog/feature_flags.rb index 59bc5fa..0804b5f 100644 --- a/lib/posthog/feature_flags.rb +++ b/lib/posthog/feature_flags.rb @@ -98,7 +98,9 @@ def load_feature_flags(force_reload = false) _load_feature_flags end - # Whether flag definitions have been successfully loaded at least once. + # 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.nil? end @@ -1194,6 +1196,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 = nil @loaded_flags_successfully_once.make_false @quota_limited.make_true return diff --git a/spec/posthog/feature_flags_async_load_spec.rb b/spec/posthog/feature_flags_async_load_spec.rb index e1697b5..56d242e 100644 --- a/spec/posthog/feature_flags_async_load_spec.rb +++ b/spec/posthog/feature_flags_async_load_spec.rb @@ -145,6 +145,18 @@ def monotonic_now 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 From 6d31979b8f7e3d5a94fd6dca4d978d67e6a0f103 Mon Sep 17 00:00:00 2001 From: Felix Livni Date: Thu, 16 Jul 2026 23:38:26 -0700 Subject: [PATCH 3/4] fix(flags): hold flag_definitions_loaded_at in an AtomicReference Written on the poller thread (always, with async load; previously only after a failed initial load) and read from application threads, so it needs a visibility guarantee. --- lib/posthog/feature_flags.rb | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/posthog/feature_flags.rb b/lib/posthog/feature_flags.rb index 0804b5f..427ca98 100644 --- a/lib/posthog/feature_flags.rb +++ b/lib/posthog/feature_flags.rb @@ -69,7 +69,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_definitions_loaded_at = Concurrent::AtomicReference.new(nil) @async_load = async_load @flag_definition_cache_provider = flag_definition_cache_provider @@ -102,10 +102,15 @@ def load_feature_flags(force_reload = false) # successful load, and false again if a quota-limited (402) response # discards them. def definitions_loaded? - !@flag_definitions_loaded_at.nil? + !@flag_definitions_loaded_at.value.nil? end - attr_reader :flag_definitions_loaded_at, :feature_flags_by_key + # 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, @@ -1196,7 +1201,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 = nil + @flag_definitions_loaded_at.value = nil @loaded_flags_successfully_once.make_false @quota_limited.make_true return @@ -1245,7 +1250,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 From 4a9f50b2c9077e43a176f83a32cd8a1b41d865e8 Mon Sep 17 00:00:00 2001 From: Felix Livni Date: Fri, 17 Jul 2026 12:20:45 -0700 Subject: [PATCH 4/4] Remove feat prefix from changeset --- .changeset/async-flag-definitions-load.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/async-flag-definitions-load.md b/.changeset/async-flag-definitions-load.md index f06fd96..4b3108d 100644 --- a/.changeset/async-flag-definitions-load.md +++ b/.changeset/async-flag-definitions-load.md @@ -2,4 +2,4 @@ "posthog-ruby": minor --- -feat: 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. +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.