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
13 changes: 12 additions & 1 deletion .github/actions/check/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,20 @@ runs:
shell: bash
run: make start-contract-test-service-bg

- uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1.2.0
- name: Run contract tests v2
if: ${{ inputs.flaky != 'true' }}
uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1
with:
test_service_port: 9000
enable_persistence_tests: true
token: ${{ inputs.token }}
stop_service: 'false'

- name: Run contract tests v3
if: ${{ inputs.flaky != 'true' }}
uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1
with:
test_service_port: 9000
enable_persistence_tests: true
token: ${{ inputs.token }}
version: v3.0.0-alpha.2
252 changes: 175 additions & 77 deletions contract-tests/client_entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,55 +14,99 @@ def initialize(log, config)

opts[:logger] = log

if config[:streaming]
data_system_config = config[:dataSystem]
if data_system_config
data_system = LaunchDarkly::DataSystem.custom

# For FDv2, persistent store config is nested inside dataSystem.store
persistent_store_config = data_system_config.dig(:store, :persistentDataStore)
if persistent_store_config
store, store_mode = build_persistent_store(persistent_store_config)
data_system.data_store(store, store_mode)
end

init_configs = data_system_config[:initializers]
if init_configs
initializers = []
init_configs.each do |init_config|
polling = init_config[:polling]
next unless polling

opts[:base_uri] = polling[:baseUri] if polling[:baseUri]
set_optional_time_prop(polling, :pollIntervalMs, opts, :poll_interval)
initializers << LaunchDarkly::DataSystem.polling_ds_builder
end
data_system.initializers(initializers)
end

sync_config = data_system_config[:synchronizers]
if sync_config
primary = sync_config[:primary]
secondary = sync_config[:secondary]

primary_builder = nil
secondary_builder = nil

if primary
streaming = primary[:streaming]
if streaming
opts[:stream_uri] = streaming[:baseUri] if streaming[:baseUri]
set_optional_time_prop(streaming, :initialRetryDelayMs, opts, :initial_reconnect_delay)
primary_builder = LaunchDarkly::DataSystem.streaming_ds_builder
elsif primary[:polling]
polling = primary[:polling]
opts[:base_uri] = polling[:baseUri] if polling[:baseUri]
set_optional_time_prop(polling, :pollIntervalMs, opts, :poll_interval)
primary_builder = LaunchDarkly::DataSystem.polling_ds_builder
end
end

if secondary
streaming = secondary[:streaming]
if streaming
opts[:stream_uri] = streaming[:baseUri] if streaming[:baseUri]
set_optional_time_prop(streaming, :initialRetryDelayMs, opts, :initial_reconnect_delay)
secondary_builder = LaunchDarkly::DataSystem.streaming_ds_builder
elsif secondary[:polling]
polling = secondary[:polling]
opts[:base_uri] = polling[:baseUri] if polling[:baseUri]
set_optional_time_prop(polling, :pollIntervalMs, opts, :poll_interval)
secondary_builder = LaunchDarkly::DataSystem.polling_ds_builder
end
end

data_system.synchronizers(primary_builder, secondary_builder) if primary_builder

if primary_builder || secondary_builder
fallback_builder = LaunchDarkly::DataSystem.fdv1_fallback_ds_builder
data_system.fdv1_compatible_synchronizer(fallback_builder)
end
end

if data_system_config[:payloadFilter]
opts[:payload_filter_key] = data_system_config[:payloadFilter]
end

opts[:data_system_config] = data_system.build
elsif config[:streaming]
streaming = config[:streaming]
opts[:stream_uri] = streaming[:baseUri] unless streaming[:baseUri].nil?
opts[:payload_filter_key] = streaming[:filter] unless streaming[:filter].nil?
opts[:initial_reconnect_delay] = streaming[:initialRetryDelayMs] / 1_000.0 unless streaming[:initialRetryDelayMs].nil?
set_optional_time_prop(streaming, :initialRetryDelayMs, opts, :initial_reconnect_delay)
elsif config[:polling]
polling = config[:polling]
opts[:stream] = false
opts[:base_uri] = polling[:baseUri] unless polling[:baseUri].nil?
opts[:payload_filter_key] = polling[:filter] unless polling[:filter].nil?
opts[:poll_interval] = polling[:pollIntervalMs] / 1_000.0 unless polling[:pollIntervalMs].nil?
set_optional_time_prop(polling, :pollIntervalMs, opts, :poll_interval)
else
opts[:use_ldd] = true
end

if config[:persistentDataStore]
store_config = {}
store_config[:prefix] = config[:persistentDataStore][:store][:prefix] if config[:persistentDataStore][:store][:prefix]

case config[:persistentDataStore][:cache][:mode]
when 'off'
store_config[:expiration] = 0
when 'infinite'
# NOTE: We don't actually support infinite cache mode, so we'll just set it to nil for now. This uses a default
# 15 second expiration time in the SDK, which is long enough to pass any test.
store_config[:expiration] = nil
when 'ttl'
store_config[:expiration] = config[:persistentDataStore][:cache][:ttl]
end

case config[:persistentDataStore][:store][:type]
when 'redis'
store_config[:redis_url] = config[:persistentDataStore][:store][:dsn]
store = LaunchDarkly::Integrations::Redis.new_feature_store(store_config)
opts[:feature_store] = store
when 'consul'
store_config[:url] = config[:persistentDataStore][:store][:url]
store = LaunchDarkly::Integrations::Consul.new_feature_store(store_config)
opts[:feature_store] = store
when 'dynamodb'
client = Aws::DynamoDB::Client.new(
region: 'us-east-1',
credentials: Aws::Credentials.new('dummy', 'dummy', 'dummy'),
endpoint: config[:persistentDataStore][:store][:dsn]
)
store_config[:existing_client] = client
store = LaunchDarkly::Integrations::DynamoDB.new_feature_store('sdk-contract-tests', store_config)
opts[:feature_store] = store
end
# Configure persistent data store for legacy (non-dataSystem) configurations
if !data_system_config && config[:persistentDataStore]
store, _store_mode = build_persistent_store(config[:persistentDataStore])
opts[:feature_store] = store
end

if config[:events]
Expand All @@ -72,7 +116,7 @@ def initialize(log, config)
opts[:diagnostic_opt_out] = !events[:enableDiagnostics]
opts[:all_attributes_private] = !!events[:allAttributesPrivate]
opts[:private_attributes] = events[:globalPrivateAttributes]
opts[:flush_interval] = (events[:flushIntervalMs] / 1_000) unless events[:flushIntervalMs].nil?
set_optional_time_prop(events, :flushIntervalMs, opts, :flush_interval)
opts[:omit_anonymous_contexts] = !!events[:omitAnonymousContexts]
opts[:compress_events] = !!events[:enableGzip]
else
Expand All @@ -81,19 +125,14 @@ def initialize(log, config)

if config[:bigSegments]
big_segments = config[:bigSegments]
big_config = { store: BigSegmentStoreFixture.new(big_segments[:callbackUri]) }

store = BigSegmentStoreFixture.new(config[:bigSegments][:callbackUri])
context_cache_time = big_segments[:userCacheTimeMs].nil? ? nil : big_segments[:userCacheTimeMs] / 1_000
status_poll_interval_ms = big_segments[:statusPollIntervalMs].nil? ? nil : big_segments[:statusPollIntervalMs] / 1_000
stale_after_ms = big_segments[:staleAfterMs].nil? ? nil : big_segments[:staleAfterMs] / 1_000

opts[:big_segments] = LaunchDarkly::BigSegmentsConfig.new(
store: store,
context_cache_size: big_segments[:userCacheSize],
context_cache_time: context_cache_time,
status_poll_interval: status_poll_interval_ms,
stale_after: stale_after_ms
)
big_config[:context_cache_size] = big_segments[:userCacheSize] if big_segments[:userCacheSize]
set_optional_time_prop(big_segments, :userCacheTimeMs, big_config, :context_cache_time)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updating to use the new set_optional_time_prop method

set_optional_time_prop(big_segments, :statusPollIntervalMs, big_config, :status_poll_interval)
set_optional_time_prop(big_segments, :staleAfterMs, big_config, :stale_after)

opts[:big_segments] = LaunchDarkly::BigSegmentsConfig.new(**big_config)
end

if config[:tags]
Expand Down Expand Up @@ -198,6 +237,50 @@ def context_comparison(params)
context1 == context2
end

def secure_mode_hash(params)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved non private methods above the private methods.

@client.secure_mode_hash(params[:context])
end

def track(params)
@client.track(params[:eventKey], params[:context], params[:data], params[:metricValue])
end

def identify(params)
@client.identify(params[:context])
end

def flush_events
@client.flush
end

def get_big_segment_store_status
status = @client.big_segment_store_status_provider.status
{ available: status.available, stale: status.stale }
end

def log
@log
end

def close
@client.close
@log.info("Test ended")
end

#
# Helper to convert millisecond time properties to seconds.
# Only sets the output if the input value is present.
#
# @param params_in [Hash] Input parameters hash
# @param name_in [Symbol] Key name in input hash (e.g., :pollIntervalMs)
# @param params_out [Hash] Output parameters hash
# @param name_out [Symbol] Key name in output hash (e.g., :poll_interval)
#
private def set_optional_time_prop(params_in, name_in, params_out, name_out)
value = params_in[name_in]
params_out[name_out] = value / 1_000.0 if value
end

private def build_context_from_params(params)
return build_single_context_from_attribute_definitions(params[:single]) unless params[:single].nil?

Expand Down Expand Up @@ -230,33 +313,48 @@ def context_comparison(params)
LaunchDarkly::LDContext.create(context)
end

def secure_mode_hash(params)
@client.secure_mode_hash(params[:context])
end

def track(params)
@client.track(params[:eventKey], params[:context], params[:data], params[:metricValue])
end

def identify(params)
@client.identify(params[:context])
end

def flush_events
@client.flush
end

def get_big_segment_store_status
status = @client.big_segment_store_status_provider.status
{ available: status.available, stale: status.stale }
end

def log
@log
end
#
# Builds a persistent data store from the contract test configuration.
#
# @param persistent_store_config [Hash] The persistentDataStore configuration
# @return [Array<Object, Symbol>] Returns [store, store_mode]
#
private def build_persistent_store(persistent_store_config)
store_config = {}
store_config[:prefix] = persistent_store_config[:store][:prefix] if persistent_store_config[:store][:prefix]

case persistent_store_config[:cache][:mode]
when 'off'
store_config[:expiration] = 0
when 'infinite'
# NOTE: We don't actually support infinite cache mode, so we'll just set it to nil for now. This uses a default
# 15 second expiration time in the SDK, which is long enough to pass any test.
store_config[:expiration] = nil
when 'ttl'
store_config[:expiration] = persistent_store_config[:cache][:ttl]
end

def close
@client.close
@log.info("Test ended")
store = case persistent_store_config[:store][:type]
when 'redis'
store_config[:redis_url] = persistent_store_config[:store][:dsn]
LaunchDarkly::Integrations::Redis.new_feature_store(store_config)
when 'consul'
store_config[:url] = persistent_store_config[:store][:url]
LaunchDarkly::Integrations::Consul.new_feature_store(store_config)
when 'dynamodb'
client = Aws::DynamoDB::Client.new(
region: 'us-east-1',
credentials: Aws::Credentials.new('dummy', 'dummy', 'dummy'),
endpoint: persistent_store_config[:store][:dsn]
)
store_config[:existing_client] = client
LaunchDarkly::Integrations::DynamoDB.new_feature_store('sdk-contract-tests', store_config)
end

store_mode = persistent_store_config[:mode] == 'read' ?
LaunchDarkly::Interfaces::DataSystem::DataStoreMode::READ_ONLY :
LaunchDarkly::Interfaces::DataSystem::DataStoreMode::READ_WRITE

[store, store_mode]
end
end
2 changes: 2 additions & 0 deletions contract-tests/service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@

configure :development do
disable :show_exceptions
set :host_authorization, { permitted_hosts: [] }
end

$log = Logger.new(STDOUT)
$log.formatter = proc {|severity, datetime, progname, msg|
"[GLOBAL] #{datetime.strftime('%Y-%m-%d %H:%M:%S.%3N')} #{severity} #{progname} #{msg}\n"
}

set :bind, '0.0.0.0'
set :port, 9000
set :logging, false

Expand Down
4 changes: 2 additions & 2 deletions lib/ldclient-rb/impl/data_system/polling.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ module DataSystem
FDV2_POLLING_ENDPOINT = "/sdk/poll"
FDV1_POLLING_ENDPOINT = "/sdk/latest-all"

LD_ENVID_HEADER = "x-launchdarkly-env-id"
LD_FD_FALLBACK_HEADER = "x-launchdarkly-fd-fallback"
LD_ENVID_HEADER = "X-LD-EnvID"
LD_FD_FALLBACK_HEADER = "X-LD-FD-Fallback"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Header lookup fails due to case mismatch

High Severity

The LD_ENVID_HEADER and LD_FD_FALLBACK_HEADER constants were changed from lowercase ("x-launchdarkly-env-id", "x-launchdarkly-fd-fallback") to mixed-case ("X-LD-EnvID", "X-LD-FD-Fallback"). However, HTTP response headers are explicitly downcased via transform_keys(&:downcase) when constructing response_headers. Since Ruby hash lookups are case-sensitive, lookups using these mixed-case constants against the all-lowercase headers hash will always return nil, breaking fallback detection and environment ID retrieval.

Fix in Cursor Fix in Web


#
# Requester protocol for polling data source
Expand Down
8 changes: 5 additions & 3 deletions lib/ldclient-rb/ldclient.rb
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,9 @@ def secure_mode_hash(context)
# @return [Boolean] true if the client has been initialized
#
def initialized?
@data_system.data_availability == @data_system.target_availability
return true if @config.offline? || @config.use_ldd?

Impl::DataSystem::DataAvailability.at_least?(@data_system.data_availability, Impl::DataSystem::DataAvailability::CACHED)
end

#
Expand Down Expand Up @@ -692,8 +694,8 @@ def flag_tracker
return detail, nil, context.error
end

unless initialized?
if @data_system.store.initialized?
if @data_system.data_availability != Impl::DataSystem::DataAvailability::REFRESHED
if @data_system.data_availability == Impl::DataSystem::DataAvailability::CACHED
@config.logger.warn { "[LDClient] Client has not finished initializing; using last known values from feature store" }
else
@config.logger.error { "[LDClient] Client has not finished initializing; feature store unavailable, returning default value" }
Expand Down