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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ POSTHOG_PERSONAL_API_KEY=your_personal_api_key_here
# PostHog host URL
# - For PostHog Cloud US: https://us.posthog.com
# - For PostHog Cloud EU: https://eu.posthog.com
# - For self-hosted: your custom URL (e.g., http://localhost:8000)
# - For self-hosted: your custom URL (e.g., http://localhost:8010)
POSTHOG_HOST=https://us.posthog.com
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 3.4.0 - 2025-12-04

1. feat: Add ETag support for feature flag definitions polling ([#84](https://github.com/PostHog/posthog-ruby/pull/84))

## 3.3.3 - 2025-10-22

1. fix: fallback to API for multi-condition flags with static cohorts ([#80](https://github.com/PostHog/posthog-ruby/pull/80))
Expand Down
141 changes: 141 additions & 0 deletions examples/etag_polling_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

# ETag Polling Test Script
#
# Tests ETag support for local evaluation polling by polling every 5 seconds
# and logging the stored flags and ETag behavior.
#
# NOTE: This script accesses internal/private fields (@flags_etag, @feature_flags)
# for debugging purposes. These are not part of the public API and may change
# or break in future versions.
#
# Usage:
# ruby examples/etag_polling_test.rb
#
# Create a .env file with:
# POSTHOG_PROJECT_API_KEY=your_project_api_key
# POSTHOG_PERSONAL_API_KEY=your_personal_api_key
# POSTHOG_HOST=https://us.posthog.com # optional

# Import the library (use local development version)
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
require 'posthog'
require 'net/http'

# Load environment variables from .env file if available
def load_env_file
env_paths = [
File.join(File.dirname(__FILE__), '.env'), # examples/.env
File.join(File.dirname(__FILE__), '..', '.env'), # repo root .env
File.join(Dir.pwd, '.env') # current working directory
]

env_paths.each do |env_path|
next unless File.exist?(env_path)

puts "Loading environment from: #{env_path}\n\n"
File.readlines(env_path).each do |line|
line = line.strip
next if line.empty? || line.start_with?('#')

key, value = line.split('=', 2)
next unless key && value

# Remove surrounding quotes if present
value = value.gsub(/\A["']|["']\z/, '')
ENV[key] = value unless ENV.key?(key)
end
break
end
end

load_env_file

API_KEY = ENV['POSTHOG_PROJECT_API_KEY'] || ''
PERSONAL_API_KEY = ENV['POSTHOG_PERSONAL_API_KEY'] || ''
HOST = ENV['POSTHOG_HOST'] || 'https://us.posthog.com'
POLL_INTERVAL_SECONDS = 5

if API_KEY.empty? || PERSONAL_API_KEY.empty?
warn 'Missing required environment variables.'
warn ''
warn 'Create a .env file with:'
warn ' POSTHOG_PROJECT_API_KEY=your_project_api_key'
warn ' POSTHOG_PERSONAL_API_KEY=your_personal_api_key'
warn ' POSTHOG_HOST=https://us.posthog.com # optional'
exit 1
end

puts '=' * 60
puts 'ETag Polling Test'
puts '=' * 60
puts "Host: #{HOST}"
puts "Poll interval: #{POLL_INTERVAL_SECONDS}s"
puts '=' * 60
puts ''

# Create PostHog client with local evaluation enabled
posthog = PostHog::Client.new(
api_key: API_KEY,
personal_api_key: PERSONAL_API_KEY,
host: HOST,
feature_flags_polling_interval: POLL_INTERVAL_SECONDS,
on_error: proc { |status, msg| puts "Error (#{status}): #{msg}" }
)

# Access the internal poller for debugging
poller = posthog.instance_variable_get(:@feature_flags_poller)

# Enable debug logging to see ETag behavior
posthog.logger.level = Logger::DEBUG

def log_flags(poller)
flags = poller.instance_variable_get(:@feature_flags) || []
etag_ref = poller.instance_variable_get(:@flags_etag)
etag = etag_ref&.value

puts '-' * 40
puts "Stored ETag: #{etag || '(none)'}"
puts "Flag count: #{flags.length}"

if flags.length.positive?
puts 'Flags:'
flags.first(10).each do |flag|
puts " - #{flag[:key]} (active: #{flag[:active]})"
end
puts " ... and #{flags.length - 10} more" if flags.length > 10
end
puts '-' * 40
puts ''
end

puts 'Waiting for initial flag load...'
puts ''

# Wait for initial load
sleep 1
log_flags(poller)

# Set up graceful shutdown
running = true
Signal.trap('INT') do
puts "\nShutting down..."
running = false
end

puts 'Press Ctrl+C to stop'
puts ''
puts 'Polling every 5 seconds. Watch for 304 Not Modified responses...'
puts ''

# Poll and log periodically
while running
sleep POLL_INTERVAL_SECONDS + 1 # Offset to log after each poll
break unless running

log_flags(poller)
end

posthog.shutdown
puts 'Done!'
43 changes: 36 additions & 7 deletions lib/posthog/feature_flags.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def initialize(
@feature_flag_request_timeout_seconds = feature_flag_request_timeout_seconds
@on_error = on_error || proc { |status, error| }
@quota_limited = Concurrent::AtomicBoolean.new(false)
@flags_etag = Concurrent::AtomicReference.new(nil)
@task =
Concurrent::TimerTask.new(
execution_interval: polling_interval
Expand Down Expand Up @@ -840,12 +841,20 @@ def variant_lookup_table(flag)

def _load_feature_flags
begin
res = _request_feature_flag_definitions
res = _request_feature_flag_definitions(etag: @flags_etag.value)
rescue StandardError => e
@on_error.call(-1, e.to_s)
return
end

# Handle 304 Not Modified - flags haven't changed, skip processing
# Only update ETag if the 304 response includes one
if res[:not_modified]
@flags_etag.value = res[:etag] if res[:etag]
logger.debug '[FEATURE FLAGS] Flags not modified (304), using cached data'
return
end

# Handle quota limits with 402 status
if res.is_a?(Hash) && res[:status] == 402
logger.warn(
Expand All @@ -862,6 +871,9 @@ def _load_feature_flags
end

if res.key?(:flags)
# Only update ETag on successful responses with flag data
@flags_etag.value = res[:etag]

@feature_flags = res[:flags] || []
@feature_flags_by_key = {}
@feature_flags.each do |flag|
Expand All @@ -877,13 +889,14 @@ def _load_feature_flags
end
end

def _request_feature_flag_definitions
def _request_feature_flag_definitions(etag: nil)
uri = URI("#{@host}/api/feature_flag/local_evaluation")
uri.query = URI.encode_www_form([['token', @project_api_key], %w[send_cohorts true]])
req = Net::HTTP::Get.new(uri)
req['Authorization'] = "Bearer #{@personal_api_key}"
req['If-None-Match'] = etag if etag

_request(uri, req)
_request(uri, req, nil, include_etag: true)
end

def _request_feature_flag_evaluation(data = {})
Expand All @@ -907,7 +920,7 @@ def _request_remote_config_payload(flag_key)
end

# rubocop:disable Lint/ShadowedException
def _request(uri, request_object, timeout = nil)
def _request(uri, request_object, timeout = nil, include_etag: false)
request_object['User-Agent'] = "posthog-ruby#{PostHog::VERSION}"
request_timeout = timeout || 10

Expand All @@ -919,16 +932,28 @@ def _request(uri, request_object, timeout = nil)
read_timeout: request_timeout
) do |http|
res = http.request(request_object)
status_code = res.code.to_i
etag = include_etag ? res['ETag'] : nil

# Handle 304 Not Modified - return special response indicating no change
if status_code == 304
logger.debug("#{request_object.method} #{_mask_tokens_in_url(uri.to_s)} returned 304 Not Modified")
return { not_modified: true, etag: etag, status: status_code }
end

# Parse response body to hash
begin
response = JSON.parse(res.body, { symbolize_names: true })
# Only add status if response is a hash
response = response.merge({ status: res.code.to_i }) if response.is_a?(Hash)
# Only add status (and etag if requested) if response is a hash
extra_fields = { status: status_code }
extra_fields[:etag] = etag if include_etag
response = response.merge(extra_fields) if response.is_a?(Hash)
return response
rescue JSON::ParserError
# Handle case when response isn't valid JSON
return { error: 'Invalid JSON response', body: res.body, status: res.code.to_i }
error_response = { error: 'Invalid JSON response', body: res.body, status: status_code }
error_response[:etag] = etag if include_etag
return error_response
end
end
rescue Timeout::Error,
Expand All @@ -945,5 +970,9 @@ def _request(uri, request_object, timeout = nil)
end
end
# rubocop:enable Lint/ShadowedException

def _mask_tokens_in_url(url)
url.gsub(/token=([^&]{10})[^&]*/, 'token=\1...')
end
end
end
2 changes: 1 addition & 1 deletion lib/posthog/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module PostHog
VERSION = '3.3.3'
VERSION = '3.4.0'
end
Loading