Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
7579ddb
feat: Add the override marker to the model, evaluator, and reason
kinyoklion Sep 25, 2026
1912e39
feat: Add the override store, overlay, and data system wiring
kinyoklion Sep 25, 2026
45c6daa
test: Vendor the specification vectors with the fallthrough expansion
kinyoklion Oct 1, 2026
54fd470
feat: Notify override flags when the LaunchDarkly data they depend on…
kinyoklion Oct 16, 2026
3ab4bfc
fix: Serve overrides-only results from the override store and keep th…
kinyoklion Oct 28, 2026
2b7dd38
test: Wait on queues without Queue#pop's timeout, which Ruby 3.1 and …
kinyoklion Oct 28, 2026
f11d735
docs: State that an override source's start is not intended to fail
kinyoklion Oct 28, 2026
b0505dc
fix: Mark the error reason of an overridden flag whose evaluation raises
kinyoklion Oct 28, 2026
8ba9928
fix: Read the override marker only from model objects on the error paths
kinyoklion Oct 28, 2026
0a4c8b7
fix: Report the overridden flags in an invalid all flags state when t…
kinyoklion Oct 29, 2026
bcf3e61
feat: Split summary counters and suppress individual events for overr…
kinyoklion Sep 25, 2026
9c29467
test: Wait for the events payload without Queue#pop's timeout
kinyoklion Oct 28, 2026
6ec252a
fix: Turn off event tracking for an overridden flag whose evaluation …
kinyoklion Oct 28, 2026
e58b756
fix: Read the event marking of a failed evaluation only from model ob…
kinyoklion Oct 28, 2026
5fa83d3
feat: Add the file-based override source
kinyoklion Sep 25, 2026
09b0442
test: Expect the fallthrough reason for value-only overrides
kinyoklion Oct 1, 2026
7bfc24f
fix: Reject empty and non-string override file paths
kinyoklion Oct 3, 2026
4e17676
fix: Write the log prefix once on the reloader's unexpected error line
kinyoklion Oct 3, 2026
c19de1c
fix: Drop a file path listed more than once instead of treating its e…
kinyoklion Oct 15, 2026
309e55f
fix: Treat a repeated override file path in different case as a repea…
kinyoklion Oct 28, 2026
79e08c0
docs: Note that a flagValues override is not available to client-side…
kinyoklion Oct 28, 2026
8b8a337
feat: Add flag overrides to the contract test service
kinyoklion Sep 25, 2026
ce5a314
fix: Keep overrides through an in-place rewrite of their files
kinyoklion Nov 2, 2026
7c34a2e
fix: Hold back YAML with no document, and bound holds by the run of h…
kinyoklion Nov 3, 2026
7903d07
fix: Bound each held file on its own and time it from its own read
kinyoklion Nov 3, 2026
81b7ced
fix: Start a held file's bound over only at a load that holds nothing…
kinyoklion Nov 3, 2026
00f7cb1
fix: Start the bound over for a file whose read or parse fails
kinyoklion Nov 3, 2026
042d3ce
fix: Keep the override source running through files that are not UTF-8
kinyoklion Nov 3, 2026
9adf231
fix: Read every file even after one fails, and digest each path and p…
kinyoklion Nov 3, 2026
2b4266a
test: Pin the format of a file named .yaml or .yml
kinyoklion Nov 3, 2026
6f10871
docs: Keep the override source docs to what an application needs
kinyoklion Nov 3, 2026
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
3 changes: 3 additions & 0 deletions contract-tests/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ gem "puma", "~> 8.0"
gem "rackup", "~> 2.2"
gem 'sinatra', '>= 4.1'

# The file-based override source uses the listen gem for its watching change detection mode.
gem 'listen', '~> 3.3'

gem 'rubocop', '~> 1.37', group: 'development'
gem 'rubocop-performance', '~> 1.15', group: 'development'

Expand Down
22 changes: 22 additions & 0 deletions contract-tests/client_entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ def initialize(log, config)
opts[:payload_filter_key] = data_system_config[:payloadFilter]
end

# Flag overrides are an option of the FDv2 data system configuration.
data_system.overrides(build_override_source(config[:overrides])) if config[:overrides]

opts[:data_system_config] = data_system.build
elsif config[:overrides]
raise ArgumentError, "flag overrides require the data system to be configured"
elsif config[:streaming]
streaming = config[:streaming]
opts[:stream_uri] = streaming[:baseUri] unless streaming[:baseUri].nil?
Expand Down Expand Up @@ -365,6 +370,23 @@ def close
builder
end

#
# Builds the file-based override source from the `overrides` block of the contract test
# configuration.
#
# @param overrides_config [Hash] the overrides configuration
# @return [Object] a builder for LaunchDarkly::DataSystem::ConfigBuilder#overrides
#
private def build_override_source(overrides_config)
options = { paths: overrides_config[:filePaths] }
if overrides_config[:duplicateKeysHandling]
options[:duplicate_keys_handling] = overrides_config[:duplicateKeysHandling].to_sym
end
options[:change_detection] = overrides_config[:changeDetection].to_sym if overrides_config[:changeDetection]
set_optional_time_prop(overrides_config, :pollIntervalMs, options, :poll_interval)
LaunchDarkly::Integrations::FileData.override_source(options)
end

#
# Builds a persistent data store from the contract test configuration.
#
Expand Down
3 changes: 2 additions & 1 deletion contract-tests/service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
}

set :bind, '0.0.0.0'
set :port, 9000
set :port, (ENV['PORT'] || 9000).to_i
set :logging, false

clients = {}
Expand Down Expand Up @@ -55,6 +55,7 @@
'flag-change-listeners',
'flag-value-change-listeners',
'fdv1-fallback',
'flag-overrides',
],
}.to_json
end
Expand Down
12 changes: 11 additions & 1 deletion lib/ldclient-rb/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -736,14 +736,18 @@ class DataSystemConfig
# @param data_store [LaunchDarkly::Interfaces::FeatureStore, nil] The (optional) data store
# @param fdv1_fallback_synchronizer [#build(String, Config), nil]
# The (optional) builder for FDv1-compatible fallback synchronizer
# @param overrides [#build(String, Config), nil] The (optional) builder for an override source.
# Flag overrides are currently experimental and subject to change.
#
def initialize(initializers: nil, synchronizers: nil,
data_store_mode: LaunchDarkly::Interfaces::DataSystem::DataStoreMode::READ_ONLY, data_store: nil, fdv1_fallback_synchronizer: nil)
data_store_mode: LaunchDarkly::Interfaces::DataSystem::DataStoreMode::READ_ONLY, data_store: nil, fdv1_fallback_synchronizer: nil,
overrides: nil)
@initializers = initializers
@synchronizers = synchronizers
@data_store_mode = data_store_mode
@data_store = data_store
@fdv1_fallback_synchronizer = fdv1_fallback_synchronizer
@overrides = overrides
end

# The initializer builders for the data system. Each builder responds to build(sdk_key, config) and returns an Initializer.
Expand All @@ -765,5 +769,11 @@ def initialize(initializers: nil, synchronizers: nil,
# The FDv1-compatible fallback synchronizer builder. Responds to build(sdk_key, config) and returns a Synchronizer.
# @return [#build(String, Config), nil]
attr_reader :fdv1_fallback_synchronizer

# The override source builder. Responds to build(sdk_key, config) and returns an
# {LaunchDarkly::Interfaces::Overrides::OverrideSource}. Flag overrides are currently
# experimental and subject to change.
# @return [#build(String, Config), nil]
attr_reader :overrides
end
end
36 changes: 35 additions & 1 deletion lib/ldclient-rb/data_system/config_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def initialize
@fdv1_fallback_synchronizer = nil
@data_store_mode = LaunchDarkly::Interfaces::DataSystem::DataStoreMode::READ_ONLY
@data_store = nil
@overrides = nil
end

#
Expand Down Expand Up @@ -85,6 +86,38 @@ def data_store(data_store, store_mode)
self
end

#
# Configures the SDK with an override source. Flag overrides are currently experimental and
# subject to change.
#
# Overrides are flag and segment definitions that take precedence over data received from
# LaunchDarkly at evaluation time, on a per-key basis. They exist for resilience during an
# incident. Overrides let an operator force one or more flags to a known state on a running
# client, whether or not the client can reach LaunchDarkly. Flags not present in the override
# data are completely unaffected.
#
# The override source is not a data source. It has no effect on the client's initialization
# status, data availability, or data source status. Without a configured source, or while
# the source supplies no overrides, the SDK behaves as it would without this feature.
#
# At most one override source can be configured. A later call replaces an earlier one.
#
# When the client is offline, the source is not built or started: no overrides are served,
# and a configuration error in the source is not reported. The SDK logs a warning instead.
#
# @example
# overrides = LaunchDarkly::Integrations::FileData.override_source(paths: ["/etc/launchdarkly/overrides.json"])
# config = LaunchDarkly::Config.new(data_system: LaunchDarkly::DataSystem.default.overrides(overrides))
#
# @param source [#build(String, Config), nil] a builder that responds to build(sdk_key, config)
# and returns a {LaunchDarkly::Interfaces::Overrides::OverrideSource}, or nil to remove one
# @return [ConfigBuilder] self for chaining
#
def overrides(source)
@overrides = source
self
end

#
# Builds the data system configuration.
#
Expand All @@ -96,7 +129,8 @@ def build
synchronizers: @synchronizers,
data_store_mode: @data_store_mode,
data_store: @data_store,
fdv1_fallback_synchronizer: @fdv1_fallback_synchronizer
fdv1_fallback_synchronizer: @fdv1_fallback_synchronizer,
overrides: @overrides
)
end
end
Expand Down
41 changes: 37 additions & 4 deletions lib/ldclient-rb/evaluation_detail.rb
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,15 @@ class EvaluationReason
# @return [Symbol]
attr_reader :big_segments_status

# True if an override affected this evaluation, directly or transitively. It is true when the
# evaluated flag came from the SDK's override store. It is also true when a prerequisite flag at
# any depth, or a segment read during the evaluation, came from the override store. In the JSON
# representation, the `overrideAffected` property appears only when this is true.
#
# Flag overrides are currently experimental and subject to change.
# @return [Boolean]
attr_reader :override_affected

# Returns an instance whose {#kind} is {#OFF}.
# @return [EvaluationReason]
def self.off
Expand Down Expand Up @@ -216,12 +225,14 @@ def ==(other)
if other.is_a? EvaluationReason
@kind == other.kind && @rule_index == other.rule_index && @rule_id == other.rule_id &&
@prerequisite_key == other.prerequisite_key && @error_kind == other.error_kind &&
@big_segments_status == other.big_segments_status
@big_segments_status == other.big_segments_status &&
@override_affected == other.override_affected
elsif other.is_a? Hash
@kind.to_s == other[:kind] && @rule_index == other[:ruleIndex] && @rule_id == other[:ruleId] &&
@prerequisite_key == other[:prerequisiteKey] &&
(other[:errorKind] == @error_kind.nil? ? nil : @error_kind.to_s) &&
(other[:bigSegmentsStatus] == @big_segments_status.nil? ? nil : @big_segments_status.to_s)
(other[:bigSegmentsStatus] == @big_segments_status.nil? ? nil : @big_segments_status.to_s) &&
!!other[:overrideAffected] == @override_affected
end
end

Expand Down Expand Up @@ -286,6 +297,8 @@ def as_json(*) # parameter is unused, but may be passed if we're using the json
unless @big_segments_status.nil?
ret[:bigSegmentsStatus] = @big_segments_status
end
# The property is written only when true, so the wire format of an ordinary evaluation is unchanged.
ret[:overrideAffected] = true if @override_affected
ret
end

Expand All @@ -312,22 +325,41 @@ def [](key)
@error_kind.nil? ? nil : @error_kind.to_s
when :bigSegmentsStatus
@big_segments_status.nil? ? nil : @big_segments_status.to_s
when :overrideAffected
@override_affected
else
nil
end
end

def with_big_segments_status(big_segments_status)
return self if @big_segments_status == big_segments_status
EvaluationReason.new(@kind, @rule_index, @rule_id, @prerequisite_key, @error_kind, @in_experiment, big_segments_status)
EvaluationReason.new(@kind, @rule_index, @rule_id, @prerequisite_key, @error_kind, @in_experiment,
big_segments_status, @override_affected)
end

#
# Returns a reason that is the same as this one apart from the {#override_affected} indicator.
# Returns this instance when the indicator already has the given value.
#
# Flag overrides are currently experimental and subject to change.
#
# @param override_affected [Boolean]
# @return [EvaluationReason]
#
def with_override_affected(override_affected)
override_affected = !!override_affected
return self if @override_affected == override_affected
EvaluationReason.new(@kind, @rule_index, @rule_id, @prerequisite_key, @error_kind, @in_experiment,
@big_segments_status, override_affected)
end

#
# Constructor that sets all properties. Applications should not normally use this constructor,
# but should use class methods like {#off} to avoid creating unnecessary instances.
#
def initialize(kind, rule_index, rule_id, prerequisite_key, error_kind, in_experiment=nil,
big_segments_status = nil)
big_segments_status = nil, override_affected = false)
@kind = kind.to_sym
@rule_index = rule_index
@rule_id = rule_id
Expand All @@ -337,6 +369,7 @@ def initialize(kind, rule_index, rule_id, prerequisite_key, error_kind, in_exper
@error_kind = error_kind
@in_experiment = in_experiment
@big_segments_status = big_segments_status
@override_affected = !!override_affected
end

private_class_method def self.make_error(error_kind)
Expand Down
40 changes: 24 additions & 16 deletions lib/ldclient-rb/events.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ def record_eval_event(
debug_until = nil,
prereq_of = nil,
sampling_ratio = nil,
exclude_from_summaries = false
exclude_from_summaries = false,
override_affected = false
)
end

Expand Down Expand Up @@ -176,10 +177,11 @@ def record_eval_event(
debug_until = nil,
prereq_of = nil,
sampling_ratio = nil,
exclude_from_summaries = false
exclude_from_summaries = false,
override_affected = false
)
post_to_inbox(LaunchDarkly::Impl::EvalEvent.new(timestamp, context, key, version, variation, value, reason,
default, track_events, debug_until, prereq_of, sampling_ratio, exclude_from_summaries))
default, track_events, debug_until, prereq_of, sampling_ratio, exclude_from_summaries, override_affected))
end

def record_identify_event(context)
Expand Down Expand Up @@ -328,8 +330,10 @@ def dispatch_event(event, outbox)
will_add_full_event = false
debug_event = nil
if event.is_a?(LaunchDarkly::Impl::EvalEvent)
will_add_full_event = event.track_events
if should_debug_event(event)
# An override-affected evaluation appears only in the summary counters. It produces no
# individual feature event and no debug event, whatever the flag's configuration requests.
will_add_full_event = event.track_events && !event.override_affected
if !event.override_affected && should_debug_event(event)
debug_event = LaunchDarkly::Impl::DebugEvent.new(event)
end
else
Expand Down Expand Up @@ -630,18 +634,22 @@ def make_output_events(events, summary)
summary.counters.each do |flagKey, flagInfo|
counters = []
flagInfo.versions.each do |version, variations|
variations.each do |variation, counter|
c = {
value: counter.value,
count: counter.count,
}
c[:variation] = variation unless variation.nil?
if version.nil?
c[:unknown] = true
else
c[:version] = version
variations.each do |variation, counters_by_marker|
counters_by_marker.each do |override_affected, counter|
c = {
value: counter.value,
count: counter.count,
}
c[:variation] = variation unless variation.nil?
if version.nil?
c[:unknown] = true
else
c[:version] = version
end
# The marker is present only for override-affected counters, like the unknown marker.
c[:overrideAffected] = true if override_affected
counters.push(c)
end
counters.push(c)
end
end
flags[flagKey] = { default: flagInfo.default, counters: counters, contextKinds: flagInfo.context_kinds.to_a }
Expand Down
Loading
Loading