diff --git a/.env.example b/.env.example index 3bef930..ef152e1 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 18fc048..11e1a37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/examples/etag_polling_test.rb b/examples/etag_polling_test.rb new file mode 100755 index 0000000..4e197d5 --- /dev/null +++ b/examples/etag_polling_test.rb @@ -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!' diff --git a/lib/posthog/feature_flags.rb b/lib/posthog/feature_flags.rb index 100aa92..8e15898 100644 --- a/lib/posthog/feature_flags.rb +++ b/lib/posthog/feature_flags.rb @@ -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 @@ -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( @@ -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| @@ -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 = {}) @@ -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 @@ -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, @@ -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 diff --git a/lib/posthog/version.rb b/lib/posthog/version.rb index a0db020..c75d9eb 100644 --- a/lib/posthog/version.rb +++ b/lib/posthog/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module PostHog - VERSION = '3.3.3' + VERSION = '3.4.0' end diff --git a/spec/posthog/flags_spec.rb b/spec/posthog/flags_spec.rb index fda8d19..b7771ec 100644 --- a/spec/posthog/flags_spec.rb +++ b/spec/posthog/flags_spec.rb @@ -1619,4 +1619,221 @@ def stub_feature_flags(flags) end end end + + describe 'FeatureFlagsPoller ETag support' do + let(:feature_flag_endpoint) { 'https://app.posthog.com/api/feature_flag/local_evaluation?token=testsecret&send_cohorts=true' } + let(:client) { Client.new(api_key: API_KEY, personal_api_key: API_KEY, test_mode: true) } + let(:poller) { client.instance_variable_get(:@feature_flags_poller) } + + describe 'load_feature_flags with ETag support' do + it 'stores ETag from initial response' do + stub_request(:get, feature_flag_endpoint) + .to_return( + status: 200, + body: { flags: [{ id: 1, key: 'beta-feature', active: true }], group_type_mapping: {}, + cohorts: {} }.to_json, + headers: { 'ETag' => '"abc123"' } + ) + + poller.load_feature_flags(true) + + expect(poller.instance_variable_get(:@flags_etag).value).to eq('"abc123"') + expect(poller.instance_variable_get(:@feature_flags).length).to eq(1) + end + + it 'sends If-None-Match header on subsequent requests' do + # Use response sequence - first response without If-None-Match check, second with + beta_flags = { flags: [{ id: 1, key: 'beta-feature', active: true }], + group_type_mapping: {}, cohorts: {} } + new_flags = { flags: [{ id: 1, key: 'new-feature', active: true }], + group_type_mapping: {}, cohorts: {} } + stub_request(:get, feature_flag_endpoint) + .to_return( + { status: 200, body: beta_flags.to_json, headers: { 'ETag' => '"initial-etag"' } }, + { status: 200, body: new_flags.to_json, headers: { 'ETag' => '"new-etag"' } } + ) + + poller.load_feature_flags(true) + poller.load_feature_flags(true) + + expect(WebMock).to have_requested(:get, feature_flag_endpoint) + .with(headers: { 'If-None-Match' => '"initial-etag"' }).once + end + + it 'handles 304 Not Modified response and preserves cached flags' do + beta_flags = { flags: [{ id: 1, key: 'beta-feature', active: true }], + group_type_mapping: { '0' => 'company' }, cohorts: {} } + stub_request(:get, feature_flag_endpoint) + .to_return( + { status: 200, body: beta_flags.to_json, headers: { 'ETag' => '"test-etag"' } }, + { status: 304, body: '', headers: { 'ETag' => '"test-etag"' } } + ) + + poller.load_feature_flags(true) + + # Verify initial flags are loaded + expect(poller.instance_variable_get(:@feature_flags).length).to eq(1) + expect(poller.instance_variable_get(:@feature_flags)[0][:key]).to eq('beta-feature') + # The JSON parser symbolizes keys, so compare with symbol keys + expect(poller.instance_variable_get(:@group_type_mapping)).to eq({ '0': 'company' }) + + poller.load_feature_flags(true) + + # Flags should still be the same (not cleared) + expect(poller.instance_variable_get(:@feature_flags).length).to eq(1) + expect(poller.instance_variable_get(:@feature_flags)[0][:key]).to eq('beta-feature') + expect(poller.instance_variable_get(:@group_type_mapping)).to eq({ '0': 'company' }) + end + + it 'updates ETag when flags change' do + # Need 3 responses: 1 for client initialization, 2 for the test + empty_flags = { flags: [], group_type_mapping: {}, cohorts: {} } + flag_v1 = { flags: [{ id: 1, key: 'flag-v1', active: true }], + group_type_mapping: {}, cohorts: {} } + flag_v2 = { flags: [{ id: 1, key: 'flag-v2', active: true }], + group_type_mapping: {}, cohorts: {} } + stub_request(:get, feature_flag_endpoint) + .to_return( + { status: 200, body: empty_flags.to_json }, + { status: 200, body: flag_v1.to_json, headers: { 'ETag' => '"etag-v1"' } }, + { status: 200, body: flag_v2.to_json, headers: { 'ETag' => '"etag-v2"' } } + ) + + poller.load_feature_flags(true) + expect(poller.instance_variable_get(:@flags_etag).value).to eq('"etag-v1"') + + poller.load_feature_flags(true) + + expect(poller.instance_variable_get(:@flags_etag).value).to eq('"etag-v2"') + expect(poller.instance_variable_get(:@feature_flags)[0][:key]).to eq('flag-v2') + end + + it 'clears ETag when server stops sending it' do + # Need 3 responses: 1 for client initialization, 2 for the test + empty_flags = { flags: [], group_type_mapping: {}, cohorts: {} } + flag_v1 = { flags: [{ id: 1, key: 'flag-v1', active: true }], + group_type_mapping: {}, cohorts: {} } + flag_v2 = { flags: [{ id: 1, key: 'flag-v2', active: true }], + group_type_mapping: {}, cohorts: {} } + stub_request(:get, feature_flag_endpoint) + .to_return( + { status: 200, body: empty_flags.to_json }, + { status: 200, body: flag_v1.to_json, headers: { 'ETag' => '"etag-v1"' } }, + { status: 200, body: flag_v2.to_json } + ) + + poller.load_feature_flags(true) + expect(poller.instance_variable_get(:@flags_etag).value).to eq('"etag-v1"') + + poller.load_feature_flags(true) + + expect(poller.instance_variable_get(:@flags_etag).value).to be_nil + expect(poller.instance_variable_get(:@feature_flags)[0][:key]).to eq('flag-v2') + end + + it 'handles 304 without ETag header and preserves existing ETag' do + beta_flags = { flags: [{ id: 1, key: 'beta-feature', active: true }], + group_type_mapping: {}, cohorts: {} } + stub_request(:get, feature_flag_endpoint) + .to_return( + { status: 200, body: beta_flags.to_json, headers: { 'ETag' => '"original-etag"' } }, + { status: 304, body: '' } + ) + + poller.load_feature_flags(true) + + poller.load_feature_flags(true) + + # ETag should be preserved since server returned 304 (even without new ETag) + expect(poller.instance_variable_get(:@flags_etag).value).to eq('"original-etag"') + # And flags should be preserved + expect(poller.instance_variable_get(:@feature_flags).length).to eq(1) + end + + it 'updates ETag when 304 response includes a new ETag' do + # Need 3 responses: 1 for client initialization, 2 for the test + empty_flags = { flags: [], group_type_mapping: {}, cohorts: {} } + beta_flags = { flags: [{ id: 1, key: 'beta-feature', active: true }], + group_type_mapping: {}, cohorts: {} } + stub_request(:get, feature_flag_endpoint) + .to_return( + { status: 200, body: empty_flags.to_json }, + { status: 200, body: beta_flags.to_json, headers: { 'ETag' => '"original-etag"' } }, + { status: 304, body: '', headers: { 'ETag' => '"updated-etag"' } } + ) + + poller.load_feature_flags(true) + expect(poller.instance_variable_get(:@flags_etag).value).to eq('"original-etag"') + + poller.load_feature_flags(true) + + # ETag should be updated to the new value from 304 response + expect(poller.instance_variable_get(:@flags_etag).value).to eq('"updated-etag"') + # And flags should be preserved + expect(poller.instance_variable_get(:@feature_flags).length).to eq(1) + end + + it 'preserves ETag when server returns an error response' do + # Need 3 responses: 1 for client initialization, 2 for the test + empty_flags = { flags: [], group_type_mapping: {}, cohorts: {} } + beta_flags = { flags: [{ id: 1, key: 'beta-feature', active: true }], + group_type_mapping: {}, cohorts: {} } + stub_request(:get, feature_flag_endpoint) + .to_return( + { status: 200, body: empty_flags.to_json }, + { status: 200, body: beta_flags.to_json, headers: { 'ETag' => '"original-etag"' } }, + { status: 500, body: { error: 'Internal Server Error' }.to_json } + ) + + poller.load_feature_flags(true) + expect(poller.instance_variable_get(:@flags_etag).value).to eq('"original-etag"') + + poller.load_feature_flags(true) + + # ETag should be preserved since the 500 error doesn't contain valid flag data + expect(poller.instance_variable_get(:@flags_etag).value).to eq('"original-etag"') + # And flags should be preserved from previous successful load + expect(poller.instance_variable_get(:@feature_flags).length).to eq(1) + expect(poller.instance_variable_get(:@feature_flags)[0][:key]).to eq('beta-feature') + end + end + + describe '_mask_tokens_in_url' do + before do + # Stub the initial feature flag definitions request made during client initialization + stub_request(:get, feature_flag_endpoint) + .to_return(status: 200, body: { flags: [] }.to_json) + end + + it 'masks token keeping first 10 chars visible' do + url = 'https://example.com/api/flags?token=phc_abc123xyz789&send_cohorts' + result = poller.send(:_mask_tokens_in_url, url) + expect(result).to eq('https://example.com/api/flags?token=phc_abc123...&send_cohorts') + end + + it 'masks token at end of URL' do + url = 'https://example.com/api/flags?token=phc_abc123xyz789' + result = poller.send(:_mask_tokens_in_url, url) + expect(result).to eq('https://example.com/api/flags?token=phc_abc123...') + end + + it 'leaves URLs without token unchanged' do + url = 'https://example.com/api/flags?other=value' + result = poller.send(:_mask_tokens_in_url, url) + expect(result).to eq('https://example.com/api/flags?other=value') + end + + it 'leaves short tokens (<10 chars) unchanged' do + url = 'https://example.com/api/flags?token=short' + result = poller.send(:_mask_tokens_in_url, url) + expect(result).to eq('https://example.com/api/flags?token=short') + end + + it 'masks exactly 10 char tokens' do + url = 'https://example.com/api/flags?token=1234567890' + result = poller.send(:_mask_tokens_in_url, url) + expect(result).to eq('https://example.com/api/flags?token=1234567890...') + end + end + end end