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/cache-consumers-without-secret.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-ruby": patch
---

Allow external flag definition cache consumers to evaluate flags locally and refresh cached definitions without a secret key.
11 changes: 8 additions & 3 deletions examples/redis_flag_cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
# flag_definition_cache_provider: cache
# )
#
# For cache-only readers, omit secret_key and use the same provider. The SDK
# reads cached definitions without calling the leader-election method. Keep
# at least one worker with secret_key configured to publish fresh definitions.
#
# Requirements:
# gem install redis

Expand All @@ -33,8 +37,8 @@
# from making N redundant API calls.
#
# Uses leader election:
# - One instance "wins" and becomes responsible for fetching
# - Other instances read from the shared cache
# - One instance with secret_key "wins" and becomes responsible for fetching
# - Other instances read from the shared cache; workers without secret_key do not compete
# - If the leader dies, the lock expires (TTL) and another instance takes over
#
# Uses Lua scripts for atomic operations, following Redis distributed lock
Expand Down Expand Up @@ -90,7 +94,8 @@ def flag_definitions

# Determine if this instance should fetch flag definitions from PostHog.
#
# Atomically either acquires the lock (if free) or extends it (if we own it).
# Called by the SDK only for workers with secret_key configured. Atomically
# either acquires the lock (if free) or extends it (if we own it).
#
# @return [Boolean] true if this instance is the leader and should fetch
def should_fetch_flag_definitions?
Expand Down
13 changes: 7 additions & 6 deletions lib/posthog/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,9 @@ def _decrement_instance_count(api_key)

# @param opts [Hash] Client configuration.
# @option opts [String, nil] :api_key Your project's API key. Missing or blank values disable the client.
# @option opts [String, nil] :secret_key The credential used for local feature flag evaluation and remote
# config. Accepts either a Personal API Key (`phx_...`) or a Project Secret API Key (`phs_...`). Required
# for local feature flag evaluation.
# @option opts [String, nil] :secret_key The credential used to fetch local feature flag definitions and remote
# config. Accepts either a Personal API Key (`phx_...`) or a Project Secret API Key (`phs_...`). Not required
# for local evaluation when definitions are read from +:flag_definition_cache_provider+.
# @option opts [String, nil] :personal_api_key
# @deprecated Use +:secret_key+ instead. Retained as an alias; when both are supplied, +:secret_key+ wins.
# @option opts [String] :host Fully qualified hostname of the PostHog server. Defaults to `https://us.i.posthog.com`.
Expand Down Expand Up @@ -180,6 +180,7 @@ def initialize(opts = {})
@feature_flags_poller = nil
@secret_key = secret_key
@personal_api_key = secret_key
@flag_definition_cache_provider = opts[:flag_definition_cache_provider]

if @disabled && !opts[:silence_disabled_client_error]
logger.error('api_key is missing or empty after trimming whitespace; check your project API key')
Expand Down Expand Up @@ -214,7 +215,7 @@ def initialize(opts = {})
opts[:host],
opts[:feature_flag_request_timeout_seconds] || Defaults::FeatureFlags::FLAG_REQUEST_TIMEOUT_SECONDS,
opts[:on_error],
flag_definition_cache_provider: opts[:flag_definition_cache_provider],
flag_definition_cache_provider: @flag_definition_cache_provider,
feature_flag_request_max_retries: opts[:feature_flag_request_max_retries],
async_load: opts[:feature_flags_async_load] == true,
user_agent: @headers['User-Agent'],
Expand Down Expand Up @@ -883,9 +884,9 @@ def get_all_flags_and_payloads(
def reload_feature_flags
return if @disabled

unless @secret_key
unless @secret_key || @flag_definition_cache_provider
logger.error(
'You need to specify a secret_key to locally evaluate feature flags'
'You need to specify a secret_key or flag_definition_cache_provider to locally evaluate feature flags'
)
return
end
Expand Down
18 changes: 14 additions & 4 deletions lib/posthog/feature_flags.rb
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,9 @@ def initialize(
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'
# Local evaluation can load definitions from either the API or an external cache.
if @secret_key.nil? && @flag_definition_cache_provider.nil?
logger.info 'No secret_key or flag_definition_cache_provider provided, disabling local evaluation'
@loaded_flags_successfully_once.make_true
else
# load once synchronously before timer, unless @async_load
Expand Down Expand Up @@ -1260,7 +1260,8 @@ def _load_feature_flags

if @flag_definition_cache_provider
begin
should_fetch = @flag_definition_cache_provider.should_fetch_flag_definitions?
# Cache-only readers must not acquire or renew fetch leadership.
should_fetch = @secret_key && @flag_definition_cache_provider.should_fetch_flag_definitions?
rescue StandardError => e
logger.error("[FEATURE FLAGS] Cache provider should_fetch error: #{e}")
should_fetch = true
Expand All @@ -1271,6 +1272,10 @@ def _load_feature_flags
begin
cached_data = @flag_definition_cache_provider.flag_definitions
if cached_data
unless cached_data.is_a?(Hash) && get_by_symbol_or_string_key(cached_data, 'flags').is_a?(Array)
raise ArgumentError, 'Cached flag definitions must contain a flags array'
end

logger.debug '[FEATURE FLAGS] Using cached flag definitions from external cache'
_apply_flag_definitions(cached_data)
return
Expand All @@ -1288,6 +1293,11 @@ def _load_feature_flags
end

def _fetch_and_apply_flag_definitions
unless @secret_key
Comment thread
dustinbyrne marked this conversation as resolved.
logger.warn '[FEATURE FLAGS] A secret_key is required to fetch flag definitions from PostHog'
return
end

begin
res = _request_feature_flag_definitions(etag: @flags_etag.value)
rescue StandardError => e
Expand Down
15 changes: 11 additions & 4 deletions lib/posthog/flag_definition_cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ module PostHog
#
# Enables multi-worker environments (Kubernetes, load-balanced servers,
# serverless functions) to share flag definitions via an external cache,
# reducing redundant API calls.
# reducing redundant API calls. Cache consumers need only the project API key;
# +:secret_key+ is required only for workers that fetch definitions from PostHog.
#
# Implement the four required methods on any object and pass it as the
# +:flag_definition_cache_provider+ option when creating a {Client}.
Expand All @@ -16,7 +17,7 @@ module PostHog
# Retrieve cached flag definitions. Return a Hash with +:flags+,
# +:group_type_mapping+, +:cohorts+, +:minimal_flag_called_events+, and +:property_matching_version+
# keys, or +nil+ if the cache is empty. Returning +nil+ triggers an API
# fetch when no flags are loaded yet (emergency fallback). Providers
# fetch when no flags are loaded yet and +:secret_key+ is configured (emergency fallback). Providers
# written before +:minimal_flag_called_events+ existed continue to work;
# a missing key is treated as +false+. Preserve +:property_matching_version+
# with the definitions: exactly +2+ selects explicit equality; missing/1
Expand All @@ -27,7 +28,9 @@ module PostHog
# @!method should_fetch_flag_definitions?
# Return +true+ if this instance should fetch new definitions from the
# API, +false+ to read from cache instead. Use for distributed lock
# coordination so only one worker fetches at a time.
# coordination so only one worker fetches at a time. Called only when
# +:secret_key+ is configured. Without it, the SDK reads +flag_definitions+
# directly without acquiring or renewing fetch leadership.
# @return [Boolean]
#
# @!method on_flag_definitions_received(data)
Expand All @@ -53,12 +56,16 @@ module PostHog
# - +on_flag_definitions_received+ errors are logged; flags remain in memory
# - +shutdown+ errors are logged; shutdown continues
#
# API fallback requires +:secret_key+. Without it, the SDK logs a warning and
# keeps any previously loaded definitions. Cache-only readers skip the fetch
# decision and never publish. Polling and manual reloads continue reading the
# cache so consumers can pick up later updates.
#
# == Example
#
# cache = RedisFlagCache.new(redis, service_key: 'my-service')
# client = PostHog::Client.new(
# api_key: '<project_api_key>',
# secret_key: '<secret_key>',
# flag_definition_cache_provider: cache
# )
#
Expand Down
30 changes: 18 additions & 12 deletions spec/posthog/feature_flags_property_matching_version_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -200,24 +200,29 @@ def expect_local_flags(poller, expected)
{ status: 200, body: 'invalid json' },
{ status: 200, body: definitions.to_json }
)
poller._load_feature_flags
expect_local_flags(poller, false)
poller._load_feature_flags
expect_local_flags(poller, false)
poller._load_feature_flags
expect_local_flags(poller, false)
poller._load_feature_flags
expect_local_flags(poller, false)
poller._load_feature_flags
expect_local_flags(poller, true)
authenticated_poller = described_class.new(60, API_KEY, API_KEY, 'https://us.i.posthog.com', 3)
expect_local_flags(authenticated_poller, false)
authenticated_poller._load_feature_flags
expect_local_flags(authenticated_poller, false)
authenticated_poller._load_feature_flags
expect_local_flags(authenticated_poller, false)
authenticated_poller._load_feature_flags
expect_local_flags(authenticated_poller, false)
authenticated_poller._load_feature_flags
expect_local_flags(authenticated_poller, true)
ensure
authenticated_poller&.shutdown_poller
end

it 'round trips version through JSON external caches and defaults older entries to legacy' do
provider = double('cache', should_fetch_flag_definitions?: true, shutdown: nil)
allow(provider).to receive(:flag_definitions)
stored = nil
allow(provider).to receive(:on_flag_definitions_received) { |data| stored = JSON.parse(JSON.generate(data)) }
poller.instance_variable_set(:@flag_definition_cache_provider, provider)
stub_request(:get, url).to_return(status: 200, body: definitions.to_json)
authenticated_poller = described_class.new(
60, API_KEY, API_KEY, 'https://us.i.posthog.com', 3, flag_definition_cache_provider: provider
)
cached_poller = described_class.new(60, nil, API_KEY, 'https://us.i.posthog.com', 3)
cached_poller.instance_variable_set(:@flag_definition_cache_provider, provider)
allow(provider).to receive(:flag_definitions) { stored }
Expand All @@ -227,7 +232,7 @@ def expect_local_flags(poller, expected)
stub_request(:get, url).to_return(
status: 200, body: definitions.merge(property_matching_version: version).to_json
)
poller._load_feature_flags
authenticated_poller._load_feature_flags
expect(stored['property_matching_version']).to eq(version)
allow(provider).to receive(:should_fetch_flag_definitions?).and_return(false)
cached_poller._load_feature_flags
Expand All @@ -238,6 +243,7 @@ def expect_local_flags(poller, expected)
cached_poller._load_feature_flags
expect_local_flags(cached_poller, true)
ensure
authenticated_poller&.shutdown_poller
cached_poller&.shutdown_poller
end
end
Loading
Loading