hackle-ruby-sdk 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 81db70f8f36a1dca6c3f03bfef469313434fd8f17e053ab32baee9c7f029f8ac
4
+ data.tar.gz: beb13a5d67bbb19d4e7a11556021cc8ca911d8e20902d191fc98973aa0683581
5
+ SHA512:
6
+ metadata.gz: a8c56e2a506eb133d44bbcce75cfe523e29105813147a458b0bef4728e90fa833f630b9a4432c9b7642ba1c201397755dd501eec4db5f69c55d311dbbdeaeee8
7
+ data.tar.gz: 1685a42a07a605653b86b3b7af369abdde427dba4308fcf763c9a4a3fad07ddcd87b1ba6a1561d2cb84ff14b878f86eea34e7b4aea1206a358e168961cb70fca
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --order random
@@ -0,0 +1,7 @@
1
+ ---
2
+ sudo: false
3
+ language: ruby
4
+ cache: bundler
5
+ rvm:
6
+ - 2.6.3
7
+ before_install: gem install bundler -v 1.17.3
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in hackle-ruby-sdk.gemspec
6
+ gemspec
@@ -0,0 +1,35 @@
1
+ # Hackle::Ruby::Sdk
2
+
3
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/hackle/ruby/sdk`. To experiment with that code, run `bin/console` for an interactive prompt.
4
+
5
+ TODO: Delete this and the text above, and describe your gem
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'hackle-ruby-sdk'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install hackle-ruby-sdk
22
+
23
+ ## Usage
24
+
25
+ TODO: Write usage instructions here
26
+
27
+ ## Development
28
+
29
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
+
31
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
+
33
+ ## Contributing
34
+
35
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/hackle-ruby-sdk.
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'hackle-ruby-sdk/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'hackle-ruby-sdk'
9
+ spec.version = Hackle::VERSION
10
+ spec.authors = ['Hackle']
11
+ spec.email = ['platform@hackle.io']
12
+ spec.summary = 'Hackle SDK for Ruby'
13
+ spec.description = 'Hackle SDK for Ruby'
14
+ spec.homepage = 'https://github.com/hackle-io/hackle-ruby-sdk'
15
+ spec.license = 'Apache-2.0'
16
+
17
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(spec)/}) }
18
+ spec.require_paths = ['lib']
19
+
20
+ spec.add_development_dependency 'bundler', '~> 1.17'
21
+ spec.add_development_dependency 'coveralls'
22
+ spec.add_development_dependency 'rake', '~> 10.0'
23
+ spec.add_development_dependency 'rspec', '~> 3.0'
24
+ spec.add_development_dependency 'rubocop', '0.73.0'
25
+
26
+ spec.add_runtime_dependency 'concurrent-ruby', '~> 1.0'
27
+ spec.add_runtime_dependency 'json', '>= 1.8'
28
+ spec.add_runtime_dependency 'murmurhash3', '~> 0.1'
29
+ end
@@ -0,0 +1,22 @@
1
+ require 'hackle-ruby-sdk/decision/bucketer'
2
+ require 'hackle-ruby-sdk/decision/decider'
3
+
4
+ require 'hackle-ruby-sdk/events/event'
5
+ require 'hackle-ruby-sdk/events/event_dispatcher'
6
+ require 'hackle-ruby-sdk/events/event_processor'
7
+
8
+ require 'hackle-ruby-sdk/http/http'
9
+
10
+ require 'hackle-ruby-sdk/models/bucket'
11
+ require 'hackle-ruby-sdk/models/event_type'
12
+ require 'hackle-ruby-sdk/models/experiment'
13
+ require 'hackle-ruby-sdk/models/slot'
14
+ require 'hackle-ruby-sdk/models/variation'
15
+
16
+ require 'hackle-ruby-sdk/workspaces/http_workspace_fetcher'
17
+ require 'hackle-ruby-sdk/workspaces/polling_workspace_fetcher'
18
+ require 'hackle-ruby-sdk/workspaces/workspace'
19
+
20
+ require 'hackle-ruby-sdk/client'
21
+ require 'hackle-ruby-sdk/config'
22
+ require 'hackle-ruby-sdk/version'
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackle
4
+
5
+ #
6
+ # A client for Hackle API.
7
+ #
8
+ class Client
9
+ def initialize(config, workspace_fetcher, event_processor, decider)
10
+ @logger = config.logger
11
+ @workspace_fetcher = workspace_fetcher
12
+ @event_processor = event_processor
13
+ @decider = decider
14
+ end
15
+
16
+ #
17
+ # Decide the variation to expose to the user for experiment.
18
+ #
19
+ # This method return the control variation 'A' if:
20
+ # - The experiment key is invalid
21
+ # - The experiment has not started yet
22
+ # - The user is not allocated to the experiment
23
+ # - The decided variation has been dropped
24
+ #
25
+ # @param experiment_key [Integer] The unique key of the experiment.
26
+ # @param user_id [String] The identifier of your customer. (e.g. user_email, account_id, decide_id, etc.)
27
+ # @param default_variation [String] The default variation of the experiment.
28
+ #
29
+ # @return [String] The decided variation for the user, or default variation
30
+ #
31
+ def variation(experiment_key, user_id, default_variation = 'A')
32
+
33
+ return default_variation if experiment_key.nil?
34
+ return default_variation if user_id.nil?
35
+
36
+ workspace = @workspace_fetcher.fetch
37
+ return default_variation if workspace.nil?
38
+
39
+ experiment = workspace.get_experiment(experiment_key)
40
+ return default_variation if experiment.nil?
41
+
42
+ decision = @decider.decide(experiment, user_id)
43
+ case decision
44
+ when Decision::NotAllocated
45
+ default_variation
46
+ when Decision::ForcedAllocated
47
+ decision.variation_key
48
+ when Decision::NaturalAllocated
49
+ @event_processor.process(Event::Exposure.new(user_id, experiment, decision.variation))
50
+ decision.variation.key
51
+ else
52
+ default_variation
53
+ end
54
+ end
55
+
56
+ #
57
+ # Records the events performed by the user.
58
+ #
59
+ # @param event_key [String] The unique key of the events.
60
+ # @param user_id [String] The identifier of user that performed the vent.
61
+ # @param value [Float] Additional numeric value of the events (e.g. purchase_amount, api_latency, etc.)
62
+ #
63
+ def track(event_key, user_id, value = nil)
64
+
65
+ return if event_key.nil?
66
+ return if user_id.nil?
67
+
68
+ workspace = @workspace_fetcher.fetch
69
+ return if workspace.nil?
70
+
71
+ event_type = workspace.get_event_type(event_key)
72
+ return if event_type.nil?
73
+
74
+ @event_processor.process(Event::Track.new(user_id, event_type, value))
75
+ end
76
+
77
+ #
78
+ # Shutdown the background task and release the resources used for the background task.
79
+ #
80
+ def close
81
+ @workspace_fetcher.stop!
82
+ @event_processor.stop!
83
+ end
84
+
85
+ #
86
+ # Instantiates a Hackle client.
87
+ #
88
+ # @param sdk_key [String] The SDK key of your Hackle environment
89
+ # @param config [Config] An optional client configuration
90
+ #
91
+ # @return [Client] The Hackle client instance.
92
+ #
93
+ def self.create(sdk_key, config = Config.new)
94
+ sdk_info = SdkInfo.new(sdk_key)
95
+
96
+ http_workspace_fetcher = HttpWorkspaceFetcher.new(config, sdk_info)
97
+ polling_workspace_fetcher = PollingWorkspaceFetcher.new(config, http_workspace_fetcher)
98
+
99
+ event_dispatcher = EventDispatcher.new(config, sdk_info)
100
+ event_processor = EventProcessor.new(config, event_dispatcher)
101
+
102
+ polling_workspace_fetcher.start!
103
+ event_processor.start!
104
+
105
+ Client.new(config, polling_workspace_fetcher, event_processor, Decider.new)
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module Hackle
6
+ class Config
7
+
8
+ def initialize(options = {})
9
+ @logger = options[:logger] || Config.default_logger
10
+ @base_uri = options[:base_uri] || Config.default_base_uri
11
+ @event_uri = options[:event_uri] || Config.default_event_uri
12
+ end
13
+
14
+ attr_reader :logger
15
+ attr_reader :base_uri
16
+ attr_reader :event_uri
17
+
18
+ def self.default_base_uri
19
+ 'https://sdk.hackle.io'
20
+ end
21
+
22
+ def self.default_event_uri
23
+ 'https://events.hackle.io'
24
+ end
25
+
26
+ def self.default_logger
27
+ if defined?(Rails) && Rails.logger
28
+ Rails.logger
29
+ else
30
+ Logger.new($stdout)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'murmurhash3'
4
+
5
+ module Hackle
6
+ class Bucketer
7
+ def bucketing(bucket, user_id)
8
+ slot_number = calculate_slot_number(bucket.seed, bucket.slot_size, user_id)
9
+ bucket.get_slot(slot_number)
10
+ end
11
+
12
+ def calculate_slot_number(seed, slot_size, user_id)
13
+ hash_value = hash(user_id, seed)
14
+ hash_value.abs % slot_size
15
+ end
16
+
17
+ def hash(data, seed)
18
+ unsigned_value = MurmurHash3::V32.str_hash(data, seed)
19
+ if (unsigned_value & 0x80000000).zero?
20
+ unsigned_value
21
+ else
22
+ -((unsigned_value ^ 0xFFFFFFFF) + 1)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackle
4
+ class Decision
5
+ class NotAllocated < Decision
6
+ end
7
+
8
+ class ForcedAllocated < Decision
9
+ attr_reader :variation_key
10
+
11
+ def initialize(variation_key)
12
+ @variation_key = variation_key
13
+ end
14
+ end
15
+
16
+ class NaturalAllocated < Decision
17
+ attr_reader :variation
18
+
19
+ def initialize(variation)
20
+ @variation = variation
21
+ end
22
+ end
23
+ end
24
+
25
+ class Decider
26
+ def initialize
27
+ @bucketer = Bucketer.new
28
+ end
29
+
30
+ def decide(experiment, user_id)
31
+ case experiment
32
+ when Experiment::Completed
33
+ Decision::ForcedAllocated.new(experiment.winner_variation_key)
34
+ when Experiment::Running
35
+ decide_running(experiment, user_id)
36
+ end
37
+ end
38
+
39
+ def decide_running(experiment, user_id)
40
+
41
+ overridden_variation = experiment.get_overridden_variation(user_id)
42
+ return Decision::ForcedAllocated.new(overridden_variation.key) unless overridden_variation.nil?
43
+
44
+ allocated_slot = @bucketer.bucketing(experiment.bucket, user_id)
45
+ return Decision::NotAllocated.new if allocated_slot.nil?
46
+
47
+ allocated_variation = experiment.get_variation(allocated_slot.variation_id)
48
+ return Decision::NotAllocated.new if allocated_variation.nil?
49
+ return Decision::NotAllocated.new if allocated_variation.dropped
50
+
51
+ Decision::NaturalAllocated.new(allocated_variation)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackle
4
+ class Event
5
+ attr_reader :timestamp, :user_id
6
+
7
+ class Exposure < Event
8
+ attr_reader :experiment, :variation
9
+
10
+ def initialize(user_id, experiment, variation)
11
+ @timestamp = Event.generate_timestamp
12
+ @user_id = user_id
13
+ @experiment = experiment
14
+ @variation = variation
15
+ end
16
+ end
17
+
18
+ class Track < Event
19
+ attr_reader :event_type, :value
20
+
21
+ def initialize(user_id, event_type, value = nil)
22
+ @timestamp = Event.generate_timestamp
23
+ @user_id = user_id
24
+ @event_type = event_type
25
+ @value = value
26
+ end
27
+ end
28
+
29
+ def self.generate_timestamp
30
+ (Time.now.to_f * 1000).to_i
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackle
4
+ class EventDispatcher
5
+
6
+ DEFAULT_DISPATCH_WORKER_SIZE = 2
7
+ DEFAULT_DISPATCH_QUEUE_CAPACITY = 50
8
+
9
+ def initialize(config, sdk_info)
10
+ @logger = config.logger
11
+ @client = HTTP.client(config.event_uri)
12
+ @headers = HTTP.sdk_headers(sdk_info)
13
+ @dispatcher_executor = Concurrent::ThreadPoolExecutor.new(
14
+ min_threads: DEFAULT_DISPATCH_WORKER_SIZE,
15
+ max_threads: DEFAULT_DISPATCH_WORKER_SIZE,
16
+ max_queue: DEFAULT_DISPATCH_QUEUE_CAPACITY
17
+ )
18
+ end
19
+
20
+ def dispatch(events)
21
+ payload = create_payload(events)
22
+ begin
23
+ @dispatcher_executor.post { dispatch_payload(payload) }
24
+ rescue Concurrent::RejectedExecutionError
25
+ @logger.warn { 'Dispatcher executor queue is full. Event dispatch rejected' }
26
+ end
27
+ end
28
+
29
+ def shutdown
30
+ @dispatcher_executor.shutdown
31
+ unless @dispatcher_executor.wait_for_termination(10)
32
+ @logger.warn { 'Failed to dispatch previously submitted events' }
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def dispatch_payload(payload)
39
+ request = Net::HTTP::Post.new('/api/v1/events', @headers)
40
+ request.content_type = 'application/json'
41
+ request.body = payload.to_json
42
+
43
+ response = @client.request(request)
44
+
45
+ status_code = response.code.to_i
46
+ HTTP.check_successful(status_code)
47
+ rescue => e
48
+ @logger.error { "Failed to dispatch events: #{e.inspect}" }
49
+ end
50
+
51
+ def create_payload(events)
52
+ exposure_events = []
53
+ track_events = []
54
+ events.each do |event|
55
+ case event
56
+ when Event::Exposure
57
+ exposure_events << create_exposure_event(event)
58
+ when Event::Track
59
+ track_events << create_track_event(event)
60
+ end
61
+ end
62
+ {
63
+ exposureEvents: exposure_events,
64
+ trackEvents: track_events
65
+ }
66
+ end
67
+
68
+ def create_exposure_event(event)
69
+ {
70
+ timestamp: event.timestamp,
71
+ userId: event.user_id,
72
+ experimentId: event.experiment.id,
73
+ experimentKey: event.experiment.key,
74
+ variationId: event.variation.id,
75
+ variationKey: event.variation.key
76
+ }
77
+ end
78
+
79
+ def create_track_event(event)
80
+ {
81
+ timestamp: event.timestamp,
82
+ userId: event.user_id,
83
+ eventTypeId: event.event_type.id,
84
+ eventTypeKey: event.event_type.key,
85
+ value: event.value
86
+ }
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackle
4
+
5
+ class EventProcessor
6
+
7
+ DEFAULT_FLUSH_INTERVAL = 10
8
+
9
+ def initialize(config, event_dispatcher)
10
+ @logger = config.logger
11
+ @event_dispatcher = event_dispatcher
12
+ @message_processor = MessageProcessor.new(event_dispatcher, config)
13
+ @flush_task = Concurrent::TimerTask.new(execution_interval: DEFAULT_FLUSH_INTERVAL) { flush }
14
+ @consume_task = nil
15
+ @running = false
16
+ end
17
+
18
+ def start!
19
+ return if @running
20
+
21
+ @consume_task = Thread.new { @message_processor.consuming_loop }
22
+ @flush_task.execute
23
+ @running = true
24
+ end
25
+
26
+ def stop!
27
+ return unless @running
28
+
29
+ @message_processor.produce(Message::Shutdown.new, non_block: false)
30
+ @consume_task.join(10)
31
+ @flush_task.shutdown
32
+ @event_dispatcher.shutdown
33
+
34
+ @running = false
35
+ end
36
+
37
+ def process(event)
38
+ @message_processor.produce(Message::Event.new(event))
39
+ end
40
+
41
+ def flush
42
+ @message_processor.produce(Message::Flush.new)
43
+ end
44
+
45
+ class Message
46
+ class Event < Message
47
+ attr_reader :event
48
+
49
+ def initialize(event)
50
+ @event = event
51
+ end
52
+ end
53
+
54
+ class Flush < Message
55
+ end
56
+
57
+ class Shutdown < Message
58
+ end
59
+ end
60
+
61
+ class MessageProcessor
62
+
63
+ DEFAULT_MESSAGE_QUEUE_CAPACITY = 1000
64
+ DEFAULT_MAX_EVENT_DISPATCH_SIZE = 500
65
+
66
+ def initialize(event_dispatcher, config)
67
+ @logger = config.logger
68
+ @event_dispatcher = event_dispatcher
69
+ @message_queue = SizedQueue.new(DEFAULT_MESSAGE_QUEUE_CAPACITY)
70
+ @random = Random.new
71
+ @consumed_events = []
72
+ end
73
+
74
+ def produce(message, non_block: true)
75
+ @message_queue.push(message, non_block)
76
+ rescue ThreadError
77
+ if @random.rand(1..100) == 1 # log only 1% of the time
78
+ @logger.warn { 'Events are produced faster than can be consumed. Some events will be dropped.' }
79
+ end
80
+ end
81
+
82
+ def consuming_loop
83
+ loop do
84
+ message = @message_queue.pop
85
+ case message
86
+ when Message::Event
87
+ consume_event(message.event)
88
+ when Message::Flush
89
+ dispatch_events
90
+ when Message::Shutdown
91
+ break
92
+ end
93
+ end
94
+ rescue => e
95
+ @logger.warn { "Uncaught exception in events message processor: #{e.inspect}" }
96
+ ensure
97
+ dispatch_events
98
+ end
99
+
100
+ private
101
+
102
+ def consume_event(event)
103
+ @consumed_events << event
104
+ dispatch_events if @consumed_events.length >= DEFAULT_MAX_EVENT_DISPATCH_SIZE
105
+ end
106
+
107
+ def dispatch_events
108
+ return if @consumed_events.empty?
109
+
110
+ @event_dispatcher.dispatch(@consumed_events)
111
+ @consumed_events = []
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+
5
+ module Hackle
6
+ class UnexpectedResponseError < StandardError
7
+
8
+ def initialize(status_code)
9
+ super("HTTP status code #{status_code}")
10
+ end
11
+ end
12
+
13
+ class HTTP
14
+ def self.client(base_uri)
15
+ uri = URI.parse(base_uri)
16
+ client = Net::HTTP.new(uri.host, uri.port)
17
+ client.use_ssl = uri.scheme == 'https'
18
+ client.open_timeout = 5
19
+ client.read_timeout = 10
20
+ client
21
+ end
22
+
23
+ def self.sdk_headers(sdk_info)
24
+ {
25
+ 'X-HACKLE-SDK-KEY' => sdk_info.key,
26
+ 'X-HACKLE-SDK-NAME' => sdk_info.name,
27
+ 'X-HACKLE-SDK-VERSION' => sdk_info.version
28
+ }
29
+ end
30
+
31
+ def self.successful?(status_code)
32
+ status_code >= 200 && status_code < 300
33
+ end
34
+
35
+ def self.check_successful(status_code)
36
+ raise UnexpectedResponseError.new(status_code) unless successful?(status_code)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,15 @@
1
+ module Hackle
2
+ class Bucket
3
+ attr_reader :seed, :slot_size
4
+
5
+ def initialize(seed, slot_size, slots)
6
+ @seed = seed
7
+ @slot_size = slot_size
8
+ @slots = slots
9
+ end
10
+
11
+ def get_slot(slot_number)
12
+ @slots.find { |slot| slot.contains?(slot_number) }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,10 @@
1
+ module Hackle
2
+ class EventType
3
+ attr_reader :id, :key
4
+
5
+ def initialize(id, key)
6
+ @id = id
7
+ @key = key
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,39 @@
1
+ module Hackle
2
+ class Experiment
3
+ attr_reader :id, :key
4
+
5
+ def initialize(id, key)
6
+ @id = id
7
+ @key = key
8
+ end
9
+
10
+ class Running < Experiment
11
+ attr_reader :bucket
12
+
13
+ def initialize(id, key, bucket, variations, user_overrides)
14
+ super(id, key)
15
+ @bucket = bucket
16
+ @variations = variations
17
+ @user_overrides = user_overrides
18
+ end
19
+
20
+ def get_variation(variation_id)
21
+ @variations[variation_id]
22
+ end
23
+
24
+ def get_overridden_variation(user_id)
25
+ variation_id = @user_overrides[user_id]
26
+ get_variation(variation_id)
27
+ end
28
+ end
29
+
30
+ class Completed < Experiment
31
+ attr_reader :winner_variation_key
32
+
33
+ def initialize(id, key, winner_variation_key)
34
+ super(id, key)
35
+ @winner_variation_key = winner_variation_key
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,15 @@
1
+ module Hackle
2
+ class Slot
3
+ attr_reader :variation_id
4
+
5
+ def initialize(start_inclusive, end_exclusive, variation_id)
6
+ @start_inclusive = start_inclusive
7
+ @end_exclusive = end_exclusive
8
+ @variation_id = variation_id
9
+ end
10
+
11
+ def contains?(slot_number)
12
+ @start_inclusive <= slot_number && slot_number < @end_exclusive
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ module Hackle
2
+ class Variation
3
+ attr_reader :id, :key, :dropped
4
+
5
+ def initialize(id, key, dropped)
6
+ @id = id
7
+ @key = key
8
+ @dropped = dropped
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackle
4
+ VERSION = '0.0.1'
5
+ SDK_NAME = 'ruby-sdk'
6
+
7
+ class SdkInfo
8
+ attr_reader :key, :name, :version
9
+ def initialize(key)
10
+ @key = key
11
+ @name = SDK_NAME
12
+ @version = VERSION
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Hackle
6
+ class HttpWorkspaceFetcher
7
+
8
+ def initialize(config, sdk_info)
9
+ @client = HTTP.client(config.base_uri)
10
+ @headers = HTTP.sdk_headers(sdk_info)
11
+ end
12
+
13
+ def fetch
14
+ request = Net::HTTP::Get.new('/api/v1/workspaces', @headers)
15
+ response = @client.request(request)
16
+
17
+ status_code = response.code.to_i
18
+ HTTP.check_successful(status_code)
19
+
20
+ data = JSON.parse(response.body, symbolize_names: true)
21
+ Workspace.create(data)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+
5
+ module Hackle
6
+ class PollingWorkspaceFetcher
7
+
8
+ DEFAULT_POLLING_INTERVAL = 10
9
+
10
+ def initialize(config, http_fetcher)
11
+ @logger = config.logger
12
+ @http_fetcher = http_fetcher
13
+ @current_workspace = Concurrent::AtomicReference.new
14
+ @task = Concurrent::TimerTask.new(execution_interval: DEFAULT_POLLING_INTERVAL) { poll }
15
+ @running = false
16
+ end
17
+
18
+ def fetch
19
+ @current_workspace.get
20
+ end
21
+
22
+ def start!
23
+ return if @running
24
+
25
+ poll
26
+ @task.execute
27
+ @running = true
28
+ end
29
+
30
+ def stop!
31
+ return unless @running
32
+
33
+ @task.shutdown
34
+ @running = false
35
+ end
36
+
37
+ def poll
38
+ workspace = @http_fetcher.fetch
39
+ @current_workspace.set(workspace)
40
+ rescue => e
41
+ @logger.error { "Failed to poll Workspace: #{e.inspect}" }
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,78 @@
1
+ module Hackle
2
+ class Workspace
3
+ def initialize(experiments, event_types)
4
+ @experiments = experiments
5
+ @event_types = event_types
6
+ end
7
+
8
+ def get_experiment(experiment_key)
9
+ @experiments[experiment_key]
10
+ end
11
+
12
+ def get_event_type(event_type_key)
13
+ @event_types[event_type_key]
14
+ end
15
+
16
+ class << self
17
+ def create(data)
18
+ buckets = Hash[data[:buckets].map { |b| [b[:id], bucket(b)] }]
19
+ running_experiments = Hash[data[:experiments].map { |re| [re[:key], running_experiment(re, buckets)] }]
20
+ completed_experiment = Hash[data[:completedExperiments].map { |ce| [ce[:experimentKey], completed_experiment(ce)] }]
21
+ event_types = Hash[data[:events].map { |e| [e[:key], event_type(e)] }]
22
+ experiments = running_experiments.merge(completed_experiment)
23
+ Workspace.new(experiments, event_types)
24
+ end
25
+
26
+ private
27
+
28
+ def running_experiment(data, buckets)
29
+ Experiment::Running.new(
30
+ data[:id],
31
+ data[:key],
32
+ buckets[data[:bucketId]],
33
+ Hash[data[:variations].map { |v| [v[:id], variation(v)] }],
34
+ Hash[data[:execution][:userOverrides].map { |u| [u[:userId], u[:variationId]] }]
35
+ )
36
+ end
37
+
38
+ def completed_experiment(data)
39
+ Experiment::Completed.new(
40
+ data[:experimentId],
41
+ data[:experimentKey],
42
+ data[:winnerVariationKey]
43
+ )
44
+ end
45
+
46
+ def variation(data)
47
+ Variation.new(
48
+ data[:id],
49
+ data[:key],
50
+ data[:status] == 'DROPPED'
51
+ )
52
+ end
53
+
54
+ def bucket(data)
55
+ Bucket.new(
56
+ data[:seed],
57
+ data[:slotSize],
58
+ data[:slots].map { |s| slot(s) }
59
+ )
60
+ end
61
+
62
+ def slot(data)
63
+ Slot.new(
64
+ data[:startInclusive],
65
+ data[:endExclusive],
66
+ data[:variationId]
67
+ )
68
+ end
69
+
70
+ def event_type(data)
71
+ EventType.new(
72
+ data[:id],
73
+ data[:key]
74
+ )
75
+ end
76
+ end
77
+ end
78
+ end
metadata ADDED
@@ -0,0 +1,180 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hackle-ruby-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Hackle
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-10-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.17'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.17'
27
+ - !ruby/object:Gem::Dependency
28
+ name: coveralls
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '10.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '10.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - '='
74
+ - !ruby/object:Gem::Version
75
+ version: 0.73.0
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - '='
81
+ - !ruby/object:Gem::Version
82
+ version: 0.73.0
83
+ - !ruby/object:Gem::Dependency
84
+ name: concurrent-ruby
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: json
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '1.8'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '1.8'
111
+ - !ruby/object:Gem::Dependency
112
+ name: murmurhash3
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '0.1'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '0.1'
125
+ description: Hackle SDK for Ruby
126
+ email:
127
+ - platform@hackle.io
128
+ executables: []
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - ".gitignore"
133
+ - ".rspec"
134
+ - ".travis.yml"
135
+ - Gemfile
136
+ - README.md
137
+ - Rakefile
138
+ - hackle-ruby-sdk.gemspec
139
+ - lib/hackle-ruby-sdk.rb
140
+ - lib/hackle-ruby-sdk/client.rb
141
+ - lib/hackle-ruby-sdk/config.rb
142
+ - lib/hackle-ruby-sdk/decision/bucketer.rb
143
+ - lib/hackle-ruby-sdk/decision/decider.rb
144
+ - lib/hackle-ruby-sdk/events/event.rb
145
+ - lib/hackle-ruby-sdk/events/event_dispatcher.rb
146
+ - lib/hackle-ruby-sdk/events/event_processor.rb
147
+ - lib/hackle-ruby-sdk/http/http.rb
148
+ - lib/hackle-ruby-sdk/models/bucket.rb
149
+ - lib/hackle-ruby-sdk/models/event_type.rb
150
+ - lib/hackle-ruby-sdk/models/experiment.rb
151
+ - lib/hackle-ruby-sdk/models/slot.rb
152
+ - lib/hackle-ruby-sdk/models/variation.rb
153
+ - lib/hackle-ruby-sdk/version.rb
154
+ - lib/hackle-ruby-sdk/workspaces/http_workspace_fetcher.rb
155
+ - lib/hackle-ruby-sdk/workspaces/polling_workspace_fetcher.rb
156
+ - lib/hackle-ruby-sdk/workspaces/workspace.rb
157
+ homepage: https://github.com/hackle-io/hackle-ruby-sdk
158
+ licenses:
159
+ - Apache-2.0
160
+ metadata: {}
161
+ post_install_message:
162
+ rdoc_options: []
163
+ require_paths:
164
+ - lib
165
+ required_ruby_version: !ruby/object:Gem::Requirement
166
+ requirements:
167
+ - - ">="
168
+ - !ruby/object:Gem::Version
169
+ version: '0'
170
+ required_rubygems_version: !ruby/object:Gem::Requirement
171
+ requirements:
172
+ - - ">="
173
+ - !ruby/object:Gem::Version
174
+ version: '0'
175
+ requirements: []
176
+ rubygems_version: 3.0.3
177
+ signing_key:
178
+ specification_version: 4
179
+ summary: Hackle SDK for Ruby
180
+ test_files: []