Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

prepare 5.4.0 release #109

Merged
merged 59 commits into from
Nov 3, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
f5092af
Remove @ashanbrown from codeowners
ashanbrown Jul 24, 2018
fd63b2b
log exception stacktraces at debug level
eli-darkly Aug 2, 2018
d4be186
re-add minimal unit test
eli-darkly Aug 2, 2018
d73d66c
log exceptions at error level
eli-darkly Aug 2, 2018
94c8485
Merge pull request #75 from launchdarkly/eb/ch19339/exception-logging
eli-darkly Aug 2, 2018
ca15234
add new version of all_flags that captures more metadata
eli-darkly Aug 17, 2018
ed19523
add tests for FeatureFlagsState
eli-darkly Aug 17, 2018
73f2d89
provide as_json method that returns a hash instead of just a string
eli-darkly Aug 20, 2018
ab896b1
state can be serialized with JSON.generate
eli-darkly Aug 21, 2018
00347c6
add $valid
eli-darkly Aug 21, 2018
bdac27e
add ability to filter for only client-side flags
eli-darkly Aug 21, 2018
748b59b
Merge pull request #76 from launchdarkly/eb/ch22308/all-flags-state
eli-darkly Aug 21, 2018
50b3aa5
Merge pull request #77 from launchdarkly/eb/ch12124/client-side-filter
eli-darkly Aug 21, 2018
cee4c18
implement evaluation with explanations
eli-darkly Aug 23, 2018
d2c2ab8
misc cleanup
eli-darkly Aug 23, 2018
64a00a1
misc cleanup, more error checking
eli-darkly Aug 23, 2018
46b642b
don't keep evaluating prerequisites if one fails
eli-darkly Aug 23, 2018
855c4e2
doc comment
eli-darkly Aug 23, 2018
a0f002f
rename variation to variation_index
eli-darkly Aug 23, 2018
4ec43db
comment
eli-darkly Aug 23, 2018
9622e01
more test coverage, convenience method
eli-darkly Aug 24, 2018
7a453b0
Merge branch 'master' of github.com:launchdarkly/ruby-client
eli-darkly Aug 27, 2018
88d217e
Merge branch 'master' of github.com:launchdarkly/ruby-client
eli-darkly Aug 27, 2018
084d9ea
fix event generation for a prerequisite that is off
eli-darkly Aug 29, 2018
02b5712
fix private
eli-darkly Aug 29, 2018
53e8408
Merge pull request #78 from launchdarkly/eb/ch19976/explanations
eli-darkly Aug 29, 2018
c78db15
Merge pull request #79 from launchdarkly/eb/ch22995/prereq-off
eli-darkly Aug 29, 2018
960bb89
Merge branch 'explanation'
eli-darkly Aug 30, 2018
39d6ad1
Merge branch 'master' of github.com:launchdarkly/ruby-client
eli-darkly Aug 30, 2018
8867638
add option to reduce front-end metadata for untracked flags
eli-darkly Oct 5, 2018
7ac39ba
Merge pull request #80 from launchdarkly/eb/ch24449/less-metadata
eli-darkly Oct 5, 2018
9ea43e0
fix logic for whether a flag is tracked in all_flags_state
eli-darkly Oct 8, 2018
cbbc2ea
Merge pull request #81 from launchdarkly/eb/ch24449/less-metadata-2
eli-darkly Oct 15, 2018
c79745a
merge from public after release
LaunchDarklyCI Oct 24, 2018
cce8e84
implement file data source
eli-darkly Oct 31, 2018
22ebded
add poll interval param, tolerate single file path string, add doc co…
eli-darkly Oct 31, 2018
b864390
make listen dependency optional
eli-darkly Oct 31, 2018
789b5a4
readme
eli-darkly Oct 31, 2018
31a62c5
fix key handling and client integration, add tests
eli-darkly Oct 31, 2018
778cb6d
debugging
eli-darkly Nov 1, 2018
20dbef2
debugging
eli-darkly Nov 1, 2018
f1c00b1
add fallback polling logic, fix tests
eli-darkly Nov 1, 2018
198b843
rm debugging
eli-darkly Nov 1, 2018
c5d1823
debugging
eli-darkly Nov 2, 2018
9baffe3
debugging
eli-darkly Nov 2, 2018
4d81215
debugging
eli-darkly Nov 2, 2018
30d0cd2
debugging
eli-darkly Nov 2, 2018
8cb2ed9
comment correction
eli-darkly Nov 2, 2018
a10f973
documentation
eli-darkly Nov 2, 2018
16cf9c0
always use YAML parser
eli-darkly Nov 2, 2018
27d954e
report internal error that shouldn't happen
eli-darkly Nov 2, 2018
fd308a9
add test for multiple files
eli-darkly Nov 2, 2018
1d016bf
fix duplicate key checking (string vs. symbol problem)
eli-darkly Nov 2, 2018
c3e66d3
Don't use 'listen' in JRuby 9.1
eli-darkly Nov 2, 2018
1a36fd8
rm debugging
eli-darkly Nov 2, 2018
78ba815
better error handling in poll thread
eli-darkly Nov 2, 2018
2d29388
Merge pull request #82 from launchdarkly/eb/ch25289/file-source
eli-darkly Nov 2, 2018
38f534f
don't use Thread.raise to stop PollingProcessor thread; add test for …
eli-darkly Nov 2, 2018
4966136
Merge pull request #83 from launchdarkly/eb/ch19334/no-thread-raise
eli-darkly Nov 3, 2018
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ else
end
```

Using flag data from a file
---------------------------
For testing purposes, the SDK can be made to read feature flag state from a file or files instead of connecting to LaunchDarkly. See [`file_data_source.rb`](https://github.com/launchdarkly/ruby-client/blob/master/lib/ldclient-rb/file_data_source.rb) for more details.

Learn more
-----------

Expand Down
1 change: 1 addition & 0 deletions ldclient-rb.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Gem::Specification.new do |spec|
spec.add_development_dependency "rake", "~> 10.0"
spec.add_development_dependency "rspec_junit_formatter", "~> 0.3.0"
spec.add_development_dependency "timecop", "~> 0.9.1"
spec.add_development_dependency "listen", "~> 3.0" # see file_data_source.rb

spec.add_runtime_dependency "json", [">= 1.8", "< 3"]
spec.add_runtime_dependency "faraday", [">= 0.9", "< 2"]
Expand Down
1 change: 1 addition & 0 deletions lib/ldclient-rb.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@
require "ldclient-rb/events"
require "ldclient-rb/redis_store"
require "ldclient-rb/requestor"
require "ldclient-rb/file_data_source"
10 changes: 8 additions & 2 deletions lib/ldclient-rb/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,11 @@ class Config
# @option opts [Boolean] :inline_users_in_events (false) Whether to include full user details in every
# analytics event. By default, events will only include the user key, except for one "index" event
# that provides the full details for the user.
# @option opts [Object] :update_processor An object that will receive feature flag data from LaunchDarkly.
# Defaults to either the streaming or the polling processor, can be customized for tests.
# @option opts [Object] :update_processor (DEPRECATED) An object that will receive feature flag data from
# LaunchDarkly. Defaults to either the streaming or the polling processor, can be customized for tests.
# @option opts [Object] :update_processor_factory A function that takes the SDK and configuration object
# as parameters, and returns an object that can obtain feature flag data and put it into the feature
# store. Defaults to creating either the streaming or the polling processor, can be customized for tests.
# @return [type] [description]
# rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
def initialize(opts = {})
Expand All @@ -88,6 +91,7 @@ def initialize(opts = {})
@user_keys_flush_interval = opts[:user_keys_flush_interval] || Config.default_user_keys_flush_interval
@inline_users_in_events = opts[:inline_users_in_events] || false
@update_processor = opts[:update_processor]
@update_processor_factory = opts[:update_processor_factory]
end

#
Expand Down Expand Up @@ -218,6 +222,8 @@ def offline?

attr_reader :update_processor

attr_reader :update_processor_factory

#
# The default LaunchDarkly client configuration. This configuration sets
# reasonable defaults for most users.
Expand Down
307 changes: 307 additions & 0 deletions lib/ldclient-rb/file_data_source.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
require 'concurrent/atomics'
require 'json'
require 'yaml'
require 'pathname'

module LaunchDarkly
# To avoid pulling in 'listen' and its transitive dependencies for people who aren't using the
# file data source or who don't need auto-updating, we only enable auto-update if the 'listen'
# gem has been provided by the host app.
@@have_listen = false
begin
require 'listen'
@@have_listen = true
rescue
end
def self.have_listen?
@@have_listen
end

#
# Provides a way to use local files as a source of feature flag state. This would typically be
# used in a test environment, to operate using a predetermined feature flag state without an
# actual LaunchDarkly connection.
#
# To use this component, call `FileDataSource.factory`, and store its return value in the
# `update_processor_factory` property of your LaunchDarkly client configuration. In the options
# to `factory`, set `paths` to the file path(s) of your data file(s):
#
# factory = FileDataSource.factory(paths: [ myFilePath ])
# config = LaunchDarkly::Config.new(update_processor_factory: factory)
#
# This will cause the client not to connect to LaunchDarkly to get feature flags. The
# client may still make network connections to send analytics events, unless you have disabled
# this with Config.send_events or Config.offline.
#
# Flag data files can be either JSON or YAML. They contain an object with three possible
# properties:
#
# - "flags": Feature flag definitions.
# - "flagValues": Simplified feature flags that contain only a value.
# - "segments": User segment definitions.
#
# The format of the data in "flags" and "segments" is defined by the LaunchDarkly application
# and is subject to change. Rather than trying to construct these objects yourself, it is simpler
# to request existing flags directly from the LaunchDarkly server in JSON format, and use this
# output as the starting point for your file. In Linux you would do this:
#
# curl -H "Authorization: {your sdk key}" https://app.launchdarkly.com/sdk/latest-all
#
# The output will look something like this (but with many more properties):
#
# {
# "flags": {
# "flag-key-1": {
# "key": "flag-key-1",
# "on": true,
# "variations": [ "a", "b" ]
# }
# },
# "segments": {
# "segment-key-1": {
# "key": "segment-key-1",
# "includes": [ "user-key-1" ]
# }
# }
# }
#
# Data in this format allows the SDK to exactly duplicate all the kinds of flag behavior supported
# by LaunchDarkly. However, in many cases you will not need this complexity, but will just want to
# set specific flag keys to specific values. For that, you can use a much simpler format:
#
# {
# "flagValues": {
# "my-string-flag-key": "value-1",
# "my-boolean-flag-key": true,
# "my-integer-flag-key": 3
# }
# }
#
# Or, in YAML:
#
# flagValues:
# my-string-flag-key: "value-1"
# my-boolean-flag-key: true
# my-integer-flag-key: 1
#
# It is also possible to specify both "flags" and "flagValues", if you want some flags
# to have simple values and others to have complex behavior. However, it is an error to use the
# same flag key or segment key more than once, either in a single file or across multiple files.
#
# If the data source encounters any error in any file-- malformed content, a missing file, or a
# duplicate key-- it will not load flags from any of the files.
#
class FileDataSource
#
# Returns a factory for the file data source component.
#
# @param options [Hash] the configuration options
# @option options [Array] :paths The paths of the source files for loading flag data. These
# may be absolute paths or relative to the current working directory.
# @option options [Boolean] :auto_update True if the data source should watch for changes to
# the source file(s) and reload flags whenever there is a change. Auto-updating will only
# work if all of the files you specified have valid directory paths at startup time.
# Note that the default implementation of this feature is based on polling the filesystem,
# which may not perform well. If you install the 'listen' gem (not included by default, to
# avoid adding unwanted dependencies to the SDK), its native file watching mechanism will be
# used instead. However, 'listen' will not be used in JRuby 9.1 due to a known instability.
# @option options [Float] :poll_interval The minimum interval, in seconds, between checks for
# file modifications - used only if auto_update is true, and if the native file-watching
# mechanism from 'listen' is not being used. The default value is 1 second.
#
def self.factory(options={})
return Proc.new do |sdk_key, config|
FileDataSourceImpl.new(config.feature_store, config.logger, options)
end
end
end

class FileDataSourceImpl
def initialize(feature_store, logger, options={})
@feature_store = feature_store
@logger = logger
@paths = options[:paths] || []
if @paths.is_a? String
@paths = [ @paths ]
end
@auto_update = options[:auto_update]
if @auto_update && LaunchDarkly.have_listen? && !options[:force_polling] # force_polling is used only for tests
# We have seen unreliable behavior in the 'listen' gem in JRuby 9.1 (https://github.com/guard/listen/issues/449).
# Therefore, on that platform we'll fall back to file polling instead.
if defined?(JRUBY_VERSION) && JRUBY_VERSION.start_with?("9.1.")
@use_listen = false
else
@use_listen = true
end
end
@poll_interval = options[:poll_interval] || 1
@initialized = Concurrent::AtomicBoolean.new(false)
@ready = Concurrent::Event.new
end

def initialized?
@initialized.value
end

def start
ready = Concurrent::Event.new

# We will return immediately regardless of whether the file load succeeded or failed -
# the difference can be detected by checking "initialized?"
ready.set

load_all

if @auto_update
# If we're going to watch files, then the start event will be set the first time we get
# a successful load.
@listener = start_listener
end

ready
end

def stop
@listener.stop if !@listener.nil?
end

private

def load_all
all_data = {
FEATURES => {},
SEGMENTS => {}
}
@paths.each do |path|
begin
load_file(path, all_data)
rescue => exn
Util.log_exception(@logger, "Unable to load flag data from \"#{path}\"", exn)
return
end
end
@feature_store.init(all_data)
@initialized.make_true
end

def load_file(path, all_data)
parsed = parse_content(IO.read(path))
(parsed[:flags] || {}).each do |key, flag|
add_item(all_data, FEATURES, flag)
end
(parsed[:flagValues] || {}).each do |key, value|
add_item(all_data, FEATURES, make_flag_with_value(key.to_s, value))
end
(parsed[:segments] || {}).each do |key, segment|
add_item(all_data, SEGMENTS, segment)
end
end

def parse_content(content)
# We can use the Ruby YAML parser for both YAML and JSON (JSON is a subset of YAML and while
# not all YAML parsers handle it correctly, we have verified that the Ruby one does, at least
# for all the samples of actual flag data that we've tested).
symbolize_all_keys(YAML.load(content))
end

def symbolize_all_keys(value)
# This is necessary because YAML.load doesn't have an option for parsing keys as symbols, and
# the SDK expects all objects to be formatted that way.
if value.is_a?(Hash)
value.map{ |k, v| [k.to_sym, symbolize_all_keys(v)] }.to_h
elsif value.is_a?(Array)
value.map{ |v| symbolize_all_keys(v) }
else
value
end
end

def add_item(all_data, kind, item)
items = all_data[kind]
raise ArgumentError, "Received unknown item kind #{kind} in add_data" if items.nil? # shouldn't be possible since we preinitialize the hash
key = item[:key].to_sym
if !items[key].nil?
raise ArgumentError, "#{kind[:namespace]} key \"#{item[:key]}\" was used more than once"
end
items[key] = item
end

def make_flag_with_value(key, value)
{
key: key,
on: true,
fallthrough: { variation: 0 },
variations: [ value ]
}
end

def start_listener
resolved_paths = @paths.map { |p| Pathname.new(File.absolute_path(p)).realpath.to_s }
if @use_listen
start_listener_with_listen_gem(resolved_paths)
else
FileDataSourcePoller.new(resolved_paths, @poll_interval, self.method(:load_all), @logger)
end
end

def start_listener_with_listen_gem(resolved_paths)
path_set = resolved_paths.to_set
dir_paths = resolved_paths.map{ |p| File.dirname(p) }.uniq
opts = { latency: @poll_interval }
l = Listen.to(*dir_paths, opts) do |modified, added, removed|
paths = modified + added + removed
if paths.any? { |p| path_set.include?(p) }
load_all
end
end
l.start
l
end

#
# Used internally by FileDataSource to track data file changes if the 'listen' gem is not available.
#
class FileDataSourcePoller
def initialize(resolved_paths, interval, reloader, logger)
@stopped = Concurrent::AtomicBoolean.new(false)
get_file_times = Proc.new do
ret = {}
resolved_paths.each do |path|
begin
ret[path] = File.mtime(path)
rescue Errno::ENOENT
ret[path] = nil
end
end
ret
end
last_times = get_file_times.call
@thread = Thread.new do
while true
sleep interval
break if @stopped.value
begin
new_times = get_file_times.call
changed = false
last_times.each do |path, old_time|
new_time = new_times[path]
if !new_time.nil? && new_time != old_time
changed = true
break
end
end
reloader.call if changed
rescue => exn
Util.log_exception(logger, "Unexpected exception in FileDataSourcePoller", exn)
end
end
end
end

def stop
@stopped.make_true
@thread.run # wakes it up if it's sleeping
end
end
end
end
Loading